topo-tools 0.1.0__py3-none-any.whl
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/__init__.py +6 -0
- topo_tools/__main__.py +6 -0
- topo_tools/api/__init__.py +8 -0
- topo_tools/api/change.py +224 -0
- topo_tools/api/clean.py +170 -0
- topo_tools/api/extend.py +128 -0
- topo_tools/api/match.py +140 -0
- topo_tools/cli/__init__.py +1 -0
- topo_tools/cli/main.py +456 -0
- topo_tools/core/__init__.py +1 -0
- topo_tools/core/change/_01_inputs.py +20 -0
- topo_tools/core/change/_02_overlap.py +85 -0
- topo_tools/core/change/_03_classify.py +428 -0
- topo_tools/core/change/_04_outputs.py +62 -0
- topo_tools/core/change/__init__.py +1 -0
- topo_tools/core/change/_columns.py +53 -0
- topo_tools/core/change/_constants.py +28 -0
- topo_tools/core/change/_unionfind.py +51 -0
- topo_tools/core/clean/_01_inputs.py +18 -0
- topo_tools/core/clean/_02_issues.py +295 -0
- topo_tools/core/clean/_03_clean.py +82 -0
- topo_tools/core/clean/_04_outputs.py +69 -0
- topo_tools/core/clean/__init__.py +5 -0
- topo_tools/core/clean/_constants.py +31 -0
- topo_tools/core/clean/_units.py +44 -0
- topo_tools/core/duckdb_utils.py +195 -0
- topo_tools/core/extend/_01_inputs.py +88 -0
- topo_tools/core/extend/_02_lines.py +50 -0
- topo_tools/core/extend/_03_points.py +165 -0
- topo_tools/core/extend/_04_voronoi.py +45 -0
- topo_tools/core/extend/_05_merge.py +89 -0
- topo_tools/core/extend/_06_outputs.py +32 -0
- topo_tools/core/extend/__init__.py +1 -0
- topo_tools/core/extend/_constants.py +51 -0
- topo_tools/core/extend/_coverage.py +102 -0
- topo_tools/core/extend/attempt.py +119 -0
- topo_tools/core/match/_01_inputs.py +20 -0
- topo_tools/core/match/_02_assign.py +83 -0
- topo_tools/core/match/_03_groups.py +174 -0
- topo_tools/core/match/_04_merge.py +22 -0
- topo_tools/core/match/_05_outputs.py +40 -0
- topo_tools/core/match/__init__.py +1 -0
- topo_tools/core/match/_clip.py +27 -0
- topo_tools/core/match/_constants.py +6 -0
- topo_tools-0.1.0.dist-info/METADATA +66 -0
- topo_tools-0.1.0.dist-info/RECORD +48 -0
- topo_tools-0.1.0.dist-info/WHEEL +4 -0
- topo_tools-0.1.0.dist-info/entry_points.txt +3 -0
topo_tools/__init__.py
ADDED
topo_tools/__main__.py
ADDED
topo_tools/api/change.py
ADDED
|
@@ -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)
|
topo_tools/api/clean.py
ADDED
|
@@ -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)
|
topo_tools/api/extend.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Public API: extend polygon boundaries outward with Voronoi diagrams."""
|
|
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.duckdb_utils import (
|
|
12
|
+
cleanup_tmp,
|
|
13
|
+
export_debug_tables,
|
|
14
|
+
get_connection,
|
|
15
|
+
log_file,
|
|
16
|
+
)
|
|
17
|
+
from topo_tools.core.extend import _01_inputs as inputs
|
|
18
|
+
from topo_tools.core.extend import _02_lines as lines
|
|
19
|
+
from topo_tools.core.extend import _05_merge as merge
|
|
20
|
+
from topo_tools.core.extend import _06_outputs as outputs
|
|
21
|
+
from topo_tools.core.extend import attempt
|
|
22
|
+
|
|
23
|
+
logger = getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
_STEP_ORDER = ["inputs", "lines", "attempt", "merge", "outputs"]
|
|
26
|
+
|
|
27
|
+
_STEP_TABLES = {
|
|
28
|
+
"inputs": ["{n}_01"],
|
|
29
|
+
"lines": ["{n}_02"],
|
|
30
|
+
"attempt": [
|
|
31
|
+
"{n}_03a",
|
|
32
|
+
"{n}_03_tmp1",
|
|
33
|
+
"{n}_03_tmp2",
|
|
34
|
+
"{n}_03_tmp3",
|
|
35
|
+
"{n}_03_tmp4",
|
|
36
|
+
"{n}_03b",
|
|
37
|
+
"{n}_04",
|
|
38
|
+
"{n}_04_tmp1",
|
|
39
|
+
"{n}_04_tmp2",
|
|
40
|
+
],
|
|
41
|
+
"merge": ["{n}_05", "{n}_05_tmp1", "{n}_05_tmp2", "{n}_05_tmp3"],
|
|
42
|
+
"outputs": [],
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def extend( # noqa: C901, PLR0912, PLR0913, PLR0915
|
|
47
|
+
input_path: str | Path,
|
|
48
|
+
output_path: str | Path | None = None,
|
|
49
|
+
*,
|
|
50
|
+
memory_gb: float = 4.0,
|
|
51
|
+
threads: int | None = None,
|
|
52
|
+
tmp_dir: str | Path | None = None,
|
|
53
|
+
overwrite: bool = False,
|
|
54
|
+
debug: bool = False,
|
|
55
|
+
step: str | None = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Extend polygon boundaries outward with Voronoi diagrams to fill coverage gaps.
|
|
58
|
+
|
|
59
|
+
Processes exactly one file per call. If output_path is omitted, it defaults
|
|
60
|
+
to input_path with an "_extended" suffix in the same directory.
|
|
61
|
+
"""
|
|
62
|
+
if step is not None and step not in _STEP_ORDER:
|
|
63
|
+
msg = f"step must be one of {_STEP_ORDER}, got {step!r}"
|
|
64
|
+
raise ValueError(msg)
|
|
65
|
+
|
|
66
|
+
input_path = Path(input_path)
|
|
67
|
+
output_path = (
|
|
68
|
+
Path(output_path)
|
|
69
|
+
if output_path is not None
|
|
70
|
+
else input_path.with_stem(input_path.stem + "_extended")
|
|
71
|
+
)
|
|
72
|
+
if output_path.exists() and not overwrite:
|
|
73
|
+
msg = f"output already exists: {output_path}"
|
|
74
|
+
raise FileExistsError(msg)
|
|
75
|
+
|
|
76
|
+
owns_tmp_dir = tmp_dir is None
|
|
77
|
+
tmp_dir_path = (
|
|
78
|
+
Path(tmp_dir)
|
|
79
|
+
if tmp_dir is not None
|
|
80
|
+
else Path(tempfile.mkdtemp(prefix="topo_tools_"))
|
|
81
|
+
)
|
|
82
|
+
tmp_dir_path.mkdir(exist_ok=True, parents=True)
|
|
83
|
+
|
|
84
|
+
name = input_path.name.replace(".", "_")
|
|
85
|
+
if not step:
|
|
86
|
+
cleanup_tmp(name, tmp_dir_path, parquet=True)
|
|
87
|
+
|
|
88
|
+
with log_file(name, tmp_dir_path):
|
|
89
|
+
conn = get_connection(name, tmp_dir_path, threads=threads, debug=debug)
|
|
90
|
+
|
|
91
|
+
def _interrupt(_sig: int, _frame: FrameType | None) -> NoReturn:
|
|
92
|
+
conn.interrupt()
|
|
93
|
+
raise KeyboardInterrupt
|
|
94
|
+
|
|
95
|
+
old_handler = signal.signal(signal.SIGINT, _interrupt)
|
|
96
|
+
try:
|
|
97
|
+
logger.info("starting: %s", name)
|
|
98
|
+
for s in _STEP_ORDER:
|
|
99
|
+
if step and step != s:
|
|
100
|
+
continue
|
|
101
|
+
if debug:
|
|
102
|
+
logger.info("=== %s ===", s)
|
|
103
|
+
if s == "inputs":
|
|
104
|
+
inputs.main(conn, name, input_path)
|
|
105
|
+
elif s == "lines":
|
|
106
|
+
lines.main(conn, name)
|
|
107
|
+
elif s == "attempt":
|
|
108
|
+
attempt.main(conn, name, memory_gb=memory_gb, debug=debug)
|
|
109
|
+
elif s == "merge":
|
|
110
|
+
merge.main(conn, name, debug=debug)
|
|
111
|
+
elif s == "outputs":
|
|
112
|
+
outputs.main(conn, name, output_path, debug=debug)
|
|
113
|
+
if debug:
|
|
114
|
+
only = None
|
|
115
|
+
if step and step in _STEP_TABLES:
|
|
116
|
+
only = {t.format(n=name) for t in _STEP_TABLES[step]}
|
|
117
|
+
export_debug_tables(conn, tmp_dir_path, only=only)
|
|
118
|
+
logger.info("done: %s", name)
|
|
119
|
+
finally:
|
|
120
|
+
signal.signal(signal.SIGINT, old_handler)
|
|
121
|
+
conn.close()
|
|
122
|
+
if not step and not debug:
|
|
123
|
+
cleanup_tmp(name, tmp_dir_path)
|
|
124
|
+
if owns_tmp_dir:
|
|
125
|
+
if debug:
|
|
126
|
+
logger.info("tmp_dir preserved for --debug: %s", tmp_dir_path)
|
|
127
|
+
else:
|
|
128
|
+
shutil.rmtree(tmp_dir_path, ignore_errors=True)
|
topo_tools/api/match.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Public API: match child polygons to parent boundaries, then extend to fill gaps."""
|
|
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.duckdb_utils import (
|
|
12
|
+
cleanup_tmp,
|
|
13
|
+
export_debug_tables,
|
|
14
|
+
get_connection,
|
|
15
|
+
log_file,
|
|
16
|
+
)
|
|
17
|
+
from topo_tools.core.match import _01_inputs as inputs
|
|
18
|
+
from topo_tools.core.match import _02_assign as assign
|
|
19
|
+
from topo_tools.core.match import _03_groups as groups
|
|
20
|
+
from topo_tools.core.match import _04_merge as merge
|
|
21
|
+
from topo_tools.core.match import _05_outputs as outputs
|
|
22
|
+
|
|
23
|
+
logger = getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
_STEP_ORDER = ["inputs", "assign", "groups", "merge", "outputs"]
|
|
26
|
+
|
|
27
|
+
_STEP_TABLES = {
|
|
28
|
+
"inputs": ["{n}_child_01", "{n}_parent_01"],
|
|
29
|
+
"assign": ["{n}_02_pairs", "{n}_02_assign", "{n}_02_unassigned"],
|
|
30
|
+
# "groups" is deliberately absent: group ids aren't known ahead of time
|
|
31
|
+
# (dynamic "{n}_g{parent_fid}" names), so it falls through to the
|
|
32
|
+
# "export everything currently in the connection" default below, same as
|
|
33
|
+
# a full (no --step) run.
|
|
34
|
+
"merge": ["{n}_04"],
|
|
35
|
+
"outputs": [],
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def match( # noqa: C901, PLR0912, PLR0913, PLR0915
|
|
40
|
+
input_path: str | Path,
|
|
41
|
+
clip_path: str | Path,
|
|
42
|
+
output_path: str | Path | None = None,
|
|
43
|
+
*,
|
|
44
|
+
memory_gb: float = 4.0,
|
|
45
|
+
threads: int | None = None,
|
|
46
|
+
tmp_dir: str | Path | None = None,
|
|
47
|
+
overwrite: bool = False,
|
|
48
|
+
debug: bool = False,
|
|
49
|
+
step: str | None = None,
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Match child polygons to their best-overlapping parent, then extend to fill gaps.
|
|
52
|
+
|
|
53
|
+
Processes exactly one child file + one parent/clip file per call. Children
|
|
54
|
+
are assigned to whichever parent polygon they share the largest area with,
|
|
55
|
+
grouped by that assignment, extended within each group independently (in
|
|
56
|
+
an isolated subprocess per group), clipped to that group's own parent,
|
|
57
|
+
reassembled, and coverage-cleaned once as a whole. If output_path is
|
|
58
|
+
omitted, it defaults to input_path with a "_matched" suffix in the same
|
|
59
|
+
directory.
|
|
60
|
+
"""
|
|
61
|
+
if step is not None and step not in _STEP_ORDER:
|
|
62
|
+
msg = f"step must be one of {_STEP_ORDER}, got {step!r}"
|
|
63
|
+
raise ValueError(msg)
|
|
64
|
+
|
|
65
|
+
input_path = Path(input_path)
|
|
66
|
+
clip_path = Path(clip_path)
|
|
67
|
+
output_path = (
|
|
68
|
+
Path(output_path)
|
|
69
|
+
if output_path is not None
|
|
70
|
+
else input_path.with_stem(input_path.stem + "_matched")
|
|
71
|
+
)
|
|
72
|
+
if output_path.exists() and not overwrite:
|
|
73
|
+
msg = f"output already exists: {output_path}"
|
|
74
|
+
raise FileExistsError(msg)
|
|
75
|
+
|
|
76
|
+
owns_tmp_dir = tmp_dir is None
|
|
77
|
+
tmp_dir_path = (
|
|
78
|
+
Path(tmp_dir)
|
|
79
|
+
if tmp_dir is not None
|
|
80
|
+
else Path(tempfile.mkdtemp(prefix="topo_tools_"))
|
|
81
|
+
)
|
|
82
|
+
tmp_dir_path.mkdir(exist_ok=True, parents=True)
|
|
83
|
+
|
|
84
|
+
# "_match" keeps every table/file this call creates distinct from an
|
|
85
|
+
# extend() run against the same input_path/tmp_dir -- e.g. extend's bare
|
|
86
|
+
# "{name}_04" (Voronoi cells) would otherwise collide with match's own
|
|
87
|
+
# bare "{name}_04" (final coverage-cleaned output) if both tools shared a
|
|
88
|
+
# tmp_dir and were run with --debug for side-by-side inspection.
|
|
89
|
+
name = input_path.name.replace(".", "_") + "_match"
|
|
90
|
+
if not step:
|
|
91
|
+
cleanup_tmp(name, tmp_dir_path, parquet=True)
|
|
92
|
+
|
|
93
|
+
with log_file(name, tmp_dir_path):
|
|
94
|
+
conn = get_connection(name, tmp_dir_path, threads=threads, debug=debug)
|
|
95
|
+
|
|
96
|
+
def _interrupt(_sig: int, _frame: FrameType | None) -> NoReturn:
|
|
97
|
+
conn.interrupt()
|
|
98
|
+
raise KeyboardInterrupt
|
|
99
|
+
|
|
100
|
+
old_handler = signal.signal(signal.SIGINT, _interrupt)
|
|
101
|
+
try:
|
|
102
|
+
logger.info("starting: %s", name)
|
|
103
|
+
for s in _STEP_ORDER:
|
|
104
|
+
if step and step != s:
|
|
105
|
+
continue
|
|
106
|
+
if debug:
|
|
107
|
+
logger.info("=== %s ===", s)
|
|
108
|
+
if s == "inputs":
|
|
109
|
+
inputs.main(conn, name, input_path, clip_path)
|
|
110
|
+
elif s == "assign":
|
|
111
|
+
assign.main(conn, name)
|
|
112
|
+
elif s == "groups":
|
|
113
|
+
groups.main(
|
|
114
|
+
conn,
|
|
115
|
+
name,
|
|
116
|
+
tmp_dir_path,
|
|
117
|
+
memory_gb=memory_gb,
|
|
118
|
+
threads=threads,
|
|
119
|
+
debug=debug,
|
|
120
|
+
)
|
|
121
|
+
elif s == "merge":
|
|
122
|
+
merge.main(conn, name, debug=debug)
|
|
123
|
+
elif s == "outputs":
|
|
124
|
+
outputs.main(conn, name, output_path, debug=debug)
|
|
125
|
+
if debug:
|
|
126
|
+
only = None
|
|
127
|
+
if step and step in _STEP_TABLES:
|
|
128
|
+
only = {t.format(n=name) for t in _STEP_TABLES[step]}
|
|
129
|
+
export_debug_tables(conn, tmp_dir_path, only=only)
|
|
130
|
+
logger.info("done: %s", name)
|
|
131
|
+
finally:
|
|
132
|
+
signal.signal(signal.SIGINT, old_handler)
|
|
133
|
+
conn.close()
|
|
134
|
+
if not step and not debug:
|
|
135
|
+
cleanup_tmp(name, tmp_dir_path)
|
|
136
|
+
if owns_tmp_dir:
|
|
137
|
+
if debug:
|
|
138
|
+
logger.info("tmp_dir preserved for --debug: %s", tmp_dir_path)
|
|
139
|
+
else:
|
|
140
|
+
shutil.rmtree(tmp_dir_path, ignore_errors=True)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Click CLI commands — the only layer allowed to import click."""
|