← All changelogs

WP6 Digital Twins — What shipped this update

Changelog · update of 14 July 2026 · covering 8 Jun – 14 Jul · branch main · 91 commits · 4 merged PRs · 3 contributors
Blue — blueberry farm · wp6-blue.spohf.fontysvenlo.dev Red — tomato greenhouse · wp6-red.spohf.fontysvenlo.dev Grey — public platform demo · wp6-grey.spohf.fontysvenlo.dev Shared — twin-agnostic platform

Headlines

🔗 The Open live buttons (↗) deep-link into the deployed dashboards. Blue and Red require a login on first open; Grey is a public demo (no login).

🫐 Blue — Forecasting & growth

Blue moves from "what happened" to "what's coming" — a look-ahead on soil conditions and a growth tracker that can't go stale.

Soil-condition forecast (next 7 days)

Blue

A new forecast page predicts soil moisture and soil temperature for day +1 through +7, one curve per treatment group. It learns from the last 30 days of readings (using lag features) and plots a shaded uncertainty band so you can see how confident each prediction is.

  • Models retrain automatically on every deploy — no manual training step, and nothing to keep in sync on disk.
  • An admin "Update" button forces a retrain on demand; concurrent clicks are safely serialised so two updates never clobber the same model files.
Open live /sensor-monitor/soil/forecast
Commits
d53e030 basic forecasting
52ca4cf soil-model bootstrap + ephemeral model dir; promote GDD route
d1c6975 soil-forecast: escape reflected HTML, offload+lock training, fix MAE band
d72d394 fixes forecast

GDD growth tracker: full-year coverage from modeled weather

Blue

Growing Degree Days (a running measure of accumulated heat that drives crop development) used to read the farm's own weather-station sensor, which lacked complete coverage for 2024 — leaving gaps in the analysis. It now sources daily min/max temperature from OpenMeteo modeled weather at the farm's exact location (Grubbenvorst), stitching together the historical archive, the recent past, and a 14-day forecast into an unbroken series from 1 Jan 2024 onward. That gives Blue a full calendar year of GDD to analyse for the first time.

  • Growth constants set to recognised values — a 5 °C base temperature (European convention, biofix 1 Jan) and a 7.2 °C chill-hour threshold.
  • GDD left the data-source toggle entirely and now has its own card on the Blue home page.
Open live /gdd
Commits
16882a2 GDD reads OpenMeteo modeled weather, decoupled from sensors
77ebf53 update GDD thresholds to Jans research
955b971 give GDD its own home-page card with growth accent

🫐 Blue — Manual data, in context

Hand-recorded farm data (irrigation events, lab measurements) is no longer siloed — it's overlaid directly on the sensor charts where it explains what the sensors show.

Fertigation events overlaid on soil charts

Blue

Irrigation/fertigation events (uploaded as CSV or via the admin UI) now appear as vertical markers on the soil monitor, so a moisture spike lines up visually with the watering that caused it. The overlay reads from the same ingested data store as every other manual dataset — one source of truth, not a separate CSV re-read.

Open live /sensor-monitor/soil
Commits
2e98fdf local fertigation overlay
fe3ea8e fertigation upload option
7aba4b9 fertigation no longer shared (blue-scoped)

Manual-measurement monitor & mixed views

Blue

A dedicated hub for the hand-recorded lab measurements, with correlation and comparison views — plus mixed views that put sensor data and manual data (or manual measurements and fertigation) on the same chart for direct comparison.

Manual monitor Mixed views /manual-monitor · /mixed-views/manual-fert
Commits
cbf7650 manual measurement monitor
788f100 mixed graphs

Broken / unreliable sensors tab

Blue

A view that surfaces sensors whose data is unreliable rather than simply missing — separating soil and weather feeds — so data-quality issues are visible instead of silently skewing charts.

Open live /broken-sensors
Commits
d3af0e0 broken sensors tab
58be207 unreliable, not broken

🍅 Red — Getting ready for new greenhouse wire sensors

Red's "multi-height wire" is a single device that measures light, temperature, humidity and CO₂ at several heights up the canopy. Ahead of installing three new/updated wires in the greenhouse, the platform was prepared so that onboarding them is a configuration change — not a code change.

Config-driven wire support, with a safety net for undeclared wires

Red

