This commit is contained in:
Home Assistant Version Control
2026-08-22 14:33:12 +00:00
parent 66fc59e9a7
commit eddec8ffd7
67 changed files with 11380 additions and 2569 deletions
+213 -26
View File
@@ -41,6 +41,11 @@ from .const import (
MATCH_MAE_PEAK_FLOOR,
MATCH_MAE_REF_PEAK,
MATCH_MAE_SCALE,
MAX_ALIGN_GRID_POINTS,
SMART_TERM_PREFIX_MAX_CANDIDATES,
SMART_TERM_PREFIX_MIN_COVERAGE,
SMART_TERM_PREFIX_MIN_POINTS,
SMART_TERM_PREFIX_MIN_RATIO,
STAGE4_INTEGRATED_ENERGY_DEVICE_TYPES,
)
@@ -322,6 +327,51 @@ def _dtw_component_score(
return scale / (scale + scaled)
def _stage3_dtw_score(
curr_arr: np.ndarray,
sample_arr: np.ndarray,
current_peak: float,
*,
dtw_mode: str,
dtw_bandwidth: float,
l1_scale: float,
ddtw_scale: float,
ensemble_w: float,
curr_resampled: np.ndarray | None = None,
) -> tuple[float, float]:
"""``(dtw_score, norm_dist)`` for one candidate: the four-way ``dtw_mode``
branch of the Stage-3 refinement.
Lifted verbatim out of ``compute_matches_worker`` so the Stage-3 loop and the
Stage-6 prefix pass (#364) share one implementation and cannot drift apart.
Behaviour-identical to the inlined version, including ``legacy`` mode's
``dtw_dist / len(curr_arr)`` normalisation and its ``norm_dist`` bookkeeping.
"""
if dtw_mode == "legacy":
# Original behaviour: raw sequences, distance / len(current),
# fixed absolute-watt scale (not peak-relative).
dtw_dist = compute_dtw_lite(curr_arr, sample_arr, band_width_ratio=dtw_bandwidth)
n_points = len(curr_arr)
norm_dist = (dtw_dist / n_points) if n_points > 0 else 999.0
return 1.0 / (1.0 + norm_dist / MATCH_DTW_DIST_SCALE), norm_dist
if dtw_mode == "ensemble":
# Blend the level-based (L1) and shape-based (derivative) DTW
# scores; they are complementary signals.
s_l1 = _dtw_component_score(curr_arr, sample_arr, current_peak, dtw_bandwidth, False, l1_scale, curr_resampled=curr_resampled)
s_dd = _dtw_component_score(curr_arr, sample_arr, current_peak, dtw_bandwidth, True, ddtw_scale, curr_resampled=curr_resampled)
# composite; per-component distance not meaningful
return ensemble_w * s_l1 + (1.0 - ensemble_w) * s_dd, 0.0
# "scaled" (default) or "ddtw": resample both onto one grid so the
# band and normalisation are consistent, then express the distance
# relative to the current peak (behaviour-neutral at
# MATCH_MAE_REF_PEAK), mirroring the Stage-2 MAE treatment.
use_deriv = dtw_mode == "ddtw"
scale = ddtw_scale if use_deriv else l1_scale
return _dtw_component_score(
curr_arr, sample_arr, current_peak, dtw_bandwidth, use_deriv, scale, curr_resampled=curr_resampled
), 0.0
def compute_matches_worker(
current_power: list[float],
current_duration: float,
@@ -368,6 +418,10 @@ def compute_matches_worker(
"profile_duration": profile_duration,
"current": current_power,
"sample": sample_power,
# True wall-clock span of `sample`, for prefix truncation (#364).
# Falls back to profile_duration so the other snapshot builders
# (devtools, matching_tuner, playground) keep working unchanged.
"sample_span_s": float(item.get("sample_span_s") or profile_duration or 0.0),
"offset": offset
})
@@ -391,31 +445,17 @@ def compute_matches_worker(
for cand in to_refine:
sample_arr = np.array(cand["sample"])
if dtw_mode == "legacy":
# Original behaviour: raw sequences, distance / len(current),
# fixed absolute-watt scale (not peak-relative).
dtw_dist = compute_dtw_lite(curr_arr, sample_arr, band_width_ratio=dtw_bandwidth)
n_points = len(curr_arr)
norm_dist = (dtw_dist / n_points) if n_points > 0 else 999.0
dtw_score = 1.0 / (1.0 + norm_dist / MATCH_DTW_DIST_SCALE)
elif dtw_mode == "ensemble":
# Blend the level-based (L1) and shape-based (derivative) DTW
# scores; they are complementary signals.
s_l1 = _dtw_component_score(curr_arr, sample_arr, current_peak, dtw_bandwidth, False, l1_scale, curr_resampled=curr_resampled)
s_dd = _dtw_component_score(curr_arr, sample_arr, current_peak, dtw_bandwidth, True, ddtw_scale, curr_resampled=curr_resampled)
dtw_score = ensemble_w * s_l1 + (1.0 - ensemble_w) * s_dd
norm_dist = 0.0 # composite; per-component distance not meaningful
else:
# "scaled" (default) or "ddtw": resample both onto one grid so the
# band and normalisation are consistent, then express the distance
# relative to the current peak (behaviour-neutral at
# MATCH_MAE_REF_PEAK), mirroring the Stage-2 MAE treatment.
use_deriv = dtw_mode == "ddtw"
scale = ddtw_scale if use_deriv else l1_scale
dtw_score = _dtw_component_score(
curr_arr, sample_arr, current_peak, dtw_bandwidth, use_deriv, scale, curr_resampled=curr_resampled
)
norm_dist = 0.0
dtw_score, norm_dist = _stage3_dtw_score(
curr_arr,
sample_arr,
current_peak,
dtw_mode=dtw_mode,
dtw_bandwidth=dtw_bandwidth,
l1_scale=l1_scale,
ddtw_scale=ddtw_scale,
ensemble_w=ensemble_w,
curr_resampled=curr_resampled,
)
cand["original_score"] = float(cand["score"])
cand["score"] = float(blend * cand["score"] + (1.0 - blend) * dtw_score)
@@ -461,8 +501,134 @@ def compute_matches_worker(
)
candidates.sort(key=lambda x: x["score"], reverse=True)
# Stage 6 (#364): prefix scores for the few candidates materially LONGER than
# the winner. Purely additive - it writes `prefix_score` and never touches
# `score`, so ranking is provably unchanged. Must run after the Stage-4
# re-sort because the anchor is the winner's duration.
annotate_prefix_scores(candidates, curr_arr, current_duration, config)
return candidates
def _prefix_point_count(
n_points: int, current_duration: float, sample_span_s: float
) -> int:
"""Leading template samples that cover ``current_duration`` seconds.
0 when the span is unknown/non-positive, when the elapsed time already covers
the whole template (then it is not a prefix), or when too few points remain to
judge. Fraction-of-array is the right operator because every snapshot flavour
is uniform in time over its own span (envelope: np.linspace; sample cycle:
resample_uniform at a fixed dt; group aggregate: np.interp onto 200 points).
"""
if n_points < SMART_TERM_PREFIX_MIN_POINTS or sample_span_s <= 0 or current_duration <= 0:
return 0
k = int(round(n_points * (current_duration / sample_span_s)))
if k < SMART_TERM_PREFIX_MIN_POINTS or k >= n_points:
return 0
return k
def prefix_shape_score(
curr_arr: np.ndarray,
sample: list[float] | np.ndarray,
current_duration: float,
sample_span_s: float,
current_peak: float,
config: dict[str, Any],
) -> float | None:
"""Score the live trace against ``sample`` TRUNCATED to ``current_duration``.
The #288 landscape guard asks whether a longer candidate has a decent shape
score against its **whole** curve - which a part-way-through trace cannot
have. This asks the question that actually matters: does the trace look like
the *beginning* of that longer programme? (#364)
Same scale as ``shape_score`` by construction: identical Stage-2 formula
(``find_best_alignment``) and identical Stage-3 DTW blend, only the reference
array differs. Returns None when the template cannot be truncated meaningfully.
NB prefix scoring normalizes on the shared resample ``grid`` (both series are
resampled to it), so it does not support the non-default ``dtw_mode="legacy"``
absolute-watt/length normalization - under which cross-candidate prefix scores of
differing native length would not be comparable. This is inert in production: the
default is ``"ensemble"`` and the live ProfileStore path never sets ``dtw_mode``;
``"legacy"`` exists only for the devtools re-sweep harness.
"""
arr = np.asarray(sample, dtype=float)
k = _prefix_point_count(arr.size, current_duration, sample_span_s)
if k == 0:
return None
prefix = arr[:k]
# Put both series on one grid so index offset equals time offset regardless of
# the template's native cadence, and honour the #388 OOM cap.
grid = int(min(curr_arr.size, k, MAX_ALIGN_GRID_POINTS))
if grid < SMART_TERM_PREFIX_MIN_POINTS:
return None
a = _resample_to(curr_arr, grid)
b = _resample_to(prefix, grid)
corr_weight = float(config.get("corr_weight", MATCH_CORR_WEIGHT))
score, _metrics, _offset = find_best_alignment(a, b, 1.0, corr_weight=corr_weight)
dtw_bandwidth = float(config.get("dtw_bandwidth", 0.1))
if dtw_bandwidth > 0.0:
dtw_score, _ = _stage3_dtw_score(
a,
b,
current_peak,
dtw_mode=str(config.get("dtw_mode", DEFAULT_DTW_MODE)),
dtw_bandwidth=dtw_bandwidth,
l1_scale=float(config.get("dtw_l1_scale", MATCH_DTW_DIST_SCALE)),
ddtw_scale=float(config.get("dtw_ddtw_scale", MATCH_DDTW_DIST_SCALE)),
ensemble_w=float(config.get("dtw_ensemble_w", MATCH_DTW_ENSEMBLE_W)),
)
blend = float(config.get("dtw_blend", MATCH_DTW_BLEND))
return float(blend * score + (1.0 - blend) * dtw_score)
return float(score)
def annotate_prefix_scores(
candidates: list[dict[str, Any]],
curr_arr: np.ndarray,
current_duration: float,
config: dict[str, Any],
) -> None:
"""Stage 6 (#364): write ``prefix_score`` on the few non-winning candidates
that are materially longer than the winner.
Mutates in place and never touches ``score``/``shape_score``, so candidate
ranking is unaffected - this only feeds the Smart-Termination prefix guard.
Every test before the first array touch is a scalar compare, so the common
case (no candidate is materially longer) costs nothing.
"""
if current_duration <= 0 or len(candidates) < 2 or curr_arr.size == 0:
return
best_dur = float(candidates[0].get("profile_duration") or 0.0)
if best_dur <= 0:
return
min_dur = best_dur * SMART_TERM_PREFIX_MIN_RATIO
current_peak = float(np.max(curr_arr))
scored = 0
for cand in candidates[1:]:
prof_dur = float(cand.get("profile_duration") or 0.0)
if prof_dur <= min_dur:
continue # not a longer look-alike
if prof_dur <= current_duration:
continue # we already outlasted it, so we are not inside its prefix
span = float(cand.get("sample_span_s") or prof_dur)
if span < prof_dur * SMART_TERM_PREFIX_MIN_COVERAGE:
continue # gap-truncated template: may not start at the programme's start
score = prefix_shape_score(
curr_arr, cand.get("sample") or [], current_duration, span, current_peak, config
)
if score is None:
continue
cand["prefix_score"] = float(score)
scored += 1
if scored >= SMART_TERM_PREFIX_MAX_CANDIDATES:
break
def _dtw_cost_matrix_scalar(
x: np.ndarray, y: np.ndarray, n: int, m: int, w: int
) -> np.ndarray:
@@ -537,6 +703,20 @@ def compute_dtw_path(
if n == 0 or m == 0:
return []
# Pre-flight memory guard: the cost matrix is (n+1)x(m+1) float64. An
# uncapped call from a 1 Hz long cycle can request >1 GB here. If the
# allocation would exceed ~80 MB, skip DTW and return an empty path so
# the caller falls back to linear interpolation (graceful degrade rather
# than OOM-killing Home Assistant — issue #388).
_DTW_CELL_BUDGET = 10_000_000 # 10 M cells x 8 B ≈ 80 MB
if (n + 1) * (m + 1) > _DTW_CELL_BUDGET:
_LOGGER.warning(
"DTW cost matrix %dx%d would need %.0f MB — skipping DTW refinement "
"(cap compute_envelope_worker inputs via MAX_ALIGN_GRID_POINTS to prevent this)",
n, m, (n + 1) * (m + 1) * 8 / 1e6,
)
return []
w = max(1, int(min(n, m) * band_width_ratio))
try:
cost_matrix = _dtw_cost_matrix_vectorized(x, y, n, m, w)
@@ -701,6 +881,9 @@ def compute_envelope_worker(
align_dt = avg_sample_rate
num_points = max(50, int(target_duration / align_dt))
if num_points > MAX_ALIGN_GRID_POINTS:
num_points = MAX_ALIGN_GRID_POINTS
align_dt = target_duration / num_points # re-derive so per-cycle grids inherit the cap
time_grid = np.linspace(0.0, target_duration, num_points)
# Robust reference curve: the pointwise MEDIAN across all cycles resampled
@@ -733,7 +916,11 @@ def compute_envelope_worker(
for offsets, values, dur in normalized_curves:
this_dur = dur
this_num_points = max(10, int(this_dur / align_dt))
# Cap this grid too, not just the reference one: a cycle far longer than the
# median would otherwise size its own grid past the cap and push the cost
# matrix over compute_dtw_path's budget, which silently drops the outlier
# back to plain interpolation. Capping keeps DTW alignment available for it.
this_num_points = min(MAX_ALIGN_GRID_POINTS, max(10, int(this_dur / align_dt)))
this_grid = np.linspace(0.0, this_dur, this_num_points)
this_array = np.interp(this_grid, offsets, values)