polars-waveform 0.1.0__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.
@@ -0,0 +1,66 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [master]
6
+ tags: ["v*"]
7
+ pull_request:
8
+ workflow_dispatch:
9
+
10
+ permissions:
11
+ contents: read
12
+
13
+ jobs:
14
+ test:
15
+ runs-on: ubuntu-latest
16
+ strategy:
17
+ fail-fast: false
18
+ matrix:
19
+ python: ["3.11", "3.13"]
20
+ polars: ["polars==1.40.0", "polars"]
21
+ steps:
22
+ - uses: actions/checkout@v4
23
+ - uses: actions/setup-python@v5
24
+ with:
25
+ python-version: ${{ matrix.python }}
26
+ - name: install
27
+ run: python -m pip install ".[test]" "${{ matrix.polars }}"
28
+ - name: pytest
29
+ run: python -m pytest -q tests
30
+
31
+ lint:
32
+ runs-on: ubuntu-latest
33
+ steps:
34
+ - uses: actions/checkout@v4
35
+ - uses: astral-sh/setup-uv@v6
36
+ - run: uvx ruff check .
37
+
38
+ build:
39
+ runs-on: ubuntu-latest
40
+ steps:
41
+ - uses: actions/checkout@v4
42
+ - uses: actions/setup-python@v5
43
+ with:
44
+ python-version: "3.13"
45
+ - run: python -m pip install build && python -m build --outdir dist
46
+ - uses: actions/upload-artifact@v4
47
+ with:
48
+ name: dist
49
+ path: dist
50
+
51
+ pypi:
52
+ name: Publish to PyPI
53
+ if: startsWith(github.ref, 'refs/tags/v')
54
+ needs: [test, lint, build]
55
+ runs-on: ubuntu-latest
56
+ environment:
57
+ name: pypi
58
+ url: https://pypi.org/p/polars-waveform
59
+ permissions:
60
+ id-token: write # trusted publishing, no API token
61
+ steps:
62
+ - uses: actions/download-artifact@v4
63
+ with:
64
+ name: dist
65
+ path: dist
66
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.pyc
3
+ dist/
4
+ build/
5
+ *.egg-info/
6
+ .pytest_cache/
7
+ uv.lock
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 polars-waveform contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,200 @@
1
+ Metadata-Version: 2.5
2
+ Name: polars-waveform
3
+ Version: 0.1.0
4
+ Summary: Waveforms and curve families on Polars, with circuit-style measurements (bandwidth, cross, rise time, ...)
5
+ Project-URL: Repository, https://github.com/henjo/polars-waveform
6
+ Project-URL: Issues, https://github.com/henjo/polars-waveform/issues
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: eda,measurement,ocean,polars,simulation,waveform
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Topic :: Scientific/Engineering :: Electronic Design Automation (EDA)
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.11
18
+ Requires-Dist: polars>=1.40
19
+ Provides-Extra: altair
20
+ Requires-Dist: altair>=5.4; extra == 'altair'
21
+ Provides-Extra: matplotlib
22
+ Requires-Dist: matplotlib; extra == 'matplotlib'
23
+ Requires-Dist: numpy; extra == 'matplotlib'
24
+ Provides-Extra: numpy
25
+ Requires-Dist: numpy; extra == 'numpy'
26
+ Provides-Extra: pandas
27
+ Requires-Dist: numpy; extra == 'pandas'
28
+ Requires-Dist: pandas>=2.2; extra == 'pandas'
29
+ Provides-Extra: test
30
+ Requires-Dist: matplotlib; extra == 'test'
31
+ Requires-Dist: numpy; extra == 'test'
32
+ Requires-Dist: pandas>=2.2; extra == 'test'
33
+ Requires-Dist: pytest; extra == 'test'
34
+ Requires-Dist: sympy; extra == 'test'
35
+ Description-Content-Type: text/markdown
36
+
37
+ # polars-waveform
38
+
39
+ Waveforms and curve families on [Polars](https://pola.rs), with circuit-style measurements:
40
+ bandwidth, crossings, rise time, phase margin, ... Works on any Polars data: simulation results
41
+ (through [polars-psf](https://github.com/henjo/polars-psf) for Cadence® Spectre® PSF files), lab measurements in Parquet,
42
+ or a DataFrame you built yourself. Pure Python; depends only on `polars`.
43
+
44
+ polars-waveform is an independent community project, not affiliated with or endorsed by the Polars
45
+ project or by Cadence Design Systems, Inc.
46
+
47
+ ```sh
48
+ pip install polars-waveform # or: uv add polars-waveform
49
+ ```
50
+
51
+ ## Quick start
52
+
53
+ ```python
54
+ import polars as pl
55
+ import polars_waveform as pw
56
+
57
+ df = pl.DataFrame({"t": [0.0, 1.0, 2.0, 3.0], "v": [0.0, 0.4, 0.9, 1.0]})
58
+ w = pw.Waveform(df, "v") # the last index column (t) is the sweep
59
+
60
+ w.ymax(), w.cross(0.5), w.rise_time(), w.value(1.5)
61
+ w.deriv(), w.clip(0.5, 2.5), w * 2 - 1
62
+ ```
63
+
64
+ ## Families: one curve per operating point
65
+
66
+ Index columns besides the sweep are groups. A Waveform then holds a family of curves (devices,
67
+ temperatures, Monte Carlo runs), and every measurement returns a table with one row per curve:
68
+
69
+ ```python
70
+ lf = pl.scan_parquet("lab.parquet") # lazy: read on first use
71
+ gain = pw.Waveform(lf.select("dut", "temp", "freq", "gain"), "gain", index=["dut", "temp", "freq"])
72
+
73
+ gain.bandwidth() # dut | temp | bandwidth
74
+ gain.leaf(dut="A1", temp=25.0) # one curve (a single curve gives plain numbers)
75
+ gain - gain.mean() # per-curve results broadcast back over the curves
76
+ ```
77
+
78
+ Those tables are ordinary Polars DataFrames: sort, filter, join with other measurements, and
79
+ `write_csv` / `write_excel`.
80
+
81
+ Families can also be reshaped, all lazily:
82
+
83
+ ```python
84
+ gain.reduce("min", over="temp") # worst case over temperature: a family over dut x freq
85
+ gain.ymax(axis="temp") # the same with an axis argument (axis=-1, the sweep, is per curve)
86
+ gain.reorder(["dut", "freq", "temp"]) # curves over temperature, one per dut and frequency
87
+ for dut, g in gain.along("dut"): # iterate over one group column
88
+ print(dut, g.bandwidth())
89
+ gain[-1] # last sample of every curve (a number for a single curve)
90
+ gain[10:20] # a slice of every curve
91
+ ```
92
+
93
+ ## From numpy arrays
94
+
95
+ `Waveform.from_arrays` takes one array per sweep axis and an n-dimensional `y` (the last axis is
96
+ the sweep, the others become groups), or ragged object arrays; `to_arrays()` converts back:
97
+
98
+ ```python
99
+ w = pw.Waveform.from_arrays([temps, freqs], gain_2d, xlabels=["temp", "freq"], ylabel="gain")
100
+ xs, y = w.to_arrays()
101
+ ```
102
+
103
+ numpy functions keep waveforms as waveforms: `np.abs(w)`, `np.log10(w)`, `array + w`.
104
+
105
+ ## Nested channels
106
+
107
+ Measurement tables often keep one row per operating point and store each *channel*, a curve
108
+ with its own sweep, in a nested column:
109
+
110
+ ```
111
+ lbw | vdd | idd | ... | pn: {offset: [..], psd: [..]} | spurs: {freq: [..], level: [..]}
112
+ ```
113
+
114
+ `from_nested` turns a channel into a family, one curve per row, without flattening the table.
115
+ Only the group columns and that channel are read:
116
+
117
+ ```python
118
+ pn = pw.from_nested(pl.scan_parquet("pll.parquet"), "pn", x="offset", y="psd",
119
+ groups=["lbw", "vdd"], units={"offset": "Hz", "psd": "dBc/Hz"})
120
+
121
+ pn.value(1e6) # phase noise at 1 MHz offset, per lbw x vdd
122
+ (10 ** (pn / 10)).integ(1e4, 1e7) # integrated phase noise, per lbw x vdd
123
+ ```
124
+
125
+ Both nested shapes work: a struct of lists (`{x: [..], y: [..]}`) and a list of structs.
126
+
127
+ ## Plotting
128
+
129
+ ```sh
130
+ pip install "polars-waveform[matplotlib]" # or [altair] for interactive charts
131
+ ```
132
+
133
+ ```python
134
+ w.plot() # matplotlib: one line per curve, labelled with its group values
135
+ w.semilogx(), w.loglog(), w.stem()
136
+ h.bode() # magnitude and phase of a complex response
137
+ w.plot(backend="altair") # interactive chart (notebooks); pw.set_plot_backend("altair")
138
+ pw.compression_plot(gain_db) # response, extrapolated line and the compression point
139
+ ```
140
+
141
+ ## Measurements
142
+
143
+ The semantics follow OCEAN, and the OCEAN and pycircuit names are aliases (`pw.dB20`,
144
+ `pw.unityGainFreq`, `pw.riseTime`, `pw.IIP3`, ...):
145
+
146
+ | | |
147
+ |---|---|
148
+ | values | `value(x)`, `ymax()`, `ymin()`, `xmax()` (x at the largest y), `xmin()`, `average()`/`mean()`, `rms()`, `stddev()` |
149
+ | crossings | `cross(threshold, edge=1, type="either")` (edges from 1, negative from the end), `delay(other, ...)`, `frequency()`, `period()` |
150
+ | transient | `rise_time()`, `fall_time()`, `slew_rate()`, `overshoot()`, `settling_time()` |
151
+ | frequency | `bandwidth(db, "low"/"high"/"band")`, `unity_gain_frequency()`, `phase_margin()`, `gain_margin()` |
152
+ | shape | `deriv()`, `integ(xfrom, xto)`, `iinteg()`, `clip(xfrom, xto)`, `dft()`, `leaf(**groups)` |
153
+ | families | `reduce(how, over)`, `ymax(axis=...)` and friends, `reorder()`, `swapaxes()`, `along()`, `dimension_first()`, `leaf()`, `w[i]`, `w[a:b]` |
154
+ | math | `+ - * /`, `**`, `10 ** w`, `abs()`/`mag()`, `db10()`, `db20()`, `phase()`, `real()`, `imag()`, `conj()`, `log10()`, `exp()`, `sqrt()` |
155
+ | RF | `pw.im2`, `pw.im3`, `pw.iip2`, `pw.iip3`, `pw.compression_point` |
156
+
157
+ The elementwise functions (`pw.db20`, `pw.phase`, `pw.mag`, ...) also take plain numbers and
158
+ numpy arrays. Complex data is a `Struct{re, im}` column; arithmetic and `db20()`/`phase()` handle it, and
159
+ `pl.col("h").cx.db20()` (registered by `polars_waveform.cx`) does the same in plain Polars.
160
+
161
+ ## Symbolic waveforms
162
+
163
+ Polars can store Python objects but cannot compute with them. `pw.PandasWaveform` holds such
164
+ values, for example sympy expressions from a symbolic circuit analysis, in the same layout
165
+ (groups, sweep, value) on a pandas Series, which applies Python operators element by element.
166
+ `pw.from_arrays` picks the kind the values need:
167
+
168
+ ```python
169
+ import sympy
170
+ R, C = sympy.symbols("R C", positive=True)
171
+
172
+ h = pw.from_arrays(freqs, [1 / (1 + 2j * sympy.pi * f * R * C) for f in freqs],
173
+ xlabels=["freq"], ylabel="H", xunits=["Hz"])
174
+ h.db20() # still symbolic
175
+ h.map(sympy.simplify) # any function, value by value
176
+ h.subs({R: 1e3, C: 1e-9}).bandwidth() # numbers: measured on numeric()
177
+ ```
178
+
179
+ Measurements and plots run on `numeric()`, the equivalent `Waveform`; it names the free symbols
180
+ while some are left. Needs `pip install "polars-waveform[pandas]"`. Both kinds implement
181
+ `pw.WaveformBase`, the contract for waveform kinds: names, units, `from_arrays` / `to_arrays`,
182
+ arithmetic and the elementwise functions.
183
+
184
+ ## Result sources
185
+
186
+ `pw.ResultSource` is the shape shared by result objects that hand out waveforms: `leaves`,
187
+ `names`, `v(signal, **params)` and `scan()`. `polars_psf.Result` implements it for simulation
188
+ results; a lab-data handler can implement it too, so analysis code runs on both.
189
+
190
+ ## Development
191
+
192
+ ```sh
193
+ pip install -e ".[test]"
194
+ pytest tests
195
+ ruff check .
196
+ ```
197
+
198
+ ## License
199
+
200
+ MIT.
@@ -0,0 +1,164 @@
1
+ # polars-waveform
2
+
3
+ Waveforms and curve families on [Polars](https://pola.rs), with circuit-style measurements:
4
+ bandwidth, crossings, rise time, phase margin, ... Works on any Polars data: simulation results
5
+ (through [polars-psf](https://github.com/henjo/polars-psf) for Cadence® Spectre® PSF files), lab measurements in Parquet,
6
+ or a DataFrame you built yourself. Pure Python; depends only on `polars`.
7
+
8
+ polars-waveform is an independent community project, not affiliated with or endorsed by the Polars
9
+ project or by Cadence Design Systems, Inc.
10
+
11
+ ```sh
12
+ pip install polars-waveform # or: uv add polars-waveform
13
+ ```
14
+
15
+ ## Quick start
16
+
17
+ ```python
18
+ import polars as pl
19
+ import polars_waveform as pw
20
+
21
+ df = pl.DataFrame({"t": [0.0, 1.0, 2.0, 3.0], "v": [0.0, 0.4, 0.9, 1.0]})
22
+ w = pw.Waveform(df, "v") # the last index column (t) is the sweep
23
+
24
+ w.ymax(), w.cross(0.5), w.rise_time(), w.value(1.5)
25
+ w.deriv(), w.clip(0.5, 2.5), w * 2 - 1
26
+ ```
27
+
28
+ ## Families: one curve per operating point
29
+
30
+ Index columns besides the sweep are groups. A Waveform then holds a family of curves (devices,
31
+ temperatures, Monte Carlo runs), and every measurement returns a table with one row per curve:
32
+
33
+ ```python
34
+ lf = pl.scan_parquet("lab.parquet") # lazy: read on first use
35
+ gain = pw.Waveform(lf.select("dut", "temp", "freq", "gain"), "gain", index=["dut", "temp", "freq"])
36
+
37
+ gain.bandwidth() # dut | temp | bandwidth
38
+ gain.leaf(dut="A1", temp=25.0) # one curve (a single curve gives plain numbers)
39
+ gain - gain.mean() # per-curve results broadcast back over the curves
40
+ ```
41
+
42
+ Those tables are ordinary Polars DataFrames: sort, filter, join with other measurements, and
43
+ `write_csv` / `write_excel`.
44
+
45
+ Families can also be reshaped, all lazily:
46
+
47
+ ```python
48
+ gain.reduce("min", over="temp") # worst case over temperature: a family over dut x freq
49
+ gain.ymax(axis="temp") # the same with an axis argument (axis=-1, the sweep, is per curve)
50
+ gain.reorder(["dut", "freq", "temp"]) # curves over temperature, one per dut and frequency
51
+ for dut, g in gain.along("dut"): # iterate over one group column
52
+ print(dut, g.bandwidth())
53
+ gain[-1] # last sample of every curve (a number for a single curve)
54
+ gain[10:20] # a slice of every curve
55
+ ```
56
+
57
+ ## From numpy arrays
58
+
59
+ `Waveform.from_arrays` takes one array per sweep axis and an n-dimensional `y` (the last axis is
60
+ the sweep, the others become groups), or ragged object arrays; `to_arrays()` converts back:
61
+
62
+ ```python
63
+ w = pw.Waveform.from_arrays([temps, freqs], gain_2d, xlabels=["temp", "freq"], ylabel="gain")
64
+ xs, y = w.to_arrays()
65
+ ```
66
+
67
+ numpy functions keep waveforms as waveforms: `np.abs(w)`, `np.log10(w)`, `array + w`.
68
+
69
+ ## Nested channels
70
+
71
+ Measurement tables often keep one row per operating point and store each *channel*, a curve
72
+ with its own sweep, in a nested column:
73
+
74
+ ```
75
+ lbw | vdd | idd | ... | pn: {offset: [..], psd: [..]} | spurs: {freq: [..], level: [..]}
76
+ ```
77
+
78
+ `from_nested` turns a channel into a family, one curve per row, without flattening the table.
79
+ Only the group columns and that channel are read:
80
+
81
+ ```python
82
+ pn = pw.from_nested(pl.scan_parquet("pll.parquet"), "pn", x="offset", y="psd",
83
+ groups=["lbw", "vdd"], units={"offset": "Hz", "psd": "dBc/Hz"})
84
+
85
+ pn.value(1e6) # phase noise at 1 MHz offset, per lbw x vdd
86
+ (10 ** (pn / 10)).integ(1e4, 1e7) # integrated phase noise, per lbw x vdd
87
+ ```
88
+
89
+ Both nested shapes work: a struct of lists (`{x: [..], y: [..]}`) and a list of structs.
90
+
91
+ ## Plotting
92
+
93
+ ```sh
94
+ pip install "polars-waveform[matplotlib]" # or [altair] for interactive charts
95
+ ```
96
+
97
+ ```python
98
+ w.plot() # matplotlib: one line per curve, labelled with its group values
99
+ w.semilogx(), w.loglog(), w.stem()
100
+ h.bode() # magnitude and phase of a complex response
101
+ w.plot(backend="altair") # interactive chart (notebooks); pw.set_plot_backend("altair")
102
+ pw.compression_plot(gain_db) # response, extrapolated line and the compression point
103
+ ```
104
+
105
+ ## Measurements
106
+
107
+ The semantics follow OCEAN, and the OCEAN and pycircuit names are aliases (`pw.dB20`,
108
+ `pw.unityGainFreq`, `pw.riseTime`, `pw.IIP3`, ...):
109
+
110
+ | | |
111
+ |---|---|
112
+ | values | `value(x)`, `ymax()`, `ymin()`, `xmax()` (x at the largest y), `xmin()`, `average()`/`mean()`, `rms()`, `stddev()` |
113
+ | crossings | `cross(threshold, edge=1, type="either")` (edges from 1, negative from the end), `delay(other, ...)`, `frequency()`, `period()` |
114
+ | transient | `rise_time()`, `fall_time()`, `slew_rate()`, `overshoot()`, `settling_time()` |
115
+ | frequency | `bandwidth(db, "low"/"high"/"band")`, `unity_gain_frequency()`, `phase_margin()`, `gain_margin()` |
116
+ | shape | `deriv()`, `integ(xfrom, xto)`, `iinteg()`, `clip(xfrom, xto)`, `dft()`, `leaf(**groups)` |
117
+ | families | `reduce(how, over)`, `ymax(axis=...)` and friends, `reorder()`, `swapaxes()`, `along()`, `dimension_first()`, `leaf()`, `w[i]`, `w[a:b]` |
118
+ | math | `+ - * /`, `**`, `10 ** w`, `abs()`/`mag()`, `db10()`, `db20()`, `phase()`, `real()`, `imag()`, `conj()`, `log10()`, `exp()`, `sqrt()` |
119
+ | RF | `pw.im2`, `pw.im3`, `pw.iip2`, `pw.iip3`, `pw.compression_point` |
120
+
121
+ The elementwise functions (`pw.db20`, `pw.phase`, `pw.mag`, ...) also take plain numbers and
122
+ numpy arrays. Complex data is a `Struct{re, im}` column; arithmetic and `db20()`/`phase()` handle it, and
123
+ `pl.col("h").cx.db20()` (registered by `polars_waveform.cx`) does the same in plain Polars.
124
+
125
+ ## Symbolic waveforms
126
+
127
+ Polars can store Python objects but cannot compute with them. `pw.PandasWaveform` holds such
128
+ values, for example sympy expressions from a symbolic circuit analysis, in the same layout
129
+ (groups, sweep, value) on a pandas Series, which applies Python operators element by element.
130
+ `pw.from_arrays` picks the kind the values need:
131
+
132
+ ```python
133
+ import sympy
134
+ R, C = sympy.symbols("R C", positive=True)
135
+
136
+ h = pw.from_arrays(freqs, [1 / (1 + 2j * sympy.pi * f * R * C) for f in freqs],
137
+ xlabels=["freq"], ylabel="H", xunits=["Hz"])
138
+ h.db20() # still symbolic
139
+ h.map(sympy.simplify) # any function, value by value
140
+ h.subs({R: 1e3, C: 1e-9}).bandwidth() # numbers: measured on numeric()
141
+ ```
142
+
143
+ Measurements and plots run on `numeric()`, the equivalent `Waveform`; it names the free symbols
144
+ while some are left. Needs `pip install "polars-waveform[pandas]"`. Both kinds implement
145
+ `pw.WaveformBase`, the contract for waveform kinds: names, units, `from_arrays` / `to_arrays`,
146
+ arithmetic and the elementwise functions.
147
+
148
+ ## Result sources
149
+
150
+ `pw.ResultSource` is the shape shared by result objects that hand out waveforms: `leaves`,
151
+ `names`, `v(signal, **params)` and `scan()`. `polars_psf.Result` implements it for simulation
152
+ results; a lab-data handler can implement it too, so analysis code runs on both.
153
+
154
+ ## Development
155
+
156
+ ```sh
157
+ pip install -e ".[test]"
158
+ pytest tests
159
+ ruff check .
160
+ ```
161
+
162
+ ## License
163
+
164
+ MIT.
@@ -0,0 +1,49 @@
1
+ """Waveforms on Polars: curves and curve families with circuit-style measurements.
2
+
3
+ A :class:`Waveform` is a value column over one or more index columns, backed by a lazy
4
+ ``pl.LazyFrame`` and materialized only when a value is needed. The last index column is the
5
+ sweep (time, frequency, offset, ...); any others are groups (corners, Monte Carlo runs, measured
6
+ operating points), so one Waveform can hold a whole family of curves::
7
+
8
+ import polars as pl
9
+ import polars_waveform as pw
10
+
11
+ w = pw.Waveform(pl.scan_parquet("lab.parquet"), "gain", index=["dut", "temp", "freq"])
12
+ w.bandwidth() # one row per dut x temp
13
+ w - w.mean() # per-curve results broadcast back over the curves
14
+ w.leaf(dut="A1", temp=25.0) # one curve
15
+
16
+ Measurements follow OCEAN semantics (``xmax`` is the x at the largest y; ``cross`` counts edges
17
+ from 1); the OCEAN and pycircuit spellings (``dB20``, ``unityGainFreq``, ``IIP3``, ...) are aliases.
18
+ Complex data is ``Struct{re, im}``; :mod:`.cx` registers ``pl.col(...).cx`` for it.
19
+ :func:`from_nested` builds families from nested channel columns, :meth:`Waveform.from_arrays`
20
+ from numpy grids, and :class:`ResultSource` is the shape shared by result objects
21
+ (``polars_psf.Result`` implements it). :class:`WaveformBase` is the contract other waveform
22
+ kinds implement; :class:`PandasWaveform` is one for values Polars cannot compute with (sympy
23
+ expressions, ...), and :func:`from_arrays` picks the kind the values need.
24
+
25
+ Modules: :mod:`.waveform` (the class), :mod:`.functions` (calculator functions), :mod:`.cx`
26
+ (complex helpers), :mod:`.nested`, :mod:`.source`, :mod:`.pandas_waveform`.
27
+ """
28
+
29
+ from importlib.metadata import PackageNotFoundError
30
+ from importlib.metadata import version as _version
31
+
32
+ from . import cx
33
+ from .base import WaveformBase
34
+ from .functions import * # noqa: F403
35
+ from .functions import __all__ as _functions
36
+ from .nested import from_nested
37
+ from .pandas_waveform import PandasWaveform, from_arrays
38
+ from .source import ResultSource
39
+ from .waveform import Waveform, either, falling, raising
40
+
41
+ __all__ = [
42
+ "PandasWaveform", "ResultSource", "Waveform", "WaveformBase", "cx", "either", "falling", "from_arrays",
43
+ "from_nested", "raising", *_functions,
44
+ ] # fmt: skip
45
+
46
+ try:
47
+ __version__ = _version("polars-waveform")
48
+ except PackageNotFoundError: # running from a source tree without an install
49
+ __version__ = "0+unknown"
@@ -0,0 +1,176 @@
1
+ """The contract shared by all waveform kinds.
2
+
3
+ :class:`~polars_waveform.Waveform` stores numeric data in Polars (lazy, families as group
4
+ columns). Other kinds implement the same interface with their own storage, for example a
5
+ symbolic waveform holding sympy expressions in numpy object arrays: Polars can store Python
6
+ objects but has no kernels to compute with them, so such data needs its own container.
7
+
8
+ A subclass implements storage, construction and elementwise math (the abstract methods), and
9
+ :meth:`WaveformBase.numeric` to turn itself into a numeric :class:`~polars_waveform.Waveform`.
10
+ Measurements it does not implement itself (``bandwidth``, ``cross``, ``rise_time``, ...) are
11
+ inherited from this class and run on ``numeric()``, so they work on any kind once its values are
12
+ numbers.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from abc import ABC, abstractmethod
18
+
19
+ __all__ = ["WaveformBase"]
20
+
21
+ # measurements that need numbers: inherited by other kinds, evaluated on numeric()
22
+ _NUMERIC = (
23
+ "value", "ymax", "ymin", "xmax", "xmin", "argmax", "argmin", "average", "mean", "rms", "stddev",
24
+ "cross", "frequency", "period", "rise_time", "fall_time", "slew_rate", "overshoot", "settling_time",
25
+ "bandwidth", "unity_gain_frequency", "phase_margin", "gain_margin", "integ", "iinteg", "deriv",
26
+ "clip", "dft", "to_polars", "plot", "semilogx", "semilogy", "loglog", "stem", "bode",
27
+ ) # fmt: skip
28
+
29
+
30
+ class WaveformBase(ABC):
31
+ """A value over one or more index columns: groups (one curve per combination) and a sweep.
32
+
33
+ The interface every waveform kind provides: names and units, conversion from and to numpy
34
+ arrays (:meth:`from_arrays`, :meth:`to_arrays`), arithmetic, the elementwise functions
35
+ (``db20``, ``phase``, ...), and the measurements, which by default evaluate on
36
+ :meth:`numeric`.
37
+ """
38
+
39
+ # --- storage and names (abstract) -------------------------------------------------------
40
+ @classmethod
41
+ @abstractmethod
42
+ def from_arrays(cls, x, y, xlabels=None, ylabel=None, xunits=None, yunit=None) -> WaveformBase:
43
+ """A waveform from numpy arrays: one x array per sweep axis (the last one is the sweep,
44
+ the others become groups) and ``y`` with shape ``(len(x0), len(x1), ...)``, or ragged
45
+ object arrays (see :meth:`polars_waveform.Waveform.from_arrays`)."""
46
+
47
+ @abstractmethod
48
+ def to_arrays(self):
49
+ """``(xs, y)`` as numpy arrays in the layout :meth:`from_arrays` accepts."""
50
+
51
+ @abstractmethod
52
+ def numeric(self):
53
+ """This waveform as a numeric :class:`~polars_waveform.Waveform`."""
54
+
55
+ @property
56
+ @abstractmethod
57
+ def index(self) -> list[str]:
58
+ """Index column names: the groups followed by the sweep."""
59
+
60
+ @property
61
+ def groups(self) -> list[str]:
62
+ """Index columns other than the sweep (one curve per combination)."""
63
+ return [c for c in self.index if c != self.xname]
64
+
65
+ @property
66
+ @abstractmethod
67
+ def xname(self) -> str: ...
68
+
69
+ @property
70
+ @abstractmethod
71
+ def yname(self) -> str: ...
72
+
73
+ @property
74
+ @abstractmethod
75
+ def xunit(self): ...
76
+
77
+ @property
78
+ @abstractmethod
79
+ def yunit(self): ...
80
+
81
+ # --- arithmetic (abstract core, shared operators) ------------------------------------------
82
+ @abstractmethod
83
+ def _binop(self, other, op: str, *, reverse: bool = False) -> WaveformBase:
84
+ """``self <op> other`` (``other <op> self`` if ``reverse``) for op in + - * / < > <= >=."""
85
+
86
+ def __add__(self, other):
87
+ return self._binop(other, "+")
88
+
89
+ def __radd__(self, other):
90
+ return self._binop(other, "+", reverse=True)
91
+
92
+ def __sub__(self, other):
93
+ return self._binop(other, "-")
94
+
95
+ def __rsub__(self, other):
96
+ return self._binop(other, "-", reverse=True)
97
+
98
+ def __mul__(self, other):
99
+ return self._binop(other, "*")
100
+
101
+ def __rmul__(self, other):
102
+ return self._binop(other, "*", reverse=True)
103
+
104
+ def __truediv__(self, other):
105
+ return self._binop(other, "/")
106
+
107
+ def __rtruediv__(self, other):
108
+ return self._binop(other, "/", reverse=True)
109
+
110
+ def __lt__(self, other):
111
+ return self._binop(other, "<")
112
+
113
+ def __le__(self, other):
114
+ return self._binop(other, "<=")
115
+
116
+ def __gt__(self, other):
117
+ return self._binop(other, ">")
118
+
119
+ def __ge__(self, other):
120
+ return self._binop(other, ">=")
121
+
122
+ # --- elementwise (abstract) ----------------------------------------------------------------
123
+ @abstractmethod
124
+ def __neg__(self): ...
125
+
126
+ @abstractmethod
127
+ def __abs__(self): ...
128
+
129
+ @abstractmethod
130
+ def __pow__(self, other): ...
131
+
132
+ @abstractmethod
133
+ def real(self): ...
134
+
135
+ @abstractmethod
136
+ def imag(self): ...
137
+
138
+ @abstractmethod
139
+ def conj(self): ...
140
+
141
+ @abstractmethod
142
+ def phase(self, deg: bool = True): ...
143
+
144
+ @abstractmethod
145
+ def db10(self): ...
146
+
147
+ @abstractmethod
148
+ def db20(self): ...
149
+
150
+ def __pos__(self):
151
+ return self
152
+
153
+ def abs(self):
154
+ """Magnitude (``abs(w)``)."""
155
+ return abs(self)
156
+
157
+ mag = abs
158
+
159
+ def conjugate(self):
160
+ return self.conj()
161
+
162
+
163
+ def _numeric_method(name: str):
164
+ def method(self, *args, **kwargs):
165
+ num = self.numeric()
166
+ if type(num) is type(self): # a numeric kind must implement the measurement itself
167
+ raise NotImplementedError(f"{type(self).__name__}.{name}()")
168
+ return getattr(num, name)(*args, **kwargs)
169
+
170
+ method.__name__ = name
171
+ method.__doc__ = f"``{name}`` evaluated on :meth:`numeric` (see ``polars_waveform.Waveform.{name}``)."
172
+ return method
173
+
174
+
175
+ for _name in _NUMERIC:
176
+ setattr(WaveformBase, _name, _numeric_method(_name))