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.

Array, MemoryFile, or a temporary file Three options for holding a mock raster. The plain NumPy array with a separate profile dictionary is free and exercises only array arithmetic, with no driver involved. The rasterio MemoryFile costs a fraction of a millisecond and additionally exercises the GeoTIFF driver, the profile round trip through real metadata, and windowed reads, all without touching the filesystem. The temporary file on disk costs an actual write and is the only option that exercises overviews, external sidecar files, and any code path that takes a filesystem path. A recommendation marks MemoryFile as the default and the temporary file as a deliberate choice when a path is genuinely needed. NumPy array + dict cost: none exercises: array arithmetic no driver involved at all good for pure statistics logic rasterio MemoryFile cost: a fraction of a millisecond exercises: driver + profile + windows real metadata, no filesystem the default choice Temporary file cost: an actual write exercises: overviews + sidecars the only one with a path when code demands a filename Default to the middle column. It buys the driver, the profile round trip and windowed reads for almost nothing, which is where most raster defects live.

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.

The Raster Properties a Mock Must Get Right

Vector mocking is mostly about geometry; raster mocking is mostly about metadata, and the asymmetry catches teams out. A mock raster whose pixel values are perfect but whose geotransform is wrong will pass every statistical assertion and fail every alignment one — and alignment is where real raster pipelines break.

Six properties carry almost all of the risk, and a mock that gets these right can have entirely arbitrary pixel values without weakening a single test.

Property What breaks when the mock gets it wrong Cheap way to get it right
Geotransform Every overlay, clip and reprojection lands offset Derive from an explicit origin and pixel size, never hard-code six numbers
CRS Alignment silently compares different spaces Set it explicitly; assert it after writing
Nodata value Statistics include fill; masks come out inverted Declare it, and make some pixels actually equal it
Dtype Overflow or precision loss on arithmetic Match production exactly, including signedness
Band count and order Index-based band access reads the wrong band Name the bands in the fixture’s description
Block or tile size Windowed reads behave differently than production Match the production creation options
Identical pixels, different geotransforms Two comparisons. On the left, two raster grids with the same origin and pixel size are drawn overlapping exactly, cell boundary on cell boundary, annotated that an overlay is a direct cell-to-cell operation and values are preserved. On the right, two grids with origins differing by half a pixel are drawn offset from one another, annotated that every overlay must resample, so values change without any error being raised and a statistical assertion still passes. A footer states that a mock with arbitrary pixel values and a correct geotransform exercises more than one with realistic pixels and an incorrect geotransform. Same geotransform — grids align cell boundary on cell boundary overlay is exact — values preserved Half-pixel offset — grids do not align origins differ by half a pixel every overlay resamples — silently A mock with arbitrary pixel values and a correct geotransform tests far more than one with realistic pixels and a wrong one. Metadata is the payload.

The nodata row rewards a moment’s thought, because getting it half-right is common. Declaring a nodata value without ever writing that value into the array produces a fixture that never exercises masking; writing the value without declaring it produces one where the fill is silently treated as data and drags every statistic. A correct raster mock does both — declares the value in the profile and places it in a known region of the array — so the test can assert both that the mask has the expected shape and that statistics computed over the valid region ignore the fill.

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.

A note on fixture size

Raster fixtures grow faster than anyone expects, and a suite that commits a handful of realistically-sized arrays quickly becomes a repository nobody enjoys cloning. Generate rather than commit wherever the fixture is deterministic, and where a file genuinely must exist, keep it as small as the assertion allows — a 32 by 32 grid exercises tiling, masking and alignment exactly as well as a 4096 by 4096 one, and reads in microseconds rather than milliseconds.

The exception is any test about block or overview behaviour, which needs an array large enough to contain several blocks. Sizing those deliberately, and documenting why they are large, keeps them from being “optimised” away by someone tidying the fixture directory.

Finally, assert the profile after writing rather than trusting the dictionary you passed in. Drivers silently adjust creation options they cannot honour — a block size that does not divide the raster, a compression the build lacks — and reading the profile back is the only way to learn that the fixture you thought you built is not the one on disk.

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.

Where a fixture must be large, generate it at session scope and reuse it across the module rather than rebuilding per test; the array construction usually costs more than the assertions it supports.