Back to report index

Step 2 trajectoryfilter ec51835: AI3D-379 Pydantic config models via iolabs-common ConfigModel

Miroslav Simko <ms@iolabs.ch> 2026-09-02T08:33:47+02:00

Commit #14 ยท 5 snippets

 pyproject.toml                                     |   5 +-
 .../_config.py                                     | 198 +++++++++------------
 tests/test_config_model.py                         |  64 +++++++
 3 files changed, 151 insertions(+), 116 deletions(-)
Importance #1: src/iolabs_point_cloud_trajectory_filter/_config.py @@ -1,137 +1,107 @@
1"""Load, merge and validate trajectory-filter config from packaged JSON.
2
3The schema is `TrajectoryFilterConfig`. To add a key, add a field on that
4model and a matching entry in ``trajectory_filter.default.json``.
5"""
6
1from __future__ import annotations7from __future__ import annotations
28
3import json9import logging
4from importlib import resources
5from pathlib import Path10from pathlib import Path
6from typing import Any11from typing import Any
712
8ALLOWED_TRAJECTORY_FILTER_CONFIG_KEYS = frozenset(13from iolabs.common import config_loader
9 {14
10 "spline_pcd_extension",15logger = logging.getLogger(__name__)
11 "fit_segment_length",16
12 "spline_smoothing",17_PACKAGE = "iolabs_point_cloud_trajectory_filter"
13 "resample_points",18_DEFAULT_FILENAME = "trajectory_filter.default.json"
14 "loop_chord_arc_ratio_min",19_CONTEXT = "trajectory-filter config"
15 "loop_pca_span_ratio_min",20
16 "min_coverage_increment_m",21
17 "max_connectivity_gap_m",22class TrajectoryFilterConfig(config_loader.ConfigModel):
18 "contributed_endpoint_window_m",23 """Validated trajectory-filter settings; field names match the default JSON."""
19 "loop_score_weight",24
20 "residual_coverage_distance_m",25 spline_pcd_extension: str = "_run1_spline_points"
21 "min_residual_fragment_length_m",26 fit_segment_length: float = 2.0
22 "min_residual_point_count",27 spline_smoothing: float = 0.0
23 "min_branch_length_m",28 resample_points: int = 400
24 "max_branch_count",29 loop_chord_arc_ratio_min: float = 0.85
25 "branch_assignment_distance_m",30 loop_pca_span_ratio_min: float = 0.6
26 "branch_assignment_min_near_fraction",31 min_coverage_increment_m: float = 50.0
27 "branch_assignment_min_near_length_m",32 max_connectivity_gap_m: float = 35.0
28 "branch_assignment_min_near_sample_count",33 contributed_endpoint_window_m: float = 10.0
29 "branch_assignment_force_spine_stems",34 loop_score_weight: float = 0.85
30 "branch_assignment_debug_ply_filename_template",35 residual_coverage_distance_m: float = 200.0
31 "manifest_filename",36 min_residual_fragment_length_m: float = 25.0
32 "save_debug_artifacts",37 min_residual_point_count: int = 10
33 "save_debug_ply",38 min_branch_length_m: float = 1000.0
34 "debug_ply_filename",39 max_branch_count: int = 20
35 "clipped_debug_ply_filename",40 branch_assignment_distance_m: float = 75.0
36 "clipped_debug_interval_margin_m",41 branch_assignment_min_near_fraction: float = 0.1
37 "candidate_debug_ply_filename",42 branch_assignment_min_near_length_m: float = 50.0
38 "debug_json_filename",43 branch_assignment_min_near_sample_count: int = 4
39 }44 branch_assignment_force_spine_stems: bool = True
40)45 branch_assignment_debug_ply_filename_template: str = (
4146 "run2_branch_{branch_index:03d}_assigned_sources.ply"
42
43class TrajectoryFilterConfigError(ValueError):
44 """Raised when trajectory-filter config contains unsupported keys."""
45
46
47def _default_config_path() -> Path:
48 if __package__ in {None, ""}:
49 return Path(__file__).resolve().with_name("trajectory_filter.default.json")
50 return Path(
51 str(resources.files(__package__).joinpath("trajectory_filter.default.json"))
52 )47 )
48 manifest_filename: str = "run2_spine_splines.json"
49 save_debug_artifacts: bool = False
50 save_debug_ply: bool = False
51 debug_ply_filename: str = "run2_spine_splines.ply"
52 clipped_debug_ply_filename: str = "run2_spine_splines_clipped.ply"
53 clipped_debug_interval_margin_m: float = 10.0
54 candidate_debug_ply_filename: str = "run2_spine_candidates.ply"
55 debug_json_filename: str = "run2_spine_debug.json"
5356
5457
55def _deep_merge_dicts(58class TrajectoryFilterConfigError(config_loader.ConfigError):
56 base: dict[str, Any],59 """Raised when trajectory-filter config contains unsupported keys."""
57 overrides: dict[str, Any],
58) -> dict[str, Any]:
59 for key, value in overrides.items():
60 if isinstance(value, dict) and isinstance(base.get(key), dict):
61 base[key] = _deep_merge_dicts(dict(base[key]), value)
62 else:
63 base[key] = value
64 return base
65
66
67def _validate_trajectory_filter_config_keys(config: dict[str, Any]) -> None:
68 unknown_keys = sorted(set(config) - ALLOWED_TRAJECTORY_FILTER_CONFIG_KEYS)
69 if not unknown_keys:
70 return
71
72 allowed_keys = ", ".join(sorted(ALLOWED_TRAJECTORY_FILTER_CONFIG_KEYS))
73 raise TrajectoryFilterConfigError(
74 "Unknown trajectory-filter config key(s): "
75 f"{', '.join(unknown_keys)}. "
76 f"Allowed keys: {allowed_keys}"
77 )
7860
7961
80def normalize_trajectory_filter_config(raw_config: dict[str, Any]) -> dict[str, Any]:62def normalize_trajectory_filter_config(raw_config: dict[str, Any]) -> dict[str, Any]:
81 config = dict(raw_config)63 """Fill defaults, reject unknown keys, and return a plain config dict."""
82 _validate_trajectory_filter_config_keys(config)64 return config_loader.validate_config(
83 config.setdefault("spline_pcd_extension", "_run1_spline_points")65 TrajectoryFilterConfig,
84 config.setdefault("fit_segment_length", 2.0)66 raw_config,
85 config.setdefault("spline_smoothing", 0.0)67 context=_CONTEXT,
86 config.setdefault("resample_points", 400)68 error_cls=TrajectoryFilterConfigError,
87 config.setdefault("loop_chord_arc_ratio_min", 0.85)69 ).model_dump()
88 config.setdefault("loop_pca_span_ratio_min", 0.6)
89 config.setdefault("min_coverage_increment_m", 50.0)
90 config.setdefault("max_connectivity_gap_m", 35.0)
91 config.setdefault("contributed_endpoint_window_m", 10.0)
92 config.setdefault("loop_score_weight", 0.85)
93 config.setdefault("residual_coverage_distance_m", 200.0)
94 config.setdefault("min_residual_fragment_length_m", 25.0)
95 config.setdefault("min_residual_point_count", 10)
96 config.setdefault("min_branch_length_m", 1000.0)
97 config.setdefault("max_branch_count", 20)
98 config.setdefault("branch_assignment_distance_m", 75.0)
99 config.setdefault("branch_assignment_min_near_fraction", 0.10)
100 config.setdefault("branch_assignment_min_near_length_m", 50.0)
101 config.setdefault("branch_assignment_min_near_sample_count", 4)
102 config.setdefault("branch_assignment_force_spine_stems", True)
103 config.setdefault(
104 "branch_assignment_debug_ply_filename_template",
105 "run2_branch_{branch_index:03d}_assigned_sources.ply",
106 )
107 config.setdefault("manifest_filename", "run2_spine_splines.json")
108 config.setdefault("save_debug_artifacts", False)
109 config.setdefault("save_debug_ply", False)
110 config.setdefault("debug_ply_filename", "run2_spine_splines.ply")
111 config.setdefault("clipped_debug_ply_filename", "run2_spine_splines_clipped.ply")
112 config.setdefault("clipped_debug_interval_margin_m", 10.0)
113 config.setdefault("candidate_debug_ply_filename", "run2_spine_candidates.ply")
114 config.setdefault("debug_json_filename", "run2_spine_debug.json")
115 return config
11670
11771
118def load_trajectory_filter_config(72def load_trajectory_filter_config(
119 config_path: str | Path | None = None,73 config_path: str | Path | None = None,
120) -> dict[str, Any]:74) -> dict[str, Any]:
121 resolved_path = (75 """Load packaged (or *config_path*) defaults and return a plain config dict."""
122 Path(config_path) if config_path is not None else _default_config_path()76 return _load_model(config_path=config_path).model_dump()
123 )
124 with resolved_path.open("r", encoding="utf-8") as handle:
125 raw_config: dict[str, Any] = json.load(handle)
126 return normalize_trajectory_filter_config(raw_config)
12777
12878
129def build_trajectory_filter_config(79def build_trajectory_filter_config(
130 *,80 *,
131 overrides: dict[str, Any] | None = None,81 overrides: dict[str, Any] | None = None,
132 config_path: str | Path | None = None,82 config_path: str | Path | None = None,
133) -> dict[str, Any]:83) -> dict[str, Any]:
134 config = load_trajectory_filter_config(config_path)84 """Load defaults, deep-merge *overrides*, and return a plain config dict."""
135 if overrides:85 return _load_model(overrides=overrides, config_path=config_path).model_dump()
136 config = _deep_merge_dicts(config, dict(overrides))86
137 return normalize_trajectory_filter_config(config)87
88def _load_model(
89 *,
90 overrides: dict[str, Any] | None = None,
91 config_path: str | Path | None = None,
92) -> TrajectoryFilterConfig:
93 """Load, merge and validate into `TrajectoryFilterConfig`."""
94 logger.debug(
95 "Loading %s from %s",
96 _CONTEXT,
97 config_path or f"{_PACKAGE}:{_DEFAULT_FILENAME}",
98 )
99 return config_loader.load_config(
100 TrajectoryFilterConfig,
101 package=_PACKAGE,
102 filename=_DEFAULT_FILENAME,
103 overrides=overrides,
104 config_path=config_path,
105 context=_CONTEXT,
106 error_cls=TrajectoryFilterConfigError,
107 )
Importance #2: tests/test_config_model.py @@ -0,0 +1,64 @@
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 #3: pyproject.toml @@ -1,16 +1,17 @@
1[project]1[project]
2name = "iolabs-point-cloud-trajectory-filter"2name = "iolabs-point-cloud-trajectory-filter"
3version = "0.2.2"3version = "0.2.3"
4description = "Spine-spline selection from per-LAS trajectory point clouds"4description = "Spine-spline selection from per-LAS trajectory point clouds"
5requires-python = ">=3.11,<3.13"5requires-python = ">=3.11,<3.13"
6dependencies = [6dependencies = [
7 "numpy>=1.20.0",7 "numpy>=1.20.0",
8 "scipy>=1.11.0",8 "scipy>=1.11.0",
9 "open3d>=0.19.0",9 "open3d>=0.19.0",
10 "iolabs-geometry-geometry",10 "iolabs-geometry-geometry",
11 "iolabs-common",11 "iolabs-common>=0.8.0",
12 "iolabs-logstash>=0.4.0",12 "iolabs-logstash>=0.4.0",
13 "pydantic>=2.7",
13]14]
1415
15[project.optional-dependencies]16[project.optional-dependencies]
16dev = [17dev = [
Importance #4: src/iolabs_point_cloud_trajectory_filter/_config.py @@ -1,137 +1,107 @@
1"""Load, merge and validate trajectory-filter config from packaged JSON.
2
3The schema is `TrajectoryFilterConfig`. To add a key, add a field on that
4model and a matching entry in ``trajectory_filter.default.json``.
5"""
6
1from __future__ import annotations7from __future__ import annotations
28
3import json9import logging
4from importlib import resources
5from pathlib import Path10from pathlib import Path
6from typing import Any11from typing import Any
712
8ALLOWED_TRAJECTORY_FILTER_CONFIG_KEYS = frozenset(13from iolabs.common import config_loader
9 {14
10 "spline_pcd_extension",15logger = logging.getLogger(__name__)
11 "fit_segment_length",16
12 "spline_smoothing",17_PACKAGE = "iolabs_point_cloud_trajectory_filter"
13 "resample_points",18_DEFAULT_FILENAME = "trajectory_filter.default.json"
14 "loop_chord_arc_ratio_min",19_CONTEXT = "trajectory-filter config"
15 "loop_pca_span_ratio_min",20
16 "min_coverage_increment_m",21
17 "max_connectivity_gap_m",22class TrajectoryFilterConfig(config_loader.ConfigModel):
18 "contributed_endpoint_window_m",23 """Validated trajectory-filter settings; field names match the default JSON."""
19 "loop_score_weight",24
20 "residual_coverage_distance_m",25 spline_pcd_extension: str = "_run1_spline_points"
21 "min_residual_fragment_length_m",26 fit_segment_length: float = 2.0
22 "min_residual_point_count",27 spline_smoothing: float = 0.0
23 "min_branch_length_m",28 resample_points: int = 400
24 "max_branch_count",29 loop_chord_arc_ratio_min: float = 0.85
25 "branch_assignment_distance_m",30 loop_pca_span_ratio_min: float = 0.6
26 "branch_assignment_min_near_fraction",31 min_coverage_increment_m: float = 50.0
27 "branch_assignment_min_near_length_m",32 max_connectivity_gap_m: float = 35.0
28 "branch_assignment_min_near_sample_count",33 contributed_endpoint_window_m: float = 10.0
29 "branch_assignment_force_spine_stems",34 loop_score_weight: float = 0.85
30 "branch_assignment_debug_ply_filename_template",35 residual_coverage_distance_m: float = 200.0
31 "manifest_filename",36 min_residual_fragment_length_m: float = 25.0
32 "save_debug_artifacts",37 min_residual_point_count: int = 10
33 "save_debug_ply",38 min_branch_length_m: float = 1000.0
34 "debug_ply_filename",39 max_branch_count: int = 20
35 "clipped_debug_ply_filename",40 branch_assignment_distance_m: float = 75.0
36 "clipped_debug_interval_margin_m",41 branch_assignment_min_near_fraction: float = 0.1
37 "candidate_debug_ply_filename",42 branch_assignment_min_near_length_m: float = 50.0
38 "debug_json_filename",43 branch_assignment_min_near_sample_count: int = 4
39 }44 branch_assignment_force_spine_stems: bool = True
40)45 branch_assignment_debug_ply_filename_template: str = (
4146 "run2_branch_{branch_index:03d}_assigned_sources.ply"
42
43class TrajectoryFilterConfigError(ValueError):
44 """Raised when trajectory-filter config contains unsupported keys."""
45
46
47def _default_config_path() -> Path:
48 if __package__ in {None, ""}:
49 return Path(__file__).resolve().with_name("trajectory_filter.default.json")
50 return Path(
51 str(resources.files(__package__).joinpath("trajectory_filter.default.json"))
52 )47 )
48 manifest_filename: str = "run2_spine_splines.json"
49 save_debug_artifacts: bool = False
50 save_debug_ply: bool = False
51 debug_ply_filename: str = "run2_spine_splines.ply"
52 clipped_debug_ply_filename: str = "run2_spine_splines_clipped.ply"
53 clipped_debug_interval_margin_m: float = 10.0
54 candidate_debug_ply_filename: str = "run2_spine_candidates.ply"
55 debug_json_filename: str = "run2_spine_debug.json"
5356
5457
55def _deep_merge_dicts(58class TrajectoryFilterConfigError(config_loader.ConfigError):
56 base: dict[str, Any],59 """Raised when trajectory-filter config contains unsupported keys."""
57 overrides: dict[str, Any],
58) -> dict[str, Any]:
59 for key, value in overrides.items():
60 if isinstance(value, dict) and isinstance(base.get(key), dict):
61 base[key] = _deep_merge_dicts(dict(base[key]), value)
62 else:
63 base[key] = value
64 return base
65
66
67def _validate_trajectory_filter_config_keys(config: dict[str, Any]) -> None:
68 unknown_keys = sorted(set(config) - ALLOWED_TRAJECTORY_FILTER_CONFIG_KEYS)
69 if not unknown_keys:
70 return
71
72 allowed_keys = ", ".join(sorted(ALLOWED_TRAJECTORY_FILTER_CONFIG_KEYS))
73 raise TrajectoryFilterConfigError(
74 "Unknown trajectory-filter config key(s): "
75 f"{', '.join(unknown_keys)}. "
76 f"Allowed keys: {allowed_keys}"
77 )
7860
7961
80def normalize_trajectory_filter_config(raw_config: dict[str, Any]) -> dict[str, Any]:62def normalize_trajectory_filter_config(raw_config: dict[str, Any]) -> dict[str, Any]:
81 config = dict(raw_config)63 """Fill defaults, reject unknown keys, and return a plain config dict."""
82 _validate_trajectory_filter_config_keys(config)64 return config_loader.validate_config(
83 config.setdefault("spline_pcd_extension", "_run1_spline_points")65 TrajectoryFilterConfig,
84 config.setdefault("fit_segment_length", 2.0)66 raw_config,
85 config.setdefault("spline_smoothing", 0.0)67 context=_CONTEXT,
86 config.setdefault("resample_points", 400)68 error_cls=TrajectoryFilterConfigError,
87 config.setdefault("loop_chord_arc_ratio_min", 0.85)69 ).model_dump()
88 config.setdefault("loop_pca_span_ratio_min", 0.6)
89 config.setdefault("min_coverage_increment_m", 50.0)
90 config.setdefault("max_connectivity_gap_m", 35.0)
91 config.setdefault("contributed_endpoint_window_m", 10.0)
92 config.setdefault("loop_score_weight", 0.85)
93 config.setdefault("residual_coverage_distance_m", 200.0)
94 config.setdefault("min_residual_fragment_length_m", 25.0)
95 config.setdefault("min_residual_point_count", 10)
96 config.setdefault("min_branch_length_m", 1000.0)
97 config.setdefault("max_branch_count", 20)
98 config.setdefault("branch_assignment_distance_m", 75.0)
99 config.setdefault("branch_assignment_min_near_fraction", 0.10)
100 config.setdefault("branch_assignment_min_near_length_m", 50.0)
101 config.setdefault("branch_assignment_min_near_sample_count", 4)
102 config.setdefault("branch_assignment_force_spine_stems", True)
103 config.setdefault(
104 "branch_assignment_debug_ply_filename_template",
105 "run2_branch_{branch_index:03d}_assigned_sources.ply",
106 )
107 config.setdefault("manifest_filename", "run2_spine_splines.json")
108 config.setdefault("save_debug_artifacts", False)
109 config.setdefault("save_debug_ply", False)
110 config.setdefault("debug_ply_filename", "run2_spine_splines.ply")
111 config.setdefault("clipped_debug_ply_filename", "run2_spine_splines_clipped.ply")
112 config.setdefault("clipped_debug_interval_margin_m", 10.0)
113 config.setdefault("candidate_debug_ply_filename", "run2_spine_candidates.ply")
114 config.setdefault("debug_json_filename", "run2_spine_debug.json")
115 return config
11670
11771
118def load_trajectory_filter_config(72def load_trajectory_filter_config(
119 config_path: str | Path | None = None,73 config_path: str | Path | None = None,
120) -> dict[str, Any]:74) -> dict[str, Any]:
121 resolved_path = (75 """Load packaged (or *config_path*) defaults and return a plain config dict."""
122 Path(config_path) if config_path is not None else _default_config_path()76 return _load_model(config_path=config_path).model_dump()
123 )
124 with resolved_path.open("r", encoding="utf-8") as handle:
125 raw_config: dict[str, Any] = json.load(handle)
126 return normalize_trajectory_filter_config(raw_config)
12777
12878
129def build_trajectory_filter_config(79def build_trajectory_filter_config(
130 *,80 *,
131 overrides: dict[str, Any] | None = None,81 overrides: dict[str, Any] | None = None,
132 config_path: str | Path | None = None,82 config_path: str | Path | None = None,
133) -> dict[str, Any]:83) -> dict[str, Any]:
134 config = load_trajectory_filter_config(config_path)84 """Load defaults, deep-merge *overrides*, and return a plain config dict."""
135 if overrides:85 return _load_model(overrides=overrides, config_path=config_path).model_dump()
136 config = _deep_merge_dicts(config, dict(overrides))86
137 return normalize_trajectory_filter_config(config)87
88def _load_model(
89 *,
90 overrides: dict[str, Any] | None = None,
91 config_path: str | Path | None = None,
92) -> TrajectoryFilterConfig:
93 """Load, merge and validate into `TrajectoryFilterConfig`."""
94 logger.debug(
95 "Loading %s from %s",
96 _CONTEXT,
97 config_path or f"{_PACKAGE}:{_DEFAULT_FILENAME}",
98 )
99 return config_loader.load_config(
100 TrajectoryFilterConfig,
101 package=_PACKAGE,
102 filename=_DEFAULT_FILENAME,
103 overrides=overrides,
104 config_path=config_path,
105 context=_CONTEXT,
106 error_cls=TrajectoryFilterConfigError,
107 )
Importance #5: tests/test_config_model.py @@ -0,0 +1,64 @@
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