224 lines
7.8 KiB
Python
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)
|