oceanarray 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- oceanarray/__init__.py +20 -0
- oceanarray/_version.py +1 -0
- oceanarray/cli.py +1343 -0
- oceanarray/clock_offset.py +675 -0
- oceanarray/config/OS1_sensor_attrs.yaml +49 -0
- oceanarray/config/OS1_var_names.yaml +5 -0
- oceanarray/config/OS1_vocab_attrs.yaml +84 -0
- oceanarray/config/legacy/project_RAPID.yaml +69 -0
- oceanarray/config/legacy/rodb_keys.txt +206 -0
- oceanarray/config/legacy/rodb_keys.yaml +1012 -0
- oceanarray/config/logging.yaml +28 -0
- oceanarray/legacy/__init__.py +47 -0
- oceanarray/legacy/caldip.py +753 -0
- oceanarray/legacy/convertOS.py +527 -0
- oceanarray/legacy/find_deployment.py +489 -0
- oceanarray/legacy/mooring_rodb.py +429 -0
- oceanarray/legacy/process_rodb.py +369 -0
- oceanarray/legacy/rodb.py +330 -0
- oceanarray/logger.py +192 -0
- oceanarray/logsheet/__init__.py +60 -0
- oceanarray/logsheet/_columns.py +97 -0
- oceanarray/logsheet/_config.py +245 -0
- oceanarray/logsheet/_firmware.py +43 -0
- oceanarray/logsheet/_latex.py +128 -0
- oceanarray/logsheet/_render.py +126 -0
- oceanarray/logsheet/builders/__init__.py +13 -0
- oceanarray/logsheet/builders/caldip_download.py +195 -0
- oceanarray/logsheet/builders/caldip_setup.py +188 -0
- oceanarray/logsheet/builders/deployment_setup.py +218 -0
- oceanarray/logsheet/builders/mooring_download.py +243 -0
- oceanarray/logsheet/builders/recovery.py +157 -0
- oceanarray/logsheet/data/column_defs.yaml +686 -0
- oceanarray/logsheet/data/latex_templates/_base_instrument.tex.j2 +42 -0
- oceanarray/logsheet/data/latex_templates/caldip_download.tex.j2 +47 -0
- oceanarray/logsheet/data/latex_templates/caldip_setup.tex.j2 +70 -0
- oceanarray/logsheet/data/latex_templates/deployment_setup.tex.j2 +97 -0
- oceanarray/logsheet/data/latex_templates/mooring_download.tex.j2 +54 -0
- oceanarray/logsheet/data/latex_templates/recovery.tex.j2 +103 -0
- oceanarray/mooring_level.py +1368 -0
- oceanarray/oceanarray.mplstyle +18 -0
- oceanarray/parameters.py +270 -0
- oceanarray/plotter.py +942 -0
- oceanarray/plotters/__init__.py +97 -0
- oceanarray/plotters/_animation.py +261 -0
- oceanarray/plotters/_current.py +705 -0
- oceanarray/plotters/_diagnostic.py +667 -0
- oceanarray/plotters/_helpers.py +57 -0
- oceanarray/plotters/_primitives.py +101 -0
- oceanarray/plotters/_section.py +16 -0
- oceanarray/plotters/_timeseries.py +11 -0
- oceanarray/rapid_interp.py +739 -0
- oceanarray/read_rbr_hex.py +541 -0
- oceanarray/readers.py +303 -0
- oceanarray/report/__init__.py +5 -0
- oceanarray/report/_array.py +386 -0
- oceanarray/report/_grid.py +539 -0
- oceanarray/report/_html_helpers.py +1004 -0
- oceanarray/report/_instrument.py +853 -0
- oceanarray/report/_mooring.py +1385 -0
- oceanarray/report/_plots.py +4284 -0
- oceanarray/report/_stack.py +941 -0
- oceanarray/stage1.py +1959 -0
- oceanarray/stage2.py +1160 -0
- oceanarray/stage3.py +2494 -0
- oceanarray/time_gridding.py +958 -0
- oceanarray/tools.py +400 -0
- oceanarray/transports.py +1 -0
- oceanarray/utilities.py +538 -0
- oceanarray/validation.py +389 -0
- oceanarray/writers.py +99 -0
- oceanarray-0.1.0.dist-info/METADATA +503 -0
- oceanarray-0.1.0.dist-info/RECORD +76 -0
- oceanarray-0.1.0.dist-info/WHEEL +5 -0
- oceanarray-0.1.0.dist-info/entry_points.txt +2 -0
- oceanarray-0.1.0.dist-info/licenses/LICENSE +21 -0
- oceanarray-0.1.0.dist-info/top_level.txt +1 -0
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,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 – {{ 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 • 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 (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
|