Miroslav Simko <ms@iolabs.ch> 2026-09-02T09:08:03+02:00
Commit #12 · 9 snippets
README.md | 4 ++-- src/iolabs_point_cloud_trajectory_detect/_config.py | 10 ++++++++-- tests/test_config.py | 21 +++++++++++++++++++++ 3 files changed, 31 insertions(+), 4 deletions(-)
| 1 | from __future__ import annotations | 1 | from __future__ import annotations |
| 2 | 2 | ||
| 3 | import logging | 3 | import logging |
| 4 | from collections.abc import Mapping | ||
| 4 | from pathlib import Path | 5 | from pathlib import Path |
| 5 | from typing import Any | 6 | from typing import Any |
| 6 | 7 | ||
| 7 | from iolabs.common import config_loader | 8 | from iolabs.common import config_loader |
| 30 | class TrajectoryConfigError(config_loader.ConfigError): | 31 | class TrajectoryConfigError(config_loader.ConfigError): |
| 31 | """Raised when trajectory config contains unsupported keys or values.""" | 32 | """Raised when trajectory config contains unsupported keys or values.""" |
| 32 | 33 | ||
| 33 | 34 | ||
| 34 | def normalize_trajectory_config(raw_config: dict[str, Any]) -> dict[str, Any]: | 35 | def normalize_trajectory_config( |
| 36 | raw_config: Mapping[str, Any] | TrajectoryConfig, | ||
| 37 | ) -> dict[str, Any]: | ||
| 35 | """Validate *raw_config* and fill field defaults. | 38 | """Validate *raw_config* and fill field defaults. |
| 36 | 39 | ||
| 37 | Args: | 40 | Args: |
| 38 | raw_config: A raw or partial trajectory config mapping. | 41 | raw_config: A raw or partial trajectory config mapping, or an already |
| 42 | validated :class:`TrajectoryConfig`. | ||
| 39 | 43 | ||
| 40 | Returns: | 44 | Returns: |
| 41 | A plain dict of the validated config. | 45 | A plain dict of the validated config. |
| 42 | 46 | ||
| 43 | Raises: | 47 | Raises: |
| 44 | TrajectoryConfigError: Unknown keys or values that cannot be coerced. | 48 | TrajectoryConfigError: Unknown keys or values that cannot be coerced. |
| 45 | """ | 49 | """ |
| 50 | if isinstance(raw_config, TrajectoryConfig): | ||
| 51 | return raw_config.model_dump() | ||
| 46 | logger.debug("Normalizing trajectory config keys=%s", sorted(raw_config)) | 52 | logger.debug("Normalizing trajectory config keys=%s", sorted(raw_config)) |
| 47 | return config_loader.validate_config( | 53 | return config_loader.validate_config( |
| 48 | TrajectoryConfig, | 54 | TrajectoryConfig, |
| 49 | raw_config, | 55 | raw_config, |
| 8 | 8 | ||
| 9 | sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) | 9 | sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) |
| 10 | 10 | ||
| 11 | from iolabs.common import config_loader # noqa: E402 | 11 | from iolabs.common import config_loader # noqa: E402 |
| 12 | from iolabs_point_cloud_trajectory_detect import _config # noqa: E402 | ||
| 12 | from iolabs_point_cloud_trajectory_detect._config import ( # noqa: E402 | 13 | from iolabs_point_cloud_trajectory_detect._config import ( # noqa: E402 |
| 14 | TrajectoryConfig, | ||
| 13 | TrajectoryConfigError, | 15 | TrajectoryConfigError, |
| 14 | build_trajectory_config, | 16 | build_trajectory_config, |
| 15 | load_trajectory_config, | 17 | load_trajectory_config, |
| 16 | normalize_trajectory_config, | 18 | normalize_trajectory_config, |
| 29 | "trajectory_outlier_removal_search_radius": 1.0, | 31 | "trajectory_outlier_removal_search_radius": 1.0, |
| 30 | } | 32 | } |
| 31 | 33 | ||
| 32 | 34 | ||
| 35 | def _packaged_defaults() -> dict: | ||
| 36 | path = Path(_config.__file__).with_name(_config._DEFAULT_FILENAME) | ||
| 37 | return json.loads(path.read_text(encoding="utf-8")) | ||
| 38 | |||
| 39 | |||
| 40 | def test_packaged_json_matches_model_field_defaults() -> None: | ||
| 41 | packaged = _packaged_defaults() | ||
| 42 | model_defaults = { | ||
| 43 | name: field.get_default(call_default_factory=True) | ||
| 44 | for name, field in TrajectoryConfig.model_fields.items() | ||
| 45 | } | ||
| 46 | assert packaged == model_defaults | ||
| 47 | |||
| 48 | |||
| 49 | def test_normalize_accepts_a_validated_model() -> None: | ||
| 50 | model = TrajectoryConfig(**_EXPECTED_DEFAULTS) | ||
| 51 | assert normalize_trajectory_config(model) == _EXPECTED_DEFAULTS | ||
| 52 | |||
| 53 | |||
| 33 | def test_error_class_is_config_error() -> None: | 54 | def test_error_class_is_config_error() -> None: |
| 34 | assert issubclass(TrajectoryConfigError, config_loader.ConfigError) | 55 | assert issubclass(TrajectoryConfigError, config_loader.ConfigError) |
| 35 | assert issubclass(TrajectoryConfigError, ValueError) | 56 | assert issubclass(TrajectoryConfigError, ValueError) |
| 36 | 57 |
| 16 | 16 | ||
| 17 | ## Requirements | 17 | ## Requirements |
| 18 | 18 | ||
| 19 | - Python ≥3.11, <3.13 | 19 | - Python ≥3.11, <3.13 |
| 20 | - numpy, open3d, laspy, torch | 20 | - numpy, open3d, laspy, torch, pydantic ≥2.7, iolabs-common ≥0.9.0, iolabs-logstash |
| 21 | 21 | ||
| 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 | ## Config |
| 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. | 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. |
| 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 | from __future__ import annotations | 1 | from __future__ import annotations |
| 2 | 2 | ||
| 3 | import logging | 3 | import logging |
| 4 | from collections.abc import Mapping | ||
| 4 | from pathlib import Path | 5 | from pathlib import Path |
| 5 | from typing import Any | 6 | from typing import Any |
| 6 | 7 | ||
| 7 | from iolabs.common import config_loader | 8 | from iolabs.common import config_loader |
| 30 | class TrajectoryConfigError(config_loader.ConfigError): | 31 | class TrajectoryConfigError(config_loader.ConfigError): |
| 31 | """Raised when trajectory config contains unsupported keys or values.""" | 32 | """Raised when trajectory config contains unsupported keys or values.""" |
| 32 | 33 | ||
| 33 | 34 | ||
| 34 | def normalize_trajectory_config(raw_config: dict[str, Any]) -> dict[str, Any]: | 35 | def normalize_trajectory_config( |
| 36 | raw_config: Mapping[str, Any] | TrajectoryConfig, | ||
| 37 | ) -> dict[str, Any]: | ||
| 35 | """Validate *raw_config* and fill field defaults. | 38 | """Validate *raw_config* and fill field defaults. |
| 36 | 39 | ||
| 37 | Args: | 40 | Args: |
| 38 | raw_config: A raw or partial trajectory config mapping. | 41 | raw_config: A raw or partial trajectory config mapping, or an already |
| 42 | validated :class:`TrajectoryConfig`. | ||
| 39 | 43 | ||
| 40 | Returns: | 44 | Returns: |
| 41 | A plain dict of the validated config. | 45 | A plain dict of the validated config. |
| 42 | 46 | ||
| 43 | Raises: | 47 | Raises: |
| 44 | TrajectoryConfigError: Unknown keys or values that cannot be coerced. | 48 | TrajectoryConfigError: Unknown keys or values that cannot be coerced. |
| 45 | """ | 49 | """ |
| 50 | if isinstance(raw_config, TrajectoryConfig): | ||
| 51 | return raw_config.model_dump() | ||
| 46 | logger.debug("Normalizing trajectory config keys=%s", sorted(raw_config)) | 52 | logger.debug("Normalizing trajectory config keys=%s", sorted(raw_config)) |
| 47 | return config_loader.validate_config( | 53 | return config_loader.validate_config( |
| 48 | TrajectoryConfig, | 54 | TrajectoryConfig, |
| 49 | raw_config, | 55 | raw_config, |
| 8 | 8 | ||
| 9 | sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) | 9 | sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) |
| 10 | 10 | ||
| 11 | from iolabs.common import config_loader # noqa: E402 | 11 | from iolabs.common import config_loader # noqa: E402 |
| 12 | from iolabs_point_cloud_trajectory_detect import _config # noqa: E402 | ||
| 12 | from iolabs_point_cloud_trajectory_detect._config import ( # noqa: E402 | 13 | from iolabs_point_cloud_trajectory_detect._config import ( # noqa: E402 |
| 14 | TrajectoryConfig, | ||
| 13 | TrajectoryConfigError, | 15 | TrajectoryConfigError, |
| 14 | build_trajectory_config, | 16 | build_trajectory_config, |
| 15 | load_trajectory_config, | 17 | load_trajectory_config, |
| 16 | normalize_trajectory_config, | 18 | normalize_trajectory_config, |
| 29 | "trajectory_outlier_removal_search_radius": 1.0, | 31 | "trajectory_outlier_removal_search_radius": 1.0, |
| 30 | } | 32 | } |
| 31 | 33 | ||
| 32 | 34 | ||
| 35 | def _packaged_defaults() -> dict: | ||
| 36 | path = Path(_config.__file__).with_name(_config._DEFAULT_FILENAME) | ||
| 37 | return json.loads(path.read_text(encoding="utf-8")) | ||
| 38 | |||
| 39 | |||
| 40 | def test_packaged_json_matches_model_field_defaults() -> None: | ||
| 41 | packaged = _packaged_defaults() | ||
| 42 | model_defaults = { | ||
| 43 | name: field.get_default(call_default_factory=True) | ||
| 44 | for name, field in TrajectoryConfig.model_fields.items() | ||
| 45 | } | ||
| 46 | assert packaged == model_defaults | ||
| 47 | |||
| 48 | |||
| 49 | def test_normalize_accepts_a_validated_model() -> None: | ||
| 50 | model = TrajectoryConfig(**_EXPECTED_DEFAULTS) | ||
| 51 | assert normalize_trajectory_config(model) == _EXPECTED_DEFAULTS | ||
| 52 | |||
| 53 | |||
| 33 | def test_error_class_is_config_error() -> None: | 54 | def test_error_class_is_config_error() -> None: |
| 34 | assert issubclass(TrajectoryConfigError, config_loader.ConfigError) | 55 | assert issubclass(TrajectoryConfigError, config_loader.ConfigError) |
| 35 | assert issubclass(TrajectoryConfigError, ValueError) | 56 | assert issubclass(TrajectoryConfigError, ValueError) |
| 36 | 57 |
normalize_trajectory_configaccepts a validated model; JSON/model parity test.