348 files

This commit is contained in:
Home Assistant Version Control
2026-07-23 19:02:12 +00:00
parent 0ed1687503
commit 016af2e013
348 changed files with 12368 additions and 11689 deletions
+89 -86
View File
@@ -3081,9 +3081,16 @@ class ProfileStore:
separate ``reference_cycles`` list so imported cycles never enter usage stats.
"""
dest = self._data["past_cycles"] if target is None else target
# Generate SHA256 ID
# Generate SHA256 ID — dedup suffix avoids collisions when two cycles share
# an identical raw start_time + duration (e.g. bulk reference-cycle imports).
existing_ids = {c.get("id") for c in dest if isinstance(c, dict)}
unique_str = f"{cycle_data['start_time']}_{cycle_data['duration']}"
cycle_data["id"] = hashlib.sha256(unique_str.encode()).hexdigest()[:12]
candidate = hashlib.sha256(unique_str.encode()).hexdigest()[:12]
suffix = 0
while candidate in existing_ids:
suffix += 1
candidate = hashlib.sha256(f"{unique_str}_{suffix}".encode()).hexdigest()[:12]
cycle_data["id"] = candidate
# Preserve profile_name if already set by manager; default to None otherwise
if "profile_name" not in cycle_data:
@@ -4814,63 +4821,6 @@ class ProfileStore:
is_prefix_ambiguous=is_prefix_ambiguous,
)
def match_profile(
self, power_data: list[tuple[str, float]], duration: float
) -> MatchResult:
"""Synchronous wrapper for matching (for use in executor tasks)."""
# Convert to list for worker
p_list = [p[1] for p in power_data]
# Prepare snapshots safely
snapshots: list[dict[str, Any]] = []
# Accessing self._data in thread is generally safe for reads if not modifying
for name, profile in self._data["profiles"].items():
sample_id = profile.get("sample_cycle_id")
sample_cycle = next((c for c in self._data["past_cycles"] if c["id"] == sample_id), None)
if not sample_cycle:
continue
# Decompress sample data
sample_p_data = decompress_power_data(sample_cycle)
if not sample_p_data:
continue
snapshots.append({
"name": name,
"avg_duration": profile.get("avg_duration", sample_cycle.get("duration", 0)),
"sample_power": [x[1] for x in sample_p_data],
})
config = {
"min_duration_ratio": self._min_duration_ratio,
"max_duration_ratio": self._max_duration_ratio,
"dtw_bandwidth": self.dtw_bandwidth,
# On-device tuned scoring weights (opt-in); empty = shipped defaults.
**self._matching_overrides(),
}
candidates = analysis.compute_matches_worker(
p_list, duration, cast(Any, snapshots), config
)
if not candidates:
return MatchResult(None, 0.0, 0.0, None, [], False, 0.0)
best = candidates[0]
margin, is_ambiguous = _ambiguity_from_candidates(candidates)
return MatchResult(
best["name"],
best["score"],
best["profile_duration"],
None,
candidates,
is_ambiguous,
margin,
ranking=candidates,
)
async def async_verify_alignment(
self,
profile_name: str,
@@ -4952,23 +4902,34 @@ class ProfileStore:
if not phases:
return None
# Guard against non-dict entries (legacy/malformed storage) and
# None/non-numeric start values before sorting.
phases_sorted = sorted(
phases,
key=lambda p: float(p.get("start", 0)),
[p for p in phases if isinstance(p, dict)],
key=lambda p: float(p.get("start") or 0),
)
last_matched: str | None = None
for phase in phases_sorted:
p_start = phase.get("start", 0)
p_end = phase.get("end", 0)
p_start = float(phase.get("start") or 0)
p_end = float(phase.get("end") or 0)
if p_end <= p_start:
# Missing or zero end — phase range is invalid; skip silently.
continue
if p_start <= duration <= p_end:
return str(phase.get("name", "Unknown"))
if p_start <= duration:
# Track the last phase whose start we have passed; used for gap fallback.
last_matched = str(phase.get("name", "Unknown"))
# If duration is outside explicit bounds, keep a phase label anyway so
# entities avoid falling back to generic running/starting states.
# Duration is outside explicit bounds keep a label so entities avoid
# falling back to generic running/starting states.
if phases_sorted:
if duration < float(phases_sorted[0].get("start", 0)):
if duration < float(phases_sorted[0].get("start") or 0):
return str(phases_sorted[0].get("name", "Unknown"))
return str(phases_sorted[-1].get("name", "Unknown"))
# Return the phase that just ended (last_matched) when in a gap, or the
# final phase when past all ranges, rather than always the absolute last.
return last_matched or str(phases_sorted[-1].get("name", "Unknown"))
return None
@@ -5835,6 +5796,19 @@ class ProfileStore:
cycle["sampling_interval"] = round(sampling_interval, 1)
cycle["duration"] = new_duration
# Recompute energy and max_power from the trimmed trace so stored stats
# reflect the kept window (not the pre-trim full cycle).
ts_s = np.array([r[0] for r in renorm], dtype=float)
p_s = np.array([r[1] for r in renorm], dtype=float)
if len(ts_s) > 1:
cycle["energy_wh"] = round(
integrate_wh(ts_s, p_s, max_gap_s=energy_gap_threshold_s(ts_s)), 3
)
cycle["max_power"] = round(float(np.max(p_s)), 1)
elif len(ts_s) == 1:
cycle["energy_wh"] = 0.0
cycle["max_power"] = round(float(p_s[0]), 1)
# Keep end_time consistent with the updated start_time and duration
new_start_ts = _value_to_timestamp(cycle.get("start_time"))
if new_start_ts is not None:
@@ -5986,38 +5960,55 @@ class ProfileStore:
return []
cycle = cycles[idx]
cycles.pop(idx) # Remove original
new_ids: list[str] = []
original_profile = cycle.get("profile_name")
# Validate before mutating — early returns below this point would otherwise
# permanently delete the source cycle from the live list.
start_dt_base_parsed = _parse_start_dt(cycle["start_time"])
if not start_dt_base_parsed:
return []
start_ts = start_dt_base_parsed.timestamp()
# Decompress original data
p_data_tuples = decompress_power_data(cycle)
if not p_data_tuples:
return []
# Pre-materialise all segment bounds before touching history; a malformed
# segment that raises after the pop would leave the source cycle permanently
# deleted with only partial children created.
try:
materialized_segs: list[tuple[float, float, str | None]] = []
for seg in segments:
if isinstance(seg, (list, tuple)):
seg_s = float(seg[0])
seg_e = float(seg[1])
seg_p: str | None = None
else:
seg_s = float(seg["start"])
seg_e = float(seg["end"])
seg_p = seg.get("profile")
if seg_e <= seg_s:
return []
materialized_segs.append((seg_s, seg_e, seg_p))
except (TypeError, ValueError, KeyError, IndexError):
return []
# A split into fewer than two pieces is not a real split; guard here so
# an empty or single-element segments list can't pop the source cycle and
# leave zero or one orphaned children.
if len(materialized_segs) < 2:
return []
cycles.pop(idx) # Remove original — all segments validated, safe to mutate
new_ids: list[str] = []
original_profile = cycle.get("profile_name")
start_ts = start_dt_base_parsed.timestamp()
# Prepare points (relative seconds)
points: list[tuple[float, float]] = []
for offset_seconds, val in p_data_tuples:
points.append((float(offset_seconds), float(val)))
# Create new cycles
for seg in segments:
if isinstance(seg, (list, tuple)):
seg_tuple = cast(tuple[Any, ...] | list[Any], seg)
seg_start = float(seg_tuple[0])
seg_end = float(seg_tuple[1])
seg_profile = None
else:
seg_start = float(seg["start"])
seg_end = float(seg["end"])
seg_profile = seg.get("profile")
for seg_start, seg_end, seg_profile in materialized_segs:
seg_dur = seg_end - seg_start
new_cycle_start = start_dt_base_parsed + timedelta(seconds=seg_start)
new_cycle_start_ts = new_cycle_start.timestamp()
@@ -6051,14 +6042,14 @@ class ProfileStore:
"power_data": p_data_abs,
"profile_name": seg_profile
}
self.add_cycle(new_cycle)
await self.async_add_cycle(new_cycle)
new_ids.append(new_cycle["id"])
# Fix profile refs (handle original sample cycle logic)
original_sample_id = cycle.get("id")
best_replacement_id = None
longest_dur = 0
new_cycles_objs = [c for c in cycles if c["id"] in new_ids] # 'cycles' is mutated by add_cycle
new_cycles_objs = [c for c in cycles if c["id"] in new_ids]
for c in new_cycles_objs:
d = c.get("duration", 0)
@@ -6239,6 +6230,18 @@ class ProfileStore:
c1["power_data"] = new_power_data
# Recompute energy from the merged trace (merge keeps only c1's original
# energy_wh which under-represents the combined cycle).
try:
ts_s = np.array([pt[0] for pt in new_power_data], dtype=float)
p_s = np.array([pt[1] for pt in new_power_data], dtype=float)
if len(ts_s) > 1:
c1["energy_wh"] = round(
integrate_wh(ts_s, p_s, max_gap_s=energy_gap_threshold_s(ts_s)), 3
)
except Exception: # pylint: disable=broad-exception-caught
self._logger.warning("Energy recompute failed after cycle merge", exc_info=True)
# New Hash ID
new_id = hashlib.sha256(f"{c1['start_time']}_{c1['duration']}".encode()).hexdigest()[:12]
old_c1_id = c1["id"]