Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

LocalSky Irrigation Engine

The engine answers one question: should I water tomorrow, and if so, how long? Every dashboard tile, every notification, every controller dispatch derives from a deterministic pipeline rooted in published agronomy and meteorology. This document walks through that pipeline end to end, with citations, so anyone with a slide rule and a quiet afternoon can reproduce the math by hand.

Pipeline overview

Weather sources ---------> MergedSnapshot -> Engine -> Verdict + per-zone runtime
Ecowitt GW (native poll) /                    |                |
                                              +-- FAO-56 ET0   +-> OpenSprinkler HTTP
                                              +-- Species Kc       (opensprinkler_direct)
                                              +-- Soil water balance
                                              +-- Skip rules (frost-skip uses native soil temp)
                                              +-- Cycle-and-soak
                                              |
                                              +-> Publishes results to HA
                                                  (sensor.localsky_<zone>_soil_*, valves, verdict)

LocalSky owns the full pipeline end to end: it polls the Ecowitt gateway directly, runs all ET and bucket math internally, evaluates skip rules (including frost-skip against its own native soil-temperature readings), and actuates OpenSprinkler via a direct HTTP controller (opensprinkler_direct, targeting the controller’s LAN address). Results are published back to HA for display, but HA is a consumer, not a driver. No Smart Irrigation, no Irrigation Unlimited, no MQTT sidecar.

Each box is a pure function of its inputs. No hidden state, no opinionated overrides, no proprietary fudge factors.

Inputs

Per source, per tick, LocalSky records:

  • Air temperature min / max / mean (deg C internally; converted from F at the boundary)
  • Relative humidity (max / min preferred, mean acceptable, dew point as fallback)
  • Wind speed at 2m (or 10m if measured higher; eq. 47 corrects)
  • Solar irradiance (W/m²)
  • Atmospheric pressure (kPa; elevation-derived if missing)
  • Rainfall (gross + intensity)
  • Observed rain over the recent window (today plus prior days’ measured totals, sensor-independent so a dropped soil probe or a paused source can’t hide real rain that already fell)
  • Day-of-year + latitude + elevation

Rainfall carries an honesty tier alongside the value: measured (a real gauge caught it), radar (a radar/QPE estimate), or model (a forecast figure). Downstream skip logic weights a measured total differently from a model guess.

Soil inputs (natively polled from the Ecowitt GW1100B gateway’s LAN address):

  • Per-zone soil moisture % (calibrated from raw FDR AD against dry/wet endpoints in LocalSky config)
  • Per-zone soil temperature (used directly for the frost-skip rule; no HA aggregation step)
  • Per-zone EC and battery state

If multiple sources report the same field, the merge engine picks the winner per merge policy: max for rainfall (one stuck gauge can’t hide actual rain), min for overnight low, highest priority for everything else.

Reference ET₀

LocalSky implements three methods. The Auto path tries them in order and picks the first one whose inputs are present.

1. FAO-56 Penman-Monteith (Allen et al., 1998 eq. 6)

The gold standard. Daily ET₀ over a hypothetical reference grass surface 12 cm tall, well-watered, with albedo 0.23 and a fixed surface resistance of 70 s/m:

ET₀ = (0.408 * Δ * (Rn - G) + γ * (900 / (T+273)) * u₂ * (es - ea))
      / (Δ + γ * (1 + 0.34 * u₂))

Where:

  • Δ – slope of vapor pressure curve at T_mean (kPa/°C), eq. 13
  • Rn – net radiation (MJ/m²/day), eq. 38 + 39 + 40
  • G – soil heat flux (~0 for daily timescale over grass)
  • γ – psychrometric constant (kPa/°C), eq. 8 = 0.665e-3 × P
  • T – mean daily temperature (°C)
  • u₂ – wind at 2m (m/s)
  • es – saturation vapor pressure (kPa), eq. 11 + 12
  • ea – actual vapor pressure (kPa), eq. 14-19 depending on humidity inputs

Rn is the trickiest term. LocalSky uses ASCE-EWRI 2005’s Brunt-form longwave model:

Rs   = measured shortwave (or 0.16 * sqrt(Tmax-Tmin) * Ra when missing)
Rns  = (1 - 0.23) * Rs       # net shortwave with albedo
Rso  = (0.75 + 2e-5 * z) * Ra # clear-sky from extraterrestrial
Rnl  = σ * ((Tmax+273)^4 + (Tmin+273)^4)/2 * (0.34 - 0.14*sqrt(ea)) *
       (1.35 * clamp(Rs/Rso, 0.3, 1.0) - 0.35)
Rn   = Rns - Rnl

Ra (extraterrestrial radiation, MJ/m²/day) is computed analytically from latitude and day-of-year via eq. 21, with the sunset hour angle clamped to [-1, 1] so high-latitude polar-day cases don’t NaN.

Implementation: src/engine/et0.rs. Hand-trace tested against eq. 6 for a 50°N April day (Tmax 21.5, Tmin 12.3, RH 84/63, u₂ 2.78, Rs 22.07): ~3.51 mm/day.

2. ASCE-EWRI 2005 short-crop reference ET

Practically identical to FAO-56 for daily computation; the coefficients differ at sub-daily resolution where LocalSky doesn’t operate. Same code path, different et0_method label for operators who want their dashboards to read “ASCE” instead.

3. Hargreaves-Samani 1985

Fallback when wind, solar, or humidity are missing:

ET₀ = 0.0023 * (Ra * 0.408) * (Tmean + 17.8) * sqrt(Tmax - Tmin)

Typical bias vs. PM is +/- 15-25% depending on climate; humid and windy climates see the largest errors. Acceptable when better data isn’t available; LocalSky flags Hargreaves-derived values in the dashboard math tile so the operator knows.

Crop ET (ETc)

For each zone:

ETc = ET₀ * Kc(species, DOY) * heat_multiplier(heat_index)

Kc (crop coefficient) is dimensionless, looked up from the species catalog by zone’s grass species and the current day-of-year. The catalog ships 12 species + ornamentals + xeriscape with monthly Kc curves; LocalSky interpolates linearly between mid-month anchors, with Dec/Jan wrap, so the curve is smooth year-over-year. Citations live inline in src/engine/species_catalog.rs.

heat_multiplier is the NOAA Steadman heat index applied as an ET boost from 1.00 at HI <= 85°F up to 1.30 at HI >= 105°F. Captures the empirical observation that 100°F + 70% RH dries a lawn faster than ET₀ alone predicts. Defined in src/engine/skip_rules.rs.

The heat index is computed per day: each day’s high temperature is paired with that same day’s humidity (the humidity at the time they co-occur), not the current “now” humidity. Pairing a cool, damp morning reading with the afternoon peak would inflate the multiplier, so the engine keeps the co-occurring pair intact.

Soil water balance

Per zone, LocalSky tracks one number: depletion_mm, the millimetres of water below field capacity. State evolves daily:

depletion[t+1] = clamp(depletion[t] + ETc - effective_rain - applied_water,
                       0, TAW)

Where:

  • effective_rain = gross_rain * capture_efficiency. Default capture efficiency is 0.70 (operator-tunable); accounts for runoff + canopy interception + evaporation losses before water enters the root zone.
  • applied_water is the depth (mm) of irrigation that reached the soil during this tick.
  • TAW (Total Available Water, mm) = (FC - WP) * root_depth_mm. FC and WP come from the soil texture catalog (USDA classes, sourced from FAO-56 Table 19 and USDA NRCS Part 652).

Trigger to irrigate:

needs_irrigation = (depletion >= RAW)
RAW = TAW * MAD%

MAD (Management Allowed Depletion) defaults per species. St. Augustine: 50%. Bahia: 55%. Ornamental shrubs: 40%. The catalog cites UF/IFAS extension publications for the warm-season species and FAO-56 Table 12 for the cool-season and non-turf categories.

Implementation: src/engine/water_balance.rs.

Runtime to depth

Once the engine decides to irrigate, runtime in seconds is:

gross_mm_needed = depletion_mm / capture_efficiency
seconds = (gross_mm_needed / precip_rate_mm_hr) * 3600

precip_rate_mm_hr per zone comes from either a measured catch-cup calibration (preferred) or the sprinkler-type default (rotor ~10 mm/hr; spray ~38 mm/hr; MP rotator ~10 mm/hr; drip ~4 mm/hr).

Runtime is capped at the zone’s run limit (max_run_minutes, 60 minutes when unset) so a misconfigured precip rate can’t run a zone for hours; an active watering restriction’s per-zone cap tightens it further via min().

Weekly water balance

The weekly allocator sizes each zone’s sessions against a true water balance in gross homeowner terms: the target is “inches per week including rain,” and the week’s ledger settles before any session is sized.

