change
change compares two versions of a polygon layer (old and new) and
classifies every unit as unchanged / renamed / modified / relocated /
split / merge / complex / created / removed, using spatial overlap
and, optionally, code/name identity linking. Ported from the sister JS app's
interactive tool (topo-tools-js/src/lib/tools/polygon-changelog/, still
internally named polygon-changelog/cw_* from its original name "Boundary
Cross-walk"); this doc covers what changed in the port and the
classification algorithm's non-obvious pieces.
Usage¶
OUTPUT_FILE (positional, optional; the tabular changelog, CSV or
GeoParquet) defaults to a name combining both stems with a _changelog
suffix. A spatial overlay layer colored by relationship_class is always
written alongside it.
| Option | Description |
|---|---|
--overlay-file |
Spatial overlay layer path. Defaults to OUTPUT_FILE with an _overlay suffix. |
--tau-match |
Minimum overlap coverage for two units to be spatially linked (default 0.8). |
--tau-same |
Minimum IoU for a 1:1 linked pair to be unchanged/renamed rather than modified (default 0.98). |
--link-by-code |
Also link units sharing a unique code value across versions. |
--link-by-name |
Also link units sharing a unique name value across versions. |
--link-mode |
How code/name identity matches combine (either/both, default either; only matters if both link flags are set). |
--code-column-a / --code-column-b |
Old/new-side code column; auto-detected if omitted. |
--name-column-a / --name-column-b |
Old/new-side name column; auto-detected if omitted. |
--overwrite |
Overwrite an existing output file. |
--threads |
DuckDB thread count. |
--debug |
Keep intermediate tables, export to Parquet, log timing/memory per query. |
--tmp-dir |
Intermediate DuckDB + Parquet location. |
--step |
Run only one named stage: inputs, overlap, classify, outputs. |
# Also link units sharing a unique pcode across versions
topo-tools change old.gpkg new.gpkg --link-by-code
# Loosen the "related" threshold for heavily redrawn boundaries
topo-tools change old.parquet new.parquet --tau-match 0.6
Run topo-tools change --help for the full, always-current option list.
Pipeline¶
_01_inputs: loads and coverage-cleans both layers by calling the sharedcore.io.read_reproject_and_clean()helper twice ({name}_a_01= old,{name}_b_01= new). Unliketopo-clean,changeisn't trying to detect defects in the raw input (it's comparing two whole layers), so pre-cleaning each side reduces the risk of native GEOS choking on invalid geometry duringST_Intersection, with no downside (same reasoning asedge-match's inputs stage, which also pre-cleans both its layers)._02_overlap: computesshared_area/coverage_a/coverage_b/ioufor every touching(a_fid, b_fid)pair._03_classify: identity + spatial union-find clustering and cardinality-based classification, run in Python; assembles the final changelog table._04_outputs: builds the spatial overlay render layer and exports both artifacts. No topology hard-gate here (unlikeedge-extend/edge-match/topo-clean):changeis a read-only comparison, not a fix, so there's nothing to validate against.
Why the WASM point-sampling fallback is dropped¶
JS's src/lib/db/overlap.ts tries exact ST_Intersection first, and falls
back to a 32×32 point-sampling estimate on failure, working around a
documented WASM-only GEOS OverlayNG bug ("found non-noded intersection") on
near-coincident, independently-digitized boundaries. JS's own commit history
(0672282, replacing an earlier noded-overlay retry) found this fallback
"fails identically even on the native CLI and adds no value", i.e. the bug
is WASM-specific. Since this repo runs native DuckDB, the port always uses
exact ST_Intersection; there is no sampling fallback, no
ComparisonMethod enum, and no capping tau_same at 0.99 for a sampling
run (JS's UI-only accommodation for the fallback's lower precision).
Overlap computation¶
_02_overlap.py mirrors core/assign/_many.py's proven pattern: both
layers are exploded into parts (UNNEST(ST_Dump(geom))) before the join, so
a multi-part fid (a country with offshore islands) doesn't get one bbox
spanning everything and defeat the prefilter. The join uses scalar
ST_XMin/ST_XMax/ST_YMin/ST_YMax predicates, not ST_Within/
ST_Intersects alone in the JOIN condition: that triggers DuckDB's
SPATIAL_JOIN operator and its ~1x-RAM virtual reservation (see
docs/explanation/topology.md). Unlike _02_assign.py (which keeps only the top-1
parent per child), every pair with shared_area > 0 is kept: classification
needs the full pair graph, not just the best match per fid.
Intersection crumbs below INTERSECTION_SLIVER_DEG2 (1e-12 deg², ~1cm²,
ported as-is from JS) are dropped by their raw, untransformed degree² area,
a cheap pre-filter before the equal-area transform, which is only ever
applied to surviving intersection geometry (not the whole layer, to bound
the cost). Areas and ratios use EPSG:8857 (Equal Earth), matching edge-match's
own reasoning for avoiding raw EPSG:4326 degree-area bias toward
higher-latitude units. EQUAL_AREA_CRS lives in the shared
core/constants.py and is imported from there, not duplicated as a
separate literal in change's own _constants.py the way it once was;
change still stays decoupled from edge-match/topo-clean the same way they're
decoupled from each other.
Classification: identity + spatial union-find¶
_03_classify.py ports classify.ts line-for-line, but runs the union-find
and cardinality classification in Python rather than SQL. This is safe under
this repo's memory model: the algorithm scales with feature count, not
vertex count: a 500K-polygon admin layer is trivial to hold as Python
dicts/sets, unlike the vertex-scaled Voronoi/coverage-clean work edge-extend/
topo-clean do. Pair rows are fetched once via conn.execute(...).fetchall(),
classified entirely in memory, and written back via conn.executemany();
no batching needed the way JS's raw-SQL-string INSERT ... VALUES batching
was (_unionfind.py mirrors JS's path-compressed, union-by-rank
unionFind.ts directly).
Two-phase matching:
- Identity (only when
--link-by-code/--link-by-nameis set): a pair is a candidate identity match if its code and/or name values are equal and unique on each side (duplicate values like a shared "No_Pcode" placeholder are excluded, matching on them would union every polygon sharing that value into one cluster). NULL-safe: a NULL code/name on either side never counts as a match. - Spatial: any pair with
max(coverage_a, coverage_b) >= tau_matchis unioned, unless already claimed by phase 1.
The identity claim guard (ported from JS commit f844472) is the most
load-bearing piece of this stage: an identity-matched pair is only
pre-unioned ahead of spatial matching if every other spatial
tau_match-passing neighbor of both fids is also identity-covered.
Without this guard, a genuine split, old unit A splits into new B1
(inheriting A's code) and new B2 (a new code), would incorrectly collapse
into a false 1:1 identity match on A↔B1, leaving B2 stranded as a spurious
created unit instead of correctly grouping A, B1, and B2 into one split
cluster. The guard defers to phase 2's spatial clustering whenever any
spatial neighbor lacks its own identity match, letting the whole cluster
resolve by cardinality instead. Verified with a synthetic fixture
(tests/test_change.py's GD1/GD2 region): a real split where only
one child inherits the parent's code stays classified split under
--link-by-code, not a false unchanged/renamed pair plus an orphaned
created.
Cardinality classification (na/nb = member count per side of a
connected component):
| na | nb | condition | class |
|---|---|---|---|
| 1 | 0 | n/a | removed |
| 0 | 1 | n/a | created |
| 1 | 1 | identity-only, no spatial tau_match pass |
relocated |
| 1 | 1 | spatial pass, iou >= tau_same, code/name unchanged |
unchanged |
| 1 | 1 | spatial pass, iou >= tau_same, code/name differ |
renamed |
| 1 | 1 | spatial pass, iou < tau_same |
modified |
| 1 | >1 | n/a | split |
| >1 | 1 | n/a | merge |
| >1 | >1 | n/a | complex |
renamed only fires when linking is enabled: in pure geometry mode,
code/name are never consulted for classification (per JS commit 7244e8a's
"geometry-first mode never consults code/name for renamed"), only for
display in the output table.
Output artifacts¶
JS's export.ts registers two sources (crosswalk_changelog tabular,
crosswalk_overlay spatial) but only wires the tabular one to a download
button; the spatial overlay exists internally but was never reachable from
the UI. Headless has no such constraint, so change() always writes
both:
- Tabular changelog (
output_path,.csvdefault or.parquet, no geometry column): one row per matched pair, plus one row per unmatched singleton (purecreated/removed, or asplit/mergeremnant with nothing on the other side). Columns:code_a, name_a, code_b, name_b, relationship_class, match_method, a_in_b (coverage_a, 3dp), b_in_a (coverage_b, 3dp), similarity (iou, 3dp), threshold_match, threshold_same, link_by_code, link_by_name, link_mode. The last five columns echo the run's own parameters into every row (ported from JS commit06c073a, "so identity-mode runs are self-documenting"), a reproducibility/audit-trail feature worth preserving for a batch tool where the invocation's flags aren't otherwise recorded alongside the output.code_a/code_b/name_a/name_bareNULLunless the corresponding--code-column-*/--name-column-*was resolved (explicitly or via auto-detection under a--link-by-*flag): a pure spatial run with no linking and no explicit column has no identity columns to report. - Spatial overlay layer (
overlay_path, defaults toold_path's own format): every new-version unit tagged with itsrelationship_class, plus every old-version unit classedremoved(gone in the new version, so no new polygon stands in for it). Together these tile the comparison area exactly once, colored by what happened (ported from JS'srender.ts:stageRender). Single geometry type (Polygon/MultiPolygon), so any ofedge-extend's four formats is valid, unliketopo-clean's mixed-type issues file.
Column auto-detection¶
core/change/_columns.py ports JS's src/lib/db/columns.ts regex
patterns verbatim, same priority order, same first-match-wins logic. Only
consulted when a --link-by-code/--link-by-name flag is set and its
column isn't explicitly named; an explicit --code-column-*/
--name-column-* always overrides. If linking is requested and no column
can be found or was given, change() raises ValueError rather than
silently falling back to geometry-only: an explicit ask deserves an
explicit failure, not silent divergence from what the user asked for.
Thresholds¶
--tau-match(default0.8, JS's current default per commit420f2ad): minimummax(coverage_a, coverage_b)for two units to be spatially linked (a union-find edge). Note themax, notcoverage_aalone: a small split fragment that's 100% contained in its parent (coverage_b = 1.0) still clears the bar even though it might be only a third of the parent's own area (coverage_a = 0.33); this is what lets split/merge fragments connect regardless of their share of the original unit.--tau-same(default0.98): minimum IoU for a 1:1 spatially-linked pair to beunchanged/renamedrather thanmodified.
Both are plain floats in [0, 1], with no auto/all string modes the way
topo-clean's --gap-width has: these thresholds have no GEOS-native
auto-default to defer to, they're this tool's own tunables.
Portolan-scale profiling¶
Real old/new version pairs from the portolan catalog, --debug, Apple
Silicon/10 logical cores:
| Comparison | fids (a/b) | Wall time | RSS peak | Notable classes |
|---|---|---|---|---|
| Philippines admin3 v02→v03 | 1,642 / 1,642 | 167s | 3.98 GB | 14 merge, 12 split, 4 modified, 1 created |
| Ethiopia admin3 v01→v04 | 981 / ~1,165 | 21s | 506 MB | 262 split, 143 modified, 29 created, 28 complex, 10 merge, 8 removed |
| Ukraine admin3 v01→v05 | 10,375 / 1,769 | 46s | 957 MB | 9,860 merge (finer v01 consolidated into v05) |
All three ran clean on the first attempt: no correctness or scale issues
found, unlike topo-clean's overlap-detection bugs (see docs/explanation/topo_clean.md). The
Ethiopia and Ukraine runs are good coverage of the classification taxonomy:
Ethiopia's real federal-boundary splits exercise split/complex/merge/
removed together, and Ukraine's 10,375→1,769 fid collapse stress-tests
merge cardinality at real scale (9,860 rows in one pass, well under a
second of the 46s total).
--link-by-code footgun, found via the Philippines run: its
adm3_pcode values changed format entirely between v02 and v03 (e.g.
PH175304000 → PH1705304), a PSGC renumbering, not a real rename. Every
1:1 spatially-matched pair therefore has a_code != b_code, and per this
tool's documented classification rule (renamed fires whenever code or
name differs under linking, see "Classification" above), all 1,624 stable
units came back renamed instead of unchanged. Re-running the identical
comparison in pure spatial mode (no --link-by-code) gave the expected
unchanged: 1,624 with everything else unchanged, confirming this is
change working exactly as designed, not a bug, but it's a sharp edge:
verify a code column is actually stable across the two versions being
compared before enabling --link-by-code: a wholesale code-scheme
change will otherwise read as a wall of false renamed rows.