Every 30 minutes, a Python script on a tiny Linux box in my flat asks itself the same question: should the battery charge from the grid right now, later in this cycle, or wait for tomorrow?
Most home batteries answer it the simple way — is the rate cheap? If yes, charge. It works. But it also leaves money on the table whenever a cheaper slot is coming tomorrow that you've already spent your headroom to grab today. So mine does the more annoying thing. It compares.
The Comparison
Octopus Agile publishes next-day rates at 16:00 — typically 46 of the 48 half-hour slots, with the final hour filling in within minutes. The planner refuses to commit until ≥44 slots are published; from that point it has two full cycles in view (whatever's left of today plus all of tomorrow) and finds the single cheapest slot in each.
The core decision compares the two. If this cycle's cheapest is ≥3p below next cycle's, the planner banks now: fill toward 95% SOC and ride tomorrow on the cheaper kWh bought tonight. If next cycle is the same or cheaper, there's no banking opportunity — just top up to the dynamic SOC target and let tomorrow's slots handle tomorrow.
The SOC-Aware Ceiling
Cheap doesn't always mean worth grabbing. Every charge decision is gated by an SOC-dependent price ceiling — buying expensive kWh into a near-full battery wastes round-trip losses on barely-arbitrageable energy. Measured end-to-end on this kit: ~10% at the AC→DC charger, ~3% inside the LFP pack, 8–12% on the DC→AC inverter, ~20% total. For a 15p import to clear after losses, the avoided peak-rate has to exceed ~19p.
The ceiling slides on a piecewise curve, anchored on the round-trip break-even at 70% SOC: 100% → 1p, 90% → 5p, 80% → 10p, 70% → 15p, 60% → 19p, 50% → 23p, 40% → 27p, 30% → 31p, 20% → 35p. Below 20% there's no ceiling — charge at any price rather than starve the microgrid.
The same curve also caps the fill length. Once a slot has been admitted, the planner inverts the curve to find the SOC at which the ceiling equals the slot price, and clamps the fill target there. Without the clamp, a 22p slot admitted at 50% SOC would charge all the way to the dynamic target — but by 60% SOC the curve's ceiling has dropped to 19p, so the last kWh would be paid for at a price the curve itself says is no longer worth it. With the clamp, the same slot fills only to ~52.5% (the SOC where the curve permits 22p) and stops.
Plunges Override Everything
Plunge slots (rate ≤ 0p) bypass every gate. The charger runs even on a full battery — the runtime itself is paid for at the negative wholesale rate, so refusing free money is a bug.
Transient Loads
Bridge-skip's drain prediction uses a 30-day rolling average of daily usage — fine for routine days, but a transient load like an e-bike charger doesn't show up in that average. So when one of the four bike-battery smart plugs flips on, the planner reads its live W draw and the matching estimated_charging_time sensor, multiplies them out, and adds the result to the bridge-window drain forecast.
If the load is small (a 60 W trickle finishing in half an hour), the prediction barely moves and bridge-skip still defers — exactly what I want when I'm just topping a battery up. If the load is real (1 kW for hours during a Deliveroo shift), predicted SOC drops below the floor and the planner switches to charging. Three new mode tags surface this on /solar/ as a violet band: bridge_skip_bike (deferring with bike on), target_bike and capacity_bike (charging because of bike load) — distinct from the green/amber modes so the chart shows when bike sessions actually moved the needle.
The Safety Net
Bridge-skip math has one more failure mode worth flagging. If predicted drain almost exactly matches the expected solar offset, the math says "SOC stays flat at 31%" — technically above the 30% bridge floor, but with zero margin for forecast error or any surprise load. That's not actually safe; it just looks safe.
So: if current SOC sits within 10 percentage points of the bridge floor (40% at the current settings) and today's forecast solar can't cover the day's typical usage (< 2 kWh), the planner overrides bridge-skip and lets the normal charging path run. The downstream stages still pick the actual hours based on whichever Agile slots are cheapest under the SOC-aware ceiling — the override only unlocks the path, it doesn't decide duration. Cheap-day pricing still shapes the response.
Don't Disturb a Running Charge
Each tick computes target hours from scratch and pushes the result into Home Assistant. Most ticks the answer is the same as last time, or zero — but if a charge is already underway and a later tick decides on a slightly smaller number (4.5h dropping to 4.0h half an hour later), naively pushing the new value reconfigures the slot picker, flaps the target_timeframes sensor, and re-triggers the recharge automation on top of itself. All a no-op functionally, but real churn — and the kind of churn where one race condition could one day brown out the relay.
The apply step now checks: if the recharge timer is currently running and the new hours value is less-than-or-equal-to what's already configured, skip the push entirely. The active chain already covers it. Increases still push (more charging is genuinely needed) and zero pushes still flow through (some modes use them to deliberately end an in-flight timer). Decision payload records skipped_push_inflight so the no-op is visible in the history.
The Twenty-Stage Pipeline
The 20 in pipeline order: check_rates → read_soc → compute_cycle_bounds → balance_complete_check → balance_display → count_published → top_of_curve → idle_at_ceiling → compute_gaps → predict_slots → project_drain → clamp_target_to_break_even → at_target_skip → price_ceiling → arbitrage → refine_floor → bridge_skip → choose_fill → greenness_trim → cycle_cap_and_apply.
Each one either short-circuits the run (early-exit when SOC's already at ceiling, no published rates, etc.) or enriches the decision context for the next.
A Real Decision From Today
Hero image, 2026-05-03 13:05 BST. SOC 61.3% — dynamic target was 88.1%, but the curve says today's cheapest slot (18.5p) only clears up to 61.2% SOC. The clamp_target_to_break_even stage drops the fill target from 88% to 61%; soc_now is already there. Next cycle's cheapest at 18.8p — essentially the same, no banking opportunity either. fogstar_target_hours = 0.0.
The pipeline re-runs every 30 minutes against fresh SOC, prices and solar forecast. Most outcomes are no-ops — the value is in the small minority where the right answer isn't obvious.
Four Days of Iteration
An earlier version of this lived in Home Assistant — half a dozen YAML triggers, template sensors, and increasingly baroque Jinja conditionals. It worked on the easy paths but not the edge cases, and end-to-end the logic was hard to reason about.
Rewrote in Python on 28 April 2026 — a single function that picked the cheapest slot and ran the charger. Four days later it's 20 ordered stages threading a PlannerCtx dataclass: SOC-aware ceiling, cycle-vs-cycle banking, plunge floor promotion, balance-complete detection, drain projection, SQLite history. The two stages firing in this morning's hero image — the piecewise SOC→price curve and the at-target early-exit — were both added today.
Why Bother
Before the planner I was setting an hours slider in Home Assistant each afternoon, aiming for ~40p of grid spend per day. Cheap rates → more hours; expensive rates → fewer. Manual, easy to forget, and worse than no charge at all if the eyeballed mix was off.
Now I don't touch it. The planner sees every slot, knows tomorrow's cheapest cycle (forecast or published), knows when the battery's already covered, and decides accordingly.
Overcast week ahead — low solar yields, more nights where the battery does the heavy lifting. The comparison logic exists for exactly that case: when solar is absent and the only lever left is Agile timing.
The £ shows up on the ROI page — about £58 of arbitrage profit on top of solar savings to date. The current cycle's reasoning is always live on /solar, regenerated within seconds of every planner run.
Takeaway for anyone running a comparable setup: stop trying to express decision pipelines in YAML. HA is the right surface for events, triggers and dashboards — that's its sweet spot. Anything that's actually arithmetic, ordered logic, or stateful ("what was the answer 30 minutes ago?") belongs in a Python script alongside it: cron-driven, reads HA state, writes back via input_number / input_datetime. Complementary, not a replacement.