weekly_target_gross_mm = weekly_budget_in * 25.4
remainder = max(0, weekly_target_gross_mm
                   - observed_rain_trailing_mm
                   - irrigation_applied_trailing_mm
                   - bias_corrected_forecast_credit_mm)
session_gross_mm    = remainder / remaining_sessions
seconds_per_session = session_gross_mm / throughput_mm_hr * 3600   (capped at the run limit)
  • The trailing window is a rolling 7 local days ending now; there is no calendar-week anchor.
  • Observed rain resolves through a ladder with per-rung provenance and COVERAGE precedence: when the observations ledger holds any gauge or radar day rows for the window, the measured total wins outright, even at 0.00 in (a yard that measured a dry week is ground truth a wetter regional model must not override). Only when measured coverage is entirely absent does the forecast provider’s past-day model archive supply the term, and an install with neither runs on the corrected forecast alone; the tuning report line names which rung applied.
  • Applied irrigation is the union of completed watering evidence in the window (cycle-soak segments and duplicate manual/observer rows cluster into single events) times the zone’s precipitation rate. Gross in against a gross target: no capture factor on either side.
  • The forecast credit covers only the days between tomorrow and the zone’s next expected session, corrected by the per-month bias multiplier (below). Rain past the next session is never credited now; it will be observed rain by the time it matters. Imminent rain is handled by the 24-hour defer gate, not the credit.
  • remaining_sessions is sessions_per_week minus the completed events in the window, floor 1. Sessions space at floor(7 / sessions_per_week) days, measured from the last completed event in the runs history.

Fixed in 0.7.17: the previous formula multiplied delivery by the heat multiplier and divided by capture efficiency (0.70), inflating session length by up to about 1.9x against a target that already reads as gross, and it credited only forward forecast rain: rain that had already fallen and water already applied never counted, so a soaked week could still schedule full sessions. The heat multiplier stays an ETc input and capture efficiency stays a soil-projection input; neither shapes session delivery any more.

Implementation: src/engine/budget.rs (the one pure implementation; the refresher assembles its inputs).

Cycle-and-soak

If applying the full runtime at the sprinkler’s precipitation rate would exceed the soil’s infiltration capacity, water runs off instead of soaking in. The splitter divides the total runtime into N cycles separated by soak gaps:

if precip_rate > infiltration_rate:
    max_cycle_minutes = (infiltration_rate / precip_rate) * 60
    N = ceil(total_runtime / max_cycle)
    each cycle = total_runtime / N
    insert soak_minutes (default 30) between cycles

infiltration_rate comes from the soil catalog, varying by texture and slope (flat / 3-5% / >5% bands per USDA NRCS Part 652 Table 11-3). Sand on flat ground: 50 mm/hr; clay on a steep slope: 3 mm/hr.

Worked example: clay (5 mm/hr infiltration on flat), spray head (15 mm/hr precip), 45-minute total runtime -> 3 cycles of 15 min with two 30-min soaks. Total elapsed wall-clock: 1h 45min. Total water applied: same 45 minutes worth, but it actually enters the root zone instead of running off.

Implementation: src/engine/cycle_soak.rs.

Cycle interleaving

With interleave_cycles = false the morning sequence is strictly serial: a zone runs every one of its cycles, idling through each soak, before the next zone starts. In the worked example above that is 1h 45min of wall clock to apply 45 minutes of water, and every other zone waits behind it.

interleave_cycles = true in the [engine] table (the default, and the toggle on the Engine settings page) interleaves instead: during one zone’s soak pause, another zone’s cycle runs, the way dedicated irrigation controllers handle cycle-and-soak. The planner lays every zone’s cycles on a single valve timeline, dispatching whichever zone can start earliest. The rules it never breaks:

  • One valve at a time, always. Interleaving never opens two zones together, regardless of what the controller hardware could do.
  • Every soak is a minimum, not an exact gap. A soak stretches when another zone’s cycle is still running as it expires; it never shrinks.
  • Each zone’s cycles run in order, and the sequence never takes longer than the serial plan.

Worked example, continued: add a rotor zone that needs one 20-minute pass. Serial, the sequence takes 1h 45min for the clay zone plus 20 minutes for the rotor, about 2h 5min. Interleaved, the rotor pass runs inside the clay zone’s first soak and the whole sequence finishes in the clay zone’s own 1h 45min.

