@@ -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
0 commit comments