Skip to content

Commit f8a3e1d

Browse files
cgmaioranoAsanto32
andauthored
97 task write trails velocity feature functions detect hesitations correct (#117)
* wrote calculate_velocity_metrics * add distance assign * new classmethod for validating dataframe does not contain duplicate timestamps * added UTC_timestap check to validator and wrote unit test * adjust unit test for validator * fixing other unit tests by adding required columns * update validate data function to only apply to trails * revert validator test back to only seconds * added new line checking for task name in metadata * remove validator function - will be its own new issue * ruff reformat * remove divide by 0 replacement * unit tests for velocity function * fix small bug from merge from main into branch * ruff reformat * wrote detect_hesitations * adjusting the functions for ink_points which no longer will be in the class but will be calculated outside for sake of simplicity * made ink_points a class attribute again and fixed all unit tests * ruff reformat * ruff remvoe unused import * ruff format * updating requested fixes * fix tests and ruff * Update src/graphomotor/core/models.py Co-authored-by: Adam Santorelli <148909356+Asanto32@users.noreply.github.com> * edit --------- Co-authored-by: Adam Santorelli <148909356+Asanto32@users.noreply.github.com>
1 parent bb3af88 commit f8a3e1d

2 files changed

Lines changed: 159 additions & 19 deletions

File tree

src/graphomotor/core/models.py

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,6 @@ class LineSegment:
189189
points: pd.DataFrame
190190
is_error: bool
191191
line_number: int
192-
ink_points: np.ndarray = dataclasses.field(default_factory=lambda: np.array([]))
193192

194193
ink_time: float = 0.0
195194
think_time: float = 0.0
@@ -203,6 +202,7 @@ class LineSegment:
203202
hesitation_duration: float = 0.0
204203
velocities: List[float] = dataclasses.field(default_factory=list)
205204
accelerations: List[float] = dataclasses.field(default_factory=list)
205+
ink_points: pd.DataFrame = dataclasses.field(default_factory=pd.DataFrame)
206206

207207
def valid_ink_trajectory(
208208
self,
@@ -230,7 +230,7 @@ def valid_ink_trajectory(
230230
end_circle: CircleTarget representing the end circle.
231231
232232
Returns:
233-
Tuple of (ink_start_idx: int, ink_end_idx: int) if valid
233+
Tuple of (ink_start_idx: Optional[int], ink_end_idx: Optional[int]) if valid
234234
trajectory exists, else (None, None).
235235
"""
236236
ink_start_idx = None
@@ -248,7 +248,6 @@ def valid_ink_trajectory(
248248
):
249249
ink_end_idx = idx
250250
break
251-
252251
if (
253252
ink_start_idx is not None
254253
and ink_end_idx is not None
@@ -292,16 +291,15 @@ def calculate_path_optimality(
292291
self.path_optimality = optimal_distance / self.distance
293292
return
294293

295-
def calculate_velocity_metrics(self, ink_points: pd.DataFrame) -> None:
296-
"""Get distance, velocity, and acceleration metrics of a LineSegment.
294+
def calculate_velocity_metrics(self) -> None:
295+
"""Get velocity metrics of a LineSegment.
297296
298297
Args:
299298
self: LineSegment object to calculate velocities for.
300-
ink_points: DataFrame of ink points with 'x', 'y', and 'seconds' columns.
301299
"""
302-
dx = np.diff(ink_points["x"].values)
303-
dy = np.diff(ink_points["y"].values)
304-
dt = np.diff(ink_points["seconds"].values)
300+
dx = np.diff(self.ink_points["x"].values)
301+
dy = np.diff(self.ink_points["y"].values)
302+
dt = np.diff(self.ink_points["seconds"].values)
305303

306304
distances = np.sqrt(dx**2 + dy**2)
307305
self.distance = np.sum(distances)
@@ -316,3 +314,41 @@ def calculate_velocity_metrics(self, ink_points: pd.DataFrame) -> None:
316314
self.accelerations = np.diff(velocities).tolist()
317315

318316
return
317+
318+
def detect_hesitations(self, threshold_percentile: int = 20) -> None:
319+
"""Detect hesitations as periods of significantly reduced velocity.
320+
321+
This function defines a hesitation as any period where the velocity falls below
322+
a certain threshold, which is determined by the specified percentile of the
323+
velocity distribution. It counts the number of distinct hesitation periods and
324+
adds 1 if the line starts with a hesitation. It also calculates the total
325+
duration of hesitations based on the number of points that fall below the
326+
threshold and the time interval between points.
327+
328+
hesitation_count defaults to 0 and hesitation_duration defaults to 0.0 in the
329+
LineSegment object if there are less than 3 velocity points. This function also
330+
assumes uniform sampling.
331+
332+
Args:
333+
threshold_percentile: Percentile to determine the velocity threshold for
334+
hesitations (default is 20, meaning the bottom 20% of velocities are
335+
considered hesitations).
336+
"""
337+
if len(self.velocities) < 3:
338+
return
339+
340+
dt = np.diff(self.ink_points["seconds"].values)
341+
342+
threshold_velocity = np.percentile(self.velocities, threshold_percentile)
343+
hesitations = self.velocities < threshold_velocity
344+
345+
hesitation_changes = np.diff(hesitations.astype(int))
346+
hesitation_count = np.sum(hesitation_changes == 1)
347+
348+
if hesitations[0]:
349+
hesitation_count += 1
350+
351+
self.hesitation_count = hesitation_count
352+
self.hesitation_duration = np.sum(hesitations) * dt[0]
353+
354+
return

tests/unit/test_models.py

Lines changed: 114 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import datetime
44
from typing import Dict, cast
55

6-
import numpy as np
76
import pandas as pd
87
import pytest
98

@@ -256,15 +255,16 @@ def test_uniform_motion() -> None:
256255
points=points,
257256
is_error=False,
258257
line_number=1,
258+
ink_points=points, # Pre-assign ink_points for velocity calculation
259259
)
260260

261-
segment.calculate_velocity_metrics(points)
261+
segment.calculate_velocity_metrics()
262262

263263
assert segment.distance == 3.0
264264
assert segment.mean_speed == 1.0
265265
assert segment.speed_variance == 0.0
266-
assert np.all(segment.velocities) == 1.0
267-
assert np.all(segment.accelerations) == 0.0
266+
assert segment.velocities == [1.0, 1.0, 1.0]
267+
assert segment.accelerations == [0.0, 0.0]
268268

269269

270270
def test_accelerating_motion() -> None:
@@ -282,13 +282,14 @@ def test_accelerating_motion() -> None:
282282
points=points,
283283
is_error=False,
284284
line_number=1,
285+
ink_points=points, # Pre-assign ink_points for velocity calculation
285286
)
286287

287-
segment.calculate_velocity_metrics(points)
288+
segment.calculate_velocity_metrics()
288289

289290
assert segment.distance == 9.0
290291
assert segment.mean_speed == 3.0
291-
assert segment.speed_variance == pytest.approx(2.6666666666666665)
292+
assert segment.speed_variance > 0.0
292293
assert segment.velocities == [1.0, 3.0, 5.0]
293294
assert segment.accelerations == [2.0, 2.0]
294295

@@ -308,15 +309,16 @@ def test_velocity_two_points_only() -> None:
308309
points=points,
309310
is_error=False,
310311
line_number=1,
312+
ink_points=points, # Pre-assign ink_points for velocity calculation
311313
)
312314

