Skip to content

Commit 7883919

Browse files
ehsun-shclaude
andcommitted
Reference transceivers at 400G and 800G
Three configurations, all dual-polarization coherent, all derived from one number and arithmetic: DP-16QAM at 59.84 GBd carries 400 Gb/s with room for the FEC, and 800 Gb/s is either twice that baud at the same format or two thirds of twice it at a denser one. Line rate is baud times bits times two polarizations. Nothing is quoted from a standard. Required OSNR is measured — a noise-loaded link, bisected until the *counted* bit error rate lands on the threshold — and compared against a relation that knows nothing about the links. 19.78 against 19.47 dB at 400G, 22.86 against 22.48 at 800G DP-16QAM, 27.22 against 26.41 at 800G DP-64QAM. What is left over is the transmitter's implementation penalty, and it grows with the format order because a denser constellation is less forgiving of the modulator's curvature. With the DSP out of the way the 800G choice is two numbers: +3.08 dB of OSNR for twice the spectrum, or +4.36 dB to fit into two thirds of it. The first is the price of bandwidth and is 3 dB in theory. `maiman.analysis` gains the bridge these rest on — snr_from_osnr, its inverse, snr_for_ber and required_osnr. The conversion is a bandwidth ratio and a polarization factor and nothing else, and the factor is the part that catches people: a dual-polarization tributary carries half the power and hears half the ASE, and those do not cancel, because the reference bandwidth has not halved. snr_for_ber bisects ber_qam rather than deriving a second closed form, so the two cannot come to disagree. And a finding, which is why the table has a fifth column. Nothing rotates the polarization on this bench, so the blind butterfly equaliser has nothing to undo and should be free. At 16-QAM it is, to within half a decibel. At 64-QAM it diverges and costs eleven: nine constellation radii sit close enough together that a noisy sample snaps to the wrong one and the correction that follows is large and in the wrong direction. A single tap recovers all of it and a step ten times smaller most of it, so the structure is right and the adaptation is not. A normalised update is the textbook fix; it was tried, it breaks seven existing dual-polarization tests because the default step is tuned for the un-normalised form, and retuning it means redoing the ninety-combination measurement that chose the current design. That is its own piece of work, and there is a test pinning the eleven decibels so that doing it will show up here. The two 16-QAM designs ship as zr400.maiman and zr800.maiman, laid out and openable in the studio, and are tested the only way a project file can be: loaded back and run. 12 of 12 sabotages caught. 832 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 576cb9e commit 7883919

7 files changed

Lines changed: 2075 additions & 1 deletion

File tree

README.md

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,57 @@ noise bins rendered onto one grid, the way an instrument shows them. Its resolut
447447
not cosmetic — widening it raises the ASE trace decibel for decibel and leaves a carrier exactly
448448
where it is, which is the clearest demonstration of why OSNR needs a stated reference bandwidth.
449449

