Skip to content

Refactor and extend NetCDF input support for topography and storm surge (met) forcing#701

Open
mandli wants to merge 58 commits into
clawpack:masterfrom
mandli:refactor-netcdf-support
Open

Refactor and extend NetCDF input support for topography and storm surge (met) forcing#701
mandli wants to merge 58 commits into
clawpack:masterfrom
mandli:refactor-netcdf-support

Conversation

@mandli

@mandli mandli commented Apr 14, 2026

Copy link
Copy Markdown
Member

This PR replaces the partial, ad-hoc NetCDF support that existed for topo_type=4 with a complete, robust NetCDF input pipeline covering both topography and gridded met forcing for storm surge including at least partially what is described in #699. The new infrastructure handles CF convention discovery, coordinate normalization, unit enforcement, and descriptor-based dispatch in a way that is consistent across both input types and extensible to future fields (precipitation, friction). New documentation can be found at clawpack/doc#240.

New Python Module netcdf_utils.py

Added a new python module src/python/geoclaw/netcdf_utils.py that provides:

  • NetCDFInterrogator -- base class for CF-aware file inspection: coordinate discovery, lon convention detection ([0,360] vs [-180,180]), lat order detection (N-to-S vs S-to-N), dimension order discovery, fill value resolution (_FillValue vs missing_value with correct CF precedence), and crop bound validation. Operates lazily via xarray -- no data arrays are loaded during interrogation.
  • TopoInterrogator(NetCDFInterrogator) -- adds fill value detection within the crop region (hard error -- silent NaN in bathymetry is a correctness hazard), unit verification and conversion to the GeoClaw contract (meters, positive up).
  • MetInterrogator(NetCDFInterrogator) -- adds multi-variable synchronization checks (wind_u, wind_v, pressure on the same grid and time axis), unit validation and conversion (pressure to Pa, wind to m/s), CF time decoding to seconds from a user-defined offset, and ensemble dimension detection.
  • DescriptorWriter -- serializes interrogator output to the descriptor format consumed by Fortran: inline key=value lines in topo.data for topography, and the body of *.storm files for met forcing.
  • CFNormalizer -- standalone utility for repairing CF metadata (standard_name, axis, units, _FillValue) on existing NetCDF files without resampling or reprojecting. Idempotent.

Modifications

  1. Added a units contract to units.py that has a GEOCLAW_NETCDF_UNITS dictionary defining the units that Fortran expects.
GEOCLAW_NETCDF_UNITS = {
    "topo":     "m",
    "wind_u":   "m/s",
    "wind_v":   "m/s",
    "pressure": "Pa",
    "time":     "s",
    # precipitation, friction: reserved for future implementation
}

Python enforces this contract during interrogation. Fortran assumes it without checking. The contract is documented centrally here and referenced from netcdf_utils.py. May want in the future to leverage a specific library for this. We could also extend this to be THE units contract for GeoClaw.

  1. Extension of topo_type = 4. This required 2 changes:

    a. Extended data.py to understand that topo_type=4 may have additional descriptors after the usual line of topo type and path. These lines carry information from NetCDFInterrogator including variable and coordinate names, coordinate conventions (e.g. order, wrapping), fill value, and cropping. This allows Fortran to simply read these values in and assume they are cogent. This also allows topography files to remain as is without pre-processing.

    b. Modification of topo_module.f90 had two sets of changes:

    Bug fixes in existing NetCDF implementation:

    • Dimension and variable inquiries were being mixed. Usually this did not break things, but depended on ordering of the variables and dimensions, not ideal.
    • Previous implementation had incomplete coordinate handling and did not account for N-to-S latitude ordering, non-standard dimension ordering, or [0, 360] longitude. These often produced silently wrong results for files (other than it may crash) that did not match the expected layout.

    New capabilities: Primarily the Fortran now reads the descriptor lines for topo_type=4 that come from DescriptorWrite / data.py.

    • Apply correct index arithmetic for lon convention and lat order without copying data
    • Compute start/count arguments for nf90_get_var from crop bounds, enabling domain subsetting without loading the full file
    • Abort cleanly on fill values within the crop region for topography
    • Emit a meaningful error message for any nf90_* call that fails, suitable for SLURM batch job logs
      Fortran reader still assumes the unit contract from units.py and no unit conversion is applied in Fortran.
  2. Modified utils.py so that get_netcdf_names is consistent with new NetCDFInterrogator. This is still a parallel implementation and will be deprecated (see known issues).

Tests

Addded tests:

pytest tests/netcdf/                          # new unit suite, all passing
pytest tests/test_storm.py                    # extended, all passing
pytest examples/tsunami/bowl-slosh-netcdf/    # extended, all passing
pytest examples/tsunami/chile2010/            # new, all passing
pytest examples/storm-surge/isaac/            # extended, all passing, 1 marked as skip

All these are marked as netcdf tests and are run with the existing NetCDF
based tests. Open question whether a couple of the regression tests take too
long. WRF variant is skipped pending MetPreprocessor implementation.

  1. Full pytest suite for netcdf_utils.py:

    • conftest.py -- in-memory NetCDF fixtures covering all coordinate variants (lon convention, lat order, dim order), fill value variants (_FillValue only, missing_value only, both present/agreeing, both present/conflicting, neither), unit variants for met forcing, and bad/edge cases
    • test_base_interrogator.py -- coordinate detection, fill value resolution, crop validation, laziness assertion
    • test_topo_interrogator.py -- fill-in-crop detection, unit paths, descriptor output correctness across coordinate variants
    • test_met_interrogator.py -- multi-variable sync, unit conversion, time decoding, ensemble dimension handling, descriptor output
    • test_descriptor_writer.py -- round-trip correctness for both topo and met descriptor formats
    • test_cf_normalizer.py -- attribute repair, idempotency, unknown variable passthrough

    All fixtures build NetCDF files in memory via netCDF4 -- no binary files are checked in.

  2. Modified: test_storm.py

    Extended to cover ERA5-style and NWS13-style NetCDF storm inputs:

    • test_netcdf_var_mapping_era5 -- verifies dimension/variable discovery for ERA5 layout (valid_time dim, u10/v10/msl variable names, lon [0,360])
    • test_netcdf_var_mapping_nws13 -- same for NWS13 variable schema
    • test_data_storm_roundtrip -- extended to parametrize over netcdf_era5 and netcdf_nws13 variants
    • test_netcdf_wrf_stub -- skipped with explanation; documents that WRF support requires MetPreprocessor for string-time axis and curvilinear grid handling, deferred to a future PR
  3. Modified: test_bowl_slosh_netcdf.py

    Extended _make_bowl_netcdf_topography to support coordinate variants (dim_order, cf_compliant, cropped). Added test_bowl_slosh_netcdf_variants parametrized over all non-standard variants, comparing against the existing reference gauge data. Results must match to 1e-4 relative/absolute since the bathymetry is identical.

  4. New test in examples/tsunami/chile2010/

    Added test_chile2010.py with a full regression test parametrized over topo input variants:

    • standard - existing ASCII topo, baseline behavior
    • netcdf_topotools - writes topo via topotools.Topography.write() to NetCDF, then verifies CF compliance via CFNormalizer; exercises the topotools write path that was previously untested
    • lat_flip - manually constructed NetCDF with N-to-S latitude
    • lon_360 - manually constructed NetCDF with [0,360] longitude

    All variants compare against committed reference gauge data.

Numerical behavior

Topography changes: the bug fixes in topo_module.f90 for coordinate handling mean that NetCDF topo files with N-to-S latitude or [0,360] longitude will now produce correct results where they previously produced silently wrong bathymetry. Files with the previously assumed layout (S-to-N, [-180,180]) are unaffected.

All other simulation parameters and numerical methods are unchanged.

Known issues and deferred work

  • util.get_netcdf_names and the discovery logic in NetCDFInterrogator are parallel implementations. Consolidation is deferred to the surge module refactor to avoid scope creep here.
  • Storm.write does not yet call DescriptorWriter directly; the .storm descriptor for NetCDF met forcing is currently written by the test setup code. Full integration is part of the surge refactor.
  • WRF support requires preprocessing for string-time axis (Times character array) and curvilinear grid (XLAT/XLONG 2D coordinate variables). Stub test documents the gap.
  • Precipitation and friction fields are reserved in GEOCLAW_NETCDF_UNITS but not yet implemented.
  • A CFNormalizer-based tool for making arbitrary NetCDF files GeoClaw-ready is available in netcdf_utils.py but not yet exposed as a command-line utility.
  • Adopt a standard unit package in Python.

@mandli mandli marked this pull request as draft April 14, 2026 20:19
@mandli

mandli commented Apr 15, 2026

Copy link
Copy Markdown
Member Author

