@@ -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+
466492def _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