The Boiler Project — and the context engineering that made it work
A Home Assistant optimisation that turns a hot-water tank into the most valuable battery in the house — and, just as much, a worked example of how deliberate context management turned a rumour and 444k rows of data into a verified, money-saving controller.
This is two write-ups in one file, which is fitting, because the project was two projects in one.
On the surface it's a home-automation story: a 200-litre hot-water tank that had been quietly heating itself during the single most expensive hour of the day, and the Home Assistant controller I built to stop it. That part saves roughly 2,000–2,600 SEK a year.
Underneath it's the thing I actually care about, and the thing this blog keeps circling: it's a worked example of context engineering. Every good decision in the project came from a deliberate context practice, and every one of the three "known facts" I started with turned out to be wrong. The boiler is the demo. The method is the point.
Part 1 — What actually got built
The trigger wasn't the boiler
It started nowhere near the boiler. The worry was financial. Sweden abolished the skattereduktion for microproduction — the "60-öringen," 60 öre/kWh you used to get back for selling solar to the grid — effective 2026-01-01. The rumour that reached me was: now that the tax break is gone, and the grid company charges you for exporting, selling solar at low prices means you're paying to give power away.
Two claims, tangled together. The first is true — I confirmed the abolition against Skatteverket and the riksdag proposition. The second is false, and it's false in plain Swedish: Ellevio's own 2026 price list says "Ingen avgift tas ut för inmatning av Mikroproduktion till elnätet." They don't charge you to export. They pay you a small nätnytta — 5.10 öre/kWh here.
When I measured the actual exposure against 56 days of the house's own price data, the worst-case cost of the thing I'd been told to panic about came to roughly 4 öre for the entire year. The rumour was aimed at the wrong number by five orders of magnitude.
But the investigation surfaced the thing that did matter. An exported kWh now nets only about spot + 5 öre. So the gap between self-consuming a kWh and exporting it widened by a full 60 öre on 2026-01-01. Overnight, that reframed the boiler from "a load I pay to run" into the most valuable battery in the house — a place to park cheap and solar energy instead of selling it for almost nothing.
This is lesson one, and I'll come back to it: interrogate the premise before you solve it. The stated problem was the wrong problem. The real lever was sitting right next to it.
The boiler, in one paragraph
A 200 L NIBE tank with a 3 kW element, switched by a Shelly relay, controlled by Home Assistant. Since April it had been running a dumb "always-on" policy — a deliberate retreat, because an earlier cost-optimiser I'd built once got too clever and produced a cold shower, which in a family house is a strictly unacceptable outcome. In the meantime I'd fitted ten DS18B20 probes up the side of the tank. The job now: use those probes to shift heating into cheap and solar hours without ever risking a cold shower.

