water monitor active
A two-stage automation that turns raw smart-water-meter readings into a daily report with a
projected bill. It is run by the on-device lil-sis agent and summarized to chat. Part of the
agents system. Full deep-dive on the blog:
What does my water actually cost?
smart water meter (Eye on Water API)
│ pull ~90 days of hourly readings → CSV / JSON
▼
water-report.py
├─ hourly deltas → daily totals
├─ tiered cost model + flat service charge
├─ billing cycle (21st → 21st), cycle-to-date
├─ projected full-cycle bill ± 20% band
├─ sprinkler-window detection (M/W/F)
└─ anomaly flags: spikes · leaks · missed runs
│
▼
lil-sis → daily chat summary (projected bill first)
Pulling the data
pull_usage.py authenticates to Eye on Water (the portal for my
utility, Liberty Utilities) through the pyonwater client
and pulls about 90 days of history
asynchronously (aiohttp), one reading per hour. The meter reports a cumulative total (the
lifetime number on the dial), not per-hour usage, so the script just records the raw readings
and leaves the differencing to the report. It writes the same data two ways: a JSON file with a
little metadata, and a flat CSV the report reads.
{
"unit": "gallons",
"pulled_at": "2026-06-08T07:00:03",
"days": 90,
"history": [
{ "date": "2026-06-07T05:00:00-04:00", "reading": 183420.0, "unit": "gallons" },
{ "date": "2026-06-07T06:00:00-04:00", "reading": 183458.0, "unit": "gallons" }
]
}
date,reading,unit
2026-06-07T05:00:00-04:00,183420.0,gallons
2026-06-07T06:00:00-04:00,183458.0,gallons
From readings to daily usage
The report reduces those cumulative readings into the two shapes everything else is built on: a list of per-hour gallons (each reading minus the one before it), and a per-day total.
# cumulative readings -> per-hour usage -> per-day totals
hourly = [] # [(datetime, gallons_used_that_hour)]
for (t, reading), (_, prev) in zip(rows[1:], rows):
delta = reading - prev
if delta >= 0: # skip meter resets / rollovers
hourly.append((t, delta))
daily_total = defaultdict(float) # {date: gallons}
for t, gal in hourly:
daily_total[t.date()] += gal
From there it slices the periods it cares about: today, yesterday, a trailing 7-day average, and the current billing cycle.
Pricing the water
Water here is billed on a tiered, cumulative monthly schedule, priced per CGL (one CGL = 100 gallons), plus a flat service charge. The interesting part is that a tier applies to the portion of the month's usage that falls inside it, so the cost of "today's gallons" depends on where you already are in the cycle. A small tier-walking function handles that marginal pricing:
# cumulative monthly tier boundaries (CGL) and their rates (the local schedule)
RATE_TIERS = [(10, 0.7118), (30, 0.7472), (36, 0.9690), (float("inf"), 1.0172)]
SERVICE_CHARGE = 18.83 # flat, per cycle
def cost_for_usage(cgl_used, cum_before):
"""Cost of `cgl_used` CGL, starting from monthly position `cum_before`."""
remaining, pos, cost = cgl_used, cum_before, 0.0
for boundary, rate in RATE_TIERS:
if pos >= boundary:
continue
block = min(remaining, boundary - pos) # how much fits in this tier
cost += block * rate
pos += block
remaining -= block
if remaining <= 0:
break
return cost
So today's cost is cost_for_usage(today_cgl, cycle_to_date_before_today), which correctly
prices today's water at whatever tier the month has climbed into.
Projecting the bill
The cycle runs the 21st to the 21st. The projection takes the average daily usage so far this cycle and extends it across the days remaining, then prices the projected total:
avg_daily = cycle_to_date_gal / complete_days_so_far
projected = cycle_to_date_gal + avg_daily * days_remaining
bill = SERVICE_CHARGE + cost_for_usage(to_cgl(projected), 0)
The confidence band is a deliberate choice: the ±20% is applied only to the unknown (extrapolated) portion, not the gallons already metered. Early in the cycle most of the total is extrapolated so the range is wide; by the last few days almost everything is known and the range collapses toward the real number.
band = (avg_daily * days_remaining) * 0.20
low, high = projected - band, projected + band
Anomaly flags
Three cheap heuristics catch the things worth a heads-up:
- Spike. Today's usage is more than 2× the trailing 7-day average.
- Possible leak. A large morning draw on a non-sprinkler day (could be a leak, a pool fill, or manual irrigation).
- Missed run. A sprinkler day where the irrigation window stayed nearly dry, so the system may have failed to fire. Sprinkler detection knows the M/W/F early-morning window and counts gallons inside it, correctly handling readings that straddle the hourly-bucket boundary.
Output is structured text led by a projected-bill line, which lil-sis condenses into a
daily summary posted to chat.
Stack: Python, aiohttp, pyonwater (Eye on Water client), a hand-rolled tiered-rate cost model, cron via the OpenClaw gateway and lil-sis.