microstructure-tpw 0.3.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 (119) hide show
  1. microstructure_tpw-0.3.0.dist-info/METADATA +234 -0
  2. microstructure_tpw-0.3.0.dist-info/RECORD +119 -0
  3. microstructure_tpw-0.3.0.dist-info/WHEEL +5 -0
  4. microstructure_tpw-0.3.0.dist-info/entry_points.txt +7 -0
  5. microstructure_tpw-0.3.0.dist-info/licenses/LICENSE +674 -0
  6. microstructure_tpw-0.3.0.dist-info/top_level.txt +1 -0
  7. odas_tpw/__init__.py +18 -0
  8. odas_tpw/chi/__init__.py +48 -0
  9. odas_tpw/chi/batchelor.py +189 -0
  10. odas_tpw/chi/chi.py +857 -0
  11. odas_tpw/chi/fp07.py +649 -0
  12. odas_tpw/chi/l2_chi.py +153 -0
  13. odas_tpw/chi/l3_chi.py +388 -0
  14. odas_tpw/chi/l4_chi.py +391 -0
  15. odas_tpw/config_base.py +423 -0
  16. odas_tpw/perturb/__init__.py +18 -0
  17. odas_tpw/perturb/adcp.py +274 -0
  18. odas_tpw/perturb/atomic_io.py +47 -0
  19. odas_tpw/perturb/binning.py +761 -0
  20. odas_tpw/perturb/cli.py +591 -0
  21. odas_tpw/perturb/combo.py +346 -0
  22. odas_tpw/perturb/config.py +1046 -0
  23. odas_tpw/perturb/ctd.py +424 -0
  24. odas_tpw/perturb/diag/__init__.py +12 -0
  25. odas_tpw/perturb/diag/_common.py +140 -0
  26. odas_tpw/perturb/diag/chi.py +42 -0
  27. odas_tpw/perturb/diag/cli.py +67 -0
  28. odas_tpw/perturb/diag/data.py +662 -0
  29. odas_tpw/perturb/diag/epsilon.py +45 -0
  30. odas_tpw/perturb/diag/inspector.py +342 -0
  31. odas_tpw/perturb/diag/mixing.py +94 -0
  32. odas_tpw/perturb/diag/render.py +543 -0
  33. odas_tpw/perturb/discover.py +40 -0
  34. odas_tpw/perturb/fp07_cal.py +354 -0
  35. odas_tpw/perturb/gps.py +458 -0
  36. odas_tpw/perturb/hotel.py +572 -0
  37. odas_tpw/perturb/logging_setup.py +181 -0
  38. odas_tpw/perturb/merge.py +320 -0
  39. odas_tpw/perturb/mk_sections.py +310 -0
  40. odas_tpw/perturb/netcdf_schema.py +520 -0
  41. odas_tpw/perturb/pipeline.py +2654 -0
  42. odas_tpw/perturb/plot/__init__.py +15 -0
  43. odas_tpw/perturb/plot/cli.py +107 -0
  44. odas_tpw/perturb/plot/diagnostics.py +195 -0
  45. odas_tpw/perturb/plot/eps_chi.py +524 -0
  46. odas_tpw/perturb/plot/figure.py +679 -0
  47. odas_tpw/perturb/plot/gamma_scaling.py +1097 -0
  48. odas_tpw/perturb/plot/grid.py +112 -0
  49. odas_tpw/perturb/plot/layout.py +361 -0
  50. odas_tpw/perturb/plot/overview.py +547 -0
  51. odas_tpw/perturb/plot/presets/eps-chi.yaml +16 -0
  52. odas_tpw/perturb/plot/presets/profiles.yaml +32 -0
  53. odas_tpw/perturb/plot/presets/scalar.yaml +21 -0
  54. odas_tpw/perturb/plot/profiles.py +652 -0
  55. odas_tpw/perturb/plot/scalar.py +391 -0
  56. odas_tpw/perturb/plot/sections.py +506 -0
  57. odas_tpw/perturb/plot/xaxis.py +396 -0
  58. odas_tpw/perturb/qc_gate.py +177 -0
  59. odas_tpw/perturb/qc_rules.py +373 -0
  60. odas_tpw/perturb/resolve.py +306 -0
  61. odas_tpw/perturb/seawater.py +76 -0
  62. odas_tpw/perturb/speed.py +14 -0
  63. odas_tpw/perturb/trim.py +313 -0
  64. odas_tpw/processing/__init__.py +62 -0
  65. odas_tpw/processing/bottom.py +215 -0
  66. odas_tpw/processing/chi_combine.py +300 -0
  67. odas_tpw/processing/ct_align.py +172 -0
  68. odas_tpw/processing/epsilon_combine.py +210 -0
  69. odas_tpw/processing/mixing.py +630 -0
  70. odas_tpw/processing/thorpe.py +458 -0
  71. odas_tpw/processing/top_trim.py +274 -0
  72. odas_tpw/pyturb/__init__.py +3 -0
  73. odas_tpw/pyturb/__main__.py +5 -0
  74. odas_tpw/pyturb/_compat.py +204 -0
  75. odas_tpw/pyturb/_profind.py +124 -0
  76. odas_tpw/pyturb/bin.py +113 -0
  77. odas_tpw/pyturb/cli.py +175 -0
  78. odas_tpw/pyturb/eps.py +353 -0
  79. odas_tpw/pyturb/merge.py +101 -0
  80. odas_tpw/pyturb/p2nc.py +76 -0
  81. odas_tpw/rsi/__init__.py +31 -0
  82. odas_tpw/rsi/adapter.py +314 -0
  83. odas_tpw/rsi/bench.py +904 -0
  84. odas_tpw/rsi/binning.py +193 -0
  85. odas_tpw/rsi/channels.py +376 -0
  86. odas_tpw/rsi/chi_io.py +980 -0
  87. odas_tpw/rsi/cli.py +1411 -0
  88. odas_tpw/rsi/combine.py +120 -0
  89. odas_tpw/rsi/config.py +126 -0
  90. odas_tpw/rsi/config_patch.py +790 -0
  91. odas_tpw/rsi/convert.py +651 -0
  92. odas_tpw/rsi/deconvolve.py +119 -0
  93. odas_tpw/rsi/diss_look.py +343 -0
  94. odas_tpw/rsi/dissipation.py +669 -0
  95. odas_tpw/rsi/helpers.py +527 -0
  96. odas_tpw/rsi/mixing_look.py +509 -0
  97. odas_tpw/rsi/p_file.py +771 -0
  98. odas_tpw/rsi/pipeline.py +924 -0
  99. odas_tpw/rsi/profile.py +624 -0
  100. odas_tpw/rsi/quick_look.py +758 -0
  101. odas_tpw/rsi/sensor_inventory.py +684 -0
  102. odas_tpw/rsi/shear_noise.py +128 -0
  103. odas_tpw/rsi/speed.py +221 -0
  104. odas_tpw/rsi/vehicle.py +74 -0
  105. odas_tpw/rsi/viewer_base.py +1071 -0
  106. odas_tpw/rsi/window.py +459 -0
  107. odas_tpw/scor160/__init__.py +36 -0
  108. odas_tpw/scor160/cli.py +242 -0
  109. odas_tpw/scor160/compare.py +484 -0
  110. odas_tpw/scor160/despike.py +227 -0
  111. odas_tpw/scor160/goodman.py +288 -0
  112. odas_tpw/scor160/io.py +690 -0
  113. odas_tpw/scor160/l2.py +311 -0
  114. odas_tpw/scor160/l3.py +279 -0
  115. odas_tpw/scor160/l4.py +850 -0
  116. odas_tpw/scor160/nasmyth.py +208 -0
  117. odas_tpw/scor160/ocean.py +287 -0
  118. odas_tpw/scor160/profile.py +231 -0
  119. odas_tpw/scor160/spectral.py +482 -0
