Skip to content

Commit 815c2ff

Browse files
cgmaioranoAsanto32
andauthored
71 task write trails utility functions valid ink trajectory (#87)
* wrote valid_ink_trajectory * wrote parameterized scenarios for testing valid_ink_trajectory * update docstring * copilot changes * ruff format * fix imports * Update trails_utils.py Not sure where that exception came from? --------- Co-authored-by: Adam Santorelli <148909356+Asanto32@users.noreply.github.com>
1 parent fdb6b6d commit 815c2ff

2 files changed

Lines changed: 135 additions & 2 deletions

File tree

src/graphomotor/utils/trails_utils.py

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,54 @@
11
"""Utility functions for trails management."""
22

3-
from typing import Dict, List
3+
from typing import Dict, List, Optional, Tuple
44

55
import pandas as pd
66

77
from graphomotor.core import models
88

99

10+
def valid_ink_trajectory(
11+
points: pd.DataFrame,
12+
start_circle: models.CircleTarget,
13+
end_circle: models.CircleTarget,
14+
) -> Tuple[Optional[int], Optional[int]]:
15+
"""Determine whether an ink trajectory exists from a start circle to an end circle.
16+
17+
An "ink trajectory" is defined as the first contiguous sequence of points that:
18+
1. Begins **after** the pen leaves the start circle, and
19+
2. Ends when the pen first enters the end circle.
20+
21+
The function scans point-by-point in order. The ink start index is the first point
22+
whose (x, y) location is *outside* the start circle. The ink end index is the first
23+
subsequent point whose (x, y) location falls *inside* the end circle. If either of
24+
these conditions never occurs, the trajectory is considered invalid.
25+
26+
Args:
27+
points: DataFrame of points with 'x' and 'y' columns.
28+
start_circle: CircleTarget representing the start circle.
29+
end_circle: CircleTarget representing the end circle.
30+
31+
Returns:
32+
Tuple of (ink_start_idx: int, ink_end_idx: int) if valid trajectory exists,
33+
else (None, None).
34+
"""
35+
ink_start_idx = None
36+
ink_end_idx = None
37+
38+
for idx, row in points.iterrows():
39+
if (
40+
not start_circle.contains_point(row["x"], row["y"])
41+
and ink_start_idx is None
42+
):
43+
ink_start_idx = idx
44+
45+
if ink_start_idx is not None and end_circle.contains_point(row["x"], row["y"]):
46+
ink_end_idx = idx
47+
break
48+
49+
return ink_start_idx, ink_end_idx
50+
51+
1052
def segment_lines(
1153
trail_data: pd.DataFrame,
1254
trail_id: str,
@@ -30,7 +72,7 @@ def segment_lines(
3072
Raises:
3173
KeyError: If trail_id is not found in circles dictionary.
3274
ValueError: If actual_path values are invalid.
33-
""" # noqa: E501
75+
"""
3476
if trail_id not in circles:
3577
raise KeyError("Trail ID not found in circles dictionary.")
3678

tests/unit/test_trails_utils.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,97 @@
99
from graphomotor.utils import trails_utils
1010

1111

12+
@pytest.mark.parametrize(
13+
"points_data,start_params,end_params,expected_start,expected_end,test_id",
14+
[
15+
(
16+
{"x": [0, 1, 2, 3, 4, 5], "y": [0, 1, 2, 3, 4, 5]},
17+
{"order": 1, "label": "A", "center_x": 0, "center_y": 0, "radius": 0.5},
18+
{"order": 2, "label": "B", "center_x": 5, "center_y": 5, "radius": 0.5},
19+
1,
20+
5,
21+
"valid_trajectory",
22+
),
23+
(
24+
{"x": [0.1, 0.2, 0.3], "y": [0.1, 0.2, 0.3]},
25+
{"order": 1, "label": "A", "center_x": 0, "center_y": 0, "radius": 1.0},
26+
{"order": 2, "label": "B", "center_x": 10, "center_y": 10, "radius": 1.0},
27+
None,
28+
None,
29+
"no_exit_from_start",
30+
),
31+
(
32+
{"x": [0, 1, 2, 3], "y": [0, 1, 2, 3]},
33+
{"order": 1, "label": "A", "center_x": 0, "center_y": 0, "radius": 0.5},
34+
{"order": 2, "label": "B", "center_x": 10, "center_y": 10, "radius": 0.5},
35+
1,
36+
None,
37+
"never_reaches_end",
38+
),
39+
(
40+
{"x": [], "y": []},
41+
{"order": 1, "label": "A", "center_x": 0, "center_y": 0, "radius": 1.0},
42+
{"order": 2, "label": "B", "center_x": 5, "center_y": 5, "radius": 1.0},
43+
None,
44+
None,
45+
"empty_dataframe",
46+
),
47+
(
48+
{"x": [0.1], "y": [0.1]},
49+
{"order": 1, "label": "A", "center_x": 0, "center_y": 0, "radius": 1.0},
50+
{"order": 2, "label": "B", "center_x": 5, "center_y": 5, "radius": 1.0},
51+
None,
52+
None,
53+
"single_point_in_start",
54+
),
55+
(
56+
{"x": [2], "y": [2]},
57+
{"order": 1, "label": "A", "center_x": 0, "center_y": 0, "radius": 0.5},
58+
{"order": 2, "label": "B", "center_x": 5, "center_y": 5, "radius": 0.5},
59+
0,
60+
None,
61+
"single_point_outside_start",
62+
),
63+
(
64+
{"x": [0, 2.5, 5], "y": [0, 2.5, 5]},
65+
{"order": 1, "label": "A", "center_x": 0, "center_y": 0, "radius": 0.5},
66+
{"order": 2, "label": "B", "center_x": 2.5, "center_y": 2.5, "radius": 1.0},
67+
1,
68+
1,
69+
"immediate_transition",
70+
),
71+
(
72+
{"x": [3, 4, 5], "y": [3, 4, 5]},
73+
{"order": 1, "label": "A", "center_x": 0, "center_y": 0, "radius": 0.5},
74+
{"order": 2, "label": "B", "center_x": 5, "center_y": 5, "radius": 0.5},
75+
0,
76+
2,
77+
"first_point_outside_start",
78+
),
79+
],
80+
ids=lambda x: x if isinstance(x, str) else "",
81+
)
82+
def test_valid_ink_trajectory_scenarios(
83+
points_data: Dict[str, list],
84+
start_params: Dict,
85+
end_params: Dict,
86+
expected_start: int,
87+
expected_end: int,
88+
test_id: str,
89+
) -> None:
90+
"""Test various trajectory scenarios between start and end circles."""
91+
points = pd.DataFrame(points_data)
92+
start_circle = models.CircleTarget(**start_params)
93+
end_circle = models.CircleTarget(**end_params)
94+
95+
ink_start, ink_end = trails_utils.valid_ink_trajectory(
96+
points, start_circle, end_circle
97+
)
98+
99+
assert ink_start == expected_start, f"Failed on {test_id}: ink_start"
100+
assert ink_end == expected_end, f"Failed on {test_id}: ink_end"
101+
102+
12103
@pytest.fixture
13104
def circles() -> Dict[str, Dict[str, models.CircleTarget]]:
14105
"""Return a minimal set of CircleTarget dictionaries for each trail."""

0 commit comments

Comments
 (0)