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
76 changes: 45 additions & 31 deletions .claude/skills/comnumpy-tutorial/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,30 @@ then learns the wrong lesson -- they came to see how the library is used.
of **one** chain are not a Python loop: grow the stimulus a leading
batch axis -- `(n_trials, N)` -- and every block broadcasts over it,
stochastic blocks draw independently per trial, and the pooled metric
is exactly the mean of the per-trial rates (D51; the exception is a
channel whose realization is frozen at construction -- a new draw per
trial is a new instance, and that loop stays visible). When the study
is exactly the mean of the per-trial rates (D51).
**Averaging over a random channel (fading, MIMO)** is the same idea
with the draws stacked into the configuration: draw a **fixed** set
of channels once -- `rayleigh_channel(size=K)`, one draw = one batch
row, the SAME K draws at every sweep point, never a draw count that
grows with the SNR (the reader compares curves, not draw counts) --
hand the stack to the channel block AND to its detector at
construction, and sweep the noise variance with `monte_carlo`: no
simulation loop at all. A detector that weights by the noise
(`MMSE`, `OSIC`) receives the swept variance too, by zipping two
dotted paths: `monte_carlo(chain, ("noise.sigma2",
"detector.sigma2"), list(zip(values, values)), ...)`.
**On the page, interleave**: one technique is one unit -- its idea
and equations, then its `Sequential` and its `monte_carlo` call
quoted together as one `literalinclude` block. The reader meets each
method whole: theory, chain, sweep.
**Never a loop over the techniques.** A `for name, chain in (...)`
around two or three `monte_carlo` calls is over-engineering: write
the calls out, one explicit statement per chain, filling
`curves[name]` by name. A loop earns its place at ten iterations,
not at two. The same goes for storage: two curves are a dict literal
of two `np.zeros`, not an init loop; and a runtime sweep is ONE loop
over the swept variable with each chain timed explicitly in the
body, never a nested loop over the techniques. When the study
is *around* a chain -- several detectors on one frame, several
receivers on one propagation -- write the loop out, on **one storage
convention**: declare the methods and the metrics first; pre-allocate
Expand Down Expand Up @@ -115,34 +136,23 @@ then learns the wrong lesson -- they came to see how the library is used.

### Naming the chain

Three rules, and they are about where the reader looks and what the
signature promises.

1. **A function only when the chain is built more than once** -- several
values of a parameter, several variants, a Monte-Carlo. A chain built
once and used once is written inline: wrapping four blocks in a
function called once, with no argument, is ceremony, and it puts an
indirection between the reader and their first chain.
2. **Always `get_<thing>()`**, one prefix, the noun saying what comes
back. Which noun follows from how the page is organized:

| the page… | names |
|---|---|
| has one chain | `get_chain(...)` |
| cuts one chain in two | `get_channel()` and `get_receiver()` |
| compares two structurally different chains | `get_uncoded_chain()`, `get_coded_chain(soft)` |
| builds a sub-assembly, not a whole chain | the part: `get_transmitter()` |

Two chains that differ by a block or two are **one** function with a
parameter, not two functions. Two chains that differ in structure --
four blocks against six -- are two functions, because merging them
would hide behind a flag exactly what the page is comparing.
3. **What varies is a parameter, never a module global.** Constants of
the page (the fibre, the constellation, the roll-off) may stay
global; anything the page *varies* goes through the signature. A
function whose signature announces three arguments and silently reads
eleven more is a closure wearing a `def`, and the reader cannot tell
from the call site what changed.
**Default: no function. One `Sequential` per technique, written out in
full, before the sweep.** A page that compares SISO, Alamouti and MRC
writes three `Sequential([...])` blocks -- even if they share most of
their blocks -- because the reader must see each chain whole; a factory
with a `kind` flag hides behind the flag exactly what the page is
comparing, and a factory called once with no argument is ceremony. The
batch contract (D51) is what makes this possible: the channel draws are
stacked into the chain at construction (see the Monte-Carlo bullet), so
nothing needs rebuilding inside a loop -- there is no loop.

A `def` remains legitimate in exactly one case: the chain is genuinely
rebuilt with different *structure* along the page (a DBP receiver with
a varying step count, a coded chain against an uncoded one built in
several places). Then it is `get_<thing>()`, the noun saying what comes
back (`get_chain`, `get_receiver`, `get_transmitter`), and what varies
is a parameter of the signature, never a module global read silently
from inside.

### The rest of the script

