Files
Home-Assistant/custom_components/versatile_thermostat/cycle_tick_logic.py
T
2026-06-16 10:33:21 -04:00

128 lines
4.2 KiB
Python

"""Pure logic for the Versatile Thermostat True Tick Cycle Scheduler."""
class UnderlyingCycleState:
"""Per-underlying state tracking for a single cycle."""
def __init__(self, underlying, offset: float):
"""Initialize the state with an underlying reference and a fixed circular offset."""
self.underlying = underlying
self.offset = offset
self.on_t: float = 0.0
self.off_t: float = 0.0
self.on_time: float = 0.0
def compute_circular_offsets(cycle_duration_sec: float, n: int) -> list[float]:
"""Compute evenly-spaced circular offsets for n underlyings.
Returns:
List of start offsets in seconds for each underlying.
"""
if n <= 1:
return [0.0] * n
step = cycle_duration_sec / n
return [round(i * step, 1) for i in range(n)]
def compute_target_state(
on_t: float, off_t: float, current_t: float, cycle_duration: float
) -> tuple[bool, float, float]:
"""Determine the theoretical target state and the next tick timestamp.
Returns:
(target_is_on, next_tick, state_duration)
"""
if off_t > on_t: # ON is confined in [on_t, off_t)
if current_t < on_t:
target = False
next_tick = on_t
elif current_t < off_t:
target = True
next_tick = off_t
else:
target = False
next_tick = cycle_duration
else: # ON wraps around: [0, off_t) U [on_t, cycle_end)
if current_t < off_t:
target = True
next_tick = off_t
elif current_t < on_t:
target = False
next_tick = on_t
else:
target = True
next_tick = cycle_duration
# Note (off_t == on_t falls in wrap-around condition):
# This works safely because upstream guards in cycle_scheduler._start_cycle_switch
# handle `on_time == 0` and `on_time == cycle_duration` before evaluating ticks.
state_duration = next_tick - current_t
return target, next_tick, state_duration
def evaluate_need_on(
under_dt: float, state_duration: float,
min_deactivation: float, min_activation: float,
on_t: float, current_t: float,
) -> tuple[str, float | None, float]:
"""Evaluate whether to actually turn ON (need_on) according to constraints.
Returns:
(action, new_on_t_or_none, penalty_delta)
action is 'turn_on' or 'skip'
"""
if under_dt >= min_deactivation and state_duration > min_activation:
return 'turn_on', None, 0.0
# CAS RACOLLAGE (Skip this turn ON)
new_on_t = max(0.0, on_t - state_duration)
penalty_delta = state_duration
if (new_on_t - current_t) < (min_deactivation - under_dt):
new_on_t = current_t + (min_deactivation - under_dt)
delay = new_on_t - current_t
penalty_delta = delay if delay < state_duration else state_duration
return 'skip', new_on_t, penalty_delta
def evaluate_need_off(
under_dt: float, state_duration: float,
min_activation: float, min_deactivation: float,
off_t: float, current_t: float,
) -> tuple[str, float | None, float]:
"""Evaluate whether to actually turn OFF (need_off) according to constraints.
Returns:
(action, new_off_t_or_none, penalty_delta)
action is 'turn_off' or 'skip'
"""
if under_dt >= min_activation and state_duration > min_deactivation:
return 'turn_off', None, 0.0
# CAS RACOLLAGE (Skip this turn OFF)
new_off_t = max(0.0, off_t - state_duration)
penalty_delta = -state_duration
if (new_off_t - current_t) < (min_activation - under_dt):
new_off_t = current_t + (min_activation - under_dt)
delay = new_off_t - current_t
penalty_delta = -delay if delay < state_duration else -state_duration
return 'skip', new_off_t, penalty_delta
def compute_e_eff(
on_percent: float, penalty: float,
cycle_duration: float, n_underlyings: int,
) -> float:
"""Compute effective power ratio (e_eff) at the end of the cycle."""
if n_underlyings == 0 or cycle_duration <= 0:
return 0.0
full_on_t = cycle_duration * n_underlyings
e_eff = (full_on_t * on_percent - penalty) / full_on_t
return max(0.0, min(1.0, e_eff))