@kbarnhart This may be useful for some of the work you are doing to read in data for precipitation into GeoClaw. While not currently listed, precipitation is I think the most likely next variable field that I would add, may be a good test.

@rjleveque

Copy link
Copy Markdown
Member

@mandli Thanks for working on this! I'm tied up this week but will try to go through it soon.

@mandli

mandli commented Apr 16, 2026

Copy link
Copy Markdown
Member Author

@mandli Thanks for working on this! I'm tied up this week but will try to go through it soon.

I have a bunch more to explain in this PR on why, what, and how it was implemented coming so no rush. I also am trying to get a docs PR to accompany this.

UPDATE - Added what changed and is now addressed. Still working on the doc PR and will link it here when issued.

mandli added 20 commits July 8, 2026 14:18
Includes better support for discovery of variables, coordinate mapping,
cropping of data files, and more robust reading overall.
Adds 3 tests to bowl-slosh-netcdf to test dimension ordering, CF compliance,
and cropping.  Lat-long coordinate utilities will be added to Chile 2010.
Also includes another bug fix for mixing up dimension and variable meta-data
inquiries in topo_module.f90.
Includes a basic test and more NetCDF tests for writing NetCDF files
from topotools, flipping latitude writing, and wrapping longitude.
Adds additional tests to the isaac test for the new NetCDF capabilities.
This allows to use dask if it is available, but then uses the generic
NetCDF backend if it is not and does not do lazy loading.  This is
probably not generally an issue for any use cases I have seen.
CI testing revealed that of course dtopo was not being generated for this
test. Written to allow future testing of dtopo variants as well.
This allows for more ways to specify variables and remove the duplicative
aspects of the met forcing that was present.  Much more robust time
handling as well without assuming epochs.
Replaced with simply checking for minimal dims and vars instead
Added a dict of common bathy variable names to fallback on if not
provided, but still allows for override.
This allows wrapping topo files in longitudinal
direction
Needed to remove the isaac.storm file being
written directly during the run of setrun.py and
instead only do so when called from `__main__`.
This was not finishing in a reasonable amount of time in the config
tested.  Lowered this to max levels = 4 for now.
Adds parameter to storm objects that allow for the
time scale of the storm to be changed. This is
useful for testing and for running idealized cases
of storms that may be slower or faster than
provided in the input data.  This also adds basic
tests in the Python to support this functionality.
mandli and others added 17 commits July 8, 2026 14:23
This brings the met forcing input up to par with
the functionality added for the topography input.
Created a type-4 writer, changed to NaN sentinels.
Uses matplotlib functionality to do so
Will give a warning if these are provided and do
not match.
Coordinate-agnostic refactor (Phases 1-2): rename lat/lon-specific
identifiers and descriptor keys to neutral x/y across Python and
Fortran so non-geographic grids (e.g. projected/rectilinear WRF
output in meters) are first-class, not assumed to be lat/lon.

Python (netcdf_utils + consumers):
- FileMetadata.lon_name/lat_name -> x_name/y_name;
  lon_convention -> lon_wrap; lat_order (str 'N_to_S'/'S_to_N') ->
  y_increasing (bool); lon_offset -> lon_wrap_offset.
- _find_lon/lat_name -> _find_x/y_name; _detect_lon_convention ->
  _detect_lon_wrap; _detect_lat_order -> _detect_y_increasing.
- dim_order role tokens 'lon'/'lat' -> 'x'/'y'; all 3 descriptor
  writers updated; topotools/dtopotools/storm/util/data consumers.

Fortran (topo_module, data_storm_module):
- nc_/dnc_ coordinate arrays and descriptor parser keys renamed to
  match; nc_y_increasing is a logged-only logical; lon_wrap drives
  the storm 0-360 longitude normalization.

Only the genuinely-geographic 0-360 longitude wrap keeps lon naming.
CF discovery strings (standard_name, fallback names, units) preserved.
Tests updated; type-4 topo/dtopo/met end-to-end all pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 0-360 longitude wrap (lon_wrap / lon_wrap_offset) only makes sense for
a geographic longitude axis.  _detect_lon_wrap now returns None for a
projected/rectilinear x axis, detected by positive evidence: length units
(m/km), a projection standard_name, or coordinate values outside the
plausible degree range [-360, 360].  Unit-less lon/lat axes still default
to geographic, so existing files are unaffected.