Which wires exist is declared in metadata.yaml and never inferred from the incoming data. Wire enumeration was centralised into one module that every view, the risk engine and the export job read from — so adding the three incoming wires means new config entries, with no code to touch. This groundwork is landing now, before the hardware goes in.

  • Undeclared-wire safety net: if a wire starts reporting into the greenhouse database without being declared, startup now logs a warning instead of silently dropping its readings — so a miswired or forgotten sensor can't go unnoticed.
  • Per-height CSV export: each wire height can be exported as its own CSV for offline analysis, with the wire enumeration shared by the export job.
Open live /multi_height · /multi_height/wire-trends
Commits
dcf5efd declare third multi-height wire, warn on undeclared wires
f4ee877 export wire devices to CSV; centralise wire enumeration

📊 Charts — pick a chart type per axis

A shared upgrade to the unified chart page that every twin inherits. (Grey is the public demo — click straight in, no login.)

Line / Scatter / Boxplot, selectable per Y-axis

SharedGrey demo

Each Y-axis on the unified chart now carries its own chart-type selector — Line, Scatter, or Boxplot — mirroring how the aggregation controls already work per axis. Boxplots are built client-side from raw points, so the bucket slider becomes a "box width" control. The choice round-trips through the URL (ct / ct_r), so a shared or bookmarked chart keeps its type; existing links are untouched and still render exactly as before.

Open live (public) /chart · ct / ct_r URL params
Commits
7203710 per-Y-axis chart types (line/scatter/boxplot)
ce04d78 persist per-axis chart type when saving a chart

🫐 Blue — Honest data-source coverage

Blue draws automated data from two feeds (the SPoHF datalake and the direct "yookr" interface). This work makes the dashboards tell the truth about which feed actually has data.

Coverage grid honors the active source

Blue

The /status coverage grid used to show a sensor as "covered up to today" even when those recent days were written by the other feed — masking the datalake's staleness. It now matches coverage at the day level per source, so under the Datalake view a sensor's days reflect only datalake-owned days. The two feeds can also coexist safely now via project-aware de-duplication.

  • A subtle follow-up: GDD/status no longer refuse data just because the datalake relay omits an ingestion timestamp.
Open live /status
Commits
641ab81 /status coverage honors source toggle (day-granularity match)
759d838 project-aware dedup so datalake & yookr-direct coexist
b9272c9 stop requiring the datalake relay's ingestion timestamp

🫐 Blue — Now fully operational on the SPoHF Datalake

The SPoHF Datalake is the intended integration for Blue. Until now the twin kept a direct Yookr API feed as a fallback, because the datalake relay wasn't yet complete enough to rely on. It now is — so the fallback, its sync job, and the whole dual-source machinery have been retired. Less code, less storage, less compute.

Blue runs entirely on the datalake — Yookr fallback removed

BlueShared

The SPoHF Datalake is now Blue's single, canonical source of automated readings. The direct Yookr API — kept as a fallback while the relay caught up — is no longer needed and has been removed, along with the source toggle and the readings.project column that distinguished the two feeds.

  • Code — 607 fewer lines of production code (289 added / 896 removed, measured across the retirement commits). Seven files deleted outright (619 lines of dedicated yookr code): the entire src/wp6_data/yookr/ client package, the 299-line sync orchestrator, blue's datasource.py + yookr.py, and the full-sync script. The remainder is net simplification of deps.py (every project filter gone), provider.py, ops.py and __main__.py — partly offset by the truncation fix below.
  • Storage — 4,104,138 rows purged, exactly 43.5% of the readings table (9,432,610 → 5,328,472). The project column is dropped from every remaining row and the dedup index loses a column. daily_coverage shed 4,323 stale device/sensor/day entries (44,930 → 40,607) that no longer had readings behind them. The hypertable now stands at 2.77 GB, already vacuumed.
  • Compute — one of two API syncs eliminated on every run. The sync job runs every 15 min (96×/day) and no longer authenticates against and paginates the direct Yookr API. A post-migration run ingested 27,078 records in 22.5 s. Two secrets (yookr-email, yookr-password) and one external dependency (api.yookr.org) are gone from the platform entirely.
  • Simplification — the toggle machinery is gone. Two parallel data-source mechanisms collapsed to zero, two BlueSensorProvider instances to one, and a cookie-dispatched provider to a static badge. Blue now matches Red's single-categorical readings.source model, closing issues 024, 026 and 029 and rendering 027 moot.
Commits
91cdda5 feat(blue)!: retire yookr-direct, drop readings.project
68bc100 chore(sync): disable sync CronJob for the drop-project migration window
50277cb docs: record the completed yookr-direct retirement; re-enable sync

277,588 sensor readings recovered — we were truncating our own ingest

