diff --git a/.github/workflows/unittest.yml b/.github/workflows/unittest.yml new file mode 100644 index 0000000..efb8ba8 --- /dev/null +++ b/.github/workflows/unittest.yml @@ -0,0 +1,26 @@ +name: Testing +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] +permissions: + contents: read +jobs: + tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Python 3.10 setup + uses: actions/setup-python@v3 + with: + python-version: "3.10" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install . + pip install pytest + + - name: Unit testing + run: | + pytest -v diff --git a/.gitignore b/.gitignore index af16946..ed4c457 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ .idea/ -*.shr build/ py_shr_parser.egg-info __pycache__/ diff --git a/Documentation/appendix.rst b/Documentation/appendix.rst index d85f876..26e13ad 100644 --- a/Documentation/appendix.rst +++ b/Documentation/appendix.rst @@ -2,6 +2,15 @@ Appendix ======== +Related Software +================ +* shr-parser_: A Rust library for parsing SHR files. +* shr-parser-py_: Another Python package for parsing SHR files written in Rust. This project is no longer + maintained at the time of writing. + +.. _shr-parser: https://github.com/Xerrion/shr_parser +.. _shr-parser-py: https://pypi.org/project/shr-parser/ + License ======= Copyright (c) 2025, WiSELab-CMU diff --git a/Documentation/pyshrparser.rst b/Documentation/pyshrparser.rst index abe3eaa..b8f1a34 100644 --- a/Documentation/pyshrparser.rst +++ b/Documentation/pyshrparser.rst @@ -34,12 +34,12 @@ This installs a package that can be used from Python (``import shr_parser``). To install for all users on the system, administrator rights (root) may be required. -From PyPI (Not published yet. This doesn't work yet) +From PyPI ---------------------------------------------------- pyshrparser can be installed from PyPI:: - pip install pyshrparser - python3 -m pip install pyshrparser + pip install py-shr-parser + python3 -m pip install py-shr-parser Developers also may be interested to get the source archive, because it contains examples, tests and this documentation. diff --git a/README.rst b/README.rst index ad1f1b0..2922177 100644 --- a/README.rst +++ b/README.rst @@ -15,16 +15,25 @@ Documentation ------------- For API documentation, usage and examples, see the files in the "Documentation" directory. The ".rst" files can be read in any text editor or being converted to HTML or PDF -using Sphinx. +using Sphinx_. Examples can be found in the documentation and tests. -Examples --------- -Examples do not exist yet... +.. _Sphinx: https://www.sphinx-doc.org/en/master/ Installation ------------ -Eventually will be published on PyPl. Currently download the repo and run .. code-block:: none - pip install + pip install py-shr-parser + +Basic Example +------------- +This shows how to open an SHR file and load all the sweep data. + +.. code-block:: python + + from shr_parser import ShrFileParser + with ShrFileParser("foo.shr") as parser: + sweeps = parser.get_all_sweeps() + +The above example is very basic. For more advanced examples, please refer to the Documentation. diff --git a/pyproject.toml b/pyproject.toml index 79fd2cb..6cf0bd0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,21 +3,36 @@ requires = ["setuptools>=61.0"] build-backend = "setuptools.build_meta" [tool.setuptools.packages.find] -where = ["."] +where = ["src"] [project] name = "py-shr-parser" -version = "0.0.0" +version = "1.0.0" authors = [ { name = "Tom Schmitz", email="tschmitz@andrew.cmu.edu" }, ] description = "Python library for parsing Signal Hound SHR files" requires-python = ">=3.8" classifiers = [ + "Development Status :: 5 - Production/Stable", "Programming Language :: Python :: 3", - "Operating System :: OS Independent" + "Operating System :: OS Independent", ] dependencies = [ "numpy", "matplotlib", ] +license = "BSD-3-Clause" +keywords = ["SHR", "Signal Hound",] +readme = "README.rst" + +[project.urls] +Homepage = "https://github.com/WiseLabCMU/py-shr-parser" +"Bug Tracker" = "https://github.com/WiseLabCMU/py-shr-parser/issues" + +[tool.pytest.ini_options] +minversion = "6.0" +addopts = "-ra -q" +testpaths = [ + "tests", +] diff --git a/shr_parser/__init__.py b/src/shr_parser/__init__.py similarity index 84% rename from shr_parser/__init__.py rename to src/shr_parser/__init__.py index 44bb21b..f1caa77 100644 --- a/shr_parser/__init__.py +++ b/src/shr_parser/__init__.py @@ -1,4 +1,5 @@ from .shr_parser import ShrSweep, ShrFileParser from .enumerations import ShrScale, ShrWindow, ShrDecimationDetector, ShrVideoDetector, ShrVideoUnits, ShrDecimationType, ShrChannelizerOutputUnits -from .exceptions import ShrFileParserException, FileNotOpenError +from .exceptions import ShrFileParserException, FileNotOpenError, ShrFileParserWarning from .metadata import ShrSweepHeader, ShrFileHeader +from .version import __version__ diff --git a/shr_parser/enumerations.py b/src/shr_parser/enumerations.py similarity index 100% rename from shr_parser/enumerations.py rename to src/shr_parser/enumerations.py diff --git a/shr_parser/exceptions.py b/src/shr_parser/exceptions.py similarity index 66% rename from shr_parser/exceptions.py rename to src/shr_parser/exceptions.py index cd0f5f4..88cf202 100644 --- a/shr_parser/exceptions.py +++ b/src/shr_parser/exceptions.py @@ -8,3 +8,8 @@ class FileNotOpenError(ShrFileParserException): def __init__(self): super().__init__("File not open") + + +class ShrFileParserWarning(RuntimeWarning): + """Warning indicating that further parsing may result in an error""" + pass diff --git a/shr_parser/metadata.py b/src/shr_parser/metadata.py similarity index 100% rename from shr_parser/metadata.py rename to src/shr_parser/metadata.py diff --git a/shr_parser/shr_parser.py b/src/shr_parser/shr_parser.py similarity index 90% rename from shr_parser/shr_parser.py rename to src/shr_parser/shr_parser.py index 4530841..23cbfce 100644 --- a/shr_parser/shr_parser.py +++ b/src/shr_parser/shr_parser.py @@ -1,9 +1,10 @@ from .enumerations import * from .metadata import ShrFileHeader, ShrSweepHeader -from .exceptions import ShrFileParserException, FileNotOpenError +from .exceptions import ShrFileParserException, FileNotOpenError, ShrFileParserWarning import struct -from io import TextIOWrapper +from io import BufferedReader import numpy as np +from pathlib import Path SHR_FILE_SIGNATURE = 0xAA10 SHR_FILE_VERSION = 0x2 @@ -35,6 +36,7 @@ class ShrSweep: """ Frequency sweep information. """ + def __init__(self, header: ShrSweepHeader, sweep: np.array, n: int, file_header: ShrFileHeader): """ Initializer. @@ -114,13 +116,14 @@ class ShrFileParser: :raises FileNotFoundError: If unable to open file for reading. :raises FileNotOpenError: If the file is not open. """ + def __init__(self, fname: str): """ Initializer. :param fname: The name of the file to parse. """ self.__fname = fname - self.__f: TextIOWrapper[bytes] | None = None + self.__f: BufferedReader | None = None self.__header = ShrFileHeader() def _open_file(self, fname: str): @@ -131,24 +134,37 @@ def _open_file(self, fname: str): bytes_read = self.__f.read(FILE_HEADER_SIZE) if len(bytes_read) != FILE_HEADER_SIZE: + self.__f.close() + self.__f = None raise ShrFileParserException("Unable to read header") self.__header.from_tuple(struct.unpack(FILE_HEADER_PACK, bytes_read)) if self.__header.signature != SHR_FILE_SIGNATURE: + self.__f.close() + self.__f = None raise ShrFileParserException("Invalid SHR file") if self.__header.version > SHR_FILE_VERSION: + self.__f.close() + self.__f = None raise ShrFileParserException( f"Tried parsing SHR file with version {self.__header.version}. Version {SHR_FILE_VERSION} " f"and lower is supported.") + sweep_data_size = Path(fname).stat().st_size - FILE_HEADER_SIZE + sz_per_sweep = (4 * self.__header.sweep_length) + SWEEP_HEADER_SIZE + + if sweep_data_size != (sz_per_sweep * self.__header.sweep_count): + raise ShrFileParserWarning(f"{fname} reported {self.__header.sweep_count} sweeps in the file. " + f"Found {sweep_data_size / sz_per_sweep} sweeps instead!") + def __enter__(self): self._open_file(self.__fname) return self def __exit__(self, exc_type, exc_val, exc_tb): - self.__f.close() + self.close() def open(self): """ @@ -201,15 +217,15 @@ def get_sweep_n(self, n: int) -> ShrSweep: header_bytes: bytes = self.__f.read(SWEEP_HEADER_SIZE) sweep_bytes: bytes = self.__f.read(4 * self.__header.sweep_length) - header = ShrSweepHeader() - header.from_tuple(struct.unpack(SWEEP_HEADER_PACK, header_bytes)) - sweep = np.frombuffer(sweep_bytes, dtype=np.float32) - if len(header_bytes) != SWEEP_HEADER_SIZE: raise ShrFileParserException("Invalid sweep header size") if len(sweep_bytes) != sweep_size: raise ShrFileParserException("Invalid sweep size") + header = ShrSweepHeader() + header.from_tuple(struct.unpack(SWEEP_HEADER_PACK, header_bytes)) + sweep = np.frombuffer(sweep_bytes, dtype=np.float32) + return ShrSweep(header, sweep, n, self.__header) def get_all_sweeps(self) -> list[ShrSweep]: diff --git a/src/shr_parser/version.py b/src/shr_parser/version.py new file mode 100644 index 0000000..0feb90d --- /dev/null +++ b/src/shr_parser/version.py @@ -0,0 +1,3 @@ +import importlib.metadata + +__version__ = importlib.metadata.version("py-shr-parser") diff --git a/shr_parser/visualization/__init__.py b/src/shr_parser/visualization/__init__.py similarity index 100% rename from shr_parser/visualization/__init__.py rename to src/shr_parser/visualization/__init__.py diff --git a/shr_parser/visualization/spectrogram.py b/src/shr_parser/visualization/spectrogram.py similarity index 100% rename from shr_parser/visualization/spectrogram.py rename to src/shr_parser/visualization/spectrogram.py diff --git a/shr_parser/visualization/spectrum.py b/src/shr_parser/visualization/spectrum.py similarity index 100% rename from shr_parser/visualization/spectrum.py rename to src/shr_parser/visualization/spectrum.py diff --git a/shr_parser/visualization/units.py b/src/shr_parser/visualization/units.py similarity index 100% rename from shr_parser/visualization/units.py rename to src/shr_parser/visualization/units.py diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_files/sweep0v2.shr b/tests/test_files/sweep0v2.shr new file mode 100644 index 0000000..7d3a42a Binary files /dev/null and b/tests/test_files/sweep0v2.shr differ diff --git a/tests/test_shr_parser.py b/tests/test_shr_parser.py new file mode 100644 index 0000000..56c5bc9 --- /dev/null +++ b/tests/test_shr_parser.py @@ -0,0 +1,188 @@ +from shr_parser import * +import pytest +import pkg_resources + + +def test_open_file_does_not_exist(): + cut = ShrFileParser("dne.shr") + with pytest.raises(FileNotFoundError): + cut.open() + assert cut._ShrFileParser__f is None + + +def test_empty_file(tmp_path): + f = tmp_path / "empty.shr" + f.write_text("") + + cut = ShrFileParser(str(f)) + with pytest.raises(ShrFileParserException): + cut.open() + assert cut._ShrFileParser__f is None + + +def test_incomplete_header(tmp_path): + f = tmp_path / "incplt.shr" + f.write_bytes(b'\x00' * 471) + + cut = ShrFileParser(str(f)) + with pytest.raises(ShrFileParserException): + cut.open() + assert cut._ShrFileParser__f is None + + +def test_bad_signature(tmp_path): + f = tmp_path / "sig.shr" + f.write_bytes(b'\x00' * 1024) + + cut = ShrFileParser(str(f)) + with pytest.raises(ShrFileParserException): + cut.open() + assert cut._ShrFileParser__f is None + + +def test_bad_version(tmp_path): + f = tmp_path / "ver.shr" + f.write_bytes(b'\x10\xAA\x03' + (b'\x00' * 1024)) + + cut = ShrFileParser(str(f)) + with pytest.raises(ShrFileParserException): + cut.open() + assert cut._ShrFileParser__f is None + + +def test_incomplete_file(tmp_path): + f = tmp_path / "incomplete.shr" + f.write_bytes( + b"\x10\xAA\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x10\x00\x00\x00\x00\x10\x00\x00" + ( + b"\x00" * 448)) + + cut = ShrFileParser(str(f)) + with pytest.raises(ShrFileParserWarning): + cut.open() + assert cut._ShrFileParser__f is not None + assert not cut._ShrFileParser__f.closed + + +def test_open_valid_file(): + f = pkg_resources.resource_filename(__name__, 'test_files/sweep0v2.shr') + + cut = ShrFileParser(str(f)) + cut.open() + assert cut._ShrFileParser__f is not None + assert not cut._ShrFileParser__f.closed + + +def test_header_file_not_open(): + cut = ShrFileParser('foo.shr') + with pytest.raises(FileNotOpenError): + h = cut.header + + +def test_header(): + f = pkg_resources.resource_filename(__name__, 'test_files/sweep0v2.shr') + + with ShrFileParser(str(f)) as parser: + header = parser.header + assert isinstance(header, ShrFileHeader) + + assert header.signature == 0xAA10 + assert header.version == 2 + assert header.data_offset == 472 + assert header.sweep_count == 417 + assert header.sweep_length == 16386 + assert header.first_bin_freq_hz == 2.9955e9 + assert header.bin_size_hz == 1220.703125 + assert header.center_freq_hz == 3.0055e9 + assert header.span_hz == 20e6 + assert header.rbw_hz == 10e3 + assert header.vbw_hz == 10e3 + assert header.ref_level == -20.0 + assert header.ref_scale == ShrScale.DBM + assert header.div == 10.0 + assert header.window == ShrWindow.FLATTOP + assert header.attenuation == 0 + assert header.gain == 0 + assert header.detector == ShrVideoDetector.AVERAGE + assert header.processing_units == ShrVideoUnits.POWER + assert header.window_bandwidth == 8.192 + assert header.decimation_type == ShrDecimationType.COUNT + assert header.decimation_count == 1 + assert header.decimation_time_ms == 1000.0 + assert not header.channelize_enable + assert header.channel_output_units == ShrChannelizerOutputUnits.DBM + assert header.channel_center_hz == 100e6 + assert header.channel_width_hz == 20e6 + + +def test_get_sweep_n_not_open(): + cut = ShrFileParser('foo.shr') + with pytest.raises(FileNotOpenError): + cut.get_sweep_n(0) + + +def test_get_sweep_n_bounds(): + f = pkg_resources.resource_filename(__name__, 'test_files/sweep0v2.shr') + + with ShrFileParser(str(f)) as parser: + with pytest.raises(ValueError): + parser.get_sweep_n(-1) + with pytest.raises(ValueError): + parser.get_sweep_n(417) + + parser.get_sweep_n(0) + parser.get_sweep_n(416) + + +def test_get_sweep_n_corrupted(tmp_path): + f = tmp_path / "incomplete.shr" + f.write_bytes( + b"\x10\xAA\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x10\x00\x00\x00\x00\x10\x00\x00" + ( + b"\x00" * 448)) + + cut = ShrFileParser(str(f)) + try: + cut.open() + except ShrFileParserWarning: + pass + + with pytest.raises(ShrFileParserException): + cut.get_sweep_n(0) + + with pytest.raises(ValueError): + cut.get_sweep_n(100) + + +def test_get_all_sweeps(): + f = pkg_resources.resource_filename(__name__, 'test_files/sweep0v2.shr') + + with ShrFileParser(str(f)) as parser: + sweeps = parser.get_all_sweeps() + assert len(sweeps) == 417 + # No need to test errors since they are handled by `get_sweep_n()` test cases + + +def test_iter(): + f = pkg_resources.resource_filename(__name__, 'test_files/sweep0v2.shr') + + with ShrFileParser(str(f)) as parser: + i = 0 + for _ in parser: + i += 1 + assert i == 417 + # Exceptions that can be thrown here are handled by other test cases + + +def test_len(): + f = pkg_resources.resource_filename(__name__, 'test_files/sweep0v2.shr') + + with ShrFileParser(str(f)) as parser: + assert len(parser) == 417 + + +def test_no_sweeps(tmp_path): + f = tmp_path / "no-sweeps.shr" + f.write_bytes(b"\x10\xAA\x02\x00\x00\x00\x00\x00\xD8\x01\x00\x00\x00\x00\x00\x00" + (b"\x00" * 456)) + + with ShrFileParser(str(f)) as parser: + assert len(parser) == 0 + assert not parser.get_all_sweeps() diff --git a/tests/test_shr_sweep.py b/tests/test_shr_sweep.py new file mode 100644 index 0000000..6d2ae85 --- /dev/null +++ b/tests/test_shr_sweep.py @@ -0,0 +1,46 @@ +from shr_parser import * +import pkg_resources + + +def test_sweep_header(): + f = pkg_resources.resource_filename(__name__, 'test_files/sweep0v2.shr') + + with ShrFileParser(str(f)) as parser: + sweeps = parser.get_all_sweeps() + file_header = parser.header + + swp_id = [] + for sweep in sweeps: + assert isinstance(sweep.header, ShrSweepHeader) + assert sweep.timestamp == sweep.header.timestamp + assert sweep.adc_overflow == sweep.header.adc_overflow + assert sweep.file_header == file_header + swp_id.append(sweep.n) + + assert len(swp_id) == len(set(swp_id)) + + +def test_sweep_f_attr(): + f = pkg_resources.resource_filename(__name__, 'test_files/sweep0v2.shr') + + with ShrFileParser(str(f)) as parser: + sweeps = parser.get_all_sweeps() + file_header = parser.header + + for sweep in sweeps: + assert sweep.sweep_bins == file_header.sweep_length + assert sweep.f_min == 2.9955e9 + assert sweep.f_max == 3.0155e9 + + +def test_sweep_sweep_data(): + f = pkg_resources.resource_filename(__name__, 'test_files/sweep0v2.shr') + + with ShrFileParser(str(f)) as parser: + sweeps = parser.get_all_sweeps() + file_header = parser.header + + for sweep in sweeps: + swp = sweep.sweep + assert len(swp) == file_header.sweep_length + assert float(sweep.peak) == float(max(swp.tolist())) diff --git a/tests/test_visualization.py b/tests/test_visualization.py new file mode 100644 index 0000000..3b3e4da --- /dev/null +++ b/tests/test_visualization.py @@ -0,0 +1,72 @@ +from shr_parser import ShrFileParser +from shr_parser.visualization import * +import pytest +import pkg_resources + + +def test_spectrogram_errors(): + with pytest.raises(TypeError): + spectrogram(69) + + with pytest.raises(ValueError): + spectrogram([]) + + with pytest.raises(TypeError): + spectrogram([69]) + + f = pkg_resources.resource_filename(__name__, 'test_files/sweep0v2.shr') + + with ShrFileParser(str(f)) as parser: + sweeps = parser.get_all_sweeps() + + sweeps += [69] + + with pytest.raises(TypeError): + spectrogram(sweeps) + + +def test_animated_spectrogram_errors(): + with pytest.raises(TypeError): + animate_spectrogram(69) + + with pytest.raises(ValueError): + animate_spectrogram([]) + + with pytest.raises(TypeError): + animate_spectrogram([69]) + + f = pkg_resources.resource_filename(__name__, 'test_files/sweep0v2.shr') + + with ShrFileParser(str(f)) as parser: + sweeps = parser.get_all_sweeps() + + sweeps += [69] + + with pytest.raises(TypeError): + animate_spectrogram(sweeps) + + +def test_spectrum_errors(): + with pytest.raises(TypeError): + plot_spectrum(69) + + +def test_animate_spectrum_errors(): + with pytest.raises(TypeError): + animate_spectrum(69) + + with pytest.raises(ValueError): + animate_spectrum([]) + + with pytest.raises(TypeError): + animate_spectrum([69]) + + f = pkg_resources.resource_filename(__name__, 'test_files/sweep0v2.shr') + + with ShrFileParser(str(f)) as parser: + sweeps = parser.get_all_sweeps() + + sweeps += [69] + + with pytest.raises(TypeError): + animate_spectrum(sweeps)