Miroslav Simko <ms@iolabs.ch> 2026-09-02T09:37:55+02:00
Commit #19 · 31 snippets
README.md | 12 ++++ docs/configuration.md | 12 ++-- .../_config.py | 70 +++++++++++++--------- tests/test_config.py | 58 +++++++++++++----- 4 files changed, 104 insertions(+), 48 deletions(-)
| 26 | longitudinal_limit_planes_filename: str = "run3_longitudinal_limit_planes.npz" | 39 | longitudinal_limit_planes_filename: str = "run3_longitudinal_limit_planes.npz" |
| 27 | 40 | ||
| 28 | 41 | ||
| 29 | class SegmentMapperVisualizationColorsConfig(config_loader.ConfigModel): | 42 | class SegmentMapperVisualizationColorsConfig(config_loader.ConfigModel): |
| 30 | """RGB visualization colors; each value is a list of three numbers.""" | 43 | """RGB visualization colors; each value is a triple of numbers.""" |
| 31 | 44 | ||
| 32 | angle_limit_rejected: list[float] = pydantic.Field( | 45 | angle_limit_rejected: tuple[float, float, float] = (0.45, 0.45, 0.45) |
| 33 | default=[0.45, 0.45, 0.45], min_length=3, max_length=3 | 46 | segmentation_plane: tuple[float, float, float] = (0.1, 0.35, 1.0) |
| 34 | ) | 47 | longitudinal_left_plane: tuple[float, float, float] = (1.0, 0.25, 0.0) |
| 35 | segmentation_plane: list[float] = pydantic.Field( | 48 | longitudinal_right_plane: tuple[float, float, float] = (1.0, 0.55, 0.0) |
| 36 | default=[0.1, 0.35, 1.0], min_length=3, max_length=3 | ||
| 37 | ) | ||
| 38 | longitudinal_left_plane: list[float] = pydantic.Field( | ||
| 39 | default=[1.0, 0.25, 0.0], min_length=3, max_length=3 | ||
| 40 | ) | ||
| 41 | longitudinal_right_plane: list[float] = pydantic.Field( | ||
| 42 | default=[1.0, 0.55, 0.0], min_length=3, max_length=3 | ||
| 43 | ) | ||
| 44 | 49 | ||
| 45 | 50 | ||
| 46 | class SegmentMapperConfig(config_loader.ConfigModel): | 51 | class SegmentMapperConfig(config_loader.ConfigModel): |
| 47 | """Segment mapper config; field names and nesting match the packaged JSON.""" | 52 | """Segment mapper config; field names and nesting match the packaged JSON.""" |
| 83 | @classmethod | 88 | @classmethod |
| 84 | def _none_write_only_segments_is_empty(cls, value: Any) -> Any: | 89 | def _none_write_only_segments_is_empty(cls, value: Any) -> Any: |
| 85 | """Treat a JSON ``null`` as 'no restriction', as the pre-pydantic code did.""" | 90 | """Treat a JSON ``null`` as 'no restriction', as the pre-pydantic code did.""" |
| 86 | if value is None: | 91 | if value is None: |
| 87 | return [] | 92 | return () |
| 88 | return value | 93 | return value |
| 89 | 94 | ||
| 90 | 95 | ||
| 91 | def _load_segment_mapper_model( | 96 | def _load_model( |
| 92 | *, | 97 | *, |
| 93 | overrides: dict[str, Any] | None = None, | 98 | overrides: Mapping[str, Any] | None = None, |
| 94 | config_path: str | Path | None = None, | 99 | config_path: str | Path | None = None, |
| 95 | ) -> SegmentMapperConfig: | 100 | ) -> SegmentMapperConfig: |
| 101 | """Load the packaged defaults (or *config_path*) with *overrides* merged on top.""" | ||
| 102 | if overrides: | ||
| 103 | logger.info("Config overrides applied: %s", ", ".join(sorted(overrides))) | ||
| 104 | if config_path is not None: | ||
| 105 | logger.info("Config file applied: %s", config_path) | ||
| 96 | return config_loader.load_config( | 106 | return config_loader.load_config( |
| 97 | SegmentMapperConfig, | 107 | SegmentMapperConfig, |
| 98 | package=_PACKAGE, | 108 | package=_PACKAGE_NAME, |
| 99 | filename=_DEFAULT_FILENAME, | 109 | filename=_DEFAULT_FILENAME, |
| 100 | overrides=overrides, | 110 | overrides=overrides, |
| 101 | config_path=config_path, | 111 | config_path=config_path, |
| 102 | context=_CONTEXT, | 112 | context=_CONTEXT, |
| 103 | error_cls=SegmentMapperConfigError, | 113 | error_cls=SegmentMapperConfigError, |
| 104 | ) | 114 | ) |
| 105 | 115 | ||
| 106 | 116 | ||
| 107 | def normalize_segment_mapper_config(raw_config: dict[str, Any]) -> dict[str, Any]: | 117 | def normalize_segment_mapper_config(raw_config: Mapping[str, Any]) -> dict[str, Any]: |
| 108 | """Merge *raw_config* onto the packaged defaults and return the validated dict.""" | 118 | """Validate *raw_config*, filling every unset key with its model default.""" |
| 109 | return _load_segment_mapper_model(overrides=dict(raw_config)).model_dump() | 119 | return config_loader.validate_config( |
| 120 | SegmentMapperConfig, | ||
| 121 | raw_config, | ||
| 122 | context=_CONTEXT, | ||
| 123 | error_cls=SegmentMapperConfigError, | ||
| 124 | ).model_dump() | ||
| 110 | 125 | ||
| 111 | 126 | ||
| 112 | def load_segment_mapper_config(config_path: str | Path | None = None) -> dict[str, Any]: | 127 | def load_segment_mapper_config(config_path: str | Path | None = None) -> dict[str, Any]: |
| 113 | """Return the validated config from *config_path*, or the packaged defaults.""" | 128 | """Return the validated config from *config_path*, or the packaged defaults.""" |
| 114 | return _load_segment_mapper_model(config_path=config_path).model_dump() | 129 | return _load_model(config_path=config_path).model_dump() |
| 115 | 130 | ||
| 116 | 131 | ||
| 117 | def build_segment_mapper_config( | 132 | def build_segment_mapper_config( |
| 118 | *, | 133 | *, |
| 119 | overrides: dict[str, Any] | None = None, | 134 | overrides: Mapping[str, Any] | None = None, |
| 120 | config_path: str | Path | None = None, | 135 | config_path: str | Path | None = None, |
| 121 | ) -> dict[str, Any]: | 136 | ) -> dict[str, Any]: |
| 122 | """Return the validated config with *overrides* merged onto the defaults.""" | 137 | """Return the validated config with *overrides* merged onto the defaults.""" |
| 123 | return _load_segment_mapper_model( | 138 | return _load_model(overrides=overrides, config_path=config_path).model_dump() |
| 124 | overrides=overrides, | ||
| 125 | config_path=config_path, | ||
| 126 | ).model_dump() |
| 1 | """Segment mapper config: pydantic model tree over the packaged default JSON.""" | 1 | """Configuration for the trajectory-based segment mapper (pipeline step 3). |
| 2 | |||
| 3 | The schema is `SegmentMapperConfig` (a `config_loader.ConfigModel`), mirroring | ||
| 4 | `segment_mapper.default.json` key for key. | ||
| 5 | |||
| 6 | Adding a config key means adding the field to the model and the same key to | ||
| 7 | `segment_mapper.default.json` — nothing else. Unknown keys are rejected. | ||
| 8 | |||
| 9 | The entry points return a plain `dict[str, Any]` (the validated `model_dump()`). | ||
| 10 | """ | ||
| 2 | 11 | ||
| 3 | from __future__ import annotations | 12 | from __future__ import annotations |
| 4 | 13 | ||
| 14 | import logging | ||
| 15 | from collections.abc import Mapping | ||
| 5 | from pathlib import Path | 16 | from pathlib import Path |
| 6 | from typing import Any | 17 | from typing import Any |
| 7 | 18 | ||
| 8 | import pydantic | 19 | import pydantic |
| 9 | from iolabs.common import config_loader | 20 | from iolabs.common import config_loader |
| 10 | 21 | ||
| 11 | _PACKAGE = "iolabs_point_cloud_segmentation_trajectory" | 22 | logger = logging.getLogger(__name__) |
| 23 | |||
| 24 | _PACKAGE_NAME = "iolabs_point_cloud_segmentation_trajectory" | ||
| 12 | _DEFAULT_FILENAME = "segment_mapper.default.json" | 25 | _DEFAULT_FILENAME = "segment_mapper.default.json" |
| 13 | _CONTEXT = "segment mapper config" | 26 | _CONTEXT = "segment mapper config" |
| 14 | 27 | ||
| 15 | 28 |
| 63 | reuse_existing_planes: bool = False | 68 | reuse_existing_planes: bool = False |
| 64 | reuse_existing_geoshift: bool = False | 69 | reuse_existing_geoshift: bool = False |
| 65 | enable_longitudinal_limit_planes: bool = True | 70 | enable_longitudinal_limit_planes: bool = True |
| 66 | longitudinal_limit_distance_m: float = pydantic.Field(default=100.0, gt=0) | 71 | longitudinal_limit_distance_m: float = pydantic.Field(default=100.0, gt=0) |
| 67 | write_only_segments: list[int] = [] | 72 | write_only_segments: tuple[int, ...] = () |
| 68 | save_longitudinal_limit_planes: bool = True | 73 | save_longitudinal_limit_planes: bool = True |
| 69 | visualization_colors: SegmentMapperVisualizationColorsConfig = ( | 74 | visualization_colors: SegmentMapperVisualizationColorsConfig = ( |
| 70 | SegmentMapperVisualizationColorsConfig() | 75 | SegmentMapperVisualizationColorsConfig() |
| 71 | ) | 76 | ) |
| 5 | import json | 5 | import json |
| 6 | from pathlib import Path | 6 | from pathlib import Path |
| 7 | 7 | ||
| 8 | import pytest | 8 | import pytest |
| 9 | from iolabs.common import config_loader | ||
| 9 | 10 | ||
| 10 | from iolabs_point_cloud_segmentation_trajectory import _config | 11 | from iolabs_point_cloud_segmentation_trajectory import _config |
| 11 | from iolabs_point_cloud_segmentation_trajectory._config import ( | 12 | from iolabs_point_cloud_segmentation_trajectory._config import ( |
| 12 | SegmentMapperConfig, | 13 | SegmentMapperConfig, |
| 37 | "reuse_existing_planes": False, | 38 | "reuse_existing_planes": False, |
| 38 | "reuse_existing_geoshift": False, | 39 | "reuse_existing_geoshift": False, |
| 39 | "enable_longitudinal_limit_planes": True, | 40 | "enable_longitudinal_limit_planes": True, |
| 40 | "longitudinal_limit_distance_m": 100.0, | 41 | "longitudinal_limit_distance_m": 100.0, |
| 41 | "write_only_segments": [], | 42 | "write_only_segments": (), |
| 42 | "save_longitudinal_limit_planes": True, | 43 | "save_longitudinal_limit_planes": True, |
| 43 | "visualization_colors": { | 44 | "visualization_colors": { |
| 44 | "angle_limit_rejected": [0.45, 0.45, 0.45], | 45 | "angle_limit_rejected": (0.45, 0.45, 0.45), |
| 45 | "segmentation_plane": [0.1, 0.35, 1.0], | 46 | "segmentation_plane": (0.1, 0.35, 1.0), |
| 46 | "longitudinal_left_plane": [1.0, 0.25, 0.0], | 47 | "longitudinal_left_plane": (1.0, 0.25, 0.0), |
| 47 | "longitudinal_right_plane": [1.0, 0.55, 0.0], | 48 | "longitudinal_right_plane": (1.0, 0.55, 0.0), |
| 48 | }, | 49 | }, |
| 49 | } | 50 | } |
| 50 | 51 | ||
| 51 | EXPECTED_FILE_NAMING_DEFAULTS: dict[str, str] = { | 52 | EXPECTED_FILE_NAMING_DEFAULTS: dict[str, str] = { |
| 83 | SegmentMapperVisualizationColorsConfig.model_fields | 84 | SegmentMapperVisualizationColorsConfig.model_fields |
| 84 | ) | 85 | ) |
| 85 | 86 | ||
| 86 | 87 | ||
| 87 | def test_packaged_json_matches_model_defaults() -> None: | 88 | def test_model_defaults_match_packaged_json() -> None: |
| 88 | """The packaged JSON must stay in sync with the model defaults, key and value.""" | 89 | """The packaged JSON must stay in sync with the model defaults, key and value.""" |
| 89 | packaged = json.loads( | 90 | packaged = json.loads( |
| 90 | ( | 91 | ( |
| 91 | Path(_config.__file__).with_name("segment_mapper.default.json") | 92 | Path(_config.__file__).with_name("segment_mapper.default.json") |
| 92 | ).read_text(encoding="utf-8") | 93 | ).read_text(encoding="utf-8") |
| 93 | ) | 94 | ) |
| 94 | assert packaged == SegmentMapperConfig().model_dump() | 95 | assert packaged == SegmentMapperConfig().model_dump(mode="json") |
| 95 | 96 | ||
| 96 | 97 | ||
| 97 | def test_normalize_is_idempotent() -> None: | 98 | def test_normalize_is_idempotent() -> None: |
| 98 | once = normalize_segment_mapper_config({}) | 99 | once = normalize_segment_mapper_config({}) |
| 111 | # Whitelist validation | 112 | # Whitelist validation |
| 112 | # --------------------------------------------------------------------------- | 113 | # --------------------------------------------------------------------------- |
| 113 | 114 | ||
| 114 | 115 | ||
| 115 | def test_unknown_top_level_key_raises() -> None: | 116 | def test_unknown_top_level_key_is_rejected() -> None: |
| 116 | with pytest.raises(SegmentMapperConfigError) as excinfo: | 117 | with pytest.raises(SegmentMapperConfigError) as excinfo: |
| 117 | normalize_segment_mapper_config({"bogus_key": 1}) | 118 | normalize_segment_mapper_config({"bogus_key": 1}) |
| 118 | message = str(excinfo.value) | 119 | message = str(excinfo.value) |
| 119 | assert "bogus_key" in message | 120 | assert "bogus_key" in message |
| 128 | assert "aaa_unknown" in message | 129 | assert "aaa_unknown" in message |
| 129 | assert "zzz_unknown" in message | 130 | assert "zzz_unknown" in message |
| 130 | 131 | ||
| 131 | 132 | ||
| 132 | def test_unknown_file_naming_key_raises() -> None: | 133 | def test_unknown_nested_key_is_rejected() -> None: |
| 133 | with pytest.raises(SegmentMapperConfigError) as excinfo: | 134 | with pytest.raises(SegmentMapperConfigError) as excinfo: |
| 134 | normalize_segment_mapper_config({"file_naming": {"bogus_fn": "x"}}) | 135 | normalize_segment_mapper_config({"file_naming": {"bogus_fn": "x"}}) |
| 135 | assert "bogus_fn" in str(excinfo.value) | 136 | assert "bogus_fn" in str(excinfo.value) |
| 136 | 137 |
| 174 | 175 | ||
| 175 | def test_write_only_segments_none_means_no_restriction() -> None: | 176 | def test_write_only_segments_none_means_no_restriction() -> None: |
| 176 | """A JSON ``null`` keeps the pre-pydantic 'write every segment' behaviour.""" | 177 | """A JSON ``null`` keeps the pre-pydantic 'write every segment' behaviour.""" |
| 177 | config = normalize_segment_mapper_config({"write_only_segments": None}) | 178 | config = normalize_segment_mapper_config({"write_only_segments": None}) |
| 178 | assert config["write_only_segments"] == [] | 179 | assert config["write_only_segments"] == () |
| 179 | 180 | ||
| 180 | 181 | ||
| 181 | def test_write_only_segments_accepts_int_list() -> None: | 182 | def test_write_only_segments_accepts_int_list() -> None: |
| 182 | config = normalize_segment_mapper_config({"write_only_segments": [3, 7]}) | 183 | config = normalize_segment_mapper_config({"write_only_segments": [3, 7]}) |
| 183 | assert config["write_only_segments"] == [3, 7] | 184 | assert config["write_only_segments"] == (3, 7) |
| 184 | 185 | ||
| 185 | 186 | ||
| 186 | def test_las_points_per_chunk_must_be_positive() -> None: | 187 | def test_las_points_per_chunk_must_be_positive() -> None: |
| 187 | for bad in (0, -1): | 188 | for bad in (0, -1): |
| 225 | def test_partial_visualization_color_override_keeps_other_defaults() -> None: | 226 | def test_partial_visualization_color_override_keeps_other_defaults() -> None: |
| 226 | config = normalize_segment_mapper_config( | 227 | config = normalize_segment_mapper_config( |
| 227 | {"visualization_colors": {"angle_limit_rejected": [0.5, 0.5, 0.5]}} | 228 | {"visualization_colors": {"angle_limit_rejected": [0.5, 0.5, 0.5]}} |
| 228 | ) | 229 | ) |
| 229 | assert config["visualization_colors"]["angle_limit_rejected"] == [0.5, 0.5, 0.5] | 230 | assert config["visualization_colors"]["angle_limit_rejected"] == (0.5, 0.5, 0.5) |
| 230 | assert config["visualization_colors"]["segmentation_plane"] == [0.1, 0.35, 1.0] | 231 | assert config["visualization_colors"]["segmentation_plane"] == (0.1, 0.35, 1.0) |
| 231 | 232 | ||
| 232 | 233 | ||
| 233 | # --------------------------------------------------------------------------- | 234 | # --------------------------------------------------------------------------- |
| 234 | # load_segment_mapper_config / build_segment_mapper_config | 235 | # load_segment_mapper_config / build_segment_mapper_config |
| 235 | # --------------------------------------------------------------------------- | 236 | # --------------------------------------------------------------------------- |
| 236 | 237 | ||
| 237 | 238 | ||
| 238 | def test_load_default_bundled_config() -> None: | 239 | def test_load_segment_mapper_config_returns_packaged_defaults() -> None: |
| 239 | """The bundled default JSON must produce the same defaults as normalizing `{}`.""" | 240 | """The bundled default JSON must produce the same defaults as normalizing `{}`.""" |
| 240 | from_disk = load_segment_mapper_config() | 241 | from_disk = load_segment_mapper_config() |
| 241 | from_empty = normalize_segment_mapper_config({}) | 242 | from_empty = normalize_segment_mapper_config({}) |
| 242 | assert from_disk == from_empty | 243 | assert from_disk == from_empty |
| 272 | with pytest.raises(SegmentMapperConfigError): | 273 | with pytest.raises(SegmentMapperConfigError): |
| 273 | load_segment_mapper_config(cfg_file) | 274 | load_segment_mapper_config(cfg_file) |
| 274 | 275 | ||
| 275 | 276 | ||
| 276 | def test_build_deep_merges_overrides_over_default_json() -> None: | 277 | def test_overrides_deep_merge_onto_defaults() -> None: |
| 277 | config = build_segment_mapper_config( | 278 | config = build_segment_mapper_config( |
| 278 | overrides={ | 279 | overrides={ |
| 279 | "n_segments": 42, | 280 | "n_segments": 42, |
| 280 | "file_naming": {"planes_filename": "custom.npz"}, | 281 | "file_naming": {"planes_filename": "custom.npz"}, |
| 327 | with pytest.raises(SegmentMapperConfigError): | 328 | with pytest.raises(SegmentMapperConfigError): |
| 328 | build_segment_mapper_config( | 329 | build_segment_mapper_config( |
| 329 | overrides={"file_naming": {"not_allowed_fn": "x"}} | 330 | overrides={"file_naming": {"not_allowed_fn": "x"}} |
| 330 | ) | 331 | ) |
| 332 | |||
| 333 | |||
| 334 | # --------------------------------------------------------------------------- | ||
| 335 | # Error class / --set overrides | ||
| 336 | # --------------------------------------------------------------------------- | ||
| 337 | |||
| 338 | |||
| 339 | def test_error_class_is_config_error() -> None: | ||
| 340 | assert issubclass(SegmentMapperConfigError, config_loader.ConfigError) | ||
| 341 | assert issubclass(SegmentMapperConfigError, ValueError) | ||
| 342 | |||
| 343 | |||
| 344 | def test_set_override_coercion_and_rejection() -> None: | ||
| 345 | overrides = config_loader.parse_set_overrides( | ||
| 346 | ["las_points_per_chunk=1e3", "save_planes=on"], | ||
| 347 | error_cls=SegmentMapperConfigError, | ||
| 348 | ) | ||
| 349 | config = build_segment_mapper_config(overrides=overrides) | ||
| 350 | assert config["las_points_per_chunk"] == 1000 | ||
| 351 | assert config["save_planes"] is True | ||
| 352 | |||
| 353 | with pytest.raises(SegmentMapperConfigError): | ||
| 354 | build_segment_mapper_config( | ||
| 355 | overrides=config_loader.parse_set_overrides( | ||
| 356 | ["save_planes=flase"], error_cls=SegmentMapperConfigError | ||
| 357 | ) | ||
| 358 | ) |
| 24 | Maps segments along trajectories for highway LIDAR scans, building on trajectory detection and intensity filtering. | 24 | Maps segments along trajectories for highway LIDAR scans, building on trajectory detection and intensity filtering. |
| 25 | 25 | ||
| 26 | For the full configuration reference, see [docs/configuration.md](docs/configuration.md). | 26 | For the full configuration reference, see [docs/configuration.md](docs/configuration.md). |
| 27 | 27 | ||
| 28 | ## Configuration | ||
| 29 | |||
| 30 | Defaults live in `src/iolabs_point_cloud_segmentation_trajectory/segment_mapper.default.json`. | ||
| 31 | The schema is `SegmentMapperConfig` in `iolabs_point_cloud_segmentation_trajectory._config` | ||
| 32 | (a `config_loader.ConfigModel`); nested JSON sections (`visualization_colors`, | ||
| 33 | `file_naming`) are nested models and unknown keys are rejected. **To add a config | ||
| 34 | key: add the field (with its type, default and any `Field` range) to the model and | ||
| 35 | the same key with the same default to the JSON — nothing else.** | ||
| 36 | `normalize_segment_mapper_config`, `load_segment_mapper_config` and | ||
| 37 | `build_segment_mapper_config` return a plain `dict`. Runtime overrides come from | ||
| 38 | repeatable `--set KEY=VALUE`, never repo-local JSON. | ||
| 39 | |||
| 28 | ### Segment count configuration | 40 | ### Segment count configuration |
| 29 | 41 | ||
| 30 | The configured `n_segments` value is not a hard cap. By default, segmentation is | 42 | The configured `n_segments` value is not a hard cap. By default, segmentation is |
| 31 | length-based because `segment_length_m` is set in the bundled config. In that | 43 | length-based because `segment_length_m` is set in the bundled config. In that |
| 3 | `SegmentMapper` accepts a strict JSON-compatible configuration mapping. Unknown | 3 | `SegmentMapper` accepts a strict JSON-compatible configuration mapping. Unknown |
| 4 | top-level keys, unknown `file_naming` keys, and unknown `visualization_colors` | 4 | top-level keys, unknown `file_naming` keys, and unknown `visualization_colors` |
| 5 | keys raise `SegmentMapperConfigError`. | 5 | keys raise `SegmentMapperConfigError`. |
| 6 | 6 | ||
| 7 | Defaults are loaded from | 7 | The schema is `SegmentMapperConfig` in |
| 8 | `src/iolabs_point_cloud_segmentation_trajectory/segment_mapper.default.json`. | 8 | `iolabs_point_cloud_segmentation_trajectory._config`, mirroring |
| 9 | Partial nested overrides are deep-merged with the bundled defaults. A config | 9 | `src/iolabs_point_cloud_segmentation_trajectory/segment_mapper.default.json` |
| 10 | key for key. Keys a caller omits fall back to the model defaults, which are | ||
| 11 | identical to the bundled JSON, so partial nested overrides keep the remaining | ||
| 12 | defaults. Sequence values (`write_only_segments`, the RGB colors) come back as | ||
| 13 | tuples in the validated dict. A config | ||
| 10 | file passed explicitly by path replaces the bundled JSON rather than extending | 14 | file passed explicitly by path replaces the bundled JSON rather than extending |
| 11 | it; keys it omits fall back to the same values the bundled JSON carries. | 15 | it; keys it omits fall back to the same values the bundled JSON carries. |
| 12 | 16 | ||
| 13 | ## Example | 17 | ## Example |
| 55 | | `enable_longitudinal_limit_planes` | `true` | Builds side limit planes for each segment and drops saved points outside the left/right corridor. This affects LAS splitting and the LAS coloring visualization. | | 59 | | `enable_longitudinal_limit_planes` | `true` | Builds side limit planes for each segment and drops saved points outside the left/right corridor. This affects LAS splitting and the LAS coloring visualization. | |
| 56 | | `longitudinal_limit_distance_m` | `100.0` | Left/right offset distance, in metres, used to build longitudinal limit planes. Must be greater than zero. | | 60 | | `longitudinal_limit_distance_m` | `100.0` | Left/right offset distance, in metres, used to build longitudinal limit planes. Must be greater than zero. | |
| 57 | | `write_only_segments` | `[]` | Restricts LAS splitting to the listed segment indices; an empty list (or `null`) writes every segment. Useful for re-running a few failed segments without redoing the whole split. | | 61 | | `write_only_segments` | `[]` | Restricts LAS splitting to the listed segment indices; an empty list (or `null`) writes every segment. Useful for re-running a few failed segments without redoing the whole split. | |
| 58 | | `save_longitudinal_limit_planes` | `true` | Writes longitudinal limit planes to `<output_dir>/lane_points/<longitudinal_limit_planes_filename>` when longitudinal limits are enabled. | | 62 | | `save_longitudinal_limit_planes` | `true` | Writes longitudinal limit planes to `<output_dir>/lane_points/<longitudinal_limit_planes_filename>` when longitudinal limits are enabled. | |
| 59 | | `visualization_colors` | See below | RGB colors used by visualization-only code. Values are lists of three numbers. | | 63 | | `visualization_colors` | See below | RGB colors used by visualization-only code. Values are triples of numbers. | |
| 60 | | `file_naming` | See below | Output filename overrides. Partial overrides keep unspecified defaults. | | 64 | | `file_naming` | See below | Output filename overrides. Partial overrides keep unspecified defaults. | |
| 61 | 65 | ||
| 62 | ## `file_naming` | 66 | ## `file_naming` |
| 63 | 67 |
| 3 | `SegmentMapper` accepts a strict JSON-compatible configuration mapping. Unknown | 3 | `SegmentMapper` accepts a strict JSON-compatible configuration mapping. Unknown |
| 4 | top-level keys, unknown `file_naming` keys, and unknown `visualization_colors` | 4 | top-level keys, unknown `file_naming` keys, and unknown `visualization_colors` |
| 5 | keys raise `SegmentMapperConfigError`. | 5 | keys raise `SegmentMapperConfigError`. |
| 6 | 6 | ||
| 7 | Defaults are loaded from | 7 | The schema is `SegmentMapperConfig` in |
| 8 | `src/iolabs_point_cloud_segmentation_trajectory/segment_mapper.default.json`. | 8 | `iolabs_point_cloud_segmentation_trajectory._config`, mirroring |
| 9 | Partial nested overrides are deep-merged with the bundled defaults. A config | 9 | `src/iolabs_point_cloud_segmentation_trajectory/segment_mapper.default.json` |
| 10 | key for key. Keys a caller omits fall back to the model defaults, which are | ||
| 11 | identical to the bundled JSON, so partial nested overrides keep the remaining | ||
| 12 | defaults. Sequence values (`write_only_segments`, the RGB colors) come back as | ||
| 13 | tuples in the validated dict. A config | ||
| 10 | file passed explicitly by path replaces the bundled JSON rather than extending | 14 | file passed explicitly by path replaces the bundled JSON rather than extending |
| 11 | it; keys it omits fall back to the same values the bundled JSON carries. | 15 | it; keys it omits fall back to the same values the bundled JSON carries. |
| 12 | 16 | ||
| 13 | ## Example | 17 | ## Example |
| 55 | | `enable_longitudinal_limit_planes` | `true` | Builds side limit planes for each segment and drops saved points outside the left/right corridor. This affects LAS splitting and the LAS coloring visualization. | | 59 | | `enable_longitudinal_limit_planes` | `true` | Builds side limit planes for each segment and drops saved points outside the left/right corridor. This affects LAS splitting and the LAS coloring visualization. | |
| 56 | | `longitudinal_limit_distance_m` | `100.0` | Left/right offset distance, in metres, used to build longitudinal limit planes. Must be greater than zero. | | 60 | | `longitudinal_limit_distance_m` | `100.0` | Left/right offset distance, in metres, used to build longitudinal limit planes. Must be greater than zero. | |
| 57 | | `write_only_segments` | `[]` | Restricts LAS splitting to the listed segment indices; an empty list (or `null`) writes every segment. Useful for re-running a few failed segments without redoing the whole split. | | 61 | | `write_only_segments` | `[]` | Restricts LAS splitting to the listed segment indices; an empty list (or `null`) writes every segment. Useful for re-running a few failed segments without redoing the whole split. | |
| 58 | | `save_longitudinal_limit_planes` | `true` | Writes longitudinal limit planes to `<output_dir>/lane_points/<longitudinal_limit_planes_filename>` when longitudinal limits are enabled. | | 62 | | `save_longitudinal_limit_planes` | `true` | Writes longitudinal limit planes to `<output_dir>/lane_points/<longitudinal_limit_planes_filename>` when longitudinal limits are enabled. | |
| 59 | | `visualization_colors` | See below | RGB colors used by visualization-only code. Values are lists of three numbers. | | 63 | | `visualization_colors` | See below | RGB colors used by visualization-only code. Values are triples of numbers. | |
| 60 | | `file_naming` | See below | Output filename overrides. Partial overrides keep unspecified defaults. | | 64 | | `file_naming` | See below | Output filename overrides. Partial overrides keep unspecified defaults. | |
| 61 | 65 | ||
| 62 | ## `file_naming` | 66 | ## `file_naming` |
| 63 | 67 |
| 1 | """Segment mapper config: pydantic model tree over the packaged default JSON.""" | 1 | """Configuration for the trajectory-based segment mapper (pipeline step 3). |
| 2 | |||
| 3 | The schema is `SegmentMapperConfig` (a `config_loader.ConfigModel`), mirroring | ||
| 4 | `segment_mapper.default.json` key for key. | ||
| 5 | |||
| 6 | Adding a config key means adding the field to the model and the same key to | ||
| 7 | `segment_mapper.default.json` — nothing else. Unknown keys are rejected. | ||
| 8 | |||
| 9 | The entry points return a plain `dict[str, Any]` (the validated `model_dump()`). | ||
| 10 | """ | ||
| 2 | 11 | ||
| 3 | from __future__ import annotations | 12 | from __future__ import annotations |
| 4 | 13 | ||
| 14 | import logging | ||
| 15 | from collections.abc import Mapping | ||
| 5 | from pathlib import Path | 16 | from pathlib import Path |
| 6 | from typing import Any | 17 | from typing import Any |
| 7 | 18 | ||
| 8 | import pydantic | 19 | import pydantic |
| 9 | from iolabs.common import config_loader | 20 | from iolabs.common import config_loader |
| 10 | 21 | ||
| 11 | _PACKAGE = "iolabs_point_cloud_segmentation_trajectory" | 22 | logger = logging.getLogger(__name__) |
| 23 | |||
| 24 | _PACKAGE_NAME = "iolabs_point_cloud_segmentation_trajectory" | ||
| 12 | _DEFAULT_FILENAME = "segment_mapper.default.json" | 25 | _DEFAULT_FILENAME = "segment_mapper.default.json" |
| 13 | _CONTEXT = "segment mapper config" | 26 | _CONTEXT = "segment mapper config" |
| 14 | 27 | ||
| 15 | 28 |
| 26 | longitudinal_limit_planes_filename: str = "run3_longitudinal_limit_planes.npz" | 39 | longitudinal_limit_planes_filename: str = "run3_longitudinal_limit_planes.npz" |
| 27 | 40 | ||
| 28 | 41 | ||
| 29 | class SegmentMapperVisualizationColorsConfig(config_loader.ConfigModel): | 42 | class SegmentMapperVisualizationColorsConfig(config_loader.ConfigModel): |
| 30 | """RGB visualization colors; each value is a list of three numbers.""" | 43 | """RGB visualization colors; each value is a triple of numbers.""" |
| 31 | 44 | ||
| 32 | angle_limit_rejected: list[float] = pydantic.Field( | 45 | angle_limit_rejected: tuple[float, float, float] = (0.45, 0.45, 0.45) |
| 33 | default=[0.45, 0.45, 0.45], min_length=3, max_length=3 | 46 | segmentation_plane: tuple[float, float, float] = (0.1, 0.35, 1.0) |
| 34 | ) | 47 | longitudinal_left_plane: tuple[float, float, float] = (1.0, 0.25, 0.0) |
| 35 | segmentation_plane: list[float] = pydantic.Field( | 48 | longitudinal_right_plane: tuple[float, float, float] = (1.0, 0.55, 0.0) |
| 36 | default=[0.1, 0.35, 1.0], min_length=3, max_length=3 | ||
| 37 | ) | ||
| 38 | longitudinal_left_plane: list[float] = pydantic.Field( | ||
| 39 | default=[1.0, 0.25, 0.0], min_length=3, max_length=3 | ||
| 40 | ) | ||
| 41 | longitudinal_right_plane: list[float] = pydantic.Field( | ||
| 42 | default=[1.0, 0.55, 0.0], min_length=3, max_length=3 | ||
| 43 | ) | ||
| 44 | 49 | ||
| 45 | 50 | ||
| 46 | class SegmentMapperConfig(config_loader.ConfigModel): | 51 | class SegmentMapperConfig(config_loader.ConfigModel): |
| 47 | """Segment mapper config; field names and nesting match the packaged JSON.""" | 52 | """Segment mapper config; field names and nesting match the packaged JSON.""" |
| 63 | reuse_existing_planes: bool = False | 68 | reuse_existing_planes: bool = False |
| 64 | reuse_existing_geoshift: bool = False | 69 | reuse_existing_geoshift: bool = False |
| 65 | enable_longitudinal_limit_planes: bool = True | 70 | enable_longitudinal_limit_planes: bool = True |
| 66 | longitudinal_limit_distance_m: float = pydantic.Field(default=100.0, gt=0) | 71 | longitudinal_limit_distance_m: float = pydantic.Field(default=100.0, gt=0) |
| 67 | write_only_segments: list[int] = [] | 72 | write_only_segments: tuple[int, ...] = () |
| 68 | save_longitudinal_limit_planes: bool = True | 73 | save_longitudinal_limit_planes: bool = True |
| 69 | visualization_colors: SegmentMapperVisualizationColorsConfig = ( | 74 | visualization_colors: SegmentMapperVisualizationColorsConfig = ( |
| 70 | SegmentMapperVisualizationColorsConfig() | 75 | SegmentMapperVisualizationColorsConfig() |
| 71 | ) | 76 | ) |
| 83 | @classmethod | 88 | @classmethod |
| 84 | def _none_write_only_segments_is_empty(cls, value: Any) -> Any: | 89 | def _none_write_only_segments_is_empty(cls, value: Any) -> Any: |
| 85 | """Treat a JSON ``null`` as 'no restriction', as the pre-pydantic code did.""" | 90 | """Treat a JSON ``null`` as 'no restriction', as the pre-pydantic code did.""" |
| 86 | if value is None: | 91 | if value is None: |
| 87 | return [] | 92 | return () |
| 88 | return value | 93 | return value |
| 89 | 94 | ||
| 90 | 95 | ||
| 91 | def _load_segment_mapper_model( | 96 | def _load_model( |
| 92 | *, | 97 | *, |
| 93 | overrides: dict[str, Any] | None = None, | 98 | overrides: Mapping[str, Any] | None = None, |
| 94 | config_path: str | Path | None = None, | 99 | config_path: str | Path | None = None, |
| 95 | ) -> SegmentMapperConfig: | 100 | ) -> SegmentMapperConfig: |
| 101 | """Load the packaged defaults (or *config_path*) with *overrides* merged on top.""" | ||
| 102 | if overrides: | ||
| 103 | logger.info("Config overrides applied: %s", ", ".join(sorted(overrides))) | ||
| 104 | if config_path is not None: | ||
| 105 | logger.info("Config file applied: %s", config_path) | ||
| 96 | return config_loader.load_config( | 106 | return config_loader.load_config( |
| 97 | SegmentMapperConfig, | 107 | SegmentMapperConfig, |
| 98 | package=_PACKAGE, | 108 | package=_PACKAGE_NAME, |
| 99 | filename=_DEFAULT_FILENAME, | 109 | filename=_DEFAULT_FILENAME, |
| 100 | overrides=overrides, | 110 | overrides=overrides, |
| 101 | config_path=config_path, | 111 | config_path=config_path, |
| 102 | context=_CONTEXT, | 112 | context=_CONTEXT, |
| 103 | error_cls=SegmentMapperConfigError, | 113 | error_cls=SegmentMapperConfigError, |
| 104 | ) | 114 | ) |
| 105 | 115 | ||
| 106 | 116 | ||
| 107 | def normalize_segment_mapper_config(raw_config: dict[str, Any]) -> dict[str, Any]: | 117 | def normalize_segment_mapper_config(raw_config: Mapping[str, Any]) -> dict[str, Any]: |
| 108 | """Merge *raw_config* onto the packaged defaults and return the validated dict.""" | 118 | """Validate *raw_config*, filling every unset key with its model default.""" |
| 109 | return _load_segment_mapper_model(overrides=dict(raw_config)).model_dump() | 119 | return config_loader.validate_config( |
| 120 | SegmentMapperConfig, | ||
| 121 | raw_config, | ||
| 122 | context=_CONTEXT, | ||
| 123 | error_cls=SegmentMapperConfigError, | ||
| 124 | ).model_dump() | ||
| 110 | 125 | ||
| 111 | 126 | ||
| 112 | def load_segment_mapper_config(config_path: str | Path | None = None) -> dict[str, Any]: | 127 | def load_segment_mapper_config(config_path: str | Path | None = None) -> dict[str, Any]: |
| 113 | """Return the validated config from *config_path*, or the packaged defaults.""" | 128 | """Return the validated config from *config_path*, or the packaged defaults.""" |
| 114 | return _load_segment_mapper_model(config_path=config_path).model_dump() | 129 | return _load_model(config_path=config_path).model_dump() |
| 115 | 130 | ||
| 116 | 131 | ||
| 117 | def build_segment_mapper_config( | 132 | def build_segment_mapper_config( |
| 118 | *, | 133 | *, |
| 119 | overrides: dict[str, Any] | None = None, | 134 | overrides: Mapping[str, Any] | None = None, |
| 120 | config_path: str | Path | None = None, | 135 | config_path: str | Path | None = None, |
| 121 | ) -> dict[str, Any]: | 136 | ) -> dict[str, Any]: |
| 122 | """Return the validated config with *overrides* merged onto the defaults.""" | 137 | """Return the validated config with *overrides* merged onto the defaults.""" |
| 123 | return _load_segment_mapper_model( | 138 | return _load_model(overrides=overrides, config_path=config_path).model_dump() |
| 124 | overrides=overrides, | ||
| 125 | config_path=config_path, | ||
| 126 | ).model_dump() |
| 5 | import json | 5 | import json |
| 6 | from pathlib import Path | 6 | from pathlib import Path |
| 7 | 7 | ||
| 8 | import pytest | 8 | import pytest |
| 9 | from iolabs.common import config_loader | ||
| 9 | 10 | ||
| 10 | from iolabs_point_cloud_segmentation_trajectory import _config | 11 | from iolabs_point_cloud_segmentation_trajectory import _config |
| 11 | from iolabs_point_cloud_segmentation_trajectory._config import ( | 12 | from iolabs_point_cloud_segmentation_trajectory._config import ( |
| 12 | SegmentMapperConfig, | 13 | SegmentMapperConfig, |
| 37 | "reuse_existing_planes": False, | 38 | "reuse_existing_planes": False, |
| 38 | "reuse_existing_geoshift": False, | 39 | "reuse_existing_geoshift": False, |
| 39 | "enable_longitudinal_limit_planes": True, | 40 | "enable_longitudinal_limit_planes": True, |
| 40 | "longitudinal_limit_distance_m": 100.0, | 41 | "longitudinal_limit_distance_m": 100.0, |
| 41 | "write_only_segments": [], | 42 | "write_only_segments": (), |
| 42 | "save_longitudinal_limit_planes": True, | 43 | "save_longitudinal_limit_planes": True, |
| 43 | "visualization_colors": { | 44 | "visualization_colors": { |
| 44 | "angle_limit_rejected": [0.45, 0.45, 0.45], | 45 | "angle_limit_rejected": (0.45, 0.45, 0.45), |
| 45 | "segmentation_plane": [0.1, 0.35, 1.0], | 46 | "segmentation_plane": (0.1, 0.35, 1.0), |
| 46 | "longitudinal_left_plane": [1.0, 0.25, 0.0], | 47 | "longitudinal_left_plane": (1.0, 0.25, 0.0), |
| 47 | "longitudinal_right_plane": [1.0, 0.55, 0.0], | 48 | "longitudinal_right_plane": (1.0, 0.55, 0.0), |
| 48 | }, | 49 | }, |
| 49 | } | 50 | } |
| 50 | 51 | ||
| 51 | EXPECTED_FILE_NAMING_DEFAULTS: dict[str, str] = { | 52 | EXPECTED_FILE_NAMING_DEFAULTS: dict[str, str] = { |
| 83 | SegmentMapperVisualizationColorsConfig.model_fields | 84 | SegmentMapperVisualizationColorsConfig.model_fields |
| 84 | ) | 85 | ) |
| 85 | 86 | ||
| 86 | 87 | ||
| 87 | def test_packaged_json_matches_model_defaults() -> None: | 88 | def test_model_defaults_match_packaged_json() -> None: |
| 88 | """The packaged JSON must stay in sync with the model defaults, key and value.""" | 89 | """The packaged JSON must stay in sync with the model defaults, key and value.""" |
| 89 | packaged = json.loads( | 90 | packaged = json.loads( |
| 90 | ( | 91 | ( |
| 91 | Path(_config.__file__).with_name("segment_mapper.default.json") | 92 | Path(_config.__file__).with_name("segment_mapper.default.json") |
| 92 | ).read_text(encoding="utf-8") | 93 | ).read_text(encoding="utf-8") |
| 93 | ) | 94 | ) |
| 94 | assert packaged == SegmentMapperConfig().model_dump() | 95 | assert packaged == SegmentMapperConfig().model_dump(mode="json") |
| 95 | 96 | ||
| 96 | 97 | ||
| 97 | def test_normalize_is_idempotent() -> None: | 98 | def test_normalize_is_idempotent() -> None: |
| 98 | once = normalize_segment_mapper_config({}) | 99 | once = normalize_segment_mapper_config({}) |
| 111 | # Whitelist validation | 112 | # Whitelist validation |
| 112 | # --------------------------------------------------------------------------- | 113 | # --------------------------------------------------------------------------- |
| 113 | 114 | ||
| 114 | 115 | ||
| 115 | def test_unknown_top_level_key_raises() -> None: | 116 | def test_unknown_top_level_key_is_rejected() -> None: |
| 116 | with pytest.raises(SegmentMapperConfigError) as excinfo: | 117 | with pytest.raises(SegmentMapperConfigError) as excinfo: |
| 117 | normalize_segment_mapper_config({"bogus_key": 1}) | 118 | normalize_segment_mapper_config({"bogus_key": 1}) |
| 118 | message = str(excinfo.value) | 119 | message = str(excinfo.value) |
| 119 | assert "bogus_key" in message | 120 | assert "bogus_key" in message |
| 128 | assert "aaa_unknown" in message | 129 | assert "aaa_unknown" in message |
| 129 | assert "zzz_unknown" in message | 130 | assert "zzz_unknown" in message |
| 130 | 131 | ||
| 131 | 132 | ||
| 132 | def test_unknown_file_naming_key_raises() -> None: | 133 | def test_unknown_nested_key_is_rejected() -> None: |
| 133 | with pytest.raises(SegmentMapperConfigError) as excinfo: | 134 | with pytest.raises(SegmentMapperConfigError) as excinfo: |
| 134 | normalize_segment_mapper_config({"file_naming": {"bogus_fn": "x"}}) | 135 | normalize_segment_mapper_config({"file_naming": {"bogus_fn": "x"}}) |
| 135 | assert "bogus_fn" in str(excinfo.value) | 136 | assert "bogus_fn" in str(excinfo.value) |
| 136 | 137 |
| 174 | 175 | ||
| 175 | def test_write_only_segments_none_means_no_restriction() -> None: | 176 | def test_write_only_segments_none_means_no_restriction() -> None: |
| 176 | """A JSON ``null`` keeps the pre-pydantic 'write every segment' behaviour.""" | 177 | """A JSON ``null`` keeps the pre-pydantic 'write every segment' behaviour.""" |
| 177 | config = normalize_segment_mapper_config({"write_only_segments": None}) | 178 | config = normalize_segment_mapper_config({"write_only_segments": None}) |
| 178 | assert config["write_only_segments"] == [] | 179 | assert config["write_only_segments"] == () |
| 179 | 180 | ||
| 180 | 181 | ||
| 181 | def test_write_only_segments_accepts_int_list() -> None: | 182 | def test_write_only_segments_accepts_int_list() -> None: |
| 182 | config = normalize_segment_mapper_config({"write_only_segments": [3, 7]}) | 183 | config = normalize_segment_mapper_config({"write_only_segments": [3, 7]}) |
| 183 | assert config["write_only_segments"] == [3, 7] | 184 | assert config["write_only_segments"] == (3, 7) |
| 184 | 185 | ||
| 185 | 186 | ||
| 186 | def test_las_points_per_chunk_must_be_positive() -> None: | 187 | def test_las_points_per_chunk_must_be_positive() -> None: |
| 187 | for bad in (0, -1): | 188 | for bad in (0, -1): |
| 225 | def test_partial_visualization_color_override_keeps_other_defaults() -> None: | 226 | def test_partial_visualization_color_override_keeps_other_defaults() -> None: |
| 226 | config = normalize_segment_mapper_config( | 227 | config = normalize_segment_mapper_config( |
| 227 | {"visualization_colors": {"angle_limit_rejected": [0.5, 0.5, 0.5]}} | 228 | {"visualization_colors": {"angle_limit_rejected": [0.5, 0.5, 0.5]}} |
| 228 | ) | 229 | ) |
| 229 | assert config["visualization_colors"]["angle_limit_rejected"] == [0.5, 0.5, 0.5] | 230 | assert config["visualization_colors"]["angle_limit_rejected"] == (0.5, 0.5, 0.5) |
| 230 | assert config["visualization_colors"]["segmentation_plane"] == [0.1, 0.35, 1.0] | 231 | assert config["visualization_colors"]["segmentation_plane"] == (0.1, 0.35, 1.0) |
| 231 | 232 | ||
| 232 | 233 | ||
| 233 | # --------------------------------------------------------------------------- | 234 | # --------------------------------------------------------------------------- |
| 234 | # load_segment_mapper_config / build_segment_mapper_config | 235 | # load_segment_mapper_config / build_segment_mapper_config |
| 235 | # --------------------------------------------------------------------------- | 236 | # --------------------------------------------------------------------------- |
| 236 | 237 | ||
| 237 | 238 | ||
| 238 | def test_load_default_bundled_config() -> None: | 239 | def test_load_segment_mapper_config_returns_packaged_defaults() -> None: |
| 239 | """The bundled default JSON must produce the same defaults as normalizing `{}`.""" | 240 | """The bundled default JSON must produce the same defaults as normalizing `{}`.""" |
| 240 | from_disk = load_segment_mapper_config() | 241 | from_disk = load_segment_mapper_config() |
| 241 | from_empty = normalize_segment_mapper_config({}) | 242 | from_empty = normalize_segment_mapper_config({}) |
| 242 | assert from_disk == from_empty | 243 | assert from_disk == from_empty |
| 272 | with pytest.raises(SegmentMapperConfigError): | 273 | with pytest.raises(SegmentMapperConfigError): |
| 273 | load_segment_mapper_config(cfg_file) | 274 | load_segment_mapper_config(cfg_file) |
| 274 | 275 | ||
| 275 | 276 | ||
| 276 | def test_build_deep_merges_overrides_over_default_json() -> None: | 277 | def test_overrides_deep_merge_onto_defaults() -> None: |
| 277 | config = build_segment_mapper_config( | 278 | config = build_segment_mapper_config( |
| 278 | overrides={ | 279 | overrides={ |
| 279 | "n_segments": 42, | 280 | "n_segments": 42, |
| 280 | "file_naming": {"planes_filename": "custom.npz"}, | 281 | "file_naming": {"planes_filename": "custom.npz"}, |
| 327 | with pytest.raises(SegmentMapperConfigError): | 328 | with pytest.raises(SegmentMapperConfigError): |
| 328 | build_segment_mapper_config( | 329 | build_segment_mapper_config( |
| 329 | overrides={"file_naming": {"not_allowed_fn": "x"}} | 330 | overrides={"file_naming": {"not_allowed_fn": "x"}} |
| 330 | ) | 331 | ) |
| 332 | |||
| 333 | |||
| 334 | # --------------------------------------------------------------------------- | ||
| 335 | # Error class / --set overrides | ||
| 336 | # --------------------------------------------------------------------------- | ||
| 337 | |||
| 338 | |||
| 339 | def test_error_class_is_config_error() -> None: | ||
| 340 | assert issubclass(SegmentMapperConfigError, config_loader.ConfigError) | ||
| 341 | assert issubclass(SegmentMapperConfigError, ValueError) | ||
| 342 | |||
| 343 | |||
| 344 | def test_set_override_coercion_and_rejection() -> None: | ||
| 345 | overrides = config_loader.parse_set_overrides( | ||
| 346 | ["las_points_per_chunk=1e3", "save_planes=on"], | ||
| 347 | error_cls=SegmentMapperConfigError, | ||
| 348 | ) | ||
| 349 | config = build_segment_mapper_config(overrides=overrides) | ||
| 350 | assert config["las_points_per_chunk"] == 1000 | ||
| 351 | assert config["save_planes"] is True | ||
| 352 | |||
| 353 | with pytest.raises(SegmentMapperConfigError): | ||
| 354 | build_segment_mapper_config( | ||
| 355 | overrides=config_loader.parse_set_overrides( | ||
| 356 | ["save_planes=flase"], error_cls=SegmentMapperConfigError | ||
| 357 | ) | ||
| 358 | ) |
<Name><Section>Config), module constants, keyword-only entry points, canonical test names, README config section. No behaviour change intended.