From 8070a85b52f3cabd1640b677edc102acf5d11bde Mon Sep 17 00:00:00 2001 From: Otto Date: Thu, 18 Jun 2026 19:06:47 +0200 Subject: [PATCH] Add actuator simulation tuning --- addon/config.yaml | 2 +- app/actuators/models.py | 13 +++ app/api/v1/actuators.py | 55 ++++++++- app/behavior/engine.py | 218 +++++++++++++++++++++++++++++++++--- app/main.py | 2 +- app/static/index.html | 89 +++++++++++++++ pyproject.toml | 2 +- tests/api/test_actuators.py | 88 ++++++++++++++- 8 files changed, 450 insertions(+), 19 deletions(-) diff --git a/addon/config.yaml b/addon/config.yaml index 66336d0..cee6b68 100644 --- a/addon/config.yaml +++ b/addon/config.yaml @@ -1,5 +1,5 @@ name: SillyHome Next -version: "1.7.0" +version: "1.7.1" slug: sillyhome_next description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren url: http://192.168.6.31:3000/pino/sillyhome-next diff --git a/app/actuators/models.py b/app/actuators/models.py index 2d74730..3bad3cd 100644 --- a/app/actuators/models.py +++ b/app/actuators/models.py @@ -154,6 +154,19 @@ class DecisionFactor(BaseModel): evidence: list[str] = Field(default_factory=list) +class SimulationOutcome(BaseModel): + scenario_id: str = Field(pattern=r"^[a-z0-9_.-]{1,120}$") + actuator_entity_id: str + sensor_states: dict[str, str] = Field(default_factory=dict) + sensor_weights: dict[str, float] = Field(default_factory=dict) + prediction: BehaviorPrediction | None = None + decision_factors: list[DecisionFactor] = Field(default_factory=list) + would_execute: bool = False + blockers: list[str] = Field(default_factory=list) + score: float = Field(default=0.0, ge=0.0, le=1.0) + recommendation: str = Field(default="", max_length=700) + + class DecisionTrace(BaseModel): trace_id: str = Field(pattern=r"^[a-z0-9_.-]{1,120}$") created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) diff --git a/app/api/v1/actuators.py b/app/api/v1/actuators.py index f26f8a8..13a19e3 100644 --- a/app/api/v1/actuators.py +++ b/app/api/v1/actuators.py @@ -10,7 +10,14 @@ from pydantic import BaseModel, Field from app.actuators.cache_db import DashboardCache from app.actuators.lifecycle import ActuatorReconciliationService -from app.actuators.models import ActuatorRecord, AnomalyEvent, FeedbackKind, ReconciliationState, SensorWeightGroup +from app.actuators.models import ( + ActuatorRecord, + AnomalyEvent, + FeedbackKind, + ReconciliationState, + SensorWeightGroup, + SimulationOutcome, +) from app.actuators.models import JobQueueItem, JobQueueState, JobStatus, SafetyProfile from app.actuators.store import ActuatorStore from app.behavior.engine import BehaviorEngine @@ -52,6 +59,14 @@ class WeightOverrideRequest(BaseModel): note: str | None = Field(default=None, max_length=500) +class SimulationRequest(BaseModel): + sensor_states: dict[str, str] = Field(default_factory=dict) + sensor_weights: dict[str, float] = Field(default_factory=dict) + state_options: dict[str, list[str]] = Field(default_factory=dict) + include_current: bool = True + max_results: int = Field(default=8, ge=1, le=20) + + class FeedbackRequest(BaseModel): correct: bool expected_state: str | None = Field(default=None, max_length=100) @@ -567,6 +582,28 @@ def evaluate_actuator( raise HTTPException(status_code=404, detail=str(exc)) from exc +@router.post("/{actuator_entity_id}/simulate", response_model=list[SimulationOutcome]) +def simulate_actuator( + actuator_entity_id: str, + payload: SimulationRequest, + request: Request, +) -> list[SimulationOutcome]: + try: + _validate_simulation_payload(payload) + return _behavior(request).simulate( + actuator_entity_id, + sensor_states=payload.sensor_states, + sensor_weights=payload.sensor_weights, + state_options=payload.state_options, + include_current=payload.include_current, + max_results=payload.max_results, + ) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + + @router.post("/{actuator_entity_id}/feedback", response_model=ActuatorRecord) def record_feedback( actuator_entity_id: str, @@ -885,6 +922,22 @@ def _validate_weight_payload(payload: WeightOverrideRequest) -> None: raise ValueError(f"Ungültige Entity-ID in Gruppe {group.name}: {entity_id}") +def _validate_simulation_payload(payload: SimulationRequest) -> None: + for entity_id in [ + *payload.sensor_states.keys(), + *payload.sensor_weights.keys(), + *payload.state_options.keys(), + ]: + if "." not in entity_id: + raise ValueError(f"Ungültige Entity-ID: {entity_id}") + for entity_id, weight in payload.sensor_weights.items(): + if not 0.0 <= weight <= 1.0: + raise ValueError(f"Ungültige Gewichtung für {entity_id}: {weight}") + for entity_id, states in payload.state_options.items(): + if not states: + raise ValueError(f"Keine Zustände für {entity_id} angegeben.") + + def _reconciliation_state_or_default(request: Request) -> ReconciliationState: store = getattr(request.app.state, "actuator_store", None) if not isinstance(store, ActuatorStore): diff --git a/app/behavior/engine.py b/app/behavior/engine.py index c6c7c89..cd7ebd4 100644 --- a/app/behavior/engine.py +++ b/app/behavior/engine.py @@ -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) diff --git a/app/main.py b/app/main.py index de6aa2c..1533acc 100644 --- a/app/main.py +++ b/app/main.py @@ -117,7 +117,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: app = FastAPI( title="SillyHome Next API", description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.", - version="1.7.0", + version="1.7.1", lifespan=lifespan, ) app.state.settings = load_settings() diff --git a/app/static/index.html b/app/static/index.html index 820ed19..31d9a1a 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -1252,6 +1252,42 @@ async function showActuator(actuatorId, evaluationMessage = "") { `; + const simulationControls = weightedCandidates.length ? ` +
+ Aktor-Simulation +

Teste Sensorzustände und Gewichtungen, ohne Home Assistant zu schalten.

+
+ ${weightedCandidates.map(candidate => { + const effective = Math.round((candidate.effective_weight ?? 1) * 100); + const currentState = candidate.state || ""; + const stateOptions = candidate.domain === "binary_sensor" + ? "off,on" + : currentState; + return ` +
+
+
+ ${escapeHtml(candidate.friendly_name || candidate.entity_id)} +
${escapeHtml(candidate.entity_id)}
+
+ Simulation +
+ + + + + + +
+ `; + }).join("")} +
+
+ +
+
+
+ ` : "

Für die Simulation müssen zuerst Kontextsensoren ausgewählt sein.

"; const currentContextControls = contexts.length ? `