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 },