AI Battery Optimiser

How I taught Home Assistant to schedule my home battery from solar forecasts, the Octopus Agile price curve and my own usage history, to cut grid import.

AI Battery Optimiser

For about a year my home battery did something almost criminally stupid every single night. It charged at full whack from the grid at 02:00, because that is when the cheap window used to be, and then it sat there proudly full while the sun did the actual work the next morning. By lunchtime the panels had nowhere to put their output and I was exporting electricity for pennies that I had bought, hours earlier, for more pennies.

The battery was working exactly as configured. That was the problem. It was following a fixed schedule I had set once and never revisited, and a fixed schedule is wrong on every day that does not look like the day you set it up on. Some days are sunny. Some days are filthy with cloud. And on a half-hourly tariff the price of grid electricity swings around so violently that “charge at night” is not a strategy, it is a superstition.

So I did what I keep doing in this lab: I replaced a static rule with something that looks at the actual conditions and makes a decision. This is the story of the battery optimiser I bolted onto Home Assistant — what it predicts, how it decides, and the unglamorous truth about how much “AI” is really in it. The honest answer is “not much, and that was the right call”, which is a theme I keep coming back to in why most AI projects fail.

The problem with a flat schedule

Let me be specific about the money, because the money is the whole point.

I am on a time-of-use tariff in the Octopus Agile style: the price of imported electricity is set in half-hour blocks and published the afternoon before for the following day. On a good day the overnight price might dip to a few pence per kWh, and the early-evening peak might be five or six times that. Occasionally, when the grid is awash with wind, the price goes negative and they pay you to consume. The spread between the cheapest and most expensive half-hour on the same day is routinely enormous.

A flat schedule cannot see any of that. It charges in the same window whether that window is the cheapest of the day or merely average. Worse, it has no idea what tomorrow’s solar is going to do. If I fill the battery from the grid overnight and then wake up to a cloudless June day, I have paid for energy I did not need and I have left no room in the battery for free solar, so the surplus gets exported at the derisory export rate. I have managed to lose money twice with one decision.

The naive fixes are all rules, and rules accumulate. “Charge at night, but only to 80% in summer.” “Don’t charge if tomorrow’s forecast is sunny.” “Discharge during the evening peak, unless the battery is below 30%.” Every one of those is a reasonable instinct and every one is a hard-coded threshold that is wrong in some corner of the year. I have lived in that house of rules. It is exhausting to maintain and impossible to reason about once you have more than about four of them interacting.

A schedule encodes a decision you made in the past. A forecast lets you make the decision with the information you actually have.

That sentence is basically the entire design philosophy, so I want to put it up front before I describe the build.

The decision: forecast-driven, not rule-based

The core decision was to stop writing rules about when to charge and instead compute, fresh every evening, the cheapest possible way to get the house through the next 24 hours given everything I can forecast.

That reframes the whole thing as an optimisation problem. I am not asking “is it night-time?” I am asking “across the next 48 half-hour slots, when should the battery charge, when should it discharge, and when should it hold, so that my total cost of importing from the grid is as low as possible — subject to the battery never doing anything that harms it or leaves me stranded?”

To answer that I need three inputs, refreshed daily:

  1. A solar forecast for the next day, in roughly half-hourly resolution. I pull this from a Solcast-style forecast service that knows my panel orientation and tilt and gives me expected generation per slot.
  2. The price curve for the next day — the Agile import prices, and the export rate, fetched once the afternoon prices are published.
  3. My own household load profile — how much the house actually draws, slot by slot, learned from months of historical data rather than guessed.

The third one is where the only genuine machine learning lives, and it is deliberately tiny. I will come back to that, because the restraint is the interesting part.

I rejected two tempting alternatives. The first was buying a commercial “smart charging” add-on from the inverter vendor. It works, but it is a black box — I cannot see why it made a decision, I cannot tune the SoC targets, and it has no idea about my specific load shape. The whole reason I run a lab is so that I own the logic. The second was going the other way and building something far too clever: a reinforcement-learning agent that would “learn the optimal policy” over time. That is a thesis project, not a battery controller, and I talk below about why I am glad I did not.

The “AI” honesty

This project lives under the AI category and I want to be straight about what that means, because the gap between what gets called AI and what is actually doing the work is the thing I find most tiresome in this industry.

There is no large language model anywhere near my battery. There would be no point. An LLM is a tool for turning language into language; deciding how many kWh to pull from the grid at 18:30 is a numerical optimisation over a known cost function. You do not need a 70-billion-parameter model that has read the entire internet to do arithmetic with constraints. You need a forecast and a solver.

The actual “intelligence” is two things working together. One is forecasting: the solar service predicts generation, and a lightweight model predicts my load. The load model is genuinely learned — it is a per-slot profile built from historical consumption, segmented by day-of-week and adjusted for recent drift, the sort of thing you can do with a few weeks of data and some sensible averaging, no GPU required. Two is optimisation: given those forecasts and the price curve, a small solver decides the charge/discharge/hold schedule that minimises cost.

