Skip to content

Increment 15b: the domain in its own CRS - #106

Merged
skavhaug merged 24 commits into
masterfrom
increment15b-domain-crs
Sep 28, 2026
Merged

skavhaug merged 24 commits into
masterfrom
increment15b-domain-crs

Conversation

@skavhaug

Copy link
Copy Markdown
Member

What

Increment 15b (docs/increments/15-dem-mosaic.md, R9/R11). Python only; 162 production lines (CLAUDE.md §2).

  • --domain reads a polygon in its own CRS (GeoJSON crs member, 4326 default, or --domain-crs) and reprojects it into the DEM's CRS vertex by vertex (crs.py: parse_crs, reprojector, the one Transformer.from_crs site, always_xy). CRS equality by pyproj.CRS, which fixes a latent bug: a west-pointing UTM33 reported EPSG 25833 and was accepted untransformed.
  • The needed region is the domain grown by the chosen plan's cell diagonal, re-planned if the lattice changes; extent refusals before any tile load; tiles in several CRSs refused with a domain; --bbox and --domain exclude each other; domain_crs and domain_transform recorded.
  • With --domain, the seam report counts only nodes inside the needed region (Ola, 2026-09-28).
  • Docs: 16's must-match rule superseded; ROADMAP "inputs in their own CRS" settled for the domain; a DOM10 note (shading, canopy height) for later; the fresh DTM10 download measured (still per-tile exports, overlaps still disagree).

Review

Red 372bb99 → green 0011741, test amendments after each finding/ruling; @Reviewer through several rounds, APPROVED at 8f46f13. Local: pytest 2103 passed, mypy, ruff, governance gates.

🤖 Generated with Claude Code

skavhaug and others added 24 commits September 28, 2026 00:02
Failing suites for 15-dem-mosaic.md R9, R11 [15b] and "Tests for @tester"
(15b); no production code. Names the design left open are pinned in the new
"Pinned by the red suite (15b)" subsection.

- test_crs.py (new, 22): parse_crs, reprojector bit-equal to pyproj's
  always_xy transform, and exactly one from_crs site, in crs.py. Run against
  a scratch crs.py (not committed): 31/31 with test_always_xy.py, and six
  mutants killed (always_xy False or dropped, input or output axes swapped,
  CRSError leaking, float32 round trip).
- test_domain.py (rewritten, 54): read_domain(path, crs) without the DEM,
  crs: str, to_crs (vertices only, re-oriented, same CRS by CRS equality not
  to_epsg, no image refused), check_extent; check_crs gone.
- test_dem_input_domain.py (new, 22): DemRequest.domain, bounds from the
  moved domain, the needed region (grown by one cell, pinned away from its
  edge), extent refusals before any load, a real DTM10 seam in EPSG:25832.
- test_cli_mesh_domain_crs.py (new, 18): 4326, 25832, WKT 3035, a CRS with
  no EPSG code and CRS84 over a 25833 mosaic; domain_crs and
  domain_transform; --bbox with --domain refused; axis order able to fail;
  same-CRS digests recorded at d34d79d, before any 15b change.
- test_refine_golden.py calls read_domain(path); it and three
  test_cli_constraint_feet.py tests that use it are red on the signature
  alone. test_cli_mesh_domain.py: two must-match tests renamed for the
  extent check that now refuses them, assertions unchanged.

Full suite: 86 failed, 20 errors (test_crs.py: ModuleNotFoundError in a
fixture), 1914 passed, 7 skipped. ruff check, ruff format --check, mypy
and check_citations clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
15-dem-mosaic.md R9, R11 [15b] and "Pinned by the red suite (15b)"; no test
file touched.