450+
## 400G and 800G, and what they cost
451+
452+
Three reference transceivers, all dual-polarization coherent, all derived from one number and
453+
arithmetic. 400G is DP-16QAM at 59.84 GBd — the shape a 400ZR module has. 800G is that payload
454+
doubled, and there are two ways to double it:
455+
456+
| configuration | GBd | line rate | payload | slot | b/s/Hz |
457+
| :--- | ---: | ---: | ---: | ---: | ---: |
458+
| 400G DP-16QAM | 59.84 | 479 Gb/s | 400 G | 75 GHz | 5.33 |
459+
| 800G DP-16QAM | 119.68 | 957 Gb/s | 800 G | 150 GHz | 5.33 |
460+
| 800G DP-64QAM | 79.79 | 957 Gb/s | 800 G | 100 GHz | 8.00 |
461+
462+
Line rate is baud × bits × 2 polarizations; a 400 Gb/s payload inside 479 leaves 16.4 % for forward
463+
error correction and framing. **Nothing here is quoted from a standard** — the 800G symbol rates
464+
follow from the 400G one, and the required OSNR below is *measured*: a noise-loaded link, bisected
465+
until the **counted** bit error rate lands on the threshold, then compared against a relation that
466+
knows nothing about any of it.
467+
468+
| configuration | closed form | ideal DSP | penalty | blind equaliser |
469+
| :--- | ---: | ---: | ---: | ---: |
470+
| 400G DP-16QAM | 19.47 dB | 19.78 dB | +0.31 | 20.25 dB |
471+
| 800G DP-16QAM | 22.48 dB | 22.86 dB | +0.38 | 23.32 dB |
472+
| 800G DP-64QAM | 26.41 dB | 27.22 dB | +0.81 | 38.03 dB |
473+
474+
The closed form assumes a perfect transmitter, perfect DSP and a noiseless receiver, so the fourth
475+
column is the transmitter's implementation penalty — growing with the format order, because a
476+
denser constellation is less forgiving of the modulator's curvature.
477+
478+
**The fifth column is a finding, not a specification.** Nothing rotates the polarization on this
479+
bench, so the blind butterfly equaliser has nothing to undo and should be free. At 16-QAM it is. At
480+
64-QAM it diverges and costs eleven decibels: nine constellation radii sit close enough together
481+
that a noisy sample snaps to the wrong one, and the correction that follows is large and in the
482+
wrong direction. A single tap recovers all of it and a step ten times smaller most of it, so the
483+
structure is right and the adaptation is not. A normalised update is the textbook fix, it moves
484+
every other format's answer with it, and it is therefore its own piece of work.
485+
486+
With the DSP out of the way, the 800G choice is two numbers:
487+
488+
twice the baud, same format +3.08 dB of OSNR, twice the spectrum
489+
denser format, less baud +4.36 dB of OSNR, two thirds of it
490+
491+
The first is the price of bandwidth and is 3 dB in theory: twice the symbol rate collects twice the
492+
noise and nothing else changes. The second buys a third of the spectrum back, and is what a link
493+
with filled fibre and optical SNR to spare pays for it.
494+
495+
`maiman.analysis` carries the bridge these rest on — `snr_from_osnr`, `snr_for_ber` and
496+
`required_osnr` — and the two reference designs ship as
497+
[`examples/zr400.maiman`](examples/zr400.maiman) and
498+
[`examples/zr800.maiman`](examples/zr800.maiman), laid out and openable in the studio.
499+
[`examples/reference_rates.py`](examples/reference_rates.py) builds them and prints the tables.
500+
450501
## Mixing products add in field, not in power
451502

452503
A link is not one fibre. The four-wave mixing product a span generates arrives on top of the one
@@ -895,7 +946,7 @@ time window, and results are reproducible.
895946
| **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 |
896947
| **1.5 — Nonlinear & amplified**| Adaptive-step SSFM, Kerr, EDFA with ASE, OSNR, PMD, APD, dispersion slope and its third-order term, cross-polarization Kerr coupling, inter-channel stimulated Raman scattering | ~2 months |
897948
| **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, root-raised-cosine shaping and matched filtering, differential quadrant encoding, receiver-side dispersion compensation over spans to 1000 km with blind estimation of the accumulated value, EVM/MER, constellation diagram, validated against closed-form SER | ~3 months |
898-
| **3 — GUI & WDM** | ✅ Wavelength-selective filters, an OSA, coupled-channel propagation (XPM with walk-off, FWM accumulating coherently across spans), the session server, and a schematic editor: add, wire, move and delete blocks, edit parameters, run, sweep, open and save · 400G/800G references, CuPy back-end | ~6 months |
949+
| **3 — GUI & WDM** | ✅ Wavelength-selective filters, an OSA, coupled-channel propagation (XPM with walk-off, FWM accumulating coherently across spans), the session server, a schematic editoradd, wire, move and delete blocks, edit parameters, run, sweep, open and save — and 400G/800G reference designs validated against the OSNR relations · CuPy back-end | ~6 months |
899950
| **4 — PIC** | Waveguides, ring resonators, MMI, MZI via integration with an existing S-matrix solver; PDK import ||
900951