Expand Down Expand Up @@ -171,6 +181,10 @@ signature promises.
one and 9.1 ms in the other.
- State the estimator's floor when a curve hits one (an SER of 1e-4 from
8192 symbols is the estimator, not the link).
- **No analysis codas.** No `np.interp` readings of a closed form, no
diversity-order polyfits, no measured/theory ratio tables, no mermaid
dumps at the end of a script: they drown the reader. What a figure
shows is said in one sentence of prose next to the figure.

## Register

Expand Down
72 changes: 72 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,78 @@ 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 — every tutorial script held to the writing doctrine

The conventions sweep closed over the whole tutorial set. The coding
page: three chains written out in place of a factory pair, the LDPC
iteration count varied by `set_params(decoder__n_iter=)` on one chain
instead of rebuilding it in a loop, two `print_data` tables in place of
hand-rolled aligned prints. The AWGN page: the chain inline, the
constellation-order study reconfiguring it with `set_params`, the
diagram section gone (its feature demo lives in the getting-started
page). The OFDM page: two explicit `monte_carlo` calls and a runtime
loop over the swept lengths only. The MIMO cost study and the PAPR
references: unrolled. The shaping and GN-model scripts lose their
mermaid dumps and stray `plt.show`; the GN-model factory stays -- its
source genuinely changes structure (a Gaussian stimulus against a
mapped one) -- and so do the two DBP scripts' factories, the one
sanctioned chain split, for the Monte-Carlo cost of re-propagating the
fibre. The interpolation the shaping page keeps reads its own computed
capacity curves, not a closed form.

### Changed — the Alamouti and MIMO tutorials rewritten on the batch

Both pages carried simulation scaffolding that drowned the reader: a
factory building five chains behind a `kind` flag, per-SNR draw counts,
zipped channel sweeps, interpolation codas reading numbers off closed
forms, mermaid dumps. They are rewritten on one doctrine, now in the
tutorial skill. The page opens on **one** chain, one channel draw, no
batch -- the problem shown plainly. The simulation then draws a
**fixed** stack of channels once (`rayleigh_channel(size=K)`, the same
K draws at every SNR point: the reader compares curves, not draw
counts), builds **one `Sequential` per technique** -- written out in
full, no factory -- with the stack on the channel block and on the
detector, and runs **one `monte_carlo` per technique** sweeping the
noise variance, zipped into the detector when it weights by it (MMSE,
OSIC): no simulation loop at all. On the MIMO page each technique is
one unit -- equations, chain, sweep -- interleaved. To make it
possible, `SpaceTimeDecoder` accepts a stacked H (channel k decodes
frame k, locked in `tests/mimo/test_stacked_channel.py`) and both
Alamouti blocks join the BROADCAST register of the batch contract.

### Changed — the 91 docstrings audited against their code

Five parallel reviewers read every `Processor` docstring against its
`forward()` -- equations, Axes lines, parameter lists, doctests -- and
62 findings were fixed. Four were **code** bugs the prose exposed:
`Resampler` resampled axis 0 (scipy's default) while promising the last
axis; `SelectiveMIMOChannel` validated its input against `N_r` instead
of `N_t`, rejecting every non-square channel's correct frames;
`BlindCFOCompensator.history` stored 16 times the offset (renamed
`history_`, D23, and fixed to store the omega_0 iterates it documents);
and `Laser` drew its "random" initial phase from a Gaussian of std 2 pi
instead of uniformly on [-pi, pi).

The rest is the docstrings catching up with the code. Wrong equations
straightened: `BWFilter`'s mask (off by 2 vs its own parameters),
`AmplitudeDemapper`'s decision (on |Re y|, per quadrature lane, not
|y|), the Rice path of `TappedDelayLineChannel` (the gamma_0 scaling
the code applies), the ambiguous alpha_dB conversion in the optical
helpers. Stale claims deleted: seven phantom pre-D46 parameters on
`FiberLink` and `DBP` (now one `fiber: FiberSpec` entry), the unusable
`norm` field of `FrequencyDomainEqualizer` (removed -- it never reached
the FFT), 1-D-only Axes lines on blocks that broadcast (CD, Kerr,
EDFA, BlindPhaseTracker), "multiplied" for a division, `"time"` as a
method that never existed. The MIMO detectors' stored decisions are
`S_` (D23), the CMA equalizer is written W -- H is the channel
everywhere else -- and `BlindPhaseSearchCompensator` gained the
Attributes section its `phase_` deserved. The batch registers were
corrected on the way: `DelayRemover` and `BlindPhaseTracker` are
verified BROADCAST, `Normalizer` moved to EXEMPT -- its gain is one
scalar over the whole array by documented design, and its docstring now
warns that a batch is pooled. `PtsPaprReductor` validates its (T, F)
layout in `prepare()` instead of dying on a bare IndexError.

