Back to blog
FILE 0x03·STOP GUESSING WHAT NEEDS WATER: A REAL WATER BALANCE FOR A G

Stop guessing what needs water: a real water balance for a garden app

July 27, 2026 · gardening, python, modelling, side-projects

My garden app knew where every plant was, what it was planted in, when it went in the ground, and what it had produced. It had no opinion at all about the one thing I do every single day: water things. So I watered on a calendar, and the suspicion that had been building all summer was that some plants were dying of kindness rather than neglect.

A calendar is the wrong instrument. A basil in an 8" pot and a tomato in the ground are not on the same schedule, and neither of them is on the same schedule in July as in April. The thing you actually want to know is how much water is left in the soil the roots can reach.

The model

That's a water balance, and it's not exotic — agronomists have used the same one for decades:

depletion += crop water use  -  effective rain  -  what you poured in

Crop water use is FAO-56's ETc = ET0 × Kc.

ET0 is reference evapotranspiration: how fast a standard grass surface loses water under today's actual conditions. It already folds in temperature, humidity, wind and solar radiation, which means I don't have to model any of those myself. Open-Meteo serves it free, no key, alongside precipitation, and will give you 30 days back and 7 days forward in one call:

https://api.open-meteo.com/v1/forecast
  ?latitude=..&longitude=..
  &daily=precipitation_sum,et0_fao_evapotranspiration,temperature_2m_max
  &precipitation_unit=inch&past_days=30&forecast_days=7

Kc scales that reference number to a specific plant at a specific age. A two-week-old pepper seedling doesn't drink like a sprawling fruiting one, and this is where most naive "smart watering" gets silly — it tells a sprout to take a gallon.

def kc_for_age(prof, age_days, season_days):
    f = age_days / float(season_days or 100)
    if f <= 0.15:  return KC_INI + (prof["kc_mid"] - KC_INI) * (f / 0.15) * 0.35
    if f <= 0.45:  return KC_INI + (prof["kc_mid"] - KC_INI) * (0.35 + 0.65 * (f - 0.15) / 0.30)
    if f <= 0.80:  return prof["kc_mid"]
    return prof["kc_mid"] + (KC_END - prof["kc_mid"]) * min(1.0, (f - 0.80) / 0.30)

The reservoir is the interesting half

Demand is the easy side. The half that actually explains why containers kill plants is supply — how much water the roots can reach before they run out.

For a pot that's volume × the fraction of potting mix that holds plant-available water (about 0.28 for a peat/bark mix; the rest is either drained away or bound too tight to be useful). For ground it's root depth × soil water-holding capacity × the plant's share of the bed. Sand holds roughly a third of what a good raised-bed mix does.

Two corrections mattered more than I expected:

Transpiration follows leaf area, not the pot's footprint. My first pass charged evaporation against the pot opening and cheerfully told me an 8" pot of basil could go five days in 95°F heat. Real answer, as anyone with a basil knows: about a day. The plant's canopy is two or three times wider than the rim, so demand scales with canopy while rain capture still only gets the opening. Same plant, same weather, two different areas in the same equation.

A seedling can't drink from soil it hasn't reached. Root depth has to ramp with age too, or the model hands a six-day-old sprout a week's worth of water and calls it fine.

area   = max(pot_opening_sqft, canopy_sqft)     # what transpires
catch  = pot_opening_sqft                       # what catches rain
root  *= max(0.3, min(1.0, 0.3 + 1.4 * age_fraction))

Rain gets discounted too — the first 0.04" wets leaves and mulch and evaporates, and anything past about an inch runs off or drains straight through.

Indoors there's no ET0 and no rain, so a low constant evaporative demand stands in. Which is the whole explanation for why houseplants die: put them on an outdoor plant's rhythm and you're delivering roughly four times what they use.

The part I actually wanted

Same model, run backwards over the watering log, answers "what am I drowning?" Three independent signals, any one of which is enough:

The frequency signal has to be the strict one. Little-and-often is a legitimate style; twice a week on something that wants a drink a fortnight is not. On my test fixtures a houseplant in a 12" pot watered every three days came back with "3.0× more water than it actually used (3.3 gal in, 1.1 gal used) · watered every 2.9 days; this one only needs it about every 16." That's the sentence I built the whole thing to get.

Honest about not knowing

First run, every plant reported 100% dry, water now — for the excellent reason that the watering log was empty, so the replay assumed a week of neglect.

That's a guess wearing a measurement's clothes, and it's exactly the failure mode that makes people stop trusting a tool. A model with no input data should say so. Plants with nothing logged now report unknown with no dryness figure at all, alongside the two things that are derivable from physics and weather with zero history — how much a full drink is, and roughly how often it'll want one — plus a row of one-tap buttons: today / yesterday / 2d / 4d / a week.

One tap backdates a watering and the balance becomes real. Twenty seconds of calibration beats a fabricated number forever.

What I'd do differently

I'd have written the fixture tests before the model rather than after. Six hand-built cases — basil in a small pot, tomato in ground, houseplant on a three-day cycle, seedling with no history — caught every one of the errors above in about a minute each, and I found them by reading output that looked wrong, not by reasoning about the code. The canopy-area bug in particular was invisible in the source and obvious the moment a number said "five days" about a basil.

The other thing: I nearly skipped ET0 and reached for temperature as a proxy. Every hour spent finding a data source that already encodes the physics is an afternoon not spent hand-tuning coefficients that were never going to generalise past one summer.