BlueShared

Auditing the two feeds before the purge turned up a 337,081-reading gap. Most of it was not the relay's fault — it was ours. The SPoHF datalake is Elasticsearch-backed and refuses to page past 10,000 records in one query. Our client paged by offset until it got a short page, so any day holding more than 10,000 readings simply stopped early, with no error. We read "the server won't give me more" as "there is no more".

  • Scale of the loss — 83% of days were affected. Two test-rig sensors (931k rows) ran from 2024-11-15 to 2025-12-31 and pushed daily volume over the cap for exactly that window. Re-syncing it with the fixed client split 330 of 396 days. Yookr-only readings fell from 337,081 to 59,493.
  • The fix — bisect, don't truncate. fetch_window now detects that the next page would cross the cap and splits the time range in half, recursing until each half fits. It is self-correcting at any future data volume, rather than needing an operator to notice and re-tune a window size.
  • Why nobody saw it. The coverage grid on /status matches at (device, sensor, day) granularity — a day counts as covered if it has at least one reading. Losing the tail of every day is invisible there. Only a row-level comparison against the second feed exposed it, which is the one thing retiring that feed would have made impossible. It was found with a week to spare.
  • What could not be saved. December 2025 remains incomplete: the relay's stored history for that month still returns one sensor per device, so 58,826 readings across 36 series exist only in the retired feed. A backfill has been requested from SPoHF; the purged rows are archived.
Commits
401ca75 fix(sync): bisect windows past the relay's result cap instead of truncating
0d4270f fix(sync): scope WP6_SYNC_END to full syncs only
4408949 docs(blue): refresh yookr-vs-datalake state — relay fixed, gap re-scoped

Finishing the job — 255 lines of dead scaffolding removed

BlueRedShared

With one automated source per twin, a whole layer of the platform existed only to support switching between sources that no longer exist. None of it was broken — it was vestigial, the kind of code that misleads the next reader into thinking a feature is still there. A follow-up sweep removed it: 255 fewer lines of production code (126 added / 381 removed) with no loss of function.

  • The source toggle is gone for good. The cookie-based switcher, its client-side JS, the per-source provider dispatch, and a data-source label threaded through ~40 render sites were all removed. Each dashboard keeps a simple static badge naming its source; nothing a user sees changed.
  • One coverage store instead of two. A `daily_coverage` table duplicated exactly what the daily-summary aggregate already held (verified identical on prod, 40,607 rows either way), and — being append-only — it could keep claiming days whose data had been deleted. Coverage now reads from the aggregate alone, which stays honest as data changes. That retired a table, two write paths, a rebuild job, and an admin button.
  • A dormant optimisation now works. The incremental sync's "stop early when a window is all duplicates" had never once fired, because of a Postgres quirk in how inserts-vs-updates were counted. Fixed — a steady sync now recognises 8,679-of-8,679 rows as already-present and can stop early.
  • Both twins converge. Blue and Red now share one readings shape keyed on a single `source` column, so the platform's shared code no longer bends around a difference that used to exist only in Blue.
Commits
a8474ba refactor: collapse single-source toggle machinery + fix dupe-count
dd9cd73 refactor(db): collapse daily_coverage into the cagg; rename categorical to source

⚙️ Platform & architecture (under the hood)

Less visible, but it keeps the twins fast, the links honest, and the codebase maintainable across three twins.

Templates split into a package; large assets served as static files

Shared
  • The monolithic templates.py became a proper package, and heavy JS/CSS assets are now real files served at static URLs instead of being inlined into every page — smaller responses, browser-cacheable.

Provider conformance suite + Red smoke tests

Shared
  • A shared test suite that every twin's data provider must satisfy, plus Red app smoke tests and one MySQL seam — so a new twin or backend can't silently diverge from the platform contract.

Config-driven docs & source links in the footer

Shared
  • Each twin's footer now links to its docs and source from configuration, rather than hardcoded per-page URLs.

Red & Blue internals tidied

RedBlue
  • Red's Multi Height route became its own domain package; stat grids and data tables now go through shared render helpers (less duplication across twins).
  • Blue's treatment vocabulary was consolidated into a single treatments.py module.
  • Trained models live on ephemeral storage and rebuild on boot — kept off the read-only export volume.
  • C4 architecture diagrams refreshed to match the current implementation.
  • Added a data-model diagram (ERD) showing how the sensor-readings, manual-upload, coverage and risk tables — plus Red's live greenhouse feed — fit together across the two databases.