initial commit v0.2.1

This commit is contained in:
buzera committed 2026-10-26 17:34:32 +02:00
commit 2b9a10979b
19 files changed
+1665

No files matched your search

+39
View File
@@ -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, {})))
+39
View File
@@ -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"
}
+169
View File
@@ -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),
}
+90
View File
@@ -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