313-
segment.calculate_velocity_metrics(points)
315+
segment.calculate_velocity_metrics()
314316

315317
assert segment.distance == 5.0
316318
assert segment.mean_speed == 2.5
317319
assert segment.speed_variance == 0.0
318320
assert segment.velocities == [2.5]
319-
assert segment.accelerations == []
321+
assert segment.accelerations == [] # No acceleration with only one velocity point
320322

321323

322324
def test_decelerating_motion() -> None:
@@ -334,9 +336,10 @@ def test_decelerating_motion() -> None:
334336
points=points,
335337
is_error=False,
336338
line_number=1,
339+
ink_points=points, # Pre-assign ink_points for velocity calculation
337340
)
338341

339-
segment.calculate_velocity_metrics(points)
342+
segment.calculate_velocity_metrics()
340343

341344
assert segment.distance == 9.0
342345
assert segment.mean_speed == 3.0
@@ -360,12 +363,113 @@ def test_stationary_motion() -> None:
360363
points=points,
361364
is_error=False,
362365
line_number=1,
366+
ink_points=points, # Pre-assign ink_points for velocity calculation
363367
)
364368

365-
segment.calculate_velocity_metrics(points)
369+
segment.calculate_velocity_metrics()
366370

367371
assert segment.distance == 0.0
368372
assert segment.mean_speed == 0.0
369373
assert segment.speed_variance == 0.0
370374
assert segment.velocities == [0.0, 0.0]
371375
assert segment.accelerations == [0.0]
376+
377+
378+
def test_no_hesitations_uniform_motion() -> None:
379+
"""Test with uniform motion where all velocities are equal."""
380+
points = pd.DataFrame(
381+
{
382+
"x": [0, 1, 2, 3],
383+
"y": [0, 0, 0, 0],
384+
"seconds": [0, 1, 2, 3],
385+
}
386+
)
387+
segment = models.LineSegment(
388+
start_label="1",
389+
end_label="2",
390+
points=points,
391+
is_error=False,
392+
line_number=1,
393+
ink_points=points, # Pre-assign ink_points for velocity calculation
394+
)
395+
396+
segment.calculate_velocity_metrics()
397+
segment.detect_hesitations()
398+
399+
assert segment.hesitation_count == 0
400+
assert segment.hesitation_duration == 0.0
401+
402+
403+
def test_hesitation_at_start() -> None:
404+
"""Test when the line starts with a hesitation."""
405+
points = pd.DataFrame(
406+
{
407+
"x": [0, 0.1, 1, 2],
408+
"y": [0, 0.1, 0, 0],
409+
"seconds": [0, 1, 2, 3],
410+
}
411+
)
412+
segment = models.LineSegment(
413+
start_label="1",
414+
end_label="2",
415+
points=points,
416+
is_error=False,
417+
line_number=1,
418+
ink_points=points, # Pre-assign ink_points for velocity calculation
419+
)
420+
421+
segment.calculate_velocity_metrics()
422+
segment.detect_hesitations()
423+
424+
assert segment.hesitation_count == 1
425+
assert segment.hesitation_duration == 1.0
426+
427+
428+
def test_multiple_hesitations() -> None:
429+
"""Test when there are multiple hesitation periods."""
430+
points = pd.DataFrame(
431+
{
432+
"x": [0, 100, 100.1, 200, 200.1, 300, 400, 500, 600],
433+
"y": [0, 0, 0, 0, 0, 0, 0, 0, 0],
434+
"seconds": [0, 1, 2, 3, 4, 5, 6, 7, 8],
435+
}
436+
)
437+
segment = models.LineSegment(
438+
start_label="1",
439+
end_label="2",
440+
points=points,
441+
is_error=False,
442+
line_number=1,
443+
ink_points=points, # Pre-assign ink_points for velocity calculation
444+
)
445+
446+
segment.calculate_velocity_metrics()
447+
segment.detect_hesitations()
448+
449+
assert segment.hesitation_count == 2
450+
assert segment.hesitation_duration == 2.0
451+
452+
453+
def test_less_than_three_velocities() -> None:
454+
"""Test early return when velocities length is less than 3."""
455+
points = pd.DataFrame(
456+
{
457+
"x": [0, 1],
458+
"y": [0, 0],
459+
"seconds": [0, 1],
460+
}
461+
)
462+
segment = models.LineSegment(
463+
start_label="1",
464+
end_label="2",
465+
points=points,
466+
is_error=False,
467+
line_number=1,
468+
ink_points=points, # Pre-assign ink_points for velocity calculation
469+
)
470+
471+
segment.calculate_velocity_metrics()
472+
segment.detect_hesitations()
473+
474+
assert segment.hesitation_count == 0
475+
assert segment.hesitation_duration == 0.0

0 commit comments

Comments
 (0)