Improve learning discovery and dashboard i18n
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled

This commit is contained in:
2026-07-26 21:57:57 +02:00
parent 1b9db62294
commit 08e41b0198
9 changed files with 739 additions and 39 deletions

View File

@@ -48,10 +48,27 @@ _MAX_DECISION_TRACES = 30
_MAX_LATENCY_MEASUREMENTS = 50
_MAX_FEEDBACK_LOG = 50
_ACTION_LOGBOOK_TOLERANCE = timedelta(seconds=10)
_CONTEXT_TRIGGER_TOLERANCE = timedelta(seconds=3)
_CONTEXT_TRIGGER_TOLERANCE = timedelta(minutes=4)
_OWN_ACTION_TOLERANCE = timedelta(seconds=20)
_SAFE_ACTIVE_DOMAINS = frozenset({"cover", "fan", "humidifier", "light", "switch"})
_SAFE_ACTIVE_DOMAINS = frozenset({
"button",
"cover",
"fan",
"humidifier",
"input_button",
"light",
"switch",
})
_AUTOMATION_CONTEXT_DOMAINS = frozenset({"automation", "script"})
_LIGHT_TARGET_ATTRIBUTES = frozenset({
"brightness",
"color_temp",
"color_temp_kelvin",
"effect",
"hs_color",
"rgb_color",
"xy_color",
})
logger = logging.getLogger(__name__)
@@ -339,7 +356,7 @@ class BehaviorEngine:
now=now,
min_support=self._settings.min_behavior_actions,
window_minutes=self._settings.prediction_window_minutes,
causal_window_seconds=self._settings.prediction_interval_seconds * 2,
causal_window_seconds=max(self._settings.prediction_interval_seconds * 2, 240),
timezone_name=self._settings.timezone,
)
if prediction is not None:
@@ -439,7 +456,11 @@ class BehaviorEngine:
self._ha_reader.call_service(
domain,
service,
{"entity_id": actuator_entity_id},
_service_data_for_prediction(
actuator_entity_id,
domain,
prediction,
),
)
decision_to_service_ms = _elapsed_ms(service_started_perf)
except (HaClientError, ValueError) as exc:
@@ -1107,7 +1128,7 @@ class BehaviorEngine:
blockers.append(
f"Sicherheit {prediction.confidence:.0%} liegt unter der Schwelle {threshold:.0%}."
)
if current_state == prediction.target_state:
if _target_reached(record.actuator_entity_id, current_state, prediction):
blockers.append("Zielzustand ist bereits erreicht.")
if not self._cooldown_elapsed(
record.behavior,
@@ -1150,12 +1171,16 @@ class BehaviorEngine:
patterns.append(
BehaviorPattern(
target_state=point.state,
target_attributes=_target_attributes_for(point),
minute_of_day=local.hour * 60 + local.minute,
weekday=local.weekday(),
context_states=contexts,
trigger_entity_id=trigger[0] if trigger else None,
trigger_from_state=trigger[1] if trigger else None,
trigger_to_state=trigger[2] if trigger else None,
trigger_entity_id=trigger[1] if trigger else None,
trigger_from_state=trigger[2] if trigger else None,
trigger_to_state=trigger[3] if trigger else None,
trigger_delay_seconds=(
int(trigger[0].total_seconds()) if trigger else None
),
source=source,
weight=weight,
observed_at=point.timestamp,
@@ -1941,6 +1966,7 @@ def predict_behavior(
minute_of_day = local.hour * 60 + local.minute
changed_at = current_context_changed_at or {}
by_state: dict[str, list[float]] = {}
attributes_by_state: dict[str, list[tuple[float, dict[str, object]]]] = {}
causal_support_by_state: dict[str, int] = {}
for pattern in patterns:
if pattern.trigger_entity_id and pattern.trigger_to_state:
@@ -1954,7 +1980,11 @@ def predict_behavior(
current_context.get(pattern.trigger_entity_id)
== pattern.trigger_to_state
and trigger_age is not None
and 0 <= trigger_age <= causal_window_seconds
and _trigger_age_matches(
trigger_age,
pattern.trigger_delay_seconds,
causal_window_seconds,
)
):
continue
comparable = [
@@ -1969,6 +1999,9 @@ def predict_behavior(
)
score = pattern.weight * (0.85 + 0.15 * context_score)
by_state.setdefault(pattern.target_state, []).append(score)
attributes_by_state.setdefault(pattern.target_state, []).append(
(score, pattern.target_attributes)
)
causal_support_by_state[pattern.target_state] = (
causal_support_by_state.get(pattern.target_state, 0) + 1
)
@@ -1998,6 +2031,9 @@ def predict_behavior(
0.45 * time_score + 0.45 * context_score + 0.10 * weekday_score
)
by_state.setdefault(pattern.target_state, []).append(score)
attributes_by_state.setdefault(pattern.target_state, []).append(
(score, pattern.target_attributes)
)
if not by_state:
return None
target_state, scores = max(
@@ -2011,6 +2047,9 @@ def predict_behavior(
return None
return BehaviorPrediction(
target_state=target_state,
target_attributes=_aggregate_target_attributes(
attributes_by_state.get(target_state, [])
),
confidence=round(confidence, 4),
generated_at=now,
matching_patterns=support,
@@ -2044,9 +2083,87 @@ def _weighted_context_score(
return matched_weight / total_weight
def _trigger_age_matches(
trigger_age_seconds: float,
expected_delay_seconds: int | None,
causal_window_seconds: int,
) -> bool:
if trigger_age_seconds < 0:
return False
if expected_delay_seconds is None or expected_delay_seconds <= 10:
return trigger_age_seconds <= causal_window_seconds
tolerance = max(30, min(90, causal_window_seconds // 2))
return abs(trigger_age_seconds - expected_delay_seconds) <= tolerance
def _aggregate_target_attributes(
weighted_attributes: list[tuple[float, dict[str, object]]],
) -> dict[str, object]:
if not weighted_attributes:
return {}
result: dict[str, object] = {}
numeric_values: dict[str, list[tuple[float, float]]] = {}
categorical_values: dict[str, dict[str, float]] = {}
for score, attributes in weighted_attributes:
for key, value in attributes.items():
if key not in _LIGHT_TARGET_ATTRIBUTES:
continue
if isinstance(value, bool) or value is None:
continue
if isinstance(value, (int, float)):
numeric_values.setdefault(key, []).append((score, float(value)))
else:
categorical_values.setdefault(key, {}).setdefault(str(value), 0.0)
categorical_values[key][str(value)] += score
for key, values in numeric_values.items():
total_weight = sum(score for score, _ in values)
if total_weight <= 0:
continue
result[key] = round(sum(score * value for score, value in values) / total_weight)
for key, values in categorical_values.items():
if key in result:
continue
result[key] = max(values.items(), key=lambda item: (item[1], item[0]))[0]
return result
def _target_attributes_for(point: StateHistoryPoint) -> dict[str, object]:
if point.state != "on":
return {}
return {
key: value
for key, value in point.attributes.items()
if key in _LIGHT_TARGET_ATTRIBUTES and value is not None
}
def _service_data_for_prediction(
actuator_entity_id: str,
domain: str,
prediction: BehaviorPrediction,
) -> dict[str, object]:
data: dict[str, object] = {"entity_id": actuator_entity_id}
if domain == "light" and prediction.target_state == "on":
data.update(prediction.target_attributes)
return data
def _target_reached(
actuator_entity_id: str,
current_state: str,
prediction: BehaviorPrediction,
) -> bool:
domain = actuator_entity_id.split(".", 1)[0]
if domain == "light" and prediction.target_state == "on" and prediction.target_attributes:
return False
return current_state == prediction.target_state
def service_for_state(domain: str, target_state: str) -> str | None:
if domain in {"fan", "humidifier", "light", "media_player", "remote", "switch"}:
return {"on": "turn_on", "off": "turn_off"}.get(target_state)
if domain in {"button", "input_button"}:
return "press"
if domain == "scene":
return "turn_on" if target_state == "on" else None
if domain == "cover":
@@ -2112,7 +2229,7 @@ def _recent_context_transition(
history: dict[str, StateHistorySeries],
context_ids: list[str],
timestamp: datetime,
) -> tuple[str, str, str] | None:
) -> tuple[timedelta, str, str, str] | None:
nearest: tuple[timedelta, str, str, str] | None = None
for entity_id in context_ids:
series = history.get(entity_id)
@@ -2131,7 +2248,7 @@ def _recent_context_transition(
previous_state = point.state
if nearest is None:
return None
return nearest[1], nearest[2], nearest[3]
return nearest
def _circular_minute_distance(left: int, right: int) -> int: