Merge pull request #577 from trails-cool/feat/gpx-parser-robustness-timestamps
feat(gpx): post-parse timestamp repair
This commit is contained in:
commit
82d726bc14
4 changed files with 128 additions and 4 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
56
packages/gpx/src/timestamp-repair.test.ts
Normal file
56
packages/gpx/src/timestamp-repair.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
67
packages/gpx/src/timestamp-repair.ts
Normal file
67
packages/gpx/src/timestamp-repair.ts
Normal 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);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue