Python API

The primary public entry point is ctdcast.report().


Main entry point

ctdcast.reports._index.report(nc_dir: Path, out_dir: Path, *, profiles_path: Path | None = None, section_yaml: Path | None = None, ladcp_dir: Path | None = None, ladcp_profiles_path: Path | None = None, ladcp_pattern: str | None = None, ship_track_nc: Path | None = None, generate: dict[str, bool] | None = None, force: bool = False, skip_existing: bool = False, section_style: str = 'pcolormesh', timeseries_style: str = 'pcolormesh', vmin_override: dict[str, float] | None = None, vmax_override: dict[str, float] | None = None, cruise_info: dict[str, Any] | None = None, config: ReportConfig | None = None, cast_filter: int | list[int] | None = None, sal_range: tuple[float, float] | None = None, trim_soak: bool = False, dbar_step: int = 1, drop_stub: bool = False) int[source]

Generate the full ctdcast HTML report suite.

Parameters:
  • nc_dir – Directory containing per-cast .nc files.

  • out_dir – Root output directory.

  • profiles_path – Path to compiled profiles.nc. Required for section and time series pages.

  • section_yaml – Path to the sections/timeseries YAML file (ctd_sections.yaml).

  • ladcp_dir – Directory containing processed LADCP .mat files named NNN.mat or NNNb.mat (letter-suffix variants supported).

  • ladcp_profiles_path – Path to compiled ladcp_profiles.nc. When present, the summary page gains a Velocity section (cruise-wide U/V overview panels) and a data-inventory pill for the file.

  • ladcp_pattern – Optional filename glob for non-standard LADCP naming, e.g. "msm_142_1_*.mat". The * is replaced with the zero-padded cast number. See find_ladcp_file().

  • ship_track_nc – Path to a ship-track netCDF for the Leaflet map background line.

  • generate – Dict of booleans controlling which page types to build. Keys: "stations", "sections", "timeseries", "index", "map". Missing keys default to True.

  • force – Regenerate all pages regardless of file modification times.

  • skip_existing – If True, skip any page whose output HTML already exists, regardless of whether the source files are newer. Use this to fill in only missing pages without touching anything already generated. Takes precedence over the mtime check but is overridden by force.

  • section_style"pcolormesh" or "contourf" for section figures.

  • timeseries_style"pcolormesh" or "contourf" for time series figures.

  • vmin_override – Per-variable colormap limit overrides (e.g. {"SA": 34.5}).

  • vmax_override – Per-variable colormap limit overrides (e.g. {"SA": 34.5}).

  • cruise_info – Cruise metadata dict (from the cruise_info: block in config.yaml).

  • config – Frozen display configuration (GEBCO path, figsizes, map bounds, colormaps). Defaults to DEFAULT_REPORT_CONFIG.

  • cast_filter – If set, rebuild only the station page for this cast number (implies generate={"stations": True, rest False}).

  • sal_range(sal_min, sal_max) — records with salinity_1 outside this range are excluded from all station page plots. The NC files are not modified. Excluded record count is shown in each page header.

  • trim_soak – If True, apply pre-soak detection on each cast: cut the first 60 s (pump activation) and any records up to the last near-surface record after pump-on. Passed through to generate_station_page().

  • dbar_step – Subsample the pressure axis by this step for section and timeseries plots (default 1, full 1-dbar resolution). build_profiles() always stores 1-dbar data; this controls plot-time resolution only.

  • drop_stub – If True, a cast-page section that applies but whose figures all failed to render is dropped and the survivors renumber over it, instead of keeping the heading with an “unavailable” placeholder. Passed through to generate_station_page().

Returns:

The number of pages that failed to build (0 on full success), so the CLI can exit non-zero when a requested page could not be generated.

Return type:

int


Cast identity

Cast identity: cast-number parsing, expansion, and compact formatting.

Single home for the cast-identity operations shared across the package: parse a cast number and letter suffix from a filename (cast_id_from_name()), expand a cast_numbers config spec to order-preserving pairs or ints (expand_cast_ids() / expand_cast_numbers()), format a single cast id as a zero-padded string (format_cast_id()), and format a list of cast numbers compactly with collapsed ranges (compact_cast_list()).

ctdcast.identity.cast_id_from_name(name: str) tuple[int, str] | None[source]

Extract (cast_num, cast_suffix) from a cast filename stem.

Uses the last 3+-digit group in the stem as the cast number, so cruise or leg numbers earlier in the name (e.g. the 142 in msm_142_1_001_1sec) are not mistaken for the cast number. Letter suffixes are recognised whether directly appended (mixsed2_004b) or underscore-separated (mixsed2_004_b). Returns None when no 3+-digit group is present.

ctdcast.identity.compact_cast_list(nums: list[int]) str[source]

Format a cast number list compactly, collapsing consecutive runs into ranges.

Example: [131, 133, 134, 136, 163] → “131, 133–134, 136, 163”.

ctdcast.identity.expand_cast_ids(cast_numbers: list) list[tuple[int, str]][source]

Expand a cast_numbers spec to (number, suffix) pairs, preserving order.

A plain cast NNN and its lettered sibling NNNb are distinct events, so identity is the pair, not the bare number. A bare int or range names plain events only (suffix ""); a “NNNb” string names the lettered event. Input order is preserved and duplicates are kept — section ordering relies on the author’s given order, so sorting and de-duplication are not applied.

Raises ValueError on a malformed entry, so a bad config fails loudly rather than silently dropping or coercing casts.

ctdcast.identity.expand_cast_numbers(cast_numbers: list) list[int][source]

Expand a cast_numbers spec to a flat list of station numbers, in order.

The integer view of expand_cast_ids() — the letter suffix is dropped, so a “10b” entry contributes station 10. Use this where callers key on the integer station (LADCP files, map positions, membership); use expand_cast_ids() where a plain cast must be distinguished from its lettered sibling (section and timeseries profile selection).

ctdcast.identity.format_cast_id(cast_num: int, cast_suffix: str = '') str[source]

Format a cast identity as a zero-padded id string, e.g. (10, "b") -> "010b".

The single formatter for the NNN/NNNb convention used in output filenames (cast_010b.html), page labels, and cast pills. Changing the pad width or suffix rule here changes it everywhere.


Figure builders

draw_*_fig functions build and return a matplotlib Figure (or None when the dataset lacks the required variables).

Figure builders: each draw_*_fig returns a matplotlib Figure or None.

Encoding to base64 PNG for embedding in a page is done separately by the _make_*_b64 wrappers in ctdcast.reports._plots.

ctdcast.plotters.plots.draw_all_sections_map_fig(sections_data: list[dict[str, Any]], all_lats: list[float], all_lons: list[float], legend_outside: bool = False, *, target_h: float = 4.5, cfg: ReportConfig = ReportConfig(gebco_path=None, clean_spines=True, profile_figsize=(7.0, 10.0), overview_figsize=(9.0, 4.5), map_lat_min=None, map_lat_max=None, map_lon_min=None, map_lon_max=None, var_cmaps=mappingproxy({'ctd_temperature_1': 'RdYlBu_r', 'ctd_temperature_2': 'RdYlBu_r', 'ctd_temperature': 'RdYlBu_r', 'ctd_salinity_1': 'YlGnBu_r', 'ctd_salinity_2': 'YlGnBu_r', 'ctd_salinity': 'YlGnBu_r', 'ctd_oxygen_1': 'RdYlGn', 'ctd_oxygen_2': 'RdYlGn', 'ctd_oxygen': 'RdYlGn', 'oxygen_saturation': 'RdYlGn', 'ctd_fluor': 'YlGn', 'ctd_turbidity': 'YlOrBr', 'conservative_temperature': 'RdYlBu_r', 'absolute_salinity': 'YlGnBu_r', 'sigma0': 'Purples', 'AOU': 'RdBu_r'}))) Figure | None[source]

Return a Figure showing all section tracks coloured by section.

ctdcast.plotters.plots.draw_aux_profiles_fig(ds: Dataset, *, cfg: ReportConfig = ReportConfig(gebco_path=None, clean_spines=True, profile_figsize=(7.0, 10.0), overview_figsize=(9.0, 4.5), map_lat_min=None, map_lat_max=None, map_lon_min=None, map_lon_max=None, var_cmaps=mappingproxy({'ctd_temperature_1': 'RdYlBu_r', 'ctd_temperature_2': 'RdYlBu_r', 'ctd_temperature': 'RdYlBu_r', 'ctd_salinity_1': 'YlGnBu_r', 'ctd_salinity_2': 'YlGnBu_r', 'ctd_salinity': 'YlGnBu_r', 'ctd_oxygen_1': 'RdYlGn', 'ctd_oxygen_2': 'RdYlGn', 'ctd_oxygen': 'RdYlGn', 'oxygen_saturation': 'RdYlGn', 'ctd_fluor': 'YlGn', 'ctd_turbidity': 'YlOrBr', 'conservative_temperature': 'RdYlBu_r', 'absolute_salinity': 'YlGnBu_r', 'sigma0': 'Purples', 'AOU': 'RdBu_r'}))) Figure | None[source]

Return a O₂ sat, fluorescence, turbidity profiles Figure (downcast + pale upcast).

ctdcast.plotters.plots.draw_cruise_map_fig(all_meta: list[dict], *, target_h: float = 4.0, cfg: ReportConfig = ReportConfig(gebco_path=None, clean_spines=True, profile_figsize=(7.0, 10.0), overview_figsize=(9.0, 4.5), map_lat_min=None, map_lat_max=None, map_lon_min=None, map_lon_max=None, var_cmaps=mappingproxy({'ctd_temperature_1': 'RdYlBu_r', 'ctd_temperature_2': 'RdYlBu_r', 'ctd_temperature': 'RdYlBu_r', 'ctd_salinity_1': 'YlGnBu_r', 'ctd_salinity_2': 'YlGnBu_r', 'ctd_salinity': 'YlGnBu_r', 'ctd_oxygen_1': 'RdYlGn', 'ctd_oxygen_2': 'RdYlGn', 'ctd_oxygen': 'RdYlGn', 'oxygen_saturation': 'RdYlGn', 'ctd_fluor': 'YlGn', 'ctd_turbidity': 'YlOrBr', 'conservative_temperature': 'RdYlBu_r', 'absolute_salinity': 'YlGnBu_r', 'sigma0': 'Purples', 'AOU': 'RdBu_r'}))) Figure | None[source]

Return a Figure of all cast positions (no single-cast highlight).

ctdcast.plotters.plots.draw_ct_sa_sigma0_fig(ds: Dataset, *, cfg: ReportConfig = ReportConfig(gebco_path=None, clean_spines=True, profile_figsize=(7.0, 10.0), overview_figsize=(9.0, 4.5), map_lat_min=None, map_lat_max=None, map_lon_min=None, map_lon_max=None, var_cmaps=mappingproxy({'ctd_temperature_1': 'RdYlBu_r', 'ctd_temperature_2': 'RdYlBu_r', 'ctd_temperature': 'RdYlBu_r', 'ctd_salinity_1': 'YlGnBu_r', 'ctd_salinity_2': 'YlGnBu_r', 'ctd_salinity': 'YlGnBu_r', 'ctd_oxygen_1': 'RdYlGn', 'ctd_oxygen_2': 'RdYlGn', 'ctd_oxygen': 'RdYlGn', 'oxygen_saturation': 'RdYlGn', 'ctd_fluor': 'YlGn', 'ctd_turbidity': 'YlOrBr', 'conservative_temperature': 'RdYlBu_r', 'absolute_salinity': 'YlGnBu_r', 'sigma0': 'Purples', 'AOU': 'RdBu_r'}))) Figure | None[source]

Return a CT, SA, σ₀ profiles side-by-side Figure (downcast + grey upcast).

ctdcast.plotters.plots.draw_ladcp_bottomtrack_fig(ladcp_path: Path | None, *, cfg: ReportConfig = ReportConfig(gebco_path=None, clean_spines=True, profile_figsize=(7.0, 10.0), overview_figsize=(9.0, 4.5), map_lat_min=None, map_lat_max=None, map_lon_min=None, map_lon_max=None, var_cmaps=mappingproxy({'ctd_temperature_1': 'RdYlBu_r', 'ctd_temperature_2': 'RdYlBu_r', 'ctd_temperature': 'RdYlBu_r', 'ctd_salinity_1': 'YlGnBu_r', 'ctd_salinity_2': 'YlGnBu_r', 'ctd_salinity': 'YlGnBu_r', 'ctd_oxygen_1': 'RdYlGn', 'ctd_oxygen_2': 'RdYlGn', 'ctd_oxygen': 'RdYlGn', 'oxygen_saturation': 'RdYlGn', 'ctd_fluor': 'YlGn', 'ctd_turbidity': 'YlOrBr', 'conservative_temperature': 'RdYlBu_r', 'absolute_salinity': 'YlGnBu_r', 'sigma0': 'Purples', 'AOU': 'RdBu_r'}))) Figure | None[source]

Return a LADCP bottom-track U and V vs depth Figure.

