Skip to content

Commit 2e62ea5

Browse files
committed
test: integrate bounded power-sharing and Deye command checks
Sources: PR springfall2008#5498 at 091716c and PR springfall2008#5497 at 68682c7. Conditional stale-SOC scenarios retain their freshness blocker. All controls remain shadow-only in the local rollout.
1 parent f9cdd87 commit 2e62ea5

12 files changed

Lines changed: 535 additions & 31 deletions

‎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/joint_site.py‎

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,15 @@
1010
proof of global optimality. Expected availability never establishes connection.
1111
"""
1212

13-
from dataclasses import dataclass
13+
from dataclasses import dataclass, replace
1414
import math
1515
from typing import Optional, Sequence, Tuple
1616

1717
STATUS_FEASIBLE = "feasible"
1818
STATUS_BOUNDED_NO_PLAN = "bounded_no_plan"
1919
BATTERY_DEMAND, BATTERY_HOLD, BATTERY_CHARGE = "demand", "hold", "charge"
2020
BATTERY_MODES = (BATTERY_DEMAND, BATTERY_HOLD, BATTERY_CHARGE)
21+
BATTERY_LIMITED_DEMAND = "limited_demand"
2122
EV_OFF, EV_ON = "off", "on"
2223
EPSILON = 1e-9
2324

@@ -101,6 +102,7 @@ class PlannerConfig:
101102
time_step_minutes: int = 5
102103
beam_width: Optional[int] = 128
103104
soc_bucket_kwh: float = 0.05
105+
allow_limited_discharge: bool = False
104106

105107

106108
@dataclass(frozen=True)
@@ -147,6 +149,7 @@ class SlotAction:
147149
slot_cost: float
148150
ev_on_minutes: float
149151
segments: Tuple[FlowSegment, ...]
152+
discharge_limit_kw: Optional[float] = None
150153

151154

152155
@dataclass(frozen=True)
@@ -201,6 +204,8 @@ def _validate(slots, ev, battery, site, planner):
201204
raise PlannerInputError("Unsupported planning step")
202205
if planner.beam_width is not None and (type(planner.beam_width) is not int or not 1 <= planner.beam_width <= 4096):
203206
raise PlannerInputError("beam_width must be an integer in 1..4096 or None")
207+
if type(planner.allow_limited_discharge) is not bool:
208+
raise PlannerInputError("allow_limited_discharge must be boolean")
204209
_require_finite("soc_bucket_kwh", planner.soc_bucket_kwh, 1e-6)
205210
if not slots or len(slots) > 576 or (planner.beam_width is None and len(slots) > 12):
206211
raise PlannerInputError("Horizon must have 1..576 slots (at most 12 for exhaustive search)")
@@ -312,18 +317,28 @@ def _segment(duration, house_kw, ev_kw, pv_kw, soc, mode, battery, ev, site):
312317
def _resolve_action(slot, ev_on, mode, node, battery, ev, site):
313318
"""Compute a complete physical step, never a partially served household/EV load."""
314319
on_minutes = 0.0
320+
discharge_limit = None
315321
if ev_on:
316322
if not _ev_is_available(ev, slot.index) or slot.start_min >= ev.deadline_min or node.ev_kwh >= ev.stop_soc_kwh - EPSILON:
317323
return None
318324
if not node.on and (node.off_minutes + EPSILON < ev.min_off_minutes or node.battery_kwh + EPSILON < battery.capacity_kwh * ev.restart_battery_percent / 100):
319325
return None
320326
on_minutes = min(slot.duration_min, ev.deadline_min - slot.start_min, (ev.stop_soc_kwh - node.ev_kwh) / ev.efficiency / ev.rate_kw * 60)
327+
flow_mode = mode
328+
if mode == BATTERY_LIMITED_DEMAND:
329+
# Still battery-first, but under a reproducible per-period power ceiling.
330+
# The fixed ceiling remains in place after a partly completed EV step;
331+
# it is not a fictitious continuously modulating grid-first controller.
332+
net_kw = (slot.ac_load_kwh - slot.pv_kwh) * 60 / slot.duration_min + (ev.rate_kw if on_minutes else 0)
333+
discharge_limit = min(battery.max_discharge_kw, max(0, math.ceil((net_kw - site.import_cap_kw) * 1000 + 2) / 1000))
334+
battery = replace(battery, max_discharge_kw=discharge_limit)
335+
flow_mode = BATTERY_DEMAND
321336
segments = []
322337
soc = node.battery_kwh
323338
for duration, ev_kw in ((on_minutes, ev.rate_kw), (slot.duration_min - on_minutes, 0)):
324339
if duration <= 1e-10:
325340
continue
326-
result = _segment(duration, slot.ac_load_kwh * 60 / slot.duration_min, ev_kw, slot.pv_kwh * 60 / slot.duration_min, soc, mode, battery, ev, site)
341+
result = _segment(duration, slot.ac_load_kwh * 60 / slot.duration_min, ev_kw, slot.pv_kwh * 60 / slot.duration_min, soc, flow_mode, battery, ev, site)
327342
if result is None:
328343
return None
329344
parts, soc = result
@@ -363,6 +378,7 @@ def _resolve_action(slot, ev_on, mode, node, battery, ev, site):
363378
cost,
364379
on_minutes,
365380
tuple(segments),
381+
discharge_limit,
366382
)
367383

368384

@@ -396,6 +412,25 @@ def _bounded_no_plan(deadline_idx, transitions, max_frontier, reason):
396412
return PlanResult(STATUS_BOUNDED_NO_PLAN, None, (), (), (), (), (), (), diagnostic, deadline_idx)
397413

398414

415+
def _reachable_floor_envelope(slots, battery, site):
416+
"""Optimistic charging bound that preserves time-local floors during pruning.
417+
418+
Assume the EV is off and any grid/PV headroom can charge the battery, even
419+
where an actual discrete action could not use all that headroom. This only
420+
rejects states unable to reach a future floor under the most favourable
421+
charging assumption; it does not claim a feasible continuation exists.
422+
"""
423+
required_end = [0.0] * len(slots)
424+
required = battery.end_min_kwh
425+
for slot in reversed(slots):
426+
required = max(required, slot.battery_min_kwh, battery.reserve_kwh)
427+
required_end[slot.index] = required
428+
headroom = max(0, site.import_cap_kw + (slot.pv_kwh - slot.ac_load_kwh) * 60 / slot.duration_min)
429+
gain = min(battery.max_charge_kw, headroom) * slot.duration_min / 60 * battery.charge_efficiency
430+
required = max(slot.battery_min_kwh, battery.reserve_kwh, required - gain)
431+
return required_end
432+
433+
399434
def plan(slots: Sequence[Slot], ev: EVConfig, battery: BatteryConfig, site: SiteConfig, planner: Optional[PlannerConfig] = None) -> PlanResult:
400435
"""Jointly search all eligible prices, EV decisions and executable-mode physics.
401436
@@ -405,6 +440,8 @@ def plan(slots: Sequence[Slot], ev: EVConfig, battery: BatteryConfig, site: Site
405440
"""
406441
planner = planner if planner is not None else PlannerConfig()
407442
_validate(slots, ev, battery, site, planner)
443+
modes = BATTERY_MODES + (BATTERY_LIMITED_DEMAND,) if planner.allow_limited_discharge else BATTERY_MODES
444+
required_end = _reachable_floor_envelope(slots, battery, site)
408445
deadline_idx = _deadline_slot_index(slots, ev.deadline_min)
409446
future = [0.0] * (len(slots) + 1)
410447
for i in range(len(slots) - 1, -1, -1):
@@ -420,12 +457,14 @@ def plan(slots: Sequence[Slot], ev: EVConfig, battery: BatteryConfig, site: Site
420457
for i, slot in enumerate(slots):
421458
next_states = {}
422459
for node in frontier:
423-
for mode in BATTERY_MODES:
460+
for mode in modes:
424461
for on in (False, True):
425462
transitions += 1
426463
action = _resolve_action(slot, on, mode, node, battery, ev, site)
427464
if action is None:
428465
continue
466+
if action.battery_soc_kwh_end < required_end[i] - EPSILON:
467+
continue
429468
if action.ev_soc_kwh_end + future[i + 1] < ev.stop_soc_kwh - EPSILON:
430469
continue
431470
if i == len(slots) - 1 and action.battery_soc_kwh_end < battery.end_min_kwh - EPSILON:
@@ -464,7 +503,7 @@ def plan(slots: Sequence[Slot], ev: EVConfig, battery: BatteryConfig, site: Site
464503
ev_stop_target_met=True,
465504
battery_end_min_met=True,
466505
ev_on_slot_count=sum(a.ev_action == EV_ON for a in actions),
467-
mode_counts=tuple((mode, sum(a.battery_mode == mode for a in actions)) for mode in BATTERY_MODES),
506+
mode_counts=tuple((mode, sum(a.battery_mode == mode for a in actions)) for mode in modes),
468507
notes=notes,
469508
transitions_evaluated=transitions,
470509
max_frontier_size=max_frontier,

0 commit comments

Comments
 (0)