Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions .claude/skills/comnumpy-tutorial/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,34 @@ then learns the wrong lesson -- they came to see how the library is used.
`compute_metric_rayleigh_theo` for the closed forms,
`constellation_capacity` and `bicm_capacity` for the rates.
- Monte-Carlo: `monte_carlo(chain, param, values, metrics, stimulus, seed=)`.
- Figures: `plot_iq`, `plot_error_rate`, `plot_spectrum`, `plot_time`,
`plot_channel_response`, and the channels' own `info()` / `plot()`.
- **A swept result is one dictionary, shown two ways.**
`data = {"x": snr_dB, "curves": {"ZF": ..., "ML": ...}}` — which is
already the shape `monte_carlo` returns — then `print_data(data,
xlabel=…, ylabel=…)` for the table the page pastes and
`plot_data(data, …)` for the figure. Never hand-roll an aligned
`print` loop with `:8.4f` widths: the table and the figure must come
from the same object, or they will eventually come from different
runs.
- Activate the style sheet once, at the top of the script, right after
the imports: `style.use()`. The colours and the figure size are
rcParams and a figure already created keeps the old ones.
- Figures: **draw with matplotlib, decorate with `style.apply(ax, kind)`**.
A scatter of real against imaginary, a `semilogy` of a rate against an
SNR: the reader knows those calls, and hiding them behind a wrapper
teaches nothing. `style.apply(ax, "iq" | "error_rate" | "time" |
"spectrum")` fills the labels that are still empty, turns on the grid
and adds the legend; it never touches the data or the scales.
A kind names the *quantity*. A runtime in ms or an effective SNR in dB
is none of them: give it its own labels and `ax.grid(True)`, and do not
borrow `"error_rate"` because the axis happens to be logarithmic.
Keep `plot_error_rate` for the case it earns: several measured curves
each paired with its closed form, which it draws in matching colours
with markers that grow so coincident curves stay legible.
For the quantities that need computing before drawing, use the
function: `plot_spectrum`, `plot_welch`, `plot_kde`,
`plot_channel_response`, `plot_carrier_allocation`. And an object that
can show itself does: `constellation.plot(ax=)`, `channel.plot()`,
`channel.info()`.
- If the library makes something awkward, that is a finding about the
library. Say so and propose the fix rather than working around it in the
example.
Expand Down
121 changes: 121 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,127 @@ one release; there is no compatibility layer.
| `core.metrics.calculate_acpr` | `compute_acpr` — it was the only `calculate_*` in the library, against 17 `compute_*` |
| `core.metrics.compute_effective_SNR`, `ofdm.metrics.compute_PAPR` | `compute_effective_snr`, `compute_papr` — the two capitalized outliers among functions otherwise all lowercase (`compute_ser`, `compute_ber`, `compute_evm`, `compute_ccdf`, `compute_mi`) |

### Changed — the figures finally use the style sheet that ships with the package

`comnumpy.mplstyle` has shipped since D27b -- Okabe-Ito, colourblind-safe,
no information carried by colour alone -- and **nothing activated it**.
Not one example, not one validation script, not one plotting function.
Every figure in the documentation was matplotlib's defaults, and the
decision was a file nobody read.

`style.use()` is the one line that turns it on, called at the top of the
42 scripts that draw, right after their imports. It has to be there and
not in a plotting helper: colours, fonts and figure size are rcParams,
so a figure already created keeps whatever was active when it was made.
Explicit rather than at import time, which stays forbidden -- importing
a library must not change the caller's matplotlib state.

All 59 figures are regenerated.

`savefig.dpi` drops from 300 to **150** at the same time. 300 is a
printed-figure setting; these are read on a web page and committed to
the repository, and it tripled every PNG for pixels no screen shows --
28 kB to 91 kB on a measured error-rate figure, some 13 MB across
`docs/`. At 150 the same figure is 45 kB.

### Added — `print_data` / `plot_data`, one result shown two ways

A sweep produces the same thing every time: an abscissa and one series
per curve. The scripts restated it once for the table and once for the
figure, and the table was always a hand-rolled `print` loop with
hard-coded widths -- `f"{value:8d} {measured['single carrier'][index]:15.4f}"`
and six more like it. Two statements of one result, which is two chances
for them to end up describing different runs.

`comnumpy.data` gives that result a shape:

```python
data = {"x": lengths, "curves": {"single carrier": sc_ms, "OFDM": ofdm_ms}}
print_data(data, xlabel="block length N", ylabel="runtime [ms]")
plot_data(data, xlabel="block length $N$", ylabel="runtime [ms]",
xscale="log", yscale="log", marker="o")
```

A plain dictionary of NumPy arrays -- no class to construct, nothing to
import before the data exists, no pandas -- and it is already what
`monte_carlo` returns, so a sweep result is a `curves` value as it
stands. `unpack` is the one place the contract is checked (both keys
present, `curves` a mapping, every series the length of `x`), so the two
renderers cannot disagree about what a valid result is.

