Miroslav Simko <ms@iolabs.ch> 2026-09-02T09:36:46+02:00
Commit #15 · 13 snippets
README.md | 31 +++++++ .../_config.py | 89 ++++++++++++-------- tests/test_config.py | 96 ++++++++++++++++++++++ tests/test_config_model.py | 64 --------------- tests/test_config_validation.py | 90 -------------------- 5 files changed, 181 insertions(+), 189 deletions(-)
| 1 | """Load, merge and validate trajectory-filter config from packaged JSON. | 1 | """Load, merge and validate the trajectory-filter configuration. |
| 2 | 2 | ||
| 3 | The schema is `TrajectoryFilterConfig`. To add a key, add a field on that | 3 | The schema is `TrajectoryFilterConfig` (a `config_loader.ConfigModel`), |
| 4 | model and a matching entry in ``trajectory_filter.default.json``. | 4 | mirroring `trajectory_filter.default.json` key for key; ranges are declared on |
| 5 | the fields here rather than checked at the point of use. | ||
| 6 | |||
| 7 | Adding a config key means adding the field to the model and the same key to | ||
| 8 | `trajectory_filter.default.json` — nothing else. Unknown keys are rejected. | ||
| 9 | |||
| 10 | The entry points return a plain `dict[str, Any]`, because callers pass | ||
| 11 | ``--set``-style overrides around as dicts and embed the normalized mapping in | ||
| 12 | their run manifests verbatim. | ||
| 5 | """ | 13 | """ |
| 6 | 14 | ||
| 7 | from __future__ import annotations | 15 | from __future__ import annotations |
| 8 | 16 | ||
| 9 | import logging | 17 | import logging |
| 18 | from collections.abc import Mapping | ||
| 10 | from pathlib import Path | 19 | from pathlib import Path |
| 11 | from typing import Any | 20 | from typing import Any |
| 12 | 21 | ||
| 22 | import pydantic | ||
| 13 | from iolabs.common import config_loader | 23 | from iolabs.common import config_loader |
| 14 | 24 | ||
| 15 | logger = logging.getLogger(__name__) | 25 | logger = logging.getLogger(__name__) |
| 16 | 26 | ||
| 17 | _PACKAGE = "iolabs_point_cloud_trajectory_filter" | 27 | _PACKAGE_NAME = "iolabs_point_cloud_trajectory_filter" |
| 18 | _DEFAULT_FILENAME = "trajectory_filter.default.json" | 28 | _DEFAULT_FILENAME = "trajectory_filter.default.json" |
| 19 | _CONTEXT = "trajectory-filter config" | 29 | _CONTEXT = "trajectory-filter config" |
| 20 | 30 | ||
| 21 | 31 | ||
| 22 | class TrajectoryFilterConfig(config_loader.ConfigModel): | 32 | class TrajectoryFilterConfig(config_loader.ConfigModel): |
| 23 | """Validated trajectory-filter settings; field names match the default JSON.""" | 33 | """Validated trajectory-filter settings; field names match the default JSON.""" |
| 24 | 34 | ||
| 25 | spline_pcd_extension: str = "_run1_spline_points" | 35 | spline_pcd_extension: str = "_run1_spline_points" |
| 26 | fit_segment_length: float = 2.0 | 36 | fit_segment_length: float = pydantic.Field(default=2.0, gt=0.0) |
| 27 | spline_smoothing: float = 0.0 | 37 | spline_smoothing: float = pydantic.Field(default=0.0, ge=0.0) |
| 28 | resample_points: int = 400 | 38 | resample_points: int = pydantic.Field(default=400, ge=2) |
| 29 | loop_chord_arc_ratio_min: float = 0.85 | 39 | loop_chord_arc_ratio_min: float = pydantic.Field(default=0.85, ge=0.0, le=1.0) |
| 30 | loop_pca_span_ratio_min: float = 0.6 | 40 | loop_pca_span_ratio_min: float = pydantic.Field(default=0.6, ge=0.0, le=1.0) |
| 31 | min_coverage_increment_m: float = 50.0 | 41 | min_coverage_increment_m: float = pydantic.Field(default=50.0, ge=0.0) |
| 32 | max_connectivity_gap_m: float = 35.0 | 42 | max_connectivity_gap_m: float = pydantic.Field(default=35.0, ge=0.0) |
| 33 | contributed_endpoint_window_m: float = 10.0 | 43 | contributed_endpoint_window_m: float = pydantic.Field(default=10.0, ge=0.0) |
| 34 | loop_score_weight: float = 0.85 | 44 | loop_score_weight: float = pydantic.Field(default=0.85, ge=0.0, le=1.0) |
| 35 | residual_coverage_distance_m: float = 200.0 | 45 | residual_coverage_distance_m: float = pydantic.Field(default=200.0, ge=0.0) |
| 36 | min_residual_fragment_length_m: float = 25.0 | 46 | min_residual_fragment_length_m: float = pydantic.Field(default=25.0, ge=0.0) |
| 37 | min_residual_point_count: int = 10 | 47 | min_residual_point_count: int = pydantic.Field(default=10, ge=0) |
| 38 | min_branch_length_m: float = 1000.0 | 48 | min_branch_length_m: float = pydantic.Field(default=1000.0, ge=0.0) |
| 39 | max_branch_count: int = 20 | 49 | max_branch_count: int = pydantic.Field(default=20, ge=1) |
| 40 | branch_assignment_distance_m: float = 75.0 | 50 | branch_assignment_distance_m: float = pydantic.Field(default=75.0, gt=0.0) |
| 41 | branch_assignment_min_near_fraction: float = 0.1 | 51 | branch_assignment_min_near_fraction: float = pydantic.Field( |
| 42 | branch_assignment_min_near_length_m: float = 50.0 | 52 | default=0.1, ge=0.0, le=1.0 |
| 43 | branch_assignment_min_near_sample_count: int = 4 | 53 | ) |
| 54 | branch_assignment_min_near_length_m: float = pydantic.Field(default=50.0, ge=0.0) | ||
| 55 | branch_assignment_min_near_sample_count: int = pydantic.Field(default=4, ge=0) | ||
| 44 | branch_assignment_force_spine_stems: bool = True | 56 | branch_assignment_force_spine_stems: bool = True |
| 45 | branch_assignment_debug_ply_filename_template: str = ( | 57 | branch_assignment_debug_ply_filename_template: str = ( |
| 46 | "run2_branch_{branch_index:03d}_assigned_sources.ply" | 58 | "run2_branch_{branch_index:03d}_assigned_sources.ply" |
| 47 | ) | 59 | ) |
| 71 | 85 | ||
| 72 | def load_trajectory_filter_config( | 86 | def load_trajectory_filter_config( |
| 73 | config_path: str | Path | None = None, | 87 | config_path: str | Path | None = None, |
| 74 | ) -> dict[str, Any]: | 88 | ) -> dict[str, Any]: |
| 75 | """Load packaged (or *config_path*) defaults and return a plain config dict.""" | 89 | """Load the packaged defaults and return a plain config dict. |
| 90 | |||
| 91 | *config_path*, when given, replaces the packaged defaults. | ||
| 92 | """ | ||
| 76 | return _load_model(config_path=config_path).model_dump() | 93 | return _load_model(config_path=config_path).model_dump() |
| 77 | 94 | ||
| 78 | 95 | ||
| 79 | def build_trajectory_filter_config( | 96 | def build_trajectory_filter_config( |
| 80 | *, | 97 | *, |
| 81 | overrides: dict[str, Any] | None = None, | 98 | overrides: Mapping[str, Any] | None = None, |
| 82 | config_path: str | Path | None = None, | 99 | config_path: str | Path | None = None, |
| 83 | ) -> dict[str, Any]: | 100 | ) -> dict[str, Any]: |
| 84 | """Load defaults, deep-merge *overrides*, and return a plain config dict.""" | 101 | """Load defaults, deep-merge *overrides*, and return a plain config dict. |
| 102 | |||
| 103 | *config_path*, when given, replaces the packaged defaults. | ||
| 104 | """ | ||
| 85 | return _load_model(overrides=overrides, config_path=config_path).model_dump() | 105 | return _load_model(overrides=overrides, config_path=config_path).model_dump() |
| 86 | 106 | ||
| 87 | 107 | ||
| 88 | def _load_model( | 108 | def _load_model( |
| 89 | *, | 109 | *, |
| 90 | overrides: dict[str, Any] | None = None, | 110 | overrides: Mapping[str, Any] | None = None, |
| 91 | config_path: str | Path | None = None, | 111 | config_path: str | Path | None = None, |
| 92 | ) -> TrajectoryFilterConfig: | 112 | ) -> TrajectoryFilterConfig: |
| 93 | """Load, merge and validate into `TrajectoryFilterConfig`.""" | 113 | """Load, merge and validate into `TrajectoryFilterConfig`.""" |
| 94 | logger.debug( | 114 | if config_path is not None: |
| 95 | "Loading %s from %s", | 115 | logger.info("Config file applied: %s", config_path) |
| 96 | _CONTEXT, | 116 | if overrides: |
| 97 | config_path or f"{_PACKAGE}:{_DEFAULT_FILENAME}", | 117 | logger.info("Config overrides applied: %s", ", ".join(sorted(overrides))) |
| 98 | ) | ||
| 99 | return config_loader.load_config( | 118 | return config_loader.load_config( |
| 100 | TrajectoryFilterConfig, | 119 | TrajectoryFilterConfig, |
| 101 | package=_PACKAGE, | 120 | package=_PACKAGE_NAME, |
| 102 | filename=_DEFAULT_FILENAME, | 121 | filename=_DEFAULT_FILENAME, |
| 103 | overrides=overrides, | 122 | overrides=overrides, |
| 104 | config_path=config_path, | 123 | config_path=config_path, |
| 105 | context=_CONTEXT, | 124 | context=_CONTEXT, |
| 49 | save_debug_artifacts: bool = False | 61 | save_debug_artifacts: bool = False |
| 50 | save_debug_ply: bool = False | 62 | save_debug_ply: bool = False |
| 51 | debug_ply_filename: str = "run2_spine_splines.ply" | 63 | debug_ply_filename: str = "run2_spine_splines.ply" |
| 52 | clipped_debug_ply_filename: str = "run2_spine_splines_clipped.ply" | 64 | clipped_debug_ply_filename: str = "run2_spine_splines_clipped.ply" |
| 53 | clipped_debug_interval_margin_m: float = 10.0 | 65 | clipped_debug_interval_margin_m: float = pydantic.Field(default=10.0, ge=0.0) |
| 54 | candidate_debug_ply_filename: str = "run2_spine_candidates.ply" | 66 | candidate_debug_ply_filename: str = "run2_spine_candidates.ply" |
| 55 | debug_json_filename: str = "run2_spine_debug.json" | 67 | debug_json_filename: str = "run2_spine_debug.json" |
| 56 | 68 | ||
| 57 | 69 | ||
| 58 | class TrajectoryFilterConfigError(config_loader.ConfigError): | 70 | class TrajectoryFilterConfigError(config_loader.ConfigError): |
| 59 | """Raised when trajectory-filter config contains unsupported keys.""" | 71 | """Raised when trajectory-filter config contains unsupported keys or values.""" |
| 60 | 72 | ||
| 61 | 73 | ||
| 62 | def normalize_trajectory_filter_config(raw_config: dict[str, Any]) -> dict[str, Any]: | 74 | def normalize_trajectory_filter_config( |
| 75 | raw_config: Mapping[str, Any], | ||
| 76 | ) -> dict[str, Any]: | ||
| 63 | """Fill defaults, reject unknown keys, and return a plain config dict.""" | 77 | """Fill defaults, reject unknown keys, and return a plain config dict.""" |
| 64 | return config_loader.validate_config( | 78 | return config_loader.validate_config( |
| 65 | TrajectoryFilterConfig, | 79 | TrajectoryFilterConfig, |
| 66 | raw_config, | 80 | raw_config, |
| 1 | """Tests for the pydantic config layer of the trajectory filter.""" | ||
| 2 | |||
| 3 | from __future__ import annotations | ||
| 4 | |||
| 5 | import json | ||
| 6 | from pathlib import Path | ||
| 7 | |||
| 8 | import pytest | ||
| 9 | from iolabs.common import config_loader | ||
| 10 | |||
| 11 | from iolabs_point_cloud_trajectory_filter import _config | ||
| 12 | |||
| 13 | _DEFAULT_JSON = Path(_config.__file__).resolve().with_name( | ||
| 14 | "trajectory_filter.default.json" | ||
| 15 | ) | ||
| 16 | |||
| 17 | |||
| 18 | def _packaged_defaults() -> dict: | ||
| 19 | return json.loads(_DEFAULT_JSON.read_text(encoding="utf-8")) | ||
| 20 | |||
| 21 | |||
| 22 | def test_model_defaults_match_packaged_json() -> None: | ||
| 23 | assert _config.TrajectoryFilterConfig().model_dump() == _packaged_defaults() | ||
| 24 | |||
| 25 | |||
| 26 | def test_load_trajectory_filter_config_returns_packaged_defaults() -> None: | ||
| 27 | assert _config.load_trajectory_filter_config() == _packaged_defaults() | ||
| 28 | |||
| 29 | |||
| 30 | def test_error_class_is_config_error() -> None: | ||
| 31 | assert issubclass(_config.TrajectoryFilterConfigError, config_loader.ConfigError) | ||
| 32 | assert issubclass(_config.TrajectoryFilterConfigError, ValueError) | ||
| 33 | |||
| 34 | |||
| 35 | def test_unknown_top_level_key_is_rejected() -> None: | ||
| 36 | with pytest.raises(_config.TrajectoryFilterConfigError, match="random_seed"): | ||
| 37 | _config.normalize_trajectory_filter_config({"random_seed": 42}) | ||
| 38 | with pytest.raises( | ||
| 39 | _config.TrajectoryFilterConfigError, | ||
| 40 | match="Unknown trajectory-filter config key", | ||
| 41 | ): | ||
| 42 | _config.build_trajectory_filter_config(overrides={"typo_branch_count": 1}) | ||
| 43 | |||
| 44 | |||
| 45 | def test_normalize_fills_model_defaults() -> None: | ||
| 46 | assert _config.normalize_trajectory_filter_config({}) == _packaged_defaults() | ||
| 47 | config = _config.normalize_trajectory_filter_config({"max_branch_count": 3}) | ||
| 48 | assert config["max_branch_count"] == 3 | ||
| 49 | assert config["manifest_filename"] == "run2_spine_splines.json" | ||
| 50 | |||
| 51 | |||
| 52 | def test_overrides_deep_merge_onto_defaults() -> None: | ||
| 53 | config = _config.build_trajectory_filter_config( | ||
| 54 | overrides={"save_debug_ply": True, "loop_score_weight": 0.75} | ||
| 55 | ) | ||
| 56 | assert config["save_debug_ply"] is True | ||
| 57 | assert config["loop_score_weight"] == 0.75 | ||
| 58 | assert config["manifest_filename"] == "run2_spine_splines.json" | ||
| 59 | |||
| 60 | |||
| 61 | def test_config_path_replaces_packaged_defaults(tmp_path: Path) -> None: | ||
| 62 | path = tmp_path / "custom.json" | ||
| 63 | path.write_text(json.dumps({"max_branch_count": 7}), encoding="utf-8") | ||
| 64 | config = _config.load_trajectory_filter_config(path) | ||
| 65 | assert config["max_branch_count"] == 7 | ||
| 66 | assert config["save_debug_ply"] is False | ||
| 67 | |||
| 68 | |||
| 69 | def test_set_override_coercion_and_rejection() -> None: | ||
| 70 | overrides = config_loader.parse_set_overrides( | ||
| 71 | [ | ||
| 72 | "save_debug_ply=true", | ||
| 73 | "max_branch_count=3", | ||
| 74 | "resample_points=1e3", | ||
| 75 | "loop_score_weight=0.5", | ||
| 76 | ] | ||
| 77 | ) | ||
| 78 | config = _config.build_trajectory_filter_config(overrides=overrides) | ||
| 79 | assert config["save_debug_ply"] is True | ||
| 80 | assert config["max_branch_count"] == 3 | ||
| 81 | assert config["resample_points"] == 1000 | ||
| 82 | assert config["loop_score_weight"] == 0.5 | ||
| 83 | |||
| 84 | with pytest.raises(_config.TrajectoryFilterConfigError): | ||
| 85 | _config.normalize_trajectory_filter_config({"save_debug_ply": "flase"}) | ||
| 86 | with pytest.raises(_config.TrajectoryFilterConfigError): | ||
| 87 | _config.normalize_trajectory_filter_config({"max_branch_count": "not-an-int"}) | ||
| 88 | |||
| 89 | |||
| 90 | def test_out_of_range_values_are_rejected() -> None: | ||
| 91 | with pytest.raises(_config.TrajectoryFilterConfigError): | ||
| 92 | _config.normalize_trajectory_filter_config({"loop_score_weight": 1.5}) | ||
| 93 | with pytest.raises(_config.TrajectoryFilterConfigError): | ||
| 94 | _config.normalize_trajectory_filter_config({"max_branch_count": 0}) | ||
| 95 | with pytest.raises(_config.TrajectoryFilterConfigError): | ||
| 96 | _config.normalize_trajectory_filter_config({"fit_segment_length": 0.0}) | ||
| 0 |
| 1 | """Tests for the pydantic config layer of the trajectory filter.""" | ||
| 2 | |||
| 3 | from __future__ import annotations | ||
| 4 | |||
| 5 | import json | ||
| 6 | from pathlib import Path | ||
| 7 | |||
| 8 | import pytest | ||
| 9 | from iolabs.common import config_loader | ||
| 10 | |||
| 11 | from iolabs_point_cloud_trajectory_filter import _config | ||
| 12 | |||
| 13 | _DEFAULT_JSON = ( | ||
| 14 | Path(_config.__file__).resolve().with_name("trajectory_filter.default.json") | ||
| 15 | ) | ||
| 16 | |||
| 17 | |||
| 18 | def test_error_class_is_config_error() -> None: | ||
| 19 | assert issubclass(_config.TrajectoryFilterConfigError, config_loader.ConfigError) | ||
| 20 | assert issubclass(_config.TrajectoryFilterConfigError, ValueError) | ||
| 21 | |||
| 22 | |||
| 23 | def test_packaged_json_matches_model_defaults() -> None: | ||
| 24 | packaged = json.loads(_DEFAULT_JSON.read_text(encoding="utf-8")) | ||
| 25 | assert packaged == _config.TrajectoryFilterConfig().model_dump() | ||
| 26 | assert _config.load_trajectory_filter_config() == packaged | ||
| 27 | |||
| 28 | |||
| 29 | def test_build_merges_set_overrides_and_coerces_strings() -> None: | ||
| 30 | overrides = config_loader.parse_set_overrides( | ||
| 31 | [ | ||
| 32 | "save_debug_ply=true", | ||
| 33 | "max_branch_count=3", | ||
| 34 | "loop_score_weight=0.5", | ||
| 35 | ] | ||
| 36 | ) | ||
| 37 | config = _config.build_trajectory_filter_config(overrides=overrides) | ||
| 38 | assert config["save_debug_ply"] is True | ||
| 39 | assert config["max_branch_count"] == 3 | ||
| 40 | assert config["loop_score_weight"] == 0.5 | ||
| 41 | assert config["manifest_filename"] == "run2_spine_splines.json" | ||
| 42 | |||
| 43 | |||
| 44 | def test_build_rejects_unknown_override_key() -> None: | ||
| 45 | with pytest.raises( | ||
| 46 | _config.TrajectoryFilterConfigError, | ||
| 47 | match="Unknown trajectory-filter config key", | ||
| 48 | ): | ||
| 49 | _config.build_trajectory_filter_config(overrides={"typo_branch_count": 1}) | ||
| 50 | |||
| 51 | |||
| 52 | def test_config_path_replaces_packaged_defaults(tmp_path: Path) -> None: | ||
| 53 | path = tmp_path / "custom.json" | ||
| 54 | path.write_text(json.dumps({"max_branch_count": 7}), encoding="utf-8") | ||
| 55 | config = _config.load_trajectory_filter_config(path) | ||
| 56 | assert config["max_branch_count"] == 7 | ||
| 57 | assert config["save_debug_ply"] is False | ||
| 58 | |||
| 59 | |||
| 60 | def test_bad_values_are_rejected() -> None: | ||
| 61 | with pytest.raises(_config.TrajectoryFilterConfigError): | ||
| 62 | _config.normalize_trajectory_filter_config({"max_branch_count": "not-an-int"}) | ||
| 63 | with pytest.raises(_config.TrajectoryFilterConfigError): | ||
| 64 | _config.normalize_trajectory_filter_config({"save_debug_ply": "flase"}) | ||
| 0 |
| 1 | import pytest | ||
| 2 | |||
| 3 | from iolabs_point_cloud_trajectory_filter import ( | ||
| 4 | TrajectoryFilterConfigError, | ||
| 5 | normalize_trajectory_filter_config, | ||
| 6 | ) | ||
| 7 | |||
| 8 | |||
| 9 | def test_normalize_rejects_unknown_keys(): | ||
| 10 | with pytest.raises( | ||
| 11 | TrajectoryFilterConfigError, match="Unknown trajectory-filter config key" | ||
| 12 | ): | ||
| 13 | normalize_trajectory_filter_config({"random_seed": 42}) | ||
| 14 | |||
| 15 | |||
| 16 | def test_normalize_applies_defaults(): | ||
| 17 | config = normalize_trajectory_filter_config({}) | ||
| 18 | |||
| 19 | assert config["spline_pcd_extension"] == "_run1_spline_points" | ||
| 20 | assert config["fit_segment_length"] == 2.0 | ||
| 21 | assert config["loop_chord_arc_ratio_min"] == 0.85 | ||
| 22 | assert config["loop_pca_span_ratio_min"] == 0.6 | ||
| 23 | assert config["min_coverage_increment_m"] == 50.0 | ||
| 24 | assert config["max_connectivity_gap_m"] == 35.0 | ||
| 25 | assert config["contributed_endpoint_window_m"] == 10.0 | ||
| 26 | assert config["loop_score_weight"] == 0.85 | ||
| 27 | assert config["residual_coverage_distance_m"] == 200.0 | ||
| 28 | assert config["min_residual_fragment_length_m"] == 25.0 | ||
| 29 | assert config["min_residual_point_count"] == 10 | ||
| 30 | assert config["min_branch_length_m"] == 1000.0 | ||
| 31 | assert config["max_branch_count"] == 20 | ||
| 32 | assert config["branch_assignment_distance_m"] == 75.0 | ||
| 33 | assert config["branch_assignment_min_near_fraction"] == 0.10 | ||
| 34 | assert config["branch_assignment_min_near_length_m"] == 50.0 | ||
| 35 | assert config["branch_assignment_min_near_sample_count"] == 4 | ||
| 36 | assert config["branch_assignment_force_spine_stems"] is True | ||
| 37 | assert ( | ||
| 38 | config["branch_assignment_debug_ply_filename_template"] | ||
| 39 | == "run2_branch_{branch_index:03d}_assigned_sources.ply" | ||
| 40 | ) | ||
| 41 | assert config["manifest_filename"] == "run2_spine_splines.json" | ||
| 42 | assert config["save_debug_artifacts"] is False | ||
| 43 | assert config["save_debug_ply"] is False | ||
| 44 | assert config["clipped_debug_ply_filename"] == "run2_spine_splines_clipped.ply" | ||
| 45 | assert config["clipped_debug_interval_margin_m"] == 10.0 | ||
| 46 | assert config["candidate_debug_ply_filename"] == "run2_spine_candidates.ply" | ||
| 47 | assert config["debug_json_filename"] == "run2_spine_debug.json" | ||
| 48 | |||
| 49 | |||
| 50 | def test_normalize_preserves_overrides(): | ||
| 51 | config = normalize_trajectory_filter_config( | ||
| 52 | { | ||
| 53 | "min_coverage_increment_m": 1.0, | ||
| 54 | "max_connectivity_gap_m": 100.0, | ||
| 55 | "contributed_endpoint_window_m": 8.0, | ||
| 56 | "loop_score_weight": 0.75, | ||
| 57 | "residual_coverage_distance_m": 7.0, | ||
| 58 | "min_residual_fragment_length_m": 9.0, | ||
| 59 | "min_residual_point_count": 6, | ||
| 60 | "min_branch_length_m": 11.0, | ||
| 61 | "max_branch_count": 3, | ||
| 62 | "branch_assignment_distance_m": 12.0, | ||
| 63 | "branch_assignment_min_near_fraction": 0.25, | ||
| 64 | "branch_assignment_min_near_length_m": 33.0, | ||
| 65 | "branch_assignment_min_near_sample_count": 8, | ||
| 66 | "branch_assignment_force_spine_stems": False, | ||
| 67 | "branch_assignment_debug_ply_filename_template": "branch_{branch_id}.ply", | ||
| 68 | "save_debug_artifacts": True, | ||
| 69 | "save_debug_ply": True, | ||
| 70 | "clipped_debug_interval_margin_m": 25.0, | ||
| 71 | } | ||
| 72 | ) | ||
| 73 | assert config["min_coverage_increment_m"] == 1.0 | ||
| 74 | assert config["max_connectivity_gap_m"] == 100.0 | ||
| 75 | assert config["contributed_endpoint_window_m"] == 8.0 | ||
| 76 | assert config["loop_score_weight"] == 0.75 | ||
| 77 | assert config["residual_coverage_distance_m"] == 7.0 | ||
| 78 | assert config["min_residual_fragment_length_m"] == 9.0 | ||
| 79 | assert config["min_residual_point_count"] == 6 | ||
| 80 | assert config["min_branch_length_m"] == 11.0 | ||
| 81 | assert config["max_branch_count"] == 3 | ||
| 82 | assert config["branch_assignment_distance_m"] == 12.0 | ||
| 83 | assert config["branch_assignment_min_near_fraction"] == 0.25 | ||
| 84 | assert config["branch_assignment_min_near_length_m"] == 33.0 | ||
| 85 | assert config["branch_assignment_min_near_sample_count"] == 8 | ||
| 86 | assert config["branch_assignment_force_spine_stems"] is False | ||
| 87 | assert config["branch_assignment_debug_ply_filename_template"] == "branch_{branch_id}.ply" | ||
| 88 | assert config["save_debug_artifacts"] is True | ||
| 89 | assert config["save_debug_ply"] is True | ||
| 90 | assert config["clipped_debug_interval_margin_m"] == 25.0 | ||
| 0 |
| 1 | # iolabs-point-cloud-trajectory-filter | ||
| 2 | |||
| 3 | Spine-spline selection from per-LAS trajectory point clouds (LaneFinder pipeline | ||
| 4 | step 2). `SpineSplineSelector` fits arc-length splines to the run-1 trajectory | ||
| 5 | clouds, scores loop/branch candidates, and writes the spine-spline manifest plus | ||
| 6 | optional debug PLY/JSON artifacts. | ||
| 7 | |||
| 8 | ## Install | ||
| 9 | |||
| 10 | ```bash | ||
| 11 | . /home/ai/dev/3dai.lanefinder/scripts/nexus_credentials.sh && uv sync | ||
| 12 | ``` | ||
| 13 | |||
| 14 | ## Tests | ||
| 15 | |||
| 16 | ```bash | ||
| 17 | uv run pytest tests/ | ||
| 18 | ``` | ||
| 19 | |||
| 20 | ## Configuration | ||
| 21 | |||
| 22 | Defaults live in | ||
| 23 | `src/iolabs_point_cloud_trajectory_filter/trajectory_filter.default.json`. The | ||
| 24 | schema is `TrajectoryFilterConfig` in | ||
| 25 | `iolabs_point_cloud_trajectory_filter._config` (a `config_loader.ConfigModel`); | ||
| 26 | nested JSON sections are nested models and unknown keys are rejected. **To add a | ||
| 27 | config key: add the field (with its type, default and any `Field` range) to the | ||
| 28 | model and the same key with the same default to the JSON — nothing else.** | ||
| 29 | `normalize_trajectory_filter_config`, `load_trajectory_filter_config` and | ||
| 30 | `build_trajectory_filter_config` return a plain `dict`. Runtime overrides come | ||
| 31 | from repeatable `--set KEY=VALUE`, never repo-local JSON. | ||
| 0 |
| 1 | """Load, merge and validate trajectory-filter config from packaged JSON. | 1 | """Load, merge and validate the trajectory-filter configuration. |
| 2 | 2 | ||
| 3 | The schema is `TrajectoryFilterConfig`. To add a key, add a field on that | 3 | The schema is `TrajectoryFilterConfig` (a `config_loader.ConfigModel`), |
| 4 | model and a matching entry in ``trajectory_filter.default.json``. | 4 | mirroring `trajectory_filter.default.json` key for key; ranges are declared on |
| 5 | the fields here rather than checked at the point of use. | ||
| 6 | |||
| 7 | Adding a config key means adding the field to the model and the same key to | ||
| 8 | `trajectory_filter.default.json` — nothing else. Unknown keys are rejected. | ||
| 9 | |||
| 10 | The entry points return a plain `dict[str, Any]`, because callers pass | ||
| 11 | ``--set``-style overrides around as dicts and embed the normalized mapping in | ||
| 12 | their run manifests verbatim. | ||
| 5 | """ | 13 | """ |
| 6 | 14 | ||
| 7 | from __future__ import annotations | 15 | from __future__ import annotations |
| 8 | 16 | ||
| 9 | import logging | 17 | import logging |
| 18 | from collections.abc import Mapping | ||
| 10 | from pathlib import Path | 19 | from pathlib import Path |
| 11 | from typing import Any | 20 | from typing import Any |
| 12 | 21 | ||
| 22 | import pydantic | ||
| 13 | from iolabs.common import config_loader | 23 | from iolabs.common import config_loader |
| 14 | 24 | ||
| 15 | logger = logging.getLogger(__name__) | 25 | logger = logging.getLogger(__name__) |
| 16 | 26 | ||
| 17 | _PACKAGE = "iolabs_point_cloud_trajectory_filter" | 27 | _PACKAGE_NAME = "iolabs_point_cloud_trajectory_filter" |
| 18 | _DEFAULT_FILENAME = "trajectory_filter.default.json" | 28 | _DEFAULT_FILENAME = "trajectory_filter.default.json" |
| 19 | _CONTEXT = "trajectory-filter config" | 29 | _CONTEXT = "trajectory-filter config" |
| 20 | 30 | ||
| 21 | 31 | ||
| 22 | class TrajectoryFilterConfig(config_loader.ConfigModel): | 32 | class TrajectoryFilterConfig(config_loader.ConfigModel): |
| 23 | """Validated trajectory-filter settings; field names match the default JSON.""" | 33 | """Validated trajectory-filter settings; field names match the default JSON.""" |
| 24 | 34 | ||
| 25 | spline_pcd_extension: str = "_run1_spline_points" | 35 | spline_pcd_extension: str = "_run1_spline_points" |
| 26 | fit_segment_length: float = 2.0 | 36 | fit_segment_length: float = pydantic.Field(default=2.0, gt=0.0) |
| 27 | spline_smoothing: float = 0.0 | 37 | spline_smoothing: float = pydantic.Field(default=0.0, ge=0.0) |
| 28 | resample_points: int = 400 | 38 | resample_points: int = pydantic.Field(default=400, ge=2) |
| 29 | loop_chord_arc_ratio_min: float = 0.85 | 39 | loop_chord_arc_ratio_min: float = pydantic.Field(default=0.85, ge=0.0, le=1.0) |
| 30 | loop_pca_span_ratio_min: float = 0.6 | 40 | loop_pca_span_ratio_min: float = pydantic.Field(default=0.6, ge=0.0, le=1.0) |
| 31 | min_coverage_increment_m: float = 50.0 | 41 | min_coverage_increment_m: float = pydantic.Field(default=50.0, ge=0.0) |
| 32 | max_connectivity_gap_m: float = 35.0 | 42 | max_connectivity_gap_m: float = pydantic.Field(default=35.0, ge=0.0) |
| 33 | contributed_endpoint_window_m: float = 10.0 | 43 | contributed_endpoint_window_m: float = pydantic.Field(default=10.0, ge=0.0) |
| 34 | loop_score_weight: float = 0.85 | 44 | loop_score_weight: float = pydantic.Field(default=0.85, ge=0.0, le=1.0) |
| 35 | residual_coverage_distance_m: float = 200.0 | 45 | residual_coverage_distance_m: float = pydantic.Field(default=200.0, ge=0.0) |
| 36 | min_residual_fragment_length_m: float = 25.0 | 46 | min_residual_fragment_length_m: float = pydantic.Field(default=25.0, ge=0.0) |
| 37 | min_residual_point_count: int = 10 | 47 | min_residual_point_count: int = pydantic.Field(default=10, ge=0) |
| 38 | min_branch_length_m: float = 1000.0 | 48 | min_branch_length_m: float = pydantic.Field(default=1000.0, ge=0.0) |
| 39 | max_branch_count: int = 20 | 49 | max_branch_count: int = pydantic.Field(default=20, ge=1) |
| 40 | branch_assignment_distance_m: float = 75.0 | 50 | branch_assignment_distance_m: float = pydantic.Field(default=75.0, gt=0.0) |
| 41 | branch_assignment_min_near_fraction: float = 0.1 | 51 | branch_assignment_min_near_fraction: float = pydantic.Field( |
| 42 | branch_assignment_min_near_length_m: float = 50.0 | 52 | default=0.1, ge=0.0, le=1.0 |
| 43 | branch_assignment_min_near_sample_count: int = 4 | 53 | ) |
| 54 | branch_assignment_min_near_length_m: float = pydantic.Field(default=50.0, ge=0.0) | ||
| 55 | branch_assignment_min_near_sample_count: int = pydantic.Field(default=4, ge=0) | ||
| 44 | branch_assignment_force_spine_stems: bool = True | 56 | branch_assignment_force_spine_stems: bool = True |
| 45 | branch_assignment_debug_ply_filename_template: str = ( | 57 | branch_assignment_debug_ply_filename_template: str = ( |
| 46 | "run2_branch_{branch_index:03d}_assigned_sources.ply" | 58 | "run2_branch_{branch_index:03d}_assigned_sources.ply" |
| 47 | ) | 59 | ) |
| 49 | save_debug_artifacts: bool = False | 61 | save_debug_artifacts: bool = False |
| 50 | save_debug_ply: bool = False | 62 | save_debug_ply: bool = False |
| 51 | debug_ply_filename: str = "run2_spine_splines.ply" | 63 | debug_ply_filename: str = "run2_spine_splines.ply" |
| 52 | clipped_debug_ply_filename: str = "run2_spine_splines_clipped.ply" | 64 | clipped_debug_ply_filename: str = "run2_spine_splines_clipped.ply" |
| 53 | clipped_debug_interval_margin_m: float = 10.0 | 65 | clipped_debug_interval_margin_m: float = pydantic.Field(default=10.0, ge=0.0) |
| 54 | candidate_debug_ply_filename: str = "run2_spine_candidates.ply" | 66 | candidate_debug_ply_filename: str = "run2_spine_candidates.ply" |
| 55 | debug_json_filename: str = "run2_spine_debug.json" | 67 | debug_json_filename: str = "run2_spine_debug.json" |
| 56 | 68 | ||
| 57 | 69 | ||
| 58 | class TrajectoryFilterConfigError(config_loader.ConfigError): | 70 | class TrajectoryFilterConfigError(config_loader.ConfigError): |
| 59 | """Raised when trajectory-filter config contains unsupported keys.""" | 71 | """Raised when trajectory-filter config contains unsupported keys or values.""" |
| 60 | 72 | ||
| 61 | 73 | ||
| 62 | def normalize_trajectory_filter_config(raw_config: dict[str, Any]) -> dict[str, Any]: | 74 | def normalize_trajectory_filter_config( |
| 75 | raw_config: Mapping[str, Any], | ||
| 76 | ) -> dict[str, Any]: | ||
| 63 | """Fill defaults, reject unknown keys, and return a plain config dict.""" | 77 | """Fill defaults, reject unknown keys, and return a plain config dict.""" |
| 64 | return config_loader.validate_config( | 78 | return config_loader.validate_config( |
| 65 | TrajectoryFilterConfig, | 79 | TrajectoryFilterConfig, |
| 66 | raw_config, | 80 | raw_config, |
| 71 | 85 | ||
| 72 | def load_trajectory_filter_config( | 86 | def load_trajectory_filter_config( |
| 73 | config_path: str | Path | None = None, | 87 | config_path: str | Path | None = None, |
| 74 | ) -> dict[str, Any]: | 88 | ) -> dict[str, Any]: |
| 75 | """Load packaged (or *config_path*) defaults and return a plain config dict.""" | 89 | """Load the packaged defaults and return a plain config dict. |
| 90 | |||
| 91 | *config_path*, when given, replaces the packaged defaults. | ||
| 92 | """ | ||
| 76 | return _load_model(config_path=config_path).model_dump() | 93 | return _load_model(config_path=config_path).model_dump() |
| 77 | 94 | ||
| 78 | 95 | ||
| 79 | def build_trajectory_filter_config( | 96 | def build_trajectory_filter_config( |
| 80 | *, | 97 | *, |
| 81 | overrides: dict[str, Any] | None = None, | 98 | overrides: Mapping[str, Any] | None = None, |
| 82 | config_path: str | Path | None = None, | 99 | config_path: str | Path | None = None, |
| 83 | ) -> dict[str, Any]: | 100 | ) -> dict[str, Any]: |
| 84 | """Load defaults, deep-merge *overrides*, and return a plain config dict.""" | 101 | """Load defaults, deep-merge *overrides*, and return a plain config dict. |
| 102 | |||
| 103 | *config_path*, when given, replaces the packaged defaults. | ||
| 104 | """ | ||
| 85 | return _load_model(overrides=overrides, config_path=config_path).model_dump() | 105 | return _load_model(overrides=overrides, config_path=config_path).model_dump() |
| 86 | 106 | ||
| 87 | 107 | ||
| 88 | def _load_model( | 108 | def _load_model( |
| 89 | *, | 109 | *, |
| 90 | overrides: dict[str, Any] | None = None, | 110 | overrides: Mapping[str, Any] | None = None, |
| 91 | config_path: str | Path | None = None, | 111 | config_path: str | Path | None = None, |
| 92 | ) -> TrajectoryFilterConfig: | 112 | ) -> TrajectoryFilterConfig: |
| 93 | """Load, merge and validate into `TrajectoryFilterConfig`.""" | 113 | """Load, merge and validate into `TrajectoryFilterConfig`.""" |
| 94 | logger.debug( | 114 | if config_path is not None: |
| 95 | "Loading %s from %s", | 115 | logger.info("Config file applied: %s", config_path) |
| 96 | _CONTEXT, | 116 | if overrides: |
| 97 | config_path or f"{_PACKAGE}:{_DEFAULT_FILENAME}", | 117 | logger.info("Config overrides applied: %s", ", ".join(sorted(overrides))) |
| 98 | ) | ||
| 99 | return config_loader.load_config( | 118 | return config_loader.load_config( |
| 100 | TrajectoryFilterConfig, | 119 | TrajectoryFilterConfig, |
| 101 | package=_PACKAGE, | 120 | package=_PACKAGE_NAME, |
| 102 | filename=_DEFAULT_FILENAME, | 121 | filename=_DEFAULT_FILENAME, |
| 103 | overrides=overrides, | 122 | overrides=overrides, |
| 104 | config_path=config_path, | 123 | config_path=config_path, |
| 105 | context=_CONTEXT, | 124 | context=_CONTEXT, |
| 1 | """Tests for the pydantic config layer of the trajectory filter.""" | ||
| 2 | |||
| 3 | from __future__ import annotations | ||
| 4 | |||
| 5 | import json | ||
| 6 | from pathlib import Path | ||
| 7 | |||
| 8 | import pytest | ||
| 9 | from iolabs.common import config_loader | ||
| 10 | |||
| 11 | from iolabs_point_cloud_trajectory_filter import _config | ||
| 12 | |||
| 13 | _DEFAULT_JSON = Path(_config.__file__).resolve().with_name( | ||
| 14 | "trajectory_filter.default.json" | ||
| 15 | ) | ||
| 16 | |||
| 17 | |||
| 18 | def _packaged_defaults() -> dict: | ||
| 19 | return json.loads(_DEFAULT_JSON.read_text(encoding="utf-8")) | ||
| 20 | |||
| 21 | |||
| 22 | def test_model_defaults_match_packaged_json() -> None: | ||
| 23 | assert _config.TrajectoryFilterConfig().model_dump() == _packaged_defaults() | ||
| 24 | |||
| 25 | |||
| 26 | def test_load_trajectory_filter_config_returns_packaged_defaults() -> None: | ||
| 27 | assert _config.load_trajectory_filter_config() == _packaged_defaults() | ||
| 28 | |||
| 29 | |||
| 30 | def test_error_class_is_config_error() -> None: | ||
| 31 | assert issubclass(_config.TrajectoryFilterConfigError, config_loader.ConfigError) | ||
| 32 | assert issubclass(_config.TrajectoryFilterConfigError, ValueError) | ||
| 33 | |||
| 34 | |||
| 35 | def test_unknown_top_level_key_is_rejected() -> None: | ||
| 36 | with pytest.raises(_config.TrajectoryFilterConfigError, match="random_seed"): | ||
| 37 | _config.normalize_trajectory_filter_config({"random_seed": 42}) | ||
| 38 | with pytest.raises( | ||
| 39 | _config.TrajectoryFilterConfigError, | ||
| 40 | match="Unknown trajectory-filter config key", | ||
| 41 | ): | ||
| 42 | _config.build_trajectory_filter_config(overrides={"typo_branch_count": 1}) | ||
| 43 | |||
| 44 | |||
| 45 | def test_normalize_fills_model_defaults() -> None: | ||
| 46 | assert _config.normalize_trajectory_filter_config({}) == _packaged_defaults() | ||
| 47 | config = _config.normalize_trajectory_filter_config({"max_branch_count": 3}) | ||
| 48 | assert config["max_branch_count"] == 3 | ||
| 49 | assert config["manifest_filename"] == "run2_spine_splines.json" | ||
| 50 | |||
| 51 | |||
| 52 | def test_overrides_deep_merge_onto_defaults() -> None: | ||
| 53 | config = _config.build_trajectory_filter_config( | ||
| 54 | overrides={"save_debug_ply": True, "loop_score_weight": 0.75} | ||
| 55 | ) | ||
| 56 | assert config["save_debug_ply"] is True | ||
| 57 | assert config["loop_score_weight"] == 0.75 | ||
| 58 | assert config["manifest_filename"] == "run2_spine_splines.json" | ||
| 59 | |||
| 60 | |||
| 61 | def test_config_path_replaces_packaged_defaults(tmp_path: Path) -> None: | ||
| 62 | path = tmp_path / "custom.json" | ||
| 63 | path.write_text(json.dumps({"max_branch_count": 7}), encoding="utf-8") | ||
| 64 | config = _config.load_trajectory_filter_config(path) | ||
| 65 | assert config["max_branch_count"] == 7 | ||
| 66 | assert config["save_debug_ply"] is False | ||
| 67 | |||
| 68 | |||
| 69 | def test_set_override_coercion_and_rejection() -> None: | ||
| 70 | overrides = config_loader.parse_set_overrides( | ||
| 71 | [ | ||
| 72 | "save_debug_ply=true", | ||
| 73 | "max_branch_count=3", | ||
| 74 | "resample_points=1e3", | ||
| 75 | "loop_score_weight=0.5", | ||
| 76 | ] | ||
| 77 | ) | ||
| 78 | config = _config.build_trajectory_filter_config(overrides=overrides) | ||
| 79 | assert config["save_debug_ply"] is True | ||
| 80 | assert config["max_branch_count"] == 3 | ||
| 81 | assert config["resample_points"] == 1000 | ||
| 82 | assert config["loop_score_weight"] == 0.5 | ||
| 83 | |||
| 84 | with pytest.raises(_config.TrajectoryFilterConfigError): | ||
| 85 | _config.normalize_trajectory_filter_config({"save_debug_ply": "flase"}) | ||
| 86 | with pytest.raises(_config.TrajectoryFilterConfigError): | ||
| 87 | _config.normalize_trajectory_filter_config({"max_branch_count": "not-an-int"}) | ||
| 88 | |||
| 89 | |||
| 90 | def test_out_of_range_values_are_rejected() -> None: | ||
| 91 | with pytest.raises(_config.TrajectoryFilterConfigError): | ||
| 92 | _config.normalize_trajectory_filter_config({"loop_score_weight": 1.5}) | ||
| 93 | with pytest.raises(_config.TrajectoryFilterConfigError): | ||
| 94 | _config.normalize_trajectory_filter_config({"max_branch_count": 0}) | ||
| 95 | with pytest.raises(_config.TrajectoryFilterConfigError): | ||
| 96 | _config.normalize_trajectory_filter_config({"fit_segment_length": 0.0}) | ||
| 0 |
| 1 | """Tests for the pydantic config layer of the trajectory filter.""" | ||
| 2 | |||
| 3 | from __future__ import annotations | ||
| 4 | |||
| 5 | import json | ||
| 6 | from pathlib import Path | ||
| 7 | |||
| 8 | import pytest | ||
| 9 | from iolabs.common import config_loader | ||
| 10 | |||
| 11 | from iolabs_point_cloud_trajectory_filter import _config | ||
| 12 | |||
| 13 | _DEFAULT_JSON = ( | ||
| 14 | Path(_config.__file__).resolve().with_name("trajectory_filter.default.json") | ||
| 15 | ) | ||
| 16 | |||
| 17 | |||
| 18 | def test_error_class_is_config_error() -> None: | ||
| 19 | assert issubclass(_config.TrajectoryFilterConfigError, config_loader.ConfigError) | ||
| 20 | assert issubclass(_config.TrajectoryFilterConfigError, ValueError) | ||
| 21 | |||
| 22 | |||
| 23 | def test_packaged_json_matches_model_defaults() -> None: | ||
| 24 | packaged = json.loads(_DEFAULT_JSON.read_text(encoding="utf-8")) | ||
| 25 | assert packaged == _config.TrajectoryFilterConfig().model_dump() | ||
| 26 | assert _config.load_trajectory_filter_config() == packaged | ||
| 27 | |||
| 28 | |||
| 29 | def test_build_merges_set_overrides_and_coerces_strings() -> None: | ||
| 30 | overrides = config_loader.parse_set_overrides( | ||
| 31 | [ | ||
| 32 | "save_debug_ply=true", | ||
| 33 | "max_branch_count=3", | ||
| 34 | "loop_score_weight=0.5", | ||
| 35 | ] | ||
| 36 | ) | ||
| 37 | config = _config.build_trajectory_filter_config(overrides=overrides) | ||
| 38 | assert config["save_debug_ply"] is True | ||
| 39 | assert config["max_branch_count"] == 3 | ||
| 40 | assert config["loop_score_weight"] == 0.5 | ||
| 41 | assert config["manifest_filename"] == "run2_spine_splines.json" | ||
| 42 | |||
| 43 | |||
| 44 | def test_build_rejects_unknown_override_key() -> None: | ||
| 45 | with pytest.raises( | ||
| 46 | _config.TrajectoryFilterConfigError, | ||
| 47 | match="Unknown trajectory-filter config key", | ||
| 48 | ): | ||
| 49 | _config.build_trajectory_filter_config(overrides={"typo_branch_count": 1}) | ||
| 50 | |||
| 51 | |||
| 52 | def test_config_path_replaces_packaged_defaults(tmp_path: Path) -> None: | ||
| 53 | path = tmp_path / "custom.json" | ||
| 54 | path.write_text(json.dumps({"max_branch_count": 7}), encoding="utf-8") | ||
| 55 | config = _config.load_trajectory_filter_config(path) | ||
| 56 | assert config["max_branch_count"] == 7 | ||
| 57 | assert config["save_debug_ply"] is False | ||
| 58 | |||
| 59 | |||
| 60 | def test_bad_values_are_rejected() -> None: | ||
| 61 | with pytest.raises(_config.TrajectoryFilterConfigError): | ||
| 62 | _config.normalize_trajectory_filter_config({"max_branch_count": "not-an-int"}) | ||
| 63 | with pytest.raises(_config.TrajectoryFilterConfigError): | ||
| 64 | _config.normalize_trajectory_filter_config({"save_debug_ply": "flase"}) | ||
| 0 |
| 1 | import pytest | ||
| 2 | |||
| 3 | from iolabs_point_cloud_trajectory_filter import ( | ||
| 4 | TrajectoryFilterConfigError, | ||
| 5 | normalize_trajectory_filter_config, | ||
| 6 | ) | ||
| 7 | |||
| 8 | |||
| 9 | def test_normalize_rejects_unknown_keys(): | ||
| 10 | with pytest.raises( | ||
| 11 | TrajectoryFilterConfigError, match="Unknown trajectory-filter config key" | ||
| 12 | ): | ||
| 13 | normalize_trajectory_filter_config({"random_seed": 42}) | ||
| 14 | |||
| 15 | |||
| 16 | def test_normalize_applies_defaults(): | ||
| 17 | config = normalize_trajectory_filter_config({}) | ||
| 18 | |||
| 19 | assert config["spline_pcd_extension"] == "_run1_spline_points" | ||
| 20 | assert config["fit_segment_length"] == 2.0 | ||
| 21 | assert config["loop_chord_arc_ratio_min"] == 0.85 | ||
| 22 | assert config["loop_pca_span_ratio_min"] == 0.6 | ||
| 23 | assert config["min_coverage_increment_m"] == 50.0 | ||
| 24 | assert config["max_connectivity_gap_m"] == 35.0 | ||
| 25 | assert config["contributed_endpoint_window_m"] == 10.0 | ||
| 26 | assert config["loop_score_weight"] == 0.85 | ||
| 27 | assert config["residual_coverage_distance_m"] == 200.0 | ||
| 28 | assert config["min_residual_fragment_length_m"] == 25.0 | ||
| 29 | assert config["min_residual_point_count"] == 10 | ||
| 30 | assert config["min_branch_length_m"] == 1000.0 | ||
| 31 | assert config["max_branch_count"] == 20 | ||
| 32 | assert config["branch_assignment_distance_m"] == 75.0 | ||
| 33 | assert config["branch_assignment_min_near_fraction"] == 0.10 | ||
| 34 | assert config["branch_assignment_min_near_length_m"] == 50.0 | ||
| 35 | assert config["branch_assignment_min_near_sample_count"] == 4 | ||
| 36 | assert config["branch_assignment_force_spine_stems"] is True | ||
| 37 | assert ( | ||
| 38 | config["branch_assignment_debug_ply_filename_template"] | ||
| 39 | == "run2_branch_{branch_index:03d}_assigned_sources.ply" | ||
| 40 | ) | ||
| 41 | assert config["manifest_filename"] == "run2_spine_splines.json" | ||
| 42 | assert config["save_debug_artifacts"] is False | ||
| 43 | assert config["save_debug_ply"] is False | ||
| 44 | assert config["clipped_debug_ply_filename"] == "run2_spine_splines_clipped.ply" | ||
| 45 | assert config["clipped_debug_interval_margin_m"] == 10.0 | ||
| 46 | assert config["candidate_debug_ply_filename"] == "run2_spine_candidates.ply" | ||
| 47 | assert config["debug_json_filename"] == "run2_spine_debug.json" | ||
| 48 | |||
| 49 | |||
| 50 | def test_normalize_preserves_overrides(): | ||
| 51 | config = normalize_trajectory_filter_config( | ||
| 52 | { | ||
| 53 | "min_coverage_increment_m": 1.0, | ||
| 54 | "max_connectivity_gap_m": 100.0, | ||
| 55 | "contributed_endpoint_window_m": 8.0, | ||
| 56 | "loop_score_weight": 0.75, | ||
| 57 | "residual_coverage_distance_m": 7.0, | ||
| 58 | "min_residual_fragment_length_m": 9.0, | ||
| 59 | "min_residual_point_count": 6, | ||
| 60 | "min_branch_length_m": 11.0, | ||
| 61 | "max_branch_count": 3, | ||
| 62 | "branch_assignment_distance_m": 12.0, | ||
| 63 | "branch_assignment_min_near_fraction": 0.25, | ||
| 64 | "branch_assignment_min_near_length_m": 33.0, | ||
| 65 | "branch_assignment_min_near_sample_count": 8, | ||
| 66 | "branch_assignment_force_spine_stems": False, | ||
| 67 | "branch_assignment_debug_ply_filename_template": "branch_{branch_id}.ply", | ||
| 68 | "save_debug_artifacts": True, | ||
| 69 | "save_debug_ply": True, | ||
| 70 | "clipped_debug_interval_margin_m": 25.0, | ||
| 71 | } | ||
| 72 | ) | ||
| 73 | assert config["min_coverage_increment_m"] == 1.0 | ||
| 74 | assert config["max_connectivity_gap_m"] == 100.0 | ||
| 75 | assert config["contributed_endpoint_window_m"] == 8.0 | ||
| 76 | assert config["loop_score_weight"] == 0.75 | ||
| 77 | assert config["residual_coverage_distance_m"] == 7.0 | ||
| 78 | assert config["min_residual_fragment_length_m"] == 9.0 | ||
| 79 | assert config["min_residual_point_count"] == 6 | ||
| 80 | assert config["min_branch_length_m"] == 11.0 | ||
| 81 | assert config["max_branch_count"] == 3 | ||
| 82 | assert config["branch_assignment_distance_m"] == 12.0 | ||
| 83 | assert config["branch_assignment_min_near_fraction"] == 0.25 | ||
| 84 | assert config["branch_assignment_min_near_length_m"] == 33.0 | ||
| 85 | assert config["branch_assignment_min_near_sample_count"] == 8 | ||
| 86 | assert config["branch_assignment_force_spine_stems"] is False | ||
| 87 | assert config["branch_assignment_debug_ply_filename_template"] == "branch_{branch_id}.ply" | ||
| 88 | assert config["save_debug_artifacts"] is True | ||
| 89 | assert config["save_debug_ply"] is True | ||
| 90 | assert config["clipped_debug_interval_margin_m"] == 25.0 | ||
| 0 |
<Name><Section>Config), module constants, keyword-only entry points, canonical test names, README config section. No behaviour change intended.