SigenEnergyManager — developer changelog
The technical history, moved out of plugin.py on 25-Aug-2026. It had reached 2,002 lines at the top of the file — 17.4% of an 11,534-line module — which cost every reader two thousand lines of scrolling to reach the imports, and bought nothing at runtime.
Nothing was lost in the move: the parsed AST of plugin.py is byte-identical either side of it, because only comments were taken out.
This is the developer record — what changed inside, why, and what it broke. The user-facing version history, in plain words, is Version history, with the newest few also under What’s new in the README.
New entries go at the top, as they were kept in the file.
v5.132.1 — 05-10-2026
Octopus’s REST consumption/ endpoint treats period_to as INCLUSIVE: it also returns the half hour that STARTS at period_to (measured 05-10-2026 — a 12:00-13:00Z request returned 12:00, 12:30 and 13:00; a local day returns 49 slots). get_import_kwh_between and _sum_consumption_for_date summed whatever came back.
- octopus_api: new
_readings_inside(intervals, start, end)keeps readings whoseinterval_startis in[start, end); a reading with no parseable, tz-aware start is dropped. Both summing paths filter through it. The free hours of 27-Sep read 19.047 + 13.128 (capped 16 + 13.128 = 29.1 kWh, £7.09) against a true 12.251 + 12.992 = 25.243 kWh (£6.15); Octopus’s 317p + 299p was right, and the “93p short” push was false. - free_hour_credits 1.2:
LEDGER_VERSION = 2,upgrade()dropsmeter_kwhon open claims so they are re-read;_load_free_hour_ledgerruns it and saves once. - scripts/mend_meter_extra_slot_2026_10.py: one-off repair of settled
daily_history.jsonrows (import_kwh_octo, gas_m3/gas_kwh and the cost fields); a row changes only when its stored figure equals its own day plus the extra slot. 65 of 123 settled days changed: 0.13 kWh import, 1.76 kWh gas, bills 1p in all. - Tests: 2,503 -> 2,512 (four in
test_octopus_api, five intest_free_hour_credits), the four new octopus_api tests watched failing with the filter disabled.
v5.132.0 — 02-10-2026
Independent review of 5.131.1, batches 2 and 3 (control, money, records). Every fix has a test watched failing first: test_review_2026_10_02.py (plugin) and _core / _exec / _money / _modbus (one per module group). 2,415 -> 2,503 tests; tools/scenario_sim.py --season all 29/29.
- plugin:
_refresh_octopus_ratesruns on a worker (_start_octopus_refresh, one at a time) — inline, Octopus timeouts held the tick 18:55:41-18:58:51 on 01-Oct; the rebuilt rates dict carries the last NUMERICexport_rate_p(readers float() it). A failed account read KEEPSflux_account_evidence(CliveS) — its ownfetched_at6 h limit governs — and the evidence is saved in accumulators and restored whatever the day._poll_vpp: a failed poll in ANNOUNCED/PRE_CHARGING re-applies the STORED event instead of None (was “cancelled” + 600 s poll); the cancel branch clearsvpp_event._end_cheap_window_import/_end_happy_hour_importclear the import ceiling record unconditionally. Day-rate guard reads 40031 viastore["remote_ems_mode_seen"](set by_verify_ems_registers; emsWorkMode is 30003 and never held “Charge Grid First”); the deadinverter_stuckbranch removed (verify corrects a stuck mode)._init_modulesloads the bias correction (a Configure save had reset the bands to 1.0)._load_accumulatorsrestores a file from YESTERDAY as yesterday so the first tick records the ended day._write_daily_historymoves an unreadable file aside (.unreadable-<ts>) instead of overwriting it;_record_vpp_ledger_eventrefuses a ledger withload_error. Half-hourly insert is an UPSERT that adds to a repeated slot (clocks-back night).export_count_todaypersisted._export_total_for_ended_dayre-bases a midnight-spanning Axle window from the rollover projection. Slow inverter states written only when present. Solar forecast device no longer seeded 0.0. - flux_strategy 2.12:
event_coveruses the event day’s own peak and, inside it, buys only for the event; peak tail returns SUPPLY_HOUSE (sell the roof) instead of handing back, andwalkbanks only the share of a step afterno_charge_until(the straddling step lifted the floor ~0.9 kWh per half-hour); the cheap-window charge runs to the whole-percent cutoff (MIN_TRADE_KWH was also the stop test). - battery_manager 3.16: on Flux the flood drain floor is max(40, dawn target, CHARGE_MIN_PCT); reason prints the real export rate.
- flux_execution: a verified restore releases even if the journal cannot be written.
- storm_watch 1.7: non-Atom XML is unknown (None), Minor severity is green.
- openmeteo_forecast 1.10: a cached fallback must be today’s and inside STALE_FALLBACK_TTL (else empty “No data”); morning baselines kept per date (
morning_baseline.jsonbaselines). - octopus_api 1.5:
_get_tou_ratesserves last good bands with a negative-cache back-off; the active TOU tariff uses the billed product; import/export day sums passcomplete; Saving Session reward parse tolerant. economics 1.1: settle needs the local day’s slots less 2 (44/46, 46/48, 48/50). free_hour_credits 1.1: measuring claims skipped and aged out after 30 days. vpp_ledger 1.3: an email stand-in never evicts Axle’s real row. - sigenergy_modbus 1.19 (SigenVPP re-synced): exact-zero lifetimes are absent;
_slow_primedreset on connect;_energyFreshKeys; U32 sentinel rejected;_write_uint32_registersvalidates. daily_energy 1.1: a reset needs two consecutive low reads; yesterday’s anchor never popped; per-key provisional anchors; implausible recovery rejected. - axle_email: sender domain parsed; web_dashboard: token compared as bytes. NOT changed: the token still does not apply to loopback, because the Dashboards proxy reaches this server there.
v5.131.2 — 02-10-2026
Independent review of 5.131.1 (02-Oct-2026), batch 1: the three P1-level faults, each with a test watched failing first (test_review_2026_10_02.py, two in test_octopus_api.py).
shutdown(): Indigo’s_pre_shutdown()callsstop_concurrent_thread()BEFOREshutdown(), soself.sleep()raises StopThread at once. The driver sleeps throughsleep_func=self.sleep, soset_self_consumption()wrote 40029 and died on its 0.15 s verify pause (swallowed);_flux_releasethe same. Live match: the 01-Oct 19:06 shutdown logged “Enabling Remote EMS control” and no mode write.shutdown()now swapsself.modbus._sleep = time.sleepfirst.- flux_strategy 2.11
plan()wraps_plan()and clampsdischarge_cutoff_pcttocharge_cutoff_pctwhen it is above it. A MODE_CHARGE target belowhouse_floor(the roof covers an Axle event the floor counts in full) was refused byFluxExecutor._valid_targetas “Invalid target energy band” every tick, while_flux_owns_cheap_windowkept the manager standing aside. - plugin
_flux_note_refused: a ‘released’ step withlast_errorwhile the decision owns setsstore["flux_refused"](one WARNING per episode) and_flux_owns_cheap_windowreturns False until Flux applies again. - octopus_api
book_happy_hour_event: “already” counts as booked only through_ALREADY_BOOKED_REand never with a limit word (_BOOKING_LIMIT_RE); “already booked the maximum number of slots” is a permanent refusal.
v5.131.1 — 01-10-2026
CliveS, 1-Oct-2026, pasting two plan notes six minutes apart (floor 49% then 48%): “I dont need to see these logs every minute or two”. 16:00-18:00 had 16 plan notes and 17 mode-2 writes.
- flux_execution
step(): the in-place path covers any change within the SAME verified mode (was limits only, cutoffs equal). Only values that moved are written, each read back; then the full_verify; any failure falls back to_apply(neutralise first). A mode change still neutralises first. - flux_strategy 2.10.2
note_control_key: the discharge floor is out of the key (the charge target stays: it is the overnight plan and moves a few times a night). - sigenergy_modbus 1.18
set_backup_soc(quiet=False): DEBUG when quiet;_FluxRawDriverpasses quiet=True (the executor reads every write back). SigenVPP’s copy synced. - Tests: executor floor-in-place, mode change still neutralises, only moved values written, failed in-place write falls back; peak note one line however the floor moves; quiet floor write; set_backup_soc quiet/default. Mutations: floor forcing full apply, unchanged values written, read-back removed, note keyed on the floor - all caught.
v5.131.0 — 01-10-2026
_start_vpp_prechargeis read-only.vpp_floor_deferredis set True always (was_flux_sale_running()), so_set_vpp_discharge_cutoffruns only in_vpp_transitionat T-2min (the 5.127.1 path, live since 28-Sep);_vpp_precharge_shares_flux()is now True for all of pre-charge, so it is not a Flux owner, not a dispatch for the floors, and not_driven_export_owns_registers. The “[Flux] An Axle window starts…” line is logged only when a sale is running.- The VPP_PRE_CHARGING “stop charging, hold in Self Consumption” step is deleted (and its unused locals). It wrote 0x02 over a 0x05 Saving Session export on 21-Sep-2026; pre-charge has not charged since the plugin stopped importing for events.
- Kept: the T-30 sufficiency log and
_alert_vpp_shortfallPushover; the state namepre_charging(persisted and shown on the Axle device). The energy itself comes fromflux_strategy.event_cover(5.124.0) and the Flux commitments. - Tests: nine that pinned the T-30 writes rewritten to pin their absence and the T-2 write (floors written once at T-2 with no sale; release before the floor; shortfall check read-only; no Self Consumption write with nothing exporting). Two mutations (T-30 floor back, Self Consumption step back) caught by 7 and 5 tests. CliveS, 1-Oct-2026: “do it”.
v5.130.1 — 01-10-2026
A review of 5.130.0 found three gaps; all three were real.
- Fallback headroom.
_cheap_window_charge_w(requested): min(requested, inverter rating, verifiedimport_limit_w- max(0, house - PV)), asflux_strategy.import_headroom_w. Used at the start of_drive_cheap_window_importand re-checked every tick (set_charge_limitwhen it moves by 500 W or more);_verify_ems_registersexpectsimport_power_wwhilecheap_window_import_active. Mitigated before by the inverter’s whole-site import cap (40040-41, asserted while Flux is armed and the limit verified), now right in the request. - flux_strategy 2.10.1
_minimum_charge. The reachability check ran only when the plan’s target was under the minimum, so a higher target returned shortfall 0 (40%, 04:55, 1 kW: ~3.42 kWh undeliverable, reported 0). It now runs whenever SOC is under the minimum; with a higher target the power is raised to what the minimum needs (within headroom) and the shortfall returned. _note_cheap_window_result. The one-point allowance (soc >= minimum - 1.0counted as met) is gone: met means short < 0.05; up to one point under is a WARNING saying the gap; JSONL gainsshort_pct.- Tests (+13, test_cheap_window_minimum.py): the reviewed case, plan power raised, no false shortfall; EV-on-timer headroom, quiet house, PV offset, unverified limit, start inside the headroom, a load trims the running charge, verify keeps the sized limit; 49.6 not met with its gap, 50.0 met, 42.0 says how far. Seven mutations caught.
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_chargetakes the minimum from it, sizes power on the real time left (MIN_HOURS_LEFTone minute, was 0.25 h: at 04:55 / 49% it asked for 1,445 W against the 4.3 kW five minutes need) and returnsshortfall_kwh, carried asFluxDecision.minimum_shortfall_kwhand in the reason.bands_problem()names the side and the time coverage stops; plugin passes it asFluxInputs.bands_problemand_validatedefers 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 withimport_purpose=CHEAP_WINDOW_PURPOSEand target max(minimum, household target, reserve) on every tick, holding included. Names only the reason(s) that set the level._household_import_targetextracted 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, withprev_importcleared so the ordinary teardown does not hand back twice); the end-of-method target stop skips it._flux_other_ownerno longer counts it once holding (SOC >= level - 0.5), so Flux plans;_flux_owns_cheap_windownow also needs_flux_owns_control() or _flux_may_claim(), so the manager keeps holding through the stand-down; when Flux claims,_forget_cheap_window_importdrops 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<data_dir>/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”, weigh the day against_flux_planning_spans(_banded_rate_for_day(..., spans=)) and return basis “provisional”; “estimated” (the fallback rate) only when even that cannot price it._reprice_provisional_days(max_days=62), run after every successful_refresh_flux_rates: for each history row whoseexport_rate_basis/import_rate_basisis “provisional”, re-weigh against PUBLISHED spans only; once covered, write the rate and basis “weighted”, keep the old figure as<rate field>_provisional, and_resettle_day_money(export_revenue_gbp, wh_net_gbp, covered; the import unit cost and bill only where the row carries its ownrate_today_p) if the day was alreadycost_settled. One INFO line per re-priced side. A row still not covered is retried on the next refresh.- Why: 1-Oct’s record would otherwise have used the sticky
export_rate_p(9.71p, the last published day band) with basis “estimated” for a day whose export is mostly 27.69p peak, understating it by about 17p a kWh for good — nothing ever re-priced an estimated day. - Tests (+9, test_provisional_day_pricing.py): published-only still refuses; the record goes provisional at the carried weighting; unpriceable stays estimated; nothing changes while unpublished; publication replaces rate and settled money and logs; a row without its own import rate keeps its bill; a new import rate rebuilds the bill; weighted rows untouched; a successful refresh runs the pass and a failed one does not. Six mutations caught.
v5.129.1 — 01-10-2026
- flux_strategy 2.9.1
carry_forward_spans(spans, tz, until)-> (spans, stopped_at): repeats the 24 local hours before the schedule’s end, one LOCAL day per step (wall clock kept across a clock change), untiluntil; nothing when it already reaches, is empty, or holds less than a day. derive_bands’ shape and single-price checks still apply to the result. - plugin
_flux_planning_spans(key): the side whose schedule ends before the other side’s is carried to the other’s end; one WARNING per (side, stop time) naming the prices used. Only_flux_inputs(the planner) reads it. Every accounting and display reader keeps_flux_rate_spans(published only), so_banded_rate_for_daystill refuses a day it cannot price from published bands (test_a_gap_in_the_published_bands_refuses_the_whole_day). - Why: 1-Oct-2026, region F (and A, C, P) FLUX-EXPORT-23-02-14 published to 2026-09-30T23:00Z only; import published to 3-Oct (13.9223 / 23.1946 / 32.4761p). Flux deferred from 00:00:04 (“contiguous Flux shape”), with a peak sale, a joined Saving Session (18:00-19:00) and an Axle event (18:00-19:00) due. CliveS: “if there are none, like no export prices then we use the existing figures until it is updated”.
- Tests (+12, test_flux_carry_forward.py) on the real published spans: refused without it, bands with it (Sept export 4.21/9.71/27.69p + Oct import), tonight’s 2am bands, peak still 16:00-19:00 including across 25-Oct, nothing carried when reached / from under a day / once published; plugin carries only the short side, logs once, nothing when the other side is empty, money readers unchanged. Two mutations caught.
v5.129.0 — 30-09-2026
CliveS, 30-Sep-2026: “i want the battery to be topped up to at least 50% every night on cheap rate … yes set it to 50%, skip free-hour days”.
- flux_strategy 2.9
CHARGE_MIN_PCT = 50.0and_minimum_charge(), applied in the cheap-window branch after_charge_plan: when SOC and the plan’s target are both under the minimum, the target becomesmin(50, site.max_charge_soc_pct), the power ismax(plan power, need / eff / hours_left)capped by charge rate and spare import, and a hold becomes a charge. Skipped whenfree_kwh >= MIN_TRADE_KWH(a free hour booked for the day); never lowers a target; nothing outside 02:00-05:00 reads it. The reason names the kWh added “to bring the battery up to the 50% minimum”. - Why: 30-Sep forecast 31.3, actual ~19.5, 22% at 2am, 42% at 5am, 56% at 4pm, ~6 kWh sellable. Replay of 161 days (chained SOC, 80% plan, sim 1.5): floor 0 / 40 / 50 / 60 = £669.20 / 667.68 / 664.89 / 656.48 net; below 50% at 5am on 34 days; a 50% floor costs £4.31 (~£10 a year). 30-Sep replayed: +2.9 kWh bought, +2.7 kWh sold at the peak, +33p. The peak total over the 161 days does not change (the 80% plan already sells the full 12 kWh on nearly every recorded day), so the floor is insurance against misses like 30-Sep.
- Simulator: only the four scenarios that started under 50% change (1, 3, 7, 8): 5am 35% -> 50%; scenario 7 (dull day after a sunny forecast, Axle) improves 43p, the very bright days cost 42-55p. Winter and free-hour Sundays unchanged. 29 scenarios, every rule kept.
- Tests (+9): the constant; a bright forecast no longer leaves it under 50%; power sized to reach it by five; a battery already above it is left to the plan; a plan already above it is unchanged; a booked free hour skips it; it never exceeds the site ceiling; only the cheap window reads it; once there it stops asking.
test_the_same_energy_arriving_before_the_load_ is_not_boughtswitches the minimum off (it tests household sizing). Three mutations caught.
v5.128.1 — 30-09-2026
- flux_strategy 2.8.
_sale_priority_holdbacksubtracts_commitment_kwh(commitments, a, b, "export")over each priority window (an Axle event over the same hour already has its energy in the sale’s floor, and the two export the same kWh once), soholdiscap x hours - roof - promised, floored at zero. Found by reading the code and the simulator (scenario 10), NOT on 30-Sep: that day had a Saving Session at 18:00 and no Axle event (the log’s “Axle event: export 18:00 - 19:00” was the NEXT day’s event, printed as a bare time; the Axle Monitor’seventStartTimecarries the date). 30-Sep itself: forecast 31.3 kWh (corrected), actual ~19-20 (0.6x, the dullest miss on record with 3-May); the 80% plan reached only 43% at 05:00, 56% at 16:00, ~6.2 kWh sellable, 4.0 kept for the 18:00 session, ~2 kWh sold, then the flip below. holdingis nowhold_raw >= MIN_TRADE_KWH(a window is being waited for, whatever is spare now) and the roof-surplus hand-back to the manager is skipped while holding, so the branch returns SUPPLY_HOUSE (charge limit 0: the roof’s surplus goes out at the peak rate, the battery keeps its charge). Before,sell_nowhovering at 0.5 kWh alternated EXPORT and a MODE_SOLAR hand-back that banked the surplus and liftedsell_nowagain: 12 cycles in 33 minutes (16:43-17:16), each a claim, a mode change and a release.- Simulator: scenario 10 (Axle + session over 18:00-19:00) now sells 6.0 kWh at the peak, the same as Axle alone (was 5.8). Every other scenario is unchanged. 29 scenarios, every rule kept.
- Tests (+7): an Axle event over the session needs no extra hold; a session alone still holds 4 kWh; with Axle the sale equals the no-session sale; a half-covered window holds only the rest; holding never hands back to the manager (30 SOC values); the mode does not flip as the spare crosses the threshold; with no session the hand-back is unchanged. Removing either fix fails tests (3 and 2).
v5.128.0 — 29-09-2026
CliveS, 29-Sep-2026: “yes set it to 80% all year” (and: this plugin is for this house only).
- flux_strategy 2.7
CHARGE_PV_FACTOR = 0.8:plan()’s cheap-window branch calls_charge_plan(replace(inputs, pv=inputs.pv.scaled(CHARGE_PV_FACTOR))), so the household charge, the resale “by_peak” room and the brighter-day check (now 1.35 x the dimmed forecast) all plan on 80%. Nothing outside 02:00-05:00 reads it: the peak sale, event_cover and the manager see the forecast as it stands. - Why: 29-Sep forecast ~15 kWh (corrected), actual ~11; 4pm at 65%, the sale ran out at 17:46 with ~7 kWh sold. 161-day replay (Apr 19 - Sep 28, chained SOC, day length by date): old £666.25 / 1887 kWh sold / 15 short days / 3.7 kWh day import; new £668.31 / 1919 / 5 / 0. Checking the brighter-day guard against the real forecast x1.35 instead earned 38p more but left 10 short days, so the guard follows the dimmed forecast. A learned per-forecast-band factor (37th-40th percentile of past ratios) earned no more and was not built.
- Tests (+5): the factor is 0.8; the charge equals the one a 0.8x forecast would make; the 29-Sep night charges higher; nothing outside the cheap window reads it; a bright day still buys little. Two v2.2 tests updated (the 21-Sep night now buys ~6.3 kWh, bound < 8; the brighter-day check is made on the dimmed forecast). Removing the scaling fails two tests.
v5.127.1 — 28-09-2026
- An Axle pre-charge no longer pre-empts a running Flux peak sale. Live 28-Sep 17:31: the pre-charge for an 18:00-19:00 event called
_set_vpp_discharge_cutoff, whose first act is_flux_preempt, and_flux_other_ownerthen countedpre_chargingas an owner, so the sale stopped with SOC 88% for a 4 kWh event. Pre-charge never imports, and the sale’s floor already carried the event (its commitment stands in PRE_CHARGING), so nothing was gained. _start_vpp_prechargesetsvpp_floor_deferred = _flux_sale_running()(executor owns control, peak open,flux_decision.mode == export). When set it skips_set_vpp_discharge_cutoffand logs one[Flux]line._vpp_precharge_shares_flux()(PRE_CHARGING + the flag) then keeps the pre-charge out of_flux_other_owner,_flux_dispatch_event_ids(so the event stays reserved) and_driven_export_owns_registers, and pre-charge Step 1 writes nothing._vpp_transition(ACTIVE)from a deferred pre-charge writes the VPP floors after the Flux pre-emption, so the release baseline cannot land over them. The flag clears on leaving PRE_CHARGING and is persisted with the VPP state.- Tests:
TestPreChargeLeavesThePeakSaleRunning(9) and a Step 1 case; six fail on 5.127.0. Suite 2,310; sim 18 clean (the sim models the event window, not the pre-charge half hour).
v5.127.0 — 28-09-2026
- flux_strategy 2.6 —
FluxInputs.sale_priority, (start, end) windows the peak sale serves first; the plugin passes joined TURN_DOWN sessions (_flux_sale_priority). In the peak branch_sale_priority_holdbacksums, for each window starting after now inside this peak, the export cap times its hours less the roof’s export then;hold = min(that, surplus)and onlysurplus - holdis sold now (sell floor raised byhold / eff, decision lease ends at the window start). UnderMIN_TRADE_KWHto sell -> SUPPLY_HOUSE (“kept for the Saving Session at 17:30”). A running window is served at the limit; windows outside the peak are ignored. It is timing only: a session still has no energy of its own (5.126.0). - Simulator 1.4 mirrors it. Winter W6 (spare 7.4 kWh, session 17:30-18:30): the session gets 4.0 kWh (was 1.4), the sale total unchanged. 29 scenarios, every rule kept.
- Tests (+8): unchanged without a session; before it only the rest is sold, floor raised, lease ends at the start; a small spare is all kept and the battery runs the house; in the session it sells at the limit; plenty of spare still sells from 4pm; a session outside the peak and a malformed window change nothing; the plugin passes only joined turn-downs. The two holdback tests fail with the holdback removed.
v5.126.0 — 28-09-2026
CliveS, 28-Sep-2026: “The winter saving sessions normally run inside the 4-7pm export window so do not need to have any kwh associated to them, if a saving session is outside that 4-7pm window then we should still join them but do not export as it is not cost effective.”
_saving_session_window()returns None when_flux_armed(), so the manager never decides ACTION_SAVING_SESSION,saving_session_export_activeis never set, and_flux_other_ownernever stands Flux down for a session: inside the peak Flux’s own sale runs through it._flux_commitments()skips TURN_DOWN sessions on Flux: no energy is reserved (planner floors, export floor, the during-dispatch uplift). Happy Hour import commitments are kept._session_join_verdict()returns True on Flux outside the peak too (joining costs nothing with no export); the token verdict still decides off Flux. The “opted in but not driven” warning is off on Flux, and the join message says which case applies.- Tests: ten tests that pinned session reservations / declines on Flux rewritten to the new rule, with non-Flux twins kept; new: a live session never drives the battery on Flux but still does without it. Simulator 1.3: sessions reserve nothing and are never driven; any export inside the session window counts. 29 scenarios (autumn + winter), every rule kept. Winter W6: the peak sale puts 1.4 kWh inside a 5:30-6:30pm session; the battery runs the house for the rest.
v5.125.4 — 28-09-2026
CliveS, 28-Sep-2026: “axle gives £1 kwh and the most we are charged is around 30p then buying in to support axle is still ok if we cannot get to 2am but this shouldn’t happen”.
event_coverreads today’s PV through_cover_track_factor(): the manager’s tracking (same accumulators, weighting, “1.0 until judged”, partial-day and date rules) withmin_factor=0.0instead of the 0.6 clamp (pv_tracking_factor(..., min_factor=)new optional arg)._flux_pv_forecast(cover=True)builds it;_event_coverswaps it into the inputs with_replace. The planner and manager keep the clamped factor.COVER_MARGIN_KWH = 1.0: the cover’s walk to 02:00 must stay that far above the reserve.- Simulator 1.1 mirrors both. Scenario 7 (forecast 34, actual 6, Axle 18:00): cover 13.9 kWh bought 13:45-15:00 at the day rate in one run (was 12.0 in four starts, one at the peak rate), nothing at the reserve before 02:00. 7c (forecast 20, actual 12) now buys 1.4 kWh of cover. 17 scenarios, all rules kept. The 7b “without tracking” comparison is removed.
- Tests (+4): cover reads the unfloored forecast; the cover factor has no 0.6 floor while the manager’s keeps it; 1.0 until judged or on a stale date; the walk keeps the margin.
v5.125.3 — 28-09-2026
_flux_pv_forecastmultiplies today’s factor bystore["pv_track_factor"](the manager’s damped, 0.6-1.3 intraday tracking; 1.0 until enough is measured, reset at midnight), soflux_strategy.planandevent_coverread the same corrected forecast the manager plans with. A missing, zero, negative or non-finite factor is ignored. Found by the new simulator: forecast 34 kWh, actual 6, Axle 18:00 -> cover under-bought and 1.3 kWh was imported at the reserve after the event; with tracking 0.6 kWh (the 0.6 clamp is the remainder).tools/scenario_sim.py(new, outside the bundle): drivesflux_strategy.plan,event_coverandBatteryManager.evaluatethrough 02:00-02:00 in 15-minute steps with the plugin’s ownership order (VPP pre-charge/active, Saving Session, Happy Hour, cover, manager import outrank Flux), PV-first grid charging, a 4 kW export cap and one-way efficiency sqrt(0.94); classifies every import (cheap window / free hour / Axle cover / at reserve / UNEXPECTED) and checks Axle delivery and the battery reaching 02:00. 18 scenarios; exit 10 when a rule breaks.--csv-dirwrites per-step traces. It models decisions, not registers.- Tests (+3): today’s forecast scales with the factor, tomorrow does not, bad factors ignored.
v5.125.2 — 28-09-2026
_flux_reserve_floor_pctadds committed event energy only while a dispatch is being served (VPP pre-charge/active, a Saving Session export, or an explicitdispatch_event). Until now it rose from announcement: 28-Sep-2026 05:00 backup reserve 31.8% for an 18:00 Axle event, battery idle at 31.8% from ~07:00 and the house importing, on a 34.7 kWh forecast (event_cover correctly found nothing to buy). Announced events are covered by the 02:00-05:00 charge,event_cover, and the peak export’s own sell floor; the during-dispatch uplift still stops one event selling a later one’s energy.- Tests: the five floor tests in test_flux_supervisor.py reworked for the new rule (announced -> 20%, union budget measured with a dispatch running).
- test_free_hour_plugin
_eventsanchored to a local 1pm-3pm: placed by “N hours before now”, the two hours straddled local midnight when the suite ran 07:00-09:00 (30 h back) or 03:00-05:00 (3 h back), so one claim became two and two TestDailyCheck tests failed. Found at 07:40 on 28-Sep-2026, failing on 5.125.1 too.
v5.125.1 — 27-09-2026
- flux_execution
_neutraliseis mode-2-first, with no zeroed limits. It used to write charge 0, discharge 0, then mode 2 before every full apply and every_restore, so each claim, mode or band change, release and post-restart reconcile stopped the battery for 15-30 s (live: 15:45:41-15:45:56 on 27-Sep-2026, discharge 0 W while the house imported). Mode 2 alone gives the same protection: it cannot grid-charge or battery-export, so every later cutoff/limit write is benign, and a refused or unread mode write still stops the sequence before any cutoff moves (test_failed_stop_never_lifts_cutoffs_while_exportingunchanged and green). - Tests (+4): a restart reconcile writes no zero discharge; claim -> export -> supply -> release writes none; mode 2 is the first write from mode 3 or 5; a refused mode write moves nothing else. Six executor tests fail on 5.125.0.
v5.125.0 — 27-09-2026
Measured on the first Happy Hour (27-Sep-2026 13:00-15:00): in Remote EMS 0x03 (Command Charging, grid first) at a 10 kW charge limit the inverter drew ~12.8 kW (house ~2.8 + battery ~10) and held PV at 0 W for the whole window, the string voltages rising from ~195/295 V to ~228/340 V (MPPT backed off to near open circuit), while PV read 1717 W at 13:00 and 1784 W at 15:00. Grid First lets PV fill only what the site import cap (16 kW) cannot.
- sigenergy_modbus 1.17:
force_charge(..., pv_first=False); True writes 0x04 (Command Charging, PV first). Default unchanged, so SigenVPP (shared copy) is unaffected. - plugin:
EMS_CHARGE_GRID_FIRST/EMS_CHARGE_PV_FIRST; every manager import (START_IMPORT, HAPPY_HOUR_IMPORT, scheduled import, Force Grid Import action) goes through_force_charge, which asks for PV first and recordsimport_ems_mode+import_power_w. AST test: no other caller ofmodbus.force_charge._verify_ems_registersexpects_import_ems_mode()during an import (was hard 0x03). Stuck-mode recovery also recognises “Charge PV First”. Flux’s own 02:00-05:00 charge stays 0x03 (dark window). _check_pv_first_charge(each manager pass, before verify): PV First has not been seen live topping up from the grid, so if battery < 0.8 x limit AND battery - PV < 500 W forPV_FIRST_SHORTFALL_PASSES(2) passes, below min(cutoff-3, 92)% SOC, set 0x03 for the rest of that import and WARN. One-way per import; a missing reading judges nothing.- Happy Hour restart:
_load_accumulatorsrestoredhappy_hour_import_activebut notimport_active, so verify expected 0x02 and switched the free charge off, and the HH branch (own flag set) never re-drove it. Now restoresimport_active,import_target_soc(persisted ashappy_hour_target_soc, default 100) andimport_power_w. _check_happy_hour_overrun: within 90 s of the latest used span’s end it ends the import at INFO (“the free hour ended”); WARNING only beyond that. The 10 s tick is normally first to see the window close (27-Sep 15:00:01 logged a WARNING for a 1.6 s overrun).
v5.124.0 — 27-09-2026
CliveS, 27-Sep-2026: “at no point during the day should the battery be stopped … 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 Axle cover to the event and 2am.
- flux_strategy 2.5: the 14:00-16:00 run-up hold is removed (
_hold_is_profitable,_peak_shortfall_kwh,HOLD_BEFORE_PEAK_MINUTESgone). Newevent_cover(inputs, source="axle", avoid_peak=True)->EventCoveror None: first upcoming Axle export commitment before the next 02:00; deadline = its start, or 16:00 when it falls in the peak; needed =required_start_kwh(deadline, next 02:00, reserve); arriving =simulate(now -> deadline)with the house served; buy = (needed - arriving) / eff when overMIN_TRADE_KWH;start_by= deadline - buy/charge rate -COVER_LEAD_MINUTES(60, clear of the VPP pre-charge);activeonce past it. Never raises for missing inputs. - flux_execution:
_valid_targetrefuses mode 2 with discharge 0 outside 02:00-05:00. - battery_manager 3.14:
_plan_peak_topupandPEAK_TOPUP_*removed; unknown cheap window or one opening after sunrise ->_hold_import(was START_IMPORT at 10 kW); new EVENT-COVER branch (1b, after BALANCE) -> START_IMPORT toevent_cover_target_pctwhileevent_cover_activeand SOC is below it;TariffData.tariff_keydefaults toTARIFF_UNKNOWN. - plugin.py:
_event_cover(soc)computed in_build_manager_snapshot(not inside 02:00-05:00 on Flux, where the Flux charge already counts announced events); logs[Cover]on change;store["event_cover_active"]makes_flux_other_ownerstand Flux aside._scheduled_import_missed_its_windowdrops a TOU cheap-window charge that fires after its window (a VPP hold can push it past 05:00). Tariff fallbacks areTARIFF_UNKNOWN, never Tracker, and_rates_for_tariffgives (None, None) for anything but a real tariff. - Tests (+19): planner never holds 05:00-02:00 on the real rates; event cover (no event, full battery, peak event before 16:00, activation, the walk meets event + reserve to 02:00, non-peak event, Saving Session ignored, running event ignored, missing inputs, 2am charge grows for an announced event); manager buys nothing in the day, holds on an unknown window, acts on cover; executor refuses a daytime hold; plugin stands Flux aside, drops a late schedule, sets and logs the cover flag once.
test_tou_peak_topup.pyrewritten for 3.14. Seven mutations of the new guards, each caught.
v5.123.0 — 27-09-2026
- flux_strategy 2.4 — the run-up hold is sized to the peak sale. New
_peak_shortfall_kwh(inputs, peak_start, until)-> (shortfall, needed, arriving):arrivingwalks now -> 16:00 from the live SOC with the house served;neededisrequired_start_kwh(peak_start, next_cheap, reserve, no_charge_until=peak_end)(the export floor at 16:00) plus the battery side of the sale,(min(discharge, export cap) x 3 h - export commitments in the peak - roof export in the peak) / eff, and zero sale whenday_rate_import_today(v2.3 sells nothing then). The run-up returns MODE_SOLAR (hand back, “no need to hold”) when the shortfall is underMIN_TRADE_KWH, else the existing hold withplanned_kwh= shortfall. Stateless and re-planned every tick: while held, the house’s draw still to come before 16:00 shrinks and the shortfall closes, so the hold lets go once it has kept what the sale needs. - Why:
_hold_is_profitablecompared 27.7p against 24.4p + 2p wear and nothing else. On 27-Sep-2026 it held a 93.5% battery 15:00-16:00 (discharge limit 0) while the house imported ~2.2 kW, although the planner’s own_resale_room_kwhalready knew the peak sells ~12 kWh. Needed 78%, arriving 93%. - Tests (test_flux_strategy.py
TestTheRunUpHoldsOnlyWhatThePeakCanSell, 7): the 27-Sep case on the real region F rates with the Axle 18-19 event, a short battery held, the shortfall closing while held, a boundary charge letting go before 16:00, an Axle hour in the peak not counted twice, no hold for a sale that will not happen.test_a_hold_fires_where_it_genuinely_paysnow uses a 50% battery. Six of the seven fail on 5.122.0; three mutations each caught.
v5.122.0 — 27-09-2026
/api/day-patterns(web_dashboard.py) ->get_dashboard_day_patterns(): one entry perDAY_SHAPE_GROUPSgroup withkey(mon / tue-fri / sat / sun),label,weekdays,own_pattern,days_used,min_days,total_kwh,kwh(48, what the plan uses) andeveryday_kwh(the blend scaled to the same total); plusavailable,away,window_days,today_weekday. Totals are the automatic day figures, as in sigen_site_config.json. While away every group is the away profile andown_patternis false. Its own path because it changes once a day and/api/statusis polled every few seconds. Dashboards 3.51.0 proxies it.- Tests: payload shape and totals, away, no profile yet, JSON-serialisable; the web API surface test now lists eight endpoints.
v5.121.0 — 27-09-2026
- Tuesday to Friday shaped, as ONE group.
DAY_SHAPE_GROUPS = ((0,), (1, 2, 3, 4), (5,), (6,));SHAPED_WEEKDAYSis derived from it;_NEED_SCALE_INDEXgives 1-4 the Tue-Fri scale [0];day_shape()(3.0) accepts a collection of weekdays and measures them as one shape. Each member of a group is stored under its own weekday with the same shape; announcements are per group (“Tuesdays to Fridays … measured from 63 of those days”). - Why a group. Measured 27-Sep-2026, each of Tue/Wed/Thu/Fri sat 0.019-0.021 kWh an hour (mean absolute, per hour) from the four-day mean, against 0.026-0.034 between the two halves of its own history. Separate shapes would be fitting noise.
- Live: 63 days; 10am-1pm 0.81 / 0.82 / 0.81 kWh an hour against 0.92 / 1.02 / 0.99 on the blend at the same total; 5pm 0.96 vs 0.84, 8pm 1.06 vs 0.93, 10pm 1.00 vs 0.88. The blend over-stated weekday mornings because it carried the weekend’s.
- site_config
hourly_kwh.tuefri(and the legacyweekdayblend) uses it. New test that every shaped day’s published hours follow its shape: the four per-day overrides in_write_site_confighad no test since 5.118.0 (a mutation disabling any of them survived). Six mutations this release, all caught.
v5.120.0 — 27-09-2026
- Monday shaped.
SHAPED_WEEKDAYS = (0, 5, 6). Each shaped day’s level now comes from_NEED_SCALE_INDEX = {0: 1, 5: 2, 6: 3}into_need_scales()(was an inline {5, 6} map); the snapshot passes{0: monday_pref, 5: saturday_pref, 6: sunday_pref}; site_confighourly_kwh.monday(and so the legacy Mon-Friweekdayblend) uses it. Live 27-Sep: 16 of 18 Mondays; 5pm-8pm 1.07 / 0.91 / 0.97 kWh an hour against 0.89 / 0.82 / 0.90 on the blend, 4pm and 9pm a little lighter. Tue-Fri stays on the blend: it is the reference bucket and most of the blend’s data. - Tests: Monday measured, Monday’s level from
scales[1], and_NEED_SCALE_INDEX/ the snapshot levels must cover exactlySHAPED_WEEKDAYS. Two mutations, both caught.
v5.119.1 — 27-09-2026
_refresh_day_shapesannounced a day’s pattern whenever the in-memoryday_shapeschanged, and the store starts empty, so every restart re-announced both days. It now compares againststore["day_shapes_announced"], persisted in accumulators.json (saved at once when it changes; restored with anything outside 0-6 dropped). Tests: a restart does not re-announce, and a real_save_accumulators_locked/_load_accumulatorsround trip; three mutations, all caught.
v5.119.0 — 27-09-2026
Saturday gets its own half-hourly shape; the half-hourly recorder is honest at midnight.
- Generalised, not copied.
sunday_shape.py->day_shape.py2.0:day_shape(rows, dates, weekday, tz, min_days).SHAPED_WEEKDAYS = (5, 6);store["day_shapes"]/["day_shape_days"]keyed by Python weekday;_refresh_day_shapes(),_day_profiles(levels)(level per day from_need_scales: Saturday [2], Sunday [3]; the snapshot passes{5: saturday_pref, 6: sunday_pref}).HalfHourProfile(day_slots={wd: 48}),ManagerSnapshot.day_profiles,profile_for_weekday()via_day_curve(),_estimate_consumption_until(..., day_profiles). site_confighourly_kwh.saturdaytoo. Live 27-Sep: 17 of 18 Saturdays, 10am-1pm 1.73 / 2.18 / 1.84 kWh an hour against 1.10 / 1.22 / 1.19 on the blend at the same total. - Recorder fault, found measuring the shapes.
_log_halfhourly_to_db_impllabelled every rownow - ENERGY_VAR_INTERVALwhile its energy is the delta since the anchor, and_check_midnight_implresetlast_energy_varwithout writing — so each day’s first row held ~55 min (23:35-00:30) under a 00:00-00:30 label, and the old day’s last half-hour was always recorded in the new day. Now:store["hh_anchor_at"]records when the anchor was taken (seed and every write) and becomes the row’sslot_start(trusted when 0 < age <= 3 h, else the old label);_check_midnight_implcalls_log_halfhourly_to_db()before the timer reset. Readers keyed on theslot_startdate (Evening_Report, band weighting, winter forecast) now see each day’s energy in that day. - Reading the old rows.
day_shape._true_spans: a row labelled within 5 min after midnight whose predecessor ended < 60 min before really began at that end. A 0.0 kWh row is dropped after placing its end — before 5.89.0 the midnight row was always written as zero (daily counter delta, clamped), so it was lost energy, not none. Each half-hour is read as a rate over the time recorded, relative to that day’s mean rate, weighted by recorded time: a restart gap is not a quiet spell, and each day still counts once. None if any half-hour was never recorded. - Tests:
test_day_shape.py(46, wastest_sunday_shape.py33) incl. recorder tests and an AST check that midnight writes before it resets. Mutation-swept: nine mutations, eight caught; the ninth (the weekday pre-filter in_refresh_day_shapes) is equivalent —day_shapefilters by weekday itself.
v5.118.0 — 27-09-2026
A Sunday’s own half-hourly shape. CliveS: the roast, both microwaves and the wash put a Sunday’s load in the afternoon. The planning profile is ONE 48-slot curve blended over every day; the four day types (5.104.0/5.105.0) only ever set the daily TOTAL. Measured over 18 Sundays to 20-Sep-2026 (live, 27-Sep): 2pm-4pm 1.26 and 1.45 kWh an hour against 0.90 and 0.90 on the blend at the same Sunday total, 10am-1pm about 0.2 kWh an hour lighter.
- New pure module
sunday_shape.py.sunday_shape(rows, dates, tz, min_days)returns 48 fractions summing to 1 (or None) fromenergy_timeseries.dbhalfhourlyrows. Rows carry no fixed phase, so each is shared across the local half-hours it overlaps. A Sunday is used only ifdaily_history.jsonaccepts it (notenergy_partial, >= 2 kWh), the rows cover 90% of it, and it is not a clock-change day. Each Sunday is weighted equally as a shape; 1-2-1 smoothing. - Why these records, not the rolling window.
home_profile_daysbegan 13-Sep-2026 and holds two Sundays;halfhourlygoes back to May and is the counter the 13-Sep audit trusted. - Shape only, level unchanged.
_sunday_profile(level)scales the shape to the profile total x the measured Sunday scale (_need_scales()[3]), the figureneedTodayKwhalready used; the snapshot passessunday_pref, so a user’s Sunday override still wins. [] while away, with no shape, or on a base profile under 5 kWh. - Readers.
HalfHourProfile(..., sunday_slots=)picks the Sunday curve per instant by LOCAL weekday, so a walk from Saturday night changes curve at midnight; an unusable Sunday curve is dropped, never fatal. Both call sites pass it (Flux planner, Happy Hour booking).ManagerSnapshot.sunday_consumption_profile;profile_for_weekday()owns the curve-for-a-day mapping besideneed_for_weekday();_estimate_consumption_until(..., sunday_profile)at all six call sites; the three “today” slices go throughprofile_for_weekday.sigen_site_config.jsonhourly_kwh.sundayuses it too. _refresh_sunday_shape()runs at the start of every_refresh_consumption_profile, outside the state lock.SUNDAY_SHAPE_MIN_DAYS = 6overDAY_UPLIFT_WINDOW_DAYS(126).- Tests:
test_sunday_shape.py(33), including AST guards that every estimate call passes the Sunday curve, no reader slicesconsumption_profiledirectly, bothHalfHourProfilesites passsunday_slots, and the snapshot is given it. Mutation-swept: six mutations, all caught.
v5.117.0 — 27-09-2026
Faults found while writing the plain-English guide. No control decision changes.
- Secrets position 0.0, 0.0 is unset.
_usable_secrets_coords()runs once at import onSITE_LATITUDE/SITE_LONGITUDE: both zero, or blank, becomesNone, so all three readers (site_config publish, storm watch, forecast init) fall through tositeLatitude/siteLongitude. The bundledIndigoSecrets_example.pynow shipsNone, not 0.0. One zero on its own is still a real position. emergencyImportTriggeredfires only forimport_purpose == "reserve". It fired on every START_IMPORT and every scheduled import._check_resilience_buffernow tags its Decision"reserve"(the Agile reserve block already did). SCHEDULE_IMPORT stores the purpose inimport_scheduled_purposeand the firing path pops it. The import itself is unchanged.- No storm check without a position.
storm_watch.py1.6 has noLATITUDE/LONGITUDEandcheck_storm_level(lat, lon, ...)requires both._check_storm_watchused to fall back to that built-in site (the author’s house) when none was configured. It now returns before the poll, leaves the stored level alone, and logs one INFO line per run (storm_no_position_logged).scripts/agile_replay.pytakes--lat/--lngor reads IndigoSecrets.py. Test fixtures use a generic 52.5, -1.5. IndigoSecrets_example.py:AXLE_API_TOKENrenamedAXLE_API_KEY, the nameplugin.pyreads.- PluginConfig: the storm release label said 50% yellow / 80% amber-red, the code is a flat 50% (
STORM_SOC_YELLOW=STORM_SOC_AMBER= 50 since 26-Jun-2026). The Flux heading lost “draft”. - Events.xml: both import and export descriptions now say what fires them.
- Tests:
TestSecretsCoordsLeftAtZeroAreUnset,TestEmergencyImportEventOnlyForTheReserve,TestStormCheckNeedsAPosition,test_storm_watch.test_there_is_no_built_in_site,TestResilienceBuffer.test_resilience_import_is_tagged_as_the_reserve.
v5.116.0 — 26-09-2026
Every session’s result published; free-hour credits chased until paid. (free_hour_credits 1.0)
- CliveS: show every detail of the Saving Sessions, and check the Octopus account every day that the free-hour electricity has been paid back, showing it until it is. Chase only the free-hour credit; Pushover when paid, late or short.
- What Octopus exposes (probed 26-Sep-2026, read-only):
SavingSessionsAccountJoinedEventsTypecarriesresultsStatus(CALCULATING/SUCCESS/FAIL),rewardGivenInOctoPoints,rewardGivenInPence(null on this account),energyDeltaKwh,baselineConsumptionDeltaKwh,consumptionDeltaKwh,energyUsedInKwh,co2SavedInGrams(null) andresultsSetAt. Points are per SESSION (200-328 here), not per kWh.loyaltyPointsBalance(accountNumber)works on the backend host with the raw token (3,524);loyaltyPointLedgersis refused (KT-CT-1111) to an API-key token. Accounttransactionson the main host give Credit/Charge/Payment/Refund withreasonCode; Charge lines carryconsumption{quantity usageCost}. - No free hour has yet been seen paid. 16-Aug-2026 left no credit line (2.33 kWh imported in the whole bill period). Older free-electricity sessions came as Credit
FREE_ELECTRICITY_REWARD(Sep/Oct 2025), so that is matched first, with looser title/reason matches beside it; every other credit since the hour is published so an unfamiliar one is visible. If Octopus zero-rates the units on the bill instead, the claim goes LATE at 14 days — the signal to change the matcher. free_hour_credits.py(pure): one claim per Sunday; kWh fromget_import_kwh_between(Octopus’s half-hourly meter data, all half hours or nothing) once 24 h have passed, the inverter’s_end_happy_hour_importreading until then; each hour capped at 16 kWh and priced at_import_rate_at(the Flux band for that moment). Credits matched oldest claim first, never before the claim’s date, never twice. paid within 5p, short beyond, late at 14 days; a paid claim stays 14 days. Notifications are deduped on claim + kind (short re-notifies only when the amount paid changes), and are not asked for in quiet hours, so nothing is marked sent that never went.- Plugin:
_check_free_hour_creditson the tick every 6 h (and on the next tick after a new claim), network I/O unlocked, file load-modify-save under_free_hour_lock. Status gainsoctopus_sessions.history(45 days),token_balance,points_balance,free_hour_credits. - Tests: 2,116 -> 2,156. Mutation sweep 15 of 16 killed; the survivor (open claims never pruned) is equivalent, since only paid or nothing-owed claims are ever given a close date.
v5.115.0 — 26-09-2026
Flux owns the 2am charge; a dull afternoon drains the dawn projection. (battery_manager 3.13)
- The cheap window is the Flux controller’s (CliveS: “let Flux own 2am”). Live 26-Sep-2026 02:00:03 Flux planned “buying about 4.0 kWh at 14.6p to 34%”; at 02:00:37 the manager’s scheduled import fired,
_flux_preemptpushed Flux aside and the manager charged to 33%, then 37.5% at 02:36.ManagerSnapshot.flux_owns_cheap_window(plugin_flux_owns_cheap_window): True while Flux is armed on a Flux account; inside the window only while the latest Flux plan is CHARGE or HOLD and_flux_other_owner()is empty (so a storm, a VPP window or the manager’s own import in flight hands it back). With it set,_plan_tou_importreturns SELF_CONSUMPTION via_leave_cheap_window_to_flux(which also retracts a queued schedule),_check_resilience_bufferstands down in the window, and_check_scheduled_import_impldrops a schedule queued earlier rather than firing it (the retraction in_act_on_decisioncannot run while Flux holds the inverter from 2pm). The day-rate peak top-up stays with the manager. - The dawn projection counts a dull afternoon.
battery_at_duskusedmax(0, solar - home), so a house outrunning the panels before dusk drained nothing. Now signed and floored at the health cutoff. No existing test covered the deficit direction; the whole suite passed unchanged before the new tests were added. - Tests: 2,100 -> 2,116. Mutation sweep 10 of 11 killed plus the snapshot wiring on a second pass; the survivor is the dusk floor clamp, which is equivalent because the dawn value is clamped at the same floor straight after.
v5.114.0 — 26-09-2026
The day rate buys only the peak; no peak export after it; Flux takes the inverter back. (battery_manager 3.12, flux_strategy 2.3)
- Live 26-Sep-2026, a dull Flux day (forecast 31.6 kWh at 02:05, about 6 kWh arrived):
_plan_tou_importfound the battery could not reach 02:00 above the 1% floor and bought tomorrow’s shortfall at once at 24.4p. Starts at 11:24, 11:36, 11:46 with the targetcurrent SOC + import_kwh— it rose with the battery, 34% -> 40.3% -> 37% -> 53.6%, andimport_neededflipped True/False/True across MIN_IMPORT_KWH in the same twelve minutes. - Why it was wrong: running out before the cheap window puts the house on the grid at the same day rate. Buying early adds the round-trip loss. Only energy that would otherwise be bought at the PEAK rate is worth buying at the day rate.
- Fix 1 —
_plan_peak_topup: on a tariff with a peak band (TariffData gainsday_rate_p,peak_start,peak_end,peak_rate_pfrom octopus_api’sstandard_p/peak_*), buyreserve + peak demand - (battery + solar before the peak - house before the peak), only whenpeak_p - (day_p / efficiency + wear) >= PEAK_TOPUP_MIN_MARGIN_P(1p) and the peak opens before the next cheap window. Otherwise SCHEDULE_IMPORT for the cheap window, whether or not the battery lasts that long. The floor is the policy discharge floor (reserve_floor_pct, the 20% Flux backup reserve), not the 1% health floor._forecast_solar_kwhis the one owner of the corrected P50 sum; the 24h balance uses it too. - Fix 2 — no stop-start: the top-up target is an absolute level, it starts only at a PEAK_TOPUP_MIN_KWH (1.0) gap and buys PEAK_TOPUP_BUFFER_KWH (0.5) over, so a restart needs the requirement to grow 1.5 kWh.
import_neededholds whileimport_pending(import running or queued) until the shortfall is zero. - Fix 3 — the words: every TOU reason now says what is short and when it will be bought (“Tomorrow is short by about 9 kWh, buying it in the cheap window from 2am”).
- Fix 4 — no peak export after a day-rate buy (CliveS’s rule):
_note_day_rate_importstampspluginPrefs["dayRateImportDate"](a local date, so restarts keep it and midnight clears it) when a manager import starts outside the Flux cheap window, from START_IMPORT, the scheduled firing, or a grid charge found on the inverter that this process did not start (a crash mid-import).FluxInputs.day_rate_import_todaysends the peak window down the no-export branch: SUPPLY_HOUSE, or SOLAR when the roof is still generating. - Fix 5 — Flux never reclaimed after a pre-emption.
_flux_preemptsetsflux_manual_preemptand the expiry readflux_preempted_at— which the supervisor re-stamps on every tick an owner stands, and the flag IS an owner. So the five minutes restarted every tick. Live 21-Sep 18:01 and 26-Sep 02:01: stood aside until the next restart, so the 26th’s cheap window ran the manager’s smaller charge (to 37.5%) instead of Flux’s. The flag has its own stamp,flux_manual_preempt_at. - Tests: 2,072 -> 2,100.
test_tou_peak_topup.py(16), plus the Flux strategy, supervisor and wiring tests. Two old tests pinned the removed behaviour and were rewritten:test_go_import_now_if_margin_too_low_for_cheap_window(now waits) andtest_the_manual_flag_holds_for_the_cooldown(reads the new stamp). Mutation sweep: 14 of 14 killed, including each start path’s_note_day_rate_import()call. - Not changed: the manager’s own 02:00 schedule still pre-empts the Flux cheap-window charge when both want it. Which planner should own that window is an open decision.
v5.113.0 — 24-09-2026
The overnight charge buys to sell only what the sun will not supply. (flux_strategy v2.2)
- Live 21 and 23 Sep-2026: the 02:00 charge bought 15.4 and 15.9 kWh (11.6 and 10.0 kWh of it “for the peak window”) on forecasts of 15.6 and 17.8 kWh. The days brought 31.5 and 23.4; the battery reached 95% at 11:03 and 12:33, and 13.0 and 7.4 kWh left before 16:00 at the 9.7p day export rate. The 16:00-19:00 sale was 11.8 kWh both days, the same as on days that bought nothing (11.4-11.8): a 4 kW export limit sells about 12 kWh in three hours.
- Cause:
_charge_plansized the resale buy asmin(spare headroom, sellable)withsellablethe full 12 kWh, never subtracting what the sun alone leaves in the battery at 16:00. - Fix:
_resale_room_kwhlimits the resale part twice, the smaller winning: (1) the peak’s sellable energy (battery side) less the solar surplus the forecast leaves at 16:00 above what the house needs to the next 02:00; (2) the largest extra start that a dayRESALE_PV_OPTIMISM(1.35) sunnier would not displace before 16:00, as spill or crowded-out free Happy Hour import (binary search onwalk, 0.2 kWh tolerance). 1.35 is the 90th percentile of actual/forecast over the last 90 records (median 0.95, p75 1.12, p90 1.32, worst 2.02 on 21-Sep).HourlyPvForecast.scaled()added for it. The household charge is unchanged. - Replayed at 02:00: 21-Sep 11.6 -> 3.2 kWh; 22-Sep 5.0 -> 0; 23-Sep 10.7 -> 2.6; 24-Sep hold both; a dull 8 kWh autumn day 19.7 -> 20.1; a 3 kWh winter day 26.3 both. Limit (1) is what bites on this house; limit (2) binds on heavier-load days (a 30 kWh house on a 25-34 kWh day).
- Six tests; each limit mutation-proven (switched off, a named test fails).
v5.112.6 — 24-09-2026
A Sunday already booked is never pushed as held back.
- Live 24-Sep-2026: 19:06 booked EVENT_90_270926 (2pm-3pm, 26 kWh forecast, ~9 kWh useful). 20:07 the hourly re-plan found the best SECOND hour worth ~4 kWh, under the 5 kWh bar, so
plan_dayreturnedHOLD_BRIGHTwithplan.bookedholding the 2pm slot.hold_messagenever readplan.booked, so the push said “Keeping your free hours for a duller Sunday … enough to fill the battery by itself” — which read as the booking being undone. Octopus still had 2pm booked, correctly. (21:07, on a duller forecast, the plugin added 1pm-2pm as designed.) - Fix:
hold_messagenames the booked hours and says they stay booked whenplan.bookedis non-empty (title “Sunday stays at one free hour”). The plugin logs that once per day and outcome (keyhold:<day>:<outcome>:booked) and sends no Pushover: not adding an hour is not news. - Two tests, both watched failing on 5.112.5.
v5.112.5 — 23-09-2026
A Saving Session that ends under an Axle VPP window lets go of the registers.
- Live 21-Sep-2026: session 18:00-19:00, VPP window took the export over at 18:28.
saving_session_export_activewas cleared only in theACTION_SELF_CONSUMPTIONbranch, and the manager decidesACTION_VPP_EXPORTfor as long as the VPP window runs, so the flag outlived the session by 33 minutes. The VPP end handed back at 19:32:23;_driven_export_owns_registers()stayed True until the 19:33:05 tick, which cleared the flag and ran a second, redundant hand-back (“Saving Session export ended” at 19:33:09). The real exposure was that ~40 s, not the hour: from 18:28 to 19:32 the VPP window owned the registers anyway. - Fix:
ACTION_VPP_EXPORTclears the session flag, without writing, once_saving_session_window()returns None. The VPP window is still driving and its own end hands back and clearsexport_active. While the session window is still live the flag stays, so an overlapping session keeps its tail and Flux still excludes its energy from the reserve. - Not changed, for CliveS to decide: any OTHER non-self-consumption decision after a session (
SCHEDULE_IMPORT,START_EXPORTwhileexport_activeis set, solar overflow already active) also leaves the flag set, and there the session’s export mode keeps running undriven until a self-consumption decision. Clearing the flag alone there would let the verify pass re-assert 0x06, so it needs a hand-back decision, not a flag change. Not seen live. - Tests: 3 in
test_plugin.py(TestSavingSessionFlagEndsWhenAVppWindowOwnsTheExport), two watched failing before the fix; the third pins the overlapping-session case.
v5.112.4 — 23-09-2026
A Saving Session ends with one hand-back, not two.
- Live 10-Sep and 16-Sep-2026: at the end of a session the manager ran the whole hand-back (Remote EMS, mode 0x02, discharge limit, charge limit) and then ran it again nine seconds later —
Saving Session export endedfollowed byExport disabled._drive_vpp_exportsetsexport_activeas well as the session flag, so inACTION_SELF_CONSUMPTIONthe session block calledset_self_consumption()and theelif prev_export:branch called it again. Idempotent, but twice the Modbus writes. - Fix: the session block records that it handed back this tick, and the generic branch skips its write and its log line. It still clears
export_active, because the retry for an unconfirmed hand-back (vpp_handback_pending) refuses to run while that flag is True. A flood-prevention end still resets its floor and fires its event. - Tests: 4 in
test_plugin.py(TestSavingSessionEndHandsBackOnce), three watched failing before the fix.
v5.112.3 — 22-09-2026
Saving the plugin’s settings is not a Modbus fault. (sigenergy_modbus.py 1.16)
- Live 22-Sep-2026 21:54: a prefs save ran
_init_modules, whichdisconnect()s the driver and builds a newSigenergyModbus. A_poll_modbuscycle was mid-flight on the old one (the read runs unlocked since v5.45.0), every remaining read failed, and the quality check loggedToo many Modbus errors (7/8) - marking disconnectedat ERROR — which Log_Error_Watch pushed to CliveS as a new fault — followed by[Modbus] Inverter poll failedat WARNING. The inverter was fine; the plugin had closed its own socket. Five occurrences since 01-Sep, at least two of them prefs saves. - Fix, both layers:
disconnect()latches_closed_on_purpose(cleared by a successfulconnect()), and the quality check logs that case at DEBUG instead of ERROR._poll_modbuskeeps a reference to the driver it read from and discards aNonefrom a driver that has since been replaced — good data from it is still used. - Shared module: sigenergy_modbus.py is mirrored byte-identically into SigenVPP in the same round, or its shared-module CI check goes red.
- Tests: 3 in
test_sigenergy_modbus.py, 3 intest_concurrency.py. Four mutations (no discard, no quiet branch, latch never cleared, discard good data too) each turned one red.
v5.112.2 — 22-09-2026
A clear Flux journal at startup is an INFO line, not a WARNING.
_init_moduleslogged[Flux] A claim journal is outstanding ...at WARNING wheneverflux_claim.jsonexisted — which, once Flux is armed, is every restart: the executor saves{"owns": false, "pending": false, "supervisor_owned": false}after each release. 15 such warnings 20-22 Sep-2026, none of them a fault.- New
_flux_journal_says_clear(): True only for version 1 with all three flags exactlyFalse. Clear -> INFO (“the journal from the last run says nothing was held”); live, missing, unreadable or unknown -> the WARNING as before. Used for the log LEVEL only: the startup reset is still skipped and the executor still reconciles on the first tick, by design (“no persisted flag proves hardware state”). - Tests 2047 -> 2051; three breakages (truthiness for
is False, version check dropped, the level choice bypassed) each caught.
v5.112.1 — 22-09-2026
The plugin trusts its own Happy Hour bookings, and a later second hour is counted.
- New persisted
happy_hour_booked_codes: every slotbook_happy_hour_eventconfirmed (the reply carried the matchingbookedEvent)._apply_local_happy_hour_bookings()marks those events joined on every poll, before booking and before the window cache, inside the booking step’s error guard. Without it a feed lagging the booking would have dropped the slot from the window cache (no free import) and let the plan book the same Sunday again, spending tokens twice. booked_messagecounts the whole day’s hours beside a span that covers the whole day, and says when an hour was added to one already booked. The first draft read “One free hour is booked for Sunday, from 1pm to 3pm”.- A third
_check_saving_sessionstest stub (TestUpcomingSessionsForDisplay) was running the booking steps into their error guard; it now carries them, inert, like the others. - Tests 2043 -> 2047; the four new guards each broken on purpose and caught.
v5.112.0 — 22-09-2026
Weekend Happy Hours booked by the plugin, and three faults that made a booked hour worth almost nothing on Flux. Design and rules: docs/happy-hour-booking.md.
- The planner ignored a booked hour.
_flux_commitments()has handed a Happy Hour to Flux as animportcommitment since 5.109, andflux_strategy.build_stepsread onlyexport, so the 02:00-05:00 charge bought ~15 kWh for the peak (15.45 kWh on 21-Sep) and a dull Sunday met the free hour ~75-85% full.flux_strategyv2.1: steps carry a 6th element (free import kWh); newwalk()returns aWalkResultwithspill_kwhandfree_in_kwh;run_stepskeeps its 4-tuple. In a free step the grid serves the house and fills the battery within the one charge limit, sun first; never beside an export commitment (the manager fails closed there)._charge_planneeded no change: its headroom comes from the walk. The cheap-window reason saysleaving room for up to N kWh of free Happy Hour electricity. The commitment is now offered ONLY whilehappyHourImportis on. - A 2pm free hour never started.
MODE_HOLDowns the inverter from 14:00; the manager never acts while Flux owns it;happy_hour_import_activeis only set by acting._flux_other_owner()now also returns an owner for a live_happy_hour_window(). - At the target the free hour handed back and the house ran on the battery. The decision now stays
ACTION_HAPPY_HOUR_IMPORTfor the whole window;force_chargecutoff = the target (was +3);set_discharge_limit(0)at start;_verify_ems_registersexpects discharge 0 whilehappy_hour_import_active; the unconditional end-of-_act_on_decisiontarget check skips a free hour; a rising target (95 -> 100) moves the cutoff. NewBatteryManager._happy_hour_target_pct(): 100 when_flux_clip_risk_passed, else the daily target. - Booking. New pure
happy_hour_booking.py(plan_day,useful_free_kwh, messages).octopus_api.book_happy_hour_event()— success only when the reply’sbookedEvent.eventIdis the one asked for; any Octopus refusal is permanent for that slot; auth/HTTP/network transient. Probed safely with a code that cannot exist: HTTP 200, data null,OE-1305. Plugin:_auto_book_happy_hours(after auto-join, before the window cache, isolated in try/except),_happy_hour_morning_note,_happy_hour_result_note,_note_happy_hour_span; notes deduped onkind:day[:codes]keys. Persisted:happy_hour_notes_sent,happy_hour_book_refused,happy_hour_used. New prefhappyHourAutoBook(off). Per-slot “Happy Hour available” pushes are suppressed while it is on. - Join verdict. New
_session_join_verdict()— one owner, asked by both the auto-join and the announcement: a session wholly inside the Flux peak (_session_inside_flux_peak, Flux armed) is always worth joining; everything else goes to_happy_hour_token_verdictunchanged. Without it the auto-join declined every session from 27-Oct and, after 1-Nov, for good. - Forecast.
openmeteo_forecastv1.9:FORECAST_DAYS = 6; days after tomorrow kept as_hourly_p50_ahead+aheadDayKwh._flux_pv_forecast(include_ahead)and_flux_site()lifted out of_flux_inputsso the booking check simulates with the same figures. - Tests: 1936 -> 2043. New
test_happy_hour_booking.py(38),test_happy_hour_plugin.py(30), plus planner, manager, act-path, verify, API and forecast cases. Mutation sweep: 30 breakages, 30 caught — the one that first survived (the per-slot push switch) had no test and now has two.
v5.111.8 — 22-09-2026
/api/status no longer queues behind a battery command.
- Measured: 133 Dashboards
[SigenProxy] status fetch failed: timed outwarnings between 15 and 22 September, 131 of them within 30 s of a SigenEnergyManager Modbus write, 70 in the 16:00 hour when Flux starts its peak export. The endpoint answers in ~5 ms when the lock is free, so every one of those was pure waiting. - Cause:
get_dashboard_datatakes_state_lockfor a millisecond snapshot (v5.45.0), and the control stages (_evaluate_managerincl. verify+act) hold that lock across their Modbus writes by design — 16 s on 22-Sep 13:40:45 -> 13:41:01. Dashboards gives up at 12 s. - Fix: wait
DASHBOARD_LOCK_WAIT_S(1.0 s) for the lock; if it is still held, answer from the last snapshot (self._dash_snapshot).timestampis now when the figures were READ, and a newsnapshot_age_ssays how old they are. With no snapshot yet (first request after a start) it waits as before rather than invent one. The control locking is unchanged. - Tests:
TestDashboardNeverQueuesBehindControlintest_concurrency.py(4). A mutation sweep of five breakages (blocking acquire, no cache write, no release,now()timestamp, zero age) turned each red.
v5.111.7 — 21-09-2026
Two faults at the 18:00 hand-over, found reviewing the first evening with a Flux peak, an Axle event (18:30-19:30) and a Saving Session (18:00-19:00) together.
Pre-charge no longer stops a running export
- Live 21-Sep-2026: the Axle pre-charge began at 18:00:24 and pre-empted Flux; the Saving Session drove a 0x05 export from 18:01:13; at 18:01:32 pre-charge Step 1 (“stop charging once SOC target is reached”) read 0x05 and wrote Self Consumption over it. The verify pass logged
VPP export mode driftand re-asserted 0x05 at 18:02:09 — 37 s lost, ~0.04 kWh. - Cause: Step 1’s guard was
cur_mode == 0x06, written when Axle’s cloud drove every dispatch ESS-first. Since self-drive (5.28) daylight export is 0x05, and Flux and Saving Sessions use the same driver. - Fix: new
_VPP_EXPORT_MODES = (0x05, 0x06). Step 1 stands aside when the mode is either, orexport_active/saving_session_export_activeis set (covers a failed mode read). A discharge mode already means “not charging”, so the step’s intent is met with no write.
A reserve the plugin moved itself is not drift
- Live: pre-charge wrote the backup reserve (40046) at 25.7% at 18:00:56, which excluded the Axle event’s own energy but still held the Saving Session’s. The session began dispatching at 18:01:13, so its energy stopped being reserved too, and the right floor became 20%. Verify corrected it correctly, but logged
Backup reserve mismatchat WARNING. - Fix: every 40046 write now goes through
_set_backup_reserve(or, for the Flux executor,_FluxRawDriver(on_backup_written=)), which records the value instore["backup_reserve_written"]. Verify logs an INFO line when the register still holds the plugin’s own last value, and the WARNING only when it holds something else. Nothing written since start counts as NOT ours, so a restart cannot quieten real drift. The correction itself is unchanged. sigenergy_modbus.pyis untouched, so the SigenVPP copy stays byte-identical.
Tests: TestPreChargeNeverStopsARunningExport (4) and TestBackupReserveMoveIsNotDrift (5); 5/5 mutations killed.
— 21-09-2026
The Flux peak is claimed at 16:00, not 16:05.
Live 21-Sep-2026: the Flux supervisor logged “No longer standing aside (solar overflow is running has ended)” and its export plan at 16:00:06, but the inverter stayed in Max Self Consumption, charging, until about 16:05. The same five minutes were lost on 17-Sep, when the fix that stopped overflow counting as an owner INSIDE the peak (5.109.5) left this half alone.
- Cause: the owner branch of
_flux_supervisor_stepstampedflux_preempted_atand zeroedflux_clear_tickson EVERY tick an owner held the inverter. Solar overflow is an owner until 15:59:59, so the stamp was seconds old at 16:00 and_flux_may_claimwaited out the fullFLUX_PREEMPT_COOLDOWN_S(300 s). - Fix: new
FLUX_OWNER_PRE_PEAK_OVERFLOWnames the one owner whose tenure ends on the clock. Its ticks neither stamp the stand-down nor reset the clear count; they count as clear. Every other owner behaves exactly as before. - No flap risk:
_flux_other_owneronly returns the overflow owner outside the peak, and the strategy never claims outside a window, so the waiver can only ever apply at the 16:00 edge. Overflow cannot become an owner again until 19:00. TestPeakClaimedAtFourNotFive, 3 tests. Two fail with the fix disabled; the third checks a real owner (export_active) still starts the stand-down.
— 20-09-2026
Contents/Resources/icon.png, which this plugin had never had.
Contents/Resources existed and was empty — that is how it came up, while checking the v5.111.4 release asset against the installed bundle. Seven Highsteads repos ship an icon; the largest plugin of the lot did not, so anywhere it is listed it falls back to Indigo’s generic plugin picture.
- 256 x 256 PNG, which
official-plugin-dev.mdcalls the optimal size; the hard floor is 128px high, and the Store shows it at about that. Checked at 128 before committing. - Drawn to the house pattern the other four already share: dark navy rounded square, the name in white caps across the top over a cyan rule, one glyph in the middle, a subtitle word in spaced cyan caps below. Here the glyph is an upright battery, charged about seven tenths, with a sun at its shoulder — solar and storage, which is the whole plugin in one mark.
tools/make_icon.pyis committed with it. A PNG nobody can regenerate is a dead end the first time the name or the palette changes; the script draws at 4x and downsamples with LANCZOS, which is the cheap way to clean edges.- It only reaches the Plugin Store through a RELEASE. The Store reads the icon from the bundle in the published release, so adding it to the repo alone changes nothing anyone sees.
Still without one, noted while checking: Dashboards. Its four tracked PNGs are all apple-touch-icon files for the web pages, not Contents/Resources/icon.png.
v5.111.4 — 20-09-2026
os.makedirs will happily create a directory named after a mock, and did — inside the bundle people install.
_get_data_dir joined indigo.server.getInstallFolderPath() onto a path and created the result, with no check on what came back. Found in the LIVE installed bundle on 20-09-2026 while verifying the v5.111.3 release asset against it:
Contents/Server Plugin/MagicMock/mock.getInstallFolderPath()/<object id>/
Preferences/Plugins/com.clives.indigoplugin.sigenergy-energy-manager
Four of them, one per test run, dated 12-09-2026 and still there eight days later. A suite had built a Plugin for real against a mocked indigo; run_tests.py chdirs into the bundle, so the junk landed inside the plugin. All empty, so nothing was lost — but nothing noticed either.
- It no longer reproduces, and that is luck rather than design. Every test today uses
Plugin.__new__, which skips__init__and so never reaches this method. Verified by running both runners over agit archivetree in a clean virtualenv: nothing is created. The route closed itself when the tests changed, and could reopen the day one of them constructs a Plugin. - The method now refuses a non-path — not a
str, or blank — with a message naming the type and value. Failing is strictly better than succeeding into the wrong directory: a plugin whose data directory is nonsense loses its accumulators, its plugin log and its VPP ledger in silence, and the only symptom is that yesterday never happened.Noneand""are covered for the same reason, those being the plausible real-world versions of the same fault. test_no_stray_artefacts.pyguards the CLASS, not the route. It walks the bundle for directories whose names look like stringified mocks and fails naming them. It checks RESIDUE rather than only this run, deliberately: test ordering would otherwise decide what it can see, and residue from an earlier run is exactly what sat unnoticed for over a week. A second test builds the precise shape the live fault left, confirms the detector fires, and removes it — a guard nobody has watched fail is not a guard.- 5 tests, 3 mutations killed: dropping the type guard, accepting a blank string, and a detector pattern that matches nothing. The control arm — a real path still works and lands exactly where it should — is there so the class cannot pass against a method that refuses everything.
Same family as feedback_test_suite_writes_live_files.
v5.111.3 — 20-09-2026
The bank-first opening line quotes the forecast the day was CLASSIFIED on, not the one showing when the hold engaged.
Live on 20-09-2026, plugin v5.111.2:
[Manager] Banking first — daytime export held until SOC reaches 95%. SOC 34.6%,
today's forecast 40.5 kWh is below the 40.0 kWh cap-saturation threshold, ...
40.5 is not below 40.0. The sentence states something a reader can check and find false.
The decision behind it was correct throughout. bank_first_first_class_kwh was 39.9, stamped at 00:05; the forecast wandered up past the threshold by 08:00 and was back to 37.8 by 10:14. _log_bank_first printed the live snapshot.raw_today_kwh beside a verdict that had been reached from a different figure hours earlier.
The fix is not the first classification of the day. That is a separate record and it is the wrong one here: a day that opens BIG and is later demoted has a first classification ABOVE the threshold, so printing it produces the same false sentence in the other direction. What the line has to name is the figure the latch IN FORCE was reached through.
bank_first_latched_small_kwh / _local are stamped on the TRANSITION into small, where still_small was reached through raw_kwh < max_kwh — so “below the threshold” is true of that figure by construction, not by luck. Six places, per the warning v5.110.2 left in this file about adding a field to five of them: the seed, the setdefault block, the daily reset, the setter, the save dict, and the restore defaults.
A missing figure prints no figure rather than a wrong one — an accumulators file written before this version restores a latch with nothing behind it.
1914 to 1920 tests. Four mutations killed, and the fourth was found by the sweep rather than by design: stamping once a day instead of on every transition SURVIVED the first pass, because the demotion test uses a day that opens big and so has no earlier small latch to keep. The case that separates them is small -> big -> small again, where a once-a-day stamp describes a hold that ended hours before. Four causes for a surviving mutant, and this was the missing test.
The strongest of the new tests asserts the property rather than the wording: whatever forecast the rendered sentence quotes must be below the threshold the same sentence quotes. That fails for any future rewording that reintroduces the fault.
v5.111.1 — 19-09-2026
“Not holding” was two different facts sharing one value, and the log reported the wrong one.
_log_bank_first writes a matched pair: one line when the bank-first hold engages, one when it lifts. It decided the hold had lifted from decision.bank_first_holding being False. But that flag is set only when bank-first is the gate that ACTUALLY refused, and _overflow_skip_reason walks the gates in order — night, 24h surplus, physics, dwell, then bank-first. The moment an earlier gate refuses, bank-first is never consulted and the flag goes False for a reason that says nothing at all about the hold.
LIVE, 19-09-2026. At 08:30 the hold engaged: Banking first — daytime export held until SOC reaches 95%. SOC 36.0%, today's forecast 32.1 kWh. At 08:31 the PHYSICS gate refused, the sun being too weak to fill the battery, and the plugin logged Bank-first satisfied at 08:31 — SOC 36.0%, export handed back to the overflow gate. Held 0h01m, 0.0 kWh not exported. Nothing was satisfied and nothing was handed back: the battery peaked at 79.0% and the export counter moved 0.07 kWh all day before the 16:00 peak.
Three consequences, in rising order of cost:
- The line is false on its face — 36% against a stated 95% gate.
- It latches once a day, so the genuine release would have been silent had the battery reached the gate that afternoon.
bank_first_released_localin the daily record took the same wrong stamp, and that record is what the 28-Sep four-week review reads to judge whether the hold behaved.
Now three states rather than two: BANK_FIRST_HOLDING, BANK_FIRST_RELEASED, BANK_FIRST_NOT_ASKED on Decision.bank_first_state, set where the gates are actually walked.
- An export already running reports NOT_ASKED, not RELEASED.
_check_solar_overflowtakes the release path when a cap is live and never walks the engage gates, so a running export is no evidence about a gate that was not consulted this tick. bank_first_holdingis kept for the callers that already read it, and a test asserts the two fields can never disagree.- Both the log line and the record stamp read the state, and
_log_bank_firstnow derivesholdingfrom it too, so the opening and closing lines cannot disagree about what the gate did. - Suppressing the wrong line must not swallow the right one. A test drives holding -> not_asked -> released and asserts the release still lands, with the real SOC in it.
- 11 tests. Four mutations killed: reverting either read to the old two-state form, claiming a running cap asked the gate, and having HOLDING set the wrong state.
Same family as feedback_absent_state_is_never_a_match — a question that was never put, counted as a question that was answered.
v5.111.0 — 19-09-2026
Pacing survived the one condition that makes it pointless.
_check_solar_overflow paces the charge so the battery reaches its target by the deadline and exports everything above that rate. v5.109.5 added fill_to_full, which raises the target to 100% on Flux once _flux_clip_risk_passed has walked every remaining hour of the forecast and found none of them able to exceed the house load plus the export cap. The target moved; the pacing did not.
That leaves the pacing doing nothing it was built for. It exists to stop the battery filling early and throwing away the afternoon above the cap — and the branch only runs once the day has been declared unable to clip. So every kWh it holds back is sold at the standard rate for no benefit at all.
LIVE, 18-09-2026. The check passed at 11:53 and the target duly became 100%, but the 1.75 kWh of room was spread over the four hours to the peak: Req charge 0.43 kW to 100% target (Flux: no clip risk left today, banking to full) | PV surplus 2.08 kW | Export 1.66 kW | Cap 427W. Cloud arrived at 12:10, the array fell from 2.5 kW to 1.1 kW, and the battery finished the afternoon at 97.7% having exported 0.39 kWh at the standard rate — energy the 16:00 window would have taken at 27.7p.
unpaced = fill_to_full and headroom_to_target > 0.0 now takes the whole surplus.
- Guarded on headroom, and that guard is the change’s only real risk. At or above the target there is no room to absorb anything, so
required_charge_kwmust fall back to the paced formula (which yields zero) and let export run at the full cap. Without it a full battery would be told to swallow the whole surplus and would export nothing.test_a_battery_at_the_target_still_exports_at_the_full_capis that guard; dropping the condition turns it red. - The cap is lifted to the inverter’s own limit while unpaced.
cap_wis otherwise the surplus measured on this tick, and the limit is only rewritten when it moves by more thanSOLAR_OVERFLOW_CAP_DEADBAND_W, so a climbing array puts the difference on the grid between ticks. Self-consumption cannot charge from anything but surplus, so a high ceiling asks for “everything spare” and can never import. - Costs nothing when the forecast holds. The surplus is still there afterwards and still exports, because
headroom_to_targetclamps to zero at the target and the cap opens to the full DNO limit. The gain is bounded by the room between the pref target and 100% — 1.75 kWh here, so under 20p on a perfect day. It is the right shape, not a large sum. - Six tests. Reverting the change reproduces the live figure to three decimal places (1.654 kW against the logged 1.66 kW), and three mutations — dropping the headroom guard, dropping the cap lift, halving the requested charge — are each caught.
v5.110.4 — 19-09-2026
The same bug one layer down: 5.110.3’s key was control_key(), and control_key() carries the raw watt figure.
The charge branch sets charge_limit_w=int(power_w), re-derived on every plan from the energy still to buy over the time left to buy it in. On the first night under the new guard it wandered 316W to 337W and changed on essentially every tick — a number that appears in no message anybody reads.
Measured rather than inferred. set_charge_limit logged 146 calls across 22 distinct watt values between 02:00 and 05:00, and the 143 [Flux] lines came out a metronomic 27 seconds apart (91 gaps of 27s, 46 of 28s), carrying five distinct sentences and only two distinct plans. A drifting value crossing a rounding boundary would have been bursty; a perfectly regular cadence said the key changed every single time.
New note_control_key() = mode, ems_mode, the SIGN of each limit (charging or not, discharging or not), and both cutoffs as WHOLE percentages.
- The key must be no finer than the message. The note prints
{target:.0f}%, so 41.4 and 41.2 are one line and 41 vs 42 are two; power is in no sentence, so only its sign survives. control_key()stays exactly as it was. It is the REGISTER identity and the executor must still re-write on a watt.- The old fixture could not express the fault. Every test in
TestNoteKeyDedupesOnPlanNotProseused a flatcharge_limit_w=10000, so seven green tests sat over the one field that was moving._jitter()now derives it from the kWh by default, andtest_a_watt_of_jitter_is_not_a_new_planasserts the premise — every tick a distinctcontrol_key()— before asserting the cure. - 12 tests, 5 mutations killed (revert to
control_key(), raw watts, drop the reason, cutoff at 1dp, drop the cutoffs). Two pre-existing RED tests fixed on the way:_seed_flux_daywas pinned to 17-Sep-2026 while the code under test read the real clock, so both passed on the 17th and 18th and went red on the 19th.
v5.110.3 — 18-09-2026
The “never the same line twice” guard had never once fired.
_flux_log_decision deduped on f"{decision.mode}|{decision.reason}", and reason embeds a running figure — buying about {buy_kwh:.1f} kWh, about {surplus_kwh:.1f} kWh is spare, for the peak in N minutes — that moves on nearly every tick. The key never matched itself and the guard was dead from the day it was written. The first full unattended day on Flux put 310 [Flux] lines into the event log (103 charge, 136 export, 71 hold), one per tick, for a day whose plan changed four times overnight and three times in the peak.
Now keys on new flux_strategy.note_key() = control_key() plus the reason with whole NUMBERS masked to #.
- Mask per NUMBER, not per digit.
19.0masks to##.#and9.9to#.#, so a per-digit mask makes the same decaying figure a new key each time it crosses a power of ten. The tests caught that; review had not. control_key()alone was rejected on evidence.MODE_SUPPLY_HOUSEexplains itself two ways with byte-identical registers, and every deferral shares all six control fields, so dropping the wording would swallow a genuinely different explanation.- 7 tests replay the real 18-Sep tick sequence (103 and 136 ticks) and assert 4 and 3 lines; all 4 mutations caught (old prose key, constant key, control_key-only, no masking).
This is our own standing rule — dedupe on stable keys built from structured findings, never on the message body — broken in our own code. Superseded the next morning by v5.110.4, which fixed the same guard again.
v5.110.2 — 18-09-2026
The day’s first bank-first verdict did not survive a restart, and v5.106.0 is why.
v5.106.0 added bank_first_first_class_kwh / _small / _local (and _promoted_local) precisely so the daily record would stop publishing the 23:59 latch as though it were the morning’s verdict. It wired them into four of the five places they belong: the __init__ seed, the midnight reset, the setter, and _load_accumulators’ restore tuple. It missed _save_accumulators_locked.
So the restore read data.get("bank_first_first_class_kwh", None) from a file that never contained the key, got None every time, and the next evaluation re-stamped all four with the CURRENT clock and the CURRENT forecast — the exact fault v5.106.0 was written to remove, reintroduced one dict further down.
Live evidence, from daily_history.json:
- 15-Sep filed
first_classified_local = 15:50on 37.4 kWh. v5.106.0 was committed at 09:22 and v5.107.0 at 14:23 that day. - 17-Sep filed
19:03. That day carried the Flux arming plus 5.109.x-5.110.0. - 16-Sep, a day with no restart, filed
00:21— correct, and the control case that shows the mechanism rather than the feature was at fault.
The four keys are now persisted. The restore tuple’s indentation is straightened too: those same four lines sat at a shallower indent than their neighbours, which is the visible fingerprint of the hasty edit that missed the save.
Nothing the battery does changes. bank_first_small_latched and bank_first_latch_date were always persisted, so the hold itself always survived a restart correctly. This is the record of why, not the control.
Two tests, and the first is deliberately generic. test_every_bank_first_key_the_plugin_seeds_is_persisted takes its key set from the plugin’s own seeding rather than a list of its own, marks every key with a distinctive value, saves and reads back — so a key added to the seed later is covered without anyone remembering to. That is the gap that let this through. The second replays 15-Sep: the opening verdict survives the round trip, and a later drifted forecast does not overwrite it. Reverting the fix turns both red.
v5.110.1 — 18-09-2026
The Flux floor stopped shouting its re-asserts (sigenergy_modbus.py 1.14 -> 1.15).
set_discharge_limit() logged at INFO on every call. The Flux executor re-asserts the discharge limit on every tick while it holds the inverter, so the first overnight window on the native controller wrote 381 identical Setting ESS max discharge limit: 0W lines into the Indigo event log between 02:00 and 05:00 — 381 of the 537 Sigenergy lines for the whole of 18-Sep — for a value that never moved after the first write. The six [Flux] lines that said what the battery was actually doing were buried in them.
set_charge_limit() already had a quiet= flag for exactly this, but the Flux layer reaches every setter through FluxExecutor._set_read(setter, reader, value, tol), whose driver contract is a one-argument setter checked by name in rebind(). Threading a kwarg through it would have changed that contract, and _set_read cannot tell a first assert from a re-assert anyway. So the decision is made where the knowledge is:
- the last successfully written value is latched on the driver;
- an equal value logs at DEBUG, a different one at INFO;
- the register is still written every time — only the logging is conditional, and a test asserts the write count, because a re-assert that stopped happening would drop the Flux floor;
- the latch clears on a failed write (which is already an ERROR naming the register), on
disconnect(), and wheneverread_discharge_limit()disagrees with it. That last one is what keeps the register auditor loud: it only writes after reading a wrong value, so its correction is a real change and must not be hidden by a stale latch.
Eight tests in test_sigenergy_modbus.py. Three mutants were checked against them before the change was trusted — dropping the condition, dropping the read invalidation, and returning early instead of writing — and all three turn the suite red.
v5.110.0 — 17-09-2026
Also in 5.110.0 — two faults found watching the first live Flux peak (Claude, Flux session):
- The peak export dropped to zero every tick. The planned discharge limit moves with PV, so the target differed each tick and
FluxExecutor.stepran a full_apply, which neutralises first (both limits 0, mode 2). Sampled 16:08-16:10: mode 5 at 3.9 kW, then mode 2 with 0/0, then mode 5 again. Same mode and same cutoffs with new power now adjusts the two limits in place and re-verifies; any unacknowledged write falls back to the full apply. After the fix mode stayed 5. - Solar overflow held Flux off the peak (see v5.109.5 below; installed in this build).
-
The export limit chased live PV, then handed the peak back.
_export_plansized the discharge limit to “export cap less live PV surplus”, so it moved every tick, and read ZERO whenever the roof alone filled the cap — whichplan()treats as nothing worth selling, so Flux handed back and re-claimed (16:43-16:46). The limit is now full battery power in export, and the inverter’s own grid export cap (40038, 4 kW) holds the meter: in Remote EMS discharge it is hardware-enforced (11-Sep Axle event: 4.7 kW from the battery, 4.05 kW peak at the grid). The review replay test now models that cap instead of an unlimited drain. - The clean end of a window logged as a fault. A renewal issued in the last seconds is refused because its lease may not outlive the window — correct, and it happens at 19:00 and 05:00 every day. It logged “The inverter did not acknowledge the export command” at WARNING (live 19:00:33). A decision whose window has already ended now says so plainly at INFO; a refusal INSIDE the window still warns.
Also observed live, and acceptable as they stand: a plugin restart mid-export leaves mode 5 running until startup reconciles to the baseline, and Flux re-claims about 90 s later (16:12-16:14); pausing the manager mid-export released the claim before the pause wrote self-consumption (16:17:55). The window closed on its own at 19:00 and the inverter went back to self-consumption with the reserve at 20% and the cutoff at 1%; the battery finished at 71.4%.
FLUX IMPORT PRICED BY BAND, AND EVERY TIER PUBLISHED. CliveS, the day the account moved to paired Flux (agreements valid from 00:00 BST 17-Sep-2026): everything before the change stays as it was, everything after uses Flux, and the Costs page must show all three import and three export prices with their hours.
What was wrong on the first Flux day:
- Import had no weighting.
rate_today_pcame from the Tracker bucket, which is empty on Flux, so every consumer fell back to the tariffMonitorrateToday— the band live at the moment of reading. The same kWh were valued at 14.6p, 24.4p or 34.1p depending on when the page was loaded. Export was already weighted (5.109.x); import now uses the same code. - The Kraken ledger query never asked for
unitRates. Flux comes back asHalfHourlyTariffwith nounitRate, sofin.elec.unit_pandfin.export.unit_pwere None andelec_unit_rate_p(24.7275) andexport_rate_p/export_rate(12.0) froze. FLUX-EXPORT codes carry no OUTGOING either, so export classification now also accepts EXPORT in the code. elec_tariff_name/export_tariff_name/gas_tariff_namehad no writer (still SILVER-25-04-11 and OUTGOING).tracker_rate_todayheld 26.21 for ever, and the morning brief read it out.- Tomorrow’s surplus revenue used the export band live NOW (27.7p during the peak).
Changes:
_banded_rate_for_day(side, date)→ (pence, reason), one implementation forimportandexport(_BAND_SIDESmaps each to its slots key, halfhourly column and agreement valid_from)._export_rate_for_day_p/_import_rate_for_day_pare thin wrappers; all the existing refusals (not banded, before agreement, partial coverage, unreadable row) hold for both._banded_rate_and_basisadds basis “time-average” when the day is banded and nothing flowed that way: the money is zero either way, but a row with no rate reads as “rate missing”.- Daily record:
rate_today_p= weighted import on banded days (Tracker days unchanged) +import_rate_basis. - Live today card (
get_dashboard_data,_cost_vars_economics) values today’s kWh at_live_rates_today_p— today’s own weighting — whilesolar.export_rate_pstays the rate now. _band_tiers(side)→ distinct prices cheapest first with LOCAL windows, a band over midnight joined into one window,currentflags; [] unless the whole day tiles._tariff_sides_payloadaddstariff.import_side/tariff.export_side{name, tariff_code, banded, now_p, tiers, valid_from}.get_account_financials: queries HalfHourlyTariffunitRates;unit_p= rate in force; carriesunit_rates+tariff_codeon both sides._write_cost_variablesre-reads the band at write time (the ledger is cached 30 min) and writes the three tariff-name variables._write_tariff_schedule_variables:tracker_rate_today/tomorrow= “n/a” andtracker_fetch_status= “not on Tracker” when the active tariff is not Tracker; newexport_rates_today_json(billed export slots overlapping today).
Not changed, deliberately: settled rows still price Octopus’s day total at the row’s (now weighted) rate; history before 17-Sep is untouched.
15 new tests (import weighting, column separation, pre-agreement day, time-average, tiers incl. the midnight join and current flag, gap → no tiers, payload, Flux ledger parse). Sabotage checked: pointing import at the export column and disabling the midnight join each turn tests red. 1857 tests, 0 failures; ruff clean. Installed after the 19:00 peak, as 5.109.5 was planned to be.
v5.109.5 — 17-09-2026
ON FLUX, BANK TO 100% ONCE THE REST OF THE DAY CANNOT CLIP. CliveS: “surely it would be better to bank the extra solar into the battery to get it to 100% if the 95% is reached earlier than to sell it at 9.5p as that would reduce the amount … topped up overnight at 14p plus.” Right on Flux: export before 16:00 earns 9.7p; a banked kWh displaces a 14.6p cheap-window kWh (about 15.5p with losses), and the peak cannot absorb it — at 95% the spare already exceeds the 12 kWh the 4 kW cap allows in 16:00-19:00. The standing 95% rule was about CLIPPING (July, Tracker), which is only a risk while forecast PV can still exceed house + export cap.
BatteryManager._flux_clip_risk_passed(): standard Flux only; every remaining hour today offorecast_p50xbias_factor_todayxpv_tracking_factor, grossed up byFLUX_CLIP_GUST_FACTOR(1.25, a CHOSEN margin for hourly means hiding cloud bursts, not measured), must fit under that hour’s profile house use +max_export_kw. No forecast, no profile (house taken as 0) or any error -> False.- When True (and not a storm), the overflow pacing target becomes 100%; reason gains “(Flux: no clip risk left today, banking to full)”. The physics gate is unchanged.
- Memory rule
feedback_battery_target_soc_is_90_not_100updated with CliveS’s decision.
Found live in the first Flux peak and fixed in the same release: solar overflow held Flux off for the whole window. An overflow export engaged at 14:24 and _flux_other_owner() counted solar_overflow_active as an owner, so from 16:00 the supervisor stood down every tick, logging nothing, with the battery at 96% and only PV going out. Inside the peak (_flux_peak_now(): armed and 16:00-19:00 local) overflow is no longer an owner; the Flux export mode sends PV first, so nothing is lost. The supervisor now logs “Standing aside:
10 new tests; 1861 tests, 0 failures; ruff clean. Installed at about 16:07 to rescue the first peak, which the owner bug was blocking.
v5.109.4 — 17-09-2026
THE SAVING SESSION PUSHOVER, REWRITTEN TO THE NOTIFICATION RULES. The 12:50 push said “NOT OPTED IN — join it in the Octopus app, or it pays nothing” about EVENT_77, which Octopus had just refused to let this account join; it also carried em-dashes, “18:00-19:00 (1.0h)” and a capitalised shout.
- The body is sentences, ASCII only (
_ascii_plain), times via_clock_words(“6pm”, “4:30pm”, “midday”) and_session_day_words(“tonight”, “tomorrow”, “on Saturday 20 September”). - The title carries the answer: “you are in”, “join it in the Octopus app”, “not worth joining”, “joining shortly”, “not one for the battery”.
- Only asks him to act when he can: auto-join ON with a transient failure says the plugin will try again within the hour; a budget refusal says why it was not joined; only auto-join OFF sends him to the app. A session this account CANNOT join (join refused permanently, or capacity FULL) is not pushed at all, logged at INFO and marked notified.
- The “opted in: NO” log line is WARNING only when he must act.
- An unknown direction is no longer described as a Power Up. The Happy Hour body is ASCII-cleaned too. The export-rate figure is dropped: on Flux the rate depends on the band, so “added to what the export itself earns” is the true statement.
6 new tests, 4 updated; 1836 tests, 0 failures; ruff clean.
v5.109.3 — 17-09-2026
ON FLUX, THE SOLAR OVERFLOW CHARGE IS PACED TO 16:00, NOT DUSK. CliveS asked whether the export policy was the best on Flux; it was not. The overflow pacing (v3.8) was built for a flat 12p export, where WHEN a kWh leaves makes no difference. On Flux a kWh exported before 16:00 earns 9.7p; held, it sells for 27.7p (about 21p after the 94% round trip and 5p wear) or saves 34p of peak import. Pacing to dusk let the battery reach the peak lower than it needed to be, having sold the difference at 9.7p.
BatteryManager._overflow_pacing_hours()returns hours to 16:00 local on standard Flux before 16:00 (never beyond dusk), else hours to dusk.required_charge_kwdivides by that. The physics gate is untouched — it still decides WHETHER a day overflows, to dusk — so only the RATE changes and a day that cannot fill the battery behaves exactly as before. Intelligent Flux keeps dusk (different windows). Fails to dusk on any error.FLUX_PEAK_START_HOUR = 16.- The decision reason gains “paced to the 4pm Flux peak (N h)”.
- The gain is capped by the 4 kW export limit (at most 12 kWh in the peak, PV included) — probably under £1 on a sunny day; the daily Flux report and the 28-Sep bank-first review (now re-scoped to judge the gate on Flux prices, question 6) measure it rather than trusting that estimate.
4 new tests incl. a BST check; 1830 tests, 0 failures; ruff clean.
v5.109.2 — 17-09-2026
ANOTHER REGION’S SAVING SESSION IS NOT NEWS. CliveS: “if it does not concern us then we don’t need to be told.” EVENT_77_170926 (6pm, regions 8-12) produced a “NOT OPTED IN, join it” Pushover, a WARNING and an amber Dashboards chip for this region-F account, and the auto-join got OE-1308 “Account’s region is outside of the target regions for this event”.
- The session query now selects
targetRegion { regionId }(introspected live: a non-null list ofTargetRegionType; Weekend Happy Hours come back[], meaning everywhere). Parsed toevent["target_regions"]; absent -> None (unknown). GSP_REGION_IDSmaps the GSP letter to Octopus’s number (A=1 … P=14, skipping I and O). Not documented; confirmed on this account — all 13 region-listed sessions it joined include 6, and the refused EVENT_77 does not. Region F = 6._saving_session_for_us(event)is False only when we KNOW: ours is not in a non-empty list, or Octopus has refused a join on region grounds (saving_sessions_not_our_region, persisted). Unknown region or no list counts as ours, so a feed change cannot silence a real session. Applied to auto-join, the announcement loop,saving_sessions_next_startand theoctopus_sessions.upcomingdisplay list, which is what Dashboards reads — so the Dashboards chip and row need no change of their own.- A region refusal on an event whose list said it was ours logs one WARNING naming the mismatch, since that would mean the numbering is wrong.
6 new tests; 1826 tests, 0 failures; ruff clean. Live check of the new query: region id 6, 77 events, EVENT_77 regions [8-12].
v5.109.1 — 17-09-2026
FLUX’S FLOORS MOVE OFF THE ABSOLUTE CUTOFF, AND THE SITE LIMIT BECOMES A HARDWARE CAP. Supervised commissioning on the live inverter, manager paused throughout:
- 40040 (grid import cap) is enforced at the meter. Mode 3, charge limit 10 kW: grid import held at 1.00 kW, then 3.00 kW, house still supplied. The battery absorbs the cap. In mode 3 PV read 0 W — grid-first charging stops solar, so a stuck mode 3 costs a day’s generation (irrelevant at 02:00-05:00, noted for comms loss).
- No watchdog. 3.5 minutes with no Modbus traffic: mode 3 and the cap both held.
- 40046 (backup reserve) stops a forced export on-grid. Mode 5, 40048 at 1%: with the reserve 0.6% BELOW SOC and PV at 2.5 kW, the battery discharged 1.8 kW; with it 2.0% ABOVE SOC and PV 2.3-2.8 kW (2 kW of export headroom) the battery held 0 W for six minutes. The vendor manual says it does not apply off-grid, where 40048 governs.
So _FluxRawDriver.set/read_discharge_cutoff now address 40046, and _policy_discharge_floor_pct (reserve + commitments) is written there by the verify pass, the teardowns, the VPP restore and the disengage path — only while Flux is armed, so an unarmed install keeps its installer’s reserve. _absolute_cutoff_pct() owns 40048: health floor, or a live flood-prevention target, and nothing economic. The VPP dispatch floor stays on 40048 as before (its night dawn floor is unchanged pre-Flux behaviour). With the site limit verified and Flux armed, the verify pass asserts 40040 to it.
The first plan after a restart no longer uses Tracker. The first manager tick ran before the first Octopus refresh, so _build_tariff_data fell back to Tracker — live on Flux the trace read tariff=tracker after each of today’s three restarts. The hardware ACTION now waits for the tariff (TARIFF_WAIT_S, 300 s); evaluation, Flux pre-emption and the device state still run, and after the wait it acts as before so an Octopus outage cannot stop the battery being managed.
Result: a lost connection mid-export leaves the reserve on 40046 — grid-tied discharge still stops there, and a power cut that follows can use the whole battery. Stale source-count guard in test_plugin.py (3 -> 4 _rates_for_tariff calls, from the dashboard fix) corrected. 1820 tests, 0 failures; ruff clean.
v5.109.0 — 17-09-2026
THE NATIVE OCTOPUS FLUX CONTROLLER, AND THE FLAT 12p THAT HAD BEEN REPORTING EVERY EXPORT. CliveS: “We are now live on Flux incoming and Outgoing so please liase with Claude and implement the change over.” Built as a two-agent draft in an isolated worktree — Claude Code wrote the planner and the plugin integration, ChatGPT Astra/Codex wrote the acknowledged executor, then reviewed the integration and corrected the final call paths. Three review rounds; every finding was checked against the real call ordering before it was called fixed, and two of my own round-3 completion claims were wrong and are corrected below.
Three new modules. flux_strategy.py is the planner: pure stdlib, no Indigo, no network, no clock of its own, so a whole Flux day runs in milliseconds under test. flux_execution.py (Codex) holds a durable journalled claim on the inverter and verifies every register it writes. The plugin.py Flux section joins them — builds the planner’s inputs from live state, decides who owns the inverter, and leases each decision.
The charge is a chronological budget, not a daily total. A half-hourly simulation from 05:00 to the NEXT 02:00 works out the least energy the battery must hold at the end of the cheap window to carry the house and every committed grid event without dipping below the reserve — PV serving the house directly before anything reaches the battery, both power limits binding per step, one-way efficiencies whose product is the configured round trip. A day-total subtraction nets a sunny afternoon against a six o’clock breakfast and buys nothing; there is a test that pins exactly that pair.
Household demand and event commitments are energy, not buffers. A flat 20% reserve, no 2 kWh contingency anywhere. Axle windows and joined Octopus sessions become EventCommitment records reserved from the moment they are ANNOUNCED — announcement is when the cheap window needs to start buying, so it deliberately does NOT stand Flux down. Overlapping commitments are combined by the union of their demand, never the sum: two schemes can pay for the same exported kWh and the house exports it once. One exported kWh earns the ordinary tariff revenue AND any event payment, both real, neither double counted, and an event reward is never added to the export price that decides a discretionary trade.
Two floors, because one is wrong. The household floor (reserve plus commitments) is what the house may not eat into; the export floor adds the whole forecast household need to the next cheap window and is what a discretionary sale may not sell through. A single floor protecting all future household consumption FROM the household would leave the battery full and the house importing at the day rate.
One owner for the hardware discharge floor. _policy_discharge_floor_pct() is now the single source for register 40048 and every writer reads it — the verify pass, the export teardown, the flood-prevention reset, the VPP raise and restore, the pause/sleep disengage, the scheduled import and the Flux release baseline. Before this each wrote batteryHealthCutoff directly, so the verify pass pulled the floor back to 1% within a minute of Flux handing back. Flood prevention may raise that floor but may not drain through it. Storm reaches the planner only: it raises a software dawn target and never writes the register, and a storm floor written to hardware is one nothing would lower.
A dispatch is never reserved against itself. Axle writes the discharge cutoff ONCE, at PRE_CHARGING, thirty minutes before the window and while the state is still ANNOUNCED — so the event is passed explicitly into the floor calculation, its own allocation is removed as union(all) minus union(serving), and only an unserved overlap tail stays reserved. Flux releases BEFORE that cutoff write, and the following transition is marked pre-empted so it cannot restore an older baseline over the new floor. The shortfall warning quotes the floor actually installed.
Startup no longer disarms a backstop it has not read about. _init_modules lifted both power limits and the charge cutoff to 100% immediately after connecting. After a crash in mode 3 with a cutoff at 80%, that removed the only thing stopping an unattended charge to full, before the claim journal had been read. Those three writes are skipped whenever a claim needs recovery. A preference save replaces self.modbus, which stranded an existing executor on a dead adapter; FluxExecutor.rebind() swaps it with validation and requires fresh reconciliation.
Proof, not a probe. Trading arms only on the ACCOUNT’s own agreements — both sides, from one response carrying its own timestamp, each live now rather than the last in the list, on the configured meter or a single unambiguous one. Fresh public prices can no longer keep stale account proof alive, a failed account read clears the evidence rather than leaving it standing, and the rates come from the BILLED product code, never from _probe_product_by_prefix, which returns the most recently launched product and on a re-versioned tariff is not what the house pays.
EXPORT WAS STILL BEING REPORTED AT A FLAT 12p, AND NOTHING HAD EVER POPULATED IT. latest_rates_data["export_rate_p"] had no writer anywhere in the plugin, so the dashboard, the manager snapshot, the VPP revenue estimate and every daily history record fell back to DEFAULT_EXPORT_RATE_P — correct while the account was on Outgoing at a flat 12p, wrong the day it moved to paired Flux. It is now published every rates refresh from _export_rate_now_p(), which reads the account’s own export schedule. And a day’s exports are valued at the bands they were actually sold in: _export_rate_for_day_p() weights the published bands by the half-hourly export the plugin already logs, because the same day’s export is worth two and a half times as much at 17:00 as at 03:00. It returns None rather than guessing — for a flat tariff, a missing series or a day with nothing exported — and each record carries export_rate_basis so a reader can tell an exact figure from an approximate one.
TWO MORE FAULTS FOUND THE DAY THE ACCOUNT WENT LIVE. _rates_for_tariff fell through to the Tracker bucket for every time-of-use tariff, so live on Flux the status line and the tariff device both read “Nonep” — a TOU bucket holds cheap/standard/peak and none of them is today_p. It now reports the band in force, compared in LOCAL wall time, with the wrapping iFlux window handled. And register 40048 is absolute — the inverter honours it off-grid — so the 20% economic floor would have locked ~7 kWh away during the very power cut the reserve exists for. _policy_discharge_floor_pct() now drops to the health floor during a VERIFIED outage (two sources must agree: the power_cut_started_at flag, which only a genuine Off-grid status can set, and a fresh live reading that still says off-grid), nothing economic can raise it back, and the outage is the highest-priority owner so the Flux claim is released before the lowered floor is written. Backup reserve belongs on 40046, which is set to 20%; batteryHealthCutoff stays 1%, which is what makes the release mean anything. If the host loses comms the plugin cannot release the floor at all — there is no hardware watchdog.
Everything refuses rather than guesses. Either switch off, commissioning not signed off, the account unproven or stale, rates missing or non-contiguous or averaged across a product change or on a different clock, the forecast not covering the hours the decision turns on, the profile malformed or a week old, SOC or flows stale or stamped in the future, any input NaN or infinite or a bool where a number belongs, the site import limit unverified, or no spare site import capacity. All produce a logged defer and no write.
1812 tests (planner 80, supervisor 157, Codex’s executor 24 and independent review contract 29). Ruff clean, PluginConfig parses, version consistency green. Ships with fluxEnabled, fluxCommissioned and fluxSiteImportVerified all false — it does nothing whatever until a person ticks them, and the commissioning checklist is docs/flux-controller.md. No hardware watchdog exists and none is claimed; the comms-loss behaviour of this inverter is unmeasured, and that is a supervised test on the checklist, not a software gap.
v5.107.0 — 15-09-2026
DO NOT EARN A TOKEN THAT CANNOT BECOME AN HOUR HE WILL USE. CliveS:
“I intend to leave the free hour slots until we have a low solar day as topping up when solar is good defeats the object, it takes 2 tokens for 1 happy hour and these need to be used by 1 november so can you make sure i do not have a saving session if the number of tokens would be too many to use before the 1 november”
A Power Down is run for the Weekend Happy Hour it pays towards (v5.106.1). Octopus end the scheme on 1 November 2026 and unused hours are lost, so past a certain point another session earns a worthless token while spending battery and selling units at a net loss.
The guard
_happy_hour_token_verdict(session_start, now_local) returns (worth, reason), checked in _auto_join_saving_sessions before the join, because the join is one-way. Three legs, in order of certainty:
- The scheme has ended. Nothing to weigh.
- This session settles too late. Octopus take about three working days to score a session and bookings close before the weekend, so
HAPPY_HOUR_SETTLEMENT_DAYS = 5(calendar, the pessimistic reading) is added to the start and the result must still reach a weekend inside the scheme. - He already holds enough tokens.
happy_hour_slots_left(day)counts weekend days in[day, end), groups them by their Saturday and allowsHAPPY_HOUR_MAX_PER_WEEKEND = 2.
Leg 3 needs his judgement, not arithmetic. The derived ceiling from 15-Sep is 7 weekends x 2 = 14 hours, which will never bind, and he will only spend an hour on a low-solar weekend day — how many of those there will be is weather. So happyHourUsableHours states the realistic figure and is capped at the derived maximum, whatever is typed. Blank leaves only the calendar legs active, so the feature is inert until he opts into it.
HAPPY_HOUR_SCHEME_END is exclusive, though 1 November 2026 is itself a Sunday and Octopus write “use them all by 1st November”. The wording is ambiguous and the two readings fail in opposite directions: assume it counts and be wrong, and a token is earned that can never be spent — the exact thing this exists to prevent. Assume it does not and the worst case is one cautious refusal.
A budget refusal is never added to saving_sessions_join_refused: that set is for Octopus refusing permanently, whereas this verdict changes as tokens are spent and as the calendar moves. It is re-asked every poll, with a warn-once latch so a declined session says so once rather than hourly.
The alert had to be taught to agree with it
The alert loop and the join loop are separate, so the same Pushover would have announced a session the plugin had just declined AND told him to opt in by hand. It now reports the refusal instead. The verdict is pure, so it is re-asked rather than carried in state and the two cannot drift apart. A session joined by hand with auto-join off is never second-guessed — that decision is his.
Mutation testing, and what it actually found
Six deliberate breakages. Three killed, and the three survivors were the useful part:
- Replacing the entire guard call with
(True, "")survived. The budget tests exercised the helper directly and nothing asserted the join ever asks it — testing the logic is not testing the dispatched path. Three wiring tests added; the mutation now fails. - Relaxing the
from_day >= endearly return to>survived, and is equivalent — thewhile day < endloop already returns 0. Noted in place so the next reader does not think it load-bearing. - Swapping the Saturday grouping for ISO weeks survived, and is also equivalent: ISO weeks run Monday to Sunday, so a Saturday and the next day always share one. The comment justifying the Saturday anchor claimed otherwise and was simply wrong — corrected.
Also
TestAutoJoinSavingSessions was anchored to datetime.now(). Since the join now consults a real calendar deadline, that suite would have begun failing by itself on 1 November 2026 — every event “in six hours” past the scheme end, every join correctly refused, nothing to do with the code under test. It runs on a fixed NOW of 15-09-2026, and the auto-join derives the local day from the now_utc it was already given rather than reading the clock itself.
Also: aliasing a @staticmethod into a test stub as a bare class attribute rebinds it as an instance method, so the stub arrived as the first positional argument and failed as a TypeError on a date comparison that said nothing about the cause. staticmethod() round the alias.
1494 -> 1511 tests.
v5.106.1 — 15-09-2026
THE POINTS ARE NOT THE POINT. CliveS, the same day v5.106.0 shipped:
“The reason i export at an Octopus Saving Session is it gives me a 1 hour free token, 2 needed, for an hour of free electricity on a Saturday or Sunday, the export amount I earn is not the reason for the export, it is secondary.”
Octopus’s own help article agrees: “if you manage to use less electricity than you’d normally use in two Power Down sessions, you’ll earn one Weekend Happy Hour” (read 15-09-2026). So a session is worth half a free hour, and the Octopoints are change.
v5.106.0 had corrected a real error — the alert published a raw OctoPoints figure that reads like ten times what it is — and then made a second one by treating the corrected number as the case for the feature. It closed with “nothing like an Axle event, so the battery is only used for it once tomorrow is already covered”: true about the cash, and it frames the session as barely worth running.
- The alert now leads with the free hour, reports token progress when Octopus supplied a balance (never guessed —
_check_saving_sessionsoverwrites the store key fromtoken_balanceon every fetch, so a seeded store proves nothing), and puts the pence last. - The Saving Session decision reason names the token before the rate.
- The operative consequence: winning matters more than volume. A session that misses the baseline earns no token however many kWh went out. Nothing in the dispatch changed here —
saving_session_exportable_kwhalready maximises the export the reserve allows — but the reasoning is now written down where the next reader will find it. - New
_happy_hour_expiry_note(): Octopus run the scheme until 1 November 2026 and say unused hours “will disappear”. Dated, so it counts down inside 45 days and returns “” once the date passes rather than nagging about an ended promotion. It is a vendor promotion and it will rot — re-read the page rather than trusting the constant past it.
Also fixed a dead total_p local and three f-strings with no placeholders, all introduced by v5.106.0 and caught by ruff. 1491 -> 1494 tests.
v5.106.0 — 15-09-2026
THE BIAS FEEDBACK LOOP HAD BEEN DEAD SINCE 5-SEP, AND THE CORRECTION WAS SCALING A FALLING FORECAST UP. Raised by CliveS asking why 14-Sep peaked at 90.3% SOC against a 95% target after a 41 kWh forecast delivered 36.43 kWh. Four faults; the first is the root.
1. record_accuracy was graded against a counter the inverter had already reset
_check_midnight_impl passed self.store["pv_daily_kwh"] — the live mirror of the inverter’s own daily accumulator. The inverter resets it at ITS midnight, and the task runs at 00:00:0x, by which time a poll has copied the zero in. 9 of the 10 days to 14-Sep-2026 recorded actual_kwh: 0.0.
The damage is not the obvious one. _compute_correction_bands filters 0.1 < factor, so a zero never reached a band — it STARVED it. The window is the last 60 RECORDS, so a run of zeros pushes real samples off the end. On 15-Sep the 40 kWh band held twelve samples, every one dated 17-Jul to 31-Aug, and returned x1.0428 over a September measuring 0.888 (489.3 kWh forecast against 434.5 actual across 14 days). The correction was scaling a falling forecast UP, and nothing in the logs said anything was wrong.
- Now reads
_energy_day_totals(yesterday)["pv"]— anchor[next day] − anchor[day], settled and exact, and the same figure_write_daily_historyuses four lines later. - A missing or zero total SKIPS the record with a WARNING. A zero is what broke this; it can never be the repair.
- New
OpenMeteoForecast.repair_zero_actuals(lookup), called once at startup againstPlugin._settled_pv_for_date(a cached index ofdaily_history.json, partial days excluded — grading a forecast against a projection is the same class of error). Repaired records carry"repaired": True. - MEASURED against the live file before shipping: 9 records repairable, all 9 repaired, and the 40 kWh band moves 1.0428 -> 0.98. That turns 14-Sep’s raw 40.4 kWh from a corrected 41.9 into 39.5, against a measured 36.43.
- A median band needs a MAJORITY of the window to turn before it moves at all. Six repaired days against twelve summer ones move it not at all. Pinned by its own test, because it sets how fast this fix can possibly work — it is necessary and is not instant.
2. The small -> big promotion had no hysteresis
v5.79.0 shipped the hold; the 05-Sep-2026 fix made the classification re-derive in BOTH directions, correctly (a one-way arm-to-small latch had held 04-Sep for its whole length while it clipped for 101 minutes). That symmetry introduced the mirror fault.
14-Sep armed SMALL at 08:00 on a raw 37.4 kWh. The 08:50 fetch read 40.7, which cleared the 40.0 threshold by 0.7 kWh. The hold released at 08:52 after 47 minutes and 0.001 kWh withheld, and 14.9 kWh went to the grid between 09:30 and 15:00 with the battery capped at ~1.1 kW by the overflow pacing.
MEASURED from six days of plugin logs (10-15 Sep 2026, 06:00-15:00): the raw forecast wanders a median 5.7 kWh across a morning (max 9.0), and its largest single upward step is a median 3.0 kWh (max 7.4). 0.7 kWh is noise, and only a margin can tell noise from a revision — the same lesson that moved this gate off a 1.0 kWh margin in the first place.
BANK_FIRST_PROMOTE_MARGIN_KWH = 5.0, one-sided by design: promotion needs the margin, demotion to small stays immediate. Holding export back is the cheap error (clip-boundary minutes have been 0 on every held day here); selling early costs 13-18p on every kWh. 04-Sep’s genuine 31.0 -> 46.0 revision still promotes, because 46.0 clears 40.0 + 5.0. Six days is a thin sample — revisit against a fuller season. 3/3 mutations killed.
3. The pacing shadow ran ZERO times between 31-Aug and 15-Sep
_record_solar_overflow_shadow guarded itself with abs(live_target - 90.0) > 0.01: return. The instinct was right — never label a different comparison 90/95 — with no handling of the consequence: the experiment it guarded was the argument for moving the live target to 95, so the moment that argument WON, the guard fired on every tick for ever.
samples: 0 on every daily record from 1-Sep, with no reason recorded, while the block published the hardcoded literals live_target_pct: 90.0 / shadow_target_pct: 95.0 — a comparison that was not being made, described with numbers that were no longer true.
- Both targets are now READ. New
solarOverflowShadowTargetSocpref (default 90.0), so the live 95 is graded against 90 rather than against itself. - Equal targets are not a fault but must not read as one:
skipped_reasonis recorded and surfaced by the menu, so a zero-sample day says which kind of zero it is. - The delta’s sign is derived from the pair.
max(0, live - shadow)was only correct while live was the LOOSER target; once live moved to 95 it could only ever produce 0.0, so a working shadow would have recorded a run of honest-looking zeroes.
4. classified_small / classified_from_kwh described 23:59, not the classification
Both were read at midnight from the latch and from latest_forecast_data as they then stood. 14-Sep filed as classified_small: false, classified_from_kwh: 41.3 — neither the verdict that governed the morning nor the number it was reached from. Added first_classified_small, first_classified_from_kwh, first_classified_local and promoted_local; the menu prefers them and marks a promoted day *. The old keys stay, named for what they actually are.
NOT DONE, deliberately — both measured first
- The 40 kWh threshold was NOT raised to 45. The bank-first spec’s pre-registered criterion is that clip-boundary minutes mean the threshold is too HIGH. There are 123 of them across 15 recorded days, 101 on 04-Sep alone — the data says down, not up. Raising it would have held 04-Sep, which clipped for 101 minutes with the battery already full.
- Stage 3’s arming latch was NOT shipped. Replayed over the 15 days of recorded telemetry it arms on 13 of 15 days — 100% of the days whose measured peak 30-minute surplus exceeded 4 kW (criterion: >=90%) and 0% of those below (criterion: <10%). It passes its ship criterion and would have changed nothing, including on 14-Sep, whose peak surplus was 6.55 kW. A no-op that looks like a fix is worse than an absent one. The real discriminator in the data is peak surplus — the two worst finishes (13-Sep 91.1%, 14-Sep 90.3%) are the two lowest big-day surpluses at 5.30 and 6.55 kW, against 8.92-11.64 for the three that finished 94-98% — but five big days cannot set a threshold. Left for the booked 28-Sep review, which now has honest telemetry to read.
Also — OctoPoints are an eighth of a penny
Octopus states it on its own Octoplus page: 800 OctoPoints = £1. A session at 85 points/kWh therefore pays 10.6p/kWh, not the pounds an Axle dispatch pays. OCTOPOINTS_PER_PENNY and octopoints_to_pence() in octopus_api.py, imported by battery_manager rather than re-declared. Every human-facing site prices it: the Pushover reads “about 11p a unit on top of the usual 12p”, and the Saving Session decision reason carries the rate and the cash. The branch’s economics are otherwise unchanged and were already sound — it exports only when the engine’s own import_needed says tomorrow is covered without buying.
1472 -> 1491 tests.
v5.101.0 — 10-09-2026
THE POWER-CUT RESERVE IS KEPT ON AGILE, THROUGH THE BLOCK PLANNER. The decision CliveS took from the readiness review’s triage entry (option (a)). _check_resilience_buffer fired on flat tariffs any time overnight and on Go/Flux inside the cheap window, and returned None on Agile — so from 1 October winterBufferPct (20%, _apply_seasonal_override Oct-Mar) and a storm’s raised dawn_target_pct would have done nothing.
- The Agile top-up is a dawn projection, not a now-floor. The flat rule tops up to the floor NOW and re-fires whenever SOC drops under it again, which costs nothing extra on a flat rate. On Agile a top-up is one cheap block, so
_agile_reserve_shortfall_kwhsizes it from the balance’sbattery_at_dawn_kwh: the block carries the drain between its end and dawn as well. The battery may sit under the reserve in the evening before the block — the same trade Go/Flux make with their window — and holds it from the block to dawn. - Not gated.
_plan_agile_import(purpose="reserve")skips the round-trip comparison: resilience is not arbitrage, and neither the flat nor the TOU branch ever priced it.Decision.import_purposecarries the difference; the hold notice is worded from it. - One block on a deficit night.
_plan_agile_import_with_reservebuys the larger of the deficit and the reserve shortfall, and if the gate turns the deficit down the reserve is planned on its own regardless._plan_agile_reservereturns None whileimport_neededis True, so the two branches never plan the same block — pinned by the audit trail. - MIN_IMPORT_KWH, daytime and “already held” all leave it alone, as before.
test_agile_readiness.py +12 (29 -> 41 in the file), 8 failing against the pre-fix code; the three that passed are the both-sides guards. Mutation sweep 12/12 after two fixture repairs — a reserve fixture whose cheapest block passed the gate on its own could not see a gated reserve, and a sunny daytime fixture had no shortfall for a daylight planner to buy. Both now carry a value that would move the answer. 1306 -> 1318. PluginConfig’s dawnSocTarget help names all three behaviours. NOT RESTARTED; override left ON.
v5.100.0 — 10-09-2026
THE AGILE IMPORT PATH, MADE SAFE BEFORE IT SPENDS REAL MONEY. Adversarial review of _plan_agile_import and everything around it, three weeks before the 1-October switch (brief: docs/agile-readiness-review-brief.md; report with the numbers: docs/agile-readiness-review-2026-09-10.md). The 07/08-Sept rehearsal proved the plumbing and could not reach the decision — a September battery has no deficit — so this is the first time the money path was exercised, and it was exercised on 7,248 real region-F half-hours (Oct-2025 to Feb-2026, product AGILE-24-10-01, pulled from the public API on 10-09-2026) rather than on a two-band fixture. Reproducible: python3 scripts/agile_replay.py.
Findings, in money order:
- The single-cheapest-slot rule was a spike-down trap (CONFIRMED, £24.79-£61.63 a season). The planner picked the cheapest half-hour before dawn and the executor charged forward from it to target. On Agile that half-hour is often the tail of the trough or a dip on the morning ramp: 29-Nov-2025 it was 06:00 at 9.64p, and the four half-hours from there cost 13-18p while 01:30-03:30 sat at 9.8-10.6p. Replayed over 150 nights at 20 kWh / 9.5 kW: single-slot £421.16, cheapest contiguous block £396.38, N cheapest non-contiguous £390.36 (needs a stop/start executor; not built, £6 residual). At 30 kWh a night the gap is £61.63; at a 6 kW cold-battery charge rate £44.06.
_plan_agile_importnow prices every candidate block — n half-hours from the GRID-side need atinverter_max_kw— requires every half-hour of it to be published, starts where the block is cheapest, and the round-trip gate judges the block MEAN, which also closes the case where a 20p slot passed the gate for a block averaging 26p. - Four branches imported 10 kW with no price (CONFIRMED, up to ~£5 an occurrence, the evening peak). No slots, no future slot before dawn, no “safely reachable” slot, unknown tariff — and all of them reachable on the REAL path, not just the override: a failed
_probe_product_by_prefixlogged at DEBUG and returned None,get_agile_ratesreturned [] silently,get_all_monitored_ratesreplaced the slots the planner already held with that [], and the planner bought at whatever the price was. Now: the probe WARNs; Agile uses the account’s own product code (the listing was a guess for the newestAGILE-product, which also matchesAGILE-OUTGOING-*); an empty fetch keeps the last good slots, with one WARNING per outage — a stale list is self-limiting because slots carry their own times, an empty one is not; and the planner HOLDS on self-consumption withimport_held=True, whichplugin.py._note_import_holdturns into a WARNING and one Pushover a day. Passthrough is the baseline the gate already prefers on flat days and cannot cost more than an unknown price. The reachability filter is dropped outright: a battery that meets its floor before the cheap block costs the house ~0.3 kWh/h of grid at the evening rate, not 19 kWh at it. - The reference-rate fallback was the current half-hour (CONFIRMED, harmless in winter, wrong in principle). Before ~16:00 tomorrow’s daytime mean is unpublished and the gate fell back to
today_rate_p— on Agile the slot in force NOW. Over 2,284 daytime half-hours it flipped the verdict in 297 (13%), every one a refusal on a cheap or negative current slot; the overnight decision after 16:00 was never affected (0 of 150 declines under any reference — winter Agile nights always clear the gate). Fallback is now today’s daytime mean, the same 12-hour window one day earlier: 0 flips. And the slot in progress is a candidate (start + 30min > now, notnow < start), so a midday plunge is bought while it is happening instead of declined against itself. - What ends an import (CONFIRMED, the brief’s question 1).
ACTION_STOP_IMPORThas not been returned bybattery_managersince the v4.0 sufficiency model. The teardown lives in_act_on_decision’s SELF_CONSUMPTION branch (target reached) and the unconditional target check at its foot, and both restore the charge cutoff — so the dead branch was a leftover, removed. BUT four OTHER exits fromimport_activecleared the flag without_restore_import_cutoff(): solar-overflow entry, Force Export, Set Self-Consumption and Return to Local EMS._verify_ems_registersre-assertsimport_charge_cutoff_pctevery minute, so an import interrupted by dawn left 40047 pinned at target+3% as the PV charge ceiling for the rest of the day. All four restore it now. - A schedule armed mid-import (CONFIRMED, small). The window excluded the slot in progress, so while importing the planner re-emitted SCHEDULE for the next half-hour; stored, that time outlived the import (nothing clears it on completion) and could fire a second charge seconds after the first completed — with
import_target_socalready 0.0, so a 12% cutoff and little energy, but a mode write and a wrong log line. SCHEDULE no longer arms whileprev_import. - Negative prices (this is fine). Sorting by rate puts the most negative first,
rate / efficiencyon a negative number stays negative, the gate passes, and the planner buys the deficit only. Trading beyond it is the offlineexperiments/agile_trading/work, deliberately not wired in. 54 negative half-hours over 7 days in the replayed winter. - Interactions (this is fine, one decision for CliveS). VPP and Saving Session outrank the import in
evaluate(), and_check_scheduled_importholds a queued import through PRE_CHARGING/ACTIVE; flood prevention needs a 3x-demand forecast, which has no deficit; bank-first is priority 5, below import. The gap:_check_resilience_buffercovers flat and TOU tariffs only, so on Agile the winter power-cut reserve (winterBufferPct, 20%) is not maintained at all. Nothing broken and no money at stake; queued inTRIAGE_QUEUE.mdas the policy choice it is.
Tests 1277 -> 1306: test_agile_readiness.py (29), every one run against the pre-fix code first (21 failed, the 8 that passed are the both-sides guards); TestAgileBreakEven’s fixture made contiguous half-hours, because a block planner cannot price hourly points. Mutation sweep 20/20 killed, 0 skipped, __pycache__ cleared before every run. Nothing here touches a register until the next deficit, and the rehearsal override stays ON.
v5.99.3 — 08-09-2026
THE BANK-FIRST “SMALL DAY” CLASSIFIER LOCKED ONTO WHICHEVER FORECAST ARRIVED FIRST, AND NEVER CAUGHT UP. Found by the bank-first-week-one-check scheduled task’s routine one-week review of the 31-Aug-2026 feature. _record_bank_first_metrics armed bank_first_small_latched from the first COMPLETE, correctly-dated forecast that read under the threshold — a genuine fix at the time (v5.88.0) for a day-rollover bug — but nothing then re-checked it. Open-Meteo revises a day’s total several times through the morning, and on 04-Sep-2026 the overnight fetch read 31.0 kWh (small), armed the day, and never released it even after a later fetch revised the total to 46.0 kWh — a genuinely big day, held for its whole length regardless. Cost: 101 minutes with export pinned at the DNO cap while the battery had already stopped taking anything — exactly the waste clip_boundary_minutes exists to measure. Live evidence, from daily_history.json’s bank_first block:
| Date | final pv_forecast_kwh | classified small | clip_boundary_minutes |
|---|---|---|---|
| 04-Sep-2026 | 46.0 kWh | yes (wrongly) | 101 |
| 05-Sep-2026 | 48.4 kWh | yes (wrongly) | 4 |
Fix: the classification now tracks the FRESHEST complete, correctly-dated fetch, in EITHER direction, instead of arming once and sticking. A partial, failed, or wrong-day fetch still changes nothing — that protection (against one bad reading deciding the afternoon) is exactly what it was before and is unchanged; the only thing that changed is that a GOOD fetch, however many came before it, now always wins. battery_manager.py’s _overflow_bank_first_blocked was not touched — its same-tick raw_today fallback beside the stored flag was always correct and stays exactly as it was; only the two stale comments describing the old one-way semantics were corrected. The 40 kWh threshold itself is not the problem and was NOT moved — every day in the week-one sample that classified correctly clipped 0-6 minutes, negligible; the 101-minute outlier traces entirely to the misclassification.
4 new tests (test_a_later_complete_forecast_above_the_threshold_clears_the_classification, its mirror arming the day mid-day instead of only on the first tick, and two guards proving a partial/wrong-day fetch cannot clear an already-small day). All four verified failing against the pre-fix code (sabotage re-applied the old one-way arm, __pycache__ cleared, exactly one test went red — the one pinning the live bug — restore verified byte-identical). 1277 tests, 0 skipped, ruff clean. NOT RESTARTED — CliveS restarts this one (it drives the battery), so the running host stays on 5.99.2 until he does.
v5.99.2 — 07-09-2026
A <Description> NEVER WRAPS, so the longest one sets the dialog’s content width. v5.99.1 fixed the control column; the prose beside it was still clipped, because this is a second and independent mechanism.
MEASURED, by shortening the single 403-char Description and reopening the dialog:
| window | widest label frame | |
|---|---|---|
| before | 1041 (hard max) | 2890 |
| after | 741 | 712 |
So a 403-character Description stretched every row to 2890 in a window that cannot exceed 1041 — 1849 pt of overflow, and every line of help text in the dialog clipped mid-word. Dashboards, whose longest Description is 54 chars, measures 696/666: a clean fit.
The JSON example was the obvious suspect and was NOT the cause — siteArraysInfo carries a 131-character unbreakable token, and breaking it with spaces changed the width by nothing at all (2890 before, 2890 after). Tested before fixing, which is the only reason a wrong “fix” did not ship.
axleScanMail (403) and happyHourTokensRequired (255) moved from <Description> to type="label" fields, which DO wrap. No Descriptions remain in this dialog.
New TestDescriptionsStayShort caps a Description at 100 chars, with the measurement and its basis in the comment. 1267 -> 1268 tests.
ESTATE SWEEP (07-09-2026): 78 over-long Descriptions across 13 OTHER plugins, worst Zigbee2MQTTBridge at 781 chars — over twice this one, so its dialog is worse. Control <Label> lengths estate-wide are CLEAN: 1369 fields scanned across 39 bundles, longest 94 (GarageDoor lightOnlyIfPresent), so v5.99.1’s fault was unique to this plugin. —
v5.99.1 — 07-09-2026
NOT ONE CONTROL IN THE CONFIGURE DIALOG WAS ON SCREEN. All six pop-up buttons measured at window-local x=1425 (System Events, all six identical) in a window whose width is hard-locked at 1041 — set size was refused from both 1200 and 2000. Every field, checkbox and menu was 384 pt past the right edge, no horizontal scrollbar, green zoom button inert, edge-drag scrolls the content instead of resizing. The dialog opened, looked normal, and could not be used.
Cause, straight from official-plugin-xml.md: “each of those Label elements is right aligned and the actual control is left aligned directly to the right of the label” — so the WIDEST control <Label> sets the control column for the entire dialog. Five had grown into paragraphs: ledgerStaleDays 231 chars, axleVppRatePerKwh 175, dawnSocTarget 163, gasKwhPerM3 141, winterBufferPct 134. Longest now 82 (siteLocationName), unchanged.
Prose moved into type="label" fields, which the same doc describes as the way “to communicate a much longer chunk of text - like instructions”. No pref, default, binding or code path touched.
A SIXTH WAS IN Actions.xml AND ONLY THE TEST FOUND IT — powerKw on Force Grid Export, 140 chars, which pushes that action’s own dialog the same way. It was never going to be found by looking at PluginConfig.xml, which is where the fault was reported.
New TestControlLabelsStayShort in test_config_xml.py caps a control label at 100 chars and carries the measurement basis in its comment (231 -> x=1425, window max 1041, measured 07-09-2026). It asserts its own scan found >20 labels first, so it cannot pass vacuously. Mutation-checked: a 150-char label turns it red, and the restore is byte-identical.
1265 -> 1267 tests. The Indigo client caches plugin dialog XML — a client restart is needed before the new layout appears. —
v5.99.0 — 07-09-2026
The shared export driver asked a flag that only the VPP state machine ever writes. _drive_vpp_export read store["vpp_is_daytime"], set exactly once, in _vpp_transition on entry to VPP_ACTIVE. ACTION_SAVING_SESSION calls the same driver, so a session steered on the last VPP window’s answer — days old, and about a different hour.
Live cost, 07-Sep-2026: the session ran 18:00-19:00 BST with the flag stale-False from the 15:12 restart, so the driver chose night_export (0x06). PV read 932 W at 17:00:45 UTC and 0 W at 17:01:06 — twenty-one seconds after the mode commit — and stayed at zero for 57 minutes, with sunset at 18:42 UTC. Roughly 0.3-0.5 kWh curtailed (estimated from the 932 W at the commit decaying to the 25-66 W measured an hour later under 0x05; the uncurtailed case was never run, so it cannot be measured exactly).
New _export_is_daylight(now_utc=None), read at drive time, never latched. _drive_vpp_export(now_utc=None) takes the clock so a test can drive it. UNKNOWN RESOLVES TO DAYLIGHT — the modes are not symmetric: daytime_export’s own docstring records that at PV == 0 mode 0x05 behaves exactly as 0x06, so guessing daylight in the dark costs nothing and guessing dark in daylight costs the array. _event_is_daytime keeps its night-is-safe fallback; its other callers ask a different question (the discharge floor, and whether a zero-PV window earns a “curtailed” verdict) where the unwarranted daylight answer is the expensive one. The latched flag stays for the post-window summary, which fairly asks “was this a daylight window”.
AND THE VERIFY LOOP WOULD HAVE FOUGHT THE FIX. _verify_ems_registers gated on vpp_state in (VPP_PRE_CHARGING, VPP_ACTIVE). A Saving Session leaves vpp_state IDLE with export_active True, so it expected 0x06 and would have overwritten a bank (0x02) or daytime-discharge (0x05 + charge 0) window inside 60 s, then put the pinned charge cap back to inverter max — the v5.29.0 missed-dispatch failure, reached by a new door. _drive_vpp_export only writes the mode on a sub-mode CHANGE, so nothing would have healed it: a silently unpaid window with one WARNING line. New _driven_export_owns_registers() is the one owner of that question, used by both the skip gates and by _verify_vpp_export_registers, which had the same blind spot and so was not checking a session’s registers at all. Either fix alone still loses the window — the daylight fix would have chosen 0x05 and the verify loop would have taken it away.
Third, same family: the ACTION_SAVING_SESSION branch never claimed the driver’s sub-mode state the way _vpp_transition(VPP_ACTIVE) does, so a session following another session (both “discharge”) would write NO mode at all and “export” in whatever the last hand-back left — 0x02. Not persisted, so a restart hid it; 07-Sep worked because the 15:12 restart had cleared it.
1242 -> 1265 tests. 9/9 mutations killed, __pycache__ cleared before every run. The existing _drive_vpp_export fixtures now carry a real forecast and a real clock, with vpp_is_daytime set to the OPPOSITE of the truth so a regression to the latch cannot pass. —
v5.98.2 — 07-09-2026
Third site of the same bug, found by the same rehearsal. _update_tariff_device also did str(tracker.get("today_p", "")) for the active rate whatever the tariff, so the Tariff Monitor device — the one control pages read — carried rateToday = 'None' on Agile while the Battery Manager device beside it correctly held 18.543. Now goes through _rates_for_tariff like the other two.
And str(d.get(k, "")) returns the STRING “None” whenever the key exists holding None, which is every unmonitored rate. Live, that device was showing goPeakRate = 'None', trackerRateToday = 'None'. New _rate_str() renders absent as blank — which is what rateTomorrow and flexibleRate already did, so the device was inconsistent with itself. Zero and negative survive: on Agile both are real prices, and an absent-is-blank helper that also blanked 0.0 would hide a genuine settled slot.
A structural test asserts the device body contains no bare "value": str( and routes all eleven rate fields through the helper. The FIRST version of that test asserted 'str(tracker.get(' was absent — which also matches the CORRECT _rate_str(tracker.get(, so it failed on the fix it was guarding. The source-text-scanning trap, again.
1237 -> 1242 tests.
v5.98.1 — 07-09-2026
The rehearsal found a real bug within four minutes, which is the whole point of it. Switched tariffOverride to agile on the live system and the status line read [Octopus] Tariff: Octopus Agile (forced) (AGILE-24-10-01), today: Nonep, tomorrow: Nonep.
_refresh_rates did tracker = monitored.get("tracker", {}) and took the displayed price from there whatever the active tariff was. _build_tariff_data, forty lines away, had already been fixed for exactly this and carried a comment saying that falling through to the Tracker branch “would display a price from a tariff we are not on” — the log line never got the same treatment. Two consequences on real Agile, not just under the override:
- the price shows as
Nonefor ever, because the Tracker bucket is not filled when Tracker is not the active tariff; - worse, the change-detector compares
None != None, so the line effectively stops reporting. Had the Tracker bucket been populated it would have printed a Tracker price beside an Agile tariff name, which is the failure the existing comment warns about.
Fixed by giving the choice ONE owner: Plugin._rates_for_tariff(tariff_key, rates), used by both _build_tariff_data and the status line. A test asserts the source calls it exactly twice and that the direct Tracker read is gone, so they cannot drift apart again.
_tariff_line_changed lifted out too, and that was a finding about the TEST rather than the code: the first flood test re-implemented the guard locally, so mutating the shipped code left it green. Extracted and pointed at the real function. On Agile the price moves every half hour by design, so the key alone gates the line — otherwise 48 entries a day in the event log. The line now reads now 16.842p (this half-hour), 46 slots held.
1227 -> 1237 tests, 5/5 mutations killed after the survivor was fixed.
v5.98.0 — 07-09-2026
A tariff can now be rehearsed before it is switched to. The Agile path has existed since v5.44.0 and had never run once: agile_slots only populates when the LIVE account is already on Agile (octopus_api.py, the tariff_key == TARIFF_AGILE gate), so the first execution of detection, the half-hourly fetch, the planner and the dashboard would all have been on the first morning of being billed for it. That is the wrong morning to find a fault.
New tariffOverride pref (menu, default auto) and an OctopusAPI(tariff_override=...) kwarg. When set, get_current_tariff() short-circuits to _forced_tariff_info().
The forced tariff resolves its OWN product code, and that is the whole design. Reusing the detected code would leave a Tracker product under an agile key, and get_active_tariff_schedule() would then fetch Tracker rates and label them Agile — a silently wrong number, which is the failure class this file is full of. The exception is forcing the tariff already active, where the detected codes ARE the right ones: Tracker is delisted from the public products listing, so the prefix probe cannot find it, and without that branch forcing tracker would resolve to nothing.
Guards, each earned:
- An unrecognised string is IGNORED with a warning, never honoured. A typo must not select a planner branch.
TARIFF_UNKNOWNis absent fromTARIFF_OVERRIDE_CHOICES. Forcing it would pick the branch that imports immediately at half inverter power, which is a fallback and never a choice.- An unresolvable product warns rather than pretending; the planner then takes its no-rates branch honestly.
detected_key/detected_product_codecarry the REAL agreement alongside the forced one, so the log can say what is billed as well as what is being rehearsed.display_namegains “(forced)” and a WARNING is logged at every start and every prefs save (_log_tariff_override_setting, modelled on_log_bank_first_setting). An override quietly left on is worse than none, so the armed state is observable without reading the config.
Billing is untouched: the Kraken financials path still reads the real agreement, because that is what Octopus charges. TARIFF_DISPLAY_NAMES hoisted out of _detect_tariff_from_account() so detection and the override name a tariff identically.
New config section has its own separator_tariff — v5.87.1 is the reminder that a block appended without one draws under the previous heading.
1216 -> 1227 tests (11 new), 5/5 mutations killed with __pycache__ cleared before every run and the source restored byte-identical. The first run of the suite was a TEST fault, not a code one: the mock logger was attached after __init__ had already validated the override, so the constructor’s warning went to the real logger and the assertion could never see it.
v5.97.0 — 06-09-2026
Settlement tripwire. On 21-05-2026 a BST/UTC mix-up drove the export a full hour late; Axle settled 0.036 kWh and only the two-minute pre-roll earned anything. Nothing compared what was driven against what was paid, so it surfaced weeks later when CliveS read the email.
The first implementation could not have caught it, and the adversarial pass found that before it shipped. It compared our in-window export against Axle’s settled figure — both integrate the SAME hour, so a mis-timed export makes both fall together and agree. Against the real 11-Aug over-run on disk (7.05 kWh driven, 3.05 outside the window) it returns 0.198 kWh, dead centre of the healthy band. A meter-agreement check wearing a timing check’s name, and worse than nothing, because its silence reads as confidence.
Rewritten around how much of what was driven landed inside the paid hour. Measured over the eight driven events on disk, healthy is 92.5-97.0%; 11-Aug was 56.7%. Gate is gap >= 0.8 kWh AND inside < 85%, both halves mutation-proven load-bearing. Proportional on purpose — a 30-minute event is worth at most 2 kWh at the DNO cap, so any flat threshold clearing the noise would swallow it whole. Latched per window, ignores anything over 45 days old. 1211 -> 1214 tests, 11/11 mutations killed. Live: flags exactly one event, correctly.
v5.96.0 — 06-09-2026
VPP narration out of the Indigo event log. CliveS: “remove the Axle VPP lines, they are going to grow and i dont need to see them”. The growth was real — a line for every announcement, pre-charge step, mode change, ledger write and summary, once per event, for ever. The event log is the estate’s dashboard, not this plugin’s diary.
New module-level vpp_log(). INFO goes only to the plugin’s own daily file via the extracted _write_plugin_log(); WARNING and ERROR still reach the event log, because that is the only place Log_Error_Watch.py can see them, and a VPP failure nothing surfaces is exactly the silence the other guards exist to end. All 93 [VPP] call sites swept through it, so the routing is ONE decision rather than 69 judgements at the call sites.
event=True forces a single INFO line to the event log, used only for RECOVERY messages: their warning half went there, so sending the all-clear elsewhere would leave the event log’s last word an error, which reads as still broken.
Both hourly nags removed. The ledger-staleness warning had been modelled on the events-feed guard, which is fair to repeat because that feed can recover on its own — a ledger only a person can feed cannot. Once, on change, with live state on the device. A structural test forbids any [VPP] line bypassing the wrapper and asserts its own scan matched over 80 sites, so it cannot pass vacuously. 1192 -> 1199 tests, 6/6 mutations killed.
v5.95.0 — 05-09-2026
Reads the settlement mail from the local Apple Mail store. CliveS corrected a wrong assumption: he does not use Email+ or the indigo@highsteads.co.uk account at all. The whole IMAP-and-forwarding plan was built on my mistake, and none of it was needed.
Verified live: an Indigo plugin host CAN read ~/Library/Mail — it has Full Disk Access, and Apple Mail has already downloaded the messages, so the fetch is a file read. No mailbox login, no credential anywhere, no forwarding rule, no Email+ device, and no trigger to build in the client.
iter_settlement_messages() walks ~/Library/Mail/V* — globbed, because V10 becomes V11 at some macOS release and a pinned path would silently stop finding anything. Two cheap filters before any parsing, since this runs on the plugin’s only thread: an mtime window, then a 4 KB byte prefilter on sender and subject. Measured: 16,276 messages -> 621 recent -> 13 hits in 1.64 s.
_scan_axle_mail() on a 6 h tick, deliberately BEFORE the ledger freshness check so an import clears the staleness warning on the same pass rather than leaving the log contradicting itself. No seen-set: the merge is window-keyed so re-reading is free, and no bookkeeping means no bookkeeping to drift. Opt-in, default false — this plugin is published, and reading someone’s personal mail store must never begin on an upgrade. 1179 -> 1192 tests, 10/10 mutations killed; one survived the first pass because the “corrupt message” fixture lacked the Axle markers, so the PREFILTER rejected it before the parser was ever reached.
v5.94.0 — 05-09-2026
Parser tested against all 13 real settlement emails, harvested read-only from the local Mail store. Yesterday’s parser was built from ONE specimen, and the real corpus uses THREE templates.
It found a 250x bug shipped in 5.93.0. When an event exports almost nothing, Axle’s “this is less than we hoped” template states the kWh in words and no event money at all — and the only pound figure in the body is the boilerplate minimum-earnings guarantee. The loose _RE_GBP fallback read that as the event’s earnings: on the real 21-May-2026 mail it produced GBP 10.00 for an event Axle settled at 4p. Money now comes only from the rate line or the phrase “you earned”, neither of which can reach the boilerplate, and a regression test carries the real wording.
Also from the real data: a nil event is filed as a genuine 0 kWh / 0p row, which is what Axle themselves record; _window_for_event_date falls back to Axle’s own events list so a settlement for an event the plugin did not drive can still be filed; and the low-export template is REFUSED rather than guessed at, because computing it from the configured rate would break the rule that Axle’s figures are taken verbatim and never derived here.
Result: 12 of 13 parse, and 12 of 12 agree exactly with Axle’s settled figures. The 13th refuses honestly. 1173 -> 1177 tests, 17/17 mutations killed.
v5.93.0 — 05-09-2026
The settlement email files itself. Built against a REAL specimen — the 16-Aug-2026 mail, supplied 05-Sep-2026 — rather than an imagined format, which is the only reason the awkward parts were visible at all.
Why the email and not the API
Recorded in v5.92.0 and restated because it is the whole justification: Axle’s /rewards/* endpoints all need an ORGANISATIONAL token (a consumer token 403s, ha-axle-vpp #19), and the account page cannot be fetched because sign-in is magic-link only, so there is no credential a program can hold. The email carries both figures and arrives DAYS earlier. It was always the live channel — the ledger’s newest Axle row has always been a hand-typed email-… one.
The awkward part, and why it turned out not to matter
The email never writes the event’s clock times as text. In the specimen “Event 20:00 → 21:00” is drawn inside the chart IMAGE. No text parser reaches it, and the ledger’s dedupe is window-keyed, so without a window a row cannot be filed at all.
It does not need to be parsed. We drove that window, so we hold it to the second in our own local ledger rows. _window_for_event_date() looks it up by the LOCAL calendar date the email names (“Sun 16th August”) against rows stored in UTC. So the email supplies the money and the plugin supplies the timing — steadier than OCR, and impossible to get subtly wrong.
The body is untrusted input
Anything that can be emailed to you can be emailed by anyone, and a parsed figure lands in an earnings record. So: the sender domain and the subject are both checked (either alone is guessable); both figures are range-bounded; and the kWh is cross-checked against the money via the rate line, on the grounds that a template which has moved is one we can no longer read safely — better to refuse than to bank a misreading. Re-filing is harmless by construction, since import_axle_payload is window-keyed.
The year is inferred from the message’s own timestamp, with a rollback: a settlement arrives days after its event, so a parsed date in the future belongs to the previous year — the 31st of December settled on the 2nd of January. Without it, one event a year is filed twelve months out.
Wiring
actionIngestAxleSettlementEmail reads the Email+ IMAP device’s OWN states (messageFrom/messageSubject/messageText/messageDate, names read from Email+’s Devices.xml rather than guessed) instead of taking them as action props — cross-plugin prop serialisation drops keys (the Email+ sendEmail bug, 09-Apr-2026), and a settlement figure arriving blank would be filed as a real one. It REFUSES a disabled device: a disabled device’s states are frozen at whatever they held when it was switched off and every read still succeeds, so reading one would re-file a months-old message as though it had just arrived.
The action carries a ConfigUI, deliberately — an action step with none stores no props and is “not completely configured” for ever at run time, with no dialog left to fix it.
Still needs a human: Axle send to axle_vpp@strudwick.co.uk while the Email+ IMAP device is indigo@highsteads.co.uk and disabled, so the mailbox is a decision rather than a code change; and the trigger itself must be made in the client, because indigo.trigger has no create.
Verification
The shipped module was run in the plugin’s own context against the real specimen and the real ledger. It reproduced the row CliveS typed by hand field for field — same id, same window, same −3.87 kWh, same 387p.
1143 -> 1173 tests. Thirteen mutations, all killed. Two survived the first pass and both were fixture gaps the specimen itself created:
- The money bound could not be killed, because the kWh/money cross-check caught every case the test could express. It needed a rate high enough to keep the two figures consistent while the money stayed absurd.
- Pence rounding could not be killed, because 3.87 × 100 is exactly 387.0 in binary floating point. 137 money values under GBP 20 are not — 1.15 × 100 is 114.99999999999999 — so a short event would have been banked a penny light, silently and for ever. The specimen’s own figure was the one that could not show it.
v5.92.0 — 05-09-2026
The earnings ledger had stopped being fed 17.2 days earlier and nothing on the system said so. Asked to automate the Axle ledger fetch, the research came back saying the fetch itself needs Axle’s permission — and turned up a fault that needed nobody’s.
The gap
_record_vpp_api_status exists because a dead events endpoint “looks exactly like a quiet week”: this install polled a revoked Axle token for six weeks in complete silence while the VPP page read a calm “Standby”. The LEDGER — the other Axle feed — had no equivalent at all. axle_age_days has been computed on every summarise() call since the ledger shipped (vpp_ledger.py:590) and rendered by nothing; earnings.age_days reaches /api/status and no HTML consumes it; the only warning lived inside menuShowVppEarnings, at a 14-day threshold, fired by hand. Same failure class, same plugin, one feed guarded and one not.
Added, modelled line-for-line on the existing guard:
_record_vpp_ledger_status(problem)— latch, log on first occurrence and hourly after, and log an explicit recovery. Silence on recovery would leave the last word being an error._check_ledger_freshness()— pure local work off the mtime-cached summary.VPP_LEDGER_CHECK_INTERVAL = 21600and a tick stage. Six hours, not minutes: settlement runs days behind and final revenue lands at the end of the following month.ledgerAgeDays+ledgerStatusdevice states besideledgerUpdated, because a bare “imported 19/08” reads as calmly as “imported this morning” on a control page.ledgerStaleDayspref, default 7, guarded coercion per the house rule.
Three distinctions that are the whole point:
- Never imported is NOT fresh. An empty ledger has an age of
None, and treating that as healthy would make a feed that has never delivered once read better than one a day late — the absent-state fault this estate keeps meeting. - Unreadable is not merely old, and is reported separately.
- A non-Axle install is never warned about a ledger it will never have.
The merge seam
_merge_axle_payload(payload) lifted out of importAxleLedger. The comment beside the ledger has always said Axle’s rows “arrive through ONE importer”, but the load/merge/save/invalidate sequence lived inline in a menu callback, so any automated feed would have had to copy it — including the _vpp_ledger_cache = None that three dashboard pages depend on.
It now refuses a ledger carrying load_error. save_ledger drops that marker and rewrites the file wholesale, so merging over an unparseable ledger destroyed both the evidence and whatever was still in it. A test asserts the damaged file is left byte-identical.
What the research established about the fetch itself
Ten agents across five discovery lanes, three adversarial route assessments and a completeness critic. Recorded here so nobody repeats it:
- The data exists and is documented. Axle publish an OpenAPI spec (docs.axle.energy) with
/rewards/{site_id}/info,/rewards/{site_id}/transactionsand/entities/site/{site_id}/flex-events. - The door is shut. All three require an ORGANISATIONAL bearer token. ha-axle-vpp issue #19 records a real HTTP 403 from a consumer token, confirmed by that maintainer. Four independent integrations (ha-axle-vpp, Predbat, the Homey app, SolisAgileManager) all stop at the events endpoint. Asking Axle would need to be for a CONSUMER endpoint where the site is implied by the token — the rewards role alone would still leave no way to resolve a
site_id, since/entities/siteis itself organisation-scoped. - The cookie route is dead, and the old comment saying otherwise is withdrawn.
vpp_ledger.pyused to name “a cookie-authenticated fetch” as a future feed. vpp.axle.energy is React Router v7, not Remix, so there is no?_data=route-loader URL; and Axle sign-in is magic-link only — no password, so no storable credential and no way to revive a dead session without a human clicking an email. - The settlement email is the live channel. The completeness critic caught the research lane killing this on “no specimen exists on this system” — true of Indigo and worthless as a conclusion, because the emails go to CliveS’s own mailbox, which nothing looked in. An unsearched channel read as an empty one, which is the absent-state fault applied to research. The plugin’s own source says the opposite:
test_vpp_ledger.py:409— “Axle email the result days before the account page catches up” — and the live ledger’s newest Axle row IS an email row (email-2026-08-16T19:00, −3.87 kWh, 387p). The window-keyed supersede logic inimport_axle_payloadwas written for exactly this.
So the email is the route, it needs no vendor negotiation, and it is blocked only on a specimen and on knowing which mailbox receives it. Both are one question to CliveS.
Tests
1118 -> 1140. Eleven mutations, each written from the consequence, all eleven proven to turn the suite red.
One SURVIVED the first sweep: removing self._vpp_ledger_cache = None from the merge changed nothing, because the cache is mtime-keyed and the file’s mtime moves on write. The invalidation is not redundant though — the cache compares cached[0] == mtime exactly, so on any filesystem with coarse mtime a merge inside the same tick would serve pre-merge figures to the dashboard. APFS is fine-grained enough that no natural test can reach it, so a test now freezes os.path.getmtime and pins it. A fixture only tests the regime it was built in.
v5.91.0 — 05-09-2026
The VPP summary headlined the figure Axle does not pay on, and the per-minute snapshot threw away evidence it had already paid for. Found by reading back the 05-Sep-2026 18:30 UTC window, which was itself textbook: 4.000 kWh integrated inside the hour against a 4 kW cap, 55 in-window samples with a mean of 3,999.6 W and a standard deviation of 19 W, mode 0x06 held on all 58 snapshots, and no external interference.
1. estimate_gbp, the Pushover title and the event-log line all priced the whole run
export_kwh is the counter delta across everything we drove. The driver deliberately runs T-2min to end+2min (v5.28), so on a textbook event it is ~0.23 kWh larger than the settled figure. window_kwh — integrated strictly inside the paid hour — has been computed and stored in the ledger since v5.63.0 with a comment saying it is “the only one comparable with what Axle settled”, and then every headline used the other one.
Axle’s own settlements decide it. Over the seven events they have settled, measured 05-Sep-2026 from vpp_ledger.json:
| mean abs error vs Axle | |
|---|---|
window_kwh | 0.17 kWh |
export_kwh | 0.82 kWh |
The ledger’s eight local rows read GBP 40.77 where the settled basis gives GBP 35.99 — 13.3% high overall, ~25p an event routinely, and GBP 3.05 of it from the single 11-Aug over-run (7.05 kWh recorded for a hour whose cap allows 4).
Changed:
vpp_ledger.record_local_eventpricesestimate_gbpoffwindow_kwh, falling back toexport_kwhonly when the window figure was never measured, and records which it used in a newestimate_basisfield._summarise_vpp_eventleads the title, the body and the event-log one-liner with the paid hour, keeps the run total as an explained secondary sentence, and says plainly when the window figure could not be measured rather than passing the run total off as settled.
Scope, stated because it is easy to overstate: estimate_gbp on a local row is written and never read by any code, and summarise() has always used window_kwh with a run_kwh fallback for its our_kwh column. So no money figure on the dashboard was ever wrong — the lifetime and month-to-date totals come from Axle’s settled pence. What was wrong is what a human reads: the notification, the log line, and a stored estimate that misled the very analysis that found this.
2. _verify_vpp_export_registers — the snapshot now acts on what it already read
_log_vpp_snapshot reads the mode register, the charge limit and the discharge limit every minute of every window and, until now, only wrote them to JSONL. So a drift landing between manager cycles was recorded and not acted on.
The VPP branch of _verify_ems_registers is extracted into _verify_vpp_export_registers(ems_mode=None, charge_w=None, discharge_w=None). Any argument left None is read as before; the snapshot passes the three it holds. One set of rules, one place — which matters here, because the question “which registers may be written mid-window” has an expensive wrong answer (10-Apr-2026, a stray limit write causing a 2 kW grid import), and two copies would have been free to diverge on it.
The state guard moved INSIDE the method. It used to be the elif condition; with a second caller, a routine that re-asserts an export mode must be unable to fire outside a live window whoever calls it.
Measured cadence, which changed the design. The first plan was to cache the reads and drop the duplication. The log says the two callers do not overlap — over the live window, verify, then the snapshot 39-48 s later, then verify ~20 s after that — so the mode is really checked every 20-45 s, and a cache would have made the effective rate variable and worse. Read counts over the hour (110 read_ems_mode, 110 read_discharge_limit, 55 read_charge_limit) confirm 55 runs of each caller. So the reads stay and the evidence is now used: no extra Modbus traffic, roughly double the acting check rate in-window.
3. _fit_pushover_body, and four stale cadence comments
Rewriting the headline into plain English took the body to 1062 characters. Pushover’s free tier truncates silently above 1024, and the casualty would have been the last line of the Ask Claude prompt. _fit_pushover_body(essential, optional, tail) sheds diagnostic lines from the bottom — every one of which is in the JSONL anyway — announces how many went, and marks a last-resort truncation rather than letting one happen invisibly. The money lines, the external-control warning and the prompt are never shed. The title’s em-dash and the ── Ask Claude ── separator became ASCII, per the standing no-Unicode rule for notifications.
Four comments claimed the manager cycle runs every ~15 minutes. MANAGER_EVAL_INTERVAL is 60, and 55 runs were counted in the hour. Corrected in _verify_ems_registers’s docstring, _end_vpp_export (which had used the wrong figure to size a failed hand-back at “~1.25 kWh” — nearer 0.08 on the real cadence), the store-init comment and _retry_vpp_handback. VPP_OVERRUN_GRACE_MINS’ comment was checked and left alone: it is about the 15-minute grace constant and already says the poll is 60 s.
Tests
1080 -> 1118. Ten mutations, each written from the consequence (“the headline shows the run total again”, “the snapshot throws its reads away again”, “the state guard is gone”), all ten proven to turn the suite red, with __pycache__ cleared before every run and each restore asserted byte-identical.
One mutation SURVIVED the first pass and it was the fixture’s fault, not the code’s: the end-to-end test used a temp path ~50 characters long where the real event-log path is ~170, so the un-fitted body still fitted and nothing noticed _fit_pushover_body being removed. A test now builds the real directory depth. A fixture only tests the regime it was built in.
v5.90.2 — 05-09-2026
openmeteo_forecast.py v1.8 — the optimiser file path is injectable; an empty forecast is never published. OPTIMISER_FORECAST_FILE was a hardcoded absolute path into the live Python Scripts folder, and test_complete_fetch_is_ok_and_cached mocks every array to return [] and calls fetch_forecast(force=True), which reaches _write_optimiser_file — so every scripts/run_tests.py run on the Indigo Mac wrote the LIVE openmeteo_forecast.json with zero slots (fingerprint: bias_bands centres present with every factor 1.0, no Wrote optimiser file line in the plugin log). It stayed wrong until the next fetch (<= 30 min), and the evening/overnight planner run in that window would have read “0 kWh of solar”. Caught rendering the 20:00 message against live data at 13:34 — the file had been zeroed by the 13:32 suite run.
OpenMeteoForecast(..., optimiser_file=None);_write_optimiser_filewritesself.optimiser_file.- The writer returns with a WARNING when
hourly_outis empty. test_openmeteo_forecast.pyre-points the module constant to a temp path for the whole module AND passesoptimiser_file=on every construction;TestOptimiserFileIsolation(4) pins both, including that the constant no longer contains “Perceptive Automation” under test.openmeteo_battery_optimiser.pyv3.18 (Python Scripts, same session): a plan-day total of 0 is UNKNOWN — on an EVENING run it falls back to the plugin’stomorrowSolarKwh, and with no hourly set the shape-dependent power-cut line says the forecast was not to hand.- General rule recorded in the global CLAUDE.md: check the live files’ mtimes around a suite run.
v5.90.1 — 05-09-2026
_write_site_config publishes the manager’s own weekday/weekend figures. v5.90.0 wrote daily_kwh_weekday = sum(profile) and daily_kwh_weekend = sum(profile) * uplift (and the hourly profiles likewise), while _build_manager_snapshot splits the blended profile through _need_scales() so the week still averages it. The optimiser’s evening message reads the file for its “against ~N kWh of house use” line and the plugin’s flood preview for its “~N kWh typical use” line, so the two figures in one message disagreed by 0.9 kWh (24.4 vs 23.5 on 05-Sep). Both now use _need_scales. Test: test_profile_is_split_with_the_same_scales_the_manager_uses. Found by auditing the 20:00 message’s sources before it went out.
v5.90.0 — 05-09-2026
Export feedback — the day’s own evidence enters the decision. Stage 3 of docs/daily-energy-revamp.md. Modules: battery_manager.py, plugin.py, Devices.xml.
The gap. _evaluate_manager ran every 60 s, and every run read remaining_solar_kwh (forecast buckets x bias_factor_today, a band fitted on PREVIOUS days), need_24h_kwh (a pref, or the profile sum x a hard-coded 1.30 at weekends) and the SOC. Nothing measured about today entered. Re-checking could not help: same inputs, same answer. The weekend uplift was measured at 1.10 (26 weekends since June: weekday mean 21.2, weekend 23.4) against the 1.30 in code, so every Saturday charged ~28 kWh of need against the balance.
pv_tracking_factor(actual, forecast)(battery_manager, pure):ratio = actual/forecast,factor = 1 + w(ratio - 1),w = min(1, forecast / 8 kWh), clamped [0.6, 1.3];(1.0, None)below 2 kWh of elapsed forecast. Applied in_calculate_24h_balanceright after the band factor:remaining_solar_kwh *= snapshot.pv_tracking_factor.- Accumulators (
_update_pv_tracking, once per evaluate):pv_track_actual_kwh+= the projection’s PV delta since the last evaluate;pv_track_forecast_kwh+=_forecast_kwh_between (last, now)— the forecast integrated bucket-by-bucket over the SAME interval (robust to an hourly refresh: each minute takes whatever bucket is current). Both advance ONLY while_pv_unclipped(): export below 95% ofmaxExportKwand not (SOC >= 99 and battery <= 100 W). At the cap, or on a full battery, the inverter turns PV away and a shortfall says nothing about the weather — learning from it would talk the plugin out of exporting on the very days it must (low ratio -> less remaining -> physics gate releases -> battery charges -> clips again). Clipped minutes are counted (pv_track_clipped_min). A day whose PV anchor islate/absentyields a neutral factor. Reset at local midnight; persisted inaccumulators.json(same-day restore). - Recorder: one row per local hour to
intraday_pv_tracking.json(ring of 2000) — the data the damping constants get tuned from. - Measured need (
_calculate_24h_balance): whenhome_today_kwhis known and not partial,need_24h = used_so_far + (profile[slot:48] / sum(profile)) * need_day. Still the full calendar day against supply to dusk (the deliberate conservatism stands), but the elapsed part is real.SufficiencyBalancegainspv_tracking_factor,need_today_used_kwh,need_today_measured. - Weekend uplift (
_measured_weekend_uplift): weekend mean / weekday mean over the last 56 days ofdaily_history.json, partial days and < 2 kWh days excluded, >= 10 weekdays and >= 4 weekend days elseWEEKEND_UPLIFT_DEFAULT= 1.10, clamped [0.9, 1.5], cached per day, logged on change._need_scales(u):wd = 7/(5+2u),we = wd*u, so the week still averages the profile (the old code set weekday = P and weekend = 1.3P, a week averaging 1.09P). A user pref set away from its default still wins, as before; away mode still means no uplift (v5.78.0).sigen_site_config.jsonpublishes the measured multiplier. - Snapshot fields:
pv_tracking_factor,pv_tracking_ratio,home_today_kwh(None until the daily-energy object has seen a reading today — a fresh start cannot present 0.0 used as a measurement),home_today_partial. Neutral defaults reproduce v5.89 exactly (pinned). - States: Battery Manager
needTodayKwh(Float),pvTrackingPct(Integer, ratio x100, 100 until judgeable). The[BALANCE]audit line and the default reason carrysolar tracking x0.87andneed today 21.4 kWh (12.4 used)only when they apply. - Designed and NOT shipped: a “min-end floor on the actual trajectory” release inside
_check_solar_overflow. The mutation sweep proved it dead by construction: the physics release fires whenevernet < headroom_to_100, and while it does not,soc + net/cap >= 100%, above any floor. With the tracking factor insideremaining_solar_kwh, the physics gate IS the stop-on-actuals (pinned bytest_tracked_shortfall_releases_a_running_export_through_the_physics_gate).
Tests: test_battery_manager.py +9, test_plugin_export_feedback.py (16, new). 1051 -> 1075. Fifteen mutations killed; the sweep’s two first-pass survivors were a dead guard (removed) and a test whose partial-day fixture carried the same value as a real day (fixed).
v5.89.0 — 05-09-2026
Daily energy figures derived from lifetime counters anchored at local midnight. Design note: docs/daily-energy-revamp.md. Modules: daily_energy.py (new, pure), sigenergy_modbus.py v1.14, plugin.py.
The fault. homeDailyKwh froze on 18.6 kWh from 4-Sep to 5-Sep, daily_history.json 4-Sep is a copy of 3-Sep (and carries battery_charge_kwh: 0.0), and every halfhourly.home_kwh since 4-Sep 00:00 is 0. Mechanism, from the plugin log: _check_midnight zeroed home_daily_kwh; the next poll merged a CACHED homeDailyDirectKwh (v1.13 read tiering, SLOW_CACHE_MAX_AGE_S = 600) and wrote yesterday’s total into the fresh store; the inverter rolled its own counter to 0.01 moments later; the reset guard read 18.60 -> 0.01 as suspicious and held the value for the day (4,019 times on 5-Sep). The guard’s comment predicted the window and defended the wrong direction.
Why the class kept recurring. Three definitions of “today” — Europe/London midnight, the inverter’s day (30092, 30566, 30572), and whenever the next poll landed (the lifetime re-anchor) — and the totals kept as MUTABLE running state, so a wrong figure could never be recomputed.
The model. DailyEnergy keeps anchors[date][key] (a lifetime counter at local midnight) and latest[key]; today()[key] = latest - anchor, completed(day) = anchor[next] - anchor[day]. Six keys, all plant-level U64 lifetime counters: pv 30088, load 30094, ESS charge 30200, ESS discharge 30204, grid import 30216, grid export 30220. Probed read-only 05-Sep-2026 against the TypQxQ V2.9 definitions (validated on the four addresses already in use): 30094 moved +0.020 kWh in 90 s at ~900 W, 30200 +0.010 kWh while charging, 30204 stayed flat. The counters update on a coarse tick (a 90 s delta under-read PV by ~30%), so they are for daily/half-hourly deltas only. Register 30000 holds local wall-clock as an epoch with tz 30002 = 0, within 1 s of the Mac — the inverter’s midnight IS ours, and nothing depends on that any more.
- Rollover:
rollover(date)takes a PROVISIONAL anchor from the last pre-midnight reading; the first fresh read within 600 s after midnight replaces it (PROVISIONAL_UPGRADE_WINDOW_S). A later first read keeps the provisional one if it was within 900 s of midnight (PROVISIONAL_MAX_AGE_S), else anchors late and flags the day partial. - Recovery: a key with no anchor takes
lifetime - device_dailyfrom the inverter’s own daily counter read THIS cycle (RECOVERY_DATA_KEYS), so a mid-day start recovers house/battery exactly. - Never cache a discontinuity:
NO_CACHE_KEYS(30092, 30566, 30572) are present inread_all()’s dict only on the cycle they were read.data["_energyReadAt"]stamps a cycle that read a lifetime block fresh;observe(fresh=False)changes nothing. - Blocks: four plant block reads (30088+6, 30094+4, 30200+8, 30216+8) replace four single reads — same transaction count, three new values. B and C carry absent-latches (three misses on a healthy link); the fallbacks are the identity for house and the device daily counters for battery flow.
- Projection:
_observe_energy_countersfeeds the object and PROJECTS the figures into the legacy store keys (pv_daily_kwhetc., plusbattery_charge_daily_kwh,battery_discharge_daily_kwh,energy_balance_kwh,energy_day_partial). The sixty-odd consumers of those keys are untouched; nothing else may write them. - Midnight: the first observe of a new day snapshots the projection (
energy_yesterday_projection) and marks the energy blocks due;_check_midnight_implwaits up toMIDNIGHT_ANCHOR_WAIT_S= 600 s for the post-midnight read to replace the provisional anchor, then records yesterday fromcompleted()— exact, and immune to the order the tasks ran in (the old code would have written the new day’s zeros, since observe runs before midnight in_tick). Record gainsenergy_balance_kwh,energy_partial,energy_sources. - Half-hourly: deltas of the lifetime snapshot (
hh_anchor_lifetime), so a slot spanning midnight keeps its energy; new columnsbattery_charge_kwh,battery_discharge_kwh(ALTER-if- missing). The old daily-store anchors are ignored and reseeded (one skipped slot). - Tripwire:
_reconcile_daily_energywarns once per day, re-armed on clearing, when|pv + import + discharge - export - charge - house| > max(0.5, 3% of throughput)or when the derived house figure differs from 30092 by more thanmax(0.5, 5%). New inverter stateenergyBalanceKwh. - Persistence:
accumulators.jsongainsdaily_energy(restored whatever day the plugin starts on) andenergy_yesterday_projection. A pre-5.89 file on the same day seeds pv/import/export anchors from*_lifetime_start_kwh(migrate_legacy); house and battery recover on the first read. - Device states: battery daily charge/discharge and the new balance come from the projection — never
data.get(..., 0.0), which would chart a fabricated 0.0 on the cycles the register was not read.
Untouched: the decision engine, the bank-first hold, VPP, storm, flood prevention. The export feedback loop that reads these figures is v5.90.0.
Tests: test_daily_energy.py (28), test_plugin_daily_energy.py (19), test_sigenergy_modbus.py +9. 1032 -> 1051. Fifteen mutations, each killed, __pycache__ cleared per run, anchors asserted unique, files restored byte-identically.
Still to do (Stage 2, no version): mend daily_history.json 4-Sep (home 20.58 -> 18.60, battery 0.0 -> 18.48/10.25), halfhourly.home_kwh for 4-Sep and 5-Sep from the SQL Logger identity, and daily_summary 4-Sep — scripts/mend_daily_energy_2026_09.py.
v5.88.0 — 04-09-2026
The bank-first latch classified today from yesterday’s forecast. _record_bank_first_metrics reset the latch on a local-date change and immediately re-armed it from latest_forecast_data ["todayKwh"], which between local midnight and the first fetch of the new day (84 minutes on 4-Sep) still held YESTERDAY’s total. 4-Sep was forecast 45-49 kWh from 00:24 and was held anyway, 08:00-12:47, 7.244 kWh withheld. Fix: every forecast dict carries forecastDate; the latch arms only when it equals today. Second fix, same family: _overflow_bank_first_blocked read a missing forecast (raw_today = 0.0) as a small day and held export off; absent is now UNKNOWN and stands aside. 989 -> 995 tests, four sabotages proven red. Full detail: the README row and the Plugins/CLAUDE.MD chain entry (this file was not updated at the time — backfilled 05-09-2026).
v5.87.1 — 04-09-2026
Six config fields drew under the wrong heading. PluginConfig.xml had no separator between the power-cut block and the solar-overflow block, so solarOverflowTargetSoc, solarOverflowMinEndSoc, both bank-first fields, solarOverflowShadowEnabled and stormExportReleasePct rendered under POWER-CUT NOTIFICATIONS. New separator_solarexport / label_solarexport (DAYTIME SOLAR EXPORT) plus an info line saying which fields start an export and which do not — CliveS had set the charge target to 93 meaning the export-start level. Wording and layout only; client restart to see it. Backfilled 05-09-2026.
v5.87.0 — 03-09-2026
The loop slept the interval on top of the work. runConcurrentThread ran _tick() then self.sleep(min(modbus_poll_s, 10)), so the real period was work + interval: 43 s before v5.86.0 and 12 s after, against a 5 s setting. Now sleeps the REMAINDER (max(0.2, interval - tick_took)), measured 12 s -> 8 s; long intervals unaffected. Companion fix: the energy fallback that integrated power used the CONFIGURED interval rather than measured elapsed time, under-counting ~8x; it now measures, clamped so a post-outage reconnect cannot dump an hour into the day. 982 -> 989 tests. Backfilled 05-09-2026.
v5.86.0 — 03-09-2026
Read tiering in sigenergy_modbus.py v1.13. read_all() did 29 transactions at the spec’s 1 s spacing, so a poll took 43 s MEASURED whatever pollInterval said, and the three power figures came from instants seconds apart while homePowerWatts is derived from them (0 W house readings on broken-cloud days). Now the six critical reads run every cycle, PV+ESS from ONE block read, and everything else rotates three per cycle behind _slow_cache (600 s TTL). ~8 transactions per cycle; fresh-reading gap 43 s -> 12 s. NB: this cache is what served the daily-load register across midnight and froze homeDailyKwh on 4/5-Sep — see v5.89.0. Backfilled 05-09-2026.
v5.85.1 — 03-09-2026
tokenBalance said 1; the Octopus app showed CliveS 0. Reported within the hour of v5.85.0 shipping. The schema is unambiguous — “The account’s Weekend Happy Hours token balance (zero if it has none)” — and it still disagreed with Octopus’s own UI.
Which is right is not the point. The app decides what the account may actually do; the API field is a number. v5.85.0 turned that number into a VERDICT — “so it cannot be booked yet” — which is a prediction about what Octopus will allow, made from a field measured to contradict them. A wrong refusal talks the owner out of a free hour he was entitled to, which is a far worse failure than a redundant nudge, so the asymmetry decides the design: report, never refuse.
The count is now ATTRIBUTED rather than asserted — “Octopus’s API reports 1 of the 2 tokens a booking needs — check the app, which is the authority, and book it there if it lets you” — and no path can produce “cannot be booked”. Two tests hold that down: one asserting the refusal wording never appears on a low balance, one asserting the number is never stated in our own voice (“You have N”). Proven by reinstating the v5.85.0 wording, which turns both red.
The general lesson, and it is not Octopus-specific: a documented API field can disagree with the vendor’s own UI, and the UI is authoritative for what the user can do. A field description tells you what a value is MEANT to be, not that it matches what the user sees. Anything derived from a vendor field and shown to a user should be attributed to that vendor, so a disagreement reads as a disagreement rather than as the plugin being wrong — or worse, as the plugin quietly overruling the thing the user is looking at.
971 -> 972 tests. No behaviour change beyond wording; nothing about how the battery is driven.
v5.85.0 — 03-09-2026
The Happy Hour alert told CliveS to do something he could not do. Four pushes went out on 03-Sep-2026 saying “opt in to earn” for four Sunday slots — and a Weekend Happy Hour is not opted into, it is BOOKED, and booking costs tokens his balance could not pay for. Four interruptions, no possible action. Advice that cannot be acted on is worse than silence, because the reader spends attention working out that there was nothing to do.
The schema had the answer all along and the query never asked: tokenBalance on the account, capacityStatus on each event. Both are now fetched, and the Happy Hour message is built from what the reader can actually do — booked (and whether the battery will charge for it), slot full, not enough tokens, or ready to book.
The token balance is reported VERBATIM and never derived. The accrual rule cannot be reconstructed: measured against this account, 24 successful turn-downs and one booking leave a balance of 1, which fits no simple “N earned per success, M spent per booking” arithmetic, and the scheme itself only began on 16-Aug-2026. So the plugin repeats the API’s own number or says nothing. None means NOT REPORTED and is deliberately distinct from 0 — “you have no tokens” is a claim, and one the API never made. The device state uses -1 for unknown, because an Integer state cannot hold None and 0 is a real balance meaning something quite different.
What a booking COSTS is a pref, not a constant. It is absent from the API and underivable, so happyHourTokensRequired (default 2, Octopus’s published figure) holds it: if they change it, that is one field to edit rather than a release. Set 0 and the alerts stop mentioning tokens at all.
Also closes the worst silent outcome available here: a slot BOOKED, free power waiting, and the plugin sitting it out because happyHourImport is unticked. The booking alert now says so, at the moment it can still be fixed.
963 -> 971 tests; four sabotages each proven red (unknown-balance-as-zero, nag-without-tokens, offer-a-full-slot, silent-when-switched-off), each asserted to have applied and each restore byte-identical. NB the repo’s own TestNoTestsStrandedBelowMain caught the new class being appended below __main__, where it would never have run — a gate earning its keep.
NB v5.84.0 shipped a README row but no entry here, so the chain skips it; that one is the other session’s to backfill.
v5.83.1 — 03-09-2026
An armed Octopus session was not observable from anywhere. Found half an hour before a live 18:00 turn-down, trying to answer “is this actually armed?” and discovering there was no way to: the window cache lives only in store["saving_sessions_windows"], is not persisted, and appears in neither the decision audit nor the status API. The audit’s [OVERRIDE] line still read “skipped — no VPP active, no running flood export”, wording that predates both new overrides, so a cached session and no session looked identical. A feature you cannot check before it runs can only ever be verified after it has already failed — and a restart clears the cache, so this was not hypothetical.
Two changes, both read-only:
- The
[OVERRIDE]audit line now namessaving_session_activeandhappy_hour_active. /api/statusgainsoctopus_sessions(the cached windows with their direction, plusnext_start) and two flags,saving_session_export_activeandhappy_hour_import_active.
Direction is included in the exposed window because it decides which way the window drives, so a reader can tell an export window from an import one without inferring it from the battery.
No behaviour change. 958 tests.
v5.83.0 — 03-09-2026
Weekend Happy Hour import — Phase 3. During a BOOKED Octopus Happy Hour (an hour of free electricity, earned by two successful turn-downs), grid-charge the battery at full inverter power so the free energy is banked instead of wasted. Specced first in docs/happy-hour-import-spec.md and signed off before a line was written; CliveS chose passive fill, count-and-tag, full charge rate.
Booked is the whole point. Octopus offers FOUR 1-hour slots each Sunday (11:00-14:00 BST) and only the reserved one comes back joined — measured 03-Sep-2026: Sun 16 Aug 12:00Z was booked, its three siblings were not. The cache admits joined events only, so the unbooked siblings can never drive anything.
It fills to the configured target, not to 100% (solarOverflowTargetSoc, 93 live). Free electricity is not a reason to override a rule that exists to protect the pack — and following the pref means changing that one setting moves both features. NB the standing note says 95 while the live pref is 93; that discrepancy is the existing open item, not a decision taken here.
No export gate, deliberately. Unlike the turn-down branch this IMPORTS, so a post-power-cut lockout or storm export-suppression is irrelevant, and a storm actively WANTS a full battery. The asymmetry is commented at both sites because it is exactly the kind of thing a later tidy-up “harmonises” into a bug.
A turn-down and a happy hour together FAILS CLOSED — they cannot both be right and guessing which way to drive the battery is worse than doing nothing. They should never co-occur (weekday evenings vs Sunday middays), so if they do, something upstream is wrong and quietly picking one would hide it.
Termination is bounded three ways — window end, target SOC, and _check_happy_hour_overrun, an independent backstop sharing no dependency with the primary path (the v5.62.0 lesson: one path ending a window is one path too few). Importing past a free hour means BUYING at 25p, so the exposure is real money. Hand-back is CONFIRMED with the vpp_handback_pending retry.
Free kWh is measured from ONE anchor — the cumulative import counter captured at entry and persisted immediately, so a restart mid-window can neither double-count nor lose it. Surfaced as happyHourFreeKwhLast, tagged rather than blended into ordinary import: the headline self-sufficiency figure stays honest and matches the meter, and the Sunday dip is explainable.
One shared window cache, two readers. _window_of_direction is the single implementation, so freshness and malformed-row handling cannot diverge; _saving_session_window and _happy_hour_window each filter to the direction they drive. A row with NO direction is driven by neither — fail closed both ways.
Reuses force_charge (mode 0x03 + charge limit + hardware charge-cutoff backstop), the same proven path ACTION_START_IMPORT uses, so a crash mid-window cannot leave it charging unbounded. New inverter_max_kw on the snapshot — the charge decision needs the CHARGE rate, and max_export_kw is the DNO EXPORT cap (4 kW here), a different number.
Honest value: ~£8-15 across the rest of the promotion, which ends 1 November. Slots land at peak solar, so a bright September Sunday leaves no headroom and it will correctly do nothing; October is where the money is. Scoped small on purpose. Pre-drain to manufacture headroom was considered and DEFERRED — revisit after one October hour shows whether it earns the extra cycle.
Ships OFF (happyHourImport). Tests 935 -> 957; five sabotages each proven red (5/1/1/5/6), each asserted to have applied and each restore byte-identical. Two existing tests were updated rather than deleted: the v5.82.0 “happy hour is never cached” assertion now asserts the unchanged INTENT (never reaches the export path) since the mechanism moved to per-reader filtering, and the turn-down window fixtures gained the direction they now carry — plus a new symmetric test that an untagged row drives neither.
v5.82.0 — 03-09-2026
NO DIRECTION GUARD — v5.81.x would have exported the battery through a Power Up and through a free-electricity hour. Found by finally reading the Octopus dashboard page, which advertises “Power Up — use more electricity in these sessions”, and then introspecting the schema: SavingSessionsFlowDirection = TURN_DOWN | TURN_UP | WEEKEND_HAPPY_HOUR, exposed as eventType on every event. The query never asked for it.
The consequences of that omission are both wrong in the same direction — spend battery, earn nothing:
- TURN_UP wants MORE consumption. Exporting is exactly backwards.
- WEEKEND_HAPPY_HOUR is FREE electricity. Exporting through it wastes the free power AND drains the battery.
And this is not hypothetical: 12 of the 62 events on this account are already happy hours. The current promotion (to 1 Nov) grants an hour of free power for every two successful turn-downs, so the account is actively working towards events of exactly the kind the code would have mishandled — and a scheduled happy hour may well arrive flagged as joined.
get_saving_sessions() now returns direction; the window cache admits only TURN_DOWN, and an absent or unrecognised value is never treated as one (a direction Octopus adds later must not be assumed safe). The alert still fires for the others — knowing a Happy Hour is coming is useful — but says what it is and that the battery is not driven for it.
Same class as the Axle import/export guard added in v5.57.0, which shipped without one and would have self-driven a full 4 kW export through an IMPORT event. Twice now: when an external feed announces an event, ask which way it runs before acting on it.
Tonight’s session is TURN_DOWN / status UPCOMING / joined, 61 pts/kWh, so it still fires. 5 tests; 930 -> 935, and removing the guard turns 3 red.
v5.81.1 — 03-09-2026
The announcement dedupe depended on the id’s Python TYPE. _check_saving_sessions compared the raw event["id"] against the persisted saving_sessions_notified set. Live that happens to work — Octopus returns the id as an int and JSON round-trips it as an int — but GraphQL’s ID type is SPECIFIED to serialise as a string, so the day that payload changes, every hourly poll stops matching and re-announces the same session. Both sides are now normalised with str(), which also migrates a set persisted as ints by 5.80.x.
Found by a probe, not by the suite. Driving the shipped _check_saving_sessions against the live API with a store seeded as ["5899"] sent a Pushover for an event that was already announced. The 927-test suite could not have caught it: every fixture used one type consistently, so it was asserting the arithmetic of matching rather than the behaviour under the type variance the API actually permits. octopus_api.py was already normalising with str() for the joined lookup, so the two halves disagreed — the more useful signal, and the reason the fix is to make the convention explicit in both places rather than to match whatever today’s payload happens to be.
3 tests (int-vs-str, str-vs-int migration, and a genuinely-new event still notifying as the negative control); 927 -> 930, and reverting the normalisation turns 2 of them red.
v5.81.0 — 03-09-2026
Phase 2, at CliveS’s request: drive the battery to export during a Saving Session — the thing v5.80.0 deliberately did not do. His rule, verbatim: do it “as long as it does not interfere with an Axle event the same day unless we can do both and still have enough to get through to next morning without importing.”
PER-EVENT OPT-IN IS THE HEADLINE, and it was found by checking rather than assuming. Campaign membership does NOT enrol you in each session. Measured today: this account reads hasJoinedCampaign=True with 36 joined events ending 16-Aug-2026, while all 17 sessions since — the entire new season — are un-joined, including 1 Sept at 68 pts/kWh and 2 Sept at
- They were missed in silence. So
get_saving_sessions()now returns a per-eventjoinedflag, the alert LEADS with “NOT OPTED IN — join it in the Octopus app, or it pays nothing” and logs at WARNING, and the export branch will not fire for an un-joined session at all: exporting into one earns no Octopoints, so it would drain the battery for the plain 12p export rate. Had I taken the earlier “you’re already enrolled” reading at face value, the feature would have driven the battery for nothing on its first run.
Axle wins, and it is not close. Axle pays about £1/kWh; a Saving Session at 61 Octopoints/kWh pays 7.6p/kWh (800 points = £1) — roughly 13x. So the new branch sits STRICTLY BELOW the VPP override in _check_overrides, which means an overlapping Axle window has already returned before this line is reached and the session can never take a paid VPP minute. It also cannot spend Axle’s energy: snapshot.vpp_today_kwh (future-only, pro-rated, in place since v5.19.4) is subtracted from what may be exported, which is the “unless we can do both” half of the rule priced rather than assumed.
“Still reaches next morning without importing” is expressed against the engine’s own projection rather than a second model: new pure saving_session_exportable_kwh() takes balance.battery_at_dawn_kwh (which already carries the solar still to come and the overnight drain), subtracts the reserve — max(dawn_target, health_cutoff) — subtracts Axle’s promised kWh, and caps the result at the DNO window (max_export_kw x window_hours). It refuses outright when balance.import_needed is already True, because the engine saying “tomorrow needs a grid import” settles the question whatever the arithmetic says. That flag is load-bearing for a specific reason: battery_at_dawn_kwh is CLAMPED at the health floor by its producer, so it cannot go negative to signal a deep shortfall — reading a shortfall out of a clamped number is exactly the trap, so the refusal keys on the flag instead.
Also: a refusal now SAYS SO in the decision reason (“Saving Session window, but not exporting: tomorrow already needs a grid import”), because a session that quietly does nothing is indistinguishable from a broken feature — which is precisely what v5.80.0 was. The stand-down latches on the flag, not the clock, so the export stops promptly if the dawn projection turns against us MID-window, and the hand-back to Self Consumption is CONFIRMED with a retry on the vpp_handback_pending path (the v5.64.0 lesson: never latch on an unconfirmed write).
Adaptive poll cadence, and it is what makes tonight work. Hourly normally, every 10 minutes once a known session starts within 2 hours — because opting in is a tap in the Octopus app that the owner may make shortly before the window, and the joined flag is what gates the export. On a flat hourly cadence an opt-in at 17:45 would not have been seen until after an 18:00 session had already started. The imminence test reads only a cached next-start (deliberately NOT filtered on joined — the point is to already be polling often enough to NOTICE the opt-in), so it costs no extra network.
A gap found by restarting and looking rather than by any test: the new ACTION_SAVING_SESSION -> "savingSession" token had no matching <Option> on the currentMode List state in Devices.xml, so currentMode.savingSession did not exist on the device and a trigger built on it would never have fired. Added, plus TestCurrentModeTokensMatchDevicesXml in test_config_xml.py which walks every token in ACTION_MODE_TOKEN and asserts an Option exists (proven to fail by removing the Option again). NB an added Option needs an Indigo CLIENT restart before the sub-state appears — the documented client XML caching.
The manager cycle does no network I/O for any of this — the hourly poll leaves the joined windows in store["saving_sessions_windows"] and _saving_session_window() is a pure read of that cache.
Ships OFF (savingSessionExport, default false) — a new behaviour that drives the battery must not switch itself on for anyone. Read through plugin_utils.as_bool, not bare bool(); NB most of plugin.py still reads checkbox prefs with bare bool(), where bool("false") is True — a real latent trap and an estate-wide sweep of its own.
Tests 903 -> 927. Four sabotages, each asserted to have applied and each restore byte-identical: disabling the VPP branch so the session steals the window (4 red), removing the import_needed refusal (1), no longer holding back Axle’s energy (1), ignoring the reserve floor (3). A fifth mutation SURVIVED and was the more useful result — adding and not vpp_active to the session branch changed nothing, because that branch already sits below the VPP one, so the mutation was meaningless rather than the test weak. Re-run as a genuine precedence break, it went red.
v5.80.1 — 03-09-2026
v5.80.0 SHIPPED SILENTLY DEAD, AND THE WAY IT FAILED IS THE WHOLE ENTRY.
get_saving_sessions() sent Authorization: JWT <token>, copying the convention every other Kraken call in octopus_api.py uses. The backend host does not take that form. Measured against the live API today, all three variants:
| header | result |
|---|---|
<token> (raw) | account resolves — hasJoinedCampaign=True, 36 joined events |
Bearer <token> | account resolves |
JWT <token> (what shipped) | account: null, errors[].extensions.errorCode = OE-0102 |
| main host + raw token | HTTP 400 — the two hosts are mirror images |
The failure shape is what made it dangerous. events is PUBLIC and resolved fine, so the reply was HTTP 200 with a complete 61-event list and only the account sub-field errored to null. acct = ss.get("account") or {} then read that as hasJoinedCampaign = False, and _check_saving_sessions returns early on exactly that — so the feature would have stayed silent for ever, with no log line, on a reply that looked entirely healthy. It was released and reported as working on the strength of a clean plugin start, which is [[feedback_verify_the_dispatched_path]] in its purest form: the plugin starting proves the plugin starts.
I noticed the discrepancy while reading barnybug’s client (he sends the raw token) and consciously kept the JWT form “for consistency with this codebase”. That was the wrong call: consistency is a property of one host, and this is a different host. The comment at the call site now says so, with the error code, so nobody harmonises it back.
Second fix, and the one that matters more: an errored account block is UNKNOWN, never “not joined”. get_saving_sessions now inspects errors[].path and returns a FAILURE (None) with a WARNING naming the error code when the account leg errored or came back null — so a future auth change surfaces in the log within the hour instead of quietly disabling the feature. A GENUINE hasJoinedCampaign: False still comes through as a real answer, and _check_saving_sessions now logs that once per start rather than returning in silence: “this account has not joined the campaign, so no session alerts will fire”. An absence and a negative are different facts — [[feedback_absent_state_is_never_a_match]], hit again, this time in an auth reply rather than a device state.
Live-verified after the restart, through the SHIPPED file: account block resolves, hasJoinedCampaign=True, 36 joined events, signed-up meter point 1234567890123. Tests 897 → 903, and both fixes mutation-tested — reverting the header turns the suite red (1 failure), reverting the guard turns it red (3) — each sabotage asserted to have applied and each restore verified byte-identical, with __pycache__ cleared before every run.
v5.80.0 — 03-09-2026
Octopus Saving Sessions — Phase 1: detect a newly-announced event and Pushover CliveS. No dispatch change at all; this is visibility only.
Why Phase 1 and not more. Checked the account’s actual history via the barnybug/savingsessions calculator: across the six sessions since the battery went live (Mar–Aug 2026), five earned £0.00 and one earned 120 points (15p) — because the battery already exports close to its own 10-day baseline most evenings, leaving little “extra” for Saving Sessions to reward. Octopoints are genuinely on top of the normal 12p/kWh export payment (confirmed against Octopus’s own FAQ: “your export tariff won’t be affected — only additional energy exported during the Session will be paid at the Saving Sessions incentive rate”), but that normal export revenue would be earned at the same flat rate whichever half hour of the day it went out in — Outgoing isn’t time-of-use. So the marginal value of actively timing dispatch around a session is still only the Octopoints bonus, and on this account’s numbers so far that’s pennies, not pounds. Not worth arbitrating against the Axle VPP and bank-first export hold for. Revisit once a winter session or two (historically worth far more — the best pre-battery session here paid 416 points/£0.52) shows real numbers.
octopus_api.py — new OctopusAPI.get_saving_sessions(), mirroring the get_account_financials() shape (30-min positive cache, 5-min negative-cache debounce, serves the last good value through a network blip). Queries savingSessions { account { hasJoinedCampaign joinedEvents { eventId } } events { id code startAt endAt rewardPerKwhInOctoPoints } } against KRAKEN_GRAPHQL_BACKEND (api.backend.octopus.energy) — this query 404s on the main api.octopus.energy graphql host that every other Kraken call in this module uses; the JWT from the existing _get_kraken_token() works unchanged against the backend host, so no new auth step was needed. Only public reference for this query is the barnybug/savingsessions open-source calculator (github.com/barnybug/savingsessions) — there is no first-party Octopus API doc for it.
plugin.py — _check_saving_sessions(), hourly tick (SAVING_SESSIONS_INTERVAL). Compares live events against store["saving_sessions_notified"] (persisted in accumulators.json, not day-scoped — same treatment as storm state, so a restart between the announcement and the session can’t re-send the push), sends one Pushover per newly-announced future event naming the window and the points/kWh rate, and stays completely silent when there is nothing new, the account isn’t a Saving Sessions member, or the poll fails. 18 new tests (9 in test_octopus_api.py, 9 in test_plugin.py) — 897 total, all green.
v5.78.1 — 29-08-2026
CONFIGURE DIALOG WAS DEAD FOR FIVE DAYS.
PAXDialogControllerError -- Field ID separator_dashboard was already used. Indigo will not build a dialog containing two <Field> elements with the same id, and v5.75.0 (24-Aug) added a second WEB DASHBOARD section that reused three IDs from the existing one: separator_dashboard, label_dashboard, label_dashboard_info. Every attempt to open Plugins -> Sigenergy Manager -> Configure failed outright, so NO setting could be changed.
Why it survived five days and two releases. Nothing in the plugin reads its own dialog XML — Indigo’s client parses it, at the moment a user opens the dialog. So the failure is invisible to python3 -m unittest, to ruff, to a plugin restart, and to every smoke test in the release ritual. It surfaced only when CliveS went to tick a new setting. Note the wrinkle from the client-XML-caching rule: a client holding the pre-5.75.0 XML would have opened the dialog fine, which is another way this hides.
Fix. The two sections merged into one, dashboardHost folded in after the token fields where it belongs (it is the same subject), and the duplicate heading deleted rather than renamed — two “WEB DASHBOARD” headings in one dialog was the real defect, the ID clash was the symptom.
Guard. test_config_xml.py walks every *.xml in the Server Plugin folder and asserts, per dialog scope (the root for PluginConfig, each <ConfigUI> elsewhere):
- every file parses
- no duplicate
Fieldid within one dialog - no
Fieldwithout an id - every
visibleBindingIdnames a field present in the same dialog
That last one is not the bug that was hit, but it is the same family and it fails SILENTLY — a mistyped binding hides the row instead of raising, so the setting simply never appears. The suite also asserts the glob matched something, since a glob matching nothing makes the other four pass vacuously. Mutation-checked 3/3.
Tests 807 -> 812.
v5.78.0 — 29-08-2026
AWAY MODE — a second consumption profile, for the days the house is empty.
The problem. _refresh_consumption_profile_impl builds the 48-slot profile as a cumulative mean over every polling day, and the accumulators persist across restarts. That is the right shape for a house someone lives in and the wrong shape for one nobody does. Six weeks away neither moves the mean far enough to change any decision during the trip, nor stays out of it afterwards — so the plugin plans the battery for a full house all holiday and then carries the quiet weeks home for months.
The measurement, not an estimate. Octopus half-hourly import, 15-Oct to 28-Nov 2025, 2,160 slots, 45 days. That absence predates the Sigen install, so grid import was house load with nothing in between:
12.16 kWh/day mean (min 11.4, max 18.3)
flat 507 W, 1.2x trough to peak, no weekly cycle at all
The occupied default shape has a morning and an evening. Falling back to it for an empty house invents a peak that is not coming, which is why away gets its own seed rather than a scale factor on the existing one.
What was added.
_away_seed_profile(daily_kwh)— module-level pure function, flat 48 slots, guarded coercion (blank / non-numeric / <=0 / >100 all fall back toAWAY_DAILY_KWH_DEFAULT).away_profile_watts_sum/away_profile_count— a second accumulator pair, fed only while away._accumulate_home_profilepicks bystore["away_active"]._is_away()— reads the configured Indigo variable by NAME (per the global rule: a name survives a recreate, an id does not). Warns ONCE on a missing variable, because it runs every Modbus poll._refresh_away_state()— called from the merge immediately BEFORE the accumulate, so a transition cannot file a full-house reading against the empty-house profile. Rebuilds the live profile on change rather than waiting for the next scheduled refresh.awayModestate onbatteryManager, behind the samein dev.statesguard ascurrentMode(a state added this version is unregistered on the first tick after restart, and writing it early logs a red line for nothing).- Config:
awayEnabled/awayVariable/awayDailyKwh.
It fails towards OCCUPIED, and that is the whole design. The two errors are not symmetrical. Believing the house is empty when it is not under-imports and leaves the battery short on a winter evening. Believing it occupied when it is empty buys a little more than needed, and the existing SOC guard caps that anyway. So a missing variable, a blank name, a junk value and an exception all return False. This is feedback_absent_state_is_never_a_match applied to a config read: unknown must not resolve to the interesting branch just because the interesting branch is the new one.
The weekend uplift is suppressed while away (_build_snapshot). The x1.30 models people being home on a Saturday. The 45-day measurement has no weekly cycle whatever, so applying it would invent 30% of Saturday demand and buy for it.
Persistence is additive. away_watts_sum / away_count are new keys in home_load_profile.json; a file written by <=5.77.3 simply lacks them and the away accumulators start empty. _load_home_profile also seeds away_active from _is_away() BEFORE the rebuild, so a restart mid-trip does not resume on the occupied profile.
Sizing, for whoever wonders whether it was worth it. 3-Dec to 15-Jan is 43 days, ~523 kWh of demand against ~250 kWh of December PV, so ~273 kWh net. Priced against the real region-F Agile rates for 3-Dec-2025 to 15-Jan-2026: buying nightly costs £31.84 (11.8 p/kWh), buying across a 5-day window costs £19.56 (7.2 p/kWh). ~£12. Modest, but the mechanism matters more than the money — a 35 kWh battery against 6.3 kWh/day of net need is five days of slack, and only a correct profile lets the planner use it.
Tests. test_away_profile.py, 25 cases: seed flatness and every coercion guard, all six unhappy paths of _is_away, accumulator routing both ways, which profile the refresh publishes, the persistence round trip including a pre-5.78 file, and the restart-while-away case. Suite 782 -> 807. Mutation-checked 4/4 — fail-safe direction flipped, routing pinned to home, away fallback swapped for the occupied default, and away keys dropped from the save, each run in a fresh subprocess with __pycache__ cleared first and the restore asserted byte-exact.
v5.77.1 — 25-08-2026
THIS FILE. The developer changelog had grown to 2,002 lines at the top of plugin.py — a sixth of an 11,534-line module — so the file opened with its own history rather than with its imports. Moved here, where it reads better as a document than it ever did as a comment block.
The move is behaviour-neutral and that is proved rather than asserted: the module’s AST, dumped with include_attributes=False so line numbers are excluded, is byte-identical either side of it. An identical dump means only comments changed. Word-diffed as a second check — the only differences are the date reformat in the headings, one per entry. 748 tests pass, ruff clean, verified live after a restart.
plugin.py 11,534 -> 9,551 lines. The file header stays, with a pointer here.
Structural measurements of the module, and what is worth slimming next, are in docs/decomposition-analysis.md and docs/decomposition-plan.md.
v5.77.0 — 24-08-2026
ONE READER FOR THE SUMMER RESILIENCE FLOOR. Four call sites coerced dawnSocTarget independently and every one of them fell back to 10 - a value this plugin refuses. Save-time validation rejects anything under 15, and the startup migration raises a stored value below it, so the only state that could produce a 10 was a pref that had never been written. And 10 is the HEALTH floor this buffer exists to sit above, so the fallback put the resilience floor exactly on the line it is meant to clear. All four now go through _dawn_target_pct(), fallback 15. The migration’s own read is deliberately left raw: it has to see the pre-migration value to know whether to raise it. Doc corrections that came with it - the README and the repo notes both claimed a “10% summer floor”, which has been 15 since v3.0, and battery_manager carried a stale “default 10%” comment. Companion script openmeteo_battery_optimiser.py v3.15 was fixed in the same pass: it mirrored the seasonal rule WITHOUT the plugin’s guard, returning the winter buffer unconditionally where _apply_seasonal_override applies it only when it exceeds the summer floor. Harmless at today’s 15/20, silently wrong the moment a dawn target above 20 is set. Tests 743 -> 748.
v5.76.0 — 24-08-2026
THE PLUGIN CARRIED A SECOND COPY OF THE DASHBOARDS ENERGY PAGE. web_dashboard.py was 2,217 lines, of which about 1,800 were an HTML/JS page held in a Python string - so no linter, no editor and no node --check had ever looked at it, and an unclosed brace in there was a blank page with no clue where to start. It also rendered charts, a calendar, tariff, cost, period totals and an export-sync table that the Dashboards plugin already draws from the same JSON. The page now lives in dashboard.html beside the module, and it has been cut back to what a fallback view is actually for: power flow, battery state, live power, the manager’s decision and today’s totals. That is the view you want when IWS is wedged or the broadband is down, which is the only reason this server has a page at all. THE JSON API IS UNCHANGED. All seven endpoints - status, history, daily, export-sync, years, calendar, vpp - answer exactly as before, because Dashboards proxies every one of them. A new test pins that list so a later tidy-up cannot quietly break the Energy and Cost pages. The bundled Chart.js (200 KB) and the /chart.js route went with the charts. 43 orphaned CSS rules went too, several of them dead since the flow diagram stopped using chevrons. scripts/check_dashboard_js.py runs the page’s own update() over a real payload in a stub DOM, so a stale element reference fails a check instead of blanking half the cards in a browser. web_dashboard.py 2,217 -> 390 lines. Page 1,835 -> 1,044. Tests 734 -> 743.
v5.75.0 — 24-08-2026
THE WEB DASHBOARD ASKED NOBODY FOR ANYTHING. Since it was written it bound every network interface on port 8179 and served the whole energy API - SOC, tariff, VPP, power cuts, the lot - to any device on the LAN, with no authentication at all. On a home network that was a known trade. It stops being one the moment the port is reached through a tunnel, and that is where this is heading. It now binds LOOPBACK by default. Nothing user-facing changes: the Dashboards plugin proxies to 127.0.0.1 server-side, so its Energy and Cost pages carry on exactly as before. What goes away is the LAN exposure. A new “Dashboard access” config setting widens it to the whole network, and that path REQUIRES a token - the server refuses to start rather than opening an unauthenticated port, because a dashboard that fails to start gets noticed and a warning in a busy log does not. The token comes from IndigoSecrets (SIGEN_DASHBOARD_TOKEN), then the config field, then one the plugin generates and keeps 0600 in its own data folder. It is accepted as a Bearer header, an X-Auth-Token header, a ?token= query string, or the cookie the query string sets, so a browser only needs it once. Loopback callers are exempt, which is what keeps the Dashboards proxy working. New menu item: Show Web Dashboard Access. Also adds sigenergy_modbus read_export_limit() (reads back the commissioned grid export cap 40038; the write side already existed) so the standalone SigenVPP daemon can share this file byte for byte instead of carrying a local edit. Tests 696 -> 734.
v5.74.0 — 20-08-2026
SHADOW-COMPARES 90% AND 95% DAYTIME PACING WITHOUT TOUCHING THE INVERTER. Each solar-overflow evaluation now runs the same immutable snapshot through a 95% target and totals the extra early export that the live 90% setting makes available. At midnight it stores that estimate beside the observed end SOC, export and post-16:00 import. It also records the actual import pattern at both Tracker’s daily price and Agile’s published half-hourly prices. This is a SAME-CONSUMPTION tariff baseline, not a claim that Agile’s battery scheduling would have behaved identically. No control setting, charge cap or tariff changes.
v5.73.0 — 19-08-2026
“PV CURTAILED” WAS AN ALARM ABOUT THE SUN GOING DOWN. Found checking the device states after the 5.72.3 restart, not from any symptom. The 16-Aug window (19:00-20:00 UTC = 20:00-21:00 BST) reported lastVppPvStatus: curtailed with PV at 0 W across all 48 snapshots. SUNSET WAS 20:40 BST, so the window opened 41 min before it and ran 19 min past — genuinely daytime by the dawn/dusk gate, which is why v5.56.0’s dark-window branch did not catch it. But the FORECAST for that hour was 153 W falling to zero: it had predicted the zero. And curtailment was IMPOSSIBLE anyway — in 0x05 with charge pinned at 0 it needs PV above house + export cap, about 5 kW, at five degrees of elevation. THE RULE WAS RIGHT AND ITS SCOPE WAS WRONG. v5.66.0 established that only a peak which never lifts means a shut-down MPPT; that holds mid-day and says nothing at the edge of the solar day, where nothing was coming anyway. So the verdict now asks what was EXPECTED: new pure _forecast_peak_w_for_window reads the hourly p50 buckets over the window’s LOCAL hours, and a peak below PV_EXPECTED_MIN_W (200 W — 1.4% of nameplate, where a zero cannot be told from cloud) reads “n/a (no PV expected)”. A substantial forecast still condemns a zero, which is the 15-Jun-2026 failure this check exists for and is pinned as the control. An UNKNOWN forecast changes NOTHING — it must never quietly excuse a real fault — and that fallback is pinned too. Raw p50 deliberately, not bias-corrected: raw runs high here (0.883), so it over-states the expectation and makes the guard LESS willing to excuse, which is the safe direction. Reporting only — nothing on the drive path changed. Tests 686 -> 696, 3/3 mutants killed with each mutation asserted to have applied.
v5.72.3 — 19-08-2026
THE HEADLINE COULD FALL BEHIND ITS OWN ROWS, SILENTLY. Straight after the 5.72.2 email import, the ledger reported a lifetime of GBP 87.60 while the transactions listed underneath it summed to GBP 91.47 — because lifetime_gbp and available_gbp are Axle’s stored figures and a payload carrying transactions and NO balance (exactly what a settlement email gives you) never refreshes them. CliveS’s screenshot of the Balance page settled it: GBP 91.47, agreeing with the ROWS. Two numbers in one ledger disagreeing, with nothing saying so. summarise now totals the rows and reports the gap as balance_behind_gbp — DETECTED AND REPORTED, never quietly corrected, because publishing our arithmetic as Axle’s settled figure reads as truth while being a computation, which is the worse fault and the rule the whole axle side turns on. The menu says which figure is which and what to do; /api/status carries the flag. SKIPPED once any withdrawal row exists: a withdrawal cuts the available balance without cutting lifetime earnings and this estate has never seen one, so the sign convention is UNVERIFIED and a check built on a guess is worse than none. THE FIXTURE CAUGHT MY OWN ASSUMPTION: the test payload is a deliberate 5-row SUBSET of the real 17 kept beside the real GBP 87.60 balance, so it never agreed with itself and the first three cases failed. The tests were wrong, not the code. Tests 50 -> 56, 2/2 mutants killed with each mutation asserted to have applied. LIVE: the real balance imported from the account page, so lifetime, available and the rows all read GBP 91.47 and the flag is clear.
v5.72.2 — 19-08-2026
A HAND-ENTERED SETTLEMENT ROW CAN NO LONGER DOUBLE- COUNT. Axle email the result of an event days before their account page catches up — the 16-Aug window arrived by email on the 19th (GBP 3.87 for 3.87 kWh) while the account still showed nothing — so entering the figure by hand is the sensible move. But import_axle_payload deduped on transaction_id ALONE, and a hand row cannot know the id Axle will later assign it, so the authentic row would have landed BESIDE the stand-in and the event would have been counted twice in the lifetime total for ever, with nothing to notice. A grid event settles once, so its WINDOW identifies it as surely as its id does: a new flex-event row whose window is already held now REPLACES the row that holds it. Deliberately flex events only — the monthly floor payment and the referral credit carry no window, and two top-ups in one month are two genuine payments, which is its own test. The 16-Aug row is now in the live ledger as email-sourced, so the next account import quietly corrects it. 4 tests (50 in the module); mutation-tested with the guard removed, and the mutation was ASSERTED to have applied before its result was believed.
v5.72.1 — 18-08-2026
THE LEDGER NOW RECORDS THE IN-WINDOW EXPORT TOO, and it changes what the comparison means. CliveS asked why 11-Aug showed 7.05 kWh against an hour whose DNO cap allows 4 — a fair question, because the figure being recorded was the counter delta across the WHOLE driven run. The driver deliberately runs T-2min to end+2min, so that is always wider than the paid window; on 11-Aug it was wider by FORTY-FIVE MINUTES, because the window never stopped (the v5.61.1 bug). MEASURED across every event: integrating grid export strictly INSIDE the window gives 4.00 kWh on every one-hour event and 8.01 on the two-hour one — the cap times the duration, as it must be — and the gap against Axle collapses from a scattered 0.4-3.2 to a consistent +0.162 to +0.197. THAT is the baseline, and it only becomes visible once both sides cover the same hour. New pure integrate_window_kwh; both figures stored; the baseline is claimed ONLY when ours is in-window, and an over-run is reported separately rather than as a shortfall. Historical rows re-seeded from the existing JSONLs. 13 tests (46 in the module), and the first three failures were all my own FIXTURES — a sample cadence that never reached the window end, a step change a trapezoid cannot resolve, and a case still asserting the old contract.
v5.72.0 — 18-08-2026
THE VPP EARNINGS LEDGER — WHAT AXLE ACTUALLY PAID. The plugin has always known what it EXPORTED and never what it EARNED, and the two are different numbers. Axle settles on flex_kwh, the change in energy flow measured against a baseline, so a window our snapshots record as 4.23 kWh settles at 3.838. Every previous session read that ~0.4 kWh gap as a shortfall to be chased; it is not a deduction at all, it is a different measurement, and the only place it exists is the Axle account. Confirmed 18-08-2026 by reading the account payload: credit_pence is exactly |flex_kwh| x 100, no deduction anywhere. New vpp_ledger.py holds two sources side by side and never merges them — axle (settled truth, imported wholesale) and local (our own per-event figure, an ESTIMATE and labelled so). THE RULE: an event with no Axle row is PENDING, not zero. Settlement runs days behind — 16-Aug was still unsettled on the 18th — so a GBP 0.00 tile would report a loss that never happened. paid_gbp stays None until Axle says otherwise, and a genuine zero (20-Apr settled at 0.000 kWh) arrives as a real transaction and is a different thing. Earnings states are Strings for the same reason: a Float cannot say “pending”. Also: /api/vpp (ledger + next window), a machine-readable next_event block on /api/status so a page can count down without parsing English, api_status carried alongside it (a dead feed and a quiet fortnight look identical otherwise — that is how a revoked token hid for six weeks), two menu items, and 33 contract tests weighted at absence. Axle rows arrive through ONE importer reading a drop file, so a future cookie fetch or a widened token changes nothing else.
v5.71.1 — 15-08-2026
A QUEUED IMPORT COULD FIRE INSIDE A PAID EXPORT WINDOW. Found by asking what Predbat’s #4520 (“boost the import rate during an Axle export event, not just export”) lands on us, not from any symptom. Their fix is planner pricing — an export event pays a premium, so charging through it forfeits that premium and their planner had no cost for it. The same opportunity cost is ours. The manager itself was already safe: BatteryManager.evaluate() is first-match-wins and step 1 is the VPP override, sitting ABOVE the import branch, and _verify_ems_registers deliberately stands down through PRE_CHARGING/ACTIVE. But _check_scheduled_import_impl runs from the 10s TICK and gated only on manager_paused — it fired on the clock alone. The manager evaluate that would retract the queue runs on the ~15-MINUTE cycle, and that retraction is skipped once an import is already running. So a schedule armed for a time inside a window started Charge Grid First at up to inverterMaxKw mid-export: buying at the import rate through the hour we are paid to sell, with the verify loop standing down and ACTION_VPP_EXPORT only re-driving the export (it never clears import_active, so the store then claimed import AND export together). Now gated on vpp_state, mirroring the manager_paused gate right above it, and HELD rather than cancelled — the battery still wants that charge, so it fires once the window closes; a gate that silently became an exclusion would be the worse fault. PRE_CHARGING is included (the plugin is already grid-charging to its own target there, and a second charge command with its own cutoff would fight it). ANNOUNCED is deliberately NOT — that can be hours ahead, and charging BEFORE a window is the arbitrage this plugin exists to do. actionForceGridImport carried the same fault plus one of its own: it sets export_active False beneath a state machine still driving the export. REFUSED there rather than held, because a person is behind that one and deserves an answer; pause the manager to override. LIKELIHOOD WAS LOW AND IS ABOUT TO RISE: on Tracker the deferral path rarely arms (no cheap window) and events are evening while imports are overnight — but CliveS leaves Tracker in October, and on a time-of-use tariff the queue arms nightly. The midnight deferral can arm ~8 hours ahead. Tests 620 -> 630; 6 of the 10 verified FAILING against 5.71.0, the other 4 deliberate both-sides guards (announced still fires, idle still fires, an absent vpp_state is not a permanent block, and the manual action still works when idle).
v5.71.0 — 13-08-2026
THE REAL GRID VOLTAGE — 252.21 V, and the ceiling is 253.0. Register 31000 is the 230 V NAMEPLATE; the measurement is 31011 (U32, gain 100), found by reading the phase-voltage table in the official protocol after the CLOUD reported an actual 251.34 V that no local register was showing. UK statutory range is 230 V +10%/-6% = 216.2 to 253.0, and an inverter MUST curtail or disconnect above the ceiling — so a high reading here is lost export revenue, and the cause is the DNO’s network, not anything in this house. Sitting 0.8 V under the limit is worth knowing about. Also reads 31017 phase current. Both are Number states so the SQL Logger charts them: the useful question is not “what is it now” but “how often does it touch 253”, which is a week of history rather than a reading. 0xFFFFFFFF is this firmware’s “not applicable” for an unused phase and is rejected — taken at face value it decodes to 42,949,672.95 V. /api/status grid_quality gains voltage, current and both statutory limits so a dashboard never has to hardcode them.
v5.70.0 — 13-08-2026
THE CONFIGURED BATTERY CAPACITY IS WRONG, AND THE DASHBOARDS WOULD NOT HAVE FOLLOWED THE FIX. Two findings. (1) /api/status published the module CONSTANT (BATTERY_CAPACITY_KWH) while every control path — the 24h balance, the dawn reserve, flood prevention — reads the batteryCapacityKwh PREF with the constant only as a fallback. They agree today at 35.04 purely BY COINCIDENCE, so the moment the pref is corrected the dashboards would have silently disagreed with the battery logic, with nothing to show it. Status now reads the pref, like everything else. (2) 35.04 is not the right number. The pack’s NAMEPLATE is 36.16 kWh, agreed to the decimal by THREE independent sources — plant register 30083, inverter register 30548 (both newly read, gain 100) and the Sigenergy cloud’s own systems list. But rated capacity is not what a SOC percentage converts at, so it was MEASURED instead, from six days of logged history: ten clean runs where only one energy counter moved give an implied capacity of 35.33 kWh from discharge and 35.85 from charge. Those BRACKET the truth — metered charge over-states (round-trip losses) and metered discharge under-states — so the real figure is ~35.6 kWh, and the bracket is the evidence rather than a single reading. Configured 35.04 therefore under-states stored energy by ~1.6%; the nameplate would over-state it by the same. rated_capacity_kwh is now published alongside so the two can never quietly drift again. THE PREF ITSELF IS CliveS’s TO SET — it feeds battery control decisions, so this release exposes the evidence and changes no behaviour. (The history query needed FORWARD-FILL to work at all: the SQL Logger writes only CHANGED values, so a row carrying a new SOC usually has null counters, and the first pass — which required all three columns present — found ZERO runs in six days. The documented sparse-row trap, walked into in person.)
v5.69.0 — 13-08-2026
READ THE DOCUMENTATION — five registers NAMED, and a correction. CliveS showed the mySigen app listing all four packs’ SOC and asked whether I had looked at the docs and API. I had not: v5.68.0 concluded per-pack data “does not exist” from a probe alone, when what a probe can show is only “not at the addresses I tried”. Worse, I had ABANDONED the most promising line — separate slave IDs for the packs — because the scan was slow, and then wrote absence as fact. THE CONCLUSION SURVIVES, on far better evidence. The official Sigenergy Modbus Protocol V2.7 PDF contains exactly TWO SoC registers, 30014 (plant) and 30601 (inverter), both aggregate, and no per-pack temperature at all; the most complete community implementation (sigenergy2mqtt, ~2200 lines of sensor definitions) exposes the PACK COUNT and no per-pack value; and slaves 2-5 do not answer. So per-pack SOC is NOT on the local Modbus interface — the app reads it from Sigenergy’s CLOUD, which is a different channel with a richer model. (Fetching the spec needs a browser User-Agent; a bare curl gets a “Blocked” HTML page, same trap as the Indigo docs.) WHAT THE DOCS PAID FOR: five registers this plugin had probed but could not identify are now named and four are shipped — 31003 [PCS] internal temperature (the inverter’s OWN temperature, 58.9 degC live, and nothing in the estate was watching it), 31037 insulation resistance (a SAFETY reading: falling means moisture ingress or damaged DC cable), 31024 PACK count (so _pack_count now ASKS THE HARDWARE instead of dividing capacity by an assumed 8.76 kWh module), and 30605 Alarm1 (reported as raised/clear — the Appendix 2 decode is not carried, and inventing a description for a code we cannot name would be worse than saying “something is raised”). THE DOCS ALSO GRADED v5.68.0’S GUESSWORK, and it held: 31000 and 31001 are indeed “Rated grid voltage” and “Rated grid frequency” while 31002 is the live “Grid frequency” — exactly what the sampling had concluded from the fact that only 31002 moved. A register that never moves is a nameplate, not a reading, and that test cost 80 seconds.
v5.68.0 — 13-08-2026
WHAT CAN HONESTLY BE SAID ABOUT THE FOUR BATTERY PACKS, PLUS GRID FREQUENCY. Asked for per-pack SOC and temperature, the answer had to start with a measurement: this inverter DOES NOT EXPOSE THEM. Probed register by register on 13-08-2026 across the inverter space (30560-31400) and the plant battery area — every battery figure is an aggregate (SOC, SOH, average cell voltage) or a max/min ACROSS clusters. Nothing per-pack exists at any address, and separate slave IDs do not answer at all. A register in the spec is not a register on the hardware, and neither is one you wish for. BUT max, min AND mean over N identical packs still BOUND the distribution: the other N-2 must average (mean*N - max - min) / (N-2), so whichever end sits furthest from that middle is the odd one out. New pure analyse_pack_balance does exactly that and returns “even” / “one_hot” / “one_cold” — a genuine per-pack signal recovered from aggregates, and precisely what an average is designed to hide. On the live reading (34.9 avg, 39.0 max, 32.4 min, 4 packs) the middle two must average 34.1, putting ONE PACK 4.9 degC clear of its siblings while the cold end is only 1.7 off — one pack running hot, which nothing in the system would otherwise show. It REFUSES to answer when the figures cannot support it: a middle falling outside [min, max] means the mean is not the mean of these packs, so every conclusion from it would be fiction. (That guard earned itself immediately by rejecting an invented test fixture of mine.) Only claims an outlier at >= 2 degC AND >= twice the other gap — one warm pack in a tight group is not news. NEW STATES: packTempSpreadC (Number, so the SQL Logger charts it — one reading says little, a spread widening over weeks is a pack going off), packBalance (List enum, so a trigger can fire on “one pack running hot”), and gridFrequencyHz. Frequency is register 31002, CONFIRMED by sampling rather than assumed: it drifted 49.98 -> 49.95 -> 49.96 Hz over 80 s, which nothing but mains frequency does, while its neighbours sat at exactly 5000 and 2300 throughout — those are the NOMINAL 50.00 Hz and 230.0 V ratings, and a register that never moves is a nameplate, not a reading. It belongs beside the VPP work: a grid event is ultimately a frequency problem, so this is the quantity the whole scheme exists to defend. /api/status battery gains the temperatures, cell voltage, SOH, pack_count and pack_balance; new grid_quality block carries the frequency. Pack count is DERIVED (capacity / 8.76 kWh module = 4 here), never painted in, with batteryPackCount / batteryModuleKwh prefs for a stack that differs; an unknown count simply skips the inference. The whole balance block is wrapped on the state path — it carries SOC, and an advisory figure must never cost it. Tests 611 -> 620.
v5.67.0 — 13-08-2026
PER-PV-STRING READINGS. The inverter’s 31025 block (probed live on this SigenStor 10 kW: [string_count, mppt_count, V1, I1 .. V4, I4], V gain 10, I gain 100; four powers summed to the plant PV total +6.8%, DC vs AC) is read once per poll cycle as a single transaction (sigenergy_modbus v1.8: _read_block_u16 + pure decode_pv_strings; NON-critical, and an absent-latch stops an install whose firmware lacks the block paying a failing read every cycle for ever — the 50000 pre-heat lesson). Each string lands as inverter device states pv1Volts/Amps/Watts..pv4 (Number, so the SQL Logger charts each string’s day — that history is what will NAME the strings: East peaks mid-morning, West in the evening) and in /api/status solar.strings as [{n, label, v, a, w, kwp?}]. Labels from the new pvStringLabels pref (“South:4.275, East:4.275, …” — kWp optional, for capacity-scaled bars), default PV1..PVn until a clear day names the curves; parsing is the pure _parse_pv_string_labels. States written only for strings actually reported — a transient block failure must not chart a phantom string dropout. Consumed by Dashboards v2.78.0 (per-string strip + the actual-vs-expected solar progress chart, which itself needs no SEM change).
v5.66.0 — 12-08-2026
PV FELL TO ZERO MID-WINDOW AND WAS REPORTED AS CURTAILED. The PV verdict in _summarise_vpp_event was a bare min_pv_w > 100 under the v5.56.0 daylight gate, so ANY daytime window whose PV touched zero at any point failed the test. The first two-hour event (12-Aug-2026, 18:00-20:00 BST) hit it: PV ran 1454 W at the start, spiked to 1757 W, then collapsed to 0 W from 18:58 and stayed there, and a textbook 8.26 kWh export was summarised “curtailed”. Curtailment was not merely absent but IMPOSSIBLE — in mode 0x05 with charge pinned at 0, PV can only be curtailed once it exceeds house + the export cap (~4.99 kW that evening) and it peaked at 1.76 kW. THE CAUSE WAS EXTERNAL, NOT US, AND NOT SUNSET: sunset was 20:47:47, i.e. 48 min AFTER the window closed and 1h50m after PV hit zero (a partial solar eclipse that evening, plus cloud — the 551 W -> 1757 W recovery inside five minutes is cloud, not an astronomical curve). Verdict now reads max_pv_w: only a peak that never lifts means the MPPT was shut down, whereas a zero MINIMUM says nothing at all, because PV can reach zero mid-window for reasons that have nothing to do with the inverter. min_pv_w is still reported (a real reading, just not the test), the summary quotes BOTH, and the peak is recorded in a new lastVppMaxPvW state so the number the verdict rests on is durable rather than only in a log line.
v5.65.0 — 12-08-2026
DEEP REVIEW #4, BATCH A1 — the control-and-safety highs. A 16-lens adversarially-verified review (187 confirmed findings, 0 critical, 13 high) against v5.60.1/5.64.0. This batch ships the five that can mis-drive the hardware or spend money on THIS install, plus the test-integrity fix that protects every batch after it. Full register in ~/.claude/plans/sem-deep-review-2026-08-10/.
- TEST INTEGRITY FIRST.
unittest.main()calls sys.exit(), so any class defined BELOW theif __name__ == "__main__"block was unreachable on a direct run. Measured: test_plugin.py ran 223 tests directly against 263 imported, test_openmeteo_forecast 36 against 41 — 45 tests invisible, INCLUDING every VPP guard added in v5.61.1 through v5.64.0. CI uses discover() so it never noticed, while the repo’s own documented command is the direct one. Blocks moved to the end of three files, plus a new TestNoTestsStrandedBelowMain that walks every test_*.py with ast. It caught me making the identical mistake twice while adding this batch’s own tests. - SCHEDULED IMPORT COULD OUTLIVE THE DECISION THAT QUEUED IT (found independently by two lenses — a changed decision, and pause). ACTION_SCHEDULE_IMPORT stored a time that nothing cleared but the firing itself, so when a later evaluate stopped wanting the import the stored time survived, fired with no fresh check, and the anti-oscillation guard then HELD the unwanted import until the stored target SOC was reached. On Tracker the midnight-deferral path arms this ~8 hours ahead. Now retracted centrally in _act_on_decision on any other action (never while an import is running — that is STOP_IMPORT’s job), and cancelled by _disengage_to_safe_baseline so pause and sleep cancel the drive they had queued. _check_scheduled_import runs OUTSIDE the paused gate, so it also refuses directly while paused.
- A 0.0 TARGET BECAME A 100% CHARGE CUTOFF.
(target_soc or 100.0) + 3.0— and 0.0 is exactly what an intervening completed import leaves behind, so the backstop meant to STOP a runaway import became permission for one. The firing path also hardcoded 10000 W; it now follows inverterMaxKw like START_IMPORT. - THE CONTROL PATH USED THE DISPLAY-ONLY BIAS SCALAR. _calculate_24h_balance scaled remaining solar by
biasFactor, the global kWh-weighted number whose own source comment reads “display only”, while corrected_today/tomorrow in the SAME balance used the per-day BAND factor — one energy balance, two scales. Live bands 12-Aug: the 30 kWh band was 1.199 against a global 0.885, 35% apart on exactly the marginal days where the 1.0 kWh overflow threshold sits, and within 3% on a bright day, which is why spot checks saw nothing. New snapshot field bias_factor_today; bias_factor kept for display. - set_self_consumption() HARDCODED 10000 W for both limit registers — right on this 10 kW inverter, a silent discharge cap on any other for a whole verify interval after every return to self-consumption. The rating now lives on the modbus object (SigenergyModbus.inverter_max_w, fed from inverterMaxKw and refreshed on every prefs save), and night_export/daytime_export default to it, so a future mode method cannot reintroduce the hardcode by omission.
- daytime_export() COMMITTED MODE 0x05 BEFORE PINNING CHARGE=0 — the exact greedy-charge window the method exists to close, entered from self-consumption where the charge limit sits at inverter max. With 0x05 live and charge open, high PV banks into the battery instead of going to grid, so a paid VPP window silently exports nothing while the mode register reads a perfectly correct 0x05. Limits are now written BEFORE the mode commit.
- STORM WATCH COULD NOT SEE HALF THE WARNINGS.
_is a word character, so the\bhazard boundaries could never fire beside it and MeteoAlarm’s compound tokens (snow_ice, rain_flood, coastal_flooding) were silently discarded — measured against the shipped regex, while the hyphenated form matched. This is the power-cut reserve feature: a missed warning means the 50% reserve never engages and check_storm_level still returns a confident all-clear. Separators are now collapsed before matching, “flood” joins “flooding”, the word boundaries are KEPT (notice/training/predicted still rejected, pinned by tests), and ignored events are NAMED in the all-clear reason so “no warnings” can never again mean “warnings I could not read”. - Tests 527 -> 588, every fix mutation-tested (mutant made to fail, restored). NB one fixture bug caught only by a deliberate assertGreater(base, 0) guard: a hand-rolled P50 shape summed to zero, and without that guard both assertions would have compared 0.0 to 0.0 and passed against the bug.
v5.64.0 — 12-08-2026
THE HAND-BACK AT WINDOW END WAS NEVER CONFIRMED. _end_vpp_export discarded set_self_consumption()’s return value, under a bare if self.modbus: with no .connected check, so a rejected/clamped/dead-socket write left the state machine IDLE while the inverter stayed in 0x05/0x06 still selling the battery. Only _verify_ems_registers caught it, on the ~15-MINUTE manager cycle — up to ~1.25 kWh to the grid outside the paid window, unpaid and silent (one generic Modbus ERROR, nothing VPP-tagged). Now confirmed, retried once immediately, WARNs in VPP terms, and sets vpp_handback_pending so the 10s tick re-asserts the safe baseline (see _retry_vpp_handback). IDLE is still reached regardless — a hand-back that wedged in ACTIVE would be worse than the bug (Predbat #4477 records exactly that latch). Prompted by Predbat’s #4477, whose own bug is NOT ours: they drive the Sigenergy through the cloud gateway and must offboard a platform authorisation; we self-drive over local Modbus. The transferable lesson is only “latch on success, not attempt”.
v5.63.0 — 11-08-2026
THE POST-EVENT SUMMARY COULD REPORT ANOTHER EVENT’S READINGS. _summarise_vpp_event appended EVERY snapshot record in the file with no check that it belonged to the window being summarised — and files DO hold foreign snapshots: tonight’s over-running window wrote 31 of them into the NEXT event’s file at elapsed -1288 min. v5.61.1 stopped that at source, but the summariser had no defence of its own, so tomorrow’s 2-hour event would have had last night’s peak grid export, min PV, mode list and driver folded into its report. A confidently wrong report is worse than a missing one, and this one would have been read as fact. New pure _snapshot_in_window(rec, event, slack_mins=15): the driver runs T-2min to end+2min, so the bound is the window plus a generous 15 min either side — a legitimate lead/trail sample is always kept while a different day’s is rejected. An UNKNOWN elapsed or duration is KEPT, not dropped: silently shrinking the summary is the worse error. Foreign rows are COUNTED and WARNed, never discarded quietly, because their presence means something upstream filed them wrongly. The live 12-Aug file was also cleaned by hand (31 removed, .bak-polluted kept). AND A NEAR-MISS WORTH THE ENTRY ON ITS OWN: the first cut inserted the new module-level helper INSIDE the class body. py_compile PASSED — the file is valid Python — but an unindented def TERMINATES the class, so _summarise_vpp_event and every method below it became nested functions and Plugin silently lost 100+ methods. Nothing would have failed until the plugin called one at runtime. A SYNTAX CHECK IS NOT A STRUCTURE CHECK. New TestPluginClassStructure asserts, via ast, that the core methods really are methods of Plugin and that the class still holds the bulk of the code — so a dedent mid-class can never ship silently. Suite 547 -> 555.
v5.62.0 — 11-08-2026
A SECOND, INDEPENDENT GUARD ON AN OVER-RUNNING VPP EXPORT. v5.61.1 fixed the cause that fired tonight, but not the shape of the risk: an active window is ended by exactly ONE path — _poll_vpp -> _apply_vpp_event — while the manager re-drives ACTION_VPP_EXPORT every 60 s from the vpp_active BOOLEAN ALONE and never looks at the clock. So every other route that stops that poll reaching its end test lands in exactly the same place as tonight: 4 kW out of the battery against a 1% discharge floor, for ever. Audited and real: axleEnabled unticked mid-window returns at the first line of _poll_vpp; a cleared token makes self.axle None and does the same; a raise before the ACTIVE branch skips the test every tick; and the VPP tick task dying while the manager lives leaves the manager happily re-asserting the export. NONE of these fired tonight — they are simply all still open, which is what “can it happen again” actually asks. New _check_vpp_overrun() runs at the TOP of _evaluate_manager_impl: manager cadence, no network, no prefs, no Axle, so it shares no dependency with the path it backs up. It force-ends through _end_vpp_export — the same path the poll uses, so summary, JSONL and state machine all land normally — and WARNs naming the overshoot, because reaching that line means the primary path failed. Conservative by construction: it acts only past our OWN STORED end + 15 min (the poll stops at end+2min on a 60 s cadence, so it can never truncate a live window), a missing or unparseable end time does NOTHING rather than guess, and the whole body is wrapped so the guard can never break the evaluate it protects. 8 tests incl. tonight’s actual 45-min overshoot; all 8 error against 5.61.1 because the method does not exist there. Suite 539 -> 547.
v5.61.1 — 11-08-2026
A VPP WINDOW NEVER STOPPED — the export ran 45 min past the end and would have run for another 21 hours. Axle publish the NEXT event within a minute of one finishing, and the VPP_ACTIVE branch of _apply_vpp_event judged the stop against the event the API had JUST RETURNED rather than our own stored window. So the test became “now >= TOMORROW’s end + 2min”, false until 18:02 the following day, and the plugin carried on self-driving 4 kW out of the battery with the discharge cutoff at the 1% health floor and nothing left to halt it. LIVE-HIT tonight: the 19:30-20:30 BST window was still exporting at 21:15, SOC 99% -> 73%, ~2.9 kWh sold at 12p that the house wanted at 26p, and on that trajectory the pack would have been flat by ~00:30 and the house importing overnight. The tell was in the JSONL — snapshots were landing in the 12-Aug file at elapsed MINUS 1288 minutes. The end is now judged against self.store[“vpp_event”], and the snapshot is written against it too so the readings stay in the running event’s file. The event is None branch has always done exactly this and even carries a comment explaining why (“we self-drive on our OWN stored window”); this branch simply never got the same care. A future event returned mid-window is picked up on the next poll once the transition has put us back in IDLE. 4 tests, the load-bearing one verified FAILING against 5.61.0 (0 calls where 1 is required); suite 535 -> 539.
v5.61.0 — 11-08-2026
THE NINE kWh VARIABLES HAD NO WRITER ANYWHERE. elec_/gas_/ export_ today/yesterday/month kWh lost their writer when the Octopus consumption script was retired (12-Apr-2026) and were never picked up by the v5.41.0 revival, which took the RATES and COSTS only. So they sat frozen next to live money: export_today_kwh read 0.000 beside an export_today_revenue_gbp of GBP 2.12 (17.69 kWh at 12p), and gas_yesterday_kwh still held the 46 kWh April figure that caused a scare. Measured before touching anything: no writer in either script folder or any plugin bundle, no trigger/schedule/action-group reference, no dashboard reader, and no hard-coded id — dead in every direction. They are now published from the SAME card the costs come from (_wh_build_card / _wh_card_from_row / the period aggregate all carry the kWh they were priced on), so the pair cannot contradict each other again. The settled card deliberately publishes import_kwh_octo, NOT grid_import_kwh — the Octopus figure is what the settle step billed, and the Sigen CT figure differs. An unknown value leaves the variable ALONE rather than writing a confident 0.000, since a fabricated measurement is worse than a stale one. Window kWh accumulate over exactly the rows the window’s money covers, so a day skipped for want of a rate contributes neither. Suite 527 -> 535; 5 of the 8 new cases verified FAILING against 5.60.1, the other 3 deliberate both-sides guards.
v5.60.1 — 08-08-2026
REQUIRED Info.plist KEY. CFBundleURLTypes was PRESENT but EMPTY, so the plugin shipped without the support URL that becomes its “About” menu item — one of the SIX keys the official Developer’s Guide lists as required. An empty array satisfies “key exists” while giving users nowhere to go, which is why an earlier sweep that only looked for a MISSING key passed it. Found by an estate check auditing the VALUE rather than the key’s presence. No plugin logic changed.
v5.60.0 — 08-08-2026
INTELLIGENT OCTOPUS GO WAS UNRECOGNISABLE, AND THE GO WINDOW HAD BEEN AN HOUR OUT SINCE v5.47.0. Four defects on the Go/IOG path, all found by asking a plain question: does the plugin actually support the tariff the house is switching to next spring? (1) DETECTION. Every live Intelligent Go product is INTELLI-FIX-* and Go 12M Fixed is GO-FIX-*; the prefix table only knew INTELLI-VAR / INTELLI-GO / GO-VAR. Both fell through to TARIFF_UNKNOWN, whose planner branch imports AT ONCE at half inverter power — on IOG that is buying at 32.4p instead of waiting for the 8p window. Prefixes updated; an unrecognised tariff now logs a WARNING naming the product code instead of failing silently. (2) IGO/IFLUX RATES WERE NEVER FETCHED. get_all_monitored_rates looped over Go and Flux only, so even with detection fixed the active tariff’s cheap window came back None and _plan_tou_import took its “cheap window unavailable, importing now” branch at 10 kW. Same shape as the v5.59.0 agile_slots bug: two correct halves, never joined. (3) THE GO WINDOW WAS WRONG. Stored 23:30-04:30 since v5.47.0; the product is 00:30-05:30 LOCAL. Verified against both a GMT day (2026-01-14) and a BST day (2026-08-07): the window is fixed in local time and the UTC timestamps move with the clocks, so the earlier “fix” read UTC as local. Cost: an hour bought at the ~30p day rate and an hour of 8.5p missed, every night, all year. (4) THE ROOT CAUSE OF (3) IS HARDCODING. The window is now DERIVED from the live rates (_derive_cheap_window), with TARIFF_WINDOWS as fallback and a WARNING when the two disagree. It refuses to guess for a flat, dynamic (Agile) or multi-window (Cosy) tariff, because a wrong window silently buys at peak and is worse than a missing one. A first attempt at (4) assumed the Agile half-hourly shape and returned None for every real time-of-use tariff — its unit tests passed because the fixture was built the same wrong way. Only running it against the live API found it; the fixtures now mirror what Octopus really serves (spans with valid_to). 13 tests, 12 verified FAILING against 5.59.0; suite 512 -> 527. Live-verified against real Octopus data for Go/Go-Fixed/IOG/Flux/Agile/Cosy/Tracker.
v5.59.0 — 08-08-2026
AGILE SUPPORT WAS A FACADE — NOW WIRED UP. The manager has had a full Agile planner (_plan_agile_import: pick the cheapest half-hour slot before dawn, with a round-trip break-even gate) since v5.44.0, and octopus_api has had get_agile_rates() to fetch those slots. Nothing ever joined the two: no code path wrote an “agile_slots” key into the rates dict, so plugin._build_tariff_data’s rates.get("agile_slots", []) was ALWAYS empty, get_agile_rates() had ZERO callers, and the planner fell every time into its no-rates branch — “importing now” at 10 kW, at whatever the price happened to be. On Agile that can be the 38p evening peak, i.e. the single worst moment to buy. Both halves were individually correct and individually tested, which is exactly why it stayed hidden. get_all_monitored_rates now fetches today AND tomorrow’s slots when Agile is the active tariff (dawn is tomorrow morning, so today alone can never cover the decision) and publishes them under “agile_slots”; a failed fetch for one day no longer loses the other. The tariff device’s today_p also stops reporting the TRACKER rate while on Agile and reports the live half-hour slot instead. 5 regression tests assert the JOIN, not either half — verified to FAIL against v5.58.0. Found while pricing a no-EV winter tariff switch, where Agile is the only time-of-use tariff this house is eligible for.
v5.58.0 — 07-08-2026
LOW-SOC HEADS-UP BEFORE A VPP WINDOW. Pre-charge has always compared SOC against what the window needs, and on a shortfall it logged ONE line and did nothing else — so the first anyone knew of an under-delivered event was the settlement figure days later. That was a fair trade while events carried 18-24 h of notice and needed a manual opt-in. Both halves of that changed on 07-08-2026: Axle now opt SigEnergy members in BY DEFAULT from the 8th, and their new short-notice events give as little as 2 h — far less room for solar to top the battery up before the window. New _alert_vpp_shortfall Pushovers the figures, the window and where export will stop. Deliberately priority 0 so quiet hours CAN suppress it: nothing can be done at 03:00, because pre-charge never imports by design, and being woken to be told the export will be smaller helps nobody — the WARNING (the level was wrong too, so it had been logging as plain Info) is the durable record. Wrapped whole: an advisory must never cost us the export. NOTHING ELSE ON THE DRIVE PATH CHANGED — the lead-in stays at T-2min, measured as already at full export by t=0 (05-Aug -0.66 s: -4000 W; 30-Jul +7.4 s: -3908 W), so the community’s “start 5-10 min early” fix addresses Axle’s CLOUD DISPATCH ramp, a path we do not use. Suite 500 -> 507; 4 of the 7 new cases verified failing against 5.57.0, the other 3 deliberate regression guards that pass on both sides.
v5.57.0 — 06-08-2026
ADVERSARIAL REVIEW OF THE VPP DRIVE PATH — the money-bearing code, first fresh-eyes pass since the 5.30 series. Five confirmed findings, all fixed; one suspected finding REFUTED (the daytime latch does survive a restart — v5.48.0’s persistence covers it).
-
NO DIRECTION GUARD (latent since v5.28.0, recorded then as “noted only” and never closed). Axle’s API carries import_export and the client returns it; _apply_vpp_event never read it. An announced IMPORT event would have been announced, pre-charged and then SELF-DRIVEN AS A FULL 4 kW EXPORT — pushing energy out through the very window the grid wants it in, draining the battery for a dispatch that settles against us. A non-export event is now treated exactly like “no event” (the None branch already stands down pre-window state cleanly), with ONE warning per event, latched on its start time — the poll repeats every 10 minutes, potentially for hours of lead time.
-
DAYTIME-DISCHARGE CHARGE CAP NOT MAINTAINED. daytime_export pins charge=0 because in 0x05 an open charge limit lets high PV charge the battery INSTEAD of exporting — the v5.29.0 missed-dispatch failure. The verify loop skipped every limit during the window, so a failed or externally reverted write resurrected that failure with the mode register reading a perfectly correct 0x05. Exact sibling of the bank charge-cap gap closed in v5.56.0; the verify branch now maintains it, daytime discharge only (dark windows never pinned charge — PV is zero and the register is irrelevant).
-
DISCHARGE LIMIT NOT MAINTAINED EITHER, despite the verify docstring promising “export_active: discharge limit = inverter max” SINCE v5.16 while the ACTIVE branch skipped the whole window. A stale low cap throttles the paid export with nothing to heal it. Now maintained in the discharge sub-mode (the register the drive itself wrote). Neither new write can cause a grid import, so the 10-Apr-2026 rule stands; the old test asserting “limits untouched in discharge” was pinning the GAP as if it were the contract, and has been flipped with its rationale.
-
vpp_export_mode NOT PERSISTED. Verify runs BEFORE the act step, so the first tick after a mid-window restart read the store default (0x06) and “corrected” a daytime window’s 0x02/0x05 register to it — one spurious mode write per restart, each costing a real mode-switch settle (~26 s of degraded export, measured 15-Jun-2026). Now saved in accumulators.json and restored; a pre-5.57 file simply leaves the default.
-
A WINDOW SPANNING MIDNIGHT SETTLED NEGATIVE (latent since v5.28.0 — Axle has only ever sent within-day windows). The export figure is (grid_export_daily_kwh - anchor) and the midnight rollover zeroes the counter, so the log, the JSONL, the device states and the Pushover would all have carried “-N kWh exported”. The rollover now re-bases the anchor (pure _vpp_export_anchor_after_midnight, unit-tested) so the delta is continuous across midnight.
Plus: the late-detection path now writes the JSONL event header it always skipped, so a late-published event’s snapshot file carries its announcement record like every other.
Suite 485 -> 500, with the TEN finding-pinned cases verified FAILING against the pre-fix code and the five deliberately-neutral ones passing on both sides (they assert behaviour that was already right — a test that fails on both sides proves nothing).
v5.56.0 — 05-08-2026
THE POST-EVENT REPORT WAS ANSWERING A QUESTION WE STOPPED ASKING IN JUNE. The VPP summary Pushover still asked what AXLE had done — “Did Axle keep PV running through battery export?”, “What EMS mode did Axle use?” — wording left over from the observe-and-hand-over model that v5.28.0 replaced with self-drive. Every window since has been driven by us over Modbus with Axle’s dispatch ignored, so those questions had a false premise baked in.
Alongside it, pv_survived was a bare min_pv_w > 100 with no daylight test, so EVERY DARK WINDOW reported “PV collapsed” — an alarm about the sun having set. Both fired together on the 05-Aug-2026 21:00-22:00 BST event: 45 snapshots at 0 W PV, a textbook 4.23 kWh export holding grid at -4000 W (+/-50 W) all hour, and a notification saying PV had collapsed and asking what Axle had done. The report was the only thing wrong with that event.
Now: the PV verdict is gated on the daylight flag latched at VPP_ACTIVE entry and reads ran / curtailed / n/a (dark window), with the boolean state kept as the “nothing went wrong” flag a trigger wants. The prompt states plainly that the export was self-driven and asks about OUR mode choice. New device states lastVppPvStatus and lastVppDriver carry what a boolean cannot.
driver in the JSONL was "self" if export_active else "axle" — it mirrored our own intent flag, so it could only ever read “self” once we started driving. It never looked at the hardware, and so could not answer the one question it existed to answer. It now compares the LIVE mode register against the mode we wrote: a mode we did not write, while we hold Remote EMS, means something external moved it. Reported per snapshot and summarised, with a WARNING if it is ever seen.
_verify_ems_registers gains ONE register during an active window: the bank sub-mode’s charge cap. In bank (0x02) that cap IS the export mechanism — it is what stops the inverter soaking the PV surplus into the battery instead of selling it. Drift there would leave the mode register reading a perfectly correct 0x02 while the export fell to nothing, and _drive_vpp_export only rewrites the cap when the surplus moves by >300 W, so it could stand for the rest of the window. This does not reopen the 10-Apr-2026 grid-import incident: that was the solar-overflow cap being written over a VPP window; here the expected value is the VPP driver’s own cap and the check runs only while bank is live. Every other limit still stays untouched for the whole window.
AND THE TIME-ZONE SWEEP IS FINALLY FINISHED — IT HAD BEEN DONE THREE TIMES AND NEVER FINISHED ONCE. v5.22.1 fixed octopus_api. v5.55.3 unified battery_manager on _london_tz / _london_localise / _to_london with stdlib zoneinfo preferred, and wrote a long, accurate note about why duplication caused the bug — filed in plugin.py, which was one of the files it had NOT fixed. plugin.py still had FIFTEEN hand-rolled copies, including _local_today_str() (the midnight-rollover basis, the exact bug class that release was written about) and _event_is_daytime() (where an hour’s error flips a dusk-edge window to daytime and runs the mode that curtails PV); openmeteo_forecast had four more.
The implementation now lives in london_time.py, BELOW every module that needs it, so there is no longer anywhere sensible to put a sixth copy. plugin.py, battery_manager, octopus_api and openmeteo_forecast all import it; import pytz appears in exactly one place in this plugin, inside that module, as the second choice after zoneinfo.
openmeteo_forecast was the piece v5.55.3 deliberately left out, because its dawn parse asks for is_dst=False to resolve the October fallback hour and zoneinfo spells that fold=1 rather than a keyword. THE MAPPING TURNED OUT TO MATTER MORE THAN THE NOTE SUGGESTED: battery_manager’s own shared helper had the pytz branch taking the SECOND (GMT) occurrence and the zoneinfo branch the FIRST (BST), so that hour resolved differently depending on which library happened to be installed. london_localise(prefer_dst=) now makes the choice explicit and identical either way, pinned by tests asserting absolute UTC instants. openmeteo’s four sites all degraded silently too — three left the datetime NAIVE and the fourth fell back to a flat “-1 hour”, which is right for BST and wrong for the four months of GMT. octopus_api’s four were already zoneinfo-first, but two ended in a bare naive datetime, and .astimezone() on one of those reads the SERVER clock.
The tests were lying in the same two ways as last time: a private _london_today helper with its own pytz fallback (so it could disagree with the module for an hour every night in BST), and a ("12:40", "11:40") assertion tolerating a pytz-less host — which would have passed against the very bug being removed. WORSE, THE HARNESS THAT PROVED “GREEN WITHOUT PYTZ” WAS ITSELF A NO-OP: it blocked the import via find_module/load_module, a protocol Python REMOVED in 3.12, so it was silently ignored and pytz was present for every run that claimed otherwise. Rewritten onto find_spec and self-tested before being trusted. With it genuinely blocking, two of the new openmeteo cases fail against the old code. Suite 434 -> 485, 0 skipped, green with and without.
v5.55.5 — 05-08-2026
SOLAR OVERFLOW WAS FLAPPING (battery_manager 3.9 -> 3.10). Its physics gate — does today’s remaining solar exceed the room left in the battery — was a hard cut at exactly zero with no hysteresis, so a day sitting on that boundary flipped the decision every few minutes. Measured on 05-Aug-2026: nine transitions in a day, four inside twenty minutes, at physics surplus 0.0 / 0.4 / 0.2 kWh. Each flip writes a full decision audit plus five Modbus registers.
The cost is not just log noise. Export STOPS for every gap, so PV surplus banks into an already-high battery with the DNO cap unused — the clipping the feature exists to prevent. Live at the time: 86.5% SOC, 7.7 kW PV, grid at -7 W.
It also self-destabilises. Engaging caps the charge, so SOC climbs slower, so headroom to 100% stays large, so the surplus falls back under zero; releasing then fills the battery fast and pushes it straight back over. Cloud (PV 8146 W -> 2152 W in twelve minutes) only adds noise on top of that loop, which is why it looked like weather.
Fix is asymmetric and errs late: engaging now needs >= 1.0 kWh of physics surplus AND ten minutes since the last release; releasing is unchanged at < 0 and stays immediate, so dusk, a storm or a collapsing forecast still stand it down on the next tick and no path can hold a stale cap. All four of that afternoon’s re-engages would have been refused by the threshold alone. Erring late is the KPI-safe direction anyway — a kWh kept in the battery beats one exported at 12p. SOLAR_OVERFLOW_CAP_DEADBAND_W has always damped cap REWRITES on exactly this reasoning; it was simply never applied to the engage/release boundary.
The OVERFLOW audit line now quotes the physics surplus and the threshold it was judged against. The old bare “no surplus or conditions not met” was what made this take a source read to diagnose. The gate and the log share one definition of that number (_overflow_physics_surplus), so they cannot drift apart — the v5.55.3 lesson, applied before rather than after.
v5.55.4 — 04-08-2026
CI has been failing since 02-08 on two ruff F541s — an f-string with no placeholders, in the charge-cutoff backstop warning at sigenergy_modbus.py:953. Dropped the two stray f prefixes. No behaviour change: the string had nothing to interpolate, which is why ruff objected. This repo’s CI is a syntax check plus ruff and has NO test suite at all, so a lint error is the whole gate — worth noting for the most complex plugin in the estate.
v5.55.3 — 30-07-2026
A SILENT UTC FALLBACK IN THE DECISION ENGINE (battery_manager 3.8 -> 3.9). Chasing why four battery_manager tests failed on the usual runner turned up something better than a test-environment quirk: two production sites converted to Europe/London with pytz ONLY and, when pytz was missing, fell back to returning the UTC value unchanged. Not an error — an answer quietly one hour out for the eight months of BST. _to_local() returned dt unconverted, so every caller compared a UTC clock against local wall-clock windows; and the overnight-drain midnight boundary was built at UTC midnight, i.e. 01:00 BST. The failing tests were not noise, they were the two sites being caught.
ROOT CAUSE WAS DUPLICATION: five hand-rolled copies of the same conversion, three with a stdlib-zoneinfo tier and two without. That is also why these two were missed when octopus_api got exactly this fix in v5.22.1 (27-May-2026). Now ONE implementation — _london_tz / _london_localise / _to_london — used by all five, with stdlib zoneinfo PREFERRED over pytz: it ships with Python 3.9+, so it cannot vanish when a Packages rebuild fails (this install has had that happen more than once), and it has no .localize() trap. Attaching a pytz zone via a bare replace(tzinfo=...) yields LMT, -00:01 for London — the exact detail a copied block gets wrong, now impossible to get wrong twice.
LIVE INSTALLS WERE NEVER AFFECTED: pytz>=2024.1 is pinned in requirements.txt and bundled in Contents/Packages (2026.1.post1 present), so the working path was always taken. This removes a latent wrong-answer path, not a live fault.
The test suite was lying too, in three ways, all fixed: two tests did their own import pytz and ERRORED before asserting anything; two more SKIPPED silently (a skipped test is a test that is not testing); and a _today_str() helper returned the UTC date where the module returns the London one, which would have disagreed for one hour every night in BST. Suite now 434 tests, 0 skipped, 0 failures, WITH and WITHOUT pytz — previously 4 failed and 2 skipped.
NOT DONE, deliberately, and flagged rather than half-finished: openmeteo_forecast.py carries the SAME pattern (module-level LONDON_TZ + a PYTZ_AVAILABLE gate that leaves a datetime NAIVE when absent). It cannot be converted piecemeal — its dawn parse calls LONDON_TZ.localize(dt, is_dst=False) to resolve the autumn fold, and zoneinfo expresses that as fold=1, not a kwarg. Changing the module constant without that mapping would break the once-a-year path. Worth doing as its own change with tests pinning the fold equivalence.
v5.55.2 — 30-07-2026
“NO EVENT” WAS BEING REPORTED AS A FAULT. Axle signals “nothing scheduled” in TWO shapes: a null body, and — from the moment an event ends — a full object with every field null: {“start_time”: null, “end_time”: null, “import_export”: null, “opted_out”: false, “updated_at”: “…”} That object is TRUTHY, so it sailed past the empty-body check and landed in the malformed-timestamps branch, logging an ERROR every 10 minutes from 20:00:47 — one minute after tonight’s event, the first in six weeks, ended. By 21:03 it had raised 8 consecutive “failures”, pushed a Pushover alert through Log_Error_Watch, and left the monitor device reading a fault.
The plugin’s BEHAVIOUR was right all along (it read the reply as “no event”); only the reporting was wrong. This misclassification is older than yesterday and was harmless while invisible — v5.55.0 made failures visible, which is exactly how it surfaced. The visibility is doing its job; the classification needed to learn Axle’s second dialect.
BOTH timestamps null = no event. Only ONE null, or present-but-unparseable = genuinely malformed, and STILL an error — that discrimination is the point, and a broader “any null → no event” guard would have quietly lost it. +2 tests, 433 -> 434. One EXISTING test had to be moved rather than relaxed: it asserted an error for a both-absent payload, which encoded the very bug being fixed, so it now uses a present-but-unparseable pair instead.
v5.55.1 — 30-07-2026
THE DASHBOARD’S VPP WINDOW WAS A HARDCODED EMPTY STRING. /api/status published "event_str": "" as a literal, from the day the block was written, so every consumer that appends it rendered “VPP event announced:” and then stopped — the one fact worth showing, WHEN, was the fact missing. Live-spotted on the phone within an hour of the feed coming back, which is the first time anything had ever taken that branch. New _vpp_event_str() formats the stored window through _local_time (so it matches the device states and the log rather than reading an hour early through BST), prefixes the date only when the window is not today, and returns “” on a missing or malformed event because every caller already treats “” as “say nothing”. +5 tests, 428 -> 433.
v5.55.0 — 30-07-2026
A FAILING AXLE POLL IS NOW VISIBLE. Axle announced a grid event for this evening; the plugin knew nothing about it, and had known nothing for six weeks. The token was revoked server-side some time after the 15-Jun event (a JWT, but NOT expired — exp is 2053; the endpoint answers 401 “Could not validate credentials”), so every poll since had failed.
NOT ONE LINE was logged about it. Two faults compounded:
- AxleAPI was the only API client in this plugin constructed WITHOUT a logger, so it fell back to logging.getLogger(“SigenEnergyManager.AxleAPI”) — a logger with no handler attached anywhere here. Every 401 was discarded. Compare OctopusAPI and SigenergyModbus, both of which are handed self.logger.
- get_next_event() returns None for “no event scheduled” AND for a hard failure, so even a caller watching the return value could not tell a dead feed from a quiet week. The VPP device read a calm “Standby” throughout.
The silence is the bug worth fixing — a rejected token is Axle’s business, but six weeks of not knowing is ours. AxleAPI now takes a logger and records last_error; _record_vpp_api_status() logs a failure once and then hourly (a sustained outage costs one line an hour, not one per 10-min poll) and logs the recovery; and the Axle VPP Monitor device carries apiStatus + apiLastOk so the state is visible without reading a log at all. +13 tests (all verified failing against 5.54.0), suite 415 -> 428.
RESOLVED SAME DAY: CliveS fetched a replacement token from his Axle account and put it in IndigoSecrets.py at 14:41; the restart onto this version picked it up and the poll went healthy immediately — apiStatus “OK”, and tonight’s 19:00-20:00 event was announced within seconds, which is the new machinery proving itself on the first try.
GOTCHA THAT NEARLY CAUSED A MISDIAGNOSIS, worth remembering: a plugin host caches IndigoSecrets in sys.modules at ITS OWN startup, so the token a host holds is the one that was on disk when that host last started, NOT what is on disk now. Testing the same key from ClaudeBridge’s context (running since 23-Jul) kept returning 401 AFTER the replacement was in place, because that host still held the old value — a sys.path.insert cannot help, the cached module wins. Read the file directly with importlib when you need to know what is REALLY on disk, and remember a credential change needs a restart of every host that reads it, not just the file edited.
v5.54.0 — 26-07-2026
the restore alert now WORKS OUT whether today’s solar will refill the reserve, instead of leaving the reader to guess which of the two release rules applies to them. CliveS asked whether this was possible or too difficult — it is neither: the plugin already answers exactly that question every 60 seconds, so the alert now asks it once more at send time and states the outcome, with both figures (spare vs needed) so the sums can be checked. New _solar_refill_outlook() builds the provisional snapshot + 24h balance the SAME way _evaluate_manager_impl does inside a lockout window, so the message and the decision cannot disagree. Four outcomes, each phrased honestly: solar covers it (export restarts within the minute), does not cover it YET (names the forecast as a way out), night (does NOT dangle a forecast that cannot arrive), and unknown — where it falls back to naming both rules and claiming nothing. The needed figure quotes the MARGIN-INFLATED bar (× 1.25), because that is the bar actually used; quoting the raw gap would make a “not yet” verdict look wrong to anyone checking it.
The same outlook is now logged at restore time. On 26-Jul-2026 export sat suppressed for 20 minutes after the restore — manager evaluating every 60 s on live data, no errors — and nothing in the log recorded what was being judged, so afterwards the only honest answer was “we cannot tell”. That gap is still UNEXPLAINED and worth a proper look; this line makes the next one answerable. +18 tests, 402 -> 415.
v5.53.0 — 26-07-2026
the power-cut alerts now carry the whole picture. These are the two messages read on a phone during an outage, and they said almost nothing: time, off-grid mode, and how long the cut lasted. Everything needed to judge the situation — how full the battery is, what the house is pulling, how long that lasts, and on a restore what export is doing and both ways the lockout ends — lived in a log nobody opens at the time. Both channels now carry the same full body. Three pure helpers so the arithmetic is testable without a power cut: _backup_runtime_hours (usable energy is everything ABOVE the discharge cutoff, since the inverter stops there and overstating backup is worst in exactly this message), _format_runtime (minutes / one decimal / whole hours / days, capped at “10+ days”), and _lockout_message. Every figure is optional: a paragraph whose readings are missing is dropped rather than printed as a zero, so a partial Modbus read costs one line and never the alert. The lockout end time is derived from the SAME pluginPrefs[“powerRestoredTime”] the window itself uses, so the time quoted cannot drift from when export actually resumes.
Also fixes a LATENT BREAKAGE IN THE STORM ALERTS, found by ruff while in here (F821, pre-existing on HEAD). The v5.45.0 locking restructure split _apply_storm_result out of _check_storm_watch and left loc_name behind in the caller, so the two bodies that quote it — the YELLOW escalation and every ALL-CLEAR — raised NameError instead of sending. Amber and red never quote it, which is why nothing looked broken. The sting is in the tail: storm_alerted_level is written only AFTER a successful send, so a failed all-clear left it stuck at the old level and every later storm at or below it was judged “already alerted” and stayed silent. Armed but never fired — no storm here since 02-Jul-2026. +28 tests total, 374 -> 402; the 3 storm tests raise NameError on the old code.
v5.52.1 — 26-07-2026
the grid-restore message named only ONE of the two export release rules. It promised “unless SOC >= 85%”, wording written before v5.50.0 added the forecast-aware solar-refill release — so when this morning’s 83-second cut (grid lost 08:39:29, restored 08:40:52) was followed by export resuming at 74% SOC at 09:00:30, the plugin had told the owner one thing and done another. The release path itself was already honest (it names WHICH rule fired), but the line read FIRST, at the moment of the outage, was not. Now names both. The Pushover/email body says nothing about export at all, so it needed no change.
v5.52.0 — 25-07-2026
dashboard economics audit — four faults found by checking the figures against the live ledger rather than reading the code.
- “Grid-only” now carries the standing charge. A grid-only home pays the same daily charge, so the counterfactual sat ~£0.62/day (~£225/yr) under the truth while the elec bill printed beside it included the charge. The solar BENEFIT is unchanged — standing cancels in (no_sol + st) - (imp + st) + exp.
- “Elec bill” now carries the standing charge on UNSETTLED days too. It was unit-only for those, and Octopus settles ~a day in arrears, so a 7-day window nearly always held one. Live proof: the week reported £3.82 against a true £4.44 — one day’s standing charge missing, under a header promising “unit + standing”. Together these two make the Period totals row a real identity: solar benefit = grid-only - elec bill + export earned
- /api/daily accepts up to 800 days (was 365). The dashboards’ week-on-week card compares against the same week last year by probing offsets 364-370, and a 365 cap returned at most one of the seven — the year column could never unlock, however long the history grew.
- A missing electricity unit rate no longer paints a green “Covered” badge. _wh_build_card billed the standing charge alone, which export nearly always beat; bill/net/covered now come back None so the page can render “—”.
- Calendar months report elec_whole_house_total_gbp too, so that table reads as the same identity as Period totals.
- The 12p export fallback was written out in nine places — now one constant, DEFAULT_EXPORT_RATE_P, and the
if export_rate_p else 12.0test that silently swapped a genuine 0p rate for 12p is now anis Nonetest. - gas_estimated now honours has_gas on the yesterday / day-before cards, so an electricity-only user stops seeing “(est)” on a £0.00 gas line.
- /api/status publishes battery.capacity_kwh so dashboards stop hardcoding this system’s 35.04 kWh pack.
v5.51.2 — 21-07-2026
shared plugin_utils.py refreshed to v1.3 — the estate-wide propagation of the four Appliance Monitor deep-review fixes.
- install_timestamp_filter() is idempotent — a second call used to stack a second filter, so every log line came out with two timestamps.
import indigois soft, so the module imports outside the Indigo host and can be exercised by offline tests.- A malformed log call keeps its arguments in the log instead of dropping them, so a %-placeholder mismatch is visible.
- New shared as_bool() — a pref re-serialised as the string “false” is truthy, which is exactly the wrong answer. This bundle keeps its LOCAL variant: install_timestamp_filter also walks up to every reachable handler so module-logger records get stamped. That walk is now idempotent too.
5.51.0 — Daytime charge is paced to a 90% target, not 100% (battery_manager 3.7→3.8). The same root cause as 5.50.0, one layer down: the plugin kept treating 100% as the goal when the owner’s requirement is 85-90%. Solar overflow paced the charge to hit 100% exactly at dusk, and because required_charge_kw is subtracted from export BEFORE the DNO cap is applied, that high target spent the low-surplus MORNING buying SOC out of kWh that would have fitted under the cap — then still met the afternoon peak with less headroom than it started with, and clipped anyway. CliveS, 20-Jul: “I do not need to get to 100%, it means the chance of clipping is greater. Anything above 90% is great, above 85% is still OK”, with 80%+ ample for a power cut. The target is a GOAL, not a ceiling. Once export is at the cap the above-cap excess still has nowhere to go but the battery, so it charges straight past the target — the high finish comes free, out of surplus that would otherwise have been binned. Modelled on 20-Jul’s measured curve (dull ~5.1 kW morning, breaking clear to 8.59 kW at 14:00): 100% target -> 45.0 kWh exported, 1.53 kWh CLIPPED, ends 91.1%. 90% target -> 46.6 kWh exported, NOTHING clipped, ends 90.8%. Three-tenths of a point of finish for 1.6 kWh of export and the elimination of the waste. New prefs solarOverflowTargetSoc (90) + solarOverflowMinEndSoc (80), guarded; new snapshot fields solar_overflow_target_pct / solar_overflow_min_end_pct / storm_active. _apply_storm_override sets storm_active, which restores the 100% target — a storm is the one time a genuinely full battery is worth clipping for — while KEEPING the lazy pacing, so it is never force-charged out of export when the day’s own solar would have reached 100% unaided (CliveS’s explicit constraint). NO dull-day guard in the pacing, and this is the interesting bit: the contract tests proved one would be dead code. The physics gate above it only exports when remaining solar EXCEEDS the room to 100%, so whenever overflow runs the day can demonstrably reach 100% — and the gate re-evaluates every tick, so the moment the rest of the day can no longer fill the battery it returns None, export stops and everything charges. A lower target keeps SOC lower, which makes that gate bite EARLIER: the pacing change is self-limiting and the end-of-day level is protected by machinery already present. The floor pref is therefore just a clamp against a mis-set target. Caveat recorded honestly: because the gate cuts export earlier in the afternoon, the real-world gain will be somewhat below the 1.6 kWh the model shows (the model has no gate). +11 contract tests (344 → 355), including a pin that target=100 reduces to the exact pre-3.8 formula, so the change is provably a no-op at the old setting. 5.50.0 — Post-power-cut export lockout is now FORECAST-aware as well as SOC-aware. Prompted by a live incident this morning: the grid dropped for 109 SECONDS at 05:25:54 (restored 05:27:43) and the standard 4-hour lockout armed at SOC 75.6%. The flat 85% floor (v5.34/5.35) then held export off until 07:36:07 while the battery climbed to the floor — 2h 08m of no export, ~3.3 kWh banked instead of sold. Every one of those kWh was exportable at the time: PV surplus ran 1.1-4.25 kW, comfortably inside the 4 kW DNO cap. The bill came due in the afternoon. By 13:28 the battery was at 91.6% with only ~2.9 kWh of headroom against 6.8 kW of PV, so once the washing machine finished the pack would fill and every watt above house+4 kW would be CLIPPED — thrown away, because the DNO cap leaves nowhere for it to go. Without the lockout we’d have been at ~82% with ~6.2 kWh of headroom and clipped nothing. The lockout had converted exportable morning kWh into afternoon curtailment. Underlining it: the overnight optimiser had already computed the day’s actual power-cut resilience minimum as 10% (3.5 kWh), while the lockout insisted on banking to 85%. FIX: a SECOND, strictly-additional release condition (_solar_refill_releases_ lockout, pure + unit-tested). Export also resumes mid-lockout once the day’s remaining solar, net of house load to dusk, covers the gap up to the SOC floor with a 1.25x margin — because on a bright summer morning holding export off banks nothing the sun wasn’t going to deliver anyway. Guarded by a new powerCutLockoutMinSocPct floor (default 50): however good the forecast, the early release never applies to a nearly-empty battery, which is not a resilience reserve. Deliberately expressed in kWh, not SOC percent — an SOC-space form (projected >= floor x margin) is unsatisfiable at the defaults since 85 x 1.25 = 106 > 100. Replayed against this morning’s logged figures it releases at the first daytime tick (~06:00) instead of 07:36; a winter night, a dull December day, an unknown SOC and a below-minimum battery all still hold. Plumbing: _power_cut_window_active() extracted from _resolve_export_lockout so _evaluate_manager_impl can build a provisional snapshot + SufficiencyBalance ONLY inside a lockout window (both calls are pure and side-effect free; outside a window — nearly always — normal ticks pay nothing). _resolve_export_lockout takes an optional balance; omitting it reproduces pre-5.50 behaviour exactly, and a failure building the balance falls back to the flat floor. The one-shot “export re-enabled during lockout” INFO now names WHICH rule released it, and is no longer able to crash formatting an unknown SOC (reachable when export is disabled mid-window). Dashboard power_cut block gains lockout_min_soc + solar_release_active so the Lockout chip can explain itself. NOT applied to the storm override, despite the two mirroring each other since v5.39: a storm forecast means the solar may not arrive, so releasing export on the strength of a forecast is exactly wrong there. Commented in both places so nobody “restores symmetry” later. +22 tests (344 total, all green). 5.49.0 — Solar card figures reconciled. The Energy page read “38.3 kWh today, forecast 53, Remaining 25.3” — figures that cannot be added up. Two causes, both in how remainingTodayKwh was derived (openmeteo_forecast.py 1.6 → 1.7): (a) it was summed off the RAW hourly p50 buckets while the forecast beside it is bias-corrected, so the two sat on different scales (raw 57.7 × 0.915 = 52.8); (b) it counted the WHOLE current hour as still to come, overstating it by up to a full peak hour (~7 kWh at midday). Both now owned by one helper, openmeteo_forecast.remaining_today_kwh, which the fetch path and the enrichment path share. This also corrects the “expected total” line in Show Today’s Energy Summary and Show Manager Status, which add pvDailyKwh to it. The hourly forecast published to the dashboards is scaled by the same day factor, so the bars and their kWh tooltips now sum to the headline forecast (the optimiser JSON already did this). New “Expected total” figure on both dashboards’ solar cards = generated so far + still to come, so the projected end-of-day number is stated rather than left to the reader to work out. NOT touched: forecast_p50 passed to the decision engine stays raw (it is compared against SOLAR_DUSK_THRESHOLD_WH, and scaling would shift dusk detection), and the persisted _hourly_p50* cache buckets stay raw because the bands are recomputed nightly. 9 new tests (27 → 36). 5.48.0 — VPP window survives a plugin restart. The Axle state machine was re-driven purely from the API each poll, so a restart mid-window relied on Axle still returning the active event; if its endpoint drops the event once live (Predbat issue #3051’s failure mode) the rest of the window was silently missed. Now the active window (state + event + pre-charge/cutoff/export flags) is persisted into accumulators.json on every vpp_transition (crash-safe, atomic — pluginPrefs only flush on a graceful shutdown), restored by _load_accumulators regardless of day (a window can span midnight), and _rehydrate_vpp_state() in startup() makes the time-based call: resume an open window WITHOUT the API, or reset the discharge-cutoff register + Self Consumption for a window that ended during downtime. New pure module helpers _serialise_vpp_event / _deserialise_vpp_event / _vpp_resume_decision with a contract test for the restart path. DST-safe throughout (UTC end-to-end). 5.47.0 — Octopus Go/iGo readiness (pre-September switch). (1) Go cheap-window corrected 00:30-05:30 -> 23:30-04:30 in TARIFF_WINDOWS (octopus_api.py + battery_manager.py) to match the live GO-FIX product (region F) — the stale window missed the cheap 23:30-00:30 hour and would have charged 04:30-05:30 at the 31.36p day rate. (2) Power-cut reserve now guaranteed on TOU tariffs: _check_resilience_buffer fired only on Tracker/Flexible, so on Go/iGo a night before a well-covered (sunny) day left nothing holding the dawn_target floor and the battery drifted to the 1% health floor. It now tops the reserve up on Go/iGo/Flux/iFlux too, but ONLY inside the cheap window (night rate, never the day/peak rate) and ONLY when the import planner is not already covering tomorrow, so it never truncates the arbitrage fill. +3 resilience tests; fixed the calendar-flaky surplus-conservatism test (weekend need). 304 pass. 5.46.0 — Gas cost settle: full-day COVERAGE gate (fixes £0.00 gas on the whole-house card). Gas settles slower than electricity and can arrive PARTIALLY: on 03-07 the 1 Jul row froze (cost_settled) at 0.034 kWh / £0.00 gas off a single 00:00-00:30 slot — and because the day/yesterday gas ESTIMATE reuses the most recent settled gas_kwh, the bad row poisoned the estimates too (Octopus app showed £0.74/£0.45; card showed £0.00). History: the same freeze happened 21-Jun and was fixed with a 46-slot gate on BOTH fuels; the later daily-read-meter accommodation (H2) relaxed gas back to presence-only, reintroducing it. Fix: _sum_consumption_for_date now returns complete — readings reach the end of the local day (90-min tolerance) — which is True for a whole half-hourly day AND for a daily meter’s single 24h reading, False for a partial day; the settle gates gas on it. The frozen 2026-07-01 row was un-settled to re-settle with complete data. +6 tests (301 pass; the 1 pre-existing failure is the time-of-day-dependent test_calm_night_drain_continues_unchanged flake, unrelated). 5.45.0 — Locking-model restructure (the last deep-review-#3 deferral). _tick no longer holds _state_lock for its duration: network stages (modbus/forecast/ octopus/VPP/storm/settle) run I/O UNLOCKED and lock only their merge; control stages (evaluate/verify/act, midnight, scheduled import) self-lock whole; get_dashboard_data takes a ms-scale locked snapshot then builds lock-free. NEW test_concurrency.py pins the contract. Bonus bug: the tick stamped last_modbus AFTER _poll_modbus returned, clobbering the v5.43.0 outage back-off (it never worked) — stamps before the call now. Live-verified: dashboard 2.6-10ms during polling (was up to ~20s mid-poll). 288→295 tests. 5.44.0 — Decision tuning. _plan_agile_import gates the cheapest viable slot on round-trip break-even (rate/0.94 must undercut tomorrow’s daytime reference; None = ungated) — returns SELF_CONSUMPTION passthrough when pre-charging loses money. surplus_kwh conservatism CONFIRMED by CliveS and pinned with an annotation + characterisation test. 283→288 tests. 5.43.1 — Deep review #3 batch 3 (~75 lows/infos): Chart.js bundled locally; power-cut state persisted; atomic JSON writes; octopus JWT purge + failure negative-caching; GTI clamps; monotonic throttle; Sigenergy variable folder auto-created; test-quality fixes (tautologies replaced, value-0 decode); companion scripts hardened (optimiser v3.14, digest v1.1, axle v1.3). 5.43.0 — Deep review #3 batch 2 (mediums): pause survives restart; staleness guard holds evaluation on frozen inverter data + poll back-off tiers; flood target + power-cut lockout crash-safe in accumulators.json; no phantom 0% SOC at restart; menu/prefs callbacks under _state_lock; connect() health probe + escalating reconnect; storm word-boundary matching; flood gate requires demand>0; Kraken null-token guard; London-day rate windows (BST skew); forecast staleness caps + day-aware persisted bias baseline; month cost vars fixed (1st-of-month zero + whole-house basis). 5.42.0 — Deep review #3 batch 1 (highs; 122 confirmed findings across the review, 257→283 tests by batch 3): hardware charge-cutoff (reg 40047) backstop on every grid import (target+3%, verify-maintained, released on stop/disengage/startup — a crash mid-import can no longer grid-charge to 100%; hardware-verified 02-07); flood pre-drain aborts when a storm suppresses export mid-drain + stops at max(target, dawn_target); modbus outage aborts the read cycle in ~1s with 2 log lines (was ~20 ERROR lines); storm-feed failure returns None not “none” (level HELD through flaky polls, ~24h decay with its own Pushover). 5.41.0 — Publish the Octopus cost/rate variables (REVIVE). The elec/gas_/ export_*/account_balance Indigo variables had no active writer since their original script was retired, so they had gone stale — elec_unit_rate_p frozen weeks behind the live Tracker rate (read 11p while the ledger said 25.78p), account_balance_gbp stuck at 0. weekly_home_digest.py reads elec_unit_rate_p / export_rate, and get_dashboard_data’s import-rate fallback reads elec_unit_rate_p, so both were silently using stale data. New _write_cost_variables (called from _write_energy_summary_variables, so the long-standing comment that elec_unit_rate_p is written every 30 min is now TRUE) republishes the bill-exact rates + balance from get_account_financials (the Kraken ledger — single source, no duplicate fetch/drift) and today/month costs from the live economics: elec_unit_rate_p, elec_standing_charge_p, gas_unit_rate_p, gas_standing_charge_p, export_rate_p (+ legacy export_rate), account_balance_gbp, elec_today_cost_gbp, gas_today_cost_gbp, export_today_revenue_gbp, combined_today_actual_gbp, elec_month_cost_gbp, export_month_revenue_gbp. Best-effort + fully guarded; a Kraken/economics hiccup leaves the values in place rather than blanking them. 5.40.0 — Storm reserve is now a FLAT 50% for ALL levels (was 50% yellow / 80% amber-red). CliveS’s call: a storm should keep a 50% power-cut reserve and NEVER grid-charge above it. The overnight resilience-buffer import (flat-rate tariff only) tops the battery to the storm floor when below it; with amber/red previously at 80% a storm night would grid-charge to ~82% (costly, against the self-sufficiency KPI). STORM_SOC_AMBER 80→50 so the floor — and thus any storm-driven grid charging — is capped at 50% (tops to ~52% via the existing +2% anti-cycling guard; solar still fills above 50% for free, and export still reopens at the 85% release). Pushover storm alerts reworded to match (50% minimum reserve, no grid charge above it, export held off until nearly full — the old “export suspended” wording predated the 5.39.0 release). Tests updated for the flat-50 reserve (+1; 73 in test_plugin, 151 across suites). 5.39.0 — Storm export suppression is now SOC-aware (mirrors the post-cut lockout floor). The storm override held export OFF for the entire duration of a wind/storm warning, regardless of SOC. With a near-full battery under good solar that rammed it to 100% (charge takes priority over export in self-consumption) and then clipped every watt of PV above the DNO export cap — and it thrashed Solar Overflow on/off every poll. Export is now suppressed ONLY while SOC < STORM_EXPORT_RELEASE_PCT (default 85, configurable via stormExportReleasePct, never below the active reserve target). At/above it the reserve is already banked, so export resumes — Solar Overflow throttles the charge and pushes surplus to grid so the battery creeps up with headroom instead of curtailing. One-shot INFO logs the mid-storm resume. Report exposes storm.export_suppressed + storm.export_release_pct. The openmeteo advisory needs no change — it already defers to the plugin’s published export_enabled/would_fire verdict. +8 tests (73 in test_plugin). 5.35.0 — Comprehensive numeric telemetry for SQL history + tidy-ups. • All numeric inverter telemetry states (batterySoc, *PowerWatts, temps, cell voltage, SoH, cutoff, daily kWh) changed from ValueType=String to Number/Integer and written as real numbers (guarded via _as_int/_as_float), so Indigo’s built-in history (indigo_history.sqlite) records them as chartable columns. Previously every state was a String so nothing but gridOnline charted. Categorical states (emsWorkMode, gridStatus, etc.) stay String. No separate DB (InfluxDB/Postgres) — SQLite + the plugin’s own half-hourly energy_timeseries.db cover it. • SOC floor for the post-cut export lockout is now configurable (powerCutLockoutSocFloor pref, default 85, guarded by _power_cut_lockout_soc_floor()). • Cosmetic: the Live Power Flow “Lockout” chip now keys off power_cut.export_suppressed, not the time window — so a battery exporting above the SOC floor shows “On Grid”, not “Lockout”. Numeric states carry a clean uiValue (_num_state) so the device UI shows “99.6” not “99.59999999999999”. +5 tests (193). 5.34.0 — Power-cut export lockout is now SOC-aware + grid-online SQL state. (1) The 4-hour post-restore export lockout previously killed ALL export, so a near-full battery (e.g. 92%) under good solar would climb to 100% and clip generation we could have exported. The lockout now holds export off only while SOC < POWER_CUT_LOCKOUT_SOC_FLOOR (85%); at/above the floor export resumes so flood-prevention can shed surplus and protect solar. New pure _export_locked_out helper (fail-safe: unknown SOC suppresses); _resolve_export_lockout(soc_pct); store flag power_cut_lockout_active now tracks the time WINDOW (cleared-event fires once on expiry) with power_cut_export_suppressed for the live state. (2) New numeric gridOnline device state (1=on-grid, 0=power cut) so SQL Logger can chart a clean power-cut timeline — the existing states are all strings and don’t chart. Not written on modbus-offline (offline != a real cut). 5.33.0 — Power-cut notifications. When the inverter reports the grid has been lost (the house islands onto the battery) and again when mains power is restored, send a Pushover alert (normal priority, so it respects the configured quiet hours) and an email. Recipient resolves IndigoSecrets.POWERCUT_EMAIL first, then the new powerCutEmailRecipient pref; toggle via powerCutNotify (default on). Both sends are best-effort and never break the poll loop — note a longer outage may also drop the broadband, in which case the alert lands once connectivity returns. 5.32.0 — Single source of truth for the flood-export gate. battery_manager gains _compute_flood_preview (pure, no daytime guard, no side effects) — the ONE place the gate math now lives; _check_flood_prevention consumes it (control behaviour unchanged, 183 tests green). Each manager tick publishes the gate it acts on to sigen_flood_preview.json (_publish_flood_preview, atomic write) so the openmeteo advisory reports the SAME gate verbatim instead of re-deriving and drifting — the 23/24-Jun-2026 “promised an export that never ran” case (advisory used the day+2 ‘tomorrow’ states at 01:45 instead of the refill day). 4 new contract tests lock the preview to the live decision (incl. the forward-looking daytime property + the regression). 5.31.6 — Solar card data: /api/status solar block now also carries actual_today_kwh, peak_w + peak_time (new daily peak-PV tracking, mirrors peak_soc — init/update/midnight-reset/persist), lifetime_kwh and total_kwp. Feeds the new Dashboards Solar card (today vs forecast, now/peak, tomorrow, yield/kWp, self-sufficiency, forecast accuracy, lifetime). Per-array forecast
- measured per-string DEFERRED to a daylight probe (PV=0 at night; inverter reports a ‘4’ count at reg 31025-ish, promising for 4 PV inputs). 179 tests. 5.31.5 — Whole-house cost: an unsettled recent day (Yesterday before its gas settles) now shows a PROVISIONAL card from the row’s Sigen-measured import/export (complete at midnight) + estimated gas, instead of a blank “awaiting settlement”. Electric + export are accurate; only gas is estimated until Octopus settles, then the frozen settled row takes over. New wh_build_card / _wh_provisional_from_row helpers (today now uses the shared builder too). +1 test (179). Pairs with Dashboards v2.14.6 (tag flips settled<->provisional). 5.31.4 — Whole-house cost: /api/status now also exposes
day_before+day_before_date(the settled day before yesterday) so the dashboard can show Today / Yesterday / Day-before. Reliably complete given the ~1-day settlement lag. +1 test (178). Pairs with Dashboards v2.14.3. 5.31.3 — Whole-house cost: don’t freeze a partially-settled day. The settle pass gated only on “gas data present”, so the most recent day (Octopus settles ~a day in arrears, often just the first 1-2 half-hour slots past midnight) was frozen with a near-zero bill PERMANENTLY (cost_settled). Now requires COST_SETTLE_MIN_SLOTS (46 of 48 half-hours) for BOTH import and gas before freezing; the 21-Jun-2026 premature row was un-settled to re-settle when complete. +2 tests (177 pass). Pairs with Dashboards v2.14.2 (Chart.js “Canvas already in use” fix on the 30-day bar). 5.31.2 — Whole-house cost deep-review medium/low batch: atomic daily_history.json writes (_atomic_write_json — temp + fsync + os.replace, both settle and midnight writers, so a crash can’t truncate the never-pruned history); settle float() of API kWh guarded (one bad day skips, not aborts the cycle); _whole_house_summary caches the history parse by file mtime (it runs every ~5s on /api/status) and bounds today’s gas estimate to the last 7 days. octopus_api 1.2->1.3: GraphQL queries parameterised (variables, not raw string-interpolation of account/key), import-vs-export classified by MPAN (not just OUTGOING), first-active-agreement wins, zoneinfo TZ fallback. +11 tests (175 pass): financials error/empty/errors paths, MPAN classification, force-bypass, gas-zero boundary, covered== boundary, partial-row coalescing. Pairs with Dashboards v2.14.1. 5.31.1 — Whole-house cost hardening (deep-review highs batch): (1) settle now values each day’s STANDING + GAS rates at the rate saved on the day (elec_standing_p_day/gas_unit_p_day/gas_standing_p_day, captured in _write_daily_history) rather than the current ledger snapshot — frozen days stay correct across a tariff/price-cap change; falls back to the current ledger only for older/backfilled rows. (2) get_account_financials now negative-caches failures (FINANCIALS_NEG_CACHE_TTL) and returns the stale value, so a Kraken outage no longer makes /api/status fire a GraphQL request every ~5s. (3) _whole_house_summary call isolated in get_dashboard_data so a fault in the new block can’t blank the rest of /api/status. +7 tests (TestSettleWholeHouseCosts, TestWholeHouseSummary); 164 pass. octopus_api 1.1->1.2. 5.31.0 — Whole-house cost (gas + electric, incl. standing charges). New /api/status economics.whole_house block: today (provisional), yesterday (settled), month-to-date net, days self-funded, account balance and a 30-day bill-vs-export series. A 6-hourly settle pass (_settle_whole_house_costs) freezes each day’s cost into daily_history.json once Octopus settles it, valued at the rate that applied on the day so a tariff change never re-writes history. Rates + balance come bill-exact from the Kraken account ledger (octopus_api.get_account_financials, active:true). Gas valued from settled m3 consumption via a configurable calorific factor; gas has no live meter so today’s gas is estimated from the latest settled day. New OCTOPUS_GAS_MPRN / OCTOPUS_GAS_SERIAL secrets + octopusGasMprn / octopusGasSerial / gasKwhPerM3 config fields. Pairs with Dashboards v2.14.0 ‘Whole-house cost’ card. 5.30.1 — Guarantee the full export across ALL PV. Closes a hysteresis gap in 5.30.0: the band was (target-HYST, target+HYST) and HELD the previous sub-mode, so if PV fell from above the cap to just below it (surplus in 3.6-4.0 kW) while latched in “bank”, self-consumption would export only the surplus (~3.7 kW), not the full target — bank mode never discharges to top up. Now: drop to “discharge” the instant surplus < target (battery tops the grid up to the target), and apply the +HYST margin only on ENTERING bank (so a brief PV spike can’t flap us into 0x02). Net guarantee, battery permitting: PV=0 -> battery exports the target; PV<target -> PV + battery = target; PV>target -> target exported + surplus banked. +2 unit tests (test_plugin). 5.30.0 — Daytime VPP export now BANKS the surplus instead of curtailing it. New _drive_vpp_export() re-evaluates every manager tick during VPP_ACTIVE and picks a sub-mode from live PV vs the export target: • “bank” (daytime, PV surplus >= target): Max Self Consumption (mode 0x02) with the battery charge limit capped to (surplus - target). The inverter exports the full target to the grid (held at the DNO cap) AND banks the PV above the target into the battery — same mechanism as Solar Overflow. Live-proven 15-Jun at 10 kW PV: export 4.06 kW + battery charge 4.94 kW + home 1.05 kW, ZERO curtailment. • “discharge” (dark window, or PV surplus < target): the v5.29.x path — mode 0x05 (PV-first) + charge 0 daytime, or 0x06 (ESS-first) dark — battery tops the export up to the target. Guarantees the paid dispatch when PV can’t cover it. Hysteresis (+/-400 W) around the crossover stops mode flapping; Modbus only writes on a sub-mode change or a charge-cap shift > 300 W. vpp_export_mode tracks the live mode (0x02/0x05/0x06) so _verify_ems_registers maintains the right one. New “Force VPP Export Drive (test)” action exercises the integrated driver on hardware. 6 new unit tests (test_plugin). This SUPERSEDES 5.29.1’s always-curtail behaviour for high-PV daytime events while keeping its guaranteed-export floor for low PV. 5.29.2 — Register-map corrections from a deep-dive review against Sigenergy Modbus Protocol V2.9 (2026-05-13), the revision after our V2.8 baseline. Doc/label only, NO behaviour change: REMOTE_EMS_MODES 0x07 is “Reserved” (was mislabelled “AI Mode”; 0x07 was never commanded), 0x08=”V2G” added; the snapshot ems_mode_name decode matches. Corrected the 40032/40034 comments — they are GLOBAL caps “regardless of EMS mode” (which is exactly why 5.29.1’s charge=0 forces export). Header notes register 40001 (PCS active-power dispatch, S32 kW, needs 40029=1 + 40031=0, no command watchdog) as a future “export a precise power” option — deliberately NOT used yet (PCS-level not a grid target, sign must be verified on hardware). sigenergy_modbus module 1.5 -> 1.6. 5.29.1 — Daytime export (mode 0x05) now pins the CHARGE limit to 0. Hardware testing at PV > 4 kW (15-Jun) showed that with the charge limit left open, mode 0x05 greedily charges the battery with PV surplus INSTEAD of exporting — grid sat near 0 for the first 20-60s and the paid 4 kW dispatch was missed (battery +3.5 kW, grid 0). Pinning charge to 0 removes the competing path, so PV is forced out to the grid up to the DNO cap immediately and stably (re-test: grid -4001 W, battery flat from the first sample). Cost: PV above (cap + house) is curtailed for the window — acceptable, the payment far outweighs the un-banked surplus and the battery refills from solar after the event. Sub-4 kW behaviour unchanged (battery already had to top the export up). daytime_export() docstring documents the why; test asserts charge=0. 5.29.0 — Daytime VPP export now uses mode 0x05 (Discharge PV First) instead of 0x06 (Discharge ESS First). 0x06 curtailed PV to 0 W during daytime windows (battery did all the work — confirmed on the 15-Jun 07:00-08:00 event, 4.22 kWh all from battery, PV flat). 0x05 sources the grid dispatch from PV first and only draws the battery for the shortfall, so PV keeps running and the battery is preserved — yet the full (paid) 4 kW dispatch is still guaranteed (and 0x05 == 0x06 when PV is zero, so no downside). _vpp_transition(VPP_ACTIVE) + the manager’s ACTION_VPP_EXPORT re-assert now pick the mode by self._event_is_daytime(); dark windows stay on 0x06. _verify_ems_registers self-heals the chosen mode during VPP_ACTIVE (mode register only — never the limits). New sigenergy_modbus.daytime_export(); new “Force Daytime Export (PV First, test)” action for hardware validation. JSONL snapshots now carry ems_mode_name + driver. 5.28.2 — Axle VPP payment rate is now a config pref (axleVppRatePerKwh, default 1.00 GBP/kWh) instead of a hardcoded £1, used for the earnings estimate on the Axle VPP Monitor. Coerced via the guarded _as_float so a blank/bad value falls back to 1.00. Mirrors Predbat’s axle_pence_per_kwh. No behaviour change beyond the estimate figure. 5.28.1 — VPP cleanup follow-up: removed the now-dead Axle release-watcher (_vpp_check_axle_release + _send_vpp_release_alert, ~122 lines), the VPP_COOLING_OFF state + its dead branches, and the unused AXLE_SUPPORT_EMAIL import. Added a dedicatedvppExportcurrentMode enum Option so a trigger can fire on “VPP export active” (previously reused the startExport token). No behaviour change to the self-drive. 5.28.0 — VPP self-drive. The plugin now drives the export itself for each announced Axle window (T-2min on -> end+2min off) instead of waiting for Axle’s cloud dispatch, which proved unreliable (10-Jun-2026 no-show; Axle confirmed a SigEnergy-API fault). Axle settle on the meter reading so self-export counts identically (~£1/kWh, stacking with Octopus Outgoing 12p). Manager override now returns ACTION_VPP_EXPORT (was a self-consumption stand-down); night_export fires on VPP_ACTIVE entry and is re-asserted idempotently each manager tick; discharge floor = next-day reserve; no grid import. The Axle release-watcher (45/60-min alerts) is bypassed — dead code retained for now, to be removed in a follow-up cleanup once proven over a couple of live events. 5.27.0 — Octopus single source of truth (retires octopus_tracker_rate.py). The plugin now writes the rate + slot-JSON variables the openmeteo battery optimiser consumes — elec_rates_today_json / elec_rates_tomorrow_json (raw Octopus slots), tracker_rate_today / tracker_rate_tomorrow, tracker_product_code/name, tracker_last_updated/fetch_status — from its own octopus_api fetch, on every octopus refresh. New octopus_api.get_active_rate_schedule() returns today+tomorrow raw slots for the ACTIVE tariff (tariff-agnostic, correct for non-Tracker users); plugin._write_tariff_schedule variables() emits them (tomorrow only once published). Removes the two-Octopus-clients duplication; the standalone script’s Indigo schedule (“Octopus Tracker Daily Rate”) is disabled and the script kept on disk as a fallback. Verified live: plugin writes fresh, correct values (fixed the script’s stale tracker_rate_today). 126 tests pass. 5.26.2 — Low-severity sweep (clears the review queue bar the octopus consolidation): • prepare_to_sleep now also resets a raised discharge cutoff to the health floor — a flood-prev/storm/VPP floor left high would lock the battery and force overnight grid import across a long Mac sleep. • Modbus power-limit setters (charge/discharge/export) clamp to a 100kW sanity ceiling before writing watts to the inverter (was lower-bound only). • Poll loop sleeps min(modbus_poll_s, 10) so the advertised 5s “very live” interval is actually honoured (was a hardcoded 10s). • Seasonal + storm overrides log only on state change (were spamming every 60s). • web_dashboard 1.3: NaN/Infinity-safe JSON (one bad float no longer breaks the live update), calendar view state hoisted to
v5.51.1 — 21-07-2026
LOG-LEVEL FIX. indigo.server.log(level=…) wants a Python logging INT — a STRING is silently ignored and the line logs as plain Info. The log() helper passed its level name straight through, so every WARNING and ERROR raised through it had been appearing as an ordinary Info line. Added _lvl() to map the name to a real level. Estate-wide sweep (38 files).
Changes: v5.18.1 (14-05-2026) — quiet the VPP event log. • Per-minute VPP/Axle snapshots moved OUT of the Indigo Event Log and INTO a per-event JSONL file under
Changes: v5.18 (14-05-2026) — TRUE Axle handoff via Remote EMS release. • v5.16 + v5.17 were both stop-gap measures that had the plugin drive the export through mode selection (0x06 or 0x02+charge_limit=0). Both held Remote EMS enabled, which BLOCKED Axle’s cloud channel and forced us to pick among simple Modbus modes that can’t do what Axle’s cloud can (e.g. simultaneous battery discharge + PV charge). • v5.18 properly releases Remote EMS at T-5min before event start via modbus.disable_remote_ems(). With Remote EMS off the inverter follows Sigenergy’s cloud commands directly — Axle now controls the inverter the way other Axle+Sigenergy users see, including keeping PV running through battery export. • Pre-export step (T-4min mode 0x06) removed — replaced by the early T-5min release so Axle has lead-time to dispatch. • Minute-by-minute countdown spam (“[VPP] Event in N min - preparing” every minute from 60 min out) removed. Single T-10min warning instead. T-30min pre-charge trigger unchanged. • New »> RELEASED CONTROL TO AXLE «< marker at T-5min, and »> REGAINED CONTROL «< marker when Axle releases the inverter in COOLING_OFF. Easy to grep. • _log_vpp_snapshot() fires once per minute during VPP_ACTIVE, dumping SOC / PV / battery / home / grid power + EMS mode + charge/discharge limits. Lets us see exactly what Axle is doing for post-event analysis. • Full event-detail dump on announcement (every field Axle’s API returned) — useful for learning what the dispatch metadata contains. • Verify loop reverted to skip during VPP_ACTIVE/COOLING_OFF: the plugin is in observe-only mode for those states; any write would fight Axle. • COOLING_OFF logic unchanged — _vpp_check_axle_release() watches for emsWorkMode containing “Self” (the inverter falls back to Max Self Consumption when Axle finishes), then re-enables Remote EMS and logs REGAINED CONTROL.
Changes: v5.17 (14-05-2026) — DAYTIME VPP fix follow-up. • v5.16 fixed the export-stops-at-event-start bug by setting mode 0x06 (Discharge ESS First) for the VPP window. Export resumed at 4 kW, but PV dropped to 0 W (curtailed by the inverter — mode 0x06 makes the battery do all the discharge, and with grid capped at 4 kW there is nowhere for PV to go, so the MPPT shuts down). • For daytime VPP the right mode is 0x02 (Max Self Consumption) with charge_limit pinned to 0 W. PV can’t be diverted to charge the battery, so PV exits via the AC side and exports to grid; battery only discharges if PV is insufficient to meet (home + grid_cap). Net effect: 4 kW grid export from PV (free), battery preserved for later, no PV curtailment. • Modbus sequence in _vpp_transition(VPP_ACTIVE): set_self_consumption() → mode 0x02, charge/discharge limits 10kW set_charge_limit(0) → battery can’t absorb PV • _verify_ems_registers maintains both registers throughout the event. • Log line updated: “PV exports to grid, battery fills any shortfall (no PV curtailment)”.
Changes: v5.16 (14-05-2026) — VPP event handoff fix. CRITICAL. • Symptom (14-May-2026 morning VPP event): pre-export started correctly at 07:56 with mode 0x06 (Discharge ESS First) — battery exporting. At 08:00:55 the plugin’s _vpp_transition(VPP_ACTIVE) called set_self_consumption() to “clear solar overflow cap before handing control to Axle”. That call switched the inverter from mode 0x06 back to 0x02 (Max Self Consumption), STOPPING the export. Axle then could not override because Remote EMS was still locked to the plugin — so for the rest of the event the battery charged from PV (4 kW) instead of exporting. Result: 0 kWh exported during the paid VPP window when ~10 kWh should have flowed. • Root cause: the plugin used to assume Axle would take Modbus control after the transition and drive the discharge itself. In practice Axle uses Sigenergy’s cloud channel, which is blocked while Remote EMS holds the lock. The “handoff” model never worked end-to-end. • Fix: switch to plugin-driven export through the VPP window. Axle measures via the smart meter, not by sending commands. Specifically: 1. _vpp_transition(VPP_ACTIVE) now calls night_export() (mode 0x06, 10 kW discharge limit) instead of set_self_consumption() — idempotent if pre-export already set the mode; rescues the late-detection path where pre-export never ran. 2. _verify_ems_registers() now actively maintains mode 0x06 during VPP_ACTIVE (was previously skipping all writes, allowing drift if anything else touched register 40031). 3. VPP_ACTIVE -> VPP_COOLING_OFF entry now calls set_self_consumption() to cleanly close the export and return to Max Self Consumption. The “waiting for Axle to release” log line is gone; there is no handback to wait for in the plugin-driven model. • Backward-compatibility: VPP_COOLING_OFF logic (_vpp_check_axle_release) left intact — it’ll see “Self Consumption” in emsWorkMode immediately after our explicit set_self_consumption() and complete the cool-off phase in normal time. • Test plan: at next VPP event, expect mode 0x06 to persist from pre-export through event end with no gap; grid should be exporting at ~10 kW with battery discharging; at event end, mode returns to 0x02 and normal self-consumption resumes.
Changes: v5.15 (13-05-2026) — publish auto-calibrated consumption profile in sigen_site_config.json: • _write_site_config() now includes a “consumption” block with hourly weekday/weekend kWh derived from the 48-slot inverter profile (only when 48 valid slots are accumulated). • _refresh_consumption_profile() republishes the site_config after each refresh so the JSON stays current. • Lets openmeteo_battery_optimiser.py (v2.10+) replace its old Octopus-grid-only profile (~11 kWh/day, wrong) with the plugin’s real-load profile (~22 kWh/day, right). • Background: the Octopus smart-meter export only sees grid imports, so for a solar+battery house it massively under-counts true home consumption. This is the root cause of yesterday’s incident. Changes: v5.14 (13-05-2026) — expose tomorrow solar/need on BatteryManager device: • New Devices.xml states tomorrowSolarKwh and tomorrowNeedKwh published every manager tick by _update_manager_device, computed from snapshot using the SAME logic battery_manager._calculate_24h_balance() uses (tomorrow_weekday + weekday_kwh/weekend_kwh). • Lets external scripts (openmeteo_battery_optimiser.py v2.9+) read the plugin’s actual flood-prevention inputs instead of computing their own and ending up with a different ratio. • Background: 12-May-2026 the optimiser’s 20:00 Pushover promised an overnight pre-drain export (40 kWh solar / 11 kWh typical = 3.6x), but at 00:27 the plugin’s internal view was 63 kWh / 22.4 kWh = 2.81x — just below the 3.0x FLOOD_PREV_FORECAST_MULT gate. No export ran. Both sides used the same constant; the inputs differed because the plugin’s auto-calibrated weekday_kwh (~22) is biased high by spring heating-on data still in its rolling 48-slot profile, while the script used a May seasonal value (~11). Aligning their inputs is cheaper and lower-risk than retuning the calibration. Changes: v5.13 (12-05-2026) — help tooltips on every static label. Changes: v5.10 (12-05-2026) — compact forecast chart with hover tips: • Hourly forecast SVG shrunk from 130px to 80px high (~60% shorter). • kWh labels above each bar removed (less visual noise). • Hover any bar to see a custom floating tooltip with the hour and exact kWh value, with glassmorphism panel + glow. • Bars highlighted on hover for clear visual feedback. • Native SVG