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.
Files changed (24) hide show
  1. {cicwave-0.7.2/src/cicwave.egg-info → cicwave-0.7.4}/PKG-INFO +19 -1
  2. {cicwave-0.7.2 → cicwave-0.7.4}/README.md +16 -0
  3. {cicwave-0.7.2 → cicwave-0.7.4}/pyproject.toml +2 -1
  4. {cicwave-0.7.2 → cicwave-0.7.4}/src/cicwave/__init__.py +6 -1
  5. {cicwave-0.7.2 → cicwave-0.7.4}/src/cicwave/analysis.py +81 -0
  6. {cicwave-0.7.2 → cicwave-0.7.4}/src/cicwave/apisource.py +20 -5
  7. {cicwave-0.7.2 → cicwave-0.7.4}/src/cicwave/cli.py +2 -1
  8. cicwave-0.7.4/src/cicwave/plugins.py +253 -0
  9. {cicwave-0.7.2 → cicwave-0.7.4}/src/cicwave/wave_pg.py +559 -31
  10. {cicwave-0.7.2 → cicwave-0.7.4}/src/cicwave/wavefiles.py +524 -2
  11. {cicwave-0.7.2 → cicwave-0.7.4/src/cicwave.egg-info}/PKG-INFO +19 -1
  12. {cicwave-0.7.2 → cicwave-0.7.4}/src/cicwave.egg-info/SOURCES.txt +1 -0
  13. {cicwave-0.7.2 → cicwave-0.7.4}/src/cicwave.egg-info/requires.txt +3 -0
  14. {cicwave-0.7.2 → cicwave-0.7.4}/LICENSE +0 -0
  15. {cicwave-0.7.2 → cicwave-0.7.4}/setup.cfg +0 -0
  16. {cicwave-0.7.2 → cicwave-0.7.4}/src/cicwave/command.py +0 -0
  17. {cicwave-0.7.2 → cicwave-0.7.4}/src/cicwave/mcp_server.py +0 -0
  18. {cicwave-0.7.2 → cicwave-0.7.4}/src/cicwave/ngraw.py +0 -0
  19. {cicwave-0.7.2 → cicwave-0.7.4}/src/cicwave/pivot.py +0 -0
  20. {cicwave-0.7.2 → cicwave-0.7.4}/src/cicwave/stdf.py +0 -0
  21. {cicwave-0.7.2 → cicwave-0.7.4}/src/cicwave/theme.py +0 -0
  22. {cicwave-0.7.2 → cicwave-0.7.4}/src/cicwave.egg-info/dependency_links.txt +0 -0
  23. {cicwave-0.7.2 → cicwave-0.7.4}/src/cicwave.egg-info/entry_points.txt +0 -0
  24. {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.2
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.2"
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
- __version__ = "1.0.0"
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
- if dtype in ("int", "float"):
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), and more.
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