When lon_wrap is None:
- topo_entries() restricts _compute_lon_entries to the identity offset
  (allow_wrap=False): a crop is taken straight from the file extent with no
  +/-360 wrap attempt.
- write_met_descriptor omits the lon_wrap line; Fortran's default (180) is
  the correct no-wrap behavior.

Adds projected-axis inspector tests (units-based, value-range-based) and a
regression test that an unit-less [0,360] axis still wraps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Parallel to the existing x_shift, add a y-direction registration shift for
both topography and dtopography, and enable x_shift for dtopography (it
previously raised NotImplementedError).  These are constant coordinate
offsets (domain = file + shift) applied to all file types.

The per-file preprocessing block grows from 7 to 8 lines: y_shift is
inserted immediately after x_shift (order: crop_extent, coarsen, buffer,
align, x_shift, y_shift, z_shift, negate_z), consistently in the Python
writer, DTopoData.read parser (10-line file block), and both Fortran
readers (read_topo_settings, read_dtopo_settings).

Python: Topography/DTopography gain y_shift; read() applies it to the y
coordinate; dtopo now applies x_shift/y_shift to x/X and y/Y; guards and
the topo_type=1 check updated.

Fortran: tp_y_shift applied to ylowtopo/yhitopo, the types-2/3 and type-4
crop y-bound conversions, the align y component, and type-4 ylocs.  New
dtp_x_shift/dtp_y_shift applied to xlow/xhi/ylow/yhidtopo.

Tests: unit round-trips updated; bowl-slosh y_shift identity e2e and a
dtopo x/y-shift identity e2e (Fortran apply path) added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Parallel to topo/dtopo, the data-storm forcing grid can now be registered
onto the domain with x_shift/y_shift (domain = file + shift).  This lets a
projected or mis-registered met grid be placed correctly.

- Storm gains x_shift/y_shift; write_data emits two descriptor lines after
  storm_time_scale; Storm.read parses them.
- data_storm_module: met_x_shift/met_y_shift read from the descriptor and
  applied to the full coordinate arrays (after the geographic lon_wrap
  normalization) before cropping, for both OWI/ASCII and NetCDF paths.
- Regenerated the committed owi_ascii/isaac.storm fixture for the new
  2-line-longer descriptor.

Tests: .storm round-trip covers x_shift/y_shift; new e2e
test_isaac_netcdf_shift_identity offsets the ERA5 grid by (+1.5,+0.75) and
cancels it via the descriptor, matching the unshifted owi_ascii baseline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Old functionality inadvertently worked and new functionality
revealed that stricter rules broke the reading of met data.
This is now fixed and better gaurds put into place.
DTopoInspector._compute_time_axis only special-cased datetime64 time
coordinates; a bare-duration units="seconds" coordinate (as written by
DTopography._write_netcdf) can decode via xarray into timedelta64, and
casting that straight to float reinterpreted the raw nanosecond tick
count as seconds -- a billion-fold inflation of t0dtopo. For a
single-frame dtopo file (e.g. chile2010's t=1s deformation), that meant
the simulated time never reached t0dtopo, so the coseismic deformation
was never applied, and since topo_finalized never flips true,
dt_max_dtopo pinned the timestep for the entire run.

Fix by opening with decode_timedelta=False and adding a defensive
timedelta64 conversion branch. Also clear a stray _FillValue=NaN that
xarray's default encoding was writing onto coordinate variables (lat/
lon/time) in both the dtopo and topo netCDF writers -- invalid for a
monotonic axis.

Separately, default dz/elevation to float32 on disk in both writers:
validated numerically that float32 gives sub-millimeter precision
across the full elevation range, including Mariana-trench depths,
roughly halving netCDF file sizes. In-memory arrays stay float64.

Add regression coverage: a unit test reproducing the timedelta64 cast
bug directly, dtype assertions for the float32 default, and a remote
chile2010 test that writes the real dtopo as netCDF and runs the full
Fortran read path end to end (fails without the fix -- flat gauge,
dt=0.2 pinned for the whole run -- passes with it).
@mandli mandli force-pushed the refactor-netcdf-support branch from a831b75 to 3522890 Compare July 8, 2026 18:24
mandli added 3 commits July 8, 2026 14:45
NetCDF4-python's internal data.shape = ...
reshape, deprecated by numpy 2.5.  Wired around it
to catch this in the netCDF4 code that was throwing
this warning.
- Now requires declared units for netCDF input files.
- Also adds time reference for dtopo files in netCDF.
- Adds control over dtypes for topo and dtopo netCDF files.

