Reference

Sensor Data Pipeline

How one reading travels from a sensor in a river to a number on a customer dashboard, which system owns each fact, and the invariants that must hold at every join. Written for whoever is next to touch it, human or model.

1The chain end to end

Device
Lume v1.2
Samples every 15 min, syncs hourly. Each reading carries a full LED × SiPM-bias sweep.
Transport
Blues Notehub
Routes payloads on. Also where firmware is pushed, via the _fw environment variable.
System of record
Pumphaus (Postgres)
Raw samples, and the truth about which sensor was at which site when.
Inventory
D1 lume-inventory-db
Ownership, status, baselines, contracts and dashboard PINs.
Serving
Worker + model
/api/site-series applies shared/ecoli-model.js and returns per-site series.
Presentation
Pages dashboards
PIN-gated customer pages on thelume.ai.

2Pumphaus is the system of record

Site and time attribution live here, not in inventory. The join that attributes a raw sample to a site is a time-bounded one, which does in-water clipping and multi-sensor stitching at once:

api_organization   (= the customer; org_name must EXACTLY equal contracts.customer)
      |
api_meter          (organization_id)
      |
api_watersystem    (the site: site_name, latitude, longitude, timezone)
      |
api_installation   (gateway_id, water_system_id, installed_time, removed_time)
      |                                          removed_time NULL = still in water
api_gateway        (barcode)
      |
api_modem  <--  api_payload  -->  api_samplesraw / api_tlfdiagnostics
Install windows are a hard rule

A reading is only valid for a site if its timestamp falls inside that site's [installed_time, removed_time) window for the sensor that was installed there at that time. Sensors move between sites and sites swap sensors. Pairing a lab result to a sensor outside its window silently corrupts the calibration set — it does not error.

Joining raw data (two traps)

Reading the TLF signal correctly

3Inventory, mirror, and read model

Pumphaus has no query filtering on its REST API, so the Worker keeps a bounded mirror in D1 and projects a flat read model from it. Three tables, three jobs:

TableOwnsWritten by
sensorsBarcode, customer, status, notes, clean-water baseline window, install_tof_baselineInventory UI; the fleet page's Notes column writes the same field
sensor_installationsMirror of Pumphaus api_installation, keyed by installation_idWorker write-through on installation edits
site_installationsFlat read model the dashboards queryrebuildSiteInstallations(db, customer), regenerated wholesale from the mirror
sensor_baselinesOne row per clean-water capture: tlf_mon2, tof_sps, temperatureLab capture, logged manually
contractscustomer and dashboard_pinContracts UI
The single most common orphan

rebuildSiteInstallations reads only from sensor_installations. Writing straight to Pumphaus without updating the mirror leaves the read model blind: the site exists, the sensor reports, and the dashboard is empty. Any Pumphaus write must be followed by a mirror update and a rebuild.

4Baselines

Every sensor has a lab-collected clean-water baseline giving its own TLF and ToF offsets. These are canonical — do not synthesise one when a lab capture exists.

5The model, and how a sensor reaches it

Single source of truth: shared/ecoli-model.js. Three consumers — a Pages Function that emits a browser IIFE at /js/ecoli-model.js, the Worker (bundled directly), and the dashboards. Never copy coefficients into a page.

predictEcoliCfu picks a path in this order, and which path a sensor lands on is decided by whether it has a baseline pair:

PathChosen whenTemperature term
ChicagoCHICAGO_MODEL.fe[bc] existson T − 20
Field (mixed effects)FIELD_MODEL.base20[bc] and btof[bc] exist−0.0205 × (T − 20)
Genericeverything else−0.157 × T  (absolute)
The generic path has a temperature ceiling

