Skip to content

Chain vertical CRS transformations through intermediate vertical CRS sharing the same datum #4707

@phaarnes

Description

@phaarnes

Summary

When transforming between two vertical CRSs and no direct registered operation exists for the exact pair, PROJ should look for registered operations involving a different vertical CRS that shares the same datum as the source or target. The intermediate CRS may differ in axis direction (height vs depth), units (metres vs feet), or both. Currently PROJ falls back to a ballpark transformation in these cases, discarding available registered operations.

Example

EPSG:5705 (Baltic 1977 height) → EPSG:5706 (Caspian depth)

The EPSG registry contains operation EPSG:5438 which transforms 5705 → 5611 (Caspian height). CRS 5611 and 5706 share the same datum (Caspian Sea) but differ in axis direction (UP vs DOWN).

Expected: PROJ composes 5705 →(EPSG:5438)→ 5611 →(axis/unit conversion)→ 5706
Actual: PROJ returns a ballpark vertical transformation, discarding the registered operation.

Current Behaviour

The registered height→height operation works correctly:

from pyproj.transformer import TransformerGroup

# ✅ Works: Baltic 1977 height → Caspian height (EPSG:5438)
tg = TransformerGroup("EPSG:5705", "EPSG:5611", always_xy=True)
t = tg.transformers[0]
print(t.to_proj4())                   # +proj=geogoffset +dh=28
print(t.transform(50.0, 40.0, 100))   # (50.0, 40.0, 128.0) ← correct

But height→depth on the same datum falls back to ballpark:

# ❌ Fails: Baltic 1977 height → Caspian depth (same datum, axis down)
tg = TransformerGroup("EPSG:5705", "EPSG:5706", always_xy=True)
t = tg.transformers[0]
print(t.description)                  # "... (ballpark vertical transformation)"
print(t.to_proj4())                   # +proj=affine +s33=-1 ← geogoffset lost
print(t.transform(50.0, 40.0, 100))   # (50.0, 40.0, -100.0) ← wrong: should be -128.0

Should Be

PROJ chains the registered transformation with the axis conversion:

# ✅ 5705 → 5611 (height → height): uses registered EPSG:5438
print(t.to_proj4())                   # +proj=geogoffset +dh=28
print(t.transform(50.0, 40.0, 100))   # (50.0, 40.0, 128.0)

# ✅ 5705 → 5706 (height → depth): chains EPSG:5438 + axis conversion
print(t.description)                  # Baltic 1977 height to Caspian height (1)
                                       #   + Conversion from Caspian height to Caspian depth
print(t.to_proj4())                   # +proj=pipeline +step +proj=geogoffset +dh=28
                                       #   +step +proj=axisswap +order=1,2,-3
print(t.transform(50.0, 40.0, 100))   # (50.0, 40.0, -128.0)

Affected Scenario

Any vertical-to-vertical transformation where:

  1. No direct registered operation exists between the exact source/target CRS pair, but
  2. A registered operation exists to/from a variant vertical CRS that shares the same datum as the target (or source), differing in axis direction, units, or both.

Proposed Behaviour

When searching for vertical-to-vertical operations, if no direct result is found, PROJ should:

  1. Strategy 1 (pivot on target datum): Find candidate vertical CRSs sharing the target's datum. If a registered operation exists from the source to any candidate, compose it with the candidate→target axis/unit conversion.
  2. Strategy 2 (pivot on source datum): Find candidate vertical CRSs sharing the source's datum. If a registered operation exists from any candidate to the target, compose the source→candidate axis/unit conversion with that operation.

This mirrors the existing logic in createOperationsGeogToVertWithIntermediateVert (which handles geographic→vertical paths) but extends it to vertical→vertical paths.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions