diff --git a/CHANGELOG.md b/CHANGELOG.md index ac6b0c7..d700989 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * **Reworked NOAA VDatum processing:** each regional coverage package is completed through its native geodetic chain, including modern IGS/xGEOID paths, before normalized coverages are mosaiced according to coverage priority. * **Physical coastal context:** resolution-independent inland decay based on Dist2Coast distances, with VDatum-aware estuary/river coverage, inland buffers, configurable shoreline extension, and optional unrestricted extrapolation for hydrodynamic modeling. * **Reference and system inspection:** expanded CLI commands for inspecting references, providers, transformation plans, external engines, and runtime configuration. +* `vdatum:mtl` (mean tide level) and `vdatum:dtl` (diurnal tide level) references. +* Common vertical datum names (e.g. `navd88`, `egm2008`, `egm96`, `prvd02`, `vivd09`, `cgvd2013`, `mean lower low water`) are accepted by `parse_reference()`. ### Changed @@ -43,6 +45,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Fixed Dist2Coast processing that could introduce edge artifacts or incorrectly discard zero-valued coastline cells as nodata. * Fixed HTDP latitude/longitude handling and validated frame transformations against the configured HTDP engine version. * Fixed Dist2Coast fetch failure. Now raises MissingGridException rather than returning None. +* Fixed VDatum model grids (e.g. xGEOID20B) being extracted into the current working directory instead of the cache directory. +* `InvalidReferenceError` and `UnsupportedReferenceError` now derive from `ReferenceInputError` (a `ValueError`) instead of Python's built-in `ReferenceError`. ### Deprecated diff --git a/src/transformez/reference/bindings.py b/src/transformez/reference/bindings.py index 65e585c..052e4cc 100644 --- a/src/transformez/reference/bindings.py +++ b/src/transformez/reference/bindings.py @@ -83,6 +83,22 @@ class HtdpFrameBinding: unit_name="metre", unit_to_metre=1.0, ), + "vdatum:mtl": VerticalReference( + id="vdatum:mtl", + name="NOAA VDatum Mean Tide Level", + kind=VerticalKind.TIDAL_HEIGHT, + axis_direction=AxisDirection.UP, + unit_name="metre", + unit_to_metre=1.0, + ), + "vdatum:dtl": VerticalReference( + id="vdatum:dtl", + name="NOAA VDatum Diurnal Tide Level", + kind=VerticalKind.TIDAL_HEIGHT, + axis_direction=AxisDirection.UP, + unit_name="metre", + unit_to_metre=1.0, + ), "vdatum:xgeoid17b": VerticalReference( id="vdatum:xgeoid17b", name="NGS X-GEOID 17b", @@ -213,6 +229,24 @@ class HtdpFrameBinding: default_model="geoid:g2018", global_proxy="global:hat", ), + "vdatum:mtl": OperationBinding( + reference_id="vdatum:mtl", + engine="vdatum_grid", + provider="vdatum", + provider_datum="mtl", + native_frame="EPSG:6319", + default_model="geoid:g2018", + global_proxy="global:mss", + ), + "vdatum:dtl": OperationBinding( + reference_id="vdatum:dtl", + engine="vdatum_grid", + provider="vdatum", + provider_datum="dtl", + native_frame="EPSG:6319", + default_model="geoid:g2018", + global_proxy="global:mss", + ), "vdatum:xgeoid17b": OperationBinding( reference_id="vdatum:xgeoid17b", engine="vdatum_grid", diff --git a/src/transformez/reference/fetcher.py b/src/transformez/reference/fetcher.py index a960953..5f7ba1b 100644 --- a/src/transformez/reference/fetcher.py +++ b/src/transformez/reference/fetcher.py @@ -502,7 +502,9 @@ def _fetch_vdatum_model_grid( ) continue - for grid_path in p_f_extract(archive_path, members=[archive_member]): + for grid_path in p_f_extract( + archive_path, outdir=self.cache_dir, members=[archive_member] + ): if grid_path.exists() and grid_path.suffix.casefold() == ".gtx": grid_paths.append(grid_path) diff --git a/src/transformez/reference/parser.py b/src/transformez/reference/parser.py index 4b60239..268a01d 100644 --- a/src/transformez/reference/parser.py +++ b/src/transformez/reference/parser.py @@ -43,16 +43,46 @@ } +# Common short names for vertical references that PROJ does not recognise on its +# own. Keys are casefolded with internal whitespace collapsed. Names PROJ already +# resolves (e.g. "NAVD88 height", "WGS84") are deliberately left to PROJ. +COMMON_NAMES = { + # NAVD88 + "navd88": "epsg:5703", + "navd 88": "epsg:5703", + "navd-88": "epsg:5703", + "north american vertical datum 1988": "epsg:5703", + # Tidal datums spelled out + "mean lower low water": "vdatum:mllw", + "mean low water": "vdatum:mlw", + "mean high water": "vdatum:mhw", + "mean sea level": "vdatum:msl", + # US territories + "prvd02": "epsg:6641", + "vivd09": "epsg:6642", + "asvd02": "epsg:6643", + # Canada + "cgvd2013": "epsg:6647", + "cgvd2013(cgg2013)": "epsg:6647", + # Global geoid models + "egm2008": "epsg:3855", + "egm 2008": "epsg:3855", + "egm08": "epsg:3855", + "egm96": "epsg:5773", + "egm 96": "epsg:5773", +} + + class ReferenceInputError(ValueError): """Base error for invalid or unsupported reference input.""" -class InvalidReferenceError(ReferenceError): - pass +class InvalidReferenceError(ReferenceInputError): + """The reference could not be parsed or is not recognised.""" -class UnsupportedReferenceError(ReferenceError): - pass +class UnsupportedReferenceError(ReferenceInputError): + """The reference is valid but cannot be used by transformez.""" def warn_legacy_alias(old: str, new: str): @@ -267,6 +297,11 @@ def parse_reference(value: ReferenceInput) -> ParsedReference: warn_legacy_alias(text, alias) return parse_reference(alias) + # Common short names (navd88, egm2008, ...) + common = COMMON_NAMES.get(" ".join(text.casefold().split())) + if common is not None: + return parse_reference(common) + # Transformez custom namespaces prefix = text.partition(":")[0].casefold() if prefix in CUSTOM_REFERENCE_PREFIXES: diff --git a/tests/test_fetcher.py b/tests/test_fetcher.py index 5327851..73faa25 100644 --- a/tests/test_fetcher.py +++ b/tests/test_fetcher.py @@ -144,6 +144,6 @@ def fake_extract(src, **kwargs): assert calls == [ ( archive, - {"members": ["CONUSPAC.gtx"]}, + {"outdir": tmp_path, "members": ["CONUSPAC.gtx"]}, ) ] diff --git a/tests/test_reference.py b/tests/test_reference.py index e3d3a74..8e2b970 100644 --- a/tests/test_reference.py +++ b/tests/test_reference.py @@ -5,6 +5,7 @@ from transformez.reference.parser import ( parse_reference, InvalidReferenceError, + ReferenceInputError, UnsupportedReferenceError, ) from transformez.reference.types import ParsedReference, VerticalKind, AxisDirection @@ -131,3 +132,66 @@ def test_parse_vertical_crs(): assert ref.horizontal is None assert ref.vertical is not None assert ref.vertical.kind is VerticalKind.GRAVITY_RELATED_HEIGHT + + +@pytest.mark.parametrize( + "name, epsg", + [ + ("navd88", 5703), + ("NAVD 88", 5703), + ("egm2008", 3855), + ("EGM96", 5773), + ("prvd02", 6641), + ("vivd09", 6642), + ("asvd02", 6643), + ("cgvd2013", 6647), + ("North American Vertical Datum 1988", 5703), + ], +) +def test_common_names(name, epsg): + """Ensure common short names resolve to their EPSG vertical references.""" + + ref = parse_reference(name) + assert ref.horizontal is None + assert ref.vertical.id == f"epsg:{epsg}" + + +@pytest.mark.parametrize( + "name, ref_id", + [ + ("mean lower low water", "vdatum:mllw"), + ("Mean Low Water", "vdatum:mlw"), + ("mean high water", "vdatum:mhw"), + ("mean sea level", "vdatum:msl"), + ], +) +def test_common_tidal_names(name, ref_id): + """Ensure spelled-out tidal datum names resolve to VDatum references.""" + + assert parse_reference(name).vertical.id == ref_id + + +def test_common_names_leave_proj_names_alone(): + """Names PROJ already knows must keep resolving through PROJ.""" + + assert parse_reference("wgs84").horizontal.to_epsg() == 4326 + assert parse_reference("NAVD88 height").vertical.id == "epsg:5703" + + +@pytest.mark.parametrize("name", ["vdatum:mtl", "vdatum:dtl"]) +def test_mtl_dtl_references(name): + ref = parse_reference(name) + assert ref.vertical.id == name + assert ref.vertical.kind == VerticalKind.TIDAL_HEIGHT + + +def test_reference_errors_share_a_value_error_base(): + """All reference errors can be caught together, including as ValueError.""" + + for exc in (InvalidReferenceError, UnsupportedReferenceError): + assert issubclass(exc, ReferenceInputError) + assert issubclass(exc, ValueError) + assert not issubclass(exc, ReferenceError) + + with pytest.raises(ValueError): + parse_reference("not_a_datum:12345")