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.
Files changed (76) hide show
  1. oceanarray/__init__.py +20 -0
  2. oceanarray/_version.py +1 -0
  3. oceanarray/cli.py +1343 -0
  4. oceanarray/clock_offset.py +675 -0
  5. oceanarray/config/OS1_sensor_attrs.yaml +49 -0
  6. oceanarray/config/OS1_var_names.yaml +5 -0
  7. oceanarray/config/OS1_vocab_attrs.yaml +84 -0
  8. oceanarray/config/legacy/project_RAPID.yaml +69 -0
  9. oceanarray/config/legacy/rodb_keys.txt +206 -0
  10. oceanarray/config/legacy/rodb_keys.yaml +1012 -0
  11. oceanarray/config/logging.yaml +28 -0
  12. oceanarray/legacy/__init__.py +47 -0
  13. oceanarray/legacy/caldip.py +753 -0
  14. oceanarray/legacy/convertOS.py +527 -0
  15. oceanarray/legacy/find_deployment.py +489 -0
  16. oceanarray/legacy/mooring_rodb.py +429 -0
  17. oceanarray/legacy/process_rodb.py +369 -0
  18. oceanarray/legacy/rodb.py +330 -0
  19. oceanarray/logger.py +192 -0
  20. oceanarray/logsheet/__init__.py +60 -0
  21. oceanarray/logsheet/_columns.py +97 -0
  22. oceanarray/logsheet/_config.py +245 -0
  23. oceanarray/logsheet/_firmware.py +43 -0
  24. oceanarray/logsheet/_latex.py +128 -0
  25. oceanarray/logsheet/_render.py +126 -0
  26. oceanarray/logsheet/builders/__init__.py +13 -0
  27. oceanarray/logsheet/builders/caldip_download.py +195 -0
  28. oceanarray/logsheet/builders/caldip_setup.py +188 -0
  29. oceanarray/logsheet/builders/deployment_setup.py +218 -0
  30. oceanarray/logsheet/builders/mooring_download.py +243 -0
  31. oceanarray/logsheet/builders/recovery.py +157 -0
  32. oceanarray/logsheet/data/column_defs.yaml +686 -0
  33. oceanarray/logsheet/data/latex_templates/_base_instrument.tex.j2 +42 -0
  34. oceanarray/logsheet/data/latex_templates/caldip_download.tex.j2 +47 -0
  35. oceanarray/logsheet/data/latex_templates/caldip_setup.tex.j2 +70 -0
  36. oceanarray/logsheet/data/latex_templates/deployment_setup.tex.j2 +97 -0
  37. oceanarray/logsheet/data/latex_templates/mooring_download.tex.j2 +54 -0
  38. oceanarray/logsheet/data/latex_templates/recovery.tex.j2 +103 -0
  39. oceanarray/mooring_level.py +1368 -0
  40. oceanarray/oceanarray.mplstyle +18 -0
  41. oceanarray/parameters.py +270 -0
  42. oceanarray/plotter.py +942 -0
  43. oceanarray/plotters/__init__.py +97 -0
  44. oceanarray/plotters/_animation.py +261 -0
  45. oceanarray/plotters/_current.py +705 -0
  46. oceanarray/plotters/_diagnostic.py +667 -0
  47. oceanarray/plotters/_helpers.py +57 -0
  48. oceanarray/plotters/_primitives.py +101 -0
  49. oceanarray/plotters/_section.py +16 -0
  50. oceanarray/plotters/_timeseries.py +11 -0
  51. oceanarray/rapid_interp.py +739 -0
  52. oceanarray/read_rbr_hex.py +541 -0
  53. oceanarray/readers.py +303 -0
  54. oceanarray/report/__init__.py +5 -0
  55. oceanarray/report/_array.py +386 -0
  56. oceanarray/report/_grid.py +539 -0
  57. oceanarray/report/_html_helpers.py +1004 -0
  58. oceanarray/report/_instrument.py +853 -0
  59. oceanarray/report/_mooring.py +1385 -0
  60. oceanarray/report/_plots.py +4284 -0
  61. oceanarray/report/_stack.py +941 -0
  62. oceanarray/stage1.py +1959 -0
  63. oceanarray/stage2.py +1160 -0
  64. oceanarray/stage3.py +2494 -0
  65. oceanarray/time_gridding.py +958 -0
  66. oceanarray/tools.py +400 -0
  67. oceanarray/transports.py +1 -0
  68. oceanarray/utilities.py +538 -0
  69. oceanarray/validation.py +389 -0
  70. oceanarray/writers.py +99 -0
  71. oceanarray-0.1.0.dist-info/METADATA +503 -0
  72. oceanarray-0.1.0.dist-info/RECORD +76 -0
  73. oceanarray-0.1.0.dist-info/WHEEL +5 -0
  74. oceanarray-0.1.0.dist-info/entry_points.txt +2 -0
  75. oceanarray-0.1.0.dist-info/licenses/LICENSE +21 -0
  76. 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)