cicwave 0.7.2__tar.gz → 0.7.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {cicwave-0.7.2/src/cicwave.egg-info → cicwave-0.7.4}/PKG-INFO +19 -1
- {cicwave-0.7.2 → cicwave-0.7.4}/README.md +16 -0
- {cicwave-0.7.2 → cicwave-0.7.4}/pyproject.toml +2 -1
- {cicwave-0.7.2 → cicwave-0.7.4}/src/cicwave/__init__.py +6 -1
- {cicwave-0.7.2 → cicwave-0.7.4}/src/cicwave/analysis.py +81 -0
- {cicwave-0.7.2 → cicwave-0.7.4}/src/cicwave/apisource.py +20 -5
- {cicwave-0.7.2 → cicwave-0.7.4}/src/cicwave/cli.py +2 -1
- cicwave-0.7.4/src/cicwave/plugins.py +253 -0
- {cicwave-0.7.2 → cicwave-0.7.4}/src/cicwave/wave_pg.py +559 -31
- {cicwave-0.7.2 → cicwave-0.7.4}/src/cicwave/wavefiles.py +524 -2
- {cicwave-0.7.2 → cicwave-0.7.4/src/cicwave.egg-info}/PKG-INFO +19 -1
- {cicwave-0.7.2 → cicwave-0.7.4}/src/cicwave.egg-info/SOURCES.txt +1 -0
- {cicwave-0.7.2 → cicwave-0.7.4}/src/cicwave.egg-info/requires.txt +3 -0
- {cicwave-0.7.2 → cicwave-0.7.4}/LICENSE +0 -0
- {cicwave-0.7.2 → cicwave-0.7.4}/setup.cfg +0 -0
- {cicwave-0.7.2 → cicwave-0.7.4}/src/cicwave/command.py +0 -0
- {cicwave-0.7.2 → cicwave-0.7.4}/src/cicwave/mcp_server.py +0 -0
- {cicwave-0.7.2 → cicwave-0.7.4}/src/cicwave/ngraw.py +0 -0
- {cicwave-0.7.2 → cicwave-0.7.4}/src/cicwave/pivot.py +0 -0
- {cicwave-0.7.2 → cicwave-0.7.4}/src/cicwave/stdf.py +0 -0
- {cicwave-0.7.2 → cicwave-0.7.4}/src/cicwave/theme.py +0 -0
- {cicwave-0.7.2 → cicwave-0.7.4}/src/cicwave.egg-info/dependency_links.txt +0 -0
- {cicwave-0.7.2 → cicwave-0.7.4}/src/cicwave.egg-info/entry_points.txt +0 -0
- {cicwave-0.7.2 → cicwave-0.7.4}/src/cicwave.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cicwave
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.4
|
|
4
4
|
Summary: Advanced waveform viewer with PyQtGraph backend
|
|
5
5
|
Author-email: Carsten Wulff <carsten@wulff.no>
|
|
6
6
|
License: MIT
|
|
@@ -35,6 +35,8 @@ Provides-Extra: parquet
|
|
|
35
35
|
Requires-Dist: pyarrow; extra == "parquet"
|
|
36
36
|
Provides-Extra: hdf5
|
|
37
37
|
Requires-Dist: tables; extra == "hdf5"
|
|
38
|
+
Provides-Extra: audio
|
|
39
|
+
Requires-Dist: soundfile; extra == "audio"
|
|
38
40
|
Provides-Extra: mcp
|
|
39
41
|
Requires-Dist: mcp[cli]; extra == "mcp"
|
|
40
42
|
Dynamic: license-file
|
|
@@ -80,8 +82,10 @@ repo. As such, you're here.
|
|
|
80
82
|
- Pickle (`.pkl`) for cached DataFrames
|
|
81
83
|
- VCD digital waveforms
|
|
82
84
|
- LitePoint `.iqvsa` IQ capture files
|
|
85
|
+
- SigMF IQ recordings (`.sigmf-meta` + `.sigmf-data`, or `.sigmf` archives)
|
|
83
86
|
- STDF (`.stdf`, `.stdf.gz`) semiconductor ATE test results
|
|
84
87
|
- Raw `uint32` counter captures (`.u32`) with a JSON sidecar
|
|
88
|
+
- Audio: `.wav` natively; FLAC, Ogg, AIFF, MP3 with `cicwave[audio]`
|
|
85
89
|
- Statistical formats: Stata (`.dta`), SAS (`.sas7bdat`), SPSS (`.sav`)
|
|
86
90
|
- **URL data sources**: load CSV/JSON/Excel/Parquet/... straight from an
|
|
87
91
|
`http(s)://` URL — REST APIs, hosted datasets, anything pandas can read
|
|
@@ -100,6 +104,11 @@ repo. As such, you're here.
|
|
|
100
104
|
- **GPU-accelerated plotting** for smooth interaction with large datasets
|
|
101
105
|
- **MCP server** (`cicwave-mcp`, optional): plot/analyze tools for an
|
|
102
106
|
agent driving tests to call directly — see [docs](https://wulffern.github.io/cicwave/mcp)
|
|
107
|
+
- **Plugins**: private file formats, annotations, analyses and constellation
|
|
108
|
+
presets from separately installed packages, listed under Help → Plugins —
|
|
109
|
+
see [docs](https://wulffern.github.io/cicwave/plugins) and the examples:
|
|
110
|
+
a UART decoder ([`examples/cicwave-uart`](examples/cicwave-uart)) and a
|
|
111
|
+
Touchstone reader with Smith chart ([`examples/cicwave-smith`](examples/cicwave-smith))
|
|
103
112
|
|
|
104
113
|
## Installation
|
|
105
114
|
|
|
@@ -113,6 +122,12 @@ For the MCP server (agent-driven plotting/analysis; requires Python 3.10+):
|
|
|
113
122
|
pip install "cicwave[mcp]"
|
|
114
123
|
```
|
|
115
124
|
|
|
125
|
+
For FLAC, Ogg, AIFF and MP3 audio (WAV works without it):
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
pip install "cicwave[audio]"
|
|
129
|
+
```
|
|
130
|
+
|
|
116
131
|
For development installation:
|
|
117
132
|
|
|
118
133
|
```bash
|
|
@@ -198,8 +213,10 @@ through to the ngspice raw reader, so non-standard suffixes (e.g. `.raw0`,
|
|
|
198
213
|
| Whitespace text | `.dat`, `.spe`, `.cou`, `.chi` | Eldo `.cou`/`.chi`, ngspice `.dat`, generic space/tab columns; `#` comments stripped by default |
|
|
199
214
|
| VCD | `.vcd` | Value Change Dump — digital simulation waveforms |
|
|
200
215
|
| LitePoint IQ | `.iqvsa` | LitePoint IQxstream / IQfact IQ capture data |
|
|
216
|
+
| SigMF | `.sigmf-meta`, `.sigmf-data`, `.sigmf-data.zip`, `.sigmf` | SigMF recordings (open either half of the pair, or a `.sigmf` archive). A zipped dataset (`<name>.sigmf-data.zip`) is read in place without unpacking. `litepoint:scale` converts counts to analyser units; `litepoint:channel_offset_hz` / `litepoint:spectrum_inverted` put a low-IF capture's FFT on the RF axis. Each recording also gets magnitude views: `mag` (\|iq\|) and `power_dBm` (dBm for LitePoint analyser captures, matching their burst annotations), `power_dBFS` (ADC counts with a stated full scale) or `power_dB`. All `core:datatype`s (`cf32_le`, `ci16_le`, `cu8`, `rf64_be`, …), multi-channel and per-capture header bytes. Gives a complex `iq` column plus `I`/`Q`, a `time` axis from `core:sample_rate`, and the FFT is centred on the first capture's `core:frequency` |
|
|
201
217
|
| STDF | `.stdf`, `.stdf.gz` | Semiconductor ATE test results (SEMI E10/V4) — parametric (PTR) results per part/site, gzip auto-detected |
|
|
202
218
|
| Raw counter | `.u32` | Bare little-endian `uint32` counter records with a `.meta.json` sidecar giving the tick length and periods per sample; converted to Hz or ns on load, with the dead time between capture chunks preserved |
|
|
219
|
+
| Audio | `.wav`, `.flac`, `.ogg`, `.opus`, `.aif`/`.aiff`, `.mp3`, … | Audio recordings. `.wav` is read natively (PCM 8/16/24/32-bit, 32/64-bit float, `WAVE_FORMAT_EXTENSIBLE`, RF64); the other formats need `pip install cicwave[audio]` (soundfile/libsndfile). Samples are in full scale (±1) with a `time` axis in seconds, one column per channel: `audio` (mono), `left`/`right` (stereo) or `ch0`, `ch1`, … The sample rate prefills the FFT and analysis dialogs |
|
|
203
220
|
|
|
204
221
|
### Tabular text formats
|
|
205
222
|
|
|
@@ -239,6 +256,7 @@ through to the ngspice raw reader, so non-standard suffixes (e.g. `.raw0`,
|
|
|
239
256
|
## Environment Variables
|
|
240
257
|
|
|
241
258
|
- `CICWAVE_X` - Default X-axis column name
|
|
259
|
+
- `CICWAVE_PLUGINS=0` - Start without loading any [plugins](https://wulffern.github.io/cicwave/plugins)
|
|
242
260
|
|
|
243
261
|
## GUI Features
|
|
244
262
|
|
|
@@ -39,8 +39,10 @@ repo. As such, you're here.
|
|
|
39
39
|
- Pickle (`.pkl`) for cached DataFrames
|
|
40
40
|
- VCD digital waveforms
|
|
41
41
|
- LitePoint `.iqvsa` IQ capture files
|
|
42
|
+
- SigMF IQ recordings (`.sigmf-meta` + `.sigmf-data`, or `.sigmf` archives)
|
|
42
43
|
- STDF (`.stdf`, `.stdf.gz`) semiconductor ATE test results
|
|
43
44
|
- Raw `uint32` counter captures (`.u32`) with a JSON sidecar
|
|
45
|
+
- Audio: `.wav` natively; FLAC, Ogg, AIFF, MP3 with `cicwave[audio]`
|
|
44
46
|
- Statistical formats: Stata (`.dta`), SAS (`.sas7bdat`), SPSS (`.sav`)
|
|
45
47
|
- **URL data sources**: load CSV/JSON/Excel/Parquet/... straight from an
|
|
46
48
|
`http(s)://` URL — REST APIs, hosted datasets, anything pandas can read
|
|
@@ -59,6 +61,11 @@ repo. As such, you're here.
|
|
|
59
61
|
- **GPU-accelerated plotting** for smooth interaction with large datasets
|
|
60
62
|
- **MCP server** (`cicwave-mcp`, optional): plot/analyze tools for an
|
|
61
63
|
agent driving tests to call directly — see [docs](https://wulffern.github.io/cicwave/mcp)
|
|
64
|
+
- **Plugins**: private file formats, annotations, analyses and constellation
|
|
65
|
+
presets from separately installed packages, listed under Help → Plugins —
|
|
66
|
+
see [docs](https://wulffern.github.io/cicwave/plugins) and the examples:
|
|
67
|
+
a UART decoder ([`examples/cicwave-uart`](examples/cicwave-uart)) and a
|
|
68
|
+
Touchstone reader with Smith chart ([`examples/cicwave-smith`](examples/cicwave-smith))
|
|
62
69
|
|
|
63
70
|
## Installation
|
|
64
71
|
|
|
@@ -72,6 +79,12 @@ For the MCP server (agent-driven plotting/analysis; requires Python 3.10+):
|
|
|
72
79
|
pip install "cicwave[mcp]"
|
|
73
80
|
```
|
|
74
81
|
|
|
82
|
+
For FLAC, Ogg, AIFF and MP3 audio (WAV works without it):
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
pip install "cicwave[audio]"
|
|
86
|
+
```
|
|
87
|
+
|
|
75
88
|
For development installation:
|
|
76
89
|
|
|
77
90
|
```bash
|
|
@@ -157,8 +170,10 @@ through to the ngspice raw reader, so non-standard suffixes (e.g. `.raw0`,
|
|
|
157
170
|
| Whitespace text | `.dat`, `.spe`, `.cou`, `.chi` | Eldo `.cou`/`.chi`, ngspice `.dat`, generic space/tab columns; `#` comments stripped by default |
|
|
158
171
|
| VCD | `.vcd` | Value Change Dump — digital simulation waveforms |
|
|
159
172
|
| LitePoint IQ | `.iqvsa` | LitePoint IQxstream / IQfact IQ capture data |
|
|
173
|
+
| SigMF | `.sigmf-meta`, `.sigmf-data`, `.sigmf-data.zip`, `.sigmf` | SigMF recordings (open either half of the pair, or a `.sigmf` archive). A zipped dataset (`<name>.sigmf-data.zip`) is read in place without unpacking. `litepoint:scale` converts counts to analyser units; `litepoint:channel_offset_hz` / `litepoint:spectrum_inverted` put a low-IF capture's FFT on the RF axis. Each recording also gets magnitude views: `mag` (\|iq\|) and `power_dBm` (dBm for LitePoint analyser captures, matching their burst annotations), `power_dBFS` (ADC counts with a stated full scale) or `power_dB`. All `core:datatype`s (`cf32_le`, `ci16_le`, `cu8`, `rf64_be`, …), multi-channel and per-capture header bytes. Gives a complex `iq` column plus `I`/`Q`, a `time` axis from `core:sample_rate`, and the FFT is centred on the first capture's `core:frequency` |
|
|
160
174
|
| STDF | `.stdf`, `.stdf.gz` | Semiconductor ATE test results (SEMI E10/V4) — parametric (PTR) results per part/site, gzip auto-detected |
|
|
161
175
|
| Raw counter | `.u32` | Bare little-endian `uint32` counter records with a `.meta.json` sidecar giving the tick length and periods per sample; converted to Hz or ns on load, with the dead time between capture chunks preserved |
|
|
176
|
+
| Audio | `.wav`, `.flac`, `.ogg`, `.opus`, `.aif`/`.aiff`, `.mp3`, … | Audio recordings. `.wav` is read natively (PCM 8/16/24/32-bit, 32/64-bit float, `WAVE_FORMAT_EXTENSIBLE`, RF64); the other formats need `pip install cicwave[audio]` (soundfile/libsndfile). Samples are in full scale (±1) with a `time` axis in seconds, one column per channel: `audio` (mono), `left`/`right` (stereo) or `ch0`, `ch1`, … The sample rate prefills the FFT and analysis dialogs |
|
|
162
177
|
|
|
163
178
|
### Tabular text formats
|
|
164
179
|
|
|
@@ -198,6 +213,7 @@ through to the ngspice raw reader, so non-standard suffixes (e.g. `.raw0`,
|
|
|
198
213
|
## Environment Variables
|
|
199
214
|
|
|
200
215
|
- `CICWAVE_X` - Default X-axis column name
|
|
216
|
+
- `CICWAVE_PLUGINS=0` - Start without loading any [plugins](https://wulffern.github.io/cicwave/plugins)
|
|
201
217
|
|
|
202
218
|
## GUI Features
|
|
203
219
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "cicwave"
|
|
7
|
-
version = "0.7.
|
|
7
|
+
version = "0.7.4"
|
|
8
8
|
authors = [
|
|
9
9
|
{ name="Carsten Wulff", email="carsten@wulff.no" },
|
|
10
10
|
]
|
|
@@ -42,6 +42,7 @@ classifiers = [
|
|
|
42
42
|
[project.optional-dependencies]
|
|
43
43
|
parquet = ["pyarrow"] # Parquet file support
|
|
44
44
|
hdf5 = ["tables"] # HDF5 file support
|
|
45
|
+
audio = ["soundfile"] # FLAC/Ogg/AIFF/MP3 audio (.wav needs nothing)
|
|
45
46
|
mcp = ["mcp[cli]"] # MCP server (cicwave-mcp); requires Python >= 3.10
|
|
46
47
|
|
|
47
48
|
[project.scripts]
|
|
@@ -11,6 +11,7 @@ Supports multiple file formats:
|
|
|
11
11
|
- NumPy .npz archives (tabular / bench traces)
|
|
12
12
|
- VCD digital waveforms
|
|
13
13
|
- LitePoint .iqvsa IQ capture files
|
|
14
|
+
- SigMF IQ recordings (.sigmf-meta/.sigmf-data, .sigmf archives)
|
|
14
15
|
|
|
15
16
|
Features:
|
|
16
17
|
- GPU-accelerated rendering with PyQtGraph
|
|
@@ -21,6 +22,10 @@ Features:
|
|
|
21
22
|
- Engineering unit formatting
|
|
22
23
|
"""
|
|
23
24
|
|
|
24
|
-
|
|
25
|
+
try:
|
|
26
|
+
from importlib.metadata import PackageNotFoundError, version as _version
|
|
27
|
+
__version__ = _version("cicwave")
|
|
28
|
+
except PackageNotFoundError: # running from a source tree that is not installed
|
|
29
|
+
__version__ = "0.0.0"
|
|
25
30
|
__author__ = "Carsten Wulff"
|
|
26
31
|
__email__ = "carsten@wulff.no"
|
|
@@ -29,6 +29,8 @@ __all__ = [
|
|
|
29
29
|
"linear_fit",
|
|
30
30
|
"LinearFitResult",
|
|
31
31
|
"difference",
|
|
32
|
+
"constellation",
|
|
33
|
+
"ConstellationResult",
|
|
32
34
|
"run_analysis_steps",
|
|
33
35
|
"AnalysisRunResult",
|
|
34
36
|
]
|
|
@@ -713,6 +715,85 @@ def difference(
|
|
|
713
715
|
return a[:n] - b[:n]
|
|
714
716
|
|
|
715
717
|
|
|
718
|
+
@dataclass(frozen=True)
|
|
719
|
+
class ConstellationResult:
|
|
720
|
+
points: np.ndarray # complex symbol (or sample) decisions
|
|
721
|
+
samples_per_symbol: float # 1.0 when no symbol rate was given
|
|
722
|
+
offset_samples: float
|
|
723
|
+
freq_offset_hz: float
|
|
724
|
+
rms: float # RMS magnitude before normalisation
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def constellation(
|
|
728
|
+
iq: np.ndarray,
|
|
729
|
+
*,
|
|
730
|
+
fs: Optional[float] = None,
|
|
731
|
+
symbol_rate: Optional[float] = None,
|
|
732
|
+
offset: float = 0.0,
|
|
733
|
+
freq_offset_hz: float = 0.0,
|
|
734
|
+
phase_deg: float = 0.0,
|
|
735
|
+
conjugate: bool = False,
|
|
736
|
+
start_sample: int = 0,
|
|
737
|
+
normalize: bool = True) -> ConstellationResult:
|
|
738
|
+
"""Pick constellation points out of a complex baseband capture.
|
|
739
|
+
|
|
740
|
+
*freq_offset_hz* is the carrier offset to remove: the capture is
|
|
741
|
+
derotated by ``exp(-j·2π·f·t)`` (needs *fs*), then turned by
|
|
742
|
+
*phase_deg*; *conjugate* then mirrors Q, undoing a spectrum-inverted
|
|
743
|
+
receiver. With *symbol_rate* the result holds one point per symbol,
|
|
744
|
+
taken at sample ``offset + k·fs/symbol_rate`` (rounded, so fractional
|
|
745
|
+
samples per symbol work); without it every sample from *offset* on is
|
|
746
|
+
returned, which shows the trajectory. *normalize* scales the points to
|
|
747
|
+
unit RMS magnitude so constellations from different gains overlay.
|
|
748
|
+
*start_sample* is where *iq* begins in the whole recording: the
|
|
749
|
+
derotation runs on the recording's time base, so a phase found for one
|
|
750
|
+
part of a capture still holds when another part is selected.
|
|
751
|
+
"""
|
|
752
|
+
z = np.asarray(iq)
|
|
753
|
+
if not np.iscomplexobj(z):
|
|
754
|
+
raise ValueError("constellation needs complex IQ samples")
|
|
755
|
+
z = z.astype(np.complex128)
|
|
756
|
+
fs = float(fs) if fs else None
|
|
757
|
+
if freq_offset_hz or symbol_rate:
|
|
758
|
+
if fs is None or not np.isfinite(fs) or fs <= 0:
|
|
759
|
+
raise ValueError(
|
|
760
|
+
"a sample rate is needed for a symbol rate or a "
|
|
761
|
+
"frequency offset")
|
|
762
|
+
if freq_offset_hz:
|
|
763
|
+
t = (np.arange(z.size, dtype=np.float64) + int(start_sample)) / fs
|
|
764
|
+
z = z * np.exp(-2j * np.pi * float(freq_offset_hz) * t)
|
|
765
|
+
if phase_deg:
|
|
766
|
+
z = z * np.exp(-1j * np.deg2rad(float(phase_deg)))
|
|
767
|
+
if conjugate:
|
|
768
|
+
z = np.conj(z)
|
|
769
|
+
|
|
770
|
+
offset = float(offset or 0.0)
|
|
771
|
+
if offset < 0:
|
|
772
|
+
raise ValueError("offset must be >= 0 samples")
|
|
773
|
+
if symbol_rate:
|
|
774
|
+
sps = fs / float(symbol_rate)
|
|
775
|
+
if sps < 1.0:
|
|
776
|
+
raise ValueError(
|
|
777
|
+
"symbol rate %.6g Hz is above the sample rate %.6g Hz"
|
|
778
|
+
% (symbol_rate, fs))
|
|
779
|
+
count = int(np.floor((z.size - 1 - offset) / sps)) + 1 \
|
|
780
|
+
if z.size > offset else 0
|
|
781
|
+
idx = np.rint(offset + np.arange(max(count, 0)) * sps).astype(np.int64)
|
|
782
|
+
idx = idx[idx < z.size]
|
|
783
|
+
pts = z[idx]
|
|
784
|
+
else:
|
|
785
|
+
sps = 1.0
|
|
786
|
+
pts = z[int(round(offset)):]
|
|
787
|
+
|
|
788
|
+
pts = pts[np.isfinite(pts)]
|
|
789
|
+
rms_mag = float(np.sqrt(np.mean(np.abs(pts) ** 2))) if pts.size else 0.0
|
|
790
|
+
if normalize and rms_mag > 0:
|
|
791
|
+
pts = pts / rms_mag
|
|
792
|
+
return ConstellationResult(
|
|
793
|
+
points=pts, samples_per_symbol=float(sps), offset_samples=offset,
|
|
794
|
+
freq_offset_hz=float(freq_offset_hz or 0.0), rms=rms_mag)
|
|
795
|
+
|
|
796
|
+
|
|
716
797
|
@dataclass
|
|
717
798
|
class AnalysisRunResult:
|
|
718
799
|
"""Structured + text output from :func:`run_analysis_steps`."""
|
|
@@ -367,6 +367,12 @@ def _run_request(req, idx, results, fetcher):
|
|
|
367
367
|
return out
|
|
368
368
|
|
|
369
369
|
|
|
370
|
+
_DERIVE_KEYS = {
|
|
371
|
+
"from", "regex", "group", "kv", "sep", "assign", "split", "index",
|
|
372
|
+
"type", "scale", "offset",
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
|
|
370
376
|
def _derive_extractor(rule, what):
|
|
371
377
|
"""Return ``f(text) -> value`` for one derive rule.
|
|
372
378
|
|
|
@@ -375,6 +381,7 @@ def _derive_extractor(rule, what):
|
|
|
375
381
|
"""
|
|
376
382
|
if not isinstance(rule, dict):
|
|
377
383
|
raise ValueError("%s: must be a mapping" % what)
|
|
384
|
+
_check_keys(rule, _DERIVE_KEYS, what)
|
|
378
385
|
|
|
379
386
|
if "regex" in rule:
|
|
380
387
|
pattern = re.compile(str(rule["regex"]))
|
|
@@ -423,16 +430,18 @@ def _derive_extractor(rule, what):
|
|
|
423
430
|
raise ValueError("%s: needs one of 'regex', 'kv' or 'split'" % what)
|
|
424
431
|
|
|
425
432
|
|
|
426
|
-
def _cast_value(value, dtype, what):
|
|
427
|
-
if dtype in (None, "str"):
|
|
433
|
+
def _cast_value(value, dtype, what, scale=None, offset=None):
|
|
434
|
+
if dtype in (None, "str") and scale is None and offset is None:
|
|
428
435
|
return value
|
|
429
|
-
if dtype not in ("int", "float"):
|
|
436
|
+
if dtype not in (None, "int", "float", "str"):
|
|
430
437
|
raise ValueError(
|
|
431
438
|
"%s: unknown type '%s' (int, float or str)" % (what, dtype))
|
|
432
439
|
try:
|
|
433
440
|
number = float(value)
|
|
434
441
|
except (TypeError, ValueError):
|
|
435
442
|
return None
|
|
443
|
+
number = number * float(1 if scale is None else scale)
|
|
444
|
+
number = number + float(0 if offset is None else offset)
|
|
436
445
|
return int(number) if dtype == "int" else number
|
|
437
446
|
|
|
438
447
|
|
|
@@ -449,8 +458,13 @@ def _derive_series(df, name, rule):
|
|
|
449
458
|
out = df[src].astype(str).map(_derive_extractor(rule, what))
|
|
450
459
|
|
|
451
460
|
dtype = rule.get("type")
|
|
452
|
-
|
|
461
|
+
scale, offset = rule.get("scale"), rule.get("offset")
|
|
462
|
+
if dtype in ("int", "float") or scale is not None or offset is not None:
|
|
453
463
|
out = pd.to_numeric(out, errors="coerce")
|
|
464
|
+
if scale is not None:
|
|
465
|
+
out = out * float(scale)
|
|
466
|
+
if offset is not None:
|
|
467
|
+
out = out + float(offset)
|
|
454
468
|
if dtype == "int":
|
|
455
469
|
out = out.astype("Int64")
|
|
456
470
|
elif dtype not in (None, "str"):
|
|
@@ -473,13 +487,14 @@ def _derive_records(rows, derive, what):
|
|
|
473
487
|
raise ValueError("%s: needs a 'from' field" % rule_what)
|
|
474
488
|
extract = _derive_extractor(rule, rule_what)
|
|
475
489
|
dtype = rule.get("type")
|
|
490
|
+
scale, offset = rule.get("scale"), rule.get("offset")
|
|
476
491
|
for row in rows:
|
|
477
492
|
if src not in row:
|
|
478
493
|
raise ValueError(
|
|
479
494
|
"%s: field '%s' is not in the records (available: %s)" % (
|
|
480
495
|
rule_what, src, ", ".join(sorted(row)) or "none"))
|
|
481
496
|
value = extract("" if row[src] is None else str(row[src]))
|
|
482
|
-
row[name] = _cast_value(value, dtype, rule_what)
|
|
497
|
+
row[name] = _cast_value(value, dtype, rule_what, scale, offset)
|
|
483
498
|
return rows
|
|
484
499
|
|
|
485
500
|
|
|
@@ -404,7 +404,8 @@ def main(files, globs, x, sheet, fmt, pivot, pivot_info, session, export,
|
|
|
404
404
|
|
|
405
405
|
Supports: .raw, .csv, .tsv, .xlsx, .json, .parquet, .feather, .npz,
|
|
406
406
|
.h5, .pkl, .vcd (digital), .iqvsa (LitePoint), .stdf (ATE),
|
|
407
|
-
.u32 (raw counter captures),
|
|
407
|
+
.u32 (raw counter captures), .sigmf-meta/.sigmf (SigMF IQ),
|
|
408
|
+
.wav/.flac/.ogg/.aiff/.mp3 (audio), and more.
|
|
408
409
|
|
|
409
410
|
\b
|
|
410
411
|
URL sources:
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Plugin API: extend cicwave from separately installed packages.
|
|
2
|
+
|
|
3
|
+
cicwave itself stays generic. Anything specific to a private format,
|
|
4
|
+
instrument or protocol lives in its own package, which declares an entry
|
|
5
|
+
point in the ``cicwave.plugins`` group::
|
|
6
|
+
|
|
7
|
+
# pyproject.toml of the plugin package
|
|
8
|
+
[project.entry-points."cicwave.plugins"]
|
|
9
|
+
myplugin = "myplugin:register"
|
|
10
|
+
|
|
11
|
+
The entry point names a callable taking this module's :class:`PluginAPI`.
|
|
12
|
+
It is called once, the first time cicwave needs its plugins, and uses the
|
|
13
|
+
API to register:
|
|
14
|
+
|
|
15
|
+
* **readers** -- ``register_reader(".ext", fn)``; ``fn(path)`` returns a
|
|
16
|
+
:class:`pandas.DataFrame`. Matched on the longest filename suffix, before
|
|
17
|
+
the built-in readers, so a plugin can also take over a built-in suffix.
|
|
18
|
+
* **annotators** -- ``register_annotator(fn)``; ``fn(df, path)`` runs after
|
|
19
|
+
every file is read and may add attributes or annotations to *df* in
|
|
20
|
+
place (see :func:`annotations`). This is how a plugin labels the parts
|
|
21
|
+
of a recording (e.g. the sections of each packet) without editing files.
|
|
22
|
+
* **analyses** -- ``register_analysis(label, fn)``; adds *label* to a
|
|
23
|
+
wave's right-click menu, and ``fn(window, wave)`` runs when chosen.
|
|
24
|
+
* **constellation presets** -- ``register_constellation_preset(name,
|
|
25
|
+
settings)``; offered in the constellation dialog, filling its fields.
|
|
26
|
+
|
|
27
|
+
``set_description(text)`` gives the plugin a one-line description for
|
|
28
|
+
Help > Plugins; without it the package's own summary is shown.
|
|
29
|
+
|
|
30
|
+
A plugin that fails to load or raises is logged and skipped, never taking
|
|
31
|
+
cicwave down with it. ``CICWAVE_PLUGINS=0`` in the environment disables
|
|
32
|
+
plugin discovery altogether (cicwave's own test suite runs that way).
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
import logging
|
|
36
|
+
import os
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"API_VERSION",
|
|
40
|
+
"PluginAPI",
|
|
41
|
+
"load_plugins",
|
|
42
|
+
"annotations",
|
|
43
|
+
"find_reader",
|
|
44
|
+
"run_annotators",
|
|
45
|
+
"analyses",
|
|
46
|
+
"constellation_presets",
|
|
47
|
+
"CONSTELLATION_FIELDS",
|
|
48
|
+
"plugin_info",
|
|
49
|
+
"describe_plugins",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
#: Bumped when the API changes incompatibly; a plugin can compare it.
|
|
53
|
+
API_VERSION = 1
|
|
54
|
+
|
|
55
|
+
ENTRY_POINT_GROUP = "cicwave.plugins"
|
|
56
|
+
|
|
57
|
+
#: Keys a constellation preset may set, mirroring the dialog's fields.
|
|
58
|
+
CONSTELLATION_FIELDS = (
|
|
59
|
+
"symbol_rate", "offset", "freq_offset", "phase", "window_start",
|
|
60
|
+
"window_stop", "conjugate", "normalize", "trajectory", "annotation",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
_log = logging.getLogger("cicwave.plugins")
|
|
64
|
+
|
|
65
|
+
_readers = {}
|
|
66
|
+
_annotators = []
|
|
67
|
+
_analyses = []
|
|
68
|
+
_presets = {}
|
|
69
|
+
_info = {}
|
|
70
|
+
_loaded = False
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class PluginAPI:
|
|
74
|
+
"""What a plugin's ``register`` callable receives."""
|
|
75
|
+
|
|
76
|
+
API_VERSION = API_VERSION
|
|
77
|
+
|
|
78
|
+
def __init__(self, name):
|
|
79
|
+
self.name = name
|
|
80
|
+
self._info = _info.setdefault(name, _new_info(name))
|
|
81
|
+
|
|
82
|
+
def set_description(self, text):
|
|
83
|
+
"""One line saying what the plugin is for, shown in Help > Plugins."""
|
|
84
|
+
self._info["description"] = str(text).strip()
|
|
85
|
+
|
|
86
|
+
def register_reader(self, suffix, fn):
|
|
87
|
+
suffix = suffix.lower()
|
|
88
|
+
if not suffix.startswith("."):
|
|
89
|
+
suffix = "." + suffix
|
|
90
|
+
_readers[suffix] = (fn, self.name)
|
|
91
|
+
self._info["readers"].append(suffix)
|
|
92
|
+
|
|
93
|
+
def register_annotator(self, fn):
|
|
94
|
+
_annotators.append((fn, self.name))
|
|
95
|
+
self._info["annotators"].append(getattr(fn, "__name__", repr(fn)))
|
|
96
|
+
|
|
97
|
+
def register_analysis(self, label, fn):
|
|
98
|
+
_analyses.append((label, fn, self.name))
|
|
99
|
+
self._info["analyses"].append(label)
|
|
100
|
+
|
|
101
|
+
def register_constellation_preset(self, name, settings):
|
|
102
|
+
unknown = set(settings) - set(CONSTELLATION_FIELDS)
|
|
103
|
+
if unknown:
|
|
104
|
+
raise ValueError("unknown constellation preset field(s): %s"
|
|
105
|
+
% ", ".join(sorted(unknown)))
|
|
106
|
+
_presets[name] = dict(settings)
|
|
107
|
+
self._info["presets"].append(name)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _new_info(name):
|
|
111
|
+
return {"name": name, "description": "", "version": "", "error": None,
|
|
112
|
+
"readers": [], "annotators": [], "analyses": [], "presets": []}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _dist_metadata(ep):
|
|
116
|
+
"""``(summary, version)`` of the package providing *ep*, if known."""
|
|
117
|
+
dist = getattr(ep, "dist", None)
|
|
118
|
+
if dist is None:
|
|
119
|
+
return "", ""
|
|
120
|
+
try:
|
|
121
|
+
return (dist.metadata.get("Summary") or "", dist.version or "")
|
|
122
|
+
except Exception: # pragma: no cover - odd metadata
|
|
123
|
+
return "", ""
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def load_plugins(entry_points=None):
|
|
127
|
+
"""Discover and register plugins, once. Returns the names loaded.
|
|
128
|
+
|
|
129
|
+
*entry_points* is for tests; by default the installed ``cicwave.plugins``
|
|
130
|
+
entry points are used. Importing :mod:`importlib.metadata` and the
|
|
131
|
+
plugins is deferred to here so a plain launch pays nothing until a file
|
|
132
|
+
is read or a menu is built.
|
|
133
|
+
"""
|
|
134
|
+
global _loaded
|
|
135
|
+
if _loaded and entry_points is None:
|
|
136
|
+
return []
|
|
137
|
+
_loaded = True
|
|
138
|
+
if entry_points is None:
|
|
139
|
+
if os.environ.get("CICWAVE_PLUGINS") == "0":
|
|
140
|
+
return []
|
|
141
|
+
try:
|
|
142
|
+
from importlib.metadata import entry_points as _eps
|
|
143
|
+
entry_points = list(_eps(group=ENTRY_POINT_GROUP))
|
|
144
|
+
except Exception as e: # pragma: no cover - broken environment
|
|
145
|
+
_log.warning("could not list cicwave plugins: %s", e)
|
|
146
|
+
return []
|
|
147
|
+
names = []
|
|
148
|
+
for ep in entry_points:
|
|
149
|
+
info = _info.setdefault(ep.name, _new_info(ep.name))
|
|
150
|
+
info["description"], info["version"] = _dist_metadata(ep)
|
|
151
|
+
try:
|
|
152
|
+
register = ep.load()
|
|
153
|
+
register(PluginAPI(ep.name))
|
|
154
|
+
names.append(ep.name)
|
|
155
|
+
except Exception as e:
|
|
156
|
+
info["error"] = str(e) or type(e).__name__
|
|
157
|
+
_log.warning("cicwave plugin %r failed to load: %s", ep.name, e)
|
|
158
|
+
return names
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def plugin_info():
|
|
162
|
+
"""What each discovered plugin is and registered, for display.
|
|
163
|
+
|
|
164
|
+
A list of dicts with ``name``, ``description``, ``version``, ``error``
|
|
165
|
+
(the load failure, or None) and the lists ``readers``, ``annotators``,
|
|
166
|
+
``analyses`` and ``presets``.
|
|
167
|
+
"""
|
|
168
|
+
load_plugins()
|
|
169
|
+
return [dict(i) for i in _info.values()]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def describe_plugins():
|
|
173
|
+
"""Plain-text summary of :func:`plugin_info`, as Help > Plugins shows."""
|
|
174
|
+
infos = plugin_info()
|
|
175
|
+
if not infos and os.environ.get("CICWAVE_PLUGINS") == "0":
|
|
176
|
+
return "Plugin discovery is off (CICWAVE_PLUGINS=0)."
|
|
177
|
+
if not infos:
|
|
178
|
+
return ("No plugins installed.\n\nA plugin is a package declaring a "
|
|
179
|
+
"'%s' entry point." % ENTRY_POINT_GROUP)
|
|
180
|
+
out = []
|
|
181
|
+
for i in infos:
|
|
182
|
+
head = i["name"] + (" " + i["version"] if i["version"] else "")
|
|
183
|
+
out.append(head)
|
|
184
|
+
if i["description"]:
|
|
185
|
+
out.append(" " + i["description"])
|
|
186
|
+
if i["error"]:
|
|
187
|
+
out.append(" FAILED TO LOAD: " + i["error"])
|
|
188
|
+
for key, title in (("readers", "Readers"),
|
|
189
|
+
("annotators", "Annotators"),
|
|
190
|
+
("analyses", "Analyses"),
|
|
191
|
+
("presets", "Constellation presets")):
|
|
192
|
+
if i[key]:
|
|
193
|
+
out.append(" %-22s %s" % (title + ":", ", ".join(i[key])))
|
|
194
|
+
out.append("")
|
|
195
|
+
return "\n".join(out).rstrip()
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def find_reader(path):
|
|
199
|
+
"""The plugin reader for *path* (longest matching suffix), or None."""
|
|
200
|
+
load_plugins()
|
|
201
|
+
lower = path.lower()
|
|
202
|
+
best = None
|
|
203
|
+
for suffix, (fn, _name) in _readers.items():
|
|
204
|
+
if lower.endswith(suffix) and (best is None or len(suffix) > len(best[0])):
|
|
205
|
+
best = (suffix, fn)
|
|
206
|
+
return best[1] if best else None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def run_annotators(df, path):
|
|
210
|
+
"""Let every registered annotator add to *df* (in place)."""
|
|
211
|
+
load_plugins()
|
|
212
|
+
for fn, name in _annotators:
|
|
213
|
+
try:
|
|
214
|
+
fn(df, path)
|
|
215
|
+
except Exception as e:
|
|
216
|
+
_log.warning("cicwave plugin %r annotator failed on %s: %s",
|
|
217
|
+
name, path, e)
|
|
218
|
+
return df
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def annotations(df):
|
|
222
|
+
"""The annotation list of *df*, created empty if missing.
|
|
223
|
+
|
|
224
|
+
Annotations follow the SigMF convention -- dicts with
|
|
225
|
+
``core:sample_start``, ``core:sample_count`` and ``core:label`` -- and
|
|
226
|
+
are what the constellation dialog offers as sample ranges. SigMF
|
|
227
|
+
recordings start with the file's own annotations; an annotator
|
|
228
|
+
appends to the same list.
|
|
229
|
+
"""
|
|
230
|
+
return df.attrs.setdefault("cicwave_annotations", [])
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def analyses():
|
|
234
|
+
"""``[(label, fn)]`` registered for the wave context menu."""
|
|
235
|
+
load_plugins()
|
|
236
|
+
return [(label, fn) for label, fn, _name in _analyses]
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def constellation_presets():
|
|
240
|
+
"""``{name: settings}`` registered for the constellation dialog."""
|
|
241
|
+
load_plugins()
|
|
242
|
+
return dict(_presets)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _reset():
|
|
246
|
+
"""Forget every registration (tests only)."""
|
|
247
|
+
global _loaded
|
|
248
|
+
_readers.clear()
|
|
249
|
+
_annotators.clear()
|
|
250
|
+
_analyses.clear()
|
|
251
|
+
_presets.clear()
|
|
252
|
+
_info.clear()
|
|
253
|
+
_loaded = False
|