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
|
@@ -0,0 +1,1385 @@
|
|
|
1
|
+
"""Mooring summary HTML template and MooringReport orchestrator class."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import socket
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
import yaml
|
|
12
|
+
|
|
13
|
+
from ._html_helpers import (
|
|
14
|
+
_check_readable,
|
|
15
|
+
_duration_str,
|
|
16
|
+
_find_array_report_href,
|
|
17
|
+
_fmt_dt,
|
|
18
|
+
_get_proc_dir,
|
|
19
|
+
_load_pdf_b64,
|
|
20
|
+
_instrument_report_exists,
|
|
21
|
+
_nav_buttons_html,
|
|
22
|
+
_parse_dt,
|
|
23
|
+
_raw_file_path,
|
|
24
|
+
_read_instrument_info,
|
|
25
|
+
_read_qc_summary,
|
|
26
|
+
_read_sensor_info,
|
|
27
|
+
_read_timing_info,
|
|
28
|
+
_resolve_clock,
|
|
29
|
+
_safe_serial,
|
|
30
|
+
_stage_files,
|
|
31
|
+
_status,
|
|
32
|
+
)
|
|
33
|
+
from ._grid import generate_grid_page
|
|
34
|
+
from ._instrument import generate_instrument_pages
|
|
35
|
+
from ._plots import (
|
|
36
|
+
_make_clock_check_b64,
|
|
37
|
+
_make_knockdown_displacement_b64,
|
|
38
|
+
_make_knockdown_hab_b64,
|
|
39
|
+
_make_knockdown_anomaly_b64,
|
|
40
|
+
)
|
|
41
|
+
from ._stack import generate_stack_page
|
|
42
|
+
from ..utilities import extract_inline_instruments
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# Mooring summary HTML template
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
_HTML_TEMPLATE = """\
|
|
50
|
+
<!DOCTYPE html>
|
|
51
|
+
<html lang="en">
|
|
52
|
+
<head>
|
|
53
|
+
<meta charset="UTF-8">
|
|
54
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
55
|
+
<title>Mooring Recovery Report – {{ mooring_name }}</title>
|
|
56
|
+
<style>
|
|
57
|
+
:root {
|
|
58
|
+
--ocean: #1a3a5c;
|
|
59
|
+
--seafoam: #e8f4f8;
|
|
60
|
+
--good: #27ae60;
|
|
61
|
+
--warn: #e67e22;
|
|
62
|
+
--bad: #c0392b;
|
|
63
|
+
--interp: #2980b9;
|
|
64
|
+
--muted: #95a5a6;
|
|
65
|
+
--text: #2c3e50;
|
|
66
|
+
}
|
|
67
|
+
* { box-sizing: border-box; }
|
|
68
|
+
body {
|
|
69
|
+
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
70
|
+
font-size: 14px;
|
|
71
|
+
color: var(--text);
|
|
72
|
+
max-width: 1150px;
|
|
73
|
+
margin: 0 auto;
|
|
74
|
+
padding: 1.5rem 2rem 4rem;
|
|
75
|
+
line-height: 1.5;
|
|
76
|
+
}
|
|
77
|
+
/* masthead */
|
|
78
|
+
.masthead {
|
|
79
|
+
background: var(--ocean);
|
|
80
|
+
color: #fff;
|
|
81
|
+
padding: 1.6rem 2rem;
|
|
82
|
+
border-radius: 8px;
|
|
83
|
+
margin-bottom: 2.5rem;
|
|
84
|
+
}
|
|
85
|
+
.masthead h1 { margin: 0 0 0.3rem; font-size: 1.75rem; font-weight: 700; letter-spacing: 0.02em; }
|
|
86
|
+
.masthead .sub { font-size: 0.9rem; opacity: 0.82; margin: 0 0 1rem; }
|
|
87
|
+
.meta-grid {
|
|
88
|
+
display: grid;
|
|
89
|
+
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
|
90
|
+
gap: 0.5rem 2rem;
|
|
91
|
+
font-size: 0.84rem;
|
|
92
|
+
}
|
|
93
|
+
.meta-grid dt { opacity: 0.68; text-transform: uppercase; font-size: 0.7rem; letter-spacing: 0.06em; margin-bottom: 0.1rem; }
|
|
94
|
+
.meta-grid dd { margin: 0; font-weight: 600; }
|
|
95
|
+
.meta-miss dd { color: #e67e22; opacity: 1; }
|
|
96
|
+
/* section headings */
|
|
97
|
+
h2 {
|
|
98
|
+
color: var(--ocean);
|
|
99
|
+
font-size: 1.05rem;
|
|
100
|
+
border-bottom: 2px solid var(--seafoam);
|
|
101
|
+
padding-bottom: 0.3rem;
|
|
102
|
+
margin: 2.5rem 0 1rem;
|
|
103
|
+
display: flex;
|
|
104
|
+
justify-content: space-between;
|
|
105
|
+
align-items: baseline;
|
|
106
|
+
}
|
|
107
|
+
.top-link { font-size: 0.72rem; font-weight: 400; color: var(--muted);
|
|
108
|
+
text-decoration: none; margin-left: auto; white-space: nowrap; }
|
|
109
|
+
.top-link:hover { color: var(--ocean); text-decoration: underline; }
|
|
110
|
+
/* tables */
|
|
111
|
+
table { width: 100%; border-collapse: collapse; font-size: 0.83rem; }
|
|
112
|
+
th {
|
|
113
|
+
background: var(--ocean);
|
|
114
|
+
color: #fff;
|
|
115
|
+
padding: 0.45rem 0.7rem;
|
|
116
|
+
text-align: left;
|
|
117
|
+
font-weight: 600;
|
|
118
|
+
white-space: nowrap;
|
|
119
|
+
}
|
|
120
|
+
td { padding: 0.38rem 0.7rem; border-bottom: 1px solid #ecf0f1; vertical-align: middle; }
|
|
121
|
+
tr:nth-child(even) td { background: var(--seafoam); }
|
|
122
|
+
tr:hover td { background: #d6eaf8; }
|
|
123
|
+
td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
|
124
|
+
/* pipeline badges */
|
|
125
|
+
.pipeline { white-space: nowrap; display: flex; flex-wrap: wrap; gap: 0.2rem; align-items: center; }
|
|
126
|
+
.badge {
|
|
127
|
+
display: inline-block;
|
|
128
|
+
padding: 0.15em 0.5em;
|
|
129
|
+
border-radius: 3px;
|
|
130
|
+
font-size: 0.73rem;
|
|
131
|
+
font-weight: 700;
|
|
132
|
+
white-space: nowrap;
|
|
133
|
+
}
|
|
134
|
+
.b-ok { background: var(--good); color: #fff; }
|
|
135
|
+
.b-warn { background: var(--warn); color: #fff; }
|
|
136
|
+
.b-miss { background: #dfe6e9; color: #999; }
|
|
137
|
+
.b-stack { background: var(--interp); color: #fff; }
|
|
138
|
+
.b-grid { background: #8e44ad; color: #fff; }
|
|
139
|
+
.arrow { color: #ccc; font-size: 0.8rem; margin: 0 0.05rem; }
|
|
140
|
+
/* clock table */
|
|
141
|
+
.none-note { color: var(--muted); font-style: italic; }
|
|
142
|
+
.pos { color: var(--warn); font-weight: 600; }
|
|
143
|
+
.neg { color: var(--interp); font-weight: 600; }
|
|
144
|
+
/* early-stoppage highlight — must override stripe and hover */
|
|
145
|
+
tr.row-warn td { background: #fef3cd !important; }
|
|
146
|
+
/* footer */
|
|
147
|
+
.report-footer {
|
|
148
|
+
margin-top: 3rem;
|
|
149
|
+
font-size: 0.76rem;
|
|
150
|
+
color: var(--muted);
|
|
151
|
+
border-top: 1px solid #ecf0f1;
|
|
152
|
+
padding-top: 0.75rem;
|
|
153
|
+
}
|
|
154
|
+
.jump-nav { background:var(--seafoam); padding:0.55rem 1rem; border-radius:6px;
|
|
155
|
+
margin-bottom:1.5rem; font-size:0.8rem; line-height:2.2; }
|
|
156
|
+
.jump-nav a { color:var(--ocean); text-decoration:none; font-weight:600;
|
|
157
|
+
margin:0 0.5rem 0 0; white-space:nowrap; }
|
|
158
|
+
.jump-nav a::before { content:"▸ "; font-size:0.7rem; }
|
|
159
|
+
.jump-nav a:hover { text-decoration:underline; }
|
|
160
|
+
@media print {
|
|
161
|
+
body { padding: 0; max-width: 100%; }
|
|
162
|
+
.masthead, th { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
|
163
|
+
h2 { page-break-after: avoid; }
|
|
164
|
+
table { page-break-inside: avoid; }
|
|
165
|
+
}
|
|
166
|
+
</style>
|
|
167
|
+
</head>
|
|
168
|
+
<body>
|
|
169
|
+
|
|
170
|
+
<!-- ══════════════════════════════════════════════ 1. HEADER ══ -->
|
|
171
|
+
<div id="top" class="masthead">
|
|
172
|
+
<h1 style="display:flex;justify-content:space-between;align-items:baseline;gap:1rem"><span>{{ mooring_name }}</span><span style="font-size:1.2rem;opacity:0.8;white-space:nowrap">Summary</span></h1>
|
|
173
|
+
<p class="sub" style="display:flex;justify-content:space-between"><span>Mooring recovery report</span><span>generated {{ generated }}</span></p>
|
|
174
|
+
{{ nav_buttons | safe }}
|
|
175
|
+
<dl class="meta-grid">
|
|
176
|
+
<div{% if cruise == '—' %} class="meta-miss"{% endif %}><dt>Cruise</dt><dd>{{ cruise }}</dd></div>
|
|
177
|
+
<div{% if ship == '—' %} class="meta-miss"{% endif %}><dt>Ship</dt><dd>{{ ship }}</dd></div>
|
|
178
|
+
<div{% if latitude == '—' %} class="meta-miss"{% endif %}><dt>Latitude</dt><dd>{{ latitude }}</dd></div>
|
|
179
|
+
<div{% if longitude == '—' %} class="meta-miss"{% endif %}><dt>Longitude</dt><dd>{{ longitude }}</dd></div>
|
|
180
|
+
<div{% if waterdepth == '—' %} class="meta-miss"{% endif %}><dt>Water depth</dt><dd>{{ waterdepth }}{% if waterdepth != '—' %} m{% endif %}</dd></div>
|
|
181
|
+
<div{% if deploy_time == '—' %} class="meta-miss"{% endif %}><dt>Deployment</dt><dd>{{ deploy_time }}</dd></div>
|
|
182
|
+
<div{% if recover_time == '—' %} class="meta-miss"{% endif %}><dt>Recovery</dt><dd>{{ recover_time }}</dd></div>
|
|
183
|
+
<div{% if duration == '—' %} class="meta-miss"{% endif %}><dt>Duration</dt><dd>{{ duration }}</dd></div>
|
|
184
|
+
<div><dt>Instruments</dt><dd>{{ n_instruments }}</dd></div>
|
|
185
|
+
</dl>
|
|
186
|
+
</div>
|
|
187
|
+
|
|
188
|
+
<nav class="jump-nav">
|
|
189
|
+
Jump to:
|
|
190
|
+
<a href="#pipeline">Processing pipeline</a>
|
|
191
|
+
<a href="#instruments">Instruments</a>
|
|
192
|
+
<a href="#timing">Deployment timing</a>
|
|
193
|
+
<a href="#clock">Clock corrections</a>
|
|
194
|
+
<a href="#calibration">Sensor calibration</a>
|
|
195
|
+
<a href="#qc">QC summary</a>
|
|
196
|
+
{% if fig_knockdown_hab_b64 or fig_knockdown_anomaly_b64 %}<a href="#knockdown">Knockdown</a>{% endif %}
|
|
197
|
+
{% if diagram_b64 %}<a href="#diagram">Mooring diagram</a>{% endif %}
|
|
198
|
+
</nav>
|
|
199
|
+
|
|
200
|
+
<!-- ══════════════════════════════════ 2. PROCESSING PIPELINE ══ -->
|
|
201
|
+
<h2 id="pipeline">2 — Processing pipeline</h2>
|
|
202
|
+
<p style="font-size:0.82rem;color:#555;margin-top:-0.5rem;">
|
|
203
|
+
Raw = file present in raw directory •
|
|
204
|
+
Read = format check passed •
|
|
205
|
+
Stage 1–3 = processed NetCDF files exist •
|
|
206
|
+
Stack = mooring-level <code>_stack.nc</code> •
|
|
207
|
+
Grid = pressure-gridded <code>_grid.nc</code>
|
|
208
|
+
</p>
|
|
209
|
+
<p style="font-size:0.82rem;color:#555;margin-top:-0.3rem;">
|
|
210
|
+
Instruments marked <code>skip: true</code> in the YAML are excluded from all
|
|
211
|
+
processing and are shown with a <em>skipped</em> note in the Note column.
|
|
212
|
+
Stack and Grid pills are grey for any instrument that did not reach Stage 3.
|
|
213
|
+
See <a href="#instruments">Table 3</a> for skip reasons where given.
|
|
214
|
+
</p>
|
|
215
|
+
<table>
|
|
216
|
+
<thead>
|
|
217
|
+
<tr>
|
|
218
|
+
<th>#</th>
|
|
219
|
+
<th>Type</th>
|
|
220
|
+
<th>S/N</th>
|
|
221
|
+
<th>Hab (m)</th>
|
|
222
|
+
<th>Depth (m)</th>
|
|
223
|
+
<th>Pipeline</th>
|
|
224
|
+
<th>Note</th>
|
|
225
|
+
</tr>
|
|
226
|
+
</thead>
|
|
227
|
+
<tbody>
|
|
228
|
+
{% for instr in instruments %}
|
|
229
|
+
<tr>
|
|
230
|
+
<td class="num">{{ loop.index }}</td>
|
|
231
|
+
<td>{{ instr.instr_type }}</td>
|
|
232
|
+
<td>{% if instr.report_exists %}<a href="instrument/{{ mooring_name }}_{{ instr.serial }}_report.html"
|
|
233
|
+
style="font-family:monospace;font-size:0.85rem">{{ instr.serial }}</a>{% else %}<span style="font-family:monospace;font-size:0.85rem">{{ instr.serial }}</span>{% endif %}</td>
|
|
234
|
+
<td class="num">{{ "%.1f"|format(instr.hab) }}</td>
|
|
235
|
+
<td class="num">{{ "%.0f"|format(instr.depth) if instr.depth is not none else "—" }}</td>
|
|
236
|
+
<td>
|
|
237
|
+
<div class="pipeline">
|
|
238
|
+
{# raw file #}
|
|
239
|
+
{% if instr.raw_exists %}
|
|
240
|
+
<span class="badge b-ok" title="{{ instr.raw_path }}">Raw ✓</span>
|
|
241
|
+
{% elif instr.filename %}
|
|
242
|
+
<span class="badge b-warn" title="Expected: {{ instr.raw_path }}">Raw ✗</span>
|
|
243
|
+
{% else %}
|
|
244
|
+
<span class="badge b-miss">Raw —</span>
|
|
245
|
+
{% endif %}
|
|
246
|
+
<span class="arrow">›</span>
|
|
247
|
+
{# readability check #}
|
|
248
|
+
{% if instr.raw_exists %}
|
|
249
|
+
{% if instr.readable %}
|
|
250
|
+
<span class="badge b-ok" title="{{ instr.readable_note }}">Read ✓</span>
|
|
251
|
+
{% else %}
|
|
252
|
+
<span class="badge b-warn" title="{{ instr.readable_note }}">Read ✗</span>
|
|
253
|
+
{% endif %}
|
|
254
|
+
{% else %}
|
|
255
|
+
<span class="badge b-miss">Read —</span>
|
|
256
|
+
{% endif %}
|
|
257
|
+
<span class="arrow">›</span>
|
|
258
|
+
{# stage 1 #}
|
|
259
|
+
{% if instr.stages.stage1 %}
|
|
260
|
+
<span class="badge b-ok">Stage 1 ✓</span>
|
|
261
|
+
{% else %}
|
|
262
|
+
<span class="badge b-miss">Stage 1 ○</span>
|
|
263
|
+
{% endif %}
|
|
264
|
+
<span class="arrow">›</span>
|
|
265
|
+
{# stage 2 #}
|
|
266
|
+
{% if instr.stages.stage2 %}
|
|
267
|
+
<span class="badge b-ok">Stage 2 ✓</span>
|
|
268
|
+
{% else %}
|
|
269
|
+
<span class="badge b-miss">Stage 2 ○</span>
|
|
270
|
+
{% endif %}
|
|
271
|
+
<span class="arrow">›</span>
|
|
272
|
+
{# stage 3 #}
|
|
273
|
+
{% if instr.stages.stage3 %}
|
|
274
|
+
<span class="badge b-ok">Stage 3 ✓</span>
|
|
275
|
+
{% else %}
|
|
276
|
+
<span class="badge b-miss">Stage 3 ○</span>
|
|
277
|
+
{% endif %}
|
|
278
|
+
<span class="arrow">›</span>
|
|
279
|
+
{# stack — only coloured if this instrument is in the stack file #}
|
|
280
|
+
{% if instr.in_stack %}
|
|
281
|
+
<span class="badge b-stack">Stack ✓</span>
|
|
282
|
+
{% else %}
|
|
283
|
+
<span class="badge b-miss">Stack ○</span>
|
|
284
|
+
{% endif %}
|
|
285
|
+
<span class="arrow">›</span>
|
|
286
|
+
{# grid — only coloured if this instrument is in the grid file #}
|
|
287
|
+
{% if instr.in_grid %}
|
|
288
|
+
<span class="badge b-grid">Grid ✓</span>
|
|
289
|
+
{% else %}
|
|
290
|
+
<span class="badge b-miss">Grid ○</span>
|
|
291
|
+
{% endif %}
|
|
292
|
+
</div>
|
|
293
|
+
</td>
|
|
294
|
+
<td style="font-size:0.8rem;color:#c0392b;">
|
|
295
|
+
{% if instr.skipped %}skipped{% endif %}
|
|
296
|
+
</td>
|
|
297
|
+
</tr>
|
|
298
|
+
{% endfor %}
|
|
299
|
+
</tbody>
|
|
300
|
+
</table>
|
|
301
|
+
|
|
302
|
+
<!-- ════════════════════════════════════ 3. INSTRUMENT SUMMARY ══ -->
|
|
303
|
+
<h2 id="instruments">3 — Instrument summary</h2>
|
|
304
|
+
<p style="font-size:0.82rem;color:#555;margin-top:-0.5rem;">
|
|
305
|
+
Variables and record length are read from stage 3 files where available,
|
|
306
|
+
otherwise stage 2. The <strong>P</strong> badge is shown as present
|
|
307
|
+
whether pressure was directly measured or interpolated from neighbouring
|
|
308
|
+
instruments — see Table 6 (QC flag summary) to distinguish. Instruments
|
|
309
|
+
marked <code>skip: true</code> are listed with the skip reason where provided.
|
|
310
|
+
</p>
|
|
311
|
+
<style>
|
|
312
|
+
.vbadge {
|
|
313
|
+
display: inline-block;
|
|
314
|
+
padding: 0.12em 0.42em;
|
|
315
|
+
border-radius: 3px;
|
|
316
|
+
font-size: 0.72rem;
|
|
317
|
+
font-weight: 700;
|
|
318
|
+
}
|
|
319
|
+
.vb-yes { background: #27ae60; color: #fff; }
|
|
320
|
+
.vb-interp { background: #2980b9; color: #fff; }
|
|
321
|
+
.vb-no { background: #ecf0f1; color: #aaa; }
|
|
322
|
+
</style>
|
|
323
|
+
<table>
|
|
324
|
+
<thead>
|
|
325
|
+
<tr>
|
|
326
|
+
<th>#</th>
|
|
327
|
+
<th>Type</th>
|
|
328
|
+
<th>S/N</th>
|
|
329
|
+
<th>Hab (m)</th>
|
|
330
|
+
<th>First sample</th>
|
|
331
|
+
<th>Last sample</th>
|
|
332
|
+
<th>N records</th>
|
|
333
|
+
<th title="Observed p90 sample interval (s); * = differs from YAML value by >10%">Δt (s)</th>
|
|
334
|
+
<th title="Pressure range from stage-2/3 trimmed record, excluding non-positive values">P range (dbar)</th>
|
|
335
|
+
<th style="text-align:center">T</th>
|
|
336
|
+
<th style="text-align:center">C</th>
|
|
337
|
+
<th style="text-align:center">P</th>
|
|
338
|
+
<th style="text-align:center">U</th>
|
|
339
|
+
<th style="text-align:center">V</th>
|
|
340
|
+
</tr>
|
|
341
|
+
</thead>
|
|
342
|
+
<tbody>
|
|
343
|
+
{% for instr in instruments %}
|
|
344
|
+
<tr{% if instr.stopped_early %} class="row-warn"{% endif %}>
|
|
345
|
+
<td class="num">{{ loop.index }}</td>
|
|
346
|
+
<td>{{ instr.instr_type }}</td>
|
|
347
|
+
<td>{% if instr.report_exists %}<a href="instrument/{{ mooring_name }}_{{ instr.serial }}_report.html"
|
|
348
|
+
style="font-family:monospace;font-size:0.85rem">{{ instr.serial }}</a>{% else %}<span style="font-family:monospace;font-size:0.85rem">{{ instr.serial }}</span>{% endif %}</td>
|
|
349
|
+
<td class="num">{{ "%.1f"|format(instr.hab) }}</td>
|
|
350
|
+
{% if instr.nc and instr.nc.get("error") %}
|
|
351
|
+
<td colspan="10" style="color:var(--bad);font-size:0.8rem;">Error: {{ instr.nc.error }}</td>
|
|
352
|
+
{% elif instr.nc and instr.nc.get("t_start") %}
|
|
353
|
+
<td>{{ instr.nc.t_start }}</td>
|
|
354
|
+
<td>{{ instr.nc.t_end }}</td>
|
|
355
|
+
<td class="num">{{ "{:,}".format(instr.nc.n_records) }}</td>
|
|
356
|
+
<td class="num">
|
|
357
|
+
{% set dt = instr.nc.dt_s %}
|
|
358
|
+
{% if dt == dt %}{# NaN check: NaN != NaN #}
|
|
359
|
+
{{ "%.0f"|format(dt) }}{% if instr.dt_mismatch %}<span title="YAML: {{ instr.yaml_interval_s }} s" style="color:#e67e22;font-weight:bold"> *</span>{% endif %}
|
|
360
|
+
{% else %}
|
|
361
|
+
<span class="none-note">—</span>
|
|
362
|
+
{% endif %}
|
|
363
|
+
</td>
|
|
364
|
+
<td class="num">
|
|
365
|
+
{% if instr.nc.p_min is not none and instr.nc.p_max is not none %}
|
|
366
|
+
{% if instr.nc.get("pressure_interpolated") %}
|
|
367
|
+
<em title="pressure interpolated">({{ "%.0f"|format(instr.nc.p_min) }}, {{ "%.0f"|format(instr.nc.p_max) }})</em>
|
|
368
|
+
{% else %}
|
|
369
|
+
({{ "%.0f"|format(instr.nc.p_min) }}, {{ "%.0f"|format(instr.nc.p_max) }})
|
|
370
|
+
{% endif %}
|
|
371
|
+
{% else %}
|
|
372
|
+
<span class="none-note">—</span>
|
|
373
|
+
{% endif %}
|
|
374
|
+
</td>
|
|
375
|
+
{% for label, present in instr.nc.shorthands %}
|
|
376
|
+
<td style="text-align:center">
|
|
377
|
+
{% if label == "P" and present and instr.nc.get("pressure_interpolated") %}
|
|
378
|
+
<span class="vbadge vb-interp" title="pressure interpolated from neighbouring instruments">{{ label }}</span>
|
|
379
|
+
{% else %}
|
|
380
|
+
<span class="vbadge {{ 'vb-yes' if present else 'vb-no' }}">{{ label }}</span>
|
|
381
|
+
{% endif %}
|
|
382
|
+
</td>
|
|
383
|
+
{% endfor %}
|
|
384
|
+
{% elif instr.skipped %}
|
|
385
|
+
<td colspan="10" class="none-note">skipped{% if instr.skip_reason %} — {{ instr.skip_reason }}{% endif %}</td>
|
|
386
|
+
{% else %}
|
|
387
|
+
<td colspan="10" class="none-note">no processed file</td>
|
|
388
|
+
{% endif %}
|
|
389
|
+
</tr>
|
|
390
|
+
{% endfor %}
|
|
391
|
+
</tbody>
|
|
392
|
+
</table>
|
|
393
|
+
|
|
394
|
+
{% set dt_mismatches = instruments | selectattr("dt_mismatch") | list %}
|
|
395
|
+
{% if dt_mismatches %}
|
|
396
|
+
<p style="font-size:0.82rem;color:#555;margin-top:0.4rem;">
|
|
397
|
+
<span style="color:#e67e22;font-weight:bold">*</span> Δt mismatch (YAML vs observed p90):
|
|
398
|
+
{% for instr in dt_mismatches %}
|
|
399
|
+
<br> <span style="font-family:monospace">{{ instr.serial }}</span>:
|
|
400
|
+
YAML gives Δt of {{ instr.yaml_interval_s }} s;
|
|
401
|
+
stage file shows Δt of {{ "%.0f"|format(instr.nc.dt_s) }} s.
|
|
402
|
+
{% endfor %}
|
|
403
|
+
</p>
|
|
404
|
+
{% endif %}
|
|
405
|
+
|
|
406
|
+
{% if grid_p_start is not none and grid_p_end is not none %}
|
|
407
|
+
<p style="font-size:0.82rem;color:#555;margin-top:0.8rem;">
|
|
408
|
+
Recommended pressure range for <code>oceanarray grid</code>, derived from the
|
|
409
|
+
min/max pressure across all instruments (rounded outward to the nearest 20 dbar):
|
|
410
|
+
</p>
|
|
411
|
+
<pre style="background:#f4f4f4;border:1px solid #ddd;border-radius:4px;padding:0.5em 0.8em;font-size:0.85rem;display:inline-block;user-select:all;cursor:text">--pmin {{ grid_p_start }} --pmax {{ grid_p_end }}</pre>
|
|
412
|
+
{% endif %}
|
|
413
|
+
|
|
414
|
+
<!-- ══════════════════════════════════════ 3.5 DEPLOYMENT TIMING ══ -->
|
|
415
|
+
<h2 id="timing">3.5 — Deployment timing</h2>
|
|
416
|
+
<p style="font-size:0.82rem;color:#555;margin-top:-0.5rem;">
|
|
417
|
+
<em>Stage 1</em> and <em>Sugg. (raw)</em> columns are in the <strong>raw
|
|
418
|
+
instrument clock</strong> (uncorrected). <em>Sugg. UTC</em> columns
|
|
419
|
+
apply the constant clock offset (and drift, for the end) and are safe to
|
|
420
|
+
paste into the YAML <code>deployment_time</code> /
|
|
421
|
+
<code>recovery_time</code> fields. Suggested UTC cells are highlighted
|
|
422
|
+
<span style="background:#fff3cd;padding:0 0.3em">amber</span> when the
|
|
423
|
+
pressure-derived suggested time differs from the YAML time by more than
|
|
424
|
+
2 Δt (indicating a sinking/rising transient was detected).
|
|
425
|
+
The Stage 1 last cell is highlighted
|
|
426
|
+
<span style="background:#fde3c0;padding:0 0.3em">orange</span> when the
|
|
427
|
+
raw record ended more than 2 Δt before the YAML recovery time
|
|
428
|
+
(instrument may have stopped early).
|
|
429
|
+
Serial numbers link to the per-instrument report; the
|
|
430
|
+
<em>6 h</em> link jumps directly to the start/end window plots.
|
|
431
|
+
</p>
|
|
432
|
+
<p style="font-size:0.85rem;background:#fffbe6;border-left:3px solid #e67e22;padding:0.5em 0.8em;margin:0.6rem 0">
|
|
433
|
+
<strong>Spot-check recommended:</strong> open the 6 h start/end
|
|
434
|
+
window plots for each instrument (click <em>6 h</em>) and visually
|
|
435
|
+
confirm that the orange suggested line (or green YAML line if they match)
|
|
436
|
+
sits at a plausible transition in the pressure (or temperature) record
|
|
437
|
+
before updating the YAML times.
|
|
438
|
+
</p>
|
|
439
|
+
<div style="overflow-x:auto">
|
|
440
|
+
<table style="font-size:0.8rem;white-space:nowrap">
|
|
441
|
+
<thead>
|
|
442
|
+
<tr>
|
|
443
|
+
<th rowspan="2">#</th>
|
|
444
|
+
<th rowspan="2">Type</th>
|
|
445
|
+
<th rowspan="2">S/N</th>
|
|
446
|
+
<th colspan="3" style="text-align:center;border-bottom:1px solid #bbb">Start</th>
|
|
447
|
+
<th colspan="3" style="text-align:center;border-bottom:1px solid #bbb">End</th>
|
|
448
|
+
</tr>
|
|
449
|
+
<tr>
|
|
450
|
+
<th>Stage1 first</th>
|
|
451
|
+
<th>Sugg. start (raw)</th>
|
|
452
|
+
<th>Sugg. start (UTC)</th>
|
|
453
|
+
<th>Stage1 last</th>
|
|
454
|
+
<th>Sugg. end (raw)</th>
|
|
455
|
+
<th>Sugg. end (UTC)</th>
|
|
456
|
+
</tr>
|
|
457
|
+
</thead>
|
|
458
|
+
<tbody>
|
|
459
|
+
{% for instr in instruments %}
|
|
460
|
+
{% set tm = instr.timing %}
|
|
461
|
+
<tr>
|
|
462
|
+
<td class="num">{{ loop.index }}</td>
|
|
463
|
+
<td>{{ instr.instr_type }}</td>
|
|
464
|
+
<td>
|
|
465
|
+
<a href="instrument/{{ mooring_name }}_{{ instr.serial }}_report.html"
|
|
466
|
+
title="Per-instrument report"><code>{{ instr.serial }}</code></a>
|
|
467
|
+
<a href="instrument/{{ mooring_name }}_{{ instr.serial }}_report.html#start"
|
|
468
|
+
style="font-size:0.75rem;margin-left:0.3em;color:#2980b9"
|
|
469
|
+
title="Start/end 6 h windows">6 h</a>
|
|
470
|
+
</td>
|
|
471
|
+
{% if tm %}
|
|
472
|
+
{# Anchor dates for deduplication: show only time when on the same day #}
|
|
473
|
+
{% set d_start = (tm.get("stage1_start") or "")[:10] %}
|
|
474
|
+
{% set d_end = (tm.get("stage1_end") or "")[:10] %}
|
|
475
|
+
{# Stage1 first — always full date+time; anchors deduplication for start columns #}
|
|
476
|
+
<td>{{ tm.get("stage1_start") or "—" }}</td>
|
|
477
|
+
{# Sugg. start (raw) — only present for pressure instruments; time only when same date #}
|
|
478
|
+
{% set v = tm.get("sugg_start") or "" %}
|
|
479
|
+
<td{% if tm.get("sugg_start_differs") %} style="background:#fff3cd"{% endif %}>
|
|
480
|
+
{{- v[11:] if (v and v[:10] == d_start) else (v or "—") -}}
|
|
481
|
+
</td>
|
|
482
|
+
{# Sugg. start (UTC) #}
|
|
483
|
+
{% set v = tm.get("sugg_start_utc") or "" %}
|
|
484
|
+
<td{% if tm.get("sugg_start_differs") %} style="background:#fff3cd"{% endif %}>
|
|
485
|
+
{{- v[11:] if (v and v[:10] == d_start) else (v or "—") -}}
|
|
486
|
+
</td>
|
|
487
|
+
{# Stage1 last — orange-amber when instrument stopped early #}
|
|
488
|
+
<td{% if tm.get("stage1_end_early") %} style="background:#fde3c0"{% endif %}>
|
|
489
|
+
{{- tm.get("stage1_end") or "—" -}}
|
|
490
|
+
</td>
|
|
491
|
+
{# Sugg. end (raw) #}
|
|
492
|
+
{% set v = tm.get("sugg_end") or "" %}
|
|
493
|
+
<td{% if tm.get("sugg_end_differs") %} style="background:#fff3cd"{% endif %}>
|
|
494
|
+
{{- v[11:] if (v and v[:10] == d_end) else (v or "—") -}}
|
|
495
|
+
</td>
|
|
496
|
+
{# Sugg. end (UTC) #}
|
|
497
|
+
{% set v = tm.get("sugg_end_utc") or "" %}
|
|
498
|
+
<td{% if tm.get("sugg_end_differs") %} style="background:#fff3cd"{% endif %}>
|
|
499
|
+
{{- v[11:] if (v and v[:10] == d_end) else (v or "—") -}}
|
|
500
|
+
</td>
|
|
501
|
+
{% elif instr.skipped %}
|
|
502
|
+
<td colspan="6" class="none-note">skipped</td>
|
|
503
|
+
{% else %}
|
|
504
|
+
<td colspan="6" class="none-note">no stage 2 file</td>
|
|
505
|
+
{% endif %}
|
|
506
|
+
</tr>
|
|
507
|
+
{% endfor %}
|
|
508
|
+
</tbody>
|
|
509
|
+
{% if rec_deploy_sec or rec_recover_sec %}
|
|
510
|
+
<tfoot>
|
|
511
|
+
<tr style="background:#e8f4f8;font-weight:600;border-top:2px solid #1a3a5c">
|
|
512
|
+
<td class="num">★</td>
|
|
513
|
+
<td colspan="2" style="font-style:italic">Mooring</td>
|
|
514
|
+
<td><span class="none-note">—</span></td>
|
|
515
|
+
<td style="font-style:italic">Mooring start</td>
|
|
516
|
+
<td>{{ rec_deploy_sec or "—" }}</td>
|
|
517
|
+
<td><span class="none-note">—</span></td>
|
|
518
|
+
<td style="font-style:italic">Mooring end</td>
|
|
519
|
+
<td>{{ rec_recover_sec or "—" }}</td>
|
|
520
|
+
</tr>
|
|
521
|
+
</tfoot>
|
|
522
|
+
{% endif %}
|
|
523
|
+
</table>
|
|
524
|
+
</div>
|
|
525
|
+
|
|
526
|
+
{% if yaml_deploy_time or yaml_recover_time or rec_deploy or rec_recover %}
|
|
527
|
+
<div style="margin:1.2rem 0 0.5rem">
|
|
528
|
+
{% if rec_differs %}
|
|
529
|
+
<p style="font-size:0.82rem;color:#555;margin:0 0 0.4rem">
|
|
530
|
+
<strong>Deployment times</strong> — pressure-based instruments suggest times that differ
|
|
531
|
+
from the current YAML. Copy the suggested snippet (blue border) into your YAML file.
|
|
532
|
+
Times are given to the minute.
|
|
533
|
+
</p>
|
|
534
|
+
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.8rem">
|
|
535
|
+
<div>
|
|
536
|
+
<p style="font-size:0.78rem;color:#888;margin:0 0 0.2rem;font-weight:600">
|
|
537
|
+
Current YAML</p>
|
|
538
|
+
<textarea readonly rows="2"
|
|
539
|
+
style="width:100%;font-family:monospace;font-size:0.85rem;padding:0.5rem 0.6rem;
|
|
540
|
+
border:1px solid #bbb;border-radius:4px;background:#f0f0f0;
|
|
541
|
+
resize:vertical;color:#555"
|
|
542
|
+
onclick="this.select()">deployment_time: '{{ yaml_deploy_time or '?' }}'
|
|
543
|
+
recovery_time: '{{ yaml_recover_time or '?' }}'</textarea>
|
|
544
|
+
</div>
|
|
545
|
+
<div>
|
|
546
|
+
<p style="font-size:0.78rem;color:#888;margin:0 0 0.2rem;font-weight:600">
|
|
547
|
+
Suggested (copy → paste into YAML)</p>
|
|
548
|
+
<textarea readonly rows="2"
|
|
549
|
+
style="width:100%;font-family:monospace;font-size:0.85rem;padding:0.5rem 0.6rem;
|
|
550
|
+
border:1px solid #2980b9;border-radius:4px;background:#f8f9fa;
|
|
551
|
+
resize:vertical;color:#2c3e50"
|
|
552
|
+
onclick="this.select()">deployment_time: '{{ rec_deploy or '?' }}'
|
|
553
|
+
recovery_time: '{{ rec_recover or '?' }}'</textarea>
|
|
554
|
+
</div>
|
|
555
|
+
</div>
|
|
556
|
+
{% else %}
|
|
557
|
+
<p style="font-size:0.82rem;color:#27ae60;margin:0 0 0.4rem">
|
|
558
|
+
✓ Current YAML times match the pressure-based suggestion from the instruments.</p>
|
|
559
|
+
<p style="font-size:0.78rem;color:#888;margin:0 0 0.2rem;font-weight:600">
|
|
560
|
+
Current YAML times</p>
|
|
561
|
+
<textarea readonly rows="2"
|
|
562
|
+
style="width:40%;font-family:monospace;font-size:0.85rem;padding:0.5rem 0.6rem;
|
|
563
|
+
border:1px solid #bbb;border-radius:4px;background:#f0f0f0;
|
|
564
|
+
resize:vertical;color:#555"
|
|
565
|
+
onclick="this.select()">deployment_time: '{{ yaml_deploy_time or '?' }}'
|
|
566
|
+
recovery_time: '{{ yaml_recover_time or '?' }}'</textarea>
|
|
567
|
+
{% endif %}
|
|
568
|
+
</div>
|
|
569
|
+
{% endif %}
|
|
570
|
+
|
|
571
|
+
<!-- ══════════════════════════════════════ 4. CLOCK CORRECTIONS ══ -->
|
|
572
|
+
<h2 id="clock">4 — Clock corrections</h2>
|
|
573
|
+
<p style="font-size:0.82rem;color:#555;margin-top:-0.5rem;">
|
|
574
|
+
Positive drift/offset = instrument was <em>slow</em> (behind UTC); correction shifts times later.
|
|
575
|
+
Negative = instrument was <em>fast</em> (ahead of UTC); correction shifts times earlier.
|
|
576
|
+
</p>
|
|
577
|
+
<table>
|
|
578
|
+
<thead>
|
|
579
|
+
<tr>
|
|
580
|
+
<th>#</th>
|
|
581
|
+
<th>Type</th>
|
|
582
|
+
<th>S/N</th>
|
|
583
|
+
<th>Hab (m)</th>
|
|
584
|
+
<th>Offset (s)</th>
|
|
585
|
+
<th>Computer time at recovery</th>
|
|
586
|
+
<th>Instrument time at recovery</th>
|
|
587
|
+
<th>Drift (s)</th>
|
|
588
|
+
<th>Source</th>
|
|
589
|
+
</tr>
|
|
590
|
+
</thead>
|
|
591
|
+
<tbody>
|
|
592
|
+
{% for instr in instruments %}
|
|
593
|
+
<tr>
|
|
594
|
+
<td class="num">{{ loop.index }}</td>
|
|
595
|
+
<td>{{ instr.instr_type }}</td>
|
|
596
|
+
<td><code>{{ instr.serial }}</code></td>
|
|
597
|
+
<td class="num">{{ "%.1f"|format(instr.hab) }}</td>
|
|
598
|
+
<td class="num">
|
|
599
|
+
{% if instr.clock.offset_s == 0 %}
|
|
600
|
+
<span class="none-note">—</span>
|
|
601
|
+
{% elif instr.clock.offset_s > 0 %}
|
|
602
|
+
<span class="pos">+{{ "%.1f"|format(instr.clock.offset_s) }}</span>
|
|
603
|
+
{% else %}
|
|
604
|
+
<span class="neg">{{ "%.1f"|format(instr.clock.offset_s) }}</span>
|
|
605
|
+
{% endif %}
|
|
606
|
+
</td>
|
|
607
|
+
<td>{% if instr.clock.computer_time %}{{ instr.clock.computer_time }}{% else %}<span class="none-note">—</span>{% endif %}</td>
|
|
608
|
+
<td>{% if instr.clock.instrument_time %}{{ instr.clock.instrument_time }}{% else %}<span class="none-note">—</span>{% endif %}</td>
|
|
609
|
+
<td class="num">
|
|
610
|
+
{% if instr.clock.drift_s is none or instr.clock.drift_s == 0 %}
|
|
611
|
+
<span class="none-note">—</span>
|
|
612
|
+
{% elif instr.clock.drift_s > 0 %}
|
|
613
|
+
<span class="pos">+{{ "%.1f"|format(instr.clock.drift_s) }}</span>
|
|
614
|
+
{% else %}
|
|
615
|
+
<span class="neg">{{ "%.1f"|format(instr.clock.drift_s) }}</span>
|
|
616
|
+
{% endif %}
|
|
617
|
+
</td>
|
|
618
|
+
<td>
|
|
619
|
+
{% if instr.clock.method == "none" %}
|
|
620
|
+
<span class="none-note">none</span>
|
|
621
|
+
{% else %}
|
|
622
|
+
{{ instr.clock.method }}
|
|
623
|
+
{% endif %}
|
|
624
|
+
</td>
|
|
625
|
+
</tr>
|
|
626
|
+
{% endfor %}
|
|
627
|
+
</tbody>
|
|
628
|
+
</table>
|
|
629
|
+
|
|
630
|
+
{% if fig_clock_check_b64 %}
|
|
631
|
+
<h3 style="font-size:0.9rem;margin-top:1.2rem;">Clock alignment check</h3>
|
|
632
|
+
<p class="note">
|
|
633
|
+
Overlaid temperature records zoomed to the first and last 30 minutes of the deployment.
|
|
634
|
+
If instrument clocks are misaligned the temperature curves will appear shifted in time.
|
|
635
|
+
Data are from stage‑3 (or stage‑2 if stage‑3 is not yet available).
|
|
636
|
+
Instruments with no temperature data are omitted.
|
|
637
|
+
</p>
|
|
638
|
+
<img class="fig" src="data:image/png;base64,{{ fig_clock_check_b64 }}" alt="Clock alignment check">
|
|
639
|
+
{% endif %}
|
|
640
|
+
|
|
641
|
+
<!-- ══════════════════════════════════════ 5. SENSOR CALIBRATION ══ -->
|
|
642
|
+
<h2 id="calibration">5 — Sensor calibration</h2>
|
|
643
|
+
<style>
|
|
644
|
+
details.coeff summary {
|
|
645
|
+
cursor: pointer;
|
|
646
|
+
color: var(--interp);
|
|
647
|
+
font-size: 0.78rem;
|
|
648
|
+
user-select: none;
|
|
649
|
+
}
|
|
650
|
+
details.coeff pre {
|
|
651
|
+
margin: 0.3em 0 0;
|
|
652
|
+
font-size: 0.72rem;
|
|
653
|
+
background: #f8f9fa;
|
|
654
|
+
padding: 0.4em 0.6em;
|
|
655
|
+
border-radius: 3px;
|
|
656
|
+
white-space: pre-wrap;
|
|
657
|
+
word-break: break-all;
|
|
658
|
+
}
|
|
659
|
+
</style>
|
|
660
|
+
{% set has_sensors = instruments | selectattr("sensors") | list %}
|
|
661
|
+
{% if has_sensors %}
|
|
662
|
+
<table>
|
|
663
|
+
<thead>
|
|
664
|
+
<tr>
|
|
665
|
+
<th>#</th>
|
|
666
|
+
<th>Type</th>
|
|
667
|
+
<th>Instr S/N</th>
|
|
668
|
+
<th>Sensor</th>
|
|
669
|
+
<th>Model</th>
|
|
670
|
+
<th>Sensor S/N</th>
|
|
671
|
+
<th>Cal date</th>
|
|
672
|
+
<th>Coefficients</th>
|
|
673
|
+
</tr>
|
|
674
|
+
</thead>
|
|
675
|
+
<tbody>
|
|
676
|
+
{% set ns = namespace(idx=0) %}
|
|
677
|
+
{% for instr in instruments %}
|
|
678
|
+
{% if instr.sensors %}
|
|
679
|
+
{% set ns.idx = ns.idx + 1 %}
|
|
680
|
+
{% for sensor in instr.sensors %}
|
|
681
|
+
<tr>
|
|
682
|
+
<td class="num">{% if loop.index0 == 0 %}{{ ns.idx }}{% endif %}</td>
|
|
683
|
+
<td>{% if loop.index0 == 0 %}{{ instr.instr_type }}{% endif %}</td>
|
|
684
|
+
<td>{% if loop.index0 == 0 %}<code>{{ instr.serial }}</code>{% endif %}</td>
|
|
685
|
+
<td>{{ sensor.sensor_type | title }}</td>
|
|
686
|
+
<td style="font-size:0.8rem">{{ sensor.sensor_model }}</td>
|
|
687
|
+
<td><code>{{ sensor.sensor_serial }}</code></td>
|
|
688
|
+
<td>{{ sensor.cal_date }}</td>
|
|
689
|
+
<td>
|
|
690
|
+
{% if sensor.coefficients %}
|
|
691
|
+
<details class="coeff">
|
|
692
|
+
<summary>show</summary>
|
|
693
|
+
<pre>{{ sensor.coefficients }}</pre>
|
|
694
|
+
</details>
|
|
695
|
+
{% else %}
|
|
696
|
+
<span class="none-note">—</span>
|
|
697
|
+
{% endif %}
|
|
698
|
+
</td>
|
|
699
|
+
</tr>
|
|
700
|
+
{% endfor %}
|
|
701
|
+
{% endif %}
|
|
702
|
+
{% endfor %}
|
|
703
|
+
</tbody>
|
|
704
|
+
</table>
|
|
705
|
+
{% else %}
|
|
706
|
+
<p class="none-note">No sensor calibration metadata found in processed files.</p>
|
|
707
|
+
{% endif %}
|
|
708
|
+
|
|
709
|
+
<!-- ══════════════════════════════════════ 6. QC FLAG SUMMARY ══ -->
|
|
710
|
+
<h2 id="qc">6 — QC flag summary</h2>
|
|
711
|
+
<style>
|
|
712
|
+
.qc-bar {
|
|
713
|
+
display: flex;
|
|
714
|
+
width: 220px;
|
|
715
|
+
height: 14px;
|
|
716
|
+
border-radius: 3px;
|
|
717
|
+
overflow: hidden;
|
|
718
|
+
gap: 1px;
|
|
719
|
+
background: #ecf0f1;
|
|
720
|
+
}
|
|
721
|
+
.qc-bar div { height: 100%; }
|
|
722
|
+
.qc-legend {
|
|
723
|
+
display: flex;
|
|
724
|
+
flex-wrap: wrap;
|
|
725
|
+
gap: 0.3rem 0.9rem;
|
|
726
|
+
font-size: 0.75rem;
|
|
727
|
+
margin: 0.3rem 0 1rem;
|
|
728
|
+
}
|
|
729
|
+
.qc-legend span { display: inline-block; width: 10px; height: 10px;
|
|
730
|
+
border-radius: 2px; vertical-align: middle; margin-right: 3px; }
|
|
731
|
+
</style>
|
|
732
|
+
<div class="qc-legend">
|
|
733
|
+
<span style="background:#27ae60"></span>good (1)
|
|
734
|
+
<span style="background:#a8e6cf"></span>prob. good (2)
|
|
735
|
+
<span style="background:#f39c12"></span>suspect (3)
|
|
736
|
+
<span style="background:#e74c3c"></span>bad (4)
|
|
737
|
+
<span style="background:#3498db"></span>interp. (8)
|
|
738
|
+
<span style="background:#bdc3c7"></span>missing (9)
|
|
739
|
+
</div>
|
|
740
|
+
{% set has_qc = instruments | selectattr("qc_summary") | list %}
|
|
741
|
+
{% if has_qc %}
|
|
742
|
+
<table>
|
|
743
|
+
<thead>
|
|
744
|
+
<tr>
|
|
745
|
+
<th>#</th>
|
|
746
|
+
<th>Type</th>
|
|
747
|
+
<th>S/N</th>
|
|
748
|
+
<th>Variable</th>
|
|
749
|
+
<th class="num">N</th>
|
|
750
|
+
<th class="num">Good %</th>
|
|
751
|
+
<th class="num">Suspect %</th>
|
|
752
|
+
<th class="num">Bad %</th>
|
|
753
|
+
<th class="num">Interp. %</th>
|
|
754
|
+
<th class="num">Missing %</th>
|
|
755
|
+
<th>Distribution</th>
|
|
756
|
+
</tr>
|
|
757
|
+
</thead>
|
|
758
|
+
<tbody>
|
|
759
|
+
{% set ns = namespace(idx=0) %}
|
|
760
|
+
{% for instr in instruments %}
|
|
761
|
+
{% if instr.qc_summary %}
|
|
762
|
+
{% set ns.idx = ns.idx + 1 %}
|
|
763
|
+
{% for row in instr.qc_summary %}
|
|
764
|
+
{% set good = row.flags | selectattr("flag", "eq", 1) | first %}
|
|
765
|
+
{% set susp = row.flags | selectattr("flag", "eq", 3) | first %}
|
|
766
|
+
{% set bad = row.flags | selectattr("flag", "eq", 4) | first %}
|
|
767
|
+
{% set interp = row.flags | selectattr("flag", "eq", 8) | first %}
|
|
768
|
+
{% set miss = row.flags | selectattr("flag", "eq", 9) | first %}
|
|
769
|
+
<tr>
|
|
770
|
+
<td class="num">{% if loop.index0 == 0 %}{{ ns.idx }}{% endif %}</td>
|
|
771
|
+
<td>{% if loop.index0 == 0 %}{{ instr.instr_type }}{% endif %}</td>
|
|
772
|
+
<td>{% if loop.index0 == 0 %}<code>{{ instr.serial }}</code>{% endif %}</td>
|
|
773
|
+
<td><code>{{ row.var }}</code></td>
|
|
774
|
+
<td class="num">{{ "{:,}".format(row.total) }}</td>
|
|
775
|
+
<td class="num" style="color:{% if good.pct >= 95 %}var(--good){% elif good.pct >= 80 %}var(--warn){% else %}var(--bad){% endif %}">
|
|
776
|
+
{{ good.pct }}
|
|
777
|
+
</td>
|
|
778
|
+
<td class="num">{% if susp.pct > 0 %}<span style="color:var(--warn)">{{ susp.pct }}</span>{% else %}–{% endif %}</td>
|
|
779
|
+
<td class="num">{% if bad.pct > 0 %}<span style="color:var(--bad)">{{ bad.pct }}</span>{% else %}–{% endif %}</td>
|
|
780
|
+
<td class="num">{% if interp.pct > 0 %}<span style="color:var(--interp)">{{ interp.pct }}</span>{% else %}–{% endif %}</td>
|
|
781
|
+
<td class="num">{% if miss.pct > 0 %}{{ miss.pct }}{% else %}–{% endif %}</td>
|
|
782
|
+
<td>
|
|
783
|
+
<div class="qc-bar">
|
|
784
|
+
{% for f in row.flags %}
|
|
785
|
+
{% if f.pct > 0 %}
|
|
786
|
+
<div style="width:{{ f.pct }}%; background:{{ f.color }};"
|
|
787
|
+
title="{{ f.label }}: {{ f.pct }}%"></div>
|
|
788
|
+
{% endif %}
|
|
789
|
+
{% endfor %}
|
|
790
|
+
</div>
|
|
791
|
+
</td>
|
|
792
|
+
</tr>
|
|
793
|
+
{% endfor %}
|
|
794
|
+
{% endif %}
|
|
795
|
+
{% endfor %}
|
|
796
|
+
</tbody>
|
|
797
|
+
</table>
|
|
798
|
+
{% else %}
|
|
799
|
+
<p class="none-note">No stage 3 QC files found — run <code>oceanarray stage3</code> first.</p>
|
|
800
|
+
{% endif %}
|
|
801
|
+
|
|
802
|
+
{% if fig_knockdown_hab_b64 or fig_knockdown_anomaly_b64 or fig_knockdown_displacement_b64 %}
|
|
803
|
+
<!-- ══════════════════════════════════════════ KNOCKDOWN ══ -->
|
|
804
|
+
<h2 id="knockdown">Mooring knockdown</h2>
|
|
805
|
+
<div style="display:flex;gap:1rem;flex-wrap:wrap;align-items:flex-start">
|
|
806
|
+
{% if fig_knockdown_hab_b64 %}
|
|
807
|
+
<div style="flex:1;min-width:260px">
|
|
808
|
+
<p class="note">Nominal HAB (x) vs. measured pressure (y). The dashed line shows expected pressure
|
|
809
|
+
(water depth − HAB). Instruments below the line were knocked down.
|
|
810
|
+
Interpolated pressure (QC flag 8) excluded.</p>
|
|
811
|
+
<img style="width:100%;border:1px solid #dce;border-radius:4px" src="data:image/png;base64,{{ fig_knockdown_hab_b64 }}" alt="Knockdown HAB vs pressure">
|
|
812
|
+
</div>
|
|
813
|
+
{% endif %}
|
|
814
|
+
{% if fig_knockdown_anomaly_b64 %}
|
|
815
|
+
<div style="flex:1;min-width:260px">
|
|
816
|
+
<p class="note">IQR of pressure anomaly (measured − nominal) per instrument.
|
|
817
|
+
Positive = deeper than design depth.
|
|
818
|
+
<span style="color:mediumseagreen">Green</span> < 100 dbar,
|
|
819
|
+
<span style="color:goldenrod">yellow</span> 100–200,
|
|
820
|
+
<span style="color:darkorange">amber</span> 200–300,
|
|
821
|
+
<span style="color:firebrick">red</span> > 300 dbar.</p>
|
|
822
|
+
<img style="width:100%;border:1px solid #dce;border-radius:4px" src="data:image/png;base64,{{ fig_knockdown_anomaly_b64 }}" alt="Knockdown pressure anomaly">
|
|
823
|
+
</div>
|
|
824
|
+
{% endif %}
|
|
825
|
+
</div>
|
|
826
|
+
{% if fig_knockdown_displacement_b64 %}
|
|
827
|
+
<p class="note">Estimated horizontal displacement (m) vs. measured pressure.
|
|
828
|
+
Left: scatter per instrument; right: normalised 2-D density across all instruments.
|
|
829
|
+
Displacement derived from the rigid-pendulum approximation:
|
|
830
|
+
<em>x</em> = √(hab<sub>nom</sub>² − hab<sub>meas</sub>²).</p>
|
|
831
|
+
<img style="width:100%;border:1px solid #dce;border-radius:4px" src="data:image/png;base64,{{ fig_knockdown_displacement_b64 }}" alt="Knockdown horizontal displacement">
|
|
832
|
+
{% endif %}
|
|
833
|
+
{% endif %}
|
|
834
|
+
|
|
835
|
+
{% if diagram_b64 %}
|
|
836
|
+
<!-- ══════════════════════════════════════════ MOORING DIAGRAM ══ -->
|
|
837
|
+
<h2 id="diagram">Mooring diagram</h2>
|
|
838
|
+
<embed src="data:application/pdf;base64,{{ diagram_b64 }}"
|
|
839
|
+
type="application/pdf" width="100%" height="850px"
|
|
840
|
+
style="border:1px solid #dce;border-radius:4px;display:block;">
|
|
841
|
+
{% endif %}
|
|
842
|
+
|
|
843
|
+
<!-- ══════════════════════════════════════════════ FOOTER ══ -->
|
|
844
|
+
<div class="report-footer">
|
|
845
|
+
Generated by <strong>oceanarray</strong> on {{ generated }} •
|
|
846
|
+
YAML: <code>{{ yaml_path }}</code>
|
|
847
|
+
</div>
|
|
848
|
+
|
|
849
|
+
<script>
|
|
850
|
+
document.querySelectorAll('h2').forEach(h => {
|
|
851
|
+
const a = document.createElement('a'); a.href = '#top'; a.className = 'top-link'; a.textContent = '↑ top'; h.appendChild(a);
|
|
852
|
+
});
|
|
853
|
+
</script>
|
|
854
|
+
</body>
|
|
855
|
+
</html>
|
|
856
|
+
"""
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
# ---------------------------------------------------------------------------
|
|
860
|
+
# Orchestrator class
|
|
861
|
+
# ---------------------------------------------------------------------------
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
def _serials_in_nc(nc_path: Path) -> frozenset:
|
|
865
|
+
"""Return the set of serial numbers present in a stack or grid NC file.
|
|
866
|
+
|
|
867
|
+
Reads the ``serial`` coordinate on the ``N_LEVELS`` dimension. Returns an
|
|
868
|
+
empty frozenset if the file is missing, unreadable, or has no ``serial``
|
|
869
|
+
coordinate.
|
|
870
|
+
|
|
871
|
+
ADCP entries use suffixed serials (e.g. ``16430_hd``, ``16430_b03``). The
|
|
872
|
+
returned set also includes the base serial (suffix stripped) so that the YAML
|
|
873
|
+
instrument serial matches correctly.
|
|
874
|
+
"""
|
|
875
|
+
import re
|
|
876
|
+
|
|
877
|
+
try:
|
|
878
|
+
import xarray as xr
|
|
879
|
+
|
|
880
|
+
ds = xr.open_dataset(nc_path, decode_timedelta=False)
|
|
881
|
+
if "serial" in ds.coords:
|
|
882
|
+
raw = frozenset(str(s) for s in ds["serial"].values)
|
|
883
|
+
base = frozenset(re.sub(r"_(hd|b\d+)$", "", s) for s in raw)
|
|
884
|
+
return raw | base
|
|
885
|
+
except Exception: # noqa: BLE001
|
|
886
|
+
pass
|
|
887
|
+
return frozenset()
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
class MooringReport:
|
|
891
|
+
"""Generate a mooring recovery HTML report from YAML and processed files."""
|
|
892
|
+
|
|
893
|
+
def __init__(
|
|
894
|
+
self,
|
|
895
|
+
base_dir: Optional[str] = None,
|
|
896
|
+
*,
|
|
897
|
+
proc_dir: Optional[str] = None,
|
|
898
|
+
raw_dir: Optional[str] = None,
|
|
899
|
+
) -> None:
|
|
900
|
+
"""Initialize with base_dir (legacy) or proc_dir + optional raw_dir (new).
|
|
901
|
+
|
|
902
|
+
Parameters
|
|
903
|
+
----------
|
|
904
|
+
base_dir : str, optional
|
|
905
|
+
Legacy: cruise-level base directory containing a ``proc/`` subdirectory.
|
|
906
|
+
proc_dir : str, optional
|
|
907
|
+
Cruise-level processed output directory. Pipeline appends ``/{mooring}/``.
|
|
908
|
+
raw_dir : str, optional
|
|
909
|
+
Cruise-level raw data directory. When provided, the report checks whether
|
|
910
|
+
each instrument's raw data file still exists on disk and shows a green/red
|
|
911
|
+
status indicator next to the filename. When absent, the check is skipped.
|
|
912
|
+
|
|
913
|
+
"""
|
|
914
|
+
if base_dir is not None:
|
|
915
|
+
self.base_dir: Optional[Path] = Path(base_dir)
|
|
916
|
+
self._proc_dir: Optional[Path] = None
|
|
917
|
+
self._raw_dir: Optional[Path] = None
|
|
918
|
+
self._legacy = True
|
|
919
|
+
else:
|
|
920
|
+
self.base_dir = None
|
|
921
|
+
self._proc_dir = Path(proc_dir) if proc_dir else None
|
|
922
|
+
self._raw_dir = Path(raw_dir) if raw_dir else None
|
|
923
|
+
self._legacy = False
|
|
924
|
+
|
|
925
|
+
def _resolve_proc_dir(self, mooring_name: str) -> Path:
|
|
926
|
+
"""Return the mooring-level proc directory."""
|
|
927
|
+
if not self._legacy and self._proc_dir is not None:
|
|
928
|
+
return self._proc_dir / mooring_name
|
|
929
|
+
return _get_proc_dir(self.base_dir, mooring_name)
|
|
930
|
+
|
|
931
|
+
def _rel(self, path: Path) -> str:
|
|
932
|
+
"""Return a short display path relative to base_dir or proc_dir."""
|
|
933
|
+
for root in (r for r in (self.base_dir, self._proc_dir) if r):
|
|
934
|
+
try:
|
|
935
|
+
return str(path.relative_to(root))
|
|
936
|
+
except ValueError:
|
|
937
|
+
continue
|
|
938
|
+
return path.name
|
|
939
|
+
|
|
940
|
+
def generate(
|
|
941
|
+
self,
|
|
942
|
+
mooring_name: str,
|
|
943
|
+
force: bool = False,
|
|
944
|
+
outdir: Optional[str] = None,
|
|
945
|
+
serials: Optional[List[str]] = None,
|
|
946
|
+
instruments: bool = False,
|
|
947
|
+
grid: bool = False,
|
|
948
|
+
stack: bool = False,
|
|
949
|
+
) -> Optional[Path]:
|
|
950
|
+
proc_dir = self._resolve_proc_dir(mooring_name)
|
|
951
|
+
if not proc_dir.exists():
|
|
952
|
+
print(f"ERROR: Processing directory not found: {proc_dir}")
|
|
953
|
+
return None
|
|
954
|
+
|
|
955
|
+
if outdir:
|
|
956
|
+
out_dir = Path(outdir)
|
|
957
|
+
else:
|
|
958
|
+
out_dir = proc_dir / "report"
|
|
959
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
960
|
+
output_path = out_dir / f"{mooring_name}_report.html"
|
|
961
|
+
if output_path.exists() and not force:
|
|
962
|
+
_status("skip", self._rel(output_path))
|
|
963
|
+
return output_path
|
|
964
|
+
|
|
965
|
+
yaml_path = proc_dir / f"{mooring_name}.mooring.yaml"
|
|
966
|
+
if not yaml_path.exists():
|
|
967
|
+
print(f"ERROR: Config not found: {yaml_path}")
|
|
968
|
+
return None
|
|
969
|
+
|
|
970
|
+
with open(yaml_path) as f:
|
|
971
|
+
cfg = yaml.safe_load(f)
|
|
972
|
+
|
|
973
|
+
# Resolve the base_dir used for raw file lookups in instrument pages
|
|
974
|
+
report_base_dir = self.base_dir or self._proc_dir or proc_dir.parent
|
|
975
|
+
|
|
976
|
+
ctx = self._build_context(mooring_name, cfg, proc_dir, yaml_path, out_dir)
|
|
977
|
+
html = self._render(ctx)
|
|
978
|
+
output_path.write_text(html, encoding="utf-8")
|
|
979
|
+
_status("file", self._rel(output_path))
|
|
980
|
+
|
|
981
|
+
if instruments:
|
|
982
|
+
generate_instrument_pages(
|
|
983
|
+
mooring_name,
|
|
984
|
+
ctx["instruments"],
|
|
985
|
+
cfg,
|
|
986
|
+
proc_dir,
|
|
987
|
+
out_dir,
|
|
988
|
+
force,
|
|
989
|
+
report_base_dir,
|
|
990
|
+
serials=serials,
|
|
991
|
+
raw_dir=self._raw_dir,
|
|
992
|
+
)
|
|
993
|
+
# Instrument pages are now on disk — re-check existence and re-render
|
|
994
|
+
# the mooring summary so serial-number links work on first run.
|
|
995
|
+
if out_dir is not None:
|
|
996
|
+
for instr in ctx["instruments"]:
|
|
997
|
+
instr["report_exists"] = _instrument_report_exists(
|
|
998
|
+
out_dir, mooring_name, instr["serial"]
|
|
999
|
+
)
|
|
1000
|
+
html = self._render(ctx)
|
|
1001
|
+
output_path.write_text(html, encoding="utf-8")
|
|
1002
|
+
|
|
1003
|
+
if grid:
|
|
1004
|
+
grid_path = proc_dir / f"{mooring_name}_grid.nc"
|
|
1005
|
+
if grid_path.exists():
|
|
1006
|
+
generate_grid_page(
|
|
1007
|
+
mooring_name, grid_path, ctx, out_dir, force, report_base_dir
|
|
1008
|
+
)
|
|
1009
|
+
else:
|
|
1010
|
+
print(" NOTE: no grid file found — run 'oceanarray grid' first")
|
|
1011
|
+
|
|
1012
|
+
if stack:
|
|
1013
|
+
stack_path = proc_dir / f"{mooring_name}_stack.nc"
|
|
1014
|
+
if stack_path.exists():
|
|
1015
|
+
generate_stack_page(
|
|
1016
|
+
mooring_name, stack_path, ctx, out_dir, force, report_base_dir
|
|
1017
|
+
)
|
|
1018
|
+
else:
|
|
1019
|
+
print(" NOTE: no stack file found — run 'oceanarray stack' first")
|
|
1020
|
+
|
|
1021
|
+
return output_path
|
|
1022
|
+
|
|
1023
|
+
def _build_context(
|
|
1024
|
+
self,
|
|
1025
|
+
mooring_name: str,
|
|
1026
|
+
cfg: Dict[str, Any],
|
|
1027
|
+
proc_dir: Path,
|
|
1028
|
+
yaml_path: Path,
|
|
1029
|
+
out_dir: Optional[Path] = None,
|
|
1030
|
+
) -> Dict[str, Any]:
|
|
1031
|
+
deploy_dt = _parse_dt(cfg.get("deployment_time"))
|
|
1032
|
+
recover_dt = _parse_dt(cfg.get("recovery_time"))
|
|
1033
|
+
waterdepth = cfg.get("waterdepth")
|
|
1034
|
+
raw_subdir = str(cfg.get("directory", "raw")).rstrip("/")
|
|
1035
|
+
|
|
1036
|
+
instrument_list = list(cfg.get("clamp", cfg.get("instruments", [])))
|
|
1037
|
+
instrument_list += extract_inline_instruments(cfg.get("inline", []))
|
|
1038
|
+
|
|
1039
|
+
stack_nc = proc_dir / f"{mooring_name}_stack.nc"
|
|
1040
|
+
grid_nc = proc_dir / f"{mooring_name}_grid.nc"
|
|
1041
|
+
stack_exists = stack_nc.exists()
|
|
1042
|
+
grid_exists = grid_nc.exists()
|
|
1043
|
+
stack_serials = _serials_in_nc(stack_nc) if stack_exists else frozenset()
|
|
1044
|
+
# Grid is a pressure-regridded version of the stack with no per-instrument
|
|
1045
|
+
# serial coordinate — use stack membership as the proxy for grid inclusion.
|
|
1046
|
+
|
|
1047
|
+
instruments = []
|
|
1048
|
+
for entry in instrument_list:
|
|
1049
|
+
if not isinstance(entry, dict):
|
|
1050
|
+
continue
|
|
1051
|
+
serial = _safe_serial(entry.get("serial", ""))
|
|
1052
|
+
instr_type = entry.get("instrument", "unknown")
|
|
1053
|
+
hab = entry.get("hab")
|
|
1054
|
+
if hab is None:
|
|
1055
|
+
continue
|
|
1056
|
+
hab = float(hab)
|
|
1057
|
+
|
|
1058
|
+
depth = (
|
|
1059
|
+
float(entry["depth"])
|
|
1060
|
+
if "depth" in entry
|
|
1061
|
+
else (float(waterdepth) - hab if waterdepth is not None else None)
|
|
1062
|
+
)
|
|
1063
|
+
|
|
1064
|
+
filename = entry.get("filename", "")
|
|
1065
|
+
file_type = entry.get("file_type", "")
|
|
1066
|
+
yaml_interval_s = entry.get("sample_interval_seconds")
|
|
1067
|
+
|
|
1068
|
+
if filename:
|
|
1069
|
+
if self._legacy:
|
|
1070
|
+
raw_path = _raw_file_path(
|
|
1071
|
+
self.base_dir, raw_subdir, instr_type, mooring_name, filename
|
|
1072
|
+
)
|
|
1073
|
+
raw_path_str = str(raw_path.relative_to(self.base_dir))
|
|
1074
|
+
elif self._raw_dir is not None:
|
|
1075
|
+
# New layout: {raw_dir}/{mooring}/{instrument}/filename
|
|
1076
|
+
raw_path = self._raw_dir / mooring_name / instr_type / filename
|
|
1077
|
+
raw_path_str = str(raw_path.relative_to(self._raw_dir))
|
|
1078
|
+
else:
|
|
1079
|
+
raw_path = Path(filename)
|
|
1080
|
+
raw_path_str = filename
|
|
1081
|
+
raw_exists = raw_path.exists()
|
|
1082
|
+
readable, readable_note = (
|
|
1083
|
+
_check_readable(raw_path, file_type)
|
|
1084
|
+
if raw_exists
|
|
1085
|
+
else (False, "file missing")
|
|
1086
|
+
)
|
|
1087
|
+
else:
|
|
1088
|
+
# Try auto-guessing filename from standard naming conventions
|
|
1089
|
+
# (same logic as MooringProcessor._guess_instrument_filename).
|
|
1090
|
+
# Skip guessing for instruments marked skip:true — they won't
|
|
1091
|
+
# be processed and there's nothing useful to report.
|
|
1092
|
+
_guessed = None
|
|
1093
|
+
if self._raw_dir is not None and not entry.get("skip"):
|
|
1094
|
+
from oceanarray.stage1 import MooringProcessor as _S1
|
|
1095
|
+
|
|
1096
|
+
_raw_mooring = self._raw_dir / mooring_name
|
|
1097
|
+
_guessed = _S1._guess_instrument_filename( # noqa: SLF001
|
|
1098
|
+
entry, mooring_name, _raw_mooring, None
|
|
1099
|
+
)
|
|
1100
|
+
if _guessed is not None:
|
|
1101
|
+
_gfname, _gftype, _ghdr = _guessed
|
|
1102
|
+
file_type = file_type or _gftype
|
|
1103
|
+
_gpath = self._raw_dir / mooring_name / instr_type / _gfname
|
|
1104
|
+
if not _gpath.exists():
|
|
1105
|
+
_gpath = self._raw_dir / mooring_name / _gfname
|
|
1106
|
+
raw_path_str = (
|
|
1107
|
+
str(_gpath.relative_to(self._raw_dir))
|
|
1108
|
+
if _gpath.exists()
|
|
1109
|
+
else f"(auto) {_gfname}"
|
|
1110
|
+
)
|
|
1111
|
+
raw_exists = _gpath.exists()
|
|
1112
|
+
readable, readable_note = (
|
|
1113
|
+
_check_readable(_gpath, file_type)
|
|
1114
|
+
if raw_exists
|
|
1115
|
+
else (False, "auto-guessed file not found")
|
|
1116
|
+
)
|
|
1117
|
+
else:
|
|
1118
|
+
raw_path_str = ""
|
|
1119
|
+
raw_exists = False
|
|
1120
|
+
readable = False
|
|
1121
|
+
readable_note = "no filename in YAML"
|
|
1122
|
+
|
|
1123
|
+
nc_info = _read_instrument_info(proc_dir, instr_type, mooring_name, serial)
|
|
1124
|
+
|
|
1125
|
+
_base_nc = proc_dir / instr_type / f"{mooring_name}_{serial}"
|
|
1126
|
+
_stage3_nc = Path(str(_base_nc) + "_stage3.nc")
|
|
1127
|
+
_stage3_nc = _stage3_nc if _stage3_nc.exists() else None
|
|
1128
|
+
|
|
1129
|
+
stopped_early = False
|
|
1130
|
+
if recover_dt and nc_info and not nc_info.get("error"):
|
|
1131
|
+
t_end_raw = nc_info.get("t_end_raw")
|
|
1132
|
+
if t_end_raw is not None:
|
|
1133
|
+
rec_np = np.datetime64(
|
|
1134
|
+
recover_dt.replace(tzinfo=None).isoformat(), "ns"
|
|
1135
|
+
)
|
|
1136
|
+
gap_s = float((rec_np - t_end_raw) / np.timedelta64(1, "s"))
|
|
1137
|
+
stopped_early = gap_s > 12 * 3600
|
|
1138
|
+
|
|
1139
|
+
instruments.append(
|
|
1140
|
+
{
|
|
1141
|
+
"serial": serial,
|
|
1142
|
+
"instr_type": instr_type,
|
|
1143
|
+
"hab": hab,
|
|
1144
|
+
"depth": depth,
|
|
1145
|
+
"filename": filename,
|
|
1146
|
+
"file_type": file_type,
|
|
1147
|
+
"raw_path": raw_path_str,
|
|
1148
|
+
"raw_exists": raw_exists,
|
|
1149
|
+
"readable": readable,
|
|
1150
|
+
"readable_note": readable_note,
|
|
1151
|
+
"yaml_interval_s": yaml_interval_s,
|
|
1152
|
+
"dt_mismatch": bool(
|
|
1153
|
+
yaml_interval_s is not None
|
|
1154
|
+
and nc_info
|
|
1155
|
+
and not nc_info.get("error")
|
|
1156
|
+
and nc_info.get("dt_s") is not None # key present
|
|
1157
|
+
and nc_info.get("dt_s") == nc_info.get("dt_s") # False for NaN
|
|
1158
|
+
and abs(float(yaml_interval_s) - float(nc_info["dt_s"]))
|
|
1159
|
+
> max(5.0, float(yaml_interval_s) * 0.1)
|
|
1160
|
+
),
|
|
1161
|
+
"stopped_early": stopped_early,
|
|
1162
|
+
"skipped": bool(entry.get("skip")),
|
|
1163
|
+
"skip_reason": entry.get("skip_reason", ""),
|
|
1164
|
+
"report_exists": (
|
|
1165
|
+
out_dir is not None
|
|
1166
|
+
and (
|
|
1167
|
+
out_dir
|
|
1168
|
+
/ "instrument"
|
|
1169
|
+
/ f"{mooring_name}_{serial}_report.html"
|
|
1170
|
+
).exists()
|
|
1171
|
+
),
|
|
1172
|
+
"stages": _stage_files(proc_dir, instr_type, mooring_name, serial),
|
|
1173
|
+
"in_stack": serial in stack_serials,
|
|
1174
|
+
"in_grid": (serial in stack_serials) and grid_exists,
|
|
1175
|
+
"clock": _resolve_clock(entry),
|
|
1176
|
+
"nc": nc_info,
|
|
1177
|
+
"timing": _read_timing_info(
|
|
1178
|
+
proc_dir, instr_type, mooring_name, serial
|
|
1179
|
+
),
|
|
1180
|
+
"sensors": _read_sensor_info(
|
|
1181
|
+
proc_dir, instr_type, mooring_name, serial
|
|
1182
|
+
),
|
|
1183
|
+
"qc_summary": _read_qc_summary(_stage3_nc) if _stage3_nc else [],
|
|
1184
|
+
}
|
|
1185
|
+
)
|
|
1186
|
+
|
|
1187
|
+
instruments.sort(key=lambda x: x["hab"])
|
|
1188
|
+
|
|
1189
|
+
# Compute recommended --p-start / --p-end for `oceanarray grid`.
|
|
1190
|
+
# Collect finite min/max pressure values from instruments that have real
|
|
1191
|
+
# pressure data (exclude stuck-at-zero sensors: p_max must be > 20 dbar).
|
|
1192
|
+
_all_pmin: List[float] = []
|
|
1193
|
+
_all_pmax: List[float] = []
|
|
1194
|
+
for _instr in instruments:
|
|
1195
|
+
_nc = _instr.get("nc") or {}
|
|
1196
|
+
_pmin = _nc.get("p_min")
|
|
1197
|
+
_pmax = _nc.get("p_max")
|
|
1198
|
+
if (
|
|
1199
|
+
_pmin is not None
|
|
1200
|
+
and _pmax is not None
|
|
1201
|
+
and np.isfinite(_pmin)
|
|
1202
|
+
and np.isfinite(_pmax)
|
|
1203
|
+
and _pmax > 20.0 # skip stuck-at-zero sensors
|
|
1204
|
+
):
|
|
1205
|
+
_all_pmin.append(_pmin)
|
|
1206
|
+
_all_pmax.append(_pmax)
|
|
1207
|
+
|
|
1208
|
+
grid_p_start: Optional[int] = None
|
|
1209
|
+
grid_p_end: Optional[int] = None
|
|
1210
|
+
if _all_pmin and _all_pmax:
|
|
1211
|
+
import math
|
|
1212
|
+
|
|
1213
|
+
grid_p_start = int(math.floor(min(_all_pmin) / 20.0) * 20)
|
|
1214
|
+
grid_p_end = int(math.ceil(max(_all_pmax) / 20.0) * 20)
|
|
1215
|
+
|
|
1216
|
+
# Compute recommended YAML deployment/recovery times from all instruments.
|
|
1217
|
+
# Start: latest suggested start UTC (most conservative — all instruments
|
|
1218
|
+
# are definitely deployed before this).
|
|
1219
|
+
# End: earliest in the main recovery cluster (instruments stopping days/weeks
|
|
1220
|
+
# before the latest are likely battery/memory failures; exclude them by
|
|
1221
|
+
# keeping only instruments whose end time is within 4 h of the latest).
|
|
1222
|
+
from datetime import timedelta as _td
|
|
1223
|
+
|
|
1224
|
+
_rec_starts: List[datetime] = []
|
|
1225
|
+
_rec_ends: List[datetime] = []
|
|
1226
|
+
for _instr in instruments:
|
|
1227
|
+
_tm = _instr.get("timing") or {}
|
|
1228
|
+
# sugg_* attrs are only written when pressure detection succeeded, so
|
|
1229
|
+
# any non-None entry here is already pressure-based.
|
|
1230
|
+
_s = _tm.get("sugg_start_utc")
|
|
1231
|
+
_e = _tm.get("sugg_end_utc")
|
|
1232
|
+
if _s:
|
|
1233
|
+
try:
|
|
1234
|
+
_rec_starts.append(datetime.fromisoformat(_s.replace("T", " ")))
|
|
1235
|
+
except Exception: # noqa: BLE001
|
|
1236
|
+
pass
|
|
1237
|
+
if _e:
|
|
1238
|
+
try:
|
|
1239
|
+
_rec_ends.append(datetime.fromisoformat(_e.replace("T", " ")))
|
|
1240
|
+
except Exception: # noqa: BLE001
|
|
1241
|
+
pass
|
|
1242
|
+
|
|
1243
|
+
rec_deploy: Optional[str] = None # minute precision — for YAML textarea
|
|
1244
|
+
rec_recover: Optional[str] = None # minute precision — for YAML textarea
|
|
1245
|
+
rec_deploy_sec: Optional[str] = None # second precision — for table summary row
|
|
1246
|
+
rec_recover_sec: Optional[str] = (
|
|
1247
|
+
None # second precision — for table summary row
|
|
1248
|
+
)
|
|
1249
|
+
|
|
1250
|
+
if _rec_starts:
|
|
1251
|
+
_best_start = max(_rec_starts)
|
|
1252
|
+
# Ceil to next whole minute so the YAML start doesn't clip a partial sample
|
|
1253
|
+
if _best_start.second or _best_start.microsecond:
|
|
1254
|
+
_best_start = _best_start.replace(second=0, microsecond=0) + _td(
|
|
1255
|
+
minutes=1
|
|
1256
|
+
)
|
|
1257
|
+
rec_deploy = _best_start.strftime("%Y-%m-%dT%H:%M")
|
|
1258
|
+
rec_deploy_sec = _best_start.strftime("%Y-%m-%d %H:%M:%S")
|
|
1259
|
+
|
|
1260
|
+
if _rec_ends:
|
|
1261
|
+
_ends_sorted = sorted(_rec_ends)
|
|
1262
|
+
_latest_end = _ends_sorted[-1]
|
|
1263
|
+
_cluster = [t for t in _ends_sorted if _latest_end - t <= _td(hours=4)]
|
|
1264
|
+
_best_end = min(_cluster)
|
|
1265
|
+
# Floor to whole minute so the YAML end doesn't include a partial sample
|
|
1266
|
+
_best_end = _best_end.replace(second=0, microsecond=0)
|
|
1267
|
+
rec_recover = _best_end.strftime("%Y-%m-%dT%H:%M")
|
|
1268
|
+
rec_recover_sec = _best_end.strftime("%Y-%m-%d %H:%M:%S")
|
|
1269
|
+
|
|
1270
|
+
# Only show the recommended YAML block when it differs from the current YAML.
|
|
1271
|
+
# Compare at minute precision (same as rec_deploy / rec_recover).
|
|
1272
|
+
_yaml_deploy_min = deploy_dt.strftime("%Y-%m-%dT%H:%M") if deploy_dt else None
|
|
1273
|
+
_yaml_recover_min = (
|
|
1274
|
+
recover_dt.strftime("%Y-%m-%dT%H:%M") if recover_dt else None
|
|
1275
|
+
)
|
|
1276
|
+
rec_differs = (rec_deploy != _yaml_deploy_min) or (
|
|
1277
|
+
rec_recover != _yaml_recover_min
|
|
1278
|
+
)
|
|
1279
|
+
|
|
1280
|
+
any_clock = any(i["clock"]["has_correction"] for i in instruments)
|
|
1281
|
+
|
|
1282
|
+
# Build {serial: nc_path} for the clock-offset comparison figure.
|
|
1283
|
+
# Prefer stage3 over stage2 (clock correction was applied in stage2 and
|
|
1284
|
+
# is preserved in stage3, so either works for comparing alignment).
|
|
1285
|
+
_clock_nc_paths: Dict[str, Path] = {}
|
|
1286
|
+
for _instr in instruments:
|
|
1287
|
+
_s = _instr["serial"]
|
|
1288
|
+
_itype = _instr["instr_type"]
|
|
1289
|
+
_base = proc_dir / _itype / f"{mooring_name}_{_s}"
|
|
1290
|
+
_s3 = Path(str(_base) + "_stage3.nc")
|
|
1291
|
+
_s2 = Path(str(_base) + "_stage2.nc")
|
|
1292
|
+
if _s3.exists():
|
|
1293
|
+
_clock_nc_paths[_s] = _s3
|
|
1294
|
+
elif _s2.exists():
|
|
1295
|
+
_clock_nc_paths[_s] = _s2
|
|
1296
|
+
fig_clock_check_b64 = _make_clock_check_b64(
|
|
1297
|
+
_clock_nc_paths, deploy_dt, recover_dt
|
|
1298
|
+
)
|
|
1299
|
+
|
|
1300
|
+
fig_knockdown_hab_b64 = None
|
|
1301
|
+
fig_knockdown_anomaly_b64 = None
|
|
1302
|
+
fig_knockdown_displacement_b64 = None
|
|
1303
|
+
if stack_exists:
|
|
1304
|
+
try:
|
|
1305
|
+
import xarray as _xr
|
|
1306
|
+
|
|
1307
|
+
with _xr.open_dataset(stack_nc) as _ds_kd:
|
|
1308
|
+
fig_knockdown_hab_b64 = _make_knockdown_hab_b64(_ds_kd)
|
|
1309
|
+
fig_knockdown_anomaly_b64 = _make_knockdown_anomaly_b64(_ds_kd)
|
|
1310
|
+
fig_knockdown_displacement_b64 = _make_knockdown_displacement_b64(
|
|
1311
|
+
_ds_kd
|
|
1312
|
+
)
|
|
1313
|
+
except Exception:
|
|
1314
|
+
pass
|
|
1315
|
+
|
|
1316
|
+
def _combined(deploy_key: str, recover_key: str, legacy_key: str) -> str:
|
|
1317
|
+
d = cfg.get(deploy_key) or cfg.get(legacy_key, "—")
|
|
1318
|
+
r = cfg.get(recover_key) or cfg.get(legacy_key) or d
|
|
1319
|
+
return d if d == r else f"{d} / {r}"
|
|
1320
|
+
|
|
1321
|
+
return {
|
|
1322
|
+
"mooring_name": mooring_name,
|
|
1323
|
+
"cruise": _combined("deployment_cruise", "recovery_cruise", "cruise"),
|
|
1324
|
+
"ship": _combined("deployment_ship", "recovery_ship", "ship"),
|
|
1325
|
+
"deploy_time": _fmt_dt(deploy_dt),
|
|
1326
|
+
"recover_time": _fmt_dt(recover_dt),
|
|
1327
|
+
"duration": _duration_str(deploy_dt, recover_dt),
|
|
1328
|
+
"waterdepth": waterdepth if waterdepth is not None else "—",
|
|
1329
|
+
"latitude": (
|
|
1330
|
+
cfg.get("seabed_latitude")
|
|
1331
|
+
or cfg.get("deployment_latitude")
|
|
1332
|
+
or cfg.get("planned_latitude")
|
|
1333
|
+
or cfg.get("latitude")
|
|
1334
|
+
or "—"
|
|
1335
|
+
),
|
|
1336
|
+
"longitude": (
|
|
1337
|
+
cfg.get("seabed_longitude")
|
|
1338
|
+
or cfg.get("deployment_longitude")
|
|
1339
|
+
or cfg.get("planned_longitude")
|
|
1340
|
+
or cfg.get("longitude")
|
|
1341
|
+
or "—"
|
|
1342
|
+
),
|
|
1343
|
+
"n_instruments": len(instruments),
|
|
1344
|
+
"instruments": instruments,
|
|
1345
|
+
"rec_deploy": rec_deploy,
|
|
1346
|
+
"rec_recover": rec_recover,
|
|
1347
|
+
"rec_deploy_sec": rec_deploy_sec,
|
|
1348
|
+
"rec_recover_sec": rec_recover_sec,
|
|
1349
|
+
"rec_differs": rec_differs,
|
|
1350
|
+
"yaml_deploy_time": (
|
|
1351
|
+
deploy_dt.strftime("%Y-%m-%dT%H:%M") if deploy_dt else None
|
|
1352
|
+
),
|
|
1353
|
+
"yaml_recover_time": (
|
|
1354
|
+
recover_dt.strftime("%Y-%m-%dT%H:%M") if recover_dt else None
|
|
1355
|
+
),
|
|
1356
|
+
"grid_p_start": grid_p_start,
|
|
1357
|
+
"grid_p_end": grid_p_end,
|
|
1358
|
+
"stack_exists": stack_exists,
|
|
1359
|
+
"grid_exists": grid_exists,
|
|
1360
|
+
"any_clock_correction": any_clock,
|
|
1361
|
+
"fig_clock_check_b64": fig_clock_check_b64,
|
|
1362
|
+
"fig_knockdown_hab_b64": fig_knockdown_hab_b64,
|
|
1363
|
+
"fig_knockdown_anomaly_b64": fig_knockdown_anomaly_b64,
|
|
1364
|
+
"fig_knockdown_displacement_b64": fig_knockdown_displacement_b64,
|
|
1365
|
+
"nav_buttons": _nav_buttons_html(
|
|
1366
|
+
mooring_name,
|
|
1367
|
+
instruments,
|
|
1368
|
+
stack_exists=stack_exists,
|
|
1369
|
+
grid_exists=grid_exists,
|
|
1370
|
+
current_report="summary",
|
|
1371
|
+
array_report_href=_find_array_report_href(out_dir),
|
|
1372
|
+
),
|
|
1373
|
+
"generated": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
|
|
1374
|
+
"proc_machine": socket.gethostname().split(".")[0],
|
|
1375
|
+
"yaml_path": self._rel(yaml_path),
|
|
1376
|
+
"diagram_b64": _load_pdf_b64(proc_dir / f"{mooring_name}_diagram.pdf"),
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
def _render(self, ctx: Dict[str, Any]) -> str:
|
|
1380
|
+
try:
|
|
1381
|
+
from jinja2 import Environment
|
|
1382
|
+
except ImportError as exc:
|
|
1383
|
+
raise ImportError("pip install jinja2") from exc
|
|
1384
|
+
env = Environment(autoescape=True)
|
|
1385
|
+
return env.from_string(_HTML_TEMPLATE).render(**ctx)
|