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.