|
| 1 | +"""DeprecationWarning tests for the vertical-CRS GeoKey attrs (PR7 of #1984). |
| 2 | +
|
| 3 | +The writer in ``xrspatial.geotiff._geotags.build_geo_tags`` does not emit |
| 4 | +the vertical GeoKey block, so ``attrs['vertical_crs']`` / |
| 5 | +``attrs['vertical_citation']`` / ``attrs['vertical_units']`` set on a |
| 6 | +DataArray are silently dropped on round-trip. The PR6 pass-through |
| 7 | +locking test (``test_attrs_contract_passthrough_1984.py``) pins that |
| 8 | +behaviour as "dropped". |
| 9 | +
|
| 10 | +PR7 deprecates these three read-side emissions for one release cycle |
| 11 | +before removal. Each test here builds a minimal TIFF carrying the |
| 12 | +relevant vertical GeoKey and asserts that ``open_geotiff`` raises a |
| 13 | +``DeprecationWarning`` whose message points at issue #1984. A final |
| 14 | +test confirms the attrs still appear on the returned DataArray during |
| 15 | +the deprecation window so existing consumers keep working. |
| 16 | +""" |
| 17 | +from __future__ import annotations |
| 18 | + |
| 19 | +import struct |
| 20 | +import warnings |
| 21 | + |
| 22 | +import pytest |
| 23 | + |
| 24 | +from xrspatial.geotiff import open_geotiff |
| 25 | +from xrspatial.geotiff.tests.conftest import make_minimal_tiff |
| 26 | + |
| 27 | + |
| 28 | +# GeoKey IDs from xrspatial.geotiff._geotags |
| 29 | +_GEOKEY_VERTICAL_CS_TYPE = 4096 |
| 30 | +_GEOKEY_VERTICAL_CITATION = 4097 |
| 31 | +_GEOKEY_VERTICAL_UNITS = 4099 |
| 32 | + |
| 33 | +# TIFF tag IDs |
| 34 | +_TAG_GEO_KEY_DIRECTORY = 34735 |
| 35 | +_TAG_GEO_ASCII_PARAMS = 34737 |
| 36 | + |
| 37 | + |
| 38 | +def _build_tiff_with_vertical_geokeys( |
| 39 | + *, |
| 40 | + vertical_epsg: int | None = None, |
| 41 | + vertical_citation: str | None = None, |
| 42 | + vertical_units_code: int | None = None, |
| 43 | +) -> bytes: |
| 44 | + """Build a minimal float32 TIFF with horizontal EPSG 4326 and optional |
| 45 | + vertical GeoKey entries. |
| 46 | +
|
| 47 | + Starts from ``make_minimal_tiff(epsg=4326)`` and rewrites the |
| 48 | + GeoKeyDirectory tag (34735) to append the requested vertical block. |
| 49 | + The format is the standard GeoTIFF GeoKey directory: |
| 50 | + ``(version, revision, minor, n_keys)`` header followed by ``n_keys`` |
| 51 | + quadruples of ``(key_id, location, count, value_or_offset)``. A |
| 52 | + ``location`` of 0 means the value is inline; ``location == 34737`` |
| 53 | + points into GeoAsciiParams. |
| 54 | + """ |
| 55 | + # Start with the minimal 4326 TIFF. |
| 56 | + base = make_minimal_tiff( |
| 57 | + 4, 4, |
| 58 | + geo_transform=(-120.0, 45.0, 0.001, -0.001), |
| 59 | + epsg=4326, |
| 60 | + ) |
| 61 | + |
| 62 | + # Find the GeoKeyDirectory tag (34735) in the IFD and replace it. |
| 63 | + # The IFD lives at offset 8 in the byte stream produced by |
| 64 | + # ``make_minimal_tiff``. We rebuild a fresh TIFF with the extra |
| 65 | + # vertical entries appended to the GeoKey directory instead of |
| 66 | + # surgically editing ``base``; this keeps the byte layout valid. |
| 67 | + # The simplest approach: build a new TIFF using a small ad-hoc |
| 68 | + # serializer that mirrors ``make_minimal_tiff`` but takes a |
| 69 | + # pre-built GeoKey directory and optional GeoAsciiParams payload. |
| 70 | + |
| 71 | + width = height = 4 |
| 72 | + import numpy as np |
| 73 | + |
| 74 | + pixel_data = np.zeros((height, width), dtype=np.float32) |
| 75 | + pixel_bytes = pixel_data.tobytes() |
| 76 | + |
| 77 | + # Build the GeoKey directory: start with the minimal 4326 keys |
| 78 | + # (model_type=2 geographic + GEOKEY_GEOGRAPHIC_CS_TYPE=2048 -> 4326), |
| 79 | + # then append any requested vertical entries. |
| 80 | + # Header: version=1, revision=1, minor=0, n_keys (filled later). |
| 81 | + gkd_entries: list[int] = [ |
| 82 | + 1024, 0, 1, 2, # GTModelType -> 2 (geographic) |
| 83 | + 2048, 0, 1, 4326, # GeographicTypeGeoKey -> 4326 |
| 84 | + ] |
| 85 | + |
| 86 | + geo_ascii_buf = bytearray() |
| 87 | + |
| 88 | + if vertical_epsg is not None: |
| 89 | + gkd_entries.extend([ |
| 90 | + _GEOKEY_VERTICAL_CS_TYPE, 0, 1, int(vertical_epsg), |
| 91 | + ]) |
| 92 | + |
| 93 | + if vertical_citation is not None: |
| 94 | + s = vertical_citation + '|' # GeoTIFF citation strings end with '|' |
| 95 | + ascii_offset = len(geo_ascii_buf) |
| 96 | + geo_ascii_buf.extend(s.encode('ascii')) |
| 97 | + gkd_entries.extend([ |
| 98 | + _GEOKEY_VERTICAL_CITATION, |
| 99 | + _TAG_GEO_ASCII_PARAMS, |
| 100 | + len(s), |
| 101 | + ascii_offset, |
| 102 | + ]) |
| 103 | + |
| 104 | + if vertical_units_code is not None: |
| 105 | + gkd_entries.extend([ |
| 106 | + _GEOKEY_VERTICAL_UNITS, 0, 1, int(vertical_units_code), |
| 107 | + ]) |
| 108 | + |
| 109 | + n_keys = len(gkd_entries) // 4 |
| 110 | + # Prepend header |
| 111 | + gkd = [1, 1, 0, n_keys] + gkd_entries |
| 112 | + |
| 113 | + # Now serialize a minimal TIFF carrying the standard image tags and |
| 114 | + # this enriched GeoKey directory plus a GeoAsciiParams tag when |
| 115 | + # populated. Reuses the layout strategy of ``make_minimal_tiff``. |
| 116 | + bo = '<' |
| 117 | + |
| 118 | + tag_list: list[tuple[int, int, int, bytes]] = [] |
| 119 | + |
| 120 | + def add_short(tag, val): |
| 121 | + tag_list.append((tag, 3, 1, struct.pack(f'{bo}H', val))) |
| 122 | + |
| 123 | + def add_long(tag, val): |
| 124 | + tag_list.append((tag, 4, 1, struct.pack(f'{bo}I', val))) |
| 125 | + |
| 126 | + def add_shorts(tag, vals): |
| 127 | + tag_list.append((tag, 3, len(vals), struct.pack(f'{bo}{len(vals)}H', *vals))) |
| 128 | + |
| 129 | + def add_doubles(tag, vals): |
| 130 | + tag_list.append((tag, 12, len(vals), struct.pack(f'{bo}{len(vals)}d', *vals))) |
| 131 | + |
| 132 | + def add_ascii(tag, raw_bytes: bytes): |
| 133 | + # ASCII type, count includes the terminating NUL. |
| 134 | + if not raw_bytes.endswith(b'\x00'): |
| 135 | + raw_bytes = raw_bytes + b'\x00' |
| 136 | + tag_list.append((tag, 2, len(raw_bytes), raw_bytes)) |
| 137 | + |
| 138 | + add_short(256, width) |
| 139 | + add_short(257, height) |
| 140 | + add_short(258, 32) # BitsPerSample |
| 141 | + add_short(259, 1) # Compression none |
| 142 | + add_short(262, 1) # Photometric BlackIsZero |
| 143 | + add_short(277, 1) # SamplesPerPixel |
| 144 | + add_short(339, 3) # SampleFormat IEEE float |
| 145 | + add_short(278, height) # RowsPerStrip |
| 146 | + add_long(273, 0) # StripOffsets placeholder |
| 147 | + add_long(279, len(pixel_bytes)) # StripByteCounts |
| 148 | + add_doubles(33550, [0.001, 0.001, 0.0]) |
| 149 | + add_doubles(33922, [0.0, 0.0, 0.0, -120.0, 45.0, 0.0]) |
| 150 | + add_shorts(_TAG_GEO_KEY_DIRECTORY, gkd) |
| 151 | + if geo_ascii_buf: |
| 152 | + add_ascii(_TAG_GEO_ASCII_PARAMS, bytes(geo_ascii_buf)) |
| 153 | + |
| 154 | + tag_list.sort(key=lambda t: t[0]) |
| 155 | + |
| 156 | + num_entries = len(tag_list) |
| 157 | + ifd_start = 8 |
| 158 | + ifd_size = 2 + 12 * num_entries + 4 |
| 159 | + overflow_start = ifd_start + ifd_size |
| 160 | + |
| 161 | + overflow_buf = bytearray() |
| 162 | + tag_offsets: dict[int, int | None] = {} |
| 163 | + for tag, _typ, _count, raw in tag_list: |
| 164 | + if len(raw) > 4: |
| 165 | + tag_offsets[tag] = len(overflow_buf) |
| 166 | + overflow_buf.extend(raw) |
| 167 | + if len(overflow_buf) % 2: |
| 168 | + overflow_buf.append(0) |
| 169 | + else: |
| 170 | + tag_offsets[tag] = None |
| 171 | + |
| 172 | + pixel_data_start = overflow_start + len(overflow_buf) |
| 173 | + |
| 174 | + # Patch StripOffsets now that we know where pixel data lives. |
| 175 | + patched = [] |
| 176 | + for tag, typ, count, raw in tag_list: |
| 177 | + if tag == 273: |
| 178 | + patched.append((tag, typ, count, struct.pack(f'{bo}I', pixel_data_start))) |
| 179 | + else: |
| 180 | + patched.append((tag, typ, count, raw)) |
| 181 | + tag_list = patched |
| 182 | + |
| 183 | + out = bytearray() |
| 184 | + out.extend(b'II') |
| 185 | + out.extend(struct.pack(f'{bo}H', 42)) |
| 186 | + out.extend(struct.pack(f'{bo}I', ifd_start)) |
| 187 | + out.extend(struct.pack(f'{bo}H', num_entries)) |
| 188 | + for tag, typ, count, raw in tag_list: |
| 189 | + out.extend(struct.pack(f'{bo}HHI', tag, typ, count)) |
| 190 | + if len(raw) <= 4: |
| 191 | + out.extend(raw.ljust(4, b'\x00')) |
| 192 | + else: |
| 193 | + ptr = overflow_start + tag_offsets[tag] |
| 194 | + out.extend(struct.pack(f'{bo}I', ptr)) |
| 195 | + out.extend(struct.pack(f'{bo}I', 0)) |
| 196 | + out.extend(overflow_buf) |
| 197 | + out.extend(pixel_bytes) |
| 198 | + |
| 199 | + # Silence the unused-import lint for ``base`` -- we deliberately |
| 200 | + # rebuild rather than patch. |
| 201 | + _ = base |
| 202 | + return bytes(out) |
| 203 | + |
| 204 | + |
| 205 | +def _write(tmp_path, blob: bytes, name: str) -> str: |
| 206 | + path = tmp_path / name |
| 207 | + path.write_bytes(blob) |
| 208 | + return str(path) |
| 209 | + |
| 210 | + |
| 211 | +def test_vertical_crs_emits_deprecation_warning(tmp_path): |
| 212 | + """Reading a TIFF with VerticalCSTypeGeoKey warns about |
| 213 | + ``attrs['vertical_crs']`` deprecation.""" |
| 214 | + blob = _build_tiff_with_vertical_geokeys(vertical_epsg=5703) |
| 215 | + path = _write(tmp_path, blob, 'vertical_crs.tif') |
| 216 | + |
| 217 | + with pytest.warns(DeprecationWarning, match=r"vertical_crs.*#1984"): |
| 218 | + open_geotiff(path) |
| 219 | + |
| 220 | + |
| 221 | +def test_vertical_citation_emits_deprecation_warning(tmp_path): |
| 222 | + """Reading a TIFF with VerticalCitationGeoKey warns about |
| 223 | + ``attrs['vertical_citation']`` deprecation.""" |
| 224 | + blob = _build_tiff_with_vertical_geokeys(vertical_citation='NAVD88') |
| 225 | + path = _write(tmp_path, blob, 'vertical_citation.tif') |
| 226 | + |
| 227 | + with pytest.warns(DeprecationWarning, match=r"vertical_citation.*#1984"): |
| 228 | + open_geotiff(path) |
| 229 | + |
| 230 | + |
| 231 | +def test_vertical_units_emits_deprecation_warning(tmp_path): |
| 232 | + """Reading a TIFF with VerticalUnitsGeoKey warns about |
| 233 | + ``attrs['vertical_units']`` deprecation.""" |
| 234 | + # 9001 is the GeoTIFF code for linear metre, mapped to 'metre' on read. |
| 235 | + blob = _build_tiff_with_vertical_geokeys(vertical_units_code=9001) |
| 236 | + path = _write(tmp_path, blob, 'vertical_units.tif') |
| 237 | + |
| 238 | + with pytest.warns(DeprecationWarning, match=r"vertical_units.*#1984"): |
| 239 | + open_geotiff(path) |
| 240 | + |
| 241 | + |
| 242 | +def test_vertical_attrs_still_emit_during_deprecation(tmp_path): |
| 243 | + """Deprecation does not yet remove the attrs: a read of a TIFF with |
| 244 | + all three vertical GeoKeys still surfaces all three keys on |
| 245 | + ``DataArray.attrs``.""" |
| 246 | + blob = _build_tiff_with_vertical_geokeys( |
| 247 | + vertical_epsg=5703, |
| 248 | + vertical_citation='NAVD88', |
| 249 | + vertical_units_code=9001, |
| 250 | + ) |
| 251 | + path = _write(tmp_path, blob, 'vertical_all.tif') |
| 252 | + |
| 253 | + with warnings.catch_warnings(): |
| 254 | + # The whole point of this test is that the attrs still emit; |
| 255 | + # the per-attr warning is asserted in the three tests above, so |
| 256 | + # silence it here to keep this test focused on the attr values. |
| 257 | + warnings.simplefilter('ignore', DeprecationWarning) |
| 258 | + da = open_geotiff(path) |
| 259 | + |
| 260 | + assert da.attrs.get('vertical_crs') == 5703 |
| 261 | + assert da.attrs.get('vertical_citation') == 'NAVD88' |
| 262 | + assert da.attrs.get('vertical_units') == 'metre' |
0 commit comments