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,527 @@
1
+ import datetime
2
+ from pathlib import Path
3
+ from typing import Optional, Union
4
+
5
+ import numpy as np
6
+ import xarray as xr
7
+ import yaml
8
+
9
+ from .. import utilities # for any shared helpers like date parsing
10
+ from ..utilities import iso8601_duration_from_seconds # or wherever you store it
11
+
12
+
13
+ def convert_rodb_to_oceansites(
14
+ ds: xr.Dataset,
15
+ metadata_txt: Union[str, Path],
16
+ var_map_yaml: Union[str, Path],
17
+ vocab_yaml: Union[str, Path],
18
+ sensor_yaml: Optional[Union[str, Path]] = None,
19
+ project_yaml: Optional[Union[str, Path]] = None,
20
+ ) -> xr.Dataset:
21
+ """Convert a dataset loaded from RODB format into OceanSITES-compliant format.
22
+
23
+ Parameters
24
+ ----------
25
+ ds : xarray.Dataset
26
+ Original dataset with RODB-style variables and TIME coordinate.
27
+ metadata_txt : str or Path
28
+ Path to RODB metadata text file (header block with key=value lines).
29
+ var_map_yaml : str or Path
30
+ YAML file mapping original variable names to OceanSITES names.
31
+ vocab_yaml : str or Path
32
+ YAML file providing OceanSITES variable attributes.
33
+ sensor_yaml : str or Path, optional
34
+ YAML file with instrument metadata and variable-instrument mapping.
35
+ project_yaml : str or Path, optional
36
+ YAML file with project-level global attributes.
37
+
38
+ Returns
39
+ -------
40
+ xarray.Dataset
41
+ OceanSITES-compliant dataset.
42
+
43
+ """
44
+ metadata, _ = parse_rodb_metadata(metadata_txt)
45
+ var_map = load_yaml(var_map_yaml)
46
+ vocab_attrs = load_yaml(vocab_yaml)
47
+
48
+ # If present, remove variables YY, MM, DD, HH and coordinate N_MEASUREMENTS
49
+ for var in ["YY", "MM", "DD", "HH", "N_MEASUREMENTS"]:
50
+ if var in ds.data_vars:
51
+ ds = ds.drop_vars(var)
52
+ if var in ds.coords:
53
+ ds = ds.drop_vars(var)
54
+
55
+ # Step 1: Rename variables using mapping
56
+ ds = ds.rename(var_map)
57
+
58
+ # Step 2: Add coordinate variables for LATITUDE, LONGITUDE, DEPTH
59
+ ds = add_fixed_coordinates(ds, metadata)
60
+
61
+ # Step 3: Add variable attributes from vocab
62
+ ds = add_variable_attributes(ds, vocab_attrs)
63
+
64
+ # Step 4: Add global attributes
65
+ ds = add_global_attributes(ds, metadata)
66
+
67
+ # Step 5: Format TIME variable
68
+ ds = format_time_variable(ds)
69
+
70
+ # Step 6: Add instrument metadata (optional)
71
+ if sensor_yaml is not None:
72
+ sensor_dict = load_yaml(sensor_yaml)
73
+ ds = add_instrument_metadata(ds, sensor_dict, metadata)
74
+ else:
75
+ # Fallback: assign default TOOL0018 for all known variables
76
+ serial = metadata.get("SERIALNUMBER", "unknown")
77
+ start = f"{metadata['START_DATE']}T{metadata['START_TIME']}:00Z"
78
+ end = f"{metadata['END_DATE']}T{metadata['END_TIME']}:00Z"
79
+ sensor_dict = {
80
+ "instrument_1": {
81
+ "sensor_model": "SBE 37 MicroCat SMP-CT",
82
+ "sensor_manufacturer": "Sea-Bird",
83
+ "sensor_serial_number": serial,
84
+ "sensor_SeaVoX_L22_code": "SDN:L22::TOOL0018",
85
+ "sensor_reference": "https://www.seabird.com/sbe37",
86
+ "sensor_data_start_date": start,
87
+ "sensor_data_end_date": end,
88
+ "comment": "Default instrument (SMP-CT) assigned by fallback.",
89
+ },
90
+ "variables": {
91
+ var: "instrument_1" for var in ds.data_vars if var not in ds.coords
92
+ },
93
+ }
94
+ ds = add_instrument_metadata(ds, sensor_dict, metadata)
95
+
96
+ # Step 7: Add project attributes (optional)
97
+ project_attrs = load_yaml(project_yaml) if project_yaml is not None else {}
98
+ ds = add_project_attributes(ds, project_attrs, metadata)
99
+
100
+ # Rename "mooring" attribute to "internal_mooring_identifier" if present
101
+ if "mooring" in ds.attrs:
102
+ ds.attrs["internal_identifier"] = ds.attrs.pop("mooring")
103
+ ds = sort_global_attributes(ds)
104
+
105
+ lat = float(ds["LATITUDE"].values.flat[0])
106
+ lon = float(ds["LONGITUDE"].values.flat[0])
107
+
108
+ ds = ds.squeeze(dim=["LATITUDE", "LONGITUDE"], drop=True)
109
+ ds = ds.assign_coords(LATITUDE=("TIME", [lat] * ds.sizes["TIME"]))
110
+ ds = ds.assign_coords(LONGITUDE=("TIME", [lon] * ds.sizes["TIME"]))
111
+
112
+ return ds
113
+
114
+
115
+ def load_yaml(path):
116
+ with open(path, "r") as f:
117
+ return yaml.safe_load(f)
118
+
119
+
120
+ def parse_rodb_metadata(txt_path):
121
+ """Parse RODB metadata from a .raw or .use file header.
122
+
123
+ Parameters
124
+ ----------
125
+ txt_path : str or Path
126
+ Path to the RODB file.
127
+
128
+ Returns
129
+ -------
130
+ tuple
131
+ (header_dict, data_start_index)
132
+
133
+ """
134
+ txt_path = Path(txt_path)
135
+ with open(txt_path, "r") as f:
136
+ lines = f.readlines()
137
+
138
+ header = {}
139
+ data_start_index = 0
140
+ for i, line in enumerate(lines):
141
+ if not line.strip():
142
+ continue
143
+ if "=" in line:
144
+ key, val = map(str.strip, line.split("=", 1))
145
+ header[key.upper()] = val
146
+ else:
147
+ data_start_index = i
148
+ break
149
+ return header, data_start_index
150
+
151
+
152
+ def add_fixed_coordinates(ds, metadata):
153
+ """Add fixed spatial coordinates LATITUDE, LONGITUDE, and DEPTH from metadata."""
154
+
155
+ def dms_to_decimal(coord_str):
156
+ parts = coord_str.split()
157
+ deg = float(parts[0])
158
+ min_ = float(parts[1])
159
+ hemi = parts[2].upper()
160
+ decimal = deg + min_ / 60
161
+ if hemi in ["S", "W"]:
162
+ decimal *= -1
163
+ return decimal
164
+
165
+ lat = dms_to_decimal(metadata["LATITUDE"])
166
+ lon = dms_to_decimal(metadata["LONGITUDE"])
167
+ depth = float(metadata["INSTRDEPTH"])
168
+
169
+ ds = ds.expand_dims({"DEPTH": [depth], "LATITUDE": [lat], "LONGITUDE": [lon]})
170
+
171
+ ds = ds.assign_coords(
172
+ {
173
+ "DEPTH": (
174
+ "DEPTH",
175
+ [depth],
176
+ {
177
+ "units": "m",
178
+ "standard_name": "depth",
179
+ "long_name": "Instrument depth",
180
+ "positive": "down",
181
+ "axis": "Z",
182
+ },
183
+ ),
184
+ "LATITUDE": (
185
+ "LATITUDE",
186
+ [lat],
187
+ {
188
+ "units": "degrees_north",
189
+ "standard_name": "latitude",
190
+ "long_name": "Latitude of instrument",
191
+ "axis": "Y",
192
+ },
193
+ ),
194
+ "LONGITUDE": (
195
+ "LONGITUDE",
196
+ [lon],
197
+ {
198
+ "units": "degrees_east",
199
+ "standard_name": "longitude",
200
+ "long_name": "Longitude of instrument",
201
+ "axis": "X",
202
+ },
203
+ ),
204
+ }
205
+ )
206
+
207
+ # Reorder coordinates on all variables to TIME, DEPTH, LATITUDE, LONGITUDE order if present
208
+ coord_order = [
209
+ c for c in ["TIME", "DEPTH", "LATITUDE", "LONGITUDE"] if c in ds.dims
210
+ ]
211
+ for var in ds.data_vars:
212
+ dims = list(ds[var].dims)
213
+ # Only reorder if all coord_order dims are present in variable
214
+ if all(dim in dims for dim in coord_order):
215
+ # Place coord_order dims first, then any remaining dims
216
+ new_order = coord_order + [d for d in dims if d not in coord_order]
217
+ ds[var] = ds[var].transpose(*new_order)
218
+
219
+ return ds
220
+
221
+
222
+ def add_variable_attributes(ds, vocab_attrs):
223
+ for var in ds.data_vars:
224
+ if var in vocab_attrs:
225
+ ds[var].attrs.update(vocab_attrs[var])
226
+ return ds
227
+
228
+
229
+ def add_global_attributes(ds, metadata):
230
+ """Add OceanSITES-compliant global attributes to the dataset."""
231
+ start = f"{metadata['START_DATE']}T{metadata['START_TIME']}:00Z"
232
+ end = f"{metadata['END_DATE']}T{metadata['END_TIME']}:00Z"
233
+ start = start.replace("/", "-")
234
+ end = end.replace("/", "-")
235
+ utilities.is_iso8601_utc(start)
236
+ utilities.is_iso8601_utc(end)
237
+
238
+ lat = metadata["LATITUDE"]
239
+ lon = metadata["LONGITUDE"]
240
+ instr_depth = float(metadata["INSTRDEPTH"])
241
+
242
+ ds.attrs.update(
243
+ {
244
+ "title": f"Time series from {metadata['MOORING']}, instrument {metadata['SERIALNUMBER']}",
245
+ "summary": "CTD mooring time series processed to OceanSITES standards.",
246
+ "Conventions": "CF-1.7, ACDD-1.3, OceanSITES-1.4",
247
+ "deployment_code": metadata["MOORING"],
248
+ "time_coverage_start": start,
249
+ "time_coverage_end": end,
250
+ "geospatial_lat_min": lat,
251
+ "geospatial_lat_max": lat,
252
+ "geospatial_lon_min": lon,
253
+ "geospatial_lon_max": lon,
254
+ "geospatial_vertical_min": instr_depth,
255
+ "geospatial_vertical_max": instr_depth,
256
+ "history": "converted to OceanSITES by convert_rodb_to_oceansites",
257
+ }
258
+ )
259
+
260
+ # remove if in the attributes ["start_time", "end_time"]
261
+ if "start_time" in ds.attrs:
262
+ del ds.attrs["start_time"]
263
+ if "end_time" in ds.attrs:
264
+ del ds.attrs["end_time"]
265
+
266
+ return ds
267
+
268
+
269
+ def format_time_variable(ds):
270
+ # Ensure TIME is CF-compliant: units, calendar, etc.
271
+ ds["TIME"].attrs.update(
272
+ {
273
+ "standard_name": "time",
274
+ "long_name": "Time of measurement",
275
+ "units": "seconds since 1970-01-01T00:00:00Z",
276
+ "calendar": "gregorian",
277
+ "axis": "T",
278
+ }
279
+ )
280
+ return ds
281
+
282
+
283
+ def add_instrument_metadata(ds, sensor_dict, metadata):
284
+ """Add centralized instrument metadata and link variables via the 'instrument' attribute.
285
+
286
+ Parameters
287
+ ----------
288
+ ds : xarray.Dataset
289
+ The dataset to modify.
290
+ sensor_dict : dict
291
+ Dictionary with 'instrument_<N>' keys and/or TOOL### templates, plus 'variables'.
292
+ metadata : dict
293
+ Header metadata to extract instrument-specific fields like serial and dates.
294
+
295
+ """
296
+ variables = sensor_dict.get("variables", {})
297
+ templates = {k: v for k, v in sensor_dict.items() if k.startswith("TOOL")}
298
+
299
+ sensor_type = "CTD" # for RAPID core measurements (TEMP, CNDC, PRES)
300
+ serial = metadata.get("SERIALNUMBER", "unknown")
301
+ inst_depth = float(metadata.get("INSTRDEPTH", 0))
302
+ start = f"{metadata['START_DATE']}T{metadata['START_TIME']}:00Z"
303
+ end = f"{metadata['END_DATE']}T{metadata['END_TIME']}:00Z"
304
+
305
+ for var, _ in variables.items():
306
+ if var in ds.data_vars:
307
+ sensor_name = f"SENSOR_{sensor_type}_{serial}"
308
+ ds[var].attrs["instrument"] = sensor_name
309
+
310
+ sensor_name = f"SENSOR_{sensor_type}_{serial}"
311
+ if sensor_name not in ds.data_vars:
312
+ # Fallback: determine base template
313
+ template_key = "TOOL0018"
314
+ for attr in ds[var].attrs.values():
315
+ if isinstance(attr, str) and attr.startswith("SDN:L22::TOOL"):
316
+ template_key = attr.split("::")[-1]
317
+ break
318
+ base = templates.get(template_key, templates.get("TOOL0018", {})).copy()
319
+
320
+ # Fill dynamic fields
321
+ base.setdefault("sensor_serial_number", serial)
322
+ base.setdefault("sensor_data_start_date", start)
323
+ base.setdefault("sensor_data_end_date", end)
324
+
325
+ # If inst_depth is an integer (i.e., no values smaller than 1), convert to int
326
+ if isinstance(inst_depth, (int, float)) and inst_depth.is_integer():
327
+ inst_depth = int(inst_depth)
328
+
329
+ # Attach as scalar variable with attributes only (no dims, no coords)
330
+ ds[sensor_name] = xr.DataArray(inst_depth, attrs=base)
331
+
332
+ return ds
333
+
334
+
335
+ def infer_data_mode(source_filename=None, history=None):
336
+ """Infer data_mode (D=delayed, R=real-time, P=provisional) from filename or processing history.
337
+
338
+ Parameters
339
+ ----------
340
+ source_filename : str or Path, optional
341
+ Original filename (e.g., 'example.raw', 'data.microcat').
342
+ history : str, optional
343
+ Global attribute describing the file's processing history.
344
+
345
+ Returns
346
+ -------
347
+ str
348
+ One of "D", "R", or "P". Defaults to "D" (delayed mode) if no clear match found.
349
+
350
+ """
351
+ # Priority 1: filename extension
352
+ if source_filename:
353
+ fname = str(source_filename).lower()
354
+ if fname.endswith((".raw", ".use")):
355
+ return "P"
356
+ elif fname.endswith(".microcat"):
357
+ return "D"
358
+
359
+ # Priority 2: clues from history
360
+ if history:
361
+ text = history.lower()
362
+ if "realtime" in text or "real-time" in text:
363
+ return "R"
364
+ elif "prelim" in text:
365
+ return "P"
366
+ elif "delayed" in text:
367
+ return "D"
368
+
369
+ # Fallback
370
+ return "P" # Default to preliminary mode if no clues found
371
+
372
+
373
+ def add_project_attributes(ds, project_attrs=None, metadata=None):
374
+ """Merge project-level attributes into dataset, with derived and dynamic values."""
375
+ if project_attrs is None:
376
+ project_attrs = {}
377
+ if metadata is None:
378
+ metadata = {}
379
+ attrs = project_attrs.copy()
380
+
381
+ # Platform and deployment from mooring name
382
+ mooring = metadata.get("MOORING", "unknown")
383
+ if "_" in mooring:
384
+ platform_code, deployment_code = mooring.split("_", 1)
385
+ else:
386
+ platform_code = mooring
387
+ deployment_code = "unknown"
388
+
389
+ attrs.setdefault("platform_code", platform_code)
390
+ attrs.setdefault("deployment_code", deployment_code)
391
+ attrs.setdefault(
392
+ "title",
393
+ f"Time series from {mooring}, instrument {metadata.get('SERIALNUMBER', '')}",
394
+ )
395
+ attrs.setdefault(
396
+ "summary", "CTD mooring time series processed to OceanSITES standards."
397
+ )
398
+
399
+ # Determine serial and data mode
400
+ serial = metadata.get("SERIALNUMBER", "unknown")
401
+ if "data_mode" not in attrs:
402
+ datamode = infer_data_mode(
403
+ source_filename=ds.attrs.get("source_file", None),
404
+ history=ds.attrs.get("history", None),
405
+ )
406
+ attrs["data_mode"] = datamode
407
+ else:
408
+ datamode = attrs["data_mode"]
409
+
410
+ # Generate unique ID
411
+ attrs["id"] = f"OS_{platform_code}_{deployment_code}_{serial}_{datamode}"
412
+
413
+ # Duration and resolution from TIME coordinate
414
+ time = ds["TIME"].values
415
+ if len(time) > 1:
416
+ duration_days = (time[-1] - time[0]) / np.timedelta64(1, "D")
417
+ mean_spacing = np.median(np.diff(time)) / np.timedelta64(1, "s")
418
+ attrs.setdefault(
419
+ "time_coverage_resolution", iso8601_duration_from_seconds(mean_spacing)
420
+ )
421
+
422
+ # Add date_created / date_modified
423
+ now = datetime.datetime.utcnow().isoformat(timespec="seconds") + "Z"
424
+ if "date_created" not in ds.attrs:
425
+ attrs["date_created"] = now
426
+ else:
427
+ attrs["date_modified"] = now
428
+
429
+ attrs.setdefault("QC_indicator", "unknown")
430
+
431
+ # Merge into dataset attributes
432
+ ds.attrs.update(attrs)
433
+
434
+ return ds
435
+
436
+
437
+ OCEANSITES_ATTR_ORDER = [
438
+ # Identifiers and project-level
439
+ "site_code",
440
+ "platform_code",
441
+ "deployment_code",
442
+ "data_mode",
443
+ "title",
444
+ "theme",
445
+ "summary",
446
+ "naming_authority",
447
+ "id",
448
+ "internal_identifier",
449
+ "wmo_platform_code",
450
+ "source",
451
+ "source_vocabulary",
452
+ "principal_investigator",
453
+ "principal_investigator_email",
454
+ "principal_investigator_id",
455
+ "creator_name",
456
+ "creator_email",
457
+ "creator_url",
458
+ "creator_institution",
459
+ "creator_id",
460
+ "creator_type",
461
+ "array",
462
+ "network",
463
+ "keywords",
464
+ "keywords_vocabulary",
465
+ "comment",
466
+ # Geospatial and temporal
467
+ "sea_area",
468
+ "sea_area_vocabulary",
469
+ "geospatial_lat_min",
470
+ "geospatial_lat_max",
471
+ "geospatial_lon_min",
472
+ "geospatial_lon_max",
473
+ "geospatial_vertical_min",
474
+ "geospatial_vertical_max",
475
+ "geospatial_vertical_units",
476
+ "geospatial_vertical_positive",
477
+ "time_coverage_start",
478
+ "time_coverage_end",
479
+ "time_coverage_duration",
480
+ "time_coverage_resolution",
481
+ # Platform and feature metadata
482
+ "cdm_data_type",
483
+ "featureType",
484
+ "platform_deployment_date",
485
+ "platform_deployment_ship_name",
486
+ "platform_deployment_cruise_name",
487
+ "platform_recovery_date",
488
+ "platform_recovery_ship_name",
489
+ "platform_recovery_cruise_name",
490
+ # File format and publisher
491
+ "data_type",
492
+ "format_version",
493
+ "Conventions",
494
+ "publisher_name",
495
+ "publisher_email",
496
+ "publisher_id",
497
+ "publisher_institution",
498
+ "publisher_institution_vocabulary",
499
+ # Links and credits
500
+ "references",
501
+ "update_interval",
502
+ "license",
503
+ "citation",
504
+ "acknowledgement",
505
+ # History and QA
506
+ "date_created",
507
+ "date_modified",
508
+ "history",
509
+ "processing_level",
510
+ "QC_indicator",
511
+ # Contributor info
512
+ "contributor_name",
513
+ "contributor_email",
514
+ "contributor_id",
515
+ "contributor_role",
516
+ "contributor_role_vocabulary",
517
+ ]
518
+
519
+
520
+ def sort_global_attributes(ds, attr_order=None):
521
+ if attr_order is None:
522
+ attr_order = OCEANSITES_ATTR_ORDER
523
+
524
+ ordered = {k: ds.attrs[k] for k in attr_order if k in ds.attrs}
525
+ remaining = {k: v for k, v in ds.attrs.items() if k not in ordered}
526
+ ds.attrs = {**ordered, **remaining}
527
+ return ds