Miroslav Simko <ms@iolabs.ch> 2026-09-02T09:37:29+02:00
Commit #55 · 13 snippets
README.md | 15 ++- src/iolabs_point_cloud_surface_mesh/__init__.py | 22 ++++ src/iolabs_point_cloud_surface_mesh/_config.py | 72 +++++++--- tests/test_config.py | 168 ++++++++++++++++++++++++ tests/test_surface_mesh_config.py | 126 ------------------ 5 files changed, 253 insertions(+), 150 deletions(-)
| 1 | """Strict config loading and validation for the surface mesh builder. | 1 | """Packaged-default configuration for the edge-bounded pavement surface mesh. |
| 2 | 2 | ||
| 3 | The schema is a pydantic model tree derived from | 3 | The schema is `SurfaceMeshConfig` (a `config_loader.ConfigModel`), mirroring |
| 4 | ``iolabs.common.config_loader.ConfigModel``. Packaged defaults live in | 4 | `surface_mesh.default.json` key for key. |
| 5 | ``surface_mesh.default.json``; ``load_config`` / ``validate_config`` handle | 5 | |
| 6 | JSON loading, deep-merge, unknown-key rejection and scalar coercion. | 6 | Adding a config key means adding the field to the model and the same key to |
| 7 | `surface_mesh.default.json` — nothing else. Unknown keys are rejected. | ||
| 8 | |||
| 9 | The entry points return a plain `dict[str, Any]`; the `surface_mesh_parameters` | ||
| 10 | section is bridged onto the mutable runtime dataclass | ||
| 11 | `params.SurfaceMeshParameters` by :func:`surface_mesh_parameters_from_config`. | ||
| 7 | """ | 12 | """ |
| 8 | 13 | ||
| 14 | from __future__ import annotations | ||
| 15 | |||
| 16 | import logging | ||
| 17 | from collections.abc import Mapping | ||
| 9 | from pathlib import Path | 18 | from pathlib import Path |
| 10 | from typing import Any | 19 | from typing import Any |
| 11 | 20 | ||
| 12 | import pydantic | 21 | import pydantic |
| 13 | from iolabs.common import config_loader, segment_points_io | 22 | from iolabs.common import config_loader, segment_points_io |
| 14 | 23 | ||
| 15 | from . import params | 24 | from . import params |
| 16 | 25 | ||
| 17 | _DEFAULT_CONFIG_FILENAME = "surface_mesh.default.json" | 26 | logger = logging.getLogger(__name__) |
| 27 | |||
| 18 | _PACKAGE_NAME = "iolabs_point_cloud_surface_mesh" | 28 | _PACKAGE_NAME = "iolabs_point_cloud_surface_mesh" |
| 19 | _CONFIG_CONTEXT = "surface mesh config" | 29 | _DEFAULT_FILENAME = "surface_mesh.default.json" |
| 30 | _CONTEXT = "surface mesh config" | ||
| 20 | 31 | ||
| 21 | 32 | ||
| 22 | class SurfaceMeshConfigError(config_loader.ConfigError): | 33 | class SurfaceMeshConfigError(config_loader.ConfigError): |
| 23 | """Raised when a surface mesh config contains unsupported keys or values.""" | 34 | """Raised when surface mesh config contains unsupported keys or values.""" |
| 24 | 35 | ||
| 25 | 36 | ||
| 26 | class SurfaceMeshFileNamingConfig(config_loader.ConfigModel): | 37 | class SurfaceMeshFileNamingConfig(config_loader.ConfigModel): |
| 27 | """Output and input filename stems for one surface-mesh run.""" | 38 | """Output and input filename stems for one surface-mesh run.""" |
| 107 | Raises: | 127 | Raises: |
| 108 | SurfaceMeshConfigError: If the config contains unknown keys at any | 128 | SurfaceMeshConfigError: If the config contains unknown keys at any |
| 109 | level or a section has the wrong type. | 129 | level or a section has the wrong type. |
| 110 | """ | 130 | """ |
| 111 | if not isinstance(raw_config, dict): | ||
| 112 | raise SurfaceMeshConfigError("surface mesh config must be a mapping") | ||
| 113 | return config_loader.validate_config( | 131 | return config_loader.validate_config( |
| 114 | SurfaceMeshConfig, | 132 | SurfaceMeshConfig, |
| 115 | raw_config, | 133 | raw_config, |
| 116 | context=_CONFIG_CONTEXT, | 134 | context=_CONTEXT, |
| 117 | error_cls=SurfaceMeshConfigError, | 135 | error_cls=SurfaceMeshConfigError, |
| 118 | ).model_dump() | 136 | ).model_dump() |
| 119 | 137 | ||
| 120 | 138 | ||
| 121 | def surface_mesh_parameters_from_config(config: dict[str, Any]) -> params.SurfaceMeshParameters: | 139 | def surface_mesh_parameters_from_config( |
| 140 | config: Mapping[str, Any], | ||
| 141 | ) -> params.SurfaceMeshParameters: | ||
| 122 | """Builds the parameters dataclass from a normalized config. | 142 | """Builds the parameters dataclass from a normalized config. |
| 123 | 143 | ||
| 124 | Args: | 144 | Args: |
| 125 | config: A config dict returned by :func:`normalize_surface_mesh_config`. | 145 | config: A config dict returned by :func:`normalize_surface_mesh_config`. |
| 126 | 146 | ||
| 127 | Returns: | 147 | Returns: |
| 128 | The ``surface_mesh_parameters`` section as a dataclass instance. | 148 | The ``surface_mesh_parameters`` section as a dataclass instance. |
| 149 | |||
| 150 | Raises: | ||
| 151 | SurfaceMeshConfigError: If the section is missing or invalid. | ||
| 129 | """ | 152 | """ |
| 130 | return params.SurfaceMeshParameters(**config["surface_mesh_parameters"]) | 153 | section = config.get("surface_mesh_parameters") |
| 154 | return _to_runtime( | ||
| 155 | config_loader.validate_config( | ||
| 156 | SurfaceMeshParametersConfig, | ||
| 157 | {} if section is None else section, | ||
| 158 | context=f"{_CONTEXT} surface_mesh_parameters", | ||
| 159 | error_cls=SurfaceMeshConfigError, | ||
| 160 | ) | ||
| 161 | ) | ||
| 131 | 162 | ||
| 132 | 163 | ||
| 133 | def load_surface_mesh_config(config_path: str | Path | None = None) -> dict[str, Any]: | 164 | def load_surface_mesh_config(config_path: str | Path | None = None) -> dict[str, Any]: |
| 134 | """Loads and normalizes a config JSON. | 165 | """Loads and normalizes a config JSON. |
| 135 | 166 | ||
| 136 | Args: | 167 | Args: |
| 137 | config_path: Path to a config JSON file. When None, the packaged | 168 | config_path: Path to a config JSON file that REPLACES the packaged |
| 138 | default ``surface_mesh.default.json`` is used. | 169 | defaults. When None, the packaged ``surface_mesh.default.json`` |
| 170 | is used. | ||
| 139 | 171 | ||
| 140 | Returns: | 172 | Returns: |
| 141 | The normalized config dict. | 173 | The normalized config dict. |
| 142 | """ | 174 | """ |
| 144 | 176 | ||
| 145 | 177 | ||
| 146 | def build_surface_mesh_config( | 178 | def build_surface_mesh_config( |
| 147 | *, | 179 | *, |
| 148 | overrides: dict[str, Any] | None = None, | 180 | overrides: Mapping[str, Any] | None = None, |
| 149 | config_path: str | Path | None = None, | 181 | config_path: str | Path | None = None, |
| 150 | ) -> dict[str, Any]: | 182 | ) -> dict[str, Any]: |
| 151 | """Deep-merges overrides onto the defaults and re-normalizes. | 183 | """Deep-merges overrides onto the defaults and re-normalizes. |
| 152 | 184 |
| 79 | 90 | ||
| 80 | 91 | ||
| 81 | def _load_model( | 92 | def _load_model( |
| 82 | *, | 93 | *, |
| 83 | overrides: dict[str, Any] | None = None, | 94 | overrides: Mapping[str, Any] | None = None, |
| 84 | config_path: str | Path | None = None, | 95 | config_path: str | Path | None = None, |
| 85 | ) -> SurfaceMeshConfig: | 96 | ) -> SurfaceMeshConfig: |
| 86 | """Load packaged (or file) defaults, merge overrides, and validate.""" | 97 | """Load packaged (or file) defaults, merge overrides, and validate.""" |
| 98 | if config_path is not None: | ||
| 99 | logger.info("Config file applied: %s", config_path) | ||
| 100 | if overrides: | ||
| 101 | logger.info("Config overrides applied: %s", ", ".join(sorted(overrides))) | ||
| 87 | return config_loader.load_config( | 102 | return config_loader.load_config( |
| 88 | SurfaceMeshConfig, | 103 | SurfaceMeshConfig, |
| 89 | package=_PACKAGE_NAME, | 104 | package=_PACKAGE_NAME, |
| 90 | filename=_DEFAULT_CONFIG_FILENAME, | 105 | filename=_DEFAULT_FILENAME, |
| 91 | overrides=overrides, | 106 | overrides=overrides, |
| 92 | config_path=config_path, | 107 | config_path=config_path, |
| 93 | context=_CONFIG_CONTEXT, | 108 | context=_CONTEXT, |
| 94 | error_cls=SurfaceMeshConfigError, | 109 | error_cls=SurfaceMeshConfigError, |
| 95 | ) | 110 | ) |
| 96 | 111 | ||
| 97 | 112 | ||
| 98 | def normalize_surface_mesh_config(raw_config: dict[str, Any]) -> dict[str, Any]: | 113 | def _to_runtime(model: SurfaceMeshParametersConfig) -> params.SurfaceMeshParameters: |
| 114 | """Copies every model field onto the mutable runtime dataclass.""" | ||
| 115 | return params.SurfaceMeshParameters(**model.model_dump()) | ||
| 116 | |||
| 117 | |||
| 118 | def normalize_surface_mesh_config(raw_config: Mapping[str, Any]) -> dict[str, Any]: | ||
| 99 | """Validates a config mapping strictly and fills in every default. | 119 | """Validates a config mapping strictly and fills in every default. |
| 100 | 120 | ||
| 101 | Args: | 121 | Args: |
| 102 | raw_config: Raw (possibly partial) config mapping. | 122 | raw_config: Raw (possibly partial) config mapping. |
| 1 | """Edge-bounded pavement surface mesh built from asphalt-edge polylines and LIDAR points.""" | 1 | """Edge-bounded pavement surface mesh built from asphalt-edge polylines and LIDAR points.""" |
| 2 | |||
| 3 | from ._config import ( | ||
| 4 | SurfaceMeshConfig, | ||
| 5 | SurfaceMeshConfigError, | ||
| 6 | SurfaceMeshFileNamingConfig, | ||
| 7 | SurfaceMeshParametersConfig, | ||
| 8 | build_surface_mesh_config, | ||
| 9 | load_surface_mesh_config, | ||
| 10 | normalize_surface_mesh_config, | ||
| 11 | surface_mesh_parameters_from_config, | ||
| 12 | ) | ||
| 13 | |||
| 14 | __all__ = [ | ||
| 15 | "SurfaceMeshConfig", | ||
| 16 | "SurfaceMeshConfigError", | ||
| 17 | "SurfaceMeshFileNamingConfig", | ||
| 18 | "SurfaceMeshParametersConfig", | ||
| 19 | "build_surface_mesh_config", | ||
| 20 | "load_surface_mesh_config", | ||
| 21 | "normalize_surface_mesh_config", | ||
| 22 | "surface_mesh_parameters_from_config", | ||
| 23 | ] |
| 1 | import dataclasses | ||
| 2 | import json | ||
| 3 | |||
| 4 | import pytest | ||
| 5 | from iolabs.common import config_loader | ||
| 6 | |||
| 7 | from iolabs_point_cloud_surface_mesh import params | ||
| 8 | from iolabs_point_cloud_surface_mesh._config import ( | ||
| 9 | SurfaceMeshConfig, | ||
| 10 | SurfaceMeshConfigError, | ||
| 11 | SurfaceMeshParametersConfig, | ||
| 12 | build_surface_mesh_config, | ||
| 13 | load_surface_mesh_config, | ||
| 14 | normalize_surface_mesh_config, | ||
| 15 | surface_mesh_parameters_from_config, | ||
| 16 | ) | ||
| 17 | |||
| 18 | _PACKAGED_JSON = ( | ||
| 19 | config_loader.default_config_path( | ||
| 20 | "iolabs_point_cloud_surface_mesh", "surface_mesh.default.json" | ||
| 21 | ) | ||
| 22 | ) | ||
| 23 | |||
| 24 | |||
| 25 | def test_model_defaults_match_packaged_json(): | ||
| 26 | packaged = json.loads(_PACKAGED_JSON.read_text(encoding="utf-8")) | ||
| 27 | assert SurfaceMeshConfig().model_dump() == packaged | ||
| 28 | |||
| 29 | |||
| 30 | def test_load_surface_mesh_config_returns_packaged_defaults(): | ||
| 31 | packaged = json.loads(_PACKAGED_JSON.read_text(encoding="utf-8")) | ||
| 32 | assert load_surface_mesh_config() == packaged | ||
| 33 | |||
| 34 | |||
| 35 | def test_error_class_is_config_error(): | ||
| 36 | assert issubclass(SurfaceMeshConfigError, config_loader.ConfigError) | ||
| 37 | assert issubclass(SurfaceMeshConfigError, ValueError) | ||
| 38 | |||
| 39 | |||
| 40 | def test_unknown_top_level_key_is_rejected(): | ||
| 41 | with pytest.raises(SurfaceMeshConfigError, match="unknown_key"): | ||
| 42 | normalize_surface_mesh_config({"unknown_key": 1}) | ||
| 43 | |||
| 44 | |||
| 45 | def test_unknown_nested_key_is_rejected(): | ||
| 46 | with pytest.raises(SurfaceMeshConfigError, match="bogus"): | ||
| 47 | normalize_surface_mesh_config({"surface_mesh_parameters": {"bogus": 1}}) | ||
| 48 | |||
| 49 | |||
| 50 | def test_overrides_deep_merge_onto_defaults(): | ||
| 51 | config = build_surface_mesh_config( | ||
| 52 | overrides={"surface_mesh_parameters": {"cells_across": 7}} | ||
| 53 | ) | ||
| 54 | assert config["surface_mesh_parameters"]["cells_across"] == 7 | ||
| 55 | assert config["surface_mesh_parameters"]["row_spacing_m"] == 3.0 | ||
| 56 | assert config["file_naming"]["segment_points_suffix"] == "_run3_points" | ||
| 57 | |||
| 58 | |||
| 59 | def test_set_override_coercion_and_rejection(): | ||
| 60 | overrides = config_loader.parse_set_overrides( | ||
| 61 | [ | ||
| 62 | "surface_mesh_parameters.min_points_per_cell=1e3", | ||
| 63 | "surface_mesh_parameters.save_diagnostics=on", | ||
| 64 | ], | ||
| 65 | error_cls=SurfaceMeshConfigError, | ||
| 66 | nested=True, | ||
| 67 | ) | ||
| 68 | config = build_surface_mesh_config(overrides=overrides) | ||
| 69 | assert config["surface_mesh_parameters"]["min_points_per_cell"] == 1000 | ||
| 70 | assert config["surface_mesh_parameters"]["save_diagnostics"] is True | ||
| 71 | with pytest.raises(SurfaceMeshConfigError): | ||
| 72 | build_surface_mesh_config( | ||
| 73 | overrides=config_loader.parse_set_overrides( | ||
| 74 | ["surface_mesh_parameters.save_diagnostics=flase"], | ||
| 75 | error_cls=SurfaceMeshConfigError, | ||
| 76 | nested=True, | ||
| 77 | ) | ||
| 78 | ) | ||
| 79 | |||
| 80 | |||
| 81 | def test_model_mirrors_runtime_dataclass(): | ||
| 82 | """Every model field must exist on the runtime dataclass, and vice versa.""" | ||
| 83 | model_keys = set(SurfaceMeshParametersConfig.model_fields) | ||
| 84 | dataclass_keys = {field.name for field in dataclasses.fields(params.SurfaceMeshParameters)} | ||
| 85 | assert model_keys == dataclass_keys | ||
| 86 | assert surface_mesh_parameters_from_config(load_surface_mesh_config()) == ( | ||
| 87 | params.SurfaceMeshParameters() | ||
| 88 | ) | ||
| 89 | |||
| 90 | |||
| 91 | def test_load_default_config_fills_all_defaults(): | ||
| 92 | config = load_surface_mesh_config() | ||
| 93 | assert config["file_naming"]["segment_points_suffix"] == "_run3_points" | ||
| 94 | assert config["file_naming"]["versions_json_name"] == "surface_mesh_versions.json" | ||
| 95 | assert config["surface_mesh_parameters"]["cells_across"] == 5 | ||
| 96 | assert config["surface_mesh_parameters"]["row_spacing_m"] == 3.0 | ||
| 97 | assert config["npz_blacklist_by_segment"] == {} | ||
| 98 | |||
| 99 | |||
| 100 | def test_normalize_rejects_unknown_file_naming_keys(): | ||
| 101 | with pytest.raises(SurfaceMeshConfigError, match="file_naming"): | ||
| 102 | normalize_surface_mesh_config({"file_naming": {"road_surface_suffix": "_x"}}) | ||
| 103 | |||
| 104 | |||
| 105 | def test_build_config_coerces_string_overrides(): | ||
| 106 | config = build_surface_mesh_config( | ||
| 107 | overrides={ | ||
| 108 | "surface_mesh_parameters": { | ||
| 109 | "row_spacing_m": "4.5", | ||
| 110 | "save_diagnostics": "yes", | ||
| 111 | } | ||
| 112 | } | ||
| 113 | ) | ||
| 114 | assert config["surface_mesh_parameters"]["row_spacing_m"] == 4.5 | ||
| 115 | assert config["surface_mesh_parameters"]["save_diagnostics"] is True | ||
| 116 | |||
| 117 | |||
| 118 | def test_build_config_rejects_unknown_override_keys(): | ||
| 119 | with pytest.raises(SurfaceMeshConfigError): | ||
| 120 | build_surface_mesh_config(overrides={"surface_parameters": {}}) | ||
| 121 | |||
| 122 | |||
| 123 | def test_blacklist_normalization_and_error_wrapping(): | ||
| 124 | config = normalize_surface_mesh_config( | ||
| 125 | {"npz_blacklist_by_segment": {"segment_007": "*Record0021*", "3": ["*bad*"]}} | ||
| 126 | ) | ||
| 127 | assert config["npz_blacklist_by_segment"] == { | ||
| 128 | 3: ["*bad*"], | ||
| 129 | 7: ["*Record0021*"], | ||
| 130 | } | ||
| 131 | with pytest.raises(SurfaceMeshConfigError): | ||
| 132 | normalize_surface_mesh_config({"npz_blacklist_by_segment": {"seg7": "*x*"}}) | ||
| 133 | |||
| 134 | |||
| 135 | def test_load_explicit_config_path(tmp_path): | ||
| 136 | config_path = tmp_path / "override.json" | ||
| 137 | config_path.write_text( | ||
| 138 | json.dumps({"surface_mesh_parameters": {"device": "CPU:0"}}), encoding="utf-8" | ||
| 139 | ) | ||
| 140 | config = load_surface_mesh_config(config_path) | ||
| 141 | assert config["surface_mesh_parameters"]["device"] == "CPU:0" | ||
| 142 | assert config["surface_mesh_parameters"]["cells_across"] == 5 | ||
| 143 | |||
| 144 | |||
| 145 | @pytest.mark.parametrize( | ||
| 146 | ("key", "value"), | ||
| 147 | [ | ||
| 148 | ("cells_across", 0), | ||
| 149 | ("cells_across", 2.5), | ||
| 150 | ("row_spacing_m", "abc"), | ||
| 151 | ("row_spacing_m", 0), | ||
| 152 | ("min_points_per_cell", 1), | ||
| 153 | ("min_measured_edge_fraction", 1.5), | ||
| 154 | ("z_trim_mad_factor", -1), | ||
| 155 | ("save_diagnostics", "maybe"), | ||
| 156 | ("device", ""), | ||
| 157 | ], | ||
| 158 | ) | ||
| 159 | def test_normalize_rejects_invalid_parameter_values(key, value): | ||
| 160 | with pytest.raises(SurfaceMeshConfigError, match=key): | ||
| 161 | normalize_surface_mesh_config({"surface_mesh_parameters": {key: value}}) | ||
| 162 | |||
| 163 | |||
| 164 | def test_normalize_treats_null_sections_as_defaults(): | ||
| 165 | config = normalize_surface_mesh_config( | ||
| 166 | {"file_naming": None, "surface_mesh_parameters": None, "npz_blacklist_by_segment": None} | ||
| 167 | ) | ||
| 168 | assert config == load_surface_mesh_config() | ||
| 0 |
| 29 | | `params` / `_config` | `SurfaceMeshParameters` algorithm object + pydantic `SurfaceMeshConfig` (`surface_mesh.default.json`), loaded via `iolabs.common.config_loader`. | | 29 | | `params` / `_config` | `SurfaceMeshParameters` algorithm object + pydantic `SurfaceMeshConfig` (`surface_mesh.default.json`), loaded via `iolabs.common.config_loader`. | |
| 30 | 30 | ||
| 31 | ## Configuration | 31 | ## Configuration |
| 32 | 32 | ||
| 33 | Packaged defaults in `surface_mesh.default.json` are validated by the pydantic | 33 | Defaults live in `src/iolabs_point_cloud_surface_mesh/surface_mesh.default.json`. |
| 34 | `SurfaceMeshConfig` tree; unknown keys raise `SurfaceMeshConfigError`. To add a | 34 | The schema is `SurfaceMeshConfig` in `iolabs_point_cloud_surface_mesh._config` |
| 35 | key, add the field to the model, the JSON default, and (for tunables) | 35 | (a `config_loader.ConfigModel`); nested JSON sections are nested models and |
| 36 | `params.SurfaceMeshParameters`. Key tunables: | 36 | unknown keys are rejected (`SurfaceMeshConfigError`). **To add a config key: add |
| 37 | the field (with its type, default and any `Field` range) to the model and the | ||
| 38 | same key with the same default to the JSON — nothing else.** Tunables under | ||
| 39 | `surface_mesh_parameters` are additionally mirrored on the mutable runtime | ||
| 40 | dataclass `params.SurfaceMeshParameters` (a test asserts the field sets match). | ||
| 41 | `load_surface_mesh_config` / `build_surface_mesh_config` / | ||
| 42 | `normalize_surface_mesh_config` return a plain `dict`. Runtime overrides come | ||
| 43 | from repeatable `--set KEY=VALUE`, never repo-local JSON. Key tunables: | ||
| 37 | `cells_across` (5), `row_spacing_m` (3.0), `device` ("CUDA:0"), | 44 | `cells_across` (5), `row_spacing_m` (3.0), `device` ("CUDA:0"), |
| 38 | `min_points_per_cell`, `z_trim_mad_factor`, `max_extrapolation_m`, | 45 | `min_points_per_cell`, `z_trim_mad_factor`, `max_extrapolation_m`, |
| 39 | `min_measured_edge_fraction`, `edge_smoothing_window_m`. File naming lives | 46 | `min_measured_edge_fraction`, `edge_smoothing_window_m`. File naming lives |
| 40 | under `file_naming`; per-segment NPZ exclusion under | 47 | under `file_naming`; per-segment NPZ exclusion under |
| 1 | """Edge-bounded pavement surface mesh built from asphalt-edge polylines and LIDAR points.""" | 1 | """Edge-bounded pavement surface mesh built from asphalt-edge polylines and LIDAR points.""" |
| 2 | |||
| 3 | from ._config import ( | ||
| 4 | SurfaceMeshConfig, | ||
| 5 | SurfaceMeshConfigError, | ||
| 6 | SurfaceMeshFileNamingConfig, | ||
| 7 | SurfaceMeshParametersConfig, | ||
| 8 | build_surface_mesh_config, | ||
| 9 | load_surface_mesh_config, | ||
| 10 | normalize_surface_mesh_config, | ||
| 11 | surface_mesh_parameters_from_config, | ||
| 12 | ) | ||
| 13 | |||
| 14 | __all__ = [ | ||
| 15 | "SurfaceMeshConfig", | ||
| 16 | "SurfaceMeshConfigError", | ||
| 17 | "SurfaceMeshFileNamingConfig", | ||
| 18 | "SurfaceMeshParametersConfig", | ||
| 19 | "build_surface_mesh_config", | ||
| 20 | "load_surface_mesh_config", | ||
| 21 | "normalize_surface_mesh_config", | ||
| 22 | "surface_mesh_parameters_from_config", | ||
| 23 | ] |
| 1 | """Strict config loading and validation for the surface mesh builder. | 1 | """Packaged-default configuration for the edge-bounded pavement surface mesh. |
| 2 | 2 | ||
| 3 | The schema is a pydantic model tree derived from | 3 | The schema is `SurfaceMeshConfig` (a `config_loader.ConfigModel`), mirroring |
| 4 | ``iolabs.common.config_loader.ConfigModel``. Packaged defaults live in | 4 | `surface_mesh.default.json` key for key. |
| 5 | ``surface_mesh.default.json``; ``load_config`` / ``validate_config`` handle | 5 | |
| 6 | JSON loading, deep-merge, unknown-key rejection and scalar coercion. | 6 | Adding a config key means adding the field to the model and the same key to |
| 7 | `surface_mesh.default.json` — nothing else. Unknown keys are rejected. | ||
| 8 | |||
| 9 | The entry points return a plain `dict[str, Any]`; the `surface_mesh_parameters` | ||
| 10 | section is bridged onto the mutable runtime dataclass | ||
| 11 | `params.SurfaceMeshParameters` by :func:`surface_mesh_parameters_from_config`. | ||
| 7 | """ | 12 | """ |
| 8 | 13 | ||
| 14 | from __future__ import annotations | ||
| 15 | |||
| 16 | import logging | ||
| 17 | from collections.abc import Mapping | ||
| 9 | from pathlib import Path | 18 | from pathlib import Path |
| 10 | from typing import Any | 19 | from typing import Any |
| 11 | 20 | ||
| 12 | import pydantic | 21 | import pydantic |
| 13 | from iolabs.common import config_loader, segment_points_io | 22 | from iolabs.common import config_loader, segment_points_io |
| 14 | 23 | ||
| 15 | from . import params | 24 | from . import params |
| 16 | 25 | ||
| 17 | _DEFAULT_CONFIG_FILENAME = "surface_mesh.default.json" | 26 | logger = logging.getLogger(__name__) |
| 27 | |||
| 18 | _PACKAGE_NAME = "iolabs_point_cloud_surface_mesh" | 28 | _PACKAGE_NAME = "iolabs_point_cloud_surface_mesh" |
| 19 | _CONFIG_CONTEXT = "surface mesh config" | 29 | _DEFAULT_FILENAME = "surface_mesh.default.json" |
| 30 | _CONTEXT = "surface mesh config" | ||
| 20 | 31 | ||
| 21 | 32 | ||
| 22 | class SurfaceMeshConfigError(config_loader.ConfigError): | 33 | class SurfaceMeshConfigError(config_loader.ConfigError): |
| 23 | """Raised when a surface mesh config contains unsupported keys or values.""" | 34 | """Raised when surface mesh config contains unsupported keys or values.""" |
| 24 | 35 | ||
| 25 | 36 | ||
| 26 | class SurfaceMeshFileNamingConfig(config_loader.ConfigModel): | 37 | class SurfaceMeshFileNamingConfig(config_loader.ConfigModel): |
| 27 | """Output and input filename stems for one surface-mesh run.""" | 38 | """Output and input filename stems for one surface-mesh run.""" |
| 79 | 90 | ||
| 80 | 91 | ||
| 81 | def _load_model( | 92 | def _load_model( |
| 82 | *, | 93 | *, |
| 83 | overrides: dict[str, Any] | None = None, | 94 | overrides: Mapping[str, Any] | None = None, |
| 84 | config_path: str | Path | None = None, | 95 | config_path: str | Path | None = None, |
| 85 | ) -> SurfaceMeshConfig: | 96 | ) -> SurfaceMeshConfig: |
| 86 | """Load packaged (or file) defaults, merge overrides, and validate.""" | 97 | """Load packaged (or file) defaults, merge overrides, and validate.""" |
| 98 | if config_path is not None: | ||
| 99 | logger.info("Config file applied: %s", config_path) | ||
| 100 | if overrides: | ||
| 101 | logger.info("Config overrides applied: %s", ", ".join(sorted(overrides))) | ||
| 87 | return config_loader.load_config( | 102 | return config_loader.load_config( |
| 88 | SurfaceMeshConfig, | 103 | SurfaceMeshConfig, |
| 89 | package=_PACKAGE_NAME, | 104 | package=_PACKAGE_NAME, |
| 90 | filename=_DEFAULT_CONFIG_FILENAME, | 105 | filename=_DEFAULT_FILENAME, |
| 91 | overrides=overrides, | 106 | overrides=overrides, |
| 92 | config_path=config_path, | 107 | config_path=config_path, |
| 93 | context=_CONFIG_CONTEXT, | 108 | context=_CONTEXT, |
| 94 | error_cls=SurfaceMeshConfigError, | 109 | error_cls=SurfaceMeshConfigError, |
| 95 | ) | 110 | ) |
| 96 | 111 | ||
| 97 | 112 | ||
| 98 | def normalize_surface_mesh_config(raw_config: dict[str, Any]) -> dict[str, Any]: | 113 | def _to_runtime(model: SurfaceMeshParametersConfig) -> params.SurfaceMeshParameters: |
| 114 | """Copies every model field onto the mutable runtime dataclass.""" | ||
| 115 | return params.SurfaceMeshParameters(**model.model_dump()) | ||
| 116 | |||
| 117 | |||
| 118 | def normalize_surface_mesh_config(raw_config: Mapping[str, Any]) -> dict[str, Any]: | ||
| 99 | """Validates a config mapping strictly and fills in every default. | 119 | """Validates a config mapping strictly and fills in every default. |
| 100 | 120 | ||
| 101 | Args: | 121 | Args: |
| 102 | raw_config: Raw (possibly partial) config mapping. | 122 | raw_config: Raw (possibly partial) config mapping. |
| 107 | Raises: | 127 | Raises: |
| 108 | SurfaceMeshConfigError: If the config contains unknown keys at any | 128 | SurfaceMeshConfigError: If the config contains unknown keys at any |
| 109 | level or a section has the wrong type. | 129 | level or a section has the wrong type. |
| 110 | """ | 130 | """ |
| 111 | if not isinstance(raw_config, dict): | ||
| 112 | raise SurfaceMeshConfigError("surface mesh config must be a mapping") | ||
| 113 | return config_loader.validate_config( | 131 | return config_loader.validate_config( |
| 114 | SurfaceMeshConfig, | 132 | SurfaceMeshConfig, |
| 115 | raw_config, | 133 | raw_config, |
| 116 | context=_CONFIG_CONTEXT, | 134 | context=_CONTEXT, |
| 117 | error_cls=SurfaceMeshConfigError, | 135 | error_cls=SurfaceMeshConfigError, |
| 118 | ).model_dump() | 136 | ).model_dump() |
| 119 | 137 | ||
| 120 | 138 | ||
| 121 | def surface_mesh_parameters_from_config(config: dict[str, Any]) -> params.SurfaceMeshParameters: | 139 | def surface_mesh_parameters_from_config( |
| 140 | config: Mapping[str, Any], | ||
| 141 | ) -> params.SurfaceMeshParameters: | ||
| 122 | """Builds the parameters dataclass from a normalized config. | 142 | """Builds the parameters dataclass from a normalized config. |
| 123 | 143 | ||
| 124 | Args: | 144 | Args: |
| 125 | config: A config dict returned by :func:`normalize_surface_mesh_config`. | 145 | config: A config dict returned by :func:`normalize_surface_mesh_config`. |
| 126 | 146 | ||
| 127 | Returns: | 147 | Returns: |
| 128 | The ``surface_mesh_parameters`` section as a dataclass instance. | 148 | The ``surface_mesh_parameters`` section as a dataclass instance. |
| 149 | |||
| 150 | Raises: | ||
| 151 | SurfaceMeshConfigError: If the section is missing or invalid. | ||
| 129 | """ | 152 | """ |
| 130 | return params.SurfaceMeshParameters(**config["surface_mesh_parameters"]) | 153 | section = config.get("surface_mesh_parameters") |
| 154 | return _to_runtime( | ||
| 155 | config_loader.validate_config( | ||
| 156 | SurfaceMeshParametersConfig, | ||
| 157 | {} if section is None else section, | ||
| 158 | context=f"{_CONTEXT} surface_mesh_parameters", | ||
| 159 | error_cls=SurfaceMeshConfigError, | ||
| 160 | ) | ||
| 161 | ) | ||
| 131 | 162 | ||
| 132 | 163 | ||
| 133 | def load_surface_mesh_config(config_path: str | Path | None = None) -> dict[str, Any]: | 164 | def load_surface_mesh_config(config_path: str | Path | None = None) -> dict[str, Any]: |
| 134 | """Loads and normalizes a config JSON. | 165 | """Loads and normalizes a config JSON. |
| 135 | 166 | ||
| 136 | Args: | 167 | Args: |
| 137 | config_path: Path to a config JSON file. When None, the packaged | 168 | config_path: Path to a config JSON file that REPLACES the packaged |
| 138 | default ``surface_mesh.default.json`` is used. | 169 | defaults. When None, the packaged ``surface_mesh.default.json`` |
| 170 | is used. | ||
| 139 | 171 | ||
| 140 | Returns: | 172 | Returns: |
| 141 | The normalized config dict. | 173 | The normalized config dict. |
| 142 | """ | 174 | """ |
| 144 | 176 | ||
| 145 | 177 | ||
| 146 | def build_surface_mesh_config( | 178 | def build_surface_mesh_config( |
| 147 | *, | 179 | *, |
| 148 | overrides: dict[str, Any] | None = None, | 180 | overrides: Mapping[str, Any] | None = None, |
| 149 | config_path: str | Path | None = None, | 181 | config_path: str | Path | None = None, |
| 150 | ) -> dict[str, Any]: | 182 | ) -> dict[str, Any]: |
| 151 | """Deep-merges overrides onto the defaults and re-normalizes. | 183 | """Deep-merges overrides onto the defaults and re-normalizes. |
| 152 | 184 |
| 1 | import dataclasses | ||
| 2 | import json | ||
| 3 | |||
| 4 | import pytest | ||
| 5 | from iolabs.common import config_loader | ||
| 6 | |||
| 7 | from iolabs_point_cloud_surface_mesh import params | ||
| 8 | from iolabs_point_cloud_surface_mesh._config import ( | ||
| 9 | SurfaceMeshConfig, | ||
| 10 | SurfaceMeshConfigError, | ||
| 11 | SurfaceMeshParametersConfig, | ||
| 12 | build_surface_mesh_config, | ||
| 13 | load_surface_mesh_config, | ||
| 14 | normalize_surface_mesh_config, | ||
| 15 | surface_mesh_parameters_from_config, | ||
| 16 | ) | ||
| 17 | |||
| 18 | _PACKAGED_JSON = ( | ||
| 19 | config_loader.default_config_path( | ||
| 20 | "iolabs_point_cloud_surface_mesh", "surface_mesh.default.json" | ||
| 21 | ) | ||
| 22 | ) | ||
| 23 | |||
| 24 | |||
| 25 | def test_model_defaults_match_packaged_json(): | ||
| 26 | packaged = json.loads(_PACKAGED_JSON.read_text(encoding="utf-8")) | ||
| 27 | assert SurfaceMeshConfig().model_dump() == packaged | ||
| 28 | |||
| 29 | |||
| 30 | def test_load_surface_mesh_config_returns_packaged_defaults(): | ||
| 31 | packaged = json.loads(_PACKAGED_JSON.read_text(encoding="utf-8")) | ||
| 32 | assert load_surface_mesh_config() == packaged | ||
| 33 | |||
| 34 | |||
| 35 | def test_error_class_is_config_error(): | ||
| 36 | assert issubclass(SurfaceMeshConfigError, config_loader.ConfigError) | ||
| 37 | assert issubclass(SurfaceMeshConfigError, ValueError) | ||
| 38 | |||
| 39 | |||
| 40 | def test_unknown_top_level_key_is_rejected(): | ||
| 41 | with pytest.raises(SurfaceMeshConfigError, match="unknown_key"): | ||
| 42 | normalize_surface_mesh_config({"unknown_key": 1}) | ||
| 43 | |||
| 44 | |||
| 45 | def test_unknown_nested_key_is_rejected(): | ||
| 46 | with pytest.raises(SurfaceMeshConfigError, match="bogus"): | ||
| 47 | normalize_surface_mesh_config({"surface_mesh_parameters": {"bogus": 1}}) | ||
| 48 | |||
| 49 | |||
| 50 | def test_overrides_deep_merge_onto_defaults(): | ||
| 51 | config = build_surface_mesh_config( | ||
| 52 | overrides={"surface_mesh_parameters": {"cells_across": 7}} | ||
| 53 | ) | ||
| 54 | assert config["surface_mesh_parameters"]["cells_across"] == 7 | ||
| 55 | assert config["surface_mesh_parameters"]["row_spacing_m"] == 3.0 | ||
| 56 | assert config["file_naming"]["segment_points_suffix"] == "_run3_points" | ||
| 57 | |||
| 58 | |||
| 59 | def test_set_override_coercion_and_rejection(): | ||
| 60 | overrides = config_loader.parse_set_overrides( | ||
| 61 | [ | ||
| 62 | "surface_mesh_parameters.min_points_per_cell=1e3", | ||
| 63 | "surface_mesh_parameters.save_diagnostics=on", | ||
| 64 | ], | ||
| 65 | error_cls=SurfaceMeshConfigError, | ||
| 66 | nested=True, | ||
| 67 | ) | ||
| 68 | config = build_surface_mesh_config(overrides=overrides) | ||
| 69 | assert config["surface_mesh_parameters"]["min_points_per_cell"] == 1000 | ||
| 70 | assert config["surface_mesh_parameters"]["save_diagnostics"] is True | ||
| 71 | with pytest.raises(SurfaceMeshConfigError): | ||
| 72 | build_surface_mesh_config( | ||
| 73 | overrides=config_loader.parse_set_overrides( | ||
| 74 | ["surface_mesh_parameters.save_diagnostics=flase"], | ||
| 75 | error_cls=SurfaceMeshConfigError, | ||
| 76 | nested=True, | ||
| 77 | ) | ||
| 78 | ) | ||
| 79 | |||
| 80 | |||
| 81 | def test_model_mirrors_runtime_dataclass(): | ||
| 82 | """Every model field must exist on the runtime dataclass, and vice versa.""" | ||
| 83 | model_keys = set(SurfaceMeshParametersConfig.model_fields) | ||
| 84 | dataclass_keys = {field.name for field in dataclasses.fields(params.SurfaceMeshParameters)} | ||
| 85 | assert model_keys == dataclass_keys | ||
| 86 | assert surface_mesh_parameters_from_config(load_surface_mesh_config()) == ( | ||
| 87 | params.SurfaceMeshParameters() | ||
| 88 | ) | ||
| 89 | |||
| 90 | |||
| 91 | def test_load_default_config_fills_all_defaults(): | ||
| 92 | config = load_surface_mesh_config() | ||
| 93 | assert config["file_naming"]["segment_points_suffix"] == "_run3_points" | ||
| 94 | assert config["file_naming"]["versions_json_name"] == "surface_mesh_versions.json" | ||
| 95 | assert config["surface_mesh_parameters"]["cells_across"] == 5 | ||
| 96 | assert config["surface_mesh_parameters"]["row_spacing_m"] == 3.0 | ||
| 97 | assert config["npz_blacklist_by_segment"] == {} | ||
| 98 | |||
| 99 | |||
| 100 | def test_normalize_rejects_unknown_file_naming_keys(): | ||
| 101 | with pytest.raises(SurfaceMeshConfigError, match="file_naming"): | ||
| 102 | normalize_surface_mesh_config({"file_naming": {"road_surface_suffix": "_x"}}) | ||
| 103 | |||
| 104 | |||
| 105 | def test_build_config_coerces_string_overrides(): | ||
| 106 | config = build_surface_mesh_config( | ||
| 107 | overrides={ | ||
| 108 | "surface_mesh_parameters": { | ||
| 109 | "row_spacing_m": "4.5", | ||
| 110 | "save_diagnostics": "yes", | ||
| 111 | } | ||
| 112 | } | ||
| 113 | ) | ||
| 114 | assert config["surface_mesh_parameters"]["row_spacing_m"] == 4.5 | ||
| 115 | assert config["surface_mesh_parameters"]["save_diagnostics"] is True | ||
| 116 | |||
| 117 | |||
| 118 | def test_build_config_rejects_unknown_override_keys(): | ||
| 119 | with pytest.raises(SurfaceMeshConfigError): | ||
| 120 | build_surface_mesh_config(overrides={"surface_parameters": {}}) | ||
| 121 | |||
| 122 | |||
| 123 | def test_blacklist_normalization_and_error_wrapping(): | ||
| 124 | config = normalize_surface_mesh_config( | ||
| 125 | {"npz_blacklist_by_segment": {"segment_007": "*Record0021*", "3": ["*bad*"]}} | ||
| 126 | ) | ||
| 127 | assert config["npz_blacklist_by_segment"] == { | ||
| 128 | 3: ["*bad*"], | ||
| 129 | 7: ["*Record0021*"], | ||
| 130 | } | ||
| 131 | with pytest.raises(SurfaceMeshConfigError): | ||
| 132 | normalize_surface_mesh_config({"npz_blacklist_by_segment": {"seg7": "*x*"}}) | ||
| 133 | |||
| 134 | |||
| 135 | def test_load_explicit_config_path(tmp_path): | ||
| 136 | config_path = tmp_path / "override.json" | ||
| 137 | config_path.write_text( | ||
| 138 | json.dumps({"surface_mesh_parameters": {"device": "CPU:0"}}), encoding="utf-8" | ||
| 139 | ) | ||
| 140 | config = load_surface_mesh_config(config_path) | ||
| 141 | assert config["surface_mesh_parameters"]["device"] == "CPU:0" | ||
| 142 | assert config["surface_mesh_parameters"]["cells_across"] == 5 | ||
| 143 | |||
| 144 | |||
| 145 | @pytest.mark.parametrize( | ||
| 146 | ("key", "value"), | ||
| 147 | [ | ||
| 148 | ("cells_across", 0), | ||
| 149 | ("cells_across", 2.5), | ||
| 150 | ("row_spacing_m", "abc"), | ||
| 151 | ("row_spacing_m", 0), | ||
| 152 | ("min_points_per_cell", 1), | ||
| 153 | ("min_measured_edge_fraction", 1.5), | ||
| 154 | ("z_trim_mad_factor", -1), | ||
| 155 | ("save_diagnostics", "maybe"), | ||
| 156 | ("device", ""), | ||
| 157 | ], | ||
| 158 | ) | ||
| 159 | def test_normalize_rejects_invalid_parameter_values(key, value): | ||
| 160 | with pytest.raises(SurfaceMeshConfigError, match=key): | ||
| 161 | normalize_surface_mesh_config({"surface_mesh_parameters": {key: value}}) | ||
| 162 | |||
| 163 | |||
| 164 | def test_normalize_treats_null_sections_as_defaults(): | ||
| 165 | config = normalize_surface_mesh_config( | ||
| 166 | {"file_naming": None, "surface_mesh_parameters": None, "npz_blacklist_by_segment": None} | ||
| 167 | ) | ||
| 168 | assert config == load_surface_mesh_config() | ||
| 0 |
<Name><Section>Config), module constants, keyword-only entry points, canonical test names, README config section. No behaviour change intended.