Skip to content
Merged
65 changes: 60 additions & 5 deletions apps/predbat/execute.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,46 @@
Execute Predbat plan
"""

# Per-inverter core charge/export states, used to resolve one headline status across a multi-inverter
# fleet instead of letting whichever inverter is processed last silently win. Precedence lists are
# ordered most-active-first so the most informative sub-state is shown when inverters within the same
# side disagree (e.g. one still actively Charging while another has already reached Hold charging).
CHARGE_STATE_PRECEDENCE = ["Charging", "Freeze charging", "Hold charging"]
EXPORT_STATE_PRECEDENCE = ["Exporting", "Freeze exporting", "Hold exporting"]
CHARGE_SIDE_STATES = set(CHARGE_STATE_PRECEDENCE)
EXPORT_SIDE_STATES = set(EXPORT_STATE_PRECEDENCE)


def resolve_multi_inverter_status(status_per_inverter, current_status):
"""Resolve one headline status across a multi-inverter fleet.

``status_per_inverter`` holds each inverter's own final core charge/export state, keyed by
inverter id, as recorded during execute_plan()'s per-inverter loop - a plain dict overwrite per
inverter, so it correctly reflects each inverter's own last-set state even if that inverter's own
processing passed through more than one core-state assignment.

If inverters disagree across the charge/export divide (one genuinely charging while another
discharges at the same time) that's real cross-charging, not noise - surfaced explicitly rather
than silently showing whichever inverter happened to be processed last. If they only disagree on
sub-state within the same side (e.g. one still actively Charging, another already Hold charging),
the most active one is shown - it's the most informative and matches what the fleet is actually
doing overall.

Falls through to ``current_status`` unchanged when no inverter reached a core charge/export state
at all (pure Demand, Read-Only, Calibration, or a Hold-for-car/iBoost annotation with nothing else
going on) - those cases are already correct and untouched by this resolution.
"""
states_present = set(status_per_inverter.values())
charge_states_present = states_present & CHARGE_SIDE_STATES
export_states_present = states_present & EXPORT_SIDE_STATES
if charge_states_present and export_states_present:
return "Cross-charging"
if charge_states_present:
return next(candidate for candidate in CHARGE_STATE_PRECEDENCE if candidate in charge_states_present)
if export_states_present:
return next(candidate for candidate in EXPORT_STATE_PRECEDENCE if candidate in export_states_present)
return current_status


class Execute:
"""Execution mixin for applying optimised plans to physical inverters.
Expand All @@ -40,6 +80,11 @@ def execute_plan(self):
status_hold_car = "" # car hold status text
status_hold_iboost = "" # iBoost hold status text
status_freeze_export = "" # freeze export during demand status text
# Each inverter's own final core charge/export state, keyed by inverter id - used after the
# loop to detect genuine cross-charging (one inverter charging while another discharges at
# the same time) rather than silently showing whichever inverter's status happened to be
# set last, which hides that the fleet is fighting itself.
status_per_inverter = {}

in_alert = self.alert_active_keep.get(self.minutes_now, 0) > 0
in_manual_soc = self.manual_soc_keep.get(self.minutes_now, 0) > 0
Expand Down Expand Up @@ -198,14 +243,16 @@ def execute_plan(self):
resetDischarge = False

