Skip to content

Commit 53f36b5

Browse files
committed
feat: time-aware Valhalla routing, /match endpoint, OSRM traffic stub
- Always send Valhalla `date_time` on `/route` and `/optimized_route` (default `{type:0}` "now") so the engine honours time-conditional OSM access tags (school zones, restricted hours, ferry schedules) and is wired for predicted-speed lookups once historical-traffic tiles land. Expose `departAt` / `arriveBy` (ISO-8601 wall-clock) on `/directions`, `/directions/optimize`, `useDirections`, and `useOptimizeRoute`. The provider models `date_time` as a discriminated union so a future caller can't construct `{type:1}` without `value`, and throws if both fields are pinned. - Add a `supportsTimeAware` flag on `RoutingProvider` (Valhalla `true`, OSRM unset) and a `requireTimeAware` filter on `getRoutingProviders` / `getOptimizeProvider`. When the caller pins a wall-clock, the orchestrator drops time-agnostic providers from the chain and the route handler returns 503 ("No time-aware routing provider available…") instead of silently falling back to OSRM and ignoring the time. The cross-mode optimize fallback is suppressed under `requireTimeAware` for the same reason. - Cache TTL now varies on time-presence: 1h for explicit `departAt`/`arriveBy` requests (deterministic), 5min for implicit "now" (so cached answers don't drift once Valhalla starts honouring time bands). The `Cache-Control` response header tracks the same window. - Add `POST /api/integrations/routing/match` backed by Valhalla `trace_attributes` (Meili HMM matcher) — snaps a recorded GPS trace to the road graph and returns per-edge OSM way ids, surface, speed, names, plus per-point match info. Trace cap of 10 000 points (~1 Hz × 2.7 h drive); ISO timestamps on input points are converted to unix-epoch seconds for the engine. `MatchResult` / `MatchEdge` / `MatchPoint` types and an optional `getMatch?` method on `RoutingProvider` live in `integrations/routing/types.ts`. `distanceAlongEdgeRatio` is intentionally passed through as Valhalla's 0–1 ratio (consumers multiply by `edges[edgeIndex].length` for metres) — earlier draft mistakenly converted as if it were km. - OSRM build pipeline now creates an empty `data/osrm-graph/segment-speeds.csv` and threads `--segment-speed-file` into `osrm-customize`, so swapping in a populated CSV becomes a config change rather than a pipeline rewrite. The CSV's content hash is folded into the build cache key (rebuild triggered when traffic data changes even if the PBF is identical). `clearPreviousOsrmGraph` is annotated to flag that the CSV is intentionally preserved across rebuilds. Runtime side (`osrm-routed --algorithm mld`) was already MLD; this completes the wiring for Tier 3 #8 live-traffic work. - Pull validators into `integrations/routing/validation.ts`: `parseTravelMode` (case-insensitive, returns 400 on `mode=banana` instead of 503) and `parseDateTime` (regex prefix + numeric range + `Date.UTC` round-trip so Feb 31 / Apr 31 are rejected and Feb 29 only validates in leap years). Both used consistently by `/directions`, `/directions/optimize`, and `/match`. Add `routingMatch` entry to `packages/core/src/api/endpoints.ts` so future hooks don't hardcode the path. - Tests: 7 new Valhalla `date_time` tests across `getRoute` + `optimizeRoute`, 10 new `getMatch` tests (request shape, edge km→m, matched-point ratio pass-through, ISO→epoch), 4 new orchestrator tests for the `requireTimeAware` filter (including the no-cross-mode-fallback guard), 13 new validator tests covering leap years and calendar-impossible dates, 2 new `osrm-graph` tests (segment-speed flag threading + CSV-triggered rebuild). 1444/1444 vitest pass; `pnpm turbo run check-types` clean across all 8 targets; `pnpm exec biome check` clean on every touched file.
1 parent 22f6498 commit 53f36b5

13 files changed

Lines changed: 1143 additions & 39 deletions

File tree

apps/api/src/services/__tests__/valhalla.test.ts

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,48 @@ describe("valhallaService", () => {
347347
expect(body.alternates).toBe(3);
348348
});
349349

