Skip to content

Commit 68682c7

Browse files
committed
feat: validate limited discharge and full Deye command trajectories
Keep manual SOC floors and power ceilings, and replay proposed commands only against isolated states. Physical timetable repair and hold commissioning remain explicit hypotheses.
1 parent 63e8238 commit 68682c7

5 files changed

Lines changed: 134 additions & 9 deletions

File tree

‎apps/predbat/deye_local.py‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -246,13 +246,15 @@ def _report(self, status, mode, reason=None):
246246
attrs["reason"] = reason
247247
self.base.dashboard_item(self.base.prefix + ".deye_local_control", state=status, attributes=attrs)
248248

249-
def apply(self, target_soc, freeze=False):
249+
def apply(self, target_soc, freeze=False, discharge_power_w=None):
250250
"""Apply one native intent with grid-source interlock and no global-limit writes."""
251251
mode = "invalid"
252252
if self.base.set_read_only or getattr(self.base, "set_read_only_axle", False):
253253
self._report("read_only", "none")
254254
return False
255255
try:
256+
if type(freeze) is not bool:
257+
raise ValueError("DEYE freeze must be boolean")
256258
target = self._number(target_soc, 0, 100, "target SOC")
257259
mode = "hold" if freeze else "charge" if target > 0 else "demand"
258260
if not self.settings.get("control", False):
@@ -263,9 +265,17 @@ def apply(self, target_soc, freeze=False):
263265
if self.base.manual_soc_max_keep:
264266
raise ValueError("DEYE local cannot enforce a manual solar charge ceiling")
265267
floor = self._number(self.inverter.reserve_percent, 0, 100, "reserve")
268+
floors = getattr(self.base, "all_active_keep", {})
269+
if not isinstance(floors, dict):
270+
raise ValueError("Invalid DEYE manual SOC floor map")
271+
manual_floor = floors.get(getattr(self.base, "minutes_now", 0), 0)
272+
if isinstance(manual_floor, bool) or not isinstance(manual_floor, (int, float)) or not math.isfinite(manual_floor) or not 0 <= manual_floor <= 100:
273+
raise ValueError("Invalid DEYE manual SOC floor")
274+
floor = max(floor, math.ceil(manual_floor - 1e-9))
266275
soc = 100 if freeze else max(target, floor)
267276
cap = min(self.settings["max_discharge_power_w"], int(round(self.inverter.battery_rate_max_discharge * MINUTE_WATT)))
268-
power = 0 if freeze else cap
277+
requested_power = cap if discharge_power_w is None else self._number(discharge_power_w, 0, cap, "period discharge power")
278+
power = 0 if freeze else requested_power
269279
source = "Grid" if mode == "charge" else "Disabled"
270280
rows = self.settings["programs"]
271281
numbers = {row[field]: value for row in rows for field, value in (("power", power), ("soc", soc))}

‎apps/predbat/deye_local_shadow.py‎

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,12 @@ class DeyeShadowState:
1313
def __init__(self, base, settings):
1414
"""Copy the required observations once; never retain the real base object."""
1515
self.prefix = base.prefix
16+
self.args = {}
1617
self.set_read_only = False
1718
self.set_read_only_axle = False
1819
self.manual_soc_max_keep = deepcopy(base.manual_soc_max_keep)
20+
self.all_active_keep = deepcopy(getattr(base, "all_active_keep", {}))
21+
self.minutes_now = getattr(base, "minutes_now", 0)
1922
self.timezone = base.get_arg("timezone", "Europe/London")
2023
self.states = {}
2124
self.writes = []
@@ -54,7 +57,7 @@ def dashboard_item(self, entity, state, attributes):
5457
self.report = {"state": state, **attributes}
5558

5659

