Skip to content

Commit 10b284d

Browse files
ehsun-shclaude
andcommitted
Add polarization multiplexing and the blind butterfly equaliser
Two independent 16-QAM tributaries on orthogonal polarizations of one wavelength: 256 Gb/s at 32 GBd. This is the increment that finally exercises Ey, which has been in Band since the first commit for exactly this purpose, and the one that makes the existing PMD model an impairment rather than a curiosity. The claim worth testing is the failure case. A fibre rotates the launched state arbitrarily, so a dual-polarization receiver's two branches carry *mixtures* rather than channels -- and past a few degrees neither is recoverable at all. Not degraded data: no data. Both halves are asserted, unrecoverable without the equaliser and error-free with it, at rotations through the 45-degree worst case. New blocks: Splitter, because a graph edge fans out for free and an optical field does not, and a dual-pol transmitter really does pay 3 dB; PolarizationCombiner, which is deliberately the opposite rule to the wavelength Combiner -- that one refuses two inputs at one frequency because co-located carriers interfere, this one requires it because orthogonal ones do not; PolarizationRotator as the impairment; DualPolarizationReceiver; ButterflyEqualizer. The equaliser is blind -- no training sequence anywhere in the link. Two stages, because one does not serve QAM: Godard's single radius opens the eye from nothing but converges to a compromise no symbol sits on, then radius-directed error closes it. On QPSK the stages coincide, because QPSK has one radius. Two things the falsification pass changed rather than confirmed. A 45-degree rotation mixes the tributaries exactly half and half, which leaves a symmetric filter initialisation equidistant from both valid solutions -- a saddle, not a minimum. The adaptation stalls there, and running it longer makes it *worse*, which is what identified it as a saddle rather than slow convergence. A small deterministic tilt off the symmetry axis removes it; 64-QAM at 45 degrees fails without it and lands on the noise floor with it, and a test guards it. The other correction is to my own work. The docstring claimed a textbook singularity guard -- re-seeding the second filter row as the unitary complement of the first -- was what kept both rows from converging onto the same tributary. Sabotaging it changed nothing, so it was measured properly across ninety channel and format combinations: it helped eight times, hurt nine, and did nothing in the rest. It has been removed and the docstring now says what was measured. A confident comment over a coin flip is worse than no comment. Six sabotages, all caught after those two fixes: dropping the tilt, never leaving the CMA stage, a non-unitary rotator, a combiner that puts both arms on one axis, a receiver that detects only X, and a filter with no cross terms. Also honest about what stays unresolved: nothing blind labels the tributaries, so a channel past 45 degrees separates them perfectly and delivers them the other way round. A real link recovers the pairing from framing; the tests and the example recover it by measuring against both references, and say so. 31 components, 450 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 221329a commit 10b284d

8 files changed

Lines changed: 1167 additions & 4 deletions

File tree

README.md

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,31 @@ subtracting a constant or a line, and it puts a ceiling on SNR that no power bud
145145
search of Pfau et al. Both halves of that claim are [asserted](tests/test_dsp.py) — the second
146146
would be meaningless without the first.
147147

