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/io.py ADDED
@@ -0,0 +1,976 @@
1
+ #!/usr/bin/env python
2
+ # coding: utf-8
3
+ import errno
4
+ import os
5
+ from pathlib import Path
6
+
7
+ import numpy as np
8
+ import pandas as pd
9
+ import scipy.io as sio
10
+ import xarray as xr
11
+ import xmltodict
12
+ from munch import Munch, munchify
13
+ from scipy.signal import savgol_filter
14
+
15
+ from .calcs import calc_allsal
16
+ from .helpers import datetime2mtlb, mtlb2datetime
17
+
18
+
19
+ def CTDx(filename):
20
+ c = CTDHex(filename).to_xarray()
21
+ add_default_proc_params(c)
22
+ return c
23
+
24
+
25
+ def add_default_proc_params(ds):
26
+ ds.attrs["verbose"] = 1
27
+ ds.attrs["bounds_p"] = [0.0, 6200.0]
28
+ ds.attrs["bounds_t"] = [-2.0, 40.0]
29
+ ds.attrs["bounds_c"] = [2.0, 6.0]
30
+ ds.attrs["bounds_s"] = [20, 38]
31
+ ds.attrs["spike_thresh_t"] = 0.5
32
+ ds.attrs["spike_thresh_s"] = 0.1
33
+ ds.attrs["prod_c"] = 5.0e-7
34
+ ds.attrs["prod_t"] = 1.0e-4
35
+ ds.attrs["prod_s"] = 1.0e-8
36
+ ds.attrs["prod_p"] = 1.0
37
+ ds.attrs["diff_c"] = 1.0e-1
38
+ ds.attrs["diff_t"] = 1.0e-1
39
+ ds.attrs["diff_s"] = 1.0e-3
40
+ ds.attrs["diff_p"] = 2.0
41
+ ds.attrs["wthresh"] = 0.1
42
+ ds.attrs["plot_spectra"] = 0
43
+ ds.attrs["plot_path"] = ""
44
+
45
+
46
+ class CTDHex(object):
47
+ """
48
+ Converter for Seabird CTD data in hex format. Initialize with full path to hex file.
49
+ xml config file needs to be located in the same directory.
50
+
51
+ TODO:
52
+ - Add oxygen hysteresis
53
+ - Convert fluorometer voltage
54
+ """
55
+
56
+ def __init__(self, filename):
57
+ self.filename = filename
58
+
59
+ self._map_attrs = dict(
60
+ lon=dict(
61
+ name="",
62
+ long_name="longitude",
63
+ standard_name="longitude",
64
+ units="degree_east",
65
+ ),
66
+ lat=dict(
67
+ name="",
68
+ long_name="latitude",
69
+ standard_name="latitude",
70
+ units="degree_north",
71
+ ),
72
+ t1=dict(
73
+ name="TemperatureSensor1",
74
+ long_name="temperature",
75
+ standard_name="sea_water_temperature",
76
+ units="degree_C",
77
+ ),
78
+ t2=dict(
79
+ name="TemperatureSensor2",
80
+ long_name="temperature",
81
+ standard_name="sea_water_temperature",
82
+ units="degree_C",
83
+ ),
84
+ c1=dict(
85
+ name="ConductivitySensor1",
86
+ long_name="conductivity",
87
+ standard_name="sea_water_conductivity",
88
+ units="mS cm-1",
89
+ ),
90
+ c2=dict(
91
+ name="ConductivitySensor2",
92
+ long_name="conductivity",
93
+ standard_name="sea_water_conductivity",
94
+ units="mS cm-1",
95
+ ),
96
+ p=dict(
97
+ name="PressureSensor",
98
+ long_name="pressure",
99
+ standard_name="sea_water_pressure",
100
+ units="dbar",
101
+ positive="down",
102
+ ),
103
+ oxygen1=dict(
104
+ name="OxygenSensor1",
105
+ long_name="oxygen",
106
+ standard_name="mass_concentration_of_oxygen_in_sea_water",
107
+ units="ml/L",
108
+ ),
109
+ alt=dict(
110
+ name="AltimeterSensor",
111
+ long_name="height above bottom",
112
+ standard_name="height_above_sea_floor",
113
+ units="m",
114
+ ),
115
+ spar=dict(name="SPAR_Sensor", long_name="SPAR"),
116
+ fl=dict(name="FluoroSeapointSensor", long_name="fluorescence"),
117
+ par=dict(name="PAR_BiosphericalLicorChelseaSensor", long_name="PAR"),
118
+ trans=dict(name="WET_LabsCStar", long_name="transmissivity"),
119
+ modcount=dict(name="modcount"),
120
+ )
121
+
122
+ volt_vars = ["oxygen1", "alt", "spar", "fl", "par", "trans"]
123
+ self._mapnames_volt = {v: self._map_attrs[v] for v in volt_vars}
124
+
125
+ # extract all data and metadata and convert to physical units
126
+ self._extract_physical_data()
127
+
128
+ def _extract_physical_data(self):
129
+ self.read_xml_config()
130
+ self.parse_hex()
131
+ self.physicalunits()
132
+
133
+ def _detect_missing_words(self):
134
+ """
135
+ Determine location of data entries in hex file line.
136
+
137
+ Sometimes there is no SPAR sensor, in this case one hex word may be
138
+ missing. It lives on voltage channel 9. There may also be words missing
139
+ if less sensors are present.
140
+
141
+ Locating the right data entries also depends on the number of bytes
142
+ written per scan. We read this information from the header.
143
+
144
+ This is still a bit of work in progress.
145
+
146
+ For details, see p.67/68 in manual-11pV2_018.pdf
147
+ """
148
+ # Read xml config file if this has not happened yet
149
+ if not hasattr(self, "cfgp"):
150
+ self.read_xml_config()
151
+ # Count the number of sensors in the configuration
152
+ n_index = self.cfgp.loc["@index"].values.shape[0]
153
+ # No SPAR sensor
154
+ if (
155
+ "14" not in self.cfgp.loc["@index"].values
156
+ and "SPAR_Sensor" not in self.cfgp.loc["@index"].keys()
157
+ ):
158
+ self._hexoffset = -6
159
+ else:
160
+ self._hexoffset = 0
161
+ # Word 8 missing (there is probably a better way to implement this)
162
+ if (
163
+ "14" not in self.cfgp.loc["@index"].values
164
+ and self._bytes_per_scan == 38
165
+ ):
166
+ self._hexoffset = -12
167
+ # Less voltage words can lead to a shorter data stream, see manual p. 68
168
+ if "14" in self.cfgp.loc["@index"].values and self._bytes_per_scan == 48:
169
+ self._extra_hexoffset = 8
170
+ elif "14" in self.cfgp.loc["@index"].values and self._bytes_per_scan == 44 and n_index == 11:
171
+ self._extra_hexoffset = 0
172
+ elif "14" in self.cfgp.loc["@index"].values and self._bytes_per_scan == 44 and n_index == 12:
173
+ self._extra_hexoffset = 8
174
+ elif "14" in self.cfgp.loc["@index"].values and self._bytes_per_scan == 45 and n_index == 10:
175
+ self._extra_hexoffset = 0
176
+ elif "14" not in self.cfgp.loc["@index"].values and self._bytes_per_scan == 45:
177
+ self._extra_hexoffset = 8
178
+ elif "14" not in self.cfgp.loc["@index"].values and self._bytes_per_scan == 38:
179
+ self._extra_hexoffset = 8
180
+ else:
181
+ self._extra_hexoffset = 0
182
+
183
+ def parse_hex(self): # noqa: C901
184
+ # Generate data structure for converted data: 5 freq, 8 voltage channels
185
+ tmp = {}
186
+ for i in range(5):
187
+ tmp["f{}".format(i)] = []
188
+ for i in range(8):
189
+ tmp["v{}".format(i)] = []
190
+ tmp["modcount"] = []
191
+ tmp["time"] = []
192
+ tmp["spar"] = []
193
+ tmp["pst"] = []
194
+ tmp["ctdstatus"] = []
195
+ tmp["lon"] = []
196
+ tmp["lat"] = []
197
+
198
+ out = dict(header="")
199
+
200
+ with open(self.filename, "rt") as fin:
201
+ # read header
202
+ for line in fin:
203
+ if len(line) == 1:
204
+ pass
205
+ elif line[0] == "*":
206
+ out["header"] += line
207
+ # if l[0:5] == "* ":
208
+ # i = l.find("=")
209
+ if line[0:17] == "* Number of Bytes":
210
+ i = line.find("=")
211
+ self._bytes_per_scan = int(line[i + 2 :])
212
+ if line[0:25] == "* Number of Voltage Words":
213
+ i = line.find("=")
214
+ self._voltage_words = int(line[i + 2 :])
215
+ if line[0:34] == "* Append System Time to Every Scan":
216
+ self._hex_has_time = True
217
+ else:
218
+ self._hex_has_time = False
219
+ if line[0:15] == "* System UTC = ":
220
+ self.header_time_str = line[15:35]
221
+ else:
222
+ break
223
+ self._detect_missing_words()
224
+ # read data
225
+ for line in fin:
226
+ # parse frequency channels
227
+ for k in tmp.keys():
228
+ if "f" in k:
229
+ i = int(k[1])
230
+ tmp[k].append(self._hexword2freq(line[slice(i * 6, i * 6 + 6)]))
231
+ # parse voltage channels
232
+ for i in range(4):
233
+ v1, v2 = self._hexword2volt(
234
+ line[slice((i + 5) * 6, (i + 5) * 6 + 6)]
235
+ )
236
+ tmp["v{}".format(i * 2)].append(v1)
237
+ tmp["v{}".format(i * 2 + 1)].append(v2)
238
+ # parse other channels
239
+ # spar
240
+ if self._hexoffset == 0:
241
+ tmp["spar"].append(self._hexword2spar(line[slice(57, 60)]))
242
+ # NMEA data
243
+ lat, lon = self._hexword2lonlat(
244
+ line[60 + self._hexoffset : 74 + self._hexoffset]
245
+ )
246
+ tmp["lon"].append(lon)
247
+ tmp["lat"].append(lat)
248
+ # pressure sensor temperature and ctd status
249
+ pst, ctdstatus = self._hexword2pstat(
250
+ line[
251
+ slice(
252
+ 74 + self._hexoffset + self._extra_hexoffset,
253
+ 78 + self._hexoffset + self._extra_hexoffset,
254
+ )
255
+ ]
256
+ )
257
+ tmp["pst"].append(pst)
258
+ tmp["ctdstatus"].append(ctdstatus)
259
+ # modcount
260
+ tmp["modcount"].append(
261
+ int(
262
+ line[
263
+ 78
264
+ + self._hexoffset
265
+ + self._extra_hexoffset : 80
266
+ + self._hexoffset
267
+ + self._extra_hexoffset
268
+ ],
269
+ 16,
270
+ )
271
+ )
272
+ # time
273
+ if self._hex_has_time:
274
+ tmp["time"].append(
275
+ int(
276
+ line[
277
+ 86
278
+ + self._hexoffset
279
+ + self._extra_hexoffset : 88
280
+ + self._hexoffset
281
+ + self._extra_hexoffset
282
+ ]
283
+ + line[
284
+ 84
285
+ + self._hexoffset
286
+ + self._extra_hexoffset : 86
287
+ + self._hexoffset
288
+ + self._extra_hexoffset
289
+ ]
290
+ + line[
291
+ 82
292
+ + self._hexoffset
293
+ + self._extra_hexoffset : 84
294
+ + self._hexoffset
295
+ + self._extra_hexoffset
296
+ ]
297
+ + line[
298
+ 80
299
+ + self._hexoffset
300
+ + self._extra_hexoffset : 82
301
+ + self._hexoffset
302
+ + self._extra_hexoffset
303
+ ],
304
+ 16,
305
+ )
306
+ )
307
+
308
+ # generate output array
309
+ # frequency variables are always there
310
+ freqvars = ["t1", "c1", "p", "t2", "c2"]
311
+ for i, k in enumerate(freqvars):
312
+ out[k] = np.array(tmp["f{}".format(i)])
313
+ # voltage variables. see which ones we have
314
+ for k, v in self._mapnames_volt.items():
315
+ if v["name"] in self.cfgp.keys():
316
+ channel = int(self.cfgp[v["name"]]["@index"])
317
+ vchannel = channel - 5
318
+ if vchannel < 8:
319
+ out[k] = np.array(tmp["v{}".format(vchannel)])
320
+ elif vchannel == 9 and k == "spar":
321
+ out["spar"] = np.array(tmp["spar"])
322
+ out["lon"] = np.array(tmp["lon"])
323
+ out["lat"] = np.array(tmp["lat"])
324
+ out["pst"] = np.array(tmp["pst"])
325
+ out["ctdstatus"] = tmp["ctdstatus"]
326
+ out["modcount"] = np.array(tmp["modcount"])
327
+ if self._hex_has_time:
328
+ out["time"] = np.array(tmp["time"])
329
+ else:
330
+ out["time"] = self._generate_time_vector(len(out["t1"]))
331
+ if "spar" in tmp:
332
+ out["spar"] = np.array(tmp["spar"])
333
+ self.dataraw = munchify(out)
334
+
335
+ def _hexword2freq_old(self, hex_str):
336
+ """
337
+ Convert Seabird hex data to frequency
338
+ each byte is given as two hex digits
339
+ each SB freq word is 3 bytes
340
+ calculates freq from 3 byte word
341
+
342
+ Parameters
343
+ ----------
344
+ hex : str
345
+ 6 character long hex string
346
+
347
+ Returns
348
+ -------
349
+ f : float
350
+ frequency
351
+
352
+ Notes
353
+ -----
354
+ This is an older version that is a bit slower than the new _hexword2freq.
355
+ """
356
+ f = (
357
+ int(hex_str[:2], 16) * 256
358
+ + int(hex_str[2:4], 16)
359
+ + int(hex_str[4:], 16) / 256
360
+ )
361
+ return f
362
+
363
+ def _hexword2freq(self, hex_str):
364
+ """
365
+ Convert Seabird hex data to frequency
366
+ each byte is given as two hex digits
367
+ each SB freq word is 3 bytes
368
+ calculates freq from 3 byte word
369
+
370
+ Parameters
371
+ ----------
372
+ hex : str
373
+ 6 character long hex string
374
+
375
+ Returns
376
+ -------
377
+ f : float
378
+ frequency
379
+
380
+ Notes
381
+ -----
382
+ This is a new version that is a bit faster than _hexword2freq_old.
383
+ See https://github.com/gunnarvoet/ctdproc/issues/1#issuecomment-2439574942
384
+ for details.
385
+ """
386
+ return int(hex_str, 16) / 256
387
+
388
+ def _hexword2volt_old(self, hex_str):
389
+ """
390
+ Convert Seabird hex data to voltage
391
+ each byte is given as two hex digits
392
+ each SB voltage is 1.5 words (8 MSB + 4 LSB)
393
+ calculates 2 voltages from 3 byte word
394
+
395
+ Parameters
396
+ ----------
397
+ hex_str : str
398
+ 6 character long hex str
399
+
400
+ Returns
401
+ -------
402
+ v1, v2 : float
403
+ voltages for 2 channels
404
+
405
+ Notes
406
+ -----
407
+ This is an older version that is a bit slower than the new _hexword2volt.
408
+ """
409
+ byte1 = format(int(hex_str[0:2], 16), "08b")
410
+ byte2 = format(int(hex_str[2:4], 16), "08b")
411
+ byte3 = format(int(hex_str[4:6], 16), "08b")
412
+
413
+ v1 = int(byte1 + byte2[:4], 2)
414
+ v2 = int(byte2[4:] + byte3, 2)
415
+
416
+ v1 = 5 * (1 - v1 / 4095)
417
+ v2 = 5 * (1 - v2 / 4095)
418
+
419
+ return v1, v2
420
+
421
+ def _hexword2volt(self, hex_str):
422
+ """
423
+ Convert Seabird hex data to voltage
424
+ each byte is given as two hex digits
425
+ each SB voltage is 1.5 words (8 MSB + 4 LSB)
426
+ calculates 2 voltages from 3 byte word
427
+
428
+ Parameters
429
+ ----------
430
+ hex_str : str
431
+ 6 character long hex str
432
+
433
+ Returns
434
+ -------
435
+ v1, v2 : float
436
+ voltages for 2 channels
437
+
438
+ Notes
439
+ -----
440
+ This is a new version that is a bit faster than _hexword2volt_old.
441
+ See https://github.com/gunnarvoet/ctdproc/issues/1#issuecomment-2455907445
442
+ for details.
443
+ """
444
+ e = int(hex_str, 16) ^ 0xffffff
445
+ v1 = (e >> 12)/819
446
+ v2 = (e & 0xfff)/819
447
+ return v1, v2
448
+
449
+ def _hexword2lonlat(self, hex_str):
450
+ """
451
+ Convert Seabird lon/lat data in hex format.
452
+ Each byte is given as two hex digits.
453
+ Last two characters contain pos/neg information.
454
+ More information on p. 43 in manual-11pV2_018.pdf
455
+
456
+ Parameters
457
+ ----------
458
+ hex_str : str
459
+ 7 byte / character long hex string
460
+
461
+ Returns
462
+ -------
463
+ lon, lat : float
464
+ longitude and latitude
465
+ """
466
+ b = format(int(hex_str[12:14], 16), "08b")
467
+ # newpos = int(b[7])
468
+ lonneg = int(b[1])
469
+ latneg = int(b[0])
470
+ lat = (
471
+ (-1) ** latneg
472
+ * (
473
+ int(hex_str[:2], 16) * 65536
474
+ + int(hex_str[2:4], 16) * 256
475
+ + int(hex_str[4:6], 16)
476
+ )
477
+ / 5e4
478
+ )
479
+ lon = (
480
+ (-1) ** lonneg
481
+ * (
482
+ int(hex_str[6:8], 16) * 65536
483
+ + int(hex_str[8:10], 16) * 256
484
+ + int(hex_str[10:12], 16)
485
+ )
486
+ / 5e4
487
+ )
488
+ return lat, lon
489
+
490
+ def _hexword2spar(self, hex_str):
491
+ """
492
+ Convert Seabird spar (Photosynthetically Active Radiation) hex data
493
+ each byte is given as two hex digits
494
+ each SB voltage is 1.5 words (8 MSB + 4 LSB)
495
+ calculates SPAR voltages from 1.5 byte half word
496
+
497
+ Parameters
498
+ ----------
499
+ hex_str : str
500
+ 3 character long hex string
501
+
502
+ Returns
503
+ -------
504
+ spar : float
505
+ Photosynthetically Active Radiation
506
+ """
507
+ byte1 = format(int(hex_str[0], 16), "04b")
508
+ byte2 = format(int(hex_str[1:3], 16), "08b")
509
+ spar = int(byte1 + byte2, 2) / 819
510
+ return spar
511
+
512
+ def _hexword2pstat(self, hex_str):
513
+ """
514
+ Convert Seabird pressure sensor temperature and ctd status from hex data.
515
+ Each byte is given as two hex digits.
516
+ 12 bit number from 0-4095 represents P sensor temperature .
517
+ 4 bit CTD status:
518
+ bit 0 = pump status = 1/0 = on/off
519
+ bit 1 = bottom contact = 1/0 = no contact/contact
520
+ bit 2 = water sampler confirm = 1/0 = deck unit detetcts/does not signal
521
+ bit 3 = CTD modem carrier detects/does not detect deck unit = 1/0
522
+
523
+ Parameters
524
+ ----------
525
+ hex_str : str
526
+ 4 character long hex string
527
+
528
+ Returns
529
+ -------
530
+ pst : int
531
+ Pressure sensor temperature
532
+ ctdstatus : binary
533
+ CTD status (see above)
534
+ """
535
+ byte1 = format(int(hex_str[0:2], 16), "08b")
536
+ byte2 = format(int(hex_str[2:4], 16), "08b")
537
+ pst = int(byte1 + byte2[:4], 2)
538
+ ctdstatus = byte2[4:]
539
+ return pst, ctdstatus
540
+
541
+ def _find_xmlconfig(self):
542
+ """Generate path to xml config file for current hex file.
543
+ Config file needs to be in the same directory as the hex file."""
544
+ pp = Path(self.filename)
545
+ name = pp.stem
546
+ # try upper case filename
547
+ xmlfile = name.upper() + ".XMLCON"
548
+ p = pp.parent
549
+ self.xmlfile = p.joinpath(xmlfile)
550
+ # use os.listdir to find the actual case of the filename if the upper
551
+ # case did not work.
552
+ if self.xmlfile.name not in os.listdir(os.path.dirname(self.xmlfile)):
553
+ xmlfile = name.lower() + ".XMLCON"
554
+ self.xmlfile = p.joinpath(xmlfile)
555
+
556
+ def read_xml_config(self):
557
+ """Read xml config file."""
558
+ self._find_xmlconfig()
559
+ try:
560
+ with open(self.xmlfile) as fd:
561
+ tmp = xmltodict.parse(fd.read())
562
+ except OSError as e:
563
+ raise FileNotFoundError(errno.ENOENT, os.strerror(errno.ENOENT), e.filename)
564
+ sa = tmp["SBE_InstrumentConfiguration"]["Instrument"]["SensorArray"]["Sensor"]
565
+ # parse only valid sensors
566
+ cfg = {}
567
+ ti = 0
568
+ ci = 0
569
+ oi = 0
570
+ for si in sa:
571
+ keys = list(si.keys())
572
+ for k in keys:
573
+ if "@" not in k and k != "NotInUse":
574
+ if k == "TemperatureSensor":
575
+ ti += 1
576
+ kstr = "{}{}".format(k, ti)
577
+ elif k == "ConductivitySensor":
578
+ ci += 1
579
+ kstr = "{}{}".format(k, ci)
580
+ elif k == "OxygenSensor":
581
+ oi += 1
582
+ kstr = "{}{}".format(k, oi)
583
+ else:
584
+ kstr = k
585
+ cfg[kstr] = si.copy()
586
+ cfg[kstr]["cal"] = munchify(cfg[kstr][k])
587
+ del cfg[kstr][k]
588
+ self.cfgp = pd.DataFrame(cfg)
589
+ self._xml_coeffs_to_float()
590
+
591
+ def _xml_coeffs_to_float(self):
592
+ # Convert calibration coefficients to floats.
593
+ keep_strings = [
594
+ "@SensorID",
595
+ "SerialNumber",
596
+ "CalibrationDate",
597
+ "UseG_J",
598
+ ]
599
+ for k in self.cfgp.keys():
600
+ for ki in self.cfgp[k].cal.keys():
601
+ if isinstance(self.cfgp[k]["cal"][ki], str):
602
+ if ki not in keep_strings:
603
+ self.cfgp[k]["cal"][ki] = float(self.cfgp[k]["cal"][ki])
604
+ elif isinstance(self.cfgp[k]["cal"][ki], list):
605
+ for i, li in enumerate(self.cfgp[k]["cal"][ki]):
606
+ for kli in li.keys():
607
+ self.cfgp[k]["cal"][ki][i][kli] = float(
608
+ self.cfgp[k]["cal"][ki][i][kli]
609
+ )
610
+ # We can't have None values in the xarray.Dataset later on
611
+ # or otherwise it won't properly write to netcdf. Therefore,
612
+ # convert any None items to 'N/A'
613
+ for ki, v in self.cfgp[k].cal.items():
614
+ if v is None:
615
+ self.cfgp[k].cal[ki] = "N/A"
616
+
617
+ def physicalunits(self):
618
+ # pressure
619
+ self._p_atm = 10.1353 # why not 10.1325 dbar?
620
+ self.data = Munch()
621
+ self.data.lon = self.dataraw.lon
622
+ self.data.lat = self.dataraw.lat
623
+ self.data.p = (
624
+ self._freq2pressure(
625
+ self.dataraw.p, self.dataraw.pst, self.cfgp.PressureSensor.cal
626
+ )
627
+ - self._p_atm
628
+ )
629
+ self.data.t1 = self._freq2temp(
630
+ self.dataraw.t1, self.cfgp.TemperatureSensor1.cal
631
+ )
632
+ self.data.t2 = self._freq2temp(
633
+ self.dataraw.t2, self.cfgp.TemperatureSensor2.cal
634
+ )
635
+ ccal1 = self.cfgp.ConductivitySensor1.cal.Coefficients[1]
636
+ self.data.c1 = self._freq2cond(
637
+ self.dataraw.c1, self.data.t1, self.data.p, ccal1
638
+ )
639
+ ccal2 = self.cfgp.ConductivitySensor2.cal.Coefficients[1]
640
+ self.data.c2 = self._freq2cond(
641
+ self.dataraw.c2, self.data.t2, self.data.p, ccal2
642
+ )
643
+ if hasattr(self.dataraw, "alt"):
644
+ self.data.alt = self._volt2alt(
645
+ self.dataraw.alt, self.cfgp.AltimeterSensor.cal
646
+ )
647
+ if hasattr(self.dataraw, "fl"):
648
+ # couldn't find any good calibration coefficients in the con file I looked at.
649
+ self.data.fl = self.dataraw.fl
650
+ if hasattr(self.dataraw, "trans"):
651
+ self.data.trans = self._volt2trans(
652
+ self.dataraw.trans, self.cfgp[self._mapnames_volt["trans"]["name"]].cal
653
+ )
654
+ if hasattr(self.dataraw, "par"):
655
+ self.data.par = self._volt2par(
656
+ self.dataraw.par, self.cfgp[self._mapnames_volt["par"]["name"]].cal
657
+ )
658
+ if hasattr(self.data, 'c1') and hasattr(self.data, 't1') and hasattr(self.data, 'p'):
659
+ if hasattr(self.data, 'lon') and hasattr(self.data, 'lat'):
660
+ lon, lat = self.data.lon, self.data.lat
661
+ else:
662
+ print("Warning: No position data, using default lon=0, lat=0")
663
+ lon, lat = 0.0, 0.0
664
+ SA, SP = calc_allsal(self.data.c1, self.data.t1, self.data.p, lon, lat)
665
+ self.data.sal = SP
666
+ self.data.SA = SA
667
+ # Ensure attributes dictionary exists
668
+ if not hasattr(self, '_map_attrs'):
669
+ self._map_attrs = {}
670
+ self._map_attrs["sal"] = {
671
+ "name": "sal",
672
+ "long_name": "Practical Salinity",
673
+ "units": "PSU",
674
+ }
675
+ self._map_attrs["SA"] = {
676
+ "name": "SA",
677
+ "long_name": "Absolute Salinity",
678
+ "units": "g/kg",
679
+ }
680
+ if hasattr(self.dataraw, "oxygen1"):
681
+ if hasattr(self.data, 'time'):
682
+ time = self.data.time
683
+ elif hasattr(self.data, 'scan'):
684
+ time = self.data.scan
685
+ else:
686
+ time = np.arange(len(self.dataraw.oxygen1))
687
+ self.data.oxygen1 = self._volt2oxygen(
688
+ self.dataraw.oxygen1,
689
+ self.data.t1,
690
+ self.data.sal,
691
+ self.data.p,
692
+ time,
693
+ self.cfgp[self._mapnames_volt["oxygen1"]["name"]].cal,
694
+ min_pressure=1.0
695
+ )
696
+ self.data.modcount = self.dataraw.modcount
697
+ self._check_modcount_errors(self.data.modcount)
698
+ self.data.dtnum = self.sbetime_to_mattime(self.dataraw.time)
699
+ self.data.time = self.mattime_to_datetime64(self.data.dtnum)
700
+
701
+ def _calculate_oxsol(self, temp, salinity):
702
+ """
703
+ Calculate oxygen solubility using Garcia and Gordon (1992).
704
+ Returns oxygen solubility in ml/L.
705
+ """
706
+ T_scaled = np.log((298.15 - temp) / (273.15 + temp))
707
+
708
+ # Garcia and Gordon (1992) coefficients
709
+ A0 = 2.00907
710
+ A1 = 3.22014
711
+ A2 = 4.05010
712
+ A3 = 4.94457
713
+ A4 = -0.256847
714
+ A5 = 3.88767
715
+ B0 = -0.00624523
716
+ B1 = -0.00737614
717
+ B2 = -0.0103410
718
+ B3 = -0.00817083
719
+ C0 = -0.000000488682
720
+
721
+ oxsol = np.exp(
722
+ A0 + A1*T_scaled + A2*T_scaled**2 + A3*T_scaled**3 +
723
+ A4*T_scaled**4 + A5*T_scaled**5 +
724
+ salinity * (B0 + B1*T_scaled + B2*T_scaled**2 + B3*T_scaled**3) +
725
+ C0 * salinity**2
726
+ )
727
+
728
+ return oxsol
729
+
730
+ def _calculate_tau(self, temp, pressure, cal):
731
+ """Calculate sensor time constant tau(T,P)."""
732
+ D0 = float(cal.D0)
733
+ D1 = float(cal.D1)
734
+ D2 = float(cal.D2)
735
+ tau20 = float(cal.Tau20)
736
+
737
+ tau = tau20 * D0 * np.exp(D1*pressure + D2*(temp - 20))
738
+ return tau
739
+
740
+ def _calculate_dvdt_window(self, volt, time_seconds, window_seconds=2.0):
741
+ """
742
+ Calculate dV/dt over a window as specified in SBE documentation.
743
+ If there are fewer than 2 points in the window (2s by default), dV/dt is set to zero.
744
+ """
745
+ # Convert the desired window length (in seconds) to samples
746
+ dt = np.mean(np.diff(time_seconds))
747
+ window_length = int(window_seconds / dt)
748
+
749
+ # window_length must be odd and >= polyorder + 2
750
+ if window_length % 2 == 0:
751
+ window_length += 1
752
+ window_length = max(window_length, 5)
753
+
754
+ # Compute the first derivative directly
755
+ dVdt = savgol_filter(volt, window_length=window_length, polyorder=1, deriv=1, delta=dt)
756
+
757
+ return dVdt
758
+
759
+ def _freq2pressure(self, freq, tc, pcal):
760
+ """Calculates pressure given frequency pressure temperature compensation
761
+ and pressure calibration structure pcal"""
762
+ psi2dbar = 0.689476
763
+
764
+ Td = pcal.AD590M * tc + pcal.AD590B
765
+
766
+ c = pcal.C1 + Td * (pcal.C2 + Td * pcal.C3)
767
+ d = pcal.D1 + Td * pcal.D2
768
+ t0 = pcal.T1 + Td * (pcal.T2 + Td * (pcal.T3 + Td * (pcal.T4 + Td * pcal.T5)))
769
+ t0f = 1e-6 * t0 * freq
770
+ fact = 1 - (t0f * t0f)
771
+ pres = psi2dbar * (c * fact * (1 - d * fact))
772
+ pres = pcal.Slope * pres + pcal.Offset
773
+ return pres
774
+
775
+ def _freq2temp(self, freq, tcal):
776
+ """Calculate temperature given frequency and
777
+ temperature calibration structure tcal
778
+ D. Rudnick 01/06/05"""
779
+
780
+ logf0f = np.log(tcal.F0 / freq)
781
+ temp = (
782
+ 1 / (tcal.G + logf0f * (tcal.H + logf0f * (tcal.I + logf0f * tcal.J)))
783
+ ) - 273.15
784
+ return temp
785
+
786
+ def _freq2cond(self, freq, temp, pres, ccal):
787
+ """Calculates conductivity given frequency, temperature,
788
+ pressure and conductivity calibration structure ccal.
789
+ D. Rudnick 01/06/05"""
790
+
791
+ ff = freq / 1000
792
+ cond = (ccal.G + ff * ff * (ccal.H + ff * (ccal.I + ff * ccal.J))) / (
793
+ 10 * (1 + ccal.CTcor * temp + ccal.CPcor * pres)
794
+ )
795
+ return cond
796
+
797
+ def _volt2alt(self, volt, acal):
798
+ """Calculate altimeter data from voltage."""
799
+ alt = volt * acal.ScaleFactor + acal.Offset
800
+ return alt
801
+
802
+ def _volt2trans(self, volt, transcal):
803
+ """Calculate transmissometer data from voltage."""
804
+ trans = volt * transcal.M + transcal.B
805
+ return trans
806
+
807
+ def _volt2par(self, volt, cal):
808
+ """Calculate PAR data from voltage."""
809
+ par = (
810
+ cal.Multiplier
811
+ * (10**9 * 10 ** ((volt - cal.B) / cal.M))
812
+ / cal.CalibrationConstant
813
+ ) + cal.Offset
814
+ return par
815
+
816
+ def _volt2oxygen(self, volt, temp, salinity, pressure, time, ocal,
817
+ use_tau_correction=True, min_pressure=1.0):
818
+ """
819
+ Convert oxygen voltage to ml/L using SBE43 equation.
820
+
821
+ Parameters
822
+ ----------
823
+ use_tau_correction : bool
824
+ Apply tau*dV/dt correction. Default is True.
825
+ min_pressure : float
826
+ Minimum pressure (dbar) below which oxygen is set to NaN (default 1.0).
827
+ """
828
+ equation_index = int(ocal.Use2007Equation) if hasattr(ocal, 'Use2007Equation') else 1
829
+ cal = ocal.CalibrationCoefficients[equation_index]
830
+
831
+ Soc = float(cal.Soc)
832
+ Voffset = float(cal.offset)
833
+ A = float(cal.A)
834
+ B = float(cal.B)
835
+ C = float(cal.C)
836
+ E = float(cal.E)
837
+
838
+ # Convert time to numeric seconds
839
+ if hasattr(time, 'dtype') and np.issubdtype(time.dtype, np.datetime64):
840
+ time_seconds = (time - time[0]) / np.timedelta64(1, 's')
841
+ elif hasattr(time, 'dtype') and np.issubdtype(time.dtype, np.timedelta64):
842
+ time_seconds = time / np.timedelta64(1, 's')
843
+ else:
844
+ time_seconds = np.asarray(time, dtype=float)
845
+
846
+ # Calculate tau*dV/dt correction if requested (for moorings)
847
+ if use_tau_correction:
848
+ dVdt = self._calculate_dvdt_window(volt, time_seconds, window_seconds=2.0)
849
+ tau = self._calculate_tau(temp, pressure, cal)
850
+ tau_correction = tau * dVdt
851
+ else:
852
+ print(" Skipping tau*dV/dt correction...")
853
+ tau_correction = 0.0
854
+
855
+ # Calculate oxygen solubility
856
+ oxsol = self._calculate_oxsol(temp, salinity)
857
+
858
+ # Convert temperature to Kelvin
859
+ K = temp + 273.15
860
+
861
+ # SBE43 equation
862
+ oxygen = Soc * (volt + Voffset + tau_correction) * \
863
+ oxsol * \
864
+ (1.0 + A*temp + B*temp**2 + C*temp**3) * \
865
+ np.exp(E * pressure / K)
866
+
867
+ # Quality control
868
+ oxygen = np.where(pressure < min_pressure, np.nan, oxygen)
869
+ oxygen = np.where(salinity < 1.0, np.nan, oxygen)
870
+ oxygen = np.where(oxygen < 0, np.nan, oxygen)
871
+ oxygen = np.where(oxygen > 15, np.nan, oxygen)
872
+
873
+ return oxygen
874
+
875
+ def _check_modcount_errors(self, modcount):
876
+ """Check for modcount errors."""
877
+ dmc = np.diff(modcount)
878
+ mmc = np.mod(dmc, 256)
879
+ fmc = np.squeeze(np.where(mmc - 1))
880
+ if np.any(fmc):
881
+ print("Warning: {} bad modcounts".format(len(dmc[mmc != 1])))
882
+ print("Warning: {} missing scans".format(np.sum(mmc[fmc])))
883
+
884
+ def sbetime_to_mattime(self, dt):
885
+ """Convert SBE time format to matlab time format."""
886
+ dtnum = dt / 24 / 3600 + 719529
887
+ return dtnum
888
+
889
+ def mattime_to_sbetime(self, dt):
890
+ """Convert matlab time format to SBE time format."""
891
+ dtnum = (dt - 719529) * 24 * 3600
892
+ return dtnum
893
+
894
+ def mattime_to_datetime64(self, dtnum):
895
+ """Convert Matlab time format to numpy datetime64 time format."""
896
+ dt64 = mtlb2datetime(dtnum)
897
+ return dt64
898
+
899
+ def _generate_time_vector(self, length):
900
+ """generate time vector in SBE time format from a start time string"""
901
+ pt = pd.to_datetime(self.header_time_str)
902
+ # generate a 24Hz time vector
903
+ pr = pd.date_range(
904
+ pt,
905
+ periods=length,
906
+ freq="{}ns".format(np.int64(np.round(1 / 24 * 1e9))),
907
+ )
908
+ # t = pr.to_numpy()
909
+ mattime = datetime2mtlb(pr.to_numpy())
910
+ sbetime = self.mattime_to_sbetime(mattime)
911
+ return sbetime
912
+
913
+ def to_mat(self, matname):
914
+ """Save data in Matlab format."""
915
+ ctdout = self.data.copy()
916
+ ctdout.pop("time")
917
+ # ctdout.pop('matlabtime')
918
+ # ctdout['den'] = ctdout['pden']
919
+ # ctdout.pop('pden')
920
+ # ctdout['time'] = ctd['matlabtime']
921
+ sio.savemat(matname, ctdout, format="5")
922
+
923
+ def to_xarray(self):
924
+ """Convert data into xarray.Dataset.
925
+
926
+ Returns
927
+ -------
928
+ ds : xarray.Dataset
929
+ CTD time series in Dataset format.
930
+ """
931
+ dsout = self.data.copy()
932
+ dsout.pop("time")
933
+ dsout.pop("dtnum")
934
+ datavars = dsout.keys()
935
+ ds_data = {var: (["time"], self.data[var]) for var in datavars}
936
+
937
+ ds = xr.Dataset(
938
+ data_vars=dict(ds_data),
939
+ coords={
940
+ "time": (
941
+ ["time"],
942
+ self.data["time"],
943
+ {
944
+ "long_name": "time",
945
+ "standard_name": "time",
946
+ },
947
+ )
948
+ },
949
+ )
950
+ # set attributes
951
+ for k in ds.data_vars:
952
+ v = self._map_attrs[k]
953
+ name = v.pop("name")
954
+ ds[k].attrs = {**ds[k].attrs, **v}
955
+ if name not in self.cfgp:
956
+ continue
957
+ ds[k].attrs["serial_number"] = self.cfgp[name].cal["SerialNumber"]
958
+ ds[k].attrs["calibration_date"] = self.cfgp[name].cal["CalibrationDate"]
959
+
960
+ return ds
961
+
962
+
963
+ def prof_to_mat(matname, datad, datau):
964
+ out = dict(
965
+ datad=datad,
966
+ datau=datau,
967
+ )
968
+ # a few adjustments before saving as .mat file
969
+ for k, v in out.items():
970
+ # matlab time as new variable
971
+ # out[k]['datenum'] = (['time'], datetime2mtlb(v.time.data))
972
+ # drop datetime64
973
+ out[k].drop("time")
974
+ # make coordinates variables so they are saved
975
+ out[k] = v.reset_coords()
976
+ sio.savemat(matname, out, format="5")