Skip to content

Commit 2cfa099

Browse files
committed
Add createCorrectionsCache to cache expensive calculations
1 parent bbd86bf commit 2cfa099

11 files changed

Lines changed: 561 additions & 34 deletions

File tree

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,23 @@ findStation("noaa/8443970"); // Boston
197197
findStation("9440083"); // Vancouver
198198
```
199199

200+
### Predicting many stations efficiently
201+
202+
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:
203+
204+
```typescript
205+
import { stationsNear, createCorrectionsCache } from "neaps";
206+
207+
const cache = createCorrectionsCache();
208+
const time = new Date();
209+
210+
const predictions = stationsNear({ latitude: 45.6, longitude: -122.7 }, 10).map((station) =>
211+
station.getWaterLevelAt({ time, cache }),
212+
);
213+
```
214+
215+
See the [`@neaps/tide-predictor` README](packages/tide-predictor/README.md#predicting-many-stations) for full details and options.
216+
200217
## Accuracy & Validation
201218

202219
Neaps is continuously validated against NOAA tidal predictions, comparing the **time** and **height** of predicted high and low tides for all NOAA tide stations.

benchmarks/noaa.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { expect } from "vitest";
22
import { mkdir, readFile, writeFile } from "fs/promises";
33
import { createWriteStream } from "fs";
44
import { join } from "path";
5-
import { findStation } from "neaps";
5+
import { createCorrectionsCache, findStation } from "neaps";
66
import { stations as db } from "@neaps/tide-database";
77
import createFetch from "make-fetch-happen";
88

@@ -47,6 +47,8 @@ const scheme = (process.env.SCHEME ?? "iho") as "iho" | "schureman";
4747
const FAST = !!process.env.FAST;
4848
const RANGE_DAYS = FAST ? 3 : 365;
4949

50+
const cache = createCorrectionsCache({ interval: 24 * 7 });
51+
5052
console.log(
5153
`Testing tide predictions against ${stations.length} NOAA stations (scheme=${scheme}, days=${RANGE_DAYS})`,
5254
);
@@ -81,6 +83,7 @@ for (const id of stations) {
8183
start,
8284
end,
8385
nodeCorrections: scheme,
86+
cache,
8487
})
8588
.extremes.map((e) => ({
8689
time: e.time.getTime(),

packages/neaps/src/index.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,14 @@ import {
66
type NearOptions,
77
type NearestOptions,
88
} from "@neaps/tide-database";
9-
import { createTidePredictor, type ExtremesInput, type TimelineInput } from "@neaps/tide-predictor";
9+
import {
10+
createTidePredictor,
11+
type ExtremesInput,
12+
type TimelineInput,
13+
type CorrectionsCache,
14+
} from "@neaps/tide-predictor";
15+
16+
export { createCorrectionsCache, type CorrectionsCache } from "@neaps/tide-predictor";
1017

1118
type Units = "meters" | "feet";
1219
type PredictionOptions = {
@@ -18,6 +25,10 @@ type PredictionOptions = {
1825

1926
/** Nodal correction fundamentals. Defaults to 'iho'. */
2027
nodeCorrections?: "iho" | "schureman";
28+
29+
/** Shared corrections cache. Pass the same cache to multiple station predictors
30+
* to avoid recomputing node corrections for each station at the same time. */
31+
cache?: CorrectionsCache;
2132
};
2233

2334
export type ExtremesOptions = ExtremesInput & PredictionOptions;
@@ -106,7 +117,7 @@ export function useStation(station: Station, distance?: number) {
106117
// Use station chart datum as the default datum if available
107118
const defaultDatum = station.chart_datum in datums ? station.chart_datum : undefined;
108119

109-
function getPredictor({ datum = defaultDatum, nodeCorrections }: PredictionOptions = {}) {
120+
function getPredictor({ datum = defaultDatum, nodeCorrections, cache }: PredictionOptions = {}) {
110121
let offset = 0;
111122

112123
if (datum) {
@@ -128,7 +139,7 @@ export function useStation(station: Station, distance?: number) {
128139
offset = mslOffset - datumOffset;
129140
}
130141

131-
return createTidePredictor(harmonic_constituents, { offset, nodeCorrections });
142+
return createTidePredictor(harmonic_constituents, { offset, nodeCorrections, cache });
132143
}
133144

134145
return {
@@ -141,9 +152,10 @@ export function useStation(station: Station, distance?: number) {
141152
datum = defaultDatum,
142153
units = defaultUnits,
143154
nodeCorrections,
155+
cache,
144156
...options
145157
}: ExtremesOptions) {
146-
const extremes = getPredictor({ datum, nodeCorrections })
158+
const extremes = getPredictor({ datum, nodeCorrections, cache })
147159
.getExtremesPrediction({ ...options, offsets: station.offsets })
148160
.map((e) => toPreferredUnits(e, units));
149161

@@ -154,9 +166,10 @@ export function useStation(station: Station, distance?: number) {
154166
datum = defaultDatum,
155167
units = defaultUnits,
156168
nodeCorrections,
169+
cache,
157170
...options
158171
}: TimelineOptions) {
159-
const timeline = getPredictor({ datum, nodeCorrections })
172+
const timeline = getPredictor({ datum, nodeCorrections, cache })
160173
.getTimelinePrediction({ ...options, offsets: station.offsets })
161174
.map((e) => toPreferredUnits(e, units));
162175

@@ -168,9 +181,10 @@ export function useStation(station: Station, distance?: number) {
168181
datum = defaultDatum,
169182
units = defaultUnits,
170183
nodeCorrections,
184+
cache,
171185
}: WaterLevelOptions) {
172186
const prediction = toPreferredUnits(
173-
getPredictor({ datum, nodeCorrections }).getWaterLevelAtTime({
187+
getPredictor({ datum, nodeCorrections, cache }).getWaterLevelAtTime({
174188
time,
175189
offsets: station.offsets,
176190
}),

packages/tide-predictor/README.md

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,9 @@ Note that all times internally are evaluated as UTC, so be sure to specify a tim
4545
Calling `createTidePredictor` will generate a new tide prediction object. It accepts the following arguments:
4646

4747
- `constituents` - An array of [constituent objects](#constituent-object)
48-
- `options` - An object with one of:
48+
- `options` - An optional object with:
4949
- `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.
50+
- `cache` - A [`CorrectionsCache`](#predicting-many-stations) to share astronomical computations across multiple predictors. Recommended when predicting tides for many stations at the same time.
5051

5152
### Tide prediction methods
5253

@@ -161,6 +162,34 @@ A single object is returned with:
161162
- `time` - A Javascript date object
162163
- `level` - The predicted water level
163164

165+
## <a name="predicting-many-stations"></a>Predicting many stations
166+
167+
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.
168+
169+
```typescript
170+
import { createCorrectionsCache, createTidePredictor } from "@neaps/tide-predictor";
171+
172+
const cache = createCorrectionsCache();
173+
const time = new Date();
174+
175+
for (const station of stations) {
176+
const predictor = createTidePredictor(station.constituents, { cache });
177+
results.push(predictor.getWaterLevelAtTime({ time }));
178+
}
179+
```
180+
181+
### `createCorrectionsCache(options?)`
182+
183+
Returns a `CorrectionsCache` that can be passed to multiple `createTidePredictor` calls.
184+
185+
**Options:**
186+
187+
- `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.
188+
189+
> [!NOTE]
190+
>
191+
> 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.
192+
164193
## Data definitions
165194

166195
### <a name="constituent-object"></a>Constituent definition
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import astro from "./astronomy/index.js";
2+
import type { AstroData } from "./astronomy/index.js";
3+
import { d2r } from "./astronomy/constants.js";
4+
import type { Constituent } from "./constituents/types.js";
5+
import type { Fundamentals, NodalCorrection } from "./node-corrections/types.js";
6+
7+
/** Cached node corrections for a single time bucket. The same object reference
8+
* is returned for all times that fall within the same bucket, enabling callers
9+
* to detect bucket transitions via `!==` reference comparison. */
10+
export interface CachedCorrections {
11+
readonly astro: AstroData;
12+
readonly corrections: Map<string, NodalCorrection>;
13+
}
14+
15+
/** A reusable node corrections cache. Pass to `createTidePredictor` to share
16+
* astronomical computations across multiple station predictors. */
17+
export interface CorrectionsCache {
18+
readonly interval: number; // quantization interval in hours
19+
/** Quantized astro — evaluated at bucket midpoint. Used for f/u corrections. */
20+
getAstro(time: Date): AstroData;
21+
/** V0 equilibrium arguments (d2r * model.value(baseAstro)) for all models,
22+
* keyed by constituent name. Computed once per (models, start time) pair and
23+
* shared across all predictors with the same start time. */
24+
getV0(time: Date, models: Record<string, Constituent>): Map<string, number>;
25+
getCorrections(
26+
time: Date,
27+
models: Record<string, Constituent>,
28+
fundamentals: Fundamentals,
29+
): CachedCorrections;
30+
}
31+
32+
export interface CorrectionsCacheOptions {
33+
/** Quantization interval in hours. Default: 24.
34+
* Node corrections change by <0.01% per day, so 24h introduces <0.1mm error. */
35+
interval?: number;
36+
}
37+
38+
/**
39+
* Create a reusable node corrections cache.
40+
*
41+
* When predicting tides for many stations at the same time, pass the same cache
42+
* to each `createTidePredictor` call. Astronomical computations and constituent
43+
* node corrections are computed once per time bucket and shared across all
44+
* station predictors.
45+
*
46+
* @example
47+
* ```ts
48+
* import { createCorrectionsCache, createTidePredictor } from "@neaps/tide-predictor";
49+
*
50+
* const cache = createCorrectionsCache();
51+
*
52+
* for (const station of stations) {
53+
* const predictor = createTidePredictor(station.constituents, { cache });
54+
* results.push(predictor.getWaterLevelAtTime({ time }));
55+
* }
56+
* ```
57+
*/
58+
export function createCorrectionsCache({
59+
interval = 24,
60+
}: CorrectionsCacheOptions = {}): CorrectionsCache {
61+
const intervalMs = interval * 3_600_000;
62+
63+
// exact ms → AstroData evaluated at that precise time (for V0 arguments)
64+
const astroCache = new Map<number, AstroData>();
65+
66+
// fundamentals ref → bucket start (ms) → CachedCorrections
67+
// The corrections Map is populated incrementally across calls so different
68+
// models dicts can share the same CachedCorrections per (fundamentals, bucket).
69+
const correctionsCache = new WeakMap<Fundamentals, Map<number, CachedCorrections>>();
70+
71+
// models ref → exact ms → constituent name → d2r * model.value(baseAstro)
72+
const v0Cache = new WeakMap<Record<string, Constituent>, Map<number, Map<string, number>>>();
73+
74+
function bucketStart(time: Date): number {
75+
return Math.floor(time.getTime() / intervalMs) * intervalMs;
76+
}
77+
78+
function getCachedAstro(time: Date): AstroData {
79+
const key = time.getTime();
80+
let cached = astroCache.get(key);
81+
if (!cached) {
82+
cached = astro(time);
83+
astroCache.set(key, cached);
84+
}
85+
return cached;
86+
}
87+
88+
const cache: CorrectionsCache = {
89+
interval,
90+
91+
// Quantize time to bucket and return astro evaluated at bucket midpoint.
92+
getAstro(time: Date): AstroData {
93+
return getCachedAstro(new Date(bucketStart(time) + intervalMs / 2));
94+
},
95+
96+
getV0(time: Date, models: Record<string, Constituent>): Map<string, number> {
97+
const key = time.getTime();
98+
99+
let byTime = v0Cache.get(models);
100+
if (!byTime) {
101+
byTime = new Map();
102+
v0Cache.set(models, byTime);
103+
}
104+
105+
let v0 = byTime.get(key);
106+
if (!v0) {
107+
const baseAstro = getCachedAstro(time);
108+
v0 = new Map<string, number>();
109+
for (const name of Object.keys(models)) {
110+
v0.set(name, d2r * models[name].value(baseAstro));
111+
}
112+
byTime.set(key, v0);
113+
}
114+
115+
return v0;
116+
},
117+
118+
getCorrections(
119+
time: Date,
120+
models: Record<string, Constituent>,
121+
fundamentals: Fundamentals,
122+
): CachedCorrections {
123+
const key = bucketStart(time);
124+
125+
let byBucket = correctionsCache.get(fundamentals);
126+
if (!byBucket) {
127+
byBucket = new Map();
128+
correctionsCache.set(fundamentals, byBucket);
129+
}
130+
131+
const cached = byBucket.get(key);
132+
if (cached) return cached;
133+
134+
const astro = cache.getAstro(time);
135+
const corrections = new Map<string, NodalCorrection>();
136+
for (const name of Object.keys(models)) {
137+
corrections.set(name, models[name].correction(astro, fundamentals));
138+
}
139+
140+
const entry: CachedCorrections = { astro, corrections };
141+
byBucket.set(key, entry);
142+
return entry;
143+
},
144+
};
145+
146+
return cache;
147+
}

packages/tide-predictor/src/harmonics/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { Constituent } from "../constituents/types.js";
44
import { iho, Fundamentals } from "../node-corrections/index.js";
55
import { d2r } from "../astronomy/constants.js";
66
import type { HarmonicConstituent, Prediction } from "./prediction.js";
7+
import type { CorrectionsCache } from "../corrections-cache.js";
78

89
export type * from "./prediction.js";
910

@@ -12,6 +13,7 @@ export interface HarmonicsOptions {
1213
constituentModels?: Record<string, Constituent>;
1314
offset: number | false;
1415
fundamentals?: Fundamentals;
16+
cache?: CorrectionsCache;
1517
}
1618

1719
export interface PredictionOptions {
@@ -56,6 +58,7 @@ const harmonicsFactory = ({
5658
constituentModels = defaultConstituentModels,
5759
offset,
5860
fundamentals = iho,
61+
cache,
5962
}: HarmonicsOptions): Harmonics => {
6063
if (!Array.isArray(harmonicConstituents)) {
6164
throw new Error("Harmonic constituents are not an array");
@@ -104,6 +107,7 @@ const harmonicsFactory = ({
104107
constituentModels,
105108
start: timeline.items[0] ?? start,
106109
fundamentals,
110+
cache,
107111
});
108112
};
109113

0 commit comments

Comments
 (0)