Merge pull request #577 from trails-cool/feat/gpx-parser-robustness-timestamps

feat(gpx): post-parse timestamp repair
This commit is contained in:
Ullrich Schäfer 2026-07-14 00:11:14 +02:00 committed by GitHub
commit 82d726bc14
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 128 additions and 4 deletions

View file

@ -11,9 +11,9 @@
## 3. Timestamp repair ## 3. Timestamp repair
- [ ] 3.1 Create `packages/gpx/src/timestamp-repair.ts`: per-segment pass — >50% invalid → drop all; else linear interpolation between valid neighbors with leading/trailing clamp; output ISO 8601 strings - [x] 3.1 Create `packages/gpx/src/timestamp-repair.ts`: per-segment pass — >50% invalid → drop all; else linear interpolation between valid neighbors with leading/trailing clamp; output ISO 8601 strings
- [ ] 3.2 Wire the repair pass into `parseTracks` output in `parse.ts` - [x] 3.2 Wire the repair pass into `parseTracks` output in `parse.ts`
- [ ] 3.3 Unit tests (`timestamp-repair.test.ts`): sparse invalid interpolated, >50% invalid dropped (and `movingTime` returns null), leading/trailing clamp, all-valid untouched, no-timestamps untouched - [x] 3.3 Unit tests (`timestamp-repair.test.ts`): sparse invalid interpolated, >50% invalid dropped (and `movingTime` returns null), leading/trailing clamp, all-valid untouched, no-timestamps untouched
## 4. Metadata fallback ## 4. Metadata fallback

View file

