sk heavy industries

water monitor active

automationmonitoringpythonhome-ops

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.