diff --git a/.claude/skills/comnumpy-tutorial/SKILL.md b/.claude/skills/comnumpy-tutorial/SKILL.md index b5e998a..1528321 100644 --- a/.claude/skills/comnumpy-tutorial/SKILL.md +++ b/.claude/skills/comnumpy-tutorial/SKILL.md @@ -41,7 +41,7 @@ Before writing a helper, look for it. The recurring failure is reimplementing from scratch what `comnumpy` already does, and the reader then learns the wrong lesson -- they came to see how the library is used. -- Chains: `Sequential`, `taps=` to observe a signal inside one, `wiring=` +- Chains: `Sequential`, `observations=` to retain a signal inside one, `wiring=` to feed a data-aided block a reference the chain produces itself, `set_params` (dotted or `__`) to change one parameter, `seed` for reproducibility, `elapsed_` for the wall time of the last pass, @@ -170,7 +170,7 @@ from inside. - **No inline `for`.** List, dict and set comprehensions, and generator expressions inside `join`/`sum`/`np.array`/`tuple`, are banned in tutorial scripts. Write the loop out, with a body. This is a hard rule. -- Name blocks (`name="data_tx"`) so that taps, wiring, `set_params` and the +- Name blocks (`name="data_tx"`) so that observations, wiring, `set_params` and the profile table all read as the same vocabulary. - Comments in the script explain *why*, and they are part of what the page shows -- the `literalinclude` ranges quote them. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b3ade90..e410323 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -522,10 +522,10 @@ from comnumpy import Sequential, SymbolGenerator, SymbolMapper, \ alphabet = get_alphabet("QAM", 16) chain = Sequential([SymbolGenerator(M=16, name="tx"), SymbolMapper(alphabet), - AWGN(snr_dB=15), SymbolDemapper(alphabet)], taps=["tx"]) + AWGN(snr_dB=15), SymbolDemapper(alphabet)], observations=["tx"]) chain.seed(42) # D6 seeded, D42 records "tx" detected = chain(10_000) -ser = compute_ser(chain.tap("tx"), detected) # SER = 0.0165 +ser = compute_ser(chain.observation("tx"), detected) # SER = 0.0165 ``` Huit lignes, un seul import, aucune connaissance de l'arborescence @@ -656,14 +656,14 @@ qu'exécuté. | # | Décision | Motif | Alternatives rejetées | Statut | |---|----------|-------|-----------------------|--------| -| D42 | **`module_list` ne contient que le système de communication.** (a) Tout bloc dont la seule fonction est d'observer est **supprimé** — `Recorder`, `Logger`, `Debugger`, `PowerReporter`, `TimeSignalMonitor`, `MetricRecorder`, la famille `Scope`, `FFTMonitor` ; `core/monitors.py` disparaît. (b) L'observation devient une **métadonnée de chaîne** : `Sequential(..., taps=["id"])` enregistre la sortie des blocs nommés pendant `forward`, relue par `chain.tap("id")`. (c) Une arête de donnée supplémentaire se déclare de la même façon : `Sequential(..., wiring={"bloc.param": "source"})` affecte au bloc cible, avant son exécution, le signal produit par la source **dans la même passe**. (d) Le tracé et le compte rendu sont des **fonctions** appliquées aux tableaux extraits (`plot_iq`, `plot_spectrum`, …, `signal_report`), jamais des blocs. (e) Ce qu'un bloc calcule en interne et que sa sortie ne contient pas s'expose en attribut souligné D23 (`pilots_` de `CarrierExtractor`), pas par un puits externe | Un `Recorder` inséré dans `module_list` fait mentir toutes les vues qui décrivent la chaîne — `__repr__`, `summary()`, `to_mermaid()` (D33), l'export JSON et les indices de `set_params` (D31/D34) voient un système à N+2 blocs. La ligne de partage est nette : un tap capture ce qui **circule sur le fil**, un attribut souligné expose ce qu'un bloc a calculé et que sa sortie ne porte pas. Le coût est une écriture de dictionnaire par bloc tappé — une **référence**, sans copie ; ce qui repose sur l'invariant, désormais écrit dans CONVENTIONS.md, qu'aucun bloc ne modifie son entrée sur place. (c) traite le seul cas que (b) ne couvre pas et que le `Recorder` servait vraiment : un estimateur assisté par les données dont la référence est **produite par la chaîne**. Un `reference=` figé y est faux et **silencieusement** faux — en Monte-Carlo il continue de comparer aux symboles du premier tirage. C'est la forme bornée du champ `inputs` de D31 : une seconde entrée déclarée par la chaîne, sans DAG général (§8) | Filtrer les `Recorder` à l'affichage (le mensonge change de place : sérialisation, indices et `set_params` les voient toujours) ; mode « tout tracer » (coût mémoire réel sur les chaînes optiques full-field, contraire à P2) ; laisser les blocs se référencer entre eux comme avant (arête de graphe passant par la configuration — non sérialisable : un paramètre *callable* est une frontière assumée de D31, donc toute chaîne enregistrant ses pilotes était inexportable) ; hooks à la PyTorch (machinerie invisible, contraire à P7) | **Acté** | +| D42 | **`module_list` ne contient que le système de communication.** (a) Tout bloc dont la seule fonction est d'observer est **supprimé** — `Recorder`, `Logger`, `Debugger`, `PowerReporter`, `TimeSignalMonitor`, `MetricRecorder`, la famille `Scope`, `FFTMonitor` ; `core/monitors.py` disparaît. (b) L'observation devient une **métadonnée de chaîne** : `Sequential(..., observations=["id"])` conserve la sortie des blocs nommés pendant `forward`, relue par `chain.observation("id")`. (c) Une arête de donnée supplémentaire se déclare de la même façon : `Sequential(..., wiring={"bloc.param": "source"})` affecte au bloc cible, avant son exécution, le signal produit par la source **dans la même passe**. (d) Le tracé et le compte rendu sont des **fonctions** appliquées aux tableaux extraits (`plot_iq`, `plot_spectrum`, …, `signal_report`), jamais des blocs. (e) Ce qu'un bloc calcule en interne et que sa sortie ne contient pas s'expose en attribut souligné D23 (`pilots_` de `CarrierExtractor`), pas par un puits externe | Un `Recorder` inséré dans `module_list` fait mentir toutes les vues qui décrivent la chaîne — `__repr__`, `summary()`, `to_mermaid()` (D33), l'export JSON et les indices de `set_params` (D31/D34) voient un système à N+2 blocs. La ligne de partage est nette : une observation capture ce qui **circule sur le fil**, un attribut souligné expose ce qu'un bloc a calculé et que sa sortie ne porte pas. Le coût est une écriture de dictionnaire par bloc observé — une **référence**, sans copie ; ce qui repose sur l'invariant, désormais écrit dans CONVENTIONS.md, qu'aucun bloc ne modifie son entrée sur place. (c) traite le seul cas que (b) ne couvre pas et que le `Recorder` servait vraiment : un estimateur assisté par les données dont la référence est **produite par la chaîne**. Un `reference=` figé y est faux et **silencieusement** faux — en Monte-Carlo il continue de comparer aux symboles du premier tirage. C'est la forme bornée du champ `inputs` de D31 : une seconde entrée déclarée par la chaîne, sans DAG général (§8) | Filtrer les `Recorder` à l'affichage (le mensonge change de place : sérialisation, indices et `set_params` les voient toujours) ; mode « tout tracer » (coût mémoire réel sur les chaînes optiques full-field, contraire à P2) ; laisser les blocs se référencer entre eux comme avant (arête de graphe passant par la configuration — non sérialisable : un paramètre *callable* est une frontière assumée de D31, donc toute chaîne enregistrant ses pilotes était inexportable) ; hooks à la PyTorch (machinerie invisible, contraire à P7) | **Acté** | **Trois besoins, trois mécanismes.** La distinction est normative ; toute demande d'observation doit tomber dans l'une des trois cases. | Besoin | Mécanisme | |---|---| -| Regarder un signal après coup | `taps=[...]` puis `chain.tap(id)` | +| Regarder un signal après coup | `observations=[...]` puis `chain.observation(id)` | | Alimenter un bloc avec un signal amont **du tirage courant** | `wiring={"bloc.param": "source"}` | | Référence connue d'avance (préambule, séquence d'apprentissage) | paramètre tableau du bloc | @@ -678,12 +678,12 @@ chain = Sequential([ BlindIQCompensator(name="gsop"), BlindCFOCompensator(name="cfo"), DataAidedPhaseCompensator(reference=np.zeros(1), name="phase"), SymbolDemapper(alphabet), -], taps=["data_tx", "awgn", "gsop", "phase"], +], observations=["data_tx", "awgn", "gsop", "phase"], wiring={"phase.reference": "signal_tx"}) y = chain(5_000) -ser = compute_ser(chain.tap("data_tx"), y) -plot_iq(chain.tap("gsop"), title="after GSOP") # D25: plotting is a function +ser = compute_ser(chain.observation("data_tx"), y) +plot_iq(chain.observation("gsop"), title="after GSOP") # D25: plotting is a function ``` `len(chain.module_list) == 9`, et les neuf sont des blocs de diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f660e2..d39cd81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,7 @@ one release; there is no compatibility layer. | Optional block parameters accepted positionally | keyword-only (D40b): only the principal first argument is positional | | Bare `ValueError` on shape mismatch | `comnumpy.ShapeError` (still a `ValueError` subclass — existing `except ValueError` keeps working) | | `import comnumpy` loaded matplotlib (~1 s) | lazy imports: no matplotlib at import time, ~90 ms (enforced in CI) | -| `Recorder(name="tx")` inserted in `module_list`, read with `chain["tx"].get_data()` | name the block itself and declare a tap: `Sequential([SymbolGenerator(16, name="tx"), ...], taps=["tx"])`, read with `chain.tap("tx")` | +| `Recorder(name="tx")` inserted in `module_list`, read with `chain["tx"].get_data()` | name the block itself and declare an observation: `Sequential([SymbolGenerator(16, name="tx"), ...], observations=["tx"])`, read with `chain.observation("tx")` (first shipped as `taps=`/`tap()`, renamed before release) | | `Logger`, `Debugger`, `PowerReporter`, `TimeSignalMonitor` blocks | removed — tap the block and call `signal_report(chain.tap("x"))`, then log the returned dict | | `MetricRecorder(metric_fn=f)` block | removed — `f(chain.tap("x"))` | | `Scope(scope_type="iq", ...)`, `TimeScope`, `SpectrumScope`, `IQScope`, `KDEScope`, `WelchScope` blocks | removed — functions `plot_iq`, `plot_time`, `plot_spectrum`, `plot_kde`, `plot_welch` applied to a tapped signal | @@ -45,6 +45,44 @@ 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 — QPSK carries the pi/4 offset of the standards + +`get_alphabet("PSK", 4)` sampled the raw circle +:math:`e^{2j\pi m/4}`: points on the axes, a constellation rotated by +pi/4 from the QPSK of the standards (3GPP, 802.11) and of every +textbook figure. QPSK now sits on the diagonals -- the same point set +as QAM-4; BPSK and :math:`M \ge 8` keep the phase-0 convention. No +error rate moves (mapper and demapper share the alphabet, the noise is +circular -- the Monte-Carlo tables of the Alamouti and MIMO pages shift +only by the draws now hitting rotated symbols), but the pi/4 surprise +against any other tool is gone, along with the tx-`PSK`/rx-`QAM` mixing +trap whose points landed exactly on the decision boundaries. The +historical-table pin now asserts the one deliberate deviation as the +relationship it is (built = table times :math:`e^{j\pi/4}`), and the +Gauss-Hermite accuracy table of `capacity.py` is re-measured for the +diagonal orientation (the node grid is axis-aligned, so the quadrature +error is not rotation-invariant even though the capacity is). + +### Changed — `taps` becomes `observations` + +The retained-signal mechanism of `Sequential` is renamed after what it +is: an **observation** is simply the output of a named mechanism, +explicitly retained for experimental analysis. It performs no +computation and does not alter the signal -- same shape, same dtype, +dimensions never reduced; power, SNR or SER stay with the metrics, +applied to the observation afterwards. Declare with +`Sequential([...], observations=["tx"])` (or `chain.observations = +[...]`), read back with `chain.observation("tx")` after the run. No +new naming system: observation names are the block names already used +by `set_params`, `wiring` and the profile table. Renamed with it: +`tapped_` -> `observed_`, the `taps` key of `graph()` and of the JSON +export -> `observations`, and the `tapped` mermaid class -> `observed`. +An empty `observations` list retains nothing, so large batches and +fibre propagations pay only for what they declare. `taps=`/`tap()` are +gone (one breaking-change window, no compatibility layer); the FIR +sense of the word -- filter taps, channel taps, `TappedDelayLine` -- is +untouched. + ### Fixed — a full-library audit: 37 findings, 37 fixed Five independent reviews swept the whole library -- core signal path, diff --git a/CONVENTIONS.md b/CONVENTIONS.md index b40c502..a394bc5 100644 --- a/CONVENTIONS.md +++ b/CONVENTIONS.md @@ -96,30 +96,38 @@ by realization inequality, `REFUSES` by the raised error) or exempted with a written reason. A new block cannot merge without declaring what a batch means for it. -## Observing signals: taps, not blocks +## Observing signals: observations, not blocks A chain describes the communication system. Nothing else goes into `module_list` — no recorder, logger, scope or debugger block. To observe -an intermediate signal, name the block and declare it as a tap: +an intermediate signal, name the block and declare it as an observation. +An observation is simply the output of a named mechanism, retained for +experimental analysis: it performs no computation and does not alter the +signal — same shape, same dtype, dimensions never reduced. ```python chain = Sequential([SymbolGenerator(16, name="tx"), SymbolMapper(alphabet), - AWGN(snr_dB=15, name="awgn")], taps=["tx", "awgn"]) + AWGN(snr_dB=15, name="awgn")], + observations=["tx", "awgn"]) y = chain(1000) -x_tx = chain.tap("tx") # signal recorded during the last run -plot_iq(chain.tap("awgn")) # plotting is a function, not a block +x_tx = chain.observation("tx") # signal retained during the last run +plot_iq(chain.observation("awgn")) # plotting is a function, not a block ``` Consequences of this rule: * `repr`, `summary()`, `to_mermaid()`, the JSON export and the block indices describe the communication system only. -* A tap costs one dictionary store of a **reference** per tapped block - (no copy). This relies on the library-wide invariant that **a block +* Power, SNR, SER and the like stay with the metrics: an observation is + data, a metric is a computation applied to it. +* An observation costs one dictionary store of a **reference** per + observed block (no copy) — an empty `observations` list retains + nothing, so large batches and fibre propagations pay only for what + they declare. This relies on the library-wide invariant that **a block never mutates its input in place**: `forward()` returns a freshly allocated array (or the input untouched). Custom blocks must honour it - — an in-place block would corrupt earlier taps *and* break chain - re-entrancy. + — an in-place block would corrupt earlier observations *and* break + chain re-entrancy. * Blocks never hold a live reference to another block. A data-aided block takes its reference as a plain array (`reference=x_tx`) when the reference is known in advance — a preamble, a training sequence. @@ -147,13 +155,15 @@ chain = Sequential([ Before `comp` runs, the chain assigns it the signal `ref` produced in the same pass. Rules: -* the source is tapped automatically — no need to list it in `taps`; +* the source is observed automatically — no need to list it in + `observations`; * the source must run **before** the target; a backward edge raises, because it would serve the previous run's value; * the wiring feeds *data*, not structure: the target's `__post_init__` is not re-run (use `set_params` for parameters that need a re-precompute); -* like taps, the edge is chain metadata — `module_list` is unchanged, and - `taps`/`wiring` are carried through the JSON export. +* like observations, the edge is chain metadata — `module_list` is + unchanged, and `observations`/`wiring` are carried through the JSON + export. This is the honest, bounded version of the `inputs` field of decision D31: a second input to a block, declared by the chain. A general DAG is diff --git a/README.md b/README.md index 31f852f..2ed1c8a 100644 --- a/README.md +++ b/README.md @@ -44,11 +44,11 @@ chain = Sequential([ SymbolMapper(alphabet), AWGN(snr_dB=15, seed=123), SymbolDemapper(alphabet), -], taps=["tx"]) +], observations=["tx"]) # Transmit 10,000 symbols and evaluate performance detected = chain(10_000) -print(f"SER = {compute_ser(chain.tap('tx'), detected)}") # SER = 0.016 +print(f"SER = {compute_ser(chain.observation('tx'), detected)}") # SER = 0.016 ``` ## Installation diff --git a/docs/documentation/core/index.rst b/docs/documentation/core/index.rst index 8858fe1..4941e04 100644 --- a/docs/documentation/core/index.rst +++ b/docs/documentation/core/index.rst @@ -34,15 +34,15 @@ Observing signals inside a chain A chain describes the communication system and nothing else: there are no recorder, logger or scope blocks to insert between the processors. To -observe a signal, name the block and declare it as a *tap*:: +observe a signal, name the block and declare it as an *observation*:: chain = Sequential([SymbolGenerator(16, name="tx"), SymbolMapper(alphabet), - AWGN(snr_dB=15, name="awgn")], taps=["tx", "awgn"]) + AWGN(snr_dB=15, name="awgn")], observations=["tx", "awgn"]) y = chain(1000) - plot_iq(chain.tap("awgn")) + plot_iq(chain.observation("awgn")) -``taps`` is chain metadata: each tapped block costs one dictionary store of -a reference (no copy), and :meth:`~comnumpy.core.generics.Sequential.tap` +``observations`` is chain metadata: each observed block costs one dictionary store of +a reference (no copy), and :meth:`~comnumpy.core.generics.Sequential.observation` returns the recorded array afterwards. Plotting and reporting are plain functions applied to the extracted arrays -- see :doc:`visualizers` and ``comnumpy.core.metrics.signal_report``. diff --git a/docs/getting_started/first_simulation.rst b/docs/getting_started/first_simulation.rst index fb3fa26..fe521d6 100644 --- a/docs/getting_started/first_simulation.rst +++ b/docs/getting_started/first_simulation.rst @@ -15,7 +15,7 @@ and shows what the second version has that the first does not. - What a one-shot AWGN simulation is, written out by hand. - Which parts of it are conventions you have to keep consistent yourself. - How to assemble the same thing as a ``Sequential`` chain, and how to look - inside it with a tap. + inside it with an observation. - What a ``Constellation`` knows about itself, and why that matters more than it looks. @@ -82,7 +82,7 @@ The Same Thing, as a Chain The diagram is not drawn by hand: it is what the chain says about itself, ``chain.to_mermaid()``, so the block names are the ones the code uses and a -dashed outline marks a tapped block. +dashed outline marks an observed block. Four processors, and each one is worth a sentence -- these four come back in every tutorial that follows: @@ -114,9 +114,9 @@ than in the caller's head. **Nothing in the chain records, displays or measures.** A chain contains communication blocks only. To see a signal in the middle of it, give the -block a name and declare that name as a **tap** -- ``taps=["tx", "awgn"]`` +block a name and declare that name as an **observation** -- ``observations=["tx", "awgn"]`` above -- and the chain keeps what that block produced. One habit to take -right away: *run the chain first, read the tap after*. A tap holds the last +right away: *run the chain first, read the observation after*. An observation holds the last run, so reading it before calling the chain raises rather than returning stale data. @@ -176,7 +176,7 @@ It also draws itself, next to what the channel did to it: :align: center The chain answers the same way: -:meth:`~comnumpy.core.generics.Sequential.tap` for a signal inside it, +:meth:`~comnumpy.core.generics.Sequential.observation` for a signal inside it, ``seed`` for reproducibility, :meth:`~comnumpy.core.generics.Sequential.set_params` to reconfigure a named block after construction, ``elapsed_`` for the time the last pass took, and diff --git a/docs/getting_started/mermaid/first_simulation.mmd b/docs/getting_started/mermaid/first_simulation.mmd index 3fffb85..e41bc34 100644 --- a/docs/getting_started/mermaid/first_simulation.mmd +++ b/docs/getting_started/mermaid/first_simulation.mmd @@ -6,5 +6,5 @@ flowchart LR tx --> symbol_mapper symbol_mapper --> awgn awgn --> symbol_demapper - classDef tapped stroke-dasharray: 4 2 - class tx,awgn tapped \ No newline at end of file + classDef observed stroke-dasharray: 4 2 + class tx,awgn observed \ No newline at end of file diff --git a/docs/getting_started/processor_profiling.rst b/docs/getting_started/processor_profiling.rst index 9a20f95..1edd1bb 100644 --- a/docs/getting_started/processor_profiling.rst +++ b/docs/getting_started/processor_profiling.rst @@ -90,7 +90,7 @@ The chain, as the chain itself describes it: The diagram above 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: +outline marks an observed block: .. literalinclude:: ../../examples/simple/profiling_awgn_ofdm.py :language: python diff --git a/docs/getting_started/who_is_comnumpy_for.rst b/docs/getting_started/who_is_comnumpy_for.rst index 04e417c..dee1a1f 100644 --- a/docs/getting_started/who_is_comnumpy_for.rst +++ b/docs/getting_started/who_is_comnumpy_for.rst @@ -51,7 +51,7 @@ the ones that matter in daily research work. A chain can be:: qam = Constellation("QAM", 16) chain = Sequential([SymbolGenerator(qam.order, name="tx"), SymbolMapper(qam), - AWGN(snr_dB=15, name="noise")], taps=["tx"]) + AWGN(snr_dB=15, name="noise")], observations=["tx"]) chain.seed(42) # every stochastic block, reproducibly chain.set_params(noise__snr_dB=12) # reconfigured after construction @@ -60,7 +60,7 @@ the ones that matter in daily research work. A chain can be:: print(chain.to_mermaid()) # the experiment, as a picture None of that requires rewriting the chain, and none of it is -instrumentation inserted between the blocks: taps, wiring and seeding +instrumentation inserted between the blocks: observations, wiring and seeding are chain *metadata*, so the block list keeps describing the communication system and nothing else. A study made of a hundred parameter points and a figure is then a few lines, and the exact diff --git a/docs/tutorials/alamouti.rst b/docs/tutorials/alamouti.rst index bdc6cc2..9681ab2 100644 --- a/docs/tutorials/alamouti.rst +++ b/docs/tutorials/alamouti.rst @@ -187,17 +187,17 @@ power keeps the comparison about diversity alone. .. code:: - one-shot SER: 0.0600 + one-shot SER: 0.0570 # block id output shape dtype time ms - 0 SymbolGenerator tx (1000,) int64 0.03 + 0 SymbolGenerator tx (1000,) int64 0.04 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.06 + 3 SpaceTimeEncoder space_time_encoder (2, 1000) complex128 0.09 4 FlatMIMOChannel channel (1, 1000) complex128 0.01 - 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 + 5 AWGN noise (1, 1000) complex128 0.06 + 6 SpaceTimeDecoder detector (1000,) complex128 0.16 + 7 Amplifier signal_amplifier_2 (1000,) complex128 0.01 + 8 SymbolDemapper symbol_demapper (1000,) int64 0.08 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 @@ -244,12 +244,12 @@ everything else -- the channels, the chains -- is frozen: 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 + 4 0.22230 1.767e-01 8.138e-02 + 8 0.11242 6.442e-02 2.169e-02 + 12 0.05004 1.635e-02 4.137e-03 + 16 0.02064 3.442e-03 6.375e-04 + 20 0.00841 5.275e-04 5.250e-05 + 24 0.00300 7.000e-05 7.500e-06 .. image:: img/one_shot_alamouti_fig3.png :width: 100% diff --git a/docs/tutorials/awgn.rst b/docs/tutorials/awgn.rst index 0ffebb5..6feeea0 100644 --- a/docs/tutorials/awgn.rst +++ b/docs/tutorials/awgn.rst @@ -118,7 +118,7 @@ of. always gives the same signal -- a curve you cannot reproduce is a curve you cannot debug. ``set_params`` addresses a block by the name it was given at construction, with the dotted notation ``"awgn_channel.snr_dB"``; this is why -blocks are named. And the tap returns what the transmitter produced, so the +blocks are named. And the observation returns what the transmitter produced, so the metric has something to compare against -- after the run, never before. Now the same thing in one call. :func:`~comnumpy.monte_carlo.monte_carlo` takes diff --git a/docs/tutorials/gn_model.rst b/docs/tutorials/gn_model.rst index 8071586..bf20a89 100644 --- a/docs/tutorials/gn_model.rst +++ b/docs/tutorials/gn_model.rst @@ -262,7 +262,7 @@ a block by its identifier and re-runs its precomputation (decision D34), and :func:`~comnumpy.monte_carlo` drives ``"launch.gain"`` over a list of values the same way. One chain, built once, answers every question below. -``taps=["tx"]`` marks the mapper output as readable from outside the chain -- +``observations=["tx"]`` marks the mapper output as readable from outside the chain -- the dashed box in the diagram, decision D33c. The transmitted symbols are what every measurement compares against. diff --git a/docs/tutorials/img/monte_carlo_mimo_fig1.png b/docs/tutorials/img/monte_carlo_mimo_fig1.png index a717f4b..b82505b 100644 Binary files a/docs/tutorials/img/monte_carlo_mimo_fig1.png and b/docs/tutorials/img/monte_carlo_mimo_fig1.png differ diff --git a/docs/tutorials/img/monte_carlo_mimo_fig2.png b/docs/tutorials/img/monte_carlo_mimo_fig2.png index a297d5c..c5d150c 100644 Binary files a/docs/tutorials/img/monte_carlo_mimo_fig2.png and b/docs/tutorials/img/monte_carlo_mimo_fig2.png differ diff --git a/docs/tutorials/img/monte_carlo_mimo_fig3.png b/docs/tutorials/img/monte_carlo_mimo_fig3.png index ac5adfc..bac12b2 100644 Binary files a/docs/tutorials/img/monte_carlo_mimo_fig3.png and b/docs/tutorials/img/monte_carlo_mimo_fig3.png differ diff --git a/docs/tutorials/img/monte_carlo_mimo_fig4.png b/docs/tutorials/img/monte_carlo_mimo_fig4.png index e189b73..6a75f28 100644 Binary files a/docs/tutorials/img/monte_carlo_mimo_fig4.png and b/docs/tutorials/img/monte_carlo_mimo_fig4.png differ diff --git a/docs/tutorials/img/monte_carlo_ofdm_papr_fig2.png b/docs/tutorials/img/monte_carlo_ofdm_papr_fig2.png index 581fcab..098a095 100644 Binary files a/docs/tutorials/img/monte_carlo_ofdm_papr_fig2.png and b/docs/tutorials/img/monte_carlo_ofdm_papr_fig2.png differ diff --git a/docs/tutorials/img/one_shot_alamouti_fig2.png b/docs/tutorials/img/one_shot_alamouti_fig2.png index 15a0ded..4170e95 100644 Binary files a/docs/tutorials/img/one_shot_alamouti_fig2.png and b/docs/tutorials/img/one_shot_alamouti_fig2.png differ diff --git a/docs/tutorials/img/one_shot_alamouti_fig3.png b/docs/tutorials/img/one_shot_alamouti_fig3.png index adbf8cc..3ac3d85 100644 Binary files a/docs/tutorials/img/one_shot_alamouti_fig3.png and b/docs/tutorials/img/one_shot_alamouti_fig3.png differ diff --git a/docs/tutorials/img/one_shot_pdm_fig1.png b/docs/tutorials/img/one_shot_pdm_fig1.png index 2585b5a..99ecb3e 100644 Binary files a/docs/tutorials/img/one_shot_pdm_fig1.png and b/docs/tutorials/img/one_shot_pdm_fig1.png differ diff --git a/docs/tutorials/index.rst b/docs/tutorials/index.rst index 8e929ac..eae82d0 100644 --- a/docs/tutorials/index.rst +++ b/docs/tutorials/index.rst @@ -14,7 +14,7 @@ and nothing is explained twice. - What it introduces * - 0 - :doc:`../getting_started/first_simulation` - - The ``Sequential`` chain, the blocks it is made of, a tap to look + - The ``Sequential`` chain, the blocks it is made of, an observation to look inside it, and one error rate. * - 1 - :doc:`awgn` diff --git a/docs/tutorials/mermaid/channel_coding.mmd b/docs/tutorials/mermaid/channel_coding.mmd deleted file mode 100644 index 1d52ed5..0000000 --- a/docs/tutorials/mermaid/channel_coding.mmd +++ /dev/null @@ -1,14 +0,0 @@ -flowchart LR - tx["SymbolGenerator"] - conv_encoder["ConvolutionalEncoder"] - symbol_mapper["SymbolMapper"] - noise["AWGN"] - symbol_demapper["SymbolDemapper"] - viterbi["ViterbiDecoder"] - tx --> conv_encoder - conv_encoder --> symbol_mapper - symbol_mapper --> noise - noise --> symbol_demapper - symbol_demapper --> viterbi - classDef tapped stroke-dasharray: 4 2 - class tx tapped \ No newline at end of file diff --git a/docs/tutorials/mermaid/shaping_study.mmd b/docs/tutorials/mermaid/shaping_study.mmd deleted file mode 100644 index c0159f7..0000000 --- a/docs/tutorials/mermaid/shaping_study.mmd +++ /dev/null @@ -1,8 +0,0 @@ -flowchart LR - tx["SymbolGenerator"] - symbol_mapper["SymbolMapper"] - noise["AWGN"] - tx --> symbol_mapper - symbol_mapper --> noise - classDef tapped stroke-dasharray: 4 2 - class tx tapped \ No newline at end of file diff --git a/docs/tutorials/mimo.rst b/docs/tutorials/mimo.rst index 6507eef..e6aa489 100644 --- a/docs/tutorials/mimo.rst +++ b/docs/tutorials/mimo.rst @@ -63,12 +63,12 @@ forcing: .. code:: - ZF, one channel draw: SER = 0.0025 + ZF, one channel draw: SER = 0.0035 Received Constellations """"""""""""""""""""""" -Let us look at what each receive antenna sees, read from the ``"noise"`` tap: +Let us look at what each receive antenna sees, read from the ``"noise"`` observation: .. literalinclude:: ../../examples/mimo/one_shot_mimo.py :language: python @@ -248,15 +248,15 @@ The five curves come from the same dictionary, shown twice: .. code:: SER - snr_dB ZF MMSE OSIC ML SD - ------------------------------------------------------- - 0 0.33427 0.28820 0.28735 2.851e-01 2.868e-01 - 3 0.21634 0.17955 0.16855 1.613e-01 1.620e-01 - 6 0.11209 0.09271 0.07311 6.785e-02 6.871e-02 - 9 0.04911 0.04034 0.02546 2.057e-02 2.095e-02 - 12 0.01876 0.01570 0.00779 4.700e-03 4.950e-03 - 15 0.00719 0.00583 0.00243 8.125e-04 6.875e-04 - 18 0.00286 0.00196 0.00100 1.000e-04 2.500e-05 + snr_dB ZF MMSE OSIC ML SD + --------------------------------------------------------- + 0 0.33381 0.28915 2.871e-01 2.842e-01 2.870e-01 + 3 0.21559 0.17933 1.676e-01 1.616e-01 1.621e-01 + 6 0.11125 0.09208 7.504e-02 6.799e-02 6.800e-02 + 9 0.04969 0.03982 2.499e-02 2.054e-02 2.142e-02 + 12 0.01900 0.01571 7.213e-03 4.837e-03 5.300e-03 + 15 0.00715 0.00554 2.325e-03 8.125e-04 7.375e-04 + 18 0.00284 0.00187 9.250e-04 1.000e-04 8.750e-05 .. image:: img/monte_carlo_mimo_fig3.png :width: 100% @@ -290,12 +290,21 @@ where that product no longer fits. We therefore time both detectors on .. code:: - ML 0 dB 176.7 ms 65536 nodes SER 0.7900 - ML 9 dB 113.7 ms 65536 nodes SER 0.5044 - ML 18 dB 119.0 ms 65536 nodes SER 0.0112 - SD 0 dB 163.4 ms 91.8 nodes SER 0.7900 - SD 9 dB 33.3 ms 19.2 nodes SER 0.5044 - SD 18 dB 10.8 ms 5.8 nodes SER 0.0112 + 16-QAM, 4x4: same decision, what it costs + ML 0 dB 149.0 ms 65536 nodes SER 0.7900 + SD 0 dB 237.1 ms 91.8 nodes SER 0.7900 + ML 3 dB 111.9 ms 65536 nodes SER 0.7288 + SD 3 dB 131.5 ms 51.3 nodes SER 0.7288 + ML 6 dB 109.4 ms 65536 nodes SER 0.6369 + SD 6 dB 76.6 ms 29.8 nodes SER 0.6369 + ML 9 dB 107.6 ms 65536 nodes SER 0.5044 + SD 9 dB 49.3 ms 19.2 nodes SER 0.5044 + ML 12 dB 93.4 ms 65536 nodes SER 0.3381 + SD 12 dB 34.1 ms 12.8 nodes SER 0.3381 + ML 15 dB 101.0 ms 65536 nodes SER 0.1256 + SD 15 dB 23.8 ms 8.6 nodes SER 0.1256 + ML 18 dB 112.2 ms 65536 nodes SER 0.0112 + SD 18 dB 16.8 ms 5.8 nodes SER 0.0112 .. image:: img/monte_carlo_mimo_fig4.png :width: 100% diff --git a/docs/tutorials/optical_fiber_nonlinearity.rst b/docs/tutorials/optical_fiber_nonlinearity.rst index e460cab..fdd43a7 100644 --- a/docs/tutorials/optical_fiber_nonlinearity.rst +++ b/docs/tutorials/optical_fiber_nonlinearity.rst @@ -112,7 +112,7 @@ symbol sequence, which the chain produces itself, so the edge is *declared* -- ``wiring={"phase.reference": "signal_tx"}`` feeds the compensator the output of the ``signal_tx`` block before it runs, on every pass. -And ``taps`` names the four signals the figures need. ``rx_field`` is the +And ``observations`` names the four signals the figures need. ``rx_field`` is the field as it comes off the fibre, before any of the DSP; ``phase`` is the final estimate. Reading them costs nothing and keeps the module list a description of the system rather than of the plotting. diff --git a/examples/mimo/monte_carlo_simulation_1.py b/examples/mimo/monte_carlo_simulation_1.py index ec8d415..2302180 100644 --- a/examples/mimo/monte_carlo_simulation_1.py +++ b/examples/mimo/monte_carlo_simulation_1.py @@ -38,7 +38,7 @@ SymbolMapper(constellation), FlatMIMOChannel(rayleigh_channel(N_r=N_r, N_t=N_t), name="channel"), AWGN(sigma2=1.0, name="noise"), - ], taps=["data_tx"]) + ], observations=["data_tx"]) # --- metrics, pre-allocated ------------------------------------------ # One array per detector, full of zeros, indexed by name -- a column @@ -62,7 +62,7 @@ chain.set_params(noise__sigma2=sigma2, channel__H=H) chain.seed(int(rng.integers(2 ** 31))) Y = chain((N_test, N_t, N)) - S_ref = chain.tap("data_tx") + S_ref = chain.observation("data_tx") detectors = { "ML": MaximumLikelihoodDetector(alphabet=constellation, H=H), diff --git a/examples/mimo/monte_carlo_simulation_2.py b/examples/mimo/monte_carlo_simulation_2.py index 115cadd..dd2b815 100644 --- a/examples/mimo/monte_carlo_simulation_2.py +++ b/examples/mimo/monte_carlo_simulation_2.py @@ -37,7 +37,7 @@ SymbolMapper(constellation), FlatMIMOChannel(rayleigh_channel(N_r=N_r, N_t=N_t), name="channel"), AWGN(sigma2=1.0, name="noise"), - ], taps=["data_tx"]) + ], observations=["data_tx"]) # --- metrics, pre-allocated ------------------------------------------ # One array per ordering, indexed by name. The error counts get one @@ -70,7 +70,7 @@ chain.seed(int(rng.integers(2 ** 31))) chain.set_params(channel__H=H) Y = chain((N_t, N)) - S_ref = chain.tap("data_tx") + S_ref = chain.observation("data_tx") for ordering, counter in counters.items(): detector = OrderedSuccessiveInterferenceCancellationDetector( diff --git a/examples/mimo/one_shot_CMA.py b/examples/mimo/one_shot_CMA.py index d1f9858..296d33d 100644 --- a/examples/mimo/one_shot_CMA.py +++ b/examples/mimo/one_shot_CMA.py @@ -84,13 +84,13 @@ def process_phase_correction(self, Y_sub): # to decide on, not the default 20 sub_block_length=500, name="filter"), DelayRemover(delay=int(0.9*N), name="tail"), - ], taps=["filter", "tail"]) + ], observations=["filter", "tail"]) # simulate communication Y = chain((N_t, N)) -data = chain.tap("filter") # full CMA output (before tail removal) -plot_iq(chain.tap("tail"), title="after CMA convergence") +data = chain.observation("filter") # full CMA output (before tail removal) +plot_iq(chain.observation("tail"), title="after CMA convergence") # compute CMA loss kernel_size = 100 diff --git a/examples/mimo/one_shot_alamouti.py b/examples/mimo/one_shot_alamouti.py index 12d6a10..e0148d7 100644 --- a/examples/mimo/one_shot_alamouti.py +++ b/examples/mimo/one_shot_alamouti.py @@ -66,20 +66,20 @@ SpaceTimeDecoder(code, H=H_one, name="detector"), Amplifier(1 / power), SymbolDemapper(constellation), -], taps=["tx", "noise", "detector"], name="Alamouti 2x1") +], observations=["tx", "noise", "detector"], name="Alamouti 2x1") one_shot.seed(7) # every stochastic block, reproducibly s_rx = one_shot(500 * code.n_symbols) -print(f"\none-shot SER: {compute_ser(one_shot.tap('tx'), s_rx):.4f}") +print(f"\none-shot SER: {compute_ser(one_shot.observation('tx'), s_rx):.4f}") one_shot.summary(500 * code.n_symbols) -received = one_shot.tap("noise")[0] -combined = one_shot.tap("detector") / power +received = one_shot.observation("noise")[0] +combined = one_shot.observation("detector") / power fig2, (ax_left, ax_right) = plt.subplots(nrows=1, ncols=2, figsize=(9, 4.2)) plot_iq(received, marker=".", ax=ax_left) -ax_left.set_title("tap('noise'): the single receive antenna") +ax_left.set_title("observation('noise'): the single receive antenna") plot_iq(combined, reference=constellation, ax=ax_right) -ax_right.set_title("tap('detector'): after Alamouti combining") +ax_right.set_title("observation('detector'): after Alamouti combining") plt.tight_layout() plt.savefig(f"{img_dir}/one_shot_alamouti_fig2.png") @@ -103,7 +103,7 @@ # zero forcing on an (N_r, 1) channel *is* maximum ratio combining: # the pseudo-inverse of a column is h^H / ||h||^2 LinearDetector(constellation, H=H_siso, name="detector"), -], taps=["tx"], name="1 Tx, 1 Rx") +], observations=["tx"], name="1 Tx, 1 Rx") alamouti = Sequential([ SymbolGenerator(M, name="tx"), @@ -115,7 +115,7 @@ SpaceTimeDecoder(code, H=H_alamouti, name="detector"), Amplifier(1 / power), SymbolDemapper(constellation), -], taps=["tx"], name="Alamouti 2x1") +], observations=["tx"], name="Alamouti 2x1") mrc = Sequential([ SymbolGenerator(M, name="tx"), @@ -123,7 +123,7 @@ FlatMIMOChannel(H_mrc, name="channel"), AWGN(sigma2=1.0, name="noise"), LinearDetector(constellation, H=H_mrc, name="detector"), -], taps=["tx"], name="MRC 1x2") +], observations=["tx"], name="MRC 1x2") # --- the sweep: no simulation loop ----------------------------------- # monte_carlo moves the noise variance; everything else is frozen in diff --git a/examples/mimo/one_shot_mimo.py b/examples/mimo/one_shot_mimo.py index 9167e70..56249b8 100644 --- a/examples/mimo/one_shot_mimo.py +++ b/examples/mimo/one_shot_mimo.py @@ -39,14 +39,14 @@ FlatMIMOChannel(H, name="channel"), AWGN(sigma2=sigma2, name="noise"), LinearDetector(constellation, H=H, method="zf", name="detector"), -], taps=["tx", "noise"], name=f"{N_r}x{N_t} MIMO, ZF") +], observations=["tx", "noise"], name=f"{N_r}x{N_t} MIMO, ZF") chain.seed(0) detected = chain((N_t, N)) print(f"ZF, one channel draw: SER = " - f"{compute_ser(chain.tap('tx'), detected):.4f}") + f"{compute_ser(chain.observation('tx'), detected):.4f}") -Y = chain.tap("noise") +Y = chain.observation("noise") fig1, axes1 = plt.subplots(nrows=1, ncols=N_r, figsize=(4 * N_r, 4)) for index in range(N_r): plot_iq(Y[index, :], ax=axes1[index]) @@ -88,7 +88,7 @@ AWGN(sigma2=1.0, name="noise"), LinearDetector(constellation, H=H_stack, method="zf", name="detector"), -], taps=["tx"], name="ZF") +], observations=["tx"], name="ZF") curves["ZF"] = monte_carlo( zf, "noise.sigma2", sigma2_list, {"ser": compute_ser}, stimulus, reference="tx", seed=1)["ser"] @@ -101,7 +101,7 @@ AWGN(sigma2=1.0, name="noise"), LinearDetector(constellation, H=H_stack, method="mmse", sigma2=1.0, name="detector"), -], taps=["tx"], name="MMSE") +], observations=["tx"], name="MMSE") curves["MMSE"] = monte_carlo( mmse, ("noise.sigma2", "detector.sigma2"), both, {"ser": compute_ser}, stimulus, reference="tx", seed=2)["ser"] @@ -115,7 +115,7 @@ OrderedSuccessiveInterferenceCancellationDetector( constellation, osic_type="sinr", H=H_stack, sigma2=1.0, name="detector"), -], taps=["tx"], name="OSIC") +], observations=["tx"], name="OSIC") curves["OSIC"] = monte_carlo( osic, ("noise.sigma2", "detector.sigma2"), both, {"ser": compute_ser}, stimulus, reference="tx", seed=3)["ser"] @@ -127,7 +127,7 @@ FlatMIMOChannel(H_stack, name="channel"), AWGN(sigma2=1.0, name="noise"), MaximumLikelihoodDetector(constellation, H=H_stack, name="detector"), -], taps=["tx"], name="ML") +], observations=["tx"], name="ML") curves["ML"] = monte_carlo( ml, "noise.sigma2", sigma2_list, {"ser": compute_ser}, stimulus, reference="tx", seed=4)["ser"] @@ -139,7 +139,7 @@ FlatMIMOChannel(H_stack, name="channel"), AWGN(sigma2=1.0, name="noise"), SphereDecoder(constellation, H=H_stack, name="detector"), -], taps=["tx"], name="SD") +], observations=["tx"], name="SD") curves["SD"] = monte_carlo( sd, "noise.sigma2", sigma2_list, {"ser": compute_ser}, stimulus, reference="tx", seed=5)["ser"] @@ -170,7 +170,7 @@ AWGN(sigma2=1.0, name="noise"), MaximumLikelihoodDetector(big_constellation, H=big_H, name="detector"), -], taps=["tx"], name="4x4 MIMO, ML") +], observations=["tx"], name="4x4 MIMO, ML") sd_16 = Sequential([ SymbolGenerator(big_constellation.order, name="tx"), @@ -178,7 +178,7 @@ FlatMIMOChannel(big_H, name="channel"), AWGN(sigma2=1.0, name="noise"), SphereDecoder(big_constellation, H=big_H, name="detector"), -], taps=["tx"], name="4x4 MIMO, SD") +], observations=["tx"], name="4x4 MIMO, SD") elapsed = {"ML": np.zeros(len(snr_dB_list)), "SD": np.zeros(len(snr_dB_list))} @@ -194,7 +194,7 @@ start = time.perf_counter() detected = ml_16((4, 400)) elapsed["ML"][index] = (time.perf_counter() - start) * 1e3 - errors = compute_ser(ml_16.tap("tx"), detected) + errors = compute_ser(ml_16.observation("tx"), detected) print(f" ML {snr_dB:2d} dB {elapsed['ML'][index]:8.1f} ms " f"{16 ** 4:7d} nodes SER {errors:.4f}") @@ -203,7 +203,7 @@ start = time.perf_counter() detected = sd_16((4, 400)) elapsed["SD"][index] = (time.perf_counter() - start) * 1e3 - errors = compute_ser(sd_16.tap("tx"), detected) + errors = compute_ser(sd_16.observation("tx"), detected) print(f" SD {snr_dB:2d} dB {elapsed['SD'][index]:8.1f} ms " f"{sd_16['detector'].nodes_:7.1f} nodes SER {errors:.4f}") diff --git a/examples/ofdm/one_shot_ofdm.py b/examples/ofdm/one_shot_ofdm.py index f9af515..f0c2aae 100644 --- a/examples/ofdm/one_shot_ofdm.py +++ b/examples/ofdm/one_shot_ofdm.py @@ -60,17 +60,17 @@ AWGN(snr_dB=snr_dB, name="data_rx"), LinearEqualizer(h, method="zf", name="data_rx_eq"), SymbolDemapper(constellation) - ], taps=["data_tx", "data_rx", "data_rx_eq"]) + ], observations=["data_tx", "data_rx", "data_rx_eq"]) sc_chain.seed(1) detected = sc_chain(N) -sc_ser = compute_ser(sc_chain.tap("data_tx"), detected) +sc_ser = compute_ser(sc_chain.observation("data_tx"), detected) print(f"single carrier: SER {sc_ser:.4f}, {sc_chain.elapsed_ * 1e3:.0f} ms") fig, axes = plt.subplots(nrows=1, ncols=2, figsize=(9, 4.2)) -for ax, tap, name in [(axes[0], "data_rx", "received"), +for ax, observed, name in [(axes[0], "data_rx", "received"), (axes[1], "data_rx_eq", "after ZF equalization")]: - plot_iq(sc_chain.tap(tap), reference=constellation, title=name, ax=ax) + plot_iq(sc_chain.observation(observed), reference=constellation, title=name, ax=ax) plt.tight_layout() plt.savefig(f"{img_dir}/one_shot_ofdm_fig2.png") @@ -85,16 +85,16 @@ AWGN(snr_dB=snr_dB, name="data_rx"), OFDMReceiver(N_carrier, N_cp, h=h, name="data_rx_eq"), SymbolDemapper(constellation) - ], taps=["data_tx", "data_rx_eq"]) + ], observations=["data_tx", "data_rx_eq"]) ofdm_chain.seed(1) detected = ofdm_chain(N) -ofdm_ser = compute_ser(ofdm_chain.tap("data_tx"), detected) +ofdm_ser = compute_ser(ofdm_chain.observation("data_tx"), detected) speedup = sc_chain.elapsed_ / ofdm_chain.elapsed_ print(f"OFDM : SER {ofdm_ser:.4f}, {ofdm_chain.elapsed_ * 1e3:.2f} ms " f"({speedup:.0f} times faster)") -plot_iq(ofdm_chain.tap("data_rx_eq"), reference=constellation, +plot_iq(ofdm_chain.observation("data_rx_eq"), reference=constellation, title="OFDM, after one-tap equalization") plt.savefig(f"{img_dir}/one_shot_ofdm_fig3.png") diff --git a/examples/optical/CD_compensation_part1.py b/examples/optical/CD_compensation_part1.py index 9b2b0b9..e2e3a05 100644 --- a/examples/optical/CD_compensation_part1.py +++ b/examples/optical/CD_compensation_part1.py @@ -49,7 +49,7 @@ SRRCFilter(rolloff, oversampling, N_h=N_h), ChromaticDispersion(z, fs=fs), AWGN(sigma2=1.0, name="noise"), - ], taps=["generator"]) + ], observations=["generator"]) compensators = { "Savory": Sequential([ @@ -97,7 +97,7 @@ chain.set_params(noise__sigma2=sigma2) chain.seed(int(point_seeds[index].generate_state(1)[0])) y = chain(N) - s = chain.tap("generator") + s = chain.observation("generator") for name, compensator in compensators.items(): s_est = compensator(y) diff --git a/examples/optical/NLI_simulation.py b/examples/optical/NLI_simulation.py index 1da34c6..e11db8c 100644 --- a/examples/optical/NLI_simulation.py +++ b/examples/optical/NLI_simulation.py @@ -67,7 +67,7 @@ def get_channel(): FiberLink(N_spans=N_span, L_span=L_span, StPS=StPS, NF_dB=NF_dB, fs=fs, fiber=fiber, name="link"), Downsampler(oversampling_ratio, use_filter=True), - ], taps=["data_tx", "signal_tx"]) + ], observations=["data_tx", "signal_tx"]) def get_receiver(steps, linear_only, *, gain, reference): @@ -87,7 +87,7 @@ def get_receiver(steps, linear_only, *, gain, reference): Amplifier(gain), DataAidedPhaseCompensator(reference, name="phase"), SymbolDemapper(constellation, name="data_rx"), - ], taps=["phase"]) + ], observations=["phase"]) # --- what the closed form expects of this link ------------------------- @@ -163,8 +163,8 @@ def get_receiver(steps, linear_only, *, gain, reference): channel.set_params(launch__gain=amp, link__use_only_linear=use_only_linear) fields[use_only_linear] = channel(N_s) - symbols = channel.tap("signal_tx") - reference = channel.tap("data_tx") + symbols = channel.observation("signal_tx") + reference = channel.observation("data_tx") # each receiver decides the field that matches its claim: the # bound receives the linear one @@ -173,7 +173,7 @@ def get_receiver(steps, linear_only, *, gain, reference): reference=symbols) detected = receiver(fields[name == "amplifier noise only"]) snr[name][index] += compute_effective_snr( - symbols, receiver.tap("phase")) / N_trial + symbols, receiver.observation("phase")) / N_trial counters[name].update(reference, detected) times[name][index] += receiver.elapsed_ diff --git a/examples/optical/gn_model.py b/examples/optical/gn_model.py index c0d70e8..24eeeb2 100644 --- a/examples/optical/gn_model.py +++ b/examples/optical/gn_model.py @@ -134,7 +134,7 @@ def get_chain(order=16, power_W=1e-3): use_only_linear=True, name="dbp"), SRRCFilter(ROLLOFF, OS, N_h=40, method="fft", scale=1 / np.sqrt(OS)), Downsampler(OS), - ], taps=["tx"], name=f"{N_SPANS} x {SPAN_KM:.0f} km SMF") + ], observations=["tx"], name=f"{N_SPANS} x {SPAN_KM:.0f} km SMF") def effective_snr_dB(sent, received): diff --git a/examples/optical/one_shot_NLI.py b/examples/optical/one_shot_NLI.py index ebc2137..e214546 100644 --- a/examples/optical/one_shot_NLI.py +++ b/examples/optical/one_shot_NLI.py @@ -77,15 +77,15 @@ def get_chain(n_spans, *, steps=1, linear_only=True): Amplifier(1 / amp), DataAidedPhaseCompensator(name="phase"), SymbolDemapper(constellation, name="data_rx"), - ], taps=["data_tx", "signal_tx", "rx_field", "phase", "data_rx"], + ], observations=["data_tx", "signal_tx", "rx_field", "phase", "data_rx"], wiring={"phase.reference": "signal_tx"}) def score(chain): """Effective SNR in dB and symbol error rate of a finished run.""" - return (compute_effective_snr(chain.tap("signal_tx"), - chain.tap("phase"), unit="dB"), - compute_ser(chain.tap("data_tx"), chain.tap("data_rx"))) + return (compute_effective_snr(chain.observation("signal_tx"), + chain.observation("phase"), unit="dB"), + compute_ser(chain.observation("data_tx"), chain.observation("data_rx"))) # --- 1. what the link does to the signal ------------------------------ @@ -110,14 +110,14 @@ def score(chain): for n_spans in spans: chain = get_chain(n_spans).seed(0) chain(N_s) - estimates[n_spans] = chain.tap("phase") + estimates[n_spans] = chain.observation("phase") snr, ser = score(chain) snr_per_span[n_spans] = snr elapsed = chain.elapsed_ theta_deg = np.rad2deg(chain["phase"].theta_) # the field the receiver saw, before any of the DSP; the loop keeps # overwriting it, so what survives it is the longest link's - received_field = chain.tap("rx_field") + received_field = chain.observation("rx_field") chain.seed(0).set_params(link__use_only_linear=True) chain(N_s) @@ -211,7 +211,7 @@ def score(chain): for ax, symbols, name in [ (axes[0], estimates[N_span], f"dispersion compensation\nSNR {snr_per_span[N_span]:.2f} dB"), - (axes[1], back_propagated.tap("phase"), + (axes[1], back_propagated.observation("phase"), f"digital back-propagation\nSNR {snr_dbp:.2f} dB")]: plot_iq(symbols, reference=constellation, title=name, ax=ax) plt.tight_layout() diff --git a/examples/optical/one_shot_pdm.py b/examples/optical/one_shot_pdm.py index 50901d0..1782dd3 100644 --- a/examples/optical/one_shot_pdm.py +++ b/examples/optical/one_shot_pdm.py @@ -48,11 +48,11 @@ PhaseNoise(2 * np.pi * linewidth / fs, name="laser"), PMDEmulator(dgd, n_sections=8, fs=fs, seed=11, name="fibre"), AWGN(snr_dB=snr_dB, name="noise"), - ], taps=["data_tx", "signal_tx", "noise"]) + ], observations=["data_tx", "signal_tx", "noise"]) chain.seed(0) received = chain((2, N)) -sent = chain.tap("signal_tx") -reference = chain.tap("data_tx") +sent = chain.observation("signal_tx") +reference = chain.observation("data_tx") # What the receiver faces, sampled naively at the symbol rate: the two # polarizations are mixed by an unknown rotation, delayed against each diff --git a/examples/simple/channel_coding.py b/examples/simple/channel_coding.py index 6675770..1159248 100644 --- a/examples/simple/channel_coding.py +++ b/examples/simple/channel_coding.py @@ -53,7 +53,7 @@ def snr_dB(ebn0, code_rate): SymbolMapper(BPSK), AWGN(snr_dB=0.0, name="noise"), SymbolDemapper(BPSK), -], taps=["tx"], name="uncoded BPSK") +], observations=["tx"], name="uncoded BPSK") hard = Sequential([ SymbolGenerator(2, name="tx"), @@ -62,7 +62,7 @@ def snr_dB(ebn0, code_rate): AWGN(snr_dB=0.0, name="noise"), SymbolDemapper(BPSK), ViterbiDecoder((0o133, 0o171)), -], taps=["tx"], name="K=7 convolutional, hard") +], observations=["tx"], name="K=7 convolutional, hard") soft = Sequential([ SymbolGenerator(2, name="tx"), @@ -71,7 +71,7 @@ def snr_dB(ebn0, code_rate): AWGN(snr_dB=0.0, name="noise"), SymbolDemapper(BPSK, soft=True), ViterbiDecoder((0o133, 0o171), soft=True), -], taps=["tx"], name="K=7 convolutional, soft") +], observations=["tx"], name="K=7 convolutional, soft") curves = {} curves["uncoded"] = monte_carlo( @@ -121,7 +121,7 @@ def snr_dB(ebn0, code_rate): AWGN(snr_dB=0.0, name="noise"), SymbolDemapper(BPSK, soft=True), LDPCDecoder(H, n_iter=5, name="decoder"), -], taps=["tx"], name="LDPC") +], observations=["tx"], name="LDPC") ldpc_curves = {} ldpc_curves[f"LDPC (2040, {ldpc_encoder.k}), 5 iterations"] = monte_carlo( diff --git a/examples/simple/monte_carlo_awgn.py b/examples/simple/monte_carlo_awgn.py index e894d23..84f92fb 100644 --- a/examples/simple/monte_carlo_awgn.py +++ b/examples/simple/monte_carlo_awgn.py @@ -30,7 +30,7 @@ SymbolMapper(constellation, name="mapper"), AWGN(snr_dB=0, name="awgn_channel"), SymbolDemapper(constellation, name="demapper"), -], taps=["tx"]) +], observations=["tx"]) # A Monte Carlo simulation is three parts, always in this order: the # metric pre-allocated, the loop that fills it, the display at the end. @@ -40,8 +40,8 @@ for index, snr_dB in enumerate(probed_snr_dB): chain.seed(1) chain.set_params(awgn_channel__snr_dB=snr_dB) - detected = chain(N) # run first: the tap is filled by it - ser_by_hand[index] = compute_ser(chain.tap("tx"), detected) + detected = chain(N) # run first: the observation is filled by it + ser_by_hand[index] = compute_ser(chain.observation("tx"), detected) # monte_carlo (decision D35) is that loop, and nothing more: the same # reconfigure-reseed-run-collect, over every point, in one call. diff --git a/examples/simple/one_shot_awgn.py b/examples/simple/one_shot_awgn.py index 99d5e67..e8838c2 100644 --- a/examples/simple/one_shot_awgn.py +++ b/examples/simple/one_shot_awgn.py @@ -68,13 +68,13 @@ SymbolMapper(constellation), AWGN(snr_dB=snr_dB, name="awgn"), SymbolDemapper(constellation), - ], taps=["tx", "awgn"]) + ], observations=["tx", "awgn"]) # seeded, so the number this page quotes is the number you get (D6) chain.seed(0) y = chain(N) -ser = compute_ser(chain.tap("tx"), y) +ser = compute_ser(chain.observation("tx"), y) # AWGN(snr_dB=) is a symbol SNR and the closed form is quoted against # Eb/N0: per="symbol" makes the conversion, which is 10log10(k) dB. ser_theo = constellation.metrics(snr_dB, per="symbol")["ser"] @@ -90,7 +90,7 @@ # --- 4. the figures --------------------------------------------------- fig, axes = plt.subplots(nrows=1, ncols=2, figsize=(9, 4.2)) constellation.plot(ax=axes[0]) -plot_iq(chain.tap("awgn"), title=f"received at {snr_dB} dB", ax=axes[1]) +plot_iq(chain.observation("awgn"), title=f"received at {snr_dB} dB", ax=axes[1]) plt.tight_layout() plt.savefig(f"{img_dir}/first_simulation_fig1.png") diff --git a/examples/simple/one_shot_cfo_iq_compensation.py b/examples/simple/one_shot_cfo_iq_compensation.py index 7f7f36f..5895393 100644 --- a/examples/simple/one_shot_cfo_iq_compensation.py +++ b/examples/simple/one_shot_cfo_iq_compensation.py @@ -24,7 +24,7 @@ # One chain describing the whole system. The phase compensator is data # aided: `wiring` feeds it the transmitted symbols of the current run, # produced upstream by the mapper -- so the reference is never stale. -# `taps` extracts the signals we want to look at afterwards. +# `observations` retains the signals we want to look at afterwards. chain = Sequential([ SymbolGenerator(constellation.order, name="data_tx"), SymbolMapper(constellation, name="signal_tx"), @@ -36,23 +36,23 @@ DataAidedPhaseCompensator(reference=np.zeros(1), name="phase_comp"), SymbolDemapper(constellation) ], - taps=["data_tx", "awgn", "gsop", "cfo_comp", "phase_comp"], + observations=["data_tx", "awgn", "gsop", "cfo_comp", "phase_comp"], wiring={"phase_comp.reference": "signal_tx"}) # simulate communication y = chain(N) # compute metric -ser_after = compute_ser(chain.tap("data_tx"), y) +ser_after = compute_ser(chain.observation("data_tx"), y) # print metric and plot print(f"after: SER={ser_after}") -for tap, name in [("awgn", "received signal"), +for observed, name in [("awgn", "received signal"), ("gsop", "after GSOP"), ("cfo_comp", "after GSOP+CFO comp"), ("phase_comp", "after GSOP + CFO comp + phase correction")]: - plot_iq(chain.tap(tap), title=name) + plot_iq(chain.observation(observed), title=name) # show evolution of the angular frequency estimate w0_history = chain["cfo_comp"].history_ diff --git a/examples/simple/one_shot_phase_compensation.py b/examples/simple/one_shot_phase_compensation.py index f35aee8..3b78860 100644 --- a/examples/simple/one_shot_phase_compensation.py +++ b/examples/simple/one_shot_phase_compensation.py @@ -30,7 +30,7 @@ AWGN(sigma2=sigma2, name="awgn"), BlindPhaseCompensation(constellation, name="phase_compensation"), SymbolDemapper(constellation) - ], taps=["data_tx", "awgn", "phase_compensation"]) + ], observations=["data_tx", "awgn", "phase_compensation"]) # simulate communication y = chain(N) @@ -41,13 +41,13 @@ print(f"compensation phase: {estimated_phase}") # compute metric -data_tx = chain.tap("data_tx") +data_tx = chain.observation("data_tx") ser_after = compute_ser(data_tx, y) # print metric and plot print(f"after: SER={ser_after}") -for tap, name in [("awgn", "received data"), +for observed, name in [("awgn", "received data"), ("phase_compensation", "after phase correction")]: - plot_iq(chain.tap(tap), title=name) + plot_iq(chain.observation(observed), title=name) plt.show() diff --git a/examples/simple/one_shot_srrc_awgn.py b/examples/simple/one_shot_srrc_awgn.py index d90e1d6..c09f79d 100644 --- a/examples/simple/one_shot_srrc_awgn.py +++ b/examples/simple/one_shot_srrc_awgn.py @@ -22,7 +22,7 @@ N_h = 1000 sigma2 = 3e-2 -# create chain; taps extract the signals to observe after the run +# create chain; observations retain the signals to inspect after the run chain = Sequential([ SymbolGenerator(constellation.order, name="tx"), SymbolMapper(constellation), @@ -33,18 +33,18 @@ Downsampler(oversampling, phase=2*oversampling*N_h), DataExtractor(selector=(0, N), name="extractor"), SymbolDemapper(constellation), - ], taps=["tx", "awgn_channel", "extractor"]) + ], observations=["tx", "awgn_channel", "extractor"]) # run chain y = chain(N) # evaluate metrics -data_tx = chain.tap("tx") +data_tx = chain.observation("tx") ser_exp = compute_ser(data_tx, y) # plot the extracted signals -plot_spectrum(chain.tap("awgn_channel"), title="received signal") -plot_iq(chain.tap("extractor"), title="after SRRC+downsampling+extractor") +plot_spectrum(chain.observation("awgn_channel"), title="received signal") +plot_iq(chain.observation("extractor"), title="after SRRC+downsampling+extractor") # plot error distribution N_min = np.min([len(data_tx), len(y)]) diff --git a/src/comnumpy/core/capacity.py b/src/comnumpy/core/capacity.py index 4c88a91..7a3d348 100644 --- a/src/comnumpy/core/capacity.py +++ b/src/comnumpy/core/capacity.py @@ -22,12 +22,14 @@ ] # Gauss-Hermite is the natural quadrature for an integral against a -# Gaussian weight. Convergence is not uniform in the constellation size: -# measured against a 100-node reference over rho in [0.01, 1000], the -# error at 40 nodes is 1.6e-7 bit for QPSK, 1.2e-6 for 16-QAM and 2.1e-5 -# for 64-QAM -- a dense constellation makes the integrand sharper. 40 is -# the default because it is accurate enough to read a coded curve against -# and still cheap; pass more through ``n_nodes`` if a figure needs it. +# Gaussian weight. Convergence is not uniform in the constellation size +# or orientation (the node grid is axis-aligned): measured against a +# 100-node reference over rho in [0.01, 1000], the error at 40 nodes is +# 1.9e-5 bit for QPSK (diagonal, the pi/4 standards convention), 1.2e-6 +# for 16-QAM and 2.1e-5 for 64-QAM -- a dense constellation makes the +# integrand sharper. 40 is the default because it is accurate enough to +# read a coded curve against and still cheap; pass more through +# ``n_nodes`` if a figure needs it. _GH_NODES = 40 # The likelihood tensor is (M, M, Q): for 256-QAM with 40 nodes that would diff --git a/src/comnumpy/core/generics.py b/src/comnumpy/core/generics.py index 8557fda..330e519 100644 --- a/src/comnumpy/core/generics.py +++ b/src/comnumpy/core/generics.py @@ -128,7 +128,7 @@ class ChainGraph(TypedDict): nodes: List[Dict[str, str]] signal_edges: List[Tuple[str, str]] data_edges: List[Tuple[str, str, str]] - taps: List[str] + observations: List[str] # (index, class name, block id, output shape, dtype, elapsed ms) @@ -183,14 +183,16 @@ class Sequential(): Dictionary of callback functions called after each processor. Keys are processor names (str) or indices (int), values are callables accepting the processor output. - taps : list of str, optional, keyword-only - Block ids (see :meth:`block_ids`) whose output should be recorded - during ``forward``. Taps are the *only* observation mechanism: - they are chain metadata, so the module list describes the - communication system and nothing else, and the cost is one - dictionary store per tapped block (a reference is kept, no copy - -- blocks allocate fresh outputs, so the reference stays valid). - Retrieve with :meth:`tap`. + observations : list of str, optional, keyword-only + Block ids (see :meth:`block_ids`) whose output should be retained + during ``forward``. An observation is simply the output of a + named mechanism, kept for experimental analysis: it performs no + computation and does not alter the signal. Observations are chain + metadata, so the module list describes the communication system + and nothing else, and the cost is one dictionary store per + observed block (a reference is kept, no copy -- blocks allocate + fresh outputs, so the reference stays valid). Retrieve with + :meth:`observation`. elapsed_ : float Wall time of the last pass, in seconds (data-dependent, hence the trailing underscore, decision D23). Every call records it, so @@ -202,19 +204,20 @@ class Sequential(): Before a target block runs, the chain assigns it the signal produced by the source block *in the same pass* -- this is how a data-aided estimator receives a reference generated upstream - (``{"phase_comp.reference": "tx"}``). The source is tapped + (``{"phase_comp.reference": "tx"}``). The source is observed automatically and must come earlier in the chain, so the value is - never stale. Like taps, the edge is chain metadata: blocks stay - declarative and never hold a reference to another block. + never stale. Like observations, the edge is chain metadata: + blocks stay declarative and never hold a reference to another + block. Examples -------- >>> from comnumpy.core.generators import SymbolGenerator >>> from comnumpy.core.channels import AWGN >>> chain = Sequential([SymbolGenerator(4), AWGN(snr_dB=10)], - ... taps=["generator"]) + ... observations=["generator"]) >>> y = chain.seed(1)(5) - >>> print(chain.tap("generator")) + >>> print(chain.observation("generator")) [0 0 3 1 3] >>> chain.elapsed_ > 0 True @@ -224,10 +227,10 @@ class Sequential(): name: str = 'sequential' callbacks: Optional[Dict[Union[str, int], Callable[[Any], None]]] = \ field(default_factory=dict) - taps: Optional[List[str]] = field(default=None, kw_only=True) + observations: Optional[List[str]] = field(default=None, kw_only=True) wiring: Optional[Dict[str, str]] = field(default=None, kw_only=True) - # signals recorded at the declared taps (references, not copies) - tapped_: Dict[str, Any] = field(init=False, repr=False, default_factory=dict) + # signals retained at the declared observations (references, not copies) + observed_: Dict[str, Any] = field(init=False, repr=False, default_factory=dict) # wall time of the last pass (D23: data-dependent, hence the underscore) elapsed_: float = field(init=False, repr=False, default=0.0) @@ -479,25 +482,25 @@ def summary(self, X: Any, print_out: bool = True) -> List[_SummaryRow]: Run the chain on ``X`` and tabulate each block's output shape, dtype and execution time (decision D33b). Returns the rows. - The pass is the ordinary one: taps are recorded and the data - edges declared in ``wiring`` are fed, so the chain summarized is - the chain that runs. + The pass is the ordinary one: observations are retained and the + data edges declared in ``wiring`` are fed, so the chain + summarized is the chain that runs. """ ids, recorded, feeds = self._resolve_edges() if ids is None: ids = self.block_ids() - self.tapped_.clear() + self.observed_.clear() rows: List[_SummaryRow] = [] Y = X for index, (block_id, module) in enumerate( zip(ids, self.module_list, strict=True)): for param, source in feeds.get(index, ()): - setattr(module, param, self.tapped_[source]) + setattr(module, param, self.observed_[source]) start_time = time.time() Y = module(Y) elapsed_ms = (time.time() - start_time) * 1e3 if block_id in recorded: - self.tapped_[block_id] = Y + self.observed_[block_id] = Y shape = getattr(Y, "shape", None) dtype = getattr(Y, "dtype", type(Y).__name__) rows.append((index, type(module).__name__, block_id, @@ -528,7 +531,8 @@ def graph(self) -> "ChainGraph": dict ``nodes`` (list of ``{"id", "type"}``), ``signal_edges`` (list of ``(source, target)``), ``data_edges`` (list of - ``(source, target, param)``) and ``taps`` (observed ids). + ``(source, target, param)``) and ``observations`` (observed + ids). Examples -------- @@ -548,7 +552,7 @@ def graph(self) -> "ChainGraph": for block_id, module in zip(ids, self.module_list, strict=True)], "signal_edges": list(zip(ids[:-1], ids[1:], strict=True)), "data_edges": data_edges, - "taps": list(self.taps or ()), + "observations": list(self.observations or ()), } def to_mermaid(self) -> str: @@ -558,22 +562,22 @@ def to_mermaid(self) -> str: Signal flow is drawn with solid arrows, declared data edges (``wiring``, D42c) with dashed arrows labelled by the parameter - they feed, and tapped blocks are outlined -- so the picture shows - every edge the chain actually has. + they feed, and observed blocks are outlined -- so the picture + shows every edge the chain actually has. Examples -------- >>> from comnumpy.core.generators import SymbolGenerator >>> from comnumpy.core.channels import AWGN >>> chain = Sequential([SymbolGenerator(4), AWGN(snr_dB=10)], - ... taps=["generator"]) + ... observations=["generator"]) >>> print(chain.to_mermaid()) flowchart LR generator["SymbolGenerator"] awgn["AWGN"] generator --> awgn - classDef tapped stroke-dasharray: 4 2 - class generator tapped + classDef observed stroke-dasharray: 4 2 + class generator observed """ model = self.graph() lines = ["flowchart LR"] @@ -583,9 +587,9 @@ class generator tapped lines.append(f" {a} --> {b}") for source, target, param in model["data_edges"]: lines.append(f" {source} -.->|{param}| {target}") - if model["taps"]: - lines.append(" classDef tapped stroke-dasharray: 4 2") - lines.append(f" class {','.join(model['taps'])} tapped") + if model["observations"]: + lines.append(" classDef observed stroke-dasharray: 4 2") + lines.append(f" class {','.join(model['observations'])} observed") return "\n".join(lines) def set_debug(self, debug: Optional[bool] = None) -> None: @@ -598,9 +602,9 @@ def set_debug(self, debug: Optional[bool] = None) -> None: def profile_execution_time(self, X: np.ndarray) -> Dict[str, float]: """Time each block over one pass, keyed by block id. - The pass is the ordinary one: taps are recorded and the data - edges declared in ``wiring`` are fed, so a chain that profiles is - the chain that runs. The keys are the ids of :meth:`block_ids`, + The pass is the ordinary one: observations are retained and the + data edges declared in ``wiring`` are fed, so a chain that + profiles is the chain that runs. The keys are the ids of :meth:`block_ids`, so two blocks sharing a name still get one entry each. Parameters @@ -625,40 +629,41 @@ def profile_execution_time(self, X: np.ndarray) -> Dict[str, float]: ids, recorded, feeds = self._resolve_edges() if ids is None: ids = self.block_ids() - self.tapped_.clear() + self.observed_.clear() Y = X time_elapsed: Dict[str, float] = {} for index, processor in enumerate(self.module_list): for param, source in feeds.get(index, ()): - setattr(processor, param, self.tapped_[source]) + setattr(processor, param, self.observed_[source]) start_time = time.time() Y = processor(Y) stop_time = time.time() if ids[index] in recorded: - self.tapped_[ids[index]] = Y + self.observed_[ids[index]] = Y time_elapsed[ids[index]] = stop_time - start_time self.elapsed_ = sum(time_elapsed.values()) return time_elapsed def _resolve_edges(self) -> _EdgePlan: - """Validate taps/wiring against the block ids; return the plan. + """Validate observations/wiring against the block ids; return the plan. Returns ``(ids, recorded, feeds)`` where ``recorded`` is the set of ids to store during the pass and ``feeds`` maps a block index to the ``(param, source_id)`` pairs to assign before it runs. """ - if not self.taps and not self.wiring: + if not self.observations and not self.wiring: return None, set(), {} ids = self.block_ids() index_of = {block_id: i for i, block_id in enumerate(ids)} - recorded: Set[str] = set(self.taps or ()) + recorded: Set[str] = set(self.observations or ()) unknown = sorted(recorded - index_of.keys()) if unknown: - raise KeyError(f"unknown tap ids {unknown}; known block ids: {ids}") + raise KeyError( + f"unknown observation ids {unknown}; known block ids: {ids}") feeds: Dict[int, List[Tuple[str, str]]] = {} for target, source in (self.wiring or {}).items(): @@ -687,22 +692,22 @@ def forward(self, X: np.ndarray) -> np.ndarray: Process the input data through all modules in the sequence. """ ids, recorded, feeds = self._resolve_edges() - # a tap is a record of *this* pass: stale entries from an earlier - # run or an earlier taps= configuration must not survive it - self.tapped_.clear() + # an observation belongs to *this* pass: stale entries from an + # earlier run or an earlier observations= list must not survive it + self.observed_.clear() Y = X start = time.perf_counter() for index, processor in enumerate(self.module_list): # feed declared data edges before the block runs (wiring) for param, source in feeds.get(index, ()): - setattr(processor, param, self.tapped_[source]) + setattr(processor, param, self.observed_[source]) Y = processor(Y) - # record the output at declared taps (reference, no copy) + # retain the output at declared observations (reference, no copy) if ids is not None and ids[index] in recorded: - self.tapped_[ids[index]] = Y + self.observed_[ids[index]] = Y # run callback if needed # callbacks= may be explicitly None, and `key in None` used to @@ -717,16 +722,19 @@ def forward(self, X: np.ndarray) -> np.ndarray: self.elapsed_ = time.perf_counter() - start return Y - def tap(self, block_id: str) -> Any: + def observation(self, block_id: str) -> Any: """ - Return the signal recorded at a declared tap during the last run. + Return the output retained at a declared observation during the + last run. The signal comes back exactly as the block produced + it -- same shape, same dtype, no computation. """ - if block_id not in self.tapped_: + if block_id not in self.observed_: raise KeyError( - f"no signal recorded for tap {block_id!r}; recorded: " - f"{sorted(self.tapped_)}, declared taps: {self.taps or []} " - f"(declare the tap, then run the chain)") - return self.tapped_[block_id] + f"no signal retained for observation {block_id!r}; retained: " + f"{sorted(self.observed_)}, declared observations: " + f"{self.observations or []} " + f"(declare the observation, then run the chain)") + return self.observed_[block_id] def get_module_by_index(self, index: int) -> Processor: """ diff --git a/src/comnumpy/core/metrics.py b/src/comnumpy/core/metrics.py index e9f7427..ef41294 100644 --- a/src/comnumpy/core/metrics.py +++ b/src/comnumpy/core/metrics.py @@ -1180,7 +1180,7 @@ def signal_report(x: np.ndarray, compute_papr: bool = False, Summary statistics of a signal, as plain data. Replaces the former in-chain monitor blocks: extract the signal with - ``Sequential(taps=...)``, compute the report, and let the caller + ``Sequential(observations=...)``, compute the report, and let the caller decide how to present it (``logging``, table, assertion, ...). Signal Model diff --git a/src/comnumpy/core/utils.py b/src/comnumpy/core/utils.py index 4ee7ceb..a73ed10 100644 --- a/src/comnumpy/core/utils.py +++ b/src/comnumpy/core/utils.py @@ -163,7 +163,14 @@ def _construct_alphabet(modulation: str, order: int, mapping: str) -> np.ndarray if modulation == "PSK": n_bits = int(np.log2(order)) labels = _gray_labels(n_bits) if mapping == "gray" else np.arange(order) - return np.exp(2j * np.pi * np.arange(order) / order)[np.argsort(labels)] + # QPSK carries the pi/4 offset of the standards (3GPP, 802.11): + # points on the diagonals, the same set as QAM-4. The raw + # exp(2j*pi*m/M) sampling put them on the axes -- a constellation + # rotated by pi/4 from every textbook figure. BPSK and M >= 8 + # keep the phase-0 convention. + offset = np.pi / 4 if order == 4 else 0.0 + angles = 2 * np.pi * np.arange(order) / order + offset + return np.exp(1j * angles)[np.argsort(labels)] if modulation == "PAM": return _pam_levels(order, mapping).astype(complex) if modulation == "QAM": diff --git a/src/comnumpy/core/validators.py b/src/comnumpy/core/validators.py index 8248ab8..c6d9e3f 100644 --- a/src/comnumpy/core/validators.py +++ b/src/comnumpy/core/validators.py @@ -28,7 +28,7 @@ def validate_data(data: object) -> None: Validate that data is array-like (convertible to a numeric numpy array). Reference signals are plain arrays: extract them with - ``Sequential(taps=...)`` before configuring a data-aided block. + ``Sequential(observations=...)`` before configuring a data-aided block. Parameters ---------- @@ -47,7 +47,7 @@ def validate_data(data: object) -> None: if arr.dtype == object: raise TypeError( f"reference must be a numeric array, got dtype=object from {type(data)!r} " - "-- extract the reference signal with Sequential(taps=...) first.") + "-- extract the reference signal with Sequential(observations=...) first.") def validate_single_path(X: object, block: str, estimand: str) -> None: diff --git a/src/comnumpy/core/visualizers.py b/src/comnumpy/core/visualizers.py index ad71709..e4d870c 100644 --- a/src/comnumpy/core/visualizers.py +++ b/src/comnumpy/core/visualizers.py @@ -2,7 +2,7 @@ Visualization is deliberately *not* part of a chain: chains describe the communication system only, signals are extracted with -``Sequential(taps=...)`` and handed to the plain functions below. Every +``Sequential(observations=...)`` and handed to the plain functions below. Every function takes ``ax=None``, draws on the given axis (creating one only when needed) and returns it (decision D25); none of them calls ``plt.show()``. diff --git a/src/comnumpy/monte_carlo.py b/src/comnumpy/monte_carlo.py index 46e9ef5..baff08e 100644 --- a/src/comnumpy/monte_carlo.py +++ b/src/comnumpy/monte_carlo.py @@ -74,7 +74,7 @@ def _evaluate(chain: Sequential, params: list[str], point: Any, y = chain(stimulus) - target = chain.tap(reference) if reference is not None else None + target = chain.observation(reference) if reference is not None else None return {name: (metric(target, y) if target is not None else metric(y)) for name, metric in metrics.items()} @@ -103,15 +103,15 @@ def monte_carlo(chain: Sequential, Parameter values, one sweep point each. metrics : mapping of str to callable Metrics collected at every point. Called as ``metric(target, y)`` - when ``reference`` names a tapped block, else ``metric(y)``. + when ``reference`` names an observed block, else ``metric(y)``. Smaller-is-better quantities (BER, SER, EVM); there is no ``score()`` (decision D24). stimulus Input passed to the chain at every point (e.g. a symbol count). reference : str, optional, keyword-only - Block id whose tapped output is the metric target. The tap is + Block id whose observed output is the metric target. The observation is declared on the chain if it is not already - (:attr:`Sequential.taps`). + (:attr:`Sequential.observations`). seed : int, optional, keyword-only Master seed; every sweep point gets an independent child seed through the chain (decisions D6/D35), so the whole curve is @@ -185,8 +185,8 @@ def monte_carlo(chain: Sequential, (2,) """ params = [param] if isinstance(param, str) else list(param) - if reference is not None and reference not in (chain.taps or []): - chain.taps = (chain.taps or []) + [reference] + if reference is not None and reference not in (chain.observations or []): + chain.observations = (chain.observations or []) + [reference] if seed is None: # Not an error: a first look at a curve does not need to be # reproducible. But a curve nobody can reproduce is not a result, diff --git a/src/comnumpy/ofdm/visualizers.py b/src/comnumpy/ofdm/visualizers.py index 1ce4aad..b19db99 100644 --- a/src/comnumpy/ofdm/visualizers.py +++ b/src/comnumpy/ofdm/visualizers.py @@ -1,7 +1,7 @@ """Plotting functions for frequency-domain OFDM signals. Like the core visualizers, these are plain functions operating on -arrays extracted with ``Sequential(taps=...)`` -- never in-chain +arrays extracted with ``Sequential(observations=...)`` -- never in-chain blocks. Every function takes ``ax=None`` and returns the axis (decision D25). """ diff --git a/src/comnumpy/serialization.py b/src/comnumpy/serialization.py index aecf95b..d78de21 100644 --- a/src/comnumpy/serialization.py +++ b/src/comnumpy/serialization.py @@ -188,8 +188,8 @@ def to_json(chain: Sequential, path: str | pathlib.Path | None = None, document: dict[str, Any] = {"comnumpy": FORMAT_VERSION, "blocks": blocks} # chain-level metadata is intent too: dropping it would rebuild a chain # that no longer records or feeds what its author declared - if chain.taps: - document["taps"] = list(chain.taps) + if chain.observations: + document["observations"] = list(chain.observations) if chain.wiring: document["wiring"] = dict(chain.wiring) if arrays: @@ -283,5 +283,5 @@ def from_json(source: str | pathlib.Path, f"{entry['inputs']}; only linear chains are supported") return Sequential([_instantiate(entry, arrays) for entry in blocks], - taps=document.get("taps"), + observations=document.get("observations"), wiring=document.get("wiring")) diff --git a/tests/core/test_alphabet.py b/tests/core/test_alphabet.py index f97a0d6..044ab69 100644 --- a/tests/core/test_alphabet.py +++ b/tests/core/test_alphabet.py @@ -50,6 +50,13 @@ def test_the_tables_are_reproduced_entry_by_entry(self): for mapping in ("gray", "bin"): with self.subTest(alphabet=f"{family}-{order}-{mapping}"): tabulated = load_reference(family, order, mapping) + if family == "PSK" and order == 4: + # the one deliberate deviation from the tables: + # QPSK carries the pi/4 offset of the standards + # (points on the diagonals), where the table put + # it on the axes. The labelling is untouched, so + # the relationship is exactly a global rotation. + tabulated = tabulated * np.exp(1j * np.pi / 4) built = _construct_alphabet(family, order, mapping) np.testing.assert_allclose(built, tabulated, atol=1e-6) diff --git a/tests/core/test_awgn_performance.py b/tests/core/test_awgn_performance.py index fec5859..e003d89 100644 --- a/tests/core/test_awgn_performance.py +++ b/tests/core/test_awgn_performance.py @@ -23,13 +23,13 @@ def test_one_shot(self): SymbolMapper(alphabet), AWGN(snr_dB=SNR_dB), SymbolDemapper(alphabet), - ], taps=["tx"]) + ], observations=["tx"]) # run chain y = chain(N) # evaluate metrics - data_tx = chain.tap("tx") + data_tx = chain.observation("tx") ser = compute_ser(data_tx, y) snr_per_bit = (10**(SNR_dB/10))/np.log2(M) diff --git a/tests/core/test_capacity.py b/tests/core/test_capacity.py index d7470fc..de47853 100644 --- a/tests/core/test_capacity.py +++ b/tests/core/test_capacity.py @@ -73,7 +73,7 @@ def test_quadrature_meets_its_documented_accuracy(self): constellation makes the integrand sharper -- so the claim is a table, not a single number, and this is what pins it. """ - for mod, order, promised in (("PSK", 4, 1.6e-7), ("QAM", 16, 1.2e-6), + for mod, order, promised in (("PSK", 4, 1.9e-5), ("QAM", 16, 1.2e-6), ("QAM", 64, 2.1e-5)): with self.subTest(modulation=f"{mod}-{order}"): alphabet = get_alphabet(mod, order) diff --git a/tests/core/test_taps.py b/tests/core/test_chain_observations.py similarity index 56% rename from tests/core/test_taps.py rename to tests/core/test_chain_observations.py index 7b2929c..f0f5aa5 100644 --- a/tests/core/test_taps.py +++ b/tests/core/test_chain_observations.py @@ -1,4 +1,4 @@ -"""Chain taps: signal probes as chain metadata, not in-chain blocks.""" +"""Chain observations: retained outputs as chain metadata, not in-chain blocks.""" import unittest import numpy as np @@ -19,35 +19,35 @@ def build(self, **kwargs): ], **kwargs) def test_tap_records_the_block_output(self): - """A tap records exactly the tapped block's output, untouched.""" + """An observation retains exactly the observed block output, untouched.""" alphabet = get_alphabet("QAM", 16) - chain = self.build(taps=["generator", "symbol_mapper"]).seed(3) + chain = self.build(observations=["generator", "symbol_mapper"]).seed(3) chain(1000) - symbols = chain.tap("generator") - # the mapper tap must be the exact image of the generator tap - np.testing.assert_array_equal(chain.tap("symbol_mapper"), + symbols = chain.observation("generator") + # the mapper observation must be the exact image of the generator one + np.testing.assert_array_equal(chain.observation("symbol_mapper"), alphabet[symbols]) - # taps do not change the chain output: same seed, no taps + # observations do not change the chain output: same seed, none declared y_ref = self.build().seed(3)(1000) np.testing.assert_array_equal( - self.build(taps=["generator"]).seed(3)(1000), y_ref) + self.build(observations=["generator"]).seed(3)(1000), y_ref) def test_module_list_stays_pure(self): """The chain description contains communication blocks only.""" - chain = self.build(taps=["generator", "noise"]) + chain = self.build(observations=["generator", "noise"]) self.assertEqual(len(chain.module_list), 4) chain(100) - self.assertEqual(sorted(chain.tapped_), ["generator", "noise"]) + self.assertEqual(sorted(chain.observed_), ["generator", "noise"]) def test_unknown_tap_rejected_at_run(self): - chain = self.build(taps=["nope"]) + chain = self.build(observations=["nope"]) with self.assertRaises(KeyError): chain(10) def test_tap_before_run_raises(self): - chain = self.build(taps=["generator"]) + chain = self.build(observations=["generator"]) with self.assertRaises(KeyError): - chain.tap("generator") + chain.observation("generator") if __name__ == "__main__": diff --git a/tests/core/test_channel_info_plot.py b/tests/core/test_channel_info_plot.py index f844207..4c6cd4b 100644 --- a/tests/core/test_channel_info_plot.py +++ b/tests/core/test_channel_info_plot.py @@ -94,20 +94,20 @@ def test_the_coherence_bandwidth_is_the_one_the_profile_computes(self): class TestDrawingAChannel(unittest.TestCase): def setUp(self): - self.taps = np.array([1.0, 0.0, 0.5]) + self.observations = np.array([1.0, 0.0, 0.5]) self.addCleanup(plt.close, "all") def test_the_delay_domain_is_a_stem_per_tap(self): - ax = plot_channel_response(self.taps) + ax = plot_channel_response(self.observations) self.assertEqual(ax.get_xlabel(), "tap index") self.assertEqual(ax.get_ylabel(), "$|h[l]|$") def test_a_sampling_rate_turns_the_abscissa_into_a_delay(self): - ax = plot_channel_response(self.taps, fs=7.68e6) + ax = plot_channel_response(self.observations, fs=7.68e6) self.assertEqual(ax.get_xlabel(), "delay [us]") def test_the_frequency_domain_is_a_curve(self): - ax = plot_channel_response(self.taps, domain="frequency") + ax = plot_channel_response(self.observations, domain="frequency") self.assertEqual(ax.get_ylabel(), "$|H(f)|$") drawn = ax.lines[0].get_ydata() # the swing of |1 + 0.5 e^{-2j pi 2 f}| over the band @@ -115,7 +115,7 @@ def test_the_frequency_domain_is_a_curve(self): self.assertAlmostEqual(float(np.min(drawn)), 0.5, places=3) def test_decibels_are_twenty_log_of_the_same_curve(self): - ax = plot_channel_response(self.taps, domain="frequency", scale="dB") + ax = plot_channel_response(self.observations, domain="frequency", scale="dB") self.assertEqual(ax.get_ylabel(), "$|H(f)|$ [dB]") drawn = ax.lines[0].get_ydata() self.assertAlmostEqual(float(np.max(drawn)), @@ -127,15 +127,15 @@ def test_a_zero_tap_does_not_send_the_axis_to_minus_infinity(self): def test_it_refuses_a_domain_it_cannot_draw(self): with self.assertRaises(ValueError): - plot_channel_response(self.taps, domain="phase") + plot_channel_response(self.observations, domain="phase") def test_it_refuses_a_scale_it_cannot_draw(self): with self.assertRaises(ValueError): - plot_channel_response(self.taps, scale="log") + plot_channel_response(self.observations, scale="log") def test_a_channel_draws_itself_on_the_axis_it_is_given(self): _, ax = plt.subplots() - returned = FIRChannel(self.taps).plot("frequency", scale="dB", ax=ax) + returned = FIRChannel(self.observations).plot("frequency", scale="dB", ax=ax) self.assertIs(returned, ax) self.assertEqual(ax.get_ylabel(), "$|H(f)|$ [dB]") diff --git a/tests/core/test_compensators_shared.py b/tests/core/test_compensators_shared.py index 5ab26c4..92d4ccc 100644 --- a/tests/core/test_compensators_shared.py +++ b/tests/core/test_compensators_shared.py @@ -106,9 +106,9 @@ def test_the_reference_arrives_through_wiring(self): Amplifier(2 * np.exp(1j * np.pi / 5), name="channel"), DataAidedComplexGainCompensator(reference=np.zeros(1), shared=True, name="gain")], - taps=["tx"], wiring={"gain.reference": "tx"}) + observations=["tx"], wiring={"gain.reference": "tx"}) out = chain.seed(0)((2, 128)) - self.assertLess(np.abs(out - chain.tapped_["tx"]).max(), 1e-12) + self.assertLess(np.abs(out - chain.observed_["tx"]).max(), 1e-12) if __name__ == "__main__": diff --git a/tests/core/test_fading_theory.py b/tests/core/test_fading_theory.py index b0c4f0f..65d3301 100644 --- a/tests/core/test_fading_theory.py +++ b/tests/core/test_fading_theory.py @@ -163,7 +163,7 @@ def simulate(self, chain, n_rx, n_tx, snr_dB, stimulus, seed=0): chain.set_params(channel__H=realization, detector__H=realization) detected = chain(stimulus) - errors += int(np.sum(chain.tap("tx") != detected)) + errors += int(np.sum(chain.observation("tx") != detected)) total += detected.size return errors / total @@ -175,7 +175,7 @@ def linear_chain(self, H): FlatMIMOChannel(H, name="channel"), AWGN(sigma2=0.1, name="noise"), LinearDetector(alphabet, H=H, name="detector"), - ], taps=["tx"]) + ], observations=["tx"]) def test_one_antenna_each_side_matches_diversity_one(self): chain = self.linear_chain(rayleigh_channel(1, 1, seed=0)) @@ -222,7 +222,7 @@ def test_alamouti_matches_two_branches_at_half_the_branch_snr(self): SpaceTimeDecoder(code, H=H, name="detector"), Amplifier(1 / power), SymbolDemapper(alphabet), - ], taps=["tx"]) + ], observations=["tx"]) for snr_dB in self.SNR_DB: measured = self.simulate(chain, 1, 2, snr_dB, self.N_SYMBOLS) theory = compute_ser_rayleigh_psk( diff --git a/tests/core/test_fir_channel.py b/tests/core/test_fir_channel.py index bc8cdc3..b90ebae 100644 --- a/tests/core/test_fir_channel.py +++ b/tests/core/test_fir_channel.py @@ -29,10 +29,10 @@ def _compute_equalizer_ser(self, equalizer): AWGN(sigma2=self.sigma2), equalizer, SymbolDemapper(self.alphabet), - ], taps=["tx"]) + ], observations=["tx"]) y = chain(self.N) - data_tx = chain.tap("tx") + data_tx = chain.observation("tx") return compute_ser(data_tx, y) def test_zf_one_shot(self): diff --git a/tests/core/test_gray_labelling.py b/tests/core/test_gray_labelling.py index 41a7b75..3d93385 100644 --- a/tests/core/test_gray_labelling.py +++ b/tests/core/test_gray_labelling.py @@ -95,10 +95,10 @@ def test_tail_ber_matches_the_gray_closed_form(self): SymbolMapper(constellation), AWGN(snr_dB=float(ebn0_to_snr_dB(11.0, bits_per_symbol=4))), SymbolDemapper(constellation), - ], taps=["tx"]) + ], observations=["tx"]) chain.seed(3) detected = chain(400000) - measured = float(compute_ber(chain.tap("tx"), detected, width=4)) + measured = float(compute_ber(chain.observation("tx"), detected, width=4)) theory = float(constellation.metrics(11.0, per="bit")["ber"]) self.assertGreater(measured, 0.0, "no errors: raise N or the SNR") self.assertLess(abs(measured - theory) / theory, 0.20, diff --git a/tests/core/test_observation.py b/tests/core/test_observation.py index d42351f..1819045 100644 --- a/tests/core/test_observation.py +++ b/tests/core/test_observation.py @@ -1,7 +1,7 @@ """Observation surface: plotting functions and signal_report. Since the Recorder/Logger/Scope blocks are gone, observation is made of -plain functions applied to arrays extracted with ``Sequential(taps=...)``. +plain functions applied to arrays extracted with ``Sequential(observations=...)``. These tests pin the contracts those functions must honour: return the axis, never call ``plt.show()``, never touch the input. """ diff --git a/tests/core/test_shaping.py b/tests/core/test_shaping.py index 7423e16..d0bbd24 100644 --- a/tests/core/test_shaping.py +++ b/tests/core/test_shaping.py @@ -705,16 +705,16 @@ def test_the_shaped_link_recovers_its_bits_and_is_reproducible(self): AWGN(snr_dB=30.0, name="noise"), AmplitudeDemapper(AMPLITUDES), DistributionDematcher(shaper), - ], taps=["bits", "mapper"]) + ], observations=["bits", "mapper"]) link.seed(2) recovered = link(20 * shaper.n_bits) - np.testing.assert_array_equal(recovered, link.tap("bits")) - signed = link.tap("mapper") + np.testing.assert_array_equal(recovered, link.observation("bits")) + signed = link.observation("mapper") # the sign block is stochastic, so D6 must reach it: same seed, # same signs, not merely the same magnitudes link.seed(2) np.testing.assert_allclose(link(20 * shaper.n_bits), recovered) - np.testing.assert_allclose(link.tap("mapper"), signed) + np.testing.assert_allclose(link.observation("mapper"), signed) class TestShapedSource(unittest.TestCase): diff --git a/tests/core/test_simple_chain.py b/tests/core/test_simple_chain.py index e12c26a..e774788 100644 --- a/tests/core/test_simple_chain.py +++ b/tests/core/test_simple_chain.py @@ -21,13 +21,13 @@ def test_one_shot(self): SymbolGenerator(M, name="tx"), SymbolMapper(alphabet), SymbolDemapper(alphabet), - ], taps=["tx"]) + ], observations=["tx"]) # run chain y = chain(N) # evaluate metrics - data_tx = chain.tap("tx") + data_tx = chain.observation("tx") ser = compute_ser(data_tx, y) np.testing.assert_allclose(ser, 0, atol=1e-8) diff --git a/tests/core/test_wiring.py b/tests/core/test_wiring.py index 2fe49fb..67b393d 100644 --- a/tests/core/test_wiring.py +++ b/tests/core/test_wiring.py @@ -37,14 +37,14 @@ def test_reference_follows_the_current_run(self): """The regression this exists for: no stale reference across runs.""" # seeded (D6): at 20 dB a 16-QAM symbol error is rare but not # impossible, and an unseeded chain made this assertion flaky - chain = self.build(taps=["tx"], wiring={"comp.reference": "ref"}).seed(7) + chain = self.build(observations=["tx"], wiring={"comp.reference": "ref"}).seed(7) for _ in range(3): y = chain(2000) # the compensator saw this pass's reference, not a previous one np.testing.assert_array_equal(chain["comp"].reference, - chain.tapped_["ref"]) + chain.observed_["ref"]) self.assertAlmostEqual(chain["comp"].theta_, -PHASE, places=1) - self.assertEqual(compute_ser(chain.tap("tx"), y), 0.0) + self.assertEqual(compute_ser(chain.observation("tx"), y), 0.0) def test_frozen_reference_would_be_stale(self): """Without wiring the reference is frozen -- documents the trap.""" @@ -55,10 +55,10 @@ def test_frozen_reference_would_be_stale(self): chain(500) np.testing.assert_array_equal(chain["comp"].reference, frozen) - def test_source_is_tapped_automatically(self): + def test_source_is_observed_automatically(self): chain = self.build(wiring={"comp.reference": "ref"}) chain(100) - self.assertIn("ref", chain.tapped_) + self.assertIn("ref", chain.observed_) def test_module_list_stays_pure(self): chain = self.build(wiring={"comp.reference": "ref"}) @@ -79,22 +79,22 @@ def test_backward_edge_rejected(self): def test_graph_shows_the_data_edge(self): """A picture of the chain must show every edge it actually has.""" - chain = self.build(taps=["tx"], wiring={"comp.reference": "ref"}) + chain = self.build(observations=["tx"], wiring={"comp.reference": "ref"}) model = chain.graph() self.assertEqual(model["data_edges"], [("ref", "comp", "reference")]) self.assertEqual(len(model["signal_edges"]), 5) - self.assertEqual(model["taps"], ["tx"]) + self.assertEqual(model["observations"], ["tx"]) mermaid = chain.to_mermaid() self.assertIn("ref -.->|reference| comp", mermaid) # dashed, labelled - self.assertIn("class tx tapped", mermaid) + self.assertIn("class tx observed", mermaid) # the signal path is still drawn with solid arrows self.assertIn("tx --> ref", mermaid) def test_graph_without_wiring_has_no_data_edge(self): model = self.build().graph() self.assertEqual(model["data_edges"], []) - self.assertEqual(model["taps"], []) + self.assertEqual(model["observations"], []) def test_reference_may_be_left_to_the_chain(self): """A wired block is built without one: there is nothing to pass yet.""" @@ -119,11 +119,11 @@ def test_missing_reference_raises_at_the_call(self): def test_profiling_runs_the_same_pass(self): """Profiling must feed the wiring too, or it profiles nothing.""" - chain = self.build(taps=["tx"], wiring={"comp.reference": "ref"}).seed(7) + chain = self.build(observations=["tx"], wiring={"comp.reference": "ref"}).seed(7) profile = chain.profile_execution_time(200) self.assertEqual(list(profile), chain.block_ids()) np.testing.assert_array_equal(chain["comp"].reference, - chain.tapped_["ref"]) + chain.observed_["ref"]) def test_every_pass_records_its_own_wall_time(self): """`elapsed_` spares the caller a stopwatch around the call.""" diff --git a/tests/mimo/test_mimo_channel.py b/tests/mimo/test_mimo_channel.py index ce6e5ec..4bd7fbf 100644 --- a/tests/mimo/test_mimo_channel.py +++ b/tests/mimo/test_mimo_channel.py @@ -31,10 +31,10 @@ def _compute_detector_ser(self, detector): FlatMIMOChannel(self.H), AWGN(sigma2=self.sigma2), detector - ], taps=["tx"]) + ], observations=["tx"]) Y = chain((self.N_t, self.N)) - data_tx = chain.tap("tx") + data_tx = chain.observation("tx") return compute_ser(data_tx, Y) def test_zf_one_shot(self): diff --git a/tests/ofdm/test_ofdm_simple_chain.py b/tests/ofdm/test_ofdm_simple_chain.py index 21fb61c..27f7615 100644 --- a/tests/ofdm/test_ofdm_simple_chain.py +++ b/tests/ofdm/test_ofdm_simple_chain.py @@ -56,7 +56,7 @@ def build_manual_chain(self): CarrierExtractor(self.carrier_type), Parallel2Serial(), SymbolDemapper(self.alphabet) - ], taps=["data_tx"]) + ], observations=["data_tx"]) def build_modular_chain(self): return Sequential([ @@ -77,13 +77,13 @@ def build_modular_chain(self): h=self.h ), SymbolDemapper(self.alphabet) - ], taps=["data_tx"]) + ], observations=["data_tx"]) def test_manual_chain_ser_zero(self): """Test manual OFDM chain with SER=0 under perfect channel knowledge.""" chain = self.build_manual_chain() y = chain(self.N) - data_tx = chain.tap("data_tx") + data_tx = chain.observation("data_tx") ser = compute_ser(data_tx, y) np.testing.assert_allclose(ser, 0, atol=1e-8) @@ -91,7 +91,7 @@ def test_modular_chain_ser_zero(self): """Test modular OFDM chain using OFDMTransmitter and OFDMReceiver.""" chain = self.build_modular_chain() y = chain(self.N) - data_tx = chain.tap("data_tx") + data_tx = chain.observation("data_tx") ser = compute_ser(data_tx, y) np.testing.assert_allclose(ser, 0, atol=1e-8) diff --git a/tests/ofdm/test_theory.py b/tests/ofdm/test_theory.py index 544d098..b1be97f 100644 --- a/tests/ofdm/test_theory.py +++ b/tests/ofdm/test_theory.py @@ -136,13 +136,13 @@ def run_chain(self, L): FrequencyDomainEqualizer(h=h), CarrierExtractor(self.alloc), Parallel2Serial(), - ], taps=["tx"]) + ], observations=["tx"]) chain.seed(SEED) n = self.N_OFDM_SYMBOLS * self.alloc.N_data # the FIR channel lengthens the stream by L-1 samples, so the # receiver's Serial2Parallel emits one extra (zero-padded) block y = chain(n)[:n] - tx = chain.tap("tx") + tx = chain.observation("tx") x = self.alphabet[tx] nmse = float(np.sum(np.abs(y - x) ** 2) / np.sum(np.abs(x) ** 2)) decided = np.argmin(np.abs(y[:, None] - self.alphabet[None, :]), axis=-1) @@ -219,14 +219,14 @@ def snr_ratio(self, N_cp, snr_dB=10): FFTProcessor(), CarrierExtractor(self.alloc), Parallel2Serial(), - ], taps=["tx", "tx_time"]) + ], observations=["tx", "tx_time"]) chain.seed(SEED) n = self.N_OFDM_SYMBOLS * self.alloc.N_data y = chain(n)[:n] - x = self.alphabet[chain.tap("tx")] + x = self.alphabet[chain.observation("tx")] # the AWGN block sets sigma2 from the power it measures on its own # input, i.e. on the time-domain stream, cyclic prefix included - snr_time = float(np.mean(np.abs(chain.tap("tx_time")) ** 2)) \ + snr_time = float(np.mean(np.abs(chain.observation("tx_time")) ** 2)) \ / chain["noise"].sigma2_ snr_sub = float(np.mean(np.abs(x) ** 2)) \ / float(np.mean(np.abs(y - x) ** 2)) diff --git a/tests/optical/test_link_budget.py b/tests/optical/test_link_budget.py index c703603..da2d754 100644 --- a/tests/optical/test_link_budget.py +++ b/tests/optical/test_link_budget.py @@ -96,9 +96,9 @@ def test_the_link_really_adds_what_it_budgets(self): link = FiberLink(5, L_span=80.0, NF_dB=5.0, fs=64e9, use_only_linear=True, name="link") chain = Sequential([GaussianGenerator(dbm_to_watt(0.0), name="tx"), - link], taps=["tx"]).seed(0) + link], observations=["tx"]).seed(0) y = chain(400000) - measured = float(np.var(y) - np.var(chain.tap("tx"))) + measured = float(np.var(y) - np.var(chain.observation("tx"))) predicted = link.budget(link.fs)["ase_power_W"] self.assertAlmostEqual(10 * np.log10(measured / predicted), 0.0, delta=0.1) diff --git a/tests/test_audit_regressions.py b/tests/test_audit_regressions.py index 6626d24..98eb9de 100644 --- a/tests/test_audit_regressions.py +++ b/tests/test_audit_regressions.py @@ -61,10 +61,10 @@ def test_summary_feeds_wiring_and_records_taps(self): rows = wired.summary(64, print_out=False) self.assertEqual(len(rows), 3) - tapped = Sequential([SymbolGenerator(4, name="generator"), - AWGN(sigma2=0.1)], taps=["generator"]) - tapped.summary(8, print_out=False) - self.assertEqual(tapped.tap("generator").shape, (8,)) + observed = Sequential([SymbolGenerator(4, name="generator"), + AWGN(sigma2=0.1)], observations=["generator"]) + observed.summary(8, print_out=False) + self.assertEqual(observed.observation("generator").shape, (8,)) def test_an_int_keyed_callback_fires(self): fired = [] @@ -74,12 +74,12 @@ def test_an_int_keyed_callback_fires(self): def test_a_tap_does_not_survive_its_reconfiguration(self): chain = Sequential([SymbolGenerator(4, name="generator"), - AWGN(sigma2=0.1)], taps=["generator", "awgn"]) + AWGN(sigma2=0.1)], observations=["generator", "awgn"]) chain(8) - chain.taps = ["awgn"] + chain.observations = ["awgn"] chain(8) with self.assertRaises(KeyError): - chain.tap("generator") + chain.observation("generator") def test_a_nested_chain_round_trips_through_json(self): chain = Sequential([SymbolGenerator(4, seed=1), diff --git a/tests/test_data_display.py b/tests/test_data_display.py index 787a16f..4de9c4a 100644 --- a/tests/test_data_display.py +++ b/tests/test_data_display.py @@ -238,7 +238,7 @@ def test_a_sweep_result_is_a_curves_dictionary(self): SymbolMapper(constellation), AWGN(snr_dB=0, name="awgn"), SymbolDemapper(constellation), - ], taps=["tx"]) + ], observations=["tx"]) snr_dB = np.arange(0, 9, 4) curves = monte_carlo(chain, "awgn.snr_dB", snr_dB, {"ser": compute_ser}, 2000, diff --git a/tests/test_monte_carlo_parallel.py b/tests/test_monte_carlo_parallel.py index 25badd6..a768020 100644 --- a/tests/test_monte_carlo_parallel.py +++ b/tests/test_monte_carlo_parallel.py @@ -31,7 +31,7 @@ def build_chain(order=4): SymbolMapper(alphabet), AWGN(snr_dB=0.0, name="noise"), SymbolDemapper(alphabet), - ], taps=["tx"]) + ], observations=["tx"]) def mean_power(y): diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 5fa7148..d6abd19 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -116,7 +116,7 @@ def test_exceptions_exported(self): self.assertTrue(issubclass(comnumpy.ShapeError, comnumpy.ComnumpyError)) def test_no_in_chain_instrumentation_block(self): - """Observation is done with taps; instrumentation blocks are gone. + """Observation is a chain declaration; instrumentation blocks are gone. A chain must describe the communication system only, so no recorder/logger/scope/monitor block may come back into the public @@ -140,7 +140,7 @@ def test_no_in_chain_instrumentation_block(self): f"{module.__name__} defines {name}") def test_chain_output_is_reachable_without_instrumentation(self): - """The canonical flow: name a block, tap it, read it back.""" + """The canonical flow: name a block, observe it, read it back.""" from comnumpy import (AWGN, Sequential, SymbolDemapper, SymbolGenerator, SymbolMapper, compute_ser, get_alphabet) alphabet = get_alphabet("QAM", 16) @@ -149,9 +149,9 @@ def test_chain_output_is_reachable_without_instrumentation(self): SymbolMapper(alphabet), AWGN(snr_dB=15, seed=123), SymbolDemapper(alphabet), - ], taps=["tx"]) + ], observations=["tx"]) detected = chain(10_000) - self.assertLess(compute_ser(chain.tap("tx"), detected), 0.05) + self.assertLess(compute_ser(chain.observation("tx"), detected), 0.05) if __name__ == "__main__": diff --git a/tests/test_serialization.py b/tests/test_serialization.py index 9ee2d17..0f61129 100644 --- a/tests/test_serialization.py +++ b/tests/test_serialization.py @@ -143,7 +143,7 @@ def test_field_role_survives_as_an_enum(self): self.assertEqual(role.name, "SYNC") def test_chain_metadata_survives(self): - """taps and wiring are intent: a rebuilt chain still records/feeds.""" + """observations and wiring are intent: a rebuilt chain still records/feeds.""" alphabet = get_alphabet("QAM", 16) chain = Sequential([ SymbolGenerator(16, seed=5, name="tx"), @@ -152,17 +152,17 @@ def test_chain_metadata_survives(self): AWGN(sigma2=0.01, seed=6), DataAidedPhaseCompensator(reference=np.zeros(1), name="comp"), SymbolDemapper(alphabet), - ], taps=["tx"], wiring={"comp.reference": "ref"}) + ], observations=["tx"], wiring={"comp.reference": "ref"}) self.run_twice_and_compare(chain, 500, npz=True) with tempfile.TemporaryDirectory() as tmp: npz = Path(tmp) / "arrays.npz" rebuilt = from_json(to_json(chain, npz_path=npz), npz_path=npz) - self.assertEqual(rebuilt.taps, ["tx"]) + self.assertEqual(rebuilt.observations, ["tx"]) self.assertEqual(rebuilt.wiring, {"comp.reference": "ref"}) # the rebuilt chain is still usable end to end y = rebuilt(500) - self.assertEqual(compute_ser(rebuilt.tap("tx"), y), 0.0) + self.assertEqual(compute_ser(rebuilt.observation("tx"), y), 0.0) def test_complex_scalar_params_roundtrip(self): """Complex gains are ordinary in this domain; JSON has no complex.""" diff --git a/validation/mimo_diversity_ber.py b/validation/mimo_diversity_ber.py index a61a94c..3069070 100644 --- a/validation/mimo_diversity_ber.py +++ b/validation/mimo_diversity_ber.py @@ -65,7 +65,7 @@ def simulate(chain, n_rx, n_tx, snr_dB, stimulus, n_channels, seed): chain.set_params(channel__H=realization, detector__H=realization) detected = chain(stimulus) - errors += int(np.sum(chain.tap("tx") != detected)) + errors += int(np.sum(chain.observation("tx") != detected)) total += detected.size return errors / total @@ -78,7 +78,7 @@ def linear_chain(H): FlatMIMOChannel(H, name="channel"), AWGN(sigma2=0.1, name="noise"), LinearDetector(ALPHABET, H=H, name="detector"), - ], taps=["tx"]) + ], observations=["tx"]) def alamouti_chain(H, code): @@ -93,7 +93,7 @@ def alamouti_chain(H, code): SpaceTimeDecoder(code, H=H, name="detector"), Amplifier(1 / power), SymbolDemapper(ALPHABET), - ], taps=["tx"]) + ], observations=["tx"]) def theory(diversity, branch_split=1): diff --git a/validation/optical_dbp_hager.py b/validation/optical_dbp_hager.py index 8a808d2..a9e7391 100644 --- a/validation/optical_dbp_hager.py +++ b/validation/optical_dbp_hager.py @@ -126,7 +126,7 @@ def get_channel(link, *, linear_only): NF_dB=NF_DB, fs=fs, use_only_linear=linear_only, name="link"), Downsampler(ratio, use_filter=True), - ], taps=["tx"]) + ], observations=["tx"]) def get_receiver(link, steps, linear_only, step_type, reference): @@ -165,7 +165,7 @@ def run(name, link): channel.seed(index * N_TRIALS + trial) channel.set_params(launch__gain=amplitude) fields[is_linear] = channel(N_SYMBOLS) - sent = linear.tap("tx") + sent = linear.observation("tx") for column, (_, steps, only_linear, step_type) in enumerate(RECEIVERS): receiver = get_receiver(link, steps, only_linear, step_type,