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.
- polars_waveform-0.1.0/.github/workflows/ci.yml +66 -0
- polars_waveform-0.1.0/.gitignore +7 -0
- polars_waveform-0.1.0/LICENSE +21 -0
- polars_waveform-0.1.0/PKG-INFO +200 -0
- polars_waveform-0.1.0/README.md +164 -0
- polars_waveform-0.1.0/polars_waveform/__init__.py +49 -0
- polars_waveform-0.1.0/polars_waveform/base.py +176 -0
- polars_waveform-0.1.0/polars_waveform/cx.py +145 -0
- polars_waveform-0.1.0/polars_waveform/functions.py +325 -0
- polars_waveform-0.1.0/polars_waveform/nested.py +82 -0
- polars_waveform-0.1.0/polars_waveform/pandas_waveform.py +354 -0
- polars_waveform-0.1.0/polars_waveform/plot.py +156 -0
- polars_waveform-0.1.0/polars_waveform/py.typed +0 -0
- polars_waveform-0.1.0/polars_waveform/source.py +41 -0
- polars_waveform-0.1.0/polars_waveform/waveform.py +1253 -0
- polars_waveform-0.1.0/pyproject.toml +47 -0
- polars_waveform-0.1.0/tests/test_arrays.py +133 -0
- polars_waveform-0.1.0/tests/test_base.py +76 -0
- polars_waveform-0.1.0/tests/test_families.py +83 -0
- polars_waveform-0.1.0/tests/test_nested.py +94 -0
- polars_waveform-0.1.0/tests/test_pandas_waveform.py +72 -0
- polars_waveform-0.1.0/tests/test_plot.py +72 -0
- polars_waveform-0.1.0/tests/test_waveform.py +280 -0
|
@@ -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,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))
|