### Added — the batch promise is a ratchet over the catalogue

A convention over 91 blocks is only worth the sweep that checks it.
Expand Down
2 changes: 1 addition & 1 deletion CONVENTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ Every block belongs to exactly one category, stated in its docstring
("Axes:" line):

1. **Element-wise** — operates pointwise, shape-agnostic
(`AWGN`, `SymbolMapper`, `HardClipper`).
(`AWGN`, `SymbolMapper`, `Clipper`).
2. **Axis -1** — hardcodes `axis=-1` and broadcasts over everything else
(filters, `FFTProcessor`, `IFFTProcessor`, `CyclicPrefixer`).
3. **Declared axis** — requires a specific axis and validates it in
Expand Down
140 changes: 64 additions & 76 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-31
:lines: 1-29

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

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

.. image:: img/one_shot_alamouti_fig1.png
:width: 100%
Expand Down Expand Up @@ -175,45 +175,29 @@ diversity costs no bandwidth**, and no feedback.
Implementation
^^^^^^^^^^^^^^

The three schemes differ by what is put on the antennas and what is done with
what comes back, so one function builds them all:
One channel draw, one chain, transmitter to decision. 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 single
antenna, a 3 dB advantage that has nothing to do with coding. Splitting the
power keeps the comparison about diversity alone.

.. literalinclude:: ../../examples/mimo/one_shot_alamouti.py
:language: python
: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
single antenna, which is a 3 dB advantage that has nothing to do with coding.
Splitting the power keeps the comparison about diversity alone.

.. mermaid:: mermaid/alamouti.mmd

The diagram is not drawn by hand. It is what the chain says about itself --
``chain.to_mermaid()`` (decision D33c) -- exported by the script, so the
block names are the ones the code uses and a dashed outline marks a tapped
block.

One-shot simulation
"""""""""""""""""""

.. literalinclude:: ../../examples/mimo/one_shot_alamouti.py
:language: python
:lines: 94-107
:lines: 55-84

.. code::

one-shot SER: 0.0600
# block id output shape dtype time ms
0 SymbolGenerator tx (1000,) int64 0.04
0 SymbolGenerator tx (1000,) int64 0.03
1 SymbolMapper symbol_mapper (1000,) complex128 0.00
2 Amplifier signal_amplifier (1000,) complex128 0.01
3 SpaceTimeEncoder space_time_encoder (2, 1000) complex128 0.10
3 SpaceTimeEncoder space_time_encoder (2, 1000) complex128 0.06
4 FlatMIMOChannel channel (1, 1000) complex128 0.01
5 AWGN noise (1, 1000) complex128 0.06
6 SpaceTimeDecoder detector (1000,) complex128 0.12
7 Amplifier signal_amplifier_2 (1000,) complex128 0.01
8 SymbolDemapper symbol_demapper (1000,) int64 0.08
5 AWGN noise (1, 1000) complex128 0.05
6 SpaceTimeDecoder detector (1000,) complex128 0.08
7 Amplifier signal_amplifier_2 (1000,) complex128 0.00
8 SymbolDemapper symbol_demapper (1000,) int64 0.06

The shape column is the code at work: 1000 symbols enter, the encoder spreads
them over ``(2, 1000)`` -- two antennas, one thousand channel uses, so rate 1
Expand All @@ -233,63 +217,67 @@ the equations above.
Monte Carlo Evaluation
^^^^^^^^^^^^^^^^^^^^^^

Averaging over fading means running the chain once per channel realization,
which is a sweep whose parameter is the channel. :func:`~comnumpy.monte_carlo.sweep`
takes several dotted parameter names at once and zips them, so one sweep point
sets the channel the signal goes through **and** the channel the detector
inverts:
Averaging over fading takes many channel draws, and the draws are a
**batch**: the *same* 5000 realizations serve every SNR point, one draw per
row. Each scheme is one chain, built once with its stack of channels -- the
channel block propagates draw :math:`k` on frame :math:`k`, and the detector
holds the same stack:

.. literalinclude:: ../../examples/mimo/one_shot_alamouti.py
:language: python
:lines: 112-169

The three curves are three readings of one closed form,
:func:`~comnumpy.core.metrics.compute_ser_rayleigh_psk`: :math:`L` branches
evaluated at the per-branch SNR, with a transmit scheme dividing that SNR by
:math:`N_t` because it splits its power over the antennas. ``plot_error_rate``
draws measurements as hollow markers and their references as lines of the same
colour, so a pair reads as one statement:
:lines: 86-126

