From 281013bf8260594e5f6593af15127975ca6eb686 Mon Sep 17 00:00:00 2001 From: Art Pelling Date: Fri, 19 Jun 2026 16:47:44 +0200 Subject: [PATCH] add verify to SofaStream --- sofar/sofastream.py | 128 ++++++++++++++++++++++++++++++++++++++- sofar/utils.py | 2 +- tests/test_sofastream.py | 30 +++++++++ 3 files changed, 158 insertions(+), 2 deletions(-) diff --git a/sofar/sofastream.py b/sofar/sofastream.py index 72fdd45..d491fe5 100644 --- a/sofar/sofastream.py +++ b/sofar/sofastream.py @@ -5,6 +5,23 @@ from netCDF4 import Dataset import numpy as np +from .io import _format_value_from_netcdf + + +class _LazyArray(np.ndarray): + """Represent a NetCDF numeric variable without reading its data.""" + + def __new__(cls, shape, dtype): + data = np.array(0, dtype=dtype) + strides = (0, ) * len(shape) + return np.lib.stride_tricks.as_strided( + data, shape=shape, strides=strides).view(cls) + + def copy(self, order='C'): + """Return a view instead of materializing the full array.""" + _ = order + return self + class SofaStream(): """ @@ -12,7 +29,8 @@ class SofaStream(): file into memory. :class:`SofaStream` opens a SOFA-file and retrieves only the requested - data. + data. It can also verify the file against the SOFA convention using the + same checks as :py:func:`~sofar.Sofa.verify`. If you want to use all the data from a SOFA-file use :class:`Sofa` class and :func:`read_sofa` function instead. @@ -83,6 +101,114 @@ def __exit__(self, *args): """ self._file.close() + def verify(self, issue_handling="raise", mode="write"): + """ + Verify a SOFA file against the SOFA standard. + + This method uses :py:func:`~sofar.Sofa.verify` internally with lazy + placeholders for numeric variables. Thus, numeric data are checked for + type and dimensions without loading the variable contents into memory. + String variables are loaded because their values and string lengths can + be relevant for convention verification. + + Parameters + ---------- + issue_handling : str, optional + Defines how detected issues are handled. See + :py:func:`~sofar.Sofa.verify` for details. + mode : str, optional + The SOFA standard is more strict for writing data than for reading + data. See :py:func:`~sofar.Sofa.verify` for details. + + Returns + ------- + issues : str, None + Detected issues as a string. None if no issues were detected. Note + that this is only returned if ``issue_handling='return'``. + """ + if hasattr(self, "_file") and self._file.isopen(): + return self._verify_open_file(issue_handling, mode) + + with Dataset(self._filename, mode="r") as file: + self._file = file + try: + return self._verify_open_file(issue_handling, mode) + finally: + del self._file + + def _verify_open_file(self, issue_handling, mode): + """Verify currently opened NetCDF file without loading numeric data.""" + import sofar as sf + + # get SOFA object with default values and convention metadata + sofa = sf.Sofa( + self._file.SOFAConventions, + version=self._file.SOFAConventionsVersion, + verify=False) + sofa.protected = False + + # NetCDF attribute _Encoding is skipped when reading SOFA files + skip = ["_Encoding"] + all_attr = [] + + # load global attributes + for attr in self._file.ncattrs(): + value = getattr(self._file, attr) + key = f"GLOBAL_{attr}" + all_attr.append(key) + + if not hasattr(sofa, key): + sofa._add_custom_api_entry(key, value, None, None, "attribute") + sofa.protected = False + else: + setattr(sofa, key, value) + + # load variable metadata and string values + for var in self._file.variables.keys(): + netcdf_variable = self._file[var] + key = var.replace(".", "_") + all_attr.append(key) + + if netcdf_variable.datatype == "S1": + value = _format_value_from_netcdf(netcdf_variable[:], var) + dtype = "string" + else: + value = _LazyArray( + netcdf_variable.shape, netcdf_variable.dtype) + dtype = "double" + + if hasattr(sofa, key): + setattr(sofa, key, value) + else: + dimensions = "".join(list(netcdf_variable.dimensions)) + sofa._add_custom_api_entry( + key, value, None, dimensions, dtype) + sofa.protected = False + + # load variable attributes + for attr in [a for a in netcdf_variable.ncattrs() + if a not in skip]: + value = getattr(netcdf_variable, attr) + attr_key = key + "_" + attr + all_attr.append(attr_key) + + if not hasattr(sofa, attr_key): + sofa._add_custom_api_entry( + attr_key, value, None, None, "attribute") + sofa.protected = False + else: + setattr(sofa, attr_key, value) + + # remove default entries that were not contained in the file + attrs = [attr for attr in sofa.__dict__.keys() + if not attr.startswith("_")] + for attr in attrs: + if attr not in all_attr: + delattr(sofa, attr) + + sofa.protected = True + return sofa.verify(issue_handling=issue_handling, mode=mode) + def __getattr__(self, name): """ Executed when accessing data within a with statement diff --git a/sofar/utils.py b/sofar/utils.py index db70d72..136bcd2 100644 --- a/sofar/utils.py +++ b/sofar/utils.py @@ -262,7 +262,7 @@ def _atleast_nd(array, ndim): def _nd_newaxis(array, ndim): """Append dimensions to the end of an array until array.ndim == ndim.""" - array = np.array(array) + array = np.asarray(array) for _ in range(ndim - array.ndim): array = array[..., np.newaxis] diff --git a/tests/test_sofastream.py b/tests/test_sofastream.py index f7b7ac0..2920967 100644 --- a/tests/test_sofastream.py +++ b/tests/test_sofastream.py @@ -5,6 +5,7 @@ import numpy as np import os import sofar as sf +import sofar.io as sfi def test_sofastream_output(temp_sofa_file): @@ -39,6 +40,35 @@ def test_sofastream_attribute_error(temp_sofa_file): _ = file.Wrong_Attribute +def test_sofastream_verify(temp_sofa_file, tmp_path_factory, monkeypatch): + + def fail_read_sofa(*_args, **_kwargs): + raise AssertionError("SofaStream.verify must not call read_sofa") + + monkeypatch.setattr(sf, "read_sofa", fail_read_sofa) + + with SofaStream(temp_sofa_file) as file: + assert file.verify(issue_handling="return") is None + + monkeypatch.undo() + + filename = tmp_path_factory.mktemp("data") / "test_sofastream_verify.sofa" + sofa = sf.Sofa("SimpleFreeFieldHRIR") + sofa.ListenerPosition_Units = "Meter" + sfi._write_sofa(filename, sofa, verify=False) + + sofa_issues = sf.read_sofa(filename, verify=False).verify( + issue_handling="return", mode="write") + + with SofaStream(filename) as file: + stream_issues = file.verify(issue_handling="return", mode="write") + with pytest.raises( + ValueError, match="lower case letters when writing"): + file.verify(mode="write") + + assert stream_issues == sofa_issues + + def test_sofastream_inspect(capfd, temp_sofa_file): tempdir = TemporaryDirectory()