Optimized routing
Test and publish container images / test (push) Successful in 2m21s
Test and publish container images / publish (push) Failing after 4s

This commit is contained in:
BuTzZ
2026-07-27 10:18:31 +02:00
parent 55ce94066e
commit 9813a1fafb
16 changed files with 1602 additions and 364 deletions
+113 -14
View File
@@ -3,8 +3,10 @@ import Fastify, { type FastifyInstance } from "fastify";
import { z } from "zod";
import {
buildFairwayRoutes,
EMDEN_BORKUM_DEMO_GRAPH,
EMDEN_EAST_EMS_GRAPH,
EMDEN_HAMM_GRAPH,
FairwayRoutingSearchLimitError,
type FairwayGraph,
type RouteOption,
type RouteRequest,
@@ -13,7 +15,10 @@ import {
import { loadEnv, type ApiEnv } from "./env.js";
import { createCache, type Cache } from "./services/cache.js";
import { appConfig } from "./services/config.js";
import { FairwayService } from "./services/fairways.js";
import {
FairwayService,
type FairwayGraphLookup
} from "./services/fairways.js";
import { FeatureService } from "./services/features.js";
import { getNearestTideSummary } from "./services/tides.js";
import { getMarineForecast } from "./services/weather.js";
@@ -186,14 +191,45 @@ export async function buildServer(deps: AppDeps = {}): Promise<FastifyInstance>
}
let sourceError: unknown;
const dynamicGraphs = await fairwayService.getGraphsForRoute(parsed.data).catch((error) => {
const graphLookup = await fairwayService.getGraphsForRoute(parsed.data).catch((error) => {
sourceError = error;
app.log.warn({ error }, "fairway extraction failed");
return [];
return { graphs: [], failures: [] } satisfies FairwayGraphLookup;
});
const route = buildRouteFromGraphs(parsed.data, dynamicGraphs);
if (graphLookup.failures.length > 0) {
app.log.warn(
{
failures: graphLookup.failures.map(({ source, error }) => ({
source,
error
}))
},
"one or more fairway sources failed"
);
}
let route: RouteResult | null;
try {
route = buildRouteFromGraphs(parsed.data, graphLookup.graphs, env.demoData);
} catch (error) {
if (error instanceof FairwayRoutingSearchLimitError) {
if (sourceError || graphLookup.failures.length > 0) {
return reply.code(503).send({
error: "fairway_sources_unavailable",
message:
"Fahrwasserdaten sind momentan nicht verfügbar. Prüfe den lokalen Deutschland-/Niederlande-Index oder versuche es später erneut."
});
}
return reply.code(422).send({
error: "fairway_search_limited",
message:
"Das Fahrwassernetz ist an den gewählten Punkten zu dicht für eine verlässliche Routensuche. Setze Start, Ziel oder Zwischenziele eindeutiger auf das gewünschte Fahrwasser."
});
}
throw error;
}
if (!route) {
if (sourceError) {
if (sourceError || graphLookup.failures.length > 0) {
return reply.code(503).send({
error: "fairway_sources_unavailable",
message:
@@ -227,32 +263,95 @@ function commaSeparatedQuery(maxItems: number) {
});
}
function buildRouteFromGraphs(request: RouteRequest, graphs: FairwayGraph[]) {
function buildRouteFromGraphs(
request: RouteRequest,
graphs: FairwayGraph[],
allowSeedGraphs: boolean
) {
let searchWasLimited = false;
for (const graph of graphs) {
const routes = buildFairwayRoutes(request, graph);
let routes: RouteOption[];
try {
routes = buildFairwayRoutes(request, graph);
} catch (error) {
if (error instanceof FairwayRoutingSearchLimitError) {
searchWasLimited = true;
continue;
}
throw error;
}
if (routes.length > 0) {
return routeResultWithAlternatives(routes);
return routeResultWithAlternatives(routes, searchWasLimited);
}
}
for (const graph of [undefined, EMDEN_EAST_EMS_GRAPH, EMDEN_HAMM_GRAPH] as const) {
const routes = graph ? buildFairwayRoutes(request, graph) : buildFairwayRoutes(request);
if (!allowSeedGraphs) {
if (searchWasLimited) {
throw new FairwayRoutingSearchLimitError();
}
return null;
}
for (const graph of [
EMDEN_BORKUM_DEMO_GRAPH,
EMDEN_EAST_EMS_GRAPH,
EMDEN_HAMM_GRAPH
]) {
let routes: RouteOption[];
try {
routes = buildFairwayRoutes(request, graph);
} catch (error) {
if (error instanceof FairwayRoutingSearchLimitError) {
searchWasLimited = true;
continue;
}
throw error;
}
if (routes.length > 0) {
return routeResultWithAlternatives(routes);
return routeResultWithAlternatives(routes, searchWasLimited);
}
}
if (searchWasLimited) {
throw new FairwayRoutingSearchLimitError();
}
return null;
}
function routeResultWithAlternatives(routes: RouteOption[]): RouteResult {
function routeResultWithAlternatives(
routes: RouteOption[],
searchWasLimited = false
): RouteResult {
const [primary, ...alternatives] = routes;
if (!primary) {
throw new Error("routeResultWithAlternatives requires at least one route");
}
const annotate = (route: RouteOption): RouteOption => {
if (
!searchWasLimited ||
route.warnings.some((warning) => warning.code === "ROUTE_SEARCH_LIMITED")
) {
return route;
}
return {
...route,
warnings: [
...route.warnings,
{
code: "ROUTE_SEARCH_LIMITED",
severity: "caution",
message:
"Eine dichtere Datenquelle überschritt das feste Suchbudget. " +
"Diese Route stammt aus einer vollständig geprüften Alternative, ist aber möglicherweise nicht graphübergreifend die kürzeste."
}
]
};
};
const annotatedPrimary = annotate(primary);
return {
...primary,
alternatives
...annotatedPrimary,
alternatives: alternatives.map(annotate)
};
}
+1 -1
View File
@@ -20,7 +20,7 @@ export function loadEnv(env: NodeJS.ProcessEnv = process.env): ApiEnv {
(env.NODE_ENV === "test"
? undefined
: "data/local/germany-netherlands-fairways.json"),
demoData: (env.WATERMAPS_DEMO_DATA ?? env.SEA_COMPASS_DEMO_DATA) !== "false",
demoData: (env.WATERMAPS_DEMO_DATA ?? env.SEA_COMPASS_DEMO_DATA) === "true",
liveOsmFairways:
(env.WATERMAPS_LIVE_FAIRWAYS ?? env.SEA_COMPASS_LIVE_FAIRWAYS) !== "false" &&
env.NODE_ENV !== "test"
+68 -98
View File
@@ -37,6 +37,18 @@ type FairwayDeps = {
localDataPath?: string;
};
export type FairwaySourceId = "configuration" | "postgis" | "local" | "live";
export type FairwaySourceFailure = {
source: FairwaySourceId;
error: unknown;
};
export type FairwayGraphLookup = {
graphs: FairwayGraph[];
failures: FairwaySourceFailure[];
};
export type FairwayRow = {
id: string;
source: string;
@@ -56,9 +68,8 @@ const MAX_BBOX_SPAN_DEG = 3;
const MAX_POSTGIS_BBOX_SPAN_DEG = 6;
const MAX_LOCAL_BBOX_SPAN_DEG = 15;
const BBOX_MARGIN_DEG = 0.15;
const ENDPOINT_SNAP_DEG = 0.0001;
const CONNECTOR_DISTANCE_NM = 0.08;
const CONNECTOR_GRID_DEG = 0.003;
const ENDPOINT_SNAP_DEG = 0.000001;
const MAX_FAIRWAY_SNAP_DISTANCE_NM = 150 / 1852;
export class FairwayService {
private readonly cache: Cache;
@@ -76,29 +87,58 @@ export class FairwayService {
this.pool = deps.databaseUrl ? new pg.Pool({ connectionString: deps.databaseUrl }) : null;
}
async getGraphsForRoute(request: RouteRequest): Promise<FairwayGraph[]> {
const results = await Promise.allSettled([
this.getPostgisGraphForRoute(request),
this.getLocalGraphForRoute(request),
this.getLiveGraphForRoute(request)
]);
const graphs = results.flatMap((result) =>
result.status === "fulfilled" && result.value ? [result.value] : []
);
async getGraphsForRoute(request: RouteRequest): Promise<FairwayGraphLookup> {
if (!this.pool && !this.localDataPath && !this.liveEnabled) {
return {
graphs: [],
failures: [
{
source: "configuration",
error: new Error("No fairway source is configured")
}
]
};
}
if (graphs.length === 0) {
const failures = results.filter((result): result is PromiseRejectedResult => result.status === "rejected");
if (failures.length > 0) {
throw new AggregateError(failures.map((failure) => failure.reason), "No fairway source produced a graph");
const sources: Array<{
source: Exclude<FairwaySourceId, "configuration">;
load: () => Promise<FairwayGraph | null>;
}> = [];
if (this.pool) {
sources.push({ source: "postgis", load: () => this.getPostgisGraphForRoute(request) });
}
if (this.localDataPath) {
sources.push({ source: "local", load: () => this.getLocalGraphForRoute(request) });
}
if (this.liveEnabled) {
sources.push({ source: "live", load: () => this.getLiveGraphForRoute(request) });
}
const results = await Promise.allSettled(sources.map(({ load }) => load()));
const graphs: FairwayGraph[] = [];
const failures: FairwaySourceFailure[] = [];
for (const [index, result] of results.entries()) {
if (result.status === "fulfilled") {
if (result.value) {
graphs.push(result.value);
}
} else {
failures.push({
source: sources[index]!.source,
error: result.reason
});
}
}
if (graphs.length < 2) {
return graphs;
return { graphs, failures };
}
const combined = mergeConnectedFairwayGraphs(graphs);
return combined ? [combined, ...graphs] : graphs;
return {
graphs: combined ? [combined, ...graphs] : graphs,
failures
};
}
async close(): Promise<void> {
@@ -169,19 +209,18 @@ export class FairwayService {
throw new Error(`Unsupported local fairway data format: ${this.localDataPath}`);
}
return document;
})
.catch((error: NodeJS.ErrnoException) => {
if (error.code === "ENOENT") {
return null;
}
throw error;
});
}
const document = await this.localDocument;
if (!document) {
this.localDocument = null;
const currentLoad = this.localDocument;
try {
return await currentLoad;
} catch (error) {
if (this.localDocument === currentLoad) {
this.localDocument = null;
}
throw error;
}
return document;
}
private async getLiveGraphForRoute(request: RouteRequest): Promise<FairwayGraph | null> {
@@ -386,7 +425,6 @@ export function mergeConnectedFairwayGraphs(graphs: FairwayGraph[]): FairwayGrap
}
}
addEndpointConnectors(nodes, edges);
if (edges.size === 0) {
return null;
}
@@ -445,8 +483,6 @@ function waysToGraph(
}
}
addEndpointConnectors(nodes, edges);
if (edges.size === 0) {
return null;
}
@@ -454,7 +490,7 @@ function waysToGraph(
return {
id,
name,
maxSnapDistanceNm: 2,
maxSnapDistanceNm: MAX_FAIRWAY_SNAP_DISTANCE_NM,
nodes: [...nodes.values()],
edges: [...edges.values()]
};
@@ -543,65 +579,6 @@ function nodeIdFor(nodes: Map<string, FairwayNode>, coordinate: Coordinate) {
return id;
}
function addEndpointConnectors(nodes: Map<string, FairwayNode>, edges: Map<string, FairwayEdge>) {
const degree = new Map<string, number>();
for (const edge of edges.values()) {
degree.set(edge.from, (degree.get(edge.from) ?? 0) + 1);
degree.set(edge.to, (degree.get(edge.to) ?? 0) + 1);
}
const endpoints = [...nodes.values()].filter((node) => (degree.get(node.id) ?? 0) <= 1);
const buckets = new Map<string, FairwayNode[]>();
for (const endpoint of endpoints) {
const [x, y] = connectorCell(endpoint.coordinate);
const key = `${x}:${y}`;
buckets.set(key, [...(buckets.get(key) ?? []), endpoint]);
}
const connectorIds = new Set<string>();
for (const first of endpoints) {
const [cellX, cellY] = connectorCell(first.coordinate);
for (let dx = -1; dx <= 1; dx += 1) {
for (let dy = -1; dy <= 1; dy += 1) {
for (const second of buckets.get(`${cellX + dx}:${cellY + dy}`) ?? []) {
if (first.id >= second.id) {
continue;
}
const distanceNm = distanceApproxNm(first.coordinate, second.coordinate);
if (distanceNm === 0 || distanceNm > CONNECTOR_DISTANCE_NM) {
continue;
}
const connectorId = `connector-${first.id}-${second.id}`;
if (connectorIds.has(connectorId)) {
continue;
}
connectorIds.add(connectorId);
edges.set(connectorId, {
id: connectorId,
name: "Fahrwasser-Verbindung",
from: first.id,
to: second.id,
coordinates: [first.coordinate, second.coordinate],
minDepthM: null,
source: "fairway-graph-connectors"
});
}
}
}
}
}
function connectorCell(coordinate: Coordinate) {
return [
Math.floor(coordinate.lon / CONNECTOR_GRID_DEG),
Math.floor(coordinate.lat / CONNECTOR_GRID_DEG)
] as const;
}
function parseDepth(tags: Record<string, string>) {
const candidates = [
tags["seamark:fairway:minimum_depth"],
@@ -699,10 +676,3 @@ function sourceFor(tags: Record<string, string>) {
}
return "osm-overpass";
}
function distanceApproxNm(a: Coordinate, b: Coordinate) {
const meanLatRad = ((a.lat + b.lat) / 2) * (Math.PI / 180);
const x = (a.lon - b.lon) * 60 * Math.cos(meanLatRad);
const y = (a.lat - b.lat) * 60;
return Math.sqrt(x * x + y * y);
}