oceanarray 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.
- oceanarray/__init__.py +20 -0
- oceanarray/_version.py +1 -0
- oceanarray/cli.py +1343 -0
- oceanarray/clock_offset.py +675 -0
- oceanarray/config/OS1_sensor_attrs.yaml +49 -0
- oceanarray/config/OS1_var_names.yaml +5 -0
- oceanarray/config/OS1_vocab_attrs.yaml +84 -0
- oceanarray/config/legacy/project_RAPID.yaml +69 -0
- oceanarray/config/legacy/rodb_keys.txt +206 -0
- oceanarray/config/legacy/rodb_keys.yaml +1012 -0
- oceanarray/config/logging.yaml +28 -0
- oceanarray/legacy/__init__.py +47 -0
- oceanarray/legacy/caldip.py +753 -0
- oceanarray/legacy/convertOS.py +527 -0
- oceanarray/legacy/find_deployment.py +489 -0
- oceanarray/legacy/mooring_rodb.py +429 -0
- oceanarray/legacy/process_rodb.py +369 -0
- oceanarray/legacy/rodb.py +330 -0
- oceanarray/logger.py +192 -0
- oceanarray/logsheet/__init__.py +60 -0
- oceanarray/logsheet/_columns.py +97 -0
- oceanarray/logsheet/_config.py +245 -0
- oceanarray/logsheet/_firmware.py +43 -0
- oceanarray/logsheet/_latex.py +128 -0
- oceanarray/logsheet/_render.py +126 -0
- oceanarray/logsheet/builders/__init__.py +13 -0
- oceanarray/logsheet/builders/caldip_download.py +195 -0
- oceanarray/logsheet/builders/caldip_setup.py +188 -0
- oceanarray/logsheet/builders/deployment_setup.py +218 -0
- oceanarray/logsheet/builders/mooring_download.py +243 -0
- oceanarray/logsheet/builders/recovery.py +157 -0
- oceanarray/logsheet/data/column_defs.yaml +686 -0
- oceanarray/logsheet/data/latex_templates/_base_instrument.tex.j2 +42 -0
- oceanarray/logsheet/data/latex_templates/caldip_download.tex.j2 +47 -0
- oceanarray/logsheet/data/latex_templates/caldip_setup.tex.j2 +70 -0
- oceanarray/logsheet/data/latex_templates/deployment_setup.tex.j2 +97 -0
- oceanarray/logsheet/data/latex_templates/mooring_download.tex.j2 +54 -0
- oceanarray/logsheet/data/latex_templates/recovery.tex.j2 +103 -0
- oceanarray/mooring_level.py +1368 -0
- oceanarray/oceanarray.mplstyle +18 -0
- oceanarray/parameters.py +270 -0
- oceanarray/plotter.py +942 -0
- oceanarray/plotters/__init__.py +97 -0
- oceanarray/plotters/_animation.py +261 -0
- oceanarray/plotters/_current.py +705 -0
- oceanarray/plotters/_diagnostic.py +667 -0
- oceanarray/plotters/_helpers.py +57 -0
- oceanarray/plotters/_primitives.py +101 -0
- oceanarray/plotters/_section.py +16 -0
- oceanarray/plotters/_timeseries.py +11 -0
- oceanarray/rapid_interp.py +739 -0
- oceanarray/read_rbr_hex.py +541 -0
- oceanarray/readers.py +303 -0
- oceanarray/report/__init__.py +5 -0
- oceanarray/report/_array.py +386 -0
- oceanarray/report/_grid.py +539 -0
- oceanarray/report/_html_helpers.py +1004 -0
- oceanarray/report/_instrument.py +853 -0
- oceanarray/report/_mooring.py +1385 -0
- oceanarray/report/_plots.py +4284 -0
- oceanarray/report/_stack.py +941 -0
- oceanarray/stage1.py +1959 -0
- oceanarray/stage2.py +1160 -0
- oceanarray/stage3.py +2494 -0
- oceanarray/time_gridding.py +958 -0
- oceanarray/tools.py +400 -0
- oceanarray/transports.py +1 -0
- oceanarray/utilities.py +538 -0
- oceanarray/validation.py +389 -0
- oceanarray/writers.py +99 -0
- oceanarray-0.1.0.dist-info/METADATA +503 -0
- oceanarray-0.1.0.dist-info/RECORD +76 -0
- oceanarray-0.1.0.dist-info/WHEEL +5 -0
- oceanarray-0.1.0.dist-info/entry_points.txt +2 -0
- oceanarray-0.1.0.dist-info/licenses/LICENSE +21 -0
- oceanarray-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import xarray as xr
|
|
7
|
+
|
|
8
|
+
from oceanarray.logger import log_debug, log_info, log_warning
|
|
9
|
+
|
|
10
|
+
from . import rodb
|
|
11
|
+
|
|
12
|
+
DUMMY_VALUE = -9.99e-29 # adjust if needed
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def middle_percent(values, percent=95):
|
|
16
|
+
"""Return the lower and upper bounds for the central `percent` of the data.
|
|
17
|
+
|
|
18
|
+
Parameters
|
|
19
|
+
----------
|
|
20
|
+
values : array-like
|
|
21
|
+
Input data (1D array). NaNs will be ignored.
|
|
22
|
+
percent : float
|
|
23
|
+
Desired central percentage (e.g., 95 for middle 95%).
|
|
24
|
+
|
|
25
|
+
Returns
|
|
26
|
+
-------
|
|
27
|
+
tuple
|
|
28
|
+
(lower_bound, upper_bound)
|
|
29
|
+
|
|
30
|
+
"""
|
|
31
|
+
values = np.asarray(values)
|
|
32
|
+
values = values[~np.isnan(values)]
|
|
33
|
+
|
|
34
|
+
if not 0 < percent < 100:
|
|
35
|
+
raise ValueError("percent must be between 0 and 100 (exclusive)")
|
|
36
|
+
|
|
37
|
+
tail = (100 - percent) / 2
|
|
38
|
+
lower = np.nanpercentile(values, tail)
|
|
39
|
+
upper = np.nanpercentile(values, 100 - tail)
|
|
40
|
+
return lower, upper
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def mean_of_middle_percent(values, percent=95):
|
|
44
|
+
"""Compute the mean of values within the central `percent` of the data.
|
|
45
|
+
|
|
46
|
+
Parameters
|
|
47
|
+
----------
|
|
48
|
+
values : array-like
|
|
49
|
+
Input data (1D array). NaNs will be ignored.
|
|
50
|
+
percent : float
|
|
51
|
+
Desired central percentage (e.g., 95 for middle 95%).
|
|
52
|
+
|
|
53
|
+
Returns
|
|
54
|
+
-------
|
|
55
|
+
float
|
|
56
|
+
Mean of values within the specified middle percentage.
|
|
57
|
+
|
|
58
|
+
"""
|
|
59
|
+
values = np.asarray(values)
|
|
60
|
+
values = values[~np.isnan(values)]
|
|
61
|
+
lower, upper = middle_percent(values, percent)
|
|
62
|
+
filtered = values[(values >= lower) & (values <= upper)]
|
|
63
|
+
return np.mean(filtered)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def std_of_middle_percent(values, percent=95):
|
|
67
|
+
"""Compute the standard deviation of values within the central `percent` of the data.
|
|
68
|
+
|
|
69
|
+
Parameters
|
|
70
|
+
----------
|
|
71
|
+
values : array-like
|
|
72
|
+
Input data (1D array). NaNs will be ignored.
|
|
73
|
+
percent : float
|
|
74
|
+
Desired central percentage (e.g., 95 for middle 95%).
|
|
75
|
+
|
|
76
|
+
Returns
|
|
77
|
+
-------
|
|
78
|
+
float
|
|
79
|
+
Standard deviation of values within the specified middle percentage.
|
|
80
|
+
|
|
81
|
+
"""
|
|
82
|
+
values = np.asarray(values)
|
|
83
|
+
values = values[~np.isnan(values)]
|
|
84
|
+
lower, upper = middle_percent(values, percent)
|
|
85
|
+
filtered = values[(values >= lower) & (values <= upper)]
|
|
86
|
+
return np.std(filtered)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def normalize_by_middle_percent(values, percent=95):
|
|
90
|
+
"""Normalize a data series by the mean and standard deviation
|
|
91
|
+
of its central `percent` range.
|
|
92
|
+
|
|
93
|
+
Parameters
|
|
94
|
+
----------
|
|
95
|
+
values : array-like
|
|
96
|
+
Input data (1D array). NaNs are ignored.
|
|
97
|
+
percent : float
|
|
98
|
+
Central percentage to define the 'middle' of the distribution (e.g., 95).
|
|
99
|
+
|
|
100
|
+
Returns
|
|
101
|
+
-------
|
|
102
|
+
array
|
|
103
|
+
Normalized array with the same shape as input.
|
|
104
|
+
|
|
105
|
+
"""
|
|
106
|
+
values = np.asarray(values)
|
|
107
|
+
mask = ~np.isnan(values)
|
|
108
|
+
valid_values = values[mask]
|
|
109
|
+
|
|
110
|
+
if valid_values.size == 0:
|
|
111
|
+
return values # return original if all NaNs
|
|
112
|
+
|
|
113
|
+
lower, upper = middle_percent(valid_values, percent)
|
|
114
|
+
middle_vals = valid_values[(valid_values >= lower) & (valid_values <= upper)]
|
|
115
|
+
|
|
116
|
+
mean_mid = np.mean(middle_vals)
|
|
117
|
+
std_mid = np.std(middle_vals)
|
|
118
|
+
|
|
119
|
+
if std_mid == 0:
|
|
120
|
+
raise ValueError(
|
|
121
|
+
"Standard deviation of middle percent is zero — normalization not possible."
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
normalized = (values - mean_mid) / std_mid
|
|
125
|
+
return normalized
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def normalize_dataset_by_middle_percent(ds, percent=95):
|
|
129
|
+
"""Normalize all 1D data variables in an xarray Dataset that match the length of TIME,
|
|
130
|
+
using the mean and std over the central `percent` of each variable.
|
|
131
|
+
|
|
132
|
+
Parameters
|
|
133
|
+
----------
|
|
134
|
+
ds : xarray.Dataset
|
|
135
|
+
Input dataset with a 'TIME' coordinate.
|
|
136
|
+
percent : float
|
|
137
|
+
Percentage of central values to define the middle (e.g., 95 for middle 95%).
|
|
138
|
+
|
|
139
|
+
Returns
|
|
140
|
+
-------
|
|
141
|
+
xarray.Dataset
|
|
142
|
+
New dataset with normalized data variables.
|
|
143
|
+
|
|
144
|
+
"""
|
|
145
|
+
ds_norm = xr.Dataset(attrs=ds.attrs)
|
|
146
|
+
time_shape = ds["TIME"].shape
|
|
147
|
+
|
|
148
|
+
for var in ds.data_vars:
|
|
149
|
+
if ds[var].shape == time_shape:
|
|
150
|
+
norm_values = normalize_by_middle_percent(ds[var].values, percent)
|
|
151
|
+
ds_norm[var] = xr.DataArray(
|
|
152
|
+
norm_values,
|
|
153
|
+
coords=ds[var].coords,
|
|
154
|
+
dims=ds[var].dims,
|
|
155
|
+
attrs=ds[var].attrs,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# Retain TIME coordinate
|
|
159
|
+
ds_norm = ds_norm.assign_coords({"TIME": ds["TIME"]})
|
|
160
|
+
return ds_norm
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def trim_suggestion(ds, percent=95, threshold=6, vars_to_check=["T", "C", "P"]):
|
|
164
|
+
"""Normalize dataset variables using the middle percentile and determine suggested
|
|
165
|
+
deployment start and end times where the normalized values are below a given threshold.
|
|
166
|
+
|
|
167
|
+
Parameters
|
|
168
|
+
----------
|
|
169
|
+
ds : xarray.Dataset
|
|
170
|
+
Input dataset with a 'TIME' coordinate and 1D data variables.
|
|
171
|
+
percent : float
|
|
172
|
+
Percentage for middle-percent normalization (e.g., 95).
|
|
173
|
+
threshold : float
|
|
174
|
+
Absolute threshold on normalized values to consider as stable.
|
|
175
|
+
vars_to_check : list of str
|
|
176
|
+
List of variable names to consider for start/end detection.
|
|
177
|
+
|
|
178
|
+
Returns
|
|
179
|
+
-------
|
|
180
|
+
start_time : np.datetime64 or None
|
|
181
|
+
Suggested deployment start time.
|
|
182
|
+
end_time : np.datetime64 or None
|
|
183
|
+
Suggested deployment end time.
|
|
184
|
+
|
|
185
|
+
"""
|
|
186
|
+
ds_norm = normalize_dataset_by_middle_percent(ds, percent=percent)
|
|
187
|
+
|
|
188
|
+
start_candidates = []
|
|
189
|
+
end_candidates = []
|
|
190
|
+
|
|
191
|
+
for var in vars_to_check:
|
|
192
|
+
if var not in ds_norm:
|
|
193
|
+
continue
|
|
194
|
+
|
|
195
|
+
arr = np.abs(ds_norm[var].values)
|
|
196
|
+
time_vals = ds_norm["TIME"].values
|
|
197
|
+
|
|
198
|
+
# First time index where normalized value drops below threshold
|
|
199
|
+
below_thresh = np.where(arr < threshold)[0]
|
|
200
|
+
if len(below_thresh) > 0:
|
|
201
|
+
first_below = time_vals[below_thresh[0]]
|
|
202
|
+
last_below = time_vals[below_thresh[-1]]
|
|
203
|
+
start_candidates.append(first_below)
|
|
204
|
+
end_candidates.append(last_below)
|
|
205
|
+
else:
|
|
206
|
+
print(f"Warning: {var} never drops below threshold {threshold}")
|
|
207
|
+
|
|
208
|
+
if not start_candidates or not end_candidates:
|
|
209
|
+
print("Could not determine suggested deployment period.")
|
|
210
|
+
return None, None
|
|
211
|
+
|
|
212
|
+
start_time = max(start_candidates)
|
|
213
|
+
end_time = min(end_candidates)
|
|
214
|
+
|
|
215
|
+
print(f"Suggested deployment start: {start_time}")
|
|
216
|
+
print(f"Suggested deployment end: {end_time}")
|
|
217
|
+
return start_time, end_time
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def stage2_trim(
|
|
221
|
+
ds: xr.Dataset,
|
|
222
|
+
deployment_start: datetime = None,
|
|
223
|
+
deployment_end: datetime = None,
|
|
224
|
+
) -> xr.Dataset:
|
|
225
|
+
"""Trim dataset to deployment period and fill time gaps with NaN.
|
|
226
|
+
|
|
227
|
+
Parameters
|
|
228
|
+
----------
|
|
229
|
+
ds : xarray.Dataset
|
|
230
|
+
Input dataset with 'TIME' coordinate and variables like 'TEMP', 'CNDC', optionally 'PRES'.
|
|
231
|
+
deployment_start : datetime, optional
|
|
232
|
+
Start of the valid deployment period. If None, uses first timestamp in ds.
|
|
233
|
+
deployment_end : datetime, optional
|
|
234
|
+
End of the valid deployment period. If None, uses last timestamp in ds.
|
|
235
|
+
|
|
236
|
+
Returns
|
|
237
|
+
-------
|
|
238
|
+
xr.Dataset
|
|
239
|
+
Trimmed and gap-filled dataset.
|
|
240
|
+
|
|
241
|
+
"""
|
|
242
|
+
if "TIME" not in ds:
|
|
243
|
+
raise ValueError("Dataset must contain 'TIME' coordinate")
|
|
244
|
+
|
|
245
|
+
if deployment_start is None:
|
|
246
|
+
deployment_start = ds.TIME.values[0]
|
|
247
|
+
if deployment_end is None:
|
|
248
|
+
deployment_end = ds.TIME.values[-1]
|
|
249
|
+
|
|
250
|
+
log_info(
|
|
251
|
+
"Trimming dataset to deployment period %s – %s",
|
|
252
|
+
deployment_start,
|
|
253
|
+
deployment_end,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
# Trim to deployment period
|
|
257
|
+
ds_trimmed = ds.sel(TIME=slice(deployment_start, deployment_end))
|
|
258
|
+
|
|
259
|
+
# Infer sampling interval
|
|
260
|
+
time_diff = np.diff(ds_trimmed.TIME.values).astype("timedelta64[s]").astype(int)
|
|
261
|
+
if len(time_diff) == 0:
|
|
262
|
+
log_warning("Only one sample in dataset — no trimming or gap filling possible.")
|
|
263
|
+
return ds_trimmed
|
|
264
|
+
|
|
265
|
+
dt_seconds = int(np.median(time_diff))
|
|
266
|
+
sampling_interval = np.timedelta64(dt_seconds, "s")
|
|
267
|
+
log_debug("Inferred sampling interval: %d seconds", dt_seconds)
|
|
268
|
+
|
|
269
|
+
# Create regular time axis and reindex
|
|
270
|
+
time_start = ds_trimmed.TIME.values[0]
|
|
271
|
+
time_end = ds_trimmed.TIME.values[-1]
|
|
272
|
+
full_time = np.arange(time_start, time_end + sampling_interval, sampling_interval)
|
|
273
|
+
|
|
274
|
+
log_info("Interpolating to regular time base with %d steps", len(full_time))
|
|
275
|
+
ds_uniform = ds_trimmed.reindex(TIME=full_time, method=None)
|
|
276
|
+
|
|
277
|
+
# Log stats
|
|
278
|
+
for var in ["TEMP", "CNDC", "PRES"]:
|
|
279
|
+
if var in ds_uniform:
|
|
280
|
+
data = ds_uniform[var]
|
|
281
|
+
valid = np.isfinite(data.values)
|
|
282
|
+
log_info(
|
|
283
|
+
"%s: %d samples, mean = %.3f, std = %.3f, min = %.3f, max = %.3f",
|
|
284
|
+
var,
|
|
285
|
+
valid.sum(),
|
|
286
|
+
np.nanmean(data),
|
|
287
|
+
np.nanstd(data),
|
|
288
|
+
np.nanmin(data),
|
|
289
|
+
np.nanmax(data),
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
return ds_uniform, deployment_start, deployment_end
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def apply_microcat_calibration_from_txt(txt_file: str, use_file: str) -> xr.Dataset:
|
|
296
|
+
"""Apply calibration offsets from a *.microcat.txt file to the original .use data file.
|
|
297
|
+
|
|
298
|
+
Parameters
|
|
299
|
+
----------
|
|
300
|
+
txt_file : str
|
|
301
|
+
Path to the calibration log file (e.g., 'wb1_12_2015_005.microcat').
|
|
302
|
+
use_file : str
|
|
303
|
+
Path to the original '.use' file.
|
|
304
|
+
|
|
305
|
+
Returns
|
|
306
|
+
-------
|
|
307
|
+
ds : xr.Dataset
|
|
308
|
+
Dataset with calibrated temperature, conductivity, and pressure.
|
|
309
|
+
|
|
310
|
+
"""
|
|
311
|
+
txt_file = Path(txt_file)
|
|
312
|
+
use_file = Path(use_file)
|
|
313
|
+
|
|
314
|
+
with open(txt_file, "r") as f:
|
|
315
|
+
text = f.read()
|
|
316
|
+
|
|
317
|
+
def get_offsets(variable):
|
|
318
|
+
pattern = rf"{variable}.*?:\s+([-0-9.]+)\s+([-0-9.]+)"
|
|
319
|
+
match = re.search(pattern, text)
|
|
320
|
+
if match:
|
|
321
|
+
return float(match.group(1)), float(match.group(2))
|
|
322
|
+
return None, None
|
|
323
|
+
|
|
324
|
+
def is_applied(var):
|
|
325
|
+
match = re.search(rf"Average {var} applied\?\s+([yn])", text, re.IGNORECASE)
|
|
326
|
+
return match and match.group(1).lower() == "y"
|
|
327
|
+
|
|
328
|
+
# Extract offsets and flags
|
|
329
|
+
c_pre, c_post = get_offsets("Conductivity")
|
|
330
|
+
t_pre, t_post = get_offsets("Temperature")
|
|
331
|
+
p_pre, p_post = get_offsets("Pressure")
|
|
332
|
+
|
|
333
|
+
apply_c = is_applied("conductivity")
|
|
334
|
+
apply_t = is_applied("temperature")
|
|
335
|
+
apply_p = is_applied("pressure")
|
|
336
|
+
|
|
337
|
+
# Load data using rodbload
|
|
338
|
+
ds = rodb.rodbload(use_file)
|
|
339
|
+
|
|
340
|
+
# Rename to standard names
|
|
341
|
+
# rename_map = {"T": "TEMP", "C": "CNDC", "P": "PRES"}
|
|
342
|
+
# ds = ds.rename({k: v for k, v in rename_map.items() if k in ds})
|
|
343
|
+
|
|
344
|
+
def apply_avg_offset(var, pre, post, flag):
|
|
345
|
+
if var not in ds or not flag or pre is None or post is None:
|
|
346
|
+
return ds.get(var, None)
|
|
347
|
+
offset = (pre + post) / 2
|
|
348
|
+
log_info(f"Applying average offset {offset:+.4f} to {var}")
|
|
349
|
+
return ds[var] + offset
|
|
350
|
+
|
|
351
|
+
# Apply calibration offsets
|
|
352
|
+
for var, pre, post, flag in [
|
|
353
|
+
("T", t_pre, t_post, apply_t),
|
|
354
|
+
("C", c_pre, c_post, apply_c),
|
|
355
|
+
("P", p_pre, p_post, apply_p),
|
|
356
|
+
]:
|
|
357
|
+
if var in ds:
|
|
358
|
+
ds[var] = apply_avg_offset(var, pre, post, flag)
|
|
359
|
+
|
|
360
|
+
# Replace dummy values
|
|
361
|
+
for var in ["T", "C", "P"]:
|
|
362
|
+
if var in ds:
|
|
363
|
+
ds[var] = ds[var].where(ds[var] != DUMMY_VALUE, np.nan)
|
|
364
|
+
|
|
365
|
+
ds.attrs["source_file"] = str(use_file)
|
|
366
|
+
ds.attrs["calibration_log"] = str(txt_file)
|
|
367
|
+
ds.attrs["comment"] = "Calibration offsets applied from microcat log file."
|
|
368
|
+
|
|
369
|
+
return ds
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""rodbhead.py - Decode RO database keywords.
|
|
2
|
+
|
|
3
|
+
Originally written in MATLAB by:
|
|
4
|
+
- G. Krahmann, IfM Kiel, Oct 1995 (v0.1.0)
|
|
5
|
+
- Updated for EPIC conventions: Feb 1996 (v0.1.1)
|
|
6
|
+
- Optimized and extended by multiple contributors through 2005
|
|
7
|
+
- Last change noted in MATLAB: C. Mertens, May 1997 (v0.1.4)
|
|
8
|
+
- Extensive extensions by D. Kieke (2000) and T. Kanzow (2005)
|
|
9
|
+
|
|
10
|
+
Ported to Python by E Frajka-Williams, 2025
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
import xarray as xr
|
|
19
|
+
import yaml
|
|
20
|
+
|
|
21
|
+
from oceanarray.logger import log_warning
|
|
22
|
+
|
|
23
|
+
from .convertOS import parse_rodb_metadata
|
|
24
|
+
|
|
25
|
+
REVERSE_KEYS = {
|
|
26
|
+
"mooring": "Mooring",
|
|
27
|
+
"serial_number": "SerialNumber",
|
|
28
|
+
"water_depth": "WaterDepth",
|
|
29
|
+
"instrdepth": "InstrDepth", # if present in attrs
|
|
30
|
+
"latitude": "Latitude", # if present in attrs
|
|
31
|
+
"longitude": "Longitude", # if present in attrs
|
|
32
|
+
"start_time": ["Start_Date", "Start_Time"], # split into date + time
|
|
33
|
+
"end_time": ["End_Date", "End_Time"], # split into date + time
|
|
34
|
+
"columns": "Columns",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# in rodb.py
|
|
39
|
+
def is_rodb_file(filepath: Path) -> bool:
|
|
40
|
+
"""Check whether a file is RODB-style based on filename and/or content."""
|
|
41
|
+
return filepath.suffix.lower() in {".raw", ".use", ".microcat"}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse_rodb_keys_file(filepath):
|
|
45
|
+
"""Parse a rodb_keys.txt file with MATLAB-style lines into structured dicts.
|
|
46
|
+
|
|
47
|
+
Returns a dictionary with a list of entries under the 'RODB_KEYS' key.
|
|
48
|
+
"""
|
|
49
|
+
entries = []
|
|
50
|
+
pattern = re.compile(r"^\s*'(?P<line>[^']+)';\s*\.\.\.\s*%?\s*(?P<comment>.*)?")
|
|
51
|
+
|
|
52
|
+
with open(filepath, "r") as f:
|
|
53
|
+
for line in f:
|
|
54
|
+
line = line.strip()
|
|
55
|
+
if not line.startswith("'"):
|
|
56
|
+
continue
|
|
57
|
+
|
|
58
|
+
match = pattern.match(line)
|
|
59
|
+
if not match:
|
|
60
|
+
continue
|
|
61
|
+
# Extract the line content and optional comment
|
|
62
|
+
print(line)
|
|
63
|
+
raw_fields = match.group("line").split()
|
|
64
|
+
if len(raw_fields) < 4:
|
|
65
|
+
continue # not enough fields
|
|
66
|
+
|
|
67
|
+
key = raw_fields[0]
|
|
68
|
+
fmt = raw_fields[1]
|
|
69
|
+
count = int(raw_fields[2])
|
|
70
|
+
code = int(raw_fields[3])
|
|
71
|
+
comment = match.group("comment").strip() if match.group("comment") else None
|
|
72
|
+
|
|
73
|
+
entry = {
|
|
74
|
+
"key": key,
|
|
75
|
+
"format": fmt,
|
|
76
|
+
"count": count,
|
|
77
|
+
"code": code,
|
|
78
|
+
}
|
|
79
|
+
if comment:
|
|
80
|
+
entry["comment"] = comment
|
|
81
|
+
|
|
82
|
+
entries.append(entry)
|
|
83
|
+
|
|
84
|
+
return {"RODB_KEYS": entries}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# Example usage
|
|
88
|
+
if __name__ == "__main__":
|
|
89
|
+
infile = Path("rodb_keys.txt") # Replace with your actual file path
|
|
90
|
+
rodb_keys_data = parse_rodb_keys_file(infile)
|
|
91
|
+
|
|
92
|
+
with open("rodb_keys.yaml", "w") as outfile:
|
|
93
|
+
yaml.dump(rodb_keys_data, outfile, sort_keys=False)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# Load full RODB key metadata from YAML file
|
|
97
|
+
RODB_KEYS_PATH = Path(__file__).parent.parent / "config" / "legacy" / "rodb_keys.yaml"
|
|
98
|
+
with open(RODB_KEYS_PATH, "r") as f:
|
|
99
|
+
RODB_KEYS = yaml.safe_load(f)
|
|
100
|
+
|
|
101
|
+
# Dummy placeholder values that indicate missing data
|
|
102
|
+
DUMMY_VALUES = [-9999, -99.999, -999.999]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def rodbload(filepath, variables: list[str] = None) -> xr.Dataset:
|
|
106
|
+
"""Read a RODB .use or .raw file into an xarray.Dataset."""
|
|
107
|
+
filepath = Path(filepath)
|
|
108
|
+
with open(filepath, "r") as f:
|
|
109
|
+
lines = f.readlines()
|
|
110
|
+
|
|
111
|
+
header, data_start_index = parse_rodb_metadata(filepath)
|
|
112
|
+
|
|
113
|
+
default_dim = "N_MEASUREMENTS"
|
|
114
|
+
|
|
115
|
+
# Determine variables
|
|
116
|
+
var_string = header.get("COLUMNS", "YY:MM:DD:HH:T:C:P")
|
|
117
|
+
file_variables = var_string.split(":")
|
|
118
|
+
if variables is None:
|
|
119
|
+
variables = file_variables
|
|
120
|
+
col_indices = {var: i for i, var in enumerate(file_variables) if var in variables}
|
|
121
|
+
|
|
122
|
+
# Load numeric data
|
|
123
|
+
data = np.genfromtxt(lines[data_start_index:], dtype=float)
|
|
124
|
+
if data.ndim == 1:
|
|
125
|
+
data = data[np.newaxis, :]
|
|
126
|
+
|
|
127
|
+
for dummy in DUMMY_VALUES:
|
|
128
|
+
data[data == dummy] = np.nan
|
|
129
|
+
|
|
130
|
+
coords = {default_dim: np.arange(data.shape[0])}
|
|
131
|
+
data_vars = {var: (default_dim, data[:, col_indices[var]]) for var in variables}
|
|
132
|
+
|
|
133
|
+
if all(var in col_indices for var in ["YY", "MM", "DD", "HH"]):
|
|
134
|
+
y, m, d, h = (data[:, col_indices[v]] for v in ["YY", "MM", "DD", "HH"])
|
|
135
|
+
time = [
|
|
136
|
+
datetime(int(yy), int(mm), int(dd), int(hh), int((hh % 1) * 60))
|
|
137
|
+
for yy, mm, dd, hh in zip(y, m, d, h)
|
|
138
|
+
]
|
|
139
|
+
coords["TIME"] = (default_dim, np.array(time, dtype="datetime64[s]"))
|
|
140
|
+
else:
|
|
141
|
+
# Optional: warn if TIME can't be constructed
|
|
142
|
+
missing = [v for v in ["YY", "MM", "DD", "HH"] if v not in col_indices]
|
|
143
|
+
log_warning("Could not create TIME coordinate. Missing: %s", ", ".join(missing))
|
|
144
|
+
|
|
145
|
+
ds = xr.Dataset(data_vars, coords=coords)
|
|
146
|
+
|
|
147
|
+
# Now convert header fields into attributes and variables
|
|
148
|
+
attrs = {}
|
|
149
|
+
|
|
150
|
+
# Global metadata
|
|
151
|
+
attrs["mooring"] = header.get("MOORING")
|
|
152
|
+
attrs["serial_number"] = header.get("SERIALNUMBER")
|
|
153
|
+
attrs["water_depth"] = int(header["WATERDEPTH"]) if "WATERDEPTH" in header else None
|
|
154
|
+
|
|
155
|
+
# Combine start and end date+time into ISO strings
|
|
156
|
+
if "START_DATE" in header and "START_TIME" in header:
|
|
157
|
+
attrs["start_time"] = f"{header['START_DATE']}T{header['START_TIME']}"
|
|
158
|
+
if "END_DATE" in header and "END_TIME" in header:
|
|
159
|
+
attrs["end_time"] = f"{header['END_DATE']}T{header['END_TIME']}"
|
|
160
|
+
|
|
161
|
+
# Convert InstrDepth, Latitude, Longitude to variables
|
|
162
|
+
if "INSTRDEPTH" in header:
|
|
163
|
+
ds["InstrDepth"] = header["INSTRDEPTH"] = float(header["INSTRDEPTH"])
|
|
164
|
+
if "LATITUDE" in header:
|
|
165
|
+
lat_str = header["LATITUDE"]
|
|
166
|
+
ds["Latitude"] = float(lat_str.split()[0]) + float(lat_str.split()[1]) / 60.0
|
|
167
|
+
if "S" in lat_str:
|
|
168
|
+
ds["Latitude"] *= -1
|
|
169
|
+
if "LONGITUDE" in header:
|
|
170
|
+
lon_str = header["LONGITUDE"]
|
|
171
|
+
ds["Longitude"] = float(lon_str.split()[0]) + float(lon_str.split()[1]) / 60.0
|
|
172
|
+
if "W" in lon_str:
|
|
173
|
+
ds["Longitude"] *= -1
|
|
174
|
+
|
|
175
|
+
# Add file reference and inferred columns
|
|
176
|
+
attrs["source_file"] = str(filepath)
|
|
177
|
+
attrs["columns"] = list(data_vars.keys())
|
|
178
|
+
# Promote TIME to an index if available
|
|
179
|
+
if "TIME" in ds.coords:
|
|
180
|
+
ds = ds.swap_dims({default_dim: "TIME"})
|
|
181
|
+
|
|
182
|
+
ds.attrs.update({k: v for k, v in attrs.items() if v is not None})
|
|
183
|
+
return ds
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
NOMINAL_HEADER_ORDER = [
|
|
187
|
+
"Mooring",
|
|
188
|
+
"SerialNumber",
|
|
189
|
+
"WaterDepth",
|
|
190
|
+
"InstrDepth",
|
|
191
|
+
"Start_Date",
|
|
192
|
+
"Start_Time",
|
|
193
|
+
"End_Date",
|
|
194
|
+
"End_Time",
|
|
195
|
+
"Latitude",
|
|
196
|
+
"Longitude",
|
|
197
|
+
"Columns",
|
|
198
|
+
]
|
|
199
|
+
|
|
200
|
+
REVERSE_ATTR_KEYS = {
|
|
201
|
+
"Mooring": "mooring",
|
|
202
|
+
"SerialNumber": "serial_number",
|
|
203
|
+
"WaterDepth": "water_depth",
|
|
204
|
+
"InstrDepth": "instrdepth",
|
|
205
|
+
"Start_Date": "start_time",
|
|
206
|
+
"Start_Time": "start_time",
|
|
207
|
+
"End_Date": "end_time",
|
|
208
|
+
"End_Time": "end_time",
|
|
209
|
+
"Latitude": "latitude",
|
|
210
|
+
"Longitude": "longitude",
|
|
211
|
+
"Columns": "columns",
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def format_latlon(value, is_lat=True):
|
|
216
|
+
deg = int(abs(value))
|
|
217
|
+
minutes = (abs(value) - deg) * 60
|
|
218
|
+
width = 2 if is_lat else 3
|
|
219
|
+
hemi = ("N" if value >= 0 else "S") if is_lat else ("E" if value >= 0 else "W")
|
|
220
|
+
return f"{deg:0{width}d} {minutes:06.3f} {hemi}"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def rodbsave(filepath, ds: xr.Dataset, fmt=None):
|
|
224
|
+
"""Save an xarray.Dataset to a RODB-style .use or .raw file.
|
|
225
|
+
|
|
226
|
+
Parameters
|
|
227
|
+
----------
|
|
228
|
+
filepath : str or Path
|
|
229
|
+
Output file path.
|
|
230
|
+
ds : xarray.Dataset
|
|
231
|
+
Dataset to write. Expects time-series variables to be indexed by 'obs'.
|
|
232
|
+
fmt : str, optional
|
|
233
|
+
Format string passed to np.savetxt. If None, one is generated automatically.
|
|
234
|
+
|
|
235
|
+
"""
|
|
236
|
+
filepath = Path(filepath)
|
|
237
|
+
attrs = ds.attrs.copy()
|
|
238
|
+
|
|
239
|
+
# Select time-series variables
|
|
240
|
+
timeseries_vars = attrs.get("columns")
|
|
241
|
+
if timeseries_vars is None:
|
|
242
|
+
timeseries_vars = [v for v in ds.data_vars if "obs" in ds[v].dims]
|
|
243
|
+
|
|
244
|
+
if fmt is None:
|
|
245
|
+
default_fmts = {
|
|
246
|
+
"YY": "%4d",
|
|
247
|
+
"MM": "%4d",
|
|
248
|
+
"DD": "%4d",
|
|
249
|
+
"HH": "%10.5f",
|
|
250
|
+
"T": "%9.4f",
|
|
251
|
+
"C": "%9.4f",
|
|
252
|
+
"P": "%7.1f",
|
|
253
|
+
}
|
|
254
|
+
fmt_parts = []
|
|
255
|
+
for var in timeseries_vars:
|
|
256
|
+
fmt_parts.append(default_fmts.get(var, "%10.4f"))
|
|
257
|
+
fmt = " ".join(fmt_parts)
|
|
258
|
+
written_keys = set()
|
|
259
|
+
|
|
260
|
+
with open(filepath, "w", newline="") as f:
|
|
261
|
+
for key in NOMINAL_HEADER_ORDER:
|
|
262
|
+
attr_key = REVERSE_ATTR_KEYS.get(key)
|
|
263
|
+
|
|
264
|
+
if (
|
|
265
|
+
key in ["Start_Date", "Start_Time"]
|
|
266
|
+
and "start_time" in attrs
|
|
267
|
+
and "Start_Date" not in written_keys
|
|
268
|
+
):
|
|
269
|
+
try:
|
|
270
|
+
dt = datetime.fromisoformat(attrs["start_time"])
|
|
271
|
+
except ValueError:
|
|
272
|
+
dt = datetime.strptime(attrs["start_time"], "%Y/%m/%dT%H:%M")
|
|
273
|
+
f.write(f"{'Start_Date':<22} = {dt.strftime('%Y/%m/%d')}\n")
|
|
274
|
+
f.write(f"{'Start_Time':<22} = {dt.strftime('%H:%M')}\n")
|
|
275
|
+
written_keys.update(["Start_Date", "Start_Time"])
|
|
276
|
+
continue
|
|
277
|
+
|
|
278
|
+
if (
|
|
279
|
+
key in ["End_Date", "End_Time"]
|
|
280
|
+
and "end_time" in attrs
|
|
281
|
+
and "End_Date" not in written_keys
|
|
282
|
+
):
|
|
283
|
+
try:
|
|
284
|
+
dt = datetime.fromisoformat(attrs["end_time"])
|
|
285
|
+
except ValueError:
|
|
286
|
+
dt = datetime.strptime(attrs["end_time"], "%Y/%m/%dT%H:%M")
|
|
287
|
+
f.write(f"{'End_Date':<22} = {dt.strftime('%Y/%m/%d')}\n")
|
|
288
|
+
f.write(f"{'End_Time':<22} = {dt.strftime('%H:%M')}\n")
|
|
289
|
+
written_keys.update(["End_Date", "End_Time"])
|
|
290
|
+
continue
|
|
291
|
+
|
|
292
|
+
if key == "Latitude" and "Latitude" not in written_keys:
|
|
293
|
+
val = ds.get("Latitude", None)
|
|
294
|
+
if val is not None:
|
|
295
|
+
f.write(
|
|
296
|
+
f"{'Latitude':<22} = {format_latlon(val.values.item(), is_lat=True)}\n"
|
|
297
|
+
)
|
|
298
|
+
written_keys.add("Latitude")
|
|
299
|
+
continue
|
|
300
|
+
|
|
301
|
+
if key == "Longitude" and "Longitude" not in written_keys:
|
|
302
|
+
val = ds.get("Longitude", None)
|
|
303
|
+
if val is not None:
|
|
304
|
+
f.write(
|
|
305
|
+
f"{'Longitude':<22} = {format_latlon(val.values.item(), is_lat=False)}\n"
|
|
306
|
+
)
|
|
307
|
+
written_keys.add("Longitude")
|
|
308
|
+
continue
|
|
309
|
+
|
|
310
|
+
if key == "Columns" and "Columns" not in written_keys:
|
|
311
|
+
f.write(f"{'Columns':<22} = " + ":".join(timeseries_vars) + "\n")
|
|
312
|
+
written_keys.add("Columns")
|
|
313
|
+
continue
|
|
314
|
+
|
|
315
|
+
if attr_key in attrs and key not in written_keys:
|
|
316
|
+
f.write(f"{key:<22} = {attrs[attr_key]}\n")
|
|
317
|
+
written_keys.add(key)
|
|
318
|
+
|
|
319
|
+
elif (
|
|
320
|
+
key in ds
|
|
321
|
+
and key not in written_keys
|
|
322
|
+
and (np.isscalar(ds[key].values) or ds[key].size == 1)
|
|
323
|
+
):
|
|
324
|
+
val = ds[key].values.item()
|
|
325
|
+
f.write(f"{key:<22} = {val:.0f}\n")
|
|
326
|
+
written_keys.add(key)
|
|
327
|
+
|
|
328
|
+
# Write the data block
|
|
329
|
+
data_array = np.column_stack([ds[var].values for var in timeseries_vars])
|
|
330
|
+
np.savetxt(f, data_array, fmt=fmt)
|