Skip to content
Merged
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
44 changes: 31 additions & 13 deletions docs/source/advanced.rst
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,26 @@ You can control whether fixed-point samples are automatically scaled:
handle_raw = sigmf.fromfile("fixed_point_data.sigmf", autoscale=False)
raw_samples = handle_raw.read_samples() # Returns original integer types

---------------------------------------------
Access the Declared Specification Version
---------------------------------------------

When loading a SigMF file, the library automatically normalizes the metadata to
the current specification version. You can access the originally declared spec
version using the ``declared_version`` property:

.. code-block:: python

import sigmf

# Load a recording (possibly created with an older SigMF spec version)
meta = sigmf.fromfile("legacy_recording.sigmf")

# meta.version always reports the current library spec version
print(f"Current spec version: {meta.version}")
# meta.declared_version preserves the spec version declared in the loaded file
print(f"Declared spec version: {meta.declared_version}")

------------------------------
Iterate over SigMF Annotations
------------------------------
Expand All @@ -114,14 +134,13 @@ the recording of the SigMF logo used in this example `from the specification

.. code-block:: python

from sigmf import SigMFFile, sigmffile
import sigmf

# Load a dataset
path = "logo/sigmf_logo" # extension is optional
signal = sigmffile.fromfile(path)
signal = sigmf.fromfile("logo/sigmf_logo.sigmf")

# Get some metadata and all annotations
sample_rate = signal.get_global_field(sigmf.SAMPLE_RATE_KEY)
sample_rate = signal.sample_rate
sample_count = signal.sample_count
signal_duration = sample_count / sample_rate
annotations = signal.get_annotations()
Expand All @@ -131,7 +150,7 @@ the recording of the SigMF logo used in this example `from the specification
annotation_start_idx = annotation[sigmf.SAMPLE_START_KEY]
annotation_length = annotation[sigmf.SAMPLE_COUNT_KEY]
annotation_comment = annotation.get(
sigmf.COMMENT_KEY, "[annotation {}]".format(adx)
sigmf.COMMENT_KEY, f"[annotation {adx}]"
)

# Get capture info associated with the start of annotation
Expand All @@ -157,10 +176,8 @@ First, create a single SigMF Recording and save it to disk:

.. code-block:: python

import datetime as dt
import numpy as np
import sigmf
from sigmf import SigMFFile
from sigmf.utils import get_data_type_str, get_sigmf_iso8601_datetime_now

# suppose we have a complex timeseries signal
Expand All @@ -170,7 +187,7 @@ First, create a single SigMF Recording and save it to disk:
data.tofile("example_cf32.sigmf-data")