148+
### Dual polarization
149+
150+
`python examples/dualpol_link.py` puts two independent 16-QAM tributaries on orthogonal
151+
polarizations of one wavelength — **256 Gb/s** — and rotates the state the way a fibre does:
152+
153+
```
154+
rotation without equaliser with equaliser
155+
0 deg EVM 2.5 / 2.5 % 0 err EVM 2.50 / 2.57 % 0 err
156+
15 deg EVM 28.1 / 29.2 % 1416 err EVM 2.51 / 2.58 % 0 err
157+
30 deg EVM 218.0 / 123.5 % 6217 err EVM 2.52 / 2.51 % 0 err
158+
45 deg EVM 278.5 / 365.2 % 6763 err EVM 2.48 / 2.55 % 0 err
159+
72 deg EVM 85.7 / 165.6 % 5523 err EVM 2.54 / 2.56 % 0 err (swapped)
160+
90 deg EVM 2.5 / 2.5 % 0 err EVM 2.54 / 2.48 % 0 err (swapped)
161+
```
162+
163+
Past a few degrees the unequalised branches are not degraded — they carry no recoverable data at
164+
all, because each is a *mixture* of both tributaries. The
165+
[butterfly equaliser](src/oosim/dsp.py) separates them blind, with no training sequence anywhere in
166+
the link. Read the two end rows together: 90° is a clean swap rather than a mixture, so it needs no
167+
equaliser at all and simply delivers the tributaries the other way round — which is also why
168+
nothing blind can label them, and why a real link recovers the pairing from framing.
169+
170+
This is the increment that finally exercises `Ey`, which has been in the signal model since the
171+
first commit for exactly this purpose.
172+
148173
```python
149174
result = sweep(graph, {("laser", "power"): [-24.0, -21.0, -18.0]}, runs=8)
150175
q = result.metric(analyzer, lambda m: m.q_factor) # shape (points, runs)
@@ -311,7 +336,7 @@ time window, and results are reproducible.
311336
| **0 — Foundations**| Signal model, context, port types, component base, registry, scheduler, `.oosim` project format, sweeps, CI | ~1 month |
312337
| **1 — MVP: linear link** *(essentially done)* | ✅ PRBS → NRZ → laser → MZM → fiber (α + CD) → PIN → filter → eye/Q/BER, validated end to end. **Python only, no GUI.** | ~2–3 months |
313338
| **1.5 — Nonlinear & amplified**| Adaptive-step SSFM, Kerr, EDFA with ASE, OSNR, PMD, APD | ~2 months |
314-
| **2 — Coherent transceiver** *(single-pol done)* |Gray-coded M-QAM to 256, IQ modulator with bias and quadrature error, 90° hybrid, balanced detection, blind carrier phase recovery, EVM/MER, constellation diagram, validated against closed-form SER · ⬜ dual polarization, pulse shaping, adaptive equaliser | ~3 months |
339+
| **2 — Coherent transceiver** | Gray-coded M-QAM to 256, IQ modulator with bias and quadrature error, 90° hybrid, balanced detection, blind carrier phase recovery, **dual polarization with a blind butterfly equaliser**, EVM/MER, constellation diagram, validated against closed-form SER · ⬜ pulse shaping, differential encoding | ~3 months |
315340
| **3 — GUI & WDM** | Session server, React Flow graph editor, OSA · DWDM + XPM/FWM crosstalk, 400G/800G references, CuPy back-end | ~6 months |
316341
| **4 — PIC** | Waveguides, ring resonators, MMI, MZI via integration with an existing S-matrix solver; PDK import ||
317342

examples/dualpol_link.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
"""A 256 Gb/s dual-polarization coherent link through a rotated channel.
2+
3+
Two independent 16-QAM tributaries share one wavelength on orthogonal
4+
polarizations. A fibre rotates the launched state arbitrarily, so what the
5+
receiver's two branches carry is a *mixture* of both tributaries rather than one
6+
each — and past a small angle neither is recoverable at all. The butterfly
7+
equaliser is what separates them again.
8+
9+
Run: ``python examples/dualpol_link.py``
10+
"""
11+
12+
from __future__ import annotations
13+
14+
from oosim import Graph, SimulationContext
15+
from oosim.components import (
16+
ButterflyEqualizer,
17+
CarrierRecovery,
18+
ConstellationAnalyzer,
19+
CWLaser,
20+
DualPolarizationReceiver,
21+
IQDriver,
22+
IQModulator,
23+
IQSampler,
24+
PolarizationCombiner,
25+
PolarizationRotator,
26+
PRBSGenerator,
27+
QAMMapper,
28+
Splitter,
29+
)
30+
31+
SYMBOL_RATE = 32e9
32+
BITS_PER_SYMBOL = 4
33+
34+
35+
def build(rotation: float, *, equalize: bool, sequence_length: int = 4096) -> tuple:
36+
ctx = SimulationContext(
37+
bit_rate=SYMBOL_RATE,
38+
samples_per_symbol=4,
39+
sequence_length=sequence_length,
40+
seed=2026,
41+
precision="double",
42+
)
43+
graph = Graph(ctx)
44+
laser = graph.add(CWLaser(power=0.0, linewidth=100.0, label="tx"))
45+
splitter = graph.add(Splitter(2, label="sp"))
46+
graph.connect(laser, splitter["in"])
47+
48+
mappers, modulators = {}, {}
49+
for index, axis in enumerate(("x", "y")):
50+
prbs = graph.add(
51+
PRBSGenerator(
52+
order=23.0 if axis == "x" else 15.0,
53+
bits_per_symbol=float(BITS_PER_SYMBOL),
54+
label=f"prbs_{axis}",
55+
)
56+
)
57+
mapper = graph.add(QAMMapper(bits_per_symbol=float(BITS_PER_SYMBOL), label=f"map_{axis}"))
58+
driver = graph.add(IQDriver(label=f"drv_{axis}"))
59+
modulator = graph.add(IQModulator(label=f"mod_{axis}"))
60+
graph.chain(prbs, mapper, driver)
61+
graph.connect(splitter[f"out{index}"], modulator["optical_in"])
62+
graph.connect(driver["i"], modulator["i"])
63+
graph.connect(driver["q"], modulator["q"])
64+
mappers[axis], modulators[axis] = mapper, modulator
65+
66+
combiner = graph.add(PolarizationCombiner(label="pbc"))
67+
graph.connect(modulators["x"], combiner["x"])
68+
graph.connect(modulators["y"], combiner["y"])
69+
rotator = graph.add(PolarizationRotator(angle=rotation, phase=25.0, label="rot"))
70+
graph.connect(combiner, rotator["in"])
71+
72+
lo = graph.add(CWLaser(power=13.0, linewidth=100.0, label="lo"))
73+
receiver = graph.add(DualPolarizationReceiver(label="rx"))
74+
graph.connect(rotator, receiver["in"])
75+
graph.connect(lo, receiver["lo"])
76+
77+
samplers = {}
78+
for axis in ("x", "y"):
79+
sampler = graph.add(IQSampler(label=f"smp_{axis}"))
80+
graph.connect(receiver[f"{axis}i"], sampler["i"])
81+
graph.connect(receiver[f"{axis}q"], sampler["q"])
82+
graph.connect(mappers[axis]["out"], sampler["reference"])
83+
samplers[axis] = sampler
84+
85+
if equalize:
86+
equalizer = graph.add(ButterflyEqualizer(label="eq"))
87+
graph.connect(samplers["x"]["out"], equalizer["x"])
88+
graph.connect(samplers["y"]["out"], equalizer["y"])
89+
sources = {"x": equalizer["x_out"], "y": equalizer["y_out"]}
90+
else:
91+
sources = {axis: samplers[axis]["out"] for axis in ("x", "y")}
92+
93+
analyzers = {}
94+
for axis in ("x", "y"):
95+
recovery = graph.add(CarrierRecovery(label=f"cr_{axis}"))
96+
graph.connect(sources[axis], recovery["in"])
97+
for reference in ("x", "y"):
98+
analyzer = graph.add(
99+
ConstellationAnalyzer(ignore_edges=128.0, label=f"vsa_{axis}{reference}")
100+
)
101+
graph.connect(recovery["out"], analyzer["in"])
102+
graph.connect(mappers[reference]["out"], analyzer["reference"])
103+
analyzers[axis + reference] = analyzer
104+
return graph, analyzers
105+
106+
107+
def measure(rotation: float, *, equalize: bool) -> tuple:
108+
graph, analyzers = build(rotation, equalize=equalize)
109+
results = graph.run(keep=[])
110+
taken = {key: results[a] for key, a in analyzers.items()}
111+
# Nothing blind labels the tributaries, so a channel that swaps them is
112+
# separated correctly and delivered the other way round. Framing resolves
113+
# this in a real link; both references resolve it here.
114+
direct = taken["xx"].symbol_errors + taken["yy"].symbol_errors
115+
swapped = taken["xy"].symbol_errors + taken["yx"].symbol_errors
116+
if direct <= swapped:
117+
return taken["xx"], taken["yy"], False
118+
return taken["xy"], taken["yx"], True
119+
120+
121+
def main() -> None:
122+
rate = SYMBOL_RATE * BITS_PER_SYMBOL * 2 / 1e9
123+
print(
124+
f"Dual-polarization 16-QAM, {SYMBOL_RATE / 1e9:.0f} GBd x 2 pol = {rate:.0f} Gb/s\n"
125+
f"Two independent tributaries on one wavelength, through a rotated channel.\n"
126+
)
127+
print("rotation without equaliser with equaliser")
128+
print("-" * 70)
129+
for rotation in (0.0, 15.0, 30.0, 45.0, 72.0, 90.0):
130+
off_x, off_y, _ = measure(rotation, equalize=False)
131+
on_x, on_y, swapped = measure(rotation, equalize=True)
132+
note = " (tributaries swapped)" if swapped else ""
133+
print(
134+
f"{rotation:5.0f} deg EVM {off_x.evm * 100:6.1f} / {off_y.evm * 100:6.1f} %"
135+
f" {off_x.symbol_errors + off_y.symbol_errors:5} err"
136+
f" EVM {on_x.evm * 100:4.2f} / {on_y.evm * 100:4.2f} %"
137+
f" {on_x.symbol_errors + on_y.symbol_errors:3} err{note}"
138+
)
139+
print(
140+
"\nPast a few degrees the unequalised branches are not degraded — they carry\n"
141+
"no recoverable data at all. Separating them is linear algebra the receiver\n"
142+
"has to learn blind, with no training sequence anywhere in the link."
143+
)
144+
145+
146+
if __name__ == "__main__":
147+
main()

src/oosim/components/__init__.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,29 +9,43 @@
99

1010
from .amplifiers import EDFA
1111
from .analyzers import BERAnalyzer, ConstellationAnalyzer, ConstellationDiagram, EyeDiagram
12-
from .coherent import CarrierRecovery, CoherentReceiver, IQSampler
12+
from .coherent import (
13+
CarrierRecovery,
14+
CoherentReceiver,
15+
DualPolarizationReceiver,
16+
IQSampler,
17+
)
1318
from .detectors import APDPhotodiode, PINPhotodiode
19+
from .dsp import ButterflyEqualizer
1420
from .electrical import DCVoltage, IQDriver, NRZDriver, PRBSGenerator
1521
from .fiber import Fiber
1622
from .filters import ElectricalFilter
1723
from .mapping import QAMMapper
1824
from .meters import OSNRMeter, PowerMeter
1925
from .modulators import IQModulator, MachZehnderModulator
20-
from .passive import Attenuator, Combiner
26+
from .passive import (
27+
Attenuator,
28+
Combiner,
29+
PolarizationCombiner,
30+
PolarizationRotator,
31+
Splitter,
32+
)
2133
from .sources import CWLaser, GaussianPulse, SechPulse
2234

2335
__all__ = [
2436
"EDFA",
2537
"APDPhotodiode",
2638
"Attenuator",
2739
"BERAnalyzer",
40+
"ButterflyEqualizer",
2841
"CWLaser",
2942
"CarrierRecovery",
3043
"CoherentReceiver",
3144
"Combiner",
3245
"ConstellationAnalyzer",
3346
"ConstellationDiagram",
3447
"DCVoltage",
48+
"DualPolarizationReceiver",
3549
"ElectricalFilter",
3650
"EyeDiagram",
3751
"Fiber",
@@ -44,7 +58,10 @@
4458
"OSNRMeter",
4559
"PINPhotodiode",
4660
"PRBSGenerator",
61+
"PolarizationCombiner",
62+
"PolarizationRotator",
4763
"PowerMeter",
4864
"QAMMapper",
4965
"SechPulse",
66+
"Splitter",
5067
]

src/oosim/components/coherent.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
from __future__ import annotations
99

10+
import math
11+
1012
import numpy as np
1113

1214
from ..component import BoolParam, Component, Param, PortType
@@ -135,6 +137,99 @@ def run(self, ctx: SimulationContext, inputs: dict[str, Signal]) -> dict[str, Si
135137
}
136138

137139

140+
class DualPolarizationReceiver(CoherentReceiver):
141+
"""Polarization-diverse coherent receiver: four photocurrents, not two.
142+
143+
The signal is split by a polarization beam splitter, the LO is split with it,
144+
and each pair goes to its own 90-degree hybrid. The result is the *complete*
145+
optical field: two complex quadratures on each of two orthogonal states, from
146+
which any launched polarization can be reconstructed by linear combination.
147+
148+
That last clause is the point. A single-polarization receiver goes deaf when
149+
the fibre rotates the signal onto the orthogonal state; this one cannot,
150+
because it measures both states and lets the DSP recombine them. It is why a
151+
coherent link can carry two independent tributaries at one wavelength while a
152+
direct-detection link carries one.
153+
154+
The PBS axes are the receiver's own, and they bear no relation to whatever
155+
the transmitter launched — a fibre rotates the state arbitrarily and drifts.
156+
So ``x`` and ``y`` here are *not* the two tributaries. Separating those is
157+
:class:`~oosim.components.dsp.ButterflyEqualizer`'s job, and without it these
158+
outputs are two mixtures rather than two channels.
159+
160+
The noise convention is inherited unchanged: each hybrid sees half the LO
161+
power, so the per-quadrature shot variance is half the single-polarization
162+
receiver's, and splitting the signal likewise halves what each branch
163+
receives. The two effects cancel in the SNR, which is why polarization
164+
multiplexing doubles capacity at the same SNR per tributary rather than
165+
costing 3 dB.
166+
"""
167+
168+
display_name = "Dual-Pol Coherent Receiver"
169+
category = "Receivers"
170+
171+
inputs = {"in": PortType.OPTICAL, "lo": PortType.OPTICAL}
172+
outputs = {
173+
"xi": PortType.ELECTRICAL,
174+
"xq": PortType.ELECTRICAL,
175+
"yi": PortType.ELECTRICAL,
176+
"yq": PortType.ELECTRICAL,
177+
}
178+
179+
def run(self, ctx: SimulationContext, inputs: dict[str, Signal]) -> dict[str, Signal]:
180+
signal: OpticalSignal = inputs["in"]
181+
lo: OpticalSignal = inputs["lo"]
182+
183+
if len(lo.bands) != 1:
184+
raise ValueError(
185+
f"{self.label}: the local oscillator must be a single band, got "
186+
f"{len(lo.bands)}; a coherent receiver mixes against one tone"
187+
)
188+
reference = lo.bands[0]
189+
# The LO is launched at 45 degrees so the PBS delivers equal power to both
190+
# hybrids. A real receiver either does this or uses a polarization-
191+
# maintaining split; either way each branch gets half.
192+
lo_amplitude = math.sqrt(reference.average_power() / 2.0)
193+
lo_phase = np.exp(1j * np.angle(reference.Ex.astype(np.complex128)))
194+
lo_branch = lo_amplitude * lo_phase
195+
196+
band = self._nearest_band(signal, reference.f0)
197+
responsivity = self.si("responsivity")
198+
bandwidth = self.noise_bandwidth(ctx)
199+
# Each of the two hybrids sees half the LO.
200+
shot_variance = Q_ELECTRON * responsivity * (reference.average_power() / 2.0) * bandwidth
201+
thermal_variance = (
202+
4.0 * K_BOLTZMANN * self.si("temperature") * bandwidth / self.si("load_resistance")
203+
if self.si("load_resistance") > 0.0
204+
else 0.0
205+
)
206+
207+
out: dict[str, Signal] = {}
208+
for axis, field in (("x", "Ex"), ("y", "Ey")):
209+
if band is None:
210+
mix = np.zeros(ctx.num_samples, dtype=np.complex128)
211+
else:
212+
beat = np.exp(2j * np.pi * (band.f0 - reference.f0) * ctx.time_axis())
213+
mix = getattr(band, field).astype(np.complex128) * np.conj(lo_branch) * beat
214+
215+
for quadrature, part in (("i", mix.real), ("q", mix.imag)):
216+
current = responsivity * part
217+
if self.shot_noise and shot_variance > 0.0:
218+
rng = ctx.rng(type(self).__name__, self.label, "shot", axis, quadrature)
219+
current = current + rng.normal(
220+
0.0, math.sqrt(shot_variance), size=ctx.num_samples
221+
)
222+
if self.thermal_noise and thermal_variance > 0.0:
223+
rng = ctx.rng(type(self).__name__, self.label, "thermal", axis, quadrature)
224+
current = current + rng.normal(
225+
0.0, math.sqrt(thermal_variance), size=ctx.num_samples
226+
)
227+
out[axis + quadrature] = ElectricalSignal(
228+
samples=current.astype(ctx.real_dtype), fs=ctx.sample_rate, unit="A"
229+
)
230+
return out
231+
232+
138233
class CarrierRecovery(Component):
139234
"""Blind carrier phase recovery, by phase search.
140235

0 commit comments

Comments
 (0)