ctdcast.plotters.plots.draw_overview_panel_fig(ds_prof: Dataset, var: str, label: str, bathy_depths: ndarray | None = None, style: str = 'pcolormesh', vmin: float | None = None, vmax: float | None = None, cast_groups: dict[str, list[int]] | None = None, *, cfg: ReportConfig = ReportConfig(gebco_path=None, clean_spines=True, profile_figsize=(7.0, 10.0), overview_figsize=(9.0, 4.5), map_lat_min=None, map_lat_max=None, map_lon_min=None, map_lon_max=None, var_cmaps=mappingproxy({'ctd_temperature_1': 'RdYlBu_r', 'ctd_temperature_2': 'RdYlBu_r', 'ctd_temperature': 'RdYlBu_r', 'ctd_salinity_1': 'YlGnBu_r', 'ctd_salinity_2': 'YlGnBu_r', 'ctd_salinity': 'YlGnBu_r', 'ctd_oxygen_1': 'RdYlGn', 'ctd_oxygen_2': 'RdYlGn', 'ctd_oxygen': 'RdYlGn', 'oxygen_saturation': 'RdYlGn', 'ctd_fluor': 'YlGn', 'ctd_turbidity': 'YlOrBr', 'conservative_temperature': 'RdYlBu_r', 'absolute_salinity': 'YlGnBu_r', 'sigma0': 'Purples', 'AOU': 'RdBu_r'}))) Figure | None[source]

Return a Figure of var vs pressure × cast number (cruise overview panel).

ctdcast.plotters.plots.draw_pressure_time_fig(ds: Dataset, *, cfg: ReportConfig = ReportConfig(gebco_path=None, clean_spines=True, profile_figsize=(7.0, 10.0), overview_figsize=(9.0, 4.5), map_lat_min=None, map_lat_max=None, map_lon_min=None, map_lon_max=None, var_cmaps=mappingproxy({'ctd_temperature_1': 'RdYlBu_r', 'ctd_temperature_2': 'RdYlBu_r', 'ctd_temperature': 'RdYlBu_r', 'ctd_salinity_1': 'YlGnBu_r', 'ctd_salinity_2': 'YlGnBu_r', 'ctd_salinity': 'YlGnBu_r', 'ctd_oxygen_1': 'RdYlGn', 'ctd_oxygen_2': 'RdYlGn', 'ctd_oxygen': 'RdYlGn', 'oxygen_saturation': 'RdYlGn', 'ctd_fluor': 'YlGn', 'ctd_turbidity': 'YlOrBr', 'conservative_temperature': 'RdYlBu_r', 'absolute_salinity': 'YlGnBu_r', 'sigma0': 'Purples', 'AOU': 'RdBu_r'}))) Figure | None[source]

Return a pressure vs elapsed time Figure (cast trajectory + bottle stops).

ctdcast.plotters.plots.draw_section_fig(ds_prof: Dataset, var: str, label: str, x_vals: ndarray, x_label: str, title: str = '', style: str = 'pcolormesh', bathy_depths: ndarray | None = None, bathy_x: ndarray | None = None, cast_labels: list | None = None, vmin: float | None = None, vmax: float | None = None, figsize: tuple[float, float] | None = None, *, cfg: ReportConfig = ReportConfig(gebco_path=None, clean_spines=True, profile_figsize=(7.0, 10.0), overview_figsize=(9.0, 4.5), map_lat_min=None, map_lat_max=None, map_lon_min=None, map_lon_max=None, var_cmaps=mappingproxy({'ctd_temperature_1': 'RdYlBu_r', 'ctd_temperature_2': 'RdYlBu_r', 'ctd_temperature': 'RdYlBu_r', 'ctd_salinity_1': 'YlGnBu_r', 'ctd_salinity_2': 'YlGnBu_r', 'ctd_salinity': 'YlGnBu_r', 'ctd_oxygen_1': 'RdYlGn', 'ctd_oxygen_2': 'RdYlGn', 'ctd_oxygen': 'RdYlGn', 'oxygen_saturation': 'RdYlGn', 'ctd_fluor': 'YlGn', 'ctd_turbidity': 'YlOrBr', 'conservative_temperature': 'RdYlBu_r', 'absolute_salinity': 'YlGnBu_r', 'sigma0': 'Purples', 'AOU': 'RdBu_r'}))) Figure | None[source]

Return a Figure of var vs pressure × x_vals.

ctdcast.plotters.plots.draw_section_map_fig(lats: list[float], lons: list[float], cast_nums: list[int], title: str = '', min_margin: float = 0.03, min_margin_lon: float | None = None, *, fig_w: float = 4.5, cfg: ReportConfig = ReportConfig(gebco_path=None, clean_spines=True, profile_figsize=(7.0, 10.0), overview_figsize=(9.0, 4.5), map_lat_min=None, map_lat_max=None, map_lon_min=None, map_lon_max=None, var_cmaps=mappingproxy({'ctd_temperature_1': 'RdYlBu_r', 'ctd_temperature_2': 'RdYlBu_r', 'ctd_temperature': 'RdYlBu_r', 'ctd_salinity_1': 'YlGnBu_r', 'ctd_salinity_2': 'YlGnBu_r', 'ctd_salinity': 'YlGnBu_r', 'ctd_oxygen_1': 'RdYlGn', 'ctd_oxygen_2': 'RdYlGn', 'ctd_oxygen': 'RdYlGn', 'oxygen_saturation': 'RdYlGn', 'ctd_fluor': 'YlGn', 'ctd_turbidity': 'YlOrBr', 'conservative_temperature': 'RdYlBu_r', 'absolute_salinity': 'YlGnBu_r', 'sigma0': 'Purples', 'AOU': 'RdBu_r'}))) Figure | None[source]

Return a GEBCO map Figure with the section track.

ctdcast.plotters.plots.draw_section_ts_histogram_fig(ds_prof: Dataset, *, cfg: ReportConfig = ReportConfig(gebco_path=None, clean_spines=True, profile_figsize=(7.0, 10.0), overview_figsize=(9.0, 4.5), map_lat_min=None, map_lat_max=None, map_lon_min=None, map_lon_max=None, var_cmaps=mappingproxy({'ctd_temperature_1': 'RdYlBu_r', 'ctd_temperature_2': 'RdYlBu_r', 'ctd_temperature': 'RdYlBu_r', 'ctd_salinity_1': 'YlGnBu_r', 'ctd_salinity_2': 'YlGnBu_r', 'ctd_salinity': 'YlGnBu_r', 'ctd_oxygen_1': 'RdYlGn', 'ctd_oxygen_2': 'RdYlGn', 'ctd_oxygen': 'RdYlGn', 'oxygen_saturation': 'RdYlGn', 'ctd_fluor': 'YlGn', 'ctd_turbidity': 'YlOrBr', 'conservative_temperature': 'RdYlBu_r', 'absolute_salinity': 'YlGnBu_r', 'sigma0': 'Purples', 'AOU': 'RdBu_r'}))) Figure | None[source]

Return a CT–SA 2-D count histogram Figure (log₁₀ colour) for section profiles.

ctdcast.plotters.plots.draw_section_ts_o2_fig(ds_prof: Dataset, *, cfg: ReportConfig = ReportConfig(gebco_path=None, clean_spines=True, profile_figsize=(7.0, 10.0), overview_figsize=(9.0, 4.5), map_lat_min=None, map_lat_max=None, map_lon_min=None, map_lon_max=None, var_cmaps=mappingproxy({'ctd_temperature_1': 'RdYlBu_r', 'ctd_temperature_2': 'RdYlBu_r', 'ctd_temperature': 'RdYlBu_r', 'ctd_salinity_1': 'YlGnBu_r', 'ctd_salinity_2': 'YlGnBu_r', 'ctd_salinity': 'YlGnBu_r', 'ctd_oxygen_1': 'RdYlGn', 'ctd_oxygen_2': 'RdYlGn', 'ctd_oxygen': 'RdYlGn', 'oxygen_saturation': 'RdYlGn', 'ctd_fluor': 'YlGn', 'ctd_turbidity': 'YlOrBr', 'conservative_temperature': 'RdYlBu_r', 'absolute_salinity': 'YlGnBu_r', 'sigma0': 'Purples', 'AOU': 'RdBu_r'}))) Figure | None[source]

Return a CT–SA histogram Figure coloured by median O₂ saturation per bin.

ctdcast.plotters.plots.draw_section_ts_profiles_fig(ds_prof: Dataset, x_vals: ndarray, *, cfg: ReportConfig = ReportConfig(gebco_path=None, clean_spines=True, profile_figsize=(7.0, 10.0), overview_figsize=(9.0, 4.5), map_lat_min=None, map_lat_max=None, map_lon_min=None, map_lon_max=None, var_cmaps=mappingproxy({'ctd_temperature_1': 'RdYlBu_r', 'ctd_temperature_2': 'RdYlBu_r', 'ctd_temperature': 'RdYlBu_r', 'ctd_salinity_1': 'YlGnBu_r', 'ctd_salinity_2': 'YlGnBu_r', 'ctd_salinity': 'YlGnBu_r', 'ctd_oxygen_1': 'RdYlGn', 'ctd_oxygen_2': 'RdYlGn', 'ctd_oxygen': 'RdYlGn', 'oxygen_saturation': 'RdYlGn', 'ctd_fluor': 'YlGn', 'ctd_turbidity': 'YlOrBr', 'conservative_temperature': 'RdYlBu_r', 'absolute_salinity': 'YlGnBu_r', 'sigma0': 'Purples', 'AOU': 'RdBu_r'}))) Figure | None[source]

Return a Figure of per-cast CT–SA profiles coloured by along-track distance.

ctdcast.plotters.plots.draw_sensor_diff_fig(ds: Dataset, *, cfg: ReportConfig = ReportConfig(gebco_path=None, clean_spines=True, profile_figsize=(7.0, 10.0), overview_figsize=(9.0, 4.5), map_lat_min=None, map_lat_max=None, map_lon_min=None, map_lon_max=None, var_cmaps=mappingproxy({'ctd_temperature_1': 'RdYlBu_r', 'ctd_temperature_2': 'RdYlBu_r', 'ctd_temperature': 'RdYlBu_r', 'ctd_salinity_1': 'YlGnBu_r', 'ctd_salinity_2': 'YlGnBu_r', 'ctd_salinity': 'YlGnBu_r', 'ctd_oxygen_1': 'RdYlGn', 'ctd_oxygen_2': 'RdYlGn', 'ctd_oxygen': 'RdYlGn', 'oxygen_saturation': 'RdYlGn', 'ctd_fluor': 'YlGn', 'ctd_turbidity': 'YlOrBr', 'conservative_temperature': 'RdYlBu_r', 'absolute_salinity': 'YlGnBu_r', 'sigma0': 'Purples', 'AOU': 'RdBu_r'}))) Figure | None[source]

Return a primary minus secondary sensor difference profiles Figure.

ctdcast.plotters.plots.draw_stability_fig(ds: Dataset, *, cfg: ReportConfig = ReportConfig(gebco_path=None, clean_spines=True, profile_figsize=(7.0, 10.0), overview_figsize=(9.0, 4.5), map_lat_min=None, map_lat_max=None, map_lon_min=None, map_lon_max=None, var_cmaps=mappingproxy({'ctd_temperature_1': 'RdYlBu_r', 'ctd_temperature_2': 'RdYlBu_r', 'ctd_temperature': 'RdYlBu_r', 'ctd_salinity_1': 'YlGnBu_r', 'ctd_salinity_2': 'YlGnBu_r', 'ctd_salinity': 'YlGnBu_r', 'ctd_oxygen_1': 'RdYlGn', 'ctd_oxygen_2': 'RdYlGn', 'ctd_oxygen': 'RdYlGn', 'oxygen_saturation': 'RdYlGn', 'ctd_fluor': 'YlGn', 'ctd_turbidity': 'YlOrBr', 'conservative_temperature': 'RdYlBu_r', 'absolute_salinity': 'YlGnBu_r', 'sigma0': 'Purples', 'AOU': 'RdBu_r'}))) Figure | None[source]

Return a N² and Turner angle (2-panel) Figure.

ctdcast.plotters.plots.draw_station_map_fig(lat: float, lon: float, all_meta: list[dict], target_h: float = 4.5, *, cfg: ReportConfig = ReportConfig(gebco_path=None, clean_spines=True, profile_figsize=(7.0, 10.0), overview_figsize=(9.0, 4.5), map_lat_min=None, map_lat_max=None, map_lon_min=None, map_lon_max=None, var_cmaps=mappingproxy({'ctd_temperature_1': 'RdYlBu_r', 'ctd_temperature_2': 'RdYlBu_r', 'ctd_temperature': 'RdYlBu_r', 'ctd_salinity_1': 'YlGnBu_r', 'ctd_salinity_2': 'YlGnBu_r', 'ctd_salinity': 'YlGnBu_r', 'ctd_oxygen_1': 'RdYlGn', 'ctd_oxygen_2': 'RdYlGn', 'ctd_oxygen': 'RdYlGn', 'oxygen_saturation': 'RdYlGn', 'ctd_fluor': 'YlGn', 'ctd_turbidity': 'YlOrBr', 'conservative_temperature': 'RdYlBu_r', 'absolute_salinity': 'YlGnBu_r', 'sigma0': 'Purples', 'AOU': 'RdBu_r'}))) Figure | None[source]

Return a GEBCO map Figure with all casts and this cast highlighted.

ctdcast.plotters.plots.draw_timeseries_fig(ds_prof: Dataset, var: str, label: str, style: str = 'pcolormesh', vmin: float | None = None, vmax: float | None = None, figw: float | None = None, *, cfg: ReportConfig = ReportConfig(gebco_path=None, clean_spines=True, profile_figsize=(7.0, 10.0), overview_figsize=(9.0, 4.5), map_lat_min=None, map_lat_max=None, map_lon_min=None, map_lon_max=None, var_cmaps=mappingproxy({'ctd_temperature_1': 'RdYlBu_r', 'ctd_temperature_2': 'RdYlBu_r', 'ctd_temperature': 'RdYlBu_r', 'ctd_salinity_1': 'YlGnBu_r', 'ctd_salinity_2': 'YlGnBu_r', 'ctd_salinity': 'YlGnBu_r', 'ctd_oxygen_1': 'RdYlGn', 'ctd_oxygen_2': 'RdYlGn', 'ctd_oxygen': 'RdYlGn', 'oxygen_saturation': 'RdYlGn', 'ctd_fluor': 'YlGn', 'ctd_turbidity': 'YlOrBr', 'conservative_temperature': 'RdYlBu_r', 'absolute_salinity': 'YlGnBu_r', 'sigma0': 'Purples', 'AOU': 'RdBu_r'}))) Figure | None[source]

