@@ -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+
11021167def _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+
11841334def _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
0 commit comments