diff --git a/oceanarray/config/parameters.py b/oceanarray/config/parameters.py
index 179bec0..ec035b7 100644
--- a/oceanarray/config/parameters.py
+++ b/oceanarray/config/parameters.py
@@ -22,6 +22,11 @@
import numpy as np
+# ---------------------------------------------------------------------------
+# Package identity (shown as the report masthead wordmark)
+# ---------------------------------------------------------------------------
+PACKAGE_NAME = "oceanarray"
+
# ---------------------------------------------------------------------------
# Matplotlib style path (used by plotters via plt.style.use)
# ---------------------------------------------------------------------------
diff --git a/oceanarray/processors/stage1.py b/oceanarray/processors/stage1.py
index 8088dd3..ee6eff9 100644
--- a/oceanarray/processors/stage1.py
+++ b/oceanarray/processors/stage1.py
@@ -845,8 +845,22 @@ def _iso_date(raw: str) -> str:
return dataset
+ #: Unit strings (lower-cased) that denote conductivity in S/m. A conductivity
+ #: variable carrying any of these is converted to the project's canonical
+ #: mS/cm (1 S/m = 10 mS/cm).
+ _SM_CONDUCTIVITY_UNITS = frozenset(
+ {"s/m", "s m-1", "sm-1", "s.m-1", "siemens/meter", "siemens per metre"}
+ )
+
def _normalize_conductivity(self, dataset: xr.Dataset) -> xr.Dataset:
- """Convert conductivity to mS/cm and rename to 'conductivity' if needed."""
+ """Normalise conductivity to the canonical mS/cm and rename it if needed.
+
+ Handles the raw SBE CNV column names ``cond0S/m`` / ``cond0mS/cm`` and,
+ for inputs that already provide a ``conductivity`` variable, converts it
+ from S/m to mS/cm when its ``units`` attribute says so (1 S/m = 10 mS/cm).
+ The source unit is recorded in ``conductivity_normalised_from`` so the
+ treatment can be reconstructed from the output file alone.
+ """
if "cond0S/m" in dataset:
# S/m → mS/cm: multiply by 10
data = dataset["cond0S/m"] * 10.0
@@ -856,6 +870,19 @@ def _normalize_conductivity(self, dataset: xr.Dataset) -> xr.Dataset:
dataset["conductivity"] = data
elif "cond0mS/cm" in dataset:
dataset = dataset.rename({"cond0mS/cm": "conductivity"})
+
+ # An input already named 'conductivity' but reported in S/m (e.g. an
+ # older seasenselib release) → convert to the canonical mS/cm.
+ if "conductivity" in dataset:
+ src_units = str(dataset["conductivity"].attrs.get("units", "")).strip()
+ if src_units.lower() in self._SM_CONDUCTIVITY_UNITS:
+ data = dataset["conductivity"] * 10.0
+ data.attrs = dict(dataset["conductivity"].attrs)
+ data.attrs["units"] = "mS cm-1"
+ data.attrs["conductivity_normalised_from"] = (
+ f"{src_units} (x10 to mS cm-1)"
+ )
+ dataset["conductivity"] = data
return dataset
# Known SBE CNV variable names containing '/' and their CF-compatible replacements.
diff --git a/oceanarray/reports/_array.py b/oceanarray/reports/_array.py
index 7a70727..73ef90b 100644
--- a/oceanarray/reports/_array.py
+++ b/oceanarray/reports/_array.py
@@ -17,6 +17,7 @@
import numpy as np
+from ._env import render_template
from ._html_helpers import (
_duration_str,
_parse_dt,
@@ -147,185 +148,6 @@ def _draw() -> "plt.Figure":
return render_b64(_draw, optional=True)
-# ---------------------------------------------------------------------------
-# HTML template
-# ---------------------------------------------------------------------------
-
-_ARRAY_HTML_TEMPLATE = """\
-
-
-
-
-
-Array report – {{ array_name }}
-
-
-
-
-
-
-
- Jump to:
- Moorings •
- Instrument summary •
- Completeness
-
-
-{% if fig_map_b64 %}
-Mooring positions
-
-{% endif %}
-
-Moorings
-
-
-
- #
- Mooring
- Latitude
- Longitude
- Depth (m)
- Deployment
- Recovery
- Duration
- Instruments
- Reports
-
-
-
- {% for r in moorings %}
-
- {% if r.color_hex %} {% endif %}{{ r.position }}
- {{ r.mooring }}
- {{ "%.4f"|format(r.lat) if r.lat is not none else "—" }}
- {{ "%.4f"|format(r.lon) if r.lon is not none else "—" }}
- {{ r.waterdepth if r.waterdepth else "—" }}
- {{ r.deploy_time }}
- {{ r.recover_time }}
- {{ r.duration }}
- {{ r.n_instruments }}
-
- {% if r.report_exists %}
- Summary
- {% else %}
- Summary
- {% endif %}
- {% if r.stack_exists %}
- Stack
- {% endif %}
- {% if r.grid_exists %}
- Grid
- {% endif %}
-
-
- {% endfor %}
-
-
-
-{% if type_summary %}
-Instrument type summary
-
-
-
- Type
- Deployed
- Complete (Stage 3 ✓)
- Skipped / no raw
- Stopped early
- Notes
-
-
-
- {% for row in type_summary %}
-
- {{ row.itype }}
- {{ row.deployed }}
- {{ row.complete }}
- {{ row.skipped }}
- {{ row.stopped_early }}
- {{ row.notes }}
-
- {% endfor %}
-
-
-{% endif %}
-
-{% if mooring_completeness %}
-Data completeness by mooring
-
-
-
- Mooring
- Depth (m)
- Deployed
- Complete
- Skipped
- Stopped early
- Deployment (UTC)
- Recovery (UTC)
- Duration
-
-
-
- {% for row in mooring_completeness %}
-
- {{ row.mooring }}
- {{ row.waterdepth if row.waterdepth else "—" }}
- {{ row.deployed }}
- {{ row.complete }}
- {{ row.skipped }}
- {{ row.stopped_early }}
- {{ row.deploy_time }}
- {{ row.recover_time }}
- {{ row.duration }}
-
- {% endfor %}
-
-
-{% endif %}
-
-
-
-"""
-
-
# ---------------------------------------------------------------------------
# Instrument status helpers
# ---------------------------------------------------------------------------
@@ -522,8 +344,6 @@ def generate_array_report(
"""
from datetime import datetime, timezone
- from jinja2 import Environment
-
array_yaml_path = Path(array_yaml_path)
proc_dir = Path(proc_dir)
@@ -655,8 +475,7 @@ def generate_array_report(
"generated": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
}
- env = Environment(autoescape=True)
- html = env.from_string(_ARRAY_HTML_TEMPLATE).render(**ctx)
+ html = render_template("array.html", **ctx)
out_path.write_text(html, encoding="utf-8")
_status("file", str(out_path))
return out_path
diff --git a/oceanarray/reports/_env.py b/oceanarray/reports/_env.py
new file mode 100644
index 0000000..049685a
--- /dev/null
+++ b/oceanarray/reports/_env.py
@@ -0,0 +1,51 @@
+"""File-based Jinja environment for the report HTML templates.
+
+Report page templates live in ``reports/templates/`` and are loaded with a
+:class:`~jinja2.FileSystemLoader` so they can use ``{% extends %}`` and
+``{% include %}`` — which the previous inline template strings could not.
+
+The environment settings are byte-identical to the previous per-module
+``Environment(autoescape=True)`` calls (autoescape on; ``trim_blocks`` and
+``lstrip_blocks`` at Jinja's defaults of ``False``), so migrating a template
+from an inline string to a file does not change the rendered output.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any
+
+from jinja2 import Environment, FileSystemLoader
+
+from .. import parameters as params
+
+#: Directory holding the report page templates.
+TEMPLATES_DIR: Path = Path(__file__).with_name("templates")
+
+_ENV = Environment(
+ loader=FileSystemLoader(str(TEMPLATES_DIR)),
+ autoescape=True,
+ auto_reload=False,
+)
+#: Package identity available to every template (masthead wordmark).
+_ENV.globals["package_name"] = params.PACKAGE_NAME
+
+
+def render_template(name: str, /, **context: Any) -> str:
+ """Render report template *name* with *context* and return the HTML string.
+
+ Parameters
+ ----------
+ name : str
+ Template filename relative to ``reports/templates/`` (e.g.
+ ``"instrument.html"``).
+ **context
+ Template variables, forwarded to :meth:`jinja2.Template.render`.
+
+ Returns
+ -------
+ str
+ The rendered HTML.
+
+ """
+ return _ENV.get_template(name).render(**context)
diff --git a/oceanarray/reports/_grid.py b/oceanarray/reports/_grid.py
index 9e8a5ce..839dfa9 100644
--- a/oceanarray/reports/_grid.py
+++ b/oceanarray/reports/_grid.py
@@ -9,6 +9,7 @@
import numpy as np
from ..utilities import parse_latlon_with_source
+from ._env import render_template
from ._html_helpers import (
_find_array_report_href,
_nav_buttons_html,
@@ -39,353 +40,6 @@
from .. import parameters as params
-# ---------------------------------------------------------------------------
-# Grid report HTML template
-# ---------------------------------------------------------------------------
-
-_GRID_HTML_TEMPLATE = """\
-
-
-
-
-
-Grid report – {{ mooring_name }}
-
-
-
-
-
-
-
- Jump to:
- {% if history_entries %}History {% endif %}
- {% if fig_hydro_b64 %}Hydrography {% endif %}
- {% if fig_vel_stacked_b64 %}Velocity {% endif %}
- {% if fig_ts_grid_b64 %}T-S diagram {% endif %}
- {% if fig_vel_iqr_b64 %}Velocity profiles {% endif %}
- {% if fig_grid_rose_b64 %}Current roses {% endif %}
- {% if fig_grid_hodograph_b64 %}Hodograph {% endif %}
- {% if fig_grid_traj_b64 %}Particle trajectory {% endif %}
- {% if fig_grid_ts_b64 %}Velocity time series {% endif %}
- {% if fig_sigma_b64 or fig_n2_b64 %}Stratification {% endif %}
- {% if sigma_sections or fig_overflow_temp_b64 or fig_isopycnal_coverage_b64 %}Overflow {% endif %}
- {% if fig_spectrum_b64 %}Power spectrum {% endif %}
- {% if fig_wavelet_b64 %}Wavelet {% endif %}
- {% if fig_rotary_b64 %}Rotary spectrum {% endif %}
- Variables
-
-
-{% if history_entries %}
-Processing history
-
- {% for e in history_entries %}
-
- {{ e.timestamp }}
- {{ e.text }}
-
- {% endfor %}
-
-{% endif %}
-
-
-{% if fig_hydro_b64 %}
-Hydrography
-Temperature, salinity, dissolved oxygen, and O₂ saturation (when present). Vertically interpolated to regular pressure grid • 20 discrete colour levels.
-show / hide
-
-
-{% endif %}
-
-
-{% if fig_vel_stacked_b64 %}
-Velocity
-East, north, and up velocity. QC-flagged samples excluded before interpolation. Shared symmetric Spectral_r colormap for east/north; separate bounds for up. No temporal gap fill — NaN where no data.
-show / hide
-
-
-{% endif %}
-
-
-{% if fig_ts_grid_b64 %}
-T-S diagram
-Left: log₁₀(count+1) per T-S bin. Right (when O₂ present): median O₂ saturation per T-S bin — bins with <5 samples masked. Colour scale: BrBG (brown=low, teal=high).
-show / hide
-
-
-{% endif %}
-
-
-{% if fig_vel_iqr_b64 %}
-Velocity IQR profiles
-Median (solid line) and interquartile range (shaded, 25–75 %) across the full deployment at each pressure level. 2.5–97.5 % outer envelope for speed. Right panel: count of non-NaN values per depth.
-show / hide
-
-
-{% endif %}
-
-
-{% if fig_grid_rose_b64 %}
-Current roses
-One rose per pressure level (up to 12, evenly subsampled). Suspect/bad QC excluded. Each petal shows the fraction of time current flowed in that direction; petal length encodes speed (m s⁻¹).
-show / hide
-
-
-{% endif %}
-
-
-{% if fig_grid_hodograph_b64 %}
-Hodograph
-Current hodographs at two pressure levels (25th and 75th percentile of the valid range). Top row = shallower level; bottom row = deeper level. Left = Tukey-smoothed raw; right = eddy component (LP mean removed). Colour indicates fractional time through the deployment (lime circle = start, red square = end). QC-bad data excluded.
-show / hide
-
-
-{% endif %}
-
-
-{% if fig_grid_traj_b64 %}
-Particle trajectory
-Pseudo-Lagrangian trajectories at each gridded pressure level, integrated from east and north velocity (Euler forward). All start at the origin. Coloured by pressure (dbar).
-show / hide
-
-
-{% endif %}
-
-
-{% if fig_grid_ts_b64 %}
-Velocity time series at depth of maximum mean speed
-Depth chosen as the pressure level with the highest time-mean current speed. Speed, east, and north velocity at that level.
-show / hide
-
-
-{% endif %}
-
-
-{% if fig_sigma_b64 or fig_n2_b64 %}
-Stratification
-{% endif %}
-
-{% if fig_sigma_b64 %}
-Potential density. 20 discrete colour levels.
-show / hide sigma0
-
-
-{% endif %}
-
-{% if fig_n2_b64 %}
-Buoyancy frequency squared (N²)
-log₁₀(N²) in s⁻². Purple = strongly stratified; yellow = weakly stratified. Computed from T and S via GSW.
-show / hide N²
-
-
-{% endif %}
-
-{% if sigma_sections or fig_overflow_temp_b64 or fig_isopycnal_coverage_b64 %}
-
-{% endif %}
-
-{% if fig_isopycnal_coverage_b64 %}
-Isopycnal coverage diagnostic
-Percentage of time steps during which each σ₀ surface (0.1 kg m⁻³ spacing) lies within the measured column range. Green ≥ 80 %; amber 50–80 %; red < 50 %. Orange diamond = current --sig-level target. Dashed line = 80 % threshold. NaN gaps in the tracking figure above correspond to times when the isopycnal is outside the measured range (pycnocline above the shallowest grid level, or below the deepest).
-show / hide
-
-
-{% endif %}
-
-{% for sec in sigma_sections %}
-{% if sec.isopycnal_b64 %}
-Isopycnal height above seabed — {{ sec.name }}
-Height above seabed (m) of each target σ₀ surface through time. Tracked by linear interpolation in density space from the gridded {{ sec.name }} field; 1-hour running median applied. NaN where the surface outcrops or is outside the observed range. Light blue = lighter water (shallower); dark blue = denser water (deeper). Targets set by --sig-level (default σ₀ = 27.70).
-show / hide
-
-
-{% endif %}
-{% endfor %}
-
-{% if fig_overflow_temp_b64 %}
-Temperature ~100 m above seabed
-1-hour running median temperature at the grid pressure level nearest to 100 m above the seabed. Level and height-above-seabed shown in figure title.
-show / hide
-
-
-{% endif %}
-
-{% if fig_spectrum_b64 %}
-Temperature power spectrum
-Left: low-frequency overview — 14-day Hann windows, 50 % overlap. Right: high-frequency zoom — 1-day windows (~14× more windows, smoother tidal/inertial estimate); x-axis in hours. LF markers: M2, 1.8 d, 4 d, f. HF markers: M2 (12.4 h), f (inertial). Dashed black line: −2 spectral slope. Colour encodes pressure (light blue = shallow, dark blue = deep). Window length controllable via hf_segment_days in _make_spectrum_fig_b64.
-show / hide
-
-
-{% endif %}
-
-{% if fig_wavelet_b64 %}
-Temperature wavelet scalogram
-Continuous wavelet transform (Morlet, ω0 = 6; Torrence & Compo 1998). Three depth levels: 2nd from top, middle, 2nd from bottom of the 100-dbar-multiple valid levels. Colour shows log10 (power) (°C² d); hatched grey region = cone of influence (edge-affected); hatched cross region = gap-filled data; black contour = 95 % significance against red noise.
-show / hide
-
-
-{% endif %}
-
-{% if fig_rotary_b64 %}
-Rotary velocity spectrum
-CW (clockwise, anticyclonic, solid red lines) and CCW (counter-clockwise, cyclonic, dashed blue lines) power spectra and rotary coefficient r = (CCW−CW)/(CCW+CW). Welch PSD, Hann window, 14-day segments, 50 % overlap. Up to 4 pressure levels (at most 1/5th of valid levels); colour encodes pressure depth. Vertical lines: M2, K1, 1.8 d, 4 d, and inertial period (f). r > 0 = CCW dominant; r < 0 = CW dominant. Physical interpretation (NH): inertial oscillations are inherently CW; for internal waves (f < ω), CW dominance indicates upward energy propagation / downward phase propagation (Leaman & Sanford 1975).
-show / hide
-
-
-{% endif %}
-
-
-NetCDF variables — {{ nc_file }}
-{% if nc_meta.get("error") %}
-Could not read file: {{ nc_meta.error }}
-{% else %}
-
-Variables
-
-
- Variable Type Dims N Valid Min / Max Units Long name Standard name QC flag
-
-
- {% for v in nc_meta.time_vars %}
- {% if not v.is_qc %}
-
- {{ v.name }}
- {{ v.dtype }}
- {{ v.dims }}
- {{ "{:,}".format(v.n) }}
- {{ "{:,}".format(v.n_valid) if v.n_valid is defined else "—" }}
- {% if v.v_min is not none %}{{ v.v_min }} / {{ v.v_max }}{% else %}—{% endif %}
- {{ v.units }}
- {{ v.long_name }}
- {{ v.standard_name }}
- {% if v.has_qc %}✓ {% else %}–{% endif %}
-
- {% endif %}
- {% endfor %}
-
-
-
-{% if nc_meta.scalar_vars %}
-Scalar metadata variables
-
-
- Variable Value Units Long name
-
-
- {% for v in nc_meta.scalar_vars %}
-
- {{ v.name }}
- {{ v.value }}
- {{ v.units }}
- {{ v.long_name }}
-
- {% endfor %}
-
-
-{% endif %}
-
-{% if nc_meta.global_attrs %}
-Global attributes
-
- Attribute Value
-
- {% for k, v in nc_meta.global_attrs.items() %}
-
- {{ k }}
- {{ v }}
-
- {% endfor %}
-
-
-{% endif %}
-
-{% endif %}
-
-
-
-
-
-"""
-
-
# ---------------------------------------------------------------------------
# Page generator
# ---------------------------------------------------------------------------
@@ -502,10 +156,8 @@ def generate_grid_page(
nc_meta = _read_nc_metadata(grid_path)
stack_exists = (grid_path.parent / f"{mooring_name}_stack.nc").exists()
- from jinja2 import Environment
-
- env = Environment(autoescape=True)
- html = env.from_string(_GRID_HTML_TEMPLATE).render(
+ html = render_template(
+ "grid.html",
mooring_name=mooring_name,
nav_buttons=_nav_buttons_html(
mooring_name,
diff --git a/oceanarray/reports/_instrument.py b/oceanarray/reports/_instrument.py
index be7c0ec..a56bb16 100644
--- a/oceanarray/reports/_instrument.py
+++ b/oceanarray/reports/_instrument.py
@@ -8,6 +8,7 @@
from typing import Any, Dict, List, Optional
+from ._env import render_template
from ._html_helpers import (
_duration_str,
_file_info,
@@ -42,512 +43,6 @@
# Per-instrument HTML template
# ---------------------------------------------------------------------------
-_INSTRUMENT_HTML_TEMPLATE = """\
-
-
-
-
-
-{{ instr_type | title }} {{ serial }} — {{ mooring_name }}
-
-
-
-
-
-
-
- Jump to:
- Files
- History
- {% if fig_adcp_velocity_b64 %}Velocity {% endif %}
- Time series
- Start/end windows
- {% if fig_tsd_b64 %}T-S diagram {% endif %}
- {% if fig_adcp_rose_b64 %}Current roses {% endif %}
- {% if fig_rose_b64 %}Current roses {% endif %}
- {% if fig_trajectory_b64 %}Trajectory {% endif %}
- {% if fig_hodograph_b64 %}Hodograph {% endif %}
- {% if fig_speed_boxplot_b64 %}Speed distribution {% endif %}
- {% if fig_analog_b64 %}Analog channels {% endif %}
- Distributions
- {% if qc_summary %}QC thresholds & flags {% endif %}
- {% if nc_meta.dims %}Dimensions {% endif %}
- Variables
-
-
-
-Files
-
- Stage Filename Size Last modified
-
- {% if raw_file.exists %}
-
- Raw
- {{ raw_file.name }}
- {{ raw_file.size }}
- {{ raw_file.mtime }}
-
- {% elif raw_file.unknown %}
-
- Raw
- {{ raw_file.name }}
- — not checked (no --raw-dir)
-
- {% else %}
-
- Raw
- {{ raw_file.name }}
- — not found
-
- {% endif %}
- {% for f in stage_files %}
-
- {{ f.label }}
- {% if f.exists %}
- {{ f.name }}
- {{ f.size }}
- {{ f.mtime }}
- {% else %}
- {{ f.name }}
- — not generated
- {% endif %}
-
- {% endfor %}
-
-
-
-
-Processing history
-{% if history_entries %}
-
- {% for e in history_entries %}
-
- {{ e.timestamp }}
- {{ e.text }}
-
- {% endfor %}
-
-{% else %}
-No history attribute found.
-{% endif %}
-
-
-{% if fig_adcp_velocity_b64 %}
-Velocity
-
- Time–range colour plots of the four velocity components (east, north, up, error).
- Y-axis: range from transducer (m).
- Colour scale: symmetric about zero; percentile 2/98 of all components combined.
- {% if data_stage and data_stage != 'stage3' %}Magnetic declination has not been applied ({{ data_stage }} data).{% endif %}
-
-
-{% endif %}
-
-
-Time series (full deployment)
-{% if fig_ts_b64 %}
-{% for _ts_img in fig_ts_b64 %}
-
-{% endfor %}
-{% else %}
-No plottable variables found.
-{% endif %}
-
-
-Start & end windows — first / last 6 h
-{% if fig_windows_b64 %}
-
- Left panel = first 7 h (1 h lead-in + 6 h of record) |
- Right panel = last 7 h (6 h of record + 1 h tail).
- ││ orange dashed = auto-detected
- (pressure-based) suggested deploy/recover time •
- ││ green dashed = YAML
- deployment_time / recovery_time •
- ││ grey = stage 1 raw record
- (same variable, same units).
-
-
- Reading the grey background:
- where the raw stage 1 record is available and uses the same physical
- units as the stage 2/3 data, it is shown in light grey so you can
- visually verify that the chosen deployment window captures the in-water
- period. Cases where grey may be absent:
-
- Sensors without their own pressure (e.g. a microCAT that
- relies on interpolated pressure from a nearby instrument) — interpolated
- pressure is only added at stage 3, so stage 1 has no pressure
- variable and no grey trace appears.
- Velocity in beam or XYZ coordinates — if an Aquadopp or
- ADCP was configured to record in BEAM or XYZ mode, the stage 1 file
- contains beam/XYZ velocity variables while stage 3 stores
- ENU velocities; the variable names differ so no grey velocity trace is shown.
- Conductivity — raw conductivity units sometimes differ
- between stage 1 and stage 3 (e.g. mS/cm vs S/m) and the
- grey trace is suppressed automatically to avoid a misleading scale mismatch.
-
- If the orange vline is absent from the right-hand panel,
- no pressure-based recovery transition was detected in the final 25 % of the
- stage 1 record — the suggested recovery time equals the last raw sample.
- Check the timing table in the mooring summary report for the suggested UTC time.
-
-{% for _win_img in fig_windows_b64 %}
-
-{% endfor %}
-{% else %}
-Insufficient data for start/end windows.
-{% endif %}
-
-
-{% if fig_tsd_b64 %}
-T-S diagram
-
- Coloured by pressure (or sample index). × = suspect | × = bad (QC flags).
-
-
-{% endif %}
-
-
-{% if fig_adcp_rose_b64 %}
-Current roses
-
- Depth-average followed by bins nearest 100, 200, 300 and 400 m from the transducer.
- Bins with fewer than two valid samples are omitted.
- Direction toward which the current flows; 0° = N, clockwise.
- {% if data_stage and data_stage != 'stage3' %}Magnetic declination not yet applied ({{ data_stage }} data).{% endif %}
-
-
-{% endif %}
-
-
-{% if fig_rose_b64 %}
-Current rose diagrams
-{% if declination_warn %}
-
- ⚠ Magnetic declination could not be applied — latitude/longitude are missing or all-zero in the mooring YAML (check seabed_latitude, deployment_latitude, or latitude/longitude).
- ENU velocities use 0° declination (magnetic north, not true north).
-
-{% endif %}
-
- {% if rose_has_xyz %}XYZ: instrument-frame velocities (before geographic rotation). {% endif %}ENU panels split by QARTOD flag: good (flag ≤ 2, Blues), suspect (flag 3, Oranges), fail (flag 4, Reds).
- Direction toward which the current flows; 0° = N, clockwise.
-
-
-{% endif %}
-
-
-{% if fig_trajectory_b64 %}
-Particle trajectory
-
- Pseudo-Lagrangian displacement obtained by integrating east/north velocity over time
- (Euler forward; NaN velocities set to zero). Coloured by temperature.
- Origin (0, 0) = deployment position; axes in metres.
-
-
-{% endif %}
-
-
-{% if fig_hodograph_b64 %}
-Hodograph
-
- East vs north velocity (m s-1 ).
- Left : full record.
- Right : eddy component — raw minus 4-day low-pass (rolling mean).
-
-
-{% endif %}
-
-
-{% if fig_speed_boxplot_b64 %}
-Current speed distribution
-
-{% endif %}
-
-
-{% if fig_analog_b64 %}
-Analog channels
-
- Full-record time series of analog channel variables containing non-zero, non-NaN data.
- One panel per channel.
- Labels and units are read from the mooring YAML — update them there to change what appears here.
-
-{% if analog_yaml_info %}
-
- {% for ch in analog_yaml_info %}
-
- {{ ch.varname }} — from YAML:
- {{ ch.yaml_key }}: {{ ch.label if ch.label else "(not set)" }}{% if ch.units %},
- {{ ch.yaml_key }}_units: "{{ ch.units }}"{% endif %}{% if ch.serial %},
- {{ ch.yaml_key }}_serial_number: {{ ch.serial }}{% endif %}
-
- {% endfor %}
-
-{% endif %}
-
-{% endif %}
-
-
-Data value distributions
-
- Orange dashed = suspect threshold | Red dotted = fail threshold (gross-range QC).
- Histogram shows non-bad data only; bad-flagged count noted in red.
-
-{% if fig_dt_b64 %}
-
-{% else %}
-Not enough samples to compute.
-{% endif %}
-
-
-{% if qc_summary %}
-QC flag breakdown
-
-{% if qc_thresholds %}
-Thresholds applied (as stored in stage 3 file)
-
-
-
- Variable
- Test
- Suspect range / threshold
- Fail range / threshold
-
-
-
- {% for row in qc_thresholds %}
-
- {{ row.var }}
- {{ row.test }}
- {{ row.suspect }}
- {{ row.fail }}
-
- {% endfor %}
-
-
-{% endif %}
-
-Flag counts
-
-
-
- Variable
- N
- Good %
- Suspect %
- Bad %
- Interp. %
- Missing %
- Distribution
-
-
-
- {% for row in qc_summary %}
- {% set good = row.flags | selectattr("flag", "eq", 1) | first %}
- {% set susp = row.flags | selectattr("flag", "eq", 3) | first %}
- {% set bad = row.flags | selectattr("flag", "eq", 4) | first %}
- {% set interp = row.flags | selectattr("flag", "eq", 8) | first %}
- {% set miss = row.flags | selectattr("flag", "eq", 9) | first %}
-
- {{ row.var }}
- {{ "{:,}".format(row.total) }}
- {{ good.pct }}
- {% if susp.n > 0 %}{% if susp.pct > 0 %}{{ susp.pct }}{% else %}<0.1% ({{ susp.n }}){% endif %} {% else %}–{% endif %}
- {% if bad.n > 0 %}{% if bad.pct > 0 %}{{ bad.pct }}{% else %}<0.1% ({{ bad.n }}){% endif %} {% else %}–{% endif %}
- {% if interp.n > 0 %}{% if interp.pct > 0 %}{{ interp.pct }}{% else %}<0.1% ({{ interp.n }}){% endif %} {% else %}–{% endif %}
- {% if miss.n > 0 %}{% if miss.pct > 0 %}{{ miss.pct }}{% else %}<0.1% ({{ miss.n }}){% endif %} {% else %}–{% endif %}
-
-
- {% for f in row.flags %}{% if f.pct > 0 %}
-
- {% endif %}{% endfor %}
-
-
-
- {% endfor %}
-
-
-{% endif %}
-
-
-{% if nc_meta.dims %}
-NetCDF dimensions — {{ nc_file }}
-
- Dimension Size
-
- {% for dim, size in nc_meta.dims.items() %}
- {{ dim }} {{ "{:,}".format(size) }}
- {% endfor %}
-
-
-{% endif %}
-
-
-NetCDF variables — {{ nc_file }}
-{% if nc_meta.get("error") %}
-Could not read file: {{ nc_meta.error }}
-{% else %}
-
-Time-series variables
-
-
- Variable Type Dims N Valid Min / Max Units Long name Standard name QC flag
-
-
- {% for v in nc_meta.time_vars %}
- {% if not v.is_qc %}
-
- {{ v.name }}
- {{ v.dtype }}
- {{ v.dims }}
- {{ "{:,}".format(v.n) }}
- {{ "{:,}".format(v.n_valid) if v.n_valid is defined else "—" }}
- {% if v.v_min is not none %}{{ v.v_min }} / {{ v.v_max }}{% else %}—{% endif %}
- {{ v.units }}
- {{ v.long_name }}
- {{ v.standard_name }}
- {% if v.has_qc %}✓ {% else %}–{% endif %}
-
- {% endif %}
- {% endfor %}
-
-
-
-{% if nc_meta.scalar_vars %}
-Scalar metadata variables
-
-
- Variable Type Value Units Long name
-
-
- {% for v in nc_meta.scalar_vars %}
-
- {{ v.name }}
- {{ v.dtype }}
- {{ v.value }}
- {{ v.units }}
- {{ v.long_name }}
-
- {% endfor %}
-
-
-{% endif %}
-
-{% if nc_meta.global_attrs %}
-Global attributes
-
- Attribute Value
-
- {% for k, v in nc_meta.global_attrs.items() %}
-
- {{ k }}
- {{ v }}
-
- {% endfor %}
-
-
-{% endif %}
-
-{% endif %}
-
-
-
-
-
-"""
-
# ---------------------------------------------------------------------------
# Page generator
@@ -859,10 +354,7 @@ def generate_instrument_pages(
ctx["rose_has_xyz"] = "velocity_x" in _tvar_names
try:
- from jinja2 import Environment
-
- env = Environment(autoescape=True)
- html = env.from_string(_INSTRUMENT_HTML_TEMPLATE).render(**ctx)
+ html = render_template("instrument.html", **ctx)
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(html, encoding="utf-8")
print(f"{prefix} {out_path.name}")
diff --git a/oceanarray/reports/_mooring.py b/oceanarray/reports/_mooring.py
index 0a65925..daac1a4 100644
--- a/oceanarray/reports/_mooring.py
+++ b/oceanarray/reports/_mooring.py
@@ -31,6 +31,7 @@
_stage_files,
_status,
)
+from ._env import render_template
from ._grid import generate_grid_page
from ._instrument import generate_instrument_pages
from ._plots import (
@@ -43,853 +44,6 @@
from ..utilities import extract_inline_instruments
-# ---------------------------------------------------------------------------
-# Mooring summary HTML template
-# ---------------------------------------------------------------------------
-
-_HTML_TEMPLATE = """\
-
-
-
-
-
-Mooring Recovery Report – {{ mooring_name }}
-
-
-
-
-
-
-
-
- Jump to:
- Processing pipeline
- Instruments
- Deployment timing
- Clock corrections
- Sensor calibration
- QC summary
- {% if fig_knockdown_hab_b64 or fig_knockdown_anomaly_b64 %}Knockdown {% endif %}
- {% if diagram_b64 %}Mooring diagram {% endif %}
-
-
-
-2 — Processing pipeline
-
- Raw = file present in raw directory •
- Read = format check passed •
- Stage 1–3 = processed NetCDF files exist •
- Stack = mooring-level _stack.nc •
- Grid = pressure-gridded _grid.nc
-
-
- Instruments marked skip: true in the YAML are excluded from all
- processing and are shown with a skipped note in the Note column.
- Stack and Grid pills are grey for any instrument that did not reach Stage 3.
- See Table 3 for skip reasons where given.
-
-
-
-
- #
- Type
- S/N
- Hab (m)
- Depth (m)
- Pipeline
- Note
-
-
-
- {% for instr in instruments %}
-
- {{ loop.index }}
- {{ instr.instr_type }}
- {% if instr.report_exists %}{{ instr.serial }} {% else %}{{ instr.serial }} {% endif %}
- {{ "%.1f"|format(instr.hab) }}
- {{ "%.0f"|format(instr.depth) if instr.depth is not none else "—" }}
-
-
- {# raw file #}
- {% if instr.raw_exists %}
- Raw ✓
- {% elif instr.filename %}
- Raw ✗
- {% else %}
- Raw —
- {% endif %}
- ›
- {# readability check #}
- {% if instr.raw_exists %}
- {% if instr.readable %}
- Read ✓
- {% else %}
- Read ✗
- {% endif %}
- {% else %}
- Read —
- {% endif %}
- ›
- {# stage 1 #}
- {% if instr.stages.stage1 %}
- Stage 1 ✓
- {% else %}
- Stage 1 ○
- {% endif %}
- ›
- {# stage 2 #}
- {% if instr.stages.stage2 %}
- Stage 2 ✓
- {% else %}
- Stage 2 ○
- {% endif %}
- ›
- {# stage 3 #}
- {% if instr.stages.stage3 %}
- Stage 3 ✓
- {% else %}
- Stage 3 ○
- {% endif %}
- ›
- {# stack — only coloured if this instrument is in the stack file #}
- {% if instr.in_stack %}
- Stack ✓
- {% else %}
- Stack ○
- {% endif %}
- ›
- {# grid — only coloured if this instrument is in the grid file #}
- {% if instr.in_grid %}
- Grid ✓
- {% else %}
- Grid ○
- {% endif %}
-
-
-
- {% if instr.skipped %}skipped{% endif %}
-
-
- {% endfor %}
-
-
-
-
-3 — Instrument summary
-
- Variables and record length are read from stage 3 files where available,
- otherwise stage 2. The P badge is shown as present
- whether pressure was directly measured or interpolated from neighbouring
- instruments — see Table 6 (QC flag summary) to distinguish. Instruments
- marked skip: true are listed with the skip reason where provided.
-
-
-
-
-
- #
- Type
- S/N
- Hab (m)
- First sample
- Last sample
- N records
- Δt (s)
- P range (dbar)
- T
- C
- P
- U
- V
-
-
-
- {% for instr in instruments %}
-
- {{ loop.index }}
- {{ instr.instr_type }}
- {% if instr.report_exists %}{{ instr.serial }} {% else %}{{ instr.serial }} {% endif %}
- {{ "%.1f"|format(instr.hab) }}
- {% if instr.nc and instr.nc.get("error") %}
- Error: {{ instr.nc.error }}
- {% elif instr.nc and instr.nc.get("t_start") %}
- {{ instr.nc.t_start }}
- {{ instr.nc.t_end }}
- {{ "{:,}".format(instr.nc.n_records) }}
-
- {% set dt = instr.nc.dt_s %}
- {% if dt == dt %}{# NaN check: NaN != NaN #}
- {{ "%.0f"|format(dt) }}{% if instr.dt_mismatch %} * {% endif %}
- {% else %}
- —
- {% endif %}
-
-
- {% if instr.nc.p_min is not none and instr.nc.p_max is not none %}
- {% if instr.nc.get("pressure_interpolated") %}
- ({{ "%.0f"|format(instr.nc.p_min) }}, {{ "%.0f"|format(instr.nc.p_max) }})
- {% else %}
- ({{ "%.0f"|format(instr.nc.p_min) }}, {{ "%.0f"|format(instr.nc.p_max) }})
- {% endif %}
- {% else %}
- —
- {% endif %}
-
- {% for label, present in instr.nc.shorthands %}
-
- {% if label == "P" and present and instr.nc.get("pressure_interpolated") %}
- {{ label }}
- {% else %}
- {{ label }}
- {% endif %}
-
- {% endfor %}
- {% elif instr.skipped %}
- skipped{% if instr.skip_reason %} — {{ instr.skip_reason }}{% endif %}
- {% else %}
- no processed file
- {% endif %}
-
- {% endfor %}
-
-
-
-{% set dt_mismatches = instruments | selectattr("dt_mismatch") | list %}
-{% if dt_mismatches %}
-
- * Δt mismatch (YAML vs observed p90):
- {% for instr in dt_mismatches %}
- {{ instr.serial }} :
- YAML gives Δt of {{ instr.yaml_interval_s }} s;
- stage file shows Δt of {{ "%.0f"|format(instr.nc.dt_s) }} s.
- {% endfor %}
-
-{% endif %}
-
-{% if grid_p_start is not none and grid_p_end is not none %}
-
- Recommended pressure range for oceanarray grid, derived from the
- min/max pressure across all instruments (rounded outward to the nearest 20 dbar):
-
---pmin {{ grid_p_start }} --pmax {{ grid_p_end }}
-{% endif %}
-
-
-3.5 — Deployment timing
-
- Stage 1 and Sugg. (raw) columns are in the raw
- instrument clock (uncorrected). Sugg. UTC columns
- apply the constant clock offset (and drift, for the end) and are safe to
- paste into the YAML deployment_time /
- recovery_time fields. Suggested UTC cells are highlighted
- amber when the
- pressure-derived suggested time differs from the YAML time by more than
- 2 Δt (indicating a sinking/rising transient was detected).
- The Stage 1 last cell is highlighted
- orange when the
- raw record ended more than 2 Δt before the YAML recovery time
- (instrument may have stopped early).
- Serial numbers link to the per-instrument report; the
- 6 h link jumps directly to the start/end window plots.
-
-
- Spot-check recommended: open the 6 h start/end
- window plots for each instrument (click 6 h ) and visually
- confirm that the orange suggested line (or green YAML line if they match)
- sits at a plausible transition in the pressure (or temperature) record
- before updating the YAML times.
-
-
-
-
-
- #
- Type
- S/N
- Start
- End
-
-
- Stage1 first
- Sugg. start (raw)
- Sugg. start (UTC)
- Stage1 last
- Sugg. end (raw)
- Sugg. end (UTC)
-
-
-
- {% for instr in instruments %}
- {% set tm = instr.timing %}
-
- {{ loop.index }}
- {{ instr.instr_type }}
-
- {{ instr.serial }}
- 6 h
-
- {% if tm %}
- {# Anchor dates for deduplication: show only time when on the same day #}
- {% set d_start = (tm.get("stage1_start") or "")[:10] %}
- {% set d_end = (tm.get("stage1_end") or "")[:10] %}
- {# Stage1 first — always full date+time; anchors deduplication for start columns #}
- {{ tm.get("stage1_start") or "—" }}
- {# Sugg. start (raw) — only present for pressure instruments; time only when same date #}
- {% set v = tm.get("sugg_start") or "" %}
-
- {{- v[11:] if (v and v[:10] == d_start) else (v or "—") -}}
-
- {# Sugg. start (UTC) #}
- {% set v = tm.get("sugg_start_utc") or "" %}
-
- {{- v[11:] if (v and v[:10] == d_start) else (v or "—") -}}
-
- {# Stage1 last — orange-amber when instrument stopped early #}
-
- {{- tm.get("stage1_end") or "—" -}}
-
- {# Sugg. end (raw) #}
- {% set v = tm.get("sugg_end") or "" %}
-
- {{- v[11:] if (v and v[:10] == d_end) else (v or "—") -}}
-
- {# Sugg. end (UTC) #}
- {% set v = tm.get("sugg_end_utc") or "" %}
-
- {{- v[11:] if (v and v[:10] == d_end) else (v or "—") -}}
-
- {% elif instr.skipped %}
- skipped
- {% else %}
- no stage 2 file
- {% endif %}
-
- {% endfor %}
-
- {% if rec_deploy_sec or rec_recover_sec %}
-
-
- ★
- Mooring
- —
- Mooring start
- {{ rec_deploy_sec or "—" }}
- —
- Mooring end
- {{ rec_recover_sec or "—" }}
-
-
- {% endif %}
-
-
-
-{% if yaml_deploy_time or yaml_recover_time or rec_deploy or rec_recover %}
-
-{% if rec_differs %}
-
- Deployment times — pressure-based instruments suggest times that differ
- from the current YAML. Copy the suggested snippet (blue border) into your YAML file.
- Times are given to the minute.
-
-
-
-
-
- Suggested (copy → paste into YAML)
-
-
-
-{% else %}
-
- ✓ Current YAML times match the pressure-based suggestion from the instruments.
-
- Current YAML times
-
-{% endif %}
-
-{% endif %}
-
-
-4 — Clock corrections
-
- Positive drift/offset = instrument was slow (behind UTC); correction shifts times later.
- Negative = instrument was fast (ahead of UTC); correction shifts times earlier.
-
-
-
-
- #
- Type
- S/N
- Hab (m)
- Offset (s)
- Computer time at recovery
- Instrument time at recovery
- Drift (s)
- Source
-
-
-
- {% for instr in instruments %}
-
- {{ loop.index }}
- {{ instr.instr_type }}
- {{ instr.serial }}
- {{ "%.1f"|format(instr.hab) }}
-
- {% if instr.clock.offset_s == 0 %}
- —
- {% elif instr.clock.offset_s > 0 %}
- +{{ "%.1f"|format(instr.clock.offset_s) }}
- {% else %}
- {{ "%.1f"|format(instr.clock.offset_s) }}
- {% endif %}
-
- {% if instr.clock.computer_time %}{{ instr.clock.computer_time }}{% else %}— {% endif %}
- {% if instr.clock.instrument_time %}{{ instr.clock.instrument_time }}{% else %}— {% endif %}
-
- {% if instr.clock.drift_s is none or instr.clock.drift_s == 0 %}
- —
- {% elif instr.clock.drift_s > 0 %}
- +{{ "%.1f"|format(instr.clock.drift_s) }}
- {% else %}
- {{ "%.1f"|format(instr.clock.drift_s) }}
- {% endif %}
-
-
- {% if instr.clock.method == "none" %}
- none
- {% else %}
- {{ instr.clock.method }}
- {% endif %}
-
-
- {% endfor %}
-
-
-
-{% if fig_clock_check_b64 %}
-Clock alignment check
-
- Overlaid temperature records zoomed to the first and last 30 minutes of the deployment.
- If instrument clocks are misaligned the temperature curves will appear shifted in time.
- Data are from stage‑3 (or stage‑2 if stage‑3 is not yet available).
- Instruments with no temperature data are omitted.
-
-
-{% endif %}
-
-
-5 — Sensor calibration
-
-{% set has_sensors = instruments | selectattr("sensors") | list %}
-{% if has_sensors %}
-
-
-
- #
- Type
- Instr S/N
- Sensor
- Model
- Sensor S/N
- Cal date
- Coefficients
-
-
-
- {% set ns = namespace(idx=0) %}
- {% for instr in instruments %}
- {% if instr.sensors %}
- {% set ns.idx = ns.idx + 1 %}
- {% for sensor in instr.sensors %}
-
- {% if loop.index0 == 0 %}{{ ns.idx }}{% endif %}
- {% if loop.index0 == 0 %}{{ instr.instr_type }}{% endif %}
- {% if loop.index0 == 0 %}{{ instr.serial }}{% endif %}
- {{ sensor.sensor_type | title }}
- {{ sensor.sensor_model }}
- {{ sensor.sensor_serial }}
- {{ sensor.cal_date }}
-
- {% if sensor.coefficients %}
-
- show
- {{ sensor.coefficients }}
-
- {% else %}
- —
- {% endif %}
-
-
- {% endfor %}
- {% endif %}
- {% endfor %}
-
-
-{% else %}
-No sensor calibration metadata found in processed files.
-{% endif %}
-
-
-6 — QC flag summary
-
-
- good (1)
- prob. good (2)
- suspect (3)
- bad (4)
- interp. (8)
- missing (9)
-
-{% set has_qc = instruments | selectattr("qc_summary") | list %}
-{% if has_qc %}
-
-
-
- #
- Type
- S/N
- Variable
- N
- Good %
- Suspect %
- Bad %
- Interp. %
- Missing %
- Distribution
-
-
-
- {% set ns = namespace(idx=0) %}
- {% for instr in instruments %}
- {% if instr.qc_summary %}
- {% set ns.idx = ns.idx + 1 %}
- {% for row in instr.qc_summary %}
- {% set good = row.flags | selectattr("flag", "eq", 1) | first %}
- {% set susp = row.flags | selectattr("flag", "eq", 3) | first %}
- {% set bad = row.flags | selectattr("flag", "eq", 4) | first %}
- {% set interp = row.flags | selectattr("flag", "eq", 8) | first %}
- {% set miss = row.flags | selectattr("flag", "eq", 9) | first %}
-
- {% if loop.index0 == 0 %}{{ ns.idx }}{% endif %}
- {% if loop.index0 == 0 %}{{ instr.instr_type }}{% endif %}
- {% if loop.index0 == 0 %}{{ instr.serial }}{% endif %}
- {{ row.var }}
- {{ "{:,}".format(row.total) }}
-
- {{ good.pct }}
-
- {% if susp.pct > 0 %}{{ susp.pct }} {% else %}–{% endif %}
- {% if bad.pct > 0 %}{{ bad.pct }} {% else %}–{% endif %}
- {% if interp.pct > 0 %}{{ interp.pct }} {% else %}–{% endif %}
- {% if miss.pct > 0 %}{{ miss.pct }}{% else %}–{% endif %}
-
-
- {% for f in row.flags %}
- {% if f.pct > 0 %}
-
- {% endif %}
- {% endfor %}
-
-
-
- {% endfor %}
- {% endif %}
- {% endfor %}
-
-
-{% else %}
-No stage 3 QC files found — run oceanarray stage3 first.
-{% endif %}
-
-{% if fig_knockdown_hab_b64 or fig_knockdown_anomaly_b64 or fig_knockdown_displacement_b64 %}
-
-Mooring knockdown
-
-{% if fig_knockdown_hab_b64 %}
-
-
Nominal HAB (x) vs. measured pressure (y). The dashed line shows expected pressure
- (water depth − HAB). Instruments below the line were knocked down.
- Interpolated pressure (QC flag 8) excluded.
-
-
-{% endif %}
-{% if fig_knockdown_anomaly_b64 %}
-
-
IQR of pressure anomaly (measured − nominal) per instrument.
- Positive = deeper than design depth.
- Green < 100 dbar,
- yellow 100–200,
- amber 200–300,
- red > 300 dbar.
-
-
-{% endif %}
-
-{% if fig_knockdown_displacement_b64 %}
-Estimated horizontal displacement (m) vs. measured pressure.
- Left: scatter per instrument; right: normalised 2-D density across all instruments.
- Displacement derived from the rigid-pendulum approximation:
- x = √(habnom ² − habmeas ²).
-
-{% endif %}
-{% endif %}
-
-{% if diagram_b64 %}
-
-Mooring diagram
-
-{% endif %}
-
-
-{% if issues.any %}
-Issues for cruise report
-
-{% if issues.skipped %}
-Skipped / missing instruments
-
-{% for i in issues.skipped %}
-SN {{ i.serial }} ({{ i.itype }}, {{ i.depth_str }}): {{ i.reason }}
-{% endfor %}
-
-{% endif %}
-
-{% if issues.stopped_early %}
-Stopped early
-
-{% for i in issues.stopped_early %}
-SN {{ i.serial }} ({{ i.itype }}, {{ i.depth_str }}): last sample {{ i.t_end }}, expected {{ i.expected }} ({{ i.delta_h }} h short)
-{% endfor %}
-
-{% endif %}
-
-{% if issues.qc_flagged %}
-Data quality flags
-
-{% for i in issues.qc_flagged %}
-SN {{ i.serial }} ({{ i.itype }}): {{ i.note }}
-{% endfor %}
-
-{% endif %}
-
-{% endif %}
-
-
-
-
-
-
-
-"""
-
-
# ---------------------------------------------------------------------------
# Orchestrator class
# ---------------------------------------------------------------------------
@@ -1512,9 +666,4 @@ def _combined(deploy_key: str, recover_key: str, legacy_key: str) -> str:
}
def _render(self, ctx: Dict[str, Any]) -> str:
- try:
- from jinja2 import Environment
- except ImportError as exc:
- raise ImportError("pip install jinja2") from exc
- env = Environment(autoescape=True)
- return env.from_string(_HTML_TEMPLATE).render(**ctx)
+ return render_template("mooring.html", **ctx)
diff --git a/oceanarray/reports/_recovery_table.py b/oceanarray/reports/_recovery_table.py
index 4dacc14..232a6db 100644
--- a/oceanarray/reports/_recovery_table.py
+++ b/oceanarray/reports/_recovery_table.py
@@ -17,6 +17,7 @@
import yaml
+from ._env import render_template
from ._html_helpers import (
_parse_dt,
_resolve_clock,
@@ -43,149 +44,6 @@
_NON_LOGGING = {"beacon", "release", "float", "swivel", "shackle"}
-# ---------------------------------------------------------------------------
-# HTML template
-# ---------------------------------------------------------------------------
-
-_RECOVERY_TABLE_TEMPLATE = """\
-
-
-
-
-
-{{ mooring_name }} – Mooring Recovery Table
-
-
-
-
-{{ mooring_name }} ({{ year }}) – Mooring Recovery
-{% if location %}{{ location }} {% endif %}
-
- Depth: {{ waterdepth }} m •
- Start: {{ deploy_time }} •
- End: {{ recover_time }} •
- Duration: {{ duration }}
-
-
-
-
-
- Height\n(above\nbottom)\n(m)
- Depth\n(nominal)\n(m)
- Instrument
- Param.
- Sample\nint.\n(s)
- Start/stop\ntime UTC
- Clock\ndrift
- First good /\nLast good record
- Notes
-
-
-
- {% for row in rows %}
- {% if row.nonlog %}
-
- {{ row.hab if row.hab is not none else "" }}
- {{ row.depth if row.depth is not none else "" }}
- {{ row.description }}
-
- {% else %}
-
- {{ row.hab if row.hab is not none else "?" }}
- {{ row.depth if row.depth is not none else "?" }}
- {{ row.instrument }}
- {{ row.params }}
- {{ row.interval_s if row.interval_s else "" }}
- {{ row.start_stop }}
- {{ row.clock_drift }}
- {{ row.first_last }}
- {{ "*" if row.comment else "" }}
-
- {% endif %}
- {% endfor %}
-
-
-
-{% if comments %}
-
-
Notes
-
- {% for c in comments %}
- SN {{ c.serial }} ({{ c.instrument }}, {{ c.depth_str }})
- {{ c.comment }}
- {% endfor %}
-
-
-{% endif %}
-
-
-
-
-
-"""
-
-
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
@@ -437,8 +295,6 @@ def generate_recovery_table(
"""
from datetime import datetime, timezone
- from jinja2 import Environment
-
from ._html_helpers import _duration_str
proc_dir = Path(proc_dir)
@@ -462,7 +318,7 @@ def generate_recovery_table(
deploy_dt = _parse_dt(cfg.get("deployment_time"))
recover_dt = _parse_dt(cfg.get("recovery_time"))
- # Build a brief location string from lat/lon if available
+ # Latitude/longitude for the canonical meta fields, if available.
lat = (
cfg.get("seabed_latitude")
or cfg.get("deployment_latitude")
@@ -473,7 +329,8 @@ def generate_recovery_table(
or cfg.get("deployment_longitude")
or cfg.get("longitude")
)
- location = f"{lat} N, {lon} W" if (lat and lon) else ""
+ latitude = f"{lat} N" if lat else ""
+ longitude = f"{lon} W" if lon else ""
rows, comments = _build_rows(proc_dir, mooring_name, cfg)
@@ -481,7 +338,8 @@ def generate_recovery_table(
"mooring_name": mooring_name,
"year": cfg.get("year", ""),
"waterdepth": cfg.get("waterdepth", "?"),
- "location": location,
+ "latitude": latitude,
+ "longitude": longitude,
"deploy_time": deploy_dt.strftime("%Y-%m-%d %H:%M UTC") if deploy_dt else "?",
"recover_time": recover_dt.strftime("%Y-%m-%d %H:%M UTC")
if recover_dt
@@ -492,8 +350,7 @@ def generate_recovery_table(
"generated": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
}
- env = Environment(autoescape=True)
- html = env.from_string(_RECOVERY_TABLE_TEMPLATE).render(**ctx)
+ html = render_template("recovery_table.html", **ctx)
out_path.write_text(html, encoding="utf-8")
_status("file", str(out_path))
return out_path
diff --git a/oceanarray/reports/_stack.py b/oceanarray/reports/_stack.py
index 177fa7c..526ffba 100644
--- a/oceanarray/reports/_stack.py
+++ b/oceanarray/reports/_stack.py
@@ -8,6 +8,7 @@
import numpy as np
+from ._env import render_template
from ._html_helpers import (
_fig_to_base64,
_find_array_report_href,
@@ -34,381 +35,6 @@
from .. import parameters as params
-# ---------------------------------------------------------------------------
-# Stack report HTML template
-# ---------------------------------------------------------------------------
-
-_STACK_HTML_TEMPLATE = """\
-
-
-
-
-
-Stack report – {{ mooring_name }}
-
-
-
-
-
-
-
- Jump to:
- {% if history_entries %}History {% endif %}
- Instruments
- {% if fig_pressure_b64 %}Pressure {% endif %}
- {% if fig_temp_b64 %}Temperature {% endif %}
- {% if fig_sal_b64 %}Salinity {% endif %}
- {% if fig_east_vel_b64 or fig_north_vel_b64 or fig_up_vel_b64 %}Velocity {% endif %}
- {% if fig_trajectories_b64 or fig_adcp_trajectories_b64 %}Trajectories {% endif %}
- {% if fig_speed_profile_b64 %}Speed profile {% endif %}
- {% if fig_analog_b64 %}Analog channels {% endif %}
- {% if fig_ts_stack_b64 %}T-S diagram {% endif %}
- {% if fig_rose_grid_b64 %}Current roses {% endif %}
- {% if fig_aquadopp_tilt_b64 %}Tilt {% endif %}
- {% if fig_spacing_b64 %}Spacing {% endif %}
- {% if fig_clock_check_b64 %}Clock check {% endif %}
- Dimensions
- Variables
-
-
-
-{% if history_entries %}
-Processing history
-
- {% for e in history_entries %}
-
- {{ e.timestamp }}
- {{ e.text }}
-
- {% endfor %}
-
-{% endif %}
-
-
-Instruments (deep-first)
-
- # Type Serial HAB (m) ~Depth (m)
-
- {% for row in instr_rows %}
-
- {{ loop.index0 }}
- {{ row.instr_type }}
- {% if row.report_exists %}{{ row.serial }} {% else %}{{ row.serial }}{% endif %}
- {{ row.hab }}
- {{ row.depth }}
-
- {% endfor %}
-
-
-
-
-{% if fig_pressure_b64 %}
-Pressure records (all instruments)
-Values with QC flag ≥ 3 (suspect/bad) masked to NaN before plotting. All data values are in {{ nc_file }} without masking.
-
-{% endif %}
-
-
-{% if fig_temp_b64 %}
-Temperature (all instruments)
-Values with QC flag ≥ 3 (suspect/bad) masked to NaN before plotting. All data values are in {{ nc_file }} without masking.
-
-{% endif %}
-
-
-{% if fig_sal_b64 %}
-Salinity (all instruments)
-Values with QC flag ≥ 3 (suspect/bad) masked to NaN before plotting. All data values are in {{ nc_file }} without masking.
-
-{% endif %}
-
-{% if fig_dissolved_oxygen_b64 %}
-Dissolved oxygen (all instruments)
-One line per instrument with dissolved oxygen data (SBE ODO sensor); QC flags ≥ 3 masked. Units: µmol L⁻¹. % saturation available in per-instrument reports.
-
-{% endif %}
-
-
-{% if fig_east_vel_b64 %}
-East velocity (U)
-ENU frame. Values with velocity_flag ≥ 3 masked to NaN before plotting. All data values are in {{ nc_file }} without masking. Instruments without velocity data omitted.
-
-{% endif %}
-
-{% if fig_north_vel_b64 %}
-North velocity (V)
-ENU frame. Values with velocity_flag ≥ 3 masked to NaN before plotting. All data values are in {{ nc_file }} without masking.
-
-{% endif %}
-
-{% if fig_up_vel_b64 %}
-Vertical velocity (W)
-ENU frame. Values with velocity_flag ≥ 3 masked to NaN before plotting. All data values are in {{ nc_file }} without masking.
-
-{% endif %}
-
-{% if fig_turbidity_b64 %}
-Turbidity
-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).
-
-{% endif %}
-
-{% if fig_trajectories_b64 or fig_adcp_trajectories_b64 %}
-Particle trajectories
-
- Pseudo-Lagrangian displacement: east/north velocity integrated over time
- (Euler forward; NaN velocities set to zero). All trajectories share a common origin (0, 0).
- Aquadopp: colour shows temperature (shared scale); end points labelled with serial and HAB.
- ADCP: per-bin trajectories coloured by height above bottom; bins entirely below the seabed are omitted.
-
-
- {% if fig_trajectories_b64 %}
-
-
Aquadopp
-
-
- {% endif %}
- {% if fig_adcp_trajectories_b64 %}
-
-
ADCP
-
-
- {% endif %}
-
-{% endif %}
-
-{% if fig_speed_profile_b64 %}
-Aquadopp speed profile
-
- Horizontal boxplot per Aquadopp, positioned at its nominal height above bottom
- (per-instrument design value; does not account for mooring knockdown).
- Box = interquartile range; line = median; whiskers = 1.5×IQR; dots = outliers.
- Computed from east/north velocity components if current_speed is not stored.
-
-
-{% endif %}
-
-{% if fig_analog_b64 %}
-Analog channels
-
- Full-record time series of analog channel variables containing non-zero, non-NaN data.
- One panel per channel.
-
-
-{% endif %}
-
-{% if fig_ts_stack_b64 %}
-T-S diagram
-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.
-
-{% endif %}
-
-{% if fig_rose_grid_b64 %}
-Current rose diagrams
-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).
-{% if rose_declination_warn %}
-
- ⚠ Magnetic declination could not be applied to
- {% if rose_declination_missing_serials %}Aquadopp(s) s/n {{ rose_declination_missing_serials | join(', ') }}{% else %}one or more Aquadopps{% endif %}
- — latitude/longitude are missing or all-zero in the mooring YAML (check seabed_latitude, deployment_latitude, or latitude/longitude).
- Re-run oceanarray process … --stage 3 for the affected instrument(s) after fixing the YAML.
- Affected ENU velocities currently use 0° declination (magnetic north, not true north).
-
-{% endif %}
-{% if rose_declination_note %}{{ rose_declination_note }}
{% endif %}
-
-{% endif %}
-
-{% if fig_aquadopp_tilt_b64 %}
-Aquadopp tilt (|pitch| / |roll| / pressure estimate)
-
- One panel per Aquadopp (deep-first). Blue = |pitch|, green = |roll|, orange dashed = tilt
- estimated from pressure difference between the Aquadopp and the nearest instrument ≥10 m above
- with valid pressure (arccos(ΔP / rope length)). All curves are non-negative.
- Horizontal lines: orange dashed = suspect threshold, red dotted = fail threshold (read from file attrs).
- Pitch and roll are stored unmasked in the stack file; use pitch_qc /
- roll_qc to filter. Plots show all available values.
-
-
-{% endif %}
-
-{% if fig_spacing_b64 %}
-Adjacent instrument spacing
-Distribution of pressure differences between adjacent instrument pairs (pairs < 2 dbar apart excluded as co-located).
-
-{% endif %}
-
-{% if fig_clock_check_b64 %}
-Clock alignment check
-
- Temperature records from all instruments overlaid, zoomed to the first and last
- 10 minutes of the deployment. A horizontal shift between curves indicates
- a clock offset between instruments. Data are from stage‑3 (or stage‑2
- if stage‑3 is not yet available). Instruments without temperature are omitted.
-
-
-{% endif %}
-
-
-NetCDF dimensions — {{ nc_file }}
-{% if nc_meta.dims %}
-
- Dimension Size
-
- {% for dim, size in nc_meta.dims.items() %}
- {{ dim }} {{ "{:,}".format(size) }}
- {% endfor %}
-
-
-{% endif %}
-
-
-NetCDF variables — {{ nc_file }}
-{% if nc_meta.get("error") %}
-Could not read file: {{ nc_meta.error }}
-{% else %}
-
-Variables
-
-
- Variable Type Dims N Valid Min / Max Units Long name Standard name QC flag
-
-
- {% for v in nc_meta.time_vars %}
- {% if not v.is_qc %}
-
- {{ v.name }}
- {{ v.dtype }}
- {{ v.dims }}
- {{ "{:,}".format(v.n) }}
- {{ "{:,}".format(v.n_valid) if v.n_valid is defined else "—" }}
- {% if v.v_min is not none %}{{ v.v_min }} / {{ v.v_max }}{% else %}—{% endif %}
- {{ v.units }}
- {{ v.long_name }}
- {{ v.standard_name }}
- {% if v.has_qc %}✓ {% else %}–{% endif %}
-
- {% endif %}
- {% endfor %}
-
-
-
-{% if nc_meta.scalar_vars %}
-Scalar metadata variables
-
-
- Variable Type Value Units Long name
-
-
- {% for v in nc_meta.scalar_vars %}
-
- {{ v.name }}
- {{ v.dtype }}
- {{ v.value }}
- {{ v.units }}
- {{ v.long_name }}
-
- {% endfor %}
-
-
-{% endif %}
-
-{% if nc_meta.global_attrs %}
-Global attributes
-
- Attribute Value
-
- {% for k, v in nc_meta.global_attrs.items() %}
-
- {{ k }}
- {{ v }}
-
- {% endfor %}
-
-
-{% endif %}
-
-{% endif %}
-
-
-
-
-
-"""
-
-
# ---------------------------------------------------------------------------
# Aquadopp tilt helper (was @staticmethod on MooringReport)
# ---------------------------------------------------------------------------
@@ -888,10 +514,8 @@ def _ts_fig(
)
grid_exists = (stack_path.parent / f"{mooring_name}_grid.nc").exists()
- from jinja2 import Environment
-
- env = Environment(autoescape=True)
- html = env.from_string(_STACK_HTML_TEMPLATE).render(
+ html = render_template(
+ "stack.html",
mooring_name=mooring_name,
nav_buttons=_nav_buttons_html(
mooring_name,
diff --git a/oceanarray/reports/templates/array.html b/oceanarray/reports/templates/array.html
new file mode 100644
index 0000000..e425c00
--- /dev/null
+++ b/oceanarray/reports/templates/array.html
@@ -0,0 +1,153 @@
+{% extends "base.html" %}
+{% block title %}{{ array_name }} – array summary{% endblock %}
+{% block page_styles %}
+ .meta-grid div { font-size:0.82rem; }
+ table { border-collapse:collapse; width:100%; margin:0.6rem 0 1.2rem; font-size:0.84rem; }
+ th { background:var(--ocean); color:#fff; padding:0.4rem 0.6rem; text-align:left; }
+ td { padding:0.35rem 0.6rem; border-bottom:1px solid #e8ecef; }
+ tr:nth-child(even) td { background:#f7f9fb; }
+ .num { text-align:right; }
+ .btn { display:inline-block; padding:0.15em 0.55em; border-radius:4px; font-size:0.75rem;
+ font-weight:700; text-decoration:none; color:#fff; margin:0 0.15rem 0.2rem 0; }
+ .btn-sum { background:#2c3e50; }
+ .btn-stk { background:#2980b9; }
+ .btn-grd { background:#8e44ad; }
+ .btn-miss { background:#bbb; cursor:default; pointer-events:none; }
+ .note { font-size:0.78rem; color:#555; margin:0.2rem 0 0.6rem; }
+ .fig { max-width:60%; display:block; margin:0.5rem auto 1rem; border:1px solid #e0e4e8; border-radius:4px; }
+{% endblock %}
+{% block masthead_title %}{{ array_name }}{% endblock %}
+{% block masthead_type %}Array{% endblock %}
+{% block masthead_nav %}{% endblock %}
+{% block masthead_meta %}
+ {% if year %}
Year {{ year }} {% endif %}
+ {% if cruise %}
Cruise {{ cruise }} {% endif %}
+ {% if ship %}
Ship {{ ship }} {% endif %}
+ {% if project %}
Project {{ project }} {% endif %}
+ {% if deploy_time %}
Deployment {{ deploy_time }} {% endif %}
+ {% if recover_time %}
Recovery {{ recover_time }} {% endif %}
+
Moorings {{ moorings | length }}
+ {% endblock %}
+{% block content %}
+
+
+ Jump to:
+ Moorings •
+ Instrument summary •
+ Completeness
+
+
+{% if fig_map_b64 %}
+Mooring positions
+
+{% endif %}
+
+Moorings
+
+
+
+ #
+ Mooring
+ Latitude
+ Longitude
+ Depth (m)
+ Deployment
+ Recovery
+ Duration
+ Instruments
+ Reports
+
+
+
+ {% for r in moorings %}
+
+ {% if r.color_hex %} {% endif %}{{ r.position }}
+ {{ r.mooring }}
+ {{ "%.4f"|format(r.lat) if r.lat is not none else "—" }}
+ {{ "%.4f"|format(r.lon) if r.lon is not none else "—" }}
+ {{ r.waterdepth if r.waterdepth else "—" }}
+ {{ r.deploy_time }}
+ {{ r.recover_time }}
+ {{ r.duration }}
+ {{ r.n_instruments }}
+
+ {% if r.report_exists %}
+ Summary
+ {% else %}
+ Summary
+ {% endif %}
+ {% if r.stack_exists %}
+ Stack
+ {% endif %}
+ {% if r.grid_exists %}
+ Grid
+ {% endif %}
+
+
+ {% endfor %}
+
+
+
+{% if type_summary %}
+Instrument type summary
+
+
+
+ Type
+ Deployed
+ Complete (Stage 3 ✓)
+ Skipped / no raw
+ Stopped early
+ Notes
+
+
+
+ {% for row in type_summary %}
+
+ {{ row.itype }}
+ {{ row.deployed }}
+ {{ row.complete }}
+ {{ row.skipped }}
+ {{ row.stopped_early }}
+ {{ row.notes }}
+
+ {% endfor %}
+
+
+{% endif %}
+
+{% if mooring_completeness %}
+Data completeness by mooring
+
+
+
+ Mooring
+ Depth (m)
+ Deployed
+ Complete
+ Skipped
+ Stopped early
+ Deployment (UTC)
+ Recovery (UTC)
+ Duration
+
+
+
+ {% for row in mooring_completeness %}
+
+ {{ row.mooring }}
+ {{ row.waterdepth if row.waterdepth else "—" }}
+ {{ row.deployed }}
+ {{ row.complete }}
+ {{ row.skipped }}
+ {{ row.stopped_early }}
+ {{ row.deploy_time }}
+ {{ row.recover_time }}
+ {{ row.duration }}
+
+ {% endfor %}
+
+
+{% endif %}
+
+{% endblock %}
diff --git a/oceanarray/reports/templates/base.html b/oceanarray/reports/templates/base.html
new file mode 100644
index 0000000..b76b4ae
--- /dev/null
+++ b/oceanarray/reports/templates/base.html
@@ -0,0 +1,81 @@
+
+
+
+
+
+{% block title %}{{ mooring_name }}{% endblock %}
+
+{% block head_extra %}{% endblock %}
+
+
+{% block masthead %}
+
+{% endblock %}
+{% block content %}{% endblock %}
+
+
diff --git a/oceanarray/reports/templates/grid.html b/oceanarray/reports/templates/grid.html
new file mode 100644
index 0000000..259d4b7
--- /dev/null
+++ b/oceanarray/reports/templates/grid.html
@@ -0,0 +1,290 @@
+{% extends "base.html" %}
+{% block title %}Grid report – {{ mooring_name }}{% endblock %}
+{% block page_styles %}
+ :root { --accent:#8e44ad; --accent-link:#e8d5ff; }
+ .fig { width:100%; border:1px solid #dce; border-radius:4px; margin-bottom:0.5rem; }
+ .note { color:var(--muted); font-size:0.82rem; margin-top:-0.5rem; }
+ .style-label { font-size:0.8rem; font-weight:600; color:var(--muted); margin:0.4rem 0 0.2rem; text-transform:uppercase; letter-spacing:0.05em; }
+ .var-table { width:100%; border-collapse:collapse; font-size:0.82rem; margin-bottom:1.5rem; }
+ .var-table th { background:var(--seafoam); text-align:left; padding:0.4rem 0.6rem; border-bottom:2px solid #cde; }
+ .var-table td { padding:0.3rem 0.6rem; border-bottom:1px solid #eef; vertical-align:top; }
+ .var-table tr:nth-child(even) td { background:#f4f9fc; }
+ .var-table tr:hover td { background:#e8f4f8; }
+ details summary.collapse-toggle { color:var(--muted); font-size:0.76rem; cursor:pointer;
+ list-style:none; padding:0.15rem 0 0.4rem; user-select:none; }
+ details summary.collapse-toggle::before { content:"▾ "; }
+ details[open] summary.collapse-toggle::before { content:"▴ "; }
+{% endblock %}
+{% block masthead_type %}Gridded{% endblock %}
+{% block masthead_meta %}
+
Cruise {{ cruise }}
+
Ship {{ ship }}
+
Latitude {{ latitude }}
+
Longitude {{ longitude }}
+
Water depth {{ waterdepth }}{% if waterdepth != '—' %} m{% endif %}
+
Deployment {{ deploy_time }}
+
Recovery {{ recover_time }}
+
Duration {{ duration }}
+
Samp. Δt {{ grid_dt_s }}{% if grid_dt_s != '—' %} s{% endif %}
+
Records {{ n_time }}
+
Grid ΔP {{ grid_dp }}
+
Pressure levels {{ n_levels }}
+
Pressure range {{ p_range }}
+
Instruments {{ n_instr }}
+
Source file {{ nc_file }}
+ {% endblock %}
+{% block content %}
+
+
+ Jump to:
+ {% if history_entries %}History {% endif %}
+ {% if fig_hydro_b64 %}Hydrography {% endif %}
+ {% if fig_vel_stacked_b64 %}Velocity {% endif %}
+ {% if fig_ts_grid_b64 %}T-S diagram {% endif %}
+ {% if fig_vel_iqr_b64 %}Velocity profiles {% endif %}
+ {% if fig_grid_rose_b64 %}Current roses {% endif %}
+ {% if fig_grid_hodograph_b64 %}Hodograph {% endif %}
+ {% if fig_grid_traj_b64 %}Particle trajectory {% endif %}
+ {% if fig_grid_ts_b64 %}Velocity time series {% endif %}
+ {% if fig_sigma_b64 or fig_n2_b64 %}Stratification {% endif %}
+ {% if sigma_sections or fig_overflow_temp_b64 or fig_isopycnal_coverage_b64 %}Overflow {% endif %}
+ {% if fig_spectrum_b64 %}Power spectrum {% endif %}
+ {% if fig_wavelet_b64 %}Wavelet {% endif %}
+ {% if fig_rotary_b64 %}Rotary spectrum {% endif %}
+ Variables
+
+
+{% if history_entries %}
+Processing history
+
+ {% for e in history_entries %}
+
+ {{ e.timestamp }}
+ {{ e.text }}
+
+ {% endfor %}
+
+{% endif %}
+
+
+{% if fig_hydro_b64 %}
+Hydrography
+Temperature, salinity, dissolved oxygen, and O₂ saturation (when present). Vertically interpolated to regular pressure grid • 20 discrete colour levels.
+show / hide
+
+
+{% endif %}
+
+
+{% if fig_vel_stacked_b64 %}
+Velocity
+East, north, and up velocity. QC-flagged samples excluded before interpolation. Shared symmetric Spectral_r colormap for east/north; separate bounds for up. No temporal gap fill — NaN where no data.
+show / hide
+
+
+{% endif %}
+
+
+{% if fig_ts_grid_b64 %}
+T-S diagram
+Left: log₁₀(count+1) per T-S bin. Right (when O₂ present): median O₂ saturation per T-S bin — bins with <5 samples masked. Colour scale: BrBG (brown=low, teal=high).
+show / hide
+
+
+{% endif %}
+
+
+{% if fig_vel_iqr_b64 %}
+Velocity IQR profiles
+Median (solid line) and interquartile range (shaded, 25–75 %) across the full deployment at each pressure level. 2.5–97.5 % outer envelope for speed. Right panel: count of non-NaN values per depth.
+show / hide
+
+
+{% endif %}
+
+
+{% if fig_grid_rose_b64 %}
+Current roses
+One rose per pressure level (up to 12, evenly subsampled). Suspect/bad QC excluded. Each petal shows the fraction of time current flowed in that direction; petal length encodes speed (m s⁻¹).
+show / hide
+
+
+{% endif %}
+
+
+{% if fig_grid_hodograph_b64 %}
+Hodograph
+Current hodographs at two pressure levels (25th and 75th percentile of the valid range). Top row = shallower level; bottom row = deeper level. Left = Tukey-smoothed raw; right = eddy component (LP mean removed). Colour indicates fractional time through the deployment (lime circle = start, red square = end). QC-bad data excluded.
+show / hide
+
+
+{% endif %}
+
+
+{% if fig_grid_traj_b64 %}
+Particle trajectory
+Pseudo-Lagrangian trajectories at each gridded pressure level, integrated from east and north velocity (Euler forward). All start at the origin. Coloured by pressure (dbar).
+show / hide
+
+
+{% endif %}
+
+
+{% if fig_grid_ts_b64 %}
+Velocity time series at depth of maximum mean speed
+Depth chosen as the pressure level with the highest time-mean current speed. Speed, east, and north velocity at that level.
+show / hide
+
+
+{% endif %}
+
+
+{% if fig_sigma_b64 or fig_n2_b64 %}
+Stratification
+{% endif %}
+
+{% if fig_sigma_b64 %}
+Potential density. 20 discrete colour levels.
+show / hide sigma0
+
+
+{% endif %}
+
+{% if fig_n2_b64 %}
+Buoyancy frequency squared (N²)
+log₁₀(N²) in s⁻². Purple = strongly stratified; yellow = weakly stratified. Computed from T and S via GSW.
+show / hide N²
+
+
+{% endif %}
+
+{% if sigma_sections or fig_overflow_temp_b64 or fig_isopycnal_coverage_b64 %}
+
+{% endif %}
+
+{% if fig_isopycnal_coverage_b64 %}
+Isopycnal coverage diagnostic
+Percentage of time steps during which each σ₀ surface (0.1 kg m⁻³ spacing) lies within the measured column range. Green ≥ 80 %; amber 50–80 %; red < 50 %. Orange diamond = current --sig-level target. Dashed line = 80 % threshold. NaN gaps in the tracking figure above correspond to times when the isopycnal is outside the measured range (pycnocline above the shallowest grid level, or below the deepest).
+show / hide
+
+
+{% endif %}
+
+{% for sec in sigma_sections %}
+{% if sec.isopycnal_b64 %}
+Isopycnal height above seabed — {{ sec.name }}
+Height above seabed (m) of each target σ₀ surface through time. Tracked by linear interpolation in density space from the gridded {{ sec.name }} field; 1-hour running median applied. NaN where the surface outcrops or is outside the observed range. Light blue = lighter water (shallower); dark blue = denser water (deeper). Targets set by --sig-level (default σ₀ = 27.70).
+show / hide
+
+
+{% endif %}
+{% endfor %}
+
+{% if fig_overflow_temp_b64 %}
+Temperature ~100 m above seabed
+1-hour running median temperature at the grid pressure level nearest to 100 m above the seabed. Level and height-above-seabed shown in figure title.
+show / hide
+
+
+{% endif %}
+
+{% if fig_spectrum_b64 %}
+Temperature power spectrum
+Left: low-frequency overview — 14-day Hann windows, 50 % overlap. Right: high-frequency zoom — 1-day windows (~14× more windows, smoother tidal/inertial estimate); x-axis in hours. LF markers: M2, 1.8 d, 4 d, f. HF markers: M2 (12.4 h), f (inertial). Dashed black line: −2 spectral slope. Colour encodes pressure (light blue = shallow, dark blue = deep). Window length controllable via hf_segment_days in _make_spectrum_fig_b64.
+show / hide
+
+
+{% endif %}
+
+{% if fig_wavelet_b64 %}
+Temperature wavelet scalogram
+Continuous wavelet transform (Morlet, ω0 = 6; Torrence & Compo 1998). Three depth levels: 2nd from top, middle, 2nd from bottom of the 100-dbar-multiple valid levels. Colour shows log10 (power) (°C² d); hatched grey region = cone of influence (edge-affected); hatched cross region = gap-filled data; black contour = 95 % significance against red noise.
+show / hide
+
+
+{% endif %}
+
+{% if fig_rotary_b64 %}
+Rotary velocity spectrum
+CW (clockwise, anticyclonic, solid red lines) and CCW (counter-clockwise, cyclonic, dashed blue lines) power spectra and rotary coefficient r = (CCW−CW)/(CCW+CW). Welch PSD, Hann window, 14-day segments, 50 % overlap. Up to 4 pressure levels (at most 1/5th of valid levels); colour encodes pressure depth. Vertical lines: M2, K1, 1.8 d, 4 d, and inertial period (f). r > 0 = CCW dominant; r < 0 = CW dominant. Physical interpretation (NH): inertial oscillations are inherently CW; for internal waves (f < ω), CW dominance indicates upward energy propagation / downward phase propagation (Leaman & Sanford 1975).
+show / hide
+
+
+{% endif %}
+
+
+NetCDF variables — {{ nc_file }}
+{% if nc_meta.get("error") %}
+Could not read file: {{ nc_meta.error }}
+{% else %}
+
+Variables
+
+
+ Variable Type Dims N Valid Min / Max Units Long name Standard name QC flag
+
+
+ {% for v in nc_meta.time_vars %}
+ {% if not v.is_qc %}
+
+ {{ v.name }}
+ {{ v.dtype }}
+ {{ v.dims }}
+ {{ "{:,}".format(v.n) }}
+ {{ "{:,}".format(v.n_valid) if v.n_valid is defined else "—" }}
+ {% if v.v_min is not none %}{{ v.v_min }} / {{ v.v_max }}{% else %}—{% endif %}
+ {{ v.units }}
+ {{ v.long_name }}
+ {{ v.standard_name }}
+ {% if v.has_qc %}✓ {% else %}–{% endif %}
+
+ {% endif %}
+ {% endfor %}
+
+
+
+{% if nc_meta.scalar_vars %}
+Scalar metadata variables
+
+
+ Variable Value Units Long name
+
+
+ {% for v in nc_meta.scalar_vars %}
+
+ {{ v.name }}
+ {{ v.value }}
+ {{ v.units }}
+ {{ v.long_name }}
+
+ {% endfor %}
+
+
+{% endif %}
+
+{% if nc_meta.global_attrs %}
+Global attributes
+
+ Attribute Value
+
+ {% for k, v in nc_meta.global_attrs.items() %}
+
+ {{ k }}
+ {{ v }}
+
+ {% endfor %}
+
+
+{% endif %}
+
+{% endif %}
+
+
+
+{% endblock %}
diff --git a/oceanarray/reports/templates/instrument.html b/oceanarray/reports/templates/instrument.html
new file mode 100644
index 0000000..3ff25f6
--- /dev/null
+++ b/oceanarray/reports/templates/instrument.html
@@ -0,0 +1,444 @@
+{% extends "base.html" %}
+{% block title %}{{ instr_type | title }} – s/n {{ serial }}{% endblock %}
+{% block page_styles %}
+ :root { --accent:#1e8449; --accent-link:#d5f5e3; }
+ .file-table { width:100%; border-collapse:collapse; font-size:0.81rem; margin-bottom:1.2rem; }
+ .file-table th { background:var(--seafoam); text-align:left; padding:0.35rem 0.65rem;
+ border-bottom:2px solid #cde; font-weight:600; }
+ .file-table td { padding:0.3rem 0.65rem; border-bottom:1px solid #eef; vertical-align:middle; }
+ .file-table tr:nth-child(even) td { background:#f4f9fc; }
+ .file-table .ok { color:var(--good); font-weight:700; }
+ .file-table .miss { color:#bbb; }
+ table { width:100%; border-collapse:collapse; font-size:0.82rem; }
+ th { background:var(--ocean); color:#fff; padding:0.4rem 0.65rem;
+ text-align:left; font-weight:600; white-space:nowrap; }
+ td { padding:0.35rem 0.65rem; border-bottom:1px solid #ecf0f1; vertical-align:middle; }
+ tr:nth-child(even) td { background:var(--seafoam); }
+ .badge { display:inline-block; padding:0.12em 0.45em; border-radius:3px;
+ font-size:0.7rem; font-weight:700; white-space:nowrap; }
+ .b-ok { background:var(--good); color:#fff; }
+ .b-warn { background:var(--warn); color:#fff; }
+ .b-miss { background:#dfe6e9; color:#999; }
+ img.fig { width:100%; max-width:100%; border-radius:4px; margin-bottom:0.5rem; }
+ .qc-bar { display:flex; width:180px; height:13px; border-radius:3px;
+ overflow:hidden; gap:1px; background:#ecf0f1; }
+ .qc-bar div { height:100%; }
+{% endblock %}
+{% block masthead_title %}{{ instr_type | title }} — s/n {{ serial }}{% endblock %}
+{% block masthead_sub %}{{ mooring_name }}{% endblock %}
+{% block masthead_meta %}
+
Cruise {{ cruise }}
+
HAB {{ "%.1f"|format(hab) }} m
+
Depth {% if depth is not none %}~{{ "%.0f"|format(depth) }} m{% else %}—{% endif %}
+
Start {{ t_start | default("—") }}
+
End {{ t_end | default("—") }}
+
Duration {{ duration }}
+
Samp. Δt {{ median_dt | default("—") }}
+
Records {{ n_records | default("—") }}
+
Source file {{ nc_file }}{% if data_stage and data_stage != 'stage3' %} ({{ data_stage }} — QC not applied) {% endif %}
+ {% endblock %}
+{% block content %}
+
+
+ Jump to:
+ Files
+ History
+ {% if fig_adcp_velocity_b64 %}Velocity {% endif %}
+ Time series
+ Start/end windows
+ {% if fig_tsd_b64 %}T-S diagram {% endif %}
+ {% if fig_adcp_rose_b64 %}Current roses {% endif %}
+ {% if fig_rose_b64 %}Current roses {% endif %}
+ {% if fig_trajectory_b64 %}Trajectory {% endif %}
+ {% if fig_hodograph_b64 %}Hodograph {% endif %}
+ {% if fig_speed_boxplot_b64 %}Speed distribution {% endif %}
+ {% if fig_analog_b64 %}Analog channels {% endif %}
+ Distributions
+ {% if qc_summary %}QC thresholds & flags {% endif %}
+ {% if nc_meta.dims %}Dimensions {% endif %}
+ Variables
+
+
+
+Files
+
+ Stage Filename Size Last modified
+
+ {% if raw_file.exists %}
+
+ Raw
+ {{ raw_file.name }}
+ {{ raw_file.size }}
+ {{ raw_file.mtime }}
+
+ {% elif raw_file.unknown %}
+
+ Raw
+ {{ raw_file.name }}
+ — not checked (no --raw-dir)
+
+ {% else %}
+
+ Raw
+ {{ raw_file.name }}
+ — not found
+
+ {% endif %}
+ {% for f in stage_files %}
+
+ {{ f.label }}
+ {% if f.exists %}
+ {{ f.name }}
+ {{ f.size }}
+ {{ f.mtime }}
+ {% else %}
+ {{ f.name }}
+ — not generated
+ {% endif %}
+
+ {% endfor %}
+
+
+
+
+Processing history
+{% if history_entries %}
+
+ {% for e in history_entries %}
+
+ {{ e.timestamp }}
+ {{ e.text }}
+
+ {% endfor %}
+
+{% else %}
+No history attribute found.
+{% endif %}
+
+
+{% if fig_adcp_velocity_b64 %}
+Velocity
+
+ Time–range colour plots of the four velocity components (east, north, up, error).
+ Y-axis: range from transducer (m).
+ Colour scale: symmetric about zero; percentile 2/98 of all components combined.
+ {% if data_stage and data_stage != 'stage3' %}Magnetic declination has not been applied ({{ data_stage }} data).{% endif %}
+
+
+{% endif %}
+
+
+Time series (full deployment)
+{% if fig_ts_b64 %}
+{% for _ts_img in fig_ts_b64 %}
+
+{% endfor %}
+{% else %}
+No plottable variables found.
+{% endif %}
+
+
+Start & end windows — first / last 6 h
+{% if fig_windows_b64 %}
+
+ Left panel = first 7 h (1 h lead-in + 6 h of record) |
+ Right panel = last 7 h (6 h of record + 1 h tail).
+ ││ orange dashed = auto-detected
+ (pressure-based) suggested deploy/recover time •
+ ││ green dashed = YAML
+ deployment_time / recovery_time •
+ ││ grey = stage 1 raw record
+ (same variable, same units).
+
+
+ Reading the grey background:
+ where the raw stage 1 record is available and uses the same physical
+ units as the stage 2/3 data, it is shown in light grey so you can
+ visually verify that the chosen deployment window captures the in-water
+ period. Cases where grey may be absent:
+
+ Sensors without their own pressure (e.g. a microCAT that
+ relies on interpolated pressure from a nearby instrument) — interpolated
+ pressure is only added at stage 3, so stage 1 has no pressure
+ variable and no grey trace appears.
+ Velocity in beam or XYZ coordinates — if an Aquadopp or
+ ADCP was configured to record in BEAM or XYZ mode, the stage 1 file
+ contains beam/XYZ velocity variables while stage 3 stores
+ ENU velocities; the variable names differ so no grey velocity trace is shown.
+ Conductivity — raw conductivity units sometimes differ
+ between stage 1 and stage 3 (e.g. mS/cm vs S/m) and the
+ grey trace is suppressed automatically to avoid a misleading scale mismatch.
+
+ If the orange vline is absent from the right-hand panel,
+ no pressure-based recovery transition was detected in the final 25 % of the
+ stage 1 record — the suggested recovery time equals the last raw sample.
+ Check the timing table in the mooring summary report for the suggested UTC time.
+
+{% for _win_img in fig_windows_b64 %}
+
+{% endfor %}
+{% else %}
+Insufficient data for start/end windows.
+{% endif %}
+
+
+{% if fig_tsd_b64 %}
+T-S diagram
+
+ Coloured by pressure (or sample index). × = suspect | × = bad (QC flags).
+
+
+{% endif %}
+
+
+{% if fig_adcp_rose_b64 %}
+Current roses
+
+ Depth-average followed by bins nearest 100, 200, 300 and 400 m from the transducer.
+ Bins with fewer than two valid samples are omitted.
+ Direction toward which the current flows; 0° = N, clockwise.
+ {% if data_stage and data_stage != 'stage3' %}Magnetic declination not yet applied ({{ data_stage }} data).{% endif %}
+
+
+{% endif %}
+
+
+{% if fig_rose_b64 %}
+Current rose diagrams
+{% if declination_warn %}
+
+ ⚠ Magnetic declination could not be applied — latitude/longitude are missing or all-zero in the mooring YAML (check seabed_latitude, deployment_latitude, or latitude/longitude).
+ ENU velocities use 0° declination (magnetic north, not true north).
+
+{% endif %}
+
+ {% if rose_has_xyz %}XYZ: instrument-frame velocities (before geographic rotation). {% endif %}ENU panels split by QARTOD flag: good (flag ≤ 2, Blues), suspect (flag 3, Oranges), fail (flag 4, Reds).
+ Direction toward which the current flows; 0° = N, clockwise.
+
+
+{% endif %}
+
+
+{% if fig_trajectory_b64 %}
+Particle trajectory
+
+ Pseudo-Lagrangian displacement obtained by integrating east/north velocity over time
+ (Euler forward; NaN velocities set to zero). Coloured by temperature.
+ Origin (0, 0) = deployment position; axes in metres.
+
+
+{% endif %}
+
+
+{% if fig_hodograph_b64 %}
+Hodograph
+
+ East vs north velocity (m s-1 ).
+ Left : full record.
+ Right : eddy component — raw minus 4-day low-pass (rolling mean).
+
+
+{% endif %}
+
+
+{% if fig_speed_boxplot_b64 %}
+Current speed distribution
+
+{% endif %}
+
+
+{% if fig_analog_b64 %}
+Analog channels
+
+ Full-record time series of analog channel variables containing non-zero, non-NaN data.
+ One panel per channel.
+ Labels and units are read from the mooring YAML — update them there to change what appears here.
+
+{% if analog_yaml_info %}
+
+ {% for ch in analog_yaml_info %}
+
+ {{ ch.varname }} — from YAML:
+ {{ ch.yaml_key }}: {{ ch.label if ch.label else "(not set)" }}{% if ch.units %},
+ {{ ch.yaml_key }}_units: "{{ ch.units }}"{% endif %}{% if ch.serial %},
+ {{ ch.yaml_key }}_serial_number: {{ ch.serial }}{% endif %}
+
+ {% endfor %}
+
+{% endif %}
+
+{% endif %}
+
+
+Data value distributions
+
+ Orange dashed = suspect threshold | Red dotted = fail threshold (gross-range QC).
+ Histogram shows non-bad data only; bad-flagged count noted in red.
+
+{% if fig_dt_b64 %}
+
+{% else %}
+Not enough samples to compute.
+{% endif %}
+
+
+{% if qc_summary %}
+QC flag breakdown
+
+{% if qc_thresholds %}
+Thresholds applied (as stored in stage 3 file)
+
+
+
+ Variable
+ Test
+ Suspect range / threshold
+ Fail range / threshold
+
+
+
+ {% for row in qc_thresholds %}
+
+ {{ row.var }}
+ {{ row.test }}
+ {{ row.suspect }}
+ {{ row.fail }}
+
+ {% endfor %}
+
+
+{% endif %}
+
+Flag counts
+
+
+
+ Variable
+ N
+ Good %
+ Suspect %
+ Bad %
+ Interp. %
+ Missing %
+ Distribution
+
+
+
+ {% for row in qc_summary %}
+ {% set good = row.flags | selectattr("flag", "eq", 1) | first %}
+ {% set susp = row.flags | selectattr("flag", "eq", 3) | first %}
+ {% set bad = row.flags | selectattr("flag", "eq", 4) | first %}
+ {% set interp = row.flags | selectattr("flag", "eq", 8) | first %}
+ {% set miss = row.flags | selectattr("flag", "eq", 9) | first %}
+
+ {{ row.var }}
+ {{ "{:,}".format(row.total) }}
+ {{ good.pct }}
+ {% if susp.n > 0 %}{% if susp.pct > 0 %}{{ susp.pct }}{% else %}<0.1% ({{ susp.n }}){% endif %} {% else %}–{% endif %}
+ {% if bad.n > 0 %}{% if bad.pct > 0 %}{{ bad.pct }}{% else %}<0.1% ({{ bad.n }}){% endif %} {% else %}–{% endif %}
+ {% if interp.n > 0 %}{% if interp.pct > 0 %}{{ interp.pct }}{% else %}<0.1% ({{ interp.n }}){% endif %} {% else %}–{% endif %}
+ {% if miss.n > 0 %}{% if miss.pct > 0 %}{{ miss.pct }}{% else %}<0.1% ({{ miss.n }}){% endif %} {% else %}–{% endif %}
+
+
+ {% for f in row.flags %}{% if f.pct > 0 %}
+
+ {% endif %}{% endfor %}
+
+
+
+ {% endfor %}
+
+
+{% endif %}
+
+
+{% if nc_meta.dims %}
+NetCDF dimensions — {{ nc_file }}
+
+ Dimension Size
+
+ {% for dim, size in nc_meta.dims.items() %}
+ {{ dim }} {{ "{:,}".format(size) }}
+ {% endfor %}
+
+
+{% endif %}
+
+
+NetCDF variables — {{ nc_file }}
+{% if nc_meta.get("error") %}
+Could not read file: {{ nc_meta.error }}
+{% else %}
+
+Time-series variables
+
+
+ Variable Type Dims N Valid Min / Max Units Long name Standard name QC flag
+
+
+ {% for v in nc_meta.time_vars %}
+ {% if not v.is_qc %}
+
+ {{ v.name }}
+ {{ v.dtype }}
+ {{ v.dims }}
+ {{ "{:,}".format(v.n) }}
+ {{ "{:,}".format(v.n_valid) if v.n_valid is defined else "—" }}
+ {% if v.v_min is not none %}{{ v.v_min }} / {{ v.v_max }}{% else %}—{% endif %}
+ {{ v.units }}
+ {{ v.long_name }}
+ {{ v.standard_name }}
+ {% if v.has_qc %}✓ {% else %}–{% endif %}
+
+ {% endif %}
+ {% endfor %}
+
+
+
+{% if nc_meta.scalar_vars %}
+Scalar metadata variables
+
+
+ Variable Type Value Units Long name
+
+
+ {% for v in nc_meta.scalar_vars %}
+
+ {{ v.name }}
+ {{ v.dtype }}
+ {{ v.value }}
+ {{ v.units }}
+ {{ v.long_name }}
+
+ {% endfor %}
+
+
+{% endif %}
+
+{% if nc_meta.global_attrs %}
+Global attributes
+
+ Attribute Value
+
+ {% for k, v in nc_meta.global_attrs.items() %}
+
+ {{ k }}
+ {{ v }}
+
+ {% endfor %}
+
+
+{% endif %}
+
+{% endif %}
+
+
+
+{% endblock %}
diff --git a/oceanarray/reports/templates/mooring.html b/oceanarray/reports/templates/mooring.html
new file mode 100644
index 0000000..b9f09ed
--- /dev/null
+++ b/oceanarray/reports/templates/mooring.html
@@ -0,0 +1,749 @@
+{% extends "base.html" %}
+{% block title %}{{ mooring_name }} – recovery report{% endblock %}
+{% block page_styles %}
+ table { width: 100%; border-collapse: collapse; font-size: 0.83rem; }
+ th {
+ background: var(--ocean);
+ color: #fff;
+ padding: 0.45rem 0.7rem;
+ text-align: left;
+ font-weight: 600;
+ white-space: nowrap;
+ }
+ td { padding: 0.38rem 0.7rem; border-bottom: 1px solid #ecf0f1; vertical-align: middle; }
+ tr:nth-child(even) td { background: var(--seafoam); }
+ tr:hover td { background: #d6eaf8; }
+ .pipeline { white-space: nowrap; display: flex; flex-wrap: wrap; gap: 0.2rem; align-items: center; }
+ .badge {
+ display: inline-block;
+ padding: 0.15em 0.5em;
+ border-radius: 3px;
+ font-size: 0.73rem;
+ font-weight: 700;
+ white-space: nowrap;
+ }
+ .b-ok { background: var(--good); color: #fff; }
+ .b-warn { background: var(--warn); color: #fff; }
+ .b-miss { background: #dfe6e9; color: #999; }
+ .b-stack { background: var(--interp); color: #fff; }
+ .b-grid { background: #8e44ad; color: #fff; }
+ .arrow { color: #ccc; font-size: 0.8rem; margin: 0 0.05rem; }
+ .pos { color: var(--warn); font-weight: 600; }
+ .neg { color: var(--interp); font-weight: 600; }
+ tr.row-warn td { background: #fef3cd !important; }
+{% endblock %}
+{% block masthead_type %}Summary{% endblock %}
+{% block masthead_sub %}Mooring recovery report{% endblock %}
+{% block masthead_meta %}
+
Cruise {{ cruise }}
+
Ship {{ ship }}
+
Latitude {{ latitude }}
+
Longitude {{ longitude }}
+
Water depth {{ waterdepth }}{% if waterdepth != '—' %} m{% endif %}
+
Deployment {{ deploy_time }}
+
Recovery {{ recover_time }}
+
Duration {{ duration }}
+
Instruments {{ n_instruments }}
+ {% endblock %}
+{% block content %}
+
+
+ Jump to:
+ Processing pipeline
+ Instruments
+ Deployment timing
+ Clock corrections
+ Sensor calibration
+ QC summary
+ {% if fig_knockdown_hab_b64 or fig_knockdown_anomaly_b64 %}Knockdown {% endif %}
+ {% if diagram_b64 %}Mooring diagram {% endif %}
+
+
+
+2 — Processing pipeline
+
+ Raw = file present in raw directory •
+ Read = format check passed •
+ Stage 1–3 = processed NetCDF files exist •
+ Stack = mooring-level _stack.nc •
+ Grid = pressure-gridded _grid.nc
+
+
+ Instruments marked skip: true in the YAML are excluded from all
+ processing and are shown with a skipped note in the Note column.
+ Stack and Grid pills are grey for any instrument that did not reach Stage 3.
+ See Table 3 for skip reasons where given.
+
+
+
+
+ #
+ Type
+ S/N
+ Hab (m)
+ Depth (m)
+ Pipeline
+ Note
+
+
+
+ {% for instr in instruments %}
+
+ {{ loop.index }}
+ {{ instr.instr_type }}
+ {% if instr.report_exists %}{{ instr.serial }} {% else %}{{ instr.serial }} {% endif %}
+ {{ "%.1f"|format(instr.hab) }}
+ {{ "%.0f"|format(instr.depth) if instr.depth is not none else "—" }}
+
+
+ {# raw file #}
+ {% if instr.raw_exists %}
+ Raw ✓
+ {% elif instr.filename %}
+ Raw ✗
+ {% else %}
+ Raw —
+ {% endif %}
+ ›
+ {# readability check #}
+ {% if instr.raw_exists %}
+ {% if instr.readable %}
+ Read ✓
+ {% else %}
+ Read ✗
+ {% endif %}
+ {% else %}
+ Read —
+ {% endif %}
+ ›
+ {# stage 1 #}
+ {% if instr.stages.stage1 %}
+ Stage 1 ✓
+ {% else %}
+ Stage 1 ○
+ {% endif %}
+ ›
+ {# stage 2 #}
+ {% if instr.stages.stage2 %}
+ Stage 2 ✓
+ {% else %}
+ Stage 2 ○
+ {% endif %}
+ ›
+ {# stage 3 #}
+ {% if instr.stages.stage3 %}
+ Stage 3 ✓
+ {% else %}
+ Stage 3 ○
+ {% endif %}
+ ›
+ {# stack — only coloured if this instrument is in the stack file #}
+ {% if instr.in_stack %}
+ Stack ✓
+ {% else %}
+ Stack ○
+ {% endif %}
+ ›
+ {# grid — only coloured if this instrument is in the grid file #}
+ {% if instr.in_grid %}
+ Grid ✓
+ {% else %}
+ Grid ○
+ {% endif %}
+
+
+
+ {% if instr.skipped %}skipped{% endif %}
+
+
+ {% endfor %}
+
+
+
+
+3 — Instrument summary
+
+ Variables and record length are read from stage 3 files where available,
+ otherwise stage 2. The P badge is shown as present
+ whether pressure was directly measured or interpolated from neighbouring
+ instruments — see Table 6 (QC flag summary) to distinguish. Instruments
+ marked skip: true are listed with the skip reason where provided.
+
+
+
+
+
+ #
+ Type
+ S/N
+ Hab (m)
+ First sample
+ Last sample
+ N records
+ Δt (s)
+ P range (dbar)
+ T
+ C
+ P
+ U
+ V
+
+
+
+ {% for instr in instruments %}
+
+ {{ loop.index }}
+ {{ instr.instr_type }}
+ {% if instr.report_exists %}{{ instr.serial }} {% else %}{{ instr.serial }} {% endif %}
+ {{ "%.1f"|format(instr.hab) }}
+ {% if instr.nc and instr.nc.get("error") %}
+ Error: {{ instr.nc.error }}
+ {% elif instr.nc and instr.nc.get("t_start") %}
+ {{ instr.nc.t_start }}
+ {{ instr.nc.t_end }}
+ {{ "{:,}".format(instr.nc.n_records) }}
+
+ {% set dt = instr.nc.dt_s %}
+ {% if dt == dt %}{# NaN check: NaN != NaN #}
+ {{ "%.0f"|format(dt) }}{% if instr.dt_mismatch %} * {% endif %}
+ {% else %}
+ —
+ {% endif %}
+
+
+ {% if instr.nc.p_min is not none and instr.nc.p_max is not none %}
+ {% if instr.nc.get("pressure_interpolated") %}
+ ({{ "%.0f"|format(instr.nc.p_min) }}, {{ "%.0f"|format(instr.nc.p_max) }})
+ {% else %}
+ ({{ "%.0f"|format(instr.nc.p_min) }}, {{ "%.0f"|format(instr.nc.p_max) }})
+ {% endif %}
+ {% else %}
+ —
+ {% endif %}
+
+ {% for label, present in instr.nc.shorthands %}
+
+ {% if label == "P" and present and instr.nc.get("pressure_interpolated") %}
+ {{ label }}
+ {% else %}
+ {{ label }}
+ {% endif %}
+
+ {% endfor %}
+ {% elif instr.skipped %}
+ skipped{% if instr.skip_reason %} — {{ instr.skip_reason }}{% endif %}
+ {% else %}
+ no processed file
+ {% endif %}
+
+ {% endfor %}
+
+
+
+{% set dt_mismatches = instruments | selectattr("dt_mismatch") | list %}
+{% if dt_mismatches %}
+
+ * Δt mismatch (YAML vs observed p90):
+ {% for instr in dt_mismatches %}
+ {{ instr.serial }} :
+ YAML gives Δt of {{ instr.yaml_interval_s }} s;
+ stage file shows Δt of {{ "%.0f"|format(instr.nc.dt_s) }} s.
+ {% endfor %}
+
+{% endif %}
+
+{% if grid_p_start is not none and grid_p_end is not none %}
+
+ Recommended pressure range for oceanarray grid, derived from the
+ min/max pressure across all instruments (rounded outward to the nearest 20 dbar):
+
+--pmin {{ grid_p_start }} --pmax {{ grid_p_end }}
+{% endif %}
+
+
+3.5 — Deployment timing
+
+ Stage 1 and Sugg. (raw) columns are in the raw
+ instrument clock (uncorrected). Sugg. UTC columns
+ apply the constant clock offset (and drift, for the end) and are safe to
+ paste into the YAML deployment_time /
+ recovery_time fields. Suggested UTC cells are highlighted
+ amber when the
+ pressure-derived suggested time differs from the YAML time by more than
+ 2 Δt (indicating a sinking/rising transient was detected).
+ The Stage 1 last cell is highlighted
+ orange when the
+ raw record ended more than 2 Δt before the YAML recovery time
+ (instrument may have stopped early).
+ Serial numbers link to the per-instrument report; the
+ 6 h link jumps directly to the start/end window plots.
+
+
+ Spot-check recommended: open the 6 h start/end
+ window plots for each instrument (click 6 h ) and visually
+ confirm that the orange suggested line (or green YAML line if they match)
+ sits at a plausible transition in the pressure (or temperature) record
+ before updating the YAML times.
+
+
+
+
+
+ #
+ Type
+ S/N
+ Start
+ End
+
+
+ Stage1 first
+ Sugg. start (raw)
+ Sugg. start (UTC)
+ Stage1 last
+ Sugg. end (raw)
+ Sugg. end (UTC)
+
+
+
+ {% for instr in instruments %}
+ {% set tm = instr.timing %}
+
+ {{ loop.index }}
+ {{ instr.instr_type }}
+
+ {{ instr.serial }}
+ 6 h
+
+ {% if tm %}
+ {# Anchor dates for deduplication: show only time when on the same day #}
+ {% set d_start = (tm.get("stage1_start") or "")[:10] %}
+ {% set d_end = (tm.get("stage1_end") or "")[:10] %}
+ {# Stage1 first — always full date+time; anchors deduplication for start columns #}
+ {{ tm.get("stage1_start") or "—" }}
+ {# Sugg. start (raw) — only present for pressure instruments; time only when same date #}
+ {% set v = tm.get("sugg_start") or "" %}
+
+ {{- v[11:] if (v and v[:10] == d_start) else (v or "—") -}}
+
+ {# Sugg. start (UTC) #}
+ {% set v = tm.get("sugg_start_utc") or "" %}
+
+ {{- v[11:] if (v and v[:10] == d_start) else (v or "—") -}}
+
+ {# Stage1 last — orange-amber when instrument stopped early #}
+
+ {{- tm.get("stage1_end") or "—" -}}
+
+ {# Sugg. end (raw) #}
+ {% set v = tm.get("sugg_end") or "" %}
+
+ {{- v[11:] if (v and v[:10] == d_end) else (v or "—") -}}
+
+ {# Sugg. end (UTC) #}
+ {% set v = tm.get("sugg_end_utc") or "" %}
+
+ {{- v[11:] if (v and v[:10] == d_end) else (v or "—") -}}
+
+ {% elif instr.skipped %}
+ skipped
+ {% else %}
+ no stage 2 file
+ {% endif %}
+
+ {% endfor %}
+
+ {% if rec_deploy_sec or rec_recover_sec %}
+
+
+ ★
+ Mooring
+ —
+ Mooring start
+ {{ rec_deploy_sec or "—" }}
+ —
+ Mooring end
+ {{ rec_recover_sec or "—" }}
+
+
+ {% endif %}
+
+
+
+{% if yaml_deploy_time or yaml_recover_time or rec_deploy or rec_recover %}
+
+{% if rec_differs %}
+
+ Deployment times — pressure-based instruments suggest times that differ
+ from the current YAML. Copy the suggested snippet (blue border) into your YAML file.
+ Times are given to the minute.
+
+
+
+
+
+ Suggested (copy → paste into YAML)
+
+
+
+{% else %}
+
+ ✓ Current YAML times match the pressure-based suggestion from the instruments.
+
+ Current YAML times
+
+{% endif %}
+
+{% endif %}
+
+
+4 — Clock corrections
+
+ Positive drift/offset = instrument was slow (behind UTC); correction shifts times later.
+ Negative = instrument was fast (ahead of UTC); correction shifts times earlier.
+
+
+
+
+ #
+ Type
+ S/N
+ Hab (m)
+ Offset (s)
+ Computer time at recovery
+ Instrument time at recovery
+ Drift (s)
+ Source
+
+
+
+ {% for instr in instruments %}
+
+ {{ loop.index }}
+ {{ instr.instr_type }}
+ {{ instr.serial }}
+ {{ "%.1f"|format(instr.hab) }}
+
+ {% if instr.clock.offset_s == 0 %}
+ —
+ {% elif instr.clock.offset_s > 0 %}
+ +{{ "%.1f"|format(instr.clock.offset_s) }}
+ {% else %}
+ {{ "%.1f"|format(instr.clock.offset_s) }}
+ {% endif %}
+
+ {% if instr.clock.computer_time %}{{ instr.clock.computer_time }}{% else %}— {% endif %}
+ {% if instr.clock.instrument_time %}{{ instr.clock.instrument_time }}{% else %}— {% endif %}
+
+ {% if instr.clock.drift_s is none or instr.clock.drift_s == 0 %}
+ —
+ {% elif instr.clock.drift_s > 0 %}
+ +{{ "%.1f"|format(instr.clock.drift_s) }}
+ {% else %}
+ {{ "%.1f"|format(instr.clock.drift_s) }}
+ {% endif %}
+
+
+ {% if instr.clock.method == "none" %}
+ none
+ {% else %}
+ {{ instr.clock.method }}
+ {% endif %}
+
+
+ {% endfor %}
+
+
+
+{% if fig_clock_check_b64 %}
+Clock alignment check
+
+ Overlaid temperature records zoomed to the first and last 30 minutes of the deployment.
+ If instrument clocks are misaligned the temperature curves will appear shifted in time.
+ Data are from stage‑3 (or stage‑2 if stage‑3 is not yet available).
+ Instruments with no temperature data are omitted.
+
+
+{% endif %}
+
+
+5 — Sensor calibration
+
+{% set has_sensors = instruments | selectattr("sensors") | list %}
+{% if has_sensors %}
+
+
+
+ #
+ Type
+ Instr S/N
+ Sensor
+ Model
+ Sensor S/N
+ Cal date
+ Coefficients
+
+
+
+ {% set ns = namespace(idx=0) %}
+ {% for instr in instruments %}
+ {% if instr.sensors %}
+ {% set ns.idx = ns.idx + 1 %}
+ {% for sensor in instr.sensors %}
+
+ {% if loop.index0 == 0 %}{{ ns.idx }}{% endif %}
+ {% if loop.index0 == 0 %}{{ instr.instr_type }}{% endif %}
+ {% if loop.index0 == 0 %}{{ instr.serial }}{% endif %}
+ {{ sensor.sensor_type | title }}
+ {{ sensor.sensor_model }}
+ {{ sensor.sensor_serial }}
+ {{ sensor.cal_date }}
+
+ {% if sensor.coefficients %}
+
+ show
+ {{ sensor.coefficients }}
+
+ {% else %}
+ —
+ {% endif %}
+
+
+ {% endfor %}
+ {% endif %}
+ {% endfor %}
+
+
+{% else %}
+No sensor calibration metadata found in processed files.
+{% endif %}
+
+
+6 — QC flag summary
+
+
+ good (1)
+ prob. good (2)
+ suspect (3)
+ bad (4)
+ interp. (8)
+ missing (9)
+
+{% set has_qc = instruments | selectattr("qc_summary") | list %}
+{% if has_qc %}
+
+
+
+ #
+ Type
+ S/N
+ Variable
+ N
+ Good %
+ Suspect %
+ Bad %
+ Interp. %
+ Missing %
+ Distribution
+
+
+
+ {% set ns = namespace(idx=0) %}
+ {% for instr in instruments %}
+ {% if instr.qc_summary %}
+ {% set ns.idx = ns.idx + 1 %}
+ {% for row in instr.qc_summary %}
+ {% set good = row.flags | selectattr("flag", "eq", 1) | first %}
+ {% set susp = row.flags | selectattr("flag", "eq", 3) | first %}
+ {% set bad = row.flags | selectattr("flag", "eq", 4) | first %}
+ {% set interp = row.flags | selectattr("flag", "eq", 8) | first %}
+ {% set miss = row.flags | selectattr("flag", "eq", 9) | first %}
+
+ {% if loop.index0 == 0 %}{{ ns.idx }}{% endif %}
+ {% if loop.index0 == 0 %}{{ instr.instr_type }}{% endif %}
+ {% if loop.index0 == 0 %}{{ instr.serial }}{% endif %}
+ {{ row.var }}
+ {{ "{:,}".format(row.total) }}
+
+ {{ good.pct }}
+
+ {% if susp.pct > 0 %}{{ susp.pct }} {% else %}–{% endif %}
+ {% if bad.pct > 0 %}{{ bad.pct }} {% else %}–{% endif %}
+ {% if interp.pct > 0 %}{{ interp.pct }} {% else %}–{% endif %}
+ {% if miss.pct > 0 %}{{ miss.pct }}{% else %}–{% endif %}
+
+
+ {% for f in row.flags %}
+ {% if f.pct > 0 %}
+
+ {% endif %}
+ {% endfor %}
+
+
+
+ {% endfor %}
+ {% endif %}
+ {% endfor %}
+
+
+{% else %}
+No stage 3 QC files found — run oceanarray stage3 first.
+{% endif %}
+
+{% if fig_knockdown_hab_b64 or fig_knockdown_anomaly_b64 or fig_knockdown_displacement_b64 %}
+
+Mooring knockdown
+
+{% if fig_knockdown_hab_b64 %}
+
+
Nominal HAB (x) vs. measured pressure (y). The dashed line shows expected pressure
+ (water depth − HAB). Instruments below the line were knocked down.
+ Interpolated pressure (QC flag 8) excluded.
+
+
+{% endif %}
+{% if fig_knockdown_anomaly_b64 %}
+
+
IQR of pressure anomaly (measured − nominal) per instrument.
+ Positive = deeper than design depth.
+ Green < 100 dbar,
+ yellow 100–200,
+ amber 200–300,
+ red > 300 dbar.
+
+
+{% endif %}
+
+{% if fig_knockdown_displacement_b64 %}
+Estimated horizontal displacement (m) vs. measured pressure.
+ Left: scatter per instrument; right: normalised 2-D density across all instruments.
+ Displacement derived from the rigid-pendulum approximation:
+ x = √(habnom ² − habmeas ²).
+
+{% endif %}
+{% endif %}
+
+{% if diagram_b64 %}
+
+Mooring diagram
+
+{% endif %}
+
+
+{% if issues.any %}
+Issues for cruise report
+
+{% if issues.skipped %}
+Skipped / missing instruments
+
+{% for i in issues.skipped %}
+SN {{ i.serial }} ({{ i.itype }}, {{ i.depth_str }}): {{ i.reason }}
+{% endfor %}
+
+{% endif %}
+
+{% if issues.stopped_early %}
+Stopped early
+
+{% for i in issues.stopped_early %}
+SN {{ i.serial }} ({{ i.itype }}, {{ i.depth_str }}): last sample {{ i.t_end }}, expected {{ i.expected }} ({{ i.delta_h }} h short)
+{% endfor %}
+
+{% endif %}
+
+{% if issues.qc_flagged %}
+Data quality flags
+
+{% for i in issues.qc_flagged %}
+SN {{ i.serial }} ({{ i.itype }}): {{ i.note }}
+{% endfor %}
+
+{% endif %}
+
+{% endif %}
+
+
+
+
+
+{% endblock %}
diff --git a/oceanarray/reports/templates/recovery_table.html b/oceanarray/reports/templates/recovery_table.html
new file mode 100644
index 0000000..b9ed151
--- /dev/null
+++ b/oceanarray/reports/templates/recovery_table.html
@@ -0,0 +1,129 @@
+{% extends "base.html" %}
+{% block title %}{{ mooring_name }} – Mooring Recovery Table{% endblock %}
+{% block masthead_class %}masthead-plain{% endblock %}
+{% block masthead_title %}{{ mooring_name }} ({{ year }}){% endblock %}
+{% block masthead_type %}Mooring Recovery{% endblock %}
+{% block masthead_nav %}{% endblock %}
+{% block masthead_meta %}
+ {% if latitude %}
Latitude {{ latitude }} {% endif %}
+ {% if longitude %}
Longitude {{ longitude }} {% endif %}
+
Water depth {{ waterdepth }} m
+
Deployment {{ deploy_time }}
+
Recovery {{ recover_time }}
+
Duration {{ duration }}
+ {% endblock %}
+{% block page_styles %}
+ table {
+ border-collapse: collapse;
+ width: 100%;
+ font-size: 9.5pt;
+ }
+ th {
+ background: var(--ocean);
+ color: #fff;
+ padding: 0.35rem 0.5rem;
+ text-align: left;
+ font-weight: bold;
+ vertical-align: bottom;
+ white-space: pre-line;
+ }
+ th.num { text-align: right; }
+ td {
+ padding: 0.28rem 0.5rem;
+ border-bottom: 1px solid #ccc;
+ vertical-align: top;
+ }
+ tr:nth-child(even) td { background: var(--seafoam); }
+ tr.nonlog td {
+ background: #f0f0f0;
+ font-style: italic;
+ color: #444;
+ border-bottom: 1px solid #aaa;
+ }
+ td.ts { font-size: 8.5pt; white-space: pre; font-family: monospace; }
+ td.drift { font-family: monospace; font-size: 9pt; text-align: center; }
+ td.star { text-align: center; font-weight: bold; }
+ .notes { margin-top: 1.2rem; font-size: 9.5pt; }
+ .notes h3 { font-size: 10pt; margin-bottom: 0.4rem; }
+ .notes dl { margin: 0; }
+ .notes dt { font-weight: bold; margin-top: 0.4rem; }
+ .notes dd { margin: 0 0 0.2rem 1.2rem; }
+ .footer { margin-top: 1.5rem; font-size: 8.5pt; color: #888; border-top: 1px solid #ddd; padding-top: 0.5rem; }
+ @media print {
+ body { padding: 0; max-width: 100%; font-size: 9pt; }
+ th { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
+ table { page-break-inside: auto; }
+ tr { page-break-inside: avoid; }
+ .footer { display: none; }
+ }
+{% endblock %}
+{% block content %}
+
+
+
+
+ Height
+(above
+bottom)
+(m)
+ Depth
+(nominal)
+(m)
+ Instrument
+ Param.
+ Sample
+int.
+(s)
+ Start/stop
+time UTC
+ Clock
+drift
+ First good /
+Last good record
+ Notes
+
+
+
+ {% for row in rows %}
+ {% if row.nonlog %}
+
+ {{ row.hab if row.hab is not none else "" }}
+ {{ row.depth if row.depth is not none else "" }}
+ {{ row.description }}
+
+ {% else %}
+
+ {{ row.hab if row.hab is not none else "?" }}
+ {{ row.depth if row.depth is not none else "?" }}
+ {{ row.instrument }}
+ {{ row.params }}
+ {{ row.interval_s if row.interval_s else "" }}
+ {{ row.start_stop }}
+ {{ row.clock_drift }}
+ {{ row.first_last }}
+ {{ "*" if row.comment else "" }}
+
+ {% endif %}
+ {% endfor %}
+
+
+
+{% if comments %}
+
+
Notes
+
+ {% for c in comments %}
+ SN {{ c.serial }} ({{ c.instrument }}, {{ c.depth_str }})
+ {{ c.comment }}
+ {% endfor %}
+
+
+{% endif %}
+
+
+
+{% endblock %}
diff --git a/oceanarray/reports/templates/stack.html b/oceanarray/reports/templates/stack.html
new file mode 100644
index 0000000..0a0ee73
--- /dev/null
+++ b/oceanarray/reports/templates/stack.html
@@ -0,0 +1,317 @@
+{% extends "base.html" %}
+{% block title %}Stack report – {{ mooring_name }}{% endblock %}
+{% block page_styles %}
+ :root { --accent:#2980b9; --accent-link:#cef; }
+ .fig { width:100%; border:1px solid #dce; border-radius:4px; margin-bottom:1.5rem; }
+ .var-table, .instr-table { width:100%; border-collapse:collapse; font-size:0.82rem; margin-bottom:1.5rem; }
+ .var-table th, .instr-table th { background:var(--seafoam); text-align:left;
+ padding:0.4rem 0.6rem; border-bottom:2px solid #cde; }
+ .var-table td, .instr-table td { padding:0.3rem 0.6rem; border-bottom:1px solid #eef; vertical-align:top; }
+ .var-table tr:nth-child(even) td, .instr-table tr:nth-child(even) td { background:#f4f9fc; }
+ .var-table tr:hover td, .instr-table tr:hover td { background:#e8f4f8; }
+{% endblock %}
+{% block masthead_type %}Stacked{% endblock %}
+{% block masthead_meta %}
+
Cruise {{ cruise }}
+
Ship {{ ship }}
+
Latitude {{ latitude }}
+
Longitude {{ longitude }}
+
Water depth {{ waterdepth }}{% if waterdepth != '—' %} m{% endif %}
+
Deployment {{ deploy_time }}
+
Recovery {{ recover_time }}
+
Duration {{ duration }}
+
Samp. Δt {{ dt_seconds }}{% if dt_seconds != '—' %} s{% endif %}
+
Records {{ n_time }}
+
Instruments {{ n_instr }}
+
Source file {{ nc_file }}
+ {% endblock %}
+{% block content %}
+
+
+ Jump to:
+ {% if history_entries %}History {% endif %}
+ Instruments
+ {% if fig_pressure_b64 %}Pressure {% endif %}
+ {% if fig_temp_b64 %}Temperature {% endif %}
+ {% if fig_sal_b64 %}Salinity {% endif %}
+ {% if fig_east_vel_b64 or fig_north_vel_b64 or fig_up_vel_b64 %}Velocity {% endif %}
+ {% if fig_trajectories_b64 or fig_adcp_trajectories_b64 %}Trajectories {% endif %}
+ {% if fig_speed_profile_b64 %}Speed profile {% endif %}
+ {% if fig_analog_b64 %}Analog channels {% endif %}
+ {% if fig_ts_stack_b64 %}T-S diagram {% endif %}
+ {% if fig_rose_grid_b64 %}Current roses {% endif %}
+ {% if fig_aquadopp_tilt_b64 %}Tilt {% endif %}
+ {% if fig_spacing_b64 %}Spacing {% endif %}
+ {% if fig_clock_check_b64 %}Clock check {% endif %}
+ Dimensions
+ Variables
+
+
+
+{% if history_entries %}
+Processing history
+
+ {% for e in history_entries %}
+
+ {{ e.timestamp }}
+ {{ e.text }}
+
+ {% endfor %}
+
+{% endif %}
+
+
+Instruments (deep-first)
+
+ # Type Serial HAB (m) ~Depth (m)
+
+ {% for row in instr_rows %}
+
+ {{ loop.index0 }}
+ {{ row.instr_type }}
+ {% if row.report_exists %}{{ row.serial }} {% else %}{{ row.serial }}{% endif %}
+ {{ row.hab }}
+ {{ row.depth }}
+
+ {% endfor %}
+
+
+
+
+{% if fig_pressure_b64 %}
+Pressure records (all instruments)
+Values with QC flag ≥ 3 (suspect/bad) masked to NaN before plotting. All data values are in {{ nc_file }} without masking.
+
+{% endif %}
+
+
+{% if fig_temp_b64 %}
+Temperature (all instruments)
+Values with QC flag ≥ 3 (suspect/bad) masked to NaN before plotting. All data values are in {{ nc_file }} without masking.
+
+{% endif %}
+
+
+{% if fig_sal_b64 %}
+Salinity (all instruments)
+Values with QC flag ≥ 3 (suspect/bad) masked to NaN before plotting. All data values are in {{ nc_file }} without masking.
+
+{% endif %}
+
+{% if fig_dissolved_oxygen_b64 %}
+Dissolved oxygen (all instruments)
+One line per instrument with dissolved oxygen data (SBE ODO sensor); QC flags ≥ 3 masked. Units: µmol L⁻¹. % saturation available in per-instrument reports.
+
+{% endif %}
+
+
+{% if fig_east_vel_b64 %}
+East velocity (U)
+ENU frame. Values with velocity_flag ≥ 3 masked to NaN before plotting. All data values are in {{ nc_file }} without masking. Instruments without velocity data omitted.
+
+{% endif %}
+
+{% if fig_north_vel_b64 %}
+North velocity (V)
+ENU frame. Values with velocity_flag ≥ 3 masked to NaN before plotting. All data values are in {{ nc_file }} without masking.
+
+{% endif %}
+
+{% if fig_up_vel_b64 %}
+Vertical velocity (W)
+ENU frame. Values with velocity_flag ≥ 3 masked to NaN before plotting. All data values are in {{ nc_file }} without masking.
+
+{% endif %}
+
+{% if fig_turbidity_b64 %}
+Turbidity
+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).
+
+{% endif %}
+
+{% if fig_trajectories_b64 or fig_adcp_trajectories_b64 %}
+Particle trajectories
+
+ Pseudo-Lagrangian displacement: east/north velocity integrated over time
+ (Euler forward; NaN velocities set to zero). All trajectories share a common origin (0, 0).
+ Aquadopp: colour shows temperature (shared scale); end points labelled with serial and HAB.
+ ADCP: per-bin trajectories coloured by height above bottom; bins entirely below the seabed are omitted.
+
+
+ {% if fig_trajectories_b64 %}
+
+
Aquadopp
+
+
+ {% endif %}
+ {% if fig_adcp_trajectories_b64 %}
+
+
ADCP
+
+
+ {% endif %}
+
+{% endif %}
+
+{% if fig_speed_profile_b64 %}
+Aquadopp speed profile
+
+ Horizontal boxplot per Aquadopp, positioned at its nominal height above bottom
+ (per-instrument design value; does not account for mooring knockdown).
+ Box = interquartile range; line = median; whiskers = 1.5×IQR; dots = outliers.
+ Computed from east/north velocity components if current_speed is not stored.
+
+
+{% endif %}
+
+{% if fig_analog_b64 %}
+Analog channels
+
+ Full-record time series of analog channel variables containing non-zero, non-NaN data.
+ One panel per channel.
+
+
+{% endif %}
+
+{% if fig_ts_stack_b64 %}
+T-S diagram
+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.
+
+{% endif %}
+
+{% if fig_rose_grid_b64 %}
+Current rose diagrams
+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).
+{% if rose_declination_warn %}
+
+ ⚠ Magnetic declination could not be applied to
+ {% if rose_declination_missing_serials %}Aquadopp(s) s/n {{ rose_declination_missing_serials | join(', ') }}{% else %}one or more Aquadopps{% endif %}
+ — latitude/longitude are missing or all-zero in the mooring YAML (check seabed_latitude, deployment_latitude, or latitude/longitude).
+ Re-run oceanarray process … --stage 3 for the affected instrument(s) after fixing the YAML.
+ Affected ENU velocities currently use 0° declination (magnetic north, not true north).
+
+{% endif %}
+{% if rose_declination_note %}{{ rose_declination_note }}
{% endif %}
+
+{% endif %}
+
+{% if fig_aquadopp_tilt_b64 %}
+Aquadopp tilt (|pitch| / |roll| / pressure estimate)
+
+ One panel per Aquadopp (deep-first). Blue = |pitch|, green = |roll|, orange dashed = tilt
+ estimated from pressure difference between the Aquadopp and the nearest instrument ≥10 m above
+ with valid pressure (arccos(ΔP / rope length)). All curves are non-negative.
+ Horizontal lines: orange dashed = suspect threshold, red dotted = fail threshold (read from file attrs).
+ Pitch and roll are stored unmasked in the stack file; use pitch_qc /
+ roll_qc to filter. Plots show all available values.
+
+
+{% endif %}
+
+{% if fig_spacing_b64 %}
+Adjacent instrument spacing
+Distribution of pressure differences between adjacent instrument pairs (pairs < 2 dbar apart excluded as co-located).
+
+{% endif %}
+
+{% if fig_clock_check_b64 %}
+Clock alignment check
+
+ Temperature records from all instruments overlaid, zoomed to the first and last
+ 10 minutes of the deployment. A horizontal shift between curves indicates
+ a clock offset between instruments. Data are from stage‑3 (or stage‑2
+ if stage‑3 is not yet available). Instruments without temperature are omitted.
+
+
+{% endif %}
+
+
+NetCDF dimensions — {{ nc_file }}
+{% if nc_meta.dims %}
+
+ Dimension Size
+
+ {% for dim, size in nc_meta.dims.items() %}
+ {{ dim }} {{ "{:,}".format(size) }}
+ {% endfor %}
+
+
+{% endif %}
+
+
+NetCDF variables — {{ nc_file }}
+{% if nc_meta.get("error") %}
+Could not read file: {{ nc_meta.error }}
+{% else %}
+
+Variables
+
+
+ Variable Type Dims N Valid Min / Max Units Long name Standard name QC flag
+
+
+ {% for v in nc_meta.time_vars %}
+ {% if not v.is_qc %}
+
+ {{ v.name }}
+ {{ v.dtype }}
+ {{ v.dims }}
+ {{ "{:,}".format(v.n) }}
+ {{ "{:,}".format(v.n_valid) if v.n_valid is defined else "—" }}
+ {% if v.v_min is not none %}{{ v.v_min }} / {{ v.v_max }}{% else %}—{% endif %}
+ {{ v.units }}
+ {{ v.long_name }}
+ {{ v.standard_name }}
+ {% if v.has_qc %}✓ {% else %}–{% endif %}
+
+ {% endif %}
+ {% endfor %}
+
+
+
+{% if nc_meta.scalar_vars %}
+Scalar metadata variables
+
+
+ Variable Type Value Units Long name
+
+
+ {% for v in nc_meta.scalar_vars %}
+
+ {{ v.name }}
+ {{ v.dtype }}
+ {{ v.value }}
+ {{ v.units }}
+ {{ v.long_name }}
+
+ {% endfor %}
+
+
+{% endif %}
+
+{% if nc_meta.global_attrs %}
+Global attributes
+
+ Attribute Value
+
+ {% for k, v in nc_meta.global_attrs.items() %}
+
+ {{ k }}
+ {{ v }}
+
+ {% endfor %}
+
+
+{% endif %}
+
+{% endif %}
+
+
+
+{% endblock %}
diff --git a/pyproject.toml b/pyproject.toml
index 32fd00b..c478215 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -66,6 +66,7 @@ oceanarray = [
"config/*.mplstyle", # vendored report.mplstyle — MPLSTYLE_PATH resolves it at runtime
"config/*.yaml",
"config/legacy/*.yaml",
+ "reports/templates/*.html", # report page templates loaded via FileSystemLoader
]
[tool.setuptools.dynamic]
diff --git a/tests/fixtures/golden/dune2/dune2_1_2026_grid_report.html b/tests/fixtures/golden/dune2/dune2_1_2026_grid_report.html
index 82d0e46..2eb41e2 100644
--- a/tests/fixtures/golden/dune2/dune2_1_2026_grid_report.html
+++ b/tests/fixtures/golden/dune2/dune2_1_2026_grid_report.html
@@ -3,39 +3,42 @@
-Grid report – dune2_1_2026
+Grid report – dune2_1_2026
+
-