diff --git a/README.md b/README.md index 6c2af6c2..e0528c41 100644 --- a/README.md +++ b/README.md @@ -197,6 +197,23 @@ findStation("noaa/8443970"); // Boston findStation("9440083"); // Vancouver ``` +### Predicting many stations efficiently + +When predicting tides for many stations at the same time, astronomical computations are identical across all stations for a given time period. Pass a shared `CorrectionsCache` to eliminate redundant work: + +```typescript +import { stationsNear, createCorrectionsCache } from "neaps"; + +const cache = createCorrectionsCache(); +const time = new Date(); + +const predictions = stationsNear({ latitude: 45.6, longitude: -122.7 }, 10).map((station) => + station.getWaterLevelAt({ time, cache }), +); +``` + +See the [`@neaps/tide-predictor` README](packages/tide-predictor/README.md#predicting-many-stations) for full details and options. + ## Accuracy & Validation Neaps is continuously validated against NOAA tidal predictions, comparing the **time** and **height** of predicted high and low tides for all NOAA tide stations. diff --git a/benchmarks/noaa.ts b/benchmarks/noaa.ts index 487b142a..ff1560e0 100644 --- a/benchmarks/noaa.ts +++ b/benchmarks/noaa.ts @@ -2,7 +2,7 @@ import { expect } from "vitest"; import { mkdir, readFile, writeFile } from "fs/promises"; import { createWriteStream } from "fs"; import { join } from "path"; -import { findStation } from "neaps"; +import { createCorrectionsCache, findStation } from "neaps"; import { stations as db } from "@neaps/tide-database"; import createFetch from "make-fetch-happen"; @@ -47,6 +47,8 @@ const scheme = (process.env.SCHEME ?? "iho") as "iho" | "schureman"; const FAST = !!process.env.FAST; const RANGE_DAYS = FAST ? 3 : 365; +const cache = createCorrectionsCache({ interval: 24 * 7 }); + console.log( `Testing tide predictions against ${stations.length} NOAA stations (scheme=${scheme}, days=${RANGE_DAYS})`, ); @@ -81,6 +83,7 @@ for (const id of stations) { start, end, nodeCorrections: scheme, + cache, }) .extremes.map((e) => ({ time: e.time.getTime(), diff --git a/packages/neaps/src/index.ts b/packages/neaps/src/index.ts index 690ab423..bb3208c4 100644 --- a/packages/neaps/src/index.ts +++ b/packages/neaps/src/index.ts @@ -6,7 +6,14 @@ 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 CorrectionsCache, +} from "@neaps/tide-predictor"; + +export { createCorrectionsCache, type CorrectionsCache } from "@neaps/tide-predictor"; type Units = "meters" | "feet"; type PredictionOptions = { @@ -18,6 +25,10 @@ type PredictionOptions = { /** Nodal correction fundamentals. Defaults to 'iho'. */ nodeCorrections?: "iho" | "schureman"; + + /** Shared corrections cache. Pass the same cache to multiple station predictors + * to avoid recomputing node corrections for each station at the same time. */ + cache?: CorrectionsCache; }; export type ExtremesOptions = ExtremesInput & PredictionOptions; @@ -106,7 +117,7 @@ export function useStation(station: Station, distance?: number) { // 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 = {}) { + function getPredictor({ datum = defaultDatum, nodeCorrections, cache }: PredictionOptions = {}) { let offset = 0; if (datum) { @@ -128,7 +139,7 @@ export function useStation(station: Station, distance?: number) { offset = mslOffset - datumOffset; } - return createTidePredictor(harmonic_constituents, { offset, nodeCorrections }); + return createTidePredictor(harmonic_constituents, { offset, nodeCorrections, cache }); } return { @@ -141,9 +152,10 @@ export function useStation(station: Station, distance?: number) { datum = defaultDatum, units = defaultUnits, nodeCorrections, + cache, ...options }: ExtremesOptions) { - const extremes = getPredictor({ datum, nodeCorrections }) + const extremes = getPredictor({ datum, nodeCorrections, cache }) .getExtremesPrediction({ ...options, offsets: station.offsets }) .map((e) => toPreferredUnits(e, units)); @@ -154,9 +166,10 @@ export function useStation(station: Station, distance?: number) { datum = defaultDatum, units = defaultUnits, nodeCorrections, + cache, ...options }: TimelineOptions) { - const timeline = getPredictor({ datum, nodeCorrections }) + const timeline = getPredictor({ datum, nodeCorrections, cache }) .getTimelinePrediction({ ...options, offsets: station.offsets }) .map((e) => toPreferredUnits(e, units)); @@ -168,9 +181,10 @@ export function useStation(station: Station, distance?: number) { datum = defaultDatum, units = defaultUnits, nodeCorrections, + cache, }: WaterLevelOptions) { const prediction = toPreferredUnits( - getPredictor({ datum, nodeCorrections }).getWaterLevelAtTime({ + getPredictor({ datum, nodeCorrections, cache }).getWaterLevelAtTime({ time, offsets: station.offsets, }), diff --git a/packages/tide-predictor/README.md b/packages/tide-predictor/README.md index 784a866b..f4e83cc6 100644 --- a/packages/tide-predictor/README.md +++ b/packages/tide-predictor/README.md @@ -45,8 +45,9 @@ Note that all times internally are evaluated as UTC, so be sure to specify a tim Calling `createTidePredictor` will generate a new tide prediction object. It accepts the following arguments: - `constituents` - An array of [constituent objects](#constituent-object) -- `options` - An object with one of: +- `options` - An optional object with: - `offset` - A value to add to **all** values predicted. This is useful if you want to, for example, offset tides by mean high water, etc. + - `cache` - A [`CorrectionsCache`](#predicting-many-stations) to share astronomical computations across multiple predictors. Recommended when predicting tides for many stations at the same time. ### Tide prediction methods @@ -161,6 +162,34 @@ A single object is returned with: - `time` - A Javascript date object - `level` - The predicted water level +## Predicting many stations + +When predicting tides for many stations at the same time, astronomical computations (node corrections, equilibrium arguments) are identical across all stations for a given time. By default each predictor computes these independently. Passing a shared `CorrectionsCache` eliminates this redundancy. + +```typescript +import { createCorrectionsCache, createTidePredictor } from "@neaps/tide-predictor"; + +const cache = createCorrectionsCache(); +const time = new Date(); + +for (const station of stations) { + const predictor = createTidePredictor(station.constituents, { cache }); + results.push(predictor.getWaterLevelAtTime({ time })); +} +``` + +### `createCorrectionsCache(options?)` + +Returns a `CorrectionsCache` that can be passed to multiple `createTidePredictor` calls. + +**Options:** + +- `interval` - Quantization interval in hours for node corrections (default: `24`). Node corrections change by less than 0.01% per day, so the default introduces less than 0.1 mm of error. + +> [!NOTE] +> +> The cache grows by roughly 36 KB per 24-hour bucket (corrections are stored for all ~400 constituent models to enable sharing across any station), so a year of predictions uses around 13 MB. For century-scale prediction tables, consider creating a new cache per time range. + ## Data definitions ### Constituent definition diff --git a/packages/tide-predictor/src/corrections-cache.ts b/packages/tide-predictor/src/corrections-cache.ts new file mode 100644 index 00000000..b02c665c --- /dev/null +++ b/packages/tide-predictor/src/corrections-cache.ts @@ -0,0 +1,147 @@ +import astro from "./astronomy/index.js"; +import type { AstroData } from "./astronomy/index.js"; +import { d2r } from "./astronomy/constants.js"; +import type { Constituent } from "./constituents/types.js"; +import type { Fundamentals, NodalCorrection } from "./node-corrections/types.js"; + +/** Cached node corrections for a single time bucket. The same object reference + * is returned for all times that fall within the same bucket, enabling callers + * to detect bucket transitions via `!==` reference comparison. */ +export interface CachedCorrections { + readonly astro: AstroData; + readonly corrections: Map; +} + +/** A reusable node corrections cache. Pass to `createTidePredictor` to share + * astronomical computations across multiple station predictors. */ +export interface CorrectionsCache { + readonly interval: number; // quantization interval in hours + /** Quantized astro — evaluated at bucket midpoint. Used for f/u corrections. */ + getAstro(time: Date): AstroData; + /** V0 equilibrium arguments (d2r * model.value(baseAstro)) for all models, + * keyed by constituent name. Computed once per (models, start time) pair and + * shared across all predictors with the same start time. */ + getV0(time: Date, models: Record): Map; + getCorrections( + time: Date, + models: Record, + fundamentals: Fundamentals, + ): CachedCorrections; +} + +export interface CorrectionsCacheOptions { + /** Quantization interval in hours. Default: 24. + * Node corrections change by <0.01% per day, so 24h introduces <0.1mm error. */ + interval?: number; +} + +/** + * Create a reusable node corrections cache. + * + * When predicting tides for many stations at the same time, pass the same cache + * to each `createTidePredictor` call. Astronomical computations and constituent + * node corrections are computed once per time bucket and shared across all + * station predictors. + * + * @example + * ```ts + * import { createCorrectionsCache, createTidePredictor } from "@neaps/tide-predictor"; + * + * const cache = createCorrectionsCache(); + * + * for (const station of stations) { + * const predictor = createTidePredictor(station.constituents, { cache }); + * results.push(predictor.getWaterLevelAtTime({ time })); + * } + * ``` + */ +export function createCorrectionsCache({ + interval = 24, +}: CorrectionsCacheOptions = {}): CorrectionsCache { + const intervalMs = interval * 3_600_000; + + // exact ms → AstroData evaluated at that precise time (for V0 arguments) + const astroCache = new Map(); + + // fundamentals ref → bucket start (ms) → CachedCorrections + // The corrections Map is populated incrementally across calls so different + // models dicts can share the same CachedCorrections per (fundamentals, bucket). + const correctionsCache = new WeakMap>(); + + // models ref → exact ms → constituent name → d2r * model.value(baseAstro) + const v0Cache = new WeakMap, Map>>(); + + function bucketStart(time: Date): number { + return Math.floor(time.getTime() / intervalMs) * intervalMs; + } + + function getCachedAstro(time: Date): AstroData { + const key = time.getTime(); + let cached = astroCache.get(key); + if (!cached) { + cached = astro(time); + astroCache.set(key, cached); + } + return cached; + } + + const cache: CorrectionsCache = { + interval, + + // Quantize time to bucket and return astro evaluated at bucket midpoint. + getAstro(time: Date): AstroData { + return getCachedAstro(new Date(bucketStart(time) + intervalMs / 2)); + }, + + getV0(time: Date, models: Record): Map { + const key = time.getTime(); + + let byTime = v0Cache.get(models); + if (!byTime) { + byTime = new Map(); + v0Cache.set(models, byTime); + } + + let v0 = byTime.get(key); + if (!v0) { + const baseAstro = getCachedAstro(time); + v0 = new Map(); + for (const name of Object.keys(models)) { + v0.set(name, d2r * models[name].value(baseAstro)); + } + byTime.set(key, v0); + } + + return v0; + }, + + getCorrections( + time: Date, + models: Record, + fundamentals: Fundamentals, + ): CachedCorrections { + const key = bucketStart(time); + + let byBucket = correctionsCache.get(fundamentals); + if (!byBucket) { + byBucket = new Map(); + correctionsCache.set(fundamentals, byBucket); + } + + const cached = byBucket.get(key); + if (cached) return cached; + + const astro = cache.getAstro(time); + const corrections = new Map(); + for (const name of Object.keys(models)) { + corrections.set(name, models[name].correction(astro, fundamentals)); + } + + const entry: CachedCorrections = { astro, corrections }; + byBucket.set(key, entry); + return entry; + }, + }; + + return cache; +} diff --git a/packages/tide-predictor/src/harmonics/index.ts b/packages/tide-predictor/src/harmonics/index.ts index 7907a883..16d9da59 100644 --- a/packages/tide-predictor/src/harmonics/index.ts +++ b/packages/tide-predictor/src/harmonics/index.ts @@ -4,6 +4,7 @@ import type { Constituent } from "../constituents/types.js"; import { iho, Fundamentals } from "../node-corrections/index.js"; import { d2r } from "../astronomy/constants.js"; import type { HarmonicConstituent, Prediction } from "./prediction.js"; +import type { CorrectionsCache } from "../corrections-cache.js"; export type * from "./prediction.js"; @@ -12,6 +13,7 @@ export interface HarmonicsOptions { constituentModels?: Record; offset: number | false; fundamentals?: Fundamentals; + cache?: CorrectionsCache; } export interface PredictionOptions { @@ -56,6 +58,7 @@ const harmonicsFactory = ({ constituentModels = defaultConstituentModels, offset, fundamentals = iho, + cache, }: HarmonicsOptions): Harmonics => { if (!Array.isArray(harmonicConstituents)) { throw new Error("Harmonic constituents are not an array"); @@ -104,6 +107,7 @@ const harmonicsFactory = ({ constituentModels, start: timeline.items[0] ?? start, fundamentals, + cache, }); }; diff --git a/packages/tide-predictor/src/harmonics/prediction.ts b/packages/tide-predictor/src/harmonics/prediction.ts index 0f82e828..2b8dbe0f 100644 --- a/packages/tide-predictor/src/harmonics/prediction.ts +++ b/packages/tide-predictor/src/harmonics/prediction.ts @@ -1,7 +1,11 @@ -import astro from "../astronomy/index.js"; import { d2r } from "../astronomy/constants.js"; import type { Constituent } from "../constituents/types.js"; import { iho, type Fundamentals } from "../node-corrections/index.js"; +import { + createCorrectionsCache, + type CorrectionsCache, + type CachedCorrections, +} from "../corrections-cache.js"; export interface Timeline { items: Date[]; @@ -104,6 +108,7 @@ interface PredictionFactoryParams { constituentModels: Record; fundamentals?: Fundamentals; start: Date; + cache?: CorrectionsCache; } /** @@ -119,9 +124,6 @@ interface ConstituentParam { /** Tolerance for bisection root-finding: 1 second in hours */ const TOLERANCE_HOURS = 1 / 3600; -/** Recompute node corrections daily for long spans */ -const CORRECTION_INTERVAL_HOURS = 24; - /** Linear interpolation between two keyframe values */ function interpolate(fraction: number, a: number, b: number): number { return a + fraction * (b - a); @@ -187,18 +189,22 @@ function predictionFactory({ constituentModels, start, fundamentals = iho, + // Create a private ephemeral cache if one is not provided. + cache = createCorrectionsCache(), }: PredictionFactoryParams): Prediction { - const baseAstro = astro(start); + // V0 equilibrium arguments: d2r * model.value(baseAstro) per constituent. + // Computed once per (models ref, start time) and shared across all predictors + // that use the same constituent models and start time (e.g. many stations). + const v0 = cache.getV0(start, constituentModels); const startMs = start.getTime(); const endHour = (timeline.items[timeline.items.length - 1].getTime() - startMs) / 3600000; /** - * Precompute flat constituent parameters with node corrections evaluated - * at a given time. Node corrections vary on the 18.6-year nodal cycle - * and change by <0.01% per day. + * Build flat ConstituentParam[] from a CachedCorrections bucket. + * Station-specific amplitude/phase multiplication happens here; the cache + * stores only station-independent f/u values. */ - function prepareParams(correctionTime: Date): ConstituentParam[] { - const correctionAstro = astro(correctionTime); + function prepareParams(cached: CachedCorrections): ConstituentParam[] { const params: ConstituentParam[] = []; for (const constituent of constituents) { @@ -207,14 +213,13 @@ function predictionFactory({ const model = constituentModels[constituent.name]; if (!model) continue; - const V0 = d2r * model.value(baseAstro); - const speed = d2r * model.speed; - const correction = model.correction(correctionAstro, fundamentals); + const correction = cached.corrections.get(constituent.name); + if (!correction) continue; params.push({ A: constituent.amplitude * correction.f, - w: speed, - phi: V0 + d2r * correction.u - constituent.phase, + w: d2r * model.speed, + phi: v0.get(constituent.name)! + d2r * correction.u - constituent.phase, }); } @@ -223,19 +228,25 @@ function predictionFactory({ /** * Create a function that returns constituent params with node corrections - * recomputed at CORRECTION_INTERVAL_HOURS. Returns a new array reference - * when corrections are recomputed, so callers can detect changes via `!==`. + * recomputed when the time crosses a cache bucket boundary. Returns a new + * array reference when corrections are recomputed, so callers can detect + * changes via `!==`. + * + * The cache handles all time quantization; no manual interval tracking here. */ function correctedParams(): (hour: number) => ConstituentParam[] { - const firstChunkEnd = Math.min(CORRECTION_INTERVAL_HOURS, endHour); - let params = prepareParams(new Date(startMs + (firstChunkEnd / 2) * 3600000)); - let nextCorrectionAt = CORRECTION_INTERVAL_HOURS; + let lastCached = cache.getCorrections(start, constituentModels, fundamentals); + let params = prepareParams(lastCached); return (hour: number): ConstituentParam[] => { - if (hour >= nextCorrectionAt) { - const chunkEnd = Math.min(nextCorrectionAt + CORRECTION_INTERVAL_HOURS, endHour); - params = prepareParams(new Date(startMs + ((nextCorrectionAt + chunkEnd) / 2) * 3600000)); - nextCorrectionAt += CORRECTION_INTERVAL_HOURS; + const current = cache.getCorrections( + new Date(startMs + hour * 3_600_000), + constituentModels, + fundamentals, + ); + if (current !== lastCached) { + lastCached = current; + params = prepareParams(current); } return params; }; @@ -275,7 +286,7 @@ function predictionFactory({ let dPrev = evalHPrime(tPrev, params); for (let tNext = tPrev + bracket; tNext <= toHour + bracket; tNext += bracket) { - // Recompute node corrections for long spans + // Recompute node corrections when crossing a cache bucket boundary const newParams = getParams(tPrev); if (newParams !== params) { params = newParams; diff --git a/packages/tide-predictor/src/index.ts b/packages/tide-predictor/src/index.ts index 323182ab..9a5c874c 100644 --- a/packages/tide-predictor/src/index.ts +++ b/packages/tide-predictor/src/index.ts @@ -3,9 +3,16 @@ import { default as constituents } from "./constituents/index.js"; import { resolveFundamentals } from "./node-corrections/index.js"; import type { HarmonicConstituent } from "./harmonics/index.js"; import type { TimelinePoint, Extreme, ExtremeOffsets } from "./harmonics/prediction.js"; +import type { CorrectionsCache } from "./corrections-cache.js"; export { default as astro } from "./astronomy/index.js"; export { default as constituents } from "./constituents/index.js"; +export { createCorrectionsCache } from "./corrections-cache.js"; +export type { + CorrectionsCache, + CorrectionsCacheOptions, + CachedCorrections, +} from "./corrections-cache.js"; export type * from "./astronomy/index.js"; export type * from "./constituents/index.js"; export type * from "./harmonics/index.js"; @@ -14,6 +21,7 @@ export type * from "./node-corrections/index.js"; export interface TidePredictionOptions { offset?: number | false; nodeCorrections?: "iho" | "schureman"; + cache?: CorrectionsCache; } export interface TimeSpan { @@ -46,11 +54,12 @@ export function createTidePredictor( constituents: HarmonicConstituent[], options: TidePredictionOptions = {}, ): TidePrediction { - const { nodeCorrections, ...harmonicsOpts } = options; + const { nodeCorrections, cache, ...harmonicsOpts } = options; const harmonicsOptions = { harmonicConstituents: constituents, fundamentals: resolveFundamentals(nodeCorrections), offset: false as number | false, + cache, ...harmonicsOpts, }; diff --git a/packages/tide-predictor/test/benchmarks/corrections-cache.bench.ts b/packages/tide-predictor/test/benchmarks/corrections-cache.bench.ts new file mode 100644 index 00000000..01f670fe --- /dev/null +++ b/packages/tide-predictor/test/benchmarks/corrections-cache.bench.ts @@ -0,0 +1,87 @@ +import { describe, bench } from "vitest"; +import harmonics from "../../src/harmonics/index.js"; +import { createCorrectionsCache } from "../../src/corrections-cache.js"; +import mockConstituents from "../_mocks/constituents.js"; + +// Simulate the "many stations at one time" use case: predict water level at a +// single point in time for a large set of stations. +// Node corrections are purely time-dependent, so without a cache they're +// recomputed from scratch for every station on each call. + +const STATION_COUNT = 500; +const time = new Date("2024-06-15T12:00:00Z"); +const end = new Date(time.getTime() + 10 * 60 * 1000); // 10-min window for getWaterLevelAtTime + +// Build a pool of mock stations with slightly varied amplitudes/phases +// to prevent any accidental short-circuit optimisation. +const stations = Array.from({ length: STATION_COUNT }, (_, i) => { + const scale = 0.5 + (i % 10) * 0.1; // 0.5 – 1.4 + const phaseShift = (i % 37) * 5; // 0 – 180° + return mockConstituents.map((c) => ({ + ...c, + amplitude: c.amplitude * scale, + phase: (c.phase + phaseShift) % 360, + })); +}); + +describe("many-stations-single-time: getWaterLevelAtTime", () => { + bench("without cache — corrections recomputed per station", () => { + for (const constituents of stations) { + void harmonics({ harmonicConstituents: constituents, offset: false }) + .setTimeSpan(time, end) + .prediction() + .getTimelinePrediction()[0]; + } + }); + + bench("with shared cache — corrections computed once", () => { + const cache = createCorrectionsCache(); + for (const constituents of stations) { + void harmonics({ harmonicConstituents: constituents, offset: false, cache }) + .setTimeSpan(time, end) + .prediction() + .getTimelinePrediction()[0]; + } + }); +}); + +// Iterate through a 24-hour period at 10-minute resolution across many stations: +// the inner scenario from the issue (times × stations loop). + +const STEP_COUNT = 24 * 6; // 144 points over 24 hours, every 10 minutes +const times = Array.from({ length: STEP_COUNT }, (_, i) => { + const t = new Date("2024-06-15T00:00:00Z"); + t.setUTCMinutes(i * 10); + return t; +}); + +// Use a smaller station count here — the outer loop is over times×stations +const SMALL_COUNT = 50; +const smallStations = stations.slice(0, SMALL_COUNT); + +describe("time-series × many-stations: getWaterLevelAtTime", () => { + bench("without cache — corrections recomputed per (time, station)", () => { + for (const t of times) { + const e = new Date(t.getTime() + 10 * 60 * 1000); + for (const constituents of smallStations) { + void harmonics({ harmonicConstituents: constituents, offset: false }) + .setTimeSpan(t, e) + .prediction() + .getTimelinePrediction()[0]; + } + } + }); + + bench("with shared cache — corrections shared across time steps and stations", () => { + const cache = createCorrectionsCache(); + for (const t of times) { + const e = new Date(t.getTime() + 10 * 60 * 1000); + for (const constituents of smallStations) { + void harmonics({ harmonicConstituents: constituents, offset: false, cache }) + .setTimeSpan(t, e) + .prediction() + .getTimelinePrediction()[0]; + } + } + }); +}); diff --git a/packages/tide-predictor/test/corrections-cache.test.ts b/packages/tide-predictor/test/corrections-cache.test.ts new file mode 100644 index 00000000..67df597e --- /dev/null +++ b/packages/tide-predictor/test/corrections-cache.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect } from "vitest"; +import { createCorrectionsCache } from "../src/corrections-cache.js"; +import defaultConstituentModels from "../src/constituents/index.js"; +import { iho, schureman } from "../src/node-corrections/index.js"; + +const time1 = new Date("2024-01-15T06:00:00Z"); // 06:00 — first half of the day +const time2 = new Date("2024-01-15T18:00:00Z"); // 18:00 — second half, same 24h bucket +const time3 = new Date("2024-01-16T06:00:00Z"); // next calendar day — different bucket + +describe("createCorrectionsCache", () => { + it("defaults to a 24-hour interval", () => { + const cache = createCorrectionsCache(); + expect(cache.interval).toBe(24); + }); + + it("accepts a custom interval", () => { + const cache = createCorrectionsCache({ interval: 12 }); + expect(cache.interval).toBe(12); + }); +}); + +describe("CorrectionsCache.getAstro", () => { + const cache = createCorrectionsCache(); + + it("returns AstroData with expected properties", () => { + const result = cache.getAstro(time1); + expect(result).toHaveProperty("s"); + expect(result).toHaveProperty("h"); + expect(result).toHaveProperty("N"); + expect(result).toHaveProperty("p"); + }); + + it("returns same reference for times in the same bucket", () => { + const a = cache.getAstro(time1); + const b = cache.getAstro(time2); + expect(a).toBe(b); + }); + + it("returns different reference for a different bucket", () => { + const a = cache.getAstro(time1); + const c = cache.getAstro(time3); + expect(a).not.toBe(c); + }); +}); + +describe("CorrectionsCache.getV0", () => { + const cache = createCorrectionsCache(); + + it("returns finite numbers for known constituents", () => { + const v0 = cache.getV0(time1, defaultConstituentModels); + expect(v0.has("M2")).toBe(true); + expect(Number.isFinite(v0.get("M2"))).toBe(true); + }); + + it("returns same reference for the same time and models", () => { + const a = cache.getV0(time1, defaultConstituentModels); + const b = cache.getV0(time1, defaultConstituentModels); + expect(a).toBe(b); + }); + + it("returns different reference for different times", () => { + const a = cache.getV0(time1, defaultConstituentModels); + const b = cache.getV0(time2, defaultConstituentModels); + expect(a).not.toBe(b); + }); +}); + +describe("CorrectionsCache.getCorrections", () => { + const cache = createCorrectionsCache(); + + it("returns corrections with f and u for known constituents", () => { + const result = cache.getCorrections(time1, defaultConstituentModels, iho); + expect(result.corrections.has("M2")).toBe(true); + const m2 = result.corrections.get("M2"); + expect(typeof m2!.f).toBe("number"); + expect(typeof m2!.u).toBe("number"); + expect(Number.isFinite(m2!.f)).toBe(true); + expect(Number.isFinite(m2!.u)).toBe(true); + }); + + it("returns same reference for times in the same bucket and same fundamentals", () => { + const a = cache.getCorrections(time1, defaultConstituentModels, iho); + const b = cache.getCorrections(time2, defaultConstituentModels, iho); + expect(a).toBe(b); + }); + + it("returns different reference for a different bucket", () => { + const a = cache.getCorrections(time1, defaultConstituentModels, iho); + const c = cache.getCorrections(time3, defaultConstituentModels, iho); + expect(a).not.toBe(c); + }); + + it("returns different reference for different fundamentals at the same time", () => { + const a = cache.getCorrections(time1, defaultConstituentModels, iho); + const b = cache.getCorrections(time1, defaultConstituentModels, schureman); + expect(a).not.toBe(b); + }); +}); + +describe("CorrectionsCache custom interval", () => { + it("separates times that fall in different 12-hour buckets", () => { + const cache = createCorrectionsCache({ interval: 12 }); + // 06:00 and 18:00 are in different 12h buckets (00-12 vs 12-24) + const morning = cache.getCorrections(time1, defaultConstituentModels, iho); + const evening = cache.getCorrections(time2, defaultConstituentModels, iho); + expect(morning).not.toBe(evening); + }); + + it("groups times that fall in the same 12-hour bucket", () => { + const cache = createCorrectionsCache({ interval: 12 }); + const t1 = new Date("2024-01-15T02:00:00Z"); + const t2 = new Date("2024-01-15T10:00:00Z"); + const a = cache.getCorrections(t1, defaultConstituentModels, iho); + const b = cache.getCorrections(t2, defaultConstituentModels, iho); + expect(a).toBe(b); + }); +}); diff --git a/packages/tide-predictor/test/harmonics/prediction.test.ts b/packages/tide-predictor/test/harmonics/prediction.test.ts index 72521cce..7e1e266c 100644 --- a/packages/tide-predictor/test/harmonics/prediction.test.ts +++ b/packages/tide-predictor/test/harmonics/prediction.test.ts @@ -3,6 +3,7 @@ import harmonics, { ExtremeOffsets, getTimeline } from "../../src/harmonics/inde import predictionFactory from "../../src/harmonics/prediction.js"; import defaultConstituentModels from "../../src/constituents/index.js"; import mockHarmonicConstituents from "../_mocks/constituents.js"; +import { createCorrectionsCache } from "../../src/corrections-cache.js"; const startDate = new Date("2019-09-01T00:00:00Z"); const endDate = new Date("2019-09-01T06:00:00Z"); @@ -487,6 +488,93 @@ describe("Secondary stations", () => { } }); + describe("corrections cache integration", () => { + it("produces identical timeline results with and without a shared cache", () => { + // Both paths use createCorrectionsCache() internally with the same defaults, + // so epoch-aligned bucket midpoints are identical → bitwise equal levels. + const cache = createCorrectionsCache(); + + const withoutCache = harmonics({ + harmonicConstituents: mockHarmonicConstituents, + offset: false, + }) + .setTimeSpan(startDate, endDate) + .prediction() + .getTimelinePrediction(); + + const withCache = harmonics({ + harmonicConstituents: mockHarmonicConstituents, + offset: false, + cache, + }) + .setTimeSpan(startDate, endDate) + .prediction() + .getTimelinePrediction(); + + expect(withCache.length).toBe(withoutCache.length); + for (let i = 0; i < withCache.length; i++) { + expect(withCache[i].level).toBe(withoutCache[i].level); + } + }); + + it("produces identical extremes results with and without a shared cache", () => { + const cache = createCorrectionsCache(); + + const withoutCache = harmonics({ + harmonicConstituents: mockHarmonicConstituents, + offset: false, + }) + .setTimeSpan(startDate, extremesEndDate) + .prediction() + .getExtremesPrediction(); + + const withCache = harmonics({ + harmonicConstituents: mockHarmonicConstituents, + offset: false, + cache, + }) + .setTimeSpan(startDate, extremesEndDate) + .prediction() + .getExtremesPrediction(); + + expect(withCache.length).toBe(withoutCache.length); + for (let i = 0; i < withCache.length; i++) { + expect(withCache[i].time.getTime()).toBe(withoutCache[i].time.getTime()); + expect(withCache[i].level).toBe(withoutCache[i].level); + expect(withCache[i].high).toBe(withoutCache[i].high); + } + }); + + it("shared cache produces consistent results across two predictors", () => { + const cache = createCorrectionsCache(); + + const pred1 = harmonics({ + harmonicConstituents: mockHarmonicConstituents, + offset: false, + cache, + }) + .setTimeSpan(startDate, endDate) + .prediction() + .getTimelinePrediction(); + + // Different offset — same constituents, same cache + const pred2 = harmonics({ + harmonicConstituents: mockHarmonicConstituents, + offset: 1.5, + cache, + }) + .setTimeSpan(startDate, endDate) + .prediction() + .getTimelinePrediction(); + + expect(pred1.length).toBe(pred2.length); + // The offset shifts every level by 1.5m + for (let i = 0; i < pred1.length; i++) { + expect(pred2[i].level).toBeCloseTo(pred1[i].level + 1.5, 10); + } + }); + }); + it("subordinate timeline is monotonic between extremes", () => { const offsets: ExtremeOffsets = { height: { type: "ratio", high: 1.1, low: 0.9 },