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
@@ -0,0 +1,148 @@
1
+ from neurotask.tmt.metrics.base_metric import BaseMetricCalculator
2
+ from neurotask.tmt.metrics.targets_touched import correct_touched_targets_for_every_cursor_point
3
+ from neurotask.tmt.model.tmt_model import TMTTrial, TMTSubject, TMTTarget, CursorInfo
4
+
5
+
6
+ class TargetTime(BaseMetricCalculator):
7
+ def add_metrics(self, metrics: dict, trial: TMTTrial, subject: TMTSubject,
8
+ trails_between_targets: list[tuple[TMTTarget, list[CursorInfo]]], calculate_crosses: bool,
9
+ speed_threshold, consecutive_points, target_radius_multiplier: float,
10
+ crosses_time_threshold: float) -> dict:
11
+
12
+ if subject is None:
13
+ raise ValueError("Subject must be provided")
14
+
15
+ intra_time = calculate_intra_target_time(trial, subject.target_radius, target_radius_multiplier)
16
+ metrics[self.get_metric_name('intra_target_time')] = intra_time
17
+ metrics[self.get_metric_name('inter_target_time')] = calculate_inter_target_time(trial, intra_time)
18
+
19
+ return metrics
20
+
21
+
22
+ def get_intra_target_intervals(
23
+ trial: TMTTrial,
24
+ target_radius: float,
25
+ multiplier: float
26
+ ) -> list[tuple[TMTTarget, CursorInfo, CursorInfo]]:
27
+ """
28
+ Identify continuous intervals where the cursor is on a correct target.
29
+
30
+ Returns a list of tuples (target, start_cursor, end_cursor) representing
31
+ continuous periods where the cursor is on the correct expected target.
32
+
33
+ An interval starts when the cursor enters a correct target and ends when:
34
+ - The cursor leaves the target (moves to a non-target position)
35
+ - The cursor moves to a different correct target
36
+ - We reach the last cursor point (interval ends at that point)
37
+
38
+ :param trial: TMTTrial instance
39
+ :param target_radius: The target radius for touch detection
40
+ :param multiplier: The radius multiplier
41
+ :return: List of (target, start_cursor, end_cursor) tuples
42
+ """
43
+ if not trial.cursor_trail:
44
+ return []
45
+
46
+ # Get correct touched targets for every cursor point
47
+ correct_touches = correct_touched_targets_for_every_cursor_point(trial, target_radius, multiplier)
48
+
49
+ if not correct_touches:
50
+ return []
51
+
52
+ intervals = []
53
+ current_interval_target = None
54
+ current_interval_start_cursor = None
55
+
56
+ for i in range(len(correct_touches)):
57
+ target, cursor_info = correct_touches[i]
58
+
59
+ if target is not None:
60
+ # We're on a correct target
61
+ if current_interval_target is None:
62
+ # Start a new interval
63
+ current_interval_target = target
64
+ current_interval_start_cursor = cursor_info
65
+ elif current_interval_target != target:
66
+ # Different target - close previous interval at current point and start new one
67
+ intervals.append((current_interval_target, current_interval_start_cursor, cursor_info))
68
+
69
+ # Start new interval
70
+ current_interval_target = target
71
+ current_interval_start_cursor = cursor_info
72
+ else:
73
+ # Not on a target - close current interval if exists
74
+ if current_interval_target is not None:
75
+ # Close interval at current point (when we left the target)
76
+ intervals.append((current_interval_target, current_interval_start_cursor, cursor_info))
77
+
78
+ current_interval_target = None
79
+ current_interval_start_cursor = None
80
+
81
+ # Close any open interval at the end
82
+ if current_interval_target is not None:
83
+ # Use the last cursor as end of interval
84
+ last_cursor = correct_touches[-1][1]
85
+ intervals.append((current_interval_target, current_interval_start_cursor, last_cursor))
86
+
87
+ return intervals
88
+
89
+
90
+ def calculate_intra_target_time(
91
+ trial: TMTTrial,
92
+ target_radius: float,
93
+ multiplier: float
94
+ ) -> float:
95
+ """
96
+ Calculate the intra-target time for a given trial and subject.
97
+ The intra-target time is defined as the sum of the times spent within each target area.
98
+
99
+ This function first identifies continuous intervals where the cursor is on correct targets,
100
+ then sums the duration of all those intervals.
101
+
102
+ :param trial: TMTTrial instance
103
+ :param target_radius: The target radius for touch detection
104
+ :param multiplier: The radius multiplier
105
+ :return: Total time spent on correct targets in seconds
106
+ """
107
+ # Get all intervals where cursor is on correct targets
108
+ intervals = get_intra_target_intervals(trial, target_radius, multiplier)
109
+
110
+ # Sum the duration of all intervals
111
+ total_time = sum(end_cursor.time - start_cursor.time for _, start_cursor, end_cursor in intervals)
112
+
113
+ return total_time
114
+
115
+
116
+ def calculate_inter_target_time(
117
+ trial: TMTTrial,
118
+ intra_time
119
+ ) -> float:
120
+ """
121
+ Calculate the inter-target time for a given trial and subject.
122
+ The inter-target time is defined as the time spent moving between targets
123
+ (i.e., not on any correct target).
124
+
125
+ This is calculated as: (last_cursor_time - first_cursor_time) - intra-target time
126
+
127
+ :param trial: TMTTrial instance
128
+ :param intra_time: Pre-calculated intra-target time
129
+ :return: Total time spent between targets in seconds
130
+ """
131
+ # Get the cursor trail (respecting custom start if present)
132
+
133
+ cursor_trail = trial.get_cursor_trail_from_start()
134
+
135
+ # If no cursor trail, return 0
136
+ if not cursor_trail:
137
+ return 0.0
138
+
139
+ # Calculate total time from first to last cursor point
140
+ total_time = cursor_trail[-1].time - cursor_trail[0].time
141
+
142
+ # Get the intra-target time
143
+
144
+ # Inter-target time is the complement: total time minus time on targets
145
+ inter_time = total_time - intra_time
146
+
147
+ return inter_time
148
+
@@ -0,0 +1,417 @@
1
+ import logging
2
+ from typing import List, Dict, Any, Optional
3
+
4
+ import numpy as np
5
+ import pandas as pd
6
+
7
+ from neurotask.tmt.crosses.crosses_metric_calculator import CrossesMetricCalculator
8
+ from neurotask.tmt.metrics.area_calculation import DifferenceFromIdealArea
9
+ from neurotask.tmt.metrics.difference_from_ideal_distance import DifferenceFromIdealDistance
10
+ from neurotask.tmt.metrics.intra_and_inter_target_time import TargetTime
11
+ from neurotask.tmt.metrics.sampling_period import SamplingPeriodCalculator
12
+ from neurotask.tmt.metrics.speed_metrics import SpeedMetricsCalculator, InvalidSpeedError, NonMonotonicTimeError
13
+ from neurotask.tmt.metrics.zig_zag_amplitud import ZigZagAmplitude
14
+ from .base_metric import ReactionTimeCalculator, BaseMetricCalculator
15
+ from .distance_calculation import TotalDistanceCalculator
16
+ from .targets_touched import TargetsTouchesCalculator, get_all_trails_between_targets, \
17
+ count_correctly_touched_targets
18
+ from ..cut_criteria.cut_criteria import CutCriteria
19
+ from ..cut_criteria.cut_implementation import cut_trial
20
+ from ..invalid_cause import InvalidCause
21
+ from ..model.tmt_model import TMTExperiment, TMTSubject, TMTTrial, TMTTarget, CursorInfo
22
+ from ..segmentation.segmentation import calculate_speed_threshold_for_all_subjects
23
+ from ..segmentation.segmentation_metric import SegmentationMetricCalculator
24
+
25
+
26
+ def generate_rows_for_subject(subject_id: str, subject: TMTSubject, correct_targets_minimum: int,
27
+ speed_threshold: float, consecutive_points: int, cut_criteria: CutCriteria,
28
+ calculate_crosses: bool, target_radius_multiplier: float,
29
+ crosses_time_threshold: float) -> List[Dict[str, Any]]:
30
+ """
31
+ Generate a list of row dictionaries, each describing metrics and information
32
+ for valid and invalid trials of a single subject.
33
+
34
+ For each trial:
35
+ 1. If the trial is invalid from the start, mark it as invalid.
36
+ 2. Otherwise, if cut criteria are provided, attempt to cut the trial.
37
+ If cutting fails, mark the trial as invalid.
38
+ 3. Compute the number of correct and incorrect target touches.
39
+ If the correct targets count is below the minimum, mark the trial invalid.
40
+ 4. If all checks pass, compute trial metrics and combine with general trial info.
41
+
42
+ :param subject_id: A unique identifier for the subject.
43
+ :param subject: The subject object containing personal info and trials.
44
+ :param correct_targets_minimum: The minimum number of correct target touches required.
45
+ :param speed_threshold: The speed threshold for calculations.
46
+ :param consecutive_points: The number of consecutive points to consider.
47
+ :param cut_criteria: The criteria to use for cutting trials; if None, no cutting is performed.
48
+ :param calculate_crosses: Whether to calculate crosses.
49
+ :param target_radius_multiplier: Multiplier to apply to the target radius.
50
+ :return: A list of dictionaries, each representing a trial (valid or invalid).
51
+ """
52
+ rows = []
53
+
54
+ for trial in subject.testing_trials:
55
+ # Check initial validity.
56
+ try:
57
+ if not trial.is_valid():
58
+ logging.warning(f"Trial {trial.id} of subject {subject_id} is not valid from the mapper.")
59
+ rows.append(
60
+ create_invalid_trial_row(
61
+ subject, subject_id, trial, speed_threshold,
62
+ error_msg="Trial is_valid function return false."
63
+ )
64
+ )
65
+ continue
66
+ except Exception as e:
67
+ logging.exception(f"Error checking validity of trial {trial.id} for subject {subject_id}: {e}")
68
+ rows.append(
69
+ create_invalid_trial_row(
70
+ subject, subject_id, trial, speed_threshold,
71
+ invalid_cause=InvalidCause.UNABLE_TO_DETERMINE_START,
72
+ error_msg=str(e)
73
+ )
74
+ )
75
+ continue
76
+
77
+ try:
78
+ processed_trial = trial
79
+
80
+ # Apply cut criteria if provided.
81
+ if cut_criteria is not None:
82
+ processed_trial = _attempt_cut_trial(
83
+ trial, correct_targets_minimum, subject, subject_id, cut_criteria, speed_threshold, rows,
84
+ target_radius_multiplier
85
+ )
86
+ if processed_trial is None:
87
+ continue
88
+
89
+ # Compute target touches.
90
+ correct_touches = count_correctly_touched_targets(
91
+ processed_trial, subject.target_radius, target_radius_multiplier
92
+ )
93
+
94
+ # Check if the trial meets the minimum correct touches.
95
+ if correct_targets_minimum is not None:
96
+ if correct_touches < correct_targets_minimum:
97
+ logging.warning(
98
+ f"Trial {trial.id} of subject {subject_id} has {correct_touches} correct target touches, "
99
+ f"but the minimum required is {correct_targets_minimum}."
100
+ )
101
+ rows.append(
102
+ create_invalid_trial_row(
103
+ subject, subject_id, trial, speed_threshold,
104
+ invalid_cause=InvalidCause.UNDER_CORRECT_TARGETS_MINIMUM,
105
+ error_msg="Not enough correct target touches"
106
+ )
107
+ )
108
+ continue
109
+
110
+ valid_row = general_trial_info(speed_threshold, subject, subject_id, trial)
111
+
112
+ if cut_criteria is not None:
113
+ non_cut_trial_metrics = get_non_cut_trial_metrics(calculate_crosses, consecutive_points, trial,
114
+ speed_threshold, subject, target_radius_multiplier,
115
+ crosses_time_threshold)
116
+ valid_row.update(non_cut_trial_metrics)
117
+
118
+ cut_trial_metrics = get_cut_trial_metrics(calculate_crosses, consecutive_points, processed_trial,
119
+ speed_threshold, subject, target_radius_multiplier,
120
+ crosses_time_threshold)
121
+ valid_row.update(cut_trial_metrics)
122
+
123
+ rows.append(valid_row)
124
+
125
+ except InvalidSpeedError as e:
126
+ print(f"ERROR: Trial {trial.id} for subject {subject_id}: {e}")
127
+ error_msg = str(e)
128
+ logging.exception(f"Invalid speed in trial {trial.id} for subject {subject_id}: {e}")
129
+ rows.append(
130
+ create_invalid_trial_row(
131
+ subject, subject_id, trial, speed_threshold,
132
+ invalid_cause=InvalidCause.INVALID_SPEED,
133
+ error_msg=error_msg
134
+ )
135
+ )
136
+
137
+ except NonMonotonicTimeError as e:
138
+ error_msg = str(e)
139
+ logging.warning(f"Non-monotonic time in trial {trial.id} for subject {subject_id}: {e}")
140
+ rows.append(
141
+ create_invalid_trial_row(
142
+ subject, subject_id, trial, speed_threshold,
143
+ invalid_cause=InvalidCause.NON_MONOTONIC_TIME,
144
+ error_msg=error_msg
145
+ )
146
+ )
147
+
148
+ except Exception as e:
149
+ print(f"ERROR: Trial {trial.id} for subject {subject_id}: {e}")
150
+ error_msg = str(e)
151
+ logging.exception(f"Error processing trial {trial.id} for subject {subject_id}: {e}")
152
+ rows.append(
153
+ create_invalid_trial_row(
154
+ subject, subject_id, trial, speed_threshold,
155
+ invalid_cause=InvalidCause.UNKNOWN_ERROR,
156
+ error_msg=error_msg
157
+ )
158
+ )
159
+
160
+ return rows
161
+
162
+
163
+ def get_cut_trial_metrics(calculate_crosses, consecutive_points, trial, speed_threshold, subject,
164
+ target_radius_multiplier: float, crosses_time_threshold: float):
165
+ metric_calculators = get_cut_trial_metric_calculators()
166
+ return compute_trial_metrics(
167
+ metric_calculators,
168
+ trial,
169
+ subject,
170
+ speed_threshold=speed_threshold,
171
+ consecutive_points=consecutive_points,
172
+ calculate_crosses=calculate_crosses,
173
+ target_radius_multiplier=target_radius_multiplier,
174
+ crosses_time_threshold=crosses_time_threshold,
175
+ )
176
+
177
+
178
+ def get_non_cut_trial_metrics(calculate_crosses, consecutive_points, trial, speed_threshold, subject,
179
+ target_radius_multiplier: float, crosses_time_threshold: float):
180
+ metric_calculators = get_non_cut_trial_metric_calculators()
181
+ return compute_trial_metrics(
182
+ metric_calculators,
183
+ trial,
184
+ subject,
185
+ speed_threshold=speed_threshold,
186
+ consecutive_points=consecutive_points,
187
+ calculate_crosses=calculate_crosses,
188
+ target_radius_multiplier=target_radius_multiplier,
189
+ crosses_time_threshold=crosses_time_threshold,
190
+ )
191
+
192
+
193
+ def get_cut_trial_metric_calculators():
194
+ return [
195
+ ZigZagAmplitude(),
196
+ TotalDistanceCalculator(),
197
+ ReactionTimeCalculator(),
198
+ SpeedMetricsCalculator(),
199
+ SamplingPeriodCalculator(),
200
+ SegmentationMetricCalculator(),
201
+ TargetsTouchesCalculator(),
202
+ CrossesMetricCalculator(),
203
+ DifferenceFromIdealDistance(),
204
+ DifferenceFromIdealArea(),
205
+ TargetTime()
206
+ ]
207
+
208
+
209
+ def get_non_cut_trial_metric_calculators():
210
+ prefix = "non_cut_"
211
+ return [
212
+ ZigZagAmplitude(prefix),
213
+ TotalDistanceCalculator(prefix),
214
+ ReactionTimeCalculator(prefix),
215
+ SpeedMetricsCalculator(prefix),
216
+ SamplingPeriodCalculator(prefix),
217
+ SegmentationMetricCalculator(prefix),
218
+ TargetsTouchesCalculator(prefix),
219
+ CrossesMetricCalculator(prefix),
220
+ DifferenceFromIdealDistance(prefix),
221
+ DifferenceFromIdealArea(prefix),
222
+ TargetTime(prefix)
223
+ ]
224
+
225
+
226
+ def _attempt_cut_trial(
227
+ trial: Any,
228
+ correct_targets_minimum: int,
229
+ subject: TMTSubject,
230
+ subject_id: str,
231
+ cut_criteria: CutCriteria,
232
+ speed_threshold: float,
233
+ rows: List[Dict[str, Any]],
234
+ target_radius_multiplier: float
235
+ ) -> Optional[Any]:
236
+ """
237
+ Attempt to cut a trial using the specified criteria.
238
+
239
+ If cutting fails, the function logs a warning and appends an invalid trial row.
240
+
241
+ :return: The processed trial if successful, or None if an error occurred.
242
+ """
243
+ try:
244
+ return cut_trial(trial, correct_targets_minimum, subject, subject_id, cut_criteria, target_radius_multiplier)
245
+ except Exception as e:
246
+ logging.exception(f"Cut trial error for trial {trial.id} of subject {subject_id}: {e}")
247
+ rows.append(
248
+ create_invalid_trial_row(
249
+ subject, subject_id, trial, speed_threshold,
250
+ invalid_cause=InvalidCause.CUT_CRITERIA_ERROR,
251
+ error_msg=str(e)
252
+ )
253
+ )
254
+ return None
255
+
256
+
257
+ def general_trial_info(speed_threshold, subject, subject_id, trial):
258
+
259
+ trial_row = {
260
+ "subject_id": subject_id,
261
+ "trial_id": trial.id,
262
+ "trial_type": trial.trial_type.name,
263
+ "is_valid": trial.is_valid(),
264
+ "trial_order_of_appearance": trial.order_of_appearance,
265
+ "speed_threshold": speed_threshold
266
+ }
267
+
268
+ session_data: Dict[str, str] = subject.session_data if subject.session_data else {}
269
+ trial_row.update(session_data)
270
+
271
+ return trial_row
272
+
273
+
274
+ def create_invalid_trial_row(
275
+ subject: TMTSubject,
276
+ subject_id: str,
277
+ trial: TMTTrial,
278
+ speed_threshold: float,
279
+ invalid_cause: Optional[InvalidCause] = None,
280
+ error_msg: Optional[str] = None
281
+ ) -> Dict[str, Any]:
282
+ # Si no se pasó una causa, obtenerla del trial
283
+ if invalid_cause is None:
284
+ invalid_cause = trial.get_invalid_cause()
285
+
286
+ invalid_trial_row = {
287
+ "subject_id": subject_id,
288
+ "trial_id": trial.id,
289
+ "total_distance": 0,
290
+ "rt": trial.rt,
291
+ "is_valid": False,
292
+ "trial_type": trial.trial_type.name,
293
+ "trial_order_of_appearance": trial.order_of_appearance,
294
+ "correct_targets_touches": np.nan,
295
+ "wrong_targets_touches": np.nan,
296
+ "speed_threshold": speed_threshold,
297
+ "mean_speed": np.nan,
298
+ "std_speed": np.nan,
299
+ "peak_speed": np.nan,
300
+ "mean_acceleration": np.nan,
301
+ "std_acceleration": np.nan,
302
+ "peak_acceleration": np.nan,
303
+ "hesitation_distance": np.nan,
304
+ "hesitation_time": np.nan,
305
+ "mean_sampling_period": np.nan,
306
+ "median_sampling_period": np.nan,
307
+ "std_sampling_period": np.nan,
308
+ "min_sampling_period": np.nan,
309
+ "max_sampling_period": np.nan,
310
+ "sample_count": np.nan,
311
+ "valid_interval_count": np.nan,
312
+ "invalid_cause": invalid_cause.name
313
+ }
314
+
315
+ session_data: Dict[str, str] = subject.session_data if subject.session_data else {}
316
+ invalid_trial_row.update(session_data)
317
+
318
+ if error_msg:
319
+ invalid_trial_row["error_message"] = error_msg
320
+
321
+ return invalid_trial_row
322
+
323
+
324
+ def compute_trial_metrics(
325
+ metric_calculators: List[BaseMetricCalculator],
326
+ trial: TMTTrial,
327
+ subject: TMTSubject,
328
+ speed_threshold,
329
+ consecutive_points,
330
+ calculate_crosses,
331
+ target_radius_multiplier: float,
332
+ crosses_time_threshold: float,
333
+ ) -> Dict[str, Any]:
334
+ """
335
+ Itera sobre cada calculador de métricas y va acumulando
336
+ sus resultados en un único dict.
337
+ """
338
+ trails_between_targets: list[tuple[TMTTarget, list[CursorInfo]]] = (
339
+ get_all_trails_between_targets(trial, subject.target_radius, target_radius_multiplier)
340
+ )
341
+ metrics: Dict[str, Any] = {}
342
+ for calculator in metric_calculators:
343
+ metrics = calculator.add_metrics(metrics, trial, subject, trails_between_targets, calculate_crosses,
344
+ speed_threshold, consecutive_points, target_radius_multiplier,
345
+ crosses_time_threshold)
346
+ return metrics
347
+
348
+
349
+ def calculate_and_save_metrics(
350
+ experiment: TMTExperiment,
351
+ save_path: str,
352
+ correct_targets_minimum: int,
353
+ consecutive_points: int,
354
+ cut_criteria: CutCriteria,
355
+ calculate_crosses: bool,
356
+ target_radius_multiplier: float,
357
+ crosses_time_threshold: float
358
+ ) -> pd.DataFrame:
359
+ """
360
+ Calculate metrics for each subject in the experiment, save the results to a CSV file,
361
+ and return the aggregated DataFrame.
362
+
363
+ This function computes the speed threshold for each subject, then iterates over all subjects
364
+ to generate trial metric rows using `generate_rows_for_subject`. The resulting rows are aggregated
365
+ into a pandas DataFrame, saved as a CSV file to the specified path, and returned.
366
+
367
+ Args:
368
+ experiment (TMTExperiment): The experiment containing subjects and their trials.
369
+ save_path (str): The file path where the CSV should be saved.
370
+ correct_targets_minimum (int): The minimum number of correct target touches required.
371
+ consecutive_points (int): The number of consecutive points for segmentation metrics.
372
+ cut_criteria (CutCriteria): The criteria to use for cutting trials.
373
+ calculate_crosses (bool): Whether to calculate crosses.
374
+
375
+ Returns:
376
+ pd.DataFrame: DataFrame containing all computed metrics for the experiment.
377
+ """
378
+ rows: List[Dict[str, Any]] = []
379
+
380
+ # Compute speed thresholds for each subject.
381
+ speed_threshold_by_subject = calculate_speed_threshold_for_all_subjects(
382
+ experiment, target_radius_multiplier
383
+ )
384
+
385
+ for subject_id, subject in experiment.subjects.items():
386
+ try:
387
+ threshold = speed_threshold_by_subject.get(subject_id)
388
+ if threshold is None:
389
+ logging.warning(f"Speed threshold not found for subject {subject_id}. Skipping subject.")
390
+ continue
391
+
392
+ subject_rows = generate_rows_for_subject(
393
+ subject_id=subject_id,
394
+ subject=subject,
395
+ correct_targets_minimum=correct_targets_minimum,
396
+ speed_threshold=threshold,
397
+ consecutive_points=consecutive_points,
398
+ cut_criteria=cut_criteria,
399
+ calculate_crosses=calculate_crosses,
400
+ target_radius_multiplier=target_radius_multiplier,
401
+ crosses_time_threshold=crosses_time_threshold
402
+ )
403
+ rows.extend(subject_rows)
404
+ except Exception as e:
405
+ logging.exception(f"Error processing subject {subject_id}: {e}")
406
+ continue
407
+
408
+ df = pd.DataFrame(rows)
409
+
410
+ try:
411
+ df.to_csv(save_path, index=False)
412
+ logging.info(f"Metrics successfully saved to {save_path}.")
413
+ except Exception as e:
414
+ logging.exception(f"Error saving CSV to {save_path}: {e}")
415
+ raise
416
+
417
+ return df
@@ -0,0 +1,89 @@
1
+ from typing import Dict, Any, List
2
+
3
+ import numpy as np
4
+
5
+ from neurotask.tmt.metrics.base_metric import BaseMetricCalculator
6
+ from neurotask.tmt.model.tmt_model import TMTTrial, TMTSubject, TMTTarget, CursorInfo
7
+
8
+
9
+ class SamplingPeriodCalculator(BaseMetricCalculator):
10
+ """
11
+ Calculates sampling period (time between samples) metrics from the cursor trail.
12
+ """
13
+
14
+ def add_metrics(
15
+ self,
16
+ metrics: dict,
17
+ trial: TMTTrial,
18
+ subject: TMTSubject,
19
+ trails_between_targets: list[tuple[TMTTarget, list[CursorInfo]]],
20
+ calculate_crosses: bool,
21
+ speed_threshold,
22
+ consecutive_points,
23
+ target_radius_multiplier: float,
24
+ crosses_time_threshold: float
25
+ ) -> dict:
26
+ period_metrics = compute_sampling_period_metrics(trial)
27
+
28
+ for key, value in period_metrics.items():
29
+ metrics[self.get_metric_name(key)] = value
30
+
31
+ return metrics
32
+
33
+
34
+ def compute_sampling_period_metrics(trial: TMTTrial) -> Dict[str, Any]:
35
+ """
36
+ Computes sampling period statistics based on cursor timestamps.
37
+
38
+ Timestamps are assumed to be in milliseconds. The sampling period is the
39
+ time interval between consecutive cursor samples.
40
+
41
+ Returns:
42
+ Dict with mean_sampling_period, median_sampling_period, std_sampling_period,
43
+ min_sampling_period, max_sampling_period, sample_count,
44
+ and valid_interval_count.
45
+ """
46
+ cursor_trail = trial.get_cursor_trail_from_start()
47
+
48
+ raw_sample_count = len(cursor_trail)
49
+
50
+ if raw_sample_count < 2:
51
+ return _empty_metrics(raw_sample_count)
52
+
53
+ time_intervals_ms = _calculate_time_intervals_ms(cursor_trail)
54
+
55
+ if not time_intervals_ms:
56
+ return _empty_metrics(raw_sample_count)
57
+
58
+ return {
59
+ "mean_sampling_period": np.mean(time_intervals_ms),
60
+ "median_sampling_period": np.median(time_intervals_ms),
61
+ "std_sampling_period": np.std(time_intervals_ms),
62
+ "min_sampling_period": np.min(time_intervals_ms),
63
+ "max_sampling_period": np.max(time_intervals_ms),
64
+ "sample_count": raw_sample_count,
65
+ "valid_interval_count": len(time_intervals_ms)
66
+ }
67
+
68
+
69
+ def _calculate_time_intervals_ms(cursor_trail: List[CursorInfo]) -> List[float]:
70
+ """Calculates valid time intervals (in ms) between consecutive samples."""
71
+ intervals = []
72
+ for i in range(1, len(cursor_trail)):
73
+ dt = cursor_trail[i].time - cursor_trail[i - 1].time
74
+ if dt > 0:
75
+ intervals.append(dt)
76
+ return intervals
77
+
78
+
79
+ def _empty_metrics(raw_sample_count: int, valid_count: int = 0) -> Dict[str, Any]:
80
+ """Returns empty metrics when calculation is not possible."""
81
+ return {
82
+ "mean_sampling_period": np.nan,
83
+ "median_sampling_period": np.nan,
84
+ "std_sampling_period": np.nan,
85
+ "min_sampling_period": np.nan,
86
+ "max_sampling_period": np.nan,
87
+ "sample_count": raw_sample_count,
88
+ "valid_interval_count": valid_count
89
+ }