sk heavy industries

Using Agents, OpenClaw and a Mac Studio to project a smart water meter bill

My water bill shows up once a month and I never know what it will be until it lands. Last summer, w/all the water related items running, I got slammed with a nasty bill. When I called to discuss, they informed me that I could check my usage via the smart meter.

Pulling the data turned out to be the easy part. Generating an accurate cost projection wasn't. Where I live, water is priced on a tiered schedule that resets every cycle, so the cost of a gallon depends on how much you've already used this month. Getting that right and then projecting it accurately is most of what this tool does.

It runs nightly on lil-sis, my on-metal agent and posts a summary to Discord. The details are as follows:

Pulling the data

The meter is read through Eye on Water (my utility company Liberty Utilities is on it). There's a small Python client, pyonwater, that handles auth and history. I pull about 90 days of hourly readings asynchronously and write them to CSV and JSON.

account = Account(eow_hostname=HOSTNAME, username=USERNAME, password=PASSWORD)
async with aiohttp.ClientSession() as session:
    client = Client(session, account)
    await client.authenticate()
    for meter in await account.fetch_meters(client):
        history = await meter.read_historical_data(client=client, days_to_load=90)
        # each point: { date, reading, unit }

The one thing to know about the readings: each is a cumulative total, the lifetime number on the dial, not the usage for that hour. So a row looks like 113265.8 gallons, then 113266.0 an hour later. The usage is the difference.

Cumulative readings to usage

The first job is turning that running total into per-hour and per-day usage. I diff each reading against the one before it. A negative delta means a meter reset or rollover, so I skip it rather than record negative water.

hourly = []                              # [(dt, gallons_used_that_hour)]
for i in range(1, len(rows)):
    dt, reading = rows[i]
    _, prev = rows[i - 1]
    delta = reading - prev
    if delta < 0:                        # meter reset / rollover
        continue
    hourly.append((dt, delta))

daily_total = defaultdict(float)         # {date: gallons}
for dt, g in hourly:
    daily_total[dt.date()] += g

From there I slice out the periods I care about: today, yesterday, a trailing 7-day average and the current billing cycle.

The cycle isn't a calendar month

My billing cycle runs the 21st of one month to the 21st of the next, not the 1st to the 30th. That sounds trivial until you write the date math and have to handle December wrapping into January, both backward (finding the cycle start) and forward (finding the end).

def billing_cycle_bounds(d):
    if d.day >= 21:
        start = d.replace(day=21)
    elif d.month == 1:
        start = d.replace(year=d.year - 1, month=12, day=21)
    else:
        start = d.replace(month=d.month - 1, day=21)
    if start.month == 12:
        end = start.replace(year=start.year + 1, month=1, day=21)
    else:
        end = start.replace(month=start.month + 1, day=21)
    return start, end

Everything about cost is anchored to this window. "Cycle-to-date" is the sum from the start through today and that running total is what makes the pricing tricky.

Pricing the water

This is the part I wasn't aware of during my first billing cycle. Water isn't a flat rate per gallon. It's a tiered, cumulative monthly schedule, priced per CGL (one CGL is 100 gallons), plus a flat service charge. As you use more in a cycle, you climb into more expensive tiers (how fun). This is roughly my local schedule:

# (upper bound in CGL, $/CGL for usage inside this tier)
RATE_TIERS = [
    (10,           0.7118),   # first 10 CGL
    (30,           0.7472),   # next 20 CGL
    (36,           0.9690),   # next 6 CGL
    (float("inf"), 1.0172),   # everything above
]
SERVICE_CHARGE = 18.83        # flat, per cycle

The subtle part is that a tier applies only to the portion of the month's usage that falls inside it. So the cost of "today's gallons" isn't a fixed number. It depends on where you already are in the cycle. The same 100 gallons is cheaper on the 1st than on the 20th, because by the 20th you've climbed into a higher tier. That's marginal pricing, and it needs a small tier-walking function:

def cost_for_usage(cgl_used, cum_before_cgl):
    """Cost of cgl_used CGL, starting from monthly position cum_before_cgl."""
    remaining, pos, cost = cgl_used, cum_before_cgl, 0.0
    for boundary, rate in RATE_TIERS:
        if pos >= boundary:
            continue
        used = min(remaining, boundary - pos)   # how much fits in this tier
        cost += used * rate
        pos += used
        remaining -= used
        if remaining <= 1e-9:
            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 already reached.

Projecting the bill

A cost-so-far number is useful, but what I actually want is the final bill. So this script extrapolates and 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_before_today / complete_days_so_far
projected = cycle_to_date_total + avg_daily * days_remaining
bill      = SERVICE_CHARGE + cost_for_usage(to_cgl(projected), 0)

There is a confidence band, applied only to the unknown portion: the gallons I'm extrapolating, not the gallons already metered. Early in the cycle most of the total is a guess, so the band is wide but by the last few days almost everything is known and the range collapses toward the real number. The low end is clamped so it can never drop below water I've already used.

