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
oceanarray/logger.py ADDED
@@ -0,0 +1,192 @@
1
+ """Logging configuration and helpers for the oceanarray processing pipeline."""
2
+
3
+ import datetime
4
+ import logging
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ # Global logger instance (will be configured by setup_logger)
9
+ log = logging.getLogger("oceanarray")
10
+ log.setLevel(logging.DEBUG) # capture everything; handlers filter later
11
+
12
+ # Global logging flag
13
+ # Set to True to enable logging, False to disable
14
+ LOGGING_ENABLED = True
15
+
16
+
17
+ def enable_logging() -> None:
18
+ """Enable logging globally."""
19
+ global LOGGING_ENABLED
20
+ LOGGING_ENABLED = True
21
+
22
+
23
+ def disable_logging() -> None:
24
+ """Disable logging globally."""
25
+ global LOGGING_ENABLED
26
+ LOGGING_ENABLED = False
27
+
28
+
29
+ def log_info(message: str, *args: Any) -> None:
30
+ """Log an info message, if logging is enabled."""
31
+ if LOGGING_ENABLED:
32
+ log.info(message, *args, stacklevel=2)
33
+
34
+
35
+ def log_warning(message: str, *args: Any) -> None:
36
+ """Log a warning message, if logging is enabled."""
37
+ if LOGGING_ENABLED:
38
+ log.warning(message, *args, stacklevel=2)
39
+
40
+
41
+ def log_error(message: str, *args: Any) -> None:
42
+ """Log an error message, if logging is enabled."""
43
+ if LOGGING_ENABLED:
44
+ log.error(message, *args, stacklevel=2)
45
+
46
+
47
+ def log_debug(message: str, *args: Any) -> None:
48
+ """Log a debug message, if logging is enabled."""
49
+ if LOGGING_ENABLED:
50
+ log.debug(message, *args, stacklevel=2)
51
+
52
+
53
+ def setup_logger(array_name: str, output_dir: str = "logs") -> None:
54
+ """Configure the global logger to output to a file for the given array.
55
+
56
+ Parameters
57
+ ----------
58
+ array_name : str
59
+ Name of the observing array (e.g., 'move', 'rapid', etc.).
60
+ output_dir : str
61
+ Directory to save log files.
62
+
63
+ """
64
+ if not LOGGING_ENABLED:
65
+ return
66
+ # Resolve output directory to project root
67
+ project_root = Path(__file__).resolve().parent.parent
68
+ output_path = project_root / output_dir
69
+ output_path.mkdir(parents=True, exist_ok=True)
70
+
71
+ timestamp = datetime.datetime.now().strftime("%Y%m%dT%H")
72
+ log_filename = f"{array_name.upper()}_{timestamp}_read.log"
73
+ log_path = output_path / log_filename
74
+
75
+ # Prevent duplicate handlers in case of multiple calls
76
+ if not any(
77
+ isinstance(h, logging.FileHandler) and h.baseFilename == log_path
78
+ for h in log.handlers
79
+ ):
80
+ file_handler = logging.FileHandler(log_path, encoding="utf-8", mode="w")
81
+ formatter = logging.Formatter(
82
+ fmt="%(asctime)s %(levelname)-8s %(funcName)s %(message)s",
83
+ datefmt="%Y%m%dT%H%M%S",
84
+ )
85
+ file_handler.setFormatter(formatter)
86
+
87
+ # Optional: console handler
88
+ console_handler = logging.StreamHandler()
89
+ console_handler.setFormatter(formatter)
90
+
91
+ # Clear existing handlers first
92
+ log.handlers.clear()
93
+
94
+ log.addHandler(file_handler)
95
+ log.addHandler(console_handler)
96
+
97
+ log.info(f"Logger initialized for array: {array_name}, writing to {log_path}")
98
+
99
+
100
+ def load_logging_config() -> dict:
101
+ """Load the global logging configuration from config/logging.yaml.
102
+
103
+ Returns
104
+ -------
105
+ dict
106
+ Logging configuration dictionary
107
+
108
+ Raises
109
+ ------
110
+ FileNotFoundError
111
+ If logging.yaml is not found
112
+ yaml.YAMLError
113
+ If logging.yaml cannot be parsed
114
+
115
+ """
116
+ import yaml
117
+
118
+ config_path = Path(__file__).parent / "config" / "logging.yaml"
119
+
120
+ if not config_path.exists():
121
+ raise FileNotFoundError(f"Logging config not found: {config_path}") # noqa: TRY003
122
+
123
+ with open(config_path, "r") as f:
124
+ config = yaml.safe_load(f)
125
+
126
+ return config.get("logging", {})
127
+
128
+
129
+ def setup_stage_logging(mooring_name: str, stage_name: str, proc_dir: Path) -> Path:
130
+ """Set up logging for a processing stage using global configuration.
131
+
132
+ This creates simple file-based logging for processing stages (stage1, stage2, etc.)
133
+ using the configuration from config/logging.yaml.
134
+
135
+ Parameters
136
+ ----------
137
+ mooring_name : str
138
+ Name of the mooring (e.g., 'dsE_1_2018')
139
+ stage_name : str
140
+ Name of the processing stage (e.g., 'stage1', 'stage2', 'time_gridding')
141
+ proc_dir : Path
142
+ Processing directory for the mooring
143
+
144
+ Returns
145
+ -------
146
+ Path
147
+ Full path to the log file. When the logging configuration file
148
+ (``config/logging.yaml``) is missing or unparseable, falls back to
149
+ ``{proc_dir}/processing_logs/{mooring_name}_{timestamp}_{stage_name}.mooring.log``.
150
+ This function does not raise; config failures are handled silently.
151
+
152
+ """
153
+ import yaml
154
+
155
+ try:
156
+ config = load_logging_config()
157
+ except (FileNotFoundError, yaml.YAMLError):
158
+ # Fallback: place logs in processing_logs/ subdir
159
+ log_time = datetime.datetime.now().strftime("%Y%m%dT%H")
160
+ log_dir = proc_dir / "processing_logs"
161
+ log_dir.mkdir(parents=True, exist_ok=True)
162
+ return log_dir / f"{mooring_name}_{log_time}_{stage_name}.mooring.log"
163
+
164
+ # Extract configuration values with defaults
165
+ log_directory = config.get("directory", "processing_logs")
166
+ filename_pattern = config.get(
167
+ "filename_pattern", "{mooring_name}_{timestamp}_{stage}.log"
168
+ )
169
+ timestamp_format = config.get("timestamp_format", "%Y%m%dT%H")
170
+ create_directory = config.get("create_directory", True)
171
+
172
+ # Generate timestamp
173
+ timestamp = datetime.datetime.now().strftime(timestamp_format)
174
+
175
+ # Determine log directory path
176
+ if Path(log_directory).is_absolute():
177
+ # Absolute path specified
178
+ log_dir = Path(log_directory)
179
+ else:
180
+ # Relative path - create within mooring proc directory
181
+ log_dir = proc_dir / log_directory
182
+
183
+ # Create directory if requested and it doesn't exist
184
+ if create_directory:
185
+ log_dir.mkdir(parents=True, exist_ok=True)
186
+
187
+ # Generate filename using pattern
188
+ filename = filename_pattern.format(
189
+ mooring_name=mooring_name, timestamp=timestamp, stage=stage_name
190
+ )
191
+
192
+ return log_dir / filename
@@ -0,0 +1,60 @@
1
+ """oceanarray.logsheet
2
+ ====================
3
+ Generate fieldwork logsheet PDFs from mooring YAML configuration files.
4
+
5
+ Bundled defaults
6
+ ----------------
7
+ ``column_defs.yaml`` and LaTeX templates are bundled with the package.
8
+ Users do **not** need to supply these.
9
+
10
+ User-supplied config (``--config-dir``)
11
+ ----------------------------------------
12
+ - ``cruise_config.yaml`` — cruise metadata, casts list, moorings to recover/deploy,
13
+ filename conventions, paths.
14
+ - ``instruments.yaml`` — lab instrument registry (serial → model, firmware, owner).
15
+
16
+ Optionally, place a ``column_defs.yaml`` or ``latex_templates/`` directory in
17
+ ``--config-dir`` to override the bundled defaults.
18
+
19
+ Mooring YAMLs
20
+ -------------
21
+ Each builder looks up the mooring YAML via:
22
+
23
+ 1. ``cruise_config.yaml → mooring_yaml_map`` (explicit path mapping), then
24
+ 2. ``{proc_dir}/{mooring}/{mooring}.mooring.yaml`` (oceanarray convention).
25
+
26
+ Example:
27
+ -------
28
+ ::
29
+
30
+ from oceanarray.logsheet import build_recovery
31
+ from oceanarray.logsheet._config import resolve_config
32
+ from pathlib import Path
33
+
34
+ cfg = resolve_config(
35
+ config_dir=Path("/data/OdB2026/logsheets_config"),
36
+ output_dir=Path("/data/OdB2026/logsheets_output"),
37
+ proc_dir=Path("/data/OdB2026/proc"),
38
+ )
39
+ build_recovery("dsG3_1_2026", cfg, fmt="tex")
40
+
41
+ """
42
+
43
+ from ._config import LogsheetsConfig, resolve_config
44
+ from .builders import (
45
+ build_caldip_setup,
46
+ build_caldip_download,
47
+ build_recovery,
48
+ build_mooring_download,
49
+ build_deployment_setup,
50
+ )
51
+
52
+ __all__ = [
53
+ "LogsheetsConfig",
54
+ "resolve_config",
55
+ "build_caldip_setup",
56
+ "build_caldip_download",
57
+ "build_recovery",
58
+ "build_mooring_download",
59
+ "build_deployment_setup",
60
+ ]
@@ -0,0 +1,97 @@
1
+ """oceanarray.logsheet._columns
2
+ ================================
3
+ Column resolution from the ``column_library``, download-convention
4
+ substitution, and data-path note formatting.
5
+ """
6
+
7
+ from ._latex import tex_safe
8
+
9
+
10
+ def resolve_columns(col_list: list, col_library: dict) -> list[dict]:
11
+ """Resolve a raw column list from ``column_defs.yaml`` into full column dicts.
12
+
13
+ Each item in *col_list* may be:
14
+
15
+ - **plain string** — a key in *col_library*, used as-is.
16
+ - **{key: {overrides}}** — library key with field overrides.
17
+ - **{header: …, width: …, input: …}** — inline full definition.
18
+
19
+ Parameters
20
+ ----------
21
+ col_list:
22
+ Raw list loaded directly from YAML.
23
+ col_library:
24
+ The ``column_library`` section from ``column_defs.yaml``.
25
+
26
+ """
27
+ result = []
28
+ for item in col_list:
29
+ if isinstance(item, str):
30
+ col = col_library.get(item, {}).copy()
31
+ if not col:
32
+ print(f" Warning: column key '{item}' not in column_library")
33
+ result.append(col)
34
+ elif isinstance(item, dict):
35
+ if "header" in item:
36
+ result.append(item.copy())
37
+ else:
38
+ key = next(iter(item))
39
+ overrides = item[key] or {}
40
+ col = col_library.get(key, {}).copy()
41
+ if not col:
42
+ print(f" Warning: column key '{key}' not in column_library")
43
+ if overrides:
44
+ col.update(overrides)
45
+ result.append(col)
46
+ return result
47
+
48
+
49
+ def apply_download_convention(cols: list[dict], convention: str) -> list[dict]:
50
+ """Return a copy of *cols* with ``{download_convention}`` in headers replaced."""
51
+ result = []
52
+ for col in cols:
53
+ col = col.copy()
54
+ if "{download_convention}" in col.get("header", ""):
55
+ col["header"] = col["header"].replace("{download_convention}", convention)
56
+ result.append(col)
57
+ return result
58
+
59
+
60
+ def format_data_path_note(
61
+ note_raw: str,
62
+ raw_data: str = "",
63
+ mooring: str = "",
64
+ caldip_data: str = "",
65
+ cast_label: str = "",
66
+ instrument: str = "",
67
+ download_convention: str = "",
68
+ ) -> list[str]:
69
+ """Substitute path placeholders in a ``data_path_note`` string.
70
+
71
+ ``{instrument}`` and ``{mooring}`` are rendered bold in the PDF.
72
+
73
+ Returns
74
+ -------
75
+ list[str]
76
+ One LaTeX-escaped string per non-blank line.
77
+
78
+ """
79
+ if not note_raw:
80
+ return []
81
+ s = note_raw.strip()
82
+ s = s.replace("{raw_data}", raw_data)
83
+ s = s.replace("{caldip_data}", caldip_data)
84
+ s = s.replace("{cast_label}", cast_label)
85
+ s = s.replace("{download_convention}", download_convention)
86
+
87
+ _B_MOORING = "\x00MOORING\x00"
88
+ _B_INSTRUMENT = "\x00INSTRUMENT\x00"
89
+ s = s.replace("{mooring}", _B_MOORING)
90
+ s = s.replace("{instrument}", _B_INSTRUMENT)
91
+
92
+ s = tex_safe(s)
93
+
94
+ s = s.replace(_B_MOORING, r"\textbf{" + tex_safe(mooring) + r"}")
95
+ s = s.replace(_B_INSTRUMENT, r"\textbf{" + tex_safe(instrument) + r"}")
96
+
97
+ return [ln for ln in s.split("\n") if ln.strip()]
@@ -0,0 +1,245 @@
1
+ """oceanarray.logsheet._config
2
+ =============================
3
+ Path resolution, YAML loading helpers, and instrument registry access.
4
+
5
+ Two categories of config files:
6
+
7
+ * **Bundled** (installed with the package, in ``data/``):
8
+ ``column_defs.yaml`` and ``latex_templates/``.
9
+
10
+ * **User-supplied** (via ``--config-dir`` or ``LOGSHEETS_CONFIG_DIR`` env var):
11
+ ``logsheet_config.yaml`` and ``instrument_inventory.csv``.
12
+
13
+ Mooring YAMLs are resolved from the oceanarray proc directory
14
+ (``{proc_dir}/{mooring}/{mooring}.mooring.yaml``), with an optional
15
+ ``mooring_yaml_map`` override in ``logsheet_config.yaml``.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import csv
21
+ import os
22
+ from dataclasses import dataclass
23
+ from pathlib import Path
24
+ from typing import Optional
25
+
26
+ import yaml
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Bundled package data
30
+ # ---------------------------------------------------------------------------
31
+
32
+ _DATA = Path(__file__).parent / "data"
33
+ DEFAULT_COLUMN_DEFS: Path = _DATA / "column_defs.yaml"
34
+ DEFAULT_TEMPLATES_DIR: Path = _DATA / "latex_templates"
35
+
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # Config container
39
+ # ---------------------------------------------------------------------------
40
+
41
+
42
+ @dataclass
43
+ class LogsheetsConfig:
44
+ """Resolved paths for a logsheet generation run."""
45
+
46
+ cruise_config_path: Path
47
+ instruments_path: Path
48
+ column_defs_path: Path
49
+ templates_dir: Path
50
+ output_dir: Path
51
+ proc_dir: Optional[Path]
52
+
53
+
54
+ def resolve_config(
55
+ config_dir: Optional[Path] = None,
56
+ inventory_path: Optional[Path] = None,
57
+ logsheet_config_path: Optional[Path] = None,
58
+ output_dir: Optional[Path] = None,
59
+ proc_dir: Optional[Path] = None,
60
+ ) -> LogsheetsConfig:
61
+ """Build a :class:`LogsheetsConfig` from user-supplied paths.
62
+
63
+ Parameters
64
+ ----------
65
+ config_dir:
66
+ Shorthand: directory containing ``logsheet_config.yaml`` and
67
+ ``instrument_inventory.csv``. Ignored for any path supplied explicitly
68
+ via *inventory_path* or *logsheet_config_path*. Falls back to the
69
+ ``LOGSHEETS_CONFIG_DIR`` env var, then the current working directory.
70
+ inventory_path:
71
+ Explicit path to ``instrument_inventory.csv``.
72
+ Takes precedence over ``config_dir / instrument_inventory.csv``.
73
+ logsheet_config_path:
74
+ Explicit path to ``logsheet_config.yaml``. Takes precedence over
75
+ ``config_dir / logsheet_config.yaml``. Optional; if the file does not
76
+ exist, path/filename fields are left blank on the sheet.
77
+ output_dir:
78
+ Where PDFs are written. Defaults to ``./logsheets/``.
79
+ proc_dir:
80
+ Oceanarray proc directory (parent of per-mooring subdirs). Used to
81
+ locate mooring YAMLs when not found via ``mooring_yaml_map``.
82
+
83
+ """
84
+ if config_dir is None and inventory_path is None and logsheet_config_path is None:
85
+ env = os.environ.get("LOGSHEETS_CONFIG_DIR")
86
+ config_dir = Path(env) if env else Path.cwd()
87
+
88
+ # Resolve cruise_config and instruments from explicit paths or config_dir
89
+ resolved_cruise = logsheet_config_path or (
90
+ (config_dir / "cruise_config.yaml")
91
+ if config_dir
92
+ else Path("cruise_config.yaml")
93
+ )
94
+ resolved_inventory = inventory_path or (
95
+ (config_dir / "instruments.yaml") if config_dir else Path("instruments.yaml")
96
+ )
97
+
98
+ # Allow user to override bundled column_defs.yaml by placing one in config_dir
99
+ user_col_defs = (config_dir / "column_defs.yaml") if config_dir else None
100
+ col_defs_path = (
101
+ user_col_defs
102
+ if (user_col_defs and user_col_defs.exists())
103
+ else DEFAULT_COLUMN_DEFS
104
+ )
105
+
106
+ # Similarly allow a custom templates/ dir alongside cruise_config
107
+ user_templates = (config_dir / "latex_templates") if config_dir else None
108
+ templates_dir = (
109
+ user_templates
110
+ if (user_templates and user_templates.is_dir())
111
+ else DEFAULT_TEMPLATES_DIR
112
+ )
113
+
114
+ return LogsheetsConfig(
115
+ cruise_config_path=resolved_cruise,
116
+ instruments_path=resolved_inventory,
117
+ column_defs_path=col_defs_path,
118
+ templates_dir=templates_dir,
119
+ output_dir=output_dir or Path("logsheets"),
120
+ proc_dir=proc_dir,
121
+ )
122
+
123
+
124
+ # ---------------------------------------------------------------------------
125
+ # YAML helpers
126
+ # ---------------------------------------------------------------------------
127
+
128
+
129
+ def load_yaml(path: Path) -> dict:
130
+ """Load and return a YAML file as a dict."""
131
+ with open(path) as fh:
132
+ return yaml.safe_load(fh)
133
+
134
+
135
+ def _parse_csv_value(val: str) -> object:
136
+ """Convert a raw CSV string to a Python scalar.
137
+
138
+ * ``"true"`` / ``"false"`` (case-insensitive) → ``bool``
139
+ * Empty string → ``None``
140
+ * Anything else → left as ``str``
141
+
142
+ Integers and floats are intentionally left as strings so that firmware
143
+ version strings like ``"3.0h"`` or serial suffixes are never miscast.
144
+ """
145
+ stripped = val.strip()
146
+ lower = stripped.lower()
147
+ if lower == "true":
148
+ return True
149
+ if lower == "false":
150
+ return False
151
+ if lower == "":
152
+ return None
153
+ return stripped
154
+
155
+
156
+ def load_instruments(cfg: LogsheetsConfig) -> dict:
157
+ """Return the instrument registry keyed by ``(type_str, serial_int)``.
158
+
159
+ Reads ``instrument_inventory.csv`` or legacy ``instruments.yaml``.
160
+ CSV files are detected by a ``.csv`` suffix; everything else is treated as
161
+ YAML. Boolean-string fields (``"true"``/``"false"``) in CSV rows are
162
+ converted to Python ``bool`` so that checks like ``entry.get("odo")``
163
+ behave correctly. Example key: ``("microcat", 7507)``.
164
+ """
165
+ path = cfg.instruments_path
166
+ lookup = {}
167
+ if path.suffix.lower() == ".csv":
168
+ with open(path, newline="") as fh:
169
+ for row in csv.DictReader(fh):
170
+ itype = row.get("type", "").strip()
171
+ serial_str = row.get("serial", "").strip()
172
+ if not itype or not serial_str:
173
+ continue
174
+ try:
175
+ sn = int(serial_str)
176
+ except ValueError:
177
+ continue
178
+ entry = {
179
+ k: _parse_csv_value(v)
180
+ for k, v in row.items()
181
+ if k not in ("type", "serial")
182
+ }
183
+ entry["serial"] = sn
184
+ lookup[(itype, sn)] = entry
185
+ else:
186
+ raw = load_yaml(path)
187
+ for itype, entries in raw.items():
188
+ for entry in entries:
189
+ sn = entry["serial"]
190
+ lookup[(itype, sn)] = entry
191
+ return lookup
192
+
193
+
194
+ def sn_to_entry(instruments: dict, sn: int) -> tuple[str, dict]:
195
+ """Look up a serial number across all instrument types.
196
+
197
+ Parameters
198
+ ----------
199
+ instruments:
200
+ Registry returned by :func:`load_instruments`.
201
+ sn:
202
+ Serial number to look up.
203
+
204
+ Returns
205
+ -------
206
+ tuple[str, dict]
207
+ ``(instrument_type, entry_dict)``.
208
+
209
+ Raises
210
+ ------
211
+ KeyError
212
+ If the serial number is not found in any type.
213
+
214
+ """
215
+ for itype in ("microcat", "aquadopp", "tr1050", "rbrsolo", "rbrduet", "adcp"):
216
+ if (itype, sn) in instruments:
217
+ return itype, instruments[(itype, sn)]
218
+ raise KeyError(f"SN {sn} not found in instrument_inventory.csv")
219
+
220
+
221
+ def resolve_mooring_yaml(
222
+ mooring: str, cfg: LogsheetsConfig, mooring_yaml_map: dict
223
+ ) -> Optional[Path]:
224
+ """Find the mooring YAML path, trying two strategies in order.
225
+
226
+ 1. ``mooring_yaml_map`` in ``logsheet_config.yaml`` (relative paths are
227
+ resolved relative to ``logsheet_config.yaml``'s parent directory).
228
+ 2. Oceanarray convention: ``{proc_dir}/{mooring}/{mooring}.mooring.yaml``.
229
+
230
+ Returns ``None`` if neither strategy finds a readable file.
231
+ """
232
+ if mooring in mooring_yaml_map:
233
+ rel = mooring_yaml_map[mooring]
234
+ p = Path(rel)
235
+ if not p.is_absolute():
236
+ p = cfg.cruise_config_path.parent / rel
237
+ if p.exists():
238
+ return p
239
+
240
+ if cfg.proc_dir is not None:
241
+ candidate = cfg.proc_dir / mooring / f"{mooring}.mooring.yaml"
242
+ if candidate.exists():
243
+ return candidate
244
+
245
+ return None
@@ -0,0 +1,43 @@
1
+ """oceanarray.logsheet._firmware
2
+ ================================
3
+ MicroCAT firmware group detection and display labels.
4
+ """
5
+
6
+ from packaging.version import Version
7
+
8
+ #: Section-title strings used in generated logsheets.
9
+ FW_GROUP_LABELS: dict[str, str] = {
10
+ "old_seaterm": r"MicroCAT --- Firmware $<$ 3.0d (Seaterm v1, 9600 baud)",
11
+ "seaterm_v2": r"MicroCAT --- Firmware $\geq$ 3.0d (SeatermV2, 38400 baud)",
12
+ "seaterm_v2_odo": r"MicroCAT --- Firmware $\geq$ 3.0d + ODO (SeatermV2, 38400 baud)",
13
+ }
14
+
15
+
16
+ def fw_group(entry: dict, sentinel_str: str = "3.0d") -> str:
17
+ """Determine the firmware group for a MicroCAT instrument entry.
18
+
19
+ Parameters
20
+ ----------
21
+ entry:
22
+ Instrument entry from ``instruments.yaml``.
23
+ sentinel_str:
24
+ Version string separating old_seaterm from seaterm_v2
25
+ (from ``cruise_config.yaml → microcat_firmware_sentinel``).
26
+
27
+ Returns
28
+ -------
29
+ str
30
+ One of ``"old_seaterm"``, ``"seaterm_v2"``, ``"seaterm_v2_odo"``.
31
+
32
+ """
33
+ if entry.get("odo"):
34
+ return "seaterm_v2_odo"
35
+ fw = entry.get("firmware")
36
+ if fw is None:
37
+ return "seaterm_v2"
38
+ try:
39
+ if Version(fw) < Version(sentinel_str):
40
+ return "old_seaterm"
41
+ return "seaterm_v2"
42
+ except Exception: # noqa: BLE001
43
+ return "old_seaterm" if fw < sentinel_str else "seaterm_v2"