175 lines
6.8 KiB
Python
175 lines
6.8 KiB
Python
# 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 = ''
|
|
# Training artifact for select_threshold only — NOT used at live inference.
|
|
# The live quality-suspicious gate reads ML_QUALITY_SUSPICIOUS_THRESHOLD from const.py.
|
|
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"]
|