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.
- neurotask/__init__.py +0 -0
- neurotask/tmt/__init__.py +0 -0
- neurotask/tmt/config.py +8 -0
- neurotask/tmt/crosses/__init__.py +0 -0
- neurotask/tmt/crosses/crosses.py +131 -0
- neurotask/tmt/crosses/crosses_metric_calculator.py +20 -0
- neurotask/tmt/cut_criteria/__init__.py +0 -0
- neurotask/tmt/cut_criteria/cut_criteria.py +6 -0
- neurotask/tmt/cut_criteria/cut_implementation.py +146 -0
- neurotask/tmt/invalid_cause.py +17 -0
- neurotask/tmt/mapper/__init__.py +0 -0
- neurotask/tmt/mapper/mapper.py +21 -0
- neurotask/tmt/metrics/__init__.py +0 -0
- neurotask/tmt/metrics/area_calculation.py +82 -0
- neurotask/tmt/metrics/base_metric.py +37 -0
- neurotask/tmt/metrics/difference_from_ideal_distance.py +38 -0
- neurotask/tmt/metrics/distance_calculation.py +61 -0
- neurotask/tmt/metrics/intra_and_inter_target_time.py +148 -0
- neurotask/tmt/metrics/metrics_calculator.py +417 -0
- neurotask/tmt/metrics/sampling_period.py +89 -0
- neurotask/tmt/metrics/speed_metrics.py +228 -0
- neurotask/tmt/metrics/targets_touched.py +253 -0
- neurotask/tmt/metrics/zig_zag_amplitud.py +52 -0
- neurotask/tmt/model/__init__.py +0 -0
- neurotask/tmt/model/tmt_model.py +165 -0
- neurotask/tmt/preprocessing/__init__.py +0 -0
- neurotask/tmt/preprocessing/interpolation.py +62 -0
- neurotask/tmt/segmentation/__init__.py +0 -0
- neurotask/tmt/segmentation/segmentation.py +539 -0
- neurotask/tmt/segmentation/segmentation_metric.py +27 -0
- neurotask/tmt/tmt_analyzer.py +188 -0
- neurotask/tmt/visualization/__init__.py +0 -0
- neurotask/tmt/visualization/utils.py +63 -0
- neurotask/tmt/visualization/visualization.py +345 -0
- neurotask-0.2.0.dist-info/METADATA +240 -0
- neurotask-0.2.0.dist-info/RECORD +39 -0
- neurotask-0.2.0.dist-info/WHEEL +5 -0
- neurotask-0.2.0.dist-info/licenses/LICENSE +21 -0
- 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
|
+
}
|