diff --git a/README.md b/README.md
index 1701817..cb195f8 100644
--- a/README.md
+++ b/README.md
@@ -15,6 +15,8 @@ OceanArray implements a multi-stage processing pipeline for mooring data, contro
```bash
git clone https://github.com/ocean-uhh/oceanarray.git
cd oceanarray
+python -m venv venv
+source venv/bin/activate
pip install -r requirements-dev.txt
pip install -e .
```
diff --git a/oceanarray/cli.py b/oceanarray/cli.py
index 0a94cca..7de65e1 100644
--- a/oceanarray/cli.py
+++ b/oceanarray/cli.py
@@ -94,7 +94,7 @@ def _print_report(basedir: str, mooring: str) -> None:
counts = f"{n_raw:>7} raw → {n_rec:>7} {stage_label}"
else:
counts = f"{'?':>7} raw → {n_rec:>7} {stage_label}"
- except Exception:
+ except Exception: # noqa: BLE001 — intentional broad catch at I/O boundary
counts = f"{'HDF ERR':>7} raw → {n_rec:>7} {stage_label}"
mtime = datetime.datetime.fromtimestamp(
@@ -103,7 +103,7 @@ def _print_report(basedir: str, mooring: str) -> None:
print(
f" s/n {serial:<8} {counts} {t0} → {t1} depth: {depth} dt: {interval} processed: {mtime} vars: {', '.join(vars_present)}"
)
- except Exception as e:
+ except Exception as e: # noqa: BLE001 — display helper; must not crash status output
print(f" {nc.name}: ERROR — {e}")
@@ -290,6 +290,7 @@ def cmd_validate(args: argparse.Namespace) -> int:
def build_parser() -> argparse.ArgumentParser:
+ """Build and return the top-level argument parser for the oceanarray CLI."""
parser = argparse.ArgumentParser(
prog="oceanarray",
description="Oceanographic mooring data processing.",
@@ -511,6 +512,7 @@ def build_parser() -> argparse.ArgumentParser:
def main() -> None:
+ """Entry point for the ``oceanarray`` command-line tool."""
parser = build_parser()
args = parser.parse_args()
sys.exit(args.func(args))
diff --git a/oceanarray/clock_offset.py b/oceanarray/clock_offset.py
index fe293dd..a82176c 100644
--- a/oceanarray/clock_offset.py
+++ b/oceanarray/clock_offset.py
@@ -15,7 +15,7 @@
from oceanarray import find_deployment, tools
-def load_mooring_instruments(mooring_name, base_dir, output_path, file_suffix="_raw"):
+def load_mooring_instruments(mooring_name, base_dir, output_path, file_suffix="_raw"): # noqa: ARG001
"""Load all instruments for a mooring from netCDF files and enrich with YAML metadata.
Parameters
@@ -161,7 +161,7 @@ def interpolate_datasets_to_grid(datasets, time_grid):
else:
print(f" No time-dependent variables found in dataset {idx}")
- except Exception as e:
+ except Exception as e: # noqa: BLE001 — log and continue; one bad dataset must not abort mooring
print(f" ERROR interpolating dataset {idx}: {e}")
continue
@@ -299,7 +299,7 @@ def calculate_timing_offsets(combined_ds, bin_width_sec=60):
# Find consensus group
vals = start_off0[np.isfinite(start_off0)]
if vals.size == 0:
- raise RuntimeError("No finite start offsets to form consensus.")
+ raise RuntimeError("No finite start offsets to form consensus.") # noqa: TRY003
vmin, vmax = vals.min(), vals.max()
bins = np.arange(vmin - bin_width_sec, vmax + 2 * bin_width_sec, bin_width_sec)
@@ -362,7 +362,6 @@ def perform_lag_correlation_analysis(combined_ds, ref_index=0, sub_sample=5):
np.diff(combined_ds["time"].values) / np.timedelta64(1, "s")
)
- n_full = len(combined_ds["temperature"][:, ref_index].values)
ref_temp_sub = combined_ds["temperature"][:, ref_index].values[::sub_sample]
n_sub = len(ref_temp_sub)
@@ -510,7 +509,6 @@ def print_correlation_summary(combined_ds, correlation_results):
"""
serial = combined_ds["serial_number"].values
- depths = combined_ds["nominal_depth"].values
print("Lag Correlation Analysis Results:")
print("(Enter the summed value, no sign change, in the yaml as clock_offset)")
diff --git a/oceanarray/find_deployment.py b/oceanarray/find_deployment.py
index 653ad71..89f1b00 100644
--- a/oceanarray/find_deployment.py
+++ b/oceanarray/find_deployment.py
@@ -387,11 +387,11 @@ def find_deployment(
deployment_time=None,
recovery_time=None,
deployment_margin_hours=2.0,
- surface_window_hours=24.0,
- smooth_window=9,
- method="sigma_band", # NEW: 'changepoint' or 'sigma_band'
- band_sigma=3.0, # NEW: used when method='sigma_band'
- dwell_seconds=1800,
+ surface_window_hours=24.0, # noqa: ARG001 — reserved for future surface-window logic
+ smooth_window=9, # noqa: ARG001 — reserved for future smoothing step
+ method="sigma_band", # noqa: ARG001 — reserved for future method dispatch
+ band_sigma=3.0, # noqa: ARG001 — reserved for future sigma_band method
+ dwell_seconds=1800, # noqa: ARG001 — reserved for future dwell filter
):
"""Populate ds with:
- start_time (N_LEVELS) : first deep time (when temps settle cold)
@@ -401,15 +401,15 @@ def find_deployment(
Returns ds with the variables added/updated.
"""
if var_name not in ds:
- raise ValueError(f"{var_name!r} not found in dataset.")
+ raise ValueError(f"{var_name!r} not found in dataset.") # noqa: TRY003
if ds[var_name].dims != ("time", "N_LEVELS"):
- raise ValueError(
+ raise ValueError( # noqa: TRY003
f"{var_name!r} must have dims ('time','N_LEVELS'). Got {ds[var_name].dims}"
)
nlev = ds.sizes.get("N_LEVELS", None)
if nlev is None:
- raise ValueError("Dimension 'N_LEVELS' not found in dataset.")
+ raise ValueError("Dimension 'N_LEVELS' not found in dataset.") # noqa: TRY003
# ensure outputs exist with correct dtype/shape
if "start_time" not in ds:
diff --git a/oceanarray/logger.py b/oceanarray/logger.py
index 99dbe12..87e8a08 100644
--- a/oceanarray/logger.py
+++ b/oceanarray/logger.py
@@ -1,7 +1,9 @@
-# oceanarray/logger.py
+"""Logging configuration and helpers for the oceanarray processing pipeline."""
+
import datetime
import logging
from pathlib import Path
+from typing import Any
# Global logger instance (will be configured by setup_logger)
log = logging.getLogger("oceanarray")
@@ -12,37 +14,37 @@
LOGGING_ENABLED = True
-def enable_logging():
+def enable_logging() -> None:
"""Enable logging globally."""
global LOGGING_ENABLED
LOGGING_ENABLED = True
-def disable_logging():
+def disable_logging() -> None:
"""Disable logging globally."""
global LOGGING_ENABLED
LOGGING_ENABLED = False
-def log_info(message, *args):
+def log_info(message: str, *args: Any) -> None:
"""Log an info message, if logging is enabled."""
if LOGGING_ENABLED:
log.info(message, *args, stacklevel=2)
-def log_warning(message, *args):
+def log_warning(message: str, *args: Any) -> None:
"""Log a warning message, if logging is enabled."""
if LOGGING_ENABLED:
log.warning(message, *args, stacklevel=2)
-def log_error(message, *args):
+def log_error(message: str, *args: Any) -> None:
"""Log an error message, if logging is enabled."""
if LOGGING_ENABLED:
log.error(message, *args, stacklevel=2)
-def log_debug(message, *args):
+def log_debug(message: str, *args: Any) -> None:
"""Log a debug message, if logging is enabled."""
if LOGGING_ENABLED:
log.debug(message, *args, stacklevel=2)
@@ -95,7 +97,7 @@ def setup_logger(array_name: str, output_dir: str = "logs") -> None:
log.info(f"Logger initialized for array: {array_name}, writing to {log_path}")
-def load_logging_config():
+def load_logging_config() -> dict:
"""Load the global logging configuration from config/logging.yaml.
Returns
@@ -116,7 +118,7 @@ def load_logging_config():
config_path = Path(__file__).parent / "config" / "logging.yaml"
if not config_path.exists():
- raise FileNotFoundError(f"Logging config not found: {config_path}")
+ raise FileNotFoundError(f"Logging config not found: {config_path}") # noqa: TRY003
with open(config_path, "r") as f:
config = yaml.safe_load(f)
diff --git a/oceanarray/mooring_level.py b/oceanarray/mooring_level.py
index 0dafa7a..03c8ddf 100644
--- a/oceanarray/mooring_level.py
+++ b/oceanarray/mooring_level.py
@@ -32,6 +32,8 @@
"east_velocity",
"north_velocity",
"up_velocity",
+ "current_speed",
+ "current_direction",
"east_velocity_qc",
"north_velocity_qc",
"up_velocity_qc",
@@ -60,6 +62,8 @@
"east_velocity",
"north_velocity",
"up_velocity",
+ "current_speed",
+ "current_direction",
"velocity_beam1",
"velocity_beam2",
"velocity_beam3",
@@ -101,8 +105,18 @@ def _dms_to_deg(s: str) -> float:
return val
-def _parse_latlon(cfg: dict):
+def _parse_latlon(cfg: dict) -> tuple[float, float]:
"""Return (lat, lon) in decimal degrees from a mooring config dict."""
+ lat, lon, _ = _parse_latlon_with_source(cfg)
+ return lat, lon
+
+
+def _parse_latlon_with_source(cfg: dict) -> tuple[float, float, str]:
+ """Return (lat, lon, source_key) from a mooring config dict.
+
+ source_key is the YAML key pair used, e.g. 'seabed_latitude/seabed_longitude',
+ or 'unknown (defaulting to 0, 0)' if none found.
+ """
for lat_key, lon_key in [
("seabed_latitude", "seabed_longitude"),
("deployment_latitude", "deployment_longitude"),
@@ -112,8 +126,12 @@ def _parse_latlon(cfg: dict):
lat_s = cfg.get(lat_key)
lon_s = cfg.get(lon_key)
if lat_s is not None and lon_s is not None:
- return _dms_to_deg(str(lat_s)), _dms_to_deg(str(lon_s))
- return 0.0, 0.0
+ return (
+ _dms_to_deg(str(lat_s)),
+ _dms_to_deg(str(lon_s)),
+ f"{lat_key}/{lon_key}",
+ )
+ return 0.0, 0.0, "unknown (defaulting to 0, 0)"
_SIGMA_META = {
@@ -291,7 +309,7 @@ def _linear_interp(
class MooringStacker:
"""Step 1: stack all instruments onto a common time axis → ``_stack.nc``."""
- def __init__(self, base_dir: str):
+ def __init__(self, base_dir: str) -> None:
self.base_dir = Path(base_dir)
def stack(
@@ -300,6 +318,15 @@ def stack(
dt_seconds: int = 60,
force: bool = False,
) -> bool:
+ """Stack all processed instruments for *mooring_name* onto a common time grid.
+
+ Reads ``_stage3.nc`` (falling back to ``_stage2.nc``) for every instrument
+ listed in the mooring YAML, resamples each to *dt_seconds* resolution, and
+ writes ``{mooring}_stack.nc`` under the mooring proc directory.
+
+ Returns True on success, False if no instruments could be loaded or an
+ unrecoverable error occurs.
+ """
proc_dir = _get_proc_dir(self.base_dir, mooring_name)
try:
proc_dir_exists = proc_dir.exists()
@@ -399,7 +426,7 @@ def stack(
try:
ds = xr.open_dataset(info["nc_path"], decode_timedelta=False).load()
ds.close()
- except Exception as e:
+ except Exception as e: # noqa: BLE001 — one bad instrument must not abort the stack
print(f" WARNING: Could not load {info['nc_path'].name}: {e}")
# Ensure scalar_meta lists stay length-consistent
for lst in scalar_meta.values():
@@ -513,7 +540,7 @@ def stack(
"reference_pressure_dbar": ref_p,
},
)
- except Exception as exc:
+ except Exception as exc: # noqa: BLE001 — gsw failure must not abort stack
print(f" WARNING: could not compute potential density: {exc}")
# velocity_flag: element-wise worst QC flag across east/north/up velocity.
@@ -624,7 +651,7 @@ def stack(
"long_name": "Serial number of the pressure reference instrument used for tilt"
},
)
- except Exception as exc:
+ except Exception as exc: # noqa: BLE001 — optional derived var; must not abort stack
print(f" WARNING: could not compute tilt_from_pressure: {exc}")
# Coordinate names — exclude these from scalar metadata to avoid name conflicts
@@ -712,7 +739,7 @@ def stack(
class MooringGridder:
"""Step 2: vertically interpolate stacked instruments onto a pressure grid → ``_grid.nc``."""
- def __init__(self, base_dir: str):
+ def __init__(self, base_dir: str) -> None:
self.base_dir = Path(base_dir)
def grid(
@@ -723,6 +750,14 @@ def grid(
dp: float = 20.0,
force: bool = False,
) -> bool:
+ """Interpolate the stacked mooring dataset onto a regular pressure grid.
+
+ Reads ``{mooring}_stack.nc``, interpolates all variables onto a pressure
+ axis from *p_start* to *p_end* in steps of *dp* dbar, and writes
+ ``{mooring}_grid.nc``.
+
+ Returns True on success, False on error.
+ """
proc_dir = _get_proc_dir(self.base_dir, mooring_name)
merge_path = proc_dir / f"{mooring_name}_stack.nc"
output_path = proc_dir / f"{mooring_name}_grid.nc"
@@ -821,7 +856,13 @@ def grid(
if _v in _GRIDDER_TSQC:
var_data[_v][~np.isfinite(var_data[_v])] = np.nan
- _vel_vars = {"east_velocity", "north_velocity", "up_velocity"}
+ _vel_vars = {
+ "east_velocity",
+ "north_velocity",
+ "up_velocity",
+ "current_speed",
+ "current_direction",
+ }
if "velocity_flag" in ds.data_vars:
_vflag = ds["velocity_flag"].values.astype(np.float64)
_bad_vel = np.isin(np.round(_vflag).astype(np.int8), [3, 4, 9])
diff --git a/oceanarray/plotters.py b/oceanarray/plotters.py
index 44fd3bd..3d502bd 100644
--- a/oceanarray/plotters.py
+++ b/oceanarray/plotters.py
@@ -96,7 +96,7 @@ def plot_climatology(
style_path = Path(__file__).parent.parent / "oceanarray" / "oceanarray.mplstyle"
plt.style.use(str(style_path))
if var not in clim_ds:
- raise ValueError(f"{var} not found in climatology dataset.")
+ raise ValueError(f"{var} not found in climatology dataset.") # noqa: TRY003
if fig is None or ax is None:
fig, ax = plt.subplots(figsize=(10, 6))
@@ -260,7 +260,10 @@ def plot_timeseries_by_depth(ds, var="TEMP"):
plt.show()
-def plot_trim_windows(ds, dstart, dend, NN=np.timedelta64(12, "h")):
+_DEFAULT_NN = np.timedelta64(12, "h")
+
+
+def plot_trim_windows(ds, dstart, dend, NN=_DEFAULT_NN):
"""Plot start and end windows for variables T, C, P in the dataset,
highlighting data before/after dstart/dend.
@@ -502,7 +505,7 @@ def show_variables(data):
print("information is based on xarray Dataset")
variables = data.variables
else:
- raise TypeError("Input data must be a file path (str) or an xarray Dataset")
+ raise TypeError("Input data must be a file path (str) or an xarray Dataset") # noqa: TRY003
info = {}
for i, key in enumerate(variables):
@@ -563,13 +566,19 @@ def show_attributes(data):
print("information is based on file: {}".format(data))
rootgrp = Dataset(data, "r", format="NETCDF4")
attributes = rootgrp.ncattrs()
- get_attr = lambda key: getattr(rootgrp, key)
+
+ def get_attr(key):
+ return getattr(rootgrp, key)
+
elif isinstance(data, xr.Dataset):
print("information is based on xarray Dataset")
attributes = data.attrs.keys()
- get_attr = lambda key: data.attrs[key]
+
+ def get_attr(key): # type: ignore[no-redef]
+ return data.attrs[key]
+
else:
- raise TypeError("Input data must be a file path (str) or an xarray Dataset")
+ raise TypeError("Input data must be a file path (str) or an xarray Dataset") # noqa: TRY003
info = {}
for i, key in enumerate(attributes):
@@ -659,7 +668,7 @@ def plot_mooring_timeseries(
use_files = sorted(mooring_proc.rglob("*_stage2.nc"))
if not use_files:
- raise FileNotFoundError(f"No _stage2.nc files found under {mooring_proc}")
+ raise FileNotFoundError(f"No _stage2.nc files found under {mooring_proc}") # noqa: TRY003
scatter_mode = var_color is not None
@@ -671,7 +680,7 @@ def plot_mooring_timeseries(
for nc in use_files:
try:
ds = xr.open_dataset(nc, decode_timedelta=False)
- except Exception:
+ except Exception: # noqa: BLE001 — skip unreadable NC; one bad file must not abort plot
continue
has_y = var_y in ds.data_vars
@@ -698,7 +707,7 @@ def plot_mooring_timeseries(
else:
step = 1
ds_small = ds[keep].isel(time=slice(None, None, step)).load()
- except Exception:
+ except Exception: # noqa: BLE001 — skip instruments that fail to subsample
ds.close()
continue
diff --git a/oceanarray/readers.py b/oceanarray/readers.py
index a9bfe70..e35403f 100644
--- a/oceanarray/readers.py
+++ b/oceanarray/readers.py
@@ -46,7 +46,7 @@ def load_dataset(
elif rodb.is_rodb_file(f):
ds = rodb.rodbload(f)
else:
- raise ValueError(f"Unknown file type: {f}")
+ raise ValueError(f"Unknown file type: {f}") # noqa: TRY003
datasets.append(ds)
return datasets if len(datasets) > 1 else datasets[0]
@@ -85,12 +85,12 @@ def rodbload_old(filepath: Path, variables: list[str]) -> xr.Dataset:
break
if data_start_index is None:
- raise ValueError("Could not locate data block in file")
+ raise ValueError("Could not locate data block in file") # noqa: TRY003
# Extract columns
- col_line = next((l for l in header_lines if "columns" in l.lower()), None)
+ col_line = next((line for line in header_lines if "columns" in line.lower()), None)
if col_line is None:
- raise ValueError("No 'columns=' line found in header")
+ raise ValueError("No 'columns=' line found in header") # noqa: TRY003
columns = col_line.split("=")[-1].strip().split(":")
print(columns)
@@ -99,7 +99,7 @@ def rodbload_old(filepath: Path, variables: list[str]) -> xr.Dataset:
# Validate requested variables
missing = [v for v in variables if v not in columns]
if missing:
- raise ValueError(f"Variables not found in file: {missing}")
+ raise ValueError(f"Variables not found in file: {missing}") # noqa: TRY003
col_indices = {v: i for i, v in enumerate(columns) if v in variables}
@@ -255,7 +255,8 @@ def _add_nortek_csv_attributes(ds: xr.Dataset) -> xr.Dataset:
def load_nortek_csv(
- file_path: Union[str, Path], header_file: Optional[str] = None
+ file_path: Union[str, Path],
+ header_file: Optional[str] = None, # noqa: ARG001
) -> xr.Dataset:
"""Load Nortek CSV data exported from AquaPro software.
@@ -275,7 +276,7 @@ def load_nortek_csv(
"""
file_path = Path(file_path)
if not file_path.exists():
- raise FileNotFoundError(f"Nortek CSV file not found: {file_path}")
+ raise FileNotFoundError(f"Nortek CSV file not found: {file_path}") # noqa: TRY003
df = pd.read_csv(file_path, delimiter=";")
df["datetime"] = pd.to_datetime(df["dateTime"])
diff --git a/oceanarray/report/_grid.py b/oceanarray/report/_grid.py
index 1a97daf..8579766 100644
--- a/oceanarray/report/_grid.py
+++ b/oceanarray/report/_grid.py
@@ -7,7 +7,7 @@
import numpy as np
-from ._html_helpers import _parse_history, _status
+from ._html_helpers import _parse_history, _read_nc_metadata, _status
from ._plots import (
_make_grid_fig_b64,
_make_grid_ts_diagram,
@@ -65,6 +65,10 @@
.history-list li:last-child { border-bottom:none; }
.history-ts { color:var(--muted); white-space:nowrap; font-size:0.76rem; min-width:11rem; }
.history-text { flex:1; }
+ td.num { text-align:right; font-variant-numeric:tabular-nums; }
+ td.mono { font-family:monospace; font-size:0.8rem; }
+ .none-note { color:var(--muted); font-style:italic; }
+ .var-qc { color:var(--good); font-size:0.78rem; }
@media print { .masthead { -webkit-print-color-adjust:exact; print-color-adjust:exact; } }
@@ -89,7 +93,7 @@
{% if fig_n2_b64 %}N²{% endif %}
{% if fig_ts_grid_b64 %}T-S diagram{% endif %}
{% if fig_spectrum_b64 %}Power spectrum{% endif %}
- {% if var_table %}Variables{% endif %}
+ Variables
{% if history_entries %}
@@ -169,18 +173,71 @@
{% endif %}
-{% if var_table %}
-
Variables in file
-
- | Variable | Long name | Units | Coverage |
+
+NetCDF variables — {{ nc_file }}
+{% if nc_meta.get("error") %}
+Could not read file: {{ nc_meta.error }}
+{% else %}
+
+Variables
+
+
+ | Variable | Dims | N | Valid | Units | Long name | Standard name | QC flag |
+
- {% for v in var_table %}
- {{ v.name }} | {{ v.long_name }} | {{ v.units }} | {{ v.coverage }} |
- {% endfor %}
+ {% for v in nc_meta.time_vars %}
+ {% if not v.is_qc %}
+
+ | {{ v.name }} |
+ {{ v.dims }} |
+ {{ "{:,}".format(v.n) }} |
+ {{ "{:,}".format(v.n_valid) if v.n_valid is defined else "—" }} |
+ {{ v.units }} |
+ {{ v.long_name }} |
+ {{ v.standard_name }} |
+ {% if v.has_qc %}✓{% else %}–{% endif %} |
+
+ {% endif %}
+ {% endfor %}
+
+
+
+{% if nc_meta.scalar_vars %}
+Scalar metadata variables
+
+
+ | Variable | Value | Units | Long name |
+
+
+ {% for v in nc_meta.scalar_vars %}
+
+ | {{ v.name }} |
+ {{ v.value }} |
+ {{ v.units }} |
+ {{ v.long_name }} |
+
+ {% endfor %}
+
+
+{% endif %}
+
+{% if nc_meta.global_attrs %}
+Global attributes
+
+ | Attribute | Value |
+
+ {% for k, v in nc_meta.global_attrs.items() %}
+
+ | {{ k }} |
+ {{ v }} |
+
+ {% endfor %}
{% endif %}
+{% endif %}
+
@@ -360,23 +417,6 @@ def _qc_masked_vel(ds, vel_var):
pass
fig_n2_b64 = _make_grid_n2_b64(ds, lat=_lat_n2)
- var_table = []
- for vname in ds.data_vars:
- da_v = ds[vname]
- if "time" not in da_v.dims:
- continue
- n_total = da_v.size
- n_valid = int(np.sum(np.isfinite(da_v.values)))
- pct = f"{100 * n_valid / n_total:.0f}%" if n_total > 0 else "—"
- var_table.append(
- {
- "name": vname,
- "long_name": da_v.attrs.get("long_name", ""),
- "units": da_v.attrs.get("units", ""),
- "coverage": pct,
- }
- )
-
sigma_sections = []
for sv in [
v
@@ -443,6 +483,7 @@ def _qc_masked_vel(ds, vel_var):
ds.close()
+ nc_meta = _read_nc_metadata(grid_path)
stack_exists = (grid_path.parent / f"{mooring_name}_stack.nc").exists()
from jinja2 import Environment
@@ -458,7 +499,8 @@ def _qc_masked_vel(ds, vel_var):
mooring_report_link=f"{mooring_name}_report.html",
stack_exists=stack_exists,
history_entries=grid_history,
- var_table=var_table,
+ nc_meta=nc_meta,
+ nc_file=grid_path.name,
fig_temp_b64=fig_temp_b64,
fig_temp_cf_b64=fig_temp_cf_b64,
temp_plow=temp_plow,
diff --git a/oceanarray/report/_html_helpers.py b/oceanarray/report/_html_helpers.py
index 8d97a0c..b8a4f33 100644
--- a/oceanarray/report/_html_helpers.py
+++ b/oceanarray/report/_html_helpers.py
@@ -129,7 +129,7 @@ def _check_readable(file_path: Path, file_type: str) -> Tuple[bool, str]:
return True, "ok (no .hdr found; may fail)"
return True, "ok"
- elif file_type == "nortek-csv":
+ elif file_type == "nortek-csv-oa":
with open(file_path, "r", errors="ignore") as f:
first = f.readline()
if ";" in first:
@@ -187,7 +187,7 @@ def _check_readable(file_path: Path, file_type: str) -> Tuple[bool, str]:
}
-def _fig_to_base64(fig) -> str:
+def _fig_to_base64(fig: Any) -> str:
buf = io.BytesIO()
fig.savefig(buf, format="png", dpi=110, bbox_inches="tight")
buf.seek(0)
@@ -249,7 +249,7 @@ def _read_nc_metadata(nc_path: Path) -> Dict[str, Any]:
scalar_vars.append(info)
else:
info["dims"] = ", ".join(str(d) for d in v.dims)
- info["n"] = v.shape[0] if v.shape else 0
+ info["n"] = int(np.prod(v.shape)) if v.shape else 0
arr = v.values
if arr.dtype.kind in ("f", "c"):
info["n_valid"] = int(np.sum(np.isfinite(arr)))
diff --git a/oceanarray/report/_plots.py b/oceanarray/report/_plots.py
index 8ea6231..199e7a5 100644
--- a/oceanarray/report/_plots.py
+++ b/oceanarray/report/_plots.py
@@ -3,7 +3,11 @@
from __future__ import annotations
from pathlib import Path
-from typing import List, Optional, Tuple
+from typing import TYPE_CHECKING, List, Optional, Tuple
+
+if TYPE_CHECKING:
+ import matplotlib.pyplot as plt
+ import xarray as xr
import numpy as np
diff --git a/oceanarray/report/_stack.py b/oceanarray/report/_stack.py
index 3bfbb12..fb00033 100644
--- a/oceanarray/report/_stack.py
+++ b/oceanarray/report/_stack.py
@@ -7,7 +7,7 @@
import numpy as np
-from ._html_helpers import _fig_to_base64, _parse_history, _status
+from ._html_helpers import _fig_to_base64, _parse_history, _read_nc_metadata, _status
from ._plots import _make_rose_grid_b64, _make_stack_ts_diagram
from .. import parameters as P
@@ -59,6 +59,10 @@
.history-list li:last-child { border-bottom:none; }
.history-ts { color:var(--muted); white-space:nowrap; font-size:0.76rem; min-width:11rem; }
.history-text { flex:1; }
+ td.num { text-align:right; font-variant-numeric:tabular-nums; }
+ td.mono { font-family:monospace; font-size:0.8rem; }
+ .none-note { color:var(--muted); font-style:italic; }
+ .var-qc { color:var(--good); font-size:0.78rem; }
@media print { .masthead { -webkit-print-color-adjust:exact; print-color-adjust:exact; } }
@@ -85,7 +89,7 @@
{% if fig_ts_stack_b64 %}T-S diagram{% endif %}
{% if fig_rose_grid_b64 %}Current roses{% endif %}
{% if fig_spacing_b64 %}Spacing{% endif %}
- {% if var_table %}Variables{% endif %}
+ Variables
@@ -190,24 +194,71 @@
{% endif %}
-
-{% if var_table %}
-Variables in file
-
- | Variable | Long name | Units | Coverage |
+
+NetCDF variables — {{ nc_file }}
+{% if nc_meta.get("error") %}
+Could not read file: {{ nc_meta.error }}
+{% else %}
+
+Variables
+
+
+ | Variable | Dims | N | Valid | Units | Long name | Standard name | QC flag |
+
- {% for v in var_table %}
-
- {{ v.name }} |
- {{ v.long_name }} |
- {{ v.units }} |
- {{ v.coverage }} |
-
- {% endfor %}
+ {% for v in nc_meta.time_vars %}
+ {% if not v.is_qc %}
+
+ | {{ v.name }} |
+ {{ v.dims }} |
+ {{ "{:,}".format(v.n) }} |
+ {{ "{:,}".format(v.n_valid) if v.n_valid is defined else "—" }} |
+ {{ v.units }} |
+ {{ v.long_name }} |
+ {{ v.standard_name }} |
+ {% if v.has_qc %}✓{% else %}–{% endif %} |
+
+ {% endif %}
+ {% endfor %}
+
+
+
+{% if nc_meta.scalar_vars %}
+Scalar metadata variables
+
+
+ | Variable | Value | Units | Long name |
+
+
+ {% for v in nc_meta.scalar_vars %}
+
+ | {{ v.name }} |
+ {{ v.value }} |
+ {{ v.units }} |
+ {{ v.long_name }} |
+
+ {% endfor %}
+
+
+{% endif %}
+
+{% if nc_meta.global_attrs %}
+Global attributes
+
+ | Attribute | Value |
+
+ {% for k, v in nc_meta.global_attrs.items() %}
+
+ | {{ k }} |
+ {{ v }} |
+
+ {% endfor %}
{% endif %}
+{% endif %}
+
@@ -443,24 +494,6 @@ def generate_stack_page(
}
)
- var_table = []
- for vname in ds.data_vars:
- da_v = ds[vname]
- if ds[vname].dims != ("N_LEVELS", "time"):
- continue
- n_total = da_v.size
- n_valid = int(np.sum(np.isfinite(da_v.values)))
- pct_num = round(100 * n_valid / n_total) if n_total > 0 else 0
- var_table.append(
- {
- "name": vname,
- "long_name": da_v.attrs.get("long_name", ""),
- "units": da_v.attrs.get("units", ""),
- "coverage": f"{pct_num}%" if n_total > 0 else "—",
- "pct_num": pct_num,
- }
- )
-
plt.style.use(str(P.MPLSTYLE))
_serial_list = list(serials)
@@ -594,6 +627,7 @@ def _ts_fig(
ds.close()
+ nc_meta = _read_nc_metadata(stack_path)
grid_exists = (stack_path.parent / f"{mooring_name}_grid.nc").exists()
from jinja2 import Environment
@@ -610,7 +644,8 @@ def _ts_fig(
grid_exists=grid_exists,
history_entries=stack_history,
instr_rows=instr_rows,
- var_table=var_table,
+ nc_meta=nc_meta,
+ nc_file=stack_path.name,
fig_pressure_b64=fig_pressure_b64,
fig_temp_b64=fig_temp_b64,
fig_sal_b64=fig_sal_b64,
diff --git a/oceanarray/stage1.py b/oceanarray/stage1.py
index a965618..e8d7b4f 100644
--- a/oceanarray/stage1.py
+++ b/oceanarray/stage1.py
@@ -8,6 +8,7 @@
import logging
+import xarray as xr
import yaml
import seasenselib
from seasenselib.writers import NetCdfWriter
@@ -54,7 +55,7 @@ def _parse_nortek_coord_system(hdr_path: Path) -> str:
m = re.match(r"^\s*Coordinate system\s{2,}(\S+)", line, re.IGNORECASE)
if m:
return m.group(1).strip().upper()
- except Exception:
+ except Exception: # noqa: BLE001 — intentional broad catch at I/O boundary
pass
return "ENU"
@@ -95,7 +96,7 @@ def _parse_nortek_T_matrix_hdr(hdr_path: Path) -> Optional[Dict[str, float]]:
if len(floats) >= 9:
keys = ["M11", "M12", "M13", "M21", "M22", "M23", "M31", "M32", "M33"]
return {k: floats[i] for i, k in enumerate(keys)}
- except Exception:
+ except Exception: # noqa: BLE001 — intentional broad catch at I/O boundary
pass
return None
@@ -171,13 +172,15 @@ class MooringProcessor:
# Supported format keys for seasenselib.read() or internal readers
SUPPORTED_FILE_TYPES = {
"sbe-cnv",
- "sbe-asc", # legacy seasenselib ASCII reader
"sbe-ascii", # newer seasenselib ASCII reader (with date normalisation)
"nortek-aqd",
"nortek-ascii",
- "nortek-csv",
+ "nortek-csv", # seasenselib reader (future; not yet implemented)
+ "nortek-csv-oa", # DEPRECATED: internal oceanarray CSV reader; use nortek-csv once seasenselib supports it
"rbr-rsk",
+ "rbr-matlab-legacy",
"rbr-dat",
+ "sbe-hex",
}
# Variables to remove for specific file types
@@ -194,7 +197,7 @@ class MooringProcessor:
"sbe-ascii": ["depth", "latitude", "longitude"],
}
- def __init__(self, base_dir: str):
+ def __init__(self, base_dir: str) -> None:
"""Initialize processor with base directory."""
self.base_dir = Path(base_dir)
self.log_file = None
@@ -219,7 +222,7 @@ def _setup_logging(self, mooring_name: str, output_path: Path) -> None:
fh.setFormatter(logging.Formatter("%(name)s %(levelname)s %(message)s"))
lib_log.addHandler(fh)
- def _log_print(self, *args, **kwargs) -> None:
+ def _log_print(self, *args: Any, **kwargs: Any) -> None:
"""Print to both console and log file."""
print(*args, **kwargs)
if self.log_file:
@@ -247,7 +250,7 @@ def _normalize_sbe_ascii(self, file_path: Path) -> Path:
if not needs_fix:
return file_path
- def _replace_date(m):
+ def _replace_date(m: re.Match) -> str:
mm, dd, yyyy = m.group(1), m.group(2), m.group(3)
return f"{dd} {MONTHS[mm]} {yyyy}"
@@ -261,19 +264,49 @@ def _replace_date(m):
def _read_file(
self, file_type: str, file_path: str, header_path: Optional[str] = None
- ):
- """Read a data file via seasenselib or an internal reader."""
+ ) -> xr.Dataset:
+ """Read a raw instrument file and return a normalised xarray Dataset.
+
+ Routes to the deprecated internal reader for ``nortek-csv-oa`` (with a
+ deprecation warning), or to ``seasenselib.read`` for all other types.
+ For Nortek formats the coordinate system is parsed from *header_path* and
+ ``_normalize_nortek_variables`` is called. For ``sbe-hex``, the seasenselib
+ output variable ``press`` is renamed to ``pressure`` for consistency.
+
+ Parameters
+ ----------
+ file_type:
+ One of ``SUPPORTED_FILE_TYPES`` (e.g. ``"nortek-aqd"``, ``"sbe-cnv"``).
+ file_path:
+ Path to the raw instrument file.
+ header_path:
+ Path to the companion header file (required for Nortek formats to read
+ the coordinate system and transformation matrix).
+
+ Returns
+ -------
+ xarray.Dataset
+
+ """
if file_type not in self.SUPPORTED_FILE_TYPES:
raise ValueError(f"Unknown file type: {file_type}")
- if file_type == "nortek-csv":
+ if file_type == "nortek-csv-oa":
+ import warnings
+
+ warnings.warn(
+ "file_type 'nortek-csv-oa' is deprecated. "
+ "Use 'nortek-csv' once seasenselib supports Nortek CSV export files.",
+ DeprecationWarning,
+ stacklevel=4,
+ )
from .readers import load_nortek_csv
ds = load_nortek_csv(file_path, header_file=header_path)
coord_system = ds.attrs.get("coordinate_system")
if coord_system is None:
print(
- f"WARNING: nortek-csv file {file_path} has no coordinate_system "
+ f"WARNING: nortek-csv-oa file {file_path} has no coordinate_system "
"attribute — coordinate system unknown (UNK); velocities not renamed."
)
coord_system = "UNK"
@@ -299,9 +332,17 @@ def _read_file(
)
coord_system = "UNK"
ds = self._normalize_nortek_variables(ds, coord_system=coord_system)
+ if (
+ file_type == "sbe-hex"
+ and "press" in ds.data_vars
+ and "pressure" not in ds.data_vars
+ ):
+ ds = ds.rename_vars({"press": "pressure"})
return ds
- def _normalize_nortek_variables(self, dataset, coord_system: str = "ENU"):
+ def _normalize_nortek_variables(
+ self, dataset: xr.Dataset, coord_system: str = "ENU"
+ ) -> xr.Dataset:
"""Normalise variable names produced by the seasenselib nortek readers.
The Nortek .hdr file describes columns for both the main .dat file and the
@@ -442,7 +483,7 @@ def _normalize_nortek_variables(self, dataset, coord_system: str = "ENU"):
dataset = dataset.drop_vars(_norm_drop)
# ── 6. CSV format: demote constant time-series to attrs; drop scaffolding ──
- # The nortek-csv reader stores deployment-config columns as full time series
+ # The nortek-csv-oa reader stores deployment-config columns as full time series
# even though they never change. Promote them to global attrs and drop.
_CSV_TO_ATTR = {
"coordinatesystem": "nortek_coordinate_system",
@@ -484,8 +525,8 @@ def _normalize_nortek_variables(self, dataset, coord_system: str = "ENU"):
return dataset
def _add_sbe_ascii_sensor_vars(
- self, dataset, file_path: Path, instrument_config: Dict[str, Any]
- ):
+ self, dataset: xr.Dataset, file_path: Path, instrument_config: Dict[str, Any]
+ ) -> xr.Dataset:
"""Parse calibration coefficients from an SBE ASCII header and add SENSOR_* vars.
seasenselib's sbe-ascii reader discards the ``* TA0 = …`` header lines.
@@ -543,7 +584,7 @@ def _extract_date(header: str, pat: str) -> str:
def _coeff_str(d: Dict[str, str]) -> str:
return ", ".join(f"{k}={v}" for k, v in d.items())
- def _make_sensor_var(name: str, attrs: Dict[str, Any]) -> xr.Variable:
+ def _make_sensor_var(name: str, attrs: Dict[str, Any]) -> xr.Variable: # noqa: ARG001
return xr.Variable((), np.array(b"", dtype="|S1"), attrs=attrs)
# Temperature
@@ -620,7 +661,7 @@ def _make_sensor_var(name: str, attrs: Dict[str, Any]) -> xr.Variable:
return dataset
- def _normalize_conductivity(self, dataset):
+ def _normalize_conductivity(self, dataset: xr.Dataset) -> xr.Dataset:
"""Convert conductivity to mS/cm and rename to 'conductivity' if needed."""
if "cond0S/m" in dataset:
# S/m → mS/cm: multiply by 10
@@ -633,7 +674,48 @@ def _normalize_conductivity(self, dataset):
dataset = dataset.rename({"cond0mS/cm": "conductivity"})
return dataset
- def _normalize_sensor_var_names(self, dataset, instrument_config: Dict[str, Any]):
+ # Known SBE CNV variable names containing '/' and their CF-compatible replacements.
+ # Keys that are already handled by _normalize_conductivity are excluded.
+ _CNV_SLASH_RENAMES: Dict[str, str] = {
+ "sbeopoxMm/Kg": "dissolved_oxygen", # SBE63 optode O2 in mmol/kg
+ "sbeox0Mm/Kg": "dissolved_oxygen", # SBE43 electrochemical O2 mmol/kg
+ "sbeox0ML/L": "dissolved_oxygen", # SBE43 electrochemical O2 mL/L
+ "sbeox1Mm/Kg": "dissolved_oxygen_2",
+ "sbeox1ML/L": "dissolved_oxygen_2",
+ "oxsatMm/Kg": "oxygen_saturation",
+ "oxsatML/L": "oxygen_saturation",
+ }
+
+ def _sanitize_slash_vars(self, dataset: xr.Dataset) -> xr.Dataset:
+ """Rename known SBE slash-named variables and warn about any remaining ones."""
+ rename_map: Dict[str, str] = {}
+ for old, new in self._CNV_SLASH_RENAMES.items():
+ if old in dataset.data_vars:
+ # Avoid clobbering a variable that already exists with the target name
+ target = new
+ if target in dataset.data_vars:
+ target = f"{new}_raw"
+ rename_map[old] = target
+
+ if rename_map:
+ dataset = dataset.rename_vars(rename_map)
+
+ # Fallback: any remaining vars with '/' are not safe for NetCDF — replace with '_per_'
+ remaining = [v for v in dataset.data_vars if "/" in v]
+ if remaining:
+ fallback = {v: v.replace("/", "_per_") for v in remaining}
+ self._log_print(
+ f"WARNING: Unrecognised variable name(s) containing '/': "
+ f"{', '.join(remaining)} — sanitising to: "
+ f"{', '.join(fallback.values())}"
+ )
+ dataset = dataset.rename_vars(fallback)
+
+ return dataset
+
+ def _normalize_sensor_var_names(
+ self, dataset: xr.Dataset, instrument_config: Dict[str, Any]
+ ) -> xr.Dataset:
"""Rename SENSOR_PRES_{sensor_serial} to SENSOR_PRES_{instrument_serial}.
seasenselib names the pressure sensor variable after the pressure sensor's
@@ -654,7 +736,9 @@ def _normalize_sensor_var_names(self, dataset, instrument_config: Dict[str, Any]
dataset = dataset.rename_vars(to_rename)
return dataset
- def _clean_dataset_variables(self, dataset, file_type: str):
+ def _clean_dataset_variables(
+ self, dataset: xr.Dataset, file_type: str
+ ) -> xr.Dataset:
"""Remove unwanted variables and coordinates from dataset."""
# Remove variables
vars_to_remove = self.VARS_TO_REMOVE.get(file_type, [])
@@ -672,7 +756,9 @@ def _clean_dataset_variables(self, dataset, file_type: str):
return dataset
- def _add_global_attributes(self, dataset, yaml_data: Dict[str, Any]):
+ def _add_global_attributes(
+ self, dataset: xr.Dataset, yaml_data: Dict[str, Any]
+ ) -> xr.Dataset:
"""Add global attributes from YAML configuration."""
global_attrs = {
"mooring_name": yaml_data["name"],
@@ -695,15 +781,19 @@ def _add_global_attributes(self, dataset, yaml_data: Dict[str, Any]):
return dataset
def _add_instrument_metadata(
- self, dataset, instrument_config: Dict[str, Any], yaml_data: Dict[str, Any]
- ):
+ self,
+ dataset: xr.Dataset,
+ instrument_config: Dict[str, Any],
+ yaml_data: Dict[str, Any],
+ ) -> xr.Dataset:
"""Add instrument-specific metadata to dataset."""
dataset["serial_number"] = instrument_config.get("serial", 0)
# Support both 'depth' (absolute) and 'hab' (height above bottom)
if "depth" in instrument_config:
depth = instrument_config["depth"]
elif "hab" in instrument_config:
- depth = yaml_data.get("waterdepth", 0) - instrument_config["hab"]
+ waterdepth = yaml_data.get("waterdepth") or 0
+ depth = waterdepth - instrument_config["hab"]
else:
depth = 0
dataset["InstrDepth"] = depth
@@ -791,7 +881,15 @@ def _process_instrument(
mooring_name: str,
force: bool = False,
) -> bool:
- """Process a single instrument's data."""
+ """Process one instrument entry from the mooring YAML.
+
+ Resolves input and output file paths, skips silently if the output
+ already exists and *force* is False, then delegates to
+ ``_read_and_write_file``. Catches all exceptions and logs them so
+ that one failed instrument does not abort the whole mooring.
+
+ Returns True on success or skip, False on error or missing filename.
+ """
if "filename" not in instrument_config:
instrument_name = instrument_config.get("instrument", "unknown")
serial = instrument_config.get("serial", "unknown")
@@ -851,7 +949,28 @@ def _read_and_write_file(
yaml_data: Dict[str, Any],
input_dir: Path,
) -> bool:
- """Read data file and write to NetCDF."""
+ """Read one raw instrument file, enrich the dataset, and write Stage 1 NetCDF.
+
+ Steps applied in order:
+
+ 1. Resolve Nortek header path (``header_file`` / ``header`` YAML key).
+ 2. Pre-normalise ``sbe-ascii`` date format if required.
+ 3. Read data via ``_read_file``.
+ 4. For Nortek: store pressure-calibration coefficients and T-matrix as
+ global attributes; if T-matrix is available and instrument reports in
+ BEAM coordinates, apply BEAM→XYZ in stage1 (velocity_x/y/z added;
+ beam velocities retained for verification).
+ 5. For ``sbe-ascii``: inject SENSOR_* calibration variables from the CNV
+ header (seasenselib discards the header section).
+ 6. Normalise conductivity units; sanitise slash characters in variable
+ names (NetCDF-illegal); fix ``"db"`` → ``"dbar"`` pressure units.
+ 7. Annotate ITS-90 temperature scale for ``sbe-ascii`` and ``sbe-hex``.
+ 8. Normalise SENSOR_PRES variable naming; clean dataset variables.
+ 9. Add global attributes and per-instrument metadata (depth, serial, etc.).
+ 10. Write to NetCDF via ``NetCdfWriter``.
+
+ Returns True on success, False if the file could not be read.
+ """
# Get header file for Nortek instruments ('header_file' preferred, 'header' accepted)
header_file = None
header_key = instrument_config.get("header_file") or instrument_config.get(
@@ -955,6 +1074,9 @@ def _read_and_write_file(
# Normalize conductivity units and name before cleaning
dataset = self._normalize_conductivity(dataset)
+ # Rename any remaining SBE CNV variable names that contain '/' (NetCDF-illegal)
+ dataset = self._sanitize_slash_vars(dataset)
+
# SeaBird CNV files use "db" (decibars) instead of the CF-standard "dbar".
for pvar in [
v for v in dataset.data_vars if v == "pressure" or v.startswith("pressure")
@@ -964,7 +1086,7 @@ def _read_and_write_file(
# sbe-ascii outputs ITS-90 temperature; seasenselib CNV files preserve
# this in cnv_original_unit, but the ASCII reader doesn't — add it here.
- if file_type == "sbe-ascii" and "temperature" in dataset.data_vars:
+ if file_type in ("sbe-ascii", "sbe-hex") and "temperature" in dataset.data_vars:
dataset["temperature"].attrs.setdefault("scale", "ITS-90")
# Rename SENSOR_PRES_{sensor_serial} → SENSOR_PRES_{instrument_serial}
diff --git a/oceanarray/stage2.py b/oceanarray/stage2.py
index 14935a8..deddf3e 100644
--- a/oceanarray/stage2.py
+++ b/oceanarray/stage2.py
@@ -68,7 +68,7 @@ def _parse_clock_str(s: str) -> Optional[pd.Timestamp]:
s = f"{s[:4]}-{s[4:6]}-{s[6:8]}T{s[9:]}"
try:
return pd.Timestamp(s)
- except Exception:
+ except Exception: # noqa: BLE001 — returns None for any unparseable timestamp string
return None
@@ -85,7 +85,7 @@ def _append_history(dataset: xr.Dataset, note: str) -> None:
class Stage2Processor:
"""Handles Stage 2 processing: clock correction and temporal trimming."""
- def __init__(self, base_dir: str):
+ def __init__(self, base_dir: str) -> None:
"""Initialize processor with base directory."""
self.base_dir = Path(base_dir)
self.log_file = None
@@ -96,7 +96,7 @@ def _setup_logging(self, mooring_name: str, output_path: Path) -> None:
self.log_file = setup_stage_logging(mooring_name, "stage2", output_path)
- def _log_print(self, *args, **kwargs) -> None:
+ def _log_print(self, *args: Any, **kwargs: Any) -> None:
"""Print to both console and log file."""
print(*args, **kwargs)
if self.log_file:
@@ -115,7 +115,7 @@ def _read_yaml_time(self, data: Dict[str, Any], key: str) -> np.datetime64:
return np.datetime64("NaT", "ns")
try:
return pd.to_datetime(val).to_datetime64()
- except Exception:
+ except Exception: # noqa: BLE001 — returns NaT for any unparseable value
return np.datetime64("NaT", "ns")
def _preserve_time_orig(self, dataset: xr.Dataset) -> xr.Dataset:
@@ -450,14 +450,26 @@ def _get_netcdf_writer_params(self) -> Dict[str, Any]:
def _process_instrument(
self,
instrument_config: Dict[str, Any],
- mooring_config: Dict[str, Any],
+ mooring_config: Dict[str, Any], # noqa: ARG002 — reserved for future per-mooring overrides
proc_dir: Path,
mooring_name: str,
deploy_time: np.datetime64,
recover_time: np.datetime64,
force: bool = False,
) -> bool:
- """Process a single instrument's Stage 1 output."""
+ """Apply clock corrections and deployment trimming to one instrument.
+
+ Reads the Stage 1 NetCDF for *instrument_config*, applies (in order):
+ constant clock offset, linear clock drift, and trimming to the
+ deployment window [*deploy_time*, *recover_time*]. Writes the result
+ as ``{mooring}_{serial}_stage2.nc`` in the same directory.
+
+ Skips silently if the Stage 1 file does not exist (not yet staged) or
+ if the Stage 2 output already exists and *force* is False.
+
+ Returns True on success or skip, False if the Stage 1 file is missing
+ or an error occurs.
+ """
import re
serial = re.sub(r"[^\w\-]", "", str(instrument_config.get("serial", "unknown")))
@@ -544,11 +556,11 @@ def _process_instrument(
relative_use = use_filepath.relative_to(self.base_dir)
_status("file", str(relative_use))
- return True
- except Exception as e:
+ except Exception as e: # noqa: BLE001 — intentional broad catch at I/O boundary
self._log_print(f"ERROR processing {instrument_type} {serial}: {e}")
return False
+ return True
def process_mooring(
self,
@@ -563,6 +575,7 @@ def process_mooring(
mooring_name: Name of the mooring to process
output_path: Optional custom output path
serials: Optional list of serial numbers to process; if None, process all.
+ force: Re-process even if Stage 2 output already exists.
Returns:
bool: True if processing completed successfully
@@ -594,7 +607,7 @@ def process_mooring(
try:
mooring_config = self._load_mooring_config(config_file)
- except Exception as e:
+ except Exception as e: # noqa: BLE001 — intentional broad catch at I/O boundary
self._log_print(f"ERROR: Failed to load configuration: {e}")
return False
diff --git a/oceanarray/stage3.py b/oceanarray/stage3.py
index 495b4de..93c9430 100644
--- a/oceanarray/stage3.py
+++ b/oceanarray/stage3.py
@@ -110,7 +110,8 @@ def _apply_beam_to_enu(
entry: Dict[str, Any],
lat: float,
lon: float,
- log_fn=None,
+ latlon_source: str = "unknown",
+ log_fn: Any = None,
) -> "xr.Dataset":
"""Transform BEAM or XYZ Nortek velocities to ENU geographic coordinates.
@@ -121,7 +122,7 @@ def _apply_beam_to_enu(
if these are absent.
"""
- def _warn(msg):
+ def _warn(msg: str) -> None:
if log_fn:
log_fn(msg)
else:
@@ -163,8 +164,13 @@ def _warn(msg):
ds.attrs["magnetic_declination"] = declination
ds.attrs["magnetic_declination_units"] = "degrees_east"
ds.attrs["magnetic_declination_method"] = "ppigrf IGRF at deployment midpoint"
+ ds.attrs["magnetic_declination_lat"] = lat
+ ds.attrs["magnetic_declination_lon"] = lon
+ ds.attrs["magnetic_declination_latlon_source"] = (
+ f"mooring YAML ({latlon_source})"
+ )
_warn(f" BEAM→ENU: magnetic declination = {declination:.2f}°")
- except Exception as e:
+ except Exception as e: # noqa: BLE001 — ppigrf optional; proceed with 0° declination
_warn(f" WARNING: magnetic declination unavailable ({e}) — using 0°")
if coord_sys == "BEAM":
@@ -546,7 +552,7 @@ def _apply_tilt_qc(
def _ensure_conductivity_units(
ds: xr.Dataset,
- log_fn=None,
+ log_fn: Any = None,
) -> xr.Dataset:
"""Convert conductivity from S/m → mS/cm if needed.
@@ -571,7 +577,7 @@ def _ensure_conductivity_units(
def _compute_salinity_data(
ds: xr.Dataset,
- log_fn=None,
+ log_fn: Any = None,
) -> xr.Dataset:
"""Compute Practical Salinity (SP) data values only — no QC flags yet.
@@ -789,7 +795,7 @@ def _apply_enu_velocity_qc(
class Stage3Processor:
"""Pressure interpolation + QARTOD QC for all mooring instruments."""
- def __init__(self, base_dir: str):
+ def __init__(self, base_dir: str) -> None:
self.base_dir = Path(base_dir)
self.log_file = None
@@ -798,7 +804,7 @@ def _setup_logging(self, mooring_name: str, output_path: Path) -> None:
self.log_file = setup_stage_logging(mooring_name, "stage3", output_path)
- def _log(self, *args, **kwargs) -> None:
+ def _log(self, *args: Any, **kwargs: Any) -> None:
print(*args, **kwargs)
if self.log_file:
try:
@@ -822,6 +828,7 @@ def process_mooring(
force: bool = False,
dry_run: bool = False,
) -> bool:
+ """Run Stage 3 QC and pressure interpolation for all instruments on a mooring."""
proc_dir = self._get_proc_dir(mooring_name)
if not proc_dir.exists():
print(f"ERROR: Processing directory not found: {proc_dir}")
@@ -844,9 +851,11 @@ def process_mooring(
)
# ── Mooring location for BEAM→ENU declination ──────────────────
- from .mooring_level import _parse_latlon
+ from .mooring_level import _parse_latlon_with_source
- _mooring_lat, _mooring_lon = _parse_latlon(mooring_config)
+ _mooring_lat, _mooring_lon, _latlon_source = _parse_latlon_with_source(
+ mooring_config
+ )
# ── Build instrument table ──────────────────────────────────────
instruments: List[Dict[str, Any]] = []
@@ -881,6 +890,7 @@ def process_mooring(
"entry": entry,
"lat": _mooring_lat,
"lon": _mooring_lon,
+ "latlon_source": _latlon_source,
}
)
@@ -914,7 +924,7 @@ def _find_pressure_var(data_vars: set) -> Optional[str]:
try:
with xr.open_dataset(info["nc_path"], decode_timedelta=False) as _ds:
info["data_vars"] = set(_ds.data_vars)
- except Exception as e:
+ except Exception as e: # noqa: BLE001 — missing file skipped; mooring continues
self._log(f" WARNING: Could not open {info['nc_path'].name}: {e}")
info["data_vars"] = set()
info["pressure_var"] = _find_pressure_var(info["data_vars"])
@@ -929,7 +939,9 @@ def _find_pressure_var(data_vars: set) -> Optional[str]:
f"not found in {info['nc_path'].name} — ignored"
)
- pressure_bad = lambda info: info["qc_flags"].get("pressure", 0) >= 3
+ def pressure_bad(info: Dict[str, Any]) -> bool:
+ return info["qc_flags"].get("pressure", 0) >= 3
+
sources = [i for i in instruments if i["has_pressure"] and not pressure_bad(i)]
targets = [i for i in instruments if not i["has_pressure"] or pressure_bad(i)]
@@ -977,7 +989,7 @@ def _find_pressure_var(data_vars: set) -> Optional[str]:
src["ds"] = xr.open_dataset(
src["nc_path"], decode_timedelta=False
).load()
- except Exception as e:
+ except Exception as e: # noqa: BLE001 — one bad source must not abort pressure interp
self._log(
f" WARNING: Could not load source {src['nc_path'].name}: {e}"
)
@@ -1018,6 +1030,26 @@ def _process_instrument(
qc_attrs: Dict[str, Any],
force: bool = False,
) -> bool:
+ """Apply Stage 3 processing to one instrument's Stage 2 NetCDF.
+
+ Steps applied (where applicable to the instrument type):
+
+ 1. Pressure interpolation — targets without a reliable pressure sensor
+ have pressure interpolated from *sources* (neighbours on the mooring).
+ 2. Conductivity unit normalisation and practical salinity computation
+ (CTD/microcat instruments only).
+ 3. QARTOD QC tests (gross-range, spike) using thresholds from *qc_attrs*.
+ 4. BEAM→ENU or XYZ→ENU coordinate transformation with magnetic declination
+ correction (current meters / Aquadopp).
+ 5. Tilt QC — velocity flagged suspect/bad when pitch or roll exceed
+ configured thresholds.
+ 6. History attribute updated with all processing steps applied.
+
+ Writes ``{mooring}_{serial}_stage3.nc`` alongside the Stage 2 file.
+ Skips if the output already exists and *force* is False.
+
+ Returns True on success or skip, False on error.
+ """
nc_path = info["nc_path"]
serial = info["serial"]
l3_path = nc_path.with_name(nc_path.name.replace("_stage2.nc", "_stage3.nc"))
@@ -1071,7 +1103,12 @@ def _process_instrument(
# Must run before tilt QC so east/north/up_velocity exist to be flagged.
coord_sys_before = ds.attrs.get("nortek_coordinate_system", "ENU")
ds = _apply_beam_to_enu(
- ds, info["entry"], info["lat"], info["lon"], log_fn=self._log
+ ds,
+ info["entry"],
+ info["lat"],
+ info["lon"],
+ latlon_source=info.get("latlon_source", "unknown"),
+ log_fn=self._log,
)
# ── ENU velocity QC + up→east/north flag propagation ──────
@@ -1144,14 +1181,14 @@ def _process_instrument(
self._log(
f" Creating output file: {l3_path.name} ({'; '.join(qc_summary)})"
)
- return True
- except Exception as e:
+ except Exception as e: # noqa: BLE001 — log and continue; one instrument must not abort mooring
self._log(f" ERROR processing {serial}: {e}")
import traceback
self._log(traceback.format_exc())
return False
+ return True
# ------------------------------------------------------------------
def _interpolate_pressure(
diff --git a/oceanarray/time_gridding.py b/oceanarray/time_gridding.py
index 7cd94b9..dfbb723 100644
--- a/oceanarray/time_gridding.py
+++ b/oceanarray/time_gridding.py
@@ -32,7 +32,7 @@
class TimeGriddingProcessor:
"""Handles Step 1 processing: time gridding and optional filtering of mooring instruments."""
- def __init__(self, base_dir: str):
+ def __init__(self, base_dir: str) -> None:
"""Initialize processor with base directory."""
self.base_dir = Path(base_dir)
self.log_file = None
@@ -43,7 +43,7 @@ def _setup_logging(self, mooring_name: str, output_path: Path) -> None:
self.log_file = setup_stage_logging(mooring_name, "time_gridding", output_path)
- def _log_print(self, *args, **kwargs) -> None:
+ def _log_print(self, *args: Any, **kwargs: Any) -> None:
"""Print to both console and log file."""
print(*args, **kwargs)
if self.log_file:
@@ -96,7 +96,7 @@ def _load_instrument_datasets(
datasets.append(ds)
found_instruments.append(f"{instrument_type}:{serial}")
- except Exception as e:
+ except Exception as e: # noqa: BLE001 — skip unreadable file; mooring continues
self._log_print(f"ERROR loading {filepath}: {e}")
missing_instruments.append(f"{instrument_type}:{serial}")
continue
@@ -308,14 +308,14 @@ def _apply_lowpass_filter(
)
self._log_print(" Successfully applied low-pass filter")
- return ds_filtered
except ImportError:
self._log_print(" ERROR: scipy not available for filtering")
return dataset
- except Exception as e:
+ except Exception as e: # noqa: BLE001 — filter failure must not abort processing
self._log_print(f" ERROR applying filter: {e}")
return dataset
+ return ds_filtered
def _filter_with_gaps(
self, data: np.ndarray, sos: np.ndarray, valid_mask: np.ndarray
@@ -339,8 +339,7 @@ def _filter_with_gaps(
try:
filtered_segment = signal.sosfiltfilt(sos, segment_data)
filtered_data[start:end] = filtered_segment
- except:
- # If filtering fails, keep original data
+ except Exception: # noqa: BLE001 — segment filter failure; keep original
filtered_data[start:end] = segment_data
else:
# Keep short segments unfiltered
@@ -359,7 +358,9 @@ def _apply_detiding_filter(
return self._apply_lowpass_filter(dataset, filter_params)
def _apply_bandpass_filter(
- self, dataset: xr.Dataset, filter_params: Optional[Dict[str, Any]] = None
+ self,
+ dataset: xr.Dataset,
+ filter_params: Optional[Dict[str, Any]] = None, # noqa: ARG002
) -> xr.Dataset:
"""Apply band-pass filter (future implementation)."""
self._log_print(" WARNING: Band-pass filtering not yet implemented")
@@ -435,7 +436,7 @@ def _analyze_timing_info(
end_times.append(end_time)
if not start_times:
- raise ValueError("No valid datasets with time information found")
+ raise ValueError("No valid datasets with time information found") # noqa: TRY003
earliest_start = min(start_times)
latest_end = max(end_times)
@@ -538,7 +539,7 @@ def _interpolate_datasets(
datasets_interp.append(ds_i)
self._log_print(f"Successfully interpolated dataset {idx}")
- except Exception as e:
+ except Exception as e: # noqa: BLE001 — skip one bad instrument; interpolation continues
self._log_print(f"ERROR interpolating dataset {idx}: {e}")
continue
@@ -575,7 +576,7 @@ def _merge_var_attrs(
def _create_combined_dataset(
self,
datasets_interp: List[xr.Dataset],
- time_grid: np.ndarray,
+ time_grid: np.ndarray, # noqa: ARG002 — reserved for future use (currently inferred from datasets_interp)
vars_to_keep: List[str] = None,
) -> xr.Dataset:
"""Combine interpolated datasets into single dataset with N_LEVELS dimension."""
@@ -590,13 +591,13 @@ def _create_combined_dataset(
]
if not datasets_interp:
- raise ValueError("No interpolated datasets provided")
+ raise ValueError("No interpolated datasets provided") # noqa: TRY003
time_coord = datasets_interp[0]["time"]
n_levels = len(datasets_interp)
# Helper functions
- def stacked_or_nan(var):
+ def stacked_or_nan(var: str) -> np.ndarray:
"""Stack variable across all datasets, filling with NaN if missing."""
arrs = []
for ds in datasets_interp:
@@ -791,7 +792,7 @@ def process_mooring(
try:
mooring_config = self._load_mooring_config(config_file)
- except Exception as e:
+ except Exception as e: # noqa: BLE001 — config parse failure must not crash mooring
self._log_print(f"ERROR: Failed to load configuration: {e}")
return False
@@ -855,11 +856,10 @@ def process_mooring(
self._log_print(f"Combined dataset shape: {dict(ds_to_save.sizes)}")
self._log_print(f"Variables: {list(ds_to_save.data_vars)}")
- return True
-
- except Exception as e:
+ except Exception as e: # noqa: BLE001 — log and report False; mooring run continues
self._log_print(f"ERROR during time gridding processing: {e}")
return False
+ return True
def time_gridding_mooring(
diff --git a/oceanarray/tools.py b/oceanarray/tools.py
index 992039b..53c53c6 100644
--- a/oceanarray/tools.py
+++ b/oceanarray/tools.py
@@ -1,3 +1,5 @@
+"""Science utility functions for oceanographic data analysis."""
+
import logging
import gsw
@@ -17,8 +19,7 @@ def lag_correlation(x, y, max_lag, min_overlap=10):
x = np.asarray(x, float)
y = np.asarray(y, float)
if x.shape != y.shape:
- raise ValueError("x and y must have same length (subsample both).")
- n = len(x)
+ raise ValueError("x and y must have same length (subsample both).") # noqa: TRY003
corrs = np.full(2 * max_lag + 1, np.nan)
for k, lag in enumerate(range(-max_lag, max_lag + 1)):
if lag < 0:
@@ -40,7 +41,7 @@ def lag_correlation(x, y, max_lag, min_overlap=10):
def split_value(data, nbins=30):
- # Example: Your 1D data array
+ """Find a histogram-based split value between two data modes."""
data = data[~np.isnan(data)] # Remove NaNs for histogram
# Step 1: Create histogram
counts, bins = np.histogram(data, bins=nbins)
@@ -65,7 +66,9 @@ def find_cold_entry_exit(
Parameters
----------
time : array-like of datetime64
+ Time coordinate array.
temp : array-like of float
+ Temperature values aligned with *time*.
quantile : float
Percentile for threshold (e.g. 0.1 ~ 10th percentile).
dwell_seconds : int
@@ -181,6 +184,7 @@ def flag_vertical_inconsistencies(ds, var="CNDC", threshold=2):
def run_qc(ds):
+ """Apply a sequence of QC tests and write combined CNDC_QC flag variable."""
if "PSAL" not in ds:
ds = calc_psal(ds)
@@ -324,9 +328,6 @@ def process_dataset(
)
standard_pressures = pgrid.flatten()
- # Tile standard pressures
- pressure_array = np.tile(standard_pressures, (temp_standard.shape[0], 1))
-
# Create ds_standard
ds_standard = xr.Dataset(
{
@@ -347,9 +348,9 @@ def process_dataset(
def calc_ds_difference(ds1, ds2):
- # Check that the time grids are the same
+ """Compute the variable-by-variable difference between two time-matched datasets."""
if not np.array_equal(ds1["TIME"].values, ds2["TIME"].values):
- raise ValueError("TIME grids do not match between datasets.")
+ raise ValueError("TIME grids do not match between datasets.") # noqa: TRY003
# Variables to exclude from differencing
exclude_vars = {"YY", "MM", "DD", "HH"}
diff --git a/oceanarray/utilities.py b/oceanarray/utilities.py
index 03367bb..194c8b7 100644
--- a/oceanarray/utilities.py
+++ b/oceanarray/utilities.py
@@ -1,19 +1,26 @@
-from oceanarray import logger
-
-log = logger.log
+"""Shared utility helpers used across the oceanarray processing pipeline."""
import math
from datetime import datetime
from functools import wraps
-from typing import Callable, List, Optional
+from typing import Any, Callable, List, Optional
import numpy as np
import xarray as xr
+from oceanarray import logger
+
+
+def concat_with_scalar_vars(
+ datasets: List[xr.Dataset],
+ dim: str,
+ scalar_vars: Optional[List[str]] = None,
+) -> xr.Dataset:
+ """Concatenate datasets along a dimension, preserving scalar variables.
-def concat_with_scalar_vars(datasets, dim, scalar_vars=None):
- """Concatenate a list of xarray Datasets along a given dimension,
- preserving scalar variables (0-D DataArrays) as scalars (not broadcast).
+ Scalar (0-D) variables would normally be broadcast to the concatenation
+ dimension by ``xr.concat``; this helper strips them beforehand and
+ re-attaches the first occurrence after the concat so they remain 0-D.
Parameters
----------
@@ -42,11 +49,11 @@ def concat_with_scalar_vars(datasets, dim, scalar_vars=None):
# Strip scalar variables and store them
cleaned = []
- for i, ds in enumerate(datasets):
+ for _i, ds in enumerate(datasets):
ds_copy = ds.copy()
for var in scalar_vars:
if var in ds_copy and ds_copy[var].ndim == 0:
- scalar_storage[(i, var)] = ds_copy[var]
+ scalar_storage[(_i, var)] = ds_copy[var]
del ds_copy[var]
cleaned.append(ds_copy)
@@ -54,14 +61,14 @@ def concat_with_scalar_vars(datasets, dim, scalar_vars=None):
combined = xr.concat(cleaned, dim=dim)
# Re-attach scalar variables as 0-D DataArrays
- for (i, var), da in scalar_storage.items():
+ for (_i, var), da in scalar_storage.items():
combined[var] = da # safe: 0-D, no coords
return combined
-def _check_necessary_variables(ds: xr.Dataset, vars: list):
- """Checks that all of a list of variables are present in a dataset.
+def _check_necessary_variables(ds: xr.Dataset, vars: list) -> None:
+ """Check that all required variables are present in a dataset.
Parameters
----------
@@ -86,7 +93,7 @@ def _check_necessary_variables(ds: xr.Dataset, vars: list):
raise KeyError(msg)
-def get_time_key(ds):
+def get_time_key(ds: xr.Dataset) -> str:
"""Return the name of the time coordinate or variable in an xarray.Dataset.
Parameters
@@ -128,11 +135,13 @@ def get_time_key(ds):
)
return name
- raise ValueError("No valid time coordinate found in dataset.")
+ raise ValueError("No valid time coordinate found in dataset.") # noqa: TRY003
-def get_dims(ds_gridded):
- """Helper function to extract pressure key, time key, and their respective dimensions from a dataset.
+def get_dims(
+ ds_gridded: xr.Dataset,
+) -> tuple:
+ """Extract pressure key, time key, and their dimensions from a dataset.
Parameters
----------
@@ -141,13 +150,13 @@ def get_dims(ds_gridded):
Returns
-------
- pres_key : str
- Key for the pressure variable.
+ pres_key : str or None
+ Key for the pressure (or depth) variable; None if not found.
time_key : str
Key for the time variable.
- pres_dim : str
- Dimension associated with the pressure variable.
- time_dim : str
+ pres_dim : str or None
+ Dimension associated with the pressure variable; None if not found.
+ time_dim : str or None
Dimension associated with the time variable.
"""
@@ -168,6 +177,8 @@ def get_dims(ds_gridded):
pres_key = name
break
+ # Fall back to depth if no pressure variable found
+ dpth_key = None
if pres_key is None:
depth_candidates = [
"DEPTH_ADJUSTED",
@@ -178,7 +189,7 @@ def get_dims(ds_gridded):
]
for name in depth_candidates:
if name in ds_gridded.coords or name in ds_gridded.variables:
- pres_key = name
+ dpth_key = name
break
# Get time key using helper
@@ -186,7 +197,7 @@ def get_dims(ds_gridded):
# Determine dimensions
time_dim = ds_gridded[time_key].dims[0] if ds_gridded[time_key].dims else None
- # Find pres_dim as the dimension of pres_key that is not the same as time_dim
+ pres_dim = None
if pres_key is None and dpth_key is not None:
pres_dims = ds_gridded[dpth_key].dims
if len(pres_dims) > 1:
@@ -201,10 +212,10 @@ def get_dims(ds_gridded):
else:
pres_dim = pres_dims[0]
- return pres_key, time_key, pres_dim, time_dim
+ return pres_key or dpth_key, time_key, pres_dim, time_dim
-def iso8601_duration_from_seconds(seconds):
+def iso8601_duration_from_seconds(seconds: float) -> str:
"""Convert a duration in seconds to an ISO 8601 duration string.
Parameters
@@ -229,8 +240,8 @@ def iso8601_duration_from_seconds(seconds):
return f"PT{seconds}S"
-def is_iso8601_utc(timestr):
- """Validate whether a string is in ISO8601 UTC format: YYYY-MM-DDTHH:MM:SSZ
+def is_iso8601_utc(timestr: str) -> bool:
+ """Validate whether a string is in ISO8601 UTC format: YYYY-MM-DDTHH:MM:SSZ.
Parameters
----------
@@ -245,17 +256,16 @@ def is_iso8601_utc(timestr):
"""
try:
datetime.strptime(timestr, "%Y/%m/%dT%H:%M:%SZ") # RODB-style
- return True
except ValueError:
try:
datetime.strptime(timestr, "%Y-%m-%dT%H:%M:%SZ") # ISO8601 style
- return True
except ValueError:
return False
+ return True
def apply_defaults(default_source: str, default_files: List[str]) -> Callable:
- """Decorator to apply default values for 'source' and 'file_list' parameters if they are None.
+ """Decorate a function to apply default values for source and file_list parameters.
Parameters
----------
@@ -276,14 +286,14 @@ def decorator(func: Callable) -> Callable:
def wrapper(
source: Optional[str] = None,
file_list: Optional[List[str]] = None,
- *args,
- **kwargs,
- ) -> Callable:
+ *args: Any,
+ **kwargs: Any,
+ ) -> Any:
if source is None:
source = default_source
if file_list is None:
file_list = default_files
- return func(source=source, file_list=file_list, *args, **kwargs)
+ return func(source=source, file_list=file_list, *args, **kwargs) # noqa: B026
return wrapper
diff --git a/oceanarray/validation.py b/oceanarray/validation.py
index 0f2c211..6ab725d 100644
--- a/oceanarray/validation.py
+++ b/oceanarray/validation.py
@@ -124,16 +124,15 @@
"sbe-hex",
"nortek-aqd",
"nortek-ascii",
- "nortek-csv",
+ "nortek-csv", # seasenselib reader (future)
+ "nortek-csv-oa", # DEPRECATED: internal oceanarray CSV reader
"rbr-rsk",
"rbr-dat",
"rbr-matlab",
"adcp-matlab",
}
-UNSUPPORTED_FILE_TYPES: Dict[str, str] = {
- "sbe-hex": "SBE hex format — not yet implemented in seasenselib",
-}
+UNSUPPORTED_FILE_TYPES: Dict[str, str] = {}
REQUIRED_MOORING_KEYS = ["name", "waterdepth", "deployment_time", "recovery_time"]
@@ -285,7 +284,7 @@ def validate_mooring_yaml(yaml_path: str) -> List[ValidationIssue]:
import pandas as _pd
_pd.Timestamp(entry[ts_key])
- except Exception:
+ except Exception: # noqa: BLE001 — report issue and continue; Timestamp parse errors vary
issues.append(
ValidationIssue(
"ERROR",
@@ -317,8 +316,8 @@ def validate_mooring_yaml(yaml_path: str) -> List[ValidationIssue]:
f"timestamp pair will be used (they agree to <1 s)",
)
)
- except Exception:
- pass # timestamp parse errors already reported above
+ except Exception: # noqa: BLE001 — parse errors already reported above; skip silently
+ pass
return issues
diff --git a/oceanarray/writers.py b/oceanarray/writers.py
index 41c39c6..ac8c080 100644
--- a/oceanarray/writers.py
+++ b/oceanarray/writers.py
@@ -31,7 +31,6 @@ def save_dataset(ds: xr.Dataset, output_file: str = "../test.nc") -> bool:
valid_types = (str, Number, np.ndarray, np.number, list, tuple)
try:
ds.to_netcdf(output_file, format="NETCDF4_CLASSIC")
- return True
except TypeError as e:
print(e.__class__.__name__, e)
for varname, variable in ds.variables.items():
@@ -43,8 +42,7 @@ def save_dataset(ds: xr.Dataset, output_file: str = "../test.nc") -> bool:
variable.attrs[k] = str(v)
try:
ds.to_netcdf(output_file, format="NETCDF4_CLASSIC")
- return True
- except Exception as e:
+ except Exception as e: # noqa: BLE001 — log failure details and report False; write errors vary
print("Failed to save dataset:", e)
datetime_vars = [
var for var in ds.variables if ds[var].dtype == "datetime64[ns]"
@@ -55,6 +53,7 @@ def save_dataset(ds: xr.Dataset, output_file: str = "../test.nc") -> bool:
]
print("Attributes with dtype float64:", float_attrs)
return False
+ return True
def save_OS_instrument(ds: xr.Dataset, data_dir: Path):
@@ -74,7 +73,7 @@ def save_OS_instrument(ds: xr.Dataset, data_dir: Path):
"""
if "id" not in ds.attrs:
- raise ValueError(
+ raise ValueError( # noqa: TRY003
"Global attribute 'id' not found. Cannot determine output filename."
)
diff --git a/pyproject.toml b/pyproject.toml
index 4af39ca..e698f00 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -66,7 +66,11 @@ line-length = 88
target-version = ['py39']
[tool.ruff]
-# …any other top‑level settings you use (extend, cache-dir, line-length, exclude, etc.)…
+exclude = [
+ "oceanarray/legacy", # legacy modules — not maintained to current standards
+ "notebooks", # notebooks are exploratory; not held to production lint standards
+ "docs", # Sphinx conf and vendored third-party code
+]
[tool.ruff.lint]
select = [
@@ -90,4 +94,29 @@ ignore = [
"E501", # line‑length
"PLR0913", # too many arguments
"C901", # complexity
+ "ANN401", # typing.Any is allowed — common in dataset/xarray helper signatures
]
+
+[tool.ruff.lint.per-file-ignores]
+"tests/**" = ["ANN", "D"] # tests don't need type annotations or docstrings
+# tools.py is being reorganised into analysis/ post-OdB; ANN/D deferred until then
+"oceanarray/tools.py" = ["ANN", "D205"]
+# The following files move to tools/ or pipeline/ post-OdB; ANN/D deferred until then
+"oceanarray/clock_offset.py" = ["ANN", "D"]
+"oceanarray/find_deployment.py" = ["ANN", "D"]
+"oceanarray/plotters.py" = ["ANN", "D"]
+"oceanarray/readers.py" = ["ANN", "D"]
+"oceanarray/writers.py" = ["ANN", "D"]
+# rapid_interp.py moves to legacy/ post-OdB; suppress everything until then
+"oceanarray/rapid_interp.py" = ["ANN", "D", "F841", "SLF001", "TRY003", "BLE001"]
+# Report generators: every function wraps rendering in try/except → None on failure.
+# This is the established pattern for keeping HTML reports partially working on bad data.
+"oceanarray/report/_plots.py" = ["BLE001", "TRY300"]
+"oceanarray/report/_html_helpers.py" = ["BLE001", "TRY300"]
+"oceanarray/report/_grid.py" = ["BLE001", "TRY300"]
+"oceanarray/report/_instrument.py" = ["BLE001", "TRY300", "ARG001"]
+"oceanarray/report/_mooring.py" = ["BLE001", "TRY003", "TRY300"]
+"oceanarray/report/_stack.py" = ["BLE001", "TRY300", "ARG001", "F841"]
+# Nortek header parsers use except Exception: pass to handle malformed/missing files.
+# Stage1 also has TRY003 for a single ValueError with a descriptive message.
+"oceanarray/stage1.py" = ["BLE001", "TRY003"]
diff --git a/tests/test_stage1.py b/tests/test_stage1.py
index 90949b5..9c6a502 100644
--- a/tests/test_stage1.py
+++ b/tests/test_stage1.py
@@ -83,7 +83,6 @@ def test_supported_file_types_completeness(self):
processor = MooringProcessor("/tmp")
expected_types = [
"sbe-cnv",
- "sbe-asc",
"sbe-ascii",
"nortek-aqd",
"rbr-rsk",