Skip to content

Raster sampling ignores coordinate epoch: time-dependent ITRF->NAD83 transform always evaluated at 2010.0 (~2 cm/yr horizontal, ~1 mm/yr vertical drift) #645

Description

@dshean

Summary

GdalRaster::createTransform never sets a coordinate epoch, so when the dataframe CRS is dynamic (ITRF2020 EPSG:9989 for r007 products via #534; the EPSG:7912 ITRF2014 fallback likewise) and the sampled raster's CRS is static (e.g. 3DEP 1 m: NAD83(2011) UTM + NAVD88), PROJ evaluates the time-dependent Helmert ("ITRF2020 to NAD83(2011) (1)", EPSG:10334, t_epoch=2010.0) at its parameter reference epoch 2010.0 instead of the observation epoch. The sampled location and the vertical-shift correction are therefore both computed for where the North America plate was in 2010.

Measured effect (western US, pyproj 3.7.2 / PROJ 9.7.1)

Difference between the 4D transform EPSG:9989 → EPSG:6318+5703 evaluated at the observation epoch vs at 2010.0, on a 362-point grid across the western US (exactly linear in time; azimuth is the direction the sampled location is displaced from the epoch-correct one):

epoch horizontal error (m) vertical error of the POI orthometric height (mm)
2018.75 0.169 +9.2
2022.75 0.246 +13.4
2026.60 0.320 +17.5

Rates: 19.3 mm/yr horizontal toward azimuth ~238° (rigid NA-plate motion; varies ~1–2.5 cm/yr with location across CONUS since it is a plate rotation) and +1.05 mm/yr vertical. Because samplePOI computes vertical_shift = z_source − z_transformed (GdalRaster.cpp:331) and adds it to the pixel value, the vertical part propagates directly into the returned value: 3dep.value is biased low by ~1.5 cm for 2024 data, growing ~1 mm/yr — a spurious trend for anyone using SlideRule for multi-year elevation change against 3DEP. The horizontal part samples the DEM ~0.2–0.3 m away from the correct pixel: on terrain slope s this adds a per-point error ≈ displacement × s (for our mountain AOI: ~3 cm NMAD-equivalent scatter at median slopes, 0.15–0.2 m per-point on slopes > 0.5 by 2026).

Verification context: we verified the rest of the chain is exact — client-side resampling of the same 3DEP COGs with an epoch-aware 4D PROJ transform (GEOID18 + the same Helmert) reproduces 3dep.value to 0.000 m median/NMAD once the epoch is forced to 2010.0. May be relevant background for other standing-offset reports against 3DEP (e.g. #263).

Code pointers (current main, apps/node/ layout)

  • apps/node/packages/geo/package/GdalRaster.cpp:1286–1358 (createTransform): OGRCoordinateTransformationOptions gets an AOI / optional user pipeline, but no coordinate epoch is ever set before OGRCreateCoordinateTransformation(&sourceCRS, &targetCRS, options).
  • apps/node/packages/geo/package/GdalRaster.cpp:304–368 (samplePOI): transforms the POI and derives vertical_shift (line 331), added to the sampled value (e.g. lines 902, 974).
  • apps/node/packages/geo/package/RasterObject.h:75–79: point_info_t already carries a per-point int64_t gps timestamp — the epoch information is plumbed to the sampling layer.

Suggested fix

Call sourceCRS.SetCoordinateEpoch(epochYears) (GDAL ≥ 3.4, RFC 81 — the coordinate epoch is a property of OGRSpatialReference, not of OGRCoordinateTransformationOptions) in createTransform before creating the transformation, with epochYears = decimal year of the request's mid-time (e.g. from the first/median point_info_t.gps of the batch, or granule metadata). GDAL then instantiates the PROJ operation at that epoch.

Granularity: within a granule-region request the point times span seconds, so one epoch per transform is exact for practical purposes; even 0.1-yr rounding (if useful for transform caching) keeps the residual under ~2 mm horizontal. Transforms reused across requests (if any) would need the epoch in their cache key. Static-source dataframes are unaffected: setting an epoch on a static CRS is a no-op for static-to-static operations, though guarding with sourceCRS.IsDynamic() keeps intent explicit.

Workaround for users (until fixed)

The vertical component can be removed post-hoc: value_corrected = value + 1.05 mm/yr × (t_obs − 2010.0) for CONUS (location-dependent at the ±10% level). The horizontal (slope-coupled) component cannot be repaired after sampling.

Full analysis scripts available on request (pyproj 4D grid evaluation + COG-resampling verification).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions