Skip to content

Commit c4fdee4

Browse files
ehsun-shclaude
andcommitted
Add transmitter and receiver: PRBS, NRZ, MZM, PIN
Completes the Phase 1 signal chain end to end: PRBS -> NRZ driver -> CW laser -> MZM -> fiber -> PIN. - PRBSGenerator: maximal-length LFSR, standard polynomials for orders 7/9/11/15/23/31 (ITU-T O.150, IEEE 802.3). - NRZDriver, DCVoltage: binary to electrical waveform; the DC source exists to sweep a modulator's transfer curve the way a bench measurement would. - MachZehnderModulator: push-pull, P_out/P_in = cos^2(pi*V/(2*V_pi)), with the finite extinction ratio as a floor so the measured null depth equals the declared ER exactly. First component with two differently-typed input ports, which is what the port type system was for. - PINPhotodiode: I = R*P + I_dark, shot noise per sample (variance tracks the instantaneous current), thermal noise over B = fs/2. New: BinarySignal (bits per symbol, unsampled, so a receiver can compare decided bits without undoing a pulse shape); ElectricalSignal now carries its unit, so a volts-vs-amperes mix-up is visible rather than assumed. BoolParam, as a separate descriptor rather than a mode on Param, so float parameters still type as float at every use site. si() refuses flags. Fixes a reproducibility bug the PRBS tests caught: automatic component labels came from a process-global counter, and since a label seeds that component's random stream, the same script produced different noise on every run. Labels are now derived from the component's position among its own class within the graph, so they depend on the graph alone. Regression tests cover both the labels and the end-to-end consequence. 54 new tests (139 total): PRBS period, mark balance, and window uniqueness; MZM transfer curve, extinction ratio, insertion loss, bias, periodicity; PIN responsivity, shot variance 2qIB, thermal variance 4kTB/R_L, and that the two add in variance. Also corrects docs/ARCHITECTURE.md, which still showed the signal containers as mutable lists. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 51af6f2 commit c4fdee4

13 files changed

Lines changed: 937 additions & 25 deletions

