Files
ha-deconz-bridge/src/ha_deconz_bridge/solver_model.py
T
2026-05-30 15:44:40 +00:00

224 lines
7.8 KiB
Python

from __future__ import annotations
from dataclasses import dataclass, field
import itertools
import numpy as np
from scipy.spatial import ConvexHull, QhullError
from ha_deconz_bridge.color import Color
from ha_deconz_bridge.lights import ControllerModeName, VirtualLightSpec
@dataclass(slots=True, frozen=True)
class CompiledChannel:
address: str
controller_id: str
mode_name: ControllerModeName
role: str
color: Color
semantic_label: str | None
order_index: int
@dataclass(slots=True, frozen=True)
class CompiledOption:
key: str
controller_modes: dict[str, ControllerModeName | None]
dimmable_channels: tuple[CompiledChannel, ...]
onoff_channels: tuple[CompiledChannel, ...]
@dataclass(slots=True, frozen=True)
class CompiledCombination:
key: str
controller_modes: dict[str, ControllerModeName | None]
dimmable_channels: tuple[CompiledChannel, ...]
onoff_channels: tuple[CompiledChannel, ...]
matrix: np.ndarray
base_xyz: np.ndarray
xy_bounds: tuple[float, float, float, float]
xy_hull_equations: np.ndarray
output_channel_count: int
total_available_lumens: float
order_key: tuple[str, ...]
@dataclass(slots=True)
class CompiledLightModel:
spec: VirtualLightSpec
combinations: tuple[CompiledCombination, ...]
reference_combinations: tuple[CompiledCombination, ...]
chroma_tolerance: float
target_cache_scale: int
approximate_chroma_band: float = 0.025
reference_cache: dict[tuple[str, int, int], tuple[object, ...]] = field(default_factory=dict)
max_brightness_cache: dict[tuple[str, int, int], float] = field(default_factory=dict)
def compile_light_model(
spec: VirtualLightSpec,
*,
chroma_tolerance: float = 1e-6,
target_cache_scale: int = 10000,
approximate_chroma_band: float = 0.025,
) -> CompiledLightModel:
choice_groups: list[tuple[CompiledOption, ...]] = []
order_index = 0
for controller in spec.controllers.values():
options: list[CompiledOption] = []
if controller.supported_modes == {"onoff"}:
options.append(
CompiledOption(
key=f"{controller.zigbee_id}:off",
controller_modes={controller.zigbee_id: None},
dimmable_channels=(),
onoff_channels=(),
)
)
for mode in controller.modes.values():
dimmable_channels: list[CompiledChannel] = []
onoff_channels: list[CompiledChannel] = []
target_list = onoff_channels if mode.name == "onoff" else dimmable_channels
for role, channel in mode.channels.items():
target_list.append(
CompiledChannel(
address=f"{controller.zigbee_id}.{mode.name}.{role}",
controller_id=controller.zigbee_id,
mode_name=mode.name,
role=role,
color=channel.color,
semantic_label=channel.effective_label(mode.name),
order_index=order_index,
)
)
order_index += 1
options.append(
CompiledOption(
key=f"{controller.zigbee_id}:{mode.name}",
controller_modes={controller.zigbee_id: mode.name},
dimmable_channels=tuple(dimmable_channels),
onoff_channels=tuple(onoff_channels),
)
)
choice_groups.append(tuple(options))
combinations: list[CompiledCombination] = []
for product in itertools.product(*choice_groups):
controller_modes: dict[str, ControllerModeName | None] = {}
dimmable_channels: list[CompiledChannel] = []
onoff_channels: list[CompiledChannel] = []
order_key: list[str] = []
for option in product:
controller_modes.update(option.controller_modes)
dimmable_channels.extend(option.dimmable_channels)
onoff_channels.extend(option.onoff_channels)
order_key.append(option.key)
dimmable_channels.sort(key=lambda channel: channel.order_index)
onoff_channels.sort(key=lambda channel: channel.order_index)
if dimmable_channels:
matrix = np.column_stack([channel.color.xyz for channel in dimmable_channels])
else:
matrix = np.zeros((3, 0), dtype=float)
if onoff_channels:
base_xyz = Color.weighted_sum(
[channel.color for channel in onoff_channels],
[1.0] * len(onoff_channels),
).xyz
else:
base_xyz = np.zeros(3, dtype=float)
all_channels = dimmable_channels + onoff_channels
if all_channels:
xs = [channel.color.xy[0] for channel in all_channels]
ys = [channel.color.xy[1] for channel in all_channels]
xy_bounds = (min(xs), max(xs), min(ys), max(ys))
else:
xy_bounds = (0.0, 0.0, 0.0, 0.0)
xy_hull_equations = _xy_hull_equations(all_channels, base_xyz)
combinations.append(
CompiledCombination(
key="|".join(order_key),
controller_modes=controller_modes,
dimmable_channels=tuple(dimmable_channels),
onoff_channels=tuple(onoff_channels),
matrix=matrix,
base_xyz=base_xyz,
xy_bounds=xy_bounds,
xy_hull_equations=xy_hull_equations,
output_channel_count=len(all_channels),
total_available_lumens=sum(channel.color.brightness for channel in all_channels),
order_key=tuple(order_key),
)
)
return CompiledLightModel(
spec=spec,
combinations=tuple(combinations),
reference_combinations=_deduplicate_reference_combinations(combinations),
chroma_tolerance=chroma_tolerance,
target_cache_scale=max(1, int(target_cache_scale)),
approximate_chroma_band=approximate_chroma_band,
)
def _deduplicate_reference_combinations(
combinations: list[CompiledCombination],
) -> tuple[CompiledCombination, ...]:
seen: set[tuple[object, ...]] = set()
unique: list[CompiledCombination] = []
for combination in combinations:
key = _reference_signature(combination)
if key in seen:
continue
seen.add(key)
unique.append(combination)
return tuple(unique)
def _reference_signature(combination: CompiledCombination) -> tuple[object, ...]:
dimmable = tuple(
sorted(
(
tuple(round(float(value), 8) for value in channel.color.xyz),
channel.semantic_label,
channel.mode_name,
channel.role,
)
for channel in combination.dimmable_channels
)
)
onoff = tuple(
sorted(
(
tuple(round(float(value), 8) for value in channel.color.xyz),
channel.semantic_label,
channel.mode_name,
channel.role,
)
for channel in combination.onoff_channels
)
)
return (dimmable, onoff)
def _xy_hull_equations(
channels: list[CompiledChannel],
base_xyz: np.ndarray,
) -> np.ndarray:
points = [channel.color.xy for channel in channels]
if float(base_xyz[1]) > 0.0:
base_sum = float(np.sum(base_xyz))
if base_sum > 0.0:
points.append((float(base_xyz[0] / base_sum), float(base_xyz[1] / base_sum)))
unique = sorted({(round(x, 10), round(y, 10)) for x, y in points})
if len(unique) < 3:
return np.zeros((0, 3), dtype=float)
try:
hull = ConvexHull(np.asarray(unique, dtype=float))
except QhullError:
return np.zeros((0, 3), dtype=float)
return np.asarray(hull.equations, dtype=float)