Asserting Raster Band Statistics in pytest
A band statistic — a mean, a minimum, a valid-cell count — is the cheapest summary a raster offers and the easiest to assert wrongly. This guide sits beneath raster and grid assertions and shows how to write statistics assertions with rasterio 1.3+ and NumPy that actually discriminate: masking the fill before computing, asserting the population alongside the value, choosing the bound form from the band’s semantics rather than by habit, and casting before any arithmetic so a difference cannot wrap into a positive number.
The failure this prevents is not a wrong number that looks wrong. It is a plausible number computed over the wrong population, which passes review, passes CI, and is discovered months later when a total does not reconcile.
Root cause: the statistic and the population are two claims
band.mean() answers a question, and the question includes an unstated clause: over which cells? If nodata is unmasked, the answer covers fill as well as measurements. If a window was read, it covers that window. If a mask was applied upstream, it covers whatever survived. Every one of those produces a number, and none of them raises.
The consequence is that asserting a value alone is only half an assertion. The other half is the count of cells that contributed — the population — and it is the half that discriminates. A mean of 11.9 over 8,412 valid cells and a mean of 11.9 over 10,000 cells including 1,588 zeros are different results from a suite’s point of view, and only the count tells them apart.
Parameter reference
| Element | Choice that discriminates | Common mistake |
|---|---|---|
| Read call | src.read(1, masked=True) |
src.read(1) — fill included |
| Population | Assert valid_count explicitly |
Assert only the statistic |
| Bound form | Relative epsilon for magnitudes | Absolute for a quantity spanning orders |
| Bound form | Absolute for bounded quantities | Relative near zero |
| Arithmetic dtype | Cast to float64 before differences |
uint8 subtraction wraps |
| Range check | Assert min and max against physical limits | Trust the mean alone |
| Failure message | Statistic, value, expected, population | “statistics differ” |
Step-by-step implementation
The gate targets rasterio 1.3+, NumPy and pytest 7+.
Step 1 — Read masked, always
Masking is a read-time decision, and getting it wrong contaminates everything downstream. Make it the only way the fixture is opened.
import rasterio
import numpy as np
def read_band(path: str, band: int = 1) -> np.ma.MaskedArray:
"""Read one band with the declared nodata masked out."""
with rasterio.open(path) as src:
if src.nodata is None:
raise AssertionError(f"{path}: no nodata declared — masking is not possible")
return src.read(band, masked=True)
Raising when nodata is undeclared is deliberate. A raster with no declared fill either genuinely has none — in which case the contract should say so — or has fill that nothing will mask, which is the defect this whole guide exists to prevent.
Step 2 — Compute the statistic and its population together
Returning both from one function makes it impossible to assert one without the other.
from dataclasses import dataclass
@dataclass(frozen=True)
class BandStats:
mean: float
minimum: float
maximum: float
valid_cells: int
total_cells: int
def band_stats(arr: np.ma.MaskedArray) -> BandStats:
valid = int(arr.count()) # unmasked cells only
return BandStats(
mean=float(arr.mean()),
minimum=float(arr.min()),
maximum=float(arr.max()),
valid_cells=valid,
total_cells=int(arr.size),
)
Step 3 — Assert the population first
The order matters for the same reason it matters elsewhere: a wrong population makes every value assertion meaningless, so failing on it first names the cause rather than a consequence.
import pytest
EXPECTED_VALID = 8412
EXPECTED_MEAN = 14.2
REL_EPS = 0.01 # 1% — the band is a magnitude, not a bounded ratio
def test_band_statistics(dem_path):
stats = band_stats(read_band(dem_path))
assert stats.valid_cells == EXPECTED_VALID, (
f"population changed: {stats.valid_cells} valid of {stats.total_cells} "
f"(expected {EXPECTED_VALID}) — the mean below is not comparable"
)
assert abs(stats.mean - EXPECTED_MEAN) / EXPECTED_MEAN <= REL_EPS, (
f"mean {stats.mean:.3f} outside {REL_EPS:.1%} of {EXPECTED_MEAN} "
f"over {stats.valid_cells} cells"
)
Step 4 — Bound the range as well as the centre
A mean can be correct while individual cells are impossible. For any physically bounded quantity, the range assertion is the one that catches an overshoot from interpolation or a wrapped dtype.
def test_band_values_are_physically_possible(reflectance_path):
stats = band_stats(read_band(reflectance_path))
assert 0.0 <= stats.minimum, f"negative reflectance: {stats.minimum}"
assert stats.maximum <= 1.0, f"reflectance above unity: {stats.maximum}"
Verify the fix
Provoke the population failure once to confirm the assertion order works:
pytest -q tests/test_band_stats.py -v
Temporarily read without masked=True and confirm the count assertion fails first, with a message naming both counts. If instead the mean assertion fails first, the ordering is wrong and a future reader will be sent to investigate a value that was never the problem.
The dtype trap in difference assertions
Comparing two bands is the most common raster assertion after a single statistic, and it has a failure mode that produces no error and a confidently wrong answer. Unsigned integer arithmetic wraps: subtracting 10 from 4 in uint8 yields 250, not −6. A difference raster computed that way has a plausible dtype, a plausible range, and values that are the arithmetic complement of the truth in exactly the cells where the second band exceeds the first.
Choosing which statistics are worth asserting
Not every summary a band offers is worth a test. Three are, one is conditional, and two are usually noise.
The valid-cell count is the most valuable single number in a raster suite, because it is sensitive to almost every structural defect — a changed mask, a truncated read, a shifted window, an extra fill value — and insensitive to the ordinary variation that makes value assertions brittle. If a suite asserts one thing about a band, this is the one.
The mean is the workhorse: sensitive enough to catch a scaling error, a unit change or a wrong band, and stable enough not to move when a handful of cells do. Bound it relatively for magnitudes and absolutely for ratios.
The min and max are the range assertion, and they catch what the mean cannot: interpolation overshoot, wrapped arithmetic, and impossible values in a physically bounded quantity. They are cheap and they fail loudly.
The standard deviation is conditional. It catches a smoothing or sharpening that leaves the mean untouched, which is genuinely useful after any resampling — and it is unstable on small fixtures, so it belongs on production-shaped data in a scheduled run rather than in a fast lane.
The median and the mode are usually noise in a continuous band, because both move discontinuously with small changes in the data and produce failures nobody can attribute. The exception is a categorical band, where the mode is the dominant class and asserting it is exactly right.
The general principle is to prefer statistics that move for the reasons you care about and stay still otherwise. A suite full of assertions that fail for legitimate data variation trains a team to widen thresholds, and once that habit exists the sensitivity that would have caught a real defect has already been spent.
Failure modes and edge cases
- A fully-masked band. If every cell is nodata,
mean()on a masked array returns a masked value rather than a number, and comparisons against it are neither true nor false. Assertvalid_cells > 0before any statistic. - Float nodata compared by equality. A nodata value of
-9999.0stored asfloat32may not compare exactly equal to the declaredfloat64constant, leaving a few fill cells unmasked. Compare with a tolerance, or declare the value in the band’s own dtype. - NaN in the data. NaN is not masked by a nodata declaration and propagates through every statistic, turning a mean into NaN — which compares
Falseagainst any bound, so an assertion using<=silently passes. Check for finiteness explicitly. - Relative bounds near zero. A relative epsilon on a quantity whose expected value is close to zero produces an absurdly tight bound. Switch to an absolute bound whenever the expected value can approach zero.
- Statistics cached in the file. Some drivers store precomputed statistics in metadata, and those can be stale relative to the pixel data. Compute from the array rather than reading the cached values, or assert that the two agree.
- Comparing across different windows. Two statistics computed over different windows are not comparable however close their values look. Recording the window alongside the statistic makes that visible in the report rather than in someone’s assumption.
Conclusion
A band statistic assertion is two claims, and most suites make only one of them. Reading masked, asserting the contributing cell count before the value, choosing the bound form from the band’s semantics, and casting before any difference converts a check that passes over plausible-looking errors into one that names the population it measured — the statistics layer of raster and grid assertions.
Related
- Raster and Grid Assertions — the parent family and where statistics sit within it.
- Testing Nodata and Mask Handling in rasterio — building the mask these statistics depend on.
- Comparing Resampled Rasters with Tolerance — which statistics survive a resampling and which do not.
- Raster Mocking Techniques — fixtures whose declared nodata is actually present in the array.