Skip to content

Commit

Permalink
Fix bounding box clipping bug #50 (#55)
Browse files Browse the repository at this point in the history
* Fix bbox clipping bug #50

* Update lockfile

* Updates for compat with pytmd==2.2.2

* Fix time

* New version 0.6.1

* Fix wording

* Add test of global clipping options

* Autouse synethic hamtide fixture

* Fix output path
  • Loading branch information
robbibt authored Feb 20, 2025
1 parent 3d38540 commit 624f70a
Show file tree
Hide file tree
Showing 8 changed files with 574 additions and 250 deletions.
6 changes: 6 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## v0.6.1

### Bug fixes

- Fixed bug causing tide model clipping with `clip_tides` to fail for bounding boxes completely west of the prime meridian ([#50](https://github.com/GeoscienceAustralia/eo-tides/issues/50))

## v0.6.0

### New features
Expand Down
9 changes: 5 additions & 4 deletions eo_tides/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import pandas as pd
import pyproj
import pyTMD
import timescale.time
from tqdm import tqdm

from .utils import DatetimeLike, _set_directory, _standardise_models, _standardise_time, idw
Expand Down Expand Up @@ -95,7 +96,7 @@ def _model_tides(
lon, lat = transformer.transform(x.flatten(), y.flatten())

# Convert datetime
timescale = pyTMD.time.timescale().from_datetime(time.flatten())
ts = timescale.time.Timescale().from_datetime(time.flatten())

try:
# Read tidal constants and interpolate to grid points
Expand Down Expand Up @@ -136,10 +137,10 @@ def _model_tides(
# Compute delta times based on model
if pytmd_model.corrections in ("OTIS", "ATLAS", "TMD3", "netcdf"):
# Use delta time at 2000.0 to match TMD outputs
deltat = np.zeros_like(timescale.tt_ut1)
deltat = np.zeros_like(ts.tt_ut1)
else:
# Use interpolated delta times
deltat = timescale.tt_ut1
deltat = ts.tt_ut1

# In "one-to-many" mode, extracted tidal constituents and timesteps
# are repeated/multiplied out to match the number of input points and
Expand All @@ -149,7 +150,7 @@ def _model_tides(
points_repeat = len(x) if mode == "one-to-many" else 1
time_repeat = len(time) if mode == "one-to-many" else 1
t, hc, deltat = (
np.tile(timescale.tide, points_repeat),
np.tile(ts.tide, points_repeat),
hc.repeat(time_repeat, axis=0),
np.tile(deltat, points_repeat),
)
Expand Down
30 changes: 16 additions & 14 deletions eo_tides/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,9 +181,9 @@ def _clip_model_file(
"""
Clips tide model netCDF datasets to a bounding box.
If the bounding box crosses 0 degrees longitude (e.g. Greenwich),
the function will clip the dataset into two parts and concatenate
them along the x-dimension to create a continuous result.
If the bounding box crosses 0 degrees longitude (e.g. Greenwich prime
meridian), the dataset will be clipped into two parts and concatenated
along the x-dimension to create a continuous result.
Parameters
----------
Expand Down Expand Up @@ -226,20 +226,22 @@ def _clip_model_file(
xcoords = nc[xcoord].compute()
ycoords = nc[ycoord].compute()

# If data falls within 0-360 degree bounds, then clip directly
if (bbox.left >= 0) & (bbox.right <= 360):
# Convert longitudes to 0-360 convention
left = bbox.left % 360
right = bbox.right % 360

# If left coordinate is smaller than right, bbox does not cross
# zero longitude and can be clipped directly
if left <= right: # bbox does not cross 0
nc_clipped = nc.sel({
ydim: (ycoords >= bbox.bottom) & (ycoords <= bbox.top),
xdim: (xcoords >= bbox.left) & (xcoords <= bbox.right),
xdim: (xcoords >= left) & (xcoords <= right),
})

# If bbox crosses zero longitude, extract left and right
# separately and then combine into one concatenated dataset
elif (bbox.left < 0) & (bbox.right > 0):
# Convert longitudes to 0-360 range
left = bbox.left % 360
right = bbox.right % 360

# If left coordinate is larger than right, bbox crosses zero longitude.
# If so, extract left and right separately and then combine into one
# concatenated dataset
elif left > right: # bbox crosses 0
# Extract data from left of 0 longitude, and convert lon
# coords to -180 to 0 range to enable continuous interpolation
# across 0 boundary
Expand Down Expand Up @@ -357,7 +359,7 @@ def clip_models(
model_files = model_database[m].get("model_file", [])
grid_file = model_database[m].get("grid_file", [])

# Convert to list if strings and combine
# Convert to list of strings and combine
model_files = model_files if isinstance(model_files, list) else [model_files]
grid_file = grid_file if isinstance(grid_file, list) else [grid_file]
all_files = model_files + grid_file
Expand Down
5 changes: 3 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "eo-tides"
version = "0.6.0"
version = "0.6.1"
description = "Tide modelling tools for large-scale satellite earth observation analysis"
authors = [
{ name = "Robbi Bishop-Taylor" },
Expand Down Expand Up @@ -44,10 +44,11 @@ dependencies = [
"psutil>=5.8.0",
"pyogrio>=0.10.0",
"pyproj>=3.7.0",
"pyTMD==2.2.1",
"pyTMD>=2.2.2",
"scikit-learn>=1.4.0",
"scipy>=1.14.1",
"shapely>=2.0.6",
"timescale>=0.0.3",
"tqdm>=4.55.0",
"xarray>=2022.3.0",
]
Expand Down
45 changes: 45 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@
"""

from copy import deepcopy
from pathlib import Path

import numpy as np
import odc.stac
import pandas as pd
import pystac_client
import pytest
import xarray as xr

GAUGE_X = 122.2183
GAUGE_Y = -18.0008
Expand Down Expand Up @@ -97,3 +100,45 @@ def satellite_ds(satellite_ds_load):
each test to ensure each test is independent
"""
return deepcopy(satellite_ds_load)


# Run once per session to generate symethic HAMTIDE11 files; autouse=True
# allows this to run without being specifically called in tests
@pytest.fixture(scope="session", autouse=True)
def create_synthetic_model(base_dir="tests/data/tide_models_synthetic"):
"""
Generates and exports synthetic HAMTIDE11 model data
to test clipping functionality.
"""
base_dir = Path(base_dir) # Ensure base_dir is a Path object

# Create coordinate arrays
lon = np.arange(0, 360.125, 0.125) # 2881 points
lat = np.arange(-90, 90.125, 0.125) # 1441 points

# List of hamtide tidal constituents
constituents = ["2n", "k1", "k2", "m2", "n2", "o1", "p1", "q1", "s2"]

# Create hamtide output directory
hamtide_dir = base_dir / "hamtide"
hamtide_dir.mkdir(parents=True, exist_ok=True)

# Create and save a NetCDF for each constituent
for constituent in constituents:
# Create synthetic hamtide dataset with random data
shape = (len(lat), len(lon)) # 1441, 2881
data = np.random.random(shape).astype(np.float32)
ds = xr.Dataset(
{
"RE": (("LAT", "LON"), data),
"IM": (("LAT", "LON"), data),
"AMPL": (("LAT", "LON"), data),
"PHAS": (("LAT", "LON"), data),
},
coords={"LON": lon, "LAT": lat},
attrs={"title": f"HAMTIDE11a: {constituent} ocean tide"},
)

# Export
filename = hamtide_dir / f"{constituent}.hamtide11a.nc"
ds.to_netcdf(filename)
83 changes: 82 additions & 1 deletion tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
from datetime import datetime

import numpy as np
import odc.geo.geom
import pandas as pd
import pytest

from eo_tides.model import model_tides
from eo_tides.utils import _standardise_models, _standardise_time, clip_models, idw, list_models


Expand Down Expand Up @@ -53,17 +55,96 @@ def test_standardise_models(model, ensemble_models, exp_process, exp_request, ex


def test_clip_models():
# Set input and output paths
in_dir = "tests/data/tide_models"
out_dir = pathlib.Path("tests/data/tide_models_clipped")

# Clip models to bbox
clip_models(
input_directory="tests/data/tide_models",
input_directory=in_dir,
output_directory=out_dir,
bbox=(122.27, -18.07, 122.29, -18.05),
)

# Assert that files were exported for all available models
output_files = set([i.stem for i in out_dir.iterdir()])
assert output_files == set(["GOT5", "EOT20", "hamtide"])

# Set modelling location
x, y = 122.28, -18.06
time = pd.date_range(start="2000-01", end="2001-03", freq="5h")

# Model using unclipped vs clipped files
df_unclipped = model_tides(
x=x,
y=y,
time=time,
model="HAMTIDE11",
directory=in_dir,
crop=False,
)
df_clipped = model_tides(
x=x,
y=y,
time=time,
model="HAMTIDE11",
directory=out_dir,
crop=False,
)

# Verify both produce the same results
assert np.allclose(df_unclipped.tide_height, df_clipped.tide_height)


# Test clipping across multiple global locations using synthetic HAMTIDE11 data
@pytest.mark.parametrize(
"bbox, name",
[
((-166, 14, -151, 29), "hawaii"), # entirely W of prime meridian
((-13, 49, 6, 60), "uk"), # crossing prime meridian
((105.292969, -47.872144, 160.312500, -5.266008), "aus"), # entirely E of prime meridian
((-256.640625, 7.013668, -119.794922, 63.391522), "pacific"), # crossing antimeridian
],
)
def test_clip_models_bbox(bbox, name):
# Set input and output paths
in_dir = "tests/data/tide_models_synthetic/"
out_dir = f"tests/data/tide_models_synthetic_{name}/"

# Clip models to input bbox
clip_models(
input_directory=in_dir,
output_directory=out_dir,
bbox=bbox,
model="HAMTIDE11",
overwrite=True,
)

# Set modelling location based on bbox centroid
x, y = odc.geo.geom.BoundingBox(*bbox, crs="EPSG:4326").polygon.centroid.xy
time = pd.date_range(start="2000-01", end="2001-03", freq="5h")

# Model using unclipped vs clipped files
df_unclipped = model_tides(
x=x,
y=y,
time=time,
model="HAMTIDE11",
directory=in_dir,
crop=False,
)
df_clipped = model_tides(
x=x,
y=y,
time=time,
model="HAMTIDE11",
directory=out_dir,
crop=False,
)

# Verify both produce the same results
assert np.allclose(df_unclipped.tide_height, df_clipped.tide_height)


@pytest.mark.parametrize(
"input_value, expected_output",
Expand Down
Loading

0 comments on commit 624f70a

Please sign in to comment.