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 +
'' +
'' +
'' + 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