710 lines
27 KiB
Python
710 lines
27 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/>.
|
|
"""Progress / remaining-time / phase / projected-energy estimation.
|
|
|
|
Single source of truth for the cycle-progress math. Both the live integration
|
|
(``manager.WashDataManager`` - thin wrappers over these functions) and the
|
|
Playground's headless simulation (``playground.SimRunner``) call the SAME
|
|
functions here, so the panel's what-if replay is byte-for-byte what the running
|
|
integration computes. Nothing here touches Home Assistant; every function is
|
|
pure given a ``ProfileStore`` (read-only), the entry options mapping, and a
|
|
replayed ``(timestamp, power)`` trace, so it is executor-safe.
|
|
|
|
Extracted verbatim from ``manager.py`` (``self.profile_store`` -> ``store``,
|
|
``self._logger`` -> ``logger``); the arithmetic is unchanged and guarded by the
|
|
existing progress/phase/ML/energy test suite plus a golden before/after snapshot.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import math
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from typing import Any, cast
|
|
|
|
import numpy as np
|
|
|
|
from .const import (
|
|
CYCLE_OVERRUN_ANOMALY_RATIO,
|
|
DEVICE_SMOOTHING_THRESHOLDS,
|
|
ML_PROGRESS_BLEND_WEIGHT,
|
|
STATE_ENDING,
|
|
STATE_PAUSED,
|
|
STATE_RUNNING,
|
|
)
|
|
from .profile_store import decompress_power_data
|
|
from .time_utils import power_data_to_offsets
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
# Minimum progress before an energy projection is trusted (mirrors the manager
|
|
# class constant of the same purpose).
|
|
PROJECTION_MIN_PROGRESS = 3.0
|
|
|
|
# Cache type for profile_end_expectation: (profile_name, base_expectation_dict).
|
|
EndExpCache = tuple[str, dict[str, float]] | None
|
|
|
|
|
|
@dataclass
|
|
class ProgressResult:
|
|
"""Output of :func:`compute_progress`."""
|
|
|
|
progress: float
|
|
smoothed: float
|
|
remaining: float
|
|
total: float
|
|
phase_progress: float | None # raw pre-smoothing estimate (diagnostic)
|
|
source: str # "phase" | "linear"
|
|
|
|
|
|
def profile_end_expectation(
|
|
store: Any,
|
|
profile_name: str,
|
|
expected_duration: float,
|
|
cache: EndExpCache = None,
|
|
) -> tuple[dict[str, float] | None, EndExpCache]:
|
|
"""Median duration/energy/peak for a matched profile, for end features.
|
|
|
|
Cached per profile (caller threads ``cache``) so the guard does not
|
|
re-decompress history on every low-power reading during ENDING. The
|
|
authoritative expected duration overrides the median when available.
|
|
Returns ``(expectation, cache)``.
|
|
"""
|
|
if cache is not None and cache[0] == profile_name:
|
|
expectation = dict(cache[1])
|
|
else:
|
|
from .ml.feature_extraction import profile_expectation
|
|
|
|
points_list: list[list[tuple[float, float]]] = []
|
|
for cycle in store.get_past_cycles():
|
|
if cycle.get("profile_name") != profile_name:
|
|
continue
|
|
pts = decompress_power_data(cycle)
|
|
if pts:
|
|
points_list.append(pts)
|
|
base = profile_expectation(points_list[-20:])
|
|
if base is None:
|
|
return None, cache
|
|
cache = (profile_name, dict(base))
|
|
expectation = dict(base)
|
|
if expected_duration and expected_duration > 0:
|
|
expectation["duration"] = float(expected_duration)
|
|
return expectation, cache
|
|
|
|
|
|
EndExpFn = Any # Callable[[str, float], dict[str, float] | None]
|
|
|
|
|
|
def ml_progress_percent(
|
|
store: Any,
|
|
options: Any,
|
|
matched_duration: float,
|
|
trace: list[tuple[datetime, float]],
|
|
profile_name: str,
|
|
end_expectation_fn: EndExpFn,
|
|
logger: logging.Logger | None = None,
|
|
) -> float | None:
|
|
"""ML completion-fraction estimate (0-100) for the running cycle, or None.
|
|
|
|
Uses the on-device ``remaining_time`` regressor; gated on the ML opt-in and
|
|
inert until training promotes a regressor. ``end_expectation_fn(name, dur)``
|
|
supplies the profile expectation (the manager passes its cached
|
|
``_profile_end_expectation``; the Playground wraps :func:`profile_end_expectation`)
|
|
so history is only decompressed after the cheap gates pass. Never raises.
|
|
"""
|
|
logger = logger or _LOGGER
|
|
try:
|
|
from .ml.engine import ml_models_enabled, resolve_regressor
|
|
|
|
if not ml_models_enabled(options):
|
|
return None
|
|
if (
|
|
not profile_name
|
|
or profile_name in ("off", "detecting...", "restored...")
|
|
or profile_name not in store.get_profiles()
|
|
):
|
|
return None
|
|
predict_fn, _src = resolve_regressor("remaining_time", store)
|
|
if predict_fn is None:
|
|
return None
|
|
if not trace or len(trace) < 4:
|
|
return None
|
|
expectation = end_expectation_fn(
|
|
profile_name, float(matched_duration or 0.0)
|
|
)
|
|
if expectation is None:
|
|
return None
|
|
t0 = trace[0][0]
|
|
pts = [(float((t - t0).total_seconds()), float(p)) for t, p in trace]
|
|
from .ml.feature_extraction import progress_features
|
|
|
|
feat = progress_features(pts, expectation)
|
|
if feat is None:
|
|
return None
|
|
frac = float(predict_fn(feat))
|
|
if not math.isfinite(frac):
|
|
return None
|
|
return float(min(max(frac, 0.0), 0.99)) * 100.0
|
|
except Exception as err: # noqa: BLE001 - ML must never break estimates
|
|
logger.debug("ML progress estimate skipped: %s", err)
|
|
return None
|
|
|
|
|
|
def ml_energy_total(
|
|
store: Any,
|
|
options: Any,
|
|
matched_duration: float,
|
|
trace: list[tuple[datetime, float]],
|
|
profile_name: str,
|
|
end_expectation_fn: EndExpFn,
|
|
logger: logging.Logger | None = None,
|
|
) -> float | None:
|
|
"""Predicted total cycle energy (Wh) from the on-device ``total_energy``
|
|
regressor, or None. ``end_expectation_fn`` as in :func:`ml_progress_percent`.
|
|
Never raises.
|
|
"""
|
|
logger = logger or _LOGGER
|
|
try:
|
|
from .ml.engine import ml_models_enabled, resolve_regressor
|
|
|
|
if not ml_models_enabled(options):
|
|
return None
|
|
if (
|
|
not profile_name
|
|
or profile_name in ("off", "detecting...", "restored...")
|
|
or profile_name not in store.get_profiles()
|
|
):
|
|
return None
|
|
predict_fn, _src = resolve_regressor("total_energy", store)
|
|
if predict_fn is None:
|
|
return None
|
|
if not trace or len(trace) < 4:
|
|
return None
|
|
expectation = end_expectation_fn(
|
|
profile_name, float(matched_duration or 0.0)
|
|
)
|
|
if expectation is None:
|
|
return None
|
|
t0 = trace[0][0]
|
|
pts = [(float((t - t0).total_seconds()), float(p)) for t, p in trace]
|
|
from .ml.feature_extraction import cumulative_energy_wh, progress_features
|
|
|
|
feat = progress_features(pts, expectation)
|
|
if feat is None:
|
|
return None
|
|
frac = float(predict_fn(feat))
|
|
# Floor the fraction so an under-confident prediction can't blow the
|
|
# projection up; below the floor, defer to the time-based fallback.
|
|
if not math.isfinite(frac) or frac < 0.05:
|
|
return None
|
|
energy_so_far = float(cumulative_energy_wh(pts)[-1])
|
|
if energy_so_far <= 0.0:
|
|
return None
|
|
total = energy_so_far / min(max(frac, 0.05), 1.0)
|
|
return max(total, energy_so_far) # never below what's already consumed
|
|
except Exception as err: # noqa: BLE001 - ML must never break estimates
|
|
logger.debug("ML energy projection skipped: %s", err)
|
|
return None
|
|
|
|
|
|
def estimate_phase_progress(
|
|
store: Any,
|
|
current_power_data: list[tuple[datetime, float]] | list[tuple[str, float]],
|
|
current_duration: float,
|
|
profile_name: str,
|
|
logger: logging.Logger | None = None,
|
|
) -> tuple[float, float] | None:
|
|
"""Estimate cycle progress by analyzing which phase we're in.
|
|
|
|
Uses cached statistical envelope built from ALL cycles labeled with this
|
|
profile, normalized by TIME to account for different sampling rates. Returns
|
|
``(progress_pct, variance_watts)`` or ``None`` if estimation fails.
|
|
"""
|
|
logger = logger or _LOGGER
|
|
# Get cached envelope (fast - already computed and stored)
|
|
envelope = store.get_envelope(profile_name)
|
|
|
|
if envelope is None:
|
|
logger.debug("No envelope cached for profile %s", profile_name)
|
|
return None
|
|
|
|
# Convert cached lists back to numpy arrays
|
|
try:
|
|
env_min = envelope.get("min", [])
|
|
env_max = envelope.get("max", [])
|
|
env_avg = envelope.get("avg", [])
|
|
env_std = envelope.get("std", [])
|
|
|
|
def extract_y_values(data: list[Any]) -> np.ndarray[Any, np.dtype[np.float64]]:
|
|
if not data:
|
|
return np.array([], dtype=float)
|
|
first = data[0]
|
|
if isinstance(first, (list, tuple)):
|
|
first_seq = cast(list[Any] | tuple[Any, ...], first)
|
|
if len(first_seq) < 2:
|
|
return np.array([], dtype=float)
|
|
# New format: [[t, y], ...]
|
|
points = cast(list[list[Any] | tuple[Any, ...]], data)
|
|
return np.array([float(pt[1]) for pt in points], dtype=float)
|
|
# Legacy format: [y, ...]
|
|
scalars = cast(list[float | int], data)
|
|
return np.array(scalars, dtype=float)
|
|
|
|
envelope_arrays: dict[str, np.ndarray[Any, np.dtype[np.float64]]] = {
|
|
"min": extract_y_values(env_min),
|
|
"max": extract_y_values(env_max),
|
|
"avg": extract_y_values(env_avg),
|
|
"std": extract_y_values(env_std),
|
|
}
|
|
time_grid: np.ndarray[Any, np.dtype[np.float64]] = np.array(
|
|
envelope.get("time_grid", []), dtype=float
|
|
)
|
|
target_duration = float(envelope.get("target_duration", 0.0) or 0.0)
|
|
except (KeyError, ValueError, TypeError, IndexError) as e:
|
|
logger.warning("Invalid envelope format for %s: %s", profile_name, e)
|
|
return None
|
|
|
|
if len(time_grid) == 0 or target_duration <= 0:
|
|
if target_duration > 0 and len(envelope_arrays["avg"]) > 0:
|
|
# Reconstruct time_grid if missing (Legacy envelope support)
|
|
count = len(envelope_arrays["avg"])
|
|
time_grid = np.linspace(0, target_duration, count)
|
|
logger.debug(
|
|
"Reconstructed missing time_grid for %s (n=%d)",
|
|
profile_name,
|
|
count,
|
|
)
|
|
else:
|
|
logger.debug("Envelope missing time grid/duration, cannot estimate phase")
|
|
return None
|
|
|
|
# Extract power offsets from current cycle (any format -> [offset, power])
|
|
current_offsets_list = power_data_to_offsets(
|
|
cast(list[list[Any] | tuple[Any, ...]], current_power_data)
|
|
)
|
|
current_offsets = np.array([o for o, _ in current_offsets_list])
|
|
current_values = np.array([p for _, p in current_offsets_list])
|
|
if current_offsets.size == 0:
|
|
logger.debug("No valid current power offsets, cannot estimate phase")
|
|
return None
|
|
|
|
# Use sliding window on TIME, not sample count
|
|
window_duration = min(60.0, target_duration * 0.25)
|
|
current_time = current_offsets[-1]
|
|
window_start_time = max(0, current_time - window_duration)
|
|
|
|
window_mask = current_offsets >= window_start_time
|
|
current_window_values = current_values[window_mask]
|
|
|
|
if len(current_window_values) < 3:
|
|
logger.debug("Insufficient data in current window for phase estimation")
|
|
return None
|
|
|
|
best_progress: float | None = None
|
|
best_score = -1.0
|
|
in_bounds = False
|
|
best_time_window_start: float | None = None
|
|
|
|
for i in range(len(time_grid) - 1):
|
|
time_window_start = float(time_grid[i])
|
|
|
|
envelope_window_start = i
|
|
envelope_window_end = min(
|
|
i + len(current_window_values), len(envelope_arrays["avg"])
|
|
)
|
|
|
|
if envelope_window_end <= envelope_window_start:
|
|
continue
|
|
|
|
avg_window = envelope_arrays["avg"][
|
|
envelope_window_start:envelope_window_end
|
|
]
|
|
min_window = envelope_arrays["min"][
|
|
envelope_window_start:envelope_window_end
|
|
]
|
|
max_window = envelope_arrays["max"][
|
|
envelope_window_start:envelope_window_end
|
|
]
|
|
|
|
if len(avg_window) != len(current_window_values):
|
|
x_old = np.linspace(0, 1, len(avg_window))
|
|
x_new = np.linspace(0, 1, len(current_window_values))
|
|
avg_window = np.interp(x_new, x_old, avg_window)
|
|
min_window = np.interp(x_new, x_old, min_window)
|
|
max_window = np.interp(x_new, x_old, max_window)
|
|
|
|
within_bounds = np.all(
|
|
(current_window_values >= min_window * 0.8)
|
|
& (current_window_values <= max_window * 1.2)
|
|
)
|
|
bounds_score = np.mean(
|
|
(current_window_values >= min_window)
|
|
& (current_window_values <= max_window)
|
|
)
|
|
|
|
try:
|
|
if np.std(current_window_values) > 0 and np.std(avg_window) > 0:
|
|
correlation = np.corrcoef(current_window_values, avg_window)[0, 1]
|
|
else:
|
|
correlation = 0.0
|
|
|
|
mae = np.mean(np.abs(current_window_values - avg_window))
|
|
max_power = max(np.max(avg_window), np.max(current_window_values), 1.0)
|
|
mae_normalized = 1.0 - min(mae / max_power, 1.0)
|
|
|
|
score = (
|
|
0.4 * max(correlation, 0.0)
|
|
+ 0.3 * mae_normalized
|
|
+ 0.3 * bounds_score
|
|
)
|
|
|
|
time_diff = abs(time_window_start - current_duration)
|
|
time_penalty = min(1.0, time_diff / (target_duration * 0.3))
|
|
|
|
score = score * (1.0 - 0.4 * time_penalty)
|
|
|
|
if score > best_score:
|
|
best_score = score
|
|
best_progress = (time_window_start / target_duration) * 100.0
|
|
in_bounds = within_bounds
|
|
best_time_window_start = float(time_window_start)
|
|
except Exception: # pylint: disable=broad-exception-caught
|
|
continue
|
|
|
|
if best_progress is None or best_score < 0.4:
|
|
logger.debug("Phase detection failed: best_score=%.3f", best_score)
|
|
return None
|
|
|
|
best_variance = 0.0
|
|
if best_time_window_start is not None:
|
|
idx_start = int((best_time_window_start / target_duration) * len(time_grid))
|
|
idx_end = min(
|
|
idx_start + len(current_window_values), len(envelope_arrays["std"])
|
|
)
|
|
if idx_end > idx_start:
|
|
window_std = envelope_arrays["std"][idx_start:idx_end]
|
|
if len(window_std) > 0:
|
|
best_variance = float(np.mean(window_std))
|
|
|
|
best_progress = max(0.0, min(best_progress, 99.0))
|
|
|
|
cycle_count = envelope.get("cycle_count", 0)
|
|
avg_sample_rates_raw = envelope.get("sampling_rates", [1.0])
|
|
avg_sample_rates = (
|
|
cast(list[float | int], avg_sample_rates_raw)
|
|
if isinstance(avg_sample_rates_raw, list)
|
|
else [1.0]
|
|
)
|
|
avg_sample_rate = (
|
|
float(np.median(np.array(avg_sample_rates, dtype=float)))
|
|
if avg_sample_rates
|
|
else 1.0
|
|
)
|
|
|
|
tws = (
|
|
best_time_window_start
|
|
if best_time_window_start is not None
|
|
else float(current_duration)
|
|
)
|
|
if not in_bounds:
|
|
logger.debug(
|
|
"Phase detection: progress=%.1f%%, score=%.3f, var=%.1fW, "
|
|
"time=%.0f/%.0fs [OUT OF BOUNDS, %s cycles, avg_sample_rate=%.1fs]",
|
|
best_progress,
|
|
best_score,
|
|
best_variance,
|
|
tws,
|
|
target_duration,
|
|
cycle_count,
|
|
avg_sample_rate,
|
|
)
|
|
else:
|
|
logger.debug(
|
|
"Phase detection: progress=%.1f%%, score=%.3f, var=%.1fW, "
|
|
"time=%.0f/%.0fs [IN BOUNDS, %s cycles, avg_sample_rate=%.1fs]",
|
|
best_progress,
|
|
best_score,
|
|
best_variance,
|
|
tws,
|
|
target_duration,
|
|
cycle_count,
|
|
avg_sample_rate,
|
|
)
|
|
|
|
return (best_progress, best_variance)
|
|
|
|
|
|
def _compute_progress_base(
|
|
device_type: str,
|
|
matched_duration: float,
|
|
duration_so_far: float,
|
|
prev_smoothed: float,
|
|
phase_result: tuple[float, float] | None,
|
|
ml_pct: float | None,
|
|
logger: logging.Logger | None = None,
|
|
) -> ProgressResult | None:
|
|
"""The blend + EMA + monotonicity + back-calculation body of the estimate loop.
|
|
|
|
Pure arithmetic: the caller supplies ``phase_result`` (from
|
|
:func:`estimate_phase_progress`, or ``None`` to force the linear fallback) and
|
|
``ml_pct`` (from :func:`ml_progress_percent`, or ``None``); both the live
|
|
manager and the Playground compute those via the same functions, so this is
|
|
the single implementation of the smoothing/back-calc. Returns ``None`` when no
|
|
profile duration is known (caller clears the estimate). Behavior-identical to
|
|
the matched-duration branch of ``manager._update_remaining_only``.
|
|
"""
|
|
logger = logger or _LOGGER
|
|
if not (matched_duration and matched_duration > 0):
|
|
return None
|
|
|
|
# --- PHASE-AWARE ESTIMATION ---
|
|
if phase_result is not None:
|
|
phase_progress, phase_variance = phase_result
|
|
|
|
if ml_pct is not None:
|
|
w = ML_PROGRESS_BLEND_WEIGHT
|
|
phase_progress = (1.0 - w) * phase_progress + w * ml_pct
|
|
|
|
if prev_smoothed == 0.0:
|
|
smoothed = phase_progress
|
|
else:
|
|
current_smoothed = prev_smoothed
|
|
alpha = 0.2 # Default
|
|
if phase_variance > 100.0:
|
|
alpha = 0.05
|
|
logger.debug(
|
|
"High variance phase (std=%.1fW), "
|
|
"locking time estimate (alpha=0.05)",
|
|
phase_variance,
|
|
)
|
|
elif phase_variance > 50.0:
|
|
alpha = 0.1
|
|
|
|
smoothing_threshold = DEVICE_SMOOTHING_THRESHOLDS.get(device_type, 5.0)
|
|
if phase_progress < current_smoothed - smoothing_threshold:
|
|
smoothed = (current_smoothed * 0.95) + (phase_progress * 0.05)
|
|
logger.debug(
|
|
"Progress drop detected (%.1f%% < %.1f%% - %.1f%%), "
|
|
"applying heavy damping for %s",
|
|
phase_progress,
|
|
current_smoothed,
|
|
smoothing_threshold,
|
|
device_type,
|
|
)
|
|
else:
|
|
smoothed = (prev_smoothed * (1.0 - alpha)) + (phase_progress * alpha)
|
|
|
|
smoothed = min(99.0, smoothed)
|
|
progress = smoothed
|
|
|
|
remaining = matched_duration * (1.0 - (progress / 100.0))
|
|
remaining = max(0.0, remaining)
|
|
total = duration_so_far + remaining
|
|
|
|
logger.debug(
|
|
"Phase-aware estimate: raw=%.1f%%, smoothed=%.1f%%, remaining=%smin",
|
|
phase_progress,
|
|
progress,
|
|
int(remaining / 60),
|
|
)
|
|
return ProgressResult(progress, smoothed, remaining, total, phase_progress, "phase")
|
|
|
|
# --- LINEAR FALLBACK (if phase analysis unavailable) ---
|
|
matched_dur = float(matched_duration)
|
|
remaining = max(matched_dur - duration_so_far, 0.0)
|
|
progress = (duration_so_far / matched_dur) * 100.0
|
|
|
|
if ml_pct is not None:
|
|
w = ML_PROGRESS_BLEND_WEIGHT
|
|
progress = (1.0 - w) * progress + w * ml_pct
|
|
remaining = max(matched_dur * (1.0 - progress / 100.0), 0.0)
|
|
|
|
if prev_smoothed > 0:
|
|
smoothed = (prev_smoothed * 0.9) + (progress * 0.1)
|
|
else:
|
|
smoothed = progress
|
|
|
|
progress = max(0.0, min(smoothed, 100.0))
|
|
remaining = max(matched_dur * (1.0 - progress / 100.0), 0.0)
|
|
total = duration_so_far + remaining
|
|
logger.debug(
|
|
"Linear estimate: remaining=%smin, progress=%.1f%%",
|
|
int(remaining / 60),
|
|
progress,
|
|
)
|
|
return ProgressResult(progress, smoothed, remaining, total, None, "linear")
|
|
|
|
|
|
def compute_progress(
|
|
device_type: str,
|
|
matched_duration: float,
|
|
duration_so_far: float,
|
|
prev_smoothed: float,
|
|
phase_result: tuple[float, float] | None,
|
|
ml_pct: float | None,
|
|
logger: logging.Logger | None = None,
|
|
phase_remaining_s: float | None = None,
|
|
) -> ProgressResult | None:
|
|
"""Progress/remaining estimate, optionally blended with a phase-resolved ETA.
|
|
|
|
When ``phase_remaining_s`` is provided (opt-in phase matching for a supported
|
|
device type), the phase-budget remaining is converted to a completion PERCENT
|
|
and blended into the phase-progress signal **before** delegating to
|
|
:func:`_compute_progress_base` - so the blend rides the proven, golden-locked
|
|
EMA + monotonicity + back-calculation guards (design §8, "one smoothing
|
|
implementation"), rather than re-deriving a raw, unsmoothed progress. The
|
|
blend leans on the phase budget early (low base progress) and on the proven
|
|
phase estimate late::
|
|
|
|
phase_pct = duration_so_far / (duration_so_far + phase_remaining_s) * 100
|
|
f = base_phase_progress / 100
|
|
blended = (1 - f) * phase_pct + f * base_phase_progress
|
|
|
|
Because this feeds the percent-domain smoothing, the displayed progress stays
|
|
monotone/smoothed (no tick-to-tick jitter or collapse-to-99%), and remaining
|
|
is re-derived by the base from ``matched_duration``.
|
|
|
|
Behaviour is BYTE-IDENTICAL to before when ``phase_remaining_s is None`` (the
|
|
default) - the golden progress snapshot and every existing caller are
|
|
unaffected. This is the single implementation of the blend; the manager and
|
|
the Playground SimRunner both go through it.
|
|
"""
|
|
blended = False
|
|
if phase_remaining_s is not None and matched_duration and matched_duration > 0:
|
|
try:
|
|
pr = float(phase_remaining_s)
|
|
except (TypeError, ValueError):
|
|
pr = float("nan")
|
|
if math.isfinite(pr) and pr >= 0.0:
|
|
denom = duration_so_far + pr
|
|
phase_pct = (duration_so_far / denom * 100.0) if denom > 0 else 0.0
|
|
phase_pct = max(0.0, min(100.0, phase_pct))
|
|
if phase_result is not None:
|
|
base_pp, variance = phase_result
|
|
f = max(0.0, min(1.0, float(base_pp) / 100.0))
|
|
phase_result = ((1.0 - f) * phase_pct + f * float(base_pp), variance)
|
|
else:
|
|
# No envelope phase-progress: blend the phase budget's implied
|
|
# percent with the linear (elapsed/matched) percent, still leaning
|
|
# on the phase budget early and the linear estimate late.
|
|
lin_pct = max(0.0, min(100.0, duration_so_far / matched_duration * 100.0))
|
|
f = lin_pct / 100.0
|
|
phase_result = ((1.0 - f) * phase_pct + f * lin_pct, 0.0)
|
|
blended = True
|
|
|
|
base = _compute_progress_base(
|
|
device_type, matched_duration, duration_so_far, prev_smoothed,
|
|
phase_result, ml_pct, logger,
|
|
)
|
|
if base is None or not blended:
|
|
return base
|
|
# Relabel the source for diagnostics; values already reflect the blend.
|
|
return ProgressResult(
|
|
base.progress, base.smoothed, base.remaining, base.total,
|
|
base.phase_progress, "phase_blend",
|
|
)
|
|
|
|
|
|
def current_phase(
|
|
store: Any,
|
|
state: str,
|
|
current_program: str | None,
|
|
cycle_progress: float,
|
|
) -> str | None:
|
|
"""Live phase from the profile's configured ranges + ML-blended progress.
|
|
|
|
Indexed by the smoothed progress fraction rather than raw elapsed seconds, so
|
|
overrun/underrun cycles still name the phase correctly. Returns ``None`` when
|
|
not running, no profile is matched, or the profile has no configured phase
|
|
ranges. Never raises.
|
|
"""
|
|
try:
|
|
if state not in (STATE_RUNNING, STATE_PAUSED, STATE_ENDING):
|
|
return None
|
|
profile = current_program
|
|
if not profile or profile in ("off", "detecting...", "restored...", "none", "unknown"):
|
|
return None
|
|
ranges = store.get_profile_phase_ranges(profile)
|
|
if not ranges:
|
|
return None
|
|
nominal = max((float(r.get("end") or 0.0) for r in ranges), default=0.0)
|
|
if nominal <= 0.0:
|
|
return None
|
|
frac = max(0.0, min(1.0, float(cycle_progress) / 100.0))
|
|
return store.check_phase_match(profile, frac * nominal)
|
|
except Exception: # noqa: BLE001 - phase readout must never break
|
|
return None
|
|
|
|
|
|
def projected_energy(
|
|
store: Any,
|
|
options: Any,
|
|
matched_duration: float,
|
|
trace: list[tuple[datetime, float]],
|
|
current_program: str | None,
|
|
cycle_progress: float,
|
|
energy_so_far: float,
|
|
price: float | None,
|
|
end_expectation_fn: EndExpFn,
|
|
logger: logging.Logger | None = None,
|
|
) -> tuple[float | None, float | None]:
|
|
"""Project total energy (Wh) and cost for the running cycle.
|
|
|
|
Prefers the on-device ``total_energy`` regressor; otherwise falls back to
|
|
``energy_so_far / progress_fraction``. Returns ``(wh, cost)``; both values are
|
|
``None`` when progress is too low or there is no energy yet. Never raises.
|
|
"""
|
|
logger = logger or _LOGGER
|
|
try:
|
|
progress = float(cycle_progress or 0.0)
|
|
energy_so_far = float(energy_so_far or 0.0)
|
|
if progress < PROJECTION_MIN_PROGRESS or energy_so_far <= 0.0:
|
|
return None, None
|
|
projected_wh = ml_energy_total(
|
|
store, options, matched_duration, trace, current_program,
|
|
end_expectation_fn, logger,
|
|
)
|
|
if projected_wh is None:
|
|
projected_wh = energy_so_far / (progress / 100.0)
|
|
projected_wh = max(projected_wh, energy_so_far)
|
|
# A valid price of 0 (free/zero tariff) must yield cost 0.0, not None; only an
|
|
# absent or non-numeric price is "unknown".
|
|
try:
|
|
price_val = float(price)
|
|
except (TypeError, ValueError):
|
|
price_val = None
|
|
cost = (projected_wh / 1000.0) * price_val if price_val is not None else None
|
|
return projected_wh, cost
|
|
except Exception: # noqa: BLE001 - projection must never break estimates
|
|
return None, None
|
|
|
|
|
|
def cycle_anomaly(matched_duration: float, duration_so_far: float) -> tuple[float, str]:
|
|
"""Return ``(overrun_ratio, anomaly)`` - the soft runtime overrun signal.
|
|
|
|
``anomaly`` is ``"overrun"`` once elapsed/expected crosses
|
|
``CYCLE_OVERRUN_ANOMALY_RATIO``, else ``"none"``. Never raises.
|
|
"""
|
|
try:
|
|
expected = float(matched_duration or 0.0)
|
|
if expected <= 0.0 or duration_so_far <= 0.0:
|
|
return 0.0, "none"
|
|
ratio = duration_so_far / expected
|
|
return ratio, ("overrun" if ratio >= CYCLE_OVERRUN_ANOMALY_RATIO else "none")
|
|
except Exception: # noqa: BLE001 - anomaly signal must never break estimates
|
|
return 0.0, "none"
|