Electricity Planning Engine: An Hourly Arbitrage API for Batteries and Solar, Plus the Timezone Bug That Almost Broke It
DEV Community

Electricity Planning Engine: An Hourly Arbitrage API for Batteries and Solar, Plus the Timezone Bug That Almost Broke It

A grid that pays you to consume electricity is not a thought experiment. In Germany, wholesale power prices have gone negative often enough that it barely makes headlines anymore: on a windy, sunny afternoon, there is simply more electricity than the grid can absorb, and someone has to be paid to take it. Meanwhile, in most homes, none of that signal reaches the washing machine, the home battery, or the EV charger. They run on habit, not on price.

That gap between "electricity has an hourly price" and "almost nothing reacts to it" is what I set out to close, at a small scale, with the Electricity Planning Engine: a Laravel API that decides, hour by hour over a 24 to 72 hour horizon, whether a household should draw from the grid, charge or discharge a battery, or export solar surplus, based on its energy contract and the market price.

Full source is on GitHub: github.com/adeutou/electricity-planning-engine. This post walks through why the problem matters, then how the solution is actually built, with enough real code that you can follow along or lift pieces of it for your own project.

Why hourly prices are not a gimmick

Germany is the best possible example for this, because it is living the consequences of both extremes at once. On the supply side, negative prices are not an anomaly - they are the visible symptom of an engineering problem: when solar and wind output exceeds demand, the grid has to either store the surplus, curtail it, or pay someone to consume it. Every megawatt-hour that gets curtailed is clean energy that was already generated and still got wasted, while a gas peaker plant somewhere ramps up a few hours later for the evening spike.

On the demand side, Germany has been pushing dynamic, hourly-priced tariffs into the mainstream, with providers like Tibber or aWATTar built entirely around the idea. Grid operators also already have mechanisms to throttle controllable devices like heat pumps and EV chargers during congestion, in exchange for reduced grid fees. The infrastructure for "your appliances should react to the grid" already exists. What is missing, in most homes, is the software that makes the decision.

And then there is Dunkelflaute, the German word for days with neither wind nor sun: an arbitrage engine cannot just chase cheap hours, it has to hold reserve for the peak it can see coming.

Building it: the domain first

I did not start with the arbitrage algorithm. I started with the domain, because if the domain model is wrong, the cleverest algorithm on top of it is still wrong. The rule I followed throughout: app/Domain never imports a single Laravel or Eloquent class. If you can use Illuminate\... in a file, it does not belong in the domain.

Contracts are strategies, not prices

An EnergyContract never computes its own price. It delegates to a PricingStrategyInterface:

interface PricingStrategyInterface {
    public function priceForHour(
        DateTimeImmutable $hour,
        ?PriceSeries $marketPrices = null
    ): Money;
}

$marketPrices is nullable and ignored by most implementations. Only a contract indexed on the wholesale market actually needs it, which is exactly the case a German Tibber-style dynamic tariff maps onto:

final class DynamicSpotPricingStrategy implements PricingStrategyInterface {
    public function __construct(
        private readonly Money $supplierFeePerKwh,
        private readonly Percentage $supplierMargin,
    ) {}

    public function priceForHour(
        DateTimeImmutable $hour,
        ?PriceSeries $marketPrices = null
    ): Money {
        if ($marketPrices === null) {
            throw DomainException::because(
                'DynamicSpotPricingStrategy requires market prices to compute a rate.'
            );
        }
        $wholesalePrice = $marketPrices->priceAt($hour);
        return $wholesalePrice
            ->multiply(1 + $this->supplierMargin->toFraction())
            ->add($this->supplierFeePerKwh);
    }
}

Three other strategies implement the same interface:

  • a flat fixed rate,
  • a peak/off-peak schedule with seasonal rates and daily time windows (midnight wraparound handled explicitly),
  • and a colored-day tariff modeled after EDF's French Tempo scheme, where whole days get reclassified rather than fixed hourly windows.

None of them know the others exist. The arbitrage engine only ever calls EnergyContract::priceForHour(), so adding a fifth strategy for a different market never touches the engine, the API, or the other three strategies.

A battery that refuses to lie about its own limits

The battery is the one piece of the domain where getting the physics slightly wrong would make every downstream decision wrong. It is an immutable entity: charge() and discharge() return a new instance rather than mutating state, which matters later when the advanced engine needs to reason about several possible futures without side effects.

public function charge(Energy $gridEnergyIn, float $hours = 1.0): self
{
    $limit = $this->maxChargeableEnergy($hours);
    if ($gridEnergyIn->isGreaterThan($limit)) {
        throw DomainException::because(
            "Cannot charge {$gridEnergyIn}: exceeds max chargeable energy of {$limit} for {$hours}h."
        );
    }
    $storedIncrease = $gridEnergyIn->multiply($this->chargeEfficiency());
    return $this->withSoc(
        $this->soc->withLevel(
            $this->soc->level()->add($storedIncrease)
        )
    );
}

Two details worth calling out:

  1. chargeEfficiency() is sqrt($this->roundTripEfficiency), applied on both the charge and discharge side, so a 90% round-trip efficiency becomes roughly 94.9% on each leg rather than 90% twice - the standard way to model it without separate manufacturer figures for each direction.
  2. Exceeding a physical limit throws, it does not silently clamp. A caller that asks a battery to accept more energy than it has headroom for has a bug, and clamping the value would just hide it a few requests later.

Two engines, one lesson in greed

V1 is greedy, on purpose. For every hour: let solar cover consumption first, then decide what to do with whatever is left over.