57-
def preview_deye_local(inverter, target_soc, freeze=False):
60+
def preview_deye_local(inverter, target_soc, freeze=False, discharge_power_w=None):
5861
"""Publish a non-executing trace alongside the unchanged native controller.
5962
6063
Success means the translator completed against simulated acknowledgements,
@@ -73,6 +76,7 @@ def preview_deye_local(inverter, target_soc, freeze=False):
7376
"hardware_confirmed": False,
7477
"target_soc": target_soc,
7578
"freeze": bool(freeze),
79+
"discharge_power_w": discharge_power_w,
7680
"native_inverter_type": inverter.inverter_type,
7781
"simulated_acknowledgements": True,
7882
"proposed_writes": [],
@@ -92,7 +96,7 @@ def preview_deye_local(inverter, target_soc, freeze=False):
9296
)
9397
adapter = DeyeLocal(fake_inverter)
9498
adapter.settings = settings
95-
success = adapter.apply(target_soc, freeze)
99+
success = adapter.apply(target_soc, freeze, discharge_power_w=discharge_power_w)
96100
state = "simulated" if success else "blocked"
97101
report["reason"] = simulated.report.get("reason", "Physical commissioning and executable integration remain required")
98102
report["mode"] = simulated.report.get("mode", "none")
@@ -110,7 +114,7 @@ def preview_deye_local(inverter, target_soc, freeze=False):
110114
for row, stamp in zip(settings["programs"], ordered):
111115
simulated.states[row["time"]]["state"] = stamp
112116
simulated.writes.clear()
113-
repaired_success = adapter.apply(target_soc, freeze)
117+
repaired_success = adapter.apply(target_soc, freeze, discharge_power_w=discharge_power_w)
114118
report["table_repair_hypothesis"] = {
115119
"physical_table_changed": False,
116120
"required_before_activation": True,
@@ -123,3 +127,58 @@ def preview_deye_local(inverter, target_soc, freeze=False):
123127
report["reason"] = str(exc) if isinstance(exc, ValueError) else "DEYE shadow input or simulation error"
124128
base.dashboard_item(base.prefix + ".deye_shadow", state=state, attributes=report)
125129
return report
130+
131+
132+
def check_joint_deye_commands(base, steps):
133+
"""Translate a whole candidate through the real Inverter entry point in memory.
134+
135+
A repaired timetable and a commissioned hold can be explicit hypotheses in
136+
this check. They never change the actual configuration or count as fulfilled
137+
activation gates. Only copied HA states are writable by the simulated base.
138+
"""
139+
from inverter import Inverter
140+
141+
report = {"available": True, "passed": False, "hardware_confirmed": False, "actuator_signals_modified": False, "hypotheses": [], "checked_steps": 0}
142+
try:
143+
original = base.args.get("deye_shadow")
144+
if not isinstance(original, dict) or original.get("control", False) is not False:
145+
raise ValueError("A disabled deye_shadow mapping is required for command replay")
146+
settings = DeyeLocal.validate_settings({**original, "control": True})
147+
simulated = DeyeShadowState(base, settings)
148+
inverter = Inverter.__new__(Inverter)
149+
inverter.base = simulated
150+
inverter.id = 0
151+
inverter.inverter_type = "DEYE"
152+
inverter.reserve_percent = int(round(base.reserve / base.soc_max * 100))
153+
inverter.battery_rate_max_discharge = base.battery_rate_max_discharge
154+
inverter.inv_write_and_poll_sleep = 0
155+
inverter.count_register_writes = 0
156+
inverter.deye_local = DeyeLocal(inverter)
157+
inverter.deye_local.settings = settings
158+
try:
159+
inverter.deye_local.snapshot()
160+
except ValueError as exc:
161+
if "boundaries must be strictly increasing" not in str(exc):
162+
raise
163+
rows = settings["programs"]
164+
times = [simulated.states[row["time"]]["state"] for row in rows]
165+
minutes = [int(stamp[:2]) * 60 + int(stamp[3:5]) for stamp in times]
166+
if len(set(minutes)) != 6:
167+
raise ValueError("Duplicate ToU times cannot be repaired by sorting") from None
168+
for row, stamp in zip(rows, [stamp for _, stamp in sorted(zip(minutes, times))]):
169+
simulated.states[row["time"]]["state"] = stamp
170+
report["hypotheses"].append("physical_timetable_must_be_normalised_before_activation")
171+
if not settings.get("hold_verified", False):
172+
settings["hold_verified"] = True
173+
report["hypotheses"].append("hold_contract_still_requires_physical_commissioning")
174+
for step in steps:
175+
simulated.minutes_now = step["start"]
176+
if not inverter.adjust_charge_immediate(step["target_soc"], freeze=step["freeze"], discharge_power_w=step["power_w"]):
177+
raise ValueError(simulated.report.get("reason", "DEYE command replay did not complete"))
178+
report["checked_steps"] += 1
179+
report["passed"] = True
180+
report["simulated_service_count"] = len(simulated.writes)
181+
report["single_initial_observation_set"] = True
182+
except Exception as exc:
183+
report["reason"] = str(exc) if isinstance(exc, ValueError) else "DEYE command replay error"
184+
return report

‎apps/predbat/inverter.py‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3396,19 +3396,21 @@ def call_service_template(self, service, data, domain="charge", extra_data=None)
33963396
# transient failure would silently downgrade a freeze into a stop rather than retrying it.
33973397
return True
33983398

3399-
def adjust_charge_immediate(self, target_soc, freeze=False):
3399+
def adjust_charge_immediate(self, target_soc, freeze=False, discharge_power_w=None):
34003400
"""
34013401
Adjust from charging or not charging based on passed target soc
34023402
"""
34033403
if self.base.args.get("deye_shadow") is not None:
34043404
try:
34053405
from deye_local_shadow import preview_deye_local
34063406

3407-
preview_deye_local(self, target_soc, freeze)
3407+
preview_deye_local(self, target_soc, freeze, discharge_power_w=discharge_power_w)
34083408
except Exception:
34093409
self.log("Warn: DEYE shadow preview failed; native inverter control continues unchanged")
34103410
if getattr(self, "deye_local", None):
3411-
return self.deye_local.apply(target_soc, freeze=freeze)
3411+
return self.deye_local.apply(target_soc, freeze=freeze, discharge_power_w=discharge_power_w)
3412+
if discharge_power_w is not None:
3413+
raise ValueError("Explicit ToU discharge power requires a DEYE local inverter")
34123414
# A Solis with a target SoC (FB00) has its Energy Storage Control Switch driven from here on every cycle
34133415
# that is not exporting. It stays on Backup/Reserve - Self-Use with the Battery Reserve bit, which makes the
34143416
# reserve Predbat writes (the Reserved SOC) a real discharge floor, so the existing reserve holds work.

‎apps/predbat/tests/test_deye_shadow.py‎

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from types import SimpleNamespace
66
from unittest.mock import patch
77

8-
from deye_local_shadow import preview_deye_local
8+
from deye_local_shadow import preview_deye_local, check_joint_deye_commands, DeyeShadowState
99
from inverter import Inverter
1010
from const import MINUTE_WATT
1111

@@ -136,6 +136,45 @@ def run_deye_shadow_tests(my_predbat):
136136
reference = calls
137137
assert calls == reference and calls
138138
tests += 1
139+
140+
source = ShadowSource()
141+
source.reserve = 1
142+
source.soc_max = 10
143+
source.battery_rate_max_discharge = 3000 / MINUTE_WATT
144+
source.all_active_keep = {125: 30.25}
145+
source.minutes_now = 120
146+
before = deepcopy((source.states, source.args, source.all_active_keep))
147+
rows = source.args["deye_shadow"]["programs"]
148+
source.states[rows[2]["time"]]["state"] = "23:55:00"
149+
before = deepcopy((source.states, source.args, source.all_active_keep))
150+
steps = [
151+
{"start": 120, "target_soc": 100, "freeze": False, "power_w": None},
152+
{"start": 125, "target_soc": 0, "freeze": False, "power_w": 1500},
153+
{"start": 130, "target_soc": 0, "freeze": True, "power_w": None},
154+
{"start": 135, "target_soc": 0, "freeze": False, "power_w": 1800},
155+
]
156+
calls = []
157+
original_call = DeyeShadowState.call_service_wrapper
158+
159+
def record_command(simulated, service, **data):
160+
"""Observe every simulated command without providing real service authority."""
161+
calls.append((service, dict(data)))
162+
return original_call(simulated, service, **data)
163+
164+
with patch.object(DeyeShadowState, "call_service_wrapper", new=record_command):
165+
checked = check_joint_deye_commands(source, steps)
166+
assert checked["passed"] and checked["checked_steps"] == 4, checked
167+
assert len(checked["hypotheses"]) == 2 and not checked["hardware_confirmed"]
168+
assert before == (source.states, source.args, source.all_active_keep)
169+
for row in rows:
170+
assert ("number/set_value", {"entity_id": row["soc"], "value": 31}) in calls
171+
assert ("number/set_value", {"entity_id": row["power"], "value": 1500}) in calls
172+
tests += 1
173+
for bad_power in (-1, 2001, 1500.5, float("nan"), True):
174+
checked = check_joint_deye_commands(source, [{**steps[1], "power_w": bad_power}])
175+
assert not checked["passed"] and checked["checked_steps"] == 0, checked
176+
assert before == (source.states, source.args, source.all_active_keep)
177+
tests += 1
139178
print("DEYE shadow: {} isolation checks passed".format(tests))
140179
return False
141180
except Exception as exc:

‎docs/deye-local.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,14 @@ down to its target, matching `set_discharge_during_charge` on this profile.
3434
The charge rate must already be set and correctly modelled by the installation;
3535
this adapter does not modulate it or raise current limits.
3636

37+
An explicit `discharge_power_w` request can narrow the per-period discharge
38+
ceiling, without changing global current limits or the EV's fixed charging rate.
39+
Battery-first demand then supplies up to that ceiling and the grid supplies the
40+
remaining load. The requested watts must be finite whole units between zero and
41+
the already configured ceiling. Active native manual SOC floors are preserved;
42+
fractional floors round upward to the hardware's whole-percent control. Neither
43+
a per-period ceiling nor a forecast is an instantaneous site-import guarantee.
44+
3745
The hold contract must be commissioned on the actual inverter before setting
3846
`hold_verified: true`. Only then is charge freeze advertised to the planner for
3947
this instance. This does not change the `SK` capability declaration. If a hold is
@@ -85,6 +93,13 @@ same times sorted **only in the private copy**. This shows the proposed commands
8593
after a possible migration; it neither repairs the physical table nor fulfils
8694
the activation precondition. Duplicate or invalid times are not guessed.
8795

96+
The joint-planning companion can also check a whole candidate through the same
97+
`Inverter.adjust_charge_immediate` entry point, including explicit discharge-watt
98+
ceilings and time-local manual SOC floors. This uses only copied observations and
99+
simulated acknowledgements. Any sorted timetable or commissioned hold used in the
100+
check is labelled as a hypothesis, never applied to the actual instance. Global
101+
current limits and hardware behaviour still require an operational preflight.
102+
88103
### Selecting the experimental controller
89104

90105
Start from the [disabled template fragment](https://github.com/springfall2008/batpred/blob/main/templates/deye.yaml),

0 commit comments

Comments
 (0)