oceanarray 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. oceanarray/__init__.py +20 -0
  2. oceanarray/_version.py +1 -0
  3. oceanarray/cli.py +1343 -0
  4. oceanarray/clock_offset.py +675 -0
  5. oceanarray/config/OS1_sensor_attrs.yaml +49 -0
  6. oceanarray/config/OS1_var_names.yaml +5 -0
  7. oceanarray/config/OS1_vocab_attrs.yaml +84 -0
  8. oceanarray/config/legacy/project_RAPID.yaml +69 -0
  9. oceanarray/config/legacy/rodb_keys.txt +206 -0
  10. oceanarray/config/legacy/rodb_keys.yaml +1012 -0
  11. oceanarray/config/logging.yaml +28 -0
  12. oceanarray/legacy/__init__.py +47 -0
  13. oceanarray/legacy/caldip.py +753 -0
  14. oceanarray/legacy/convertOS.py +527 -0
  15. oceanarray/legacy/find_deployment.py +489 -0
  16. oceanarray/legacy/mooring_rodb.py +429 -0
  17. oceanarray/legacy/process_rodb.py +369 -0
  18. oceanarray/legacy/rodb.py +330 -0
  19. oceanarray/logger.py +192 -0
  20. oceanarray/logsheet/__init__.py +60 -0
  21. oceanarray/logsheet/_columns.py +97 -0
  22. oceanarray/logsheet/_config.py +245 -0
  23. oceanarray/logsheet/_firmware.py +43 -0
  24. oceanarray/logsheet/_latex.py +128 -0
  25. oceanarray/logsheet/_render.py +126 -0
  26. oceanarray/logsheet/builders/__init__.py +13 -0
  27. oceanarray/logsheet/builders/caldip_download.py +195 -0
  28. oceanarray/logsheet/builders/caldip_setup.py +188 -0
  29. oceanarray/logsheet/builders/deployment_setup.py +218 -0
  30. oceanarray/logsheet/builders/mooring_download.py +243 -0
  31. oceanarray/logsheet/builders/recovery.py +157 -0
  32. oceanarray/logsheet/data/column_defs.yaml +686 -0
  33. oceanarray/logsheet/data/latex_templates/_base_instrument.tex.j2 +42 -0
  34. oceanarray/logsheet/data/latex_templates/caldip_download.tex.j2 +47 -0
  35. oceanarray/logsheet/data/latex_templates/caldip_setup.tex.j2 +70 -0
  36. oceanarray/logsheet/data/latex_templates/deployment_setup.tex.j2 +97 -0
  37. oceanarray/logsheet/data/latex_templates/mooring_download.tex.j2 +54 -0
  38. oceanarray/logsheet/data/latex_templates/recovery.tex.j2 +103 -0
  39. oceanarray/mooring_level.py +1368 -0
  40. oceanarray/oceanarray.mplstyle +18 -0
  41. oceanarray/parameters.py +270 -0
  42. oceanarray/plotter.py +942 -0
  43. oceanarray/plotters/__init__.py +97 -0
  44. oceanarray/plotters/_animation.py +261 -0
  45. oceanarray/plotters/_current.py +705 -0
  46. oceanarray/plotters/_diagnostic.py +667 -0
  47. oceanarray/plotters/_helpers.py +57 -0
  48. oceanarray/plotters/_primitives.py +101 -0
  49. oceanarray/plotters/_section.py +16 -0
  50. oceanarray/plotters/_timeseries.py +11 -0
  51. oceanarray/rapid_interp.py +739 -0
  52. oceanarray/read_rbr_hex.py +541 -0
  53. oceanarray/readers.py +303 -0
  54. oceanarray/report/__init__.py +5 -0
  55. oceanarray/report/_array.py +386 -0
  56. oceanarray/report/_grid.py +539 -0
  57. oceanarray/report/_html_helpers.py +1004 -0
  58. oceanarray/report/_instrument.py +853 -0
  59. oceanarray/report/_mooring.py +1385 -0
  60. oceanarray/report/_plots.py +4284 -0
  61. oceanarray/report/_stack.py +941 -0
  62. oceanarray/stage1.py +1959 -0
  63. oceanarray/stage2.py +1160 -0
  64. oceanarray/stage3.py +2494 -0
  65. oceanarray/time_gridding.py +958 -0
  66. oceanarray/tools.py +400 -0
  67. oceanarray/transports.py +1 -0
  68. oceanarray/utilities.py +538 -0
  69. oceanarray/validation.py +389 -0
  70. oceanarray/writers.py +99 -0
  71. oceanarray-0.1.0.dist-info/METADATA +503 -0
  72. oceanarray-0.1.0.dist-info/RECORD +76 -0
  73. oceanarray-0.1.0.dist-info/WHEEL +5 -0
  74. oceanarray-0.1.0.dist-info/entry_points.txt +2 -0
  75. oceanarray-0.1.0.dist-info/licenses/LICENSE +21 -0
  76. oceanarray-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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 != '—' %}&thinsp;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 &mdash; Processing pipeline</h2>
