Skip to content

Commit 54f3a00

Browse files
fix(axle): boost the import rate during an Axle export event, not just export
An Axle export event pays a premium to export, so charging instead during the same window carries the same opportunity cost - but load_axle_slot only ever boosted rate_export, leaving rate_import untouched. Predbat could therefore plan to charge cheaply through an event just because the normal import rate happened to be low at that time, with no cost reflected in the final plan. load_axle_slot now applies an export-direction session's pence_per_kwh to both rate_export and rate_import, mirroring how Octopus saving sessions already boost both directions. Managed mode's price curve conversion no longer needs to fabricate a second, negated "import" session per slot to get the same effect, so that trick is removed too. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 6477e28 commit 54f3a00

3 files changed

Lines changed: 194 additions & 64 deletions

File tree

apps/predbat/axle.py

Lines changed: 24 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -458,15 +458,12 @@ async def _fetch_managed_price_curve(self):
458458
def _process_price_curve(self, data):
459459
"""Convert Axle price curve to session format for load_axle_slot().
460460
461-
The price curve provides half-hourly wholesale market prices (GBP/MWh).
462-
These are overlaid onto existing tariff rates:
463-
- Export: wholesale price added to rate_export (high price = more export)
464-
- Import: wholesale price added to rate_import (high price = less import,
465-
negative price = cheap import encourages charging)
466-
- Null prices are skipped (no modification, normal tariff applies)
467-
468-
Note: load_axle_slot subtracts import pence_per_kwh, so we negate it here
469-
to achieve addition of the wholesale price to the import rate.
461+
The price curve provides half-hourly wholesale market prices (GBP/MWh), overlaid onto
462+
existing tariff rates. One "export" session per slot is enough: load_axle_slot adds
463+
the wholesale price to both rate_export and rate_import for an export-direction session
464+
(high price = more export and pricier import; negative price = cheap import, which
465+
also encourages charging). Null prices are skipped (no modification, normal tariff
466+
applies).
470467
"""
471468
prices = data.get("half_hourly_traded_prices", [])
472469
session_count = 0
@@ -492,27 +489,16 @@ def _process_price_curve(self, data):
492489
start_formatted = start_dt.strftime(TIME_FORMAT)
493490
end_formatted = end_dt.strftime(TIME_FORMAT)
494491

