From a7de34e4ec4a48074b00ced85c75982840df695b Mon Sep 17 00:00:00 2001 From: Iktae Kim Date: Mon, 2 Mar 2026 16:17:15 -0500 Subject: [PATCH 1/2] Add Stroke model and tests --- src/graphomotor/core/models.py | 38 +++++++++++++++ tests/unit/test_stroke.py | 89 ++++++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 tests/unit/test_stroke.py diff --git a/src/graphomotor/core/models.py b/src/graphomotor/core/models.py index 207304a..1ff4364 100644 --- a/src/graphomotor/core/models.py +++ b/src/graphomotor/core/models.py @@ -142,6 +142,7 @@ class GridCell: y_max: Top boundary of the cell. index: Position of the cell in the grid (0-based). label: Display label for the cell (e.g., 'A', 'B', '1'). + strokes: List of Stroke objects assigned to this cell. """ x_min: float @@ -150,6 +151,7 @@ class GridCell: y_max: float index: int = 0 label: str = "" + strokes: List["Stroke"] = dataclasses.field(default_factory=list) def __post_init__(self) -> None: """Validate that min bounds are strictly less than max bounds. @@ -187,6 +189,42 @@ def contains_points(self, points: pd.DataFrame) -> bool: ) +@dataclasses.dataclass +class Stroke: + """Represents a single stroke in an Alphabet or DSYM task. + + Similar to LineSegment for Trails, this class holds stroke data and + computed features. Features are populated by utility functions after + initialization. + + Attributes: + points: DataFrame with columns including 'x', 'y', and 'seconds'. + line_number: The line number identifying this stroke in the raw data. + duration: Total time spent drawing the stroke. + distance: Total distance of the stroke path. + mean_speed: Mean drawing speed. + speed_variance: Variance of drawing speed. + smoothness: Smoothness of the stroke based on curvature changes. + hesitation_count: Number of hesitations during the stroke. + hesitation_duration: Total duration of hesitations. + velocities: List of velocities at each point in the stroke. + accelerations: List of accelerations at each point in the stroke. + """ + + points: pd.DataFrame + line_number: int + + duration: float = 0.0 + distance: float = 0.0 + mean_speed: float = 0.0 + speed_variance: float = 0.0 + smoothness: float = 0.0 + hesitation_count: int = 0 + hesitation_duration: float = 0.0 + velocities: List[float] = dataclasses.field(default_factory=list) + accelerations: List[float] = dataclasses.field(default_factory=list) + + @dataclasses.dataclass class CircleTarget: """Represents a target circle in the drawing task. diff --git a/tests/unit/test_stroke.py b/tests/unit/test_stroke.py new file mode 100644 index 0000000..ec4c3fb --- /dev/null +++ b/tests/unit/test_stroke.py @@ -0,0 +1,89 @@ +"""Test cases for the Stroke model.""" + +import pandas as pd + +from graphomotor.core import models + + +def _make_points(line_number: int = 0) -> pd.DataFrame: + """Create a stroke DataFrame matching real Alphabet CSV structure. + + In the real data each stroke (line_number) contains many rows with the same + line_number value, plus x, y, seconds, and timestamp columns. + """ + return pd.DataFrame( + { + "line_number": [line_number] * 5, + "x": [18.35, 18.24, 18.15, 18.12, 17.99], + "y": [92.84, 92.85, 92.88, 92.92, 93.01], + "seconds": [0.0, 0.02, 0.037, 0.046, 0.062], + } + ) + + +def test_stroke_initialization() -> None: + """Stroke should store points and line_number with default feature values.""" + points = _make_points(line_number=0) + stroke = models.Stroke(points=points, line_number=0) + + assert stroke.line_number == 0 + assert stroke.points.equals(points) + assert list(stroke.points.columns) == ["line_number", "x", "y", "seconds"] + assert len(stroke.points) == 5 + assert stroke.duration == 0.0 + assert stroke.distance == 0.0 + assert stroke.mean_speed == 0.0 + assert stroke.speed_variance == 0.0 + assert stroke.smoothness == 0.0 + assert stroke.hesitation_count == 0 + assert stroke.hesitation_duration == 0.0 + assert stroke.velocities == [] + assert stroke.accelerations == [] + + +def test_stroke_features_are_mutable() -> None: + """Computed features should be writable after initialization.""" + stroke = models.Stroke(points=_make_points(line_number=0), line_number=0) + + stroke.duration = 0.5 + stroke.distance = 25.0 + stroke.mean_speed = 50.0 + stroke.velocities = [40.0, 50.0, 60.0] + + assert stroke.duration == 0.5 + assert stroke.distance == 25.0 + assert stroke.mean_speed == 50.0 + assert stroke.velocities == [40.0, 50.0, 60.0] + + +def test_grid_cell_stores_strokes() -> None: + """GridCell should hold Stroke objects in its strokes list.""" + cell = models.GridCell(x_min=0.0, x_max=30.0, y_min=70.0, y_max=100.0) + stroke_a = models.Stroke(points=_make_points(line_number=0), line_number=0) + stroke_b = models.Stroke(points=_make_points(line_number=1), line_number=1) + + cell.strokes.append(stroke_a) + cell.strokes.append(stroke_b) + + assert len(cell.strokes) == 2 + assert cell.strokes[0].line_number == 0 + assert cell.strokes[1].line_number == 1 + + +def test_grid_cell_strokes_default_empty() -> None: + """GridCell should default to an empty strokes list.""" + cell = models.GridCell(x_min=0.0, x_max=10.0, y_min=0.0, y_max=10.0) + assert cell.strokes == [] + + +def test_grid_cell_strokes_not_shared() -> None: + """Each GridCell should have its own independent strokes list.""" + cell_a = models.GridCell(x_min=0.0, x_max=10.0, y_min=0.0, y_max=10.0) + cell_b = models.GridCell(x_min=10.0, x_max=20.0, y_min=0.0, y_max=10.0) + + cell_a.strokes.append( + models.Stroke(points=_make_points(line_number=0), line_number=0) + ) + + assert len(cell_a.strokes) == 1 + assert len(cell_b.strokes) == 0 From ccf853e02ab6f29523f462ba853f42ee9e9fdc4a Mon Sep 17 00:00:00 2001 From: Iktae Kim Date: Tue, 17 Mar 2026 10:05:03 -0400 Subject: [PATCH 2/2] minor changes to docstring and removing an unnecessary test --- src/graphomotor/core/models.py | 17 ++++++++--------- tests/unit/test_stroke.py | 13 ------------- 2 files changed, 8 insertions(+), 22 deletions(-) diff --git a/src/graphomotor/core/models.py b/src/graphomotor/core/models.py index 1ff4364..9329694 100644 --- a/src/graphomotor/core/models.py +++ b/src/graphomotor/core/models.py @@ -193,22 +193,21 @@ def contains_points(self, points: pd.DataFrame) -> bool: class Stroke: """Represents a single stroke in an Alphabet or DSYM task. - Similar to LineSegment for Trails, this class holds stroke data and - computed features. Features are populated by utility functions after - initialization. + This class holds stroke data and computed features. Features are populated by + utility functions after initialization. Attributes: points: DataFrame with columns including 'x', 'y', and 'seconds'. line_number: The line number identifying this stroke in the raw data. - duration: Total time spent drawing the stroke. - distance: Total distance of the stroke path. - mean_speed: Mean drawing speed. + duration: Total time (s) spent drawing the stroke. + distance: Total distance (px) of the stroke path. + mean_speed: Mean drawing speed (px/s). speed_variance: Variance of drawing speed. smoothness: Smoothness of the stroke based on curvature changes. hesitation_count: Number of hesitations during the stroke. - hesitation_duration: Total duration of hesitations. - velocities: List of velocities at each point in the stroke. - accelerations: List of accelerations at each point in the stroke. + hesitation_duration: Total duration of hesitations (s). + velocities: List of velocities at each point in the stroke (px/s). + accelerations: List of accelerations at each point in the stroke (px/s²). """ points: pd.DataFrame diff --git a/tests/unit/test_stroke.py b/tests/unit/test_stroke.py index ec4c3fb..3b478d7 100644 --- a/tests/unit/test_stroke.py +++ b/tests/unit/test_stroke.py @@ -74,16 +74,3 @@ def test_grid_cell_strokes_default_empty() -> None: """GridCell should default to an empty strokes list.""" cell = models.GridCell(x_min=0.0, x_max=10.0, y_min=0.0, y_max=10.0) assert cell.strokes == [] - - -def test_grid_cell_strokes_not_shared() -> None: - """Each GridCell should have its own independent strokes list.""" - cell_a = models.GridCell(x_min=0.0, x_max=10.0, y_min=0.0, y_max=10.0) - cell_b = models.GridCell(x_min=10.0, x_max=20.0, y_min=0.0, y_max=10.0) - - cell_a.strokes.append( - models.Stroke(points=_make_points(line_number=0), line_number=0) - ) - - assert len(cell_a.strokes) == 1 - assert len(cell_b.strokes) == 0