transformez 0.5.2__tar.gz → 0.5.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. {transformez-0.5.2 → transformez-0.5.3}/CITATION.cff +2 -2
  2. {transformez-0.5.2 → transformez-0.5.3}/PKG-INFO +2 -10
  3. {transformez-0.5.2 → transformez-0.5.3}/README.md +1 -9
  4. {transformez-0.5.2 → transformez-0.5.3}/pyproject.toml +2 -2
  5. {transformez-0.5.2 → transformez-0.5.3}/src/transformez/_version.py +1 -1
  6. {transformez-0.5.2 → transformez-0.5.3}/src/transformez/api.py +124 -0
  7. {transformez-0.5.2 → transformez-0.5.3}/src/transformez/cli.py +67 -4
  8. {transformez-0.5.2 → transformez-0.5.3}/src/transformez/definitions.py +7 -6
  9. {transformez-0.5.2 → transformez-0.5.3}/src/transformez/grid_engine.py +47 -1
  10. {transformez-0.5.2 → transformez-0.5.3}/src/transformez/htdp.py +57 -31
  11. {transformez-0.5.2 → transformez-0.5.3}/src/transformez/srs.py +7 -11
  12. {transformez-0.5.2 → transformez-0.5.3}/src/transformez/transform.py +59 -10
  13. {transformez-0.5.2 → transformez-0.5.3}/AUTHORS.md +0 -0
  14. {transformez-0.5.2 → transformez-0.5.3}/CHANGELOG.md +0 -0
  15. {transformez-0.5.2 → transformez-0.5.3}/CONTRIBUTING.md +0 -0
  16. {transformez-0.5.2 → transformez-0.5.3}/LICENSE +0 -0
  17. {transformez-0.5.2 → transformez-0.5.3}/src/transformez/__init__.py +0 -0
  18. {transformez-0.5.2 → transformez-0.5.3}/src/transformez/_spatial.py +0 -0
  19. {transformez-0.5.2 → transformez-0.5.3}/src/transformez/hooks.py +0 -0
  20. {transformez-0.5.2 → transformez-0.5.3}/src/transformez/modules.py +0 -0
  21. {transformez-0.5.2 → transformez-0.5.3}/src/transformez/utils.py +0 -0
  22. {transformez-0.5.2 → transformez-0.5.3}/src/transformez/vdatum.py +0 -0
  23. {transformez-0.5.2 → transformez-0.5.3}/tests/test_cli.py +0 -0
@@ -14,6 +14,6 @@ authors:
14
14
 
15
15
  website: https://transformez.readthedocs.io
16
16
  title: "Transformez"
17
- version: 0.5.0
18
- date-released: 2026-06-06
17
+ version: 0.5.3
18
+ date-released: 2026-07-22
19
19
  url: "https://github.com/continuous-dems/transformez"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: transformez
3
- Version: 0.5.2
3
+ Version: 0.5.3
4
4
  Summary: A standalone utility for vertical elevation datum transformations.
5
5
  Project-URL: Homepage, https://github.com/continuous-dems/transformez
6
6
  Project-URL: Issues, https://github.com/continuous-dems/transformez/issues
@@ -57,7 +57,7 @@ Description-Content-Type: text/markdown
57
57
  <p align="center"><strong>Global vertical datum transformations, simplified.</strong></p>
58
58
 
59
59
  <p align="center">
60
- <a href="https://github.com/continuous-dems/transformez"><img src="https://img.shields.io/badge/version-0.5.1-blue.svg" alt="Version"></a>
60
+ <a href="https://github.com/continuous-dems/transformez"><img src="https://img.shields.io/badge/version-0.5.3-blue.svg" alt="Version"></a>
61
61
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green.svg" alt="License"></a>
62
62
  <a href="https://www.python.org/"><img src="https://img.shields.io/badge/python-3.12+-yellow.svg" alt="Python"></a>
63
63
  <a href="https://badge.fury.io/py/transformez"><img src="https://badge.fury.io/py/transformez.svg" alt="PyPI version"></a>
