Files
2026-06-16 10:33:21 -04:00

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,
)