status = "Freeze charging"
status_per_inverter[inverter.id] = status
status_extra += " target" if inverter.id == 0 else " /" # Append multi-inverter target SoC's together
status_extra += " {}%".format(inverter.soc_percent)
status_extra += " {} {}%".format(status, inverter.soc_percent)
self.log("Inverter {} Freeze charging with SoC {}%".format(inverter.id, inverter.soc_percent))
else:
# We can only hold charge if a) we have a way to hold the charge level on the reserve or with a pause feature
# and the current charge level is above the target for all inverters
if self.set_soc_enable and inverter.soc_percent >= inv_target_soc_percent:
status = "Hold charging"
status_per_inverter[inverter.id] = status
self.log(
"Inverter {} Hold charging as SoC {}% is above target SoC {}% (global soc target {}%) set_discharge_during_charge {}".format(
inverter.id, inverter.soc_percent, dp0(inv_target_soc_percent), dp0(target_soc), self.set_discharge_during_charge
Expand Down Expand Up @@ -240,10 +287,11 @@ def execute_plan(self):
inverter.adjust_charge_window(charge_start_time, charge_end_time, self.minutes_now)
else:
status = "Charging"
status_per_inverter[inverter.id] = status
inverter.adjust_charge_window(charge_start_time, charge_end_time, self.minutes_now)

status_extra += " target" if inverter.id == 0 else " /" # append multi-inverter target SoC's together
status_extra += " {}%-{}%".format(inverter.soc_percent, inv_target_soc_percent)
status_extra += " {} {}%-{}%".format(status, inverter.soc_percent, inv_target_soc_percent)

if not self.set_discharge_during_charge and resetPause:
# Do we discharge discharge during charge
Expand Down Expand Up @@ -378,8 +426,9 @@ def execute_plan(self):
self.isExporting_Target = int(target)

status = "Exporting"
status_per_inverter[inverter.id] = status
status_extra += " target" if inverter.id == 0 else " /" # append multi-inverter target SoC's together
status_extra += " {}%-{}%".format(inverter.soc_percent, int(target))
status_extra += " {} {}%-{}%".format(status, inverter.soc_percent, int(target))
# Immediate export mode
else:
inverter.adjust_force_export(False)
Expand All @@ -398,16 +447,18 @@ def execute_plan(self):

self.log("Export Freeze as exporting is now at/below target - current SoC {}kWh and target {}kWh".format(self.soc_kw, discharge_soc))
status = "Freeze exporting"
status_per_inverter[inverter.id] = status
status_extra += " current SoC" if inverter.id == 0 else " /" # append multi-inverter target SoC's together
status_extra += " {}%".format(inverter.soc_percent) # Discharge limit (99) is meaningless when Freeze Exporting so don't display it
status_extra += " {} {}%".format(status, inverter.soc_percent) # Discharge limit (99) is meaningless when Freeze Exporting so don't display it
isExporting = True
target = self.export_window_best[0].get("target", self.export_limits_best[0])
self.isExporting_Target = int(target)
else:
status = "Hold exporting"
status_per_inverter[inverter.id] = status
target = self.export_window_best[0].get("target", self.export_limits_best[0])
status_extra += " target" if inverter.id == 0 else " /" # append multi-inverter target SoC's together
status_extra += " {}%-{}%".format(inverter.soc_percent, inverter.soc_percent)
status_extra += " {} {}%-{}%".format(status, inverter.soc_percent, inverter.soc_percent)
self.isExporting_Target = inverter.soc_percent
self.log("Export Hold (Demand mode) as export is now at/below target or freeze only is set - current SoC {}kWh and target {}kWh".format(self.soc_kw, discharge_soc))
else:
Expand Down Expand Up @@ -628,6 +679,10 @@ def execute_plan(self):
self.count_inverter_writes[inverter.id] += inverter.count_register_writes
inverter.count_register_writes = 0

# Resolve the headline status across all inverters rather than leaving whichever inverter was
# processed last to silently win.
status = resolve_multi_inverter_status(status_per_inverter, status)

# Set the charge/discharge status information
Comment thread
springfall2008 marked this conversation as resolved.
self.set_charge_export_status(isCharging, isExporting, not (isCharging or isExporting))
self.isCharging = isCharging
Expand Down
22 changes: 18 additions & 4 deletions apps/predbat/tests/test_execute.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ def run_execute_test(
assert_pause_discharge=False,
assert_pause_discharge_array=None,
assert_status="Demand",
assert_status_extra=None,
assert_charge_start_time_minutes=-1,
assert_charge_end_time_minutes=-1,
assert_charge_start_time_minutes_array=None,
Expand Down Expand Up @@ -317,6 +318,10 @@ def run_execute_test(

status, status_extra = my_predbat.execute_plan()

if assert_status_extra is not None and assert_status_extra != status_extra:
print("ERROR: status_extra should be {!r} got {!r}".format(assert_status_extra, status_extra))
failed = True

for inverter in my_predbat.inverters:
if assert_status != status:
print("ERROR: Inverter {} status should be {} got {}".format(inverter.id, assert_status, status))
Expand Down Expand Up @@ -675,7 +680,10 @@ def run_execute_tests(my_predbat):
if failed:
return failed

# Here we are already above the target SOC, we can discharge as a hold
# Here inverter 0 is below target (still charging) and inverter 1 is already above target
# (holding). The headline status shows "Charging" - the most active state present across the
# fleet (#4446) - rather than whichever inverter happened to be processed last, since the fleet
# overall genuinely is still charging until inverter 0 catches up.
failed |= run_execute_test(
my_predbat,
"charge_imbalance2",
Expand All @@ -684,7 +692,8 @@ def run_execute_tests(my_predbat):
assert_charge_time_enable=True,
set_charge_window=True,
set_export_window=True,
assert_status="Hold charging",
assert_status="Charging",
assert_status_extra=" target Charging 90%-100.0% / Hold charging 100%-100.0%",
assert_charge_start_time_minutes=-1,
assert_charge_end_time_minutes=my_predbat.minutes_now + 60,
soc_kw=9.5,
Expand Down Expand Up @@ -1704,6 +1713,8 @@ def run_execute_tests(my_predbat):

# Charge freeze, one inverter is empty and the other at 40%
# Inverter 0 will charge to 10% while inverter 1 will freeze at current level of 40% (as it is above the reserve of 1kWh)
# Headline status shows "Charging" - the most active state present (#4446) - since inverter 0
# genuinely is still charging, not just whichever inverter happened to be processed last.
failed |= run_execute_test(
my_predbat,
"charge_freeze_imb1",
Expand All @@ -1714,7 +1725,8 @@ def run_execute_tests(my_predbat):
assert_charge_time_enable_array=[True, False],
soc_kw=2,
assert_pause_discharge_array=[False, True],
assert_status="Freeze charging",
assert_status="Charging",
assert_status_extra=" target Charging 0%-10% / Freeze charging 40%",
assert_reserve=0,
assert_soc_target_array=[10, 100],
assert_immediate_soc_target_array=[10, 40],
Expand Down Expand Up @@ -1772,6 +1784,8 @@ def run_execute_tests(my_predbat):
return failed

# One inverter below the reserve, the other above it, re-balance at 10%
# Headline status shows "Charging" - the most active state present (#4446) - since inverter 0
# genuinely is still charging toward the target, not just whichever inverter was processed last.
failed |= run_execute_test(
my_predbat,
"charge_freeze_imb4",
Expand All @@ -1782,7 +1796,7 @@ def run_execute_tests(my_predbat):
assert_charge_time_enable_array=[True, False],
soc_kw=1,
assert_pause_discharge_array=[False, True],
assert_status="Freeze charging",
assert_status="Charging",
assert_reserve_array=[0, 0],
assert_soc_target_array=[10, 100],
assert_immediate_soc_target_array=[10, 15],
Expand Down
95 changes: 95 additions & 0 deletions apps/predbat/tests/test_execute_multi_inverter_status.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# -----------------------------------------------------------------------------
# Predbat Home Battery System
# Copyright Trefor Southwell 2026 - All Rights Reserved
# This application maybe used for personal use only and not for commercial use
# -----------------------------------------------------------------------------
# fmt off
# pylint: disable=consider-using-f-string
# pylint: disable=line-too-long
# pylint: disable=attribute-defined-outside-init

"""Tests for resolve_multi_inverter_status() (#4446) - the multi-inverter headline status
resolver that replaced execute_plan()'s previous "whichever inverter is processed last wins"
overwrite, which silently hid genuine cross-inverter disagreement (e.g. real cross-charging).
"""

from execute import resolve_multi_inverter_status


def test_multi_inverter_status(my_predbat):
"""Verify the aggregation rule: unchanged when no inverter reached a core state, the shared
state when all agree, the most-active same-side state when they disagree within one side, and
'Cross-charging' when they disagree across the charge/export divide.
"""
failed = False
print("**** Testing resolve_multi_inverter_status ****")

print("Test: no inverter reached a core charge/export state - falls through to current_status unchanged")
for current_status in ["Demand", "Demand (Holiday)", "Read-Only", "Read-Only (Axle)", "Calibration", "Hold for car", "Hold for iBoost"]:
result = resolve_multi_inverter_status({}, current_status)
if result != current_status:
print(" ERROR: expected {!r} unchanged with an empty status_per_inverter, got {!r}".format(current_status, result))
failed = True

print("Test: a single inverter's own state passes straight through")
for state in ["Charging", "Freeze charging", "Hold charging", "Exporting", "Freeze exporting", "Hold exporting"]:
result = resolve_multi_inverter_status({0: state}, "Demand")
if result != state:
print(" ERROR: expected single-inverter state {!r}, got {!r}".format(state, result))
failed = True

print("Test: all inverters agree on the same state - returns that state regardless of current_status")
result = resolve_multi_inverter_status({0: "Charging", 1: "Charging", 2: "Charging"}, "Demand")
if result != "Charging":
print(" ERROR: expected 'Charging' when all 3 inverters agree, got {!r}".format(result))
failed = True

print("Test: charge-side sub-states disagree - most active (Charging) wins over Freeze/Hold charging")
result = resolve_multi_inverter_status({0: "Hold charging", 1: "Charging", 2: "Freeze charging"}, "Demand")
if result != "Charging":
print(" ERROR: expected 'Charging' to win charge-side precedence, got {!r}".format(result))
failed = True

print("Test: charge-side sub-states disagree without Charging present - Freeze charging beats Hold charging")
result = resolve_multi_inverter_status({0: "Hold charging", 1: "Freeze charging"}, "Demand")
if result != "Freeze charging":
print(" ERROR: expected 'Freeze charging' to beat 'Hold charging', got {!r}".format(result))
failed = True

print("Test: export-side sub-states disagree - most active (Exporting) wins over Freeze/Hold exporting")
result = resolve_multi_inverter_status({0: "Hold exporting", 1: "Exporting", 2: "Freeze exporting"}, "Demand")
if result != "Exporting":
print(" ERROR: expected 'Exporting' to win export-side precedence, got {!r}".format(result))
failed = True

print("Test: export-side sub-states disagree without Exporting present - Freeze exporting beats Hold exporting")
result = resolve_multi_inverter_status({0: "Hold exporting", 1: "Freeze exporting"}, "Demand")
if result != "Freeze exporting":
print(" ERROR: expected 'Freeze exporting' to beat 'Hold exporting', got {!r}".format(result))
failed = True

print("Test: genuine cross-charging - one inverter charging, another exporting, both at their most active sub-state")
result = resolve_multi_inverter_status({0: "Charging", 1: "Exporting"}, "Demand")
if result != "Cross-charging":
print(" ERROR: expected 'Cross-charging' for Charging+Exporting, got {!r}".format(result))
failed = True

print("Test: cross-charging detected even from the mildest sub-states on each side (Hold charging + Hold exporting)")
result = resolve_multi_inverter_status({0: "Hold charging", 1: "Hold exporting"}, "Demand")
if result != "Cross-charging":
print(" ERROR: expected 'Cross-charging' for Hold charging+Hold exporting, got {!r}".format(result))
failed = True

print("Test: cross-charging detected across a mix of sub-states on each side, regardless of precedence order")
result = resolve_multi_inverter_status({0: "Freeze charging", 1: "Hold charging", 2: "Exporting"}, "Demand")
if result != "Cross-charging":
print(" ERROR: expected 'Cross-charging' when any charge-side and any export-side state coexist, got {!r}".format(result))
failed = True

print("Test: a Read-Only/Calibration-style current_status is preserved even with an empty dict, not clobbered")
result = resolve_multi_inverter_status({}, "Calibration")
if result != "Calibration":
print(" ERROR: expected 'Calibration' preserved with no per-inverter core states recorded, got {!r}".format(result))
failed = True

return failed
2 changes: 2 additions & 0 deletions apps/predbat/unit_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from tests.test_predict_pv_power import run_predict_pv_power_tests
from tests.test_kernel_parity import run_kernel_parity_tests, run_model_kernel_tests
from tests.test_execute import run_execute_tests
from tests.test_execute_multi_inverter_status import test_multi_inverter_status
from tests.test_octopus_slots import run_load_octopus_slots_tests
from tests.test_multi_car_iog import run_multi_car_iog_tests
from tests.test_fetch_config_options import test_fetch_config_options
Expand Down Expand Up @@ -272,6 +273,7 @@ def main():
("kernel_parity", run_kernel_parity_tests, "C++ prediction kernel vs Python engine parity tests", False),
("inverter", run_inverter_tests, "Inverter tests", False),
("execute", run_execute_tests, "Execute tests", False),
("multi_inverter_status", test_multi_inverter_status, "Multi-inverter headline status resolution tests (#4446)", False),
("basic_rates", test_basic_rates, "Basic rates tests", False),
("rate_min_forward_calc", test_rate_min_forward_calc, "Rate min forward calc tests", False),
("window_sort", run_window_sort_tests, "Window sort tests", False),
Expand Down
Loading