Add actuator simulation tuning
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-06-18 19:06:47 +02:00
parent 575211f0db
commit 8070a85b52
8 changed files with 450 additions and 19 deletions

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import logging
from itertools import product
from collections.abc import Sequence
from datetime import datetime, timedelta, timezone
from time import perf_counter
@@ -29,6 +30,7 @@ from app.actuators.models import (
SafetyProfile,
SafetyStage,
SceneSuggestion,
SimulationOutcome,
TimeProfile,
)
from app.actuators.store import ActuatorStore
@@ -333,6 +335,7 @@ class BehaviorEngine:
record.behavior.patterns,
current_context=current_context,
current_context_changed_at=current_context_changed_at,
context_weights=_context_weights_for(record),
now=now,
min_support=self._settings.min_behavior_actions,
window_minutes=self._settings.prediction_window_minutes,
@@ -514,6 +517,116 @@ class BehaviorEngine:
)
return self._save_behavior(record, behavior)
def simulate(
self,
actuator_entity_id: str,
*,
sensor_states: dict[str, str],
sensor_weights: dict[str, float],
state_options: dict[str, list[str]],
max_results: int,
include_current: bool = True,
) -> list[SimulationOutcome]:
record = self._store.get(actuator_entity_id)
now = datetime.now(timezone.utc)
current_entities = self._ha_reader.read_entities()
entities = {entity.entity_id: entity for entity in current_entities}
actuator = entities.get(actuator_entity_id)
if actuator is None:
raise KeyError("Aktor ist aktuell nicht in Home Assistant verfügbar.")
selected_context_ids = [
entity_id
for entity_id in [
record.assignment.selected_numeric_entity_id,
*record.assignment.selected_context_entity_ids,
]
if entity_id
]
if not selected_context_ids:
return []
base_context = {
entity_id: entities[entity_id].state
for entity_id in selected_context_ids
if entity_id in entities and entities[entity_id].state is not None
}
base_changed_at = {
entity_id: entities[entity_id].last_changed
for entity_id in base_context
}
context_weights = _context_weights_for(record)
for entity_id, weight in sensor_weights.items():
if entity_id in selected_context_ids:
context_weights[entity_id] = max(0.0, min(1.0, weight))
scenarios = _simulation_contexts(
base_context,
sensor_states=sensor_states,
state_options=state_options,
selected_context_ids=selected_context_ids,
include_current=include_current,
)
outcomes: list[SimulationOutcome] = []
for index, context in enumerate(scenarios[:64], start=1):
prediction_context: dict[str, str | None] = dict(context)
changed_at = dict(base_changed_at)
for entity_id, state in context.items():
if base_context.get(entity_id) != state:
changed_at[entity_id] = now
prediction = predict_behavior(
record.behavior.patterns,
current_context=prediction_context,
current_context_changed_at=changed_at,
context_weights=context_weights,
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,
timezone_name=self._settings.timezone,
)
if prediction is not None:
would_execute, blockers = self._assess_safety(record, actuator.state, prediction, now)
recommendation = (
f"Bestes Szenario: {prediction.target_state} mit {prediction.confidence:.0%}."
if would_execute
else (
f"Vorhersage {prediction.target_state} mit {prediction.confidence:.0%}, "
"aber blockiert: " + " ".join(blockers)
)
)
else:
would_execute = False
blockers = ["Keine fällige Vorhersage."]
recommendation = "Dieses Szenario erzeugt keine fällige Vorhersage."
outcomes.append(
SimulationOutcome(
scenario_id=f"scenario-{index}",
actuator_entity_id=actuator_entity_id,
sensor_states=context,
sensor_weights={
entity_id: round(context_weights.get(entity_id, 1.0), 4)
for entity_id in context
},
prediction=prediction,
decision_factors=_decision_factors_for(
record,
prediction_context,
prediction,
context_weights=context_weights,
),
would_execute=would_execute,
blockers=blockers,
score=round(prediction.confidence if prediction is not None else 0.0, 4),
recommendation=recommendation,
)
)
return sorted(
outcomes,
key=lambda item: (
item.prediction is None,
-item.score,
item.scenario_id,
),
)[:max_results]
def record_feedback(
self,
actuator_entity_id: str,
@@ -1379,15 +1492,21 @@ def _decision_factors_for(
record: ActuatorRecord,
current_context: dict[str, str | None],
prediction: BehaviorPrediction | None,
*,
context_weights: dict[str, float] | None = None,
) -> list[DecisionFactor]:
factors: list[DecisionFactor] = []
weights = context_weights or {}
candidates = {
candidate.entity_id: candidate
for candidate in [*record.numeric_candidates, *record.context_candidates]
}
for entity_id, state in current_context.items():
candidate = candidates.get(entity_id)
weight = candidate.effective_weight if candidate is not None else 1.0
weight = weights.get(
entity_id,
candidate.effective_weight if candidate is not None else 1.0,
)
relevance = candidate.confidence if candidate is not None else 0.5
contribution = round(min(1.0, weight * relevance), 4)
factors.append(
@@ -1423,6 +1542,62 @@ def _decision_factors_for(
return sorted(factors, key=lambda item: (-item.contribution, item.label))[:12]
def _context_weights_for(record: ActuatorRecord) -> dict[str, float]:
weights = {
candidate.entity_id: candidate.effective_weight
for candidate in [*record.numeric_candidates, *record.context_candidates]
}
override = record.manual_override
if override is not None:
for entity_id, weight in override.sensor_weights.items():
weights[entity_id] = max(0.0, min(1.0, weight))
for group in override.sensor_weight_groups:
for entity_id in group.entity_ids:
weights[entity_id] = max(0.0, min(1.0, group.weight))
return weights
def _simulation_contexts(
base_context: dict[str, str | None],
*,
sensor_states: dict[str, str],
state_options: dict[str, list[str]],
selected_context_ids: list[str],
include_current: bool,
) -> list[dict[str, str]]:
selected = set(selected_context_ids)
base = {
entity_id: state
for entity_id, state in base_context.items()
if entity_id in selected and state is not None
}
for entity_id, state in sensor_states.items():
if entity_id in selected:
base[entity_id] = state
option_items = [
(
entity_id,
list(dict.fromkeys(state for state in states if state))[:6],
)
for entity_id, states in state_options.items()
if entity_id in selected and states
][:6]
contexts: list[dict[str, str]] = []
if include_current or not option_items:
contexts.append(dict(base))
if option_items:
keys = [item[0] for item in option_items]
value_lists = [item[1] for item in option_items]
for values in product(*value_lists):
context = dict(base)
context.update(dict(zip(keys, values, strict=True)))
if context not in contexts:
contexts.append(context)
if len(contexts) >= 64:
break
return contexts
def _knowledge_lines(
record: ActuatorRecord,
sample_count: int,
@@ -1756,6 +1931,7 @@ def predict_behavior(
min_support: int,
window_minutes: int,
current_context_changed_at: dict[str, datetime | None] | None = None,
context_weights: dict[str, float] | None = None,
causal_window_seconds: int = 120,
timezone_name: str = "Europe/Berlin",
) -> BehaviorPrediction | None:
@@ -1786,14 +1962,10 @@ def predict_behavior(
for entity_id, expected in pattern.context_states.items()
if entity_id in current_context
]
context_score = (
sum(
current_context[entity_id] == expected
for entity_id, expected in comparable
)
/ len(comparable)
if comparable
else 0.5
context_score = _weighted_context_score(
comparable,
current_context,
context_weights or {},
)
score = pattern.weight * (0.85 + 0.15 * context_score)
by_state.setdefault(pattern.target_state, []).append(score)
@@ -1817,11 +1989,10 @@ def predict_behavior(
for entity_id, expected in pattern.context_states.items()
if entity_id in current_context
]
context_score = (
sum(current_context[entity_id] == expected for entity_id, expected in comparable)
/ len(comparable)
if comparable
else 0.5
context_score = _weighted_context_score(
comparable,
current_context,
context_weights or {},
)
score = pattern.weight * (
0.45 * time_score + 0.45 * context_score + 0.10 * weekday_score
@@ -1854,6 +2025,25 @@ def predict_behavior(
)
def _weighted_context_score(
comparable: list[tuple[str, str]],
current_context: dict[str, str | None],
context_weights: dict[str, float],
) -> float:
if not comparable:
return 0.5
total_weight = 0.0
matched_weight = 0.0
for entity_id, expected in comparable:
weight = max(0.0, min(1.0, context_weights.get(entity_id, 1.0)))
total_weight += weight
if current_context.get(entity_id) == expected:
matched_weight += weight
if total_weight <= 0:
return 0.5
return matched_weight / total_weight
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)