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

Boiler and PWM improvements #97

Open
wants to merge 22 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
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
2 changes: 2 additions & 0 deletions .github/FUNDING.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
github: Alexwijn
buy_me_a_coffee: alexwijn
10 changes: 9 additions & 1 deletion custom_components/sat/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,16 @@
CLIMATE,
SENTRY,
COORDINATOR,
OPTIONS_DEFAULTS,
CONF_MODE,
CONF_DEVICE,
CONF_ERROR_MONITORING, OPTIONS_DEFAULTS,
CONF_ERROR_MONITORING,
SERVICE_RESET_INTEGRAL,
SERVICE_PULSE_WIDTH_MODULATION,
)
from .coordinator import SatDataUpdateCoordinatorFactory
from .services import async_register_services
from .util import get_climate_entities

_LOGGER: logging.Logger = logging.getLogger(__name__)
PLATFORMS = [CLIMATE_DOMAIN, SENSOR_DOMAIN, NUMBER_DOMAIN, BINARY_SENSOR_DOMAIN]
Expand Down Expand Up @@ -57,6 +62,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
# Forward entry setup for used platforms
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

# Register the services
await async_register_services(hass)

# Add an update listener for this entry
entry.async_on_unload(entry.add_update_listener(async_reload_entry))

Expand Down
3 changes: 0 additions & 3 deletions custom_components/sat/area.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,7 @@
from .heating_curve import HeatingCurve
from .helpers import float_value
from .pid import PID
from .pwm import PWM
from .util import (
create_pwm_controller,
create_pid_controller,
create_heating_curve_controller,
)
Expand All @@ -27,7 +25,6 @@ def __init__(self, config_data: MappingProxyType[str, Any], config_options: Mapp
# Create controllers with the given configuration options
self.pid: PID = create_pid_controller(config_options)
self.heating_curve: HeatingCurve = create_heating_curve_controller(config_data, config_options)
self.pwm: PWM = create_pwm_controller(self.heating_curve, config_data, config_options)

@property
def id(self) -> str:
Expand Down
2 changes: 1 addition & 1 deletion custom_components/sat/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ async def async_setup_entry(_hass: HomeAssistant, _config_entry: ConfigEntry, _a
if coordinator.supports_setpoint_management:
_async_add_entities([SatControlSetpointSynchroSensor(coordinator, _config_entry, climate)])

if coordinator.supports_relative_modulation_management:
if coordinator.supports_relative_modulation:
_async_add_entities([SatRelativeModulationSynchroSensor(coordinator, _config_entry, climate)])

if len(_config_entry.options.get(CONF_WINDOW_SENSORS, [])) > 0:
Expand Down
59 changes: 42 additions & 17 deletions custom_components/sat/boiler.py
Original file line number Diff line number Diff line change
@@ -1,42 +1,67 @@
import logging
from enum import Enum
from typing import Optional

from .const import MINIMUM_SETPOINT

_LOGGER = logging.getLogger(__name__)

STABILIZATION_MARGIN = 5
EXCEED_SETPOINT_MARGIN = 0.1
EXCEED_SETPOINT_MARGIN = 1.0


class BoilerStatus(str, Enum):
HOT_WATER = "hot_water"
PREHEATING = "preheating"
HEATING_UP = "heating_up"
AT_SETPOINT = "at_setpoint"
COOLING_DOWN = "cooling_down"
NEAR_SETPOINT = "near_setpoint"
PUMP_STARTING = "pump_starting"
WAITING_FOR_FLAME = "waiting_for_flame"
OVERSHOOT_HANDLING = "overshoot_handling"
OVERSHOOT_STABILIZED = "overshoot_stabilized"

IDLE = "idle"
UNKNOWN = "unknown"
INITIALIZING = "initializing"


class BoilerState:
"""
Represents the operational state of a boiler, including activity, flame status, hot water usage, and current temperature.
"""

def __init__(self, device_active: bool, flame_active: bool, hot_water_active: bool, temperature: float):
"""
Initialize with the boiler's state parameters.
def __init__(self, device_active: bool, device_status: BoilerStatus, flame_active: bool, flame_on_since: Optional[int], hot_water_active: bool, temperature: float):
"""Initialize with the boiler's state parameters."""
self._flame_active: bool = flame_active
self._hot_water_active: bool = hot_water_active

:param device_active: Whether the boiler is currently operational.
:param flame_active: Whether the boiler's flame is ignited.
:param hot_water_active: Whether the boiler is heating water.
:param temperature: The current boiler temperature in Celsius.
"""
self._temperature = temperature
self._flame_active = flame_active
self._device_active = device_active
self._hot_water_active = hot_water_active
self._temperature: float = temperature
self._device_active: bool = device_active
self._device_status: BoilerStatus = device_status
self._flame_on_since: Optional[int] = flame_on_since

@property
def device_active(self) -> bool:
"""Indicates whether the boiler is running."""
return self._device_active

@property
def device_status(self) -> BoilerStatus:
"""Indicates the boiler status."""
return self._device_status

@property
def flame_active(self) -> bool:
"""Indicates whether the flame is ignited."""
return self._flame_active

@property
def flame_on_since(self) -> Optional[int]:
"""Indicates when the flame has been ignited."""
return self._flame_on_since

@property
def hot_water_active(self) -> bool:
"""Indicates whether the boiler is heating water."""
Expand Down Expand Up @@ -70,7 +95,7 @@ def update(self, boiler_temperature: float, boiler_temperature_derivative: float
self._last_setpoint = setpoint

if setpoint < self._last_setpoint and not self._adjusting_to_lower_setpoint:
self._handle_setpoint_decrease()
self._handle_setpoint_decrease()

if not flame_active:
self._handle_flame_inactive()
Expand Down Expand Up @@ -107,10 +132,10 @@ def _handle_tracking(self, boiler_temperature: float, boiler_temperature_derivat
if not self._warming_up and boiler_temperature_derivative == 0:
return self._stop_tracking("Temperature not changing.", boiler_temperature, setpoint)

if setpoint <= boiler_temperature - EXCEED_SETPOINT_MARGIN:
return self._stop_tracking("Exceeds setpoint significantly.", boiler_temperature, setpoint)
if boiler_temperature - EXCEED_SETPOINT_MARGIN > setpoint:
return self._stop_tracking("Exceeds setpoint.", boiler_temperature, setpoint)

if setpoint > boiler_temperature and setpoint - STABILIZATION_MARGIN < boiler_temperature < self._last_boiler_temperature:
if setpoint > boiler_temperature and setpoint - STABILIZATION_MARGIN < boiler_temperature + 1 < self._last_boiler_temperature:
return self._stop_warming_up("Stabilizing below setpoint.", boiler_temperature, setpoint)

def _handle_adjusting_to_lower_setpoint(self, boiler_temperature: float, boiler_temperature_derivative: float, setpoint: float):
Expand Down
87 changes: 30 additions & 57 deletions custom_components/sat/climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,21 +31,21 @@
from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, STATE_UNAVAILABLE, STATE_UNKNOWN, ATTR_ENTITY_ID, STATE_ON, STATE_OFF, EVENT_HOMEASSISTANT_STARTED
from homeassistant.core import HomeAssistant, ServiceCall, Event, CoreState
from homeassistant.core import HomeAssistant, Event, CoreState
from homeassistant.helpers import entity_registry
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_state_change_event, async_track_time_interval
from homeassistant.helpers.restore_state import RestoreEntity

from .area import Areas, SENSOR_TEMPERATURE_ID
from .boiler import BoilerState
from .boiler import BoilerStatus
from .const import *
from .coordinator import SatDataUpdateCoordinator, DeviceState, DeviceStatus
from .coordinator import SatDataUpdateCoordinator, DeviceState
from .entity import SatEntity
from .helpers import convert_time_str_to_seconds, seconds_since
from .helpers import convert_time_str_to_seconds
from .manufacturers.geminox import Geminox
from .pwm import PWMState
from .relative_modulation import RelativeModulation, RelativeModulationState
from .setpoint_adjuster import SetpointAdjuster
from .summer_simmer import SummerSimmer
from .util import create_pid_controller, create_heating_curve_controller, create_pwm_controller, create_minimum_setpoint_controller

Expand Down Expand Up @@ -179,9 +179,6 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn
self._sensor_max_value_age = convert_time_str_to_seconds(config_options.get(CONF_SENSOR_MAX_VALUE_AGE))
self._window_minimum_open_time = convert_time_str_to_seconds(config_options.get(CONF_WINDOW_MINIMUM_OPEN_TIME))

# Create the Setpoint Adjuster controller
self._setpoint_adjuster = SetpointAdjuster()

# Create PID controller with given configuration options
self.pid = create_pid_controller(config_options)

Expand All @@ -198,7 +195,7 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn
self._minimum_setpoint = create_minimum_setpoint_controller(config_entry.data, config_options)

# Create PWM controller with given configuration options
self.pwm = create_pwm_controller(self.heating_curve, config_entry.data, config_options)
self.pwm = create_pwm_controller(self.heating_curve, coordinator.supports_relative_modulation_management, config_entry.data, config_options)

if self._simulation:
_LOGGER.warning("Simulation mode!")
Expand Down Expand Up @@ -231,9 +228,6 @@ async def async_added_to_hass(self) -> None:
await self._register_event_listeners()
await self.async_control_heating_loop()

# Register services
await self._register_services()

# Initialize the area system
await self.areas.async_added_to_hass(self.hass)

Expand Down Expand Up @@ -361,14 +355,6 @@ async def _restore_previous_state_or_set_defaults(self):

self.async_write_ha_state()

async def _register_services(self):
async def reset_integral(_call: ServiceCall):
"""Service to reset the integral part of the PID controller."""
self.pid.reset()
self.areas.pids.reset()

self.hass.services.async_register(DOMAIN, SERVICE_RESET_INTEGRAL, reset_integral)

@property
def name(self):
"""Return the friendly name of the sensor."""
Expand Down Expand Up @@ -574,7 +560,10 @@ def pulse_width_modulation_enabled(self) -> bool:

@property
def relative_modulation_value(self) -> int:
return self._maximum_relative_modulation if self._relative_modulation.enabled else MINIMUM_RELATIVE_MOD
if not self._relative_modulation.enabled and self._coordinator.supports_relative_modulation_management:
return MINIMUM_RELATIVE_MODULATION

return self._maximum_relative_modulation

@property
def relative_modulation_state(self) -> RelativeModulationState:
Expand All @@ -586,8 +575,8 @@ def minimum_setpoint(self) -> float:
if self._minimum_setpoint_version == 1 and self._minimum_setpoint.current is not None:
return self._minimum_setpoint.current

if self._minimum_setpoint_version == 2 and self._setpoint_adjuster.current is not None:
return self._setpoint_adjuster.current
if self._minimum_setpoint_version == 2:
return self.pwm.setpoint

return self._coordinator.minimum_setpoint

Expand Down Expand Up @@ -697,12 +686,10 @@ async def _async_climate_changed(self, event: Event) -> None:

# If the state has changed or the old state is not available, update the PID controller
if not old_state or new_state.state != old_state.state:
self._setpoint_adjuster.reset()
await self._async_control_pid(True)

# If the target temperature has changed, update the PID controller
elif new_attrs.get("temperature") != old_attrs.get("temperature"):
self._setpoint_adjuster.reset()
await self._async_control_pid(True)

# If the current temperature has changed, update the PID controller
Expand Down Expand Up @@ -832,15 +819,7 @@ async def _async_control_setpoint(self, pwm_state: PWMState) -> None:
self._setpoint = self._minimum_setpoint.current

if self._minimum_setpoint_version == 2:
if self._coordinator.flame_active and seconds_since(self._coordinator.flame_on_since) > 6 and self._coordinator.device_status != DeviceStatus.PUMP_STARTING:
self._setpoint = self._setpoint_adjuster.adjust(self._coordinator.boiler_temperature - 2)
elif self._setpoint_adjuster.current is not None:
self._setpoint = self._setpoint_adjuster.current
elif not self._coordinator.flame_active:
self._setpoint = self._setpoint_adjuster.force(self._coordinator.boiler_temperature + 10)
elif self._setpoint is None:
_LOGGER.debug("Setpoint not available.")
return
self._setpoint = self.pwm.setpoint
else:
self._setpoint = self._coordinator.minimum_setpoint

Expand All @@ -855,19 +834,26 @@ async def _async_control_setpoint(self, pwm_state: PWMState) -> None:

async def _async_control_relative_modulation(self) -> None:
"""Control the relative modulation value based on the conditions."""
if not self._coordinator.supports_relative_modulation_management:
if not self._coordinator.supports_relative_modulation:
_LOGGER.debug("Relative modulation management is not supported. Skipping control.")
return

# Update relative modulation state
await self._relative_modulation.update(self.pulse_width_modulation_enabled)

# Retrieve the relative modulation
relative_modulation_value = self.relative_modulation_value

# Apply some filters based on the manufacturer
if isinstance(self._coordinator.manufacturer, Geminox):
relative_modulation_value = max(10, relative_modulation_value)

# Determine if the value needs to be updated
if self._coordinator.maximum_relative_modulation_value == self.relative_modulation_value:
_LOGGER.debug("Relative modulation value unchanged (%d%%). No update necessary.", self.relative_modulation_value)
if self._coordinator.maximum_relative_modulation_value == relative_modulation_value:
_LOGGER.debug("Relative modulation value unchanged (%d%%). No update necessary.", relative_modulation_value)
return

await self._coordinator.async_set_control_max_relative_modulation(self.relative_modulation_value)
await self._coordinator.async_set_control_max_relative_modulation(relative_modulation_value)

async def _async_update_rooms_from_climates(self) -> None:
"""Update the temperature setpoint for each room based on their associated climate entity."""
Expand All @@ -891,7 +877,6 @@ async def _async_update_rooms_from_climates(self) -> None:
async def reset_control_state(self):
"""Reset control state when major changes occur."""
self.pwm.disable()
self._setpoint_adjuster.reset()

async def async_track_sensor_temperature(self, entity_id):
"""
Expand Down Expand Up @@ -935,30 +920,18 @@ async def async_control_heating_loop(self, _time: Optional[datetime] = None) ->
self._calculated_setpoint = round(self._alpha * self._calculate_control_setpoint() + (1 - self._alpha) * self._calculated_setpoint, 1)

# Check for overshoot
if self._coordinator.device_status == DeviceStatus.OVERSHOOT_HANDLING:
if self._coordinator.device_status == BoilerStatus.OVERSHOOT_HANDLING:
_LOGGER.info("Overshoot Handling detected, enabling Pulse Width Modulation.")
self.pwm.enable()

# Check if we are above the overshoot temperature
if (
self._coordinator.device_status == DeviceStatus.COOLING_DOWN and
self._setpoint_adjuster.current is not None and math.floor(self._calculated_setpoint) > math.floor(self._setpoint_adjuster.current)
):
if self._coordinator.device_status == BoilerStatus.COOLING_DOWN and math.floor(self._calculated_setpoint) > math.floor(self.pwm.setpoint):
_LOGGER.info("Setpoint stabilization detected, disabling Pulse Width Modulation.")
self.pwm.disable()

# Pulse Width Modulation
# Update Pulse Width Modulation when enabled
if self.pulse_width_modulation_enabled:
boiler_state = BoilerState(
flame_active=self._coordinator.flame_active,
device_active=self._coordinator.device_active,
hot_water_active=self._coordinator.hot_water_active,
temperature=self._coordinator.boiler_temperature
)

await self.pwm.update(self._calculated_setpoint, boiler_state)
else:
self.pwm.reset()
await self.pwm.update(self._coordinator.state, self._calculated_setpoint)

# Set the control setpoint to make sure we always stay in control
await self._async_control_setpoint(self.pwm.state)
Expand All @@ -974,9 +947,9 @@ async def async_control_heating_loop(self, _time: Optional[datetime] = None) ->
await self.areas.async_control_heating_loops()

# Control our dynamic minimum setpoint (version 1)
if not self._coordinator.hot_water_active and self._coordinator.flame_active:
if self._minimum_setpoint_version == 1 and not self._coordinator.hot_water_active and self._coordinator.flame_active:
# Calculate the base return temperature
if self._coordinator.device_status == DeviceStatus.HEATING_UP:
if self._coordinator.device_status == BoilerStatus.HEATING_UP:
self._minimum_setpoint.warming_up(self._coordinator.return_temperature)

# Calculate the dynamic minimum setpoint
Expand Down
Loading