"""Configuration loading and merging. Configs are nested dicts. The single source of truth for defaults is ``configs/default.yaml`` (shipped alongside the package); ``DEFAULT_CONFIG`` mirrors its structure for in-memory programmatic overrides. Public functions: - :func:`load_config` — load a YAML file or accept an already-built dict. - :func:`load_profile` — load ``configs/profiles/.yaml`` and merge on top of the defaults. - :func:`deep_merge` — recursive dict merge (override wins). - :func:`apply_dotted` — set a value via ``"sampling.spacing_mm"``. - :func:`resolve_auto` — fill in fields whose value is the literal string ``"auto"`` (currently: ``plane_fit.ransac_inlier_thresh_mm``). """ from __future__ import annotations import copy from pathlib import Path from typing import Any import yaml _PKG_ROOT = Path(__file__).resolve().parent _DEFAULT_YAML = _PKG_ROOT.parent / "configs" / "default.yaml" def _load_yaml(path: Path) -> dict[str, Any]: with path.open("r", encoding="utf-8") as fh: data = yaml.safe_load(fh) if not isinstance(data, dict): raise ValueError(f"{path}: expected a mapping at the top level") return data def _load_default() -> dict[str, Any]: if _DEFAULT_YAML.exists(): return _load_yaml(_DEFAULT_YAML) # Package may have been installed without configs/. Fall back to a minimal # in-code default so library use without the YAML still works. return _IN_CODE_FALLBACK # Loaded lazily; recomputed on each call to keep tests deterministic. def default_config() -> dict[str, Any]: """Return a fresh copy of the default configuration.""" return copy.deepcopy(_load_default()) DEFAULT_CONFIG: dict[str, Any] # populated below for convenience def load_config( source: str | Path | dict[str, Any] | None, *, profile: str = "auto", ) -> dict[str, Any]: """Resolve the final configuration to use. The merge order is:: default.yaml < profile (if any) < source (path or dict) Parameters ---------- source : path, dict, or None If ``None``, only defaults (and the profile) are used. profile : str ``"auto"`` / ``"default"`` skip the profile step. Any other name is looked up at ``configs/profiles/.yaml``. """ cfg = default_config() if profile not in (None, "auto", "default", "custom"): prof_path = _PKG_ROOT.parent / "configs" / "profiles" / f"{profile}.yaml" if not prof_path.exists(): raise FileNotFoundError(f"profile '{profile}' not found at {prof_path}") cfg = deep_merge(cfg, _load_yaml(prof_path)) if source is None: pass elif isinstance(source, dict): cfg = deep_merge(cfg, source) else: cfg = deep_merge(cfg, _load_yaml(Path(source))) return cfg def load_profile(name: str) -> dict[str, Any]: """Load a profile YAML directly; useful for tests.""" path = _PKG_ROOT.parent / "configs" / "profiles" / f"{name}.yaml" return _load_yaml(path) def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: """Recursive dict merge. ``override`` wins on leaf collisions.""" out = copy.deepcopy(base) for key, val in override.items(): if ( key in out and isinstance(out[key], dict) and isinstance(val, dict) ): out[key] = deep_merge(out[key], val) else: out[key] = copy.deepcopy(val) return out def apply_dotted(cfg: dict[str, Any], dotted_key: str, value: Any) -> dict[str, Any]: """Set ``cfg["a"]["b"]["c"] = value`` from ``"a.b.c"``. Returns ``cfg``.""" parts = dotted_key.split(".") node = cfg for p in parts[:-1]: node = node.setdefault(p, {}) if not isinstance(node, dict): raise TypeError(f"cannot apply '{dotted_key}': '{p}' is not a dict") node[parts[-1]] = value return cfg def resolve_auto(cfg: dict[str, Any]) -> dict[str, Any]: """Replace literal ``"auto"`` markers with concrete values. Currently: - ``plane_fit.ransac_inlier_thresh_mm == "auto"`` → ``3 * noise.sigma_mm`` (or 0.5 mm if ``noise.sigma_mm == 0``). """ out = copy.deepcopy(cfg) pf = out.get("plane_fit", {}) if pf.get("ransac_inlier_thresh_mm", None) == "auto": sigma = float(out.get("noise", {}).get("sigma_mm", 0.0) or 0.0) pf["ransac_inlier_thresh_mm"] = max(3.0 * sigma, 0.5) out["plane_fit"] = pf return out # Minimal in-code fallback used only when the YAML is missing. _IN_CODE_FALLBACK: dict[str, Any] = { "schema_version": "1.0", "cad": { "linear_deflection_mm": 0.5, "angular_deflection_rad": 0.1, "locating_hole_radius_mm": [4.0, 12.0], "central_hole_radius_min_mm": 20.0, "rectangle_aspect_tol": 0.15, }, "sampling": {"method": "poisson_disk", "spacing_mm": 1.0, "num_points": 100000, "seed": 42}, "noise": {"enabled": True, "sigma_mm": 0.5, "mode": "isotropic", "seed": 43}, "transform": { "enabled": True, "rotation_deg": 3.0, "rotation_axis": "random", "translation_mm": 5.0, "translation_dir": "random", "seed": 44, }, "denoise": {"steps": []}, "registration": {"method": "kabsch_four_holes", "pre_align": "pca_centroid"}, "detection": { "panel_normal_seed": "from_cad", "grid_pixel_mm": 0.5, "panel_min_area_ratio": 0.4, "hole_min_area_mm2": 30.0, "hole_max_area_mm2": 800.0, "circle_fit": {"method": "algebraic_then_lsq", "inlier_residual_mm": 0.5}, "select_top_k_by_radius_match": 4, "rectangle_aspect_tol": 0.20, }, "roi": { "hole_dilate_mm": 3.0, "central_hole_dilate_mm": 5.0, "edge_buffer_mm": 5.0, "chamfer_normal_dot_min": 0.85, "outlier_z_score": 3.0, }, "plane_fit": { "method": "ransac_svd", "ransac_iterations": 200, "ransac_inlier_thresh_mm": "auto", "min_inlier_ratio": 0.6, "enforce_nx_positive": True, }, "metrics": {"right_axis_sign": 1, "angle_clamp_deg": 0.0}, "artifacts": { "save_intermediate": True, "ply_format": "binary", "image_dpi": 150, "heatmap_colormap": "viridis", }, } DEFAULT_CONFIG = copy.deepcopy(_IN_CODE_FALLBACK) __all__ = [ "DEFAULT_CONFIG", "default_config", "load_config", "load_profile", "deep_merge", "apply_dotted", "resolve_auto", ]