This commit is contained in:
2026-06-14 02:01:49 -04:00
parent 16c1da4289
commit dcc2524785
132 changed files with 136000 additions and 4 deletions
+275
View File
@@ -0,0 +1,275 @@
"""Unified time/power-data utilities for WashData.
Canonical storage format for power_data: ``[[offset_seconds, power], ...]``
where ``offset_seconds`` is a float relative to the cycle's ``start_time``.
All helpers in this module accept *any* of the three in-flight formats and
normalise them to the canonical form so consumers never need to guess.
Formats recognised:
- ``(datetime, float)`` live trace from CycleDetector internals
- ``(iso_str, float)`` legacy on-disk format (pre-offset era)
- ``[offset_float, float]`` current canonical on-disk format
"""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any, Literal, cast
import homeassistant.util.dt as dt_util
_LOGGER = logging.getLogger(__name__)
# Type aliases
PowerPoint = list[Any] | tuple[Any, ...]
PowerData = list[PowerPoint]
def detect_power_data_format(
power_data: PowerData,
) -> Literal["offset", "iso", "datetime", "empty", "unknown", "unix_timestamp"]:
"""Identify which format a power_data list is in.
Returns one of: ``"offset"``, ``"iso"``, ``"datetime"``, ``"empty"``,
``"unknown"``, ``"unix_timestamp"``.
"""
if not power_data:
return "empty"
ts = None
for sample in power_data:
if isinstance(sample, (list, tuple)) and len(sample) >= 2 and sample[0] is not None:
ts = sample[0]
break
if ts is None:
return "unknown"
if isinstance(ts, datetime):
return "datetime"
if isinstance(ts, str):
return "iso"
if isinstance(ts, (int, float)):
# Values > 1e8 (≈ 3+ years of seconds) are absolute Unix epoch timestamps,
# not relative offsets. Treat them differently so we can subtract start_time.
if float(ts) > 1e8:
return "unix_timestamp"
return "offset"
return "unknown"
def power_data_to_offsets(
power_data: PowerData,
start_time_iso: str | None = None,
) -> list[list[float]]:
"""Normalise *any* power_data format to ``[[offset_sec, power], ...]``.
Args:
power_data: Input list in any recognised format.
start_time_iso: ISO-8601 cycle start time string. Required when
converting from the ISO-string format so that offsets can be
computed. When converting from datetime format, used as the anchor
if provided; falls back to the first sample's timestamp otherwise.
Ignored for offset format.
Returns:
List of ``[offset_seconds, power]`` pairs. Empty list on failure.
"""
if not power_data:
return []
fmt = detect_power_data_format(power_data)
if fmt == "unix_timestamp":
# Absolute Unix epoch floats - subtract cycle start to get relative offsets.
base_ts: float | None = None
if start_time_iso:
try:
parsed_start = dt_util.parse_datetime(start_time_iso)
if parsed_start is not None:
base_ts = parsed_start.timestamp()
except (ValueError, OSError) as e:
_LOGGER.debug("Failed to parse start_time_iso %s: %s", start_time_iso, e)
result: list[list[float]] = []
for item in power_data:
try:
ts_abs = float(item[0])
p = float(item[1])
if base_ts is None:
base_ts = ts_abs # use first reading as anchor
offset = round(ts_abs - base_ts, 1)
result.append([max(0.0, offset), p])
except (TypeError, ValueError, IndexError):
continue
return result
if fmt == "offset":
# Already canonical return a clean list of [float, float]
result: list[list[float]] = []
for item in power_data:
try:
result.append([float(item[0]), float(item[1])])
except (TypeError, ValueError, IndexError):
continue
return result
if fmt == "datetime":
start_ts: float | None = None
if start_time_iso:
try:
parsed_start = dt_util.parse_datetime(start_time_iso)
if parsed_start is not None:
start_ts = parsed_start.timestamp()
except (ValueError, OSError) as e:
_LOGGER.debug("Failed to parse datetime %s: %s", start_time_iso, e)
result: list[list[float]] = []
for item in power_data:
try:
ts_raw = item[0]
if not isinstance(ts_raw, datetime):
continue
p = float(item[1])
ts = ts_raw
if start_ts is None:
start_ts = ts.timestamp()
result.append([round(ts.timestamp() - start_ts, 1), p])
except (TypeError, ValueError, AttributeError, IndexError):
continue
return result
if fmt == "iso":
# We need start_time to compute offsets
base_ts: float | None = None
if start_time_iso:
try:
parsed = dt_util.parse_datetime(start_time_iso)
if parsed is None:
return []
base_ts = parsed.timestamp()
except (ValueError, OSError) as e:
_LOGGER.debug("Failed to parse datetime %s: %s", start_time_iso, e)
return []
result: list[list[float]] = []
first_ts: float | None = None
for item in power_data:
try:
ts_raw = item[0]
if not isinstance(ts_raw, str):
continue
p = float(item[1])
parsed_ts = dt_util.parse_datetime(ts_raw)
if parsed_ts is None:
continue
t_val = parsed_ts.timestamp()
if base_ts is not None:
offset = round(t_val - base_ts, 1)
else:
# Fallback: use first reading as zero reference
if first_ts is None:
first_ts = t_val
_LOGGER.warning(
"power_data_to_offsets: start_time_iso missing/invalid; "
"shifting timestamps to first sample as zero reference "
"(first sample: %s, total samples: %d)",
ts_raw,
len(power_data),
)
offset = round(t_val - first_ts, 1)
if offset < 0:
_LOGGER.debug(
"power_data_to_offsets: clamping negative offset %.1f to 0 "
"(power=%.1f, index=%d)",
offset, p, len(result),
)
result.append([max(0.0, offset), p])
except (TypeError, ValueError, AttributeError, IndexError):
continue
return result
_LOGGER.debug("power_data_to_offsets: unrecognised format, returning empty")
return []
def power_data_offsets_to_datetimes(
power_data: PowerData,
start_time_iso: str,
) -> list[tuple[datetime, float]]:
"""Convert stored ``[[offset_sec, power], ...]`` to ``[(datetime, power), ...]``.
Args:
power_data: Offset-format power data.
start_time_iso: ISO-8601 cycle start time.
Returns:
List of ``(datetime, power)`` tuples. Empty list on failure.
"""
try:
start_dt = dt_util.parse_datetime(start_time_iso)
if start_dt is None:
return []
start_ts = start_dt.timestamp()
except Exception: # pylint: disable=broad-exception-caught
return []
result: list[tuple[datetime, float]] = []
for item in power_data:
try:
offset = float(item[0])
p = float(item[1])
ts = datetime.fromtimestamp(start_ts + offset, tz=start_dt.tzinfo)
result.append((ts, p))
except (TypeError, ValueError, IndexError):
continue
return result
def migrate_power_data_to_offsets(cycle: dict[str, Any]) -> bool:
"""Migrate a single cycle's power_data to offset format in-place.
Detects if ``power_data`` is still in legacy ISO-string format and converts
it. Safe to call on already-converted cycles.
Returns:
``True`` if the cycle was modified, ``False`` if no change was needed.
"""
raw = cycle.get("power_data")
if not isinstance(raw, list) or not raw:
return False
raw_power_data = cast(PowerData, raw)
fmt = detect_power_data_format(raw_power_data)
if fmt in ("offset", "empty"):
return False # Already canonical
if fmt not in ("iso", "datetime", "unix_timestamp"):
_LOGGER.warning(
"migrate_power_data_to_offsets: unknown format '%s', skipping", fmt
)
return False
start_time_raw = cycle.get("start_time")
start_time_iso: str | None = (
str(start_time_raw) if isinstance(start_time_raw, str) and start_time_raw else None
)
if fmt == "iso":
if not start_time_iso:
_LOGGER.warning(
"migrate_power_data_to_offsets: missing start_time, skipping"
)
return False
if dt_util.parse_datetime(start_time_iso) is None:
_LOGGER.warning(
"migrate_power_data_to_offsets: unparsable start_time '%s', skipping",
start_time_iso,
)
return False
converted = power_data_to_offsets(raw_power_data, start_time_iso)
if not converted:
_LOGGER.warning(
"migrate_power_data_to_offsets: conversion produced empty result, skipping"
)
return False
cycle["power_data"] = converted
return True