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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.3.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any