Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion SigenEnergyManager.indigoPlugin/Contents/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
<key>NSHumanReadableCopyright</key>
<string>Copyright 2026 CliveS. All rights reserved.</string>
<key>PluginVersion</key>
<string>5.129.2</string>
<string>5.130.0</string>
<key>ServerApiVersion</key>
<string>3.8</string>
</dict>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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]:
Expand Down
Loading
Loading