Production, storage, and a forecast graded daily.
Run a battery on a time-of-use rate so it makes money and lasts —
then prove the whole thing live, in public.
an experiment in progress
Electricity has a rush hour. At our house it starts at 4 PM — in summer every kilowatt-hour costs 2.24× what it did at noon — exactly when the roof’s solar is winding down. But at noon, the panels make more than the house can use. The whole experiment is one move: catch the cheap sun, hold it in a battery, and spend it during the expensive evening — automatically, every day, without wearing the battery out.
Can a home battery save the most money and last the longest — automatically, every day, even in December?
The two halves fight. Saving money wants the battery full before every expensive evening; lasting wants it to spend as little time full as possible, because sitting at 100% is what ages its cells. A strategy is only interesting if it serves both at once — and keeps working on a rainy winter day. That tension is the experiment.
Everything below follows from three measured facts. First, the price of power follows the clock. We’re on PG&E’s EV2-A rate (NEM 2.0) — the hours never change, only the prices:
| window | hours | summer (Jun–Sep) | winter (Oct–May) |
|---|---|---|---|
| Off-peak | 00:00–15:00 | $0.25 | $0.25 |
| Mid-peak | 15–16 + 21–24 | $0.45 | $0.41 |
| Peak | 16:00–21:00 | $0.56 | $0.43 |
The hypothesis: charge from sunshine, arrive at 100% just in time for the expensive evening — never early — rest low overnight, and buy from the grid only as a cheap fallback when the sun falls short.
Stated as a spec, in priority order:
Goals 1 and 4 pull against each other, and that tension is the strategy. Here is what one working day looks like — measured on this system, where the evening peak consumes almost exactly one full pack (100% at 15:00 falls to the 20% reserve at ~21:20, minutes after peak ends):
Our first automation ran flawlessly every night — and achieved none of its goals. Four findings, each paid for with real kilowatt-hours:
The first automation set the reserve to 60% and enabled grid charging, believing the pack would stop there. It doesn’t — with grid charging on, a Powerwall charges to 100% regardless. The result: ~10.4 kWh bought nightly (twice the intent), 13.3 hours a day parked at 100% — identical to the behaviour it was written to eliminate. Trace your automations against measured state; a clean run log proves nothing.
Measured: 20 → 71% runs flat at 5.7 kW and takes one hour; 71 → 100% tapers hard and takes two more. So “top up just before 3 PM” cannot work as a guarantee — a worst-case fill must start by noon. Hence two gates: 12:00 catches the deep deficit (SoC < 70%), 13:30 catches the shallow one (SoC < 95%) without committing early to power the afternoon sun was about to deliver anyway.
Under NEM 2.0 the broken overnight charge cost roughly $0 — the midnight purchase and the displaced midday export were both $0.25. The real cost was battery wear: a full extra cycle plus 13 h/day at high SoC, the exact calendar-ageing the design existed to avoid. No dashboard tile will ever show that number. If you only watch dollars, this class of bug lives forever.
The elegant-sounding move is a solar forecast deciding the morning’s charge. But at 12:00 the battery’s own state of charge is the measurement of how much sun actually arrived — no forecast needed for the fallback decision. The forecast earns its keep elsewhere (Part II), and only if it proves it can beat this simple rule.
A strategy that only works in July isn’t a strategy. From 1,041 days of production records (Part II explains how we got them):
unknown until the first event ever fires). The 20% reserve is
untested outage insurance by design. And NEM 3.0 changes the arithmetic
entirely — export credits collapse, and the forecast-driven version becomes the
money-maker.
The rules above are simple on purpose. Could a weather forecast run the day better? Maybe — but first it has to prove it, in public.
Can we predict tomorrow’s solar, for this exact roof, better than a commercial forecasting service?
Our approach: replace a textbook solar-forecast formula with a curve fitted to our own roof, our own weather station, and 2.8 years of our own production history. The model runs twice a day on a Mac mini, predicts tomorrow’s solar in kWh, writes every prediction to a database beside what actually happened, and scores itself. A commercial forecast (Forecast.Solar) runs beside it on the same leaderboard, as a rival — never a target.
Forecasting solar production sounds like one hard problem, but it decomposes into three arrows — and two of them were already solved on this homelab:
forecast cloud % → forecast sunlight (GHI) ← the ONLY empirical unknown
forecast GHI → irradiance on OUR roof ← deterministic astronomy
roof irradiance → AC kilowatt-hours ← measured system efficiencyThe astronomy (NOAA solar position → Haurwitz clear-sky → Erbs diffuse split → Hay–Davies transposition onto the real 23° / 245° + 155° roof planes) already ran at ingest, validated to 0.0000% against an identity control.
The efficiency is measured end to end — actual kWh ÷ clear-model kWh over recent verified-clear days — one number that absorbs every systematic bias in the chain and re-calibrates itself as seasons change.
The transmission arrow — how much light this valley’s clouds actually block — is the only thing that had to be learned, and it is exactly the thing a generic vendor model cannot know about one microclimate. Hourly cloud forecasts come free from two providers already in Home Assistant (met.no and OpenWeatherMap, 48 h ahead), which also makes them competing inputs, scored against each other.
The shortcut — 2.8 years of data we already owned. The Tesla app renders
production history back to install day, and the Fleet API serves the same data:
1,165 days (2023-05-30 → present), pulled in one run, verified three independent
ways — the lifetime total agrees with the documented lifetime counter to 0.1%; overlap
days match our own meter to a consistent ~3%; and the record starts on exactly the
install date, with empty months before it. Each day was labelled
actual ÷ clear-sky ceiling — its transmission ratio — and paired with
ERA5’s hourly observed cloud for the same dates. That’s the training set that would
otherwise have taken months of waiting for cloudy days to accumulate. One
boundary was measured, not assumed: the first four months ran export-capped pending
PG&E permission-to-operate, so those 124 days were deleted — they measure the
cap, not the sky.
| model | holdout MAE (all 218 days of 2026, never seen during fitting) |
|---|---|
| Fitted: T = 1 − 0.36·(c/100)5.20 | 3.97 kWh/day |
| Kasten–Czeplak textbook seed | 5.14 |
| Cloud-blind (ignore clouds entirely) | 5.28 |
23% better than the textbook, 25% better than ignoring clouds — on data the fit never touched. Training was 823 post-PTO days paired with hourly observed cloud from ERA5 reanalysis, efficiency always solved from the train set so the holdout stays honest.
a = 0.36 is the genuinely interesting part: the textbook curve (fit to
1980s Hamburg) assumes clouds block 75% of light at full cover — our sky, as ERA5 sees
it, passes roughly twice what Hamburg’s did. Marine-layer optics, plus
grid-cell “overcast” rarely meaning locally black. That one number is why the seed was
systematically wrong here, and it’s knowledge no vendor model would have handed you.
And the first real test was scheduled by the weather: the first cloudy forecast day split the sources for the first time (met.no 29.2 kWh vs OWM 24.6) — until then every prediction had been a sunny-day handshake. One caveat stays standing: the curve was trained on reanalysis cloud and runs on forecast cloud — that gap is exactly what the daily scoring loop exists to measure.
The rules the pipeline enforces on itself — the part worth publishing. They stop being claims the moment the scoreboard below goes stale-checkable.
Forecast.Solar runs on the same leaderboard, configured honestly with the real roof geometry — but only actuals ever tune our model. Tuning to a rival teaches imitation, not prediction.
The curve was fitted on 2023–2025 and judged on 2026 only. The efficiency scale was always solved from the train set — the holdout stayed untouched until the final comparison.
Rows are tagged with the model that made them (kc_seed_v1,
omfit_v1, vendor), so no average ever mixes eras. When the
model is refit, this page shows the era boundary — never a merged score.
The forecast controls nothing. The battery-charging automation keeps its simple, static rules until the projection demonstrably beats them on decision accuracy — “would we have needed the grid?” — not just kWh error. House rule: a new job reports; it does not act. Authority is earned.
Until the record contains genuinely cloudy days, accuracy numbers are vacuously good — sunny-day agreement tests the astronomy, not the forecast. The daily report states this itself, and so does the scoreboard below.
This is the results section that never stops updating. Every run writes predictions for today and tomorrow from three sources — our model on met.no clouds, our model on OWM clouds, Forecast.Solar — then back-fills what actually happened onto every completed day and reports MAE per source and per horizon. Day one of the benchmark: for a forecast-sunny tomorrow, our model said 39.3 kWh, Forecast.Solar said 27.7 — against recent sunny-day actuals of 39–41. The leaderboard settles it from here.
The forecast controls nothing today — that’s rule 04, not an accident. The battery keeps its simple, static rules until the projection demonstrably beats them on decision accuracy — “would we have needed the grid?” — across a window that includes genuinely cloudy days. If it wins, it earns the noon decision, with the static rule kept as the fallback. Either way, the scoreboard above keeps score in public.