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 | 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(); 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 | 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, 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(samples: T[]): T[] { const byTimestamp = new Map(); 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; }