From 5f40b12c3d7e1e843f35a0d78dad8e81842bd7b8 Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Mon, 19 Jan 2026 14:02:35 -0500 Subject: [PATCH 01/42] wrote calculate_velocity_metrics --- src/graphomotor/core/models.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/graphomotor/core/models.py b/src/graphomotor/core/models.py index 5b2401e..ab9321d 100644 --- a/src/graphomotor/core/models.py +++ b/src/graphomotor/core/models.py @@ -236,3 +236,30 @@ def calculate_path_optimality( if optimal_distance > 0: self.path_optimality = optimal_distance / self.distance return + + def calculate_velocity_metrics(self, ink_points: pd.DataFrame) -> None: + """Get velocity metrics of a LineSegment. + + Args: + self: LineSegment object to calculate velocities for. + ink_points: DataFrame of ink points with 'x', 'y', and 'seconds' columns. + """ + dx = np.diff(ink_points["x"].values) + dy = np.diff(ink_points["y"].values) + dt = np.diff(ink_points["seconds"].values) + + # NOTE: need to change the way this case is handled + # Avoid division by zero + dt[dt == 0] = 1e-6 + + distances = np.sqrt(dx**2 + dy**2) + velocities = distances / dt + self.velocities = velocities.tolist() + + self.mean_speed = np.mean(velocities) + self.speed_variance = np.var(velocities) + + if len(velocities) >= 2: + self.accelerations = np.diff(velocities).tolist() + + return From de323372be38ac88bb04a4f0eccc6b1fdbd62e84 Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Mon, 19 Jan 2026 14:08:11 -0500 Subject: [PATCH 02/42] add distance assign --- src/graphomotor/core/models.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/graphomotor/core/models.py b/src/graphomotor/core/models.py index ab9321d..364a386 100644 --- a/src/graphomotor/core/models.py +++ b/src/graphomotor/core/models.py @@ -253,6 +253,8 @@ def calculate_velocity_metrics(self, ink_points: pd.DataFrame) -> None: dt[dt == 0] = 1e-6 distances = np.sqrt(dx**2 + dy**2) + self.distance = np.sum(distances) + velocities = distances / dt self.velocities = velocities.tolist() From 591a7522e9b36fb74d18227f034be54ff04f5247 Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Tue, 10 Feb 2026 10:05:35 -0500 Subject: [PATCH 03/42] new classmethod for validating dataframe does not contain duplicate timestamps --- src/graphomotor/core/models.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/graphomotor/core/models.py b/src/graphomotor/core/models.py index 364a386..73fcd92 100644 --- a/src/graphomotor/core/models.py +++ b/src/graphomotor/core/models.py @@ -73,6 +73,26 @@ def validate_metadata(cls, v: dict) -> dict: return v + @pydantic.field_validator("data") + @classmethod + def validate_timestamps(cls, v: pd.DataFrame) -> pd.DataFrame: + """Validate that the data DataFrame does not contain duplicate timestamps. + + Args: + cls: The class. + v: The dataframe to validate. + + Returns: + The dataframe if it does not contain duplicate timestamps. + + Raises: + ValueError: If the dataframe contains duplicate timestamps. + """ + if v["seconds"].duplicated().any(): + raise ValueError("DataFrame contains duplicate timestamps.") + + return v + class SpiralFeatureCategories: """Class to hold valid feature categories for Graphomotor.""" From ac264944d7fb2a94966b774a9783a55a2f47c686 Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Tue, 10 Feb 2026 10:21:30 -0500 Subject: [PATCH 04/42] added UTC_timestap check to validator and wrote unit test --- src/graphomotor/core/models.py | 5 ++++- tests/unit/test_models.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/graphomotor/core/models.py b/src/graphomotor/core/models.py index 73fcd92..6e90da6 100644 --- a/src/graphomotor/core/models.py +++ b/src/graphomotor/core/models.py @@ -89,7 +89,10 @@ def validate_timestamps(cls, v: pd.DataFrame) -> pd.DataFrame: ValueError: If the dataframe contains duplicate timestamps. """ if v["seconds"].duplicated().any(): - raise ValueError("DataFrame contains duplicate timestamps.") + raise ValueError("duplicate timestamps in 'seconds'.") + + if v["UTC_Timestamp"].duplicated().any(): + raise ValueError("duplicate timestamps in 'UTC_Timestamp'.") return v diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index e158f47..7bf4e2a 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -61,6 +61,37 @@ def test_invalid_metadata_values( ) +@pytest.mark.parametrize( + "key,time,duplicate_time,expected_error", + [ + ( + "UTC_Timestamp", + datetime.datetime(2024, 1, 1, 12, 0, 0), + datetime.datetime(2024, 1, 1, 12, 0, 0), + "duplicate timestamps in 'UTC_Timestamp'.", + ), + ("seconds", 1.73, 1.73, "duplicate timestamps in 'seconds'."), + ], +) +def test_duplicate_timestamps( + valid_spiral_data: pd.DataFrame, + valid_spiral_metadata: dict[str, str | datetime.datetime], + key: str, + time: datetime.datetime | float, + duplicate_time: datetime.datetime | float, + expected_error: str, +) -> None: + """Test that duplicate timestamps in the DataFrame aren't allowed.""" + invalid_data = valid_spiral_data.copy() + invalid_data[key][0] = time + invalid_data[key][1] = duplicate_time + + with pytest.raises(ValueError, match=expected_error): + models.Drawing( + data=invalid_data, task_name="spiral", metadata=valid_spiral_metadata + ) + + @pytest.fixture def circle() -> models.CircleTarget: """Create a standard circle at origin with radius 10.""" From 0952f4a52ef7eea8d4328482134aa4e7a62a2e58 Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Tue, 10 Feb 2026 10:32:39 -0500 Subject: [PATCH 05/42] adjust unit test for validator --- tests/unit/test_models.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index 7bf4e2a..8ccf356 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -66,8 +66,8 @@ def test_invalid_metadata_values( [ ( "UTC_Timestamp", - datetime.datetime(2024, 1, 1, 12, 0, 0), - datetime.datetime(2024, 1, 1, 12, 0, 0), + 16.596, + 16.596, "duplicate timestamps in 'UTC_Timestamp'.", ), ("seconds", 1.73, 1.73, "duplicate timestamps in 'seconds'."), @@ -77,8 +77,8 @@ def test_duplicate_timestamps( valid_spiral_data: pd.DataFrame, valid_spiral_metadata: dict[str, str | datetime.datetime], key: str, - time: datetime.datetime | float, - duplicate_time: datetime.datetime | float, + time: str | float, + duplicate_time: str | float, expected_error: str, ) -> None: """Test that duplicate timestamps in the DataFrame aren't allowed.""" From 3983c9caf14252bc41c7665820e930dc7780aa97 Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Tue, 10 Feb 2026 10:39:05 -0500 Subject: [PATCH 06/42] fixing other unit tests by adding required columns --- tests/unit/test_trails_drawing_metrics.py | 12 +++++- tests/unit/test_trails_utils.py | 48 ++++++++++------------- 2 files changed, 31 insertions(+), 29 deletions(-) diff --git a/tests/unit/test_trails_drawing_metrics.py b/tests/unit/test_trails_drawing_metrics.py index cd87758..b447410 100644 --- a/tests/unit/test_trails_drawing_metrics.py +++ b/tests/unit/test_trails_drawing_metrics.py @@ -10,7 +10,11 @@ def test_get_total_errors() -> None: """Test ValueError when total_number_of_errors column doesn't exist.""" - invalid_df = pd.DataFrame({"some_other_column": [0, 1, 2]}) + invalid_df = pd.DataFrame({ + "some_other_column": [0, 1, 2], + "seconds": [0.0, 1.0, 2.0], + "UTC_Timestamp": [0.0, 1.0, 2.0], + }) drawing = models.Drawing( data=invalid_df, task_name="trails", metadata={"id": "5555555"} ) @@ -33,7 +37,11 @@ def test_valid_total_errors() -> None: def test_percent_accurate_paths_missing_columns() -> None: """Test ValueError when required columns are missing.""" - invalid_df = pd.DataFrame({"some_other_column": [0, 1, 2]}) + invalid_df = pd.DataFrame({ + "some_other_column": [0, 1, 2], + "seconds": [0.0, 1.0, 2.0], + "UTC_Timestamp": [0.0, 1.0, 2.0], + }) drawing = models.Drawing( data=invalid_df, task_name="trails", metadata={"id": "5555555"} ) diff --git a/tests/unit/test_trails_utils.py b/tests/unit/test_trails_utils.py index 8844aea..1bb1bd4 100644 --- a/tests/unit/test_trails_utils.py +++ b/tests/unit/test_trails_utils.py @@ -127,15 +127,13 @@ def test_multiple_unique_paths( circles: Dict[str, Dict[str, models.CircleTarget]], ) -> None: """Multiple unique actual_path values produce correct segments.""" - df = pd.DataFrame( - { - "actual_path": ["1 ~ 2", "2 ~ 1"], - "line_number": [0, 1], - "is_error": [False, True], - "x": [0, 1], - "y": [0, 1], - } - ) + df = pd.DataFrame({ + "actual_path": ["1 ~ 2", "2 ~ 1"], + "line_number": [0, 1], + "is_error": [False, True], + "x": [0, 1], + "y": [0, 1], + }) segments = trails_utils.segment_lines(df, "trail2", circles) assert len(segments) == 2 assert segments[0].start_label == "1" @@ -152,15 +150,13 @@ def test_single_path_fallback_line_number( circles: Dict[str, Dict[str, models.CircleTarget]], ) -> None: """Single unique path falls back to line_number segmentation.""" - df = pd.DataFrame( - { - "actual_path": ["1 ~ 2", "1 ~ 2"], - "line_number": [0, 1], - "is_error": [False, False], - "x": [0, 1], - "y": [0, 1], - } - ) + df = pd.DataFrame({ + "actual_path": ["1 ~ 2", "1 ~ 2"], + "line_number": [0, 1], + "is_error": [False, False], + "x": [0, 1], + "y": [0, 1], + }) segments = trails_utils.segment_lines(df, "trail2", circles) assert len(segments) == 2 for idx, seg in enumerate(segments): @@ -183,15 +179,13 @@ def test_invalid_trail_id( circles: Dict[str, Dict[str, models.CircleTarget]], ) -> None: """Passing an invalid trail_id raises KeyError.""" - df = pd.DataFrame( - { - "x": [0], - "y": [0], - "actual_path": ["1 ~ 2"], - "line_number": [0], - "is_error": [False], - } - ) + df = pd.DataFrame({ + "x": [0], + "y": [0], + "actual_path": ["1 ~ 2"], + "line_number": [0], + "is_error": [False], + }) with pytest.raises(KeyError, match="Trail ID not found in circles dictionary."): trails_utils.segment_lines(df, "invalid", circles) From 3843b466bcd0921b650fb2fdc1aa30517a5f24c2 Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Tue, 10 Feb 2026 10:45:25 -0500 Subject: [PATCH 07/42] update validate data function to only apply to trails --- src/graphomotor/core/models.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/graphomotor/core/models.py b/src/graphomotor/core/models.py index 6e90da6..87a4907 100644 --- a/src/graphomotor/core/models.py +++ b/src/graphomotor/core/models.py @@ -88,11 +88,14 @@ def validate_timestamps(cls, v: pd.DataFrame) -> pd.DataFrame: Raises: ValueError: If the dataframe contains duplicate timestamps. """ - if v["seconds"].duplicated().any(): - raise ValueError("duplicate timestamps in 'seconds'.") - - if v["UTC_Timestamp"].duplicated().any(): - raise ValueError("duplicate timestamps in 'UTC_Timestamp'.") + if cls.task_name != "trails": + return v + else: + if v["seconds"].duplicated().any(): + raise ValueError("duplicate timestamps in 'seconds'.") + + if v["UTC_Timestamp"].duplicated().any(): + raise ValueError("duplicate timestamps in 'UTC_Timestamp'.") return v From a3efd898bb3c53377634515e106b674426d1c7a6 Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Tue, 10 Feb 2026 10:51:49 -0500 Subject: [PATCH 08/42] revert validator test back to only seconds --- src/graphomotor/core/models.py | 12 +++--------- tests/unit/test_models.py | 6 ------ tests/unit/test_trails_drawing_metrics.py | 2 -- 3 files changed, 3 insertions(+), 17 deletions(-) diff --git a/src/graphomotor/core/models.py b/src/graphomotor/core/models.py index 87a4907..b4bf09b 100644 --- a/src/graphomotor/core/models.py +++ b/src/graphomotor/core/models.py @@ -17,7 +17,7 @@ class Drawing(pydantic.BaseModel): data: DataFrame containing drawing data with required columns (line_number, x, y, UTC_Timestamp, seconds). task_name: Name of the drawing task (e.g., 'spiral', 'trails', etc.). - metadata: Dictionary containing metadata about the spiral: + metadata: Dictionary containing metadata about the drawing: - id: Unique identifier for the participant, - hand: Hand used ('Dom' for dominant, 'NonDom' for non-dominant), - task: Task name, @@ -88,14 +88,8 @@ def validate_timestamps(cls, v: pd.DataFrame) -> pd.DataFrame: Raises: ValueError: If the dataframe contains duplicate timestamps. """ - if cls.task_name != "trails": - return v - else: - if v["seconds"].duplicated().any(): - raise ValueError("duplicate timestamps in 'seconds'.") - - if v["UTC_Timestamp"].duplicated().any(): - raise ValueError("duplicate timestamps in 'UTC_Timestamp'.") + if v["seconds"].duplicated().any(): + raise ValueError("duplicate timestamps in 'seconds'.") return v diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index 8ccf356..6a5f912 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -64,12 +64,6 @@ def test_invalid_metadata_values( @pytest.mark.parametrize( "key,time,duplicate_time,expected_error", [ - ( - "UTC_Timestamp", - 16.596, - 16.596, - "duplicate timestamps in 'UTC_Timestamp'.", - ), ("seconds", 1.73, 1.73, "duplicate timestamps in 'seconds'."), ], ) diff --git a/tests/unit/test_trails_drawing_metrics.py b/tests/unit/test_trails_drawing_metrics.py index b447410..8f37dea 100644 --- a/tests/unit/test_trails_drawing_metrics.py +++ b/tests/unit/test_trails_drawing_metrics.py @@ -13,7 +13,6 @@ def test_get_total_errors() -> None: invalid_df = pd.DataFrame({ "some_other_column": [0, 1, 2], "seconds": [0.0, 1.0, 2.0], - "UTC_Timestamp": [0.0, 1.0, 2.0], }) drawing = models.Drawing( data=invalid_df, task_name="trails", metadata={"id": "5555555"} @@ -40,7 +39,6 @@ def test_percent_accurate_paths_missing_columns() -> None: invalid_df = pd.DataFrame({ "some_other_column": [0, 1, 2], "seconds": [0.0, 1.0, 2.0], - "UTC_Timestamp": [0.0, 1.0, 2.0], }) drawing = models.Drawing( data=invalid_df, task_name="trails", metadata={"id": "5555555"} From e0cdea0e0bf05a19743be4f7fd899c45fae10198 Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Tue, 10 Feb 2026 10:59:42 -0500 Subject: [PATCH 09/42] added new line checking for task name in metadata --- src/graphomotor/core/models.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/graphomotor/core/models.py b/src/graphomotor/core/models.py index b4bf09b..32dfad4 100644 --- a/src/graphomotor/core/models.py +++ b/src/graphomotor/core/models.py @@ -88,8 +88,11 @@ def validate_timestamps(cls, v: pd.DataFrame) -> pd.DataFrame: Raises: ValueError: If the dataframe contains duplicate timestamps. """ - if v["seconds"].duplicated().any(): - raise ValueError("duplicate timestamps in 'seconds'.") + if cls.metadata.get("task") not in {"trail1", "trail2", "trail3", "trail4"}: + return v + else: + if v["seconds"].duplicated().any(): + raise ValueError("duplicate timestamps in 'seconds'.") return v From 3489b85169cd06d1210f9374442bee9b26ad572e Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Tue, 10 Feb 2026 11:30:14 -0500 Subject: [PATCH 10/42] remove validator function - will be its own new issue --- src/graphomotor/core/models.py | 23 ----------------------- tests/unit/test_models.py | 25 ------------------------- 2 files changed, 48 deletions(-) diff --git a/src/graphomotor/core/models.py b/src/graphomotor/core/models.py index 32dfad4..30a975f 100644 --- a/src/graphomotor/core/models.py +++ b/src/graphomotor/core/models.py @@ -73,29 +73,6 @@ def validate_metadata(cls, v: dict) -> dict: return v - @pydantic.field_validator("data") - @classmethod - def validate_timestamps(cls, v: pd.DataFrame) -> pd.DataFrame: - """Validate that the data DataFrame does not contain duplicate timestamps. - - Args: - cls: The class. - v: The dataframe to validate. - - Returns: - The dataframe if it does not contain duplicate timestamps. - - Raises: - ValueError: If the dataframe contains duplicate timestamps. - """ - if cls.metadata.get("task") not in {"trail1", "trail2", "trail3", "trail4"}: - return v - else: - if v["seconds"].duplicated().any(): - raise ValueError("duplicate timestamps in 'seconds'.") - - return v - class SpiralFeatureCategories: """Class to hold valid feature categories for Graphomotor.""" diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index 6a5f912..e158f47 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -61,31 +61,6 @@ def test_invalid_metadata_values( ) -@pytest.mark.parametrize( - "key,time,duplicate_time,expected_error", - [ - ("seconds", 1.73, 1.73, "duplicate timestamps in 'seconds'."), - ], -) -def test_duplicate_timestamps( - valid_spiral_data: pd.DataFrame, - valid_spiral_metadata: dict[str, str | datetime.datetime], - key: str, - time: str | float, - duplicate_time: str | float, - expected_error: str, -) -> None: - """Test that duplicate timestamps in the DataFrame aren't allowed.""" - invalid_data = valid_spiral_data.copy() - invalid_data[key][0] = time - invalid_data[key][1] = duplicate_time - - with pytest.raises(ValueError, match=expected_error): - models.Drawing( - data=invalid_data, task_name="spiral", metadata=valid_spiral_metadata - ) - - @pytest.fixture def circle() -> models.CircleTarget: """Create a standard circle at origin with radius 10.""" From eb4b5221a09e460ffc9612adf16c4a92fc942196 Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Tue, 10 Feb 2026 11:34:07 -0500 Subject: [PATCH 11/42] ruff reformat --- tests/unit/test_trails_drawing_metrics.py | 20 ++++++---- tests/unit/test_trails_utils.py | 48 +++++++++++++---------- 2 files changed, 39 insertions(+), 29 deletions(-) diff --git a/tests/unit/test_trails_drawing_metrics.py b/tests/unit/test_trails_drawing_metrics.py index 8f37dea..f78cf69 100644 --- a/tests/unit/test_trails_drawing_metrics.py +++ b/tests/unit/test_trails_drawing_metrics.py @@ -10,10 +10,12 @@ def test_get_total_errors() -> None: """Test ValueError when total_number_of_errors column doesn't exist.""" - invalid_df = pd.DataFrame({ - "some_other_column": [0, 1, 2], - "seconds": [0.0, 1.0, 2.0], - }) + invalid_df = pd.DataFrame( + { + "some_other_column": [0, 1, 2], + "seconds": [0.0, 1.0, 2.0], + } + ) drawing = models.Drawing( data=invalid_df, task_name="trails", metadata={"id": "5555555"} ) @@ -36,10 +38,12 @@ def test_valid_total_errors() -> None: def test_percent_accurate_paths_missing_columns() -> None: """Test ValueError when required columns are missing.""" - invalid_df = pd.DataFrame({ - "some_other_column": [0, 1, 2], - "seconds": [0.0, 1.0, 2.0], - }) + invalid_df = pd.DataFrame( + { + "some_other_column": [0, 1, 2], + "seconds": [0.0, 1.0, 2.0], + } + ) drawing = models.Drawing( data=invalid_df, task_name="trails", metadata={"id": "5555555"} ) diff --git a/tests/unit/test_trails_utils.py b/tests/unit/test_trails_utils.py index 1bb1bd4..8844aea 100644 --- a/tests/unit/test_trails_utils.py +++ b/tests/unit/test_trails_utils.py @@ -127,13 +127,15 @@ def test_multiple_unique_paths( circles: Dict[str, Dict[str, models.CircleTarget]], ) -> None: """Multiple unique actual_path values produce correct segments.""" - df = pd.DataFrame({ - "actual_path": ["1 ~ 2", "2 ~ 1"], - "line_number": [0, 1], - "is_error": [False, True], - "x": [0, 1], - "y": [0, 1], - }) + df = pd.DataFrame( + { + "actual_path": ["1 ~ 2", "2 ~ 1"], + "line_number": [0, 1], + "is_error": [False, True], + "x": [0, 1], + "y": [0, 1], + } + ) segments = trails_utils.segment_lines(df, "trail2", circles) assert len(segments) == 2 assert segments[0].start_label == "1" @@ -150,13 +152,15 @@ def test_single_path_fallback_line_number( circles: Dict[str, Dict[str, models.CircleTarget]], ) -> None: """Single unique path falls back to line_number segmentation.""" - df = pd.DataFrame({ - "actual_path": ["1 ~ 2", "1 ~ 2"], - "line_number": [0, 1], - "is_error": [False, False], - "x": [0, 1], - "y": [0, 1], - }) + df = pd.DataFrame( + { + "actual_path": ["1 ~ 2", "1 ~ 2"], + "line_number": [0, 1], + "is_error": [False, False], + "x": [0, 1], + "y": [0, 1], + } + ) segments = trails_utils.segment_lines(df, "trail2", circles) assert len(segments) == 2 for idx, seg in enumerate(segments): @@ -179,13 +183,15 @@ def test_invalid_trail_id( circles: Dict[str, Dict[str, models.CircleTarget]], ) -> None: """Passing an invalid trail_id raises KeyError.""" - df = pd.DataFrame({ - "x": [0], - "y": [0], - "actual_path": ["1 ~ 2"], - "line_number": [0], - "is_error": [False], - }) + df = pd.DataFrame( + { + "x": [0], + "y": [0], + "actual_path": ["1 ~ 2"], + "line_number": [0], + "is_error": [False], + } + ) with pytest.raises(KeyError, match="Trail ID not found in circles dictionary."): trails_utils.segment_lines(df, "invalid", circles) From 4e4ca3ec52b4a0efaa6f6201c2c56350542a2789 Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Tue, 10 Feb 2026 11:39:40 -0500 Subject: [PATCH 12/42] remove divide by 0 replacement --- src/graphomotor/core/models.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/graphomotor/core/models.py b/src/graphomotor/core/models.py index 30a975f..bf4157f 100644 --- a/src/graphomotor/core/models.py +++ b/src/graphomotor/core/models.py @@ -248,10 +248,6 @@ def calculate_velocity_metrics(self, ink_points: pd.DataFrame) -> None: dy = np.diff(ink_points["y"].values) dt = np.diff(ink_points["seconds"].values) - # NOTE: need to change the way this case is handled - # Avoid division by zero - dt[dt == 0] = 1e-6 - distances = np.sqrt(dx**2 + dy**2) self.distance = np.sum(distances) From a3a191889821604ae8c26bdd767b1c5421919118 Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Tue, 10 Feb 2026 11:48:28 -0500 Subject: [PATCH 13/42] unit tests for velocity function --- tests/unit/test_models.py | 135 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index e158f47..8c12c32 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -135,3 +135,138 @@ def test_path_optimality_non_positive_distance() -> None: segment.calculate_path_optimality(start, end) assert segment.path_optimality == 0.0 + + +def test_uniform_motion() -> None: + """Test with points moving at constant velocity.""" + points = pd.DataFrame({ + "x": [0, 1, 2, 3], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + }) + segment = models.LineSegment( + start_label="1", + end_label="2", + points=points, + is_error=False, + line_number=1, + ) + + segment.calculate_velocity_metrics(points) + + assert segment.distance == pytest.approx(3.0) + assert segment.mean_speed == pytest.approx(1.0) + assert segment.speed_variance == pytest.approx(0.0) + assert len(segment.velocities) == 3 + assert all(v == pytest.approx(1.0) for v in segment.velocities) + assert len(segment.accelerations) == 2 + assert all(a == pytest.approx(0.0) for a in segment.accelerations) + + +def test_accelerating_motion() -> None: + """Test with motion accelerating over time.""" + points = pd.DataFrame({ + "x": [0, 1, 4, 9], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + }) + segment = models.LineSegment( + start_label="1", + end_label="2", + points=points, + is_error=False, + line_number=1, + ) + + segment.calculate_velocity_metrics(points) + + assert segment.distance == pytest.approx(9.0) + assert segment.mean_speed == pytest.approx(3.0) + assert segment.speed_variance > 0.0 + assert len(segment.velocities) == 3 + assert segment.velocities[0] == pytest.approx(1.0) + assert segment.velocities[1] == pytest.approx(3.0) + assert segment.velocities[2] == pytest.approx(5.0) + assert len(segment.accelerations) == 2 + assert segment.accelerations[0] == pytest.approx(2.0) + assert segment.accelerations[1] == pytest.approx(2.0) + + +def test_velocity_two_points_only() -> None: + """Test velocity calculation with only two points (one velocity, no acceleration).""" + points = pd.DataFrame({ + "x": [0, 3], + "y": [0, 4], + "seconds": [0, 2], + }) + segment = models.LineSegment( + start_label="1", + end_label="2", + points=points, + is_error=False, + line_number=1, + ) + + segment.calculate_velocity_metrics(points) + + assert segment.distance == pytest.approx(5.0) + assert segment.mean_speed == pytest.approx(2.5) + assert segment.speed_variance == pytest.approx(0.0) + assert len(segment.velocities) == 1 + assert segment.velocities[0] == pytest.approx(2.5) + assert len(segment.accelerations) == 0 + + +def test_decelerating_motion() -> None: + """Test with decelerating motion (negative acceleration).""" + points = pd.DataFrame({ + "x": [0, 4, 7, 9], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + }) + segment = models.LineSegment( + start_label="1", + end_label="2", + points=points, + is_error=False, + line_number=1, + ) + + segment.calculate_velocity_metrics(points) + + assert segment.distance == pytest.approx(9.0) + assert segment.mean_speed == pytest.approx(3.0) + assert segment.speed_variance > 0.0 + assert len(segment.velocities) == 3 + assert segment.velocities[0] == pytest.approx(4.0) + assert segment.velocities[1] == pytest.approx(3.0) + assert segment.velocities[2] == pytest.approx(2.0) + assert len(segment.accelerations) == 2 + assert segment.accelerations[0] == pytest.approx(-1.0) + assert segment.accelerations[1] == pytest.approx(-1.0) + + +def test_stationary_motion() -> None: + """Test with no movement (all points the same).""" + points = pd.DataFrame({ + "x": [1, 1, 1], + "y": [1, 1, 1], + "seconds": [0, 1, 2], + }) + segment = models.LineSegment( + start_label="1", + end_label="2", + points=points, + is_error=False, + line_number=1, + ) + + segment.calculate_velocity_metrics(points) + + assert segment.distance == pytest.approx(0.0) + assert segment.mean_speed == pytest.approx(0.0) + assert segment.speed_variance == pytest.approx(0.0) + assert len(segment.velocities) == 2 + assert all(v == pytest.approx(0.0) for v in segment.velocities) + assert len(segment.accelerations) == 1 + assert segment.accelerations[0] == pytest.approx(0.0) From 467553cc57afc4f2781e22400d75a9d46bf1d22c Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Tue, 10 Feb 2026 11:55:10 -0500 Subject: [PATCH 14/42] fix small bug from merge from main into branch --- tests/unit/test_models.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index 98d7779..56bcf0c 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -239,7 +239,7 @@ def test_valid_ink_trajectory( assert result_start == expected_start, f"Start index mismatch for {test_id}" assert result_end == expected_end, f"End index mismatch for {test_id}" - + def test_uniform_motion() -> None: """Test with points moving at constant velocity.""" points = pd.DataFrame({ @@ -251,6 +251,10 @@ def test_uniform_motion() -> None: start_label="1", end_label="2", points=points, + is_error=False, + line_number=1, + ) + segment.calculate_velocity_metrics(points) assert segment.distance == pytest.approx(3.0) @@ -292,7 +296,7 @@ def test_accelerating_motion() -> None: def test_velocity_two_points_only() -> None: - """Test velocity calculation with only two points (one velocity, no acceleration).""" + """Test velocity calculation with only two points.""" points = pd.DataFrame({ "x": [0, 3], "y": [0, 4], @@ -369,4 +373,3 @@ def test_stationary_motion() -> None: assert all(v == pytest.approx(0.0) for v in segment.velocities) assert len(segment.accelerations) == 1 assert segment.accelerations[0] == pytest.approx(0.0) - From 10957f96b9729932c6399c8a109b67bb0eb4c02c Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Tue, 10 Feb 2026 11:58:28 -0500 Subject: [PATCH 15/42] ruff reformat --- tests/unit/test_models.py | 60 +++++++++++++++++++++++---------------- 1 file changed, 35 insertions(+), 25 deletions(-) diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index 56bcf0c..fb2be62 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -242,11 +242,13 @@ def test_valid_ink_trajectory( def test_uniform_motion() -> None: """Test with points moving at constant velocity.""" - points = pd.DataFrame({ - "x": [0, 1, 2, 3], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - }) + points = pd.DataFrame( + { + "x": [0, 1, 2, 3], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -268,11 +270,13 @@ def test_uniform_motion() -> None: def test_accelerating_motion() -> None: """Test with motion accelerating over time.""" - points = pd.DataFrame({ - "x": [0, 1, 4, 9], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - }) + points = pd.DataFrame( + { + "x": [0, 1, 4, 9], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -297,11 +301,13 @@ def test_accelerating_motion() -> None: def test_velocity_two_points_only() -> None: """Test velocity calculation with only two points.""" - points = pd.DataFrame({ - "x": [0, 3], - "y": [0, 4], - "seconds": [0, 2], - }) + points = pd.DataFrame( + { + "x": [0, 3], + "y": [0, 4], + "seconds": [0, 2], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -322,11 +328,13 @@ def test_velocity_two_points_only() -> None: def test_decelerating_motion() -> None: """Test with decelerating motion (negative acceleration).""" - points = pd.DataFrame({ - "x": [0, 4, 7, 9], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - }) + points = pd.DataFrame( + { + "x": [0, 4, 7, 9], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -351,11 +359,13 @@ def test_decelerating_motion() -> None: def test_stationary_motion() -> None: """Test with no movement (all points the same).""" - points = pd.DataFrame({ - "x": [1, 1, 1], - "y": [1, 1, 1], - "seconds": [0, 1, 2], - }) + points = pd.DataFrame( + { + "x": [1, 1, 1], + "y": [1, 1, 1], + "seconds": [0, 1, 2], + } + ) segment = models.LineSegment( start_label="1", end_label="2", From 9e4883e202c2635053303a268bca89df5529dad1 Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Tue, 10 Feb 2026 13:52:08 -0500 Subject: [PATCH 16/42] wrote detect_hesitations --- src/graphomotor/core/models.py | 37 ++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/graphomotor/core/models.py b/src/graphomotor/core/models.py index 5e6aa0c..d83a0c2 100644 --- a/src/graphomotor/core/models.py +++ b/src/graphomotor/core/models.py @@ -316,3 +316,40 @@ def calculate_velocity_metrics(self, ink_points: pd.DataFrame) -> None: self.accelerations = np.diff(velocities).tolist() return + + def detect_hesitations(self, threshold_percentile: int = 20) -> None: + """Detect hesitations as periods of significantly reduced velocity. + + This function defines a hesitation as any period where the velocity falls below + a certain threshold, which is determined by the specified percentile of the + velocity distribution. It counts the number of distinct hesitation periods and + adds 1 if the line starts with a hesitation. It also calculates the total + duration of hesitations based on the number of points that fall below the + threshold and the time interval between points. + + Args: + threshold_percentile: Percentile to determine the velocity threshold for + hesitations (default is 20, meaning the bottom 20% of velocities are + considered hesitations). + """ + if len(self.velocities) < 3: + return + + dt = np.diff(self.ink_points["seconds"].values) + + threshold = np.percentile(self.velocities, threshold_percentile) + hesitations = self.velocities < threshold + + hesitation_changes = np.diff(hesitations.astype(int)) + hesitation_starts = np.where(hesitation_changes == 1)[0] + 1 + hesitation_count = len(hesitation_starts) + + if hesitations[0]: + hesitation_count += 1 + + hesitation_duration = np.sum(hesitations) * dt[0] + + self.hesitation_count = hesitation_count + self.hesitation_duration = hesitation_duration + + return From eded58d92db588c20e16d8d454a7769b320976ff Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Tue, 10 Feb 2026 14:27:02 -0500 Subject: [PATCH 17/42] adjusting the functions for ink_points which no longer will be in the class but will be calculated outside for sake of simplicity --- src/graphomotor/core/models.py | 19 ++-- tests/unit/test_models.py | 158 ++++++++++++++++++++++++--------- 2 files changed, 125 insertions(+), 52 deletions(-) diff --git a/src/graphomotor/core/models.py b/src/graphomotor/core/models.py index d83a0c2..00843fd 100644 --- a/src/graphomotor/core/models.py +++ b/src/graphomotor/core/models.py @@ -189,7 +189,6 @@ class LineSegment: points: pd.DataFrame is_error: bool line_number: int - ink_points: np.ndarray = dataclasses.field(default_factory=lambda: np.array([])) ink_time: float = 0.0 think_time: float = 0.0 @@ -230,7 +229,7 @@ def valid_ink_trajectory( end_circle: CircleTarget representing the end circle. Returns: - Tuple of (ink_start_idx: int, ink_end_idx: int) if valid + Tuple of (ink_start_idx: Optional[int], ink_end_idx: Optional[int]) if valid trajectory exists, else (None, None). """ ink_start_idx = None @@ -249,13 +248,6 @@ def valid_ink_trajectory( ink_end_idx = idx break - if ( - ink_start_idx is not None - and ink_end_idx is not None - and ink_end_idx > ink_start_idx - ): - self.ink_points = self.points.iloc[ink_start_idx : ink_end_idx + 1].copy() - return ink_start_idx, ink_end_idx def calculate_path_optimality( @@ -297,7 +289,7 @@ def calculate_velocity_metrics(self, ink_points: pd.DataFrame) -> None: Args: self: LineSegment object to calculate velocities for. - ink_points: DataFrame of ink points with 'x', 'y', and 'seconds' columns. + ink_points: DataFrame containing the ink points for the line segment. """ dx = np.diff(ink_points["x"].values) dy = np.diff(ink_points["y"].values) @@ -317,7 +309,9 @@ def calculate_velocity_metrics(self, ink_points: pd.DataFrame) -> None: return - def detect_hesitations(self, threshold_percentile: int = 20) -> None: + def detect_hesitations( + self, ink_points: pd.DataFrame, threshold_percentile: int = 20 + ) -> None: """Detect hesitations as periods of significantly reduced velocity. This function defines a hesitation as any period where the velocity falls below @@ -328,6 +322,7 @@ def detect_hesitations(self, threshold_percentile: int = 20) -> None: threshold and the time interval between points. Args: + ink_points: DataFrame containing the ink points for the line segment. threshold_percentile: Percentile to determine the velocity threshold for hesitations (default is 20, meaning the bottom 20% of velocities are considered hesitations). @@ -335,7 +330,7 @@ def detect_hesitations(self, threshold_percentile: int = 20) -> None: if len(self.velocities) < 3: return - dt = np.diff(self.ink_points["seconds"].values) + dt = np.diff(ink_points["seconds"].values) threshold = np.percentile(self.velocities, threshold_percentile) hesitations = self.velocities < threshold diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index fb2be62..a0ce2c7 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -242,13 +242,11 @@ def test_valid_ink_trajectory( def test_uniform_motion() -> None: """Test with points moving at constant velocity.""" - points = pd.DataFrame( - { - "x": [0, 1, 2, 3], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - } - ) + points = pd.DataFrame({ + "x": [0, 1, 2, 3], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -257,7 +255,7 @@ def test_uniform_motion() -> None: line_number=1, ) - segment.calculate_velocity_metrics(points) + segment.calculate_velocity_metrics(ink_points=points) assert segment.distance == pytest.approx(3.0) assert segment.mean_speed == pytest.approx(1.0) @@ -270,13 +268,11 @@ def test_uniform_motion() -> None: def test_accelerating_motion() -> None: """Test with motion accelerating over time.""" - points = pd.DataFrame( - { - "x": [0, 1, 4, 9], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - } - ) + points = pd.DataFrame({ + "x": [0, 1, 4, 9], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -285,7 +281,7 @@ def test_accelerating_motion() -> None: line_number=1, ) - segment.calculate_velocity_metrics(points) + segment.calculate_velocity_metrics(ink_points=points) assert segment.distance == pytest.approx(9.0) assert segment.mean_speed == pytest.approx(3.0) @@ -301,13 +297,11 @@ def test_accelerating_motion() -> None: def test_velocity_two_points_only() -> None: """Test velocity calculation with only two points.""" - points = pd.DataFrame( - { - "x": [0, 3], - "y": [0, 4], - "seconds": [0, 2], - } - ) + points = pd.DataFrame({ + "x": [0, 3], + "y": [0, 4], + "seconds": [0, 2], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -316,7 +310,7 @@ def test_velocity_two_points_only() -> None: line_number=1, ) - segment.calculate_velocity_metrics(points) + segment.calculate_velocity_metrics(ink_points=points) assert segment.distance == pytest.approx(5.0) assert segment.mean_speed == pytest.approx(2.5) @@ -328,13 +322,11 @@ def test_velocity_two_points_only() -> None: def test_decelerating_motion() -> None: """Test with decelerating motion (negative acceleration).""" - points = pd.DataFrame( - { - "x": [0, 4, 7, 9], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - } - ) + points = pd.DataFrame({ + "x": [0, 4, 7, 9], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -343,7 +335,7 @@ def test_decelerating_motion() -> None: line_number=1, ) - segment.calculate_velocity_metrics(points) + segment.calculate_velocity_metrics(ink_points=points) assert segment.distance == pytest.approx(9.0) assert segment.mean_speed == pytest.approx(3.0) @@ -359,13 +351,11 @@ def test_decelerating_motion() -> None: def test_stationary_motion() -> None: """Test with no movement (all points the same).""" - points = pd.DataFrame( - { - "x": [1, 1, 1], - "y": [1, 1, 1], - "seconds": [0, 1, 2], - } - ) + points = pd.DataFrame({ + "x": [1, 1, 1], + "y": [1, 1, 1], + "seconds": [0, 1, 2], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -374,7 +364,7 @@ def test_stationary_motion() -> None: line_number=1, ) - segment.calculate_velocity_metrics(points) + segment.calculate_velocity_metrics(ink_points=points) assert segment.distance == pytest.approx(0.0) assert segment.mean_speed == pytest.approx(0.0) @@ -383,3 +373,91 @@ def test_stationary_motion() -> None: assert all(v == pytest.approx(0.0) for v in segment.velocities) assert len(segment.accelerations) == 1 assert segment.accelerations[0] == pytest.approx(0.0) + + +def test_no_hesitations_uniform_motion() -> None: + """Test with uniform motion where all velocities are equal.""" + points = pd.DataFrame({ + "x": [0, 1, 2, 3], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + }) + segment = models.LineSegment( + start_label="1", + end_label="2", + points=points, + is_error=False, + line_number=1, + ) + + segment.calculate_velocity_metrics(ink_points=points) + segment.detect_hesitations(ink_points=points) + + assert segment.hesitation_count == 0 + assert segment.hesitation_duration == pytest.approx(0.0) + + +def test_hesitation_at_start() -> None: + """Test when the line starts with a hesitation.""" + points = pd.DataFrame({ + "x": [0, 0.1, 1, 2], + "y": [0, 0.1, 0, 0], + "seconds": [0, 1, 2, 3], + }) + segment = models.LineSegment( + start_label="1", + end_label="2", + points=points, + is_error=False, + line_number=1, + ) + + segment.calculate_velocity_metrics(ink_points=points) + segment.detect_hesitations(ink_points=points) + + assert segment.hesitation_count == 1 + assert segment.hesitation_duration == pytest.approx(1.0) + + +def test_multiple_hesitations() -> None: + """Test when there are multiple hesitation periods.""" + points = pd.DataFrame({ + "x": [0, 100, 100.1, 200, 200.1, 300, 400, 500, 600], + "y": [0, 0, 0, 0, 0, 0, 0, 0, 0], + "seconds": [0, 1, 2, 3, 4, 5, 6, 7, 8], + }) + segment = models.LineSegment( + start_label="1", + end_label="2", + points=points, + is_error=False, + line_number=1, + ) + + segment.calculate_velocity_metrics(ink_points=points) + segment.detect_hesitations(ink_points=points) + + assert segment.hesitation_count == 2 + assert segment.hesitation_duration == pytest.approx(2.0) + + +def test_less_than_three_velocities() -> None: + """Test early return when velocities length is less than 3.""" + points = pd.DataFrame({ + "x": [0, 1], + "y": [0, 0], + "seconds": [0, 1], + }) + segment = models.LineSegment( + start_label="1", + end_label="2", + points=points, + is_error=False, + line_number=1, + ) + + segment.calculate_velocity_metrics(ink_points=points) + segment.detect_hesitations(ink_points=points) + + assert segment.hesitation_count == 0 + assert segment.hesitation_duration == pytest.approx(0.0) From 679cffc1053e21ae5c740fda28ab7c250758b3ae Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Tue, 10 Feb 2026 14:57:26 -0500 Subject: [PATCH 18/42] made ink_points a class attribute again and fixed all unit tests --- src/graphomotor/core/models.py | 21 ++++++++++++-------- tests/unit/test_models.py | 35 +++++++++++++++++++++------------- 2 files changed, 35 insertions(+), 21 deletions(-) diff --git a/src/graphomotor/core/models.py b/src/graphomotor/core/models.py index 00843fd..3707f64 100644 --- a/src/graphomotor/core/models.py +++ b/src/graphomotor/core/models.py @@ -202,6 +202,7 @@ class LineSegment: hesitation_duration: float = 0.0 velocities: List[float] = dataclasses.field(default_factory=list) accelerations: List[float] = dataclasses.field(default_factory=list) + ink_points: pd.DataFrame = dataclasses.field(default_factory=pd.DataFrame) def valid_ink_trajectory( self, @@ -247,6 +248,12 @@ def valid_ink_trajectory( ): ink_end_idx = idx break + if ( + ink_start_idx is not None + and ink_end_idx is not None + and ink_end_idx > ink_start_idx + ): + self.ink_points = self.points.iloc[ink_start_idx : ink_end_idx + 1].copy() return ink_start_idx, ink_end_idx @@ -284,16 +291,16 @@ def calculate_path_optimality( self.path_optimality = optimal_distance / self.distance return - def calculate_velocity_metrics(self, ink_points: pd.DataFrame) -> None: + def calculate_velocity_metrics(self) -> None: """Get velocity metrics of a LineSegment. Args: self: LineSegment object to calculate velocities for. ink_points: DataFrame containing the ink points for the line segment. """ - dx = np.diff(ink_points["x"].values) - dy = np.diff(ink_points["y"].values) - dt = np.diff(ink_points["seconds"].values) + dx = np.diff(self.ink_points["x"].values) + dy = np.diff(self.ink_points["y"].values) + dt = np.diff(self.ink_points["seconds"].values) distances = np.sqrt(dx**2 + dy**2) self.distance = np.sum(distances) @@ -309,9 +316,7 @@ def calculate_velocity_metrics(self, ink_points: pd.DataFrame) -> None: return - def detect_hesitations( - self, ink_points: pd.DataFrame, threshold_percentile: int = 20 - ) -> None: + def detect_hesitations(self, threshold_percentile: int = 20) -> None: """Detect hesitations as periods of significantly reduced velocity. This function defines a hesitation as any period where the velocity falls below @@ -330,7 +335,7 @@ def detect_hesitations( if len(self.velocities) < 3: return - dt = np.diff(ink_points["seconds"].values) + dt = np.diff(self.ink_points["seconds"].values) threshold = np.percentile(self.velocities, threshold_percentile) hesitations = self.velocities < threshold diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index a0ce2c7..3948013 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -253,9 +253,10 @@ def test_uniform_motion() -> None: points=points, is_error=False, line_number=1, + ink_points=points, # Pre-assign ink_points for velocity calculation ) - segment.calculate_velocity_metrics(ink_points=points) + segment.calculate_velocity_metrics() assert segment.distance == pytest.approx(3.0) assert segment.mean_speed == pytest.approx(1.0) @@ -279,9 +280,10 @@ def test_accelerating_motion() -> None: points=points, is_error=False, line_number=1, + ink_points=points, # Pre-assign ink_points for velocity calculation ) - segment.calculate_velocity_metrics(ink_points=points) + segment.calculate_velocity_metrics() assert segment.distance == pytest.approx(9.0) assert segment.mean_speed == pytest.approx(3.0) @@ -308,9 +310,10 @@ def test_velocity_two_points_only() -> None: points=points, is_error=False, line_number=1, + ink_points=points, # Pre-assign ink_points for velocity calculation ) - segment.calculate_velocity_metrics(ink_points=points) + segment.calculate_velocity_metrics() assert segment.distance == pytest.approx(5.0) assert segment.mean_speed == pytest.approx(2.5) @@ -333,9 +336,10 @@ def test_decelerating_motion() -> None: points=points, is_error=False, line_number=1, + ink_points=points, # Pre-assign ink_points for velocity calculation ) - segment.calculate_velocity_metrics(ink_points=points) + segment.calculate_velocity_metrics() assert segment.distance == pytest.approx(9.0) assert segment.mean_speed == pytest.approx(3.0) @@ -362,9 +366,10 @@ def test_stationary_motion() -> None: points=points, is_error=False, line_number=1, + ink_points=points, # Pre-assign ink_points for velocity calculation ) - segment.calculate_velocity_metrics(ink_points=points) + segment.calculate_velocity_metrics() assert segment.distance == pytest.approx(0.0) assert segment.mean_speed == pytest.approx(0.0) @@ -388,10 +393,11 @@ def test_no_hesitations_uniform_motion() -> None: points=points, is_error=False, line_number=1, + ink_points=points, # Pre-assign ink_points for velocity calculation ) - segment.calculate_velocity_metrics(ink_points=points) - segment.detect_hesitations(ink_points=points) + segment.calculate_velocity_metrics() + segment.detect_hesitations() assert segment.hesitation_count == 0 assert segment.hesitation_duration == pytest.approx(0.0) @@ -410,10 +416,11 @@ def test_hesitation_at_start() -> None: points=points, is_error=False, line_number=1, + ink_points=points, # Pre-assign ink_points for velocity calculation ) - segment.calculate_velocity_metrics(ink_points=points) - segment.detect_hesitations(ink_points=points) + segment.calculate_velocity_metrics() + segment.detect_hesitations() assert segment.hesitation_count == 1 assert segment.hesitation_duration == pytest.approx(1.0) @@ -432,10 +439,11 @@ def test_multiple_hesitations() -> None: points=points, is_error=False, line_number=1, + ink_points=points, # Pre-assign ink_points for velocity calculation ) - segment.calculate_velocity_metrics(ink_points=points) - segment.detect_hesitations(ink_points=points) + segment.calculate_velocity_metrics() + segment.detect_hesitations() assert segment.hesitation_count == 2 assert segment.hesitation_duration == pytest.approx(2.0) @@ -454,10 +462,11 @@ def test_less_than_three_velocities() -> None: points=points, is_error=False, line_number=1, + ink_points=points, # Pre-assign ink_points for velocity calculation ) - segment.calculate_velocity_metrics(ink_points=points) - segment.detect_hesitations(ink_points=points) + segment.calculate_velocity_metrics() + segment.detect_hesitations() assert segment.hesitation_count == 0 assert segment.hesitation_duration == pytest.approx(0.0) From e9f54eb5985f7f6668e7d817ac4235e26e364273 Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Tue, 10 Feb 2026 15:01:12 -0500 Subject: [PATCH 19/42] ruff reformat --- tests/unit/test_models.py | 108 ++++++++++++++++++++++---------------- 1 file changed, 63 insertions(+), 45 deletions(-) diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index 3948013..86456aa 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -242,11 +242,13 @@ def test_valid_ink_trajectory( def test_uniform_motion() -> None: """Test with points moving at constant velocity.""" - points = pd.DataFrame({ - "x": [0, 1, 2, 3], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - }) + points = pd.DataFrame( + { + "x": [0, 1, 2, 3], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -269,11 +271,13 @@ def test_uniform_motion() -> None: def test_accelerating_motion() -> None: """Test with motion accelerating over time.""" - points = pd.DataFrame({ - "x": [0, 1, 4, 9], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - }) + points = pd.DataFrame( + { + "x": [0, 1, 4, 9], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -299,11 +303,13 @@ def test_accelerating_motion() -> None: def test_velocity_two_points_only() -> None: """Test velocity calculation with only two points.""" - points = pd.DataFrame({ - "x": [0, 3], - "y": [0, 4], - "seconds": [0, 2], - }) + points = pd.DataFrame( + { + "x": [0, 3], + "y": [0, 4], + "seconds": [0, 2], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -325,11 +331,13 @@ def test_velocity_two_points_only() -> None: def test_decelerating_motion() -> None: """Test with decelerating motion (negative acceleration).""" - points = pd.DataFrame({ - "x": [0, 4, 7, 9], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - }) + points = pd.DataFrame( + { + "x": [0, 4, 7, 9], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -355,11 +363,13 @@ def test_decelerating_motion() -> None: def test_stationary_motion() -> None: """Test with no movement (all points the same).""" - points = pd.DataFrame({ - "x": [1, 1, 1], - "y": [1, 1, 1], - "seconds": [0, 1, 2], - }) + points = pd.DataFrame( + { + "x": [1, 1, 1], + "y": [1, 1, 1], + "seconds": [0, 1, 2], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -382,11 +392,13 @@ def test_stationary_motion() -> None: def test_no_hesitations_uniform_motion() -> None: """Test with uniform motion where all velocities are equal.""" - points = pd.DataFrame({ - "x": [0, 1, 2, 3], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - }) + points = pd.DataFrame( + { + "x": [0, 1, 2, 3], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -405,11 +417,13 @@ def test_no_hesitations_uniform_motion() -> None: def test_hesitation_at_start() -> None: """Test when the line starts with a hesitation.""" - points = pd.DataFrame({ - "x": [0, 0.1, 1, 2], - "y": [0, 0.1, 0, 0], - "seconds": [0, 1, 2, 3], - }) + points = pd.DataFrame( + { + "x": [0, 0.1, 1, 2], + "y": [0, 0.1, 0, 0], + "seconds": [0, 1, 2, 3], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -428,11 +442,13 @@ def test_hesitation_at_start() -> None: def test_multiple_hesitations() -> None: """Test when there are multiple hesitation periods.""" - points = pd.DataFrame({ - "x": [0, 100, 100.1, 200, 200.1, 300, 400, 500, 600], - "y": [0, 0, 0, 0, 0, 0, 0, 0, 0], - "seconds": [0, 1, 2, 3, 4, 5, 6, 7, 8], - }) + points = pd.DataFrame( + { + "x": [0, 100, 100.1, 200, 200.1, 300, 400, 500, 600], + "y": [0, 0, 0, 0, 0, 0, 0, 0, 0], + "seconds": [0, 1, 2, 3, 4, 5, 6, 7, 8], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -451,11 +467,13 @@ def test_multiple_hesitations() -> None: def test_less_than_three_velocities() -> None: """Test early return when velocities length is less than 3.""" - points = pd.DataFrame({ - "x": [0, 1], - "y": [0, 0], - "seconds": [0, 1], - }) + points = pd.DataFrame( + { + "x": [0, 1], + "y": [0, 0], + "seconds": [0, 1], + } + ) segment = models.LineSegment( start_label="1", end_label="2", From c5ce67c0d043ec5e49c9e955c86051f90d3464b9 Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Wed, 11 Feb 2026 10:56:44 -0500 Subject: [PATCH 20/42] move calculate smoothness into LineSegment, move smoothness unit tests into test_models, write calculate segment metrics --- src/graphomotor/core/models.py | 113 +++++++++ .../features/trails/drawing_metrics.py | 46 ---- tests/unit/test_models.py | 222 +++++++++++++----- tests/unit/test_trails_drawing_metrics.py | 81 +------ 4 files changed, 280 insertions(+), 182 deletions(-) diff --git a/src/graphomotor/core/models.py b/src/graphomotor/core/models.py index 3707f64..52efc02 100644 --- a/src/graphomotor/core/models.py +++ b/src/graphomotor/core/models.py @@ -353,3 +353,116 @@ def detect_hesitations(self, threshold_percentile: int = 20) -> None: self.hesitation_duration = hesitation_duration return + + def calculate_smoothness(self) -> None: + """Calculate path smoothness based on Root Mean Square (RMS) curvature. + + Represents the curvature per unit arc length. + Lower values indicate smoother drawings. Penalizes sharp corners (e.g., 90° turns) + and noisy corrections. Normalized by arc length to reduce sampling-rate dependence. + """ + if len(self.ink_points) < 3: + return 0.0 + + xy = self.ink_points[["x", "y"]].to_numpy() + + forward_vector = xy[1:-1] - xy[:-2] + backward_vector = xy[2:] - xy[1:-1] + + forward_norm = np.linalg.norm(forward_vector, axis=1) + backward_norm = np.linalg.norm(backward_vector, axis=1) + + valid = (forward_norm > 0) & (backward_norm > 0) + if not np.any(valid): + return + + valid_forward_vector = forward_vector[valid] + valid_backward_vector = backward_vector[valid] + valid_forward_norm = forward_norm[valid] + valid_backward_norm = backward_norm[valid] + + cos_angle = (valid_forward_vector * valid_backward_vector).sum(axis=1) / ( + valid_forward_norm * valid_backward_norm + ) + cos_angle = np.clip(cos_angle, -1.0, 1.0) + + angles = np.arccos(cos_angle) + + avg_segment_length = (valid_forward_norm + valid_backward_norm) / 2.0 + curvatures = angles / avg_segment_length + + self.smoothness = float(np.sqrt(np.mean(curvatures**2))) + + return + + def compute_segment_metrics( + self, circles: dict[str, dict[str, CircleTarget]], trail_id: str + ) -> None: + """Compute metrics for a line segment (excluding think time which is calculated separately). + + Args: + circles: Dictionary of circle targets keyed by trail ID + trail_id: Trail identifier for circle lookup + + Returns: + LineSegment with computed metrics + """ + circles = circles[trail_id] + points = self.points.copy() + + if len(points) < 2: + return self + + if self.start_label not in circles or self.end_label not in circles: + return self + + start_circle = circles[self.start_label] + end_circle = circles[self.end_label] + + points = points.reset_index(drop=True) + + # NOTE: Think time is now calculated separately using consecutive segments + # This method only handles ink time and movement metrics + + ink_start_idx, ink_end_idx = self.valid_ink_trajectory( + points, start_circle, end_circle + ) + + if ( + ink_start_idx is not None + and ink_end_idx is not None + and ink_end_idx > ink_start_idx + ): + ink_points = points.iloc[ink_start_idx : ink_end_idx + 1].copy() + + if len(ink_points) >= 2: + ink_start = ink_points.iloc[0]["seconds"] + ink_end = ink_points.iloc[-1]["seconds"] + self.ink_time = ink_end - ink_start + + # NOTE: Unsure if we are going to resample or not + # # Resample to 60Hz if duration is sufficient + # if self.ink_time > 0.05: # Only resample if duration > 50ms + # ink_points = resample_to_60hz(ink_points, ink_start, ink_end) + + # Calculate velocities and speeds + self.calculate_velocity_metrics() + + # Calculate path optimality + self.calculate_path_optimality(start_circle, end_circle) + # Smoothness (based on curvature changes) + if len(ink_points) >= 3: + self.calculate_smoothness() + + # Hesitation detection + self.detect_hesitations() + + elif ink_start_idx is not None: + # Line started but never reached destination - use all remaining points + ink_points = points.iloc[ink_start_idx:].copy() + if len(ink_points) >= 2: + self.ink_time = ( + ink_points.iloc[-1]["seconds"] - ink_points.iloc[0]["seconds"] + ) + + return diff --git a/src/graphomotor/features/trails/drawing_metrics.py b/src/graphomotor/features/trails/drawing_metrics.py index 98a2a80..e887fa0 100644 --- a/src/graphomotor/features/trails/drawing_metrics.py +++ b/src/graphomotor/features/trails/drawing_metrics.py @@ -43,49 +43,3 @@ def percent_accurate_paths(drawing: models.Drawing) -> dict[str, float]: (drawing.data["correct_path"] == drawing.data["actual_path"]).mean() * 100 ) } - - -def calculate_smoothness(points: pd.DataFrame) -> float: - """Calculate path smoothness based on Root Mean Square (RMS) curvature. - - Represents the curvature per unit arc length. - Lower values indicate smoother drawings. Penalizes sharp corners (e.g., 90° turns) - and noisy corrections. Normalized by arc length to reduce sampling-rate dependence. - - Args: - points: DataFrame representing drawing points. - - Returns: - Smoothness metric as a float. - """ - if len(points) < 3: - return 0.0 - - xy = points[["x", "y"]].to_numpy() - - forward_vector = xy[1:-1] - xy[:-2] - backward_vector = xy[2:] - xy[1:-1] - - forward_norm = np.linalg.norm(forward_vector, axis=1) - backward_norm = np.linalg.norm(backward_vector, axis=1) - - valid = (forward_norm > 0) & (backward_norm > 0) - if not np.any(valid): - return 0.0 - - valid_forward_vector = forward_vector[valid] - valid_backward_vector = backward_vector[valid] - valid_forward_norm = forward_norm[valid] - valid_backward_norm = backward_norm[valid] - - cos_angle = (valid_forward_vector * valid_backward_vector).sum(axis=1) / ( - valid_forward_norm * valid_backward_norm - ) - cos_angle = np.clip(cos_angle, -1.0, 1.0) - - angles = np.arccos(cos_angle) - - avg_segment_length = (valid_forward_norm + valid_backward_norm) / 2.0 - curvatures = angles / avg_segment_length - - return float(np.sqrt(np.mean(curvatures**2))) diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index 86456aa..12ba850 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -3,6 +3,7 @@ import datetime from typing import Dict, cast +import numpy as np import pandas as pd import pytest @@ -242,13 +243,11 @@ def test_valid_ink_trajectory( def test_uniform_motion() -> None: """Test with points moving at constant velocity.""" - points = pd.DataFrame( - { - "x": [0, 1, 2, 3], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - } - ) + points = pd.DataFrame({ + "x": [0, 1, 2, 3], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -271,13 +270,11 @@ def test_uniform_motion() -> None: def test_accelerating_motion() -> None: """Test with motion accelerating over time.""" - points = pd.DataFrame( - { - "x": [0, 1, 4, 9], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - } - ) + points = pd.DataFrame({ + "x": [0, 1, 4, 9], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -303,13 +300,11 @@ def test_accelerating_motion() -> None: def test_velocity_two_points_only() -> None: """Test velocity calculation with only two points.""" - points = pd.DataFrame( - { - "x": [0, 3], - "y": [0, 4], - "seconds": [0, 2], - } - ) + points = pd.DataFrame({ + "x": [0, 3], + "y": [0, 4], + "seconds": [0, 2], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -331,13 +326,11 @@ def test_velocity_two_points_only() -> None: def test_decelerating_motion() -> None: """Test with decelerating motion (negative acceleration).""" - points = pd.DataFrame( - { - "x": [0, 4, 7, 9], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - } - ) + points = pd.DataFrame({ + "x": [0, 4, 7, 9], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -363,13 +356,11 @@ def test_decelerating_motion() -> None: def test_stationary_motion() -> None: """Test with no movement (all points the same).""" - points = pd.DataFrame( - { - "x": [1, 1, 1], - "y": [1, 1, 1], - "seconds": [0, 1, 2], - } - ) + points = pd.DataFrame({ + "x": [1, 1, 1], + "y": [1, 1, 1], + "seconds": [0, 1, 2], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -392,13 +383,11 @@ def test_stationary_motion() -> None: def test_no_hesitations_uniform_motion() -> None: """Test with uniform motion where all velocities are equal.""" - points = pd.DataFrame( - { - "x": [0, 1, 2, 3], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - } - ) + points = pd.DataFrame({ + "x": [0, 1, 2, 3], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -417,13 +406,11 @@ def test_no_hesitations_uniform_motion() -> None: def test_hesitation_at_start() -> None: """Test when the line starts with a hesitation.""" - points = pd.DataFrame( - { - "x": [0, 0.1, 1, 2], - "y": [0, 0.1, 0, 0], - "seconds": [0, 1, 2, 3], - } - ) + points = pd.DataFrame({ + "x": [0, 0.1, 1, 2], + "y": [0, 0.1, 0, 0], + "seconds": [0, 1, 2, 3], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -442,13 +429,11 @@ def test_hesitation_at_start() -> None: def test_multiple_hesitations() -> None: """Test when there are multiple hesitation periods.""" - points = pd.DataFrame( - { - "x": [0, 100, 100.1, 200, 200.1, 300, 400, 500, 600], - "y": [0, 0, 0, 0, 0, 0, 0, 0, 0], - "seconds": [0, 1, 2, 3, 4, 5, 6, 7, 8], - } - ) + points = pd.DataFrame({ + "x": [0, 100, 100.1, 200, 200.1, 300, 400, 500, 600], + "y": [0, 0, 0, 0, 0, 0, 0, 0, 0], + "seconds": [0, 1, 2, 3, 4, 5, 6, 7, 8], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -467,13 +452,11 @@ def test_multiple_hesitations() -> None: def test_less_than_three_velocities() -> None: """Test early return when velocities length is less than 3.""" - points = pd.DataFrame( - { - "x": [0, 1], - "y": [0, 0], - "seconds": [0, 1], - } - ) + points = pd.DataFrame({ + "x": [0, 1], + "y": [0, 0], + "seconds": [0, 1], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -488,3 +471,116 @@ def test_less_than_three_velocities() -> None: assert segment.hesitation_count == 0 assert segment.hesitation_duration == pytest.approx(0.0) + + +def test_smoothness_less_than_three_points() -> None: + """Less than 3 points cannot define curvature.""" + points = pd.DataFrame({ + "x": [0, 1], + "y": [0, 0], + "seconds": [0, 1], + }) + segment = models.LineSegment( + start_label="1", + end_label="2", + points=points, + is_error=False, + line_number=1, + ink_points=points, # Pre-assign ink_points for smoothness calculation + ) + segment.calculate_smoothness() + assert segment.smoothness == 0.0 + + +def test_smoothness_straight_line() -> None: + """Collinear points have zero curvature.""" + points = pd.DataFrame({"x": [0, 1, 2, 3], "y": [0, 0, 0, 0]}) + segment = models.LineSegment( + start_label="1", + end_label="2", + points=points, + is_error=False, + line_number=1, + ink_points=points, # Pre-assign ink_points for smoothness calculation + ) + segment.calculate_smoothness() + assert segment.smoothness == 0.0 + + +def test_smoothness_single_right_angle() -> None: + """A single 90-degree corner should produce a large smoothness value. + + (non-zero), since sharp turns are penalized. + """ + points = pd.DataFrame({"x": [0, 1, 1], "y": [0, 0, 1]}) + segment = models.LineSegment( + start_label="1", + end_label="2", + points=points, + is_error=False, + line_number=1, + ink_points=points, # Pre-assign ink_points for smoothness calculation + ) + expected = np.pi / 2 + segment.calculate_smoothness() + assert np.isclose(segment.smoothness, expected) + + +def test_smoothness_varied_angles() -> None: + """Multiple angles should produce RMS curvature. + + Path has a 90° turn followed by a 45° turn. + """ + points = pd.DataFrame({"x": [0, 1, 1, 2], "y": [0, 0, 1, 2]}) + segment = models.LineSegment( + start_label="1", + end_label="2", + points=points, + is_error=False, + line_number=1, + ink_points=points, # Pre-assign ink_points for smoothness calculation + ) + c1 = np.pi / 2 + c2 = (np.pi / 4) / ((1 + np.sqrt(2)) / 2) + expected = np.sqrt((c1**2 + c2**2) / 2) + + segment.calculate_smoothness() + + assert np.isclose(segment.smoothness, expected) + + +def test_smoothness_zero_length_segments() -> None: + """Zero-length segments should be skipped; no angles → smoothness 0.""" + points = pd.DataFrame({"x": [0, 1, 1, 2], "y": [0, 0, 0, 0]}) + segment = models.LineSegment( + start_label="1", + end_label="2", + points=points, + is_error=False, + line_number=1, + ink_points=points, # Pre-assign ink_points for smoothness calculation + ) + segment.calculate_smoothness() + assert segment.smoothness == 0.0 + + +def test_smoothness_single_180_degree_turn() -> None: + """A single 180-degree turn should produce a very large smoothness value. + + Since it represents maximal curvature. + """ + points = pd.DataFrame({ + "x": [0, 1, 0], + "y": [0, 0, 0], + }) + segment = models.LineSegment( + start_label="1", + end_label="2", + points=points, + is_error=False, + line_number=1, + ink_points=points, # Pre-assign ink_points for smoothness calculation + ) + expected = np.pi + segment.calculate_smoothness() + assert np.isclose(segment.smoothness, expected) diff --git a/tests/unit/test_trails_drawing_metrics.py b/tests/unit/test_trails_drawing_metrics.py index 6507b74..b8be4dd 100644 --- a/tests/unit/test_trails_drawing_metrics.py +++ b/tests/unit/test_trails_drawing_metrics.py @@ -11,12 +11,10 @@ def test_get_total_errors() -> None: """Test ValueError when total_number_of_errors column doesn't exist.""" - invalid_df = pd.DataFrame( - { - "some_other_column": [0, 1, 2], - "seconds": [0.0, 1.0, 2.0], - } - ) + invalid_df = pd.DataFrame({ + "some_other_column": [0, 1, 2], + "seconds": [0.0, 1.0, 2.0], + }) drawing = models.Drawing( data=invalid_df, task_name="trails", metadata={"id": "5555555"} ) @@ -39,12 +37,10 @@ def test_valid_total_errors() -> None: def test_percent_accurate_paths_missing_columns() -> None: """Test ValueError when required columns are missing.""" - invalid_df = pd.DataFrame( - { - "some_other_column": [0, 1, 2], - "seconds": [0.0, 1.0, 2.0], - } - ) + invalid_df = pd.DataFrame({ + "some_other_column": [0, 1, 2], + "seconds": [0.0, 1.0, 2.0], + }) drawing = models.Drawing( data=invalid_df, task_name="trails", metadata={"id": "5555555"} ) @@ -63,64 +59,3 @@ def test_percent_accurate_paths_sample_data() -> None: result = drawing_metrics.percent_accurate_paths(drawing) assert result == {"percent_accurate_paths": 100.0} - - -def test_smoothness_less_than_three_points() -> None: - """Less than 3 points cannot define curvature.""" - points = pd.DataFrame({"x": [0, 1], "y": [0, 1]}) - assert drawing_metrics.calculate_smoothness(points) == 0.0 - - -def test_smoothness_straight_line() -> None: - """Collinear points have zero curvature.""" - points = pd.DataFrame({"x": [0, 1, 2, 3], "y": [0, 0, 0, 0]}) - assert drawing_metrics.calculate_smoothness(points) == 0.0 - - -def test_smoothness_single_right_angle() -> None: - """A single 90-degree corner should produce a large smoothness value. - - (non-zero), since sharp turns are penalized. - """ - points = pd.DataFrame({"x": [0, 1, 1], "y": [0, 0, 1]}) - expected = np.pi / 2 - smoothness = drawing_metrics.calculate_smoothness(points) - assert np.isclose(smoothness, expected) - - -def test_smoothness_varied_angles() -> None: - """Multiple angles should produce RMS curvature. - - Path has a 90° turn followed by a 45° turn. - """ - points = pd.DataFrame({"x": [0, 1, 1, 2], "y": [0, 0, 1, 2]}) - c1 = np.pi / 2 - c2 = (np.pi / 4) / ((1 + np.sqrt(2)) / 2) - expected = np.sqrt((c1**2 + c2**2) / 2) - - smoothness = drawing_metrics.calculate_smoothness(points) - - assert np.isclose(smoothness, expected) - - -def test_smoothness_zero_length_segments() -> None: - """Zero-length segments should be skipped; no angles → smoothness 0.""" - points = pd.DataFrame({"x": [0, 1, 1, 2], "y": [0, 0, 0, 0]}) - smoothness = drawing_metrics.calculate_smoothness(points) - assert smoothness == 0.0 - - -def test_smoothness_single_180_degree_turn() -> None: - """A single 180-degree turn should produce a very large smoothness value. - - Since it represents maximal curvature. - """ - points = pd.DataFrame( - { - "x": [0, 1, 0], - "y": [0, 0, 0], - } - ) - expected = np.pi - smoothness = drawing_metrics.calculate_smoothness(points) - assert np.isclose(smoothness, expected) From 0c40daf3f5296f10dbcc33b05db86fc5d0ff13d8 Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Wed, 11 Feb 2026 11:11:07 -0500 Subject: [PATCH 21/42] cleande up compute_segment metrics, moved ink_point assigning out of valid_ink_trajectory and into compute_segment_metrics --- src/graphomotor/core/models.py | 70 +++++++++++++--------------------- 1 file changed, 26 insertions(+), 44 deletions(-) diff --git a/src/graphomotor/core/models.py b/src/graphomotor/core/models.py index 52efc02..b0ffe45 100644 --- a/src/graphomotor/core/models.py +++ b/src/graphomotor/core/models.py @@ -248,12 +248,6 @@ def valid_ink_trajectory( ): ink_end_idx = idx break - if ( - ink_start_idx is not None - and ink_end_idx is not None - and ink_end_idx > ink_start_idx - ): - self.ink_points = self.points.iloc[ink_start_idx : ink_end_idx + 1].copy() return ink_start_idx, ink_end_idx @@ -358,8 +352,9 @@ def calculate_smoothness(self) -> None: """Calculate path smoothness based on Root Mean Square (RMS) curvature. Represents the curvature per unit arc length. - Lower values indicate smoother drawings. Penalizes sharp corners (e.g., 90° turns) - and noisy corrections. Normalized by arc length to reduce sampling-rate dependence. + Lower values indicate smoother drawings. Penalizes sharp corners (e.g., + 90° turns) and noisy corrections. Normalized by arc length to reduce + sampling-rate dependence. """ if len(self.ink_points) < 3: return 0.0 @@ -398,71 +393,58 @@ def calculate_smoothness(self) -> None: def compute_segment_metrics( self, circles: dict[str, dict[str, CircleTarget]], trail_id: str ) -> None: - """Compute metrics for a line segment (excluding think time which is calculated separately). + """Compute all metrics for a line segment. - Args: - circles: Dictionary of circle targets keyed by trail ID - trail_id: Trail identifier for circle lookup + This function computes various metrics for the line segment, including ink time, + velocity metrics, path optimality, smoothness, and hesitation detection. It + first determines the valid ink trajectory between the start and end circles. If + a valid trajectory is found, it updates the ink_points attribute and calculates + the metrics. - Returns: - LineSegment with computed metrics + Args: + circles: A dictionary mapping each trail type to dictionaries of + CircleTarget instances (output of load_scaled_circles in config). + trail_id: Trail identifier for circle lookup. """ circles = circles[trail_id] points = self.points.copy() if len(points) < 2: - return self + return if self.start_label not in circles or self.end_label not in circles: - return self + return start_circle = circles[self.start_label] end_circle = circles[self.end_label] - points = points.reset_index(drop=True) - - # NOTE: Think time is now calculated separately using consecutive segments - # This method only handles ink time and movement metrics - - ink_start_idx, ink_end_idx = self.valid_ink_trajectory( - points, start_circle, end_circle - ) + ink_start_idx, ink_end_idx = self.valid_ink_trajectory(start_circle, end_circle) if ( ink_start_idx is not None and ink_end_idx is not None and ink_end_idx > ink_start_idx ): - ink_points = points.iloc[ink_start_idx : ink_end_idx + 1].copy() + self.ink_points = self.points.iloc[ink_start_idx : ink_end_idx + 1].copy() - if len(ink_points) >= 2: - ink_start = ink_points.iloc[0]["seconds"] - ink_end = ink_points.iloc[-1]["seconds"] + if len(self.ink_points) >= 2: + ink_start = self.ink_points.iloc[0]["seconds"] + ink_end = self.ink_points.iloc[-1]["seconds"] self.ink_time = ink_end - ink_start - # NOTE: Unsure if we are going to resample or not - # # Resample to 60Hz if duration is sufficient - # if self.ink_time > 0.05: # Only resample if duration > 50ms - # ink_points = resample_to_60hz(ink_points, ink_start, ink_end) - - # Calculate velocities and speeds self.calculate_velocity_metrics() - # Calculate path optimality self.calculate_path_optimality(start_circle, end_circle) - # Smoothness (based on curvature changes) - if len(ink_points) >= 3: - self.calculate_smoothness() - # Hesitation detection + self.calculate_smoothness() + self.detect_hesitations() elif ink_start_idx is not None: - # Line started but never reached destination - use all remaining points - ink_points = points.iloc[ink_start_idx:].copy() - if len(ink_points) >= 2: + self.ink_points = points.iloc[ink_start_idx:].copy() + if len(self.ink_points) >= 2: self.ink_time = ( - ink_points.iloc[-1]["seconds"] - ink_points.iloc[0]["seconds"] + self.ink_points.iloc[-1]["seconds"] + - self.ink_points.iloc[0]["seconds"] ) - return From 215f70111779c03805d088b0b6978726be4ff09a Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Wed, 11 Feb 2026 11:16:49 -0500 Subject: [PATCH 22/42] ruff reformat --- tests/unit/test_models.py | 130 +++++++++++++--------- tests/unit/test_trails_drawing_metrics.py | 20 ++-- 2 files changed, 88 insertions(+), 62 deletions(-) diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index 12ba850..0fc86d6 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -243,11 +243,13 @@ def test_valid_ink_trajectory( def test_uniform_motion() -> None: """Test with points moving at constant velocity.""" - points = pd.DataFrame({ - "x": [0, 1, 2, 3], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - }) + points = pd.DataFrame( + { + "x": [0, 1, 2, 3], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -270,11 +272,13 @@ def test_uniform_motion() -> None: def test_accelerating_motion() -> None: """Test with motion accelerating over time.""" - points = pd.DataFrame({ - "x": [0, 1, 4, 9], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - }) + points = pd.DataFrame( + { + "x": [0, 1, 4, 9], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -300,11 +304,13 @@ def test_accelerating_motion() -> None: def test_velocity_two_points_only() -> None: """Test velocity calculation with only two points.""" - points = pd.DataFrame({ - "x": [0, 3], - "y": [0, 4], - "seconds": [0, 2], - }) + points = pd.DataFrame( + { + "x": [0, 3], + "y": [0, 4], + "seconds": [0, 2], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -326,11 +332,13 @@ def test_velocity_two_points_only() -> None: def test_decelerating_motion() -> None: """Test with decelerating motion (negative acceleration).""" - points = pd.DataFrame({ - "x": [0, 4, 7, 9], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - }) + points = pd.DataFrame( + { + "x": [0, 4, 7, 9], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -356,11 +364,13 @@ def test_decelerating_motion() -> None: def test_stationary_motion() -> None: """Test with no movement (all points the same).""" - points = pd.DataFrame({ - "x": [1, 1, 1], - "y": [1, 1, 1], - "seconds": [0, 1, 2], - }) + points = pd.DataFrame( + { + "x": [1, 1, 1], + "y": [1, 1, 1], + "seconds": [0, 1, 2], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -383,11 +393,13 @@ def test_stationary_motion() -> None: def test_no_hesitations_uniform_motion() -> None: """Test with uniform motion where all velocities are equal.""" - points = pd.DataFrame({ - "x": [0, 1, 2, 3], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - }) + points = pd.DataFrame( + { + "x": [0, 1, 2, 3], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -406,11 +418,13 @@ def test_no_hesitations_uniform_motion() -> None: def test_hesitation_at_start() -> None: """Test when the line starts with a hesitation.""" - points = pd.DataFrame({ - "x": [0, 0.1, 1, 2], - "y": [0, 0.1, 0, 0], - "seconds": [0, 1, 2, 3], - }) + points = pd.DataFrame( + { + "x": [0, 0.1, 1, 2], + "y": [0, 0.1, 0, 0], + "seconds": [0, 1, 2, 3], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -429,11 +443,13 @@ def test_hesitation_at_start() -> None: def test_multiple_hesitations() -> None: """Test when there are multiple hesitation periods.""" - points = pd.DataFrame({ - "x": [0, 100, 100.1, 200, 200.1, 300, 400, 500, 600], - "y": [0, 0, 0, 0, 0, 0, 0, 0, 0], - "seconds": [0, 1, 2, 3, 4, 5, 6, 7, 8], - }) + points = pd.DataFrame( + { + "x": [0, 100, 100.1, 200, 200.1, 300, 400, 500, 600], + "y": [0, 0, 0, 0, 0, 0, 0, 0, 0], + "seconds": [0, 1, 2, 3, 4, 5, 6, 7, 8], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -452,11 +468,13 @@ def test_multiple_hesitations() -> None: def test_less_than_three_velocities() -> None: """Test early return when velocities length is less than 3.""" - points = pd.DataFrame({ - "x": [0, 1], - "y": [0, 0], - "seconds": [0, 1], - }) + points = pd.DataFrame( + { + "x": [0, 1], + "y": [0, 0], + "seconds": [0, 1], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -475,11 +493,13 @@ def test_less_than_three_velocities() -> None: def test_smoothness_less_than_three_points() -> None: """Less than 3 points cannot define curvature.""" - points = pd.DataFrame({ - "x": [0, 1], - "y": [0, 0], - "seconds": [0, 1], - }) + points = pd.DataFrame( + { + "x": [0, 1], + "y": [0, 0], + "seconds": [0, 1], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -569,10 +589,12 @@ def test_smoothness_single_180_degree_turn() -> None: Since it represents maximal curvature. """ - points = pd.DataFrame({ - "x": [0, 1, 0], - "y": [0, 0, 0], - }) + points = pd.DataFrame( + { + "x": [0, 1, 0], + "y": [0, 0, 0], + } + ) segment = models.LineSegment( start_label="1", end_label="2", diff --git a/tests/unit/test_trails_drawing_metrics.py b/tests/unit/test_trails_drawing_metrics.py index b8be4dd..34956d8 100644 --- a/tests/unit/test_trails_drawing_metrics.py +++ b/tests/unit/test_trails_drawing_metrics.py @@ -11,10 +11,12 @@ def test_get_total_errors() -> None: """Test ValueError when total_number_of_errors column doesn't exist.""" - invalid_df = pd.DataFrame({ - "some_other_column": [0, 1, 2], - "seconds": [0.0, 1.0, 2.0], - }) + invalid_df = pd.DataFrame( + { + "some_other_column": [0, 1, 2], + "seconds": [0.0, 1.0, 2.0], + } + ) drawing = models.Drawing( data=invalid_df, task_name="trails", metadata={"id": "5555555"} ) @@ -37,10 +39,12 @@ def test_valid_total_errors() -> None: def test_percent_accurate_paths_missing_columns() -> None: """Test ValueError when required columns are missing.""" - invalid_df = pd.DataFrame({ - "some_other_column": [0, 1, 2], - "seconds": [0.0, 1.0, 2.0], - }) + invalid_df = pd.DataFrame( + { + "some_other_column": [0, 1, 2], + "seconds": [0.0, 1.0, 2.0], + } + ) drawing = models.Drawing( data=invalid_df, task_name="trails", metadata={"id": "5555555"} ) From 9d4e4627a372691710e662e44782909434272a25 Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Wed, 11 Feb 2026 11:18:26 -0500 Subject: [PATCH 23/42] ruff remove unused imports --- src/graphomotor/features/trails/drawing_metrics.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/graphomotor/features/trails/drawing_metrics.py b/src/graphomotor/features/trails/drawing_metrics.py index e887fa0..f1b3bae 100644 --- a/src/graphomotor/features/trails/drawing_metrics.py +++ b/src/graphomotor/features/trails/drawing_metrics.py @@ -1,8 +1,5 @@ """Feature extraction module for drawing error-based metrics in trails drawing data.""" -import numpy as np -import pandas as pd - from graphomotor.core import models From abf56bd63ca57ab0f05a09a2760f842ac17a5468 Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Wed, 11 Feb 2026 11:19:33 -0500 Subject: [PATCH 24/42] ruff remove unused imports --- tests/unit/test_trails_drawing_metrics.py | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/tests/unit/test_trails_drawing_metrics.py b/tests/unit/test_trails_drawing_metrics.py index 34956d8..8f37dea 100644 --- a/tests/unit/test_trails_drawing_metrics.py +++ b/tests/unit/test_trails_drawing_metrics.py @@ -1,6 +1,5 @@ """Test cases for drawing_metrics.py functions.""" -import numpy as np import pandas as pd import pytest @@ -11,12 +10,10 @@ def test_get_total_errors() -> None: """Test ValueError when total_number_of_errors column doesn't exist.""" - invalid_df = pd.DataFrame( - { - "some_other_column": [0, 1, 2], - "seconds": [0.0, 1.0, 2.0], - } - ) + invalid_df = pd.DataFrame({ + "some_other_column": [0, 1, 2], + "seconds": [0.0, 1.0, 2.0], + }) drawing = models.Drawing( data=invalid_df, task_name="trails", metadata={"id": "5555555"} ) @@ -39,12 +36,10 @@ def test_valid_total_errors() -> None: def test_percent_accurate_paths_missing_columns() -> None: """Test ValueError when required columns are missing.""" - invalid_df = pd.DataFrame( - { - "some_other_column": [0, 1, 2], - "seconds": [0.0, 1.0, 2.0], - } - ) + invalid_df = pd.DataFrame({ + "some_other_column": [0, 1, 2], + "seconds": [0.0, 1.0, 2.0], + }) drawing = models.Drawing( data=invalid_df, task_name="trails", metadata={"id": "5555555"} ) From 3c0dea474b5aa0644e656a2f6fd87744044d9999 Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Wed, 11 Feb 2026 11:27:45 -0500 Subject: [PATCH 25/42] ruff reformat and rename variables --- src/graphomotor/core/models.py | 10 +++++----- tests/unit/test_trails_drawing_metrics.py | 20 ++++++++++++-------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/src/graphomotor/core/models.py b/src/graphomotor/core/models.py index b0ffe45..9cd0eea 100644 --- a/src/graphomotor/core/models.py +++ b/src/graphomotor/core/models.py @@ -357,7 +357,7 @@ def calculate_smoothness(self) -> None: sampling-rate dependence. """ if len(self.ink_points) < 3: - return 0.0 + return xy = self.ink_points[["x", "y"]].to_numpy() @@ -406,17 +406,17 @@ def compute_segment_metrics( CircleTarget instances (output of load_scaled_circles in config). trail_id: Trail identifier for circle lookup. """ - circles = circles[trail_id] + trail_circles = circles[trail_id] points = self.points.copy() if len(points) < 2: return - if self.start_label not in circles or self.end_label not in circles: + if self.start_label not in trail_circles or self.end_label not in trail_circles: return - start_circle = circles[self.start_label] - end_circle = circles[self.end_label] + start_circle = trail_circles[self.start_label] + end_circle = trail_circles[self.end_label] ink_start_idx, ink_end_idx = self.valid_ink_trajectory(start_circle, end_circle) diff --git a/tests/unit/test_trails_drawing_metrics.py b/tests/unit/test_trails_drawing_metrics.py index 8f37dea..f78cf69 100644 --- a/tests/unit/test_trails_drawing_metrics.py +++ b/tests/unit/test_trails_drawing_metrics.py @@ -10,10 +10,12 @@ def test_get_total_errors() -> None: """Test ValueError when total_number_of_errors column doesn't exist.""" - invalid_df = pd.DataFrame({ - "some_other_column": [0, 1, 2], - "seconds": [0.0, 1.0, 2.0], - }) + invalid_df = pd.DataFrame( + { + "some_other_column": [0, 1, 2], + "seconds": [0.0, 1.0, 2.0], + } + ) drawing = models.Drawing( data=invalid_df, task_name="trails", metadata={"id": "5555555"} ) @@ -36,10 +38,12 @@ def test_valid_total_errors() -> None: def test_percent_accurate_paths_missing_columns() -> None: """Test ValueError when required columns are missing.""" - invalid_df = pd.DataFrame({ - "some_other_column": [0, 1, 2], - "seconds": [0.0, 1.0, 2.0], - }) + invalid_df = pd.DataFrame( + { + "some_other_column": [0, 1, 2], + "seconds": [0.0, 1.0, 2.0], + } + ) drawing = models.Drawing( data=invalid_df, task_name="trails", metadata={"id": "5555555"} ) From fa2a84c492e21a9b86983545a653533c4c3c1dad Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Wed, 11 Feb 2026 11:43:59 -0500 Subject: [PATCH 26/42] unit tests for compute_segment_metrics --- tests/unit/test_models.py | 397 ++++++++++++++++++++++++++++++-------- 1 file changed, 321 insertions(+), 76 deletions(-) diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index 0fc86d6..d884be0 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -243,13 +243,11 @@ def test_valid_ink_trajectory( def test_uniform_motion() -> None: """Test with points moving at constant velocity.""" - points = pd.DataFrame( - { - "x": [0, 1, 2, 3], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - } - ) + points = pd.DataFrame({ + "x": [0, 1, 2, 3], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -272,13 +270,11 @@ def test_uniform_motion() -> None: def test_accelerating_motion() -> None: """Test with motion accelerating over time.""" - points = pd.DataFrame( - { - "x": [0, 1, 4, 9], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - } - ) + points = pd.DataFrame({ + "x": [0, 1, 4, 9], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -304,13 +300,11 @@ def test_accelerating_motion() -> None: def test_velocity_two_points_only() -> None: """Test velocity calculation with only two points.""" - points = pd.DataFrame( - { - "x": [0, 3], - "y": [0, 4], - "seconds": [0, 2], - } - ) + points = pd.DataFrame({ + "x": [0, 3], + "y": [0, 4], + "seconds": [0, 2], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -332,13 +326,11 @@ def test_velocity_two_points_only() -> None: def test_decelerating_motion() -> None: """Test with decelerating motion (negative acceleration).""" - points = pd.DataFrame( - { - "x": [0, 4, 7, 9], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - } - ) + points = pd.DataFrame({ + "x": [0, 4, 7, 9], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -364,13 +356,11 @@ def test_decelerating_motion() -> None: def test_stationary_motion() -> None: """Test with no movement (all points the same).""" - points = pd.DataFrame( - { - "x": [1, 1, 1], - "y": [1, 1, 1], - "seconds": [0, 1, 2], - } - ) + points = pd.DataFrame({ + "x": [1, 1, 1], + "y": [1, 1, 1], + "seconds": [0, 1, 2], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -393,13 +383,11 @@ def test_stationary_motion() -> None: def test_no_hesitations_uniform_motion() -> None: """Test with uniform motion where all velocities are equal.""" - points = pd.DataFrame( - { - "x": [0, 1, 2, 3], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - } - ) + points = pd.DataFrame({ + "x": [0, 1, 2, 3], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -418,13 +406,11 @@ def test_no_hesitations_uniform_motion() -> None: def test_hesitation_at_start() -> None: """Test when the line starts with a hesitation.""" - points = pd.DataFrame( - { - "x": [0, 0.1, 1, 2], - "y": [0, 0.1, 0, 0], - "seconds": [0, 1, 2, 3], - } - ) + points = pd.DataFrame({ + "x": [0, 0.1, 1, 2], + "y": [0, 0.1, 0, 0], + "seconds": [0, 1, 2, 3], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -443,13 +429,11 @@ def test_hesitation_at_start() -> None: def test_multiple_hesitations() -> None: """Test when there are multiple hesitation periods.""" - points = pd.DataFrame( - { - "x": [0, 100, 100.1, 200, 200.1, 300, 400, 500, 600], - "y": [0, 0, 0, 0, 0, 0, 0, 0, 0], - "seconds": [0, 1, 2, 3, 4, 5, 6, 7, 8], - } - ) + points = pd.DataFrame({ + "x": [0, 100, 100.1, 200, 200.1, 300, 400, 500, 600], + "y": [0, 0, 0, 0, 0, 0, 0, 0, 0], + "seconds": [0, 1, 2, 3, 4, 5, 6, 7, 8], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -468,13 +452,11 @@ def test_multiple_hesitations() -> None: def test_less_than_three_velocities() -> None: """Test early return when velocities length is less than 3.""" - points = pd.DataFrame( - { - "x": [0, 1], - "y": [0, 0], - "seconds": [0, 1], - } - ) + points = pd.DataFrame({ + "x": [0, 1], + "y": [0, 0], + "seconds": [0, 1], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -493,13 +475,11 @@ def test_less_than_three_velocities() -> None: def test_smoothness_less_than_three_points() -> None: """Less than 3 points cannot define curvature.""" - points = pd.DataFrame( - { - "x": [0, 1], - "y": [0, 0], - "seconds": [0, 1], - } - ) + points = pd.DataFrame({ + "x": [0, 1], + "y": [0, 0], + "seconds": [0, 1], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -589,12 +569,10 @@ def test_smoothness_single_180_degree_turn() -> None: Since it represents maximal curvature. """ - points = pd.DataFrame( - { - "x": [0, 1, 0], - "y": [0, 0, 0], - } - ) + points = pd.DataFrame({ + "x": [0, 1, 0], + "y": [0, 0, 0], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -606,3 +584,270 @@ def test_smoothness_single_180_degree_turn() -> None: expected = np.pi segment.calculate_smoothness() assert np.isclose(segment.smoothness, expected) + + +def test_compute_segment_metrics_less_than_two_points() -> None: + """Test early return when segment has less than 2 points.""" + points = pd.DataFrame({ + "x": [0], + "y": [0], + "seconds": [0], + }) + segment = models.LineSegment( + start_label="1", + end_label="2", + points=points, + is_error=False, + line_number=1, + ) + circles = { + "A": { + "1": models.CircleTarget( + order=1, label="1", center_x=0, center_y=0, radius=10 + ), + "2": models.CircleTarget( + order=2, label="2", center_x=100, center_y=0, radius=10 + ), + } + } + + segment.compute_segment_metrics(circles=circles, trail_id="A") + + assert segment.ink_time == 0.0 + assert segment.distance == 0.0 + assert len(segment.velocities) == 0 + + +def test_compute_segment_metrics_invalid_start_label() -> None: + """Test early return when start_label not in trail circles.""" + points = pd.DataFrame({ + "x": [0, 50, 100], + "y": [0, 0, 0], + "seconds": [0, 1, 2], + }) + segment = models.LineSegment( + start_label="999", + end_label="2", + points=points, + is_error=False, + line_number=1, + ) + + circles = { + "A": { + "1": models.CircleTarget( + order=1, label="1", center_x=0, center_y=0, radius=10 + ), + "2": models.CircleTarget( + order=2, label="2", center_x=100, center_y=0, radius=10 + ), + } + } + + segment.compute_segment_metrics(circles=circles, trail_id="A") + + assert segment.ink_time == 0.0 + assert segment.distance == 0.0 + assert len(segment.velocities) == 0 + + +def test_compute_segment_metrics_invalid_end_label() -> None: + """Test early return when end_label not in trail circles.""" + points = pd.DataFrame({ + "x": [0, 50, 100], + "y": [0, 0, 0], + "seconds": [0, 1, 2], + }) + segment = models.LineSegment( + start_label="1", + end_label="999", + points=points, + is_error=False, + line_number=1, + ) + + circles = { + "A": { + "1": models.CircleTarget( + order=1, label="1", center_x=0, center_y=0, radius=10 + ), + "2": models.CircleTarget( + order=2, label="2", center_x=100, center_y=0, radius=10 + ), + } + } + + segment.compute_segment_metrics(circles=circles, trail_id="A") + + assert segment.ink_time == 0.0 + assert segment.distance == 0.0 + assert len(segment.velocities) == 0 + + +def test_compute_segment_metrics_valid_trajectory() -> None: + """Test successful computation of all metrics with valid trajectory.""" + points = pd.DataFrame({ + "x": [0, 25, 50, 75, 100], + "y": [0, 0, 0, 0, 0], + "seconds": [0, 1, 2, 3, 4], + }) + segment = models.LineSegment( + start_label="1", + end_label="2", + points=points, + is_error=False, + line_number=1, + ) + segment.valid_ink_trajectory = lambda start, end: (0, 4) + circles = { + "A": { + "1": models.CircleTarget( + order=1, label="1", center_x=0, center_y=0, radius=10 + ), + "2": models.CircleTarget( + order=2, label="2", center_x=100, center_y=0, radius=10 + ), + } + } + + segment.compute_segment_metrics(circles=circles, trail_id="A") + + assert segment.ink_time == pytest.approx(4.0) + assert len(segment.ink_points) == 5 + assert segment.distance > 0.0 + assert segment.mean_speed > 0.0 + assert len(segment.velocities) > 0 + assert segment.path_optimality > 0.0 + assert segment.smoothness == pytest.approx(0.0) + + +def test_compute_segment_metrics_ink_end_equals_ink_start() -> None: + """Test when ink_end_idx equals ink_start_idx (no valid trajectory).""" + points = pd.DataFrame({ + "x": [0, 50, 100], + "y": [0, 0, 0], + "seconds": [0, 1, 2], + }) + segment = models.LineSegment( + start_label="1", + end_label="2", + points=points, + is_error=False, + line_number=1, + ) + segment.valid_ink_trajectory = lambda start, end: (1, 1) + circles = { + "A": { + "1": models.CircleTarget( + order=1, label="1", center_x=0, center_y=0, radius=10 + ), + "2": models.CircleTarget( + order=2, label="2", center_x=100, center_y=0, radius=10 + ), + } + } + + segment.compute_segment_metrics(circles=circles, trail_id="A") + + assert segment.distance == 0.0 + assert len(segment.velocities) == 0 + + +def test_compute_segment_metrics_ink_end_before_ink_start() -> None: + """Test when ink_end_idx is before ink_start_idx (invalid trajectory).""" + points = pd.DataFrame({ + "x": [0, 50, 100], + "y": [0, 0, 0], + "seconds": [0, 1, 2], + }) + segment = models.LineSegment( + start_label="1", + end_label="2", + points=points, + is_error=False, + line_number=1, + ) + segment.valid_ink_trajectory = lambda start, end: (2, 0) + circles = { + "A": { + "1": models.CircleTarget( + order=1, label="1", center_x=0, center_y=0, radius=10 + ), + "2": models.CircleTarget( + order=2, label="2", center_x=100, center_y=0, radius=10 + ), + } + } + + segment.compute_segment_metrics(circles=circles, trail_id="A") + + assert segment.distance == 0.0 + assert len(segment.velocities) == 0 + + +def test_compute_segment_metrics_only_start_index_found() -> None: + """Test when only ink_start_idx is found (end is None).""" + points = pd.DataFrame({ + "x": [0, 25, 50, 75], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + }) + segment = models.LineSegment( + start_label="1", + end_label="2", + points=points, + is_error=False, + line_number=1, + ) + segment.valid_ink_trajectory = lambda start, end: (1, None) + circles = { + "A": { + "1": models.CircleTarget( + order=1, label="1", center_x=0, center_y=0, radius=10 + ), + "2": models.CircleTarget( + order=2, label="2", center_x=100, center_y=0, radius=10 + ), + } + } + + segment.compute_segment_metrics(circles=circles, trail_id="A") + + assert segment.ink_time == pytest.approx(2.0) + assert len(segment.ink_points) == 3 + assert segment.distance == 0.0 + assert len(segment.velocities) == 0 + + +def test_compute_segment_metrics_calls_all_metric_functions() -> None: + """Test that all metric calculation functions are called.""" + points = pd.DataFrame({ + "x": [0, 25, 50, 75, 100], + "y": [0, 0, 0, 0, 0], + "seconds": [0, 1, 2, 3, 4], + }) + segment = models.LineSegment( + start_label="1", + end_label="2", + points=points, + is_error=False, + line_number=1, + ) + segment.valid_ink_trajectory = lambda start, end: (0, 4) + circles = { + "A": { + "1": models.CircleTarget( + order=1, label="1", center_x=0, center_y=0, radius=10 + ), + "2": models.CircleTarget( + order=2, label="2", center_x=100, center_y=0, radius=10 + ), + } + } + + segment.compute_segment_metrics(circles=circles, trail_id="A") + + assert len(segment.velocities) > 0 + assert segment.path_optimality > 0.0 + assert segment.smoothness == pytest.approx(0.0) + assert segment.hesitation_count >= 0 From 8292a6cde3d2e67b3332373cd9f7908be883136f Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Wed, 11 Feb 2026 11:50:31 -0500 Subject: [PATCH 27/42] ruff reformat, mypy errors fixing --- tests/unit/test_models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index d884be0..e597dff 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -611,7 +611,7 @@ def test_compute_segment_metrics_less_than_two_points() -> None: } } - segment.compute_segment_metrics(circles=circles, trail_id="A") + segment.valid_ink_trajectory = lambda start, end: (0, 4) # type: ignore[method-assign] assert segment.ink_time == 0.0 assert segment.distance == 0.0 From dc4a43f55c69ab694eef705695af54f4f4ad0c14 Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Wed, 11 Feb 2026 11:52:55 -0500 Subject: [PATCH 28/42] trying to solve mypy error --- tests/unit/test_models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index e597dff..d884be0 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -611,7 +611,7 @@ def test_compute_segment_metrics_less_than_two_points() -> None: } } - segment.valid_ink_trajectory = lambda start, end: (0, 4) # type: ignore[method-assign] + segment.compute_segment_metrics(circles=circles, trail_id="A") assert segment.ink_time == 0.0 assert segment.distance == 0.0 From 8838cbc0a8ea572f49379c3b5d31e93bd5ba3140 Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Wed, 11 Feb 2026 12:00:34 -0500 Subject: [PATCH 29/42] ruff and mypy and unit errors --- tests/unit/test_models.py | 253 ++++++++++++++++++++++---------------- 1 file changed, 148 insertions(+), 105 deletions(-) diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index d884be0..820f664 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -2,6 +2,7 @@ import datetime from typing import Dict, cast +from unittest.mock import patch import numpy as np import pandas as pd @@ -243,11 +244,13 @@ def test_valid_ink_trajectory( def test_uniform_motion() -> None: """Test with points moving at constant velocity.""" - points = pd.DataFrame({ - "x": [0, 1, 2, 3], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - }) + points = pd.DataFrame( + { + "x": [0, 1, 2, 3], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -270,11 +273,13 @@ def test_uniform_motion() -> None: def test_accelerating_motion() -> None: """Test with motion accelerating over time.""" - points = pd.DataFrame({ - "x": [0, 1, 4, 9], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - }) + points = pd.DataFrame( + { + "x": [0, 1, 4, 9], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -300,11 +305,13 @@ def test_accelerating_motion() -> None: def test_velocity_two_points_only() -> None: """Test velocity calculation with only two points.""" - points = pd.DataFrame({ - "x": [0, 3], - "y": [0, 4], - "seconds": [0, 2], - }) + points = pd.DataFrame( + { + "x": [0, 3], + "y": [0, 4], + "seconds": [0, 2], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -326,11 +333,13 @@ def test_velocity_two_points_only() -> None: def test_decelerating_motion() -> None: """Test with decelerating motion (negative acceleration).""" - points = pd.DataFrame({ - "x": [0, 4, 7, 9], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - }) + points = pd.DataFrame( + { + "x": [0, 4, 7, 9], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -356,11 +365,13 @@ def test_decelerating_motion() -> None: def test_stationary_motion() -> None: """Test with no movement (all points the same).""" - points = pd.DataFrame({ - "x": [1, 1, 1], - "y": [1, 1, 1], - "seconds": [0, 1, 2], - }) + points = pd.DataFrame( + { + "x": [1, 1, 1], + "y": [1, 1, 1], + "seconds": [0, 1, 2], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -383,11 +394,13 @@ def test_stationary_motion() -> None: def test_no_hesitations_uniform_motion() -> None: """Test with uniform motion where all velocities are equal.""" - points = pd.DataFrame({ - "x": [0, 1, 2, 3], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - }) + points = pd.DataFrame( + { + "x": [0, 1, 2, 3], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -406,11 +419,13 @@ def test_no_hesitations_uniform_motion() -> None: def test_hesitation_at_start() -> None: """Test when the line starts with a hesitation.""" - points = pd.DataFrame({ - "x": [0, 0.1, 1, 2], - "y": [0, 0.1, 0, 0], - "seconds": [0, 1, 2, 3], - }) + points = pd.DataFrame( + { + "x": [0, 0.1, 1, 2], + "y": [0, 0.1, 0, 0], + "seconds": [0, 1, 2, 3], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -429,11 +444,13 @@ def test_hesitation_at_start() -> None: def test_multiple_hesitations() -> None: """Test when there are multiple hesitation periods.""" - points = pd.DataFrame({ - "x": [0, 100, 100.1, 200, 200.1, 300, 400, 500, 600], - "y": [0, 0, 0, 0, 0, 0, 0, 0, 0], - "seconds": [0, 1, 2, 3, 4, 5, 6, 7, 8], - }) + points = pd.DataFrame( + { + "x": [0, 100, 100.1, 200, 200.1, 300, 400, 500, 600], + "y": [0, 0, 0, 0, 0, 0, 0, 0, 0], + "seconds": [0, 1, 2, 3, 4, 5, 6, 7, 8], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -452,11 +469,13 @@ def test_multiple_hesitations() -> None: def test_less_than_three_velocities() -> None: """Test early return when velocities length is less than 3.""" - points = pd.DataFrame({ - "x": [0, 1], - "y": [0, 0], - "seconds": [0, 1], - }) + points = pd.DataFrame( + { + "x": [0, 1], + "y": [0, 0], + "seconds": [0, 1], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -475,11 +494,13 @@ def test_less_than_three_velocities() -> None: def test_smoothness_less_than_three_points() -> None: """Less than 3 points cannot define curvature.""" - points = pd.DataFrame({ - "x": [0, 1], - "y": [0, 0], - "seconds": [0, 1], - }) + points = pd.DataFrame( + { + "x": [0, 1], + "y": [0, 0], + "seconds": [0, 1], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -569,10 +590,12 @@ def test_smoothness_single_180_degree_turn() -> None: Since it represents maximal curvature. """ - points = pd.DataFrame({ - "x": [0, 1, 0], - "y": [0, 0, 0], - }) + points = pd.DataFrame( + { + "x": [0, 1, 0], + "y": [0, 0, 0], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -588,11 +611,13 @@ def test_smoothness_single_180_degree_turn() -> None: def test_compute_segment_metrics_less_than_two_points() -> None: """Test early return when segment has less than 2 points.""" - points = pd.DataFrame({ - "x": [0], - "y": [0], - "seconds": [0], - }) + points = pd.DataFrame( + { + "x": [0], + "y": [0], + "seconds": [0], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -620,11 +645,13 @@ def test_compute_segment_metrics_less_than_two_points() -> None: def test_compute_segment_metrics_invalid_start_label() -> None: """Test early return when start_label not in trail circles.""" - points = pd.DataFrame({ - "x": [0, 50, 100], - "y": [0, 0, 0], - "seconds": [0, 1, 2], - }) + points = pd.DataFrame( + { + "x": [0, 50, 100], + "y": [0, 0, 0], + "seconds": [0, 1, 2], + } + ) segment = models.LineSegment( start_label="999", end_label="2", @@ -653,11 +680,13 @@ def test_compute_segment_metrics_invalid_start_label() -> None: def test_compute_segment_metrics_invalid_end_label() -> None: """Test early return when end_label not in trail circles.""" - points = pd.DataFrame({ - "x": [0, 50, 100], - "y": [0, 0, 0], - "seconds": [0, 1, 2], - }) + points = pd.DataFrame( + { + "x": [0, 50, 100], + "y": [0, 0, 0], + "seconds": [0, 1, 2], + } + ) segment = models.LineSegment( start_label="1", end_label="999", @@ -665,7 +694,6 @@ def test_compute_segment_metrics_invalid_end_label() -> None: is_error=False, line_number=1, ) - circles = { "A": { "1": models.CircleTarget( @@ -686,11 +714,13 @@ def test_compute_segment_metrics_invalid_end_label() -> None: def test_compute_segment_metrics_valid_trajectory() -> None: """Test successful computation of all metrics with valid trajectory.""" - points = pd.DataFrame({ - "x": [0, 25, 50, 75, 100], - "y": [0, 0, 0, 0, 0], - "seconds": [0, 1, 2, 3, 4], - }) + points = pd.DataFrame( + { + "x": [0, 25, 50, 75, 100], + "y": [0, 0, 0, 0, 0], + "seconds": [0, 1, 2, 3, 4], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -698,7 +728,7 @@ def test_compute_segment_metrics_valid_trajectory() -> None: is_error=False, line_number=1, ) - segment.valid_ink_trajectory = lambda start, end: (0, 4) + circles = { "A": { "1": models.CircleTarget( @@ -710,7 +740,8 @@ def test_compute_segment_metrics_valid_trajectory() -> None: } } - segment.compute_segment_metrics(circles=circles, trail_id="A") + with patch.object(segment, "valid_ink_trajectory", return_value=(0, 4)): + segment.compute_segment_metrics(circles=circles, trail_id="A") assert segment.ink_time == pytest.approx(4.0) assert len(segment.ink_points) == 5 @@ -723,11 +754,13 @@ def test_compute_segment_metrics_valid_trajectory() -> None: def test_compute_segment_metrics_ink_end_equals_ink_start() -> None: """Test when ink_end_idx equals ink_start_idx (no valid trajectory).""" - points = pd.DataFrame({ - "x": [0, 50, 100], - "y": [0, 0, 0], - "seconds": [0, 1, 2], - }) + points = pd.DataFrame( + { + "x": [0, 50, 100], + "y": [0, 0, 0], + "seconds": [0, 1, 2], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -735,7 +768,7 @@ def test_compute_segment_metrics_ink_end_equals_ink_start() -> None: is_error=False, line_number=1, ) - segment.valid_ink_trajectory = lambda start, end: (1, 1) + circles = { "A": { "1": models.CircleTarget( @@ -747,7 +780,8 @@ def test_compute_segment_metrics_ink_end_equals_ink_start() -> None: } } - segment.compute_segment_metrics(circles=circles, trail_id="A") + with patch.object(segment, "valid_ink_trajectory", return_value=(1, 1)): + segment.compute_segment_metrics(circles=circles, trail_id="A") assert segment.distance == 0.0 assert len(segment.velocities) == 0 @@ -755,11 +789,13 @@ def test_compute_segment_metrics_ink_end_equals_ink_start() -> None: def test_compute_segment_metrics_ink_end_before_ink_start() -> None: """Test when ink_end_idx is before ink_start_idx (invalid trajectory).""" - points = pd.DataFrame({ - "x": [0, 50, 100], - "y": [0, 0, 0], - "seconds": [0, 1, 2], - }) + points = pd.DataFrame( + { + "x": [0, 50, 100], + "y": [0, 0, 0], + "seconds": [0, 1, 2], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -767,7 +803,7 @@ def test_compute_segment_metrics_ink_end_before_ink_start() -> None: is_error=False, line_number=1, ) - segment.valid_ink_trajectory = lambda start, end: (2, 0) + circles = { "A": { "1": models.CircleTarget( @@ -779,7 +815,8 @@ def test_compute_segment_metrics_ink_end_before_ink_start() -> None: } } - segment.compute_segment_metrics(circles=circles, trail_id="A") + with patch.object(segment, "valid_ink_trajectory", return_value=(2, 0)): + segment.compute_segment_metrics(circles=circles, trail_id="A") assert segment.distance == 0.0 assert len(segment.velocities) == 0 @@ -787,11 +824,13 @@ def test_compute_segment_metrics_ink_end_before_ink_start() -> None: def test_compute_segment_metrics_only_start_index_found() -> None: """Test when only ink_start_idx is found (end is None).""" - points = pd.DataFrame({ - "x": [0, 25, 50, 75], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - }) + points = pd.DataFrame( + { + "x": [0, 25, 50, 75], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -799,7 +838,7 @@ def test_compute_segment_metrics_only_start_index_found() -> None: is_error=False, line_number=1, ) - segment.valid_ink_trajectory = lambda start, end: (1, None) + circles = { "A": { "1": models.CircleTarget( @@ -811,7 +850,8 @@ def test_compute_segment_metrics_only_start_index_found() -> None: } } - segment.compute_segment_metrics(circles=circles, trail_id="A") + with patch.object(segment, "valid_ink_trajectory", return_value=(1, None)): + segment.compute_segment_metrics(circles=circles, trail_id="A") assert segment.ink_time == pytest.approx(2.0) assert len(segment.ink_points) == 3 @@ -821,11 +861,13 @@ def test_compute_segment_metrics_only_start_index_found() -> None: def test_compute_segment_metrics_calls_all_metric_functions() -> None: """Test that all metric calculation functions are called.""" - points = pd.DataFrame({ - "x": [0, 25, 50, 75, 100], - "y": [0, 0, 0, 0, 0], - "seconds": [0, 1, 2, 3, 4], - }) + points = pd.DataFrame( + { + "x": [0, 25, 50, 75, 100], + "y": [0, 0, 0, 0, 0], + "seconds": [0, 1, 2, 3, 4], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -833,7 +875,7 @@ def test_compute_segment_metrics_calls_all_metric_functions() -> None: is_error=False, line_number=1, ) - segment.valid_ink_trajectory = lambda start, end: (0, 4) + circles = { "A": { "1": models.CircleTarget( @@ -845,7 +887,8 @@ def test_compute_segment_metrics_calls_all_metric_functions() -> None: } } - segment.compute_segment_metrics(circles=circles, trail_id="A") + with patch.object(segment, "valid_ink_trajectory", return_value=(0, 4)): + segment.compute_segment_metrics(circles=circles, trail_id="A") assert len(segment.velocities) > 0 assert segment.path_optimality > 0.0 From ecd4ea102f3e3092f5e01f4490386af5eca1fa3d Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Mon, 16 Feb 2026 10:11:10 -0500 Subject: [PATCH 30/42] all my functions disappeared during merge from main so added back in functions and fixed tests --- src/graphomotor/core/models.py | 152 ++++++++++++++++++- tests/unit/test_models.py | 265 ++++++++++++++------------------- 2 files changed, 255 insertions(+), 162 deletions(-) diff --git a/src/graphomotor/core/models.py b/src/graphomotor/core/models.py index 1ce902a..1d7cac0 100644 --- a/src/graphomotor/core/models.py +++ b/src/graphomotor/core/models.py @@ -285,16 +285,16 @@ def calculate_path_optimality( self.path_optimality = optimal_distance / self.distance return - def calculate_velocity_metrics(self, ink_points: pd.DataFrame) -> None: - """Get distance, velocity, and acceleration metrics of a LineSegment. + def calculate_velocity_metrics(self) -> None: + """Get velocity metrics of a LineSegment. Args: self: LineSegment object to calculate velocities for. - ink_points: DataFrame of ink points with 'x', 'y', and 'seconds' columns. + ink_points: DataFrame containing the ink points for the line segment. """ - dx = np.diff(ink_points["x"].values) - dy = np.diff(ink_points["y"].values) - dt = np.diff(ink_points["seconds"].values) + dx = np.diff(self.ink_points["x"].values) + dy = np.diff(self.ink_points["y"].values) + dt = np.diff(self.ink_points["seconds"].values) distances = np.sqrt(dx**2 + dy**2) self.distance = np.sum(distances) @@ -309,3 +309,143 @@ def calculate_velocity_metrics(self, ink_points: pd.DataFrame) -> None: self.accelerations = np.diff(velocities).tolist() return + + def detect_hesitations(self, threshold_percentile: int = 20) -> None: + """Detect hesitations as periods of significantly reduced velocity. + + This function defines a hesitation as any period where the velocity falls below + a certain threshold, which is determined by the specified percentile of the + velocity distribution. It counts the number of distinct hesitation periods and + adds 1 if the line starts with a hesitation. It also calculates the total + duration of hesitations based on the number of points that fall below the + threshold and the time interval between points. + + Args: + ink_points: DataFrame containing the ink points for the line segment. + threshold_percentile: Percentile to determine the velocity threshold for + hesitations (default is 20, meaning the bottom 20% of velocities are + considered hesitations). + """ + if len(self.velocities) < 3: + return + + dt = np.diff(self.ink_points["seconds"].values) + + threshold = np.percentile(self.velocities, threshold_percentile) + hesitations = self.velocities < threshold + + hesitation_changes = np.diff(hesitations.astype(int)) + hesitation_starts = np.where(hesitation_changes == 1)[0] + 1 + hesitation_count = len(hesitation_starts) + + if hesitations[0]: + hesitation_count += 1 + + hesitation_duration = np.sum(hesitations) * dt[0] + + self.hesitation_count = hesitation_count + self.hesitation_duration = hesitation_duration + + return + + def calculate_smoothness(self) -> None: + """Calculate path smoothness based on Root Mean Square (RMS) curvature. + + Represents the curvature per unit arc length. + Lower values indicate smoother drawings. Penalizes sharp corners (e.g., + 90° turns) and noisy corrections. Normalized by arc length to reduce + sampling-rate dependence. + """ + if len(self.ink_points) < 3: + return 0.0 + return + + xy = self.ink_points[["x", "y"]].to_numpy() + + forward_vector = xy[1:-1] - xy[:-2] + backward_vector = xy[2:] - xy[1:-1] + + forward_norm = np.linalg.norm(forward_vector, axis=1) + backward_norm = np.linalg.norm(backward_vector, axis=1) + + valid = (forward_norm > 0) & (backward_norm > 0) + if not np.any(valid): + return + + valid_forward_vector = forward_vector[valid] + valid_backward_vector = backward_vector[valid] + valid_forward_norm = forward_norm[valid] + valid_backward_norm = backward_norm[valid] + + cos_angle = (valid_forward_vector * valid_backward_vector).sum(axis=1) / ( + valid_forward_norm * valid_backward_norm + ) + cos_angle = np.clip(cos_angle, -1.0, 1.0) + + angles = np.arccos(cos_angle) + + avg_segment_length = (valid_forward_norm + valid_backward_norm) / 2.0 + curvatures = angles / avg_segment_length + + self.smoothness = float(np.sqrt(np.mean(curvatures**2))) + + return + + def compute_segment_metrics( + self, circles: dict[str, dict[str, CircleTarget]], trail_id: str + ) -> None: + """Compute all metrics for a line segment. + + This function computes various metrics for the line segment, including ink time, + velocity metrics, path optimality, smoothness, and hesitation detection. It + first determines the valid ink trajectory between the start and end circles. If + a valid trajectory is found, it updates the ink_points attribute and calculates + the metrics. + + Args: + circles: A dictionary mapping each trail type to dictionaries of + CircleTarget instances (output of load_scaled_circles in config). + trail_id: Trail identifier for circle lookup. + """ + trail_circles = circles[trail_id] + points = self.points.copy() + + if len(points) < 2: + return + + if self.start_label not in trail_circles or self.end_label not in trail_circles: + return + + start_circle = trail_circles[self.start_label] + end_circle = trail_circles[self.end_label] + + ink_start_idx, ink_end_idx = self.valid_ink_trajectory(start_circle, end_circle) + + if ( + ink_start_idx is not None + and ink_end_idx is not None + and ink_end_idx > ink_start_idx + ): + self.ink_points = self.points.iloc[ink_start_idx : ink_end_idx + 1].copy() + + if len(self.ink_points) >= 2: + ink_start = self.ink_points.iloc[0]["seconds"] + ink_end = self.ink_points.iloc[-1]["seconds"] + self.ink_time = ink_end - ink_start + + self.calculate_velocity_metrics() + + self.calculate_path_optimality(start_circle, end_circle) + + self.calculate_smoothness() + + self.detect_hesitations() + + elif ink_start_idx is not None: + self.ink_points = points.iloc[ink_start_idx:].copy() + if len(self.ink_points) >= 2: + self.ink_time = ( + self.ink_points.iloc[-1]["seconds"] + - self.ink_points.iloc[0]["seconds"] + ) + return diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index aac561d..d65bae2 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -244,22 +244,21 @@ def test_valid_ink_trajectory( def test_uniform_motion() -> None: """Test with points moving at constant velocity.""" - points = pd.DataFrame( - { - "x": [0, 1, 2, 3], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - } - ) + points = pd.DataFrame({ + "x": [0, 1, 2, 3], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + }) segment = models.LineSegment( start_label="1", end_label="2", points=points, is_error=False, line_number=1, + ink_points=points, # Pre-assign ink_points for velocity calculation ) - segment.calculate_velocity_metrics(points) + segment.calculate_velocity_metrics() assert segment.distance == 3.0 assert segment.mean_speed == 1.0 @@ -270,13 +269,11 @@ def test_uniform_motion() -> None: def test_accelerating_motion() -> None: """Test with motion accelerating over time.""" - points = pd.DataFrame( - { - "x": [0, 1, 4, 9], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - } - ) + points = pd.DataFrame({ + "x": [0, 1, 4, 9], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -297,13 +294,11 @@ def test_accelerating_motion() -> None: def test_velocity_two_points_only() -> None: """Test velocity calculation with only two points.""" - points = pd.DataFrame( - { - "x": [0, 3], - "y": [0, 4], - "seconds": [0, 2], - } - ) + points = pd.DataFrame({ + "x": [0, 3], + "y": [0, 4], + "seconds": [0, 2], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -324,13 +319,11 @@ def test_velocity_two_points_only() -> None: def test_decelerating_motion() -> None: """Test with decelerating motion (negative acceleration).""" - points = pd.DataFrame( - { - "x": [0, 4, 7, 9], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - } - ) + points = pd.DataFrame({ + "x": [0, 4, 7, 9], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -351,13 +344,11 @@ def test_decelerating_motion() -> None: def test_stationary_motion() -> None: """Test with no movement (all points the same).""" - points = pd.DataFrame( - { - "x": [1, 1, 1], - "y": [1, 1, 1], - "seconds": [0, 1, 2], - } - ) + points = pd.DataFrame({ + "x": [1, 1, 1], + "y": [1, 1, 1], + "seconds": [0, 1, 2], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -369,24 +360,20 @@ def test_stationary_motion() -> None: segment.calculate_velocity_metrics() - assert segment.distance == pytest.approx(0.0) - assert segment.mean_speed == pytest.approx(0.0) - assert segment.speed_variance == pytest.approx(0.0) - assert len(segment.velocities) == 2 - assert all(v == pytest.approx(0.0) for v in segment.velocities) - assert len(segment.accelerations) == 1 - assert segment.accelerations[0] == pytest.approx(0.0) + assert segment.distance == 0.0 + assert segment.mean_speed == 0.0 + assert segment.speed_variance == 0.0 + assert segment.velocities == [0.0, 0.0] + assert segment.accelerations == [0.0] def test_no_hesitations_uniform_motion() -> None: """Test with uniform motion where all velocities are equal.""" - points = pd.DataFrame( - { - "x": [0, 1, 2, 3], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - } - ) + points = pd.DataFrame({ + "x": [0, 1, 2, 3], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -400,18 +387,16 @@ def test_no_hesitations_uniform_motion() -> None: segment.detect_hesitations() assert segment.hesitation_count == 0 - assert segment.hesitation_duration == pytest.approx(0.0) + assert segment.hesitation_duration == 0.0 def test_hesitation_at_start() -> None: """Test when the line starts with a hesitation.""" - points = pd.DataFrame( - { - "x": [0, 0.1, 1, 2], - "y": [0, 0.1, 0, 0], - "seconds": [0, 1, 2, 3], - } - ) + points = pd.DataFrame({ + "x": [0, 0.1, 1, 2], + "y": [0, 0.1, 0, 0], + "seconds": [0, 1, 2, 3], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -425,18 +410,16 @@ def test_hesitation_at_start() -> None: segment.detect_hesitations() assert segment.hesitation_count == 1 - assert segment.hesitation_duration == pytest.approx(1.0) + assert segment.hesitation_duration == 1.0 def test_multiple_hesitations() -> None: """Test when there are multiple hesitation periods.""" - points = pd.DataFrame( - { - "x": [0, 100, 100.1, 200, 200.1, 300, 400, 500, 600], - "y": [0, 0, 0, 0, 0, 0, 0, 0, 0], - "seconds": [0, 1, 2, 3, 4, 5, 6, 7, 8], - } - ) + points = pd.DataFrame({ + "x": [0, 100, 100.1, 200, 200.1, 300, 400, 500, 600], + "y": [0, 0, 0, 0, 0, 0, 0, 0, 0], + "seconds": [0, 1, 2, 3, 4, 5, 6, 7, 8], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -450,18 +433,16 @@ def test_multiple_hesitations() -> None: segment.detect_hesitations() assert segment.hesitation_count == 2 - assert segment.hesitation_duration == pytest.approx(2.0) + assert segment.hesitation_duration == 2.0 def test_less_than_three_velocities() -> None: """Test early return when velocities length is less than 3.""" - points = pd.DataFrame( - { - "x": [0, 1], - "y": [0, 0], - "seconds": [0, 1], - } - ) + points = pd.DataFrame({ + "x": [0, 1], + "y": [0, 0], + "seconds": [0, 1], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -475,18 +456,16 @@ def test_less_than_three_velocities() -> None: segment.detect_hesitations() assert segment.hesitation_count == 0 - assert segment.hesitation_duration == pytest.approx(0.0) + assert segment.hesitation_duration == 0.0 def test_smoothness_less_than_three_points() -> None: """Less than 3 points cannot define curvature.""" - points = pd.DataFrame( - { - "x": [0, 1], - "y": [0, 0], - "seconds": [0, 1], - } - ) + points = pd.DataFrame({ + "x": [0, 1], + "y": [0, 0], + "seconds": [0, 1], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -576,12 +555,10 @@ def test_smoothness_single_180_degree_turn() -> None: Since it represents maximal curvature. """ - points = pd.DataFrame( - { - "x": [0, 1, 0], - "y": [0, 0, 0], - } - ) + points = pd.DataFrame({ + "x": [0, 1, 0], + "y": [0, 0, 0], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -597,13 +574,11 @@ def test_smoothness_single_180_degree_turn() -> None: def test_compute_segment_metrics_less_than_two_points() -> None: """Test early return when segment has less than 2 points.""" - points = pd.DataFrame( - { - "x": [0], - "y": [0], - "seconds": [0], - } - ) + points = pd.DataFrame({ + "x": [0], + "y": [0], + "seconds": [0], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -631,13 +606,11 @@ def test_compute_segment_metrics_less_than_two_points() -> None: def test_compute_segment_metrics_invalid_start_label() -> None: """Test early return when start_label not in trail circles.""" - points = pd.DataFrame( - { - "x": [0, 50, 100], - "y": [0, 0, 0], - "seconds": [0, 1, 2], - } - ) + points = pd.DataFrame({ + "x": [0, 50, 100], + "y": [0, 0, 0], + "seconds": [0, 1, 2], + }) segment = models.LineSegment( start_label="999", end_label="2", @@ -666,13 +639,11 @@ def test_compute_segment_metrics_invalid_start_label() -> None: def test_compute_segment_metrics_invalid_end_label() -> None: """Test early return when end_label not in trail circles.""" - points = pd.DataFrame( - { - "x": [0, 50, 100], - "y": [0, 0, 0], - "seconds": [0, 1, 2], - } - ) + points = pd.DataFrame({ + "x": [0, 50, 100], + "y": [0, 0, 0], + "seconds": [0, 1, 2], + }) segment = models.LineSegment( start_label="1", end_label="999", @@ -700,13 +671,11 @@ def test_compute_segment_metrics_invalid_end_label() -> None: def test_compute_segment_metrics_valid_trajectory() -> None: """Test successful computation of all metrics with valid trajectory.""" - points = pd.DataFrame( - { - "x": [0, 25, 50, 75, 100], - "y": [0, 0, 0, 0, 0], - "seconds": [0, 1, 2, 3, 4], - } - ) + points = pd.DataFrame({ + "x": [0, 25, 50, 75, 100], + "y": [0, 0, 0, 0, 0], + "seconds": [0, 1, 2, 3, 4], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -740,13 +709,11 @@ def test_compute_segment_metrics_valid_trajectory() -> None: def test_compute_segment_metrics_ink_end_equals_ink_start() -> None: """Test when ink_end_idx equals ink_start_idx (no valid trajectory).""" - points = pd.DataFrame( - { - "x": [0, 50, 100], - "y": [0, 0, 0], - "seconds": [0, 1, 2], - } - ) + points = pd.DataFrame({ + "x": [0, 50, 100], + "y": [0, 0, 0], + "seconds": [0, 1, 2], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -775,13 +742,11 @@ def test_compute_segment_metrics_ink_end_equals_ink_start() -> None: def test_compute_segment_metrics_ink_end_before_ink_start() -> None: """Test when ink_end_idx is before ink_start_idx (invalid trajectory).""" - points = pd.DataFrame( - { - "x": [0, 50, 100], - "y": [0, 0, 0], - "seconds": [0, 1, 2], - } - ) + points = pd.DataFrame({ + "x": [0, 50, 100], + "y": [0, 0, 0], + "seconds": [0, 1, 2], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -810,13 +775,11 @@ def test_compute_segment_metrics_ink_end_before_ink_start() -> None: def test_compute_segment_metrics_only_start_index_found() -> None: """Test when only ink_start_idx is found (end is None).""" - points = pd.DataFrame( - { - "x": [0, 25, 50, 75], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - } - ) + points = pd.DataFrame({ + "x": [0, 25, 50, 75], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -847,13 +810,11 @@ def test_compute_segment_metrics_only_start_index_found() -> None: def test_compute_segment_metrics_calls_all_metric_functions() -> None: """Test that all metric calculation functions are called.""" - points = pd.DataFrame( - { - "x": [0, 25, 50, 75, 100], - "y": [0, 0, 0, 0, 0], - "seconds": [0, 1, 2, 3, 4], - } - ) + points = pd.DataFrame({ + "x": [0, 25, 50, 75, 100], + "y": [0, 0, 0, 0, 0], + "seconds": [0, 1, 2, 3, 4], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -876,16 +837,8 @@ def test_compute_segment_metrics_calls_all_metric_functions() -> None: with patch.object(segment, "valid_ink_trajectory", return_value=(0, 4)): segment.compute_segment_metrics(circles=circles, trail_id="A") - assert len(segment.velocities) > 0 - assert segment.path_optimality > 0.0 - assert segment.smoothness == pytest.approx(0.0) - assert segment.hesitation_count >= 0 - ) - - segment.calculate_velocity_metrics(points) - - assert segment.distance == 0.0 - assert segment.mean_speed == 0.0 + assert segment.distance == 100.0 + assert segment.mean_speed == 25.0 assert segment.speed_variance == 0.0 - assert segment.velocities == [0.0, 0.0] - assert segment.accelerations == [0.0] + assert segment.velocities == [25.0, 25.0, 25.0, 25.0] + assert segment.accelerations == [0.0, 0.0, 0.0] From d33c221dc4375bc7c9184c3386129c6d49ae562a Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Mon, 16 Feb 2026 10:11:47 -0500 Subject: [PATCH 31/42] ruff format --- tests/unit/test_models.py | 226 ++++++++++++++++++++++---------------- 1 file changed, 132 insertions(+), 94 deletions(-) diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index d65bae2..d097331 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -244,11 +244,13 @@ def test_valid_ink_trajectory( def test_uniform_motion() -> None: """Test with points moving at constant velocity.""" - points = pd.DataFrame({ - "x": [0, 1, 2, 3], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - }) + points = pd.DataFrame( + { + "x": [0, 1, 2, 3], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -269,11 +271,13 @@ def test_uniform_motion() -> None: def test_accelerating_motion() -> None: """Test with motion accelerating over time.""" - points = pd.DataFrame({ - "x": [0, 1, 4, 9], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - }) + points = pd.DataFrame( + { + "x": [0, 1, 4, 9], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -294,11 +298,13 @@ def test_accelerating_motion() -> None: def test_velocity_two_points_only() -> None: """Test velocity calculation with only two points.""" - points = pd.DataFrame({ - "x": [0, 3], - "y": [0, 4], - "seconds": [0, 2], - }) + points = pd.DataFrame( + { + "x": [0, 3], + "y": [0, 4], + "seconds": [0, 2], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -319,11 +325,13 @@ def test_velocity_two_points_only() -> None: def test_decelerating_motion() -> None: """Test with decelerating motion (negative acceleration).""" - points = pd.DataFrame({ - "x": [0, 4, 7, 9], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - }) + points = pd.DataFrame( + { + "x": [0, 4, 7, 9], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -344,11 +352,13 @@ def test_decelerating_motion() -> None: def test_stationary_motion() -> None: """Test with no movement (all points the same).""" - points = pd.DataFrame({ - "x": [1, 1, 1], - "y": [1, 1, 1], - "seconds": [0, 1, 2], - }) + points = pd.DataFrame( + { + "x": [1, 1, 1], + "y": [1, 1, 1], + "seconds": [0, 1, 2], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -369,11 +379,13 @@ def test_stationary_motion() -> None: def test_no_hesitations_uniform_motion() -> None: """Test with uniform motion where all velocities are equal.""" - points = pd.DataFrame({ - "x": [0, 1, 2, 3], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - }) + points = pd.DataFrame( + { + "x": [0, 1, 2, 3], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -392,11 +404,13 @@ def test_no_hesitations_uniform_motion() -> None: def test_hesitation_at_start() -> None: """Test when the line starts with a hesitation.""" - points = pd.DataFrame({ - "x": [0, 0.1, 1, 2], - "y": [0, 0.1, 0, 0], - "seconds": [0, 1, 2, 3], - }) + points = pd.DataFrame( + { + "x": [0, 0.1, 1, 2], + "y": [0, 0.1, 0, 0], + "seconds": [0, 1, 2, 3], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -415,11 +429,13 @@ def test_hesitation_at_start() -> None: def test_multiple_hesitations() -> None: """Test when there are multiple hesitation periods.""" - points = pd.DataFrame({ - "x": [0, 100, 100.1, 200, 200.1, 300, 400, 500, 600], - "y": [0, 0, 0, 0, 0, 0, 0, 0, 0], - "seconds": [0, 1, 2, 3, 4, 5, 6, 7, 8], - }) + points = pd.DataFrame( + { + "x": [0, 100, 100.1, 200, 200.1, 300, 400, 500, 600], + "y": [0, 0, 0, 0, 0, 0, 0, 0, 0], + "seconds": [0, 1, 2, 3, 4, 5, 6, 7, 8], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -438,11 +454,13 @@ def test_multiple_hesitations() -> None: def test_less_than_three_velocities() -> None: """Test early return when velocities length is less than 3.""" - points = pd.DataFrame({ - "x": [0, 1], - "y": [0, 0], - "seconds": [0, 1], - }) + points = pd.DataFrame( + { + "x": [0, 1], + "y": [0, 0], + "seconds": [0, 1], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -461,11 +479,13 @@ def test_less_than_three_velocities() -> None: def test_smoothness_less_than_three_points() -> None: """Less than 3 points cannot define curvature.""" - points = pd.DataFrame({ - "x": [0, 1], - "y": [0, 0], - "seconds": [0, 1], - }) + points = pd.DataFrame( + { + "x": [0, 1], + "y": [0, 0], + "seconds": [0, 1], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -555,10 +575,12 @@ def test_smoothness_single_180_degree_turn() -> None: Since it represents maximal curvature. """ - points = pd.DataFrame({ - "x": [0, 1, 0], - "y": [0, 0, 0], - }) + points = pd.DataFrame( + { + "x": [0, 1, 0], + "y": [0, 0, 0], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -574,11 +596,13 @@ def test_smoothness_single_180_degree_turn() -> None: def test_compute_segment_metrics_less_than_two_points() -> None: """Test early return when segment has less than 2 points.""" - points = pd.DataFrame({ - "x": [0], - "y": [0], - "seconds": [0], - }) + points = pd.DataFrame( + { + "x": [0], + "y": [0], + "seconds": [0], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -606,11 +630,13 @@ def test_compute_segment_metrics_less_than_two_points() -> None: def test_compute_segment_metrics_invalid_start_label() -> None: """Test early return when start_label not in trail circles.""" - points = pd.DataFrame({ - "x": [0, 50, 100], - "y": [0, 0, 0], - "seconds": [0, 1, 2], - }) + points = pd.DataFrame( + { + "x": [0, 50, 100], + "y": [0, 0, 0], + "seconds": [0, 1, 2], + } + ) segment = models.LineSegment( start_label="999", end_label="2", @@ -639,11 +665,13 @@ def test_compute_segment_metrics_invalid_start_label() -> None: def test_compute_segment_metrics_invalid_end_label() -> None: """Test early return when end_label not in trail circles.""" - points = pd.DataFrame({ - "x": [0, 50, 100], - "y": [0, 0, 0], - "seconds": [0, 1, 2], - }) + points = pd.DataFrame( + { + "x": [0, 50, 100], + "y": [0, 0, 0], + "seconds": [0, 1, 2], + } + ) segment = models.LineSegment( start_label="1", end_label="999", @@ -671,11 +699,13 @@ def test_compute_segment_metrics_invalid_end_label() -> None: def test_compute_segment_metrics_valid_trajectory() -> None: """Test successful computation of all metrics with valid trajectory.""" - points = pd.DataFrame({ - "x": [0, 25, 50, 75, 100], - "y": [0, 0, 0, 0, 0], - "seconds": [0, 1, 2, 3, 4], - }) + points = pd.DataFrame( + { + "x": [0, 25, 50, 75, 100], + "y": [0, 0, 0, 0, 0], + "seconds": [0, 1, 2, 3, 4], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -709,11 +739,13 @@ def test_compute_segment_metrics_valid_trajectory() -> None: def test_compute_segment_metrics_ink_end_equals_ink_start() -> None: """Test when ink_end_idx equals ink_start_idx (no valid trajectory).""" - points = pd.DataFrame({ - "x": [0, 50, 100], - "y": [0, 0, 0], - "seconds": [0, 1, 2], - }) + points = pd.DataFrame( + { + "x": [0, 50, 100], + "y": [0, 0, 0], + "seconds": [0, 1, 2], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -742,11 +774,13 @@ def test_compute_segment_metrics_ink_end_equals_ink_start() -> None: def test_compute_segment_metrics_ink_end_before_ink_start() -> None: """Test when ink_end_idx is before ink_start_idx (invalid trajectory).""" - points = pd.DataFrame({ - "x": [0, 50, 100], - "y": [0, 0, 0], - "seconds": [0, 1, 2], - }) + points = pd.DataFrame( + { + "x": [0, 50, 100], + "y": [0, 0, 0], + "seconds": [0, 1, 2], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -775,11 +809,13 @@ def test_compute_segment_metrics_ink_end_before_ink_start() -> None: def test_compute_segment_metrics_only_start_index_found() -> None: """Test when only ink_start_idx is found (end is None).""" - points = pd.DataFrame({ - "x": [0, 25, 50, 75], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - }) + points = pd.DataFrame( + { + "x": [0, 25, 50, 75], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -810,11 +846,13 @@ def test_compute_segment_metrics_only_start_index_found() -> None: def test_compute_segment_metrics_calls_all_metric_functions() -> None: """Test that all metric calculation functions are called.""" - points = pd.DataFrame({ - "x": [0, 25, 50, 75, 100], - "y": [0, 0, 0, 0, 0], - "seconds": [0, 1, 2, 3, 4], - }) + points = pd.DataFrame( + { + "x": [0, 25, 50, 75, 100], + "y": [0, 0, 0, 0, 0], + "seconds": [0, 1, 2, 3, 4], + } + ) segment = models.LineSegment( start_label="1", end_label="2", From de67b854555a92688d370c0b73a39493761f6307 Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Mon, 16 Feb 2026 10:13:35 -0500 Subject: [PATCH 32/42] mypy error --- src/graphomotor/core/models.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/graphomotor/core/models.py b/src/graphomotor/core/models.py index 1d7cac0..9cd0eea 100644 --- a/src/graphomotor/core/models.py +++ b/src/graphomotor/core/models.py @@ -357,7 +357,6 @@ def calculate_smoothness(self) -> None: sampling-rate dependence. """ if len(self.ink_points) < 3: - return 0.0 return xy = self.ink_points[["x", "y"]].to_numpy() From d9d382e9e5249b890b939d7f17cd0b5ffa9c15d9 Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Mon, 16 Feb 2026 14:54:41 -0500 Subject: [PATCH 33/42] mypy and ruff errors --- tests/unit/test_models.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index 7c789ce..700cfe0 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -4,6 +4,7 @@ from typing import Dict, cast from unittest.mock import patch +import numpy as np import pandas as pd import pytest From faf5d73e4c3a88164d81801cc645ee1cc33939bf Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Thu, 5 Mar 2026 14:19:04 -0500 Subject: [PATCH 34/42] import logger, remove extra unit test --- src/graphomotor/core/models.py | 7 + tests/unit/test_models.py | 252 ++++++++++++--------------------- 2 files changed, 96 insertions(+), 163 deletions(-) diff --git a/src/graphomotor/core/models.py b/src/graphomotor/core/models.py index 1166059..5032ca1 100644 --- a/src/graphomotor/core/models.py +++ b/src/graphomotor/core/models.py @@ -2,6 +2,7 @@ import dataclasses import datetime +from asyncio.log import logger from typing import Callable, List, Optional, Tuple import numpy as np @@ -412,6 +413,12 @@ def compute_segment_metrics( return if self.start_label not in trail_circles or self.end_label not in trail_circles: + logger.warning( + "Missing start/end labels: start=%s end=%s available=%s", + self.start_label, + self.end_label, + list(trail_circles.keys()), + ) return start_circle = trail_circles[self.start_label] diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index 700cfe0..9d4ede8 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -244,13 +244,11 @@ def test_valid_ink_trajectory( def test_uniform_motion() -> None: """Test with points moving at constant velocity.""" - points = pd.DataFrame( - { - "x": [0, 1, 2, 3], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - } - ) + points = pd.DataFrame({ + "x": [0, 1, 2, 3], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -271,13 +269,11 @@ def test_uniform_motion() -> None: def test_accelerating_motion() -> None: """Test with motion accelerating over time.""" - points = pd.DataFrame( - { - "x": [0, 1, 4, 9], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - } - ) + points = pd.DataFrame({ + "x": [0, 1, 4, 9], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -298,13 +294,11 @@ def test_accelerating_motion() -> None: def test_velocity_two_points_only() -> None: """Test velocity calculation with only two points.""" - points = pd.DataFrame( - { - "x": [0, 3], - "y": [0, 4], - "seconds": [0, 2], - } - ) + points = pd.DataFrame({ + "x": [0, 3], + "y": [0, 4], + "seconds": [0, 2], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -325,13 +319,11 @@ def test_velocity_two_points_only() -> None: def test_decelerating_motion() -> None: """Test with decelerating motion (negative acceleration).""" - points = pd.DataFrame( - { - "x": [0, 4, 7, 9], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - } - ) + points = pd.DataFrame({ + "x": [0, 4, 7, 9], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -352,13 +344,11 @@ def test_decelerating_motion() -> None: def test_stationary_motion() -> None: """Test with no movement (all points the same).""" - points = pd.DataFrame( - { - "x": [1, 1, 1], - "y": [1, 1, 1], - "seconds": [0, 1, 2], - } - ) + points = pd.DataFrame({ + "x": [1, 1, 1], + "y": [1, 1, 1], + "seconds": [0, 1, 2], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -379,13 +369,11 @@ def test_stationary_motion() -> None: def test_no_hesitations_uniform_motion() -> None: """Test with uniform motion where all velocities are equal.""" - points = pd.DataFrame( - { - "x": [0, 1, 2, 3], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - } - ) + points = pd.DataFrame({ + "x": [0, 1, 2, 3], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -404,13 +392,11 @@ def test_no_hesitations_uniform_motion() -> None: def test_hesitation_at_start() -> None: """Test when the line starts with a hesitation.""" - points = pd.DataFrame( - { - "x": [0, 0.1, 1, 2], - "y": [0, 0.1, 0, 0], - "seconds": [0, 1, 2, 3], - } - ) + points = pd.DataFrame({ + "x": [0, 0.1, 1, 2], + "y": [0, 0.1, 0, 0], + "seconds": [0, 1, 2, 3], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -429,13 +415,11 @@ def test_hesitation_at_start() -> None: def test_multiple_hesitations() -> None: """Test when there are multiple hesitation periods.""" - points = pd.DataFrame( - { - "x": [0, 100, 100.1, 200, 200.1, 300, 400, 500, 600], - "y": [0, 0, 0, 0, 0, 0, 0, 0, 0], - "seconds": [0, 1, 2, 3, 4, 5, 6, 7, 8], - } - ) + points = pd.DataFrame({ + "x": [0, 100, 100.1, 200, 200.1, 300, 400, 500, 600], + "y": [0, 0, 0, 0, 0, 0, 0, 0, 0], + "seconds": [0, 1, 2, 3, 4, 5, 6, 7, 8], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -454,13 +438,11 @@ def test_multiple_hesitations() -> None: def test_less_than_three_velocities() -> None: """Test early return when velocities length is less than 3.""" - points = pd.DataFrame( - { - "x": [0, 1], - "y": [0, 0], - "seconds": [0, 1], - } - ) + points = pd.DataFrame({ + "x": [0, 1], + "y": [0, 0], + "seconds": [0, 1], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -479,13 +461,11 @@ def test_less_than_three_velocities() -> None: def test_smoothness_less_than_three_points() -> None: """Less than 3 points cannot define curvature.""" - points = pd.DataFrame( - { - "x": [0, 1], - "y": [0, 0], - "seconds": [0, 1], - } - ) + points = pd.DataFrame({ + "x": [0, 1], + "y": [0, 0], + "seconds": [0, 1], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -575,12 +555,10 @@ def test_smoothness_single_180_degree_turn() -> None: Since it represents maximal curvature. """ - points = pd.DataFrame( - { - "x": [0, 1, 0], - "y": [0, 0, 0], - } - ) + points = pd.DataFrame({ + "x": [0, 1, 0], + "y": [0, 0, 0], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -596,13 +574,11 @@ def test_smoothness_single_180_degree_turn() -> None: def test_compute_segment_metrics_less_than_two_points() -> None: """Test early return when segment has less than 2 points.""" - points = pd.DataFrame( - { - "x": [0], - "y": [0], - "seconds": [0], - } - ) + points = pd.DataFrame({ + "x": [0], + "y": [0], + "seconds": [0], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -630,13 +606,11 @@ def test_compute_segment_metrics_less_than_two_points() -> None: def test_compute_segment_metrics_invalid_start_label() -> None: """Test early return when start_label not in trail circles.""" - points = pd.DataFrame( - { - "x": [0, 50, 100], - "y": [0, 0, 0], - "seconds": [0, 1, 2], - } - ) + points = pd.DataFrame({ + "x": [0, 50, 100], + "y": [0, 0, 0], + "seconds": [0, 1, 2], + }) segment = models.LineSegment( start_label="999", end_label="2", @@ -665,13 +639,11 @@ def test_compute_segment_metrics_invalid_start_label() -> None: def test_compute_segment_metrics_invalid_end_label() -> None: """Test early return when end_label not in trail circles.""" - points = pd.DataFrame( - { - "x": [0, 50, 100], - "y": [0, 0, 0], - "seconds": [0, 1, 2], - } - ) + points = pd.DataFrame({ + "x": [0, 50, 100], + "y": [0, 0, 0], + "seconds": [0, 1, 2], + }) segment = models.LineSegment( start_label="1", end_label="999", @@ -699,13 +671,11 @@ def test_compute_segment_metrics_invalid_end_label() -> None: def test_compute_segment_metrics_valid_trajectory() -> None: """Test successful computation of all metrics with valid trajectory.""" - points = pd.DataFrame( - { - "x": [0, 25, 50, 75, 100], - "y": [0, 0, 0, 0, 0], - "seconds": [0, 1, 2, 3, 4], - } - ) + points = pd.DataFrame({ + "x": [0, 25, 50, 75, 100], + "y": [0, 0, 0, 0, 0], + "seconds": [0, 1, 2, 3, 4], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -739,13 +709,11 @@ def test_compute_segment_metrics_valid_trajectory() -> None: def test_compute_segment_metrics_ink_end_equals_ink_start() -> None: """Test when ink_end_idx equals ink_start_idx (no valid trajectory).""" - points = pd.DataFrame( - { - "x": [0, 50, 100], - "y": [0, 0, 0], - "seconds": [0, 1, 2], - } - ) + points = pd.DataFrame({ + "x": [0, 50, 100], + "y": [0, 0, 0], + "seconds": [0, 1, 2], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -774,13 +742,11 @@ def test_compute_segment_metrics_ink_end_equals_ink_start() -> None: def test_compute_segment_metrics_ink_end_before_ink_start() -> None: """Test when ink_end_idx is before ink_start_idx (invalid trajectory).""" - points = pd.DataFrame( - { - "x": [0, 50, 100], - "y": [0, 0, 0], - "seconds": [0, 1, 2], - } - ) + points = pd.DataFrame({ + "x": [0, 50, 100], + "y": [0, 0, 0], + "seconds": [0, 1, 2], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -809,13 +775,11 @@ def test_compute_segment_metrics_ink_end_before_ink_start() -> None: def test_compute_segment_metrics_only_start_index_found() -> None: """Test when only ink_start_idx is found (end is None).""" - points = pd.DataFrame( - { - "x": [0, 25, 50, 75], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - } - ) + points = pd.DataFrame({ + "x": [0, 25, 50, 75], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -842,41 +806,3 @@ def test_compute_segment_metrics_only_start_index_found() -> None: assert len(segment.ink_points) == 3 assert segment.distance == 0.0 assert len(segment.velocities) == 0 - - -def test_compute_segment_metrics_calls_all_metric_functions() -> None: - """Test that all metric calculation functions are called.""" - points = pd.DataFrame( - { - "x": [0, 25, 50, 75, 100], - "y": [0, 0, 0, 0, 0], - "seconds": [0, 1, 2, 3, 4], - } - ) - segment = models.LineSegment( - start_label="1", - end_label="2", - points=points, - is_error=False, - line_number=1, - ) - - circles = { - "A": { - "1": models.CircleTarget( - order=1, label="1", center_x=0, center_y=0, radius=10 - ), - "2": models.CircleTarget( - order=2, label="2", center_x=100, center_y=0, radius=10 - ), - } - } - - with patch.object(segment, "valid_ink_trajectory", return_value=(0, 4)): - segment.compute_segment_metrics(circles=circles, trail_id="A") - - assert segment.distance == 100.0 - assert segment.mean_speed == 25.0 - assert segment.speed_variance == 0.0 - assert segment.velocities == [25.0, 25.0, 25.0, 25.0] - assert segment.accelerations == [0.0, 0.0, 0.0] From baa163829c0f4810d17a3dcd9e8be6115d6cb8fa Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Thu, 5 Mar 2026 14:27:00 -0500 Subject: [PATCH 35/42] correcting pytest.approx --- tests/unit/test_models.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index 9d4ede8..ba0fef1 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -698,13 +698,13 @@ def test_compute_segment_metrics_valid_trajectory() -> None: with patch.object(segment, "valid_ink_trajectory", return_value=(0, 4)): segment.compute_segment_metrics(circles=circles, trail_id="A") - assert segment.ink_time == pytest.approx(4.0) + assert np.isclose(segment.ink_time, 4.0) assert len(segment.ink_points) == 5 assert segment.distance > 0.0 assert segment.mean_speed > 0.0 assert len(segment.velocities) > 0 assert segment.path_optimality > 0.0 - assert segment.smoothness == pytest.approx(0.0) + assert np.isclose(segment.smoothness, 0.0) def test_compute_segment_metrics_ink_end_equals_ink_start() -> None: @@ -802,7 +802,7 @@ def test_compute_segment_metrics_only_start_index_found() -> None: with patch.object(segment, "valid_ink_trajectory", return_value=(1, None)): segment.compute_segment_metrics(circles=circles, trail_id="A") - assert segment.ink_time == pytest.approx(2.0) + assert np.isclose(segment.ink_time, 2.0) assert len(segment.ink_points) == 3 assert segment.distance == 0.0 assert len(segment.velocities) == 0 From a6a02b3537edd4142930f0d26157592dc52c6348 Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Thu, 5 Mar 2026 14:33:58 -0500 Subject: [PATCH 36/42] rework if else chain --- src/graphomotor/core/models.py | 54 ++++++++++++++++++++++++---------- 1 file changed, 38 insertions(+), 16 deletions(-) diff --git a/src/graphomotor/core/models.py b/src/graphomotor/core/models.py index 5032ca1..4d7b099 100644 --- a/src/graphomotor/core/models.py +++ b/src/graphomotor/core/models.py @@ -410,6 +410,11 @@ def compute_segment_metrics( points = self.points.copy() if len(points) < 2: + logger.warning( + "Not enough points to calculate metrics for line segment: start=%s end=%s", + self.start_label, + self.end_label, + ) return if self.start_label not in trail_circles or self.end_label not in trail_circles: @@ -426,32 +431,49 @@ def compute_segment_metrics( ink_start_idx, ink_end_idx = self.valid_ink_trajectory(start_circle, end_circle) - if ( - ink_start_idx is not None - and ink_end_idx is not None - and ink_end_idx > ink_start_idx - ): + if ink_start_idx is None: + logger.warning( + "No valid ink trajectory found for line segment: start=%s end=%s", + self.start_label, + self.end_label, + ) + elif ink_end_idx is None: + self.ink_points = points.iloc[ink_start_idx:].copy() + if len(self.ink_points) >= 2: + self.ink_time = ( + self.ink_points.iloc[-1]["seconds"] + - self.ink_points.iloc[0]["seconds"] + ) + else: + logger.warning( + "Not enough ink points to calculate metrics for line segment: start=%s end=%s", + self.start_label, + self.end_label, + ) + elif ink_end_idx <= ink_start_idx: + logger.warning( + "Invalid ink trajectory: end index (%d) is not greater than start index (%d) for line segment: start=%s end=%s", + ink_end_idx, + ink_start_idx, + self.start_label, + self.end_label, + ) + else: self.ink_points = self.points.iloc[ink_start_idx : ink_end_idx + 1].copy() if len(self.ink_points) >= 2: ink_start = self.ink_points.iloc[0]["seconds"] ink_end = self.ink_points.iloc[-1]["seconds"] self.ink_time = ink_end - ink_start - self.calculate_velocity_metrics() - self.calculate_path_optimality(start_circle, end_circle) - self.calculate_smoothness() - self.detect_hesitations() - - elif ink_start_idx is not None: - self.ink_points = points.iloc[ink_start_idx:].copy() - if len(self.ink_points) >= 2: - self.ink_time = ( - self.ink_points.iloc[-1]["seconds"] - - self.ink_points.iloc[0]["seconds"] + else: + logger.warning( + "Not enough ink points to calculate metrics for line segment: start=%s end=%s", + self.start_label, + self.end_label, ) return From 384a1391f80584cbb574f54927f17d92fa149377 Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Thu, 5 Mar 2026 14:35:54 -0500 Subject: [PATCH 37/42] ruff --- tests/unit/test_models.py | 214 ++++++++++++++++++++++---------------- 1 file changed, 125 insertions(+), 89 deletions(-) diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index ba0fef1..798136f 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -244,11 +244,13 @@ def test_valid_ink_trajectory( def test_uniform_motion() -> None: """Test with points moving at constant velocity.""" - points = pd.DataFrame({ - "x": [0, 1, 2, 3], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - }) + points = pd.DataFrame( + { + "x": [0, 1, 2, 3], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -269,11 +271,13 @@ def test_uniform_motion() -> None: def test_accelerating_motion() -> None: """Test with motion accelerating over time.""" - points = pd.DataFrame({ - "x": [0, 1, 4, 9], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - }) + points = pd.DataFrame( + { + "x": [0, 1, 4, 9], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -294,11 +298,13 @@ def test_accelerating_motion() -> None: def test_velocity_two_points_only() -> None: """Test velocity calculation with only two points.""" - points = pd.DataFrame({ - "x": [0, 3], - "y": [0, 4], - "seconds": [0, 2], - }) + points = pd.DataFrame( + { + "x": [0, 3], + "y": [0, 4], + "seconds": [0, 2], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -319,11 +325,13 @@ def test_velocity_two_points_only() -> None: def test_decelerating_motion() -> None: """Test with decelerating motion (negative acceleration).""" - points = pd.DataFrame({ - "x": [0, 4, 7, 9], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - }) + points = pd.DataFrame( + { + "x": [0, 4, 7, 9], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -344,11 +352,13 @@ def test_decelerating_motion() -> None: def test_stationary_motion() -> None: """Test with no movement (all points the same).""" - points = pd.DataFrame({ - "x": [1, 1, 1], - "y": [1, 1, 1], - "seconds": [0, 1, 2], - }) + points = pd.DataFrame( + { + "x": [1, 1, 1], + "y": [1, 1, 1], + "seconds": [0, 1, 2], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -369,11 +379,13 @@ def test_stationary_motion() -> None: def test_no_hesitations_uniform_motion() -> None: """Test with uniform motion where all velocities are equal.""" - points = pd.DataFrame({ - "x": [0, 1, 2, 3], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - }) + points = pd.DataFrame( + { + "x": [0, 1, 2, 3], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -392,11 +404,13 @@ def test_no_hesitations_uniform_motion() -> None: def test_hesitation_at_start() -> None: """Test when the line starts with a hesitation.""" - points = pd.DataFrame({ - "x": [0, 0.1, 1, 2], - "y": [0, 0.1, 0, 0], - "seconds": [0, 1, 2, 3], - }) + points = pd.DataFrame( + { + "x": [0, 0.1, 1, 2], + "y": [0, 0.1, 0, 0], + "seconds": [0, 1, 2, 3], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -415,11 +429,13 @@ def test_hesitation_at_start() -> None: def test_multiple_hesitations() -> None: """Test when there are multiple hesitation periods.""" - points = pd.DataFrame({ - "x": [0, 100, 100.1, 200, 200.1, 300, 400, 500, 600], - "y": [0, 0, 0, 0, 0, 0, 0, 0, 0], - "seconds": [0, 1, 2, 3, 4, 5, 6, 7, 8], - }) + points = pd.DataFrame( + { + "x": [0, 100, 100.1, 200, 200.1, 300, 400, 500, 600], + "y": [0, 0, 0, 0, 0, 0, 0, 0, 0], + "seconds": [0, 1, 2, 3, 4, 5, 6, 7, 8], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -438,11 +454,13 @@ def test_multiple_hesitations() -> None: def test_less_than_three_velocities() -> None: """Test early return when velocities length is less than 3.""" - points = pd.DataFrame({ - "x": [0, 1], - "y": [0, 0], - "seconds": [0, 1], - }) + points = pd.DataFrame( + { + "x": [0, 1], + "y": [0, 0], + "seconds": [0, 1], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -461,11 +479,13 @@ def test_less_than_three_velocities() -> None: def test_smoothness_less_than_three_points() -> None: """Less than 3 points cannot define curvature.""" - points = pd.DataFrame({ - "x": [0, 1], - "y": [0, 0], - "seconds": [0, 1], - }) + points = pd.DataFrame( + { + "x": [0, 1], + "y": [0, 0], + "seconds": [0, 1], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -555,10 +575,12 @@ def test_smoothness_single_180_degree_turn() -> None: Since it represents maximal curvature. """ - points = pd.DataFrame({ - "x": [0, 1, 0], - "y": [0, 0, 0], - }) + points = pd.DataFrame( + { + "x": [0, 1, 0], + "y": [0, 0, 0], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -574,11 +596,13 @@ def test_smoothness_single_180_degree_turn() -> None: def test_compute_segment_metrics_less_than_two_points() -> None: """Test early return when segment has less than 2 points.""" - points = pd.DataFrame({ - "x": [0], - "y": [0], - "seconds": [0], - }) + points = pd.DataFrame( + { + "x": [0], + "y": [0], + "seconds": [0], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -606,11 +630,13 @@ def test_compute_segment_metrics_less_than_two_points() -> None: def test_compute_segment_metrics_invalid_start_label() -> None: """Test early return when start_label not in trail circles.""" - points = pd.DataFrame({ - "x": [0, 50, 100], - "y": [0, 0, 0], - "seconds": [0, 1, 2], - }) + points = pd.DataFrame( + { + "x": [0, 50, 100], + "y": [0, 0, 0], + "seconds": [0, 1, 2], + } + ) segment = models.LineSegment( start_label="999", end_label="2", @@ -639,11 +665,13 @@ def test_compute_segment_metrics_invalid_start_label() -> None: def test_compute_segment_metrics_invalid_end_label() -> None: """Test early return when end_label not in trail circles.""" - points = pd.DataFrame({ - "x": [0, 50, 100], - "y": [0, 0, 0], - "seconds": [0, 1, 2], - }) + points = pd.DataFrame( + { + "x": [0, 50, 100], + "y": [0, 0, 0], + "seconds": [0, 1, 2], + } + ) segment = models.LineSegment( start_label="1", end_label="999", @@ -671,11 +699,13 @@ def test_compute_segment_metrics_invalid_end_label() -> None: def test_compute_segment_metrics_valid_trajectory() -> None: """Test successful computation of all metrics with valid trajectory.""" - points = pd.DataFrame({ - "x": [0, 25, 50, 75, 100], - "y": [0, 0, 0, 0, 0], - "seconds": [0, 1, 2, 3, 4], - }) + points = pd.DataFrame( + { + "x": [0, 25, 50, 75, 100], + "y": [0, 0, 0, 0, 0], + "seconds": [0, 1, 2, 3, 4], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -709,11 +739,13 @@ def test_compute_segment_metrics_valid_trajectory() -> None: def test_compute_segment_metrics_ink_end_equals_ink_start() -> None: """Test when ink_end_idx equals ink_start_idx (no valid trajectory).""" - points = pd.DataFrame({ - "x": [0, 50, 100], - "y": [0, 0, 0], - "seconds": [0, 1, 2], - }) + points = pd.DataFrame( + { + "x": [0, 50, 100], + "y": [0, 0, 0], + "seconds": [0, 1, 2], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -742,11 +774,13 @@ def test_compute_segment_metrics_ink_end_equals_ink_start() -> None: def test_compute_segment_metrics_ink_end_before_ink_start() -> None: """Test when ink_end_idx is before ink_start_idx (invalid trajectory).""" - points = pd.DataFrame({ - "x": [0, 50, 100], - "y": [0, 0, 0], - "seconds": [0, 1, 2], - }) + points = pd.DataFrame( + { + "x": [0, 50, 100], + "y": [0, 0, 0], + "seconds": [0, 1, 2], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -775,11 +809,13 @@ def test_compute_segment_metrics_ink_end_before_ink_start() -> None: def test_compute_segment_metrics_only_start_index_found() -> None: """Test when only ink_start_idx is found (end is None).""" - points = pd.DataFrame({ - "x": [0, 25, 50, 75], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - }) + points = pd.DataFrame( + { + "x": [0, 25, 50, 75], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + } + ) segment = models.LineSegment( start_label="1", end_label="2", From 52d16fe9f2712895e8e00c037d8baeda32765d26 Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Thu, 5 Mar 2026 14:38:08 -0500 Subject: [PATCH 38/42] line too long --- src/graphomotor/core/models.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/graphomotor/core/models.py b/src/graphomotor/core/models.py index 4d7b099..ef75a30 100644 --- a/src/graphomotor/core/models.py +++ b/src/graphomotor/core/models.py @@ -411,7 +411,8 @@ def compute_segment_metrics( if len(points) < 2: logger.warning( - "Not enough points to calculate metrics for line segment: start=%s end=%s", + "Not enough points to calculate metrics for line segment: " + "start=%s end=%s", self.start_label, self.end_label, ) @@ -446,13 +447,15 @@ def compute_segment_metrics( ) else: logger.warning( - "Not enough ink points to calculate metrics for line segment: start=%s end=%s", + "Not enough ink points to calculate metrics for line segment: " + "start=%s end=%s", self.start_label, self.end_label, ) elif ink_end_idx <= ink_start_idx: logger.warning( - "Invalid ink trajectory: end index (%d) is not greater than start index (%d) for line segment: start=%s end=%s", + "Invalid ink trajectory: end index (%d) is not greater than " + "start index (%d) for line segment: start=%s end=%s", ink_end_idx, ink_start_idx, self.start_label, @@ -471,7 +474,8 @@ def compute_segment_metrics( self.detect_hesitations() else: logger.warning( - "Not enough ink points to calculate metrics for line segment: start=%s end=%s", + "Not enough ink points to calculate metrics for line segment: " + "start=%s end=%s", self.start_label, self.end_label, ) From 7d2a1659d95f0b43b8f58d1b74032fa2931cd691 Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Fri, 6 Mar 2026 09:51:13 -0500 Subject: [PATCH 39/42] update logger method to match one in config --- src/graphomotor/core/models.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/graphomotor/core/models.py b/src/graphomotor/core/models.py index ef75a30..f845bad 100644 --- a/src/graphomotor/core/models.py +++ b/src/graphomotor/core/models.py @@ -2,7 +2,7 @@ import dataclasses import datetime -from asyncio.log import logger +import logging from typing import Callable, List, Optional, Tuple import numpy as np @@ -10,6 +10,8 @@ import pydantic import scipy.spatial.distance as dist +from graphomotor.core import config + class Drawing(pydantic.BaseModel): """Class representing a drawing task, encapsulating both raw data and metadata. @@ -406,6 +408,7 @@ def compute_segment_metrics( CircleTarget instances (output of load_scaled_circles in config). trail_id: Trail identifier for circle lookup. """ + logger = config.get_logger() trail_circles = circles[trail_id] points = self.points.copy() From 00e9968e51c33dbc8c56a92034bbaf38af2f62ee Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Fri, 6 Mar 2026 09:55:25 -0500 Subject: [PATCH 40/42] fixing unused import --- src/graphomotor/core/models.py | 1 - tests/unit/test_models.py | 214 ++++++++++++++------------------- 2 files changed, 89 insertions(+), 126 deletions(-) diff --git a/src/graphomotor/core/models.py b/src/graphomotor/core/models.py index f845bad..b052e9b 100644 --- a/src/graphomotor/core/models.py +++ b/src/graphomotor/core/models.py @@ -2,7 +2,6 @@ import dataclasses import datetime -import logging from typing import Callable, List, Optional, Tuple import numpy as np diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index 798136f..ba0fef1 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -244,13 +244,11 @@ def test_valid_ink_trajectory( def test_uniform_motion() -> None: """Test with points moving at constant velocity.""" - points = pd.DataFrame( - { - "x": [0, 1, 2, 3], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - } - ) + points = pd.DataFrame({ + "x": [0, 1, 2, 3], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -271,13 +269,11 @@ def test_uniform_motion() -> None: def test_accelerating_motion() -> None: """Test with motion accelerating over time.""" - points = pd.DataFrame( - { - "x": [0, 1, 4, 9], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - } - ) + points = pd.DataFrame({ + "x": [0, 1, 4, 9], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -298,13 +294,11 @@ def test_accelerating_motion() -> None: def test_velocity_two_points_only() -> None: """Test velocity calculation with only two points.""" - points = pd.DataFrame( - { - "x": [0, 3], - "y": [0, 4], - "seconds": [0, 2], - } - ) + points = pd.DataFrame({ + "x": [0, 3], + "y": [0, 4], + "seconds": [0, 2], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -325,13 +319,11 @@ def test_velocity_two_points_only() -> None: def test_decelerating_motion() -> None: """Test with decelerating motion (negative acceleration).""" - points = pd.DataFrame( - { - "x": [0, 4, 7, 9], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - } - ) + points = pd.DataFrame({ + "x": [0, 4, 7, 9], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -352,13 +344,11 @@ def test_decelerating_motion() -> None: def test_stationary_motion() -> None: """Test with no movement (all points the same).""" - points = pd.DataFrame( - { - "x": [1, 1, 1], - "y": [1, 1, 1], - "seconds": [0, 1, 2], - } - ) + points = pd.DataFrame({ + "x": [1, 1, 1], + "y": [1, 1, 1], + "seconds": [0, 1, 2], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -379,13 +369,11 @@ def test_stationary_motion() -> None: def test_no_hesitations_uniform_motion() -> None: """Test with uniform motion where all velocities are equal.""" - points = pd.DataFrame( - { - "x": [0, 1, 2, 3], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - } - ) + points = pd.DataFrame({ + "x": [0, 1, 2, 3], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -404,13 +392,11 @@ def test_no_hesitations_uniform_motion() -> None: def test_hesitation_at_start() -> None: """Test when the line starts with a hesitation.""" - points = pd.DataFrame( - { - "x": [0, 0.1, 1, 2], - "y": [0, 0.1, 0, 0], - "seconds": [0, 1, 2, 3], - } - ) + points = pd.DataFrame({ + "x": [0, 0.1, 1, 2], + "y": [0, 0.1, 0, 0], + "seconds": [0, 1, 2, 3], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -429,13 +415,11 @@ def test_hesitation_at_start() -> None: def test_multiple_hesitations() -> None: """Test when there are multiple hesitation periods.""" - points = pd.DataFrame( - { - "x": [0, 100, 100.1, 200, 200.1, 300, 400, 500, 600], - "y": [0, 0, 0, 0, 0, 0, 0, 0, 0], - "seconds": [0, 1, 2, 3, 4, 5, 6, 7, 8], - } - ) + points = pd.DataFrame({ + "x": [0, 100, 100.1, 200, 200.1, 300, 400, 500, 600], + "y": [0, 0, 0, 0, 0, 0, 0, 0, 0], + "seconds": [0, 1, 2, 3, 4, 5, 6, 7, 8], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -454,13 +438,11 @@ def test_multiple_hesitations() -> None: def test_less_than_three_velocities() -> None: """Test early return when velocities length is less than 3.""" - points = pd.DataFrame( - { - "x": [0, 1], - "y": [0, 0], - "seconds": [0, 1], - } - ) + points = pd.DataFrame({ + "x": [0, 1], + "y": [0, 0], + "seconds": [0, 1], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -479,13 +461,11 @@ def test_less_than_three_velocities() -> None: def test_smoothness_less_than_three_points() -> None: """Less than 3 points cannot define curvature.""" - points = pd.DataFrame( - { - "x": [0, 1], - "y": [0, 0], - "seconds": [0, 1], - } - ) + points = pd.DataFrame({ + "x": [0, 1], + "y": [0, 0], + "seconds": [0, 1], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -575,12 +555,10 @@ def test_smoothness_single_180_degree_turn() -> None: Since it represents maximal curvature. """ - points = pd.DataFrame( - { - "x": [0, 1, 0], - "y": [0, 0, 0], - } - ) + points = pd.DataFrame({ + "x": [0, 1, 0], + "y": [0, 0, 0], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -596,13 +574,11 @@ def test_smoothness_single_180_degree_turn() -> None: def test_compute_segment_metrics_less_than_two_points() -> None: """Test early return when segment has less than 2 points.""" - points = pd.DataFrame( - { - "x": [0], - "y": [0], - "seconds": [0], - } - ) + points = pd.DataFrame({ + "x": [0], + "y": [0], + "seconds": [0], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -630,13 +606,11 @@ def test_compute_segment_metrics_less_than_two_points() -> None: def test_compute_segment_metrics_invalid_start_label() -> None: """Test early return when start_label not in trail circles.""" - points = pd.DataFrame( - { - "x": [0, 50, 100], - "y": [0, 0, 0], - "seconds": [0, 1, 2], - } - ) + points = pd.DataFrame({ + "x": [0, 50, 100], + "y": [0, 0, 0], + "seconds": [0, 1, 2], + }) segment = models.LineSegment( start_label="999", end_label="2", @@ -665,13 +639,11 @@ def test_compute_segment_metrics_invalid_start_label() -> None: def test_compute_segment_metrics_invalid_end_label() -> None: """Test early return when end_label not in trail circles.""" - points = pd.DataFrame( - { - "x": [0, 50, 100], - "y": [0, 0, 0], - "seconds": [0, 1, 2], - } - ) + points = pd.DataFrame({ + "x": [0, 50, 100], + "y": [0, 0, 0], + "seconds": [0, 1, 2], + }) segment = models.LineSegment( start_label="1", end_label="999", @@ -699,13 +671,11 @@ def test_compute_segment_metrics_invalid_end_label() -> None: def test_compute_segment_metrics_valid_trajectory() -> None: """Test successful computation of all metrics with valid trajectory.""" - points = pd.DataFrame( - { - "x": [0, 25, 50, 75, 100], - "y": [0, 0, 0, 0, 0], - "seconds": [0, 1, 2, 3, 4], - } - ) + points = pd.DataFrame({ + "x": [0, 25, 50, 75, 100], + "y": [0, 0, 0, 0, 0], + "seconds": [0, 1, 2, 3, 4], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -739,13 +709,11 @@ def test_compute_segment_metrics_valid_trajectory() -> None: def test_compute_segment_metrics_ink_end_equals_ink_start() -> None: """Test when ink_end_idx equals ink_start_idx (no valid trajectory).""" - points = pd.DataFrame( - { - "x": [0, 50, 100], - "y": [0, 0, 0], - "seconds": [0, 1, 2], - } - ) + points = pd.DataFrame({ + "x": [0, 50, 100], + "y": [0, 0, 0], + "seconds": [0, 1, 2], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -774,13 +742,11 @@ def test_compute_segment_metrics_ink_end_equals_ink_start() -> None: def test_compute_segment_metrics_ink_end_before_ink_start() -> None: """Test when ink_end_idx is before ink_start_idx (invalid trajectory).""" - points = pd.DataFrame( - { - "x": [0, 50, 100], - "y": [0, 0, 0], - "seconds": [0, 1, 2], - } - ) + points = pd.DataFrame({ + "x": [0, 50, 100], + "y": [0, 0, 0], + "seconds": [0, 1, 2], + }) segment = models.LineSegment( start_label="1", end_label="2", @@ -809,13 +775,11 @@ def test_compute_segment_metrics_ink_end_before_ink_start() -> None: def test_compute_segment_metrics_only_start_index_found() -> None: """Test when only ink_start_idx is found (end is None).""" - points = pd.DataFrame( - { - "x": [0, 25, 50, 75], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - } - ) + points = pd.DataFrame({ + "x": [0, 25, 50, 75], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + }) segment = models.LineSegment( start_label="1", end_label="2", From 0fbf5a5b25ce2db00556e7bb0b6e48ffe0eb4b39 Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Fri, 6 Mar 2026 09:58:01 -0500 Subject: [PATCH 41/42] ruff reformat --- tests/unit/test_models.py | 214 ++++++++++++++++++++++---------------- 1 file changed, 125 insertions(+), 89 deletions(-) diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index ba0fef1..798136f 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -244,11 +244,13 @@ def test_valid_ink_trajectory( def test_uniform_motion() -> None: """Test with points moving at constant velocity.""" - points = pd.DataFrame({ - "x": [0, 1, 2, 3], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - }) + points = pd.DataFrame( + { + "x": [0, 1, 2, 3], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -269,11 +271,13 @@ def test_uniform_motion() -> None: def test_accelerating_motion() -> None: """Test with motion accelerating over time.""" - points = pd.DataFrame({ - "x": [0, 1, 4, 9], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - }) + points = pd.DataFrame( + { + "x": [0, 1, 4, 9], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -294,11 +298,13 @@ def test_accelerating_motion() -> None: def test_velocity_two_points_only() -> None: """Test velocity calculation with only two points.""" - points = pd.DataFrame({ - "x": [0, 3], - "y": [0, 4], - "seconds": [0, 2], - }) + points = pd.DataFrame( + { + "x": [0, 3], + "y": [0, 4], + "seconds": [0, 2], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -319,11 +325,13 @@ def test_velocity_two_points_only() -> None: def test_decelerating_motion() -> None: """Test with decelerating motion (negative acceleration).""" - points = pd.DataFrame({ - "x": [0, 4, 7, 9], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - }) + points = pd.DataFrame( + { + "x": [0, 4, 7, 9], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -344,11 +352,13 @@ def test_decelerating_motion() -> None: def test_stationary_motion() -> None: """Test with no movement (all points the same).""" - points = pd.DataFrame({ - "x": [1, 1, 1], - "y": [1, 1, 1], - "seconds": [0, 1, 2], - }) + points = pd.DataFrame( + { + "x": [1, 1, 1], + "y": [1, 1, 1], + "seconds": [0, 1, 2], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -369,11 +379,13 @@ def test_stationary_motion() -> None: def test_no_hesitations_uniform_motion() -> None: """Test with uniform motion where all velocities are equal.""" - points = pd.DataFrame({ - "x": [0, 1, 2, 3], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - }) + points = pd.DataFrame( + { + "x": [0, 1, 2, 3], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -392,11 +404,13 @@ def test_no_hesitations_uniform_motion() -> None: def test_hesitation_at_start() -> None: """Test when the line starts with a hesitation.""" - points = pd.DataFrame({ - "x": [0, 0.1, 1, 2], - "y": [0, 0.1, 0, 0], - "seconds": [0, 1, 2, 3], - }) + points = pd.DataFrame( + { + "x": [0, 0.1, 1, 2], + "y": [0, 0.1, 0, 0], + "seconds": [0, 1, 2, 3], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -415,11 +429,13 @@ def test_hesitation_at_start() -> None: def test_multiple_hesitations() -> None: """Test when there are multiple hesitation periods.""" - points = pd.DataFrame({ - "x": [0, 100, 100.1, 200, 200.1, 300, 400, 500, 600], - "y": [0, 0, 0, 0, 0, 0, 0, 0, 0], - "seconds": [0, 1, 2, 3, 4, 5, 6, 7, 8], - }) + points = pd.DataFrame( + { + "x": [0, 100, 100.1, 200, 200.1, 300, 400, 500, 600], + "y": [0, 0, 0, 0, 0, 0, 0, 0, 0], + "seconds": [0, 1, 2, 3, 4, 5, 6, 7, 8], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -438,11 +454,13 @@ def test_multiple_hesitations() -> None: def test_less_than_three_velocities() -> None: """Test early return when velocities length is less than 3.""" - points = pd.DataFrame({ - "x": [0, 1], - "y": [0, 0], - "seconds": [0, 1], - }) + points = pd.DataFrame( + { + "x": [0, 1], + "y": [0, 0], + "seconds": [0, 1], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -461,11 +479,13 @@ def test_less_than_three_velocities() -> None: def test_smoothness_less_than_three_points() -> None: """Less than 3 points cannot define curvature.""" - points = pd.DataFrame({ - "x": [0, 1], - "y": [0, 0], - "seconds": [0, 1], - }) + points = pd.DataFrame( + { + "x": [0, 1], + "y": [0, 0], + "seconds": [0, 1], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -555,10 +575,12 @@ def test_smoothness_single_180_degree_turn() -> None: Since it represents maximal curvature. """ - points = pd.DataFrame({ - "x": [0, 1, 0], - "y": [0, 0, 0], - }) + points = pd.DataFrame( + { + "x": [0, 1, 0], + "y": [0, 0, 0], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -574,11 +596,13 @@ def test_smoothness_single_180_degree_turn() -> None: def test_compute_segment_metrics_less_than_two_points() -> None: """Test early return when segment has less than 2 points.""" - points = pd.DataFrame({ - "x": [0], - "y": [0], - "seconds": [0], - }) + points = pd.DataFrame( + { + "x": [0], + "y": [0], + "seconds": [0], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -606,11 +630,13 @@ def test_compute_segment_metrics_less_than_two_points() -> None: def test_compute_segment_metrics_invalid_start_label() -> None: """Test early return when start_label not in trail circles.""" - points = pd.DataFrame({ - "x": [0, 50, 100], - "y": [0, 0, 0], - "seconds": [0, 1, 2], - }) + points = pd.DataFrame( + { + "x": [0, 50, 100], + "y": [0, 0, 0], + "seconds": [0, 1, 2], + } + ) segment = models.LineSegment( start_label="999", end_label="2", @@ -639,11 +665,13 @@ def test_compute_segment_metrics_invalid_start_label() -> None: def test_compute_segment_metrics_invalid_end_label() -> None: """Test early return when end_label not in trail circles.""" - points = pd.DataFrame({ - "x": [0, 50, 100], - "y": [0, 0, 0], - "seconds": [0, 1, 2], - }) + points = pd.DataFrame( + { + "x": [0, 50, 100], + "y": [0, 0, 0], + "seconds": [0, 1, 2], + } + ) segment = models.LineSegment( start_label="1", end_label="999", @@ -671,11 +699,13 @@ def test_compute_segment_metrics_invalid_end_label() -> None: def test_compute_segment_metrics_valid_trajectory() -> None: """Test successful computation of all metrics with valid trajectory.""" - points = pd.DataFrame({ - "x": [0, 25, 50, 75, 100], - "y": [0, 0, 0, 0, 0], - "seconds": [0, 1, 2, 3, 4], - }) + points = pd.DataFrame( + { + "x": [0, 25, 50, 75, 100], + "y": [0, 0, 0, 0, 0], + "seconds": [0, 1, 2, 3, 4], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -709,11 +739,13 @@ def test_compute_segment_metrics_valid_trajectory() -> None: def test_compute_segment_metrics_ink_end_equals_ink_start() -> None: """Test when ink_end_idx equals ink_start_idx (no valid trajectory).""" - points = pd.DataFrame({ - "x": [0, 50, 100], - "y": [0, 0, 0], - "seconds": [0, 1, 2], - }) + points = pd.DataFrame( + { + "x": [0, 50, 100], + "y": [0, 0, 0], + "seconds": [0, 1, 2], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -742,11 +774,13 @@ def test_compute_segment_metrics_ink_end_equals_ink_start() -> None: def test_compute_segment_metrics_ink_end_before_ink_start() -> None: """Test when ink_end_idx is before ink_start_idx (invalid trajectory).""" - points = pd.DataFrame({ - "x": [0, 50, 100], - "y": [0, 0, 0], - "seconds": [0, 1, 2], - }) + points = pd.DataFrame( + { + "x": [0, 50, 100], + "y": [0, 0, 0], + "seconds": [0, 1, 2], + } + ) segment = models.LineSegment( start_label="1", end_label="2", @@ -775,11 +809,13 @@ def test_compute_segment_metrics_ink_end_before_ink_start() -> None: def test_compute_segment_metrics_only_start_index_found() -> None: """Test when only ink_start_idx is found (end is None).""" - points = pd.DataFrame({ - "x": [0, 25, 50, 75], - "y": [0, 0, 0, 0], - "seconds": [0, 1, 2, 3], - }) + points = pd.DataFrame( + { + "x": [0, 25, 50, 75], + "y": [0, 0, 0, 0], + "seconds": [0, 1, 2, 3], + } + ) segment = models.LineSegment( start_label="1", end_label="2", From a4ebe586e67dd0d86e086e8df98e47c1f6d421de Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Tue, 10 Mar 2026 12:20:16 -0400 Subject: [PATCH 42/42] fix if else chain --- src/graphomotor/core/models.py | 54 ++++++++++++++++++---------------- 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/src/graphomotor/core/models.py b/src/graphomotor/core/models.py index b052e9b..514ecc5 100644 --- a/src/graphomotor/core/models.py +++ b/src/graphomotor/core/models.py @@ -440,21 +440,22 @@ def compute_segment_metrics( self.start_label, self.end_label, ) - elif ink_end_idx is None: + return + if ink_end_idx is None: self.ink_points = points.iloc[ink_start_idx:].copy() - if len(self.ink_points) >= 2: - self.ink_time = ( - self.ink_points.iloc[-1]["seconds"] - - self.ink_points.iloc[0]["seconds"] - ) - else: + if len(self.ink_points) < 2: logger.warning( "Not enough ink points to calculate metrics for line segment: " "start=%s end=%s", self.start_label, self.end_label, ) - elif ink_end_idx <= ink_start_idx: + return + self.ink_time = ( + self.ink_points.iloc[-1]["seconds"] - self.ink_points.iloc[0]["seconds"] + ) + return + if ink_end_idx <= ink_start_idx: logger.warning( "Invalid ink trajectory: end index (%d) is not greater than " "start index (%d) for line segment: start=%s end=%s", @@ -463,23 +464,24 @@ def compute_segment_metrics( self.start_label, self.end_label, ) - else: - self.ink_points = self.points.iloc[ink_start_idx : ink_end_idx + 1].copy() - - if len(self.ink_points) >= 2: - ink_start = self.ink_points.iloc[0]["seconds"] - ink_end = self.ink_points.iloc[-1]["seconds"] - self.ink_time = ink_end - ink_start - self.calculate_velocity_metrics() - self.calculate_path_optimality(start_circle, end_circle) - self.calculate_smoothness() - self.detect_hesitations() - else: - logger.warning( - "Not enough ink points to calculate metrics for line segment: " - "start=%s end=%s", - self.start_label, - self.end_label, - ) + return + self.ink_points = self.points.iloc[ink_start_idx : ink_end_idx + 1].copy() + + if len(self.ink_points) < 2: + logger.warning( + "Not enough ink points to calculate metrics for line segment: " + "start=%s end=%s", + self.start_label, + self.end_label, + ) + return + + self.ink_time = ( + self.ink_points.iloc[-1]["seconds"] - self.ink_points.iloc[0]["seconds"] + ) + self.calculate_velocity_metrics() + self.calculate_path_optimality(start_circle, end_circle) + self.calculate_smoothness() + self.detect_hesitations() return