Hi all,
I’ve been working on a battery dispatch optimizer that generates
hourly charge/discharge schedules for battery systems on dynamic tariffs.
Thought I’d share the implementation since I’ve seen a lot of interest in
this space.
What it does
Instead of simple “charge during cheapest N hours” logic, it runs a
Mixed-Integer Linear Programming (MILP) solver that co-optimizes across the
full day — finding the optimal combination of charge/discharge cycles given
the complete price curve. On a typical NL day with solar midday dip + evening
peak, it finds 2-3 cycles per day rather than just one.
The schedule updates every 2 hours and covers 7 days ahead:
- Day 1-2: Uses actual published day-ahead prices (EPEX)
- Day 3-7: Uses a machine learning price forecast (trained on weather, price
history, calendar patterns)
Tested on 12 months of NL day-ahead data with a 5kW/10kWh battery spec, the optimizer
captures ~25% more revenue than a ToU baseline.
How it talks to the hardware.
The schedule output is a simple JSON array of hourly
actions:
[
{“hour”: “01:00”, “action”: “charge”, “power_kw”: 5.0},
{“hour”: “02:00”, “action”: “idle”, “power_kw”: 0},
{“hour”: “06:00”, “action”: “discharge”, “power_kw”: 5.0},
{“hour”: “13:00”, “action”: “charge”, “power_kw”: 5.0},
{“hour”: “18:00”, “action”: “discharge”, “power_kw”: 5.0}
]
A Home Assistant automation can fetch the schedule and write the
AcPowerSetpoint via MQTT every 30 seconds (the keep-alive requirement).
So the .yaml would be something like this:
# configuration.yaml
rest:
* resource: “http://YOUR_SCHEDULE_API/v1/schedule”
headers:
X-Api-Key: “YOUR_KEY”
scan_interval: 7200 # refresh every 2h
sensor:
* name: “Battery Dispatch Schedule”
value_template: “{{ value_json.days[0].price_source }}”
json_attributes_path: “$.days[0]”
json_attributes:
* hourly
* revenue_eur
* cycles
# automations.yaml (Example for Victron)
* alias: “Victron ESS Dispatch”
trigger:
* platform: time_pattern
seconds: “/30”
action:
* variables:
schedule: “{{ state_attr(‘sensor.battery_dispatch_schedule’,
‘hourly’) }}”
current_hour: “{{ now().strftime(‘%H:00’) }}”
current_action: >
{% set match = schedule | selectattr(‘hour’, ‘eq’, current_hour) |
list %}
{% if match | length > 0 %}{{ match[0] }}{% else %}{“action”:
“idle”, “power_kw”: 0}{% endif %}
* choose:
* conditions:
* “{{ current_action.action == ‘charge’ }}”
sequence:
* service: mqtt.publish
data:
topic: “W/<VENUS_ID>/vebus/276/Hub4/L1/AcPowerSetpoint”
payload: ‘{“value”: -{{ current_action.power_kw * 1000 }}}’
* conditions:
* “{{ current_action.action == ‘discharge’ }}”
sequence:
* service: mqtt.publish
data:
topic: “W/<VENUS_ID>/vebus/276/Hub4/L1/AcPowerSetpoint”
payload: ‘{“value”: {{ current_action.power_kw * 1000 }}}’
default:
* service: mqtt.publish
data:
topic: “W/<VENUS_ID>/vebus/276/Hub4/L1/AcPowerSetpoint”
payload: ‘{“value”: 0}’
# Keep-alive
* service: mqtt.publish
data:
topic: “R/<VENUS_ID>/system/0/Serial”
payload: “”
Replace <VENUS_ID> with your Cerbo GX portal ID and 276 with your vebus
device instance.
Technical details
- MILP solver;
- Battery model: SoC dynamics with per-direction sqrt(0.90) efficiency (~90%
round-trip), 10-90% SoC bounds, charge/discharge mutex - Price data: ENTSO-E day-ahead prices (15-min resolution for NL, hourly for
DE/BE) - Forecast: Machine Learning regression on weather + price lag features,
quantile objective for robust median predictions - Schedule API: REST endpoint returning JSON, refreshed every 2 hours
- Currently supports NL bidding zone, DE and BE coming soon.
What I’m looking for
I’m running a small technical pilot to validate the optimizer against
real-world residential battery performance. If you have a 5 kW (all around that) setup on a dynamic tariff in the Netherlands and
want to try this, I’d be happy to share access and compare notes on actual vs
predicted revenue.
Happy to share more details or answer questions about the implementation.