← All changelogs

WP6 Digital Twins — What shipped this update

Changelog · update of 12 October 2026 · covering 3 Sep – 8 Oct · branch main · 34 commits · 0 merged PRs · 1 contributor
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). Model pages are admin-only.

🔴 Red's light model: measured, honest, and better

It began as one odd report: on long summer days the lamps are off, yet the forecast page showed three different numbers where two should match. Following that up turned up a series of quiet errors in how light was measured and scored.

Lamp light is measured, not inferred

Red

Red has two light sensors, one at the roof above the lamps and one at plant level below them. Until now the lamps' share of the light was worked out as "whatever the model got wrong". A model error is not a lamp, and this method could never report "lamps off": on a summer day with a perfect daily prediction it still made up a 5.5 mol lamp contribution.

The new rule is simple and physical. If the plants get more light than the roof sensor above them, a lamp must be on, because glass and screens can only take light away. Judged over a rolling two-week window per hour of the day, it finds:

  • zero lamp hours in May, July and August, which is correct;
  • lamps running 23:00 – 15:00 through the winter daylight. The old method only looked at dark hours, found 8 of those 17 hours and underestimated winter lamp light by 53%;
  • a lamp schedule that crosses midnight, which the old code couldn't represent at all;
  • a light-loss factor through the roof and screens of 0.625, agreeing with two independent earlier estimates. Previously they only agreed in summer.

The forecast was also being given different weather inputs than it was trained on: total sunlight in place of direct sunlight, and a fixed 50% cloud cover. It now gets the real inputs, and refuses to predict if one is missing, rather than quietly filling in a value that looks plausible.

Open DLI forecast /dli/forecast
Commits
af710f8 fix(red): the light model — lamps measured, not inferred from model error
94c64e3 feat(shared): resample readings to any interval, and encode hour of day

New: the lamp calendar

Red

One square for every hour of every day, coloured by what lit the crop: lamps only, daylight + lamps, daylight or dark. You can check the lamp detection by looking at it instead of taking it on trust. On December data it shows lamps from 23:00 to 06:00, daylight plus lamps until 15:00 and darkness after that. Toward February the 16:00 hour switches on as the days get longer, which nothing was programmed to show.

Days when the sensors weren't measuring (they were outside the greenhouse from 27 May to 12 July) are left out instead of being drawn as dark nights.

Open lamp calendar /dli/lamps
Commits
61ed817 feat(red): a lamp calendar — which hours the lamps actually ran
a2d42ab feat(shared): let a page say where its "All" date preset begins

The model is scored on unseen days, and now beats "same as yesterday"

Red

The DLI model used to report only how well it fitted its own training data. It is now tested the way the climate model is: on whole days held out from training, and compared with the two simple forecasts any model has to beat, "same as yesterday" and "the usual for this time of year". The first honest result was uncomfortable. The weather step had no real skill (it had memorised its training data), and the full chain only matched "same as yesterday".

The main cause turned out to be the data, not the model. A daily total was computed by adding up sensor readings, so it partly measured how often the sensor reported. In June the roof sensor dropped from ~270 to 97 readings a day, and the sunniest days of the year came out at a third of their real value. Daily totals are now proper integrals over time:

  • full chain on unseen days: R² 0.655 → 0.926
  • skill against "same as yesterday": −0.02 → +0.52

The performance page had three related fixes. It compared a plant-level prediction with the roof sensor, which looked like a 38% underprediction but was a units mismatch. It counted sensor outages as perfect days. And it drew outages as days of zero light. Outages now show as a labelled gap in the line and are left out of the score.

Open DLI performance Open DLI model (admin) /dli/performance · /dli/model/
Commits
e724186 feat(red): DLI chain beats persistence — a daily total is an integral
5c6bc56 feat(red): DLI scored out of sample — and stage 1 has no skill at all
a977724 fix(red): DLI performance — compare like with like, and stop scoring outages
da6cdcd fix(red): an outage is a gap in the line, not a day of zero light

DLI history: sensors labelled by where they hang

Red

The two light lines were labelled "Natural Light" and "Total Light", which suggests the second includes the first. Whenever the sun was out, the plant-level line fell below the roof line and the chart looked self-contradictory. They are now Light above lamps and Light below lamps, at plant level, with a short explanation of why the crop gets less light than the roof.

The "Lamp DLI" column, which had been calculated as total minus natural, went negative all summer and was too high in winter. It now uses the measured lamp contribution described above.

Open DLI history /dli/history
Commits
49ac8b1 fix(red): DLI history — label the sensors by height, measure the lamp

🔴 Red's climate, height by height

The goal is to predict the climate at the level of the plants. These features move from greenhouse-wide averages to what each height and each fruit cohort actually experienced.

New: climate forecast per growth section

RedShared