`format_data` returns the table as a string; `print_data` prints it.
The split exists because a page pastes that string, and because a
formatting rule you can only observe through stdout is a rule nobody
tests.

The formatting is per **column**, not per cell: one format for a whole
column, chosen from its own dynamic range, so the decimal points line up
when the column is read down -- which is the only reason a table beats a
list. An integral column keeps no decimals, a column spanning more than
four decades goes to scientific notation (an error rate does, a runtime
in ms does not), and a `nan` prints as `-` rather than as a word in a
column of numbers, because a sweep point with no measurement is a hole.

Deviation from the specification, deliberate: it asked for `x_label` on
the text side and `xlabel` on the plotting side. Both are `xlabel` and
`ylabel` here -- matplotlib's spelling, and the one `plot_error_rate`
already uses. On the text side `ylabel` is a caption above the table,
since a table has one column per curve and no single ordinate; the unit
belongs there rather than repeated in every cell.

### Changed — the cosmetics are a function, the drawing is matplotlib

A figure of this library was drawn one of two ways, and neither was
good. Either through a `plot_*` wrapper, which for a single curve hides
two lines of matplotlib behind a signature to learn -- 33 `ax.plot` and
17 `ax.semilogy` in `examples/` say what the scripts actually prefer --
or by hand, in which case it got no house look at all. And the style
sheet shipped since D27b was activated by **nothing**: not one script,
not one plotting function. Every figure in the documentation was drawn
with matplotlib's defaults.

`comnumpy.style.apply(ax, kind)` is the missing half. A page draws its
curves itself and hands the axis over for the decoration:

```python
fig, ax = plt.subplots()
line, = ax.semilogy(snr_dB, ser, "o", fillstyle="none", label="16-QAM")
ax.semilogy(snr_dB, ser_theory, "-", color=line.get_color())
style.apply(ax, "error_rate")
```

It fills the labels that are still empty -- never one the caller set --
turns the grid on, on both decades when the axis is logarithmic, and
adds a legend when something is labelled. `"iq"` also sets an equal
aspect ratio, because a constellation read on unequal axes is a
different constellation. It never touches the data and never changes a
scale: whether a curve is a `plot` or a `semilogy`, with which marker
and in which colour, is the page's statement about its measurement and
stays in the page's code.

A kind names a **quantity**, not a plotting mode, which is what caught
the misuse: of twelve `plot_error_rate` calls, four drew no error rate
at all -- an effective SNR in dB twice, a runtime in ms twice. Those
four are now written out, with `ax.grid(True)` and their own labels;
they do not borrow `"error_rate"` because their ordinate happens to be
logarithmic. The twenty `plot_iq` calls became the two matplotlib lines
they always were.

`plot_error_rate` keeps the case it earns -- several measured curves
each paired with its closed form, drawn in matching colours with
markers that grow so that two detectors which agree do not hide each
other -- and its decoration now goes through `style.apply`, so a figure
drawn by hand and one drawn by it cannot end up looking different.

One behaviour of it turned out to be load-bearing in a way worth
recording. It filtered zeros out before plotting; the filter is gone,
replaced by the ordinate's `nonpositive="mask"`, and the figures are
byte-identical. But the two are *not* interchangeable with matplotlib's
default: `"clip"` sends a zero to some 2e5 pixels below the axis, so a
joined curve dives off the bottom of the figure and comes back where
there should be a gap. Anyone drawing an error rate by hand needs
`nonpositive="mask"`, and the docstring now says so.

### Documentation — the AWGN tutorial derives its reference curve

The Monte-Carlo page compared a measurement against
Expand Down
5 changes: 5 additions & 0 deletions docs/documentation/toplevel/data.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Data display
============

.. automodule:: comnumpy.data
:members:
4 changes: 3 additions & 1 deletion docs/documentation/toplevel/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ Chain services

Facilities that act on a whole chain rather than on a signal: the
exception hierarchy every block raises through, JSON export and
import, parameter sweeps, and the plotting style.
import, parameter sweeps, the result structure a sweep is shown
through, and the plotting style.

.. toctree::
:maxdepth: 1
Expand All @@ -12,4 +13,5 @@ import, parameter sweeps, and the plotting style.
exceptions
serialization
monte_carlo
data
style
12 changes: 6 additions & 6 deletions docs/getting_started/first_simulation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ Prerequisites

.. literalinclude:: ../../examples/simple/one_shot_awgn.py
:language: python
:lines: 1-21
:lines: 1-24


By Hand
Expand All @@ -44,7 +44,7 @@ Gaussian; the receiver keeps the nearest point.

