Skip to content

Commit c472c0a

Browse files
authored
Fix/timed export restore mode safety (#27)
* Guard against GRID_EXPORT as timed-export restore mode (#26) If the inverter is already in GRID_EXPORT when a new timed export starts (e.g. due to a stale duplicate scheduler instance), saving GRID_EXPORT as the restore mode would leave the inverter exporting indefinitely with no SOC floor protection after the timed window ends. Extend the existing TOU→SELF_POWERED redirect to also cover GRID_EXPORT→SELF_POWERED. * Add high_soc_export_set flag to prevent mid-period safety export oscillation (#27) The mid-period high-SOC safety export had no one-shot guard — unlike the clipping check (gated by clipping_export_set), it evaluated every tick. Once an export ended at the SOC floor, solar would recharge past the 50% trigger threshold and it would fire again after the 15-minute cooldown, causing repeated export/restore cycles within the same period. Add high_soc_export_set (same pattern as clipping_export_set) to DayStateEntry. Once a mid-period high-SOC export fires, the flag prevents re-triggering for the rest of that period. Auto-extension in maybe_restore_timed_grid_export handles continuous strong solar during the active window. * Set 10% gap for mid-period high-SOC safety export (trigger 55%, floor 45%) (#27) Raise trigger threshold from 50% to 55% and use DAYTIME_TIMED_EXPORT_MIN_SOC_PERCENT (45%) as the SOC floor directly, replacing the headroom-target-based floor that was collapsing the gap to zero for Green days (max(45, 50) = 50%). Result: export only fires when battery is above 55%, stops at 45%, giving a 10% band of guaranteed discharge per export window.
1 parent 4f792c3 commit c472c0a

8 files changed

Lines changed: 39 additions & 26 deletions

File tree

‎config/settings.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,8 +219,9 @@
219219
MORNING_HIGH_SOC_PROTECTION_ENABLED = True
220220
# SOC threshold for the mid-period high-SOC safety export check (combined with solar).
221221
# When SOC >= this AND solar >= MID_PERIOD_SAFETY_SOLAR_TRIGGER_KW, export is triggered.
222-
# 50% (12 kWh) matches HEADROOM_TARGET_KWH: above this the battery cannot absorb a full-day Green surplus without clipping.
223-
MORNING_HIGH_SOC_THRESHOLD_PERCENT = 50.0
222+
# 55% gives a 10% gap above the 45% SOC floor (DAYTIME_TIMED_EXPORT_MIN_SOC_PERCENT),
223+
# ensuring each export creates meaningful headroom before stopping.
224+
MORNING_HIGH_SOC_THRESHOLD_PERCENT = 55.0
224225
# Solar generation threshold (kW) for mid-period high-SOC safety export.
225226
# Only triggers when live solar is this strong and SOC >= MORNING_HIGH_SOC_THRESHOLD_PERCENT.
226227
MID_PERIOD_SAFETY_SOLAR_TRIGGER_KW = round(SOLAR_PV_KW * 0.39, 2) # ~39% of array capacity

‎logic/afternoon.py‎

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ async def handle_afternoon_period(ctx: PeriodHandlerContext) -> bool:
124124
# --- Mid-period high-SOC safety export check ---
125125
if (
126126
s["start_set"]
127+
and not s["high_soc_export_set"]
127128
and not timed_export_override["active"]
128129
and now_utc >= period_start
129130
and (period_end_utc is None or now_utc < period_end_utc)
@@ -168,17 +169,14 @@ async def handle_afternoon_period(ctx: PeriodHandlerContext) -> bool:
168169
mode=SIGEN_MODES["GRID_EXPORT"], reason=mid_period_reason,
169170
outcome="mid-period high-SOC safety export triggered",
170171
)
171-
mid_period_headroom_target_soc = max(
172-
DAYTIME_TIMED_EXPORT_MIN_SOC_PERCENT,
173-
100.0 - mid_period_headroom_target_kwh / BATTERY_KWH * 100.0,
174-
)
175172
mid_period_override_started = await start_timed_grid_export(
176173
period=PERIOD, reason=mid_period_reason,
177174
duration_minutes=mid_period_duration_minutes,
178175
now_utc=now_utc, battery_soc=mid_period_soc, is_clipping_export=True,
179-
export_soc_floor=mid_period_headroom_target_soc,
176+
export_soc_floor=DAYTIME_TIMED_EXPORT_MIN_SOC_PERCENT,
180177
)
181178
if mid_period_override_started:
179+
s["high_soc_export_set"] = True
182180
return True
183181

184182
# --- Pre-period export check ---

‎logic/evening.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ async def handle_evening_period(ctx: PeriodHandlerContext) -> bool:
198198
# --- Mid-period high-SOC safety export check ---
199199
if (
200200
s["start_set"]
201+
and not s["high_soc_export_set"]
201202
and not timed_export_override["active"]
202203
and now_utc >= period_start
203204
and (period_end_utc is None or now_utc < period_end_utc)
@@ -247,6 +248,7 @@ async def handle_evening_period(ctx: PeriodHandlerContext) -> bool:
247248
export_soc_floor=DAYTIME_TIMED_EXPORT_MIN_SOC_PERCENT,
248249
)
249250
if mid_period_override_started:
251+
s["high_soc_export_set"] = True
250252
return True
251253

252254
# --- Pre-period export check ---

‎logic/morning.py‎

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ async def handle_morning_period(ctx: PeriodHandlerContext) -> bool:
124124
# --- Mid-period high-SOC safety export check ---
125125
if (
126126
s["start_set"]
127+
and not s["high_soc_export_set"]
127128
and not timed_export_override["active"]
128129
and now_utc >= period_start
129130
and (period_end_utc is None or now_utc < period_end_utc)
@@ -168,17 +169,14 @@ async def handle_morning_period(ctx: PeriodHandlerContext) -> bool:
168169
mode=SIGEN_MODES["GRID_EXPORT"], reason=mid_period_reason,
169170
outcome="mid-period high-SOC safety export triggered",
170171
)
171-
mid_period_headroom_target_soc = max(
172-
DAYTIME_TIMED_EXPORT_MIN_SOC_PERCENT,
173-
100.0 - mid_period_headroom_target_kwh / BATTERY_KWH * 100.0,
174-
)
175172
mid_period_override_started = await start_timed_grid_export(
176173
period=PERIOD, reason=mid_period_reason,
177174
duration_minutes=mid_period_duration_minutes,
178175
now_utc=now_utc, battery_soc=mid_period_soc, is_clipping_export=True,
179-
export_soc_floor=mid_period_headroom_target_soc,
176+
export_soc_floor=DAYTIME_TIMED_EXPORT_MIN_SOC_PERCENT,
180177
)
181178
if mid_period_override_started:
179+
s["high_soc_export_set"] = True
182180
return True
183181

184182
# --- Pre-period export check ---

‎logic/scheduler_operations.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,13 +104,13 @@ async def refresh_daily_data(
104104

105105
if reset_day_state:
106106
state.day_state = {
107-
p: {"pre_set": False, "start_set": False, "clipping_export_set": False}
107+
p: {"pre_set": False, "start_set": False, "clipping_export_set": False, "high_soc_export_set": False}
108108
for p in daytime_periods
109109
}
110110
else:
111111
for period in daytime_periods:
112112
state.day_state.setdefault(
113-
period, {"pre_set": False, "start_set": False, "clipping_export_set": False}
113+
period, {"pre_set": False, "start_set": False, "clipping_export_set": False, "high_soc_export_set": False}
114114
)
115115

116116
state.last_forecast_refresh_utc = datetime.now(timezone.utc)

‎logic/scheduler_state.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,15 @@ class DayStateEntry(TypedDict):
2020
pre_set: True once the pre-period headroom/export check has been applied.
2121
start_set: True once the period-start mode decision has been applied.
2222
clipping_export_set: True once a live clipping-risk export has been started.
23+
high_soc_export_set: True once a mid-period high-SOC safety export has fired.
24+
Prevents repeated exports within the same period after solar recharges
25+
the battery above the trigger threshold.
2326
"""
2427

2528
pre_set: bool
2629
start_set: bool
2730
clipping_export_set: bool
31+
high_soc_export_set: bool
2832

2933

3034
class NightState(TypedDict):

‎logic/timed_export.py‎

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -301,19 +301,22 @@ async def start_timed_grid_export(
301301
current_mode_raw,
302302
)
303303
return False
304-
# TOU is a night-only charging mode and should never be the restore
305-
# target after a timed export:
306-
# - During cheap-rate: restoring to TOU would let the inverter
307-
# recharge from the grid, immediately undoing any headroom just
308-
# created by the export.
309-
# - After cheap-rate: TOU left over from an overnight schedule
310-
# would leave the inverter in the wrong mode for the rest of
311-
# the day.
312-
# In both cases, restore to SELF_POWERED instead.
313-
if restore_mode == SIGEN_MODES["TOU"]:
304+
# Neither TOU nor GRID_EXPORT should ever be the restore target:
305+
# - TOU is a night-only charging mode; restoring to it during or
306+
# after cheap-rate would either recharge the battery from the
307+
# grid (undoing headroom) or leave the inverter in the wrong
308+
# daytime mode.
309+
# - GRID_EXPORT as a restore target means the inverter was already
310+
# in export mode when this export started (e.g. a stale state or
311+
# duplicate scheduler instance). Restoring to GRID_EXPORT would
312+
# leave the inverter exporting indefinitely with no SOC floor
313+
# protection once the timed window ends.
314+
# In both cases, fall back to SELF_POWERED.
315+
if restore_mode in (SIGEN_MODES["TOU"], SIGEN_MODES["GRID_EXPORT"]):
314316
logger.info(
315-
"[TIMED EXPORT] Current mode is TOU — "
316-
"will restore to SELF_POWERED instead to preserve headroom."
317+
"[TIMED EXPORT] Current mode is %s — "
318+
"will restore to SELF_POWERED instead.",
319+
restore_label,
317320
)
318321
restore_mode = SIGEN_MODES["SELF_POWERED"]
319322
restore_label = str(mode_names.get(restore_mode, restore_mode))

‎tests/test_period_handler_ticks.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,14 @@ def _make_period_state(
4040
pre_set: bool = False,
4141
start_set: bool = False,
4242
clipping_export_set: bool = False,
43+
high_soc_export_set: bool = False,
4344
) -> DayStateEntry:
4445
"""Return a fresh DayStateEntry with the given flag values."""
4546
return DayStateEntry(
4647
pre_set=pre_set,
4748
start_set=start_set,
4849
clipping_export_set=clipping_export_set,
50+
high_soc_export_set=high_soc_export_set,
4951
)
5052

5153

@@ -167,6 +169,11 @@ def test_clipping_export_set_false_initially(self):
167169
entry = _make_period_state()
168170
assert entry["clipping_export_set"] is False
169171

172+
def test_high_soc_export_set_false_initially(self):
173+
"""A freshly created DayStateEntry has high_soc_export_set=False."""
174+
entry = _make_period_state()
175+
assert entry["high_soc_export_set"] is False
176+
170177

171178
# ---------------------------------------------------------------------------
172179
# Pre-period tests

0 commit comments

Comments
 (0)