Initial Watermaps import

This commit is contained in:
BuTzZ
2026-07-24 11:29:24 +02:00
commit 57f7b4dedb
129 changed files with 43136 additions and 0 deletions
+288
View File
@@ -0,0 +1,288 @@
import { describe, expect, it } from "vitest";
import {
analyzeTideWindow,
calculateAnchorRodePlan,
evaluateAnchorWatch,
type AnchorTideWindowResult,
type TideCurvePoint,
type TideSummary
} from "../src/index.js";
const METRES_PER_EQUATOR_DEGREE = 111_195.08;
const START = Date.parse("2026-07-20T01:00:00.000Z");
function latitudeOffsetM(metres: number): number {
return metres / METRES_PER_EQUATOR_DEGREE;
}
function curve(): TideCurvePoint[] {
return [
{ time: "2026-07-20T00:00:00.000Z", predictedM: 1 },
{
time: "2026-07-20T02:00:00.000Z",
predictedM: 2,
forecastM: 2.5,
measuredM: 3
},
{ time: "2026-07-20T04:00:00.000Z", predictedM: 0 },
{ time: "2026-07-20T06:00:00.000Z", predictedM: 2 }
];
}
function completeTide(maximumRiseM = 1): AnchorTideWindowResult {
return {
coverage: "complete",
reason: null,
fromTime: "2026-07-20T00:00:00.000Z",
untilTime: "2026-07-20T06:00:00.000Z",
coveredUntilTime: "2026-07-20T06:00:00.000Z",
horizonHours: 6,
startHeightM: 0,
minimumHeightM: 0,
maximumHeightM: maximumRiseM,
maximumRiseM,
tidalRangeM: maximumRiseM,
sampleCount: 3
};
}
describe("anchor watch", () => {
it("alarms only when the entire GPS accuracy circle is outside the anchor radius", () => {
const result = evaluateAnchorWatch({
anchorPoint: { lat: 0, lon: 0 },
position: { lat: latitudeOffsetM(150), lon: 0 },
alarmRadiusM: 100,
accuracyM: 20,
maxReliableAccuracyM: 50
});
expect(result).not.toBeNull();
expect(result?.distanceFromAnchorM).toBeCloseTo(150, 0);
expect(result?.conservativeDistanceFromAnchorM).toBeCloseTo(130, 0);
expect(result?.positionReliable).toBe(true);
expect(result?.isOutsideAlarmRadius).toBe(true);
expect(result?.isConservativelyOutsideAlarmRadius).toBe(true);
expect(result?.alarmTriggered).toBe(true);
expect(result?.status).toBe("alarm");
});
it("does not alarm while GPS uncertainty still overlaps the permitted circle", () => {
const result = evaluateAnchorWatch({
anchorPoint: { lat: 0, lon: 0 },
position: { lat: latitudeOffsetM(110), lon: 0 },
alarmRadiusM: 100,
accuracyM: 20
});
expect(result?.isOutsideAlarmRadius).toBe(true);
expect(result?.conservativeDistanceFromAnchorM).toBeCloseTo(90, 0);
expect(result?.isConservativelyOutsideAlarmRadius).toBe(false);
expect(result?.alarmTriggered).toBe(false);
expect(result?.status).toBe("safe");
});
it("suppresses alarms for inaccurate or missing accuracy information", () => {
const inaccurate = evaluateAnchorWatch({
anchorPoint: { lat: 0, lon: 0 },
position: { lat: latitudeOffsetM(400), lon: 0 },
alarmRadiusM: 100,
accuracyM: 150,
maxReliableAccuracyM: 100
});
const missing = evaluateAnchorWatch({
anchorPoint: { lat: 0, lon: 0 },
position: { lat: latitudeOffsetM(400), lon: 0 },
alarmRadiusM: 100
});
expect(inaccurate?.isConservativelyOutsideAlarmRadius).toBe(true);
expect(inaccurate?.positionReliable).toBe(false);
expect(inaccurate?.alarmTriggered).toBe(false);
expect(inaccurate?.status).toBe("gps-unreliable");
expect(missing?.conservativeDistanceFromAnchorM).toBeNull();
expect(missing?.positionReliable).toBe(false);
expect(missing?.alarmTriggered).toBe(false);
});
it("rejects invalid watch geometry and configuration without throwing", () => {
expect(evaluateAnchorWatch({
anchorPoint: { lat: Number.NaN, lon: 7 },
position: { lat: 53, lon: 7 },
alarmRadiusM: 100,
accuracyM: 10
})).toBeNull();
expect(evaluateAnchorWatch({
anchorPoint: { lat: 53, lon: 7 },
position: { lat: 53, lon: 7 },
alarmRadiusM: 0,
accuracyM: 10
})).toBeNull();
});
});
describe("anchor tide window", () => {
it("interpolates both window boundaries and calculates rise and tidal range", () => {
const result = analyzeTideWindow(curve(), START, 4);
expect(result.coverage).toBe("complete");
expect(result.reason).toBeNull();
expect(result.startHeightM).toBeCloseTo(2, 6);
expect(result.minimumHeightM).toBe(0);
expect(result.maximumHeightM).toBe(3);
expect(result.maximumRiseM).toBe(1);
expect(result.tidalRangeM).toBe(3);
expect(result.coveredUntilTime).toBe("2026-07-20T05:00:00.000Z");
expect(result.sampleCount).toBe(4);
});
it("accepts a TideSummary and prioritises measurements over forecasts and predictions", () => {
const summary: TideSummary = {
station: "Test",
distanceKm: 1,
nextHigh: null,
nextLow: null,
waterLevelCurve: curve(),
source: "test",
updatedAt: "2026-07-20T00:00:00.000Z"
};
const result = analyzeTideWindow(summary, Date.parse("2026-07-20T02:00:00Z"), 1);
expect(result.startHeightM).toBe(3);
expect(result.maximumHeightM).toBe(3);
});
it("reports partial coverage and still describes only the known part", () => {
const result = analyzeTideWindow(curve(), START, 8);
expect(result.coverage).toBe("partial");
expect(result.reason).toBe("incomplete-horizon");
expect(result.untilTime).toBe("2026-07-20T09:00:00.000Z");
expect(result.coveredUntilTime).toBe("2026-07-20T06:00:00.000Z");
expect(result.maximumRiseM).toBe(1);
expect(result.tidalRangeM).toBe(3);
});
it("handles missing, malformed, and out-of-coverage tide data", () => {
expect(analyzeTideWindow(undefined, START, 6)).toEqual(
expect.objectContaining({ coverage: "unavailable", reason: "missing-tide-data" })
);
expect(analyzeTideWindow([
{ time: "not-a-time", predictedM: 2 },
{ time: "2026-07-20T00:00:00Z", predictedM: null }
], START, 6)).toEqual(
expect.objectContaining({ coverage: "unavailable", reason: "missing-tide-data" })
);
expect(analyzeTideWindow(curve(), Date.parse("2026-07-19T20:00:00Z"), 2)).toEqual(
expect.objectContaining({ coverage: "unavailable", reason: "start-outside-coverage" })
);
expect(analyzeTideWindow(curve(), Number.NaN, 6)).toEqual(
expect.objectContaining({ coverage: "unavailable", reason: "invalid-window" })
);
expect(analyzeTideWindow(curve(), Number.MAX_VALUE, 6)).toEqual(
expect.objectContaining({ coverage: "unavailable", reason: "invalid-window", fromTime: null })
);
expect(analyzeTideWindow(curve(), START, 0)).toEqual(
expect.objectContaining({ coverage: "unavailable", reason: "invalid-window" })
);
});
it("never treats a falling tide as a negative future rise", () => {
const falling = analyzeTideWindow([
{ time: "2026-07-20T00:00:00Z", predictedM: 3 },
{ time: "2026-07-20T02:00:00Z", predictedM: 2 },
{ time: "2026-07-20T04:00:00Z", predictedM: 1 }
], Date.parse("2026-07-20T00:00:00Z"), 4);
expect(falling.coverage).toBe("complete");
expect(falling.maximumRiseM).toBe(0);
expect(falling.tidalRangeM).toBe(2);
});
});
describe("anchor rode plan", () => {
it("includes tide rise, bow roller, safety allowance, and selected scope", () => {
const plan = calculateAnchorRodePlan({
depthAtSetM: 4,
bowRollerHeightM: 1,
deployedRodeLengthM: 40,
scopeRatio: 5,
safetyAllowanceM: 0.5,
tideWindow: completeTide(1)
});
expect(plan).not.toBeNull();
expect(plan?.calculationComplete).toBe(true);
expect(plan?.verticalDistanceAtSetM).toBe(5);
expect(plan?.minimumRequiredRodeLengthM).toBe(27.5);
expect(plan?.maximumVerticalDistanceM).toBe(6);
expect(plan?.planningVerticalDistanceM).toBe(6.5);
expect(plan?.requiredRodeLengthM).toBe(32.5);
expect(plan?.rodeReserveM).toBe(7.5);
expect(plan?.hasSufficientRode).toBe(true);
expect(plan?.horizontalReachM).toBeCloseTo(Math.sqrt(40 ** 2 - 6 ** 2), 6);
expect(plan?.rodeReachesBottomAtMaximumTide).toBe(true);
});
it("reports a negative reserve and insufficient rode", () => {
const plan = calculateAnchorRodePlan({
depthAtSetM: 4,
bowRollerHeightM: 1,
deployedRodeLengthM: 30,
scopeRatio: 5,
safetyAllowanceM: 0.5,
tideWindow: completeTide(1)
});
expect(plan?.requiredRodeLengthM).toBe(32.5);
expect(plan?.rodeReserveM).toBe(-2.5);
expect(plan?.hasSufficientRode).toBe(false);
});
it("does not present an incomplete tide horizon as a safe future plan", () => {
const partialTide = { ...completeTide(1), coverage: "partial" as const, reason: "incomplete-horizon" as const };
const plan = calculateAnchorRodePlan({
depthAtSetM: 4,
bowRollerHeightM: 1,
deployedRodeLengthM: 40,
scopeRatio: 5,
tideWindow: partialTide
});
expect(plan?.calculationComplete).toBe(false);
expect(plan?.minimumRequiredRodeLengthM).toBe(27.5);
expect(plan?.requiredRodeLengthM).toBeNull();
expect(plan?.rodeReserveM).toBeNull();
expect(plan?.hasSufficientRode).toBeNull();
expect(plan?.horizontalReachAtSetM).toBeCloseTo(Math.sqrt(40 ** 2 - 5 ** 2), 6);
expect(plan?.horizontalReachM).toBeNull();
});
it("validates physical inputs and handles a rode shorter than the vertical drop", () => {
expect(calculateAnchorRodePlan({
depthAtSetM: -1,
bowRollerHeightM: 1,
deployedRodeLengthM: 20,
scopeRatio: 5,
tideWindow: completeTide()
})).toBeNull();
expect(calculateAnchorRodePlan({
depthAtSetM: 4,
bowRollerHeightM: 1,
deployedRodeLengthM: 20,
scopeRatio: 0,
tideWindow: completeTide()
})).toBeNull();
const tooShort = calculateAnchorRodePlan({
depthAtSetM: 8,
bowRollerHeightM: 2,
deployedRodeLengthM: 9,
scopeRatio: 3,
tideWindow: completeTide(1)
});
expect(tooShort?.horizontalReachAtSetM).toBe(0);
expect(tooShort?.horizontalReachM).toBe(0);
expect(tooShort?.rodeReachesBottomAtSet).toBe(false);
expect(tooShort?.rodeReachesBottomAtMaximumTide).toBe(false);
});
});