@@ -71,14 +71,6 @@ Originally developed as the core transformation engine for the [CUDEM](https://g
71
71
 
72
72
  ---
73
73
 
74
- ## ❓ Why Transformez?
75
-
76
- Vertical Datum transformations can cause a lot of confusion and headache when trying to process heterogeneous data from around the world into a single unified vertial reference.
77
-
78
- **Transformez simplifies this process!**
79
-
80
- ---
81
-
82
74
  ## 📦 Installation
83
75
 
84
76
  **Install Transformez**
@@ -7,7 +7,7 @@
7
7
  <p align="center"><strong>Global vertical datum transformations, simplified.</strong></p>
8
8
 
9
9
  <p align="center">
10
- <a href="https://github.com/continuous-dems/transformez"><img src="https://img.shields.io/badge/version-0.5.1-blue.svg" alt="Version"></a>
10
+ <a href="https://github.com/continuous-dems/transformez"><img src="https://img.shields.io/badge/version-0.5.3-blue.svg" alt="Version"></a>
11
11
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green.svg" alt="License"></a>
12
12
  <a href="https://www.python.org/"><img src="https://img.shields.io/badge/python-3.12+-yellow.svg" alt="Python"></a>
13
13
  <a href="https://badge.fury.io/py/transformez"><img src="https://badge.fury.io/py/transformez.svg" alt="PyPI version"></a>
@@ -21,14 +21,6 @@ Originally developed as the core transformation engine for the [CUDEM](https://g
21
21
 
22
22
  ---
23
23
 
24
- ## ❓ Why Transformez?
25
-
26
- Vertical Datum transformations can cause a lot of confusion and headache when trying to process heterogeneous data from around the world into a single unified vertial reference.
27
-
28
- **Transformez simplifies this process!**
29
-
30
- ---
31
-
32
24
  ## 📦 Installation
33
25
 
34
26
  **Install Transformez**
@@ -51,9 +51,9 @@ test = [
51
51
  "pytest-cov",
52
52
  ]
53
53
  docs = [
54
- "sphinx",
54
+ "sphinx<9.0.0", # temp pin for sphinx-click
55
55
  "sphinx-autodoc-typehints>=2.0.1",
56
- "sphinx-argparse-cli",
56
+ "sphinx-click>=6.0",
57
57
  "pydata-sphinx-theme",
58
58
  "myst-parser",
59
59
  ]
@@ -1,4 +1,4 @@
1
1
  # This file is auto-generated by Hatchling. As such, do not:
2
2
  # - modify
3
3
  # - track in version control e.g. be sure to add to .gitignore
4
- __version__ = VERSION = '0.5.2'
4
+ __version__ = VERSION = '0.5.3'
@@ -359,3 +359,127 @@ def transform_raster(
359
359
  if success:
360
360
  return output_raster
361
361
  return None
362
+
363
+
364
+ def prefetch_region(
365
+ region: Union[List[float], str, Region],
366
+ datum_in: Optional[str] = None,
367
+ datum_out: Optional[str] = None,
368
+ fetch_all: bool = False,
369
+ cache_dir: Optional[str] = None,
370
+ verbose: bool = True,
371
+ ) -> bool:
372
+ """Pre-download transformation grids and reference datasets for offline field use.
373
+
374
+ Args:
375
+ region: Bounds as [W, E, S, N], a 'loc:' string, or a Region object.
376
+ datum_in: Source datum string (e.g. 'mllw') to limit fetching to a specific chain.
377
+ datum_out: Target datum string (e.g. '5703') to limit fetching to a specific chain.
378
+ fetch_all: If True, fetches ALL available geoids, tidal surfaces, and coastlines for the region.
379
+ cache_dir: Directory where downloaded assets will be cached.
380
+ verbose: Enable detailed logging.
381
+
382
+ Returns:
383
+ bool: True if prefetching succeeded, False otherwise.
384
+ """
385
+
386
+ if isinstance(region, Region):
387
+ region_obj = region
388
+ else:
389
+ regions = parse_region(region)
390
+ if not regions:
391
+ logger.error(f"Could not parse region: {region}")
392
+ return False
393
+ region_obj = regions[0]
394
+
395
+ logger.info(f"Initiating offline prefetch for region: {region_obj}")
396
+
397
+ # Minimal dimensions (10x10) to avoid allocating memory for large arrays
398
+ vt_nx, vt_ny = 10, 10
399
+
400
+ try:
401
+ if fetch_all or (not datum_in and not datum_out):
402
+ logger.info(
403
+ "Mode: FULL PREFETCH. Downloading all geoids, VDatum grids, and coastlines..."
404
+ )
405
+
406
+ # Instantiate base engine to leverage internal fetchers
407
+ vt = VerticalTransform(
408
+ region=region_obj,
409
+ nx=vt_nx,
410
+ ny=vt_ny,
411
+ epsg_in=4979, # Base WGS84
412
+ epsg_out=6319, # Base NAD83
413
+ cache_dir=cache_dir,
414
+ verbose=verbose,
415
+ )
416
+
417
+ # Vector Coastline Tiles (GSHHG / CUSP)
418
+ logger.info(" -> [1/5] Fetching coastline vector tiles...")
419
+ vt._fetch_coastline_shapefiles()
420
+
421
+ # All Registered Geoids
422
+ logger.info(" -> [2/5] Fetching Geoid grids...")
423
+ for g_name, g_def in Datums.GEOIDS.items():
424
+ provider = g_def.get("provider", "proj")
425
+ logger.info(f" - Fetching Geoid: {g_name} ({provider})")
426
+ try:
427
+ vt.fetch_grid(provider, datatype=g_name, query=g_name)
428
+ except Exception as e:
429
+ logger.warning(f" - Skipping '{g_name}': {e}")
430
+
431
+ # USA VDatum Tidal Grids
432
+ logger.info(" -> [3/5] Fetching VDatum regional grids...")
433
+ for s_key, s_def in Datums.SURFACES.items():
434
+ s_name = s_def.get("name", s_key)
435
+ if s_def.get("region") == "usa":
436
+ logger.info(f" - Fetching VDatum Surface: {s_name}")
437
+ try:
438
+ vt.fetch_grid("vdatum", datatype=s_name, query=s_name)
439
+ except Exception as e:
440
+ logger.warning(f" - Skipping VDatum '{s_name}': {e}")
441
+
442
+ # Topography of the Sea Surface (TSS)
443
+ logger.info(" -> [4/5] Fetching VDatum TSS grid...")
444
+ try:
445
+ vt.fetch_grid("vdatum", datatype="tss", query="tss")
446
+ except Exception as e:
447
+ logger.warning(f" - Skipping TSS: {e}")
448
+
449
+ # Global Satellite Models (FES / SEANOE)
450
+ logger.info(" -> [5/5] Fetching Global FES / MSS proxy grids...")
451
+ for proxy_name in ["lat", "msl", "mss"]:
452
+ logger.info(f" - Fetching Global Proxy: {proxy_name}")
453
+ try:
454
+ vt.fetch_grid("fes", datatype=proxy_name, query=proxy_name)
455
+ except Exception as e:
456
+ logger.warning(f" - Skipping Global '{proxy_name}': {e}")
457
+
458
+ else:
459
+ epsg_in, geoid_in = _parse_datum(datum_in) if datum_in else (4979, None)
460
+ epsg_out, geoid_out = _parse_datum(datum_out) if datum_out else (6319, None)
461
+
462
+ logger.info(
463
+ f"Mode: TARGETED PREFETCH for chain ({datum_in or 'WGS84'} ➔ {datum_out or 'NAD83'})..."
464
+ )
465
+
466
+ vt = VerticalTransform(
467
+ region=region_obj,
468
+ nx=vt_nx,
469
+ ny=vt_ny,
470
+ epsg_in=epsg_in or 4979,
471
+ epsg_out=epsg_out or 6319,
472
+ geoid_in=geoid_in,
473
+ geoid_out=geoid_out,
474
+ cache_dir=cache_dir,
475
+ verbose=verbose,
476
+ )
477
+
478
+ vt._vertical_transform(vt.epsg_in, vt.epsg_out)
479
+
480
+ logger.info("Successfully populated offline cache!")
481
+ return True
482
+
483
+ except Exception as e:
484
+ logger.error(f"Prefetch failed: {e}")
485
+ return False
@@ -20,8 +20,8 @@ from fetchez.cli import setup_logging
20
20
  from transformez import api
21
21
 
22
22
  TRANSFORMEZ_COMMANDS = {
23
- "Execution": ["run", "grid", "raster"],
24
- "Discovery": ["list"],
23
+ "Execution": ["grid", "raster"],
24
+ "Discovery": ["list", "prefetch"],
25
25
  "External": ["htdp", "vdatum"],
26
26
  }
27
27
 
@@ -401,7 +401,9 @@ def transform_list():
401
401
 
402
402
 
403
403
  # --- HTDP CLI GROUP ---
404
- @transformez_cli.group(cls=FetchezMainGroup, name="htdp", fetchez_commands=["install"])
404
+ @transformez_cli.group(
405
+ cls=FetchezMainGroup, name="htdp", fetchez_commands=["install", "run"]
406
+ )
405
407
  def htdp_group():
406
408
  """Manage and run NGS HTDP (Horizontal Time-Dependent Positioning)."""
407
409
 
@@ -422,9 +424,19 @@ def install_htdp(version):
422
424
  install_htdp_binary(version=version)
423
425
 
424
426
 
427
+ @htdp_group.command("run", cls=FetchezMainCommand)
428
+ @click.option("--control", help="input control file, if omitted, run interactively")
429
+ def run_htpd(control):
430
+ """Run HTDP from transformez"""
431
+
432
+ from transformez.htdp import HTDP
433
+
434
+ HTDP.run_cmd(control)
435
+
436
+
425
437
  # --- VDATUM CLI GROUP ---
426
438
  @transformez_cli.group(
427
- cls=FetchezMainGroup, name="vdatum", fetchez_commands=["install", "run"]
439
+ cls=FetchezMainGroup, name="vdatum", fetchez_commands=["install"]
428
440
  )
429
441
  def vdatum_group():
430
442
  """Manage and run the NOAA VDatum Java engine."""
@@ -480,5 +492,56 @@ def vdatum_list():
480
492
  click.echo(vd)
481
493
 
482
494
 
495
+ # --- PREFETCH CLI GROUP ---
496
+ @transformez_cli.command("prefetch", cls=FetchezMainCommand)
497
+ @click.option("-R", "--region", required=True, help="Bounding box or location string.")
498
+ @click.option(
499
+ "-I",
500
+ "--input-datum",
501
+ help="Source Datum (Optional: limits fetch to specific chain).",
502
+ )
503
+ @click.option(
504
+ "-O",
505
+ "--output-datum",
506
+ help="Target Datum (Optional: limits fetch to specific chain).",
507
+ )
508
+ @click.option(
509
+ "--all",
510
+ "fetch_all",
511
+ is_flag=True,
512
+ help="Fetch ALL available geoids, tidal models, and coastlines for this region.",
513
+ )
514
+ def transform_prefetch(region, input_datum, output_datum, fetch_all):
515
+ """Pre-download transformation grids and reference data for offline field use.
516
+
517
+ Examples:\n
518
+ Prefetch a specific conversion : transformez prefetch -R loc:"Newport, OR" -I mllw -O 5703
519
+ Prefetch EVERYTHING for a region: transformez prefetch -R loc:"Miami, FL" --all
520
+ Prefetch the entire planet : transformez prefetch -R -180/180/-90/90 --all
521
+ """
522
+
523
+ click.secho(
524
+ f"Populating offline cache for region: {region}...", fg="cyan", bold=True
525
+ )
526
+
527
+ result = api.prefetch_region(
528
+ region=region,
529
+ datum_in=input_datum,
530
+ datum_out=output_datum,
531
+ fetch_all=fetch_all,
532
+ verbose=True,
533
+ )
534
+
535
+ if result:
536
+ click.secho(
537
+ f"Successfully pre-cached all assets for {region}. Ready for offline use!",
538
+ fg="green",
539
+ bold=True,
540
+ )
541
+ else:
542
+ click.secho("Prefetch encountered errors. Check logs for details.", fg="red")
543
+ sys.exit(1)
544
+
545
+
483
546
  if __name__ == "__main__":
484
547
  transformez_cli()
@@ -12,6 +12,7 @@ and definitions.
12
12
  :license: MIT, see LICENSE for more details.
13
13
  """
14
14
 
15
+ from typing import Any, Dict
15
16
  import logging
16
17
 
17
18
  logger = logging.getLogger(__name__)
@@ -42,7 +43,7 @@ class Datums:
42
43
  # =========================================================================
43
44
  # Vertical Datum References
44
45
  # =========================================================================
45
- SURFACES = {
46
+ SURFACES: Dict[int, Dict[str, Any]] = {
46
47
  # --- Tidal Datums ---
47
48
  1089: {"name": "mllw", "description": "Mean Lower Low Water", "region": "usa"},
48
49
  5866: {"name": "mllw", "description": "Mean Lower Low Water", "region": "usa"},
@@ -104,7 +105,7 @@ class Datums:
104
105
  9003: {"name": "mss", "description": "Mean Sea Surface", "region": "global"},
105
106
  }
106
107
 
107
- GLOBAL_ALIASES = {
108
+ GLOBAL_ALIASES: Dict[str, Any] = {
108
109
  "mllw": "lat", # Mean Lower Low Water -> Lowest Astronomical Tide
109
110
  "mlw": "lat", # Mean Low Water -> LAT (Conservative)
110
111
  "mhhw": "hat", # Mean Higher High Water -> Highest Astronomical Tide
@@ -114,7 +115,7 @@ class Datums:
114
115
  "dtl": "mss", # Diurnal Tide Level -> MSS
115
116
  }
116
117
 
117
- HTDP = {
118
+ HTDP: Dict[int, Dict[str, Any]] = {
118
119
  4269: {
119
120
  "name": "NAD_83(2011/CORS96/2007)",
120
121
  "description": "(North American plate fixed)",
@@ -362,7 +363,7 @@ class Datums:
362
363
  },
363
364
  }
364
365
 
365
- CDN = {
366
+ CDN: Dict[int, Dict[str, Any]] = {
366
367
  # CONUS / Alaska / Hawaii / PR / VI
367
368
  5703: {
368
369
  "name": "NAVD88 height",
@@ -415,7 +416,7 @@ class Datums:
415
416
  # 4979: {'name': 'WGS84', 'vdatum_id': 'wgs84:m:height'},
416
417
  }
417
418
 
418
- GEOIDS = {
419
+ GEOIDS: Dict[str, Dict[str, Any]] = {
419
420
  # Standard PROJ-CDN Geoids (Default provider is 'proj')
420
421
  "g2018": {"name": "geoid 2018", "uncertainty": 0.0127, "provider": "proj"},
421
422
  "g2012b": {"name": "geoid 2012b", "uncertainty": 0.017, "provider": "proj"},
@@ -428,7 +429,7 @@ class Datums:
428
429
  "CGG2013": {"name": "CGG2013", "uncertainty": 0.01, "provider": "proj"},
429
430
  }
430
431
 
431
- MODELS = {
432
+ MODELS: Dict[str, Dict[str, Any]] = {
432
433
  "fes2014": {"provider": "seanoe", "grids": {"lat": "LAT", "mss": "MSL"}},
433
434
  "dtu10": {"provider": "dtu", "grids": {"mss": "mss", "mdt": "mdt"}},
434
435
  "egm2008": {"provider": "proj", "grid": "egm2008"},
@@ -454,6 +454,52 @@ class GridWriter:
454
454
  return filename
455
455
 
456
456
 
457
+ def calculate_psmsl_msl(csv_path: str) -> float:
458
+ """Reads a PSMSL time-series CSV generated by fetchez, filters out
459
+ missing data flags (-99999), and calculates the all-time Mean Sea Level.
460
+
461
+ Returns the MSL value in METERS.
462
+ """
463
+
464
+ import csv
465
+
466
+ valid_measurements = []
467
+
468
+ try:
469
+ with open(csv_path, "r", encoding="utf-8") as f:
470
+ reader = csv.reader(f, delimiter=";")
471
+ for row in reader:
472
+ if len(row) < 4:
473
+ continue
474
+
475
+ try:
476
+ # Column 1 is the MSL value in millimeters
477
+ msl_mm = float(row[1].strip())
478
+
479
+ # -99999 is the PSMSL nodata flag
480
+ if msl_mm != -99999.0:
481
+ valid_measurements.append(msl_mm)
482
+ except ValueError:
483
+ continue
484
+
485
+ if not valid_measurements:
486
+ logger.warning(f"No valid data found in {csv_path}")
487
+ return np.nan
488
+
489
+ # Calculate the mean in mm, then convert to meters
490
+ mean_mm = sum(valid_measurements) / len(valid_measurements)
491
+ mean_meters = mean_mm / 1000.0
492
+
493
+ logger.info(
494
+ f"Processed {len(valid_measurements)} months of data. MSL: {mean_meters:.3f} m"
495
+ )
496
+ return mean_meters
497
+
498
+ except Exception as e:
499
+ logger.error(f"Failed to process PSMSL file {csv_path}: {e}")
500
+ return np.nan
501
+
502
+
457
503
  class GridGen:
458
504
  @staticmethod
459
505
  def from_stations(
@@ -463,7 +509,7 @@ class GridGen:
463
509
  If a station lacks the target datum, it falls back to MSL and uses the
464
510
  baseline_grid (FES) to bridge the gap to the geodetic frame.
465
511
  """
466
- import os
512
+
467
513
  import json
468
514
  from fetchez.modules.tides import Tides
469
515
  import fetchez
@@ -23,19 +23,41 @@ import zipfile
23
23
  import numpy as np
24
24
  from typing import Tuple
25
25
 
26
- from . import utils
26
+ # from . import utils
27
27
  from .definitions import Datums # Required for ID lookups
28
28
 
29
29
  logger = logging.getLogger(__name__)
30
30
 
31
- ae = ".exe" if sys.platform == "win32" else ""
32
- # Validating HTDP existence
33
- htdp_cmd = (
34
- "echo 0 | htdp 2>&1"
35
- if sys.platform == "win32"
36
- else "echo 0 | htdp 2>&1 | grep SOFTWARE | awk '{print $3}'"
37
- )
38
- HAS_HTDP = utils.cmd_check(f"htdp{ae}", htdp_cmd)
31
+ # ae = ".exe" if sys.platform == "win32" else ""
32
+ # # Validating HTDP existence
33
+ # htdp_cmd = (
34
+ # "echo 0 | htdp 2>&1"
35
+ # if sys.platform == "win32"
36
+ # else "echo 0 | htdp 2>&1 | grep SOFTWARE | awk '{print $3}'"
37
+ # )
38
+ # HAS_HTDP = utils.cmd_check(f"htdp{ae}", htdp_cmd)
39
+
40
+
41
+ def resolve_htdp_path(version="3.5.0"):
42
+ """Finds the HTDP executable, prioritizing the local cache over system PATH."""
43
+
44
+ clean_version = version.replace("v", "")
45
+ ae = ".exe" if sys.platform == "win32" else ""
46
+
47
+ # Check local transformez_cache/bin/
48
+ cache_bin = os.path.join(
49
+ os.getcwd(), "transformez_cache", "bin", f"htdp_{clean_version}{ae}"
50
+ )
51
+ if os.path.exists(cache_bin):
52
+ return cache_bin
53
+
54
+ # Check system PATH
55
+ import shutil
56
+
57
+ if shutil.which(f"htdp{ae}"):
58
+ return f"htdp{ae}"
59
+
60
+ return None
39
61
 
40
62
 
41
63
  class HTDP:
@@ -47,7 +69,11 @@ class HTDP:
47
69
  self.htdp_bin = htdp_bin
48
70
  self.version = version
49
71
  self.verbose = verbose
50
- if not HAS_HTDP:
72
+
73
+ self.htdp_bin = htdp_bin or resolve_htdp_path(self.version)
74
+ self.has_htdp = self.htdp_bin is not None
75
+
76
+ if not self.has_htdp:
51
77
  logger.error(
52
78
  f"HTDP {self.version} is not installed or not in PATH. Run 'transformez htdp install --version {self.version}"
53
79
  )
@@ -57,7 +83,7 @@ class HTDP:
57
83
  Generates a shift grid between two frames.
58
84
  """
59
85
 
60
- if not HAS_HTDP:
86
+ if not self.has_htdp:
61
87
  logger.warning("HTDP missing. Returning zero shift.")
62
88
  return np.zeros((ny, nx))
63
89
 
@@ -190,6 +216,7 @@ class HTDP:
190
216
 
191
217
  def run_cmd(self, control_fn):
192
218
  """Executes the binary."""
219
+
193
220
  try:
194
221
  with open(control_fn, "r") as stdin:
195
222
  subprocess.run(
@@ -268,29 +295,26 @@ def install_htdp_binary(version="3.5.0"):
268
295
  v_tag = f"v{version}" if not version.startswith("v") else version
269
296
  clean_version = v_tag.replace("v", "")
270
297
 
271
- if sys.platform == "win32":
272
- logger.info(f"Downloading Windows HTDP executable ({v_tag})...")
273
- url = f"https://github.com/noaa-ngs/HTDP/releases/download/{v_tag}/htdp.exe"
274
- exe_path = os.path.join(cache_dir, f"htdp_{clean_version}.exe")
275
-
276
- try:
277
- urllib.request.urlretrieve(url, exe_path)
278
- logger.info(f"HTDP {clean_version} installed successfully to: {exe_path}")
279
- except Exception as e:
280
- logger.error(f"Failed to download {v_tag} from GitHub: {e}")
281
-
282
- else:
283
- logger.info(f"Downloading Unix HTDP source code ({v_tag})...")
284
- url = f"https://github.com/noaa-ngs/HTDP/archive/refs/tags/{v_tag}.zip"
285
- zip_path = os.path.join(cache_dir, f"htdp_{clean_version}.zip")
298
+ logger.info(f"Downloading HTDP reposity ({v_tag})...")
299
+ url = f"https://github.com/noaa-ngs/HTDP/archive/refs/tags/{v_tag}.zip"
300
+ zip_path = os.path.join(cache_dir, f"htdp_{clean_version}.zip")
286
301
 
287
- try:
288
- urllib.request.urlretrieve(url, zip_path)
302
+ try:
303
+ urllib.request.urlretrieve(url, zip_path)
289
304
 
305
+ if sys.platform == "win32":
306
+ with zipfile.ZipFile(zip_path, "r") as z:
307
+ exe_name = next(name for name in z.namelist() if name.endswith(".exe"))
308
+ extracted_path = z.extract(exe_name, cache_dir)
309
+ exe_path = os.path.join(cache_dir, f"htdp_{clean_version}.exe")
310
+
311
+ if os.path.exists(exe_path):
312
+ os.remove(exe_path)
313
+ shutil.move(extracted_path, exe_path)
314
+ else:
290
315
  extract_dir = os.path.join(cache_dir, f"htdp_src_{clean_version}")
291
316
  with zipfile.ZipFile(zip_path, "r") as z:
292
317
  z.extractall(extract_dir)
293
- os.remove(zip_path)
294
318
 
295
319
  # GitHub extracts to a folder named Repository-Tag (e.g., HTDP-3.5.0)
296
320
  source_folder = os.path.join(extract_dir, f"HTDP-{clean_version}")
@@ -308,8 +332,10 @@ def install_htdp_binary(version="3.5.0"):
308
332
  # Clean up the raw source files
309
333
  shutil.rmtree(extract_dir)
310
334
 
311
- except Exception as e:
312
- logger.error(f"Failed to install HTDP {clean_version}: {e}")
335
+ os.remove(zip_path)
336
+
337
+ except Exception as e:
338
+ logger.error(f"Failed to install HTDP {clean_version}: {e}")
313
339
 
314
340
 
315
341
  def _install_htdp_binary():
@@ -17,7 +17,7 @@ import logging
17
17
  from pyproj import CRS, Transformer
18
18
 
19
19
  import numpy as np
20
- from rasterio.warp import transform_bounds, reproject, Resampling
20
+ from rasterio.warp import reproject, Resampling
21
21
  from rasterio.transform import from_bounds
22
22
 
23
23
  from fetchez.spatial import Region
@@ -184,19 +184,15 @@ class SRSParser:
184
184
 
185
185
  # Determine Native WGS84 Region for Transformez
186
186
  src_is_projected = self.tc["src_crs"].is_projected
187
+ if not proc_region.srs:
188
+ proc_region.srs = (self.tc["src_crs"],)
189
+
187
190
  if src_is_projected:
188
191
  # Transform the native bounding box to WGS84 to query the shift models
189
- w, s, e, n = transform_bounds(
190
- self.tc["src_crs"],
191
- "EPSG:4326",
192
- proc_region.xmin,
193
- proc_region.ymin,
194
- proc_region.xmax,
195
- proc_region.ymax,
196
- )
197
- vt_region = Region(w, e, s, n)
192
+ vt_region = proc_region.copy()
193
+ vt_region.warp("EPSG:4326")
198
194
  else:
199
- vt_region = proc_region
195
+ vt_region = proc_region.copy()
200
196
 
201
197
  # Generate grid resolution based on WGS84 bounds (approx 3 arc-seconds)
202
198
  inc_deg = 3.0 / 3600.0
@@ -29,6 +29,12 @@ WGS84_EPSG = 4979
29
29
  NAD83_EPSG = 6319
30
30
 
31
31
 
32
+ class MissingGridError(Exception):
33
+ """Raised when a required shift grid cannot be fetched, is missing, or is persistently corrupted."""
34
+
35
+ pass
36
+
37
+
32
38
  class VerticalTransform:
33
39
  """Generate a vertical transformation grid using Transformez."""
34
40
 
@@ -107,7 +113,7 @@ class VerticalTransform:
107
113
 
108
114
  files = fetchez.get(
109
115
  module=module_name,
110
- region=self.region.to_list(),
116
+ region=self.region,
111
117
  outdir=self.cache_dir,
112
118
  threads=2,
113
119
  check_size=True,
@@ -212,7 +218,8 @@ class VerticalTransform:
212
218
  from .grid_engine import GridCorruptionError
213
219
 
214
220
  if not name:
215
- return np.zeros((self.ny, self.nx))
221
+ # return np.zeros((self.ny, self.nx))
222
+ raise MissingGridError("A valid grid name must be provided to the fetcher.")
216
223
 
217
224
  if not provider:
218
225
  provider = "proj"
@@ -298,7 +305,30 @@ class VerticalTransform:
298
305
  files.sort(key=sort_key, reverse=True)
299
306
 
300
307
  if not files:
301
- return np.zeros((self.ny, self.nx))
308
+ # return np.zeros((self.ny, self.nx))
309
+ if attempt < max_retries - 1:
310
+ logger.warning(
311
+ f"Grid '{name}' not found. Wiping cache for '{provider}' and retrying (Attempt {attempt + 2}/{max_retries})..."
312
+ )
313
+
314
+ if os.path.exists(self.cache_dir):
315
+ for f in os.listdir(self.cache_dir):
316
+ if (
317
+ name.lower() in f.lower()
318
+ or provider.lower() in f.lower()
319
+ ):
320
+ try:
321
+ os.remove(os.path.join(self.cache_dir, f))
322
+ except OSError:
323
+ pass
324
+ continue
325
+ else:
326
+ logger.error(
327
+ f"FATAL: Max retries exhausted. Could not locate grid '{name}' from '{provider}'."
328
+ )
329
+ raise MissingGridError(
330
+ f"Required shift grid '{name}' is missing or unavailable."
331
+ )
302
332
 
303
333
  try:
304
334
  if provider == "seanoe" or provider == "fes":
@@ -327,11 +357,15 @@ class VerticalTransform:
327
357
  logger.error(
328
358
  "Max retries reached. Could not secure an uncorrupted grid."
329
359
  )
330
- return np.zeros((self.ny, self.nx))
360
+ # return np.zeros((self.ny, self.nx))
361
+ raise MissingGridError(f"Grid '{name}' is persistently corrupted.")
331
362
 
332
- return np.zeros((self.ny, self.nx))
363
+ # return np.zeros((self.ny, self.nx))
364
+ raise MissingGridError(
365
+ f"Failed to fetch grid '{name}' due to an unknown error."
366
+ )
333
367
 
334
- def _get_htdp_shift(self, epsg_from, epsg_to, epoch_from, epoch_to):
368
+ def _get_htdp_shift(self, epsg_from, epsg_to, epoch_from, epoch_to, context=""):
335
369
  """Calculate Frame Shift via HTDP with Fallback."""
336
370
 
337
371
  if epsg_from == epsg_to and epoch_from == epoch_to:
@@ -340,7 +374,10 @@ class VerticalTransform:
340
374
  from . import htdp
341
375
 
342
376
  try:
343
- logger.info(f" [HTDP] Frame Shift: EPSG:{epsg_from} -> EPSG:{epsg_to}")
377
+ ctx_str = f" {context}" if context else ""
378
+ logger.info(
379
+ f" [HTDP] Frame Shift: EPSG:{epsg_from} -> EPSG:{epsg_to}{ctx_str}"
380
+ )
344
381
  tool = htdp.HTDP(version="3.5.0", verbose=False)
345
382
 
346
383
  # Attempt 1: Full Cross-Epoch Shift (Datum Shift + Crustal Velocity)
@@ -548,7 +585,11 @@ class VerticalTransform:
548
585
 
549
586
  if global_shift is not None and np.any(global_shift):
550
587
  htdp_wgs_to_nad = self._get_htdp_shift(
551
- WGS84_EPSG, NAD83_EPSG, self.epoch_in, 2010.0
588
+ WGS84_EPSG,
589
+ NAD83_EPSG,
590
+ self.epoch_in,
591
+ 2010.0,
592
+ context="(Aligning Global Proxy to NAD83)",
552
593
  )
553
594
  fes_nad83 = global_shift + htdp_wgs_to_nad
554
595
  fes_navd88 = fes_nad83 - geoid_grid
@@ -748,7 +789,11 @@ class VerticalTransform:
748
789
  if chain_shift is not None:
749
790
  if native_epsg != self.hub_epsg:
750
791
  htdp_shift = self._get_htdp_shift(
751
- native_epsg, self.hub_epsg, epoch, self.epoch_out
792
+ native_epsg,
793
+ self.hub_epsg,
794
+ epoch,
795
+ self.epoch_out,
796
+ context="(Stepping to Central Hub)",
752
797
  )
753
798
  chain_shift += htdp_shift
754
799
  chain_desc += f" + Frame({native_epsg}->{self.hub_epsg})"
@@ -769,7 +814,11 @@ class VerticalTransform:
769
814
 
770
815
  if self.hub_epsg != native_epsg:
771
816
  htdp_shift = self._get_htdp_shift(
772
- self.hub_epsg, native_epsg, self.epoch_in, epoch
817
+ self.hub_epsg,
818
+ native_epsg,
819
+ self.epoch_in,
820
+ epoch,
821
+ context="(Extracting from Central Hub)",
773
822
  )
774
823
  total_out += htdp_shift
775
824
  desc_parts.append(f"Hub({self.hub_epsg}->{native_epsg})")
File without changes
File without changes
File without changes
File without changes