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.
Files changed (63) hide show
  1. ctdcast/__init__.py +25 -0
  2. ctdcast/__main__.py +6 -0
  3. ctdcast/_version.py +9 -0
  4. ctdcast/analysis/__init__.py +21 -0
  5. ctdcast/analysis/bathymetry.py +200 -0
  6. ctdcast/analysis/derive.py +407 -0
  7. ctdcast/analysis/geometry.py +68 -0
  8. ctdcast/cli/__init__.py +62 -0
  9. ctdcast/cli/convert.py +229 -0
  10. ctdcast/cli/draft.py +278 -0
  11. ctdcast/cli/init.py +1234 -0
  12. ctdcast/cli/process.py +392 -0
  13. ctdcast/cli/report.py +293 -0
  14. ctdcast/cli/run.py +164 -0
  15. ctdcast/cli/validate.py +244 -0
  16. ctdcast/config/__init__.py +1 -0
  17. ctdcast/config/loader.py +67 -0
  18. ctdcast/config/parameters.py +504 -0
  19. ctdcast/converters.py +27 -0
  20. ctdcast/ctdcast.mplstyle +24 -0
  21. ctdcast/identity.py +132 -0
  22. ctdcast/plotters/__init__.py +1 -0
  23. ctdcast/plotters/plots.py +1815 -0
  24. ctdcast/processors/__init__.py +129 -0
  25. ctdcast/processors/profiles.py +380 -0
  26. ctdcast/processors/qc.py +113 -0
  27. ctdcast/processors/stage1.py +213 -0
  28. ctdcast/processors/stage2.py +346 -0
  29. ctdcast/processors/stage3.py +138 -0
  30. ctdcast/readers/__init__.py +1 -0
  31. ctdcast/readers/cnv.py +50 -0
  32. ctdcast/readers/ladcp.py +55 -0
  33. ctdcast/readers/metadata.py +101 -0
  34. ctdcast/reports/__init__.py +5 -0
  35. ctdcast/reports/_cast.py +278 -0
  36. ctdcast/reports/_chrome.py +28 -0
  37. ctdcast/reports/_css.py +120 -0
  38. ctdcast/reports/_env.py +45 -0
  39. ctdcast/reports/_format.py +34 -0
  40. ctdcast/reports/_index.py +1192 -0
  41. ctdcast/reports/_leaflet.py +544 -0
  42. ctdcast/reports/_plots.py +655 -0
  43. ctdcast/reports/_section.py +394 -0
  44. ctdcast/reports/_timeseries.py +335 -0
  45. ctdcast/reports/leaflet/leaflet.min.css +8 -0
  46. ctdcast/reports/leaflet/leaflet.min.js +12 -0
  47. ctdcast/reports/templates/base.html +38 -0
  48. ctdcast/reports/templates/cast.html +203 -0
  49. ctdcast/reports/templates/casts.html +100 -0
  50. ctdcast/reports/templates/index.html +73 -0
  51. ctdcast/reports/templates/leaflet.html +165 -0
  52. ctdcast/reports/templates/section.html +147 -0
  53. ctdcast/reports/templates/sections.html +76 -0
  54. ctdcast/reports/templates/timeseries.html +80 -0
  55. ctdcast/reports/templates/timeseries_page.html +136 -0
  56. ctdcast/writers/__init__.py +1 -0
  57. ctdcast/writers/netcdf.py +89 -0
  58. ctdcast-0.1.0.dist-info/METADATA +192 -0
  59. ctdcast-0.1.0.dist-info/RECORD +63 -0
  60. ctdcast-0.1.0.dist-info/WHEEL +5 -0
  61. ctdcast-0.1.0.dist-info/entry_points.txt +2 -0
  62. ctdcast-0.1.0.dist-info/licenses/LICENSE +21 -0
  63. 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
@@ -0,0 +1,6 @@
1
+ """Entry point for ``python -m ctdcast``."""
2
+
3
+ from ctdcast.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
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