This commit is contained in:
Home Assistant Version Control
2026-08-18 14:36:46 +00:00
parent 13dc64b70f
commit 88e26358aa
89 changed files with 26068 additions and 18351 deletions
+43 -2
View File
@@ -41,9 +41,23 @@ from .const import (
MATCH_MAE_PEAK_FLOOR,
MATCH_MAE_REF_PEAK,
MATCH_MAE_SCALE,
STAGE4_INTEGRATED_ENERGY_DEVICE_TYPES,
)
def stage4_energy_mode(device_type: str | None) -> str:
"""Return the Stage-4 ``energy_mode`` for a device type.
``"integrated"`` for device types in
``STAGE4_INTEGRATED_ENERGY_DEVICE_TYPES`` (washing machine / washer-dryer),
where same-duration temperature/spin variants make integrated energy the
right discriminator; ``"mean"`` (the historical default) otherwise. Single
source of truth for the gate, used by the manager, Playground and matching
tuner so all three stay consistent with the live matcher.
"""
return "integrated" if device_type in STAGE4_INTEGRATED_ENERGY_DEVICE_TYPES else "mean"
def _agreement(observed: float, expected: float, scale: float) -> float:
"""1.0 when observed==expected, decaying with the |log-ratio| / scale."""
if observed <= 0 or expected <= 0 or scale <= 0:
@@ -426,12 +440,18 @@ def compute_matches_worker(
dur_w, en_w = dur_w / de_sum, en_w / de_sum
shape_w = max(0.0, 1.0 - dur_w - en_w)
if (dur_w > 0 or en_w > 0) and candidates and current_duration > 0:
cur_energy = float(np.mean(curr_arr)) # mean power (W) — no duration multiplication
# energy_mode: "mean" (default) compares whole-cycle mean power (W);
# "integrated" compares true integrated energy (mean x duration). Opt-in so
# the historical default is byte-for-byte preserved. See register item 99.
integrated = config.get("energy_mode", "mean") == "integrated"
cur_mean = float(np.mean(curr_arr))
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)
sample = cand.get("sample") or []
cand_energy = float(np.mean(sample)) if sample else 0.0
cand_mean = float(np.mean(sample)) if sample else 0.0
cand_energy = cand_mean * prof_dur if integrated else cand_mean
en_ag = _agreement(cur_energy, cand_energy, en_scale)
cand["shape_score"] = float(cand["score"])
cand["score"] = float(
@@ -620,6 +640,27 @@ def compute_envelope_worker(
if len(offsets) < 3:
continue
# Stored offsets are rounded to 0.1s, so two readings less than 0.1s apart
# collapse onto the same offset. A single such duplicate must not discard the
# whole trace (#377): drop the duplicate sample(s) instead of the cycle. Only
# exact duplicates are collapsed here; a genuinely out-of-order (decreasing)
# offset - which sorted storage never produces - is left for the strict check
# below to reject, exactly as before.
if offsets.size > 1:
diffs = np.diff(offsets)
if np.any(diffs == 0):
keep = np.concatenate(([True], diffs != 0))
dropped = int((~keep).sum())
offsets = offsets[keep]
values = values[keep]
_LOGGER.debug(
"compute_envelope_worker: dropped %d duplicate sample offset(s) "
"from a cycle trace (0.1s offset rounding)",
dropped,
)
if len(offsets) < 3:
continue
if not np.all(np.diff(offsets) > 0):
continue