"""End-to-end orchestration for *research / validation* runs. Composition:: data_prep.prepare_cloud_from_cad / from_scan (Phase A) api.compute_coupler_measurement (Phase B) Phase A is the validation / data-preparation part: STP → mesh → sample → noise → transform → denoise → register. Phase B is the public measurement algorithm. This module saves every intermediate artifact under a structured run directory so engineers can debug and so the sweep runner can compare runs. Production callers should *not* use this module — they should call :func:`coupler_panel.api.compute_coupler_measurement` directly with their own clean point cloud. """ from __future__ import annotations from pathlib import Path from typing import Any import numpy as np from .api import compute_coupler_measurement, ProgressCallback from .config import load_config, resolve_auto from .data_prep import ( PreparedCloud, denoise_cloud, parse_cad, prepare_cloud_from_cad, prepare_cloud_from_scan, register_cloud, synthesize_cloud, ) from .io_utils import ( Artifacts, load_point_cloud, write_json, write_point_cloud, write_yaml, ) from .metrics import compute_deflection_metrics from .roi_select import ROISpec, apply_roi, default_roi_spec, load_roi_json from .structures import CouplerMeasurement # --------------------------------------------------------------------------- # Cloud-only research entry point # --------------------------------------------------------------------------- def run_coupler_measurement_pipeline( input_cloud: str | Path, out_dir: str | Path, *, config_path: str | Path | None = None, profile: str = "auto", cad_stp_path: str | Path | None = None, roi_spec: ROISpec | None = None, roi_json: str | Path | None = None, progress: ProgressCallback | None = None, ) -> CouplerMeasurement: """Main measurement entry: cloud (in CAD frame) → ROI → denoise → measure. **No registration step.** The input cloud is assumed to already live in the CAD / world frame (scanner extrinsics calibrated upstream). If it doesn't, 4-hole Kabsch against nominal CAD holes would also cancel the panel-deflection signal we want to measure — so this pipeline deliberately does not register. For the research path that *does* register (using oracle deflected GT), see :func:`run_from_cad_pipeline`. Inputs: - ``cad_stp_path`` — optional STEP file. Parsed for the analytical panel-normal seed (speeds up + stabilises hole detection's in-plane projection); the parsed GT is written as ``01_gt.json`` to ``out_dir`` for auditing. - ``roi_spec`` / ``roi_json`` — ROI selector applied to the cloud *before* denoising. If both omitted, the default ``mode='all'`` spec (pass-through) is used. ``roi_spec`` wins over ``roi_json``. Writes every intermediate to ``out_dir``. Returns a :class:`CouplerMeasurement`. """ art = Artifacts(out_dir) cfg = load_config(config_path, profile=profile) cfg = resolve_auto(cfg) write_yaml(cfg, art.resolved_config) # ---- Load + ROI clip -------------------------------------------------- raw_pts = load_point_cloud(input_cloud) if roi_spec is None: roi_spec = load_roi_json(roi_json) if roi_json is not None else default_roi_spec() pts, roi_diag = apply_roi(raw_pts, roi_spec) write_point_cloud(pts, art.path("sampled", ext="ply")) write_json({"spec": roi_spec.to_dict(), "diagnostics": roi_diag}, art.root / "roi.json") # ---- Optional CAD parse (panel_normal_seed + audit GT JSON) ---------- panel_seed: np.ndarray | None = None if cad_stp_path is not None: parsed = parse_cad(cad_stp_path, cfg["cad"]) write_json(parsed.gt.to_dict(), art.path("gt", ext="json")) panel_seed = np.asarray(parsed.gt.plane.normal, dtype=np.float64) # ---- Denoise (no Kabsch, no prepare_cloud_from_scan) ----------------- denoised = denoise_cloud(pts, cfg.get("denoise", {})) write_point_cloud(denoised.points, art.path("denoised", ext="ply")) # ---- Measurement ------------------------------------------------------ result = compute_coupler_measurement( denoised.points, config=cfg, profile="custom", panel_normal_seed=panel_seed, progress=progress, ) write_json(result.to_dict(), art.report_json) write_json(result.to_dict()["report"], art.path("metrics", ext="json")) return result # --------------------------------------------------------------------------- # CAD-driven research entry point # --------------------------------------------------------------------------- def run_from_cad_pipeline( stp_path: str | Path, out_dir: str | Path, *, config_path: str | Path | None = None, profile: str = "auto", progress: ProgressCallback | None = None, ) -> dict[str, Any]: """End-to-end research run: STP → synthesise → prepare → measure → diff GT. Returns a summary dict containing the measurement, GT metrics, and measured-minus-GT differences. Writes every intermediate cloud and JSON to ``out_dir``. """ art = Artifacts(out_dir) cfg = load_config(config_path, profile=profile) cfg = resolve_auto(cfg) write_yaml(cfg, art.resolved_config) # ---- Phase A.1 — parse + GT ------------------------------------------- parsed = parse_cad(stp_path, cfg["cad"]) write_point_cloud(parsed.mesh.vertices, art.path("cad_mesh", ext="ply")) write_json(parsed.gt.to_dict(), art.path("gt", ext="json")) # ---- Phase A.2 — synthesise -------------------------------------------- synth = synthesize_cloud( parsed, sampling_cfg=cfg["sampling"], noise_cfg=cfg.get("noise", {}), transform_cfg=cfg.get("transform", {}), scan_cfg=cfg.get("scan_simulator", {}), deflection_cfg=cfg.get("panel_deflection", {}), ) write_point_cloud(synth.sampled, art.path("sampled", ext="ply")) write_point_cloud(synth.noisy, art.path("noisy", ext="ply")) write_point_cloud(synth.transformed, art.path("transformed", ext="ply")) # ---- Phase A.3 — denoise ---------------------------------------------- denoised = denoise_cloud(synth.transformed, cfg.get("denoise", {})) write_point_cloud(denoised.points, art.path("denoised", ext="ply")) # ---- Phase A.4 — register --------------------------------------------- # Use PCA on the cloud itself for the projection seed (the cloud is # still in the pre-Kabsch frame here; using the CAD normal would # cause foreshortening bias). Register against the *deflected* GT # holes so Kabsch only cancels scanner-pose jitter, not the panel # deflection signal we want to measure. registered = register_cloud( denoised.points, target_holes=synth.gt_holes_after_deflection, detection_cfg=cfg["detection"], cad_cfg=cfg["cad"], registration_cfg=cfg["registration"], panel_normal_seed=None, ) write_point_cloud(registered.points, art.path("registered", ext="ply")) # ---- Phase B — measurement algorithm ---------------------------------- result = compute_coupler_measurement( registered.points, config=cfg, profile="custom", panel_normal_seed=np.asarray(parsed.gt.plane.normal, dtype=np.float64), progress=progress, ) # ---- GT metrics -------------------------------------------------------- # GT for the metrics is the *deflected* panel: angles equal the # injection, and the maximum-distance / maximum-x-deviation values are # computed over the deflected 4-hole rectangle (the same primitive the # algorithm later sees through the noise + jitter chain). gt_normal = synth.gt_normal_after_deflection centers = synth.gt_holes_after_deflection.locating_centers() gt_metrics = compute_deflection_metrics( normal=gt_normal, center_C=tuple(centers.mean(axis=0).tolist()), panel_points=centers, right_axis_sign=int(cfg["metrics"].get("right_axis_sign", 1)), angle_clamp_deg=float(cfg["metrics"].get("angle_clamp_deg", 0.0)), ) diff = { "theta_right_deg": result.theta_right_deg - gt_metrics.theta_right_deg, "theta_ref_deg": result.theta_ref_deg - gt_metrics.theta_ref_deg, "D_y_max_mm": result.D_y_max_mm - gt_metrics.D_y_max_mm, "D_z_max_mm": result.D_z_max_mm - gt_metrics.D_z_max_mm, "dx_y_max_mm": result.dx_y_max_mm - gt_metrics.dx_y_max_mm, "dx_z_max_mm": result.dx_z_max_mm - gt_metrics.dx_z_max_mm, } summary = { "stp": str(stp_path), "panel_deflection_injected": { "theta_right_deg": synth.panel_deflection_theta_right_deg, "theta_ref_deg": synth.panel_deflection_theta_ref_deg, "R": synth.panel_deflection_R.tolist(), }, "scan_pose_perturbation": synth.gt_transform_4x4.tolist(), "registration": { "matrix": registered.transform_4x4.tolist(), "rmse_mm": registered.rmse_mm, "max_residual_mm": registered.max_residual_mm, }, "measured": result.to_dict(), "gt_cad": parsed.gt.to_dict(), "gt_after_deflection": { "normal": gt_normal.tolist(), "locating_centers": centers.tolist(), }, "gt_metrics": gt_metrics.to_dict(), "diff": diff, "config": cfg, } write_json(summary, art.path("metrics", ext="json")) write_json(summary, art.report_json) return summary # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _try_load_normal_from_gt_json(path: str | Path) -> np.ndarray | None: """Read ``plane.normal`` from a GT JSON if present. Returns ``None`` otherwise.""" import json try: with Path(path).open("r", encoding="utf-8") as fh: d = json.load(fh) except Exception: return None n = d.get("plane", {}).get("normal") if n is None or len(n) != 3: return None arr = np.asarray(n, dtype=np.float64) norm = float(np.linalg.norm(arr)) if norm < 1e-9: return None return arr / norm def _load_holeset_from_json(path: str | Path): """Reconstruct a HoleSet from the JSON written by ``GroundTruth.to_dict``.""" import json from .structures import Hole, HoleSet with Path(path).open("r", encoding="utf-8") as fh: d = json.load(fh) holes = d["holes"] locating = tuple( Hole( center=tuple(h["center"]), radius_mm=float(h["radius_mm"]), fit_residual_rms_mm=float(h.get("fit_residual_rms_mm", 0.0)), role=h.get("role", "locating"), ) for h in holes["locating"] ) central = None if holes.get("central") is not None: c = holes["central"] central = Hole( center=tuple(c["center"]), radius_mm=float(c["radius_mm"]), fit_residual_rms_mm=float(c.get("fit_residual_rms_mm", 0.0)), role="central", ) return HoleSet(locating=locating, central=central) __all__ = [ "run_coupler_measurement_pipeline", "run_from_cad_pipeline", ]