From a8ef70dffbfaeb754131737b24b46aafebe49ed3 Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Sun, 1 Feb 2026 17:59:47 -0500 Subject: [PATCH 1/2] Add explicit types for station predictions --- packages/neaps/src/index.ts | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/packages/neaps/src/index.ts b/packages/neaps/src/index.ts index 690ab423..eb5efafb 100644 --- a/packages/neaps/src/index.ts +++ b/packages/neaps/src/index.ts @@ -6,7 +6,13 @@ import { type NearOptions, type NearestOptions, } from "@neaps/tide-database"; -import { createTidePredictor, type ExtremesInput, type TimelineInput } from "@neaps/tide-predictor"; +import { + createTidePredictor, + type ExtremesInput, + type TimelineInput, + type Extreme, + type TimelinePoint, +} from "@neaps/tide-predictor"; type Units = "meters" | "feet"; type PredictionOptions = { @@ -24,6 +30,31 @@ export type ExtremesOptions = ExtremesInput & PredictionOptions; export type TimelineOptions = TimelineInput & PredictionOptions; export type WaterLevelOptions = { time: Date } & PredictionOptions; +export type StationPrediction = { + datum: string | undefined; + units: Units; + station: Station; + distance?: number; +}; + +export type StationExtremesPrediction = StationPrediction & { + extremes: Extreme[]; +}; + +export type StationTimelinePrediction = StationPrediction & { + timeline: TimelinePoint[]; +}; + +export type StationWaterLevelPrediction = StationPrediction & TimelinePoint; + +export type StationPredictor = Station & { + distance?: number; + defaultDatum?: string; + getExtremesPrediction: (options: ExtremesOptions) => StationExtremesPrediction; + getTimelinePrediction: (options: TimelineOptions) => StationTimelinePrediction; + getWaterLevelAtTime: (options: WaterLevelOptions) => StationWaterLevelPrediction; +}; + const feetPerMeter = 3.2808399; const defaultUnits: Units = "meters"; @@ -95,7 +126,7 @@ export function findStation(query: string) { return useStation(found); } -export function useStation(station: Station, distance?: number) { +export function useStation(station: Station, distance?: number): StationPredictor { // If subordinate station, use the reference station for datums and constituents let reference = station; if (station.type === "subordinate" && station.offsets?.reference) { From b68c5838451952322676f65dd1d3fb451665133a Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Tue, 3 Feb 2026 07:19:53 -0500 Subject: [PATCH 2/2] Move useStation into @neaps/tide-pridictor --- .changeset/free-pumas-occur.md | 6 + packages/neaps/src/index.ts | 165 +------- packages/neaps/test/index.test.ts | 404 ------------------- packages/tide-predictor/package.json | 4 +- packages/tide-predictor/src/index.ts | 2 + packages/tide-predictor/src/station.ts | 182 +++++++++ packages/tide-predictor/test/index.test.ts | 44 +- packages/tide-predictor/test/station.test.ts | 368 +++++++++++++++++ 8 files changed, 609 insertions(+), 566 deletions(-) create mode 100644 .changeset/free-pumas-occur.md create mode 100644 packages/tide-predictor/src/station.ts create mode 100644 packages/tide-predictor/test/station.test.ts diff --git a/.changeset/free-pumas-occur.md b/.changeset/free-pumas-occur.md new file mode 100644 index 00000000..1909cde3 --- /dev/null +++ b/.changeset/free-pumas-occur.md @@ -0,0 +1,6 @@ +--- +"@neaps/tide-predictor": minor +"neaps": minor +--- + +Moved `useStation` into @neaps/tide-predictor so it can be used without the heavy dependency of @neaps/tide-database. diff --git a/packages/neaps/src/index.ts b/packages/neaps/src/index.ts index eb5efafb..ca16647a 100644 --- a/packages/neaps/src/index.ts +++ b/packages/neaps/src/index.ts @@ -2,62 +2,18 @@ import { stations, near, nearest, - type Station, type NearOptions, type NearestOptions, } from "@neaps/tide-database"; import { - createTidePredictor, - type ExtremesInput, - type TimelineInput, - type Extreme, - type TimelinePoint, + useStation, + type Station, + type StationPredictor, + type StationExtremesOptions, + type StationTimelineOptions, + type StationWaterLevelOptions, } from "@neaps/tide-predictor"; -type Units = "meters" | "feet"; -type PredictionOptions = { - /** Datum to return predictions in. Defaults to the nearest station's datum. */ - datum?: string; - - /** Units for returned water levels. Defaults to 'meters'. */ - units?: Units; - - /** Nodal correction fundamentals. Defaults to 'iho'. */ - nodeCorrections?: "iho" | "schureman"; -}; - -export type ExtremesOptions = ExtremesInput & PredictionOptions; -export type TimelineOptions = TimelineInput & PredictionOptions; -export type WaterLevelOptions = { time: Date } & PredictionOptions; - -export type StationPrediction = { - datum: string | undefined; - units: Units; - station: Station; - distance?: number; -}; - -export type StationExtremesPrediction = StationPrediction & { - extremes: Extreme[]; -}; - -export type StationTimelinePrediction = StationPrediction & { - timeline: TimelinePoint[]; -}; - -export type StationWaterLevelPrediction = StationPrediction & TimelinePoint; - -export type StationPredictor = Station & { - distance?: number; - defaultDatum?: string; - getExtremesPrediction: (options: ExtremesOptions) => StationExtremesPrediction; - getTimelinePrediction: (options: TimelineOptions) => StationTimelinePrediction; - getWaterLevelAtTime: (options: WaterLevelOptions) => StationWaterLevelPrediction; -}; - -const feetPerMeter = 3.2808399; -const defaultUnits: Units = "meters"; - /** * Get extremes prediction using the nearest station to the given position. * @@ -73,21 +29,21 @@ const defaultUnits: Units = "meters"; * datum: 'MLLW', // optional, defaults to station's datum * }) */ -export function getExtremesPrediction(options: NearestOptions & ExtremesOptions) { +export function getExtremesPrediction(options: NearestOptions & StationExtremesOptions) { return nearestStation(options).getExtremesPrediction(options); } /** * Get timeline prediction using the nearest station to the given position. */ -export function getTimelinePrediction(options: NearestOptions & TimelineOptions) { +export function getTimelinePrediction(options: NearestOptions & StationTimelineOptions) { return nearestStation(options).getTimelinePrediction(options); } /** * Get water level at a specific time using the nearest station to the given position. */ -export function getWaterLevelAtTime(options: NearestOptions & WaterLevelOptions) { +export function getWaterLevelAtTime(options: NearestOptions & StationWaterLevelOptions) { return nearestStation(options).getWaterLevelAtTime(options); } @@ -97,7 +53,7 @@ export function getWaterLevelAtTime(options: NearestOptions & WaterLevelOptions) export function nearestStation(options: NearestOptions) { const data = nearest(options); if (!data) throw new Error(`No stations found with options: ${JSON.stringify(options)}`); - return useStation(...data); + return useStation(...data, findStation); } /** @@ -105,13 +61,13 @@ export function nearestStation(options: NearestOptions) { * @param limit Maximum number of stations to return (default: 10) */ export function stationsNear(options: NearOptions) { - return near(options).map(([station, distance]) => useStation(station, distance)); + return near(options).map(([station, distance]) => useStation(station, distance, findStation)); } /** * Find a specific station by its ID or source ID. */ -export function findStation(query: string) { +export function findStation(query: string): StationPredictor { const searches = [(s: Station) => s.id === query, (s: Station) => s.source.id === query]; let found: Station | undefined = undefined; @@ -122,100 +78,5 @@ export function findStation(query: string) { } if (!found) throw new Error(`Station not found: ${query}`); - - return useStation(found); -} - -export function useStation(station: Station, distance?: number): StationPredictor { - // If subordinate station, use the reference station for datums and constituents - let reference = station; - if (station.type === "subordinate" && station.offsets?.reference) { - reference = findStation(station.offsets?.reference); - } - const { datums, harmonic_constituents } = reference; - - // Use station chart datum as the default datum if available - const defaultDatum = station.chart_datum in datums ? station.chart_datum : undefined; - - function getPredictor({ datum = defaultDatum, nodeCorrections }: PredictionOptions = {}) { - let offset = 0; - - if (datum) { - const datumOffset = datums?.[datum]; - const mslOffset = datums?.["MSL"]; - - if (typeof datumOffset !== "number") { - throw new Error( - `Station ${station.id} missing ${datum} datum. Available datums: ${Object.keys(datums).join(", ")}`, - ); - } - - if (typeof mslOffset !== "number") { - throw new Error( - `Station ${station.id} missing MSL datum, so predictions can't be given in ${datum}.`, - ); - } - - offset = mslOffset - datumOffset; - } - - return createTidePredictor(harmonic_constituents, { offset, nodeCorrections }); - } - - return { - ...station, - distance, - datums, - harmonic_constituents, - defaultDatum, - getExtremesPrediction({ - datum = defaultDatum, - units = defaultUnits, - nodeCorrections, - ...options - }: ExtremesOptions) { - const extremes = getPredictor({ datum, nodeCorrections }) - .getExtremesPrediction({ ...options, offsets: station.offsets }) - .map((e) => toPreferredUnits(e, units)); - - return { datum, units, station, distance, extremes }; - }, - - getTimelinePrediction({ - datum = defaultDatum, - units = defaultUnits, - nodeCorrections, - ...options - }: TimelineOptions) { - const timeline = getPredictor({ datum, nodeCorrections }) - .getTimelinePrediction({ ...options, offsets: station.offsets }) - .map((e) => toPreferredUnits(e, units)); - - return { datum, units, station, distance, timeline }; - }, - - getWaterLevelAtTime({ - time, - datum = defaultDatum, - units = defaultUnits, - nodeCorrections, - }: WaterLevelOptions) { - const prediction = toPreferredUnits( - getPredictor({ datum, nodeCorrections }).getWaterLevelAtTime({ - time, - offsets: station.offsets, - }), - units, - ); - - return { datum, units, station, distance, ...prediction }; - }, - }; -} - -function toPreferredUnits(prediction: T, units: Units): T { - let { level } = prediction; - if (units === "feet") level *= feetPerMeter; - else if (units !== "meters") throw new Error(`Unsupported units: ${units}`); - return { ...prediction, level }; + return useStation(found, undefined, findStation); } diff --git a/packages/neaps/test/index.test.ts b/packages/neaps/test/index.test.ts index 6f9b658f..75dea0c6 100644 --- a/packages/neaps/test/index.test.ts +++ b/packages/neaps/test/index.test.ts @@ -1,56 +1,13 @@ -import { stations } from "@neaps/tide-database"; import { getExtremesPrediction, nearestStation, findStation, - useStation, getTimelinePrediction, getWaterLevelAtTime, stationsNear, } from "../src/index.js"; import { describe, test, expect } from "vitest"; -describe("timezone independence", () => { - const location = { lat: 26.772, lon: -80.05 }; - - test("equivalent instants yield identical extremes", () => { - const station = nearestStation(location); - - // Same instant range expressed in different offsets - const utc = { - start: new Date("2025-12-18T00:00:00Z"), - end: new Date("2025-12-19T00:00:00Z"), - datum: "MLLW" as const, - }; - const newYork = { - start: new Date("2025-12-17T19:00:00-05:00"), - end: new Date("2025-12-18T19:00:00-05:00"), - datum: "MLLW" as const, - }; - const tokyo = { - start: new Date("2025-12-18T09:00:00+09:00"), - end: new Date("2025-12-19T09:00:00+09:00"), - datum: "MLLW" as const, - }; - - const baseline = station.getExtremesPrediction(utc).extremes; - const ny = station.getExtremesPrediction(newYork).extremes; - const jp = station.getExtremesPrediction(tokyo).extremes; - - [ny, jp].forEach((result) => { - expect(result.length).toBe(baseline.length); - result.forEach((extreme, index) => { - const base = baseline[index]; - expect(extreme.time.valueOf()).toBe(base.time.valueOf()); - expect(extreme.high).toBe(base.high); - expect(extreme.low).toBe(base.low); - expect(extreme.label).toBe(base.label); - expect(extreme.level).toBeCloseTo(base.level, 6); - }); - }); - }); -}); - describe("getExtremesPrediction", () => { const options = { lat: 26.772, @@ -179,241 +136,6 @@ describe("for a specific station", () => { }); }); - describe("for a subordinate station", () => { - const station = findStation("8724307"); - - test("gets datums and harmonic_constituents from reference station", () => { - expect(station.type).toBe("subordinate"); - const reference = findStation("8724580"); - - expect(station.datums).toBeDefined(); - expect(station.datums).toEqual(reference.datums); - expect(station.harmonic_constituents).toBeDefined(); - expect(station.harmonic_constituents).toEqual(reference.harmonic_constituents); - expect(station.defaultDatum).toBe("MLLW"); - }); - - describe("getExtremesPrediction", () => { - test("matches NOAA extremes for subordinate station", () => { - const start = new Date("2025-12-17T00:00:00Z"); - const end = new Date("2025-12-19T00:00:00Z"); - - const prediction = station.getExtremesPrediction({ - start, - end, - timeFidelity: 60, - datum: "MLLW", - }); - - // https://api.tidesandcurrents.noaa.gov/api/prod/datagetter?station=8724307&format=json&product=predictions&units=metric&time_zone=gmt&begin_date=2025-12-17&end_date=2025-12-18&interval=hilo&datum=MLLW - const noaa = [ - { t: "2025-12-17T02:55:00z", v: 1.128, type: "H" }, - { t: "2025-12-17T10:57:00z", v: -0.044, type: "L" }, - { t: "2025-12-17T16:48:00z", v: 0.658, type: "H" }, - { t: "2025-12-17T22:04:00z", v: 0.337, type: "L" }, - { t: "2025-12-18T03:33:00z", v: 1.148, type: "H" }, - { t: "2025-12-18T11:35:00z", v: -0.099, type: "L" }, - { t: "2025-12-18T17:25:00z", v: 0.64, type: "H" }, - { t: "2025-12-18T22:40:00z", v: 0.316, type: "L" }, - ]; - - noaa.forEach((expected, index) => { - const actual = prediction.extremes[index]; - expect(actual.time).toBeWithin(new Date(expected.t).valueOf(), 5 * 60 * 1000 /* min */); - expect(actual.level).toBeWithin(expected.v, 0.04 /* m */); - }); - }); - }); - - describe("getTimelinePrediction", () => { - test("returns interpolated timeline", () => { - const prediction = station.getTimelinePrediction({ - start: new Date("2025-12-17T00:00:00Z"), - end: new Date("2025-12-17T01:00:00Z"), - datum: "MLLW", - }); - - expect(prediction.timeline.length).toBe(7); // 10-min intervals for 1 hour - expect(prediction.datum).toBe("MLLW"); - expect(prediction.units).toBe("meters"); - prediction.timeline.forEach((point) => { - expect(typeof point.level).toBe("number"); - expect(point.level).not.toBeNaN(); - }); - }); - - test("timeline levels are consistent with extremes", () => { - const start = new Date("2025-12-17T00:00:00Z"); - const end = new Date("2025-12-18T00:00:00Z"); - - const { extremes } = station.getExtremesPrediction({ start, end, datum: "MLLW" }); - const { timeline } = station.getTimelinePrediction({ start, end, datum: "MLLW" }); - - const timelineLevels = timeline.map((p) => p.level); - const maxTimeline = Math.max(...timelineLevels); - const minTimeline = Math.min(...timelineLevels); - - // Timeline max/min should be close to (but not exceed) the extremes - const highExtremes = extremes.filter((e) => e.high).map((e) => e.level); - const lowExtremes = extremes.filter((e) => e.low).map((e) => e.level); - - expect(maxTimeline).toBeLessThanOrEqual(Math.max(...highExtremes) + 0.01); - expect(minTimeline).toBeGreaterThanOrEqual(Math.min(...lowExtremes) - 0.01); - }); - - test("tidal range is scaled by height offset ratios", () => { - const start = new Date("2025-01-15T00:00:00Z"); - const end = new Date("2025-01-18T00:00:00Z"); - - const reference = findStation("8724580"); - const { timeline: refTimeline } = reference.getTimelinePrediction({ - start, - end, - datum: "MLLW", - }); - const { timeline: subTimeline } = station.getTimelinePrediction({ - start, - end, - datum: "MLLW", - }); - - // Height ratio offsets: high=2.13, low=1.83 - // Subordinate levels should be roughly in the range [1.83x, 2.13x] of reference - // but time-shifted, so we compare overall scale rather than point-by-point. - const refRange = - Math.max(...refTimeline.map((p) => p.level)) - - Math.min(...refTimeline.map((p) => p.level)); - const subRange = - Math.max(...subTimeline.map((p) => p.level)) - - Math.min(...subTimeline.map((p) => p.level)); - const rangeRatio = subRange / refRange; - - // Range ratio should be between the low and high height ratios - expect(rangeRatio).toBeGreaterThan(1.83); - expect(rangeRatio).toBeLessThan(2.13); - }); - }); - - describe("getWaterLevelAtTime", () => { - test("returns water level at specific time", () => { - const prediction = station.getWaterLevelAtTime({ - time: new Date("2025-12-17T12:00:00Z"), - datum: "MLLW", - }); - - expect(prediction.time).toEqual(new Date("2025-12-17T12:00:00Z")); - expect(prediction.datum).toBe("MLLW"); - expect(typeof prediction.level).toBe("number"); - expect(prediction.level).not.toBeNaN(); - }); - }); - }); - - describe("subordinate vs reference curve comparison", () => { - const start = new Date("2025-01-15T00:00:00Z"); - const end = new Date("2025-01-18T00:00:00Z"); - - function rmsError(a: { level: number }[], b: { level: number }[]): number { - let sumSq = 0; - for (let i = 0; i < a.length; i++) { - const diff = a[i].level - b[i].level; - sumSq += diff * diff; - } - return Math.sqrt(sumSq / a.length); - } - - function tidalRange(timeline: { level: number }[]): number { - const levels = timeline.map((p) => p.level); - return Math.max(...levels) - Math.min(...levels); - } - - describe("identity offsets (Cabrillo Beach: height=1.0/1.0, time=0/0)", () => { - const sub = findStation("9410650"); - const ref = findStation(sub.offsets!.reference!); - - test("timeline matches reference curve", () => { - const { timeline: refTimeline } = ref.getTimelinePrediction({ start, end }); - const { timeline: subTimeline } = sub.getTimelinePrediction({ start, end }); - - expect(subTimeline.length).toBe(refTimeline.length); - expect(rmsError(subTimeline, refTimeline) / tidalRange(refTimeline)).toBeLessThan(0.05); - }); - - test("extremes match reference", () => { - const { extremes: refExtremes } = ref.getExtremesPrediction({ start, end }); - const { extremes: subExtremes } = sub.getExtremesPrediction({ start, end }); - - expect(subExtremes.length).toBe(refExtremes.length); - for (let i = 0; i < refExtremes.length; i++) { - expect(subExtremes[i].level).toBeCloseTo(refExtremes[i].level, 2); - expect(subExtremes[i].high).toBe(refExtremes[i].high); - } - }); - }); - - describe("time-only offsets (Hanauma Bay: height=1.0/1.0, time=-59/-45 min)", () => { - const sub = findStation("1612301"); - const ref = findStation(sub.offsets!.reference!); - - test("timeline has same tidal range as reference", () => { - const { timeline: refTimeline } = ref.getTimelinePrediction({ start, end }); - const { timeline: subTimeline } = sub.getTimelinePrediction({ start, end }); - - // Same height ratios (1.0), so tidal ranges should be nearly equal - const refRange = tidalRange(refTimeline); - const subRange = tidalRange(subTimeline); - expect(subRange / refRange).toBeGreaterThan(0.95); - expect(subRange / refRange).toBeLessThan(1.05); - }); - - test("extremes are time-shifted but same height as reference", () => { - const { extremes: refExtremes } = ref.getExtremesPrediction({ start, end }); - const { extremes: subExtremes } = sub.getExtremesPrediction({ start, end }); - - expect(subExtremes.length).toBe(refExtremes.length); - for (let i = 0; i < refExtremes.length; i++) { - // Heights should match (ratio=1.0) - expect(subExtremes[i].level).toBeCloseTo(refExtremes[i].level, 2); - // Times should differ by the offset (high=-59min, low=-45min) - const timeDiffMin = - (subExtremes[i].time.getTime() - refExtremes[i].time.getTime()) / 60000; - const expectedOffset = subExtremes[i].high ? -59 : -45; - expect(timeDiffMin).toBeCloseTo(expectedOffset, 0); - } - }); - }); - - describe("height-only offsets (Great Diamond Island: height=1.0/1.03, time=0/0)", () => { - const sub = findStation("8417988"); - const ref = findStation(sub.offsets!.reference!); - - test("extremes occur at same times as reference", () => { - const { extremes: refExtremes } = ref.getExtremesPrediction({ start, end }); - const { extremes: subExtremes } = sub.getExtremesPrediction({ start, end }); - - expect(subExtremes.length).toBe(refExtremes.length); - for (let i = 0; i < refExtremes.length; i++) { - // Times should be identical (time offset = 0) - expect(subExtremes[i].time.getTime()).toBe(refExtremes[i].time.getTime()); - // Heights: high should match (1.0), low should be scaled by 1.03 - if (subExtremes[i].high) { - expect(subExtremes[i].level).toBeCloseTo(refExtremes[i].level, 2); - } else { - expect(subExtremes[i].level).toBeCloseTo(refExtremes[i].level * 1.03, 2); - } - } - }); - - test("timeline closely follows reference curve", () => { - const { timeline: refTimeline } = ref.getTimelinePrediction({ start, end }); - const { timeline: subTimeline } = sub.getTimelinePrediction({ start, end }); - - // With only a 3% low ratio difference and no time shift, curves should be close - expect(rmsError(subTimeline, refTimeline) / tidalRange(refTimeline)).toBeLessThan(0.1); - }); - }); - }); - describe("getTimelinePrediction", () => { test("gets timeline", () => { const prediction = station.getTimelinePrediction({ @@ -483,129 +205,3 @@ describe("findStation", () => { expect(station.getExtremesPrediction).toBeDefined(); }); }); - -describe("nodeCorrections", () => { - const station = findStation("noaa/8722588"); - const corrections = ["iho", "schureman"] as const; - - test("getExtremesPrediction produces different results", () => { - const options = { - start: new Date("2025-12-17T00:00:00Z"), - end: new Date("2025-12-18T00:00:00Z"), - timeFidelity: 60, - datum: "MLLW", - }; - - const [iho, schureman] = corrections.map((nodeCorrections) => - station.getExtremesPrediction({ ...options, nodeCorrections }), - ); - - expect(iho.extremes.length).toBeGreaterThan(0); - expect(schureman.extremes.length).toBeGreaterThan(0); - - const ihoLevels = iho.extremes.map((e) => e.level); - const schuremanLevels = schureman.extremes.map((e) => e.level); - expect(ihoLevels).not.toEqual(schuremanLevels); - }); - - test("getTimelinePrediction produces different results", () => { - const options = { - start: new Date("2025-12-19T00:00:00Z"), - end: new Date("2025-12-19T01:00:00Z"), - }; - - const [iho, schureman] = corrections.map((nodeCorrections) => - station.getTimelinePrediction({ ...options, nodeCorrections }), - ); - - expect(iho.timeline.length).toBeGreaterThan(0); - expect(schureman.timeline.length).toBeGreaterThan(0); - - const ihoLevels = iho.timeline.map((e) => e.level); - const schuremanLevels = schureman.timeline.map((e) => e.level); - expect(ihoLevels).not.toEqual(schuremanLevels); - }); - - test("getWaterLevelAtTime produces different results", () => { - const options = { - time: new Date("2025-12-19T00:30:00Z"), - datum: "MLLW", - }; - - const [iho, schureman] = corrections.map((nodeCorrections) => - station.getWaterLevelAtTime({ ...options, nodeCorrections }), - ); - - expect(iho.level).not.toBe(schureman.level); - }); -}); - -describe("datum", () => { - test("defaults to station's chart datum", () => { - const station = findStation("noaa/8722274"); - const noaa = station.getExtremesPrediction({ - start: new Date("2025-12-17T00:00:00Z"), - end: new Date("2025-12-18T00:00:00Z"), - }); - expect(noaa.datum).toBe("MLLW"); - - const aus = findStation("ticon/fremantle-62230-aus-bom").getExtremesPrediction({ - start: new Date("2025-12-17T00:00:00Z"), - end: new Date("2025-12-18T00:00:00Z"), - }); - expect(aus.datum).toBe("LAT"); - }); - - test("accepts datum option", () => { - const station = findStation("8722274"); - const extremes = station.getExtremesPrediction({ - start: new Date("2025-12-17T00:00:00Z"), - end: new Date("2025-12-18T00:00:00Z"), - datum: "NAVD88", - }); - expect(extremes.datum).toBe("NAVD88"); - }); - - test("throws error for unavailable datum", () => { - const station = findStation("noaa/8443970"); - expect(() => { - station.getExtremesPrediction({ - start: new Date("2025-12-17T00:00:00Z"), - end: new Date("2025-12-18T00:00:00Z"), - datum: "UNKNOWN_DATUM", - }); - }).toThrow(/missing UNKNOWN_DATUM/); - }); - - test("throws error when missing MSL datum", () => { - // Take a real station and strip its MSL datum to force the error path - const real = stations.find((s) => s.type === "reference" && Object.keys(s.datums).length > 1); - if (!real) expect.fail("This should never fail"); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { MSL: _, ...datums } = real.datums; - // Create a station without MSL datum - const station = { ...real, datums }; - expect(() => { - useStation(station).getExtremesPrediction({ - start: new Date("2025-12-17T00:00:00Z"), - end: new Date("2025-12-18T00:00:00Z"), - datum: Object.keys(datums)[0], - }); - }).toThrow(/missing MSL/); - }); - - test("does not apply datums when non available", () => { - // Find a station with no datums - const station = stations.find( - (s) => s.type === "reference" && Object.entries(s.datums).length === 0, - ); - if (!station) expect.fail("No station without datums found"); - const extremes = useStation(station).getExtremesPrediction({ - start: new Date("2025-12-17T00:00:00Z"), - end: new Date("2025-12-18T00:00:00Z"), - }); - - expect(extremes.datum).toBeUndefined(); - expect(extremes.extremes.length).toBeGreaterThan(0); - }); -}); diff --git a/packages/tide-predictor/package.json b/packages/tide-predictor/package.json index 6d867228..efaf57e8 100644 --- a/packages/tide-predictor/package.json +++ b/packages/tide-predictor/package.json @@ -21,7 +21,9 @@ "files": [ "dist" ], - "devDependencies": {}, + "devDependencies": { + "@neaps/tide-database": "*" + }, "scripts": { "build": "tsdown", "prepack": "npm run build" diff --git a/packages/tide-predictor/src/index.ts b/packages/tide-predictor/src/index.ts index 4baa09f9..0d6388cc 100644 --- a/packages/tide-predictor/src/index.ts +++ b/packages/tide-predictor/src/index.ts @@ -99,3 +99,5 @@ createTidePredictor.constituents = constituents; /** @deprecated Use `import { createTidePredictor } from "@neaps/tide-predictor";` */ export default createTidePredictor; +export type { HarmonicConstituent, TimelinePoint, Extreme }; +export * from "./station.js"; diff --git a/packages/tide-predictor/src/station.ts b/packages/tide-predictor/src/station.ts new file mode 100644 index 00000000..d8150187 --- /dev/null +++ b/packages/tide-predictor/src/station.ts @@ -0,0 +1,182 @@ +import tidePredictor, { + type ExtremesInput, + type TimelineInput, + type Extreme, + type TimelinePoint, +} from "./index.js"; +import type { HarmonicConstituent, ExtremeOffsets } from "./harmonics/index.js"; + +export type Units = "meters" | "feet"; + +export type Station = { + id: string; + name: string; + continent: string; + country: string; + region?: string; + timezone: string; + disclaimers: string; + latitude: number; + longitude: number; + + // Data source information + source: { + name: string; + id: string; + url: string; + }; + + datums: Record; + chart_datum?: string; + type: "reference" | "subordinate"; + harmonic_constituents: HarmonicConstituent[]; + offsets?: { reference?: string } & ExtremeOffsets; +}; + +export type StationPredictionOptions = { + /** Datum to return predictions in. Defaults to the station chart datum when available. */ + datum?: string; + + /** Units for returned water levels. Defaults to 'meters'. */ + units?: Units; + + /** Nodal correction fundamentals. Defaults to 'iho'. */ + nodeCorrections?: "iho" | "schureman"; +}; + +export type StationExtremesOptions = ExtremesInput & StationPredictionOptions; +export type StationTimelineOptions = TimelineInput & StationPredictionOptions; +export type StationWaterLevelOptions = { time: Date } & StationPredictionOptions; + +export type StationPrediction = { + datum: string | undefined; + units: Units; + station: Station; + distance?: number; +}; + +export type StationExtremesPrediction = StationPrediction & { + extremes: Extreme[]; +}; + +export type StationTimelinePrediction = StationPrediction & { + timeline: TimelinePoint[]; +}; + +export type StationWaterLevelPrediction = StationPrediction & TimelinePoint; + +export type StationPredictor = Station & { + distance?: number; + defaultDatum?: string; + harmonic_constituents: HarmonicConstituent[]; + getExtremesPrediction: (options: StationExtremesOptions) => StationExtremesPrediction; + getTimelinePrediction: (options: StationTimelineOptions) => StationTimelinePrediction; + getWaterLevelAtTime: (options: StationWaterLevelOptions) => StationWaterLevelPrediction; +}; + +const feetPerMeter = 3.2808399; +const defaultUnits: Units = "meters"; + +export function useStation( + station: Station, + distance?: number, + findStation?: (query: string) => StationPredictor, +): StationPredictor { + // If subordinate station, use the reference station for datums and constituents + let reference = station; + if (station.type === "subordinate" && station.offsets?.reference) { + if (!findStation) + throw new Error( + "findStation function must be provided to resolve subordinate station references.", + ); + reference = findStation(station.offsets?.reference); + } + + const { datums, harmonic_constituents } = reference; + + // Use station chart datum as the default datum if available + const defaultDatum = + station.chart_datum && station.chart_datum in datums ? station.chart_datum : undefined; + + function getPredictor({ datum = defaultDatum, nodeCorrections }: StationPredictionOptions = {}) { + let offset = 0; + + if (datum) { + const datumOffset = datums?.[datum]; + const mslOffset = datums?.["MSL"]; + + if (typeof datumOffset !== "number") { + throw new Error( + `Station ${station.id} missing ${datum} datum. Available datums: ${Object.keys(datums).join(", ")}`, + ); + } + + if (typeof mslOffset !== "number") { + throw new Error( + `Station ${station.id} missing MSL datum, so predictions can't be given in ${datum}.`, + ); + } + + offset = mslOffset - datumOffset; + } + + return tidePredictor(harmonic_constituents, { offset, nodeCorrections }); + } + + return { + ...station, + distance, + datums, + harmonic_constituents, + defaultDatum, + getExtremesPrediction({ + datum = defaultDatum, + units = defaultUnits, + nodeCorrections, + ...options + }: StationExtremesOptions) { + const extremes = getPredictor({ datum, nodeCorrections }) + .getExtremesPrediction({ ...options, offsets: station.offsets }) + .map((e) => toPreferredUnits(e, units)); + + return { datum, units, station, distance, extremes }; + }, + + getTimelinePrediction({ + datum = defaultDatum, + units = defaultUnits, + nodeCorrections, + ...options + }: StationTimelineOptions) { + const timeline = getPredictor({ datum, nodeCorrections }) + .getTimelinePrediction({ ...options, offsets: station.offsets }) + .map((e) => toPreferredUnits(e, units)); + + return { datum, units, station, distance, timeline }; + }, + + getWaterLevelAtTime({ + time, + datum = defaultDatum, + units = defaultUnits, + nodeCorrections, + }: StationWaterLevelOptions) { + const prediction = toPreferredUnits( + getPredictor({ datum, nodeCorrections }).getWaterLevelAtTime({ + time, + offsets: station.offsets, + }), + units, + ); + + return { datum, units, station, distance, ...prediction }; + }, + }; +} + +function toPreferredUnits(prediction: T, units: Units): T { + let { level } = prediction; + if (units === "feet") level *= feetPerMeter; + else if (units !== "meters") throw new Error(`Unsupported units: ${units}`); + return { ...prediction, level }; +} diff --git a/packages/tide-predictor/test/index.test.ts b/packages/tide-predictor/test/index.test.ts index d2b4f60e..6d4473c9 100644 --- a/packages/tide-predictor/test/index.test.ts +++ b/packages/tide-predictor/test/index.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; import mockConstituents from "./_mocks/constituents.js"; -import tidePrediction from "../src/index.js"; +import { createTidePredictor } from "../src/index.js"; const startDate = new Date("2019-09-01T00:00:00Z"); const endDate = new Date("2019-09-01T06:00:00Z"); @@ -10,14 +10,14 @@ describe("Tidal station", () => { let stationCreated = true; try { - tidePrediction(mockConstituents); + createTidePredictor(mockConstituents); } catch { stationCreated = false; } expect(stationCreated).toBe(true); try { - tidePrediction(mockConstituents); + createTidePredictor(mockConstituents); } catch { stationCreated = false; } @@ -25,7 +25,7 @@ describe("Tidal station", () => { }); it("it predicts the tides in a timeline", () => { - const results = tidePrediction(mockConstituents).getTimelinePrediction({ + const results = createTidePredictor(mockConstituents).getTimelinePrediction({ start: startDate, end: endDate, }); @@ -36,7 +36,7 @@ describe("Tidal station", () => { }); it("it predicts the tides in a timeline with time fidelity", () => { - const results = tidePrediction(mockConstituents).getTimelinePrediction({ + const results = createTidePredictor(mockConstituents).getTimelinePrediction({ start: startDate, end: endDate, timeFidelity: 60, @@ -48,7 +48,7 @@ describe("Tidal station", () => { }); it("it predicts the tidal extremes", () => { - const results = tidePrediction(mockConstituents).getExtremesPrediction({ + const results = createTidePredictor(mockConstituents).getExtremesPrediction({ start: startDate, end: endDate, }); @@ -56,7 +56,7 @@ describe("Tidal station", () => { }); it("it predicts the tidal extremes with high fidelity", () => { - const results = tidePrediction(mockConstituents).getExtremesPrediction({ + const results = createTidePredictor(mockConstituents).getExtremesPrediction({ start: startDate, end: endDate, timeFidelity: 60, @@ -65,17 +65,43 @@ describe("Tidal station", () => { }); it("it fetches a single water level", () => { - const result = tidePrediction(mockConstituents).getWaterLevelAtTime({ + const result = createTidePredictor(mockConstituents).getWaterLevelAtTime({ time: startDate, }); expect(result.level).toBeCloseTo(-1.46903456, 4); }); it("it adds offset phases", () => { - const results = tidePrediction(mockConstituents, { + const results = createTidePredictor(mockConstituents, { offset: 3, }).getExtremesPrediction({ start: startDate, end: endDate }); expect(results[0].level).toBeCloseTo(1.32716067, 4); }); + + it("equivalent instants in different timezones yield identical extremes", () => { + const predictor = createTidePredictor(mockConstituents); + + const utc = { start: new Date("2019-09-01T00:00:00Z"), end: new Date("2019-09-01T06:00:00Z") }; + const newYork = { + start: new Date("2019-08-31T20:00:00-04:00"), + end: new Date("2019-09-01T02:00:00-04:00"), + }; + const tokyo = { + start: new Date("2019-09-01T09:00:00+09:00"), + end: new Date("2019-09-01T15:00:00+09:00"), + }; + + const baseline = predictor.getExtremesPrediction(utc); + const ny = predictor.getExtremesPrediction(newYork); + const jp = predictor.getExtremesPrediction(tokyo); + + [ny, jp].forEach((result) => { + expect(result.length).toBe(baseline.length); + result.forEach((extreme, index) => { + expect(extreme.time.valueOf()).toBe(baseline[index].time.valueOf()); + expect(extreme.level).toBeCloseTo(baseline[index].level, 6); + }); + }); + }); }); diff --git a/packages/tide-predictor/test/station.test.ts b/packages/tide-predictor/test/station.test.ts new file mode 100644 index 00000000..ddccf886 --- /dev/null +++ b/packages/tide-predictor/test/station.test.ts @@ -0,0 +1,368 @@ +import { describe, test, expect } from "vitest"; +import { stations } from "@neaps/tide-database"; +import { useStation } from "../src/station.js"; +import mockConstituents from "./_mocks/constituents.js"; +import type { Station, StationPredictor } from "../src/station.js"; + +function findStation(query: string): StationPredictor { + const found = stations.find((s) => s.id === query || s.source.id === query); + if (!found) throw new Error(`Station not found: ${query}`); + return useStation(found as Station, undefined, findStation); +} + +const baseStation: Station = { + id: "test/station", + name: "Test Station", + continent: "North America", + country: "US", + timezone: "America/New_York", + disclaimers: "", + latitude: 0, + longitude: 0, + source: { name: "test", id: "station", url: "" }, + datums: { MSL: 0, MLLW: -0.5, MHHW: 0.5 }, + chart_datum: "MLLW", + type: "reference", + harmonic_constituents: mockConstituents, +}; + +describe("useStation", () => { + describe("nodeCorrections", () => { + const station = findStation("noaa/8722588"); + const corrections = ["iho", "schureman"] as const; + + test("getExtremesPrediction produces different results", () => { + const options = { + start: new Date("2025-12-17T00:00:00Z"), + end: new Date("2025-12-18T00:00:00Z"), + timeFidelity: 60, + datum: "MLLW", + }; + + const [iho, schureman] = corrections.map((nodeCorrections) => + station.getExtremesPrediction({ ...options, nodeCorrections }), + ); + + expect(iho.extremes.length).toBeGreaterThan(0); + expect(schureman.extremes.length).toBeGreaterThan(0); + expect(iho.extremes.map((e) => e.level)).not.toEqual(schureman.extremes.map((e) => e.level)); + }); + + test("getTimelinePrediction produces different results", () => { + const options = { + start: new Date("2025-12-19T00:00:00Z"), + end: new Date("2025-12-19T01:00:00Z"), + }; + + const [iho, schureman] = corrections.map((nodeCorrections) => + station.getTimelinePrediction({ ...options, nodeCorrections }), + ); + + expect(iho.timeline.length).toBeGreaterThan(0); + expect(schureman.timeline.length).toBeGreaterThan(0); + expect(iho.timeline.map((e) => e.level)).not.toEqual(schureman.timeline.map((e) => e.level)); + }); + + test("getWaterLevelAtTime produces different results", () => { + const options = { time: new Date("2025-12-19T00:30:00Z"), datum: "MLLW" }; + const [iho, schureman] = corrections.map((nodeCorrections) => + station.getWaterLevelAtTime({ ...options, nodeCorrections }), + ); + expect(iho.level).not.toBe(schureman.level); + }); + }); + + describe("datum", () => { + test("defaults to station's chart datum", () => { + const noaa = findStation("noaa/8722274").getExtremesPrediction({ + start: new Date("2025-12-17T00:00:00Z"), + end: new Date("2025-12-18T00:00:00Z"), + }); + expect(noaa.datum).toBe("MLLW"); + + const aus = findStation("ticon/fremantle-62230-aus-bom").getExtremesPrediction({ + start: new Date("2025-12-17T00:00:00Z"), + end: new Date("2025-12-18T00:00:00Z"), + }); + expect(aus.datum).toBe("LAT"); + }); + + test("accepts datum option", () => { + const extremes = findStation("8722274").getExtremesPrediction({ + start: new Date("2025-12-17T00:00:00Z"), + end: new Date("2025-12-18T00:00:00Z"), + datum: "NAVD88", + }); + expect(extremes.datum).toBe("NAVD88"); + }); + + test("throws error for unavailable datum", () => { + const station = findStation("noaa/8443970"); + expect(() => { + station.getExtremesPrediction({ + start: new Date("2025-12-17T00:00:00Z"), + end: new Date("2025-12-18T00:00:00Z"), + datum: "UNKNOWN_DATUM", + }); + }).toThrow(/missing UNKNOWN_DATUM/); + }); + + test("throws error when missing MSL datum", () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { MSL: _, ...datums } = baseStation.datums; + const station = { ...baseStation, datums }; + expect(() => { + useStation(station).getExtremesPrediction({ + start: new Date("2025-12-17T00:00:00Z"), + end: new Date("2025-12-18T00:00:00Z"), + datum: Object.keys(datums)[0], + }); + }).toThrow(/missing MSL/); + }); + + test("does not apply datums when none available", () => { + const stationData = stations.find( + (s) => s.type === "reference" && Object.entries(s.datums).length === 0, + ); + if (!stationData) expect.fail("No station without datums found"); + const station = useStation(stationData as Station); + const extremes = station.getExtremesPrediction({ + start: new Date("2025-12-17T00:00:00Z"), + end: new Date("2025-12-18T00:00:00Z"), + }); + expect(extremes.datum).toBeUndefined(); + expect(extremes.extremes.length).toBeGreaterThan(0); + }); + }); + + describe("subordinate station", () => { + test("throws when findStation is not provided", () => { + const station: Station = { + ...baseStation, + type: "subordinate", + offsets: { reference: "test/reference" }, + }; + expect(() => useStation(station)).toThrow(/findStation/); + }); + + const station = findStation("8724307"); + + test("gets datums and harmonic_constituents from reference station", () => { + expect(station.type).toBe("subordinate"); + const reference = findStation("8724580"); + + expect(station.datums).toBeDefined(); + expect(station.datums).toEqual(reference.datums); + expect(station.harmonic_constituents).toBeDefined(); + expect(station.harmonic_constituents).toEqual(reference.harmonic_constituents); + expect(station.defaultDatum).toBe("MLLW"); + }); + + describe("getExtremesPrediction", () => { + test("matches NOAA extremes for subordinate station", () => { + const start = new Date("2025-12-17T00:00:00Z"); + const end = new Date("2025-12-19T00:00:00Z"); + + const prediction = station.getExtremesPrediction({ + start, + end, + timeFidelity: 60, + datum: "MLLW", + }); + + // https://api.tidesandcurrents.noaa.gov/api/prod/datagetter?station=8724307&format=json&product=predictions&units=metric&time_zone=gmt&begin_date=2025-12-17&end_date=2025-12-18&interval=hilo&datum=MLLW + const noaa = [ + { t: "2025-12-17T02:55:00z", v: 1.128, type: "H" }, + { t: "2025-12-17T10:57:00z", v: -0.044, type: "L" }, + { t: "2025-12-17T16:48:00z", v: 0.658, type: "H" }, + { t: "2025-12-17T22:04:00z", v: 0.337, type: "L" }, + { t: "2025-12-18T03:33:00z", v: 1.148, type: "H" }, + { t: "2025-12-18T11:35:00z", v: -0.099, type: "L" }, + { t: "2025-12-18T17:25:00z", v: 0.64, type: "H" }, + { t: "2025-12-18T22:40:00z", v: 0.316, type: "L" }, + ]; + + noaa.forEach((expected, index) => { + const actual = prediction.extremes[index]; + expect(actual.time).toBeWithin(new Date(expected.t).valueOf(), 5 * 60 * 1000); + expect(actual.level).toBeWithin(expected.v, 0.04); + }); + }); + }); + + describe("getTimelinePrediction", () => { + test("returns interpolated timeline", () => { + const prediction = station.getTimelinePrediction({ + start: new Date("2025-12-17T00:00:00Z"), + end: new Date("2025-12-17T01:00:00Z"), + datum: "MLLW", + }); + + expect(prediction.timeline.length).toBe(7); // 10-min intervals for 1 hour + expect(prediction.datum).toBe("MLLW"); + expect(prediction.units).toBe("meters"); + prediction.timeline.forEach((point) => { + expect(typeof point.level).toBe("number"); + expect(point.level).not.toBeNaN(); + }); + }); + + test("timeline levels are consistent with extremes", () => { + const start = new Date("2025-12-17T00:00:00Z"); + const end = new Date("2025-12-18T00:00:00Z"); + + const { extremes } = station.getExtremesPrediction({ start, end, datum: "MLLW" }); + const { timeline } = station.getTimelinePrediction({ start, end, datum: "MLLW" }); + + const timelineLevels = timeline.map((p) => p.level); + const highExtremes = extremes.filter((e) => e.high).map((e) => e.level); + const lowExtremes = extremes.filter((e) => e.low).map((e) => e.level); + + expect(Math.max(...timelineLevels)).toBeLessThanOrEqual(Math.max(...highExtremes) + 0.01); + expect(Math.min(...timelineLevels)).toBeGreaterThanOrEqual(Math.min(...lowExtremes) - 0.01); + }); + + test("tidal range is scaled by height offset ratios", () => { + const start = new Date("2025-01-15T00:00:00Z"); + const end = new Date("2025-01-18T00:00:00Z"); + + const reference = findStation("8724580"); + const { timeline: refTimeline } = reference.getTimelinePrediction({ + start, + end, + datum: "MLLW", + }); + const { timeline: subTimeline } = station.getTimelinePrediction({ + start, + end, + datum: "MLLW", + }); + + // Height ratio offsets: high=2.13, low=1.83 + const refRange = + Math.max(...refTimeline.map((p) => p.level)) - + Math.min(...refTimeline.map((p) => p.level)); + const subRange = + Math.max(...subTimeline.map((p) => p.level)) - + Math.min(...subTimeline.map((p) => p.level)); + const rangeRatio = subRange / refRange; + + expect(rangeRatio).toBeGreaterThan(1.83); + expect(rangeRatio).toBeLessThan(2.13); + }); + }); + + describe("getWaterLevelAtTime", () => { + test("returns water level at specific time", () => { + const prediction = station.getWaterLevelAtTime({ + time: new Date("2025-12-17T12:00:00Z"), + datum: "MLLW", + }); + + expect(prediction.time).toEqual(new Date("2025-12-17T12:00:00Z")); + expect(prediction.datum).toBe("MLLW"); + expect(typeof prediction.level).toBe("number"); + expect(prediction.level).not.toBeNaN(); + }); + }); + }); + + describe("subordinate vs reference curve comparison", () => { + const start = new Date("2025-01-15T00:00:00Z"); + const end = new Date("2025-01-18T00:00:00Z"); + + function rmsError(a: { level: number }[], b: { level: number }[]): number { + let sumSq = 0; + for (let i = 0; i < a.length; i++) { + const diff = a[i].level - b[i].level; + sumSq += diff * diff; + } + return Math.sqrt(sumSq / a.length); + } + + function tidalRange(timeline: { level: number }[]): number { + const levels = timeline.map((p) => p.level); + return Math.max(...levels) - Math.min(...levels); + } + + describe("identity offsets (Cabrillo Beach: height=1.0/1.0, time=0/0)", () => { + const sub = findStation("9410650"); + const ref = findStation(sub.offsets!.reference!); + + test("timeline matches reference curve", () => { + const { timeline: refTimeline } = ref.getTimelinePrediction({ start, end }); + const { timeline: subTimeline } = sub.getTimelinePrediction({ start, end }); + + expect(subTimeline.length).toBe(refTimeline.length); + expect(rmsError(subTimeline, refTimeline) / tidalRange(refTimeline)).toBeLessThan(0.05); + }); + + test("extremes match reference", () => { + const { extremes: refExtremes } = ref.getExtremesPrediction({ start, end }); + const { extremes: subExtremes } = sub.getExtremesPrediction({ start, end }); + + expect(subExtremes.length).toBe(refExtremes.length); + for (let i = 0; i < refExtremes.length; i++) { + expect(subExtremes[i].level).toBeCloseTo(refExtremes[i].level, 2); + expect(subExtremes[i].high).toBe(refExtremes[i].high); + } + }); + }); + + describe("time-only offsets (Hanauma Bay: height=1.0/1.0, time=-59/-45 min)", () => { + const sub = findStation("1612301"); + const ref = findStation(sub.offsets!.reference!); + + test("timeline has same tidal range as reference", () => { + const { timeline: refTimeline } = ref.getTimelinePrediction({ start, end }); + const { timeline: subTimeline } = sub.getTimelinePrediction({ start, end }); + + const refRange = tidalRange(refTimeline); + const subRange = tidalRange(subTimeline); + expect(subRange / refRange).toBeGreaterThan(0.95); + expect(subRange / refRange).toBeLessThan(1.05); + }); + + test("extremes are time-shifted but same height as reference", () => { + const { extremes: refExtremes } = ref.getExtremesPrediction({ start, end }); + const { extremes: subExtremes } = sub.getExtremesPrediction({ start, end }); + + expect(subExtremes.length).toBe(refExtremes.length); + for (let i = 0; i < refExtremes.length; i++) { + expect(subExtremes[i].level).toBeCloseTo(refExtremes[i].level, 2); + const timeDiffMin = + (subExtremes[i].time.getTime() - refExtremes[i].time.getTime()) / 60000; + const expectedOffset = subExtremes[i].high ? -59 : -45; + expect(timeDiffMin).toBeCloseTo(expectedOffset, 0); + } + }); + }); + + describe("height-only offsets (Great Diamond Island: height=1.0/1.03, time=0/0)", () => { + const sub = findStation("8417988"); + const ref = findStation(sub.offsets!.reference!); + + test("extremes occur at same times as reference", () => { + const { extremes: refExtremes } = ref.getExtremesPrediction({ start, end }); + const { extremes: subExtremes } = sub.getExtremesPrediction({ start, end }); + + expect(subExtremes.length).toBe(refExtremes.length); + for (let i = 0; i < refExtremes.length; i++) { + expect(subExtremes[i].time.getTime()).toBe(refExtremes[i].time.getTime()); + if (subExtremes[i].high) { + expect(subExtremes[i].level).toBeCloseTo(refExtremes[i].level, 2); + } else { + expect(subExtremes[i].level).toBeCloseTo(refExtremes[i].level * 1.03, 2); + } + } + }); + + test("timeline closely follows reference curve", () => { + const { timeline: refTimeline } = ref.getTimelinePrediction({ start, end }); + const { timeline: subTimeline } = sub.getTimelinePrediction({ start, end }); + + expect(rmsError(subTimeline, refTimeline) / tidalRange(refTimeline)).toBeLessThan(0.1); + }); + }); + }); +});