diff --git a/config.yaml b/config.yaml index 4616ba5..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" @@ -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-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-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-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-05-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..ee9a741 100644 --- a/garden/agent/runner.py +++ b/garden/agent/runner.py @@ -5,14 +5,15 @@ 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 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 @@ -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") @@ -277,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 @@ -289,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. @@ -336,6 +350,197 @@ 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" +_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: + 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: + 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: + """ + 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 = 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: + continue + if not force and _agronomy_already_run_today(bed_id, local_now): + 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 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=24) + 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) + + is_good_soak = watering.get("quality") == "good_soak" + + # 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 = 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, + 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 +549,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..9c4e6a3 100644 --- a/garden/dashboard/static/css/components.css +++ b/garden/dashboard/static/css/components.css @@ -336,6 +336,11 @@ .bed-moisture-row { grid-template-columns: 1fr; } } +.bed-gdd-chart-row { + grid-template-columns: 1fr; + padding-top: 0; +} + /* ── EMPTY STATE ────────────────────────────────────────────────────── */ .empty { @@ -437,6 +442,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..fae461a 100644 --- a/garden/dashboard/static/js/garden.js +++ b/garden/dashboard/static/js/garden.js @@ -1539,6 +1539,89 @@ function renderBedMoistureCards() { }).join(''); } +/* ════════════════════════════════════════════════════════════════════════════ + 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. */ +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 single GDD chart-card. */ +function renderBedGddCards() { + const grid = document.getElementById('bed-gdd-grid'); + 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; + + /* 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 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 = dates.map(fmtDate); + + let chart = instances['gdd-all']; + if (!chart) { + chart = new Chart(canvas, makeChartOpts(datasets[0].borderColor, { datasets: datasets, legend: true })); + instances['gdd-all'] = chart; + } + chart.data.labels = labels; + chart.data.datasets = datasets; + 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 @@ -1548,10 +1631,9 @@ function renderBedMoistureCards() { ════════════════════════════════════════════════════════════════════════════ */ 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 @@ -2028,6 +2110,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 +2122,7 @@ function _buildBedDetailHTML(bed, stress, waterBalanceIn) { rangeHTML + rateHTML + '
' + + gddHTML + '
' + '
' + battHTML + '
' + '
' + verdict + '
' + @@ -2044,6 +2130,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 +2204,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); } @@ -2156,6 +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); })); + /* 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]; @@ -2370,8 +2486,11 @@ function renderLoadingSkeletons() { /* ── Boot ── */ renderBeds(); renderBedMoistureCards(); +renderBedGddCards(); renderLoadingSkeletons(); _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/dashboard/templates/partials/_climate-trends.html b/garden/dashboard/templates/partials/_climate-trends.html index 289f6bc..2856182 100644 --- a/garden/dashboard/templates/partials/_climate-trends.html +++ b/garden/dashboard/templates/partials/_climate-trends.html @@ -27,7 +27,11 @@
- + +
+ diff --git a/garden/derived.py b/garden/derived.py index 17920c3..bbe990e 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,212 @@ 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 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`. + """ + bases = dict(GDD_BASE_F) + if custom_bases: + bases.update(custom_bases) + + candidates = list(dict.fromkeys(p for p in plants if p in bases)) + if not candidates: + return None + + 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]: + """ + 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..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, ...) """ @@ -14,9 +15,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 +59,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") @@ -121,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") @@ -199,7 +229,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 +251,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..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") @@ -79,6 +80,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 +296,144 @@ 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_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.""" + 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( + """ + 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_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_config.py b/tests/test_config.py index 6e8f740..5259116 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-05-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..6565c56 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,190 @@ 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 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, "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"]) + 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..f59cb19 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -116,3 +116,114 @@ 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"] + + +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