Topo crop_extent / antimeridian: stop failing silently, and make the working paths reachable - #739
Open
mandli wants to merge 2 commits into
Open
Topo crop_extent / antimeridian: stop failing silently, and make the working paths reachable#739mandli wants to merge 2 commits into
crop_extent / antimeridian: stop failing silently, and make the working paths reachable#739mandli wants to merge 2 commits into
Conversation
Eleven silent failures in crop_extent handling produced wrong answers with no message: wrapped spellings gave empty grids, sub-cell and non-overlapping crops gave the full file, a cropped type-4 read reported the full-file extent, and buffer was dropped for every descriptor-cropped NetCDF file. Adds the first Fortran regression coverage for the descriptor-crop path and documents what a Topography represents across the antimeridian. Signed-off-by: Kyle Mandli <kyle.mandli@gmail.com> Assisted-by: claude claude-opus-5[1m]
mandli
force-pushed
the
topo-crop-silent-failures
branch
from
September 5, 2026 01:04
63e8cf6 to
c893f23
Compare
A URL in topofiles was mangled by os.path.abspath into a bogus local path before the reader's existing URL guard could see it; it is now rejected with the fetch_remote_topo recipe. A crop crossing the antimeridian raised 'crop_bounds exceed file extent' even though _compute_lon_entries already covered it; TopographyData.write now resolves entries before writing and splits such a crop into one descriptor entry per side, carrying buffer and coarsen onto each so the Fortran buffer fix applies. Adds byte-exact topo.data goldens and the first end-to-end test of two entries with differing lon_wrap_offset. Signed-off-by: Kyle Mandli <kyle.mandli@gmail.com> Assisted-by: claude claude-opus-5[1m]
crop_extent / antimeridian: stop failing silentlycrop_extent / antimeridian: stop failing silently, and make the working paths reachable
This was referenced Sep 6, 2026
Open
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The second commit updates one error message introduced by the first, so they read best in order.
Claude Description of PR
Part 1 — every one of these produced a wrong answer with no message
None of them raised, warned, or logged. Several returned an empty or full-file grid where a crop was asked for; one made GeoClaw abort on correctly specified input.
crop_extent=[170, -170]produced an emptyTopography(Z.shape=(11, 0));.extentthen raised an opaque numpy "zero-size array to reduction" far from the causeValueErrornaming both spellingsValueErrorreportingdx/dycrop_extent=[-211, -99]on a[-180, 180]file silently clipped 112° → 81°stop 1crop()reported viaprint()— uncatchable, unfilterablewarnings.warnread(unstructured=True, crop_extent=...)died withTypeError: list indices must be integers or slices, not tupleNotImplementedError, matching whatcrop()already didtopo_type=5(GeoTIFF) written totopo.data; Fortran has nocase(5)and abortstopo_type=Nonehit the:3dformat and raised aTypeErrornaming neither file nor attribute — reachable via the recommendedTopography-object pathdetermine_topo_type, else raises naming the attributecrop_extenton a type-4 file wrotecrop_bounds = 170.0 -170.0; Fortran resolves that tomx=0, my=0— an empty topography, no errorread_header()then a cropped type-4read()left.extentreporting the full file — and.extentis what_compute_priority_orderand the plotting routines use_extent/_deltainvalidated alongside_X/_Ybuffersilently ignored for every file carrying descriptorcrop_bounds— i.e. every filetopo_entries()writesnbuf4set on that branch tooItem 10 was suspected by reading, not running; it is confirmed here with a NetCDF fixture (
.extentreturned[-100, -80, 20, 30]for data covering[-95, -90, 22, 25]).The Fortran hunk (item 11), measured
One line in
topo_module.f90— file I/O and index arithmetic, no solver numerics. It was asked for as a measurement rather than an assurance, and it is not small. On a case whose domain sits in the buffer ring:83.5% coverage; the missing 16.5% is exactly the buffer ring that was discarded. The failure mode was a hard abort on correctly specified input, not a small numerical shift.
Part 2 — the capability existed; it just wasn't reachable
Reported from the field, writing the most natural possible setrun code:
make data→FileNotFoundError: /run/dir/https:/www.ngdc.noaa.gov/...— a path the user never typed.crop_extent = [-190, -120, -60, 0]to span the date line →ValueError: crop_bounds lon [-190.0, -120.0] exceed file extent [-180.0, 180.0].Part 1 fixes neither. The second crop is ascending, so the new descending-crop check never fires.
NetCDFInspector.__init__has guarded against thehttps://→https:/collapse since Add fetch_remote_topo; deprecate read_netcdf; refactor etopotools #726 — butTopographyData.write()ran the path throughos.path.abspathfirst, corrupting it before the guard saw it. Same bug inDTopoData.write()._compute_lon_entriesalready covers a cross-seam crop. For(-180, 180, -190, -120)it returns(-180, -120, offset 0)and(170, 180, offset -360).topo_entries()wraps it and_normalize_topofilesalready accepts its output as a first-class entry form. Nothing called it fromwrite().Remote URLs rejected with the working recipe
The scheme-anchored regex from
NetCDFInspector.__init__is nownetcdf_utils.is_remote_url(), shared rather than repeated. Both.datawriters check beforeabspathand raise withfetch_remote_topo's own documented pattern (fetch the hyperslab → write a local file → append that).Pass-through was considered and rejected: it needs netcdf-fortran built with DAP, which is not guaranteed, and it makes every run depend on the server.
A cross-seam crop is split across the seam automatically
TopographyData.write()becomes resolve-then-write —ntopofilesis written before the blocks, and the entry count is no longer known until every file is resolved. A type-4 crop running off the file's longitude extent now produces one entry per side:Three things this had to get right:
bufferandcoarsenreach every entry.topo_entries()builds Topography objects carrying only_netcdf_meta, so routing through it naively writesbuffer = 0— which would have silently undone item 11 above, the change that madebufferwork for descriptor crops at all.topo_entries()inspects withcrop_boundsunset (it must — a wrapping crop lies outside the file extent by construction), so its fill and magnitude checks read the entire variable and reject NaN anywhere in it. On a global DEM over OPeNDAP that turnsmake datainto a full download.inspect_topo/topo_entriesgainfill_scan(defaultTrue, so no existing caller changes) andwrite()passesFalse, preserving exactly its previous cost and behaviour.lon_wrap_offsetcomes from the computed entry rather than the hard-coded0.0— wrong even for a single entry, since[185, 195]on a[-180, 180]file needs offset+360.Non-wrapping cases take the identical code path as before; the resolver only diverges once it has established that the crop runs off the file.
Verification
A byte-exact
topo.datagolden suite, written before the refactor.write()runs on everymake data, and the existing tests check substrings and line counts only — the write paths could diverge in field width, whitespace or float formatting and still pass. Five cases are pinned byte-for-byte.I generated them from the pre-refactor code, regenerated after, and diffed the two sets: identical. So the restructure is a demonstrated pure refactor, not an asserted one. (I first regenerated them post-refactor, which would have defeated the purpose; the diff is what caught it.)
25 new Python tests, plus
test_crop_no_overlap_keeps_full_gridupdated to assert the warning it now emits. Every one was checked for the ability to fail by reverting the source: 14 of 16 in Part 1 and all 9 in Part 2. The two that pass either way are the two that should — a guard against the new clipping warning becoming noise, and one pinning the existing, correct buffer-before-coarsen order.Two Part 2 tests reproduce the reported errors verbatim. One is worth calling out:
test_wrapping_write_does_not_scan_the_whole_filespies on_check_fill_in_cropand asserts it is never called — that regression is invisible on a small local fixture and ruinous on a remote DEM.A new Fortran regression suite,
tests/regression/topo_crop/— the first tests that compile GeoClaw and feed it a NetCDF topo file through a descriptor fromtopo_entries(). Five cases, including two that feed GeoClaw two entries for the same file with differentlon_wrap_offset: the Python side was tested, the Fortran side was assumed. One asserts a date-line-straddling domain is fully covered; the other asserts both grids are reported in domain coordinates infort.geo, so the wrapped entry appears at -190 rather than at its file position of +170. Verified by sabotage — forcing the offset to0.0fails both (assert -180.0 == -190.0).Three fixture defects were found and fixed by that exercise, each of which would have made a test that pins nothing:
.tt3, so without the fix it died innumpy.loadtxton the header — an unrelated parse error, not theTypeErrorit claims to pin. Now a real.xyz, and the pre-fix failure is verified.arange(..., 0.05), whose spacing comes out as4.9999999999997e-2; the inclusivecrop_boundscomparison then landed one index differently on each edge.DELTAis now0.125, exact in binary.Results: 546 passed / 4 skipped / 1 xfailed; 12 regression tests (7 met_forcing + 5 topo_crop). All met_forcing goldens byte-identical.
bufferbeforecoarsen— correct as implemented, now tested and documentedConfirmed by reading both sides:
_crop_indicesand Fortranapply_align_buffer_coarsenagree, andbuffercounts coarsened output points —buffer=2, coarsen=4adds 2 points per edge, not 8. Nothing pinned that (the existing combined test is a relative netCDF-vs-ASCII equality, which passes for either meaning). Now asserted directly and documented inread(),crop()and the class docstring rather than only in a private helper.Documentation
The
Topographydocstring gains two sections aimed at the question of what a croppedTopographyrepresents across the seam. The short version: a Topography never wraps — wrapping is applied by the Fortran reader from alon_wrap_offsetthat only exists in a NetCDF descriptor. The three cases are spelled out, including that reading and writing deliberately differ (reading clips and warns; writing splits across the seam), plus the crop → align → buffer → coarsen order.Behaviour changes worth a release note
crop_extentnow raises (previously an empty grid).crop_extentnow raises (previously the full file).unstructured=Truewith preprocessing raisesNotImplementedError(previously aTypeError, so no working code is affected).topofiles/dtopofilesnow raises with instructions (previously aFileNotFoundErrornaming a mangled path).crop_extentin continuous coordinates now works, writing two entries where it previously raised.nbuf4fix changes what data a descriptor-cropped type-4 read returns, by adding the buffer that was requested.Notes for reviewers
.txt, not.data:.gitignoreexcludes*.data, so goldens named for what they are would have been silently left out of the commit and the test would have failed for everyone else. Same convention as themet_forcinggoldens.num_wavesdefaults to 1 while the GeoClaw Riemann solver always returns 3 and uses f-waves, so omittingnum_waves = 3/limiter = ['mc']*3/use_fwaves = Truelets the solver write past the end of the wave arrays. With them set: 0 crashes in 20 runs, where before it was 5–12 in 20. Recorded as a comment in the newsetrun.pyrather than left as an unexplained incantation.Follow-ups, recorded not fixed
fill_scan=Falsepreserves today's behaviour but is a safety downgrade relative to scanning the crop properly. Belongs withtopo-input-unify.FrictionData.writeis broken: it writes"'%s' %s\n " % fname— two format specifiers, one argument, so it raisesTypeErrorwheneverfriction_filesis non-empty, and that path has zero test coverage. Left alone because the feature is a known-dormant stub (friction_module.f90prints "File based friction specification unimplemented") with its own tracked design doc.