From e3cda8fe8f92851903c065c8ac0378d4b5e2aed7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 22:02:24 +0000 Subject: [PATCH 1/5] Add Growing Degree Days and per-bed ET/water-balance metrics Adds a once-daily accumulation pipeline that computes, per bed: - GDD ((Tmax+Tmin)/2 - Tbase) from local sensor history, tracked cumulatively from a per-bed planted_on date, to derive growth stage and a harvest-date projection per vegetable. - Per-bed crop evapotranspiration (ETc = ET0 x Kc, reusing Open-Meteo's existing Penman-Monteith ET0) combined with rain and an irrigation estimate modeled from soil-moisture-rise, to give a per-bed water balance in place of the old whole-garden-only figure. New pure functions live in garden/derived.py, persisted daily to a new bed_daily_agronomy SQLite table, computed by a new cron job in runner.py, surfaced via /api/insights, and rendered in the bed detail panel on the dashboard. --- config.yaml | 33 +++ garden/agent/runner.py | 140 +++++++++- garden/config.py | 8 + garden/dashboard/static/css/components.css | 6 + garden/dashboard/static/js/garden.js | 35 ++- garden/derived.py | 289 +++++++++++++++++++++ garden/main.py | 53 +++- garden/storage.py | 81 ++++++ tests/test_config.py | 11 + tests/test_derived.py | 186 +++++++++++++ tests/test_storage.py | 49 ++++ 11 files changed, 886 insertions(+), 5 deletions(-) diff --git a/config.yaml b/config.yaml index 4616ba5..8d8247f 100644 --- a/config.yaml +++ b/config.yaml @@ -91,23 +91,31 @@ sensors: # | tomato_cherry | tomato_roma | tomato_beefsteak | tomato_heirloom | tomato_grape | tomato_san_marzano) dashboard: beds: + # planted_on: date the bed's crops went in — start of GDD accumulation + # (garden/agent/runner.py run_daily_agronomy_accumulation). One date per + # bed, matching how plants: is already treated as one shared-fate group. + # EDIT THESE to your actual planting dates. - id: bed1 name: "Bed 1" sensors: {soil_moisture: soilmoisture1, soil_battery: soilbatt1} plants: [tomato_cherry, tomato_roma, tomato_beefsteak, tomato_heirloom, tomato_grape, tomato_san_marzano] + planted_on: "2026-04-15" - id: bed2 name: "Bed 2" sensors: {soil_moisture: soilmoisture2, soil_battery: soilbatt2} plants: [eggplant, okra, okra, eggplant, okra, okra, okra, okra, okra, okra, okra, okra] + planted_on: "2026-04-15" - id: bed3 name: "Bed 3" sensors: {soil_moisture: soilmoisture3, soil_battery: soilbatt3} plants: [sweet_pepper_red, sweet_pepper_yellow, sweet_pepper_green, sweet_pepper_orange] + planted_on: "2026-04-15" - id: bed4 name: "Bed 4" sensors: {soil_moisture: soilmoisture4, soil_battery: soilbatt4} plants: [zucchini, eggplant] layout: vertical # stack plants in a single column instead of side-by-side + planted_on: "2026-04-15" weather_keys: {temp: temp1_f, humidity: humidity1, pressure: baromrel_inhg} stat_groups: - name: "Bed 1" @@ -243,3 +251,28 @@ crops: sweet_pepper_orange: {moist: [50, 75], temp: [65, 90]} hot_pepper: {moist: [45, 70], temp: [65, 95]} zucchini: {moist: [55, 80], temp: [60, 90]} + +# ── Agronomy: GDD + per-bed ET/water-balance accumulation ───────────────────── +# Runs once/day at accumulation_hour_local (garden/agent/runner.py +# run_daily_agronomy_accumulation) — deliberately a different hour from +# daily_brief.hour_local so the two once-daily jobs don't compete for +# attention in the same cron tick. All irrigation/root-zone figures are +# MODELED ESTIMATES derived from soil-moisture-rise, not direct flow +# measurements — see garden/derived.py estimated_irrigation_in() docstring. +agronomy: + enabled: true + accumulation_hour_local: 23 # late in the day so temp range + forecast are ~final + gdd_temp_key: temp_f # station outdoor sensor — NOT temp1_f (gazebo runs warm/sheltered) + # Per-bed root-zone depth (in) and soil available-water-capacity (in of + # water per in of soil depth) used by estimated_irrigation_in(). Defaults + # below are typical raised-bed potting-mix values; deeper-rooted crops + # (tomato/eggplant) can reasonably go 10-12in, shallow ones (peas) ~6in. + beds: + bed1: {root_zone_depth_in: 10.0, awc_in_per_in: 0.17} + bed2: {root_zone_depth_in: 9.0, awc_in_per_in: 0.17} + bed3: {root_zone_depth_in: 8.0, awc_in_per_in: 0.17} + bed4: {root_zone_depth_in: 9.0, awc_in_per_in: 0.17} + # Optional overrides layered onto derived.py's GDD_BASE_F / KC_MID, same + # override pattern as the crops: block above / _merge_crop_ranges(). + gdd_base_overrides: {} + kc_overrides: {} diff --git a/garden/agent/runner.py b/garden/agent/runner.py index f1031db..5e412b8 100644 --- a/garden/agent/runner.py +++ b/garden/agent/runner.py @@ -5,7 +5,8 @@ evaluate_instant(snap_id, ts, metrics) — called inline on every POST run_cron_tick() — called by the systemd timer every 15 min -The cron tick also handles the daily morning brief (replaces the old heartbeat). +The cron tick also handles the daily morning brief (replaces the old +heartbeat) and the once-daily GDD/water-balance accumulation. """ from __future__ import annotations @@ -145,6 +146,11 @@ def run_cron_tick() -> None: except Exception: log.exception("Daily brief failed") + try: + _maybe_daily_agronomy_accumulation() + except Exception: + log.exception("Daily agronomy accumulation failed") + log.info("Cron tick complete") @@ -336,6 +342,132 @@ def _maybe_daily_brief() -> None: send_daily_brief(force=False) +# ── Daily agronomy accumulation (GDD + per-bed ET/water balance) ───────────── +# +# Once per local day, persist each bed's GDD and water-balance figures to +# bed_daily_agronomy (see garden/storage.py). Same once-per-day idempotency +# pattern as send_daily_brief/_already_sent_today above, keyed by its own +# alert_state rule_id per bed so it can't collide with the brief's dedup. + +_AGRONOMY_RULE_PREFIX = "agronomy_accum" + + +def _agronomy_already_run_today(bed_id: str, local_now: datetime) -> bool: + state = storage.get_alert_state(f"{_AGRONOMY_RULE_PREFIX}_{bed_id}") + last_fired = state.get("last_fired_ts", "") + if not last_fired: + return False + try: + last_dt = datetime.fromisoformat(last_fired.replace("Z", "+00:00")) + tz_name = cfg.location.get("timezone", "UTC") + try: + tz = ZoneInfo(tz_name) + except ZoneInfoNotFoundError: + tz = ZoneInfo("UTC") + return last_dt.astimezone(tz).date() == local_now.astimezone(tz).date() + except Exception: + return False + + +def run_daily_agronomy_accumulation(force: bool = False) -> None: + """ + Once per local day (config: agronomy.accumulation_hour_local, default 23 + — late enough that the day's temp range and forecast snapshot are close + to final), compute and persist each bed's GDD + water-balance row. + + Called by run_cron_tick(); mirrors send_daily_brief's force/hour/dedup shape. + """ + if not cfg.agronomy.get("enabled", True): + return + + local_now = _local_now() + if not force: + hour_local = cfg.agronomy.get("accumulation_hour_local", 23) + if local_now.hour != hour_local: + return + + today_str = local_now.date().isoformat() + fc = get_forecast() + temp_key = cfg.agronomy.get("gdd_temp_key", "temp_f") + gdd_base_overrides = cfg.agronomy.get("gdd_base_overrides") or {} + kc_overrides = cfg.agronomy.get("kc_overrides") or {} + beds_cfg = cfg.agronomy.get("beds", {}) + + for bed in cfg.dashboard.get("beds", []): + bed_id = bed.get("id") + if not bed_id: + continue + if not force and _agronomy_already_run_today(bed_id, local_now): + continue + + temp_stats = storage.stats(temp_key, hours=24) + if temp_stats is None: + log.debug("Agronomy accumulation: no %s data yet for %s, skipping", temp_key, bed_id) + continue + + base = derived.gdd_base_for_bed(bed.get("plants", []), gdd_base_overrides) + if base is None: + continue # no recognised crops in this bed + base_f, ref_crop = base + + gdd_today = derived.gdd_daily(temp_stats["max"], temp_stats["min"], base_f) + + # Irrigation estimate from a recent soil-moisture rise (module docstring: + # analyze_watering() wants a narrow window, <=2h, to avoid bucket smearing). + moist_key = bed.get("sensors", {}).get("soil_moisture") + watering: dict = {} + if moist_key: + rows = storage.series(moist_key, hours=2) + samples = [ + (datetime.fromisoformat(r["ts"].replace("Z", "+00:00")).timestamp(), r["value"]) + for r in rows + ] + watering = derived.analyze_watering(samples) + + bed_agro_cfg = beds_cfg.get(bed_id, {}) + root_zone_in = bed_agro_cfg.get("root_zone_depth_in", 9.0) + awc = bed_agro_cfg.get("awc_in_per_in", 0.17) + irrigation_in = ( + derived.estimated_irrigation_in(watering["absorbed"], root_zone_in, awc) + if watering.get("detected") else 0.0 + ) + + kc = derived.kc_for_crop(ref_crop, kc_overrides) or 1.0 + et0_in = fc.get("et0_in") if fc else None + rain_in = (fc.get("precip_in") if fc else None) or 0.0 + etc_in = derived.etc_from_kc(et0_in, kc) if et0_in is not None else 0.0 + wb_daily = derived.bed_water_balance(rain_in, irrigation_in, etc_in) + + prev = storage.get_bed_agronomy_latest(bed_id) + is_good_soak = watering.get("quality") == "good_soak" + + # GDD is a phenology clock tied to planted_on -- it always accumulates, + # never resets on a watering event. Water balance resets to just + # today's value on a good soak (re-anchors "deficit since last real + # recharge"), same spirit as drydown_rate's post-watering re-anchor. + gdd_cum = gdd_today + (prev["gdd_cumulative"] if prev else 0.0) + wb_cum = wb_daily if (prev is None or is_good_soak) else prev["water_balance_cumulative"] + wb_daily + + storage.upsert_bed_agronomy( + bed_id, today_str, + tmax_f=temp_stats["max"], tmin_f=temp_stats["min"], + gdd_daily=gdd_today, gdd_cumulative=gdd_cum, + et0_in=et0_in, etc_in=etc_in, + rain_in=rain_in, irrigation_est_in=irrigation_in, + water_balance_daily=wb_daily, water_balance_cumulative=wb_cum, + reset_reason="good_soak" if is_good_soak else "", + ) + storage.set_alert_state(f"{_AGRONOMY_RULE_PREFIX}_{bed_id}", "", active=False, last_fired_ts=_now_iso()) + log.info( + "Agronomy accumulation %s: +%.1f GDD (%.1f total), water balance %+.2fin (%+.2fin total)", + bed_id, gdd_today, gdd_cum, wb_daily, wb_cum, + ) + + +def _maybe_daily_agronomy_accumulation() -> None: + run_daily_agronomy_accumulation(force=False) + + # ── CLI entry point (used by garden-cron.service) ──────────────────────────── if __name__ == "__main__": @@ -344,12 +476,16 @@ def _maybe_daily_brief() -> None: format="%(asctime)s %(levelname)s %(name)s: %(message)s", ) parser = argparse.ArgumentParser(description="garden-agent cron runner") - parser.add_argument("--cron", action="store_true", help="Run cron tick (rules + brief)") + parser.add_argument("--cron", action="store_true", help="Run cron tick (rules + brief + agronomy)") parser.add_argument("--brief", action="store_true", help="Force-send morning brief now (ignores hour/dedup)") + parser.add_argument("--agronomy", action="store_true", help="Force-run GDD/water-balance accumulation now (ignores hour/dedup)") args = parser.parse_args() if args.brief: storage.init_db() send_daily_brief(force=True) + elif args.agronomy: + storage.init_db() + run_daily_agronomy_accumulation(force=True) elif args.cron: run_cron_tick() diff --git a/garden/config.py b/garden/config.py index b7e96df..3097d3e 100644 --- a/garden/config.py +++ b/garden/config.py @@ -72,6 +72,7 @@ def __init__(self, raw: dict[str, Any]) -> None: self.daily_brief: dict[str, Any] = raw.get("daily_brief", {}) self.derived: dict[str, Any] = raw.get("derived", {}) self.crops: dict[str, Any] = raw.get("crops", {}) + self.agronomy: dict[str, Any] = raw.get("agronomy", {}) # ── helpers ─────────────────────────────────────────────────────────────── @@ -108,6 +109,13 @@ def bed_crops_label(self, sensor_key: str) -> str: from garden.derived import family_labels # lazy import avoids any import cycle return ", ".join(family_labels(bed.get("plants", []))) + def bed_planted_on(self, bed_id: str) -> str | None: + """planted_on date string ("YYYY-MM-DD") for a bed, or None if unset.""" + for bed in self.dashboard.get("beds", []): + if bed.get("id") == bed_id: + return bed.get("planted_on") + return None + # Module-level singleton — import and use anywhere: # from garden.config import cfg diff --git a/garden/dashboard/static/css/components.css b/garden/dashboard/static/css/components.css index 320656c..9e331a1 100644 --- a/garden/dashboard/static/css/components.css +++ b/garden/dashboard/static/css/components.css @@ -437,6 +437,12 @@ footer { .bed-detail-rate.is-down { color: var(--accent); } .bed-detail-rate.is-flat { color: var(--text-faint); } +.bed-detail-gdd { + font-size: 0.75rem; + color: var(--text-faint); + margin-bottom: 8px; +} + .bed-detail-chart { position: relative; height: 84px; diff --git a/garden/dashboard/static/js/garden.js b/garden/dashboard/static/js/garden.js index 197ca74..322866a 100644 --- a/garden/dashboard/static/js/garden.js +++ b/garden/dashboard/static/js/garden.js @@ -2028,6 +2028,9 @@ function _buildBedDetailHTML(bed, stress, waterBalanceIn) { const verdict = stale ? 'Check the sensor, no recent data' : _bedVerdict(stress ? stress.status : 'unknown', waterBalanceIn); + const gdd = LAST_INSIGHTS && LAST_INSIGHTS.gdd ? LAST_INSIGHTS.gdd[bed.id] : null; + const gddHTML = _buildGddLineHTML(gdd); + return ( '
' + '
' + plainLine + '
' + @@ -2037,6 +2040,7 @@ function _buildBedDetailHTML(bed, stress, waterBalanceIn) { rangeHTML + rateHTML + '
' + + gddHTML + '
' + '
' + battHTML + '
' + '
' + verdict + '
' + @@ -2044,6 +2048,29 @@ function _buildBedDetailHTML(bed, stress, waterBalanceIn) { ); } +/** GDD / growth-stage / harvest-projection line for the bed-detail panel, or + * '' when no accumulation row exists yet (new bed, cron hasn't run tonight). */ +function _buildGddLineHTML(gdd) { + if (!gdd || !gdd.stage) return ''; + + const STAGE_LABELS = { + germination: 'germination', vegetative: 'vegetative', flowering: 'flowering', + fruiting: 'fruiting', maturity: 'mature', unrecognized: null, + }; + const stageLabel = STAGE_LABELS[gdd.stage.stage]; + if (!stageLabel) return ''; + + const harvestBit = (gdd.harvest_projection && gdd.harvest_projection.label) + ? ' · harvest ' + gdd.harvest_projection.label + : ''; + + return ( + '
' + + Math.round(gdd.cumulative) + ' GDD · ' + stageLabel + harvestBit + + '
' + ); +} + /** Draws/updates the small per-bed moisture chart, reusing whatever the shared * refresh cycle already cached in seriesCache for that bed's sensor key. */ function _drawBedChart(bed) { @@ -2095,10 +2122,14 @@ function renderBedDetail() { const byId = {}; (LAST_INSIGHTS && LAST_INSIGHTS.beds || []).forEach(function (b) { byId[b.id] = b; }); - const wb = LAST_INSIGHTS && LAST_INSIGHTS.forecast ? LAST_INSIGHTS.forecast.water_balance_in : null; + const bedInsight = byId[bed.id]; + /* Prefer the bed's own accumulated water balance; fall back to the global + forecast-level figure for a brand-new bed before its first cron tick. */ + const globalWb = LAST_INSIGHTS && LAST_INSIGHTS.forecast ? LAST_INSIGHTS.forecast.water_balance_in : null; + const wb = (bedInsight && bedInsight.water_balance) ? bedInsight.water_balance.cumulative_in : globalWb; panel.hidden = false; - panel.innerHTML = _buildBedDetailHTML(bed, byId[bed.id], wb); + panel.innerHTML = _buildBedDetailHTML(bed, bedInsight, wb); _drawBedChart(bed); } diff --git a/garden/derived.py b/garden/derived.py index 17920c3..27e96e1 100644 --- a/garden/derived.py +++ b/garden/derived.py @@ -16,8 +16,17 @@ analyze_watering(samples) → {detected, baseline, peak, settled, quality, ...} drydown_rate(samples) → {per_day, per_hour, n_points, reason} days_until_dry(moist, rate, dry_threshold) → {days, label} + gdd_daily(tmax_f, tmin_f, base_f) → float (°F-days, never negative) + gdd_base_for_bed(plants) → (base_f, reference_crop) | None + gdd_growth_stage(cumulative_gdd, crop_key) → {stage, pct_to_maturity, ...} + project_harvest_date(cum_gdd, maturity, avg_rate, today) → {days, date, label} + etc_from_kc(et0_in, kc) → float (inches) — ETc = ET0 x Kc + estimated_irrigation_in(absorbed_pct, root_zone_in, awc) → float (inches, modeled estimate) + bed_water_balance(rain, irrigation, etc) → float (inches, positive = surplus) CROP_RANGES — default ideal soil-moisture/temp ranges per vegetable type. +GDD_BASE_F / GDD_STAGES / KC_MID — GDD base temps, growth-stage breakpoints, + and crop coefficients per vegetable type (see the GDD section below). Watering-lifecycle functions (analyze_watering/drydown_rate/days_until_dry) take samples as list[tuple[float, float]] of (epoch_seconds, moisture_pct), oldest→newest. @@ -30,6 +39,7 @@ import math import statistics +from datetime import date, timedelta from typing import Any @@ -193,6 +203,83 @@ def frost_risk(dewpoint_f_val: float, frost_threshold_f: float = 35.6) -> tuple[ } +# ── GDD (Growing Degree Day) reference data ─────────────────────────────────── + +# Base temperature (Tbase, °F) below which a crop accrues no growth for the +# day. Standard agronomic consensus values (NOAA/university-extension GDD +# guides), one entry per CROP_RANGES key — variants share their family's +# Tbase since base temperature doesn't vary by fruit color/variety: +# - Warm-season fruiting crops (tomato / eggplant / sweet & hot pepper): 50°F +# - Okra / zucchini (higher heat requirement): 55°F +# - Peas (cool-season legume): 40°F +GDD_BASE_F: dict[str, float] = { + "tomato": 50.0, + "tomato_cherry": 50.0, + "tomato_roma": 50.0, + "tomato_beefsteak": 50.0, + "tomato_heirloom": 50.0, + "tomato_grape": 50.0, + "tomato_san_marzano": 50.0, + "eggplant": 50.0, + "okra": 55.0, + "peas": 40.0, + "sweet_pepper": 50.0, + "sweet_pepper_red": 50.0, + "sweet_pepper_green": 50.0, + "sweet_pepper_yellow": 50.0, + "sweet_pepper_orange": 50.0, + "hot_pepper": 50.0, + "zucchini": 55.0, +} + +# Cumulative-GDD breakpoints (°F-days, base per GDD_BASE_F) marking the START +# of each growth stage; "maturity" is the first-harvest target. One entry per +# crop FAMILY (not variant, unlike CROP_RANGES/GDD_BASE_F) — stage-timing +# research doesn't distinguish tomato colors. Sourced from typical extension- +# service GDD-to-maturity tables; treat as rough midpoints for common +# varieties, not variety-specific data — same "good enough, documented" +# spirit as heat_index_f's regression validity bounds. +GDD_STAGES: dict[str, dict[str, float]] = { + "tomato": {"germination": 0, "vegetative": 90, "flowering": 400, "fruiting": 700, "maturity": 1200}, + "eggplant": {"germination": 0, "vegetative": 100, "flowering": 450, "fruiting": 750, "maturity": 1300}, + "okra": {"germination": 0, "vegetative": 80, "flowering": 350, "fruiting": 550, "maturity": 900}, + "peas": {"germination": 0, "vegetative": 60, "flowering": 250, "fruiting": 400, "maturity": 600}, + "sweet_pepper": {"germination": 0, "vegetative": 110, "flowering": 500, "fruiting": 800, "maturity": 1400}, + "hot_pepper": {"germination": 0, "vegetative": 110, "flowering": 500, "fruiting": 800, "maturity": 1500}, + "zucchini": {"germination": 0, "vegetative": 60, "flowering": 200, "fruiting": 350, "maturity": 550}, +} +_GDD_STAGE_ORDER = ("germination", "vegetative", "flowering", "fruiting", "maturity") + +# Flat FAO-56 mid-season crop coefficient (Kc) per crop family — a single +# average value rather than staged Kc-ini/Kc-mid/Kc-late. Proportionate for a +# home dashboard: slightly over-estimates ETc during germination and under- +# estimates during late senescence, but avoids a full dual-crop-coefficient +# model. (Staging Kc by gdd_growth_stage()'s result is a cheap v2 if needed.) +KC_MID: dict[str, float] = { + "tomato": 1.15, + "eggplant": 1.05, + "okra": 1.05, + "peas": 1.15, + "sweet_pepper": 1.05, + "hot_pepper": 1.05, + "zucchini": 1.00, +} + + +def _gdd_family(crop_key: str) -> str | None: + """ + Resolve a crop variant (e.g. 'tomato_cherry', 'sweet_pepper_red') to its + GDD_STAGES/KC_MID reference family key (e.g. 'tomato', 'sweet_pepper'). + Returns None for unrecognised keys. + """ + if crop_key in GDD_STAGES: + return crop_key + for fam in sorted(GDD_STAGES, key=len, reverse=True): + if crop_key.startswith(fam + "_"): + return fam + return None + + def family_labels(plants: list[str]) -> list[str]: """ Collapse a bed's plant list to unique lowercase crop-family labels, order preserved. @@ -583,3 +670,205 @@ def days_until_dry( label = f"~{n} day" if n == 1 else f"~{n} days" return {"days": days, "label": label} + + +# ── Growing Degree Days + per-bed ET/water balance ─────────────────────────── + +def gdd_daily(tmax_f: float, tmin_f: float, base_temp_f: float) -> float: + """ + Single-day Growing Degree Days: (Tmax+Tmin)/2 - Tbase. + + Tmax/Tmin are floor-clamped to base_temp_f BEFORE averaging — the + standard agronomic convention (NOAA/extension-service GDD guides): a day + whose entire range sits below base contributes exactly 0, and a day + where only the low dips below base isn't artificially deflated by + averaging in a below-base low. Never negative. + """ + tmax = max(tmax_f, base_temp_f) + tmin = max(tmin_f, base_temp_f) + return max(0.0, (tmax + tmin) / 2.0 - base_temp_f) + + +def gdd_base_for_bed( + plants: list[str], + custom_bases: dict[str, float] | None = None, +) -> tuple[float, str] | None: + """ + (Tbase °F, reference crop key) for a bed's recognised crops. + + Tbase is the HIGHEST base temperature among the bed's crops — the most + conservative choice (mirrors bed_moisture_band's intersection logic): no + GDD accrues on a day too cold for the pickiest crop in the bed. The crop + that produced that Tbase also doubles as the bed's reference crop for + gdd_growth_stage()/KC_MID lookups, since a mixed bed has no single true + growth curve — using the pickiest crop keeps both numbers consistent + with each other without a separate "primary crop" config field. + + Returns None when no recognised crop is in `plants`. + """ + bases = dict(GDD_BASE_F) + if custom_bases: + bases.update(custom_bases) + + candidates = [(bases[p], p) for p in plants if p in bases] + if not candidates: + return None + base_f, crop_key = max(candidates, key=lambda c: c[0]) + return base_f, crop_key + + +def gdd_growth_stage(cumulative_gdd: float, crop_key: str) -> dict[str, Any]: + """ + Classify a bed's cumulative GDD into a growth stage for `crop_key` + (resolved to its GDD_STAGES family via _gdd_family — pass either a + variant like 'tomato_cherry' or a family key like 'tomato'). + + Returns: + { + "stage": "germination"|"vegetative"|"flowering"|"fruiting"|"maturity"|"unrecognized", + "pct_to_maturity": 0-100+ (can exceed 100 once past maturity), or None if unrecognized, + "gdd_into_stage": GDD accrued since this stage's breakpoint, or None if unrecognized, + "gdd_to_next_stage": GDD remaining to the next breakpoint, or None at/after maturity/unrecognized, + } + """ + fam = _gdd_family(crop_key) + if fam is None: + return { + "stage": "unrecognized", + "pct_to_maturity": None, + "gdd_into_stage": None, + "gdd_to_next_stage": None, + } + + breakpoints = GDD_STAGES[fam] + maturity = breakpoints["maturity"] + pct = round((cumulative_gdd / maturity) * 100.0, 1) if maturity else None + + stage = _GDD_STAGE_ORDER[0] + next_gdd: float | None = None + for i, name in enumerate(_GDD_STAGE_ORDER): + if cumulative_gdd >= breakpoints[name]: + stage = name + next_gdd = ( + breakpoints[_GDD_STAGE_ORDER[i + 1]] + if i + 1 < len(_GDD_STAGE_ORDER) else None + ) + else: + break + + gdd_into_stage = cumulative_gdd - breakpoints[stage] + gdd_to_next_stage = (next_gdd - cumulative_gdd) if next_gdd is not None else None + + return { + "stage": stage, + "pct_to_maturity": pct, + "gdd_into_stage": round(gdd_into_stage, 1), + "gdd_to_next_stage": round(gdd_to_next_stage, 1) if gdd_to_next_stage is not None else None, + } + + +def project_harvest_date( + cumulative_gdd: float, + maturity_gdd: float, + avg_gdd_per_day: float | None, + today: date, +) -> dict[str, Any]: + """ + Project the harvest (maturity) date from the current GDD pace. + + Mirrors days_until_dry()'s shape/philosophy: {days, date, label}. days is + None (label "not enough data") when avg_gdd_per_day is None/zero/negative. + Already-mature beds return {"days": 0.0, ..., "label": "ready"}. Far + projections (>=60 days out) clamp to a "60+ days" label rather than a + false-precise date, same spirit as days_until_dry's "2+ weeks" clamp. + """ + remaining = maturity_gdd - cumulative_gdd + if remaining <= 0: + return {"days": 0.0, "date": today.isoformat(), "label": "ready"} + + if avg_gdd_per_day is None or avg_gdd_per_day <= 0: + return {"days": None, "date": None, "label": "not enough data"} + + days = remaining / avg_gdd_per_day + + if days >= 60: + return {"days": days, "date": None, "label": "60+ days"} + + harvest_date = today + timedelta(days=round(days)) + n = round(days) + label = f"~{n} day" if n == 1 else f"~{n} days" + return {"days": days, "date": harvest_date.isoformat(), "label": label} + + +def maturity_gdd_for_crop(crop_key: str) -> float | None: + """ + Cumulative GDD (°F-days) at maturity/first-harvest for `crop_key` + (resolved via _gdd_family — accepts a variant like 'tomato_cherry' or a + family key like 'tomato'). Returns None for unrecognised crops. + """ + fam = _gdd_family(crop_key) + if fam is None: + return None + return GDD_STAGES[fam]["maturity"] + + +def kc_for_crop(crop_key: str, custom_kc: dict[str, float] | None = None) -> float | None: + """ + FAO-56 mid-season crop coefficient for `crop_key` (resolved via + _gdd_family — accepts a variant like 'tomato_cherry' or a family key + like 'tomato'). Returns None for unrecognised crops. + """ + kc_table = dict(KC_MID) + if custom_kc: + kc_table.update(custom_kc) + fam = _gdd_family(crop_key) + if fam is None: + return None + return kc_table.get(fam) + + +def etc_from_kc(et0_in: float, kc: float) -> float: + """ + Crop evapotranspiration (FAO-56): ETc = ET0 x Kc. + + et0_in: reference evapotranspiration in inches (e.g. Open-Meteo's + et0_fao_evapotranspiration — already the Penman-Monteith standard). + kc: crop coefficient, e.g. from KC_MID. + """ + return et0_in * kc + + +def estimated_irrigation_in( + absorbed_moisture_pct: float, + root_zone_depth_in: float, + awc_in_per_in: float, +) -> float: + """ + MODELED ESTIMATE of irrigation applied, not a direct measurement — there + is no flow meter or rain gauge on the beds. Converts a soil-moisture-% + rise (analyze_watering()'s `absorbed` field, from the WH51 sensor) into + an inches-of-water equivalent, assuming the rise is uniform across the + effective root zone: + + inches = (absorbed_pct / 100) * root_zone_depth_in * awc_in_per_in + + awc_in_per_in: available water capacity of the soil, inches of water per + inch of soil depth. Typical raised-bed potting-mix blends + run ~0.15-0.20 in/in. + root_zone_depth_in: effective root zone depth in inches (shallower for + peas ~6in, deeper for tomato/eggplant ~10-12in). + + Clamped to >= 0 — a moisture drop is not negative irrigation. + """ + absorbed = max(0.0, absorbed_moisture_pct) + return (absorbed / 100.0) * root_zone_depth_in * awc_in_per_in + + +def bed_water_balance(rain_in: float, irrigation_in: float, etc_in: float) -> float: + """ + Net daily per-bed water balance in inches: rain + irrigation - ETc. + + Same sign convention as et0_water_balance(): positive = surplus (bed + received more water than it used), negative = deficit (needs watering). + """ + return rain_in + irrigation_in - etc_in diff --git a/garden/main.py b/garden/main.py index 7bf35b3..4845b20 100644 --- a/garden/main.py +++ b/garden/main.py @@ -14,9 +14,12 @@ import json import logging +import statistics from contextlib import asynccontextmanager +from datetime import datetime from pathlib import Path from typing import Any +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from fastapi import BackgroundTasks, FastAPI, Query, Request from fastapi.responses import JSONResponse @@ -55,6 +58,16 @@ async def lifespan(app: FastAPI): app.mount("/static", StaticFiles(directory=_STATIC), name="static") +def _local_today(): + """Today's date in the configured timezone (for GDD harvest projections).""" + tz_name = cfg.location.get("timezone", "UTC") + try: + tz = ZoneInfo(tz_name) + except ZoneInfoNotFoundError: + tz = ZoneInfo("UTC") + return datetime.now(tz).date() + + # ── /health ─────────────────────────────────────────────────────────────────── @app.get("/health") @@ -199,7 +212,9 @@ async def api_insights() -> JSONResponse: air_temp_f = air_temp_row["value"] if air_temp_row else None bed_results: list[dict[str, Any]] = [] + gdd_results: dict[str, Any] = {} for bed in cfg.dashboard.get("beds", []): + bed_id = bed.get("id") moist_key = bed.get("sensors", {}).get("soil_moisture") moist_row = latest_map.get(moist_key) if moist_key else None soil_moist = moist_row["value"] if moist_row else None @@ -219,13 +234,49 @@ async def api_insights() -> JSONResponse: "crops": [], } + agro = storage.get_bed_agronomy_latest(bed_id) if bed_id else None + + water_balance = None + if agro is not None: + wb_cum = agro["water_balance_cumulative"] + water_balance = { + "etc_in": agro.get("etc_in"), + "irrigation_est_in": agro.get("irrigation_est_in"), + "rain_in": agro.get("rain_in"), + "cumulative_in": wb_cum, + "status": "Surplus" if wb_cum > 0.05 else "Deficit" if wb_cum < -0.05 else "Even", + } + bed_results.append({ - "id": bed.get("id"), + "id": bed_id, "name": bed.get("name"), **stress, + "water_balance": water_balance, }) + if agro is not None: + base = drv.gdd_base_for_bed(bed.get("plants", []), cfg.agronomy.get("gdd_base_overrides")) + ref_crop = base[1] if base else None + stage = drv.gdd_growth_stage(agro["gdd_cumulative"], ref_crop) if ref_crop else None + + harvest = None + if ref_crop: + maturity = drv.maturity_gdd_for_crop(ref_crop) + if maturity: + trailing = storage.bed_agronomy_series(bed_id, days=7) + rates = [r["gdd_daily"] for r in trailing if r.get("gdd_daily") is not None] + avg_rate = statistics.mean(rates) if rates else None + harvest = drv.project_harvest_date(agro["gdd_cumulative"], maturity, avg_rate, _local_today()) + + gdd_results[bed_id] = { + "cumulative": round(agro["gdd_cumulative"], 1), + "stage": stage, + "harvest_projection": harvest, + "planted_on": cfg.bed_planted_on(bed_id), + } + insights["beds"] = bed_results + insights["gdd"] = gdd_results # ── 24h min/max stats — scoped to only the sensors the UI actually renders ── stat_keys: set[str] = {"vpd_kpa"} diff --git a/garden/storage.py b/garden/storage.py index a0a86ea..9143e55 100644 --- a/garden/storage.py +++ b/garden/storage.py @@ -79,6 +79,23 @@ def _conn() -> Generator[sqlite3.Connection, None, None]: active INTEGER NOT NULL DEFAULT 0, -- 1 = condition currently tripped last_fired_ts TEXT NOT NULL DEFAULT '' ); + +CREATE TABLE IF NOT EXISTS bed_daily_agronomy ( + bed_id TEXT NOT NULL, + local_date TEXT NOT NULL, -- YYYY-MM-DD in the configured timezone + tmax_f REAL, + tmin_f REAL, + gdd_daily REAL, + gdd_cumulative REAL, + et0_in REAL, + etc_in REAL, + rain_in REAL, + irrigation_est_in REAL, + water_balance_daily REAL, + water_balance_cumulative REAL, + reset_reason TEXT NOT NULL DEFAULT '', -- 'good_soak' when water_balance_cumulative reset that day + PRIMARY KEY (bed_id, local_date) +); """ @@ -278,3 +295,67 @@ def set_alert_state( """, (rule_id, sensor_key, int(active), last_fired_ts), ) + + +# ── bed_daily_agronomy helpers (GDD + per-bed ET/water-balance accumulation) ── + +_AGRONOMY_COLUMNS = ( + "tmax_f", "tmin_f", "gdd_daily", "gdd_cumulative", + "et0_in", "etc_in", "rain_in", "irrigation_est_in", + "water_balance_daily", "water_balance_cumulative", "reset_reason", +) + + +def get_bed_agronomy_latest(bed_id: str) -> dict[str, Any] | None: + """Most recent bed_daily_agronomy row for a bed, or None if it has none yet.""" + with _conn() as con: + row = con.execute( + """ + SELECT * FROM bed_daily_agronomy + WHERE bed_id = ? + ORDER BY local_date DESC LIMIT 1 + """, + (bed_id,), + ).fetchone() + return dict(row) if row else None + + +def upsert_bed_agronomy(bed_id: str, local_date: str, **fields: Any) -> None: + """ + Insert or overwrite the (bed_id, local_date) row. Only keys in + _AGRONOMY_COLUMNS are accepted, so callers must pass exactly the + expected fields (matches set_alert_state's ON CONFLICT upsert style). + """ + unknown = set(fields) - set(_AGRONOMY_COLUMNS) + if unknown: + raise ValueError(f"Unknown bed_daily_agronomy column(s): {sorted(unknown)}") + + columns = list(fields.keys()) + values = [fields[c] for c in columns] + placeholders = ", ".join("?" for _ in columns) + update_clause = ", ".join(f"{c} = excluded.{c}" for c in columns) + + with _conn() as con: + con.execute( + f""" + INSERT INTO bed_daily_agronomy(bed_id, local_date, {", ".join(columns)}) + VALUES (?, ?, {placeholders}) + ON CONFLICT(bed_id, local_date) DO UPDATE SET {update_clause} + """, + (bed_id, local_date, *values), + ) + + +def bed_agronomy_series(bed_id: str, days: int = 30) -> list[dict[str, Any]]: + """Trailing `days` calendar days of bed_daily_agronomy rows, oldest → newest.""" + cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).date().isoformat() + with _conn() as con: + rows = con.execute( + """ + SELECT * FROM bed_daily_agronomy + WHERE bed_id = ? AND local_date >= ? + ORDER BY local_date ASC + """, + (bed_id, cutoff), + ).fetchall() + return [dict(r) for r in rows] diff --git a/tests/test_config.py b/tests/test_config.py index 6e8f740..aa4b236 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -30,3 +30,14 @@ def test_daily_brief_defaults(): def test_timezone_set(): # Confirm the env-var timezone is forwarded into cfg.location assert cfg.location["timezone"] == "America/Chicago" + + +def test_agronomy_defaults(): + assert cfg.agronomy.get("enabled", True) is True + assert cfg.agronomy.get("gdd_temp_key", "temp_f") == "temp_f" + assert cfg.agronomy.get("accumulation_hour_local", 23) != cfg.daily_brief.get("hour_local", 7) + + +def test_bed_planted_on(): + assert cfg.bed_planted_on("bed1") == "2026-04-15" + assert cfg.bed_planted_on("no_such_bed") is None diff --git a/tests/test_derived.py b/tests/test_derived.py index b7252cf..838ad01 100644 --- a/tests/test_derived.py +++ b/tests/test_derived.py @@ -6,6 +6,7 @@ """ import math +from datetime import date import pytest @@ -14,12 +15,21 @@ analyze_watering, bed_moisture_band, bed_stress, + bed_water_balance, days_until_dry, dew_point_f, drydown_rate, + estimated_irrigation_in, et0_water_balance, + etc_from_kc, frost_risk, + gdd_base_for_bed, + gdd_daily, + gdd_growth_stage, heat_index_f, + kc_for_crop, + maturity_gdd_for_crop, + project_harvest_date, vpd_kpa, vpd_status, ) @@ -455,3 +465,179 @@ def test_label_pluralization(self): two = days_until_dry(38.0, 4.0, 30.0) assert one["label"] == "~1 day" assert two["label"] == "~2 days" + + +# ── gdd_daily ───────────────────────────────────────────────────────────────── + +class TestGddDaily: + def test_known_value(self): + # (90+60)/2 - 50 = 25.0 + assert gdd_daily(90.0, 60.0, 50.0) == pytest.approx(25.0) + + def test_entire_range_below_base(self): + # Both tmax/tmin below base -> clamped to base -> 0 GDD + assert gdd_daily(45.0, 30.0, 50.0) == pytest.approx(0.0) + + def test_low_only_below_base_clamped(self): + # tmin clamped to base before averaging: (80+50)/2 - 50 = 15.0, not (80+30)/2-50=5.0 + assert gdd_daily(80.0, 30.0, 50.0) == pytest.approx(15.0) + + def test_never_negative(self): + assert gdd_daily(20.0, 10.0, 50.0) >= 0.0 + + +# ── gdd_base_for_bed ────────────────────────────────────────────────────────── + +class TestGddBaseForBed: + def test_picks_highest_base(self): + # eggplant (50) + okra (55) -> okra's higher base wins + result = gdd_base_for_bed(["eggplant", "okra", "okra"]) + assert result == (55.0, "okra") + + def test_variants_resolve_to_family_base(self): + result = gdd_base_for_bed(["tomato_cherry", "tomato_roma"]) + assert result == (50.0, "tomato_cherry") or result == (50.0, "tomato_roma") + assert result[0] == 50.0 + + def test_no_recognised_crops(self): + assert gdd_base_for_bed(["unknown_plant"]) is None + + def test_custom_base_override(self): + result = gdd_base_for_bed(["eggplant", "okra"], custom_bases={"eggplant": 60.0}) + assert result == (60.0, "eggplant") + + +# ── gdd_growth_stage ────────────────────────────────────────────────────────── + +class TestGddGrowthStage: + def test_germination(self): + result = gdd_growth_stage(50.0, "tomato") + assert result["stage"] == "germination" + assert result["gdd_into_stage"] == pytest.approx(50.0) + assert result["gdd_to_next_stage"] == pytest.approx(40.0) + + def test_flowering(self): + result = gdd_growth_stage(500.0, "tomato") + assert result["stage"] == "flowering" + assert result["gdd_into_stage"] == pytest.approx(100.0) + assert result["gdd_to_next_stage"] == pytest.approx(200.0) + assert result["pct_to_maturity"] == pytest.approx(500.0 / 1200 * 100, abs=0.1) + + def test_variant_resolves_to_family(self): + result = gdd_growth_stage(500.0, "tomato_cherry") + assert result["stage"] == "flowering" + + def test_at_maturity(self): + result = gdd_growth_stage(1200.0, "tomato") + assert result["stage"] == "maturity" + assert result["gdd_to_next_stage"] is None + assert result["pct_to_maturity"] == pytest.approx(100.0) + + def test_past_maturity(self): + result = gdd_growth_stage(1500.0, "tomato") + assert result["stage"] == "maturity" + assert result["pct_to_maturity"] > 100.0 + + def test_unrecognized_crop(self): + result = gdd_growth_stage(500.0, "unknown_plant") + assert result["stage"] == "unrecognized" + assert result["pct_to_maturity"] is None + + +# ── project_harvest_date ────────────────────────────────────────────────────── + +class TestProjectHarvestDate: + def test_known_projection(self): + result = project_harvest_date(700.0, 1200.0, 10.0, date(2026, 7, 8)) + assert result["days"] == pytest.approx(50.0) + assert result["label"] == "~50 days" + assert result["date"] == "2026-08-27" + + def test_already_mature(self): + result = project_harvest_date(1300.0, 1200.0, 10.0, date(2026, 7, 8)) + assert result["days"] == 0.0 + assert result["label"] == "ready" + assert result["date"] == "2026-07-08" + + def test_no_rate_data(self): + result = project_harvest_date(500.0, 1200.0, None, date(2026, 7, 8)) + assert result["days"] is None + assert result["label"] == "not enough data" + + def test_zero_rate(self): + result = project_harvest_date(500.0, 1200.0, 0.0, date(2026, 7, 8)) + assert result["days"] is None + + def test_far_horizon_clamp(self): + result = project_harvest_date(100.0, 1200.0, 5.0, date(2026, 7, 8)) + assert result["days"] >= 60 + assert result["label"] == "60+ days" + assert result["date"] is None + + +# ── maturity_gdd_for_crop ───────────────────────────────────────────────────── + +class TestMaturityGddForCrop: + def test_family_key(self): + assert maturity_gdd_for_crop("tomato") == 1200 + + def test_variant_resolves_to_family(self): + assert maturity_gdd_for_crop("sweet_pepper_yellow") == 1400 + + def test_unrecognized_returns_none(self): + assert maturity_gdd_for_crop("unknown_plant") is None + + +# ── kc_for_crop ─────────────────────────────────────────────────────────────── + +class TestKcForCrop: + def test_family_key(self): + assert kc_for_crop("tomato") == pytest.approx(1.15) + + def test_variant_resolves_to_family(self): + assert kc_for_crop("tomato_cherry") == pytest.approx(1.15) + assert kc_for_crop("sweet_pepper_red") == pytest.approx(1.05) + + def test_unrecognized_returns_none(self): + assert kc_for_crop("unknown_plant") is None + + def test_custom_override(self): + assert kc_for_crop("tomato", custom_kc={"tomato": 1.3}) == pytest.approx(1.3) + + +# ── etc_from_kc ─────────────────────────────────────────────────────────────── + +class TestEtcFromKc: + def test_known_value(self): + assert etc_from_kc(0.2, 1.15) == pytest.approx(0.23) + + def test_zero_et0(self): + assert etc_from_kc(0.0, 1.15) == pytest.approx(0.0) + + +# ── estimated_irrigation_in ─────────────────────────────────────────────────── + +class TestEstimatedIrrigationIn: + def test_known_value(self): + # 20% absorbed, 9in root zone, 0.17 in/in AWC -> 0.306in + assert estimated_irrigation_in(20.0, 9.0, 0.17) == pytest.approx(0.306) + + def test_zero_absorbed(self): + assert estimated_irrigation_in(0.0, 9.0, 0.17) == pytest.approx(0.0) + + def test_negative_absorbed_clamped(self): + assert estimated_irrigation_in(-5.0, 9.0, 0.17) == pytest.approx(0.0) + + +# ── bed_water_balance ───────────────────────────────────────────────────────── + +class TestBedWaterBalance: + def test_surplus(self): + # 0.1 rain + 0.3 irrigation - 0.25 etc = 0.15 surplus + assert bed_water_balance(0.1, 0.3, 0.25) == pytest.approx(0.15) + + def test_deficit(self): + assert bed_water_balance(0.0, 0.0, 0.25) == pytest.approx(-0.25) + + def test_even(self): + assert bed_water_balance(0.1, 0.0, 0.1) == pytest.approx(0.0) diff --git a/tests/test_storage.py b/tests/test_storage.py index 7afabae..5f9693b 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -116,3 +116,52 @@ def test_wide_window_downsamples(self, db): assert 0 < len(rows) < 480 # Endpoints of the window are still represented. assert rows[0]["ts"] < rows[-1]["ts"] + + +class TestBedDailyAgronomy: + def test_no_rows_returns_none(self, db): + assert db.get_bed_agronomy_latest("bed1") is None + + def test_upsert_then_read_latest(self, db): + db.upsert_bed_agronomy( + "bed1", "2026-07-07", + tmax_f=90.0, tmin_f=65.0, gdd_daily=27.5, gdd_cumulative=27.5, + et0_in=0.2, etc_in=0.23, rain_in=0.0, irrigation_est_in=0.0, + water_balance_daily=-0.23, water_balance_cumulative=-0.23, + reset_reason="", + ) + row = db.get_bed_agronomy_latest("bed1") + assert row["local_date"] == "2026-07-07" + assert row["gdd_cumulative"] == 27.5 + assert row["water_balance_cumulative"] == -0.23 + + def test_latest_picks_most_recent_date(self, db): + db.upsert_bed_agronomy("bed1", "2026-07-06", gdd_daily=20.0, gdd_cumulative=20.0) + db.upsert_bed_agronomy("bed1", "2026-07-07", gdd_daily=25.0, gdd_cumulative=45.0) + row = db.get_bed_agronomy_latest("bed1") + assert row["local_date"] == "2026-07-07" + assert row["gdd_cumulative"] == 45.0 + + def test_upsert_overwrites_same_day_not_duplicates(self, db): + db.upsert_bed_agronomy("bed1", "2026-07-07", gdd_daily=10.0, gdd_cumulative=10.0) + db.upsert_bed_agronomy("bed1", "2026-07-07", gdd_daily=12.0, gdd_cumulative=12.0) + series = db.bed_agronomy_series("bed1", days=30) + assert len(series) == 1 + assert series[0]["gdd_cumulative"] == 12.0 + + def test_beds_are_independent(self, db): + db.upsert_bed_agronomy("bed1", "2026-07-07", gdd_cumulative=10.0) + db.upsert_bed_agronomy("bed2", "2026-07-07", gdd_cumulative=99.0) + assert db.get_bed_agronomy_latest("bed1")["gdd_cumulative"] == 10.0 + assert db.get_bed_agronomy_latest("bed2")["gdd_cumulative"] == 99.0 + + def test_unknown_column_rejected(self, db): + with pytest.raises(ValueError): + db.upsert_bed_agronomy("bed1", "2026-07-07", not_a_real_column=1.0) + + def test_series_ordered_oldest_to_newest(self, db): + db.upsert_bed_agronomy("bed1", "2026-07-05", gdd_daily=5.0) + db.upsert_bed_agronomy("bed1", "2026-07-07", gdd_daily=7.0) + db.upsert_bed_agronomy("bed1", "2026-07-06", gdd_daily=6.0) + series = db.bed_agronomy_series("bed1", days=30) + assert [r["local_date"] for r in series] == ["2026-07-05", "2026-07-06", "2026-07-07"] From a5c0853bdd4633fdf2036967991f8eecb1a4cdf9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 23:37:13 +0000 Subject: [PATCH 2/5] Set actual planted_on date (May 15, 2026) for all beds Corrects the placeholder GDD accumulation start date to the real planting date across all four beds. --- config.yaml | 8 ++++---- tests/test_config.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/config.yaml b/config.yaml index 8d8247f..14ebb70 100644 --- a/config.yaml +++ b/config.yaml @@ -99,23 +99,23 @@ dashboard: name: "Bed 1" sensors: {soil_moisture: soilmoisture1, soil_battery: soilbatt1} plants: [tomato_cherry, tomato_roma, tomato_beefsteak, tomato_heirloom, tomato_grape, tomato_san_marzano] - planted_on: "2026-04-15" + planted_on: "2026-05-15" - id: bed2 name: "Bed 2" sensors: {soil_moisture: soilmoisture2, soil_battery: soilbatt2} plants: [eggplant, okra, okra, eggplant, okra, okra, okra, okra, okra, okra, okra, okra] - planted_on: "2026-04-15" + planted_on: "2026-05-15" - id: bed3 name: "Bed 3" sensors: {soil_moisture: soilmoisture3, soil_battery: soilbatt3} plants: [sweet_pepper_red, sweet_pepper_yellow, sweet_pepper_green, sweet_pepper_orange] - planted_on: "2026-04-15" + planted_on: "2026-05-15" - id: bed4 name: "Bed 4" sensors: {soil_moisture: soilmoisture4, soil_battery: soilbatt4} plants: [zucchini, eggplant] layout: vertical # stack plants in a single column instead of side-by-side - planted_on: "2026-04-15" + planted_on: "2026-05-15" weather_keys: {temp: temp1_f, humidity: humidity1, pressure: baromrel_inhg} stat_groups: - name: "Bed 1" diff --git a/tests/test_config.py b/tests/test_config.py index aa4b236..5259116 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -39,5 +39,5 @@ def test_agronomy_defaults(): def test_bed_planted_on(): - assert cfg.bed_planted_on("bed1") == "2026-04-15" + assert cfg.bed_planted_on("bed1") == "2026-05-15" assert cfg.bed_planted_on("no_such_bed") is None From 85e124d13b9780bda4b90a06c1a264169b9c4180 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 23:44:49 +0000 Subject: [PATCH 3/5] Add per-bed GDD accumulation chart to the Trends section Renders a new row of 4 small-multiple charts showing each bed's cumulative Growing Degree Days since planting, sourced from the new /api/agronomy_series endpoint (backed by bed_daily_agronomy). Kept independent of the existing 1h/3h/12h/24h/7d Trends range control since GDD is a once-daily accumulator, not a live sensor series. --- garden/dashboard/static/css/components.css | 12 ++++ garden/dashboard/static/js/garden.js | 62 +++++++++++++++++++ .../templates/partials/_climate-trends.html | 4 ++ garden/main.py | 17 +++++ 4 files changed, 95 insertions(+) diff --git a/garden/dashboard/static/css/components.css b/garden/dashboard/static/css/components.css index 9e331a1..e10fcaf 100644 --- a/garden/dashboard/static/css/components.css +++ b/garden/dashboard/static/css/components.css @@ -336,6 +336,18 @@ .bed-moisture-row { grid-template-columns: 1fr; } } +.bed-gdd-row { + grid-template-columns: repeat(4, 1fr); + padding-top: 0; +} + +@media (max-width: 900px) { + .bed-gdd-row { grid-template-columns: repeat(2, 1fr); } +} +@media (max-width: 720px) { + .bed-gdd-row { grid-template-columns: 1fr; } +} + /* ── EMPTY STATE ────────────────────────────────────────────────────── */ .empty { diff --git a/garden/dashboard/static/js/garden.js b/garden/dashboard/static/js/garden.js index 322866a..0ed2f60 100644 --- a/garden/dashboard/static/js/garden.js +++ b/garden/dashboard/static/js/garden.js @@ -1539,6 +1539,66 @@ function renderBedMoistureCards() { }).join(''); } +/* ════════════════════════════════════════════════════════════════════════════ + PER-BED GDD ACCUMULATION ROW + Season-to-date cumulative Growing Degree Days per bed, from the new + bed_daily_agronomy history (garden/storage.py bed_agronomy_series()) -- + a once-daily accumulator, not a live sensor series, so it's fetched from + its own endpoint and kept out of the 1h/3h/12h/24h/7d range control. + ════════════════════════════════════════════════════════════════════════════ */ + +/** Cache of latest bed_daily_agronomy rows, keyed by bed id. */ +const agronomySeriesCache = {}; + +/** Format a YYYY-MM-DD date string as "Jul 08" for chart axis ticks. */ +function fmtDate(isoDate) { + const d = new Date(isoDate + 'T00:00:00'); + return d.toLocaleDateString([], { month: 'short', day: '2-digit' }); +} + +/** Build the 4 per-bed GDD chart-cards (one row), mirroring renderBedMoistureCards(). */ +function renderBedGddCards() { + const grid = document.getElementById('bed-gdd-grid'); + if (!grid || !BEDS.length) return; + grid.innerHTML = BEDS.map(function (bed) { + return ( + '
' + + '
' + bedEmoji(bed.plants) + ' ' + bed.name + ' · GDD to date
' + + '
' + + '
' + ); + }).join(''); +} + +/** Fetches + draws one bed's cumulative-GDD chart from bed_daily_agronomy history. */ +async function loadAgronomyChart(bed) { + const resp = await fetch('/api/agronomy_series?bed=' + encodeURIComponent(bed.id) + '&days=120'); + if (!resp.ok) return; + const rows = await resp.json(); + agronomySeriesCache[bed.id] = rows; + + const canvas = document.getElementById('chart-gdd-' + bed.id); + if (!canvas) return; + + const labels = rows.map(function (r) { return fmtDate(r.local_date); }); + const data = rows.map(function (r) { return r.gdd_cumulative; }); + const color = cfg_moisture_color(bed); + + const instKey = 'gdd-' + bed.id; + let chart = instances[instKey]; + if (!chart) { + chart = new Chart(canvas, makeChartOpts(color)); + instances[instKey] = chart; + } + chart.data.labels = labels; + chart.data.datasets[0].data = data; + chart._bands = []; + chart._lines = []; + chart._wateringEvents = []; + chart._projection = null; + chart.update('none'); +} + /* ════════════════════════════════════════════════════════════════════════════ TRENDS — grouped climate charts (region D) — redesign.md §6 Outdoor + gazebo series share one card/axis per family instead of a card @@ -2187,6 +2247,7 @@ async function refresh() { so the chip always painted from the prior cycle's conditions. */ var chartLoads = CHARTS.map(function (c) { return loadChart(c.key, c.color); }); chartLoads = chartLoads.concat(MOISTURE_GROUP.map(function (m) { return loadChart(m.key, m.color); })); + chartLoads = chartLoads.concat(BEDS.map(function (bed) { return loadAgronomyChart(bed); })); var insightsLoad = loadInsights(); var results = await Promise.all([fetch('/api/latest')].concat(chartLoads)); var latestResp = results[0]; @@ -2401,6 +2462,7 @@ function renderLoadingSkeletons() { /* ── Boot ── */ renderBeds(); renderBedMoistureCards(); +renderBedGddCards(); renderLoadingSkeletons(); _tickClock(); setInterval(_tickClock, 15_000); diff --git a/garden/dashboard/templates/partials/_climate-trends.html b/garden/dashboard/templates/partials/_climate-trends.html index 289f6bc..82391da 100644 --- a/garden/dashboard/templates/partials/_climate-trends.html +++ b/garden/dashboard/templates/partials/_climate-trends.html @@ -27,6 +27,10 @@
+ +
diff --git a/garden/main.py b/garden/main.py index 4845b20..7fc24e5 100644 --- a/garden/main.py +++ b/garden/main.py @@ -7,6 +7,7 @@ POST /api/ecowitt — Ecowitt-protocol ingest from GW1200 GET /api/latest — latest reading per sensor (JSON) GET /api/series — time-series for one sensor (JSON) + GET /api/agronomy_series — daily GDD/water-balance history for one bed (JSON) POST /api/telegram — inbound Telegram bot webhook (/bed1, /weather, ...) """ @@ -134,6 +135,22 @@ async def api_series( return JSONResponse(storage.series(sensor, hours)) +# ── GET /api/agronomy_series ────────────────────────────────────────────────── + +@app.get("/api/agronomy_series") +async def api_agronomy_series( + bed: str = Query(..., description="bed id"), + days: int = Query(120, ge=1, le=365), +) -> JSONResponse: + """ + Daily GDD/water-balance history for one bed (one row/local day, from + bed_daily_agronomy). Powers the dashboard's per-bed GDD-accumulation + chart — a season-to-date series, unrelated to the 1h/3h/12h/24h/7d + Trends range control that drives /api/series. + """ + return JSONResponse(storage.bed_agronomy_series(bed, days)) + + # ── GET /api/insights ──────────────────────────────────────────────────────── @app.get("/api/insights") From 8075a6583bc0cfd2e9748df2acbec151140a4a2f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 00:45:47 +0000 Subject: [PATCH 4/5] Declutter the dashboard: merge GDD charts, drop pressure/dew point chart Trends now shows 8 chart cards instead of 12: - The 4 per-bed GDD charts become one chart with a line per bed (same multi-line pattern already used for the Temperature chart), instead of 4 separate cards. - The Pressure & dew point chart is dropped. Pressure's live value stays visible in the hero sky-strip chip (a separate code path); frost-risk alerting is unaffected since it runs server-side, not through this chart. --- config.yaml | 4 +- garden/dashboard/static/css/components.css | 11 +- garden/dashboard/static/js/garden.js | 106 +++++++++++------- .../templates/partials/_climate-trends.html | 10 +- 4 files changed, 73 insertions(+), 58 deletions(-) diff --git a/config.yaml b/config.yaml index 14ebb70..467de37 100644 --- a/config.yaml +++ b/config.yaml @@ -42,7 +42,7 @@ sensors: baromrel_inhg: label: "Pressure" color: "#a78bfa" - chart: true + chart: false # stat-strip only — already live in the hero sky-strip chip; dropped its Trends chart to declutter baromabs_inhg: label: "Abs. pressure" color: "#8b5cf6" @@ -79,7 +79,7 @@ sensors: dewpoint_f: label: "Dew point" color: "#38bdf8" - chart: true + chart: false # stat-strip only — dropped its Trends chart to declutter; frost-risk alerting is unaffected (server-side, not dashboard-driven) heatindex_f: label: "Feels like" color: "#ef4444" diff --git a/garden/dashboard/static/css/components.css b/garden/dashboard/static/css/components.css index e10fcaf..9c4e6a3 100644 --- a/garden/dashboard/static/css/components.css +++ b/garden/dashboard/static/css/components.css @@ -336,18 +336,11 @@ .bed-moisture-row { grid-template-columns: 1fr; } } -.bed-gdd-row { - grid-template-columns: repeat(4, 1fr); +.bed-gdd-chart-row { + grid-template-columns: 1fr; padding-top: 0; } -@media (max-width: 900px) { - .bed-gdd-row { grid-template-columns: repeat(2, 1fr); } -} -@media (max-width: 720px) { - .bed-gdd-row { grid-template-columns: 1fr; } -} - /* ── EMPTY STATE ────────────────────────────────────────────────────── */ .empty { diff --git a/garden/dashboard/static/js/garden.js b/garden/dashboard/static/js/garden.js index 0ed2f60..35d4787 100644 --- a/garden/dashboard/static/js/garden.js +++ b/garden/dashboard/static/js/garden.js @@ -1540,11 +1540,12 @@ function renderBedMoistureCards() { } /* ════════════════════════════════════════════════════════════════════════════ - PER-BED GDD ACCUMULATION ROW - Season-to-date cumulative Growing Degree Days per bed, from the new - bed_daily_agronomy history (garden/storage.py bed_agronomy_series()) -- - a once-daily accumulator, not a live sensor series, so it's fetched from - its own endpoint and kept out of the 1h/3h/12h/24h/7d range control. + GDD ACCUMULATION CHART + Season-to-date cumulative Growing Degree Days, one line per bed on a + single chart, from the bed_daily_agronomy history (garden/storage.py + bed_agronomy_series()) -- a once-daily accumulator, not a live sensor + series, so it's fetched from its own endpoint and kept out of the + 1h/3h/12h/24h/7d range control. ════════════════════════════════════════════════════════════════════════════ */ /** Cache of latest bed_daily_agronomy rows, keyed by bed id. */ @@ -1556,46 +1557,68 @@ function fmtDate(isoDate) { return d.toLocaleDateString([], { month: 'short', day: '2-digit' }); } -/** Build the 4 per-bed GDD chart-cards (one row), mirroring renderBedMoistureCards(). */ +/** Build the single GDD chart-card. */ function renderBedGddCards() { const grid = document.getElementById('bed-gdd-grid'); - if (!grid || !BEDS.length) return; - grid.innerHTML = BEDS.map(function (bed) { - return ( - '
' + - '
' + bedEmoji(bed.plants) + ' ' + bed.name + ' · GDD to date
' + - '
' + - '
' - ); - }).join(''); -} + if (!grid) return; + grid.innerHTML = + '
' + + '
GDD to date · all beds
' + + '
' + + '
'; +} + +/** Fetches every bed's GDD history and draws them as one multi-line chart + * (one dataset per bed, same pattern _drawTrendGroupChart uses for the + * multi-line Temperature chart), instead of a separate card per bed. */ +async function loadAgronomyChart() { + const results = await Promise.all(BEDS.map(function (bed) { + return fetch('/api/agronomy_series?bed=' + encodeURIComponent(bed.id) + '&days=120') + .then(function (resp) { return resp.ok ? resp.json() : []; }) + .then(function (rows) { return { bed: bed, rows: rows }; }); + })); + + Object.keys(agronomySeriesCache).forEach(function (k) { delete agronomySeriesCache[k]; }); + results.forEach(function (r) { agronomySeriesCache[r.bed.id] = r.rows; }); + + const canvas = document.getElementById('chart-gdd-all'); + if (!canvas) return; -/** Fetches + draws one bed's cumulative-GDD chart from bed_daily_agronomy history. */ -async function loadAgronomyChart(bed) { - const resp = await fetch('/api/agronomy_series?bed=' + encodeURIComponent(bed.id) + '&days=120'); - if (!resp.ok) return; - const rows = await resp.json(); - agronomySeriesCache[bed.id] = rows; + /* Union of every date seen across beds, sorted -- a bed's row is missing + on any day its accumulation job didn't run (no sensor data yet, etc). */ + const dateSet = {}; + results.forEach(function (r) { r.rows.forEach(function (row) { dateSet[row.local_date] = true; }); }); + const dates = Object.keys(dateSet).sort(); + if (!dates.length) return; - const canvas = document.getElementById('chart-gdd-' + bed.id); - if (!canvas) return; + const datasets = results.map(function (r) { + const byDate = {}; + r.rows.forEach(function (row) { byDate[row.local_date] = row.gdd_cumulative; }); + return { + label: r.bed.name, + data: dates.map(function (d) { return Object.prototype.hasOwnProperty.call(byDate, d) ? byDate[d] : null; }), + borderColor: cfg_moisture_color(r.bed), + borderWidth: 1.5, + pointRadius: 0, + tension: 0.3, + fill: false, + spanGaps: true, + }; + }); - const labels = rows.map(function (r) { return fmtDate(r.local_date); }); - const data = rows.map(function (r) { return r.gdd_cumulative; }); - const color = cfg_moisture_color(bed); + const labels = dates.map(fmtDate); - const instKey = 'gdd-' + bed.id; - let chart = instances[instKey]; + let chart = instances['gdd-all']; if (!chart) { - chart = new Chart(canvas, makeChartOpts(color)); - instances[instKey] = chart; + chart = new Chart(canvas, makeChartOpts(datasets[0].borderColor, { datasets: datasets, legend: true })); + instances['gdd-all'] = chart; } - chart.data.labels = labels; - chart.data.datasets[0].data = data; - chart._bands = []; - chart._lines = []; - chart._wateringEvents = []; - chart._projection = null; + chart.data.labels = labels; + chart.data.datasets = datasets; + chart._bands = []; + chart._lines = []; + chart._wateringEvents = []; + chart._projection = null; chart.update('none'); } @@ -1608,10 +1631,9 @@ async function loadAgronomyChart(bed) { ════════════════════════════════════════════════════════════════════════════ */ const TRENDS_GROUPS = [ - { id: 'temperature', title: 'Temperature', keys: ['temp_f', 'temp1_f'] }, - { id: 'humidity', title: 'Humidity', keys: ['humidity', 'humidity1'] }, - { id: 'vpd', title: 'VPD', keys: ['vpd_kpa'], vpdBand: true }, - { id: 'pressure', title: 'Pressure & dew point', keys: ['baromrel_inhg', 'dewpoint_f'], dualAxis: true }, + { id: 'temperature', title: 'Temperature', keys: ['temp_f', 'temp1_f'] }, + { id: 'humidity', title: 'Humidity', keys: ['humidity', 'humidity1'] }, + { id: 'vpd', title: 'VPD', keys: ['vpd_kpa'], vpdBand: true }, ]; /** Builds the grouped climate chart cards once, then (re)draws each on every @@ -2247,7 +2269,7 @@ async function refresh() { so the chip always painted from the prior cycle's conditions. */ var chartLoads = CHARTS.map(function (c) { return loadChart(c.key, c.color); }); chartLoads = chartLoads.concat(MOISTURE_GROUP.map(function (m) { return loadChart(m.key, m.color); })); - chartLoads = chartLoads.concat(BEDS.map(function (bed) { return loadAgronomyChart(bed); })); + chartLoads.push(loadAgronomyChart()); var insightsLoad = loadInsights(); var results = await Promise.all([fetch('/api/latest')].concat(chartLoads)); var latestResp = results[0]; diff --git a/garden/dashboard/templates/partials/_climate-trends.html b/garden/dashboard/templates/partials/_climate-trends.html index 82391da..2856182 100644 --- a/garden/dashboard/templates/partials/_climate-trends.html +++ b/garden/dashboard/templates/partials/_climate-trends.html @@ -27,11 +27,11 @@
- -
- + +
+ From 09591d25d1f4efdfd2040dcabef8d0af9bfe529a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 03:00:02 +0000 Subject: [PATCH 5/5] Fix bugs found in code review of the GDD/water-balance feature A code-review pass (4 parallel review angles) surfaced real bugs in the GDD/water-balance work, several confirmed independently by multiple review agents. Fixes: - Cumulative GDD/water balance were chained off the previous row's stored total, so a forced same-day rerun (--agronomy, a documented flag) silently double-counted today's contribution on top of itself. Now recomputed from row history via SQL SUM each time, which is naturally idempotent under a rerun. - planted_on was stored and displayed but never actually used to seed GDD accumulation -- it silently started from whichever day the cron first ran instead of the real planting date, understating the live dashboard's cumulative GDD by the entire deployment gap (confirmed: bed1 jumped from 95 to 1237.5 cumulative GDD after this fix, against the real config's May 15 planting date). Water balance is not backfilled (no historical weather data exists to backfill from), documented as a known gap. - gdd_base_for_bed() picked the bed's growth-stage/harvest-date reference crop by "highest base temperature," unrelated to which crop actually takes longest to mature -- wrong for the real bed2 (eggplant+okra) and bed4 (zucchini+eggplant), where the faster co-planted crop could make the bed read "ready to harvest" while a slower crop in the same bed was still mid-season. Now picks the crop with the longest maturity requirement instead. - bed_agronomy_series()'s trailing-window cutoff used UTC "now" while the rows it filters are keyed by local-timezone dates -- an off-by- one-day risk near local-midnight-crossing-UTC-day boundaries. - The nightly irrigation estimate only looked at the last 2 hours of soil-moisture data, missing any watering earlier in the day; widened to 24h. - Two efficiency fixes (a bed-invariant DB query no longer repeated per bed; the dashboard's GDD chart no longer refetched every 60s for data that changes once a day) and one cleanup (collapsed duplicated once-per-day dedup logic into a shared helper). Added storage/runner-level regression tests for the rerun-idempotency and backfill fixes, verified end-to-end against the real config.yaml. --- garden/agent/runner.py | 143 ++++++++++++++++++------ garden/dashboard/static/js/garden.js | 6 +- garden/derived.py | 23 ++-- garden/storage.py | 80 +++++++++++++- tests/test_agronomy_accumulation.py | 158 +++++++++++++++++++++++++++ tests/test_derived.py | 15 ++- tests/test_storage.py | 62 +++++++++++ 7 files changed, 440 insertions(+), 47 deletions(-) create mode 100644 tests/test_agronomy_accumulation.py diff --git a/garden/agent/runner.py b/garden/agent/runner.py index 5e412b8..ee9a741 100644 --- a/garden/agent/runner.py +++ b/garden/agent/runner.py @@ -13,7 +13,7 @@ import argparse import logging -from datetime import datetime, timezone +from datetime import date, datetime, timedelta, timezone from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from garden import derived, storage @@ -283,9 +283,13 @@ def _local_now() -> datetime: return datetime.now(tz) -def _already_sent_today(local_now: datetime) -> bool: - """True if the brief was already sent today (local date).""" - state = storage.get_alert_state(_BRIEF_RULE_ID) +def _rule_already_fired_today(rule_id: str, local_now: datetime) -> bool: + """ + True if alert_state[rule_id].last_fired_ts falls on local_now's local + date. Shared once-per-local-day dedup check -- used by both the daily + brief and the agronomy accumulation job, each with their own rule_id. + """ + state = storage.get_alert_state(rule_id) last_fired = state.get("last_fired_ts", "") if not last_fired: return False @@ -295,14 +299,18 @@ def _already_sent_today(local_now: datetime) -> bool: try: tz = ZoneInfo(tz_name) except ZoneInfoNotFoundError: - log.warning("Unknown timezone %r in _already_sent_today, falling back to UTC", tz_name) + log.warning("Unknown timezone %r in _rule_already_fired_today, falling back to UTC", tz_name) tz = ZoneInfo("UTC") - last_local = last_dt.astimezone(tz) - return last_local.date() == local_now.astimezone(tz).date() + return last_dt.astimezone(tz).date() == local_now.astimezone(tz).date() except Exception: return False +def _already_sent_today(local_now: datetime) -> bool: + """True if the brief was already sent today (local date).""" + return _rule_already_fired_today(_BRIEF_RULE_ID, local_now) + + def send_daily_brief(force: bool = False) -> None: """ Send the morning garden brief. Called by run_cron_tick() and the --brief CLI flag. @@ -350,23 +358,63 @@ def _maybe_daily_brief() -> None: # alert_state rule_id per bed so it can't collide with the brief's dedup. _AGRONOMY_RULE_PREFIX = "agronomy_accum" +_MAX_BACKFILL_DAYS = 366 # defensive cap in case planted_on is garbage/far in the past def _agronomy_already_run_today(bed_id: str, local_now: datetime) -> bool: - state = storage.get_alert_state(f"{_AGRONOMY_RULE_PREFIX}_{bed_id}") - last_fired = state.get("last_fired_ts", "") - if not last_fired: - return False + return _rule_already_fired_today(f"{_AGRONOMY_RULE_PREFIX}_{bed_id}", local_now) + + +def _local_day_bounds_utc(day: date, tz: ZoneInfo) -> tuple[str, str]: + """UTC ISO bounds [start, end) for one local calendar day.""" + start_local = datetime(day.year, day.month, day.day, tzinfo=tz) + end_local = start_local + timedelta(days=1) + start_utc = start_local.astimezone(timezone.utc).replace(microsecond=0).isoformat() + end_utc = end_local.astimezone(timezone.utc).replace(microsecond=0).isoformat() + return start_utc, end_utc + + +def _backfill_gdd(bed_id: str, base_f: float, temp_key: str, tz: ZoneInfo, today: date) -> None: + """ + On a bed's first accumulation run, backfill gdd_daily/gdd_cumulative for + every day from planted_on up to (not including) today, using whatever + local sensor history already exists for temp_key. Without this, GDD + would silently start counting from whichever day the cron first ran + instead of the actual planting date. + + Water balance is intentionally NOT backfilled -- weather.py only caches + TODAY's Open-Meteo forecast in-process, nothing historical is persisted, + so there's no accurate past rain/ET0 to backfill from. It simply starts + accruing from today forward; a documented gap, not a bug. + + Days with no sensor history (e.g. before the station was recording) are + skipped silently -- they contribute 0 GDD rather than crashing. + """ + planted_str = cfg.bed_planted_on(bed_id) try: - last_dt = datetime.fromisoformat(last_fired.replace("Z", "+00:00")) - tz_name = cfg.location.get("timezone", "UTC") - try: - tz = ZoneInfo(tz_name) - except ZoneInfoNotFoundError: - tz = ZoneInfo("UTC") - return last_dt.astimezone(tz).date() == local_now.astimezone(tz).date() - except Exception: - return False + day = date.fromisoformat(planted_str) if planted_str else today + except ValueError: + log.warning("Bed %s has an unparseable planted_on %r, skipping GDD backfill", bed_id, planted_str) + return + + n = 0 + while day < today and n < _MAX_BACKFILL_DAYS: + day_str = day.isoformat() + start_utc, end_utc = _local_day_bounds_utc(day, tz) + day_temp = storage.day_stats(temp_key, start_utc, end_utc) + if day_temp is not None: + day_gdd = derived.gdd_daily(day_temp["max"], day_temp["min"], base_f) + gdd_cum = storage.bed_gdd_cumulative_before(bed_id, day_str) + day_gdd + storage.upsert_bed_agronomy( + bed_id, day_str, + tmax_f=day_temp["max"], tmin_f=day_temp["min"], + gdd_daily=day_gdd, gdd_cumulative=gdd_cum, + ) + day += timedelta(days=1) + n += 1 + + if n: + log.info("Backfilled GDD for %s: %d day(s) from %s", bed_id, n, planted_str or today.isoformat()) def run_daily_agronomy_accumulation(force: bool = False) -> None: @@ -386,13 +434,27 @@ def run_daily_agronomy_accumulation(force: bool = False) -> None: if local_now.hour != hour_local: return - today_str = local_now.date().isoformat() + today = local_now.date() + today_str = today.isoformat() fc = get_forecast() temp_key = cfg.agronomy.get("gdd_temp_key", "temp_f") gdd_base_overrides = cfg.agronomy.get("gdd_base_overrides") or {} kc_overrides = cfg.agronomy.get("kc_overrides") or {} beds_cfg = cfg.agronomy.get("beds", {}) + tz_name = cfg.location.get("timezone", "UTC") + try: + tz = ZoneInfo(tz_name) + except ZoneInfoNotFoundError: + tz = ZoneInfo("UTC") + + # gdd_temp_key is one global config value, not per-bed -- fetch once + # rather than re-querying the same stats identically for every bed. + temp_stats = storage.stats(temp_key, hours=24) + if temp_stats is None: + log.debug("Agronomy accumulation: no %s data yet, skipping all beds", temp_key) + return + for bed in cfg.dashboard.get("beds", []): bed_id = bed.get("id") if not bed_id: @@ -400,24 +462,27 @@ def run_daily_agronomy_accumulation(force: bool = False) -> None: if not force and _agronomy_already_run_today(bed_id, local_now): continue - temp_stats = storage.stats(temp_key, hours=24) - if temp_stats is None: - log.debug("Agronomy accumulation: no %s data yet for %s, skipping", temp_key, bed_id) - continue - base = derived.gdd_base_for_bed(bed.get("plants", []), gdd_base_overrides) if base is None: continue # no recognised crops in this bed base_f, ref_crop = base + if storage.get_bed_agronomy_latest(bed_id) is None: + _backfill_gdd(bed_id, base_f, temp_key, tz, today) + gdd_today = derived.gdd_daily(temp_stats["max"], temp_stats["min"], base_f) - # Irrigation estimate from a recent soil-moisture rise (module docstring: - # analyze_watering() wants a narrow window, <=2h, to avoid bucket smearing). + # Irrigation estimate from the day's soil-moisture rise. A 24h window + # here (not the <=2h analyze_watering()'s docstring recommends for + # precise spike CHARACTERIZATION) is deliberate: this only needs + # "did watering happen at all today," so a bed watered in the + # morning isn't invisible to this nightly job. Bucket smearing at + # 24h is mild (~4min buckets vs the ~60s ingest interval) compared + # to the multi-hour smearing a week-long window would cause. moist_key = bed.get("sensors", {}).get("soil_moisture") watering: dict = {} if moist_key: - rows = storage.series(moist_key, hours=2) + rows = storage.series(moist_key, hours=24) samples = [ (datetime.fromisoformat(r["ts"].replace("Z", "+00:00")).timestamp(), r["value"]) for r in rows @@ -438,15 +503,23 @@ def run_daily_agronomy_accumulation(force: bool = False) -> None: etc_in = derived.etc_from_kc(et0_in, kc) if et0_in is not None else 0.0 wb_daily = derived.bed_water_balance(rain_in, irrigation_in, etc_in) - prev = storage.get_bed_agronomy_latest(bed_id) is_good_soak = watering.get("quality") == "good_soak" - # GDD is a phenology clock tied to planted_on -- it always accumulates, - # never resets on a watering event. Water balance resets to just - # today's value on a good soak (re-anchors "deficit since last real + # Cumulative totals are recomputed from row history via SQL SUM each + # time (storage.bed_gdd_cumulative_before / + # bed_water_balance_cumulative_since_reset), not chained off a + # stored running total -- so reprocessing today (e.g. a forced + # --agronomy rerun) recomputes the same value instead of double- + # counting today's contribution on top of itself. GDD always + # accumulates from planted_on and never resets on a watering event + # (it's a phenology clock); water balance resets to just today's + # value on a good soak (re-anchors "deficit since last real # recharge"), same spirit as drydown_rate's post-watering re-anchor. - gdd_cum = gdd_today + (prev["gdd_cumulative"] if prev else 0.0) - wb_cum = wb_daily if (prev is None or is_good_soak) else prev["water_balance_cumulative"] + wb_daily + gdd_cum = storage.bed_gdd_cumulative_before(bed_id, today_str) + gdd_today + wb_cum = ( + wb_daily if is_good_soak + else storage.bed_water_balance_cumulative_since_reset(bed_id, today_str) + wb_daily + ) storage.upsert_bed_agronomy( bed_id, today_str, diff --git a/garden/dashboard/static/js/garden.js b/garden/dashboard/static/js/garden.js index 35d4787..fae461a 100644 --- a/garden/dashboard/static/js/garden.js +++ b/garden/dashboard/static/js/garden.js @@ -2269,7 +2269,9 @@ async function refresh() { so the chip always painted from the prior cycle's conditions. */ var chartLoads = CHARTS.map(function (c) { return loadChart(c.key, c.color); }); chartLoads = chartLoads.concat(MOISTURE_GROUP.map(function (m) { return loadChart(m.key, m.color); })); - chartLoads.push(loadAgronomyChart()); + /* NOT loadAgronomyChart() here -- bed_daily_agronomy only changes once a + day server-side, so fetching it on this 60s cycle would be 1440x more + often than useful; it gets its own much slower interval at boot instead. */ var insightsLoad = loadInsights(); var results = await Promise.all([fetch('/api/latest')].concat(chartLoads)); var latestResp = results[0]; @@ -2490,3 +2492,5 @@ _tickClock(); setInterval(_tickClock, 15_000); refresh(); setInterval(refresh, 60_000); +loadAgronomyChart(); +setInterval(loadAgronomyChart, 30 * 60_000); /* daily-changing data -- 30min is plenty fresh */ diff --git a/garden/derived.py b/garden/derived.py index 27e96e1..bbe990e 100644 --- a/garden/derived.py +++ b/garden/derived.py @@ -698,11 +698,16 @@ def gdd_base_for_bed( Tbase is the HIGHEST base temperature among the bed's crops — the most conservative choice (mirrors bed_moisture_band's intersection logic): no - GDD accrues on a day too cold for the pickiest crop in the bed. The crop - that produced that Tbase also doubles as the bed's reference crop for - gdd_growth_stage()/KC_MID lookups, since a mixed bed has no single true - growth curve — using the pickiest crop keeps both numbers consistent - with each other without a separate "primary crop" config field. + GDD accrues on a day too cold for the pickiest crop in the bed. + + The reference crop (used for gdd_growth_stage()/kc_for_crop()/ + maturity_gdd_for_crop() lookups) is a SEPARATE choice: the crop with the + LONGEST maturity_gdd_for_crop() among the bed's recognised crops — the + "bottleneck" crop. A mixed bed has no single true growth curve, so + reporting progress off whichever crop happens to be fastest would show + the bed as further along (or "ready to harvest") the moment that one + crop matures, even if a slower co-planted crop is still mid-season. + Tbase and the reference crop can therefore be different crops. Returns None when no recognised crop is in `plants`. """ @@ -710,11 +715,13 @@ def gdd_base_for_bed( if custom_bases: bases.update(custom_bases) - candidates = [(bases[p], p) for p in plants if p in bases] + candidates = list(dict.fromkeys(p for p in plants if p in bases)) if not candidates: return None - base_f, crop_key = max(candidates, key=lambda c: c[0]) - return base_f, crop_key + + base_f = max(bases[p] for p in candidates) + reference_crop = max(candidates, key=lambda p: maturity_gdd_for_crop(p) or 0.0) + return base_f, reference_crop def gdd_growth_stage(cumulative_gdd: float, crop_key: str) -> dict[str, Any]: diff --git a/garden/storage.py b/garden/storage.py index 9143e55..14a58cc 100644 --- a/garden/storage.py +++ b/garden/storage.py @@ -23,6 +23,7 @@ from pathlib import Path from typing import Any, Generator +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError log = logging.getLogger("garden.storage") @@ -346,9 +347,86 @@ def upsert_bed_agronomy(bed_id: str, local_date: str, **fields: Any) -> None: ) +def bed_gdd_cumulative_before(bed_id: str, local_date: str) -> float: + """ + Sum of gdd_daily for all rows strictly before local_date (0.0 if none). + + Recomputing cumulative GDD this way, rather than reading the previous + row's stored cumulative and adding today's delta, makes writing a given + day's row idempotent: re-processing the same local_date (e.g. a forced + --agronomy rerun) recomputes the same total instead of compounding a + double-count on top of a value that already includes today's contribution. + """ + with _conn() as con: + row = con.execute( + "SELECT COALESCE(SUM(gdd_daily), 0.0) as total FROM bed_daily_agronomy " + "WHERE bed_id = ? AND local_date < ?", + (bed_id, local_date), + ).fetchone() + return row["total"] + + +def bed_water_balance_cumulative_since_reset(bed_id: str, local_date: str) -> float: + """ + Sum of water_balance_daily since the most recent good_soak reset strictly + before local_date (or since the start of history if there's no reset yet), + exclusive of local_date itself. 0.0 if no rows. + + Same idempotency rationale as bed_gdd_cumulative_before(): recomputed from + the row history each time rather than chained off a stored running total. + """ + with _conn() as con: + reset_row = con.execute( + "SELECT local_date FROM bed_daily_agronomy " + "WHERE bed_id = ? AND local_date < ? AND reset_reason = 'good_soak' " + "ORDER BY local_date DESC LIMIT 1", + (bed_id, local_date), + ).fetchone() + since = reset_row["local_date"] if reset_row else None + if since: + row = con.execute( + "SELECT COALESCE(SUM(water_balance_daily), 0.0) as total FROM bed_daily_agronomy " + "WHERE bed_id = ? AND local_date > ? AND local_date < ?", + (bed_id, since, local_date), + ).fetchone() + else: + row = con.execute( + "SELECT COALESCE(SUM(water_balance_daily), 0.0) as total FROM bed_daily_agronomy " + "WHERE bed_id = ? AND local_date < ?", + (bed_id, local_date), + ).fetchone() + return row["total"] + + +def day_stats(sensor_key: str, start_ts_utc: str, end_ts_utc: str) -> dict[str, Any] | None: + """ + Min/max/count for one arbitrary UTC time window [start_ts_utc, end_ts_utc). + + Generalizes stats()'s "trailing N hours from now" into an explicit bounded + window, so a caller can ask about any past calendar day (e.g. to backfill + GDD for days before this feature was first deployed). Returns None when + there are zero readings for this sensor_key in the window. + """ + with _conn() as con: + row = con.execute( + "SELECT MIN(value) as min, MAX(value) as max, COUNT(*) as n FROM readings " + "WHERE sensor_key = ? AND ts >= ? AND ts < ?", + (sensor_key, start_ts_utc, end_ts_utc), + ).fetchone() + if row is None or row["n"] == 0: + return None + return {"min": row["min"], "max": row["max"], "n": row["n"]} + + def bed_agronomy_series(bed_id: str, days: int = 30) -> list[dict[str, Any]]: """Trailing `days` calendar days of bed_daily_agronomy rows, oldest → newest.""" - cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).date().isoformat() + from garden.config import cfg + try: + tz = ZoneInfo(cfg.location.get("timezone", "UTC")) + except ZoneInfoNotFoundError: + tz = ZoneInfo("UTC") + today_local = datetime.now(tz).date() + cutoff = (today_local - timedelta(days=days)).isoformat() with _conn() as con: rows = con.execute( """ diff --git a/tests/test_agronomy_accumulation.py b/tests/test_agronomy_accumulation.py new file mode 100644 index 0000000..21d1585 --- /dev/null +++ b/tests/test_agronomy_accumulation.py @@ -0,0 +1,158 @@ +""" +test_agronomy_accumulation.py — integration tests for +garden.agent.runner.run_daily_agronomy_accumulation(): the same-day rerun +idempotency fix (Fix 1) and the planted_on GDD backfill fix (Fix 2). + +storage._conn() opens a fresh sqlite3.connect() per call; with DB_PATH set +to ":memory:" (the conftest.py default), every connection is a distinct, +empty database. These tests need writes and reads to share one database, so +the `db` fixture below points storage at a real on-disk temp file instead, +same technique as tests/test_storage.py. +""" + +from datetime import datetime, timedelta, timezone +from zoneinfo import ZoneInfo + +import pytest + +from garden import storage +from garden.agent import runner +from garden.config import cfg + + +@pytest.fixture +def db(tmp_path, monkeypatch): + monkeypatch.setattr(storage, "_db_path", tmp_path / "test.sqlite3") + storage.init_db() + return storage + + +@pytest.fixture +def one_bed_config(): + """Replace the configured beds with a single controlled test bed.""" + original_beds = cfg.dashboard.get("beds") + original_agronomy = dict(cfg.agronomy) + + cfg.dashboard["beds"] = [{ + "id": "testbed", + "name": "Test Bed", + "sensors": {}, + "plants": ["tomato"], + "planted_on": runner._local_now().date().isoformat(), + }] + cfg.agronomy.clear() + cfg.agronomy.update({ + "enabled": True, + "accumulation_hour_local": 23, + "gdd_temp_key": "temp_f", + "beds": {}, + "gdd_base_overrides": {}, + "kc_overrides": {}, + }) + + yield + + cfg.dashboard["beds"] = original_beds + cfg.agronomy.clear() + cfg.agronomy.update(original_agronomy) + + +def _iso(minutes_ago: float) -> str: + return (datetime.now(timezone.utc) - timedelta(minutes=minutes_ago)).isoformat() + + +def _write_recent_temps(db, tmin: float, tmax: float) -> None: + """Two temp_f readings within the last few hours (for storage.stats() trailing-24h).""" + db.write_snapshot(_iso(180), {"temp_f": (tmin, "F")}, {"raw": True}) + db.write_snapshot(_iso(30), {"temp_f": (tmax, "F")}, {"raw": True}) + + +def _write_local_day_temps(db, local_day, tmin: float, tmax: float, tz_name: str) -> None: + """Two temp_f readings solidly inside one local calendar day (6am/3pm local).""" + tz = ZoneInfo(tz_name) + for hour, val in ((6, tmin), (15, tmax)): + local_dt = datetime(local_day.year, local_day.month, local_day.day, hour, tzinfo=tz) + db.write_snapshot(local_dt.astimezone(timezone.utc).isoformat(), {"temp_f": (val, "F")}, {"raw": True}) + + +class TestRerunIdempotency: + def test_forced_rerun_does_not_double_count(self, db, one_bed_config, monkeypatch): + _write_recent_temps(db, 60.0, 90.0) + monkeypatch.setattr(runner, "get_forecast", lambda: None) + + runner.run_daily_agronomy_accumulation(force=True) + first = db.get_bed_agronomy_latest("testbed") + assert first is not None + + runner.run_daily_agronomy_accumulation(force=True) + second = db.get_bed_agronomy_latest("testbed") + + assert first["gdd_cumulative"] == second["gdd_cumulative"] + assert first["water_balance_cumulative"] == second["water_balance_cumulative"] + + def test_repeated_reruns_still_match_a_fresh_sum(self, db, one_bed_config, monkeypatch): + _write_recent_temps(db, 60.0, 90.0) + monkeypatch.setattr(runner, "get_forecast", lambda: None) + + for _ in range(3): + runner.run_daily_agronomy_accumulation(force=True) + + today_str = runner._local_now().date().isoformat() + row = db.get_bed_agronomy_latest("testbed") + # (90+60)/2 - 50 = 25.0 GDD for the one day on record, regardless of + # how many times it was (re)computed. + assert row["gdd_daily"] == pytest.approx(25.0) + assert row["gdd_cumulative"] == pytest.approx(25.0) + assert db.bed_gdd_cumulative_before("testbed", today_str) == 0.0 + + +class TestPlantedOnBackfill: + def test_backfills_from_planted_on_on_first_run(self, db, one_bed_config, monkeypatch): + tz_name = cfg.location.get("timezone", "UTC") + today_local = runner._local_now().date() + planted = today_local - timedelta(days=5) + cfg.dashboard["beds"][0]["planted_on"] = planted.isoformat() + + # 5 backfilled days (planted..today-1), each 60/90 -> 25.0 GDD. + for d in range(5): + day = planted + timedelta(days=d) + _write_local_day_temps(db, day, 60.0, 90.0, tz_name) + # Plus a reading in the last 24h so today's own row can be computed too. + _write_recent_temps(db, 60.0, 90.0) + + monkeypatch.setattr(runner, "get_forecast", lambda: None) + runner.run_daily_agronomy_accumulation(force=True) + + series = db.bed_agronomy_series("testbed", days=30) + assert [r["local_date"] for r in series] == [ + (planted + timedelta(days=d)).isoformat() for d in range(5) + ] + [today_local.isoformat()] + + # 5 backfilled days + today, each contributing 25.0 GDD. + latest = db.get_bed_agronomy_latest("testbed") + assert latest["gdd_cumulative"] == pytest.approx(25.0 * 6) + + def test_no_backfill_when_planted_on_is_today(self, db, one_bed_config, monkeypatch): + # one_bed_config's default planted_on is today -- nothing to backfill. + _write_recent_temps(db, 60.0, 90.0) + monkeypatch.setattr(runner, "get_forecast", lambda: None) + + runner.run_daily_agronomy_accumulation(force=True) + + series = db.bed_agronomy_series("testbed", days=30) + assert len(series) == 1 + + def test_missing_sensor_history_skipped_not_crashed(self, db, one_bed_config, monkeypatch): + # planted_on is 5 days ago, but there's NO sensor history for any of + # those days (station wasn't recording yet) -- backfill should skip + # them silently rather than raise, and today's own row still writes. + today_local = runner._local_now().date() + planted = today_local - timedelta(days=5) + cfg.dashboard["beds"][0]["planted_on"] = planted.isoformat() + _write_recent_temps(db, 60.0, 90.0) + + monkeypatch.setattr(runner, "get_forecast", lambda: None) + runner.run_daily_agronomy_accumulation(force=True) + + series = db.bed_agronomy_series("testbed", days=30) + assert [r["local_date"] for r in series] == [today_local.isoformat()] diff --git a/tests/test_derived.py b/tests/test_derived.py index 838ad01..6565c56 100644 --- a/tests/test_derived.py +++ b/tests/test_derived.py @@ -490,9 +490,20 @@ def test_never_negative(self): class TestGddBaseForBed: def test_picks_highest_base(self): - # eggplant (50) + okra (55) -> okra's higher base wins + # eggplant (50) + okra (55) -> okra's higher base wins for Tbase. + # But eggplant takes far longer to mature (1300 vs okra's 900 GDD), + # so eggplant -- not okra -- is the reference crop for growth-stage/ + # harvest-date reporting. See test_reference_crop_uses_longest_maturity. result = gdd_base_for_bed(["eggplant", "okra", "okra"]) - assert result == (55.0, "okra") + assert result == (55.0, "eggplant") + + def test_reference_crop_uses_longest_maturity_not_highest_base(self): + # Tbase and reference crop can be different crops: okra sets the + # (higher, more conservative) Tbase, but eggplant -- the slower, + # "bottleneck" crop -- is the reference for stage/harvest reporting. + result = gdd_base_for_bed(["eggplant", "okra"]) + assert result[0] == 55.0 # okra's Tbase, still the conservative max + assert result[1] == "eggplant" # eggplant's longer maturity wins reference-crop def test_variants_resolve_to_family_base(self): result = gdd_base_for_bed(["tomato_cherry", "tomato_roma"]) diff --git a/tests/test_storage.py b/tests/test_storage.py index 5f9693b..f59cb19 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -165,3 +165,65 @@ def test_series_ordered_oldest_to_newest(self, db): db.upsert_bed_agronomy("bed1", "2026-07-06", gdd_daily=6.0) series = db.bed_agronomy_series("bed1", days=30) assert [r["local_date"] for r in series] == ["2026-07-05", "2026-07-06", "2026-07-07"] + + +class TestBedGddCumulativeBefore: + def test_no_rows_returns_zero(self, db): + assert db.bed_gdd_cumulative_before("bed1", "2026-07-07") == 0.0 + + def test_sums_strictly_before_date(self, db): + db.upsert_bed_agronomy("bed1", "2026-07-05", gdd_daily=10.0) + db.upsert_bed_agronomy("bed1", "2026-07-06", gdd_daily=15.0) + db.upsert_bed_agronomy("bed1", "2026-07-07", gdd_daily=20.0) + # 2026-07-07 itself is excluded -- "before", not "on or before" + assert db.bed_gdd_cumulative_before("bed1", "2026-07-07") == 25.0 + + def test_idempotent_under_reprocessing_same_day(self, db): + # Reprocessing 2026-07-07 (e.g. a forced rerun) must not change what + # bed_gdd_cumulative_before("2026-07-07") returns -- it only sums + # STRICTLY earlier days, so today's own (repeated) row is irrelevant. + db.upsert_bed_agronomy("bed1", "2026-07-06", gdd_daily=15.0) + before_first_run = db.bed_gdd_cumulative_before("bed1", "2026-07-07") + db.upsert_bed_agronomy("bed1", "2026-07-07", gdd_daily=20.0, gdd_cumulative=35.0) + db.upsert_bed_agronomy("bed1", "2026-07-07", gdd_daily=20.0, gdd_cumulative=35.0) # rerun + after_rerun = db.bed_gdd_cumulative_before("bed1", "2026-07-07") + assert before_first_run == after_rerun == 15.0 + + +class TestBedWaterBalanceCumulativeSinceReset: + def test_no_rows_returns_zero(self, db): + assert db.bed_water_balance_cumulative_since_reset("bed1", "2026-07-07") == 0.0 + + def test_sums_from_start_of_history_when_no_reset(self, db): + db.upsert_bed_agronomy("bed1", "2026-07-05", water_balance_daily=-0.1, reset_reason="") + db.upsert_bed_agronomy("bed1", "2026-07-06", water_balance_daily=-0.2, reset_reason="") + assert db.bed_water_balance_cumulative_since_reset("bed1", "2026-07-07") == pytest.approx(-0.3) + + def test_sums_only_since_most_recent_reset(self, db): + db.upsert_bed_agronomy("bed1", "2026-07-04", water_balance_daily=-0.5, reset_reason="") + db.upsert_bed_agronomy("bed1", "2026-07-05", water_balance_daily=0.3, reset_reason="good_soak") + db.upsert_bed_agronomy("bed1", "2026-07-06", water_balance_daily=-0.1, reset_reason="") + # The 07-04 deficit is behind the 07-05 reset -- must not be included. + assert db.bed_water_balance_cumulative_since_reset("bed1", "2026-07-07") == pytest.approx(-0.1) + + +class TestDayStats: + def test_no_readings_returns_none(self, db): + assert db.day_stats("temp_f", "2026-07-01T00:00:00+00:00", "2026-07-02T00:00:00+00:00") is None + + def test_reading_in_window_included(self, db): + db.write_snapshot("2026-07-01T12:00:00+00:00", {"temp_f": (70.0, "F")}, {"raw": True}) + db.write_snapshot("2026-07-01T18:00:00+00:00", {"temp_f": (85.0, "F")}, {"raw": True}) + s = db.day_stats("temp_f", "2026-07-01T00:00:00+00:00", "2026-07-02T00:00:00+00:00") + assert s["min"] == 70.0 + assert s["max"] == 85.0 + assert s["n"] == 2 + + def test_reading_outside_window_excluded(self, db): + db.write_snapshot("2026-06-30T23:00:00+00:00", {"temp_f": (40.0, "F")}, {"raw": True}) # before window + db.write_snapshot("2026-07-01T12:00:00+00:00", {"temp_f": (70.0, "F")}, {"raw": True}) # in window + db.write_snapshot("2026-07-02T01:00:00+00:00", {"temp_f": (90.0, "F")}, {"raw": True}) # after window + s = db.day_stats("temp_f", "2026-07-01T00:00:00+00:00", "2026-07-02T00:00:00+00:00") + assert s["min"] == 70.0 + assert s["max"] == 70.0 + assert s["n"] == 1