Skip Rules
LocalSky’s irrigation skip-check is a 17-rule ladder. Every morning (or whenever the engine recomputes), inputs flow through the ladder in order. First matching rule wins. Order matters: explicit overrides beat safety beats current conditions beat forecast beats heat advisory beats dry-run beats run.
Source: src/engine/skip_rules.rs.
Ladder
| # | Rule | Trigger | Threshold | Tunable? |
|---|---|---|---|---|
| 1 | Manual override: skip tomorrow | is_tomorrow && override_tomorrow == "skip" | none | UI |
| 2 | Manual override: run tomorrow | is_tomorrow && override_tomorrow == "run" | none | UI |
| 3 | Vacation pause (timed) | pause_until_epoch > now_epoch | none | UI |
| 4 | Vacation pause (toggle) | is_paused == true | none | UI |
| 5 | Currently raining | rain_intensity_now_in_hr > 0.01 | 0.01 in/hr (0.25 mm/hr) | rain_now_in_hr |
| 6 | Freeze risk now | temp_now_f < min_temp_f | 38°F (3.3°C) | min_temp_f |
| 7 | Overnight freeze | temp_min_24h_f < min_temp_f | 38°F (3.3°C) | min_temp_f |
| 8 | Soil frost | soil_temp_yard_min_f < frost_skip_soil_f | 35°F (1.7°C) | frost_skip_soil_f |
| 9 | Wind too high now | wind_now_mph > max_wind_mph | 10 mph (16 km/h) | max_wind_mph |
| 10 | Windy day forecast | wind_max_today_mph > max_wind_mph + 5 | +5 mph (8 km/h) slack | wind_forecast_slack_mph |
| 11 | Already wet | rain_today_in >= 0.05 | 0.05 in (1.3 mm) | already_wet_in |
| 11b | Observed rain recently | rain_observed_recent_in >= rain_skip_in | 0.25 in (6.4 mm) over the recent window | rain_skip_in, rain_observed_window_days |
| 12 | All zones soil-saturated | every zone’s moisture % >= saturation threshold | per-zone | per-zone soil settings |
| 13 | Rain in next 4 hours | rain_next_4h_in >= 0.10 | 0.10 in (2.5 mm) | rain_next_4h_skip_in |
| 14 | Tomorrow rain (confidence-weighted) | forecast_in * prob/100 >= rain_skip_in | 0.25 in (6.4 mm), weighted | rain_skip_in |
| 15 | 3-day rain rollup | rain_3day_weighted_in >= 1.5 * rain_skip_in | 1.5x multiplier | rain_3day_factor |
| 16 | Heat advisory (pre-water) | 3-day max >= 95°F (35°C) + humidity >= 60% + 2+ dry days | composite | heat_advisory_* |
| 17 | Dry-run mode | is_dry_run == true | none | UI |
| - | Default | (no rule matched) | none | run |
Verdict types
The ladder returns one of three verdicts:
skip: don’t irrigate.reasoncarries a human-readable explanation.run: proceed with the engine’s computed runtime.run_extended: proceed at 115% of the engine’s computed runtime. Used only by rule 16 (heat advisory pre-water).
Per-rule details
Currently raining (rule 5)
Live precipitation intensity from the Tempest hub (or merged from any source advertising RainIntensityInHr). 0.01 in/hr (0.25 mm/hr) is essentially “you can see the pavement getting wet”; anything above triggers the skip.
A hard “currently raining” skip only applies when the rain source is observation-grade: a local gauge, an NWS observation, or NOAA MRMS radar. A model forecast rain rate is treated as a soft skip that a measured-dry zone can demote to a run (see Soil floor (the moat)).
Freeze + soil frost (rules 6-8)
Three independent freeze checks. Air temp now blocks daytime watering on a cold front. Forecast overnight low blocks a 6 AM run when the lawn would freeze later. Soil frost is the strongest signal: cold soil + a sprinkler is how you ice a lawn.
Soil temperature comes from any source providing soil_temp_yard_min_f. If no source reports it (probe offline), this rule silently no-ops and the verdict surfaces “(weather rules only; soil rules offline)” instead of a false-clear.
Wind (rules 9-10)
Two thresholds: live wind right now, and forecast peak with a 5 mph (8 km/h) slack on the latter (forecast peaks tend to overshoot real maxes). Operators with sensitive sprinkler types (mp_rotator, drip) want max_wind_mph lower (~6 mph / 10 km/h); rotor heads tolerate up to 12-15 mph (19-24 km/h).
Already wet (rule 11)
Fixed floor at 0.05 in (1.3 mm) of accumulated rain today. Configurable but rarely changed, it’s a sanity check that says “I’m not going to add water to a wet lawn.”
Observed rain recently (rule 11b)
The sensor-independent backstop. rain_observed_recent_in sums today’s measured rain plus the past rain_observed_window_days (default 1) of measured daily rain totals, and skips watering on its own when that sum reaches rain_skip_in (default 0.25 in / 6.4 mm). This is what makes a real afternoon rain suppress the NEXT morning’s run: it carries measured rain forward independent of any soil probe or forecast. Because it reads PAST observed rain rather than a forecast, it is not gated on forecast staleness. It is a hard skip that binds every zone (the soil-floor moat below never demotes it).
Yard-wide soil saturation (rule 12)
Skip only when EVERY zone reports moisture >= its per-zone saturation threshold AND every zone has a current reading (no None / probe-offline). A single dry zone or a single missing reading breaks the skip. The per-zone HA automation irrigation_per_zone_saturation_skip still mutes individual saturated zones; this rule operates at the sequence level.
Forecast rain (rules 13-15)
Three look-ahead windows: next 4 hours (hourly forecast), tomorrow (probability-weighted to deflate uncertain forecasts), and 3-day rollup. The 3-day uses a 1.5x multiplier on the user’s rain-skip threshold to require more total rain before skipping (a wider window is a weaker signal).
Soil floor (the moat)
A soft, forecast-based rain skip (next 4 hours, tomorrow, or the 3-day rollup) may be demoted to a run when a zone is measured healthy-dry: its soil percent is below its per-zone dry floor, target_min_pct_soil, with a present probe reading above zero. This honours measured soil truth over an uncertain forecast. Hard skips (measured rain now, observed recent rain, freeze, wind, soil saturation) are never demotable, and observation-grade rain (a real gauge or MRMS radar) never demotes.
Bad or offline soil probes (quarantine)
When soil_quarantine_enabled is true (the default), a probe that is offline or reads as a wild outlier versus its siblings (beyond soil_outlier_threshold_pct, default 35 pp) is distrusted, and that zone’s effective soil for the saturation and dry-floor gates is inferred from the trustworthy sibling readings. This stops a single bad-spot probe from driving a saturated zone to water, while a genuinely saturated zone still skips. Set soil_quarantine_enabled to false to restore the exact pre-quarantine behavior.
Heat advisory pre-water (rule 16)
The only rule that can fire run_extended. Triggers when:
temp_max_3day_f >= 95°F(35°C; or operator’s heat_advisory_temp_f)humidity_now_pct >= 60%(heat_advisory_humidity_pct)days_since_significant_rain >= 2(heat_advisory_dry_days)rain_3day_weighted_in < 0.5 * rain_skip_in(forecast doesn’t cover it)
Empirically gets ahead of heat stress that ET-based math underestimates on multi-day spikes. Disabled in cooler climates by raising heat_advisory_temp_f.
Tunable parameters
All thresholds live under cfg.engine.skip_rules in /data/localsky.toml. The defaults in src/config/schema.rs match the v0.1 hardcoded constants exactly so upgrades preserve verdicts:
[engine.skip_rules]
already_wet_in = 0.05 # 1.3 mm
rain_now_in_hr = 0.01 # 0.25 mm/hr
rain_next_4h_skip_in = 0.10 # 2.5 mm
rain_3day_factor = 1.5
heat_advisory_temp_f = 95.0 # 35 C
heat_advisory_humidity_pct = 60.0
heat_advisory_dry_days = 2
wind_forecast_slack_mph = 5.0 # 8 km/h
max_wind_mph = 10.0 # 16 km/h
min_temp_f = 38.0 # 3.3 C
rain_skip_in = 0.25 # 6.4 mm
frost_skip_soil_f = 35.0 # 1.7 C
rain_observed_window_days = 1 # today + N past days of measured rain
soil_quarantine_enabled = true # distrust offline / outlier probes
soil_outlier_threshold_pct = 35.0 # pp from sibling median before distrust
Edit via PUT /api/config (the settings UI does this); changes apply on the next engine tick (default 60s).
Replay + audit
Every verdict that fires gets logged to verdict_history (M0005 migration) with the full Inputs blob as inputs_json. Operators investigating a strange decision can replay any historical row through the current engine and compare. cargo test engine::skip_rules includes a regression guard test that runs production verdict history through the engine and asserts 100% verdict + reason match.