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/readers.py ADDED
@@ -0,0 +1,303 @@
1
+ from pathlib import Path
2
+ from typing import Dict, List, Optional, Union
3
+
4
+ import numpy as np
5
+ import pandas as pd
6
+ import xarray as xr
7
+
8
+ from oceanarray.logger import log_info
9
+ from oceanarray.legacy import rodb
10
+
11
+ DUMMY_VALUES = [1e32, -9.0, -9.9]
12
+
13
+
14
+ def load_dataset(
15
+ source: Union[str, Path, List[Union[str, Path]]],
16
+ ) -> Union[xr.Dataset, List[xr.Dataset]]:
17
+ """Load one or more observational data files and return as xarray Datasets.
18
+ Dispatches based on file extension or known formats.
19
+
20
+ Parameters
21
+ ----------
22
+ source : str, Path, or list of str/Path
23
+ Single file or list of files to load.
24
+
25
+ Returns
26
+ -------
27
+ xarray.Dataset or list of xarray.Dataset
28
+ Loaded dataset(s). A single dataset is returned if one file is given;
29
+ a list of datasets is returned for multiple files.
30
+
31
+ Raises
32
+ ------
33
+ ValueError
34
+ If file type is unrecognized.
35
+
36
+ """
37
+ if isinstance(source, (str, Path)):
38
+ source = [Path(source)]
39
+ else:
40
+ source = [Path(f) for f in source]
41
+
42
+ datasets = []
43
+ for f in source:
44
+ if f.suffix.lower() == ".nc":
45
+ ds = xr.open_dataset(f)
46
+ elif rodb.is_rodb_file(f):
47
+ ds = rodb.rodbload(f)
48
+ else:
49
+ raise ValueError(f"Unknown file type: {f}") # noqa: TRY003
50
+ datasets.append(ds)
51
+
52
+ return datasets if len(datasets) > 1 else datasets[0]
53
+
54
+
55
+ def rodbload_old(filepath: Path, variables: list[str]) -> xr.Dataset:
56
+ """Load a RODB-style file into an xarray.Dataset.
57
+
58
+ Parameters
59
+ ----------
60
+ filepath : Path
61
+ Path to the .use, .raw or .dat file
62
+ variables : list of str
63
+ Variables to extract (must be present in columns= line)
64
+
65
+ Returns
66
+ -------
67
+ ds : xr.Dataset
68
+ Dataset containing requested variables
69
+
70
+ """
71
+ with open(filepath, "r") as f:
72
+ lines = f.readlines()
73
+
74
+ # Extract header lines (up to first data block)
75
+ header_lines = []
76
+ data_start_index = None
77
+
78
+ for i, line in enumerate(lines):
79
+ if not line.strip():
80
+ continue
81
+ if line.lstrip().startswith("#") or "=" in line:
82
+ header_lines.append(line.strip())
83
+ else:
84
+ data_start_index = i
85
+ break
86
+
87
+ if data_start_index is None:
88
+ raise ValueError("Could not locate data block in file") # noqa: TRY003
89
+
90
+ # Extract columns
91
+ col_line = next((line for line in header_lines if "columns" in line.lower()), None)
92
+ if col_line is None:
93
+ raise ValueError("No 'columns=' line found in header") # noqa: TRY003
94
+
95
+ columns = col_line.split("=")[-1].strip().split(":")
96
+ print(columns)
97
+ log_info("Found columns: %s", columns)
98
+
99
+ # Validate requested variables
100
+ missing = [v for v in variables if v not in columns]
101
+ if missing:
102
+ raise ValueError(f"Variables not found in file: {missing}") # noqa: TRY003
103
+
104
+ col_indices = {v: i for i, v in enumerate(columns) if v in variables}
105
+
106
+ # Load data block
107
+ data = np.genfromtxt(lines[data_start_index:], dtype=float)
108
+ if data.ndim == 1:
109
+ data = data[np.newaxis, :] # in case of only 1 line
110
+
111
+ # Replace dummy values with NaN
112
+ for dummy in DUMMY_VALUES:
113
+ data[data == dummy] = np.nan
114
+
115
+ # Build xarray dataset
116
+ coords = {"obs": np.arange(data.shape[0])}
117
+ data_vars = {var: (("obs",), data[:, col_indices[var]]) for var in variables}
118
+
119
+ ds = xr.Dataset(data_vars, coords=coords)
120
+ ds.attrs["source_file"] = str(filepath)
121
+ return ds
122
+
123
+
124
+ def _clean_nortek_var_name(col: str) -> str:
125
+ """Convert a raw Nortek CSV column name to a valid NetCDF variable name."""
126
+ import re
127
+
128
+ name = col.lower().strip()
129
+ name = re.sub(r"[^\w\s]", " ", name) # special chars → spaces
130
+ name = re.sub(r"\s+", "_", name.strip())
131
+ name = re.sub(r"_+", "_", name)
132
+ return name
133
+
134
+
135
+ def _parse_nortek_csv_columns(df: pd.DataFrame) -> Dict:
136
+ """Build a complete variable dict from all columns in a Nortek CSV DataFrame.
137
+
138
+ Nortek AquaPro exports mix two column-naming styles:
139
+ - camelCase (``velBeam1#1``, ``speedOfSound``) for the AquaPro velocity block
140
+ - Title Case (``Pressure``, ``Temperature``, ``Soundspeed``) for the
141
+ environmental summary block
142
+
143
+ Rules applied:
144
+ - Column matching for canonical names is case-insensitive.
145
+ - When the same name appears more than once (e.g. ``Pressure`` in dbar then
146
+ ``Pressure`` in m), the first occurrence maps to the canonical name and
147
+ all later occurrences are dropped — the second Pressure column is depth
148
+ (derived from pressure) and is not needed.
149
+ - All remaining columns are included with cleaned variable names so that
150
+ no data from the raw file is silently discarded.
151
+ - Time component columns (Year, Month, Day, Hour, Minute, Second) are
152
+ dropped because the ``time`` coordinate already captures this information.
153
+ """
154
+ # Build case-insensitive lookup: lower_name -> first df column name
155
+ col_map: Dict[str, str] = {}
156
+ for col in df.columns:
157
+ key = col.lower().strip()
158
+ if key not in col_map:
159
+ col_map[key] = col # first occurrence wins
160
+
161
+ # Canonical name mappings — covers both camelCase and Title Case spellings
162
+ canonical: Dict[str, str] = {} # df_col_name -> var_name
163
+
164
+ scalar_mappings = [
165
+ (["temperature"], "temperature"),
166
+ (["pressure"], "pressure"), # first = dbar
167
+ (["heading"], "heading"),
168
+ (["pitch"], "pitch"),
169
+ (["roll"], "roll"),
170
+ (["soundspeed", "speedofsound"], "speed_of_sound"),
171
+ (["battery voltage", "batteryvoltage"], "battery_voltage"),
172
+ (["speed"], "speed"),
173
+ ]
174
+ for csv_names, var_name in scalar_mappings:
175
+ for csv_name in csv_names:
176
+ if csv_name in col_map:
177
+ canonical[col_map[csv_name]] = var_name
178
+ break
179
+
180
+ # AquaPro camelCase beam variables
181
+ for i in [1, 2, 3]:
182
+ for data_type, prefix in [
183
+ ("vel", "velocity"),
184
+ ("amp", "amplitude"),
185
+ ("corr", "correlation"),
186
+ ]:
187
+ df_col = f"{data_type}Beam{i}#1"
188
+ if df_col in df.columns:
189
+ canonical[df_col] = f"{prefix}_beam{i}"
190
+
191
+ # Columns to drop entirely
192
+ time_components = {"year", "month", "day", "hour", "minute", "second"}
193
+ # The second (and any further) occurrence of "pressure" is depth in metres
194
+ first_pressure_col = col_map.get("pressure")
195
+ duplicate_pressure = {
196
+ col
197
+ for col in df.columns
198
+ if col.lower().strip() == "pressure" and col != first_pressure_col
199
+ }
200
+ drop_cols = time_components | duplicate_pressure | {"datetime"}
201
+
202
+ # Build data_vars: canonical names first, then everything else
203
+ data_vars: Dict = {}
204
+ assigned_df_cols: set = set(canonical.keys())
205
+
206
+ for df_col, var_name in canonical.items():
207
+ data_vars[var_name] = (["time"], df[df_col].values)
208
+
209
+ for df_col in df.columns:
210
+ if df_col in assigned_df_cols:
211
+ continue
212
+ if df_col.lower().strip() in drop_cols or df_col in drop_cols:
213
+ continue
214
+ var_name = _clean_nortek_var_name(df_col)
215
+ if var_name and var_name not in data_vars:
216
+ data_vars[var_name] = (["time"], df[df_col].values)
217
+
218
+ return data_vars
219
+
220
+
221
+ def _add_nortek_csv_attributes(ds: xr.Dataset) -> xr.Dataset:
222
+ """Add units and metadata attributes to variables in a Nortek CSV dataset."""
223
+ attr_map = {
224
+ "temperature": {"units": "degrees_C", "long_name": "Water Temperature"},
225
+ "pressure": {"units": "dbar", "long_name": "Pressure"},
226
+ "heading": {"units": "degrees", "long_name": "Heading"},
227
+ "pitch": {"units": "degrees", "long_name": "Pitch"},
228
+ "roll": {"units": "degrees", "long_name": "Roll"},
229
+ "speed_of_sound": {"units": "m/s", "long_name": "Speed of Sound"},
230
+ "battery_voltage": {"units": "V", "long_name": "Battery Voltage"},
231
+ }
232
+ for var_name, attrs in attr_map.items():
233
+ if var_name in ds.data_vars:
234
+ ds[var_name].attrs.update(attrs)
235
+
236
+ for i in [1, 2, 3]:
237
+ if f"velocity_beam{i}" in ds.data_vars:
238
+ ds[f"velocity_beam{i}"].attrs.update(
239
+ {
240
+ "units": "m/s",
241
+ "long_name": f"Velocity Beam {i}",
242
+ "coordinate_system": "BEAM",
243
+ }
244
+ )
245
+ if f"amplitude_beam{i}" in ds.data_vars:
246
+ ds[f"amplitude_beam{i}"].attrs.update(
247
+ {"units": "counts", "long_name": f"Amplitude Beam {i}"}
248
+ )
249
+ if f"correlation_beam{i}" in ds.data_vars:
250
+ ds[f"correlation_beam{i}"].attrs.update(
251
+ {"units": "%", "long_name": f"Correlation Beam {i}"}
252
+ )
253
+
254
+ return ds
255
+
256
+
257
+ def load_nortek_csv(
258
+ file_path: Union[str, Path],
259
+ header_file: Optional[str] = None, # noqa: ARG001
260
+ ) -> xr.Dataset:
261
+ """Load Nortek CSV data exported from AquaPro software.
262
+
263
+ Parameters
264
+ ----------
265
+ file_path : str or Path
266
+ Path to the semicolon-delimited CSV data file (e.g. "Average Velocity DF3.csv").
267
+ header_file : str, optional
268
+ Path to the accompanying Units.csv file (reserved for future metadata use).
269
+
270
+ Returns
271
+ -------
272
+ xr.Dataset
273
+ Dataset with time coordinate and variables for velocity beams,
274
+ amplitude, correlation, and environmental channels.
275
+
276
+ """
277
+ file_path = Path(file_path)
278
+ if not file_path.exists():
279
+ raise FileNotFoundError(f"Nortek CSV file not found: {file_path}") # noqa: TRY003
280
+
281
+ df = pd.read_csv(file_path, delimiter=";")
282
+ df["datetime"] = pd.to_datetime(df["dateTime"])
283
+ times = df["datetime"].values
284
+
285
+ data_vars = _parse_nortek_csv_columns(df)
286
+ ds = xr.Dataset(data_vars, coords={"time": times})
287
+
288
+ ds.attrs.update(
289
+ {
290
+ "instrument_type": "Nortek_Aquadopp",
291
+ "filename": str(file_path),
292
+ "data_format": "Nortek_CSV_Export",
293
+ "coordinate_system": "BEAM",
294
+ }
295
+ )
296
+
297
+ if "serialNumber" in df.columns:
298
+ ds.attrs["serial_number"] = str(df["serialNumber"].iloc[0])
299
+
300
+ ds = _add_nortek_csv_attributes(ds)
301
+
302
+ log_info("Nortek CSV: loaded %d samples from %s", len(times), file_path.name)
303
+ return ds
@@ -0,0 +1,5 @@
1
+ """Mooring report package — public API."""
2
+
3
+ from ._mooring import MooringReport
4
+
5
+ __all__ = ["MooringReport"]
@@ -0,0 +1,386 @@
1
+ """Array-level HTML summary report.
2
+
3
+ Reads a ``*.array.yaml`` file, gathers per-mooring metadata from each
4
+ mooring's YAML, draws a simple position map, and writes a single HTML
5
+ index page with clickable links to every mooring's report.
6
+
7
+ Entry point: :func:`generate_array_report`.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+ from typing import Any, Dict, List, Optional
14
+
15
+ import yaml
16
+
17
+ from ._html_helpers import _duration_str, _fig_to_base64, _parse_dt, _status
18
+
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # Lat/lon parsing
22
+ # ---------------------------------------------------------------------------
23
+
24
+
25
+ def _parse_decdeg(val: Any) -> Optional[float]:
26
+ """Parse a latitude or longitude value to decimal degrees.
27
+
28
+ Handles:
29
+ - Numeric types (float / int): returned as-is.
30
+ - ``"65.567"`` or ``"-27.5"`` — plain decimal-degree strings.
31
+ - ``"65 43.913"`` — degrees + decimal-minutes, sign for hemisphere.
32
+ - ``"65 34.004 N"`` / ``"029 25.878 W"`` — DM with hemisphere letter.
33
+ """
34
+ if val is None:
35
+ return None
36
+ if isinstance(val, (int, float)):
37
+ return float(val)
38
+ s = str(val).strip()
39
+ try:
40
+ return float(s)
41
+ except ValueError:
42
+ pass
43
+ parts = s.split()
44
+ if len(parts) >= 2:
45
+ try:
46
+ deg = float(parts[0])
47
+ minutes = float(parts[1])
48
+ dd = abs(deg) + minutes / 60.0
49
+ if deg < 0:
50
+ dd = -dd
51
+ if len(parts) >= 3:
52
+ hemi = parts[2].strip(".,").upper()
53
+ if hemi in ("S", "W"):
54
+ dd = -abs(dd)
55
+ except (ValueError, IndexError):
56
+ pass
57
+ else:
58
+ return dd
59
+ return None
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # Mooring metadata extraction
64
+ # ---------------------------------------------------------------------------
65
+
66
+
67
+ def _lat_lon_from_cfg(cfg: Dict[str, Any]) -> tuple:
68
+ """Return (lat_dd, lon_dd) decimal degrees from a mooring YAML dict."""
69
+ lat_raw = (
70
+ cfg.get("seabed_latitude")
71
+ or cfg.get("deployment_latitude")
72
+ or cfg.get("planned_latitude")
73
+ or cfg.get("latitude")
74
+ )
75
+ lon_raw = (
76
+ cfg.get("seabed_longitude")
77
+ or cfg.get("deployment_longitude")
78
+ or cfg.get("planned_longitude")
79
+ or cfg.get("longitude")
80
+ )
81
+ return _parse_decdeg(lat_raw), _parse_decdeg(lon_raw)
82
+
83
+
84
+ def _count_instruments(cfg: Dict[str, Any]) -> int:
85
+ """Count instruments in a mooring YAML (clamp or instruments key)."""
86
+ entries = cfg.get("clamp") or cfg.get("instruments") or []
87
+ return sum(1 for e in entries if isinstance(e, dict) and not e.get("skip"))
88
+
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # Map figure
92
+ # ---------------------------------------------------------------------------
93
+
94
+
95
+ def _make_array_map_b64(
96
+ rows: List[Dict[str, Any]],
97
+ array_name: str,
98
+ ) -> Optional[str]:
99
+ """Return a base64-encoded PNG of mooring positions, or None on failure."""
100
+ try:
101
+ import matplotlib.pyplot as plt
102
+ from oceanarray import parameters as P
103
+
104
+ plt.style.use(str(P.MPLSTYLE))
105
+
106
+ lats = [r["lat"] for r in rows if r["lat"] is not None]
107
+ if len(lats) < 1:
108
+ return None
109
+
110
+ mean_lat_rad = float(__import__("math").radians(sum(lats) / len(lats)))
111
+ aspect = 1.0 / __import__("math").cos(mean_lat_rad)
112
+
113
+ fig, ax = plt.subplots(figsize=(6, 5))
114
+ ax.set_aspect(aspect)
115
+
116
+ import matplotlib.colors as _mcolors
117
+
118
+ _tab20 = plt.get_cmap("tab20")
119
+ for idx, r in enumerate(rows):
120
+ if r["lat"] is None or r["lon"] is None:
121
+ continue
122
+ color = _tab20(idx % 20)
123
+ r["color_hex"] = _mcolors.to_hex(color)
124
+ ax.scatter(r["lon"], r["lat"], s=60, color=color, zorder=3)
125
+ # Only label moorings that have been recovered
126
+ if r.get("_recover_dt") is not None:
127
+ ax.annotate(
128
+ r["mooring"],
129
+ (r["lon"], r["lat"]),
130
+ textcoords="offset points",
131
+ xytext=(5, 3),
132
+ fontsize=10,
133
+ color=color,
134
+ )
135
+
136
+ ax.set_xlabel("Longitude (°)")
137
+ ax.set_ylabel("Latitude (°)")
138
+ ax.set_title(array_name, fontsize=9)
139
+ ax.grid(True, linestyle="--", linewidth=0.3, alpha=0.5)
140
+ plt.tight_layout()
141
+ b64 = _fig_to_base64(fig)
142
+ plt.close(fig)
143
+ except Exception: # noqa: BLE001
144
+ return None
145
+ else:
146
+ return b64
147
+ return None
148
+
149
+
150
+ # ---------------------------------------------------------------------------
151
+ # HTML template
152
+ # ---------------------------------------------------------------------------
153
+
154
+ _ARRAY_HTML_TEMPLATE = """\
155
+ <!DOCTYPE html>
156
+ <html lang="en">
157
+ <head>
158
+ <meta charset="UTF-8">
159
+ <meta name="viewport" content="width=device-width, initial-scale=1">
160
+ <title>Array report &ndash; {{ array_name }}</title>
161
+ <style>
162
+ :root { --ocean:#1a3a5c; --seafoam:#e8f4f8; --text:#2c3e50; --muted:#95a5a6; }
163
+ body { font-family: system-ui, sans-serif; max-width: 1100px; margin: 0 auto; padding: 1rem; color: var(--text); }
164
+ .masthead { background: var(--ocean); color:#fff; border-radius:6px; padding:1.2rem 1.6rem 1rem; margin-bottom:1.2rem; }
165
+ .masthead h1 { margin:0 0 0.2rem; font-size:1.6rem; }
166
+ .masthead p.sub { margin:0; font-size:0.82rem; opacity:0.75; }
167
+ .meta-grid { display:flex; flex-wrap:wrap; gap:0.5rem 1.4rem; margin-top:0.8rem; }
168
+ .meta-grid div { font-size:0.82rem; }
169
+ .meta-grid dt { opacity:0.7; font-size:0.72rem; }
170
+ .meta-grid dd { margin:0; font-weight:600; }
171
+ .meta-miss dd { color:#f0ad4e; }
172
+ table { border-collapse:collapse; width:100%; margin:0.6rem 0 1.2rem; font-size:0.84rem; }
173
+ th { background:var(--ocean); color:#fff; padding:0.4rem 0.6rem; text-align:left; }
174
+ td { padding:0.35rem 0.6rem; border-bottom:1px solid #e8ecef; }
175
+ tr:nth-child(even) td { background:#f7f9fb; }
176
+ .num { text-align:right; }
177
+ .btn { display:inline-block; padding:0.15em 0.55em; border-radius:4px; font-size:0.75rem;
178
+ font-weight:700; text-decoration:none; color:#fff; margin:0 0.15rem 0.2rem 0; }
179
+ .btn-sum { background:#2c3e50; }
180
+ .btn-stk { background:#2980b9; }
181
+ .btn-grd { background:#8e44ad; }
182
+ .btn-miss { background:#bbb; cursor:default; pointer-events:none; }
183
+ .note { font-size:0.78rem; color:#555; margin:0.2rem 0 0.6rem; }
184
+ .fig { max-width:60%; display:block; margin:0.5rem auto 1rem; border:1px solid #e0e4e8; border-radius:4px; }
185
+ h2 { color:var(--ocean); border-bottom:2px solid var(--seafoam); padding-bottom:0.3rem; margin-top:1.4rem; }
186
+ </style>
187
+ </head>
188
+ <body>
189
+
190
+ <div class="masthead">
191
+ <h1>{{ array_name }}</h1>
192
+ <p class="sub">Array summary &bull; generated {{ generated }}</p>
193
+ <dl class="meta-grid">
194
+ {% if year %}<div><dt>Year</dt><dd>{{ year }}</dd></div>{% endif %}
195
+ {% if cruise %}<div><dt>Cruise</dt><dd>{{ cruise }}</dd></div>{% endif %}
196
+ {% if ship %}<div><dt>Ship</dt><dd>{{ ship }}</dd></div>{% endif %}
197
+ {% if project %}<div><dt>Project</dt><dd>{{ project }}</dd></div>{% endif %}
198
+ {% if deploy_time %}<div><dt>Deployment</dt><dd>{{ deploy_time }}</dd></div>{% endif %}
199
+ {% if recover_time %}<div><dt>Recovery</dt><dd>{{ recover_time }}</dd></div>{% endif %}
200
+ <div><dt>Moorings</dt><dd>{{ moorings | length }}</dd></div>
201
+ </dl>
202
+ </div>
203
+
204
+ {% if fig_map_b64 %}
205
+ <h2>Mooring positions</h2>
206
+ <img class="fig" src="data:image/png;base64,{{ fig_map_b64 }}" alt="Array map">
207
+ {% endif %}
208
+
209
+ <h2>Moorings</h2>
210
+ <table>
211
+ <thead>
212
+ <tr>
213
+ <th>#</th>
214
+ <th>Mooring</th>
215
+ <th class="num">Latitude</th>
216
+ <th class="num">Longitude</th>
217
+ <th class="num">Depth&nbsp;(m)</th>
218
+ <th>Deployment</th>
219
+ <th>Recovery</th>
220
+ <th class="num">Duration</th>
221
+ <th class="num">Instruments</th>
222
+ <th>Reports</th>
223
+ </tr>
224
+ </thead>
225
+ <tbody>
226
+ {% for r in moorings %}
227
+ <tr>
228
+ <td class="num">{% if r.color_hex %}<span style="display:inline-block;width:0.75em;height:0.75em;border-radius:50%;background:{{ r.color_hex }};margin-right:0.35em;vertical-align:middle"></span>{% endif %}{{ r.position }}</td>
229
+ <td><strong>{{ r.mooring }}</strong></td>
230
+ <td class="num">{{ "%.4f"|format(r.lat) if r.lat is not none else "—" }}</td>
231
+ <td class="num">{{ "%.4f"|format(r.lon) if r.lon is not none else "—" }}</td>
232
+ <td class="num">{{ r.waterdepth if r.waterdepth else "—" }}</td>
233
+ <td>{{ r.deploy_time }}</td>
234
+ <td>{{ r.recover_time }}</td>
235
+ <td class="num">{{ r.duration }}</td>
236
+ <td class="num">{{ r.n_instruments }}</td>
237
+ <td>
238
+ {% if r.report_exists %}
239
+ <a class="btn btn-sum" href="{{ r.mooring }}/report/{{ r.mooring }}_report.html">Summary</a>
240
+ {% else %}
241
+ <span class="btn btn-miss">Summary</span>
242
+ {% endif %}
243
+ {% if r.stack_exists %}
244
+ <a class="btn btn-stk" href="{{ r.mooring }}/report/{{ r.mooring }}_stack_report.html">Stack</a>
245
+ {% endif %}
246
+ {% if r.grid_exists %}
247
+ <a class="btn btn-grd" href="{{ r.mooring }}/report/{{ r.mooring }}_grid_report.html">Grid</a>
248
+ {% endif %}
249
+ </td>
250
+ </tr>
251
+ {% endfor %}
252
+ </tbody>
253
+ </table>
254
+
255
+ </body>
256
+ </html>
257
+ """
258
+
259
+
260
+ # ---------------------------------------------------------------------------
261
+ # Public entry point
262
+ # ---------------------------------------------------------------------------
263
+
264
+
265
+ def generate_array_report(
266
+ array_yaml_path: Path,
267
+ proc_dir: Path,
268
+ out_path: Optional[Path] = None,
269
+ force: bool = False,
270
+ ) -> Optional[Path]:
271
+ """Generate an HTML array-level summary report from an array YAML file.
272
+
273
+ Parameters
274
+ ----------
275
+ array_yaml_path : Path
276
+ Path to the ``*.array.yaml`` file.
277
+ proc_dir : Path
278
+ Root processing directory. Each mooring directory is expected to be
279
+ ``proc_dir / mooring_id``.
280
+ out_path : Path, optional
281
+ Output HTML path. Defaults to
282
+ ``proc_dir / "{array_name}_array_report.html"``.
283
+ force : bool
284
+ Overwrite existing output file.
285
+
286
+ Returns
287
+ -------
288
+ Path or None
289
+ Path to the written HTML file, or None on failure.
290
+
291
+ """
292
+ from datetime import datetime, timezone
293
+
294
+ from jinja2 import Environment
295
+
296
+ array_yaml_path = Path(array_yaml_path)
297
+ proc_dir = Path(proc_dir)
298
+
299
+ if not array_yaml_path.exists():
300
+ print(f"ERROR: array YAML not found: {array_yaml_path}")
301
+ return None
302
+
303
+ with open(array_yaml_path) as fh:
304
+ array_cfg = yaml.safe_load(fh)
305
+
306
+ array_name = array_cfg.get("name", array_yaml_path.stem)
307
+
308
+ if out_path is None:
309
+ out_path = proc_dir / f"{array_name}_array_report.html"
310
+
311
+ if out_path.exists() and not force:
312
+ _status("skip", str(out_path))
313
+ return out_path
314
+
315
+ mooring_entries = array_cfg.get("moorings", [])
316
+ rows: List[Dict[str, Any]] = []
317
+
318
+ for pos_idx, entry in enumerate(mooring_entries, start=1):
319
+ mooring_id = entry.get("mooring", "")
320
+ config_name = entry.get("config", f"{mooring_id}.mooring.yaml")
321
+ mooring_yaml = proc_dir / mooring_id / config_name
322
+
323
+ mcfg: Dict[str, Any] = {}
324
+ if mooring_yaml.exists():
325
+ try:
326
+ with open(mooring_yaml) as fh:
327
+ mcfg = yaml.safe_load(fh) or {}
328
+ except Exception as exc: # noqa: BLE001
329
+ print(f"WARNING: could not read {mooring_yaml}: {exc}")
330
+ else:
331
+ print(f"WARNING: mooring YAML not found: {mooring_yaml}")
332
+
333
+ lat, lon = _lat_lon_from_cfg(mcfg)
334
+ deploy_dt = _parse_dt(mcfg.get("deployment_time"))
335
+ recover_dt = _parse_dt(mcfg.get("recovery_time"))
336
+
337
+ report_dir = proc_dir / mooring_id / "report"
338
+ rows.append(
339
+ {
340
+ "position": entry.get("position", str(pos_idx)),
341
+ "mooring": mooring_id,
342
+ "lat": lat,
343
+ "lon": lon,
344
+ "color_hex": None,
345
+ "waterdepth": mcfg.get("waterdepth"),
346
+ "deploy_time": deploy_dt.strftime("%Y-%m-%d %H:%M")
347
+ if deploy_dt
348
+ else "—",
349
+ "recover_time": recover_dt.strftime("%Y-%m-%d %H:%M")
350
+ if recover_dt
351
+ else "—",
352
+ "duration": _duration_str(deploy_dt, recover_dt),
353
+ "_deploy_dt": deploy_dt,
354
+ "_recover_dt": recover_dt,
355
+ "n_instruments": _count_instruments(mcfg),
356
+ "report_exists": (report_dir / f"{mooring_id}_report.html").exists(),
357
+ "stack_exists": (
358
+ report_dir / f"{mooring_id}_stack_report.html"
359
+ ).exists(),
360
+ "grid_exists": (report_dir / f"{mooring_id}_grid_report.html").exists(),
361
+ }
362
+ )
363
+
364
+ fig_map_b64 = _make_array_map_b64(rows, array_name)
365
+
366
+ deploy_dt_arr = _parse_dt(array_cfg.get("deployment_time"))
367
+ recover_dt_arr = _parse_dt(array_cfg.get("recovery_time"))
368
+
369
+ ctx = {
370
+ "array_name": array_name,
371
+ "year": array_cfg.get("year"),
372
+ "cruise": array_cfg.get("cruise"),
373
+ "ship": array_cfg.get("ship"),
374
+ "project": array_cfg.get("project"),
375
+ "deploy_time": deploy_dt_arr.strftime("%Y-%m-%d") if deploy_dt_arr else "",
376
+ "recover_time": recover_dt_arr.strftime("%Y-%m-%d") if recover_dt_arr else "",
377
+ "moorings": rows,
378
+ "fig_map_b64": fig_map_b64,
379
+ "generated": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
380
+ }
381
+
382
+ env = Environment(autoescape=True)
383
+ html = env.from_string(_ARRAY_HTML_TEMPLATE).render(**ctx)
384
+ out_path.write_text(html, encoding="utf-8")
385
+ _status("file", str(out_path))
386
+ return out_path