85 lines
2.6 KiB
Python
85 lines
2.6 KiB
Python
"""Versioned Brain-internal rule configuration and decisions."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
|
|
|
|
class RuleConfigError(ValueError):
|
|
pass
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class NormalizedPoint:
|
|
x: float
|
|
y: float
|
|
|
|
def __post_init__(self) -> None:
|
|
if not 0.0 <= self.x <= 1.0 or not 0.0 <= self.y <= 1.0:
|
|
raise RuleConfigError("rule coordinates must be normalized to 0..1")
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class AreaDefinition:
|
|
rule_id: str
|
|
points: tuple[NormalizedPoint, ...]
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class DirectionalLineDefinition:
|
|
rule_id: str
|
|
start: NormalizedPoint
|
|
end: NormalizedPoint
|
|
trigger_direction: str
|
|
deadband: float = 0.005
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class RuleSet:
|
|
version: str
|
|
profile_id: str
|
|
width: int
|
|
height: int
|
|
areas: tuple[AreaDefinition, ...] = ()
|
|
directional_lines: tuple[DirectionalLineDefinition, ...] = ()
|
|
|
|
def __post_init__(self) -> None:
|
|
if not self.version or not self.profile_id or self.width <= 0 or self.height <= 0:
|
|
raise RuleConfigError("rule version, profile and dimensions are required")
|
|
identifiers = [rule.rule_id for rule in self.areas] + [rule.rule_id for rule in self.directional_lines]
|
|
if any(not identifier for identifier in identifiers) or len(set(identifiers)) != len(identifiers):
|
|
raise RuleConfigError("rule ids must be non-empty and unique")
|
|
for area in self.areas:
|
|
if len(area.points) < 3 or abs(_polygon_area(area.points)) < 1e-9:
|
|
raise RuleConfigError(f"area {area.rule_id!r} must be a non-degenerate polygon")
|
|
for line in self.directional_lines:
|
|
if line.start == line.end:
|
|
raise RuleConfigError(f"line {line.rule_id!r} must have distinct endpoints")
|
|
if line.trigger_direction not in {"left_to_right", "right_to_left"}:
|
|
raise RuleConfigError(f"line {line.rule_id!r} has invalid trigger direction")
|
|
if not 0.0 <= line.deadband < 0.5:
|
|
raise RuleConfigError(f"line {line.rule_id!r} has invalid deadband")
|
|
|
|
|
|
def _polygon_area(points: tuple[NormalizedPoint, ...]) -> float:
|
|
return sum(
|
|
first.x * second.y - second.x * first.y
|
|
for first, second in zip(points, points[1:] + points[:1])
|
|
) / 2.0
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class RuleDecision:
|
|
rule_id: str
|
|
rule_type: str
|
|
track_id: str
|
|
state: str
|
|
triggered: bool
|
|
reason: str
|
|
config_version: str
|
|
profile_id: str
|
|
width: int
|
|
height: int
|
|
anchor: NormalizedPoint
|
|
timestamp_ns: int
|