Note: This may currently break backwards compatibility due to the
unit requirements.  This needs to be addressed as to what the best
approach is to handle units and when there are inconsistencies, either
implied or explicit.
Comment thread src/python/geoclaw/netcdf_utils.py Outdated
# Prefer netcdf4, fall back to h5netcdf, else leave as None so xarray
# guesses (and raises its own clear error if no engine is installed).
engine = next(
(name for name in ("netcdf4", "h5netcdf")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had to change line 260 to

(name.lower() for name in ("netCDF4", "h5netcdf")

to get this to work, since netCDF4 is the name of the module I have installed but xarray expects engine="netcdf4" lowercase.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, strange it was working for me and I have netCDF4 installed.

@rjleveque

Copy link
Copy Markdown
Member

Is it possible to include compression when writing netCDF files? I tried an example taking an dtopo file in dtopo_type=3 with 34 times on a 1201 x 721 grid and the original was 184M, the .nc file 112M, which is somewhat smaller. But then when I gzipped the .nc file it went down to 19M. It seems there are ways to better enable compression when creating the file but I don't understand it well enough.

@mandli

mandli commented Jul 12, 2026

Copy link
Copy Markdown
Member Author

Is it possible to include compression when writing netCDF files? I tried an example taking an dtopo file in dtopo_type=3 with 34 times on a 1201 x 721 grid and the original was 184M, the .nc file 112M, which is somewhat smaller. But then when I gzipped the .nc file it went down to 19M. It seems there are ways to better enable compression when creating the file but I don't understand it well enough.

Yes, there is, although there are some pitfalls with using some of the libraries that may be required. Let me look into it and at least expose an interface that will do it and we can decide whether we want to default to that or not.

mandli and others added 6 commits July 12, 2026 10:44
Supersede the strict rejection of non-contract units with automatic
conversion, on both read paths.  Python computes a multiplicative
scale_factor (topo/dtopo elevation, met wind/pressure) and, for the met time
axis, a time_scale (seconds per file time unit) via units.convert(); the
in-memory readers (Topography.read / DTopography.read, *_type=4) apply it
directly, and Fortran applies it on the descriptor path used by a running
simulation.  All unit logic stays in Python; Fortran is a dumb multiplier.
Defaults of 1.0 leave every contract-unit file bit-identical (regression
suite is the guardrail).

Missing units still raise (with the assume_units override) and unrecognized
or dimensionally-incompatible units still raise -- only recognized units
convert.  A real ERA5 file (hPa pressure, "hours since ..." time) now runs
directly instead of being rejected.

Fortran: nc_scale_factor (topo_module.f90), dnc_scale_factor (dtopo),
var_scale + nc_time_scale (data_storm_module.f90); the met time_scale is
distinct from the existing physical storm_time_scale (they compose).

Tests: reject-tests flipped to convert-tests; descriptor-writer asserts the
new keys; end-to-end regression variants prove the converted run matches the
contract baseline bit-for-bit -- bowl-slosh km topo, chile2010 km dtopo, and
isaac met in hPa (variable scale) and hours-since (time scale).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When a met NetCDF variable has no CF 'units' attribute, use the storm
format's documented unit instead of raising: MetInspector gains a
format_units {role: unit} option, consulted before assume_units, then
converted via the existing scale_factor path (e.g. NWS13/OWI pressure
'mbar' -> Pa, scale 100). Storm._met_format_units maps nws13/owi to
{wind m/s, pressure mbar} and Storm.write_data passes it. CF/ERA5 files,
which carry their own units, are unaffected. Python-only.
Warn+auto-correct hPa-mislabeled-as-Pa; raise on implausible elevation/
wind/pressure (skip_sanity_check opts out). Remove the dead allow_conversion
param so inspectors always convert, fix stale reject-and-pre-convert docs,
and add a met hPa + "hours since" regression.
Case matters as it turns out, and xarray was being picky about
what we were handing it.  Thanks to @rjleveque for finding this one.
Add a compression= option to Topography/DTopography NetCDF writers
(None default = uncompressed/bit-identical; True = zlib level 1 +
shuffle) via netcdf_utils.compression_encoding. Reads are transparent.
If only float32 is used when writing netCDF files, the resulting
may not have enough precision on a lat/lon grid that is on the order of
the finest topo files now.  Dtopo was already forcing this, topo now
matches.
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.

2 participants