Back to report index

Step 9 filteringsurface 126c81b: AI3D-379 Align config module with fleet pattern

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(-)
Importance #1: src/iolabs_point_cloud_surface_mesh/_config.py @@ -1,27 +1,38 @@
1"""Strict config loading and validation for the surface mesh builder.1"""Packaged-default configuration for the edge-bounded pavement surface mesh.
22
3The schema is a pydantic model tree derived from3The schema is `SurfaceMeshConfig` (a `config_loader.ConfigModel`), mirroring
4``iolabs.common.config_loader.ConfigModel``. Packaged defaults live in4`surface_mesh.default.json` key for key.
5``surface_mesh.default.json``; ``load_config`` / ``validate_config`` handle5
6JSON loading, deep-merge, unknown-key rejection and scalar coercion.6Adding 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
9The entry points return a plain `dict[str, Any]`; the `surface_mesh_parameters`
10section is bridged onto the mutable runtime dataclass
11`params.SurfaceMeshParameters` by :func:`surface_mesh_parameters_from_config`.
7"""12"""
813
14from __future__ import annotations
15
16import logging
17from collections.abc import Mapping
9from pathlib import Path18from pathlib import Path
10from typing import Any19from typing import Any
1120
12import pydantic21import pydantic
13from iolabs.common import config_loader, segment_points_io22from iolabs.common import config_loader, segment_points_io
1423
15from . import params24from . import params
1625
17_DEFAULT_CONFIG_FILENAME = "surface_mesh.default.json"26logger = 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"
2031
2132
22class SurfaceMeshConfigError(config_loader.ConfigError):33class 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."""
2435
2536
26class SurfaceMeshFileNamingConfig(config_loader.ConfigModel):37class 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."""
Importance #2: src/iolabs_point_cloud_surface_mesh/_config.py @@ -107,36 +127,48 @@
107 Raises:127 Raises:
108 SurfaceMeshConfigError: If the config contains unknown keys at any128 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()
119137
120138
121def surface_mesh_parameters_from_config(config: dict[str, Any]) -> params.SurfaceMeshParameters:139def 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.
123143
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`.
126146
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 )
131162
132163
133def load_surface_mesh_config(config_path: str | Path | None = None) -> dict[str, Any]:164def 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.
135166
136 Args:167 Args:
137 config_path: Path to a config JSON file. When None, the packaged168 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.
139171
140 Returns:172 Returns:
141 The normalized config dict.173 The normalized config dict.
142 """174 """
Importance #3: src/iolabs_point_cloud_surface_mesh/_config.py @@ -144,9 +176,9 @@
144176
145177
146def build_surface_mesh_config(178def 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.
152184
Importance #4: src/iolabs_point_cloud_surface_mesh/_config.py @@ -79,24 +90,33 @@
7990
8091
81def _load_model(92def _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 )
96111
97112
98def normalize_surface_mesh_config(raw_config: dict[str, Any]) -> dict[str, Any]:113def _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
118def 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.
100120
101 Args:121 Args:
102 raw_config: Raw (possibly partial) config mapping.122 raw_config: Raw (possibly partial) config mapping.
Importance #5: src/iolabs_point_cloud_surface_mesh/__init__.py @@ -1 +1,23 @@
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
3from ._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]
Importance #6: tests/test_config.py @@ -0,0 +1,168 @@
1import dataclasses
2import json
3
4import pytest
5from iolabs.common import config_loader
6
7from iolabs_point_cloud_surface_mesh import params
8from 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
25def 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
30def 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
35def test_error_class_is_config_error():
36 assert issubclass(SurfaceMeshConfigError, config_loader.ConfigError)
37 assert issubclass(SurfaceMeshConfigError, ValueError)
38
39
40def 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
45def 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
50def 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
59def 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
81def 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
91def 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
100def 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
105def 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
118def test_build_config_rejects_unknown_override_keys():
119 with pytest.raises(SurfaceMeshConfigError):
120 build_surface_mesh_config(overrides={"surface_parameters": {}})
121
122
123def 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
135def 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)
159def 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
164def 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
Importance #7: README.md @@ -29,12 +29,19 @@
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`. |
3030
31## Configuration31## Configuration
3232
33Packaged defaults in `surface_mesh.default.json` are validated by the pydantic33Defaults live in `src/iolabs_point_cloud_surface_mesh/surface_mesh.default.json`.
34`SurfaceMeshConfig` tree; unknown keys raise `SurfaceMeshConfigError`. To add a34The schema is `SurfaceMeshConfig` in `iolabs_point_cloud_surface_mesh._config`
35key, 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:36unknown keys are rejected (`SurfaceMeshConfigError`). **To add a config key: add
37the field (with its type, default and any `Field` range) to the model and the
38same key with the same default to the JSON — nothing else.** Tunables under
39`surface_mesh_parameters` are additionally mirrored on the mutable runtime
40dataclass `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
43from 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 lives46`min_measured_edge_fraction`, `edge_smoothing_window_m`. File naming lives
40under `file_naming`; per-segment NPZ exclusion under47under `file_naming`; per-segment NPZ exclusion under
Importance #8: src/iolabs_point_cloud_surface_mesh/__init__.py @@ -1 +1,23 @@
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
3from ._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]
Importance #9: src/iolabs_point_cloud_surface_mesh/_config.py @@ -1,27 +1,38 @@
1"""Strict config loading and validation for the surface mesh builder.1"""Packaged-default configuration for the edge-bounded pavement surface mesh.
22
3The schema is a pydantic model tree derived from3The schema is `SurfaceMeshConfig` (a `config_loader.ConfigModel`), mirroring
4``iolabs.common.config_loader.ConfigModel``. Packaged defaults live in4`surface_mesh.default.json` key for key.
5``surface_mesh.default.json``; ``load_config`` / ``validate_config`` handle5
6JSON loading, deep-merge, unknown-key rejection and scalar coercion.6Adding 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
9The entry points return a plain `dict[str, Any]`; the `surface_mesh_parameters`
10section is bridged onto the mutable runtime dataclass
11`params.SurfaceMeshParameters` by :func:`surface_mesh_parameters_from_config`.
7"""12"""
813
14from __future__ import annotations
15
16import logging
17from collections.abc import Mapping
9from pathlib import Path18from pathlib import Path
10from typing import Any19from typing import Any
1120
12import pydantic21import pydantic
13from iolabs.common import config_loader, segment_points_io22from iolabs.common import config_loader, segment_points_io
1423
15from . import params24from . import params
1625
17_DEFAULT_CONFIG_FILENAME = "surface_mesh.default.json"26logger = 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"
2031
2132
22class SurfaceMeshConfigError(config_loader.ConfigError):33class 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."""
2435
2536
26class SurfaceMeshFileNamingConfig(config_loader.ConfigModel):37class 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."""
Importance #10: src/iolabs_point_cloud_surface_mesh/_config.py @@ -79,24 +90,33 @@
7990
8091
81def _load_model(92def _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 )
96111
97112
98def normalize_surface_mesh_config(raw_config: dict[str, Any]) -> dict[str, Any]:113def _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
118def 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.
100120
101 Args:121 Args:
102 raw_config: Raw (possibly partial) config mapping.122 raw_config: Raw (possibly partial) config mapping.
Importance #11: src/iolabs_point_cloud_surface_mesh/_config.py @@ -107,36 +127,48 @@
107 Raises:127 Raises:
108 SurfaceMeshConfigError: If the config contains unknown keys at any128 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()
119137
120138
121def surface_mesh_parameters_from_config(config: dict[str, Any]) -> params.SurfaceMeshParameters:139def 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.
123143
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`.
126146
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 )
131162
132163
133def load_surface_mesh_config(config_path: str | Path | None = None) -> dict[str, Any]:164def 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.
135166
136 Args:167 Args:
137 config_path: Path to a config JSON file. When None, the packaged168 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.
139171
140 Returns:172 Returns:
141 The normalized config dict.173 The normalized config dict.
142 """174 """
Importance #12: src/iolabs_point_cloud_surface_mesh/_config.py @@ -144,9 +176,9 @@
144176
145177
146def build_surface_mesh_config(178def 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.
152184
Importance #13: tests/test_config.py @@ -0,0 +1,168 @@
1import dataclasses
2import json
3
4import pytest
5from iolabs.common import config_loader
6
7from iolabs_point_cloud_surface_mesh import params
8from 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
25def 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
30def 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
35def test_error_class_is_config_error():
36 assert issubclass(SurfaceMeshConfigError, config_loader.ConfigError)
37 assert issubclass(SurfaceMeshConfigError, ValueError)
38
39
40def 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
45def 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
50def 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
59def 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
81def 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
91def 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
100def 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
105def 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
118def test_build_config_rejects_unknown_override_keys():
119 with pytest.raises(SurfaceMeshConfigError):
120 build_surface_mesh_config(overrides={"surface_parameters": {}})
121
122
123def 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
135def 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)
159def 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
164def 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