208 lines
6.4 KiB
Python
208 lines
6.4 KiB
Python
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))
|