446 lines
15 KiB
TypeScript
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;
|
|
}
|