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.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`
|
||||
- [ ] 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.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.2 Wire the repair pass into `parseTracks` output in `parse.ts`
|
||||
- [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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { Waypoint } from "@trails-cool/types";
|
||||
import type { GpxData, TrackPoint, ElevationProfile, NoGoArea } from "./types.ts";
|
||||
import { repairTimestamps } from "./timestamp-repair.ts";
|
||||
|
||||
/**
|
||||
* 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 description = doc.querySelector("metadata > desc")?.textContent ?? undefined;
|
||||
const waypoints = parseWaypoints(doc);
|
||||
const tracks = parseTracks(doc);
|
||||
const tracks = repairTimestamps(parseTracks(doc));
|
||||
const noGoAreas = parseNoGoAreas(doc);
|
||||
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