diff --git a/README.md b/README.md
index ea0d792..497f152 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
**Runs a Sigenergy solar battery from Indigo, so the house buys as little from the grid as it can.**
-**Version:** 5.129.2 | **Author:** CliveS & Claude | **Needs:** Indigo 2025.2 or later and a Sigenergy inverter
+**Version:** 5.130.0 | **Author:** CliveS & Claude | **Needs:** Indigo 2025.2 or later and a Sigenergy inverter
**[Read the full guide](https://highsteads.github.io/SigenEnergyManager/)** — setting up, how it decides what the battery does, and what to do when something goes wrong.
@@ -54,6 +54,8 @@ The [full guide](https://highsteads.github.io/SigenEnergyManager/) goes through
## What's new
+**v5.130.0** — The 50% overnight minimum now holds even when the Flux strategy cannot plan, and 5am reports the level the battery actually reached.
+
**v5.129.2** — A day recorded before Octopus has published its prices is valued provisionally at the last published prices, then re-priced automatically once the real ones arrive.
**v5.129.1** — When Octopus has not published one side of the Flux prices yet, the strategy plans on the last published day's prices until it does.
@@ -106,6 +108,7 @@ The [full guide](https://highsteads.github.io/SigenEnergyManager/) goes through
| Version | Released | In short |
|---|---|---|
+| 5.130.0 | 1 October 2026 | The 50% minimum holds when Flux cannot plan |
| 5.129.2 | 1 October 2026 | Unpublished days valued provisionally, re-priced later |
| 5.129.1 | 1 October 2026 | Missing Flux prices: plan on the last published day's |
| 5.129.0 | 30 September 2026 | The 2am charge reaches at least 50% every night |
diff --git a/SigenEnergyManager.indigoPlugin/Contents/Info.plist b/SigenEnergyManager.indigoPlugin/Contents/Info.plist
index 5a85c08..685a839 100644
--- a/SigenEnergyManager.indigoPlugin/Contents/Info.plist
+++ b/SigenEnergyManager.indigoPlugin/Contents/Info.plist
@@ -31,7 +31,7 @@
NSHumanReadableCopyright
Copyright 2026 CliveS. All rights reserved.
PluginVersion
- 5.129.2
+ 5.130.0
ServerApiVersion
3.8
diff --git a/SigenEnergyManager.indigoPlugin/Contents/Server Plugin/battery_manager.py b/SigenEnergyManager.indigoPlugin/Contents/Server Plugin/battery_manager.py
index eb068d0..a0b8575 100644
--- a/SigenEnergyManager.indigoPlugin/Contents/Server Plugin/battery_manager.py
+++ b/SigenEnergyManager.indigoPlugin/Contents/Server Plugin/battery_manager.py
@@ -4,9 +4,20 @@
# Description: 24-hour sufficiency model — export surplus today, import only
# when tomorrow's battery+solar falls short of tomorrow's daily load.
# No overnight forced discharge.
-# Author: CliveS & Claude Opus 5; 3.12-3.14 Claude Opus 5.5
-# Date: 05-08-2026; 3.12-3.13 26-09-2026; 3.14 27-09-2026
-# Version: 3.14
+# Author: CliveS & Claude Opus 5; 3.12-3.15 Claude Opus 5.5
+# Date: 05-08-2026; 3.12-3.13 26-09-2026; 3.14 27-09-2026; 3.15 01-10-2026
+# Version: 3.15
+# 3.15 — THE CHEAP WINDOW HAS A FALLBACK THAT KEEPS THE 50% MINIMUM. 1-Oct-2026:
+# Octopus had published no October Flux export price, so the Flux controller
+# could not plan; the manager bought to its own household target (44%), went back
+# to self consumption at 03:48 and the house ran on the battery until 5am. On Flux,
+# inside 02:00-05:00, whenever Flux is not running the window, the manager now
+# does what Flux would: charges to the highest of the agreed minimum
+# (snapshot.cheap_window_min_pct, from flux_strategy.cheap_window_minimum_pct, so
+# one rule and one owner), tomorrow's household target and the power-cut floor,
+# and holds the battery there until 05:00 with the house on the cheap grid. The
+# import carries import_purpose CHEAP_WINDOW_PURPOSE so plugin.py runs it to the
+# end of the window instead of stopping at the target.
# 3.14 — THE DAY BUYS NOTHING BUT FREE HOURS AND AXLE COVER. CliveS, 27-Sep-2026:
# "the only time during the day that we import is when we have free hours or
# the battery gets near the lower battery limit", plus an Axle event the
@@ -276,6 +287,11 @@ def need_for_weekday(snapshot, weekday_index):
ACTION_SAVING_SESSION = "saving_session" # Octopus Saving Session: export above baseline for Octopoints
ACTION_HAPPY_HOUR_IMPORT = "happy_hour_import" # Octopus Weekend Happy Hour: bank FREE grid electricity
+# 3.15: Decision.import_purpose of the Flux cheap-window fallback. plugin.py runs an
+# import with this purpose to the END of the window (the hardware cutoff stops the
+# charging, the house stays on the cheap grid) rather than stopping at the target.
+CHEAP_WINDOW_PURPOSE = "cheap_window"
+
# ── Octopus Saving Session export ───────────────────────────────────────────
# Below the Axle VPP override on purpose, and it is not close: Axle pays about
# £1/kWh, a Saving Session about 61 Octopoints/kWh = 7.6p/kWh (800 points = £1).
@@ -834,6 +850,10 @@ class ManagerSnapshot:
# other tariff and whenever Flux cannot plan, so the manager's own cheap-window
# import stays as the fallback (CliveS, 26-Sep-2026: "let Flux own 2am").
flux_owns_cheap_window: bool = False
+ # 3.15 — the agreed overnight minimum in force now (CHARGE_MIN_PCT on Flux inside
+ # 02:00-05:00), 0.0 outside the window, on a booked free-hour day and off Flux.
+ # Worked out by plugin.py with flux_strategy.cheap_window_minimum_pct.
+ cheap_window_min_pct: float = 0.0
@dataclass
@@ -899,7 +919,8 @@ class Decision:
# and is gated on the round-trip comparison; "reserve" buys the power-cut floor
# and is not. plugin.py words the hold notice from it. v5.117.0: the flat/TOU
# resilience buffer is tagged "reserve" too, and only a "reserve" import fires
- # the Emergency Import Triggered event. Nothing decides on this field.
+ # the Emergency Import Triggered event. 3.15: CHEAP_WINDOW_PURPOSE also makes
+ # plugin.py hold the import to the end of the window.
import_purpose: str = "tomorrow"
# v3.11 — set when the bank-first gate is what refused the overflow branch.
# Carried as a FLAG, not as text: the overflow reason string is already close
@@ -1035,6 +1056,17 @@ def evaluate(self, snapshot: ManagerSnapshot) -> Decision:
if not snapshot.event_cover_reason else
f"skipped — planned, not yet time: {snapshot.event_cover_reason}")))
+ # 1c. The Flux cheap window when Flux is not running it (3.15).
+ cheap = self._check_cheap_window_fallback(snapshot, balance)
+ if cheap is not None:
+ audit.append(("CHEAP-WINDOW", f"matched -> {cheap.reason}"))
+ cheap.audit_trail = audit
+ return cheap
+ audit.append(("CHEAP-WINDOW", (
+ "skipped — the Flux controller runs the cheap window"
+ if snapshot.flux_owns_cheap_window else
+ "skipped — not the Flux cheap window")))
+
# 2. Resilience buffer (flat-rate any-time; TOU only in the cheap window
# when tomorrow is already covered; Agile in the cheapest block, v5.101.0
# — see _check_resilience_buffer)
@@ -1799,13 +1831,8 @@ def _plan_import(
Never imports for profit — only to cover a genuine tomorrow shortfall.
Uses cheapest available time window for the active tariff.
"""
- tariff = snapshot.tariff
- battery_kwh = snapshot.current_soc_pct / 100.0 * snapshot.capacity_kwh
-
- # Target SOC: current SOC + the net import deficit.
- # +2% safety buffer; capped at 98% to preserve solar headroom at sunrise.
- target_kwh = battery_kwh + balance.import_kwh
- target_soc = min(98.0, target_kwh / max(1.0, snapshot.capacity_kwh) * 100.0 + 2.0)
+ tariff = snapshot.tariff
+ target_soc = self._household_import_target(snapshot, balance)
# Defensive guard: battery already above target → no import needed.
# Can occur if viability and snapshot are inconsistent (e.g. forecast race).
@@ -1840,6 +1867,74 @@ def _plan_import(
return self._hold_import(
balance, f"the tariff '{tariff.tariff_key}' is not recognised")
+ @staticmethod
+ def _household_import_target(snapshot: ManagerSnapshot,
+ balance: SufficiencyBalance) -> float:
+ """Target SOC: current SOC + the net import deficit. +2% safety buffer;
+ capped at 98% to preserve solar headroom at sunrise."""
+ battery_kwh = snapshot.current_soc_pct / 100.0 * snapshot.capacity_kwh
+ target_kwh = battery_kwh + balance.import_kwh
+ return min(98.0, target_kwh / max(1.0, snapshot.capacity_kwh) * 100.0 + 2.0)
+
+ def _check_cheap_window_fallback(self, snapshot: ManagerSnapshot,
+ balance: SufficiencyBalance) -> Optional[Decision]:
+ """The Flux cheap window, run by the manager when Flux is not running it (3.15).
+
+ Flux owns 02:00-05:00 whenever it can plan. When it cannot (1-Oct-2026: no
+ October export price published) this does what Flux would have done, short of
+ trading: charge to the highest of the agreed minimum, tomorrow's household
+ target and the power-cut floor, and hold the battery there until 05:00 with
+ the house on the cheap grid. It never buys to sell at the peak — that needs the
+ export price Flux was missing.
+
+ The decision is a START_IMPORT on every tick of the window, holding included,
+ with import_purpose CHEAP_WINDOW_PURPOSE; plugin.py stops the charging with the
+ hardware cutoff and ends the import at 05:00, never at the target.
+ """
+ tariff = snapshot.tariff
+ if tariff.tariff_key != TARIFF_FLUX or snapshot.flux_owns_cheap_window:
+ return None
+ if not tariff.cheap_start or not tariff.cheap_end:
+ return None
+ now_hm = self._to_local(snapshot.now).strftime("%H:%M")
+ if not self._time_in_window(now_hm, tariff.cheap_start, tariff.cheap_end):
+ return None
+ soc = snapshot.current_soc_pct
+ wants = [] # (level, why) — the highest one is bought
+ floor = float(snapshot.cheap_window_min_pct or 0.0)
+ if floor > 0:
+ wants.append((floor, f"the {floor:.0f}% minimum"))
+ if balance.import_needed:
+ wants.append((self._household_import_target(snapshot, balance),
+ f"tomorrow ({balance.import_kwh_grid:.0f} kWh short)"))
+ if soc < snapshot.dawn_target_pct:
+ wants.append((min(snapshot.dawn_target_pct + 2.0, 98.0), "the reserve"))
+ target = max([level for level, _ in wants] or [0.0])
+ # Name only what sets the level: "for the 20% minimum and tomorrow" when
+ # tomorrow alone decides it would credit the minimum with a charge it is
+ # not causing.
+ because = [why for level, why in wants if level >= target - 0.5]
+ target = min(target, 100.0)
+ until = _spoken_hm(tariff.cheap_end)
+ if target > soc + 0.5:
+ buy_kwh = (target - soc) / 100.0 * snapshot.capacity_kwh
+ reason = (f"Flux is not running the cheap window: charging about "
+ f"{buy_kwh:.0f} kWh to {target:.0f}% for {' and '.join(because)}, "
+ f"then holding until {until} with the house on the cheap grid")
+ else:
+ reason = (f"Flux is not running the cheap window: holding the battery at "
+ f"{soc:.0f}% until {until} with the house on the cheap grid")
+ return Decision(
+ action = ACTION_START_IMPORT,
+ reason = reason,
+ power_watts = 10000,
+ target_soc_pct = round(target, 1),
+ dawn_viable = True,
+ soc_at_dawn_kwh = balance.battery_at_dawn_kwh,
+ import_kwh = balance.import_kwh_grid,
+ import_purpose = CHEAP_WINDOW_PURPOSE,
+ )
+
@staticmethod
def _check_event_cover(snapshot: ManagerSnapshot,
balance: SufficiencyBalance) -> Optional[Decision]:
diff --git a/SigenEnergyManager.indigoPlugin/Contents/Server Plugin/flux_strategy.py b/SigenEnergyManager.indigoPlugin/Contents/Server Plugin/flux_strategy.py
index ee5b3ba..6f75859 100644
--- a/SigenEnergyManager.indigoPlugin/Contents/Server Plugin/flux_strategy.py
+++ b/SigenEnergyManager.indigoPlugin/Contents/Server Plugin/flux_strategy.py
@@ -5,11 +5,24 @@
# solar forecast, a household profile, event commitments and one
# battery observation in; one decision, or a refusal, out.
# Author: CliveS & Claude Opus 5 (1M context); v2.1-2.7 Claude Opus 5.5; v2.8-2.9 Claude Sonnet 5.5;
-# v2.9.1 Claude Opus 5.5
+# v2.9.1-2.10 Claude Opus 5.5
# Date: 16-09-2026; v2.1 22-09-2026; v2.2 24-09-2026; v2.3 26-09-2026;
# v2.4 and v2.5 27-09-2026; v2.6 28-09-2026; v2.7 29-09-2026;
-# v2.8 and v2.9 30-09-2026; v2.9.1 01-10-2026
-# Version: 2.9.1
+# v2.8 and v2.9 30-09-2026; v2.9.1 and v2.10 01-10-2026
+# Version: 2.10
+#
+# v2.10 (SigenEnergyManager 5.130.0) makes the 50% minimum hold when Flux cannot plan.
+# * 1-Oct-2026: Octopus published October's Flux IMPORT prices but no EXPORT price
+# after 00:00 BST (every region; the account's export agreement is still
+# FLUX-EXPORT-23-02-14, open-ended). derive_bands rightly refused, Flux deferred all
+# night, and the ordinary manager bought to its own household target (44%) and then
+# ran the house from the battery until 5am. The rule now has ONE owner,
+# cheap_window_minimum_pct(), read by this planner and by the manager's fallback.
+# * bands_problem() says WHICH side is missing and from when, instead of "could not be
+# read as a contiguous Flux shape".
+# * _minimum_charge sized its power on at least 15 minutes left, so at 04:55 it asked
+# for a third of what five minutes needed. It now uses the real time left, and
+# reports the part it cannot reach (minimum_shortfall_kwh) instead of staying silent.
#
# v2.9.1 (SigenEnergyManager 5.129.1) carry_forward_spans(). 1-Oct-2026: Octopus
# published October's Flux import prices but no export price after midnight BST (every
@@ -358,6 +371,8 @@ class FluxInputs:
# v2.6: (start, end) windows the peak sale serves FIRST (joined Saving Sessions).
# Not a reservation: they only decide WHEN inside the peak the spare is sold.
sale_priority: Tuple[Tuple[datetime, datetime], ...] = field(default_factory=tuple)
+ # v2.10: why derive_bands refused, in words (bands_problem), when bands is None.
+ bands_problem: str = ""
@dataclass(frozen=True)
@@ -378,6 +393,9 @@ class FluxDecision:
margin_p: float = 0.0
committed_kwh: float = 0.0
infeasible_kwh: float = 0.0
+ # v2.10: battery-side kWh the 50% minimum still needs that the time left and the
+ # charge rate cannot deliver before 05:00. A configured target is not an achieved one.
+ minimum_shortfall_kwh: float = 0.0
@property
def deferred(self):
@@ -770,6 +788,71 @@ def derive_bands(import_spans, export_spans, tz, now, day=None):
return FluxBands(covers_until=min(imp_until, exp_until), **prices)
+def bands_problem(import_spans, export_spans, tz, now, day=None):
+ """Why derive_bands refuses, in words; "" when it does not (v2.10).
+
+ Says which side and from when, because the fix differs: a side Octopus has not
+ published yet (1-Oct-2026: no October export price) is waited out, a band shape
+ that is not Flux any more is a tariff change. Never a price, never a guess.
+ """
+ if tz is None:
+ return "the local timezone is unavailable"
+ now = _aware(now, "now")
+
+ def _side(spans, name):
+ if not spans:
+ return f"Octopus has published no Flux {name} prices at all"
+ until = _covered_until(spans, now)
+ if until is None:
+ last = max(s.end for s in spans).astimezone(tz)
+ if last <= now:
+ return (f"Octopus has not published the Flux {name} prices beyond "
+ f"{last:%H:%M} on {last.day} {last:%B}, so there is no {name} "
+ f"price for now")
+ return (f"the published Flux {name} prices have a gap or two different "
+ f"prices for the same time")
+ return ""
+
+ for spans, name in ((import_spans, "import"), (export_spans, "export")):
+ why = _side(spans, name)
+ if why:
+ return why
+ day = day or _local_date(now, tz)
+ if _band_window(import_spans, tz, day, True) != (FLUX_CHEAP_START, FLUX_CHEAP_END):
+ return (f"today's published import prices do not have the Flux cheap band at "
+ f"{FLUX_CHEAP_START:%H:%M} to {FLUX_CHEAP_END:%H:%M}")
+ if _band_window(export_spans, tz, day, False) != (FLUX_PEAK_START, FLUX_PEAK_END):
+ return (f"today's published export prices do not have the Flux peak band at "
+ f"{FLUX_PEAK_START:%H:%M} to {FLUX_PEAK_END:%H:%M}")
+ if derive_bands(import_spans, export_spans, tz, now, day) is None:
+ return "one of today's Flux bands has more than one published price"
+ return ""
+
+
+def _free_hour_booked(commitments, window_end, tz):
+ """True when a booked free-import window falls in the day this cheap window
+ serves (v2.10: the one test, shared by the planner and the manager's fallback)."""
+ free_kwh = _commitment_kwh(commitments, window_end,
+ next_local(window_end, tz, FLUX_CHEAP_START), "import")
+ return free_kwh >= MIN_TRADE_KWH
+
+
+def cheap_window_minimum_pct(site, commitments, now, tz):
+ """The agreed overnight minimum in force NOW, or 0.0 (v2.10).
+
+ CHARGE_MIN_PCT, capped by the site's charge ceiling, while the 02:00-05:00 window
+ is open; 0.0 outside it and on a day with a booked free hour, which wants the
+ room. ONE owner: the planner's _minimum_charge and the manager's fallback both
+ read this, so the rule cannot hold in one and lapse in the other (1-Oct-2026).
+ """
+ if tz is None or not in_window(now, tz, FLUX_CHEAP_START, FLUX_CHEAP_END):
+ return 0.0
+ window_end = next_local(now, tz, FLUX_CHEAP_END)
+ if _free_hour_booked(commitments, window_end, tz):
+ return 0.0
+ return max(0.0, min(CHARGE_MIN_PCT, float(site.max_charge_soc_pct)))
+
+
# ================================================================
# The chronological energy budget
# ================================================================
@@ -1198,8 +1281,9 @@ def _validate(inputs):
if inputs.local_tz is None:
raise FluxDeferred("the local timezone is unavailable")
if inputs.bands is None:
- raise FluxDeferred("the published Flux import and export rates could not be "
- "read as a contiguous Flux shape")
+ raise FluxDeferred(inputs.bands_problem
+ or "the published Flux import and export rates could not be "
+ "read as a contiguous Flux shape")
_age(inputs.rates_age_s, "the Flux rates age", MAX_RATES_AGE_S)
if inputs.bands.covers_until <= inputs.now:
raise FluxDeferred("the published Flux rates do not reach the present moment")
@@ -1336,32 +1420,43 @@ def _charge_plan(inputs):
return target_pct, power_w, buy_kwh, margin_p, household_buy_kwh, infeasible
-def _minimum_charge(inputs, window_end, target_pct, power_w, buy_kwh, free_booked):
- """(target_pct, power_w, buy_kwh, raised_kwh) with the nightly minimum applied (v2.9).
+# v2.10: the shortest time left the minimum's power is sized on. Below a minute the
+# sum asks for absurd power that the charge rate caps anyway; the floor only keeps the
+# division finite, it is not a planning horizon (v2.9 used 15 minutes, see header).
+MIN_HOURS_LEFT = 1.0 / 60.0
+
+
+def _minimum_charge(inputs, window_end, target_pct, power_w, buy_kwh, minimum):
+ """(target_pct, power_w, buy_kwh, raised_kwh, shortfall_kwh) with the nightly
+ minimum applied (v2.9; v2.10 sizes on the real time left and reports a shortfall).
- `target_pct` None means the plan found nothing to buy. The minimum does not apply
- when a free hour is booked for the day (`free_booked`): that wants the room, and the
- free electricity fills it. It never lowers a target, never exceeds the site's charge
- ceiling, and sizes the power to reach the minimum by the end of the window, within
- the site's charge rate and spare import capacity. `raised_kwh` is what it added
- (battery side), 0.0 when it changed nothing.
+ `minimum` comes from cheap_window_minimum_pct, 0.0 on a free-hour day. `target_pct`
+ None means the plan found nothing to buy. It never lowers a target, never exceeds
+ the site's charge ceiling, and sizes the power to reach the minimum by the end of
+ the window, within the site's charge rate and spare import capacity. `raised_kwh` is
+ what it added (battery side); `shortfall_kwh` is the part of the minimum the time
+ left cannot deliver at that power, 0.0 when it is reachable.
"""
site = inputs.site
- minimum = min(CHARGE_MIN_PCT, float(site.max_charge_soc_pct))
soc = float(inputs.soc_pct)
- if free_booked or minimum <= 0 or soc >= minimum - 0.05:
- return target_pct, power_w, buy_kwh, 0.0
+ if minimum <= 0 or soc >= minimum - 0.05:
+ return target_pct, power_w, buy_kwh, 0.0, 0.0
if target_pct is not None and float(target_pct) >= minimum:
- return target_pct, power_w, buy_kwh, 0.0
+ return target_pct, power_w, buy_kwh, 0.0, 0.0
need_kwh = (minimum - soc) / 100.0 * site.capacity_kwh # battery side
- hours_left = max(0.25, (window_end - inputs.now).total_seconds() / 3600.0)
- wanted_w = int(need_kwh / site.one_way_efficiency / hours_left * 1000.0)
+ hours_left = max(MIN_HOURS_LEFT, (window_end - inputs.now).total_seconds() / 3600.0)
+ wanted_w = int(math.ceil(need_kwh / site.one_way_efficiency / hours_left * 1000.0))
available = min(site.charge_power_w, import_headroom_w(inputs))
power = int(min(max(int(power_w or 0), wanted_w), available))
+ reachable_kwh = max(0, power) / 1000.0 * hours_left * site.one_way_efficiency
+ shortfall = max(0.0, need_kwh - reachable_kwh)
+ if shortfall < 0.01:
+ shortfall = 0.0
if power <= 0:
- return target_pct, power_w, buy_kwh, 0.0
+ return target_pct, power_w, buy_kwh, 0.0, round(need_kwh, 2)
total = max(float(buy_kwh or 0.0), need_kwh)
- return minimum, power, total, max(0.0, total - float(buy_kwh or 0.0))
+ return (minimum, power, total, max(0.0, total - float(buy_kwh or 0.0)),
+ round(shortfall, 2))
def _resale_room_kwh(inputs, base_kwh, window_end, horizon_end, ceiling_kwh):
@@ -1624,12 +1719,17 @@ def plan(inputs):
# v2.9: at least CHARGE_MIN_PCT by the end of the window, except on a
# day with a booked free hour.
min_note = ""
- (target_pct, power_w, buy_kwh, raised_kwh) = _minimum_charge(
- inputs, window_end, target_pct, power_w, buy_kwh,
- free_booked=free_kwh >= MIN_TRADE_KWH)
+ minimum = cheap_window_minimum_pct(site, inputs.commitments, inputs.now, tz)
+ (target_pct, power_w, buy_kwh, raised_kwh,
+ shortfall_kwh) = _minimum_charge(
+ inputs, window_end, target_pct, power_w, buy_kwh, minimum)
if raised_kwh > 0:
min_note = (f", and {raised_kwh:.1f} kWh of that is to bring the "
- f"battery up to the {CHARGE_MIN_PCT:.0f}% minimum")
+ f"battery up to the {minimum:.0f}% minimum")
+ if shortfall_kwh > 0:
+ min_note += (f". The time left and the charge rate fall about "
+ f"{shortfall_kwh:.1f} kWh short of the {minimum:.0f}% "
+ f"minimum by {FLUX_CHEAP_END:%H:%M}")
if target_pct is None:
return FluxDecision(
mode=MODE_HOLD, owns=True, decision_at=inputs.now,
@@ -1641,9 +1741,10 @@ def plan(inputs):
protect_soc_pct=house_floor, household_floor_pct=house_floor,
margin_p=margin_p, committed_kwh=committed,
infeasible_kwh=infeasible,
+ minimum_shortfall_kwh=shortfall_kwh,
reason=("the cheap window is open and the battery already holds "
"everything the day ahead is forecast to need"
- + free_note))
+ + min_note + free_note))
arb_kwh = max(0.0, buy_kwh - household_kwh)
reason = (f"cheap window — buying about {buy_kwh:.1f} kWh at "
f"{bands.import_cheap_p:.1f}p to {target_pct:.0f}%, of which "
@@ -1665,7 +1766,8 @@ def plan(inputs):
decision_until=_decision_until(inputs, window_end),
protect_soc_pct=house_floor, household_floor_pct=house_floor,
planned_kwh=round(buy_kwh, 2), margin_p=margin_p,
- committed_kwh=committed, infeasible_kwh=infeasible, reason=reason)
+ committed_kwh=committed, infeasible_kwh=infeasible,
+ minimum_shortfall_kwh=shortfall_kwh, reason=reason)
# ── the peak window ─────────────────────────────────────────────────
if in_window(inputs.now, tz, FLUX_PEAK_START, FLUX_PEAK_END):
diff --git a/SigenEnergyManager.indigoPlugin/Contents/Server Plugin/plugin.py b/SigenEnergyManager.indigoPlugin/Contents/Server Plugin/plugin.py
index dadb6b6..6923ea2 100644
--- a/SigenEnergyManager.indigoPlugin/Contents/Server Plugin/plugin.py
+++ b/SigenEnergyManager.indigoPlugin/Contents/Server Plugin/plugin.py
@@ -62,8 +62,9 @@
# Claude Sonnet 5.5 (5.129.0 — the 2am charge reaches at least 50% every night, except on a free-hour day)
# Claude Opus 5.5 (5.129.1 — a Flux side Octopus has not published yet uses the last published day's prices)
# Claude Opus 5.5 (5.129.2 — such a day is valued provisionally, then re-priced when Octopus publishes)
+# Claude Opus 5.5 (5.130.0 — the 50% minimum holds when Flux cannot plan; the manager runs the cheap window to 5am)
# Date: 28-09-2026
-# Version: 5.129.2
+# Version: 5.130.0
#
# CHANGELOG: docs/plugin-changelog.md
# The full technical history used to live here and had reached 2,002 lines - 17.4% of
@@ -294,7 +295,7 @@ def _as_bool(value, default=False):
ACTION_SELF_CONSUMPTION, ACTION_START_IMPORT, ACTION_STOP_IMPORT,
ACTION_SCHEDULE_IMPORT, ACTION_START_EXPORT, ACTION_STOP_EXPORT,
ACTION_VPP_EXPORT, ACTION_SAVING_SESSION, ACTION_HAPPY_HOUR_IMPORT,
- HAPPY_HOUR_FAIR_USE_KWH,
+ HAPPY_HOUR_FAIR_USE_KWH, CHEAP_WINDOW_PURPOSE,
ACTION_SOLAR_OVERFLOW, FLOOD_PREV_SOC_THRESHOLD_PCT, FLOOD_PREV_TARGET_PCT,
FLOOD_PREV_FORECAST_MULT,
pv_tracking_factor as _pv_tracking_factor,
@@ -1771,6 +1772,10 @@ def __init__(self, plugin_id, plugin_display_name, plugin_version, plugin_prefs)
self.store["happy_hour_tokens"] = None
self.store["saving_session_export_active"] = False
self.store["happy_hour_import_active"] = False
+ # 5.130.0: the manager is running the Flux cheap window because Flux is not
+ # (battery_manager 3.15). Not persisted: a restart mid-window re-drives it
+ # from the next decision, and after the window there is nothing to resume.
+ self.store["cheap_window_import_active"] = False
self.store["happy_hour_anchor_kwh"] = None # grid-import counter at window entry
self.store["happy_hour_free_kwh"] = 0.0 # free kWh banked in the last window
# v5.112.0 booking. All three persisted (see _save_accumulators_locked).
@@ -4159,6 +4164,10 @@ def _evaluate_manager_impl(self):
# 6. Log if action changed or heartbeat
self._log_manager_decision(decision, snapshot, soc_pct)
self._note_import_hold(decision)
+ try:
+ self._note_cheap_window_result()
+ except Exception as exc: # noqa: BLE001
+ self.logger.debug(f"[Overnight] cheap-window note failed: {exc!r}")
# 7 + 8. Verify and act — UNLESS the Flux supervisor is holding the
# inverter. This is the whole of the "no overlay that fights the old
@@ -4188,6 +4197,9 @@ def _evaluate_manager_impl(self):
if owner:
self._flux_release(f"pre-empted by {owner}", preempted=True)
if self._flux_owns_control():
+ if self.store.get("cheap_window_import_active"):
+ self._forget_cheap_window_import(
+ "it can plan the cheap window again")
self.store["manager_hands_off_reason"] = (
"the Flux supervisor holds the inverter")
self._update_manager_device(decision, snapshot)
@@ -5132,6 +5144,8 @@ def _build_manager_snapshot(self, soc_pct, export_enabled, vpp_reserved_kwh):
import_pending = bool(self.store.get("import_active")
or self.store.get("import_scheduled_time") is not None),
flux_owns_cheap_window = self._flux_owns_cheap_window(),
+ # 5.130.0: the agreed overnight minimum, from the same function Flux uses.
+ cheap_window_min_pct = self._cheap_window_minimum_pct(),
# 5.124.0: an Axle event the battery cannot cover alone.
event_cover_active = bool(_cover is not None and _cover.active),
event_cover_target_pct = float(_cover.target_pct) if _cover else 0.0,
@@ -7350,7 +7364,22 @@ def _act_on_decision(self, decision):
self.store["import_scheduled_time"] = None
self.store["import_scheduled_logged"] = False
- if action == ACTION_START_IMPORT:
+ cheap_purpose = (getattr(decision, "import_purpose", None) == CHEAP_WINDOW_PURPOSE)
+ if (self.store.get("cheap_window_import_active")
+ and not (action == ACTION_START_IMPORT and cheap_purpose)):
+ # 5.130.0: the cheap-window charge runs to the END of the window and no
+ # further. Any other decision — 05:00 passing, Flux taking the window
+ # back, a VPP window — hands back here, whatever the battery reached,
+ # so it can never go on charging at the day rate.
+ self._end_cheap_window_import(
+ "the cheap window has closed" if not self._in_flux_cheap_window()
+ else f"the manager now wants '{action}'")
+ prev_import = False
+
+ if action == ACTION_START_IMPORT and cheap_purpose:
+ self._drive_cheap_window_import(decision, prev_import)
+
+ elif action == ACTION_START_IMPORT:
if not prev_import:
log(f"[Manager] Starting grid import: {decision.reason}")
power_w = min(decision.power_watts or 10000,
@@ -7684,7 +7713,10 @@ def _act_on_decision(self, decision):
# here would hand back to self consumption with the free hour still
# running, and the manager — seeing its import flag still set — would
# never re-drive it. The window end and the overrun backstop end it.
- if self.store["import_active"] and not self.store.get("happy_hour_import_active"):
+ # 5.130.0: nor for the cheap-window charge, which HOLDS at its target to 05:00
+ # (the hardware cutoff stops the charging) and is ended above, not here.
+ if (self.store["import_active"] and not self.store.get("happy_hour_import_active")
+ and not self.store.get("cheap_window_import_active")):
current_soc = self.latest_inverter_data.get("batterySoc", 0.0)
target_soc = self.store["import_target_soc"]
if current_soc >= target_soc:
@@ -7735,6 +7767,198 @@ def _restore_import_cutoff(self):
self.modbus.set_charge_cutoff(100.0)
self._set_import_cutoff(None)
+ def _in_flux_cheap_window(self, now=None):
+ """True inside 02:00-05:00 London. False when Flux code is unavailable."""
+ if _flux_strategy is None:
+ return False
+ try:
+ return bool(_flux_strategy.in_window(
+ now or datetime.now(timezone.utc), _london_tz(),
+ _flux_strategy.FLUX_CHEAP_START, _flux_strategy.FLUX_CHEAP_END))
+ except Exception as exc: # noqa: BLE001
+ self.logger.debug(f"[Manager] cheap-window check failed: {exc!r}")
+ return False
+
+ def _cheap_window_minimum_pct(self):
+ """The agreed overnight minimum in force now, or 0.0 (5.130.0).
+
+ flux_strategy.cheap_window_minimum_pct is the one owner of the rule: 50% while
+ the Flux cheap window is open, none on a day with a booked free hour. Flux
+ accounts only — the minimum is a Flux policy, and the account's tariff is what
+ says so, not whether the Flux controller happens to be able to plan.
+ """
+ if _flux_strategy is None:
+ return 0.0
+ tariff_key = (self.latest_rates_data or {}).get("tariff_info", {}).get("tariff_key")
+ if tariff_key != TARIFF_FLUX:
+ return 0.0
+ try:
+ return float(_flux_strategy.cheap_window_minimum_pct(
+ self._flux_site(), self._flux_commitments(),
+ datetime.now(timezone.utc), _london_tz()))
+ except Exception as exc: # noqa: BLE001
+ # Fail towards the minimum, not away from it: an unreadable booking list
+ # must not quietly drop the floor the night it is needed.
+ self.logger.debug(f"[Manager] cheap-window minimum not worked out: {exc!r}")
+ return (_flux_strategy.CHARGE_MIN_PCT
+ if self._in_flux_cheap_window() else 0.0)
+
+ def _cheap_window_import_holding(self):
+ """True when the manager's cheap-window charge has reached its level and is only
+ holding it. Holding is not an owner Flux must stand aside for (5.130.0): Flux
+ plans again and, if it can, takes the window back."""
+ if not self.store.get("cheap_window_import_active"):
+ return False
+ soc = float(self.latest_inverter_data.get("batterySoc", 0.0) or 0.0)
+ return soc >= float(self.store.get("import_target_soc") or 0.0) - 0.5
+
+ def _drive_cheap_window_import(self, decision, prev_import):
+ """Run the Flux cheap window for Flux when Flux cannot (5.130.0).
+
+ Charge PV First with the hardware cutoff at the level wanted — or at the
+ battery's present level when that is already higher, which is a pure hold —
+ and the discharge pinned at zero, so the house runs on the cheap grid until
+ 05:00 exactly as Flux's own hold does. The level only ever rises inside the
+ window. Ended by _end_cheap_window_import, never by reaching the target.
+ """
+ if not (self.modbus and self.modbus.connected):
+ return
+ soc = float(self.latest_inverter_data.get("batterySoc", 0.0) or 0.0)
+ target = float(decision.target_soc_pct or 0.0)
+ if not self.store.get("cheap_window_import_active"):
+ level = min(100.0, max(target, round(soc, 1)))
+ power_w = min(int(decision.power_watts or 10000),
+ int(_as_float(self.pluginPrefs.get("inverterMaxKw"), 10.0) * 1000))
+ if prev_import:
+ ok = bool(self.modbus.set_charge_cutoff(level))
+ else:
+ ok = self._force_charge(power_w, level)
+ if not ok:
+ log("[Manager] The cheap-window charge could not be started (the inverter "
+ "refused the command or did not answer); retrying next tick",
+ level="WARNING")
+ return
+ self.store["import_active"] = True
+ self.store["cheap_window_import_active"] = True
+ self.store["import_target_soc"] = level
+ self.store["export_active"] = False
+ if level > soc + 0.5:
+ self.store["had_import_today"] = True
+ self._set_import_cutoff(level)
+ # Best effort, as for a Happy Hour: _verify_ems_registers expects zero
+ # for as long as the cheap-window import runs and re-asserts it.
+ self.modbus.set_discharge_limit(0)
+ log(f"[Manager] {decision.reason}")
+ return
+ current = float(self.store.get("import_target_soc") or 0.0)
+ if target >= current + 1.0:
+ level = min(100.0, target)
+ if self.modbus.set_charge_cutoff(level):
+ self.store["import_target_soc"] = level
+ self.store["had_import_today"] = True
+ self._set_import_cutoff(level)
+ log(f"[Manager] Cheap-window charge now to {level:.0f}%: {decision.reason}")
+
+ def _end_cheap_window_import(self, why):
+ """Hand the manager's cheap-window charge back to self consumption."""
+ soc = float(self.latest_inverter_data.get("batterySoc", 0.0) or 0.0)
+ self.store["cheap_window_import_active"] = False
+ self.store["import_active"] = False
+ self.store["import_target_soc"] = 0.0
+ if self.modbus and self.modbus.connected:
+ if not self.modbus.set_self_consumption():
+ log("[Manager] Cheap-window hand-back to Self Consumption was NOT "
+ "confirmed — retrying on the next tick", level="WARNING")
+ self.store["vpp_handback_pending"] = True
+ self._restore_import_cutoff()
+ else:
+ self.store["vpp_handback_pending"] = True
+ log(f"[Manager] Cheap-window charge ended ({why}) with the battery at {soc:.0f}%")
+
+ def _forget_cheap_window_import(self, why):
+ """Drop the bookkeeping, with NO writes, when Flux has taken the window back.
+
+ Flux owns the registers now and has written its own; a hand-back here would
+ put mode 2 over its charge. Clearing the cutoff record keeps Flux's 05:00
+ release from restoring the manager's old ceiling.
+ """
+ self.store["cheap_window_import_active"] = False
+ self.store["import_active"] = False
+ self.store["import_target_soc"] = 0.0
+ self._set_import_cutoff(None)
+ log(f"[Manager] Cheap-window charge handed to Flux ({why}).")
+
+ def _note_cheap_window_result(self):
+ """Once a day after 05:00 on Flux: what the window ACHIEVED, against the
+ minimum it was set (5.130.0). A configured target is not an achieved one, and
+ the two-week review of the 50% rule needs the achieved figure for every night.
+
+ One line in the event log (a WARNING when the minimum was missed) and one JSON
+ line in cheap_window_results.jsonl beside the plugin's other records.
+ """
+ if _flux_strategy is None:
+ return
+ tariff_key = (self.latest_rates_data or {}).get("tariff_info", {}).get("tariff_key")
+ if tariff_key != TARIFF_FLUX:
+ return
+ tz = _london_tz()
+ now = datetime.now(timezone.utc)
+ if self._in_flux_cheap_window(now):
+ # Remember, through the window, the minimum that applied and who ran it.
+ minimum = self._cheap_window_minimum_pct()
+ rec = self.store.get("cheap_window_watch") or {}
+ day = str(now.astimezone(tz).date())
+ if rec.get("day") != day:
+ rec = {"day": day, "minimum_pct": 0.0, "manager_ran": False,
+ "flux_ran": False, "flux_reason": ""}
+ rec["minimum_pct"] = max(float(rec.get("minimum_pct") or 0.0), minimum)
+ if self.store.get("cheap_window_import_active"):
+ rec["manager_ran"] = True
+ if self._flux_owns_control():
+ rec["flux_ran"] = True
+ else:
+ rec["flux_reason"] = str(self.store.get("flux_status") or "")[:200]
+ self.store["cheap_window_watch"] = rec
+ return
+ rec = self.store.get("cheap_window_watch") or {}
+ local = now.astimezone(tz)
+ if (not rec or rec.get("day") != str(local.date()) or rec.get("reported")
+ or local.time() < _flux_strategy.FLUX_CHEAP_END):
+ return
+ rec["reported"] = True
+ self.store["cheap_window_watch"] = rec
+ soc = float(self.latest_inverter_data.get("batterySoc", 0.0) or 0.0)
+ minimum = float(rec.get("minimum_pct") or 0.0)
+ who = ("Flux ran the window" if rec.get("flux_ran") and not rec.get("manager_ran")
+ else "the manager ran the window because Flux could not"
+ if rec.get("manager_ran") else "nothing ran the window")
+ if minimum <= 0:
+ msg = (f"[Overnight] The cheap window closed with the battery at {soc:.0f}% "
+ f"({who}; no minimum tonight — a free hour is booked)")
+ level = "INFO"
+ elif soc >= minimum - 1.0:
+ msg = (f"[Overnight] The cheap window closed with the battery at {soc:.0f}%, "
+ f"which meets the {minimum:.0f}% minimum ({who})")
+ level = "INFO"
+ else:
+ msg = (f"[Overnight] The cheap window closed with the battery at {soc:.0f}%, "
+ f"BELOW the {minimum:.0f}% minimum ({who}"
+ + (f"; Flux said: {rec.get('flux_reason')}" if rec.get("flux_reason")
+ and not rec.get("flux_ran") else "") + ")")
+ level = "WARNING"
+ log(msg, level=level)
+ try:
+ path = os.path.join(self.data_dir, "cheap_window_results.jsonl")
+ with open(path, "a", encoding="utf-8") as fh:
+ fh.write(json.dumps({
+ "day": rec.get("day"), "soc_at_close_pct": round(soc, 1),
+ "minimum_pct": minimum, "flux_ran": bool(rec.get("flux_ran")),
+ "manager_ran": bool(rec.get("manager_ran")),
+ "flux_reason": rec.get("flux_reason", ""),
+ "met": bool(minimum <= 0 or soc >= minimum - 1.0)}) + "\n")
+ except Exception as exc: # noqa: BLE001
+ self.logger.debug(f"[Overnight] result not recorded: {exc!r}")
+
def _driven_export_owns_registers(self):
"""True while a self-driven export window owns the mode and limit registers.
@@ -7869,6 +8093,10 @@ def _verify_ems_registers(self):
# pass must hold that, not "correct" it back to maximum.
if self.store.get("happy_hour_import_active"):
expected_discharge_w = 0
+ # 5.130.0: and inside the manager's cheap-window charge, which holds the
+ # battery for the house to run on the cheap grid, as Flux's hold does.
+ if self.store.get("cheap_window_import_active"):
+ expected_discharge_w = 0
# During solar overflow the charge limit is intentionally reduced.
# Use the stored cap as the expected value so verify() doesn't fight it.
@@ -13356,7 +13584,9 @@ def _flux_other_owner(self):
# 5.124.0: an Axle event the battery cannot cover alone. The manager
# buys the gap, so Flux must let go even in the peak.
return "an Axle event needs a grid top-up"
- if self.store.get("import_active"):
+ if self.store.get("import_active") and not self._cheap_window_import_holding():
+ # 5.130.0: a cheap-window charge that has reached its level is only
+ # holding it, and Flux may take the window back from a hold.
return "the manager has a grid import in flight"
if self.store.get("export_active"):
return "the manager has an export in flight"
@@ -14323,6 +14553,10 @@ def _flux_owns_cheap_window(self):
return True
if self._flux_other_owner():
return False
+ # 5.130.0: a plan Flux is not yet allowed to apply (the stand-down after an
+ # import) does not run the window, so the manager's hold stays until it can.
+ if not (self._flux_owns_control() or self._flux_may_claim()):
+ return False
decision = self.store.get("flux_decision")
return bool(decision is not None and getattr(decision, "mode", None)
in (_flux_strategy.MODE_CHARGE, _flux_strategy.MODE_HOLD))
@@ -14414,12 +14648,16 @@ def _flux_inputs(self, observed, observed_at):
verified, _why = self._flux_tariff_verified()
bands = None
+ bands_why = ""
try:
+ imp_spans = self._flux_planning_spans("flux_import_slots")
+ exp_spans = self._flux_planning_spans("flux_export_slots")
# 5.129.1: planning reads a side Octopus has not published yet as the
# last published day's prices; see _flux_planning_spans.
- bands = _flux_strategy.derive_bands(
- self._flux_planning_spans("flux_import_slots"),
- self._flux_planning_spans("flux_export_slots"), tz, now)
+ bands = _flux_strategy.derive_bands(imp_spans, exp_spans, tz, now)
+ if bands is None:
+ # 5.130.0: say which side is missing and from when.
+ bands_why = _flux_strategy.bands_problem(imp_spans, exp_spans, tz, now)
except Exception as exc: # noqa: BLE001
self.logger.debug(f"[Flux] band derivation failed: {exc!r}")
@@ -14467,6 +14705,7 @@ def _flux_inputs(self, observed, observed_at):
flows_age_s = max(0.0, time.time() - observed_at),
day_rate_import_today = self._day_rate_import_today(),
sale_priority = self._flux_sale_priority(),
+ bands_problem = bands_why,
)
def _flux_sale_priority(self):
diff --git a/SigenEnergyManager.indigoPlugin/Contents/Server Plugin/test_cheap_window_minimum.py b/SigenEnergyManager.indigoPlugin/Contents/Server Plugin/test_cheap_window_minimum.py
new file mode 100644
index 0000000..e27e47a
--- /dev/null
+++ b/SigenEnergyManager.indigoPlugin/Contents/Server Plugin/test_cheap_window_minimum.py
@@ -0,0 +1,519 @@
+#! /usr/bin/env python
+# -*- coding: utf-8 -*-
+# Filename: test_cheap_window_minimum.py
+# Description: The 50% overnight minimum holds whoever runs the 02:00-05:00 window
+# (SigenEnergyManager 5.130.0, flux_strategy 2.10, battery_manager 3.15).
+# Built from 1-Oct-2026: Octopus published October's Flux import prices
+# but no export price after midnight, Flux deferred all night, and the
+# manager bought to 44%, then ran the house on the battery to 5am.
+# Author: CliveS & Claude Opus 5.5
+# Date: 01-10-2026
+# Version: 1.0
+
+import json
+import os
+import sys
+import tempfile
+import types
+import unittest
+from dataclasses import replace
+from datetime import datetime, timedelta, timezone
+from unittest.mock import MagicMock, patch
+from zoneinfo import ZoneInfo
+
+_ind = types.ModuleType("indigo")
+
+
+class _PB:
+ class StopThread(Exception):
+ pass
+
+ def __init__(self, *a, **k):
+ pass
+
+
+_ind.PluginBase = _PB
+_ind.Dict = dict
+_ind.List = list
+for _a in ("kStateImageSel", "server", "devices", "variables", "variable",
+ "kDeviceAction", "activePlugin", "trigger"):
+ setattr(_ind, _a, MagicMock())
+sys.modules.setdefault("indigo", _ind)
+
+_pm = MagicMock()
+sys.modules.setdefault("pymodbus", _pm)
+sys.modules.setdefault("pymodbus.client", _pm.client)
+sys.modules.setdefault("pymodbus.exceptions", _pm.exceptions)
+sys.modules.setdefault("requests", MagicMock())
+
+import plugin # noqa: E402
+import flux_strategy as fs # noqa: E402
+from battery_manager import ( # noqa: E402
+ BatteryManager, TariffData, ACTION_START_IMPORT, ACTION_SELF_CONSUMPTION,
+ CHEAP_WINDOW_PURPOSE,
+)
+from test_tou_peak_topup import _snap # noqa: E402
+
+LONDON = ZoneInfo("Europe/London")
+UTC = timezone.utc
+
+
+def _local(y, mo, d, h, mi=0):
+ return datetime(y, mo, d, h, mi, tzinfo=LONDON).astimezone(UTC)
+
+
+def _span(a, b, p):
+ return fs.RateSpan(start=datetime.fromisoformat(a.replace("Z", "+00:00")),
+ end=datetime.fromisoformat(b.replace("Z", "+00:00")), p=p)
+
+
+# What Octopus actually published for region F, read 1-Oct-2026 13:39 BST. The
+# export schedule stops at 2026-09-30T23:00Z, i.e. midnight BST, in every region.
+IMPORT_PUBLISHED = [
+ _span("2026-09-29T18:00:00Z", "2026-09-30T01:00:00Z", 24.35433),
+ _span("2026-09-30T01:00:00Z", "2026-09-30T04:00:00Z", 14.618415),
+ _span("2026-09-30T04:00:00Z", "2026-09-30T15:00:00Z", 24.35433),
+ _span("2026-09-30T15:00:00Z", "2026-09-30T18:00:00Z", 34.099905),
+ _span("2026-09-30T18:00:00Z", "2026-10-01T01:00:00Z", 23.1946),
+ _span("2026-10-01T01:00:00Z", "2026-10-01T04:00:00Z", 13.9223),
+ _span("2026-10-01T04:00:00Z", "2026-10-01T15:00:00Z", 23.1946),
+ _span("2026-10-01T15:00:00Z", "2026-10-01T18:00:00Z", 32.4761),
+ _span("2026-10-01T18:00:00Z", "2026-10-02T01:00:00Z", 23.1946),
+ _span("2026-10-02T01:00:00Z", "2026-10-02T04:00:00Z", 13.9223),
+ _span("2026-10-02T04:00:00Z", "2026-10-02T15:00:00Z", 23.1946),
+ _span("2026-10-02T15:00:00Z", "2026-10-02T18:00:00Z", 32.4761),
+ _span("2026-10-02T18:00:00Z", "2026-10-03T01:00:00Z", 23.1946),
+]
+EXPORT_PUBLISHED = [
+ _span("2026-09-29T18:00:00Z", "2026-09-30T01:00:00Z", 9.7084),
+ _span("2026-09-30T01:00:00Z", "2026-09-30T04:00:00Z", 4.2064),
+ _span("2026-09-30T04:00:00Z", "2026-09-30T15:00:00Z", 9.7084),
+ _span("2026-09-30T15:00:00Z", "2026-09-30T18:00:00Z", 27.6905),
+ _span("2026-09-30T18:00:00Z", "2026-09-30T23:00:00Z", 9.7084),
+]
+
+
+def _export_published_later():
+ """The same export SHAPE carried into 1-2 October — a test fixture standing in
+ for a schedule Octopus has not published, never a price the plugin may use."""
+ out = list(EXPORT_PUBLISHED[:-1])
+ out.append(_span("2026-09-30T18:00:00Z", "2026-10-01T01:00:00Z", 9.7084))
+ for day in ("2026-10-01", "2026-10-02"):
+ nxt = "2026-10-02" if day == "2026-10-01" else "2026-10-03"
+ out += [_span(f"{day}T01:00:00Z", f"{day}T04:00:00Z", 4.2064),
+ _span(f"{day}T04:00:00Z", f"{day}T15:00:00Z", 9.7084),
+ _span(f"{day}T15:00:00Z", f"{day}T18:00:00Z", 27.6905),
+ _span(f"{day}T18:00:00Z", f"{nxt}T01:00:00Z", 9.7084)]
+ return out
+
+
+def _site(**over):
+ base = dict(capacity_kwh=35.04, one_way_efficiency=0.97, charge_power_w=10000,
+ discharge_power_w=10000, export_limit_w=4000, import_limit_w=16000,
+ import_limit_verified=True, reserve_pct=20.0, max_charge_soc_pct=100.0,
+ wear_p_per_kwh=2.0)
+ fields = set(fs.FluxSite.__dataclass_fields__)
+ return fs.FluxSite(**{k: v for k, v in {**base, **over}.items() if k in fields})
+
+
+def _free_hour(day_local, h0, h1):
+ return fs.EventCommitment(source="octopus", kind="import",
+ start=_local(*day_local, h0), end=_local(*day_local, h1),
+ energy_kwh=10.0 * (h1 - h0), event_id=f"hh-{h0}")
+
+
+# ============================================================================
+# The midnight rollover — why Flux deferred
+# ============================================================================
+
+class TestTheMidnightRollover(unittest.TestCase):
+
+ def test_the_published_rates_at_midnight_are_refused(self):
+ now = _local(2026, 10, 1, 0, 0) + timedelta(seconds=4)
+ self.assertIsNone(fs.derive_bands(IMPORT_PUBLISHED, EXPORT_PUBLISHED, LONDON, now))
+
+ def test_the_reason_names_the_missing_export_side_and_when(self):
+ now = _local(2026, 10, 1, 3, 48)
+ why = fs.bands_problem(IMPORT_PUBLISHED, EXPORT_PUBLISHED, LONDON, now)
+ self.assertIn("export", why)
+ self.assertIn("00:00 on 1 October", why)
+ self.assertNotIn("import prices beyond", why)
+
+ def test_the_planner_defers_with_that_reason(self):
+ now = _local(2026, 10, 1, 3, 48)
+ why = fs.bands_problem(IMPORT_PUBLISHED, EXPORT_PUBLISHED, LONDON, now)
+ inputs = fs.FluxInputs(now=now, local_tz=LONDON, bands=None, site=_site(),
+ soc_pct=44.9, house=None, pv=None, tariff_verified=True,
+ commissioned=True, enabled=True, bands_problem=why)
+ d = fs.plan(inputs)
+ self.assertTrue(d.deferred)
+ self.assertIn("00:00 on 1 October", d.reason)
+
+ def test_once_export_is_published_the_new_import_prices_are_read(self):
+ now = _local(2026, 10, 1, 3, 48)
+ bands = fs.derive_bands(IMPORT_PUBLISHED, _export_published_later(), LONDON, now)
+ self.assertIsNotNone(bands)
+ self.assertAlmostEqual(bands.import_cheap_p, 13.9223)
+ self.assertAlmostEqual(bands.import_peak_p, 32.4761)
+ self.assertEqual(fs.bands_problem(IMPORT_PUBLISHED, _export_published_later(),
+ LONDON, now), "")
+
+ def test_a_missing_import_side_is_named_as_import(self):
+ now = _local(2026, 10, 3, 3, 0)
+ why = fs.bands_problem(IMPORT_PUBLISHED, _export_published_later(), LONDON, now)
+ self.assertIn("import", why)
+
+
+# ============================================================================
+# The rule's one owner
+# ============================================================================
+
+class TestTheMinimumHasOneOwner(unittest.TestCase):
+
+ def test_fifty_inside_the_window(self):
+ self.assertEqual(fs.cheap_window_minimum_pct(
+ _site(), (), _local(2026, 10, 1, 3, 0), LONDON), 50.0)
+
+ def test_none_outside_the_window(self):
+ for h in (1, 5, 12, 23):
+ self.assertEqual(fs.cheap_window_minimum_pct(
+ _site(), (), _local(2026, 10, 1, h, 30 if h == 1 else 0), LONDON), 0.0, h)
+
+ def test_none_on_a_day_with_a_booked_free_hour(self):
+ free = (_free_hour((2026, 10, 4), 13, 15),)
+ self.assertEqual(fs.cheap_window_minimum_pct(
+ _site(), free, _local(2026, 10, 4, 3, 0), LONDON), 0.0)
+
+ def test_a_free_hour_on_another_day_does_not_count(self):
+ free = (_free_hour((2026, 10, 5), 13, 15),)
+ self.assertEqual(fs.cheap_window_minimum_pct(
+ _site(), free, _local(2026, 10, 4, 3, 0), LONDON), 50.0)
+
+ def test_never_above_the_site_ceiling(self):
+ self.assertEqual(fs.cheap_window_minimum_pct(
+ _site(max_charge_soc_pct=40.0), (), _local(2026, 10, 1, 3, 0), LONDON), 40.0)
+
+
+# ============================================================================
+# The late-window sizing, and an honest shortfall
+# ============================================================================
+
+def _inputs_at(hh, mm, soc, site=None):
+ return types.SimpleNamespace(now=_local(2026, 10, 1, hh, mm), soc_pct=soc,
+ site=site or _site(), flows=None)
+
+
+class TestTheMinimumIsSizedOnTheRealTimeLeft(unittest.TestCase):
+
+ def _min(self, hh, mm, soc, site=None, headroom=None):
+ inputs = _inputs_at(hh, mm, soc, site)
+ window_end = _local(2026, 10, 1, 5, 0)
+ with patch.object(fs, "import_headroom_w",
+ return_value=headroom if headroom is not None
+ else (site or _site()).import_limit_w):
+ return fs._minimum_charge(inputs, window_end, None, 0, 0.0, 50.0)
+
+ def test_a_restart_at_four_fifty_five_asks_for_enough(self):
+ # 49% -> 50% is 0.3504 kWh in five minutes. v2.9 asked for 1,445 W, which
+ # delivers about 0.117 kWh, a third of it.
+ target, power, buy, raised, short = self._min(4, 55, 49.0)
+ need = 0.01 * 35.04
+ delivered = power / 1000.0 * (5 / 60) * 0.97
+ self.assertEqual(target, 50.0)
+ self.assertGreaterEqual(delivered, need - 1e-6)
+ self.assertGreater(power, 1445 * 2)
+ self.assertEqual(short, 0.0)
+
+ def test_limited_charge_rate_reports_the_shortfall(self):
+ # 30% -> 50% is 7 kWh; at 1 kW for an hour the battery gets about 0.97 kWh.
+ target, power, buy, raised, short = self._min(4, 0, 30.0,
+ site=_site(charge_power_w=1000))
+ self.assertEqual(power, 1000)
+ self.assertAlmostEqual(short, 0.2 * 35.04 - 0.97, places=1)
+
+ def test_no_import_headroom_reports_the_whole_need(self):
+ target, power, buy, raised, short = self._min(3, 0, 40.0, headroom=0)
+ self.assertIsNone(target)
+ self.assertAlmostEqual(short, 0.1 * 35.04, places=2)
+
+ def test_an_early_start_has_no_shortfall(self):
+ *_rest, short = self._min(2, 0, 20.0)
+ self.assertEqual(short, 0.0)
+
+
+# ============================================================================
+# The manager's fallback (battery_manager 3.15)
+# ============================================================================
+
+class TestTheManagerKeepsTheMinimumWhenFluxCannot(unittest.TestCase):
+
+ def setUp(self):
+ self.bm = BatteryManager()
+
+ def _eval(self, soc, hh, mm=0, minimum=50.0, **kw):
+ return self.bm.evaluate(replace(_snap(soc, hh, mm, **kw),
+ cheap_window_min_pct=minimum))
+
+ def test_1_october_at_03_38_charges_on_to_fifty(self):
+ d = self._eval(40.8, 3, 38)
+ self.assertEqual(d.action, ACTION_START_IMPORT)
+ self.assertEqual(d.import_purpose, CHEAP_WINDOW_PURPOSE)
+ self.assertGreaterEqual(d.target_soc_pct, 50.0)
+ self.assertIn("50% minimum", d.reason)
+
+ def test_above_the_minimum_it_holds_rather_than_discharging(self):
+ d = self._eval(55.0, 4, 30)
+ self.assertEqual(d.action, ACTION_START_IMPORT)
+ self.assertEqual(d.import_purpose, CHEAP_WINDOW_PURPOSE)
+ self.assertLessEqual(d.target_soc_pct, 55.0)
+ self.assertIn("holding the battery at 55%", d.reason)
+
+ def test_tomorrow_wins_when_it_needs_more(self):
+ d = self._eval(12.0, 3, 0, minimum=20.0)
+ self.assertGreater(d.target_soc_pct, 20.0)
+ self.assertIn("for tomorrow", d.reason)
+ self.assertNotIn("minimum", d.reason)
+
+ def test_a_free_hour_day_has_no_minimum_but_still_holds(self):
+ d = self._eval(70.0, 3, 0, minimum=0.0)
+ self.assertEqual(d.import_purpose, CHEAP_WINDOW_PURPOSE)
+ self.assertLessEqual(d.target_soc_pct, 70.0)
+ self.assertNotIn("minimum", d.reason)
+
+ def test_flux_running_the_window_keeps_the_manager_out(self):
+ d = self._eval(40.8, 3, 38, flux_owns=True)
+ self.assertNotEqual(d.import_purpose, CHEAP_WINDOW_PURPOSE)
+
+ def test_five_am_ends_the_fallback(self):
+ d = self._eval(48.0, 5, 0)
+ self.assertNotEqual(d.import_purpose, CHEAP_WINDOW_PURPOSE)
+
+ def test_other_tariffs_are_untouched(self):
+ go = TariffData(tariff_key="go", today_rate_p=8.5, cheap_start="00:30",
+ cheap_end="05:30", cheap_rate_p=8.5, day_rate_p=25.0)
+ d = self._eval(40.0, 3, 0, tariff=go, reserve=0.0)
+ self.assertNotEqual(d.import_purpose, CHEAP_WINDOW_PURPOSE)
+
+ def test_the_branch_is_in_the_audit(self):
+ d = self._eval(40.8, 3, 38)
+ self.assertIn("CHEAP-WINDOW", [tag for tag, _ in d.audit_trail])
+
+
+# ============================================================================
+# The plugin's side: drive, hold, end, hand to Flux, report
+# ============================================================================
+
+def _mk(soc=40.8, prefs=None):
+ p = plugin.Plugin.__new__(plugin.Plugin)
+ p.logger = MagicMock()
+ p.debug = False
+ p.pluginPrefs = {"inverterMaxKw": "10", "batteryCapacityKwh": "35.04",
+ "fluxReservePct": "20", "batteryHealthCutoff": "1.0"}
+ p.pluginPrefs.update(prefs or {})
+ p.store = {"import_active": False, "export_active": False, "import_target_soc": 0.0,
+ "cheap_window_import_active": False, "happy_hour_import_active": False,
+ "import_charge_cutoff_pct": None, "vpp_state": plugin.VPP_IDLE,
+ "flood_prev_target_soc": None, "storm_level": "none"}
+ p.latest_inverter_data = {"batterySoc": soc, "emsWorkMode": "Max Self Consumption"}
+ p.latest_rates_data = {"tariff_info": {"tariff_key": plugin.TARIFF_FLUX}}
+ p.modbus = MagicMock()
+ p.modbus.connected = True
+ p.modbus.force_charge.return_value = True
+ p.modbus.set_charge_cutoff.return_value = True
+ p.modbus.set_self_consumption.return_value = True
+ p._save_accumulators = MagicMock()
+ p._trigger_event = MagicMock()
+ p.flux_executor = None
+ return p
+
+
+def _decision(target, purpose=CHEAP_WINDOW_PURPOSE, action=ACTION_START_IMPORT):
+ d = MagicMock()
+ d.action = action
+ d.target_soc_pct = target
+ d.power_watts = 10000
+ d.import_purpose = purpose
+ d.reason = "Flux is not running the cheap window: test"
+ return d
+
+
+class TestThePluginRunsTheWindowToFiveAm(unittest.TestCase):
+
+ def test_it_charges_to_the_minimum_with_the_house_on_the_grid(self):
+ p = _mk(soc=40.8)
+ with patch.object(p, "_in_flux_cheap_window", return_value=True):
+ p._act_on_decision(_decision(50.0))
+ p.modbus.force_charge.assert_called_once()
+ self.assertEqual(p.modbus.force_charge.call_args.kwargs["cutoff_soc"], 50.0)
+ p.modbus.set_discharge_limit.assert_called_with(0)
+ self.assertTrue(p.store["cheap_window_import_active"])
+ self.assertEqual(p.store["import_target_soc"], 50.0)
+
+ def test_it_does_not_stop_at_the_target_inside_the_window(self):
+ p = _mk(soc=40.8)
+ with patch.object(p, "_in_flux_cheap_window", return_value=True):
+ p._act_on_decision(_decision(50.0))
+ p.latest_inverter_data["batterySoc"] = 50.4
+ p.modbus.set_self_consumption.reset_mock()
+ p._act_on_decision(_decision(50.0))
+ p.modbus.set_self_consumption.assert_not_called()
+ self.assertTrue(p.store["import_active"])
+
+ def test_already_above_it_holds_where_it_is(self):
+ p = _mk(soc=57.0)
+ with patch.object(p, "_in_flux_cheap_window", return_value=True):
+ p._act_on_decision(_decision(50.0))
+ self.assertEqual(p.modbus.force_charge.call_args.kwargs["cutoff_soc"], 57.0)
+
+ def test_five_am_ends_it_even_below_the_target(self):
+ p = _mk(soc=40.8)
+ with patch.object(p, "_in_flux_cheap_window", return_value=True):
+ p._act_on_decision(_decision(50.0))
+ p.latest_inverter_data["batterySoc"] = 47.0
+ logged = []
+ with patch.object(p, "_in_flux_cheap_window", return_value=False), \
+ patch.object(plugin, "log", side_effect=lambda m, level="INFO": logged.append(m)):
+ p._act_on_decision(_decision(0.0, purpose="tomorrow",
+ action=ACTION_SELF_CONSUMPTION))
+ # ONE hand-back, said once: not a second "Import complete" from the
+ # ordinary import teardown on the same tick.
+ p.modbus.set_self_consumption.assert_called_once()
+ self.assertFalse(any("Import complete" in m for m in logged), logged)
+ self.assertTrue(any("Cheap-window charge ended (the cheap window has closed)" in m
+ for m in logged), logged)
+ self.assertFalse(p.store["import_active"])
+ self.assertFalse(p.store["cheap_window_import_active"])
+
+ def test_the_level_only_rises(self):
+ p = _mk(soc=40.8)
+ with patch.object(p, "_in_flux_cheap_window", return_value=True):
+ p._act_on_decision(_decision(50.0))
+ p.modbus.set_charge_cutoff.reset_mock()
+ p._act_on_decision(_decision(45.0))
+ p.modbus.set_charge_cutoff.assert_not_called()
+ p._act_on_decision(_decision(62.0))
+ p.modbus.set_charge_cutoff.assert_called_with(62.0)
+ self.assertEqual(p.store["import_target_soc"], 62.0)
+
+
+class TestFluxCanTakeTheWindowBack(unittest.TestCase):
+
+ def test_charging_to_the_minimum_stands_flux_aside(self):
+ p = _mk(soc=40.8)
+ p.store.update(import_active=True, cheap_window_import_active=True,
+ import_target_soc=50.0)
+ with patch.object(p, "_grid_outage_active", return_value=False), \
+ patch.object(p, "_happy_hour_window", return_value=None):
+ self.assertEqual(p._flux_other_owner(), "the manager has a grid import in flight")
+
+ def test_holding_at_the_minimum_does_not(self):
+ p = _mk(soc=50.2)
+ p.store.update(import_active=True, cheap_window_import_active=True,
+ import_target_soc=50.0)
+ with patch.object(p, "_grid_outage_active", return_value=False), \
+ patch.object(p, "_happy_hour_window", return_value=None), \
+ patch.object(p, "_flux_peak_now", return_value=False):
+ self.assertNotEqual(p._flux_other_owner(),
+ "the manager has a grid import in flight")
+
+ def test_an_ordinary_import_still_stands_flux_aside_at_its_target(self):
+ p = _mk(soc=50.2)
+ p.store.update(import_active=True, import_target_soc=50.0)
+ with patch.object(p, "_grid_outage_active", return_value=False), \
+ patch.object(p, "_happy_hour_window", return_value=None):
+ self.assertEqual(p._flux_other_owner(), "the manager has a grid import in flight")
+
+ def test_a_plan_flux_may_not_yet_apply_does_not_own_the_window(self):
+ p = _mk(soc=50.2)
+ p.store["flux_decision"] = MagicMock(mode=fs.MODE_HOLD)
+ with patch.object(p, "_flux_armed", return_value=True), \
+ patch.object(p, "_flux_other_owner", return_value=""), \
+ patch.object(p, "_flux_owns_control", return_value=False), \
+ patch.object(p, "_flux_may_claim", return_value=False), \
+ patch.object(plugin._flux_strategy, "in_window", return_value=True):
+ self.assertFalse(p._flux_owns_cheap_window())
+ with patch.object(p, "_flux_armed", return_value=True), \
+ patch.object(p, "_flux_other_owner", return_value=""), \
+ patch.object(p, "_flux_owns_control", return_value=False), \
+ patch.object(p, "_flux_may_claim", return_value=True), \
+ patch.object(plugin._flux_strategy, "in_window", return_value=True):
+ self.assertTrue(p._flux_owns_cheap_window())
+
+ def test_taking_over_drops_the_bookkeeping_without_writing(self):
+ p = _mk(soc=50.2)
+ p.store.update(import_active=True, cheap_window_import_active=True,
+ import_target_soc=50.0, import_charge_cutoff_pct=50.0)
+ p.modbus.reset_mock()
+ p._forget_cheap_window_import("test")
+ self.assertFalse(p.store["import_active"])
+ self.assertIsNone(p.store["import_charge_cutoff_pct"])
+ self.assertEqual(p.modbus.method_calls, [])
+ self.assertEqual(p._flux_baseline()["baseline_charge_cutoff_pct"], 100.0)
+
+
+class TestTheMinimumComesFromTheSharedRule(unittest.TestCase):
+
+ def _at(self, p, when, commitments=()):
+ class _DT(datetime):
+ @classmethod
+ def now(cls, tz=None):
+ return when
+ with patch.object(plugin, "datetime", _DT), \
+ patch.object(p, "_flux_commitments", return_value=commitments), \
+ patch.object(p, "_flux_site", return_value=_site()):
+ return p._cheap_window_minimum_pct()
+
+ def test_fifty_on_flux_in_the_window(self):
+ self.assertEqual(self._at(_mk(), _local(2026, 10, 1, 3, 0)), 50.0)
+
+ def test_none_with_a_free_hour_booked(self):
+ p = _mk()
+ self.assertEqual(self._at(p, _local(2026, 10, 4, 3, 0),
+ (_free_hour((2026, 10, 4), 13, 15),)), 0.0)
+
+ def test_none_off_flux(self):
+ p = _mk()
+ p.latest_rates_data = {"tariff_info": {"tariff_key": "go"}}
+ self.assertEqual(self._at(p, _local(2026, 10, 1, 3, 0)), 0.0)
+
+
+class TestTheMorningSaysWhatWasAchieved(unittest.TestCase):
+
+ def test_a_missed_minimum_is_a_warning_and_a_record(self):
+ p = _mk(soc=44.9)
+ with tempfile.TemporaryDirectory() as tmp:
+ p.data_dir = tmp
+ logged = []
+ with patch.object(plugin, "log", side_effect=lambda m, level="INFO":
+ logged.append((level, m))), \
+ patch.object(p, "_flux_owns_control", return_value=False):
+ with patch.object(p, "_in_flux_cheap_window", return_value=True), \
+ patch.object(p, "_cheap_window_minimum_pct", return_value=50.0):
+ p.store["flux_status"] = "deferred: no export price"
+ p._note_cheap_window_result()
+ p.latest_inverter_data["batterySoc"] = 42.0
+
+ class _DT(datetime):
+ @classmethod
+ def now(cls, tz=None):
+ return _local(2026, 10, 1, 5, 1)
+ rec = p.store["cheap_window_watch"]
+ rec["day"] = "2026-10-01"
+ with patch.object(plugin, "datetime", _DT), \
+ patch.object(p, "_in_flux_cheap_window", return_value=False):
+ p._note_cheap_window_result()
+ p._note_cheap_window_result() # once a day only
+ with open(os.path.join(tmp, "cheap_window_results.jsonl"),
+ encoding="utf-8") as fh:
+ rows = [json.loads(line) for line in fh]
+ warnings = [m for lvl, m in logged if lvl == "WARNING"]
+ self.assertEqual(len(warnings), 1)
+ self.assertIn("BELOW the 50% minimum", warnings[0])
+ self.assertIn("no export price", warnings[0])
+ self.assertEqual(len(rows), 1)
+ self.assertFalse(rows[0]["met"])
+ self.assertEqual(rows[0]["soc_at_close_pct"], 42.0)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/SigenEnergyManager.indigoPlugin/Contents/Server Plugin/test_tou_peak_topup.py b/SigenEnergyManager.indigoPlugin/Contents/Server Plugin/test_tou_peak_topup.py
index d7367ed..b3da2f2 100644
--- a/SigenEnergyManager.indigoPlugin/Contents/Server Plugin/test_tou_peak_topup.py
+++ b/SigenEnergyManager.indigoPlugin/Contents/Server Plugin/test_tou_peak_topup.py
@@ -117,7 +117,10 @@ def test_go_has_no_peak_so_it_always_waits(self):
def test_the_cheap_window_still_buys_tomorrow(self):
d = self.bm.evaluate(_snap(12.0, 3, 0))
self.assertEqual(d.action, ACTION_START_IMPORT)
- self.assertIn("cheap window (2am to 5am)", d.reason)
+ # 3.15: with Flux not running the window, the manager's cheap-window fallback
+ # buys it, and holds the battery to 5am.
+ self.assertEqual(d.import_purpose, "cheap_window")
+ self.assertIn("for tomorrow", d.reason)
def test_an_unknown_cheap_window_holds_rather_than_buying_now(self):
"""One failed Octopus slot fetch used to mean 10 kW at once, at any hour."""
diff --git a/docs/changelog.md b/docs/changelog.md
index 6d6ef3c..0d3ac1f 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -7,6 +7,14 @@ nav_order: 9
The newest version is at the top. A fuller, technical record of the recent versions is in the [developer changelog](plugin-changelog.md).
+## 5.130.0 — 1 October 2026
+
+- **The 50% overnight minimum now holds even when the Flux strategy cannot plan.** Since midnight on 1 October, Octopus has published October's Flux import prices but no export price at all, in any region, although the account is still on the same Flux export tariff. Without an export price the strategy rightly refuses to trade, so it stepped aside all night. The ordinary plan bought only to its own figure of 44%, then ran the house on the battery until 5am, so the day started at about 42%. Now, whenever the strategy is not running the 2am to 5am window, the ordinary plan charges to 50%, or to what tomorrow needs if that is more, and holds the battery there until 5am with the house on the cheap grid. It still never buys to sell at the peak, because that needs the export price. The free-hour exception is unchanged. Version 5.129.1 already lets the strategy plan through a missing export price, so this is the safety net for every other reason it might not be able to plan.
+- **The strategy takes the window back as soon as it can.** Once the ordinary plan has reached its level and is only holding it, the strategy plans again and, if it can, takes over.
+- **A late start is sized properly.** The minimum's charging power assumed at least 15 minutes were left, so after a restart at 4:55 it asked for about a third of what five minutes needed. It now uses the real time left, and says how far short it will fall when the charge rate cannot make it.
+- **5am reports what was achieved.** One line in the Event Log at 5am gives the battery level against the minimum and who ran the window. It is a warning when the battery is below 50%. Each night is also written to `cheap_window_results.jsonl` for the two-week review.
+- **The "no Flux decision" message says what is missing.** For example, "Octopus has not published the Flux export prices beyond 00:00 on 1 October", instead of "could not be read as a contiguous Flux shape".
+
## 5.129.2 — 1 October 2026
- **A day recorded before Octopus has published its prices is no longer undervalued in the money tables.** With the October Flux export prices still unpublished, 1 October's exports would have been recorded at the last export price the plugin held (the 9.7p day rate) when most of them went out in the 4pm to 7pm peak at 27.7p. Such a day is now valued at the last published day's prices, the same figures the strategy plans on, and marked "provisional". The day Octopus publishes, the plugin weighs it again at the real prices, replaces the figure and, where the day's whole-house cost has already been settled, works the export income and the net out again. The Event Log says so when it happens.
diff --git a/docs/flux.md b/docs/flux.md
index 974ac14..98e77fc 100644
--- a/docs/flux.md
+++ b/docs/flux.md
@@ -14,7 +14,7 @@ I have run it on my own house since 17 September 2026.
## What it does
-**From 2am to 5am** it charges the battery with what the house will need, worked out hour by hour from what your house uses, 80% of the solar forecast (so a forecast that is too sunny does not leave the battery short), the losses of charging, the size of the battery, the time left to charge, and any Axle event already announced. It leaves room for the day's solar, but never leaves the battery below 50% by 5am: a dull day after a sunny forecast would otherwise leave too little to sell at the peak. The one exception is a day with a booked free hour, which wants the room. It adds energy to sell at the peak only when the sale pays after the losses and the battery wear charge, and only as much as the 4pm to 7pm sale can use beyond what the sun will leave in the battery by 4pm on its own. It also checks that a day 35% sunnier than forecast would not push that energy straight back out to the grid at the standard price.
+**From 2am to 5am** it charges the battery with what the house will need, worked out hour by hour from what your house uses, 80% of the solar forecast (so a forecast that is too sunny does not leave the battery short), the losses of charging, the size of the battery, the time left to charge, and any Axle event already announced. It leaves room for the day's solar, but never leaves the battery below 50% by 5am: a dull day after a sunny forecast would otherwise leave too little to sell at the peak. The one exception is a day with a booked free hour, which wants the room. If the strategy cannot plan the night (Octopus has not published a price it needs, say), the ordinary plan does the same job without the trading: it charges to 50%, or to what tomorrow needs if that is more, and keeps the battery there until 5am with the house on the cheap grid. At 5am the Event Log says what level the battery actually reached, and warns when it is below 50%. It adds energy to sell at the peak only when the sale pays after the losses and the battery wear charge, and only as much as the 4pm to 7pm sale can use beyond what the sun will leave in the battery by 4pm on its own. It also checks that a day 35% sunnier than forecast would not push that energy straight back out to the grid at the standard price.
**Through the day** the battery always runs the house. The plugin never stops it to save charge for the peak. The house only draws from the grid during free-electricity hours, when the battery reaches its lowest allowed charge, or when an [Axle event](axle.md) needs more than the battery holds, and then only enough to run the event and carry the house to 2am.
diff --git a/docs/plugin-changelog.md b/docs/plugin-changelog.md
index 4a460be..a4ed93a 100644
--- a/docs/plugin-changelog.md
+++ b/docs/plugin-changelog.md
@@ -21,6 +21,49 @@ New entries go at the top, as they were kept in the file.
---
+## v5.130.0 — 01-10-2026
+
+Root cause, 1-Oct-2026: Octopus's public schedule for E-1R-FLUX-EXPORT-23-02-14-F (and
+regions A, C, P) ends at 2026-09-30T23:00Z; October import prices are published (cheap
+13.9223p, day 23.1946p, peak 32.4761p). The account's export agreement is still that tariff,
+open-ended. `derive_bands` refused (export `_covered_until(now)` is None), Flux deferred from
+00:00:04 onwards (still deferring at 13:40), `_flux_owns_cheap_window` was False, and
+battery_manager's TOU import bought to its household target 44% (cutoff 47.5%), "Import
+complete" at 03:47:51 (44.9%), then self consumption: 5am about 42%. Flux's "No longer
+standing aside" at 03:48:11 changed nothing, because Flux had no decision to stand on.
+- **flux_strategy 2.10.** `cheap_window_minimum_pct(site, commitments, now, tz)` is the one
+ owner of the rule (CHARGE_MIN_PCT in 02:00-05:00, capped by the site ceiling; 0 outside it
+ and when `_free_hour_booked`). `_minimum_charge` takes the minimum from it, sizes power on
+ the real time left (`MIN_HOURS_LEFT` one minute, was 0.25 h: at 04:55 / 49% it asked for
+ 1,445 W against the 4.3 kW five minutes need) and returns `shortfall_kwh`, carried as
+ `FluxDecision.minimum_shortfall_kwh` and in the reason. `bands_problem()` names the side and
+ the time coverage stops; plugin passes it as `FluxInputs.bands_problem` and `_validate`
+ defers with it.
+- **battery_manager 3.15.** Branch 1c CHEAP-WINDOW (after EVENT-COVER, before RESILIENCE): on
+ Flux, in the cheap window, `not flux_owns_cheap_window` -> START_IMPORT with
+ `import_purpose=CHEAP_WINDOW_PURPOSE` and target max(minimum, household target, reserve) on
+ every tick, holding included. Names only the reason(s) that set the level.
+ `_household_import_target` extracted from `_plan_import`.
+- **plugin.** `_drive_cheap_window_import`: Charge PV First, hardware cutoff at the level (or at
+ the present SOC when higher, i.e. a hold), discharge limit 0 (verify pass expects 0 while it
+ runs), level only rises. Any other decision ends it via `_end_cheap_window_import` (once,
+ with `prev_import` cleared so the ordinary teardown does not hand back twice); the
+ end-of-method target stop skips it. `_flux_other_owner` no longer counts it once holding
+ (SOC >= level - 0.5), so Flux plans; `_flux_owns_cheap_window` now also needs
+ `_flux_owns_control() or _flux_may_claim()`, so the manager keeps holding through the
+ stand-down; when Flux claims, `_forget_cheap_window_import` drops the bookkeeping and the
+ cutoff record with no writes (Flux's 05:00 release then restores 100%).
+ `_note_cheap_window_result`: one INFO/WARNING after 05:00 (achieved SOC vs minimum, who ran
+ it, Flux's deferral reason) and one line in `/cheap_window_results.jsonl`.
+- Tests (+36, `test_cheap_window_minimum.py`): the published 30-Sep/1-Oct spans refused at
+ midnight and named; new import prices read once export exists; the rule's one owner (window,
+ free hour, other day, ceiling); 04:55 sizing; charge-rate and zero-headroom shortfalls;
+ manager fallback at 03:38 / holding / tomorrow wins / free hour / Flux owns / 05:00 / Go;
+ plugin drive, no stop at target, hold above, 05:00 end below target with one hand-back,
+ level only rises; owner while charging not while holding; no ownership before Flux may
+ claim; takeover without writes; the shared rule through the plugin; the 05:00 record. Eleven
+ mutations, all caught. Simulator unchanged (it runs the planner only). Suite 2,367.
+
## v5.129.2 — 01-10-2026
- **`_banded_rate_and_basis`**: when the published weighting refuses with "gap" or "no bands",