# 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 = ''
# Training artifact for select_threshold only — NOT used at live inference.
# The live defer-finish gate reads DEFAULT_DEFER_FINISH_CONFIDENCE from const.py.
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"]