I resisted over-engineering this, and resisting was a conscious effort. It would have been more fun to train a neural net. It would not have been better. The optimisation problem here is small, well understood, and has a known structure, and throwing ML at a problem that linear programming already solves cleanly is how you end up with something impressive in a demo and unmaintainable in production. This is the same argument I make at length in why most AI projects fail: the failure is almost never the model, it is reaching for a model when you needed a function. Picking the right tool, even when the right tool is boring, is the actual skill. Or as I put it in AI is becoming infrastructure — the win is when the cleverness disappears into plumbing you stop thinking about.

The model is not the product. The decision is the product.

How it fits together

The whole thing rides on the Home Assistant instance that already runs the real-world automation side of the lab I described in building an AI infrastructure lab at home. Home Assistant is the integration layer: it already talks to the inverter and battery over the vendor integration, it already has the solar forecast and Agile price sensors, and it already logs everything to its recorder database.

The optimiser itself is a Python routine that runs nightly under pyscript inside Home Assistant (AppDaemon would do the same job; I went with pyscript because it keeps the code inside the HA config and out of a separate container). It wakes up once the next day’s Agile prices are published, gathers the three inputs, runs the solve, and writes the resulting schedule into a set of helper entities. A handful of automations then read those entities and actually push charge/discharge commands at the inverter.

Notice the loop at the bottom: yesterday’s real outcomes feed back into the load profile, and everything that happens is logged to a dashboard so I can see what the optimiser decided and whether reality agreed. That feedback and that observability are not decoration. They are how I came to trust the thing enough to leave it running unattended.

The override deserves a mention too. There is a single input_boolean that, when on, freezes the optimiser and hands control back to a safe default. If guests are staying, or I know I am about to run the oven and the tumble dryer and charge a car all at once, I flip the switch and the system stops being clever. A system you cannot easily switch off is a system you will eventually rip out.

Here is the automation that drives a single slot — deliberately dumb, because all the thinking already happened in the optimiser:

automation:
  - alias: "Battery - apply optimised slot"
    trigger:
      - platform: time_pattern
        minutes: "/30"
    condition:
      - condition: state
        entity_id: input_boolean.battery_optimiser_override
        state: "off"
    action:
      - variables:
          slot: "{{ now().hour * 2 + (now().minute // 30) }}"
          mode: "{{ state_attr('sensor.battery_schedule', 'slots')[slot] }}"
      - choose:
          - conditions: "{{ mode == 'charge' }}"
            sequence:
              - service: number.set_value
                target: { entity_id: number.inverter_charge_power }
                data: { value: 3000 }
          - conditions: "{{ mode == 'discharge' }}"
            sequence:
              - service: number.set_value
                target: { entity_id: number.inverter_charge_power }
                data: { value: -3000 }
        default:
          - service: number.set_value
            target: { entity_id: number.inverter_charge_power }
            data: { value: 0 }

And here is the heart of the optimiser — a stripped-down sketch of the solve. The real version uses a small linear-programming library, but the shape is exactly this: walk the slots, respect the battery’s state-of-charge limits, and at each step prefer free solar, then cheap grid, then stored energy, never letting the battery go below its health floor.

# nightly_optimiser.py  (pyscript)
# Inputs are 48 half-hour slots for the next day.

BATTERY_KWH   = 10.0      # usable capacity
CHARGE_MAX    = 1.5       # kWh movable per slot (3kW for 30 min)
SOC_FLOOR     = 0.15      # never deep-discharge below 15%
SOC_CEILING   = 0.95      # protect the cells at the top end
SOC_TARGET_AM = 0.30      # reserve for the morning before solar kicks in

def optimise(price, export, solar, load, soc_start):
    soc = soc_start * BATTERY_KWH
    schedule = []
    # Rank slots by how cheap grid import is; charge in the cheapest
    # ones only if tomorrow's solar will not already fill the battery.
    expected_solar = sum(solar)
    cheap_slots = sorted(range(48), key=lambda i: price[i])

    plan = ["hold"] * 48
    for i in range(48):
        surplus = solar[i] - load[i]          # free energy this slot
        if surplus > 0:
            plan[i] = "charge"                 # soak up solar first, always
        elif price[i] > export * 3 and soc > SOC_FLOOR * BATTERY_KWH:
            plan[i] = "discharge"              # beat the peak with stored energy

    # Only pre-charge from the grid if solar won't get us to target.
    if expected_solar < BATTERY_KWH * (SOC_CEILING - SOC_TARGET_AM):
        for i in cheap_slots:
            if soc >= SOC_TARGET_AM * BATTERY_KWH:
                break
            if plan[i] == "hold":
                plan[i] = "charge"
                soc += CHARGE_MAX

    return _enforce_soc_limits(plan, solar, load, soc_start)

