Skip to content

Commit

Permalink
Merge pull request #96 from Alexwijn/feature/improvements
Browse files Browse the repository at this point in the history
Add service constants, rename boiler state, update PWM
  • Loading branch information
Alexwijn authored Feb 16, 2025
2 parents 3545d11 + ab26cf2 commit 48443fd
Show file tree
Hide file tree
Showing 19 changed files with 319 additions and 159 deletions.
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
73 changes: 19 additions & 54 deletions custom_components/sat/climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,22 +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 @@ -180,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 @@ -199,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 @@ -232,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 @@ -362,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 @@ -575,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 @@ -587,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 @@ -698,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 @@ -833,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 @@ -856,7 +834,7 @@ 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

Expand Down Expand Up @@ -899,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 @@ -943,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 @@ -982,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
5 changes: 3 additions & 2 deletions custom_components/sat/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@

MINIMUM_SETPOINT = 10
MAXIMUM_SETPOINT = 65
MINIMUM_RELATIVE_MOD = 0
MAXIMUM_RELATIVE_MOD = 100
MINIMUM_RELATIVE_MODULATION = 0
MAXIMUM_RELATIVE_MODULATION = 100

MAX_BOILER_TEMPERATURE_AGE = 60

Expand Down Expand Up @@ -166,6 +166,7 @@

# Services
SERVICE_RESET_INTEGRAL = "reset_integral"
SERVICE_PULSE_WIDTH_MODULATION = "pulse_width_modulation"
SERVICE_SET_OVERSHOOT_PROTECTION_VALUE = "set_overshoot_protection_value"
SERVICE_START_OVERSHOOT_PROTECTION_CALCULATION = "start_overshoot_protection_calculation"

Expand Down
Loading

0 comments on commit 48443fd

Please sign in to comment.