@@ -103,6 +103,11 @@ def _parse_soc_raw(state, unit, capacity_kwh):
103103 return value
104104
105105
106+ def _observation_clock ():
107+ """Read wall time, never replace an entity's actual observation timestamp."""
108+ return datetime .now (timezone .utc )
109+
110+
106111def _options (base ):
107112 """Read a dated expected session and entity-driven evidence without treating them as permission to switch."""
108113 settings = base .args .get ("joint_site" )
@@ -175,6 +180,10 @@ def _options(base):
175180 soc_payload = _read_entity_state (base , soc_entity ) if soc_entity else None
176181 connection_payload = _read_entity_state (base , connection_entity ) if connection_entity else None
177182 permission_payload = _read_entity_state (base , permission_entity ) if permission_entity else None
183+ # Predbat's forecast reference is floored to five minutes. A real observation
184+ # later in that block is NOT future-dated. Read the observation clock after
185+ # the cached states so a concurrent HA update is compared with current time.
186+ observed_now = _aware (_observation_clock (), "observation_clock" )
178187 manual_target_active = bool (getattr (base , "car_charging_manual_soc" , None ) and base .car_charging_manual_soc and base .car_charging_manual_soc [0 ])
179188 manual_target_kwh = None
180189 if manual_target_active :
@@ -192,7 +201,7 @@ def _options(base):
192201 # No live entity configured: dated session is the only source.
193202 observed = _aware (session ["soc_observed_at" ], "soc_observed_at" ) if isinstance (session ["soc_observed_at" ], datetime ) else _parse_entity_timestamp (session ["soc_observed_at" ], "soc_observed_at" )
194203 max_age = _number (settings .get ("soc_max_age_minutes" , 60 ), "soc_max_age_minutes" , 1 , 1440 )
195- age = (now - observed ).total_seconds () / 60
204+ age = (observed_now - observed ).total_seconds () / 60
196205 if age < 0 or age > max_age :
197206 raise ValueError ("EV SoC observation is stale or future-dated" )
198207 ev_soc = _number (session ["soc_kwh" ], "soc_kwh" , 0 , base .car_charging_battery_size [0 ])
@@ -211,7 +220,7 @@ def _options(base):
211220 entity_blockers .append ("soc_entity_out_of_bounds" )
212221 else :
213222 soc_max_age = _number (settings .get ("soc_max_age_minutes" , 60 ), "soc_max_age_minutes" , 1 , 1440 )
214- soc_age_minutes = (now - soc_payload ["last_updated" ]).total_seconds () / 60
223+ soc_age_minutes = (observed_now - soc_payload ["last_updated" ]).total_seconds () / 60
215224 if soc_age_minutes < 0 or soc_age_minutes > soc_max_age :
216225 entity_blockers .append ("soc_entity_stale_or_future" )
217226 else :
@@ -239,7 +248,7 @@ def _options(base):
239248 raise ValueError ("relay_entity state must be 'on' or 'off'" )
240249 relay_last_changed = relay_payload ["last_changed" ] if relay_payload else None
241250 if relay_last_changed is not None :
242- elapsed_seconds = (now - relay_last_changed ).total_seconds ()
251+ elapsed_seconds = (observed_now - relay_last_changed ).total_seconds ()
243252 if elapsed_seconds < 0 :
244253 entity_blockers .append ("relay_timestamp_future" )
245254 elapsed_minutes_floor = 0
@@ -271,7 +280,10 @@ def _options(base):
271280 "arrival" : start_offset ,
272281 "control_model" : control_model ,
273282 "deadline" : end_offset ,
274- "departure_passed" : departure <= now ,
283+ "departure_passed" : departure <= observed_now ,
284+ "planning_reference_at" : now .isoformat (),
285+ "observations_checked_at" : observed_now .isoformat (),
286+ "planning_alignment_seconds" : (observed_now - now ).total_seconds (),
275287 "ev_soc" : ev_soc ,
276288 "ev_soc_source" : ev_soc_source ,
277289 "load_cap" : _number (settings .get ("load_port_limit_w" ), "load_port_limit_w" , 1 ) / 1000 ,
@@ -570,6 +582,9 @@ def evaluate_joint_site(base, publish=True):
570582 "counterfactual_controls" : options ["control_model" ] != "native" ,
571583 "activation_requirements" : execution_requirements ,
572584 "manual_domestic_floor_slots" : len (base .all_active_keep ),
585+ "planning_reference_at" : options ["planning_reference_at" ],
586+ "observations_checked_at" : options ["observations_checked_at" ],
587+ "planning_alignment_seconds" : options ["planning_alignment_seconds" ],
573588 }
574589 blockers = _collect_blockers (model_base , options )
575590 # Always surface the entity observations even when topology blockers
0 commit comments