202
+ <p style="font-size:0.82rem;color:#555;margin-top:-0.5rem;">
203
+ Raw = file present in raw directory &bull;
204
+ Read = format check passed &bull;
205
+ Stage&nbsp;1–3 = processed NetCDF files exist &bull;
206
+ Stack = mooring-level <code>_stack.nc</code> &bull;
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&nbsp;3.
213
+ See <a href="#instruments">Table&nbsp;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&nbsp;(m)</th>
222
+ <th>Depth&nbsp;(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&nbsp;1 ✓</span>
261
+ {% else %}
262
+ <span class="badge b-miss">Stage&nbsp;1 ○</span>
263
+ {% endif %}
264
+ <span class="arrow">›</span>
265
+ {# stage 2 #}
266
+ {% if instr.stages.stage2 %}
267
+ <span class="badge b-ok">Stage&nbsp;2 ✓</span>
268
+ {% else %}
269
+ <span class="badge b-miss">Stage&nbsp;2 ○</span>
270
+ {% endif %}
271
+ <span class="arrow">›</span>
272
+ {# stage 3 #}
273
+ {% if instr.stages.stage3 %}
274
+ <span class="badge b-ok">Stage&nbsp;3 ✓</span>
275
+ {% else %}
276
+ <span class="badge b-miss">Stage&nbsp;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 &mdash; 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&nbsp;3 files where available,
306
+ otherwise stage&nbsp;2. The <strong>P</strong> badge is shown as present
307
+ whether pressure was directly measured or interpolated from neighbouring
308
+ instruments — see Table&nbsp;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&nbsp;(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 &gt;10%">Δt&nbsp;(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>&nbsp;&nbsp;<span style="font-family:monospace">{{ instr.serial }}</span>:
400
+ YAML gives Δt of {{ instr.yaml_interval_s }}&thinsp;s;
401
+ stage file shows Δt of {{ "%.0f"|format(instr.nc.dt_s) }}&thinsp;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&thinsp;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 &mdash; Deployment timing</h2>
416
+ <p style="font-size:0.82rem;color:#555;margin-top:-0.5rem;">
417
+ <em>Stage&nbsp;1</em> and <em>Sugg.&nbsp;(raw)</em> columns are in the <strong>raw
418
+ instrument clock</strong> (uncorrected). <em>Sugg.&nbsp;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&thinsp;&Delta;t (indicating a sinking/rising transient was detected).
425
+ The Stage&nbsp;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&thinsp;&Delta;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&thinsp;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&thinsp;h start/end
434
+ window plots for each instrument (click <em>6&thinsp;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.&nbsp;start (raw)</th>
452
+ <th>Sugg.&nbsp;start (UTC)</th>
453
+ <th>Stage1 last</th>
454
+ <th>Sugg.&nbsp;end (raw)</th>
455
+ <th>Sugg.&nbsp;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&thinsp;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 &rarr; 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
+ &#10003; 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 &mdash; 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&nbsp;(m)</th>
584
+ <th>Offset&nbsp;(s)</th>
585
+ <th>Computer time at recovery</th>
586
+ <th>Instrument time at recovery</th>
587
+ <th>Drift&nbsp;(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&#8209;3 (or stage&#8209;2 if stage&#8209;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 &mdash; 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&nbsp;S/N</th>
668
+ <th>Sensor</th>
669
+ <th>Model</th>
670
+ <th>Sensor&nbsp;S/N</th>
671
+ <th>Cal&nbsp;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 &mdash; 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&nbsp;(1)
734
+ <span style="background:#a8e6cf"></span>prob.&nbsp;good&nbsp;(2)
735
+ <span style="background:#f39c12"></span>suspect&nbsp;(3)
736
+ <span style="background:#e74c3c"></span>bad&nbsp;(4)
737
+ <span style="background:#3498db"></span>interp.&nbsp;(8)
738
+ <span style="background:#bdc3c7"></span>missing&nbsp;(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&nbsp;%</th>
751
+ <th class="num">Suspect&nbsp;%</th>
752
+ <th class="num">Bad&nbsp;%</th>
753
+ <th class="num">Interp.&nbsp;%</th>
754
+ <th class="num">Missing&nbsp;%</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 %}&ndash;{% endif %}</td>
779
+ <td class="num">{% if bad.pct > 0 %}<span style="color:var(--bad)">{{ bad.pct }}</span>{% else %}&ndash;{% endif %}</td>
780
+ <td class="num">{% if interp.pct > 0 %}<span style="color:var(--interp)">{{ interp.pct }}</span>{% else %}&ndash;{% endif %}</td>
781
+ <td class="num">{% if miss.pct > 0 %}{{ miss.pct }}{% else %}&ndash;{% 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&nbsp;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&nbsp;depth&nbsp;&minus;&nbsp;HAB). Instruments below the line were knocked down.
810
+ Interpolated pressure (QC&nbsp;flag&nbsp;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 &minus; nominal) per instrument.
817
+ Positive = deeper than design depth.
818
+ <span style="color:mediumseagreen">Green</span> &lt;&nbsp;100&nbsp;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> &gt;&nbsp;300&nbsp;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>&nbsp;=&nbsp;&radic;(hab<sub>nom</sub>&sup2;&nbsp;&minus;&nbsp;hab<sub>meas</sub>&sup2;).</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 }} &bull;
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)