@ -1,5 +1,6 @@
import type { Waypoint } from "@trails-cool/types"; import type { Waypoint } from "@trails-cool/types";
import type { GpxData, TrackPoint, ElevationProfile, NoGoArea } from "./types.ts"; import type { GpxData, TrackPoint, ElevationProfile, NoGoArea } from "./types.ts";
import { repairTimestamps } from "./timestamp-repair.ts";
/** /**
* Parse a GPX XML string into structured data. * Parse a GPX XML string into structured data.
@ -31,7 +32,7 @@ function parseGpxWithParser(parser: DOMParser, xml: string): GpxData {
const name = doc.querySelector("metadata > name")?.textContent ?? undefined; const name = doc.querySelector("metadata > name")?.textContent ?? undefined;
const description = doc.querySelector("metadata > desc")?.textContent ?? undefined; const description = doc.querySelector("metadata > desc")?.textContent ?? undefined;
const waypoints = parseWaypoints(doc); const waypoints = parseWaypoints(doc);
const tracks = parseTracks(doc); const tracks = repairTimestamps(parseTracks(doc));
const noGoAreas = parseNoGoAreas(doc); const noGoAreas = parseNoGoAreas(doc);
const { totalDistance, ...elevation } = computeElevation(tracks); const { totalDistance, ...elevation } = computeElevation(tracks);

View file

@ -0,0 +1,56 @@
import { describe, it, expect } from "vitest";
import { repairSegmentTimestamps } from "./timestamp-repair.ts";
import { movingTime } from "./moving-time.ts";
import type { TrackPoint } from "./types.ts";
const pt = (time?: string, lat = 52.5, lon = 13.4): TrackPoint => ({ lat, lon, time });
describe("repairSegmentTimestamps", () => {
it("interpolates a sparse invalid timestamp between valid neighbours", () => {
const out = repairSegmentTimestamps([
pt("2026-01-01T10:00:00Z"),
pt("garbage"),
pt("2026-01-01T10:02:00Z"),
pt("2026-01-01T10:03:00Z"),
]);
// index 1 is midway (by index) between valid index 0 and index 2.
expect(out[1]!.time).toBe("2026-01-01T10:01:00.000Z");
// Valid points keep their original strings untouched.
expect(out[0]!.time).toBe("2026-01-01T10:00:00Z");
expect(out[3]!.time).toBe("2026-01-01T10:03:00Z");
});
it("drops all timestamps when more than half the segment is invalid", () => {
const out = repairSegmentTimestamps([
pt("2026-01-01T10:00:00Z"),
pt("garbage"),
pt(undefined),
pt("nope"),
]);
expect(out.every((p) => p.time === undefined)).toBe(true);
// A dropped time channel means the segment reads as untimed.
expect(movingTime([out])).toBeNull();
});
it("clamps leading and trailing invalid runs to the nearest valid timestamp", () => {
const out = repairSegmentTimestamps([
pt("garbage"),
pt("2026-01-01T10:01:00Z"),
pt("2026-01-01T10:02:00Z"),
pt(undefined),
]);
expect(out[0]!.time).toBe("2026-01-01T10:01:00.000Z"); // leading clamp
expect(out[3]!.time).toBe("2026-01-01T10:02:00.000Z"); // trailing clamp
});
it("leaves an all-valid segment untouched", () => {
const input = [pt("2026-01-01T10:00:00Z"), pt("2026-01-01T10:01:00Z")];
const out = repairSegmentTimestamps(input);
expect(out.map((p) => p.time)).toEqual(["2026-01-01T10:00:00Z", "2026-01-01T10:01:00Z"]);
});
it("leaves an untimed segment (no timestamps) untouched", () => {
const out = repairSegmentTimestamps([pt(undefined), pt(undefined), pt(undefined)]);
expect(out.every((p) => p.time === undefined)).toBe(true);
});
});

View file

@ -0,0 +1,67 @@
import type { TrackPoint } from "./types.ts";
// Post-parse timestamp repair (spec: gpx-parser-robustness "Timestamp
// repair"). Recorders from flaky devices emit runs of missing or garbage
// `<time>` values; passed through raw they silently shrink moving time and
// skew start-time derivation. This is a pure per-segment pass with an
// explicit policy, so `movingTime`/`computeDays`/`startTime` stay correct
// without each re-implementing (and drifting on) the same rules.
//
// Policy:
// - Validity = `Date.parse` yields a finite epoch.
// - A segment with NO valid timestamps is left untouched (an untimed
// track — downstream already handles that).
// - > 50% of a segment's points invalid → drop ALL of that segment's
// timestamps (a mostly-broken time channel is noise, not signal).
// - Otherwise → linearly interpolate invalid runs between their valid
// neighbours; a leading/trailing invalid run clamps to the nearest
// valid timestamp. Only repaired points are rewritten (as ISO 8601);
// valid points keep their original string untouched.
//
// Monotonicity is deliberately not enforced — `movingTime` already skips
// non-positive intervals, and reordering recorded data would be fabrication.
function epochOf(time: string | undefined): number {
return time == null ? NaN : Date.parse(time);
}
export function repairSegmentTimestamps(points: TrackPoint[]): TrackPoint[] {
const epochs = points.map((p) => epochOf(p.time));
const validIdx = epochs.map((e, i) => (Number.isFinite(e) ? i : -1)).filter((i) => i >= 0);
// Nothing to anchor to, or the time channel is mostly broken → treat the
// segment as untimed. (When there are zero valid timestamps this leaves
// the already-absent times as-is.)
if (validIdx.length === 0) return points;
const invalidCount = points.length - validIdx.length;
if (invalidCount === 0) return points; // all valid → untouched
if (invalidCount / points.length > 0.5) {
return points.map((p) => (p.time === undefined ? p : { ...p, time: undefined }));
}
const firstValid = validIdx[0]!;
const lastValid = validIdx[validIdx.length - 1]!;
return points.map((p, i) => {
if (Number.isFinite(epochs[i]!)) return p; // valid → keep original string
let t: number;
if (i < firstValid) {
t = epochs[firstValid]!; // leading run clamps forward
} else if (i > lastValid) {
t = epochs[lastValid]!; // trailing run clamps back
} else {
// Between two valid anchors: linear interpolation by point index.
let prev = i;
while (!Number.isFinite(epochs[prev]!)) prev--;
let next = i;
while (!Number.isFinite(epochs[next]!)) next++;
const frac = (i - prev) / (next - prev);
t = epochs[prev]! + (epochs[next]! - epochs[prev]!) * frac;
}
return { ...p, time: new Date(t).toISOString() };
});
}
export function repairTimestamps(segments: TrackPoint[][]): TrackPoint[][] {
return segments.map(repairSegmentTimestamps);
}