@oceanmetrics/engine 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/LICENSE +21 -0
- package/README.md +194 -2
- package/dist/cells.d.ts +84 -0
- package/dist/cells.js +179 -0
- package/dist/chunks.d.ts +29 -0
- package/dist/chunks.js +49 -0
- package/dist/dataset.d.ts +72 -0
- package/dist/dataset.js +115 -0
- package/dist/engine.d.ts +72 -0
- package/dist/engine.js +154 -0
- package/dist/erddap.d.ts +117 -0
- package/dist/erddap.js +224 -0
- package/dist/extent.d.ts +64 -0
- package/dist/extent.js +151 -0
- package/dist/fetchRetry.d.ts +33 -0
- package/dist/fetchRetry.js +76 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +16 -0
- package/dist/lobes.d.ts +24 -0
- package/dist/lobes.js +55 -0
- package/dist/mask.d.ts +71 -0
- package/dist/mask.js +363 -0
- package/dist/preview.d.ts +121 -0
- package/dist/preview.js +205 -0
- package/dist/sql.d.ts +10 -0
- package/dist/sql.js +33 -0
- package/dist/sqlTemplates.d.ts +1 -0
- package/dist/sqlTemplates.js +8 -0
- package/dist/wkb.d.ts +5 -0
- package/dist/wkb.js +80 -0
- package/package.json +71 -4
- package/sql/last_step.sql +31 -0
- package/sql/points_tabledap.sql +21 -0
- package/sql/stats_categorical.sql +36 -0
- package/sql/stats_daily.sql +27 -0
- package/sql/stats_tabledap.sql +36 -0
- package/src/cells.ts +190 -0
- package/src/chunks.ts +58 -0
- package/src/dataset.ts +160 -0
- package/src/engine.ts +190 -0
- package/src/erddap.ts +271 -0
- package/src/extent.ts +172 -0
- package/src/fetchRetry.ts +71 -0
- package/src/index.ts +37 -0
- package/src/lobes.ts +66 -0
- package/src/mask.ts +341 -0
- package/src/preview.ts +282 -0
- package/src/sql.ts +36 -0
- package/src/sqlTemplates.ts +8 -0
- package/src/wkb.ts +81 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.1 (2026-10-23)
|
|
4
|
+
|
|
5
|
+
- Fix: `sliceSource().slice()` read the lobes' slabs without the `SELECT DISTINCT` that `runPreview` applies, so where two
|
|
6
|
+
places' boxes overlap (Channel Islands and Chumash) every cell of the overlap came back twice. Regression test with two
|
|
7
|
+
overlapping synthetic slabs.
|
|
8
|
+
|
|
9
|
+
## 0.1.0 (2026-10-23)
|
|
10
|
+
|
|
11
|
+
The browser engine, build week 2 step 2b: erddap-places' engine as a platform package, so the indicators Map's `grid`
|
|
12
|
+
slot and the live preview run the same SQL as the Python engine (`om_extract`).
|
|
13
|
+
|
|
14
|
+
- **Moved from erddap-places** (app 0.3.12): `gridMask` as `mask.ts` and `wkb.ts` byte for byte; `placeLobes` (now
|
|
15
|
+
`lobes.ts`, reading a `LobePlace`); `erddap.ts` (`griddapUrl`, `griddapSliceUrl`, `tabledapUrl`, `pickFormat`,
|
|
16
|
+
`fetchAxis`, `fetchSlab`, the constraint rules), `extent.ts`, `fetchRetry.ts` and `cells.ts` unchanged but for `.js`
|
|
17
|
+
specifiers; `catalog.ts`'s pure functions as `dataset.ts` (`toDataset`, `valueExpr`, `statsTemplate`, `lobeLonSpan`,
|
|
18
|
+
`resolveDataset` ...); the precompute's `window`, `chunks` and `chunkDaysFor` as `chunks.ts`, the two limits now options.
|
|
19
|
+
- **The SQL templates** in `sql/` (`stats_daily`, `stats_categorical`, `last_step`, `stats_tabledap`,
|
|
20
|
+
`points_tabledap`), shipped as strings (`TEMPLATES`, generated by `scripts/sql-templates.mjs`), rendered by `render`.
|
|
21
|
+
- **`Engine`** over DuckDB-WASM (a peer, >= 1.29): `registerBuffer`, `insertMask` (Arrow IPC), `insertRows`, `exec`,
|
|
22
|
+
`runTemplate`, `toParquet`, `dropFile`; the app passes `connect()`, and `connectBrowser(duckdb, bundles)` is the
|
|
23
|
+
browser boot. `toRows` scales DECIMAL columns (DuckDB-WASM returns them unscaled).
|
|
24
|
+
- **The live preview**: `previewWindow`, `lobeAxes`, `lobeMasks`, `runPreview` (a chain of chunked griddap Parquet
|
|
25
|
+
requests, `onChunk` in time order, abortable) and `sliceSource` (a `SliceSource` for `@oceanmetrics/map`'s `gridSlot`).
|
|
26
|
+
- Dependencies: turf 7.4.0 pinned (the parity fixtures record it); `@duckdb/duckdb-wasm` and `apache-arrow` as peers.
|
|
27
|
+
- Size budget set from a measurement: 26 794 B gzip, budget 40 000 B.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ocean Metrics LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,195 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @oceanmetrics/engine
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The browser engine of Ocean Metrics: a live series for any polygon × ERDDAP grid. The place mask (which grid cells a
|
|
4
|
+
polygon covers, with partial-area weights), the lobe split at ±180, the ERDDAP request URLs, the SQL templates shared
|
|
5
|
+
with the Python engine (`om_extract`), DuckDB-WASM as the statistics engine, the chunked live preview and the map's grid
|
|
6
|
+
slice. TypeScript, ESM.
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import { Engine, connectBrowser, placeLobes, lobeAxes, runPreview, sliceSource, toDataset } from '@oceanmetrics/engine'
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
**One rule, two engines.** The browser (this package, DuckDB-WASM) and the precompute and extractions (`om_extract`,
|
|
13
|
+
native DuckDB in Python) run the same mask arithmetic and the same SQL text, and the tests hold them to it: the mask
|
|
14
|
+
reproduces the fixtures in `extract/tests/fixtures/mask/` row for row with `===`, and `sql/*.sql` are byte-identical to
|
|
15
|
+
`om_extract/sql/` (see "Tests").
|
|
16
|
+
|
|
17
|
+
**Consumers.** `indicators` is the first (build week 2 step 2c): its Map's `grid` slot reads `sliceSource()` and its
|
|
18
|
+
live preview runs `runPreview()`. erddap-places' code is the frozen original: the app keeps its own copies of these
|
|
19
|
+
modules until its redirect to indicators at the end of build week 2, and nothing here edits it.
|
|
20
|
+
|
|
21
|
+
## What moved
|
|
22
|
+
|
|
23
|
+
From `oceanmetrics/erddap-places` on 2026-10-23 (app 0.3.12, commit 3b1868a):
|
|
24
|
+
|
|
25
|
+
| here | from | what changed |
|
|
26
|
+
|---|---|---|
|
|
27
|
+
| `src/mask.ts` | `app/src/lib/gridMask.ts` | nothing: byte for byte (its sha256 is the one the parity fixtures record) |
|
|
28
|
+
| `src/wkb.ts` | `app/src/lib/wkb.ts` | nothing: byte for byte |
|
|
29
|
+
| `src/lobes.ts` | `app/src/lib/gazetteer.ts` `placeLobes`, `Lobe` | `placeLobes()` word for word; it reads `LobePlace` (`place_id`, `geometry`), the slice of the gazetteer's `Place` it uses |
|
|
30
|
+
| `src/erddap.ts` | `app/src/lib/erddap.ts` | `.js` import specifiers only |
|
|
31
|
+
| `src/extent.ts` | `app/src/lib/extent.ts` | `.js` import specifiers only |
|
|
32
|
+
| `src/fetchRetry.ts` | `app/src/lib/fetchRetry.ts` | `.js` import specifiers only |
|
|
33
|
+
| `src/cells.ts` | `app/src/lib/cells.ts` | nothing (`@oceanmetrics/map` `grid.ts` has its own port of `cellSquares`; `cellPoints` and `placeMapBounds` live here) |
|
|
34
|
+
| `src/dataset.ts` | `app/src/lib/catalog.ts` | the pure functions word for word (`toDataset`, `valueExpr`, `statsTemplate`, `lobeLonSpan`, `resolveDataset` ...); `loadDatasets` (reads the gazetteer's catalog.json) and `linkMismatch` (the app's wording) stay in the app |
|
|
35
|
+
| `src/chunks.ts` | `precompute/src/stats.ts` `window`, `chunks`, `chunkDaysFor`; `precompute/src/targets.ts` `MAX_CHUNK_DAYS`, `MAX_REQUEST_VALUES` | `chunks()` word for word; `chunkDaysFor()` takes the two limits as options (defaulting to 90 and 100,000); `window()` defaults to `'all'` (the precompute's `DAYS`) |
|
|
36
|
+
| `src/sql.ts`, `sql/*.sql` | `app/src/lib/sql.ts`, the repo-root `sql/` | the templates come from `src/sqlTemplates.ts`, generated from `sql/*.sql` by `scripts/sql-templates.mjs` (run by `npm run build`), instead of Vite's `import.meta.glob`; `lit` and `render` unchanged |
|
|
37
|
+
| `src/engine.ts` | `app/src/lib/engine.ts` | the app boots DuckDB, not the package: `new Engine(connect)` with `connect() -> { db, conn }`, and `connectBrowser(duckdb, bundles)` for a browser; no `engine` singleton; `toRows` scales DECIMAL columns (below) |
|
|
38
|
+
| `src/preview.ts` | new: the precompute's `maskLobe` and `computeOne` chunk loop, for the browser | |
|
|
39
|
+
|
|
40
|
+
The moved tests came with them (`gridMask`, `wkb`, the lobe cases of `gazetteer`, `erddap`, `tabledap`, `extent`,
|
|
41
|
+
`sql`, `cells`, `catalog`, `maskPerf`, `fetchRetry`, and the precompute's `window`/`chunks`/`chunkDaysFor` cases), with
|
|
42
|
+
the fixtures `fixtures/{CINMS,FKNMS,HIHWNMS,PMNM}.geojson` (NOAA sanctuary boundaries), the Seascapes info table and ten
|
|
43
|
+
published STAC Collections (`fixtures/stac/`).
|
|
44
|
+
|
|
45
|
+
Not moved, and why:
|
|
46
|
+
|
|
47
|
+
- the live-network halves of `erddap.test.ts`, `extent.test.ts`, `catalog.test.ts`, `gazetteer.test.ts` and
|
|
48
|
+
`maskPerf.test.ts` (PacIOOS, the gazetteer bucket): this package's tests run offline;
|
|
49
|
+
- `maskPerf.test.ts`'s Svelte-proxy case (`plainPlace` unwrapping `$state`): a Svelte concern, it stays in the app;
|
|
50
|
+
- the lobe cases on the gazetteer's `places.parquet` run here on the GeoJSON fixtures instead (the same PMNM and FKNMS);
|
|
51
|
+
- the gazetteer readers (`loadPlaces`, `placeGeometry`, `unionBbox`, `placeBbox`): they read the places gazetteer
|
|
52
|
+
(`@oceanmetrics/places`), not a grid;
|
|
53
|
+
- the precompute's disk cache (`planChunks`, `computeOne`, `maskKey`, the provenance): Node only, they belong to the
|
|
54
|
+
precompute (now `om_extract`);
|
|
55
|
+
- the tabledap run (`pointMask` and `tabledapUrl` moved; the preview is griddap only, as the vision's row is).
|
|
56
|
+
|
|
57
|
+
## The SQL contract shared with Python
|
|
58
|
+
|
|
59
|
+
Five templates in `sql/`, rendered by `render(name, params)` (`{{expr}}`, `{{slab}}` and `{{mask}}` spliced as they
|
|
60
|
+
are, any other param quoted as a literal by `lit`):
|
|
61
|
+
|
|
62
|
+
| template | over | one row per | columns |
|
|
63
|
+
|---|---|---|---|
|
|
64
|
+
| `stats_daily` | a griddap slab | UTC day | `date, n, mean, mean_wt, sd, min, max, p10, p90, weight_sum` |
|
|
65
|
+
| `stats_categorical` | a categorical griddap slab | day × class | `date, class, n, weight, fraction, percent_cells` |
|
|
66
|
+
| `last_step` | a griddap slab | masked cell of the last step | `date, latitude, longitude, weight, value` (value NULL where the step has no data) |
|
|
67
|
+
| `stats_tabledap` | a tabledap slab | month | `date, n, n_casts, mean, sd, min, max, p10, p90` |
|
|
68
|
+
| `points_tabledap` | a tabledap slab | station | `latitude, longitude, weight, value, n, date` |
|
|
69
|
+
|
|
70
|
+
- `{{expr}}` is the value expression over the slab alias `s` (`valueExpr()`: `s."CRW_SST"`, or `(s."analysed_sst" -
|
|
71
|
+
273.15)` for Kelvin); `{{slab}}` a registered Parquet file or a subquery; `{{mask}}` a table of `latitude, longitude,
|
|
72
|
+
weight` (0, 1].
|
|
73
|
+
- The join is an exact equality on `ROUND(x, 3)` (grids) or `ROUND(x, 5)` (stations): ERDDAP's axis is float32 in the
|
|
74
|
+
Parquet, the mask float64.
|
|
75
|
+
- Days come from `epoch_ms(time)`, because DuckDB-WASM ships without ICU (`TIMESTAMPTZ::DATE` is unimplemented there).
|
|
76
|
+
- `om_extract/sql/` carries byte-identical copies of `stats_daily`, `stats_categorical` and `last_step` (the tabledap
|
|
77
|
+
two have no Python reader yet); `test/sql.test.ts` compares the bytes when the extract and erddap-places checkouts sit
|
|
78
|
+
beside platform. A change to a template is a change in all three repos.
|
|
79
|
+
|
|
80
|
+
## The preview rule
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
const ds = toDataset(collection) // a STAC erddap/* Collection
|
|
84
|
+
const v = ds.variables[0]
|
|
85
|
+
const lobes = placeLobes(place) // one griddap request per lobe
|
|
86
|
+
const axes = await lobeAxes(ds, lobes, { signal }) // each lobe's axis vectors from the server
|
|
87
|
+
const { cells } = lobeMasks(ds, lobes, axes) // mask once, share it with the slice
|
|
88
|
+
const run = await runPreview({
|
|
89
|
+
dataset: ds, variable: v, axes, mask: cells, t0: '2016-01-01', t1: '2026-10-20', engine, extent, signal,
|
|
90
|
+
onChunk: (rows, chunk) => chart.append(rows), // oldest first, as each lands
|
|
91
|
+
})
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
- **The chunk rule is the precompute's.** `previewWindow(cells, stepDays, { maxValues = 100_000, maxDays = 90 })` is
|
|
95
|
+
`chunkDaysFor`: at most 90 time steps a request (a coarser grid counts its own steps: 90 months of a monthly product),
|
|
96
|
+
shrunk so one request (one lobe) stays under 100,000 values (extractr's `n_max_vals_per_req`), never under one step.
|
|
97
|
+
`cells` is the largest lobe's lon × lat axis cells. A long window is `chunks(t0, t1, days)`, consecutive and
|
|
98
|
+
non-overlapping.
|
|
99
|
+
- **Each chunk is the precompute's chunk.** One griddap Parquet request per lobe (`griddapUrl`, the lobe's axes as the
|
|
100
|
+
bounds, the extent's own instants at the window's two ends via `timeInstant`), registered in DuckDB-WASM, read as
|
|
101
|
+
`(SELECT DISTINCT * FROM read_parquet([...]))`, aggregated with `stats_daily` or `stats_categorical`
|
|
102
|
+
(`statsTemplate(v)`), then dropped.
|
|
103
|
+
- **Drawn as they land, in time order.** Up to `concurrency` (default 2, the precompute's) requests are in flight, the
|
|
104
|
+
next chunk fetched while this one aggregates, and `onChunk` is called strictly in time order. A step that two chunks
|
|
105
|
+
snap onto (a coarse axis) is given once, as the precompute's DISTINCT merge does. `date` comes back as `yyyy-mm-dd`.
|
|
106
|
+
- **Abortable.** `signal` cancels the in-flight requests, nothing after the abort reaches `onChunk`, and the promise
|
|
107
|
+
rejects with the signal's reason. The run's mask table (`preview_mask_<n>`) and slabs are dropped either way.
|
|
108
|
+
- Griddap and Parquet only: a tabledap dataset or a server without Parquet (`pickFormat` gave `csvp` or `jsonp`) is
|
|
109
|
+
refused with a message. A mask with no cells asks the server nothing.
|
|
110
|
+
|
|
111
|
+
**The map's slice.** `sliceSource({ dataset, variable, lobes | mask, axes, engine, extent })` is a `SliceSource` for
|
|
112
|
+
`@oceanmetrics/map`'s `gridSlot(spec, source)`: `slice(t)` fetches the one step `t` (a day or an ISO instant;
|
|
113
|
+
`null` is the extent's end, or ERDDAP's `last` without an extent) of every lobe and returns `last_step`'s rows as
|
|
114
|
+
`{ lat, lon, value, weight }`, the mask's own centres, a cell without data kept with `value: null`. The mask goes into
|
|
115
|
+
the engine once, at the first slice. The slabs of the lobes are read as `SELECT DISTINCT *` (as `runPreview` does), so a
|
|
116
|
+
cell inside two overlapping boxes (Channel Islands and Chumash) comes back once, not twice (0.1.1); the mask you pass is
|
|
117
|
+
the places' union and must hold each cell once.
|
|
118
|
+
|
|
119
|
+
## DuckDB-WASM: the peer and the worker setup
|
|
120
|
+
|
|
121
|
+
`@duckdb/duckdb-wasm` (>= 1.29, < 2; the apps pin 1.29 or 1.32) and `apache-arrow` (>= 17, which DuckDB-WASM itself
|
|
122
|
+
depends on) are peer dependencies: the package imports only `apache-arrow` at run time (the mask's Arrow IPC insert)
|
|
123
|
+
and never loads DuckDB itself. The app owns the bundles and the Web Worker, and hands the engine a `connect()`:
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
// a Vite app: the wasm and worker files served from the app's own origin
|
|
127
|
+
import * as duckdb from '@duckdb/duckdb-wasm'
|
|
128
|
+
import ehWasm from '@duckdb/duckdb-wasm/dist/duckdb-eh.wasm?url'
|
|
129
|
+
import ehWorker from '@duckdb/duckdb-wasm/dist/duckdb-browser-eh.worker.js?url'
|
|
130
|
+
import mvpWasm from '@duckdb/duckdb-wasm/dist/duckdb-mvp.wasm?url'
|
|
131
|
+
import mvpWorker from '@duckdb/duckdb-wasm/dist/duckdb-browser-mvp.worker.js?url'
|
|
132
|
+
import { Engine, connectBrowser } from '@oceanmetrics/engine'
|
|
133
|
+
|
|
134
|
+
export const engine = new Engine(() => connectBrowser(duckdb, {
|
|
135
|
+
mvp: { mainModule: mvpWasm, mainWorker: mvpWorker },
|
|
136
|
+
eh : { mainModule: ehWasm, mainWorker: ehWorker },
|
|
137
|
+
}))
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
`connectBrowser` is erddap-places' boot: `selectBundle` (eh where the browser has exceptions, else mvp), a `Worker` on
|
|
141
|
+
the bundle's worker script, `AsyncDuckDB.instantiate`, one connection. `new Engine()` connects at once, so construct
|
|
142
|
+
it (or `import()` the module that does) when the app first needs it, keeping the 7 MB of WASM off the first paint. One
|
|
143
|
+
connection, and every call queued on one promise chain: a query never runs before an earlier insert has finished. Any
|
|
144
|
+
`{ db, conn }` with the methods `DuckDbLike` and `DuckConnLike` name works; the tests use the node-blocking bundle
|
|
145
|
+
(`test/helpers/nodeDb.ts`).
|
|
146
|
+
|
|
147
|
+
`toRows` scales DECIMAL columns: DuckDB-WASM hands a DECIMAL back as its unscaled integer, so erddap-places' engine
|
|
148
|
+
would have read `points_tabledap`'s `1.0 AS weight` as 10 (the app reads `n` there, so nothing on screen was wrong).
|
|
149
|
+
|
|
150
|
+
## Size
|
|
151
|
+
|
|
152
|
+
`npm run size` measures the gzipped sum of `dist/**/*.js` (comments included, before an app minifies): 26 794 B on
|
|
153
|
+
2026-10-23 at 0.1.0, budget 40 000 B (about 1.5×). Only this package's own modules count: DuckDB-WASM and Apache
|
|
154
|
+
Arrow are peers, and turf is a dependency the app's bundler adds (as `@oceanmetrics/map`'s geotiff is). The five SQL
|
|
155
|
+
templates do count (`sqlTemplates.js`, 2 285 B on their own).
|
|
156
|
+
|
|
157
|
+
What an app pays, bundled and minified with its dependencies (rolldown, gzip -9, `apache-arrow` and
|
|
158
|
+
`@duckdb/duckdb-wasm` external, 2026-10-23):
|
|
159
|
+
|
|
160
|
+
| entry | gzip |
|
|
161
|
+
|---|--:|
|
|
162
|
+
| the URLs and the dataset (`griddapUrl`, `tabledapUrl`, `pickFormat`, `fetchTimeExtent`, `toDataset`, `chunks`) | 2.6 kB |
|
|
163
|
+
| the mask (`gridMask`, `placeLobes`, `wkbToGeoJSON`, with turf) | 5.7 kB |
|
|
164
|
+
| the preview and the slice (`Engine`, `connectBrowser`, `placeLobes`, `lobeAxes`, `runPreview`, `sliceSource`, `toDataset`) | 12.7 kB |
|
|
165
|
+
| everything | 16.2 kB |
|
|
166
|
+
|
|
167
|
+
## Tests
|
|
168
|
+
|
|
169
|
+
`npm test` from the repo root. One test per rule with synthetic fixtures and no network:
|
|
170
|
+
|
|
171
|
+
- **Parity with Python** (`test/parity.test.ts`): the 15 cases of `extract/tests/fixtures/mask/*.json.gz` (written by
|
|
172
|
+
erddap-places' TypeScript through `make_expected.mjs`, and reproduced by `om_extract.mask`): the same WKB through
|
|
173
|
+
`wkbToGeoJSON`, `placeLobes` and `gridMask` gives the same lobes and every row (76,774 in 18 lobes) in order, weights
|
|
174
|
+
by `===`. The fixtures' recorded sha256 of `gridMask.ts`, `wkb.ts` and `placeLobes()` must be those of this package's
|
|
175
|
+
sources, and turf's version theirs. Skipped, with the reason, when the extract checkout is not beside platform
|
|
176
|
+
(`OM_EXTRACT_DIR` overrides) or when this CPU's `Math.sin` differs from the fixtures' arm64 generator
|
|
177
|
+
(`v8_sin.json.gz`).
|
|
178
|
+
- **The originals** (`test/moved.test.ts`): the modules against erddap-places, byte for byte or function for function,
|
|
179
|
+
when that checkout is beside platform (`OM_ERDDAP_PLACES_DIR` overrides).
|
|
180
|
+
- **The SQL** (`test/sql.test.ts`): `sqlTemplates.ts` is current, `sql/` is byte-identical to erddap-places' `sql/` and
|
|
181
|
+
`om_extract/sql/`, and erddap-places' `sql.test.ts` cases (they ran the native duckdb CLI) run in DuckDB-WASM.
|
|
182
|
+
- **The engine** (`test/engine.test.ts`): on `fixtures/slabs/stats_3x3x2.parquet` (extract's `test_stats.py` slab,
|
|
183
|
+
written by pyarrow with float32 axes: `python fixtures/slabs/make_slabs.py`) and its diagonal mask, `stats_daily`,
|
|
184
|
+
`stats_categorical` and `last_step` give that test's hand-computed rows exactly; the queue survives a failed query;
|
|
185
|
+
DECIMAL is scaled; `connectBrowser` boots in order.
|
|
186
|
+
- **The preview** (`test/preview.test.ts`): an injected fetch serves the slabs cut by a second DuckDB-WASM: the chunk
|
|
187
|
+
boundaries (90 days, the value cap), `onChunk` in time order when a later chunk lands first, two lobes unioned
|
|
188
|
+
DISTINCT, a snapped step given once, the Python numbers through the whole chain, abort mid-chain and during a hung
|
|
189
|
+
request, the slice rows, `slice(null)`.
|
|
190
|
+
- the moved unit tests beside each module in `src/`.
|
|
191
|
+
|
|
192
|
+
## Licence
|
|
193
|
+
|
|
194
|
+
MIT, Copyright (c) 2026 Ocean Metrics LLC. The sanctuary boundary fixtures are NOAA Office of National Marine
|
|
195
|
+
Sanctuaries data (public domain).
|
package/dist/cells.d.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { FeatureCollection, Point, Polygon } from 'geojson';
|
|
2
|
+
/** a mask cell with the value of one time step (null when the grid has no data there). */
|
|
3
|
+
export interface ValueCell {
|
|
4
|
+
lon: number;
|
|
5
|
+
lat: number;
|
|
6
|
+
weight: number;
|
|
7
|
+
value?: number | null;
|
|
8
|
+
}
|
|
9
|
+
/** what each square carries into the map (hover shows `value` and `weight`). */
|
|
10
|
+
export interface CellProps {
|
|
11
|
+
lon: number;
|
|
12
|
+
lat: number;
|
|
13
|
+
weight: number;
|
|
14
|
+
value: number | null;
|
|
15
|
+
}
|
|
16
|
+
export type LonCenter = 0 | 180;
|
|
17
|
+
export interface CellSquaresOptions {
|
|
18
|
+
/** cell width in degrees; inferred from the distinct cell longitudes when omitted. */
|
|
19
|
+
lonSpacing?: number;
|
|
20
|
+
/** cell height in degrees; inferred from the distinct cell latitudes when omitted. */
|
|
21
|
+
latSpacing?: number;
|
|
22
|
+
/** longitude frame: (-180, 180] for 0, [0, 360) for 180, or 'auto' by the cells' own span. */
|
|
23
|
+
center?: LonCenter | 'auto';
|
|
24
|
+
}
|
|
25
|
+
export interface CellSquares {
|
|
26
|
+
geojson: FeatureCollection<Polygon, CellProps>;
|
|
27
|
+
lonSpacing: number;
|
|
28
|
+
latSpacing: number;
|
|
29
|
+
center: LonCenter;
|
|
30
|
+
/** [west, south, east, north] of the squares, in the chosen frame (east may exceed 180). */
|
|
31
|
+
bbox: [number, number, number, number];
|
|
32
|
+
/** value range over the cells that have one, for the colour ramp ([0, 0] when there are none). */
|
|
33
|
+
range: [number, number];
|
|
34
|
+
}
|
|
35
|
+
/** a longitude in the frame `center` names: (-180, 180] for 0, [0, 360) for 180. */
|
|
36
|
+
export declare function normalizeLon(lon: number, center?: LonCenter): number;
|
|
37
|
+
/**
|
|
38
|
+
* The step of an axis given only the values that were used: the **median** positive gap between
|
|
39
|
+
* distinct sorted values, so gaps left by cells outside the mask (2x, 3x the step) cannot inflate
|
|
40
|
+
* it. Returns 0 when there is nothing to measure (fewer than two distinct values).
|
|
41
|
+
*/
|
|
42
|
+
export declare function axisSpacing(values: number[]): number;
|
|
43
|
+
/** the frame the cells fit in without wrapping: 180 when they span more than half the globe. */
|
|
44
|
+
export declare function pickCenter(lons: number[]): LonCenter;
|
|
45
|
+
/**
|
|
46
|
+
* One square per mask cell: `lon +- lonSpacing/2`, `lat +- latSpacing/2` (latitude clamped to the
|
|
47
|
+
* poles), in a single continuous longitude frame, with `value`, `weight`, `lon` and `lat` as
|
|
48
|
+
* properties. Cells whose value is missing are kept (drawn as a gap) with `value: null`.
|
|
49
|
+
*/
|
|
50
|
+
export declare function cellSquares(cells: ValueCell[], opts?: CellSquaresOptions): CellSquares;
|
|
51
|
+
/**
|
|
52
|
+
* Map bounds for a place, antimeridian-aware.
|
|
53
|
+
*
|
|
54
|
+
* The gazetteer splits geometry at +-180, so a place that straddles the line (PMNM) has the useless
|
|
55
|
+
* stored bbox `[-180, .., 180, ..]`. Only in that case — width > 180 — is the geometry walked, in
|
|
56
|
+
* the [0, 360) frame, giving bounds like `[177.8, 19.2, 210.0, 31.8]` that MapLibre fits across the
|
|
57
|
+
* seam. Every other place returns its stored bbox untouched, so selecting a place still does no
|
|
58
|
+
* geometry work (the FKNMS/TBNMS vertex counts are the reason that matters).
|
|
59
|
+
*/
|
|
60
|
+
export declare function placeMapBounds(place: {
|
|
61
|
+
bbox: [number, number, number, number];
|
|
62
|
+
geometry?: unknown;
|
|
63
|
+
}): [number, number, number, number];
|
|
64
|
+
/**
|
|
65
|
+
* The box to fit for a place of the gazetteer index. The index unwraps a place cut at the antimeridian
|
|
66
|
+
* (NMS:PMNM is 177.8..199.0, `centroid_lon` 188), so its bbox fits directly. A few rows still read -180..180
|
|
67
|
+
* (the geometry is split but the bbox is not unwrapped): those need the geometry, and give null until it is
|
|
68
|
+
* loaded (the caller fits when it arrives).
|
|
69
|
+
*/
|
|
70
|
+
export declare function indexBounds(bbox: [number, number, number, number], geometry?: unknown): [number, number, number, number] | null;
|
|
71
|
+
export interface CellPoints {
|
|
72
|
+
geojson: FeatureCollection<Point, CellProps>;
|
|
73
|
+
center: LonCenter;
|
|
74
|
+
bbox: [number, number, number, number];
|
|
75
|
+
range: [number, number];
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* One point per sample position, for a **tabledap** run: there is no cell to draw, so the map gets
|
|
79
|
+
* circles instead of squares. Same longitude framing as `cellSquares()`, so an antimeridian place
|
|
80
|
+
* stays in one piece, and the same properties, so the hover popup and the colour ramp are shared.
|
|
81
|
+
*/
|
|
82
|
+
export declare function cellPoints(cells: ValueCell[], opts?: {
|
|
83
|
+
center?: LonCenter | 'auto';
|
|
84
|
+
}): CellPoints;
|
package/dist/cells.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/** a longitude in the frame `center` names: (-180, 180] for 0, [0, 360) for 180. */
|
|
2
|
+
export function normalizeLon(lon, center = 0) {
|
|
3
|
+
let x = ((((lon + 180) % 360) + 360) % 360) - 180; // (-180, 180]
|
|
4
|
+
if (x === -180)
|
|
5
|
+
x = 180;
|
|
6
|
+
return center === 180 && x < 0 ? x + 360 : x;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* The step of an axis given only the values that were used: the **median** positive gap between
|
|
10
|
+
* distinct sorted values, so gaps left by cells outside the mask (2x, 3x the step) cannot inflate
|
|
11
|
+
* it. Returns 0 when there is nothing to measure (fewer than two distinct values).
|
|
12
|
+
*/
|
|
13
|
+
export function axisSpacing(values) {
|
|
14
|
+
const v = [...new Set(values.filter((x) => Number.isFinite(x)))].sort((a, b) => a - b);
|
|
15
|
+
if (v.length < 2)
|
|
16
|
+
return 0;
|
|
17
|
+
const gaps = [];
|
|
18
|
+
for (let i = 1; i < v.length; i++) {
|
|
19
|
+
const d = v[i] - v[i - 1];
|
|
20
|
+
if (d > 0)
|
|
21
|
+
gaps.push(d);
|
|
22
|
+
}
|
|
23
|
+
if (!gaps.length)
|
|
24
|
+
return 0;
|
|
25
|
+
gaps.sort((a, b) => a - b);
|
|
26
|
+
const m = gaps.length >> 1;
|
|
27
|
+
return gaps.length % 2 ? gaps[m] : (gaps[m - 1] + gaps[m]) / 2;
|
|
28
|
+
}
|
|
29
|
+
/** the frame the cells fit in without wrapping: 180 when they span more than half the globe. */
|
|
30
|
+
export function pickCenter(lons) {
|
|
31
|
+
if (!lons.length)
|
|
32
|
+
return 0;
|
|
33
|
+
const x = lons.map((l) => normalizeLon(l, 0));
|
|
34
|
+
return Math.max(...x) - Math.min(...x) > 180 ? 180 : 0;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* One square per mask cell: `lon +- lonSpacing/2`, `lat +- latSpacing/2` (latitude clamped to the
|
|
38
|
+
* poles), in a single continuous longitude frame, with `value`, `weight`, `lon` and `lat` as
|
|
39
|
+
* properties. Cells whose value is missing are kept (drawn as a gap) with `value: null`.
|
|
40
|
+
*/
|
|
41
|
+
export function cellSquares(cells, opts = {}) {
|
|
42
|
+
const center = opts.center === undefined || opts.center === 'auto'
|
|
43
|
+
? pickCenter(cells.map((c) => c.lon))
|
|
44
|
+
: opts.center;
|
|
45
|
+
const lons = cells.map((c) => normalizeLon(c.lon, center));
|
|
46
|
+
let dx = opts.lonSpacing ?? axisSpacing(lons);
|
|
47
|
+
let dy = opts.latSpacing ?? axisSpacing(cells.map((c) => c.lat));
|
|
48
|
+
// a single row or column gives that axis no gap to measure: borrow the other axis's step
|
|
49
|
+
if (!dx)
|
|
50
|
+
dx = dy;
|
|
51
|
+
if (!dy)
|
|
52
|
+
dy = dx;
|
|
53
|
+
const hx = dx / 2, hy = dy / 2;
|
|
54
|
+
let w = Infinity, s = Infinity, e = -Infinity, n = -Infinity;
|
|
55
|
+
let vmin = Infinity, vmax = -Infinity;
|
|
56
|
+
const features = cells.map((c, i) => {
|
|
57
|
+
const x = lons[i], y = c.lat;
|
|
58
|
+
const x0 = x - hx, x1 = x + hx;
|
|
59
|
+
const y0 = Math.max(-90, y - hy), y1 = Math.min(90, y + hy);
|
|
60
|
+
const value = typeof c.value === 'number' && Number.isFinite(c.value) ? c.value : null;
|
|
61
|
+
if (value !== null) {
|
|
62
|
+
if (value < vmin)
|
|
63
|
+
vmin = value;
|
|
64
|
+
if (value > vmax)
|
|
65
|
+
vmax = value;
|
|
66
|
+
}
|
|
67
|
+
if (x0 < w)
|
|
68
|
+
w = x0;
|
|
69
|
+
if (x1 > e)
|
|
70
|
+
e = x1;
|
|
71
|
+
if (y0 < s)
|
|
72
|
+
s = y0;
|
|
73
|
+
if (y1 > n)
|
|
74
|
+
n = y1;
|
|
75
|
+
return {
|
|
76
|
+
type: 'Feature',
|
|
77
|
+
id: i,
|
|
78
|
+
properties: { lon: x, lat: y, weight: c.weight, value },
|
|
79
|
+
geometry: { type: 'Polygon', coordinates: [[[x0, y0], [x1, y0], [x1, y1], [x0, y1], [x0, y0]]] },
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
return {
|
|
83
|
+
geojson: { type: 'FeatureCollection', features },
|
|
84
|
+
lonSpacing: dx, latSpacing: dy, center,
|
|
85
|
+
bbox: features.length ? [w, s, e, n] : [0, 0, 0, 0],
|
|
86
|
+
range: Number.isFinite(vmin) ? [vmin, vmax] : [0, 0],
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
// ── map bounds for a place ────────────────────────────────────────────────────
|
|
90
|
+
/**
|
|
91
|
+
* Map bounds for a place, antimeridian-aware.
|
|
92
|
+
*
|
|
93
|
+
* The gazetteer splits geometry at +-180, so a place that straddles the line (PMNM) has the useless
|
|
94
|
+
* stored bbox `[-180, .., 180, ..]`. Only in that case — width > 180 — is the geometry walked, in
|
|
95
|
+
* the [0, 360) frame, giving bounds like `[177.8, 19.2, 210.0, 31.8]` that MapLibre fits across the
|
|
96
|
+
* seam. Every other place returns its stored bbox untouched, so selecting a place still does no
|
|
97
|
+
* geometry work (the FKNMS/TBNMS vertex counts are the reason that matters).
|
|
98
|
+
*/
|
|
99
|
+
export function placeMapBounds(place) {
|
|
100
|
+
const [x0, y0, x1, y1] = place.bbox;
|
|
101
|
+
if (x1 - x0 <= 180 || !place.geometry)
|
|
102
|
+
return [x0, y0, x1, y1];
|
|
103
|
+
let w = Infinity, s = Infinity, e = -Infinity, n = -Infinity;
|
|
104
|
+
const walk = (c) => {
|
|
105
|
+
if (typeof c?.[0] === 'number') {
|
|
106
|
+
const x = normalizeLon(c[0], 180), y = c[1];
|
|
107
|
+
if (x < w)
|
|
108
|
+
w = x;
|
|
109
|
+
if (x > e)
|
|
110
|
+
e = x;
|
|
111
|
+
if (y < s)
|
|
112
|
+
s = y;
|
|
113
|
+
if (y > n)
|
|
114
|
+
n = y;
|
|
115
|
+
}
|
|
116
|
+
else if (Array.isArray(c))
|
|
117
|
+
for (const k of c)
|
|
118
|
+
walk(k);
|
|
119
|
+
};
|
|
120
|
+
const g = place.geometry;
|
|
121
|
+
if (g.type === 'GeometryCollection')
|
|
122
|
+
for (const sub of g.geometries)
|
|
123
|
+
walk(sub.coordinates);
|
|
124
|
+
else
|
|
125
|
+
walk(g.coordinates);
|
|
126
|
+
return Number.isFinite(w) ? [w, s, e, n] : [x0, y0, x1, y1];
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* The box to fit for a place of the gazetteer index. The index unwraps a place cut at the antimeridian
|
|
130
|
+
* (NMS:PMNM is 177.8..199.0, `centroid_lon` 188), so its bbox fits directly. A few rows still read -180..180
|
|
131
|
+
* (the geometry is split but the bbox is not unwrapped): those need the geometry, and give null until it is
|
|
132
|
+
* loaded (the caller fits when it arrives).
|
|
133
|
+
*/
|
|
134
|
+
export function indexBounds(bbox, geometry) {
|
|
135
|
+
if (bbox[2] - bbox[0] <= 180)
|
|
136
|
+
return bbox;
|
|
137
|
+
return geometry ? placeMapBounds({ bbox, geometry }) : null;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* One point per sample position, for a **tabledap** run: there is no cell to draw, so the map gets
|
|
141
|
+
* circles instead of squares. Same longitude framing as `cellSquares()`, so an antimeridian place
|
|
142
|
+
* stays in one piece, and the same properties, so the hover popup and the colour ramp are shared.
|
|
143
|
+
*/
|
|
144
|
+
export function cellPoints(cells, opts = {}) {
|
|
145
|
+
const center = opts.center === undefined || opts.center === 'auto'
|
|
146
|
+
? pickCenter(cells.map((c) => c.lon))
|
|
147
|
+
: opts.center;
|
|
148
|
+
let w = Infinity, s = Infinity, e = -Infinity, n = -Infinity;
|
|
149
|
+
let vmin = Infinity, vmax = -Infinity;
|
|
150
|
+
const features = cells.map((c, i) => {
|
|
151
|
+
const x = normalizeLon(c.lon, center), y = c.lat;
|
|
152
|
+
const value = typeof c.value === 'number' && Number.isFinite(c.value) ? c.value : null;
|
|
153
|
+
if (value !== null) {
|
|
154
|
+
if (value < vmin)
|
|
155
|
+
vmin = value;
|
|
156
|
+
if (value > vmax)
|
|
157
|
+
vmax = value;
|
|
158
|
+
}
|
|
159
|
+
if (x < w)
|
|
160
|
+
w = x;
|
|
161
|
+
if (x > e)
|
|
162
|
+
e = x;
|
|
163
|
+
if (y < s)
|
|
164
|
+
s = y;
|
|
165
|
+
if (y > n)
|
|
166
|
+
n = y;
|
|
167
|
+
return {
|
|
168
|
+
type: 'Feature', id: i,
|
|
169
|
+
properties: { lon: x, lat: y, weight: c.weight, value },
|
|
170
|
+
geometry: { type: 'Point', coordinates: [x, y] },
|
|
171
|
+
};
|
|
172
|
+
});
|
|
173
|
+
return {
|
|
174
|
+
geojson: { type: 'FeatureCollection', features },
|
|
175
|
+
center,
|
|
176
|
+
bbox: features.length ? [w, s, e, n] : [0, 0, 0, 0],
|
|
177
|
+
range: Number.isFinite(vmin) ? [vmin, vmax] : [0, 0],
|
|
178
|
+
};
|
|
179
|
+
}
|
package/dist/chunks.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type TimeExtent } from './extent.js';
|
|
2
|
+
/** never ask ERDDAP for more than this many time steps in one request (90 days of a daily grid). */
|
|
3
|
+
export declare const MAX_CHUNK_DAYS = 90;
|
|
4
|
+
/**
|
|
5
|
+
* and shrink the chunk further so a single request (one lobe) stays under this many values: the
|
|
6
|
+
* `n_max_vals_per_req = 100000` of marinebon/extractr `ed_extract()`, which keeps an ERDDAP server
|
|
7
|
+
* responsive with many light requests instead of a few heavy ones.
|
|
8
|
+
*/
|
|
9
|
+
export declare const MAX_REQUEST_VALUES = 100000;
|
|
10
|
+
export interface ChunkLimits {
|
|
11
|
+
/** values per request (one lobe): time steps x axis cells */
|
|
12
|
+
maxValues?: number;
|
|
13
|
+
/** time steps per request */
|
|
14
|
+
maxDays?: number;
|
|
15
|
+
}
|
|
16
|
+
/** the last `days` days of the dataset, or its full extent when that is shorter (or `days` is 'all'). */
|
|
17
|
+
export declare function window(ext: TimeExtent, days?: number | 'all'): {
|
|
18
|
+
start: string;
|
|
19
|
+
end: string;
|
|
20
|
+
};
|
|
21
|
+
/** split [start, end] into consecutive, non-overlapping slices of at most `chunkDays` days. */
|
|
22
|
+
export declare function chunks(start: string, end: string, chunkDays: number): Array<[string, string]>;
|
|
23
|
+
/**
|
|
24
|
+
* days per request: `maxDays` time steps (90 days of a daily grid; a coarser grid's steps are
|
|
25
|
+
* counted in its own days, so a monthly product asks for up to 90 months at once), shrunk so one
|
|
26
|
+
* request stays under `maxValues` values (extractr's n_max_vals_per_req). `cellsPerStep` is the
|
|
27
|
+
* largest lobe's axis cells: each lobe is its own request. Never below one step.
|
|
28
|
+
*/
|
|
29
|
+
export declare function chunkDaysFor(cellsPerStep: number, stepDays?: number, { maxValues, maxDays }?: ChunkLimits): number;
|
package/dist/chunks.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// the time chunking of a long window: a window is cut into requests of at most 90 time steps and 100,000 values, so a
|
|
2
|
+
// server is asked many light questions instead of a few heavy ones. moved from erddap-places `precompute/src/stats.ts`
|
|
3
|
+
// (`window`, `chunks`, `chunkDaysFor`) with the two constants of `precompute/src/targets.ts`; the arithmetic is
|
|
4
|
+
// unchanged, and the two limits are now options (defaulting to the precompute's values) so the browser preview runs
|
|
5
|
+
// the same rule.
|
|
6
|
+
import { addDays, day, daysBetween } from './extent.js';
|
|
7
|
+
/** never ask ERDDAP for more than this many time steps in one request (90 days of a daily grid). */
|
|
8
|
+
export const MAX_CHUNK_DAYS = 90;
|
|
9
|
+
/**
|
|
10
|
+
* and shrink the chunk further so a single request (one lobe) stays under this many values: the
|
|
11
|
+
* `n_max_vals_per_req = 100000` of marinebon/extractr `ed_extract()`, which keeps an ERDDAP server
|
|
12
|
+
* responsive with many light requests instead of a few heavy ones.
|
|
13
|
+
*/
|
|
14
|
+
export const MAX_REQUEST_VALUES = 100_000;
|
|
15
|
+
/** the last `days` days of the dataset, or its full extent when that is shorter (or `days` is 'all'). */
|
|
16
|
+
export function window(ext, days = 'all') {
|
|
17
|
+
const end = day(ext.end);
|
|
18
|
+
if (days === 'all' || !Number.isFinite(days))
|
|
19
|
+
return { start: day(ext.start), end };
|
|
20
|
+
let start = addDays(end, -(days - 1));
|
|
21
|
+
if (daysBetween(day(ext.start), start) < 0)
|
|
22
|
+
start = day(ext.start);
|
|
23
|
+
return { start, end };
|
|
24
|
+
}
|
|
25
|
+
/** split [start, end] into consecutive, non-overlapping slices of at most `chunkDays` days. */
|
|
26
|
+
export function chunks(start, end, chunkDays) {
|
|
27
|
+
const out = [];
|
|
28
|
+
let s = start;
|
|
29
|
+
while (daysBetween(s, end) >= 0) {
|
|
30
|
+
const e = addDays(s, chunkDays - 1);
|
|
31
|
+
out.push([s, daysBetween(e, end) < 0 ? end : e]);
|
|
32
|
+
s = addDays(e, 1);
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* days per request: `maxDays` time steps (90 days of a daily grid; a coarser grid's steps are
|
|
38
|
+
* counted in its own days, so a monthly product asks for up to 90 months at once), shrunk so one
|
|
39
|
+
* request stays under `maxValues` values (extractr's n_max_vals_per_req). `cellsPerStep` is the
|
|
40
|
+
* largest lobe's axis cells: each lobe is its own request. Never below one step.
|
|
41
|
+
*/
|
|
42
|
+
export function chunkDaysFor(cellsPerStep, stepDays = 1, { maxValues = MAX_REQUEST_VALUES, maxDays = MAX_CHUNK_DAYS } = {}) {
|
|
43
|
+
const sd = Math.max(1, stepDays);
|
|
44
|
+
const cap = Math.floor(maxDays * sd);
|
|
45
|
+
if (!(cellsPerStep > 0))
|
|
46
|
+
return cap;
|
|
47
|
+
const steps = Math.max(1, Math.floor(maxValues / cellsPerStep));
|
|
48
|
+
return Math.max(1, Math.min(cap, Math.floor(steps * sd)));
|
|
49
|
+
}
|