Source code for pelagos_py.utils.file_probe

# This file is part of pelagos_py.
#
# Copyright 2025-2026 National Oceanography Centre and The Contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#    http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Cheap metadata probe of an OG1 NetCDF file for the config builder: variable
names, units, which float variables are entirely NaN, and a median for a few
variables whose *values* decide how they must be treated (e.g. CNDC unit
mislabelling)."""

import functools
import json
import os
import subprocess
import sys
from pathlib import Path

import numpy as np

# Variables whose median is needed to tell a unit/naming problem from real data.
MEDIAN_VARIABLES = ("CNDC", "BBP700", "BETA_BACKSCATTERING700")

TIMEOUT = 30
CHUNK = 500_000
PACKED_ATTRS = ("scale_factor", "add_offset", "_FillValue", "missing_value")


[docs] def probe_file(file_path, logger=None): """``{variable: {"units", "numeric", "all_nan", "median"?}}`` for every variable in ``file_path``, or ``None`` if it can't be read. Cached on (path, mtime) so repeated validation of the same file is free.""" try: mtime = Path(file_path).stat().st_mtime except OSError as exc: if logger: logger.info("Could not read '%s': %s", file_path, exc) return None probe, error = _probe(str(file_path), mtime) if error and logger: logger.info("Could not read '%s' to inspect its variables: %s", file_path, error) return probe
@functools.lru_cache(maxsize=1) def _probe(file_path, mtime): # `mtime` is only part of the cache key, so an edited file is probed again. # Runs this file in a subprocess: some netCDF4/HDF5 + h5py combinations segfault # the whole interpreter on open, which try/except can't catch. # HDF5 file locking can transiently fail (Errno -101) if the load step # reopens the file right after this subprocess closes it. env = dict(os.environ, HDF5_USE_FILE_LOCKING="FALSE") cmd = [sys.executable, __file__, file_path, json.dumps(MEDIAN_VARIABLES)] try: result = subprocess.run(cmd, capture_output=True, text=True, timeout=TIMEOUT, env=env) except Exception as exc: return None, str(exc) if result.returncode != 0: stderr_lines = (result.stderr or "").strip().splitlines() return None, stderr_lines[-1] if stderr_lines else "unknown error" return json.loads(result.stdout), None
[docs] def present(probe, name): """Whether ``name`` is in the file with real (not all-NaN) data.""" info = (probe or {}).get(name) return bool(info) and not info["all_nan"]
# The subprocess side, run as `python file_probe.py <path> <median_vars>`. def _chunks(v): # Read in slices so a variable with real data settles on its first one. if v.ndim == 0: yield np.ma.filled(np.ma.asarray(v[...]).astype(float), np.nan).ravel() return for start in range(0, v.shape[0], CHUNK): yield np.ma.filled(np.ma.asarray(v[start:start + CHUNK]).astype(float), np.nan).ravel() def _summarise(v, want_median): kind = getattr(v.dtype, "kind", "") info = {"units": str(getattr(v, "units", "")), "numeric": kind in "fiu", "all_nan": False} can_be_nan = kind == "f" or (kind in "iu" and any(hasattr(v, a) for a in PACKED_ATTRS)) if not can_be_nan: return info info["all_nan"] = True finite_parts = [] for x in _chunks(v): finite = x[np.isfinite(x)] if finite.size: info["all_nan"] = False if not want_median: break finite_parts.append(finite) # Median of the whole variable, matching what Prepare OG1 computes at run time. if finite_parts: info["median"] = float(np.median(np.concatenate(finite_parts))) return info def _summarise_file(file_path, median_vars): # Imported here so the parent process never loads netCDF4. import netCDF4 with netCDF4.Dataset(file_path) as ds: return {name: _summarise(v, name in median_vars) for name, v in ds.variables.items()} if __name__ == "__main__": print(json.dumps(_summarise_file(sys.argv[1], json.loads(sys.argv[2]))))