Return a Figure of var vs cast time × pressure, both down and upcast.

ctdcast.plotters.plots.draw_ts_density_fig(ds: Dataset, ladcp_path: Path | None = None, *, cfg: ReportConfig = ReportConfig(gebco_path=None, clean_spines=True, profile_figsize=(7.0, 10.0), overview_figsize=(9.0, 4.5), map_lat_min=None, map_lat_max=None, map_lon_min=None, map_lon_max=None, var_cmaps=mappingproxy({'ctd_temperature_1': 'RdYlBu_r', 'ctd_temperature_2': 'RdYlBu_r', 'ctd_temperature': 'RdYlBu_r', 'ctd_salinity_1': 'YlGnBu_r', 'ctd_salinity_2': 'YlGnBu_r', 'ctd_salinity': 'YlGnBu_r', 'ctd_oxygen_1': 'RdYlGn', 'ctd_oxygen_2': 'RdYlGn', 'ctd_oxygen': 'RdYlGn', 'oxygen_saturation': 'RdYlGn', 'ctd_fluor': 'YlGn', 'ctd_turbidity': 'YlOrBr', 'conservative_temperature': 'RdYlBu_r', 'absolute_salinity': 'YlGnBu_r', 'sigma0': 'Purples', 'AOU': 'RdBu_r'}))) Figure | None[source]

Return a CT/SA/σ₀ profiles Figure, optionally alongside LADCP U/V.

ctdcast.plotters.plots.draw_ts_diagram_fig(ds: Dataset, *, cfg: ReportConfig = ReportConfig(gebco_path=None, clean_spines=True, profile_figsize=(7.0, 10.0), overview_figsize=(9.0, 4.5), map_lat_min=None, map_lat_max=None, map_lon_min=None, map_lon_max=None, var_cmaps=mappingproxy({'ctd_temperature_1': 'RdYlBu_r', 'ctd_temperature_2': 'RdYlBu_r', 'ctd_temperature': 'RdYlBu_r', 'ctd_salinity_1': 'YlGnBu_r', 'ctd_salinity_2': 'YlGnBu_r', 'ctd_salinity': 'YlGnBu_r', 'ctd_oxygen_1': 'RdYlGn', 'ctd_oxygen_2': 'RdYlGn', 'ctd_oxygen': 'RdYlGn', 'oxygen_saturation': 'RdYlGn', 'ctd_fluor': 'YlGn', 'ctd_turbidity': 'YlOrBr', 'conservative_temperature': 'RdYlBu_r', 'absolute_salinity': 'YlGnBu_r', 'sigma0': 'Purples', 'AOU': 'RdBu_r'}))) Figure | None[source]

Return a T-S diagram Figure colored by O₂ saturation.

ctdcast.plotters.plots.draw_ts_diagram_timeseries_fig(ds_ts: Dataset, *, cfg: ReportConfig = ReportConfig(gebco_path=None, clean_spines=True, profile_figsize=(7.0, 10.0), overview_figsize=(9.0, 4.5), map_lat_min=None, map_lat_max=None, map_lon_min=None, map_lon_max=None, var_cmaps=mappingproxy({'ctd_temperature_1': 'RdYlBu_r', 'ctd_temperature_2': 'RdYlBu_r', 'ctd_temperature': 'RdYlBu_r', 'ctd_salinity_1': 'YlGnBu_r', 'ctd_salinity_2': 'YlGnBu_r', 'ctd_salinity': 'YlGnBu_r', 'ctd_oxygen_1': 'RdYlGn', 'ctd_oxygen_2': 'RdYlGn', 'ctd_oxygen': 'RdYlGn', 'oxygen_saturation': 'RdYlGn', 'ctd_fluor': 'YlGn', 'ctd_turbidity': 'YlOrBr', 'conservative_temperature': 'RdYlBu_r', 'absolute_salinity': 'YlGnBu_r', 'sigma0': 'Purples', 'AOU': 'RdBu_r'}))) Figure | None[source]

Return a CT–SA diagram Figure for all timeseries profiles, coloured by time.

ctdcast.plotters.plots.draw_ts_updown_fig(ds: Dataset, *, cfg: ReportConfig = ReportConfig(gebco_path=None, clean_spines=True, profile_figsize=(7.0, 10.0), overview_figsize=(9.0, 4.5), map_lat_min=None, map_lat_max=None, map_lon_min=None, map_lon_max=None, var_cmaps=mappingproxy({'ctd_temperature_1': 'RdYlBu_r', 'ctd_temperature_2': 'RdYlBu_r', 'ctd_temperature': 'RdYlBu_r', 'ctd_salinity_1': 'YlGnBu_r', 'ctd_salinity_2': 'YlGnBu_r', 'ctd_salinity': 'YlGnBu_r', 'ctd_oxygen_1': 'RdYlGn', 'ctd_oxygen_2': 'RdYlGn', 'ctd_oxygen': 'RdYlGn', 'oxygen_saturation': 'RdYlGn', 'ctd_fluor': 'YlGn', 'ctd_turbidity': 'YlOrBr', 'conservative_temperature': 'RdYlBu_r', 'absolute_salinity': 'YlGnBu_r', 'sigma0': 'Purples', 'AOU': 'RdBu_r'}))) Figure | None[source]

Return a CT–SA scatter Figure: downcast in blue, upcast in red, σ₀ contours.

ctdcast.plotters.plots.draw_updown_diff_fig(ds: Dataset, *, cfg: ReportConfig = ReportConfig(gebco_path=None, clean_spines=True, profile_figsize=(7.0, 10.0), overview_figsize=(9.0, 4.5), map_lat_min=None, map_lat_max=None, map_lon_min=None, map_lon_max=None, var_cmaps=mappingproxy({'ctd_temperature_1': 'RdYlBu_r', 'ctd_temperature_2': 'RdYlBu_r', 'ctd_temperature': 'RdYlBu_r', 'ctd_salinity_1': 'YlGnBu_r', 'ctd_salinity_2': 'YlGnBu_r', 'ctd_salinity': 'YlGnBu_r', 'ctd_oxygen_1': 'RdYlGn', 'ctd_oxygen_2': 'RdYlGn', 'ctd_oxygen': 'RdYlGn', 'oxygen_saturation': 'RdYlGn', 'ctd_fluor': 'YlGn', 'ctd_turbidity': 'YlOrBr', 'conservative_temperature': 'RdYlBu_r', 'absolute_salinity': 'YlGnBu_r', 'sigma0': 'Purples', 'AOU': 'RdBu_r'}))) Figure | None[source]

Return a downcast minus upcast profiles Figure: ΔCT, ΔSA, Δσ₀.

ctdcast.plotters.plots.map_panel(ax: Any, cax: Any, xl0: float, xl1: float, yl0: float, yl1: float, *, cfg: ReportConfig) None[source]

Draw the GEBCO depth field into ax and its ‘Depth (m)’ colorbar into cax.

cax is a pre-placed colorbar axes from _map_layout(); when there is no GEBCO to draw it is hidden so no empty box remains.

ctdcast.plotters.plots.section_figsize_and_slot(p_max_dbar: float, dist_km: float) tuple[tuple[float, float], str][source]

Return figure size and CSS slot class for a section pcolormesh plot.

The figure width is always a canonical slot width (one of SLOTS), so the rendered PNG fills its slot at the same oversample as every other figure and the browser never rescales it — otherwise a section rendered at a between-slots width is squeezed into the nearest slot box, shrinking its baked-in fonts.

Aspect is calibrated (_SECTION_STRETCH) so KTout (416 dbar, 94 km) is short at full width. The widest slot whose resulting height stays within _MAX_SECTION_H is chosen; height then follows the data aspect (floored at _MIN_SECTION_H). A section too deep for even the narrowest slot keeps that slot’s width and accepts the height cap.

Parameters:
  • p_max_dbar – Maximum pressure in the section (dbar).

  • dist_km – Total along-track distance of the section (km).

Returns:

  • tuple of ((fig_w, fig_h), css_slot) where fig_w equals the slot’s

  • canonical inch width and css_slot is one of "slot-full",

  • "slot-twothirds", "slot-half", or "slot-third".

Layer-1 primitives

ax-taking primitives that draw into a caller-supplied axes and create no Figure, so a panel shared by more than one page type has a single implementation.

Layer-1 ax-taking primitives that draw into a provided axes and create no Figure.

ctdcast.plotters.primitives.mesh_field(ax: Any, fig: Any, x: ndarray, y: ndarray, data2d: ndarray, *, cmap: Any, norm: Any, cmap_name: str, bounds: ndarray, style: str, cbar_label: str = '') Any[source]

Draw a pcolormesh/contourf field with a matched discrete colorbar into ax; return the colorbar.

The colorbar has a fixed inch width (not a fraction of the host axes), so its thickness and the resulting right margin are identical on every field figure regardless of slot width, and its labels are ~6 round values (nice_colorbar_ticks()) rather than one per discretisation boundary, with cbar_label written as a title on top (unit_colorbar()) so it does not widen the figure.

make_axes_locatable (via unit_colorbar(reserve=True)) is used only because this axes is free-aspect. It attaches the colorbar to the divider of the axes’ box at layout time; if something resizes that box afterwards — set_aspect("equal", adjustable="box"), or a hand-placed map layout — the cax tracks the pre-resize box and ends up the wrong size. The rule (see .claude/notes/2026-08-14-consistent-cruise-maps.md): make_axes_locatable for free-aspect axes; hand-reserved inches whenever the aspect is locked or the axes are hand-placed. Do not add set_aspect("equal") to a figure that colorbars through here without switching to the reserved-inches path.

ctdcast.plotters.primitives.nice_colorbar_ticks(vmin: float, vmax: float, *, max_ticks: int = 6) ndarray[source]

Return at most max_ticks nicely-rounded tick positions in [vmin, vmax].

Decoupled from the colorbar’s colour discretisation: a 20-level BoundaryNorm bar can still show ~6 round labels (e.g. 34.8, 34.9, … 35.2) instead of one label per boundary. Uses MaxNLocator with round step multiples so labels land on clean values.

Parameters:
  • vmin (float) – Data range of the colorbar.

  • vmax (float) – Data range of the colorbar.

  • max_ticks (int) – Maximum number of ticks (approximate; the locator may return a few fewer).

Returns:

Tick positions, clipped to [vmin, vmax].

Return type:

numpy.ndarray

ctdcast.plotters.primitives.sigma0_isopycnals(ax: Any, x: ndarray, y: ndarray, data2d: ndarray) None[source]

Overlay the 27.7 and 27.8 σ₀ isopycnal contours (labelled) on ax, swallowing contour failures.

ctdcast.plotters.primitives.unit_colorbar(target: Any, mappable: Any, *, unit: str = '', ticks: ndarray | None = None, extend: str = 'neither', reserve: bool = False, title_loc: str = 'center') Any[source]

Draw the report-standard colorbar with the unit as a title on top.

One entry point, two placement strategies so the appearance (bar width, gap, tick choice, unit-on-top) is set in a single place regardless of how the axes was laid out.

Parameters:
  • target (matplotlib Axes) – When reserve is False, a colorbar axes already reserved by a hand layout. When reserve is True, the host plot axes, into which a fixed-inch cax is appended — allowed only for free-aspect axes (see mesh_field()).

  • mappable (matplotlib ScalarMappable) – The artist to map (pcolormesh, contourf set, …).

  • unit (str) – Text placed above the bar (cax.set_title) rather than as a rotated side label — reads cleanly and, unlike a side label, does not widen the figure. A unit ("m s⁻¹") or a full label ("CT (°C)"); empty renders no title.

  • ticks (numpy.ndarray, optional) – Explicit tick positions (e.g. from nice_colorbar_ticks()).

  • extend (str) – "neither"/"both"/"min"/"max" — pointed ends for out-of-range.

  • reserve (bool) – Append a fixed-inch cax to target instead of treating it as the cax.

  • title_loc (str) – Horizontal anchor for the on-top title — "center" (default), "left" or "right". "left" anchors the title at the thin bar’s left edge so it extends right into the margin, clear of a figure’s top-left annotations (e.g. the cast-marker strip on field figures); centering it over the thin bar would instead overhang the plot.

Return type:

matplotlib.colorbar.Colorbar

Base64 encoders

_make_*_b64 wrappers render a figure builder’s Figure to an embedded base64 PNG, returning None on any exception so a missing figure never prevents a page from being written.

Base64 PNG encoders — thin wrappers that render a Figure for a page.

Each _make_*_b64 builds a figure via a draw_*_fig in ctdcast.plotters.plots and encodes it with render_b64(). Two use a custom wrapper instead of render_b64(): _make_all_sections_map_b64 still delegates drawing to its draw_*_fig but needs a post-tight_layout adjustment, and _make_ladcp_section_b64 does its own plotting because it returns a list of RenderedPanel rather than a single figure.

class ctdcast.reports._plots.RenderedPanel(b64: str | None, title: str = '', short: str = '', figsize: tuple[float, float] | None = None, slot: str | None = None)[source]

A rendered figure plus the layout metadata the HTML template needs.

b64: str | None