A three-step model predicts temperature, humidity, CO₂ and light at each height on the measuring wires:

  1. public weather (OpenMeteo) is calibrated against the weather station on site;
  2. that, plus the greenhouse's recent state, predicts the greenhouse-wide sensors;
  3. each height is predicted as a deviation from the greenhouse-wide reading.

The model is split where the data is. The wires have about nine usable summer weeks and the greenhouse sensors have eleven months, so only the smallest step learns from the wires. Light reuses the measured lamp contribution from the DLI work instead of trying to learn the operators' lamp schedule from the weather. Every score is tested on held-out whole days against simple baselines.

Growers see a band chart per height on /climate/forecast. Admins see the skill grids and retraining suggestions on /climate/model/. The full method is written up in the model paper in the repository.

Open climate forecast Open climate model (admin) /climate/forecast · /climate/model/
Commits
6ca623e feat(red): climate model — weather to greenhouse level to per-height wire
ee0d079 refactor(red): one forecast chart, not two — and mend the outdoor line

New: uniformity across the wires

Red

Three measuring wires hang in the greenhouse, but every multi-height page made you pick one, so you couldn't ask whether the greenhouse is uniform. This page shows all three side by side, per growth section. Each wire gets a verdict that separates a constant offset (how it hangs or is calibrated) from a divergence at one height (a genuinely different microclimate there).

Colours use a fixed scale, so a calm day and a drifting day can't look alike. Every number shows how much data is behind it. Against production data it immediately showed two things:

  • the wires are not instrumented alike: some heights report no light, and one wire reports no climate at all;
  • light differs sharply between wires while climate doesn't. PAR and DLI spread well past the "notable" threshold, while temperature, humidity, CO₂ and VPD agree closely.
Open wire uniformity /multi_height/uniformity
Commits
fce36c7 feat(red): uniformity across wires — where the greenhouse agrees, and where it doesn't

New: crop cycles, linking each fruit sample to the climate it grew in

RedShared