# create the metadata
meta = SigMFFile(
meta = sigmf.SigMFFile(
data_file="example_cf32.sigmf-data", # extension is optional
global_info={
sigmf.DATATYPE_KEY: get_data_type_str(data), # in this case, 'cf32_le'
Expand Down Expand Up @@ -207,7 +224,8 @@ Now lets add another SigMF Recording and associate them with a SigMF Collection:

.. code-block:: python

from sigmf import SigMFFile, SigMFCollection
import numpy as np
import sigmf

data_ci16 = np.zeros(1024, dtype=np.complex64)

Expand All @@ -216,7 +234,7 @@ Now lets add another SigMF Recording and associate them with a SigMF Collection:
data_ci16.view(np.float32).astype(np.int16).tofile("example_ci16.sigmf-data")

# create the metadata for the second file
meta_ci16 = SigMFFile(
meta_ci16 = sigmf.SigMFFile(
data_file="example_ci16.sigmf-data", # extension is optional
global_info={
sigmf.DATATYPE_KEY: "ci16_le", # get_data_type_str() is only valid for numpy types
Expand All @@ -227,12 +245,12 @@ Now lets add another SigMF Recording and associate them with a SigMF Collection:
meta_ci16.add_capture(0, metadata=meta.get_capture_info(0))
meta_ci16.tofile("example_ci16.sigmf-meta")

collection = SigMFCollection(
collection = sigmf.SigMFCollection(
["example_cf32.sigmf-meta", "example_ci16.sigmf-meta"],
metadata={
"collection": {
SigMFCollection.AUTHOR_KEY: "sigmf@sigmf.org",
SigMFCollection.DESCRIPTION_KEY: "Collection of two all zero files.",
sigmf.AUTHOR_KEY: "sigmf@sigmf.org",
sigmf.DESCRIPTION_KEY: "Collection of two all zero files.",
}
},
)
Expand Down
2 changes: 1 addition & 1 deletion docs/source/developers.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ To install from source:

$ git clone https://github.com/sigmf/sigmf-python.git
$ cd sigmf-python
$ pip install .[test]
$ pip install --editable .[test]

-------
Testing
Expand Down
6 changes: 3 additions & 3 deletions docs/source/quickstart.rst
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ For full control over global fields, captures, and annotations:
.. code-block:: python

import numpy as np
from sigmf import SigMFFile
import sigmf
from sigmf.utils import get_data_type_str, get_sigmf_iso8601_datetime_now

# suppose we have a complex timeseries signal
Expand All @@ -100,7 +100,7 @@ For full control over global fields, captures, and annotations:
data.tofile("example.sigmf-data")

# create the metadata
meta = SigMFFile(
meta = sigmf.SigMFFile(
data_file="example.sigmf-data", # extension is optional
global_info={
sigmf.DATATYPE_KEY: get_data_type_str(data), # in this case, "cf32_le"
Expand Down Expand Up @@ -146,7 +146,7 @@ method-based approach.
import sigmf

# read some recording
meta = sigmf.SigMFFile("sigmf_logo")
meta = sigmf.fromfile("sigmf_logo.sigmf")

# read global metadata
print(f"Sample rate: {meta.sample_rate}")
Expand Down
16 changes: 11 additions & 5 deletions docs/source/siggen.rst
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ Basic Usage

.. code-block:: python

from sigmf.siggen import SigMFGenerator
import sigmf

# generate a 1 kHz tone at 48 kHz sample rate for 1 second
signal = SigMFGenerator().tone(1000).sample_rate(48000).duration(1.0).generate()
signal = sigmf.SigMFGenerator().tone(1000).sample_rate(48000).duration(1.0).generate()
signal.read_samples() # complex64 numpy array

The returned object is a standard :class:`~sigmf.sigmffile.SigMFFile` backed by
Expand All @@ -36,8 +36,10 @@ label.

.. code-block:: python

import sigmf

signal = (
SigMFGenerator()
sigmf.SigMFGenerator()
.tone(1000)
.tone(-2500)
.sweep(500, 4000)
Expand All @@ -60,8 +62,10 @@ A seed ensures reproducibility across runs.

.. code-block:: python

import sigmf

# deterministic random signal
signal = SigMFGenerator(seed=0xDEADBEEF).generate()
signal = sigmf.SigMFGenerator(seed=0xDEADBEEF).generate()

# the number and type of components are randomly chosen
print(signal.description) # e.g. "synthetic signal with 3 tones and 2 sweeps"
Expand All @@ -79,8 +83,10 @@ author, and comment can be set via the builder.

.. code-block:: python

import sigmf

signal = (
SigMFGenerator()
sigmf.SigMFGenerator()
.tone(440)
.sample_rate(44100)
.duration(1.0)
Expand Down
2 changes: 1 addition & 1 deletion sigmf/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
# SPDX-License-Identifier: LGPL-3.0-or-later

# version of this python module
__version__ = "1.12.0"
__version__ = "1.13.0"
# matching version of the SigMF specification
__specification__ = "1.2.6"

Expand Down
32 changes: 22 additions & 10 deletions sigmf/sigmffile.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"""SigMFFile Object"""

import codecs
import copy
import io
import json
import warnings
Expand Down Expand Up @@ -88,10 +89,10 @@ class SigMFMetafile(metaclass=_SigMFDeprecatingMeta):
VALID_KEYS: dict[str, list[str]] = {}

def __init__(self):
self.version = None
self.schema = None
self._metadata = None
self.shape = None
self._metadata = None
self._declared_version = None

def __str__(self):
return self.dumps()
Expand Down Expand Up @@ -459,25 +460,28 @@ def get_schema(self):
"""
Return a schema object valid for the current metadata
"""
current_metadata_version = self.get_global_info().get(keys.VERSION_KEY)
if self.version != current_metadata_version or self.schema is None:
self.version = current_metadata_version
self.schema = schema.get_schema(self.version)
if not isinstance(self.schema, dict):
raise SigMFError("SigMF schema expects a dict (key, value pairs)")
if self.schema is None:
version = self.get_global_field(keys.VERSION_KEY)
self.schema = schema.get_schema(version)
return self.schema

def set_metadata(self, metadata):
"""
Read provided metadata as either None (empty), string, bytes, or dictionary.

If an existing core:version is present, it is preserved in the declared_version
property, but the metadata will always report the current library version.
"""
if metadata is None:
# Create empty
self._metadata = {self.GLOBAL_KEY: {}, self.CAPTURE_KEY: [], self.ANNOTATION_KEY: []}
self._declared_version = None
elif isinstance(metadata, dict):
self._metadata = metadata
self._metadata = copy.deepcopy(metadata)
self._declared_version = self._metadata.get(self.GLOBAL_KEY, {}).get(keys.VERSION_KEY)
elif isinstance(metadata, (str, bytes)):
self._metadata = json.loads(metadata)
self._declared_version = self._metadata.get(self.GLOBAL_KEY, {}).get(keys.VERSION_KEY)
else:
raise SigMFError("Unable to interpret provided metadata.")

Expand All @@ -487,7 +491,7 @@ def set_metadata(self, metadata):
if self.get_global_field(keys.OFFSET_KEY) is None:
self.set_global_field(keys.OFFSET_KEY, 0)

# set version to current implementation
# set version to current (object operates per current spec and always writes current version)
self.set_global_field(keys.VERSION_KEY, __specification__)

def set_global_info(self, new_global):
Expand Down Expand Up @@ -517,6 +521,14 @@ def get_global_field(self, key, default=None):
"""
return self._metadata[self.GLOBAL_KEY].get(key, default)

@property
def declared_version(self) -> str | None:
"""
Return the core:version that may have been present in the metadata when this
SigMFFile was loaded, before normalization to the current spec.
"""
return self._declared_version

def add_capture(self, start_index, metadata=None):
"""
Insert capture info for sample starting at start_index.
Expand Down
7 changes: 3 additions & 4 deletions tests/test_archive.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
"""Tests for SigMFArchive"""

import codecs
import copy
import json
import shutil
import tarfile
Expand All @@ -34,7 +33,7 @@ def setUp(self):
self.temp_path_meta = self.temp_dir / "trash.sigmf-meta"
self.temp_path_archive = self.temp_dir / "test.sigmf"
TEST_FLOAT32_DATA.tofile(self.temp_path_data)
self.sigmf_object = SigMFFile(copy.deepcopy(TEST_METADATA), data_file=self.temp_path_data)
self.sigmf_object = SigMFFile(TEST_METADATA, data_file=self.temp_path_data)
self.sigmf_object.tofile(self.temp_path_meta)
self.sigmf_object.tofile(self.temp_path_archive)
self.sigmf_tarfile = tarfile.open(self.temp_path_archive, mode="r", format=tarfile.PAX_FORMAT)
Expand Down Expand Up @@ -188,7 +187,7 @@ def setUp(self):
self.temp_dir = Path(tempfile.mkdtemp())
self.temp_path_data = self.temp_dir / "test.sigmf-data"
TEST_FLOAT32_DATA.tofile(self.temp_path_data)
self.sigmf_object = SigMFFile(copy.deepcopy(TEST_METADATA), data_file=self.temp_path_data)
self.sigmf_object = SigMFFile(TEST_METADATA, data_file=self.temp_path_data)
self.original_samples = self.sigmf_object.read_samples()

def tearDown(self):
Expand Down Expand Up @@ -318,7 +317,7 @@ def test_data_buffer_writes_data_file(self):
data_buffer.write(TEST_FLOAT32_DATA.tobytes())
data_buffer.seek(0)

meta = SigMFFile(copy.deepcopy(TEST_METADATA))
meta = SigMFFile(TEST_METADATA)
meta.set_data_file(data_buffer=data_buffer)

# tofile without archive extension should create separate files
Expand Down
52 changes: 50 additions & 2 deletions tests/test_attributes.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
"""Tests for dynamic attribute access functionality."""

import copy
import unittest

from packaging.version import parse

import sigmf
from sigmf import SigMFFile
from sigmf.error import SigMFAccessError
Expand All @@ -19,7 +20,7 @@ class TestDynamicAttributeAccess(unittest.TestCase):

def setUp(self):
"""create test sigmf file with some initial metadata"""
self.meta = SigMFFile(copy.deepcopy(TEST_METADATA))
self.meta = SigMFFile(TEST_METADATA)

def test_getter_existing_fields(self):
"""test attribute getters for existing core fields"""
Expand Down Expand Up @@ -88,3 +89,50 @@ def test_existing_properties_unaffected(self):
# test that existing properties like data_file still work
self.meta.data_file = None # this should work normally
self.assertIsNone(self.meta.data_file)


class TestDeclaredVersion(unittest.TestCase):
"""Test declared_version property behavior"""

def test_declared_version_preserved(self):
"""declared_version preserves the declared version from loaded metadata"""
# create metadata with old version
meta_with_old_version = dict(TEST_METADATA)
meta_with_old_version[SigMFFile.GLOBAL_KEY] = dict(TEST_METADATA[SigMFFile.GLOBAL_KEY])
meta_with_old_version[SigMFFile.GLOBAL_KEY][sigmf.VERSION_KEY] = "0.1.0"

meta = SigMFFile(metadata=meta_with_old_version)

# declared version is preserved
self.assertEqual(meta.declared_version, "0.1.0")
# object reports current version (from library)
self.assertEqual(meta.version, sigmf.__specification__)
# declared is older than current (using proper version comparison)
self.assertLess(parse(meta.declared_version), parse(meta.version))

def test_declared_version_none_for_new_files(self):
"""declared_version is None for newly created files"""
meta = SigMFFile()
self.assertIsNone(meta.declared_version)
# object reports current library version
self.assertEqual(meta.version, sigmf.__specification__)

def test_declared_version_matches_current_when_loaded_from_current(self):
"""declared_version matches current version when loading file with current version"""
# TEST_METADATA gets current version injected
meta = SigMFFile(TEST_METADATA)
# both should be current library version
self.assertEqual(meta.declared_version, sigmf.__specification__)
self.assertEqual(meta.version, sigmf.__specification__)

def test_dict_not_mutated(self):
"""initialization dict is deepcopied, not mutated"""
meta_copy = dict(TEST_METADATA)
meta_copy[SigMFFile.GLOBAL_KEY] = dict(TEST_METADATA[SigMFFile.GLOBAL_KEY])
meta_copy[SigMFFile.GLOBAL_KEY][sigmf.VERSION_KEY] = "0.1.0"
declared_version = meta_copy[SigMFFile.GLOBAL_KEY][sigmf.VERSION_KEY]

SigMFFile(metadata=meta_copy)

# caller's dict should be unchanged
self.assertEqual(meta_copy[SigMFFile.GLOBAL_KEY][sigmf.VERSION_KEY], declared_version)
Loading