Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion src/graphomotor/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
132 changes: 131 additions & 1 deletion tests/unit/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import datetime
from typing import Dict, cast

import numpy as np
import pandas as pd
import pytest

Expand Down Expand Up @@ -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]
14 changes: 12 additions & 2 deletions tests/unit/test_trails_drawing_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
)
Expand All @@ -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"}
)
Expand Down