Skip to content

One simulation structure across the tutorials, tried against an Experiment object - #40

Merged
vincentchoqueuse merged 7 commits into
mainfrom
claude/comnumpy-v1-architecture-5dtrz3
Aug 13, 2026
Merged

vincentchoqueuse merged 7 commits into
mainfrom
claude/comnumpy-v1-architecture-5dtrz3

Conversation

@vincentchoqueuse

Copy link
Copy Markdown
Owner

Seven commits that tell one story: what the tutorials needed was a storage convention, not an engine. The branch keeps the full record — an Experiment object was built, measured, extended, and removed the same day — because the measurements are what justify the destination.

The destination

Every simulation section in the tutorials now reads in the same three parts, under the same markers, in the same order:

# --- metrics, pre-allocated ---
# --- simulation loop ---
# --- results: tables and figures ---

The convention, written into the tutorial skill and applied to nine sweeps across the MIMO, OFDM, optical and AWGN pages:

  • the methods and the metrics are declared first, as the ordered dictionaries they are;
  • storage is pre-allocated to zeros, one np.zeros(n_points) per (metric, method), indexed by name on both levels — never a 2-D array indexed by position, whose countable column offset is the classic silent bug and was live in one of these scripts (ber_data[index_snr, index_comp+1]);
  • the loop draws one child seed per point from a master seed (SeedSequence.spawn, D6/D35) and fills the arrays — the loop stays visible, because the loop is the pedagogy;
  • the display comes last, from the same dictionaries the loop filled — each inner dict is exactly the curves that print_data and plot_data render.

The detour, and why it is in the history

An Experiment(config, parameter=, values=, seed=) object ran the loop behind a simulate callback, per a full specification: auto-kept seeds, save= selection, grouped observations, result.print()/result.plot(). Measured against the four studies it ran: −2, +5, +11, −16 lines — shorter nowhere — and it hid exactly the loop the tutorials exist to teach. Removed before any release.

What survives from it is what mattered:

  • Three studies were reproducible by nobody, their author included — the two MIMO detector sweeps and the CD compensator comparison ran with no seed at all. All are seeded now, in the script where the reader can see it.
  • The migration is verified to the digit: the DBP page's tables are identical between the object and the loop, because the in-script seeding reproduces the object's derivation exactly.

What the review pass found on the way

  • A list comprehension in one_shot_ofdm.py that the tutorial rules ban, hiding a monte_carlo call inside an expression.
  • The last append-and-convert accumulations, a hand-rolled aligned print loop that print_data replaces, a dead five-line dict-to-array conversion, and a bits-per-symbol column now computed by np.log2(orders).astype(int) — NumPy over iteration wherever the loop taught nothing.
  • A drift: the OFDM page still pasted the runtime table in a format the script had stopped printing.

The launch amplifier also moves before the upsampler in the three optical scripts that had it after the pulse shaping — verified equivalent to 1.6e-14 on the symbols, and the fourth script already did it — so the amp/1/amp pair now acts on the same quantity at both ends of the chain.

Verification

All five re-run tutorial scripts produce physics identical to the digit; wall-clock tables are re-pasted from the runs. ruff check ., 901 tests + 1155 subtests, doctests, pyright against NumPy 2, the literalinclude guards, and sphinx-build -W all pass.


Generated by Claude Code

claude added 7 commits August 13, 2026 15:02
The transmit amplifier sat after the pulse shaping and its inverse sat
after the final downsampling, so the pair that has to agree acted on two
different representations -- one on the field at 64 GHz, the other on
symbols at 10.7 GBd. Both correct, but checking that they match took a
moment's thought rather than a glance.

It commutes: the amplifier, the upsampler and the SRRC filter are all
linear, and the measurement says so -- same effective SNR to 1e-6 dB,
1.6e-14 on the symbols themselves, which is float64 rounding. So it
moves next to the mapper, and the launch amplitude now brackets the
whole shaping chain at the symbol level on both sides:

    SymbolMapper -> Amplifier(amp) -> Upsampler -> SRRCFilter -> [fibre]
    [fibre] -> ... -> Downsampler -> Amplifier(1/amp) -> phase

Three of the four optical scripts had it after the filter and
validation/optical_dbp_hager.py had it before, which is how a convention
looks when nothing states it -- and that script reproduces published
curves, so the position was never in question, only the consistency.
The four now agree.

Every published number is unchanged: the span table, the launch-power
sweep and the GN-model page print exactly what they printed before, and
the figures are byte-identical. Only gn_model.mmd moves, because the
chain draws the order it now has.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The rule that replaced it -- draw with matplotlib, decorate with
style.apply -- is right for a page that shows one constellation and
wants the reader to see what an IQ plane is made of. Applied to every
call site it was wrong, and the DBP tutorial is where it showed: seven
IQ panels, five lines each, in a file whose subject is the Kerr effect.
It grew that script from 210 lines to 232, and the twenty-two added
lines taught nothing the first panel had not.

So the fourteen sites go back to plot_iq, which also keeps the equal
aspect ratio no one will remember to set the day they copy the block.
The style sheet is active now, so the grid comes from rcParams and the
function does not need to ask for it.

The rule in the tutorial skill is rewritten to say when each applies:
the function when one exists -- plot_iq, plot_spectrum, plot_error_rate
for measured curves paired with their closed forms -- and style.apply
for a figure with no function behind it, a runtime or an effective SNR.
That is the same line the four misused plot_error_rate calls were moved
across earlier; it just now runs in both directions.

