neurotask 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. neurotask/__init__.py +0 -0
  2. neurotask/tmt/__init__.py +0 -0
  3. neurotask/tmt/config.py +8 -0
  4. neurotask/tmt/crosses/__init__.py +0 -0
  5. neurotask/tmt/crosses/crosses.py +131 -0
  6. neurotask/tmt/crosses/crosses_metric_calculator.py +20 -0
  7. neurotask/tmt/cut_criteria/__init__.py +0 -0
  8. neurotask/tmt/cut_criteria/cut_criteria.py +6 -0
  9. neurotask/tmt/cut_criteria/cut_implementation.py +146 -0
  10. neurotask/tmt/invalid_cause.py +17 -0
  11. neurotask/tmt/mapper/__init__.py +0 -0
  12. neurotask/tmt/mapper/mapper.py +21 -0
  13. neurotask/tmt/metrics/__init__.py +0 -0
  14. neurotask/tmt/metrics/area_calculation.py +82 -0
  15. neurotask/tmt/metrics/base_metric.py +37 -0
  16. neurotask/tmt/metrics/difference_from_ideal_distance.py +38 -0
  17. neurotask/tmt/metrics/distance_calculation.py +61 -0
  18. neurotask/tmt/metrics/intra_and_inter_target_time.py +148 -0
  19. neurotask/tmt/metrics/metrics_calculator.py +417 -0
  20. neurotask/tmt/metrics/sampling_period.py +89 -0
  21. neurotask/tmt/metrics/speed_metrics.py +228 -0
  22. neurotask/tmt/metrics/targets_touched.py +253 -0
  23. neurotask/tmt/metrics/zig_zag_amplitud.py +52 -0
  24. neurotask/tmt/model/__init__.py +0 -0
  25. neurotask/tmt/model/tmt_model.py +165 -0
  26. neurotask/tmt/preprocessing/__init__.py +0 -0
  27. neurotask/tmt/preprocessing/interpolation.py +62 -0
  28. neurotask/tmt/segmentation/__init__.py +0 -0
  29. neurotask/tmt/segmentation/segmentation.py +539 -0
  30. neurotask/tmt/segmentation/segmentation_metric.py +27 -0
  31. neurotask/tmt/tmt_analyzer.py +188 -0
  32. neurotask/tmt/visualization/__init__.py +0 -0
  33. neurotask/tmt/visualization/utils.py +63 -0
  34. neurotask/tmt/visualization/visualization.py +345 -0
  35. neurotask-0.2.0.dist-info/METADATA +240 -0
  36. neurotask-0.2.0.dist-info/RECORD +39 -0
  37. neurotask-0.2.0.dist-info/WHEEL +5 -0
  38. neurotask-0.2.0.dist-info/licenses/LICENSE +21 -0
  39. neurotask-0.2.0.dist-info/top_level.txt +1 -0
neurotask/__init__.py ADDED
File without changes
File without changes
@@ -0,0 +1,8 @@
1
+ """
2
+ Centralized configuration for TMT analysis.
3
+ """
4
+
5
+ # Maximum valid speed threshold (in pixels/millisecond).
6
+ # Speeds above this value are considered measurement errors.
7
+ INVALID_SPEED_THRESHOLD = 8.0
8
+
File without changes
@@ -0,0 +1,131 @@
1
+ from typing import List, Tuple
2
+
3
+ from ..model.tmt_model import TMTTrial, Coordinate
4
+
5
+ def calculate_crosses_for_trial(
6
+ trial: TMTTrial,
7
+ crosses_time_threshold: float,
8
+ ) -> Tuple[int, List[Tuple[Tuple[Coordinate, Coordinate], Tuple[Coordinate, Coordinate], float]]]:
9
+ """
10
+ Calculates the number of times the cursor trail crosses itself, excluding segments that are very near in time.
11
+
12
+ Parameters:
13
+ - trial: TMTTrial object containing the cursor_trail.
14
+ - crosses_time_threshold: float, the minimum time gap (in milliseconds) between segments to consider them for intersection.
15
+ Segments with gap < threshold are excluded as "too close in time".
16
+
17
+ Returns:
18
+ - num_crosses: int, the number of times the cursor trail crosses itself.
19
+ - cross_segments: list of tuples: (seg1, seg2, time_gap) for each detected crossing.
20
+ time_gap is in milliseconds.
21
+ """
22
+ cursor_trail = trial.get_cursor_trail_from_start()
23
+
24
+ # Not enough points to form non-adjacent segments
25
+ if len(cursor_trail) < 4:
26
+ return 0, []
27
+
28
+ # Build segments with endpoint times
29
+ segments: List[Tuple[Tuple[Coordinate, float], Tuple[Coordinate, float]]] = []
30
+ for i in range(len(cursor_trail) - 1):
31
+ p1 = cursor_trail[i].position
32
+ p2 = cursor_trail[i + 1].position
33
+ t1 = cursor_trail[i].time
34
+ t2 = cursor_trail[i + 1].time
35
+ segments.append(((p1, t1), (p2, t2)))
36
+
37
+ num_crosses = 0
38
+ cross_segments: List[Tuple[Tuple[Coordinate, Coordinate], Tuple[Coordinate, Coordinate], float]] = []
39
+
40
+ for i in range(len(segments)):
41
+ for j in range(i + 1, len(segments)):
42
+ # Skip adjacent segments (they share a point)
43
+ if j == i + 1:
44
+ continue
45
+
46
+ # Time interval for each segment
47
+ t_start_i = min(segments[i][0][1], segments[i][1][1])
48
+ t_end_i = max(segments[i][0][1], segments[i][1][1])
49
+ t_start_j = min(segments[j][0][1], segments[j][1][1])
50
+ t_end_j = max(segments[j][0][1], segments[j][1][1])
51
+
52
+ # Compute gap between time intervals (0 if overlapping)
53
+ if t_end_i < t_start_j:
54
+ time_gap = t_start_j - t_end_i
55
+ elif t_end_j < t_start_i:
56
+ time_gap = t_start_i - t_end_j
57
+ else:
58
+ time_gap = 0.0
59
+
60
+ # Skip if segments are too close in time
61
+ if time_gap < crosses_time_threshold:
62
+ continue
63
+
64
+ seg1 = (segments[i][0][0], segments[i][1][0])
65
+ seg2 = (segments[j][0][0], segments[j][1][0])
66
+
67
+ if segments_intersect(seg1[0], seg1[1], seg2[0], seg2[1]):
68
+ num_crosses += 1
69
+ cross_segments.append((seg1, seg2, time_gap))
70
+
71
+ return num_crosses, cross_segments
72
+
73
+
74
+
75
+ def orientation(p: Coordinate, q: Coordinate, r: Coordinate) -> int:
76
+ """
77
+ Determines the orientation of the triplet (p, q, r).
78
+
79
+ Returns:
80
+ - 0 : Colinear
81
+ - 1 : Clockwise
82
+ - 2 : Counterclockwise
83
+ """
84
+ val = (q.y - p.y) * (r.x - q.x) - (q.x - p.x) * (r.y - q.y)
85
+ if abs(val) < 1e-10:
86
+ return 0 # Colinear
87
+ elif val > 0:
88
+ return 1 # Clockwise
89
+ else:
90
+ return 2 # Counterclockwise
91
+
92
+
93
+ def on_segment(p: Coordinate, q: Coordinate, r: Coordinate) -> bool:
94
+ """
95
+ Checks if point q lies on segment pr.
96
+ """
97
+ if min(p.x, r.x) <= q.x <= max(p.x, r.x) and \
98
+ min(p.y, r.y) <= q.y <= max(p.y, r.y):
99
+ return True
100
+ return False
101
+
102
+
103
+ def segments_intersect(p1: Coordinate, q1: Coordinate, p2: Coordinate, q2: Coordinate) -> bool:
104
+ """
105
+ Checks if two line segments (p1,q1) and (p2,q2) intersect.
106
+ """
107
+ # Find the four orientations needed for general and special cases
108
+ o1 = orientation(p1, q1, p2)
109
+ o2 = orientation(p1, q1, q2)
110
+ o3 = orientation(p2, q2, p1)
111
+ o4 = orientation(p2, q2, q1)
112
+
113
+ # General case
114
+ if o1 != o2 and o3 != o4:
115
+ return True
116
+
117
+ # Special Cases
118
+ # p1, q1 and p2 are colinear and p2 lies on segment p1q1
119
+ if o1 == 0 and on_segment(p1, p2, q1):
120
+ return True
121
+ # p1, q1 and q2 are colinear and q2 lies on segment p1q1
122
+ if o2 == 0 and on_segment(p1, q2, q1):
123
+ return True
124
+ # p2, q2 and p1 are colinear and p1 lies on segment p2q2
125
+ if o3 == 0 and on_segment(p2, p1, q2):
126
+ return True
127
+ # p2, q2 and q1 are colinear and q1 lies on segment p2q2
128
+ if o4 == 0 and on_segment(p2, q1, q2):
129
+ return True
130
+
131
+ return False
@@ -0,0 +1,20 @@
1
+ import numpy as np
2
+ from neurotask.tmt.crosses.crosses import calculate_crosses_for_trial
3
+ from neurotask.tmt.metrics.base_metric import BaseMetricCalculator
4
+ from neurotask.tmt.model.tmt_model import TMTTrial, TMTSubject, TMTTarget, CursorInfo
5
+
6
+
7
+ class CrossesMetricCalculator(BaseMetricCalculator):
8
+ def add_metrics(self, metrics: dict, trial: TMTTrial, subject: TMTSubject,
9
+ trails_between_targets: list[tuple[TMTTarget, list[CursorInfo]]], calculate_crosses: bool,
10
+ speed_threshold, consecutive_points, target_radius_multiplier: float,
11
+ crosses_time_threshold: float) -> dict:
12
+ metric_key = self.get_metric_name("number_of_crosses")
13
+
14
+ if calculate_crosses:
15
+ num_crosses, _ = calculate_crosses_for_trial(trial, crosses_time_threshold)
16
+ metrics[metric_key] = num_crosses
17
+ else:
18
+ metrics[metric_key] = np.nan
19
+
20
+ return metrics
File without changes
@@ -0,0 +1,6 @@
1
+ from enum import Enum
2
+
3
+
4
+ class CutCriteria(Enum):
5
+
6
+ MINIMUM_TARGETS = "MINIMUM_TARGETS"
@@ -0,0 +1,146 @@
1
+ from typing import Optional
2
+
3
+ from neurotask.tmt.cut_criteria.cut_criteria import CutCriteria
4
+ from neurotask.tmt.metrics.intra_and_inter_target_time import get_intra_target_intervals
5
+ from neurotask.tmt.metrics.targets_touched import count_correctly_touched_targets
6
+ from neurotask.tmt.model.tmt_model import TMTTrial, TMTSubject, TMTTarget, CursorInfo
7
+
8
+
9
+ def cut_trial(
10
+ trial: TMTTrial,
11
+ correct_targets_minimum: Optional[int],
12
+ subject: TMTSubject,
13
+ subject_id: str,
14
+ cut_criteria: CutCriteria,
15
+ target_radius_multiplier: float
16
+ ) -> TMTTrial:
17
+ """
18
+ Process the given trial based on the specified cut criteria.
19
+
20
+ Args:
21
+ trial (TMTTrial): The trial to process.
22
+ correct_targets_minimum (Optional[int]): The minimum correct target touches required.
23
+ subject (TMTSubject): The subject associated with the trial.
24
+ subject_id (str): Unique identifier for the subject.
25
+ cut_criteria (CutCriteria): The criteria used to cut the trial.
26
+ target_radius_multiplier (float): Multiplier to apply to the target radius.
27
+
28
+ Returns:
29
+ TMTTrial: The processed (cut) trial.
30
+
31
+ Raises:
32
+ ValueError: If the cut criteria is invalid or if required parameters are missing.
33
+ """
34
+ if cut_criteria == CutCriteria.MINIMUM_TARGETS:
35
+ if correct_targets_minimum is None:
36
+ raise ValueError("Minimum targets criteria requires a minimum number of correct targets.")
37
+ return cut_trial_at_minimum_targets(correct_targets_minimum, subject, subject_id, trial, target_radius_multiplier)
38
+
39
+ raise ValueError(f"Invalid cut criteria: {cut_criteria}")
40
+
41
+
42
+ def cut_trial_at_minimum_targets(
43
+ correct_targets_minimum: int,
44
+ subject: TMTSubject,
45
+ subject_id: str,
46
+ trial: TMTTrial,
47
+ target_radius_multiplier: float
48
+ ) -> TMTTrial:
49
+ """
50
+ Cuts the trial at the point where the minimum number of correct target touches is reached.
51
+
52
+ Args:
53
+ correct_targets_minimum (int): The minimum number of correct touches required.
54
+ subject (TMTSubject): The subject associated with the trial.
55
+ subject_id (str): Unique identifier for the subject.
56
+ trial (TMTTrial): The trial to be processed.
57
+ target_radius_multiplier (float): Multiplier to apply to the target radius.
58
+
59
+ Returns:
60
+ TMTTrial: The trial cut at the minimum correct target touch.
61
+
62
+ Raises:
63
+ ValueError: If the trial does not meet the required number of correct target touches.
64
+ """
65
+ correct_targets_touches = count_correctly_touched_targets(trial, subject.target_radius, target_radius_multiplier)
66
+
67
+ if correct_targets_touches < correct_targets_minimum:
68
+ raise ValueError(
69
+ f"Trial {trial.id} of subject {subject_id} has {correct_targets_touches} correct target touches, "
70
+ f"but the minimum required is {correct_targets_minimum}.")
71
+
72
+ return cut_trial_at_minimum_correct_targets(trial, correct_targets_minimum, subject, target_radius_multiplier)
73
+
74
+
75
+ def cut_trial_at_minimum_correct_targets(
76
+ trial: TMTTrial,
77
+ correct_targets_minimum: int,
78
+ subject: TMTSubject,
79
+ target_radius_multiplier: float
80
+ ) -> TMTTrial:
81
+ """
82
+ Cuts the trial at the time corresponding to reaching the minimum correct target touches.
83
+
84
+ Args:
85
+ trial (TMTTrial): The trial to be cut.
86
+ correct_targets_minimum (int): The required number of correct touches.
87
+ subject (TMTSubject): The subject with target_radius used to determine correct touches.
88
+ target_radius_multiplier (float): The radius multiplier to use for target detection.
89
+
90
+ Returns:
91
+ TMTTrial: The trial cut at the appropriate time.
92
+
93
+ Raises:
94
+ ValueError: If the trial does not contain the required number of correct target segments.
95
+ """
96
+ intra_target_interval: list[tuple[TMTTarget, CursorInfo, CursorInfo]] = get_intra_target_intervals(
97
+ trial, subject.target_radius, target_radius_multiplier
98
+ )
99
+
100
+ if len(intra_target_interval) < correct_targets_minimum:
101
+ raise ValueError(f"Trial {trial.id} has less than {correct_targets_minimum} correct targets.")
102
+
103
+ # Get the Nth interval (where N = correct_targets_minimum)
104
+ # Since correct_touched_targets_for_every_cursor_point ensures targets are touched in order,
105
+ # the Nth interval corresponds to the Nth target in trial.stimuli
106
+ cutoff_interval = intra_target_interval[correct_targets_minimum - 1]
107
+
108
+ # Use the start cursor (index 1) to cut when the target is first touched
109
+ cursor_info = cutoff_interval[1]
110
+
111
+ return cut_at_time(trial, cursor_info.time, correct_targets_minimum)
112
+
113
+
114
+ def cut_at_time(
115
+ trial: TMTTrial,
116
+ cutoff_time: float,
117
+ correct_targets_minimum: int
118
+ ) -> TMTTrial:
119
+ """
120
+ Cuts the trial at the specified cutoff time.
121
+
122
+ This function creates a new trial instance where the cursor trail is truncated
123
+ at the given cutoff time and only the first 'correct_targets_minimum' stimuli are retained.
124
+
125
+ Args:
126
+ trial (TMTTrial): The trial to be cut.
127
+ cutoff_time (float): The time at which to cut the trial.
128
+ correct_targets_minimum (int): The number of stimuli to retain.
129
+
130
+ Returns:
131
+ TMTTrial: A new trial instance cut at the specified time.
132
+ """
133
+ new_cursor_trail = [cursor for cursor in trial.cursor_trail if cursor.time <= cutoff_time]
134
+ new_rt = cutoff_time # Ensure this behavior is as intended.
135
+ new_stimuli = trial.stimuli[:correct_targets_minimum]
136
+
137
+ return TMTTrial(
138
+ id=trial.id,
139
+ stimuli=new_stimuli,
140
+ cursor_trail=new_cursor_trail,
141
+ rt=new_rt,
142
+ trial_type=trial.trial_type,
143
+ order_of_appearance=trial.order_of_appearance,
144
+ with_custom_start=trial.with_custom_start,
145
+ start=trial.start
146
+ )
@@ -0,0 +1,17 @@
1
+ from enum import Enum
2
+
3
+
4
+ class InvalidCause(Enum):
5
+ """
6
+ Enumeration of possible causes of invalid trials in TMT.
7
+ """
8
+
9
+ CUT_CRITERIA_ERROR = "CUT_CRITERIA_ERROR"
10
+ UNKNOWN_ERROR = "UNKNOWN_ERROR"
11
+ UNDER_CORRECT_TARGETS_MINIMUM = "UNDER_CORRECT_TARGETS_MINIMUM"
12
+ INVALID_LENGTH = "INVALID_LENGTH"
13
+ INVALID_START_CONFIGURATION = "INVALID_START_CONFIGURATION"
14
+ INVALID_MODEL = "INVALID_MODEL"
15
+ UNABLE_TO_DETERMINE_START = "UNABLE_DETERMINE_START"
16
+ INVALID_SPEED = "INVALID_SPEED"
17
+ NON_MONOTONIC_TIME = "NON_MONOTONIC_TIME"
File without changes
@@ -0,0 +1,21 @@
1
+ from abc import abstractmethod, ABC
2
+ from typing import Optional
3
+
4
+ from ..model.tmt_model import TMTExperiment
5
+
6
+
7
+ class TMTMapper(ABC):
8
+
9
+ @abstractmethod
10
+ def map(self, data_path: str) -> TMTExperiment:
11
+ """
12
+ Método abstracto para mapear los datos (y opcionalmente la metadata) a una instancia de TMTExperiment.
13
+
14
+ Parameters:
15
+ - data_path: str -> La ruta al archivo de datos.
16
+ - metadata_path: Optional[str] -> La ruta al archivo de metadata. (Opcional)
17
+
18
+ Returns:
19
+ - TMTExperiment -> Instancia de TMTExperiment mapeada a partir de los datos (y la metadata si aplica).
20
+ """
21
+ pass
File without changes
@@ -0,0 +1,82 @@
1
+ from typing import List
2
+
3
+ import numpy as np
4
+ from neurotask.tmt.metrics.base_metric import BaseMetricCalculator
5
+ from neurotask.tmt.model.tmt_model import CursorInfo, TMTTarget, TMTTrial, TMTSubject
6
+
7
+ # np.trapz se eliminó en numpy 2.4; np.trapezoid existe desde numpy 2.0
8
+ _trapezoid = getattr(np, "trapezoid", None) or np.trapz
9
+
10
+
11
+ def area_between_real_and_ideal(segment: List[CursorInfo]) -> float:
12
+ # 1) Extraer coordenadas (x, y) de los puntos reales
13
+ point_coords = np.array([[cursor_info.position.x, cursor_info.position.y] for cursor_info in segment], dtype=float)
14
+
15
+ return area_between_real_and_ideal_points(point_coords)
16
+
17
+
18
+ def area_between_real_and_ideal_points(point_coords: np.ndarray) -> float:
19
+ """
20
+ Integra la distancia perpendicular absoluta entre la trayectoria real
21
+ y la línea recta ideal que une el primer y último punto del segmento.
22
+
23
+ :param segment: lista de CursorInfo reales
24
+ :return: área bajo la curva de desviación, >= 0
25
+ """
26
+ num_points = len(point_coords)
27
+ if num_points < 2:
28
+ return 0.0
29
+
30
+ # 2) Definir el vector ideal desde el primer al último punto
31
+ start_point = point_coords[0]
32
+ end_point = point_coords[-1]
33
+ ideal_vector = end_point - start_point
34
+ ideal_length_sq = np.dot(ideal_vector, ideal_vector)
35
+ ideal_length = np.sqrt(ideal_length_sq)
36
+
37
+ # 3) Proyectar cada punto real sobre la línea ideal
38
+ # a) desplazamientos desde el inicio
39
+ offsets = point_coords - start_point[np.newaxis, :]
40
+ # b) factor de proyección t ∈ [0,1] sobre el vector ideal
41
+ projection_factors = (offsets @ ideal_vector) / ideal_length_sq
42
+ # c) puntos proyectados en la línea ideal
43
+ projection_points = start_point + projection_factors[:, np.newaxis] * ideal_vector
44
+
45
+ # 4) Calcular distancia perpendicular de cada punto real a la ideal
46
+ perpendicular_distances = np.linalg.norm(point_coords - projection_points, axis=1)
47
+
48
+ # 5) Coordenadas escalarizadas a lo largo de la línea ideal
49
+ # (distancia desde el inicio a cada proyección)
50
+ line_positions = projection_factors * ideal_length
51
+
52
+ # 6) Integración por la regla del trapecio
53
+ area = _trapezoid(perpendicular_distances, line_positions)
54
+
55
+ return float(area)
56
+
57
+
58
+ class DifferenceFromIdealArea(BaseMetricCalculator):
59
+
60
+ def add_metrics(self, metrics: dict, trial: TMTTrial, subject: TMTSubject,
61
+ trails_between_targets: list[tuple[TMTTarget, list[CursorInfo]]], calculate_crosses: bool,
62
+ speed_threshold, consecutive_points, target_radius_multiplier: float,
63
+ crosses_time_threshold: float) -> dict:
64
+
65
+ areas = []
66
+ for trail in trails_between_targets:
67
+ target, cursor_trail = trail
68
+ if target is None:
69
+ continue
70
+ area = area_between_real_and_ideal(cursor_trail)
71
+ areas.append(area)
72
+
73
+ if areas:
74
+ metrics[self.get_metric_name('area_difference_from_ideal')] = float(np.mean(areas))
75
+ else:
76
+ raise ValueError(
77
+ f"Cannot calculate area_difference_from_ideal: No valid target segments found. "
78
+ f"Trial {trial.id} has {len(trails_between_targets)} trails_between_targets, "
79
+ f"but none had valid targets (all targets were None or segments were invalid)."
80
+ )
81
+
82
+ return metrics
@@ -0,0 +1,37 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Optional
3
+
4
+ from neurotask.tmt.model.tmt_model import TMTTrial, TMTSubject, TMTTarget, CursorInfo
5
+
6
+
7
+ class BaseMetricCalculator(ABC):
8
+ def __init__(self, prefix: Optional[str] = None):
9
+ self.prefix = prefix
10
+
11
+ def get_metric_name(self, metric_name: str) -> str:
12
+ """
13
+ Devuelve el nombre de la métrica con el prefijo si se ha definido.
14
+ """
15
+ if self.prefix:
16
+ return f'{self.prefix}{metric_name}'
17
+ return metric_name
18
+
19
+ @abstractmethod
20
+ def add_metrics(self, metrics: dict, trial: TMTTrial, subject: TMTSubject,
21
+ trails_between_targets: list[tuple[TMTTarget, list[CursorInfo]]], calculate_crosses: bool,
22
+ speed_threshold, consecutive_points, target_radius_multiplier: float,
23
+ crosses_time_threshold: float) -> dict:
24
+ """
25
+ Añade las claves/valores de esta métrica al dict `metrics`.
26
+ `trial` es tu objeto TMTTrial (o las estructuras que uses).
27
+ """
28
+ pass
29
+
30
+
31
+ class ReactionTimeCalculator(BaseMetricCalculator):
32
+ def add_metrics(self, metrics: dict, trial: TMTTrial, subject: TMTSubject,
33
+ trails_between_targets: list[tuple[TMTTarget, list[CursorInfo]]], calculate_crosses: bool,
34
+ speed_threshold, consecutive_points, target_radius_multiplier: float,
35
+ crosses_time_threshold: float) -> dict:
36
+ metrics[self.get_metric_name('rt')] = trial.rt
37
+ return metrics
@@ -0,0 +1,38 @@
1
+ import numpy as np
2
+ from neurotask.tmt.metrics.base_metric import BaseMetricCalculator
3
+ from neurotask.tmt.metrics.distance_calculation import calculate_distance, calculate_total_distance_from_segment
4
+ from neurotask.tmt.model.tmt_model import TMTTrial, CursorInfo, TMTTarget, TMTSubject
5
+
6
+
7
+ class DifferenceFromIdealDistance(BaseMetricCalculator):
8
+
9
+ def add_metrics(self, metrics: dict, trial: TMTTrial, subject: TMTSubject,
10
+ trails_between_targets: list[tuple[TMTTarget, list[CursorInfo]]], calculate_crosses: bool,
11
+ speed_threshold, consecutive_points, target_radius_multiplier: float,
12
+ crosses_time_threshold: float) -> dict:
13
+
14
+ differences = []
15
+ for trail in trails_between_targets:
16
+ target, cursor_trail = trail
17
+ if target is None:
18
+ continue
19
+ difference = self.calculate_distance_difference_from_ideal(cursor_trail)
20
+ differences.append(difference)
21
+
22
+ if differences:
23
+ metrics[self.get_metric_name('distance_difference_from_ideal')] = float(np.mean(differences))
24
+ else:
25
+ raise ValueError(
26
+ f"Cannot calculate distance_difference_from_ideal: No valid target segments found. "
27
+ f"Trial {trial.id} has {len(trails_between_targets)} trails_between_targets, "
28
+ f"but none had valid targets (all targets were None or segments were invalid)."
29
+ )
30
+
31
+ return metrics
32
+
33
+ def calculate_distance_difference_from_ideal(self, segment: list[CursorInfo]) -> float:
34
+
35
+ segment_distance = calculate_total_distance_from_segment(segment)
36
+ ideal_distance = calculate_distance(segment[0].position, segment[-1].position)
37
+
38
+ return abs(segment_distance - ideal_distance)
@@ -0,0 +1,61 @@
1
+ import math
2
+ from typing import List
3
+
4
+ from .base_metric import BaseMetricCalculator
5
+ from ..model.tmt_model import Coordinate, TMTTrial, CursorInfo, TMTSubject, TMTTarget
6
+
7
+
8
+ class TotalDistanceCalculator(BaseMetricCalculator):
9
+ def add_metrics(self, metrics: dict, trial: TMTTrial, subject: TMTSubject,
10
+ trails_between_targets: list[tuple[TMTTarget, list[CursorInfo]]], calculate_crosses: bool,
11
+ speed_threshold, consecutive_points, target_radius_multiplier: float,
12
+ crosses_time_threshold: float) -> dict:
13
+ metrics[self.get_metric_name('total_distance')] = calculate_total_distance(trial)
14
+ return metrics
15
+
16
+
17
+ def calculate_distance(pos1: Coordinate, pos2: Coordinate) -> float:
18
+ dx = pos1.x - pos2.x
19
+ dy = pos1.y - pos2.y
20
+ return math.hypot(dx, dy)
21
+
22
+
23
+ def is_inside_target(
24
+ cursor_pos: Coordinate,
25
+ target: TMTTarget,
26
+ target_radius: float,
27
+ multiplier: float
28
+ ) -> bool:
29
+ """
30
+ Determine if the cursor is inside the target's effective radius.
31
+
32
+ :param cursor_pos: The cursor position.
33
+ :param target: The target to check.
34
+ :param target_radius: The base target radius.
35
+ :param multiplier: The radius multiplier.
36
+ :return: True if cursor is inside the target's effective radius.
37
+ """
38
+ effective_radius = target_radius * multiplier
39
+ distance = calculate_distance(cursor_pos, target.position)
40
+ return distance < effective_radius
41
+
42
+
43
+ def calculate_total_distance(trial: TMTTrial):
44
+ """
45
+ Calculate the total distance of the cursor trail in a TMT trial.
46
+
47
+ :param trial: TMTTrial object containing the cursor trail.
48
+ :return: Total distance traveled by the cursor.
49
+ """
50
+ cursor_trail_from_first_click = trial.get_cursor_trail_from_start()
51
+ return sum(
52
+ calculate_distance(cursor_trail_from_first_click[i].position, cursor_trail_from_first_click[i + 1].position)
53
+ for i in range(len(cursor_trail_from_first_click) - 1)
54
+ )
55
+
56
+
57
+ def calculate_total_distance_from_segment(segment_trial: List[CursorInfo]) -> float:
58
+ return sum(
59
+ calculate_distance(segment_trial[i].position, segment_trial[i + 1].position)
60
+ for i in range(len(segment_trial) - 1)
61
+ )