feat(targets): automatic brightness limiting (ABL) / per-LED power budget

Cap an addressable strip's estimated current draw to a PSU budget so bright/
white scenes can't brown out an under-spec'd supply (voltage sag -> red/orange
shift, flicker, controller resets) — a classic 'it's broken' first impression.

- New core/processing/power_limit.py: pure current estimate (full white over N
  LEDs draws N * mA_per_led) + a (0,1] scale to land a frame on budget.
- Applied in WledTargetProcessor._send_to_device (single choke point, every send
  path; scales into a reusable scratch buffer, never mutates shared frames).
- Two per-target fields on LED targets: max_milliamps (0 = unlimited) and
  milliamps_per_led (default 55), threaded through model/store/manager/processor/
  schema/route with hot-update via update_target_settings. Additive with safe
  defaults (no data migration needed; legacy targets read as unlimited).
- Frontend: editor fields + i18n (en/ru/zh) + LedOutputTarget type.
- Tests: 10 unit tests for the estimator/scale; full suite green (1911 passed).
This commit is contained in:
2026-06-04 22:56:50 +03:00
parent 9960f15a1b
commit ffee156c17
14 changed files with 272 additions and 0 deletions
@@ -70,6 +70,8 @@ def _led_target_to_response(target: WledOutputTarget) -> LedOutputTargetResponse
min_brightness_threshold=target.min_brightness_threshold.to_dict(),
adaptive_fps=target.adaptive_fps,
protocol=target.protocol,
max_milliamps=target.max_milliamps,
milliamps_per_led=target.milliamps_per_led,
description=target.description,
tags=target.tags,
icon=getattr(target, "icon", "") or "",
@@ -302,6 +304,8 @@ async def create_target(
min_brightness_threshold=data.min_brightness_threshold,
adaptive_fps=data.adaptive_fps,
protocol=data.protocol,
max_milliamps=data.max_milliamps,
milliamps_per_led=data.milliamps_per_led,
)
case HALightOutputTargetCreate():
if data.source_kind == "color_vs":
@@ -464,6 +468,8 @@ async def update_target(
min_brightness_threshold=data.min_brightness_threshold,
adaptive_fps=data.adaptive_fps,
protocol=data.protocol,
max_milliamps=data.max_milliamps,
milliamps_per_led=data.milliamps_per_led,
)
css_changed = data.color_strip_source_id is not None
brightness_changed = data.brightness is not None
@@ -476,6 +482,8 @@ async def update_target(
data.min_brightness_threshold,
data.adaptive_fps,
data.brightness,
data.max_milliamps,
data.milliamps_per_led,
)
)
device_changed = data.device_id is not None
@@ -92,6 +92,10 @@ class LedOutputTargetResponse(_OutputTargetResponseBase):
default=False, description="Auto-reduce FPS when device is unresponsive"
)
protocol: str = Field(default="ddp", description="Send protocol (ddp or http)")
max_milliamps: int = Field(
default=0, description="ABL: PSU current budget in mA (0 = unlimited)"
)
milliamps_per_led: int = Field(default=55, description="ABL: full-white draw of one LED in mA")
class HALightOutputTargetResponse(_OutputTargetResponseBase):
@@ -236,6 +240,18 @@ class LedOutputTargetCreate(_OutputTargetCreateBase):
pattern="^(ddp|http)$",
description="Send protocol: ddp (UDP) or http (JSON API)",
)
max_milliamps: int = Field(
default=0,
ge=0,
le=200000,
description="Automatic brightness limiting: PSU current budget in mA (0 = unlimited)",
)
milliamps_per_led: int = Field(
default=55,
ge=1,
le=200,
description="ABL: estimated full-white draw of a single LED, in mA",
)
class HALightOutputTargetCreate(_OutputTargetCreateBase):
@@ -372,6 +388,12 @@ class LedOutputTargetUpdate(_OutputTargetUpdateBase):
protocol: str | None = Field(
None, pattern="^(ddp|http)$", description="Send protocol: ddp (UDP) or http (JSON API)"
)
max_milliamps: int | None = Field(
None, ge=0, le=200000, description="ABL: PSU current budget in mA (0 = unlimited)"
)
milliamps_per_led: int | None = Field(
None, ge=1, le=200, description="ABL: full-white draw of one LED in mA"
)
class HALightOutputTargetUpdate(_OutputTargetUpdateBase):
@@ -0,0 +1,58 @@
"""Automatic brightness limiting (ABL) — keep a strip within a PSU current budget.
Estimates the current an addressable LED strip would draw for a frame of
already-brightness-scaled RGB bytes and, if it exceeds the configured budget,
returns a uniform scale factor to bring it back under budget. This prevents the
classic under-spec'd-PSU failure mode: a full-white scene browning out the rail
(voltage sag -> red/orange shift, flicker, controller resets) — which reads to a
new user as "this software is broken".
Model: one addressable LED at full white ``(255, 255, 255)`` draws
``milliamps_per_led`` mA, and current scales linearly with the sum of channel
values, so a frame's draw is::
estimated_ma = sum(channel_bytes) * milliamps_per_led / (255 * 3)
(``255 * 3 = 765`` channel-units == one LED at full white.) Standby/idle current
is intentionally ignored: the limiter only needs to catch the high-draw frames
that cause brownouts, and the default 55 mA/LED already carries real-world
headroom. The same convention as WLED's "maximum current" setting.
"""
from __future__ import annotations
import numpy as np
# Channel units in one LED at full white (R + G + B = 255 * 3).
_FULL_WHITE_UNITS = 765.0
# Typical full-white draw of a single WS2812/SK6812-class LED, in mA.
DEFAULT_MILLIAMPS_PER_LED = 55
def estimate_current_ma(colors: np.ndarray, milliamps_per_led: int) -> float:
"""Estimate strip draw (mA) for already-brightness-scaled RGB bytes.
``colors`` is an ``(N, 3)`` uint8 array of the values actually sent to the
strip. Full white over ``N`` LEDs returns ``N * milliamps_per_led``.
"""
if milliamps_per_led <= 0 or colors.size == 0:
return 0.0
channel_sum = float(int(colors.sum()))
return channel_sum * milliamps_per_led / _FULL_WHITE_UNITS
def power_limit_scale(colors: np.ndarray, max_milliamps: int, milliamps_per_led: int) -> float:
"""Return a scale in ``(0, 1]`` that keeps estimated draw within budget.
Returns ``1.0`` when limiting is disabled (``max_milliamps <= 0``) or the
frame is already within budget. Because current is linear in the channel
values, scaling every pixel by ``max_milliamps / estimated`` lands the frame
exactly on the budget.
"""
if max_milliamps <= 0 or milliamps_per_led <= 0:
return 1.0
estimated = estimate_current_ma(colors, milliamps_per_led)
if estimated <= max_milliamps:
return 1.0
return max_milliamps / estimated
@@ -407,6 +407,8 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
min_brightness_threshold: int = 0,
adaptive_fps: bool = False,
protocol: str = "ddp",
max_milliamps: int = 0,
milliamps_per_led: int = 55,
):
"""Register a WLED target processor."""
if target_id in self._processors:
@@ -425,6 +427,8 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
min_brightness_threshold=min_brightness_threshold,
adaptive_fps=adaptive_fps,
protocol=protocol,
max_milliamps=max_milliamps,
milliamps_per_led=milliamps_per_led,
ctx=self._build_context(),
)
self._processors[target_id] = proc
@@ -17,6 +17,7 @@ from ledgrab.core.devices.led_client import (
get_device_capabilities,
)
from ledgrab.core.capture.screen_capture import get_available_displays
from ledgrab.core.processing.power_limit import DEFAULT_MILLIAMPS_PER_LED, power_limit_scale
from ledgrab.core.processing.target_processor import (
ProcessingMetrics,
TargetContext,
@@ -62,6 +63,8 @@ class WledTargetProcessor(TargetProcessor):
min_brightness_threshold: int = 0,
adaptive_fps: bool = False,
protocol: str = "ddp",
max_milliamps: int = 0,
milliamps_per_led: int = 55,
ctx: TargetContext = None,
):
from ledgrab.storage.bindable import BindableFloat, bfloat
@@ -81,6 +84,13 @@ class WledTargetProcessor(TargetProcessor):
self._min_brightness_threshold = int(bfloat(min_brightness_threshold, 0.0))
self._adaptive_fps = adaptive_fps
self._protocol = protocol
# Automatic brightness limiting (ABL). 0 mA budget = disabled.
self._max_milliamps = max(0, int(max_milliamps or 0))
self._milliamps_per_led = max(1, int(milliamps_per_led or DEFAULT_MILLIAMPS_PER_LED))
# Reusable scratch for in-place power scaling (allocated on first use).
self._power_u16: np.ndarray | None = None
self._power_out: np.ndarray | None = None
self._power_n = 0
# Adaptive FPS / liveness probe runtime state
self._effective_fps: int = self._target_fps
@@ -313,6 +323,12 @@ class WledTargetProcessor(TargetProcessor):
self._adaptive_fps = settings["adaptive_fps"]
if not self._adaptive_fps:
self._effective_fps = self._target_fps
if "max_milliamps" in settings:
self._max_milliamps = max(0, int(settings["max_milliamps"] or 0))
if "milliamps_per_led" in settings:
self._milliamps_per_led = max(
1, int(settings["milliamps_per_led"] or DEFAULT_MILLIAMPS_PER_LED)
)
logger.info(f"Updated settings for target {self._target_id}")
def update_device(self, device_id: str) -> None:
@@ -787,8 +803,33 @@ class WledTargetProcessor(TargetProcessor):
np.copyto(out, blend, casting="unsafe") # float32 → uint8
return out
def _apply_power_limit(self, colors: np.ndarray) -> np.ndarray:
"""Scale ``colors`` down to stay within the PSU current budget (ABL).
Returns ``colors`` unchanged when limiting is disabled or the frame is
already within budget; otherwise returns a scaled copy in a reusable
scratch buffer (the input is never mutated — it may be a shared frame).
"""
if self._max_milliamps <= 0:
return colors
scale = power_limit_scale(colors, self._max_milliamps, self._milliamps_per_led)
if scale >= 1.0:
return colors
factor = int(scale * 256) # 0..255 fixed-point multiplier
n = len(colors)
if self._power_u16 is None or self._power_n != n:
self._power_n = n
self._power_u16 = np.empty((n, 3), dtype=np.uint16)
self._power_out = np.empty((n, 3), dtype=np.uint8)
np.copyto(self._power_u16, colors, casting="unsafe")
self._power_u16 *= factor
self._power_u16 >>= 8
np.copyto(self._power_out, self._power_u16, casting="unsafe")
return self._power_out
async def _send_to_device(self, send_colors: np.ndarray) -> float:
"""Send colors to LED device and return send time in ms."""
send_colors = self._apply_power_limit(send_colors)
t_start = time.perf_counter()
if self._led_client.supports_fast_send:
self._led_client.send_pixels_fast(send_colors)
@@ -171,6 +171,8 @@ class TargetEditorModal extends Modal {
fps: _fpsWidget ? JSON.stringify(_fpsWidget.getValue()) : '30',
keepalive_interval: (document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value,
adaptive_fps: (document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked,
max_milliamps: (document.getElementById('target-editor-max-milliamps') as HTMLInputElement).value,
milliamps_per_led: (document.getElementById('target-editor-ma-per-led') as HTMLInputElement).value,
tags: JSON.stringify(_targetTagsInput ? _targetTagsInput.getValue() : []),
};
}
@@ -401,6 +403,8 @@ export async function showTargetEditor(targetId: string | null = null, cloneData
(document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked = target.adaptive_fps ?? false;
(document.getElementById('target-editor-protocol') as HTMLSelectElement).value = target.protocol || 'ddp';
(document.getElementById('target-editor-max-milliamps') as HTMLInputElement).value = String(target.max_milliamps ?? 0);
(document.getElementById('target-editor-ma-per-led') as HTMLInputElement).value = String(target.milliamps_per_led ?? 55);
_populateCssDropdown(target.color_strip_source_id || '');
_ensureBrightnessWidget().setValue(target.brightness ?? 1.0);
@@ -419,6 +423,8 @@ export async function showTargetEditor(targetId: string | null = null, cloneData
(document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked = cloneData.adaptive_fps ?? false;
(document.getElementById('target-editor-protocol') as HTMLSelectElement).value = cloneData.protocol || 'ddp';
(document.getElementById('target-editor-max-milliamps') as HTMLInputElement).value = String(cloneData.max_milliamps ?? 0);
(document.getElementById('target-editor-ma-per-led') as HTMLInputElement).value = String(cloneData.milliamps_per_led ?? 55);
_populateCssDropdown(cloneData.color_strip_source_id || '');
_ensureBrightnessWidget().setValue(cloneData.brightness ?? 1.0);
@@ -435,6 +441,8 @@ export async function showTargetEditor(targetId: string | null = null, cloneData
(document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked = false;
(document.getElementById('target-editor-protocol') as HTMLSelectElement).value = 'ddp';
(document.getElementById('target-editor-max-milliamps') as HTMLInputElement).value = '0';
(document.getElementById('target-editor-ma-per-led') as HTMLInputElement).value = '55';
_populateCssDropdown('');
_ensureBrightnessWidget().setValue(1.0);
@@ -515,6 +523,8 @@ export async function saveTargetEditor() {
const adaptiveFps = (document.getElementById('target-editor-adaptive-fps') as HTMLInputElement).checked;
const protocol = (document.getElementById('target-editor-protocol') as HTMLSelectElement).value;
const maxMilliamps = Math.max(0, Math.round(Number((document.getElementById('target-editor-max-milliamps') as HTMLInputElement).value) || 0));
const milliampsPerLed = Math.max(1, Math.round(Number((document.getElementById('target-editor-ma-per-led') as HTMLInputElement).value) || 55));
const payload: any = {
name,
@@ -526,6 +536,8 @@ export async function saveTargetEditor() {
keepalive_interval: standbyInterval,
adaptive_fps: adaptiveFps,
protocol,
max_milliamps: maxMilliamps,
milliamps_per_led: milliampsPerLed,
tags: _targetTagsInput ? _targetTagsInput.getValue() : [],
};
@@ -50,6 +50,8 @@ export interface LedOutputTarget extends OutputTargetBase {
min_brightness_threshold?: BindableFloat;
adaptive_fps: boolean;
protocol: string;
max_milliamps?: number;
milliamps_per_led?: number;
}
export type HALightSourceKind = 'css' | 'color_vs';
@@ -2079,6 +2079,10 @@
"targets.adaptive_fps.hint": "Automatically reduce send rate when the device becomes unresponsive, and gradually recover when it stabilizes. Recommended for WiFi devices with weak signal.",
"targets.protocol": "Protocol:",
"targets.protocol.hint": "DDP sends pixels via fast UDP (recommended for most setups). HTTP uses the JSON API — slower but reliable, limited to ~500 LEDs.",
"targets.power_limit": "Max current (ABL):",
"targets.power_limit.hint": "Caps the strip's estimated current draw to your power-supply budget to prevent brownouts (voltage sag, color shift, flicker) on bright/white scenes. Set it to your PSU's rated current, leaving some headroom. 0 = unlimited.",
"targets.power_limit.ma_suffix": "mA (0 = unlimited)",
"targets.power_limit.per_led": "mA per LED (full white):",
"targets.protocol.ddp": "DDP (UDP)",
"targets.protocol.ddp.desc": "Fast raw UDP packets — recommended",
"targets.protocol.http": "HTTP",
@@ -1939,6 +1939,10 @@
"targets.adaptive_fps.hint": "Автоматически снижает частоту отправки, когда устройство перестаёт отвечать, и постепенно восстанавливает её при стабилизации. Рекомендуется для WiFi-устройств со слабым сигналом.",
"targets.protocol": "Протокол:",
"targets.protocol.hint": "DDP отправляет пиксели по быстрому UDP (рекомендуется). HTTP использует JSON API — медленнее, но надёжнее, ограничение ~500 LED.",
"targets.power_limit": "Макс. ток (ABL):",
"targets.power_limit.hint": "Ограничивает расчётный ток ленты бюджетом блока питания, чтобы избежать просадок напряжения (сдвиг цвета, мерцание, перезагрузки) на ярких/белых сценах. Укажите номинальный ток вашего БП с запасом. 0 = без ограничения.",
"targets.power_limit.ma_suffix": "мА (0 = без ограничения)",
"targets.power_limit.per_led": "мА на светодиод (полный белый):",
"targets.protocol.ddp": "DDP (UDP)",
"targets.protocol.ddp.desc": "Быстрые UDP-пакеты — рекомендуется",
"targets.protocol.http": "HTTP",
@@ -1935,6 +1935,10 @@
"targets.adaptive_fps.hint": "当设备无响应时自动降低发送速率,稳定后逐步恢复。推荐用于信号较弱的WiFi设备。",
"targets.protocol": "协议:",
"targets.protocol.hint": "DDP通过快速UDP发送像素(推荐)。HTTP使用JSON API——较慢但可靠,限制约500个LED。",
"targets.power_limit": "最大电流 (ABL)",
"targets.power_limit.hint": "将灯带的估算电流限制在电源预算内,以防止明亮/白色场景下的电压骤降(颜色偏移、闪烁、重启)。请设为电源的额定电流并留有余量。0 = 不限制。",
"targets.power_limit.ma_suffix": "mA0 = 不限制)",
"targets.power_limit.per_led": "每颗 LED 电流(全白):",
"targets.protocol.ddp": "DDP (UDP)",
"targets.protocol.ddp.desc": "快速UDP数据包 - 推荐",
"targets.protocol.http": "HTTP",
@@ -95,6 +95,8 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
min_brightness_threshold: Any = 0,
adaptive_fps: bool = False,
protocol: str = "ddp",
max_milliamps: int = 0,
milliamps_per_led: int = 55,
description: str | None = None,
tags: List[str] | None = None,
# legacy compat
@@ -116,6 +118,8 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
min_brightness_threshold=BindableFloat.from_raw(min_brightness_threshold, default=0.0),
adaptive_fps=adaptive_fps,
protocol=protocol,
max_milliamps=max(0, int(max_milliamps or 0)),
milliamps_per_led=max(1, int(milliamps_per_led or 55)),
description=description,
created_at=now,
updated_at=now,
@@ -335,6 +339,8 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
min_brightness_threshold: Any = None,
adaptive_fps: bool | None = None,
protocol: str | None = None,
max_milliamps: int | None = None,
milliamps_per_led: int | None = None,
description: str | None = None,
tags: List[str] | None = None,
icon: str | None = None,
@@ -356,6 +362,8 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]):
min_brightness_threshold=min_brightness_threshold,
adaptive_fps=adaptive_fps,
protocol=protocol,
max_milliamps=max_milliamps,
milliamps_per_led=milliamps_per_led,
description=description,
tags=tags,
icon=icon,
@@ -24,6 +24,11 @@ class WledOutputTarget(OutputTarget, type_key="led"):
min_brightness_threshold: BindableFloat = field(default_factory=lambda: BindableFloat(0.0))
adaptive_fps: bool = False # auto-reduce FPS when device is unresponsive
protocol: str = "ddp" # "ddp" (UDP) or "http" (JSON API)
# Automatic brightness limiting (ABL): cap estimated strip draw to a PSU
# budget. max_milliamps <= 0 disables it. milliamps_per_led is the full-white
# draw of one LED (WS2812-class default 55 mA).
max_milliamps: int = 0
milliamps_per_led: int = 55
def register_with_manager(self, manager) -> None:
"""Register this WLED target with the processor manager."""
@@ -39,6 +44,8 @@ class WledOutputTarget(OutputTarget, type_key="led"):
min_brightness_threshold=self.min_brightness_threshold,
adaptive_fps=self.adaptive_fps,
protocol=self.protocol,
max_milliamps=self.max_milliamps,
milliamps_per_led=self.milliamps_per_led,
)
def sync_with_manager(
@@ -59,6 +66,8 @@ class WledOutputTarget(OutputTarget, type_key="led"):
"state_check_interval": self.state_check_interval,
"min_brightness_threshold": self.min_brightness_threshold,
"adaptive_fps": self.adaptive_fps,
"max_milliamps": self.max_milliamps,
"milliamps_per_led": self.milliamps_per_led,
},
)
if css_changed:
@@ -81,6 +90,8 @@ class WledOutputTarget(OutputTarget, type_key="led"):
min_brightness_threshold=None,
adaptive_fps=None,
protocol=None,
max_milliamps=None,
milliamps_per_led=None,
description=None,
tags: List[str] | None = None,
icon: str | None = None,
@@ -122,6 +133,10 @@ class WledOutputTarget(OutputTarget, type_key="led"):
self.adaptive_fps = adaptive_fps
if protocol is not None:
self.protocol = protocol
if max_milliamps is not None:
self.max_milliamps = max(0, int(max_milliamps))
if milliamps_per_led is not None:
self.milliamps_per_led = max(1, int(milliamps_per_led))
@property
def has_picture_source(self) -> bool:
@@ -139,6 +154,8 @@ class WledOutputTarget(OutputTarget, type_key="led"):
d["min_brightness_threshold"] = self.min_brightness_threshold.to_dict()
d["adaptive_fps"] = self.adaptive_fps
d["protocol"] = self.protocol
d["max_milliamps"] = self.max_milliamps
d["milliamps_per_led"] = self.milliamps_per_led
return d
@classmethod
@@ -165,6 +182,8 @@ class WledOutputTarget(OutputTarget, type_key="led"):
),
adaptive_fps=data.get("adaptive_fps", False),
protocol=data.get("protocol", "ddp"),
max_milliamps=int(data.get("max_milliamps", 0) or 0),
milliamps_per_led=int(data.get("milliamps_per_led", 55) or 55),
description=data.get("description"),
tags=data.get("tags", []),
icon=data.get("icon", ""),
@@ -138,6 +138,22 @@
<small class="input-hint" style="display:none" data-i18n="targets.keepalive_interval.hint">How often to resend the last frame when the screen is static, to keep the device in live mode (0.5-5.0s)</small>
<input type="range" id="target-editor-keepalive-interval" min="0.5" max="5.0" step="0.5" value="1.0" oninput="document.getElementById('target-editor-keepalive-interval-value').textContent = this.value">
</div>
<div class="form-group" id="target-editor-power-limit-group">
<div class="label-row">
<label for="target-editor-max-milliamps" data-i18n="targets.power_limit">Max current (ABL):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="targets.power_limit.hint">Caps the strip's estimated current draw to your power-supply budget to prevent brownouts (voltage sag, color shift, flicker) on bright/white scenes. Set it to your PSU's rated current, leaving some headroom. 0 = unlimited.</small>
<div class="label-row">
<input type="number" id="target-editor-max-milliamps" min="0" max="200000" step="100" value="0">
<span data-i18n="targets.power_limit.ma_suffix">mA (0 = unlimited)</span>
</div>
<div class="label-row">
<label for="target-editor-ma-per-led" data-i18n="targets.power_limit.per_led">mA per LED (full white):</label>
<input type="number" id="target-editor-ma-per-led" min="1" max="200" step="1" value="55">
</div>
</div>
</div>
</details>
</div>
+70
View File
@@ -0,0 +1,70 @@
"""Unit tests for automatic brightness limiting (ABL) current estimation."""
import numpy as np
import pytest
from ledgrab.core.processing.power_limit import (
DEFAULT_MILLIAMPS_PER_LED,
estimate_current_ma,
power_limit_scale,
)
def test_default_ma_per_led_constant():
assert DEFAULT_MILLIAMPS_PER_LED == 55
def test_full_white_draws_ma_per_led_times_count():
colors = np.full((100, 3), 255, dtype=np.uint8)
assert estimate_current_ma(colors, 55) == pytest.approx(100 * 55)
def test_black_draws_zero():
colors = np.zeros((100, 3), dtype=np.uint8)
assert estimate_current_ma(colors, 55) == 0.0
def test_half_white_is_half_current():
full = estimate_current_ma(np.full((100, 3), 255, dtype=np.uint8), 55)
half = estimate_current_ma(np.full((100, 3), 128, dtype=np.uint8), 55)
assert half == pytest.approx(full * 128 / 255, rel=1e-6)
def test_zero_ma_per_led_draws_zero():
colors = np.full((100, 3), 255, dtype=np.uint8)
assert estimate_current_ma(colors, 0) == 0.0
def test_empty_frame_is_safe():
colors = np.zeros((0, 3), dtype=np.uint8)
assert estimate_current_ma(colors, 55) == 0.0
assert power_limit_scale(colors, 1000, 55) == 1.0
def test_scale_is_one_when_disabled():
colors = np.full((100, 3), 255, dtype=np.uint8)
assert power_limit_scale(colors, 0, 55) == 1.0
assert power_limit_scale(colors, -1, 55) == 1.0
def test_scale_is_one_within_budget():
colors = np.full((100, 3), 255, dtype=np.uint8) # 5500 mA at 55 mA/LED
assert power_limit_scale(colors, 6000, 55) == 1.0
assert power_limit_scale(colors, 5500, 55) == 1.0 # exactly on budget
def test_scale_brings_full_white_to_budget():
colors = np.full((100, 3), 255, dtype=np.uint8) # 5500 mA
scale = power_limit_scale(colors, 2750, 55) # half budget
assert scale == pytest.approx(0.5, rel=1e-6)
def test_applying_scale_lands_within_budget():
colors = np.full((100, 3), 255, dtype=np.uint8) # 5500 mA
budget = 2750
scale = power_limit_scale(colors, budget, 55)
# Mirror the processor's fixed-point application (factor/256).
factor = int(scale * 256)
scaled = ((colors.astype(np.uint16) * factor) >> 8).astype(np.uint8)
# Fixed-point rounding can only ever round DOWN, so we never exceed budget.
assert estimate_current_ma(scaled, 55) <= budget