Files
watermaps/packages/shared/src/anchor-watch.ts
T
2026-07-24 11:29:24 +02:00

446 lines
15 KiB
TypeScript

import { haversineDistanceM } from "./geo.js";
import type { Coordinate, TideCurvePoint, TideSummary } from "./types.js";
const DEFAULT_MAX_RELIABLE_ACCURACY_M = 100;
const DEFAULT_SAFETY_ALLOWANCE_M = 0.5;
const MILLISECONDS_PER_HOUR = 60 * 60 * 1_000;
export type AnchorWatchStatus = "safe" | "alarm" | "gps-unreliable";
export type AnchorWatchInput = {
anchorPoint: Coordinate;
position: Coordinate;
alarmRadiusM: number;
accuracyM?: number | null;
maxReliableAccuracyM?: number;
};
export type AnchorWatchResult = {
status: AnchorWatchStatus;
anchorPoint: Coordinate;
position: Coordinate;
alarmRadiusM: number;
accuracyM: number | null;
maxReliableAccuracyM: number;
distanceFromAnchorM: number;
conservativeDistanceFromAnchorM: number | null;
positionReliable: boolean;
isOutsideAlarmRadius: boolean;
isConservativelyOutsideAlarmRadius: boolean;
alarmTriggered: boolean;
};
export type AnchorTideSource =
| Pick<TideSummary, "waterLevelCurve">
| readonly TideCurvePoint[]
| null
| undefined;
export type AnchorTideWindowCoverage = "complete" | "partial" | "unavailable";
export type AnchorTideWindowReason =
| "invalid-window"
| "missing-tide-data"
| "start-outside-coverage"
| "incomplete-horizon"
| null;
export type AnchorTideWindowResult = {
coverage: AnchorTideWindowCoverage;
reason: AnchorTideWindowReason;
fromTime: string | null;
untilTime: string | null;
coveredUntilTime: string | null;
horizonHours: number;
startHeightM: number | null;
minimumHeightM: number | null;
maximumHeightM: number | null;
/** Largest non-negative water-level rise relative to fromTime. */
maximumRiseM: number | null;
/** Maximum minus minimum water level inside the covered part of the window. */
tidalRangeM: number | null;
sampleCount: number;
};
export type AnchorRodePlanInput = {
depthAtSetM: number;
bowRollerHeightM: number;
deployedRodeLengthM: number;
scopeRatio: number;
safetyAllowanceM?: number;
tideWindow?: AnchorTideWindowResult | null;
};
export type AnchorRodePlan = {
calculationComplete: boolean;
depthAtSetM: number;
bowRollerHeightM: number;
deployedRodeLengthM: number;
scopeRatio: number;
safetyAllowanceM: number;
verticalDistanceAtSetM: number;
minimumRequiredRodeLengthM: number;
maximumFutureTideRiseM: number | null;
maximumVerticalDistanceM: number | null;
planningVerticalDistanceM: number | null;
requiredRodeLengthM: number | null;
rodeReserveM: number | null;
hasSufficientRode: boolean | null;
horizontalReachAtSetM: number;
/** Maximum horizontal reach at the highest known water level in the horizon. */
horizontalReachM: number | null;
rodeReachesBottomAtSet: boolean;
rodeReachesBottomAtMaximumTide: boolean | null;
};
type TideSample = {
timestamp: number;
heightM: number;
priority: number;
};
/**
* Evaluates an anchor alarm conservatively. A fix is outside only when the
* complete reported accuracy circle lies beyond the alarm radius. Missing or
* excessive GPS accuracy can therefore never trigger an alarm by itself.
*/
export function evaluateAnchorWatch(input: AnchorWatchInput): AnchorWatchResult | null {
const maxReliableAccuracyM = input.maxReliableAccuracyM ?? DEFAULT_MAX_RELIABLE_ACCURACY_M;
if (
!isCoordinate(input.anchorPoint)
|| !isCoordinate(input.position)
|| !isPositiveFinite(input.alarmRadiusM)
|| !isPositiveFinite(maxReliableAccuracyM)
) {
return null;
}
const accuracyM = isNonNegativeFinite(input.accuracyM) ? input.accuracyM : null;
const distanceFromAnchorM = haversineDistanceM(input.anchorPoint, input.position);
const conservativeDistanceFromAnchorM = accuracyM === null
? null
: Math.max(0, distanceFromAnchorM - accuracyM);
const positionReliable = accuracyM !== null && accuracyM <= maxReliableAccuracyM;
const isOutsideAlarmRadius = distanceFromAnchorM > input.alarmRadiusM;
const isConservativelyOutsideAlarmRadius = conservativeDistanceFromAnchorM !== null
&& conservativeDistanceFromAnchorM > input.alarmRadiusM;
const alarmTriggered = positionReliable && isConservativelyOutsideAlarmRadius;
return {
status: !positionReliable ? "gps-unreliable" : alarmTriggered ? "alarm" : "safe",
anchorPoint: { ...input.anchorPoint },
position: { ...input.position },
alarmRadiusM: input.alarmRadiusM,
accuracyM,
maxReliableAccuracyM,
distanceFromAnchorM,
conservativeDistanceFromAnchorM,
positionReliable,
isOutsideAlarmRadius,
isConservativelyOutsideAlarmRadius,
alarmTriggered
};
}
/**
* Interpolates the tide level at both window boundaries, then includes every
* valid curve sample between them. Measurements take precedence over forecast
* values, which in turn take precedence over astronomical predictions.
*/
export function analyzeTideWindow(
source: AnchorTideSource,
fromMs: number,
horizonHours: number
): AnchorTideWindowResult {
if (!isValidTimestamp(fromMs) || !isPositiveFinite(horizonHours)) {
return unavailableTideWindow("invalid-window", fromMs, horizonHours);
}
const untilMs = fromMs + horizonHours * MILLISECONDS_PER_HOUR;
if (!isValidTimestamp(untilMs) || untilMs <= fromMs) {
return unavailableTideWindow("invalid-window", fromMs, horizonHours);
}
const samples = normalizeTideSamples(tideCurveFromSource(source));
if (samples.length === 0) {
return unavailableTideWindow("missing-tide-data", fromMs, horizonHours, untilMs);
}
const startHeightM = interpolateTideHeight(samples, fromMs);
if (startHeightM === null) {
return unavailableTideWindow("start-outside-coverage", fromMs, horizonHours, untilMs);
}
const lastTimestamp = samples.at(-1)!.timestamp;
const coveredUntilMs = Math.min(untilMs, lastTimestamp);
const endHeightM = interpolateTideHeight(samples, coveredUntilMs);
if (endHeightM === null) {
return unavailableTideWindow("start-outside-coverage", fromMs, horizonHours, untilMs);
}
const windowSamples = [
{ timestamp: fromMs, heightM: startHeightM },
...samples
.filter(({ timestamp }) => timestamp > fromMs && timestamp < coveredUntilMs)
.map(({ timestamp, heightM }) => ({ timestamp, heightM })),
{ timestamp: coveredUntilMs, heightM: endHeightM }
];
const uniqueWindowSamples = deduplicateWindowSamples(windowSamples);
const heights = uniqueWindowSamples.map(({ heightM }) => heightM);
const minimumHeightM = Math.min(...heights);
const maximumHeightM = Math.max(...heights);
const complete = lastTimestamp >= untilMs;
return {
coverage: complete ? "complete" : "partial",
reason: complete ? null : "incomplete-horizon",
fromTime: new Date(fromMs).toISOString(),
untilTime: new Date(untilMs).toISOString(),
coveredUntilTime: new Date(coveredUntilMs).toISOString(),
horizonHours,
startHeightM,
minimumHeightM,
maximumHeightM,
maximumRiseM: Math.max(0, maximumHeightM - startHeightM),
tidalRangeM: maximumHeightM - minimumHeightM,
sampleCount: uniqueWindowSamples.length
};
}
/**
* Plans rode length with the conventional scope ratio:
* (depth + bow roller + future tide rise + safety allowance) * scope.
* A partial or missing tide window intentionally produces null future values
* so that an incomplete forecast cannot be presented as a safe rode length.
*/
export function calculateAnchorRodePlan(input: AnchorRodePlanInput): AnchorRodePlan | null {
const safetyAllowanceM = input.safetyAllowanceM ?? DEFAULT_SAFETY_ALLOWANCE_M;
if (
!isNonNegativeFinite(input.depthAtSetM)
|| !isNonNegativeFinite(input.bowRollerHeightM)
|| !isNonNegativeFinite(input.deployedRodeLengthM)
|| !isPositiveFinite(input.scopeRatio)
|| !isNonNegativeFinite(safetyAllowanceM)
) {
return null;
}
const verticalDistanceAtSetM = input.depthAtSetM + input.bowRollerHeightM;
const minimumPlanningVerticalDistanceM = verticalDistanceAtSetM + safetyAllowanceM;
const minimumRequiredRodeLengthM = minimumPlanningVerticalDistanceM * input.scopeRatio;
if (
!Number.isFinite(verticalDistanceAtSetM)
|| !Number.isFinite(minimumPlanningVerticalDistanceM)
|| !Number.isFinite(minimumRequiredRodeLengthM)
) {
return null;
}
const rodeReachesBottomAtSet = input.deployedRodeLengthM >= verticalDistanceAtSetM;
const horizontalReachAtSetM = horizontalReach(
input.deployedRodeLengthM,
verticalDistanceAtSetM
);
const maximumFutureTideRiseM = completeTideRise(input.tideWindow);
if (maximumFutureTideRiseM === null) {
return {
calculationComplete: false,
depthAtSetM: input.depthAtSetM,
bowRollerHeightM: input.bowRollerHeightM,
deployedRodeLengthM: input.deployedRodeLengthM,
scopeRatio: input.scopeRatio,
safetyAllowanceM,
verticalDistanceAtSetM,
minimumRequiredRodeLengthM,
maximumFutureTideRiseM: null,
maximumVerticalDistanceM: null,
planningVerticalDistanceM: null,
requiredRodeLengthM: null,
rodeReserveM: null,
hasSufficientRode: null,
horizontalReachAtSetM,
horizontalReachM: null,
rodeReachesBottomAtSet,
rodeReachesBottomAtMaximumTide: null
};
}
const maximumVerticalDistanceM = verticalDistanceAtSetM + maximumFutureTideRiseM;
const planningVerticalDistanceM = maximumVerticalDistanceM + safetyAllowanceM;
const requiredRodeLengthM = planningVerticalDistanceM * input.scopeRatio;
const rodeReserveM = input.deployedRodeLengthM - requiredRodeLengthM;
if (
!Number.isFinite(maximumVerticalDistanceM)
|| !Number.isFinite(planningVerticalDistanceM)
|| !Number.isFinite(requiredRodeLengthM)
|| !Number.isFinite(rodeReserveM)
) {
return null;
}
const rodeReachesBottomAtMaximumTide =
input.deployedRodeLengthM >= maximumVerticalDistanceM;
return {
calculationComplete: true,
depthAtSetM: input.depthAtSetM,
bowRollerHeightM: input.bowRollerHeightM,
deployedRodeLengthM: input.deployedRodeLengthM,
scopeRatio: input.scopeRatio,
safetyAllowanceM,
verticalDistanceAtSetM,
minimumRequiredRodeLengthM,
maximumFutureTideRiseM,
maximumVerticalDistanceM,
planningVerticalDistanceM,
requiredRodeLengthM,
rodeReserveM,
hasSufficientRode: rodeReserveM >= 0,
horizontalReachAtSetM,
horizontalReachM: horizontalReach(input.deployedRodeLengthM, maximumVerticalDistanceM),
rodeReachesBottomAtSet,
rodeReachesBottomAtMaximumTide
};
}
function completeTideRise(tideWindow: AnchorTideWindowResult | null | undefined): number | null {
return tideWindow?.coverage === "complete" && isNonNegativeFinite(tideWindow.maximumRiseM)
? tideWindow.maximumRiseM
: null;
}
function tideCurveFromSource(source: AnchorTideSource): readonly TideCurvePoint[] {
if (Array.isArray(source)) {
return source;
}
if (source && typeof source === "object" && "waterLevelCurve" in source) {
return Array.isArray(source.waterLevelCurve) ? source.waterLevelCurve : [];
}
return [];
}
function normalizeTideSamples(curve: readonly TideCurvePoint[]): TideSample[] {
const byTimestamp = new Map<number, TideSample>();
for (const point of curve) {
const timestamp = Date.parse(point.time);
const value = tidePointValue(point);
if (!Number.isFinite(timestamp) || !value) {
continue;
}
const existing = byTimestamp.get(timestamp);
if (!existing || value.priority > existing.priority) {
byTimestamp.set(timestamp, { timestamp, ...value });
}
}
return [...byTimestamp.values()].sort((a, b) => a.timestamp - b.timestamp);
}
function tidePointValue(point: TideCurvePoint): Pick<TideSample, "heightM" | "priority"> | null {
if (isFiniteNumber(point.measuredM)) {
return { heightM: point.measuredM, priority: 3 };
}
if (isFiniteNumber(point.forecastM)) {
return { heightM: point.forecastM, priority: 2 };
}
if (isFiniteNumber(point.predictedM)) {
return { heightM: point.predictedM, priority: 1 };
}
return null;
}
function interpolateTideHeight(samples: TideSample[], timestamp: number): number | null {
if (timestamp < samples[0]!.timestamp || timestamp > samples.at(-1)!.timestamp) {
return null;
}
let low = 0;
let high = samples.length - 1;
while (low <= high) {
const middle = Math.floor((low + high) / 2);
const sample = samples[middle]!;
if (sample.timestamp === timestamp) {
return sample.heightM;
}
if (sample.timestamp < timestamp) {
low = middle + 1;
} else {
high = middle - 1;
}
}
const before = samples[high];
const after = samples[low];
if (!before || !after || after.timestamp === before.timestamp) {
return null;
}
const fraction = (timestamp - before.timestamp) / (after.timestamp - before.timestamp);
return before.heightM + (after.heightM - before.heightM) * fraction;
}
function unavailableTideWindow(
reason: Exclude<AnchorTideWindowReason, "incomplete-horizon" | null>,
fromMs: number,
horizonHours: number,
untilMs?: number
): AnchorTideWindowResult {
return {
coverage: "unavailable",
reason,
fromTime: isoTimestamp(fromMs),
untilTime: typeof untilMs === "number" ? isoTimestamp(untilMs) : null,
coveredUntilTime: null,
horizonHours,
startHeightM: null,
minimumHeightM: null,
maximumHeightM: null,
maximumRiseM: null,
tidalRangeM: null,
sampleCount: 0
};
}
function deduplicateWindowSamples<T extends { timestamp: number }>(samples: T[]): T[] {
const byTimestamp = new Map<number, T>();
for (const sample of samples) {
byTimestamp.set(sample.timestamp, sample);
}
return [...byTimestamp.values()].sort((a, b) => a.timestamp - b.timestamp);
}
function horizontalReach(rodeLengthM: number, verticalDistanceM: number): number {
if (rodeLengthM <= verticalDistanceM) {
return 0;
}
const verticalRatio = verticalDistanceM / rodeLengthM;
return rodeLengthM * Math.sqrt(Math.max(0, 1 - verticalRatio ** 2));
}
function isValidTimestamp(value: number): boolean {
return Number.isFinite(value) && Number.isFinite(new Date(value).getTime());
}
function isoTimestamp(value: number): string | null {
return isValidTimestamp(value) ? new Date(value).toISOString() : null;
}
function isCoordinate(value: Coordinate): boolean {
return Boolean(value)
&& isFiniteNumber(value.lat)
&& value.lat >= -90
&& value.lat <= 90
&& isFiniteNumber(value.lon)
&& value.lon >= -180
&& value.lon <= 180;
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}
function isNonNegativeFinite(value: unknown): value is number {
return isFiniteNumber(value) && value >= 0;
}
function isPositiveFinite(value: unknown): value is number {
return isFiniteNumber(value) && value > 0;
}