xshelftransects 0.1.1__py3-none-any.whl
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.
- xshelftransects/__init__.py +36 -0
- xshelftransects/geometry.py +239 -0
- xshelftransects/isobath.py +85 -0
- xshelftransects/sampling.py +170 -0
- xshelftransects/transects.py +193 -0
- xshelftransects-0.1.1.dist-info/METADATA +72 -0
- xshelftransects-0.1.1.dist-info/RECORD +8 -0
- xshelftransects-0.1.1.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from .transects import cross_shelf_transects, xesmf_interpolation, xarray_interpolation
|
|
2
|
+
from .isobath import longest_boundary_contour, longest_contour
|
|
3
|
+
from .geometry import (
|
|
4
|
+
_project_lonlat,
|
|
5
|
+
_resample_contour_xy,
|
|
6
|
+
_tangent_normal_xy,
|
|
7
|
+
_make_transect_lonlat,
|
|
8
|
+
_orient_normals_by_offshore_slope,
|
|
9
|
+
)
|
|
10
|
+
from .sampling import (
|
|
11
|
+
_locstream_out,
|
|
12
|
+
_as_dataset,
|
|
13
|
+
_sample_vars_xesmf,
|
|
14
|
+
_sample_vars_xarray,
|
|
15
|
+
_sample_vars,
|
|
16
|
+
_unstack_loc,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"cross_shelf_transects",
|
|
21
|
+
"xesmf_interpolation",
|
|
22
|
+
"xarray_interpolation",
|
|
23
|
+
"longest_boundary_contour",
|
|
24
|
+
"longest_contour",
|
|
25
|
+
"_project_lonlat",
|
|
26
|
+
"_resample_contour_xy",
|
|
27
|
+
"_tangent_normal_xy",
|
|
28
|
+
"_make_transect_lonlat",
|
|
29
|
+
"_orient_normals_by_offshore_slope",
|
|
30
|
+
"_locstream_out",
|
|
31
|
+
"_as_dataset",
|
|
32
|
+
"_sample_vars_xesmf",
|
|
33
|
+
"_sample_vars_xarray",
|
|
34
|
+
"_sample_vars",
|
|
35
|
+
"_unstack_loc",
|
|
36
|
+
]
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import warnings
|
|
2
|
+
import numpy as np
|
|
3
|
+
import xarray as xr
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
from scipy.stats import theilslopes
|
|
7
|
+
except ModuleNotFoundError:
|
|
8
|
+
theilslopes = None
|
|
9
|
+
|
|
10
|
+
def _project_lonlat(lon2d, lat2d, crs_out):
|
|
11
|
+
"""
|
|
12
|
+
Project 2D lon/lat arrays to x/y in meters for a specified projected CRS.
|
|
13
|
+
|
|
14
|
+
Parameters
|
|
15
|
+
----------
|
|
16
|
+
lon2d, lat2d : array-like
|
|
17
|
+
2D longitude/latitude arrays (degrees).
|
|
18
|
+
crs_out : str
|
|
19
|
+
Projected CRS (meters), e.g., "EPSG:3031" for Antarctica, "EPSG:3413" for
|
|
20
|
+
the Arctic, or "EPSG:3857" for a global web-mercator projection.
|
|
21
|
+
|
|
22
|
+
Returns
|
|
23
|
+
-------
|
|
24
|
+
x2d, y2d : ndarray
|
|
25
|
+
Projected coordinates in meters.
|
|
26
|
+
"""
|
|
27
|
+
from pyproj import Transformer
|
|
28
|
+
|
|
29
|
+
# Build a lon/lat (EPSG:4326) -> projected CRS transformer; EPSG:4326 is degrees,
|
|
30
|
+
# so projected outputs are in meters when crs_out is a meter-based CRS (e.g., EPSG:3031).
|
|
31
|
+
tf = Transformer.from_crs("EPSG:4326", crs_out, always_xy=True)
|
|
32
|
+
return tf.transform(np.asarray(lon2d), np.asarray(lat2d))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _resample_contour_xy(contour_xy, transect_spacing):
|
|
36
|
+
"""
|
|
37
|
+
Resample a contour to approximately uniform spacing along arc-length.
|
|
38
|
+
|
|
39
|
+
This is used to generate evenly spaced "sections" along a predefined contour.
|
|
40
|
+
|
|
41
|
+
Parameters
|
|
42
|
+
----------
|
|
43
|
+
contour_xy : (N,2) ndarray
|
|
44
|
+
Contour vertices in projected meters.
|
|
45
|
+
transect_spacing : float
|
|
46
|
+
Target along-contour spacing (meters).
|
|
47
|
+
|
|
48
|
+
Returns
|
|
49
|
+
-------
|
|
50
|
+
contour_xy_rs : (M,2) ndarray
|
|
51
|
+
Resampled contour vertices in projected meters.
|
|
52
|
+
s_m : (M,) ndarray
|
|
53
|
+
Along-contour distance from start (meters) for each resampled vertex.
|
|
54
|
+
"""
|
|
55
|
+
contour_xy = np.asarray(contour_xy)
|
|
56
|
+
seg = np.sqrt(np.sum(np.diff(contour_xy, axis=0) ** 2, axis=1))
|
|
57
|
+
s = np.concatenate([[0.0], np.cumsum(seg)])
|
|
58
|
+
if s[-1] <= 0:
|
|
59
|
+
return contour_xy, s
|
|
60
|
+
n = max(2, int(np.floor(s[-1] / transect_spacing)) + 1)
|
|
61
|
+
si = np.linspace(0.0, s[-1], n)
|
|
62
|
+
xi = np.interp(si, s, contour_xy[:, 0])
|
|
63
|
+
yi = np.interp(si, s, contour_xy[:, 1])
|
|
64
|
+
return np.c_[xi, yi], si
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _tangent_normal_xy(contour_xy):
|
|
68
|
+
"""
|
|
69
|
+
Unit tangents and normals along a contour line in Cartesian coordinates.
|
|
70
|
+
|
|
71
|
+
Notes
|
|
72
|
+
-----
|
|
73
|
+
- np.gradient is taken with respect to vertex index, not an explicit spatial coordinate.
|
|
74
|
+
Because the contour is resampled to roughly uniform spacing and we normalize to unit
|
|
75
|
+
vectors, this is typically adequate.
|
|
76
|
+
- The normal sign is arbitrary here; it is oriented later using contour orientation.
|
|
77
|
+
"""
|
|
78
|
+
dxy = np.gradient(np.asarray(contour_xy), axis=0)
|
|
79
|
+
t = dxy / np.maximum(np.linalg.norm(dxy, axis=1, keepdims=True), 1e-12)
|
|
80
|
+
n = np.c_[-t[:, 1], t[:, 0]]
|
|
81
|
+
return t, n
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _contour_outward_sign(contour_xy):
|
|
85
|
+
"""
|
|
86
|
+
Determine a global sign (+1 or -1) for outward normals based on contour orientation.
|
|
87
|
+
|
|
88
|
+
Notes
|
|
89
|
+
-----
|
|
90
|
+
- For a counter-clockwise contour, the left normal points inward, so outward is -1.
|
|
91
|
+
- For a clockwise contour, the left normal points outward, so outward is +1.
|
|
92
|
+
"""
|
|
93
|
+
contour_xy = np.asarray(contour_xy)
|
|
94
|
+
if contour_xy.shape[0] < 3:
|
|
95
|
+
return 1.0
|
|
96
|
+
x = contour_xy[:, 0]
|
|
97
|
+
y = contour_xy[:, 1]
|
|
98
|
+
area = 0.5 * np.sum(x[:-1] * y[1:] - x[1:] * y[:-1])
|
|
99
|
+
if not np.isfinite(area) or area == 0.0:
|
|
100
|
+
return 1.0
|
|
101
|
+
return -1.0 if area > 0.0 else 1.0
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _make_transect_lonlat(tf_inv, x0, y0, nx, ny, X):
|
|
105
|
+
"""
|
|
106
|
+
Create transect target points (lon/lat) from projected anchors and unit normals.
|
|
107
|
+
|
|
108
|
+
Each transect is a straight line:
|
|
109
|
+
(x,y)(section, transect_length) = (x0,y0)(section) + transect_length * (nx,ny)(section)
|
|
110
|
+
|
|
111
|
+
Returns
|
|
112
|
+
-------
|
|
113
|
+
lon_t, lat_t : (nsec, nx) ndarrays
|
|
114
|
+
Target lon/lat coordinates for sampling.
|
|
115
|
+
"""
|
|
116
|
+
xt = x0[:, None] + nx[:, None] * X[None, :]
|
|
117
|
+
yt = y0[:, None] + ny[:, None] * X[None, :]
|
|
118
|
+
lon_t, lat_t = tf_inv.transform(xt, yt)
|
|
119
|
+
return np.asarray(lon_t), np.asarray(lat_t)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _orient_normals_by_offshore_slope(transect_length, dloc0, offshore_slope_window=200e3, min_wet_fraction=0.5):
|
|
123
|
+
"""
|
|
124
|
+
Orient normals so that +transect_length points offshore (depth increases with transect_length).
|
|
125
|
+
|
|
126
|
+
Mechanism
|
|
127
|
+
---------
|
|
128
|
+
- Given depth sampled along provisional transects (dloc0[section, transect_length]),
|
|
129
|
+
check that near-boundary points are wet, then estimate the near-boundary slope
|
|
130
|
+
from points within offshore_slope_window of the boundary.
|
|
131
|
+
- If the median slope < 0, flip the normal for that section.
|
|
132
|
+
|
|
133
|
+
Returns
|
|
134
|
+
-------
|
|
135
|
+
flip : (nsec,) ndarray of {+1, -1}
|
|
136
|
+
"""
|
|
137
|
+
depth_tran0 = xr.DataArray(
|
|
138
|
+
dloc0,
|
|
139
|
+
dims=("section", "transect_length"),
|
|
140
|
+
coords={"section": np.arange(dloc0.shape[0]), "transect_length": transect_length},
|
|
141
|
+
).where(lambda z: z > 0)
|
|
142
|
+
|
|
143
|
+
x = depth_tran0["transect_length"]
|
|
144
|
+
window_mask = x <= offshore_slope_window
|
|
145
|
+
wet_mask = xr.apply_ufunc(np.isfinite, depth_tran0).astype(bool)
|
|
146
|
+
wet_first = wet_mask.where(window_mask).sum("transect_length")
|
|
147
|
+
n_window = window_mask.sum().item()
|
|
148
|
+
min_wet = max(1, int(np.ceil(min_wet_fraction * n_window)))
|
|
149
|
+
|
|
150
|
+
dx = xr.DataArray(
|
|
151
|
+
np.diff(np.asarray(transect_length)),
|
|
152
|
+
dims=("transect_length",),
|
|
153
|
+
coords={"transect_length": depth_tran0["transect_length"].isel(transect_length=slice(0, -1))},
|
|
154
|
+
)
|
|
155
|
+
depth_filled = depth_tran0.fillna(0.0)
|
|
156
|
+
slopes = depth_filled.diff("transect_length") / dx
|
|
157
|
+
valid_pair = wet_mask & wet_mask.shift(transect_length=-1, fill_value=False)
|
|
158
|
+
slopes = slopes.where(valid_pair.isel(transect_length=slice(0, -1)))
|
|
159
|
+
slopes_window = slopes.where(window_mask.isel(transect_length=slice(0, -1)))
|
|
160
|
+
slope_med = slopes_window.median("transect_length", skipna=True)
|
|
161
|
+
|
|
162
|
+
def _theil_sen_1d(y, x_vals):
|
|
163
|
+
if theilslopes is None:
|
|
164
|
+
raise ModuleNotFoundError(
|
|
165
|
+
"Theil-Sen slope requires scipy. Install scipy or choose a different orientation method."
|
|
166
|
+
)
|
|
167
|
+
valid = np.isfinite(y)
|
|
168
|
+
if valid.sum() < 2:
|
|
169
|
+
warnings.warn("Theil-Sen slope: fewer than 2 valid points; returning NaN.", RuntimeWarning)
|
|
170
|
+
return np.nan
|
|
171
|
+
xi = x_vals[valid]
|
|
172
|
+
yi = y[valid]
|
|
173
|
+
slope, _, _, _ = theilslopes(yi, xi)
|
|
174
|
+
return slope
|
|
175
|
+
|
|
176
|
+
depth_window = depth_tran0.where(window_mask)
|
|
177
|
+
slope_ts = xr.apply_ufunc(
|
|
178
|
+
lambda y: _theil_sen_1d(y, np.asarray(transect_length)),
|
|
179
|
+
depth_window,
|
|
180
|
+
input_core_dims=[["transect_length"]],
|
|
181
|
+
output_core_dims=[[]],
|
|
182
|
+
vectorize=True,
|
|
183
|
+
dask="parallelized",
|
|
184
|
+
output_dtypes=[float],
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
flip = xr.where(wet_first < min_wet, -1.0, 1.0)
|
|
188
|
+
flip = xr.where(slope_med < 0, -1.0, flip)
|
|
189
|
+
flip = xr.where(slope_ts < 0, -1.0, flip)
|
|
190
|
+
return flip.values
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _build_transect_geometry_dataset(
|
|
194
|
+
lon0,
|
|
195
|
+
lat0,
|
|
196
|
+
nx,
|
|
197
|
+
ny,
|
|
198
|
+
dloc,
|
|
199
|
+
contour_lon,
|
|
200
|
+
contour_lat,
|
|
201
|
+
s_m,
|
|
202
|
+
transect_length,
|
|
203
|
+
crs,
|
|
204
|
+
engine,
|
|
205
|
+
method,
|
|
206
|
+
transect_spacing,
|
|
207
|
+
):
|
|
208
|
+
"""
|
|
209
|
+
Build the geometry dataset for cross-shelf transects.
|
|
210
|
+
"""
|
|
211
|
+
return xr.Dataset(
|
|
212
|
+
data_vars=dict(
|
|
213
|
+
lon0=(("section",), np.asarray(lon0), {"units": "degrees_east", "description": "Longitude where transect intersects boundary (transect_length=0)."}),
|
|
214
|
+
lat0=(("section",), np.asarray(lat0), {"units": "degrees_north", "description": "Latitude where transect intersects boundary (transect_length=0)."}),
|
|
215
|
+
s_m=(("section",), np.asarray(s_m), {"units": "m", "description": "Along-boundary distance from start."}),
|
|
216
|
+
nx=(("section",), np.asarray(nx), {"units": "1", "description": "Unit normal x-component in projected CRS."}),
|
|
217
|
+
ny=(("section",), np.asarray(ny), {"units": "1", "description": "Unit normal y-component in projected CRS."}),
|
|
218
|
+
depth_xshelf=(("section", "transect_length"), dloc, {"units": "m", "description": "Sampled depth along transects."}),
|
|
219
|
+
contour_lon=(("contour_pt",), np.asarray(contour_lon), {"units": "degrees_east", "description": "Boundary contour longitude."}),
|
|
220
|
+
contour_lat=(("contour_pt",), np.asarray(contour_lat), {"units": "degrees_north", "description": "Boundary contour latitude."}),
|
|
221
|
+
),
|
|
222
|
+
coords=dict(
|
|
223
|
+
section=("section", np.arange(s_m.size), {"description": "Section index along boundary."}),
|
|
224
|
+
transect_length=("transect_length", transect_length, {"units": "m", "description": "Cross-shelf distance from boundary."}),
|
|
225
|
+
contour_pt=("contour_pt", np.arange(np.asarray(contour_lon).size), {"description": "Boundary contour vertex index."}),
|
|
226
|
+
),
|
|
227
|
+
attrs=dict(
|
|
228
|
+
crs=crs,
|
|
229
|
+
engine=engine,
|
|
230
|
+
sampling_method=method,
|
|
231
|
+
description=(
|
|
232
|
+
"Cross-shelf transects built from boundary_mask contour; transect_length=0 at "
|
|
233
|
+
"contour; +transect_length oriented toward deeper water; land masked to NaN."
|
|
234
|
+
),
|
|
235
|
+
deptho_convention="deptho is ocean depth in meters, positive downward; deptho<=0 treated as land/ice.",
|
|
236
|
+
transect_spacing=float(transect_spacing),
|
|
237
|
+
optional_dependency="xesmf is only required when engine='xesmf'",
|
|
238
|
+
),
|
|
239
|
+
)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from pyproj import Transformer
|
|
3
|
+
|
|
4
|
+
try:
|
|
5
|
+
import contourpy as cp
|
|
6
|
+
except ModuleNotFoundError:
|
|
7
|
+
cp = None
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# =============================================================================
|
|
11
|
+
# Isobath / contour helpers
|
|
12
|
+
# =============================================================================
|
|
13
|
+
|
|
14
|
+
def _contour_arclen(v):
|
|
15
|
+
"""
|
|
16
|
+
Arc-length of a contour vertex list v[:, (x,y)] in the same units as v (typically meters).
|
|
17
|
+
"""
|
|
18
|
+
dv = np.diff(v, axis=0)
|
|
19
|
+
return np.sum(np.sqrt((dv**2).sum(axis=1)))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def longest_boundary_contour(ds, boundary_mask, crs="EPSG:3031"):
|
|
23
|
+
"""
|
|
24
|
+
Extract the longest boundary contour of a mask.
|
|
25
|
+
|
|
26
|
+
What this does
|
|
27
|
+
--------------
|
|
28
|
+
- Treats `boundary_mask` as a binary field (0/1) and extracts its boundary by contouring at
|
|
29
|
+
the midpoint value (0.5), which corresponds to the 0/1 interface.
|
|
30
|
+
- Among all contour segments, selects the longest segment (by arc-length in a projected CRS).
|
|
31
|
+
- Returns both:
|
|
32
|
+
(i) the contour vertex list in projected meters (for geometry),
|
|
33
|
+
(ii) the same contour in lon/lat (for plotting).
|
|
34
|
+
|
|
35
|
+
Requirements
|
|
36
|
+
------------
|
|
37
|
+
- `ds` must contain ds["lon"], ds["lat"] as 2D arrays on the same grid as boundary_mask.
|
|
38
|
+
|
|
39
|
+
Parameters
|
|
40
|
+
----------
|
|
41
|
+
ds : xarray.Dataset
|
|
42
|
+
Must contain ds["lon"], ds["lat"] (2D).
|
|
43
|
+
boundary_mask : xarray.DataArray
|
|
44
|
+
Binary/boolean mask on the same horizontal grid as ds["lon"]/ds["lat"].
|
|
45
|
+
crs : str
|
|
46
|
+
Projected CRS used internally for length/geometry (meters).
|
|
47
|
+
|
|
48
|
+
Returns
|
|
49
|
+
-------
|
|
50
|
+
contour_xy : (N,2) ndarray
|
|
51
|
+
Contour vertices in projected coordinates (meters).
|
|
52
|
+
contour_lon, contour_lat : (N,) ndarrays
|
|
53
|
+
Same contour vertices in degrees (EPSG:4326).
|
|
54
|
+
"""
|
|
55
|
+
from .geometry import _project_lonlat
|
|
56
|
+
|
|
57
|
+
lon2d = ds["lon"]
|
|
58
|
+
lat2d = ds["lat"]
|
|
59
|
+
x2d, y2d = _project_lonlat(lon2d, lat2d, crs_out=crs)
|
|
60
|
+
|
|
61
|
+
m = boundary_mask.astype(float).fillna(0.0)
|
|
62
|
+
|
|
63
|
+
if cp is None:
|
|
64
|
+
raise ModuleNotFoundError(
|
|
65
|
+
"contourpy is required to extract boundary contours. Install contourpy."
|
|
66
|
+
)
|
|
67
|
+
cg = cp.contour_generator(x=x2d, y=y2d, z=np.asarray(m))
|
|
68
|
+
segs = cg.lines(0.5)
|
|
69
|
+
|
|
70
|
+
if len(segs) == 0:
|
|
71
|
+
raise ValueError("No contour found for boundary_mask (level=0.5).")
|
|
72
|
+
|
|
73
|
+
lens = np.array([_contour_arclen(v) for v in segs])
|
|
74
|
+
contour_xy = segs[int(np.argmax(lens))]
|
|
75
|
+
|
|
76
|
+
tf_inv = Transformer.from_crs(crs, "EPSG:4326", always_xy=True)
|
|
77
|
+
contour_lon, contour_lat = tf_inv.transform(contour_xy[:, 0], contour_xy[:, 1])
|
|
78
|
+
return contour_xy, np.asarray(contour_lon), np.asarray(contour_lat)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def longest_contour(ds, boundary_mask, crs="EPSG:3031"):
|
|
82
|
+
"""
|
|
83
|
+
Backwards-compatible alias for longest_boundary_contour.
|
|
84
|
+
"""
|
|
85
|
+
return longest_boundary_contour(ds, boundary_mask, crs=crs)
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import xarray as xr
|
|
3
|
+
import pandas as pd
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
import xesmf as xe
|
|
7
|
+
except ModuleNotFoundError:
|
|
8
|
+
xe = None
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# =============================================================================
|
|
12
|
+
# Sampling helpers
|
|
13
|
+
# =============================================================================
|
|
14
|
+
|
|
15
|
+
def _locstream_out(lon_t, lat_t):
|
|
16
|
+
"""
|
|
17
|
+
Build a LocStream output grid (1D 'loc') from (section, transect_length) lon/lat arrays.
|
|
18
|
+
"""
|
|
19
|
+
return xr.Dataset(
|
|
20
|
+
{"lon": xr.DataArray(np.asarray(lon_t).ravel(), dims=("loc",)),
|
|
21
|
+
"lat": xr.DataArray(np.asarray(lat_t).ravel(), dims=("loc",))}
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _as_dataset(obj):
|
|
26
|
+
"""
|
|
27
|
+
Normalize a DataArray/Dataset into a Dataset so multi-variable sampling is uniform.
|
|
28
|
+
|
|
29
|
+
Returns
|
|
30
|
+
-------
|
|
31
|
+
xarray.Dataset
|
|
32
|
+
"""
|
|
33
|
+
if isinstance(obj, xr.Dataset):
|
|
34
|
+
return obj
|
|
35
|
+
if isinstance(obj, xr.DataArray):
|
|
36
|
+
name = obj.name if obj.name is not None else "var"
|
|
37
|
+
return obj.to_dataset(name=name)
|
|
38
|
+
raise TypeError("obj must be xarray.DataArray or xarray.Dataset")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _sample_vars_xesmf(ds_in, obj, lon_t, lat_t, method, regridder=None):
|
|
42
|
+
"""
|
|
43
|
+
xESMF-based sampling at LocStream target points (optional dependency).
|
|
44
|
+
|
|
45
|
+
Parameters
|
|
46
|
+
----------
|
|
47
|
+
ds_in : xarray.Dataset
|
|
48
|
+
Must contain ds_in["lon"], ds_in["lat"] defining the source grid (typically 2D).
|
|
49
|
+
obj : xarray.DataArray or xarray.Dataset
|
|
50
|
+
Field(s) on the source grid to sample.
|
|
51
|
+
lon_t, lat_t : ndarray
|
|
52
|
+
Target lon/lat arrays with shape (section, transect_length).
|
|
53
|
+
method : str
|
|
54
|
+
xESMF method, e.g. "bilinear" or "nearest_s2d".
|
|
55
|
+
regridder : xesmf.Regridder or None
|
|
56
|
+
If provided, used directly.
|
|
57
|
+
|
|
58
|
+
Returns
|
|
59
|
+
-------
|
|
60
|
+
xarray.Dataset
|
|
61
|
+
Sampled output with trailing dimension "loc".
|
|
62
|
+
"""
|
|
63
|
+
if xe is None:
|
|
64
|
+
raise ModuleNotFoundError(
|
|
65
|
+
"engine='xesmf' requires the optional dependency 'xesmf'. "
|
|
66
|
+
"Install it (and its ESMF backend) to use xESMF-based sampling."
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
ds_out = _locstream_out(lon_t, lat_t)
|
|
70
|
+
if regridder is None:
|
|
71
|
+
regridder = xe.Regridder(
|
|
72
|
+
ds_in,
|
|
73
|
+
ds_out,
|
|
74
|
+
method,
|
|
75
|
+
locstream_out=True,
|
|
76
|
+
unmapped_to_nan=True,
|
|
77
|
+
)
|
|
78
|
+
return regridder(_as_dataset(obj))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _sample_vars_xarray(obj, lon_t, lat_t, method, lon_name="lon", lat_name="lat"):
|
|
82
|
+
"""
|
|
83
|
+
xarray.interp-based sampling at LocStream target points.
|
|
84
|
+
|
|
85
|
+
Important limitation
|
|
86
|
+
--------------------
|
|
87
|
+
This only applies to rectilinear grids where `obj` has 1D lon/lat coordinates
|
|
88
|
+
(tensor-product grid). It is not a curvilinear regridder.
|
|
89
|
+
|
|
90
|
+
method mapping
|
|
91
|
+
--------------
|
|
92
|
+
- method="bilinear" -> xarray method="linear"
|
|
93
|
+
|
|
94
|
+
Returns
|
|
95
|
+
-------
|
|
96
|
+
xarray.Dataset
|
|
97
|
+
Sampled output with trailing dimension "loc".
|
|
98
|
+
"""
|
|
99
|
+
ds_out = _locstream_out(lon_t, lat_t)
|
|
100
|
+
interp_method = "linear" if method == "bilinear" else method
|
|
101
|
+
|
|
102
|
+
out = xr.Dataset()
|
|
103
|
+
lon_loc = ds_out["lon"]
|
|
104
|
+
lat_loc = ds_out["lat"]
|
|
105
|
+
for vn, da in _as_dataset(obj).data_vars.items():
|
|
106
|
+
out[vn] = da.interp({lon_name: lon_loc, lat_name: lat_loc}, method=interp_method)
|
|
107
|
+
return out
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _sample_vars(
|
|
111
|
+
ds,
|
|
112
|
+
ds_in,
|
|
113
|
+
obj,
|
|
114
|
+
lon_t,
|
|
115
|
+
lat_t,
|
|
116
|
+
engine="xesmf",
|
|
117
|
+
method="bilinear",
|
|
118
|
+
regridder=None,
|
|
119
|
+
lon_name="lon",
|
|
120
|
+
lat_name="lat",
|
|
121
|
+
):
|
|
122
|
+
"""
|
|
123
|
+
Dispatch sampling to either xESMF or xarray.
|
|
124
|
+
|
|
125
|
+
Returns
|
|
126
|
+
-------
|
|
127
|
+
xarray.Dataset with trailing dimension "loc".
|
|
128
|
+
"""
|
|
129
|
+
if engine == "xesmf":
|
|
130
|
+
return _sample_vars_xesmf(
|
|
131
|
+
ds_in, obj, lon_t, lat_t, method,
|
|
132
|
+
regridder=regridder,
|
|
133
|
+
)
|
|
134
|
+
if engine == "xarray":
|
|
135
|
+
return _sample_vars_xarray(obj, lon_t, lat_t, method, lon_name=lon_name, lat_name=lat_name)
|
|
136
|
+
raise ValueError("engine must be 'xesmf' or 'xarray'")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _unstack_loc(vloc_ds, transect_length, s_m):
|
|
140
|
+
"""
|
|
141
|
+
Convert (..., loc) output back to (..., section, transect_length) using a MultiIndex.
|
|
142
|
+
|
|
143
|
+
Parameters
|
|
144
|
+
----------
|
|
145
|
+
vloc_ds : xarray.Dataset
|
|
146
|
+
Output from _sample_vars with a trailing 'loc' dimension.
|
|
147
|
+
transect_length : (nx,) ndarray
|
|
148
|
+
Cross-shelf coordinate values.
|
|
149
|
+
s_m : (nsec,) ndarray
|
|
150
|
+
Along-boundary distances for sections.
|
|
151
|
+
|
|
152
|
+
Returns
|
|
153
|
+
-------
|
|
154
|
+
xarray.Dataset
|
|
155
|
+
Same data with dimensions (..., section, transect_length), plus coord s_m(section).
|
|
156
|
+
"""
|
|
157
|
+
nsec = s_m.size
|
|
158
|
+
# MultiIndex flattens the (section, transect_length) grid into a 1D "loc" index.
|
|
159
|
+
mi = pd.MultiIndex.from_product([np.arange(nsec), transect_length], names=("section", "transect_length"))
|
|
160
|
+
# Explicitly wrap the MultiIndex to keep xarray's coordinate behavior stable.
|
|
161
|
+
mindex_coords = xr.Coordinates.from_pandas_multiindex(mi, "loc")
|
|
162
|
+
return (
|
|
163
|
+
vloc_ds
|
|
164
|
+
# The loc dimension is a flattened (section, transect_length) grid.
|
|
165
|
+
.assign_coords(mindex_coords)
|
|
166
|
+
# Unstack loc back into 2D section/transect_length dimensions.
|
|
167
|
+
.unstack("loc")
|
|
168
|
+
# Keep along-boundary distance as a section coordinate.
|
|
169
|
+
.assign_coords(s_m=("section", np.asarray(s_m)))
|
|
170
|
+
)
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import xarray as xr
|
|
3
|
+
from pyproj import Transformer
|
|
4
|
+
|
|
5
|
+
from .geometry import (
|
|
6
|
+
_build_transect_geometry_dataset,
|
|
7
|
+
_contour_outward_sign,
|
|
8
|
+
_make_transect_lonlat,
|
|
9
|
+
_resample_contour_xy,
|
|
10
|
+
_tangent_normal_xy,
|
|
11
|
+
)
|
|
12
|
+
from .isobath import longest_boundary_contour
|
|
13
|
+
from .sampling import (
|
|
14
|
+
_sample_vars,
|
|
15
|
+
_sample_vars_xarray,
|
|
16
|
+
_sample_vars_xesmf,
|
|
17
|
+
_unstack_loc,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# =============================================================================
|
|
22
|
+
# Public API
|
|
23
|
+
# =============================================================================
|
|
24
|
+
|
|
25
|
+
def xesmf_interpolation(ds_in, obj, lon_t, lat_t, method="bilinear", regridder=None):
|
|
26
|
+
"""
|
|
27
|
+
Public wrapper around the xESMF LocStream sampling helper.
|
|
28
|
+
"""
|
|
29
|
+
return _sample_vars_xesmf(
|
|
30
|
+
ds_in,
|
|
31
|
+
obj,
|
|
32
|
+
lon_t,
|
|
33
|
+
lat_t,
|
|
34
|
+
method,
|
|
35
|
+
regridder=regridder,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def xarray_interpolation(obj, lon_t, lat_t, method="bilinear", lon_name="lon", lat_name="lat"):
|
|
40
|
+
"""
|
|
41
|
+
Public wrapper around the xarray.interp sampling helper.
|
|
42
|
+
"""
|
|
43
|
+
return _sample_vars_xarray(obj, lon_t, lat_t, method, lon_name=lon_name, lat_name=lat_name)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def cross_shelf_transects(
|
|
47
|
+
ds,
|
|
48
|
+
var, # str | list[str]
|
|
49
|
+
boundary_mask, # DataArray; binary mask where the 0/1 interface defines transect_length=0
|
|
50
|
+
transect_length=np.arange(0.0, 200e3 + 2e3, 2e3),
|
|
51
|
+
transect_spacing=10e3,
|
|
52
|
+
crs="EPSG:3031",
|
|
53
|
+
engine="xesmf",
|
|
54
|
+
method="bilinear",
|
|
55
|
+
lon_name="lon",
|
|
56
|
+
lat_name="lat",
|
|
57
|
+
return_geometry=True,
|
|
58
|
+
):
|
|
59
|
+
"""
|
|
60
|
+
Construct and sample cross-shelf transects from a mask boundary.
|
|
61
|
+
|
|
62
|
+
Parameters
|
|
63
|
+
----------
|
|
64
|
+
ds : xarray.Dataset
|
|
65
|
+
Dataset containing 2D lon/lat coordinates and bathymetry.
|
|
66
|
+
var : str or list[str]
|
|
67
|
+
Variable name(s) in ds to sample along transects.
|
|
68
|
+
boundary_mask : xarray.DataArray
|
|
69
|
+
Binary mask whose 0/1 interface defines the transect anchor line
|
|
70
|
+
(transect_length = 0). The interface is extracted by contouring
|
|
71
|
+
at the midpoint value (0.5).
|
|
72
|
+
transect_length : 1D array-like
|
|
73
|
+
Cross-shelf distances (meters). Convention is transect_length >= 0 (offshore direction).
|
|
74
|
+
transect_spacing : float
|
|
75
|
+
Target spacing along the boundary between successive sections (meters).
|
|
76
|
+
crs : str
|
|
77
|
+
Projected CRS used for along-boundary geometry (meters), e.g., "EPSG:3031"
|
|
78
|
+
for Antarctica, "EPSG:3413" for the Arctic, or "EPSG:3857" for a more
|
|
79
|
+
general global projection.
|
|
80
|
+
engine : {"xesmf", "xarray"}
|
|
81
|
+
Sampling backend. "xesmf" supports curvilinear grids; "xarray" requires 1D lon/lat.
|
|
82
|
+
method : str
|
|
83
|
+
Interpolation method for sampling (e.g., "bilinear", "nearest_s2d").
|
|
84
|
+
lon_name, lat_name : str
|
|
85
|
+
Coordinate names for lon/lat when engine="xarray".
|
|
86
|
+
return_geometry : bool
|
|
87
|
+
If True, return the geometry dataset in addition to sampled values.
|
|
88
|
+
|
|
89
|
+
Returns
|
|
90
|
+
-------
|
|
91
|
+
xshelf : xarray.DataArray or xarray.Dataset
|
|
92
|
+
Sampled values with dims (..., section, transect_length). If `var` is a string, returns a DataArray;
|
|
93
|
+
if `var` is a list, returns a Dataset.
|
|
94
|
+
geometry : xarray.Dataset
|
|
95
|
+
Returned only if return_geometry=True. Contains:
|
|
96
|
+
- anchor lon0/lat0 (section)
|
|
97
|
+
- along-boundary distance s_m (section)
|
|
98
|
+
- normals nx/ny (section)
|
|
99
|
+
- sampled depth along transects depth_xshelf (section, transect_length)
|
|
100
|
+
- boundary contour lon/lat (contour_pt)
|
|
101
|
+
|
|
102
|
+
Notes
|
|
103
|
+
-----
|
|
104
|
+
The boundary is extracted as the longest contour of `boundary_mask` at the 0/1
|
|
105
|
+
midpoint (0.5), resampled at `transect_spacing`. Transects are oriented by
|
|
106
|
+
contour winding so +transect_length points away from the enclosed mask, and
|
|
107
|
+
land/ice (deptho <= 0) are masked to NaN.
|
|
108
|
+
"""
|
|
109
|
+
if ("lon" not in ds) or ("lat" not in ds):
|
|
110
|
+
raise KeyError(
|
|
111
|
+
"cross_shelf_transects requires ds['lon'] and ds['lat'] as 2D horizontal coordinates. "
|
|
112
|
+
"Rename your coordinate variables to 'lon'/'lat'."
|
|
113
|
+
)
|
|
114
|
+
if "deptho" not in ds:
|
|
115
|
+
raise KeyError(
|
|
116
|
+
"cross_shelf_transects requires ds['deptho'] (ocean depth in meters, positive downward). "
|
|
117
|
+
"Rename your bathymetry variable to 'deptho'."
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
ds_in = ds[["lon", "lat"]] if engine == "xesmf" else None
|
|
121
|
+
|
|
122
|
+
vars = [var] if isinstance(var, (str, bytes)) else list(var)
|
|
123
|
+
X = np.asarray(transect_length, dtype=float)
|
|
124
|
+
|
|
125
|
+
# (1) Boundary contour and (2) resampled sections along it
|
|
126
|
+
contour_xy, contour_lon, contour_lat = longest_boundary_contour(ds, boundary_mask, crs=crs)
|
|
127
|
+
contour_xy, s_m = _resample_contour_xy(contour_xy, transect_spacing)
|
|
128
|
+
_, n = _tangent_normal_xy(contour_xy)
|
|
129
|
+
normal_sign = _contour_outward_sign(contour_xy)
|
|
130
|
+
|
|
131
|
+
x0, y0 = contour_xy[:, 0], contour_xy[:, 1]
|
|
132
|
+
nx, ny = n[:, 0].copy() * normal_sign, n[:, 1].copy() * normal_sign
|
|
133
|
+
|
|
134
|
+
tf_inv = Transformer.from_crs(crs, "EPSG:4326", always_xy=True)
|
|
135
|
+
deptho_filled = ds["deptho"].fillna(0.0)
|
|
136
|
+
|
|
137
|
+
# (3) Final transects
|
|
138
|
+
lon_t, lat_t = _make_transect_lonlat(tf_inv, x0, y0, nx, ny, X)
|
|
139
|
+
|
|
140
|
+
# (6) Sample requested variables
|
|
141
|
+
obj_vars = [vn for vn in vars if vn != "deptho"]
|
|
142
|
+
obj = ds[obj_vars + ["deptho"]].where(deptho_filled > 0)
|
|
143
|
+
vloc = _sample_vars(
|
|
144
|
+
ds, ds_in, obj, lon_t, lat_t,
|
|
145
|
+
engine=engine, method=method, regridder=None,
|
|
146
|
+
lon_name=lon_name, lat_name=lat_name,
|
|
147
|
+
)
|
|
148
|
+
vloc = _unstack_loc(vloc, X, s_m)
|
|
149
|
+
dloc = vloc["deptho"].values
|
|
150
|
+
wet = np.isfinite(dloc) & (dloc > 0)
|
|
151
|
+
vloc = vloc.where(wet)
|
|
152
|
+
if "deptho" not in vars:
|
|
153
|
+
vloc = vloc.drop_vars("deptho")
|
|
154
|
+
vloc = vloc.assign_coords(
|
|
155
|
+
depth=(
|
|
156
|
+
("section", "transect_length"),
|
|
157
|
+
np.where(wet, dloc, np.nan),
|
|
158
|
+
{"units": "m", "description": "Sampled depth along transects."},
|
|
159
|
+
),
|
|
160
|
+
lon=(
|
|
161
|
+
("section", "transect_length"),
|
|
162
|
+
lon_t,
|
|
163
|
+
{"units": "degrees_east", "description": "Transect longitude."},
|
|
164
|
+
),
|
|
165
|
+
lat=(
|
|
166
|
+
("section", "transect_length"),
|
|
167
|
+
lat_t,
|
|
168
|
+
{"units": "degrees_north", "description": "Transect latitude."},
|
|
169
|
+
),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
xshelf = vloc[vars[0]] if len(vars) == 1 else vloc
|
|
173
|
+
if not return_geometry:
|
|
174
|
+
return xshelf
|
|
175
|
+
|
|
176
|
+
lon0, lat0 = tf_inv.transform(x0, y0)
|
|
177
|
+
geometry = _build_transect_geometry_dataset(
|
|
178
|
+
lon0=lon0,
|
|
179
|
+
lat0=lat0,
|
|
180
|
+
nx=nx,
|
|
181
|
+
ny=ny,
|
|
182
|
+
dloc=dloc,
|
|
183
|
+
contour_lon=contour_lon,
|
|
184
|
+
contour_lat=contour_lat,
|
|
185
|
+
s_m=s_m,
|
|
186
|
+
transect_length=X,
|
|
187
|
+
crs=crs,
|
|
188
|
+
engine=engine,
|
|
189
|
+
method=method,
|
|
190
|
+
transect_spacing=transect_spacing,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
return xshelf, geometry
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xshelftransects
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary:
|
|
5
|
+
Author: Anthony Meza
|
|
6
|
+
Author-email: 64243783+anthony-meza@users.noreply.github.com
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
13
|
+
Requires-Dist: cartopy
|
|
14
|
+
Requires-Dist: contourpy
|
|
15
|
+
Requires-Dist: intake (>=2.0.8,<3.0.0)
|
|
16
|
+
Requires-Dist: intake-esm (>=2025.12.12,<2026.0.0)
|
|
17
|
+
Requires-Dist: intake-geopandas[all] (>=0.4.0,<0.5.0)
|
|
18
|
+
Requires-Dist: intake-xarray[all] (>=2.0.0,<3.0.0)
|
|
19
|
+
Requires-Dist: netcdf4 (<1.7.3)
|
|
20
|
+
Requires-Dist: pyarrow (<=19.0.0)
|
|
21
|
+
Requires-Dist: pyproj (<3.7)
|
|
22
|
+
Requires-Dist: requests (>=2.32.5,<3.0.0)
|
|
23
|
+
Requires-Dist: scipy (<=1.16.0)
|
|
24
|
+
Requires-Dist: xarray[complete]
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
xshelftransects
|
|
28
|
+
==============
|
|
29
|
+
|
|
30
|
+
Build and sample cross-shelf transects from a binary boundary mask on model grids. Useful for understanding gravity current and coastally-trapped waves, which are defined by their orientation and proximity to the continental shelves.
|
|
31
|
+
|
|
32
|
+
Install
|
|
33
|
+
-------
|
|
34
|
+
```bash
|
|
35
|
+
conda create --name xshelftransects python=3.11
|
|
36
|
+
conda activate xshelftransects
|
|
37
|
+
pip install -e .
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Optional dependencies
|
|
41
|
+
---------------------
|
|
42
|
+
- xesmf (for `engine="xesmf"` on curvilinear grids)
|
|
43
|
+
- scipy (for Theil-Sen based transect orientation)
|
|
44
|
+
|
|
45
|
+
Basic usage
|
|
46
|
+
-----------
|
|
47
|
+
```python
|
|
48
|
+
import numpy as np
|
|
49
|
+
import xshelftransects
|
|
50
|
+
|
|
51
|
+
transects, geometry = xshelftransects.cross_shelf_transects(
|
|
52
|
+
ds,
|
|
53
|
+
"thetao",
|
|
54
|
+
boundary_mask, # binary mask: 0/1 interface is transect_length=0
|
|
55
|
+
transect_length=np.arange(0.0, 200e3 + 2e3, 2e3),
|
|
56
|
+
transect_spacing=10e3,
|
|
57
|
+
crs="EPSG:3031",
|
|
58
|
+
engine="xesmf",
|
|
59
|
+
)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Inputs and expectations
|
|
63
|
+
- `ds["lon"]`, `ds["lat"]`: 2D longitude/latitude arrays.
|
|
64
|
+
- `ds["deptho"]`: ocean depth in meters, positive downward (deptho <= 0 treated as land/ice).
|
|
65
|
+
- `boundary_mask`: DataArray on the same grid as lon/lat; 0/1 interface is the boundary.
|
|
66
|
+
- `crs`: projected CRS in meters (e.g., "EPSG:3031" Antarctica, "EPSG:3413" Arctic,
|
|
67
|
+
"EPSG:3857" global).
|
|
68
|
+
|
|
69
|
+
Outputs
|
|
70
|
+
- `transects`: sampled values with dims (..., section, transect_length) and lon/lat coords.
|
|
71
|
+
- `geometry`: Dataset with anchor points, normals, and boundary contour.
|
|
72
|
+
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
xshelftransects/__init__.py,sha256=pu9xG_DqDwJSYOEpbCJITMRRcmO8RoVcrBl-P00_iPs,897
|
|
2
|
+
xshelftransects/geometry.py,sha256=IFcX1XCub_BqH6a9miLF5LUq3B9Ud1Bvmhxs3Y0cFYk,9195
|
|
3
|
+
xshelftransects/isobath.py,sha256=cdaXgSvnIqrnzhv0HemN1BlopdVm_hOOgnpoBK3BdIY,2818
|
|
4
|
+
xshelftransects/sampling.py,sha256=40apbhLkaaoG0tUmctDAl_MOcPoZ3SXFyu_1d7qN7wY,5143
|
|
5
|
+
xshelftransects/transects.py,sha256=KfQCkyRB9F9EWbh5ghEgdhHUm7qX9Uvq3Jk2JF9A8a0,6725
|
|
6
|
+
xshelftransects-0.1.1.dist-info/METADATA,sha256=t1uMBe2-7kPNcfW6Ux5YDsEnz03nq0gR0yqiq28Se5w,2359
|
|
7
|
+
xshelftransects-0.1.1.dist-info/WHEEL,sha256=3ny-bZhpXrU6vSQ1UPG34FoxZBp3lVcvK0LkgUz6VLk,88
|
|
8
|
+
xshelftransects-0.1.1.dist-info/RECORD,,
|