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,941 @@
1
+ """Stack report HTML template, tilt panels helper, and page generator."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any, Dict, List, Optional
7
+
8
+ import numpy as np
9
+
10
+ from ._html_helpers import (
11
+ _fig_to_base64,
12
+ _find_array_report_href,
13
+ _instrument_report_exists,
14
+ _instrument_report_href,
15
+ _nav_buttons_html,
16
+ _parse_dt,
17
+ _parse_history,
18
+ _read_nc_metadata,
19
+ _status,
20
+ )
21
+ from ._plots import (
22
+ _make_clock_check_b64,
23
+ _make_rose_grid_b64,
24
+ _make_stack_ts_diagram,
25
+ _make_multi_aquadopp_trajectories,
26
+ _make_aquadopp_speed_profile,
27
+ _make_adcp_trajectories_b64,
28
+ _make_analog_timeseries,
29
+ )
30
+ from .. import parameters as P
31
+
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # Stack report HTML template
35
+ # ---------------------------------------------------------------------------
36
+
37
+ _STACK_HTML_TEMPLATE = """\
38
+ <!DOCTYPE html>
39
+ <html lang="en">
40
+ <head>
41
+ <meta charset="UTF-8">
42
+ <meta name="viewport" content="width=device-width, initial-scale=1">
43
+ <title>Stack report &ndash; {{ mooring_name }}</title>
44
+ <style>
45
+ :root { --ocean:#1a3a5c; --seafoam:#e8f4f8; --muted:#95a5a6; --text:#2c3e50;
46
+ --good:#27ae60; --warn:#e67e22; --bad:#c0392b; }
47
+ * { box-sizing:border-box; }
48
+ body { font-family:system-ui,-apple-system,"Segoe UI",sans-serif; font-size:14px;
49
+ color:var(--text); max-width:1200px; margin:0 auto; padding:1.5rem 2rem 4rem; }
50
+ .masthead { background:#2980b9; color:#fff; padding:1.6rem 2rem;
51
+ border-radius:8px; margin-bottom:2rem; }
52
+ .masthead h1 { margin:0 0 0.3rem; font-size:1.75rem; font-weight:700; letter-spacing:0.02em; }
53
+ .masthead .sub { font-size:0.9rem; opacity:0.88; margin:0 0 0.15rem; }
54
+ .masthead .sub a { color:#cef; font-weight:600; text-decoration:none; }
55
+ .masthead .sub a:hover { text-decoration:underline; }
56
+ .meta-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(190px,1fr));
57
+ gap:0.5rem 2rem; font-size:0.84rem; margin-top:0.9rem; }
58
+ .meta-grid dt { opacity:0.7; text-transform:uppercase; font-size:0.7rem;
59
+ letter-spacing:0.06em; margin-bottom:0.1rem; }
60
+ .meta-grid dd { margin:0; font-weight:600; }
61
+ .meta-miss dd { color:#e67e22; opacity:1; }
62
+ h2 { color:var(--ocean); font-size:1rem; border-bottom:2px solid var(--seafoam);
63
+ padding-bottom:0.3rem; margin:2.5rem 0 1rem;
64
+ display:flex; justify-content:space-between; align-items:baseline; }
65
+ .top-link { font-size:0.72rem; font-weight:400; color:var(--muted);
66
+ text-decoration:none; margin-left:auto; white-space:nowrap; }
67
+ .top-link:hover { color:var(--ocean); text-decoration:underline; }
68
+ .fig { width:100%; border:1px solid #dce; border-radius:4px; margin-bottom:1.5rem; }
69
+ .var-table, .instr-table { width:100%; border-collapse:collapse; font-size:0.82rem; margin-bottom:1.5rem; }
70
+ .var-table th, .instr-table th { background:var(--seafoam); text-align:left;
71
+ padding:0.4rem 0.6rem; border-bottom:2px solid #cde; }
72
+ .var-table td, .instr-table td { padding:0.3rem 0.6rem; border-bottom:1px solid #eef; vertical-align:top; }
73
+ .var-table tr:nth-child(even) td, .instr-table tr:nth-child(even) td { background:#f4f9fc; }
74
+ .var-table tr:hover td, .instr-table tr:hover td { background:#e8f4f8; }
75
+ .report-footer { margin-top:3rem; font-size:0.76rem; color:var(--muted); border-top:1px solid #eee; padding-top:0.8rem; }
76
+ .jump-nav { background:var(--seafoam); padding:0.55rem 1rem; border-radius:6px;
77
+ margin-bottom:1.5rem; font-size:0.8rem; line-height:2.2; }
78
+ .jump-nav a { color:var(--ocean); text-decoration:none; font-weight:600;
79
+ margin:0 0.5rem 0 0; white-space:nowrap; }
80
+ .jump-nav a::before { content:"▸ "; font-size:0.7rem; }
81
+ .jump-nav a:hover { text-decoration:underline; }
82
+ .history-list { list-style:none; padding:0; margin:0; }
83
+ .history-list li { display:flex; gap:1rem; padding:0.3rem 0; border-bottom:1px solid #f0f0f0; font-size:0.83rem; }
84
+ .history-list li:last-child { border-bottom:none; }
85
+ .history-ts { color:var(--muted); white-space:nowrap; font-size:0.76rem; min-width:11rem; }
86
+ .history-text { flex:1; }
87
+ td.num { text-align:right; font-variant-numeric:tabular-nums; }
88
+ td.mono { font-family:monospace; font-size:0.8rem; }
89
+ .none-note { color:var(--muted); font-style:italic; }
90
+ .var-qc { color:var(--good); font-size:0.78rem; }
91
+ @media print { .masthead { -webkit-print-color-adjust:exact; print-color-adjust:exact; } }
92
+ </style>
93
+ </head>
94
+ <body>
95
+
96
+ <div id="top" class="masthead">
97
+ <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">Stacked</span></h1>
98
+ <p class="sub" style="text-align:right">generated {{ generated }}</p>
99
+ {{ nav_buttons | safe }}
100
+ <dl class="meta-grid">
101
+ <div{% if cruise == '—' %} class="meta-miss"{% endif %}><dt>Cruise</dt><dd>{{ cruise }}</dd></div>
102
+ <div{% if ship == '—' %} class="meta-miss"{% endif %}><dt>Ship</dt><dd>{{ ship }}</dd></div>
103
+ <div{% if latitude == '—' %} class="meta-miss"{% endif %}><dt>Latitude</dt><dd>{{ latitude }}</dd></div>
104
+ <div{% if longitude == '—' %} class="meta-miss"{% endif %}><dt>Longitude</dt><dd>{{ longitude }}</dd></div>
105
+ <div{% if waterdepth == '—' %} class="meta-miss"{% endif %}><dt>Water depth</dt><dd>{{ waterdepth }}{% if waterdepth != '—' %}&thinsp;m{% endif %}</dd></div>
106
+ <div{% if deploy_time == '—' %} class="meta-miss"{% endif %}><dt>Deployment</dt><dd>{{ deploy_time }}</dd></div>
107
+ <div{% if recover_time == '—' %} class="meta-miss"{% endif %}><dt>Recovery</dt><dd>{{ recover_time }}</dd></div>
108
+ <div{% if duration == '—' %} class="meta-miss"{% endif %}><dt>Duration</dt><dd>{{ duration }}</dd></div>
109
+ <div{% if dt_seconds == '—' %} class="meta-miss"{% endif %}><dt>Samp.&nbsp;&Delta;t</dt><dd>{{ dt_seconds }}{% if dt_seconds != '—' %}&thinsp;s{% endif %}</dd></div>
110
+ <div><dt>Records</dt><dd>{{ n_time }}</dd></div>
111
+ <div><dt>Instruments</dt><dd>{{ n_instr }}</dd></div>
112
+ <div><dt>Source&nbsp;file</dt><dd>{{ nc_file }}</dd></div>
113
+ </dl>
114
+ </div>
115
+
116
+ <nav class="jump-nav">
117
+ Jump to:
118
+ {% if history_entries %}<a href="#history">History</a>{% endif %}
119
+ <a href="#instruments">Instruments</a>
120
+ {% if fig_pressure_b64 %}<a href="#pressure">Pressure</a>{% endif %}
121
+ {% if fig_temp_b64 %}<a href="#temp">Temperature</a>{% endif %}
122
+ {% if fig_sal_b64 %}<a href="#sal">Salinity</a>{% endif %}
123
+ {% if fig_east_vel_b64 or fig_north_vel_b64 or fig_up_vel_b64 %}<a href="#vel">Velocity</a>{% endif %}
124
+ {% if fig_aquadopp_tilt_b64 %}<a href="#tilt">Tilt</a>{% endif %}
125
+ {% if fig_trajectories_b64 or fig_adcp_trajectories_b64 %}<a href="#trajectories">Trajectories</a>{% endif %}
126
+ {% if fig_speed_profile_b64 %}<a href="#speed-profile">Speed profile</a>{% endif %}
127
+ {% if fig_analog_b64 %}<a href="#analog">Analog channels</a>{% endif %}
128
+ {% if fig_ts_stack_b64 %}<a href="#ts">T-S diagram</a>{% endif %}
129
+ {% if fig_rose_grid_b64 %}<a href="#roses">Current roses</a>{% endif %}
130
+ {% if fig_spacing_b64 %}<a href="#spacing">Spacing</a>{% endif %}
131
+ {% if fig_clock_check_b64 %}<a href="#clock-check">Clock check</a>{% endif %}
132
+ <a href="#dims">Dimensions</a>
133
+ <a href="#vars">Variables</a>
134
+ </nav>
135
+
136
+ <!-- Processing history -->
137
+ {% if history_entries %}
138
+ <h2 id="history">Processing history</h2>
139
+ <ul class="history-list">
140
+ {% for e in history_entries %}
141
+ <li>
142
+ <span class="history-ts">{{ e.timestamp }}</span>
143
+ <span class="history-text">{{ e.text }}</span>
144
+ </li>
145
+ {% endfor %}
146
+ </ul>
147
+ {% endif %}
148
+
149
+ <!-- Instrument list -->
150
+ <h2 id="instruments">Instruments (deep-first)</h2>
151
+ <table class="instr-table">
152
+ <thead><tr><th>#</th><th>Type</th><th>Serial</th><th>HAB (m)</th><th>~Depth (m)</th></tr></thead>
153
+ <tbody>
154
+ {% for row in instr_rows %}
155
+ <tr>
156
+ <td>{{ loop.index0 }}</td>
157
+ <td>{{ row.instr_type }}</td>
158
+ <td>{% if row.report_exists %}<a href="{{ row.report_href }}">{{ row.serial }}</a>{% else %}{{ row.serial }}{% endif %}</td>
159
+ <td>{{ row.hab }}</td>
160
+ <td>{{ row.depth }}</td>
161
+ </tr>
162
+ {% endfor %}
163
+ </tbody>
164
+ </table>
165
+
166
+ <!-- Pressure time series -->
167
+ {% if fig_pressure_b64 %}
168
+ <h2 id="pressure">Pressure records (all instruments)</h2>
169
+ <p class="note">Values with QC flag &ge; 3 (suspect/bad) masked to NaN before plotting. All data values are in <code>{{ nc_file }}</code> without masking.</p>
170
+ <img class="fig" src="data:image/png;base64,{{ fig_pressure_b64 }}" alt="Pressure time series">
171
+ {% endif %}
172
+
173
+ <!-- Temperature time series -->
174
+ {% if fig_temp_b64 %}
175
+ <h2 id="temp">Temperature (all instruments)</h2>
176
+ <p class="note">Values with QC flag &ge; 3 (suspect/bad) masked to NaN before plotting. All data values are in <code>{{ nc_file }}</code> without masking.</p>
177
+ <img class="fig" src="data:image/png;base64,{{ fig_temp_b64 }}" alt="Temperature time series">
178
+ {% endif %}
179
+
180
+ <!-- Salinity time series -->
181
+ {% if fig_sal_b64 %}
182
+ <h2 id="sal">Salinity (all instruments)</h2>
183
+ <p class="note">Values with QC flag &ge; 3 (suspect/bad) masked to NaN before plotting. All data values are in <code>{{ nc_file }}</code> without masking.</p>
184
+ <img class="fig" src="data:image/png;base64,{{ fig_sal_b64 }}" alt="Salinity time series">
185
+ {% endif %}
186
+
187
+ {% if fig_dissolved_oxygen_b64 %}
188
+ <h2 id="dissolved-oxygen">Dissolved oxygen (all instruments)</h2>
189
+ <p class="note">One line per instrument with dissolved oxygen data (SBE ODO sensor); QC flags &ge; 3 masked. Units: µmol L⁻¹. % saturation available in per-instrument reports.</p>
190
+ <img class="fig" src="data:image/png;base64,{{ fig_dissolved_oxygen_b64 }}" alt="Dissolved oxygen time series">
191
+ {% endif %}
192
+
193
+ <!-- Velocity time series -->
194
+ {% if fig_east_vel_b64 %}
195
+ <h2 id="vel">East velocity (U)</h2>
196
+ <p class="note">ENU frame. Values with <code>velocity_flag</code> &ge; 3 masked to NaN before plotting. All data values are in <code>{{ nc_file }}</code> without masking. Instruments without velocity data omitted.</p>
197
+ <img class="fig" src="data:image/png;base64,{{ fig_east_vel_b64 }}" alt="East velocity time series">
198
+ {% endif %}
199
+
200
+ {% if fig_north_vel_b64 %}
201
+ <h2>North velocity (V)</h2>
202
+ <p class="note">ENU frame. Values with <code>velocity_flag</code> &ge; 3 masked to NaN before plotting. All data values are in <code>{{ nc_file }}</code> without masking.</p>
203
+ <img class="fig" src="data:image/png;base64,{{ fig_north_vel_b64 }}" alt="North velocity time series">
204
+ {% endif %}
205
+
206
+ {% if fig_up_vel_b64 %}
207
+ <h2>Vertical velocity (W)</h2>
208
+ <p class="note">ENU frame. Values with <code>velocity_flag</code> &ge; 3 masked to NaN before plotting. All data values are in <code>{{ nc_file }}</code> without masking.</p>
209
+ <img class="fig" src="data:image/png;base64,{{ fig_up_vel_b64 }}" alt="Vertical velocity time series">
210
+ {% endif %}
211
+
212
+ {% if fig_turbidity_b64 %}
213
+ <h2 id="turbidity">Turbidity</h2>
214
+ <p class="note">One line per instrument with turbidity data; QC flags &ge; 3 masked. Dots overlaid to reveal individual samples near zero. Units from file attrs (verify: NTU, FTU, or V depending on sensor).</p>
215
+ <img class="fig" src="data:image/png;base64,{{ fig_turbidity_b64 }}" alt="Turbidity time series">
216
+ {% endif %}
217
+
218
+ {% if fig_aquadopp_tilt_b64 %}
219
+ <h2 id="tilt">Aquadopp tilt (|pitch| / |roll| / pressure estimate)</h2>
220
+ <p class="note">
221
+ One panel per Aquadopp (deep-first). Blue = |pitch|, green = |roll|, orange dashed = tilt
222
+ estimated from pressure difference between the Aquadopp and the nearest instrument &ge;10 m above
223
+ with valid pressure (arccos(&Delta;P / rope length)). All curves are non-negative.
224
+ Horizontal lines: orange dashed = suspect threshold, red dotted = fail threshold (read from file attrs).
225
+ Pitch and roll are stored <em>unmasked</em> in the stack file; use <code>pitch_qc</code> /
226
+ <code>roll_qc</code> to filter. Plots show all available values.
227
+ </p>
228
+ <img class="fig" src="data:image/png;base64,{{ fig_aquadopp_tilt_b64 }}" alt="Aquadopp tilt panels">
229
+ {% endif %}
230
+
231
+ {% if fig_trajectories_b64 or fig_adcp_trajectories_b64 %}
232
+ <h2 id="trajectories">Particle trajectories</h2>
233
+ <p class="note">
234
+ Pseudo-Lagrangian displacement: east/north velocity integrated over time
235
+ (Euler forward; NaN velocities set to zero). All trajectories share a common origin (0,&nbsp;0).
236
+ Aquadopp: colour shows temperature (shared scale); end points labelled with serial and HAB.
237
+ ADCP: per-bin trajectories coloured by height above bottom; bins entirely below the seabed are omitted.
238
+ </p>
239
+ <div style="display:flex;gap:1rem;flex-wrap:wrap;align-items:flex-start">
240
+ {% if fig_trajectories_b64 %}
241
+ <div style="flex:1;min-width:280px">
242
+ <p style="text-align:center;font-weight:600;margin-bottom:0.3rem">Aquadopp</p>
243
+ <img class="fig" style="max-width:100%;width:100%" src="data:image/png;base64,{{ fig_trajectories_b64 }}" alt="Aquadopp trajectories">
244
+ </div>
245
+ {% endif %}
246
+ {% if fig_adcp_trajectories_b64 %}
247
+ <div style="flex:0 0 50%;max-width:50%">
248
+ <p style="text-align:center;font-weight:600;margin-bottom:0.3rem">ADCP</p>
249
+ <img class="fig" style="max-width:100%;width:100%" src="data:image/png;base64,{{ fig_adcp_trajectories_b64 }}" alt="ADCP trajectories">
250
+ </div>
251
+ {% endif %}
252
+ </div>
253
+ {% endif %}
254
+
255
+ {% if fig_speed_profile_b64 %}
256
+ <h2 id="speed-profile">Aquadopp speed profile</h2>
257
+ <p class="note">
258
+ Horizontal boxplot per Aquadopp, positioned at its nominal height above bottom
259
+ (per-instrument design value; does not account for mooring knockdown).
260
+ Box = interquartile range; line = median; whiskers = 1.5&times;IQR; dots = outliers.
261
+ Computed from east/north velocity components if <code>current_speed</code> is not stored.
262
+ </p>
263
+ <img class="fig" style="max-width:50%" src="data:image/png;base64,{{ fig_speed_profile_b64 }}" alt="Aquadopp speed profile">
264
+ {% endif %}
265
+
266
+ {% if fig_analog_b64 %}
267
+ <h2 id="analog">Analog channels</h2>
268
+ <p class="note">
269
+ Full-record time series of analog channel variables containing non-zero, non-NaN data.
270
+ One panel per channel.
271
+ </p>
272
+ <img class="fig" src="data:image/png;base64,{{ fig_analog_b64 }}" alt="Analog channels">
273
+ {% endif %}
274
+
275
+ {% if fig_ts_stack_b64 %}
276
+ <h2 id="ts">T-S diagram</h2>
277
+ <p class="note">Left: scatter coloured by pressure. Middle: 2-D count heatmap. Right (when oxygen data present): scatter coloured by O₂ saturation (%). Bad (flag&nbsp;4) and missing (flag&nbsp;9) excluded; interpolated pressure (flag&nbsp;8) retained.</p>
278
+ <img class="fig" src="data:image/png;base64,{{ fig_ts_stack_b64 }}" alt="T-S diagram">
279
+ {% endif %}
280
+
281
+ {% if fig_rose_grid_b64 %}
282
+ <h2 id="roses">Current rose diagrams</h2>
283
+ <p class="note">Direction the current flows toward (oceanographic convention, 0&deg;=N). Speed coloured light&rarr;dark blue (slow&rarr;fast). QC-flagged samples excluded. Title shows serial number and height above bottom (m).</p>
284
+ {% if rose_declination_warn %}
285
+ <p style="font-size:0.82rem;background:#fff8e1;border-left:4px solid #f9a825;padding:0.5rem 0.8rem;margin-bottom:0.6rem;">
286
+ ⚠ Magnetic declination could not be applied to
287
+ {% if rose_declination_missing_serials %}Aquadopp(s) s/n&nbsp;{{ rose_declination_missing_serials | join(', ') }}{% else %}one or more Aquadopps{% endif %}
288
+ — latitude/longitude are missing or all-zero in the mooring YAML (check <code>seabed_latitude</code>, <code>deployment_latitude</code>, or <code>latitude</code>/<code>longitude</code>).
289
+ Re-run <code>oceanarray process … --stage 3</code> for the affected instrument(s) after fixing the YAML.
290
+ Affected ENU velocities currently use 0° declination (magnetic north, not true north).
291
+ </p>
292
+ {% endif %}
293
+ {% if rose_declination_note %}<p class="note">{{ rose_declination_note }}</p>{% endif %}
294
+ <img class="fig" style="max-width:{{ rose_img_width }}%" src="data:image/png;base64,{{ fig_rose_grid_b64 }}" alt="Current rose grid">
295
+ {% endif %}
296
+
297
+ {% if fig_spacing_b64 %}
298
+ <h2 id="spacing">Adjacent instrument spacing</h2>
299
+ <p class="note">Distribution of pressure differences between adjacent instrument pairs (pairs &lt; 2 dbar apart excluded as co-located).</p>
300
+ <img style="max-width:33%;border:1px solid #dce;border-radius:4px" src="data:image/png;base64,{{ fig_spacing_b64 }}" alt="Instrument spacing histogram">
301
+ {% endif %}
302
+
303
+ {% if fig_clock_check_b64 %}
304
+ <h2 id="clock-check">Clock alignment check</h2>
305
+ <p class="note">
306
+ Temperature records from all instruments overlaid, zoomed to the first and last
307
+ 30&thinsp;minutes of the deployment. A horizontal shift between curves indicates
308
+ a clock offset between instruments. Data are from stage&#8209;3 (or stage&#8209;2
309
+ if stage&#8209;3 is not yet available). Instruments without temperature are omitted.
310
+ </p>
311
+ <img class="fig" src="data:image/png;base64,{{ fig_clock_check_b64 }}" alt="Clock alignment check">
312
+ {% endif %}
313
+
314
+ <!-- ══ NetCDF dimensions ══ -->
315
+ <h2 id="dims">NetCDF dimensions &mdash; {{ nc_file }}</h2>
316
+ {% if nc_meta.dims %}
317
+ <table class="var-table" style="max-width:28rem">
318
+ <thead><tr><th>Dimension</th><th class="num">Size</th></tr></thead>
319
+ <tbody>
320
+ {% for dim, size in nc_meta.dims.items() %}
321
+ <tr><td class="mono">{{ dim }}</td><td class="num">{{ "{:,}".format(size) }}</td></tr>
322
+ {% endfor %}
323
+ </tbody>
324
+ </table>
325
+ {% endif %}
326
+
327
+ <!-- ══ NetCDF variables ══ -->
328
+ <h2 id="vars">NetCDF variables &mdash; {{ nc_file }}</h2>
329
+ {% if nc_meta.get("error") %}
330
+ <p class="none-note">Could not read file: {{ nc_meta.error }}</p>
331
+ {% else %}
332
+
333
+ <h3 style="font-size:0.88rem;color:var(--ocean);margin:1rem 0 0.4rem;">Variables</h3>
334
+ <table class="var-table">
335
+ <thead>
336
+ <tr><th>Variable</th><th>Type</th><th>Dims</th><th class="num">N</th><th class="num">Valid</th><th class="num">Min / Max</th><th>Units</th><th>Long name</th><th>Standard name</th><th>QC&nbsp;flag</th></tr>
337
+ </thead>
338
+ <tbody>
339
+ {% for v in nc_meta.time_vars %}
340
+ {% if not v.is_qc %}
341
+ <tr>
342
+ <td class="mono">{{ v.name }}</td>
343
+ <td class="mono" style="font-size:0.75rem">{{ v.dtype }}</td>
344
+ <td class="mono" style="font-size:0.75rem">{{ v.dims }}</td>
345
+ <td class="num">{{ "{:,}".format(v.n) }}</td>
346
+ <td class="num" {% if v.n_valid is defined and v.n_valid < v.n %}style="color:#c0392b;font-weight:600"{% endif %}>{{ "{:,}".format(v.n_valid) if v.n_valid is defined else "&mdash;" }}</td>
347
+ <td class="num" style="font-size:0.76rem;white-space:nowrap">{% if v.v_min is not none %}{{ v.v_min }} / {{ v.v_max }}{% else %}&mdash;{% endif %}</td>
348
+ <td>{{ v.units }}</td>
349
+ <td>{{ v.long_name }}</td>
350
+ <td style="font-size:0.78rem;color:var(--muted)">{{ v.standard_name }}</td>
351
+ <td style="text-align:center">{% if v.has_qc %}<span class="var-qc">✓</span>{% else %}&ndash;{% endif %}</td>
352
+ </tr>
353
+ {% endif %}
354
+ {% endfor %}
355
+ </tbody>
356
+ </table>
357
+
358
+ {% if nc_meta.scalar_vars %}
359
+ <h3 style="font-size:0.88rem;color:var(--ocean);margin:1.4rem 0 0.4rem;">Scalar metadata variables</h3>
360
+ <table class="var-table">
361
+ <thead>
362
+ <tr><th>Variable</th><th>Type</th><th>Value</th><th>Units</th><th>Long name</th></tr>
363
+ </thead>
364
+ <tbody>
365
+ {% for v in nc_meta.scalar_vars %}
366
+ <tr>
367
+ <td class="mono">{{ v.name }}</td>
368
+ <td class="mono" style="font-size:0.75rem">{{ v.dtype }}</td>
369
+ <td class="mono" style="font-size:0.78rem;word-break:break-all">{{ v.value }}</td>
370
+ <td>{{ v.units }}</td>
371
+ <td>{{ v.long_name }}</td>
372
+ </tr>
373
+ {% endfor %}
374
+ </tbody>
375
+ </table>
376
+ {% endif %}
377
+
378
+ {% if nc_meta.global_attrs %}
379
+ <h3 style="font-size:0.88rem;color:var(--ocean);margin:1.4rem 0 0.4rem;">Global attributes</h3>
380
+ <table class="var-table">
381
+ <thead><tr><th>Attribute</th><th>Value</th></tr></thead>
382
+ <tbody>
383
+ {% for k, v in nc_meta.global_attrs.items() %}
384
+ <tr>
385
+ <td class="mono">{{ k }}</td>
386
+ <td style="font-size:0.8rem;word-break:break-all">{{ v }}</td>
387
+ </tr>
388
+ {% endfor %}
389
+ </tbody>
390
+ </table>
391
+ {% endif %}
392
+
393
+ {% endif %}
394
+
395
+ <div class="report-footer">
396
+ Generated by <strong>oceanarray</strong> on {{ generated }}{% if proc_machine %} &bull; {{ proc_machine }}{% endif %}
397
+ </div>
398
+ <script>
399
+ document.querySelectorAll('h2').forEach(h => {
400
+ const a = document.createElement('a'); a.href = '#top'; a.className = 'top-link'; a.textContent = '↑ top'; h.appendChild(a);
401
+ });
402
+ </script>
403
+ </body>
404
+ </html>
405
+ """
406
+
407
+
408
+ # ---------------------------------------------------------------------------
409
+ # Aquadopp tilt helper (was @staticmethod on MooringReport)
410
+ # ---------------------------------------------------------------------------
411
+
412
+
413
+ def _make_aquadopp_tilt_panels(ds: Any, step: int = 1) -> Optional[str]:
414
+ """One subplot per Aquadopp showing pitch, roll, and tilt_from_pressure.
415
+
416
+ All three curves share the same y-axis so they can be compared directly.
417
+ Horizontal reference lines are drawn at the suspect and fail thresholds
418
+ read from ds.attrs (falling back to 20° / 30° if absent).
419
+ Returns None if no Aquadopp levels are found or none of the relevant
420
+ variables exist.
421
+ """
422
+ try:
423
+ import matplotlib.pyplot as plt
424
+ import matplotlib.dates as mdates
425
+
426
+ tilt_suspect = float(ds.attrs.get("tilt_suspect_threshold", 20.0))
427
+ tilt_fail = float(ds.attrs.get("tilt_fail_threshold", 30.0))
428
+
429
+ instr_types = ds["instrument_type"].values
430
+ serials = ds["serial"].values
431
+ habs = ds["hab"].values
432
+ ref_habs = (
433
+ ds["tilt_pressure_ref_hab"].values
434
+ if "tilt_pressure_ref_hab" in ds.data_vars
435
+ else None
436
+ )
437
+ ref_serials = (
438
+ ds["tilt_pressure_ref_serial"].values
439
+ if "tilt_pressure_ref_serial" in ds.data_vars
440
+ else None
441
+ )
442
+
443
+ aq_indices = [
444
+ i for i, t in enumerate(instr_types) if str(t).lower() == "aquadopp"
445
+ ]
446
+ if not aq_indices:
447
+ return None
448
+
449
+ has_pitch = "pitch" in ds.data_vars
450
+ has_roll = "roll" in ds.data_vars
451
+ has_tilt_p = "tilt_from_pressure" in ds.data_vars
452
+ if not has_pitch and not has_roll and not has_tilt_p:
453
+ return None
454
+
455
+ time_ds = ds["time"].values[::step]
456
+ n_panels = len(aq_indices)
457
+ plt.style.use(str(P.MPLSTYLE))
458
+
459
+ fig = plt.figure(figsize=(16, 2.8 * n_panels), constrained_layout=True)
460
+ gs = fig.add_gridspec(n_panels, 3, width_ratios=[2, 2, 1])
461
+
462
+ ax_ts_first = None
463
+ for row, i in enumerate(aq_indices):
464
+ serial = serials[i]
465
+ hab = habs[i]
466
+
467
+ ax_ts = fig.add_subplot(gs[row, :2], sharex=ax_ts_first)
468
+ if ax_ts_first is None:
469
+ ax_ts_first = ax_ts
470
+ ax_sc = fig.add_subplot(gs[row, 2])
471
+
472
+ p_data = r_data = tp_data = None
473
+ if has_pitch:
474
+ p_data = np.abs(ds["pitch"].values[::step, i].astype(float))
475
+ if np.any(np.isfinite(p_data)):
476
+ ax_ts.plot(
477
+ time_ds, p_data, lw=0.7, color="#2980b9", label="|pitch|"
478
+ )
479
+ if has_roll:
480
+ r_data = np.abs(ds["roll"].values[::step, i].astype(float))
481
+ if np.any(np.isfinite(r_data)):
482
+ ax_ts.plot(time_ds, r_data, lw=0.7, color="#27ae60", label="|roll|")
483
+ if has_tilt_p:
484
+ tp_data = ds["tilt_from_pressure"].values[::step, i].astype(float)
485
+ if np.any(np.isfinite(tp_data)):
486
+ ax_ts.plot(
487
+ time_ds,
488
+ tp_data,
489
+ lw=0.9,
490
+ color="#e67e22",
491
+ ls="--",
492
+ label="tilt (pressure)",
493
+ )
494
+
495
+ ax_ts.axhline(tilt_suspect, color="tab:orange", lw=0.8, ls="--", zorder=0)
496
+ ax_ts.axhline(tilt_fail, color="tab:red", lw=0.8, ls=":", zorder=0)
497
+ ax_ts.set_ylim(bottom=0.0)
498
+ ax_ts.set_ylabel("Degrees (°)")
499
+
500
+ _ref_note = ""
501
+ if ref_habs is not None and np.isfinite(ref_habs[i]):
502
+ _ref_s = str(ref_serials[i]) if ref_serials is not None else "?"
503
+ _ref_note = f" [ref: s/n {_ref_s} @ {ref_habs[i]:.0f} m]"
504
+ ax_ts.set_title(f"s/n {serial} ({hab:.0f} m hab){_ref_note}")
505
+ ax_ts.legend(
506
+ loc="upper left",
507
+ bbox_to_anchor=(1.01, 1.0),
508
+ borderaxespad=0,
509
+ framealpha=0.8,
510
+ )
511
+
512
+ if row < n_panels - 1:
513
+ ax_ts.tick_params(labelbottom=False)
514
+ else:
515
+ loc = mdates.AutoDateLocator()
516
+ ax_ts.xaxis.set_major_locator(loc)
517
+ ax_ts.xaxis.set_major_formatter(mdates.ConciseDateFormatter(loc))
518
+ ax_ts.tick_params(axis="x")
519
+
520
+ if tp_data is not None and np.any(np.isfinite(tp_data)):
521
+ sc_kw = dict(s=3, alpha=0.25, rasterized=True, linewidths=0)
522
+ if p_data is not None and np.any(np.isfinite(p_data)):
523
+ fin = np.isfinite(tp_data) & np.isfinite(p_data)
524
+ ax_sc.scatter(
525
+ tp_data[fin],
526
+ p_data[fin],
527
+ color="#2980b9",
528
+ label="|pitch|",
529
+ **sc_kw,
530
+ )
531
+ if r_data is not None and np.any(np.isfinite(r_data)):
532
+ fin = np.isfinite(tp_data) & np.isfinite(r_data)
533
+ ax_sc.scatter(
534
+ tp_data[fin],
535
+ r_data[fin],
536
+ color="#27ae60",
537
+ label="|roll|",
538
+ **sc_kw,
539
+ )
540
+ _lim = max(ax_sc.get_xlim()[1], ax_sc.get_ylim()[1], 35.0)
541
+ ax_sc.plot(
542
+ [0, _lim],
543
+ [0, _lim],
544
+ color="0.4",
545
+ lw=0.8,
546
+ ls="--",
547
+ label="1:1",
548
+ zorder=2,
549
+ )
550
+ ax_sc.axvline(
551
+ tilt_suspect, color="tab:orange", lw=0.7, ls="--", zorder=0
552
+ )
553
+ ax_sc.axvline(tilt_fail, color="tab:red", lw=0.7, ls=":", zorder=0)
554
+ ax_sc.axhline(
555
+ tilt_suspect, color="tab:orange", lw=0.7, ls="--", zorder=0
556
+ )
557
+ ax_sc.axhline(tilt_fail, color="tab:red", lw=0.7, ls=":", zorder=0)
558
+ ax_sc.set_xlim(left=0.0)
559
+ ax_sc.set_ylim(bottom=0.0)
560
+ ax_sc.set_xlabel("tilt (pressure) [°]")
561
+ ax_sc.set_ylabel("|pitch|, |roll| [°]")
562
+ ax_sc.legend(
563
+ loc="upper left",
564
+ bbox_to_anchor=(1.01, 1.0),
565
+ borderaxespad=0,
566
+ framealpha=0.8,
567
+ markerscale=3,
568
+ )
569
+ else:
570
+ ax_sc.text(
571
+ 0.5,
572
+ 0.5,
573
+ "no tilt data",
574
+ transform=ax_sc.transAxes,
575
+ ha="center",
576
+ va="center",
577
+ color="gray",
578
+ )
579
+ ax_sc.set_axis_off()
580
+
581
+ b64 = _fig_to_base64(fig)
582
+ plt.close(fig)
583
+ return b64
584
+ except Exception:
585
+ return None
586
+
587
+
588
+ # ---------------------------------------------------------------------------
589
+ # Page generator
590
+ # ---------------------------------------------------------------------------
591
+
592
+
593
+ def generate_stack_page(
594
+ mooring_name: str,
595
+ stack_path: Path,
596
+ ctx: Dict[str, Any],
597
+ out_dir: Path,
598
+ force: bool,
599
+ base_dir: Path,
600
+ ) -> None:
601
+ """Generate a stack report HTML page with pressure and T time series."""
602
+ out_path = out_dir / f"{mooring_name}_stack_report.html"
603
+ if out_path.exists() and not force:
604
+ _status("skip", str(out_path.relative_to(base_dir)))
605
+ return
606
+
607
+ try:
608
+ import matplotlib.pyplot as plt
609
+ import matplotlib.dates as mdates
610
+ import xarray as xr
611
+
612
+ ds = xr.open_dataset(stack_path).load()
613
+ n_time = ds.sizes["time"]
614
+ n_instr = ds.sizes["N_LEVELS"]
615
+ dt_seconds = ds.attrs.get("dt_seconds", "?")
616
+ waterdepth = float(ds.attrs.get("waterdepth", 0) or 0)
617
+ stack_history = _parse_history(ds.attrs.get("history", ""))
618
+ _t_cov_start = ds.attrs.get("time_coverage_start")
619
+ _t_cov_end = ds.attrs.get("time_coverage_end")
620
+
621
+ step = max(1, n_time // 5000)
622
+ time_ds = ds["time"].values[::step]
623
+
624
+ serials = ds["serial"].values
625
+ instr_types = ds["instrument_type"].values
626
+ habs = ds["hab"].values
627
+
628
+ instr_rows = []
629
+ for i in range(n_instr):
630
+ depth = f"{waterdepth - habs[i]:.0f}" if waterdepth else "—"
631
+ _ser = str(serials[i])
632
+ instr_rows.append(
633
+ {
634
+ "serial": _ser,
635
+ "instr_type": instr_types[i],
636
+ "hab": f"{habs[i]:.1f}",
637
+ "depth": depth,
638
+ "stage": "",
639
+ "report_href": _instrument_report_href(mooring_name, _ser),
640
+ "report_exists": _instrument_report_exists(
641
+ out_dir, mooring_name, _ser
642
+ ),
643
+ }
644
+ )
645
+
646
+ plt.style.use(str(P.MPLSTYLE))
647
+
648
+ _serial_list = list(serials)
649
+ _tab20 = plt.get_cmap("tab20")
650
+ _serial_colors = {s: _tab20(i % 20) for i, s in enumerate(_serial_list)}
651
+
652
+ def _ts_fig(
653
+ varname: str,
654
+ ylabel: str,
655
+ invert: bool = False,
656
+ hlines: Optional[List[tuple]] = None,
657
+ exclude_types: Optional[set] = None,
658
+ dot_overlay: bool = False,
659
+ ) -> Optional[str]:
660
+ if varname not in ds.data_vars:
661
+ return None
662
+ arr = ds[varname].values.copy()
663
+ qc_varname = f"{varname}_qc"
664
+ if qc_varname in ds.data_vars:
665
+ qc = ds[qc_varname].values
666
+ arr[qc >= 3] = np.nan
667
+ fig, ax = plt.subplots(figsize=(13, 4))
668
+ plotted = False
669
+ for i in range(n_instr):
670
+ if exclude_types and instr_types[i].lower() in exclude_types:
671
+ continue
672
+ serial = _serial_list[i]
673
+ color = _serial_colors[serial]
674
+ y = arr[::step, i]
675
+ if not np.any(np.isfinite(y)):
676
+ continue
677
+ plotted = True
678
+ ax.plot(time_ds, y, color=color, lw=0.7, alpha=0.85, label=f"{serial}")
679
+ if dot_overlay:
680
+ ax.plot(
681
+ time_ds,
682
+ y,
683
+ ".",
684
+ color=color,
685
+ markersize=2,
686
+ linewidth=0,
687
+ alpha=0.85,
688
+ )
689
+ if not plotted:
690
+ plt.close(fig)
691
+ return None
692
+ if hlines:
693
+ for val, col, ls, lbl in hlines:
694
+ ax.axhline(val, color=col, lw=0.9, ls=ls, label=lbl, zorder=0)
695
+ if invert:
696
+ ax.invert_yaxis()
697
+ locator = mdates.AutoDateLocator()
698
+ ax.xaxis.set_major_locator(locator)
699
+ ax.xaxis.set_major_formatter(mdates.ConciseDateFormatter(locator))
700
+ ax.set_ylabel(ylabel)
701
+ ax.set_xlabel("Time")
702
+ if _t_cov_start and _t_cov_end:
703
+ try:
704
+ ax.set_xlim(np.datetime64(_t_cov_start), np.datetime64(_t_cov_end))
705
+ except Exception:
706
+ pass
707
+ n_plotted = sum(
708
+ 1 for i in range(n_instr) if np.any(np.isfinite(arr[::step, i]))
709
+ )
710
+ ax.legend(
711
+ loc="upper left",
712
+ bbox_to_anchor=(1.01, 1.0),
713
+ borderaxespad=0,
714
+ framealpha=0.8,
715
+ fontsize=6,
716
+ ncol=2,
717
+ )
718
+ plt.tight_layout()
719
+ b64 = _fig_to_base64(fig)
720
+ plt.close(fig)
721
+ return b64
722
+
723
+ fig_pressure_b64 = _ts_fig(
724
+ "pressure", "Pressure (dbar)", invert=True, exclude_types={"adcp"}
725
+ )
726
+ fig_temp_b64 = _ts_fig(
727
+ "temperature",
728
+ f"Temperature ({ds['temperature'].attrs.get('units', '°C')})"
729
+ if "temperature" in ds
730
+ else "Temperature",
731
+ )
732
+ fig_sal_b64 = (
733
+ _ts_fig(
734
+ "salinity",
735
+ f"Salinity ({ds['salinity'].attrs.get('units', '')})"
736
+ if "salinity" in ds
737
+ else None,
738
+ )
739
+ if "salinity" in ds
740
+ else None
741
+ )
742
+ fig_dissolved_oxygen_b64 = (
743
+ _ts_fig(
744
+ "dissolved_oxygen",
745
+ f"Dissolved oxygen ({ds['dissolved_oxygen'].attrs.get('units', 'µmol/L')})",
746
+ )
747
+ if "dissolved_oxygen" in ds
748
+ else None
749
+ )
750
+ fig_east_vel_b64 = (
751
+ _ts_fig("east_velocity", "U — East velocity (m/s)")
752
+ if "east_velocity" in ds
753
+ else None
754
+ )
755
+ fig_north_vel_b64 = (
756
+ _ts_fig("north_velocity", "V — North velocity (m/s)")
757
+ if "north_velocity" in ds
758
+ else None
759
+ )
760
+ fig_up_vel_b64 = (
761
+ _ts_fig("up_velocity", "W — Up velocity (m/s)")
762
+ if "up_velocity" in ds
763
+ else None
764
+ )
765
+ fig_turbidity_b64 = (
766
+ _ts_fig(
767
+ "turbidity",
768
+ f"Turbidity ({ds['turbidity'].attrs.get('units', 'NTU')})",
769
+ dot_overlay=True,
770
+ )
771
+ if "turbidity" in ds
772
+ else None
773
+ )
774
+
775
+ fig_rose_grid_b64, _n_rose = _make_rose_grid_b64(ds, _serial_list)
776
+ # Width cap: 33% for 1 panel, 50% for 2, 66% for 3, 83% for 4, 100% for 5+
777
+ _rose_w_map = {1: "33", 2: "50", 3: "66", 4: "83"}
778
+ rose_img_width = _rose_w_map.get(_n_rose, "100")
779
+
780
+ _decl_vals: list = []
781
+ _decl_missing = False
782
+ _decl_missing_serials: list = []
783
+ if "magnetic_declination" in ds.data_vars:
784
+ _dv = ds["magnetic_declination"].values
785
+ # _dv may be 0-D (scalar) if mooring_level collapsed identical values
786
+ _dv_flat = _dv.ravel()
787
+ _decl_vals = sorted(
788
+ {round(float(v), 2) for v in _dv_flat if np.isfinite(float(v))}
789
+ )
790
+ if "instrument_type" in ds:
791
+ _aqd_mask = np.array(
792
+ [str(t).lower() == "aquadopp" for t in ds["instrument_type"].values]
793
+ )
794
+ if _dv.ndim == 0:
795
+ # Scalar → same value for all instruments
796
+ _decl_missing = bool(
797
+ _aqd_mask.any() and not np.isfinite(float(_dv))
798
+ )
799
+ else:
800
+ _aqd_bad = _aqd_mask & ~np.isfinite(_dv)
801
+ _decl_missing = bool(_aqd_mask.any() and _aqd_bad.any())
802
+ if _decl_missing:
803
+ _svar = next(
804
+ (v for v in ("serial_number", "serial") if v in ds), None
805
+ )
806
+ if _svar is not None:
807
+ _svals = [str(s) for s in ds[_svar].values]
808
+ _decl_missing_serials = [
809
+ _svals[i] for i, bad in enumerate(_aqd_bad) if bad
810
+ ]
811
+ elif "instrument_type" in ds:
812
+ _aqd_mask = np.array(
813
+ [str(t).lower() == "aquadopp" for t in ds["instrument_type"].values]
814
+ )
815
+ _decl_missing = bool(_aqd_mask.any())
816
+ rose_declination_note = (
817
+ f"Magnetic declination applied: {', '.join(f'{v:+.2f}°' for v in _decl_vals)}"
818
+ if _decl_vals
819
+ else None
820
+ )
821
+ rose_declination_warn = _decl_missing
822
+ rose_declination_missing_serials = _decl_missing_serials
823
+
824
+ fig_spacing_b64: Optional[str] = None
825
+ if "pressure" in ds.data_vars and n_instr > 1:
826
+ try:
827
+ pres_arr = ds["pressure"].values # (time, N_LEVELS)
828
+ med_p = np.nanmedian(pres_arr, axis=0) # one value per N_LEVELS
829
+ sort_idx = np.argsort(med_p)
830
+ pres_sorted = pres_arr[:, sort_idx]
831
+ all_spacings: list = []
832
+ for i in range(1, n_instr):
833
+ spacing = pres_sorted[:, i] - pres_sorted[:, i - 1]
834
+ valid = spacing[np.isfinite(spacing) & (spacing >= 2.0)]
835
+ all_spacings.extend(valid.tolist())
836
+ if all_spacings:
837
+ fig_sp, ax_sp = plt.subplots(figsize=(4, 3))
838
+ ax_sp.hist(
839
+ all_spacings, bins=60, color="steelblue", edgecolor="white"
840
+ )
841
+ ax_sp.set_xlabel("Instrument spacing (dbar)")
842
+ ax_sp.set_ylabel("Count (instrument pair × time step)")
843
+ ax_sp.set_title("Adjacent instrument spacing distribution")
844
+ plt.tight_layout()
845
+ fig_spacing_b64 = _fig_to_base64(fig_sp)
846
+ plt.close(fig_sp)
847
+ except Exception as _exc_sp:
848
+ print(f"WARNING: pressure spacing figure failed: {_exc_sp}")
849
+
850
+ fig_ts_stack_b64 = _make_stack_ts_diagram(ds)
851
+ fig_aquadopp_tilt_b64 = _make_aquadopp_tilt_panels(ds, step=step)
852
+ fig_trajectories_b64 = _make_multi_aquadopp_trajectories(ds)
853
+ fig_adcp_trajectories_b64 = _make_adcp_trajectories_b64(ds)
854
+ fig_speed_profile_b64 = _make_aquadopp_speed_profile(ds)
855
+ # Clock alignment check: one temperature trace per instrument zoomed to
856
+ # first/last 30 min. Build {serial: path} preferring stage3 over stage2.
857
+ proc_dir = stack_path.parent
858
+ _clock_nc_paths: Dict[str, Path] = {}
859
+ for i in range(n_instr):
860
+ _s = str(serials[i])
861
+ _itype = str(instr_types[i])
862
+ _base = proc_dir / _itype / f"{mooring_name}_{_s}"
863
+ _s3 = Path(str(_base) + "_stage3.nc")
864
+ _s2 = Path(str(_base) + "_stage2.nc")
865
+ if _s3.exists():
866
+ _clock_nc_paths[_s] = _s3
867
+ elif _s2.exists():
868
+ _clock_nc_paths[_s] = _s2
869
+ _deploy_dt = _parse_dt(ctx.get("deploy_time"))
870
+ _recover_dt = _parse_dt(ctx.get("recover_time"))
871
+ fig_clock_check_b64 = _make_clock_check_b64(
872
+ _clock_nc_paths, _deploy_dt, _recover_dt
873
+ )
874
+
875
+ ds.close()
876
+
877
+ nc_meta = _read_nc_metadata(stack_path)
878
+ analog_vars = nc_meta.get("analog_vars", [])
879
+ fig_analog_b64 = (
880
+ _make_analog_timeseries(stack_path, analog_vars) if analog_vars else None
881
+ )
882
+ grid_exists = (stack_path.parent / f"{mooring_name}_grid.nc").exists()
883
+
884
+ from jinja2 import Environment
885
+
886
+ env = Environment(autoescape=True)
887
+ html = env.from_string(_STACK_HTML_TEMPLATE).render(
888
+ mooring_name=mooring_name,
889
+ nav_buttons=_nav_buttons_html(
890
+ mooring_name,
891
+ ctx.get("instruments", []),
892
+ stack_exists=True,
893
+ grid_exists=grid_exists,
894
+ current_report="stack",
895
+ array_report_href=_find_array_report_href(out_dir),
896
+ ),
897
+ cruise=ctx.get("cruise", "—"),
898
+ ship=ctx.get("ship", "—"),
899
+ deploy_time=ctx["deploy_time"],
900
+ recover_time=ctx["recover_time"],
901
+ duration=ctx.get("duration", "—"),
902
+ waterdepth=ctx.get("waterdepth", "—"),
903
+ n_instr=n_instr,
904
+ dt_seconds=dt_seconds,
905
+ n_time=n_time,
906
+ mooring_report_link=f"{mooring_name}_report.html",
907
+ grid_exists=grid_exists,
908
+ latitude=ctx.get("latitude", "—"),
909
+ longitude=ctx.get("longitude", "—"),
910
+ history_entries=stack_history,
911
+ instr_rows=instr_rows,
912
+ nc_meta=nc_meta,
913
+ nc_file=stack_path.name,
914
+ fig_pressure_b64=fig_pressure_b64,
915
+ fig_temp_b64=fig_temp_b64,
916
+ fig_sal_b64=fig_sal_b64,
917
+ fig_east_vel_b64=fig_east_vel_b64,
918
+ fig_north_vel_b64=fig_north_vel_b64,
919
+ fig_up_vel_b64=fig_up_vel_b64,
920
+ fig_turbidity_b64=fig_turbidity_b64,
921
+ fig_dissolved_oxygen_b64=fig_dissolved_oxygen_b64,
922
+ fig_rose_grid_b64=fig_rose_grid_b64,
923
+ rose_img_width=rose_img_width,
924
+ rose_declination_note=rose_declination_note,
925
+ rose_declination_warn=rose_declination_warn,
926
+ rose_declination_missing_serials=rose_declination_missing_serials,
927
+ fig_spacing_b64=fig_spacing_b64,
928
+ fig_ts_stack_b64=fig_ts_stack_b64,
929
+ fig_aquadopp_tilt_b64=fig_aquadopp_tilt_b64,
930
+ fig_trajectories_b64=fig_trajectories_b64,
931
+ fig_adcp_trajectories_b64=fig_adcp_trajectories_b64,
932
+ fig_speed_profile_b64=fig_speed_profile_b64,
933
+ fig_clock_check_b64=fig_clock_check_b64,
934
+ fig_analog_b64=fig_analog_b64,
935
+ generated=ctx["generated"],
936
+ proc_machine=ctx.get("proc_machine", ""),
937
+ )
938
+ out_path.write_text(html, encoding="utf-8")
939
+ _status("file", str(out_path.relative_to(base_dir)))
940
+ except Exception as exc:
941
+ print(f" ERROR generating stack report: {exc}")