PyMVP 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.
PyMVP/main.py ADDED
@@ -0,0 +1,1776 @@
1
+ ##########################################################################
2
+ # PyMVP/main.py
3
+ # Author: Maximilien Wemaere (LMD/CNRS)
4
+ # Date: March 2026
5
+ #
6
+ #
7
+ # Simple routines to load, analyze and correct data from a Moving Vessel Profiler (MVP) 300
8
+ # Requires numpy, matplotlib, gsw, seabird, tqdm, cartopy
9
+ #
10
+ #
11
+ # Routines to read mvp data are adapted from routines provided by Pierre l'Hegaret (UBO)
12
+ # (mvp_routines.py, temporal_lag_correction.py, thermal_mass_correction.py)
13
+ #
14
+ #
15
+ # STILL IN DEVELOPMENT !
16
+ #
17
+ #
18
+ ##########################################################################
19
+
20
+
21
+
22
+
23
+ from math import e
24
+ import numpy as np
25
+ import glob
26
+ from datetime import datetime
27
+ import matplotlib.pyplot as plt
28
+ import os
29
+ import gsw
30
+ from seabird.cnv import fCNV
31
+ from tqdm import tqdm
32
+ import cartopy.crs as ccrs
33
+ import cartopy.feature as cfeature
34
+ import xarray as xr
35
+ from . import mvp_routines as mvp
36
+ from scipy.ndimage import median_filter
37
+
38
+
39
+ class Analyzer:
40
+ def __init__(self, data_path, output_path=None, subdirs=False, Yorig=1950):
41
+ """
42
+ Initialize the analyzer with the data path and reference year.
43
+ Args:
44
+ data_path (str): Path to the folder containing MVP files.
45
+ subdirs (bool): Whether to search in subdirectories for MVP files (default False).
46
+ Yorig (int): Reference year for dates (default 1950).
47
+ """
48
+ self.Yorig = Yorig
49
+ self.date_ref = datetime(Yorig, 1, 1)
50
+ self.data_path = data_path
51
+ self.output_path = output_path if output_path is not None else data_path
52
+ self.subdirs = subdirs
53
+ self.mvp = False
54
+ self.ctd = False
55
+
56
+
57
+ def load_mvp_data(self,delp=[],data_path=None,format='raw',only_new=False):
58
+ """
59
+ Load MVP data from .raw and .log files in the data_path folder.
60
+ Fills the object attributes with data matrices and associated metadata.
61
+ Args:
62
+ delp (list): Indices of profiles to remove from the list (optional).
63
+ data_path (str): Path to the folder containing MVP files (optional).
64
+ """
65
+ if data_path is not None:
66
+ self.data_path = data_path
67
+
68
+ if format=='raw':
69
+ if self.subdirs:
70
+ files = sorted(filter(os.path.isfile,glob.glob(self.data_path + '**/*.raw', recursive=True)))
71
+ else:
72
+ files = sorted(filter(os.path.isfile,glob.glob(self.data_path + '*.raw', recursive=self.subdirs)))
73
+
74
+
75
+ if only_new:
76
+ list_output = [f for f in os.listdir(self.output_path) if f.endswith(".nc")]
77
+ files = [f for f in files if not "MVP_"+os.path.basename(f).replace('.raw', '.nc') in list_output]
78
+
79
+ elif format=='ncdf':
80
+ if self.subdirs:
81
+ files = sorted(filter(os.path.isfile,glob.glob(self.data_path + '**/MVP*.nc', recursive=True)))
82
+ else:
83
+ files = sorted(filter(os.path.isfile,glob.glob(self.data_path + 'MVP*.nc', recursive=self.subdirs)))
84
+
85
+ if only_new:
86
+ list_output = [f for f in os.listdir(self.output_path) if f.endswith(".nc")]
87
+ files = [f for f in files if not "MVP_"+os.path.basename(f) in list_output]
88
+
89
+
90
+ print('Found ' + str(len(files)) + ' MVP files in the directory: ' + self.data_path)
91
+
92
+
93
+
94
+ if format=='ncdf':
95
+
96
+ for f in files:
97
+ nc = xr.open_dataset(f)
98
+ self.PRES_mvp = nc['PRES'].values
99
+ self.TEMP_mvp = nc['TEMP'].values
100
+ self.COND_mvp = nc['COND'].values
101
+ self.SOUNDVEL_mvp = nc['SOUNDVEL'].values
102
+ self.DO_mvp = nc['DO'].values
103
+ self.TEMP2_mvp = nc['TEMP2'].values
104
+ self.SUNA_mvp = nc['SUNA'].values
105
+ self.FLUO_mvp = nc['FLUO'].values
106
+ self.TURB_mvp = nc['TURB'].values
107
+ self.PH_mvp = nc['PH'].values
108
+ self.SALT_mvp = nc['SAL'].values
109
+ self.TIME_mvp = nc['TIME_s'].values
110
+ self.LAT_mvp = nc['LATITUDE'].values
111
+ self.LON_mvp = nc['LONGITUDE'].values
112
+ self.DATETIME_mvp = nc['profile_time'].values
113
+ self.DIR = nc['direction'].values
114
+ self.label_mvp = nc['profile'].values
115
+ self.freq_echant = nc.attrs['sampling frequency_hz']
116
+
117
+ nc.close()
118
+ print('MVP data loaded successfully.')
119
+ self.mvp = True
120
+
121
+ return
122
+
123
+ PRES_temp = []
124
+ TEMP_temp = []
125
+ COND_temp = []
126
+ SOUNDVEL_temp = []
127
+ DO_temp = []
128
+ TEMP2_temp = [] # temp from DO sensor
129
+ SUNA_temp = []
130
+ FLUO_temp = []
131
+ TURB_temp = []
132
+ PH_temp = []
133
+ SALT_temp = []
134
+ TIME_mvp_temp = []
135
+ LAT_temp = []
136
+ LON_temp= []
137
+ DATETIME_mvp = []
138
+ DIR = []
139
+ Label_mvp = []
140
+
141
+ delp.sort(reverse=True)
142
+ for i in delp:
143
+ del files[i]
144
+
145
+ for mvp_dat_name in files[0:]:
146
+
147
+ mvp_log_name=mvp_dat_name[:-4]+'.log'
148
+
149
+ # Get start and end time of the cycle
150
+
151
+ if format=='raw':
152
+ (mvp_tstart,mvp_tend,cycle_dur, lat, lon, dt_station) = mvp.get_log(mvp_log_name,self.Yorig)
153
+
154
+
155
+ if cycle_dur>1:
156
+
157
+ # Read one cycle MVP data
158
+ (pres,soundvel,cond,temp,do_raw,temp2_raw,suna_raw,fluo_raw,turb_raw,ph_raw) = mvp.read_mvp_cycle_raw(mvp_dat_name)
159
+ (pres,soundvel,cond,temp,do,temp2,suna,fluo,turb,ph) = mvp.raw_data_conversion(pres,soundvel,cond,temp,do_raw,temp2_raw,suna_raw,fluo_raw,turb_raw,ph_raw)
160
+
161
+
162
+ freq_echant = float(len(pres)/cycle_dur)
163
+
164
+ DATETIME_mvp.append(dt_station)
165
+
166
+ if np.nanmax(pres)-np.nanmin(pres)>2:
167
+
168
+ # Allocate time to samples and select the ascending part
169
+ (pres_up,soundvel_up,cond_up,temp_up,do_up,temp2_up,suna_up,fluo_up,turb_up,ph_up,time_up) = mvp.time_mvp_cycle_up([pres,soundvel,cond,temp,do,temp2,suna,fluo,turb,ph],mvp_tstart,mvp_tend)
170
+ (pres_down,soundvel_down,cond_down,temp_down,do_down,temp2_down,suna_down,fluo_down,turb_down,ph_down,time_down) = mvp.time_mvp_cycle_down([pres,soundvel,cond,temp,do,temp2,suna,fluo,turb,ph],mvp_tstart,mvp_tend)
171
+
172
+ if len(pres_down)>0:
173
+ if np.nanmax(pres_down)-np.nanmin(pres_down)>2:
174
+ PRES_temp.append(pres_down)
175
+ SOUNDVEL_temp.append(soundvel_down)
176
+ COND_temp.append(cond_down)
177
+ TEMP_temp.append(temp_down)
178
+ DO_temp.append(do_down)
179
+ TEMP2_temp.append(temp2_down)
180
+ SUNA_temp.append(suna_down)
181
+ FLUO_temp.append(fluo_down)
182
+ TURB_temp.append(turb_down)
183
+ PH_temp.append(ph_down)
184
+ SALT_temp.append(gsw.SP_from_C(cond_down, temp_down,pres_down))
185
+ TIME_mvp_temp.append(time_down)
186
+ LAT_temp.append(lat)
187
+ LON_temp.append(lon)
188
+
189
+ DIR.append('down')
190
+ Label_mvp.append(mvp_dat_name.replace('\\','/').split('/')[-2])
191
+
192
+ else:
193
+ print('ohohoh no down profile found for file: ' + mvp_dat_name)
194
+
195
+
196
+ if len(pres_up)>0:
197
+ if np.nanmax(pres_up)-np.nanmin(pres_up)>2:
198
+ PRES_temp.append(pres_up)
199
+ SOUNDVEL_temp.append(soundvel_up)
200
+ COND_temp.append(cond_up)
201
+ TEMP_temp.append(temp_up)
202
+ DO_temp.append(do_up)
203
+ TEMP2_temp.append(temp2_up)
204
+ SUNA_temp.append(suna_up)
205
+ FLUO_temp.append(fluo_up)
206
+ TURB_temp.append(turb_up)
207
+ PH_temp.append(ph_up)
208
+ SALT_temp.append(gsw.SP_from_C(cond_up, temp_up,pres_up))
209
+ TIME_mvp_temp.append(time_up)
210
+ LAT_temp.append(lat)
211
+ LON_temp.append(lon)
212
+ DIR.append('up')
213
+ Label_mvp.append(mvp_dat_name.replace('\\','/').split('/')[-2])
214
+
215
+ else:
216
+ print('ohohoh no up profile found for file: ' + mvp_dat_name)
217
+
218
+ else:
219
+ print('ohohoh no profile found for file: ' + mvp_dat_name)
220
+
221
+
222
+
223
+
224
+ # Re-arange files into matrices
225
+ M_size = 0
226
+ for i in range(len(PRES_temp)):
227
+ M_size = max(M_size, len(PRES_temp[i]))
228
+
229
+ PRES_mvp = np.zeros(( len(PRES_temp), M_size))
230
+ SOUNDVEL_mvp = np.zeros(( len(PRES_temp), M_size))
231
+ COND_mvp = np.zeros(( len(PRES_temp), M_size))
232
+ TEMP_mvp = np.zeros(( len(PRES_temp), M_size))
233
+ DO_mvp = np.zeros(( len(PRES_temp), M_size))
234
+ TEMP_mvp2 = np.zeros(( len(PRES_temp), M_size))
235
+ SUNA_mvp = np.zeros(( len(PRES_temp), M_size))
236
+ FLUO_mvp = np.zeros(( len(PRES_temp), M_size))
237
+ TURB_mvp = np.zeros(( len(PRES_temp), M_size))
238
+ PH_mvp = np.zeros(( len(PRES_temp), M_size))
239
+ SALT_mvp = np.zeros(( len(PRES_temp), M_size))
240
+ TIME_mvp = np.zeros(( len(PRES_temp), M_size))
241
+ LAT_mvp = np.zeros(( len(PRES_temp), M_size))
242
+ LON_mvp = np.zeros(( len(PRES_temp), M_size))
243
+ PRES_mvp[:] = np.nan
244
+ SOUNDVEL_mvp[:] = np.nan
245
+ COND_mvp[:] = np.nan
246
+ TEMP_mvp[:] = np.nan
247
+ DO_mvp[:] = np.nan
248
+ TEMP_mvp2[:] = np.nan
249
+ SUNA_mvp[:] = np.nan
250
+ FLUO_mvp[:] = np.nan
251
+ TURB_mvp[:] = np.nan
252
+ PH_mvp[:] = np.nan
253
+ SALT_mvp[:] = np.nan
254
+ TIME_mvp[:] = np.nan
255
+ LAT_mvp[:] = np.nan
256
+ LON_mvp[:] = np.nan
257
+
258
+ del M_size
259
+
260
+ for i in range(len(PRES_temp)):
261
+ PRES_mvp[i,0:len(PRES_temp[i])] = PRES_temp[i]
262
+ SOUNDVEL_mvp[i,0:len(SOUNDVEL_temp[i])] = SOUNDVEL_temp[i]
263
+ COND_mvp[i,0:len(COND_temp[i])] = COND_temp[i]
264
+ TEMP_mvp[i,0:len(TEMP_temp[i])] = TEMP_temp[i]
265
+ DO_mvp[i,0:len(DO_temp[i])] = DO_temp[i]
266
+ TEMP_mvp2[i,0:len(TEMP2_temp[i])] = TEMP2_temp[i]
267
+ SUNA_mvp[i,0:len(SUNA_temp[i])] = SUNA_temp[i]
268
+ FLUO_mvp[i,0:len(FLUO_temp[i])] = FLUO_temp[i]
269
+ TURB_mvp[i,0:len(TURB_temp[i])] = TURB_temp[i]
270
+ PH_mvp[i,0:len(PH_temp[i])] = PH_temp[i]
271
+ SALT_mvp[i,0:len(SALT_temp[i])] = SALT_temp[i]
272
+ TIME_mvp[i,0:len(TIME_mvp_temp[i])] = TIME_mvp_temp[i]
273
+ LAT_mvp[i,0:len(PRES_temp[i])] = LAT_temp[i]
274
+ LON_mvp[i,0:len(PRES_temp[i])] = LON_temp[i]
275
+
276
+
277
+ self.PRES_mvp = PRES_mvp
278
+ self.SOUNDVEL_mvp = SOUNDVEL_mvp
279
+ self.COND_mvp = COND_mvp
280
+ self.TEMP_mvp = TEMP_mvp
281
+ self.DO_mvp = DO_mvp
282
+ self.TEMP2_mvp = TEMP_mvp2
283
+ self.SUNA_mvp = SUNA_mvp
284
+ self.FLUO_mvp = FLUO_mvp
285
+ self.TURB_mvp = TURB_mvp
286
+ self.PH_mvp = PH_mvp
287
+ self.SALT_mvp = SALT_mvp
288
+ self.TIME_mvp = TIME_mvp
289
+ self.LAT_mvp = LAT_mvp
290
+ self.LON_mvp = LON_mvp
291
+ self.DATETIME_mvp = DATETIME_mvp
292
+ self.DIR = DIR
293
+ self.label_mvp = Label_mvp
294
+ self.freq_echant = freq_echant
295
+
296
+ del PRES_temp, SOUNDVEL_temp, DO_temp, TEMP2_temp, SUNA_temp, FLUO_temp, TURB_temp, PH_temp, COND_temp, TEMP_temp, SALT_temp, TIME_mvp_temp, LAT_temp, LON_temp
297
+
298
+ print('MVP data loaded successfully.')
299
+ self.mvp = True
300
+
301
+
302
+
303
+
304
+ def load_mvp_data_again(self,data_path=None,format='raw',delp=[]):
305
+ """
306
+ Load MVP data from .raw and .log files in the data_path folder.
307
+ Fills the object attributes with data matrices and associated metadata.
308
+ Args:
309
+ data_path (str): Path to the folder containing MVP files.
310
+ delp (list): Indices of profiles to remove from the list (optional).
311
+ """
312
+ if data_path is not None:
313
+ self.data_path = data_path
314
+
315
+ if format=='raw':
316
+ files = sorted(filter(os.path.isfile,glob.glob(self.data_path + '*.raw', recursive=True)))
317
+ elif format=='ncdf':
318
+ files = sorted(filter(os.path.isfile,glob.glob(self.data_path + '**/MVP*.nc', recursive=True)))
319
+ print('Found ' + str(len(files)) + ' MVP files in the directory: ' + self.data_path)
320
+
321
+
322
+
323
+ if format=='ncdf':
324
+ for f in files:
325
+ nc = xr.open_dataset(f)
326
+ self.PRES_mvp = nc['PRES'].values
327
+ self.TEMP_mvp = nc['TEMP'].values
328
+ self.COND_mvp = nc['COND'].values
329
+ self.SOUNDVEL_mvp = nc['SOUNDVEL'].values
330
+ self.DO_mvp = nc['DO'].values
331
+ self.TEMP2_mvp = nc['TEMP2'].values
332
+ self.SUNA_mvp = nc['SUNA'].values
333
+ self.FLUO_mvp = nc['FLUO'].values
334
+ self.TURB_mvp = nc['TURB'].values
335
+ self.PH_mvp = nc['PH'].values
336
+ self.SALT_mvp = nc['SAL'].values
337
+ self.TIME_mvp = nc['TIME'].values
338
+ self.LAT_mvp = nc['LATITUDE'].values
339
+ self.LON_mvp = nc['LONGITUDE'].values
340
+ self.DATETIME_mvp = nc['profile_time'].values
341
+ self.DIR = nc['direction'].values
342
+ self.Label_mvp = nc['profile'].values
343
+ self.freq_echant = nc.attrs['sampling frequency_hz']
344
+
345
+ nc.close()
346
+ print('MVP data loaded successfully.')
347
+ self.mvp = True
348
+
349
+ return
350
+
351
+
352
+
353
+
354
+ PRES_temp = []
355
+ TEMP_temp = []
356
+ COND_temp = []
357
+ SOUNDVEL_temp = []
358
+ DO_temp = []
359
+ TEMP2_temp = [] # temp from DO sensor
360
+ SUNA_temp = []
361
+ FLUO_temp = []
362
+ TURB_temp = []
363
+ PH_temp = []
364
+ SALT_temp = []
365
+ TIME_mvp_temp = []
366
+ LAT_temp = []
367
+ LON_temp= []
368
+ DATETIME_mvp = []
369
+ DIR = []
370
+ Label_mvp = []
371
+
372
+ delp.sort(reverse=True)
373
+ for i in delp:
374
+ del files[i]
375
+
376
+ for mvp_dat_name in files[0:]:
377
+
378
+ mvp_log_name=mvp_dat_name[:-4]+'.log'
379
+
380
+ # Get start and end time of the cycle
381
+ (mvp_tstart,mvp_tend,cycle_dur, lat, lon, dt_station) = mvp.get_log(mvp_log_name,self.Yorig)
382
+
383
+
384
+ if cycle_dur>1:
385
+
386
+ # Read one cycle MVP data
387
+
388
+ (pres,soundvel,cond,temp,do_raw,temp2_raw,suna_raw,fluo_raw,turb_raw,ph_raw) = mvp.read_mvp_cycle_raw(mvp_dat_name)
389
+ (pres,soundvel,cond,temp,do,temp2,suna,fluo,turb,ph) = mvp.raw_data_conversion(pres,soundvel,cond,temp,do_raw,temp2_raw,suna_raw,fluo_raw,turb_raw,ph_raw)
390
+
391
+
392
+ freq_echant = float(len(pres)/cycle_dur)
393
+
394
+ DATETIME_mvp.append(dt_station)
395
+
396
+ if np.nanmax(pres)-np.nanmin(pres)>2:
397
+
398
+ # Allocate time to samples and select the ascending part
399
+ (pres_up,soundvel_up,cond_up,temp_up,do_up,temp2_up,suna_up,fluo_up,turb_up,ph_up,time_up) = mvp.time_mvp_cycle_up([pres,soundvel,cond,temp,do,temp2,suna,fluo,turb,ph],mvp_tstart,mvp_tend)
400
+ (pres_down,soundvel_down,cond_down,temp_down,do_down,temp2_down,suna_down,fluo_down,turb_down,ph_down,time_down) = mvp.time_mvp_cycle_down([pres,soundvel,cond,temp,do,temp2,suna,fluo,turb,ph],mvp_tstart,mvp_tend)
401
+
402
+
403
+ if len(pres_down)>0:
404
+ if np.nanmax(pres_down)-np.nanmin(pres_down)>2:
405
+ PRES_temp.append(pres_down)
406
+ SOUNDVEL_temp.append(soundvel_down)
407
+ COND_temp.append(cond_down)
408
+ TEMP_temp.append(temp_down)
409
+ DO_temp.append(do_down)
410
+ TEMP2_temp.append(temp2_down)
411
+ SUNA_temp.append(suna_down)
412
+ FLUO_temp.append(fluo_down)
413
+ TURB_temp.append(turb_down)
414
+ PH_temp.append(ph_down)
415
+ SALT_temp.append(gsw.SP_from_C(cond_down, temp_down,pres_down))
416
+ TIME_mvp_temp.append(time_down)
417
+ LAT_temp.append(lat)
418
+ LON_temp.append(lon)
419
+
420
+ DIR.append('down')
421
+ Label_mvp.append(mvp_dat_name.replace('\\','/').split('/')[-2])
422
+
423
+ else:
424
+ print('ohohoh no down profile found for file: ' + mvp_dat_name)
425
+
426
+
427
+ if len(pres_up)>0:
428
+ if np.nanmax(pres_up)-np.nanmin(pres_up)>2:
429
+ PRES_temp.append(pres_up)
430
+ SOUNDVEL_temp.append(soundvel_up)
431
+ COND_temp.append(cond_up)
432
+ TEMP_temp.append(temp_up)
433
+ DO_temp.append(do_up)
434
+ TEMP2_temp.append(temp2_up)
435
+ SUNA_temp.append(suna_up)
436
+ FLUO_temp.append(fluo_up)
437
+ TURB_temp.append(turb_up)
438
+ PH_temp.append(ph_up)
439
+ SALT_temp.append(gsw.SP_from_C(cond_up, temp_up,pres_up))
440
+ TIME_mvp_temp.append(time_up)
441
+ LAT_temp.append(lat)
442
+ LON_temp.append(lon)
443
+
444
+ DIR.append('up')
445
+ Label_mvp.append(mvp_dat_name.replace('\\','/').split('/')[-2])
446
+
447
+
448
+ else:
449
+ print('ohohoh no up profile found for file: ' + mvp_dat_name)
450
+
451
+ else:
452
+ print('ohohoh no profile found for file: ' + mvp_dat_name)
453
+
454
+
455
+
456
+
457
+ # Re-arange files into matrices
458
+ M_size = 0
459
+ for i in range(len(PRES_temp)):
460
+ M_size = max(M_size, len(PRES_temp[i]))
461
+
462
+ if M_size < self.PRES_mvp.shape[1]:
463
+ M_size = self.PRES_mvp.shape[1]
464
+ else:
465
+ nan_cols = np.full((self.PRES_mvp.shape[0], M_size - self.PRES_mvp.shape[1]), np.nan)
466
+ self.PRES_mvp = np.hstack((self.PRES_mvp, nan_cols))
467
+ self.SOUNDVEL_mvp = np.hstack((self.SOUNDVEL_mvp, nan_cols))
468
+ self.COND_mvp = np.hstack((self.COND_mvp, nan_cols))
469
+ self.TEMP_mvp = np.hstack((self.TEMP_mvp, nan_cols))
470
+ self.DO_mvp = np.hstack((self.DO_mvp, nan_cols))
471
+ self.TEMP2_mvp = np.hstack((self.TEMP2_mvp, nan_cols))
472
+ self.SUNA_mvp = np.hstack((self.SUNA_mvp, nan_cols))
473
+ self.FLUO_mvp = np.hstack((self.FLUO_mvp, nan_cols))
474
+ self.TURB_mvp = np.hstack((self.TURB_mvp, nan_cols))
475
+ self.PH_mvp = np.hstack((self.PH_mvp, nan_cols))
476
+ self.SALT_mvp = np.hstack((self.SALT_mvp, nan_cols))
477
+ self.TIME_mvp = np.hstack((self.TIME_mvp, nan_cols))
478
+ self.LAT_mvp = np.hstack((self.LAT_mvp, nan_cols))
479
+ self.LON_mvp = np.hstack((self.LON_mvp, nan_cols))
480
+
481
+
482
+
483
+
484
+ PRES_mvp = np.zeros(( len(PRES_temp), M_size))
485
+ SOUNDVEL_mvp = np.zeros(( len(PRES_temp), M_size))
486
+ COND_mvp = np.zeros(( len(PRES_temp), M_size))
487
+ TEMP_mvp = np.zeros(( len(PRES_temp), M_size))
488
+ DO_mvp = np.zeros(( len(PRES_temp), M_size))
489
+ TEMP_mvp2 = np.zeros(( len(PRES_temp), M_size))
490
+ SUNA_mvp = np.zeros(( len(PRES_temp), M_size))
491
+ FLUO_mvp = np.zeros(( len(PRES_temp), M_size))
492
+ TURB_mvp = np.zeros(( len(PRES_temp), M_size))
493
+ PH_mvp = np.zeros(( len(PRES_temp), M_size))
494
+ SALT_mvp = np.zeros(( len(PRES_temp), M_size))
495
+ TIME_mvp = np.zeros(( len(PRES_temp), M_size))
496
+ LAT_mvp = np.zeros(( len(PRES_temp), M_size))
497
+ LON_mvp = np.zeros(( len(PRES_temp), M_size))
498
+ PRES_mvp[:] = np.nan
499
+ SOUNDVEL_mvp[:] = np.nan
500
+ COND_mvp[:] = np.nan
501
+ TEMP_mvp[:] = np.nan
502
+ DO_mvp[:] = np.nan
503
+ TEMP_mvp2[:] = np.nan
504
+ SUNA_mvp[:] = np.nan
505
+ FLUO_mvp[:] = np.nan
506
+ TURB_mvp[:] = np.nan
507
+ PH_mvp[:] = np.nan
508
+ SALT_mvp[:] = np.nan
509
+ TIME_mvp[:] = np.nan
510
+ LAT_mvp[:] = np.nan
511
+ LON_mvp[:] = np.nan
512
+
513
+ del M_size
514
+
515
+ for i in range(len(PRES_temp)):
516
+ PRES_mvp[i,0:len(PRES_temp[i])] = PRES_temp[i]
517
+ SOUNDVEL_mvp[i,0:len(SOUNDVEL_temp[i])] = SOUNDVEL_temp[i]
518
+ COND_mvp[i,0:len(COND_temp[i])] = COND_temp[i]
519
+ TEMP_mvp[i,0:len(TEMP_temp[i])] = TEMP_temp[i]
520
+ DO_mvp[i,0:len(DO_temp[i])] = DO_temp[i]
521
+ TEMP_mvp2[i,0:len(TEMP2_temp[i])] = TEMP2_temp[i]
522
+ SUNA_mvp[i,0:len(SUNA_temp[i])] = SUNA_temp[i]
523
+ FLUO_mvp[i,0:len(FLUO_temp[i])] = FLUO_temp[i]
524
+ TURB_mvp[i,0:len(TURB_temp[i])] = TURB_temp[i]
525
+ PH_mvp[i,0:len(PH_temp[i])] = PH_temp[i]
526
+ SALT_mvp[i,0:len(SALT_temp[i])] = SALT_temp[i]
527
+ TIME_mvp[i,0:len(TIME_mvp_temp[i])] = TIME_mvp_temp[i]
528
+ LAT_mvp[i,0:len(PRES_temp[i])] = LAT_temp[i]
529
+ LON_mvp[i,0:len(PRES_temp[i])] = LON_temp[i]
530
+
531
+
532
+ self.PRES_mvp = np.concatenate((self.PRES_mvp, PRES_mvp), axis=0)
533
+ self.SOUNDVEL_mvp = np.concatenate((self.SOUNDVEL_mvp, SOUNDVEL_mvp), axis=0)
534
+ self.COND_mvp = np.concatenate((self.COND_mvp, COND_mvp), axis=0)
535
+ self.TEMP_mvp = np.concatenate((self.TEMP_mvp, TEMP_mvp), axis=0)
536
+ self.DO_mvp = np.concatenate((self.DO_mvp, DO_mvp), axis=0)
537
+ self.TEMP2_mvp = np.concatenate((self.TEMP2_mvp, TEMP_mvp2), axis=0)
538
+ self.SUNA_mvp = np.concatenate((self.SUNA_mvp, SUNA_mvp), axis=0)
539
+ self.FLUO_mvp = np.concatenate((self.FLUO_mvp, FLUO_mvp), axis=0)
540
+ self.TURB_mvp = np.concatenate((self.TURB_mvp, TURB_mvp), axis=0)
541
+ self.PH_mvp = np.concatenate((self.PH_mvp, PH_mvp), axis=0)
542
+ self.SALT_mvp = np.concatenate((self.SALT_mvp, SALT_mvp), axis=0)
543
+ self.TIME_mvp = np.concatenate((self.TIME_mvp, TIME_mvp), axis=0)
544
+ self.LAT_mvp = np.concatenate((self.LAT_mvp, LAT_mvp), axis=0)
545
+ self.LON_mvp = np.concatenate((self.LON_mvp, LON_mvp), axis=0)
546
+
547
+ self.DATETIME_mvp.extend(DATETIME_mvp)
548
+ self.DIR.extend(DIR)
549
+ self.label_mvp.extend(Label_mvp)
550
+
551
+ del PRES_temp, SOUNDVEL_temp, DO_temp, TEMP2_temp, SUNA_temp, FLUO_temp, TURB_temp, PH_temp, COND_temp, TEMP_temp, SALT_temp, TIME_mvp_temp, LAT_temp, LON_temp
552
+
553
+ print('MVP data loaded successfully.')
554
+ self.mvp = True
555
+
556
+
557
+ def load_ctd_data(self,data_path_ctd,format='cnv'):
558
+ """
559
+ Load CTD data from .cnv files in the data_path_ctd folder.
560
+ Fills the object attributes with data matrices and associated metadata.
561
+ Args:
562
+ data_path_ctd (str): Path to the folder containing CTD files.
563
+ """
564
+
565
+
566
+ if format=='cnv':
567
+ list_of_ctd_files = sorted(filter(os.path.isfile,\
568
+ glob.glob(data_path_ctd + '*.cnv')))
569
+ elif format=='ncdf':
570
+ list_of_ctd_files = sorted(filter(os.path.isfile,\
571
+ glob.glob(data_path_ctd + 'CTD'+'*.nc')))
572
+ print('Found ' + str(len(list_of_ctd_files)) + ' CTD files in the directory: ' + data_path_ctd)
573
+
574
+
575
+
576
+
577
+
578
+ # keys: ['scan', 'timeJ', 'timeQ', 'LATITUDE', 'LONGITUDE', 'PRES', 'TEMP', 'CNDC', 'descentrate', 'flECO-AFL', 'v1', 'wetCDOM', 'v0', 'turbWETntu0', 'v5', 'CStarTr0', 'CStarAt0', 'oxygen_ml_L', 'oxsolML/L', 'v2', 'flag', 'timeS']
579
+ LAT_ctd_temp = []
580
+ LON_ctd_temp = []
581
+ PRES_ctd_temp = []
582
+ TEMP_ctd_temp = []
583
+ COND_ctd_temp = []
584
+ TURB_ctd_temp = []
585
+ OXY_ctd_temp = []
586
+ FLUO_ctd_temp = []
587
+ CDOM_ctd_temp = []
588
+ DATETIME_ctd = []
589
+ SALT_ctd_temp = []
590
+
591
+ if format=='ncdf':
592
+ for f in list_of_ctd_files:
593
+ nc = xr.open_dataset(f)
594
+ PRES_ctd_temp.append(nc['PRES'].values[0])
595
+ PRES_ctd_temp.append(nc['PRES'].values[1])
596
+ TEMP_ctd_temp.append(nc['TEMP'].values[0])
597
+ TEMP_ctd_temp.append(nc['TEMP'].values[1])
598
+ COND_ctd_temp.append(nc['COND'].values[0])
599
+ COND_ctd_temp.append(nc['COND'].values[1])
600
+ SALT_ctd_temp.append(nc['SAL'].values[0])
601
+ SALT_ctd_temp.append(nc['SAL'].values[1])
602
+ TURB_ctd_temp.append(nc['TURB'].values[0])
603
+ TURB_ctd_temp.append(nc['TURB'].values[1])
604
+ OXY_ctd_temp.append(nc['OXY'].values[0])
605
+ OXY_ctd_temp.append(nc['OXY'].values[1])
606
+ FLUO_ctd_temp.append(nc['FLUO'].values[0])
607
+ FLUO_ctd_temp.append(nc['FLUO'].values[1])
608
+ CDOM_ctd_temp.append(nc['CDOM'].values[0])
609
+ CDOM_ctd_temp.append(nc['CDOM'].values[1])
610
+ LAT_ctd_temp.append(nc['LATITUDE'].values[0])
611
+ LAT_ctd_temp.append(nc['LATITUDE'].values[1])
612
+ LON_ctd_temp.append(nc['LONGITUDE'].values[0])
613
+ LON_ctd_temp.append(nc['LONGITUDE'].values[1])
614
+ DATETIME_ctd.append(nc['profile_time'].values[0])
615
+
616
+ nc.close()
617
+
618
+ self.PRES_ctd = np.array(PRES_ctd_temp)
619
+ self.TEMP_ctd = np.array(TEMP_ctd_temp)
620
+ self.COND_ctd = np.array(COND_ctd_temp)
621
+ self.SALT_ctd = np.array(SALT_ctd_temp)
622
+ self.TURB_ctd = np.array(TURB_ctd_temp)
623
+ self.OXY_ctd = np.array(OXY_ctd_temp)
624
+ self.FLUO_ctd = np.array(FLUO_ctd_temp)
625
+ self.CDOM_ctd = np.array(CDOM_ctd_temp)
626
+ self.LAT_ctd = np.array(LAT_ctd_temp)
627
+ self.LON_ctd = np.array(LON_ctd_temp)
628
+ self.DATETIME_ctd = np.array(DATETIME_ctd)
629
+
630
+
631
+ print('CTD data loaded successfully.')
632
+ self.ctd = True
633
+
634
+ return
635
+
636
+
637
+
638
+
639
+
640
+
641
+ for ctd_dat_name in tqdm(list_of_ctd_files[0:]):
642
+ ctd_files = ctd_dat_name
643
+
644
+ cnv = fCNV(ctd_files)
645
+
646
+ Lat_up,Lat_down = split_ctd(cnv['PRES'], cnv['LATITUDE'])
647
+ Lon_up,Lon_down = split_ctd(cnv['PRES'], cnv['LONGITUDE'])
648
+ Pres_up,Pres_down = split_ctd(cnv['PRES'], cnv['PRES'])
649
+ Temp_up,Temp_down = split_ctd(cnv['PRES'], cnv['TEMP'])
650
+ Cond_up,Cond_down = split_ctd(cnv['PRES'], cnv['CNDC']*10)
651
+ Turb_up,Turb_down = split_ctd(cnv['PRES'], cnv['turbWETntu0'])
652
+ Oxy_up,Oxy_down = split_ctd(cnv['PRES'],np.array([a/b*100 for a,b in zip(cnv['oxygen_ml_L'], cnv['oxsolML/L'])]))
653
+ Fluo_up,Fluo_down = split_ctd(cnv['PRES'], cnv['flECO-AFL'])
654
+ Cdom_up,Cdom_down = split_ctd(cnv['PRES'], cnv['wetCDOM'])
655
+ Salt_up,Salt_down = split_ctd(cnv['PRES'], gsw.SP_from_C(cnv['CNDC']*10, cnv['TEMP'], cnv['PRES']))
656
+
657
+
658
+
659
+
660
+
661
+ LAT_ctd_temp.append(Lat_down)
662
+ LAT_ctd_temp.append(Lat_up)
663
+ LON_ctd_temp.append(Lon_down)
664
+ LON_ctd_temp.append(Lon_up)
665
+ PRES_ctd_temp.append(Pres_down)
666
+ PRES_ctd_temp.append(Pres_up)
667
+ TEMP_ctd_temp.append(Temp_down)
668
+ TEMP_ctd_temp.append(Temp_up)
669
+ COND_ctd_temp.append(Cond_down)
670
+ COND_ctd_temp.append(Cond_up)
671
+ TURB_ctd_temp.append(Turb_down)
672
+ TURB_ctd_temp.append(Turb_up)
673
+ OXY_ctd_temp.append(Oxy_down)
674
+ OXY_ctd_temp.append(Oxy_up)
675
+ FLUO_ctd_temp.append(Fluo_down)
676
+ FLUO_ctd_temp.append(Fluo_up)
677
+ CDOM_ctd_temp.append(Cdom_down)
678
+ CDOM_ctd_temp.append(Cdom_up)
679
+ SALT_ctd_temp.append(Salt_down)
680
+ SALT_ctd_temp.append(Salt_up)
681
+
682
+
683
+
684
+
685
+ with open(ctd_dat_name, 'r') as f:
686
+ header_lines = []
687
+ for _ in range(10):
688
+ header_lines.append(f.readline().strip())
689
+
690
+ line = header_lines[9]
691
+ date_str = line.split('=')[1].strip()
692
+ dt = datetime.strptime(date_str, "%b %d %Y %H:%M:%S")
693
+ DATETIME_ctd.append(dt)
694
+
695
+ # Re-arange files into matrices
696
+ M_size = 0
697
+ for i in range(len(PRES_ctd_temp)):
698
+ M_size = max(M_size, len(PRES_ctd_temp[i]))
699
+
700
+ PRES_ctd = np.zeros(( len(PRES_ctd_temp), M_size))
701
+ COND_ctd = np.zeros(( len(PRES_ctd_temp), M_size))
702
+ SALT_ctd = np.zeros(( len(PRES_ctd_temp), M_size))
703
+ TEMP_ctd = np.zeros(( len(PRES_ctd_temp), M_size))
704
+ TURB_ctd = np.zeros(( len(PRES_ctd_temp), M_size))
705
+ OXY_ctd = np.zeros(( len(PRES_ctd_temp), M_size))
706
+ FLUO_ctd = np.zeros(( len(PRES_ctd_temp), M_size))
707
+ CDOM_ctd = np.zeros(( len(PRES_ctd_temp), M_size))
708
+ LAT_ctd = np.zeros(( len(PRES_ctd_temp), M_size))
709
+ LON_ctd = np.zeros(( len(PRES_ctd_temp), M_size))
710
+ PRES_ctd[:] = np.nan
711
+ COND_ctd[:] = np.nan
712
+ SALT_ctd[:] = np.nan
713
+ TEMP_ctd[:] = np.nan
714
+ TURB_ctd[:] = np.nan
715
+ OXY_ctd[:] = np.nan
716
+ FLUO_ctd[:] = np.nan
717
+ CDOM_ctd[:] = np.nan
718
+ LAT_ctd[:] = np.nan
719
+ LON_ctd[:] = np.nan
720
+ del M_size
721
+ for i in range(len(PRES_ctd_temp)):
722
+ LAT_ctd[i,0:len(PRES_ctd_temp[i])] = LAT_ctd_temp[i]
723
+ LON_ctd[i,0:len(PRES_ctd_temp[i])] = LON_ctd_temp[i]
724
+ PRES_ctd[i,0:len(PRES_ctd_temp[i])] = PRES_ctd_temp[i]
725
+ TEMP_ctd[i,0:len(PRES_ctd_temp[i])] = TEMP_ctd_temp[i]
726
+ COND_ctd[i,0:len(PRES_ctd_temp[i])] = COND_ctd_temp[i]
727
+ SALT_ctd[i,0:len(PRES_ctd_temp[i])] = SALT_ctd_temp[i]
728
+ TURB_ctd[i,0:len(PRES_ctd_temp[i])] = TURB_ctd_temp[i]
729
+ OXY_ctd[i,0:len(PRES_ctd_temp[i])] = OXY_ctd_temp[i]
730
+ FLUO_ctd[i,0:len(PRES_ctd_temp[i])] = FLUO_ctd_temp[i]
731
+ CDOM_ctd[i,0:len(PRES_ctd_temp[i])] = CDOM_ctd_temp[i]
732
+ del PRES_ctd_temp, TEMP_ctd_temp, COND_ctd_temp, SALT_ctd_temp, TURB_ctd_temp, OXY_ctd_temp, FLUO_ctd_temp, CDOM_ctd_temp, LAT_ctd_temp, LON_ctd_temp
733
+
734
+ self.PRES_ctd = PRES_ctd
735
+ self.TEMP_ctd = TEMP_ctd
736
+ self.COND_ctd = COND_ctd
737
+ self.SALT_ctd = SALT_ctd
738
+ self.TURB_ctd = TURB_ctd
739
+ self.OXY_ctd = OXY_ctd
740
+ self.FLUO_ctd = FLUO_ctd
741
+ self.CDOM_ctd = CDOM_ctd
742
+ self.LAT_ctd = LAT_ctd
743
+ self.LON_ctd = LON_ctd
744
+ self.DATETIME_ctd = DATETIME_ctd
745
+
746
+ print('CTD data loaded successfully.')
747
+ self.ctd = True
748
+
749
+
750
+ def compute_waterflow(self,horizontal_speed=2,corr=False):
751
+ """
752
+ Compute the water flow speed (u,v) from the horizontal speed and the direction of the profiles.
753
+ Args:
754
+ horizontal_speed (float): Horizontal speed of the boat in m/s.
755
+ """
756
+
757
+ if corr:
758
+ SPEED_MVP = []
759
+ for i in range(len(self.PRES_mvp_corr)):
760
+ SPEED_MVP.append(np.sqrt(np.gradient(self.PRES_mvp_corr[i], 1/self.freq_echant)**2+ horizontal_speed**2))
761
+ self.SPEED_mvp_corr = {i: SPEED_MVP[i] for i in range(len(SPEED_MVP))}
762
+ else:
763
+ SPEED_MVP = np.zeros((self.PRES_mvp.shape[0], self.PRES_mvp.shape[1]))
764
+ for i in range(self.PRES_mvp.shape[0]):
765
+ SPEED_MVP[i,:] = np.sqrt(np.gradient(self.PRES_mvp[i,:], 1/self.freq_echant)**2+ horizontal_speed**2)
766
+
767
+ self.SPEED_mvp = SPEED_MVP
768
+ print('Water flow speed computed successfully.')
769
+
770
+ def print_profile_metadata(self):
771
+ """
772
+ Print main metadata (date, position, number of samples) for each loaded MVP and CTD profile.
773
+ """
774
+
775
+ if self.mvp:
776
+ print('MVP data:')
777
+ print('Number of profiles: ' + str(len(self.DATETIME_mvp)))
778
+ for i in range(0,len(self.DATETIME_mvp)):
779
+ print(f" Profil down {2*i} - Profil up {2*i+1} - Latitude: {self.LAT_mvp[2*i,0]:.5f}, Longitude: {self.LON_mvp[2*i,0]:.5f}, Date/Heure: {self.DATETIME_mvp[i]}")
780
+
781
+ if self.ctd:
782
+ print('CTD data:')
783
+ print('Number of profiles: ' + str(len(self.DATETIME_ctd)))
784
+ for i in range(0,len(self.DATETIME_ctd)):
785
+ print(f" Profil down {2*i} - Profil up {2*i+1} - Latitude: {self.LAT_ctd[2*i,0]:.5f}, Longitude: {self.LON_ctd[2*i,0]:.5f}, Date/Heure: {self.DATETIME_ctd[i]}")
786
+
787
+
788
+ def keep_selected_profiles(self, id_mvp, id_ctd=None):
789
+ """
790
+ Keep only the selected MVP and CTD profiles in the object attributes.
791
+ Args:
792
+ id_mvp (list): Indices of MVP profiles to keep.
793
+ id_ctd (list): Indices of CTD profiles to keep (optional).
794
+ """
795
+
796
+
797
+ # Make a list of all id to keep for MVP profiles
798
+ l_id = []
799
+ l_id2 = []
800
+ for i in id_mvp:
801
+ l_id.append(i)
802
+ l_id.append(i+1) # Add the next profile for the up profile
803
+ l_id2.append(i//2)
804
+
805
+
806
+
807
+ # Keep only the selected profiles
808
+
809
+ if self.mvp:
810
+
811
+ self.PRES_mvp = self.PRES_mvp[l_id,:]
812
+ self.SOUNDVEL_mvp = self.SOUNDVEL_mvp[l_id,:]
813
+ self.COND_mvp = self.COND_mvp[l_id,:]
814
+ self.TEMP_mvp = self.TEMP_mvp[l_id,:]
815
+ self.DO_mvp = self.DO_mvp[l_id,:]
816
+ self.TEMP2_mvp = self.TEMP2_mvp[l_id,:]
817
+ self.SUNA_mvp = self.SUNA_mvp[l_id,:]
818
+ self.FLUO_mvp = self.FLUO_mvp[l_id,:]
819
+ self.TURB_mvp = self.TURB_mvp[l_id,:]
820
+ self.PH_mvp = self.PH_mvp[l_id,:]
821
+ self.SALT_mvp = self.SALT_mvp[l_id,:]
822
+ self.TIME_mvp = self.TIME_mvp[l_id,:]
823
+ self.LAT_mvp = self.LAT_mvp[l_id,:]
824
+ self.LON_mvp = self.LON_mvp[l_id,:]
825
+ self.DATETIME_mvp = np.array(self.DATETIME_mvp)[l_id2]
826
+ self.DIR = np.array(self.DIR)[l_id]
827
+ self.label_mvp = np.array(self.label_mvp)[l_id]
828
+
829
+ if self.ctd and id_ctd != None:
830
+
831
+ l_id = []
832
+ l_id2 = []
833
+ for i in id_ctd:
834
+ l_id.append(i)
835
+ l_id.append(i+1) # Add the next profile for the up profile
836
+ l_id2.append(i//2)
837
+
838
+ self.PRES_ctd = self.PRES_ctd[l_id,:]
839
+ self.TEMP_ctd = self.TEMP_ctd[l_id,:]
840
+ self.SALT_ctd = self.SALT_ctd[l_id,:]
841
+ self.COND_ctd = self.COND_ctd[l_id,:]
842
+ self.TURB_ctd = self.TURB_ctd[l_id,:]
843
+ self.OXY_ctd = self.OXY_ctd[l_id,:]
844
+ self.FLUO_ctd = self.FLUO_ctd[l_id,:]
845
+ self.CDOM_ctd = self.CDOM_ctd[l_id,:]
846
+ self.LAT_ctd = self.LAT_ctd[l_id,:]
847
+ self.LON_ctd = self.LON_ctd[l_id,:]
848
+ self.DATETIME_ctd = np.array(self.DATETIME_ctd)[l_id2]
849
+
850
+
851
+ def plot_vertical_speed(self,id,mean=False,window=20):
852
+
853
+ if self.mvp==False:
854
+ print('No MVP data loaded.')
855
+ return
856
+
857
+ if mean:
858
+ v_z_down = np.gradient(self.PRES_mvp[0::2], 1/self.freq_echant,axis=1)
859
+ v_z_up = np.gradient(self.PRES_mvp[1::2], 1/self.freq_echant,axis=1)
860
+
861
+ # smooth speed
862
+ for i in range(v_z_down.shape[0]):
863
+ v_z_down[i,:] = np.convolve(v_z_down[i,:], np.ones(2*window+1)/(2*window+1), mode='same')
864
+ v_z_up[i,:] = np.convolve(v_z_up[i,:], np.ones(2*window+1)/(2*window+1), mode='same')
865
+
866
+ # take mean profile
867
+ v_z_down = np.nanmean(v_z_down,axis=0)
868
+ v_z_up = np.nanmean(v_z_up,axis=0)
869
+
870
+ self.v_z_down = v_z_down
871
+ self.v_z_up = v_z_up
872
+
873
+
874
+ else:
875
+
876
+ v_z_down = np.gradient(self.PRES_mvp[id,:], 1/self.freq_echant)
877
+ v_z_up = np.gradient(self.PRES_mvp[id+1,:], 1/self.freq_echant)
878
+
879
+ # smooth speed
880
+ self.v_z_down = np.convolve(v_z_down, np.ones(2*window+1)/(2*window+1), mode='same')
881
+ self.v_z_up = np.convolve(v_z_up, np.ones(2*window+1)/(2*window+1), mode='same')
882
+
883
+
884
+
885
+
886
+ plt.figure()
887
+
888
+ plt.plot(v_z_down,self.PRES_mvp[id], label='down')
889
+ plt.plot(v_z_up,self.PRES_mvp[id+1], label='up')
890
+
891
+ plt.gca().invert_yaxis()
892
+ plt.legend()
893
+ plt.grid()
894
+ plt.xlabel('Vertical speed, m/s')
895
+ plt.ylabel('Pressure, dbar')
896
+ plt.title('Vertical speed profiles')
897
+ plt.legend()
898
+
899
+
900
+ def plot_profile_map(self):
901
+ """
902
+ Plot a map of the start locations of each profile (MVP and CTD),
903
+ with a land/ocean background and coastlines using cartopy.
904
+ The map is automatically zoomed to the profile area (no excessive margin).
905
+ Requires the cartopy module (pip install cartopy).
906
+ """
907
+
908
+ fig = plt.figure(figsize=(8, 8))
909
+ ax = plt.axes(projection=ccrs.PlateCarree())
910
+ ax.set_title('Carte des profils (début de plongée)')
911
+ ax.set_aspect('equal', adjustable='datalim')
912
+ ax.add_feature(cfeature.LAND, zorder=0, edgecolor='black', facecolor='lightgray')
913
+ ax.add_feature(cfeature.OCEAN, zorder=0, facecolor='lightblue')
914
+ ax.add_feature(cfeature.COASTLINE, linewidth=1.2)
915
+ ax.add_feature(cfeature.BORDERS, linestyle=':', linewidth=0.8)
916
+ gl = ax.gridlines(draw_labels=True, linewidth=0.5, color='gray', alpha=0.5, linestyle='--')
917
+ gl.top_labels = False
918
+ gl.right_labels = False
919
+ colors = plt.cm.tab10.colors
920
+
921
+ # MVP
922
+ if hasattr(self, 'LAT_mvp') and hasattr(self, 'LON_mvp'):
923
+
924
+ put_label = True
925
+ c = 0
926
+ for i in range(0,self.LAT_mvp.shape[0],2):
927
+ if i>0:
928
+ if self.label_mvp[i] == self.label_mvp[i-1]:
929
+ put_label = False
930
+ else:
931
+ put_label = True
932
+ c+=1
933
+
934
+ lat = self.LAT_mvp[i,0] if self.LAT_mvp.ndim == 2 else self.LAT_mvp[i]
935
+ lon = self.LON_mvp[i,0] if self.LON_mvp.ndim == 2 else self.LON_mvp[i]
936
+ ax.scatter(lon, lat, color=colors[c], marker='o', label='MVP '+self.label_mvp[i] if put_label else "", transform=ccrs.PlateCarree())
937
+
938
+ # CTD
939
+ if hasattr(self, 'LAT_ctd') and hasattr(self, 'LON_ctd'):
940
+ for i in range(0,self.LAT_ctd.shape[0],2):
941
+ lat = self.LAT_ctd[i,0] if self.LAT_ctd.ndim == 2 else self.LAT_ctd[i]
942
+ lon = self.LON_ctd[i,0] if self.LON_ctd.ndim == 2 else self.LON_ctd[i]
943
+ ax.scatter(lon, lat, color='red', marker='^', label='CTD' if i==0 else "", transform=ccrs.PlateCarree())
944
+
945
+ handles, labels = ax.get_legend_handles_labels()
946
+ by_label = dict(zip(labels, handles))
947
+ ax.legend(by_label.values(), by_label.keys())
948
+ plt.show()
949
+
950
+ def plot_TSprofile(self, id_mvp,id_ctd=None,correction=False):
951
+ """
952
+ Plot temperature and salinity profiles versus pressure for a given profile (MVP and CTD).
953
+ Args:
954
+ id_mvp (int): Index of the MVP profile to plot.
955
+ id_ctd (int, optional): Index of the CTD profile to plot (default: same as id_mvp).
956
+ correction (bool): If True, plot corrected profiles.
957
+ """
958
+
959
+ if id_ctd is None:
960
+ id_ctd = id_mvp
961
+
962
+
963
+
964
+ plt.figure()
965
+ if self.mvp:
966
+ if correction:
967
+ plt.plot(self.TEMP_mvp_corr[id_mvp],self.PRES_mvp_corr[id_mvp],label='MVP down corrected')
968
+ plt.plot(self.TEMP_mvp_corr[id_mvp+1],self.PRES_mvp_corr[id_mvp+1],label='MVP up corrected')
969
+ else:
970
+ plt.plot(self.TEMP_mvp[id_mvp],self.PRES_mvp[id_mvp],label='MVP down')
971
+ plt.plot(self.TEMP_mvp[id_mvp+1],self.PRES_mvp[id_mvp+1],label='MVP up')
972
+ if self.ctd:
973
+ plt.plot(self.TEMP_ctd[id_ctd],self.PRES_ctd[id_ctd],label='CTD down')
974
+ plt.plot(self.TEMP_ctd[id_ctd+1],self.PRES_ctd[id_ctd+1],label='CTD up')
975
+ plt.legend()
976
+ plt.gca().invert_yaxis()
977
+ plt.grid()
978
+ plt.xlabel('Temperature, C')
979
+ plt.ylabel('Pressure, dbar')
980
+
981
+
982
+ plt.figure()
983
+ if self.mvp:
984
+ if correction:
985
+ plt.plot(self.SALT_mvp_corr[id_mvp],self.PRES_mvp_corr[id_mvp],label='MVP down corrected')
986
+ plt.plot(self.SALT_mvp_corr[id_mvp+1],self.PRES_mvp_corr[id_mvp+1],label='MVP up corrected')
987
+ else:
988
+ plt.plot(self.SALT_mvp[id_mvp],self.PRES_mvp[id_mvp],label='MVP down')
989
+ plt.plot(self.SALT_mvp[id_mvp+1],self.PRES_mvp[id_mvp+1],label='MVP up')
990
+ if self.ctd:
991
+ plt.plot(self.SALT_ctd[id_ctd],self.PRES_ctd[id_ctd],label='CTD down')
992
+ plt.plot(self.SALT_ctd[id_ctd+1],self.PRES_ctd[id_ctd+1],label='CTD up')
993
+ plt.legend()
994
+ plt.gca().invert_yaxis()
995
+ plt.grid()
996
+ plt.xlabel('Salinity, psu')
997
+ plt.ylabel('Pressure, dbar')
998
+
999
+ def plot_BGCprofile(self, id_mvp,id_ctd=None,):
1000
+ """
1001
+ Plot raw biogeochemical profiles (O2, turbidity, fluorescence) for a given profile (MVP and CTD).
1002
+ Args:
1003
+ id_mvp (int): Index of the MVP profile to plot.
1004
+ id_ctd (int, optional): Index of the CTD profile to plot (default: same as id_mvp).
1005
+ """
1006
+
1007
+ if id_ctd is None:
1008
+ id_ctd = id_mvp
1009
+
1010
+
1011
+
1012
+ plt.figure()
1013
+ if self.mvp:
1014
+ plt.plot(self.DO_mvp[id_mvp],self.PRES_mvp[id_mvp],label='MVP down')
1015
+ plt.plot(self.DO_mvp[id_mvp+1],self.PRES_mvp[id_mvp+1],label='MVP up')
1016
+ if self.ctd:
1017
+ plt.plot(self.OXY_ctd[id_ctd],self.PRES_ctd[id_ctd],label='CTD down')
1018
+ plt.plot(self.OXY_ctd[id_ctd+1],self.PRES_ctd[id_ctd+1],label='CTD up')
1019
+ plt.legend()
1020
+ plt.gca().invert_yaxis()
1021
+ plt.grid()
1022
+ plt.xlabel('Dissolved Oxygen, %')
1023
+ plt.ylabel('Pressure, dbar')
1024
+
1025
+
1026
+ plt.figure()
1027
+ if self.mvp:
1028
+ plt.plot(self.TURB_mvp[id_mvp],self.PRES_mvp[id_mvp],label='MVP down')
1029
+ plt.plot(self.TURB_mvp[id_mvp+1],self.PRES_mvp[id_mvp+1],label='MVP up')
1030
+ if self.ctd:
1031
+ plt.plot(self.TURB_ctd[id_ctd],self.PRES_ctd[id_ctd],label='CTD down')
1032
+ plt.plot(self.TURB_ctd[id_ctd+1],self.PRES_ctd[id_ctd+1],label='CTD up')
1033
+ plt.legend()
1034
+ plt.gca().invert_yaxis()
1035
+ plt.grid()
1036
+ plt.xlabel('Turbidity, NTU')
1037
+ plt.ylabel('Pressure, dbar')
1038
+
1039
+ plt.figure()
1040
+ if self.mvp:
1041
+ plt.plot(self.FLUO_mvp[id_mvp],self.PRES_mvp[id_mvp],label='MVP down')
1042
+ plt.plot(self.FLUO_mvp[id_mvp+1],self.PRES_mvp[id_mvp+1],label='MVP up')
1043
+ if self.ctd:
1044
+ plt.plot(self.FLUO_ctd[id_ctd],self.PRES_ctd[id_ctd],label='CTD down')
1045
+ plt.plot(self.FLUO_ctd[id_ctd+1],self.PRES_ctd[id_ctd+1],label='CTD up')
1046
+ plt.legend()
1047
+ plt.gca().invert_yaxis()
1048
+ plt.grid()
1049
+ plt.xlabel('Fluorescence, ug/L')
1050
+ plt.ylabel('Pressure, dbar')
1051
+
1052
+ def plot_diagramTS_raw(self,id_mvp=None,id_ctd=None,correction=False):
1053
+ """
1054
+ Plot the TS diagram (Salinity vs Temperature) for one or more profiles, with isopycnals.
1055
+ Args:
1056
+ id_mvp (int, optional): Index of the MVP profile to plot, or None for all profiles.
1057
+ id_ctd (int, optional): Index of the CTD profile to plot, or None for all profiles.
1058
+ correction (bool): If True, plot corrected profiles.
1059
+ """
1060
+
1061
+
1062
+
1063
+
1064
+ plt.figure()
1065
+ if id_mvp != None:
1066
+ if id_ctd == None:
1067
+ id_ctd = id_mvp
1068
+
1069
+ if self.mvp:
1070
+ if correction:
1071
+ plt.plot(self.SALT_mvp_corr[id_mvp],self.TEMP_mvp_corr[id_mvp],label='MVP down corrected',linestyle='', marker='.')
1072
+ plt.plot(self.SALT_mvp_corr[id_mvp+1],self.TEMP_mvp_corr[id_mvp+1],label='MVP up corrected',linestyle='', marker='.')
1073
+ else:
1074
+ plt.plot(self.SALT_mvp[id_mvp],self.TEMP_mvp[id_mvp],label='MVP down',linestyle='', marker='.')
1075
+ plt.plot(self.SALT_mvp[id_mvp+1],self.TEMP_mvp[id_mvp+1],label='MVP up',linestyle='', marker='.')
1076
+ if self.ctd:
1077
+ plt.plot(self.SALT_ctd[id_ctd],self.TEMP_ctd[id_ctd],label='CTD down', linestyle='', marker='.')
1078
+ plt.plot(self.SALT_ctd[id_ctd+1],self.TEMP_ctd[id_ctd+1],label='CTD up', linestyle='', marker='.')
1079
+
1080
+ else:
1081
+ if self.mvp:
1082
+ if correction:
1083
+ plt.plot(self.SALT_mvp_corr[0],self.TEMP_mvp_corr[0],linestyle='',color='red', marker='.',label='MVP down corrected')
1084
+ plt.plot(self.SALT_mvp_corr[1],self.TEMP_mvp_corr[1],linestyle='',color='blue', marker='.',label='MVP up corrected')
1085
+ for i in range(2,len(self.PRES_mvp),2):
1086
+ plt.plot(self.SALT_mvp_corr[i],self.TEMP_mvp_corr[i],linestyle='',color='red', marker='.')
1087
+ plt.plot(self.SALT_mvp_corr[i+1],self.TEMP_mvp_corr[i+1],linestyle='',color='blue', marker='.')
1088
+ else:
1089
+ plt.plot(self.SALT_mvp[0],self.TEMP_mvp[0],linestyle='',color='red', marker='.',label='MVP down')
1090
+ plt.plot(self.SALT_mvp[1],self.TEMP_mvp[1],linestyle='',color='blue', marker='.',label='MVP up')
1091
+ for i in range(2,len(self.PRES_mvp),2):
1092
+ plt.plot(self.SALT_mvp[i],self.TEMP_mvp[i],linestyle='',color='red', marker='.')
1093
+ plt.plot(self.SALT_mvp[i+1],self.TEMP_mvp[i+1],linestyle='',color='blue', marker='.')
1094
+ if self.ctd:
1095
+ plt.plot(self.SALT_ctd[0],self.TEMP_ctd[0],color='green', linestyle='', marker='.',label='CTD down')
1096
+ plt.plot(self.SALT_ctd[1],self.TEMP_ctd[1],color='orange', linestyle='', marker='.',label='CTD up')
1097
+ for i in range(2,len(self.PRES_ctd),2):
1098
+ plt.plot(self.SALT_ctd[i],self.TEMP_ctd[i],color='green', linestyle='', marker='.')
1099
+ plt.plot(self.SALT_ctd[i+1],self.TEMP_ctd[i+1],color='orange', linestyle='', marker='.')
1100
+
1101
+
1102
+ s_lim = plt.xlim()
1103
+ t_lim = plt.ylim()
1104
+ SA = np.linspace(s_lim[0], s_lim[1], 100) # Absolute Salinity [g/kg]
1105
+ CT = np.linspace(t_lim[0], t_lim[1], 100)
1106
+ SA_grid, CT_grid = np.meshgrid(SA, CT)
1107
+ # Calcul de la densité potentielle sigma0 (kg/m³ - 1000)
1108
+ sigma0 = gsw.sigma0(SA_grid, CT_grid)
1109
+ # Dessiner les contours (les isopycnes)
1110
+ contour_plot = plt.contour(SA_grid, CT_grid, sigma0, colors='k', linestyles='dotted')
1111
+ # Ajouter les étiquettes (les chiffres) le long des contours
1112
+ plt.clabel(contour_plot, inline=True, fontsize=10, fmt='%1.1f')
1113
+
1114
+ plt.legend()
1115
+ plt.xlabel('Salinity, psu')
1116
+ plt.ylabel('Temperature, C')
1117
+
1118
+ def stat_compar(self,id_mvp=[],id_ctd=None,num_sample=5000,cond=False,speed=False,correction=False):
1119
+ """
1120
+ Statistically compare MVP and CTD profiles (temperature and salinity),
1121
+ print statistics and interpolated differences.
1122
+ Args:
1123
+ id (list): Indices of profiles to compare (all if empty).
1124
+ num_sample (int): Number of pressure levels for interpolation.
1125
+ """
1126
+
1127
+ if not self.mvp or not self.ctd:
1128
+ raise ValueError("MVP or CTD data not loaded.")
1129
+
1130
+ if id_mvp == []:
1131
+ id_mvp = list(range(0, self.PRES_mvp.shape[0]))
1132
+ if id_ctd is None:
1133
+ id_ctd = id_mvp
1134
+
1135
+ if len(id_mvp) != len(id_ctd):
1136
+ raise ValueError("id_mvp and id_ctd must have the same length.")
1137
+
1138
+ if correction:
1139
+ Pres = self.PRES_mvp_corr
1140
+ Temp = self.TEMP_mvp_corr
1141
+ Salt = self.SALT_mvp_corr
1142
+ Cond = self.COND_mvp_corr
1143
+ else:
1144
+ Pres = self.PRES_mvp
1145
+ Temp = self.TEMP_mvp
1146
+ Salt = self.SALT_mvp
1147
+ Cond = self.COND_mvp
1148
+ Do = self.DO_mvp
1149
+
1150
+ # Interpolate MVP and CTD data to match pressure levels
1151
+ pmin = np.nanmin(Pres)
1152
+ pmax = np.nanmax(Pres)
1153
+ pressure_grid = np.linspace(pmin, pmax, num_sample)
1154
+
1155
+ TEMP_mvp_interp = mvp.vertical_interp(Pres[id_mvp,:],Temp[id_mvp,:], pressure_grid)
1156
+ SALT_mvp_interp = mvp.vertical_interp(Pres[id_mvp,:], Salt[id_mvp,:], pressure_grid)
1157
+ DO_mvp_interp = mvp.vertical_interp(Pres[id_mvp,:], Do[id_mvp,:], pressure_grid)
1158
+ COND_mvp_interp = mvp.vertical_interp(Pres[id_mvp,:], Cond[id_mvp,:], pressure_grid)
1159
+
1160
+ # keep only down profiles
1161
+ id_ctd1 = [id_ctd[i] for i in range(len(id_ctd)) if id_ctd[i]%2 == 0]
1162
+
1163
+ TEMP_ctd_interp = mvp.vertical_interp(self.PRES_ctd[id_ctd1,:],self.TEMP_ctd[id_ctd1,:], pressure_grid)
1164
+ SALT_ctd_interp = mvp.vertical_interp(self.PRES_ctd[id_ctd1,:],self.SALT_ctd[id_ctd1,:], pressure_grid)
1165
+ DO_ctd_interp = mvp.vertical_interp(self.PRES_ctd[id_ctd1,:],self.OXY_ctd[id_ctd1,:], pressure_grid)
1166
+ COND_ctd_interp = mvp.vertical_interp(self.PRES_ctd[id_ctd1,:],self.COND_ctd[id_ctd1,:], pressure_grid)
1167
+
1168
+ # differences study between MVP down and CTD profiles
1169
+
1170
+ # Calcul des différences entre les profils interpolés (MVP - CTD)
1171
+ diff_temp_down = TEMP_mvp_interp[0::2] - TEMP_ctd_interp
1172
+ diff_temp_up = TEMP_mvp_interp[1::2] - TEMP_ctd_interp
1173
+ diff_salt_down = SALT_mvp_interp[0::2] - SALT_ctd_interp
1174
+ diff_salt_up = SALT_mvp_interp[1::2] - SALT_ctd_interp
1175
+ diff_do_down = DO_mvp_interp[0::2] - DO_ctd_interp
1176
+ diff_do_up = DO_mvp_interp[1::2] - DO_ctd_interp
1177
+ diff_cond_down = COND_mvp_interp[0::2] - COND_ctd_interp
1178
+ diff_cond_up = COND_mvp_interp[1::2] - COND_ctd_interp
1179
+
1180
+
1181
+ # Plot mean error vs depth for each variable (down/up)
1182
+ fig, axes = plt.subplots(1, 3, figsize=(15, 5))
1183
+
1184
+ # Compute mean error along profiles (axis=0: profiles, axis=1: depth)
1185
+ mean_temp_down = np.absolute(np.nanmean(diff_temp_down, axis=0))
1186
+ mean_temp_up = np.absolute(np.nanmean(diff_temp_up, axis=0))
1187
+ mean_salt_down = np.absolute(np.nanmean(diff_salt_down, axis=0))
1188
+ mean_salt_up = np.absolute(np.nanmean(diff_salt_up, axis=0))
1189
+ mean_do_down = np.absolute(np.nanmean(diff_do_down, axis=0))
1190
+ mean_do_up = np.absolute(np.nanmean(diff_do_up, axis=0))
1191
+ mean_cond_down = np.absolute(np.nanmean(diff_cond_down, axis=0))
1192
+ mean_cond_up = np.absolute(np.nanmean(diff_cond_up, axis=0))
1193
+
1194
+ axes[0].plot(mean_temp_down, pressure_grid, label='Down')
1195
+ axes[0].plot(mean_temp_up, pressure_grid, label='Up')
1196
+ axes[0].invert_yaxis()
1197
+ axes[0].set_xlabel('Absolute Mean Error (°C)')
1198
+ axes[0].set_ylabel('Pressure (dbar)')
1199
+ axes[0].set_title('Temperature Error')
1200
+ axes[0].legend()
1201
+ axes[0].grid()
1202
+
1203
+
1204
+ if cond:
1205
+
1206
+ axes[1].plot(mean_cond_down, pressure_grid, label='Down')
1207
+ axes[1].plot(mean_cond_up, pressure_grid, label='Up')
1208
+ axes[1].invert_yaxis()
1209
+ axes[1].set_xlabel('Absolute Mean Error (S/m)')
1210
+ axes[1].set_ylabel('Pressure (dbar)')
1211
+ axes[1].set_title('Conductivity Error')
1212
+ axes[1].legend()
1213
+ axes[1].grid()
1214
+
1215
+ else:
1216
+
1217
+ axes[1].plot(mean_salt_down, pressure_grid, label='Down')
1218
+ axes[1].plot(mean_salt_up, pressure_grid, label='Up')
1219
+ axes[1].invert_yaxis()
1220
+ axes[1].set_xlabel('Absolute Mean Error (psu)')
1221
+ axes[1].set_ylabel('Pressure (dbar)')
1222
+ axes[1].set_title('Salinity Error')
1223
+ axes[1].legend()
1224
+ axes[1].grid()
1225
+
1226
+ if speed:
1227
+
1228
+ axes[2].plot(self.v_z_down, self.PRES_mvp[0], label='Down')
1229
+ axes[2].plot(self.v_z_up, self.PRES_mvp[0], label='Up')
1230
+ axes[2].invert_yaxis()
1231
+ axes[2].set_xlabel('Vertical Speed (m/s)')
1232
+ axes[2].set_ylabel('Pressure (dbar)')
1233
+ axes[2].set_title('Vertical Speed')
1234
+ axes[2].legend()
1235
+ axes[2].grid()
1236
+
1237
+
1238
+ else:
1239
+
1240
+ axes[2].plot(mean_do_down, pressure_grid, label='Down')
1241
+ axes[2].plot(mean_do_up, pressure_grid, label='Up')
1242
+ axes[2].invert_yaxis()
1243
+ axes[2].set_xlabel('Absolute Mean Error (%)')
1244
+ axes[2].set_ylabel('Pressure (dbar)')
1245
+ axes[2].set_title('Oxygen Error')
1246
+ axes[2].legend()
1247
+ axes[2].grid()
1248
+
1249
+ fig.suptitle('Absolute Mean Error (MVP - CTD) vs Depth')
1250
+ fig.tight_layout()
1251
+ plt.show()
1252
+
1253
+
1254
+ # Compute RMSE
1255
+
1256
+ rmse_temp_down = np.mean(np.sqrt(np.nanmean(diff_temp_down**2, axis=1)))
1257
+ rmse_temp_up = np.mean(np.sqrt(np.nanmean(diff_temp_up**2, axis=1)))
1258
+ rmse_salt_down = np.mean(np.sqrt(np.nanmean(diff_salt_down**2, axis=1)))
1259
+ rmse_salt_up = np.mean(np.sqrt(np.nanmean(diff_salt_up**2, axis=1)))
1260
+ rmse_do_down = np.mean(np.sqrt(np.nanmean(diff_do_down**2, axis=1)))
1261
+ rmse_do_up = np.mean(np.sqrt(np.nanmean(diff_do_up**2, axis=1)))
1262
+ rmse_cond_down = np.mean(np.sqrt(np.nanmean(diff_cond_down**2, axis=1)))
1263
+ rmse_cond_up = np.mean(np.sqrt(np.nanmean(diff_cond_up**2, axis=1)))
1264
+
1265
+ # Find index where depth >= 200 dbar; fallback to 0 if not found
1266
+ i_200 = 0
1267
+ for i in range(len(pressure_grid)):
1268
+ if pressure_grid[i] >= 200:
1269
+ i_200 = i
1270
+ break
1271
+
1272
+ # Slice along depth axis (columns) to keep depths >= 200 dbar
1273
+ rmse_temp_down_deep = np.mean(np.sqrt(np.nanmean(diff_temp_down[:, i_200:]**2, axis=1)))
1274
+ rmse_temp_up_deep = np.mean(np.sqrt(np.nanmean(diff_temp_up[:, i_200:]**2, axis=1)))
1275
+ rmse_salt_down_deep = np.mean(np.sqrt(np.nanmean(diff_salt_down[:, i_200:]**2, axis=1)))
1276
+ rmse_salt_up_deep = np.mean(np.sqrt(np.nanmean(diff_salt_up[:, i_200:]**2, axis=1)))
1277
+ rmse_do_down_deep = np.mean(np.sqrt(np.nanmean(diff_do_down[:, i_200:]**2, axis=1)))
1278
+ rmse_do_up_deep = np.mean(np.sqrt(np.nanmean(diff_do_up[:, i_200:]**2, axis=1)))
1279
+ rmse_cond_down_deep = np.mean(np.sqrt(np.nanmean(diff_cond_down[:, i_200:]**2, axis=1)))
1280
+ rmse_cond_up_deep = np.mean(np.sqrt(np.nanmean(diff_cond_up[:, i_200:]**2, axis=1)))
1281
+
1282
+
1283
+ # Print statistics + grouped deep RMSE
1284
+
1285
+ temp_rmse = [rmse_temp_down, rmse_temp_up]
1286
+ salt_rmse = [rmse_salt_down, rmse_salt_up]
1287
+ do_rmse = [rmse_do_down, rmse_do_up]
1288
+
1289
+ temp_rmse_deep = [rmse_temp_down_deep, rmse_temp_up_deep]
1290
+ salt_rmse_deep = [rmse_salt_down_deep, rmse_salt_up_deep]
1291
+ do_rmse_deep = [rmse_do_down_deep, rmse_do_up_deep]
1292
+
1293
+ labels = ['MVP down', 'MVP up']
1294
+ colors = ['blue', 'orange']
1295
+
1296
+ fig, axes = plt.subplots(1, 3, figsize=(14, 4))
1297
+
1298
+ for idx, (ax, data, data_deep, title, ylabel) in enumerate(zip(
1299
+ axes,
1300
+ [temp_rmse, salt_rmse, do_rmse],
1301
+ [temp_rmse_deep, salt_rmse_deep, do_rmse_deep],
1302
+ ['Temperature', 'Salinity', 'Oxygen'],
1303
+ ['RMSE (°C)', 'RMSE (psu)', 'RMSE (%)']
1304
+ )):
1305
+ x = np.arange(len(labels))
1306
+ width = 0.35
1307
+ # Side-by-side grouped bars: left = All depths, right = Deep
1308
+ label_all = 'All depths' if idx == 0 else None
1309
+ label_deep = 'Deep (≥200 dbar)' if idx == 0 else None
1310
+ bars_all = ax.bar(x - width/2, data, width=width, color=colors, edgecolor='k', label=label_all)
1311
+ bars_deep = ax.bar(x + width/2, data_deep, width=width, color=colors, edgecolor='k', alpha=0.6, label=label_deep)
1312
+
1313
+ ax.set_xticks(x)
1314
+ ax.set_xticklabels(labels, rotation=20)
1315
+ ax.set_title(title)
1316
+ ax.set_ylabel(ylabel)
1317
+ ax.grid(axis='y', linestyle=':', alpha=0.5)
1318
+ ymax = max(max(data), max(data_deep)) * 1.25 # 25% margin above highest
1319
+ ax.set_ylim(0, ymax)
1320
+
1321
+ # Annotations
1322
+ for b in bars_all:
1323
+ h = b.get_height()
1324
+ if np.isfinite(h):
1325
+ ax.annotate(f'{h:.3f}', (b.get_x() + b.get_width()/2, h),
1326
+ xytext=(0, 3), textcoords='offset points',
1327
+ ha='center', va='bottom', fontsize=10, fontweight='bold')
1328
+ for b in bars_deep:
1329
+ h = b.get_height()
1330
+ if np.isfinite(h):
1331
+ ax.annotate(f'{h:.3f}', (b.get_x() + b.get_width()/2, h),
1332
+ xytext=(0, 3), textcoords='offset points',
1333
+ ha='center', va='bottom', fontsize=9)
1334
+
1335
+ if idx == 0:
1336
+ ax.legend()
1337
+
1338
+ fig.suptitle('RMSE MVP vs CTD')
1339
+ fig.tight_layout()
1340
+ plt.show()
1341
+
1342
+
1343
+ if cond:
1344
+ print("Conductivity RMSE (MVP - CTD):")
1345
+ print(f" MVP down: {rmse_cond_down:.4f} S/m (deep: {rmse_cond_down_deep:.4f} S/m)")
1346
+ print(f" MVP up: {rmse_cond_up:.4f} S/m (deep: {rmse_cond_up_deep:.4f} S/m)")
1347
+
1348
+ def correct_oxygen(self,id_mvp=None,id_ctd=None,num_sample=500,plotting=False,correction=False):
1349
+ """
1350
+ Apply oxygen correction to MVP dissolved oxygen profiles thanks to CTD data.
1351
+ Args:
1352
+ id_mvp (int): Index of the MVP profile to use for correction.
1353
+ id_ctd (int): Index of the CTD profile to use for correction.
1354
+ num_sample (int): Number of pressure levels for interpolation.
1355
+ plotting (bool): If True, plot the correction results.
1356
+ correction (bool): If True, update corrected attributes.
1357
+ """
1358
+
1359
+ if not self.mvp or not self.ctd:
1360
+ raise ValueError("MVP or CTD data not loaded.")
1361
+
1362
+
1363
+ if id_mvp is None:
1364
+ id_mvp,id_ctd = 0,0
1365
+ print(f"No profile index provided, using first profiles: MVP {id_mvp} and CTD {id_ctd}.")
1366
+ elif id_ctd is None:
1367
+ id_ctd = id_mvp
1368
+
1369
+
1370
+ # Interpolate MVP and CTD data to match pressure levels
1371
+ pmin = np.nanmin(self.PRES_mvp)
1372
+ pmax = np.nanmax(self.PRES_mvp)
1373
+ pressure_grid = np.linspace(pmin, pmax, num_sample)
1374
+
1375
+
1376
+ DO_mvp_interp = mvp.vertical_interp(self.PRES_mvp[id_mvp,:], self.DO_mvp[id_mvp,:], pressure_grid)
1377
+ DO_ctd_interp = mvp.vertical_interp(self.PRES_ctd[id_ctd,:],self.OXY_ctd[id_ctd,:], pressure_grid)
1378
+
1379
+ mask = ~np.isnan(DO_mvp_interp) & ~np.isnan(DO_ctd_interp)
1380
+ pressure_grid = pressure_grid[mask[0]]
1381
+ DO_mvp_interp = DO_mvp_interp[mask]
1382
+ DO_ctd_interp = DO_ctd_interp[mask]
1383
+
1384
+ diff = DO_mvp_interp-DO_ctd_interp
1385
+
1386
+ A = np.vstack([pressure_grid, np.ones_like(pressure_grid)]).T
1387
+ print(A.shape, diff.shape)
1388
+ diff = diff.flatten()
1389
+ a_estime, b_estime = np.linalg.lstsq(A, diff, rcond=None)[0]
1390
+
1391
+ print(f"Pente estimée (a) : {a_estime:.6f} ")
1392
+ print(f"Biais estimé (b) : {b_estime:.6f} ")
1393
+
1394
+ DO_mvp_corr = DO_mvp_interp - (a_estime*pressure_grid + b_estime)
1395
+
1396
+
1397
+
1398
+ rmse_before = np.sqrt(np.nanmean((DO_mvp_interp - DO_ctd_interp)**2))
1399
+ rmse_after = np.sqrt(np.nanmean((DO_mvp_corr - DO_ctd_interp)**2))
1400
+ print(f"RMSE before correction: {rmse_before:.4f}")
1401
+ print(f"RMSE after correction: {rmse_after:.4f}")
1402
+
1403
+ DO_mvp_corr_full = self.DO_mvp - (a_estime*self.PRES_mvp + b_estime)
1404
+
1405
+ DO_mvp_corr_full_interp = mvp.vertical_interp(self.PRES_mvp, DO_mvp_corr_full, pressure_grid)
1406
+ rmse_after_full = np.mean(np.sqrt(np.nanmean((DO_mvp_corr_full_interp - DO_ctd_interp)**2,axis=1)))
1407
+ print(f"RMSE after correction (full profile): {rmse_after_full:.4f}")
1408
+
1409
+
1410
+ if correction:
1411
+ self.DO_mvp = DO_mvp_corr_full
1412
+
1413
+ if plotting:
1414
+
1415
+ plt.figure()
1416
+ plt.plot(DO_mvp_interp,pressure_grid,label='MVP')
1417
+ plt.plot(DO_ctd_interp,pressure_grid,label='CTD')
1418
+ plt.plot(DO_mvp_corr,pressure_grid,label='MVP corrected')
1419
+ plt.gca().invert_yaxis()
1420
+ plt.xlabel('Dissolved Oxygen, %')
1421
+ plt.ylabel('Pressure, dbar')
1422
+ plt.title('Oxygen correction')
1423
+ plt.legend()
1424
+ plt.grid()
1425
+ plt.show()
1426
+
1427
+
1428
+
1429
+ def mvp_correction(self,high_cutoff=1,dp=0.1):
1430
+
1431
+ T_MVP_corr = []
1432
+ P_MVP_corr = []
1433
+ C_MVP_corr = []
1434
+ S_MVP_corr = []
1435
+ Time_MVP_corr = []
1436
+
1437
+ print("Applying corrections to MVP profiles...")
1438
+
1439
+ for id in tqdm(range(0,self.PRES_mvp.shape[0])):
1440
+
1441
+ T = self.TEMP_mvp[id]
1442
+ C = self.COND_mvp[id]
1443
+ P = self.PRES_mvp[id]
1444
+ S = self.SALT_mvp[id]
1445
+ Time = np.linspace(0,len(P)/self.freq_echant,len(P))
1446
+
1447
+ mask = ~np.isnan(C) & ~np.isnan(T)
1448
+
1449
+ C = C[mask]
1450
+ T = T[mask]
1451
+ Time = Time[mask]
1452
+ P = P[mask]
1453
+ S = S[mask]
1454
+
1455
+ T,C = mvp.filtering_tc(T,C,self.freq_echant,high_cutoff)
1456
+ T_corr,S_corr = mvp.temporal_lag(T,C,P,self.freq_echant)
1457
+
1458
+ if dp != None:
1459
+ P_ba,T_corr_ba,C_ba,S_corr_ba,Time_ba = mvp.bin_average_v2(P,T_corr,C,S_corr,Time,dp=0.1)
1460
+
1461
+ S_corr_medfilt = median_filter(S_corr_ba, size=5)
1462
+
1463
+ else:
1464
+
1465
+ T_corr_ba = T_corr
1466
+ C_ba = C
1467
+ S_corr_medfilt = S_corr
1468
+ P_ba = P
1469
+ Time_ba = Time
1470
+
1471
+ T_MVP_corr.append(T_corr_ba)
1472
+ P_MVP_corr.append(P_ba)
1473
+ C_MVP_corr.append(C_ba)
1474
+ S_MVP_corr.append(S_corr_medfilt)
1475
+ Time_MVP_corr.append(Time_ba)
1476
+
1477
+ self.TEMP_mvp_corr = {i: sublist for i, sublist in enumerate(T_MVP_corr)}
1478
+ self.PRES_mvp_corr = {i: sublist for i, sublist in enumerate(P_MVP_corr)}
1479
+ self.COND_mvp_corr = {i: sublist for i, sublist in enumerate(C_MVP_corr)}
1480
+ self.SALT_mvp_corr = {i: sublist for i, sublist in enumerate(S_MVP_corr)}
1481
+ self.TIME_mvp_corr = {i: sublist for i, sublist in enumerate(Time_MVP_corr)}
1482
+
1483
+
1484
+ print("MVP profiles corrected.")
1485
+
1486
+
1487
+ def interpolate_CTD_and_MVPcorrected(self,length):
1488
+
1489
+ """
1490
+ Interpolate CTD data onto the corrected MVP pressure levels.
1491
+ """
1492
+ if not self.ctd:
1493
+ raise ValueError("CTD data not loaded.")
1494
+
1495
+ if not hasattr(self, 'PRES_mvp_corr'):
1496
+ raise ValueError("Corrected MVP data not available. Apply corrections first.")
1497
+
1498
+
1499
+ max_lenpres = max([len(p) for p in self.PRES_mvp_corr.values()])
1500
+ PRES_mvp_corr_mat = np.array([list(row) + [np.nan] * (max_lenpres - len(row)) for row in self.PRES_mvp_corr.values()])
1501
+
1502
+ max_lentemp = max([len(p) for p in self.TEMP_mvp_corr.values()])
1503
+ TEMP_mvp_corr_mat = np.array([list(row) + [np.nan] * (max_lentemp - len(row)) for row in self.TEMP_mvp_corr.values()])
1504
+
1505
+ max_lencond = max([len(p) for p in self.COND_mvp_corr.values()])
1506
+ COND_mvp_corr_mat = np.array([list(row) + [np.nan] * (max_lencond - len(row)) for row in self.COND_mvp_corr.values()])
1507
+
1508
+ max_lensalt = max([len(p) for p in self.SALT_mvp_corr.values()])
1509
+ SALT_mvp_corr_mat = np.array([list(row) + [np.nan] * (max_lensalt - len(row)) for row in self.SALT_mvp_corr.values()])
1510
+
1511
+ max_lenvspd = max([len(p) for p in self.SPEED_mvp_corr.values()])
1512
+ SPEED_mvp_corr_mat = np.array([list(row) + [np.nan] * (max_lenvspd - len(row)) for row in self.SPEED_mvp_corr.values()])
1513
+
1514
+ max_lentime = max([len(p) for p in self.TIME_mvp_corr.values()])
1515
+ TIME_mvp_corr_mat = np.array([list(row) + [np.nan] * (max_lentime - len(row)) for row in self.TIME_mvp_corr.values()])
1516
+
1517
+
1518
+ pressure_grid = np.linspace(np.nanmin(PRES_mvp_corr_mat), np.nanmax(PRES_mvp_corr_mat), length)
1519
+
1520
+ self.TEMP_ctd_on_mvp = mvp.vertical_interp(self.PRES_ctd, self.TEMP_ctd, pressure_grid)
1521
+ self.PRES_ctd_on_mvp = mvp.vertical_interp(self.PRES_ctd, self.PRES_ctd, pressure_grid)
1522
+ self.COND_ctd_on_mvp = mvp.vertical_interp(self.PRES_ctd, self.COND_ctd, pressure_grid)
1523
+ self.SALT_ctd_on_mvp = mvp.vertical_interp(self.PRES_ctd, self.SALT_ctd, pressure_grid)
1524
+ self.OXY_ctd_on_mvp = mvp.vertical_interp(self.PRES_ctd, self.OXY_ctd, pressure_grid)
1525
+ self.TEMP_mvp_corr_interp = mvp.vertical_interp(PRES_mvp_corr_mat, TEMP_mvp_corr_mat, pressure_grid)
1526
+ self.PRES_mvp_corr_interp = mvp.vertical_interp(PRES_mvp_corr_mat, PRES_mvp_corr_mat, pressure_grid)
1527
+ self.COND_mvp_corr_interp = mvp.vertical_interp(PRES_mvp_corr_mat, COND_mvp_corr_mat, pressure_grid)
1528
+ self.SALT_mvp_corr_interp = mvp.vertical_interp(PRES_mvp_corr_mat, SALT_mvp_corr_mat, pressure_grid)
1529
+ self.SPEED_mvp_corr_interp = mvp.vertical_interp(PRES_mvp_corr_mat, SPEED_mvp_corr_mat, pressure_grid)
1530
+ self.TIME_mvp_corr_interp = mvp.vertical_interp(PRES_mvp_corr_mat, TIME_mvp_corr_mat, pressure_grid)
1531
+
1532
+ print('CTD data interpolated onto corrected MVP pressure levels.')
1533
+
1534
+
1535
+ def to_netcdf(self, filepath=None, corrected=False, compression=True, engine=None, per_profile_files=False):
1536
+ """
1537
+ Export MVP data to a NetCDF file using xarray.
1538
+
1539
+ Args:
1540
+ filepath (str): Output NetCDF file path.
1541
+ corrected (bool): Also write corrected arrays if present (*_mvp_corr). Default False.
1542
+ compression (bool): Enable compression (engine dependent). Default True.
1543
+ engine (str|None): One of 'netcdf4', 'h5netcdf', 'scipy'. If None, choose netcdf4.
1544
+ per_profile_files (bool): If True, write one .nc per MVP cycle (two rows: down and up).
1545
+ """
1546
+ if not getattr(self, 'mvp', False):
1547
+ raise RuntimeError("No MVP data loaded. Call load_mvp_data() first.")
1548
+
1549
+ engine = 'netcdf4' if engine is None else engine
1550
+ if engine == 'scipy' and compression:
1551
+ print('Warning: scipy backend does not support compression; writing without compression.')
1552
+ compression = False
1553
+
1554
+ # Dimensions
1555
+ n_prof, n_samp = self.PRES_mvp.shape
1556
+
1557
+ # Coordinates
1558
+ profile_idx = np.arange(n_prof, dtype=np.int32)
1559
+ sample_idx = np.arange(n_samp, dtype=np.int32)
1560
+
1561
+ # Direction per profile (down/up)
1562
+ direction = None
1563
+ if hasattr(self, 'DIR') and len(self.DIR) == n_prof:
1564
+ direction = np.array(self.DIR, dtype=object)
1565
+ else:
1566
+ # Fallback based on even/odd
1567
+ direction = np.array(['down' if i % 2 == 0 else 'up' for i in range(n_prof)], dtype=object)
1568
+
1569
+ # Per-sample time as seconds since reference origin
1570
+ # TIME_mvp is in days relative to self.date_ref
1571
+ time_seconds = None
1572
+ if hasattr(self, 'TIME_mvp'):
1573
+ time_seconds = self.TIME_mvp * 24.0 * 3600.0
1574
+ else:
1575
+ time_seconds = np.full((n_prof, n_samp), np.nan)
1576
+
1577
+ # Per-profile datetime (one timestamp per cast pair); map using i//2
1578
+ profile_time = None
1579
+ if hasattr(self, 'DATETIME_mvp') and len(getattr(self, 'DATETIME_mvp', [])) > 0:
1580
+ prof_times = []
1581
+ for i in range(n_prof):
1582
+ j = i // 2
1583
+ if j < len(self.DATETIME_mvp) and self.DATETIME_mvp[j] is not None:
1584
+ prof_times.append(np.datetime64(self.DATETIME_mvp[j]))
1585
+ else:
1586
+ prof_times.append(np.datetime64('NaT'))
1587
+ profile_time = np.array(prof_times, dtype='datetime64[ns]')
1588
+ else:
1589
+ profile_time = np.array([np.datetime64('NaT')] * n_prof, dtype='datetime64[ns]')
1590
+
1591
+ # Build dataset variables safely
1592
+ data_vars = {}
1593
+
1594
+ def add_var(var_name, arr, units=None, long_name=None):
1595
+ if arr is None:
1596
+ return
1597
+ data_vars[var_name] = (
1598
+ ('profile', 'sample'), arr,
1599
+ {k: v for k, v in [('units', units), ('long_name', long_name)] if v is not None}
1600
+ )
1601
+
1602
+ add_var('PRES', getattr(self, 'PRES_mvp', None), units='dbar', long_name='Sea water pressure')
1603
+ add_var('TEMP', getattr(self, 'TEMP_mvp', None), units='degC', long_name='In-situ temperature')
1604
+ add_var('COND', getattr(self, 'COND_mvp', None), units='mS/cm', long_name='Conductivity')
1605
+ add_var('SAL', getattr(self, 'SALT_mvp', None), units='psu', long_name='Practical salinity')
1606
+ add_var('SOUNDVEL', getattr(self, 'SOUNDVEL_mvp', None), units='m s-1', long_name='Sound speed')
1607
+ add_var('DO', getattr(self, 'DO_mvp', None), units='ml/L', long_name='Dissolved oxygen')
1608
+ add_var('TEMP2', getattr(self, 'TEMP2_mvp', None), units='degC', long_name='Oxygen sensor temperature')
1609
+ add_var('SUNA', getattr(self, 'SUNA_mvp', None), long_name='SUNA raw/derived')
1610
+ add_var('FLUO', getattr(self, 'FLUO_mvp', None), units='ug/L', long_name='Chl fluorescence')
1611
+ add_var('TURB', getattr(self, 'TURB_mvp', None), units='NTU', long_name='Turbidity')
1612
+ add_var('PH', getattr(self, 'PH_mvp', None), units='1', long_name='pH')
1613
+
1614
+ # Position and time arrays (2D)
1615
+ if hasattr(self, 'LAT_mvp'):
1616
+ add_var('LATITUDE', self.LAT_mvp, units='degrees_north', long_name='Latitude at sample')
1617
+ if hasattr(self, 'LON_mvp'):
1618
+ add_var('LONGITUDE', self.LON_mvp, units='degrees_east', long_name='Longitude at sample')
1619
+ # Time seconds since reference
1620
+ data_vars['TIME'] = (
1621
+ ('profile', 'sample'), time_seconds,
1622
+ {
1623
+ 'units': f'seconds since {self.date_ref.strftime("%Y-%m-%d %H:%M:%S")}',
1624
+ 'long_name': 'Time at sample'
1625
+ }
1626
+ )
1627
+
1628
+ # Include corrected arrays if requested and present
1629
+ if corrected:
1630
+ def add_corr(name, attr, units=None, long_name=None):
1631
+ if hasattr(self, attr):
1632
+ data_vars[name] = (
1633
+ ('profile', 'sample'), getattr(self, attr),
1634
+ {k: v for k, v in [('units', units), ('long_name', long_name)] if v is not None}
1635
+ )
1636
+ add_corr('pressure_corrected', 'PRES_mvp_corr', units='dbar', long_name='Corrected pressure')
1637
+ add_corr('temperature_corrected', 'TEMP_mvp_corr', units='degC', long_name='Corrected temperature')
1638
+ add_corr('conductivity_corrected', 'COND_mvp_corr', units='mS/cm', long_name='Corrected conductivity')
1639
+ add_corr('salinity_corrected', 'SALT_mvp_corr', units='psu', long_name='Corrected salinity')
1640
+ if hasattr(self, 'TIME_mvp_corr'):
1641
+ data_vars['time_corrected'] = (
1642
+ ('profile', 'sample'), self.TIME_mvp_corr * 24.0 * 3600.0,
1643
+ {
1644
+ 'units': f'seconds since {self.date_ref.strftime("%Y-%m-%d %H:%M:%S")}',
1645
+ 'long_name': 'Corrected time at sample'
1646
+ }
1647
+ )
1648
+ if hasattr(self, 'LAT_mvp_corr'):
1649
+ add_corr('latitude_corrected', 'LAT_mvp_corr', units='degrees_north', long_name='Corrected latitude at sample')
1650
+ if hasattr(self, 'LON_mvp_corr'):
1651
+ add_corr('longitude_corrected', 'LON_mvp_corr', units='degrees_east', long_name='Corrected longitude at sample')
1652
+
1653
+ # Coordinates and auxiliary per-profile variables
1654
+ coords = {
1655
+ 'profile': ('profile', profile_idx),
1656
+ 'sample': ('sample', sample_idx)
1657
+ }
1658
+
1659
+ # Encode direction/time according to engine capabilities
1660
+ if engine in ('netcdf4', 'h5netcdf'):
1661
+ coords['direction'] = ('profile', direction.astype('U'), {'long_name': 'Profile direction'})
1662
+ coords['profile_time'] = ('profile', profile_time, {'long_name': 'Profile nominal time'})
1663
+ else:
1664
+ # scipy backend: avoid object strings and datetime; use numeric fallbacks
1665
+ dir_flag = np.where(direction.astype('U') == 'down', 0, 1).astype('int8')
1666
+ coords['direction_flag'] = (
1667
+ 'profile', dir_flag, {'long_name': 'Profile direction (0=down,1=up)'}
1668
+ )
1669
+ ref = np.datetime64(self.date_ref)
1670
+ pt = profile_time.astype('datetime64[s]')
1671
+ mask = (pt == np.datetime64('NaT'))
1672
+ secs = (pt - ref).astype('timedelta64[s]').astype('float64')
1673
+ secs[mask] = np.nan
1674
+ coords['profile_time_sec'] = (
1675
+ 'profile', secs,
1676
+ {'units': f'seconds since {self.date_ref.strftime("%Y-%m-%d %H:%M:%S")}',
1677
+ 'long_name': 'Profile nominal time'}
1678
+ )
1679
+
1680
+ # Optional per-profile lat/lon (first valid sample)
1681
+ def first_valid(vec):
1682
+ # vec shape (n_prof, n_samp)
1683
+ out = np.full((vec.shape[0],), np.nan)
1684
+ for i in range(vec.shape[0]):
1685
+ row = vec[i]
1686
+ j = np.where(~np.isnan(row))[0]
1687
+ if j.size:
1688
+ out[i] = row[j[0]]
1689
+ return out
1690
+
1691
+ if hasattr(self, 'LAT_mvp'):
1692
+ coords['profile_lat'] = (
1693
+ 'profile', first_valid(self.LAT_mvp), {'units': 'degrees_north', 'long_name': 'Profile latitude'}
1694
+ )
1695
+ if hasattr(self, 'LON_mvp'):
1696
+ coords['profile_lon'] = (
1697
+ 'profile', first_valid(self.LON_mvp), {'units': 'degrees_east', 'long_name': 'Profile longitude'}
1698
+ )
1699
+
1700
+ # Global attributes
1701
+ attrs = {
1702
+ 'title': 'MVP profile data',
1703
+ 'Conventions': 'CF-1.8',
1704
+ 'institution': 'LMD/CNRS',
1705
+ 'source': 'MVPAnalyzer',
1706
+ 'history': f"Created on {datetime.now().isoformat()}",
1707
+ 'mvp_Yorig': int(self.Yorig)
1708
+ }
1709
+
1710
+ ds = xr.Dataset(data_vars=data_vars, coords=coords, attrs=attrs)
1711
+
1712
+ # Compression encoding per engine
1713
+ encoding = None
1714
+ if compression:
1715
+ if engine == 'netcdf4':
1716
+ encoding = {name: {'zlib': True, 'complevel': 4} for name in data_vars.keys()}
1717
+ elif engine == 'h5netcdf':
1718
+ encoding = {name: {'compression': 'gzip', 'compression_opts': 4} for name in data_vars.keys()}
1719
+
1720
+ # Determine output base directory
1721
+ if filepath is None:
1722
+ base_dir = self.output_path if hasattr(self, 'output_path') else os.getcwd() + os.sep
1723
+ else:
1724
+ # If a full file path was provided and not per_profile_files, honor it
1725
+ if (not per_profile_files) and filepath.lower().endswith('.nc'):
1726
+ out_path = filepath
1727
+ ds.to_netcdf(out_path, encoding=encoding, engine=engine)
1728
+ print(f"NetCDF written: {out_path} using engine={engine}")
1729
+ return
1730
+ base_dir = filepath
1731
+
1732
+ if not base_dir.endswith(os.sep):
1733
+ base_dir = base_dir + os.sep
1734
+
1735
+ base_name = "MVP_" + os.path.basename(self.data_path).rstrip(os.sep)
1736
+ if per_profile_files:
1737
+ # Write one file per pair (down/up)
1738
+ total_pairs = (n_prof + 1) // 2
1739
+ for i in range(total_pairs):
1740
+ idxs = [k for k in (2*i, 2*i+1) if k < n_prof]
1741
+ if not idxs:
1742
+ continue
1743
+ ds_i = ds.isel(profile=idxs)
1744
+
1745
+ #add i to filename
1746
+ fname = f"{base_name}_profile_{i:03d}.nc"
1747
+ out_path = os.path.join(base_dir, fname)
1748
+ ds_i.to_netcdf(out_path, encoding=encoding, engine=engine)
1749
+ print(f"NetCDF written per profile into: {base_dir} using engine={engine}")
1750
+ else:
1751
+ file_name = f"{base_name}.nc"
1752
+ out_path = os.path.join(base_dir, file_name)
1753
+ ds.to_netcdf(out_path, encoding=encoding, engine=engine)
1754
+ print(f"NetCDF written: {out_path} using engine={engine}")
1755
+
1756
+
1757
+ def help(self):
1758
+ """
1759
+ Print all methods of the class with their docstring (header).
1760
+ """
1761
+ for attr in dir(self):
1762
+ if callable(getattr(self, attr)) and not attr.startswith("__"):
1763
+ method = getattr(self, attr)
1764
+ doc = method.__doc__
1765
+ print(f"{attr}:\n{doc}\n{'-'*40}")
1766
+
1767
+
1768
+
1769
+ def split_ctd(pres, array):
1770
+
1771
+ ibot = np.min(np.where(pres == pres.max()))
1772
+
1773
+ array_down = array[:ibot]
1774
+ array_up = array[ibot:]
1775
+
1776
+ return array_down, array_up