@uwrl/qc-utils 0.0.23 → 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/README.md CHANGED
@@ -1,56 +1,263 @@
1
1
  # @uwrl/qc-utils
2
2
 
3
- Quality-control utilities for hydrological time-series data. Used by [hydroserver-qc-app](https://github.com/hydroserver2/hydroserver-qc-app).
3
+ Worker-parallelized quality-control primitives for hydrological time-series.
4
+ Powers the QC pipeline in
5
+ [hydroserver-qc-app](https://github.com/hydroserver2/hydroserver-qc-app),
6
+ but the runtime has no Vue / app dependencies — anywhere you can run a
7
+ modern browser bundle is fair game.
4
8
 
5
- ## Local development with `npm link`
9
+ The package wraps a paired `Float64Array` (timestamps, ms epoch) and
10
+ `Float32Array` (values) in an `ObservationRecord` and exposes a single
11
+ history-driven dispatch surface. Every edit and filter is logged as a
12
+ `HistoryItem` you can replay, undo / redo, calibrate against the host
13
+ machine, and serialize to disk as a JSON "QC script".
6
14
 
7
- This package is consumed by `hydroserver-qc-app` via `npm link`. For active local development:
8
-
9
- **1. From `qc-utils/`** (this repo):
15
+ ## Install
10
16
 
11
17
  ```sh
12
- npm install
13
- npm link # registers @uwrl/qc-utils globally
14
- npm run dev # starts vite build --watch — rebuilds dist/ on every source change
18
+ npm install @uwrl/qc-utils
15
19
  ```
16
20
 
17
- Leave that terminal running.
21
+ ## Quick start
18
22
 
19
- **2. From `hydroserver-qc-app/`** (the consumer, in a second terminal):
23
+ ```ts
24
+ import {
25
+ ObservationRecord,
26
+ EnumFilterOperations,
27
+ EnumEditOperations,
28
+ Operator,
29
+ } from '@uwrl/qc-utils'
20
30
 
21
- ```sh
22
- npm install
23
- npm run link-qc-utils # symlinks node_modules/@uwrl/qc-utils → this dist/
24
- npm run dev # starts the Vite dev server
31
+ // Build a record from parallel datetime + value arrays.
32
+ const record = new ObservationRecord({
33
+ datetimes: [1704067200000, 1704067260000, 1704067320000, 1704067380000],
34
+ dataValues: [10.0, 11.5, 999.9, 12.3],
35
+ })
36
+ await record.reload()
37
+
38
+ // Find the spike, replace it with the previous value.
39
+ await record.dispatch([
40
+ [EnumFilterOperations.VALUE_THRESHOLD, { 'Greater than': 100 }],
41
+ [EnumEditOperations.CHANGE_VALUES, Operator.ASSIGN, 11.5],
42
+ ])
43
+
44
+ record.dataY[2] // 11.5
45
+ record.history.length // 2
46
+ await record.undo() // replays without CHANGE_VALUES
47
+ record.dataY[2] // 999.9
48
+ ```
49
+
50
+ The dispatch chain is the canonical pattern: a filter (or explicit
51
+ `SELECTION`) seeds an index list, the next selection-consuming edit
52
+ reads it off `history[length - 2].selected`. See
53
+ [`docs/HISTORY_SCRIPT.md`](./docs/HISTORY_SCRIPT.md) for the full
54
+ operation-by-operation contract and the JSON wire format.
55
+
56
+ ## Concepts
57
+
58
+ ### `ObservationRecord`
59
+
60
+ The single state container. Holds:
61
+
62
+ - `dataX` / `dataY` — typed-array views into a (possibly shared) buffer.
63
+ - `history` — every committed `HistoryItem` since the last `reload()`.
64
+ - `redoStack` — items popped by `undo()`, ready for `redo()`.
65
+
66
+ Mutations only happen through `dispatch` / `dispatchAction` /
67
+ `dispatchFilter` / `undo` / `redo` / `reload` / `reloadHistory` /
68
+ `removeHistoryItem`. The handlers themselves are private — operations
69
+ are driven by enum + args so the same call shape works at runtime, on
70
+ replay from a saved script, and in unit tests.
71
+
72
+ ### Edit operations (`EnumEditOperations`)
73
+
74
+ | Op | Purpose |
75
+ |-------------------------|----------------------------------------------------------|
76
+ | `ADD_POINTS` | Insert (datetime, value) tuples; reindex + sort by date. |
77
+ | `CHANGE_VALUES` | Apply `Operator` (ADD / SUB / MULT / DIV / ASSIGN) at the prior selection's indices. |
78
+ | `ASSIGN_VALUES_BULK` | Write parallel `values[i] → dataY[selection[i]]`. Table-driven edits. |
79
+ | `ASSIGN_DATETIMES_BULK` | Write parallel datetimes; runs as one combined delete + add. |
80
+ | `DELETE_POINTS` | Drop the selection from x / y in a single skip-on-delete pass. |
81
+ | `INTERPOLATE` | Linear interpolation across each consecutive group in the selection. |
82
+ | `SHIFT_DATETIMES` | Offset the selection's timestamps by `(amount, TimeUnit)`. |
83
+ | `DRIFT_CORRECTION` | Apply linear drift `value` to every consecutive group in the selection. |
84
+ | `FILL_GAPS` | Detect gaps over `gapThreshold`; insert points at `fillCadence` (interpolated or constant `fillValue`). |
85
+
86
+ ### Filter operations (`EnumFilterOperations`)
87
+
88
+ All scan-style filters accept an optional trailing `[startTs, endTs]`
89
+ window in epoch ms; `DATETIME_RANGE`'s args ARE the window.
90
+
91
+ | Op | Args |
92
+ |-------------------|------------------------------------------------------------|
93
+ | `VALUE_THRESHOLD` | `[{ 'Greater than': n, 'Less than': n, ... }, range?]` |
94
+ | `DATETIME_RANGE` | `[fromTs?, toTs?]` |
95
+ | `CHANGE` | `[comparator, value, range?]` — Δ between adjacent points |
96
+ | `RATE_OF_CHANGE` | `[comparator, value, range?]` — value is a fraction (0.5 = 50%) |
97
+ | `FIND_GAPS` | `[amount, unit, range?]` |
98
+ | `PERSISTENCE` | `[times, range?]` — runs of identical repeated values |
99
+ | `SELECTION` | `[indices[]]` — explicit user selection |
100
+
101
+ ### Worker dispatch + calibration
102
+
103
+ Every long-running kernel ships in two flavours: an inline core
104
+ (`changeValuesCore`, `fillGapsCore`, …) and a worker pool that scans
105
+ shared `Float64Array` / `Float32Array` views in parallel.
106
+ [`shouldUseWorker`](./src/utils/plotting/calibration.ts) picks per
107
+ call:
108
+
109
+ ```ts
110
+ import { ensureCalibration, shouldUseWorker, EnumEditOperations } from '@uwrl/qc-utils'
111
+
112
+ await ensureCalibration() // benchmark once per device, cached in localStorage
113
+
114
+ shouldUseWorker(EnumEditOperations.FILL_GAPS, {
115
+ datasetSize: record.dataX.length,
116
+ selectionSize: 0,
117
+ })
118
+ // → { useWorker: false, predictedInlineMs: 12.4, predictedWorkerMs: 53.0,
119
+ // reason: 'inline faster (12.4 vs 53.0 ms)' }
120
+ ```
121
+
122
+ Workers require `SharedArrayBuffer`, which means the host page must
123
+ serve `Cross-Origin-Opener-Policy: same-origin` +
124
+ `Cross-Origin-Embedder-Policy: require-corp`. When SAB is unavailable
125
+ the dispatch transparently falls back to inline kernels. See
126
+ [`docs/CALIBRATION.md`](./docs/CALIBRATION.md) for the benchmark
127
+ methodology and the per-op cost table.
128
+
129
+ ### QC scripts (save / load)
130
+
131
+ Every `ObservationRecord` history is round-trippable as JSON. The
132
+ on-disk shape IS the wire format used by the HydroServer API:
133
+
134
+ ```ts
135
+ import { serializeHistory, parseScript, applyScript } from '@uwrl/qc-utils'
136
+
137
+ const script = serializeHistory(record, {
138
+ startDate: '2024-01-01T00:00:00.000Z',
139
+ endDate: '2024-06-30T23:59:59.999Z',
140
+ })
141
+ // → { version: '1', createdAt, window, operations: [{ method, args }, ...] }
142
+
143
+ // On a fresh ObservationRecord with the same window's data loaded:
144
+ const fresh = new ObservationRecord(rawObservations)
145
+ await fresh.reload()
146
+ const report = await applyScript(fresh, parseScript(script))
147
+ report.applied // 12
148
+ report.failed // [{ index, method, error }] — per-op failures don't abort replay
149
+ ```
150
+
151
+ Scripts are reusable across datastreams: they don't pin a datastream id,
152
+ they store the wall-clock window and the `[method, ...args]` tuples.
153
+ See [`docs/HISTORY_SCRIPT.md`](./docs/HISTORY_SCRIPT.md) for versioning,
154
+ loader workflow, and per-op arg shape.
155
+
156
+ ## Public API surface
157
+
158
+ ```ts
159
+ // State container
160
+ import { ObservationRecord, INCREASE_AMOUNT } from '@uwrl/qc-utils'
161
+
162
+ // Operation enums
163
+ import {
164
+ EnumEditOperations,
165
+ EnumFilterOperations,
166
+ Operator,
167
+ FilterOperation,
168
+ TimeUnit,
169
+ timeUnitMultipliers,
170
+ } from '@uwrl/qc-utils'
171
+
172
+ // QC scripts
173
+ import {
174
+ serializeHistory,
175
+ parseScript,
176
+ applyScript,
177
+ QcScript,
178
+ QcScriptOperation,
179
+ QcScriptWindow,
180
+ QC_SCRIPT_VERSION,
181
+ ApplyScriptReport,
182
+ } from '@uwrl/qc-utils'
183
+
184
+ // Calibration
185
+ import {
186
+ shouldUseWorker,
187
+ ensureCalibration,
188
+ runBenchmarks,
189
+ getCalibration,
190
+ onCalibrationChange,
191
+ clearCalibration,
192
+ DeviceProfile,
193
+ DispatchSignals,
194
+ DispatchDecision,
195
+ } from '@uwrl/qc-utils'
196
+
197
+ // Helpers
198
+ import {
199
+ findFirstGreaterOrEqual,
200
+ findLastLessOrEqual,
201
+ formatDate,
202
+ formatDuration,
203
+ measureEllapsedTime,
204
+ } from '@uwrl/qc-utils'
205
+ ```
206
+
207
+ A `Snackbar` notification helper is also exported for browser
208
+ consumers. It's the package's only DOM-touching symbol — the QC
209
+ engine itself is headless.
210
+
211
+ ```ts
212
+ import { Snackbar } from '@uwrl/qc-utils'
213
+ Snackbar.success('Saved')
25
214
  ```
26
215
 
27
- Edit any file in `qc-utils/src/`. The watcher rebuilds `dist/` within ~1s. Refresh the app browser to pick up the change.
216
+ For HydroServer REST calls, use `@hydroserver/client` directly. An
217
+ earlier `services/` REST client lived in this package and was
218
+ removed in `0.1.0` when the qc-app finished its migration to the
219
+ dedicated client.
28
220
 
29
- ### Caveats
221
+ ## Browser requirements
30
222
 
31
- - `vite build --watch` rebuilds the JS bundle but **does not emit `.d.ts` files** (those come from the full `npm run build`). If the app surfaces stale type errors during dev, run `npm run build` once in `qc-utils` and they'll refresh.
32
- - Two terminals required (one per repo). If the friction proves real, a future phase can wire `concurrently` to run both watchers from a single command.
33
- - HMR is **not** propagated through the linked dependency Vite library mode doesn't expose HMR boundaries to the consumer. Browser refresh is the loop.
223
+ - ES2022 / native `import`. Built as ESM with a CJS shim
224
+ (`dist/index.js` + `dist/index.cjs`).
225
+ - `SharedArrayBuffer` for the worker fast path (graceful inline fallback
226
+ when unavailable; see Calibration above).
227
+ - `Float64Array` / `Float32Array` typed-array `resize()` /
228
+ `SharedArrayBuffer.grow()` — Chrome 111+, Firefox 119+, Safari 16.4+.
34
229
 
35
- ## Scripts
230
+ ## Contributing
36
231
 
37
- | Script | Purpose |
38
- |--------|---------|
39
- | `npm run dev` | Start the watch-mode bundler for linked-dev workflow (alias of `watch`). |
40
- | `npm run watch` | Same as `dev` (kept for backward compatibility). |
41
- | `npm run build` | Production build bundle + emit `.d.ts` declarations. |
42
- | `npm run test` | Run the Vitest suite once. |
43
- | `npm run coverage` | Run the suite with v8 coverage and the 80% threshold. |
44
- | `npm run lint` | Run ESLint over `src/`. |
45
- | `npm run lint:fix` | Auto-fix lintable errors. |
46
- | `npm run preview` | Preview the production build with Vite. |
47
- | `npm link` | Register this package for `npm link @uwrl/qc-utils` in consumers. |
48
- | `npm publish` (`pub`) | Publish to npm under `--access public`. |
232
+ Clone, `npm install`, `npm run dev` (alias of `watch`) rebuilds
233
+ `dist/` on every source change so an `npm link`-ed consumer picks up
234
+ edits in ~1 s. The watch build skips `.d.ts` emit; run `npm run build`
235
+ once if the consumer surfaces stale type errors. CI runs
236
+ `tsc --noEmit coverage lint → build` on every push and PR to main.
49
237
 
50
- ## CI
238
+ | Script | Purpose |
239
+ |---------------------|--------------------------------------------------------|
240
+ | `npm run dev` | Watch-mode bundler for linked-dev workflow. |
241
+ | `npm run build` | Production build — bundle + emit `.d.ts` declarations. |
242
+ | `npm run test` | Vitest suite. |
243
+ | `npm run coverage` | Vitest with v8 coverage and the 80 % threshold. |
244
+ | `npm run lint` | ESLint over `src/`. |
245
+
246
+ Linking into a sibling `hydroserver-qc-app` checkout:
247
+
248
+ ```sh
249
+ # qc-utils (terminal 1)
250
+ npm link
251
+ npm run dev
252
+
253
+ # hydroserver-qc-app (terminal 2)
254
+ npm run link-qc-utils
255
+ npm run dev
256
+ ```
51
257
 
52
- GitHub Actions runs `tsc --noEmit coverage lint → build` on every push and PR-to-main. See `.github/workflows/ci.yml`.
258
+ HMR doesn't propagate through linked packages refresh the consumer
259
+ browser to pick up changes.
53
260
 
54
261
  ## License
55
262
 
56
- ISC
263
+ [BSD 3-Clause](./LICENSE).
package/dist/index.d.ts CHANGED
@@ -1,4 +1,3 @@
1
1
  export * from './types';
2
- export * from './services';
3
2
  export * from './models';
4
3
  export * from './utils';