Hinweise optimiert
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
import { haversineDistanceNm, sumRouteDistanceNm } from "./geo.js";
|
||||
import type {
|
||||
Coordinate,
|
||||
DepthSample,
|
||||
RouteOption,
|
||||
RouteRequest,
|
||||
RouteResult,
|
||||
@@ -22,7 +21,6 @@ export type FairwayEdge = {
|
||||
from: string;
|
||||
to: string;
|
||||
coordinates: Coordinate[];
|
||||
minDepthM: number | null;
|
||||
maxAirDraftM?: number | null;
|
||||
maxBeamM?: number | null;
|
||||
maxDraughtM?: number | null;
|
||||
@@ -126,7 +124,6 @@ export const EMDEN_BORKUM_DEMO_GRAPH: FairwayGraph = {
|
||||
name: "Emden Außenhafen Ausfahrt",
|
||||
from: "emden-aussenhafen",
|
||||
to: "emden-harbour-leading",
|
||||
minDepthM: 8.5,
|
||||
coordinates: [
|
||||
{ lat: 53.344167, lon: 7.186111 },
|
||||
{ lat: 53.339, lon: 7.191 },
|
||||
@@ -138,7 +135,6 @@ export const EMDEN_BORKUM_DEMO_GRAPH: FairwayGraph = {
|
||||
name: "Emder Leitlinie zum Emsfahrwasser",
|
||||
from: "emden-harbour-leading",
|
||||
to: "ems-fairway-inner",
|
||||
minDepthM: 8.5,
|
||||
coordinates: [
|
||||
{ lat: 53.333897, lon: 7.201469 },
|
||||
{ lat: 53.332236, lon: 7.132959 },
|
||||
@@ -150,7 +146,6 @@ export const EMDEN_BORKUM_DEMO_GRAPH: FairwayGraph = {
|
||||
name: "Inneres Emsfahrwasser",
|
||||
from: "ems-fairway-inner",
|
||||
to: "knock-reach",
|
||||
minDepthM: 8.5,
|
||||
coordinates: [
|
||||
{ lat: 53.33532, lon: 7.132674 },
|
||||
{ lat: 53.333, lon: 7.075 },
|
||||
@@ -162,7 +157,6 @@ export const EMDEN_BORKUM_DEMO_GRAPH: FairwayGraph = {
|
||||
name: "Außenems über Paapsand",
|
||||
from: "knock-reach",
|
||||
to: "paapsand-reach",
|
||||
minDepthM: 7.5,
|
||||
coordinates: [
|
||||
{ lat: 53.327597, lon: 7.015002 },
|
||||
{ lat: 53.323764, lon: 6.985 },
|
||||
@@ -175,7 +169,6 @@ export const EMDEN_BORKUM_DEMO_GRAPH: FairwayGraph = {
|
||||
name: "Westerems Südansteuerung",
|
||||
from: "paapsand-reach",
|
||||
to: "westerems-south",
|
||||
minDepthM: 7,
|
||||
coordinates: [
|
||||
{ lat: 53.339, lon: 6.93 },
|
||||
{ lat: 53.348, lon: 6.9 },
|
||||
@@ -188,7 +181,6 @@ export const EMDEN_BORKUM_DEMO_GRAPH: FairwayGraph = {
|
||||
name: "Westerems Fahrwasser",
|
||||
from: "westerems-south",
|
||||
to: "eemshaven-approach",
|
||||
minDepthM: 7,
|
||||
coordinates: [
|
||||
{ lat: 53.376, lon: 6.865 },
|
||||
{ lat: 53.398, lon: 6.85 },
|
||||
@@ -201,7 +193,6 @@ export const EMDEN_BORKUM_DEMO_GRAPH: FairwayGraph = {
|
||||
name: "Borkum Südansteuerung",
|
||||
from: "eemshaven-approach",
|
||||
to: "borkum-south-approach",
|
||||
minDepthM: 6,
|
||||
coordinates: [
|
||||
{ lat: 53.442996, lon: 6.833146 },
|
||||
{ lat: 53.463, lon: 6.807 },
|
||||
@@ -214,7 +205,6 @@ export const EMDEN_BORKUM_DEMO_GRAPH: FairwayGraph = {
|
||||
name: "Borkum Reede Ansteuerung",
|
||||
from: "borkum-south-approach",
|
||||
to: "borkum-reede",
|
||||
minDepthM: 5,
|
||||
coordinates: [
|
||||
{ lat: 53.505, lon: 6.765 },
|
||||
{ lat: 53.525, lon: 6.758 },
|
||||
@@ -382,19 +372,7 @@ function buildFairwayRouteCandidate(
|
||||
const departureTimestamp = requestedDepartureTimestamp(request.departureTime);
|
||||
const durationMinutes = Math.round((distanceNm / speedKn) * 60);
|
||||
const eta = new Date(departureTimestamp + durationMinutes * 60 * 1000).toISOString();
|
||||
const depthSamples = request.depthSamples ?? edgeDepthSamples(usedEdges);
|
||||
const depthAssessment = assessFairwayDepthSamples(depthSamples, request.vesselProfile);
|
||||
const warnings: RouteWarning[] = [
|
||||
{
|
||||
code: "FAIRWAY_ROUTE",
|
||||
severity: "info",
|
||||
message: `Route über bekannten Fahrwasser-Graphen: ${routableGraph.name}.`
|
||||
},
|
||||
{
|
||||
code: "FAIRWAY_DATA_NOT_OFFICIAL",
|
||||
severity: "caution",
|
||||
message: "Fahrwasser-Graph ist eine MVP-Planungshilfe und keine amtliche Navigationsgrundlage."
|
||||
},
|
||||
...(searchTruncated
|
||||
? [
|
||||
{
|
||||
@@ -406,8 +384,7 @@ function buildFairwayRouteCandidate(
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...routeSnapWarnings(routeSnaps),
|
||||
...depthAssessment.warnings
|
||||
...routeSnapWarnings(routeSnaps)
|
||||
];
|
||||
|
||||
const route: RouteResult = {
|
||||
@@ -420,16 +397,9 @@ function buildFairwayRouteCandidate(
|
||||
departureTime: new Date(departureTimestamp).toISOString(),
|
||||
durationMinutes,
|
||||
warnings,
|
||||
minKnownDepthM: depthAssessment.minKnownDepthM,
|
||||
unknownDepthRatio: depthAssessment.unknownDepthRatio,
|
||||
dataSources: [
|
||||
`fairway-graph:${routableGraph.id}`,
|
||||
...uniqueSources(usedEdges),
|
||||
request.depthSamples?.length
|
||||
? "submitted-depth-samples"
|
||||
: usedEdges.some((edge) => edge.minDepthM === null)
|
||||
? "fairway-depth-unknown"
|
||||
: "fairway-depth-estimates"
|
||||
...uniqueSources(usedEdges)
|
||||
],
|
||||
routingMode: "fairway",
|
||||
routeSnaps
|
||||
@@ -620,14 +590,10 @@ export function mergeFairwayGraphs(id: string, name: string, graphs: FairwayGrap
|
||||
}
|
||||
|
||||
function filterRestrictedEdges(graph: FairwayGraph, request: RouteRequest): FairwayGraph {
|
||||
const requiredDepthM = request.vesselProfile.draughtM + request.vesselProfile.safetyReserveM;
|
||||
return {
|
||||
...graph,
|
||||
maxSnapDistanceNm: Math.min(graph.maxSnapDistanceNm, MAX_ROUTE_SNAP_DISTANCE_NM),
|
||||
edges: graph.edges.filter((edge) => {
|
||||
if (edge.minDepthM !== null && edge.minDepthM < requiredDepthM) {
|
||||
return false;
|
||||
}
|
||||
if (edge.maxDraughtM != null && request.vesselProfile.draughtM > edge.maxDraughtM) {
|
||||
return false;
|
||||
}
|
||||
@@ -1099,13 +1065,6 @@ function edgeLengthNm(edge: FairwayEdge) {
|
||||
return sumRouteDistanceNm(edge.coordinates);
|
||||
}
|
||||
|
||||
function edgeDepthSamples(edges: FairwayEdge[]): DepthSample[] {
|
||||
return edges.map((edge) => ({
|
||||
coordinate: edge.coordinates[Math.floor(edge.coordinates.length / 2)]!,
|
||||
depthM: edge.minDepthM
|
||||
}));
|
||||
}
|
||||
|
||||
function uniqueSources(edges: FairwayEdge[]) {
|
||||
const sources = new Set<string>();
|
||||
for (const edge of edges) {
|
||||
@@ -1114,47 +1073,6 @@ function uniqueSources(edges: FairwayEdge[]) {
|
||||
return [...sources];
|
||||
}
|
||||
|
||||
function assessFairwayDepthSamples(
|
||||
samples: DepthSample[],
|
||||
profile: VesselProfile
|
||||
): {
|
||||
minKnownDepthM: number | null;
|
||||
unknownDepthRatio: number;
|
||||
warnings: RouteWarning[];
|
||||
} {
|
||||
const known = samples.filter((sample) => typeof sample.depthM === "number");
|
||||
const unknownDepthRatio = samples.length > 0 ? round((samples.length - known.length) / samples.length, 2) : 1;
|
||||
const minKnownDepthM = known.length > 0 ? Math.min(...known.map((sample) => sample.depthM!)) : null;
|
||||
const requiredDepthM = round(profile.draughtM + profile.safetyReserveM, 2);
|
||||
const warnings: RouteWarning[] = [];
|
||||
|
||||
if (unknownDepthRatio > 0) {
|
||||
warnings.push({
|
||||
code: "DEPTH_PARTIAL",
|
||||
severity: unknownDepthRatio > 0.5 ? "caution" : "info",
|
||||
message: `${Math.round(unknownDepthRatio * 100)}% der Route haben keine Tiefenprobe.`
|
||||
});
|
||||
}
|
||||
|
||||
if (minKnownDepthM !== null && minKnownDepthM < requiredDepthM) {
|
||||
warnings.push({
|
||||
code: "DEPTH_TOO_SHALLOW",
|
||||
severity: "critical",
|
||||
message: `Minimale bekannte Tiefe ${round(minKnownDepthM, 1)} m unterschreitet erforderliche Tiefe ${requiredDepthM} m.`
|
||||
});
|
||||
}
|
||||
|
||||
if (known.length === 0) {
|
||||
warnings.push({
|
||||
code: "NO_KNOWN_DEPTH",
|
||||
severity: "caution",
|
||||
message: "Alle geprüften Tiefenpunkte sind unbekannt."
|
||||
});
|
||||
}
|
||||
|
||||
return { minKnownDepthM, unknownDepthRatio, warnings };
|
||||
}
|
||||
|
||||
function appendCoordinate(points: Coordinate[], coordinate: Coordinate) {
|
||||
const previous = points.at(-1);
|
||||
if (previous && haversineDistanceNm(previous, coordinate) < 0.000001) {
|
||||
|
||||
@@ -274,7 +274,6 @@ export const EMDEN_HAMM_GRAPH: FairwayGraph = {
|
||||
{ lat: 52.622597, lon: 7.3071821 },
|
||||
{ lat: 52.6170897, lon: 7.3077526 }
|
||||
],
|
||||
minDepthM: null,
|
||||
maxDraughtM: 2.5,
|
||||
source: "openstreetmap-geofabrik-curated-seed"
|
||||
},
|
||||
@@ -454,7 +453,6 @@ export const EMDEN_HAMM_GRAPH: FairwayGraph = {
|
||||
{ lat: 51.6572451, lon: 7.362679 },
|
||||
{ lat: 51.6480728, lon: 7.3527065 }
|
||||
],
|
||||
minDepthM: null,
|
||||
maxDraughtM: 2.5,
|
||||
source: "openstreetmap-nominatim-curated-seed"
|
||||
},
|
||||
@@ -511,7 +509,6 @@ export const EMDEN_HAMM_GRAPH: FairwayGraph = {
|
||||
{ lat: 51.6792115, lon: 7.9600807 },
|
||||
{ lat: 51.677629, lon: 7.9640468 }
|
||||
],
|
||||
minDepthM: null,
|
||||
maxDraughtM: 2.5,
|
||||
source: "openstreetmap-nominatim-curated-seed"
|
||||
}
|
||||
@@ -537,7 +534,6 @@ export const EMDEN_EAST_EMS_GRAPH: FairwayGraph = {
|
||||
name: "Unterems östlich von Emden",
|
||||
from: "emden-east-start",
|
||||
to: "emden-east-destination",
|
||||
minDepthM: null,
|
||||
source: "openstreetmap-geofabrik-curated-seed",
|
||||
coordinates: [
|
||||
{ lat: 53.3422, lon: 7.1871 },
|
||||
|
||||
@@ -6,11 +6,8 @@ import {
|
||||
} from "./fairway-routing.js";
|
||||
import { EMDEN_EAST_EMS_GRAPH, EMDEN_HAMM_GRAPH } from "./inland-seed.js";
|
||||
import type {
|
||||
DepthSample,
|
||||
RouteRequest,
|
||||
RouteResult,
|
||||
RouteWarning,
|
||||
VesselProfile
|
||||
RouteResult
|
||||
} from "./types.js";
|
||||
|
||||
const DEFAULT_CRUISE_SPEED_KN = 6;
|
||||
@@ -41,66 +38,6 @@ export function buildRoute(
|
||||
);
|
||||
}
|
||||
|
||||
export function requiredDepthM(profile: VesselProfile): number {
|
||||
return round(profile.draughtM + profile.safetyReserveM, 2);
|
||||
}
|
||||
|
||||
export function assessDepthSamples(
|
||||
samples: DepthSample[] | undefined,
|
||||
profile: VesselProfile
|
||||
): {
|
||||
minKnownDepthM: number | null;
|
||||
unknownDepthRatio: number;
|
||||
warnings: RouteWarning[];
|
||||
} {
|
||||
if (!samples || samples.length === 0) {
|
||||
return {
|
||||
minKnownDepthM: null,
|
||||
unknownDepthRatio: 1,
|
||||
warnings: [
|
||||
{
|
||||
code: "DEPTH_UNKNOWN",
|
||||
severity: "caution",
|
||||
message: "Keine belastbaren Tiefendaten entlang der Route vorhanden."
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
const known = samples.filter((sample) => typeof sample.depthM === "number");
|
||||
const unknownDepthRatio = round((samples.length - known.length) / samples.length, 2);
|
||||
const minKnownDepthM =
|
||||
known.length > 0 ? Math.min(...known.map((sample) => sample.depthM!)) : null;
|
||||
const warnings: RouteWarning[] = [];
|
||||
const required = requiredDepthM(profile);
|
||||
|
||||
if (unknownDepthRatio > 0) {
|
||||
warnings.push({
|
||||
code: "DEPTH_PARTIAL",
|
||||
severity: unknownDepthRatio > 0.5 ? "caution" : "info",
|
||||
message: `${Math.round(unknownDepthRatio * 100)}% der Route haben keine Tiefenprobe.`
|
||||
});
|
||||
}
|
||||
|
||||
if (minKnownDepthM !== null && minKnownDepthM < required) {
|
||||
warnings.push({
|
||||
code: "DEPTH_TOO_SHALLOW",
|
||||
severity: "critical",
|
||||
message: `Minimale bekannte Tiefe ${round(minKnownDepthM, 1)} m unterschreitet erforderliche Tiefe ${required} m.`
|
||||
});
|
||||
}
|
||||
|
||||
if (known.length === 0) {
|
||||
warnings.push({
|
||||
code: "NO_KNOWN_DEPTH",
|
||||
severity: "caution",
|
||||
message: "Alle geprüften Tiefenpunkte sind unbekannt."
|
||||
});
|
||||
}
|
||||
|
||||
return { minKnownDepthM, unknownDepthRatio, warnings };
|
||||
}
|
||||
|
||||
export function buildManualRoute(request: RouteRequest): RouteResult {
|
||||
const points = [
|
||||
request.start,
|
||||
@@ -116,8 +53,6 @@ export function buildManualRoute(request: RouteRequest): RouteResult {
|
||||
const departureTimestamp = Number.isFinite(parsedDeparture) ? parsedDeparture : Date.now();
|
||||
const durationMinutes = Math.round((distanceNm / speedKn) * 60);
|
||||
const eta = new Date(departureTimestamp + durationMinutes * 60 * 1000).toISOString();
|
||||
const depthAssessment = assessDepthSamples(request.depthSamples, request.vesselProfile);
|
||||
|
||||
return {
|
||||
geometry: {
|
||||
type: "LineString",
|
||||
@@ -132,15 +67,9 @@ export function buildManualRoute(request: RouteRequest): RouteResult {
|
||||
code: "MANUAL_ROUTE",
|
||||
severity: "info",
|
||||
message: "MVP-Route nutzt direkte Wegpunktsegmente ohne automatische Fahrwasserlogik."
|
||||
},
|
||||
...depthAssessment.warnings
|
||||
],
|
||||
minKnownDepthM: depthAssessment.minKnownDepthM,
|
||||
unknownDepthRatio: depthAssessment.unknownDepthRatio,
|
||||
dataSources: [
|
||||
"user-waypoints",
|
||||
request.depthSamples?.length ? "submitted-depth-samples" : "depth-unavailable"
|
||||
}
|
||||
],
|
||||
dataSources: ["user-waypoints"],
|
||||
routingMode: "manual"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -70,8 +70,6 @@ type RouteDetails = {
|
||||
departureTime?: string;
|
||||
durationMinutes?: number;
|
||||
warnings: RouteWarning[];
|
||||
minKnownDepthM: number | null;
|
||||
unknownDepthRatio: number;
|
||||
dataSources: string[];
|
||||
routingMode?: "manual" | "fairway";
|
||||
routeSnaps?: RouteSnaps;
|
||||
@@ -128,18 +126,12 @@ export type TideSummary = {
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type DepthSample = {
|
||||
coordinate: Coordinate;
|
||||
depthM: number | null;
|
||||
};
|
||||
|
||||
export type RouteRequest = {
|
||||
start: Coordinate;
|
||||
destination: Coordinate;
|
||||
waypoints?: Coordinate[];
|
||||
departureTime?: string;
|
||||
vesselProfile: VesselProfile;
|
||||
depthSamples?: DepthSample[];
|
||||
};
|
||||
|
||||
export type AppConfig = {
|
||||
|
||||
@@ -268,8 +268,6 @@ describe("route guidance", () => {
|
||||
distanceNm: 1,
|
||||
eta: null,
|
||||
warnings: [],
|
||||
minKnownDepthM: null,
|
||||
unknownDepthRatio: 1,
|
||||
dataSources: []
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
buildRoute,
|
||||
FairwayRoutingSearchLimitError,
|
||||
haversineDistanceNm,
|
||||
requiredDepthM,
|
||||
type FairwayEdge,
|
||||
type FairwayGraph
|
||||
} from "../src/index.js";
|
||||
@@ -14,6 +13,14 @@ import {
|
||||
const EMDEN_AUSSENHAFEN = { lat: 53.344167, lon: 7.186111 };
|
||||
const BORKUM_REEDE = { lat: 53.563776, lon: 6.750562 };
|
||||
const HAMM_INNENSTADT_MARINA = { lat: 51.6814536, lon: 7.8042615 };
|
||||
const REMOVED_AUTOMATIC_ROUTE_WARNING_CODES = new Set([
|
||||
"FAIRWAY_ROUTE",
|
||||
"FAIRWAY_DATA_NOT_OFFICIAL",
|
||||
"DEPTH_UNKNOWN",
|
||||
"DEPTH_PARTIAL",
|
||||
"NO_KNOWN_DEPTH",
|
||||
"DEPTH_TOO_SHALLOW"
|
||||
]);
|
||||
|
||||
const ALTERNATIVE_GRAPH: FairwayGraph = {
|
||||
id: "alternative-test",
|
||||
@@ -92,7 +99,6 @@ function edge(
|
||||
from,
|
||||
to,
|
||||
coordinates,
|
||||
minDepthM: 4,
|
||||
source: "synthetic-test",
|
||||
...restrictions
|
||||
};
|
||||
@@ -119,32 +125,17 @@ function singleEdgeGraph(restrictions: Partial<FairwayEdge>): FairwayGraph {
|
||||
};
|
||||
}
|
||||
|
||||
describe("route assessment", () => {
|
||||
it("marks routes with no depth data as unknown", () => {
|
||||
describe("route building", () => {
|
||||
it("builds manual routes without synthesizing a depth status", () => {
|
||||
const result = buildManualRoute({
|
||||
start: { lat: 54.18, lon: 12.08 },
|
||||
destination: { lat: 54.32, lon: 12.22 },
|
||||
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5 }
|
||||
});
|
||||
|
||||
expect(result.unknownDepthRatio).toBe(1);
|
||||
expect(result.warnings.some((warning) => warning.code === "DEPTH_UNKNOWN")).toBe(true);
|
||||
});
|
||||
|
||||
it("flags known shallow samples as critical", () => {
|
||||
const result = buildManualRoute({
|
||||
start: { lat: 54.18, lon: 12.08 },
|
||||
destination: { lat: 54.32, lon: 12.22 },
|
||||
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5 },
|
||||
depthSamples: [
|
||||
{ coordinate: { lat: 54.2, lon: 12.1 }, depthM: 1.6 },
|
||||
{ coordinate: { lat: 54.25, lon: 12.16 }, depthM: 2.4 }
|
||||
]
|
||||
});
|
||||
|
||||
expect(requiredDepthM({ draughtM: 1.4, safetyReserveM: 0.5 })).toBe(1.9);
|
||||
expect(result.minKnownDepthM).toBe(1.6);
|
||||
expect(result.warnings.some((warning) => warning.severity === "critical")).toBe(true);
|
||||
expect(result.warnings.map((warning) => warning.code)).toEqual(["MANUAL_ROUTE"]);
|
||||
expect(result).not.toHaveProperty("minKnownDepthM");
|
||||
expect(result).not.toHaveProperty("unknownDepthRatio");
|
||||
});
|
||||
|
||||
it("routes Emden Außenhafen to Borkum Reede along the Ems fairway graph", () => {
|
||||
@@ -175,8 +166,14 @@ describe("route assessment", () => {
|
||||
|
||||
expect(result.routingMode).toBe("fairway");
|
||||
expect(result.dataSources).toContain("fairway-graph:ems-borkum-seed");
|
||||
expect(result.warnings.some((warning) => warning.code === "FAIRWAY_ROUTE")).toBe(true);
|
||||
expect(result.warnings.some((warning) => warning.code === "MANUAL_ROUTE")).toBe(false);
|
||||
expect(
|
||||
result.warnings.some((warning) =>
|
||||
REMOVED_AUTOMATIC_ROUTE_WARNING_CODES.has(warning.code)
|
||||
)
|
||||
).toBe(false);
|
||||
expect(result).not.toHaveProperty("minKnownDepthM");
|
||||
expect(result).not.toHaveProperty("unknownDepthRatio");
|
||||
expect(coordinates.length).toBeGreaterThan(20);
|
||||
expect(result.distanceNm).toBeGreaterThan(directDistanceNm * 1.2);
|
||||
expect(largestSegmentNm).toBeLessThan(6);
|
||||
@@ -606,7 +603,11 @@ describe("route assessment", () => {
|
||||
expect(result?.dataSources).toContain("fairway-graph:emden-hamm-inland-seed");
|
||||
expect(result?.dataSources).toContain("openstreetmap-geofabrik-curated-seed");
|
||||
expect(result?.dataSources).toContain("openstreetmap-nominatim-curated-seed");
|
||||
expect(result?.warnings.some((warning) => warning.code === "FAIRWAY_DATA_NOT_OFFICIAL")).toBe(true);
|
||||
expect(
|
||||
result?.warnings.some((warning) =>
|
||||
REMOVED_AUTOMATIC_ROUTE_WARNING_CODES.has(warning.code)
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("uses the requested departure time as the basis for duration and ETA", () => {
|
||||
|
||||
Reference in New Issue
Block a user