112 files

This commit is contained in:
Home Assistant Version Control
2026-09-18 15:36:46 +00:00
parent 94867222bd
commit b1fd0abc94
112 changed files with 11348 additions and 2701 deletions
+154 -21
View File
@@ -34,6 +34,8 @@ from .const import (
MATCH_DTW_REFINE_TOP_N,
MATCH_DTW_RESAMPLE_N,
MATCH_DURATION_SCALE,
MATCH_DURATION_SCALE_OVERRUN,
MATCH_PREFIX_SHAPE_MAX_RATIO,
MATCH_DURATION_WEIGHT,
MATCH_ENERGY_SCALE,
MATCH_ENERGY_WEIGHT,
@@ -390,7 +392,24 @@ def compute_matches_worker(
dur_weight = float(config.get("duration_weight", MATCH_DURATION_WEIGHT))
en_weight = float(config.get("energy_weight", MATCH_ENERGY_WEIGHT))
dur_scale = float(config.get("duration_scale", MATCH_DURATION_SCALE))
dur_overrun_scale = float(
config.get("duration_overrun_scale", MATCH_DURATION_SCALE_OVERRUN)
)
en_scale = float(config.get("energy_scale", MATCH_ENERGY_SCALE))
# #400: while the cycle is still running, Stage 4 compares like with like. Both
# of its terms describe the cycle SO FAR; without this they are graded against
# each candidate's COMPLETE duration and energy, which makes a long program 40%
# through numerically indistinguishable from a finished short one. Opt-in (live
# match path only) so the final match at cycle end - where the whole-cycle
# figures are the right comparison - is byte-identical.
in_progress = bool(config.get("in_progress"))
# ...and Stages 2/3 score the SHAPE against the same truncated stretch, while the
# cycle is still clearly mid-run (MATCH_PREFIX_SHAPE_MAX_RATIO). On by default
# for a live match; `prefix_shape: False` turns it off for the A/B harnesses.
prefix_shape_on = in_progress and bool(config.get("prefix_shape", True))
prefix_shape_max_ratio = float(
config.get("prefix_shape_max_ratio", MATCH_PREFIX_SHAPE_MAX_RATIO)
)
curr_arr = np.array(current_power)
@@ -405,10 +424,29 @@ def compute_matches_worker(
if ratio < min_duration_ratio or ratio > max_duration_ratio:
continue
# Core Similarity
score, metrics, offset = find_best_alignment(
current_power, sample_power, 1.0, corr_weight=corr_weight
)
# Core Similarity. While the cycle is mid-run this compares it against the
# candidate truncated to the elapsed time, on a shared grid (#400) - the
# same pair Stage 3 then warps, so both shape stages ask one question.
# `sample` below stays the FULL template: Stage 4 takes its own prefix of it
# (analysis.prefix_mean), and truncating it here would truncate twice.
span_s = float(item.get("sample_span_s") or profile_duration or 0.0)
shape_pair = None
if (
prefix_shape_on
and span_s > 0
and current_duration <= span_s * prefix_shape_max_ratio
):
shape_pair = prefix_shape_arrays(
curr_arr, sample_power, current_duration, span_s
)
if shape_pair is not None:
score, metrics, offset = find_best_alignment(
shape_pair[0], shape_pair[1], 1.0, corr_weight=corr_weight
)
else:
score, metrics, offset = find_best_alignment(
current_power, sample_power, 1.0, corr_weight=corr_weight
)
if score > keep_min:
candidates.append({
@@ -418,6 +456,9 @@ def compute_matches_worker(
"profile_duration": profile_duration,
"current": current_power,
"sample": sample_power,
# Transient, popped after Stage 3: the truncated (current, template)
# pair Stage 2 scored, so Stage 3 warps the same thing.
"_shape_pair": shape_pair,
# 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.
@@ -443,10 +484,16 @@ def compute_matches_worker(
curr_resampled = _resample_to(curr_arr, MATCH_DTW_RESAMPLE_N)
for cand in to_refine:
sample_arr = np.array(cand["sample"])
pair = cand.get("_shape_pair")
if pair is not None:
warp_curr, sample_arr, cand_resampled = pair[0], pair[1], None
else:
warp_curr, sample_arr, cand_resampled = (
curr_arr, np.array(cand["sample"]), curr_resampled
)
dtw_score, norm_dist = _stage3_dtw_score(
curr_arr,
warp_curr,
sample_arr,
current_peak,
dtw_mode=dtw_mode,
@@ -454,7 +501,7 @@ def compute_matches_worker(
l1_scale=l1_scale,
ddtw_scale=ddtw_scale,
ensemble_w=ensemble_w,
curr_resampled=curr_resampled,
curr_resampled=cand_resampled,
)
cand["original_score"] = float(cand["score"])
@@ -463,6 +510,11 @@ def compute_matches_worker(
candidates.sort(key=lambda x: x["score"], reverse=True)
# The truncated pair is scratch for the two shape stages; it must not reach the
# MatchResult ranking (numpy arrays, and the store/WS serialise that dict).
for cand in candidates:
cand.pop("_shape_pair", None)
# Final pass: blend in duration + energy agreement. Shape correlation alone
# cannot separate profiles that differ mainly in duration/energy (the main
# multi-program washing-machine failure mode), so nudge the score toward
@@ -488,10 +540,29 @@ def compute_matches_worker(
cur_energy = cur_mean * current_duration if integrated else cur_mean
for cand in candidates:
prof_dur = float(cand.get("profile_duration") or 0.0)
dur_ag = _agreement(current_duration, prof_dur, dur_scale)
if in_progress and prof_dur > 0 and current_duration > prof_dur:
# The cycle has outlasted this candidate: real evidence against it,
# penalised on the sharper scale. Below a candidate's duration the
# term is unchanged - suppressing the penalty there was measured and
# rejected (see MATCH_DURATION_SCALE_OVERRUN in const.py).
dur_ag = _agreement(current_duration, prof_dur, dur_overrun_scale)
else:
dur_ag = _agreement(current_duration, prof_dur, dur_scale)
sample = cand.get("sample") or []
cand_mean = float(np.mean(sample)) if sample else 0.0
cand_energy = cand_mean * prof_dur if integrated else cand_mean
if in_progress:
cand_mean, cand_span = prefix_mean(
sample,
current_duration,
float(cand.get("sample_span_s") or prof_dur or 0.0),
prof_dur,
)
else:
cand_mean = float(np.mean(sample)) if sample else 0.0
cand_span = prof_dur
# In integrated mode the candidate figure must cover the same stretch of
# time as cur_energy does, so a prefix mean is scaled by the elapsed
# duration and a whole-template mean by the candidate's own duration.
cand_energy = cand_mean * cand_span if integrated else cand_mean
en_ag = _agreement(cur_energy, cand_energy, en_scale)
cand["shape_score"] = float(cand["score"])
cand["score"] = float(
@@ -509,6 +580,35 @@ def compute_matches_worker(
return candidates
def prefix_mean(
sample: list[float] | np.ndarray,
current_duration: float,
sample_span_s: float,
profile_duration: float,
) -> tuple[float, float]:
"""Mean power of ``sample`` over its leading ``current_duration`` seconds, and
the span that mean covers - the Stage-4 like-for-like pair for a cycle that is
still running (#400).
The Stage-4 like-for-like pair, and the same pair Stage 5 uses to choose between
a group's members while a cycle is running - one definition, two callers.
Falls back to the whole template (and the candidate's own duration) when the
cycle has already outlasted it: that candidate has finished, so its total is
the honest comparison. Deliberately NOT ``_prefix_point_count``: that helper's
12-sample floor exists because Stage 6 *correlates* the prefix, while a mean
over a handful of leading samples is perfectly well defined - applying the
floor here would silently restore whole-template energy for the first few
percent of every cycle, which is exactly the window #400 is about.
"""
if len(sample) == 0:
return 0.0, profile_duration
if sample_span_s > 0 and current_duration < sample_span_s:
k = max(1, int(round(len(sample) * (current_duration / sample_span_s))))
return float(np.mean(sample[:k])), current_duration
return float(np.mean(sample)), profile_duration
def _prefix_point_count(
n_points: int, current_duration: float, sample_span_s: float
) -> int:
@@ -528,6 +628,47 @@ def _prefix_point_count(
return k
def prefix_shape_arrays(
curr_arr: np.ndarray,
sample: list[float] | np.ndarray,
current_duration: float,
sample_span_s: float,
) -> tuple[np.ndarray, np.ndarray] | None:
"""``(current, template)`` on one grid, with the template TRUNCATED to the
elapsed time - or None when it cannot be truncated meaningfully.
One definition of "the same stretch of both curves", shared by the two callers
that need it: the live Stage-2/3 shape scoring (#400) and the Stage-6 prefix
guard (#364). Both series go onto a shared grid so an index offset equals a time
offset regardless of the template's native cadence; the grid also honours the
#388 OOM cap. The 12-sample floor is real here (unlike in ``prefix_mean``):
these arrays get correlated and warped, not averaged.
The grid is shared **between the two series**, not across candidates: ``k``
is the candidate's own truncated point count, so a longer template can be
scored on a finer grid than a shorter one. That asymmetry is deliberate and
measured. Capping the grid at ``k`` is what stops ``arr[:k]`` being upsampled
past the points it actually has, which would invent template detail the
recording never contained. Dropping the ``k`` term to make the grid purely
candidate-independent (``min(curr_arr.size, MAX_ALIGN_GRID_POINTS)``) was
tried and measured on ``devtools/prefix_guard_eval.py``: at the shipped
constants it takes the #364 split guard from **59/114 caught (52%) to 52/114
(46%)** while removing only 2 of 13 false blocks. A miss there is a SPLIT
CYCLE and a false block is merely a later finish, so that trade is
net-negative. ``devtools/dtw_ab_eval.py`` is byte-identical either way
(it scores only complete cycles, which never take the prefix path), so it
cannot be used to judge this function - use ``prefix_guard_eval.py``.
"""
arr = np.asarray(sample, dtype=float)
k = _prefix_point_count(arr.size, current_duration, sample_span_s)
if k == 0:
return None
grid = int(min(curr_arr.size, k, MAX_ALIGN_GRID_POINTS))
if grid < SMART_TERM_PREFIX_MIN_POINTS:
return None
return _resample_to(curr_arr, grid), _resample_to(arr[:k], grid)
def prefix_shape_score(
curr_arr: np.ndarray,
sample: list[float] | np.ndarray,
@@ -554,18 +695,10 @@ def prefix_shape_score(
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:
pair = prefix_shape_arrays(curr_arr, sample, current_duration, sample_span_s)
if pair is None:
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)
a, b = pair
corr_weight = float(config.get("corr_weight", MATCH_CORR_WEIGHT))
score, _metrics, _offset = find_best_alignment(a, b, 1.0, corr_weight=corr_weight)