What the data actually said (56 days, 444k rows)
The evidence wasn't in Home Assistant's recorder — that only keeps 10 days. It was in a CSV on a Debian box, exported nightly: 56 days, about 444,000 rows. Pulling it and actually analysing it overturned two things I'd written down as fact and found the real fault:
| Belief (written in CLAUDE.md) | Reality (measured) |
|---|---|
| ~9–10 kWh/day demand | 14.0 kWh/day |
| Standing loss is significant | |
| Boiler heats "whenever" | It buys 2.05 kWh at 19:00 and 1.43 kWh at 20:00 — the two most expensive hours — and almost nothing in the four cheapest |
The cause was mechanical, and once I saw it, elegant. The tank's own thermostat probe sits low, near the element. The 18:00 family showers pull hot water off the top of the tank and backfill the bottom with 11 °C mains water. The thermostat, sitting low, sees cold and fires a reheat at 19:00–20:00 — dead on the evening price peak.
But here's the part that makes the fix safe: tank_top barely moves during a draw. Across 56 days it was below 50 °C only 0.30% of the time. So the evening reheat isn't the showers demanding hot water — the top of the tank is still plenty hot. It's the thermostat reacting to cold mains at the bottom. The reheat is thermostat-driven, not shower-driven, which is exactly why blocking it doesn't cost anyone a warm shower.
The controller
The design keeps the tank's own thermostat in charge of the setpoint and only lets the Shelly decide when heating is permitted at all. ON = allowed to heat; OFF = coast on stored heat. The logic is a first-match-wins rule stack:
- Vacation → OFF
- Force heat (also the hook the legionella cycle uses) → ON
- Safety floor — tank top < 52 °C, or a probe is unavailable → ON
- Pre-peak bank (15:00–17:00) → ON
- Solar surplus (with hysteresis) → ON
- Cheap hour (cheapest N of the next 24 h) → ON
- else → OFF (coast)
Note the ordering. Safety sits above every optimisation. A missing probe forces heating on — the system fails warm, never cold. The optimiser only ever gets to act inside the envelope the safety rules allow.
Measured baseline was 24.60 SEK/day. The simulated new policy comes in around 17.7 SEK/day — roughly 2,000–2,600 SEK/year. And I caught it working live the same evening: at 18:57, half an hour after the family showers, the tank was still about 74.5 °C (real temperature), and the element had been off since 18:28. It was coasting straight through the peak, exactly as designed.
The bugs I found on the way
An honest write-up includes the things that were quietly broken:
sensor.solar_surplus_powerhad been stuck at 0 W for 23 days. It was reading the wrong Tibber sensor — the import power, which never goes negative — so the solar override had never once fired in three weeks. Fixed to read real export.- The price sensor excludes energy tax. It's spot × 1.25 (VAT only), not the all-in cost Tibber's docs imply. That single misread shifted every derived threshold by ~45 öre/kWh. I verified the true figure three independent ways before trusting it.
- The cost sensors understated true cost by ~2×. Rebuilt to an all-in, höglasttid-aware model.
- The tank probes read ~6.3 °C low when hot — no thermal paste under the clamp. I measured the error against the paste-clamped outlet probe: the dial is maxed and the tank really banks to ~77.7 °C. I deliberately did not fold that offset into the safety floor. The error isn't constant — it collapses at low temperatures — and I was not about to put false precision on the one code path whose entire job is preventing a cold shower. The safety floor stays conservative on the raw reading.
- I introduced a trigger storm and caught it in the traces. A bare state trigger on a threshold helper fires on attribute changes too, not just state changes — so the automation was re-running every ~15 seconds and flushing the 5-deep trace buffer before I could read it. Fixed by pinning the trigger with
to:.
Part 2 — The context engineering (the actual point)
The controller is maybe half the value. The other half is that the next change to this system will be fast and safe, because the hard-won context is now written down, verified, and loaded automatically. That didn't happen by accident. It came out of a handful of deliberate practices — none exotic, all from the same playbook I've been building across this blog. The discipline is in doing them every time.
1. The persistent memory *was* the deliverable
The most valuable artefacts weren't the automations — they were the memory files and the CLAUDE.md edits. An automation nobody understands is a liability: the next session either re-derives its shape from scratch or breaks it. So the durable, non-obvious conclusions got written down:
reference-tibber-price-sensor-excludes-energy-tax— the single most load-bearing fact in the whole project. Get it wrong and every cost calculation is off by 45 öre. Encoded once, verified, linked everywhere it's relevant.project-solar-export-economics-2026— so the export worry never has to be re-litigated from zero.reference-boiler-history-csv-on-debian-server— where the good data actually lives. This one note saves an hour of hunting every future session.reference-ha-state-trigger-fires-on-attribute-changes— a general Home Assistant lesson extracted from a specific bug, so it can't bite twice.
The rule throughout: write down what was non-obvious and will be needed again; don't write down what the code already says. Memory is a cache of hard-won conclusions, not a diary. That's exactly the distinction I drew when I argued your .md files are a memory architecture — episodic record in one place, distilled understanding in another.
2. CLAUDE.md is always loaded — so it has to be kept honest
CLAUDE.md is read at the start of every session, which makes a stale fact in it actively dangerous — more dangerous than no fact at all, because it's trusted by default. Part of the work was correcting it in flight: the Home Assistant version (2026.4.3 → 2026.6.4), the demand figure (9–10 → 14 kWh/day), the boiler policy, and — most importantly — flipping the governing rule from "never block heating without sensor confirmation" to "OFF is now a legitimate state; never re-assert the old always-on behaviour blindly." A session that trusted the old CLAUDE.md would have quietly fought the new controller.
The corollary is one I keep coming back to: memory and docs are point-in-time claims, not live state. This is the lesson at the heart of the context-rot piece — a note that names a file or an entity is a hypothesis about a system that has moved on since. Every recalled fact that pointed at a sensor or a file got re-verified against the running system before I trusted it.
3. Sub-agents kept the main thread clean
Two chunks of this were genuinely bulky: the regulatory research (a pile of conflicting Swedish sources on export billing) and the data-gathering (444k rows). Both ran as background sub-agents. Around 40 web fetches and the entire row-level analysis stayed out of the main context window — the main thread only ever saw the conclusions.
This is the separation-of-concerns argument applied not to build a product but just to survive an investigation. A scoped agent that only reads and summarises never accumulates the constraint load that degrades the main thread's coherence. And because I was steering rather than absorbing, when a sub-agent's framing came back slightly off I corrected it mid-flight and stopped it early once the crux was settled from primary sources — rather than swallowing all its raw output and letting it silt up the reasoning.
4. Analysis where the data is; results where the reasoning is
The 444k-row CSV never entered the conversation as rows. It was pulled to a scratchpad and chewed on with throwaway Python; only the distilled tables came back. My first regression was garbage — R² of 0.03, and it implied a mains temperature of −220 °C — and that failure was debugged in the scratchpad. What surfaced into the main context was the corrected method and the one-line lesson, not the wreckage. Keeping raw volume out of the reasoning space is what lets the main thread stay a chain of conclusions instead of a log.
5. Verify in the world, not on paper
Every non-trivial change was checked against the running system, not just reasoned about. Automation traces confirmed the rule ordering actually fired in the order I intended. A live template render confirmed the decision logic. The trigger-storm fix was verified by watching the trace cadence settle down. The final push was proven by a forced run. When a "−2.1 SEK" blip showed up, I predicted it and flagged it as a settling artefact rather than mistaking it for a regression. A save is not a proof.
6. Scope discipline under a moving target
Real sessions wander. This one went export economics → boiler → an unrelated "wait, is my redtail container still up?" → three mid-turn pivots on how to get notifications (ntfy → the HA app → "actually I already have the app"). Each got handled without dropping the main thread, and the notification decision landed on the simplest option consistent with the project's own stated preference for native Home Assistant — not the most elaborate one on the table.
The transferable lessons
Strip away the boiler and this is what's left — and it generalises well beyond home automation:
- Interrogate the premise before you solve it. The stated problem was the wrong problem; the real lever was adjacent.
- Measure; don't inherit. Three "known facts" were wrong. The data to correct them was already being collected — it just hadn't been looked at.
- Write down conclusions, not process. Memory is a cache of what was hard to work out, not a transcript.
- Keep raw volume out of the reasoning space. Sub-agents and scratchpad analysis keep the main context a chain of conclusions, not a pile of logs.
- Distrust your own stored notes. Re-verify anything that names a file or a value against the live system before you trust it.
- Verify in the world. A save is not a proof.
- Safety rails survive optimisation. The cold shower was the one unacceptable outcome, and every design choice deferred to it.
The boiler will save something like 2,000–2,600 SEK a year, which is a nice result. But the reason the next change to this system will be quick and safe is that the context — why every rule exists, where the good data lives, which sensors lie — is now written down, verified, and loaded automatically. That's the real return, and it's the same return every project in this series has been quietly compounding.