Back to report index

Step 2 trajectoryfilter d3ef109: AI3D-379 Align config module with fleet pattern

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(-)
Importance #1: src/iolabs_point_cloud_trajectory_filter/_config.py @@ -1,47 +1,59 @@
1"""Load, merge and validate trajectory-filter config from packaged JSON.1"""Load, merge and validate the trajectory-filter configuration.
22
3The schema is `TrajectoryFilterConfig`. To add a key, add a field on that3The schema is `TrajectoryFilterConfig` (a `config_loader.ConfigModel`),
4model and a matching entry in ``trajectory_filter.default.json``.4mirroring `trajectory_filter.default.json` key for key; ranges are declared on
5the fields here rather than checked at the point of use.
6
7Adding 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
10The entry points return a plain `dict[str, Any]`, because callers pass
11``--set``-style overrides around as dicts and embed the normalized mapping in
12their run manifests verbatim.
5"""13"""
614
7from __future__ import annotations15from __future__ import annotations
816
9import logging17import logging
18from collections.abc import Mapping
10from pathlib import Path19from pathlib import Path
11from typing import Any20from typing import Any
1221
22import pydantic
13from iolabs.common import config_loader23from iolabs.common import config_loader
1424
15logger = logging.getLogger(__name__)25logger = logging.getLogger(__name__)
1626
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"
2030
2131
22class TrajectoryFilterConfig(config_loader.ConfigModel):32class 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."""
2434
25 spline_pcd_extension: str = "_run1_spline_points"35 spline_pcd_extension: str = "_run1_spline_points"
26 fit_segment_length: float = 2.036 fit_segment_length: float = pydantic.Field(default=2.0, gt=0.0)
27 spline_smoothing: float = 0.037 spline_smoothing: float = pydantic.Field(default=0.0, ge=0.0)
28 resample_points: int = 40038 resample_points: int = pydantic.Field(default=400, ge=2)
29 loop_chord_arc_ratio_min: float = 0.8539 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.640 loop_pca_span_ratio_min: float = pydantic.Field(default=0.6, ge=0.0, le=1.0)
31 min_coverage_increment_m: float = 50.041 min_coverage_increment_m: float = pydantic.Field(default=50.0, ge=0.0)
32 max_connectivity_gap_m: float = 35.042 max_connectivity_gap_m: float = pydantic.Field(default=35.0, ge=0.0)
33 contributed_endpoint_window_m: float = 10.043 contributed_endpoint_window_m: float = pydantic.Field(default=10.0, ge=0.0)
34 loop_score_weight: float = 0.8544 loop_score_weight: float = pydantic.Field(default=0.85, ge=0.0, le=1.0)
35 residual_coverage_distance_m: float = 200.045 residual_coverage_distance_m: float = pydantic.Field(default=200.0, ge=0.0)
36 min_residual_fragment_length_m: float = 25.046 min_residual_fragment_length_m: float = pydantic.Field(default=25.0, ge=0.0)
37 min_residual_point_count: int = 1047 min_residual_point_count: int = pydantic.Field(default=10, ge=0)
38 min_branch_length_m: float = 1000.048 min_branch_length_m: float = pydantic.Field(default=1000.0, ge=0.0)
39 max_branch_count: int = 2049 max_branch_count: int = pydantic.Field(default=20, ge=1)
40 branch_assignment_distance_m: float = 75.050 branch_assignment_distance_m: float = pydantic.Field(default=75.0, gt=0.0)
41 branch_assignment_min_near_fraction: float = 0.151 branch_assignment_min_near_fraction: float = pydantic.Field(
42 branch_assignment_min_near_length_m: float = 50.052 default=0.1, ge=0.0, le=1.0
43 branch_assignment_min_near_sample_count: int = 453 )
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 = True56 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 )
Importance #2: src/iolabs_point_cloud_trajectory_filter/_config.py @@ -71,35 +85,40 @@
7185
72def load_trajectory_filter_config(86def 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()
7794
7895
79def build_trajectory_filter_config(96def 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()
86106
87107
88def _load_model(108def _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,
Importance #3: src/iolabs_point_cloud_trajectory_filter/_config.py @@ -49,18 +61,20 @@
49 save_debug_artifacts: bool = False61 save_debug_artifacts: bool = False
50 save_debug_ply: bool = False62 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.065 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"
5668
5769
58class TrajectoryFilterConfigError(config_loader.ConfigError):70class TrajectoryFilterConfigError(config_loader.ConfigError):
59 """Raised when trajectory-filter config contains unsupported keys."""71 """Raised when trajectory-filter config contains unsupported keys or values."""
6072
6173
62def normalize_trajectory_filter_config(raw_config: dict[str, Any]) -> dict[str, Any]:74def 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,
Importance #4: tests/test_config.py @@ -0,0 +1,96 @@
1"""Tests for the pydantic config layer of the trajectory filter."""
2
3from __future__ import annotations
4
5import json
6from pathlib import Path
7
8import pytest
9from iolabs.common import config_loader
10
11from 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
18def _packaged_defaults() -> dict:
19 return json.loads(_DEFAULT_JSON.read_text(encoding="utf-8"))
20
21
22def test_model_defaults_match_packaged_json() -> None:
23 assert _config.TrajectoryFilterConfig().model_dump() == _packaged_defaults()
24
25
26def test_load_trajectory_filter_config_returns_packaged_defaults() -> None:
27 assert _config.load_trajectory_filter_config() == _packaged_defaults()
28
29
30def test_error_class_is_config_error() -> None:
31 assert issubclass(_config.TrajectoryFilterConfigError, config_loader.ConfigError)
32 assert issubclass(_config.TrajectoryFilterConfigError, ValueError)
33
34
35def 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
45def 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
52def 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
61def 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
69def 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
90def 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
Importance #5: tests/test_config_model.py @@ -1,64 +0,0 @@
1"""Tests for the pydantic config layer of the trajectory filter."""
2
3from __future__ import annotations
4
5import json
6from pathlib import Path
7
8import pytest
9from iolabs.common import config_loader
10
11from 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
18def test_error_class_is_config_error() -> None:
19 assert issubclass(_config.TrajectoryFilterConfigError, config_loader.ConfigError)
20 assert issubclass(_config.TrajectoryFilterConfigError, ValueError)
21
22
23def 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
29def 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
44def 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
52def 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
60def 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
Importance #6: tests/test_config_validation.py @@ -1,90 +0,0 @@
1import pytest
2
3from iolabs_point_cloud_trajectory_filter import (
4 TrajectoryFilterConfigError,
5 normalize_trajectory_filter_config,
6)
7
8
9def 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
16def 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
50def 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
Importance #7: README.md @@ -0,0 +1,31 @@
1# iolabs-point-cloud-trajectory-filter
2
3Spine-spline selection from per-LAS trajectory point clouds (LaneFinder pipeline
4step 2). `SpineSplineSelector` fits arc-length splines to the run-1 trajectory
5clouds, scores loop/branch candidates, and writes the spine-spline manifest plus
6optional 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
17uv run pytest tests/
18```
19
20## Configuration
21
22Defaults live in
23`src/iolabs_point_cloud_trajectory_filter/trajectory_filter.default.json`. The
24schema is `TrajectoryFilterConfig` in
25`iolabs_point_cloud_trajectory_filter._config` (a `config_loader.ConfigModel`);
26nested JSON sections are nested models and unknown keys are rejected. **To add a
27config key: add the field (with its type, default and any `Field` range) to the
28model 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
31from repeatable `--set KEY=VALUE`, never repo-local JSON.
0
Importance #8: src/iolabs_point_cloud_trajectory_filter/_config.py @@ -1,47 +1,59 @@
1"""Load, merge and validate trajectory-filter config from packaged JSON.1"""Load, merge and validate the trajectory-filter configuration.
22
3The schema is `TrajectoryFilterConfig`. To add a key, add a field on that3The schema is `TrajectoryFilterConfig` (a `config_loader.ConfigModel`),
4model and a matching entry in ``trajectory_filter.default.json``.4mirroring `trajectory_filter.default.json` key for key; ranges are declared on
5the fields here rather than checked at the point of use.
6
7Adding 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
10The entry points return a plain `dict[str, Any]`, because callers pass
11``--set``-style overrides around as dicts and embed the normalized mapping in
12their run manifests verbatim.
5"""13"""
614
7from __future__ import annotations15from __future__ import annotations
816
9import logging17import logging
18from collections.abc import Mapping
10from pathlib import Path19from pathlib import Path
11from typing import Any20from typing import Any
1221
22import pydantic
13from iolabs.common import config_loader23from iolabs.common import config_loader
1424
15logger = logging.getLogger(__name__)25logger = logging.getLogger(__name__)
1626
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"
2030
2131
22class TrajectoryFilterConfig(config_loader.ConfigModel):32class 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."""
2434
25 spline_pcd_extension: str = "_run1_spline_points"35 spline_pcd_extension: str = "_run1_spline_points"
26 fit_segment_length: float = 2.036 fit_segment_length: float = pydantic.Field(default=2.0, gt=0.0)
27 spline_smoothing: float = 0.037 spline_smoothing: float = pydantic.Field(default=0.0, ge=0.0)
28 resample_points: int = 40038 resample_points: int = pydantic.Field(default=400, ge=2)
29 loop_chord_arc_ratio_min: float = 0.8539 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.640 loop_pca_span_ratio_min: float = pydantic.Field(default=0.6, ge=0.0, le=1.0)
31 min_coverage_increment_m: float = 50.041 min_coverage_increment_m: float = pydantic.Field(default=50.0, ge=0.0)
32 max_connectivity_gap_m: float = 35.042 max_connectivity_gap_m: float = pydantic.Field(default=35.0, ge=0.0)
33 contributed_endpoint_window_m: float = 10.043 contributed_endpoint_window_m: float = pydantic.Field(default=10.0, ge=0.0)
34 loop_score_weight: float = 0.8544 loop_score_weight: float = pydantic.Field(default=0.85, ge=0.0, le=1.0)
35 residual_coverage_distance_m: float = 200.045 residual_coverage_distance_m: float = pydantic.Field(default=200.0, ge=0.0)
36 min_residual_fragment_length_m: float = 25.046 min_residual_fragment_length_m: float = pydantic.Field(default=25.0, ge=0.0)
37 min_residual_point_count: int = 1047 min_residual_point_count: int = pydantic.Field(default=10, ge=0)
38 min_branch_length_m: float = 1000.048 min_branch_length_m: float = pydantic.Field(default=1000.0, ge=0.0)
39 max_branch_count: int = 2049 max_branch_count: int = pydantic.Field(default=20, ge=1)
40 branch_assignment_distance_m: float = 75.050 branch_assignment_distance_m: float = pydantic.Field(default=75.0, gt=0.0)
41 branch_assignment_min_near_fraction: float = 0.151 branch_assignment_min_near_fraction: float = pydantic.Field(
42 branch_assignment_min_near_length_m: float = 50.052 default=0.1, ge=0.0, le=1.0
43 branch_assignment_min_near_sample_count: int = 453 )
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 = True56 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 )
Importance #9: src/iolabs_point_cloud_trajectory_filter/_config.py @@ -49,18 +61,20 @@
49 save_debug_artifacts: bool = False61 save_debug_artifacts: bool = False
50 save_debug_ply: bool = False62 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.065 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"
5668
5769
58class TrajectoryFilterConfigError(config_loader.ConfigError):70class TrajectoryFilterConfigError(config_loader.ConfigError):
59 """Raised when trajectory-filter config contains unsupported keys."""71 """Raised when trajectory-filter config contains unsupported keys or values."""
6072
6173
62def normalize_trajectory_filter_config(raw_config: dict[str, Any]) -> dict[str, Any]:74def 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,
Importance #10: src/iolabs_point_cloud_trajectory_filter/_config.py @@ -71,35 +85,40 @@
7185
72def load_trajectory_filter_config(86def 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()
7794
7895
79def build_trajectory_filter_config(96def 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()
86106
87107
88def _load_model(108def _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,
Importance #11: tests/test_config.py @@ -0,0 +1,96 @@
1"""Tests for the pydantic config layer of the trajectory filter."""
2
3from __future__ import annotations
4
5import json
6from pathlib import Path
7
8import pytest
9from iolabs.common import config_loader
10
11from 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
18def _packaged_defaults() -> dict:
19 return json.loads(_DEFAULT_JSON.read_text(encoding="utf-8"))
20
21
22def test_model_defaults_match_packaged_json() -> None:
23 assert _config.TrajectoryFilterConfig().model_dump() == _packaged_defaults()
24
25
26def test_load_trajectory_filter_config_returns_packaged_defaults() -> None:
27 assert _config.load_trajectory_filter_config() == _packaged_defaults()
28
29
30def test_error_class_is_config_error() -> None:
31 assert issubclass(_config.TrajectoryFilterConfigError, config_loader.ConfigError)
32 assert issubclass(_config.TrajectoryFilterConfigError, ValueError)
33
34
35def 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
45def 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
52def 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
61def 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
69def 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
90def 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
Importance #12: tests/test_config_model.py @@ -1,64 +0,0 @@
1"""Tests for the pydantic config layer of the trajectory filter."""
2
3from __future__ import annotations
4
5import json
6from pathlib import Path
7
8import pytest
9from iolabs.common import config_loader
10
11from 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
18def test_error_class_is_config_error() -> None:
19 assert issubclass(_config.TrajectoryFilterConfigError, config_loader.ConfigError)
20 assert issubclass(_config.TrajectoryFilterConfigError, ValueError)
21
22
23def 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
29def 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
44def 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
52def 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
60def 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
Importance #13: tests/test_config_validation.py @@ -1,90 +0,0 @@
1import pytest
2
3from iolabs_point_cloud_trajectory_filter import (
4 TrajectoryFilterConfigError,
5 normalize_trajectory_filter_config,
6)
7
8
9def 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
16def 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
50def 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