From 5f40b12c3d7e1e843f35a0d78dad8e81842bd7b8 Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Mon, 19 Jan 2026 14:02:35 -0500 Subject: [PATCH 01/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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 369887dc83c0c47cea0a30a1ec34aea97b937ab7 Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Thu, 12 Feb 2026 10:34:28 -0500 Subject: [PATCH 20/25] ruff remvoe unused import --- tests/unit/test_models.py | 109 ++++++++++++++++---------------------- 1 file changed, 45 insertions(+), 64 deletions(-) diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index f5a064b..3948013 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -3,7 +3,6 @@ import datetime from typing import Dict, cast -import numpy as np import pandas as pd import pytest @@ -243,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", @@ -272,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", @@ -304,13 +299,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 +325,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 +355,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 +382,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 +405,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 +428,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 +451,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", From ea1379b7754af1f0c603f0e774ac3a1042b56ac2 Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Thu, 12 Feb 2026 10:36:51 -0500 Subject: [PATCH 21/25] ruff format --- 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 8e14942606bef3df83b610aa39cf63b7fd726767 Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Mon, 16 Feb 2026 09:41:04 -0500 Subject: [PATCH 22/25] updating requested fixes --- src/graphomotor/core/models.py | 14 ++-- tests/unit/test_models.py | 120 ++++++++++++++------------------- 2 files changed, 57 insertions(+), 77 deletions(-) diff --git a/src/graphomotor/core/models.py b/src/graphomotor/core/models.py index 3707f64..6096d10 100644 --- a/src/graphomotor/core/models.py +++ b/src/graphomotor/core/models.py @@ -296,7 +296,6 @@ def calculate_velocity_metrics(self) -> None: Args: self: LineSegment object to calculate velocities for. - ink_points: DataFrame containing the ink points for the line segment. """ dx = np.diff(self.ink_points["x"].values) dy = np.diff(self.ink_points["y"].values) @@ -326,8 +325,11 @@ def detect_hesitations(self, threshold_percentile: int = 20) -> None: duration of hesitations based on the number of points that fall below the threshold and the time interval between points. + hesitation_count defaults to 0 and hesitation_duration defaults to 0.0 in the + LineSegment object if there are less than 3 velocity points. This function also + assumes uniform sampling. + 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). @@ -337,8 +339,8 @@ def detect_hesitations(self, threshold_percentile: int = 20) -> None: dt = np.diff(self.ink_points["seconds"].values) - threshold = np.percentile(self.velocities, threshold_percentile) - hesitations = self.velocities < threshold + threshold_velocity = np.percentile(self.velocities, threshold_percentile) + hesitations = self.velocities < threshold_velocity hesitation_changes = np.diff(hesitations.astype(int)) hesitation_starts = np.where(hesitation_changes == 1)[0] + 1 @@ -347,9 +349,7 @@ def detect_hesitations(self, threshold_percentile: int = 20) -> None: if hesitations[0]: hesitation_count += 1 - hesitation_duration = np.sum(hesitations) * dt[0] - self.hesitation_count = hesitation_count - self.hesitation_duration = hesitation_duration + self.hesitation_duration = np.sum(hesitations) * dt[0] return diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index 86456aa..e9d4771 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", @@ -260,24 +258,20 @@ def test_uniform_motion() -> None: segment.calculate_velocity_metrics() - 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) + assert segment.distance == 3.0 + assert segment.mean_speed == 1.0 + assert segment.speed_variance == 0.0 + assert segment.velocities == [1.0, 1.0, 1.0] + assert segment.accelerations == [0.0, 0.0] 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 +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", @@ -331,13 +323,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 +353,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 +380,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 +403,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 +426,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 +449,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", From dec843fc890e4845af2633a72e5ff1dd04766c95 Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Mon, 16 Feb 2026 09:46:37 -0500 Subject: [PATCH 23/25] fix tests and ruff --- tests/unit/test_models.py | 165 ++++++++++++++++++++------------------ 1 file changed, 85 insertions(+), 80 deletions(-) diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index e9d4771..d78ee1a 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", @@ -267,11 +269,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", @@ -283,25 +287,22 @@ def test_accelerating_motion() -> None: segment.calculate_velocity_metrics() - assert segment.distance == pytest.approx(9.0) - assert segment.mean_speed == pytest.approx(3.0) + assert segment.distance == 9.0 + assert segment.mean_speed == 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) + assert segment.velocities == [1.0, 3.0, 5.0] + assert segment.accelerations == [2.0, 2.0] 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", @@ -313,21 +314,22 @@ def test_velocity_two_points_only() -> None: segment.calculate_velocity_metrics() - 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 + assert segment.distance == 5.0 + assert segment.mean_speed == 2.5 + assert segment.speed_variance == 0.0 + assert segment.velocities == [2.5] + assert segment.accelerations == [] # No acceleration with only one velocity point 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", @@ -339,25 +341,22 @@ def test_decelerating_motion() -> None: segment.calculate_velocity_metrics() - assert segment.distance == pytest.approx(9.0) - assert segment.mean_speed == pytest.approx(3.0) + assert segment.distance == 9.0 + assert segment.mean_speed == 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) + assert segment.velocities == [4.0, 3.0, 2.0] + assert segment.accelerations == [-1.0, -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], - }) + 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,22 +368,22 @@ 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", @@ -398,16 +397,18 @@ 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", @@ -421,16 +422,18 @@ 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", @@ -444,16 +447,18 @@ 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", @@ -467,4 +472,4 @@ 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 From af311e7bbbc2bb8863bdf1f640e1a5bc27064142 Mon Sep 17 00:00:00 2001 From: Celia Maiorano <113949028+cgmaiorano@users.noreply.github.com> Date: Mon, 16 Feb 2026 14:40:12 -0500 Subject: [PATCH 24/25] Update src/graphomotor/core/models.py Co-authored-by: Adam Santorelli <148909356+Asanto32@users.noreply.github.com> --- src/graphomotor/core/models.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/graphomotor/core/models.py b/src/graphomotor/core/models.py index 6096d10..bd58d42 100644 --- a/src/graphomotor/core/models.py +++ b/src/graphomotor/core/models.py @@ -343,8 +343,7 @@ def detect_hesitations(self, threshold_percentile: int = 20) -> None: hesitations = self.velocities < threshold_velocity hesitation_changes = np.diff(hesitations.astype(int)) - hesitation_starts = np.where(hesitation_changes == 1)[0] + 1 - hesitation_count = len(hesitation_starts) + hesitation_count = np.sum(hesitation_changes == 1) if hesitations[0]: hesitation_count += 1 From 793d2bf7942c5e0e0924fbd2691800c7b308c9ba Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Mon, 16 Feb 2026 14:41:41 -0500 Subject: [PATCH 25/25] edit --- src/graphomotor/core/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/graphomotor/core/models.py b/src/graphomotor/core/models.py index bd58d42..73dccc1 100644 --- a/src/graphomotor/core/models.py +++ b/src/graphomotor/core/models.py @@ -343,7 +343,7 @@ def detect_hesitations(self, threshold_percentile: int = 20) -> None: hesitations = self.velocities < threshold_velocity hesitation_changes = np.diff(hesitations.astype(int)) - hesitation_count = np.sum(hesitation_changes == 1) + hesitation_count = np.sum(hesitation_changes == 1) if hesitations[0]: hesitation_count += 1