ctdproc 0.2.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.
ctdproc/proc.py ADDED
@@ -0,0 +1,770 @@
1
+ #!/usr/bin/env python
2
+ # coding: utf-8
3
+ import gsw
4
+ import matplotlib as mpl
5
+ import numpy as np
6
+ import xarray as xr
7
+ from scipy import fft, optimize, signal, stats
8
+
9
+ from . import calcs, helpers
10
+
11
+ # We are running into trouble if the default backend is not installed
12
+ # in the current python environment. Try to import pyplot the default
13
+ # way first. If this fails, set backend to agg which should always work.
14
+ try:
15
+ import matplotlib.pyplot as plt
16
+ except ImportError:
17
+ mpl.use("agg")
18
+ import matplotlib.pyplot as plt
19
+
20
+
21
+ def run_all(ds):
22
+ """
23
+ Run all standard processing steps on raw CTD time series.
24
+
25
+ Parameters
26
+ ----------
27
+ data : xarray.Dataset
28
+ CTD time series data structure
29
+
30
+ Returns
31
+ -------
32
+ datad : xarray.Dataset
33
+ Downcast time series
34
+ datau : xarray.Dataset
35
+ Upcast time series
36
+ """
37
+ ds = cleanup(ds)
38
+
39
+ ds_updown = split_updn(ds)
40
+
41
+ for v, d in ds_updown.items():
42
+ add_tcfit_default(d)
43
+ d = d.dropna("time", how="any")
44
+ d = phase_correct(d)
45
+ d = calcs.swcalcs(d)
46
+ d = rmloops(d)
47
+ d = cleanup_ud(d)
48
+ ds_updown[v] = d
49
+
50
+ return ds_updown
51
+
52
+
53
+ def plot_profile(ds, var_list):
54
+ for i, v in enumerate(var_list, start=1):
55
+ plt.subplot(1, len(var_list), i)
56
+ if "time" in ds.dims:
57
+ ds[v].plot(x="time")
58
+ elif "depth" in ds.dims:
59
+ ds[v].plot(y="depth")
60
+
61
+
62
+ def plot_TS(ds):
63
+ plt.plot(ds["t1"], ds["s1"], alpha=0.1)
64
+ plt.plot(ds["t2"], ds["s2"], alpha=0.1)
65
+
66
+
67
+ def add_tcfit_default(ds):
68
+ """
69
+ Get default values for tc fit range depending on depth of cast.
70
+
71
+ Range for tc fit is 200dbar to maximum pressure if the cast is
72
+ shallower than 1000dbar, 500dbar to max pressure otherwise.
73
+
74
+ Parameters
75
+ ----------
76
+ ds : xarray.Dataset
77
+ CTD time series data structure
78
+
79
+ Returns
80
+ -------
81
+ tcfit : tuple
82
+ Upper and lower limit for tc fit in phase_correct.
83
+ """
84
+ if ds.p.max() > 1000:
85
+ tcfit = [500, ds.p.max().data]
86
+ elif ds.p.max() > 300:
87
+ tcfit = [200, ds.p.max().data]
88
+ else:
89
+ tcfit = [50, ds.p.max().data]
90
+ ds.attrs["tcfit"] = tcfit
91
+
92
+
93
+ def cleanup(ds):
94
+ """
95
+ Clean up CTD raw time series.
96
+
97
+ - despike pressure
98
+ - eliminate data near surface
99
+ - remove spikes in other data
100
+ - remove smaller T, C, glitches
101
+ """
102
+
103
+ # despike pressure
104
+ ds["p"].data = helpers.glitchcorrect(
105
+ ds.p.data, ds.attrs["diff_p"], ds.attrs["prod_p"]
106
+ )
107
+ ipmax = np.argmax(ds.p.data)
108
+
109
+ # eliminate near-surface data
110
+ ptop = 1
111
+ fdeep = np.squeeze(np.where(ds.p.data > ptop))
112
+ ideepstart, ideepstop, ideeplen = helpers.findsegments(fdeep)
113
+ ii = np.max(ideepstart[ideepstart < ipmax])
114
+ jj = np.min(ideepstop[ideepstop >= ipmax])
115
+ ds = ds.isel(time=range(ii, jj + 1))
116
+
117
+ # remove spikes in temperature
118
+ ib = np.squeeze(
119
+ np.where(np.absolute(np.diff(ds.t1.data)) > ds.attrs["spike_thresh_t"])
120
+ )
121
+ ds.t1[ib] = np.nan
122
+ ib = np.squeeze(
123
+ np.where(np.absolute(np.diff(ds.t2.data)) > ds.attrs["spike_thresh_t"])
124
+ )
125
+ ds.t2[ib] = np.nan
126
+
127
+ # remove out of range values
128
+ ds = preen_ctd(ds)
129
+ # no trans, fl ***
130
+
131
+ # remove nans at start
132
+ fnan1 = np.squeeze(np.where(np.isnan(ds.c1.data)))
133
+ fnan2 = np.squeeze(np.where(np.isnan(ds.c2.data)))
134
+ if np.array_equiv(fnan1, fnan2) is False:
135
+ print("warning: NaNs index different in data.c1 and data.c2")
136
+ if fnan1.size > 0:
137
+ istart, istop, ilen = helpers.findsegments(fnan1)
138
+ if istart[0] != 0 | istart.size != 1:
139
+ print("warning: more NaNs")
140
+ ds = ds.isel(time=range(istop[0] + 1, ds.time.size))
141
+
142
+ return ds
143
+
144
+
145
+ def cleanup_ud(ds):
146
+ """More cleaning and calculation of derived variables."""
147
+
148
+ # remove spikes in temperature
149
+ for v in ["t1", "t2"]:
150
+ ds[v] = despike(ds[v], ds.attrs["spike_thresh_t"])
151
+
152
+ # despike T, C
153
+ ibefore = 1
154
+ iafter = 1
155
+ for v in ["c1", "c2", "t1", "t2"]:
156
+ ds[v].data = helpers.glitchcorrect(
157
+ ds[v], ds.attrs[f"diff_{v[0]}"], ds.attrs[f"prod_{v[0]}"], ibefore, iafter
158
+ )
159
+
160
+ # Calculate salinity (both absolute and practical)
161
+ ds = calcs.calc_sal(ds)
162
+
163
+ # despike s
164
+ ibefore = 2
165
+ iafter = 2
166
+ for v in ["s1", "s2", "SA1", "SA2"]:
167
+ # remove spikes
168
+ ds[v] = despike(ds[v], ds.attrs["spike_thresh_s"])
169
+ # remove out of bounds data
170
+ ds[v] = remove_out_of_bounds(
171
+ ds[v], bmin=ds.attrs["bounds_s"][0], bmax=ds.attrs["bounds_s"][1]
172
+ )
173
+ ds[v].data = helpers.glitchcorrect(
174
+ ds[v], ds.attrs["diff_s"], ds.attrs["prod_s"], ibefore, iafter
175
+ )
176
+
177
+ # calculate potential/conservative temperature, potential density anomaly
178
+ ds = calcs.calc_temp(ds)
179
+ ds = calcs.calc_sigma(ds)
180
+
181
+ return ds
182
+
183
+
184
+ def despike(da, spike_threshold):
185
+ """Set spikes to NaN."""
186
+ absdiff = np.absolute(np.diff(da.data))
187
+ # Using np.greater instead of the > operator as we can use the where option
188
+ # and avoid the warning when nans are compared to a number. It broadcasts
189
+ # to the original array size.
190
+ ib = np.squeeze(
191
+ np.where(np.greater(absdiff, spike_threshold, where=np.isfinite(absdiff)))
192
+ )
193
+ da[ib] = np.nan
194
+
195
+ return da
196
+
197
+
198
+ def remove_out_of_bounds(da, bmin, bmax):
199
+ """Remove out of bounds data."""
200
+
201
+ da = da.where((da<=bmax) & (da>=bmin))
202
+
203
+ return da
204
+
205
+
206
+ def preen_ctd(ds):
207
+ """Remove spikes in p, t1, t2, c1, c2.
208
+
209
+ Parameters
210
+ ----------
211
+ data : xarray.Dataset
212
+ Dataset with CTD time series.
213
+
214
+ Returns
215
+ -------
216
+ data : xarray.Dataset
217
+ Cleaned dataset.
218
+ """
219
+ for v in ["p", "t1", "t2", "c1", "c2"]:
220
+ ds[v] = ds[v].where(
221
+ xr.zeros_like(ds[v]),
222
+ helpers.preen(
223
+ ds[v].data, ds.attrs[f"bounds_{v[0]}"][0], ds.attrs[f"bounds_{v[0]}"][1]
224
+ ),
225
+ )
226
+
227
+ # TODO: remove spikes in oxygen, trans, fl in volts.
228
+ return ds
229
+
230
+
231
+ def phase_correct(ds):
232
+ """
233
+ Bring temperature and conductivity in phase.
234
+
235
+ Parameters
236
+ ----------
237
+ ds : dtype
238
+ description
239
+
240
+ Returns
241
+ -------
242
+ ds : dtype
243
+ description
244
+ """
245
+
246
+ # remove spikes
247
+ # TODO: bring this back in. however, the function fails later on if there
248
+ # are nan's present. Could interpolate over anything that is just a few data points
249
+ # for field in ["t1", "t2", "c1", "c2"]:
250
+ # ib = np.squeeze(np.where(np.absolute(np.diff(data[field].data)) > 0.5))
251
+ # data[field][ib] = np.nan
252
+
253
+ # ---Spectral Analysis of Raw Data---
254
+ # 24Hz data
255
+ dt = 1 / 24
256
+ # number of points per segment
257
+ N = 2**9
258
+
259
+ # only data within tcfit range.
260
+ ii = np.squeeze(
261
+ np.argwhere(
262
+ (ds.p.data > ds.attrs["tcfit"][0]) & (ds.p.data < ds.attrs["tcfit"][1])
263
+ )
264
+ )
265
+ i1 = ii[0]
266
+ i2 = ii[-1]
267
+ n = i2 - i1 + 1
268
+ n = (np.floor(n / N) * N).astype("int64")
269
+ # Truncate to be multiple of N elements long
270
+ i2 = (i1 + n).astype("int64")
271
+ # number of segments = dof/2
272
+ m = (n / N).astype("int64")
273
+ # Number of degrees of freedom (power of 2)
274
+ dof = 2 * m
275
+ # Frequency resolution at dof degrees of freedom.
276
+ df = 1 / (N * dt)
277
+
278
+ # fft of each segment (row). Data are detrended, then windowed.
279
+ window = signal.windows.triang(N) * np.ones((m, N))
280
+ At1 = fft.fft(
281
+ signal.detrend(np.reshape(ds.t1.data[i1:i2], newshape=(m, N))) * window
282
+ )
283
+ At2 = fft.fft(
284
+ signal.detrend(np.reshape(ds.t2.data[i1:i2], newshape=(m, N))) * window
285
+ )
286
+ Ac1 = fft.fft(
287
+ signal.detrend(np.reshape(ds.c1.data[i1:i2], newshape=(m, N))) * window
288
+ )
289
+ Ac2 = fft.fft(
290
+ signal.detrend(np.reshape(ds.c2.data[i1:i2], newshape=(m, N))) * window
291
+ )
292
+
293
+ # Positive frequencies only
294
+ At1 = At1[:, 0 : int(N / 2)]
295
+ At2 = At2[:, 0 : int(N / 2)]
296
+ Ac1 = Ac1[:, 0 : int(N / 2)]
297
+ Ac2 = Ac2[:, 0 : int(N / 2)]
298
+
299
+ # Frequency
300
+ f = fft.ifftshift(np.linspace(-N / 2, N / 2 - 1, N) / N / dt)
301
+ f = f[: int(N / 2)]
302
+ fold = f
303
+
304
+ # Spectral Estimates. Note: In Matlab, At1*conj(At1) is not complex anymore.
305
+ # Here, it is still a complex number but the imaginary part is zero.
306
+ # We keep only the real part to stay consistent.
307
+ Et1 = 2 * np.real(np.nanmean(At1 * np.conj(At1) / df / N**2, axis=0))
308
+ Et2 = 2 * np.real(np.nanmean(At2 * np.conj(At2) / df / N**2, axis=0))
309
+ Ec1 = 2 * np.real(np.nanmean(Ac1 * np.conj(Ac1) / df / N**2, axis=0))
310
+ Ec2 = 2 * np.real(np.nanmean(Ac2 * np.conj(Ac2) / df / N**2, axis=0))
311
+
312
+ # Cross Spectral Estimates
313
+ Ct1c1 = 2 * np.nanmean(At1 * np.conj(Ac1) / df / N**2, axis=0)
314
+ Ct2c2 = 2 * np.nanmean(At2 * np.conj(Ac2) / df / N**2, axis=0)
315
+
316
+ # Squared Coherence Estimates
317
+ Coht1c1 = np.real(Ct1c1 * np.conj(Ct1c1) / (Et1 * Ec1))
318
+ Coht2c2 = np.real(Ct2c2 * np.conj(Ct2c2) / (Et2 * Ec2))
319
+
320
+ # Cross-spectral Phase Estimates
321
+ Phit1c1 = np.arctan2(np.imag(Ct1c1), np.real(Ct1c1))
322
+ Phit2c2 = np.arctan2(np.imag(Ct2c2), np.real(Ct2c2))
323
+
324
+ # ---Determine tau and L---
325
+ # tau is the thermistor time constant (sec)
326
+ # L is the lag of t behind c due to sensor separation (sec)
327
+ # Matrix of weights based on squared coherence.
328
+ W1 = np.diag(Coht1c1)
329
+ W2 = np.diag(Coht2c2)
330
+
331
+ x1 = optimize.fmin(
332
+ func=helpers.atanfit, x0=[0, 0], args=(f, Phit1c1, W1), disp=False
333
+ )
334
+ x2 = optimize.fmin(
335
+ func=helpers.atanfit, x0=[0, 0], args=(f, Phit2c2, W2), disp=False
336
+ )
337
+
338
+ tau1 = x1[0]
339
+ tau2 = x2[0]
340
+ L1 = x1[1]
341
+ L2 = x2[1]
342
+
343
+ print("1: tau = {:1.4f}s, lag = {:1.4f}s".format(tau1, L1))
344
+ print("2: tau = {:1.4f}s, lag = {:1.4f}s".format(tau2, L2))
345
+
346
+ # ---Apply Phase Correction and LP Filter---
347
+ ii = np.squeeze(np.argwhere(ds.p.data > 1))
348
+ i1 = ii[0]
349
+ i2 = ii[-1]
350
+ n = i2 - i1 + 1
351
+ n = (np.floor(n / N) * N).astype("int64")
352
+ # Truncate to be multiple of N elements long
353
+ i2 = (i1 + n).astype("int64")
354
+ # number of segments = dof/2
355
+ m = (n / N).astype("int64")
356
+
357
+ # Number of degrees of freedom (power of 2)
358
+ dof = 2 * m
359
+ # Frequency resolution at dof degrees of freedom.
360
+ df = 1 / (N * dt)
361
+
362
+ # Transfer function
363
+ f = fft.ifftshift(np.linspace(-N / 2, N / 2 - 1, N) / N / dt)
364
+ H1 = (1 + 1j * 2 * np.pi * f * tau1) * np.exp(1j * 2 * np.pi * f * L1)
365
+ H2 = (1 + 1j * 2 * np.pi * f * tau2) * np.exp(1j * 2 * np.pi * f * L2)
366
+
367
+ # Low Pass Filter
368
+ f0 = 6 # Cutoff frequency
369
+ LP = 1 / (1 + (f / f0) ** 6)
370
+
371
+ # Restructure data with overlapping segments.
372
+ # Staggered segments
373
+ vars = [
374
+ "t1",
375
+ "t2",
376
+ "c1",
377
+ "c2",
378
+ "p",
379
+ "trans",
380
+ "fl",
381
+ "par",
382
+ "alt",
383
+ "oxygen1",
384
+ "oxygen2",
385
+ "ph",
386
+ ]
387
+ vard = {}
388
+ for v in vars:
389
+ if v in ds:
390
+ vard[v] = np.zeros((2 * m - 1, N))
391
+ vard[v][: 2 * m - 1 : 2, :] = np.reshape(ds[v].data[i1:i2], newshape=(m, N))
392
+ vard[v][1::2, :] = np.reshape(
393
+ ds[v].data[i1 + int(N / 2) : i2 - int(N / 2)],
394
+ newshape=(m - 1, N),
395
+ )
396
+
397
+ time = ds.time[i1:i2]
398
+ lon = ds.lon[i1:i2]
399
+ lat = ds.lat[i1:i2]
400
+
401
+ # FFTs of staggered segments (each row)
402
+ Ad = {}
403
+ for v in vars:
404
+ if v in ds:
405
+ Ad[v] = fft.fft(vard[v])
406
+
407
+ # Corrected Fourier transforms of temperature.
408
+ Ad["t1"] = Ad["t1"] * ((H1 * LP) * np.ones((2 * m - 1, 1)))
409
+ Ad["t2"] = Ad["t2"] * ((H2 * LP) * np.ones((2 * m - 1, 1)))
410
+
411
+ # LP filter remaining variables
412
+ vars2 = [
413
+ "c1",
414
+ "c2",
415
+ "p",
416
+ "trans",
417
+ "fl",
418
+ "par",
419
+ "alt",
420
+ "oxygen1",
421
+ "oxygen2",
422
+ "ph",
423
+ ]
424
+ for v in vars2:
425
+ if v in ds:
426
+ Ad[v] = Ad[v] * (LP * np.ones((2 * m - 1, 1)))
427
+
428
+ # Inverse transforms of corrected temperature
429
+ # and low passed other variables
430
+ Adi = {}
431
+ for v in vars:
432
+ if v in ds:
433
+ Adi[v] = np.real(fft.ifft(Ad[v]))
434
+ Adi[v] = np.squeeze(
435
+ np.reshape(Adi[v][:, int(N / 4) : (3 * int(N / 4))], newshape=(1, -1))
436
+ )
437
+
438
+ time = time[int(N / 4) : -int(N / 4)]
439
+ lon = lon[int(N / 4) : -int(N / 4)]
440
+ lat = lat[int(N / 4) : -int(N / 4)]
441
+
442
+ # Generate output structure. Copy attributes over.
443
+ out = xr.Dataset(coords={"time": time})
444
+ out.attrs = ds.attrs
445
+ out["lon"] = lon
446
+ out["lat"] = lat
447
+ for v in vars:
448
+ if v in ds:
449
+ out[v] = xr.DataArray(Adi[v], coords=(out.time,))
450
+ out[v].attrs = ds[v].attrs
451
+ out.assign_attrs(dict(tau1=tau1, tau2=tau2, L1=L1, L2=L2))
452
+
453
+ # ---Recalculate and replot spectra, coherence and phase---
454
+ t1 = Adi["t1"][int(N / 4) : -int(N / 4)] # Now N elements shorter
455
+ t2 = Adi["t2"][int(N / 4) : -int(N / 4)]
456
+ c1 = Adi["c1"][int(N / 4) : -int(N / 4)]
457
+ c2 = Adi["c2"][int(N / 4) : -int(N / 4)]
458
+ # p = Adi["p"][int(N / 4) : -int(N / 4)]
459
+
460
+ m = (i2 - N) / N # number of segments = dof/2
461
+ m = np.floor(m).astype("int64")
462
+ dof = 2 * m # Number of degrees of freedom (power of 2)
463
+ df = 1 / (N * dt) # Frequency resolution at dof degrees of freedom.
464
+
465
+ window = signal.windows.triang(N) * np.ones((m, N))
466
+ At1 = fft.fft(signal.detrend(np.reshape(t1, newshape=(m, N))) * window)
467
+ At2 = fft.fft(signal.detrend(np.reshape(t2, newshape=(m, N))) * window)
468
+ Ac1 = fft.fft(signal.detrend(np.reshape(c1, newshape=(m, N))) * window)
469
+ Ac2 = fft.fft(signal.detrend(np.reshape(c2, newshape=(m, N))) * window)
470
+
471
+ # Positive frequencies only
472
+ At1 = At1[:, 0 : int(N / 2)]
473
+ At2 = At2[:, 0 : int(N / 2)]
474
+ Ac1 = Ac1[:, 0 : int(N / 2)]
475
+ Ac2 = Ac2[:, 0 : int(N / 2)]
476
+ fn = f[0 : int(N / 2)]
477
+
478
+ Et1n = 2 * np.nanmean(np.absolute(At1[:, : int(N / 2)]) ** 2, 0) / df / N**2
479
+ Et2n = 2 * np.nanmean(np.absolute(At2[:, : int(N / 2)]) ** 2, 0) / df / N**2
480
+ Ec1n = 2 * np.nanmean(np.absolute(Ac1[:, : int(N / 2)]) ** 2, 0) / df / N**2
481
+ Ec2n = 2 * np.nanmean(np.absolute(Ac2[:, : int(N / 2)]) ** 2, 0) / df / N**2
482
+
483
+ # Cross Spectral Estimates
484
+ Ct1c1n = 2 * np.nanmean(At1 * np.conj(Ac1) / df / N**2, axis=0)
485
+ Ct2c2n = 2 * np.nanmean(At2 * np.conj(Ac2) / df / N**2, axis=0)
486
+
487
+ # Squared Coherence Estimates
488
+ Coht1c1n = np.real(Ct1c1n * np.conj(Ct1c1n) / (Et1n * Ec1n))
489
+ Coht2c2n = np.real(Ct2c2n * np.conj(Ct2c2n) / (Et2n * Ec2n))
490
+ # 95% confidence bound
491
+ # epsCoht1c1n = np.sqrt(2) * (1 - Coht1c1n) / np.sqrt(Coht1c1n) / np.sqrt(m)
492
+ # epsCoht2c2n = np.sqrt(2) * (1 - Coht2c2n) / np.sqrt(Coht2c2n) / np.sqrt(m)
493
+ # 95% significance level for coherence from Gille notes
494
+ betan = 1 - 0.05 ** (1 / (m - 1))
495
+
496
+ # Cross-spectral Phase Estimates
497
+ Phit1c1n = np.arctan2(np.imag(Ct1c1n), np.real(Ct1c1n))
498
+ Phit2c2n = np.arctan2(np.imag(Ct2c2n), np.real(Ct2c2n))
499
+ # 95% error bound
500
+ # epsPhit1c1n = np.arcsin(
501
+ # stats.t.ppf(0.05, dof) * np.sqrt((1 - Coht1c1n) / (dof * Coht1c1n))
502
+ # )
503
+ # epsPhit1c2n = np.arcsin(
504
+ # stats.t.ppf(0.05, dof) * np.sqrt((1 - Coht2c2n) / (dof * Coht2c2n))
505
+ # )
506
+
507
+ if ds.attrs["plot_spectra"]:
508
+ fig, ax = plt.subplots(
509
+ nrows=2, ncols=2, figsize=(9, 7), constrained_layout=True
510
+ )
511
+ ax0, ax1, ax2, ax3 = ax.flatten()
512
+
513
+ ax0.plot(fold, Et1, label="1 uncorrected", color="0.5")
514
+ ax0.plot(fold, Et2, label="2 uncorrected", color="0.8")
515
+ ax0.plot(fn, Et1n, label="sensor 1")
516
+ ax0.plot(fn, Et2n, label="sensor 2")
517
+ ax0.set(
518
+ yscale="log",
519
+ xscale="log",
520
+ xlabel="frequency [Hz]",
521
+ ylabel=r"spectral density [$^{\circ}$C$^2$/Hz]",
522
+ title="temperature spectra",
523
+ )
524
+ ax0.plot(
525
+ [fn[50], fn[50]],
526
+ [
527
+ dof * Et1n[100] / stats.distributions.chi2.ppf(0.05 / 2, dof),
528
+ dof * Et1n[100] / stats.distributions.chi2.ppf(1 - 0.05 / 2, dof),
529
+ ],
530
+ "k",
531
+ )
532
+ ax0.legend()
533
+
534
+ ax1.plot(fold, Ec1, label="1 uncorrected", color="0.5")
535
+ ax1.plot(fold, Ec2, label="2 uncorrected", color="0.8")
536
+ ax1.plot(fn, Ec1n, label="1")
537
+ ax1.plot(fn, Ec2n, label="2")
538
+ ax1.set(
539
+ yscale="log",
540
+ xscale="log",
541
+ xlabel="frequency [Hz]",
542
+ ylabel=r"spectral density [mmho$^2$/cm$^2$/Hz]",
543
+ title="conductivity spectra",
544
+ )
545
+ ax1.plot(
546
+ [fn[50], fn[50]],
547
+ [
548
+ dof * Ec1n[100] / stats.distributions.chi2.ppf(0.05 / 2, dof),
549
+ dof * Ec1n[100] / stats.distributions.chi2.ppf(1 - 0.05 / 2, dof),
550
+ ],
551
+ "k",
552
+ )
553
+
554
+ # Coherence between Temperature and Conductivity
555
+ ax2.plot(fold, Coht1c1, color="0.5")
556
+ ax2.plot(fold, Coht2c2, color="0.8")
557
+ ax2.plot(fn, Coht1c1n)
558
+ ax2.plot(fn, Coht2c2n)
559
+
560
+ # ax.plot(fn, Coht1c1 / (1 + 2 * epsCoht1c1), color="b", linewidth=0.5, alpha=0.2)
561
+ # ax.plot(fn, Coht1c1 / (1 - 2 * epsCoht1c1), color="b", linewidth=0.5, alpha=0.2)
562
+ ax2.plot(fn, betan * np.ones(fn.size), "k--")
563
+ ax2.set(
564
+ xlabel="frequency [Hz]",
565
+ ylabel="squared coherence",
566
+ ylim=(-0.1, 1.1),
567
+ title="t/c coherence",
568
+ )
569
+
570
+ # Phase between Temperature and Conductivity
571
+ ax3.plot(fold, Phit1c1, color="0.5")
572
+ ax3.plot(fold, Phit2c2, color="0.8")
573
+ ax3.plot(fn, Phit1c1n)
574
+ ax3.plot(fn, Phit2c2n)
575
+ ax3.set(
576
+ xlabel="frequency [Hz]",
577
+ ylabel="phase [rad]",
578
+ ylim=[-4, 4],
579
+ title="t/c phase",
580
+ # xscale="log",
581
+ )
582
+ ax3.plot(
583
+ fold,
584
+ -np.arctan(2 * np.pi * fold * x1[0]) - 2 * np.pi * fold * x1[1],
585
+ "k--",
586
+ )
587
+ ax3.plot(
588
+ fold,
589
+ -np.arctan(2 * np.pi * fold * x2[0]) - 2 * np.pi * fold * x2[1],
590
+ "k--",
591
+ )
592
+
593
+ if ds.attrs["plot_path"] != "":
594
+ plt.savefig(ds.attrs["plot_path"], dpi=200)
595
+
596
+ return out
597
+
598
+
599
+ def rmloops(ds):
600
+ """
601
+ Eliminate depth loops in CTD data based on sinking velocity.
602
+
603
+ All data variables are set to NaN within depth loops.
604
+
605
+ Parameters
606
+ ----------
607
+ ds : xarray.Dataset
608
+ CTD time series dataset
609
+
610
+ Returns
611
+ -------
612
+ ds : xarray.Dataset
613
+ CTD time series dataset
614
+ """
615
+
616
+ assert "wthresh" in ds.attrs
617
+
618
+ tsmooth = 0.25 # seconds
619
+ fs = 24 # Hz
620
+ w = calcs.wsink(ds.p.data, tsmooth, fs) # down/up +/-ve
621
+ iloop = np.array([])
622
+
623
+ # up or downcast?
624
+ pol = np.polyfit(np.array(range(ds.p.size)), ds.p, 1)
625
+ dn = 1 if pol[0] > 0 else 0
626
+
627
+ if dn:
628
+ # downcast
629
+ flp = np.squeeze(np.argwhere(w < ds.attrs["wthresh"]))
630
+ if flp.size >= 1:
631
+ ia, ib, ilen = helpers.findsegments(flp)
632
+ if ia[0] == ib[0] == 0:
633
+ ia = np.delete(ia, 0)
634
+ ib = np.delete(ib, 0)
635
+ for start, stop in zip(ia, ib):
636
+ pmi = np.argmax(ds.p[:stop].data)
637
+ pm = ds.p[pmi].data
638
+ tmp = np.squeeze(np.where(ds.p[pmi:] < pm))
639
+ iloop = np.append(iloop, pmi + 1)
640
+ iloop = np.append(iloop, pmi + tmp - 1)
641
+ iloop = np.unique(iloop)
642
+ iloop = iloop.astype("int64")
643
+ else:
644
+ iloop = np.array([])
645
+ else:
646
+ # upcast
647
+ flp = np.squeeze(np.argwhere(w > -ds.attrs["wthresh"]))
648
+ if flp.size >= 1:
649
+ ia, ib, ilen = helpers.findsegments(flp)
650
+ if ia[0] == ib[0] == 0:
651
+ ia = np.delete(ia, 0)
652
+ ib = np.delete(ib, 0)
653
+ for start, stop in zip(ia, ib):
654
+ pmi = np.argmax(ds.p[:stop].data)
655
+ pm = ds.p[pmi].data
656
+ tmp = np.squeeze(np.where(ds.p[:pmi] < pm))
657
+ iloop = np.append(iloop, pmi)
658
+ iloop = np.append(iloop, pmi - tmp)
659
+ iloop = np.unique(iloop)
660
+ iloop = iloop.astype("int64")
661
+ else:
662
+ iloop = np.array([])
663
+
664
+ # get data variable names
665
+ varnames = [k for k, v in ds.data_vars.items()]
666
+ for rmitem in ["lon", "lat", "z", "depth"]:
667
+ if rmitem in varnames:
668
+ varnames.remove(rmitem)
669
+ # set loops to NaN
670
+ for varn in varnames:
671
+ ds[varn][iloop] = np.nan
672
+
673
+ return ds
674
+
675
+
676
+ def bincast(ds, dz, zmin, zmax):
677
+ """
678
+ Depth-bin CTD time series.
679
+
680
+ Parameters
681
+ ----------
682
+ ds : xr.Dataset
683
+ CTD time series
684
+ dz : float
685
+ Bin size [m]
686
+ zmin : float
687
+ Minimum bin depth center [m]
688
+ zmax : float
689
+ Maximum bin depth center [m]
690
+
691
+ Returns
692
+ -------
693
+ ds : xr.Dataset
694
+ Depth-binned CTD profile
695
+ """
696
+ dz2 = dz / 2
697
+ zbin = np.arange(zmin - dz2, zmax + dz + dz2, dz)
698
+ zbinlabel = np.arange(zmin, zmax + dz, dz)
699
+
700
+ # prepare dataset
701
+ tmp = ds.swap_dims({"time": "depth"})
702
+ tmp = tmp.reset_coords()
703
+
704
+ # need to bin time separately, not sure why
705
+ btime = tmp.time.groupby_bins(
706
+ "depth", bins=zbin, labels=zbinlabel, right=True, include_lowest=True
707
+ ).mean()
708
+ # bin all variables
709
+ out = tmp.groupby_bins(
710
+ "depth", bins=zbin, labels=zbinlabel, right=True, include_lowest=True
711
+ ).mean()
712
+
713
+ # organize
714
+ out.coords["time"] = btime
715
+ out = out.set_coords(["lon", "lat"])
716
+ out = out.rename({"depth_bins": "depth"})
717
+
718
+ # copy attributes
719
+ # get data variable names
720
+ varnames = [k for k, v in ds.data_vars.items()]
721
+ for vari in varnames:
722
+ out[vari].attrs = ds[vari].attrs
723
+ out["depth"].attrs = {
724
+ "long_name": "depth",
725
+ "units": "m",
726
+ "standard_name": "depth",
727
+ "positive": "down",
728
+ }
729
+ out.attrs = ds.attrs
730
+
731
+ # recalculate pressure from depth bins
732
+ out["p"] = (("depth",), gsw.p_from_z(-1 * out.depth.data, out.lat.data))
733
+ out.p.attrs = {
734
+ "long_name": "pressure",
735
+ "units": "dbar",
736
+ "standard_name": "sea_water_pressure",
737
+ "positive": "down",
738
+ }
739
+
740
+ return out
741
+
742
+
743
+ def split_updn(ds):
744
+ """
745
+ Separate into down/up-casts and apply corrections.
746
+
747
+ ## TODO: (should this really be here?)
748
+ - tc lag correction
749
+ - thermal mass correction
750
+ - lowpass-filter T, C, oxygen
751
+
752
+ Parameters
753
+ ----------
754
+ data : xarray.Dataset
755
+ CTD time series
756
+
757
+ Returns
758
+ -------
759
+ datad : xarray.Dataset
760
+ CTD time series for downcast
761
+ datau : xarray.Dataset
762
+ CTD time series for upcast
763
+ """
764
+ n = ds.p.size
765
+ ipmax = np.argmax(ds.p.data)
766
+
767
+ datad = ds.isel(time=range(0, ipmax))
768
+ datau = ds.isel(time=range(ipmax, n))
769
+
770
+ return {"down": datad, "up": datau}