Base64-encoded PNG string, or None when the figure could not be rendered.

figsize: tuple[float, float] | None = None

Figure dimensions (width, height) in inches, or None when not recorded.

short: str = ''

Short label used in <figcaption> elements (e.g. "CT", "U").

slot: str | None = None

CSS slot class (e.g. "slot-full") matching the PNG aspect ratio, or None.

title: str = ''

Long descriptive title (used in alt attributes and headings).

Plotting parameters

Package-wide constants: plotting parameters, CNV aliases, variable metadata, CCHDO conventions.

Compile-time constants only — the per-run display settings (GEBCO path, clean_spines, figsizes, map bounds, colormap overrides) live in the frozen ctdcast.config.report_config.ReportConfig, built once and threaded down.

Section headers mark what kind of constant each block holds, because that determines who may change it and what breaks when they do:

Contract — changing it makes output wrong or non-conformant.

Requires a code review and a version bump.

Science — changing it gives a different but equally valid answer.

Per-cruise overrides go in display.variables: in the cruise config.yaml; use ctdcast.config.loader.load_display_config().

Derived — computed from another constant; must live here to avoid drift. Deferred — belongs in oceanvis once that package exists.

ctdcast.config.parameters.SECTION_BIOGEO_VARS: tuple[str, ...] = ('ctd_oxygen_1', 'ctd_fluor', 'ctd_turbidity')

Biogeochemical variables drawn on section, overview, and timeseries pages, in order.

ctdcast.config.parameters.SECTION_PHYSICS_VARS: tuple[str, ...] = ('conservative_temperature', 'absolute_salinity', 'sigma0')

Physics variables drawn on section, overview, and timeseries pages, in order. Use vlabel(var) for the axis/panel label and VARIABLES[var]["label"] for the short caption. Defined here so the three report modules share one source of truth and cannot silently diverge from each other or from VARIABLES.

ctdcast.config.parameters.resolve_sensor_var(ds: xr.Dataset, var: str) str[source]

Return the name to use for var in ds, applying the single/dual-sensor rule.

A variable may be stored plain (single sensor, e.g. ctd_oxygen) or suffixed (dual sensor, e.g. ctd_oxygen_1). This resolves whichever form ds actually holds: a suffixed var falls back to its plain form, and a plain var falls back to the _1 form. Returns var unchanged when neither is present, so the caller’s draw function then returns None for the missing variable.

Parameters:
  • ds – The dataset whose variables to resolve against.

  • var – The requested variable name (plain or _1/_2 suffixed).

Returns:

The name present in ds, or var unchanged if neither form is found.

Return type:

str

ctdcast.config.parameters.vlabel(var: str, prefix: str = '') str[source]

Return a matplotlib axis label for var using the VARIABLES registry.

Format is "Label (units)" when label_units is non-empty, or just "Label" when there are no units (e.g. dimensionless quantities). Units are always in the Unicode display form from label_units — never the ASCII-safe units string used for netCDF attributes.

Parameters:
  • var – VARIABLES key (e.g. "conservative_temperature").

  • prefix – Optional prefix prepended to the label component only, not the units. Use "Δ" to produce difference labels such as "ΔCT (°C)".

Returns:

Ready-to-use axis label string. Falls back to var itself when var is not in VARIABLES.

Return type:

str

ctdcast.config.parameters.vlabel_html(var: str, prefix: str = '') str[source]

Return vlabel() as HTML-ready text — mathtext subscripts as Unicode.

Use this wherever a variable label is written into HTML (a figure caption, a table cell): matplotlib needs the mathtext form, but HTML must show σ₀, not the literal $\sigma_0$. One helper so the label-form choice lives in one place.

ctdcast.config.parameters.vunit(var: str) str[source]

Return the Unicode display unit for var, or "" when dimensionless.

The unit half of vlabel(), for field colorbars that place the unit as a title on top of the bar rather than a "Label (units)" side label. Uses the label_units display form (never the ASCII units netCDF string). Works for both canonical and single-/dual-sensor-resolved names (both carry the same label_units in VARIABLES).


Section manifest

Each report page is described by a Profile of Section and Panel entries, resolved by resolve() into a numbered, rendered report. The model is package-neutral; each page’s concrete registry lives in its own generator module. _anchors maps the old hand-authored #s-* anchors onto the new section ids for one release.

Section-manifest model and resolver for report pages.

A report page is described by a Profile: an ordered sequence of Section (and Expand) entries, each naming Panel ids that render figures or tables. resolve() walks a profile once against a render context and returns a ResolvedReport whose sections are numbered over the rendered subset — absent sections leave no gap, and identity is the section id (a stable slug), not the integer.

This module holds only the model and the resolution algorithm — it is package-neutral (names no variable, page, or science) and is vendored byte-identical to the sister repos. Each page’s concrete registry (its Ctx builder, panels, sections, and profiles) lives in that page’s own module — grid’s in reports/_grid.py, and so on — never in a plural _manifests.py companion. The design rationale is in .claude/notes/2026-08-15-report-section-manifest-design.md.

class ctdcast.reports._manifest.Expand(over: Callable[[Any], Sequence[Any]], section: Callable[[Any], Section])[source]

A single manifest entry that becomes N sections at resolution time.

Parameters:
  • over (Callable) – Returns the sequence of items to expand over (e.g. the variables present on the page, in display order).

  • section (Callable) – Builds one Section per item yielded by over.

over: Callable[[Any], Sequence[Any]]
section: Callable[[Any], Section]
class ctdcast.reports._manifest.Panel(id: str, render: ~typing.Callable[[~typing.Any], str | None], kind: ~typing.Literal['figure', 'html', 'table'] = 'figure', slot: str | ~typing.Callable[[~typing.Any], str] | None = 'full', caption: str | None = None, applies_to: ~typing.Callable[[~typing.Any], bool] = <function _always>, unavailable_if: ~typing.Callable[[~typing.Any], str | None] = <function _unavailable_never>)[source]

One figure or table, addressable by id.

Parameters:
  • id (str) – Unique panel identifier, e.g. "temperature_field". Also the anchor a caption or cross-reference can point at.

  • render (Callable) – Adapter that returns the panel’s payload — a base64 PNG string for a "figure", or ready-to-emit markup for "html"/"table" — or None when the panel applies but no output could be produced (a plot raised, or the variable is absent), which the resolver turns into a .warn stub. Figure adapters wrap the existing _make_*_b64 functions unchanged.

  • kind ({"figure", "html", "table"}, optional) – Content discriminator. The template macro branches on it so that only "html"/"table" payloads are emitted |safe; a "figure" payload is always escaped into an image src.

  • slot (str or Callable or None, optional) – SLOTS key giving the panel’s display width, or a callable ctx -> slot that computes it (e.g. from a section’s aspect ratio). Belongs to the panel, not the section, so a panel is the same width wherever it is placed. The resolver calls it when callable. None marks a figure that is not rendered through the slot system: it is emitted as a bare .fig that fills the content column (no slot-* class, so no rendered-width contract).

  • caption (str, optional) – Caption text rendered beneath the panel.

  • applies_to (Callable, optional) – Predicate deciding whether the panel is attempted at all. False omits the panel silently (it is excluded, not merely unavailable).

  • unavailable_if (Callable, optional) – Precondition checked before render: given the context, return a reason string when the panel applies but cannot be produced (e.g. a required metadata field is missing), or None to proceed. A returned reason becomes a .warn stub with that reason and render is not called. This is the channel for defects knowable from context; a render that still returns None gets the generic stub reason, because that is the “should have worked and didn’t” case.

applies_to() bool

Return True for any context (the default applies_to predicate).

caption: str | None = None
id: str
kind: Literal['figure', 'html', 'table'] = 'figure'
render: Callable[[Any], str | None]
slot: str | Callable[[Any], str] | None = 'full'
unavailable_if() str | None

Return None for any context (the default unavailable_if predicate).

class ctdcast.reports._manifest.PanelGroup(over: Callable[[Any], Sequence[Any]], panel: Callable[[Any], Panel])[source]

A Section.panels entry that expands to N panels at resolution time.

Places a data-driven run of panels — one per item — under a single heading without inflating the section count. Use this (not Expand) when the run is over a numeric or unbounded set, e.g. one panel per isopycnal; reserve Expand’s section-level expansion for a closed editorial vocabulary. Numbering reflects editorial structure, not data cardinality.

Parameters:
  • over (Callable) – Returns the sequence of items to expand over, in display order.

  • panel (Callable) – Builds one Panel per item yielded by over.

over: Callable[[Any], Sequence[Any]]
panel: Callable[[Any], Panel]
ctdcast.reports._manifest.PanelKind

Panel content kinds. "figure" renders a base64 PNG (escaped as an image src); "html" and "table" render pre-built markup that the template macro emits with |safe. The discriminator keeps the autoescape=True boundary to one auditable branch — a figure payload is never |safe-d.

alias of Literal[‘figure’, ‘html’, ‘table’]

class ctdcast.reports._manifest.Profile(numbering: str = 'flat', entries: tuple[~ctdcast.reports._manifest.Section | ~ctdcast.reports._manifest.Expand, ...]=<factory>)[source]

An ordered page description plus a numbering policy.

Parameters:
  • numbering (str) – "flat" (content 1..N, appendices A..), "none" (no numbers), or "grouped" (reserved — not yet implemented).

  • entries (tuple of (Section or Expand)) – The page’s sections, in order.

entries: tuple[Section | Expand, ...]
numbering: str = 'flat'
class ctdcast.reports._manifest.ResolvedPanel(id: str, kind: Literal['figure', 'html', 'table'], slot: str | None, payload: str | None, caption: str | None, stub_reason: str | None = None)[source]

A panel after rendering: a payload (figure b64 or markup) or a .warn stub.

caption: str | None
id: str
property is_stub: bool

True when the panel applied but produced no output.

kind: Literal['figure', 'html', 'table']
payload: str | None
slot: str | None
stub_reason: str | None = None
class ctdcast.reports._manifest.ResolvedReport(sections: tuple[ResolvedSection, ...], not_applicable: tuple[str, ...])[source]

The full resolved page: numbered sections plus the not-applicable list.

anchor(section_id: str) str | None[source]

Return the display number for section_id, or None if not rendered.

not_applicable: tuple[str, ...]
sections: tuple[ResolvedSection, ...]
class ctdcast.reports._manifest.ResolvedSection(id: str, number: str, title: str, level: int, intro: str | None, panels: tuple[ResolvedPanel, ...], role: str, layout: str | None = None)[source]

A section after resolution: numbered heading plus resolved panels.

id: str
intro: str | None
layout: str | None = None
level: int
number: str
panels: tuple[ResolvedPanel, ...]
role: str
title: str
class ctdcast.reports._manifest.Section(id: str, title: str, panels: tuple[str | PanelGroup, ...], level: int = 2, intro: str | None = None, applies_to: Callable[[Any], bool] | None = None, role: str = 'content', layout: str | None = None)[source]

A numbered heading and the panels beneath it.

Parameters:
  • id (str) – Unique section identifier, e.g. "hydrography". Doubles as the anchor and the cross-reference key.

  • title (str) – Human-readable heading text (without a number — the number is computed).

  • panels (tuple of (str or PanelGroup)) – Panel ids, in display order. An entry may instead be a PanelGroup, which expands to a data-driven run of panels under this one heading.

  • level (int, optional) – Heading level (2 for <h2>).

  • intro (str, optional) – Introductory prose rendered under the heading, before the panels.

  • applies_to (Callable, optional) – Predicate deciding whether the section is included. When None (default), the section applies if any of its panels apply. A section dropped here is named in the report’s “not applicable” footer line.

  • role (str, optional) – "content" (numbered 1..N) or "appendix" (numbered A..), so appendix material such as a NetCDF-variable table does not pad the science numbering.

  • layout (str, optional) – Within-section panel arrangement. None (default) stacks panels vertically; "row" lays them out in a wrapping flex row, each panel in a cell sized by its slot (so two slot="half" figures sit side-by-side and a slot="full" one wraps to its own line).

applies_to: Callable[[Any], bool] | None = None
id: str
intro: str | None = None
layout: str | None = None
level: int = 2
panels: tuple[str | PanelGroup, ...]
role: str = 'content'
title: str
ctdcast.reports._manifest.resolve(profile: Profile, ctx: Any, panels: dict[str, Panel], *, drop_stub: bool = False) ResolvedReport[source]

Resolve profile against ctx into a numbered ResolvedReport.

One pass: expand Expand entries, drop sections whose applies_to is false (collecting them for the not-applicable footer), resolve each kept section’s panels (None render → .warn stub), then number the survivors — content sections 1..N and appendix sections A...

Parameters:
  • profile (Profile) – The page’s ordered section description and numbering policy.

  • ctx (Any) – The render context passed to every predicate and render callable (dataset, config, paths — whatever the page’s panels need).

  • panels (dict of str to Panel) – The panel registry; section panels entries are ids into this map.

  • drop_stub (bool, optional) – When True, a section that applies but whose panels are all stubs (applicable-but-entirely-unavailable) is dropped and the survivors renumber over it, so the page closes cleanly. Default False keeps the heading with its stub, which surfaces the failure rather than hiding it. A section dropped this way is not added to not_applicable — that list stays reserved for genuinely-not-applicable-to-this-deployment sections, so a plot failure never reads as “not applicable”. A section with any non-stub panel is never dropped.

Returns:

Numbered, rendered sections plus the titles of any omitted sections.

Return type:

ResolvedReport

Raises:
  • NotImplementedError – If profile.numbering == "grouped" (reserved for a later branch).

  • KeyError – If a section references a panel id absent from panels.

