Miroslav Simko <ms@iolabs.ch> 2026-09-02T09:36:31+02:00
Commit #13 · 15 snippets
README.md | 4 +- .../_config.py | 80 +++++++++++++--------- tests/test_config.py | 69 ++++++++++--------- 3 files changed, 84 insertions(+), 69 deletions(-)
| 1 | """Configuration for trajectory extraction from LIDAR point clouds (Step 1). | ||
| 2 | |||
| 3 | The schema is `TrajectoryConfig` (a `config_loader.ConfigModel`), mirroring | ||
| 4 | `trajectories.default.json` key for key. | ||
| 5 | |||
| 6 | Adding a config key means adding the field to the model and the same key to | ||
| 7 | `trajectories.default.json` — nothing else. Unknown keys are rejected. | ||
| 8 | |||
| 9 | The entry points return a plain `dict[str, Any]`, not the frozen model. | ||
| 10 | """ | ||
| 11 | |||
| 1 | from __future__ import annotations | 12 | from __future__ import annotations |
| 2 | 13 | ||
| 3 | import logging | 14 | import logging |
| 4 | from collections.abc import Mapping | 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 | ||
| 19 | import pydantic | ||
| 20 | |||
| 8 | from iolabs.common import config_loader | 21 | from iolabs.common import config_loader |
| 9 | 22 | ||
| 10 | logger = logging.getLogger(__name__) | 23 | logger = logging.getLogger(__name__) |
| 11 | 24 | ||
| 12 | _PACKAGE = "iolabs_point_cloud_trajectory_detect" | 25 | _PACKAGE_NAME = "iolabs_point_cloud_trajectory_detect" |
| 13 | _DEFAULT_FILENAME = "trajectories.default.json" | 26 | _DEFAULT_FILENAME = "trajectories.default.json" |
| 27 | _CONTEXT = "trajectory config" | ||
| 14 | 28 | ||
| 15 | 29 | ||
| 16 | class TrajectoryConfig(config_loader.ConfigModel): | 30 | class TrajectoryConfig(config_loader.ConfigModel): |
| 17 | """Packaged trajectory-detect configuration.""" | 31 | """Packaged trajectory-detect configuration.""" |
| 70 | Raises: | 99 | Raises: |
| 71 | TrajectoryConfigError: Malformed JSON, unknown keys, or bad values. | 100 | TrajectoryConfigError: Malformed JSON, unknown keys, or bad values. |
| 72 | OSError: The config file could not be read. | 101 | OSError: The config file could not be read. |
| 73 | """ | 102 | """ |
| 74 | return config_loader.load_config( | 103 | return _load_model(config_path=config_path).model_dump() |
| 75 | TrajectoryConfig, | ||
| 76 | package=_PACKAGE, | ||
| 77 | filename=_DEFAULT_FILENAME, | ||
| 78 | config_path=config_path, | ||
| 79 | context="trajectory config", | ||
| 80 | error_cls=TrajectoryConfigError, | ||
| 81 | ).model_dump() | ||
| 82 | 104 | ||
| 83 | 105 | ||
| 84 | def build_trajectory_config( | 106 | def build_trajectory_config( |
| 85 | *, | 107 | *, |
| 86 | overrides: dict[str, Any] | None = None, | 108 | overrides: Mapping[str, Any] | None = None, |
| 87 | config_path: str | Path | None = None, | 109 | config_path: str | Path | None = None, |
| 88 | ) -> dict[str, Any]: | 110 | ) -> dict[str, Any]: |
| 89 | """Load defaults (or *config_path*) and deep-merge *overrides* on top. | 111 | """Load defaults (or *config_path*) and deep-merge *overrides* on top. |
| 90 | 112 |
| 20 | save_ply: bool = False | 34 | save_ply: bool = False |
| 21 | only_within_80_degrees: bool = True | 35 | only_within_80_degrees: bool = True |
| 22 | pcd_extension: str = "" | 36 | pcd_extension: str = "" |
| 23 | spline_pcd_extension: str = "_run1_spline_points" | 37 | spline_pcd_extension: str = "_run1_spline_points" |
| 24 | trajectory_max_angle: float = 5.0 | 38 | trajectory_max_angle: float = pydantic.Field(default=5.0, gt=0.0, le=180.0) |
| 25 | trajectory_first_downsample: float = 0.01 | 39 | trajectory_first_downsample: float = pydantic.Field(default=0.01, gt=0.0) |
| 26 | trajectory_second_downsample: float = 0.005 | 40 | trajectory_second_downsample: float = pydantic.Field(default=0.005, gt=0.0) |
| 27 | trajectory_outlier_removal_nb_points: int = 7 | 41 | trajectory_outlier_removal_nb_points: int = pydantic.Field(default=7, ge=1) |
| 28 | trajectory_outlier_removal_search_radius: float = 1.0 | 42 | trajectory_outlier_removal_search_radius: float = pydantic.Field(default=1.0, gt=0.0) |
| 29 | 43 | ||
| 30 | 44 | ||
| 31 | class TrajectoryConfigError(config_loader.ConfigError): | 45 | class TrajectoryConfigError(config_loader.ConfigError): |
| 32 | """Raised when trajectory config contains unsupported keys or values.""" | 46 | """Raised when trajectory config contains unsupported keys or values.""" |
| 33 | 47 | ||
| 34 | 48 | ||
| 35 | def normalize_trajectory_config( | 49 | def _load_model( |
| 36 | raw_config: Mapping[str, Any] | TrajectoryConfig, | 50 | *, |
| 37 | ) -> dict[str, Any]: | 51 | overrides: Mapping[str, Any] | None = None, |
| 52 | config_path: str | Path | None = None, | ||
| 53 | ) -> TrajectoryConfig: | ||
| 54 | """Load the packaged defaults (or *config_path*) with *overrides* merged on top.""" | ||
| 55 | if overrides: | ||
| 56 | logger.info("Config overrides applied: %s", ", ".join(sorted(overrides))) | ||
| 57 | if config_path is not None: | ||
| 58 | logger.info("Config file applied: %s", config_path) | ||
| 59 | return config_loader.load_config( | ||
| 60 | TrajectoryConfig, | ||
| 61 | package=_PACKAGE_NAME, | ||
| 62 | filename=_DEFAULT_FILENAME, | ||
| 63 | overrides=dict(overrides) if overrides else None, | ||
| 64 | config_path=config_path, | ||
| 65 | context=_CONTEXT, | ||
| 66 | error_cls=TrajectoryConfigError, | ||
| 67 | ) | ||
| 68 | |||
| 69 | |||
| 70 | def normalize_trajectory_config(raw_config: Mapping[str, Any]) -> dict[str, Any]: | ||
| 38 | """Validate *raw_config* and fill field defaults. | 71 | """Validate *raw_config* and fill field defaults. |
| 39 | 72 | ||
| 40 | Args: | 73 | Args: |
| 41 | raw_config: A raw or partial trajectory config mapping, or an already | 74 | raw_config: A raw or partial trajectory config mapping. |
| 42 | validated :class:`TrajectoryConfig`. | ||
| 43 | 75 | ||
| 44 | Returns: | 76 | Returns: |
| 45 | A plain dict of the validated config. | 77 | A plain dict of the validated config. |
| 46 | 78 | ||
| 47 | Raises: | 79 | Raises: |
| 48 | TrajectoryConfigError: Unknown keys or values that cannot be coerced. | 80 | TrajectoryConfigError: Unknown keys or values that cannot be coerced. |
| 49 | """ | 81 | """ |
| 50 | if isinstance(raw_config, TrajectoryConfig): | ||
| 51 | return raw_config.model_dump() | ||
| 52 | logger.debug("Normalizing trajectory config keys=%s", sorted(raw_config)) | ||
| 53 | return config_loader.validate_config( | 82 | return config_loader.validate_config( |
| 54 | TrajectoryConfig, | 83 | TrajectoryConfig, |
| 55 | raw_config, | 84 | raw_config, |
| 56 | context="trajectory config", | 85 | context=_CONTEXT, |
| 57 | error_cls=TrajectoryConfigError, | 86 | error_cls=TrajectoryConfigError, |
| 58 | ).model_dump() | 87 | ).model_dump() |
| 59 | 88 | ||
| 60 | 89 |
| 98 | Raises: | 120 | Raises: |
| 99 | TrajectoryConfigError: Malformed JSON, unknown keys, or bad values. | 121 | TrajectoryConfigError: Malformed JSON, unknown keys, or bad values. |
| 100 | OSError: The config file could not be read. | 122 | OSError: The config file could not be read. |
| 101 | """ | 123 | """ |
| 102 | return config_loader.load_config( | 124 | return _load_model(overrides=overrides, config_path=config_path).model_dump() |
| 103 | TrajectoryConfig, | ||
| 104 | package=_PACKAGE, | ||
| 105 | filename=_DEFAULT_FILENAME, | ||
| 106 | overrides=overrides, | ||
| 107 | config_path=config_path, | ||
| 108 | context="trajectory config", | ||
| 109 | error_cls=TrajectoryConfigError, | ||
| 110 | ).model_dump() |
| 1 | from __future__ import annotations | 1 | from __future__ import annotations |
| 2 | 2 | ||
| 3 | import json | 3 | import json |
| 4 | import sys | ||
| 5 | from pathlib import Path | 4 | from pathlib import Path |
| 6 | 5 | ||
| 7 | import pytest | 6 | import pytest |
| 8 | 7 | ||
| 9 | sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) | 8 | from iolabs.common import config_loader |
| 10 | 9 | from iolabs_point_cloud_trajectory_detect import _config | |
| 11 | from iolabs.common import config_loader # noqa: E402 | 10 | from iolabs_point_cloud_trajectory_detect._config import ( |
| 12 | from iolabs_point_cloud_trajectory_detect import _config # noqa: E402 | ||
| 13 | from iolabs_point_cloud_trajectory_detect._config import ( # noqa: E402 | ||
| 14 | TrajectoryConfig, | 11 | TrajectoryConfig, |
| 15 | TrajectoryConfigError, | 12 | TrajectoryConfigError, |
| 16 | build_trajectory_config, | 13 | build_trajectory_config, |
| 17 | load_trajectory_config, | 14 | load_trajectory_config, |
| 36 | path = Path(_config.__file__).with_name(_config._DEFAULT_FILENAME) | 33 | path = Path(_config.__file__).with_name(_config._DEFAULT_FILENAME) |
| 37 | return json.loads(path.read_text(encoding="utf-8")) | 34 | return json.loads(path.read_text(encoding="utf-8")) |
| 38 | 35 | ||
| 39 | 36 | ||
| 40 | def test_packaged_json_matches_model_field_defaults() -> None: | 37 | def test_model_defaults_match_packaged_json() -> None: |
| 41 | packaged = _packaged_defaults() | ||
| 42 | model_defaults = { | 38 | model_defaults = { |
| 43 | name: field.get_default(call_default_factory=True) | 39 | name: field.get_default(call_default_factory=True) |
| 44 | for name, field in TrajectoryConfig.model_fields.items() | 40 | for name, field in TrajectoryConfig.model_fields.items() |
| 45 | } | 41 | } |
| 46 | assert packaged == model_defaults | 42 | assert _packaged_defaults() == model_defaults |
| 43 | assert TrajectoryConfig().model_dump() == _EXPECTED_DEFAULTS | ||
| 47 | 44 | ||
| 48 | 45 | ||
| 49 | def test_normalize_accepts_a_validated_model() -> None: | 46 | def test_load_trajectory_config_returns_packaged_defaults() -> None: |
| 50 | model = TrajectoryConfig(**_EXPECTED_DEFAULTS) | 47 | config = load_trajectory_config() |
| 51 | assert normalize_trajectory_config(model) == _EXPECTED_DEFAULTS | 48 | assert isinstance(config, dict) |
| 49 | assert config == _EXPECTED_DEFAULTS | ||
| 52 | 50 | ||
| 53 | 51 | ||
| 54 | def test_error_class_is_config_error() -> None: | 52 | def test_error_class_is_config_error() -> None: |
| 55 | assert issubclass(TrajectoryConfigError, config_loader.ConfigError) | 53 | assert issubclass(TrajectoryConfigError, config_loader.ConfigError) |
| 56 | assert issubclass(TrajectoryConfigError, ValueError) | 54 | assert issubclass(TrajectoryConfigError, ValueError) |
| 57 | 55 | ||
| 58 | 56 | ||
| 59 | def test_load_trajectory_config_returns_packaged_defaults() -> None: | 57 | def test_unknown_top_level_key_is_rejected() -> None: |
| 60 | config = load_trajectory_config() | 58 | with pytest.raises(TrajectoryConfigError, match="not_a_real_key") as exc: |
| 61 | assert isinstance(config, dict) | 59 | normalize_trajectory_config({"not_a_real_key": 1}) |
| 62 | assert config == _EXPECTED_DEFAULTS | 60 | message = str(exc.value) |
| 61 | assert "Unknown trajectory config key" in message | ||
| 62 | assert "Allowed keys:" in message | ||
| 63 | with pytest.raises(TrajectoryConfigError, match="typo_max_angle"): | ||
| 64 | build_trajectory_config(overrides={"typo_max_angle": 1.0}) | ||
| 63 | 65 | ||
| 64 | 66 | ||
| 65 | def test_normalize_fills_defaults_and_rejects_unknown_keys() -> None: | 67 | def test_normalize_fills_defaults() -> None: |
| 66 | config = normalize_trajectory_config({"pcd_extension": "_custom"}) | 68 | config = normalize_trajectory_config({"pcd_extension": "_custom"}) |
| 67 | assert config["pcd_extension"] == "_custom" | 69 | assert config["pcd_extension"] == "_custom" |
| 68 | assert config["trajectory_max_angle"] == 5.0 | 70 | assert config["trajectory_max_angle"] == 5.0 |
| 69 | with pytest.raises(TrajectoryConfigError, match="Unknown trajectory config key") as exc: | ||
| 70 | normalize_trajectory_config({"not_a_real_key": 1}) | ||
| 71 | message = str(exc.value) | ||
| 72 | assert "not_a_real_key" in message | ||
| 73 | assert "Allowed keys:" in message | ||
| 74 | 71 | ||
| 75 | 72 | ||
| 76 | def test_build_deep_merges_overrides_and_coerces_set_strings() -> None: | 73 | def test_overrides_deep_merge_onto_defaults() -> None: |
| 74 | config = build_trajectory_config(overrides={"save_npz": True}) | ||
| 75 | assert config["save_npz"] is True | ||
| 76 | assert config["spline_pcd_extension"] == "_run1_spline_points" | ||
| 77 | |||
| 78 | |||
| 79 | def test_set_override_coercion_and_rejection() -> None: | ||
| 77 | overrides = config_loader.parse_set_overrides( | 80 | overrides = config_loader.parse_set_overrides( |
| 78 | [ | 81 | [ |
| 79 | "save_npz=true", | 82 | "save_npz=true", |
| 80 | "trajectory_max_angle=2.5", | 83 | "trajectory_max_angle=2.5", |
| 84 | config = build_trajectory_config(overrides=overrides) | 87 | config = build_trajectory_config(overrides=overrides) |
| 85 | assert config["save_npz"] is True | 88 | assert config["save_npz"] is True |
| 86 | assert config["trajectory_max_angle"] == 2.5 | 89 | assert config["trajectory_max_angle"] == 2.5 |
| 87 | assert config["trajectory_outlier_removal_nb_points"] == 9 | 90 | assert config["trajectory_outlier_removal_nb_points"] == 9 |
| 88 | assert config["spline_pcd_extension"] == "_run1_spline_points" | 91 | with pytest.raises(TrajectoryConfigError): |
| 92 | normalize_trajectory_config({"trajectory_max_angle": "not-a-float"}) | ||
| 93 | with pytest.raises(TrajectoryConfigError): | ||
| 94 | normalize_trajectory_config({"save_npz": "flase"}) | ||
| 89 | 95 | ||
| 90 | 96 | ||
| 91 | def test_build_rejects_unknown_override_key() -> None: | 97 | def test_out_of_range_value_is_rejected() -> None: |
| 92 | with pytest.raises(TrajectoryConfigError, match="Unknown trajectory config key"): | 98 | with pytest.raises(TrajectoryConfigError): |
| 93 | build_trajectory_config(overrides={"typo_max_angle": 1.0}) | 99 | normalize_trajectory_config({"trajectory_first_downsample": 0.0}) |
| 100 | with pytest.raises(TrajectoryConfigError): | ||
| 101 | normalize_trajectory_config({"trajectory_outlier_removal_nb_points": 0}) | ||
| 94 | 102 | ||
| 95 | 103 | ||
| 96 | def test_load_config_path_replaces_packaged_defaults(tmp_path: Path) -> None: | 104 | def test_load_config_path_replaces_packaged_defaults(tmp_path: Path) -> None: |
| 97 | path = tmp_path / "custom.json" | 105 | path = tmp_path / "custom.json" |
| 98 | path.write_text(json.dumps({"trajectory_max_angle": 12.0}), encoding="utf-8") | 106 | path.write_text(json.dumps({"trajectory_max_angle": 12.0}), encoding="utf-8") |
| 99 | config = load_trajectory_config(path) | 107 | config = load_trajectory_config(path) |
| 100 | assert config["trajectory_max_angle"] == 12.0 | 108 | assert config["trajectory_max_angle"] == 12.0 |
| 101 | assert config["save_npz"] is False | 109 | assert config["save_npz"] is False |
| 102 | |||
| 103 | |||
| 104 | def test_bad_value_is_rejected() -> None: | ||
| 105 | with pytest.raises(TrajectoryConfigError): | ||
| 106 | normalize_trajectory_config({"trajectory_max_angle": "not-a-float"}) | ||
| 107 | with pytest.raises(TrajectoryConfigError): | ||
| 108 | normalize_trajectory_config({"save_npz": "flase"}) |
| 22 | ## Usage | 22 | ## Usage |
| 23 | 23 | ||
| 24 | Use this package to detect and extract trajectories (e.g. scanner path or vehicle path) from LIDAR point cloud data. | 24 | Use this package to detect and extract trajectories (e.g. scanner path or vehicle path) from LIDAR point cloud data. |
| 25 | 25 | ||
| 26 | ## Config | 26 | ## Configuration |
| 27 | 27 | ||
| 28 | Defaults live in `src/iolabs_point_cloud_trajectory_detect/trajectories.default.json`. The schema is `TrajectoryConfig` in `_config.py` (a `config_loader.ConfigModel`). To add a key, add the field to the model and the matching default in the JSON; nothing else. Unknown keys are rejected. `load_trajectory_config` / `build_trajectory_config` / `normalize_trajectory_config` return a plain `dict`, not the frozen model. | 28 | Defaults live in `src/iolabs_point_cloud_trajectory_detect/trajectories.default.json`. The schema is `TrajectoryConfig` in `_config.py` (a `config_loader.ConfigModel`); nested JSON sections are nested models and unknown keys are rejected. **To add a config key: add the field (with its type, default and any `Field` range) to the model and the same key with the same default to the JSON — nothing else.** `load_trajectory_config` / `build_trajectory_config` / `normalize_trajectory_config` return a plain `dict`. Runtime overrides come from repeatable `--set KEY=VALUE`, never repo-local JSON. |
| 29 | 29 | ||
| 30 | ## Develop locally (Nexus) | 30 | ## Develop locally (Nexus) |
| 31 | 31 | ||
| 32 | Internal `iolabs-*` dependencies resolve through the private Nexus index declared in `pyproject.toml`. Export Nexus credentials before any `uv` command that touches private deps — e.g. by sourcing `../3dai.lanefinder/scripts/nexus_credentials.sh` from your shell rc — then: | 32 | Internal `iolabs-*` dependencies resolve through the private Nexus index declared in `pyproject.toml`. Export Nexus credentials before any `uv` command that touches private deps — e.g. by sourcing `../3dai.lanefinder/scripts/nexus_credentials.sh` from your shell rc — then: |
| 1 | """Configuration for trajectory extraction from LIDAR point clouds (Step 1). | ||
| 2 | |||
| 3 | The schema is `TrajectoryConfig` (a `config_loader.ConfigModel`), mirroring | ||
| 4 | `trajectories.default.json` key for key. | ||
| 5 | |||
| 6 | Adding a config key means adding the field to the model and the same key to | ||
| 7 | `trajectories.default.json` — nothing else. Unknown keys are rejected. | ||
| 8 | |||
| 9 | The entry points return a plain `dict[str, Any]`, not the frozen model. | ||
| 10 | """ | ||
| 11 | |||
| 1 | from __future__ import annotations | 12 | from __future__ import annotations |
| 2 | 13 | ||
| 3 | import logging | 14 | import logging |
| 4 | from collections.abc import Mapping | 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 | ||
| 19 | import pydantic | ||
| 20 | |||
| 8 | from iolabs.common import config_loader | 21 | from iolabs.common import config_loader |
| 9 | 22 | ||
| 10 | logger = logging.getLogger(__name__) | 23 | logger = logging.getLogger(__name__) |
| 11 | 24 | ||
| 12 | _PACKAGE = "iolabs_point_cloud_trajectory_detect" | 25 | _PACKAGE_NAME = "iolabs_point_cloud_trajectory_detect" |
| 13 | _DEFAULT_FILENAME = "trajectories.default.json" | 26 | _DEFAULT_FILENAME = "trajectories.default.json" |
| 27 | _CONTEXT = "trajectory config" | ||
| 14 | 28 | ||
| 15 | 29 | ||
| 16 | class TrajectoryConfig(config_loader.ConfigModel): | 30 | class TrajectoryConfig(config_loader.ConfigModel): |
| 17 | """Packaged trajectory-detect configuration.""" | 31 | """Packaged trajectory-detect configuration.""" |
| 20 | save_ply: bool = False | 34 | save_ply: bool = False |
| 21 | only_within_80_degrees: bool = True | 35 | only_within_80_degrees: bool = True |
| 22 | pcd_extension: str = "" | 36 | pcd_extension: str = "" |
| 23 | spline_pcd_extension: str = "_run1_spline_points" | 37 | spline_pcd_extension: str = "_run1_spline_points" |
| 24 | trajectory_max_angle: float = 5.0 | 38 | trajectory_max_angle: float = pydantic.Field(default=5.0, gt=0.0, le=180.0) |
| 25 | trajectory_first_downsample: float = 0.01 | 39 | trajectory_first_downsample: float = pydantic.Field(default=0.01, gt=0.0) |
| 26 | trajectory_second_downsample: float = 0.005 | 40 | trajectory_second_downsample: float = pydantic.Field(default=0.005, gt=0.0) |
| 27 | trajectory_outlier_removal_nb_points: int = 7 | 41 | trajectory_outlier_removal_nb_points: int = pydantic.Field(default=7, ge=1) |
| 28 | trajectory_outlier_removal_search_radius: float = 1.0 | 42 | trajectory_outlier_removal_search_radius: float = pydantic.Field(default=1.0, gt=0.0) |
| 29 | 43 | ||
| 30 | 44 | ||
| 31 | class TrajectoryConfigError(config_loader.ConfigError): | 45 | class TrajectoryConfigError(config_loader.ConfigError): |
| 32 | """Raised when trajectory config contains unsupported keys or values.""" | 46 | """Raised when trajectory config contains unsupported keys or values.""" |
| 33 | 47 | ||
| 34 | 48 | ||
| 35 | def normalize_trajectory_config( | 49 | def _load_model( |
| 36 | raw_config: Mapping[str, Any] | TrajectoryConfig, | 50 | *, |
| 37 | ) -> dict[str, Any]: | 51 | overrides: Mapping[str, Any] | None = None, |
| 52 | config_path: str | Path | None = None, | ||
| 53 | ) -> TrajectoryConfig: | ||
| 54 | """Load the packaged defaults (or *config_path*) with *overrides* merged on top.""" | ||
| 55 | if overrides: | ||
| 56 | logger.info("Config overrides applied: %s", ", ".join(sorted(overrides))) | ||
| 57 | if config_path is not None: | ||
| 58 | logger.info("Config file applied: %s", config_path) | ||
| 59 | return config_loader.load_config( | ||
| 60 | TrajectoryConfig, | ||
| 61 | package=_PACKAGE_NAME, | ||
| 62 | filename=_DEFAULT_FILENAME, | ||
| 63 | overrides=dict(overrides) if overrides else None, | ||
| 64 | config_path=config_path, | ||
| 65 | context=_CONTEXT, | ||
| 66 | error_cls=TrajectoryConfigError, | ||
| 67 | ) | ||
| 68 | |||
| 69 | |||
| 70 | def normalize_trajectory_config(raw_config: Mapping[str, Any]) -> dict[str, Any]: | ||
| 38 | """Validate *raw_config* and fill field defaults. | 71 | """Validate *raw_config* and fill field defaults. |
| 39 | 72 | ||
| 40 | Args: | 73 | Args: |
| 41 | raw_config: A raw or partial trajectory config mapping, or an already | 74 | raw_config: A raw or partial trajectory config mapping. |
| 42 | validated :class:`TrajectoryConfig`. | ||
| 43 | 75 | ||
| 44 | Returns: | 76 | Returns: |
| 45 | A plain dict of the validated config. | 77 | A plain dict of the validated config. |
| 46 | 78 | ||
| 47 | Raises: | 79 | Raises: |
| 48 | TrajectoryConfigError: Unknown keys or values that cannot be coerced. | 80 | TrajectoryConfigError: Unknown keys or values that cannot be coerced. |
| 49 | """ | 81 | """ |
| 50 | if isinstance(raw_config, TrajectoryConfig): | ||
| 51 | return raw_config.model_dump() | ||
| 52 | logger.debug("Normalizing trajectory config keys=%s", sorted(raw_config)) | ||
| 53 | return config_loader.validate_config( | 82 | return config_loader.validate_config( |
| 54 | TrajectoryConfig, | 83 | TrajectoryConfig, |
| 55 | raw_config, | 84 | raw_config, |
| 56 | context="trajectory config", | 85 | context=_CONTEXT, |
| 57 | error_cls=TrajectoryConfigError, | 86 | error_cls=TrajectoryConfigError, |
| 58 | ).model_dump() | 87 | ).model_dump() |
| 59 | 88 | ||
| 60 | 89 |
| 70 | Raises: | 99 | Raises: |
| 71 | TrajectoryConfigError: Malformed JSON, unknown keys, or bad values. | 100 | TrajectoryConfigError: Malformed JSON, unknown keys, or bad values. |
| 72 | OSError: The config file could not be read. | 101 | OSError: The config file could not be read. |
| 73 | """ | 102 | """ |
| 74 | return config_loader.load_config( | 103 | return _load_model(config_path=config_path).model_dump() |
| 75 | TrajectoryConfig, | ||
| 76 | package=_PACKAGE, | ||
| 77 | filename=_DEFAULT_FILENAME, | ||
| 78 | config_path=config_path, | ||
| 79 | context="trajectory config", | ||
| 80 | error_cls=TrajectoryConfigError, | ||
| 81 | ).model_dump() | ||
| 82 | 104 | ||
| 83 | 105 | ||
| 84 | def build_trajectory_config( | 106 | def build_trajectory_config( |
| 85 | *, | 107 | *, |
| 86 | overrides: dict[str, Any] | None = None, | 108 | overrides: Mapping[str, Any] | None = None, |
| 87 | config_path: str | Path | None = None, | 109 | config_path: str | Path | None = None, |
| 88 | ) -> dict[str, Any]: | 110 | ) -> dict[str, Any]: |
| 89 | """Load defaults (or *config_path*) and deep-merge *overrides* on top. | 111 | """Load defaults (or *config_path*) and deep-merge *overrides* on top. |
| 90 | 112 |
| 98 | Raises: | 120 | Raises: |
| 99 | TrajectoryConfigError: Malformed JSON, unknown keys, or bad values. | 121 | TrajectoryConfigError: Malformed JSON, unknown keys, or bad values. |
| 100 | OSError: The config file could not be read. | 122 | OSError: The config file could not be read. |
| 101 | """ | 123 | """ |
| 102 | return config_loader.load_config( | 124 | return _load_model(overrides=overrides, config_path=config_path).model_dump() |
| 103 | TrajectoryConfig, | ||
| 104 | package=_PACKAGE, | ||
| 105 | filename=_DEFAULT_FILENAME, | ||
| 106 | overrides=overrides, | ||
| 107 | config_path=config_path, | ||
| 108 | context="trajectory config", | ||
| 109 | error_cls=TrajectoryConfigError, | ||
| 110 | ).model_dump() |
| 1 | from __future__ import annotations | 1 | from __future__ import annotations |
| 2 | 2 | ||
| 3 | import json | 3 | import json |
| 4 | import sys | ||
| 5 | from pathlib import Path | 4 | from pathlib import Path |
| 6 | 5 | ||
| 7 | import pytest | 6 | import pytest |
| 8 | 7 | ||
| 9 | sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) | 8 | from iolabs.common import config_loader |
| 10 | 9 | from iolabs_point_cloud_trajectory_detect import _config | |
| 11 | from iolabs.common import config_loader # noqa: E402 | 10 | from iolabs_point_cloud_trajectory_detect._config import ( |
| 12 | from iolabs_point_cloud_trajectory_detect import _config # noqa: E402 | ||
| 13 | from iolabs_point_cloud_trajectory_detect._config import ( # noqa: E402 | ||
| 14 | TrajectoryConfig, | 11 | TrajectoryConfig, |
| 15 | TrajectoryConfigError, | 12 | TrajectoryConfigError, |
| 16 | build_trajectory_config, | 13 | build_trajectory_config, |
| 17 | load_trajectory_config, | 14 | load_trajectory_config, |
| 36 | path = Path(_config.__file__).with_name(_config._DEFAULT_FILENAME) | 33 | path = Path(_config.__file__).with_name(_config._DEFAULT_FILENAME) |
| 37 | return json.loads(path.read_text(encoding="utf-8")) | 34 | return json.loads(path.read_text(encoding="utf-8")) |
| 38 | 35 | ||
| 39 | 36 | ||
| 40 | def test_packaged_json_matches_model_field_defaults() -> None: | 37 | def test_model_defaults_match_packaged_json() -> None: |
| 41 | packaged = _packaged_defaults() | ||
| 42 | model_defaults = { | 38 | model_defaults = { |
| 43 | name: field.get_default(call_default_factory=True) | 39 | name: field.get_default(call_default_factory=True) |
| 44 | for name, field in TrajectoryConfig.model_fields.items() | 40 | for name, field in TrajectoryConfig.model_fields.items() |
| 45 | } | 41 | } |
| 46 | assert packaged == model_defaults | 42 | assert _packaged_defaults() == model_defaults |
| 43 | assert TrajectoryConfig().model_dump() == _EXPECTED_DEFAULTS | ||
| 47 | 44 | ||
| 48 | 45 | ||
| 49 | def test_normalize_accepts_a_validated_model() -> None: | 46 | def test_load_trajectory_config_returns_packaged_defaults() -> None: |
| 50 | model = TrajectoryConfig(**_EXPECTED_DEFAULTS) | 47 | config = load_trajectory_config() |
| 51 | assert normalize_trajectory_config(model) == _EXPECTED_DEFAULTS | 48 | assert isinstance(config, dict) |
| 49 | assert config == _EXPECTED_DEFAULTS | ||
| 52 | 50 | ||
| 53 | 51 | ||
| 54 | def test_error_class_is_config_error() -> None: | 52 | def test_error_class_is_config_error() -> None: |
| 55 | assert issubclass(TrajectoryConfigError, config_loader.ConfigError) | 53 | assert issubclass(TrajectoryConfigError, config_loader.ConfigError) |
| 56 | assert issubclass(TrajectoryConfigError, ValueError) | 54 | assert issubclass(TrajectoryConfigError, ValueError) |
| 57 | 55 | ||
| 58 | 56 | ||
| 59 | def test_load_trajectory_config_returns_packaged_defaults() -> None: | 57 | def test_unknown_top_level_key_is_rejected() -> None: |
| 60 | config = load_trajectory_config() | 58 | with pytest.raises(TrajectoryConfigError, match="not_a_real_key") as exc: |
| 61 | assert isinstance(config, dict) | 59 | normalize_trajectory_config({"not_a_real_key": 1}) |
| 62 | assert config == _EXPECTED_DEFAULTS | 60 | message = str(exc.value) |
| 61 | assert "Unknown trajectory config key" in message | ||
| 62 | assert "Allowed keys:" in message | ||
| 63 | with pytest.raises(TrajectoryConfigError, match="typo_max_angle"): | ||
| 64 | build_trajectory_config(overrides={"typo_max_angle": 1.0}) | ||
| 63 | 65 | ||
| 64 | 66 | ||
| 65 | def test_normalize_fills_defaults_and_rejects_unknown_keys() -> None: | 67 | def test_normalize_fills_defaults() -> None: |
| 66 | config = normalize_trajectory_config({"pcd_extension": "_custom"}) | 68 | config = normalize_trajectory_config({"pcd_extension": "_custom"}) |
| 67 | assert config["pcd_extension"] == "_custom" | 69 | assert config["pcd_extension"] == "_custom" |
| 68 | assert config["trajectory_max_angle"] == 5.0 | 70 | assert config["trajectory_max_angle"] == 5.0 |
| 69 | with pytest.raises(TrajectoryConfigError, match="Unknown trajectory config key") as exc: | ||
| 70 | normalize_trajectory_config({"not_a_real_key": 1}) | ||
| 71 | message = str(exc.value) | ||
| 72 | assert "not_a_real_key" in message | ||
| 73 | assert "Allowed keys:" in message | ||
| 74 | 71 | ||
| 75 | 72 | ||
| 76 | def test_build_deep_merges_overrides_and_coerces_set_strings() -> None: | 73 | def test_overrides_deep_merge_onto_defaults() -> None: |
| 74 | config = build_trajectory_config(overrides={"save_npz": True}) | ||
| 75 | assert config["save_npz"] is True | ||
| 76 | assert config["spline_pcd_extension"] == "_run1_spline_points" | ||
| 77 | |||
| 78 | |||
| 79 | def test_set_override_coercion_and_rejection() -> None: | ||
| 77 | overrides = config_loader.parse_set_overrides( | 80 | overrides = config_loader.parse_set_overrides( |
| 78 | [ | 81 | [ |
| 79 | "save_npz=true", | 82 | "save_npz=true", |
| 80 | "trajectory_max_angle=2.5", | 83 | "trajectory_max_angle=2.5", |
| 84 | config = build_trajectory_config(overrides=overrides) | 87 | config = build_trajectory_config(overrides=overrides) |
| 85 | assert config["save_npz"] is True | 88 | assert config["save_npz"] is True |
| 86 | assert config["trajectory_max_angle"] == 2.5 | 89 | assert config["trajectory_max_angle"] == 2.5 |
| 87 | assert config["trajectory_outlier_removal_nb_points"] == 9 | 90 | assert config["trajectory_outlier_removal_nb_points"] == 9 |
| 88 | assert config["spline_pcd_extension"] == "_run1_spline_points" | 91 | with pytest.raises(TrajectoryConfigError): |
| 92 | normalize_trajectory_config({"trajectory_max_angle": "not-a-float"}) | ||
| 93 | with pytest.raises(TrajectoryConfigError): | ||
| 94 | normalize_trajectory_config({"save_npz": "flase"}) | ||
| 89 | 95 | ||
| 90 | 96 | ||
| 91 | def test_build_rejects_unknown_override_key() -> None: | 97 | def test_out_of_range_value_is_rejected() -> None: |
| 92 | with pytest.raises(TrajectoryConfigError, match="Unknown trajectory config key"): | 98 | with pytest.raises(TrajectoryConfigError): |
| 93 | build_trajectory_config(overrides={"typo_max_angle": 1.0}) | 99 | normalize_trajectory_config({"trajectory_first_downsample": 0.0}) |
| 100 | with pytest.raises(TrajectoryConfigError): | ||
| 101 | normalize_trajectory_config({"trajectory_outlier_removal_nb_points": 0}) | ||
| 94 | 102 | ||
| 95 | 103 | ||
| 96 | def test_load_config_path_replaces_packaged_defaults(tmp_path: Path) -> None: | 104 | def test_load_config_path_replaces_packaged_defaults(tmp_path: Path) -> None: |
| 97 | path = tmp_path / "custom.json" | 105 | path = tmp_path / "custom.json" |
| 98 | path.write_text(json.dumps({"trajectory_max_angle": 12.0}), encoding="utf-8") | 106 | path.write_text(json.dumps({"trajectory_max_angle": 12.0}), encoding="utf-8") |
| 99 | config = load_trajectory_config(path) | 107 | config = load_trajectory_config(path) |
| 100 | assert config["trajectory_max_angle"] == 12.0 | 108 | assert config["trajectory_max_angle"] == 12.0 |
| 101 | assert config["save_npz"] is False | 109 | assert config["save_npz"] is False |
| 102 | |||
| 103 | |||
| 104 | def test_bad_value_is_rejected() -> None: | ||
| 105 | with pytest.raises(TrajectoryConfigError): | ||
| 106 | normalize_trajectory_config({"trajectory_max_angle": "not-a-float"}) | ||
| 107 | with pytest.raises(TrajectoryConfigError): | ||
| 108 | normalize_trajectory_config({"save_npz": "flase"}) |
<Name><Section>Config), module constants, keyword-only entry points, canonical test names, README config section. No behaviour change intended.