112 files

This commit is contained in:
Home Assistant Version Control
2026-09-18 15:36:46 +00:00
parent 94867222bd
commit b1fd0abc94
112 changed files with 11348 additions and 2701 deletions
@@ -21,7 +21,10 @@ Constraint: All computations must be dt-aware (robust to irregular cadence).
Constraint: Resampling must be segment-based (no interpolation across gaps).
"""
from __future__ import annotations
from dataclasses import dataclass
from collections.abc import Sequence
from typing import List, Tuple
import numpy as np
@@ -97,6 +100,195 @@ def integrate_wh(
# ─── Time-weighted energy cost (#426) ─────────────────────────────────────────
#
# A dynamic tariff moves while the appliance runs, so the single price in force
# when the cycle ended is not the price the energy was bought at. These helpers
# integrate the power trace against a piecewise-constant price timeline instead.
#
# Pure and hass-free on purpose: the manager (live cycles), the recorder backfill
# (historic cycles) and the tests all go through the same math, so a cycle costed
# live and the same cycle recosted from recorder history cannot disagree.
def compact_price_timeline(
points: Sequence[Tuple[float, float]],
*,
max_points: int = 240,
decimals: int = 6,
) -> List[Tuple[float, float]]:
"""Normalize a ``(offset_s, price)`` timeline for storage.
Sorts by offset, rounds prices, drops entries that repeat the previous price
(a tariff that did not change costs nothing to record), and coarsens to at
most ``max_points`` by keeping the entries that introduce the largest price
steps - the points that matter least to the integral go first, so the capped
timeline stays close to the uncapped one instead of being truncated at an
arbitrary time.
"""
cleaned: List[Tuple[float, float]] = []
for entry in points or []:
try:
offset = float(entry[0])
price = round(float(entry[1]), decimals)
except (TypeError, ValueError, IndexError, OverflowError):
# OverflowError: json keeps an oversized integer literal as an unbounded
# int, and float() on one of those raises rather than returning inf. An
# imported cycle's price_timeline reaches here unvalidated, and this
# function's contract is to drop a malformed entry, not to raise.
continue
if not np.isfinite(offset) or not np.isfinite(price):
# nan AND +-inf: "inf" parses out of a sensor state like any other
# float, and an infinite price makes every downstream cost infinite.
continue
cleaned.append((offset, price))
if not cleaned:
return []
cleaned.sort(key=lambda item: item[0])
deduped: List[Tuple[float, float]] = []
for offset, price in cleaned:
if deduped and deduped[-1][1] == price:
continue
deduped.append((offset, price))
if max_points > 0 and len(deduped) > max_points:
# Rank the entries by the size of the price step each one introduces and keep
# the largest. Index 0 is never a candidate - it anchors the price in force at
# cycle start. Ranked in one pass rather than removing the smallest step
# repeatedly: this also runs on the event loop (the in-memory bound in
# ``manager._append_price_sample``), and the quadratic version is what a
# pathologically chatty price entity would pay for.
ranked = sorted(
range(1, len(deduped)),
key=lambda i: abs(deduped[i][1] - deduped[i - 1][1]),
reverse=True,
)
keep = set(ranked[: max_points - 1])
keep.add(0)
deduped = [deduped[i] for i in sorted(keep)]
return deduped
def integrate_wh_by_price(
timestamps: np.ndarray,
power: np.ndarray,
price_points: Sequence[Tuple[float, float]],
*,
max_gap_s: float | None = None,
) -> List[Tuple[float, float]]:
"""Split a trace's energy across a piecewise-constant price timeline.
Returns ``[(price, wh), ...]``, one entry per price point, in timeline order.
Each trapezoid interval is charged whole to the price in force at its
*midpoint* rather than being split at the price boundary. That keeps the sum
of the returned Wh **exactly** equal to :func:`integrate_wh` over the same
inputs, which is what lets the caller apportion an external meter reading
across the segments without the two figures drifting apart. Splitting would
also break the ``max_gap_s`` outage mask: a boundary inserted inside a gap
would turn one excluded interval into two short included ones. The error is
bounded by a single sample interval per price change - seconds against a
tariff that steps hourly.
"""
try:
prices = [float(p) for _, p in price_points or []]
offsets = np.asarray([float(o) for o, _ in price_points or []], dtype=float)
except (TypeError, ValueError, OverflowError):
# Same JSON boundary as compact_price_timeline above, which already drops
# an entry for exactly these reasons: an imported or hand-edited
# price_timeline keeps an oversized integer literal as an unbounded int,
# and float() on one raises rather than returning inf. Every in-repo
# caller compacts first, so this only binds a direct caller - and for one
# of those "unusable timeline" means no timeline, which is the empty
# return cycle_cost below already falls back on. Returning a zero Wh per
# price instead would claim the timeline was fine and the trace carried no
# energy, a different statement that only lands on the same fallback by
# accident.
return []
if not prices:
return []
ts = np.asarray(timestamps, dtype=float)
pw = np.asarray(power, dtype=float)
if ts.size < 2 or pw.size != ts.size:
return [(price, 0.0) for price in prices]
dt_hours = np.diff(ts) / 3600.0
avg_power = (pw[:-1] + pw[1:]) * 0.5
energy = avg_power * dt_hours
if max_gap_s is not None:
mask = (dt_hours > 0) & (dt_hours <= float(max_gap_s) / 3600.0)
energy = np.where(mask, energy, 0.0)
midpoints = (ts[:-1] + ts[1:]) * 0.5
# side="right" - 1 gives the last price point at or before the midpoint.
# Clipped at 0 so a trace that starts before the first price point is charged
# at that first price rather than dropped.
idx = np.clip(np.searchsorted(offsets, midpoints, side="right") - 1, 0, len(prices) - 1)
totals = np.bincount(idx, weights=energy, minlength=len(prices))
return [(prices[i], float(totals[i])) for i in range(len(prices))]
def cycle_cost(
timestamps: np.ndarray,
power: np.ndarray,
price_points: Sequence[Tuple[float, float]],
*,
max_gap_s: float | None = None,
report_wh: float | None = None,
) -> Tuple[float, float] | None:
"""Time-weighted cost of a cycle, and the effective price per kWh it implies.
``report_wh`` is the user-facing energy figure when it differs from the
integral - i.e. an external meter's start->end delta (issue #316). The
segments are scaled so they sum to it, which apportions the meter's total by
the shape of the power trace and guarantees ``cost == report_wh/1000 *
effective_price``. Without that, the cost shown next to a kWh figure would be
computed from a different amount of energy than the kWh figure itself.
Returns ``None`` when the trace carries no energy to charge for, or when
``report_wh`` is given but is not a usable positive figure, so the caller can
fall back to the single-price behaviour instead of reporting a false zero or a
cost that describes a different amount of energy than the kWh beside it.
"""
segments = integrate_wh_by_price(timestamps, power, price_points, max_gap_s=max_gap_s)
if not segments:
return None
integrated = sum(wh for _, wh in segments)
if integrated <= 0:
return None
scale = 1.0
if report_wh is not None:
try:
report = float(report_wh)
# OverflowError alongside the rest, as in compact_price_timeline above: an
# imported or hand-edited record keeps an oversized integer literal as an
# unbounded int, and float() on one raises instead of returning inf. This
# function's contract is to hand the decision back, not to raise.
except (TypeError, ValueError, OverflowError):
return None
if not np.isfinite(report) or report <= 0:
# The caller asked for the cost of *this* figure. Costing the trace
# integral instead would print a price beside a 0 kWh readout, so hand
# the decision back rather than answer a question that was not asked.
return None
scale = report / integrated
cost = sum(wh * scale / 1000.0 * price for price, wh in segments)
# Finite inputs can still leave the finite range: a price near the float
# ceiling against a multi-kWh trace overflows to inf, and a subnormal
# `report_wh` (5e-324 passes every check above) makes `scale` underflow, so
# the charged energy rounds to exactly 0.0 and the division raises. Both end
# in a non-finite or undefined cost, which is what the caller's fallback is
# for, so hand the decision back rather than publish one.
charged_kwh = integrated * scale / 1000.0
if not np.isfinite(cost) or charged_kwh <= 0.0:
return None
effective_price = cost / charged_kwh
if not np.isfinite(effective_price):
return None
return cost, effective_price
def resample_uniform(
timestamps: np.ndarray, power: np.ndarray, dt_s: float = 5.0, gap_s: float = 60.0
) -> List[Segment]: