initial commit v0.2.1
This commit is contained in:
commit
2b9a10979b
19 files changed
+1665
No files matched your search
@@ -0,0 +1,39 @@
|
||||
"""Heating MPC custom integration, initial release shadow-only."""
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.storage import Store
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import HeatingMPCCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.BINARY_SENSOR]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
coordinator = HeatingMPCCoordinator(hass, entry)
|
||||
await coordinator.async_restore_model()
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
entry.async_on_unload(entry.add_update_listener(async_reload_entry))
|
||||
return True
|
||||
|
||||
|
||||
async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
if ok:
|
||||
hass.data[DOMAIN].pop(entry.entry_id, None)
|
||||
return ok
|
||||
|
||||
|
||||
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Remove the learned model when this integration instance is deleted."""
|
||||
store = Store(hass, 1, f"{DOMAIN}.{entry.entry_id}")
|
||||
await store.async_remove()
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Read-only simulated ON/OFF demand. It never changes a physical switch."""
|
||||
from homeassistant.components.binary_sensor import BinarySensorEntity
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
async def async_setup_entry(hass, entry, async_add_entities):
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
async_add_entities([HeatingMPCProposal(coordinator, entry.entry_id)])
|
||||
|
||||
|
||||
class HeatingMPCProposal(CoordinatorEntity, BinarySensorEntity):
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = "Heating MPC Proposed Heat"
|
||||
_attr_icon = "mdi:fire"
|
||||
|
||||
def __init__(self, coordinator, entry_id):
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = f"{entry_id}_proposal"
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return self.coordinator.last_update_success and self.coordinator.data.get("status") == "ready"
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
return self.coordinator.data.get("proposal")
|
||||
@@ -0,0 +1,184 @@
|
||||
"""UI-only installation and reconfiguration: rooms, source priorities, weights."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import selector
|
||||
|
||||
from .const import (
|
||||
DOMAIN, CONF_CLIMATE, CONF_HEATER, CONF_OUTSIDE, CONF_ROOMS,
|
||||
CONF_WEIGHTS, CONF_HORIZON, CONF_MIN_ON, CONF_MIN_OFF,
|
||||
CONF_SWITCH_COST, CONF_HEAT_COST, CONF_BAND, DEFAULTS,
|
||||
WEATHER_SOURCES, WEATHER_MAIN, WEATHER_EXTRA,
|
||||
)
|
||||
from .weather_sources import sources_for
|
||||
|
||||
|
||||
def _entity_picker(multiple: bool = False):
|
||||
return selector.EntitySelector(
|
||||
selector.EntitySelectorConfig(domain="sensor", multiple=multiple,
|
||||
**({"reorder": True} if multiple else {}))
|
||||
)
|
||||
|
||||
|
||||
def _required(key: str, data: dict):
|
||||
return vol.Required(key, default=data[key]) if key in data else vol.Required(key)
|
||||
|
||||
|
||||
def _main_schema(config: dict[str, Any] | None = None, detailed: bool = False):
|
||||
data = config or {}
|
||||
schema: dict[Any, Any] = {
|
||||
_required(CONF_CLIMATE, data): selector.EntitySelector(
|
||||
selector.EntitySelectorConfig(domain="climate")),
|
||||
_required(CONF_HEATER, data): selector.EntitySelector(
|
||||
selector.EntitySelectorConfig(domain="switch")),
|
||||
_required(CONF_ROOMS, data): _entity_picker(multiple=True),
|
||||
}
|
||||
if detailed:
|
||||
for key, allowed in {
|
||||
CONF_HORIZON: vol.In([60, 90, 120, 150, 180]),
|
||||
CONF_MIN_ON: vol.All(vol.Coerce(int), vol.Range(min=5, max=60)),
|
||||
CONF_MIN_OFF: vol.All(vol.Coerce(int), vol.Range(min=5, max=60)),
|
||||
CONF_SWITCH_COST: vol.All(vol.Coerce(float), vol.Range(min=0, max=5)),
|
||||
CONF_HEAT_COST: vol.All(vol.Coerce(float), vol.Range(min=0, max=5)),
|
||||
CONF_BAND: vol.All(vol.Coerce(float), vol.Range(min=0.05, max=0.7)),
|
||||
}.items():
|
||||
schema[vol.Required(key, default=data.get(key, DEFAULTS[key]))] = allowed
|
||||
return vol.Schema(schema)
|
||||
|
||||
|
||||
def _weather_schema(config: dict[str, Any], metrics: tuple[str, ...]):
|
||||
schema = {}
|
||||
for metric in metrics:
|
||||
key = WEATHER_SOURCES[metric]
|
||||
selected = sources_for(config, metric)
|
||||
if metric == "temperature":
|
||||
# A temperature source is mandatory: other weather inputs are optional.
|
||||
marker = vol.Required(key, default=selected) if selected else vol.Required(key)
|
||||
else:
|
||||
marker = vol.Optional(key, default=selected)
|
||||
schema[marker] = _entity_picker(multiple=True)
|
||||
return vol.Schema(schema)
|
||||
|
||||
|
||||
def _weight_schema(rooms: list[str], existing: dict[str, float] | None = None):
|
||||
weights = existing or {}
|
||||
return vol.Schema({
|
||||
vol.Required(f"weight__{room}", default=weights.get(room, 1.0)):
|
||||
vol.All(vol.Coerce(float), vol.Range(min=0.1, max=10))
|
||||
for room in rooms
|
||||
})
|
||||
|
||||
|
||||
def _valid_rooms(rooms: list[str]) -> bool:
|
||||
return bool(rooms) and len(rooms) <= 12 and len(rooms) == len(set(rooms))
|
||||
|
||||
|
||||
def _valid_weather(rooms: list[str], data: dict) -> bool:
|
||||
sources = sources_for(data, "temperature")
|
||||
return bool(sources) and len(sources) <= 10 and not set(sources).intersection(rooms)
|
||||
|
||||
|
||||
class HeatingMPCConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Create a native observation-only MPC installation."""
|
||||
|
||||
VERSION = 2
|
||||
|
||||
def __init__(self):
|
||||
self._draft: dict[str, Any] = {}
|
||||
|
||||
async def async_step_user(self, user_input: dict[str, Any] | None = None):
|
||||
if user_input is not None:
|
||||
if not _valid_rooms(user_input[CONF_ROOMS]):
|
||||
return self.async_show_form(step_id="user", data_schema=_main_schema(),
|
||||
errors={"base": "invalid_rooms"})
|
||||
await self.async_set_unique_id("home_heating")
|
||||
self._abort_if_unique_id_configured()
|
||||
self._draft = user_input
|
||||
return await self.async_step_weather()
|
||||
return self.async_show_form(step_id="user", data_schema=_main_schema())
|
||||
|
||||
async def async_step_weather(self, user_input: dict[str, Any] | None = None):
|
||||
if user_input is not None:
|
||||
if not _valid_weather(self._draft[CONF_ROOMS], user_input):
|
||||
return self.async_show_form(step_id="weather", data_schema=_weather_schema(
|
||||
user_input, WEATHER_MAIN), errors={"base": "invalid_weather"})
|
||||
self._draft.update(user_input)
|
||||
return await self.async_step_weather_extra()
|
||||
return self.async_show_form(step_id="weather", data_schema=_weather_schema(
|
||||
self._draft, WEATHER_MAIN))
|
||||
|
||||
async def async_step_weather_extra(self, user_input: dict[str, Any] | None = None):
|
||||
if user_input is not None:
|
||||
self._draft.update(user_input)
|
||||
return await self.async_step_weights()
|
||||
return self.async_show_form(step_id="weather_extra", data_schema=_weather_schema(
|
||||
self._draft, WEATHER_EXTRA))
|
||||
|
||||
async def async_step_weights(self, user_input: dict[str, Any] | None = None):
|
||||
if user_input is not None:
|
||||
self._draft[CONF_WEIGHTS] = {
|
||||
room: float(user_input[f"weight__{room}"])
|
||||
for room in self._draft[CONF_ROOMS]
|
||||
}
|
||||
return self.async_create_entry(title="Heating MPC", data=self._draft)
|
||||
return self.async_show_form(step_id="weights", data_schema=_weight_schema(
|
||||
self._draft[CONF_ROOMS]))
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(config_entry: config_entries.ConfigEntry):
|
||||
return HeatingMPCOptionsFlow()
|
||||
|
||||
|
||||
class HeatingMPCOptionsFlow(config_entries.OptionsFlow):
|
||||
"""Edit sensors, ranked alternative weather providers and MPC parameters."""
|
||||
|
||||
def __init__(self):
|
||||
self._draft: dict[str, Any] = {}
|
||||
|
||||
async def async_step_init(self, user_input: dict[str, Any] | None = None):
|
||||
config = {**self.config_entry.data, **self.config_entry.options}
|
||||
if user_input is not None:
|
||||
if not _valid_rooms(user_input[CONF_ROOMS]):
|
||||
return self.async_show_form(step_id="init", data_schema=_main_schema(
|
||||
config, detailed=True), errors={"base": "invalid_rooms"})
|
||||
self._draft = user_input
|
||||
return await self.async_step_weather()
|
||||
return self.async_show_form(step_id="init", data_schema=_main_schema(
|
||||
config, detailed=True))
|
||||
|
||||
async def async_step_weather(self, user_input: dict[str, Any] | None = None):
|
||||
config = {**self.config_entry.data, **self.config_entry.options, **self._draft}
|
||||
if user_input is not None:
|
||||
if not _valid_weather(self._draft[CONF_ROOMS], user_input):
|
||||
return self.async_show_form(step_id="weather", data_schema=_weather_schema(
|
||||
user_input, WEATHER_MAIN), errors={"base": "invalid_weather"})
|
||||
self._draft.update(user_input)
|
||||
return await self.async_step_weather_extra()
|
||||
return self.async_show_form(step_id="weather", data_schema=_weather_schema(
|
||||
config, WEATHER_MAIN))
|
||||
|
||||
async def async_step_weather_extra(self, user_input: dict[str, Any] | None = None):
|
||||
config = {**self.config_entry.data, **self.config_entry.options, **self._draft}
|
||||
if user_input is not None:
|
||||
self._draft.update(user_input)
|
||||
return await self.async_step_weights()
|
||||
return self.async_show_form(step_id="weather_extra", data_schema=_weather_schema(
|
||||
config, WEATHER_EXTRA))
|
||||
|
||||
async def async_step_weights(self, user_input: dict[str, Any] | None = None):
|
||||
if user_input is not None:
|
||||
self._draft[CONF_WEIGHTS] = {
|
||||
room: float(user_input[f"weight__{room}"])
|
||||
for room in self._draft[CONF_ROOMS]
|
||||
}
|
||||
# Prevent an old single-temperature sensor from overriding new sources.
|
||||
self._draft.pop(CONF_OUTSIDE, None)
|
||||
return self.async_create_entry(title="", data=self._draft)
|
||||
config = {**self.config_entry.data, **self.config_entry.options}
|
||||
return self.async_show_form(step_id="weights", data_schema=_weight_schema(
|
||||
self._draft[CONF_ROOMS], config.get(CONF_WEIGHTS, {})))
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Configuration constants for Heating MPC."""
|
||||
DOMAIN = "heating_mpc"
|
||||
CONF_CLIMATE = "climate_entity"
|
||||
CONF_HEATER = "heater_entity"
|
||||
CONF_OUTSIDE = "outside_entity" # v0.1 legacy single temperature source
|
||||
CONF_ROOMS = "room_entities"
|
||||
CONF_WEIGHTS = "weights"
|
||||
CONF_HORIZON = "horizon_minutes"
|
||||
CONF_MIN_ON = "minimum_on_minutes"
|
||||
CONF_MIN_OFF = "minimum_off_minutes"
|
||||
CONF_SWITCH_COST = "switching_cost"
|
||||
CONF_HEAT_COST = "heating_cost"
|
||||
CONF_BAND = "comfort_band"
|
||||
|
||||
# Each source list is ordered: first valid sensor wins; remaining are fallbacks.
|
||||
WEATHER_SOURCES = {
|
||||
"temperature": "outside_temperature_sources",
|
||||
"wind": "outside_wind_sources",
|
||||
"clouds": "outside_clouds_sources",
|
||||
"humidity": "outside_humidity_sources",
|
||||
"feels_like": "outside_feelslike_sources",
|
||||
"pressure": "outside_pressure_sources",
|
||||
"visibility": "outside_visibility_sources",
|
||||
"rain": "outside_rain_sources",
|
||||
"snow": "outside_snow_sources",
|
||||
}
|
||||
WEATHER_MAIN = ("temperature", "wind", "clouds")
|
||||
WEATHER_EXTRA = ("humidity", "feels_like", "pressure", "visibility", "rain", "snow")
|
||||
|
||||
DEFAULTS = {
|
||||
CONF_HORIZON: 120,
|
||||
CONF_MIN_ON: 15,
|
||||
CONF_MIN_OFF: 10,
|
||||
CONF_SWITCH_COST: 0.15,
|
||||
CONF_HEAT_COST: 0.025,
|
||||
CONF_BAND: 0.15,
|
||||
}
|
||||
SAMPLE_MINUTES = 5
|
||||
BLOCK_MINUTES = 15
|
||||
@@ -0,0 +1,199 @@
|
||||
"""Read existing HA entities, learn thermal response, produce shadow MPC decisions."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from functools import partial
|
||||
from math import isfinite
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.helpers.storage import Store
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .const import (
|
||||
DOMAIN, CONF_CLIMATE, CONF_HEATER, CONF_ROOMS, CONF_WEIGHTS, WEATHER_SOURCES,
|
||||
CONF_HORIZON, CONF_MIN_ON, CONF_MIN_OFF, CONF_SWITCH_COST, CONF_HEAT_COST,
|
||||
CONF_BAND, DEFAULTS,
|
||||
)
|
||||
from .physics import HEAT_LAG, Learner, optimize
|
||||
from .weather_sources import choose_weather_sources, sources_for, normalize
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"""The existing generic thermostat stays the only actuator owner."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=timedelta(minutes=5))
|
||||
self.entry = entry
|
||||
self.config = {**DEFAULTS, **entry.data, **entry.options}
|
||||
self.rooms: list[str] = self.config[CONF_ROOMS]
|
||||
self.learners: dict[str, Learner] = {room: Learner() for room in self.rooms}
|
||||
self._store = Store(hass, 1, f"{DOMAIN}.{entry.entry_id}")
|
||||
self._previous: dict[str, Any] | None = None
|
||||
self._heat_lag: float | None = None
|
||||
self._on_samples = 0
|
||||
self._off_samples = 0
|
||||
|
||||
async def async_restore_model(self) -> None:
|
||||
stored = await self._store.async_load() or {}
|
||||
# Replacing the actual heating system invalidates heating coefficients.
|
||||
# Switching among weather providers does not discard months of learning.
|
||||
if stored.get("heater_entity", self.config[CONF_HEATER]) != self.config[CONF_HEATER]:
|
||||
_LOGGER.warning("Heating MPC heater changed; starting a fresh thermal model")
|
||||
return
|
||||
# Models carry room identity, so changing the room list is safe.
|
||||
for room, data in stored.get("learners", {}).items():
|
||||
if room in self.learners:
|
||||
self.learners[room] = Learner.from_dict(data)
|
||||
self._on_samples = max(0, int(stored.get("on_samples", 0)))
|
||||
self._off_samples = max(0, int(stored.get("off_samples", 0)))
|
||||
|
||||
def _value(self, entity_id: str, low: float, high: float) -> float | None:
|
||||
state = self.hass.states.get(entity_id)
|
||||
if state is None or state.state in ("unknown", "unavailable"):
|
||||
return None
|
||||
value = normalize("temperature", state.state, state.attributes.get("unit_of_measurement"))
|
||||
if value is None or not low <= value <= high:
|
||||
return None
|
||||
return value
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
now = datetime.now(timezone.utc)
|
||||
heater = self.hass.states.get(self.config[CONF_HEATER])
|
||||
climate = self.hass.states.get(self.config[CONF_CLIMATE])
|
||||
# Choose each weather variable independently from ordered sensor sources.
|
||||
# Sensor priorities are editable in Options without touching integration code.
|
||||
weather_entity_ids = {
|
||||
entity_id for metric in WEATHER_SOURCES
|
||||
for entity_id in sources_for(self.config, metric)
|
||||
}
|
||||
weather_states = {
|
||||
entity_id: (state.state, state.attributes.get("unit_of_measurement"))
|
||||
for entity_id in weather_entity_ids
|
||||
if (state := self.hass.states.get(entity_id)) is not None
|
||||
}
|
||||
weather, active_sources, weather_comparisons = choose_weather_sources(
|
||||
self.config, weather_states
|
||||
)
|
||||
outside = weather["temperature"]
|
||||
temps = {room: self._value(room, 0.0, 45.0) for room in self.rooms}
|
||||
status: dict[str, Any] = {
|
||||
"status": "waiting_for_sensors", "proposal": None, "prediction_60m": {},
|
||||
"temperatures": {r: t for r, t in temps.items() if t is not None},
|
||||
"on_samples": self._on_samples, "off_samples": self._off_samples,
|
||||
"samples": min(learner.count for learner in self.learners.values()),
|
||||
"mode": climate.state if climate else "unavailable",
|
||||
"weather": {k: v for k, v in weather.items() if v is not None},
|
||||
"weather_active_sources": {k: v for k, v in active_sources.items() if v},
|
||||
"weather_comparison": {k: v for k, v in weather_comparisons.items() if v},
|
||||
"weather_model_inputs": ["temperature"],
|
||||
}
|
||||
if heater is None or heater.state not in ("on", "off") or climate is None:
|
||||
self._previous = None
|
||||
self._heat_lag = None
|
||||
return status
|
||||
if outside is None or any(temp is None for temp in temps.values()):
|
||||
self._previous = None
|
||||
self._heat_lag = None
|
||||
return status
|
||||
|
||||
# No casting surprises: all rooms are valid here.
|
||||
vals: dict[str, float] = {room: float(temps[room]) for room in self.rooms}
|
||||
heater_on = heater.state == "on"
|
||||
if self._heat_lag is None:
|
||||
self._heat_lag = float(heater_on)
|
||||
if self._previous is not None:
|
||||
dt = (now - self._previous["time"]).total_seconds()
|
||||
# Do not fit one transition across a provider/fallback switch.
|
||||
if (240 <= dt <= 420 and
|
||||
self._previous["outside_source"] == active_sources["temperature"]):
|
||||
next_lag = self._heat_lag + HEAT_LAG * (float(self._previous["heater_on"]) - self._heat_lag)
|
||||
for room in self.rooms:
|
||||
self.learners[room].learn(
|
||||
self._previous["temps"][room], vals[room],
|
||||
self._previous["outside"], next_lag
|
||||
)
|
||||
self._heat_lag = next_lag
|
||||
if self._previous["heater_on"]:
|
||||
self._on_samples += 1
|
||||
else:
|
||||
self._off_samples += 1
|
||||
# Persist at each sample so restart does not erase hours of training.
|
||||
await self._store.async_save({
|
||||
"learners": {room: learner.as_dict() for room, learner in self.learners.items()},
|
||||
"on_samples": self._on_samples, "off_samples": self._off_samples,
|
||||
"heater_entity": self.config[CONF_HEATER],
|
||||
"outside_source": active_sources["temperature"],
|
||||
})
|
||||
else:
|
||||
# Model lag state becomes uncertain after long gaps.
|
||||
self._heat_lag = float(heater_on)
|
||||
|
||||
self._previous = {
|
||||
"time": now, "temps": vals, "outside": outside, "heater_on": heater_on,
|
||||
"outside_source": active_sources["temperature"],
|
||||
}
|
||||
weights = self.config.get(CONF_WEIGHTS, {})
|
||||
weights = {r: float(weights.get(r, 1.0)) for r in self.rooms}
|
||||
weighted_temp = sum(weights[r] * vals[r] for r in self.rooms) / sum(weights.values())
|
||||
status.update({
|
||||
"weighted_temperature": round(weighted_temp, 3),
|
||||
"outside_temperature": outside,
|
||||
"reference_heat": heater_on,
|
||||
"on_samples": self._on_samples,
|
||||
"off_samples": self._off_samples,
|
||||
"samples": min(learner.count for learner in self.learners.values()),
|
||||
"models": {
|
||||
room: {"loss": round(model.theta[0], 6),
|
||||
"heat": round(model.theta[1], 6),
|
||||
"bias": round(model.theta[2], 6),
|
||||
"mae_5m": round(model.mae, 4) if model.mae is not None else None}
|
||||
for room, model in self.learners.items()
|
||||
},
|
||||
})
|
||||
if climate.state != "heat":
|
||||
status["status"] = "paused"
|
||||
return status
|
||||
try:
|
||||
target = float(climate.attributes["temperature"])
|
||||
if not isfinite(target) or not 5 <= target <= 35:
|
||||
raise ValueError
|
||||
except (TypeError, KeyError, ValueError):
|
||||
status["status"] = "waiting_for_target"
|
||||
return status
|
||||
status["target"] = target
|
||||
# Do not act on a cold model: need ~17 hours of samples plus both conditions.
|
||||
if status["samples"] < 200 or self._on_samples < 30 or self._off_samples < 30:
|
||||
status["status"] = "learning"
|
||||
return status
|
||||
try:
|
||||
# 180-minute exhaustive search can take several seconds on RPi5;
|
||||
# never block Home Assistant's event loop with CPU work.
|
||||
job = partial(
|
||||
optimize,
|
||||
vals, outside, self._heat_lag, target, weights, self.learners,
|
||||
heater_on, (now - heater.last_changed).total_seconds(),
|
||||
horizon_minutes=int(self.config[CONF_HORIZON]),
|
||||
minimum_on_minutes=int(self.config[CONF_MIN_ON]),
|
||||
minimum_off_minutes=int(self.config[CONF_MIN_OFF]),
|
||||
switching_cost=float(self.config[CONF_SWITCH_COST]),
|
||||
heating_cost=float(self.config[CONF_HEAT_COST]),
|
||||
comfort_band=float(self.config[CONF_BAND]),
|
||||
)
|
||||
output = await self.hass.async_add_executor_job(job)
|
||||
except (ValueError, RuntimeError, OverflowError) as err:
|
||||
_LOGGER.warning("MPC prediction failed: %s", err)
|
||||
status["status"] = "prediction_error"
|
||||
return status
|
||||
status.update({
|
||||
"status": "ready", "proposal": output["proposal"],
|
||||
"prediction_60m": output["predicted_60m"],
|
||||
"prediction_end": output["predicted_end"],
|
||||
"plan_15min": output["plan_15min"], "objective": output["cost"],
|
||||
"model_quality_note": "5-minute in-sample MAE; not a validated 60-minute forecast error",
|
||||
})
|
||||
return status
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"domain": "heating_mpc",
|
||||
"name": "Heating MPC",
|
||||
"codeowners": [],
|
||||
"config_flow": true,
|
||||
"dependencies": [],
|
||||
"documentation": "https://www.home-assistant.io/docs/configuration/",
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "0.2.1"
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Pure-Python small grey-box thermal model and binary model predictive control.
|
||||
|
||||
No Home Assistant imports; this module can be tested in isolation. Step=5 minutes.
|
||||
This is an experimental thermal model, not a certified boiler controller.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from itertools import product
|
||||
from math import exp, isfinite
|
||||
from typing import Any
|
||||
|
||||
STEP_MINUTES = 5
|
||||
HEAT_LAG = 1.0 - exp(-STEP_MINUTES / 20.0)
|
||||
|
||||
|
||||
def bound(value: float, lo: float, hi: float) -> float:
|
||||
return max(lo, min(hi, value))
|
||||
|
||||
|
||||
@dataclass
|
||||
class Learner:
|
||||
"""Recursive least squares for dT=a*(Tout-T)+b*effective_heat+c."""
|
||||
|
||||
theta: list[float] = field(default_factory=lambda: [0.002, 0.045, 0.0])
|
||||
p: list[list[float]] = field(default_factory=lambda: [[0.0005, 0., 0.], [0., 0.8, 0.], [0., 0., 0.08]])
|
||||
count: int = 0
|
||||
error_sum: float = 0.0
|
||||
|
||||
def predict_delta(self, temp: float, outside: float, heat: float) -> float:
|
||||
a, b, c = self.theta
|
||||
return a * (outside - temp) + b * heat + c
|
||||
|
||||
def learn(self, temp_before: float, temp_after: float, outside: float, effective_heat: float) -> None:
|
||||
if not all(isfinite(x) for x in (temp_before, temp_after, outside, effective_heat)):
|
||||
return
|
||||
y = temp_after - temp_before
|
||||
if abs(y) > 0.8: # sensor glitch/door opened/restarted device
|
||||
return
|
||||
x = [outside - temp_before, effective_heat, 1.0]
|
||||
predicted = sum(t * v for t, v in zip(self.theta, x))
|
||||
residual = y - predicted
|
||||
px = [sum(self.p[i][j] * x[j] for j in range(3)) for i in range(3)]
|
||||
forgetting = 0.999
|
||||
denominator = forgetting + sum(x[i] * px[i] for i in range(3))
|
||||
if denominator <= 0:
|
||||
return
|
||||
k = [value / denominator for value in px]
|
||||
self.theta = [t + k[i] * residual for i, t in enumerate(self.theta)]
|
||||
for i in range(3):
|
||||
for j in range(3):
|
||||
self.p[i][j] = (self.p[i][j] - k[i] * px[j]) / forgetting
|
||||
# Physical constraints; avoids unstable temperature predictions.
|
||||
self.theta = [
|
||||
bound(self.theta[0], 0.0, 0.04),
|
||||
bound(self.theta[1], 0.0, 0.25),
|
||||
bound(self.theta[2], -0.06, 0.06),
|
||||
]
|
||||
self.count += 1
|
||||
self.error_sum += abs(residual)
|
||||
|
||||
@property
|
||||
def mae(self) -> float | None:
|
||||
return self.error_sum / self.count if self.count else None
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {"theta": self.theta, "p": self.p, "count": self.count, "error_sum": self.error_sum}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, source: dict[str, Any]) -> "Learner":
|
||||
obj = cls()
|
||||
try:
|
||||
theta = [float(v) for v in source["theta"]]
|
||||
p = [[float(v) for v in r] for r in source["p"]]
|
||||
if len(theta) != 3 or len(p) != 3 or any(len(r) != 3 for r in p):
|
||||
return obj
|
||||
if not all(isfinite(v) for v in theta + [v for row in p for v in row]):
|
||||
return obj
|
||||
obj.theta, obj.p = theta, p
|
||||
obj.count = max(0, int(source.get("count", 0)))
|
||||
obj.error_sum = max(0., float(source.get("error_sum", 0)))
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return cls()
|
||||
return obj
|
||||
|
||||
|
||||
def simulate_step(temps: dict[str, float], outside: float, lag: float,
|
||||
command: bool, learners: dict[str, Learner]) -> tuple[dict[str, float], float]:
|
||||
next_lag = lag + HEAT_LAG * (float(command) - lag)
|
||||
new = {name: value + learners[name].predict_delta(value, outside, next_lag)
|
||||
for name, value in temps.items()}
|
||||
return new, next_lag
|
||||
|
||||
|
||||
def optimize(
|
||||
temps: dict[str, float], outside: float, effective_heat: float,
|
||||
target: float, weights: dict[str, float], learners: dict[str, Learner],
|
||||
is_on: bool, seconds_since_change: float, horizon_minutes: int = 120,
|
||||
minimum_on_minutes: int = 15, minimum_off_minutes: int = 10,
|
||||
switching_cost: float = .15, heating_cost: float = .025,
|
||||
comfort_band: float = .15,
|
||||
) -> dict[str, Any]:
|
||||
"""Enumerate 15min ON/OFF plans; honor minimum ON/OFF durations.
|
||||
|
||||
120min = 256 plans (fast on Raspberry Pi 5). All rooms share one burner demand.
|
||||
"""
|
||||
if not temps or set(temps) != set(learners):
|
||||
raise ValueError("Missing room temperatures or learner")
|
||||
if not 30 <= horizon_minutes <= 180 or horizon_minutes % 15:
|
||||
raise ValueError("Horizon must be 30..180 minutes in 15-minute steps")
|
||||
if not isfinite(target) or not isfinite(outside):
|
||||
raise ValueError("Invalid temperature")
|
||||
if any(not isfinite(t) for t in temps.values()):
|
||||
raise ValueError("Invalid room temperature")
|
||||
if any(float(weights.get(room, 0)) <= 0 for room in temps):
|
||||
raise ValueError("All rooms need a positive weight")
|
||||
total_weight = sum(float(weights[room]) for room in temps)
|
||||
norm = {room: float(weights[room]) / total_weight for room in temps}
|
||||
blocks = horizon_minutes // 15
|
||||
nsteps_per_block = 3
|
||||
best = float("inf")
|
||||
result = None
|
||||
min_on_s = minimum_on_minutes * 60
|
||||
min_off_s = minimum_off_minutes * 60
|
||||
elapsed_initial = max(0.0, seconds_since_change)
|
||||
# More sequences than 4096 are deliberately excluded by horizon validation.
|
||||
for bits in product((False, True), repeat=blocks):
|
||||
current = bool(is_on)
|
||||
elapsed_s = elapsed_initial
|
||||
state = dict(temps)
|
||||
lag = effective_heat
|
||||
cost = 0.0
|
||||
at_60 = None
|
||||
feasible = True
|
||||
for idx, bit in enumerate(bits):
|
||||
if bit != current:
|
||||
minimum = min_on_s if current else min_off_s
|
||||
if elapsed_s < minimum:
|
||||
feasible = False
|
||||
break
|
||||
cost += switching_cost
|
||||
elapsed_s = 0.0
|
||||
current = bit
|
||||
for tick in range(nsteps_per_block):
|
||||
state, lag = simulate_step(state, outside, lag, bit, learners)
|
||||
mismatch = sum(
|
||||
norm[room] * max(0.0, abs(value - target) - comfort_band) ** 2
|
||||
for room, value in state.items()
|
||||
)
|
||||
# A lower bound on weighted average comfort also matters.
|
||||
avg = sum(norm[r] * value for r, value in state.items())
|
||||
mismatch += max(0.0, abs(avg - target) - comfort_band) ** 2
|
||||
cost += 8.0 * mismatch + (heating_cost if bit else 0.0)
|
||||
if idx * 15 + (tick + 1) * STEP_MINUTES == 60:
|
||||
at_60 = dict(state)
|
||||
elapsed_s += 15 * 60
|
||||
if feasible and cost < best:
|
||||
best = cost
|
||||
result = (bits, at_60 or dict(state), dict(state))
|
||||
if result is None:
|
||||
raise RuntimeError("No feasible schedule")
|
||||
plan, temps_60m, temps_end = result
|
||||
return {
|
||||
"proposal": bool(plan[0]),
|
||||
"plan_15min": [int(v) for v in plan],
|
||||
"predicted_60m": {r: round(t, 3) for r, t in temps_60m.items()},
|
||||
"predicted_end": {r: round(t, 3) for r, t in temps_end.items()},
|
||||
"cost": round(best, 4),
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Diagnostics and predicted temperatures exposed as HA sensors."""
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity, SensorDeviceClass
|
||||
from homeassistant.const import UnitOfTemperature
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
async def async_setup_entry(hass, entry, async_add_entities) -> None:
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
entities = [MPCStatus(coordinator, entry.entry_id), MPCWeightedTemp(coordinator, entry.entry_id), MPCOutdoorTemperature(coordinator, entry.entry_id)]
|
||||
entities += [MPCRoomPrediction(coordinator, entry.entry_id, room) for room in coordinator.rooms]
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class MPCStatus(CoordinatorEntity, SensorEntity):
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = "Heating MPC Status"
|
||||
_attr_icon = "mdi:brain"
|
||||
|
||||
def __init__(self, coordinator, entry_id):
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = f"{entry_id}_status"
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
return self.coordinator.data.get("status")
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self):
|
||||
data = self.coordinator.data
|
||||
return {key: value for key, value in data.items() if key not in ("status", "prediction_60m", "prediction_end")}
|
||||
|
||||
|
||||
class MPCWeightedTemp(CoordinatorEntity, SensorEntity):
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = "Heating MPC Weighted Temperature"
|
||||
_attr_device_class = SensorDeviceClass.TEMPERATURE
|
||||
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
|
||||
|
||||
def __init__(self, coordinator, entry_id):
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = f"{entry_id}_weighted_temp"
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
return self.coordinator.data.get("weighted_temperature")
|
||||
|
||||
|
||||
class MPCRoomPrediction(CoordinatorEntity, SensorEntity):
|
||||
_attr_has_entity_name = True
|
||||
_attr_device_class = SensorDeviceClass.TEMPERATURE
|
||||
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
|
||||
_attr_icon = "mdi:chart-line"
|
||||
|
||||
def __init__(self, coordinator, entry_id, room):
|
||||
super().__init__(coordinator)
|
||||
self.room = room
|
||||
self._attr_unique_id = f"{entry_id}_{room}_predicted_60min"
|
||||
self._attr_name = f"MPC {room} 60min Prediction"
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
return self.coordinator.data.get("prediction_60m", {}).get(self.room)
|
||||
|
||||
class MPCOutdoorTemperature(CoordinatorEntity, SensorEntity):
|
||||
"""Display chosen outdoor temperature and all currently valid alternatives."""
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = "MPC Outdoor Temperature"
|
||||
_attr_device_class = SensorDeviceClass.TEMPERATURE
|
||||
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
|
||||
_attr_icon = "mdi:thermometer"
|
||||
|
||||
def __init__(self, coordinator, entry_id):
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = f"{entry_id}_outside_temperature"
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
return self.coordinator.data.get("weather", {}).get("temperature")
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self):
|
||||
data = self.coordinator.data
|
||||
return {
|
||||
"active_source": data.get("weather_active_sources", {}).get("temperature"),
|
||||
"alternatives": data.get("weather_comparison", {}).get("temperature", {}),
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"title": "Heating MPC",
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Heating MPC — room inputs",
|
||||
"description": "Choose thermostat, read-only relay and room sensors. This integration NEVER commands the heater.",
|
||||
"data": {
|
||||
"climate_entity": "Existing Generic Thermostat",
|
||||
"heater_entity": "Physical heating switch (read-only)",
|
||||
"room_entities": "Individual room temperature sensors"
|
||||
}
|
||||
},
|
||||
"weather": {
|
||||
"title": "Primary weather sources",
|
||||
"description": "Choose as many sensors per metric as needed and arrange them in priority order. If the preferred source is unavailable, the next one is used automatically. Outdoor temperature is mandatory; wind and cloud coverage are diagnostic-only in this version.",
|
||||
"data": {
|
||||
"outside_temperature_sources": "Outdoor temperature sources (ordered: preferred first)",
|
||||
"outside_wind_sources": "Wind speed sources (optional)",
|
||||
"outside_clouds_sources": "Cloud coverage sources (optional)"
|
||||
}
|
||||
},
|
||||
"weather_extra": {
|
||||
"title": "Additional weather sources",
|
||||
"description": "Additional weather inputs are optional and used only for comparison/diagnostics in v0.2; they do not yet change MPC predictions.",
|
||||
"data": {
|
||||
"outside_humidity_sources": "Outdoor humidity sources (optional)",
|
||||
"outside_feelslike_sources": "Feels-like temperature sources (optional)",
|
||||
"outside_pressure_sources": "Atmospheric pressure sources (optional)",
|
||||
"outside_visibility_sources": "Visibility sources (optional)",
|
||||
"outside_rain_sources": "Rain sources (optional)",
|
||||
"outside_snow_sources": "Snow sources (optional)"
|
||||
}
|
||||
},
|
||||
"weights": {
|
||||
"title": "Room priorities",
|
||||
"description": "Each room has a relative comfort weight between 0.1 and 10; sensor entity is shown by each field."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"invalid_rooms": "Select 1–12 distinct room sensors.",
|
||||
"invalid_weather": "Select at least one outdoor temperature sensor, distinct from room sensors."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Heating MPC is already configured."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Heating MPC options",
|
||||
"description": "Change room sensors and controller settings. This integration remains shadow-only.",
|
||||
"data": {
|
||||
"climate_entity": "Existing Generic Thermostat",
|
||||
"heater_entity": "Physical heating switch (read-only)",
|
||||
"room_entities": "Individual room temperature sensors",
|
||||
"horizon_minutes": "Prediction horizon (minutes)",
|
||||
"minimum_on_minutes": "Minimum ON time (minutes)",
|
||||
"minimum_off_minutes": "Minimum OFF time (minutes)",
|
||||
"switching_cost": "Switching cost",
|
||||
"heating_cost": "Heating runtime cost",
|
||||
"comfort_band": "Comfort tolerance (°C)"
|
||||
}
|
||||
},
|
||||
"weather": {
|
||||
"title": "Weather source priority",
|
||||
"description": "Choose as many sensors per metric as needed and arrange them in priority order. If the preferred source is unavailable, the next one is used automatically. Outdoor temperature is mandatory; wind and cloud coverage are diagnostic-only in this version.",
|
||||
"data": {
|
||||
"outside_temperature_sources": "Outdoor temperature sources (ordered: preferred first)",
|
||||
"outside_wind_sources": "Wind speed sources (optional)",
|
||||
"outside_clouds_sources": "Cloud coverage sources (optional)"
|
||||
}
|
||||
},
|
||||
"weather_extra": {
|
||||
"title": "Additional weather sensors",
|
||||
"description": "Additional weather inputs are optional and used only for comparison/diagnostics in v0.2; they do not yet change MPC predictions.",
|
||||
"data": {
|
||||
"outside_humidity_sources": "Outdoor humidity sources (optional)",
|
||||
"outside_feelslike_sources": "Feels-like temperature sources (optional)",
|
||||
"outside_pressure_sources": "Atmospheric pressure sources (optional)",
|
||||
"outside_visibility_sources": "Visibility sources (optional)",
|
||||
"outside_rain_sources": "Rain sources (optional)",
|
||||
"outside_snow_sources": "Snow sources (optional)"
|
||||
}
|
||||
},
|
||||
"weights": {
|
||||
"title": "Room priorities",
|
||||
"description": "Weights determine relative comfort importance, not physics coefficients."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"invalid_rooms": "Select 1–12 distinct room sensors.",
|
||||
"invalid_weather": "Select at least one outdoor temperature sensor, distinct from room sensors."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"title": "Heating MPC",
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Heating MPC — room inputs",
|
||||
"description": "Choose thermostat, read-only relay and room sensors. This integration NEVER commands the heater.",
|
||||
"data": {
|
||||
"climate_entity": "Existing Generic Thermostat",
|
||||
"heater_entity": "Physical heating switch (read-only)",
|
||||
"room_entities": "Individual room temperature sensors"
|
||||
}
|
||||
},
|
||||
"weather": {
|
||||
"title": "Primary weather sources",
|
||||
"description": "Choose as many sensors per metric as needed and arrange them in priority order. If the preferred source is unavailable, the next one is used automatically. Outdoor temperature is mandatory; wind and cloud coverage are diagnostic-only in this version.",
|
||||
"data": {
|
||||
"outside_temperature_sources": "Outdoor temperature sources (ordered: preferred first)",
|
||||
"outside_wind_sources": "Wind speed sources (optional)",
|
||||
"outside_clouds_sources": "Cloud coverage sources (optional)"
|
||||
}
|
||||
},
|
||||
"weather_extra": {
|
||||
"title": "Additional weather sources",
|
||||
"description": "Additional weather inputs are optional and used only for comparison/diagnostics in v0.2; they do not yet change MPC predictions.",
|
||||
"data": {
|
||||
"outside_humidity_sources": "Outdoor humidity sources (optional)",
|
||||
"outside_feelslike_sources": "Feels-like temperature sources (optional)",
|
||||
"outside_pressure_sources": "Atmospheric pressure sources (optional)",
|
||||
"outside_visibility_sources": "Visibility sources (optional)",
|
||||
"outside_rain_sources": "Rain sources (optional)",
|
||||
"outside_snow_sources": "Snow sources (optional)"
|
||||
}
|
||||
},
|
||||
"weights": {
|
||||
"title": "Room priorities",
|
||||
"description": "Each room has a relative comfort weight between 0.1 and 10; sensor entity is shown by each field."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"invalid_rooms": "Select 1–12 distinct room sensors.",
|
||||
"invalid_weather": "Select at least one outdoor temperature sensor, distinct from room sensors."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Heating MPC is already configured."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Heating MPC options",
|
||||
"description": "Change room sensors and controller settings. This integration remains shadow-only.",
|
||||
"data": {
|
||||
"climate_entity": "Existing Generic Thermostat",
|
||||
"heater_entity": "Physical heating switch (read-only)",
|
||||
"room_entities": "Individual room temperature sensors",
|
||||
"horizon_minutes": "Prediction horizon (minutes)",
|
||||
"minimum_on_minutes": "Minimum ON time (minutes)",
|
||||
"minimum_off_minutes": "Minimum OFF time (minutes)",
|
||||
"switching_cost": "Switching cost",
|
||||
"heating_cost": "Heating runtime cost",
|
||||
"comfort_band": "Comfort tolerance (°C)"
|
||||
}
|
||||
},
|
||||
"weather": {
|
||||
"title": "Weather source priority",
|
||||
"description": "Choose as many sensors per metric as needed and arrange them in priority order. If the preferred source is unavailable, the next one is used automatically. Outdoor temperature is mandatory; wind and cloud coverage are diagnostic-only in this version.",
|
||||
"data": {
|
||||
"outside_temperature_sources": "Outdoor temperature sources (ordered: preferred first)",
|
||||
"outside_wind_sources": "Wind speed sources (optional)",
|
||||
"outside_clouds_sources": "Cloud coverage sources (optional)"
|
||||
}
|
||||
},
|
||||
"weather_extra": {
|
||||
"title": "Additional weather sensors",
|
||||
"description": "Additional weather inputs are optional and used only for comparison/diagnostics in v0.2; they do not yet change MPC predictions.",
|
||||
"data": {
|
||||
"outside_humidity_sources": "Outdoor humidity sources (optional)",
|
||||
"outside_feelslike_sources": "Feels-like temperature sources (optional)",
|
||||
"outside_pressure_sources": "Atmospheric pressure sources (optional)",
|
||||
"outside_visibility_sources": "Visibility sources (optional)",
|
||||
"outside_rain_sources": "Rain sources (optional)",
|
||||
"outside_snow_sources": "Snow sources (optional)"
|
||||
}
|
||||
},
|
||||
"weights": {
|
||||
"title": "Room priorities",
|
||||
"description": "Weights determine relative comfort importance, not physics coefficients."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"invalid_rooms": "Select 1–12 distinct room sensors.",
|
||||
"invalid_weather": "Select at least one outdoor temperature sensor, distinct from room sensors."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"title": "Heating MPC",
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Heating MPC — configurare camere",
|
||||
"description": "Selectează termostatul, releul de monitorizat și senzorii camerelor. Integrarea NU comandă centrala.",
|
||||
"data": {
|
||||
"climate_entity": "Termostatul Generic Thermostat existent",
|
||||
"heater_entity": "Releul fizic (numai citire)",
|
||||
"room_entities": "Senzorii individuali din camere"
|
||||
}
|
||||
},
|
||||
"weather": {
|
||||
"title": "Surse meteo principale",
|
||||
"description": "Alege câți senzori dorești pentru fiecare variabilă și ordonează-i după prioritate. Când prima sursă este indisponibilă, integrarea încearcă automat următoarea. Temperatura exterioară este obligatorie; vântul și nebulozitatea sunt acum doar diagnostice.",
|
||||
"data": {
|
||||
"outside_temperature_sources": "Senzori temperatură exterioară (prioritatea: primul selectat)",
|
||||
"outside_wind_sources": "Senzori viteză vânt (opțional)",
|
||||
"outside_clouds_sources": "Senzori nebulozitate (opțional)"
|
||||
}
|
||||
},
|
||||
"weather_extra": {
|
||||
"title": "Surse meteo suplimentare",
|
||||
"description": "Datele suplimentare sunt opționale și doar pentru afișare și comparație în v0.2, nu intră încă în optimizarea MPC.",
|
||||
"data": {
|
||||
"outside_humidity_sources": "Senzori umiditate exterioară (opțional)",
|
||||
"outside_feelslike_sources": "Senzori temperatură resimțită (opțional)",
|
||||
"outside_pressure_sources": "Senzori presiune atmosferică (opțional)",
|
||||
"outside_visibility_sources": "Senzori vizibilitate (opțional)",
|
||||
"outside_rain_sources": "Senzori precipitații ploaie (opțional)",
|
||||
"outside_snow_sources": "Senzori precipitații ninsoare (opțional)"
|
||||
}
|
||||
},
|
||||
"weights": {
|
||||
"title": "Ponderile camerelor",
|
||||
"description": "Fiecare cameră are o pondere de confort între 0,1 și 10; senzorul este indicat în numele câmpului."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"invalid_rooms": "Selectează 1–12 senzori diferiți.",
|
||||
"invalid_weather": "Alege cel puțin un senzor de temperatură exterioară, diferit de senzorii camerelor."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Heating MPC este deja configurat."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Configurare Heating MPC",
|
||||
"description": "Modifică senzorii, ponderile și optimizatorul. Rămâne activ doar modul de observație.",
|
||||
"data": {
|
||||
"climate_entity": "Termostatul Generic Thermostat existent",
|
||||
"heater_entity": "Releul fizic (numai citire)",
|
||||
"room_entities": "Senzorii individuali din camere",
|
||||
"horizon_minutes": "Orizontul predicției (minute)",
|
||||
"minimum_on_minutes": "Durată minimă ON (minute)",
|
||||
"minimum_off_minutes": "Durată minimă OFF (minute)",
|
||||
"switching_cost": "Penalizare comutări",
|
||||
"heating_cost": "Penalizare durată încălzire",
|
||||
"comfort_band": "Toleranță confort (°C)"
|
||||
}
|
||||
},
|
||||
"weather": {
|
||||
"title": "Prioritatea surselor meteo",
|
||||
"description": "Alege câți senzori dorești pentru fiecare variabilă și ordonează-i după prioritate. Când prima sursă este indisponibilă, integrarea încearcă automat următoarea. Temperatura exterioară este obligatorie; vântul și nebulozitatea sunt acum doar diagnostice.",
|
||||
"data": {
|
||||
"outside_temperature_sources": "Senzori temperatură exterioară (prioritatea: primul selectat)",
|
||||
"outside_wind_sources": "Senzori viteză vânt (opțional)",
|
||||
"outside_clouds_sources": "Senzori nebulozitate (opțional)"
|
||||
}
|
||||
},
|
||||
"weather_extra": {
|
||||
"title": "Senzori meteo suplimentari",
|
||||
"description": "Datele suplimentare sunt opționale și doar pentru afișare și comparație în v0.2, nu intră încă în optimizarea MPC.",
|
||||
"data": {
|
||||
"outside_humidity_sources": "Senzori umiditate exterioară (opțional)",
|
||||
"outside_feelslike_sources": "Senzori temperatură resimțită (opțional)",
|
||||
"outside_pressure_sources": "Senzori presiune atmosferică (opțional)",
|
||||
"outside_visibility_sources": "Senzori vizibilitate (opțional)",
|
||||
"outside_rain_sources": "Senzori precipitații ploaie (opțional)",
|
||||
"outside_snow_sources": "Senzori precipitații ninsoare (opțional)"
|
||||
}
|
||||
},
|
||||
"weights": {
|
||||
"title": "Ponderi pentru camere",
|
||||
"description": "Ponderile afectează importanța confortului, nu coeficienții fizici."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"invalid_rooms": "Selectează 1–12 senzori diferiți.",
|
||||
"invalid_weather": "Alege cel puțin un senzor de temperatură exterioară, diferit de senzorii camerelor."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Read-only weather source selection and unit normalization.
|
||||
|
||||
Pure-Python helpers so source and unit behavior can be tested without HAOS.
|
||||
Optional weather variables are observability inputs in v0.2, not MPC features.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from math import isfinite
|
||||
|
||||
from .const import CONF_OUTSIDE, WEATHER_SOURCES
|
||||
|
||||
|
||||
LIMITS = {
|
||||
"temperature": (-45.0, 55.0),
|
||||
"feels_like": (-65.0, 65.0),
|
||||
"wind": (0.0, 90.0), # m/s
|
||||
"clouds": (0.0, 100.0), # percentage
|
||||
"humidity": (0.0, 100.0), # percentage
|
||||
"pressure": (800.0, 1100.0), # hPa
|
||||
"visibility": (0.0, 100000.0), # metres
|
||||
"rain": (0.0, 500.0), # mm (sampling period defined by upstream sensor)
|
||||
"snow": (0.0, 500.0),
|
||||
}
|
||||
|
||||
|
||||
def sources_for(config: dict, metric: str) -> list[str]:
|
||||
"""Return ordered de-duplicated entity IDs, including v0.1 migration."""
|
||||
key = WEATHER_SOURCES[metric]
|
||||
sources = config.get(key)
|
||||
if sources is None and metric == "temperature":
|
||||
sources = config.get(CONF_OUTSIDE)
|
||||
if isinstance(sources, str):
|
||||
sources = [sources]
|
||||
if not isinstance(sources, (list, tuple)):
|
||||
return []
|
||||
return list(dict.fromkeys(s for s in sources if isinstance(s, str) and s.startswith("sensor.")))
|
||||
|
||||
|
||||
def normalize(metric: str, raw: object, unit: str | None = None) -> float | None:
|
||||
"""Validate and normalize common HA units; reject unknown/mismatched units."""
|
||||
try:
|
||||
value = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not isfinite(value):
|
||||
return None
|
||||
unit = (unit or "").strip().lower().replace(" ", "")
|
||||
if metric in ("temperature", "feels_like"):
|
||||
if unit in ("°f", "f", "fahrenheit"):
|
||||
value = (value - 32.0) * 5.0 / 9.0
|
||||
elif unit not in ("", "°c", "c", "celsius"):
|
||||
return None
|
||||
elif metric == "wind":
|
||||
if unit in ("km/h", "kmh", "kph"):
|
||||
value /= 3.6
|
||||
elif unit in ("mph",):
|
||||
value *= 0.44704
|
||||
elif unit not in ("", "m/s", "mps"):
|
||||
return None
|
||||
elif metric == "pressure":
|
||||
if unit == "pa":
|
||||
value /= 100.0
|
||||
elif unit == "kpa":
|
||||
value *= 10.0
|
||||
elif unit in ("inhg",):
|
||||
value *= 33.8638867
|
||||
elif unit not in ("", "hpa", "mbar"):
|
||||
return None
|
||||
elif metric == "visibility":
|
||||
if unit in ("km",):
|
||||
value *= 1000.0
|
||||
elif unit not in ("", "m"):
|
||||
return None
|
||||
elif metric in ("humidity", "clouds"):
|
||||
if unit not in ("", "%"):
|
||||
return None
|
||||
elif metric in ("rain", "snow"):
|
||||
if unit not in ("", "mm", "mm/h"):
|
||||
return None
|
||||
lo, hi = LIMITS[metric]
|
||||
return round(value, 4) if lo <= value <= hi else None
|
||||
|
||||
|
||||
def choose_weather_sources(
|
||||
config: dict, states: dict[str, tuple[object, str | None]]
|
||||
) -> tuple[dict[str, float | None], dict[str, str | None], dict[str, dict[str, float]]]:
|
||||
"""Choose first valid sensor per metric, expose alternatives for comparisons.
|
||||
|
||||
`states` maps entity ID -> (value, unit). In HA these are current state snapshots.
|
||||
"""
|
||||
values: dict[str, float | None] = {}
|
||||
active: dict[str, str | None] = {}
|
||||
comparisons: dict[str, dict[str, float]] = {}
|
||||
for metric in WEATHER_SOURCES:
|
||||
valid: dict[str, float] = {}
|
||||
for entity_id in sources_for(config, metric):
|
||||
raw, unit = states.get(entity_id, (None, None))
|
||||
value = normalize(metric, raw, unit)
|
||||
if value is not None:
|
||||
valid[entity_id] = value
|
||||
comparisons[metric] = valid
|
||||
active[metric] = next(iter(valid), None)
|
||||
values[metric] = valid[active[metric]] if active[metric] else None
|
||||
return values, active, comparisons
|
||||
Reference in new issue
Block a user