350+
it("defaults date_time to type 0 (current departure) when no time given", async () => {
351+
mockFetch.mockResolvedValueOnce(mockOk(makeValhallaResponse()));
352+
353+
const { valhallaService } = await import("@integrations/routing-valhalla/provider.js");
354+
await valhallaService.getRoute(waypoints, "driving");
355+
356+
const body = JSON.parse(mockFetch.mock.calls[0][1].body as string);
357+
expect(body.date_time).toEqual({ type: 0 });
358+
});
359+
360+
it("sets date_time type 1 with value when departAt is given", async () => {
361+
mockFetch.mockResolvedValueOnce(mockOk(makeValhallaResponse()));
362+
363+
const { valhallaService } = await import("@integrations/routing-valhalla/provider.js");
364+
await valhallaService.getRoute(waypoints, "driving", { departAt: "2026-05-04T08:30" });
365+
366+
const body = JSON.parse(mockFetch.mock.calls[0][1].body as string);
367+
expect(body.date_time).toEqual({ type: 1, value: "2026-05-04T08:30" });
368+
});
369+
370+
it("sets date_time type 2 with value when arriveBy is given", async () => {
371+
mockFetch.mockResolvedValueOnce(mockOk(makeValhallaResponse()));
372+
373+
const { valhallaService } = await import("@integrations/routing-valhalla/provider.js");
374+
await valhallaService.getRoute(waypoints, "driving", { arriveBy: "2026-05-04T17:00" });
375+
376+
const body = JSON.parse(mockFetch.mock.calls[0][1].body as string);
377+
expect(body.date_time).toEqual({ type: 2, value: "2026-05-04T17:00" });
378+
});
379+
380+
it("throws when both departAt and arriveBy are set", async () => {
381+
const { valhallaService } = await import("@integrations/routing-valhalla/provider.js");
382+
383+
await expect(
384+
valhallaService.getRoute(waypoints, "driving", {
385+
departAt: "2026-05-04T08:30",
386+
arriveBy: "2026-05-04T17:00",
387+
}),
388+
).rejects.toThrow("departAt and arriveBy are mutually exclusive");
389+
expect(mockFetch).not.toHaveBeenCalled();
390+
});
391+
350392
it("does not request alternates with 3+ waypoints", async () => {
351393
mockFetch.mockResolvedValueOnce(mockOk(makeValhallaResponse()));
352394

@@ -489,5 +531,211 @@ describe("valhallaService", () => {
489531
expect(loc.type).toBe("break");
490532
}
491533
});
534+
535+
it("defaults date_time to type 0 when no time given", async () => {
536+
mockFetch.mockResolvedValueOnce(mockOk(makeOptimizeResponse()));
537+
538+
const { valhallaService } = await import("@integrations/routing-valhalla/provider.js");
539+
await valhallaService.optimizeRoute?.(fourWaypoints, "driving");
540+
541+
const body = JSON.parse(mockFetch.mock.calls[0][1].body as string);
542+
expect(body.date_time).toEqual({ type: 0 });
543+
});
544+
545+
it("threads departAt into date_time type 1", async () => {
546+
mockFetch.mockResolvedValueOnce(mockOk(makeOptimizeResponse()));
547+
548+
const { valhallaService } = await import("@integrations/routing-valhalla/provider.js");
549+
await valhallaService.optimizeRoute?.(fourWaypoints, "driving", {
550+
departAt: "2026-05-04T08:30",
551+
});
552+
553+
const body = JSON.parse(mockFetch.mock.calls[0][1].body as string);
554+
expect(body.date_time).toEqual({ type: 1, value: "2026-05-04T08:30" });
555+
});
556+
});
557+
558+
describe("getMatch()", () => {
559+
const trace = [
560+
{ lat: 52.517, lng: 13.388 },
561+
{ lat: 52.521, lng: 13.392 },
562+
{ lat: 52.529, lng: 13.397 },
563+
];
564+
565+
function makeTraceResponse(overrides: Record<string, unknown> = {}) {
566+
return {
567+
shape: "encoded_polyline_data",
568+
edges: [
569+
{
570+
way_id: 12345,
571+
length: 0.5, // km
572+
speed: 50,
573+
surface: "paved",
574+
names: ["Friedrichstraße"],
575+
begin_shape_index: 0,
576+
end_shape_index: 2,
577+
},
578+
],
579+
matched_points: [
580+
{
581+
lat: 52.517,
582+
lon: 13.388,
583+
type: "matched",
584+
edge_index: 0,
585+
distance_along_edge: 0.05, // 0–1 ratio along the matched edge
586+
distance_from_trace_point: 4.2, // metres
587+
},
588+
],
589+
...overrides,
590+
};
591+
}
592+
593+
it("POSTs to VALHALLA_URL/trace_attributes with shape + filters + walk_or_snap default", async () => {
594+
mockFetch.mockResolvedValueOnce(mockOk(makeTraceResponse()));
595+
596+
const { valhallaService } = await import("@integrations/routing-valhalla/provider.js");
597+
await valhallaService.getMatch?.(trace, "driving");
598+
599+
const url = mockFetch.mock.calls[0][0] as string;
600+
const init = mockFetch.mock.calls[0][1] as RequestInit;
601+
const body = JSON.parse(init.body as string);
602+
603+
expect(url).toContain("/trace_attributes");
604+
expect(init.method).toBe("POST");
605+
expect(body.shape).toEqual([
606+
{ lat: 52.517, lon: 13.388 },
607+
{ lat: 52.521, lon: 13.392 },
608+
{ lat: 52.529, lon: 13.397 },
609+
]);
610+
expect(body.shape_match).toBe("walk_or_snap");
611+
expect(body.costing).toBe("auto");
612+
expect(body.filters.action).toBe("include");
613+
expect(body.filters.attributes).toEqual(expect.arrayContaining(["edge.way_id", "shape"]));
614+
});
615+
616+
it("uses pedestrian costing for walking and bicycle for cycling", async () => {
617+
mockFetch
618+
.mockResolvedValueOnce(mockOk(makeTraceResponse()))
619+
.mockResolvedValueOnce(mockOk(makeTraceResponse()));
620+
621+
const { valhallaService } = await import("@integrations/routing-valhalla/provider.js");
622+
await valhallaService.getMatch?.(trace, "walking");
623+
await valhallaService.getMatch?.(trace, "cycling");
624+
625+
const body1 = JSON.parse(mockFetch.mock.calls[0][1].body as string);
626+
const body2 = JSON.parse(mockFetch.mock.calls[1][1].body as string);
627+
expect(body1.costing).toBe("pedestrian");
628+
expect(body2.costing).toBe("bicycle");
629+
});
630+
631+
it("honours an explicit shapeMatch option", async () => {
632+
mockFetch.mockResolvedValueOnce(mockOk(makeTraceResponse()));
633+
634+
const { valhallaService } = await import("@integrations/routing-valhalla/provider.js");
635+
await valhallaService.getMatch?.(trace, "driving", { shapeMatch: "map_snap" });
636+
637+
const body = JSON.parse(mockFetch.mock.calls[0][1].body as string);
638+
expect(body.shape_match).toBe("map_snap");
639+
});
640+
641+
it("converts ISO timestamps on trace points to unix epoch seconds", async () => {
642+
mockFetch.mockResolvedValueOnce(mockOk(makeTraceResponse()));
643+
644+
const { valhallaService } = await import("@integrations/routing-valhalla/provider.js");
645+
await valhallaService.getMatch?.(
646+
[
647+
{ lat: 52.517, lng: 13.388, time: "2026-05-04T08:00:00Z" },
648+
{ lat: 52.521, lng: 13.392, time: "2026-05-04T08:00:30Z" },
649+
],
650+
"driving",
651+
);
652+
653+
const body = JSON.parse(mockFetch.mock.calls[0][1].body as string);
654+
expect(body.shape[0]).toEqual({
655+
lat: 52.517,
656+
lon: 13.388,
657+
time: Math.round(Date.parse("2026-05-04T08:00:00Z") / 1000),
658+
});
659+
expect(body.shape[1].time).toBe(Math.round(Date.parse("2026-05-04T08:00:30Z") / 1000));
660+
});
661+
662+
it("transforms edges: way_id, length km->metres, names, shape indices", async () => {
663+
mockFetch.mockResolvedValueOnce(mockOk(makeTraceResponse()));
664+
665+
const { valhallaService } = await import("@integrations/routing-valhalla/provider.js");
666+
const result = await valhallaService.getMatch?.(trace, "driving");
667+
const edge = result?.edges[0];
668+
669+
expect(edge?.wayId).toBe(12345);
670+
expect(edge?.length).toBe(500); // 0.5 km -> 500 m
671+
expect(edge?.speed).toBe(50);
672+
expect(edge?.surface).toBe("paved");
673+
expect(edge?.names).toEqual(["Friedrichstraße"]);
674+
expect(edge?.beginShapeIndex).toBe(0);
675+
expect(edge?.endShapeIndex).toBe(2);
676+
});
677+
678+
it("transforms matched_points: lon->lng, distance_along_edge passed through as ratio", async () => {
679+
mockFetch.mockResolvedValueOnce(mockOk(makeTraceResponse()));
680+
681+
const { valhallaService } = await import("@integrations/routing-valhalla/provider.js");
682+
const result = await valhallaService.getMatch?.(trace, "driving");
683+
const point = result?.points[0];
684+
685+
expect(point?.lng).toBe(13.388);
686+
expect(point?.lat).toBe(52.517);
687+
expect(point?.type).toBe("matched");
688+
expect(point?.edgeIndex).toBe(0);
689+
// Valhalla returns this as a 0–1 ratio along the edge; we pass it through
690+
// unchanged so consumers can multiply by edges[edgeIndex].length themselves.
691+
expect(point?.distanceAlongEdgeRatio).toBe(0.05);
692+
expect(point?.distanceFromTracePoint).toBe(4.2);
693+
});
694+
695+
it("returns the decoded polyline as geometry and mode in the result", async () => {
696+
mockFetch.mockResolvedValueOnce(mockOk(makeTraceResponse()));
697+
698+
const { valhallaService } = await import("@integrations/routing-valhalla/provider.js");
699+
const result = await valhallaService.getMatch?.(trace, "cycling");
700+
701+
expect(result?.mode).toBe("cycling");
702+
// decodePolyline is module-mocked at the top of the file to a fixed shape.
703+
expect(result?.geometry).toEqual([
704+
[13.388, 52.517],
705+
[13.392, 52.521],
706+
[13.397, 52.529],
707+
[13.405, 52.535],
708+
]);
709+
});
710+
711+
it("returns empty arrays when Valhalla omits shape / edges / matched_points", async () => {
712+
mockFetch.mockResolvedValueOnce(mockOk({}));
713+
714+
const { valhallaService } = await import("@integrations/routing-valhalla/provider.js");
715+
const result = await valhallaService.getMatch?.(trace, "driving");
716+
717+
expect(result?.geometry).toEqual([]);
718+
expect(result?.edges).toEqual([]);
719+
expect(result?.points).toEqual([]);
720+
});
721+
722+
it("throws when the trace has fewer than 2 points", async () => {
723+
const { valhallaService } = await import("@integrations/routing-valhalla/provider.js");
724+
725+
await expect(valhallaService.getMatch?.([{ lat: 1, lng: 2 }], "driving")).rejects.toThrow(
726+
"trace requires at least 2 points",
727+
);
728+
expect(mockFetch).not.toHaveBeenCalled();
729+
});
730+
731+
it("throws on HTTP error", async () => {
732+
mockFetch.mockResolvedValueOnce(mockNotOk(500));
733+
734+
const { valhallaService } = await import("@integrations/routing-valhalla/provider.js");
735+
736+
await expect(valhallaService.getMatch?.(trace, "driving")).rejects.toThrow(
737+
"Valhalla trace_attributes error 500",
738+
);
739+
});
492740
});
493741
});

0 commit comments

Comments
 (0)