ctdcast 0.1.0__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.
- ctdcast/__init__.py +25 -0
- ctdcast/__main__.py +6 -0
- ctdcast/_version.py +9 -0
- ctdcast/analysis/__init__.py +21 -0
- ctdcast/analysis/bathymetry.py +200 -0
- ctdcast/analysis/derive.py +407 -0
- ctdcast/analysis/geometry.py +68 -0
- ctdcast/cli/__init__.py +62 -0
- ctdcast/cli/convert.py +229 -0
- ctdcast/cli/draft.py +278 -0
- ctdcast/cli/init.py +1234 -0
- ctdcast/cli/process.py +392 -0
- ctdcast/cli/report.py +293 -0
- ctdcast/cli/run.py +164 -0
- ctdcast/cli/validate.py +244 -0
- ctdcast/config/__init__.py +1 -0
- ctdcast/config/loader.py +67 -0
- ctdcast/config/parameters.py +504 -0
- ctdcast/converters.py +27 -0
- ctdcast/ctdcast.mplstyle +24 -0
- ctdcast/identity.py +132 -0
- ctdcast/plotters/__init__.py +1 -0
- ctdcast/plotters/plots.py +1815 -0
- ctdcast/processors/__init__.py +129 -0
- ctdcast/processors/profiles.py +380 -0
- ctdcast/processors/qc.py +113 -0
- ctdcast/processors/stage1.py +213 -0
- ctdcast/processors/stage2.py +346 -0
- ctdcast/processors/stage3.py +138 -0
- ctdcast/readers/__init__.py +1 -0
- ctdcast/readers/cnv.py +50 -0
- ctdcast/readers/ladcp.py +55 -0
- ctdcast/readers/metadata.py +101 -0
- ctdcast/reports/__init__.py +5 -0
- ctdcast/reports/_cast.py +278 -0
- ctdcast/reports/_chrome.py +28 -0
- ctdcast/reports/_css.py +120 -0
- ctdcast/reports/_env.py +45 -0
- ctdcast/reports/_format.py +34 -0
- ctdcast/reports/_index.py +1192 -0
- ctdcast/reports/_leaflet.py +544 -0
- ctdcast/reports/_plots.py +655 -0
- ctdcast/reports/_section.py +394 -0
- ctdcast/reports/_timeseries.py +335 -0
- ctdcast/reports/leaflet/leaflet.min.css +8 -0
- ctdcast/reports/leaflet/leaflet.min.js +12 -0
- ctdcast/reports/templates/base.html +38 -0
- ctdcast/reports/templates/cast.html +203 -0
- ctdcast/reports/templates/casts.html +100 -0
- ctdcast/reports/templates/index.html +73 -0
- ctdcast/reports/templates/leaflet.html +165 -0
- ctdcast/reports/templates/section.html +147 -0
- ctdcast/reports/templates/sections.html +76 -0
- ctdcast/reports/templates/timeseries.html +80 -0
- ctdcast/reports/templates/timeseries_page.html +136 -0
- ctdcast/writers/__init__.py +1 -0
- ctdcast/writers/netcdf.py +89 -0
- ctdcast-0.1.0.dist-info/METADATA +192 -0
- ctdcast-0.1.0.dist-info/RECORD +63 -0
- ctdcast-0.1.0.dist-info/WHEEL +5 -0
- ctdcast-0.1.0.dist-info/entry_points.txt +2 -0
- ctdcast-0.1.0.dist-info/licenses/LICENSE +21 -0
- ctdcast-0.1.0.dist-info/top_level.txt +1 -0
ctdcast/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""ctdcast — CTD data processing and reporting for shipboard oceanography.
|
|
2
|
+
|
|
3
|
+
Subpackages
|
|
4
|
+
-----------
|
|
5
|
+
analysis: TEOS-10, cast geometry, GEBCO bathymetry (pure computation).
|
|
6
|
+
processors: per-cast and cruise-level processing pipeline (all stages).
|
|
7
|
+
readers: format readers (LADCP ``.mat``, CNV shim, sensor metadata).
|
|
8
|
+
reports: HTML page builders and the public ``report()`` entry point.
|
|
9
|
+
writers: output writers (CF-compliant netCDF).
|
|
10
|
+
|
|
11
|
+
Public API
|
|
12
|
+
----------
|
|
13
|
+
report(nc_dir, profiles_path, section_yaml, out_dir, force=False)
|
|
14
|
+
stage1(cnv_dir, nc_dir, ...)
|
|
15
|
+
profiles(nc_dir, profiles_path, ...)
|
|
16
|
+
process(stage=None, *, proc_dir, force=False, **kw)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from ctdcast._version import __version__
|
|
20
|
+
from ctdcast.processors import process
|
|
21
|
+
from ctdcast.processors.profiles import build_profiles as profiles
|
|
22
|
+
from ctdcast.processors.stage1 import stage1
|
|
23
|
+
from ctdcast.reports._index import report
|
|
24
|
+
|
|
25
|
+
__all__ = ["__version__", "process", "profiles", "report", "stage1"]
|
ctdcast/__main__.py
ADDED
ctdcast/_version.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Package version, resolved from installed metadata."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError
|
|
4
|
+
from importlib.metadata import version as _pkg_version
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
__version__: str = _pkg_version("ctdcast")
|
|
8
|
+
except PackageNotFoundError:
|
|
9
|
+
__version__ = "0.1.0.dev0"
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Shared scientific analysis: TEOS-10 derived variables, cast geometry, GEBCO bathymetry."""
|
|
2
|
+
|
|
3
|
+
from ctdcast.analysis.derive import (
|
|
4
|
+
derive_AOU,
|
|
5
|
+
derive_CT,
|
|
6
|
+
derive_SA,
|
|
7
|
+
derive_salinity,
|
|
8
|
+
derive_sigma0,
|
|
9
|
+
derive_teos10,
|
|
10
|
+
derive_teos10_profiles,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"derive_AOU",
|
|
15
|
+
"derive_CT",
|
|
16
|
+
"derive_SA",
|
|
17
|
+
"derive_salinity",
|
|
18
|
+
"derive_sigma0",
|
|
19
|
+
"derive_teos10",
|
|
20
|
+
"derive_teos10_profiles",
|
|
21
|
+
]
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""GEBCO bathymetry loading and interpolation.
|
|
2
|
+
|
|
3
|
+
Pure computation — no matplotlib, no HTML. GEBCO stores elevation as negative
|
|
4
|
+
below sea level; this module returns depth as positive below sea level
|
|
5
|
+
(``depth = -elevation``).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
import xarray as xr
|
|
14
|
+
|
|
15
|
+
# Module-level numpy cache: populated by preload_gebco() at report start.
|
|
16
|
+
# Maps str(path) → (all_lons, all_lats, all_depth) numpy arrays for the cruise area.
|
|
17
|
+
# Once populated, load_gebco subsets with numpy (zero disk I/O).
|
|
18
|
+
_GEBCO_CACHE: dict[str, tuple[np.ndarray, np.ndarray, np.ndarray]] = {}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def preload_gebco(
|
|
22
|
+
path: Path,
|
|
23
|
+
lat_lo: float,
|
|
24
|
+
lat_hi: float,
|
|
25
|
+
lon_lo: float,
|
|
26
|
+
lon_hi: float,
|
|
27
|
+
margin: float = 1.0,
|
|
28
|
+
) -> bool:
|
|
29
|
+
"""Load a GEBCO region into memory once for the cruise area.
|
|
30
|
+
|
|
31
|
+
Call this at report-generation start with the full lat/lon extent of all
|
|
32
|
+
casts. Subsequent ``load_gebco`` calls then subset from numpy arrays
|
|
33
|
+
(no disk I/O) instead of reopening the file for every map figure.
|
|
34
|
+
|
|
35
|
+
Parameters
|
|
36
|
+
----------
|
|
37
|
+
path:
|
|
38
|
+
Path to GEBCO netCDF file.
|
|
39
|
+
lat_lo, lat_hi, lon_lo, lon_hi:
|
|
40
|
+
Bounding box of the cruise area.
|
|
41
|
+
margin:
|
|
42
|
+
Extra degrees around the bounding box. Default 1.0 deg.
|
|
43
|
+
|
|
44
|
+
Returns
|
|
45
|
+
-------
|
|
46
|
+
bool
|
|
47
|
+
True if the file was found and cached successfully.
|
|
48
|
+
"""
|
|
49
|
+
if not Path(path).exists():
|
|
50
|
+
return False
|
|
51
|
+
try:
|
|
52
|
+
bathy = xr.open_dataset(path, engine="netcdf4")
|
|
53
|
+
lon_dim = "lon" if "lon" in bathy.coords else "longitude"
|
|
54
|
+
lat_dim = "lat" if "lat" in bathy.coords else "latitude"
|
|
55
|
+
sub = bathy.sel(
|
|
56
|
+
{
|
|
57
|
+
lon_dim: slice(lon_lo - margin, lon_hi + margin),
|
|
58
|
+
lat_dim: slice(lat_lo - margin, lat_hi + margin),
|
|
59
|
+
}
|
|
60
|
+
)
|
|
61
|
+
lons = sub[lon_dim].values.copy()
|
|
62
|
+
lats = sub[lat_dim].values.copy()
|
|
63
|
+
depth = -sub["elevation"].values.copy() # GEBCO: negative = below sea level
|
|
64
|
+
bathy.close()
|
|
65
|
+
_GEBCO_CACHE[str(path)] = (lons, lats, depth)
|
|
66
|
+
return True
|
|
67
|
+
except Exception: # noqa: BLE001
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def load_gebco(
|
|
72
|
+
lat_lo: float,
|
|
73
|
+
lat_hi: float,
|
|
74
|
+
lon_lo: float,
|
|
75
|
+
lon_hi: float,
|
|
76
|
+
margin: float = 0.05,
|
|
77
|
+
path: Path | None = None,
|
|
78
|
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray] | None:
|
|
79
|
+
"""Return a GEBCO subset as (lons, lats, depth_m) or None if unavailable.
|
|
80
|
+
|
|
81
|
+
If ``preload_gebco`` has been called for *path*, subsets from the in-memory
|
|
82
|
+
numpy cache (fast). Otherwise opens the file from disk (slow).
|
|
83
|
+
|
|
84
|
+
Parameters
|
|
85
|
+
----------
|
|
86
|
+
path:
|
|
87
|
+
Path to GEBCO_2025.nc. Pass ``plots.GEBCO_PATH`` from the caller.
|
|
88
|
+
Returns None if not provided or file not found.
|
|
89
|
+
"""
|
|
90
|
+
if path is None or not Path(path).exists():
|
|
91
|
+
return None
|
|
92
|
+
path_str = str(path)
|
|
93
|
+
try:
|
|
94
|
+
if path_str in _GEBCO_CACHE:
|
|
95
|
+
all_lons, all_lats, all_depth = _GEBCO_CACHE[path_str]
|
|
96
|
+
lon_mask = (all_lons >= lon_lo - margin) & (all_lons <= lon_hi + margin)
|
|
97
|
+
lat_mask = (all_lats >= lat_lo - margin) & (all_lats <= lat_hi + margin)
|
|
98
|
+
lons = all_lons[lon_mask]
|
|
99
|
+
lats = all_lats[lat_mask]
|
|
100
|
+
if lons.size == 0 or lats.size == 0:
|
|
101
|
+
return None
|
|
102
|
+
depth = all_depth[lat_mask][:, lon_mask]
|
|
103
|
+
return lons, lats, depth
|
|
104
|
+
# Fallback when preload_gebco was not called: open file directly.
|
|
105
|
+
bathy = xr.open_dataset(path, engine="netcdf4")
|
|
106
|
+
lon_dim = "lon" if "lon" in bathy.coords else "longitude"
|
|
107
|
+
lat_dim = "lat" if "lat" in bathy.coords else "latitude"
|
|
108
|
+
sub = bathy.sel(
|
|
109
|
+
{
|
|
110
|
+
lon_dim: slice(lon_lo - margin, lon_hi + margin),
|
|
111
|
+
lat_dim: slice(lat_lo - margin, lat_hi + margin),
|
|
112
|
+
}
|
|
113
|
+
)
|
|
114
|
+
lons = sub[lon_dim].values
|
|
115
|
+
lats = sub[lat_dim].values
|
|
116
|
+
depth = -sub["elevation"].values # GEBCO: negative = below sea level
|
|
117
|
+
bathy.close()
|
|
118
|
+
return lons, lats, depth
|
|
119
|
+
except Exception: # noqa: BLE001
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def interpolate_bathy_at_casts(
|
|
124
|
+
lats: list[float],
|
|
125
|
+
lons: list[float],
|
|
126
|
+
path: Path | None = None,
|
|
127
|
+
) -> np.ndarray | None:
|
|
128
|
+
"""Return GEBCO water depth (m, positive below sea level) at each cast position.
|
|
129
|
+
|
|
130
|
+
Uses bilinear interpolation via xarray. Returns ``None`` if GEBCO is not
|
|
131
|
+
available or on any error. Land points (elevation > 0) are clamped to 0.
|
|
132
|
+
"""
|
|
133
|
+
if path is None or not Path(path).exists():
|
|
134
|
+
return None
|
|
135
|
+
try:
|
|
136
|
+
lats_arr = np.asarray(lats, dtype=float)
|
|
137
|
+
lons_arr = np.asarray(lons, dtype=float)
|
|
138
|
+
finite = np.isfinite(lats_arr) & np.isfinite(lons_arr)
|
|
139
|
+
if not finite.any():
|
|
140
|
+
return None
|
|
141
|
+
lat_lo = float(lats_arr[finite].min())
|
|
142
|
+
lat_hi = float(lats_arr[finite].max())
|
|
143
|
+
lon_lo = float(lons_arr[finite].min())
|
|
144
|
+
lon_hi = float(lons_arr[finite].max())
|
|
145
|
+
gebco = load_gebco(lat_lo, lat_hi, lon_lo, lon_hi, margin=0.1, path=path)
|
|
146
|
+
if gebco is None:
|
|
147
|
+
return None
|
|
148
|
+
lons_g, lats_g, depth_g = gebco
|
|
149
|
+
# depth_g shape: (N_lat, N_lon) — positive = below sea level
|
|
150
|
+
da = xr.DataArray(
|
|
151
|
+
depth_g,
|
|
152
|
+
dims=["lat", "lon"],
|
|
153
|
+
coords={"lat": lats_g, "lon": lons_g},
|
|
154
|
+
)
|
|
155
|
+
da = da.sortby("lat").sortby("lon")
|
|
156
|
+
result = da.interp(
|
|
157
|
+
lat=xr.DataArray(lats_arr, dims="n"),
|
|
158
|
+
lon=xr.DataArray(lons_arr, dims="n"),
|
|
159
|
+
method="linear",
|
|
160
|
+
)
|
|
161
|
+
return np.maximum(result.values.astype(float), 0.0)
|
|
162
|
+
except Exception: # noqa: BLE001
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def dense_bathy_along_track(
|
|
167
|
+
lats: list[float],
|
|
168
|
+
lons: list[float],
|
|
169
|
+
x_vals: np.ndarray,
|
|
170
|
+
path: Path | None = None,
|
|
171
|
+
n_per_segment: int = 20,
|
|
172
|
+
) -> tuple[np.ndarray | None, np.ndarray | None]:
|
|
173
|
+
"""Return ``(dense_x, dense_depths)`` interpolated between cast positions.
|
|
174
|
+
|
|
175
|
+
Generates *n_per_segment* equally-spaced points along each segment between
|
|
176
|
+
consecutive casts, giving a smooth GEBCO bathymetry fill rather than the
|
|
177
|
+
stepped appearance produced by one sample per cast. Returns ``(None, None)``
|
|
178
|
+
when GEBCO is unavailable or fewer than two cast positions are supplied.
|
|
179
|
+
"""
|
|
180
|
+
if path is None or not Path(path).exists() or len(lats) < 2:
|
|
181
|
+
return None, None
|
|
182
|
+
try:
|
|
183
|
+
dense_lats: list[float] = []
|
|
184
|
+
dense_lons: list[float] = []
|
|
185
|
+
dense_x: list[float] = []
|
|
186
|
+
for i in range(len(lats) - 1):
|
|
187
|
+
t = np.linspace(0.0, 1.0, n_per_segment, endpoint=False)
|
|
188
|
+
dense_lats.extend((lats[i] + t * (lats[i + 1] - lats[i])).tolist())
|
|
189
|
+
dense_lons.extend((lons[i] + t * (lons[i + 1] - lons[i])).tolist())
|
|
190
|
+
dense_x.extend((x_vals[i] + t * (x_vals[i + 1] - x_vals[i])).tolist())
|
|
191
|
+
dense_lats.append(lats[-1])
|
|
192
|
+
dense_lons.append(lons[-1])
|
|
193
|
+
dense_x.append(float(x_vals[-1]))
|
|
194
|
+
|
|
195
|
+
depths = interpolate_bathy_at_casts(dense_lats, dense_lons, path=path)
|
|
196
|
+
if depths is None:
|
|
197
|
+
return None, None
|
|
198
|
+
return np.array(dense_x), depths
|
|
199
|
+
except Exception: # noqa: BLE001
|
|
200
|
+
return None, None
|
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
"""Derived physical quantities computed from raw CTD measurements.
|
|
2
|
+
|
|
3
|
+
All functions use GSW — the same library oceanographers use directly.
|
|
4
|
+
|
|
5
|
+
Per-cast (1-D, dim=time) functions
|
|
6
|
+
------------------------------------
|
|
7
|
+
derive_salinity SP from conductivity/temperature/pressure
|
|
8
|
+
derive_SA Absolute Salinity from SP/pressure/lat/lon
|
|
9
|
+
derive_CT Conservative Temperature from SA/in-situ-T/pressure
|
|
10
|
+
derive_sigma0 Potential density anomaly from SA/CT
|
|
11
|
+
derive_AOU Apparent Oxygen Utilization from oxsat_1 (% sat)
|
|
12
|
+
derive_teos10 Convenience: SA + CT + sigma0 + optional O2 unit conversion
|
|
13
|
+
|
|
14
|
+
Profiles (2-D, dims N_PROF × pressure) functions
|
|
15
|
+
--------------------------------------------------
|
|
16
|
+
derive_teos10_profiles SA + CT + sigma0 for compiled profiles datasets
|
|
17
|
+
|
|
18
|
+
Output variable names match the VARIABLES registry in
|
|
19
|
+
``ctdcast.config.parameters``: ``absolute_salinity``,
|
|
20
|
+
``conservative_temperature``, ``sigma0``.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import warnings
|
|
26
|
+
|
|
27
|
+
import gsw
|
|
28
|
+
import numpy as np
|
|
29
|
+
import xarray as xr
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# SP from C/T/P
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def derive_salinity(ds: xr.Dataset) -> xr.Dataset:
|
|
37
|
+
"""Re-compute practical salinity from conductivity, temperature, pressure.
|
|
38
|
+
|
|
39
|
+
Uses ``gsw.SP_from_C`` with conductivity in mS/cm (seasenselib stores in
|
|
40
|
+
S/m; conversion: ``C_mS_cm = conductivity_1 * 10``). Call this after any
|
|
41
|
+
conductivity calibration so that ``salinity_1`` (and ``salinity_2`` if
|
|
42
|
+
``conductivity_2`` is present) reflects the calibrated conductivity.
|
|
43
|
+
|
|
44
|
+
Does nothing if ``conductivity_1`` or ``temperature_1`` are absent.
|
|
45
|
+
Records the conversion method in ``salinity_1.attrs``.
|
|
46
|
+
|
|
47
|
+
Parameters
|
|
48
|
+
----------
|
|
49
|
+
ds:
|
|
50
|
+
Per-cast Dataset (dim=time) containing at minimum ``conductivity_1``,
|
|
51
|
+
``temperature_1``, and ``pressure`` in their expected units
|
|
52
|
+
(conductivity in S/m, temperature in °C ITS-90, pressure in dbar).
|
|
53
|
+
|
|
54
|
+
Returns
|
|
55
|
+
-------
|
|
56
|
+
xr.Dataset
|
|
57
|
+
New Dataset with updated ``salinity_1`` (and ``salinity_2`` when
|
|
58
|
+
``conductivity_2`` is present); input is not mutated.
|
|
59
|
+
"""
|
|
60
|
+
if "conductivity_1" not in ds or "temperature_1" not in ds:
|
|
61
|
+
return ds
|
|
62
|
+
|
|
63
|
+
ds = ds.copy()
|
|
64
|
+
p = ds["pressure"].values.astype(float)
|
|
65
|
+
t = ds["temperature_1"].values.astype(float)
|
|
66
|
+
|
|
67
|
+
for c_var, s_var in [
|
|
68
|
+
("conductivity_1", "salinity_1"),
|
|
69
|
+
("conductivity_2", "salinity_2"),
|
|
70
|
+
]:
|
|
71
|
+
if c_var not in ds:
|
|
72
|
+
continue
|
|
73
|
+
c_s_per_m = ds[c_var].values.astype(float)
|
|
74
|
+
c_ms_cm = c_s_per_m * 10.0 # S/m → mS/cm for gsw.SP_from_C
|
|
75
|
+
with warnings.catch_warnings():
|
|
76
|
+
warnings.filterwarnings("ignore", category=RuntimeWarning, module="gsw")
|
|
77
|
+
sp = gsw.SP_from_C(c_ms_cm, t, p)
|
|
78
|
+
|
|
79
|
+
existing_attrs = dict(ds[s_var].attrs) if s_var in ds else {}
|
|
80
|
+
existing_attrs["derived_from"] = (
|
|
81
|
+
f"{c_var}, temperature_1, pressure via gsw.SP_from_C"
|
|
82
|
+
)
|
|
83
|
+
existing_attrs.setdefault("units", "1")
|
|
84
|
+
existing_attrs.setdefault("standard_name", "sea_water_practical_salinity")
|
|
85
|
+
existing_attrs.setdefault("reference_scale", "PSS-78")
|
|
86
|
+
dim = ds["pressure"].dims[0]
|
|
87
|
+
ds[s_var] = xr.DataArray(
|
|
88
|
+
sp.astype(np.float32),
|
|
89
|
+
dims=[dim],
|
|
90
|
+
attrs=existing_attrs,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
return ds
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
# TEOS-10 per-cast (1-D)
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def derive_SA(ds: xr.Dataset) -> xr.Dataset:
|
|
102
|
+
"""Return *ds* with Absolute Salinity (SA) added.
|
|
103
|
+
|
|
104
|
+
Uses ``gsw.SA_from_SP`` with ``salinity_1`` (practical salinity, PSS-78),
|
|
105
|
+
``pressure`` (dbar), and the cast's median latitude/longitude.
|
|
106
|
+
|
|
107
|
+
Parameters
|
|
108
|
+
----------
|
|
109
|
+
ds:
|
|
110
|
+
Per-cast Dataset (dim=time) with ``salinity_1``, ``pressure``,
|
|
111
|
+
``latitude``, ``longitude``.
|
|
112
|
+
|
|
113
|
+
Returns
|
|
114
|
+
-------
|
|
115
|
+
xr.Dataset
|
|
116
|
+
New Dataset with ``ds["absolute_salinity"]`` added; input is not mutated.
|
|
117
|
+
"""
|
|
118
|
+
ds = ds.copy()
|
|
119
|
+
sp = ds["salinity_1"].values.astype(float)
|
|
120
|
+
p = ds["pressure"].values.astype(float)
|
|
121
|
+
lat = float(np.nanmedian(ds["latitude"].values))
|
|
122
|
+
lon = float(np.nanmedian(ds["longitude"].values))
|
|
123
|
+
with warnings.catch_warnings():
|
|
124
|
+
warnings.filterwarnings("ignore", category=RuntimeWarning, module="gsw")
|
|
125
|
+
sa = gsw.SA_from_SP(sp, p, lon, lat)
|
|
126
|
+
dim = ds["pressure"].dims[0]
|
|
127
|
+
ds["absolute_salinity"] = xr.DataArray(
|
|
128
|
+
sa.astype(np.float32),
|
|
129
|
+
dims=[dim],
|
|
130
|
+
attrs={"long_name": "Absolute Salinity", "units": "g kg-1"},
|
|
131
|
+
)
|
|
132
|
+
return ds
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def derive_CT(ds: xr.Dataset) -> xr.Dataset:
|
|
136
|
+
"""Return *ds* with Conservative Temperature (CT) added.
|
|
137
|
+
|
|
138
|
+
Requires ``ds["absolute_salinity"]`` to already be present (call :func:`derive_SA` first).
|
|
139
|
+
Uses ``gsw.CT_from_t`` with in-situ ``temperature_1`` and ``pressure``.
|
|
140
|
+
|
|
141
|
+
Parameters
|
|
142
|
+
----------
|
|
143
|
+
ds:
|
|
144
|
+
Per-cast Dataset (dim=time) with ``absolute_salinity``, ``temperature_1``,
|
|
145
|
+
``pressure``.
|
|
146
|
+
|
|
147
|
+
Returns
|
|
148
|
+
-------
|
|
149
|
+
xr.Dataset
|
|
150
|
+
New Dataset with ``ds["conservative_temperature"]`` added; input is not mutated.
|
|
151
|
+
"""
|
|
152
|
+
ds = ds.copy()
|
|
153
|
+
sa = ds["absolute_salinity"].values.astype(float)
|
|
154
|
+
t = ds["temperature_1"].values.astype(float)
|
|
155
|
+
p = ds["pressure"].values.astype(float)
|
|
156
|
+
with warnings.catch_warnings():
|
|
157
|
+
warnings.filterwarnings("ignore", category=RuntimeWarning, module="gsw")
|
|
158
|
+
ct = gsw.CT_from_t(sa, t, p)
|
|
159
|
+
dim = ds["pressure"].dims[0]
|
|
160
|
+
ds["conservative_temperature"] = xr.DataArray(
|
|
161
|
+
ct.astype(np.float32),
|
|
162
|
+
dims=[dim],
|
|
163
|
+
attrs={"long_name": "Conservative Temperature", "units": "degree_Celsius"},
|
|
164
|
+
)
|
|
165
|
+
return ds
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def derive_sigma0(ds: xr.Dataset) -> xr.Dataset:
|
|
169
|
+
"""Return *ds* with potential density anomaly (sigma0) added.
|
|
170
|
+
|
|
171
|
+
Requires ``ds["absolute_salinity"]`` and ``ds["conservative_temperature"]`` to already be present.
|
|
172
|
+
Uses ``gsw.sigma0``.
|
|
173
|
+
|
|
174
|
+
Parameters
|
|
175
|
+
----------
|
|
176
|
+
ds:
|
|
177
|
+
Per-cast Dataset (dim=time) with ``absolute_salinity`` and ``conservative_temperature``.
|
|
178
|
+
|
|
179
|
+
Returns
|
|
180
|
+
-------
|
|
181
|
+
xr.Dataset
|
|
182
|
+
New Dataset with ``ds["sigma0"]`` added; input is not mutated.
|
|
183
|
+
"""
|
|
184
|
+
ds = ds.copy()
|
|
185
|
+
sa = ds["absolute_salinity"].values.astype(float)
|
|
186
|
+
ct = ds["conservative_temperature"].values.astype(float)
|
|
187
|
+
with warnings.catch_warnings():
|
|
188
|
+
warnings.filterwarnings("ignore", category=RuntimeWarning, module="gsw")
|
|
189
|
+
sig0 = gsw.sigma0(sa, ct)
|
|
190
|
+
dim = ds["absolute_salinity"].dims[0]
|
|
191
|
+
ds["sigma0"] = xr.DataArray(
|
|
192
|
+
sig0.astype(np.float32),
|
|
193
|
+
dims=[dim],
|
|
194
|
+
attrs={"long_name": "Potential density anomaly", "units": "kg m-3"},
|
|
195
|
+
)
|
|
196
|
+
return ds
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def derive_teos10(ds: xr.Dataset) -> xr.Dataset:
|
|
200
|
+
"""Return *ds* with SA, CT, sigma0 added (1-D per-cast Dataset, dim=time).
|
|
201
|
+
|
|
202
|
+
Convenience function that calls :func:`derive_SA` → :func:`derive_CT` →
|
|
203
|
+
:func:`derive_sigma0` in order. Also handles ``oxygen_1`` in two cases:
|
|
204
|
+
|
|
205
|
+
- If ``oxygen_1`` carries molar units (µmol/L or µmol/kg), derives
|
|
206
|
+
``oxsat_1`` (% saturation) and adds it; ``oxygen_1`` is left unchanged.
|
|
207
|
+
- If ``oxygen_1`` already carries % saturation units (pre-Phase-3 NC files
|
|
208
|
+
where the variable was stored under the wrong name), renames it to
|
|
209
|
+
``oxsat_1`` so the plotting pipeline finds it under the canonical name.
|
|
210
|
+
|
|
211
|
+
Parameters
|
|
212
|
+
----------
|
|
213
|
+
ds:
|
|
214
|
+
Per-cast Dataset (dim=time) with ``salinity_1``, ``temperature_1``,
|
|
215
|
+
``pressure``, ``latitude``, ``longitude``.
|
|
216
|
+
|
|
217
|
+
Returns
|
|
218
|
+
-------
|
|
219
|
+
xr.Dataset
|
|
220
|
+
New Dataset with SA, CT, sigma0 added; input is not mutated.
|
|
221
|
+
"""
|
|
222
|
+
ds = derive_SA(ds)
|
|
223
|
+
ds = derive_CT(ds)
|
|
224
|
+
ds = derive_sigma0(ds)
|
|
225
|
+
if "oxygen_1" in ds:
|
|
226
|
+
units = ds["oxygen_1"].attrs.get("units", "")
|
|
227
|
+
u_lower = units.lower()
|
|
228
|
+
if "umol" in u_lower or "µmol" in u_lower:
|
|
229
|
+
sa = ds["absolute_salinity"].values.astype(float)
|
|
230
|
+
ct = ds["conservative_temperature"].values.astype(float)
|
|
231
|
+
p = ds["pressure"].values.astype(float)
|
|
232
|
+
lat = float(np.nanmedian(ds["latitude"].values))
|
|
233
|
+
lon = float(np.nanmedian(ds["longitude"].values))
|
|
234
|
+
ds = _derive_oxsat_from_oxygen(ds, sa, ct, p, lat, lon)
|
|
235
|
+
elif "%" in u_lower or "sat" in u_lower or "percent" in u_lower:
|
|
236
|
+
# Pre-Phase-3 NC files stored % saturation under "oxygen_1".
|
|
237
|
+
# Rename to the canonical "oxsat_1" so panels find it.
|
|
238
|
+
ds = ds.rename({"oxygen_1": "oxsat_1"})
|
|
239
|
+
return ds
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# ---------------------------------------------------------------------------
|
|
243
|
+
# TEOS-10 profiles (2-D, dims N_PROF × pressure)
|
|
244
|
+
# ---------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def derive_teos10_profiles(ds: xr.Dataset) -> xr.Dataset:
|
|
248
|
+
"""Return *ds* with SA, CT, sigma0 added (2-D profiles Dataset).
|
|
249
|
+
|
|
250
|
+
Expects ``pressure`` as a 1-D coordinate and ``temperature_1``,
|
|
251
|
+
``salinity_1``, ``latitude``, ``longitude`` as variables with dims
|
|
252
|
+
``(N_PROF,)`` or ``(N_PROF, pressure)``. Returns *ds* unchanged if
|
|
253
|
+
SA, CT, and sigma0 are already present.
|
|
254
|
+
|
|
255
|
+
Parameters
|
|
256
|
+
----------
|
|
257
|
+
ds:
|
|
258
|
+
Profiles Dataset (dims N_PROF × pressure).
|
|
259
|
+
|
|
260
|
+
Returns
|
|
261
|
+
-------
|
|
262
|
+
xr.Dataset
|
|
263
|
+
New Dataset with SA, CT, sigma0 added; input is not mutated.
|
|
264
|
+
"""
|
|
265
|
+
if (
|
|
266
|
+
"conservative_temperature" in ds
|
|
267
|
+
and "absolute_salinity" in ds
|
|
268
|
+
and "sigma0" in ds
|
|
269
|
+
):
|
|
270
|
+
return ds
|
|
271
|
+
ds = ds.copy()
|
|
272
|
+
p = ds["pressure"].values.astype(float) # (N_P,)
|
|
273
|
+
t = ds["temperature_1"].values.astype(float) # (N_PROF, N_P)
|
|
274
|
+
sp = ds["salinity_1"].values.astype(float) # (N_PROF, N_P)
|
|
275
|
+
lat = ds["latitude"].values.astype(float) # (N_PROF,)
|
|
276
|
+
lon = ds["longitude"].values.astype(float) # (N_PROF,)
|
|
277
|
+
with warnings.catch_warnings():
|
|
278
|
+
warnings.filterwarnings("ignore", category=RuntimeWarning, module="gsw")
|
|
279
|
+
sa = gsw.SA_from_SP(
|
|
280
|
+
sp, p[np.newaxis, :], lon[:, np.newaxis], lat[:, np.newaxis]
|
|
281
|
+
)
|
|
282
|
+
ct = gsw.CT_from_t(sa, t, p[np.newaxis, :])
|
|
283
|
+
sig0 = gsw.sigma0(sa, ct)
|
|
284
|
+
dims = tuple(ds["temperature_1"].dims)
|
|
285
|
+
ds["absolute_salinity"] = xr.DataArray(
|
|
286
|
+
sa.astype(np.float32),
|
|
287
|
+
dims=dims,
|
|
288
|
+
attrs={"long_name": "Absolute Salinity", "units": "g kg-1"},
|
|
289
|
+
)
|
|
290
|
+
ds["conservative_temperature"] = xr.DataArray(
|
|
291
|
+
ct.astype(np.float32),
|
|
292
|
+
dims=dims,
|
|
293
|
+
attrs={"long_name": "Conservative Temperature", "units": "degree_Celsius"},
|
|
294
|
+
)
|
|
295
|
+
ds["sigma0"] = xr.DataArray(
|
|
296
|
+
sig0.astype(np.float32),
|
|
297
|
+
dims=dims,
|
|
298
|
+
attrs={"long_name": "Potential density anomaly", "units": "kg m-3"},
|
|
299
|
+
)
|
|
300
|
+
return ds
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# ---------------------------------------------------------------------------
|
|
304
|
+
# AOU
|
|
305
|
+
# ---------------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def derive_AOU(ds: xr.Dataset) -> xr.Dataset:
|
|
309
|
+
"""Return *ds* with AOU added as 100 - oxsat_1 (O₂ saturation deficit, % sat).
|
|
310
|
+
|
|
311
|
+
Note: this is a saturation-deficit proxy, not the traditional AOU in
|
|
312
|
+
µmol/kg, because it uses ``oxsat_1`` (% saturation) rather than
|
|
313
|
+
dissolved O₂ in µmol/kg. When ``oxygen_1`` (µmol/kg) is available,
|
|
314
|
+
the traditional AOU is ``gsw.O2sol(SA, CT, p, lon, lat) - oxygen_1``.
|
|
315
|
+
|
|
316
|
+
Returns *ds* unchanged if ``oxsat_1`` is absent or ``AOU`` already exists.
|
|
317
|
+
|
|
318
|
+
Parameters
|
|
319
|
+
----------
|
|
320
|
+
ds:
|
|
321
|
+
Dataset (any dimensionality) with ``oxsat_1`` in % saturation.
|
|
322
|
+
|
|
323
|
+
Returns
|
|
324
|
+
-------
|
|
325
|
+
xr.Dataset
|
|
326
|
+
New Dataset with ``AOU`` added; input is not mutated.
|
|
327
|
+
"""
|
|
328
|
+
if "AOU" in ds or "oxsat_1" not in ds:
|
|
329
|
+
return ds
|
|
330
|
+
ds = ds.copy()
|
|
331
|
+
dims = ds["oxsat_1"].dims
|
|
332
|
+
ds["AOU"] = xr.DataArray(
|
|
333
|
+
(100.0 - ds["oxsat_1"].values).astype(np.float32),
|
|
334
|
+
dims=dims,
|
|
335
|
+
attrs={"long_name": "O₂ saturation deficit", "units": "% sat"},
|
|
336
|
+
)
|
|
337
|
+
return ds
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
# ---------------------------------------------------------------------------
|
|
341
|
+
# Private helper
|
|
342
|
+
# ---------------------------------------------------------------------------
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _derive_oxsat_from_oxygen(
|
|
346
|
+
ds: xr.Dataset,
|
|
347
|
+
sa: np.ndarray,
|
|
348
|
+
ct: np.ndarray,
|
|
349
|
+
p: np.ndarray,
|
|
350
|
+
lat: float,
|
|
351
|
+
lon: float,
|
|
352
|
+
) -> xr.Dataset:
|
|
353
|
+
"""Derive ``oxsat_1`` (% saturation) from ``oxygen_1`` (µmol/L or µmol/kg).
|
|
354
|
+
|
|
355
|
+
Adds ``oxsat_1`` to *ds*; leaves ``oxygen_1`` unchanged. Does nothing if
|
|
356
|
+
``oxygen_1`` units do not indicate a molar concentration. Records the
|
|
357
|
+
conversion method in ``oxsat_1`` attributes for provenance.
|
|
358
|
+
|
|
359
|
+
Parameters
|
|
360
|
+
----------
|
|
361
|
+
ds:
|
|
362
|
+
Dataset containing ``oxygen_1`` in molar units; must already have SA/CT
|
|
363
|
+
computed (i.e. call after :func:`derive_SA` / :func:`derive_CT`).
|
|
364
|
+
sa, ct, p:
|
|
365
|
+
Absolute Salinity (g/kg), Conservative Temperature (°C), pressure (dbar)
|
|
366
|
+
arrays matching the ``oxygen_1`` dimension.
|
|
367
|
+
lat, lon:
|
|
368
|
+
Representative cast latitude and longitude for ``gsw.O2sol``.
|
|
369
|
+
"""
|
|
370
|
+
units = ds["oxygen_1"].attrs.get("units", "")
|
|
371
|
+
if not units:
|
|
372
|
+
return ds
|
|
373
|
+
u_lower = units.lower()
|
|
374
|
+
if "umol" not in u_lower and "µmol" not in u_lower:
|
|
375
|
+
return ds
|
|
376
|
+
|
|
377
|
+
with warnings.catch_warnings():
|
|
378
|
+
warnings.filterwarnings("ignore", category=RuntimeWarning, module="gsw")
|
|
379
|
+
o2_sat_umol_kg = gsw.O2sol(sa, ct, p, lat, lon)
|
|
380
|
+
|
|
381
|
+
measured = ds["oxygen_1"].values.astype(float)
|
|
382
|
+
|
|
383
|
+
if "/l" in u_lower or "l-1" in u_lower:
|
|
384
|
+
with warnings.catch_warnings():
|
|
385
|
+
warnings.filterwarnings("ignore", category=RuntimeWarning, module="gsw")
|
|
386
|
+
rho = gsw.rho(sa, ct, p) # kg/m³
|
|
387
|
+
o2_sat_umol_l = o2_sat_umol_kg * rho / 1000.0
|
|
388
|
+
pct_sat = measured / o2_sat_umol_l * 100.0
|
|
389
|
+
method = f"derived from oxygen_1 ({units}) via gsw.O2sol + gsw.rho"
|
|
390
|
+
else:
|
|
391
|
+
pct_sat = measured / o2_sat_umol_kg * 100.0
|
|
392
|
+
method = f"derived from oxygen_1 ({units}) via gsw.O2sol"
|
|
393
|
+
|
|
394
|
+
new_attrs = {
|
|
395
|
+
"units": "% saturation",
|
|
396
|
+
"long_name": "O₂ saturation",
|
|
397
|
+
"source_units": units,
|
|
398
|
+
"oxygen_conversion": method,
|
|
399
|
+
}
|
|
400
|
+
dim = ds["oxygen_1"].dims[0]
|
|
401
|
+
ds = ds.copy()
|
|
402
|
+
ds["oxsat_1"] = xr.DataArray(
|
|
403
|
+
pct_sat.astype(np.float32),
|
|
404
|
+
dims=[dim],
|
|
405
|
+
attrs=new_attrs,
|
|
406
|
+
)
|
|
407
|
+
return ds
|