Legacy anchor aliases for report pages — a transition shim for the D3 fix.

Section ids became page anchors, so the old hand-authored #s-* anchors change (#s-profiles/#s-physics/#s-hydro all collapse onto #hydrography). The committed demo pages under docs/source/_static/demo/ deep-link the old ids, and external links (papers, issues, cruise reports) may too. For one release each page emits an empty <span id="old"> for every old anchor whose new section is rendered on it, so those links keep resolving.

Remove this module and the one template call that uses it once the old links are gone. The set was audited complete against the templates and the demo pages.

ctdcast.reports._anchors.LEGACY_ANCHORS: dict[str, str] = {'s-aux': 'biogeochemistry', 's-biogeo': 'biogeochemistry', 's-diagnostics': 'diagnostics', 's-hydro': 'hydrography', 's-ladcp': 'velocity', 's-map': 'map', 's-overview': 'overview', 's-physics': 'hydrography', 's-profiles': 'hydrography', 's-sensors': 'sensors', 's-stability': 'stability', 's-ts': 'ts_diagram'}

Old #s-* anchor -> new section id (which is the new anchor). Multiple old anchors mapping to one new id is the D3 defect being retired: Hydrography was s-profiles (cast), s-physics (section/timeseries) and s-hydro (index); Biogeochemistry was s-aux (cast) and s-biogeo (elsewhere).

ctdcast.reports._anchors.legacy_anchor_spans(rendered_ids: set[str]) str[source]

Return empty <span id="old"> aliases for rendered sections’ old anchors.

Parameters:

rendered_ids – The set of section ids actually rendered on the page. A legacy anchor is aliased only when its new section is present, so no alias dangles at a section the page does not have.

Returns:

Concatenated empty spans, one per matching legacy anchor (possibly empty).

Return type:

str


Page generators

Tier-2: generate a per-cast HTML report page.

ctdcast.reports._cast.CAST_DEFAULT: Profile = Profile(numbering='flat', entries=(Section(id='overview', title='Overview', panels=('ts_density', 'station_map', 'ts_updown'), level=2, intro='CT · SA · σ₀ profiles, station location, and T–S down-vs-up.', applies_to=None, role='content', layout=None), Section(id='hydrography', title='Hydrography', panels=('ct_sa_sigma0',), level=2, intro='CT · SA · σ₀ vs pressure downcast in colour, upcast in grey.', applies_to=<function _has_ts>, role='content', layout=None), Section(id='biogeochemistry', title='Biogeochemistry', panels=('aux',), level=2, intro='O₂ saturation · fluorescence · turbidity.', applies_to=<function _has_biogeo>, role='content', layout=None), Section(id='ts_diagram', title='T–S diagram', panels=('ts_diagram',), level=2, intro='Coloured by O₂ saturation downcast only.', applies_to=<function _has_ts>, role='content', layout=None), Section(id='stability', title='Stability', panels=('stability',), level=2, intro='N² and Turner angle downcast only.', applies_to=<function _has_ts>, role='content', layout=None), Section(id='velocity', title='Velocity (bottom track)', panels=('ladcp_bottomtrack',), level=2, intro=None, applies_to=<function <lambda>>, role='content', layout=None), Section(id='diagnostics', title='Diagnostics', panels=('pressure_time', 'sensor_diff', 'updown_diff'), level=2, intro=None, applies_to=None, role='content', layout=None), Section(id='sensors', title='Sensors', panels=('sensors_table',), level=2, intro=None, applies_to=<function <lambda>>, role='appendix', layout=None), Section(id='data_ranges', title='netCDF data ranges', panels=('data_ranges',), level=2, intro='Min · max · valid count for every variable in the cast file on disk.', applies_to=None, role='appendix', layout=None)))

same figures, same grouping — the manifest only renumbers (closing the D1 gaps), generates the jump-nav, and turns section ids into anchors (the D3 fix).

Type:

The cast page profile. Conservative port of the current page

ctdcast.reports._cast.CAST_PANELS: dict[str, Panel] = {'aux': Panel(id='aux', render=<function <lambda>>, kind='figure', slot='full', caption=None, applies_to=<function _always>, unavailable_if=<function _unavailable_never>), 'ct_sa_sigma0': Panel(id='ct_sa_sigma0', render=<function <lambda>>, kind='figure', slot='full', caption=None, applies_to=<function _always>, unavailable_if=<function _unavailable_never>), 'data_ranges': Panel(id='data_ranges', render=<function <lambda>>, kind='table', slot='full', caption=None, applies_to=<function _always>, unavailable_if=<function _unavailable_never>), 'ladcp_bottomtrack': Panel(id='ladcp_bottomtrack', render=<function <lambda>>, kind='figure', slot='third', caption=None, applies_to=<function _always>, unavailable_if=<function _unavailable_never>), 'pressure_time': Panel(id='pressure_time', render=<function <lambda>>, kind='figure', slot='third', caption='Cast trajectory: pressure vs elapsed time', applies_to=<function _always>, unavailable_if=<function _unavailable_never>), 'sensor_diff': Panel(id='sensor_diff', render=<function <lambda>>, kind='figure', slot='twothirds', caption='T₁−T₂, S₁−S₂: primary minus secondary sensor. Ideal: scatter around zero with ±0.01 spread.', applies_to=<function _has_dual_sensors>, unavailable_if=<function _unavailable_never>), 'sensors_table': Panel(id='sensors_table', render=<function <lambda>>, kind='table', slot='full', caption=None, applies_to=<function _always>, unavailable_if=<function _unavailable_never>), 'stability': Panel(id='stability', render=<function <lambda>>, kind='figure', slot='twothirds', caption=None, applies_to=<function _always>, unavailable_if=<function _unavailable_never>), 'station_map': Panel(id='station_map', render=<function <lambda>>, kind='figure', slot='two-fifths', caption=None, applies_to=<function _always>, unavailable_if=<function _unavailable_never>), 'ts_density': Panel(id='ts_density', render=<function <lambda>>, kind='figure', slot='three-fifths', caption=None, applies_to=<function _always>, unavailable_if=<function _unavailable_never>), 'ts_diagram': Panel(id='ts_diagram', render=<function <lambda>>, kind='figure', slot='third', caption='Contours: σ₀ (kg m⁻³) potential density referenced to surface', applies_to=<function _always>, unavailable_if=<function _unavailable_never>), 'ts_updown': Panel(id='ts_updown', render=<function <lambda>>, kind='figure', slot='two-fifths', caption=None, applies_to=<function _always>, unavailable_if=<function _unavailable_never>), 'updown_diff': Panel(id='updown_diff', render=<function <lambda>>, kind='figure', slot='full', caption='ΔCT, ΔSA, Δσ₀ downcast minus upcast on 1-dbar grid measures hysteresis from pump lag or sensor response time', applies_to=<function _always>, unavailable_if=<function _unavailable_never>)}

Cast panel registry — each wraps an existing _make_*_b64 adapter unchanged, reading only from PageCtx. Slots mirror the current cast.html layout.

ctdcast.reports._cast.CAST_SECTION_COLUMNS: dict[str, int] = {'overview': 1}

Presentational intra-section layout, kept out of the layout-neutral manifest model. Maps a section id to the panel index from which the trailing panels stack in a right-hand fig-col (rather than wrapping onto their own row). overview: 1 puts the CT·SA·σ₀ profiles on the left and stacks the station map above the T–S down-vs-up plot in the right column.

class ctdcast.reports._cast.PageCtx(ds: Any, cfg: ReportConfig, lat: float, lon: float, all_meta: list[dict[str, Any]], ladcp_path: Path | None, ladcp_configured: bool, ladcp_exists: bool, sensor_info: list[dict[str, Any]], nc_path: Path)[source]

Per-cast render context: the frozen inputs every cast panel/predicate reads.

Wraps the frozen ReportConfig (cfg) with the values derived once per cast, so a panel’s render/applies_to depends only on this object. Keeping it frozen and section-independent is what makes “derived context must not depend on section inclusion” enforceable rather than aspirational.

all_meta: list[dict[str, Any]]
cfg: ReportConfig
ds: Any
ladcp_configured: bool
ladcp_exists: bool
ladcp_path: Path | None
lat: float
lon: float
nc_path: Path
sensor_info: list[dict[str, Any]]
ctdcast.reports._cast.generate_station_page(nc_path: Path, out_dir: Path, all_meta: list[dict[str, Any]], prev_cast_str: str | None = None, next_cast_str: str | None = None, force: bool = False, ladcp_dir: Path | None = None, ladcp_pattern: str | None = None, cast_num_str: str | None = None, sal_range: tuple[float, float] | None = None, trim_soak: bool = False, cast_notes: list[str] | None = None, cruise_info: dict | None = None, drop_stub: bool = False, cfg: ReportConfig = ReportConfig(gebco_path=None, clean_spines=True, profile_figsize=(7.0, 10.0), overview_figsize=(9.0, 4.5), map_lat_min=None, map_lat_max=None, map_lon_min=None, map_lon_max=None, var_cmaps=mappingproxy({'ctd_temperature_1': 'RdYlBu_r', 'ctd_temperature_2': 'RdYlBu_r', 'ctd_temperature': 'RdYlBu_r', 'ctd_salinity_1': 'YlGnBu_r', 'ctd_salinity_2': 'YlGnBu_r', 'ctd_salinity': 'YlGnBu_r', 'ctd_oxygen_1': 'RdYlGn', 'ctd_oxygen_2': 'RdYlGn', 'ctd_oxygen': 'RdYlGn', 'oxygen_saturation': 'RdYlGn', 'ctd_fluor': 'YlGn', 'ctd_turbidity': 'YlOrBr', 'conservative_temperature': 'RdYlBu_r', 'absolute_salinity': 'YlGnBu_r', 'sigma0': 'Purples', 'AOU': 'RdBu_r'}))) Path | None[source]

Generate a per-cast HTML report page and write it to out_dir/casts/.

Parameters:
  • nc_path – Path to a single cast .nc file.

  • out_dir – Root output directory.

  • all_meta – List of dicts with keys lat, lon for all casts (used for map).

  • prev_cast_str – Full cast identifier string of the previous cast for nav links, e.g. "010" or "004b" (or None for no previous link).

  • next_cast_str – Full cast identifier string of the next cast for nav links (or None).

  • force – Overwrite existing file if True.

  • ladcp_dir – Directory containing processed LADCP .mat files named NNN.mat or NNNb.mat. If None or no matching file exists, LADCP panels are omitted.

  • ladcp_pattern – Optional filename glob for non-standard LADCP naming conventions, e.g. "msm_142_1_*.mat". The * is replaced with the zero-padded cast number. Falls back to glob-based discovery when omitted.

  • cast_num_str – Full cast identifier string, e.g. "011" or "004b". Derived from nc_path if not provided.

  • sal_range(sal_min, sal_max) — records with salinity_1 outside this range are excluded from all plots (but the NC file is not modified). The count of excluded records is shown in the page header.

  • trim_soak – If True, apply pre-soak detection via find_soak_end(). Finds the last record within 10 dbar of the surface before the cast maximum depth, crawls back up to 20 seconds to the shallowest point preceding the real descent, and trims everything up to that point. Applied before sal_range trimming. NC files are not modified.

  • cast_notes – Optional list of free-text notes for this cast (e.g. “SBE43 malfunction”). Rendered as warning banners near the top of the page.

Return type:

Path to the written HTML file, or None on failure.

ctdcast.reports._cast.resolve_cast(ctx: PageCtx, *, drop_stub: bool = False) ResolvedReport[source]

Resolve the cast profile against ctx into numbered, rendered sections.

drop_stub (from the --drop-stub CLI flag) drops an applicable section whose panels all failed to render, instead of keeping its heading with a stub.

Tier-2: generate a per-section HTML report page.

ctdcast.reports._section.SECTION_DEFAULT: Profile = Profile(numbering='flat', entries=(Section(id='map', title='Map', panels=('section_map',), level=2, intro='The section track and the CTD stations it comprises.', applies_to=None, role='content', layout=None), Section(id='hydrography', title='Hydrography', panels=(PanelGroup(over=<function <lambda>>, panel=<function _field_panel>),), level=2, intro='Sections of conservative temperature (CT), absolute salinity (SA) and potential density (σ₀) against distance along the section. The σ₀ panel carries the 27.7 and 27.8 kg m⁻³ isopycnals (black, labelled). Open triangles along the top of each panel mark the profiles; station numbers are labelled at intervals.', applies_to=None, role='content', layout=None), Section(id='biogeochemistry', title='Biogeochemistry', panels=(PanelGroup(over=<function _biogeo_present>, panel=<function <lambda>>),), level=2, intro='Sections of the biogeochemical sensors present on this section oxygen, fluorescence and turbidity where available against distance along the section.', applies_to=None, role='content', layout=None), Section(id='velocity', title='Velocity (U east, V north)', panels=(PanelGroup(over=<function <lambda>>, panel=<function _ladcp_panel>),), level=2, intro='Eastward (U) and northward (V) velocity from the LADCP, against distance along the section.', applies_to=None, role='content', layout=None), Section(id='ts_diagram', title='T–S diagrams', panels=(PanelGroup(over=<function <lambda>>, panel=<function _ts_panel>),), level=2, intro='Water-mass structure of the section in temperature–salinity space.', applies_to=None, role='content', layout=None)))

The section page profile. Same order as the previous hand-authored page — Map, Hydrography, Biogeochemistry, then the former “extra cards” Velocity and T–S — now numbered and anchored by the resolver. Physics/biogeo are PanelGroups over their variables; Velocity is a PanelGroup over the pre-rendered LADCP panels.

