Skip to content

Commit

Permalink
Merge pull request #206 from gnuradio/improve_sigmf_ncd_tooling
Browse files Browse the repository at this point in the history
tooling improvements for NCDs and captures access
  • Loading branch information
bhilburn authored Jan 6, 2022
2 parents ee8c9a9 + ddf6bcf commit 3f60b65
Show file tree
Hide file tree
Showing 5 changed files with 213 additions and 12 deletions.
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ This module can be installed the typical way:
pip install .
```

To run the included QA tests:
```bash
pytest
```

## Use Cases

### Load a SigMF archive; read all samples & metadata
Expand Down
8 changes: 7 additions & 1 deletion sigmf/error.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ class SigMFValidationError(SigMFError):
pass


class SigMFAccessError(SigMFError):
"""Exceptions related to accessing the contents of SigMF metadata, notably
when expexted fields are missing or accessing out of bounds captures."""
pass


class SigMFFileError(SigMFError):
"""Exceptions related to reading or writing SigMF archives."""
"""Exceptions related to reading or writing SigMF files or archives."""
pass
102 changes: 95 additions & 7 deletions sigmf/sigmffile.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
from . import __version__, schema, sigmf_hash, validate
from .archive import SigMFArchive, SIGMF_DATASET_EXT, SIGMF_METADATA_EXT, SIGMF_ARCHIVE_EXT
from .utils import dict_merge, insert_sorted_dict_list
from .error import SigMFFileError
from .error import SigMFFileError, SigMFAccessError


class SigMFFile():
Expand All @@ -59,6 +59,7 @@ class SigMFFile():
VERSION_KEY = "core:version"
DATATYPE_KEY = "core:datatype"
FREQUENCY_KEY = "core:frequency"
HEADER_BYTES_KEY = "core:header_bytes"
FLO_KEY = "core:freq_lower_edge"
FHI_KEY = "core:freq_upper_edge"
SAMPLE_RATE_KEY = "core:sample_rate"
Expand Down Expand Up @@ -91,7 +92,7 @@ class SigMFFile():
GEOLOCATION_KEY, HASH_KEY, HW_KEY, LICENSE_KEY, META_DOI_KEY, METADATA_ONLY_KEY, NUM_CHANNELS_KEY, RECORDER_KEY,
SAMPLE_RATE_KEY, START_OFFSET_KEY, TRAILING_BYTES_KEY, VERSION_KEY
]
VALID_CAPTURE_KEYS = [DATETIME_KEY, FREQUENCY_KEY, GLOBAL_INDEX_KEY, START_INDEX_KEY]
VALID_CAPTURE_KEYS = [DATETIME_KEY, FREQUENCY_KEY, HEADER_BYTES_KEY, GLOBAL_INDEX_KEY, START_INDEX_KEY]
VALID_ANNOTATION_KEYS = [
COMMENT_KEY, FHI_KEY, FLO_KEY, GENERATOR_KEY, LABEL_KEY, LAT_KEY, LENGTH_INDEX_KEY, LON_KEY, START_INDEX_KEY
]
Expand Down Expand Up @@ -134,6 +135,27 @@ def get_num_channels(self):
'''Returns integer number of channels if present, otherwise 1'''
return self.get_global_field(self.NUM_CHANNELS_KEY, 1)

def _is_conforming_dataset(self):
"""
Returns `True` if the dataset is conforming to SigMF, `False` otherwise
The dataset is non-conforming if the datafile contains non-sample bytes
which means global trailing_bytes field is zero or not set, all captures
`header_bytes` fields are zero or not set. Because we do not necessarily
know the filename no means of verifying the meta/data filename roots
match, but this will also check that a data file exists.
"""
if self.get_global_field(self.TRAILING_BYTES_KEY, 0):
return False
for capture in self.get_captures():
# check for any non-zero `header_bytes` fields in captures segments
if capture.get(self.HEADER_BYTES_KEY, 0):
return False
if not path.isfile(self.data_file):
return False
# if we get here, the file exists and is conforming
return True

def _validate_dict_in_section(self, entries, section_key):
"""
Checks a dictionary for validity.
Expand Down Expand Up @@ -234,6 +256,41 @@ def get_capture_info(self, index):
cap_info = capture
return cap_info

def get_capture_start(self, index):
"""
Returns a the start sample index of a given capture, will raise
SigMFAccessError if this field is missing.
"""
start = self.get_captures()[index].get(self.START_INDEX_KEY)
if start is None:
raise SigMFAccessError("Capture {} does not have required {} key".format(index, self.START_INDEX_KEY))
return start

def get_capture_byte_boundarys(self, index):
"""
Returns a tuple of the file byte range in a dataset of a given SigMF
capture of the form [start, stop). This function works on either
compliant or noncompliant SigMF Recordings.
"""
if index >= len(self.get_captures()):
raise SigMFAccessError("Invalid captures index {} (only {} captures in Recording)".format(index, len(self.get_captures())))

start_byte = 0
prev_start_sample = 0
for ii, capture in enumerate(self.get_captures()):
start_byte += capture.get(self.HEADER_BYTES_KEY, 0)
start_byte += (self.get_capture_start(ii) - prev_start_sample) * self.get_sample_size() * self.get_num_channels()
prev_start_sample = self.get_capture_start(ii)
if ii >= index:
break

end_byte = start_byte
if index == len(self.get_captures())-1: # last captures...data is the rest of the file
end_byte = path.getsize(self.data_file) - self.get_global_field(self.TRAILING_BYTES_KEY, 0)
else:
end_byte += (self.get_capture_start(index+1) - self.get_capture_start(index)) * self.get_sample_size() * self.get_num_channels()
return (start_byte, end_byte)

def add_annotation(self, start_index, length, metadata=None):
"""
Insert annotation
Expand Down Expand Up @@ -293,7 +350,8 @@ def _count_samples(self):
else:
sample_count = 0
else:
file_size = path.getsize(self.data_file) - self.get_global_field(self.TRAILING_BYTES_KEY, 0) # in bytes
header_bytes = sum([c.get(self.HEADER_BYTES_KEY, 0) for c in self.get_captures()])
file_size = path.getsize(self.data_file) - self.get_global_field(self.TRAILING_BYTES_KEY, 0) - header_bytes # bytes
sample_size = self.get_sample_size() # size of a sample in bytes
num_channels = self.get_num_channels()
sample_count = file_size // sample_size // num_channels
Expand Down Expand Up @@ -434,6 +492,29 @@ def tofile(self, file_path, pretty=True, toarchive=False):
with open(fns['meta_fn'], 'w') as fp:
self.dump(fp, pretty=pretty)

def read_samples_in_capture(self, index=0, autoscale=True):
'''
Reads samples from the specified captures segment in its entirety.
Parameters
----------
index : int, default 0
Captures segment to read samples from.
autoscale : bool, default True
If dataset is in a fixed-point representation, scale samples from (min, max) to (-1.0, 1.0)
Returns
-------
data : ndarray
Samples are returned as an array of float or complex, with number of dimensions equal to NUM_CHANNELS_KEY.
'''
cb = self.get_capture_byte_boundarys(index)
if (cb[1] - cb[0]) % (self.get_sample_size() * self.get_num_channels()):
warnings.warn(f'Capture `{index}` in `{self.data_file}` does not contain '
'an integer number of samples across channels. It may be invalid.')

return self._read_datafile(cb[0], (cb[1] - cb[0]) // self.get_sample_size(), autoscale, False)

def read_samples(self, start_index=0, count=-1, autoscale=True, raw_components=False):
'''
Reads the specified number of samples starting at the specified index from the associated data file.
Expand Down Expand Up @@ -465,23 +546,30 @@ def read_samples(self, start_index=0, count=-1, autoscale=True, raw_components=F
raise SigMFFileError("Cannot read samples from a metadata only distribution.")
else:
raise SigMFFileError("No signal data file has bfeen associated with the metadata.")
first_byte = start_index * self.get_sample_size() * self.get_num_channels()

if not self._is_conforming_dataset():
warnings.warn(f'Recording dataset appears non-compliant, resulting data may be erroneous')
return self._read_datafile(first_byte, count * self.get_num_channels(), autoscale, False)

def _read_datafile(self, first_byte, nitems, autoscale, raw_components):
'''
internal function for reading samples from datafile
'''
dtype = dtype_info(self.get_global_field(self.DATATYPE_KEY))
is_complex_data = dtype['is_complex']
is_fixedpoint_data = dtype['is_fixedpoint']
is_unsigned_data = dtype['is_unsigned']
data_type_in = dtype['sample_dtype']
component_type_in = dtype['component_dtype']
sample_size = dtype['sample_size']
component_size = dtype['component_size']

data_type_out = np.dtype("f4") if not is_complex_data else np.dtype("f4, f4")
num_channels = self.get_num_channels()

fp = open(self.data_file, "rb")
fp.seek(start_index * sample_size * num_channels, 0)

data = np.fromfile(fp, dtype=data_type_in, count=count*num_channels)
fp.seek(first_byte, 0)
data = np.fromfile(fp, dtype=data_type_in, count=nitems)
if num_channels != 1:
# return reshaped view for num_channels
# first dimension will be double size if `is_complex_data`
Expand Down
69 changes: 65 additions & 4 deletions tests/test_sigmffile.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,13 @@
import os
import shutil
import tempfile

import json
import numpy as np

from sigmf import sigmffile, utils
from sigmf.sigmffile import SigMFFile

from .testdata import TEST_FLOAT32_DATA, TEST_METADATA
from .testdata import *


def simulate_capture(sigmf_md, n, capture_len):
Expand Down Expand Up @@ -69,15 +69,16 @@ def test_add_annotation():


def test_fromarchive(test_sigmffile):
print("test_sigmffile is:\n",test_sigmffile)
tf = tempfile.mkstemp()[1]
td = tempfile.mkdtemp()
archive_path = test_sigmffile.archive(name=tf)
result = sigmffile.fromarchive(archive_path=archive_path, dir=td)

assert result._metadata == test_sigmffile._metadata == TEST_METADATA

data = np.fromfile(result.data_file, dtype=np.float32)
assert np.array_equal(data, TEST_FLOAT32_DATA)
#data = np.fromfile(result.data_file, dtype=np.float32)
#assert np.array_equal(data, TEST_FLOAT32_DATA)

os.remove(tf)
shutil.rmtree(td)
Expand Down Expand Up @@ -169,3 +170,63 @@ def test_ordered_metadata():
top_sort_order = ['global', 'captures', 'annotations']
for kdx, key in enumerate(sigf.ordered_metadata()):
assert kdx == top_sort_order.index(key)


def test_captures_checking():
'''
these tests make sure the various captures access tools work properly
'''
np.array(TEST_U8_DATA0, dtype=np.uint8).tofile('/tmp/d0.sigmf-data')
with open('/tmp/d0.sigmf-meta','w') as f0: json.dump(TEST_U8_META0, f0)
np.array(TEST_U8_DATA1, dtype=np.uint8).tofile('/tmp/d1.sigmf-data')
with open('/tmp/d1.sigmf-meta','w') as f1: json.dump(TEST_U8_META1, f1)
np.array(TEST_U8_DATA2, dtype=np.uint8).tofile('/tmp/d2.sigmf-data')
with open('/tmp/d2.sigmf-meta','w') as f2: json.dump(TEST_U8_META2, f2)
np.array(TEST_U8_DATA3, dtype=np.uint8).tofile('/tmp/d3.sigmf-data')
with open('/tmp/d3.sigmf-meta','w') as f3: json.dump(TEST_U8_META3, f3)
np.array(TEST_U8_DATA4, dtype=np.uint8).tofile('/tmp/d4.sigmf-data')
with open('/tmp/d4.sigmf-meta','w') as f4: json.dump(TEST_U8_META4, f4)

sigmf0 = sigmffile.fromfile('/tmp/d0.sigmf-meta', skip_checksum=True)
sigmf1 = sigmffile.fromfile('/tmp/d1.sigmf-meta', skip_checksum=True)
sigmf2 = sigmffile.fromfile('/tmp/d2.sigmf-meta', skip_checksum=True)
sigmf3 = sigmffile.fromfile('/tmp/d3.sigmf-meta', skip_checksum=True)
sigmf4 = sigmffile.fromfile('/tmp/d4.sigmf-meta', skip_checksum=True)

assert sigmf0._count_samples() == 256
assert sigmf0._is_conforming_dataset()
assert (0,0) == sigmf0.get_capture_byte_boundarys(0)
assert (0,256) == sigmf0.get_capture_byte_boundarys(1)
assert np.array_equal(TEST_U8_DATA0, sigmf0.read_samples(autoscale=False))
assert np.array_equal(np.array([]), sigmf0.read_samples_in_capture(0))
assert np.array_equal(TEST_U8_DATA0, sigmf0.read_samples_in_capture(1,autoscale=False))

assert sigmf1._count_samples() == 192
assert not sigmf1._is_conforming_dataset()
assert (32,160) == sigmf1.get_capture_byte_boundarys(0)
assert (160,224) == sigmf1.get_capture_byte_boundarys(1)
assert np.array_equal(np.array(range(128)), sigmf1.read_samples_in_capture(0,autoscale=False))
assert np.array_equal(np.array(range(128,192)), sigmf1.read_samples_in_capture(1,autoscale=False))

assert sigmf2._count_samples() == 192
assert not sigmf2._is_conforming_dataset()
assert (32,160) == sigmf2.get_capture_byte_boundarys(0)
assert (176,240) == sigmf2.get_capture_byte_boundarys(1)
assert np.array_equal(np.array(range(128)), sigmf2.read_samples_in_capture(0,autoscale=False))
assert np.array_equal(np.array(range(128,192)), sigmf2.read_samples_in_capture(1,autoscale=False))

assert sigmf3._count_samples() == 192
assert not sigmf3._is_conforming_dataset()
assert (32,64) == sigmf3.get_capture_byte_boundarys(0)
assert (64,160) == sigmf3.get_capture_byte_boundarys(1)
assert (192,256) == sigmf3.get_capture_byte_boundarys(2)
assert np.array_equal(np.array(range(32)), sigmf3.read_samples_in_capture(0,autoscale=False))
assert np.array_equal(np.array(range(32,128)), sigmf3.read_samples_in_capture(1,autoscale=False))
assert np.array_equal(np.array(range(128,192)), sigmf3.read_samples_in_capture(2,autoscale=False))

assert sigmf4._count_samples() == 96
assert not sigmf4._is_conforming_dataset()
assert (32,160) == sigmf4.get_capture_byte_boundarys(0)
assert (160,224) == sigmf4.get_capture_byte_boundarys(1)
assert np.array_equal(np.array(range(64)), sigmf4.read_samples_in_capture(0,autoscale=False)[:,0])
assert np.array_equal(np.array(range(64,96)), sigmf4.read_samples_in_capture(1,autoscale=False)[:,1])
41 changes: 41 additions & 0 deletions tests/testdata.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,44 @@
}
}

# Data0 is a test of a compliant two capture recording
TEST_U8_DATA0 = list(range(256))
TEST_U8_META0 = {
SigMFFile.ANNOTATION_KEY: [],
SigMFFile.CAPTURE_KEY: [ {SigMFFile.START_INDEX_KEY: 0, SigMFFile.HEADER_BYTES_KEY: 0},
{SigMFFile.START_INDEX_KEY: 0, SigMFFile.HEADER_BYTES_KEY: 0} ], # very strange..but technically legal?
SigMFFile.GLOBAL_KEY: {SigMFFile.DATATYPE_KEY: 'ru8', SigMFFile.TRAILING_BYTES_KEY: 0}
}
# Data1 is a test of a two capture recording with header_bytes and trailing_bytes set
TEST_U8_DATA1 = [0xfe]*32 + list(range(192)) + [0xff]*32
TEST_U8_META1 = {
SigMFFile.ANNOTATION_KEY: [],
SigMFFile.CAPTURE_KEY: [ {SigMFFile.START_INDEX_KEY: 0, SigMFFile.HEADER_BYTES_KEY: 32},
{SigMFFile.START_INDEX_KEY: 128} ],
SigMFFile.GLOBAL_KEY: {SigMFFile.DATATYPE_KEY: 'ru8', SigMFFile.TRAILING_BYTES_KEY: 32}
}
# Data2 is a test of a two capture recording with multiple header_bytes set
TEST_U8_DATA2 = [0xfe]*32 + list(range(128)) + [0xfe]*16 + list(range(128,192)) + [0xff]*16
TEST_U8_META2 = {
SigMFFile.ANNOTATION_KEY: [],
SigMFFile.CAPTURE_KEY: [ {SigMFFile.START_INDEX_KEY: 0, SigMFFile.HEADER_BYTES_KEY: 32},
{SigMFFile.START_INDEX_KEY: 128, SigMFFile.HEADER_BYTES_KEY: 16} ],
SigMFFile.GLOBAL_KEY: {SigMFFile.DATATYPE_KEY: 'ru8', SigMFFile.TRAILING_BYTES_KEY: 16}
}
# Data3 is a test of a three capture recording with multiple header_bytes set
TEST_U8_DATA3 = [0xfe]*32 + list(range(128)) + [0xfe]*32 + list(range(128,192))
TEST_U8_META3 = {
SigMFFile.ANNOTATION_KEY: [],
SigMFFile.CAPTURE_KEY: [ {SigMFFile.START_INDEX_KEY: 0, SigMFFile.HEADER_BYTES_KEY: 32},
{SigMFFile.START_INDEX_KEY: 32},
{SigMFFile.START_INDEX_KEY: 128, SigMFFile.HEADER_BYTES_KEY: 32} ],
SigMFFile.GLOBAL_KEY: {SigMFFile.DATATYPE_KEY: 'ru8'}
}
# Data4 is a two channel version of Data0
TEST_U8_DATA4 = [0xfe]*32 + [y for y in list(range(96)) for i in [0,1]] + [0xff]*32
TEST_U8_META4 = {
SigMFFile.ANNOTATION_KEY: [],
SigMFFile.CAPTURE_KEY: [ {SigMFFile.START_INDEX_KEY: 0, SigMFFile.HEADER_BYTES_KEY: 32},
{SigMFFile.START_INDEX_KEY: 64} ],
SigMFFile.GLOBAL_KEY: {SigMFFile.DATATYPE_KEY: 'ru8', SigMFFile.TRAILING_BYTES_KEY: 32, SigMFFile.NUM_CHANNELS_KEY: 2}
}

0 comments on commit 3f60b65

Please sign in to comment.