792 lines
32 KiB
Python
792 lines
32 KiB
Python
"""Learning and self-tuning logic for WashData."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import datetime
|
|
from typing import Any, Optional, TYPE_CHECKING, cast
|
|
|
|
import numpy as np
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.helpers import translation
|
|
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
|
import homeassistant.util.dt as dt_util
|
|
|
|
from .const import (
|
|
CONF_AUTO_LABEL_CONFIDENCE,
|
|
CONF_DURATION_TOLERANCE,
|
|
CONF_END_ENERGY_THRESHOLD,
|
|
CONF_LEARNING_CONFIDENCE,
|
|
CONF_MIN_OFF_GAP,
|
|
CONF_MIN_POWER,
|
|
CONF_NO_UPDATE_ACTIVE_TIMEOUT,
|
|
CONF_OFF_DELAY,
|
|
CONF_PROFILE_DURATION_TOLERANCE,
|
|
CONF_PROFILE_MATCH_INTERVAL,
|
|
CONF_PROFILE_MATCH_MAX_DURATION_RATIO,
|
|
CONF_PROFILE_MATCH_MIN_DURATION_RATIO,
|
|
CONF_RUNNING_DEAD_ZONE,
|
|
CONF_SAMPLING_INTERVAL,
|
|
CONF_START_THRESHOLD_W,
|
|
CONF_STOP_THRESHOLD_W,
|
|
CONF_SUPPRESS_FEEDBACK_NOTIFICATIONS,
|
|
CONF_WATCHDOG_INTERVAL,
|
|
DEFAULT_AUTO_LABEL_CONFIDENCE,
|
|
DEFAULT_DURATION_TOLERANCE,
|
|
DEFAULT_LEARNING_CONFIDENCE,
|
|
DEFAULT_SUPPRESS_FEEDBACK_NOTIFICATIONS,
|
|
DOMAIN,
|
|
SIGNAL_WASHER_UPDATE,
|
|
)
|
|
from .suggestion_engine import SuggestionEngine
|
|
from .log_utils import DeviceLoggerAdapter
|
|
|
|
if TYPE_CHECKING:
|
|
from .profile_store import ProfileStore
|
|
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
class StatisticalModel:
|
|
"""Helper to track running stats for a metric."""
|
|
|
|
def __init__(self, max_samples: int = 200) -> None:
|
|
self._samples: list[float] = []
|
|
self._max_samples = max_samples
|
|
self._last_update: datetime | None = None
|
|
self._stats: dict[str, Any] = {"median": None, "p95": None, "count": 0}
|
|
|
|
def add_sample(self, value: float, now: datetime) -> None:
|
|
"""Add a sample and update stats."""
|
|
self._samples.append(value)
|
|
if len(self._samples) > self._max_samples:
|
|
self._samples = self._samples[-self._max_samples:]
|
|
self._last_update = now
|
|
self._compute_stats()
|
|
|
|
def _compute_stats(self) -> None:
|
|
if not self._samples:
|
|
self._stats = {"median": None, "p95": None, "count": 0}
|
|
return
|
|
|
|
arr = np.array(self._samples)
|
|
self._stats = {
|
|
"median": float(np.median(arr)),
|
|
"p95": float(np.percentile(arr, 95)),
|
|
"count": int(len(self._samples)),
|
|
}
|
|
|
|
@property
|
|
def median(self) -> float | None:
|
|
"""Return the median of samples."""
|
|
return self._stats.get("median")
|
|
|
|
@property
|
|
def p95(self) -> float | None:
|
|
"""Return the 95th percentile of samples."""
|
|
return self._stats.get("p95")
|
|
|
|
@property
|
|
def count(self) -> int:
|
|
"""Return the number of samples."""
|
|
return self._stats.get("count", 0)
|
|
|
|
|
|
class LearningManager:
|
|
"""Manages cycle learning, user feedback, and auto-tuning."""
|
|
|
|
def __init__(
|
|
self,
|
|
hass: HomeAssistant,
|
|
entry_id: str,
|
|
profile_store: "ProfileStore",
|
|
device_type: str | None = None,
|
|
device_name: str = "",
|
|
) -> None:
|
|
"""Initialize the learning manager."""
|
|
self._logger = DeviceLoggerAdapter(_LOGGER, device_name)
|
|
self.hass = hass
|
|
self.entry_id = entry_id
|
|
self.profile_store = profile_store
|
|
self.device_type = device_type
|
|
self.suggestion_engine = SuggestionEngine(
|
|
hass, entry_id, profile_store, device_type
|
|
)
|
|
|
|
# Operational Stats
|
|
self._sample_interval_model = StatisticalModel(max_samples=200)
|
|
self._last_suggestion_update: datetime | None = None
|
|
self._last_batch_simulation_count: int = 0 # track when to re-run batch
|
|
|
|
def _apply_suggestions_and_notify(self, suggestions: dict[str, Any]) -> None:
|
|
"""Apply suggestions and notify once when they become actionable."""
|
|
if not suggestions:
|
|
return
|
|
|
|
_actionable_keys = (
|
|
CONF_MIN_POWER,
|
|
CONF_OFF_DELAY,
|
|
CONF_WATCHDOG_INTERVAL,
|
|
CONF_NO_UPDATE_ACTIVE_TIMEOUT,
|
|
CONF_SAMPLING_INTERVAL,
|
|
CONF_PROFILE_MATCH_INTERVAL,
|
|
CONF_AUTO_LABEL_CONFIDENCE,
|
|
CONF_DURATION_TOLERANCE,
|
|
CONF_PROFILE_DURATION_TOLERANCE,
|
|
CONF_PROFILE_MATCH_MIN_DURATION_RATIO,
|
|
CONF_PROFILE_MATCH_MAX_DURATION_RATIO,
|
|
CONF_MIN_OFF_GAP,
|
|
CONF_STOP_THRESHOLD_W,
|
|
CONF_START_THRESHOLD_W,
|
|
CONF_END_ENERGY_THRESHOLD,
|
|
CONF_RUNNING_DEAD_ZONE,
|
|
)
|
|
|
|
# Drop suggestions whose value already matches the current config - so
|
|
# that applied suggestions don't immediately reappear on the next cycle.
|
|
entry = self.hass.config_entries.async_get_entry(self.entry_id)
|
|
current_options: dict[str, Any] = {}
|
|
if entry:
|
|
current_options = {**entry.data, **entry.options}
|
|
|
|
filtered_suggestions: dict[str, Any] = {}
|
|
for key, data in suggestions.items():
|
|
if isinstance(data, dict) and "value" in data:
|
|
current_val = current_options.get(key)
|
|
suggested_val = data["value"]
|
|
if current_val is not None and suggested_val is not None:
|
|
try:
|
|
if float(current_val) == float(suggested_val):
|
|
self.profile_store.delete_suggestion(key)
|
|
continue # already applied, remove stale entry
|
|
except (TypeError, ValueError):
|
|
pass
|
|
filtered_suggestions[key] = data
|
|
|
|
if not filtered_suggestions:
|
|
return
|
|
|
|
def _count_actionable(s: dict) -> int:
|
|
return sum(
|
|
1 for k in _actionable_keys
|
|
if isinstance(s.get(k), dict) and s[k].get("value") is not None
|
|
)
|
|
|
|
current = self.profile_store.get_suggestions()
|
|
before_count = _count_actionable(current) if isinstance(current, dict) else 0
|
|
|
|
self.suggestion_engine.apply_suggestions(filtered_suggestions)
|
|
|
|
updated = self.profile_store.get_suggestions()
|
|
after_count = _count_actionable(updated) if isinstance(updated, dict) else 0
|
|
|
|
if before_count == 0 and after_count > 0:
|
|
device_title = entry.title if entry else DOMAIN
|
|
self.hass.async_create_task(
|
|
self._async_send_suggestions_ready_notification(device_title, after_count)
|
|
)
|
|
|
|
def process_power_reading(
|
|
self, _power: float, now: datetime, last_reading_time: datetime | None
|
|
) -> None:
|
|
"""Ingest power reading metadata for statistical analysis."""
|
|
if last_reading_time:
|
|
delta = (now - last_reading_time).total_seconds()
|
|
# Ignore ultra-small jitter (<0.1s) and massive gaps (>1800s - likely downtime)
|
|
if 0.1 < delta < 1800:
|
|
self._sample_interval_model.add_sample(delta, now)
|
|
|
|
# Periodically update suggestions based on operational stats
|
|
if (
|
|
self._last_suggestion_update is None
|
|
or (now - self._last_suggestion_update).total_seconds() > 300 # Check every 5 mins
|
|
):
|
|
self._update_operational_suggestions(now)
|
|
|
|
def process_cycle_end(
|
|
self,
|
|
cycle_data: dict[str, Any],
|
|
detected_profile: str | None = None,
|
|
confidence: float = 0.0,
|
|
predicted_duration: float | None = None,
|
|
match_result: Any | None = None,
|
|
) -> None:
|
|
"""Analyze completed cycle for learning.
|
|
|
|
Args:
|
|
cycle_data: Completed cycle data
|
|
detected_profile: Profile name detected
|
|
confidence: Match confidence score (0.0-1.0)
|
|
predicted_duration: Expected duration in seconds
|
|
match_result: MatchResult from profile_store.async_match_profile() (optional)
|
|
"""
|
|
# 1. Trigger background simulation to find optimal parameters for this cycle
|
|
if cycle_data.get("power_data"):
|
|
# Offload to executor since simulation can be heavy
|
|
self.hass.async_create_task(self._async_run_simulation(cycle_data))
|
|
|
|
# 2. Check if we should request feedback
|
|
self._maybe_request_feedback(
|
|
cycle_data, detected_profile, confidence, predicted_duration, match_result
|
|
)
|
|
|
|
# 3. Update model-based suggestions (durations etc)
|
|
self._update_model_suggestions(dt_util.now())
|
|
|
|
# 4. Run multi-cycle batch simulation when enough new labeled cycles have accumulated
|
|
self._maybe_run_batch_simulation()
|
|
|
|
def _maybe_run_batch_simulation(self) -> None:
|
|
"""Schedule a batch simulation when enough new labeled cycles have arrived."""
|
|
_BATCH_MIN = 5
|
|
_BATCH_RERUN_DELTA = 5 # Re-run every 5 new labeled cycles
|
|
|
|
labeled_cycles = [
|
|
c for c in self.profile_store.get_past_cycles()
|
|
if isinstance(c, dict)
|
|
and c.get("profile_name")
|
|
and c.get("profile_name") != "noise"
|
|
and c.get("power_data")
|
|
and c.get("status") in ("completed", "force_stopped")
|
|
]
|
|
current_count = len(labeled_cycles)
|
|
|
|
if current_count < _BATCH_MIN:
|
|
return
|
|
if (current_count - self._last_batch_simulation_count) < _BATCH_RERUN_DELTA:
|
|
return
|
|
|
|
self._last_batch_simulation_count = current_count
|
|
self.hass.async_create_task(self._async_run_batch_simulation(labeled_cycles, current_count))
|
|
|
|
async def _async_run_batch_simulation(self, cycles: list[dict[str, Any]], expected_count: int) -> None:
|
|
"""Run multi-cycle batch simulation asynchronously."""
|
|
try:
|
|
new_suggestions = await self.hass.async_add_executor_job(
|
|
self.suggestion_engine.run_batch_simulation, cycles
|
|
)
|
|
if new_suggestions:
|
|
self._apply_suggestions_and_notify(new_suggestions)
|
|
self._logger.debug(
|
|
"Batch simulation (%d cycles) produced suggestions: %s",
|
|
len(cycles),
|
|
list(new_suggestions.keys()),
|
|
)
|
|
except Exception as e: # pylint: disable=broad-exception-caught
|
|
self._logger.error("Batch simulation failed: %s", e)
|
|
|
|
async def _async_run_simulation(self, cycle_data: dict[str, Any]) -> None:
|
|
"""Run simulation asynchronously."""
|
|
try:
|
|
# Simulation runner derives optimal thresholds
|
|
# Offload to executor since simulation can be heavy (CPU bound)
|
|
new_suggestions = await self.hass.async_add_executor_job(
|
|
self.suggestion_engine.run_simulation, cycle_data
|
|
)
|
|
if new_suggestions:
|
|
self._apply_suggestions_and_notify(new_suggestions)
|
|
self._logger.debug("Post-cycle simulation completed with suggestions: %s", new_suggestions.keys())
|
|
except Exception as e:
|
|
self._logger.error("Background simulation failed: %s", e)
|
|
|
|
def _update_operational_suggestions(self, now: datetime) -> None:
|
|
"""Generate suggestions for operational parameters (intervals, timeouts)."""
|
|
if self._sample_interval_model.count < 20:
|
|
return
|
|
|
|
p95 = self._sample_interval_model.p95
|
|
median = self._sample_interval_model.median
|
|
|
|
if p95 is None or median is None:
|
|
return
|
|
|
|
suggestions = self.suggestion_engine.generate_operational_suggestions(p95, median)
|
|
self._apply_suggestions_and_notify(suggestions)
|
|
self._last_suggestion_update = now
|
|
|
|
def _update_model_suggestions(self, now: datetime) -> None:
|
|
"""Generate suggestions for model parameters (tolerances, ratios)."""
|
|
suggestions = self.suggestion_engine.generate_model_suggestions()
|
|
self._apply_suggestions_and_notify(suggestions)
|
|
|
|
async def _async_send_suggestions_ready_notification(
|
|
self, device_title: str, suggestions_count: int
|
|
) -> None:
|
|
"""Send a one-time persistent notification when suggestions become available."""
|
|
try:
|
|
notification_id = f"ha_washdata_suggestions_ready_{self.entry_id}"
|
|
|
|
translations = await translation.async_get_translations(
|
|
self.hass, self.hass.config.language, "options", {DOMAIN}
|
|
)
|
|
|
|
default_title = "WashData: Suggested Settings Ready ({device})"
|
|
default_msg = (
|
|
"The **Suggested Settings** sensor now reports **{count}** actionable recommendations.\n\n"
|
|
"To review and apply them: **Settings > Devices & Services > WashData > Configure > "
|
|
"Advanced Settings > Apply Suggested Values**.\n\n"
|
|
"Suggestions are optional and shown for review before you save."
|
|
)
|
|
|
|
title_template = translations.get(
|
|
f"component.{DOMAIN}.options.error.suggestions_ready_notification_title",
|
|
default_title,
|
|
)
|
|
msg_template = translations.get(
|
|
f"component.{DOMAIN}.options.error.suggestions_ready_notification_message",
|
|
default_msg,
|
|
)
|
|
|
|
title = title_template.format(device=device_title)
|
|
message = msg_template.format(count=suggestions_count)
|
|
|
|
await self.hass.services.async_call(
|
|
"persistent_notification",
|
|
"create",
|
|
{
|
|
"message": message,
|
|
"title": title,
|
|
"notification_id": notification_id,
|
|
},
|
|
)
|
|
except Exception: # pylint: disable=broad-exception-caught
|
|
self._logger.exception("Failed to create suggestions-ready notification")
|
|
|
|
def _set_suggestion(self, key: str, value: Any, reason: str) -> None:
|
|
"""Persist a suggested setting."""
|
|
current: Any = self.profile_store.get_suggestions().get(key, {})
|
|
if isinstance(current, dict):
|
|
current_dict = cast(dict[str, Any], current)
|
|
if current_dict.get("value") == value:
|
|
return # No change
|
|
|
|
self.profile_store.set_suggestion(key, value, reason=reason)
|
|
# We fire a background save task if possible, or rely on next periodic save.
|
|
# Since learning manager doesn't hold reference to hass task creation easily,
|
|
# we can just rely on ProfileStore's periodic save or trigger one if referenced.
|
|
# Ideally ProfileStore handles dirtiness.
|
|
# But wait, Manager calls save periodically. We should just mark it dirty?
|
|
# ProfileStore.async_save() is needed.
|
|
# We'll just trigger it via hass if available.
|
|
if self.hass:
|
|
self.hass.async_create_task(self.profile_store.async_save())
|
|
|
|
def _maybe_request_feedback(
|
|
self,
|
|
cycle_data: dict[str, Any],
|
|
detected_profile: str | None,
|
|
confidence: float,
|
|
predicted_duration: float | None,
|
|
match_result: Any | None = None,
|
|
) -> None:
|
|
"""Check if feedback should be requested for this completed cycle."""
|
|
if (
|
|
not predicted_duration
|
|
or not detected_profile
|
|
or detected_profile in ("off", "detecting...")
|
|
):
|
|
# No match was made, don't request feedback
|
|
return
|
|
|
|
# Get the cycle ID from the cycle_data
|
|
cycle_id = cycle_data.get("id")
|
|
if not cycle_id:
|
|
self._logger.warning("Cycle data missing ID, cannot request feedback")
|
|
return
|
|
|
|
# Get Configured Thresholds
|
|
entry = self.hass.config_entries.async_get_entry(self.entry_id)
|
|
if not entry:
|
|
return
|
|
|
|
auto_label_conf = entry.options.get(
|
|
CONF_AUTO_LABEL_CONFIDENCE, DEFAULT_AUTO_LABEL_CONFIDENCE
|
|
)
|
|
learning_conf = entry.options.get(
|
|
CONF_LEARNING_CONFIDENCE, DEFAULT_LEARNING_CONFIDENCE
|
|
)
|
|
duration_tol = entry.options.get(
|
|
CONF_DURATION_TOLERANCE, DEFAULT_DURATION_TOLERANCE
|
|
)
|
|
|
|
# Auto-label if very high confidence
|
|
if confidence >= auto_label_conf:
|
|
labeled = self.auto_label_high_confidence(
|
|
cycle_id=cycle_id,
|
|
profile_name=detected_profile,
|
|
confidence=confidence,
|
|
confidence_threshold=auto_label_conf,
|
|
)
|
|
if labeled:
|
|
# Rebuild envelope first, then persist (issue #131)
|
|
self.hass.async_create_task(
|
|
self._async_rebuild_and_save_profile(detected_profile)
|
|
)
|
|
self._logger.debug("Auto-labeled high-confidence cycle %s", cycle_id)
|
|
return
|
|
|
|
# Skip low-confidence matches below learning threshold
|
|
if confidence < learning_conf:
|
|
self._logger.debug(
|
|
"Skipping feedback for low-confidence match (conf=%.2f < %.2f)",
|
|
confidence,
|
|
learning_conf,
|
|
)
|
|
return
|
|
|
|
actual_duration = cycle_data.get("duration", 0)
|
|
|
|
# Request feedback via learning manager for moderate confidence
|
|
self.request_cycle_verification(
|
|
cycle_id=cycle_id,
|
|
detected_profile=detected_profile,
|
|
confidence=confidence,
|
|
estimated_duration=predicted_duration,
|
|
actual_duration=actual_duration,
|
|
duration_tolerance=duration_tol,
|
|
match_result=match_result,
|
|
)
|
|
|
|
# Persist pending feedback request so it survives restart
|
|
self.hass.async_create_task(self.profile_store.async_save())
|
|
|
|
# Create user-visible notification (skipped when suppressed via option).
|
|
# Use `is True` so that un-configured mock objects in tests don't
|
|
# accidentally suppress notifications by being truthy.
|
|
suppress = entry.options.get(
|
|
CONF_SUPPRESS_FEEDBACK_NOTIFICATIONS,
|
|
DEFAULT_SUPPRESS_FEEDBACK_NOTIFICATIONS,
|
|
) is True
|
|
if not suppress:
|
|
self.hass.async_create_task(
|
|
self._async_send_feedback_notification(
|
|
entry.title, cycle_data, detected_profile, confidence
|
|
)
|
|
)
|
|
|
|
async def _async_send_feedback_notification(
|
|
self, device_title: str, cycle_data: dict[str, Any], profile: str, confidence: float
|
|
) -> None:
|
|
"""Send a persistent notification for feedback (Async with translation)."""
|
|
try:
|
|
cycle_id = cycle_data.get("id", "unknown")
|
|
start_ts = cycle_data.get("start_time")
|
|
end_ts = dt_util.now() # Approximate, or pass actual end time
|
|
|
|
# Format times
|
|
t_str = ""
|
|
if start_ts:
|
|
try:
|
|
s_dt = datetime.fromisoformat(str(start_ts)) if isinstance(start_ts, str) else start_ts
|
|
s_local = dt_util.as_local(s_dt)
|
|
e_local = dt_util.as_local(end_ts)
|
|
t_str = f"{s_local.strftime('%H:%M')} - {e_local.strftime('%H:%M')}"
|
|
except Exception:
|
|
t_str = "Just now"
|
|
|
|
notification_id = f"ha_washdata_feedback_{self.entry_id}_{cycle_id}"
|
|
|
|
# Load translations (from en.json / localization files)
|
|
# We use "options" category to access the error keys where we stored these strings
|
|
translations = await translation.async_get_translations(
|
|
self.hass, self.hass.config.language, "options", {DOMAIN}
|
|
)
|
|
|
|
# Default templates
|
|
default_title = "WashData: Verify Cycle ({device})"
|
|
default_msg = (
|
|
"**Device**: {device}\n"
|
|
"**Program**: {program} ({confidence}% confidence)\n"
|
|
"**Time**: {time}\n\n"
|
|
"WashData needs your help to verify this detected cycle.\n\n"
|
|
"Please go to **Settings > Devices & Services > WashData > Configure > Learning Feedbacks** to confirm or correct this result."
|
|
)
|
|
|
|
title_template = translations.get(
|
|
f"component.{DOMAIN}.options.error.feedback_notification_title", default_title
|
|
)
|
|
msg_template = translations.get(
|
|
f"component.{DOMAIN}.options.error.feedback_notification_message", default_msg
|
|
)
|
|
|
|
# Confidence as percentage
|
|
conf_pct = int(confidence * 100)
|
|
|
|
title = title_template.format(device=device_title)
|
|
message = msg_template.format(
|
|
device=device_title,
|
|
program=profile,
|
|
confidence=conf_pct,
|
|
time=t_str
|
|
)
|
|
|
|
# Use standard service call
|
|
await self.hass.services.async_call(
|
|
"persistent_notification",
|
|
"create",
|
|
{
|
|
"message": message,
|
|
"title": title,
|
|
"notification_id": notification_id,
|
|
},
|
|
)
|
|
except Exception: # pylint: disable=broad-exception-caught
|
|
self._logger.exception("Failed to create feedback notification")
|
|
|
|
def _send_feedback_notification(
|
|
self, device_title: str, cycle_data: dict[str, Any], profile: str, confidence: float
|
|
) -> None:
|
|
"""Deprecated sync wrapper."""
|
|
self.hass.async_create_task(
|
|
self._async_send_feedback_notification(
|
|
device_title, cycle_data, profile, confidence
|
|
)
|
|
)
|
|
|
|
def request_cycle_verification(
|
|
self,
|
|
cycle_id: str,
|
|
detected_profile: Optional[str],
|
|
confidence: float,
|
|
estimated_duration: Optional[float],
|
|
actual_duration: float,
|
|
duration_tolerance: float = 0.10,
|
|
match_result: Any | None = None,
|
|
) -> None:
|
|
"""Request user verification for a detected cycle."""
|
|
duration_match_pct = (
|
|
(actual_duration / estimated_duration * 100) if estimated_duration else 0
|
|
)
|
|
tolerance_pct = duration_tolerance * 100
|
|
is_close_match = (
|
|
estimated_duration and abs(duration_match_pct - 100) <= tolerance_pct
|
|
)
|
|
|
|
# Extract match ranking from MatchResult if available (for UI visualization)
|
|
ranking_summary: list[dict[str, Any]] = []
|
|
if match_result and hasattr(match_result, "ranking") and match_result.ranking:
|
|
for cand in match_result.ranking[:5]: # Store top 5
|
|
try:
|
|
ranking_summary.append({
|
|
"name": cand.get("name", "Unknown"),
|
|
"score": float(cand.get("score", 0.0)),
|
|
"metrics": cand.get("metrics", {}),
|
|
"profile_duration": float(cand.get("profile_duration", 0.0)),
|
|
})
|
|
except (TypeError, ValueError, KeyError, AttributeError):
|
|
continue
|
|
|
|
feedback_req: dict[str, Any] = {
|
|
"cycle_id": cycle_id,
|
|
"detected_profile": detected_profile,
|
|
"confidence": confidence,
|
|
"estimated_duration": estimated_duration,
|
|
"actual_duration": actual_duration,
|
|
"duration_match_pct": duration_match_pct,
|
|
"is_close_match": is_close_match,
|
|
"created_at": dt_util.now().isoformat(),
|
|
"user_response": None,
|
|
"expires_at": None,
|
|
"ranking": ranking_summary, # Top candidates for UI display
|
|
}
|
|
|
|
self.profile_store.add_pending_feedback(cycle_id, feedback_req)
|
|
|
|
est_min = int(estimated_duration / 60) if estimated_duration else 0
|
|
self._logger.info(
|
|
"Feedback requested for cycle %s: profile='%s' (conf=%.2f), "
|
|
"est=%smin, actual=%smin (%.0f%%)",
|
|
cycle_id,
|
|
detected_profile,
|
|
confidence,
|
|
est_min,
|
|
int(actual_duration / 60),
|
|
duration_match_pct,
|
|
)
|
|
|
|
def auto_label_high_confidence(
|
|
self,
|
|
cycle_id: str,
|
|
profile_name: str,
|
|
confidence: float,
|
|
confidence_threshold: float,
|
|
) -> bool:
|
|
"""Auto-label a cycle with high confidence."""
|
|
if confidence < confidence_threshold:
|
|
return False
|
|
|
|
# Reuse existing internal logic
|
|
self._auto_label_cycle(cycle_id, profile_name)
|
|
|
|
# Verify it was labeled (cycle found)
|
|
cycles = self.profile_store.get_past_cycles()
|
|
cycle = next((c for c in cycles if c["id"] == cycle_id), None)
|
|
|
|
return bool(cycle and cycle.get("auto_labeled"))
|
|
|
|
async def async_submit_cycle_feedback(
|
|
self,
|
|
cycle_id: str,
|
|
user_confirmed: bool,
|
|
corrected_profile: Optional[str] = None,
|
|
corrected_duration: Optional[float] = None,
|
|
notes: str = "",
|
|
dismiss: bool = False,
|
|
) -> bool:
|
|
"""Submit user feedback for a cycle."""
|
|
pending = self.profile_store.get_pending_feedback().get(cycle_id)
|
|
if not pending:
|
|
return False
|
|
|
|
# Parse corrected_duration before writing to history so a bad value
|
|
# never leaves a partially-applied state.
|
|
duration_sec: float | None = None
|
|
if corrected_duration is not None:
|
|
try:
|
|
duration_sec = float(corrected_duration)
|
|
except (TypeError, ValueError):
|
|
self._logger.warning(
|
|
"Invalid corrected_duration %r for cycle %s, ignoring",
|
|
corrected_duration,
|
|
cycle_id,
|
|
)
|
|
|
|
feedback_record: dict[str, Any] = {
|
|
"cycle_id": cycle_id,
|
|
"original_detected_profile": pending["detected_profile"],
|
|
"original_confidence": pending["confidence"],
|
|
"user_confirmed": user_confirmed,
|
|
"corrected_profile": corrected_profile,
|
|
"corrected_duration": duration_sec,
|
|
"notes": notes,
|
|
"submitted_at": dt_util.now().isoformat(),
|
|
}
|
|
|
|
self.profile_store.get_feedback_history()[cycle_id] = feedback_record
|
|
|
|
# Track which profiles need envelope rebuild (issue #131)
|
|
profiles_to_rebuild: set[str] = set()
|
|
|
|
if dismiss:
|
|
# Just dismiss, no action
|
|
pass
|
|
elif user_confirmed:
|
|
profile_name = pending.get("detected_profile")
|
|
if isinstance(profile_name, str) and profile_name:
|
|
self._auto_label_cycle(cycle_id, profile_name, duration_sec)
|
|
if duration_sec is not None:
|
|
cycles = self.profile_store.get_past_cycles()
|
|
confirmed_cycle = next((c for c in cycles if c["id"] == cycle_id), None)
|
|
if confirmed_cycle:
|
|
confirmed_cycle["duration"] = duration_sec
|
|
profiles_to_rebuild.add(profile_name)
|
|
else:
|
|
# Correction path: only use corrected_profile when user_confirmed is False.
|
|
# Duration-only corrections (no profile specified) are handled by the elif branch below.
|
|
target_profile = corrected_profile
|
|
detected_profile_name = pending.get("detected_profile")
|
|
|
|
if isinstance(target_profile, str) and target_profile:
|
|
self._apply_correction_learning(
|
|
cycle_id, target_profile, duration_sec
|
|
)
|
|
profiles_to_rebuild.add(target_profile)
|
|
if (
|
|
isinstance(detected_profile_name, str)
|
|
and detected_profile_name
|
|
and detected_profile_name != target_profile
|
|
):
|
|
profiles_to_rebuild.add(detected_profile_name)
|
|
elif duration_sec is not None:
|
|
# No valid profile could be determined, but a duration correction was
|
|
# explicitly provided - apply it directly to the cycle so the value
|
|
# is never silently dropped.
|
|
cycles = self.profile_store.get_past_cycles()
|
|
cycle_to_fix = next((c for c in cycles if c["id"] == cycle_id), None)
|
|
if cycle_to_fix:
|
|
cycle_to_fix["duration"] = duration_sec
|
|
cycle_to_fix["manual_duration"] = duration_sec
|
|
existing_profile = cycle_to_fix.get("profile_name")
|
|
if isinstance(existing_profile, str) and existing_profile:
|
|
profiles_to_rebuild.add(existing_profile)
|
|
else:
|
|
self._logger.warning(
|
|
"Duration correction skipped: cycle %s not found in past_cycles",
|
|
cycle_id,
|
|
)
|
|
|
|
# Remove from pending (add_pending_feedback was wrapper, remove is direct)
|
|
if cycle_id in self.profile_store.get_pending_feedback():
|
|
del self.profile_store.get_pending_feedback()[cycle_id]
|
|
|
|
# Rebuild envelopes for all modified profiles to recalculate min/max/avg (issue #131)
|
|
for profile_name in profiles_to_rebuild:
|
|
try:
|
|
await self.profile_store.async_rebuild_envelope(profile_name)
|
|
except Exception as e: # pylint: disable=broad-exception-caught
|
|
self._logger.error("Failed to rebuild envelope for profile '%s': %s", profile_name, e)
|
|
|
|
# Persist changes
|
|
await self.profile_store.async_save()
|
|
|
|
# Trigger UI and sensor refresh (Issue #155)
|
|
async_dispatcher_send(self.hass, f"ha_washdata_update_{self.entry_id}")
|
|
|
|
return True
|
|
|
|
def _auto_label_cycle(self, cycle_id: str, profile_name: str, manual_duration: float | None = None) -> None:
|
|
cycles = self.profile_store.get_past_cycles()
|
|
cycle = next((c for c in cycles if c["id"] == cycle_id), None)
|
|
if cycle:
|
|
cycle["profile_name"] = profile_name
|
|
cycle["auto_labeled"] = True
|
|
if manual_duration:
|
|
cycle["manual_duration"] = manual_duration
|
|
|
|
def _apply_correction_learning(
|
|
self,
|
|
cycle_id: str,
|
|
corrected_profile: str,
|
|
corrected_duration: Optional[float] = None,
|
|
) -> None:
|
|
"""Apply user correction to a cycle (fix for issue #131).
|
|
|
|
Note: We do not update avg_duration here with EMA. Instead, the envelope
|
|
rebuild in async_submit_cycle_feedback() will recalculate all statistics
|
|
(min/max/avg) from labeled cycles, ensuring accuracy.
|
|
"""
|
|
self._auto_label_cycle(cycle_id, corrected_profile, corrected_duration)
|
|
if corrected_duration is not None:
|
|
cycles = self.profile_store.get_past_cycles()
|
|
cycle = next((c for c in cycles if c["id"] == cycle_id), None)
|
|
if cycle:
|
|
cycle["duration"] = corrected_duration
|
|
# Profile stats will be recalculated when envelope is rebuilt
|
|
|
|
async def _async_rebuild_profile_envelope(self, profile_name: str) -> None:
|
|
"""Async helper to rebuild a profile's envelope (issue #131 fix).
|
|
|
|
This wraps async_rebuild_envelope with error handling for safe task scheduling.
|
|
"""
|
|
try:
|
|
await self.profile_store.async_rebuild_envelope(profile_name)
|
|
self._logger.debug("Rebuilt envelope for profile '%s'", profile_name)
|
|
except Exception as e: # pylint: disable=broad-exception-caught
|
|
self._logger.error("Failed to rebuild envelope for profile '%s': %s", profile_name, e)
|
|
|
|
async def _async_rebuild_and_save_profile(self, detected_profile: str) -> None:
|
|
"""Rebuild profile envelope then persist in deterministic order."""
|
|
await self._async_rebuild_profile_envelope(detected_profile)
|
|
await self.profile_store.async_save()
|
|
|
|
def get_pending_feedback(self) -> dict[str, dict[str, Any]]:
|
|
"""Return pending feedback requests."""
|
|
return dict(self.profile_store.get_pending_feedback())
|
|
|
|
def get_feedback_history(self, limit: int = 20) -> list[dict[str, Any]]:
|
|
"""Return submitted feedback history."""
|
|
items = list(self.profile_store.get_feedback_history().values())
|
|
items.sort(key=lambda x: x.get("submitted_at", ""), reverse=True)
|
|
return items[:limit]
|