Skip to content

Commit 66d92ec

Browse files
committed
fix: integrate joint planning with verified Solarman templates
1 parent affe776 commit 66d92ec

9 files changed

Lines changed: 1790 additions & 23 deletions

‎apps/predbat/joint_site.py‎

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -97,12 +97,21 @@ class PlannerConfig:
9797
9898
Bucketing merges search labels, never stored energy. Such merging and beam
9999
pruning can miss a feasible/cheaper plan, so a miss is not infeasibility proof.
100+
101+
`allowed_battery_modes` declares the subset of battery dispatch modes the
102+
planner may emit. It must be a tuple or list of string tags drawn from
103+
the supported battery-mode vocabulary (`demand`, `hold`, `charge`). The
104+
default equals the full base vocabulary; a tighter subset is honoured by
105+
`_validate` and the search loop. When `allow_limited_discharge` is true,
106+
`limited_demand` is added on top of whatever subset is declared here.
107+
The planner never returns a mode that is not in this subset.
100108
"""
101109

102110
time_step_minutes: int = 5
103111
beam_width: Optional[int] = 128
104112
soc_bucket_kwh: float = 0.05
105113
allow_limited_discharge: bool = False
114+
allowed_battery_modes: Tuple[str, ...] = BATTERY_MODES
106115

107116

108117
@dataclass(frozen=True)
@@ -206,6 +215,20 @@ def _validate(slots, ev, battery, site, planner):
206215
raise PlannerInputError("beam_width must be an integer in 1..4096 or None")
207216
if type(planner.allow_limited_discharge) is not bool:
208217
raise PlannerInputError("allow_limited_discharge must be boolean")
218+
allowed = planner.allowed_battery_modes
219+
if not isinstance(allowed, (tuple, list)) or isinstance(allowed, (str, bytes)):
220+
raise PlannerInputError("allowed_battery_modes must be a tuple or list")
221+
if not allowed:
222+
raise PlannerInputError("allowed_battery_modes must not be empty")
223+
seen = set()
224+
for mode in allowed:
225+
if type(mode) is not str:
226+
raise PlannerInputError("allowed_battery_modes entries must be strings")
227+
if mode not in BATTERY_MODES:
228+
raise PlannerInputError("allowed_battery_modes contains unsupported mode")
229+
if mode in seen:
230+
raise PlannerInputError("allowed_battery_modes contains duplicate mode")
231+
seen.add(mode)
209232
_require_finite("soc_bucket_kwh", planner.soc_bucket_kwh, 1e-6)
210233
if not slots or len(slots) > 576 or (planner.beam_width is None and len(slots) > 12):
211234
raise PlannerInputError("Horizon must have 1..576 slots (at most 12 for exhaustive search)")
@@ -382,13 +405,21 @@ def _resolve_action(slot, ev_on, mode, node, battery, ev, site):
382405
)
383406

384407

385-
def _prune(nodes, width, bucket, terminal_value):
386-
"""Preserve EV-progress diversity instead of pruning every energy-consuming EV path."""
408+
def _prune(nodes, width, bucket, terminal_value, preserve_battery=False):
409+
"""Preserve EV progress and optionally stored-energy resilience within the same beam bound."""
387410
groups = {}
388411
for node in nodes:
389412
groups.setdefault(_bucket(node.ev_kwh, bucket), []).append(node)
390413
for group in groups.values():
391414
group.sort(key=lambda n: (n.cost - terminal_value * n.battery_kwh, n.switches))
415+
if preserve_battery:
416+
# With no hold action, the cheapest label can have too little stored
417+
# energy to serve a later fixed-rate EV start. Keep the cost leader
418+
# and highest-energy label before filling the remaining cost layers.
419+
high = max(group, key=lambda n: n.battery_kwh)
420+
if high is not group[0]:
421+
group.remove(high)
422+
group.insert(1, high)
392423
ordered = [groups[key] for key in sorted(groups, reverse=True)]
393424
kept = []
394425
layer = 0
@@ -440,7 +471,8 @@ def plan(slots: Sequence[Slot], ev: EVConfig, battery: BatteryConfig, site: Site
440471
"""
441472
planner = planner if planner is not None else PlannerConfig()
442473
_validate(slots, ev, battery, site, planner)
443-
modes = BATTERY_MODES + (BATTERY_LIMITED_DEMAND,) if planner.allow_limited_discharge else BATTERY_MODES
474+
base_modes = tuple(planner.allowed_battery_modes)
475+
modes = base_modes + ((BATTERY_LIMITED_DEMAND,) if planner.allow_limited_discharge and BATTERY_LIMITED_DEMAND not in base_modes else ())
444476
required_end = _reachable_floor_envelope(slots, battery, site)
445477
deadline_idx = _deadline_slot_index(slots, ev.deadline_min)
446478
future = [0.0] * (len(slots) + 1)
@@ -484,7 +516,7 @@ def plan(slots: Sequence[Slot], ev: EVConfig, battery: BatteryConfig, site: Site
484516
return _bounded_no_plan(deadline_idx, transitions, max_frontier, "no_complete_transition_at_slot_{}".format(i))
485517
frontier = list(next_states.values())
486518
if planner.beam_width is not None and len(frontier) > planner.beam_width:
487-
frontier = _prune(frontier, planner.beam_width, planner.soc_bucket_kwh, battery.terminal_value_per_kwh)
519+
frontier = _prune(frontier, planner.beam_width, planner.soc_bucket_kwh, battery.terminal_value_per_kwh, preserve_battery=BATTERY_HOLD not in base_modes)
488520
pruned = True
489521
max_frontier = max(max_frontier, len(frontier))
490522
best = min(frontier, key=lambda n: (n.cost - battery.terminal_value_per_kwh * n.battery_kwh, n.switches))

‎apps/predbat/joint_site_apply.py‎

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,8 @@ def execution_fingerprint(base):
102102
payload = {name: getattr(base, name, None) for name in names}
103103
payload["settings"] = base.args.get("joint_site")
104104
payload["deye_local"] = base.args.get("deye_local")
105+
if isinstance(payload["settings"], dict) and payload["settings"].get("control_model") == "deye_solarman":
106+
payload["template_controls"] = {name: base.args.get(name) for name in ("inverter", "inverter_type", "deye_entity_prefix", "charge_start_service", "charge_stop_service", "discharge_stop_service", "charge_freeze_service")}
105107
encoded = json.dumps(payload, sort_keys=True, allow_nan=False, default=str).encode()
106108
return hashlib.sha256(encoded).hexdigest()
107109

@@ -118,7 +120,12 @@ def _cached(base, entity):
118120

119121
def _control_fingerprint(inverter):
120122
"""Detect observed control drift without claiming read-back proves hardware state."""
121-
snapshot = inverter.deye_local.snapshot()
123+
if getattr(inverter, "inverter_type", None) == "DeyeSolarman":
124+
from joint_site_template import snapshot_template
125+
126+
snapshot = snapshot_template(inverter)
127+
else:
128+
snapshot = inverter.deye_local.snapshot()
122129
return hashlib.sha256(json.dumps(snapshot, sort_keys=True, allow_nan=False).encode()).hexdigest()
123130

124131

@@ -137,19 +144,29 @@ def _evidence(base):
137144
now = _clock()
138145
_require(all(0 <= (now - stamp).total_seconds() <= OBSERVATION_MAX_AGE_SECONDS for stamp in stamps), "physical_observation_stale_or_future")
139146
options = _options(base)
140-
_require(options is not None and options["mode"] == "apply" and options["control_model"] == "deye_tou", "real_deye_controls_required")
147+
_require(options is not None and options["mode"] == "apply" and options["control_model"] in ("deye_tou", "deye_solarman"), "real_deye_controls_required")
141148
_require(not _collect_blockers(base, options), "native_inputs_or_overrides_blocked")
142149
_require(not base.set_read_only and not getattr(base, "set_read_only_axle", False), "read_only")
143150
_require(not any(getattr(base, name, False) for name in ("inverter_needs_reset", "had_errors", "fatal_error")), "native_state_not_ready")
144151
_require(not base.get_arg("template", False), "template_mode")
145152
inverters = getattr(base, "inverters", None)
146153
_require(isinstance(inverters, list) and len(inverters) == 1, "single_inverter_required")
147154
inverter = inverters[0]
148-
_require(getattr(inverter, "inverter_type", None) == "DEYE", "local_deye_required")
149-
adapter = getattr(inverter, "deye_local", None)
150-
_require(adapter is not None, "local_deye_adapter_missing")
151-
_require(adapter.settings.get("control") is True and adapter.settings.get("hold_verified") is True, "deye_commissioning_required")
152-
_require(getattr(inverter, "inv_support_charge_freeze", False) is True and not getattr(inverter, "in_calibration", True), "inverter_capability_or_calibration")
155+
if options["control_model"] == "deye_solarman":
156+
from joint_site_template import validate_template
157+
158+
_require(getattr(inverter, "inverter_type", None) == "DeyeSolarman", "template_inverter_required")
159+
try:
160+
validate_template(inverter)
161+
except (ValueError, TypeError, KeyError, AttributeError):
162+
raise JointBlocked("template_contract_invalid") from None
163+
_require(not getattr(inverter, "in_calibration", True), "inverter_capability_or_calibration")
164+
else:
165+
_require(getattr(inverter, "inverter_type", None) == "DEYE", "local_deye_required")
166+
adapter = getattr(inverter, "deye_local", None)
167+
_require(adapter is not None, "local_deye_adapter_missing")
168+
_require(adapter.settings.get("control") is True and adapter.settings.get("hold_verified") is True, "deye_commissioning_required")
169+
_require(getattr(inverter, "inv_support_charge_freeze", False) is True and not getattr(inverter, "in_calibration", True), "inverter_capability_or_calibration")
153170
_require(not getattr(base, "manual_soc_max_keep", None), "manual_ceiling_unsupported")
154171
_require(base.export_limit == 0 and base.set_discharge_during_charge, "deye_dispatch_model_mismatch")
155172
_require(options["import_limit_w"] == getattr(base, "import_limit", options["import_limit_w"]), "import_limit_mismatch")
@@ -168,7 +185,7 @@ def _evidence(base):
168185
minute = (now - base.midnight_utc).total_seconds() / 60
169186
floor = max(base.reserve, base.all_active_keep.get(int(minute), 0) * base.soc_max / 100)
170187
_require(base.soc_kw >= floor - 0.002, "domestic_manual_floor")
171-
adapter.snapshot()
188+
_control_fingerprint(inverter)
172189
return options, inverter, relay, now, minute
173190

174191

@@ -192,6 +209,9 @@ def _intent(command, inverter):
192209
_require(type(command.get("target_soc")) is int and command["target_soc"] in (0, 100), "invalid_target")
193210
_require(type(command.get("freeze")) is bool and type(command.get("ev_on")) is bool, "invalid_command_boolean")
194211
_require(not command["freeze"] or command["target_soc"] == 0, "invalid_hold_target")
212+
if getattr(inverter, "inverter_type", None) == "DeyeSolarman":
213+
_require(command["freeze"] is False and command.get("power_w") is None, "unsupported_template_command")
214+
return command["target_soc"], False, None
195215
cap = min(inverter.deye_local.settings["max_discharge_power_w"], inverter.battery_rate_max_discharge * MINUTE_WATT)
196216
power = command.get("power_w")
197217
_require(_finite(cap, 1), "invalid_discharge_cap")
@@ -313,7 +333,12 @@ def execute_joint_site(base):
313333
# No call to the adapter directly: this is the same native entry point
314334
# whose optional DEYE implementation owns all physical service writes.
315335
attempted = True
316-
observed = inverter.adjust_charge_immediate(target, freeze=freeze, discharge_power_w=power)
336+
if options["control_model"] == "deye_solarman":
337+
from joint_site_template import dispatch_template
338+
339+
observed = dispatch_template(inverter, target)
340+
else:
341+
observed = inverter.adjust_charge_immediate(target, freeze=freeze, discharge_power_w=power)
317342
_require(observed is True, "inverter_command_not_observed")
318343
# Permission, clocks and protection inputs can change while writes settle.
319344
options, inverter, relay, now, minute = _evidence(base)

‎apps/predbat/joint_site_native.py‎

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -145,16 +145,16 @@ def _options(base):
145145
if mode not in ("shadow", "apply"):
146146
raise ValueError("joint_site mode must be off, shadow or apply")
147147
control_model = settings.get("control_model", "native")
148-
if control_model not in ("native", "deye_tou"):
148+
if control_model not in ("native", "deye_tou", "deye_solarman"):
149149
raise ValueError("Unsupported joint-site control model")
150150
if type(settings.get("allow_stale_shadow_soc", False)) is not bool:
151151
raise ValueError("allow_stale_shadow_soc must be boolean")
152152
if type(settings.get("apply_verified", False)) is not bool:
153153
raise ValueError("apply_verified must be boolean")
154154
if type(settings.get("ev_executor_lease_verified", False)) is not bool:
155155
raise ValueError("ev_executor_lease_verified must be boolean")
156-
if mode == "apply" and (control_model != "deye_tou" or settings.get("allow_stale_shadow_soc", False)):
157-
raise ValueError("Apply requires real DEYE controls and cannot use stale SOC assumptions")
156+
if mode == "apply" and (control_model not in ("deye_tou", "deye_solarman") or settings.get("allow_stale_shadow_soc", False)):
157+
raise ValueError("Apply requires supported real controls and cannot use stale SOC assumptions")
158158
for name in ("connection_observed_at_entity", "soc_observed_at_entity"):
159159
if settings.get(name) is not None and (not isinstance(settings[name], str) or not settings[name]):
160160
raise ValueError("Observation timestamp entities must be non-empty strings")
@@ -366,8 +366,26 @@ def _collect_blockers(base, options):
366366
blockers.append("iboost_unsupported")
367367
if not base.car_energy_reported_load:
368368
blockers.append("load_not_reported")
369-
if not base.set_charge_window or not base.set_charge_freeze or base.set_charge_freeze_only or base.set_charge_low_power:
369+
template_model = options and options["control_model"] == "deye_solarman"
370+
if not base.set_charge_window or (not template_model and not base.set_charge_freeze) or base.set_charge_freeze_only or base.set_charge_low_power:
370371
blockers.append("freeze_required")
372+
if template_model:
373+
from joint_site_template import validate_template
374+
375+
inverters = getattr(base, "inverters", None)
376+
if not isinstance(inverters, list) or len(inverters) != 1:
377+
blockers.append("single_template_inverter_required")
378+
else:
379+
try:
380+
validate_template(inverters[0])
381+
except (ValueError, TypeError, KeyError, AttributeError):
382+
blockers.append("template_contract_invalid")
383+
if base.export_limit != 0 or not base.set_discharge_during_charge:
384+
blockers.append("template_dispatch_model_mismatch")
385+
if base.args.get("charge_freeze_service") not in (None, "", []):
386+
# The native target-equals-current branch can call this even without
387+
# an explicit freeze. The narrow template must not acquire that writer.
388+
blockers.append("template_freeze_service_unsupported")
371389
if not base.car_charging_from_battery:
372390
blockers.append("ev_from_battery_required")
373391
if getattr(base, "manual_charge_times", None):
@@ -459,10 +477,18 @@ def build_candidate(base, options):
459477
initial_off_minutes=options["initial_off_minutes"],
460478
)
461479
site = SiteConfig(options["import_limit_w"] / 1000, base.export_limit * 60, options["load_cap"])
462-
result = plan(slots, ev, battery, site, PlannerConfig(beam_width=options["beam"], allow_limited_discharge=options["control_model"] == "deye_tou"))
480+
modes = ("demand", "charge") if options["control_model"] == "deye_solarman" else ("demand", "hold", "charge")
481+
result = plan(slots, ev, battery, site, PlannerConfig(beam_width=options["beam"], allow_limited_discharge=options["control_model"] == "deye_tou", allowed_battery_modes=modes))
463482
return result, slots
464483