- crs.py (new): parse_crs (CRSError becomes a ValueError naming the text),
  reprojector (pyproj's always_xy transform, bit for bit), crs_label (EPSG:n
  only for an exact code, else pyproj's own text) and transform_description.
  One Transformer.from_crs site in src_python/ (I8).
- domain.py: read_domain(path, crs=None) no longer takes the DEM;
  DomainPolygon.crs is text, and any CRS pyproj reads is accepted. to_crs
  moves vertices only, re-orients, returns self for an equal pyproj.CRS
  (not an equal to_epsg(), so +axis=wnu UTM 33 is transformed), and refuses
  a vertex with no image naming both CRSs. check_crs is gone; the extent
  check is public, check_extent(domain, meta).
- dem_input.py: DemRequest.domain (exclusive with bounds), DemInput.domain in
  the DEM's CRS. open_dem plans on the moved domain's bounds, with the polygon
  grown by the cell diagonal (mitred) as the needed region, checks the extent
  against the plan, then assembles; refusals keep their type and name both
  CRSs. One addition the pins did not name: 15a's window snaps a box edge
  within 1e-6 cell of a node line onto it, which cut off a domain vertex
  1e-6 m past a node line (test_cli_mesh_domain.py's near-node vertex); such
  an edge is moved half a cell out and the plan redone.
- cli.py: --bbox with --domain is a usage error; the domain is read before
  the DEM is opened; domain_crs and domain_transform ("none" when the CRS is
  the DEM's, with no transformer made) are recorded.

Production LOC (CLAUDE.md s2 unit, against 372bb99): +149 -51, net 98;
estimate 115, worst 160.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ayout

check_crs is gone at the green commit, so 15-dem-mosaic.md R9's domain.py:97
now names the tree it was true of. project_structure.md: domain.py no longer
checks the CRS, dem_input.py takes a domain, and crs.py is listed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ettled for the domain, 15 row

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…'s R1), CI runs the codecs-gated CLI and golden suites

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…inned, multi-EPSG refusal

A test amendment after green, from @Reviewer's CHANGES REQUESTED on 15b.

B1 (red): dem_input grows the needed region by footprints[0]'s spacing, the
first tile by name in the repository, not the chosen plan's. Two-spacing,
same-EPSG repositories, both name orders:
- 10 m L with the SE block missing, NEAR_THE_HOLE, a far 1 m tile: refused
  without it and with z_fine.tif; with a_fine.tif it is accepted (load
  reached). Red.
- The mirror, a 1 m L and a far 10 m tile, AROUND_THE_HOLE scaled by 0.1
  (3.5 m from the nearest missing node): filler without it and with
  z_coarse.tif; a_coarse.tif grows by 14 m and refuses 36 nodes. Red.
- An unselected far tile, either order, leaves the plan unchanged (pin).
A scratch fix taking the spacing from the selected lattice turns all green.

S1 (pins): domain window == --bbox window with every edge on a node line and
with a vertex 2e-6 cell past one; one line wider on that side only at 1e-7
cell past, each of the four edges. `<` -> `<=` (and `>` -> `>=`) in _past
fails 13 of 13 affected cases; _past disabled fails the four in-band cases.

S2 (pin): tiles in EPSG:25832 and 25833 with a domain are refused before any
load, naming both codes and "a domain needs one". Deleting the check fails it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
B1 from @Reviewer: _domain_plan grew the domain by footprints[0]'s cell, the
first tile by name in the repository, so a tile the domain does not select,
and where its name sorts, changed what was needed. Now it plans on the
polygon itself, grows by that plan's cell diagonal, and re-plans while the
lattice chosen has a larger one; the reach only grows, so it ends. All
header-only, before any load; _past and the snap band are unchanged.

domain.py's docstring names what it now imports (numpy, pydantic,
tin_engine.crs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd, one CRS

"Pinned by the red suite (15b)" in 15-dem-mosaic.md gains the test
amendment's pins (6143d87): the needed region is grown by the chosen plan's
cell, independent of unselected tiles and name order (B1); the snap-band
cases (S1); and the refusal of tiles in several CRSs with a domain (S2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… re-plan loop and three open edge cases recorded; 15b LOC as built

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nt, the two-PR reason

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Test amendment. The 15b review found nothing pinning that `DemInput.seams`
survives `open_dem`'s domain path: a resolution that set `seams=()`
whenever `request.domain` is set passed the whole suite.

- test_dem_input_domain.py, TestSeamsOnTheDomainPath: a quadrant pair
  planted to disagree (+0.5, +2.0, and +0.0005, below the 1 mm rule, on the
  shared column) opened with a domain in EPSG:25833, 4326 and 25832 reports
  ne.tif | nw.tif, nodes 2, max 2.0, median 1.25; the report equals the
  node-by-node oracle and the --bbox run at the moved domain's bounds;
  agreeing quadrants with a domain report ().
- test_cli_mesh_domain_crs.py, TestSeamsWithADomain: `dem_seams` is written
  on the --domain path (4326 domain): the pair when planted, `none` when
  the overlaps agree.

Under the mutant (`seams=()` when a domain is given in open_dem) the five
disagreeing-case tests fail; the agreeing ones pass, as they must.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ed in the status line

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… region only

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ps still disagree

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ola ruled on 2026-09-28 that with --domain the seam report counts only
nodes inside the needed region (the domain grown by the plan's cell
diagonal, mitred): an irregular catchment's bounding box can be several
times its area, and a disagreement bilinear z never reads is noise in the
report. No production code here; the amendment pins the ruling first.

- A thin diagonal strip whose plan is the whole grid but whose region
  misses the planted overlap: seams == () and dem_seams "none" (red).
- An L whose region takes two of the planted nodes: exactly those,
  against seams_of masked by the needed region (red).
- A rectangle 7 m short of the overlap still counts it (pin).
- --bbox at the same bounds reports every planted node, and the mosaic
  values are unchanged by the domain (pins).

mosaic_fixtures.seams_of takes an optional node mask. Mutants against a
scratch implementation: ungrown-polygon mask and no mask both killed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…Ola)

Ola's ruling of 2026-09-28: with --domain the seam report counts only the
overlap nodes inside the needed region, the domain grown by the chosen
plan's cell diagonal, mitred. Without a domain the report is unchanged, and
which tile's value a node takes is unchanged everywhere.

- `_domain_plan` also returns the grown polygon it already computed;
  `open_dem` hands it to `assemble` (None without a domain).
- `assemble` takes an optional `needed` geometry, as `plan_mosaic` does,
  and masks only each pair's overlap strip before `_seam`
  (`shapely.intersects_xy`, closed, prepared once); `_decide` never sees
  it. No canvas-sized mask; `mosaic.py` stays file- and CRS-free.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e's pair count

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PR #106's CI failed on Linux x86 (GCC, Python 3.12/3.13/3.14) in
TestTheSameCrs::test_the_mesh_is_16s_bit_for_bit: the square's digest,
recorded on macOS arm64 at d34d79d, is not what Linux produces for the
random float32 terrain. A recorded digest pinned a platform, not the
behaviour. The quarter-circle digest was never reached there (the codecs
step was skipped) and had the same exposure.

The guarantee is now relational, on one machine, for both cases:
- the run equals (SHA-256 of points, cells, cell and point arrays) the same
  run with cli.open_dem replaced by 16's data flow: the whole file, opened
  as without a domain, and the domain object exactly as read_domain
  returned it, never through DomainPolygon.to_crs or the window cut;
- the domain handed to _dem_mesh has the read vertices bit for bit;
- Transformer.from_crs is never called (the no-transformer fixture,
  unchanged).
The platform-stable absolute anchor is increment 18's
test_refine_golden.py CLI quarter circle, a same-CRS domain; referenced in
the docstring and the design's pin, not duplicated. On macOS the reference
run reproduces the two dropped digests exactly (seen under mutant M3 below).

Mutants (production files restored after each):
- M1, to_crs round-trips a same-CRS domain through EPSG:4326: all three
  fail on the no-transformer check; with that fixture neutralised the
  vertex check still kills both, and with the vertex check also removed
  the square passes (the 1 mm snap absorbs the round trip) while the
  quarter circle is refused by the extent check. The vertex check is what
  makes the "not transformed" half bite without the fixture.
- M3, the domain path's tile +0.001 m: both digest relations fail.
Full suite 2103 passed, 7 skipped; ruff check, ruff format, mypy clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@skavhaug

Copy link
Copy Markdown
Member Author

Fix for the red CI at 8f46f13: 24d178c makes the same-CRS bit-for-bit tests relational (same-machine reference through increment 16's data flow) instead of comparing digests recorded on macOS arm64, which differ on Linux x86 for random terrain. Increment 18's platform-stable golden remains the absolute anchor.

Note on 24d178c's commit message (not rewritten): under the round-trip mutant two of the three TestTheSameCrs tests fail, not three; the quarter circle is killed by the extent refusal rather than the vertex check, and under the 1 mm shift it fails by refusal before its digest is compared. The conclusion (mutants killed) stands.

🤖 Generated with Claude Code

@skavhaug
skavhaug merged commit dc372a5 into master Sep 28, 2026
8 checks passed
@skavhaug
skavhaug deleted the increment15b-domain-crs branch September 29, 2026 21:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant