Initial Watermaps import
This commit is contained in:
@@ -0,0 +1,445 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user