465484

485+
def _validate_candidate_modes(options, result):
486+
"""Reject unsupported solver actions before replay/staging, never reinterpret a hold as demand."""
487+
if options["control_model"] == "deye_solarman":
488+
if any(action.battery_mode not in ("demand", "charge") or getattr(action, "discharge_limit_kw", None) is not None for action in result.actions):
489+
raise UnsupportedJointSite("unsupported_template_candidate_controls")
490+
491+
466492
def _native_windows(base, result, slots):
467493
"""Translate modes to native charge/freeze windows without altering executable lists."""
468494
if len(result.actions) != len(slots) or any(action.slot_index != slot.index for action, slot in zip(result.actions, slots)):
@@ -641,7 +667,7 @@ def evaluate_joint_site(base, publish=True):
641667
},
642668
"ready_to_activate": False,
643669
"control_model": options["control_model"],
644-
"counterfactual_controls": options["control_model"] != "native" and options["mode"] == "shadow",
670+
"counterfactual_controls": options["control_model"] == "deye_tou" and options["mode"] == "shadow",
645671
"requested_mode": options["mode"],
646672
"activation_requirements": execution_requirements,
647673
"manual_domestic_floor_slots": len(base.all_active_keep),
@@ -694,6 +720,16 @@ def evaluate_joint_site(base, publish=True):
694720
_forecast_inputs(model_base, options)
695721
stage = "solver"
696722
result, slots = build_candidate(model_base, options)
723+
if options["control_model"] == "deye_solarman":
724+
report["supported_battery_modes"] = ["demand", "charge"]
725+
report["search_beam_attempts"] = [{"beam_width": options["beam"], "solver_status": result.status}]
726+
if not result.actions and options["beam"] < 512:
727+
# A bounded search miss is not infeasibility. One explicit, reported
728+
# retry retains the exact capabilities/inputs and stays within the cap.
729+
retry_options = {**options, "beam": 512}
730+
result, slots = build_candidate(model_base, retry_options)
731+
report["search_beam_attempts"].append({"beam_width": 512, "solver_status": result.status})
732+
_validate_candidate_modes(options, result)
697733
if not result.actions:
698734
report["status"] = "conditional_no_plan_found" if stale_hypothesis else "no_plan_found"
699735
report["execution_blockers"] = list(blockers)
@@ -704,12 +740,13 @@ def evaluate_joint_site(base, publish=True):
704740
windows = _native_windows(model_base, result, slots)
705741
checks = {"nominal": _replay(model_base, options, result, slots, windows, 0), "pv10": _replay(model_base, options, result, slots, windows, 1)}
706742
attempts = [{"forecast_basis": "nominal", "solver_status": result.status, "native_passed": all(check["passed"] for check in checks.values())}]
707-
if options["control_model"] == "deye_tou" and not attempts[0]["native_passed"]:
743+
if options["control_model"] in ("deye_tou", "deye_solarman") and not attempts[0]["native_passed"]:
708744
# One bounded retry uses the forecast envelope and PV10 charge
709745
# delivery assumption. Native nominal AND PV10 still validate
710746
# the same full-rate commands; this is not charge modulation.
711747
retry_options = {**options, "forecast_basis": "envelope"}
712748
retry_result, retry_slots = build_candidate(model_base, retry_options)
749+
_validate_candidate_modes(options, retry_result)
713750
retry_checks = {}
714751
if retry_result.actions:
715752
retry_windows = _native_windows(model_base, retry_result, retry_slots)

0 commit comments

Comments
 (0)