495-
# Create export session: adds wholesale price as export bonus
496-
# load_axle_slot does: rate_export + pence_per_kwh
497-
export_session = {
492+
# load_axle_slot applies an export-direction session's pence_per_kwh to both
493+
# rate_export and rate_import, so a single session models the wholesale price's
494+
# effect on both sides of the meter.
495+
session = {
498496
"start_time": start_formatted,
499497
"end_time": end_formatted,
500498
"import_export": "export",
501499
"pence_per_kwh": pence_per_kwh,
502500
}
503-
self.add_event_to_history(export_session, allow_future=True)
504-
505-
# Create import session: adds wholesale price to import cost
506-
# load_axle_slot does: rate_import - pence_per_kwh, so we negate
507-
# to get rate_import + wholesale_price (high price = expensive import,
508-
# negative price = cheap import)
509-
import_session = {
510-
"start_time": start_formatted,
511-
"end_time": end_formatted,
512-
"import_export": "import",
513-
"pence_per_kwh": -pence_per_kwh,
514-
}
515-
self.add_event_to_history(import_session, allow_future=True)
501+
self.add_event_to_history(session, allow_future=True)
516502
session_count += 1
517503

518504
self.log(f"AxleAPI: Processed {session_count} price curve slots into sessions")
@@ -676,17 +662,23 @@ def load_axle_slot(base, axle_sessions, rate_dict, export, rate_replicate=None):
676662
end_minutes = min(minutes_to_time(end_time, base.midnight_utc), base.forecast_minutes + base.minutes_now)
677663

678664
if start_minutes is not None and end_minutes is not None and start_minutes < (base.forecast_minutes + base.minutes_now):
679-
if (export and import_export == "export") or (not export and import_export == "import"):
665+
if import_export == "export":
666+
# An export event pays a premium to export, so charging instead during the same
667+
# window carries the same opportunity cost - apply the same boost to both the
668+
# export and the import rate (mirrors how Octopus saving sessions boost both
669+
# directions), rather than only making export look attractive.
680670
base.log("Setting Axle VPP session in range {} - {} export {} pence_per_kwh {}".format(base.time_abs_str(start_minutes), base.time_abs_str(end_minutes), export, pence_per_kwh))
681671
for minute in range(start_minutes, end_minutes):
672+
rate_dict[minute] = rate_dict.get(minute, 0) + pence_per_kwh
673+
rate_replicate[minute] = "saving"
682674
if export:
683-
rate_dict[minute] = rate_dict.get(minute, 0) + pence_per_kwh
684675
base.load_scaling_dynamic[minute] = base.load_scaling_saving
685-
rate_replicate[minute] = "saving"
686-
else:
687-
rate_dict[minute] = rate_dict.get(minute, 0) - pence_per_kwh
688-
base.load_scaling_dynamic[minute] = base.load_scaling_free
689-
rate_replicate[minute] = "saving"
676+
elif import_export == "import" and not export:
677+
base.log("Setting Axle VPP session in range {} - {} export {} pence_per_kwh {}".format(base.time_abs_str(start_minutes), base.time_abs_str(end_minutes), export, pence_per_kwh))
678+
for minute in range(start_minutes, end_minutes):
679+
rate_dict[minute] = rate_dict.get(minute, 0) - pence_per_kwh
680+
base.load_scaling_dynamic[minute] = base.load_scaling_free
681+
rate_replicate[minute] = "saving"
690682

691683

692684
def fetch_axle_active(base):

apps/predbat/tests/test_axle.py

Lines changed: 168 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,9 @@ def test_axle(my_predbat=None):
144144
("history_cleanup", _test_axle_history_cleanup, "History cleanup old events"),
145145
("fetch_sessions", _test_axle_fetch_sessions, "Fetch sessions from API"),
146146
("load_slot_export", _test_axle_load_slot_export, "Load slot export integration"),
147+
("load_slot_export_boosts_import", _test_axle_load_slot_export_boosts_import_rate, "Load slot export event also boosts rate_import"),
147148
("load_slot_import", _test_axle_load_slot_import, "Load slot import integration"),
149+
("byok_export_boosts_import", _test_axle_byok_export_event_boosts_import_rate, "BYOK export event boosts import rate"),
148150
("active_function", _test_axle_active_function, "Active status checking"),
149151
("managed_init", _test_axle_managed_initialization, "Managed mode initialisation"),
150152
("managed_price_curve", _test_axle_managed_price_curve_processing, "Managed mode price curve processing"),
@@ -1099,6 +1101,69 @@ def get_arg(self, name, indirect=True):
10991101
return False
11001102

11011103

1104+
def _test_axle_load_slot_export_boosts_import_rate(my_predbat=None):
1105+
"""
1106+
Test that load_axle_slot also raises rate_import for an export-direction session.
1107+
1108+
Exporting during an Axle event earns a premium, so charging instead carries the same
1109+
opportunity cost - the import rate should reflect that too, not just the export rate.
1110+
"""
1111+
from axle import load_axle_slot
1112+
from datetime import datetime, timezone
1113+
1114+
print("Testing load_axle_slot export event also boosts rate_import...")
1115+
1116+
class MockBase:
1117+
def __init__(self):
1118+
self.midnight_utc = datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
1119+
self.now_utc = datetime(2024, 1, 1, 10, 0, 0, tzinfo=timezone.utc)
1120+
self.minutes_now = 10 * 60
1121+
self.forecast_minutes = 24 * 60
1122+
self.prefix = "predbat"
1123+
self.rate_import = {minute: 15.0 for minute in range(self.forecast_minutes)}
1124+
self.load_scaling_dynamic = {}
1125+
self.load_scaling_saving = 0.5
1126+
self.load_scaling_free = 0.0
1127+
1128+
def log(self, message):
1129+
print(f" [LOG] {message}")
1130+
1131+
def time_abs_str(self, minutes):
1132+
return f"{minutes // 60:02d}:{minutes % 60:02d}"
1133+
1134+
base = MockBase()
1135+
1136+
axle_sessions = [
1137+
{
1138+
"start_time": "2024-01-01T14:00:00+00:00",
1139+
"end_time": "2024-01-01T16:00:00+00:00",
1140+
"import_export": "export",
1141+
"pence_per_kwh": 100.0,
1142+
}
1143+
]
1144+
1145+
start_minutes = 14 * 60
1146+
end_minutes = 16 * 60
1147+
1148+
rate_replicate = {}
1149+
load_axle_slot(base, axle_sessions, base.rate_import, export=False, rate_replicate=rate_replicate)
1150+
1151+
for minute in range(start_minutes, end_minutes):
1152+
assert base.rate_import[minute] == 115.0, f"rate_import at minute {minute} should be boosted to 115.0 (15.0 + 100), got {base.rate_import[minute]}"
1153+
assert rate_replicate.get(minute) == "saving"
1154+
1155+
assert base.rate_import[start_minutes - 1] == 15.0, "Rate before event should be unchanged"
1156+
assert base.rate_import[end_minutes] == 15.0, "Rate at end_minutes (not inclusive) should be unchanged"
1157+
1158+
# load_scaling_saving is only set on the export=True call (see _test_axle_load_slot_export);
1159+
# an export-direction session processed here (export=False) should not touch it.
1160+
assert base.load_scaling_dynamic == {}, "Processing rate_import should not set load_scaling_dynamic for an export session"
1161+
1162+
print(" ✓ rate_import boosted by 100p/kWh for the export event's 2-hour period")
1163+
1164+
return False
1165+
1166+
11021167
def _test_axle_load_slot_import(my_predbat=None):
11031168
"""
11041169
Test that load_axle_slot decreases import rates by pence_per_kwh and applies load_scaling_free for import events
@@ -1181,6 +1246,91 @@ def get_arg(self, name, indirect=True):
11811246
return False
11821247

11831248

1249+
def _test_axle_byok_export_event_boosts_import_rate(my_predbat=None):
1250+
"""
1251+
A BYOK Axle export event pays a premium to export, so charging (importing) instead during
1252+
the same window carries the same opportunity cost - Predbat should not see it as free to
1253+
charge cheaply for the whole period. load_axle_slot now applies an export-direction
1254+
session's pence_per_kwh to both rate_export and rate_import (mirroring how Octopus saving
1255+
sessions boost both directions), so the single event Axle reports is enough - no need to
1256+
fabricate a second session.
1257+
1258+
Regression test for: BYOK export events only boosted rate_export, leaving rate_import
1259+
untouched for the event period.
1260+
"""
1261+
from axle import load_axle_slot, fetch_axle_sessions
1262+
from datetime import datetime, timezone
1263+
1264+
print("Test: BYOK Axle export event also boosts the import rate")
1265+
1266+
axle = MockAxleAPI()
1267+
axle.initialize(api_key="test_key", pence_per_kwh=100, automatic=False)
1268+
1269+
now = datetime(2025, 12, 20, 13, 30, 0, tzinfo=timezone.utc)
1270+
axle._now_utc = now
1271+
1272+
json_data = {"start_time": "2025-12-20T14:00:00Z", "end_time": "2025-12-20T16:00:00Z", "import_export": "export", "updated_at": "2025-12-20T13:45:00Z"}
1273+
mock_response = create_aiohttp_mock_response(status=200, json_data=json_data)
1274+
mock_session = create_aiohttp_mock_session(mock_response=mock_response)
1275+
1276+
with patch("aiohttp.ClientSession", return_value=mock_session):
1277+
run_async(axle.fetch_axle_event())
1278+
axle.publish_axle_event()
1279+
1280+
# The event hasn't started yet, so it's only visible via event_current - no history entry
1281+
# is needed or created for it to be seen ahead of time.
1282+
assert axle.event_history == [], "Future export event should not be in history yet"
1283+
1284+
class MockBase:
1285+
def __init__(self):
1286+
self.midnight_utc = datetime(2025, 12, 20, 0, 0, 0, tzinfo=timezone.utc)
1287+
self.minutes_now = 13 * 60 + 30
1288+
self.forecast_minutes = 24 * 60
1289+
self.rate_import = {minute: 15.0 for minute in range(24 * 60)}
1290+
self.rate_export = {minute: 5.0 for minute in range(24 * 60)}
1291+
self.load_scaling_dynamic = {}
1292+
self.load_scaling_saving = 0.5
1293+
self.load_scaling_free = 0.0
1294+
self.prefix = "predbat"
1295+
1296+
def log(self, message):
1297+
pass
1298+
1299+
def time_abs_str(self, minutes):
1300+
return f"{minutes // 60:02d}:{minutes % 60:02d}"
1301+
1302+
def get_state_wrapper(self, entity_id, default=None, attribute=None):
1303+
sensor = axle.dashboard_items.get(entity_id)
1304+
if not sensor:
1305+
return default
1306+
if attribute:
1307+
return sensor["attributes"].get(attribute, default)
1308+
return sensor["state"]
1309+
1310+
def get_arg(self, name, indirect=True):
1311+
return "binary_sensor.predbat_axle_event"
1312+
1313+
base = MockBase()
1314+
axle_sessions = fetch_axle_sessions(base)
1315+
assert len(axle_sessions) == 1, "Only the single export event should be reported, no synthetic companion"
1316+
1317+
rate_replicate_import = {}
1318+
rate_replicate_export = {}
1319+
load_axle_slot(base, axle_sessions, base.rate_import, export=False, rate_replicate=rate_replicate_import)
1320+
load_axle_slot(base, axle_sessions, base.rate_export, export=True, rate_replicate=rate_replicate_export)
1321+
1322+
start_minutes = 14 * 60
1323+
end_minutes = 16 * 60
1324+
for minute in range(start_minutes, end_minutes):
1325+
assert base.rate_import[minute] == 115.0, f"rate_import at minute {minute} should be boosted to 115.0 (15.0 + 100 opportunity cost), got {base.rate_import[minute]}"
1326+
assert base.rate_export[minute] == 105.0, f"rate_export at minute {minute} should still be boosted to 105.0, got {base.rate_export[minute]}"
1327+
1328+
print(" ✓ Import rate boosted by pence_per_kwh during the Axle export event window")
1329+
print(" ✓ Export rate still boosted, from the same single session")
1330+
1331+
return False
1332+
1333+
11841334
def _test_axle_active_function(my_predbat=None):
11851335
"""Test fetch_axle_active function to check if VPP event is currently active"""
11861336
print("Testing fetch_axle_active function...")
@@ -1288,36 +1438,26 @@ def _test_axle_managed_price_curve_processing(my_predbat=None):
12881438

12891439
axle._process_price_curve(price_curve)
12901440

1291-
# 2 valid slots × 2 directions = 4 events
1292-
assert len(axle.event_history) == 4, f"Expected 4 events, got {len(axle.event_history)}"
1293-
1294-
# Check first slot: 50 GBP/MWh = 5.0 p/kWh
1295-
export_events = [e for e in axle.event_history if e["import_export"] == "export"]
1296-
import_events = [e for e in axle.event_history if e["import_export"] == "import"]
1297-
assert len(export_events) == 2
1298-
assert len(import_events) == 2
1299-
1300-
# First export: 50 GBP/MWh → 5.0 p/kWh
1301-
first_export = [e for e in export_events if "14:00:00" in e["start_time"]][0]
1302-
assert first_export["pence_per_kwh"] == 5.0, f"Expected 5.0, got {first_export['pence_per_kwh']}"
1441+
# 2 valid slots × 1 session each = 2 events. load_axle_slot applies an export-direction
1442+
# session to both rate_export and rate_import, so a single session per slot is enough.
1443+
assert len(axle.event_history) == 2, f"Expected 2 events, got {len(axle.event_history)}"
1444+
assert all(e["import_export"] == "export" for e in axle.event_history)
13031445

1304-
# First import: negated → -5.0 p/kWh
1305-
first_import = [e for e in import_events if "14:00:00" in e["start_time"]][0]
1306-
assert first_import["pence_per_kwh"] == -5.0, f"Expected -5.0, got {first_import['pence_per_kwh']}"
1446+
# First slot: 50 GBP/MWh → 5.0 p/kWh
1447+
first = [e for e in axle.event_history if "14:00:00" in e["start_time"]][0]
1448+
assert first["pence_per_kwh"] == 5.0, f"Expected 5.0, got {first['pence_per_kwh']}"
13071449

1308-
# Second slot: -20 GBP/MWh = -2.0 p/kWh (negative wholesale price)
1309-
second_export = [e for e in export_events if "14:30:00" in e["start_time"]][0]
1310-
assert second_export["pence_per_kwh"] == -2.0, f"Expected -2.0, got {second_export['pence_per_kwh']}"
1311-
1312-
second_import = [e for e in import_events if "14:30:00" in e["start_time"]][0]
1313-
assert second_import["pence_per_kwh"] == 2.0, f"Expected 2.0, got {second_import['pence_per_kwh']}"
1450+
# Second slot: -20 GBP/MWh = -2.0 p/kWh (negative wholesale price passes straight through,
1451+
# so load_axle_slot lowers both rate_export and rate_import for this slot)
1452+
second = [e for e in axle.event_history if "14:30:00" in e["start_time"]][0]
1453+
assert second["pence_per_kwh"] == -2.0, f"Expected -2.0, got {second['pence_per_kwh']}"
13141454

13151455
# Null price slot should be skipped
13161456
null_events = [e for e in axle.event_history if "15:00:00" in e.get("start_time", "")]
13171457
assert len(null_events) == 0, "Null price slots should be skipped"
13181458

13191459
print(" ✓ Price curve conversion correct (GBP/MWh → p/kWh)")
1320-
print(" ✓ Export and import sessions created per slot")
1460+
print(" ✓ One session created per slot (load_axle_slot applies it to both directions)")
13211461
print(" ✓ Null prices skipped")
13221462
return False
13231463

@@ -1560,16 +1700,12 @@ def session_factory(*args, **kwargs):
15601700
run_async(axle.fetch_axle_event())
15611701
axle.publish_axle_event() # run() always republishes after a fetch attempt
15621702

1563-
# Should have 4 events: 2 slots × 2 directions
1564-
assert len(axle.event_history) == 4, f"Expected 4 events, got {len(axle.event_history)}"
1703+
# Should have 2 events: 2 slots × 1 session each
1704+
assert len(axle.event_history) == 2, f"Expected 2 events, got {len(axle.event_history)}"
15651705

15661706
# Check conversion: 80 GBP/MWh = 8.0 p/kWh
1567-
export_events = [e for e in axle.event_history if e["import_export"] == "export"]
1568-
import_events = [e for e in axle.event_history if e["import_export"] == "import"]
1569-
assert len(export_events) == 2
1570-
assert len(import_events) == 2
1571-
1572-
first_export = [e for e in export_events if "14:00:00" in e["start_time"]][0]
1707+
assert all(e["import_export"] == "export" for e in axle.event_history)
1708+
first_export = [e for e in axle.event_history if "14:00:00" in e["start_time"]][0]
15731709
assert first_export["pence_per_kwh"] == 8.0
15741710

15751711
# Sensor should be published
@@ -1630,8 +1766,8 @@ def session_factory(*args, **kwargs):
16301766
with patch("asyncio.sleep"):
16311767
run_async(axle.fetch_axle_event())
16321768

1633-
# Should succeed after retry: 1 slot × 2 directions = 2 events
1634-
assert len(axle.event_history) == 2, f"Expected 2 events after token retry, got {len(axle.event_history)}"
1769+
# Should succeed after retry: 1 slot × 1 session = 1 event
1770+
assert len(axle.event_history) == 1, f"Expected 1 event after token retry, got {len(axle.event_history)}"
16351771
assert axle.partner_token == "tok_fresh_456"
16361772

16371773
# Verify token was invalidated and re-fetched

docs/energy-rates.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -566,6 +566,8 @@ You should sign up for the Axle 'Events Only' service, not 'Full Control' which
566566

567567
Once signed up to Axle, Predbat's [Axle Energy VPP component](https://springfall2008.github.io/batpred/components/#axle-energy-vpp-axle) polls the Axle API to obtain details of future events and add Axle event details to **binary_sensor.predbat_axle_event**.
568568

569+
During an Axle export event, **axle_pence_per_kwh** is added to both your export rate and your import rate for the event period. This reflects that importing (charging) instead of exporting during the event carries the same opportunity cost as missing out on the payment, so Predbat will not plan to charge cheaply through an event just because your normal import rate happens to be low at that time.
570+
569571
The following configuration options for the Axle VPP can be set in `apps.yaml`:
570572

571573
- **axle_api_key** - Sets your API key to communicate with the Axle Energy VPP (Virtual Power Plant) service

0 commit comments

Comments
 (0)