"""Result dataclasses for coupler_panel. These types form the **stable public schema** of the package; their field names and semantics are guarded by ``SCHEMA_VERSION`` (see ``_version.py``). All numeric units are **mm** for distances and **degree** for angles unless otherwise noted. """ from __future__ import annotations from dataclasses import asdict, dataclass, field from typing import Any import numpy as np # --------------------------------------------------------------------------- # Building blocks # --------------------------------------------------------------------------- @dataclass(frozen=True) class Hole: """A single detected (or GT) circular hole on the panel.""" center: tuple[float, float, float] # (x, y, z), mm radius_mm: float fit_residual_rms_mm: float # 0 for GT role: str # "locating" | "central" | "unknown" @dataclass(frozen=True) class HoleSet: """The four locating holes plus an optional central hole. ``locating`` is ordered consistently between GT and detection so that Kabsch pairing is index-aligned. """ locating: tuple[Hole, Hole, Hole, Hole] central: Hole | None = None def locating_centers(self) -> np.ndarray: """Return a (4, 3) ndarray of locating-hole centres.""" return np.asarray([h.center for h in self.locating], dtype=np.float64) @dataclass(frozen=True) class PanelPlane: """The fitted (or GT) panel plane.""" normal: tuple[float, float, float] # unit vector, n_x > 0 enforced point_on_plane: tuple[float, float, float] d_offset: float # n . x + d = 0 inlier_ratio: float # 1.0 for GT inlier_residual_rms_mm: float # 0 for GT @dataclass(frozen=True) class ReferenceFrame: """Reference geometry derived from the four-hole panel centre.""" center_C: tuple[float, float, float] # Reference line L = C + lam * e_x (always parallel to the +x axis). line_direction: tuple[float, float, float] = (1.0, 0.0, 0.0) # Reference plane: z = C_z. Stored as plane normal + offset. reference_plane_z: float = 0.0 # Longitudinal mid-plane: y = C_y. longitudinal_mid_y: float = 0.0 @dataclass(frozen=True) class StageTiming: """Per-stage wall-clock and peak-memory cost.""" stage: str seconds: float peak_kib: float # --------------------------------------------------------------------------- # Top-level result # --------------------------------------------------------------------------- @dataclass class CouplerMeasurement: """Top-level result of the coupler-panel measurement pipeline. See ``docs/USAGE.md`` for the stable-API contract. """ schema_version: str algorithm_version: str # --- Primary measurands --- theta_right_deg: float # atan2(n_y, n_x), with right_axis_sign applied theta_ref_deg: float # atan2(n_z, n_x) D_y_max_mm: float # max |y_i - C_y| over panel ROI D_z_max_mm: float # max |z_i - C_z| over panel ROI dx_y_max_mm: float # |A| * D_y_max, A = -n_y / n_x dx_z_max_mm: float # |B| * D_z_max, B = -n_z / n_x # --- Geometry --- center_C: tuple[float, float, float] normal: tuple[float, float, float] # n_x > 0 holes: HoleSet plane: PanelPlane frame: ReferenceFrame # --- Diagnostics --- quality_flags: list[str] = field(default_factory=list) timings: list[StageTiming] = field(default_factory=list) report: dict[str, Any] = field(default_factory=dict) def to_dict(self) -> dict[str, Any]: """Return a fully JSON-serialisable dict.""" return _to_jsonable(asdict(self)) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _to_jsonable(obj: Any) -> Any: """Recursively convert numpy / tuple structures into plain Python types.""" if isinstance(obj, dict): return {k: _to_jsonable(v) for k, v in obj.items()} if isinstance(obj, (list, tuple)): return [_to_jsonable(v) for v in obj] if isinstance(obj, np.ndarray): return obj.tolist() if isinstance(obj, (np.floating, np.integer)): return obj.item() return obj __all__ = [ "Hole", "HoleSet", "PanelPlane", "ReferenceFrame", "StageTiming", "CouplerMeasurement", ]