The _enforce_soc_limits pass is the safety net and it is non-negotiable: it walks the plan forward, tracks the running state of charge, and downgrades any instruction that would push the battery below the floor or above the ceiling to a hold. The optimiser is allowed to be wrong about price and weather. It is never allowed to deep-discharge the battery. Safety constraints are not part of the objective function — they are hard limits that sit outside it, because a constraint you can trade away for a few pence is not a safety constraint at all.

What I got wrong, and what the data taught me

The first and biggest lesson: forecasts are wrong, so build for the error, not the forecast. My early version trusted the solar forecast as if it were a measurement. It would decline to pre-charge overnight because Solcast promised a brilliant day, and then a band of cloud would roll in and I would spend the morning importing at full price with an empty battery. The fix was not a better forecast. The fix was humility: I now keep a morning reserve (SOC_TARGET_AM above) so that even if tomorrow’s free energy fails to show up, I have a buffer to ride through the morning without paying peak rates. I treat the forecast as the central guess of a distribution, and I leave headroom for the times it is wrong. That single change did more for real-world savings than any tuning of the optimiser.

The second lesson: modelled savings and real savings are different numbers, and only one of them pays your bill. My optimiser will happily tell me it saved me a tidy sum versus a flat schedule. But that figure is computed against the same forecasts the optimiser used to make its decisions, so of course it looks good — it is marking its own homework. The number I actually trust is measured: total metered grid import cost this month versus the same month last year on the old schedule, normalised for the weather. It is a smaller, messier, more honest number, and watching it is the only thing that told me the project was genuinely worth it.

The edge cases are where the design earned its keep. Cloudy days are handled by the morning reserve. Export pricing turned out to matter more than I expected: on days with a high export rate, there are slots where it is genuinely better to discharge the battery to the grid for profit than to hold it, which is why the discharge condition compares import price against a multiple of the export rate rather than treating export as worthless. And battery wear is a quiet, long-term cost that no single day’s optimisation can see — every full cycle ages the cells, so I cap how aggressively the system cycles the battery for marginal pennies. Chasing a 4p arbitrage that costs me a measurable slice of battery lifespan is a bad trade dressed up as a clever one.

Underpinning all of it is observability, and I cannot overstate how much this mattered. I built a Grafana panel — fed from the same Prometheus stack I use for the rest of the lab — that overlays four lines for each day: the price curve, the forecast solar, the actual solar, and what the battery actually did. The first week I had that dashboard up, I caught two bugs in an afternoon that I would never have found from the bill alone. You cannot trust an automated system you cannot see inside. Building the dashboard before fully trusting the optimiser is the same instinct I apply to everything I automate, and it is the difference between a tool and a liability. If I cannot watch it think, I do not let it touch the inverter.

Where this goes next

There are three concrete things on the roadmap, in roughly the order I will tackle them.

The first is tighter ML on the load profile. Right now the household model is a fairly blunt per-slot average segmented by weekday. It does not know that a cold snap means the heat pump will run hard, or that a Friday in term time looks different from a Friday in the holidays. Feeding in weather and a calendar signal and using a proper short-horizon regression would sharpen the load forecast, and the load forecast is currently the weakest of my three inputs. This is the one place I would let a bit more ML in — but only because I can measure whether it actually beats the simple average, and I will rip it out if it does not.

The second is EV charging coordination. At the moment the car charger and the battery optimiser are two systems that do not talk, which means they occasionally fight over the same cheap slots. Folding the car’s charging demand into the same optimisation — it is just another load with a deadline and a flexible schedule — would let me fill the car and the house from the same cheapest half-hours instead of competing for them. This is the upgrade I am most excited about, because the car is by far the biggest movable load in the house.

The third is dynamic export. Some tariffs now pay a variable, half-hourly export rate, and the moment that is true, the discharge decision becomes a proper arbitrage problem: buy low overnight, hold through the cheap-export daytime, sell into the export peak. My current logic treats export as a flat fallback. Making it forecast-driven on both sides — import and export — is the natural completion of the original idea.

I would also like to wire the daily schedule into Atlas, my local assistant, so I can ask “why did the battery not charge last night?” and get a plain-English answer pulled from the optimiser’s own logs rather than squinting at a Grafana panel. That is a small, genuinely useful place for an LLM — explaining a decision after the fact, not making it.

Closing thought

The thing I keep turning over about this project is how little of it is the part everyone wants to talk about. The “AI” headline is a per-slot average and a small solver. The real engineering is in the boring scaffolding: the morning reserve that admits the forecast will be wrong, the hard SoC floor that sits outside the objective function, the override switch, the dashboard that let me trust it. None of that is clever. All of it is necessary.

I think that is the actual lesson of building useful automation, and it generalises well beyond batteries. The intelligence is cheap and getting cheaper. The judgement about where to put a hard limit, how much to trust a forecast, and how to make a system you can see inside and switch off — that is the work, and that is the part no model is going to do for you. My battery still does something every night. These days it is the right thing, most of the time, and I can see exactly when it is not.