Conservative regridding¶
Use method="conservative" when grid values represent cell averages and you
need spherical area-overlap averages. It works for coarsening or refinement,
including non-nested grids and arbitrary resolution ratios. It does not smooth
inside a source cell: nested refinement replicates that cell's value.
The default method="meanpreserving" is unchanged. That
method smooths during refinement and preserves each parent's unweighted mean
over its nearest-parent children. It can overshoot at discontinuities and is
not an area-weighted coarsener.
Spherical overlaps, two 1-D factors¶
For an EPSG:4326 cell bounded by constant latitudes and longitudes, its area is
Thus overlap fractions separate into a latitude factor measured in
sin(latitude) and a longitude factor measured in degrees. The radius and
degree-to-radian longitude conversion cancel in the ratios. The default weights
are overlap area divided by the full target cell area:
The two sparse factors are stored as resampler.axis_weights, ordered
(latitude, longitude). Applying them takes two sparse contractions per batch
slice, choosing the smaller intermediate. When the intermediate sizes tie,
refinement ends on the latitude contraction so the larger result is contiguous
for output copies and normalization; coarsening retains latitude first. This
especially helps skipna=True with missing data, which normalizes two projected
arrays. A full grid-to-grid Kronecker matrix is never built: for this method,
resampler.transform_matrix is None.
Use resampler.apply_grid(values) or resample_grid_with_matrix for application.
Mean-preserving resamplers still expose their existing CSR transform matrix.
For finite data and matching footprints, the default preserves
sum(value * cell_area) up to floating-point roundoff. For fully covered cells,
weights are nonnegative and sum to one, so values remain within the source
range. On partially covered cells, uncovered area contributes zero, which can
move a positive constant below its original value; see normalization below.
Matched footprints¶
This example describes the same 40°–60° latitude, 0°–20° longitude footprint on two nested grids. It returns spherical-area-weighted 4×4 block means:
import numpy as np
import xarray as xr
import geohalo as ghl
fine_lat = np.arange(40.25, 60.0, 0.5)
fine_lon = np.arange(0.25, 20.0, 0.5)
coarse_lat = np.arange(41.0, 60.0, 2.0)
coarse_lon = np.arange(1.0, 20.0, 2.0)
source = xr.DataArray(
np.random.default_rng(0).random((40, 40)),
dims=("latitude", "longitude"),
coords={"latitude": fine_lat, "longitude": fine_lon},
)
resampler = ghl.Resampler.compute(
fine_lat, fine_lon, coarse_lat, coarse_lon, method="conservative",
)
coarse = ghl.resample_grid_with_matrix(source, resampler)
Conservation over the entire source requires that the target cells cover
its footprint. Otherwise only the overlapping portion contributes. The
convenience call resample_grid(source, 2, method="conservative") retains the
existing source-centre-based target generation, not edge-aligned generation:
its target centres would start at 40.25° and 0.25°, not at 41° and 1° as above.
Do not assume matching cell extents merely because centre ranges are similar.
Use explicit target coordinates or bounds when the footprint matters.
Coordinates and cell bounds¶
Conservative coordinates must be finite, one-dimensional, and strictly
monotonic. Both ascending and descending axes work. Source latitude is stored
ascending, as for the existing resampler; the xarray adapter handles its stored
orientation. Source longitude and target row order are retained. The NumPy
apply_grid method accepts descending=True for descending source latitude.
Without explicit bounds, midpoint edges are inferred. Inferred latitude edges are clipped at −90° and +90°; centres and explicit latitude bounds must already be inside that range. Midpoint inference requires at least two coordinates, except a singleton periodic source longitude, which covers a full cycle.
Pass source_bounds=(lat_edges, lon_edges) and/or
target_bounds=(lat_edges, lon_edges) to the builder or cache method when cell
edges differ from these assumptions. Each edge array has N + 1 entries for
N centres, follows that axis's direction, and must contain each centre inside
its cell. Explicit bounds support single-cell source or target axes:
one_cell = ghl.Resampler.compute(
fine_lat, fine_lon, np.array([50.0]), np.array([10.0]),
method="conservative",
target_bounds=(np.array([40.0, 60.0]), np.array([0.0, 20.0])),
)
resample_grid accepts source_bounds and derives target bounds from the
requested resolution, so a coarsened singleton target also works. Its generated
target coordinates are unchanged from the existing helper. Irregular grids are
supported by conservative regridding, though polygon stencils still require
regular raster coordinates. Computing physical areas for explicit or clipped
bounds must use those bounds in the area formula above.
With period=360, longitude overlaps wrap across the seam. Inferred source
longitude bounds use cyclic midpoints and cover a full cycle; explicit source
longitude bounds must cover exactly one period. Source and target centre spans
must be less than a period, with no repeated endpoint. Target bounds may cover
at most one period. Explicit targets remain regional unless their supplied or
inferred bounds cover a full cycle; they may be shifted by whole periods.
The convenience API generates a full cycle and uses cyclic target midpoint
bounds. Latitude never wraps, and polygon geometries are not changed.
Partial coverage and missing values¶
The builder's normalization accepts:
"destination"(default): divide by full target area. A constant 10 covering half a target cell produces 5; this retains the integral over the overlap."covered": divide by source-covered target area. The same cell produces- For an integral, multiply by the covered area, not the full target area.
These correspond to the destination-area and fraction-area conventions
described in the ESMF normalization documentation.
resampler.coverage returns a (target_lat, target_lon) array of covered-area
fractions, independent of data values. Cells with no overlap return NaN under
either normalization; the raw 1-D matrices contain zero rows for them.
NaNs propagate from overlapping source cells by default. Opt into valid-area averaging at application time:
coarse = ghl.resample_grid_with_matrix(source, resampler, skipna=True)
flat = resampler.apply_grid(source.to_numpy(), skipna=True)
skipna=True projects both zero-filled values and a validity mask, then divides
once after both axis contractions. This overrides destination normalization:
the denominator is valid covered area, even on clean partially covered cells.
All-missing and unmapped cells return NaN. Only NaNs are excluded; infinities
are not treated as missing. Valid-area renormalization is not a promise to
preserve the original full-area integral when coverage or data are missing.
Resampling skipna=True is currently supported only for conservative operators;
using it with the mean-preserving method raises ValueError. This does not
change the separate skipna behavior of prebuilt polygon reducers.
Reuse, memory, and scope¶
LocalCache.get_or_compute_resampler and its Redis counterpart accept the same
method, normalization, period, and bounds options. Cache hits restore the two
axis factors without rebuilding; skipna and preserve_dtype are apply-time
options and do not change the key. Both methods use the
portable NPZ format; input digests are unchanged, while
legacy pickle cache entries require a one-time rebuild.
Conservative coefficients use float64; float32 inputs normally produce float64
results. Set preserve_dtype=True on resample_grid,
resample_grid_with_matrix, or Resampler.apply_grid to store each real
floating input's result in its own dtype. Accumulation and normalization retain
their existing precision, and integer, boolean, and complex inputs keep normal
promotion. The result is allocated in the chosen dtype; final rounding may
reduce conservation accuracy. See result dtypes.
Work is bounded per batch slice, including missing-data masks and reversed/strided inputs; the dense output still needs to fit in memory. As before, the xarray resampling adapter eagerly loads lazy inputs before application. This is not chunk-aware source reading or a new Dask execution mode.
To compare the contraction order with the 2.0.0 implementation on synthetic
global grids, run uv run python -m benchmarks.conservative_refinement from a
source checkout. The benchmark checks numerical agreement and reports
interleaved timings for refinement, coarsening, and unequal axis ratios.
Use --memory to measure application allocations separately from timings.
Leave iterations=1 with method="conservative"; other values are rejected
because iteration only controls the mean-preserving algorithm. FactoredResampler
and fused polygon reducers remain mean-preserving. To conservatively regrid
before polygon reduction, first resample the field, then reduce it on the
resulting grid. No reprojection or higher-order reconstruction is introduced.