901952
¹ One developer, part-time. Estimates, not commitments.

examples/reference_rates.py

Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
"""400G and 800G reference transceivers, and what they cost in optical SNR.
2+
3+
Three configurations, all dual-polarization coherent, all built from one number
4+
and arithmetic. 400G is DP-16QAM at 59.84 GBd — the shape a 400ZR module has.
5+
800G is the same payload doubled, and there are two ways to double it: twice the
6+
symbol rate at the same format, or the same-ish symbol rate at a denser one.
7+
Which one a line system picks is a trade between optical SNR and spectrum, and
8+
this prints both sides of it.
9+
10+
Nothing here is quoted from a standard. The symbol rates for 800G are derived
11+
from the 400G one, the line rates are ``baud * bits * 2``, and the required OSNR
12+
is *measured* — a noise-loaded link, bisected until the counted bit error rate
13+
sits on the threshold — and then compared against
14+
:func:`maiman.analysis.required_osnr`, which knows nothing about any of it.
15+
16+
Run: ``python examples/reference_rates.py``
17+
"""
18+
19+
from __future__ import annotations
20+
21+
from pathlib import Path
22+
23+
from maiman import Component, Graph, SimulationContext
24+
from maiman.analysis import required_osnr
25+
from maiman.component import Port
26+
from maiman.components import (
27+
EDFA,
28+
Attenuator,
29+
ButterflyEqualizer,
30+
CarrierRecovery,
31+
ConstellationAnalyzer,
32+
CWLaser,
33+
DispersionCompensator,
34+
DualPolarizationReceiver,
35+
Fiber,
36+
IQDriver,
37+
IQModulator,
38+
IQSampler,
39+
OSNRMeter,
40+
PolarizationCombiner,
41+
PRBSGenerator,
42+
QAMMapper,
43+
Splitter,
44+
)
45+
from maiman.project import save
46+
47+
#: The one measured number everything else follows from: DP-16QAM at this baud
48+
#: carries a 400 Gb/s payload with room for the FEC, which is the 400ZR shape.
49+
BAUD_400G = 59.84e9
50+
51+
#: Pre-FEC bit error rate the required-OSNR figures are quoted at. Soft-decision
52+
#: FEC in this class corrects from somewhere near here; the exact threshold is a
53+
#: property of the code, so it is a parameter of the table rather than a fact
54+
#: about the link.
55+
THRESHOLD = 2e-2
56+
57+
ROLL_OFF = 0.1
58+
SPAN_KM = 80.0
59+
60+
#: name -> (symbol rate, bits per symbol per polarization, grid slot)
61+
CONFIGURATIONS: dict[str, tuple[float, int, float]] = {
62+
# Twice the payload needs twice the baud at the same format...
63+
"400G DP-16QAM": (BAUD_400G, 4, 75e9),
64+
"800G DP-16QAM": (2.0 * BAUD_400G, 4, 150e9),
65+
# ...or two thirds of that baud at a format carrying half again as many bits.
66+
"800G DP-64QAM": (2.0 * BAUD_400G * 4 / 6, 6, 100e9),
67+
}
68+
69+
70+
def build(
71+
symbol_rate: float,
72+
bits: int,
73+
*,
74+
pad_db: float = 0.0,
75+
span_km: float = 0.0,
76+
equalize: bool = True,
77+
sequence_length: int = 4096,
78+
) -> tuple[Graph, dict[str, ConstellationAnalyzer], OSNRMeter]:
79+
"""One transceiver, optionally over a span, optionally noise loaded.
80+
81+
``pad_db`` is a variable optical attenuator followed by an amplifier that
82+
exactly undoes it: the power comes back and the noise does not, which is how
83+
a required-OSNR measurement is made on a bench.
84+
85+
``equalize=False`` wires the carrier recovery straight onto the samplers.
86+
Nothing rotates the polarization here, so an ideal separator would be the
87+
identity and leaving it out costs nothing — which makes the difference
88+
between the two a measurement of the blind equaliser itself.
89+
"""
90+
ctx = SimulationContext(
91+
bit_rate=symbol_rate,
92+
samples_per_symbol=4,
93+
sequence_length=sequence_length,
94+
seed=2026,
95+
precision="double",
96+
)
97+
graph = Graph(ctx)
98+
laser = graph.add(CWLaser(power=0.0, wavelength=1550.0, linewidth=100.0, label="tx"))
99+
splitter = graph.add(Splitter(2, label="pbs"))
100+
graph.connect(laser, splitter["in"])
101+
102+
mappers: dict[str, QAMMapper] = {}
103+
modulators: dict[str, IQModulator] = {}
104+
for index, axis in enumerate(("x", "y")):
105+
prbs = graph.add(
106+
PRBSGenerator(
107+
order=23.0 if axis == "x" else 15.0,
108+
bits_per_symbol=float(bits),
109+
label=f"prbs_{axis}",
110+
)
111+
)
112+
mapper = graph.add(QAMMapper(bits_per_symbol=float(bits), label=f"map_{axis}"))
113+
driver = graph.add(
114+
IQDriver(
115+
v_pi=4.0,
116+
predistort=True,
117+
drive_ratio=0.4,
118+
pulse_shaping=True,
119+
roll_off=ROLL_OFF,
120+
label=f"drv_{axis}",
121+
)
122+
)
123+
modulator = graph.add(IQModulator(v_pi=4.0, label=f"mod_{axis}"))
124+
graph.chain(prbs, mapper, driver)
125+
graph.connect(splitter[f"out{index}"], modulator["optical_in"])
126+
graph.connect(driver["i"], modulator["i"])
127+
graph.connect(driver["q"], modulator["q"])
128+
mappers[axis], modulators[axis] = mapper, modulator
129+
130+
combiner = graph.add(PolarizationCombiner(label="pbc"))
131+
graph.connect(modulators["x"], combiner["x"])
132+
graph.connect(modulators["y"], combiner["y"])
133+
134+
tail: Component = combiner
135+
if span_km:
136+
fiber = graph.add(Fiber(length=span_km, attenuation=0.2, dispersion=17.0, label="fib"))
137+
booster = graph.add(EDFA(gain=0.2 * span_km, noise_figure=5.0, label="edfa"))
138+
graph.connect(combiner, fiber["in"])
139+
graph.connect(fiber, booster["in"])
140+
tail = booster
141+
if pad_db:
142+
pad = graph.add(Attenuator(attenuation=pad_db, label="voa"))
143+
loader = graph.add(EDFA(gain=pad_db, noise_figure=5.0, label="ase"))
144+
graph.connect(tail, pad["in"])
145+
graph.connect(pad, loader["in"])
146+
tail = loader
147+
148+
meter = graph.add(OSNRMeter(label="osnr"))
149+
graph.connect(tail, meter["in"])
150+
151+
lo = graph.add(CWLaser(power=13.0, wavelength=1550.0, linewidth=100.0, label="lo"))
152+
receiver = graph.add(DualPolarizationReceiver(responsivity=0.8, label="rx"))
153+
graph.connect(tail, receiver["in"])
154+
graph.connect(lo, receiver["lo"])
155+
156+
samplers: dict[str, IQSampler] = {}
157+
for axis in ("x", "y"):
158+
source: Component = receiver
159+
if span_km:
160+
compensator = graph.add(
161+
DispersionCompensator(
162+
accumulated_dispersion=17.0 * span_km, wavelength=1550.0, label=f"cdc_{axis}"
163+
)
164+
)
165+
graph.connect(receiver[f"{axis}i"], compensator["i"])
166+
graph.connect(receiver[f"{axis}q"], compensator["q"])
167+
source = compensator
168+
ports = ("i", "q")
169+
else:
170+
ports = (f"{axis}i", f"{axis}q")
171+
sampler = graph.add(IQSampler(matched_filter=True, roll_off=ROLL_OFF, label=f"smp_{axis}"))
172+
graph.connect(source[ports[0]], sampler["i"])
173+
graph.connect(source[ports[1]], sampler["q"])
174+
graph.connect(mappers[axis]["out"], sampler["reference"])
175+
samplers[axis] = sampler
176+
177+
sources: dict[str, Port] = {axis: samplers[axis]["out"] for axis in ("x", "y")}
178+
if equalize:
179+
equalizer = graph.add(ButterflyEqualizer(label="eq"))
180+
graph.connect(samplers["x"]["out"], equalizer["x"])
181+
graph.connect(samplers["y"]["out"], equalizer["y"])
182+
sources = {axis: equalizer[f"{axis}_out"] for axis in ("x", "y")}
183+
184+
analyzers: dict[str, ConstellationAnalyzer] = {}
185+
for axis in ("x", "y"):
186+
recovery = graph.add(CarrierRecovery(label=f"cr_{axis}"))
187+
graph.connect(sources[axis], recovery["in"])
188+
analyzer = graph.add(ConstellationAnalyzer(ignore_edges=128.0, label=f"vsa_{axis}"))
189+
graph.connect(recovery["out"], analyzer["in"])
190+
graph.connect(mappers[axis]["out"], analyzer["reference"])
191+
analyzers[axis] = analyzer
192+
return graph, analyzers, meter
193+
194+
195+
def measure(
196+
symbol_rate: float, bits: int, pad_db: float, *, equalize: bool = True
197+
) -> tuple[float, float]:
198+
"""OSNR and counted BER, averaged over the two tributaries."""
199+
graph, analyzers, meter = build(symbol_rate, bits, pad_db=pad_db, equalize=equalize)
200+
results = graph.run()
201+
counted = [results[analyzer].ber_counted for analyzer in analyzers.values()]
202+
return float(results[meter]), sum(counted) / len(counted)
203+
204+
205+
def measured_required_osnr(
206+
symbol_rate: float, bits: int, *, equalize: bool = True, threshold: float = THRESHOLD
207+
) -> float:
208+
"""Bisect the attenuator until the counted BER lands on the threshold.
209+
210+
Counted, not estimated: an estimate derived from the measured SNR through the
211+
same closed form the answer is compared against would be circular, and the
212+
window carries enough symbols for a few hundred errors at this rate.
213+
"""
214+
low, high = 0.0, 40.0
215+
for _ in range(14):
216+
middle = 0.5 * (low + high)
217+
if measure(symbol_rate, bits, middle, equalize=equalize)[1] < threshold:
218+
low = middle
219+
else:
220+
high = middle
221+
return measure(symbol_rate, bits, 0.5 * (low + high), equalize=equalize)[0]
222+
223+
224+
def layout(graph: Graph) -> dict[str, dict[str, float]]:
225+
"""Positions for the studio: a column per stage, in dependency order."""
226+
stages = [
227+
("prbs", "map", "drv"),
228+
("tx", "pbs", "mod", "pbc"),
229+
("fib", "edfa", "voa", "ase", "lo"),
230+
("osnr", "rx", "cdc"),
231+
("smp", "eq"),
232+
("cr", "vsa"),
233+
]
234+
positions: dict[str, dict[str, float]] = {}
235+
for column, prefixes in enumerate(stages):
236+
row = 0
237+
for component in graph.components:
238+
if not component.label.split("_")[0].startswith(tuple(prefixes)):
239+
continue
240+
if component.label in positions:
241+
continue
242+
positions[component.label] = {"x": 40.0 + column * 150.0, "y": 40.0 + row * 90.0}
243+
row += 1
244+
for component in graph.components:
245+
positions.setdefault(component.label, {"x": 40.0, "y": 40.0})
246+
return positions
247+
248+
249+
def main() -> None:
250+
print("Reference transceivers, dual-polarization coherent.\n")
251+
print(
252+
f"{'configuration':16} {'GBd':>8} {'line rate':>11} {'payload':>9} "
253+
f"{'slot':>7} {'b/s/Hz':>7}"
254+
)
255+
print("-" * 64)
256+
for name, (rate, bits, slot) in CONFIGURATIONS.items():
257+
line = rate * bits * 2
258+
payload = 400e9 if name.startswith("400G") else 800e9
259+
print(
260+
f"{name:16} {rate / 1e9:8.2f} {line / 1e9:8.0f} Gb/s {payload / 1e9:6.0f} G "
261+
f"{slot / 1e9:5.0f} GHz {payload / slot:7.2f}"
262+
)
263+
print(
264+
f"\n Line rate is baud x bits x 2 polarizations. A 400 Gb/s payload inside\n"
265+
f" {BAUD_400G * 4 * 2 / 1e9:.0f} Gb/s leaves "
266+
f"{100 * (1 - 400e9 / (BAUD_400G * 4 * 2)):.1f} % for forward error correction and\n"
267+
f" framing, and the 800G rows carry the same fraction."
268+
)
269+
270+
print(f"\nRequired OSNR at a pre-FEC BER of {THRESHOLD:.0e}:\n")
271+
print(
272+
f"{'configuration':16} {'closed form':>12} {'ideal DSP':>11} {'penalty':>8} "
273+
f"{'blind equaliser':>16}"
274+
)
275+
print("-" * 68)
276+
ideal: dict[str, float] = {}
277+
for name, (rate, bits, _) in CONFIGURATIONS.items():
278+
want = required_osnr(THRESHOLD, bits, symbol_rate=rate)
279+
clean = measured_required_osnr(rate, bits, equalize=False)
280+
blind = measured_required_osnr(rate, bits, equalize=True)
281+
ideal[name] = clean
282+
print(f"{name:16} {want:9.2f} dB {clean:8.2f} dB {clean - want:+8.2f} {blind:13.2f} dB")
283+
print(
284+
"\n The closed form assumes a perfect transmitter, perfect DSP and a\n"
285+
" noiseless receiver, so the gap in the fourth column is the transmitter's\n"
286+
" implementation penalty.\n"
287+
"\n The fifth is a finding rather than a specification. Nothing rotates the\n"
288+
" polarization on this bench, so the blind butterfly equaliser has nothing\n"
289+
" to undo and should cost nothing — and at 16-QAM it does not. At 64-QAM it\n"
290+
" diverges: nine constellation radii sit close enough together that a noisy\n"
291+
" sample snaps to the wrong one, and the correction that follows is large\n"
292+
" and in the wrong direction. A smaller step recovers most of it and a\n"
293+
" single tap all of it, so the structure is right and the adaptation is not.\n"
294+
" A normalised update is the textbook fix; it moves every other format's\n"
295+
" answer too, so it is a separate piece of work and not this one."
296+
)
297+
298+
print("\nTwo ways to carry 800 Gb/s, with the DSP out of the way:\n")
299+
doubled = ideal["800G DP-16QAM"] - ideal["400G DP-16QAM"]
300+
denser = ideal["800G DP-64QAM"] - ideal["800G DP-16QAM"]
301+
print(f" twice the baud, same format {doubled:+6.2f} dB of OSNR, twice the spectrum")
302+
print(f" denser format, less baud {denser:+6.2f} dB of OSNR, two thirds of it")
303+
print(
304+
"\n The first is the price of bandwidth and is 3 dB in theory: twice the\n"
305+
" symbol rate collects twice the noise and nothing else changes. The second\n"
306+
" buys a third of the spectrum back, and is what a link with filled fibre\n"
307+
" and optical SNR to spare pays for it."
308+
)
309+
310+
here = Path(__file__).parent
311+
for name, filename in (("400G DP-16QAM", "zr400.maiman"), ("800G DP-16QAM", "zr800.maiman")):
312+
rate, bits, _ = CONFIGURATIONS[name]
313+
graph, _, _ = build(rate, bits, span_km=SPAN_KM, sequence_length=1024)
314+
save(graph, here / filename, ui=layout(graph))
315+
print(f"\nwrote {filename}: {name} over {SPAN_KM:.0f} km, {len(graph.components)} blocks")
316+
317+
318+
if __name__ == "__main__":
319+
main()

0 commit comments

Comments
 (0)