@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.
Files changed (51) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/LICENSE +21 -0
  3. package/README.md +194 -2
  4. package/dist/cells.d.ts +84 -0
  5. package/dist/cells.js +179 -0
  6. package/dist/chunks.d.ts +29 -0
  7. package/dist/chunks.js +49 -0
  8. package/dist/dataset.d.ts +72 -0
  9. package/dist/dataset.js +115 -0
  10. package/dist/engine.d.ts +72 -0
  11. package/dist/engine.js +154 -0
  12. package/dist/erddap.d.ts +117 -0
  13. package/dist/erddap.js +224 -0
  14. package/dist/extent.d.ts +64 -0
  15. package/dist/extent.js +151 -0
  16. package/dist/fetchRetry.d.ts +33 -0
  17. package/dist/fetchRetry.js +76 -0
  18. package/dist/index.d.ts +24 -0
  19. package/dist/index.js +16 -0
  20. package/dist/lobes.d.ts +24 -0
  21. package/dist/lobes.js +55 -0
  22. package/dist/mask.d.ts +71 -0
  23. package/dist/mask.js +363 -0
  24. package/dist/preview.d.ts +121 -0
  25. package/dist/preview.js +205 -0
  26. package/dist/sql.d.ts +10 -0
  27. package/dist/sql.js +33 -0
  28. package/dist/sqlTemplates.d.ts +1 -0
  29. package/dist/sqlTemplates.js +8 -0
  30. package/dist/wkb.d.ts +5 -0
  31. package/dist/wkb.js +80 -0
  32. package/package.json +71 -4
  33. package/sql/last_step.sql +31 -0
  34. package/sql/points_tabledap.sql +21 -0
  35. package/sql/stats_categorical.sql +36 -0
  36. package/sql/stats_daily.sql +27 -0
  37. package/sql/stats_tabledap.sql +36 -0
  38. package/src/cells.ts +190 -0
  39. package/src/chunks.ts +58 -0
  40. package/src/dataset.ts +160 -0
  41. package/src/engine.ts +190 -0
  42. package/src/erddap.ts +271 -0
  43. package/src/extent.ts +172 -0
  44. package/src/fetchRetry.ts +71 -0
  45. package/src/index.ts +37 -0
  46. package/src/lobes.ts +66 -0
  47. package/src/mask.ts +341 -0
  48. package/src/preview.ts +282 -0
  49. package/src/sql.ts +36 -0
  50. package/src/sqlTemplates.ts +8 -0
  51. package/src/wkb.ts +81 -0
