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.

Decision tree for choosing a mock raster family Starting from a single fixture node, the pixel-value semantics question branches into four mock raster families. Single-band continuous uses uint8 or uint16 with nodata 0 and a pixel-drift tolerance under 0.5 percent. Multi-band spectral uses uint16 with nodata 0 and an inter-band correlation tolerance of plus or minus 0.02. DEM or elevation uses float32 with nodata minus 9999.0 and a vertical RMSE tolerance under 0.05 metres. Categorical or classified uses uint8 with nodata 255 and requires exact class equality. Mock raster fixture Pixel-value semantics? Single-band continuous uint8 / uint16 · nodata 0 · pixel drift ≤ 0.5% Multi-band spectral uint16 · nodata 0 · inter-band r within ±0.02 DEM / float elevation float32 · nodata −9999.0 · vertical RMSE ≤ 0.05 m Categorical / classified uint8 · nodata 255 · exact class equality (no interpolation)
Pick the family first; its dtype, nodata sentinel, and dominant tolerance check follow from that single decision.

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 vobsv_{obs} against the seeded expectation vexpv_{exp}, the gate passes when

δ=vobsvexpmax(vexp,ϵ)τ\delta = \frac{\lvert v_{obs} - v_{exp}\rvert}{\max(v_{exp},\, \epsilon)} \le \tau

where τ\tau is pixel_value_drift_percent / 100 and ϵ\epsilon guards against division by zero in nodata regions. Corner registration uses an absolute bound in CRS linear units: cobscexp2τxy\lVert c_{obs} - c_{exp}\rVert_2 \le \tau_{xy} metres.

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 — O(chunk)O(\text{chunk}), not O(extent)O(\text{extent}). Use rasterio.windows (or xarray backed by dask for distributed runs):

  1. Open the output with rasterio.open(path, "w", driver="GTiff", dtype=..., count=..., nodata=...).
  2. Build a Window grid matching the configured chunk size (256×256 or 512×512 is typical).
  3. Generate each window’s data with a seeded numpy.random.Generator derived deterministically from the window index.
  4. Write with dataset.write(chunk, window=window) and finalize with dataset.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

  1. Legacy global RNG. Calling numpy.random.seed() plus bare numpy.random.* functions leaks state across tests and breaks determinism. Always use an explicit numpy.random.default_rng(seed) generator scoped to the fixture.
  2. Nodata sentinel collisions. A 0 nodata on a uint16 reflectance band silently masks legitimate dark pixels. Choose a sentinel outside the valid value range, or use a separate mask band for continuous data.
  3. Float equality on DEMs. Comparing float32 elevation pixels with == fails on platform-dependent rounding. Use the relative-error bound δτ\delta \le \tau, never exact equality.
  4. 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.
  5. 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.
  6. 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 O(chunk)O(\text{chunk}).
  7. 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.