.. image:: img/one_shot_alamouti_fig3.png
:width: 100%
:align: center
From there the sweep needs no simulation loop at all:
:func:`~comnumpy.monte_carlo.monte_carlo` moves the noise variance, and
everything else -- the channels, the chains -- is frozen:

The two statements this tutorial is about -- the slope and the 3 dB -- are
exact, so they are read off the closed form rather than fitted to the points;
the simulation is what confronts them:
.. literalinclude:: ../../examples/mimo/one_shot_alamouti.py
:language: python
:lines: 128-143

.. literalinclude:: ../../examples/mimo/one_shot_alamouti.py
:language: python
:lines: 171-210
:lines: 145-164

.. code::

1 Tx, 1 Rx (no diversity) measured / closed form 0.99 0.97 0.94 0.90 0.83 0.86
Alamouti, 2 Tx, 1 Rx measured / closed form 0.98 0.92 0.88 0.77 0.84 0.65
MRC, 1 Tx, 2 Rx measured / closed form 0.98 0.91 0.82 0.72 0.73 0.28
1 Tx, 1 Rx (no diversity) diversity order 1.00
Alamouti, 2 Tx, 1 Rx diversity order 2.00
MRC, 1 Tx, 2 Rx diversity order 2.00
SNR for SER = 0.001: MRC 15.6 dB, Alamouti 18.6 dB, gap 3.01 dB (10log10(N_t) = 3.01 dB)

**The slope.** The single-antenna scheme has diversity order 1, the two
others 2: the error rate falls twice as fast, which is the whole reason a
space-time code exists.

**The 3 dB.** Reaching a symbol error rate of :math:`10^{-3}` costs 15.6 dB
with two receive antennas and 18.6 dB with Alamouti, a gap of **3.01 dB**
against :math:`10\log_{10} N_t = 3.01` dB. That is the price of transmitting
*blind*: the receiver knows the channel and weights its branches by
:math:`h_i^{*}`, the transmitter cannot and splits its power evenly. Alamouti
buys the full diversity order anyway; it pays only in array gain, not in
slope.

**The ratios.** The measurement tracks the closed form to a few percent at low
SNR and drifts below it as the SNR grows, down to 0.28 for the steepest curve:
6000 channel draws no longer sample the deep fades that dominate the average
there. Nothing is wrong with either side --
``validation/mimo_diversity_ber.py`` runs the same three schemes with up to
80 000 draws and lands within 4.4 % of the same curves.
SER
snr_dB 1 Tx, 1 Rx (no diversity) Alamouti, 2 Tx, 1 Rx MRC, 1 Tx, 2 Rx
------------------------------------------------------------------------
4 0.22261 1.778e-01 8.162e-02
8 0.11186 6.430e-02 2.155e-02
12 0.04976 1.675e-02 4.200e-03
16 0.02082 3.373e-03 5.700e-04
20 0.00843 5.525e-04 8.000e-05
24 0.00312 6.250e-05 2.500e-06

.. image:: img/one_shot_alamouti_fig3.png
:width: 100%
:align: center

The three curves are three readings of one closed form,
:func:`~comnumpy.core.metrics.compute_ser_rayleigh_psk`: :math:`L` branches
evaluated at the per-branch SNR, with a transmit scheme dividing that SNR by
:math:`N_t` because it splits its power over the antennas.
``plot_error_rate`` draws measurements as hollow markers and their references
as lines of the same colour, so a pair reads as one statement -- and the two
statements this tutorial is about are on the figure:

**The slope.** The single-antenna curve loses one decade of error rate per
10 dB; the two others fall twice as fast. That is diversity order 2, the
whole reason a space-time code exists.

**The 3 dB.** The Alamouti and MRC curves are parallel, separated
horizontally by :math:`10\log_{10} N_t = 3` dB. That is the price of
transmitting *blind*: the receiver knows the channel and weights its
branches by :math:`h_i^{*}`; the transmitter cannot, and splits its power
evenly. Alamouti buys the full diversity order anyway -- it pays only in
array gain, never in slope.

The last MRC point sits on the estimator's floor: 2.5e-6 out of 400 000
symbols is one error, not a rate. ``validation/mimo_diversity_ber.py`` runs
the same three schemes with up to 80 000 draws and lands within 4.4 % of the
closed forms.


Beyond two antennas
Expand Down
Loading
Loading