.. literalinclude:: ../../examples/simple/one_shot_awgn.py
:language: python
:lines: 23-46
:lines: 26-49

That works, and it is worth being able to write. But look at what had to be
kept consistent, by you, across those twenty lines:
Expand All @@ -64,7 +64,7 @@ kept consistent, by you, across those twenty lines:

.. literalinclude:: ../../examples/simple/one_shot_awgn.py
:language: python
:lines: 48-56
:lines: 51-59

None of this is hard. All of it is a place where a study drifts as it grows:
change the modulation and four independent lines have to change together,
Expand All @@ -76,7 +76,7 @@ The Same Thing, as a Chain

.. literalinclude:: ../../examples/simple/one_shot_awgn.py
:language: python
:lines: 58-79
:lines: 61-82

.. mermaid:: mermaid/first_simulation.mmd

Expand Down Expand Up @@ -146,7 +146,7 @@ itself:

.. literalinclude:: ../../examples/simple/one_shot_awgn.py
:language: python
:lines: 81-84
:lines: 84-87

.. code::

Expand All @@ -170,7 +170,7 @@ It also draws itself, next to what the channel did to it:

.. literalinclude:: ../../examples/simple/one_shot_awgn.py
:language: python
:lines: 86-96
:lines: 89-101

.. image:: img/first_simulation_fig1.png
:width: 100%
Expand Down
Binary file modified docs/getting_started/img/first_simulation_fig1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/getting_started/img/profiling_chain_fig1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 5 additions & 5 deletions docs/getting_started/processor_profiling.rst
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ We start by importing the necessary libraries:

.. literalinclude:: ../../examples/simple/profiling_awgn_ofdm.py
:language: python
:lines: 1-14
:lines: 1-17

Define Parameters
"""""""""""""""""
Expand All @@ -32,7 +32,7 @@ Next, we define the communication and channel parameters:

.. literalinclude:: ../../examples/simple/profiling_awgn_ofdm.py
:language: python
:lines: 16-32
:lines: 19-35


OFDM Communication Chain
Expand All @@ -46,7 +46,7 @@ This chain includes mapping, carrier allocation, IFFT/FFT processing, cyclic pre

.. literalinclude:: ../../examples/simple/profiling_awgn_ofdm.py
:language: python
:lines: 34-51
:lines: 37-54

The chain is composed of the following processors:

Expand Down Expand Up @@ -94,7 +94,7 @@ outline marks a tapped block:

.. literalinclude:: ../../examples/simple/profiling_awgn_ofdm.py
:language: python
:lines: 56-59
:lines: 59-62


Profiling the Chain
Expand All @@ -107,7 +107,7 @@ and produces a bar chart of the results.

.. literalinclude:: ../../examples/simple/profiling_awgn_ofdm.py
:language: python
:lines: 53-54
:lines: 56-57

The resulting figure shows the time spent in each processor, making it easy to identify which stages dominate the computation.

Expand Down
12 changes: 6 additions & 6 deletions docs/tutorials/alamouti.rst
Original file line number Diff line number Diff line change
Expand Up @@ -53,14 +53,14 @@ We start with the imports and the parameters, QPSK over a Rayleigh channel:

.. literalinclude:: ../../examples/mimo/one_shot_alamouti.py
:language: python
:lines: 1-29
:lines: 1-31

Let us check the law on draws from
:func:`~comnumpy.mimo.utils.rayleigh_channel`:

.. literalinclude:: ../../examples/mimo/one_shot_alamouti.py
:language: python
:lines: 35-53
:lines: 37-55

.. image:: img/one_shot_alamouti_fig1.png
:width: 100%
Expand Down Expand Up @@ -180,7 +180,7 @@ what comes back, so one function builds them all:

.. literalinclude:: ../../examples/mimo/one_shot_alamouti.py
:language: python
:lines: 58-89
:lines: 60-91

The scaling by :math:`1/\sqrt{N_t}` matters for the comparison that follows.
Two antennas each transmitting :math:`|s|^2` would spend twice the power of a
Expand All @@ -199,7 +199,7 @@ One-shot simulation

.. literalinclude:: ../../examples/mimo/one_shot_alamouti.py
:language: python
:lines: 92-105
:lines: 94-112

.. code::

Expand Down Expand Up @@ -241,7 +241,7 @@ inverts:

.. literalinclude:: ../../examples/mimo/one_shot_alamouti.py
:language: python
:lines: 110-160
:lines: 117-167

The three curves are three readings of one closed form,
:func:`~comnumpy.core.metrics.compute_ser_rayleigh_psk`: :math:`L` branches
Expand All @@ -260,7 +260,7 @@ the simulation is what confronts them:

.. literalinclude:: ../../examples/mimo/one_shot_alamouti.py
:language: python
:lines: 162-201
:lines: 169-208

.. code::

Expand Down
Loading
Loading