Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improved Area Support #88

Draft
wants to merge 6 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions custom_components/sat/area.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
from types import MappingProxyType
from typing import Any, List
from typing import Any, List, Optional

from homeassistant.components.climate import HVACMode
from homeassistant.const import STATE_UNKNOWN, STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant, State

from .const import CONF_ROOMS
from .const import CONF_ROOMS, CONF_ROOM_WEIGHTS
from .heating_curve import HeatingCurve
from .helpers import float_value
from .pid import PID
Expand All @@ -23,6 +23,7 @@ class Area:
def __init__(self, config_data: MappingProxyType[str, Any], config_options: MappingProxyType[str, Any], entity_id: str):
self._entity_id: str = entity_id
self._hass: HomeAssistant | None = None
self._weight = config_data[CONF_ROOM_WEIGHTS][entity_id] or 1

# Create controllers with the given configuration options
self.pid: PID = create_pid_controller(config_options)
Expand All @@ -33,6 +34,10 @@ def __init__(self, config_data: MappingProxyType[str, Any], config_options: Mapp
def id(self) -> str:
return self._entity_id

@property
def weight(self) -> float:
return self._weight

@property
def state(self) -> State | None:
"""Retrieve the current state of the climate entity."""
Expand Down Expand Up @@ -92,6 +97,10 @@ def __init__(self, config_data: MappingProxyType[str, Any], config_options: Mapp
self._entity_ids: list[str] = config_data.get(CONF_ROOMS) or []
self._areas: list[Area] = [Area(config_data, config_options, entity_id) for entity_id in self._entity_ids]

@property
def focus(self) -> Optional[Area]:
return max(self._areas, key=lambda area: area.error, default=None)

@property
def errors(self) -> List[float]:
"""Return a list of all the error values for all areas."""
Expand Down Expand Up @@ -129,7 +138,7 @@ def update(self, current_outside_temperature: float) -> None:
if area.target_temperature is None:
continue

area.heating_curve.update(area.target_temperature, current_outside_temperature)
area.heating_curve.update(area.target_temperature * area.weight, current_outside_temperature)

class _PIDs:
def __init__(self, areas: list[Area]):
Expand All @@ -138,9 +147,14 @@ def __init__(self, areas: list[Area]):
def update(self, boiler_temperature: float) -> None:
for area in self.areas:
if area.error is not None:
area.pid.update(area.error, area.heating_curve.value, boiler_temperature)
area.pid.update(area.error * area.weight, area.heating_curve.value, boiler_temperature)

def update_reset(self) -> None:
for area in self.areas:
if area.error is not None:
area.pid.update_reset(area.error * area.weight, area.heating_curve.value)

def reset(self) -> None:
"""Reset PID controllers for all areas."""
for area in self.areas:
area.pid.reset()
area.pid.reset()
53 changes: 32 additions & 21 deletions custom_components/sat/climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -496,24 +496,36 @@ def hvac_action(self):

return HVACAction.HEATING

@property
def max_error(self) -> float:
if self._heating_mode == HEATING_MODE_ECO:
return self.error

return max([self.error] + self.areas.errors)

@property
def setpoint(self) -> float | None:
return self._setpoint

@property
def requested_setpoint(self) -> float:
"""Get the requested setpoint based on the heating curve and PID output."""
if self.heating_curve.value is None:
"""
Calculate the requested setpoint based on the heating curve and PID output.

The setpoint is determined primarily by the global heating curve and PID output.
If the system is in 'comfort mode' and a focus area is defined with higher priority (based on its error),
the setpoint will be overridden by the focus area's heating curve and PID output.

The setpoint is always constrained to be above the minimum allowable setpoint.
"""
# Default to global heating curve and PID output
pid_output = self.pid.output
heating_curve = self.heating_curve.value

# Override with focus area values in comfort mode
if self._heating_mode == HEATING_MODE_COMFORT and self.areas.focus is not None and self.areas.focus.error > self.error:
pid_output = self.areas.focus.pid.output
heating_curve = self.areas.focus.heating_curve.value

# Fallback to minimum setpoint if no heating curve value is available
if heating_curve is None:
return MINIMUM_SETPOINT

return round(max(self.heating_curve.value + self.pid.output, MINIMUM_SETPOINT), 1)
# Calculate and constrain the setpoint to be no less than the minimum allowed value
return round(max(heating_curve + pid_output, MINIMUM_SETPOINT), 1)

@property
def valves_open(self) -> bool:
Expand Down Expand Up @@ -771,20 +783,17 @@ async def _async_control_pid(self, reset: bool = False) -> None:
self.pid.reset()
self.areas.pids.reset()

# Calculate the maximum error between the current temperature and the target temperature of all climates
max_error = self.max_error

# Make sure we use the latest heating curve value
if self.target_temperature is not None:
self.areas.heating_curves.update(self.current_outside_temperature)
self.heating_curve.update(self.target_temperature, self.current_outside_temperature)

# Update the PID controller with the maximum error
if not reset:
_LOGGER.info(f"Updating error value to {max_error} (Reset: False)")
_LOGGER.info(f"Updating error value to {self.error} (Reset: False)")

# Calculate an optimal heating curve when we are in the deadband
if self.target_temperature is not None and -DEADBAND <= max_error <= DEADBAND:
if self.target_temperature is not None and -DEADBAND <= self.error <= DEADBAND:
self.heating_curve.autotune(
setpoint=self.requested_setpoint,
target_temperature=self.target_temperature,
Expand All @@ -796,14 +805,16 @@ async def _async_control_pid(self, reset: bool = False) -> None:
self.areas.pids.update(self._coordinator.boiler_temperature_filtered)

if self.heating_curve.value is not None:
self.pid.update(max_error, self.heating_curve.value, self._coordinator.boiler_temperature_filtered)
self.pid.update(self.error, self.heating_curve.value, self._coordinator.boiler_temperature_filtered)

elif max_error != self.pid.last_error:
_LOGGER.info(f"Updating error value to {max_error} (Reset: True)")
else:
_LOGGER.info(f"Updating error value to {self.error} (Reset: True)")

self.pid.update_reset(error=max_error, heating_curve_value=self.heating_curve.value)
self._calculated_setpoint = None
self.pwm.reset()
self.areas.pids.update_reset()
self.pid.update_reset(error=self.error, heating_curve_value=self.heating_curve.value)

self._calculated_setpoint = None

self.async_write_ha_state()

Expand Down Expand Up @@ -968,7 +979,7 @@ async def async_control_heating_loop(self, _time: Optional[datetime] = None) ->

# Control the integral (if exceeded the time limit)
if self.heating_curve.value is not None:
self.pid.update_integral(self.max_error, self.heating_curve.value)
self.pid.update_integral(self.error, self.heating_curve.value)

# Control our areas
await self.areas.async_control_heating_loops()
Expand Down
1 change: 1 addition & 0 deletions custom_components/sat/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
CONF_MAXIMUM_SETPOINT = "maximum_setpoint"
CONF_MAXIMUM_RELATIVE_MODULATION = "maximum_relative_modulation"
CONF_ROOMS = "secondary_climates"
CONF_ROOM_WEIGHTS = "secondary_climate_weights"
CONF_MQTT_TOPIC = "mqtt_topic"
CONF_RADIATORS = "main_climates"
CONF_WINDOW_SENSORS = "window_sensors"
Expand Down
Loading