band_gal = (avg_daily * days_remaining) * 0.20
proj_low_gal  = max(cycle_to_date_total, projected - band_gal)
proj_high_gal = projected + band_gal

Reasoning with the meter about sprinkler runtime

I have the sprinkler system automation to water on throughout the week, and I want the report to confirm the system actually ran and monitor the incremental cost. Additionally, my pool and pond require infrequent topping as the water evaporates on hot summer days.

The meter reports a cumulative reading at the top of each hour so the delta I compute for 5:00 is really the water used between 4:00 and 5:00. A sprinkler run from 4:30 to 5:30 lands across two hourly buckets. I can't just check whether a single timestamp is "in the window." I have to test whether each hour's bucket overlaps the schedule, which is an interval intersection:

def in_sprinkler_window(dt):
    if dt.weekday() not in SPRINKLER_DAYS:        # M, W, F
        return False
    bucket_start = (dt - timedelta(hours=1)).time()
    bucket_end = dt.time()
    return max(bucket_start, SPRINKLER_START) < min(bucket_end, SPRINKLER_END)

Anomaly flags

On top of the numbers I have three heuristic flags:

  • Spike. Today's usage is more than 2x 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 watering.
  • Missed run. A sprinkler day where the window stayed nearly dry, so the system may not have fired.

What it produces

The script prints a structured report. lil-sis runs it nightly and condenses it into a short Discord summary with the projected bill first. A sanitized run looks like this:

Water Report — Thu Jun 18, 2026 (as of 21:00 EDT)

Today:        212.0 gal   ~$2.16
Yesterday:    240.0 gal   ~$2.44
7-day avg:    228.0 gal/day

Sprinklers today: not a sprinkler day

By period today:
  Overnight (00-05):   18.0 gal
  Morning   (05-12):   96.0 gal
  Afternoon (12-18):   54.0 gal
  Evening   (18-24):   44.0 gal

PROJECTED FULL-CYCLE BILL: ~$76.40  (range $75.60–$77.30)
   6,520 gal projected total, cycle ends Sun 6/21

Billing cycle 5/21–6/21 (day 29 of 31):
  Cycle-to-date: 6,100 gal (61.0 CGL, in tier 4) — ~$53.31 usage + $18.83 service = $72.14

No anomalies flagged.

How it runs: Agents, OpenClaw, and a Mac Studio

The script is only half the project. The other half is what runs it, and that's a small self-hosted agent system sitting on a Mac Studio. None of this touches a cloud LLM.

I run this through OpenClaw, an open-source self-hosted AI gateway (I've contributed to). OpenClaw orchestrates everything: the agent definitions, the messaging channels, and the scheduled jobs. A primary orchestrator agent fields requests and delegates to subagents. One of those subagents is lil-sis, and she's the one who owns the recurring water reports.

The water report is a cron job registered with the gateway. It fires once a day:

schedule:  0 21 * * *        # 21:00 ET, nightly
agent:     lil-sis           # on-device
action:    run water-report.sh, summarize the output to Discord

When it fires, lil-sis runs the Python script, reads the structured text it prints, and writes a short Discord summary with the projected bill on the first line. That summarizing step is a language model, and here's the part that ties the whole thing together: the model is local. lil-sis runs on a Mac Studio (M3 Ultra, 96 GB unified memory) and serves a model through Apple's MLX over an OpenAI-compatible endpoint.

The model choice is deliberate, and it isn't the biggest one that fits. These reports are agentic loops, not single prompts, so a mixture-of-experts model with a few billion active parameters finishes the loop far faster than a dense 70B that times out halfway through. For this kind of always-on work, wall-clock per turn beats raw model size.

Keeping the model on-device isn't just a performance choice. A water meter is a quiet record of when I'm home, when I shower, and when I travel. The whole point of projecting the bill is to read that pattern closely, so I'd rather the pattern never leave the house. The cloud API hands over the raw readings. Everything after that, from pricing the gallons to the nightly summary, happens on hardware I own.

What it doesn't do

  • It doesn't read the meter in real time. Eye on Water lags by a few hours, so this is a daily picture, not a live one.
  • The projection assumes the rest of the cycle looks like the part so far. A one-off event late in the cycle (filling a pool, a guest week) will throw it, which is exactly what the confidence band is for.
  • The leak heuristic is a heads-up not a verdict. It can't tell a real leak from a one-time draw. It just tells me where to look.

The takeaway

Prior to agents, LLMs, etc, this all could be solved with cron, scripts and API usage. But OpenClaw, local models and local hardware are fun to hack on, and this project solves a real world problem for me. Thanks for reading!

Stack: Python, aiohttp, pyonwater (Eye on Water client), a hand-rolled tiered-rate cost model, cron via the OpenClaw gateway and lil-sis.