Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 42 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ All contributions are welcome: bug reports, feature suggestions, documentation i

## Table of Contents

- [Licensing of contributions](#licensing-of-contributions)
- [Credit and authorship](#credit-and-authorship)
- [I Have a Question](#i-have-a-question)
- [Reporting Bugs](#reporting-bugs)
- [Suggesting Enhancements](#suggesting-enhancements)
Expand All @@ -14,6 +16,33 @@ All contributions are welcome: bug reports, feature suggestions, documentation i

---

## Licensing of contributions

oceanarray is MIT licensed, and contributions are accepted **under the same licence** — what GitHub calls "inbound = outbound". By opening a pull request you confirm that:

1. you wrote the contribution, or you have the right to submit it; and
2. you agree to license it to the project under the MIT licence.

This does not transfer your copyright, which remains yours.

**If your contribution contains code from somewhere else** — another repository, a paper's supplementary material, a Stack Overflow answer, a colleague's script, or generated output you did not review — say so in the pull request and name the source and its licence. This is the single most useful thing you can tell a reviewer. Code from an unlicensed source cannot be merged until its author agrees, so flagging it early avoids the work being wasted.

---

## Credit and authorship

Three separate things.

**Copyright** stays with whoever wrote the code. You are not asked to assign it.

**Contributor credit** is automatic: everyone whose pull request is merged appears in the git history. If your name or preferred email in the git log is wrong, add a `.mailmap` entry via a pull request — that is the correct fix.

**Citation authorship** is the author list in `CITATION.cff`, which propagates into the Zenodo DOI for every release and therefore into other people's bibliographies. It reflects *substantial* contribution to the software — its design, a significant body of its implementation, its test suite, or its documentation architecture — and is decided by the maintainer at release time. Funding or supervision alone does not qualify. There is no line count that guarantees it or excludes it. If you believe your contribution crosses that line and it has not been reflected, please say so in an issue — being asked is better than being resented.

Where a contribution is adapted from someone else's work rather than written from scratch, we credit it **in the docstring of the code itself**, so the attribution travels with the code rather than living in a file nobody reads (see the existing examples in `utilities.py` and `plotters/current.py`).

---

## I Have a Question

Read the [documentation](https://ocean-uhh.github.io/oceanarray/) first.
Expand Down Expand Up @@ -87,7 +116,17 @@ pip install seasenselib --no-deps
1. Fork the repository and create a feature branch from `main`.
2. Make your changes, following the conventions below.
3. Run `ruff check . --fix` and then `pytest` — all tests must pass.
4. Open a pull request against `main`. Use a title prefixed with `[FEAT]`, `[FIX]`, `[REFACTOR]`, `[DOC]`, `[TEST]`, or `[CLEANUP]`.
4. Open a pull request against `main`. Prefix the title with a bracket tag: `[FEAT]`, `[FIX]`, `[REFACTOR]`, `[DOC]`, `[TEST]`, `[CI]`, or `[CLEANUP]`. (PR titles use bracket tags; individual **commit messages** use conventional-commit prefixes — see below.)

Keep each pull request to one logical change. A rename PR that also fixes a bug is a PR nobody can review — split them. (This matters most once more than one person is working on the code.)

### Commit messages

Use conventional-commit prefixes (`feat:`, `fix:`, `refactor:`, `docs:`, `test:`, `ci:`, `chore:`) with an imperative subject. If you worked with someone, or adapted their code, add a trailer:

```
Co-authored-by: Name <email@example.com>
```

### Code conventions

Expand All @@ -97,6 +136,7 @@ pip install seasenselib --no-deps
- **Units**: never strip or assume units. Use `gsw` for all seawater property calculations.
- **Plot style**: discrete colorbars (`BoundaryNorm`, ≤ 20 levels); call `plt.style.use(str(P.MPLSTYLE))` at the start of every plot function.
- **Data provenance**: store all processing parameters (thresholds, coefficients) in NC output attributes so any file can be reprocessed exactly from itself.
- **No silent defaults**: never substitute a default, guess, or approximation when the correct value cannot be determined. Raise, or warn loudly and record what was assumed in the output's metadata. A plausible wrong number is worse than an error, because nothing downstream can detect it.

### Testing

Expand All @@ -108,7 +148,7 @@ pytest tests/unit -m "not slow and not needs_seasenselib" # without seasenselib
pytest --cov=oceanarray --cov-report=term-missing -q # with coverage
```

New or changed code must have test coverage. Integration tests use committed NetCDF fixtures in `tests/fixtures/`.
New or changed code must have a test. For anything numerical, assert a **value** you can justify independently of the code — `pytest.approx` or `numpy.testing.assert_allclose` against an analytic case, an invariant that must hold for any input, or a cross-check against `gsw` or another implementation — rather than a value produced by running the code (which proves only that the code has not changed). Do not add tests that pass when nothing happened: `assert result is not None` on a function that returns `None` on failure is the shape to avoid. Integration tests use committed NetCDF fixtures in `tests/fixtures/`.

---

Expand Down
24 changes: 24 additions & 0 deletions oceanarray/config/report.mplstyle
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
axes.titlesize : 12
axes.labelsize : 10
date.autoformatter.day: %Y-%m-%d
date.converter: auto
figure.figsize: 8, 4
figure.dpi: 100
font.family: sans-serif
font.sans-serif: Helvetica Neue, Helvetica, Arial, Liberation Sans, DejaVu Sans
font.style: normal
font.size: 10
legend.fontsize: 9
lines.linewidth : 1
lines.linestyle: -
lines.markersize : 10
xtick.labelsize : 10
xtick.alignment: center
ytick.labelsize : 10
axes.grid : False
axes.linewidth : 0.6
grid.alpha : 0.5
grid.color : 0.5
grid.linestyle : :
grid.linewidth : 0.6
contour.linewidth : 0.8
206 changes: 206 additions & 0 deletions oceanarray/config/report_tokens.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
"""Shared report design tokens — the single source of every presentation value.

This module is written to be **vendored** across the packages that share the
report design system. It is
package-neutral — it names no package and holds only data (plus the mplstyle
path, resolved relative to this file so no package name appears in the text) — so
the copies can be frozen and checked later; for now this document and convention
are the shared reference (no cross-repo hash test yet). A value that belongs to
one package (a scientific variable registry) does **not** live here.

Layering: this is a leaf, and it lives in ``config/`` for that reason — its only
import is :mod:`pathlib`. Both the plotters (which size figures from
:data:`SLOTS`) and the report/CSS layer read it; it reads nothing of theirs.
Placing it under ``reports/`` would form an import cycle
(``plotters.plots -> reports -> reports._index -> plotters.plots``).

Part II of ``2026-08-12-report-spec.md`` is the prose behind these numbers.
Templates and plotters read them; they never restate them.
"""

from __future__ import annotations

from pathlib import Path

# ---------------------------------------------------------------------------
# Style file and error policy
# ---------------------------------------------------------------------------
# The report mplstyle sits next to this module under config/, with a
# package-neutral name so this line is byte-identical across packages.
MPLSTYLE_PATH: Path = Path(__file__).with_name("report.mplstyle")

# When True, a plotting failure (or a None from a required panel) re-raises
# instead of being swallowed. Test infrastructure toggles this at runtime; the
# default False is identical across packages. Read at call time by both the
# plotters and the encoder so a single flag governs both.
RAISE_ON_PLOT_ERROR: bool = False

# ---------------------------------------------------------------------------
# Geometry (spec §11)
# ---------------------------------------------------------------------------
CONTENT_MAX_PX: int = 1150 # body max-width
CONTENT_PAD_PX: int = 32 # body padding, each side
USABLE_PX: int = CONTENT_MAX_PX - 2 * CONTENT_PAD_PX # 1086
W_FULL: float = 9.0 # full-slot figure width in inches (the basis of SLOTS)
FIG_DPI: int = 150 # savefig dpi; with W_FULL this fixes every PNG width
# Oversample (png_px / display_px) is DERIVED, not a free knob: W_FULL, FIG_DPI
# and USABLE_PX over-determine it, so declaring all three independently would
# guarantee a contradiction. The true value is ≈1.243, not a round 1.25 — the
# difference is invisible, and keeping W_FULL and FIG_DPI clean (they are the two
# that appear in code, as figsize and savefig dpi) reproduces the package's existing
# PNG widths exactly. Nothing reads this; it is documentation.
OVERSAMPLE: float = W_FULL * FIG_DPI / USABLE_PX # ≈ 1.2431
PNG_PALETTE_COLORS: int = 256 # 8-bit palette quantization in the encoder

# ---------------------------------------------------------------------------
# Slot table (spec §14)
# ---------------------------------------------------------------------------
# name -> (fraction of USABLE_PX, figure width in inches).
# Invariant asserted by the slot-contract test: inches == W_FULL * fraction, so
# display_px / fig_in is identical for every figure and one font size renders at
# one on-screen size everywhere. Test 2 asserts each saved PNG is exactly
# round(inches * FIG_DPI) px wide (1350 / 900 / 810 / 675 / 540 / 450).
SLOTS: dict[str, tuple[float, float]] = {
"full": (1.0, 9.0),
"twothirds": (2 / 3, 6.0),
"three-fifths": (0.6, 5.4),
"half": (0.5, 4.5),
"two-fifths": (0.4, 3.6),
"third": (1 / 3, 3.0),
}

# Ergonomic width aliases (inches) for plotter call sites; derived from SLOTS.
W_TWOTHIRDS: float = SLOTS["twothirds"][1]
W_THREE_FIFTHS: float = SLOTS["three-fifths"][1]
W_HALF: float = SLOTS["half"][1]
W_TWO_FIFTHS: float = SLOTS["two-fifths"][1]
W_THIRD: float = SLOTS["third"][1]

# Aspect-locked figure constants (spec §14).
SECTION_STRETCH: float = (
16.0 # calibrated: 416 dbar × 94 km → 2.5 in tall at full width
)
MAX_SECTION_H: float = 5.2 # height cap; tall/narrow sections get a narrower fig_w
MIN_SECTION_H: float = 3.0 # height floor

# ---------------------------------------------------------------------------
# Figure annotation font sizes (points, spec §13.3)
# ---------------------------------------------------------------------------
# The only per-call font sizes a plotter may set: matplotlib does not route
# annotation text through a style key, so these cannot come from the mplstyle.
# Everything else (axes/tick/legend/title sizes) is the mplstyle's job and must
# not be set per call. The "no stray typography" test allow-lists exactly these
# three names.
CLABEL_FS: int = 8 # ax.clabel() contour labels
ANNOT_FS: int = 8 # in-axes annotation / panel-label text boxes
CAST_LABEL_FS: int = 6 # dense in-axes cast-number labels on maps and sections

# ---------------------------------------------------------------------------
# Spacing and radii (spec §15) [data; applied by emit_css() in rep/vis-system]
# ---------------------------------------------------------------------------
SPACE: dict[str, str] = {
"1": "4px",
"2": "8px",
"3": "12px",
"4": "16px",
"5": "24px",
"6": "32px",
"7": "48px",
}
RADII: dict[str, str] = {"card": "8px", "btn": "4px", "pill": "999px"}

# ---------------------------------------------------------------------------
# Typography (spec §13.2) [data; applied by emit_css() in rep/vis-system]
# ---------------------------------------------------------------------------
# THE single source of page font sizes. Nothing else in the repository sets a
# page font size. Values in rem (the browser's 16px root, exactly as the current
# CSS uses them) plus one absolute px for the body base. This is the established,
# reviewed page scale (spec §13.2) — the values to match, not a redesign. Keyed
# by role, matching the CSS custom
# properties emit_css() generates. Figure font sizes are a *separate* knob (the
# mplstyle, in points).
TYPE: dict[str, dict[str, str]] = {
"root": {"size": "14px", "line": "1.5"}, # body base
"h1": {"size": "1.75rem", "weight": "700"}, # masthead title
"type": {"size": "1.35rem", "weight": "700"}, # .masthead-type page label
"h2": {"size": "1rem"}, # section headings — colour/weight/underline, not size
"meta": {"size": "0.84rem"}, # meta-grid <dd>
"note": {"size": "0.82rem"}, # .note, .caption, .explainer
"nav": {"size": "0.8rem"}, # jump-nav, .btn-nav
"cap": {"size": "0.76rem"}, # figcaption
"xs": {"size": "0.75rem"}, # breadcrumb, footer
"top": {"size": "0.72rem"}, # ↑ top link
"dt": {"size": "0.7rem"}, # meta-grid <dt>, jump-nav ▸
}

# Page font stacks (spec §13.1). The CSS uses the native `system-ui` stack —
# zero-install, matches the host OS. The *figure* font is separate (the mplstyle
# names a Helvetica stack); figure text is baked into a raster, so the two need
# not match.
FONT_SANS: str = 'system-ui, -apple-system, "Segoe UI", sans-serif'
FONT_MONO: str = (
'ui-monospace, "SF Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace'
)

# ---------------------------------------------------------------------------
# Colour — base tokens (spec §12.1) [data; applied in rep/vis-system]
# ---------------------------------------------------------------------------
COLORS: dict[str, str] = {
"ocean": "#1a3a5c", # headings, structural dark
"seafoam": "#e8f4f8", # h2 underline, jump-nav background
"muted": "#95a5a6", # footer, breadcrumb separators, ↑ top
"text": "#2c3e50", # body text
"rule": "#dfe6e9", # table borders, hairlines
"bg": "#ffffff", # page background
"bg-sunken": "#f7f9fa", # table zebra, card interiors
"warn": "#e67e22", # .warn border and icon
"warn-bg": "#fdf3e7", # .warn background
"error": "#c0392b", # failed QC, sentinel values
}

# Role accent (spec §12.2): masthead background + nav pill. `landing` is the
# summary/index accent; entity/collection/component share the structural dark
# --ocean; the aggregate and map roles have their own colours. The `landing`
# value is a per-package choice (see the spec's role table).
ROLE_ACCENT: dict[str, str] = {
"landing": "#2980b9", # summary / index accent
"entity": "#1a3a5c",
"collection": "#1a3a5c",
"component": "#1a3a5c",
"aggregate-a": "#8e44ad", # sections
"aggregate-b": "#27ae60", # timeseries
"map": "#ee3377",
}

# Package accent (spec §12.2): confined to two additive places so it restyles no
# existing pixel — a small masthead wordmark and the footer's border-top. h2
# underlines stay --seafoam and links stay --ocean.
PACKAGE_ACCENT: dict[str, str] = {
"oceanarray": "#1a3a5c",
"ctdcast": "#0e6e6e",
"caldip": "#7a4b8a", # reserved
"amocatlas": "#8a5a2b", # reserved
}

# Neutral gray scale — consolidates the ad-hoc grays that the per-page template
# <style> blocks used (text shades, hairlines, sunken fills). Emitted as
# --gray-1 (lightest) … --gray-7 (near-black). A few template literals shift to
# the nearest step here; the changes are small and were signed off on OdB.
GRAYS: dict[str, str] = {
"gray-1": "#f5f7fa", # lightest sunken fill (cast-note / card backgrounds)
"gray-2": "#e0e0e0", # hairlines, borders
"gray-3": "#aaaaaa", # faint text ("not generated" placeholders)
"gray-4": "#888888", # soft secondary text, <details> summaries
"gray-5": "#555555", # secondary body text, descriptions
"gray-6": "#333333", # strong text (cast/trim notes, filenames)
"gray-7": "#111111", # darkest text (interactive-map labels)
}

# Semantic status colours used by badges and banners. --error/--warn already
# exist in COLORS; --ok is new (the LADCP-present badge green).
SEMANTIC: dict[str, str] = {
"ok": "#2c6e49", # LADCP-present badge; "good/available" green
}
# Note: leaflet.html is a standalone page that does not load this shared CSS, so
# its dark-UI palette (#1a1a2e / #aed6f1 / #4a6fa5) stays as literals there —
# tokenizing it would need the :root injected into that page.
16 changes: 9 additions & 7 deletions oceanarray/plotters/diagnostic.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@

_COMPACT_PANEL_VARS: frozenset = frozenset({"battery_voltage", "speed_of_sound"})
_COMPACT_PANEL_HEIGHT: float = 1.5
#: Height (relative units) of a normal, non-compact instrument panel row. Shared
#: by ``draw_windows`` here and ``_build_fig_from_ds`` in ``report/_plots.py`` so
#: the full time-series and start/end-window figures use the same row height.
_PANEL_HEIGHT: float = 2.0

# QC overlay marker styles (OceanSITES flag codes 3, 4, 8).
_QC_COLORS: Dict[int, str] = {
Expand Down Expand Up @@ -849,10 +853,6 @@ def draw_windows(
Width of each window in hours (default 6).
show_qc : bool
Overlay QC flag markers on the data.
panels : list, optional
Subset of ``_instrument_panels`` tuples to draw. When given, only these
rows are rendered (used to paginate a tall window figure across several
images); otherwise every panel for the instrument is drawn on one figure.
vlines : list of (time_val, color, label), optional
Vertical marker lines to draw on both panels. *time_val* may be a
``numpy.datetime64``, an ISO-8601 string, or a ``pandas.Timestamp``.
Expand All @@ -868,6 +868,10 @@ def draw_windows(
transition appears even if the stage2/3 YAML trim cut it off. The
y-axis limits are taken from the primary (stage2/3) data only so that
bench-pressure outliers (p ≈ 0) do not squish the deployment-depth view.
panels : list, optional
Subset of ``_instrument_panels`` tuples to draw. When given, only these
rows are rendered (used to paginate a tall window figure across several
images); otherwise every panel for the instrument is drawn on one figure.

Returns
-------
Expand Down Expand Up @@ -904,16 +908,14 @@ def draw_windows(
end_mask = time >= time[-1] - np.timedelta64(hours * 3600, "s")
if start_mask.sum() < 2 and end_mask.sum() < 2:
return None
# One sample interval used to expand x-axis limits (stage2/3 fallback).
_dt_one = (time[1] - time[0]) if len(time) > 1 else np.timedelta64(300, "s") # noqa: F841

if panels is None:
panels = _instrument_panels(ds, combine_pitch_roll=True)
if not panels:
return None

height_ratios = [
_COMPACT_PANEL_HEIGHT if vname in _COMPACT_PANEL_VARS else 2.0
_COMPACT_PANEL_HEIGHT if vname in _COMPACT_PANEL_VARS else _PANEL_HEIGHT
for vname, *_ in panels
]
nrows = len(panels)
Expand Down
Loading
Loading