Added Alexa Music

This commit is contained in:
2026-07-17 10:12:15 -04:00
parent 92c5268dc8
commit 28a8cb98f6
757 changed files with 151171 additions and 85450 deletions
@@ -0,0 +1,84 @@
# WashData ML subsystem (experimental, gated)
Compact, **NumPy-only** models plus the runtime that trains and consumes them.
No new dependencies (NumPy is already in `manifest.json`). Everything here is
gated by flags in `const.py` and is inert until enabled, so the proven
detection/matching/ETA code paths are unchanged by default.
## Feature flags (const.py)
- `SHOW_ML_LAB` - show the ML Lab panel tab (shadow-mode comparison + review).
- `ENABLE_ML_SUGGESTIONS` - surface ML-calibrated setting suggestions alongside
the classic ones (`MLSuggestionEngine`).
- `ENABLE_ML_TRAINING` - allow the scheduled/manual on-device training loop.
- `CONF_ENABLE_ML_MODELS` (per-device option) - opt-in gate (via
`ml_models_enabled(options)`) for feeding ML signals into runtime decisions;
default off so callers keep existing behavior. (No runtime consumer wires this
yet - the live ML paths below run under their own flags.)
## What ships here
- `promoted_manifest.json` + `<name>_model.py` - the embedded **baseline** models
(the broad-corpus models trained offline in `/root/ml_washdata`). Each module
is self-contained and exposes `score()`, `predict()`, `FEATURE_COLUMNS`,
`THRESHOLD`, `MODEL_METRICS`. `<name>_feature_contract.json` documents the live
data each feature comes from; `<name>_parity.json` are golden feature→score
cases the tests assert against.
- `feature_extraction.py` - NumPy-only runtime feature extractors
(`latest_end_event_features`, `live_match_features`, `quality_features`,
`profile_expectation`, energy integration) matching the models' `FEATURE_COLUMNS`.
- `engine.py` - `resolve_scorer(capability, store)`, the single bridge that
returns a **classifier** scoring callable preferring an on-device trained spec
over the embedded baseline (`"on_device"` vs `"baseline"`); `resolve_regressor(
capability, store)` is its **regression** twin for `standardized_linear` heads
that have no shipped baseline (returns `(None, None)` until one is promoted),
plus `ml_models_enabled` (opt-in gate) and `available_models` (manifest provenance).
- `trainer.py` - NumPy-only training for two spec kinds: logistic classifiers
(`fit_logistic`, `select_threshold`, `binary_metrics`, `auc`, `build_spec`/
`score_spec` - byte-compatible with the embedded `score()` math) and ridge
**regressors** (`fit_ridge`, `regression_metrics`, `build_regression_spec`/
`predict_value_spec` - standardized features + standardized target).
- `training_task.py` - on-device orchestration: derives labels from the device's
own cycles (end events from trace geometry; quality from status + ML-Lab review
labels; live_match from match-ranking-history snapshots), synthesises
completion-fraction examples for the regression capabilities, trains, and
promotes a classifier only when its held-out AUC is within margin of the
baseline (a regressor only when its held-out MAE beats the naive elapsed/
expected projection).
- `matching_tuner.py` - `tune_matching_config(cycles)`: NumPy-only, executor-safe
leave-one-out tuning of the matcher's bounded scoring weights (`corr_weight`,
`duration_weight`, `energy_weight`, `dtw_ensemble_w`) over the device's own
labelled cycles. Same promotion discipline as the models (gate on a held-out
split by a margin); it only ever changes the emphasis between shape/level/energy,
never structural matching behaviour.
Models (all standardized-logistic; only models that beat their baseline are shipped):
- `hybrid_curve_quality_model` - P(finished cycle is a problem).
- `live_match_commit_model` - P(top-1 live program match is correct).
- `cycle_end_detector_model` - P(a low-power event is the true end vs a pause).
(No regression baseline is shipped: the `remaining_time` and `total_energy`
completion-fraction regressors did not beat the `expected_duration - elapsed`
heuristic on the broad corpus, so they stay inert until on-device training
promotes a per-device spec that beats that naive projection.)
## How trained models reach inference
`resolve_scorer(capability, store)` is used by the ML Lab shadow comparison
(`ws_api._compute_ml_comparison`) and by `MLSuggestionEngine`. If the profile
store holds an on-device spec for that capability (trained by `training_task` and
persisted under `ml_model_versions`), it is used; otherwise the embedded baseline
module is used. The shipped baseline is a broad-corpus model - per-user accuracy
gains come from on-device training, not from replacing the baseline.
## Regenerating the embedded baseline (offline lab only)
```bash
cd /root/ml_washdata
./ml.sh experiment # retrain + verify the determinism gate
python promote_to_integration.py --target <this directory> # reads output/promoted/
```
`promote_to_integration.py` refuses to copy any model whose encode/decode round
trip is not deterministic. On-device training never touches these baseline files;
it writes trained specs into the profile store instead.
@@ -0,0 +1,37 @@
# WashData - Home Assistant integration for appliance cycle monitoring via smart plugs.
# Copyright (C) 2026 Lukas Bandura
# SPDX-License-Identifier: AGPL-3.0-or-later
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""Opt-in, NumPy-only ML models for WashData (experimental).
Models are trained offline in the ml_washdata lab and embedded here as base64
blobs. They are inert unless the user enables them. See engine.py and README.md.
"""
from .engine import (
CONF_ENABLE_ML_MODELS,
available_models,
ml_models_enabled,
resolve_regressor,
resolve_scorer,
)
__all__ = [
"CONF_ENABLE_ML_MODELS",
"available_models",
"ml_models_enabled",
"resolve_regressor",
"resolve_scorer",
]
@@ -0,0 +1,42 @@
[
{
"feature": "elapsed_fraction",
"group": "cycle_end",
"runtime_source": "elapsed_seconds / matched profile expected duration"
},
{
"feature": "energy_fraction",
"group": "cycle_end",
"runtime_source": "energy delivered so far (Wh) / matched profile expected energy"
},
{
"feature": "energy_remaining_expected",
"group": "cycle_end",
"runtime_source": "max(0, 1 - energy_fraction)"
},
{
"feature": "power_before_ratio",
"group": "cycle_end",
"runtime_source": "mean power just before the drop / profile expected peak"
},
{
"feature": "drop_ratio",
"group": "cycle_end",
"runtime_source": "(power_before - current_power) / profile expected peak, clipped"
},
{
"feature": "peak_seen_ratio",
"group": "cycle_end",
"runtime_source": "max power seen so far / profile expected peak"
},
{
"feature": "low_run_s_log",
"group": "cycle_end",
"runtime_source": "log1p(seconds power has stayed below the low threshold)"
},
{
"feature": "elapsed_log",
"group": "cycle_end",
"runtime_source": "log1p(elapsed seconds since cycle start)"
}
]
@@ -0,0 +1,129 @@
# WashData - Home Assistant integration for appliance cycle monitoring via smart plugs.
# Copyright (C) 2026 Lukas Bandura
# SPDX-License-Identifier: AGPL-3.0-or-later
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""Auto-generated by ml_washdata/wash_ml/promotion.py. Do not edit by hand.
Embedded WashData model: 'cycle_end_detector' (target: 'cycle_truly_ended').
Kind: standardized logistic regression. Runtime dependency: NumPy only.
Regenerate with ``./ml.sh experiment`` in the ml_washdata lab and copy the new
file. Determinism check at generation time: max_abs_score_diff=7.994e-09
over 1168 rows.
Usage in the integration::
from .cycle_end_detector_model import score, predict, FEATURE_COLUMNS
features = build_runtime_features(...) # must populate FEATURE_COLUMNS
is_positive = predict(features)
"""
from __future__ import annotations
import base64
import gzip
import json
from typing import Mapping
import numpy as np
MODEL_NAME = 'cycle_end_detector'
MODEL_TARGET = 'cycle_truly_ended'
MODEL_KIND = 'standardized_logistic'
TARGET_UNITS = ''
THRESHOLD = 0.6
FEATURE_COLUMNS = [
'elapsed_fraction',
'energy_fraction',
'energy_remaining_expected',
'power_before_ratio',
'drop_ratio',
'peak_seen_ratio',
'low_run_s_log',
'elapsed_log',
]
# Provenance (metrics at training time):
MODEL_METRICS = json.loads("""{
"owner_holdout": {
"accuracy": 0.865889,
"balanced_accuracy": 0.85704,
"f1": 0.760417,
"fn": 14,
"fp": 32,
"positive_rate": 0.306122,
"precision": 0.695238,
"problem_recall": 0.83908,
"rows": 343,
"specificity": 0.875,
"tn": 224,
"tp": 73
},
"premature_stop_rate": 0.125
}""")
_MODEL_BLOB = (
'H4sIAAAAAAACA21U227jNhD9FUIvzaK2Q5EUKXnRAkWaFgXa7GIvT8FCYKSxTUQiDZGK113sv/dQTtI+9G04M5wzc+byrXhwNhbb'
'tdhwUxqlVLUqOvKJpmJ7zzeV1JWWWqz4RpdKaM0NRNkoXkleQyyrxijN9SKWqi6lgmiaqja1rFZqU2shFTfVymwEl0YJVX8BRKAd'
'AMpNI2oOXQaoVaVEI5vVGrIEMC9zWC251kbJHJbXwjSNzB5clULoJqu5kZVskCTUUtUS7wwxkU3UtzYV20Jwoddcr0XzSfCtarZc'
'/8j5lvNiVezgN0/UdmGYRw8y7gsa7DHi726yXXLBw4s8Tfvz/2gmGq3zzu9b+nqkDpCwHcOJpvaBdgGBJ4sfUPZTOL4+jmQf20jk'
'XzVDOLXT7NvYDmGf4z8nkV+oZ+8SUhxHl+vRvLJgFl6PzvdQxGR9b6fe/X354WJyHcwjpcl1KOpbEU5IuD2EoQ9zygrbdTPKORdb'
'EK6rum5WxYMdrO8ybf81VoYrEFXmh9Gg3uDli22ZtcdiK0UuObrknpZyKTuib2gRDBN1LmbOoNRNJWSdleFhoBHsdXYYFhDZYKKK'
'KZyQrUS/iwg63c51Ll2ywBAVCVGEAGwCrJHfl/DjpYExXfhd0EtRwejtiFfRnbuBWvJ921NCj8IEanxItHT7JlvXsLIu+J3rCQRs'
'2fsrNGS9NJLRE1aCucjSgViaZmJLRJb/PEVm2dHOka53dohLoDcbxL9ZmsVSYLd3v/5x9zs7Hciz2GEk2M8/IdREMXfjLQsIO51c'
'JPZIdGQnCyL9nl1ltAsQoAf3SMP5gnQBeP9S+TpXznLl7GqxR5ZZpZ7ZmHOMb15yf6Qziozp7b/wi2n2cEamCz6SRukZ4cPskxuJ'
'jXNM+DceZ0Cgmva3218+ff5w2968+/PzX3cf2W4K4wIwYALYhbSYsIEjw1wuFqTaHYCCzu/cQD8gs+d9YZdNuu7nZRX8dd6NTZ55'
'DCog25eDhFV/1UVUiNaWWfUs3+f15zgwIl8FaUpe1qbJYlXL0pT5yIh8HqqyWkSF25QlWTdGyKZelRul6xqezUrg4GmFSTdfMsAB'
'VGOQTjYeepvsBlWMIZ+XMfQ0XJcgK9lpT+l12jAmwznP3HIQLsZ29i5h5oqseWnAshbf/wGoRgdWigUAAA=='
)
_MODEL_CACHE: dict | None = None
def _load() -> dict:
global _MODEL_CACHE
if _MODEL_CACHE is None:
payload = gzip.decompress(base64.b64decode(_MODEL_BLOB.encode("ascii")))
spec = json.loads(payload.decode("utf-8"))
_MODEL_CACHE = {
"center": np.asarray(spec["center"], dtype=float),
"scale": np.asarray(spec["scale"], dtype=float),
"coef": np.asarray(spec["coef"], dtype=float),
"bias": float(spec["bias"]),
"threshold": float(spec["threshold"]),
"output_center": float(spec.get("output_center") or 0.0),
"output_scale": float(spec.get("output_scale") if spec.get("output_scale") is not None else 1.0),
"feature_columns": list(spec["feature_columns"]),
}
return _MODEL_CACHE
def score(features: Mapping[str, float]) -> float:
"""Return the model probability in [0, 1] for one feature mapping."""
model = _load()
vector = np.array(
[float(features.get(column) or 0.0) for column in model["feature_columns"]],
dtype=float,
)
scaled = (vector - model["center"]) / model["scale"]
logit = float(scaled @ model["coef"] + model["bias"])
logit = max(-60.0, min(60.0, logit))
return 1.0 / (1.0 + np.exp(-logit))
def predict(features: Mapping[str, float]) -> bool:
"""True when the example crosses the embedded decision threshold."""
return score(features) >= _load()["threshold"]
@@ -0,0 +1,110 @@
{
"cases": [
{
"expected_score": 0.00031123,
"features": {
"drop_ratio": 0.01298192,
"elapsed_fraction": 0.02478321,
"elapsed_log": 5.83217569,
"energy_fraction": 0.00621172,
"energy_remaining_expected": 0.99378828,
"low_run_s_log": 5.70711026,
"peak_seen_ratio": 0.03683001,
"power_before_ratio": 0.03293804
}
},
{
"expected_score": 0.00046928,
"features": {
"drop_ratio": 0.0,
"elapsed_fraction": 0.00012455,
"elapsed_log": 0.69314718,
"energy_fraction": 0.0,
"energy_remaining_expected": 1.0,
"low_run_s_log": 5.49264984,
"peak_seen_ratio": 0.00356295,
"power_before_ratio": 0.00356295
}
},
{
"expected_score": 0.02292853,
"features": {
"drop_ratio": 0.03340821,
"elapsed_fraction": 0.43403046,
"elapsed_log": 8.69235585,
"energy_fraction": 0.53437329,
"energy_remaining_expected": 0.46562671,
"low_run_s_log": 5.69069724,
"peak_seen_ratio": 0.99399227,
"power_before_ratio": 0.03434855
}
},
{
"expected_score": 0.07338325,
"features": {
"drop_ratio": 0.01944167,
"elapsed_fraction": 0.60789299,
"elapsed_log": 8.57715877,
"energy_fraction": 0.59948446,
"energy_remaining_expected": 0.40051554,
"low_run_s_log": 4.79991426,
"peak_seen_ratio": 0.99750748,
"power_before_ratio": 0.03240279
}
},
{
"expected_score": 0.16173027,
"features": {
"drop_ratio": 0.03459041,
"elapsed_fraction": 0.73611397,
"elapsed_log": 8.76851206,
"energy_fraction": 0.63858395,
"energy_remaining_expected": 0.36141605,
"low_run_s_log": 4.11577984,
"peak_seen_ratio": 0.98851149,
"power_before_ratio": 0.03459041
}
},
{
"expected_score": 0.61603994,
"features": {
"drop_ratio": 0.00228805,
"elapsed_fraction": 0.8429428,
"elapsed_log": 8.89713534,
"energy_fraction": 0.98494733,
"energy_remaining_expected": 0.01505267,
"low_run_s_log": 5.15329159,
"peak_seen_ratio": 1.0,
"power_before_ratio": 0.0186657
}
},
{
"expected_score": 0.91631555,
"features": {
"drop_ratio": 0.19111446,
"elapsed_fraction": 1.05040586,
"elapsed_log": 9.13275714,
"energy_fraction": 0.97753783,
"energy_remaining_expected": 0.02246217,
"low_run_s_log": 3.7208625,
"peak_seen_ratio": 0.95060241,
"power_before_ratio": 0.20135542
}
},
{
"expected_score": 0.99973816,
"features": {
"drop_ratio": 0.18426352,
"elapsed_fraction": 1.83438035,
"elapsed_log": 8.41134367,
"energy_fraction": 1.54899093,
"energy_remaining_expected": 0.0,
"low_run_s_log": 3.71843826,
"peak_seen_ratio": 1.0,
"power_before_ratio": 0.19404488
}
}
],
"kind": "standardized_logistic",
"model": "cycle_end_detector"
}
+216
View File
@@ -0,0 +1,216 @@
# WashData - Home Assistant integration for appliance cycle monitoring via smart plugs.
# Copyright (C) 2026 Lukas Bandura
# SPDX-License-Identifier: AGPL-3.0-or-later
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""Opt-in ML scoring bridge for WashData (experimental).
This package holds compact, NumPy-only models trained offline in the
``ml_washdata`` lab and embedded here as base64 blobs (see
``promoted_manifest.json`` for provenance). The integration runtime stays
NumPy-only; no sklearn/torch/scipy are imported.
The single runtime entry point is :func:`resolve_scorer`, which returns a scoring
callable for a capability, preferring an on-device trained spec over the shipped
embedded baseline. All live ML consumers go through it (the panel's ``ml_health``
shadow comparison in ``ws_api`` and :class:`MLSuggestionEngine`), and any new
runtime consumer should too — feature extraction lives in ``feature_extraction``
and gating in :func:`ml_models_enabled`, so there is no separate engine object.
Each model consumes a feature mapping whose keys are the model's
``FEATURE_COLUMNS``; the integration computes those from live data per the
``*_feature_contract.json`` files shipped alongside the model modules.
"""
from __future__ import annotations
import importlib
import json
import logging
from pathlib import Path
from typing import Mapping
_LOGGER = logging.getLogger(__name__)
CONF_ENABLE_ML_MODELS = "enable_ml_models"
# Logical capability -> generated model module name (without the _model suffix).
_MODEL_MODULES = {
"quality": "hybrid_curve_quality_model",
"live_match": "live_match_commit_model",
"end": "cycle_end_detector_model",
}
def ml_models_enabled(options: Mapping[str, object] | None) -> bool:
"""True when the user has opted into experimental ML models."""
if not options:
return False
return bool(options.get(CONF_ENABLE_ML_MODELS, False))
def resolve_scorer(capability: str, store: object | None):
"""Return ``(score_fn, source)`` for a capability, preferring an on-device
trained spec over the shipped embedded baseline.
``score_fn`` maps a feature mapping -> float in [0,1]; ``source`` is
``"on_device"`` or ``"baseline"``. Returns ``(None, None)`` when neither is
available. This is the single bridge that lets trained models (Stage 4)
actually reach inference (ML Lab shadow comparison + MLSuggestionEngine)
while transparently falling back to the baseline.
"""
def _baseline():
"""Resolve the shipped embedded baseline scorer for this capability.
Kept as a lazily-invoked helper so the baseline module is only imported
when the on-device spec is absent *or* fails at call time - preserving the
original "baseline only loaded when needed" semantics.
"""
module_name = _MODEL_MODULES.get(capability)
if module_name is None:
return (None, None)
try:
module = importlib.import_module(f"{__package__}.{module_name}")
except Exception as exc: # noqa: BLE001
_LOGGER.warning(
"Failed to load embedded baseline for capability %r: %s",
capability, exc,
)
return (None, None)
def _baseline_score(feats, _m=module):
# The embedded baseline must never raise into live inference either
# (mirrors _on_device_score's call-time guard): on any scoring error
# log and return a neutral 0.0 so a gate treats the signal as absent
# rather than letting the exception reach live detection/matching.
try:
return float(_m.score(feats))
except Exception as exc: # noqa: BLE001 - never raise into live inference
_LOGGER.warning(
"Embedded baseline scorer for capability %r failed at call "
"time, returning neutral 0.0: %s", capability, exc,
)
return 0.0
return (_baseline_score, "baseline")
# 1) On-device trained spec from the store.
if store is not None:
try:
versions = store.get_ml_model_versions() or {} # type: ignore[attr-defined]
record = versions.get(capability)
spec = record.get("spec") if isinstance(record, dict) else None
# Only treat a spec as a classifier here. A regression spec
# (standardized_linear) must never be sigmoid-squashed by score_spec;
# classifier and regression capability keys are disjoint today, but this
# guard keeps it safe if a key were ever reused.
if isinstance(spec, dict) and spec.get("kind") != "standardized_linear":
from .trainer import score_spec
def _on_device_score(feats, _s=spec):
# A malformed / dimensionally-incompatible promoted spec must
# never raise into live detection/matching: on any call-time
# error fall back to the embedded baseline (or a neutral 0.0).
try:
return float(score_spec(_s, feats))
except Exception as exc: # noqa: BLE001 - never raise into live inference
_LOGGER.warning(
"Trained scorer for capability %r failed at call time, "
"falling back to baseline: %s", capability, exc,
)
fn, _src = _baseline()
if fn is not None:
try:
return fn(feats)
except Exception: # noqa: BLE001 - baseline must not raise either
pass
return 0.0
return (_on_device_score, "on_device")
except Exception as exc: # noqa: BLE001 - never let a bad store break inference
_LOGGER.warning(
"Failed to load trained spec for capability %r, falling back to baseline: %s",
capability, exc,
)
# 2) Shipped embedded baseline module.
return _baseline()
def resolve_regressor(capability: str, store: object | None):
"""Return ``(predict_fn, source)`` for a regression capability.
Regression models (``"remaining_time"`` and ``"total_energy"``) have **no**
shipped embedded baseline - they are trained purely on-device (Stage 4) and
stored as ``standardized_linear`` specs. This returns ``(None, None)`` until
on-device training promotes one, so live behaviour is unchanged until then.
``predict_fn`` maps a feature mapping -> float in the model's target units
(a completion fraction in ~[0, 1] for both regression capabilities).
"""
if store is None:
return (None, None)
try:
versions = store.get_ml_model_versions() or {} # type: ignore[attr-defined]
record = versions.get(capability)
spec = record.get("spec") if isinstance(record, dict) else None
if isinstance(spec, dict) and spec.get("kind") == "standardized_linear":
from .trainer import predict_value_spec
def _on_device_predict(feats, _s=spec):
# A malformed / incompatible promoted regression spec must never
# raise into the live remaining-time / energy estimates: on any
# call-time error return NaN so the (isfinite-guarded) consumers
# treat this capability as inert.
try:
return float(predict_value_spec(_s, feats))
except Exception as exc: # noqa: BLE001 - never raise into live inference
_LOGGER.warning(
"Trained regressor for capability %r failed at call time, "
"returning inert value: %s", capability, exc,
)
return float("nan")
return (_on_device_predict, "on_device")
except Exception as exc: # noqa: BLE001 - never let a bad store break inference
_LOGGER.warning(
"Failed to load trained regression spec for capability %r, capability will be inert: %s",
capability, exc,
)
return (None, None)
_MANIFEST_MODELS_CACHE: list[dict[str, object]] | None = None
def available_models() -> list[dict[str, object]]:
"""Return provenance for the embedded models, or [] if none are shipped.
The manifest is a shipped baseline file that never changes at runtime
(on-device training writes specs into the store, not this file), so the parsed
result is cached module-side after the first read.
"""
global _MANIFEST_MODELS_CACHE
if _MANIFEST_MODELS_CACHE is not None:
return _MANIFEST_MODELS_CACHE
manifest = Path(__file__).resolve().parent / "promoted_manifest.json"
if not manifest.exists():
return []
try:
payload = json.loads(manifest.read_text(encoding="utf-8"))
except (OSError, ValueError):
return []
models = payload.get("models")
result = models if isinstance(models, list) else []
_MANIFEST_MODELS_CACHE = result
return result
@@ -0,0 +1,807 @@
# WashData - Home Assistant integration for appliance cycle monitoring via smart plugs.
# Copyright (C) 2026 Lukas Bandura
# SPDX-License-Identifier: AGPL-3.0-or-later
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""NumPy-only runtime feature extraction for the embedded ML models.
This is the bridge between live cycle data and the embedded models: it computes
the exact ``FEATURE_COLUMNS`` each model expects, ported faithfully from the
``ml_washdata`` lab feature definitions (see each ``*_feature_contract.json``).
Three feature extractors are implemented:
- **Cycle-end detector** (``END_FEATURE_COLUMNS``): self-contained from a live
power series + profile expectation; call ``latest_end_event_features``.
- **Live-match commit confidence** (``LIVE_MATCH_FEATURE_COLUMNS``): requires the
match ranking from ``ProfileStore`` plus the observed prefix; call
``live_match_features``.
- **Hybrid cycle quality** (``QUALITY_FEATURE_COLUMNS``): requires the complete
cycle power trace plus profile/match context; call ``quality_features``.
All inputs are plain Python/NumPy (offset-seconds, watts), so this module has no
Home Assistant dependency and is unit-tested directly. It is only invoked when
the user opts into experimental ML models (see engine.py).
"""
from __future__ import annotations
import math
from typing import Sequence
import numpy as np
# ---------------------------------------------------------------------------
# Cycle-end detector
# ---------------------------------------------------------------------------
# Mirrors ml_washdata/wash_ml/end_detection.py. The test suite asserts this list
# equals the embedded model's FEATURE_COLUMNS so the two cannot drift.
END_FEATURE_COLUMNS = [
"elapsed_fraction",
"energy_fraction",
"energy_remaining_expected",
"power_before_ratio",
"drop_ratio",
"peak_seen_ratio",
"low_run_s_log",
"elapsed_log",
]
MIN_LOW_RUN_S = 45.0
Point = tuple[float, float]
def cumulative_energy_wh(points: Sequence[Point]) -> np.ndarray:
"""Trapezoidal cumulative energy (Wh) aligned to each reading, with gap handling.
Segments spanning sensor-outage gaps (larger than ``energy_gap_threshold_s``)
are zeroed out so energy does not inflate across outages, matching the behaviour
of ``signal_processing.integrate_wh`` used for stored ``energy_wh`` fields.
"""
from ..signal_processing import energy_gap_threshold_s # noqa: PLC0415
offsets = np.asarray([float(offset) for offset, _power in points], dtype=float)
powers = np.asarray([max(0.0, float(power)) for _offset, power in points], dtype=float)
if offsets.size < 2:
return np.zeros(offsets.size, dtype=float)
max_gap = energy_gap_threshold_s(offsets)
deltas = np.diff(offsets)
segment = (powers[:-1] + powers[1:]) / 2.0 * deltas / 3600.0
# Zero out segments that span a sensor outage gap to match integrate_wh behaviour.
segment[deltas > max_gap] = 0.0
return np.concatenate([[0.0], np.cumsum(segment)])
def profile_expectation(cycles_points: Sequence[Sequence[Point]]) -> dict[str, float] | None:
"""Median expected duration (s), energy (Wh) and peak (W) over a profile's cycles."""
durations: list[float] = []
energies: list[float] = []
peaks: list[float] = []
for points in cycles_points:
if len(points) < 2:
continue
offsets = [float(offset) for offset, _power in points]
duration = offsets[-1] - offsets[0]
if duration <= 0:
continue
durations.append(duration)
energies.append(float(cumulative_energy_wh(points)[-1]))
peaks.append(max((float(power) for _offset, power in points), default=0.0))
if not durations:
return None
return {
"duration": float(np.median(durations)),
"energy": float(np.median(energies)),
"peak": float(np.median(peaks)),
}
def profile_expectations(cycles: list[dict]) -> dict[str, dict[str, float]]:
"""Median duration/energy/peak per profile from stored cycle dicts.
The dict-based counterpart of :func:`profile_expectation` (which works from
decompressed traces): reads the ``duration``/``energy_wh``/``max_power``
scalar fields already stored on each cycle. Shared by on-device training
(``training_task``) and the ML suggestion engine so the "profile expectation"
definition lives in one place. Profiles with no usable duration are skipped;
missing energy/peak default to 500.
"""
stats: dict[str, dict[str, list[float]]] = {}
for c in cycles:
name = c.get("profile_name")
if not isinstance(name, str) or not name:
continue
s = stats.setdefault(name, {"d": [], "e": [], "p": []})
for key, field in (("d", "duration"), ("e", "energy_wh"), ("p", "max_power")):
v = c.get(field)
if isinstance(v, (int, float)) and not isinstance(v, bool):
s[key].append(float(v))
out: dict[str, dict[str, float]] = {}
for name, s in stats.items():
if not s["d"]:
continue
out[name] = {
"duration": float(np.median(s["d"])),
"energy": float(np.median(s["e"])) if s["e"] else 500.0,
"peak": float(np.median(s["p"])) if s["p"] else 500.0,
}
return out
def latest_end_event_features(
points: Sequence[Point],
expectation: dict[str, float],
*,
min_low_run_s: float = MIN_LOW_RUN_S,
) -> dict[str, float] | None:
"""Features for the most recent low-power run (the "is this the end?" moment).
Returns ``None`` when there is no qualifying low-power run yet (the cycle is
still clearly active), in which case the caller keeps the existing behavior.
Mirrors ``end_detection._cycle_events`` for a single (latest) event.
"""
if len(points) < 4 or not expectation:
return None
offsets = np.asarray([float(offset) for offset, _power in points], dtype=float)
powers = np.asarray([max(0.0, float(power)) for _offset, power in points], dtype=float)
start = float(offsets[0])
peak = max(float(expectation.get("peak") or 0.0), float(np.max(powers)), 1.0)
low_threshold = max(5.0, 0.02 * peak)
profile_duration = max(float(expectation.get("duration") or 0.0), 1.0)
profile_energy = max(float(expectation.get("energy") or 0.0), 1e-6)
cumulative = cumulative_energy_wh(points)
# Find the most recent contiguous low-power run.
count = len(points)
run_start: int | None = None
index = count - 1
while index >= 0 and powers[index] < low_threshold:
run_start = index
index -= 1
if run_start is None:
return None
run_end = count - 1
run_duration = float(offsets[run_end] - offsets[run_start])
if run_duration < min_low_run_s:
return None
event_time = float(offsets[run_start] - start)
elapsed = max(event_time, 1.0)
energy_so_far = float(cumulative[run_start])
window = powers[max(0, run_start - 4):run_start]
power_before = float(np.mean(window)) if window.size else float(powers[run_start])
running_peak = float(np.max(powers[: run_start + 1]))
return {
"elapsed_fraction": float(min(elapsed / profile_duration, 2.0)),
"energy_fraction": float(min(energy_so_far / profile_energy, 2.0)),
"energy_remaining_expected": float(max(0.0, 1.0 - energy_so_far / profile_energy)),
"power_before_ratio": float(min(power_before / peak, 2.0)),
"drop_ratio": float(np.clip((power_before - float(powers[run_start])) / peak, 0.0, 1.0)),
"peak_seen_ratio": float(min(running_peak / peak, 2.0)),
"low_run_s_log": float(math.log1p(max(0.0, run_duration))),
"elapsed_log": float(math.log1p(elapsed)),
}
# ---------------------------------------------------------------------------
# Live-match commit confidence
# ---------------------------------------------------------------------------
# Mirrors ml_washdata/wash_ml/live_matching.py COMMIT_FEATURE_COLUMNS.
# The test suite asserts this equals the embedded model's FEATURE_COLUMNS.
LIVE_MATCH_FEATURE_COLUMNS = [
"match_progress_top1",
"top1_distance",
"margin",
"distance_ratio",
"candidate_count_log",
"prefix_active_fraction",
"duration_ratio_top1",
"elapsed_log",
]
def live_match_features(
points: Sequence[Point],
elapsed_s: float,
top1_distance: float,
top2_distance: float | None,
top1_median_duration_s: float,
candidate_count: int,
) -> dict[str, float]:
"""Features for the live-match commit-confidence model.
Args:
points: Observed power readings (offset_s, watts) for the current prefix.
elapsed_s: Seconds elapsed since cycle start.
top1_distance: Blended RMSE+DTW shape distance to the top-1 candidate
prefix (as returned by the profile matcher).
top2_distance: Distance to the top-2 candidate; pass ``None`` or ``0.0``
when only one candidate is available (margin defaults to 1.0).
top1_median_duration_s: Expected (median) duration of the top-1 candidate
profile in seconds.
candidate_count: Number of candidate profiles on this device.
Returns a dict with exactly ``LIVE_MATCH_FEATURE_COLUMNS`` keys.
"""
elapsed = max(0.0, float(elapsed_s))
top1 = max(0.0, float(top1_distance))
top2_raw = float(top2_distance) if top2_distance is not None else 0.0
top2 = top2_raw if top2_raw > 1e-9 else top1 + 1.0
margin = max(0.0, top2 - top1)
dur = float(top1_median_duration_s)
progress = (elapsed / dur) if dur > 0 else 1.0
# prefix_active_fraction: fraction of prefix readings clearly above idle.
# Lab uses > 0.05 on a peak-normalised trace; equivalent here is > 5% of
# peak, with a 1 W floor so a cold trace never divides by near-zero.
if points:
powers = np.asarray([max(0.0, float(p)) for _, p in points], dtype=float)
peak = float(np.max(powers)) if powers.size else 0.0
active_thr = max(1.0, 0.05 * peak)
prefix_active_fraction = float(np.mean(powers > active_thr)) if powers.size else 0.0
else:
prefix_active_fraction = 0.0
return {
"match_progress_top1": float(min(progress, 2.0)),
"top1_distance": float(top1),
"margin": float(margin),
"distance_ratio": float(top1 / top2) if top2 > 1e-9 else 1.0,
"candidate_count_log": float(math.log1p(max(0, int(candidate_count)))),
"prefix_active_fraction": float(prefix_active_fraction),
"duration_ratio_top1": float(min(progress, 2.0)),
"elapsed_log": float(math.log1p(elapsed)),
}
# ---------------------------------------------------------------------------
# Remaining-time / progress regressor
# ---------------------------------------------------------------------------
# Feature columns for the on-device remaining-time regressor. Unlike the three
# classifier heads this model is a ``standardized_linear`` regressor whose target
# is the cycle completion fraction (elapsed / total_actual). There is no shipped
# baseline: the model exists only once on-device training promotes one over the
# naive elapsed/expected estimate (``elapsed_over_expected`` is deliberately the
# first column so the naive baseline is trivially recoverable). The same
# extractor runs at training time on synthesized prefixes and at inference on the
# live trace, so the columns cannot drift.
PROGRESS_FEATURE_COLUMNS = [
"elapsed_over_expected",
"energy_over_expected",
"mean_power_over_peak",
"recent_power_over_peak",
"tail_slope_norm",
"active_fraction",
"elapsed_log",
]
def progress_features(
points: Sequence[Point],
expectation: dict[str, float],
) -> dict[str, float] | None:
"""Features for the remaining-time regressor from a running-cycle prefix.
Args:
points: Observed prefix power readings (offset_s, watts).
expectation: Matched profile's median ``duration``/``energy``/``peak``
(as produced by :func:`profile_expectation`).
Returns a dict with exactly ``PROGRESS_FEATURE_COLUMNS`` keys, or ``None``
when there is too little data to characterise progress.
"""
pts = _clean_points(points)
if len(pts) < 4 or not expectation:
return None
offsets = np.asarray([o for o, _ in pts], dtype=float)
powers = np.asarray([p for _, p in pts], dtype=float)
elapsed = max(float(offsets[-1] - offsets[0]), 1.0)
exp_dur = max(float(expectation.get("duration") or 0.0), 1.0)
exp_energy = max(float(expectation.get("energy") or 0.0), 1e-6)
exp_peak = max(float(expectation.get("peak") or 0.0), 1.0)
energy_so_far = float(cumulative_energy_wh(pts)[-1])
active_thr = max(1.0, 0.05 * exp_peak)
active_mask = powers > active_thr
active = powers[active_mask]
mean_power = float(np.mean(active)) if active.size else 0.0
# Recent power: mean of the trailing ~5% of samples (min one sample).
tail_n = max(1, len(pts) // 20)
recent_power = float(np.mean(powers[-tail_n:]))
# Tail slope over the last quarter (W per sample), normalised by peak: a
# declining tail is a strong "near the end" signal.
quarter = max(2, len(pts) // 4)
tail = powers[-quarter:]
if tail.size >= 2:
x = np.arange(tail.size, dtype=float)
xm = x - float(np.mean(x))
denom = float(np.dot(xm, xm))
slope = float(np.dot(xm, tail - float(np.mean(tail))) / denom) if denom > 1e-9 else 0.0
else:
slope = 0.0
return {
"elapsed_over_expected": float(min(elapsed / exp_dur, 3.0)),
"energy_over_expected": float(min(energy_so_far / exp_energy, 3.0)),
"mean_power_over_peak": float(min(mean_power / exp_peak, 2.0)),
"recent_power_over_peak": float(min(recent_power / exp_peak, 2.0)),
"tail_slope_norm": float(np.clip(slope / exp_peak, -2.0, 2.0)),
"active_fraction": float(np.mean(active_mask)) if powers.size else 0.0,
"elapsed_log": float(math.log1p(elapsed)),
}
# ---------------------------------------------------------------------------
# Hybrid cycle quality
# ---------------------------------------------------------------------------
# Mirrors ml_washdata/wash_ml/hybrid_curve_quality.py HYBRID_FEATURE_COLUMNS.
# Order must match the embedded model exactly; the test suite asserts this.
QUALITY_FEATURE_COLUMNS = [
# profile / context
"duration_log_ratio",
"energy_log_ratio",
"peak_log_ratio",
"profile_distance",
"label_margin_positive",
"max_gap_ratio",
"low_power_gap_ratio",
"false_end_energy_ratio",
"sample_density_log",
"peak_density_log",
"local_spike_score",
"local_spike_rate",
"local_noise_score",
"leading_idle_ratio",
"trailing_idle_ratio",
"trimmed_duration_log_ratio",
"flag_pressure",
"shape_fit_penalty",
# trace shape
"shape_active_fraction",
"shape_early_energy_fraction",
"shape_late_energy_fraction",
"shape_mid_trough_depth",
"shape_peak_density",
"shape_max_step_drop",
"shape_max_step_rise",
"shape_active_cv",
"shape_autocorr_lag1",
"shape_derivative_sign_changes",
"shape_plateau_ratio",
"shape_tail_slope",
# availability
"has_trace",
]
_QUALITY_TRACE_LENGTH = 128
_IDLE_THRESHOLD_W = 2.0
_STOP_THRESHOLD_W = 2.0
_SHAPE_COLUMNS = [
"shape_active_fraction",
"shape_early_energy_fraction",
"shape_late_energy_fraction",
"shape_mid_trough_depth",
"shape_peak_density",
"shape_max_step_drop",
"shape_max_step_rise",
"shape_active_cv",
"shape_autocorr_lag1",
"shape_derivative_sign_changes",
"shape_plateau_ratio",
"shape_tail_slope",
]
def quality_features(
points: Sequence[Point],
profile_median_duration_s: float,
profile_median_energy_wh: float,
profile_median_peak_w: float,
profile_distance: float,
label_margin: float,
profile_fit_score: float,
flag_count: int,
*,
trace_length: int = _QUALITY_TRACE_LENGTH,
) -> dict[str, float]:
"""Features for the hybrid curve-quality model (problem/bad-cycle detector).
Args:
points: Complete cycle power trace (offset_s, watts).
profile_median_duration_s: Median duration of the matched profile (s).
profile_median_energy_wh: Median energy of the matched profile (Wh).
profile_median_peak_w: Median peak power of the matched profile (W).
profile_distance: Shape distance from the MatchResult to the assigned
profile envelope (higher = worse fit).
label_margin: Score margin between the top-1 and top-2 profile candidates
(positive = confident match; 0.0 when only one candidate exists).
profile_fit_score: Profile fit score in [0, 1] from the matcher.
flag_count: Number of detection/anomaly flags raised for this cycle by
the existing detector (early_power_dip, false_end_pause_seen, etc.).
Returns a dict with exactly ``QUALITY_FEATURE_COLUMNS`` keys.
"""
pts = _clean_points(points)
if len(pts) < 4:
return _no_trace_quality_features(
profile_distance=profile_distance,
label_margin=label_margin,
profile_fit_score=profile_fit_score,
flag_count=flag_count,
)
offsets = np.asarray([float(o) for o, _ in pts], dtype=float)
powers = np.asarray([float(p) for _, p in pts], dtype=float)
duration_s = max(float(offsets[-1] - offsets[0]), 1.0)
total_energy_wh = float(cumulative_energy_wh(pts)[-1])
max_power_w = float(np.max(powers))
# -- profile context ratios --
prof_dur = max(float(profile_median_duration_s), 1.0)
prof_energy = max(float(profile_median_energy_wh), 1e-6)
prof_peak = max(float(profile_median_peak_w), 1.0)
# -- sampling gap features --
intervals = np.diff(offsets)
usable = intervals[(intervals > 0) & (intervals < 3600)]
max_gap_s = float(np.max(usable)) if usable.size else 0.0
# -- low-power / false-end features --
low_gap_s = _longest_low_power_gap_s(pts, _STOP_THRESHOLD_W)
fe_energy_wh = _false_end_energy_wh(pts, _STOP_THRESHOLD_W)
# -- density features --
sample_count = len(pts)
peak_count = _power_peak_count_arr(powers)
# -- noise features (from raw points) --
noise = _trace_noise_features(pts)
# -- idle-padding features --
padding = _trace_padding_ratios(pts, offsets, powers, duration_s, _IDLE_THRESHOLD_W)
# -- trace shape descriptors (from resampled + trimmed trace) --
trace = _resample_to_length(pts, trace_length)
shape = _trace_shape_descriptors(trace) if trace is not None else {c: 0.0 for c in _SHAPE_COLUMNS}
trimmed_ratio = float(padding["trimmed_duration_ratio"])
trimmed_log = math.log(max(1e-6, trimmed_ratio)) # always <= 0
return {
"duration_log_ratio": _log_ratio(duration_s / prof_dur),
"energy_log_ratio": _log_ratio(total_energy_wh / prof_energy),
"peak_log_ratio": _log_ratio(max_power_w / prof_peak),
"profile_distance": float(profile_distance),
"label_margin_positive": float(max(0.0, float(label_margin))),
"max_gap_ratio": _safe_div(max_gap_s, duration_s),
"low_power_gap_ratio": _safe_div(low_gap_s, duration_s),
"false_end_energy_ratio": _safe_div(fe_energy_wh, max(total_energy_wh, 1e-6)),
"sample_density_log": math.log1p(_safe_div(sample_count * 60.0, duration_s)),
"peak_density_log": math.log1p(_safe_div(peak_count * 3600.0, duration_s)),
"local_spike_score": float(noise["local_spike_score"]),
"local_spike_rate": float(noise["local_spike_rate"]),
"local_noise_score": float(noise["local_noise_score"]),
"leading_idle_ratio": float(padding["leading_idle_ratio"]),
"trailing_idle_ratio": float(padding["trailing_idle_ratio"]),
"trimmed_duration_log_ratio": float(trimmed_log),
"flag_pressure": float(max(0, int(flag_count))),
"shape_fit_penalty": float(max(0.0, 1.0 - float(profile_fit_score))),
**shape,
"has_trace": 1.0,
}
def _no_trace_quality_features(
*,
profile_distance: float,
label_margin: float,
profile_fit_score: float,
flag_count: int,
) -> dict[str, float]:
"""Zero-valued quality features for cycles with no usable power trace."""
return {
"duration_log_ratio": 0.0,
"energy_log_ratio": 0.0,
"peak_log_ratio": 0.0,
"profile_distance": float(profile_distance),
"label_margin_positive": float(max(0.0, float(label_margin))),
"max_gap_ratio": 0.0,
"low_power_gap_ratio": 0.0,
"false_end_energy_ratio": 0.0,
"sample_density_log": 0.0,
"peak_density_log": 0.0,
"local_spike_score": 0.0,
"local_spike_rate": 0.0,
"local_noise_score": 0.0,
"leading_idle_ratio": 0.0,
"trailing_idle_ratio": 0.0,
"trimmed_duration_log_ratio": 0.0,
"flag_pressure": float(max(0, int(flag_count))),
"shape_fit_penalty": float(max(0.0, 1.0 - float(profile_fit_score))),
**{c: 0.0 for c in _SHAPE_COLUMNS},
"has_trace": 0.0,
}
# ---------------------------------------------------------------------------
# Shared helpers (ported from ml_washdata/wash_ml/features.py and
# ml_washdata/wash_ml/hybrid_curve_quality.py - NumPy only)
# ---------------------------------------------------------------------------
def _clean_points(points: Sequence[Point]) -> list[Point]:
"""Filter out non-finite readings, sort by offset, and deduplicate."""
clean: list[Point] = []
for offset_raw, power_raw in points:
offset = float(offset_raw)
power = float(power_raw)
if math.isfinite(offset) and math.isfinite(power):
clean.append((offset, max(0.0, power)))
clean.sort(key=lambda pt: pt[0])
deduped: list[Point] = []
for offset, power in clean:
if deduped and offset == deduped[-1][0]:
deduped[-1] = (offset, power)
else:
deduped.append((offset, power))
return deduped
def _longest_low_power_gap_s(points: list[Point], threshold_w: float) -> float:
"""Longest contiguous span below ``threshold_w`` (seconds)."""
longest = 0.0
current = 0.0
for i in range(1, len(points)):
prev_t, prev_p = points[i - 1]
curr_t, curr_p = points[i]
dt = curr_t - prev_t
if dt <= 0 or dt > 3600:
current = 0.0
continue
avg = (prev_p + curr_p) / 2.0
if avg < threshold_w:
current += dt
longest = max(longest, current)
else:
current = 0.0
return float(longest)
def _false_end_energy_wh(points: list[Point], threshold_w: float) -> float:
"""Energy accumulated during low-power pauses that were followed by more power.
These "false ends" indicate the cycle was interrupted but resumed. Returns
the maximum such pause energy (Wh); 0.0 if no false end occurred.
"""
false_energies: list[float] = []
in_pause = False
pause_energy = 0.0
for i in range(1, len(points)):
prev_t, prev_p = points[i - 1]
curr_t, curr_p = points[i]
dt = curr_t - prev_t
if dt <= 0 or dt > 3600:
in_pause = False
pause_energy = 0.0
continue
avg = (prev_p + curr_p) / 2.0
if avg < threshold_w:
in_pause = True
pause_energy += avg * (dt / 3600.0)
elif in_pause:
false_energies.append(pause_energy)
in_pause = False
pause_energy = 0.0
return float(max(false_energies) if false_energies else 0.0)
def _power_peak_count_arr(powers: np.ndarray) -> int:
"""Number of above-p75 power peaks (rising transitions through the p75 threshold)."""
if powers.size < 3:
return 0
threshold = max(float(np.percentile(powers, 75)), 10.0)
above = powers > threshold
transitions = np.diff(above.astype(int))
return int(np.sum(transitions == 1) + (1 if above[0] else 0))
def _trace_noise_features(points: list[Point]) -> dict[str, float]:
"""Local spike and noise floor metrics over the raw power trace.
Ported from ml_washdata/wash_ml/features.py ``trace_noise_features``.
Distinguishes narrow single-sample spikes from broad appliance phases.
"""
if len(points) < 5:
return {"local_spike_score": 0.0, "local_spike_rate": 0.0, "local_noise_score": 0.0}
powers = np.asarray([float(p) for _, p in points], dtype=float)
active = powers[powers > 0.5]
scale = float(np.percentile(active, 95)) if active.size else float(np.max(powers))
if not math.isfinite(scale) or scale <= 1e-6:
return {"local_spike_score": 0.0, "local_spike_rate": 0.0, "local_noise_score": 0.0}
normalized = np.clip(powers / scale, 0.0, 8.0)
spike_scores: list[float] = []
residuals: list[float] = []
n = normalized.size
for i, value in enumerate(normalized):
left = max(0, i - 2)
right = min(n, i + 3)
neighbors = np.concatenate([normalized[left:i], normalized[i + 1:right]])
if neighbors.size < 2:
continue
local_median = float(np.median(neighbors))
residual = abs(float(value - local_median))
residuals.append(residual)
left_nbr = float(normalized[i - 1]) if i > 0 else local_median
right_nbr = float(normalized[i + 1]) if i + 1 < n else local_median
shoulder = max(left_nbr, right_nbr)
narrow_jump = float(value - shoulder)
if value > 0.15 and (value - local_median) > 0.28 and narrow_jump > 0.18:
spike_scores.append(min(3.0, max(float(value - local_median), narrow_jump)))
spike_count = len(spike_scores)
return {
"local_spike_score": round(float(max(spike_scores, default=0.0)), 6),
"local_spike_rate": round(float(spike_count / max(1, n)), 6),
"local_noise_score": round(float(np.percentile(np.asarray(residuals, dtype=float), 95)) if residuals else 0.0, 6),
}
def _trace_padding_ratios(
pts: list[Point],
offsets: np.ndarray,
powers: np.ndarray,
duration_s: float,
idle_threshold_w: float,
) -> dict[str, float]:
"""Leading/trailing idle fractions and trimmed-duration ratio."""
active_mask = powers > idle_threshold_w
active_indexes = np.where(active_mask)[0]
if active_indexes.size == 0 or duration_s <= 0:
return {"leading_idle_ratio": 0.0, "trailing_idle_ratio": 0.0, "trimmed_duration_ratio": 1.0}
first_active_t = float(offsets[active_indexes[0]])
last_active_t = float(offsets[active_indexes[-1]])
start_t = float(offsets[0])
end_t = float(offsets[-1])
leading = max(0.0, first_active_t - start_t)
trailing = max(0.0, end_t - last_active_t)
trimmed = max(0.0, last_active_t - first_active_t)
return {
"leading_idle_ratio": float(leading / duration_s),
"trailing_idle_ratio": float(trailing / duration_s),
"trimmed_duration_ratio": float(trimmed / duration_s) if duration_s > 0 else 1.0,
}
def _resample_to_length(points: list[Point], length: int) -> np.ndarray | None:
"""Trim idle padding, resample to ``length`` points, and peak-normalise.
Ported from ml_washdata/wash_ml/hybrid_curve_quality.py ``_resample_trace``.
Returns ``None`` when the trace is too short to be useful.
"""
# Trim leading/trailing idle.
active_indexes = [i for i, (_, p) in enumerate(points) if p > _IDLE_THRESHOLD_W]
if not active_indexes:
return None
pad_s = 60.0
start_off = max(points[0][0], points[active_indexes[0]][0] - pad_s)
end_off = points[active_indexes[-1]][0] + pad_s
trimmed = [(o, p) for o, p in points if start_off <= o <= end_off]
if len(trimmed) < 2:
return None
offsets = np.asarray([float(o) for o, _ in trimmed], dtype=float)
powers = np.asarray([max(0.0, float(p)) for _, p in trimmed], dtype=float)
valid = np.isfinite(offsets) & np.isfinite(powers)
offsets = offsets[valid]
powers = powers[valid]
if offsets.size < 2 or offsets[-1] <= offsets[0]:
return None
grid = np.linspace(offsets[0], offsets[-1], length)
trace = np.interp(grid, offsets, powers)
active = trace[trace > 0.5]
scale = float(np.percentile(active, 95)) if active.size else float(np.max(trace))
if not math.isfinite(scale) or scale <= 1e-6:
scale = 1.0
return np.clip(trace / scale, 0.0, 5.0)
def _trace_shape_descriptors(trace: np.ndarray) -> dict[str, float]:
"""Deterministic, scale-robust shape descriptors over a normalised trace.
Ported from ml_washdata/wash_ml/hybrid_curve_quality.py
``_trace_shape_descriptors``. Every value is NumPy-computable at runtime.
"""
if trace is None or trace.size < 4:
return {c: 0.0 for c in _SHAPE_COLUMNS}
trace = np.asarray(trace, dtype=float)
length = trace.size
total = float(np.sum(trace))
active_mask = trace > 0.5
active = trace[active_mask]
quarter = max(1, length // 4)
early_energy = float(np.sum(trace[:quarter]))
late_energy = float(np.sum(trace[-quarter:]))
mid = trace[quarter: length - quarter]
active_level = float(np.median(active)) if active.size else 0.0
mid_trough_depth = 0.0
if mid.size and active_level > 1e-6:
mid_trough_depth = float(np.clip(1.0 - float(np.min(mid)) / active_level, 0.0, 1.0))
diffs = np.diff(trace)
return {
"shape_active_fraction": float(np.mean(active_mask)),
"shape_early_energy_fraction": _safe_div(early_energy, total),
"shape_late_energy_fraction": _safe_div(late_energy, total),
"shape_mid_trough_depth": float(mid_trough_depth),
"shape_peak_density": _shape_peak_density(trace),
"shape_max_step_drop": float(max(0.0, -float(np.min(diffs)))) if diffs.size else 0.0,
"shape_max_step_rise": float(max(0.0, float(np.max(diffs)))) if diffs.size else 0.0,
"shape_active_cv": _safe_div(float(np.std(active)), float(np.mean(active))) if active.size else 0.0,
"shape_autocorr_lag1": _autocorr_lag1(trace),
"shape_derivative_sign_changes": _safe_div(
float(np.sum(np.abs(np.diff(np.sign(diffs))) > 0)), float(diffs.size)
) if diffs.size else 0.0,
"shape_plateau_ratio": _plateau_ratio(trace, active_level),
"shape_tail_slope": _safe_div(float(trace[-1] - trace[-quarter]), float(quarter)),
}
def _shape_peak_density(trace: np.ndarray, prominence: float = 0.2) -> float:
"""Prominent local maxima per sample."""
if trace.size < 3:
return 0.0
peaks = sum(
1
for i in range(1, trace.size - 1)
if trace[i] > trace[i - 1] and trace[i] >= trace[i + 1] and trace[i] >= prominence
)
return float(peaks) / float(trace.size)
def _autocorr_lag1(trace: np.ndarray) -> float:
"""Lag-1 autocorrelation (smoothness indicator)."""
centered = trace - float(np.mean(trace))
denom = float(np.dot(centered, centered))
if denom <= 1e-9:
return 0.0
return float(np.dot(centered[:-1], centered[1:]) / denom)
def _plateau_ratio(trace: np.ndarray, active_level: float, band: float = 0.12) -> float:
"""Fraction of trace within ``band`` of the running active level."""
if active_level <= 1e-6:
return 0.0
within = np.abs(trace - active_level) <= band
return float(np.mean(within & (trace > 0.5)))
def _log_ratio(ratio: float) -> float:
"""log(ratio) clamped to finite; 0.0 for non-positive or non-finite inputs."""
if not math.isfinite(ratio) or ratio <= 0:
return 0.0
return float(math.log(max(1e-6, ratio)))
def _safe_div(numerator: float, denominator: float) -> float:
if not math.isfinite(numerator) or not math.isfinite(denominator) or abs(denominator) <= 1e-9:
return 0.0
return float(numerator / denominator)
@@ -0,0 +1,157 @@
[
{
"feature": "duration_log_ratio",
"group": "profile_context",
"runtime_source": "log(cycle_duration / profile_target_duration)"
},
{
"feature": "energy_log_ratio",
"group": "profile_context",
"runtime_source": "log(cycle_energy_wh / profile_median_energy_wh)"
},
{
"feature": "peak_log_ratio",
"group": "profile_context",
"runtime_source": "log(max_power_w / profile_median_peak_w)"
},
{
"feature": "profile_distance",
"group": "match",
"runtime_source": "MatchResult distance to assigned profile envelope"
},
{
"feature": "label_margin_positive",
"group": "match",
"runtime_source": "max(0, score margin between top-1 and top-2 profile candidates)"
},
{
"feature": "max_gap_ratio",
"group": "sampling",
"runtime_source": "max sample gap seconds / cycle duration seconds"
},
{
"feature": "low_power_gap_ratio",
"group": "sampling",
"runtime_source": "longest low-power gap seconds / cycle duration seconds"
},
{
"feature": "false_end_energy_ratio",
"group": "tail",
"runtime_source": "energy after first apparent end / total energy"
},
{
"feature": "sample_density_log",
"group": "sampling",
"runtime_source": "log1p(sample_count * 60 / duration_s)"
},
{
"feature": "peak_density_log",
"group": "sampling",
"runtime_source": "log1p(power_peak_count * 3600 / duration_s)"
},
{
"feature": "local_spike_score",
"group": "noise",
"runtime_source": "trace-local spike magnitude score"
},
{
"feature": "local_spike_rate",
"group": "noise",
"runtime_source": "trace-local spike rate"
},
{
"feature": "local_noise_score",
"group": "noise",
"runtime_source": "trace-local noise floor score"
},
{
"feature": "leading_idle_ratio",
"group": "trim",
"runtime_source": "leading 0W padding fraction before true start"
},
{
"feature": "trailing_idle_ratio",
"group": "trim",
"runtime_source": "trailing 0W padding fraction after true end"
},
{
"feature": "trimmed_duration_log_ratio",
"group": "trim",
"runtime_source": "log(trimmed duration / raw duration)"
},
{
"feature": "flag_pressure",
"group": "flags",
"runtime_source": "count of detection/anomaly flags raised for the cycle"
},
{
"feature": "shape_fit_penalty",
"group": "match",
"runtime_source": "max(0, 1 - profile_fit_score)"
},
{
"feature": "shape_active_fraction",
"group": "trace_shape",
"runtime_source": "fraction of normalized trace above 0.5"
},
{
"feature": "shape_early_energy_fraction",
"group": "trace_shape",
"runtime_source": "energy share in first 25% of trace"
},
{
"feature": "shape_late_energy_fraction",
"group": "trace_shape",
"runtime_source": "energy share in last 25% of trace"
},
{
"feature": "shape_mid_trough_depth",
"group": "trace_shape",
"runtime_source": "1 - min(mid trace)/median active (split/pause depth)"
},
{
"feature": "shape_peak_density",
"group": "trace_shape",
"runtime_source": "prominent local maxima per sample"
},
{
"feature": "shape_max_step_drop",
"group": "trace_shape",
"runtime_source": "largest single-step drop (false-end signature)"
},
{
"feature": "shape_max_step_rise",
"group": "trace_shape",
"runtime_source": "largest single-step rise"
},
{
"feature": "shape_active_cv",
"group": "trace_shape",
"runtime_source": "std/mean over active region"
},
{
"feature": "shape_autocorr_lag1",
"group": "trace_shape",
"runtime_source": "lag-1 autocorrelation (smoothness)"
},
{
"feature": "shape_derivative_sign_changes",
"group": "trace_shape",
"runtime_source": "derivative sign-change rate (oscillation)"
},
{
"feature": "shape_plateau_ratio",
"group": "trace_shape",
"runtime_source": "fraction within band of running active level"
},
{
"feature": "shape_tail_slope",
"group": "trace_shape",
"runtime_source": "slope across the last 25% of trace"
},
{
"feature": "has_trace",
"group": "availability",
"runtime_source": "1.0 when a usable power trace exists, else 0.0"
}
]
@@ -0,0 +1,172 @@
# WashData - Home Assistant integration for appliance cycle monitoring via smart plugs.
# Copyright (C) 2026 Lukas Bandura
# SPDX-License-Identifier: AGPL-3.0-or-later
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""Auto-generated by ml_washdata/wash_ml/promotion.py. Do not edit by hand.
Embedded WashData model: 'hybrid_curve_quality' (target: 'problem_cycle').
Kind: standardized logistic regression. Runtime dependency: NumPy only.
Regenerate with ``./ml.sh experiment`` in the ml_washdata lab and copy the new
file. Determinism check at generation time: max_abs_score_diff=6.8331e-08
over 373 rows.
Usage in the integration::
from .hybrid_curve_quality_model import score, predict, FEATURE_COLUMNS
features = build_runtime_features(...) # must populate FEATURE_COLUMNS
is_positive = predict(features)
"""
from __future__ import annotations
import base64
import gzip
import json
from typing import Mapping
import numpy as np
MODEL_NAME = 'hybrid_curve_quality'
MODEL_TARGET = 'problem_cycle'
MODEL_KIND = 'standardized_logistic'
TARGET_UNITS = ''
THRESHOLD = 0.19
FEATURE_COLUMNS = [
'duration_log_ratio',
'energy_log_ratio',
'peak_log_ratio',
'profile_distance',
'label_margin_positive',
'max_gap_ratio',
'low_power_gap_ratio',
'false_end_energy_ratio',
'sample_density_log',
'peak_density_log',
'local_spike_score',
'local_spike_rate',
'local_noise_score',
'leading_idle_ratio',
'trailing_idle_ratio',
'trimmed_duration_log_ratio',
'flag_pressure',
'shape_fit_penalty',
'shape_active_fraction',
'shape_early_energy_fraction',
'shape_late_energy_fraction',
'shape_mid_trough_depth',
'shape_peak_density',
'shape_max_step_drop',
'shape_max_step_rise',
'shape_active_cv',
'shape_autocorr_lag1',
'shape_derivative_sign_changes',
'shape_plateau_ratio',
'shape_tail_slope',
'has_trace',
]
# Provenance (metrics at training time):
MODEL_METRICS = json.loads("""{
"owner_holdout": {
"accuracy": 0.872,
"balanced_accuracy": 0.89997,
"f1": 0.898734,
"fn": 15,
"fp": 1,
"positive_rate": 0.576,
"precision": 0.986111,
"problem_recall": 0.825581,
"rows": 125,
"specificity": 0.974359,
"tn": 38,
"tp": 71
},
"synthetic_all": {
"accuracy": 1.0,
"balanced_accuracy": 0.5,
"f1": 1.0,
"fn": 0,
"fp": 0,
"positive_rate": 1.0,
"precision": 1.0,
"problem_recall": 1.0,
"rows": 1255,
"specificity": 0.0,
"tn": 0,
"tp": 1255
}
}""")
_MODEL_BLOB = (
'H4sIAAAAAAACA3VW247bOAz9FcOvzaS6X7JPi6L7tBeg230qCkNjK4lR2/L60uls0X/fQzmTmem0yENkiiIPDymRX8vbNszlQe+V'
'1txoo3dlHYclTuXhww3bM2GMsZbvaO0151Zua6mtMMLt2N5aJ6URBkshmJLcqh0pC6ad87SU0mqns5QLeIE5vudeSyNNPmaMwo+W'
'UljpLfebrpQqGxPeCuW1yFKulJN5mSFYTXg4Z5o5pXZib5y2XAkyYTkThI5MKCaga7FUWnLvLWHnkjvpHCl4ox3XnqTMCuGV4CTV'
'QltpCZqHKas84eFcGkjJhZMGMq/JLnBLZjJgphwXVmSqaO2yMY99ydVHcJziEQwTT84J5mmbawk4hkwxnJYe9NJ5w5UGYGKHG4Ms'
'2Cx2nkGZkHEBsreAmBZcARwRLBzywTI9nkllPMvKiAO8kRfhPNg22YuwzIktSVwII9nmhHEH6s2WcoUAlATF0nBmrCLQSkoHpxek'
'xiuWQ2UMgGymUCKLcG22oiACsmkkCV5UzgKVhMxpktbCpdrKzXkOg7SWYFttYu457NqNL2etyim1wiKTlzIFPKcFsTzFsMSmCkt5'
'KAVq+YaZG+HfC3ZQ7qD4K8YOjJW78gi9dYpVnbq1H3AfPpTNOoWlTUPVpVOVl9CLQ5xO989EYwyfngumdGy7WDXtvIShjhB14TZ2'
'VR+mUztUY5rbpf1M8j58qU5hvB7t0h227+L0THoM3RyrODTVxf3Dxhz6kRzFARYzqgc8z0VdqkNXzWP7KVZznab4nQz2HkVDaucn'
'ajE07XCq2qaLV7/LFNruB9K270H2D4k7duFUjVOc5zXbnc9hjNWxXaoxDqFb7q+yUBM51XGiRRqu8him7v6BgRe7HUL46WbfNtUy'
'pfV0BjHjcr5uPOXqURtJmZc4Vs2UxpfSCfR8D7b+/ChZlwTuJiA68au0iVP7OWTduT0NVX0OwynOj0AIf1gfM5ulC2iu5i6N5PAc'
'ZgQRUE8o7BOIq1Pft1TYhungjIDOp3ZoIKC6a8LUtP8hHcgCCrGtqdwiclSjur+W6Q5cVefUNWldSBDqGnmr78sD3jQ8XOVt6Kh6'
'cXme7uAFsMgm3z6clQpfQ3ng6BvHEf8owEt9b2V1oJtu6FbEup0pK5B4Zzjn+arcdrGvsBe6LtsUGs/wrpzSHXCiW4CLESePbU05'
'orNWSe1RbrAkHf7h1fJv0LsflnNEpFW29TQkvmc/CUhvwWQFioNtYbAXYWSNJ0Fcvp/jz8Ir9JfY2QabbahJ5RuAD6GHg/J8fzuh'
'UIENTv9dQ7cV5ZCWmB+kN6m/bYfYFLkKbnKJFK+Ky3Pzuk7o2l+W4oLppr6vu1g0cYn1kqY9LL1bh6XtY9GvM9TSuFLRFb+9/fX9'
'P+/eVm/++v2fP/78uzhOqS86BF5kC68v9osmLKG4jUe8CwW9Drj/ZPQ93gIClYbiDrepoPq/ye/dXIzdOhfXtBSEcErd/EuxtKfz'
'EnGiXc7Fee3DcDPFz228g6HL2YundY7TzTHU8EaR9Yku9p6uAOp2XHELLsNKJvcim5GNh5Rd1ui0Gi3G0uzB9gb/uW9YJZzFLIN2'
'KRyXCk2StiVnaBa5J6NdcbHNI2jeArWXJxoMCyjVTdc6oahvOpzSioTU29CPbFbFWki3zTYavSkb0IxhmtC5+aEJW0vNDxJcKSGA'
'RqFlWp/nK8EdBpNsjNoaV1vvRjBOcDqGhu8xKG1LWKCeTy4waUlBUsOkMyoPMRqtmsamTRfDyzZ5YBPjk89LULKNTByTj9xGG8ZB'
'HQaAXZ4IMTF595G4Pcc+oHTvwnym+thvKcId61MTu9f0AC7oepGeqYfLksvqulGtQ7ugvkuSnNEg6E2idHL/7X9yriqGnQoAAA=='
)
_MODEL_CACHE: dict | None = None
def _load() -> dict:
global _MODEL_CACHE
if _MODEL_CACHE is None:
payload = gzip.decompress(base64.b64decode(_MODEL_BLOB.encode("ascii")))
spec = json.loads(payload.decode("utf-8"))
_MODEL_CACHE = {
"center": np.asarray(spec["center"], dtype=float),
"scale": np.asarray(spec["scale"], dtype=float),
"coef": np.asarray(spec["coef"], dtype=float),
"bias": float(spec["bias"]),
"threshold": float(spec["threshold"]),
"output_center": float(spec.get("output_center") or 0.0),
"output_scale": float(spec.get("output_scale") if spec.get("output_scale") is not None else 1.0),
"feature_columns": list(spec["feature_columns"]),
}
return _MODEL_CACHE
def score(features: Mapping[str, float]) -> float:
"""Return the model probability in [0, 1] for one feature mapping."""
model = _load()
vector = np.array(
[float(features.get(column) or 0.0) for column in model["feature_columns"]],
dtype=float,
)
scaled = (vector - model["center"]) / model["scale"]
logit = float(scaled @ model["coef"] + model["bias"])
logit = max(-60.0, min(60.0, logit))
return 1.0 / (1.0 + np.exp(-logit))
def predict(features: Mapping[str, float]) -> bool:
"""True when the example crosses the embedded decision threshold."""
return score(features) >= _load()["threshold"]
@@ -0,0 +1,294 @@
{
"cases": [
{
"expected_score": 0.0058697,
"features": {
"duration_log_ratio": 0.0,
"energy_log_ratio": 0.0,
"false_end_energy_ratio": 0.0,
"flag_pressure": 0.0,
"has_trace": 1.0,
"label_margin_positive": 0.0,
"leading_idle_ratio": 0.0,
"local_noise_score": 0.918434,
"local_spike_rate": 0.0,
"local_spike_score": 0.0,
"low_power_gap_ratio": 0.0462132,
"max_gap_ratio": 0.0462132,
"peak_density_log": 2.28328714,
"peak_log_ratio": -0.34046848,
"profile_distance": 0.0,
"sample_density_log": 0.81000683,
"shape_active_cv": 0.14447535,
"shape_active_fraction": 0.46875,
"shape_autocorr_lag1": 0.30744328,
"shape_derivative_sign_changes": 0.52755906,
"shape_early_energy_fraction": 0.33872822,
"shape_fit_penalty": 0.0,
"shape_late_energy_fraction": 0.11182152,
"shape_max_step_drop": 0.94082077,
"shape_max_step_rise": 0.94068383,
"shape_mid_trough_depth": 0.93907254,
"shape_peak_density": 0.2109375,
"shape_plateau_ratio": 0.3828125,
"shape_tail_slope": -0.03142463,
"trailing_idle_ratio": 0.052709,
"trimmed_duration_log_ratio": -0.05414895
}
},
{
"expected_score": 0.05607764,
"features": {
"duration_log_ratio": 0.0,
"energy_log_ratio": 0.0,
"false_end_energy_ratio": 0.0,
"flag_pressure": 0.0,
"has_trace": 1.0,
"label_margin_positive": 0.0,
"leading_idle_ratio": 0.0,
"local_noise_score": 0.142423,
"local_spike_rate": 0.0,
"local_spike_score": 0.0,
"low_power_gap_ratio": 0.0,
"max_gap_ratio": 0.00865005,
"peak_density_log": 1.65845988,
"peak_log_ratio": 0.0,
"profile_distance": 0.0,
"sample_density_log": 0.72835069,
"shape_active_cv": 0.08213481,
"shape_active_fraction": 0.078125,
"shape_autocorr_lag1": 0.93041708,
"shape_derivative_sign_changes": 0.46456693,
"shape_early_energy_fraction": 0.71529561,
"shape_fit_penalty": 0.0,
"shape_late_energy_fraction": 0.14814543,
"shape_max_step_drop": 0.77920376,
"shape_max_step_rise": 0.5558909,
"shape_mid_trough_depth": 0.99420388,
"shape_peak_density": 0.03125,
"shape_plateau_ratio": 0.0625,
"shape_tail_slope": -0.00093727,
"trailing_idle_ratio": 0.0,
"trimmed_duration_log_ratio": 0.0
}
},
{
"expected_score": 0.1196706,
"features": {
"duration_log_ratio": 0.0,
"energy_log_ratio": 0.0,
"false_end_energy_ratio": 1.508e-05,
"flag_pressure": 1.0,
"has_trace": 1.0,
"label_margin_positive": 0.0,
"leading_idle_ratio": 0.012663,
"local_noise_score": 0.055005,
"local_spike_rate": 0.0,
"local_spike_score": 0.0,
"low_power_gap_ratio": 0.01154096,
"max_gap_ratio": 0.01163994,
"peak_density_log": 2.17918982,
"peak_log_ratio": 0.0,
"profile_distance": 0.0,
"sample_density_log": 0.88104527,
"shape_active_cv": 0.01007372,
"shape_active_fraction": 0.375,
"shape_autocorr_lag1": 0.96196064,
"shape_derivative_sign_changes": 0.52755906,
"shape_early_energy_fraction": 0.45151166,
"shape_fit_penalty": 0.0,
"shape_late_energy_fraction": 0.01292759,
"shape_max_step_drop": 0.95083153,
"shape_max_step_rise": 0.98360456,
"shape_mid_trough_depth": 0.99823463,
"shape_peak_density": 0.1015625,
"shape_plateau_ratio": 0.375,
"shape_tail_slope": -0.00030196,
"trailing_idle_ratio": 0.00013,
"trimmed_duration_log_ratio": -0.01287655
}
},
{
"expected_score": 0.34367089,
"features": {
"duration_log_ratio": 0.0,
"energy_log_ratio": 0.0,
"false_end_energy_ratio": 0.0,
"flag_pressure": 2.0,
"has_trace": 1.0,
"label_margin_positive": 0.0,
"leading_idle_ratio": 0.0,
"local_noise_score": 0.914775,
"local_spike_rate": 0.005714,
"local_spike_score": 0.697412,
"low_power_gap_ratio": 0.14300259,
"max_gap_ratio": 0.08871048,
"peak_density_log": 2.33230715,
"peak_log_ratio": 0.0,
"profile_distance": 0.0,
"sample_density_log": 0.75631571,
"shape_active_cv": 0.12076531,
"shape_active_fraction": 0.453125,
"shape_autocorr_lag1": 0.37598497,
"shape_derivative_sign_changes": 0.48031496,
"shape_early_energy_fraction": 0.33717205,
"shape_fit_penalty": 0.0,
"shape_late_energy_fraction": 0.09040023,
"shape_max_step_drop": 0.93456211,
"shape_max_step_rise": 0.94494752,
"shape_mid_trough_depth": 0.9376202,
"shape_peak_density": 0.203125,
"shape_plateau_ratio": 0.3828125,
"shape_tail_slope": -0.00193552,
"trailing_idle_ratio": 0.14928,
"trimmed_duration_log_ratio": -0.16167223
}
},
{
"expected_score": 0.53702273,
"features": {
"duration_log_ratio": 0.0,
"energy_log_ratio": 0.0,
"false_end_energy_ratio": 0.0,
"flag_pressure": 2.0,
"has_trace": 1.0,
"label_margin_positive": 0.0,
"leading_idle_ratio": 0.0,
"local_noise_score": 0.757831,
"local_spike_rate": 0.241379,
"local_spike_score": 0.742169,
"low_power_gap_ratio": 0.0,
"max_gap_ratio": 0.04654033,
"peak_density_log": 3.01770482,
"peak_log_ratio": 0.0,
"profile_distance": 0.0,
"sample_density_log": 0.9424837,
"shape_active_cv": 0.26732401,
"shape_active_fraction": 0.4296875,
"shape_autocorr_lag1": 0.9008239,
"shape_derivative_sign_changes": 0.1496063,
"shape_early_energy_fraction": 0.17508041,
"shape_fit_penalty": 0.0,
"shape_late_energy_fraction": 0.23482531,
"shape_max_step_drop": 0.34637187,
"shape_max_step_rise": 0.19347146,
"shape_mid_trough_depth": 0.93833705,
"shape_peak_density": 0.0703125,
"shape_plateau_ratio": 0.15625,
"shape_tail_slope": -0.01567805,
"trailing_idle_ratio": 0.0,
"trimmed_duration_log_ratio": 0.0
}
},
{
"expected_score": 0.8497107,
"features": {
"duration_log_ratio": 0.0,
"energy_log_ratio": 0.0,
"false_end_energy_ratio": 7.272e-05,
"flag_pressure": 2.0,
"has_trace": 1.0,
"label_margin_positive": 0.0,
"leading_idle_ratio": 0.0,
"local_noise_score": 0.515134,
"local_spike_rate": 0.114894,
"local_spike_score": 0.905233,
"low_power_gap_ratio": 0.05700163,
"max_gap_ratio": 0.01734271,
"peak_density_log": 2.66830551,
"peak_log_ratio": 0.01278344,
"profile_distance": 0.0,
"sample_density_log": 1.10556404,
"shape_active_cv": 0.01663372,
"shape_active_fraction": 0.0546875,
"shape_autocorr_lag1": 0.858075,
"shape_derivative_sign_changes": 0.51181102,
"shape_early_energy_fraction": 0.57687186,
"shape_fit_penalty": 0.0,
"shape_late_energy_fraction": 0.18846404,
"shape_max_step_drop": 1.22193942,
"shape_max_step_rise": 1.21758141,
"shape_mid_trough_depth": 0.99936713,
"shape_peak_density": 0.03125,
"shape_plateau_ratio": 0.0546875,
"shape_tail_slope": -0.00140962,
"trailing_idle_ratio": 0.062792,
"trimmed_duration_log_ratio": -0.06485004
}
},
{
"expected_score": 0.93456044,
"features": {
"duration_log_ratio": 0.0,
"energy_log_ratio": 0.0,
"false_end_energy_ratio": 0.0,
"flag_pressure": 2.0,
"has_trace": 1.0,
"label_margin_positive": 0.0,
"leading_idle_ratio": 0.0,
"local_noise_score": 0.044825,
"local_spike_rate": 0.001105,
"local_spike_score": 0.38183,
"low_power_gap_ratio": 0.0,
"max_gap_ratio": 0.00883896,
"peak_density_log": 2.27572511,
"peak_log_ratio": 0.0,
"profile_distance": 0.0,
"sample_density_log": 2.34285295,
"shape_active_cv": 0.03491006,
"shape_active_fraction": 0.1796875,
"shape_autocorr_lag1": 0.83801221,
"shape_derivative_sign_changes": 0.5511811,
"shape_early_energy_fraction": 0.86309364,
"shape_fit_penalty": 0.0,
"shape_late_energy_fraction": 0.02448348,
"shape_max_step_drop": 0.95857301,
"shape_max_step_rise": 0.95800353,
"shape_mid_trough_depth": 0.99645606,
"shape_peak_density": 0.0625,
"shape_plateau_ratio": 0.171875,
"shape_tail_slope": -0.00415246,
"trailing_idle_ratio": 0.006029,
"trimmed_duration_log_ratio": -0.00604725
}
},
{
"expected_score": 0.9999956,
"features": {
"duration_log_ratio": 0.0,
"energy_log_ratio": 0.0,
"false_end_energy_ratio": 0.0,
"flag_pressure": 4.0,
"has_trace": 1.0,
"label_margin_positive": 0.0,
"leading_idle_ratio": 0.0,
"local_noise_score": 0.0,
"local_spike_rate": 0.0,
"local_spike_score": 0.0,
"low_power_gap_ratio": 0.0,
"max_gap_ratio": 0.99441282,
"peak_density_log": 0.0,
"peak_log_ratio": 0.0,
"profile_distance": 0.0,
"sample_density_log": 3.23289577,
"shape_active_cv": 0.0,
"shape_active_fraction": 1.0,
"shape_autocorr_lag1": 0.0,
"shape_derivative_sign_changes": 0.0,
"shape_early_energy_fraction": 0.25,
"shape_fit_penalty": 0.0,
"shape_late_energy_fraction": 0.25,
"shape_max_step_drop": 0.0,
"shape_max_step_rise": 0.0,
"shape_mid_trough_depth": 0.0,
"shape_peak_density": 0.0,
"shape_plateau_ratio": 1.0,
"shape_tail_slope": 0.0,
"trailing_idle_ratio": 0.0,
"trimmed_duration_log_ratio": 0.0
}
}
],
"kind": "standardized_logistic",
"model": "hybrid_curve_quality"
}
@@ -0,0 +1,42 @@
[
{
"feature": "match_progress_top1",
"group": "live_match",
"runtime_source": "elapsed_seconds / top-1 candidate expected duration"
},
{
"feature": "top1_distance",
"group": "live_match",
"runtime_source": "blended RMSE+DTW shape distance to the top-1 candidate prefix"
},
{
"feature": "margin",
"group": "live_match",
"runtime_source": "top-2 distance minus top-1 distance (decision confidence)"
},
{
"feature": "distance_ratio",
"group": "live_match",
"runtime_source": "top-1 distance / top-2 distance"
},
{
"feature": "candidate_count_log",
"group": "live_match",
"runtime_source": "log1p(number of candidate profiles on the device)"
},
{
"feature": "prefix_active_fraction",
"group": "live_match",
"runtime_source": "fraction of the observed prefix above the active threshold"
},
{
"feature": "duration_ratio_top1",
"group": "live_match",
"runtime_source": "elapsed / top-1 expected duration (clipped)"
},
{
"feature": "elapsed_log",
"group": "live_match",
"runtime_source": "log1p(elapsed seconds since cycle start)"
}
]
@@ -0,0 +1,127 @@
# WashData - Home Assistant integration for appliance cycle monitoring via smart plugs.
# Copyright (C) 2026 Lukas Bandura
# SPDX-License-Identifier: AGPL-3.0-or-later
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""Auto-generated by ml_washdata/wash_ml/promotion.py. Do not edit by hand.
Embedded WashData model: 'live_match_commit' (target: 'match_top1_correct').
Kind: standardized logistic regression. Runtime dependency: NumPy only.
Regenerate with ``./ml.sh experiment`` in the ml_washdata lab and copy the new
file. Determinism check at generation time: max_abs_score_diff=1.6467e-08
over 2392 rows.
Usage in the integration::
from .live_match_commit_model import score, predict, FEATURE_COLUMNS
features = build_runtime_features(...) # must populate FEATURE_COLUMNS
is_positive = predict(features)
"""
from __future__ import annotations
import base64
import gzip
import json
from typing import Mapping
import numpy as np
MODEL_NAME = 'live_match_commit'
MODEL_TARGET = 'match_top1_correct'
MODEL_KIND = 'standardized_logistic'
TARGET_UNITS = ''
THRESHOLD = 0.371786
FEATURE_COLUMNS = [
'match_progress_top1',
'top1_distance',
'margin',
'distance_ratio',
'candidate_count_log',
'prefix_active_fraction',
'duration_ratio_top1',
'elapsed_log',
]
# Provenance (metrics at training time):
MODEL_METRICS = json.loads("""{
"owner_holdout": {
"accuracy": 0.771789,
"balanced_accuracy": 0.741539,
"f1": 0.836751,
"fn": 120,
"fp": 79,
"positive_rate": 0.675459,
"precision": 0.865874,
"problem_recall": 0.809524,
"rows": 872,
"specificity": 0.673554,
"tn": 163,
"tp": 510
}
}""")
_MODEL_BLOB = (
'H4sIAAAAAAACA1VU246kNhD9FYunRKGJDb7BKpGi0UaKtJONktmn1Qq5wXRbAxgZdzqb1fx7jmG6NXmiqMupOnXxt+zozJo1tNCM'
'aVELnWednaMNWfOZFoJyJcqS57Rglaoha4gVo5UQsoQoGOe6ZiJnRc2kkoolX64oE7pi+RsEVWheUa7FF2TwdtjwS0UlFzo5Ms2o'
'oila8lJQIWV+YEXJeF1zKfIDUJlAjUwmWai6qsSW7I6R1JwBRaqUI1gTbd+amDVZSUt5oPJQ1k8lbXjdUPEDpQ2lWZ4N8LsE23Z+'
'vEwzevE5m0zszu0S/CnYdW2jXxgc06ft3RrN3Fn8Tyac3AzhpmuDic5D0Zm5dz3SA/Qyx3b0J2iXYAf3T2u66P627RCS4Lf4yxY4'
'7/G3dHY0ywoCKRh8Ti4CbZpc4iOpMFqW8Hp2cw9FKqA3oXf/7hGoyHWpRhuD60DqW+avsw3t2Y+9v8SkMF2HxN3XNH2lmNJ1nh3N'
'mJigbW+N6HwF48C2RamkEgx/c9awkkJYskbBvPjVbdRAwyZP+HFRb8Q7tyauKVwKrXhS+uNopxY2M46bhdYCi5IFf0XBWpV5ti6I'
'HFzn4tcdEHsHj5hSywoCUgtGX17ybDYTkmZjKmCf32u3YPLRbnP9AONhM5LdeOj8PLjegnJD/vgOnT8wcp8ecSuJZwvfgCoj2RbC'
'TN8XwHzY4kn0xJCU82YkO/z1bGeyItCSn38CCPYodf4d8QAMV7da8mztsnu7+USQk6xXd481pHfDYANu8U1BR4u67UpuZcdUylMw'
'brY98TO5WvNMzCX6w2iOdlzfkYD9c5Ml02WNifRyAczDx8fH357aX9//8vTpz/ftw8cPnx5//4sMwU8b4Y3QTiSYGSt2KtIKYm8Q'
'3t7eB1rQu27FENF+llSvcjpvXWnNucCVUq1Lzfj2JJSqqvFspJeE0ppj99KbwSvFZbVdNB4UhcD8fwh4B7A5FfuSMpztZDDtq1nP'
'aIwp0P3Jp3OffG/HH7dzxXnadCz7NmzX+zrJu7W9zC5iNbKkuQ0pMavSPciX/wDk5KXIHwUAAA=='
)
_MODEL_CACHE: dict | None = None
def _load() -> dict:
global _MODEL_CACHE
if _MODEL_CACHE is None:
payload = gzip.decompress(base64.b64decode(_MODEL_BLOB.encode("ascii")))
spec = json.loads(payload.decode("utf-8"))
_MODEL_CACHE = {
"center": np.asarray(spec["center"], dtype=float),
"scale": np.asarray(spec["scale"], dtype=float),
"coef": np.asarray(spec["coef"], dtype=float),
"bias": float(spec["bias"]),
"threshold": float(spec["threshold"]),
"output_center": float(spec.get("output_center") or 0.0),
"output_scale": float(spec.get("output_scale") if spec.get("output_scale") is not None else 1.0),
"feature_columns": list(spec["feature_columns"]),
}
return _MODEL_CACHE
def score(features: Mapping[str, float]) -> float:
"""Return the model probability in [0, 1] for one feature mapping."""
model = _load()
vector = np.array(
[float(features.get(column) or 0.0) for column in model["feature_columns"]],
dtype=float,
)
scaled = (vector - model["center"]) / model["scale"]
logit = float(scaled @ model["coef"] + model["bias"])
logit = max(-60.0, min(60.0, logit))
return 1.0 / (1.0 + np.exp(-logit))
def predict(features: Mapping[str, float]) -> bool:
"""True when the example crosses the embedded decision threshold."""
return score(features) >= _load()["threshold"]
@@ -0,0 +1,110 @@
{
"cases": [
{
"expected_score": 0.0409139,
"features": {
"candidate_count_log": 2.30258509,
"distance_ratio": 0.99324714,
"duration_ratio_top1": 0.0713484,
"elapsed_log": 6.43615037,
"margin": 0.0003374,
"match_progress_top1": 0.0713484,
"prefix_active_fraction": 1.0,
"top1_distance": 0.04962666
}
},
{
"expected_score": 0.23638102,
"features": {
"candidate_count_log": 2.30258509,
"distance_ratio": 0.9767654,
"duration_ratio_top1": 0.77657169,
"elapsed_log": 9.10509096,
"margin": 0.00796904,
"match_progress_top1": 0.77657169,
"prefix_active_fraction": 0.28125,
"top1_distance": 0.33501266
}
},
{
"expected_score": 0.36610898,
"features": {
"candidate_count_log": 1.79175947,
"distance_ratio": 0.98383705,
"duration_ratio_top1": 0.67719326,
"elapsed_log": 8.00670085,
"margin": 0.00441228,
"match_progress_top1": 0.67719326,
"prefix_active_fraction": 0.328125,
"top1_distance": 0.26857515
}
},
{
"expected_score": 0.50799834,
"features": {
"candidate_count_log": 2.30258509,
"distance_ratio": 0.56963091,
"duration_ratio_top1": 0.56899238,
"elapsed_log": 8.35501068,
"margin": 0.12556275,
"match_progress_top1": 0.56899238,
"prefix_active_fraction": 0.40625,
"top1_distance": 0.16619322
}
},
{
"expected_score": 0.67345544,
"features": {
"candidate_count_log": 2.30258509,
"distance_ratio": 0.61349302,
"duration_ratio_top1": 0.99052133,
"elapsed_log": 8.74369111,
"margin": 0.12364826,
"match_progress_top1": 0.99052133,
"prefix_active_fraction": 0.296875,
"top1_distance": 0.19626385
}
},
{
"expected_score": 0.88134857,
"features": {
"candidate_count_log": 1.94591015,
"distance_ratio": 0.37757139,
"duration_ratio_top1": 1.01608929,
"elapsed_log": 6.5539334,
"margin": 0.1729941,
"match_progress_top1": 1.01608929,
"prefix_active_fraction": 0.859375,
"top1_distance": 0.10493994
}
},
{
"expected_score": 0.95653616,
"features": {
"candidate_count_log": 1.09861229,
"distance_ratio": 0.21720322,
"duration_ratio_top1": 0.30021136,
"elapsed_log": 7.89561849,
"margin": 0.51761286,
"match_progress_top1": 0.30021136,
"prefix_active_fraction": 0.234375,
"top1_distance": 0.14362244
}
},
{
"expected_score": 0.99762013,
"features": {
"candidate_count_log": 1.09861229,
"distance_ratio": 0.02465625,
"duration_ratio_top1": 1.00014576,
"elapsed_log": 9.5269014,
"margin": 1.86460354,
"match_progress_top1": 1.00014576,
"prefix_active_fraction": 0.09375,
"top1_distance": 0.04713633
}
}
],
"kind": "standardized_logistic",
"model": "live_match_commit"
}
@@ -0,0 +1,261 @@
# WashData - Home Assistant integration for appliance cycle monitoring via smart plugs.
# Copyright (C) 2026 Lukas Bandura
# SPDX-License-Identifier: AGPL-3.0-or-later
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""On-device tuning of the matcher's scoring weights (Stage 4/5, opt-in).
Mirrors the offline ``devtools/dtw_ab_eval.py`` methodology but as a shippable,
NumPy-only, executor-safe pure function: it does leave-one-out matching over the
device's own labelled cycles, sweeps a small grid of the highest-impact scoring
weights (corr/MAE split, duration agreement weight, energy agreement weight, and
DTW ensemble weight independently), and - only if a candidate beats the shipped
defaults on a HELD-OUT split by a margin - returns a per-device config override. The caller persists it; the matcher reads it live
and falls back to the const defaults otherwise.
Discipline (same as model promotion): tune on a train split, gate on a held-out
split, require a margin, cap the grid to bounded scoring weights (never
structural behaviour). This guards against over-fitting the small, partly
manually-labelled per-user cycle set.
"""
from __future__ import annotations
from typing import Any
import numpy as np
from .. import analysis
_RESAMPLE_L = 150
def _powers(cycle: dict[str, Any]) -> list[float]:
pd = cycle.get("power_data") or []
out: list[float] = []
for p in pd:
try:
out.append(float(p[1]))
except (TypeError, ValueError, IndexError):
pass
return out
def _resample(vals: list[float], n: int) -> np.ndarray:
a = np.asarray(vals, dtype=float)
if a.size == 0:
return np.zeros(n)
if a.size == n:
return a
return np.interp(np.linspace(0, 1, n), np.linspace(0, 1, a.size), a)
def _prep(cycles: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]:
"""Group labelled cycles by profile, caching powers/duration/resampled curve."""
by_profile: dict[str, list[dict[str, Any]]] = {}
for c in cycles:
name = c.get("profile_name")
pw = _powers(c)
if not name or len(pw) < 4:
continue
try:
dur = float(c.get("duration"))
except (TypeError, ValueError):
dur = 0.0
if dur <= 0:
# No reliable wall-clock duration: skip rather than fabricate one from the
# sample count (len(pw)), which distorts duration scoring on devices that
# sample every 30-60 s. Real cycles always carry a 'duration', so this only
# drops degenerate entries.
continue
by_profile.setdefault(name, []).append(
{"pw": pw, "dur": dur, "rs": _resample(pw, _RESAMPLE_L)}
)
return by_profile
def _snaps(by_profile: dict[str, list[dict]], exclude: tuple[str, int] | None) -> list[dict[str, Any]]:
snaps = []
for name, items in by_profile.items():
curves, durs = [], []
for idx, it in enumerate(items):
if exclude is not None and (name, idx) == exclude:
continue
curves.append(it["rs"])
durs.append(it["dur"])
if curves:
snaps.append({
"name": name,
"avg_duration": float(np.mean(durs)),
"sample_power": np.mean(np.array(curves), axis=0).tolist(),
})
return snaps
def _top1(by_profile: dict[str, list[dict]], targets: list[tuple[str, int]], cfg: dict[str, Any]) -> float:
"""Fraction of the given (profile, idx) targets whose true profile ranks #1
under leave-one-out matching with the given config."""
if not targets:
return 0.0
correct = 0
total = 0
for name, idx in targets:
it = by_profile[name][idx]
snaps = _snaps(by_profile, exclude=(name, idx))
if len(snaps) < 2:
continue
cands = analysis.compute_matches_worker(it["pw"], it["dur"], snaps, cfg)
total += 1
if cands and cands[0]["name"] == name:
correct += 1
return correct / total if total else 0.0
_BASE_CFG = {"min_duration_ratio": 0.10, "max_duration_ratio": 1.5}
#: Bounded scoring weights the tuner may promote. All live in [0, 1], so a tuned
#: config can only shift emphasis (shape vs level vs energy, and how much the DTW
#: ensemble leans on the derivative/DDTW component) - never structural behaviour.
OVERRIDE_KEYS = ("corr_weight", "duration_weight", "energy_weight", "dtw_ensemble_w")
def _grid() -> list[dict[str, Any]]:
"""Small, high-impact grid over four bounded scoring weights.
Axes: corr/MAE split × duration agreement weight × energy agreement weight
× DTW ensemble weight. The duration and energy axes are now independent so
the tuner can find asymmetric configurations (e.g. a device with highly
variable energy but stable duration benefits from a low energy_weight and a
high duration_weight). All values are bounded scoring weights (see
OVERRIDE_KEYS) so a promoted config can never change structural behaviour.
Grid size: 4 × 2 × 2 × 3 = 48 configurations (was 4 × 2 × 3 = 24).
"""
out = []
for cw in (0.40, 0.45, 0.50, 0.60):
for dur_w in (0.15, 0.22):
for en_w in (0.15, 0.22):
for ew in (0.55, 0.70, 0.85):
out.append({
"corr_weight": cw,
"duration_weight": dur_w,
"energy_weight": en_w,
"dtw_ensemble_w": ew,
})
return out
def tune_matching_config(
cycles: list[dict[str, Any]],
*,
min_cycles: int = 25,
# Kept intentionally low so per-device tuning becomes useful early; the noise
# a small sample would introduce is controlled by the multi-split majority gate
# below (a lucky single split can't promote), not by a large ``min_targets``.
min_targets: int = 12,
margin: float = 0.03,
seed: int = 0,
) -> dict[str, Any]:
"""Leave-one-out per-device tuning of matcher scoring weights.
Methodology (no target leakage between selection and gating):
1. Partition the device's labelled cycles ONCE into a *search* pool and an
untouched *holdout* pool; no target is ever used for both.
2. **Select** the candidate config as the grid entry with the best
leave-one-out top-1 on the SEARCH pool only. (Reference snapshots are
built from all cycles — as in production, where a query is matched
against aggregates of the full profile library; only the *query* targets
are partitioned.)
3. **Gate** the fixed candidate on the HOLDOUT pool: it must beat the
shipped defaults by at least ``margin`` on a MAJORITY of reshuffled
holdout subsamples (a variance check that rejects a lucky single split)
AND on the holdout mean. ``min_targets`` is kept intentionally low so
per-device tuning becomes useful early; the majority gate — not a large
sample — controls the noise.
Returns a status dict; ``promoted`` is True only when both holdout gates pass.
When promoted, ``config`` holds the override to persist (bounded scoring
weights only — never structural matching behaviour). Never raises for data
reasons; returns {"promoted": False, "reason": ...}.
"""
by_profile = _prep(cycles)
multi = {n: items for n, items in by_profile.items() if len(items) >= 2}
n_cycles = sum(len(v) for v in by_profile.values())
if len(multi) < 2 or n_cycles < min_cycles:
return {"promoted": False, "reason": "insufficient data", "n_cycles": n_cycles, "n_profiles": len(by_profile)}
# Partition targets ONCE, up front, into a search pool (used to pick the
# candidate config) and an untouched holdout pool (used only to gate it). No
# target is ever used for both selection and gating -> no target leakage.
rng = np.random.default_rng(seed)
targets = [(n, i) for n, items in multi.items() for i in range(len(items))]
rng.shuffle(targets)
if len(targets) < min_targets:
return {"promoted": False, "reason": "too few targets", "n_targets": len(targets)}
cut = max(1, len(targets) // 2)
search_pool, holdout_pool = targets[:cut], targets[cut:]
if not holdout_pool:
return {"promoted": False, "reason": "too few targets", "n_targets": len(targets)}
base = {**_BASE_CFG}
# Candidate: the grid config with the best top-1 on the SEARCH pool only.
best_search = _top1(by_profile, search_pool, base)
best_cfg = base
for extra in _grid():
acc = _top1(by_profile, search_pool, {**base, **extra})
if acc > best_search:
best_search, best_cfg = acc, {**base, **extra}
override = {k: best_cfg[k] for k in OVERRIDE_KEYS if k in best_cfg}
# Gate the FIXED candidate on the held-out pool: require it to beat the defaults
# by ``margin`` on a MAJORITY of reshuffled subsamples of the holdout (variance
# check), rejecting a lucky single split while keeping min_targets low.
n_splits, min_wins = 5, 4
base_tests: list[float] = []
tuned_tests: list[float] = []
wins = 0
for k in range(n_splits):
r = np.random.default_rng(seed + 1 + k)
pool = list(holdout_pool)
r.shuffle(pool)
held = pool[: max(1, len(pool) // 2)]
bt = _top1(by_profile, held, base)
tt = _top1(by_profile, held, best_cfg)
base_tests.append(bt)
tuned_tests.append(tt)
if tt - bt >= margin:
wins += 1
mean_base = float(np.mean(base_tests)) if base_tests else 0.0
mean_tuned = float(np.mean(tuned_tests)) if tuned_tests else 0.0
has_override = bool(override)
enough_wins = wins >= min_wins
enough_margin = (mean_tuned - mean_base) >= margin
promoted = has_override and enough_wins and enough_margin
if promoted:
reason = f"beat baseline on {wins}/{n_splits} held-out subsamples"
elif not has_override:
reason = "defaults already optimal (no override)"
elif not enough_wins:
reason = f"only {wins}/{n_splits} held-out subsamples beat baseline by margin"
else:
reason = f"mean held-out gain {mean_tuned - mean_base:+.3f} below margin {margin}"
return {
"promoted": promoted,
"config": override if promoted else None,
"baseline_test_top1": round(mean_base, 3),
"tuned_test_top1": round(mean_tuned, 3),
"train_top1": round(best_search, 3),
"holdout_wins": wins,
"holdout_splits": n_splits,
"n_targets": len(targets),
"reason": reason,
}
@@ -0,0 +1,96 @@
{
"generated_at": "2026-07-01T06:27:13+00:00",
"models": [
{
"created_at": "2026-06-29T20:49:06+00:00",
"git_commit": "605a862",
"kind": "standardized_logistic",
"metrics": {
"owner_holdout": {
"accuracy": 0.865889,
"balanced_accuracy": 0.85704,
"f1": 0.760417,
"fn": 14,
"fp": 32,
"positive_rate": 0.306122,
"precision": 0.695238,
"problem_recall": 0.83908,
"rows": 343,
"specificity": 0.875,
"tn": 224,
"tp": 73
},
"premature_stop_rate": 0.125
},
"module": "cycle_end_detector_model.py",
"name": "cycle_end_detector",
"target": "cycle_truly_ended",
"target_units": ""
},
{
"created_at": "2026-06-29T20:48:41+00:00",
"git_commit": "605a862",
"kind": "standardized_logistic",
"metrics": {
"owner_holdout": {
"accuracy": 0.872,
"balanced_accuracy": 0.89997,
"f1": 0.898734,
"fn": 15,
"fp": 1,
"positive_rate": 0.576,
"precision": 0.986111,
"problem_recall": 0.825581,
"rows": 125,
"specificity": 0.974359,
"tn": 38,
"tp": 71
},
"synthetic_all": {
"accuracy": 1.0,
"balanced_accuracy": 0.5,
"f1": 1.0,
"fn": 0,
"fp": 0,
"positive_rate": 1.0,
"precision": 1.0,
"problem_recall": 1.0,
"rows": 1255,
"specificity": 0.0,
"tn": 0,
"tp": 1255
}
},
"module": "hybrid_curve_quality_model.py",
"name": "hybrid_curve_quality",
"target": "problem_cycle",
"target_units": ""
},
{
"created_at": "2026-06-29T20:49:05+00:00",
"git_commit": "605a862",
"kind": "standardized_logistic",
"metrics": {
"owner_holdout": {
"accuracy": 0.771789,
"balanced_accuracy": 0.741539,
"f1": 0.836751,
"fn": 120,
"fp": 79,
"positive_rate": 0.675459,
"precision": 0.865874,
"problem_recall": 0.809524,
"rows": 872,
"specificity": 0.673554,
"tn": 163,
"tp": 510
}
},
"module": "live_match_commit_model.py",
"name": "live_match_commit",
"target": "match_top1_correct",
"target_units": ""
}
],
"source": "ml_washdata/output/promoted"
}
+440
View File
@@ -0,0 +1,440 @@
# WashData - Home Assistant integration for appliance cycle monitoring via smart plugs.
# Copyright (C) 2026 Lukas Bandura
# SPDX-License-Identifier: AGPL-3.0-or-later
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""On-device, NumPy-only model training for WashData (Stage 4).
This is the runtime counterpart of the offline lab's promotion pipeline. All
three embedded models are ``standardized_logistic`` heads - a mean/std scaler
plus a weight vector, bias, and decision threshold - which the lab fits with a
short pure-NumPy gradient descent (``wash_ml/end_detection.py::_fit_logistic``).
This module reproduces that fit and the exact scoring math the embedded
``*_model.py`` modules use, so a model trained here on the user's own cycles is
byte-compatible with the shipped baseline and can be scored identically.
No new dependencies: NumPy only. Nothing here runs unless the caller (behind the
``ENABLE_ML_TRAINING`` flag) invokes it.
"""
from __future__ import annotations
from typing import Any, Mapping, Sequence
import numpy as np
# Matches wash_ml/promotion.py so a trained spec is interchangeable with the
# shipped bundles and could be rendered into a *_model.py if ever needed.
PROMOTION_SCHEMA = "washdata.promoted_model/1"
def _sigmoid(values: np.ndarray) -> np.ndarray:
clipped = np.clip(values, -60.0, 60.0)
return 1.0 / (1.0 + np.exp(-clipped))
def fit_logistic(
matrix: np.ndarray,
labels: np.ndarray,
*,
l2: float = 0.01,
learning_rate: float = 0.2,
iterations: int = 4000,
) -> dict[str, np.ndarray | float]:
"""Fit a class-balanced, L2-regularised logistic head with NumPy GD.
Identical in shape to the lab's ``_fit_logistic``: mean/std standardisation,
inverse-frequency class weights, and a fixed-step gradient descent. Returns
``{center, scale, coef, bias}``.
"""
matrix = np.asarray(matrix, dtype=float)
labels = np.asarray(labels, dtype=float)
if matrix.ndim != 2 or matrix.shape[0] == 0:
raise ValueError("matrix must be a non-empty 2D array")
if labels.shape[0] != matrix.shape[0]:
raise ValueError("labels/matrix row mismatch")
if len(np.unique(labels)) < 2:
raise ValueError(
f"fit_logistic requires both positive and negative examples; "
f"got labels: {np.unique(labels)}"
)
center = np.mean(matrix, axis=0)
scale = np.std(matrix, axis=0)
scale = np.where(scale <= 1e-8, 1.0, scale)
scaled = (matrix - center) / scale
weight = np.ones(labels.size, dtype=float)
for label_value in (0.0, 1.0):
mask = labels == label_value
count = float(np.sum(mask))
if count > 0:
weight[mask] = labels.size / (2.0 * count)
normalized = weight / (float(np.sum(weight)) or 1.0)
coef = np.zeros(matrix.shape[1], dtype=float)
bias = 0.0
for _ in range(iterations):
predictions = _sigmoid(scaled @ coef + bias)
residual = (predictions - labels) * normalized
coef -= learning_rate * (scaled.T @ residual + l2 * coef)
bias -= learning_rate * float(np.sum(residual))
return {"center": center, "scale": scale, "coef": coef, "bias": float(bias)}
def _safe_ratio(numerator: float, denominator: float) -> float:
return float(numerator) / float(denominator) if denominator else 0.0
def binary_metrics(labels: np.ndarray, scores: np.ndarray, threshold: float) -> dict[str, Any]:
"""Confusion-matrix metrics at a threshold (pure NumPy)."""
labels = np.asarray(labels, dtype=float)
scores = np.asarray(scores, dtype=float)
if labels.size == 0:
return {}
predictions = (scores >= threshold).astype(int)
tp = int(np.sum((labels == 1) & (predictions == 1)))
fp = int(np.sum((labels == 0) & (predictions == 1)))
tn = int(np.sum((labels == 0) & (predictions == 0)))
fn = int(np.sum((labels == 1) & (predictions == 0)))
precision = _safe_ratio(tp, tp + fp)
recall = _safe_ratio(tp, tp + fn)
specificity = _safe_ratio(tn, tn + fp)
f1 = _safe_ratio(2.0 * precision * recall, precision + recall)
accuracy = _safe_ratio(tp + tn, labels.size)
positive_rate = _safe_ratio(int(np.sum(labels == 1)), labels.size)
# Key names mirror the shipped MODEL_METRICS schema (see *_model.py):
# ``problem_recall`` (recall of the positive/"problem" class) and
# ``positive_rate`` (base rate of positives), so on-device-trained metrics
# are schema-identical to the embedded baselines they are compared against.
return {
"rows": int(labels.size),
"tp": tp, "fp": fp, "tn": tn, "fn": fn,
"precision": round(precision, 6),
"problem_recall": round(recall, 6),
"positive_rate": round(positive_rate, 6),
"specificity": round(specificity, 6),
"balanced_accuracy": round((recall + specificity) / 2.0, 6),
"f1": round(f1, 6),
"accuracy": round(accuracy, 6),
}
def auc(labels: np.ndarray, scores: np.ndarray) -> float:
"""Rank-based ROC AUC (Mann-Whitney U). 0.5 when one class is absent."""
labels = np.asarray(labels, dtype=float)
scores = np.asarray(scores, dtype=float)
finite_mask = np.isfinite(scores)
scores = scores[finite_mask]
labels = labels[finite_mask]
if len(scores) == 0:
return 0.5
pos = scores[labels == 1]
neg = scores[labels == 0]
if pos.size == 0 or neg.size == 0:
return 0.5
order = np.argsort(scores, kind="mergesort")
ranks = np.empty(scores.size, dtype=float)
ranks[order] = np.arange(1, scores.size + 1, dtype=float)
# Average ranks over ties so AUC is exact for discrete scores.
_assign_tie_ranks(scores, ranks, order)
rank_sum_pos = float(np.sum(ranks[labels == 1]))
n_pos = float(pos.size)
n_neg = float(neg.size)
u = rank_sum_pos - n_pos * (n_pos + 1.0) / 2.0
return float(u / (n_pos * n_neg))
def _assign_tie_ranks(scores: np.ndarray, ranks: np.ndarray, order: np.ndarray) -> None:
sorted_scores = scores[order]
i = 0
n = scores.size
while i < n:
j = i
while j + 1 < n and sorted_scores[j + 1] == sorted_scores[i]:
j += 1
if j > i:
avg = (ranks[order[i]] + ranks[order[j]]) / 2.0
for k in range(i, j + 1):
ranks[order[k]] = avg
i = j + 1
def select_threshold(
labels: np.ndarray,
scores: np.ndarray,
*,
default: float = 0.5,
) -> float:
"""Pick the threshold maximising balanced accuracy (model-agnostic).
Ties break toward the ``default`` so the operating point stays stable when
the data does not clearly prefer one cut.
"""
labels = np.asarray(labels, dtype=float)
scores = np.asarray(scores, dtype=float)
if scores.size == 0:
return default
candidates = np.unique(
np.concatenate([
np.quantile(scores, np.linspace(0.05, 0.95, 37)),
np.linspace(0.1, 0.95, 86),
])
)
candidates = candidates[(candidates >= 0.05) & (candidates <= 0.999)]
if candidates.size == 0:
pos_scores = scores[labels == 1]
return float(np.min(pos_scores)) if len(pos_scores) > 0 else default
best_key: tuple[float, float] | None = None
best_threshold = default
best_ba = 0.5
for threshold in candidates:
m = binary_metrics(labels, scores, float(threshold))
bal = float(m.get("balanced_accuracy") or 0.0)
key = (bal, -abs(float(threshold) - default))
if best_key is None or key > best_key:
best_key = key
best_threshold = float(threshold)
best_ba = bal
if best_ba == 0.5:
pos_scores = scores[labels == 1]
return float(np.min(pos_scores)) if len(pos_scores) > 0 else default
return round(best_threshold, 6)
def build_spec(
*,
name: str,
target: str,
feature_columns: Sequence[str],
fit: Mapping[str, Any],
threshold: float,
metrics: Mapping[str, Any] | None = None,
trained_at: str = "",
cycle_count: int = 0,
) -> dict[str, Any]:
"""Assemble a runtime model spec (same schema as the shipped bundles).
The returned dict is JSON-serialisable and can be scored by
:func:`score_spec` with math identical to the embedded ``*_model.py``.
"""
return {
"schema": PROMOTION_SCHEMA,
"name": name,
"kind": "standardized_logistic",
"target": target,
"target_units": "",
"feature_columns": list(feature_columns),
"center": [round(float(v), 8) for v in np.asarray(fit["center"], dtype=float)],
"scale": [round(float(v), 8) for v in np.asarray(fit["scale"], dtype=float)],
"coef": [round(float(v), 8) for v in np.asarray(fit["coef"], dtype=float)],
"bias": round(float(fit["bias"]), 8),
"threshold": round(float(threshold), 8),
"output_center": 0.0,
"output_scale": 1.0,
"metrics": dict(metrics or {}),
"notes": ["Trained on-device from the user's own labelled cycles."],
"created_at": trained_at,
"cycle_count": int(cycle_count),
"source": "on_device",
}
def score_matrix_spec(spec: Mapping[str, Any], matrix: np.ndarray) -> np.ndarray:
"""Pure-NumPy probabilities for a (rows, features) matrix from a spec."""
matrix = np.asarray(matrix, dtype=float)
if matrix.size == 0:
return np.empty(0, dtype=float)
center = np.asarray(spec["center"], dtype=float)
scale = np.asarray(spec["scale"], dtype=float)
coef = np.asarray(spec["coef"], dtype=float)
raw = ((matrix - center) / scale) @ coef + float(spec["bias"])
return _sigmoid(raw)
def score_spec(spec: Mapping[str, Any], features: Mapping[str, float]) -> float:
"""Pure-NumPy probability for one feature mapping.
Byte-identical to the embedded ``score()`` in ``*_model.py`` for *complete*
feature mappings (the normal case: the extractors in ``feature_extraction``
always populate every ``FEATURE_COLUMNS`` key). The two intentionally differ
only on the defensive missing-key fallback: this fills a missing feature with
the training center (standardises to 0.0 = neutral, avoiding 8+ SD corruption
of inference), whereas the embedded ``score()`` fills raw 0.0. That path is
not exercised by the parity fixtures and is not reachable in practice.
"""
columns = spec["feature_columns"]
center = np.asarray(spec["center"], dtype=float)
row = []
for i, col in enumerate(columns):
val = features.get(col)
row.append(float(center[i]) if val is None else float(val))
vector = np.array(row, dtype=float)
return float(score_matrix_spec(spec, vector.reshape(1, -1))[0])
# ---------------------------------------------------------------------------
# Regression head (standardized_linear) - remaining-time / progress regressor.
#
# The three classifier heads above are logistic. The remaining-time model is a
# ridge-regularised linear regressor over standardised features with a
# standardised target; prediction un-standardises back to target units using the
# spec's ``output_center``/``output_scale``. Same NumPy-only, JSON-serialisable
# spec schema as :func:`build_spec` so it is stored/loaded identically, but it is
# scored with :func:`predict_matrix_spec` (no sigmoid) rather than ``score_spec``.
# ---------------------------------------------------------------------------
def fit_ridge(
matrix: np.ndarray,
labels: np.ndarray,
*,
alpha: float = 1.0,
) -> dict[str, np.ndarray | float]:
"""Fit a standardised ridge-regression head via NumPy normal equations.
Standardises features (mean/std) and the target, solves
``(ZᵀZ + αI) w = Zᵀ y_std`` in closed form, and returns
``{center, scale, coef, bias, y_center, y_scale}``. Because both the
standardised features and the centred target are zero-mean, the intercept in
standardised space is 0. Prediction is
``((x - center)/scale) @ coef * y_scale + y_center``.
"""
matrix = np.asarray(matrix, dtype=float)
labels = np.asarray(labels, dtype=float)
if matrix.ndim != 2 or matrix.shape[0] == 0:
raise ValueError("matrix must be a non-empty 2D array")
if labels.shape[0] != matrix.shape[0]:
raise ValueError("labels/matrix row mismatch")
if np.std(labels) < 1e-8:
raise ValueError(
f"fit_ridge requires non-constant targets; "
f"all labels are approximately {labels[0]:.4f}"
)
center = np.mean(matrix, axis=0)
scale = np.std(matrix, axis=0)
scale = np.where(scale <= 1e-8, 1.0, scale)
scaled = (matrix - center) / scale
y_center = float(np.mean(labels))
y_scale = float(np.std(labels))
if y_scale <= 1e-9:
y_scale = 1.0
y_std = (labels - y_center) / y_scale
n_features = scaled.shape[1]
gram = scaled.T @ scaled + float(alpha) * np.eye(n_features)
rhs = scaled.T @ y_std
try:
coef = np.linalg.solve(gram, rhs)
except np.linalg.LinAlgError:
coef = np.linalg.lstsq(gram, rhs, rcond=None)[0]
return {
"center": center,
"scale": scale,
"coef": coef,
"bias": 0.0,
"y_center": y_center,
"y_scale": y_scale,
}
def regression_metrics(labels: np.ndarray, predictions: np.ndarray) -> dict[str, Any]:
"""MAE / RMSE / R² for a regression fit (pure NumPy)."""
labels = np.asarray(labels, dtype=float)
predictions = np.asarray(predictions, dtype=float)
if labels.size == 0:
return {}
err = predictions - labels
mae = float(np.mean(np.abs(err)))
rmse = float(np.sqrt(np.mean(err ** 2)))
ss_res = float(np.sum(err ** 2))
ss_tot = float(np.sum((labels - float(np.mean(labels))) ** 2))
r2 = float(1.0 - ss_res / ss_tot) if ss_tot > 1e-12 else 0.0
return {
"rows": int(labels.size),
"mae": round(mae, 6),
"rmse": round(rmse, 6),
"r2": round(r2, 6),
}
def predict_matrix_spec(spec: Mapping[str, Any], matrix: np.ndarray) -> np.ndarray:
"""Regression predictions (target units) for a (rows, features) matrix."""
matrix = np.asarray(matrix, dtype=float)
if matrix.size == 0:
return np.empty(0, dtype=float)
center = np.asarray(spec["center"], dtype=float)
scale = np.asarray(spec["scale"], dtype=float)
coef = np.asarray(spec["coef"], dtype=float)
y_std = ((matrix - center) / scale) @ coef + float(spec.get("bias", 0.0))
y_center = float(spec.get("output_center", 0.0))
y_scale = float(spec.get("output_scale", 1.0))
return y_std * y_scale + y_center
def predict_value_spec(spec: Mapping[str, Any], features: Mapping[str, float]) -> float:
"""Un-standardised regression output for one feature mapping.
Missing feature keys are filled with the training center (which standardises
to 0.0 = neutral), not raw 0.0, to avoid 8+ SD corruption of inference.
"""
columns = spec["feature_columns"]
center = np.asarray(spec["center"], dtype=float)
row = []
for i, col in enumerate(columns):
val = features.get(col)
row.append(float(center[i]) if val is None else float(val))
vector = np.array(row, dtype=float)
return float(predict_matrix_spec(spec, vector.reshape(1, -1))[0])
def build_regression_spec(
*,
name: str,
target: str,
feature_columns: Sequence[str],
fit: Mapping[str, Any],
target_units: str = "",
metrics: Mapping[str, Any] | None = None,
trained_at: str = "",
cycle_count: int = 0,
) -> dict[str, Any]:
"""Assemble a ``standardized_linear`` regression spec (JSON-serialisable).
Scored by :func:`predict_matrix_spec` / :func:`predict_value_spec` with math
that mirrors :func:`fit_ridge`. ``threshold`` is retained (0.0) only so the
spec shape stays uniform with the classifier bundles.
"""
return {
"schema": PROMOTION_SCHEMA,
"name": name,
"kind": "standardized_linear",
"target": target,
"target_units": target_units,
"feature_columns": list(feature_columns),
"center": [round(float(v), 8) for v in np.asarray(fit["center"], dtype=float)],
"scale": [round(float(v), 8) for v in np.asarray(fit["scale"], dtype=float)],
"coef": [round(float(v), 8) for v in np.asarray(fit["coef"], dtype=float)],
"bias": round(float(fit.get("bias", 0.0)), 8),
"threshold": 0.0,
"output_center": round(float(fit["y_center"]), 8),
"output_scale": round(float(fit["y_scale"]), 8),
"metrics": dict(metrics or {}),
"notes": ["Trained on-device from the user's own labelled cycles."],
"created_at": trained_at,
"cycle_count": int(cycle_count),
"source": "on_device",
}
@@ -0,0 +1,814 @@
# WashData - Home Assistant integration for appliance cycle monitoring via smart plugs.
# Copyright (C) 2026 Lukas Bandura
# SPDX-License-Identifier: AGPL-3.0-or-later
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""On-device training orchestration (Stage 4, gated by ENABLE_ML_TRAINING).
Gathers the user's own labelled cycles, derives training labels from data the
integration already has, fits NumPy-only logistic heads with :mod:`.trainer`,
and promotes a retrained model over the shipped baseline only when it is at
least as good on a held-out split. Nothing here runs unless the training loop
(behind the feature flag + per-device opt-in) invokes it.
Label sources (no manual labelling required to start):
* end detector - from trace geometry: a completed cycle's final low-power
event is a true end (positive); earlier pauses that resumed are non-ends.
* quality model - from cycle status + optional ML-Lab review labels: clean
completed / "good" / "golden" -> not a problem; force_stopped / interrupted
/ "bad" / "unusable" -> a problem.
* live_match - from match-ranking-history snapshots: :func:`_live_match_dataset`
labels each snapshot 1/0 by comparing its top-1 candidate to the confirmed
profile (wired into :func:`train_from_cycles` via ``ranking_history``).
"""
from __future__ import annotations
import importlib
import logging
from typing import Any
import numpy as np
_LOGGER = logging.getLogger(__name__)
from ..const import (
DEFAULT_DEFER_FINISH_CONFIDENCE,
ML_MATCH_COMMIT_THRESHOLD,
ML_QUALITY_SUSPICIOUS_THRESHOLD,
ML_TRAINING_AUC_MARGIN,
ML_TRAINING_BACC_MARGIN,
ML_TRAINING_MIN_POSITIVES,
ML_TRAINING_MIN_REGRESSION_ROWS,
ML_TRAINING_REGRESSION_MARGIN,
)
from . import trainer as T
# Capability -> (embedded module name, target label). Mirrors engine._MODEL_MODULES.
# The target label MUST match each baseline module's MODEL_TARGET (and
# promoted_manifest.json) so a promoted on-device spec records the same target as the
# shipped baseline it replaces.
_CAPABILITIES = {
"end": ("cycle_end_detector_model", "cycle_truly_ended"),
"quality": ("hybrid_curve_quality_model", "problem_cycle"),
"live_match": ("live_match_commit_model", "match_top1_correct"),
}
# The FIXED probability cutoff each live consumer applies to this capability's
# score. AUC alone is calibration-blind, so on-device retraining must also not
# degrade balanced accuracy AT the operating point the model is actually used at
# (else a "better AUC" model can silently shift decision rates). See _train_capability.
_OPERATING_THRESHOLD = {
"end": DEFAULT_DEFER_FINISH_CONFIDENCE,
"quality": ML_QUALITY_SUSPICIOUS_THRESHOLD,
"live_match": ML_MATCH_COMMIT_THRESHOLD,
}
# Regression capabilities have no embedded baseline module - they are promoted
# only when they beat a naive analytic estimate on held-out data. capability ->
# (target label, target units).
_REGRESSION_CAPABILITIES = {
"remaining_time": ("progress_fraction", "fraction"),
"total_energy": ("energy_fraction", "fraction"),
}
# Elapsed fractions at which each clean cycle is cut to synthesize a training row.
_PROGRESS_CUT_FRACTIONS = (0.15, 0.30, 0.45, 0.60, 0.75, 0.90)
_ACTIVE_FLOOR_RATIO = 0.02
_MIN_ROWS = 40
def _read_points(cycle: dict[str, Any]) -> list[tuple[float, float]]:
"""Return power data as offset-seconds/watts pairs, handling str and datetime start_time."""
from ..profile_store import decompress_power_data # noqa: PLC0415
try:
return decompress_power_data(cycle)
except Exception: # noqa: BLE001
return []
def _matrix(rows: list[dict[str, float]], columns: list[str]) -> np.ndarray:
if not rows:
return np.empty((0, len(columns)), dtype=float)
return np.array(
[[float(r.get(col) or 0.0) for col in columns] for r in rows], dtype=float
)
def _end_dataset(
clean: list[dict[str, Any]],
expectations: dict[str, dict[str, float]],
stop_thr: float,
) -> tuple[np.ndarray, np.ndarray, list[str], np.ndarray]:
"""Positives = each completed clean cycle's final end; negatives = pauses that resumed.
Also returns a per-row ``groups`` array (source-cycle index) so the holdout split
keeps every row from a given cycle on the same side (this dataset emits 1+N rows
per cycle; row-level splitting would leak siblings across train/test — B5)."""
from .feature_extraction import END_FEATURE_COLUMNS, latest_end_event_features
rows: list[dict[str, float]] = []
labels: list[float] = []
groups: list[int] = []
for ci, c in enumerate(clean):
exp = expectations.get(c.get("profile_name"))
if not exp:
continue
points = _read_points(c)
if len(points) < 6:
continue
peak = max((p for _, p in points), default=0.0)
if peak <= 0:
continue
active_thr = max(stop_thr, _ACTIVE_FLOOR_RATIO * peak)
in_low = False
low_start = 0.0
for i, (t, p) in enumerate(points):
if not in_low and p < active_thr:
in_low = True
low_start = t
elif in_low and p >= active_thr:
if (points[i - 1][0] - low_start) >= 30.0:
feat = latest_end_event_features(points[:i], exp)
if feat is not None:
rows.append(feat)
labels.append(0.0) # resumed -> not the end
groups.append(ci)
in_low = False
feat_end = latest_end_event_features(points, exp)
if feat_end is not None:
rows.append(feat_end)
labels.append(1.0) # trace ends here -> true end
groups.append(ci)
return (_matrix(rows, list(END_FEATURE_COLUMNS)), np.array(labels, dtype=float),
list(END_FEATURE_COLUMNS), np.array(groups, dtype=int))
def _quality_label(cycle: dict[str, Any]) -> float | None:
"""1 = problem, 0 = clean, None = unknown (skip)."""
review = cycle.get("ml_review")
if isinstance(review, dict):
if review.get("golden"):
return 0.0 # pinned reference cycle -> definitely clean
q = review.get("quality")
if q in ("good", "golden"):
return 0.0
if q in ("bad", "unusable"):
return 1.0
status = cycle.get("status")
if status in ("force_stopped", "interrupted"):
return 1.0
if status == "completed":
return 0.0
return None
def _quality_dataset(
cycles: list[dict[str, Any]],
expectations: dict[str, dict[str, float]],
) -> tuple[np.ndarray, np.ndarray, list[str], np.ndarray]:
"""Uses ALL cycles (not clean-filtered) so mis-detected cycles are the positives.
Emits at most one row per cycle, so ``groups`` is unique-per-row (splitting by
group is equivalent to row-level here) — returned for a uniform split API."""
from .feature_extraction import QUALITY_FEATURE_COLUMNS, quality_features
rows: list[dict[str, float]] = []
labels: list[float] = []
groups: list[int] = []
for ci, c in enumerate(cycles):
exp = expectations.get(c.get("profile_name"))
if not exp:
continue
label = _quality_label(c)
if label is None:
continue
points = _read_points(c)
if len(points) < 6:
continue
raw_conf = c.get("match_confidence")
if isinstance(raw_conf, (int, float)) and not isinstance(raw_conf, bool) and raw_conf > 0:
conf = float(raw_conf)
proxy_dist, proxy_margin, proxy_fit = max(0.0, 1.0 - conf), conf, conf
else:
proxy_dist, proxy_margin, proxy_fit = 0.25, 0.30, 0.75
# Use the cycle's real detected-artifact count so the flag_pressure feature
# is not train-time-constant (which would zero its learned coefficient and
# blind the AUC gate to it). Mirrors inference in manager._compute_cycle_quality_score.
arts = c.get("artifacts")
flag_count = len(arts) if isinstance(arts, list) else 0
try:
feat = quality_features(
points, exp["duration"], exp["energy"], exp["peak"],
proxy_dist, proxy_margin, proxy_fit, flag_count,
)
except Exception: # pylint: disable=broad-exception-caught
continue
rows.append(feat)
labels.append(label)
groups.append(ci)
return (_matrix(rows, list(QUALITY_FEATURE_COLUMNS)), np.array(labels, dtype=float),
list(QUALITY_FEATURE_COLUMNS), np.array(groups, dtype=int))
def _live_match_dataset(
snapshots: list[dict[str, Any]],
) -> tuple[np.ndarray, np.ndarray, list[str], np.ndarray]:
"""Build a training matrix from accumulated match ranking snapshots.
Each snapshot was captured mid-cycle; at cycle end the confirmed profile
was back-filled as ``confirmed_label``. We label by whether the model's
top-1 candidate at recording time matched the final confirmed label:
1.0 = top-1 was correct (should commit), 0.0 = wrong (should not commit).
Snapshots without a confirmed label are skipped.
One cycle produces several snapshots (matching re-runs every ~5 min), all
back-filled with the same label, so ``groups`` keys rows by source cycle
(``cycle_id`` → ``start_time_iso`` → unique) to stop the holdout split leaking
correlated same-cycle snapshots across train/test (B5).
"""
from .feature_extraction import LIVE_MATCH_FEATURE_COLUMNS
columns = list(LIVE_MATCH_FEATURE_COLUMNS)
rows: list[dict[str, float]] = []
labels: list[float] = []
group_keys: list[str] = []
for i, snap in enumerate(snapshots):
if not isinstance(snap, dict):
continue
confirmed = snap.get("confirmed_label")
if not isinstance(confirmed, str) or not confirmed:
continue
top1 = snap.get("top1_profile")
if not isinstance(top1, str):
continue
feat = snap.get("features")
if not isinstance(feat, dict):
continue
label = 1.0 if confirmed == top1 else 0.0
rows.append({col: float(feat.get(col) or 0.0) for col in columns})
labels.append(label)
group_keys.append(str(snap.get("cycle_id") or snap.get("start_time_iso") or f"_row{i}"))
return (_matrix(rows, columns), np.array(labels, dtype=float),
columns, _group_ids(group_keys))
def _group_ids(keys: list[Any]) -> np.ndarray:
"""Map an ordered list of group keys to stable integer ids (first-seen order)."""
seen: dict[Any, int] = {}
out: list[int] = []
for k in keys:
if k not in seen:
seen[k] = len(seen)
out.append(seen[k])
return np.array(out, dtype=int)
def _progress_dataset(
clean: list[dict[str, Any]],
expectations: dict[str, dict[str, float]],
) -> tuple[np.ndarray, np.ndarray, list[str], np.ndarray]:
"""Synthesize (features, completion_fraction) rows for the remaining-time model.
Each clean completed cycle is cut at several elapsed fractions; the target is
the true completion fraction of the prefix (``prefix_elapsed / total``). This
turns every stored trace into a handful of supervised progress examples, so
the regressor learns the device's own progress curve (e.g. a program that
reliably runs longer than its labelled duration) rather than the naive
elapsed/expected assumption.
"""
from .feature_extraction import PROGRESS_FEATURE_COLUMNS, progress_features
columns = list(PROGRESS_FEATURE_COLUMNS)
rows: list[dict[str, float]] = []
labels: list[float] = []
groups: list[int] = []
for ci, c in enumerate(clean):
exp = expectations.get(c.get("profile_name"))
if not exp:
continue
points = _read_points(c)
if len(points) < 12:
continue
t0 = points[0][0]
total = points[-1][0] - t0
if total <= 60.0:
continue
for frac in _PROGRESS_CUT_FRACTIONS:
cut_t = t0 + frac * total
prefix = [(o, p) for o, p in points if o <= cut_t]
if len(prefix) < 4:
continue
feat = progress_features(prefix, exp)
if feat is None:
continue
actual_elapsed = prefix[-1][0] - t0
label = actual_elapsed / total
rows.append(feat)
labels.append(float(min(max(label, 0.0), 1.0)))
groups.append(ci)
return (_matrix(rows, columns), np.array(labels, dtype=float),
columns, np.array(groups, dtype=int))
def _energy_dataset(
clean: list[dict[str, Any]],
expectations: dict[str, dict[str, float]],
) -> tuple[np.ndarray, np.ndarray, list[str], np.ndarray]:
"""Synthesize (features, energy_completion_fraction) rows for the total-energy
model. Same feature vector as the remaining-time model; the label is
``energy_so_far / total_energy`` at each cut, so the regressor learns how
energy accumulates *non-linearly* over the cycle (heating front-loads it)
rather than assuming it tracks elapsed time. The naive baseline in
``_train_regression_capability`` is ``elapsed_over_expected`` (time progress),
which is exactly the current ``energy_so_far / progress`` projection — so a
model is only promoted when it beats that.
"""
from .feature_extraction import (
PROGRESS_FEATURE_COLUMNS,
progress_features,
cumulative_energy_wh,
)
columns = list(PROGRESS_FEATURE_COLUMNS)
rows: list[dict[str, float]] = []
labels: list[float] = []
groups: list[int] = []
for ci, c in enumerate(clean):
exp = expectations.get(c.get("profile_name"))
if not exp:
continue
points = _read_points(c)
if len(points) < 12:
continue
t0 = points[0][0]
total_dur = points[-1][0] - t0
if total_dur <= 60.0:
continue
total_energy = float(cumulative_energy_wh(points)[-1])
if total_energy <= 1e-6:
continue
for frac in _PROGRESS_CUT_FRACTIONS:
cut_t = t0 + frac * total_dur
prefix = [(o, p) for o, p in points if o <= cut_t]
if len(prefix) < 4:
continue
feat = progress_features(prefix, exp)
if feat is None:
continue
energy_so_far = float(cumulative_energy_wh(prefix)[-1])
label = energy_so_far / total_energy
rows.append(feat)
labels.append(float(min(max(label, 0.0), 1.0)))
groups.append(ci)
return (_matrix(rows, columns), np.array(labels, dtype=float),
columns, np.array(groups, dtype=int))
def _group_holdout_indices(
groups: np.ndarray, frac: float, seed: int
) -> tuple[np.ndarray, np.ndarray] | None:
"""Assign whole groups to train/test so no group straddles the split (B5).
Returns (train_idx, test_idx) row-index arrays, or None if there are too few
distinct groups to hold any out while leaving ≥1 training group.
"""
uniq = np.unique(groups)
if uniq.size < 2:
return None
rng = np.random.default_rng(seed)
perm = rng.permutation(uniq)
n_test_groups = max(1, int(round(uniq.size * frac)))
if uniq.size - n_test_groups < 1:
n_test_groups = uniq.size - 1
test_groups = set(perm[:n_test_groups].tolist())
test_mask = np.array([g in test_groups for g in groups])
train_idx = np.where(~test_mask)[0]
test_idx = np.where(test_mask)[0]
if train_idx.size == 0 or test_idx.size == 0:
return None
return train_idx, test_idx
def _regression_split(
X: np.ndarray, y: np.ndarray, groups: np.ndarray | None = None,
*, frac: float = 0.2, seed: int = 0
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
"""Seeded train/test split for regression (no class balancing).
When ``groups`` is given, splits by group so correlated same-cycle rows never
span train and test; falls back to in-sample eval if it cannot.
"""
n = X.shape[0]
if groups is not None and getattr(groups, "size", 0) == n:
split = _group_holdout_indices(groups, frac, seed)
if split is not None and split[0].size >= 2:
train_idx, test_idx = split
return X[train_idx], y[train_idx], X[test_idx], y[test_idx]
return X, y, X, y
rng = np.random.default_rng(seed)
idx = rng.permutation(n)
n_test = max(1, int(round(n * frac)))
if n - n_test < 2: # keep at least a couple of training rows
return X, y, X, y
test_idx, train_idx = idx[:n_test], idx[n_test:]
return X[train_idx], y[train_idx], X[test_idx], y[test_idx]
def _train_regression_capability(
capability: str,
target: str,
target_units: str,
X: np.ndarray,
y: np.ndarray,
columns: list[str],
trained_at: str,
groups: np.ndarray | None = None,
) -> dict[str, Any]:
"""Fit + gate one regression capability against a naive analytic baseline.
The naive baseline for the completion-fraction target is
``elapsed_over_expected`` (the first feature column) clamped to [0, 1] - i.e.
the current profile-duration assumption. A trained regressor is only promoted
when its held-out MAE is at least ``ML_TRAINING_REGRESSION_MARGIN`` lower.
"""
n = X.shape[0]
if n < ML_TRAINING_MIN_REGRESSION_ROWS:
return {"capability": capability, "promoted": False,
"reason": f"insufficient data (rows={n})"}
X_tr, y_tr, X_te, y_te = _regression_split(X, y, groups)
# Detect in-sample fallback (too few rows to split).
in_sample = X_tr is X and X_te is X
if in_sample:
_LOGGER.warning(
"ML training '%s': too few rows (%d) to split for regression — "
"evaluating in-sample; NOT promoting. Add more cycles for a reliable holdout.",
capability, n,
)
try:
fit = T.fit_ridge(X_tr, y_tr, alpha=1.0)
except ValueError as err:
return {"capability": capability, "promoted": False, "reason": str(err)}
spec_probe = {
"center": fit["center"], "scale": fit["scale"], "coef": fit["coef"],
"bias": fit["bias"], "output_center": fit["y_center"], "output_scale": fit["y_scale"],
"feature_columns": columns,
}
preds = np.clip(T.predict_matrix_spec(spec_probe, X_te), 0.0, 1.0)
metrics = T.regression_metrics(y_te, preds)
model_mae = float(metrics.get("mae") or 1.0)
naive_col = columns.index("elapsed_over_expected") if "elapsed_over_expected" in columns else 0
naive = np.clip(X_te[:, naive_col], 0.0, 1.0)
naive_mae = float(np.mean(np.abs(naive - y_te))) if y_te.size else 1.0
# Distinct source cycles: each clean cycle contributes several prefix rows via
# `groups`, so ``n`` (rows) overstates how many real cycles trained the model.
n_cycles = (
int(np.unique(groups).size)
if groups is not None and getattr(groups, "size", 0) == n
else n
)
# Never promote on an in-sample (non-held-out) evaluation.
promote = (model_mae <= naive_mae * (1.0 - ML_TRAINING_REGRESSION_MARGIN)) and not in_sample
record: dict[str, Any] = {
"capability": capability,
"promoted": bool(promote),
"rows": n,
"cycle_count": n_cycles,
"model_mae": round(model_mae, 5),
"naive_mae": round(naive_mae, 5),
"metrics": metrics,
}
if promote:
record["spec"] = T.build_regression_spec(
name=capability, target=target, feature_columns=columns, fit=fit,
target_units=target_units,
metrics={"holdout": metrics, "model_mae": round(model_mae, 5),
"naive_mae": round(naive_mae, 5)},
trained_at=trained_at, cycle_count=n_cycles,
)
record["trained_at"] = trained_at
elif in_sample:
record["reason"] = "no held-out split (in-sample eval); not promoted"
else:
record["reason"] = f"MAE {model_mae:.4f} not below naive {naive_mae:.4f} - margin"
return record
def _holdout_split(
X: np.ndarray, y: np.ndarray, groups: np.ndarray | None = None,
*, frac: float = 0.2, seed: int = 0
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
"""Seeded split that keeps both classes in the test set when possible.
When ``groups`` is given, splits by group (no same-cycle row spans the split, B5);
if the resulting split loses a class from either side it retries a few seeds, then
falls back to in-sample eval.
"""
n = X.shape[0]
if groups is not None and getattr(groups, "size", 0) == n:
for s in range(seed, seed + 8):
split = _group_holdout_indices(groups, frac, s)
if split is None:
break
train_idx, test_idx = split
if (len(np.unique(y[test_idx])) >= 2 and len(np.unique(y[train_idx])) >= 2):
return X[train_idx], y[train_idx], X[test_idx], y[test_idx]
return X, y, X, y
rng = np.random.default_rng(seed)
idx = rng.permutation(n)
n_test = max(1, int(round(n * frac)))
test_idx, train_idx = idx[:n_test], idx[n_test:]
# Guarantee both classes present in test; otherwise fall back to all-data eval.
if len(np.unique(y[test_idx])) < 2 or len(np.unique(y[train_idx])) < 2:
return X, y, X, y
return X[train_idx], y[train_idx], X[test_idx], y[test_idx]
def _embedded_module(capability: str):
module_name = _CAPABILITIES.get(capability, (None, None))[0]
if module_name is None:
return None
try:
return importlib.import_module(f"{__package__}.{module_name}")
except Exception: # pylint: disable=broad-exception-caught
return None
def _baseline_scores(capability: str, X_test: np.ndarray, columns: list[str]) -> np.ndarray | None:
"""Embedded-baseline probabilities on X_test, or None if it can't load/score."""
module = _embedded_module(capability)
if module is None:
return None
try:
return np.array(
[float(module.score(dict(zip(columns, row)))) for row in X_test], dtype=float
)
except Exception: # pylint: disable=broad-exception-caught
return None
def _baseline_threshold(capability: str, default: float) -> float:
module = _embedded_module(capability)
thr = getattr(module, "THRESHOLD", None) if module is not None else None
return float(thr) if isinstance(thr, (int, float)) else default
def _train_capability(
capability: str,
target: str,
X: np.ndarray,
y: np.ndarray,
columns: list[str],
trained_at: str,
groups: np.ndarray | None = None,
) -> dict[str, Any]:
"""Fit + gate one capability. Returns a status record (promoted or not)."""
n = X.shape[0]
n_pos = int(np.sum(y == 1))
n_neg = int(np.sum(y == 0))
if n < _MIN_ROWS or n_pos < ML_TRAINING_MIN_POSITIVES or n_neg < 5:
return {"capability": capability, "promoted": False,
"reason": f"insufficient data (rows={n}, pos={n_pos}, neg={n_neg})"}
X_tr, y_tr, X_te, y_te = _holdout_split(X, y, groups)
# Detect in-sample fallback (holdout returned full dataset for both splits).
in_sample = X_tr is X and X_te is X
if in_sample:
_LOGGER.warning(
"ML training '%s': dataset too small or imbalanced to split "
"(n=%d, pos=%d, neg=%d) — AUC evaluated in-sample; NOT promoting "
"(an in-sample AUC is optimistic). Add more labeled cycles.",
capability, n, n_pos, n_neg,
)
fit = T.fit_logistic(X_tr, y_tr)
default_thr = _baseline_threshold(capability, 0.5)
spec_probe = {"center": fit["center"], "scale": fit["scale"], "coef": fit["coef"],
"bias": fit["bias"], "feature_columns": columns}
train_scores = T.score_matrix_spec(spec_probe, X_tr)
threshold = T.select_threshold(y_tr, train_scores, default=default_thr)
test_scores = T.score_matrix_spec(spec_probe, X_te)
new_auc = T.auc(y_te, test_scores)
metrics = T.binary_metrics(y_te, test_scores, threshold)
# Distinct source cycles (some capabilities emit >1 row per cycle, e.g. an
# end classifier with several candidate events); mirror the regression path.
n_cycles = (
int(np.unique(groups).size)
if groups is not None and getattr(groups, "size", 0) == n
else n
)
base_scores = _baseline_scores(capability, X_te, columns)
if base_scores is None:
# Every classifier capability ships an embedded baseline; None here means
# it failed to load/score, NOT that it is legitimately absent. Don't promote
# against a fabricated 0.5 bar (that would let a near-random model win).
return {"capability": capability, "promoted": False,
"rows": n, "positives": n_pos, "negatives": n_neg,
"cycle_count": n_cycles, "new_auc": round(new_auc, 4),
"threshold": threshold, "metrics": metrics,
"reason": "embedded baseline unavailable; cannot gate promotion"}
baseline = T.auc(y_te, base_scores)
# Calibration-aware gate: the live consumer applies a FIXED probability cutoff to
# this capability, so AUC (rank quality) alone isn't enough — a retrained model
# must also not degrade balanced accuracy AT that operating cutoff, else a
# differently-calibrated on-device model silently shifts decision rates.
op_thr = _OPERATING_THRESHOLD.get(capability)
trained_op_bacc: float | None = None
base_op_bacc: float | None = None
calib_ok = True
if op_thr is not None:
trained_op_bacc = float(
T.binary_metrics(y_te, test_scores, op_thr).get("balanced_accuracy") or 0.0
)
base_op_bacc = float(
T.binary_metrics(y_te, base_scores, op_thr).get("balanced_accuracy") or 0.0
)
calib_ok = trained_op_bacc >= (base_op_bacc - ML_TRAINING_BACC_MARGIN)
# Never promote on an in-sample (non-held-out) evaluation: the AUC is optimistic.
promote = (
(new_auc >= (baseline - ML_TRAINING_AUC_MARGIN)) and not in_sample and calib_ok
)
record: dict[str, Any] = {
"capability": capability,
"promoted": bool(promote),
"rows": n, "positives": n_pos, "negatives": n_neg,
"cycle_count": n_cycles,
"new_auc": round(new_auc, 4),
"baseline_auc": round(baseline, 4),
"threshold": threshold,
"metrics": metrics,
}
if op_thr is not None:
record["operating_threshold"] = op_thr
record["op_balanced_accuracy"] = round(trained_op_bacc or 0.0, 4)
record["baseline_op_balanced_accuracy"] = round(base_op_bacc or 0.0, 4)
if promote:
record["spec"] = T.build_spec(
name=capability, target=target, feature_columns=columns,
fit=fit, threshold=threshold,
metrics={"holdout": metrics, "auc": round(new_auc, 4), "baseline_auc": round(baseline, 4)},
trained_at=trained_at, cycle_count=n_cycles,
)
record["trained_at"] = trained_at
elif in_sample:
record["reason"] = "no held-out split (in-sample eval); not promoted"
elif not calib_ok:
record["reason"] = (
f"balanced accuracy at operating threshold {op_thr} "
f"({trained_op_bacc:.3f}) below baseline ({base_op_bacc:.3f}) - margin"
)
else:
record["reason"] = f"AUC {new_auc:.3f} below baseline {baseline:.3f} - margin"
return record
def train_from_cycles(
cycles: list[dict[str, Any]],
device_type: str | None,
stop_threshold_w: float = 2.0,
trained_at: str = "",
ranking_history: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Pure function (executor-safe): build datasets, train, gate all capabilities.
Returns ``{"results": [record, ...], "promoted": {capability: record}}``.
Caller persists the promoted records via ``profile_store.set_ml_model_version``.
``ranking_history`` is the accumulated match ranking snapshots from the store
(see :meth:`.ProfileStore.get_match_ranking_history`). When provided it
unlocks on-device training for the ``live_match`` capability.
"""
from ..suggestion_engine import select_clean_cycles
from .feature_extraction import profile_expectations
clean, _excluded = select_clean_cycles(cycles, stop_threshold_w=stop_threshold_w)
expectations = profile_expectations(cycles)
datasets: dict[str, tuple[np.ndarray, np.ndarray, list[str], np.ndarray]] = {
"end": _end_dataset(clean, expectations, stop_threshold_w),
"quality": _quality_dataset(cycles, expectations),
"live_match": _live_match_dataset(ranking_history or []),
}
results: list[dict[str, Any]] = []
promoted: dict[str, Any] = {}
for capability, (module_name, target) in _CAPABILITIES.items():
X, y, columns, groups = datasets[capability]
try:
record = _train_capability(capability, target, X, y, columns, trained_at, groups)
except ValueError as exc:
_LOGGER.debug("Skipping %s training: %s", capability, exc)
results.append({"capability": capability, "promoted": False, "reason": str(exc)})
continue
results.append(record)
if record.get("promoted") and "spec" in record:
promoted[capability] = {
"spec": record["spec"],
"trained_at": trained_at,
"cycle_count": record["cycle_count"],
"metrics": record["metrics"],
"new_auc": record["new_auc"],
"baseline_auc": record["baseline_auc"],
}
# Regression capabilities (no embedded baseline; gated against a naive estimate).
reg_datasets: dict[str, tuple[np.ndarray, np.ndarray, list[str], np.ndarray]] = {
"remaining_time": _progress_dataset(clean, expectations),
"total_energy": _energy_dataset(clean, expectations),
}
for capability, (target, target_units) in _REGRESSION_CAPABILITIES.items():
X, y, columns, groups = reg_datasets[capability]
record = _train_regression_capability(
capability, target, target_units, X, y, columns, trained_at, groups
)
results.append(record)
if record.get("promoted") and "spec" in record:
promoted[capability] = {
"spec": record["spec"],
"trained_at": trained_at,
"cycle_count": record["cycle_count"],
"metrics": record["metrics"],
"model_mae": record["model_mae"],
"naive_mae": record["naive_mae"],
}
return {"results": results, "promoted": promoted}
async def async_run_training(hass: Any, manager: Any) -> dict[str, Any]:
"""Public entry point: train on this device's cycles and persist winners.
Offloads the CPU work to an executor thread and persists any promoted model
specs into the profile store. Returns a summary for logging / the event.
"""
from ..const import CONF_MIN_POWER, CONF_STOP_THRESHOLD_W
store = manager.profile_store
entry = hass.config_entries.async_get_entry(manager.entry_id)
merged = {**(entry.data if entry else {}), **(entry.options if entry else {})}
stop_thr = 2.0
for key in (CONF_STOP_THRESHOLD_W, CONF_MIN_POWER):
try:
v = float(merged.get(key))
except (TypeError, ValueError):
continue
if v > 0:
stop_thr = v
break
from homeassistant.util import dt as dt_util
trained_at = dt_util.now().isoformat()
cycles = list(store.get_past_cycles()) # snapshot before executor to avoid data race
# get_match_ranking_history() already returns a shallow copy of the top-level
# list, but wrap it in list(...) too so the executor never iterates a list that
# the event loop could mutate mid-training - matching the get_past_cycles()
# snapshot above.
ranking_history = list(store.get_match_ranking_history())
_LOGGER.info(
"On-device ML training starting: %d cycles, %d ranking snapshots, "
"device_type=%s, stop_threshold=%.1fW",
len(cycles), len(ranking_history), manager.device_type, stop_thr,
)
summary = await hass.async_add_executor_job(
train_from_cycles, cycles, manager.device_type, stop_thr, trained_at, ranking_history
)
for record in summary.get("results", []):
is_regression = "model_mae" in record
if record.get("promoted") and is_regression:
_LOGGER.info(
"ML training PROMOTED %s: MAE %.4f vs naive %.4f (rows=%s)",
record["capability"], record.get("model_mae", 0), record.get("naive_mae", 0),
record.get("rows"),
)
elif record.get("promoted"):
_LOGGER.info(
"ML training PROMOTED %s: AUC %.3f vs baseline %.3f (rows=%s, pos=%s)",
record["capability"], record.get("new_auc", 0), record.get("baseline_auc", 0),
record.get("rows"), record.get("positives"),
)
else:
_LOGGER.info(
"ML training kept baseline for %s: %s",
record["capability"], record.get("reason", "not promoted"),
)
for capability, record in summary.get("promoted", {}).items():
await store.set_ml_model_version(capability, record)
return summary