Skip to content

Commit f9cdd87

Browse files
committed
fix: integrate verified live observation clock correction
Source: joint-planner PR springfall2008#5498, commit 5653cc0. Shadow-only; no control activation.
1 parent 7a800b7 commit f9cdd87

3 files changed

Lines changed: 44 additions & 5 deletions

File tree

‎apps/predbat/joint_site_native.py‎

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
106111
def _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

‎apps/predbat/tests/test_joint_site_native.py‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -687,6 +687,22 @@ def test_closed_session_does_not_roll_to_tomorrow(base):
687687
assert _controls(base) == before and report["ready_to_activate"] is False
688688

689689

690+
def test_observations_after_rounded_plan_time_are_not_future(base):
691+
"""Reproduce a mid-slot live startup without accepting truly future data."""
692+
_setup(base)
693+
actual_now = base.now_utc + timedelta(minutes=2)
694+
_set_entity(base, "sensor.test_ev_soc", "53", last_updated=base.now_utc + timedelta(seconds=90))
695+
_set_entity(base, "binary_sensor.test_relay", "off", last_changed=actual_now - timedelta(seconds=59), last_updated=actual_now)
696+
base.args["joint_site"].update(soc_entity="sensor.test_ev_soc", soc_entity_unit="%", relay_entity="binary_sensor.test_relay")
697+
with patch("joint_site_native._observation_clock", return_value=actual_now):
698+
options = _options(base)
699+
assert not options["entity_blockers"], options
700+
assert options["ev_soc"] == 13.25 and options["initial_off_minutes"] == 0
701+
assert options["planning_alignment_seconds"] == 120
702+
_set_entity(base, "sensor.test_ev_soc", "53", last_updated=actual_now + timedelta(seconds=1))
703+
assert "soc_entity_stale_or_future" in _options(base)["entity_blockers"]
704+
705+
690706
def run_joint_site_native_tests(base):
691707
"""Run native integration cases without a live HA connection or inverter writes."""
692708
for test in (
@@ -724,7 +740,9 @@ def run_joint_site_native_tests(base):
724740
test_real_ha_timestamp_cache_and_elapsed_cooldown,
725741
test_deye_counterfactual_preserves_native_capabilities_and_manual_floor,
726742
test_closed_session_does_not_roll_to_tomorrow,
743+
test_observations_after_rounded_plan_time_are_not_future,
727744
):
728-
test(base)
745+
with patch("joint_site_native._observation_clock", side_effect=lambda: base.now_utc):
746+
test(base)
729747
print("PASS:", test.__name__)
730748
return False

‎docs/joint-site-planning.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,12 @@ HA's `last_updated` separately from `last_changed` avoids equating an unchanged
9191
value with an old observation. Cached HA freshness still does not prove physical
9292
device freshness or connection, and no hardware refresh or service is requested.
9393

94+
Freshness and elapsed relay-OFF time use a wall clock read after the cached
95+
observations, not Predbat's rounded five-minute forecast reference. Otherwise a
96+
valid observation inside the current block would appear future-dated at startup.
97+
Reports expose both references and their alignment difference. Entity timestamps
98+
are never replaced with the time at which the cache was read.
99+
94100
The initial integration supports one metered EV and one inverter, full-rate charge
95101
and charge-freeze controls, no iBoost, and no manual mode/ceiling overrides. Domestic
96102
manual minimum targets are retained as time-local SOC constraints and checked in

0 commit comments

Comments
 (0)