Optimized routing
This commit is contained in:
+113
-14
@@ -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
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user