README.md

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,12 @@
1212
> ### ⚠️ Project status: pre-alpha, Phase 0
1313
>
1414
> The engine core exists and is tested: simulation context, multi-band optical signal model,
15-
> typed ports, component/parameter system, and the block-mode scheduler. Implemented components:
16-
> CW laser, Gaussian pulse source, fiber (attenuation + chromatic dispersion), combiner,
17-
> attenuator, power meter — with the physics validated against closed-form results in CI.
15+
> typed ports, component/parameter system, and the block-mode scheduler. The transmitter-to-receiver
16+
> chain runs end to end — PRBS → NRZ driver → CW laser → MZM → fiber (loss + dispersion) → PIN —
17+
> with every physics block validated against closed-form results in CI.
1818
>
19-
> **Not implemented yet:** modulators, photodetectors, SSFM/nonlinearity, PMD, amplifiers, DSP,
20-
> BER/eye analysis, and the GUI. Those are Phases 1–2; see the [roadmap](#roadmap).
19+
> **Not implemented yet:** eye/BER analysis, SSFM/nonlinearity, PMD, amplifiers, equalisers,
20+
> coherent detection, and the GUI. See the [roadmap](#roadmap).
2121
>
2222
> This is not yet a useful simulator. It is a foundation with the expensive decisions made and
2323
> tested. Criticism of those decisions is worth more right now than any feature —
@@ -199,7 +199,7 @@ time window, and results are reproducible.
199199
| Phase | Scope | Estimate¹ |
200200
| :--- | :--- | :--- |
201201
| **0 — Foundations** *(in progress)* | ✅ Signal model, context, port types, component base, scheduler, CI · ⬜ project file format, sweeps | ~1 month |
202-
| **1 — MVP: linear link** | PRBS → NRZ → CW laser → MZM → fiber (α + CD) → PIN eye/BER. **Python only, no GUI.** Full analytical validation suite. | ~2–3 months |
202+
| **1 — MVP: linear link** *(in progress)* |PRBS → NRZ → CW laser → MZM → fiber (α + CD) → PIN · ⬜ eye diagram, Q-factor, BER. **Python only, no GUI.** | ~2–3 months |
203203
| **1.5 — Nonlinear & amplified** | Adaptive-step SSFM, Kerr, PMD, EDFA (gain/NF/saturation/ASE), APD | ~2 months |
204204
| **2 — GUI & DSP** | Graph editor, plots, pulse shaping, FIR, equalizers (LMS/CMA), OSA, constellation, sweeps | ~3–4 months |
205205
| **3 — Coherent & WDM** | IQ mod, M-QAM, LO, 90° hybrid, balanced detection, coherent DSP, DWDM + crosstalk, 400G/800G references, CuPy back-end | ~6 months |
@@ -226,10 +226,11 @@ Every physics block ships with a test against a closed-form result, run in CI
226226
| Chirped Gaussian | `T₁/T₀ = √((1 + Cβ₂z/T₀²)² + (β₂z/T₀²)²)` — pins the sign of β₂ ||
227227
| Dispersion compensation | `+D` then `−D` restores the input sample-for-sample ||
228228
| GVD | Energy conserved (Parseval); β₂ = −Dλ²/2πc per band ||
229+
| PRBS | Period `2ⁿ−1`; `2ⁿ⁻¹` marks; every n-bit window appears once ||
230+
| Ideal push-pull MZM | `P_out/P_in = cos²(πV / 2V_π)`; null depth equals the declared ER ||
231+
| PIN detector | `I = R·P`; shot `σ² = 2qIB`; thermal `σ² = 4kTB/R_L` ||
229232
| Lossless SSFM | Energy conserved with nonlinearity ||
230233
| Fundamental soliton (N=1) | Envelope magnitude invariant along propagation ||
231-
| Ideal push-pull MZM | `P_out/P_in = cos²(πV / 2V_π)` ||
232-
| PIN detector | `I = R·P`; shot `σ² = 2qIB`; thermal `σ² = 4kTB/R_L` ||
233234
| Ideal OOK, Gaussian noise | `BER = ½·erfc(Q/√2)` ||
234235
| EDFA | `P_ASE = 2·n_sp·hν·(G−1)·B_o` ||
235236

docs/ARCHITECTURE.md

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -186,15 +186,15 @@ spanning 4 THz cannot be represented that way without sampling the entire band
186186
rewriting the core type and every block that touches it.
187187

188188
```python
189-
@dataclass
189+
@dataclass(frozen=True)
190190
class Band:
191191
"""One sampled band: complex envelope in two orthogonal polarizations (Jones vector)."""
192-
Ex: np.ndarray # complex64/128, shape (N,)
193-
Ey: np.ndarray # complex64/128, shape (N,)
194-
f0: float # band center frequency [Hz]
192+
Ex: np.ndarray # complex64/128, shape (N,), read-only
193+
Ey: np.ndarray # complex64/128, shape (N,), read-only
194+
f0: float # band centre frequency [Hz]
195195
fs: float # band sample rate [Hz] — usually context.sample_rate
196196

197-
@dataclass
197+
@dataclass(frozen=True)
198198
class NoiseBin:
199199
"""Spectrally-resolved noise carried separately from the sampled bands.
200200
@@ -206,12 +206,16 @@ class NoiseBin:
206206
psd_x: float # PSD per polarization [W/Hz]
207207
psd_y: float
208208

209-
@dataclass
209+
@dataclass(frozen=True)
210210
class OpticalSignal:
211-
bands: list[Band]
212-
noise: list[NoiseBin]
211+
bands: tuple[Band, ...]
212+
noise: tuple[NoiseBin, ...]
213213
```
214214

215+
Fields are in units of sqrt(W), so instantaneous power is `|Ex|**2 + |Ey|**2`. The containers
216+
are tuples and the arrays are read-only: §3.4's immutability rule has to be enforced by the
217+
type, not merely documented, or the buffer sharing it enables is unsafe.
218+
215219
Rules the engine enforces:
216220

217221
* Bands are disjoint in frequency. A block that would cause spectral overlap (e.g. a broadening

src/oosim/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,13 @@
1717

1818
from __future__ import annotations
1919

20-
from .component import Component, Param, Port, PortType
20+
from .component import BoolParam, Component, Param, Port, PortType
2121
from .context import SimulationContext
2222
from .graph import CycleError, Graph, GraphError, Results
2323
from .signals import (
2424
Band,
2525
BandPower,
26+
BinarySignal,
2627
ElectricalSignal,
2728
NoiseBin,
2829
OpticalSignal,
@@ -34,6 +35,8 @@
3435
__all__ = [
3536
"Band",
3637
"BandPower",
38+
"BinarySignal",
39+
"BoolParam",
3740
"Component",
3841
"CycleError",
3942
"ElectricalSignal",

src/oosim/component.py

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
import math
66
from dataclasses import dataclass
77
from enum import Enum
8-
from itertools import count
98
from typing import Any, ClassVar, overload
109

1110
from .context import SimulationContext
@@ -109,7 +108,40 @@ def to_dict(self) -> dict[str, Any]:
109108
return d
110109

111110

112-
_instance_counter = count(1)
111+
class BoolParam(Param):
112+
"""A component parameter that is a flag rather than a quantity.
113+
114+
A separate class rather than a mode on :class:`Param` so that a float
115+
parameter still types as ``float`` at every use site. Flags are read as plain
116+
attributes; :meth:`Component.si` refuses them, because converting a switch to
117+
an SI unit is meaningless and asking for it means something is confused.
118+
"""
119+
120+
def __init__(self, default: bool, *, doc: str = "") -> None:
121+
super().__init__(0.0, doc=doc)
122+
self.default = default
123+
124+
@overload
125+
def __get__(self, obj: None, owner: type | None = None) -> BoolParam: ...
126+
127+
@overload
128+
def __get__(self, obj: Component, owner: type | None = None) -> bool: ...
129+
130+
def __get__(self, obj: Component | None, owner: type | None = None) -> BoolParam | bool:
131+
if obj is None:
132+
return self
133+
return bool(obj._values.get(self.name, self.default))
134+
135+
def validate(self, value: object) -> bool:
136+
if not isinstance(value, bool):
137+
raise TypeError(f"{self.name} must be True or False, got {value!r}")
138+
return value
139+
140+
def to_dict(self) -> dict[str, Any]:
141+
d: dict[str, Any] = {"type": "bool", "default": self.default}
142+
if self.doc:
143+
d["doc"] = self.doc
144+
return d
113145

114146

115147
class Component:
@@ -135,7 +167,7 @@ class Component:
135167
inputs: dict[str, PortType] = {}
136168
outputs: dict[str, PortType] = {}
137169

138-
def __init__(self, *, label: str | None = None, **params: float) -> None:
170+
def __init__(self, *, label: str | None = None, **params: float | bool) -> None:
139171
self.inputs = dict(type(self).inputs)
140172
self.outputs = dict(type(self).outputs)
141173
declared = self.param_specs()
@@ -145,10 +177,15 @@ def __init__(self, *, label: str | None = None, **params: float) -> None:
145177
f"{type(self).__name__} has no parameter(s) {sorted(unknown)}; "
146178
f"declared: {sorted(declared)}"
147179
)
148-
self._values: dict[str, float] = {
180+
self._values: dict[str, float | bool] = {
149181
name: declared[name].validate(value) for name, value in params.items()
150182
}
151-
self.label = label or f"{type(self).__name__}{next(_instance_counter)}"
183+
184+
# A label identifies the component to the RNG, so it must depend only on
185+
# the graph, never on how many components happened to be constructed
186+
# earlier in the process. Graph.add() assigns the automatic form.
187+
self.has_explicit_label = label is not None
188+
self.label = label if label is not None else type(self).__name__
152189

153190
# -- introspection ----------------------------------------------------
154191

@@ -186,6 +223,8 @@ def si(self, name: str) -> float:
186223
spec = self.param_specs().get(name)
187224
if spec is None:
188225
raise KeyError(f"{type(self).__name__} has no parameter {name!r}")
226+
if isinstance(spec, BoolParam):
227+
raise TypeError(f"{name} is a flag, not a quantity; read it as an attribute")
189228
return to_si(getattr(self, name), spec.unit)
190229

191230
def display(self, name: str) -> tuple[float, str]:

src/oosim/components/__init__.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,24 @@
77

88
from __future__ import annotations
99

10+
from .detectors import PINPhotodiode
11+
from .electrical import DCVoltage, NRZDriver, PRBSGenerator
1012
from .fiber import Fiber
1113
from .meters import PowerMeter
14+
from .modulators import MachZehnderModulator
1215
from .passive import Attenuator, Combiner
1316
from .sources import CWLaser, GaussianPulse
1417

15-
__all__ = ["Attenuator", "CWLaser", "Combiner", "Fiber", "GaussianPulse", "PowerMeter"]
18+
__all__ = [
19+
"Attenuator",
20+
"CWLaser",
21+
"Combiner",
22+
"DCVoltage",
23+
"Fiber",
24+
"GaussianPulse",
25+
"MachZehnderModulator",
26+
"NRZDriver",
27+
"PINPhotodiode",
28+
"PRBSGenerator",
29+
"PowerMeter",
30+
]

src/oosim/components/detectors.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
"""Photodetectors.
2+
3+
Model reference: G. P. Agrawal, *Fiber-Optic Communication Systems*, ch. 4
4+
(photodetectors, receiver noise).
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import numpy as np
10+
11+
from ..component import BoolParam, Component, Param, PortType
12+
from ..context import SimulationContext
13+
from ..signals import ElectricalSignal, OpticalSignal, Signal
14+
from ..units import K_BOLTZMANN, Q_ELECTRON
15+
16+
17+
class PINPhotodiode(Component):
18+
"""PIN photodiode: square-law detection with shot and thermal noise.
19+
20+
The mean photocurrent is ``I = R * P + I_dark``, with ``R`` the responsivity
21+
[A/W]. Two noise sources are added, both white over the simulated bandwidth
22+
``B = fs / 2``:
23+
24+
* **Shot noise**, variance ``2 * q * I * B``. It scales with the
25+
instantaneous current, so it is generated per sample rather than as a
26+
single constant — bright samples really are noisier than dark ones, which
27+
is why an eye diagram's rails have different thicknesses.
28+
* **Thermal (Johnson) noise**, variance ``4 * k * T * B / R_load``,
29+
independent of the received power.
30+
31+
Two simplifications worth stating plainly, both of which change results and
32+
neither of which is hidden by the interface:
33+
34+
* Bands are detected incoherently — powers add. Beating between bands lands
35+
at their frequency separation, far above any realistic receiver bandwidth
36+
for the channel spacings this is used with, but it is genuinely absent
37+
rather than merely negligible.
38+
* Noise bins contribute mean power and its shot noise, but signal-ASE beat
39+
noise is not modelled. That term only matters once there is an amplifier
40+
to produce ASE, and it arrives with the EDFA in Phase 1.5.
41+
"""
42+
43+
display_name = "PIN Photodiode"
44+
category = "Receivers"
45+
46+
responsivity = Param(0.8, unit="", min=0.0, doc="Responsivity R [A/W]")
47+
dark_current = Param(0.0, unit="", min=0.0, doc="Dark current [A]")
48+
load_resistance = Param(50.0, unit="", min=0.0, doc="Load resistance [ohm]")
49+
temperature = Param(300.0, unit="", min=0.0, doc="Receiver temperature [K]")
50+
shot_noise = BoolParam(True, doc="Add shot noise")
51+
thermal_noise = BoolParam(True, doc="Add thermal (Johnson) noise")
52+
53+
inputs = {"in": PortType.OPTICAL}
54+
outputs = {"out": PortType.ELECTRICAL}
55+
56+
def noise_bandwidth(self, ctx: SimulationContext) -> float:
57+
"""Effective one-sided noise bandwidth [Hz] of the sampled representation."""
58+
return ctx.sample_rate / 2.0
59+
60+
def run(self, ctx: SimulationContext, inputs: dict[str, Signal]) -> dict[str, Signal]:
61+
signal: OpticalSignal = inputs["in"]
62+
63+
power = np.zeros(ctx.num_samples, dtype=np.float64)
64+
for band in signal.bands:
65+
power += np.abs(band.Ex.astype(np.complex128)) ** 2
66+
power += np.abs(band.Ey.astype(np.complex128)) ** 2
67+
power += signal.noise_power()
68+
69+
current = self.si("responsivity") * power + self.si("dark_current")
70+
bandwidth = self.noise_bandwidth(ctx)
71+
72+
if self.shot_noise:
73+
# Variance tracks the instantaneous current, so it is per sample:
74+
# bright samples really are noisier than dark ones.
75+
shot_variance = 2.0 * Q_ELECTRON * np.maximum(current, 0.0) * bandwidth
76+
rng = ctx.rng("PINPhotodiode", self.label, "shot")
77+
current = current + rng.normal(0.0, np.sqrt(shot_variance))
78+
79+
if self.thermal_noise and self.si("load_resistance") > 0.0:
80+
thermal_variance = (
81+
4.0 * K_BOLTZMANN * self.si("temperature") * bandwidth / self.si("load_resistance")
82+
)
83+
rng = ctx.rng("PINPhotodiode", self.label, "thermal")
84+
current = current + rng.normal(0.0, np.sqrt(thermal_variance), size=ctx.num_samples)
85+
86+
return {
87+
"out": ElectricalSignal(
88+
samples=current.astype(ctx.real_dtype), fs=ctx.sample_rate, unit="A"
89+
)
90+
}

0 commit comments

Comments
 (0)