Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 35 additions & 2 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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: {}
227 changes: 218 additions & 9 deletions garden/agent/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")


Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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__":
Expand All @@ -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()
8 changes: 8 additions & 0 deletions garden/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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
Expand Down
Loading
Loading