From 8fdd123c4b3751eb41a1c095c667052a924af5f2 Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Tue, 9 Dec 2025 10:00:20 -0500 Subject: [PATCH 01/10] path optimality function --- .../features/trails/drawing_metrics.py | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/graphomotor/features/trails/drawing_metrics.py b/src/graphomotor/features/trails/drawing_metrics.py index 5e2fa76..aa008a1 100644 --- a/src/graphomotor/features/trails/drawing_metrics.py +++ b/src/graphomotor/features/trails/drawing_metrics.py @@ -1,5 +1,7 @@ """Feature extraction module for drawing error-based metrics in trails drawing data.""" +import scipy.spatial.distance as dist + from graphomotor.core import models @@ -29,3 +31,31 @@ def get_total_errors(drawing: models.Drawing) -> dict[str, float]: "Drawing data does not contain 'total_number_of_errors' column." ) return {"total_errors": drawing.data["total_number_of_errors"].iloc[0]} + + +def calculate_path_optimality( + segment: models.LineSegment, + start_circle: models.CircleTarget, + end_circle: models.CircleTarget, +) -> None: + """Calculate path optimality ratio. + + Args: + segment: LineSegment object for which to calculate path optimality. + start_circle: CircleTarget representing the start circle. + end_circle: CircleTarget representing the end circle. + + Returns: + Path optimality ratio. + """ + optimal_distance = ( + dist.euclidean( + [start_circle.cx, start_circle.cy], [end_circle.cx, end_circle.cy] + ) + - start_circle.radius + - end_circle.radius + ) + + if optimal_distance > 0: + segment.path_optimality = optimal_distance / segment.distance + return From 9b3ea8cdc2ac63de2637b615fdb7d5c5b503972f Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Tue, 9 Dec 2025 10:07:20 -0500 Subject: [PATCH 02/10] fix function and first unit test --- .../features/trails/drawing_metrics.py | 3 ++- tests/unit/test_trails_drawing_metrics.py | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/graphomotor/features/trails/drawing_metrics.py b/src/graphomotor/features/trails/drawing_metrics.py index aa008a1..c8160f3 100644 --- a/src/graphomotor/features/trails/drawing_metrics.py +++ b/src/graphomotor/features/trails/drawing_metrics.py @@ -50,7 +50,8 @@ def calculate_path_optimality( """ optimal_distance = ( dist.euclidean( - [start_circle.cx, start_circle.cy], [end_circle.cx, end_circle.cy] + [start_circle.center_x, start_circle.center_y], + [end_circle.center_x, end_circle.center_y], ) - start_circle.radius - end_circle.radius diff --git a/tests/unit/test_trails_drawing_metrics.py b/tests/unit/test_trails_drawing_metrics.py index 56718c4..f17e78b 100644 --- a/tests/unit/test_trails_drawing_metrics.py +++ b/tests/unit/test_trails_drawing_metrics.py @@ -45,3 +45,22 @@ def test_valid_total_errors() -> None: result = drawing_metrics.get_total_errors(drawing) assert result == {"total_errors": 1.0} + + +def test_path_optimality_positive() -> None: + """Test case for path optimality with positive optimal distance.""" + start = models.CircleTarget(order=1, label="1", center_x=0, center_y=0, radius=1) + end = models.CircleTarget(order=2, label="2", center_x=10, center_y=0, radius=1) + segment = models.LineSegment( + start_label="1", + end_label="2", + points=pd.DataFrame(), + is_error=False, + line_number=1, + distance=8, + ) + + drawing_metrics.calculate_path_optimality(segment, start, end) + + expected_optimal_distance = 10 - 1 - 1 + assert segment.path_optimality == expected_optimal_distance / 8 From dfd8ea95d30621424c4678843d948fa300229616 Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Tue, 9 Dec 2025 10:11:26 -0500 Subject: [PATCH 03/10] unit tests --- tests/unit/test_trails_drawing_metrics.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/unit/test_trails_drawing_metrics.py b/tests/unit/test_trails_drawing_metrics.py index f17e78b..db701ca 100644 --- a/tests/unit/test_trails_drawing_metrics.py +++ b/tests/unit/test_trails_drawing_metrics.py @@ -2,6 +2,7 @@ import pandas as pd import pytest +import scipy.spatial.distance as dist from graphomotor.core import models from graphomotor.features.trails import drawing_metrics @@ -64,3 +65,21 @@ def test_path_optimality_positive() -> None: expected_optimal_distance = 10 - 1 - 1 assert segment.path_optimality == expected_optimal_distance / 8 + + +def test_path_optimality_non_positive_distance() -> None: + """Test case where optimal distance is zero or negative, so no assignment occurs.""" + start = models.CircleTarget(order=1, label="1", center_x=0, center_y=0, radius=5) + end = models.CircleTarget(order=2, label="2", center_x=8, center_y=0, radius=5) + segment = models.LineSegment( + start_label="1", + end_label="2", + points=pd.DataFrame(), + is_error=False, + line_number=1, + distance=5, + ) + + drawing_metrics.calculate_path_optimality(segment, start, end) + + assert segment.path_optimality == 0.0 From 928c213b9e5459b92a14001b72db761b0438836e Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Tue, 9 Dec 2025 10:25:06 -0500 Subject: [PATCH 04/10] remove unused import --- tests/unit/test_trails_drawing_metrics.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit/test_trails_drawing_metrics.py b/tests/unit/test_trails_drawing_metrics.py index db701ca..a12b688 100644 --- a/tests/unit/test_trails_drawing_metrics.py +++ b/tests/unit/test_trails_drawing_metrics.py @@ -2,7 +2,6 @@ import pandas as pd import pytest -import scipy.spatial.distance as dist from graphomotor.core import models from graphomotor.features.trails import drawing_metrics From 92f4b1e6927c821faca2468ef4e45c33f10a05d8 Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Mon, 12 Jan 2026 11:49:26 -0500 Subject: [PATCH 05/10] update docstring --- src/graphomotor/features/trails/drawing_metrics.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/graphomotor/features/trails/drawing_metrics.py b/src/graphomotor/features/trails/drawing_metrics.py index c8160f3..f1db9d3 100644 --- a/src/graphomotor/features/trails/drawing_metrics.py +++ b/src/graphomotor/features/trails/drawing_metrics.py @@ -40,6 +40,12 @@ def calculate_path_optimality( ) -> None: """Calculate path optimality ratio. + The default value for path optimality in the LineSegment object is 0.0. This + function updates the path_optimality attribute of the LineSegment object based on + the optimal distance between the start and end circles, adjusted for their radii. + If the optimal distance is less than or equal to zero, the path optimality remains + 0.0. + Args: segment: LineSegment object for which to calculate path optimality. start_circle: CircleTarget representing the start circle. From 06c4a6789daf09fc0ccc3852338eeabd661a05f2 Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Mon, 12 Jan 2026 11:58:35 -0500 Subject: [PATCH 06/10] correcting unit test format --- tests/unit/test_trails_drawing_metrics.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_trails_drawing_metrics.py b/tests/unit/test_trails_drawing_metrics.py index a12b688..97760a5 100644 --- a/tests/unit/test_trails_drawing_metrics.py +++ b/tests/unit/test_trails_drawing_metrics.py @@ -51,6 +51,8 @@ def test_path_optimality_positive() -> None: """Test case for path optimality with positive optimal distance.""" start = models.CircleTarget(order=1, label="1", center_x=0, center_y=0, radius=1) end = models.CircleTarget(order=2, label="2", center_x=10, center_y=0, radius=1) + expected_optimal_distance = 10 - 1 - 1 + expected_path_optimality = expected_optimal_distance / 8 segment = models.LineSegment( start_label="1", end_label="2", @@ -62,8 +64,7 @@ def test_path_optimality_positive() -> None: drawing_metrics.calculate_path_optimality(segment, start, end) - expected_optimal_distance = 10 - 1 - 1 - assert segment.path_optimality == expected_optimal_distance / 8 + assert segment.path_optimality == expected_path_optimality def test_path_optimality_non_positive_distance() -> None: From 94f316b0f22aeb6455fb88dca57e21cf8c8317ee Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Wed, 14 Jan 2026 11:04:11 -0500 Subject: [PATCH 07/10] move function into LineSegment class and move unit tests --- src/graphomotor/core/models.py | 90 +++++++++++++------ .../features/trails/drawing_metrics.py | 37 -------- tests/unit/test_models.py | 38 ++++++++ tests/unit/test_trails_drawing_metrics.py | 38 -------- 4 files changed, 101 insertions(+), 102 deletions(-) diff --git a/src/graphomotor/core/models.py b/src/graphomotor/core/models.py index 60b416d..fa3291f 100644 --- a/src/graphomotor/core/models.py +++ b/src/graphomotor/core/models.py @@ -7,6 +7,7 @@ import numpy as np import pandas as pd import pydantic +import scipy.spatial.distance as dist class Drawing(pydantic.BaseModel): @@ -119,6 +120,39 @@ def get_extractors( } +@dataclasses.dataclass +class CircleTarget: + """Represents a target circle in the drawing task. + + Attributes: + order: The order of the circle in the sequence. + label: The label of the circle. + center_x: The x-coordinate of the circle's center. + center_y: The y-coordinate of the circle's center. + radius: The radius of the circle. + """ + + order: int + label: str + center_x: float + center_y: float + radius: float + + def contains_point(self, x: float, y: float, tolerance: float = 1.5) -> bool: + """Check if a point is within the circle (with tolerance multiplier). + + Args: + x: X coordinate of the point. + y: Y coordinate of the point. + tolerance: Multiplier for the radius to define tolerance boundary. + + Returns: + True if the point is within the circle (with tolerance), False otherwise. + """ + distance = np.sqrt((x - self.center_x) ** 2 + (y - self.center_y) ** 2) + return distance <= (self.radius * tolerance) + + @dataclasses.dataclass class LineSegment: """Represents a line drawn between two circles. @@ -164,35 +198,37 @@ class LineSegment: velocities: typing.List[float] = dataclasses.field(default_factory=list) accelerations: typing.List[float] = dataclasses.field(default_factory=list) - -@dataclasses.dataclass -class CircleTarget: - """Represents a target circle in the drawing task. - - Attributes: - order: The order of the circle in the sequence. - label: The label of the circle. - center_x: The x-coordinate of the circle's center. - center_y: The y-coordinate of the circle's center. - radius: The radius of the circle. - """ - - order: int - label: str - center_x: float - center_y: float - radius: float - - def contains_point(self, x: float, y: float, tolerance: float = 1.5) -> bool: - """Check if a point is within the circle (with tolerance multiplier). + @classmethod + def calculate_path_optimality( + cls, + start_circle: CircleTarget, + end_circle: CircleTarget, + ) -> None: + """Calculate path optimality ratio. + + The default value for path optimality in the LineSegment object is 0.0. This + function updates the path_optimality attribute of the LineSegment object based on + the optimal distance between the start and end circles, adjusted for their radii. + If the optimal distance is less than or equal to zero, the path optimality remains + 0.0. Args: - x: X coordinate of the point. - y: Y coordinate of the point. - tolerance: Multiplier for the radius to define tolerance boundary. + segment: LineSegment object for which to calculate path optimality. + start_circle: CircleTarget representing the start circle. + end_circle: CircleTarget representing the end circle. Returns: - True if the point is within the circle (with tolerance), False otherwise. + Path optimality ratio. """ - distance = np.sqrt((x - self.center_x) ** 2 + (y - self.center_y) ** 2) - return distance <= (self.radius * tolerance) + optimal_distance = ( + dist.euclidean( + [start_circle.center_x, start_circle.center_y], + [end_circle.center_x, end_circle.center_y], + ) + - start_circle.radius + - end_circle.radius + ) + + if optimal_distance > 0: + cls.path_optimality = optimal_distance / cls.distance + return diff --git a/src/graphomotor/features/trails/drawing_metrics.py b/src/graphomotor/features/trails/drawing_metrics.py index f1db9d3..5e2fa76 100644 --- a/src/graphomotor/features/trails/drawing_metrics.py +++ b/src/graphomotor/features/trails/drawing_metrics.py @@ -1,7 +1,5 @@ """Feature extraction module for drawing error-based metrics in trails drawing data.""" -import scipy.spatial.distance as dist - from graphomotor.core import models @@ -31,38 +29,3 @@ def get_total_errors(drawing: models.Drawing) -> dict[str, float]: "Drawing data does not contain 'total_number_of_errors' column." ) return {"total_errors": drawing.data["total_number_of_errors"].iloc[0]} - - -def calculate_path_optimality( - segment: models.LineSegment, - start_circle: models.CircleTarget, - end_circle: models.CircleTarget, -) -> None: - """Calculate path optimality ratio. - - The default value for path optimality in the LineSegment object is 0.0. This - function updates the path_optimality attribute of the LineSegment object based on - the optimal distance between the start and end circles, adjusted for their radii. - If the optimal distance is less than or equal to zero, the path optimality remains - 0.0. - - Args: - segment: LineSegment object for which to calculate path optimality. - start_circle: CircleTarget representing the start circle. - end_circle: CircleTarget representing the end circle. - - Returns: - Path optimality ratio. - """ - optimal_distance = ( - dist.euclidean( - [start_circle.center_x, start_circle.center_y], - [end_circle.center_x, end_circle.center_y], - ) - - start_circle.radius - - end_circle.radius - ) - - if optimal_distance > 0: - segment.path_optimality = optimal_distance / segment.distance - return diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index 9a2302e..2025956 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -95,3 +95,41 @@ def test_point_outside_with_default_tolerance(circle: models.CircleTarget) -> No """Point outside default tolerance boundary should not be contained.""" assert not circle.contains_point(16.0, 0.0) assert not circle.contains_point(0.0, 16.0) + + +def test_path_optimality_positive() -> None: + """Test case for path optimality with positive optimal distance.""" + start = models.CircleTarget(order=1, label="1", center_x=0, center_y=0, radius=1) + end = models.CircleTarget(order=2, label="2", center_x=10, center_y=0, radius=1) + expected_optimal_distance = 10 - 1 - 1 + expected_path_optimality = expected_optimal_distance / 8 + segment = models.LineSegment( + start_label="1", + end_label="2", + points=pd.DataFrame(), + is_error=False, + line_number=1, + distance=8, + ) + + segment.calculate_path_optimality(start, end) + + assert segment.path_optimality == expected_path_optimality + + +def test_path_optimality_non_positive_distance() -> None: + """Test case where optimal distance is zero or negative, so no assignment occurs.""" + start = models.CircleTarget(order=1, label="1", center_x=0, center_y=0, radius=5) + end = models.CircleTarget(order=2, label="2", center_x=8, center_y=0, radius=5) + segment = models.LineSegment( + start_label="1", + end_label="2", + points=pd.DataFrame(), + is_error=False, + line_number=1, + distance=5, + ) + + segment.calculate_path_optimality(start, end) + + assert segment.path_optimality == 0.0 diff --git a/tests/unit/test_trails_drawing_metrics.py b/tests/unit/test_trails_drawing_metrics.py index 97760a5..56718c4 100644 --- a/tests/unit/test_trails_drawing_metrics.py +++ b/tests/unit/test_trails_drawing_metrics.py @@ -45,41 +45,3 @@ def test_valid_total_errors() -> None: result = drawing_metrics.get_total_errors(drawing) assert result == {"total_errors": 1.0} - - -def test_path_optimality_positive() -> None: - """Test case for path optimality with positive optimal distance.""" - start = models.CircleTarget(order=1, label="1", center_x=0, center_y=0, radius=1) - end = models.CircleTarget(order=2, label="2", center_x=10, center_y=0, radius=1) - expected_optimal_distance = 10 - 1 - 1 - expected_path_optimality = expected_optimal_distance / 8 - segment = models.LineSegment( - start_label="1", - end_label="2", - points=pd.DataFrame(), - is_error=False, - line_number=1, - distance=8, - ) - - drawing_metrics.calculate_path_optimality(segment, start, end) - - assert segment.path_optimality == expected_path_optimality - - -def test_path_optimality_non_positive_distance() -> None: - """Test case where optimal distance is zero or negative, so no assignment occurs.""" - start = models.CircleTarget(order=1, label="1", center_x=0, center_y=0, radius=5) - end = models.CircleTarget(order=2, label="2", center_x=8, center_y=0, radius=5) - segment = models.LineSegment( - start_label="1", - end_label="2", - points=pd.DataFrame(), - is_error=False, - line_number=1, - distance=5, - ) - - drawing_metrics.calculate_path_optimality(segment, start, end) - - assert segment.path_optimality == 0.0 From 534edd53e6d39301181626e73088b922c54b79d1 Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Wed, 14 Jan 2026 11:16:34 -0500 Subject: [PATCH 08/10] fix unit test error --- src/graphomotor/core/models.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/graphomotor/core/models.py b/src/graphomotor/core/models.py index fa3291f..1b164a3 100644 --- a/src/graphomotor/core/models.py +++ b/src/graphomotor/core/models.py @@ -198,9 +198,8 @@ class LineSegment: velocities: typing.List[float] = dataclasses.field(default_factory=list) accelerations: typing.List[float] = dataclasses.field(default_factory=list) - @classmethod def calculate_path_optimality( - cls, + self, start_circle: CircleTarget, end_circle: CircleTarget, ) -> None: @@ -230,5 +229,5 @@ def calculate_path_optimality( ) if optimal_distance > 0: - cls.path_optimality = optimal_distance / cls.distance + self.path_optimality = optimal_distance / self.distance return From 6ec45ffb2cd0f8bde26879400efed4530b302137 Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Wed, 14 Jan 2026 11:18:01 -0500 Subject: [PATCH 09/10] ruff errors --- src/graphomotor/core/models.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/graphomotor/core/models.py b/src/graphomotor/core/models.py index 1b164a3..57a348a 100644 --- a/src/graphomotor/core/models.py +++ b/src/graphomotor/core/models.py @@ -206,10 +206,10 @@ def calculate_path_optimality( """Calculate path optimality ratio. The default value for path optimality in the LineSegment object is 0.0. This - function updates the path_optimality attribute of the LineSegment object based on - the optimal distance between the start and end circles, adjusted for their radii. - If the optimal distance is less than or equal to zero, the path optimality remains - 0.0. + function updates the path_optimality attribute of the LineSegment object based + on the optimal distance between the start and end circles, adjusted for their + radii. If the optimal distance is less than or equal to zero, the path + optimality remains 0.0. Args: segment: LineSegment object for which to calculate path optimality. From f4e6852b653f6031f3d46de8c75259b2a3d63ff4 Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Thu, 15 Jan 2026 10:42:18 -0500 Subject: [PATCH 10/10] edit unit test to be more modular --- tests/unit/test_models.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index 2025956..e158f47 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -101,8 +101,6 @@ def test_path_optimality_positive() -> None: """Test case for path optimality with positive optimal distance.""" start = models.CircleTarget(order=1, label="1", center_x=0, center_y=0, radius=1) end = models.CircleTarget(order=2, label="2", center_x=10, center_y=0, radius=1) - expected_optimal_distance = 10 - 1 - 1 - expected_path_optimality = expected_optimal_distance / 8 segment = models.LineSegment( start_label="1", end_label="2", @@ -111,6 +109,10 @@ def test_path_optimality_positive() -> None: line_number=1, distance=8, ) + expected_optimal_distance = ( + end.center_x - start.center_x - start.radius - end.radius + ) + expected_path_optimality = expected_optimal_distance / segment.distance segment.calculate_path_optimality(start, end)