Its temperature term is applied to absolute T and is large: at 26.5 °C it contributes −4.16 against an intercept of +3.54. A sensor sitting exactly at its own clean-water baseline therefore goes negative above 22.6 °C and clamps to the 0.1 CFU floor regardless of what TLF is doing — and warm water quenches TLF, which is precisely when mon2 − baseline is least able to compensate. On 2026-08-06 this had seven installed sensors reporting “0 CFU, safe”, including a paying customer.

The fix was not new model structure. FIELD_MODEL already has a designed fallback for units with no grabs — fe_default plus the population slopes. What those sensors lacked was only the per-sensor baseline pair, which is what routes a sensor onto that path at all. Supplying base20/btof from each unit's own lab capture moved them onto the field path. Entries added this way are listed in FIELD_MODEL.synthetic_base, and isLocallyCalibrated() deliberately reports them as not grab-calibrated: routed onto the field path is not the same as validated against local grabs.

6Validation data — the reference pipeline

Sensor readings are one half of the system; the other is ground truth. Every lab result we calibrate against lands in one D1 table, validation_grabs, and is served read-only. There is no login and no key.

Where grabs come from

SourceCoversTimezone handling
mwaterBoulder and Denver Colilert grabs, from the mWater datagridStored in the data-entry device's Mountain clock; the sample's own timezone field is the correction key
chicagoH2NOW Chicago programme grabs, from the programme Google SheetCDT (UTC−5) → add 5 h for UTC
emailResults delivered directly by a partnerAs recorded

An hourly cron refreshes the table from its sources with idempotent upserts. Manual exclusions survive the refresh.

Option A — read it in a browser

Open validation.thelume.ai/colilert/ and scroll to Field Calibration Data. The table is served live from the database, with Source and Method columns and both local and UTC times.

Option B — pull the raw data (JSON, no auth)

https://lume-inventory-api.evan-thomas-3d8.workers.dev/api/validation/grabs
# All grabs (Boulder + Denver + Chicago), including swept/deduped rows
curl "https://lume-inventory-api.evan-thomas-3d8.workers.dev/api/validation/grabs?status=all"

# Chicago only
curl "https://lume-inventory-api.evan-thomas-3d8.workers.dev/api/validation/grabs?source=chicago&status=all"

# Chicago since a date
curl "https://lume-inventory-api.evan-thomas-3d8.workers.dev/api/validation/grabs?source=chicago&since=2026-06-01"

The response is { "grabs": [ ... ], "count": N }.

FilterValues
sourcemwater / chicago / email
methodcolilert / mf
customere.g. Chicago
barcodesensor barcode
sinceISO date, e.g. 2026-06-01
statusactive (default) or all
present1 (default, live rows) or all to include deduped

Key fields per row: sample_at (UTC), sample_local_raw + sample_tz, site_name, barcode, ecoli_cfu, method, source, customer, present.

For Chicago, site_name is the H2NOW site — North Branch, Wild Mile, Main Stem, South Branch, Cal-Sag Channel, Little Calumet — and barcode is populated only where a sensor was validly installed at that site at that time. As of 2026-08-06 that is 26 of 47 Chicago rows; the other 21 predate an install or fall outside a window, and are deliberately left unpaired rather than guessed.

A grab is not usable until it is paired — and pairing has rules

Match a grab to sensor data within ±20 minutes, taking exactly one reading: LED 512, sipm_bias nearest 3000. The 2960–3040 band is an acceptance guard, not a set to average over.

The pairing is only valid if the grab time falls inside that site's install window for the sensor that was actually there (section 2). A grab with no in-window reading stays unmatched. Leaving it unmatched is correct; reaching for the nearest sensor instead is how a calibration set quietly acquires wrong rows.

Normalise every timestamp to UTC before the ±20 min test and the window test.

What the grab set currently supports

Of 212 Colilert grabs carrying a measured result, 174 pair to grab-calibrated field sensors and 26 to the Chicago model — leaving only 12 on the generic path, from three barcodes. That is why the generic path has almost no recreational validation, and why the ceiling described in section 5 went unnoticed. Detection limits differ by method and must not be mixed: Colilert/Quanti-Tray tops out at 2,419.6 MPN/100 mL (a censored “>” value, not a measurement), while Aquagenx CBT tops out at 100 CFU/100 mL and needs censored handling.

