oceanarray 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- oceanarray/__init__.py +20 -0
- oceanarray/_version.py +1 -0
- oceanarray/cli.py +1343 -0
- oceanarray/clock_offset.py +675 -0
- oceanarray/config/OS1_sensor_attrs.yaml +49 -0
- oceanarray/config/OS1_var_names.yaml +5 -0
- oceanarray/config/OS1_vocab_attrs.yaml +84 -0
- oceanarray/config/legacy/project_RAPID.yaml +69 -0
- oceanarray/config/legacy/rodb_keys.txt +206 -0
- oceanarray/config/legacy/rodb_keys.yaml +1012 -0
- oceanarray/config/logging.yaml +28 -0
- oceanarray/legacy/__init__.py +47 -0
- oceanarray/legacy/caldip.py +753 -0
- oceanarray/legacy/convertOS.py +527 -0
- oceanarray/legacy/find_deployment.py +489 -0
- oceanarray/legacy/mooring_rodb.py +429 -0
- oceanarray/legacy/process_rodb.py +369 -0
- oceanarray/legacy/rodb.py +330 -0
- oceanarray/logger.py +192 -0
- oceanarray/logsheet/__init__.py +60 -0
- oceanarray/logsheet/_columns.py +97 -0
- oceanarray/logsheet/_config.py +245 -0
- oceanarray/logsheet/_firmware.py +43 -0
- oceanarray/logsheet/_latex.py +128 -0
- oceanarray/logsheet/_render.py +126 -0
- oceanarray/logsheet/builders/__init__.py +13 -0
- oceanarray/logsheet/builders/caldip_download.py +195 -0
- oceanarray/logsheet/builders/caldip_setup.py +188 -0
- oceanarray/logsheet/builders/deployment_setup.py +218 -0
- oceanarray/logsheet/builders/mooring_download.py +243 -0
- oceanarray/logsheet/builders/recovery.py +157 -0
- oceanarray/logsheet/data/column_defs.yaml +686 -0
- oceanarray/logsheet/data/latex_templates/_base_instrument.tex.j2 +42 -0
- oceanarray/logsheet/data/latex_templates/caldip_download.tex.j2 +47 -0
- oceanarray/logsheet/data/latex_templates/caldip_setup.tex.j2 +70 -0
- oceanarray/logsheet/data/latex_templates/deployment_setup.tex.j2 +97 -0
- oceanarray/logsheet/data/latex_templates/mooring_download.tex.j2 +54 -0
- oceanarray/logsheet/data/latex_templates/recovery.tex.j2 +103 -0
- oceanarray/mooring_level.py +1368 -0
- oceanarray/oceanarray.mplstyle +18 -0
- oceanarray/parameters.py +270 -0
- oceanarray/plotter.py +942 -0
- oceanarray/plotters/__init__.py +97 -0
- oceanarray/plotters/_animation.py +261 -0
- oceanarray/plotters/_current.py +705 -0
- oceanarray/plotters/_diagnostic.py +667 -0
- oceanarray/plotters/_helpers.py +57 -0
- oceanarray/plotters/_primitives.py +101 -0
- oceanarray/plotters/_section.py +16 -0
- oceanarray/plotters/_timeseries.py +11 -0
- oceanarray/rapid_interp.py +739 -0
- oceanarray/read_rbr_hex.py +541 -0
- oceanarray/readers.py +303 -0
- oceanarray/report/__init__.py +5 -0
- oceanarray/report/_array.py +386 -0
- oceanarray/report/_grid.py +539 -0
- oceanarray/report/_html_helpers.py +1004 -0
- oceanarray/report/_instrument.py +853 -0
- oceanarray/report/_mooring.py +1385 -0
- oceanarray/report/_plots.py +4284 -0
- oceanarray/report/_stack.py +941 -0
- oceanarray/stage1.py +1959 -0
- oceanarray/stage2.py +1160 -0
- oceanarray/stage3.py +2494 -0
- oceanarray/time_gridding.py +958 -0
- oceanarray/tools.py +400 -0
- oceanarray/transports.py +1 -0
- oceanarray/utilities.py +538 -0
- oceanarray/validation.py +389 -0
- oceanarray/writers.py +99 -0
- oceanarray-0.1.0.dist-info/METADATA +503 -0
- oceanarray-0.1.0.dist-info/RECORD +76 -0
- oceanarray-0.1.0.dist-info/WHEEL +5 -0
- oceanarray-0.1.0.dist-info/entry_points.txt +2 -0
- oceanarray-0.1.0.dist-info/licenses/LICENSE +21 -0
- oceanarray-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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 – {{ 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 != '—' %} 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. Δt</dt><dd>{{ dt_seconds }}{% if dt_seconds != '—' %} 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 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 ≥ 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 ≥ 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 ≥ 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 ≥ 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> ≥ 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> ≥ 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> ≥ 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 ≥ 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 ≥10 m above
|
|
223
|
+
with valid pressure (arccos(Δ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, 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×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 4) and missing (flag 9) excluded; interpolated pressure (flag 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°=N). Speed coloured light→dark blue (slow→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 {{ 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 < 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 minutes of the deployment. A horizontal shift between curves indicates
|
|
308
|
+
a clock offset between instruments. Data are from stage‑3 (or stage‑2
|
|
309
|
+
if stage‑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 — {{ 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 — {{ 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 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 "—" }}</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 %}—{% 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 %}–{% 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 %} • {{ 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}")
|