foreach ($horizon->iterateHours() as $hourIndex => $hourStart) {
    $priceNow = $pricesByHour[$hourIndex];
    $pvProduction = $pv->productionAt($hourStart, $hourIndex);
    $demand = $consumption->consumptionAt($hourStart, $hourIndex);
    $consumptionFromPv = Energy::min($pvProduction, $demand);
    $surplus = $pvProduction->subtract($consumptionFromPv);
    $deficit = $demand->subtract($consumptionFromPv);

    if (! $surplus->isZero()) {
        // charge the battery if the price now beats the average of the
        // next few hours, otherwise export the surplus
        [$battery, $batteryCharge, $exportToGrid] = $this->handleSurplus(/* ... */);
    } elseif (! $deficit->isZero()) {
        // discharge if the price now beats the average of the past few
        // hours, otherwise pull from the grid
        [$battery, $batteryDischarge] = $this->handleDeficit(/* ... */);
    }
    // ... build the HourlyDecision for this hour and move on
}

It is simple, it is correct, and it has one specific weakness: a battery can get spent on an hour that only looks expensive relative to its immediate neighbors, and sit empty when the real daily peak shows up later, outside that local comparison window.

V2 fixes exactly that. Instead of comparing each hour to its local neighborhood, it ranks every hour in the whole horizon by price in a first pass - cheapest surplus hours and priciest deficit hours first - and accumulates until the battery's available headroom would be filled. That produces two global price thresholds. A second pass then replays the horizon hour by hour, gating charge and discharge decisions on those global thresholds instead of a rolling average.

I did not want to just assert V2 is smarter, so I wrote a test that proves it: a price series with a moderate bump early in the day, enough to trip V1's local rule, and the real peak much later. V1 spends its battery early and has nothing left for the real peak. V2 recognizes the later peak is the better opportunity and holds capacity for it. Total cost on that scenario: 12.5% lower with V2. Same battery, same prices, just a different notion of "when."

The bug that lied to me for a day

The most interesting bug in this project was not in the arbitrage logic. It was in a class that looked almost too simple to break: PriceSeries, which indexes hourly prices so the engine can look one up by timestamp.

The original version indexed points by an ISO-8601 string that included the UTC offset:

private function key(DateTimeImmutable $hour): string
{
    return $hour->format(DATE_ATOM); // e.g. "2026-07-18T14:00:00+02:00"
}

Two DateTimeImmutable instances representing the exact same instant, but constructed in different timezones, produce two different strings for that instant, and therefore two different keys. The first time I round-tripped a plan through PostgreSQL, this bit me for real: the contract was built in Europe/Berlin, the database returned everything in UTC, and a price that was unambiguously there suddenly came back as "not found."

The cause was subtle because nothing about it looked wrong locally. The contract logic was right. The database mapping was right. It was the equality check itself, hiding one layer below both of them, comparing string representations of time instead of the instant they represented.

The fix:

private function key(DateTimeImmutable $hour): int
{
    return $hour->getTimestamp();
}

A Unix timestamp does not care what timezone produced it. I paired the fix with normalizing every timestamp to UTC at the Eloquent persistence boundary, and with a permanent regression test that constructs the same instant in two different timezones and asserts the lookup still succeeds - because this is exactly the kind of bug that comes back quietly if nothing is watching for it.

Try it yourself

git clone https://github.com/adeutou/electricity-planning-engine.git
cd electricity-planning-engine
docker compose up -d --build

That starts the API, PostgreSQL, and Redis. Then ask it to plan a day for a flat-rate contract with a 6 kWc solar system and a 10 kWh battery:

curl -X POST http://localhost:8000/api/simulate \
  -H "Content-Type: application/json" \
  -d '{
    "contract": {"country_code":"DE","zone":"DE","contract_type":"fixed","pricing_config":{"price_per_kwh":0.30}},
    "horizon": {"start":"2026-07-20T00:00:00+02:00","end":"2026-07-21T00:00:00+02:00"},
    "mode": "advanced",
    "pv": {"peak_power_kwc": 6},
    "battery": {"capacity_kwh": 10, "initial_soc_percent": 50}
  }'

The response includes an id. GET /api/plans/{id}/chart.svg renders the resulting plan as a chart, server-side, no JavaScript involved, straight in a browser tab.

The pipeline behind it

Every push runs the full test suite on PHP 8.2 and 8.3 (the range declared in composer.json) and validates the Docker build. Nothing gets published on an ordinary push - a release is a deliberate action:

on:
  push:
    tags:
      - 'v*.*.*'
permissions:
  contents: write          # create the GitHub Release
  packages: write          # publish to ghcr.io
jobs:
  tests:
    uses: ./.github/workflows/tests.yml
  publish-image:
    needs: tests
    # ... build and push ghcr.io/adeutou/electricity-planning-engine:<version>
  release:
    needs: publish-image
    # ... create the GitHub Release with generated notes

Pushing git tag v1.0.0 && git push origin v1.0.0 is the only thing that triggers a publish. Everything else, every ordinary commit, only has to prove it still builds and passes.

Why this matters beyond the demo

This project is a technical demonstration, but the problem underneath it is not a toy one. As more homes add solar panels, batteries, and EVs, and as the grid leans harder into intermittent generation, "when should this device consume, store, or sell" stops being a nice-to-have and becomes both a household economics question and a grid stability one. Germany is not a special case here - it is just further along the curve, which makes it the clearest place to see what everyone else is heading toward.

Writing this kind of decision logic properly, with a domain model that actually holds up and algorithms whose behavior you can prove with a test instead of just hoping for, is exactly the kind of backend engineering work I want to keep doing.

Source, tests, and the full architecture write-up: GitHub. MIT licensed, issues and forks welcome.

Comments

No comments yet. Start the discussion.