Skip to content

GPS/GPS_ACCURACY chunks corrupt the shared sample clock, producing wild depth spikes #1

Description

@trustthegoose

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions