Repository navigation
Name the Monte-Carlo, make the fibre cheap, derive the AWGN curve, and run the examples nobody ran - #38
Merged
Conversation
Seven example scripts wrapped their chain in a factory, under four spellings for the same idea: `get_link`, `link`, `link_chain`, `get_full_chain`, `uncoded_chain`. A reader who learnt one had to re-learn it on the next page, and nothing said when a factory was called for in the first place. The tutorial skill now carries three rules. A factory exists only when the chain is built more than once; a chain built once and used once stays inline, because wrapping four blocks in a function called once with no argument puts an indirection between the reader and their first chain. When it exists it is `get_<thing>()`, the noun following the page's organisation: `get_chain()` for one chain, `get_channel()` and `get_receiver()` for the two halves when a page cuts one in two, `get_transmitter()` when what comes back is a sub-assembly, and `get_uncoded_chain()` / `get_coded_chain(soft)` when two chains differ in structure rather than in a parameter -- two blocks of difference is an argument, four blocks against six is two functions, since merging those would hide behind a flag exactly what the page compares. The third rule is the one that motivated the other two, and it is the same shape as the defect fixed in the previous commit: what the page varies is a parameter, never a module global read behind the signature's back. `get_full_chain(n_spans, steps, linear_only)` also read eleven module-level names, so the call site did not say what changed between two calls. The rename reaches `validation/fec_coding_gain.py` as well: a layer that contradicts the convention is where the convention starts to rot. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pn2F8X4HZhS8fEJcL5enzw
Some blocks precompute in `__post_init__`, others in `prepare()`, and nothing said which belonged where. `set_params` was never the ambiguity -- it validates the field is an init field and re-runs `__post_init__` on every block it touches, so both families are correct through it. The hole is direct assignment, which stays legal and silently leaves the majority of blocks inconsistent: of the 95 Processor subclasses, 57 of the 67 that derive anything derive it in the constructor. The rule splits by what a value depends on. `__post_init__` precomputes what depends only on the block's parameters; `prepare(x)` derives what depends on the signal. The direction is asymmetric, which is why there are two places: signal-dependent state in `__post_init__` is wrong, while parameter-only state in `prepare()` is merely repeated work -- `FiberLink` and `DBP` do the latter, negligibly against the split-step cost, so deriving in `prepare()` is not evidence of signal dependence. Blocks cannot be frozen to close the hole: `wiring` writes `reference` during the pass and estimators write `theta_`, so a Processor must stay mutable. The discipline is therefore carried by the rule rather than by the type, and the corollary that makes it work is that `__post_init__` must be re-callable with no side effect. The measured example in CONVENTIONS.md is checked, not asserted: `Downsampler(4, use_filter=True)` keeps a 1/4 cutoff after `.L = 2` and rebuilds it to 1/2 after `set_params`. The ratchet that would make an incomplete `__post_init__` unshippable -- build in A, `set_params` to B, require the output of a block built directly in B -- is named as not done. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pn2F8X4HZhS8fEJcL5enzw
Three tutorials wrote the same amplifier-noise product by hand, with three spellings of the polarization factor: `2 * N_SPANS * density * BAUD`, `N_span * density * R_s`, `n_spans * n_ase * R_s`. That factor is the one whose convention made the GN-model page and the back-propagation page look like they disagreed, and `FiberLink` already held every term of it. `FiberLink.budget(bandwidth_Hz, polarizations=)` returns the noise the block will actually add, integrated over the bandwidth a receiver keeps -- the symbol rate, not the simulated `fs`, which is why the examples were recomputing rather than reading `edfa_N_ase`. `prepare` and `budget` now share `_ase_densities`, so the two cannot drift: one turns the density into a variance over `fs`, the other into a power over the receiver's bandwidth. The three call sites reproduce their published numbers to the decimal: -21.88 dBm, -20.88 dBm. `launch_amplitude(power_W, polarizations=)` names the other half of the same trap -- the `sqrt(P/2)` of a dual-polarization launch, written by hand at three call sites. Two smaller things the examples were doing the long way: `compute_effective_snr` already takes `unit="dB"` and two of its three call sites converted by hand, and the optical chains decided outside the chain with `hard_projector` where a `SymbolDemapper` and a tap give both the soft estimate and the decisions. D50 found a live regression within the hour. `CD_compensation_part1.py` raised `TypeError: 'Constellation' object is not subscriptable`: it sets `chain["mapper"].alphabet` by assignment, and the `Constellation` migration put the coercion in `__post_init__`, which a direct write skips. The example is in `SLOW`, so the smoke test never ran it and the breakage shipped silently -- nine examples are in that position and nothing executes them. Fixed here through `set_params`, along with the three other scripts that assigned into a block (`monte_carlo_simulation_1` and `_2` on `noise.sigma2` and `channel.H`). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pn2F8X4HZhS8fEJcL5enzw
The receiver gained a `SymbolDemapper`, so the profile table gained a `data_rx` row and every wall time on the page moved. All four blocks now come from a single execution of the example, which is the only way two tables on one page can be read against each other: 12.3 ms of dispersion compensation against 2892 ms of back-propagation, 235 times for 8.2 dB. Every measured decibel is unchanged -- the effective SNRs, the fibre's cost per span, the two checks and the launch-power sweep are identical to the figures they replace, which is what the refactoring promised. One sentence had to go with them. The span table's time column was introduced as growing with the number of spans, and on a shared runner it does not do so monotonically; what the column actually shows, and what `profile_execution_time` then explains, is that the propagation is the whole cost. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pn2F8X4HZhS8fEJcL5enzw
A Monte-Carlo over a fading channel, over several detectors and over a split-step link has a different shape each time, and that shape is what the reader of an example came to see -- so the loop stays written out. What is the same everywhere, and what hides mistakes, is the counting. `ErrorCounter` accumulates errors and symbols across the trials of one point. The rate it returns is a ratio of totals rather than a mean of per-trial rates: identical while every trial has the same length, which is where the examples stand, and a factor fifty apart when they do not -- one error in ten symbols then none in a thousand is 1/1010, not 1/20. A test pins both readings. It also keeps `n_errors` readable, which is the number a point's credibility rests on. A rate of 1e-4 from three errors and one from three hundred are different claims, and the launch-power sweep now prints, per receiver, how many errors it actually saw at its best power -- so the page can state its resolution instead of apologising for it in prose. Only two of the four hand-written loops count what this counts, and the other two are left alone rather than bent to fit: `monte_carlo_simulation_1` accumulates a *block* error rate, and `CD_compensation_part1` runs one trial per point and has nothing to accumulate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pn2F8X4HZhS8fEJcL5enzw
`sweep` was the only bare verb in the root namespace -- beside `compute_ser`, `get_alphabet`, `to_json`, `ebn0_to_snr_dB` -- and the only exported name that said nothing about what it returns. More to the point, it said nothing about the points being random: `sweep` is what a frequency or DC scan is called, while every point here draws its own noise and needs `seed=` to be reproducible. `monte_carlo` says that, and it is the word the tutorials already use for these sections. The module, its two test files and its API page move with it, and the migration table carries the row. A pyright entry pinned the old path, which is what the D37 ratchet is for: it failed on the rename. The English noun is left alone. "A launch-power sweep", "a sweep over N_cp", "the points of a sweep" are the domain's own words and still read as such in the prose; only code references, ``sweep`` markup and the identifier itself moved. A first blanket rewrite replaced the noun too and was reverted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pn2F8X4HZhS8fEJcL5enzw
The original `NLI_simulation.py` reproduced the effective-SNR curves of Hager and Pfister (arXiv:2010.14258) on their own setup, and successive rewrites turned it into a teaching script: 16-QAM instead of the paper's Gaussian stimulus, linear steps where the paper uses logarithmic ones for the one- and two-step back-propagators, and far fewer steps. The reproduction was lost, and a reproduction is not a tutorial -- under D7 it belongs in `validation/`, which is where a claim of correctness lives. `validation/optical_dbp_hager.py` restores the setup: Gaussian symbols, both links (25 x 80 km at 10.7 GBd and 10 x 100 km at 32 GBd), logarithmic steps for the low-step receivers, and the paper's own complex-scalar gain and phase correction -- which is `DataAidedComplexGainCompensator(shared=True)` rather than four lines of algebra. The amplifier-noise reference spans 16.04 to 29.75 dB over -6 to +8 dBm, against the 16.5 to 29.5 dB of the paper's figure for that link. What is asserted is the structure those figures show -- the reference climbs monotonically, every other receiver turns over inside the range, more steps buy a higher peak at a higher power in order, and the best one stays within a few decibels of the bound -- because the published values are read off figures and transcribing them by eye would assert my eyesight. The table is printed in full so the curves can be laid over the paper's. The default run is ten seconds; `--full` is the paper's StPS = 500 with ten trials on both links. One thing is deliberately not restored. The original band-limited with `BWFilter(1 / oversampling_sim)` before decimating by `oversampling_sim // oversampling_dsp`, which is the defect fixed in #37: it put a distortion floor under every curve and is why a reference that should climb as P/P_ASE saturated instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pn2F8X4HZhS8fEJcL5enzw
Pyright strict caught what ruff cannot: `budget()` was annotated `Dict[str, float]` while returning a span count and a polarization count as ints, and `watt_to_dbm` as `ndarray | float`. The other dictionaries of this kind -- `Constellation.info`, `SpaceTimeCode.info` -- are `dict[str, Any]`, which is what a mapping holding a count beside a power honestly is. My miss: ruff passed locally and I did not run pyright, which is the job that guards the py.typed marker (D37).
Both optical examples propagated at 200 steps per span on the strength
of one sentence -- "enough that the propagation is not in question, 500
steps give the same effective SNR to three decimals". True, and four
times more than needed.
Measured where the step-size error is worst, at the highest launch power
of the sweep, by refining until the answer stops moving:
StPS = 200 11.758 dB +0.000 dB 38.0 s
StPS = 100 11.759 dB +0.001 dB 19.3 s
StPS = 50 11.763 dB +0.005 dB 9.4 s
StPS = 25 11.776 dB +0.018 dB 5.0 s
StPS = 12 11.845 dB +0.087 dB 2.5 s
Fifty steps sit 0.005 dB from the converged answer, two orders of
magnitude below the hundredth of a decibel this page quotes at best. The
other link agrees: +0.002 dB on dispersion compensation and 0.000 dB on
back-propagation, at 5.4 s against 22.7 s.
The page now carries that table instead of the sentence, which shows a
reader how a step count is chosen rather than telling them one. The
one-shot example drops from 17.4 s to 4.4 s per 25-span run and its
profile from 17.2 s to 4.6 s; the effective SNRs move by 0.01 dB at
most, and the fibre's cost per span, the two checks and the error rates
are unchanged.
The launch-power sweep is re-running; its tables land in the next
commit.
The sweep at StPS = 50 against the same sweep at 200: every effective SNR in the eight-by-six table is identical to the tenth of a decibel, the GN-model comparison still reads 18.77 dB predicted against 18.78 dB measured, and one entry of the summary moves by a hundredth -- four steps per span, 23.81 to 23.82 dB. Every claim the prose makes from those numbers survives unchanged: 4.5 dB from one step to four, 2.1 dB more from four to fifty at twelve times the cost, 0.41 dB left to the noise-only bound, 5.0 dB over dispersion compensation. That is the point of the measurement in the previous commit: a discretization two orders of magnitude below what is claimed from it does not move what is claimed. `SLOW` re-measured: the sweep drops from ~1400 s to 370 s, the one-shot example from 73 s to 22 s.
`compute_ser_truncated` and `compute_ber_bits` are not library
functions: they are local definitions in two validation scripts, and
both duplicate something `comnumpy.core.metrics` already does.
compute_ser_truncated(tx, rx) == compute_ser(tx, rx)
compute_ber_bits(a, b) == compute_ber(a, b, 1)
Checked rather than asserted, and both scripts still pass with the same
numbers.
They exist for two different reasons, and only the second is the
library's fault. `compute_ser` already truncates to the common length --
its docstring says so in as many words -- so the first helper duplicates
documented behaviour that its author did not find. The second is a real
friction: `width` is a mandatory positional argument, so `compute_ber`
cannot be handed to `monte_carlo` as a metric without binding it, and a
lambda cannot cross the process boundary that `n_jobs` opens. The answer
is `partial(compute_ber, width=1)`, which is picklable and which nothing
in the documentation points to; the script now uses it, with the reason
written beside it.
…able `is_polarization_pair` reads axes -2 and -1 only, so `(B, P, N)` has always propagated B realizations in one call and nothing said so, tested it, or pointed a user at it. For a capability that is the same as not having it: the next refactor of `prepare()` could take it away and every test would still pass. It is worth having. Measured against the same B calls: 1.76x at 12288 samples and 3.14x at 1024, on the sweep's own configuration. The gain is not where I first said it was, twice over. A batched FFT on `(B, N)` is no faster than B FFTs -- 40.2 ms against 39.1 -- and the per-call costs I then blamed are noise: constructing the per-span amplifier is 20 us, a bare block dispatch 0.25 us, together 0.03 % of a 700 ms pass. What the batch amortizes is `apply_chromatic_dispersion` rebuilding its transfer function on every half-step: an `fftfreq`, a `w**2` and a length-N complex exponential, 0.458 ms against 0.768 ms for the FFT pair it serves, a thousand times per propagation. Looped, that is 32000 exponentials; batched, 1000. Which means the same speedup is available without any batch, by precomputing what depends only on `(dz, fs, beta2, N)` -- and every step of a linear span shares those. That is left for a separate change; this one only makes the existing behaviour a promise. The error message now names both readings of `(B, N)`: multiplex WDM channels, or write the polarization axis as `(B, 1, N)`. Eight tests pin that a batch is the same simulation as the loop, bit for bit with the noise off -- including that a hundred-times-louder item does not leak its Kerr phase into a quiet neighbour -- and that with the noise on each item still carries the power the link budgets.
`apply_chromatic_dispersion` built its transfer function on every call
and threw it away: an `fftfreq`, a `w**2` and a length-N complex
exponential, for something that depends only on
`(n, dz, beta2, fs, alpha_dB, direction)` and is applied a thousand
times per propagation. Measured at 12288 samples, building it costs
0.42 ms against 0.77 ms for the FFT pair it serves, and the exponential
alone is 88 % of that.
Three functions where there was one, and the composed one keeps its name
and its behaviour so nothing downstream moved:
linear_step_transfer(n, z, beta2, ...) what the step *is*
apply_frequency_response(x, H) what it *does*
apply_chromatic_dispersion(...) the two composed
The attenuation is folded into the transfer function rather than left
beside it: both factors are the operator D of the split step, one real
and flat, the other complex and frequency-dependent, and separating them
would make every caller carry a scalar alongside the array.
`step_transfers` builds one row per *distinct* step length and returns
the row each step reads, so the two schedules stop being two code paths:
a linear one collapses to a single row whatever `StPS` is, a logarithmic
one keeps `StPS`, and the propagation loop indexes a table without ever
testing `step_type`. It is keyed on everything it depends on and
returned unchanged when the key matches, so a sweep builds it once
instead of once per pass. One implementation serves both directions:
`FiberLink` asks for +1, `DBP` for -1.
Propagation drops from 1.25 s to 0.683 s -- 1.83x, with no batch, on
every existing chain and with no signature changed. Output is unchanged
to 3e-15 relative across all nine configurations (both schedules, both
schemes, both blocks, plus `use_only_linear`), which is the rounding of
folding the attenuation in.
Two defects fixed on the way. `wdm.py` called `np.fft` directly instead
of the cupy-compatible backend, so that block could never have run on
GPU (D3); it and the two Raman tilt sites now use the shared helper.
Every measured decibel is identical -- the effective SNRs, the fibre's
cost per span, the two checks, the eight-by-six sweep and the
GN-model comparison at 18.77 against 18.78 dB. What moved is time, and
only time.
25-span run 4.4 s -> 2.6 s
the profile's link 4557 ms -> 2514 ms
back-propagation 3026 ms -> 1653 ms
the whole sweep 370 s -> 203 s
the one-shot page 22 s -> 18 s
Two figures in the prose follow: back-propagation costs 262 times a
dispersion compensation rather than 235, because the cache helped the
cheap receiver proportionally more than the expensive one, and the
propagation is 99.5 % of the profile rather than 99.9 % for the same
reason. Everything the page concludes is unchanged, which is the point:
at 3e-15 the refactor cannot move a conclusion, only the clock.
…mbols Three defects, none of them ergonomic. **Nothing ran the nine SLOW examples.** The smoke test skips them, which is what SLOW is for -- they cost minutes each and a pull request should not wait. But no other job ran them either, so a regression lived in `CD_compensation_part1.py` for two commits: it raised `TypeError: 'Constellation' object is not subscriptable` on its first pass while every check stayed green. They now run weekly and on demand. The list is not repeated in the workflow -- the runner imports `SLOW` from the test module, so an entry added there is covered and an entry removed stops being run, with no workflow to edit. **A serial `monte_carlo` without `seed=` was silently unreproducible.** The parallel path refuses outright; the serial one said nothing, and a curve nobody can reproduce is not a result. It warns now, rather than raising: a first look at a curve does not need to be reproducible. All eleven call sites in the repository already pass a seed, so nothing existing trips it. **`N_s` did not mean what it said.** `channel(N_s * oversampling_sim)` passed a *symbol* count to the generator and the `Upsampler` applied the oversampling a second time, so the two optical examples simulated six times the data their own variable names promised -- and the page quoted "8192 symbols per point" and a 1.2e-4 floor for what is 49152 symbols and 2.0e-5. `N_s` now holds the symbol count that runs, so nothing moved numerically, and the floor paragraph is replaced by the counters' own output: four receivers of six saw no error at all at their best power, which is a statement about the estimator rather than about the link. Every measured decibel across both pages is unchanged. The clock is not: the sweep is 144 s and the one-shot page 13 s, against 1400 s and 73 s this morning.
…ssing The strict ratchet had ten complaints about the step-transfer cache, and two of them were real bugs wearing a type error's clothes. `step_transfers` returned a bare `tuple`, so its shape lived only in the call sites. It is now `TransferTable`, an alias beside `TransferKey`, and `dbp.py` -- which annotated a field with `TransferKey` without importing it, and would have raised `NameError` the moment anyone evaluated the annotation -- imports it. The second was the `previous=` argument. It was handed a three-tuple built from three `Optional` fields, which on the first call is `(None, None, None)`: a value that is not a transfer table and only happened to work because the key comparison failed against it. It is now `None` until there is a table to pass. The rest is the guard the sibling fields already had: `prepare()` fills `transfer_` and `transfer_index_` before any `forward`, and the assert next to `beta2` and `step_size` now says so. Also drops `pyright_all_tmp.json` -- a scratch copy of the ratchet list that was committed by accident and has been shadowing the real `pyrightconfig.json` in every directory listing since. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pn2F8X4HZhS8fEJcL5enzw
…d against The previous commit guarded `previous=` on the key alone, which is the one field whose type is not `ndarray`: the two arrays stayed `Optional` in the tuple that was built from them, and strict rejected the call. All three are now tested together, so the tuple that reaches `step_transfers` is a `TransferTable` or nothing. The reason that fix took two rounds is worth writing down, because it will happen to anyone else: `pyright` disagrees with itself depending on the NumPy in the environment. The 1.26 stubs make `ndarray` a generic that must carry type arguments; the 2.x stubs do not. Against the pinned oldest NumPy the ratchet reports several hundred errors that are not real, which is a report nobody can read -- and a genuine error hides in it perfectly well, which is exactly what happened here. CONTRIBUTING now says to keep a second environment for the check and pass it with `--pythonpath`, which is what CI effectively does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pn2F8X4HZhS8fEJcL5enzw
…st it The Monte-Carlo page asked the constellation for a closed form and drew it. That shows theory and measurement agreeing without ever showing what the theory is, which is the wrong lesson: the reader learns that an object returns the right numbers, not why those numbers are right. The page now writes the expression out -- a square QAM as two independent PAM constellations, the argument of Q, the two lines that follow, Proakis and Salehi section 4.3 -- evaluates it in NumPy, and only then shows `constellation.metrics` returning the same array to 3.3e-16. The method is what the rest of the library uses afterwards, and the page says why: one line instead of five, and it cannot describe a modulation other than the one the chain transmits. It also stopped at one constellation, which answers "is the simulation right" and not "what is a constellation worth". A closing section sweeps 4-, 16-, 64- and 256-QAM at equal energy per bit -- the comparison `ebn0_to_snr_dB` exists for, and the one that is fair -- and reads off what the density costs: 6.8, 10.5, 14.8 and 19.4 dB for a BER of 1e-3, three steps of 3.7, 4.3 and 4.6 dB. The section says where its two floors are, because both are visible in the figure: the Gray-mapping approximation P_b ~ P_s/k runs a factor 2.2 under the measurement for 256-QAM at 0 dB before settling onto it below 1e-2, and 100 000 symbols per point cannot resolve a 4-QAM BER below 5e-6, so the axis stops there. Two defects turned up on the way. The page still pointed at `sweep` in two cross-references after the rename. And the figure it displayed was written by nothing: the script ended on `plt.show()` with no `savefig`, so `img/monte_carlo_awgn.png` was whatever a human last produced by hand. It is regenerated by the script now, like every other figure here. The chain becomes `get_chain(constellation)` because the last section builds one per order -- the naming rule, which the previous inline chain predated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pn2F8X4HZhS8fEJcL5enzw
vincentchoqueuse
added a commit
that referenced
this pull request
Aug 13, 2026
Three defects nothing was catching: the nine SLOW examples were run by no job at all, a serial monte_carlo without seed= was silently unreproducible, and N_s named a symbol count that the Upsampler then multiplied again, so both optical examples simulated six times what their variable names promised. The fibre is seven times cheaper and says the same thing. apply_chromatic_dispersion rebuilt its transfer function on every half-step; it is now split into linear_step_transfer (what the step is) and apply_frequency_response (what it does), with step_transfers caching one row per distinct step length. With StPS measured down from 200 to 50 -- 0.005 dB at the sweep's most nonlinear power -- the launch-power sweep went from ~1400 s to 144 s and the one-shot page from 73 s to 13 s. Nine configurations agree with the old code to 3e-15. The AWGN tutorial now derives its reference curve instead of asking an object for it: the two-PAM decomposition of a square QAM, the argument of Q, Proakis and Salehi section 4.3, evaluated in plain NumPy, and only then constellation.metrics returning the same array to 3.3e-16. It ends on 4-, 16-, 64- and 256-QAM at equal energy per bit -- 6.8, 10.5, 14.8 and 19.4 dB for a BER of 1e-3 -- with both floors of that figure named. Three measurements contradicted me along the way. A batched FFT is not faster than B separate ones, 40.2 ms against 39.1. The per-call overhead I then blamed is 0.03 % of a pass. It was the transfer function all along. Smaller things: FiberLink.budget() puts the amplifier-noise formula next to the block that generates the noise; ErrorCounter makes an error rate a ratio of totals rather than a mean of rates; sweep becomes monte_carlo; wdm.py was calling np.fft instead of the backend, so that block could never have run on cupy; validation/optical_dbp_hager.py restores the reproduction of Hager and Pfister on their own setup; and CONTRIBUTING says which NumPy the strict-typing ratchet must be read against. ruff, 860 tests + 1145 subtests, doctests and sphinx-build -W pass. 44 new tests.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Twenty commits. The thread running through them: every claim was
measured before it was acted on, and three of the measurements
contradicted me.
Three defects
Nothing ran the nine
SLOWexamples. The smoke test skips them —that is what
SLOWis for — but no other job ran them either. Aregression lived in
CD_compensation_part1.pyfor two commits:TypeError: 'Constellation' object is not subscriptableon its firstpass, every check green. They now run weekly and on demand, and the
runner imports
SLOWfrom the test module rather than repeating it, sothe list has one home.
A serial
monte_carlowithoutseed=was silently unreproducible.The parallel path refuses outright; the serial one said nothing. It
warns now — a first look at a curve does not need to be reproducible, but
a curve nobody can reproduce is not a result.
N_sdid not mean what it said.channel(N_s * oversampling_sim)handed a symbol count to the generator and the
Upsamplerapplied theoversampling again, so both optical examples simulated six times the
data their variable names promised — and the page quoted "8192 symbols
per point" for what is 49152.
The fibre is seven times cheaper, and says the same thing
apply_chromatic_dispersionrebuilt its transfer function on everyhalf-step: an
fftfreq, aw**2and a length-N complex exponential,a thousand times per propagation, for something that depends only on
(n, dz, beta2, fs, alpha_dB, direction). Split intolinear_step_transfer(what the step is) andapply_frequency_response(what it does), with
step_transferscaching one row per distinct steplength — one for a linear schedule whatever
StPSis,StPSfor alogarithmic one, so the loop indexes a table and never branches on the
schedule.
With
StPSmeasured down from 200 to 50 — 0.005 dB at the sweep's mostnonlinear power — the launch-power sweep went from ~1400 s to 144 s
and the one-shot page from 73 s to 13 s. Every measured decibel on
both pages is unchanged; nine configurations agree with the old code to
3e-15.
The AWGN tutorial derives its reference curve
The Monte-Carlo page asked the constellation for a closed form and drew
it, which teaches that an object returns the right numbers rather than
why they are right. It now states the expression — a square QAM as two
independent PAM constellations, the argument of
Q, Proakis and Salehisection 4.3 — evaluates it in plain NumPy, and only then shows
constellation.metricsreturning the same array to 3.3e-16.It also no longer stops at one constellation. A closing section sweeps
4-, 16-, 64- and 256-QAM at equal energy per bit and reads off what
the density costs: 6.8, 10.5, 14.8 and 19.4 dB of Eb/N0 for a BER of
1e-3. Both floors of that figure are named rather than glossed — the
Gray-mapping approximation runs a factor 2.2 under the measurement for
256-QAM at 0 dB before settling onto it below 1e-2, and 100 000 symbols
per point cannot resolve a 4-QAM BER under 5e-6.
Two more defects surfaced there: the page still named
sweepin twocross-references after the rename, and the figure it displayed was
written by no script —
monte_carlo_awgn.pyended onplt.show()andsaved nothing, so the committed PNG was whatever a human last produced
by hand.
Where I was wrong, three times
A batched FFT is not faster than B separate ones — 40.2 ms against
39.1. Nor was the gain the per-call overhead I then blamed: building the
per-span amplifier is 20 µs and a block dispatch 0.25 µs, together 0.03 %
of a pass. It was the transfer function all along. The batch axis is
still worth having and is now documented, tested and discoverable, but
for what it is: 1.76x by amortizing that exponential, and the
precondition for a GPU backend.
Smaller things
FiberLink.budget()puts the amplifier-noise formula next to the blockthat generates the noise, where three tutorials had written it by hand
with three spellings of the polarization factor.
ErrorCountermakes anerror rate a ratio of totals rather than a mean of per-trial rates.
sweepbecomesmonte_carlo, which says that its points are randomdraws. Two "helpers" in
validation/turned out to be the library's ownfunctions under other names and were deleted.
wdm.pywas callingnp.fftinstead of the backend, so that block could never have run oncupy.
validation/optical_dbp_hager.pyrestores the reproduction of Häger andPfister's curves on their own setup — Gaussian stimulus, logarithmic
steps for the low-step back-propagators — which successive rewrites had
turned into a teaching script.
CONTRIBUTING.mdnow says which NumPy the strict-typing ratchet is readagainst: the 1.26 stubs require
ndarrayto carry type arguments andthe 2.x stubs do not, so linting against the pinned oldest NumPy buries
a real error under several hundred that are not.
Also removed:
pyright_all_tmp.json, a scratch copy of the ratchet listcommitted by accident.
Verification
ruff check ., 860 tests + 1145 subtests, doctests, andsphinx-build -Wpass. 44 new tests.