@oceanmetrics/engine 0.0.0-stage → 0.1.0
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 +21 -0
- package/LICENSE +21 -0
- package/README.md +192 -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 +202 -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 +279 -0
- package/src/sql.ts +36 -0
- package/src/sqlTemplates.ts +8 -0
- package/src/wkb.ts +81 -0
package/src/cells.ts
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// the masked grid, as map geometry: one GeoJSON square per mask cell, sized from the dataset's own
|
|
2
|
+
// axis spacing (lon/lat +- half a step), carrying the cell's value and area weight.
|
|
3
|
+
//
|
|
4
|
+
// antimeridian: the mask cells of a place like PMNM arrive on both sides of +-180. drawn naively in
|
|
5
|
+
// the (-180, 180] frame, the two halves sit at opposite edges of the map and any square straddling
|
|
6
|
+
// the line wraps the whole globe. so the whole set is put in one continuous frame first: when the
|
|
7
|
+
// cells span more than 180 degrees of longitude, they are expressed in [0, 360) ("centred at 180")
|
|
8
|
+
// and the squares keep going past 180 (179.95 -> 180.05) instead of wrapping. MapLibre accepts
|
|
9
|
+
// longitudes outside [-180, 180] and draws them across the seam.
|
|
10
|
+
import type { Feature, FeatureCollection, Point, Polygon } from 'geojson'
|
|
11
|
+
|
|
12
|
+
/** a mask cell with the value of one time step (null when the grid has no data there). */
|
|
13
|
+
export interface ValueCell { lon: number; lat: number; weight: number; value?: number | null }
|
|
14
|
+
/** what each square carries into the map (hover shows `value` and `weight`). */
|
|
15
|
+
export interface CellProps { lon: number; lat: number; weight: number; value: number | null }
|
|
16
|
+
|
|
17
|
+
export type LonCenter = 0 | 180
|
|
18
|
+
export interface CellSquaresOptions {
|
|
19
|
+
/** cell width in degrees; inferred from the distinct cell longitudes when omitted. */
|
|
20
|
+
lonSpacing?: number
|
|
21
|
+
/** cell height in degrees; inferred from the distinct cell latitudes when omitted. */
|
|
22
|
+
latSpacing?: number
|
|
23
|
+
/** longitude frame: (-180, 180] for 0, [0, 360) for 180, or 'auto' by the cells' own span. */
|
|
24
|
+
center?: LonCenter | 'auto'
|
|
25
|
+
}
|
|
26
|
+
export interface CellSquares {
|
|
27
|
+
geojson : FeatureCollection<Polygon, CellProps>
|
|
28
|
+
lonSpacing: number
|
|
29
|
+
latSpacing: number
|
|
30
|
+
center : LonCenter
|
|
31
|
+
/** [west, south, east, north] of the squares, in the chosen frame (east may exceed 180). */
|
|
32
|
+
bbox : [number, number, number, number]
|
|
33
|
+
/** value range over the cells that have one, for the colour ramp ([0, 0] when there are none). */
|
|
34
|
+
range : [number, number]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** a longitude in the frame `center` names: (-180, 180] for 0, [0, 360) for 180. */
|
|
38
|
+
export function normalizeLon(lon: number, center: LonCenter = 0): number {
|
|
39
|
+
let x = ((((lon + 180) % 360) + 360) % 360) - 180 // (-180, 180]
|
|
40
|
+
if (x === -180) x = 180
|
|
41
|
+
return center === 180 && x < 0 ? x + 360 : x
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The step of an axis given only the values that were used: the **median** positive gap between
|
|
46
|
+
* distinct sorted values, so gaps left by cells outside the mask (2x, 3x the step) cannot inflate
|
|
47
|
+
* it. Returns 0 when there is nothing to measure (fewer than two distinct values).
|
|
48
|
+
*/
|
|
49
|
+
export function axisSpacing(values: number[]): number {
|
|
50
|
+
const v = [...new Set(values.filter((x) => Number.isFinite(x)))].sort((a, b) => a - b)
|
|
51
|
+
if (v.length < 2) return 0
|
|
52
|
+
const gaps: number[] = []
|
|
53
|
+
for (let i = 1; i < v.length; i++) { const d = v[i] - v[i - 1]; if (d > 0) gaps.push(d) }
|
|
54
|
+
if (!gaps.length) return 0
|
|
55
|
+
gaps.sort((a, b) => a - b)
|
|
56
|
+
const m = gaps.length >> 1
|
|
57
|
+
return gaps.length % 2 ? gaps[m] : (gaps[m - 1] + gaps[m]) / 2
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** the frame the cells fit in without wrapping: 180 when they span more than half the globe. */
|
|
61
|
+
export function pickCenter(lons: number[]): LonCenter {
|
|
62
|
+
if (!lons.length) return 0
|
|
63
|
+
const x = lons.map((l) => normalizeLon(l, 0))
|
|
64
|
+
return Math.max(...x) - Math.min(...x) > 180 ? 180 : 0
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* One square per mask cell: `lon +- lonSpacing/2`, `lat +- latSpacing/2` (latitude clamped to the
|
|
69
|
+
* poles), in a single continuous longitude frame, with `value`, `weight`, `lon` and `lat` as
|
|
70
|
+
* properties. Cells whose value is missing are kept (drawn as a gap) with `value: null`.
|
|
71
|
+
*/
|
|
72
|
+
export function cellSquares(cells: ValueCell[], opts: CellSquaresOptions = {}): CellSquares {
|
|
73
|
+
const center = opts.center === undefined || opts.center === 'auto'
|
|
74
|
+
? pickCenter(cells.map((c) => c.lon))
|
|
75
|
+
: opts.center
|
|
76
|
+
const lons = cells.map((c) => normalizeLon(c.lon, center))
|
|
77
|
+
let dx = opts.lonSpacing ?? axisSpacing(lons)
|
|
78
|
+
let dy = opts.latSpacing ?? axisSpacing(cells.map((c) => c.lat))
|
|
79
|
+
// a single row or column gives that axis no gap to measure: borrow the other axis's step
|
|
80
|
+
if (!dx) dx = dy
|
|
81
|
+
if (!dy) dy = dx
|
|
82
|
+
const hx = dx / 2, hy = dy / 2
|
|
83
|
+
|
|
84
|
+
let w = Infinity, s = Infinity, e = -Infinity, n = -Infinity
|
|
85
|
+
let vmin = Infinity, vmax = -Infinity
|
|
86
|
+
const features: Feature<Polygon, CellProps>[] = cells.map((c, i) => {
|
|
87
|
+
const x = lons[i], y = c.lat
|
|
88
|
+
const x0 = x - hx, x1 = x + hx
|
|
89
|
+
const y0 = Math.max(-90, y - hy), y1 = Math.min(90, y + hy)
|
|
90
|
+
const value = typeof c.value === 'number' && Number.isFinite(c.value) ? c.value : null
|
|
91
|
+
if (value !== null) { if (value < vmin) vmin = value; if (value > vmax) vmax = value }
|
|
92
|
+
if (x0 < w) w = x0; if (x1 > e) e = x1
|
|
93
|
+
if (y0 < s) s = y0; if (y1 > n) n = y1
|
|
94
|
+
return {
|
|
95
|
+
type : 'Feature',
|
|
96
|
+
id : i,
|
|
97
|
+
properties: { lon: x, lat: y, weight: c.weight, value },
|
|
98
|
+
geometry : { type: 'Polygon', coordinates: [[[x0, y0], [x1, y0], [x1, y1], [x0, y1], [x0, y0]]] },
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
geojson : { type: 'FeatureCollection', features },
|
|
104
|
+
lonSpacing: dx, latSpacing: dy, center,
|
|
105
|
+
bbox : features.length ? [w, s, e, n] : [0, 0, 0, 0],
|
|
106
|
+
range : Number.isFinite(vmin) ? [vmin, vmax] : [0, 0],
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── map bounds for a place ────────────────────────────────────────────────────
|
|
111
|
+
/**
|
|
112
|
+
* Map bounds for a place, antimeridian-aware.
|
|
113
|
+
*
|
|
114
|
+
* The gazetteer splits geometry at +-180, so a place that straddles the line (PMNM) has the useless
|
|
115
|
+
* stored bbox `[-180, .., 180, ..]`. Only in that case — width > 180 — is the geometry walked, in
|
|
116
|
+
* the [0, 360) frame, giving bounds like `[177.8, 19.2, 210.0, 31.8]` that MapLibre fits across the
|
|
117
|
+
* seam. Every other place returns its stored bbox untouched, so selecting a place still does no
|
|
118
|
+
* geometry work (the FKNMS/TBNMS vertex counts are the reason that matters).
|
|
119
|
+
*/
|
|
120
|
+
export function placeMapBounds(
|
|
121
|
+
place: { bbox: [number, number, number, number]; geometry?: unknown },
|
|
122
|
+
): [number, number, number, number] {
|
|
123
|
+
const [x0, y0, x1, y1] = place.bbox
|
|
124
|
+
if (x1 - x0 <= 180 || !place.geometry) return [x0, y0, x1, y1]
|
|
125
|
+
let w = Infinity, s = Infinity, e = -Infinity, n = -Infinity
|
|
126
|
+
const walk = (c: any) => {
|
|
127
|
+
if (typeof c?.[0] === 'number') {
|
|
128
|
+
const x = normalizeLon(c[0], 180), y = c[1]
|
|
129
|
+
if (x < w) w = x; if (x > e) e = x
|
|
130
|
+
if (y < s) s = y; if (y > n) n = y
|
|
131
|
+
} else if (Array.isArray(c)) for (const k of c) walk(k)
|
|
132
|
+
}
|
|
133
|
+
const g: any = place.geometry
|
|
134
|
+
if (g.type === 'GeometryCollection') for (const sub of g.geometries) walk(sub.coordinates)
|
|
135
|
+
else walk(g.coordinates)
|
|
136
|
+
return Number.isFinite(w) ? [w, s, e, n] : [x0, y0, x1, y1]
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The box to fit for a place of the gazetteer index. The index unwraps a place cut at the antimeridian
|
|
141
|
+
* (NMS:PMNM is 177.8..199.0, `centroid_lon` 188), so its bbox fits directly. A few rows still read -180..180
|
|
142
|
+
* (the geometry is split but the bbox is not unwrapped): those need the geometry, and give null until it is
|
|
143
|
+
* loaded (the caller fits when it arrives).
|
|
144
|
+
*/
|
|
145
|
+
export function indexBounds(
|
|
146
|
+
bbox: [number, number, number, number],
|
|
147
|
+
geometry?: unknown,
|
|
148
|
+
): [number, number, number, number] | null {
|
|
149
|
+
if (bbox[2] - bbox[0] <= 180) return bbox
|
|
150
|
+
return geometry ? placeMapBounds({ bbox, geometry }) : null
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── points (tabledap) ─────────────────────────────────────────────────────────
|
|
154
|
+
export interface CellPoints {
|
|
155
|
+
geojson: FeatureCollection<Point, CellProps>
|
|
156
|
+
center : LonCenter
|
|
157
|
+
bbox : [number, number, number, number]
|
|
158
|
+
range : [number, number]
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* One point per sample position, for a **tabledap** run: there is no cell to draw, so the map gets
|
|
163
|
+
* circles instead of squares. Same longitude framing as `cellSquares()`, so an antimeridian place
|
|
164
|
+
* stays in one piece, and the same properties, so the hover popup and the colour ramp are shared.
|
|
165
|
+
*/
|
|
166
|
+
export function cellPoints(cells: ValueCell[], opts: { center?: LonCenter | 'auto' } = {}): CellPoints {
|
|
167
|
+
const center = opts.center === undefined || opts.center === 'auto'
|
|
168
|
+
? pickCenter(cells.map((c) => c.lon))
|
|
169
|
+
: opts.center
|
|
170
|
+
let w = Infinity, s = Infinity, e = -Infinity, n = -Infinity
|
|
171
|
+
let vmin = Infinity, vmax = -Infinity
|
|
172
|
+
const features: Feature<Point, CellProps>[] = cells.map((c, i) => {
|
|
173
|
+
const x = normalizeLon(c.lon, center), y = c.lat
|
|
174
|
+
const value = typeof c.value === 'number' && Number.isFinite(c.value) ? c.value : null
|
|
175
|
+
if (value !== null) { if (value < vmin) vmin = value; if (value > vmax) vmax = value }
|
|
176
|
+
if (x < w) w = x; if (x > e) e = x
|
|
177
|
+
if (y < s) s = y; if (y > n) n = y
|
|
178
|
+
return {
|
|
179
|
+
type: 'Feature', id: i,
|
|
180
|
+
properties: { lon: x, lat: y, weight: c.weight, value },
|
|
181
|
+
geometry : { type: 'Point', coordinates: [x, y] },
|
|
182
|
+
}
|
|
183
|
+
})
|
|
184
|
+
return {
|
|
185
|
+
geojson: { type: 'FeatureCollection', features },
|
|
186
|
+
center,
|
|
187
|
+
bbox : features.length ? [w, s, e, n] : [0, 0, 0, 0],
|
|
188
|
+
range : Number.isFinite(vmin) ? [vmin, vmax] : [0, 0],
|
|
189
|
+
}
|
|
190
|
+
}
|
package/src/chunks.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
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, type TimeExtent } from './extent.js'
|
|
7
|
+
|
|
8
|
+
/** never ask ERDDAP for more than this many time steps in one request (90 days of a daily grid). */
|
|
9
|
+
export const MAX_CHUNK_DAYS = 90
|
|
10
|
+
/**
|
|
11
|
+
* and shrink the chunk further so a single request (one lobe) stays under this many values: the
|
|
12
|
+
* `n_max_vals_per_req = 100000` of marinebon/extractr `ed_extract()`, which keeps an ERDDAP server
|
|
13
|
+
* responsive with many light requests instead of a few heavy ones.
|
|
14
|
+
*/
|
|
15
|
+
export const MAX_REQUEST_VALUES = 100_000
|
|
16
|
+
|
|
17
|
+
export interface ChunkLimits {
|
|
18
|
+
/** values per request (one lobe): time steps x axis cells */
|
|
19
|
+
maxValues?: number
|
|
20
|
+
/** time steps per request */
|
|
21
|
+
maxDays?: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** the last `days` days of the dataset, or its full extent when that is shorter (or `days` is 'all'). */
|
|
25
|
+
export function window(ext: TimeExtent, days: number | 'all' = 'all'): { start: string; end: string } {
|
|
26
|
+
const end = day(ext.end)
|
|
27
|
+
if (days === 'all' || !Number.isFinite(days)) return { start: day(ext.start), end }
|
|
28
|
+
let start = addDays(end, -(days - 1))
|
|
29
|
+
if (daysBetween(day(ext.start), start) < 0) start = day(ext.start)
|
|
30
|
+
return { start, end }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** split [start, end] into consecutive, non-overlapping slices of at most `chunkDays` days. */
|
|
34
|
+
export function chunks(start: string, end: string, chunkDays: number): Array<[string, string]> {
|
|
35
|
+
const out: Array<[string, string]> = []
|
|
36
|
+
let s = start
|
|
37
|
+
while (daysBetween(s, end) >= 0) {
|
|
38
|
+
const e = addDays(s, chunkDays - 1)
|
|
39
|
+
out.push([s, daysBetween(e, end) < 0 ? end : e])
|
|
40
|
+
s = addDays(e, 1)
|
|
41
|
+
}
|
|
42
|
+
return out
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* days per request: `maxDays` time steps (90 days of a daily grid; a coarser grid's steps are
|
|
47
|
+
* counted in its own days, so a monthly product asks for up to 90 months at once), shrunk so one
|
|
48
|
+
* request stays under `maxValues` values (extractr's n_max_vals_per_req). `cellsPerStep` is the
|
|
49
|
+
* largest lobe's axis cells: each lobe is its own request. Never below one step.
|
|
50
|
+
*/
|
|
51
|
+
export function chunkDaysFor(cellsPerStep: number, stepDays = 1,
|
|
52
|
+
{ maxValues = MAX_REQUEST_VALUES, maxDays = MAX_CHUNK_DAYS }: ChunkLimits = {}): number {
|
|
53
|
+
const sd = Math.max(1, stepDays)
|
|
54
|
+
const cap = Math.floor(maxDays * sd)
|
|
55
|
+
if (!(cellsPerStep > 0)) return cap
|
|
56
|
+
const steps = Math.max(1, Math.floor(maxValues / cellsPerStep))
|
|
57
|
+
return Math.max(1, Math.min(cap, Math.floor(steps * sd)))
|
|
58
|
+
}
|
package/src/dataset.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// the STAC side of a dataset: one erddap/* Collection -> the fields a run needs (the griddap URL, the value expression,
|
|
2
|
+
// the template). moved from erddap-places `app/src/lib/catalog.ts`: the pure functions, unchanged; `loadDatasets` (it
|
|
3
|
+
// reads the gazetteer's catalog.json) and `linkMismatch` (the app's wording) stay in the app.
|
|
4
|
+
import type { Format } from './erddap.js'
|
|
5
|
+
import { pickFormat } from './erddap.js'
|
|
6
|
+
export interface CubeVariable {
|
|
7
|
+
name : string
|
|
8
|
+
unit : string | null
|
|
9
|
+
description: string
|
|
10
|
+
categorical: boolean
|
|
11
|
+
/** class value -> label, for categorical variables (from `erddap-places:classes`). */
|
|
12
|
+
classes ?: Record<string, string>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** the long-format columns of a tabledap table: `measurement_type` / `measurement_value`. */
|
|
16
|
+
export interface LongFormat { typeColumn: string; valueColumn: string }
|
|
17
|
+
|
|
18
|
+
export interface Dataset {
|
|
19
|
+
id : string // erddap/dhw_5km
|
|
20
|
+
title : string
|
|
21
|
+
baseUrl : string
|
|
22
|
+
datasetId : string
|
|
23
|
+
protocol : 'griddap' | 'tabledap'
|
|
24
|
+
/** set when the table keeps its variables in a column (tabledap, long format) */
|
|
25
|
+
longFormat ?: LongFormat
|
|
26
|
+
version ?: string
|
|
27
|
+
cors : boolean
|
|
28
|
+
formats : string[]
|
|
29
|
+
lonRange : [number, number]
|
|
30
|
+
latDescending: boolean
|
|
31
|
+
variables : CubeVariable[]
|
|
32
|
+
timeStep ?: string // e.g. P1D, P8D
|
|
33
|
+
timeExtent ?: [string | null, string | null]
|
|
34
|
+
/** `erddap-places:depth`: the depth level (m) to slice on a grid with a depth axis (the surface) */
|
|
35
|
+
depth ?: number
|
|
36
|
+
/** `erddap-places:status`: 'pending' when the server does not serve it yet (list it, greyed out) */
|
|
37
|
+
status ?: string
|
|
38
|
+
format : Format // best format this server can serve us
|
|
39
|
+
collection : any // the raw STAC Collection
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const KELVIN = /^(k|kelvin|degree_?k(elvin)?)$/i
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The SQL expression for a variable's value, converting Kelvin to °C.
|
|
46
|
+
*
|
|
47
|
+
* For a long-format table the variable is not a column: the rows were already filtered to
|
|
48
|
+
* `measurement_type = '<name>'` by the request, so the expression is the value column.
|
|
49
|
+
*/
|
|
50
|
+
export function valueExpr(v: CubeVariable, alias = 's', long?: LongFormat): string {
|
|
51
|
+
const col = long ? `${alias}."${long.valueColumn}"` : `${alias}."${v.name}"`
|
|
52
|
+
return KELVIN.test(v.unit ?? '') ? `(${col} - 273.15)` : col
|
|
53
|
+
}
|
|
54
|
+
/** the axis label for a variable, after any unit conversion. */
|
|
55
|
+
export function valueLabel(v: CubeVariable): string {
|
|
56
|
+
if (KELVIN.test(v.unit ?? '')) return '°C'
|
|
57
|
+
const u = (v.unit ?? '').trim()
|
|
58
|
+
if (!u || u === '1' || u.toLowerCase() === 'none') return v.name
|
|
59
|
+
if (/^(celsius|degree_?c(elsius)?)$/i.test(u)) return '°C'
|
|
60
|
+
return u
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** the `erddap-places:categorical` flag as published in the catalog (boolean, or the string). */
|
|
64
|
+
export function isCategorical(v: any): boolean {
|
|
65
|
+
const f = v?.['erddap-places:categorical']
|
|
66
|
+
return f === true || f === 'true'
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** `erddap-places:long_format` -> the two column names, or undefined. */
|
|
70
|
+
export function toLongFormat(f: any): LongFormat | undefined {
|
|
71
|
+
const t = f?.type_column, v = f?.value_column
|
|
72
|
+
return t && v ? { typeColumn: String(t), valueColumn: String(v) } : undefined
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The SQL template a run is summarised with: point (tabledap) data rolls up by month, a flagged
|
|
77
|
+
* categorical grid gets per-class fractions, everything else is the daily grid statistics.
|
|
78
|
+
*/
|
|
79
|
+
export function statsTemplate(
|
|
80
|
+
v: Pick<CubeVariable, 'categorical'> | null | undefined,
|
|
81
|
+
protocol: 'griddap' | 'tabledap' = 'griddap',
|
|
82
|
+
): 'stats_daily' | 'stats_categorical' | 'stats_tabledap' {
|
|
83
|
+
if (protocol === 'tabledap') return 'stats_tabledap'
|
|
84
|
+
return v?.categorical === true ? 'stats_categorical' : 'stats_daily'
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function toVariable(name: string, v: any): CubeVariable {
|
|
88
|
+
return {
|
|
89
|
+
name,
|
|
90
|
+
unit : v?.unit ?? null,
|
|
91
|
+
description: v?.description ?? name,
|
|
92
|
+
categorical: isCategorical(v),
|
|
93
|
+
classes : v?.['erddap-places:classes'] ?? undefined,
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** one STAC Collection -> the fields the app uses. */
|
|
98
|
+
export function toDataset(c: any): Dataset {
|
|
99
|
+
const vars = Object.entries(c['cube:variables'] ?? {})
|
|
100
|
+
.filter(([, v]: [string, any]) => (v?.type ?? 'data') === 'data')
|
|
101
|
+
.map(([k, v]) => toVariable(k, v))
|
|
102
|
+
const time = c['cube:dimensions']?.time ?? {}
|
|
103
|
+
return {
|
|
104
|
+
id : c.id,
|
|
105
|
+
title : c.title ?? c.id,
|
|
106
|
+
baseUrl : c['erddap:base_url'],
|
|
107
|
+
datasetId : c['erddap:dataset_id'],
|
|
108
|
+
protocol : c['erddap:protocol'] === 'tabledap' ? 'tabledap' : 'griddap',
|
|
109
|
+
longFormat : toLongFormat(c['erddap-places:long_format']),
|
|
110
|
+
version : c['erddap:version'],
|
|
111
|
+
cors : c['erddap:cors'] !== false,
|
|
112
|
+
formats : c['erddap:formats'] ?? [],
|
|
113
|
+
lonRange : c['erddap:lon_range'] ?? [-180, 180],
|
|
114
|
+
latDescending: c['erddap:lat_descending'] === true,
|
|
115
|
+
variables : vars,
|
|
116
|
+
timeStep : time.step,
|
|
117
|
+
timeExtent : time.extent,
|
|
118
|
+
depth : Number.isFinite(Number(c['erddap-places:depth'])) && c['erddap-places:depth'] != null
|
|
119
|
+
? Number(c['erddap-places:depth']) : undefined,
|
|
120
|
+
status : c['erddap-places:status'] ?? undefined,
|
|
121
|
+
format : pickFormat({ erddap: { cors: c['erddap:cors'], formats: c['erddap:formats'] } }),
|
|
122
|
+
collection : c,
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The dataset a link names. The app writes the STAC collection id (`erddap/dhw_5km`), but links written elsewhere name
|
|
128
|
+
* ERDDAP's own dataset id (`dhw_5km`, `IMERG_monthly_global_precip`), and one collection id differs from its dataset id
|
|
129
|
+
* in case (`erddap/imerg_monthly_global_precip`). Tried in turn, each ignoring case: the collection id, the collection
|
|
130
|
+
* id with `erddap/` before it, ERDDAP's dataset id. A pending dataset is never picked; null when nothing matches.
|
|
131
|
+
*/
|
|
132
|
+
export function resolveDataset<D extends Pick<Dataset, 'id' | 'datasetId' | 'status'>>(datasets: D[], wanted: string): D | null {
|
|
133
|
+
const w = String(wanted ?? '').trim().toLowerCase()
|
|
134
|
+
if (!w) return null
|
|
135
|
+
const live = datasets.filter((d) => d.status !== 'pending')
|
|
136
|
+
const eq = (a: string | undefined, b: string) => (a ?? '').toLowerCase() === b
|
|
137
|
+
return live.find((d) => d.id === wanted)
|
|
138
|
+
?? live.find((d) => eq(d.id, w))
|
|
139
|
+
?? live.find((d) => eq(d.id, `erddap/${w}`))
|
|
140
|
+
?? live.find((d) => eq(d.datasetId, w.replace(/^erddap\//, '')))
|
|
141
|
+
?? null
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** ERDDAP wants a lon in the dataset's own range: [-180, 180] or [0, 360]. */
|
|
145
|
+
export function toDatasetLon(lon: number, range: [number, number]): number {
|
|
146
|
+
if (range[1] > 180 && lon < 0) return lon + 360
|
|
147
|
+
if (range[0] < 0 && lon > 180) return lon - 360
|
|
148
|
+
return lon
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* A lobe's longitude span in the dataset's own frame, [min, max], clamped to `erddap:lon_range`:
|
|
153
|
+
* a lobe that runs to 180 on a grid ending at 179.75 (CMEMS 0.25°) would otherwise ask for a
|
|
154
|
+
* longitude past the axis maximum, which ERDDAP refuses (404).
|
|
155
|
+
*/
|
|
156
|
+
export function lobeLonSpan(bbox: [number, number, number, number] | number[], range: [number, number]): [number, number] {
|
|
157
|
+
const lo = toDatasetLon(bbox[0], range), hi = toDatasetLon(bbox[2], range)
|
|
158
|
+
const clamp = (x: number) => Math.min(Math.max(x, range[0]), range[1])
|
|
159
|
+
return [clamp(Math.min(lo, hi)), clamp(Math.max(lo, hi))]
|
|
160
|
+
}
|
package/src/engine.ts
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// DuckDB-WASM as the statistics engine: the slab goes in as a registered Parquet buffer, the mask as an Arrow table,
|
|
2
|
+
// and every statistic is a sql/ template rendered with {{named}} params. moved from erddap-places
|
|
3
|
+
// `app/src/lib/engine.ts`; the methods are unchanged, but the package never boots DuckDB itself: the app owns the
|
|
4
|
+
// bundles and the Web Worker (Vite `?url` imports, a CDN, or the node-blocking bundle in tests) and hands the Engine a
|
|
5
|
+
// `connect()` that resolves to `{ db, conn }`. `connectBrowser(duckdb, bundles)` is that boot for a browser app, with the
|
|
6
|
+
// duckdb-wasm module passed in (a peer dependency, never imported at run time here).
|
|
7
|
+
import { DataType, tableFromArrays, tableToIPC } from 'apache-arrow'
|
|
8
|
+
import type * as duckdbWasm from '@duckdb/duckdb-wasm'
|
|
9
|
+
import type { MaskCell } from './mask.js'
|
|
10
|
+
import { render } from './sql.js'
|
|
11
|
+
import type { Params } from './sql.js'
|
|
12
|
+
|
|
13
|
+
// ---- the database it runs on ----
|
|
14
|
+
|
|
15
|
+
/** the slice of a DuckDB-WASM database the engine uses: `AsyncDuckDB` in a browser, the node-blocking bindings in tests */
|
|
16
|
+
export interface DuckDbLike {
|
|
17
|
+
registerFileBuffer(name: string, buf: Uint8Array): unknown
|
|
18
|
+
copyFileToBuffer(name: string): Uint8Array | Promise<Uint8Array>
|
|
19
|
+
dropFile(name: string): unknown
|
|
20
|
+
}
|
|
21
|
+
/** the slice of a connection the engine uses: `AsyncDuckDBConnection`, or the blocking `DuckDBConnection` */
|
|
22
|
+
export interface DuckConnLike {
|
|
23
|
+
/** an Arrow table (or a promise of one): iterable rows, `schema.fields`, `numRows` */
|
|
24
|
+
query(sql: string): any
|
|
25
|
+
insertArrowFromIPCStream(buf: Uint8Array, opts: { name: string; create?: boolean }): unknown
|
|
26
|
+
}
|
|
27
|
+
export interface Duck { db: DuckDbLike; conn: DuckConnLike }
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The browser boot erddap-places' engine did itself: pick the bundle (eh or mvp), start its Web Worker, instantiate,
|
|
31
|
+
* connect. `duckdb` is the app's `import * as duckdb from '@duckdb/duckdb-wasm'`, `bundles` the module and worker
|
|
32
|
+
* URLs it serves (see the README, "Worker setup").
|
|
33
|
+
*/
|
|
34
|
+
export async function connectBrowser(duckdb: typeof duckdbWasm, bundles: duckdbWasm.DuckDBBundles,
|
|
35
|
+
logger: duckdbWasm.Logger = new duckdb.VoidLogger()): Promise<Duck> {
|
|
36
|
+
const bundle = await duckdb.selectBundle(bundles)
|
|
37
|
+
const worker = new Worker(bundle.mainWorker!)
|
|
38
|
+
const db = new duckdb.AsyncDuckDB(logger, worker)
|
|
39
|
+
await db.instantiate(bundle.mainModule, bundle.pthreadWorker)
|
|
40
|
+
return { db, conn: await db.connect() }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---- rows ----
|
|
44
|
+
export type Row = Record<string, any>
|
|
45
|
+
/**
|
|
46
|
+
* an Arrow result as plain rows: BIGINT as a number, and DECIMAL scaled. DuckDB-WASM hands a DECIMAL back as its
|
|
47
|
+
* unscaled integer (`1.0 AS weight` in points_tabledap.sql arrived as 10, a `VALUES (20.05, ...)` latitude as 2005);
|
|
48
|
+
* erddap-places' toRows passed it through, which no ERDDAP slab (float axes, float values) ever exercised.
|
|
49
|
+
*/
|
|
50
|
+
export function toRows(t: any): Row[] {
|
|
51
|
+
const cols: string[] = t.schema.fields.map((f: any) => f.name)
|
|
52
|
+
const scale: number[] = t.schema.fields.map((f: any) => (DataType.isDecimal(f.type) ? 10 ** f.type.scale : 0))
|
|
53
|
+
const out: Row[] = []
|
|
54
|
+
for (const r of t) {
|
|
55
|
+
const o: Row = {}
|
|
56
|
+
cols.forEach((c, i) => {
|
|
57
|
+
const v = r[c]
|
|
58
|
+
o[c] = typeof v === 'bigint' ? Number(v) : scale[i] && v !== null && v !== undefined ? Number(v) / scale[i] : v
|
|
59
|
+
})
|
|
60
|
+
out.push(o)
|
|
61
|
+
}
|
|
62
|
+
return out
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface EngineEvent { name: string; ms: number; note?: string }
|
|
66
|
+
|
|
67
|
+
const now = () => (globalThis.performance ?? Date).now()
|
|
68
|
+
|
|
69
|
+
export class Engine {
|
|
70
|
+
db!: DuckDbLike
|
|
71
|
+
conn!: DuckConnLike
|
|
72
|
+
ready: Promise<void>
|
|
73
|
+
marks: EngineEvent[] = []
|
|
74
|
+
lastSql = ''
|
|
75
|
+
private q: Promise<any> = Promise.resolve() // one connection: serialize everything
|
|
76
|
+
|
|
77
|
+
/** `connect` runs at once (construct the engine when the app first needs it) */
|
|
78
|
+
constructor(connect: () => Promise<Duck> | Duck) { this.ready = this.init(connect) }
|
|
79
|
+
private mark(name: string, ms: number, note?: string) { this.marks.push({ name, ms: Math.round(ms), note }) }
|
|
80
|
+
|
|
81
|
+
private async init(connect: () => Promise<Duck> | Duck) {
|
|
82
|
+
const t = now()
|
|
83
|
+
const { db, conn } = await connect()
|
|
84
|
+
this.db = db
|
|
85
|
+
this.conn = conn
|
|
86
|
+
this.mark('connect', now() - t)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** register an already-fetched slab (parquet bytes) under a virtual file name. */
|
|
90
|
+
async registerBuffer(name: string, buf: Uint8Array) {
|
|
91
|
+
const run = async () => {
|
|
92
|
+
await this.ready
|
|
93
|
+
const t = now()
|
|
94
|
+
await this.db.registerFileBuffer(name, buf)
|
|
95
|
+
this.mark(`register:${name}`, now() - t, `${(buf.byteLength / 1e3).toFixed(0)} kB`)
|
|
96
|
+
}
|
|
97
|
+
this.q = this.q.then(run, run)
|
|
98
|
+
return this.q
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** forget a registered file (a slab whose statistics are done). */
|
|
102
|
+
async dropFile(name: string) {
|
|
103
|
+
const run = async () => {
|
|
104
|
+
await this.ready
|
|
105
|
+
await this.db.dropFile(name)
|
|
106
|
+
}
|
|
107
|
+
this.q = this.q.then(run, run)
|
|
108
|
+
return this.q
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** the gridMask cells as a DuckDB table (via Arrow IPC, so no giant VALUES list). */
|
|
112
|
+
async insertMask(cells: MaskCell[], table = 'mask') {
|
|
113
|
+
const run = async () => {
|
|
114
|
+
await this.ready
|
|
115
|
+
const t = now()
|
|
116
|
+
const arrow = tableFromArrays({
|
|
117
|
+
latitude : Float64Array.from(cells.map((c) => c.lat)),
|
|
118
|
+
longitude: Float64Array.from(cells.map((c) => c.lon)),
|
|
119
|
+
weight : Float64Array.from(cells.map((c) => c.weight)),
|
|
120
|
+
})
|
|
121
|
+
await this.conn.query(`DROP TABLE IF EXISTS ${table}`)
|
|
122
|
+
await this.conn.insertArrowFromIPCStream(tableToIPC(arrow, 'stream'), { name: table, create: true })
|
|
123
|
+
this.mark(`mask:${table}`, now() - t, `${cells.length} cells`)
|
|
124
|
+
}
|
|
125
|
+
this.q = this.q.then(run, run)
|
|
126
|
+
return this.q
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** rows from a non-Parquet slab (.csvp / .jsonp) as a table, so the same SQL works on them. */
|
|
130
|
+
async insertRows(rows: Record<string, unknown>[], table: string) {
|
|
131
|
+
const run = async () => {
|
|
132
|
+
await this.ready
|
|
133
|
+
const t = now()
|
|
134
|
+
const cols = Object.keys(rows[0] ?? {})
|
|
135
|
+
const arrays: Record<string, any> = {}
|
|
136
|
+
for (const c of cols) {
|
|
137
|
+
const vals = rows.map((r) => r[c])
|
|
138
|
+
arrays[c] = vals.every((v) => typeof v === 'number' || v === null)
|
|
139
|
+
? Float64Array.from(vals.map((v) => (v === null ? NaN : (v as number))))
|
|
140
|
+
: vals.map((v) => (v === null || v === undefined ? null : String(v)))
|
|
141
|
+
}
|
|
142
|
+
await this.conn.query(`DROP TABLE IF EXISTS ${table}`)
|
|
143
|
+
await this.conn.insertArrowFromIPCStream(tableToIPC(tableFromArrays(arrays), 'stream'), { name: table, create: true })
|
|
144
|
+
this.mark(`rows:${table}`, now() - t, `${rows.length} rows`)
|
|
145
|
+
}
|
|
146
|
+
this.q = this.q.then(run, run)
|
|
147
|
+
return this.q
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
exec(sql: string, label = 'exec'): Promise<Row[]> {
|
|
151
|
+
const run = async () => {
|
|
152
|
+
await this.ready
|
|
153
|
+
const t = now()
|
|
154
|
+
this.lastSql = sql
|
|
155
|
+
const res = await this.conn.query(sql)
|
|
156
|
+
this.mark(`query:${label}`, now() - t, `${res.numRows} rows`)
|
|
157
|
+
return toRows(res)
|
|
158
|
+
}
|
|
159
|
+
this.q = this.q.then(run, run)
|
|
160
|
+
return this.q
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The result table as Parquet bytes, written by DuckDB itself.
|
|
165
|
+
*
|
|
166
|
+
* The rows go back into a temporary table (Arrow IPC, as the mask does), `COPY … TO` writes a
|
|
167
|
+
* Parquet file into the WASM filesystem and `copyFileToBuffer` hands it back. Going through the
|
|
168
|
+
* rows rather than re-running the statistics keeps the download identical to what is on screen,
|
|
169
|
+
* whatever has happened to the registered slab since.
|
|
170
|
+
*/
|
|
171
|
+
async toParquet(rows: Record<string, unknown>[], table = '_export', file = 'export.parquet'): Promise<Uint8Array> {
|
|
172
|
+
if (!rows.length) throw new Error('nothing to export')
|
|
173
|
+
await this.insertRows(rows, table)
|
|
174
|
+
const run = async () => {
|
|
175
|
+
await this.ready
|
|
176
|
+
const t = now()
|
|
177
|
+
await this.conn.query(`COPY (SELECT * FROM ${table}) TO '${file}' (FORMAT PARQUET)`)
|
|
178
|
+
const buf = await this.db.copyFileToBuffer(file)
|
|
179
|
+
await Promise.resolve(this.db.dropFile(file)).catch(() => {})
|
|
180
|
+
await this.conn.query(`DROP TABLE IF EXISTS ${table}`)
|
|
181
|
+
this.mark(`parquet:${file}`, now() - t, `${rows.length} rows, ${(buf.byteLength / 1e3).toFixed(0)} kB`)
|
|
182
|
+
return buf
|
|
183
|
+
}
|
|
184
|
+
this.q = this.q.then(run, run)
|
|
185
|
+
return this.q
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** render a `sql/<name>.sql` template with {{params}} and run it. */
|
|
189
|
+
runTemplate(name: string, params: Params): Promise<Row[]> { return this.exec(render(name, params), name) }
|
|
190
|
+
}
|