Clock Drift Error Notes
Problem
Behavior trial starts from the validated Unreal/VR behavior file match the hardware trigger sequence at the beginning of the recording, but the timing difference grows across trials.
Observed example:
Best deltas:
[0.015, 0.626, 1.419, 2.203, 3.043, 3.560, 4.033, 4.767, 5.252, 5.707]
Signed deltas:
[-0.015, -0.626, -1.419, -2.203, -3.043, -3.560, -4.033, -4.767, -5.252, -5.707]
The signed deltas are increasingly negative:
behavior_start - trigger_start < 0
This means the behavior timestamps become earlier relative to the recorded hardware trigger timestamps as the session progresses.
Diagnostic Evidence
Spacing between consecutive behavior starts is consistently shorter than the spacing between consecutive trigger starts:
behav_spacing=115.256, trigger_spacing=115.867, spacing_error=-0.611
behav_spacing=99.056, trigger_spacing=99.848, spacing_error=-0.792
behav_spacing=97.544, trigger_spacing=98.329, spacing_error=-0.785
behav_spacing=107.889, trigger_spacing=108.728, spacing_error=-0.840
So this is probably not one bad Arduino trigger. It looks like a systematic timebase mismatch.
Likely Cause
The Unreal task may trigger the Arduino and save a timestamp close together, but those timestamps may not come from the same effective clock as the recorded hardware trigger stream.
Possible timebases involved:
- Unreal game/session time saved in the behavior file.
- Trigger pulse arrival time recorded by the acquisition/LSL/Biopac system.
- A separate recording PC or acquisition clock.
Important point: this drift is probably too large to be normal quartz clock drift. The observed drift is roughly 0.5% to 0.8%, or about 5-8 ms per second. That is much larger than expected for ordinary PC quartz clocks, so the more likely explanation is a software/timebase mismatch, such as Unreal game time versus recorder time.
Working Interpretation
The behavior timeline appears compressed relative to the hardware trigger timeline.
In plain English:
The two clocks start nearly aligned, but the Unreal behavior clock is walking slightly slower/shorter than the recorder trigger clock. Small differences accumulate until later trial starts are several seconds away.
Processing-Side Solution
Correct this in processing by mapping behavior time onto recorder/trigger time.
Use matched pairs:
(behavior_trial_start, trigger_trial_start)
Fit a linear conversion:
recorder_time = slope * behavior_time + intercept
Then apply the conversion to both starts and ends of behavior intervals:
corrected_start = slope * behav_start + intercept
corrected_end = slope * behav_end + intercept
Because the error accumulates over time, a single offset is not enough. The correction needs both:
- an offset, handled by
intercept - a stretch/compression factor, handled by
slope
Expected result: slope should be slightly greater than 1.0, because behavior
time appears compressed compared with trigger time.
Matching Notes
The matching logic should avoid reusing trigger intervals:
- Build candidate trigger intervals.
- Match each behavior trial start to the nearest unused trigger start.
- After a successful match, remove that trigger from the remaining candidates.
If behavior starts correspond only to even trigger points, candidate triggers
should eventually be filtered to TP0, TP2, TP4, etc.
Tomorrow's Next Step
Add a small correction step after initial matching:
- Collect matched behavior starts and trigger starts.
- Fit
slopeandintercept. - Convert all behavior interval starts and ends into trigger/recorder time.
- Use corrected behavior intervals for slicing physiology data.
Update: this approach is now deprecated
The linear-fit approach above was implemented as
get_crane_predicted_trigger_intervals (a PolynomialFeatures +
LinearRegression pipeline) and
align_crane_behav_intervals_with_trigger_intervals, both in
crane_trial_intervals.py. Both are now marked @deprecated — see
Golden Rules
for what that decorator does. The reason, from the decorator's own message:
"Flawed matching algorithm specific to crane. Replace with a more general
one."
They've been superseded by align_biopac_trigger_drift_from_behav_file
(trial_intervals.py) — a more general implementation, not tied to Crane
specifically, now called from CraneGetTrialIntervalStrategyStep.run(). See
Interval QC Plot for how its output is checked, and item 9
in Next Steps
for the fuller history of this change.
Update: how it's actually solved now — not a curve fit
Worth spelling out, because it turned out to be a different (and simpler)
idea than this doc originally planned: align_biopac_trigger_drift_from_behav_file
does not fit a slope/intercept correction at all. No regression, no
fitted conversion between clocks.
The realization that made the fit unnecessary: trigger timestamps don't need "correcting" — they're already recorded on the physiology recording's own clock, which is the timebase everything eventually gets sliced against anyway. The real problem was never "what's the true time," it was "which trigger pulse belongs to which named behaviour trial." That gets solved by position, not by time-matching:
- If there are fewer trigger intervals than behaviour trials (a trigger
got missed — see
TrialIntervals.shift_intervals_forward_by), pad the front of the trigger list with NaN-valued placeholders until the counts match. TrialIntervals.relabel_withpairs the two sets up by sorted position — 1st trigger ↔ 1st behaviour trial, 2nd ↔ 2nd, and so on — and copies each trigger interval's own(start, end)values, just renamed to the matching behaviour trial's name. The trigger's original time values are never adjusted or transformed.drop_nan_intervals()then removes any padded placeholder that ended up "matched" to a behaviour name — correctly leaving that trial unlabelled, rather than assigning it a fabricated time.- A diagnostic-only check,
TrialIntervals.get_overlap, compares each matched pair's duration (not start time) and logs the mean difference, purely as a sanity check on the matching — this is what ends up visible in the Interval QC Plot's "Matched with Behav" row.
So the fix for "clock drift" turned out to be an ordering/counting problem, not a timebase-conversion one: as long as the sequence of triggers lines up with the sequence of behaviour trial names, no clock-fitting is needed at all. That's also why this function generalizes beyond Crane, unlike the deprecated regression-based matcher above — it never assumed anything Crane-specific about how the drift behaved, only that trials happen in the same order on both sides.