The default is on: with a municipal or otherwise pressurized supply, the shorter sequence is strictly better. Turn it off on installs fed by a well or a low-recovery pump, where the serial plan’s idle soak gaps double as recovery time for the water source and interleaving would fill that idle time with more pumping. The setup wizard’s water-supply question sets this for you; the toggle lives on the Engine settings page. The setting hot-reloads with the rest of the watering policy, so a change applies on the next scheduler tick (the next morning’s plan), no restart needed.

Either way, the scheduler works the sequence’s true wall time (runs, soaks, and preambles) backwards from its sunrise finish target, so a cycle-and-soak morning still ends about 15 minutes before sunrise.

Implementation: src/engine/interleave.rs.

Skip rules

Before any zone fires, the engine runs a deterministic rule ladder. First matching rule wins. Order encodes intent: explicit user overrides > paused > current-conditions safety (raining now, freeze, soil frost, wind) > observed recent rain > soil saturation > forecast skips > heat advisory > dry-run > run.

Observed recent rain (measured and sensor-independent) is checked before both the soil and forecast gates: if enough real rain has already fallen over the recent window, the zone skips regardless of what a probe or a forecast says. A soft forecast-rain skip is not the last word, though: when a zone reads measured-dry, the engine can demote that forecast skip back to a run so soil truth wins over an uncertain forecast (the soil floor moat). And an offline or outlier soil probe does not silently break a zone: its state is inferred from trustworthy neighbouring probes (soil quarantine) so one bad reading can’t force a skip or a needless run.

Full enumeration in skip-rules.md. All thresholds are typed config fields in cfg.engine.skip_rules; defaults match the original hardcoded values exactly so upgrading doesn’t change any verdict for unchanged inputs.

Heat advisory pre-water

When the 3-day forecast shows >= 95°F + >= 60% RH and the zone has been dry for >= 2 days, the engine returns verdict run_extended instead of plain run. Dashboard surfaces this; the controller adapter receives 115% of the computed runtime. Empirically gets ahead of the heat stress before it shows in the soil moisture data. Disabled if the 3-day rain forecast covers >= half the operator’s rain-skip threshold.

7-day forward verdict strip

Every dashboard render projects the next 7 days through the same rule ladder, using the daily forecast as synthetic Inputs. The “preview” is the actual decision the engine would make if today were that future day, with the live-only signals (wind_now, rain_intensity_now) zeroed out so they don’t false-fire. Operator gets a glance-able strip showing “skip Tuesday because heavy rain forecast”, “run extended Friday because heat advisory”, etc.

Implementation: src/engine/verdict_strip.rs.

Provenance

Every field in the merged snapshot records source_id, observed_at, and an optional method tag. The dashboard’s math tile reveals “ET₀ 5.2 mm via tempest_lan (penman_monteith)” or “wind 8 mph via open_meteo (forecast)”. Operators always know which input drove which decision; no opaque “the system says so.”

Forecast bias correction

Open-Meteo, NWS, and every other regional forecast source carries systematic bias in any given microclimate. A bowl behind a hill that sees consistent overprediction in summer afternoons doesn’t need the operator to hand-tune their rain-skip threshold every season; LocalSky learns the bias from observed data and folds it out.

How it works

Every refresh, LocalSky records one row per local calendar day in forecast_observations:

columnsource
predicted_inThe morning’s forecast (forecast.daily[0].precipitation_sum). First write of the day wins.
observed_inThe day’s observed rain from the merge-contested daily total. Day-max: the recorded value only ever rises within a day, so a gauge going stale mid-storm cannot reset the total.
observed_sourceWhich kind of source supplied the day’s max: gauge or radar for measured day totals, or none (a placeholder 0.0, excluded from the bias fit, the dryness counters, and the scorecard). A model-nature rain owner also records the placeholder: its “rain today” is the whole day’s forecast, including hours that have not happened, and the day-max semantics would make phantom rain permanent. Rows written before 0.7.17 read legacy and count as gauge-quality only on installs with a station source.
month1..12, denormalized so the bias query indexes by month-of-year.

The first write of the day plants the prediction; the rest of the day refines the observation. Once MIN_OBSERVATIONS (currently 5) days exist in a given month within the rolling 90-day window, the engine computes a per-month bias multiplier:

multiplier = median(observed_in / predicted_in)   over the month bucket
multiplier = clamp(multiplier, 0.5, 1.5)

Multiplicative not additive: rain bias is the same shape at 0.2 inch and 2.0 inch. Median not mean: a single 2-inch surprise storm shouldn’t tank the model.

