Improve learning discovery and dashboard i18n
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user