@@ -0,0 +1,205 @@
1
+ // the live preview: a series for any polygon x ERDDAP grid, in the browser, with the precompute's own chunking. a long
2
+ // window is a chain of griddap requests (at most 90 steps and 100,000 values each, `chunkDaysFor`), and each chunk's
3
+ // statistics are handed to the caller as they land, oldest first, so a chart draws while the rest is still coming.
4
+ // the per-lobe masking (the 0..360 shift included) is the precompute's `maskLobe` and the App's run(); the slab of a
5
+ // chunk is `(SELECT DISTINCT * FROM read_parquet([...one file per lobe]))` and the SQL is `stats_daily` or
6
+ // `stats_categorical`, exactly as `precompute/src/stats.ts` `computeOne` runs them. `sliceSource` is the map's half: one
7
+ // step's `last_step` rows for `@oceanmetrics/map`'s grid slot.
8
+ import { chunkDaysFor, chunks } from './chunks.js';
9
+ import { lobeLonSpan, statsTemplate, toDatasetLon, valueExpr } from './dataset.js';
10
+ import { AXIS_TIMEOUT_MS, SLICE_TIMEOUT_MS, fetchAxis, fetchSlab, griddapSliceUrl, griddapUrl, isParquet, noonZ } from './erddap.js';
11
+ import { timeInstant } from './extent.js';
12
+ import { gridMask } from './mask.js';
13
+ // ---- the chunk rule ----
14
+ /**
15
+ * Days per request for a window over `cells` axis cells per step (the largest lobe's lon x lat) of a grid whose step
16
+ * is `stepDays`: the precompute's `chunkDaysFor`, at most `maxDays` steps and `maxValues` values a request.
17
+ */
18
+ export function previewWindow(cells, stepDays = 1, { maxValues = 100_000, maxDays = 90 } = {}) {
19
+ return chunkDaysFor(cells, stepDays, { maxValues, maxDays });
20
+ }
21
+ // ---- the mask ----
22
+ /**
23
+ * The mask of every lobe on its axes, in the dataset's own longitude frame (a 0..360 grid's axis goes to the polygon's
24
+ * -180..180 for the mask and its cells come back to 0..360, so they join the slab), and the cells per step of the
25
+ * largest lobe (what sets the chunk size).
26
+ */
27
+ export function lobeMasks(dataset, lobes, axes) {
28
+ if (lobes.length !== axes.length)
29
+ throw new Error(`lobeMasks: ${lobes.length} lobe(s) but ${axes.length} axis pair(s)`);
30
+ const shifted = dataset.lonRange[1] > 180;
31
+ const toPoly = (x) => (shifted && x > 180 ? x - 360 : x);
32
+ const cells = [];
33
+ for (const [i, lobe] of lobes.entries()) {
34
+ const m = gridMask(lobe.geojson, axes[i].lon.map(toPoly), axes[i].lat);
35
+ for (const c of m.cells)
36
+ cells.push(shifted ? { ...c, lon: toDatasetLon(c.lon, dataset.lonRange) } : c);
37
+ }
38
+ return { cells, cellsPerStep: Math.max(0, ...axes.map((a) => a.lon.length * a.lat.length)) };
39
+ }
40
+ /** each lobe's axis vectors from the server (`fetchAxis`, the lobe's longitude span in the dataset's frame) */
41
+ export async function lobeAxes(dataset, lobes, { fetch: fetchFn, signal, retry = {} } = {}) {
42
+ const r = { timeoutMs: AXIS_TIMEOUT_MS, ...retry, ...(fetchFn ? { fetchFn } : {}) };
43
+ return Promise.all(lobes.map(async (lobe) => {
44
+ const [lo, hi] = lobeLonSpan(lobe.bbox, dataset.lonRange);
45
+ const [lon, lat] = await Promise.all([
46
+ fetchAxis(dataset.baseUrl, dataset.datasetId, 'longitude', lo, hi, false, signal, r),
47
+ fetchAxis(dataset.baseUrl, dataset.datasetId, 'latitude', lobe.bbox[1], lobe.bbox[3], dataset.latDescending, signal, r),
48
+ ]);
49
+ return { lon, lat };
50
+ }));
51
+ }
52
+ // ---- shared ----
53
+ let seq = 0;
54
+ const uid = (what) => `${what}_${++seq}`;
55
+ const aborted = (signal) => { if (signal?.aborted)
56
+ throw signal.reason ?? new DOMException('aborted', 'AbortError'); };
57
+ /** a yyyy-mm-dd UTC day from what DuckDB-WASM hands back for a DATE (epoch ms, a Date, or a string) */
58
+ export function isoDay(v) {
59
+ if (v instanceof Date)
60
+ return v.toISOString().slice(0, 10);
61
+ if (typeof v === 'number' || typeof v === 'bigint')
62
+ return new Date(Number(v)).toISOString().slice(0, 10);
63
+ return String(v).slice(0, 10);
64
+ }
65
+ function slabUrl(dataset, variable, ax, time) {
66
+ const grid = {
67
+ base: dataset.baseUrl, datasetId: dataset.datasetId, variable,
68
+ lat: [ax.lat[ax.lat.length - 1], ax.lat[0]], lon: [ax.lon[0], ax.lon[ax.lon.length - 1]],
69
+ latDescending: dataset.latDescending, depth: dataset.depth, format: dataset.format ?? 'parquet',
70
+ };
71
+ return Array.isArray(time) ? griddapUrl({ ...grid, time }) : griddapSliceUrl({ ...grid, at: time.at });
72
+ }
73
+ function parquetFormat(dataset) {
74
+ if (dataset.protocol === 'tabledap')
75
+ throw new Error(`${dataset.datasetId}: the preview is for griddap datasets (tabledap has no lattice)`);
76
+ const f = dataset.format ?? 'parquet';
77
+ if (!isParquet(f))
78
+ throw new Error(`${dataset.datasetId}: the preview reads Parquet slabs, and this server offers .${f}`);
79
+ return f;
80
+ }
81
+ /** fetch every lobe's slab for one request, register each under a fresh name; the read_parquet() list of them */
82
+ async function loadSlabs(engine, urls, format, signal, retry) {
83
+ const slabs = await Promise.all(urls.map((u) => fetchSlab(u, format, undefined, signal, retry)));
84
+ aborted(signal);
85
+ const files = [];
86
+ for (const s of slabs) {
87
+ const f = `${uid('slab')}.parquet`;
88
+ await engine.registerBuffer(f, s.buffer);
89
+ files.push(f);
90
+ }
91
+ return files;
92
+ }
93
+ const readList = (files) => `read_parquet([${files.map((f) => `'${f}'`).join(', ')}])`;
94
+ // the lobes' slabs as one relation without a row twice: where two places' boxes overlap (Channel Islands and Chumash)
95
+ // both slabs hold the cells of the overlap, and the join over the mask would return each of them twice
96
+ const distinctSlab = (files) => `(SELECT DISTINCT * FROM ${readList(files)})`;
97
+ /**
98
+ * The statistics of `variable` over the place for [t0, t1], chunk by chunk. Each chunk's slab (one Parquet request per
99
+ * lobe) is registered, aggregated with the template over the mask and dropped; `onChunk` gets its rows as soon as they
100
+ * are computed, in time order. `signal` stops the chain: the in-flight requests are cancelled, nothing after the abort
101
+ * reaches `onChunk`, and the promise rejects with the abort reason. The mask table and the slabs are dropped either way.
102
+ */
103
+ export async function runPreview(o) {
104
+ const { dataset, variable, axes, engine, signal } = o;
105
+ const format = parquetFormat(dataset);
106
+ aborted(signal);
107
+ const masked = o.mask
108
+ ? { cells: o.mask, cellsPerStep: Math.max(0, ...axes.map((a) => a.lon.length * a.lat.length)) }
109
+ : lobeMasks(dataset, o.lobes ?? [], axes);
110
+ const name = statsTemplate(variable);
111
+ const chunkDays = previewWindow(masked.cellsPerStep, o.stepDays ?? o.extent?.stepDays ?? 1, o.limits);
112
+ const at = (d) => timeInstant(d, o.extent ?? null, noonZ);
113
+ const list = chunks(o.t0, o.t1, chunkDays).map(([s, e], index, all) => ({
114
+ index, count: all.length, start: s, end: e,
115
+ urls: axes.map((ax) => slabUrl(dataset, variable.name, ax, [at(s), at(e)])),
116
+ }));
117
+ const result = { rows: [], chunks: list, chunkDays, template: name, maskCells: masked.cells.length };
118
+ if (!masked.cells.length || !list.length)
119
+ return result;
120
+ const retry = { ...o.retry, ...(o.fetch ? { fetchFn: o.fetch } : {}) };
121
+ const mask = uid('preview_mask');
122
+ const expr = valueExpr(variable);
123
+ const seen = new Set();
124
+ const inFlight = [];
125
+ // the fetch half of a chunk: started up to `concurrency` chunks ahead, consumed in order
126
+ const start = (k) => {
127
+ if (k < list.length && !inFlight[k]) {
128
+ inFlight[k] = Promise.all(list[k].urls.map((u) => fetchSlab(u, format, undefined, signal, retry)));
129
+ inFlight[k].catch(() => { }); // a later chunk's failure surfaces when it is awaited, or never (after an abort)
130
+ }
131
+ };
132
+ const ahead = Math.max(1, o.concurrency ?? 2);
133
+ try {
134
+ await engine.insertMask(masked.cells, mask);
135
+ for (const chunk of list) {
136
+ for (let k = chunk.index; k < chunk.index + ahead; k++)
137
+ start(k);
138
+ const slabs = await inFlight[chunk.index];
139
+ aborted(signal);
140
+ const files = [];
141
+ try {
142
+ for (const s of slabs) {
143
+ const f = `${uid('slab')}.parquet`;
144
+ await engine.registerBuffer(f, s.buffer);
145
+ files.push(f);
146
+ }
147
+ // DISTINCT: a coarse axis (the 8-day grid) can snap a chunk's two endpoints onto one step
148
+ const slab = distinctSlab(files);
149
+ const out = await engine.runTemplate(name, { expr, slab, mask });
150
+ aborted(signal);
151
+ // two chunks snapped onto the same step of a coarse axis give that step twice, identical: keep the first
152
+ const rows = out.map((r) => ({ ...r, date: isoDay(r.date) })).filter((r) => !seen.has(r.date));
153
+ for (const r of rows)
154
+ seen.add(r.date);
155
+ result.rows.push(...rows);
156
+ o.onChunk?.(rows, chunk);
157
+ }
158
+ finally {
159
+ for (const f of files)
160
+ await engine.dropFile(f).catch(() => { });
161
+ }
162
+ }
163
+ }
164
+ finally {
165
+ await engine.exec(`DROP TABLE IF EXISTS ${mask}`, 'drop').catch(() => { });
166
+ }
167
+ return result;
168
+ }
169
+ /**
170
+ * A `SliceSource` for the grid slot: `slice(t)` fetches the one step `t` (a yyyy-mm-dd day or an ISO instant; null for
171
+ * the newest) of every lobe and runs `last_step` over the mask: the masked cells with their own lat/lon, the area
172
+ * weight and the value (null where the step has no data). The mask goes into the engine once, at the first slice.
173
+ */
174
+ export function sliceSource(o) {
175
+ const format = parquetFormat(o.dataset);
176
+ const cells = o.mask ?? lobeMasks(o.dataset, o.lobes ?? [], o.axes).cells;
177
+ const mask = uid('slice_mask');
178
+ let inserted = null;
179
+ const retry = { timeoutMs: SLICE_TIMEOUT_MS, ...o.retry, ...(o.fetch ? { fetchFn: o.fetch } : {}) };
180
+ return {
181
+ async slice(t, signal) {
182
+ aborted(signal);
183
+ if (!cells.length)
184
+ return [];
185
+ const at = t === null ? (o.extent ? o.extent.end : 'last')
186
+ : t.length <= 10 ? timeInstant(t, o.extent ?? null, noonZ) : t;
187
+ inserted ??= o.engine.insertMask(cells, mask).catch((e) => { inserted = null; throw e; });
188
+ const urls = o.axes.map((ax) => slabUrl(o.dataset, o.variable.name, ax, { at }));
189
+ const files = await loadSlabs(o.engine, urls, format, signal, retry);
190
+ try {
191
+ await inserted;
192
+ aborted(signal);
193
+ const rows = await o.engine.runTemplate('last_step', { expr: valueExpr(o.variable), slab: distinctSlab(files), mask });
194
+ return rows.map((r) => ({
195
+ lat: Number(r.latitude), lon: Number(r.longitude), weight: Number(r.weight),
196
+ value: r.value === null || r.value === undefined ? null : Number(r.value),
197
+ }));
198
+ }
199
+ finally {
200
+ for (const f of files)
201
+ await o.engine.dropFile(f).catch(() => { });
202
+ }
203
+ },
204
+ };
205
+ }
package/dist/sql.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ /** the names of the shipped templates */
2
+ export type TemplateName = 'stats_daily' | 'stats_categorical' | 'last_step' | 'stats_tabledap' | 'points_tabledap';
3
+ export declare function template(name: string): string;
4
+ export declare function templateNames(): string[];
5
+ export type Param = string | number | boolean | null;
6
+ export interface Params {
7
+ [k: string]: Param;
8
+ }
9
+ export declare function lit(v: Param): string;
10
+ export declare function render(name: string, params: Params): string;
package/dist/sql.js ADDED
@@ -0,0 +1,33 @@
1
+ // the {{named}}-parameter SQL templates of sql/, rendered without DuckDB. moved from erddap-places `app/src/lib/sql.ts`:
2
+ // the templates come from the generated sqlTemplates.ts (built from sql/*.sql by scripts/sql-templates.mjs) instead of
3
+ // Vite's import.meta.glob; `lit` and `render` are unchanged, so the browser, the precompute and om_extract (which
4
+ // carries byte-identical copies of the same .sql files) run the same text.
5
+ import { TEMPLATES } from './sqlTemplates.js';
6
+ export function template(name) {
7
+ const t = TEMPLATES[name];
8
+ if (t === undefined)
9
+ throw new Error(`no SQL template ${name} (have: ${Object.keys(TEMPLATES).join(', ')})`);
10
+ return t;
11
+ }
12
+ export function templateNames() {
13
+ return Object.keys(TEMPLATES).sort();
14
+ }
15
+ /** params spliced literally (identifiers and expressions: table/column names); everything else quoted. */
16
+ const RAW = new Set(['var', 'expr', 'slab', 'mask', 'src']);
17
+ export function lit(v) {
18
+ if (v === null || v === undefined)
19
+ return 'NULL';
20
+ if (typeof v === 'number')
21
+ return Number.isFinite(v) ? String(v) : 'NULL';
22
+ if (typeof v === 'boolean')
23
+ return v ? 'TRUE' : 'FALSE';
24
+ return `'${String(v).replace(/'/g, "''")}'`;
25
+ }
26
+ export function render(name, params) {
27
+ const body = template(name);
28
+ return body.replace(/\{\{(\w+)\}\}/g, (_, k) => {
29
+ if (!(k in params))
30
+ throw new Error(`template ${name}: missing param ${k}`);
31
+ return RAW.has(k) ? String(params[k]) : lit(params[k]);
32
+ }).trim();
33
+ }
@@ -0,0 +1 @@
1
+ export declare const TEMPLATES: Readonly<Record<string, string>>;
@@ -0,0 +1,8 @@
1
+ // generated by scripts/sql-templates.mjs from sql/*.sql: do not edit (edit the .sql, then run the script)
2
+ export const TEMPLATES = {
3
+ last_step: "-- the masked cells of the **last** time step in the slab, for the map layer.\n-- {{expr}} the value expression over the slab alias `s` (e.g. s.\"CRW_SST\", or\n-- (s.\"analysed_sst\" - 273.15) to convert Kelvin to Celsius)\n-- {{slab}} the registered griddap file (parquet) or view\n-- {{mask}} the mask table: latitude, longitude, weight (0,1]\n-- one row per cell: the mask's own latitude/longitude (so the squares land on the dataset grid), the\n-- area weight and the value. cells with no data on that step are kept with a NULL value, so the map\n-- draws the hole instead of moving the neighbouring squares. the join is the same exact equality on\n-- ROUND(x, 3) as stats_daily.sql (float32 axis vs float64 mask).\nWITH joined AS (\n SELECT\n s.\"time\" AS time,\n m.latitude AS latitude,\n m.longitude AS longitude,\n m.weight AS weight,\n {{expr}} AS value\n FROM {{slab}} s\n JOIN {{mask}} m\n ON ROUND(s.latitude::DOUBLE, 3) = ROUND(m.latitude::DOUBLE, 3)\n AND ROUND(s.longitude::DOUBLE, 3) = ROUND(m.longitude::DOUBLE, 3)\n)\nSELECT\n -- utc day; via epoch_ms because duckdb-wasm ships without icu, so TIMESTAMPTZ::DATE is unimplemented there\n make_timestamp(epoch_ms(time) * 1000)::DATE AS date,\n latitude,\n longitude,\n weight,\n value\nFROM joined\nWHERE time = (SELECT max(time) FROM joined)\nORDER BY latitude, longitude\n",
4
+ points_tabledap: "-- one row per masked sampling station, for the map layer of a tabledap run.\n-- {{expr}} the value expression over the slab alias `s`\n-- {{slab}} the registered tabledap file (parquet) or view\n-- {{mask}} the station table: latitude, longitude\n-- a tabledap window holds many casts at the same position and many depths per cast, so the map\n-- shows each **station**: its mean value over the window, the number of measurements behind it and\n-- the last time it was sampled.\nSELECT\n m.latitude AS latitude,\n m.longitude AS longitude,\n 1.0 AS weight,\n avg({{expr}}) AS value,\n count({{expr}}) AS n,\n make_timestamp(epoch_ms(max(s.\"time\")) * 1000)::DATE AS date\nFROM {{slab}} s\nJOIN {{mask}} m\n ON ROUND(s.latitude::DOUBLE, 5) = ROUND(m.latitude::DOUBLE, 5)\n AND ROUND(s.longitude::DOUBLE, 5) = ROUND(m.longitude::DOUBLE, 5)\nWHERE {{expr}} IS NOT NULL\nGROUP BY 1, 2\nORDER BY 1, 2\n",
5
+ stats_categorical: "-- per date x class composition for one place from a categorical ERDDAP griddap slab (seascapeR's\n-- sum_ss_grds_to_ts(): how much of the place is in each class on each date).\n-- {{expr}} the class expression over the slab alias `s` (e.g. s.\"CLASS\"); cast to BIGINT here\n-- {{slab}} the registered griddap file (parquet) or view\n-- {{mask}} the mask table: latitude, longitude, weight (0,1]\n-- `fraction` is area-weighted (partial boundary cells count for their overlap) and sums to exactly 1\n-- over the classes of a date; `percent_cells` is the unweighted cell count share.\n-- the join is the same exact equality on ROUND(x, 3) as stats_daily.sql (float32 axis vs float64 mask).\nWITH cells AS (\n SELECT\n -- utc day; via epoch_ms because duckdb-wasm ships without icu, so TIMESTAMPTZ::DATE is unimplemented there\n make_timestamp(epoch_ms(s.\"time\") * 1000)::DATE AS date,\n CAST({{expr}} AS BIGINT) AS class,\n m.weight AS weight\n FROM {{slab}} s\n JOIN {{mask}} m\n ON ROUND(s.latitude::DOUBLE, 3) = ROUND(m.latitude::DOUBLE, 3)\n AND ROUND(s.longitude::DOUBLE, 3) = ROUND(m.longitude::DOUBLE, 3)\n WHERE {{expr}} IS NOT NULL\n),\ntotals AS (\n SELECT date, count(*) AS n_total, sum(weight) AS weight_total\n FROM cells\n GROUP BY 1\n)\nSELECT\n c.date AS date,\n c.class AS class,\n count(*) AS n,\n sum(c.weight) AS weight,\n sum(c.weight) / any_value(t.weight_total) AS fraction,\n 100.0 * count(*) / any_value(t.n_total) AS percent_cells\nFROM cells c\nJOIN totals t USING (date)\nGROUP BY c.date, c.class\nORDER BY 1, 2\n",
6
+ stats_daily: "-- daily key statistics for one place from an ERDDAP griddap slab.\n-- {{expr}} the value expression over the slab alias `s` (e.g. s.\"CRW_SST\", or\n-- (s.\"analysed_sst\" - 273.15) to convert Kelvin to Celsius)\n-- {{slab}} the registered griddap file (parquet) or view\n-- {{mask}} the mask table: latitude, longitude, weight (0,1]\n-- the join is an exact equality on ROUND(x, 3): the ERDDAP axis is float32 in the parquet and float64\n-- in the JS mask, so 21.375 arrives as 21.374999... on one side; rounding to the grid's 3 decimals\n-- (0.05 spacing) makes the two match without a tolerance join.\nSELECT\n -- utc day; via epoch_ms because duckdb-wasm ships without icu, so TIMESTAMPTZ::DATE is unimplemented there\n make_timestamp(epoch_ms(s.\"time\") * 1000)::DATE AS date,\n count({{expr}}) AS n,\n avg({{expr}}) AS mean,\n sum({{expr}} * m.weight) / sum(m.weight) AS mean_wt,\n stddev_samp({{expr}}) AS sd,\n min({{expr}}) AS min,\n max({{expr}}) AS max,\n quantile_cont({{expr}}, 0.10) AS p10,\n quantile_cont({{expr}}, 0.90) AS p90,\n sum(m.weight) AS weight_sum\nFROM {{slab}} s\nJOIN {{mask}} m\n ON ROUND(s.latitude::DOUBLE, 3) = ROUND(m.latitude::DOUBLE, 3)\n AND ROUND(s.longitude::DOUBLE, 3) = ROUND(m.longitude::DOUBLE, 3)\nWHERE {{expr}} IS NOT NULL\nGROUP BY 1\nORDER BY 1\n",
7
+ stats_tabledap: "-- monthly key statistics for one place from an ERDDAP **tabledap** slab (point samples).\n-- {{expr}} the value expression over the slab alias `s` (e.g. s.\"measurement_value\")\n-- {{slab}} the registered tabledap file (parquet) or view\n-- {{mask}} the station table: latitude, longitude (weight is 1 — a point is in or out)\n-- tabledap points are sparse and irregular, so the roll-up is by **month**, not by day: a CalCOFI\n-- quarterly cruise gives a handful of days a year, and a daily grouping would be mostly empty.\n-- `n_casts` counts the distinct sampling events (time x position) behind the n measurements, since\n-- one cast contributes many depths.\n-- the join is an exact equality on ROUND(x, 5): the station positions come back out of DuckDB, go\n-- through the JS point-in-polygon mask and come back in, so five decimals (~1 m) is exact here.\nWITH pts AS (\n SELECT\n -- utc, via epoch_ms because duckdb-wasm ships without icu: TIMESTAMPTZ::DATE is unimplemented there\n make_timestamp(epoch_ms(s.\"time\") * 1000) AS ts,\n s.longitude::DOUBLE AS longitude,\n s.latitude::DOUBLE AS latitude,\n {{expr}} AS value\n FROM {{slab}} s\n JOIN {{mask}} m\n ON ROUND(s.latitude::DOUBLE, 5) = ROUND(m.latitude::DOUBLE, 5)\n AND ROUND(s.longitude::DOUBLE, 5) = ROUND(m.longitude::DOUBLE, 5)\n WHERE {{expr}} IS NOT NULL\n)\nSELECT\n date_trunc('month', ts)::DATE AS date,\n count(value) AS n,\n count(DISTINCT (ts, longitude, latitude)) AS n_casts,\n avg(value) AS mean,\n stddev_samp(value) AS sd,\n min(value) AS min,\n max(value) AS max,\n quantile_cont(value, 0.10) AS p10,\n quantile_cont(value, 0.90) AS p90\nFROM pts\nGROUP BY 1\nORDER BY 1\n",
8
+ };
package/dist/wkb.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ import type { Geometry } from 'geojson';
2
+ /** decode one WKB (or EWKB) geometry to GeoJSON, 2-D. */
3
+ export declare function wkbToGeoJSON(bytes: Uint8Array | ArrayBuffer): Geometry;
4
+ /** every Polygon of a geometry, as a MultiPolygon-style coordinate array. */
5
+ export declare function polygonCoordinates(g: Geometry): number[][][][];
package/dist/wkb.js ADDED
@@ -0,0 +1,80 @@
1
+ const u32 = (c) => { const v = c.view.getUint32(c.off, c.le); c.off += 4; return v; };
2
+ const f64 = (c) => { const v = c.view.getFloat64(c.off, c.le); c.off += 8; return v; };
3
+ /** byte order + type code of the geometry at the cursor; leaves the cursor after the header. */
4
+ function header(c) {
5
+ const order = c.view.getUint8(c.off);
6
+ c.off += 1;
7
+ if (order !== 0 && order !== 1)
8
+ throw new Error(`WKB: bad byte order ${order} at ${c.off - 1}`);
9
+ c.le = order === 1;
10
+ const raw = u32(c);
11
+ const ewkbZ = (raw & 0x80000000) !== 0;
12
+ const ewkbM = (raw & 0x40000000) !== 0;
13
+ const srid = (raw & 0x20000000) !== 0;
14
+ const base = raw & 0x0fffffff;
15
+ const iso = Math.floor(base / 1000); // 0 = XY, 1 = Z, 2 = M, 3 = ZM
16
+ const type = base % 1000;
17
+ if (srid)
18
+ u32(c); // skip the SRID
19
+ const dims = 2 + (ewkbZ ? 1 : 0) + (ewkbM ? 1 : 0) + (iso === 1 || iso === 2 ? 1 : iso === 3 ? 2 : 0);
20
+ return { type, dims };
21
+ }
22
+ const point = (c, dims) => {
23
+ const xy = [f64(c), f64(c)];
24
+ for (let i = 2; i < dims; i++)
25
+ f64(c); // drop Z / M
26
+ return xy;
27
+ };
28
+ const ring = (c, dims) => {
29
+ const n = u32(c), out = new Array(n);
30
+ for (let i = 0; i < n; i++)
31
+ out[i] = point(c, dims);
32
+ return out;
33
+ };
34
+ const rings = (c, dims) => {
35
+ const n = u32(c), out = new Array(n);
36
+ for (let i = 0; i < n; i++)
37
+ out[i] = ring(c, dims);
38
+ return out;
39
+ };
40
+ function geometry(c) {
41
+ const { type, dims } = header(c);
42
+ switch (type) {
43
+ case 1: return { type: 'Point', coordinates: point(c, dims) };
44
+ case 2: return { type: 'LineString', coordinates: ring(c, dims) };
45
+ case 3: return { type: 'Polygon', coordinates: rings(c, dims) };
46
+ case 4:
47
+ case 5:
48
+ case 6:
49
+ case 7: {
50
+ const n = u32(c);
51
+ const parts = new Array(n);
52
+ for (let i = 0; i < n; i++)
53
+ parts[i] = geometry(c); // each part carries its own header
54
+ if (type === 7)
55
+ return { type: 'GeometryCollection', geometries: parts };
56
+ if (type === 4)
57
+ return { type: 'MultiPoint', coordinates: parts.map((p) => p.coordinates) };
58
+ if (type === 5)
59
+ return { type: 'MultiLineString', coordinates: parts.map((p) => p.coordinates) };
60
+ return { type: 'MultiPolygon', coordinates: parts.map((p) => p.coordinates) };
61
+ }
62
+ default: throw new Error(`WKB: unsupported geometry type ${type}`);
63
+ }
64
+ }
65
+ /** decode one WKB (or EWKB) geometry to GeoJSON, 2-D. */
66
+ export function wkbToGeoJSON(bytes) {
67
+ const u = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
68
+ const view = new DataView(u.buffer, u.byteOffset, u.byteLength);
69
+ return geometry({ view, off: 0, le: true });
70
+ }
71
+ /** every Polygon of a geometry, as a MultiPolygon-style coordinate array. */
72
+ export function polygonCoordinates(g) {
73
+ if (g.type === 'Polygon')
74
+ return [g.coordinates];
75
+ if (g.type === 'MultiPolygon')
76
+ return g.coordinates;
77
+ if (g.type === 'GeometryCollection')
78
+ return g.geometries.flatMap(polygonCoordinates);
79
+ return [];
80
+ }
package/package.json CHANGED
@@ -1,6 +1,73 @@
1
1
  {
2
2
  "name": "@oceanmetrics/engine",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "the browser engine of Ocean Metrics: the place mask, ERDDAP requests, the SQL shared with the Python engine, DuckDB-WASM statistics and the chunked live preview",
5
+ "keywords": [
6
+ "ocean",
7
+ "erddap",
8
+ "griddap",
9
+ "duckdb-wasm",
10
+ "zonal-statistics",
11
+ "mask"
12
+ ],
13
+ "homepage": "https://github.com/oceanmetrics/platform/tree/main/packages/engine#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/oceanmetrics/platform/issues"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/oceanmetrics/platform.git",
20
+ "directory": "packages/engine"
21
+ },
22
+ "license": "MIT",
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "type": "module",
27
+ "main": "./dist/index.js",
28
+ "types": "./dist/index.d.ts",
29
+ "exports": {
30
+ ".": {
31
+ "types": "./dist/index.d.ts",
32
+ "default": "./dist/index.js"
33
+ },
34
+ "./package.json": "./package.json"
35
+ },
36
+ "files": [
37
+ "dist",
38
+ "src",
39
+ "!src/**/*.test.ts",
40
+ "sql",
41
+ "README.md",
42
+ "CHANGELOG.md",
43
+ "LICENSE"
44
+ ],
45
+ "sideEffects": false,
46
+ "engines": {
47
+ "node": ">=20"
48
+ },
49
+ "scripts": {
50
+ "build": "node scripts/sql-templates.mjs && tsc -p tsconfig.build.json",
51
+ "prepack": "npm run build",
52
+ "typecheck": "tsc --noEmit",
53
+ "test": "vitest run --root ../.. packages/engine"
54
+ },
55
+ "dependencies": {
56
+ "@turf/area": "7.4.0",
57
+ "@turf/bbox-clip": "7.4.0",
58
+ "@turf/boolean-point-in-polygon": "7.4.0",
59
+ "@turf/helpers": "7.4.0"
60
+ },
61
+ "peerDependencies": {
62
+ "@duckdb/duckdb-wasm": ">=1.29.0 <2",
63
+ "apache-arrow": ">=17"
64
+ },
65
+ "devDependencies": {
66
+ "@duckdb/duckdb-wasm": "1.29.0",
67
+ "@types/geojson": "^7946.0.16",
68
+ "@types/node": "^24.13.3",
69
+ "apache-arrow": "^17.0.0",
70
+ "typescript": "~5.9.0",
71
+ "vitest": "^5.0.1"
72
+ }
73
+ }
@@ -0,0 +1,31 @@
1
+ -- the masked cells of the **last** time step in the slab, for the map layer.
2
+ -- {{expr}} the value expression over the slab alias `s` (e.g. s."CRW_SST", or
3
+ -- (s."analysed_sst" - 273.15) to convert Kelvin to Celsius)
4
+ -- {{slab}} the registered griddap file (parquet) or view
5
+ -- {{mask}} the mask table: latitude, longitude, weight (0,1]
6
+ -- one row per cell: the mask's own latitude/longitude (so the squares land on the dataset grid), the
7
+ -- area weight and the value. cells with no data on that step are kept with a NULL value, so the map
8
+ -- draws the hole instead of moving the neighbouring squares. the join is the same exact equality on
9
+ -- ROUND(x, 3) as stats_daily.sql (float32 axis vs float64 mask).
10
+ WITH joined AS (
11
+ SELECT
12
+ s."time" AS time,
13
+ m.latitude AS latitude,
14
+ m.longitude AS longitude,
15
+ m.weight AS weight,
16
+ {{expr}} AS value
17
+ FROM {{slab}} s
18
+ JOIN {{mask}} m
19
+ ON ROUND(s.latitude::DOUBLE, 3) = ROUND(m.latitude::DOUBLE, 3)
20
+ AND ROUND(s.longitude::DOUBLE, 3) = ROUND(m.longitude::DOUBLE, 3)
21
+ )
22
+ SELECT
23
+ -- utc day; via epoch_ms because duckdb-wasm ships without icu, so TIMESTAMPTZ::DATE is unimplemented there
24
+ make_timestamp(epoch_ms(time) * 1000)::DATE AS date,
25
+ latitude,
26
+ longitude,
27
+ weight,
28
+ value
29
+ FROM joined
30
+ WHERE time = (SELECT max(time) FROM joined)
31
+ ORDER BY latitude, longitude
@@ -0,0 +1,21 @@
1
+ -- one row per masked sampling station, for the map layer of a tabledap run.
2
+ -- {{expr}} the value expression over the slab alias `s`
3
+ -- {{slab}} the registered tabledap file (parquet) or view
4
+ -- {{mask}} the station table: latitude, longitude
5
+ -- a tabledap window holds many casts at the same position and many depths per cast, so the map
6
+ -- shows each **station**: its mean value over the window, the number of measurements behind it and
7
+ -- the last time it was sampled.
8
+ SELECT
9
+ m.latitude AS latitude,
10
+ m.longitude AS longitude,
11
+ 1.0 AS weight,
12
+ avg({{expr}}) AS value,
13
+ count({{expr}}) AS n,
14
+ make_timestamp(epoch_ms(max(s."time")) * 1000)::DATE AS date
15
+ FROM {{slab}} s
16
+ JOIN {{mask}} m
17
+ ON ROUND(s.latitude::DOUBLE, 5) = ROUND(m.latitude::DOUBLE, 5)
18
+ AND ROUND(s.longitude::DOUBLE, 5) = ROUND(m.longitude::DOUBLE, 5)
19
+ WHERE {{expr}} IS NOT NULL
20
+ GROUP BY 1, 2
21
+ ORDER BY 1, 2
@@ -0,0 +1,36 @@
1
+ -- per date x class composition for one place from a categorical ERDDAP griddap slab (seascapeR's
2
+ -- sum_ss_grds_to_ts(): how much of the place is in each class on each date).
3
+ -- {{expr}} the class expression over the slab alias `s` (e.g. s."CLASS"); cast to BIGINT here
4
+ -- {{slab}} the registered griddap file (parquet) or view
5
+ -- {{mask}} the mask table: latitude, longitude, weight (0,1]
6
+ -- `fraction` is area-weighted (partial boundary cells count for their overlap) and sums to exactly 1
7
+ -- over the classes of a date; `percent_cells` is the unweighted cell count share.
8
+ -- the join is the same exact equality on ROUND(x, 3) as stats_daily.sql (float32 axis vs float64 mask).
9
+ WITH cells AS (
10
+ SELECT
11
+ -- utc day; via epoch_ms because duckdb-wasm ships without icu, so TIMESTAMPTZ::DATE is unimplemented there
12
+ make_timestamp(epoch_ms(s."time") * 1000)::DATE AS date,
13
+ CAST({{expr}} AS BIGINT) AS class,
14
+ m.weight AS weight
15
+ FROM {{slab}} s
16
+ JOIN {{mask}} m
17
+ ON ROUND(s.latitude::DOUBLE, 3) = ROUND(m.latitude::DOUBLE, 3)
18
+ AND ROUND(s.longitude::DOUBLE, 3) = ROUND(m.longitude::DOUBLE, 3)
19
+ WHERE {{expr}} IS NOT NULL
20
+ ),
21
+ totals AS (
22
+ SELECT date, count(*) AS n_total, sum(weight) AS weight_total
23
+ FROM cells
24
+ GROUP BY 1
25
+ )
26
+ SELECT
27
+ c.date AS date,
28
+ c.class AS class,
29
+ count(*) AS n,
30
+ sum(c.weight) AS weight,
31
+ sum(c.weight) / any_value(t.weight_total) AS fraction,
32
+ 100.0 * count(*) / any_value(t.n_total) AS percent_cells
33
+ FROM cells c
34
+ JOIN totals t USING (date)
35
+ GROUP BY c.date, c.class
36
+ ORDER BY 1, 2
@@ -0,0 +1,27 @@
1
+ -- daily key statistics for one place from an ERDDAP griddap slab.
2
+ -- {{expr}} the value expression over the slab alias `s` (e.g. s."CRW_SST", or
3
+ -- (s."analysed_sst" - 273.15) to convert Kelvin to Celsius)
4
+ -- {{slab}} the registered griddap file (parquet) or view
5
+ -- {{mask}} the mask table: latitude, longitude, weight (0,1]
6
+ -- the join is an exact equality on ROUND(x, 3): the ERDDAP axis is float32 in the parquet and float64
7
+ -- in the JS mask, so 21.375 arrives as 21.374999... on one side; rounding to the grid's 3 decimals
8
+ -- (0.05 spacing) makes the two match without a tolerance join.
9
+ SELECT
10
+ -- utc day; via epoch_ms because duckdb-wasm ships without icu, so TIMESTAMPTZ::DATE is unimplemented there
11
+ make_timestamp(epoch_ms(s."time") * 1000)::DATE AS date,
12
+ count({{expr}}) AS n,
13
+ avg({{expr}}) AS mean,
14
+ sum({{expr}} * m.weight) / sum(m.weight) AS mean_wt,
15
+ stddev_samp({{expr}}) AS sd,
16
+ min({{expr}}) AS min,
17
+ max({{expr}}) AS max,
18
+ quantile_cont({{expr}}, 0.10) AS p10,
19
+ quantile_cont({{expr}}, 0.90) AS p90,
20
+ sum(m.weight) AS weight_sum
21
+ FROM {{slab}} s
22
+ JOIN {{mask}} m
23
+ ON ROUND(s.latitude::DOUBLE, 3) = ROUND(m.latitude::DOUBLE, 3)
24
+ AND ROUND(s.longitude::DOUBLE, 3) = ROUND(m.longitude::DOUBLE, 3)
25
+ WHERE {{expr}} IS NOT NULL
26
+ GROUP BY 1
27
+ ORDER BY 1
@@ -0,0 +1,36 @@
1
+ -- monthly key statistics for one place from an ERDDAP **tabledap** slab (point samples).
2
+ -- {{expr}} the value expression over the slab alias `s` (e.g. s."measurement_value")
3
+ -- {{slab}} the registered tabledap file (parquet) or view
4
+ -- {{mask}} the station table: latitude, longitude (weight is 1 — a point is in or out)
5
+ -- tabledap points are sparse and irregular, so the roll-up is by **month**, not by day: a CalCOFI
6
+ -- quarterly cruise gives a handful of days a year, and a daily grouping would be mostly empty.
7
+ -- `n_casts` counts the distinct sampling events (time x position) behind the n measurements, since
8
+ -- one cast contributes many depths.
9
+ -- the join is an exact equality on ROUND(x, 5): the station positions come back out of DuckDB, go
10
+ -- through the JS point-in-polygon mask and come back in, so five decimals (~1 m) is exact here.
11
+ WITH pts AS (
12
+ SELECT
13
+ -- utc, via epoch_ms because duckdb-wasm ships without icu: TIMESTAMPTZ::DATE is unimplemented there
14
+ make_timestamp(epoch_ms(s."time") * 1000) AS ts,
15
+ s.longitude::DOUBLE AS longitude,
16
+ s.latitude::DOUBLE AS latitude,
17
+ {{expr}} AS value
18
+ FROM {{slab}} s
19
+ JOIN {{mask}} m
20
+ ON ROUND(s.latitude::DOUBLE, 5) = ROUND(m.latitude::DOUBLE, 5)
21
+ AND ROUND(s.longitude::DOUBLE, 5) = ROUND(m.longitude::DOUBLE, 5)
22
+ WHERE {{expr}} IS NOT NULL
23
+ )
24
+ SELECT
25
+ date_trunc('month', ts)::DATE AS date,
26
+ count(value) AS n,
27
+ count(DISTINCT (ts, longitude, latitude)) AS n_casts,
28
+ avg(value) AS mean,
29
+ stddev_samp(value) AS sd,
30
+ min(value) AS min,
31
+ max(value) AS max,
32
+ quantile_cont(value, 0.10) AS p10,
33
+ quantile_cont(value, 0.90) AS p90
34
+ FROM pts
35
+ GROUP BY 1
36
+ ORDER BY 1