GPS/GPS_ACCURACY chunks corrupt the shared sample clock, producing wild depth spikes
suunto_nautic_parser_parse() sums every chunk's leading millisecond delta into one shared time_ms running clock:
// src/suunto_nautic_parser.c
if (chunk.id != CHUNK_TIMELINE_BASE && chunk.size >= 2)
time_ms += (int16_t) array_uint16_le (chunk.data);
GPS (0x0B) and GPS_ACCURACY (0x0E) chunks don't belong on that clock. The watch buffers and writes them with a lag — their delta is relative to their own last GPS-type entry, not to whatever chunk immediately precedes them in file order. On a real capture this shows up as a time_ms rewind of several hundred ms every time one of these chunks appears:
...IMU_ALT ticking forward normally (100ms steps)...
0x0e (GPS_ACCURACY) delta=-660 time_ms=3269440 <== rewinds the shared clock
...IMU_ALT resumes forward from the rewound point...
The very next DC_SAMPLE_TIME (typically the next IMU tick) inherits that corrupted, rewound timestamp.
Why it produces multi-kilometer depth spikes
Depth is only reported directly by CHUNK_EXTENDED_STATUS, roughly once every ~10s. Everything in between is NAN and gets linearly interpolated between the two real neighbors in the platform wrapper's fill_missing_depths() (libdc_download.c):
unsigned int t0 = dive->samples[prev].time_ms;
unsigned int t1 = dive->samples[i].time_ms;
...
double fraction = t1 > t0
? (double)(dive->samples[j].time_ms - t0) / (double)(t1 - t0)
: 0.0;
dive->samples[j].depth = d0 + (d1 - d0) * fraction;
time_ms is unsigned int. When a corrupted sample's timestamp is less than t0, dive->samples[j].time_ms - t0 doesn't go negative — it underflows to a value near UINT_MAX, and dividing that by a small (t1 - t0) (typical right at the end of a dive, where real readings are close together) produces an enormous fraction. The unclamped formula then extrapolates the depth by that same enormous factor. On a real Suunto Ocean dive this produced two isolated one-sample spikes of 212,373 m and 751,979 m, each immediately adjacent to samples reading a few centimeters.
This single corrupted sample cascades into wrong dive-level statistics anywhere downstream that scans the profile for max depth or feeds it into a tissue-loading model — max depth off by 5 orders of magnitude, ppO2 in the thousands of bar, absurd gradient-factor and missed-deco-stop findings.
Reproduction
No real dive data needed — the mechanism reproduces from three synthetic chunks: two real depth readings 200ms apart, with one GPS_ACCURACY chunk between them carrying a realistic negative delta that lands its own timestamp 15 real seconds before both of them.
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "libdc_wrapper.h"
static void put_u16le(unsigned char *p, unsigned int v) {
p[0] = (unsigned char)(v & 0xFF);
p[1] = (unsigned char)((v >> 8) & 0xFF);
}
static void put_f32le(unsigned char *p, float v) { memcpy(p, &v, sizeof(float)); }
// [id:1][length:1][timeDelta:2 LE][depth:4 LE float]
static unsigned char *put_extended_status(unsigned char *p, int16_t delta_ms, float depth_m) {
p[0] = 0x16; p[1] = 6;
put_u16le(p + 2, (uint16_t)delta_ms);
put_f32le(p + 4, depth_m);
return p + 8;
}
// [id:1][length:1][timeDelta:2 LE][dEHPE:1][dEVPE:1][pad:2]
static unsigned char *put_gps_accuracy(unsigned char *p, int16_t delta_ms) {
p[0] = 0x0E; p[1] = 6;
put_u16le(p + 2, (uint16_t)delta_ms);
p[4] = p[5] = p[6] = p[7] = 0;
return p + 8;
}
int main(void) {
unsigned char buf[8 + 8 * 3];
unsigned char *p = buf;
memcpy(p, "SBEM0103", 8); p += 8;
p = put_extended_status(p, 20000, 0.05f); // t=20000 (real)
p = put_gps_accuracy(p, -15000); // t=5000 (corrupted)
p = put_extended_status(p, 15200, 0.08f); // t=20200 (real)
libdc_parsed_dive_t result;
char err[256] = {0};
int rc = libdc_parse_raw_dive("Suunto", "Ocean", 1, buf, (unsigned int)sizeof(buf),
&result, err, sizeof(err));
if (rc != 0) { fprintf(stderr, "FAIL: %s\n", err); return 1; }
for (unsigned int i = 0; i < result.sample_count; i++)
printf("[%u] time_ms=%u depth=%.6f\n", i, result.samples[i].time_ms, result.samples[i].depth);
return 0;
}
Output against current main:
[0] time_ms=20000 depth=0.050000
[1] time_ms=5000 depth=644242.840000
[2] time_ms=20200 depth=0.080000
Same run with the fix below applied:
[0] time_ms=20000 depth=0.050000
[1] time_ms=20000 depth=0.050000
[2] time_ms=35200 depth=0.080000
(time_ms=20000 twice and 35200 on the last line, not 20200, because with the fix the GPS_ACCURACY chunk no longer perturbs the shared clock at all — its own sample just holds the last known-good time, and the following real reading's delta of 15200 is now added on top of the correct 20000 instead of the rewound 5000. Either way the point holds: time_ms never goes backward, and depth never leaves [0.05, 0.08].)
Fix
Exclude CHUNK_GPS and CHUNK_GPS_ACCURACY from advancing the shared time_ms, the same way CHUNK_TIMELINE_BASE already is — their own emitted samples then fall back to the shared clock's last known-good position instead of a value reconstructed from sub-second GPS timing the format doesn't reliably encode once interleaved. As a second, independent safety net, fill_missing_depths()'s fraction should be clamped to [0, 1] — depth can never legitimately be extrapolated outside the two real bracketing samples, so this holds regardless of what causes a timeline irregularity.
Have both fixes ready to submit as a PR if that's useful.
GPS/GPS_ACCURACY chunks corrupt the shared sample clock, producing wild depth spikes
suunto_nautic_parser_parse()sums every chunk's leading millisecond delta into one sharedtime_msrunning clock:GPS (
0x0B) and GPS_ACCURACY (0x0E) chunks don't belong on that clock. The watch buffers and writes them with a lag — their delta is relative to their own last GPS-type entry, not to whatever chunk immediately precedes them in file order. On a real capture this shows up as atime_msrewind of several hundred ms every time one of these chunks appears:The very next
DC_SAMPLE_TIME(typically the next IMU tick) inherits that corrupted, rewound timestamp.Why it produces multi-kilometer depth spikes
Depth is only reported directly by
CHUNK_EXTENDED_STATUS, roughly once every ~10s. Everything in between isNANand gets linearly interpolated between the two real neighbors in the platform wrapper'sfill_missing_depths()(libdc_download.c):time_msisunsigned int. When a corrupted sample's timestamp is less thant0,dive->samples[j].time_ms - t0doesn't go negative — it underflows to a value nearUINT_MAX, and dividing that by a small(t1 - t0)(typical right at the end of a dive, where real readings are close together) produces an enormousfraction. The unclamped formula then extrapolates the depth by that same enormous factor. On a real Suunto Ocean dive this produced two isolated one-sample spikes of 212,373 m and 751,979 m, each immediately adjacent to samples reading a few centimeters.This single corrupted sample cascades into wrong dive-level statistics anywhere downstream that scans the profile for max depth or feeds it into a tissue-loading model — max depth off by 5 orders of magnitude, ppO2 in the thousands of bar, absurd gradient-factor and missed-deco-stop findings.
Reproduction
No real dive data needed — the mechanism reproduces from three synthetic chunks: two real depth readings 200ms apart, with one
GPS_ACCURACYchunk between them carrying a realistic negative delta that lands its own timestamp 15 real seconds before both of them.Output against current
main:Same run with the fix below applied:
(
time_ms=20000twice and35200on the last line, not20200, because with the fix the GPS_ACCURACY chunk no longer perturbs the shared clock at all — its own sample just holds the last known-good time, and the following real reading's delta of15200is now added on top of the correct20000instead of the rewound5000. Either way the point holds:time_msnever goes backward, and depth never leaves[0.05, 0.08].)Fix
Exclude
CHUNK_GPSandCHUNK_GPS_ACCURACYfrom advancing the sharedtime_ms, the same wayCHUNK_TIMELINE_BASEalready is — their own emitted samples then fall back to the shared clock's last known-good position instead of a value reconstructed from sub-second GPS timing the format doesn't reliably encode once interleaved. As a second, independent safety net,fill_missing_depths()'sfractionshould be clamped to[0, 1]— depth can never legitimately be extrapolated outside the two real bracketing samples, so this holds regardless of what causes a timeline irregularity.Have both fixes ready to submit as a PR if that's useful.