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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 127 additions & 1 deletion sofar/sofastream.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,32 @@
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():
"""
Read desired data from SOFA-file directly from disk without loading entire
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.
Expand Down Expand Up @@ -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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add something like 'see :py:func:~sofar.Sofa.verify for more information on the verification process and rules.'?


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.

Comment on lines +116 to +122

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would suggest to copy paste the parameter description from Sofa.verify to make this more self contained.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Imports should be at the top. Any reason that this is here?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

slop :^)


# 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
Expand Down
2 changes: 1 addition & 1 deletion sofar/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
30 changes: 30 additions & 0 deletions tests/test_sofastream.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two suggestions because this test two things at once:

How about having one test to only check that read_sofa is not called (and add a brief docstring to the test even though the existing tests do not follow this)

Since you are calling Sofa.verify, which is already tested in test_sofa_verify.py, we should think about a good way to test only the added functionality here. Would it be sufficient to test this against sf.io.read_sofa? This would mean to test if

  • string variables and attributes are loaded as intended
  • all variables exist and have the same type and shape

I think this could be done if the 'fake' sofa file that is created in SofaStream._verify_open_file is returned in an intermediate step (so we could test it) and verified in a next step. So maybe split the functionality into

  • SofaStream._read_open_file_for_verification and
  • SofaStream._verify_open_file

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am also thinking that we could also add upgrade_conventions in a similar way. Going through with this fully could imply implementing all Sofa methods. Then, we should probably work with inheritance.

@f-brinkmann should we just concentrate on verify in this PR or zoom out a bit already?

@f-brinkmann f-brinkmann Jun 24, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@artpelling I would definately focus on verify at the moment. I see how for example adding variables can be helpful for example to sequentially write data from measurements into SOFA files, but we should maybe check if its possible and worth the effort.


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()
Expand Down
Loading