7Invariants — and the audit that checks them

These are asserted by scripts/audit_pipeline.js in the virridy-lume-summary repo. It is read-only and touches nothing.

node scripts/audit_pipeline.js           # human readable
node scripts/audit_pipeline.js --json    # machine readable
node scripts/audit_pipeline.js --strict  # exit 1 on any ERROR (use in CI)
pumphaus.duplicate-gateway
A replaced unit leaves a second gateway row for the same barcode. The install API can bind the dead one, producing a dashboard that is silently empty.
pumphaus.multiple-open-installs
Two open installations for one barcode make the install-window rule ambiguous.
pumphaus.broken-site-chain
An installation that does not resolve up to an organization cannot appear on any dashboard.
link.org-name-not-a-contract
org_name must equal contracts.customer exactly. Anything else resolves the PIN to zero sites.
inventory.deployed-without-installation
Deployed in inventory, no open installation: appears nowhere, serves nothing.
inventory.installed-but-not-deployed
Installed in Pumphaus but not Deployed: the proxy scopes a PIN to Deployed sensors and 403s the barcode.
mirror.open-install-not-mirrored
An open installation missing from the mirror is invisible to rebuildSiteInstallations.
mirror.drifted-from-pumphaus
A mirror row disagreeing on install time or site name pairs data against the wrong window — the quiet corruption case.
readmodel.count-drift
site_installations out of step with the mirror: a rebuild was skipped.
dashboard.pin-resolves-to-no-sites
A PIN with no sites behind it. This is exactly the Paris 404.
model.baseline-pair-incomplete
base20 without btof (or the reverse) yields NaN inside predictEcoliCfu.
model.installed-on-generic-path
An installed recreational sensor with no field baseline, which will floor above 22.6 °C.

8Runbooks

Bringing a new site live

  1. Pumphaus: api_modem + api_gateway if the device is new.
  2. Pumphaus: api_organization (name must match contracts.customer exactly) → api_meterapi_watersystemapi_installation. Dry-run inside BEGIN/ROLLBACK first.
  3. Set installed_time to when the sensor entered the water, not when it was powered on. See the recovery method below.
  4. D1: insert the sensor_installations mirror row, then rebuild site_installations.
  5. D1: set sensors.status = 'Deployed' and the correct customer.
  6. Ensure a baseline exists, and that the sensor has a base20/btof pair.
  7. Run the audit. Then confirm /api/site-series returns data for the PIN.
Recovering an unknown install time from the data

Units are typically powered on indoors and installed later — sometimes days later. Entering water drops both the TLF and ToF channels sharply in a single 15-minute sample, because water absorbs the 940 nm ToF IR and kills the dry-chamber backscatter that inflates mon2.

The reliable discriminator is the daily range of enclosure temperature: roughly 0.7–1.2 °C indoors (climate controlled), 2–6 °C once in a river, with a one-day spike on the transport day. Do not use chamber_status.dark_dry — it fired true once in 341,575 readings and stayed false through a confirmed air exposure. It is inert.

Moving a sensor between sites

  1. Close the old installation (removed_time), open a new one. Never edit a site name in place to mean a move — that rewrites history and silently re-attributes every past reading.
  2. Update the mirror, rebuild the read model, re-run the audit.
  3. Re-check any calibration set that paired grabs to that sensor.

Changing the model

  1. Edit shared/ecoli-model.js only, and bump MODEL_VERSION.
  2. Confirm previously-calibrated sensors are bit-identical on fixed inputs before deploying.
  3. Deploy Pages and the Worker — they carry independent copies of the module.
  4. Verify the served /js/ecoli-model.js reports the new MODEL_VERSION.