ctdcast.reports._section.SECTION_PANELS: dict[str, Panel] = {'section_map': Panel(id='section_map', render=<function <lambda>>, kind='figure', slot='half', caption=None, applies_to=<function <lambda>>, unavailable_if=<function _unavailable_never>)}

String-addressable section panels. Only the Map is fixed; field panels (physics/biogeo), LADCP and T–S panels are all data-driven PanelGroups.

class ctdcast.reports._section.SectionPageCtx(ds_sec: Any, x_vals: Any, x_label: str, section_style: str, bathy_depths: Any, bathy_x: Any, cast_labels: list[int], vmin: dict[str, float], vmax: dict[str, float], section_figsize: tuple[float, float], section_slot_key: str, section_name: str, lats: list[float], lons: list[float], map_b64: str | None, ts_panels: tuple[RenderedPanel, ...], ladcp_panels: tuple[RenderedPanel, ...], cfg: ReportConfig)[source]

Per-section render context: the frozen inputs every section panel reads.

Holds the values derived once per section (selected profiles, x-axis, bathy, colour limits, the computed figure geometry) so a panel’s render depends only on this object.

bathy_depths: Any
bathy_x: Any
cast_labels: list[int]
cfg: ReportConfig
ds_sec: Any
ladcp_panels: tuple[RenderedPanel, ...]
lats: list[float]
lons: list[float]
map_b64: str | None
section_figsize: tuple[float, float]
section_name: str
section_slot_key: str
section_style: str
ts_panels: tuple[RenderedPanel, ...]
vmax: dict[str, float]
vmin: dict[str, float]
x_label: str
x_vals: Any
ctdcast.reports._section.generate_section_page(section_name: str, section_cfg: dict[str, Any], profiles_path: Path, out_dir: Path, force: bool = False, section_style: str = 'pcolormesh', vmin_override: dict[str, float] | None = None, vmax_override: dict[str, float] | None = None, ladcp_dir: Path | None = None, ladcp_pattern: str | None = None, dbar_step: int = 1, prev_name: str | None = None, next_name: str | None = None, cruise_info: dict[str, Any] | None = None, cfg: ReportConfig = ReportConfig(gebco_path=None, clean_spines=True, profile_figsize=(7.0, 10.0), overview_figsize=(9.0, 4.5), map_lat_min=None, map_lat_max=None, map_lon_min=None, map_lon_max=None, var_cmaps=mappingproxy({'ctd_temperature_1': 'RdYlBu_r', 'ctd_temperature_2': 'RdYlBu_r', 'ctd_temperature': 'RdYlBu_r', 'ctd_salinity_1': 'YlGnBu_r', 'ctd_salinity_2': 'YlGnBu_r', 'ctd_salinity': 'YlGnBu_r', 'ctd_oxygen_1': 'RdYlGn', 'ctd_oxygen_2': 'RdYlGn', 'ctd_oxygen': 'RdYlGn', 'oxygen_saturation': 'RdYlGn', 'ctd_fluor': 'YlGn', 'ctd_turbidity': 'YlOrBr', 'conservative_temperature': 'RdYlBu_r', 'absolute_salinity': 'YlGnBu_r', 'sigma0': 'Purples', 'AOU': 'RdBu_r'}))) Path | None[source]

Generate a section HTML report page.

Parameters:
  • section_name – Key from ctd_sections.yaml, e.g. "KTout".

  • section_cfg – Dict with keys description, cast_numbers, color.

  • profiles_path – Path to profiles.nc (built by cnv_build_profiles.py).

  • out_dir – Root output directory.

  • force – Overwrite existing file if True.

  • section_style"pcolormesh" or "contourf" — passed through to each section figure.

  • vmin_override – Per-variable colormap limit overrides (e.g. {"SA": 34.5}).

  • vmax_override – Per-variable colormap limit overrides (e.g. {"SA": 34.5}).

  • ladcp_dir – Directory containing processed LADCP .mat files. If None, the LADCP velocity section panel is omitted.

  • ladcp_pattern – Filename pattern for LADCP files, e.g. "msm_142_1_*.mat". The * is replaced with the zero-padded cast number. Falls back to NNN.mat if not given.

  • dbar_step – Subsample the pressure axis by this step before plotting (default 1, no subsampling). build_profiles() always stores 1-dbar data; this controls plot-time resolution only.

  • prev_name – Name of the preceding section (for the ← nav button). None omits the button.

  • next_name – Name of the following section (for the → nav button). None omits the button.

Return type:

Path to the written HTML file, or None on failure.

ctdcast.reports._section.resolve_section(ctx: SectionPageCtx) ResolvedReport[source]

Resolve the section profile against ctx into numbered, rendered sections.

Tier-2: per-group timeseries HTML report pages.

A timeseries in this codebase is a named group of repeat casts at one location (yoyo CTDs, e.g. 24 h of repeated profiles). Config is analogous to sections: a name, a description, and a list of cast numbers.

The cruise-wide stacked overview plots (all casts, station-number x-axis) live on index.html, generated by _index.py. They are not timeseries pages.

ctdcast.reports._timeseries.TIMESERIES_DEFAULT: Profile = Profile(numbering='flat', entries=(Section(id='map', title='Map', panels=('ts_location_map',), level=2, intro='Location of the repeat CTD casts in this group.', applies_to=None, role='content', layout=None), Section(id='hydrography', title='Hydrography', panels=(PanelGroup(over=<function <lambda>>, panel=<function _ts_field_panel>),), level=2, intro='Time series of conservative temperature (CT), absolute salinity (SA) and potential density (σ₀) against cast time. The σ₀ panel carries the 27.7 and 27.8 kg m⁻³ isopycnals (black, labelled). Triangles along the top mark each profile downward for downcasts, upward for upcasts with station numbers labelled at intervals above the downcasts.', applies_to=None, role='content', layout=None), Section(id='biogeochemistry', title='Biogeochemistry', panels=(PanelGroup(over=<function _ts_biogeo_present>, panel=<function <lambda>>),), level=2, intro='Time series of the biogeochemical sensors present in this group oxygen, fluorescence and turbidity where available against cast time.', applies_to=None, role='content', layout=None), Section(id='velocity', title='Velocity (U east, V north)', panels=(PanelGroup(over=<function <lambda>>, panel=<function _ts_ladcp_panel>),), level=2, intro='Eastward (U) and northward (V) velocity from the LADCP, against hours since the first cast.', applies_to=None, role='content', layout=None), Section(id='ts_diagram', title='T–S diagram', panels=(PanelGroup(over=<function <lambda>>, panel=<function _ts_diagram_panel>),), level=2, intro='Water-mass structure over the occupation in temperature–salinity space, profiles coloured by time.', applies_to=None, role='content', layout=None)))

