Radius-1 IDW neighbor fallback and domain-gap fill for interpolate_in_space - #16
Radius-1 IDW neighbor fallback and domain-gap fill for interpolate_in_space#16cgrudz wants to merge 7 commits into
Conversation
…erpolate_in_space Sofar's 0.25/0.5/1.0 deg WW3 grids share an origin, so bilinear interpolation always collapses to a single coincident source point; when that point is masked/land, interpolate_in_space returned NaN instead of using nearby valid data. NdInterpolator now takes an opt-in nan_fallback_radius, defaulting off for its other callers, that inverse-distance-weights valid neighbors within that radius and still returns NaN when none exist (e.g. real domain gaps).
There was a problem hiding this comment.
Pull request overview
Adds radius-1 IDW fallback for masked WW3 grid points while preserving default interpolation behavior.
Changes:
- Adds configurable Haversine-based neighbor fallback.
- Enables fallback for restart-file spectrum interpolation.
- Adds unit tests for fallback behavior.
Reviewed changes
Copilot reviewed 3 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
src/roguewave/interpolate/nd_interp.py |
Implements neighbor fallback logic. |
src/roguewave/wavewatch3/restart_file.py |
Enables radius-1 fallback for spectra. |
tests/interpolate/test_nd_interp.py |
Tests fallback scenarios. |
tests/interpolate/__init__.py |
Marks the test package. |
Suppressed comments (3)
src/roguewave/interpolate/nd_interp.py:376
- This hard-codes the interpolated point dimension as axis 0.
NdInterpolatorsupports passive dimensions beforeinterp_index_coord_name, in which caseneighbor_valueand the output place the point axis elsewhere; constructing and boolean-indexingvalue_by_pointthis way produces incompatible shapes. Use the existing output-index helpers for both source and destination (which also avoids allocating another full output-sized array per neighbor).
value_by_point = numpy.zeros(
(number_points,) + neighbor_value.shape[1:], dtype=numpy.float64
)
value_by_point[usable_global_position] = neighbor_value[
neighbor_value_is_valid
src/roguewave/interpolate/nd_interp.py:290
- These are coordinates of the nearest source node, not of the requested interpolation point. For any non-coincident coastal target whose bilinear lookup fails, the fallback therefore computes IDW around the wrong location (for example, east/west neighbors become equally weighted even when the target is closer to one). Thread the requested latitude/longitude values into this helper and use them for the Haversine distances, or explicitly limit fallback eligibility to coincident targets.
target_latitude = latitude_values[
coincident_source_index_of_failed_points[latitude_axis_index]
]
target_longitude = longitude_values[
coincident_source_index_of_failed_points[longitude_axis_index]
src/roguewave/interpolate/nd_interp.py:347
neighbor_value_is_validis sized only for the in-bounds subset, but it is applied here to arrays sized for all failed points. In a batch containing both an edge failure and an interior failure, an offset that excludes only the edge point causes a boolean-dimension mismatch and aborts interpolation. Index these coordinates with the already mappedusable_local_position.
neighbor_latitude_value = latitude_values[
neighbor_source_index_per_axis[latitude_axis_index][
neighbor_value_is_valid
]
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
_fancy_index crashed on an empty index array (numpy.array([]) collapses to shape (0,) instead of (0, num_freq, num_dir)), hit whenever a radius-1 neighborhood is entirely land -- common for domain gaps and the case that most needs to degrade to NaN gracefully. Also, per automated PR review: exclude out-of-domain points from the fallback (their clipped bracket indices aren't a real coincident node), measure fallback distances from the actually-requested point rather than the coincident source node, fix a sizing mismatch that could crash on a batch mixing in- and out-of-bounds points at the same offset, and fix a periodic-longitude test that never exercised the modulo wraparound it claimed to test.
|
Addressing the three additional findings from the review body's "suppressed comments" (these weren't rendered as separate inline threads, so replying here instead) — all fixed in 6bdd585:
Re-ran the full unit test suite and the real production 0.25°→0.5° validation (469/156635 NaN, unchanged) after these fixes to confirm no regression. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 4 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
src/roguewave/interpolate/nd_interp.py:395
- This hardcodes the interpolation-point axis as axis 0, even though
NdInterpolatorsupports passive dimensions before that axis viaoutput_index_coord_index. For data shaped like(frequency, latitude, longitude),neighbor_valueis(frequency, failed_points), so this allocation has the wrong shape and the boolean index on the next line is applied to the frequency axis, causing an indexing error. Build the buffer inoutput_shapeand use the existing axis-aware index helper.
value_by_point = numpy.zeros(
(number_points,) + neighbor_value.shape[1:], dtype=numpy.float64
)
value_by_point[usable_global_position] = neighbor_value[
_radius_neighbor_fallback previously allocated its accumulators (and a fresh scratch tensor per neighbor offset) at the full output shape -- number_points * frequency * direction -- even though fallback work only ever touches the failed-point subset, typically a small fraction of a production grid. For the shape in this PR (156635 points), that's gigabytes of unnecessary allocation. Size everything to the failed subset instead and scatter the small resolved result into the primary result at the end.
The radius-1 fallback correctly leaves a point NaN when no radius-1 neighbor has data at all -- a real domain gap (e.g. Hudson Bay, absent from the 0.25 source grid entirely) rather than a coastal artifact, per team discussion on the NASA OSTST Phase 2 dev plan. For those residual points, add a WW3 cold-start-style fill matching ww3_strt's ITYPE options: "calm" (zero energy, default), "user_defined" (broadcast a supplied spectrum), "gaussian", and "jonswap" (parametric shapes normalized/parameterized to match those ITYPEs' conventions, not a port of WW3's Fortran). Confirmed against the real production 0.25->0.5 restart: interpolate_in_space's 469 residual NaN points all resolve to 0 after fill_missing_spectra(fill_type="calm").
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 5 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
src/roguewave/interpolate/nd_interp.py:323
- The radius search excludes offset zero, which drops the nearest source node for non-grid-aligned queries. A primary lookup can fail with
weights_sum <= 0.5even when that nearest node is valid (for example, it is the only valid bilinear corner); the fallback then ignores the best available value and may return NaN or blend only farther nodes. Include the center candidate—the exact-coincident failure case remains unchanged because its center value is invalid.
neighbor_offsets = [
offset
for offset in itertools.product(
range(-radius, radius + 1), repeat=self.interp_ndims
)
if any(offset)
]
src/roguewave/wavewatch3/restart_file.py:353
- The new empty-index path is not exercised by the added tests: their fake
get_databypassesRestartFile.__getitem__and_fancy_index, although the real fallback reaches this branch whenever a neighbor batch contains no sea indices. Add a regression test using a real or minimally constructedRestartFileto verify that empty fancy indexing survives the conversion and Spectrum construction with shape(0, frequency, direction).
if len(indices) == 0:
# numpy.array([]) on an empty list of spectra collapses to shape
# (0,) instead of (0, number_of_frequencies, number_of_directions),
# which xarray then rejects as a dimension-size conflict.
return numpy.empty(
Excluding the zero offset assumed the coincident node was always already-known-invalid whenever the fallback triggers. That holds for today's grid-nested use case (its weight is always exactly 0 or 1, never partial), but not in general: a non-grid-aligned bilinear miss can have a valid coincident node whose own weight is still <= 0.5 (e.g. the largest of four roughly-even corner weights), which the fallback would previously discard in favor of a worse, farther neighbor. Also adds a regression test for the empty fancy-index crash fixed earlier, using a fully synthetic, in-memory RestartFile (Grid and MetaData are plain dataclasses, no file I/O needed) -- the existing fallback tests use a synthetic get_data callback that never actually exercised RestartFile._fancy_index's real empty-array path.
|
Addressing the other finding from the latest review's "suppressed comments" (`restart_file.py:353`, no separate inline thread rendered for it) — fixed in 6ba6897: Confirmed real gap: the fallback tests use a synthetic `get_data` callback that never calls the real `RestartFile.getitem`/`_fancy_index`, so the empty-index crash fix (from an earlier round) had zero regression coverage — only a manual rerun of the production validation script would have caught it breaking again. Added `tests/restart_files/test_empty_index.py` with a fully synthetic, in-memory `RestartFile` — `Grid` and `MetaData` are plain dataclasses with no file I/O, and the empty-index path never touches `resource`, so this needed no S3 access or binary parsing. It exercises the real `getitem` → `_fancy_index` → `to_frequency_energy_density` → `Spectrum`/`Dataset` construction path end to end with an empty index array, asserting the correct |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 6 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/roguewave/interpolate/nd_interp.py:398
- This boolean index always targets axis 0, although
neighbor_value_is_validindexes the interpolated-point axis. If a passive dimension precedes the interpolation coordinates—for example data shaped(frequency, latitude, longitude, direction)—neighbor_valueis(frequency, candidates, direction), so this raises a boolean-length mismatch (or selects frequencies). Move the configured point axis to the accumulator's axis 0 before filtering.
usable_value = neighbor_value[neighbor_value_is_valid]
src/roguewave/interpolate/nd_interp.py:228
resolved_valueis built with failed points on axis 0, but this destination keeps the interpolated-point axis atoutput_index_coord_index. For a supported layout such as(frequency, latitude, longitude, direction), that axis is 1, so assigning(failed, frequency, direction)into(frequency, failed, direction)fails or misorders values. Move the internal point axis back to the output position before scattering.
result[self.output_indexing_full(failed_point_position)] = resolved_value
Its docstring described only the return shape, with no mention that masked/land coincident points are now filled from radius-1 neighbors when possible, or that fill_missing_spectra exists for the residual domain-gap points that still come back NaN.
…nal_variance_density Breaks composing interpolate_in_space's output directly with write_restart_file, which this branch's regridding path relies on. Fixes #15. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Description
What kind of change is this?
Bug Fix / Addition
Scope
src/roguewave/interpolate/nd_interp.py—NdInterpolatorsrc/roguewave/wavewatch3/restart_file.py—RestartFile.interpolate_in_space,RestartFile._fancy_index,RestartFile.fill_missing_spectra(new)src/roguewave/wavewatch3/io.py—write_restart_filetests/interpolate/test_nd_interp.py,tests/restart_files/test_fill_missing_spectra.py(new),tests/restart_files/test_empty_index.py(new),tests/restart_files/test_io.py,tests/interpolate/__init__.py(new, test package scaffolding)Current behavior:
Sofar's 0.25°/0.5°/1.0° WW3 grids share an origin and are exact integer-ratio refinements of one another, so a target point's bilinear stencil always collapses to 100% weight on a single coincident source grid point. When that coincident point is masked/land,
interpolate_in_spacereturns a NaN spectrum for the target point, even when the point is a single source-grid step away from open ocean on every side. Measured against a real production 0.25° restart, this affects up to several percent of target sea points depending on target resolution, concentrated at real coastlines (Antarctic coast, Indonesian archipelago, etc.), invisible toskipna=Truebulk diagnostics but present in the raw array written to restart files.New behavior:
NdInterpolatorgains an opt-innan_fallback_radiusconstructor parameter (default0, preserving current behavior for its other three callers:dataset.py,dataarray.py,cluster.py). When a point's primary bilinear lookup fails andnan_fallback_radius > 0, the interpolator checks that many source-grid-index steps around the coincident point — including the coincident point itself, which can still be the best available candidate for a general (non-grid-aligned) miss — inverse-distance-weights (haversine) whichever of those have valid data, and still returns NaN if none do. So a genuine domain gap (e.g. a source grid with no sea points nearby at all) is left alone rather than silently papered over.RestartFile.interpolate_in_spacepassesnan_fallback_radius=1, matching the fix's mechanism to the actual grid ratios in play. The sibling depth interpolator andRestartFileTimeStackare intentionally untouched — separate, already-tracked concerns.RestartFile.fill_missing_spectrafills them with a WW3 cold-start-style spectrum instead, matchingww3_strt's ITYPE options:"calm"(zero energy, default, matches ITYPE 5),"user_defined"(broadcast a caller-supplied spectrum, matches ITYPE 4),"gaussian"(matches ITYPE 1), and"jonswap"(matches ITYPE 2). These are roguewave's own implementations of the same named spectral shapes/conventions, not a port of WW3's Fortran. Points that already have data are left untouched.RestartFile._fancy_indexcrashed on an empty index array —numpy.array([])on an empty list of read spectra collapses to shape(0,)instead of(0, number_of_frequencies, number_of_directions), which xarray then rejected as a dimension-size conflict duringSpectrum/Datasetconstruction. This is common whenever a radius-1 neighborhood is entirely land (e.g. exactly the Hudson Bay case above), so the fallback in (1) would not have worked on real data without it.write_restart_file: it read.variance_densityinstead of.directional_variance_densityonSpectrum/Datasetinput, which broke composinginterpolate_in_space's output directly intowrite_restart_file— exactly the composition this PR's regridding path relies on. Fixes write_restart_file reads .variance_density instead of .directional_variance_density -- breaks when given a Spectrum/Dataset with directional data #15. Added a regression test (test_clone_restart_file) covering the one in-repo path that hits this branch, previously untested.Verified against the real production 0.25°→0.5° restart (2026-08-05 00Z cycle): before this PR, 885/156635 (0.565%) target sea points came back NaN; after the radius-1 fallback, 469/156635 (0.2994%) remain NaN, all genuine domain gaps (447 in Hudson Bay); after
fill_missing_spectra(fill_type="calm"), 0 remain NaN. Separately, on the parallel cluster, a restart file produced by this full path (interpolate_in_space+fill_missing_spectra+write_restart_file) was confirmed to load correctly and letww3_multi(the real production-pinnedesm-box:0.5.7build) step forward cleanly with no error, at production's exact 320-rank/10-node scale.Links to context docs:
Deploy plan
None beyond a normal merge — pure library change, no infra side effects. Downstream consumers pick this up on their next
roguewaveversion pin bump.TODO