784 lines
33 KiB
Python
784 lines
33 KiB
Python
"""CycleScheduler: orchestrates cycles for multiple underlyings within a master cycle.
|
|
|
|
For switches: manages staggered ON/OFF PWM timing to minimize overlap
|
|
and smooth electrical load.
|
|
For valves: passthrough mode that calls set_valve_open_percent() directly
|
|
without temporal scheduling.
|
|
"""
|
|
|
|
import logging
|
|
import time
|
|
from vtherm_api.log_collector import get_vtherm_logger
|
|
from typing import Any, Callable
|
|
|
|
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
|
|
from homeassistant.helpers.event import async_call_later
|
|
|
|
|
|
from .vtherm_hvac_mode import VThermHvacMode, VThermHvacMode_OFF
|
|
|
|
from .cycle_tick_logic import (
|
|
UnderlyingCycleState,
|
|
compute_circular_offsets,
|
|
compute_target_state,
|
|
evaluate_need_on,
|
|
evaluate_need_off,
|
|
compute_e_eff,
|
|
)
|
|
_LOGGER = get_vtherm_logger(__name__)
|
|
|
|
|
|
def calculate_cycle_times(
|
|
on_percent: float,
|
|
cycle_min: int,
|
|
minimal_activation_delay: int | None = 0,
|
|
minimal_deactivation_delay: int | None = 0,
|
|
) -> tuple[int, int, bool]:
|
|
"""Convert on_percent to on_time_sec and off_time_sec.
|
|
|
|
Applies minimal activation and deactivation delays to avoid
|
|
very short on/off periods that may damage equipment or be ineffective.
|
|
|
|
Args:
|
|
on_percent: The calculated heating percentage (0.0 to 1.0)
|
|
cycle_min: The cycle duration in minutes
|
|
minimal_activation_delay: Minimum on time in seconds (below this, turn off)
|
|
minimal_deactivation_delay: Minimum off time in seconds (below this, stay on)
|
|
|
|
Returns:
|
|
Tuple of (on_time_sec, off_time_sec, forced_by_timing)
|
|
- forced_by_timing: True if min_on or min_off delays modified the percentage significantly.
|
|
"""
|
|
min_on = minimal_activation_delay if minimal_activation_delay is not None else 0
|
|
min_off = minimal_deactivation_delay if minimal_deactivation_delay is not None else 0
|
|
|
|
on_percent = max(0.0, min(1.0, on_percent))
|
|
|
|
cycle_sec = cycle_min * 60
|
|
on_time_sec = on_percent * cycle_sec
|
|
forced_by_timing = False
|
|
|
|
if on_time_sec > 0 and on_time_sec < min_on:
|
|
on_time_sec = 0
|
|
forced_by_timing = True
|
|
|
|
off_time_sec = cycle_sec - on_time_sec
|
|
|
|
if on_time_sec < cycle_sec and off_time_sec < min_off:
|
|
on_time_sec = cycle_sec
|
|
off_time_sec = 0
|
|
forced_by_timing = True
|
|
|
|
return int(on_time_sec), int(off_time_sec), forced_by_timing
|
|
|
|
|
|
class CycleScheduler:
|
|
"""Orchestrates cycles for multiple underlyings within a master cycle.
|
|
|
|
For switches: all underlyings operate within the same time window.
|
|
ON periods are staggered using computed offsets to minimize overlap.
|
|
For valves: passthrough mode — calls set_valve_open_percent() directly.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
hass: HomeAssistant,
|
|
thermostat: Any,
|
|
underlyings: list,
|
|
cycle_duration_sec: float,
|
|
min_activation_delay: int = 0,
|
|
min_deactivation_delay: int = 0,
|
|
):
|
|
self._hass = hass
|
|
self._thermostat = thermostat
|
|
self._underlyings = underlyings
|
|
self._cycle_duration_sec = cycle_duration_sec
|
|
self.min_activation_delay: int = min_activation_delay
|
|
self.min_deactivation_delay: int = min_deactivation_delay
|
|
self._tick_unsub: CALLBACK_TYPE | None = None
|
|
self._cycle_end_unsub: CALLBACK_TYPE | None = None
|
|
self._on_cycle_start_callbacks: list[Callable] = []
|
|
self._on_cycle_end_callbacks: list[Callable] = []
|
|
# Current cycle parameters (for repeat at cycle end)
|
|
self._current_hvac_mode: VThermHvacMode | None = None
|
|
self._current_on_time_sec: float = 0
|
|
self._current_off_time_sec: float = 0
|
|
self._current_on_percent: float = 0
|
|
# Active cycle parameters describe what is physically being executed
|
|
# right now. They intentionally differ from _current_* when a running
|
|
# cycle receives non-forced updates that should only apply to the next
|
|
# repeat at cycle end.
|
|
self._active_hvac_mode: VThermHvacMode | None = None
|
|
self._active_on_time_sec: float = 0
|
|
self._active_off_time_sec: float = 0
|
|
self._active_on_percent: float = 0
|
|
self._states: list[UnderlyingCycleState] = []
|
|
self._penalty: float = 0.0
|
|
self._cycle_start_time: float = 0.0
|
|
# For valves, keep the applied-power segments within the current
|
|
# master cycle so realized e_eff reflects real mid-cycle updates.
|
|
self._valve_cycle_trace: list[tuple[float, float]] = []
|
|
# Guard flags to keep is_cycle_running=True during async yield points
|
|
# where timers are not yet (re-)installed:
|
|
# _is_cancelling: True while cancel_cycle() awaits _fire_cycle_end_callbacks()
|
|
# _is_starting: True while start_cycle() awaits callbacks or device-control
|
|
# operations before the new timers are set
|
|
self._is_cancelling: bool = False
|
|
self._is_starting: bool = False
|
|
# Detect valve mode from underlying types
|
|
self._is_valve_mode: bool = self._detect_valve_mode()
|
|
|
|
@property
|
|
def is_cycle_running(self) -> bool:
|
|
"""Return True if a cycle is currently scheduled or in a lifecycle transition.
|
|
|
|
_is_cancelling stays True while cancel_cycle() awaits _fire_cycle_end_callbacks(),
|
|
preventing concurrent start_cycle(force=False) from seeing is_cycle_running=False
|
|
and starting a duplicate cycle during that window (Race 1).
|
|
|
|
_is_starting stays True while start_cycle() has finished cancellation but has not
|
|
yet finished device-control setup (including _fire_cycle_start_callbacks and
|
|
_start_cycle_switch/_start_cycle_valve), preventing concurrent start_cycle(force=False)
|
|
from starting a duplicate cycle during that window (Race 2).
|
|
"""
|
|
return self._tick_unsub is not None or self._cycle_end_unsub is not None or self._is_cancelling or self._is_starting
|
|
|
|
@property
|
|
def is_valve_mode(self) -> bool:
|
|
"""Return True if managing valve underlyings (passthrough mode)."""
|
|
return self._is_valve_mode
|
|
|
|
def _detect_valve_mode(self) -> bool:
|
|
"""Detect if underlyings are valves by checking entity_type."""
|
|
from .underlyings import UnderlyingEntityType # pylint: disable=import-outside-toplevel
|
|
if not self._underlyings:
|
|
return False
|
|
return self._underlyings[0].entity_type in (
|
|
UnderlyingEntityType.VALVE,
|
|
UnderlyingEntityType.VALVE_REGULATION,
|
|
)
|
|
|
|
def register_cycle_start_callback(self, callback: Callable):
|
|
"""Register a callback to be called at the start of each master cycle.
|
|
|
|
Callback signature: async def callback(on_time_sec, off_time_sec, on_percent, hvac_mode)
|
|
"""
|
|
self._on_cycle_start_callbacks.append(callback)
|
|
|
|
def register_cycle_end_callback(self, callback: Callable[[float], Any]):
|
|
"""Register a callback to be called at the end of each master cycle."""
|
|
self._on_cycle_end_callbacks.append(callback)
|
|
|
|
def _set_pending_cycle(
|
|
self,
|
|
hvac_mode: VThermHvacMode | None,
|
|
on_time_sec: float,
|
|
off_time_sec: float,
|
|
on_percent: float,
|
|
) -> None:
|
|
"""Store parameters that the next cycle repeat must use."""
|
|
self._current_hvac_mode = hvac_mode
|
|
self._current_on_time_sec = on_time_sec
|
|
self._current_off_time_sec = off_time_sec
|
|
self._current_on_percent = on_percent
|
|
|
|
def _set_active_cycle(
|
|
self,
|
|
hvac_mode: VThermHvacMode | None,
|
|
on_time_sec: float,
|
|
off_time_sec: float,
|
|
on_percent: float,
|
|
) -> None:
|
|
"""Store parameters of the master cycle currently being executed."""
|
|
self._active_hvac_mode = hvac_mode
|
|
self._active_on_time_sec = on_time_sec
|
|
self._active_off_time_sec = off_time_sec
|
|
self._active_on_percent = on_percent
|
|
|
|
@staticmethod
|
|
def _same_cycle_request(
|
|
hvac_mode_a: VThermHvacMode | None,
|
|
on_time_sec_a: float,
|
|
off_time_sec_a: float,
|
|
on_percent_a: float,
|
|
hvac_mode_b: VThermHvacMode | None,
|
|
on_time_sec_b: float,
|
|
off_time_sec_b: float,
|
|
on_percent_b: float,
|
|
) -> bool:
|
|
"""Return True when two cycle requests are effectively identical."""
|
|
return (
|
|
hvac_mode_a == hvac_mode_b
|
|
and on_time_sec_a == on_time_sec_b
|
|
and off_time_sec_a == off_time_sec_b
|
|
and abs(on_percent_a - on_percent_b) <= 1e-9
|
|
)
|
|
|
|
async def start_cycle(
|
|
self,
|
|
hvac_mode: VThermHvacMode,
|
|
on_percent: float,
|
|
force: bool = False,
|
|
_from_cycle_end: bool = False,
|
|
):
|
|
"""Start a new master cycle for all underlyings.
|
|
|
|
Computes on_time_sec and off_time_sec from on_percent, applying
|
|
min_activation_delay and min_deactivation_delay constraints.
|
|
|
|
Args:
|
|
hvac_mode: Current HVAC mode.
|
|
on_percent: Power percentage as a fraction (0.0 to 1.0).
|
|
force: If True, cancel any running cycle and restart immediately.
|
|
_from_cycle_end: Internal flag — True when called from _on_master_cycle_end.
|
|
"""
|
|
cycle_min = self._cycle_duration_sec / 60
|
|
on_time_sec, off_time_sec, _ = calculate_cycle_times(
|
|
on_percent,
|
|
cycle_min,
|
|
self.min_activation_delay,
|
|
self.min_deactivation_delay,
|
|
)
|
|
realized_on_percent = on_time_sec / self._cycle_duration_sec if self._cycle_duration_sec > 0 else 0.0
|
|
|
|
# Always update thermostat timing attributes immediately so sensors
|
|
# reflect the latest computed value, even when the cycle returns early.
|
|
self._thermostat._on_time_sec = on_time_sec
|
|
self._thermostat._off_time_sec = off_time_sec
|
|
|
|
if self.is_cycle_running and not force:
|
|
if self._is_valve_mode:
|
|
# Valve mode must keep the current master-cycle window for
|
|
# learning callbacks, but the physical valve command still has
|
|
# to follow each new regulation result immediately.
|
|
_LOGGER.debug(
|
|
"%s - Valve cycle already running, applying immediate update: "
|
|
"on_time=%.0f, off_time=%.0f, on_percent=%.2f",
|
|
self._thermostat,
|
|
on_time_sec,
|
|
off_time_sec,
|
|
realized_on_percent,
|
|
)
|
|
await self._update_running_valve_cycle(
|
|
hvac_mode,
|
|
on_time_sec,
|
|
off_time_sec,
|
|
realized_on_percent,
|
|
)
|
|
return
|
|
if self._active_on_time_sec > 0:
|
|
# A real cycle is actively running — don't interrupt it.
|
|
# Just update stored params so the next auto-repeat uses them.
|
|
_LOGGER.debug(
|
|
"%s - Cycle already running (on_time=%.0fs), skipping (force=%s). "
|
|
"Updating params for next repeat: on_time=%.0f, off_time=%.0f, on_percent=%.2f",
|
|
self._thermostat,
|
|
self._active_on_time_sec,
|
|
force,
|
|
on_time_sec,
|
|
off_time_sec,
|
|
realized_on_percent,
|
|
)
|
|
self._set_pending_cycle(hvac_mode, on_time_sec, off_time_sec, realized_on_percent)
|
|
return
|
|
# Current cycle is idle (on_time=0, device off).
|
|
# Keep it running if the requested idle cycle is unchanged; otherwise
|
|
# cancel it and allow the new cycle to start immediately.
|
|
if self._same_cycle_request(
|
|
self._active_hvac_mode,
|
|
self._active_on_time_sec,
|
|
self._active_off_time_sec,
|
|
self._active_on_percent,
|
|
hvac_mode,
|
|
on_time_sec,
|
|
off_time_sec,
|
|
realized_on_percent,
|
|
):
|
|
_LOGGER.debug(
|
|
"%s - Current cycle is idle and unchanged, keeping existing cycle",
|
|
self._thermostat,
|
|
)
|
|
self._set_pending_cycle(hvac_mode, on_time_sec, off_time_sec, realized_on_percent)
|
|
return
|
|
_LOGGER.debug(
|
|
"%s - Current cycle is idle (on_time=0), replacing with changed cycle",
|
|
self._thermostat,
|
|
)
|
|
|
|
await self._cancel_cycle_impl()
|
|
|
|
# Store current cycle parameters for repeat
|
|
self._set_pending_cycle(hvac_mode, on_time_sec, off_time_sec, realized_on_percent)
|
|
self._set_active_cycle(hvac_mode, on_time_sec, off_time_sec, realized_on_percent)
|
|
|
|
# _is_starting guards the Race window: after _cancel_cycle_impl() cleared all
|
|
# timers and flags, but before the new timers are set by _start_cycle_switch() or
|
|
# _start_cycle_valve(). During this window, is_cycle_running must return True to
|
|
# prevent concurrent start_cycle(force=False) from starting a duplicate full cycle.
|
|
self._is_starting = True
|
|
try:
|
|
# Fire cycle start callbacks with realized percent so learners see actual applied power
|
|
await self._fire_cycle_start_callbacks(on_time_sec, off_time_sec, realized_on_percent, hvac_mode)
|
|
|
|
if self._is_valve_mode:
|
|
await self._start_cycle_valve(hvac_mode)
|
|
else:
|
|
await self._start_cycle_switch(hvac_mode, on_time_sec, off_time_sec, realized_on_percent)
|
|
finally:
|
|
self._is_starting = False
|
|
|
|
async def apply_valve_update(
|
|
self,
|
|
hvac_mode: VThermHvacMode,
|
|
on_percent: float,
|
|
) -> None:
|
|
"""Apply a deferred valve recompute without running a full control cycle."""
|
|
if not self._is_valve_mode:
|
|
return
|
|
|
|
cycle_min = self._cycle_duration_sec / 60
|
|
on_time_sec, off_time_sec, _ = calculate_cycle_times(
|
|
on_percent,
|
|
cycle_min,
|
|
self.min_activation_delay,
|
|
self.min_deactivation_delay,
|
|
)
|
|
realized_on_percent = on_time_sec / self._cycle_duration_sec if self._cycle_duration_sec > 0 else 0.0
|
|
|
|
self._thermostat._on_time_sec = on_time_sec
|
|
self._thermostat._off_time_sec = off_time_sec
|
|
self._set_pending_cycle(hvac_mode, on_time_sec, off_time_sec, realized_on_percent)
|
|
|
|
if self.is_cycle_running:
|
|
await self._update_running_valve_cycle(
|
|
hvac_mode,
|
|
on_time_sec,
|
|
off_time_sec,
|
|
realized_on_percent,
|
|
)
|
|
return
|
|
|
|
self._set_active_cycle(hvac_mode, on_time_sec, off_time_sec, realized_on_percent)
|
|
await self._apply_valve_command(hvac_mode, on_time_sec, off_time_sec)
|
|
|
|
async def _update_running_valve_cycle(
|
|
self,
|
|
hvac_mode: VThermHvacMode,
|
|
on_time_sec: float,
|
|
off_time_sec: float,
|
|
on_percent: float,
|
|
) -> None:
|
|
"""Apply a valve update while preserving the current master-cycle window."""
|
|
self._set_pending_cycle(hvac_mode, on_time_sec, off_time_sec, on_percent)
|
|
self._set_active_cycle(hvac_mode, on_time_sec, off_time_sec, on_percent)
|
|
await self._apply_valve_command(hvac_mode, on_time_sec, off_time_sec)
|
|
self._append_valve_cycle_trace(on_percent)
|
|
|
|
async def _apply_valve_command(
|
|
self,
|
|
hvac_mode: VThermHvacMode,
|
|
on_time_sec: float,
|
|
off_time_sec: float,
|
|
) -> None:
|
|
"""Apply the latest valve command to all underlyings immediately."""
|
|
for under in self._underlyings:
|
|
under._on_time_sec = on_time_sec
|
|
under._off_time_sec = off_time_sec
|
|
under._hvac_mode = hvac_mode
|
|
await under.set_valve_open_percent()
|
|
|
|
def _reset_valve_cycle_trace(self, on_percent: float) -> None:
|
|
"""Start a new valve trace for the current master cycle."""
|
|
self._valve_cycle_trace = [(0.0, max(0.0, min(1.0, on_percent)))]
|
|
|
|
def _append_valve_cycle_trace(self, on_percent: float) -> None:
|
|
"""Append a new applied-power segment for a running valve cycle."""
|
|
if self._cycle_start_time <= 0:
|
|
self._reset_valve_cycle_trace(on_percent)
|
|
return
|
|
|
|
applied_on_percent = max(0.0, min(1.0, on_percent))
|
|
if self._valve_cycle_trace:
|
|
last_offset, last_on_percent = self._valve_cycle_trace[-1]
|
|
if abs(last_on_percent - applied_on_percent) <= 1e-9:
|
|
return
|
|
else:
|
|
last_offset = 0.0
|
|
|
|
offset = min(
|
|
max(0.0, time.time() - self._cycle_start_time),
|
|
self._cycle_duration_sec,
|
|
)
|
|
offset = max(offset, last_offset)
|
|
self._valve_cycle_trace.append((offset, applied_on_percent))
|
|
|
|
async def _start_cycle_valve(self, hvac_mode: VThermHvacMode):
|
|
"""Valve passthrough: call set_valve_open_percent() on each underlying.
|
|
|
|
Valves don't need temporal ON/OFF scheduling. They just need
|
|
their open percentage updated. A master cycle window is still kept so
|
|
cycle callbacks remain available to SmartPI in valve-based setups.
|
|
"""
|
|
self._cycle_start_time = time.time()
|
|
await self._apply_valve_command(
|
|
hvac_mode,
|
|
self._current_on_time_sec,
|
|
self._current_off_time_sec,
|
|
)
|
|
self._reset_valve_cycle_trace(self._current_on_percent)
|
|
self._cycle_end_unsub = async_call_later(
|
|
self._hass,
|
|
self._cycle_duration_sec,
|
|
self._on_master_cycle_end,
|
|
)
|
|
|
|
async def _start_cycle_switch(
|
|
self,
|
|
hvac_mode: VThermHvacMode,
|
|
on_time_sec: float,
|
|
off_time_sec: float,
|
|
on_percent: float,
|
|
):
|
|
"""Switch True Tick scheduling: initialize cycle and start ticking."""
|
|
# Update on_time/off_time on each underlying for keep-alive and monitoring
|
|
for under in self._underlyings:
|
|
under._on_time_sec = on_time_sec
|
|
under._off_time_sec = off_time_sec
|
|
under._hvac_mode = hvac_mode
|
|
|
|
if hvac_mode == VThermHvacMode_OFF or on_time_sec <= 0:
|
|
# Turn off all underlyings
|
|
for under in self._underlyings:
|
|
if under.is_device_active:
|
|
await under.turn_off()
|
|
under._should_be_on = False
|
|
# Keep a real master-cycle start time so the next automatic restart
|
|
# reports a full elapsed_ratio instead of looking interrupted with 0 s elapsed.
|
|
self._cycle_start_time = time.time()
|
|
# Schedule next cycle evaluation
|
|
self._cycle_end_unsub = async_call_later(self._hass, self._cycle_duration_sec, self._on_master_cycle_end)
|
|
return
|
|
|
|
if on_time_sec >= self._cycle_duration_sec:
|
|
# 100% power: Turn on all underlyings unconditionally to enforce state
|
|
for under in self._underlyings:
|
|
await under.turn_on()
|
|
under._should_be_on = True
|
|
# Keep a real master-cycle start time for the same reason as 0% cycles.
|
|
self._cycle_start_time = time.time()
|
|
# Schedule next cycle evaluation
|
|
self._cycle_end_unsub = async_call_later(self._hass, self._cycle_duration_sec, self._on_master_cycle_end)
|
|
return
|
|
|
|
self._init_cycle(on_percent)
|
|
|
|
# Start ticking immediately with is_initial=True to enforce state
|
|
await self._tick(_is_initial=True)
|
|
|
|
# Also ensure master cycle end is scheduled independently to wrap up the cycle
|
|
self._cycle_end_unsub = async_call_later(self._hass, self._cycle_duration_sec, self._on_master_cycle_end)
|
|
|
|
def _init_cycle(self, on_percent: float):
|
|
"""Initialize states and penalty for the new cycle.
|
|
|
|
Uses circular offsets for evenly distributed power across underlyings,
|
|
with natural wrap-around for smooth load distribution.
|
|
"""
|
|
self._penalty = 0.0
|
|
self._cycle_start_time = time.time()
|
|
|
|
n = len(self._underlyings)
|
|
on_time = self._cycle_duration_sec * on_percent
|
|
offsets = compute_circular_offsets(self._cycle_duration_sec, n)
|
|
|
|
self._states = []
|
|
for i, under in enumerate(self._underlyings):
|
|
state = UnderlyingCycleState(under, offsets[i])
|
|
state.on_t = offsets[i]
|
|
state.on_time = on_time
|
|
state.off_t = (state.on_t + state.on_time) % self._cycle_duration_sec
|
|
self._states.append(state)
|
|
|
|
_LOGGER.debug(
|
|
"%s - Initialized true tick cycle: on_percent=%.2f, offsets=%s",
|
|
self._thermostat, on_percent, offsets
|
|
)
|
|
|
|
async def _tick(self, _now=None, _is_initial: bool = False):
|
|
"""Evaluate all underlyings and schedule the next tick.
|
|
|
|
When _is_initial=True (called at cycle start), current_t is forced to 0.0
|
|
to avoid floating-point drift, and the is_device_active check is skipped so
|
|
the desired state is always enforced unconditionally on the first tick.
|
|
"""
|
|
from homeassistant.util import dt as dt_util
|
|
now = time.time()
|
|
current_t = 0.0 if _is_initial else (now - self._cycle_start_time)
|
|
|
|
if not _is_initial and current_t >= self._cycle_duration_sec:
|
|
# We reached the end of the cycle; let the master cycle end handle it.
|
|
return
|
|
|
|
next_global_tick = self._cycle_duration_sec - current_t
|
|
|
|
for state in self._states:
|
|
under = state.underlying
|
|
|
|
target_is_on, next_tick, state_duration = compute_target_state(
|
|
state.on_t, state.off_t, current_t, self._cycle_duration_sec
|
|
)
|
|
|
|
# Update global next tick to the earliest upcoming event
|
|
time_to_next = next_tick - current_t
|
|
if time_to_next > 0 and time_to_next < next_global_tick:
|
|
next_global_tick = time_to_next
|
|
|
|
under_dt = 0.0
|
|
if under.last_change:
|
|
under_dt = (dt_util.utcnow() - under.last_change).total_seconds()
|
|
else:
|
|
under_dt = 999999.0 # Safe large value if no history
|
|
|
|
if target_is_on:
|
|
if not under.is_device_active:
|
|
action, new_on_t, pen_delta = evaluate_need_on(
|
|
under_dt, state_duration,
|
|
self.min_deactivation_delay, self.min_activation_delay,
|
|
state.on_t, current_t
|
|
)
|
|
if action == 'turn_on':
|
|
_LOGGER.info(
|
|
"%s - tick turn_on (state_duration=%.1fs, initial=%s)",
|
|
under, state_duration, _is_initial
|
|
)
|
|
try:
|
|
await under.turn_on()
|
|
under._should_be_on = True
|
|
except Exception as err:
|
|
_LOGGER.error("%s - tick turn_on failed: %s", under, err)
|
|
elif action == 'skip' and new_on_t is not None:
|
|
_LOGGER.debug(
|
|
"%s - tick skip turn_on (racollage), on_t shifted %.1f -> %.1f, penalty=%.1f",
|
|
under, state.on_t, new_on_t, pen_delta
|
|
)
|
|
state.on_t = new_on_t
|
|
self._penalty += pen_delta
|
|
resched_time = new_on_t - current_t
|
|
if 0 < resched_time < next_global_tick:
|
|
next_global_tick = resched_time
|
|
elif _is_initial:
|
|
# Enforce state unconditionally on the first tick
|
|
if not under.is_device_active:
|
|
await under.turn_on()
|
|
under._should_be_on = True
|
|
|
|
elif not target_is_on:
|
|
if under.is_device_active:
|
|
action, new_off_t, pen_delta = evaluate_need_off(
|
|
under_dt, state_duration,
|
|
self.min_activation_delay, self.min_deactivation_delay,
|
|
state.off_t, current_t
|
|
)
|
|
if action == 'turn_off':
|
|
_LOGGER.info(
|
|
"%s - tick turn_off (state_duration=%.1fs, initial=%s)",
|
|
under, state_duration, _is_initial
|
|
)
|
|
try:
|
|
await under.turn_off()
|
|
under._should_be_on = False
|
|
except Exception as err:
|
|
_LOGGER.error("%s - tick turn_off failed: %s", under, err)
|
|
elif action == 'skip' and new_off_t is not None:
|
|
_LOGGER.debug(
|
|
"%s - tick skip turn_off (racollage), off_t shifted %.1f -> %.1f, penalty=%.1f",
|
|
under, state.off_t, new_off_t, pen_delta
|
|
)
|
|
state.off_t = new_off_t
|
|
self._penalty += pen_delta
|
|
resched_time = new_off_t - current_t
|
|
if 0 < resched_time < next_global_tick:
|
|
next_global_tick = resched_time
|
|
elif _is_initial:
|
|
# Enforce state unconditionally on the first tick
|
|
if under.is_device_active:
|
|
try:
|
|
await under.turn_off()
|
|
under._should_be_on = False
|
|
except Exception as err:
|
|
_LOGGER.error("%s - initial turn_off failed: %s", under, err)
|
|
|
|
# Ensure we do not schedule too fast (< 0.1s)
|
|
next_global_tick = max(0.1, next_global_tick)
|
|
|
|
# Schedule next tick
|
|
self._tick_unsub = async_call_later(self._hass, next_global_tick, self._tick)
|
|
|
|
async def _on_master_cycle_end(self, _now):
|
|
"""Called at the end of the master cycle. Restart with the same parameters.
|
|
|
|
The cycle end callback (e_eff) is fired by cancel_cycle(), which is called
|
|
inside start_cycle(force=True). This ensures exactly one callback per cycle
|
|
end regardless of how the cycle terminates.
|
|
"""
|
|
if not self.is_cycle_running:
|
|
return
|
|
|
|
# Increment energy counter
|
|
self._thermostat.incremente_energy()
|
|
|
|
# Restart cycle — cancel_cycle() inside start_cycle will fire the end callback.
|
|
await self.start_cycle(
|
|
self._current_hvac_mode,
|
|
self._current_on_percent,
|
|
force=True,
|
|
_from_cycle_end=True,
|
|
)
|
|
|
|
def shutdown(self):
|
|
"""Cancel pending timers immediately without firing end-of-cycle callbacks.
|
|
|
|
Must be called synchronously when the entity is being removed from HA so
|
|
that leftover async_call_later handles cannot fire after the new entity
|
|
(potentially with a different cycle duration) has already started.
|
|
"""
|
|
if self._tick_unsub:
|
|
self._tick_unsub()
|
|
self._tick_unsub = None
|
|
if self._cycle_end_unsub:
|
|
self._cycle_end_unsub()
|
|
self._cycle_end_unsub = None
|
|
self._valve_cycle_trace = []
|
|
self._set_pending_cycle(None, 0, 0, 0.0)
|
|
self._set_active_cycle(None, 0, 0, 0.0)
|
|
self._is_cancelling = False
|
|
self._is_starting = False
|
|
|
|
async def cancel_cycle(self):
|
|
"""Cancel the current cycle if one is running."""
|
|
await self._cancel_cycle_impl()
|
|
|
|
async def _cancel_cycle_impl(self):
|
|
"""Internal cancel logic, shared by cancel_cycle() and start_cycle()."""
|
|
was_running = self.is_cycle_running
|
|
|
|
# Set before unsubscribing so that is_cycle_running stays True
|
|
# throughout this coroutine, even during the async yield below (Race 1 guard).
|
|
self._is_cancelling = True
|
|
|
|
if self._tick_unsub:
|
|
self._tick_unsub()
|
|
self._tick_unsub = None
|
|
if self._cycle_end_unsub:
|
|
self._cycle_end_unsub()
|
|
self._cycle_end_unsub = None
|
|
|
|
elapsed_sec = time.time() - self._cycle_start_time if self._cycle_start_time > 0 else 0
|
|
|
|
# Fire end-of-cycle callback for cycles that ran long enough.
|
|
# This includes normal ends (via _on_master_cycle_end -> start_cycle(force=True))
|
|
# and mid-cycle interruptions (force restart on setpoint change, etc.).
|
|
# During this await, _is_cancelling=True keeps is_cycle_running=True so any
|
|
# concurrent start_cycle(force=False) call takes the update path, not full start.
|
|
if was_running and elapsed_sec > 1.0:
|
|
realized_e_eff = self._calculate_realized_e_eff(elapsed_sec)
|
|
elapsed_ratio = min(1.0, elapsed_sec / self._cycle_duration_sec) if self._cycle_duration_sec > 0 else 1.0
|
|
|
|
_LOGGER.debug("%s - cycle end: elapsed_sec=%.1f, realized_e_eff=%.3f, elapsed_ratio=%.2f", self._thermostat, elapsed_sec, realized_e_eff, elapsed_ratio)
|
|
await self._fire_cycle_end_callbacks(realized_e_eff, elapsed_ratio)
|
|
|
|
self._states = []
|
|
self._valve_cycle_trace = []
|
|
self._set_pending_cycle(None, 0, 0, 0.0)
|
|
self._set_active_cycle(None, 0, 0, 0.0)
|
|
self._cycle_start_time = 0.0
|
|
# Reset only after all state is cleared so the guard stays active until fully done.
|
|
self._is_cancelling = False
|
|
_LOGGER.debug("%s - Cycle cancelled", self._thermostat)
|
|
|
|
def _calculate_realized_e_eff(self, elapsed_sec: float) -> float:
|
|
"""Calculate the actual effective power applied over the given elapsed time."""
|
|
if not self._underlyings or elapsed_sec <= 0:
|
|
return 0.0
|
|
|
|
if self._is_valve_mode:
|
|
if not self._valve_cycle_trace:
|
|
return max(0.0, min(1.0, self._active_on_percent))
|
|
|
|
weighted_power = 0.0
|
|
for idx, (start_offset, on_percent) in enumerate(self._valve_cycle_trace):
|
|
if start_offset >= elapsed_sec:
|
|
break
|
|
end_offset = elapsed_sec
|
|
if idx + 1 < len(self._valve_cycle_trace):
|
|
end_offset = min(self._valve_cycle_trace[idx + 1][0], elapsed_sec)
|
|
if end_offset > start_offset:
|
|
weighted_power += (end_offset - start_offset) * on_percent
|
|
|
|
return max(0.0, min(1.0, weighted_power / elapsed_sec))
|
|
|
|
# When _states is empty the cycle ran at either 0% or 100% (no tick scheduling).
|
|
# Infer from _active_on_time_sec: if it covers the full duration, e_eff = 1.0.
|
|
if not self._states:
|
|
if self._active_on_time_sec >= self._cycle_duration_sec:
|
|
return 1.0
|
|
return 0.0
|
|
|
|
t_on_actual = 0.0
|
|
for state in self._states:
|
|
if state.off_t >= state.on_t:
|
|
start_on = min(state.on_t, elapsed_sec)
|
|
end_on = min(state.off_t, elapsed_sec)
|
|
if end_on > start_on:
|
|
t_on_actual += (end_on - start_on)
|
|
else:
|
|
end_on_1 = min(state.off_t, elapsed_sec)
|
|
t_on_actual += end_on_1
|
|
|
|
start_on_2 = min(state.on_t, elapsed_sec)
|
|
end_on_2 = elapsed_sec
|
|
if end_on_2 > start_on_2:
|
|
t_on_actual += (end_on_2 - start_on_2)
|
|
|
|
# e_eff is the true instantaneous duty cycle over the elapsed window.
|
|
e_eff = max(0.0, t_on_actual - self._penalty) / (elapsed_sec * len(self._underlyings))
|
|
return max(0.0, min(1.0, e_eff))
|
|
|
|
async def _fire_cycle_start_callbacks(
|
|
self, on_time_sec, off_time_sec, on_percent, hvac_mode
|
|
):
|
|
"""Fire all registered cycle start callbacks."""
|
|
for callback in self._on_cycle_start_callbacks:
|
|
try:
|
|
await callback(
|
|
on_time_sec=on_time_sec,
|
|
off_time_sec=off_time_sec,
|
|
on_percent=on_percent,
|
|
hvac_mode=hvac_mode,
|
|
)
|
|
except Exception as ex:
|
|
_LOGGER.warning(
|
|
"%s - Error calling cycle start callback %s: %s",
|
|
self._thermostat,
|
|
callback,
|
|
ex,
|
|
)
|
|
|
|
async def _fire_cycle_end_callbacks(self, e_eff: float, elapsed_ratio: float = 1.0):
|
|
"""Fire all registered cycle end callbacks with e_eff and elapsed_ratio."""
|
|
cycle_duration_min = self._cycle_duration_sec / 60.0
|
|
for callback in self._on_cycle_end_callbacks:
|
|
try:
|
|
await callback(
|
|
e_eff=e_eff,
|
|
elapsed_ratio=elapsed_ratio,
|
|
cycle_duration_min=cycle_duration_min,
|
|
)
|
|
except Exception as ex:
|
|
_LOGGER.warning(
|
|
"%s - Error calling cycle end callback %s: %s",
|
|
self._thermostat,
|
|
callback,
|
|
ex,
|
|
)
|