The timeseries page profile — the same shape and section ids as the section page (so the shared anchors and the legacy #s-* aliases resolve identically), but with time-axis panels and cast-count-driven widths.

ctdcast.reports._timeseries.TIMESERIES_PANELS: dict[str, Panel] = {'ts_location_map': Panel(id='ts_location_map', render=<function <lambda>>, kind='figure', slot='third', caption=None, applies_to=<function <lambda>>, unavailable_if=<function _unavailable_never>)}

Only the location map is fixed; fields, LADCP and T–S are data-driven groups.

class ctdcast.reports._timeseries.TimeseriesPageCtx(ds_ts: Any, section_style: str, vmin: dict[str, float], vmax: dict[str, float], ts_figw: float, ts_slot_key: str, fig_location_b64: str | None, ts_panels: tuple[RenderedPanel, ...], ladcp_panels: tuple[RenderedPanel, ...], cfg: ReportConfig)[source]

Per-timeseries render context: the frozen inputs every panel reads.

Holds the values derived once per group (the selected profiles, colour limits, the cast-count-driven figure width and slot, and the pre-rendered location map, LADCP and T–S panels) so a panel’s render depends only on this object.

cfg: ReportConfig
ds_ts: Any
fig_location_b64: str | None
ladcp_panels: tuple[RenderedPanel, ...]
section_style: str
ts_figw: float
ts_panels: tuple[RenderedPanel, ...]
ts_slot_key: str
vmax: dict[str, float]
vmin: dict[str, float]
ctdcast.reports._timeseries.generate_timeseries_page(ts_name: str, ts_cfg: dict, profiles_path: Path, out_dir: Path, force: bool = False, section_style: str = 'contourf', vmin_override: dict[str, float] | None = None, vmax_override: dict[str, float] | None = None, all_meta: list[dict] | None = None, ladcp_dir: Path | None = None, ladcp_pattern: str | None = None, dbar_step: int = 1, prev_name: str | None = None, next_name: str | None = None, cruise_info: dict[str, Any] | None = None, cfg: ReportConfig = ReportConfig(gebco_path=None, clean_spines=True, profile_figsize=(7.0, 10.0), overview_figsize=(9.0, 4.5), map_lat_min=None, map_lat_max=None, map_lon_min=None, map_lon_max=None, var_cmaps=mappingproxy({'ctd_temperature_1': 'RdYlBu_r', 'ctd_temperature_2': 'RdYlBu_r', 'ctd_temperature': 'RdYlBu_r', 'ctd_salinity_1': 'YlGnBu_r', 'ctd_salinity_2': 'YlGnBu_r', 'ctd_salinity': 'YlGnBu_r', 'ctd_oxygen_1': 'RdYlGn', 'ctd_oxygen_2': 'RdYlGn', 'ctd_oxygen': 'RdYlGn', 'oxygen_saturation': 'RdYlGn', 'ctd_fluor': 'YlGn', 'ctd_turbidity': 'YlOrBr', 'conservative_temperature': 'RdYlBu_r', 'absolute_salinity': 'YlGnBu_r', 'sigma0': 'Purples', 'AOU': 'RdBu_r'}))) Path | None[source]

Generate a per-timeseries HTML page for a named group of repeat casts.

Plots both downcast and upcast profiles sorted by time_start on a shared time x-axis. Output: <out_dir>/timeseries/timeseries_<ts_name>.html.

Parameters:
  • ts_name – Group name (used in filename and page title).

  • ts_cfg – Dict with cast_numbers, description, and color keys.

  • profiles_path – Path to profiles.nc.

  • out_dir – Root output directory.

  • force – Overwrite existing file if True.

  • section_style"pcolormesh" or "contourf".

  • vmin_override – Per-variable colormap limit overrides.

  • vmax_override – Per-variable colormap limit overrides.

  • all_meta – List of per-cast metadata dicts (keys lat, lon, cast_num); used to render a cruise-context location map. If None, no map is shown.

  • ladcp_dir – Directory containing processed LADCP .mat files. If None, the LADCP velocity panel is omitted.

  • ladcp_pattern – Filename pattern for LADCP files, e.g. "msm_142_1_*.mat". The * is replaced with the zero-padded cast number. Falls back to NNN.mat if not given.

  • dbar_step – Subsample the pressure axis by this step before plotting (default 1, no subsampling). build_profiles() always stores 1-dbar data; this controls plot-time resolution only.

  • prev_name – Name of the preceding timeseries group (for the ← nav button). None omits the button.

  • next_name – Name of the following timeseries group (for the → nav button). None omits the button.

Return type:

Path to the written HTML file, or None if skipped or failed.

ctdcast.reports._timeseries.resolve_timeseries(ctx: TimeseriesPageCtx) ResolvedReport[source]

Resolve the timeseries profile against ctx into numbered, rendered sections.


Interactive map

Tier-2: self-contained interactive cruise map using Leaflet.js.

Leaflet JS/CSS (~160 KB) is bundled in ctdcast/reports/leaflet/ as package data so the generated leaflet.html requires no internet access at either generation or view time.

If a GEBCO path is configured, GEBCO bathymetry for the cruise region is rendered as an embedded PNG image layer using discrete depth bands (standard oceanographic levels: 0, 100, 200, 500, 1000, 2000, 3000, 4000, 6000 m).

Interaction:
  • Hover cast dot or section line → info panel updates (bottom-left).

  • Click cast dot → navigate directly to station page.

  • Click section line → navigate directly to section page.

  • Scroll or +/− buttons to zoom.

  • Shift+drag to box-zoom (Leaflet built-in).

ctdcast.reports._leaflet.generate_leaflet_map(all_meta: list[dict[str, Any]], sections_cfg: dict[str, Any], out_dir: Path, force: bool = False, ship_track_nc: Path | None = None, cruise: str = 'UNK', cfg: ReportConfig = ReportConfig(gebco_path=None, clean_spines=True, profile_figsize=(7.0, 10.0), overview_figsize=(9.0, 4.5), map_lat_min=None, map_lat_max=None, map_lon_min=None, map_lon_max=None, var_cmaps=mappingproxy({'ctd_temperature_1': 'RdYlBu_r', 'ctd_temperature_2': 'RdYlBu_r', 'ctd_temperature': 'RdYlBu_r', 'ctd_salinity_1': 'YlGnBu_r', 'ctd_salinity_2': 'YlGnBu_r', 'ctd_salinity': 'YlGnBu_r', 'ctd_oxygen_1': 'RdYlGn', 'ctd_oxygen_2': 'RdYlGn', 'ctd_oxygen': 'RdYlGn', 'oxygen_saturation': 'RdYlGn', 'ctd_fluor': 'YlGn', 'ctd_turbidity': 'YlOrBr', 'conservative_temperature': 'RdYlBu_r', 'absolute_salinity': 'YlGnBu_r', 'sigma0': 'Purples', 'AOU': 'RdBu_r'}))) Path | None[source]

Generate a Leaflet.js interactive cruise map at <out_dir>/leaflet.html.

Always regenerates (force is accepted for API symmetry but ignored). If ship_track_nc is provided and the file exists, the ship track is loaded, subsampled, and rendered as a grey polyline behind cast markers. cruise should be the resolved cruise identifier (from cruise_info or the NC file attribute); it is displayed in the map page title. Returns the output path, or None if all_meta is empty.


Analysis helpers

Derived physical quantities computed from raw CTD measurements.

All functions use GSW — the same library oceanographers use directly.

Per-cast (1-D, dim=time) functions

derive_salinity SP from conductivity/temperature/pressure derive_SA Absolute Salinity from SP/pressure/lat/lon derive_CT Conservative Temperature from SA/in-situ-T/pressure derive_sigma0 Potential density anomaly from SA/CT derive_AOU Apparent Oxygen Utilization from oxygen_saturation (% sat) derive_teos10 Convenience: SA + CT + sigma0 + optional O2 unit conversion

Profiles (2-D, dims N_PROF × pressure) functions

derive_teos10_profiles SA + CT + sigma0 for compiled profiles datasets

Output variable names match the VARIABLES registry in ctdcast.config.parameters: absolute_salinity, conservative_temperature, sigma0.

Variable resolution

Functions accept both the canonical CCHDO names (ctd_temperature, ctd_salinity, ctd_oxygen) and the suffixed dual-sensor names (ctd_temperature_1, ctd_salinity_1, ctd_oxygen_1). Old pre-rename names (temperature_1, salinity_1, oxygen_1) are accepted for backward compatibility with NC files written before the stage1-normalise rename.

ctdcast.analysis.derive.derive_AOU(ds: Dataset) Dataset[source]

Return ds with AOU added as 100 - oxygen_saturation (O₂ saturation deficit, % sat).

Note: this is a saturation-deficit proxy, not the traditional AOU in µmol/kg, because it uses oxygen_saturation (% saturation) rather than dissolved O₂ in µmol/kg.

Returns ds unchanged if no oxygen saturation variable is present or AOU already exists. Accepts oxygen_saturation (canonical) or oxsat_1 (pre-rename name).

Parameters:

ds – Dataset (any dimensionality) with oxygen_saturation or oxsat_1 in % saturation.

Returns:

New Dataset with AOU added; input is not mutated.

Return type:

xr.Dataset

ctdcast.analysis.derive.derive_CT(ds: Dataset) Dataset[source]

Return ds with Conservative Temperature (CT) added.

Requires ds["absolute_salinity"] to already be present (call derive_SA() first). Uses gsw.CT_from_t with the first available temperature variable (ctd_temperature, ctd_temperature_1, or temperature_1) and pressure.

Parameters:

ds – Per-cast Dataset (dim=time) with absolute_salinity, a temperature variable, and pressure.

Returns:

New Dataset with ds["conservative_temperature"] added; input is not mutated.

Return type:

xr.Dataset

ctdcast.analysis.derive.derive_SA(ds: Dataset) Dataset[source]

Return ds with Absolute Salinity (SA) added.

Uses gsw.SA_from_SP with the first available salinity variable (ctd_salinity, ctd_salinity_1, or salinity_1), pressure (dbar), and the cast’s median latitude/longitude.

Parameters:

ds – Per-cast Dataset (dim=time) with a salinity variable, pressure, latitude, longitude.

Returns:

New Dataset with ds["absolute_salinity"] added; input is not mutated.

Return type:

xr.Dataset

ctdcast.analysis.derive.derive_salinity(ds: Dataset) Dataset[source]

Re-compute practical salinity from conductivity, temperature, pressure.

Uses gsw.SP_from_C, which expects conductivity in mS/cm. Stored conductivity is mS/cm since stage1; a file still carrying S/m (units not mS/cm) is converted by ×10. Call this after any conductivity calibration so that salinity reflects the calibrated conductivity.

Does nothing if conductivity_1 or any temperature variable is absent.

Writes output to ctd_salinity_1 / ctd_salinity_2 (CCHDO canonical names). Records the conversion method in the variable’s attrs.

Parameters:

ds – Per-cast Dataset (dim=time) containing at minimum conductivity_1, a temperature variable, and pressure in their expected units (conductivity in mS/cm, temperature in °C ITS-90, pressure in dbar).

Returns:

New Dataset with updated ctd_salinity_1 (and ctd_salinity_2 when conductivity_2 is present); input is not mutated.

Return type:

xr.Dataset

ctdcast.analysis.derive.derive_sigma0(ds: Dataset) Dataset[source]

Return ds with potential density anomaly (sigma0) added.

Requires ds["absolute_salinity"] and ds["conservative_temperature"] to already be present. Uses gsw.sigma0.

Parameters:

ds – Per-cast Dataset (dim=time) with absolute_salinity and conservative_temperature.

Returns:

New Dataset with ds["sigma0"] added; input is not mutated.

Return type:

xr.Dataset

ctdcast.analysis.derive.derive_teos10(ds: Dataset) Dataset[source]

Return ds with SA, CT, sigma0 added (1-D per-cast Dataset, dim=time).

Convenience function that calls derive_SA()derive_CT()derive_sigma0() in order. Also derives oxygen_saturation (% sat) from the first available oxygen variable (ctd_oxygen, ctd_oxygen_1, or oxygen_1) when that variable carries molar units.

Parameters:

ds – Per-cast Dataset (dim=time) with a salinity variable, a temperature variable, pressure, latitude, longitude.

Returns:

New Dataset with SA, CT, sigma0 added; input is not mutated.

Return type:

xr.Dataset

ctdcast.analysis.derive.derive_teos10_profiles(ds: Dataset) Dataset[source]

Return ds with SA, CT, sigma0 added (2-D profiles Dataset).

Expects pressure as a 1-D coordinate and a temperature variable, a salinity variable, latitude, longitude with dims (N_PROF,) or (N_PROF, pressure). Returns ds unchanged if SA, CT, and sigma0 are already present.

Parameters:

ds – Profiles Dataset (dims N_PROF × pressure).

Returns:

New Dataset with SA, CT, sigma0 added; input is not mutated.

Return type:

xr.Dataset

Cast geometry: along-track distance and section orientation.

Pure computation — no matplotlib, no HTML.

ctdcast.analysis.geometry.along_track_km(lats: list[float], lons: list[float]) tuple[ndarray, str][source]

Return (cumulative_distance_km, x_axis_label) for a list of positions.

ctdcast.analysis.geometry.distance_from_km(key_lat: float, key_lon: float, lats: list[float], lons: list[float]) ndarray[source]

Return great-circle distance in km from a key position to each position.

Used for key_cast section ordering: each cast’s x-coordinate is its distance from the chosen key cast. Uses gsw.distance per pair so the convention matches along_track_km(). A position identical to the key yields 0. A gsw.distance failure is allowed to propagate rather than being silently substituted with a fabricated distance.

ctdcast.analysis.geometry.section_orientation(lats: list[float], lons: list[float]) bool[source]

Return True if the section x-axis should be flipped for geographic convention.

Convention: west on the left for E–W-dominant sections; north on the left for N–S-dominant sections. Dominance is determined by comparing the end-to-end longitude span against the latitude span.

Parameters:
  • lats – Latitude of each cast in the section, in cast order.

  • lons – Longitude of each cast in the section, in cast order.

Returns:

True if x_vals (cumulative along-track distance from first cast) should be replaced by x_total - x_vals before plotting.

Return type:

bool

GEBCO bathymetry loading and interpolation.

Pure computation — no matplotlib, no HTML. GEBCO stores elevation as negative below sea level; this module returns depth as positive below sea level (depth = -elevation).

ctdcast.analysis.bathymetry.dense_bathy_along_track(lats: list[float], lons: list[float], x_vals: ndarray, path: Path | None = None, n_per_segment: int = 20) tuple[ndarray | None, ndarray | None][source]

Return (dense_x, dense_depths) interpolated between cast positions.

Generates n_per_segment equally-spaced points along each segment between consecutive casts, giving a smooth GEBCO bathymetry fill rather than the stepped appearance produced by one sample per cast. Returns (None, None) when GEBCO is unavailable or fewer than two cast positions are supplied.

ctdcast.analysis.bathymetry.interpolate_bathy_at_casts(lats: list[float], lons: list[float], path: Path | None = None) ndarray | None[source]

Return GEBCO water depth (m, positive below sea level) at each cast position.

Uses bilinear interpolation via xarray. Returns None if GEBCO is not available or on any error. Land points (elevation > 0) are clamped to 0.

ctdcast.analysis.bathymetry.load_gebco(lat_lo: float, lat_hi: float, lon_lo: float, lon_hi: float, margin: float = 0.05, path: Path | None = None) tuple[ndarray, ndarray, ndarray] | None[source]

Return a GEBCO subset as (lons, lats, depth_m) or None if unavailable.

If preload_gebco has been called for path, subsets from the in-memory numpy cache (fast). Otherwise opens the file from disk (slow).

Parameters:

path – Path to GEBCO_2025.nc. Pass cfg.gebco_path from the caller. Returns None if not provided or file not found.

ctdcast.analysis.bathymetry.preload_gebco(path: Path, lat_lo: float, lat_hi: float, lon_lo: float, lon_hi: float, margin: float = 1.0) bool[source]

Load a GEBCO region into memory once for the cruise area.

Call this at report-generation start with the full lat/lon extent of all casts. Subsequent load_gebco calls then subset from numpy arrays (no disk I/O) instead of reopening the file for every map figure.

Parameters:
  • path – Path to GEBCO netCDF file.

  • lat_lo – Bounding box of the cruise area.

  • lat_hi – Bounding box of the cruise area.

  • lon_lo – Bounding box of the cruise area.

  • lon_hi – Bounding box of the cruise area.

  • margin – Extra degrees around the bounding box. Default 1.0 deg.

Returns:

True if the file was found and cached successfully.

Return type:

bool


Cast processing

Stage 1 — CNV-to-netCDF conversion.

Defines the CtdBackend Protocol, concrete backend implementations, and stage1(), the public function that converts a directory of CNV files to per-cast netCDF. To add a new backend implement CtdBackend and add a branch in get_ctd_backend() — nothing else changes.

The converters module re-exports these names for backward compatibility.

class ctdcast.processors.stage1.CtdBackend(*args, **kwargs)[source]

Protocol for per-cast CNV-to-netCDF converters.

convert_cast(cnv_path: Path, nc_path: Path, *, force: bool = False) bool[source]

Convert one CNV file to netCDF.

Parameters:
  • cnv_path – Path to the raw SBE CNV input file.

  • nc_path – Desired output netCDF path.

  • force – If True, overwrite an existing nc_path.

Returns:

True if the file was written; False if skipped.

Return type:

bool

ctdcast.processors.stage1.get_ctd_backend(name: str) CtdBackend[source]

Return a CtdBackend instance for the given backend name.

Parameters:

name – Currently only "seasenselib".

Raises:
  • ValueError – If name is not a recognised backend.

  • ImportError – If the requested backend’s package is not installed.

ctdcast.processors.stage1.run(cnv_dir: Path, nc_dir: Path, *, force: bool = False, dry_run: bool = False, cast_tags: set[str] | None = None, **kw: object) int[source]

Run stage1 (CNV → netCDF) for explicit input and output directories.

Called by ctdcast.processors.process() with stage=1 or stage="stage1".

Parameters:
  • cnv_dir – Directory containing raw SBE CNV files.

  • nc_dir – Output directory for per-cast netCDF files (created if absent).

  • force – Overwrite existing NC files.

  • dry_run – Print what would be converted without writing any files.

  • cast_tags – If given, process only files whose stem contains one of the zero-padded 3-digit cast numbers (e.g. {"042", "043"}).

  • **kw – Passed to stage1() (e.g. backend, pattern).

Returns:

Number of files written (0 for dry_run).

Return type:

int

ctdcast.processors.stage1.stage1(cnv_dir: Path, nc_dir: Path, *, backend: str = 'seasenselib', force: bool = False, cast_filter: int | list[int] | None = None, pattern: str = '*.cnv') int[source]

Convert per-cast CNV files to netCDF using the specified backend.

Parameters:
  • cnv_dir – Directory containing raw SBE CNV files.

  • nc_dir – Output directory for per-cast netCDF files (created if absent).

  • backend – Backend name (currently only "seasenselib").

  • force – Overwrite existing netCDF files.

  • cast_filter – If given, convert only files whose stem contains the zero-padded cast number. Accepts a single int or a list of ints for multi-cast filtering.

  • pattern – Filename glob pattern applied within cnv_dir (default: "*.cnv").

Returns:

Number of files written (skipped files not counted).

Return type:

int

Raises:

ImportError – If the chosen backend’s package is not installed.

Stage 2 — trim.

Downcast/upcast splitting and soak / back-on-deck detection. This is processing, not analysis: it decides which scans belong to the real cast.

apply_stage2() is the pipeline entry point: it sets QARTOD flag 4 on soak and post-recovery deck records and records the parameters used in ds.attrs["history"]. find_soak_end and find_cast_end are kept public because reports._cast calls them directly for report-time plot trimming (that coupling will be removed in a later phase when reports read flags from the files).

The turnaround convention (last pressure within 2 dbar of the maximum) and the soak/deck algorithms are deliberate — see the individual docstrings.

ctdcast.processors.stage2.apply_stage2(ds: Dataset, *, near_surface_dbar: float = 10.0, search_seconds: float = 20.0, deck_window_seconds: float = 20.0, margin_dbar: float = 0.5, max_deck_dbar: float = 20.0) Dataset[source]

Apply QARTOD flag 4 to soak and post-recovery deck records.

Creates {var}_qc arrays (int8, flag 1=pass) for each physical data variable, then sets flag 4 (fail) on the pre-descent soak window and the post-recovery on-deck window. Records the parameters used in ds.attrs["history"] so the treatment is reproducible from the output file alone.

Parameters:
  • ds – Per-cast Dataset (dim=time).

  • near_surface_dbar – Passed to find_soak_end — pressure threshold for last near-surface crossing before the real descent.

  • search_seconds – Passed to find_soak_end — backward-crawl window width.

  • deck_window_seconds – Passed to find_cast_end — tail window for on-deck reference pressure.

  • margin_dbar – Passed to find_cast_end — added to on-deck median to form the cut.

  • max_deck_dbar – Passed to find_cast_end — if on-deck median exceeds this, no trim.

Returns:

New Dataset; input is not mutated.

Return type:

xr.Dataset

ctdcast.processors.stage2.find_cast_end(pressure: ndarray, times: ndarray, deck_window_seconds: float = 20.0, margin_dbar: float = 0.5, max_deck_dbar: float = 20.0) int[source]

Return the exclusive end index, trimming post-recovery deck records.

Algorithm:

  1. Take the median pressure of the last deck_window_seconds seconds as the on-deck reference pressure. Using a median handles sensor offset (the pressure sensor may not read exactly 0 dbar when the CTD is in the air) and is robust to brief oscillations on deck.

  2. Find the first index after the pressure maximum where pressure falls at or below p_deck_median + margin_dbar. Trim from that index onward.

Returns len(pressure) (no trim) if:

  • the record is empty or the CTD never returned near the surface (p_deck_median > max_deck_dbar), or

  • pressure never drops to the threshold on the upcast.

Parameters:
  • pressure – Pressure array in dbar.

  • times – Time coordinate array (numpy.datetime64 or numeric seconds).

  • deck_window_seconds – Duration of the tail window used to estimate on-deck pressure.

  • margin_dbar – Added to the on-deck median to form the cut threshold.

  • max_deck_dbar – If the on-deck median exceeds this value the CTD is considered not to have returned to the surface and no trim is applied.

Returns:

Exclusive end index; slice with ds.isel(time=slice(None, idx)).

Return type:

int

ctdcast.processors.stage2.find_soak_end(pressure: ndarray, times: ndarray, near_surface_dbar: float = 10.0, search_seconds: float = 20.0) int[source]

Return the index at which the real downcast begins (exclusive end of soak).

Algorithm (three steps):

  1. Find i_max, the index of the global pressure maximum (deepest point of the cast). Searching only in pressure[0:i_max+1] keeps the upcast recovery — when the CTD returns to the surface at the end of the cast — from being confused with the pre-soak position.

  2. Within pressure[0:i_max+1], find the last index where pressure < near_surface_dbar. For a typical MSM-style cast this falls on the early real descent, just as the CTD passes near_surface_dbar going downward.

  3. Crawl backward from that index within search_seconds to find the minimum pressure — the shallowest point (closest to the surface) just before the real descent began. Return the index immediately after that minimum as the start of the real downcast.

In bad-weather conditions where the CTD soaks at depth and is never raised back to the surface, step 3 finds the minimum within the soak window and removes only the first search_seconds of the soak. The operator- visible effect is a truncation of the pre-soak data, not a clean removal.

Parameters:
  • pressure – Pressure array in dbar (1-D, same length as times).

  • times – Time coordinate array. May be numpy.datetime64 or numeric seconds; elapsed time is computed relative to times[0].

  • near_surface_dbar – Pressure threshold used to find the last near-surface crossing before the main descent. Default is 10 dbar (≈10 m), safely below the typical soak depth of 8–10 m.

  • search_seconds – Width of the backward-crawl window (seconds) used to find the pre-descent surface minimum. Default is 20 s.

Returns:

Index of the first record to keep; slice with ds.isel(time=slice(idx, None)). Returns 0 if the cast never reaches below near_surface_dbar (no trim applied).

Return type:

int

ctdcast.processors.stage2.run(nc_dir: Path, *, force: bool = False, dry_run: bool = False, cast_tags: set[str] | None = None, **kw: object) int[source]

Apply stage2 (soak/deck flagging) to NC files in nc_dir.

Reads each *.nc file, applies apply_stage2(), and writes the result back in place using ctdcast.writers.netcdf.write(). Called by ctdcast.processors.process() with stage=2 or stage="stage2".

Parameters:
  • nc_dir – Directory of per-cast netCDF files (read and written in place).

  • force – Reprocess files that already carry _qc flag variables. Without force, already-flagged files are skipped.

  • dry_run – Print which files would be processed without writing any output.

  • cast_tags – If given, process only files whose stem contains one of the zero-padded 3-digit cast numbers (e.g. {"042", "043"}).

  • **kw – Passed to apply_stage2() (e.g. near_surface_dbar).

Returns:

Number of files written (0 for dry_run).

Return type:

int

Raises:

FileNotFoundError – If nc_dir does not exist or is not a directory.

ctdcast.processors.stage2.split_cast(ds: Dataset) tuple[Dataset, Dataset][source]

Split ds (individual cast file, dim=time) into (downcast, upcast).

Uses the turnaround convention: last index where pressure is within 2 dbar of its maximum.

Stage 3 — QC, calibration, and derived-variable orchestrator.

Stage3 is iterative: re-run it as calibration improves. Each run applies gross-range QC, then any conductivity calibration present in the cruise config, then re-derives salinity from the calibrated conductivity.

Sea-Bird processing-chain calibration (hex-level, frequency coefficients) is Phase 5 scope; this module covers only the post-conversion treatment.

ctdcast.processors.stage3.run(nc_dir: Path, *, force: bool = False, dry_run: bool = False, cast_tags: set[str] | None = None, **kw: object) int[source]

Apply stage3 (QC + calibration) to NC files in nc_dir.

Reads each *.nc file, applies stage3(), and writes the result back in place using ctdcast.writers.netcdf.write(). Called by ctdcast.processors.process() with stage=3 or stage="stage3".

Parameters:
  • nc_dir – Directory of per-cast netCDF files (read and written in place).

  • force – Reprocess files that already carry QC flag variables. Without force, already-QC’d files are skipped.

  • dry_run – Print which files would be processed without writing any output.

  • cast_tags – If given, process only files whose stem contains one of the zero-padded 3-digit cast numbers (e.g. {"042", "043"}).

  • **kw – Passed to stage3() (e.g. cruise_cfg).

Returns:

Number of files written (0 for dry_run).

Return type:

int

Raises:

FileNotFoundError – If nc_dir does not exist or is not a directory.

ctdcast.processors.stage3.stage3(ds: Dataset, cruise_cfg: dict | None = None) Dataset[source]

Apply QC and calibration to a per-cast Dataset.

Applies the following in order:

  1. Gross-range QC (qc.apply_gross_range), using GROSS_RANGE_DEFAULTS merged with any overrides from cruise_cfg["qc"]["gross_range"].

  2. Conductivity calibration slope, if cruise_cfg["calibration"]["conductivity_slope"] is present. Multiplies conductivity_1 (and conductivity_2 if present) by the slope and records it in the variable’s attributes.

  3. Re-derives salinity_1 (and salinity_2) from the calibrated conductivity using derive_salinity() — only when a conductivity calibration was applied.

Parameters:
  • ds – Per-cast Dataset (dim=time), already through stage1 and stage2.

  • cruise_cfg – Optional dict with sub-keys qc and calibration. Pass cfg.get("processing") or the full cruise config dict.

Returns:

New Dataset; input is not mutated.

Return type:

xr.Dataset

Stage QC — gross-range flagging.

Sets QARTOD flag 3 (suspect) on any record outside the configured physical range. Operates on per-cast Datasets (dim=time); call after apply_stage2 so the flag arrays already exist.

ctdcast.processors.qc.apply_gross_range(ds: Dataset, thresholds: dict[str, tuple[float, float]] | None = None) Dataset[source]

Set QARTOD flag 3 on records outside gross-range bounds.

Creates {var}_qc arrays (int8, initialised 1=pass) if they do not already exist. Merges GROSS_RANGE_DEFAULTS with any caller-supplied thresholds; caller wins per variable. Records each threshold used in ds.attrs["history"].

Parameters:
  • ds – Per-cast Dataset (dim=time). Modified variables are those present in both ds and the merged threshold dict.

  • thresholds – Per-variable overrides: {"ctd_salinity_1": (30.0, 40.0)}. Caller-supplied values replace the defaults for that variable.

Returns:

New Dataset; input is not mutated.

Return type:

xr.Dataset

Cruise-level profile compiler: per-cast netCDF → profiles.nc.

Reads all per-cast netCDF files in a directory, splits each into downcast and upcast halves, bins to a common 1-dbar grid, and writes a single (N_PROF × pressure) netCDF. The converters module re-exports build_profiles for backward compatibility.

ctdcast.processors.profiles.build_profiles(nc_dir: Path, profiles_path: Path, *, force: bool = False, gebco_path: Path | None = None) bool[source]

Compile per-cast netCDF files into a single profiles.nc on a 1-dbar grid.

Reads all *.nc files in nc_dir, splits each cast into downcast and upcast halves, bins to a common 1-dbar pressure grid, and writes a single (N_PROF × pressure) netCDF. N_PROF is a plain integer index (0, 1, 2, …); cast identity is carried by cast_number, cast_suffix, and cast_direction variables.

Per-cast scalar variables added to the output:

  • max_pressure_dbar — maximum pressure recorded over the full cast.

  • gebco_depth_m — GEBCO bathymetry depth (m, positive down) at the max-pressure lat/lon position; NaN when gebco_path is None or the file is unavailable.

The altimeter channel (when present in the input files) is binned onto the 1-dbar grid as a standard 2-D variable.

Parameters:
  • nc_dir – Directory containing per-cast netCDF files.

  • profiles_path – Output path for the compiled profiles netCDF.

  • force – Overwrite an existing profiles_path.

  • gebco_path – Path to a GEBCO_2025.nc file. Used to look up water depth at each cast’s max-pressure position. Pass cfg.gebco_path when calling from report generation code. Silently omitted when None.

Returns:

True if profiles.nc was written; False if skipped (existed, force=False).

Return type:

bool

Raises:

ValueError – If no recognised cast files are found in nc_dir.

ctdcast.processors.profiles.run(nc_dir: Path, profiles_path: Path, *, force: bool = False, dry_run: bool = False, **kw: object) bool[source]

Build profiles.nc from NC files in nc_dir.

Called by ctdcast.processors.process() with stage="profiles".

Parameters:
  • nc_dir – Directory of per-cast netCDF files.

  • profiles_path – Output path for the compiled profiles netCDF.

  • force – Overwrite an existing profiles.nc.

  • dry_run – Print what would be built without writing any output.

  • **kw – Passed to build_profiles() (e.g. gebco_path).

Returns:

True if profiles.nc was written; False if skipped (or dry_run).

Return type:

bool


Readers

Reader for LDEO IXv14 LADCP .mat files.

Locates the .mat file for a cast (find_ladcp_file()), loads it with a single set of scipy.io.loadmat options (read_ladcp()), and maps the result struct to a single-cast xarray.Dataset on the native 10 m depth grid (read_ladcp_cast()). The .mat is the LDEO IX velocity solution; its ~50 fields are mapped to the compiled-dataset schema in .claude/notes/2026-08-17-ladcp-compiled-dataset.md.

ctdcast.readers.ladcp.find_ladcp_file(ladcp_dir: Path, cast_num: int, cast_suffix: str = '', ladcp_pattern: str | None = None) Path | None[source]

Return the .mat file for cast_num in ladcp_dir, or None if absent.

If ladcp_pattern is given (e.g. "msm_142_1_*.mat"), the * wildcard is replaced with the zero-padded cast number (and optional suffix) and that name is tried first. Falls back to standard names (NNN.mat, NNNb.mat) then a *_NNN.mat glob for cruise-prefixed filenames. The first glob match (lexicographic) is returned when multiple files match.

ctdcast.readers.ladcp.read_ladcp(path: Path | str) dict[str, Any][source]

Load an LDEO IXv14 LADCP .mat file.

Uses squeeze_me=True and struct_as_record=False so the LADCP result struct is reachable as read_ladcp(path)["dr"] with attribute access.

ctdcast.readers.ladcp.read_ladcp_cast(path: Path | str, *, cast_num: int, cast_suffix: str = '') Dataset[source]

Map an LDEO LADCP .mat to a single-cast Dataset on the native 10 m grid.

Returns a Dataset with dimension depth (uniform 10 m, positive down), an auxiliary pressure(depth) coordinate (dbar, the bridge to profiles.nc), the inverse (u/v) and shear (u_shear/v_shear/w_shear) velocity solutions, per-instrument down/up-looker profiles, per-cast scalars (barotropic, bottom-track, position, instrument_config), and the LADCP processing provenance as global attributes. Velocity carries the CF eastward/northward/error_sea_water_velocity standard names.

Reader for per-cast sensor metadata written by seasenselib.

Parses the raw_metadata global attribute (a JSON blob) into a list of sensor descriptors for the cast page.

ctdcast.readers.metadata.parse_sensor_info(ds: Dataset) list[dict[str, str]][source]

Extract sensor serial numbers and calibration dates from ds.

Parses the raw_metadata global attribute (a JSON string written by seasenselib) and returns one entry per sensor channel that has both a sensor_type and a serial_number.

Parameters:

ds – Per-cast Dataset as opened from a netCDF file.

Returns:

Each dict has keys sensor_type (human-readable label), serial_number, and calibration_date. Returns [] if raw_metadata is absent, unparseable, or contains no usable sensors.

Return type:

list[dict[str, str]]