Raster Mocking Techniques
Raster mocking is the practice of synthesizing deterministic, lightweight, schema-compliant gridded assets — GeoTIFFs, COGs, and multi-dimensional arrays — so that gridded geoprocessing logic can be validated without touching production satellite, aerial, or LiDAR archives. It is one of the three generation modalities inside Test Data Generation & Mocking Strategies, the parent discipline that supplies reproducible inputs to every downstream assertion. Unlike real imagery, a mock raster eliminates licensing friction, network latency, and unpredictable sensor artifacts while preserving the exact dimensional, spectral, and coordinate reference system (CRS) characteristics a geoprocessing engine expects. This page details the implementation patterns GIS QA engineers, data engineers, and platform teams use to build mock rasters that are byte-reproducible, memory-safe under constrained runners, and gated by explicit tolerance thresholds.
A mock raster is never just a random array. It is a contract: band topology, pixel resolution, data type, nodata sentinel, affine geotransform, and CRS are all fixed by configuration, and every one of those properties is asserted at generation time and again at validation time. That closed loop is what separates a disposable scratch file from a versioned test fixture you can trust across local development, staging, and CI.
Raster Mock Taxonomy and Tolerance Strategy
The four families below cover the overwhelming majority of gridded fixtures a spatial pipeline needs. Each maps to a distinct dtype, nodata convention, and dominant validation concern. Pick the family first; the tolerance strategy follows from it.
| Mock family | Typical dtype | Nodata sentinel | Dominant tolerance | Threshold range | CRS / unit concern |
|---|---|---|---|---|---|
| Single-band continuous | uint8 / uint16 |
0 |
Pixel-value drift | 0.0–0.5% | Geotransform corner deviation |
| Multi-band spectral | uint16 |
0 |
Inter-band correlation | ±0.02 on r | Per-band registration |
| DEM / elevation | float32 |
-9999.0 |
Vertical RMSE | 0.001–0.05 m | Vertical datum + linear unit |
| Categorical / classified | uint8 |
255 |
Exact class equality | 0 (exact) | Resampling must stay nearest |
The recommended posture is to enforce the narrowest tolerance the consuming algorithm can survive. A categorical land-cover mock tolerates zero pixel drift because any interpolation corrupts class codes, whereas a continuous reflectance mock can absorb sub-percent variance from seeded noise. Set these bounds the same way you would spatial tolerance thresholds for geometric assertions: declared once, version-pinned, and shared between the generator and the validator so neither can drift independently.
The pixel-value drift check itself is a relative-error bound. For an observed pixel
where pixel_value_drift_percent / 100 and
Configuration-Driven Generation
Raster mocking is a declarative, configuration-first process, not an ad-hoc scripting exercise. Production implementations read a version-controlled YAML or JSON manifest that fixes band topology, resolution, data types, nodata values, spatial extent, and the tolerance thresholds the fixture must satisfy. Pinning the manifest in source control makes the fixture reproducible and makes any change to it reviewable.
raster_mock:
crs: "EPSG:32610" # UTM 10N — linear unit is metres
resolution: [10.0, 10.0] # ground sample distance, x/y
dimensions: [512, 512] # columns, rows
origin: [500000.0, 4180000.0] # top-left easting/northing
bands:
- name: "red"
dtype: "uint16"
min_val: 0
max_val: 10000
statistical_profile: "gaussian"
- name: "nir"
dtype: "uint16"
min_val: 0
max_val: 10000
statistical_profile: "lognormal"
nodata_value: 0
seed: 20260625 # pipeline-level determinism anchor
tolerances:
max_crs_deviation_meters: 0.001
pixel_value_drift_percent: 0.5
spatial_extent_tolerance_pixels: 0
metadata_schema_version: "v2.1"
max_crs_deviation_meters keeps the mocked geotransform aligned within sub-centimetre bounds of the expected projection; pixel_value_drift_percent caps acceptable variance from the seeded distribution. Both are evaluated during generation and again during validation, so a malformed fixture fails fast rather than silently poisoning a downstream test. The seed field is the determinism anchor — change it deliberately, never incidentally.
Single-Band Continuous Rasters
A single-band continuous mock stands in for reflectance, temperature, or any scalar field. The predicate that matters is value distribution within [min_val, max_val] and a correctly encoded nodata sentinel. A seeded generator produces a reproducible band:
rng = numpy.random.default_rng(seed=20260625)
band = rng.integers(low=0, high=10000, size=(512, 512), dtype="uint16")
band[mask_nodata] = 0 # nodata sentinel for uint16
Multi-Band Spectral Rasters
Spectral mocks must preserve realistic inter-band relationships so that index math — NDVI, NDWI, classification thresholds — behaves as it would on real imagery. Independent random bands produce nonsense indices. Impose structure with a Cholesky factor of a target covariance matrix so the red–NIR inverse correlation of vegetation survives:
cov = numpy.array([[1.0, -0.6], [-0.6, 1.0]]) # red/NIR target correlation
L = numpy.linalg.cholesky(cov)
flat = rng.standard_normal((2, 512 * 512))
correlated = (L @ flat).reshape(2, 512, 512)
Each band is then rescaled into its uint16 range. Because spectral mocks are frequently paired with mocked vector masks for zonal tests, generate them from the same manifest you use for synthetic vector data so bounding boxes and CRS stay identical across modalities.
DEM and Float Elevation Rasters
Elevation mocks are float32 with a -9999.0 nodata sentinel and a vertical tolerance distinct from horizontal registration. The dominant check is vertical RMSE against the seeded surface; smooth, continuous surfaces (a tilted plane plus low-amplitude noise) exercise slope, aspect, and hillshade algorithms without injecting unrealistic discontinuities:
yy, xx = numpy.mgrid[0:512, 0:512].astype("float32")
dem = 100.0 + 0.05 * xx + 0.03 * yy # gently tilted plane, metres
dem += rng.normal(0.0, 0.5, dem.shape).astype("float32") # micro-relief
dem[void_mask] = -9999.0
Floating-point fixtures are the most common source of coordinate-precision surprises; the same drift mechanics are dissected in coordinate precision loss during conversion.
Categorical and Classified Rasters
Land-cover, masks, and any label grid are uint8 with class equality as the only acceptable tolerance — interpolation is forbidden because resampling a class code is meaningless. Generate from a fixed palette and assert that no value outside the declared class set ever appears:
classes = numpy.array([11, 21, 41, 52, 71], dtype="uint8") # NLCD-style codes
labels = classes[rng.integers(0, classes.size, size=(512, 512))]
assert set(numpy.unique(labels)).issubset(set(classes.tolist()))
Memory-Safe Execution and Resource Governance
Raster mocking pipelines fail in constrained CI runners when they allocate the full extent in memory at once. Memory-safe execution requires windowed I/O, explicit chunking, and a write pattern whose footprint scales with the chunk size rather than the total extent — rasterio.windows (or xarray backed by dask for distributed runs):
- Open the output with
rasterio.open(path, "w", driver="GTiff", dtype=..., count=..., nodata=...). - Build a
Windowgrid matching the configured chunk size (256×256 or 512×512 is typical). - Generate each window’s data with a seeded
numpy.random.Generatorderived deterministically from the window index. - Write with
dataset.write(chunk, window=window)and finalize withdataset.close().
Deriving each window seed from the global seed plus the window’s (row, col) keeps output byte-identical regardless of iteration order or worker count, which is what lets you parallelize with dask.delayed without sacrificing reproducibility. This is the gridded analogue of the chunked-read discipline used to structure pytest-geo for large shapefiles.
Production-Grade pytest Implementation
The fixture and its validator share one config object, so the tolerance the generator honoured is the tolerance the test enforces. The block below is runnable against rasterio>=1.3 and pytest>=7:
import numpy
import rasterio
from rasterio.transform import from_origin
import pytest
def build_mock_raster(path, cfg):
"""Generate a deterministic single-band mock from a config dict."""
rng = numpy.random.default_rng(cfg["seed"])
cols, rows = cfg["dimensions"]
res_x, res_y = cfg["resolution"]
ox, oy = cfg["origin"]
transform = from_origin(ox, oy, res_x, res_y)
profile = {
"driver": "GTiff",
"dtype": "uint16",
"count": 1,
"width": cols,
"height": rows,
"crs": cfg["crs"],
"transform": transform,
"nodata": cfg["nodata_value"],
}
band = rng.integers(0, 10000, size=(rows, cols), dtype="uint16")
with rasterio.open(path, "w", **profile) as dst:
dst.write(band, 1)
return transform
def test_mock_raster_meets_contract(tmp_path):
cfg = {
"crs": "EPSG:32610",
"resolution": [10.0, 10.0],
"dimensions": [512, 512],
"origin": [500000.0, 4180000.0],
"nodata_value": 0,
"seed": 20260625,
"tolerances": {"max_crs_deviation_meters": 0.001},
}
out = tmp_path / "mock.tif"
expected_transform = build_mock_raster(out, cfg)
tau_xy = cfg["tolerances"]["max_crs_deviation_meters"]
with rasterio.open(out) as src:
# CRS identity — fail on any reprojection drift.
assert src.crs.to_epsg() == 32610
# Corner registration within sub-centimetre bounds (metres).
ox_obs, oy_obs = src.transform.c, src.transform.f
assert abs(ox_obs - cfg["origin"][0]) <= tau_xy
assert abs(oy_obs - cfg["origin"][1]) <= tau_xy
# Nodata sentinel correctly encoded at the declared bit depth.
assert src.nodata == 0
# Determinism — regenerating with the same seed is byte-identical.
replay = tmp_path / "replay.tif"
build_mock_raster(replay, cfg)
assert out.read_bytes() == replay.read_bytes()
The byte-identity assertion is the regression backstop: if a library upgrade silently changes GeoTIFF tag ordering or compression defaults, this test catches it before the mock reaches a consuming pipeline.
PostGIS Raster Counterparts
When the consumer is a database rather than a file, the same fixture must land as a PostGIS raster column. Load the generated GeoTIFF with raster2pgsql, then assert the server-side metadata matches the manifest — this is the gridded equivalent of the connection isolation covered in mocking PostGIS connections:
-- Load a 512x512 tiled mock; -t sets the tile size, -N the nodata sentinel.
-- raster2pgsql -s 32610 -t 256x256 -N 0 mock.tif test.mock_raster | psql
SELECT ST_SRID(rast) AS srid,
ST_Width(rast) AS width,
ST_BandNoDataValue(rast, 1) AS nodata,
(ST_MetaData(rast)).scalex AS res_x
FROM test.mock_raster
LIMIT 1;
-- Expected: srid=32610, width=256, nodata=0, res_x=10.0
Asserting ST_SRID and ST_BandNoDataValue on the loaded tile closes the same loop as the file-side test: the projection and nodata sentinel survived the round trip into the database.
Pipeline Integration
Mock generation belongs in the same CI gate as the assertions that consume it. Generate the fixture in a setup stage, run the contract test, and pin the geospatial runtime so that GDAL, GEOS, and PROJ versions cannot drift between developer machines and the runner — an unpinned PROJ release can shift a corner coordinate past max_crs_deviation_meters with no code change. Emit structured log events (fixture seed, CRS, dimensions, tolerance deltas, output checksum) so a failed gate is diagnosable from logs alone. Pair this with automated CRS validation in CI so projection regressions surface at merge time, and feed the mock into cross-format parity testing when the pipeline writes the same grid to more than one driver. Where the fixture sits in the broader test hierarchy is governed by the GIS test pyramid: mock-backed unit checks at the base, integration tests over coupled raster–vector artifacts above them.
Common Failure Modes and Gotchas
- Legacy global RNG. Calling
numpy.random.seed()plus barenumpy.random.*functions leaks state across tests and breaks determinism. Always use an explicitnumpy.random.default_rng(seed)generator scoped to the fixture. - Nodata sentinel collisions. A
0nodata on auint16reflectance band silently masks legitimate dark pixels. Choose a sentinel outside the valid value range, or use a separate mask band for continuous data. - Float equality on DEMs. Comparing
float32elevation pixels with==fails on platform-dependent rounding. Use the relative-error bound, never exact equality. - Resampling a categorical grid. Any reprojection or overview build that defaults to bilinear corrupts class codes. Force nearest-neighbour resampling and assert the unique value set stays a subset of the declared palette.
- UTM-zone-edge drift. Mocks placed near a zone boundary or the anti-meridian expose coordinate-wrapping bugs; an unpinned PROJ can move corners past the tolerance. Pin PROJ and assert corner registration explicitly.
- Heap fragmentation on full-extent writes. Materialising the whole array before writing blows the runner’s memory budget on large extents. Stream with windowed writes so the footprint stays
. - Tag-order non-determinism. GeoTIFF metadata tag ordering can vary across GDAL builds, breaking byte-identity checks. Pin the GDAL version and normalize tags before checksum comparison.
These pathologies are exactly the boundary conditions catalogued in Edge Case Spatial Data Creation; promoting each one to a first-class fixture ensures gridded engines degrade gracefully rather than failing catastrophically on real data.
Conclusion
Treating mock rasters as version-controlled contracts — fixed CRS, declared nodata, seeded distributions, and tolerances enforced on both sides of the generate-validate loop — turns gridded test data from a source of flakiness into a deterministic foundation for spatial QA. Build the fixture from a pinned manifest, gate it with the narrowest tolerance the consumer can survive, and the rest of the pipeline inherits that reliability. For the surrounding strategy and the sibling generation modalities, return to Test Data Generation & Mocking Strategies.
Related
- Synthetic Vector Data Generation — coupled vector fixtures for raster–vector integration tests
- Edge Case Spatial Data Creation — degenerate and boundary-condition geometries
- Setting Up Spatial Tolerance Thresholds in Assertions — declaring the bounds your mock must satisfy
- Automating CRS Validation in CI Pipelines — catching projection drift at merge time
- Testing Coordinate Precision Loss During Conversion — float drift mechanics for DEM mocks