Skip to content

Commit 1435246

Browse files
committed
polish implementation, new fromarray() method
* fromarray() only creates SigMFFile object from numpy array, doesn't write files * SigMFFile.tofile() auto-detects archive/compression from extension * when data_buffer exists, will also write `.sigmf-data`, like when using (SigMFGenerator) * added more tests
1 parent 05ac806 commit 1435246

12 files changed

Lines changed: 279 additions & 216 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,9 @@ import numpy as np
4646
import sigmf
4747

4848
data = np.array([0.1 + 0.2j, 0.3 + 0.4j], dtype=np.complex64)
49+
meta = sigmf.fromarray(data, sample_rate=48000)
4950
# creates recording.sigmf-data and recording.sigmf-meta
50-
meta = sigmf.tofile("recording", data, sample_rate=48000)
51+
meta.tofile("recording")
5152
```
5253

5354
### Docs

docs/source/advanced.rst

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -200,8 +200,8 @@ read it, this can be done "in mid air" or "without touching the ground (disk)".
200200
Compressed SigMF Archives
201201
------------------------------
202202

203-
SigMF archives can be compressed using gzip, xz, or zip. The compression format
204-
is determined by the file extension:
203+
SigMF archives can be compressed using gzip, xz, or zip.
204+
The file extension determines the archive format:
205205

206206
+---------------------+-------------+
207207
| Extension | Format |
@@ -222,24 +222,23 @@ is determined by the file extension:
222222
>>> import sigmf
223223
>>> signal = sigmf.sigmffile.fromfile('recording.sigmf-meta')
224224

225-
# compress by extension
226-
>>> signal.archive('recording.sigmf.xz')
225+
# extension determines format
226+
>>> signal.tofile('recording.sigmf.xz')
227+
>>> signal.archive('recording.sigmf.gz')
227228

228-
# or specify compression explicitly
229-
>>> signal.archive('recording.sigmf', compression='gz')
229+
# compression parameter creates archive with correct extension
230+
>>> signal.tofile('recording', compression='xz') # → recording.sigmf.xz
231+
>>> signal.archive('recording', compression='gz') # → recording.sigmf.gz
230232

231233
**Reading compressed archives:**
232234

233235
::
234236

235-
>>> arc = sigmf.SigMFArchiveReader('recording.sigmf.xz')
236-
>>> arc[:10]
237+
>>> signal = sigmf.fromfile('recording.sigmf.xz')
238+
>>> signal[:10]
237239
array([-20.+11.j, ...], dtype=complex64)
238240

239241
**Memory behavior:**
240242

241-
Uncompressed ``.sigmf`` archives use ``numpy.memmap`` to access the data
242-
directly inside the tar file — no extra memory is needed, even for very large
243-
recordings. Compressed archives (``.sigmf.gz``, ``.sigmf.xz``, ``.sigmf.zip``)
244-
must decompress the data into RAM before it can be accessed. Keep this in mind
245-
when working with large compressed recordings.
243+
Uncompressed ``.sigmf`` archives use ``numpy.memmap`` for zero-copy access.
244+
Compressed archives must decompress into RAM before access.

docs/source/quickstart.rst

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -53,17 +53,20 @@ Save a Numpy array as a SigMF Recording
5353
# suppose we have a complex timeseries signal
5454
data = np.zeros(1024, dtype=np.complex64)
5555
56-
# write to disk — datatype is inferred from the numpy array
57-
meta = sigmf.tofile("example", data, sample_rate=48000, frequency=915e6)
56+
# create SigMFFile from array — datatype is inferred from the numpy array
57+
meta = sigmf.fromarray(data, sample_rate=48000, frequency=915e6)
58+
59+
# write to separate .sigmf-meta and .sigmf-data files
60+
meta.tofile("example")
5861
5962
# or write to a SigMF archive (example.sigmf)
60-
meta = sigmf.tofile("example.sigmf", data, sample_rate=48000, frequency=915e6)
63+
meta.tofile("example.sigmf")
6164
62-
# or write directly to a compressed archive (example.sigmf.xz)
63-
meta = sigmf.tofile("example", data, sample_rate=48000, compression="xz")
65+
# or write to a compressed archive (example.sigmf.xz)
66+
meta.tofile("example.sigmf.xz")
6467
65-
The returned ``SigMFFile`` object can be used to add captures, annotations,
66-
or archive the recording.
68+
The ``SigMFFile`` object can be modified before writing to add additional
69+
captures, annotations, or global metadata fields.
6770

6871
---------------------------------------------------
6972
Save a Numpy array with Full Metadata (Advanced)

sigmf/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,4 @@
2222
from .archive import SigMFArchive
2323
from .archivereader import SigMFArchiveReader
2424
from .siggen import SigMFGenerator
25-
from .sigmffile import SigMFCollection, SigMFFile, fromarchive, fromfile, tofile
25+
from .sigmffile import SigMFCollection, SigMFFile, fromarchive, fromarray, fromfile

sigmf/convert/blue.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -726,12 +726,12 @@ def construct_sigmf(
726726
meta.add_capture(0, metadata=capture_info)
727727

728728
if create_archive:
729-
meta.tofile(filenames["archive_fn"], toarchive=True, overwrite=overwrite)
729+
meta.tofile(filenames["archive_fn"], overwrite=overwrite)
730730
log.info("wrote SigMF archive to %s", filenames["archive_fn"])
731731
# metadata returned should be for this archive
732732
meta = fromfile(filenames["archive_fn"])
733733
else:
734-
meta.tofile(filenames["meta_fn"], toarchive=False, overwrite=overwrite)
734+
meta.tofile(filenames["meta_fn"], overwrite=overwrite)
735735
log.info("wrote SigMF metadata to %s", filenames["meta_fn"])
736736

737737
log.debug("created %r", meta)

sigmf/convert/signalhound.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,12 @@
1010
import io
1111
import logging
1212
import tempfile
13-
import defusedxml.ElementTree as ET
14-
from xml.etree.ElementTree import Element
1513
from datetime import datetime, timedelta, timezone
1614
from pathlib import Path
1715
from typing import List, Optional, Tuple
16+
from xml.etree.ElementTree import Element
1817

18+
import defusedxml.ElementTree as ET
1919
import numpy as np
2020

2121
from .. import SigMFFile, fromfile
@@ -407,7 +407,7 @@ def signalhound_to_sigmf(
407407
if out_path is not None:
408408
output_dir = filenames["meta_fn"].parent
409409
output_dir.mkdir(parents=True, exist_ok=True)
410-
meta.tofile(filenames["meta_fn"], toarchive=False, overwrite=overwrite)
410+
meta.tofile(filenames["meta_fn"], overwrite=overwrite)
411411
log.info("wrote SigMF non-conforming metadata to %s", filenames["meta_fn"])
412412

413413
log.debug("created %r", meta)
@@ -435,7 +435,7 @@ def signalhound_to_sigmf(
435435

436436
output_dir = filenames["archive_fn"].parent
437437
output_dir.mkdir(parents=True, exist_ok=True)
438-
meta.tofile(filenames["archive_fn"], toarchive=True, overwrite=overwrite)
438+
meta.tofile(filenames["archive_fn"], overwrite=overwrite)
439439
log.info("wrote SigMF archive to %s", filenames["archive_fn"])
440440
# metadata returned should be for this archive
441441
meta = fromfile(filenames["archive_fn"])
@@ -460,7 +460,7 @@ def signalhound_to_sigmf(
460460
_add_annotations(meta, annotations)
461461

462462
# write metadata file
463-
meta.tofile(filenames["meta_fn"], toarchive=False, overwrite=overwrite)
463+
meta.tofile(filenames["meta_fn"], overwrite=overwrite)
464464
log.info("wrote SigMF metadata to %s", filenames["meta_fn"])
465465

466466
log.debug("created %r", meta)

sigmf/convert/wav.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ def wav_to_sigmf(
176176
filenames = get_sigmf_filenames(out_path)
177177
output_dir = filenames["meta_fn"].parent
178178
output_dir.mkdir(parents=True, exist_ok=True)
179-
meta.tofile(filenames["meta_fn"], toarchive=False, overwrite=overwrite)
179+
meta.tofile(filenames["meta_fn"], overwrite=overwrite)
180180
log.info("wrote SigMF non-conforming metadata to %s", filenames["meta_fn"])
181181

182182
log.debug("created %r", meta)
@@ -201,7 +201,7 @@ def wav_to_sigmf(
201201
meta = SigMFFile(data_file=data_path, global_info=global_info)
202202
meta.add_capture(0, metadata=capture_info)
203203

204-
meta.tofile(filenames["archive_fn"], toarchive=True, overwrite=overwrite)
204+
meta.tofile(filenames["archive_fn"], overwrite=overwrite)
205205
log.info("wrote SigMF archive to %s", filenames["archive_fn"])
206206
# metadata returned should be for this archive
207207
meta = fromfile(filenames["archive_fn"])
@@ -219,7 +219,7 @@ def wav_to_sigmf(
219219
meta = SigMFFile(data_file=data_path, global_info=global_info)
220220
meta.add_capture(0, metadata=capture_info)
221221

222-
meta.tofile(filenames["meta_fn"], toarchive=False, overwrite=overwrite)
222+
meta.tofile(filenames["meta_fn"], overwrite=overwrite)
223223
log.info("wrote SigMF metadata to %s", filenames["meta_fn"])
224224

225225
log.debug("created %r", meta)

sigmf/sigmffile.py

Lines changed: 75 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -824,38 +824,79 @@ def archive(self, name=None, fileobj=None, compression=None, overwrite=False):
824824

825825
def tofile(self, file_path, pretty=True, toarchive=False, compression=None, skip_validate=False, overwrite=False):
826826
"""
827-
Write metadata file or full archive containing metadata & dataset.
827+
Write metadata file or archive based on file extension.
828+
829+
The file extension determines the output format:
830+
- No extension or other extension → `.sigmf-meta` file (and `.sigmf-data` if data_buffer exists)
831+
- `.sigmf` → uncompressed archive
832+
- `.sigmf.gz`, `.sigmf.xz`, `.sigmf.zip` → compressed archive
828833
829834
Parameters
830835
----------
831836
file_path : string
832-
Location to save.
837+
Location to save. Extension determines output format.
833838
pretty : bool, default True
834-
When True will write more human-readable output, otherwise will be flat JSON.
839+
When True will write human-readable JSON, otherwise flat JSON.
835840
toarchive : bool, default False
836-
If True will write both dataset & metadata into SigMF archive format.
837-
If False will only write metadata to `sigmf-meta`.
841+
If True, forces archive creation (writes metadata and data to archive) regardless of file extension.
838842
compression : str, optional
839-
Compression type when toarchive=True: "gz", "xz", "zip", or None.
843+
Compression type: "gz", "xz", "zip", or None.
844+
If specified, must match file extension if extension implies compression.
845+
If no archive extension is present, creates a compressed archive.
840846
skip_validate : bool, default False
841847
Skip validation of metadata before writing.
842848
overwrite : bool, default False
843849
If False, raise exception if output file already exists.
850+
851+
Examples
852+
--------
853+
>>> meta.tofile('recording') # creates recording.sigmf-meta
854+
>>> meta.tofile('recording.sigmf') # creates recording.sigmf (archive)
855+
>>> meta.tofile('recording.sigmf.gz') # creates recording.sigmf.gz (compressed)
856+
>>> meta.tofile('recording', compression='xz') # creates recording.sigmf.xz
844857
"""
845858
if not skip_validate:
846859
self.validate()
847-
fns = get_sigmf_filenames(file_path)
860+
861+
path = Path(file_path)
862+
863+
# auto-detect compression from extension
864+
detected_compression = _detect_compression(path)
865+
if detected_compression is not None:
866+
if compression is not None and compression != detected_compression:
867+
raise SigMFFileError(
868+
f"Extension implies '{detected_compression}' compression but compression='{compression}' was specified."
869+
)
870+
compression = detected_compression
871+
toarchive = True
872+
873+
# auto-detect archive from .sigmf extension
874+
if path.name.lower().endswith(SIGMF_ARCHIVE_EXT):
875+
toarchive = True
876+
877+
# compression implies archive
878+
if compression is not None:
879+
toarchive = True
848880

849881
if toarchive:
850-
self.archive(fns["archive_fn"], compression=compression, overwrite=overwrite)
882+
# pass the original file_path to archive() so it handles extension properly
883+
self.archive(file_path, compression=compression, overwrite=overwrite)
851884
else:
852-
# check if metadata file exists
885+
# write metadata file (and data file if data_buffer exists)
886+
fns = get_sigmf_filenames(file_path)
853887
if not overwrite and fns["meta_fn"].exists():
854888
raise SigMFFileExistsError(fns["meta_fn"], "Metadata file")
855889
with open(fns["meta_fn"], "w") as fp:
856890
self.dump(fp, pretty=pretty)
857891
fp.write("\n") # text files should end in carriage return
858892

893+
# write data file if data_buffer exists
894+
if self.data_buffer is not None:
895+
if not overwrite and fns["data_fn"].exists():
896+
raise SigMFFileExistsError(fns["data_fn"], "Data file")
897+
with open(fns["data_fn"], "wb") as fp:
898+
fp.write(self.data_buffer.getbuffer())
899+
859900
def read_samples_in_capture(self, index=0):
860901
"""
861902
Reads samples from the specified captures segment in its entirety.
@@ -1260,69 +1301,48 @@ def get_dataset_filename_from_metadata(meta_fn, metadata=None):
12601301
return None
12611302

12621303

1263-
def tofile(filename, data, sample_rate, frequency=None, toarchive=False, compression=None, global_info=None):
1304+
def fromarray(data, sample_rate, frequency=None, global_info=None):
12641305
"""
1265-
Convenience method to write a numpy array to a SigMF recording.
1306+
Create a SigMFFile from a numpy array.
12661307
1267-
For quick saves — infers the SigMF datatype from the numpy dtype, writes
1268-
the data file, creates metadata with a single capture at index 0, and
1269-
saves to disk. For full control over captures, annotations, and global
1308+
Convenience function that infers the SigMF datatype from the numpy dtype,
1309+
creates an in-memory SigMFFile with a single capture at index 0. The
1310+
returned object can then be written to disk using ``tofile()`` or
1311+
``archive()``. For full control over captures, annotations, and global
12701312
fields, use ``SigMFFile`` directly.
12711313
12721314
Parameters
12731315
----------
1274-
filename : str | PathLike
1275-
Base filename or archive path. Accepts:
1276-
- ``"recording"`` — produces ``recording.sigmf-data`` and ``recording.sigmf-meta``
1277-
- ``"recording.sigmf"`` — produces uncompressed archive (auto-detects toarchive)
1278-
- ``"recording.sigmf.xz"`` — produces compressed archive (auto-detects compression)
12791316
data : np.ndarray
1280-
Signal samples to write.
1317+
Signal samples.
12811318
sample_rate : float
12821319
Sample rate in Hz.
12831320
frequency : float, optional
12841321
Center frequency in Hz for the capture.
1285-
toarchive : bool, default False
1286-
If True, produce a ``.sigmf`` archive instead of loose data/meta files.
1287-
Auto-detected from filename extension if not specified.
1288-
compression : str, optional
1289-
If set, also creates a compressed archive. One of "gz", "xz", "zip".
1290-
Auto-detected from filename extension if not specified. Implies toarchive.
12911322
global_info : dict, optional
12921323
Additional global metadata fields to include.
12931324
12941325
Returns
12951326
-------
12961327
SigMFFile
1297-
The SigMFFile object with data and metadata.
1328+
The SigMFFile object with in-memory data and metadata.
1329+
1330+
Examples
1331+
--------
1332+
>>> import numpy as np
1333+
>>> data = np.random.randn(1000) + 1j * np.random.randn(1000)
1334+
>>> meta = fromarray(data, sample_rate=1e6, frequency=915e6)
1335+
>>> meta.tofile('recording') # creates recording.sigmf-meta and recording.sigmf-data
1336+
>>> meta.tofile('recording.sigmf') # creates recording.sigmf archive
12981337
"""
1299-
file_path = Path(filename)
1300-
1301-
# detect compressed extension and extract base name
1302-
detected = _detect_compression(file_path)
1303-
if detected is not None:
1304-
if compression is not None and compression != detected:
1305-
raise SigMFFileError(
1306-
f"Extension implies '{detected}' compression but compression='{compression}' was specified."
1307-
)
1308-
compression = detected
1309-
base_name = _get_archive_basename(file_path)
1310-
base_path = file_path.parent / base_name
1311-
elif file_path.name.endswith(SIGMF_ARCHIVE_EXT):
1312-
toarchive = True
1313-
base_path = file_path.parent / file_path.stem
1314-
else:
1315-
base_path = file_path
1316-
1317-
# compression implies archive
1318-
if compression is not None:
1319-
toarchive = True
1320-
1321-
fns = get_sigmf_filenames(base_path)
1322-
data_path = fns["data_fn"]
1338+
import io
13231339

1324-
data.tofile(data_path)
1340+
# create in-memory data buffer
1341+
data_buffer = io.BytesIO()
1342+
data_buffer.write(data.tobytes())
1343+
data_buffer.seek(0)
13251344

1345+
# build metadata
13261346
info = {
13271347
SigMFFile.DATATYPE_KEY: get_data_type_str(data),
13281348
SigMFFile.SAMPLE_RATE_KEY: sample_rate,
@@ -1334,16 +1354,11 @@ def tofile(filename, data, sample_rate, frequency=None, toarchive=False, compres
13341354
if frequency is not None:
13351355
capture_meta = {SigMFFile.FREQUENCY_KEY: frequency}
13361356

1337-
meta = SigMFFile(data_file=data_path, global_info=info)
1357+
# create sigmffile object with in-memory buffer
1358+
meta = SigMFFile(global_info=info)
1359+
meta.set_data_file(data_buffer=data_buffer)
13381360
meta.add_capture(0, metadata=capture_meta)
13391361

1340-
if toarchive:
1341-
# create archive only — no loose files
1342-
meta.archive(str(fns["base_fn"]), compression=compression)
1343-
data_path.unlink()
1344-
else:
1345-
meta.tofile(base_path)
1346-
13471362
return meta
13481363

13491364

0 commit comments

Comments
 (0)