Skip to content

Commit a5de725

Browse files
authored
Fix clipping/high-SOC export ignoring stored floor in Amber Evening (#58) (#58)
Eve = Amber -> _current_export_soc_floor returns 75% (AMBER_HEADROOM_TARGET_KWH = 6 kWh -> 100 - 25 = 75%). A high-SOC mid-period export that started at SOC 60.8% with a stored floor of DAYTIME_TIMED_EXPORT_MIN_SOC_PERCENT (40%) was immediately terminating: maybe_restore_timed_grid_export picked the dynamic 75% floor unconditionally (current_export_soc_floor wins when not None), and 60.8% <= 75% -> "SOC floor reached" after one tick. The dynamic floor override is designed for headroom exports that cross a period boundary mid-run (e.g. a pre-period Aftn export that extends into Eve should switch to the Eve floor). Clipping and high-SOC exports always start and end within the same period, so the stored floor (40%) is always correct. Fix: clipping exports (is_clipping=True) now use the stored export_soc_floor unconditionally; only headroom exports (is_clipping=False) use the dynamic current_export_soc_floor when it is provided. Two regression tests added: one confirming the export continues at 59.8% with dynamic floor=75% and stored floor=40%, one confirming it still restores at 39% (below the stored 40% floor).
1 parent 36eaf6c commit a5de725

3 files changed

Lines changed: 108 additions & 3 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1525,6 +1525,7 @@ python scripts/battery_throughput.py
15251525
## Recent Updates
15261526

15271527
**2026-06-01**
1528+
- **Fix: high-SOC mid-period export in Amber Evening terminated immediately.** The export triggered at SOC 60.8% (Eve = Amber, headroom deficit 4.99 kWh) but stopped after one tick with "SOC floor reached (59.8% ≤ 75.0%)". Root cause: `maybe_restore_timed_grid_export` was overriding the stored 40% floor with the dynamic period-status-based floor (75% for Amber) even for clipping/high-SOC exports. The dynamic floor is only intended for headroom exports that cross a period boundary mid-run. Clipping and high-SOC exports now use the floor stored at start time (`DAYTIME_TIMED_EXPORT_MIN_SOC_PERCENT = 40%`) rather than the dynamic override.
15281529
- **Fix: crash and blind restart when Solcast drops past periods mid-day.** Two related bugs surfaced when the monitor restarted at 10:38 UTC. (1) The intra-day forecast refresh replaced `today_period_forecast` wholesale with Solcast's 2-period result (Aftn, Eve) while `ordered_period_windows` still held all 3 periods — the coordinator then crashed with `KeyError: 'Morn'`. (2) On restart, `today_period_windows` is empty so the startup flow fell into the intra-day branch, produced `daytime_periods = []`, and fired no handlers for the rest of the day. Fixed by: merging (not replacing) `today_period_forecast` on intra-day refresh; always anchoring window boundaries to the canonical `["Morn","Aftn","Eve"]` triplet on day-reset so windows remain thirds even after a mid-day restart; triggering a day-reset when `ordered_period_windows` is empty at startup; and guarding `_process_period_windows` against any period absent from the forecast.
15291530

15301531
**2026-05-31**

‎logic/timed_export.py‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -436,10 +436,14 @@ async def maybe_restore_timed_grid_export(
436436
is_clipping = timed_export_override.get("is_clipping_export", False)
437437
clipping_soc_floor = timed_export_override.get("clipping_soc_floor")
438438
export_soc_floor = timed_export_override.get("export_soc_floor")
439-
# Use the caller-supplied current floor when available so that the threshold
440-
# reflects the active period's status rather than the status at export start.
439+
# For headroom exports, use the dynamically computed floor so that the
440+
# threshold reflects the active period's status if a period boundary was
441+
# crossed mid-export. For clipping/high-SOC exports the stored floor
442+
# (DAYTIME_TIMED_EXPORT_MIN_SOC_PERCENT) is always correct — using the
443+
# dynamic Amber floor (75%) would fire immediately when SOC < 75%.
441444
effective_export_soc_floor = (
442-
current_export_soc_floor if current_export_soc_floor is not None else export_soc_floor
445+
export_soc_floor if is_clipping
446+
else (current_export_soc_floor if current_export_soc_floor is not None else export_soc_floor)
443447
)
444448

445449
# SOC floor checks: export_soc_floor is evaluated first because it is an

‎tests/test_scheduler_core_logic.py‎

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,106 @@ def set_override(state):
428428
assert result == "restored", "Should restore when SOC is at trigger threshold"
429429
apply_mode_change.assert_called_once()
430430

431+
@pytest.mark.asyncio
432+
async def test_clipping_export_ignores_dynamic_floor_uses_stored_floor(self):
433+
"""Clipping/high-SOC export must use its stored floor, not the dynamic period floor.
434+
435+
Regression: 2026-06-01. Eve = Amber → dynamic floor = 75%. High-SOC export
436+
started at SOC 60.8% with stored floor = 40%. The export was terminating
437+
immediately because 60.8% <= 75% (dynamic). With the fix, clipping exports
438+
always use the stored floor (40%), so 60.8% > 40% → export continues.
439+
"""
440+
from logic.timed_export import maybe_restore_timed_grid_export
441+
from config.settings import DAYTIME_TIMED_EXPORT_MIN_SOC_PERCENT
442+
443+
started_at = datetime(2026, 6, 1, 15, 33, tzinfo=timezone.utc)
444+
restore_at = datetime(2026, 6, 1, 18, 5, tzinfo=timezone.utc)
445+
now_utc = started_at + timedelta(minutes=5) # Still within window
446+
447+
override = {
448+
"active": True,
449+
"started_at": started_at,
450+
"restore_at": restore_at,
451+
"restore_mode": SIGEN_MODES["SELF_POWERED"],
452+
"restore_mode_label": "SELF_POWERED",
453+
"trigger_period": "Eve",
454+
"duration_minutes": 152,
455+
"is_clipping_export": True,
456+
"clipping_soc_floor": None,
457+
"export_soc_floor": DAYTIME_TIMED_EXPORT_MIN_SOC_PERCENT, # 40%
458+
}
459+
460+
saved_state = {}
461+
462+
def set_override(state):
463+
saved_state.update(state)
464+
465+
fetch_soc = AsyncMock(return_value=59.8) # Below dynamic Amber floor (75%), above stored (40%)
466+
apply_mode_change = AsyncMock(return_value=True)
467+
468+
result = await maybe_restore_timed_grid_export(
469+
timed_export_override=override,
470+
set_timed_export_override=set_override,
471+
now_utc=now_utc,
472+
fetch_soc=fetch_soc,
473+
sigen=None,
474+
mode_names={},
475+
apply_mode_change=apply_mode_change,
476+
logger=logger,
477+
current_export_soc_floor=75.0, # Dynamic Amber floor — must be ignored for clipping export
478+
)
479+
480+
assert result == "active", (
481+
"Clipping export must not terminate at 59.8% when stored floor is 40%"
482+
)
483+
apply_mode_change.assert_not_called()
484+
485+
@pytest.mark.asyncio
486+
async def test_clipping_export_restores_at_stored_floor_not_dynamic(self):
487+
"""Clipping export must restore at the stored floor (40%), not the dynamic one."""
488+
from logic.timed_export import maybe_restore_timed_grid_export
489+
from config.settings import DAYTIME_TIMED_EXPORT_MIN_SOC_PERCENT
490+
491+
started_at = datetime(2026, 6, 1, 15, 33, tzinfo=timezone.utc)
492+
restore_at = datetime(2026, 6, 1, 18, 5, tzinfo=timezone.utc)
493+
now_utc = started_at + timedelta(minutes=5)
494+
495+
override = {
496+
"active": True,
497+
"started_at": started_at,
498+
"restore_at": restore_at,
499+
"restore_mode": SIGEN_MODES["SELF_POWERED"],
500+
"restore_mode_label": "SELF_POWERED",
501+
"trigger_period": "Eve",
502+
"duration_minutes": 152,
503+
"is_clipping_export": True,
504+
"clipping_soc_floor": None,
505+
"export_soc_floor": DAYTIME_TIMED_EXPORT_MIN_SOC_PERCENT, # 40%
506+
}
507+
508+
saved_state = {}
509+
510+
def set_override(state):
511+
saved_state.update(state)
512+
513+
fetch_soc = AsyncMock(return_value=39.0) # Below both 40% stored and 75% dynamic
514+
apply_mode_change = AsyncMock(return_value=True)
515+
516+
result = await maybe_restore_timed_grid_export(
517+
timed_export_override=override,
518+
set_timed_export_override=set_override,
519+
now_utc=now_utc,
520+
fetch_soc=fetch_soc,
521+
sigen=None,
522+
mode_names={},
523+
apply_mode_change=apply_mode_change,
524+
logger=logger,
525+
current_export_soc_floor=75.0,
526+
)
527+
528+
assert result == "restored", "Export must restore when SOC drops below the stored 40% floor"
529+
apply_mode_change.assert_called_once()
530+
431531
def test_timed_export_persistence_structure(self):
432532
"""Persisted timed export state should include all required fields."""
433533
state_to_persist = {

0 commit comments

Comments
 (0)