Miroslav Simko <ms@iolabs.ch> 2026-09-02T08:32:36+02:00
Commit #65 · 23 snippets
README.md | 12 +++--- pyproject.toml | 5 ++- src/iolabs_point_cloud_tablecloth/config.py | 64 +++++++++++++--------------- src/iolabs_point_cloud_tablecloth/summary.py | 3 +- tests/test_config.py | 17 +++++++- tests/test_ground_smrf.py | 2 +- 6 files changed, 57 insertions(+), 46 deletions(-)
| 1 | """Tablecloth configuration. | 1 | """Tablecloth configuration. |
| 2 | 2 | ||
| 3 | Package-owned JSON defaults plus a frozen dataclass schema. Runtime overrides | 3 | Package-owned JSON defaults plus a frozen pydantic schema. Runtime overrides |
| 4 | come from repeatable ``--set KEY=VALUE`` flags (JSON-decoded), never repo-local | 4 | come from repeatable ``--set KEY=VALUE`` flags (JSON-decoded), never repo-local |
| 5 | JSON. Config plumbing (packaged load, deep merge, unknown-key rejection) comes | 5 | JSON. Config plumbing (packaged load, deep merge, unknown-key rejection, |
| 6 | from :mod:`iolabs.common.config_loader`; only the mechanism check stays local. | 6 | coercion) comes from :mod:`iolabs.common.config_loader`. |
| 7 | |||
| 8 | To add a config key, add a field to :class:`TableclothConfig` and a matching | ||
| 9 | entry in ``tablecloth.default.json`` — nothing else. Packaged defaults must | ||
| 10 | validate with zero overrides. | ||
| 7 | 11 | ||
| 8 | Coercion is strict, by design: a value must be valid for the field's declared | 12 | Coercion is strict, by design: a value must be valid for the field's declared |
| 9 | type or the load fails. ``--set cell_m=true`` (bool for a float field), | 13 | type or the load fails. ``--set cell_m=true`` (bool for a float field), |
| 10 | ``--set mechanism=5`` (non-string for a str field), ``--set | 14 | ``--set mechanism=5`` (non-string for a ``Literal`` field), ``--set |
| 11 | csf_iterations=3.7`` (non-integral for an int field) and ``--set | 15 | csf_iterations=3.7`` (non-integral for an int field) and ``--set |
| 12 | overlay_enabled=flase`` (bool typo) are all rejected rather than silently | 16 | overlay_enabled=flase`` (bool typo) are all rejected rather than silently |
| 13 | coerced. | 17 | coerced. |
| 14 | """ | 18 | """ |
| 15 | 19 | ||
| 20 | from __future__ import annotations | ||
| 21 | |||
| 16 | import logging | 22 | import logging |
| 17 | from dataclasses import dataclass | 23 | from typing import Any, Literal |
| 18 | from typing import Any | ||
| 19 | 24 | ||
| 20 | from iolabs.common.config_loader import ( | 25 | from iolabs.common import config_loader |
| 21 | ConfigError, | ||
| 22 | dataclass_from_mapping, | ||
| 23 | deep_merge_dicts, | ||
| 24 | load_packaged_json, | ||
| 25 | ) | ||
| 26 | from iolabs.common.config_loader import parse_set_overrides as common_parse_set_overrides | ||
| 27 | 26 | ||
| 28 | logger = logging.getLogger(__name__) | 27 | logger = logging.getLogger(__name__) |
| 29 | 28 | ||
| 30 | _ALLOWED_MECHANISMS = frozenset({"none", "smrf_numpy", "csf_cloth"}) | ||
| 31 | _DEFAULT_CONFIG_NAME = "tablecloth.default.json" | 29 | _DEFAULT_CONFIG_NAME = "tablecloth.default.json" |
| 32 | _PACKAGE_NAME = "iolabs_point_cloud_tablecloth" | 30 | _PACKAGE_NAME = "iolabs_point_cloud_tablecloth" |
| 33 | 31 | ||
| 34 | 32 | ||
| 35 | class TableclothConfigError(ConfigError): | 33 | class TableclothConfigError(config_loader.ConfigError): |
| 36 | """Raised when the tablecloth config contains unsupported keys or values.""" | 34 | """Raised when the tablecloth config contains unsupported keys or values.""" |
| 37 | 35 | ||
| 38 | 36 | ||
| 39 | @dataclass(frozen=True) | 37 | class TableclothConfig(config_loader.ConfigModel): |
| 40 | class TableclothConfig: | ||
| 41 | """Filter and runtime thresholds for tablecloth ground classification.""" | 38 | """Filter and runtime thresholds for tablecloth ground classification.""" |
| 42 | 39 | ||
| 43 | # Mechanism: none | smrf_numpy | csf_cloth | 40 | # Mechanism: none | smrf_numpy | csf_cloth |
| 44 | mechanism: str = "smrf_numpy" | 41 | mechanism: Literal["none", "smrf_numpy", "csf_cloth"] = "smrf_numpy" |
| 45 | 42 | ||
| 46 | # SMRF (lip-first defaults; see plan §3) | 43 | # SMRF (lip-first defaults; see plan §3) |
| 47 | cell_m: float = 0.20 # Lip-safe fine grid; band 0.15–0.25 | 44 | cell_m: float = 0.20 # Lip-safe fine grid; band 0.15–0.25 |
| 48 | slope_threshold: float = 0.15 # Progressive SMRF slope; curb vs wall | 45 | slope_threshold: float = 0.15 # Progressive SMRF slope; curb vs wall |
| 74 | 71 | ||
| 75 | def load_default_config_dict() -> dict[str, Any]: | 72 | def load_default_config_dict() -> dict[str, Any]: |
| 76 | """Return the package-owned default config as a plain dict.""" | 73 | """Return the package-owned default config as a plain dict.""" |
| 77 | package = __package__ or _PACKAGE_NAME | 74 | package = __package__ or _PACKAGE_NAME |
| 78 | return load_packaged_json(package, _DEFAULT_CONFIG_NAME) | 75 | return config_loader.load_packaged_json(package, _DEFAULT_CONFIG_NAME) |
| 79 | |||
| 80 | |||
| 81 | def _validate_mechanism(mechanism: str) -> None: | ||
| 82 | if mechanism not in _ALLOWED_MECHANISMS: | ||
| 83 | raise TableclothConfigError( | ||
| 84 | f"Unknown mechanism '{mechanism}'. " | ||
| 85 | f"Expected one of: {', '.join(sorted(_ALLOWED_MECHANISMS))}." | ||
| 86 | ) | ||
| 87 | 76 | ||
| 88 | 77 | ||
| 89 | def config_from_dict(raw: dict[str, Any]) -> TableclothConfig: | 78 | def config_from_dict(raw: dict[str, Any]) -> TableclothConfig: |
| 90 | """Build a validated :class:`TableclothConfig` from a raw mapping. | 79 | """Build a validated :class:`TableclothConfig` from a raw mapping. |
| 91 | 80 | ||
| 92 | Unknown-key rejection and per-field value coercion come from | 81 | Unknown-key rejection and per-field value coercion come from |
| 93 | :func:`iolabs.common.config_loader.dataclass_from_mapping`; only the | 82 | :func:`iolabs.common.config_loader.validate_config`. ``mechanism`` is |
| 94 | mechanism check is tablecloth-specific. | 83 | restricted to the declared ``Literal`` choices. |
| 95 | 84 | ||
| 96 | Args: | 85 | Args: |
| 97 | raw: Merged config mapping (packaged defaults plus ``--set`` | 86 | raw: Merged config mapping (packaged defaults plus ``--set`` |
| 98 | overrides). | 87 | overrides). |
| 103 | Raises: | 92 | Raises: |
| 104 | TableclothConfigError: *raw* holds an unknown key, a value that is not | 93 | TableclothConfigError: *raw* holds an unknown key, a value that is not |
| 105 | valid for its declared field type, or an unsupported mechanism. | 94 | valid for its declared field type, or an unsupported mechanism. |
| 106 | """ | 95 | """ |
| 107 | config = dataclass_from_mapping( | 96 | return config_loader.validate_config( |
| 108 | TableclothConfig, | 97 | TableclothConfig, |
| 109 | raw, | 98 | raw, |
| 110 | context="tablecloth config", | 99 | context="tablecloth config", |
| 111 | error_cls=TableclothConfigError, | 100 | error_cls=TableclothConfigError, |
| 112 | ) | 101 | ) |
| 113 | _validate_mechanism(config.mechanism) | ||
| 114 | return config | ||
| 115 | 102 | ||
| 116 | 103 | ||
| 117 | def load_config(overrides: dict[str, Any] | None = None) -> TableclothConfig: | 104 | def load_config(overrides: dict[str, Any] | None = None) -> TableclothConfig: |
| 118 | """Load the default config and apply flat ``KEY=VALUE`` overrides. | 105 | """Load the default config and apply flat ``KEY=VALUE`` overrides. |
| 119 | 106 | ||
| 120 | Overrides come from the CLI ``--set`` flag (already parsed into a dict). | 107 | Overrides come from the CLI ``--set`` flag (already parsed into a dict). |
| 121 | """ | 108 | """ |
| 122 | merged = deep_merge_dicts(load_default_config_dict(), dict(overrides or {})) | 109 | package = __package__ or _PACKAGE_NAME |
| 123 | config = config_from_dict(merged) | 110 | config = config_loader.load_config( |
| 111 | TableclothConfig, | ||
| 112 | package=package, | ||
| 113 | filename=_DEFAULT_CONFIG_NAME, | ||
| 114 | overrides=overrides, | ||
| 115 | context="tablecloth config", | ||
| 116 | error_cls=TableclothConfigError, | ||
| 117 | ) | ||
| 124 | if overrides: | 118 | if overrides: |
| 125 | logger.info("Config overrides applied: %s", ", ".join(sorted(overrides))) | 119 | logger.info("Config overrides applied: %s", ", ".join(sorted(overrides))) |
| 126 | return config | 120 | return config |
| 127 | 121 |
| 140 | 134 | ||
| 141 | Raises: | 135 | Raises: |
| 142 | TableclothConfigError: An override is missing its ``=``. | 136 | TableclothConfigError: An override is missing its ``=``. |
| 143 | """ | 137 | """ |
| 144 | return common_parse_set_overrides(raw_overrides, error_cls=TableclothConfigError) | 138 | return config_loader.parse_set_overrides(raw_overrides, error_cls=TableclothConfigError) |
| 1 | """Aggregate run summary → ``out/run_summary.json``.""" | 1 | """Aggregate run summary → ``out/run_summary.json``.""" |
| 2 | 2 | ||
| 3 | import logging | 3 | import logging |
| 4 | from dataclasses import asdict | ||
| 5 | from pathlib import Path | 4 | from pathlib import Path |
| 6 | from typing import Any | 5 | from typing import Any |
| 7 | 6 | ||
| 8 | from iolabs.common.run_stats import write_stats | 7 | from iolabs.common.run_stats import write_stats |
| 62 | "runtime_seconds": round(float(runtime_seconds), 3), | 61 | "runtime_seconds": round(float(runtime_seconds), 3), |
| 63 | "peak_rss_gb": round(float(peak_rss_gb), 3), | 62 | "peak_rss_gb": round(float(peak_rss_gb), 3), |
| 64 | "memory_budget_gb": float(config.memory_budget_gb), | 63 | "memory_budget_gb": float(config.memory_budget_gb), |
| 65 | "mechanism": config.mechanism, | 64 | "mechanism": config.mechanism, |
| 66 | "config": asdict(config), | 65 | "config": config.model_dump(), |
| 67 | } | 66 | } |
| 68 | 67 | ||
| 69 | 68 | ||
| 70 | def write_run_summary(out_dir: Path, entries: list[dict[str, Any]]) -> Path: | 69 | def write_run_summary(out_dir: Path, entries: list[dict[str, Any]]) -> Path: |
| 1 | import pytest | 1 | import pytest |
| 2 | from iolabs.common import config_loader | ||
| 2 | 3 | ||
| 3 | from iolabs_point_cloud_tablecloth.config import ( | 4 | from iolabs_point_cloud_tablecloth.config import ( |
| 5 | TableclothConfig, | ||
| 4 | TableclothConfigError, | 6 | TableclothConfigError, |
| 5 | config_from_dict, | 7 | config_from_dict, |
| 6 | load_config, | 8 | load_config, |
| 7 | load_default_config_dict, | 9 | load_default_config_dict, |
| 8 | parse_set_overrides, | 10 | parse_set_overrides, |
| 9 | ) | 11 | ) |
| 10 | 12 | ||
| 11 | 13 | ||
| 14 | def test_packaged_defaults_validate_as_config_model() -> None: | ||
| 15 | """Zero-override load must yield a ConfigModel whose dump matches the JSON.""" | ||
| 16 | config = load_config() | ||
| 17 | assert isinstance(config, config_loader.ConfigModel) | ||
| 18 | assert issubclass(TableclothConfigError, config_loader.ConfigError) | ||
| 19 | assert load_default_config_dict() == config.model_dump() | ||
| 20 | |||
| 21 | |||
| 22 | def test_model_defaults_match_packaged_json() -> None: | ||
| 23 | """Field defaults must stay identical to the packaged default JSON.""" | ||
| 24 | assert TableclothConfig().model_dump() == load_default_config_dict() | ||
| 25 | |||
| 26 | |||
| 12 | def test_parse_set_overrides_json_decodes_values() -> None: | 27 | def test_parse_set_overrides_json_decodes_values() -> None: |
| 13 | parsed = parse_set_overrides(["cell_m=0.15", "overlay_enabled=true", "mechanism=none"]) | 28 | parsed = parse_set_overrides(["cell_m=0.15", "overlay_enabled=true", "mechanism=none"]) |
| 14 | assert parsed == {"cell_m": 0.15, "overlay_enabled": True, "mechanism": "none"} | 29 | assert parsed == {"cell_m": 0.15, "overlay_enabled": True, "mechanism": "none"} |
| 15 | 30 |
| 121 | load_config({"cell_m": True}) | 136 | load_config({"cell_m": True}) |
| 122 | 137 | ||
| 123 | 138 | ||
| 124 | def test_non_string_mechanism_rejected() -> None: | 139 | def test_non_string_mechanism_rejected() -> None: |
| 125 | with pytest.raises(TableclothConfigError, match="Invalid str"): | 140 | with pytest.raises(TableclothConfigError, match="Expected one of"): |
| 126 | load_config({"mechanism": 5}) | 141 | load_config({"mechanism": 5}) |
| 70 | 70 | ||
| 71 | def test_unknown_mechanism_raises() -> None: | 71 | def test_unknown_mechanism_raises() -> None: |
| 72 | cloud = flat_plane(n=100) | 72 | cloud = flat_plane(n=100) |
| 73 | # Bypass config validation to hit the dispatcher. | 73 | # Bypass config validation to hit the dispatcher. |
| 74 | bad = TableclothConfig(mechanism="not_a_mechanism") # type: ignore[arg-type] | 74 | bad = TableclothConfig.model_construct(mechanism="not_a_mechanism") |
| 75 | with pytest.raises(TableclothConfigError): | 75 | with pytest.raises(TableclothConfigError): |
| 76 | classify_ground(cloud.points, bad) | 76 | classify_ground(cloud.points, bad) |
| 77 | 77 | ||
| 78 | 78 |
| 1 | [project] | 1 | [project] |
| 2 | name = "iolabs-point-cloud-tablecloth" | 2 | name = "iolabs-point-cloud-tablecloth" |
| 3 | version = "0.2.0" | 3 | version = "0.2.1" |
| 4 | description = "Ground/hard-surface separation for MLS point clouds before lanefinder rasterization" | 4 | description = "Ground/hard-surface separation for MLS point clouds before lanefinder rasterization" |
| 5 | requires-python = ">=3.11" | 5 | requires-python = ">=3.11" |
| 6 | dependencies = [ | 6 | dependencies = [ |
| 7 | "numpy>=1.26", | 7 | "numpy>=1.26", |
| 8 | "scipy>=1.13", | 8 | "scipy>=1.13", |
| 9 | "pillow>=10.0", | 9 | "pillow>=10.0", |
| 10 | "matplotlib>=3.8", | 10 | "matplotlib>=3.8", |
| 11 | "iolabs-common>=0.7.0", | 11 | "pydantic>=2.7", |
| 12 | "iolabs-common>=0.8.0", | ||
| 12 | "iolabs-geometry-geometry>=0.11.0", | 13 | "iolabs-geometry-geometry>=0.11.0", |
| 13 | ] | 14 | ] |
| 14 | 15 | ||
| 15 | [project.optional-dependencies] | 16 | [project.optional-dependencies] |
| 35 | ## Stages | 35 | ## Stages |
| 36 | 36 | ||
| 37 | | Stage | What it does | | 37 | | Stage | What it does | |
| 38 | |---|---| | 38 | |---|---| |
| 39 | | **A** | Package scaffold, frozen `TableclothConfig`, default JSON, config tests | | 39 | | **A** | Package scaffold, frozen `TableclothConfig` model, default JSON, config tests | |
| 40 | | **B** | Streaming npz IO, SMRF core + CSF adapter, synthetic ground-filter tests | | 40 | | **B** | Streaming npz IO, SMRF core + CSF adapter, synthetic ground-filter tests | |
| 41 | | **C** | Filter CLI, overlays, `run_summary.json`, full README (this document) | | 41 | | **C** | Filter CLI, overlays, `run_summary.json`, full README (this document) | |
| 42 | 42 | ||
| 43 | See [docs/plans/v1-implementation-plan.md](docs/plans/v1-implementation-plan.md) | 43 | See [docs/plans/v1-implementation-plan.md](docs/plans/v1-implementation-plan.md) |
| 47 | 47 | ||
| 48 | Following the other iolabs point-cloud packages (guardrails, asphaltedge, …), | 48 | Following the other iolabs point-cloud packages (guardrails, asphaltedge, …), |
| 49 | the package owns an algorithm config | 49 | the package owns an algorithm config |
| 50 | `src/iolabs_point_cloud_tablecloth/tablecloth.default.json`. | 50 | `src/iolabs_point_cloud_tablecloth/tablecloth.default.json`. |
| 51 | `config.py` is the loader/schema: the frozen `TableclothConfig` dataclass is the | 51 | `config.py` is the loader/schema: the frozen `TableclothConfig` pydantic model |
| 52 | typed params object and its field set is the schema. Every dataclass default is | 52 | (`config_loader.ConfigModel`) is the typed params object and its fields are the |
| 53 | kept identical to the JSON (asserted by `tests/test_config.py`). | 53 | schema. To add a config key, add a field to the model and a matching entry in |
| 54 | `tablecloth.default.json` — nothing else. Packaged defaults must validate with | ||
| 55 | zero overrides (asserted by `tests/test_config.py`). | ||
| 54 | 56 | ||
| 55 | Runtime overrides use the repeatable `--set KEY=VALUE` CLI flag (values are | 57 | Runtime overrides use the repeatable `--set KEY=VALUE` CLI flag (values are |
| 56 | JSON-decoded), never repo-local JSON files. Each value is coerced strictly to | 58 | JSON-decoded), never repo-local JSON files. Each value is coerced strictly to |
| 57 | its field's declared type: a bool for a float field, a non-integral value for | 59 | its field's declared type: a bool for a float field, a non-integral value for |
| 121 | "runtime_seconds": 12.345, | 123 | "runtime_seconds": 12.345, |
| 122 | "peak_rss_gb": 3.21, | 124 | "peak_rss_gb": 3.21, |
| 123 | "memory_budget_gb": 10.0, | 125 | "memory_budget_gb": 10.0, |
| 124 | "mechanism": "smrf_numpy", | 126 | "mechanism": "smrf_numpy", |
| 125 | "config": { "...": "full TableclothConfig asdict" } | 127 | "config": { "...": "full TableclothConfig model_dump" } |
| 126 | } | 128 | } |
| 127 | ``` | 129 | ``` |
| 128 | 130 | ||
| 129 | ## Memory / streaming | 131 | ## Memory / streaming |
| 1 | [project] | 1 | [project] |
| 2 | name = "iolabs-point-cloud-tablecloth" | 2 | name = "iolabs-point-cloud-tablecloth" |
| 3 | version = "0.2.0" | 3 | version = "0.2.1" |
| 4 | description = "Ground/hard-surface separation for MLS point clouds before lanefinder rasterization" | 4 | description = "Ground/hard-surface separation for MLS point clouds before lanefinder rasterization" |
| 5 | requires-python = ">=3.11" | 5 | requires-python = ">=3.11" |
| 6 | dependencies = [ | 6 | dependencies = [ |
| 7 | "numpy>=1.26", | 7 | "numpy>=1.26", |
| 8 | "scipy>=1.13", | 8 | "scipy>=1.13", |
| 9 | "pillow>=10.0", | 9 | "pillow>=10.0", |
| 10 | "matplotlib>=3.8", | 10 | "matplotlib>=3.8", |
| 11 | "iolabs-common>=0.7.0", | 11 | "pydantic>=2.7", |
| 12 | "iolabs-common>=0.8.0", | ||
| 12 | "iolabs-geometry-geometry>=0.11.0", | 13 | "iolabs-geometry-geometry>=0.11.0", |
| 13 | ] | 14 | ] |
| 14 | 15 | ||
| 15 | [project.optional-dependencies] | 16 | [project.optional-dependencies] |
| 1 | """Tablecloth configuration. | 1 | """Tablecloth configuration. |
| 2 | 2 | ||
| 3 | Package-owned JSON defaults plus a frozen dataclass schema. Runtime overrides | 3 | Package-owned JSON defaults plus a frozen pydantic schema. Runtime overrides |
| 4 | come from repeatable ``--set KEY=VALUE`` flags (JSON-decoded), never repo-local | 4 | come from repeatable ``--set KEY=VALUE`` flags (JSON-decoded), never repo-local |
| 5 | JSON. Config plumbing (packaged load, deep merge, unknown-key rejection) comes | 5 | JSON. Config plumbing (packaged load, deep merge, unknown-key rejection, |
| 6 | from :mod:`iolabs.common.config_loader`; only the mechanism check stays local. | 6 | coercion) comes from :mod:`iolabs.common.config_loader`. |
| 7 | |||
| 8 | To add a config key, add a field to :class:`TableclothConfig` and a matching | ||
| 9 | entry in ``tablecloth.default.json`` — nothing else. Packaged defaults must | ||
| 10 | validate with zero overrides. | ||
| 7 | 11 | ||
| 8 | Coercion is strict, by design: a value must be valid for the field's declared | 12 | Coercion is strict, by design: a value must be valid for the field's declared |
| 9 | type or the load fails. ``--set cell_m=true`` (bool for a float field), | 13 | type or the load fails. ``--set cell_m=true`` (bool for a float field), |
| 10 | ``--set mechanism=5`` (non-string for a str field), ``--set | 14 | ``--set mechanism=5`` (non-string for a ``Literal`` field), ``--set |
| 11 | csf_iterations=3.7`` (non-integral for an int field) and ``--set | 15 | csf_iterations=3.7`` (non-integral for an int field) and ``--set |
| 12 | overlay_enabled=flase`` (bool typo) are all rejected rather than silently | 16 | overlay_enabled=flase`` (bool typo) are all rejected rather than silently |
| 13 | coerced. | 17 | coerced. |
| 14 | """ | 18 | """ |
| 15 | 19 | ||
| 20 | from __future__ import annotations | ||
| 21 | |||
| 16 | import logging | 22 | import logging |
| 17 | from dataclasses import dataclass | 23 | from typing import Any, Literal |
| 18 | from typing import Any | ||
| 19 | 24 | ||
| 20 | from iolabs.common.config_loader import ( | 25 | from iolabs.common import config_loader |
| 21 | ConfigError, | ||
| 22 | dataclass_from_mapping, | ||
| 23 | deep_merge_dicts, | ||
| 24 | load_packaged_json, | ||
| 25 | ) | ||
| 26 | from iolabs.common.config_loader import parse_set_overrides as common_parse_set_overrides | ||
| 27 | 26 | ||
| 28 | logger = logging.getLogger(__name__) | 27 | logger = logging.getLogger(__name__) |
| 29 | 28 | ||
| 30 | _ALLOWED_MECHANISMS = frozenset({"none", "smrf_numpy", "csf_cloth"}) | ||
| 31 | _DEFAULT_CONFIG_NAME = "tablecloth.default.json" | 29 | _DEFAULT_CONFIG_NAME = "tablecloth.default.json" |
| 32 | _PACKAGE_NAME = "iolabs_point_cloud_tablecloth" | 30 | _PACKAGE_NAME = "iolabs_point_cloud_tablecloth" |
| 33 | 31 | ||
| 34 | 32 | ||
| 35 | class TableclothConfigError(ConfigError): | 33 | class TableclothConfigError(config_loader.ConfigError): |
| 36 | """Raised when the tablecloth config contains unsupported keys or values.""" | 34 | """Raised when the tablecloth config contains unsupported keys or values.""" |
| 37 | 35 | ||
| 38 | 36 | ||
| 39 | @dataclass(frozen=True) | 37 | class TableclothConfig(config_loader.ConfigModel): |
| 40 | class TableclothConfig: | ||
| 41 | """Filter and runtime thresholds for tablecloth ground classification.""" | 38 | """Filter and runtime thresholds for tablecloth ground classification.""" |
| 42 | 39 | ||
| 43 | # Mechanism: none | smrf_numpy | csf_cloth | 40 | # Mechanism: none | smrf_numpy | csf_cloth |
| 44 | mechanism: str = "smrf_numpy" | 41 | mechanism: Literal["none", "smrf_numpy", "csf_cloth"] = "smrf_numpy" |
| 45 | 42 | ||
| 46 | # SMRF (lip-first defaults; see plan §3) | 43 | # SMRF (lip-first defaults; see plan §3) |
| 47 | cell_m: float = 0.20 # Lip-safe fine grid; band 0.15–0.25 | 44 | cell_m: float = 0.20 # Lip-safe fine grid; band 0.15–0.25 |
| 48 | slope_threshold: float = 0.15 # Progressive SMRF slope; curb vs wall | 45 | slope_threshold: float = 0.15 # Progressive SMRF slope; curb vs wall |
| 74 | 71 | ||
| 75 | def load_default_config_dict() -> dict[str, Any]: | 72 | def load_default_config_dict() -> dict[str, Any]: |
| 76 | """Return the package-owned default config as a plain dict.""" | 73 | """Return the package-owned default config as a plain dict.""" |
| 77 | package = __package__ or _PACKAGE_NAME | 74 | package = __package__ or _PACKAGE_NAME |
| 78 | return load_packaged_json(package, _DEFAULT_CONFIG_NAME) | 75 | return config_loader.load_packaged_json(package, _DEFAULT_CONFIG_NAME) |
| 79 | |||
| 80 | |||
| 81 | def _validate_mechanism(mechanism: str) -> None: | ||
| 82 | if mechanism not in _ALLOWED_MECHANISMS: | ||
| 83 | raise TableclothConfigError( | ||
| 84 | f"Unknown mechanism '{mechanism}'. " | ||
| 85 | f"Expected one of: {', '.join(sorted(_ALLOWED_MECHANISMS))}." | ||
| 86 | ) | ||
| 87 | 76 | ||
| 88 | 77 | ||
| 89 | def config_from_dict(raw: dict[str, Any]) -> TableclothConfig: | 78 | def config_from_dict(raw: dict[str, Any]) -> TableclothConfig: |
| 90 | """Build a validated :class:`TableclothConfig` from a raw mapping. | 79 | """Build a validated :class:`TableclothConfig` from a raw mapping. |
| 91 | 80 | ||
| 92 | Unknown-key rejection and per-field value coercion come from | 81 | Unknown-key rejection and per-field value coercion come from |
| 93 | :func:`iolabs.common.config_loader.dataclass_from_mapping`; only the | 82 | :func:`iolabs.common.config_loader.validate_config`. ``mechanism`` is |
| 94 | mechanism check is tablecloth-specific. | 83 | restricted to the declared ``Literal`` choices. |
| 95 | 84 | ||
| 96 | Args: | 85 | Args: |
| 97 | raw: Merged config mapping (packaged defaults plus ``--set`` | 86 | raw: Merged config mapping (packaged defaults plus ``--set`` |
| 98 | overrides). | 87 | overrides). |
| 103 | Raises: | 92 | Raises: |
| 104 | TableclothConfigError: *raw* holds an unknown key, a value that is not | 93 | TableclothConfigError: *raw* holds an unknown key, a value that is not |
| 105 | valid for its declared field type, or an unsupported mechanism. | 94 | valid for its declared field type, or an unsupported mechanism. |
| 106 | """ | 95 | """ |
| 107 | config = dataclass_from_mapping( | 96 | return config_loader.validate_config( |
| 108 | TableclothConfig, | 97 | TableclothConfig, |
| 109 | raw, | 98 | raw, |
| 110 | context="tablecloth config", | 99 | context="tablecloth config", |
| 111 | error_cls=TableclothConfigError, | 100 | error_cls=TableclothConfigError, |
| 112 | ) | 101 | ) |
| 113 | _validate_mechanism(config.mechanism) | ||
| 114 | return config | ||
| 115 | 102 | ||
| 116 | 103 | ||
| 117 | def load_config(overrides: dict[str, Any] | None = None) -> TableclothConfig: | 104 | def load_config(overrides: dict[str, Any] | None = None) -> TableclothConfig: |
| 118 | """Load the default config and apply flat ``KEY=VALUE`` overrides. | 105 | """Load the default config and apply flat ``KEY=VALUE`` overrides. |
| 119 | 106 | ||
| 120 | Overrides come from the CLI ``--set`` flag (already parsed into a dict). | 107 | Overrides come from the CLI ``--set`` flag (already parsed into a dict). |
| 121 | """ | 108 | """ |
| 122 | merged = deep_merge_dicts(load_default_config_dict(), dict(overrides or {})) | 109 | package = __package__ or _PACKAGE_NAME |
| 123 | config = config_from_dict(merged) | 110 | config = config_loader.load_config( |
| 111 | TableclothConfig, | ||
| 112 | package=package, | ||
| 113 | filename=_DEFAULT_CONFIG_NAME, | ||
| 114 | overrides=overrides, | ||
| 115 | context="tablecloth config", | ||
| 116 | error_cls=TableclothConfigError, | ||
| 117 | ) | ||
| 124 | if overrides: | 118 | if overrides: |
| 125 | logger.info("Config overrides applied: %s", ", ".join(sorted(overrides))) | 119 | logger.info("Config overrides applied: %s", ", ".join(sorted(overrides))) |
| 126 | return config | 120 | return config |
| 127 | 121 |
| 140 | 134 | ||
| 141 | Raises: | 135 | Raises: |
| 142 | TableclothConfigError: An override is missing its ``=``. | 136 | TableclothConfigError: An override is missing its ``=``. |
| 143 | """ | 137 | """ |
| 144 | return common_parse_set_overrides(raw_overrides, error_cls=TableclothConfigError) | 138 | return config_loader.parse_set_overrides(raw_overrides, error_cls=TableclothConfigError) |
| 1 | """Aggregate run summary → ``out/run_summary.json``.""" | 1 | """Aggregate run summary → ``out/run_summary.json``.""" |
| 2 | 2 | ||
| 3 | import logging | 3 | import logging |
| 4 | from dataclasses import asdict | ||
| 5 | from pathlib import Path | 4 | from pathlib import Path |
| 6 | from typing import Any | 5 | from typing import Any |
| 7 | 6 | ||
| 8 | from iolabs.common.run_stats import write_stats | 7 | from iolabs.common.run_stats import write_stats |
| 62 | "runtime_seconds": round(float(runtime_seconds), 3), | 61 | "runtime_seconds": round(float(runtime_seconds), 3), |
| 63 | "peak_rss_gb": round(float(peak_rss_gb), 3), | 62 | "peak_rss_gb": round(float(peak_rss_gb), 3), |
| 64 | "memory_budget_gb": float(config.memory_budget_gb), | 63 | "memory_budget_gb": float(config.memory_budget_gb), |
| 65 | "mechanism": config.mechanism, | 64 | "mechanism": config.mechanism, |
| 66 | "config": asdict(config), | 65 | "config": config.model_dump(), |
| 67 | } | 66 | } |
| 68 | 67 | ||
| 69 | 68 | ||
| 70 | def write_run_summary(out_dir: Path, entries: list[dict[str, Any]]) -> Path: | 69 | def write_run_summary(out_dir: Path, entries: list[dict[str, Any]]) -> Path: |
| 1 | import pytest | 1 | import pytest |
| 2 | from iolabs.common import config_loader | ||
| 2 | 3 | ||
| 3 | from iolabs_point_cloud_tablecloth.config import ( | 4 | from iolabs_point_cloud_tablecloth.config import ( |
| 5 | TableclothConfig, | ||
| 4 | TableclothConfigError, | 6 | TableclothConfigError, |
| 5 | config_from_dict, | 7 | config_from_dict, |
| 6 | load_config, | 8 | load_config, |
| 7 | load_default_config_dict, | 9 | load_default_config_dict, |
| 8 | parse_set_overrides, | 10 | parse_set_overrides, |
| 9 | ) | 11 | ) |
| 10 | 12 | ||
| 11 | 13 | ||
| 14 | def test_packaged_defaults_validate_as_config_model() -> None: | ||
| 15 | """Zero-override load must yield a ConfigModel whose dump matches the JSON.""" | ||
| 16 | config = load_config() | ||
| 17 | assert isinstance(config, config_loader.ConfigModel) | ||
| 18 | assert issubclass(TableclothConfigError, config_loader.ConfigError) | ||
| 19 | assert load_default_config_dict() == config.model_dump() | ||
| 20 | |||
| 21 | |||
| 22 | def test_model_defaults_match_packaged_json() -> None: | ||
| 23 | """Field defaults must stay identical to the packaged default JSON.""" | ||
| 24 | assert TableclothConfig().model_dump() == load_default_config_dict() | ||
| 25 | |||
| 26 | |||
| 12 | def test_parse_set_overrides_json_decodes_values() -> None: | 27 | def test_parse_set_overrides_json_decodes_values() -> None: |
| 13 | parsed = parse_set_overrides(["cell_m=0.15", "overlay_enabled=true", "mechanism=none"]) | 28 | parsed = parse_set_overrides(["cell_m=0.15", "overlay_enabled=true", "mechanism=none"]) |
| 14 | assert parsed == {"cell_m": 0.15, "overlay_enabled": True, "mechanism": "none"} | 29 | assert parsed == {"cell_m": 0.15, "overlay_enabled": True, "mechanism": "none"} |
| 15 | 30 |
| 121 | load_config({"cell_m": True}) | 136 | load_config({"cell_m": True}) |
| 122 | 137 | ||
| 123 | 138 | ||
| 124 | def test_non_string_mechanism_rejected() -> None: | 139 | def test_non_string_mechanism_rejected() -> None: |
| 125 | with pytest.raises(TableclothConfigError, match="Invalid str"): | 140 | with pytest.raises(TableclothConfigError, match="Expected one of"): |
| 126 | load_config({"mechanism": 5}) | 141 | load_config({"mechanism": 5}) |
| 70 | 70 | ||
| 71 | def test_unknown_mechanism_raises() -> None: | 71 | def test_unknown_mechanism_raises() -> None: |
| 72 | cloud = flat_plane(n=100) | 72 | cloud = flat_plane(n=100) |
| 73 | # Bypass config validation to hit the dispatcher. | 73 | # Bypass config validation to hit the dispatcher. |
| 74 | bad = TableclothConfig(mechanism="not_a_mechanism") # type: ignore[arg-type] | 74 | bad = TableclothConfig.model_construct(mechanism="not_a_mechanism") |
| 75 | with pytest.raises(TableclothConfigError): | 75 | with pytest.raises(TableclothConfigError): |
| 76 | classify_ground(cloud.points, bad) | 76 | classify_ground(cloud.points, bad) |
| 77 | 77 | ||
| 78 | 78 |
ConfigModel: nested section models mirror the packaged*.default.jsonkey for key; whitelist sets and hand-rolled coercion deleted; loader built onconfig_loader.load_config. Public entry-point names and return types unchanged so lanefinder wrappers keep working.pydantic>=2.7dependency.