Technical divers wear two computers for redundancy. If one dies 75 m inside a cave or at 90 m on a wreck, and the other can get you out. Mine are a Garmin Descent Mk3i on the left wrist and a Shearwater Perdix 2 on the right, and they both log everything. Which means every dive I do produces two files that describe the same hour underwater and agree on almost nothing administrative. Neither the start time nor the duration, and sometimes not even how many dives it was. bottomtime’s job is to figure out which logs are the same physical dive without ever merging them. This post is about how.
Nobody agrees what time it is
Take my deepest dive, #202, 101.5 m at the Drop Off dive site in Tulamben. The Perdix says it started at 2025-09-27 11:55:43. The Garmin says 2025-09-27 03:56:02. Both are right.
The Perdix logs its epochs on a wall clock with no timezone attached. That 11:55:43 is Bali time wearing a UTC costume. The Garmin records true UTC and, separately, the local offset (+480 minutes). Subtract eight hours from the Perdix’s number and you get 03:55:43 UTC, 19 seconds before the Garmin’s start. Nineteen seconds is me pressing two buttons on two wrists while I do my surface swim to the entry point.
But “subtract eight hours” was the answer, and the matcher’s first problem is that the Shearwater log never states the question. So it borrows the answer from the other wrist. For each Shearwater dive, try the UTC offsets observed on Garmin dives nearby in calendar time (all 84 of my matched pairs so far are +480, but the code doesn’t assume that. When I dive somewhere else, the Garmin will have logged the new offset). Under each candidate offset, look for interval overlap of at least half the shorter log, with a sanity check that the shorter log isn’t somehow deeper than the longer one.
Passing the overlap check only makes a pair a candidate. Acceptance rides on the depth profiles.
The depth profile is the fingerprint
Two computers strapped to the same body sample the same physical curve, and depth over time is the only channel they share. (GPS is no help. Seawater absorbs the signal within a metre of the surface, so the Garmin has a fix for the entry point and nothing after it.) The matcher takes both depth series, lays them on an in-memory 10-second grid, and slides one against the other across lags of ±300 s, scoring each shift with normalized cross-correlation. The stored samples are never resampled. The grid exists only long enough to compute the score.
Here are the two real series from dive #202. Drag the lag and watch the score respond:
(The widget runs the same resample-and-correlate code as the matcher, ported to ~40 lines of JS, on the actual logs downsampled to the 10 s grid. If it’s not rendering, the data file didn’t load, and hiding beats faking.)
Two things show up while you drag. The score peaks at +20 s. After the whole-hours offset is removed, the residual disagreement between my two wrists is 20 seconds, and the matcher stores that number (residual_skew_s) rather than correcting either series. And the peak is gentle. Even 120 seconds off, this dive still scores above 0.95 because a deco profile is mostly slow monotonic segments. That gentleness is why the acceptance threshold is a greedy 0.95 with an ambiguity rule. If two candidate Garmin dives score within 0.02 of each other, the matcher refuses to choose and flags the dive for human review instead.
On the full-resolution series the winning score for this pair is 0.99976, with a duration disagreement of 43 seconds and a max-depth disagreement of 0.37 m.
Then I took it to a pool
Cross-correlation has a failure mode. It measures the agreement of variation, and a pool session has almost none. Go 3 m down, stay there, come up. Normalize a flat line and you get noise.
My worst real case: one afternoon of skills practice on 2025-12-17. The Garmin logged it as a single 113-minute activity, max depth 8.2 m, because the Descent keeps recording through surface pauses. The Perdix, which cuts a log whenever I surface for more than a moment, cut eight logs over the same afternoon, two of them 46-second and 116-second blips too brief to pair with anything. The six that could pair ran from 345 s to 1,382 s. The two deeper drills matched on profile shape (0.991 and 0.975). The four shallow ones scored as low as 0.559, hopeless as correlations, while being blatantly the same water on the same day.
So there’s a second acceptance path for this shape of failure. If the interval overlap is near-total (at least 0.85) and the median absolute depth difference at the best lag is 0.75 m or less, accept. Two instruments on one body must agree on depth even when the profile is too boring to correlate. Thirteen of my 84 matches came in through that door, all of them pool sessions and shallow drills.
None of these thresholds is principled. They’re placed in the gaps of the observed distributions. Profile-accepted pairs score between 0.962 and 0.99997 against the 0.95 bar, and the pool path’s observed depth deltas run 0.07 to 0.20 m against the 0.75 m ceiling, so each rule has room on both sides. Duration is not used as evidence at all. The deltas run from 1 second to 6,463 seconds (median 48 s, but 26 of the 84 pairs disagree by more than five minutes), all of it the Descent’s surface-pause habit, none of it saying anything about identity.
A match is just a row
When a pair is accepted, nothing is merged. The match is a row: which Garmin log, which Shearwater log, the clock offset (+28,800 s), the residual skew, the score, and which rule accepted it. Both sample series stay exactly as their computers wrote them. Canonical dives are then just the connected components of those links, which handles the pool afternoon gracefully. One canonical dive with seven members, one Garmin log and six Shearwater logs, nothing pretending the Perdix’s six dives didn’t happen.
And 84 matches does not mean 84 out of 84. My Shearwater computers have logged 142 dives, and the 58 unmatched ones are all accounted for. Forty-nine happened on days I wasn’t wearing the Garmin at all, mostly early confined-water sessions and a CCR (closed-circuit rebreather) course where my second computer was a Petrel 3 instead (the matcher only links Garmin to Shearwater, so those two-Shearwater days stay unlinked, a limitation I haven’t needed to fix yet). Eight are bench tests under two metres or two minutes. Exactly one, a six-minute 2.5 m bob during a surface interval on 2025-12-19, overlapped a real Garmin log and was still rejected, correctly, by both rules.
The residual skews are their own small dataset. The median is +20 s across my 84 pairs, 53 of them within ±30 s, worst case +190 s on a day I apparently took my time pressing the second button. If I ever want to know how my two computers’ crystals drift apart over months, the numbers are sitting in a table, because correcting a disagreement in place would have destroyed the record of it.
The verification suite closes the loop. After alignment, the median depth disagreement across all 84 matched pairs, median-of-medians, is 0.162 m. When I ask bottomtime plot for dive #202, both profiles land on one time axis using the stored offset and skew, no resampling, and the two lines sit on top of each other.