Where it surfaces

  • API: GET /api/v1/forecast/bias returns the current-month multiplier plus the full 12-month table with sample counts.
  • Pure module: engine::forecast_bias::BiasModel::from_observations(observations, today, window) is callable from anywhere; ideal for backtests and replay against historical verdict logs.
  • The weekly balance: the balance’s forward credit is the multiplier’s first engine consumer: credit = forecast_rain * precip_weight * multiplier_for(month) over the days until the zone’s next session. Under-trained months multiply by 1.0 by design, and the tuning report states the sample count instead of implying a correction. The multiplier applies only to forward forecast, never to observed terms. Rows whose observed side had no rain-capable source (observed_source = 'none') are excluded from the fit, so gauge-less installs cannot train the floor on fabricated dry days.
  • Skip rules: the multiplier is not yet folded into the rain inputs going into the skip ladder; wiring corrected_rain = raw_rain * multiplier upstream of skip_rules::evaluate remains planned. The same observation rows already do decision work elsewhere: the observed-rain backstop reads them live, and the tuning report’s forecast-skip scorecard judges every rain-family skip against them.

Defaults and bounds

ConstantValueWhy
MIN_OBSERVATIONS5Below this, a single outlier dominates. Multiplier stays at 1.0.
BIAS_FLOOR0.5Real bias rarely halves a forecast; below this is almost certainly a broken pipeline.
BIAS_CEIL1.5Same intuition on the other side.
DEFAULT_WINDOW_DAYS90One season. Tracks microclimate shifts without dragging in last year’s summer into this year’s.
NOISE_FLOOR_IN0.02Below this in both columns, the day is “dry” and not informative for a multiplicative model.

Implementation: src/engine/forecast_bias.rs (pure functions + 11 unit tests).

Results-based tuning

The engine’s per-zone parameters (texture, root depth, sprinkler rate, weekly budget) start as informed guesses. The tuning report closes the loop: it reads a window of recorded outcomes and emits at most one deterministic recommendation per zone. The user-facing walkthrough is Tuning report; this section covers the machinery.

How it works

Four checks run per zone over a 7 to 30 day window (default 14), each a pure function over persisted rows:

checkreadsflags when
cap clamplive run-duration math + run days in the windowthe duration cap chronically trims the model’s desired session
interval plausibilitysoil catalog RAW / forward mean daily ETcone filling of the root zone lasts under 1.5 or over 21 days
drying driftprobe series slope across dry stretches vs mean_daily_etc / TAWthe measured drying rate is outside 0.6x to 1.6x the modeled rate
rate backoutprobe rise bracketing each watering eventthe backed-out precipitation rate differs from the configured one by over 30%

The install-wide forecast-skip scorecard builds on the existing accuracy scoreboard above (same forecast_observations rows, same WET/SIG thresholds as assess_day), extended with window-aware confirmation: a tomorrow-rain skip is judged against the NEXT day’s observed total and a 3-day-rain skip against the following 3-day sum, where the accuracy scoreboard judges same-day only. Only forecast-driven skips enter the tally (rain expected within 4 hours, tomorrow rain, 3-day rain); reactive skips (rain now, observed rain, already wet) are triggered by rain that already happened, so scoring them against observed rain would be self-confirming, and they are counted on a separate unscored line instead.

Where it surfaces

  • API: GET /api/v1/irrigation/tuning returns the full report; POST /api/v1/config/zones/apply writes one recommendation through the validated config path.
  • Pure module: engine::tuning holds every rule (slope estimator, event clustering, backout math, scorecard scoring, ranking) with unit tests; the store assembly is a thin layer above it.
  • UI: the zone detail’s Tuning panel and the irrigation page’s strip.

Defaults and bounds

ConstantValueWhy
window7..=30 days, default 14Enough mornings to call a pattern chronic; short enough to track the season.
dry stretch>= 48h, >= 2 stretches, >= 8 readings eachOne quiet weekend is not a drying signal.
drift band0.6x to 1.6xProbe percent is a relative scale; only a large, repeated ratio is trustworthy.
backout events>= 3 clean events, median rateA single rise can be rain residue or a probe artifact.
rate tolerance30%Catalog rates are honest to roughly this band anyway.
scorecard minimum3 scored daysBelow this the tally would be noise; the report says so instead.

One recommendation per zone at most, ranked cap clamp > drying drift > rate backout > interval plausibility, and the backout check is only consulted when drift did not flag the zone in the same report. Counts with no data behind them are null on the wire, never zero.

Implementation: src/engine/tuning.rs (pure rules + unit tests) and src/tuning.rs (store assembly).

Where to read further