Skip to content

Commit 30767bb

Browse files
committed
add decay_pixels arg to api/cli; coastal_aware_blend to merge FES and VDATUM, only decay inland
1 parent e4939fd commit 30767bb

4 files changed

Lines changed: 125 additions & 19 deletions

File tree

src/transformez/api.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,10 @@ def generate_grid(
6161
increment: Union[str, float],
6262
datum_in: str,
6363
datum_out: str,
64+
decay_pixels: int = 100,
6465
out_fn: Optional[str] = None,
6566
cache_dir: Optional[str] = None,
66-
verbose: bool = False
67+
verbose: bool = False,
6768
) -> Optional[np.ndarray]:
6869
"""Generate a vertical shift grid for a specific region.
6970
@@ -72,6 +73,7 @@ def generate_grid(
7273
increment: Resolution (e.g., '3s' or 0.0008333).
7374
datum_in: Source datum (e.g., 'mllw', '5703').
7475
datum_out: Target datum (e.g., '4979', '6319').
76+
decay_pixels: Set the pixel decay in case extrapolation is required.
7577
out_fn: If provided, saves the grid to this file (.tif or .gtx).
7678
cache_dir: Path to store downloaded grids.
7779
verbose: Enable debug logging.
@@ -109,6 +111,7 @@ def generate_grid(
109111
nx=nx, ny=ny,
110112
epsg_in=epsg_in, epsg_out=epsg_out,
111113
geoid_in=geoid_in, geoid_out=geoid_out,
114+
decay_pixels=decay_pixels,
112115
cache_dir=cache_dir,
113116
verbose=verbose
114117
)
@@ -130,6 +133,7 @@ def transform_raster(
130133
input_raster: str,
131134
datum_in: str,
132135
datum_out: str,
136+
decay_pixels: int = 100,
133137
output_raster: Optional[str] = None,
134138
cache_dir: Optional[str] = None,
135139
verbose: bool = False
@@ -141,6 +145,7 @@ def transform_raster(
141145
datum_in: Source datum of the DEM.
142146
datum_out: Target datum for the output DEM.
143147
output_raster: Path to save the transformed DEM. If None, auto-generates a name.
148+
decay_pixels: Set the pixel decay in case extrapolation is required.
144149
cache_dir: Path to store downloaded grids.
145150
verbose: Enable debug logging.
146151
@@ -177,6 +182,7 @@ def transform_raster(
177182
nx=nx, ny=ny,
178183
epsg_in=epsg_in, epsg_out=epsg_out,
179184
geoid_in=geoid_in, geoid_out=geoid_out,
185+
decay_pixels=decay_pixels,
180186
cache_dir=cache_dir,
181187
verbose=verbose
182188
)

src/transformez/cli.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,10 @@ def transformez_cli():
100100
grp_out.add_argument('-o', '--output', help='Output filename (default: auto-named).')
101101
grp_out.add_argument('--preview', action='store_true', help='Show plot of shift grid before saving.')
102102

103+
grp_proc = parser.add_argument_group('Process')
104+
grp_proc.add_argument('--decay-pixels', type=int, default=100,
105+
help='Number of pixels to decay tidal shifts inland (default: 100). Set to 0 for strict VDatum boundaries.')
106+
103107
grp_sys = parser.add_argument_group('System')
104108
grp_sys.add_argument('--list-datums', action='store_true', help='List supported datums.')
105109
grp_sys.add_argument('--download-htdp', action='store_true', help='Download the NGS HTDP software to the current directory.')
@@ -202,6 +206,7 @@ def transformez_cli():
202206
nx=nx, ny=ny,
203207
epsg_in=epsg_in, epsg_out=epsg_out,
204208
geoid_in=geoid_in, geoid_out=geoid_out,
209+
decay_pixels=args.decay_pixels,
205210
cache_dir=cache_dir,
206211
verbose=args.verbose
207212
)

src/transformez/grid_engine.py

Lines changed: 67 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ def plot_grid(grid_array, region, title="Vertical Shift Preview"):
4545
plt.figure(figsize=(10, 6))
4646
plot_region = [region.xmin, region.xmax, region.ymin, region.ymax]
4747

48-
im = plt.imshow(masked_data, extent=plot_region, cmap="RdBu_r", origin="upper")
48+
# im = plt.imshow(masked_data, extent=plot_region, cmap="RdBu_r", origin="upper")
49+
im = plt.imshow(masked_data, extent=plot_region, cmap="viridis", origin="upper")
4950
cbar = plt.colorbar(im)
5051
cbar.set_label("Vertical Shift (meters)")
5152
plt.title(title)
@@ -110,14 +111,71 @@ def load_and_interpolate(source_files, target_region, nx, ny, decay_pixels=100):
110111
continue
111112

112113
# Fill inland areas (decaying to 0) before we clear the remaining NaNs
113-
mosaic = GridEngine.fill_nans(mosaic, decay_pixels=decay_pixels)
114-
mosaic[np.isnan(mosaic)] = 0.0
114+
# mosaic = GridEngine.fill_nans(mosaic, decay_pixels=decay_pixels)
115+
# mosaic[np.isnan(mosaic)] = 0.0
115116

116117
return mosaic
117118

118119
@staticmethod
119-
def fill_nans(data, decay_pixels=100):
120-
"""Fill NaNs with the nearest valid value, decayed to zero over distance."""
120+
def smart_blend(in_grid, background_grid, blend_pixels=50):
121+
"""Smoothly blends the grid into a background grid."""
122+
123+
mask = np.isnan(in_grid)
124+
125+
if not mask.any():
126+
return in_grid
127+
128+
if mask.all():
129+
return background_grid
130+
131+
dist = ndimage.distance_transform_edt(mask)
132+
alpha = np.clip(dist / blend_pixels, 0.0, 1.0)
133+
134+
nearest_indices = ndimage.distance_transform_edt(mask, return_distances=False, return_indices=True)
135+
extended_vdatum = in_grid.copy()
136+
extended_vdatum[mask] = in_grid[tuple(nearest_indices)][mask]
137+
138+
blended_data = (extended_vdatum * (1.0 - alpha)) + (background_grid * alpha)
139+
140+
return blended_data
141+
142+
@staticmethod
143+
def coastal_aware_composite(vdatum_grid, global_grid, decay_pixels=100, buffer_pixels=10, max_discontinuity=0.5):
144+
"""Intelligently handles inland decay vs. offshore blending, while
145+
filtering out low-resolution global artifacts.
146+
"""
147+
148+
final_grid = vdatum_grid.copy()
149+
vdatum_mask = np.isnan(vdatum_grid)
150+
if not vdatum_mask.all():
151+
nearest_idx = ndimage.distance_transform_edt(vdatum_mask, return_distances=False, return_indices=True)
152+
nearest_vdatum_vals = vdatum_grid[tuple(nearest_idx)]
153+
154+
fes_anomaly_mask = np.abs(global_grid - nearest_vdatum_vals) > max_discontinuity
155+
156+
global_grid[fes_anomaly_mask] = np.nan
157+
158+
is_vdatum = ~np.isnan(vdatum_grid)
159+
is_ocean = ~np.isnan(global_grid)
160+
161+
is_inland = ~is_vdatum & ~is_ocean
162+
is_offshore = ~is_vdatum & is_ocean
163+
164+
if is_offshore.any():
165+
blended_ocean = GridEngine.smart_blend(vdatum_grid, global_grid, blend_pixels=50)
166+
final_grid[is_offshore] = blended_ocean[is_offshore]
167+
168+
if is_inland.any():
169+
decayed_inland = GridEngine.fill_nans(vdatum_grid, decay_pixels=decay_pixels, buffer_pixels=buffer_pixels)
170+
final_grid[is_inland] = decayed_inland[is_inland]
171+
172+
return final_grid
173+
174+
@staticmethod
175+
def fill_nans(data, decay_pixels=100, buffer_pixels=50):
176+
"""Extrapolates nearest valid value for 'buffer_pixels',
177+
then decays to zero over 'decay_pixels'.
178+
"""
121179

122180
mask = np.isnan(data)
123181
if not mask.any() or mask.all():
@@ -127,7 +185,10 @@ def fill_nans(data, decay_pixels=100):
127185
mask, return_distances=True, return_indices=True
128186
)
129187
coast_values = data[tuple(indices)]
130-
decay_factor = np.clip((decay_pixels - dist) / decay_pixels, 0, 1)
188+
189+
# Subtract the buffer from the distance. Anything <= 0 is in the buffer zone.
190+
effective_dist = np.clip(dist - buffer_pixels, 0, None)
191+
decay_factor = np.clip((decay_pixels - effective_dist) / decay_pixels, 0, 1)
131192

132193
out_data = data.copy()
133194
out_data[mask] = coast_values[mask] * decay_factor[mask]

src/transformez/transform.py

Lines changed: 46 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ class VerticalTransform:
3333

3434
def __init__(self, region, nx, ny, epsg_in, epsg_out,
3535
geoid_in=None, geoid_out=None, epoch_in=2010.0, epoch_out=2010.0,
36-
cache_dir=None, verbose=True):
36+
decay_pixels=100, cache_dir=None, verbose=True):
3737

3838
self.region = region
3939
self.nx = nx
@@ -55,6 +55,8 @@ def __init__(self, region, nx, ny, epsg_in, epsg_out,
5555
self.ref_in = Datums.get_frame_type(self.epsg_in)
5656
self.ref_out = Datums.get_frame_type(self.epsg_out)
5757

58+
self.decay_pixels = decay_pixels
59+
5860
# --- HUB SELECTION ---
5961
# Determine the Native Ellipsoid of Input and Output
6062
native_in = self._get_native_ellipsoid(self.epsg_in, self.ref_in)
@@ -178,9 +180,9 @@ def _get_grid(self, provider, name):
178180
if provider == 'seanoe' or provider == 'fes':
179181
var_name = "lat_elevation" if "lat" in name.lower() else "msl_elevation"
180182
nc_path = f"netcdf:{files[0]}:{var_name}"
181-
return GridEngine.load_and_interpolate([nc_path], self.region, self.nx, self.ny)
183+
return GridEngine.load_and_interpolate([nc_path], self.region, self.nx, self.ny, decay_pixels=self.decay_pixels)
182184

183-
return GridEngine.load_and_interpolate(files, self.region, self.nx, self.ny)
185+
return GridEngine.load_and_interpolate(files, self.region, self.nx, self.ny, decay_pixels=self.decay_pixels)
184186

185187
def _get_htdp_shift(self, epsg_from, epsg_to, epoch_from, epoch_to):
186188
"""Calculate Frame Shift via HTDP."""
@@ -232,33 +234,65 @@ def _fetch_geoid_with_fallback(self, target_geoid):
232234
# =========================================================================
233235
def _get_vdatum_chain(self, datum_name, geoid_name):
234236
"""Builds shift: Tidal -> [NAD83 Native]."""
235-
total_shift = np.zeros((self.ny, self.nx))
237+
hydro_shift = np.zeros((self.ny, self.nx))
236238
desc = []
237239

238240
# Tidal -> LMSL
239241
if datum_name not in ['msl', '5714', 'lmsl']:
240242
grid = self._get_grid('vdatum', datum_name)
241-
if not np.any(grid):
243+
if np.isnan(grid).all() or (grid == 0).all():
242244
return None, f"Missing Tidal Grid: {datum_name}"
243-
total_shift += grid
245+
hydro_shift += grid
244246
desc.append(f"({datum_name}->LMSL)")
245247

246248
# LMSL -> Ortho (TSS)
247249
tss = self._get_grid('vdatum', 'tss')
248-
249-
if not np.any(tss):
250+
if np.isnan(tss).all() or (tss == 0).all():
250251
return None, "Outside VDatum coverage (Missing TSS)"
251252

252-
total_shift += tss
253+
hydro_shift += tss
253254
desc.append("TSS(LMSL->NAVD88)")
254255

255-
# Ortho -> NAD83 (Smart Geoid Fallback)
256+
# Ortho -> NAD83 (Geoid)
257+
# We fetch the geoid, but DO NOT add it to the shift yet!
256258
actual_geoid = geoid_name if geoid_name else 'g2018'
257259
geoid_grid, used_geoid = self._fetch_geoid_with_fallback(actual_geoid)
258-
259-
total_shift += geoid_grid
260260
desc.append(f"Geoid({used_geoid}->NAD83)")
261261

262+
# =======================================================
263+
# Coastal Blend
264+
# =======================================================
265+
total_shift = np.zeros((self.ny, self.nx))
266+
267+
if np.isnan(hydro_shift).any():
268+
proxy_name = Datums.get_global_proxy(datum_name)
269+
if proxy_name:
270+
logger.info(f"Partial VDatum coverage detected. Fetching {proxy_name.upper()} (FES) for offshore blending...")
271+
global_shift, d_global = self._get_global_chain(proxy_name, model='fes2014')
272+
273+
if global_shift is not None and np.any(global_shift):
274+
# We have valid FES data. We must align it to NAD83.
275+
htdp_wgs_to_nad = self._get_htdp_shift(WGS84_EPSG, NAD83_EPSG, self.epoch_in, 2010.0)
276+
fes_full = global_shift + htdp_wgs_to_nad
277+
278+
hydro_shift = GridEngine.coastal_aware_composite(
279+
vdatum_grid=hydro_shift,
280+
global_grid=fes_full,
281+
decay_pixels=self.decay_pixels,
282+
buffer_pixels=10,
283+
max_discontinuity=0.5
284+
)
285+
desc.append(f"Blended w/ Global({proxy_name.upper()})")
286+
else:
287+
hydro_shift = GridEngine.fill_nans(hydro_shift, decay_pixels=self.decay_pixels, buffer_pixels=10)
288+
desc.append("Inland Hydro Decay")
289+
else:
290+
hydro_shift = GridEngine.fill_nans(hydro_shift, decay_pixels=self.decay_pixels, buffer_pixels=10)
291+
desc.append("Inland Hydro Decay")
292+
293+
total_shift = hydro_shift + geoid_grid
294+
total_shift[np.isnan(total_shift)] = 0.0
295+
262296
return total_shift, " + ".join(desc)
263297

264298
def _get_global_chain(self, datum_name, model="fes2014"):

0 commit comments

Comments
 (0)