A tomato plant sets a new truss every week, and each one ripens over about eight weeks, so several "cohorts" are always growing at once. The crop-cycles page gives each cohort its own lane, coloured by the climate it lived through. Its measured outcome (Brix, weight, …) is shown at the harvest end, so you can see which cohorts were exposed to a particular spell of weather.

  • The caption now says where the chosen climate metric was measured. For example, temperature and humidity come from a sensor at the top of the greenhouse, above the screens. A colour shouldn't suggest more than the sensor's position supports.
  • Several samples from one cohort are combined into one marker that says "mean of N", and the labels no longer cover the ripening weeks they describe.
  • Measurements that fall outside every declared cycle are flagged, never dropped. That flag is how the provisional cycle dates get corrected, and it has already flagged two dates.
  • The underlying cycles/cohorts model is twin-agnostic. A Blue version (treatments against the season's weather) was built, reviewed and deliberately not kept.
Open crop cycles /crop-cycles/
Commits
4625820 feat(red): crop cycles waterfall, on a shared cycles/cohorts model
45aae7c fix(red): one marker per cohort and cultivar, aggregated as the measure declares
761fa28 fix(shared): a value chip is a label, not a lid
be4c565 feat(red): say where the climate on crop-cycles was measured
8db3f65 / 7a30286 / 315948c blue seasons page: built, fixed, reverted

Brix in the fruit-quality upload

Red

The final 2025/26 fruit-quality dataset added a converted-°Brix column, so production rejected the upload. Brix is now ingested (all 128 rows land between 2.6 and 6.2 °Brix, the normal range for tomato). The rejection message is fixed too: it used to report the last column as missing, which pointed away from the real cause. It now names the added column by its Excel letter.

Commits
a01aefc feat(red): ingest Sijia's Brix column, and say which column drifted

🚀 Updates without downtime

Until now, every deploy was a short outage, and every restart threw away the trained models. Both have been fixed.

Files moved to object storage, so the dashboards update without going offline

SharedRedBlue

Each dashboard kept its files (nightly exports, trained models, uploaded spreadsheets) on a disk that can only attach to one machine at a time. So the old version had to stop before the new one could start, and every deploy meant downtime. It is also why the nightly exports silently failed for 23 days after the cluster move (see the previous update).

In three steps, all of those files moved to the university's object storage:

  • Exports, which also removed the workaround that pinned export jobs to one machine;
  • Trained models for both twins;
  • Manual uploads, the last disk. Every file was copied and verified by checksum.

Both dashboards now attach no disks at all. A deploy starts the new version, waits until it is ready, and only then stops the old one. The first rolling deploy served every request to Red but dropped one request on Blue, because the old pod shut down a moment before the router noticed. A short pause on shutdown closed that gap.

Commits
519e24f feat: CSV exports live in the bucket, so the export job stops being pinned to a node
0e455dd feat(red): trained models live in the bucket, and red is down to one PVC
c68af06 feat(blue): soil models in the bucket — both twins now hold one PVC each
b6d8e66 feat: uploads in the bucket — no PVCs left, and the dashboards finally roll
0de5c75 fix(helm): preStop sleep, so a rollout drops nothing at all
9a543d9 fix(ci): swap MinIO for adobe/s3mock — MinIO's images need auth now

A data-loss bug in upload clean-up, found and fixed

SharedBlueRed

Uploads are stored by content, so uploading the same file twice gives two records that point at one stored file. Blue's yearly long_data workbook is re-uploaded like that on purpose. The clean-up of old uploads deleted the file without checking whether a newer record still needed it, which is how Blue's 2024 workbook and Red's original fruit-quality file had silently disappeared. Clean-up now keeps any file that a kept record still uses. The missing files were restored from operator copies and matched against their checksums, so every live upload record has its file again.

Commits
b6d8e66 feat: uploads in the bucket — no PVCs left, and the dashboards finally roll

Trained models survive a deploy, and know what they were trained on

SharedRedBlue

Models used to be lost on every deploy or restart and retrained on startup. For Red's climate model that takes ~17 minutes in the cluster, and during that time the page was "up" with nothing to show. Models are now kept, and retrained every night (Red 03:00, Blue 03:30 UTC) so they keep learning as new data comes in.

Keeping models brings a risk: serving a model trained for a different question. So each model stores a fingerprint of the settings it was trained with. If the configuration changes (a wider training window, a new forecast horizon, a different sensor), the model is retrained instead of serving stale answers. Earlier, Red's DLI model filled gaps in old files with guessed defaults, and once loaded a guess looks exactly like a fitted value. Old or mismatched models are now refused.

Commits
1e70f07 feat: trained models survive a deploy, and say what fitted them

Object-storage certificate on the Status page

RedBlueShared

Now that exports, models and uploads all depend on object storage, its security certificate matters. The one in use at the time of writing expires on 11 October. The risky case isn't the expiry date but a renewal signed by an authority our software doesn't trust (one of the two roots involved isn't in the default list). So the check does a real connection test the same way the twins connect, and shows both the days left and who signed the certificate.

The Status page shows OK above 21 days, RENEW SOON below that and ACTION NEEDED under 7 days or if the connection test fails. The check can never crash the Status page itself.

Commits
1aada56 feat: watch the object store's certificate, which expires in 11 days

🟦 Blue: harvest and the 2026 data

The first look at yield per plant, and support for this year's pooled fruit-quality samples.

New: harvest per treatment, per plant and per pick date

Blue

A new Manual Monitor page shows, for each year, how per-plant season yield varies within each treatment, and how each pick date adds to the season total. A plant's season yield is the sum of its picks, so the plant number from the long_data workbook is now kept as a sample number. Treatments are listed in field-layout order with their plot codes. The Manual Monitor now takes its years from the data, so it no longer stops at 2025.

Open harvest /manual-monitor/harvest
Commits
cb5ca1d feat(blue): harvest per treatment, per plant and per pick date

2026's pooled fruit-quality samples accepted

Blue

In 2026 the berries of three treatments are pooled before measuring weight, Brix and firmness: Mix_D (Ca + V_CA + V_CA_G_BrPK) and Mix_E (K + G_K + V_K_G_CaBrP). These are now separate pooled groups, because a blend can't be credited to any one of its members. Before this, 180 rows of the 2026 file were skipped as unknown.

Open Manual Monitor /manual-monitor
Commits
b0689bc feat(blue): accept 2026's pooled Mix_D/Mix_E fruit-quality samples

⚙️ Platform & architecture (under the hood)

Less visible, but it keeps the twins trustworthy and the codebase maintainable.

Shared building blocks promoted out of Red

Shared
  • Resampling readings to any interval, leaving empty intervals as gaps instead of zeros, plus an hour-of-day encoding where 23:00 and 00:00 count as neighbours. Both light and climate models use these.
  • The date filter's "All" button can now start where a page's data actually begins, not at a fixed 2024-01-01.
  • A general matrix heatmap chart, and twin-agnostic cycles/cohorts and waterfall components.
Commits
94c64e3 feat(shared): resample readings to any interval, and encode hour of day
a2d42ab feat(shared): let a page say where its "All" date preset begins

Test and tooling hardening

SharedRedBlue
  • Fixed a second intermittent CI-only test failure, where a background database refresh started on a fresh database at the same moment as the first tests.
  • A backup script for Red's upstream MySQL database that works with read-only access.
  • A check script for whether the datalake API's paging is safe yet. Earlier it both skipped and duplicated rows between pages.
  • The local test object store is now a lightweight S3 mock, because MinIO's images no longer allow anonymous downloads.
  • Unused export helpers removed.
Commits
439630d test(e2e): disarm blue's cagg refresh policy so it cannot race the tests
893ed4f chore(scripts): add a red MySQL backup script
57b22bc chore(scripts): check whether datalake offset pagination is safe yet
00ad1c6 chore: drop the dead export-metadata helpers