examples/ loses 60 lines. one_shot_NLI.py is back to 219.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every study in the examples was the same sentence -- run the same
experiment for several values of one parameter, keep what each run
measured -- and every script spelled it out again: pre-allocated
np.zeros((n_points, n_methods)), nested loops indexed by hand, one
accumulator per metric divided by N_test at the end. The two MIMO
detector sweeps also ran unseeded, so the curves in the documentation
were reproducible by nobody, their author included.

Experiment is that sentence as an object. simulate(config, seed) is the
experiment itself, written by the user: it receives a copy of the
conditions with the studied parameter set and a per-point child seed
(D6/D35, the same spawning monte_carlo uses), and returns a dictionary
of what it observed. The object aligns each entry into an array,
refuses a simulate that changes its observations mid-run, and hands
back a result carrying the parameter, the values, the configuration,
the seed and the wall time. When no seed is given, one is drawn and
kept -- result.seed always answers, so an interesting accident can be
re-run. save= narrows what is kept. The result renders through
comnumpy.data: result.print() is the conditions plus the table,
result.plot() the same object drawn.

Deliberately not a framework: one parameter, one callable, one
dictionary out. A single-chain sweep with standard metrics stays
monte_carlo; Experiment is for the study around a chain -- several
detectors on one frame, several receivers on one propagation -- where
the collection loop was becoming the longest part of the script.

Rewritten on it here: both MIMO detector sweeps and the launch-power
sweep of the DBP tutorial. The DBP page's numbers move slightly with
the new seeding and are re-pasted from the run -- the GN-model gap is
now 0.08 dB where the old seeds happened to give 0.01, and the page
says so; the fifty-step receiver sits 0.46 dB from the noise-only
bound. monte_carlo_simulation_1.py leaves SLOW on the way, listed at
32 s and measured twice at 4.4 s.

17 new tests, a doctest, a documentation page, and the D37 ratchet
covers the module.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The last of the hand-rolled collection loops: np.zeros((21, 3)) indexed
by two counters, an unseeded chain, and nothing printed but the filter
length. One Experiment per modulation now, seeded, each printing its
conditions and its BER table before the shared figure.

Measured at 332 s alongside a docs build (227 s noted on 2026-08-09);
the SLOW entry says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first version of Experiment collected flat dictionaries only, so
the DBP sweep had to smuggle its structure through prefixed keys --
"ser " + name packed on one side of the run, unpacked by loops on the
other. That was the storage boilerplate the object was meant to remove,
moved rather than removed.

An observation may now itself be a mapping: one SNR per receiver, one
BER per detector. Such a group collects into a dictionary of arrays, so
simulate returns {"snr [dB]": {"ZF": ..., "ML": ...}} and
result.data["snr [dB]"] is directly the curves that print_data and
plot_data render. result.print() prints one table per group, captioned
by the group's name -- which is where the unit lives -- and
result.plot("snr [dB]") draws one family. A group whose members change
mid-run is refused, like a quantity that appears mid-run always was.

result.print() loses its ylabel parameter to the group name, and
result.plot() gains the key as its first argument. Seven new tests pin
the grouped path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The object made the scripts shorter nowhere -- measured at -2, +5, +11
and -16 lines over the four studies it ran -- and it hid exactly the
loop the tutorials exist to teach. The abstraction the tutorials needed
was a convention for storing results, not an engine running the loop
behind a callback. Tried, measured, removed before any release.

The convention, now written into the tutorial skill and applied to all
four sweeps: declare the methods and the metrics first, as the ordered
dictionaries they are; pre-allocate one np.zeros(n_points) per
(metric, method), indexed by name on both levels -- never a 2-D array
indexed by position, whose countable column offset is the classic
silent bug and was live in one of these scripts; draw one child seed
per point from a master seed (SeedSequence.spawn, D6/D35); let the
visible loop fill the arrays; display last, from the same dictionaries
the loop filled, each inner dict being exactly the curves that
print_data and plot_data render.

What survives from the object is what mattered: the two MIMO sweeps
and the CD comparison are seeded for the first time, and the DBP page's
tables are reproduced to the digit by the in-script seeding -- verified
by re-running all four studies and diffing the tutorial's pasted output
against the runs. Only two wall-clock cells moved, and they are
re-pasted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ound

Every sweep in the tutorial scripts now reads in the same three parts,
under the same markers, in the same order:

  # --- metrics, pre-allocated ---
  # --- simulation loop ---
  # --- results: tables and figures ---

The pass is a review of the simulation code as a NumPy reader would
write it, and it removed what it found on the way:

- a list comprehension in one_shot_ofdm.py that the tutorial rules ban,
  hiding a monte_carlo call inside an expression -- now an explicit
  loop over a pre-allocated (n_trials, n_points) array;
- the last append-and-convert accumulations (one_shot_ofdm,
  one_shot_mimo, one_shot_alamouti, and the by-hand loop of
  monte_carlo_awgn), replaced by arrays pre-allocated to zeros and
  filled by index -- the by-hand loop is the page that teaches the
  pattern, so it now teaches this one;
- a hand-rolled aligned print loop in one_shot_mimo, replaced by
  print_data;
- a dead five-line dict-to-array conversion in one_shot_NLI;
- a bits-per-symbol column computed by np.log2(orders).astype(int)
  rather than a loop -- NumPy over iteration wherever the loop taught
  nothing.

All five scripts re-run; the physics is unchanged everywhere (the AWGN,
Alamouti and span tables are identical to the digit) and the wall-clock
tables are re-pasted from the runs, which also caught a drift: the OFDM
page still showed the runtime table in a format the script stopped
printing a day ago.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vincentchoqueuse
vincentchoqueuse merged commit 63885ca into main Aug 13, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants