diff --git a/src/graphomotor/core/models.py b/src/graphomotor/core/models.py index 0fd9150..34aaa61 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, @@ -291,3 +291,28 @@ 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 distance, velocity, and acceleration 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) + + distances = np.sqrt(dx**2 + dy**2) + self.distance = np.sum(distances) + + 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 diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index adfd930..00875ac 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 @@ -232,10 +233,139 @@ def test_valid_ink_trajectory( is_error=False, line_number=1, ) - result_start, result_end = line_segment.valid_ink_trajectory( start_circle, end_circle ) 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( + { + "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 == 3.0 + assert segment.mean_speed == 1.0 + assert segment.speed_variance == 0.0 + assert np.all(segment.velocities) == 1.0 + assert np.all(segment.accelerations) == 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], + } + ) + 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 == 9.0 + assert segment.mean_speed == 3.0 + assert segment.speed_variance == pytest.approx(2.6666666666666665) + 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], + } + ) + 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 == 5.0 + assert segment.mean_speed == 2.5 + assert segment.speed_variance == 0.0 + assert segment.velocities == [2.5] + assert segment.accelerations == [] + + +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 == 9.0 + assert segment.mean_speed == 3.0 + assert segment.speed_variance > 0.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], + } + ) + 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 == 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] diff --git a/tests/unit/test_trails_drawing_metrics.py b/tests/unit/test_trails_drawing_metrics.py index 19e13ad..6507b74 100644 --- a/tests/unit/test_trails_drawing_metrics.py +++ b/tests/unit/test_trails_drawing_metrics.py @@ -11,7 +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]}) + 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"} ) @@ -34,7 +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]}) + 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"} )