topo-tools 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- topo_tools-0.1.0/PKG-INFO +66 -0
- topo_tools-0.1.0/README.md +43 -0
- topo_tools-0.1.0/pyproject.toml +86 -0
- topo_tools-0.1.0/topo_tools/__init__.py +6 -0
- topo_tools-0.1.0/topo_tools/__main__.py +6 -0
- topo_tools-0.1.0/topo_tools/api/__init__.py +8 -0
- topo_tools-0.1.0/topo_tools/api/change.py +224 -0
- topo_tools-0.1.0/topo_tools/api/clean.py +170 -0
- topo_tools-0.1.0/topo_tools/api/extend.py +128 -0
- topo_tools-0.1.0/topo_tools/api/match.py +140 -0
- topo_tools-0.1.0/topo_tools/cli/__init__.py +1 -0
- topo_tools-0.1.0/topo_tools/cli/main.py +456 -0
- topo_tools-0.1.0/topo_tools/core/__init__.py +1 -0
- topo_tools-0.1.0/topo_tools/core/change/_01_inputs.py +20 -0
- topo_tools-0.1.0/topo_tools/core/change/_02_overlap.py +85 -0
- topo_tools-0.1.0/topo_tools/core/change/_03_classify.py +428 -0
- topo_tools-0.1.0/topo_tools/core/change/_04_outputs.py +62 -0
- topo_tools-0.1.0/topo_tools/core/change/__init__.py +1 -0
- topo_tools-0.1.0/topo_tools/core/change/_columns.py +53 -0
- topo_tools-0.1.0/topo_tools/core/change/_constants.py +28 -0
- topo_tools-0.1.0/topo_tools/core/change/_unionfind.py +51 -0
- topo_tools-0.1.0/topo_tools/core/clean/_01_inputs.py +18 -0
- topo_tools-0.1.0/topo_tools/core/clean/_02_issues.py +295 -0
- topo_tools-0.1.0/topo_tools/core/clean/_03_clean.py +82 -0
- topo_tools-0.1.0/topo_tools/core/clean/_04_outputs.py +69 -0
- topo_tools-0.1.0/topo_tools/core/clean/__init__.py +5 -0
- topo_tools-0.1.0/topo_tools/core/clean/_constants.py +31 -0
- topo_tools-0.1.0/topo_tools/core/clean/_units.py +44 -0
- topo_tools-0.1.0/topo_tools/core/duckdb_utils.py +195 -0
- topo_tools-0.1.0/topo_tools/core/extend/_01_inputs.py +88 -0
- topo_tools-0.1.0/topo_tools/core/extend/_02_lines.py +50 -0
- topo_tools-0.1.0/topo_tools/core/extend/_03_points.py +165 -0
- topo_tools-0.1.0/topo_tools/core/extend/_04_voronoi.py +45 -0
- topo_tools-0.1.0/topo_tools/core/extend/_05_merge.py +89 -0
- topo_tools-0.1.0/topo_tools/core/extend/_06_outputs.py +32 -0
- topo_tools-0.1.0/topo_tools/core/extend/__init__.py +1 -0
- topo_tools-0.1.0/topo_tools/core/extend/_constants.py +51 -0
- topo_tools-0.1.0/topo_tools/core/extend/_coverage.py +102 -0
- topo_tools-0.1.0/topo_tools/core/extend/attempt.py +119 -0
- topo_tools-0.1.0/topo_tools/core/match/_01_inputs.py +20 -0
- topo_tools-0.1.0/topo_tools/core/match/_02_assign.py +83 -0
- topo_tools-0.1.0/topo_tools/core/match/_03_groups.py +174 -0
- topo_tools-0.1.0/topo_tools/core/match/_04_merge.py +22 -0
- topo_tools-0.1.0/topo_tools/core/match/_05_outputs.py +40 -0
- topo_tools-0.1.0/topo_tools/core/match/__init__.py +1 -0
- topo_tools-0.1.0/topo_tools/core/match/_clip.py +27 -0
- topo_tools-0.1.0/topo_tools/core/match/_constants.py +6 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: topo-tools
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: DuckDB-powered geospatial topology utilities (edge-matching, topology cleaning, more)
|
|
5
|
+
Author: fieldmaps.io
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering :: GIS
|
|
15
|
+
Requires-Dist: click
|
|
16
|
+
Requires-Dist: duckdb>=1.5.3
|
|
17
|
+
Requires-Dist: psutil
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Project-URL: Homepage, https://github.com/fieldmaps/topo-tools-py
|
|
20
|
+
Project-URL: Repository, https://github.com/fieldmaps/topo-tools-py
|
|
21
|
+
Project-URL: Issues, https://github.com/fieldmaps/topo-tools-py/issues
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# topo-tools
|
|
25
|
+
|
|
26
|
+
[](https://github.com/fieldmaps/topo-tools-py/actions/workflows/ci.yml)
|
|
27
|
+
[](https://pypi.org/project/topo-tools/)
|
|
28
|
+
[](https://pypi.org/project/topo-tools/)
|
|
29
|
+
[](LICENSE)
|
|
30
|
+
|
|
31
|
+

|
|
32
|
+
|
|
33
|
+
`topo-tools` is a collection of DuckDB-powered geospatial topology utilities
|
|
34
|
+
for cleaning and reconciling administrative boundary polygons. It ships four
|
|
35
|
+
tools, usable from the CLI or as a Python package:
|
|
36
|
+
|
|
37
|
+
| Tool | What it does | Usage |
|
|
38
|
+
| --- | --- | --- |
|
|
39
|
+
| **clean** | Detects and fixes gap/overlap defects in a single polygon layer; reports slivers separately for manual review. | [`docs/clean.md`](docs/clean.md) |
|
|
40
|
+
| **extend** | Fills gaps around a polygon layer (missing coastline, disputed areas, water bodies) with a Voronoi extension, producing full coverage. | [`docs/extend.md`](docs/extend.md) |
|
|
41
|
+
| **match** | Fits a finer child polygon layer into a coarser parent layer, grouping and extending each child to fill gaps within its own parent. | [`docs/match.md`](docs/match.md) |
|
|
42
|
+
| **change** | Compares two versions of a polygon layer and classifies every unit as unchanged, renamed, modified, split, merged, created, or removed. | [`docs/change.md`](docs/change.md) |
|
|
43
|
+
|
|
44
|
+
## Installation
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
uv tool install topo-tools # CLI (recommended)
|
|
48
|
+
uv add topo-tools # Python library
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Or with pip:
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
pip install topo-tools
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Each linked doc above covers that tool's CLI/Python usage, options, and examples.
|
|
58
|
+
|
|
59
|
+
## Supported Formats
|
|
60
|
+
|
|
61
|
+
Polygon inputs/outputs: GeoParquet (`.parquet`), GeoPackage (`.gpkg`),
|
|
62
|
+
Shapefile (`.shp`), GeoJSON (`.geojson`). Output format matches input format.
|
|
63
|
+
`change`'s tabular changelog is CSV or GeoParquet only; its spatial overlay
|
|
64
|
+
layer supports the same four formats as the other tools.
|
|
65
|
+
|
|
66
|
+
See [`CONTRIBUTING.md`](CONTRIBUTING.md) for development setup.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# topo-tools
|
|
2
|
+
|
|
3
|
+
[](https://github.com/fieldmaps/topo-tools-py/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/topo-tools/)
|
|
5
|
+
[](https://pypi.org/project/topo-tools/)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+

|
|
9
|
+
|
|
10
|
+
`topo-tools` is a collection of DuckDB-powered geospatial topology utilities
|
|
11
|
+
for cleaning and reconciling administrative boundary polygons. It ships four
|
|
12
|
+
tools, usable from the CLI or as a Python package:
|
|
13
|
+
|
|
14
|
+
| Tool | What it does | Usage |
|
|
15
|
+
| --- | --- | --- |
|
|
16
|
+
| **clean** | Detects and fixes gap/overlap defects in a single polygon layer; reports slivers separately for manual review. | [`docs/clean.md`](docs/clean.md) |
|
|
17
|
+
| **extend** | Fills gaps around a polygon layer (missing coastline, disputed areas, water bodies) with a Voronoi extension, producing full coverage. | [`docs/extend.md`](docs/extend.md) |
|
|
18
|
+
| **match** | Fits a finer child polygon layer into a coarser parent layer, grouping and extending each child to fill gaps within its own parent. | [`docs/match.md`](docs/match.md) |
|
|
19
|
+
| **change** | Compares two versions of a polygon layer and classifies every unit as unchanged, renamed, modified, split, merged, created, or removed. | [`docs/change.md`](docs/change.md) |
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
uv tool install topo-tools # CLI (recommended)
|
|
25
|
+
uv add topo-tools # Python library
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Or with pip:
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
pip install topo-tools
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Each linked doc above covers that tool's CLI/Python usage, options, and examples.
|
|
35
|
+
|
|
36
|
+
## Supported Formats
|
|
37
|
+
|
|
38
|
+
Polygon inputs/outputs: GeoParquet (`.parquet`), GeoPackage (`.gpkg`),
|
|
39
|
+
Shapefile (`.shp`), GeoJSON (`.geojson`). Output format matches input format.
|
|
40
|
+
`change`'s tabular changelog is CSV or GeoParquet only; its spatial overlay
|
|
41
|
+
layer supports the same four formats as the other tools.
|
|
42
|
+
|
|
43
|
+
See [`CONTRIBUTING.md`](CONTRIBUTING.md) for development setup.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "topo-tools"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "DuckDB-powered geospatial topology utilities (edge-matching, topology cleaning, more)"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
authors = [{ name = "fieldmaps.io" }]
|
|
8
|
+
requires-python = ">=3.10"
|
|
9
|
+
dependencies = ["click", "duckdb>=1.5.3", "psutil"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Programming Language :: Python :: 3",
|
|
12
|
+
"Programming Language :: Python :: 3.10",
|
|
13
|
+
"Programming Language :: Python :: 3.11",
|
|
14
|
+
"Programming Language :: Python :: 3.12",
|
|
15
|
+
"Programming Language :: Python :: 3.13",
|
|
16
|
+
"Programming Language :: Python :: 3.14",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Topic :: Scientific/Engineering :: GIS",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://github.com/fieldmaps/topo-tools-py"
|
|
23
|
+
Repository = "https://github.com/fieldmaps/topo-tools-py"
|
|
24
|
+
Issues = "https://github.com/fieldmaps/topo-tools-py/issues"
|
|
25
|
+
|
|
26
|
+
[project.scripts]
|
|
27
|
+
topo-tools = "topo_tools.cli.main:cli"
|
|
28
|
+
|
|
29
|
+
[dependency-groups]
|
|
30
|
+
dev = ["import-linter", "pre-commit", "pytest", "ruff"]
|
|
31
|
+
|
|
32
|
+
[build-system]
|
|
33
|
+
requires = ["uv_build>=0.11.27,<0.12.0"]
|
|
34
|
+
build-backend = "uv_build"
|
|
35
|
+
|
|
36
|
+
[tool.uv.build-backend]
|
|
37
|
+
module-root = ""
|
|
38
|
+
|
|
39
|
+
[tool.ruff]
|
|
40
|
+
fix = true
|
|
41
|
+
|
|
42
|
+
[tool.ruff.lint]
|
|
43
|
+
select = ["ALL"]
|
|
44
|
+
ignore = ["COM812", "D203", "D213", "S603", "S607", "S608"]
|
|
45
|
+
|
|
46
|
+
[tool.ruff.lint.per-file-ignores]
|
|
47
|
+
"tests/*" = ["ANN", "D103", "INP001", "S101"]
|
|
48
|
+
|
|
49
|
+
[tool.importlinter]
|
|
50
|
+
root_package = "topo_tools"
|
|
51
|
+
include_external_packages = true
|
|
52
|
+
|
|
53
|
+
[[tool.importlinter.contracts]]
|
|
54
|
+
id = "core-no-click"
|
|
55
|
+
name = "Core modules must not import Click"
|
|
56
|
+
type = "forbidden"
|
|
57
|
+
source_modules = ["topo_tools.core"]
|
|
58
|
+
forbidden_modules = ["click"]
|
|
59
|
+
|
|
60
|
+
[[tool.importlinter.contracts]]
|
|
61
|
+
id = "api-no-cli"
|
|
62
|
+
name = "API modules must not import CLI"
|
|
63
|
+
type = "forbidden"
|
|
64
|
+
source_modules = ["topo_tools.api"]
|
|
65
|
+
forbidden_modules = ["topo_tools.cli"]
|
|
66
|
+
|
|
67
|
+
[[tool.importlinter.contracts]]
|
|
68
|
+
id = "match-may-use-extend-not-reverse"
|
|
69
|
+
name = "match may depend on extend; extend must not depend on match"
|
|
70
|
+
type = "forbidden"
|
|
71
|
+
source_modules = ["topo_tools.core.extend"]
|
|
72
|
+
forbidden_modules = ["topo_tools.core.match"]
|
|
73
|
+
|
|
74
|
+
[[tool.importlinter.contracts]]
|
|
75
|
+
id = "clean-may-use-extend-not-reverse"
|
|
76
|
+
name = "clean may depend on extend; extend must not depend on clean"
|
|
77
|
+
type = "forbidden"
|
|
78
|
+
source_modules = ["topo_tools.core.extend"]
|
|
79
|
+
forbidden_modules = ["topo_tools.core.clean"]
|
|
80
|
+
|
|
81
|
+
[[tool.importlinter.contracts]]
|
|
82
|
+
id = "change-may-use-extend-not-reverse"
|
|
83
|
+
name = "change may depend on extend; extend must not depend on change"
|
|
84
|
+
type = "forbidden"
|
|
85
|
+
source_modules = ["topo_tools.core.extend"]
|
|
86
|
+
forbidden_modules = ["topo_tools.core.change"]
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Public API: compare two polygon layer versions and classify what changed."""
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import signal
|
|
5
|
+
import tempfile
|
|
6
|
+
from logging import getLogger
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from types import FrameType
|
|
9
|
+
from typing import NoReturn
|
|
10
|
+
|
|
11
|
+
from duckdb import DuckDBPyConnection
|
|
12
|
+
|
|
13
|
+
from topo_tools.core.change import _01_inputs as inputs
|
|
14
|
+
from topo_tools.core.change import _02_overlap as overlap
|
|
15
|
+
from topo_tools.core.change import _03_classify as classify
|
|
16
|
+
from topo_tools.core.change import _04_outputs as outputs
|
|
17
|
+
from topo_tools.core.change._columns import detect_code_column, detect_name_column
|
|
18
|
+
from topo_tools.core.change._constants import (
|
|
19
|
+
TABLE_COPY_OPTS,
|
|
20
|
+
TAU_MATCH_DEFAULT,
|
|
21
|
+
TAU_SAME_DEFAULT,
|
|
22
|
+
)
|
|
23
|
+
from topo_tools.core.duckdb_utils import (
|
|
24
|
+
cleanup_tmp,
|
|
25
|
+
export_debug_tables,
|
|
26
|
+
get_connection,
|
|
27
|
+
log_file,
|
|
28
|
+
)
|
|
29
|
+
from topo_tools.core.extend._constants import COPY_OPTS
|
|
30
|
+
|
|
31
|
+
logger = getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
_STEP_ORDER = ["inputs", "overlap", "classify", "outputs"]
|
|
34
|
+
|
|
35
|
+
_STEP_TABLES = {
|
|
36
|
+
"inputs": ["{n}_a_01", "{n}_b_01"],
|
|
37
|
+
"overlap": ["{n}_02"],
|
|
38
|
+
"classify": ["{n}_03a", "{n}_03b", "{n}_03c"],
|
|
39
|
+
"outputs": [],
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _resolve_column(
|
|
44
|
+
conn: DuckDBPyConnection, table: str, explicit: str | None, *, kind: str, side: str
|
|
45
|
+
) -> str | None:
|
|
46
|
+
if explicit is not None:
|
|
47
|
+
return explicit
|
|
48
|
+
detector = detect_code_column if kind == "code" else detect_name_column
|
|
49
|
+
column = detector(conn, table)
|
|
50
|
+
if column is None:
|
|
51
|
+
msg = (
|
|
52
|
+
f"--link-by-{kind} was requested but no {kind} column could be "
|
|
53
|
+
f"auto-detected on the {side} side; pass --{kind}-column-{side} explicitly"
|
|
54
|
+
)
|
|
55
|
+
raise ValueError(msg)
|
|
56
|
+
return column
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def change( # noqa: C901, PLR0912, PLR0913, PLR0915
|
|
60
|
+
old_path: str | Path,
|
|
61
|
+
new_path: str | Path,
|
|
62
|
+
output_path: str | Path | None = None,
|
|
63
|
+
overlay_path: str | Path | None = None,
|
|
64
|
+
*,
|
|
65
|
+
tau_match: float = TAU_MATCH_DEFAULT,
|
|
66
|
+
tau_same: float = TAU_SAME_DEFAULT,
|
|
67
|
+
link_by_code: bool = False,
|
|
68
|
+
link_by_name: bool = False,
|
|
69
|
+
link_mode: str = "either",
|
|
70
|
+
code_column_a: str | None = None,
|
|
71
|
+
code_column_b: str | None = None,
|
|
72
|
+
name_column_a: str | None = None,
|
|
73
|
+
name_column_b: str | None = None,
|
|
74
|
+
threads: int | None = None,
|
|
75
|
+
tmp_dir: str | Path | None = None,
|
|
76
|
+
overwrite: bool = False,
|
|
77
|
+
debug: bool = False,
|
|
78
|
+
step: str | None = None,
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Compare two polygon layer versions and classify every unit's relationship.
|
|
81
|
+
|
|
82
|
+
Processes exactly one old file + one new file per call. Classifies each
|
|
83
|
+
unit as unchanged/renamed/modified/relocated/split/merge/complex/created/
|
|
84
|
+
removed, using spatial overlap (tau_match/tau_same) and, optionally,
|
|
85
|
+
code/name identity linking. Always writes two files: a tabular changelog
|
|
86
|
+
(output_path, CSV/Parquet, "_changelog" suffix if omitted) and a spatial
|
|
87
|
+
overlay layer colored by relationship_class (overlay_path, "_overlay"
|
|
88
|
+
suffix if omitted).
|
|
89
|
+
"""
|
|
90
|
+
if step is not None and step not in _STEP_ORDER:
|
|
91
|
+
msg = f"step must be one of {_STEP_ORDER}, got {step!r}"
|
|
92
|
+
raise ValueError(msg)
|
|
93
|
+
if link_mode not in ("either", "both"):
|
|
94
|
+
msg = f"link_mode must be 'either' or 'both', got {link_mode!r}"
|
|
95
|
+
raise ValueError(msg)
|
|
96
|
+
|
|
97
|
+
old_path = Path(old_path)
|
|
98
|
+
new_path = Path(new_path)
|
|
99
|
+
output_path = (
|
|
100
|
+
Path(output_path)
|
|
101
|
+
if output_path is not None
|
|
102
|
+
else old_path.parent / f"{old_path.stem}_{new_path.stem}_changelog.csv"
|
|
103
|
+
)
|
|
104
|
+
if output_path.suffix not in TABLE_COPY_OPTS:
|
|
105
|
+
msg = (
|
|
106
|
+
f"output file must be one of {sorted(TABLE_COPY_OPTS)} (a tabular "
|
|
107
|
+
"format -- the changelog has no geometry column), got "
|
|
108
|
+
f"{output_path.suffix!r}"
|
|
109
|
+
)
|
|
110
|
+
raise ValueError(msg)
|
|
111
|
+
overlay_path = (
|
|
112
|
+
Path(overlay_path)
|
|
113
|
+
if overlay_path is not None
|
|
114
|
+
else output_path.with_stem(output_path.stem + "_overlay").with_suffix(
|
|
115
|
+
old_path.suffix
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
if overlay_path.suffix not in COPY_OPTS:
|
|
119
|
+
msg = (
|
|
120
|
+
f"overlay file must be one of {sorted(COPY_OPTS)}, "
|
|
121
|
+
f"got {overlay_path.suffix!r}"
|
|
122
|
+
)
|
|
123
|
+
raise ValueError(msg)
|
|
124
|
+
if output_path.exists() and not overwrite:
|
|
125
|
+
msg = f"output already exists: {output_path}"
|
|
126
|
+
raise FileExistsError(msg)
|
|
127
|
+
if overlay_path.exists() and not overwrite:
|
|
128
|
+
msg = f"output already exists: {overlay_path}"
|
|
129
|
+
raise FileExistsError(msg)
|
|
130
|
+
|
|
131
|
+
owns_tmp_dir = tmp_dir is None
|
|
132
|
+
tmp_dir_path = (
|
|
133
|
+
Path(tmp_dir)
|
|
134
|
+
if tmp_dir is not None
|
|
135
|
+
else Path(tempfile.mkdtemp(prefix="topo_tools_"))
|
|
136
|
+
)
|
|
137
|
+
tmp_dir_path.mkdir(exist_ok=True, parents=True)
|
|
138
|
+
|
|
139
|
+
# "_changelog" keeps every table/file this call creates distinct from an
|
|
140
|
+
# extend()/match()/clean() run against the same old_path/tmp_dir, same
|
|
141
|
+
# collision-avoidance reasoning as match's "_match" and clean's "_clean".
|
|
142
|
+
name = old_path.name.replace(".", "_") + "_changelog"
|
|
143
|
+
if not step:
|
|
144
|
+
cleanup_tmp(name, tmp_dir_path, parquet=True)
|
|
145
|
+
|
|
146
|
+
with log_file(name, tmp_dir_path):
|
|
147
|
+
conn = get_connection(name, tmp_dir_path, threads=threads, debug=debug)
|
|
148
|
+
|
|
149
|
+
def _interrupt(_sig: int, _frame: FrameType | None) -> NoReturn:
|
|
150
|
+
conn.interrupt()
|
|
151
|
+
raise KeyboardInterrupt
|
|
152
|
+
|
|
153
|
+
old_handler = signal.signal(signal.SIGINT, _interrupt)
|
|
154
|
+
try:
|
|
155
|
+
logger.info("starting: %s", name)
|
|
156
|
+
for s in _STEP_ORDER:
|
|
157
|
+
if step and step != s:
|
|
158
|
+
continue
|
|
159
|
+
if debug:
|
|
160
|
+
logger.info("=== %s ===", s)
|
|
161
|
+
if s == "inputs":
|
|
162
|
+
inputs.main(conn, name, old_path, new_path)
|
|
163
|
+
elif s == "overlap":
|
|
164
|
+
overlap.main(conn, name)
|
|
165
|
+
elif s == "classify":
|
|
166
|
+
resolved_code_a = (
|
|
167
|
+
_resolve_column(
|
|
168
|
+
conn, f"{name}_a_01", code_column_a, kind="code", side="a"
|
|
169
|
+
)
|
|
170
|
+
if link_by_code
|
|
171
|
+
else code_column_a
|
|
172
|
+
)
|
|
173
|
+
resolved_code_b = (
|
|
174
|
+
_resolve_column(
|
|
175
|
+
conn, f"{name}_b_01", code_column_b, kind="code", side="b"
|
|
176
|
+
)
|
|
177
|
+
if link_by_code
|
|
178
|
+
else code_column_b
|
|
179
|
+
)
|
|
180
|
+
resolved_name_a = (
|
|
181
|
+
_resolve_column(
|
|
182
|
+
conn, f"{name}_a_01", name_column_a, kind="name", side="a"
|
|
183
|
+
)
|
|
184
|
+
if link_by_name
|
|
185
|
+
else name_column_a
|
|
186
|
+
)
|
|
187
|
+
resolved_name_b = (
|
|
188
|
+
_resolve_column(
|
|
189
|
+
conn, f"{name}_b_01", name_column_b, kind="name", side="b"
|
|
190
|
+
)
|
|
191
|
+
if link_by_name
|
|
192
|
+
else name_column_b
|
|
193
|
+
)
|
|
194
|
+
classify.main(
|
|
195
|
+
conn,
|
|
196
|
+
name,
|
|
197
|
+
tau_match=tau_match,
|
|
198
|
+
tau_same=tau_same,
|
|
199
|
+
link_by_code=link_by_code,
|
|
200
|
+
link_by_name=link_by_name,
|
|
201
|
+
link_mode=link_mode,
|
|
202
|
+
code_col_a=resolved_code_a,
|
|
203
|
+
code_col_b=resolved_code_b,
|
|
204
|
+
name_col_a=resolved_name_a,
|
|
205
|
+
name_col_b=resolved_name_b,
|
|
206
|
+
)
|
|
207
|
+
elif s == "outputs":
|
|
208
|
+
outputs.main(conn, name, output_path, overlay_path, debug=debug)
|
|
209
|
+
if debug:
|
|
210
|
+
only = None
|
|
211
|
+
if step and step in _STEP_TABLES:
|
|
212
|
+
only = {t.format(n=name) for t in _STEP_TABLES[step]}
|
|
213
|
+
export_debug_tables(conn, tmp_dir_path, only=only)
|
|
214
|
+
logger.info("done: %s", name)
|
|
215
|
+
finally:
|
|
216
|
+
signal.signal(signal.SIGINT, old_handler)
|
|
217
|
+
conn.close()
|
|
218
|
+
if not step and not debug:
|
|
219
|
+
cleanup_tmp(name, tmp_dir_path)
|
|
220
|
+
if owns_tmp_dir:
|
|
221
|
+
if debug:
|
|
222
|
+
logger.info("tmp_dir preserved for --debug: %s", tmp_dir_path)
|
|
223
|
+
else:
|
|
224
|
+
shutil.rmtree(tmp_dir_path, ignore_errors=True)
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Public API: detect and fix coverage defects (gaps, overlaps) in a polygon layer."""
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import signal
|
|
5
|
+
import tempfile
|
|
6
|
+
from logging import getLogger
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from types import FrameType
|
|
9
|
+
from typing import NoReturn
|
|
10
|
+
|
|
11
|
+
from topo_tools.core.clean import _01_inputs as inputs
|
|
12
|
+
from topo_tools.core.clean import _02_issues as issues
|
|
13
|
+
from topo_tools.core.clean import _03_clean as clean_stage
|
|
14
|
+
from topo_tools.core.clean import _04_outputs as outputs
|
|
15
|
+
from topo_tools.core.clean._constants import SLIVER_TOLERANCE_DEFAULT_M
|
|
16
|
+
from topo_tools.core.duckdb_utils import (
|
|
17
|
+
cleanup_tmp,
|
|
18
|
+
export_debug_tables,
|
|
19
|
+
get_connection,
|
|
20
|
+
log_file,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
logger = getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
_STEP_ORDER = ["inputs", "issues", "clean", "outputs"]
|
|
26
|
+
|
|
27
|
+
_STEP_TABLES = {
|
|
28
|
+
"inputs": ["{n}_01"],
|
|
29
|
+
"issues": ["{n}_02"],
|
|
30
|
+
"clean": ["{n}_03"],
|
|
31
|
+
"outputs": [],
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _parse_gap_width(value: str) -> tuple[str, float | None]:
|
|
36
|
+
if value in ("auto", "all"):
|
|
37
|
+
return value, None
|
|
38
|
+
try:
|
|
39
|
+
return "value", float(value)
|
|
40
|
+
except ValueError:
|
|
41
|
+
msg = f"--gap-width must be 'auto', 'all', or a number in meters, got {value!r}"
|
|
42
|
+
raise ValueError(msg) from None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _parse_snap_tolerance(value: str) -> tuple[str, float | None]:
|
|
46
|
+
if value == "auto":
|
|
47
|
+
return "auto", None
|
|
48
|
+
try:
|
|
49
|
+
return "value", float(value)
|
|
50
|
+
except ValueError:
|
|
51
|
+
msg = f"--snap-tolerance must be 'auto' or a number in meters, got {value!r}"
|
|
52
|
+
raise ValueError(msg) from None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def clean( # noqa: C901, PLR0912, PLR0913, PLR0915
|
|
56
|
+
input_path: str | Path,
|
|
57
|
+
output_path: str | Path | None = None,
|
|
58
|
+
issues_path: str | Path | None = None,
|
|
59
|
+
*,
|
|
60
|
+
gap_width: str = "all",
|
|
61
|
+
snap_tolerance: str = "auto",
|
|
62
|
+
sliver_tolerance_m: float = SLIVER_TOLERANCE_DEFAULT_M,
|
|
63
|
+
threads: int | None = None,
|
|
64
|
+
tmp_dir: str | Path | None = None,
|
|
65
|
+
overwrite: bool = False,
|
|
66
|
+
debug: bool = False,
|
|
67
|
+
step: str | None = None,
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Detect and fix gap/overlap defects in a single polygon layer.
|
|
70
|
+
|
|
71
|
+
Processes exactly one file per call. Slivers (near-miss boundary
|
|
72
|
+
mismatches) are detected and reported but never auto-fixed -- widening
|
|
73
|
+
ST_CoverageClean's snap tolerance to force one closed re-nodes the whole
|
|
74
|
+
coverage, not just the defect site. Always writes two files: the cleaned
|
|
75
|
+
dataset (output_path, "_cleaned" suffix if omitted) and an issues report
|
|
76
|
+
(issues_path, "_issues" suffix if omitted) so a human can review flagged
|
|
77
|
+
slivers -- and any gaps left unfilled -- before deciding what to do with
|
|
78
|
+
them.
|
|
79
|
+
"""
|
|
80
|
+
if step is not None and step not in _STEP_ORDER:
|
|
81
|
+
msg = f"step must be one of {_STEP_ORDER}, got {step!r}"
|
|
82
|
+
raise ValueError(msg)
|
|
83
|
+
|
|
84
|
+
parsed_gap_width = _parse_gap_width(gap_width)
|
|
85
|
+
parsed_snap_tolerance = _parse_snap_tolerance(snap_tolerance)
|
|
86
|
+
|
|
87
|
+
input_path = Path(input_path)
|
|
88
|
+
output_path = (
|
|
89
|
+
Path(output_path)
|
|
90
|
+
if output_path is not None
|
|
91
|
+
else input_path.with_stem(input_path.stem + "_cleaned")
|
|
92
|
+
)
|
|
93
|
+
issues_path = (
|
|
94
|
+
Path(issues_path)
|
|
95
|
+
if issues_path is not None
|
|
96
|
+
else output_path.with_stem(output_path.stem + "_issues")
|
|
97
|
+
)
|
|
98
|
+
if issues_path.suffix == ".shp":
|
|
99
|
+
msg = (
|
|
100
|
+
"issues file cannot be Shapefile: the issues report mixes Polygon "
|
|
101
|
+
"(gap/overlap) and LineString (sliver) geometry in one table, which "
|
|
102
|
+
"Shapefile's single-geometry-type-per-file format can't represent"
|
|
103
|
+
)
|
|
104
|
+
raise ValueError(msg)
|
|
105
|
+
if output_path.exists() and not overwrite:
|
|
106
|
+
msg = f"output already exists: {output_path}"
|
|
107
|
+
raise FileExistsError(msg)
|
|
108
|
+
if issues_path.exists() and not overwrite:
|
|
109
|
+
msg = f"output already exists: {issues_path}"
|
|
110
|
+
raise FileExistsError(msg)
|
|
111
|
+
|
|
112
|
+
owns_tmp_dir = tmp_dir is None
|
|
113
|
+
tmp_dir_path = (
|
|
114
|
+
Path(tmp_dir)
|
|
115
|
+
if tmp_dir is not None
|
|
116
|
+
else Path(tempfile.mkdtemp(prefix="topo_tools_"))
|
|
117
|
+
)
|
|
118
|
+
tmp_dir_path.mkdir(exist_ok=True, parents=True)
|
|
119
|
+
|
|
120
|
+
name = input_path.name.replace(".", "_") + "_clean"
|
|
121
|
+
if not step:
|
|
122
|
+
cleanup_tmp(name, tmp_dir_path, parquet=True)
|
|
123
|
+
|
|
124
|
+
with log_file(name, tmp_dir_path):
|
|
125
|
+
conn = get_connection(name, tmp_dir_path, threads=threads, debug=debug)
|
|
126
|
+
|
|
127
|
+
def _interrupt(_sig: int, _frame: FrameType | None) -> NoReturn:
|
|
128
|
+
conn.interrupt()
|
|
129
|
+
raise KeyboardInterrupt
|
|
130
|
+
|
|
131
|
+
old_handler = signal.signal(signal.SIGINT, _interrupt)
|
|
132
|
+
try:
|
|
133
|
+
logger.info("starting: %s", name)
|
|
134
|
+
for s in _STEP_ORDER:
|
|
135
|
+
if step and step != s:
|
|
136
|
+
continue
|
|
137
|
+
if debug:
|
|
138
|
+
logger.info("=== %s ===", s)
|
|
139
|
+
if s == "inputs":
|
|
140
|
+
inputs.main(conn, name, input_path)
|
|
141
|
+
elif s == "issues":
|
|
142
|
+
issues.main(
|
|
143
|
+
conn, name, sliver_tolerance_m=sliver_tolerance_m, debug=debug
|
|
144
|
+
)
|
|
145
|
+
elif s == "clean":
|
|
146
|
+
clean_stage.main(
|
|
147
|
+
conn,
|
|
148
|
+
name,
|
|
149
|
+
gap_width=parsed_gap_width,
|
|
150
|
+
snap_tolerance=parsed_snap_tolerance,
|
|
151
|
+
debug=debug,
|
|
152
|
+
)
|
|
153
|
+
elif s == "outputs":
|
|
154
|
+
outputs.main(conn, name, output_path, issues_path, debug=debug)
|
|
155
|
+
if debug:
|
|
156
|
+
only = None
|
|
157
|
+
if step and step in _STEP_TABLES:
|
|
158
|
+
only = {t.format(n=name) for t in _STEP_TABLES[step]}
|
|
159
|
+
export_debug_tables(conn, tmp_dir_path, only=only)
|
|
160
|
+
logger.info("done: %s", name)
|
|
161
|
+
finally:
|
|
162
|
+
signal.signal(signal.SIGINT, old_handler)
|
|
163
|
+
conn.close()
|
|
164
|
+
if not step and not debug:
|
|
165
|
+
cleanup_tmp(name, tmp_dir_path)
|
|
166
|
+
if owns_tmp_dir:
|
|
167
|
+
if debug:
|
|
168
|
+
logger.info("tmp_dir preserved for --debug: %s", tmp_dir_path)
|
|
169
|
+
else:
|
|
170
|
+
shutil.rmtree(tmp_dir_path, ignore_errors=True)
|