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 all commits
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
21 changes: 18 additions & 3 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: float = config_options.get(CONF_ROOM_WEIGHTS).get(entity_id, 1.0)

# 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,11 @@ 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]:
valid_areas = [area for area in self._areas if area.error is not None]
return max(valid_areas, key=lambda area: area.error, default=None) if valid_areas else None

@property
def errors(self) -> List[float]:
"""Return a list of all the error values for all areas."""
Expand Down Expand Up @@ -140,7 +150,12 @@ def update(self, boiler_temperature: float) -> None:
if area.error is not None:
area.pid.update(area.error, 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.heating_curve.value)

def reset(self) -> None:
"""Reset PID controllers for all areas."""
for area in self.areas:
area.pid.reset()
area.pid.reset()
55 changes: 34 additions & 21 deletions custom_components/sat/climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -496,24 +496,38 @@ 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 multiplied by its weight.

The setpoint is always constrained to be above the minimum allowable setpoint.
"""
# Default to global heating curve and PID output
weight = 1.0
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:
weight = self.areas.focus.weight
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) * weight, MINIMUM_SETPOINT), 1)

@property
def valves_open(self) -> bool:
Expand Down Expand Up @@ -771,20 +785,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 +807,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)

else:
_LOGGER.info(f"Updating error value to {self.error} (Reset: True)")

elif max_error != self.pid.last_error:
_LOGGER.info(f"Updating error value to {max_error} (Reset: True)")
self.pwm.reset()
self.areas.pids.update_reset()
self.pid.update_reset(error=self.error, heating_curve_value=self.heating_curve.value)

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

self.async_write_ha_state()

Expand Down Expand Up @@ -968,7 +981,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
32 changes: 31 additions & 1 deletion custom_components/sat/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -548,7 +548,12 @@ def __init__(self, config_entry: ConfigEntry):
self._options = dict(config_entry.options)

async def async_step_init(self, _user_input: dict[str, Any] | None = None):
menu_options = ["general", "presets", "system_configuration"]
menu_options = ["general", "presets"]

if len(self._config_entry.data.get(CONF_ROOMS, [])) > 0:
menu_options.append("areas")

menu_options.append("system_configuration")

if self.show_advanced_options:
menu_options.append("advanced")
Expand Down Expand Up @@ -671,6 +676,31 @@ async def async_step_presets(self, _user_input: dict[str, Any] | None = None):
})
)

async def async_step_areas(self, _user_input: dict[str, Any] | None = None):
room_labels: dict[str, str] = {}
room_weights: dict[str, float] = self._options.get(CONF_ROOM_WEIGHTS, {})

for entity_id in self._config_entry.data.get(CONF_ROOMS, []):
state = self.hass.states.get(entity_id)
name = state.name if state else entity_id
room_labels[entity_id] = f"{name} ({entity_id})"

if _user_input is not None:
return await self.update_options({
CONF_ROOM_WEIGHTS: {
entity_id: float(_user_input[friendly_name])
for entity_id, friendly_name in room_labels.items()
}
})

return self.async_show_form(
step_id="areas",
data_schema=vol.Schema({
vol.Required(friendly_name, default=room_weights.get(entity_id, 1.0)): selector.NumberSelector(
selector.NumberSelectorConfig(min=0.1, max=3.0, step=0.1)
) for entity_id, friendly_name in room_labels.items()
})
)
async def async_step_system_configuration(self, _user_input: dict[str, Any] | None = None):
if _user_input is not None:
return await self.update_options(_user_input)
Expand Down
2 changes: 2 additions & 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 Expand Up @@ -111,6 +112,7 @@

CONF_RADIATORS: [],
CONF_ROOMS: [],
CONF_ROOM_WEIGHTS: {},

CONF_SIMULATION: False,
CONF_WINDOW_SENSORS: [],
Expand Down
5 changes: 5 additions & 0 deletions custom_components/sat/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -214,9 +214,14 @@
"advanced": "Advanced Options",
"general": "General",
"presets": "Presets",
"areas": "Areas",
"system_configuration": "System Configuration"
}
},
"areas": {
"description": "Adjust the heating weights for each room to control how much heat is allocated when the room has the highest error. Higher weights will increase the heat sent to the room relative to others.",
"title": "Areas"
},
"presets": {
"data": {
"activity_temperature": "Activity Temperature",
Expand Down
Loading