from __future__ import annotations import math from dataclasses import dataclass from decimal import Decimal, ROUND_FLOOR LOW_VOLTAGE_CRITICAL_THRESHOLD = 4.5 LOW_VOLTAGE_LIMIT_THRESHOLD = 4.8 LOW_VOLTAGE_CRITICAL_BRIGHTNESS_PERCENT = 35 PROTECTION_MODE_DISABLED = "disabled" PROTECTION_MODE_UNAVAILABLE = "unavailable" PROTECTION_MODE_INACTIVE = "inactive" PROTECTION_MODE_LIMITING = "limiting" PROTECTION_MODE_CRITICAL = "critical" NORMAL_SAMPLING_INTERVAL_SECONDS = 5.0 LIMITING_SAMPLING_INTERVAL_SECONDS = 1.0 FIRST_CRITICAL_SAMPLING_INTERVAL_SECONDS = 0.5 MIN_CRITICAL_SAMPLING_INTERVAL_SECONDS = 0.5 MAX_CRITICAL_SAMPLING_INTERVAL_SECONDS = 2.0 MAX_CRITICAL_SLOWDOWN_STEP_SECONDS = 0.5 ERROR_RETRY_INTERVALS_SECONDS = (1.0, 2.0, 5.0) @dataclass(frozen=True) class LowVoltageProtectionDirective: sequence: int enabled: bool mode: str brightness_limit_percent: int | None reading_stale: bool def brightness_limit_for_voltage(volts: float) -> int | None: """Return the output cap for a calibrated, unrounded voltage reading.""" voltage = float(volts) if not math.isfinite(voltage): raise ValueError("voltage must be finite") if voltage > LOW_VOLTAGE_LIMIT_THRESHOLD: return None if voltage < LOW_VOLTAGE_CRITICAL_THRESHOLD: return LOW_VOLTAGE_CRITICAL_BRIGHTNESS_PERCENT if voltage == LOW_VOLTAGE_LIMIT_THRESHOLD: return 100 if voltage == LOW_VOLTAGE_CRITICAL_THRESHOLD: return 50 decimal_voltage = Decimal(str(voltage)) limit = int( ( Decimal(50) + Decimal(50) * (decimal_voltage - Decimal("4.5")) / Decimal("0.3") ).to_integral_value(rounding=ROUND_FLOOR) ) return max(50, min(100, limit)) def protection_directive_for_voltage( sequence: int, enabled: bool, volts: float | None, *, reading_stale: bool = False, ) -> LowVoltageProtectionDirective: if not enabled: return LowVoltageProtectionDirective( sequence=sequence, enabled=False, mode=PROTECTION_MODE_DISABLED, brightness_limit_percent=None, reading_stale=False, ) if volts is None: return LowVoltageProtectionDirective( sequence=sequence, enabled=True, mode=PROTECTION_MODE_UNAVAILABLE, brightness_limit_percent=None, reading_stale=reading_stale, ) limit = brightness_limit_for_voltage(volts) if volts > LOW_VOLTAGE_LIMIT_THRESHOLD: mode = PROTECTION_MODE_INACTIVE limit = None elif volts < LOW_VOLTAGE_CRITICAL_THRESHOLD: mode = PROTECTION_MODE_CRITICAL else: mode = PROTECTION_MODE_LIMITING return LowVoltageProtectionDirective( sequence=sequence, enabled=True, mode=mode, brightness_limit_percent=limit, reading_stale=reading_stale, ) def same_protection_level( left: LowVoltageProtectionDirective, right: LowVoltageProtectionDirective, ) -> bool: return ( left.enabled == right.enabled and left.mode == right.mode and left.brightness_limit_percent == right.brightness_limit_percent ) def is_more_restrictive( candidate: LowVoltageProtectionDirective, current: LowVoltageProtectionDirective, ) -> bool: ranks = { PROTECTION_MODE_DISABLED: 0, PROTECTION_MODE_UNAVAILABLE: 0, PROTECTION_MODE_INACTIVE: 0, PROTECTION_MODE_LIMITING: 1, PROTECTION_MODE_CRITICAL: 2, } candidate_rank = ranks[candidate.mode] current_rank = ranks[current.mode] if candidate_rank != current_rank: return candidate_rank > current_rank if candidate.mode == PROTECTION_MODE_LIMITING: assert candidate.brightness_limit_percent is not None assert current.brightness_limit_percent is not None return candidate.brightness_limit_percent < current.brightness_limit_percent return False def more_conservative( left: LowVoltageProtectionDirective, right: LowVoltageProtectionDirective, ) -> LowVoltageProtectionDirective: """Choose the stricter directive while retaining the newest sequence on a tie.""" if is_more_restrictive(left, right): return left if is_more_restrictive(right, left): return right return right if right.sequence >= left.sequence else left def error_retry_interval(consecutive_errors: int) -> float: if consecutive_errors <= 0: return NORMAL_SAMPLING_INTERVAL_SECONDS index = min(consecutive_errors, len(ERROR_RETRY_INTERVALS_SECONDS)) - 1 return ERROR_RETRY_INTERVALS_SECONDS[index] def successful_sampling_interval( *, enabled: bool, mode: str, volts: float, previous_volts: float | None, elapsed_seconds: float | None, previous_interval_seconds: float, first_critical_sample: bool, ) -> float: """Return the next group-start interval for a successful voltage sample.""" if not enabled: return NORMAL_SAMPLING_INTERVAL_SECONDS if mode == PROTECTION_MODE_LIMITING: return LIMITING_SAMPLING_INTERVAL_SECONDS if mode == PROTECTION_MODE_CRITICAL: if first_critical_sample: return FIRST_CRITICAL_SAMPLING_INTERVAL_SECONDS rate = _voltage_rate(volts, previous_volts, elapsed_seconds) target = _clamp( MIN_CRITICAL_SAMPLING_INTERVAL_SECONDS, MAX_CRITICAL_SAMPLING_INTERVAL_SECONDS, 0.02 / max(abs(rate), 0.01), ) if target > previous_interval_seconds: return min( target, previous_interval_seconds + MAX_CRITICAL_SLOWDOWN_STEP_SECONDS, ) return target if mode == PROTECTION_MODE_INACTIVE: rate = _voltage_rate(volts, previous_volts, elapsed_seconds) if rate < 0: seconds_to_limit = (volts - LOW_VOLTAGE_LIMIT_THRESHOLD) / -rate return _clamp(1.0, NORMAL_SAMPLING_INTERVAL_SECONDS, seconds_to_limit) return NORMAL_SAMPLING_INTERVAL_SECONDS def _voltage_rate( volts: float, previous_volts: float | None, elapsed_seconds: float | None, ) -> float: if ( previous_volts is None or elapsed_seconds is None or not math.isfinite(elapsed_seconds) or elapsed_seconds <= 0 ): return 0.0 return (volts - previous_volts) / elapsed_seconds def _clamp(minimum: float, maximum: float, value: float) -> float: return max(minimum, min(maximum, value))