sim/ — the virtual farm#
Build-order steps 2–4 from docs/VIRTUAL_FARM.md. Stdlib
only (plus cbor2, via the SDK), Apache-2.0.
cd sim && python3 -m unittest discover -p 'test_*.py' # 240 tests (~95 s; the drills are slow)
python3 drill.py --compare # one probe against two
python3 world_demo.py # a day of Harrow Bend
python3 world_demo.py --stuck-float # the fault, and the catch
python3 world_demo.py --outage 10 --days 2 # buffer, evict, report
python3 world_demo.py --dr 3 # a better link
python3 forecast_drill.py # forecasts on real 2007 weather, scored
python3 forecast_drill.py --springs 1991-2020 --weather W.csv --ensemble 20 # heavy: ~1.6 GB a spring
python3 irrigation_drill.py # watering by timer, forecast and probe
python3 lawn_drill.py # a lawn's sprinklers and their add-ons
| Module | What it is |
|---|---|
physics.py |
Ground truth: a seeded seasonal climate — or RecordedClimate, a real place's daily weather replayed at the 5-minute tick — a soil water balance, a trough with a float valve, an unheated tunnel fitted to GreenLight per film (tested by modules/advise/tunnel_gain.py). Given the sky's cloud, from its long-wave (cloud_from_longwave), the tunnel's nights follow it, and a clear night under plain polyethylene is colder inside than out. A bed may lie under mulch (SoilBed.mulch_m), which damps and delays its soil's daily wave as depth does. A bed may be made of horizons (LayeredBed): brought-in topsoil over a compacted subsoil, each layer passing water only as fast as its conductivity allows, so a watering perches over the slow layer and a long run runs off; water soaks in as Green and Ampt have it, fast into dry soil at first. Its soils' values are round, typical ones, assumed rather than taken from a survey |
data/ |
Recorded weather from NASA POWER (CC BY 4.0): McAllister Farm's spring of 2007, the Easter freeze in it; its summer of 2013, the wettest of thirty; and its summer of 2012, kept to test what was tuned on 2013. tools/weather_csv.py writes any site's years. Also pyfao56-mcallister-summers.csv, the days USDA's pyfao56 scheduler would water the drill's bed on thirty summers (tools/pyfao56_schedules.py) |
forecaster.py |
Forecast runs read from the world's own future, with an error of declared size that grows with lead time; and ensembles whose members share the run's error and spread about it |
forecast_drill.py |
Forecasts through layer zero (Q26) on recorded weather: scored against the farm's station by lead day, and a frost watch scored on the alert standing 12 h before each frost and on false alert episodes; --springs over many years, and --ensemble to warn at a chance of frost rather than a margin |
harrow_bend.py |
Harrow Bend, the canonical farm: built from a seed, every device and series declared, a season lived through; the completeness matrix (test_harrow_bend.py) is enforced against it |
siting_drill.py |
The depth and two-peer siting checks scored on a control farm and on one where five probes are moved, pulled or swapped and nobody logs it |
export_demo.py |
A season exported and verified, printing what a farmer would receive |
irrigation_drill.py |
Five watering rules on identical recorded summers and forecasts — timer, skip on forecast rain, skip knowing the rain, soil probe, probe with skip — by water applied, drained and transpired, and stress; with --controllers, OpenSprinkler's Zimmerman and ETo methods and Home Assistant's Smart Irrigation beside them, from their source, with FAO-56 reference ET |
lawn_drill.py |
A home lawn on spray heads and a timer, and the add-ons it can take (rain sensor, bypass and on-demand soil sensors, weather-based adjustment, cycle and soak, rotary nozzles), on a native silt loam and a layered yard: water applied, runoff, drainage, dry and soggy hours (notes/lawn-sprinklers) |
node.py |
A duty-cycled node: sampling, buffering, transmit-time restamping, eviction, resend, reboot |
link.py |
A radio that loses, delays, duplicates, drops acks and goes out — seeded |
faults.py |
Injectable sensor faults: drift, sticking, bias, noise, death, out-of-range |
harness.py |
The loop, in §5.1's order, shared by the demo and the tests |
drill.py |
Seeded faults scored against ground truth: recall, precision, false alarms, latency |
world_demo.py |
The whole chain — physics → node → link → gateway → observations |
Ground truth lives in physics.py, and only there#
Sensors, their quantisation and their faults belong to node.py. That separation is
the producer/detector doctrine: a detector is only honestly scored if the thing that
produced the fault and the thing meant to notice it were written apart. physics.py
has never heard of checks.py.
Fidelity, stated plainly: plausible physics with the right shape, not a validated agronomic model. A single-layer soil bucket with FAO-56-flavoured stress and drainage, ET as a seasonal curve scaled by temperature rather than Penman-Monteith, rain as a seeded two-state process. Enough to exercise every clause in the spec and to make drift look like drift. Not enough to tell a farmer when to irrigate.
Rain is the climate's only stochastic input, so two seeds produce identical output over a window that is dry in both — a property, not a bug, and the reason seed sensitivity is tested over three days rather than one.
What running it has already found#
The sim earns its keep by failing. Four defects, each now a spec amendment:
- A healthy farm flagged 228 of 384 readings. A working float holds a trough at one value for 226 consecutive samples, and a soil probe at three decimals repeats for eight hours overnight when ET is near zero. Both are correct readings. Flatline is a category error on a regulated quantity, and a flatline threshold has to exceed the quantisation dwell (§4.2).
- A node inside its latency ceiling lost data permanently and looked healthy. It
sampled twelve readings an hour and sent one frame an hour carrying five.
max_latencyis a ceiling, not a schedule; a node must drain on backlog, and an infeasible sample-rate/link combination must be refused at registration (§5.3.1). - Derived values carried IEEE 754 noise —
0.08600000000000001— into the SensorThings output. A calibration may now declare its output resolution (§3.2). - Two readings could land on one instant, violating §3.2's uniqueness. Wire ages are whole seconds, so that bounds the minimum sample interval per channel (§5.2).
- A resend took a fresh sequence number, so the gateway could not recognise it and wrote the same readings again at timestamps a rounding apart — 2498 observations where 768 were expected. §6.3 states this rule for commands; it was never stated for readings, and now is (§5.2).
- Without an acknowledgement deadline, one lost frame stalled delivery forever. The gateway's highest contiguous sequence cannot pass a hole, so the node held every later reading until it began evicting — healthy-looking and delivering nothing (§5.2).
- Two mixed-clock bugs, both in this harness rather than the SDK. Sampling against true time while transmitting against a skewed clock, and polling the link for acknowledgements on the node's clock instead of the medium's. Neither raised an error; the first silently destroyed §2's offset cancellation and the second made the node resend forever. A node has one clock and the link has another, and the spec now says so.
test_world.py is where that first one is pinned: a clean day must produce a clean
record, and every reading must still be assessed — silence because nothing was
checked is not the same as silence because nothing was wrong.
Why clock skew is not an injectable fault#
Because §2 makes it a non-event, and that is worth demonstrating rather than
asserting. A node reports ages, computed from its own clock at both ends, so a
constant offset subtracts out exactly: (t2 + e) - (t1 + e) is t2 - t1. A node six
hours wrong still produces a correct measured_at, and
test_faults.py::TestClockOffsetCancels proves it. Only drift across the buffer dwell
survives, and over the minutes or hours a reading actually waits it is far below the
uncertainty the node already declares.
What does corrupt times is stamping the age when the reading is taken rather than
when the frame leaves — the mistake §2 warns about. Node.stamp_at_sample models it
so the damage is visible: without an outage it is invisible, which is exactly why it
would ship, and after a six-hour outage the entire window collapses onto the recovery
instant and nothing in the record says so.
What the drills measured#
make drill. Onset at a third of the way in, scored against Sensor.injected():
| fault | 1 probe | 2 probes |
|---|---|---|
| control (nothing wrong) | 0 flags | 0 flags |
| disconnected, reads its rail | 100% | 100% |
| frozen probe | 87% | 87% |
| drift +0.05/day | 0% | 79% |
| noise sigma 0.03 | 0% | 19% |
| bias +0.08 | 0% | 100% |
| dead (absence, not a wrong value) | no flags — correct | no flags — correct |
A single probe on a feature cannot see drift, bias or noise at all. Every reading is individually plausible and the series looks healthy. Drift is the commonest real failure of the three, so this is an installation decision, not a tuning one.
The first scored run reported a 99.8% false-alarm rate on a clean farm, which is how the cross-check's unit error was found: it was comparing a derived series in physical units against a neighbour's raw series in wire units, 0.280 against 280. No amount of fault-detection recall would have revealed that — only the control did.
Not here yet#
Harrow Bend itself is not assembled — this is one bed and one trough, not the six beds, paddock, research plot, hoop house and well of the completeness matrix (step 5).