187 files

This commit is contained in:
Home Assistant Version Control
2026-10-09 11:57:31 +00:00
parent 28fa34d78a
commit 1e1b12d52b
187 changed files with 40899 additions and 32654 deletions
+333 -198
View File
@@ -18,7 +18,7 @@
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
Playground's headless replay (``playground.py``) 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
@@ -34,6 +34,7 @@ import logging
import math
from dataclasses import dataclass
from datetime import datetime
from operator import le as _le
from typing import Any, cast
import numpy as np
@@ -41,19 +42,23 @@ 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 .profile_store import _envelope_y, 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
# Minimum progress before an energy projection is shown. 10, not 3 (audit
# PROGRESS-04): at 3% both divisors were off by 77-101% MAPE over 670 LOO folds
# (devtools/energy_projection_eval.py), at 10% the energy-share divisor is 50%.
PROJECTION_MIN_PROGRESS = 10.0
# Floor on the matched profile's cumulative-energy share used as the projection
# divisor: below it the curve's start is noise and the division explodes.
PROJECTION_MIN_ENERGY_FRACTION = 0.05
# The progress EMA weights below are per *estimate*, and were chosen against the
# manager's 5 s estimate throttle. See :func:`_dt_scaled_alpha`.
@@ -62,6 +67,9 @@ SMOOTHING_NOMINAL_DT_S = 5.0
# Cache type for profile_end_expectation: (profile_name, base_expectation_dict).
EndExpCache = tuple[str, dict[str, float]] | None
# How many of a profile's most recent traces its end expectation is taken from.
_END_EXPECTATION_CYCLES = 20
@dataclass
class ProgressResult:
@@ -93,14 +101,24 @@ def profile_end_expectation(
else:
from .ml.feature_extraction import profile_expectation
# The 20 most recent non-empty traces, oldest first - walked newest-first
# and stopped there, so a long history is not decompressed just to be
# thrown away (49-248 ms on the largest corpus profiles, on the event
# loop, once per cycle start).
cycles = store.get_past_cycles() or []
if not isinstance(cycles, (list, tuple)):
cycles = list(cycles)
points_list: list[list[tuple[float, float]]] = []
for cycle in store.get_past_cycles():
for cycle in reversed(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 len(points_list) >= _END_EXPECTATION_CYCLES:
break
points_list.reverse()
base = profile_expectation(points_list)
if base is None:
return None, cache
cache = (profile_name, dict(base))
@@ -113,61 +131,6 @@ def profile_end_expectation(
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,
@@ -178,8 +141,12 @@ def ml_energy_total(
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.
regressor, or None. Never raises.
``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.
"""
logger = logger or _LOGGER
try:
@@ -225,37 +192,13 @@ def ml_energy_total(
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,
quiet_threshold_w: float = 0.0,
) -> tuple[float, float] | None:
"""Estimate cycle progress by analyzing which phase we're in.
_PHASE_ENVELOPE_CACHE: dict[tuple[Any, int, Any], tuple[Any, tuple[dict[str, Any], Any, float]]] = {}
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.
``quiet_threshold_w`` is the detector's own off-noise floor
(``CycleDetectorConfig.stop_threshold_w``, itself derived from the configured
minimum power). A window that never rises above it is *not* the appliance
doing something, so it carries no phase information and the scan declines
rather than guessing (#386); a dead-flat window declines for the same reason
at any power level. The default 0.0 leaves only the flatness rule for callers
that do not know the floor.
"""
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
def _parse_phase_envelope(
envelope: dict[str, Any], profile_name: str, logger: logging.Logger
) -> tuple[dict[str, Any], Any, float] | None:
"""``(arrays, time_grid, target_duration)`` of a stored envelope, read-only."""
try:
env_min = envelope.get("min", [])
env_max = envelope.get("max", [])
@@ -287,9 +230,124 @@ def estimate_phase_progress(
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:
except (KeyError, ValueError, TypeError, IndexError, OverflowError) as e:
logger.warning("Invalid envelope format for %s: %s", profile_name, e)
return None
for _arr in (*envelope_arrays.values(), time_grid):
_arr.setflags(write=False)
return envelope_arrays, time_grid, target_duration
def _window_values(
power_data: Any, window_s: float
) -> np.ndarray[Any, np.dtype[np.float64]] | None:
"""Powers of the trailing ``window_s`` of a ``(datetime, power)`` trace.
Exactly what ``power_data_to_offsets`` + the ``offsets >= last - window``
mask in :func:`estimate_phase_progress` select (same anchor, same 0.1 s
rounding, same skipped rows), without converting the whole trace. Only the
``datetime`` format the detector hands out takes this path, and only when
its timestamps never go backwards: then everything before the first row
that falls out of the window is out of it too. Anything else returns None
and the caller converts the whole trace as before.
"""
try:
if not isinstance(power_data, (list, tuple)) or not power_data:
return None
first = power_data[0]
if not (
isinstance(first, (list, tuple))
and len(first) >= 2
and isinstance(first[0], datetime)
):
return None
stamps = [row[0] for row in power_data]
if not all(map(_le, stamps, stamps[1:])):
return None
def _row(row: Any) -> tuple[datetime, float] | None:
# The same rows `power_data_to_offsets` keeps (and the same order of
# checks, so the same one anchors the offsets).
try:
ts = row[0]
if not isinstance(ts, datetime):
return None
return ts, float(row[1])
except (TypeError, ValueError, AttributeError, IndexError, OverflowError):
return None
anchor: float | None = None
for row in power_data:
kept = _row(row)
if kept is not None:
anchor = kept[0].timestamp()
break
if anchor is None:
return np.array([], dtype=float)
window_start: float | None = None
tail: list[float] = []
for row in reversed(power_data):
kept = _row(row)
if kept is None:
continue
offset = round(kept[0].timestamp() - anchor, 1)
if window_start is None:
window_start = max(0, offset - window_s)
if offset < window_start:
break
tail.append(kept[1])
tail.reverse()
return np.array(tail)
except Exception: # pylint: disable=broad-exception-caught
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,
quiet_threshold_w: float = 0.0,
) -> 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.
``quiet_threshold_w`` is the detector's own off-noise floor
(``CycleDetectorConfig.stop_threshold_w``, itself derived from the configured
minimum power). A window that never rises above it is *not* the appliance
doing something, so it carries no phase information and the scan declines
rather than guessing (#386); a dead-flat window declines for the same reason
at any power level. The default 0.0 leaves only the flatness rule for callers
that do not know the floor.
"""
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
# Parse the stored lists into arrays once per envelope build, not on every
# 5 s estimate (audit PERF-06: ~20% of a 17 ms call, on the event loop).
# Keyed on the envelope object and its `updated` stamp; arrays are read-only.
_key = (profile_name, id(envelope), envelope.get("updated"))
_hit = _PHASE_ENVELOPE_CACHE.get(_key)
if _hit is not None and _hit[0] is envelope:
_parsed = _hit[1]
else:
_parsed = _parse_phase_envelope(envelope, profile_name, logger)
if _parsed is None:
return None
if len(_PHASE_ENVELOPE_CACHE) > 32:
_PHASE_ENVELOPE_CACHE.clear()
# The envelope itself is held, so its id cannot be recycled while cached.
_PHASE_ENVELOPE_CACHE[_key] = (envelope, _parsed)
envelope_arrays, time_grid, target_duration = _parsed
if len(time_grid) == 0 or target_duration <= 0:
if target_duration > 0 and len(envelope_arrays["avg"]) > 0:
@@ -305,23 +363,33 @@ def estimate_phase_progress(
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)
# Only the last `window_duration` seconds of the trace are read, so convert
# only those (audit PROGRESS-17: the whole trace was converted on every 5 s
# estimate, on the event loop). `_window_values` returns exactly what the
# full conversion + time mask selects, or None to take that full path.
_windowed = _window_values(current_power_data, window_duration)
if _windowed is 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
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]
window_mask = current_offsets >= window_start_time
current_window_values = current_values[window_mask]
elif _windowed.size == 0:
logger.debug("No valid current power offsets, cannot estimate phase")
return None
else:
current_window_values = _windowed
if len(current_window_values) < 3:
logger.debug("Insufficient data in current window for phase estimation")
@@ -605,7 +673,7 @@ def _dt_scaled_alpha(alpha: float, dt_s: float | None) -> float:
alpha_dt = 1 - (1 - alpha) ** (dt / SMOOTHING_NOMINAL_DT_S)
``dt_s`` of ``None`` (or <= 0) keeps the nominal weight, so every caller that
does not track its own cadence - and the golden snapshot - is unchanged.
does not track its own cadence (and the Playground replay) is unchanged.
"""
if dt_s is None or not math.isfinite(dt_s) or dt_s <= 0.0:
return alpha
@@ -615,23 +683,36 @@ def _dt_scaled_alpha(alpha: float, dt_s: float | None) -> float:
return 1.0 - (1.0 - alpha) ** steps
def ema_seed(
prev_smoothed: float, prev_program: str | None, program: str | None
) -> float:
"""The EMA state an estimate for ``program`` continues from (audit PROGRESS-09).
A programme switch or a pin re-seeds to 0.0 (a cold start, i.e. the raw
estimate for the new programme). Carrying the old percent onto the new
duration read 62-67 min against a 90 min truth and took up to 12 min to
settle; an honest backwards jump at a switch is the correct information.
"""
if prev_program is not None and program != prev_program:
return 0.0
return prev_smoothed
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,
dt_seconds: float | None = None,
) -> ProgressResult | None:
"""The blend + EMA + monotonicity + back-calculation body of the estimate loop.
"""The 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
:func:`estimate_phase_progress`, or ``None`` to force the linear fallback);
the live manager and the Playground compute it via the same function, 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``.
"""
@@ -643,10 +724,6 @@ def _compute_progress_base(
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:
@@ -665,8 +742,12 @@ def _compute_progress_base(
smoothing_threshold = DEVICE_SMOOTHING_THRESHOLDS.get(device_type, 5.0)
if phase_progress < current_smoothed - smoothing_threshold:
# Backward step: damping here exists to resist regression, not to
# track, so it stays per-estimate (unscaled) on purpose.
smoothed = (current_smoothed * 0.95) + (phase_progress * 0.05)
# track. It is still a time constant, not a step count (audit
# PROGRESS-13): per estimate, the Playground's 30 s steps (and a
# plug reporting every 30 s live) gave way to a real drop 6x
# slower than a 5 s plug. dt=None keeps the plain 95/5 step.
beta = _dt_scaled_alpha(0.05, dt_seconds)
smoothed = (current_smoothed * (1.0 - beta)) + (phase_progress * beta)
logger.debug(
"Progress drop detected (%.1f%% < %.1f%% - %.1f%%), "
"applying heavy damping for %s",
@@ -680,10 +761,22 @@ def _compute_progress_base(
smoothed = (prev_smoothed * (1.0 - alpha)) + (phase_progress * alpha)
smoothed = min(99.0, smoothed)
if duration_so_far >= matched_duration and prev_smoothed > smoothed:
# Past the expected end the cycle is finishing, not going backwards.
# In an overrun tail the phase scan declines on quiet windows, so the
# branches alternate: the linear one reaches 100%, then the next phase
# estimate's backward step (and its 99% cap) pulled the shown progress
# back to ~97% (audit PROGRESS-13 follow-up). Hold what was shown.
smoothed = prev_smoothed
progress = smoothed
remaining = matched_duration * (1.0 - (progress / 100.0))
remaining = max(0.0, remaining)
if duration_so_far >= matched_duration:
# Overrun: the 99% cap would pin remaining at 1% of the profile for as
# long as the run lasts, re-arming the live chronometer "now + 36 s"
# every tick (audit PROGRESS-06). The linear branch already says 0.
remaining = 0.0
total = duration_so_far + remaining
logger.debug(
@@ -699,18 +792,16 @@ def _compute_progress_base(
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:
lin_alpha = _dt_scaled_alpha(0.1, dt_seconds)
smoothed = (prev_smoothed * (1.0 - lin_alpha)) + (progress * lin_alpha)
else:
smoothed = progress
progress = max(0.0, min(smoothed, 100.0))
# Clamped in the carried state too: unclamped, a run past a short mis-match
# carried 146% into the correct longer programme (audit PROGRESS-09).
smoothed = max(0.0, min(smoothed, 100.0))
progress = smoothed
remaining = max(matched_dur * (1.0 - progress / 100.0), 0.0)
total = duration_so_far + remaining
logger.debug(
@@ -727,83 +818,74 @@ def compute_progress(
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,
dt_seconds: 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(
"""Progress/remaining estimate: the one entry point for the manager and the
Playground replay (the phase-resolved ETA blend that used to sit here was
removed, audit PROGRESS-01/02: it never ran in production, and revived it was
10% worse at 25% on washers)."""
return _compute_progress_base(
device_type, matched_duration, duration_so_far, prev_smoothed,
phase_result, ml_pct, logger, dt_seconds,
)
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",
phase_result, logger, dt_seconds,
)
def phase_timeline_span(
ranges: list[dict[str, Any]], expected_duration: float | None
) -> float:
"""Seconds the progress fraction maps onto: ``max(last range end, expected)``.
Phase ranges are minutes into the programme, so a profile that marks only
Wash 0-30 / Rinse 30-60 on a 100 min programme reads Rinse at minute 45 and
no phase at minute 80 (audit PROGRESS-10). Stretching the ranges over the
whole cycle (the old scale, the last range end) named Wash at 45%. Ranges
that run past the expected duration keep their own end. 0.0 when unusable.
"""
span = max((float(r.get("end") or 0.0) for r in ranges), default=0.0)
try:
expected = float(expected_duration or 0.0)
except (TypeError, ValueError, OverflowError):
expected = 0.0
if math.isfinite(expected) and expected > span:
span = expected
return span if math.isfinite(span) and span > 0.0 else 0.0
def phase_at(
ranges: list[dict[str, Any]], position_s: float, span_s: float
) -> str | None:
"""The range containing ``position_s``: ``[start, end)``, the timeline's own
end included. None in a gap or past every range - no nearest-phase guess.
The panel's Status timeline applies the same rule."""
at_end = position_s >= span_s
for r in sorted(ranges, key=lambda x: float(x.get("start") or 0.0)):
start = float(r.get("start") or 0.0)
end = float(r.get("end") or 0.0)
if end <= start:
continue
if start <= position_s < end or (at_end and end >= span_s and start <= position_s):
name = str(r.get("name") or "").strip()
return name or None
return None
def current_phase(
store: Any,
state: str,
current_program: str | None,
cycle_progress: float,
expected_duration: float | None = None,
) -> str | None:
"""Live phase from the profile's configured ranges + ML-blended progress.
"""Live phase from the profile's configured ranges + the smoothed 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.
overrun/underrun cycles still name the phase correctly; the fraction maps onto
:func:`phase_timeline_span` (the matched profile's ``expected_duration`` unless
the ranges run longer), so ranges are read at their real minutes. Returns
``None`` when not running, no profile is matched, the profile has no phase
ranges, or no range covers this point (audit PROGRESS-11: no guessed phase).
Never raises.
"""
try:
if state not in (STATE_RUNNING, STATE_PAUSED, STATE_ENDING):
@@ -814,15 +896,63 @@ def current_phase(
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:
span = phase_timeline_span(ranges, expected_duration)
if span <= 0.0:
return None
frac = max(0.0, min(1.0, float(cycle_progress) / 100.0))
return store.check_phase_match(profile, frac * nominal)
return phase_at(ranges, frac * span, span)
except Exception: # noqa: BLE001 - phase readout must never break
return None
_ENERGY_CURVES: dict[tuple[str, int, Any], tuple[Any, tuple[np.ndarray, np.ndarray] | None]] = {}
def envelope_energy_fraction(
store: Any, program: str | None, progress_pct: float
) -> float | None:
"""Share of the matched profile's energy used by ``progress_pct`` (audit PROGRESS-04).
The cumulative integral of the envelope's ``avg`` curve, read at the same
fraction of its time grid. Energy does not accrue linearly in time - heaters
front-load it - so ``energy / time_fraction`` projected washers 1.89x too high
at 25%. None without a usable envelope (the caller falls back to that).
"""
if not program or store is None:
return None
try:
env = store.get_envelope(program)
except Exception: # noqa: BLE001 - a projection input, never fatal
return None
if not isinstance(env, dict):
return None
key = (program, id(env), env.get("updated"))
hit = _ENERGY_CURVES.get(key)
if hit is None or hit[0] is not env:
if len(_ENERGY_CURVES) > 64:
_ENERGY_CURVES.clear()
curve = None
try:
tg = np.asarray(env.get("time_grid") or [], dtype=float)
avg = _envelope_y(env.get("avg"))
if tg.size >= 2 and avg.size == tg.size and np.all(np.isfinite(avg)):
cum = np.concatenate(
([0.0], np.cumsum(np.diff(tg) * (avg[1:] + avg[:-1]) / 2.0))
)
if cum[-1] > 0 and tg[-1] > tg[0]:
curve = (tg, cum / cum[-1])
except (TypeError, ValueError, OverflowError):
curve = None
# The envelope itself is held, so its id cannot be recycled while cached.
_ENERGY_CURVES[key] = (env, curve)
curve = _ENERGY_CURVES[key][1]
if curve is None:
return None
tg, frac = curve
x = tg[0] + (tg[-1] - tg[0]) * min(max(float(progress_pct) / 100.0, 0.0), 1.0)
return max(float(np.interp(x, tg, frac)), PROJECTION_MIN_ENERGY_FRACTION)
def projected_energy(
store: Any,
options: Any,
@@ -839,8 +969,10 @@ def projected_energy(
) -> 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
Prefers the on-device ``total_energy`` regressor; otherwise divides
``energy_so_far`` by the matched profile's cumulative-energy share at this
progress (:func:`envelope_energy_fraction`), and by the time fraction only
when the profile has no usable envelope. Returns ``(wh, cost)``; both values are
``None`` when progress is too low or there is no energy yet. Never raises.
``cost_so_far`` is the dynamic-tariff cost already incurred (#426): the energy
@@ -868,13 +1000,16 @@ def projected_energy(
end_expectation_fn, logger,
)
if projected_wh is None:
projected_wh = energy_so_far / (progress / 100.0)
fraction = envelope_energy_fraction(store, current_program, progress)
projected_wh = energy_so_far / (
fraction if fraction is not None else 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):
except (TypeError, ValueError, OverflowError):
price_val = None
if price_val is None:
cost = None
@@ -885,7 +1020,7 @@ def projected_energy(
if cost_so_far_wh is not None:
try:
charged_wh = float(cost_so_far_wh)
except (TypeError, ValueError):
except (TypeError, ValueError, OverflowError):
charged_wh = energy_so_far
remaining_wh = max(0.0, projected_wh - charged_wh)
cost = float(cost_so_far) + (remaining_wh / 1000.0) * price_val