@@ -0,0 +1,520 @@
1
+ # Mar-2026, Claude and Pat Welch, pat@mousebrains.com
2
+ """CF/ACDD NetCDF schema definitions.
3
+
4
+ Python replacement for Matlab's Combo.json and CTD.json.
5
+ Provides variable metadata (units, standard_name, long_name) for
6
+ CF-1.13 / ACDD-1.3 compliant NetCDF output.
7
+
8
+ Reference: Code/Combo.json, Code/CTD.json
9
+ """
10
+
11
+ import xarray as xr
12
+
13
+ # ---------------------------------------------------------------------------
14
+ # UDUNITS fixups for unit strings emitted by RSI's INI config
15
+ # ---------------------------------------------------------------------------
16
+ #
17
+ # RSI .p files carry unit strings in the embedded ASCII config that are
18
+ # either UDUNITS-incompatible (``umol_L-1`` instead of ``umol L-1``) or
19
+ # ambiguous (``deg`` — degree of arc or degree Celsius?). compliance-checker
20
+ # emits hard CF errors on these. The mapping below normalizes everything to
21
+ # UDUNITS-parseable strings and is applied by :func:`canonicalize_units`
22
+ # wherever we write per-channel attributes (e.g. extract_profiles).
23
+
24
+ UNITS_FIXUP: dict[str, str] = {
25
+ "umol_L-1": "umol L-1",
26
+ "mS_cm-1": "mS cm-1",
27
+ "deg_C": "degree_Celsius",
28
+ "deg": "degree",
29
+ # Turbidity: FTU is widely used in oceanography but absent from UDUNITS.
30
+ # CF guidance is to mark it dimensionless and document in long_name.
31
+ "FTU": "1",
32
+ "NTU": "1",
33
+ }
34
+
35
+
36
+ def canonicalize_units(units: str) -> str:
37
+ """Translate an RSI-emitted unit string to a UDUNITS-parseable one."""
38
+ return UNITS_FIXUP.get(units.strip(), units.strip())
39
+
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Schema definitions — maps variable names to CF attributes
43
+ # ---------------------------------------------------------------------------
44
+
45
+ COMBO_SCHEMA: dict[str, dict] = {
46
+ "bin": {
47
+ "units": "m",
48
+ "standard_name": "depth",
49
+ "long_name": "depth bin center",
50
+ "positive": "down",
51
+ "axis": "Z",
52
+ },
53
+ "profile": {
54
+ "long_name": "profile index",
55
+ "cf_role": "profile_id",
56
+ },
57
+ "time": {
58
+ "nc_name": "time",
59
+ "units": "seconds since 1970-01-01",
60
+ "standard_name": "time",
61
+ "long_name": "time",
62
+ },
63
+ "stime": {
64
+ "nc_name": "stime",
65
+ "units": "seconds since 1970-01-01",
66
+ "standard_name": "time",
67
+ "long_name": "profile start time",
68
+ },
69
+ "etime": {
70
+ "nc_name": "etime",
71
+ "units": "seconds since 1970-01-01",
72
+ "standard_name": "time",
73
+ "long_name": "profile end time",
74
+ },
75
+ "depth": {
76
+ "nc_name": "depth",
77
+ "units": "m",
78
+ "standard_name": "depth",
79
+ "long_name": "depth",
80
+ "positive": "down",
81
+ },
82
+ "P": {
83
+ "nc_name": "pressure",
84
+ "units": "dbar",
85
+ "standard_name": "sea_water_pressure",
86
+ "long_name": "sea water pressure",
87
+ },
88
+ "T": {
89
+ "nc_name": "temp",
90
+ "units": "degree_Celsius",
91
+ "standard_name": "sea_water_temperature",
92
+ "long_name": "in-situ temperature",
93
+ "units_metadata": "temperature: on_scale",
94
+ },
95
+ "T1": {
96
+ "units": "degree_Celsius",
97
+ "standard_name": "sea_water_temperature",
98
+ "long_name": "FP07 thermistor temperature (probe 1)",
99
+ "units_metadata": "temperature: on_scale",
100
+ },
101
+ "T2": {
102
+ "units": "degree_Celsius",
103
+ "standard_name": "sea_water_temperature",
104
+ "long_name": "FP07 thermistor temperature (probe 2)",
105
+ "units_metadata": "temperature: on_scale",
106
+ },
107
+ "DO_T": {
108
+ "units": "degree_Celsius",
109
+ "long_name": "dissolved-oxygen sensor temperature",
110
+ "units_metadata": "temperature: on_scale",
111
+ },
112
+ "DO": {
113
+ "units": "umol L-1",
114
+ "standard_name": "mole_concentration_of_dissolved_molecular_oxygen_in_sea_water",
115
+ "long_name": "dissolved oxygen",
116
+ },
117
+ "Chlorophyll": {
118
+ "units": "ug L-1",
119
+ "standard_name": "mass_concentration_of_chlorophyll_in_sea_water",
120
+ "long_name": "chlorophyll concentration",
121
+ },
122
+ "Turbidity": {
123
+ "units": "1",
124
+ "long_name": "turbidity (formazin turbidity units)",
125
+ },
126
+ "P_dP": {
127
+ "units": "dbar",
128
+ "long_name": "pressure offset (P - low-pass filtered P)",
129
+ },
130
+ "Gnd": {
131
+ "units": "V",
132
+ "long_name": "ground reference voltage",
133
+ },
134
+ "V_Bat": {
135
+ "units": "V",
136
+ "long_name": "battery voltage",
137
+ },
138
+ "PV": {
139
+ "units": "V",
140
+ "long_name": "pressure transducer voltage",
141
+ },
142
+ "Incl_X": {
143
+ "units": "degree",
144
+ "long_name": "inclinometer X angle",
145
+ },
146
+ "Incl_Y": {
147
+ "units": "degree",
148
+ "long_name": "inclinometer Y angle",
149
+ },
150
+ "Incl_T": {
151
+ "units": "degree_Celsius",
152
+ "long_name": "inclinometer temperature",
153
+ "units_metadata": "temperature: on_scale",
154
+ },
155
+ "W_slow": {
156
+ "units": "dbar s-1",
157
+ "long_name": "profiling rate (smoothed |dP/dt|)",
158
+ },
159
+ "JAC_T": {
160
+ "nc_name": "temp",
161
+ "units": "degree_Celsius",
162
+ "standard_name": "sea_water_temperature",
163
+ "long_name": "in-situ temperature (JFE)",
164
+ "units_metadata": "temperature: on_scale",
165
+ },
166
+ "JAC_C": {
167
+ "nc_name": "cond",
168
+ "units": "mS/cm",
169
+ "standard_name": "sea_water_electrical_conductivity",
170
+ "long_name": "conductivity (JFE)",
171
+ },
172
+ "SP": {
173
+ "nc_name": "SP",
174
+ # CF/TEOS-10: practical salinity is dimensionless ("1"), not "PSU"
175
+ # (not UDUNITS-parseable); the standard_name carries the meaning (#38).
176
+ "units": "1",
177
+ "standard_name": "sea_water_practical_salinity",
178
+ "long_name": "practical salinity (PSU)",
179
+ },
180
+ "SA": {
181
+ "nc_name": "SA",
182
+ "units": "g/kg",
183
+ "standard_name": "sea_water_absolute_salinity",
184
+ "long_name": "absolute salinity",
185
+ },
186
+ "CT": {
187
+ "nc_name": "CT",
188
+ "units": "degree_Celsius",
189
+ "standard_name": "sea_water_conservative_temperature",
190
+ "long_name": "conservative temperature",
191
+ "units_metadata": "temperature: on_scale",
192
+ },
193
+ "sigma0": {
194
+ "nc_name": "sigma0",
195
+ "units": "kg m-3",
196
+ "standard_name": "sea_water_sigma_theta",
197
+ "long_name": "potential density anomaly",
198
+ },
199
+ "rho": {
200
+ "nc_name": "rho",
201
+ "units": "kg m-3",
202
+ # No CF standard_name: this is in-situ density MINUS 1000 (an offset
203
+ # storage convention, mirroring sigma0), not full in-situ density.
204
+ # CF's "sea_water_density" denotes the real ~1025 kg/m^3 value, so
205
+ # claiming it would mislead a standard_name-aware consumer by ~1000.
206
+ "long_name": "in-situ density - 1000",
207
+ },
208
+ "lat": {
209
+ "nc_name": "lat",
210
+ "units": "degrees_north",
211
+ "standard_name": "latitude",
212
+ "long_name": "latitude",
213
+ },
214
+ "lon": {
215
+ "nc_name": "lon",
216
+ "units": "degrees_east",
217
+ "standard_name": "longitude",
218
+ "long_name": "longitude",
219
+ },
220
+ "e_1": {
221
+ "nc_name": "e_1",
222
+ "units": "W/kg",
223
+ "standard_name": "specific_turbulent_kinetic_energy_dissipation_in_sea_water",
224
+ "long_name": "TKE dissipation rate (probe 1)",
225
+ },
226
+ "e_2": {
227
+ "nc_name": "e_2",
228
+ "units": "W/kg",
229
+ "standard_name": "specific_turbulent_kinetic_energy_dissipation_in_sea_water",
230
+ "long_name": "TKE dissipation rate (probe 2)",
231
+ },
232
+ "epsilonMean": {
233
+ "nc_name": "epsilonMean",
234
+ "units": "W/kg",
235
+ "standard_name": "specific_turbulent_kinetic_energy_dissipation_in_sea_water",
236
+ "long_name": "combined TKE dissipation rate",
237
+ },
238
+ "epsilonLnSigma": {
239
+ "nc_name": "epsilonLnSigma",
240
+ "units": "",
241
+ "long_name": "sigma of ln(epsilon)",
242
+ "comment": (
243
+ "Expected single-probe standard deviation of ln(epsilon) from the "
244
+ "Lueck variance model; this is NOT the sigma of the combined mean "
245
+ "(no sqrt(n) reduction is applied). After depth-binning it is the "
246
+ "RMS of the contributing per-window sigmas in the bin, not a spread "
247
+ "recomputed from the binned estimates."
248
+ ),
249
+ },
250
+ "FM_1": {
251
+ "nc_name": "FM_1",
252
+ "units": "",
253
+ "long_name": "figure of merit (probe 1)",
254
+ },
255
+ "FM_2": {
256
+ "nc_name": "FM_2",
257
+ "units": "",
258
+ "long_name": "figure of merit (probe 2)",
259
+ },
260
+ # "cpm" (cycles per meter) is deliberately retained as the conventional
261
+ # oceanographic wavenumber unit here and on the K_max/kB vars below: it is
262
+ # not strict UDUNITS (the parseable equivalent is "m-1"), but it is
263
+ # unambiguous and matches the per-profile rsi/chi outputs. Left as-is by
264
+ # decision (#38); only the PSU -> "1" salinity fix was applied.
265
+ "K_max_1": {
266
+ "nc_name": "K_max_1",
267
+ "units": "cpm",
268
+ "long_name": "maximum resolved wavenumber (probe 1)",
269
+ },
270
+ "K_max_2": {
271
+ "nc_name": "K_max_2",
272
+ "units": "cpm",
273
+ "long_name": "maximum resolved wavenumber (probe 2)",
274
+ },
275
+ "speed": {
276
+ "nc_name": "speed",
277
+ "units": "m/s",
278
+ "long_name": "profiling speed",
279
+ },
280
+ "nu": {
281
+ "nc_name": "nu",
282
+ "units": "m2 s-1",
283
+ "long_name": "kinematic viscosity",
284
+ },
285
+ "T_mean": {
286
+ "nc_name": "T_mean",
287
+ "units": "degree_Celsius",
288
+ "long_name": "mean in-situ temperature within FFT window",
289
+ "units_metadata": "temperature: on_scale",
290
+ },
291
+ # Background stratification (Thorpe-sorted). The averaging scale differs by
292
+ # product (a pressure window for the profile product, the processing window
293
+ # for diss); each product NetCDF carries the precise scale in its variable
294
+ # comment, so the schema long_name stays scale-neutral. Profile-only (not on
295
+ # the CTD product, which spans the whole up/down trajectory).
296
+ "N2": {
297
+ "nc_name": "N2",
298
+ "units": "s-2",
299
+ "standard_name": "square_of_brunt_vaisala_frequency_in_sea_water",
300
+ "long_name": "buoyancy frequency squared (Thorpe-sorted)",
301
+ },
302
+ "dTdz": {
303
+ "nc_name": "dTdz",
304
+ "units": "K m-1",
305
+ "long_name": "background temperature gradient (positive down)",
306
+ },
307
+ }
308
+
309
+ CHI_SCHEMA: dict[str, dict] = {
310
+ # Headline combined chi products. Without these entries, binning strips all
311
+ # per-variable attrs and the published combo carries chiMean/chiLnSigma with
312
+ # no units or long_name.
313
+ "chiMean": {
314
+ "nc_name": "chiMean",
315
+ "units": "K2 s-1",
316
+ "long_name": "combined thermal variance dissipation rate",
317
+ },
318
+ "chiLnSigma": {
319
+ "nc_name": "chiLnSigma",
320
+ "units": "",
321
+ "long_name": "sigma of ln(chi)",
322
+ "comment": (
323
+ "Expected single-probe standard deviation of ln(chi); this is NOT "
324
+ "the sigma of the combined mean (no sqrt(n) reduction is applied). "
325
+ "After depth-binning it is the RMS of the contributing per-window "
326
+ "sigmas in the bin, not a spread recomputed from the binned "
327
+ "estimates."
328
+ ),
329
+ },
330
+ "epsilon_paired": {
331
+ "nc_name": "epsilon_paired",
332
+ "units": "W/kg",
333
+ "standard_name": "specific_turbulent_kinetic_energy_dissipation_in_sea_water",
334
+ "long_name": "epsilonMean paired onto the chi window grid",
335
+ "comment": (
336
+ "Nearest-window epsilonMean from the matching diss dataset — the "
337
+ "epsilon that entered the per-window Gamma and K_rho. In binned/"
338
+ "combo products this is the bin aggregate of that pairing (see "
339
+ "the per-profile chi product for exact per-window traceability)."
340
+ ),
341
+ },
342
+ "chi_1": {
343
+ "nc_name": "chi_1",
344
+ "units": "K2 s-1",
345
+ "long_name": "thermal variance dissipation (probe 1)",
346
+ },
347
+ "chi_2": {
348
+ "nc_name": "chi_2",
349
+ "units": "K2 s-1",
350
+ "long_name": "thermal variance dissipation (probe 2)",
351
+ },
352
+ "kB_1": {
353
+ "nc_name": "kB_1",
354
+ "units": "cpm",
355
+ "long_name": "Batchelor wavenumber (probe 1)",
356
+ },
357
+ "kB_2": {
358
+ "nc_name": "kB_2",
359
+ "units": "cpm",
360
+ "long_name": "Batchelor wavenumber (probe 2)",
361
+ },
362
+ "fom_T_1": {
363
+ "nc_name": "fom_T_1",
364
+ "units": "",
365
+ "long_name": "figure of merit chi (probe 1)",
366
+ },
367
+ "fom_T_2": {
368
+ "nc_name": "fom_T_2",
369
+ "units": "",
370
+ "long_name": "figure of merit chi (probe 2)",
371
+ },
372
+ "K_max_T_1": {
373
+ "nc_name": "K_max_T_1",
374
+ "units": "cpm",
375
+ "long_name": "max wavenumber chi (probe 1)",
376
+ },
377
+ "K_max_T_2": {
378
+ "nc_name": "K_max_T_2",
379
+ "units": "cpm",
380
+ "long_name": "max wavenumber chi (probe 2)",
381
+ },
382
+ # Shared diagnostic vars carried over from the diss/profile pipeline
383
+ "speed": COMBO_SCHEMA["speed"],
384
+ "nu": COMBO_SCHEMA["nu"],
385
+ "T_mean": COMBO_SCHEMA["T_mean"],
386
+ "lat": COMBO_SCHEMA["lat"],
387
+ "lon": COMBO_SCHEMA["lon"],
388
+ "stime": COMBO_SCHEMA["stime"],
389
+ "etime": COMBO_SCHEMA["etime"],
390
+ "depth": COMBO_SCHEMA["depth"],
391
+ "P": COMBO_SCHEMA["P"],
392
+ "bin": COMBO_SCHEMA["bin"],
393
+ "profile": COMBO_SCHEMA["profile"],
394
+ # Stratification + derived mixing quantities on the chi grid.
395
+ "N2": COMBO_SCHEMA["N2"],
396
+ "dTdz": COMBO_SCHEMA["dTdz"],
397
+ "K_T": {
398
+ "nc_name": "K_T",
399
+ "units": "m2 s-1",
400
+ "long_name": "Osborn-Cox eddy diffusivity of heat",
401
+ },
402
+ "Gamma": {
403
+ "nc_name": "Gamma",
404
+ "units": "1",
405
+ "long_name": "mixing coefficient (measured)",
406
+ },
407
+ "K_rho": {
408
+ "nc_name": "K_rho",
409
+ "units": "m2 s-1",
410
+ "long_name": "Osborn diapycnal diffusivity (Gamma_0 = 0.2)",
411
+ },
412
+ }
413
+
414
+ CTD_SCHEMA: dict[str, dict] = {
415
+ "time": {
416
+ "nc_name": "time",
417
+ "units": "seconds since 1970-01-01",
418
+ "standard_name": "time",
419
+ "long_name": "time bin center",
420
+ },
421
+ "JAC_T": COMBO_SCHEMA["JAC_T"],
422
+ "JAC_C": COMBO_SCHEMA["JAC_C"],
423
+ "P": COMBO_SCHEMA["P"],
424
+ "depth": COMBO_SCHEMA["depth"],
425
+ "SP": COMBO_SCHEMA["SP"],
426
+ "SA": COMBO_SCHEMA["SA"],
427
+ "CT": COMBO_SCHEMA["CT"],
428
+ "sigma0": COMBO_SCHEMA["sigma0"],
429
+ "rho": COMBO_SCHEMA["rho"],
430
+ "lat": COMBO_SCHEMA["lat"],
431
+ "lon": COMBO_SCHEMA["lon"],
432
+ "DO": COMBO_SCHEMA["DO"],
433
+ "Chlorophyll": COMBO_SCHEMA["Chlorophyll"],
434
+ "Turbidity": COMBO_SCHEMA["Turbidity"],
435
+ # N2/dTdz are profile-only (down-cast) quantities and are intentionally not
436
+ # carried on the CTD product; they live on the profile/diss/chi schemas.
437
+ }
438
+
439
+ # Fixed global attributes for CF-1.13 / ACDD-1.3 compliance
440
+ GLOBAL_ATTRS: dict[str, str] = {
441
+ "Conventions": "CF-1.13, ACDD-1.3",
442
+ "standard_name_vocabulary": "CF Standard Name Table v89",
443
+ "featureType": "profile",
444
+ }
445
+
446
+
447
+ _COVERAGE_CONTENT_TYPE: dict[str, str] = {
448
+ # Geometric / time coordinate variables
449
+ "bin": "coordinate",
450
+ "depth": "coordinate",
451
+ "profile": "coordinate",
452
+ "time": "coordinate",
453
+ "stime": "coordinate",
454
+ "etime": "coordinate",
455
+ "lat": "coordinate",
456
+ "lon": "coordinate",
457
+ # Auxiliary diagnostics — CF/ACDD distinguishes these from the actual
458
+ # measured field. ACDD §coverage_content_type calls this
459
+ # ``auxiliaryInformation``.
460
+ "FM_1": "auxiliaryInformation",
461
+ "FM_2": "auxiliaryInformation",
462
+ "fom_T_1": "auxiliaryInformation",
463
+ "fom_T_2": "auxiliaryInformation",
464
+ "K_max_1": "auxiliaryInformation",
465
+ "K_max_2": "auxiliaryInformation",
466
+ "K_max_T_1": "auxiliaryInformation",
467
+ "K_max_T_2": "auxiliaryInformation",
468
+ "epsilonLnSigma": "auxiliaryInformation",
469
+ "n_samples": "auxiliaryInformation",
470
+ "Gnd": "auxiliaryInformation",
471
+ "PV": "auxiliaryInformation",
472
+ "P_dP": "auxiliaryInformation",
473
+ "V_Bat": "auxiliaryInformation",
474
+ "Incl_X": "auxiliaryInformation",
475
+ "Incl_Y": "auxiliaryInformation",
476
+ "Incl_T": "auxiliaryInformation",
477
+ "DO_T": "auxiliaryInformation",
478
+ "speed": "auxiliaryInformation",
479
+ "W_slow": "auxiliaryInformation",
480
+ "nu": "auxiliaryInformation",
481
+ }
482
+
483
+
484
+ def coverage_content_type_for(var_name: str) -> str:
485
+ """Return ACDD ``coverage_content_type`` for *var_name*.
486
+
487
+ Defaults to ``physicalMeasurement`` for any variable not explicitly
488
+ classified — that is the right call for T, S, P, epsilon, chi, density,
489
+ DO, etc. Coordinate vars and pure diagnostics get their own values.
490
+ """
491
+ return _COVERAGE_CONTENT_TYPE.get(var_name, "physicalMeasurement")
492
+
493
+
494
+ def apply_schema(ds: xr.Dataset, schema: dict[str, dict]) -> xr.Dataset:
495
+ """Apply CF attributes from *schema* to variables in *ds*.
496
+
497
+ For each variable in *ds* that has a matching entry in *schema*,
498
+ the schema's metadata (units, standard_name, long_name, etc.) is
499
+ applied as variable attributes. Every variable also receives an ACDD
500
+ ``coverage_content_type`` (auxiliary diagnostics, coordinates, and
501
+ physical measurements all get distinct types).
502
+
503
+ Parameters
504
+ ----------
505
+ ds : xr.Dataset
506
+ schema : dict mapping variable names to attribute dicts.
507
+
508
+ Returns
509
+ -------
510
+ xr.Dataset with updated attributes.
511
+ """
512
+ ds = ds.copy()
513
+ for vname in [str(v) for v in ds.data_vars] + [str(c) for c in ds.coords]:
514
+ if vname in schema:
515
+ attrs = schema[vname]
516
+ for key, val in attrs.items():
517
+ if key != "nc_name":
518
+ ds[vname].attrs[key] = val
519
+ ds[vname].attrs.setdefault("coverage_content_type", coverage_content_type_for(vname))
520
+ return ds