Skip to content

Commit 51af6f2

Browse files
ehsun-shclaude
andcommitted
Add chromatic dispersion with sign-verified validation
Fiber gains a `dispersion` parameter (D in ps/nm/km). Propagation solves the linear NLSE exactly in the frequency domain: A(z,w) = A(0,w)*exp(i*b2*w^2*z/2), an all-pass phase rotation, so it conserves energy and inverts exactly. beta2 is computed per band from that band's own wavelength. Two channels hundreds of nanometres apart really do disperse differently, and the multi-band signal model gets that right with no special handling — a single-carrier model could not express it at all. New: kernels.py (frequency grid, D->beta2, dispersion propagator) as the narrow array-in/array-out boundary a CuPy or native back-end can later replace; analysis.py (RMS time width, peak/instantaneous power); GaussianPulse source, which exists because a Gaussian through pure GVD has a closed-form solution and is therefore the reference input for validating the fiber. Validation (18 new tests, 85 total): - Unchirped broadening T1/T0 = sqrt(1 + (z/L_D)^2) across z/L_D = 0.5 to 3 - L_D is where the pulse broadens by sqrt(2); broadening scales as T0^2 - Chirped broadening, including compression to ~45% for C=+2 in anomalous fiber - Energy conservation (Parseval), exact reversibility with +D then -D - beta2 = -21.7 ps^2/km for D = 17 ps/nm/km at 1550 nm, with the sign asserted - Each band dispersed at its own wavelength The chirped tests are not redundant with the unchirped ones: the unchirped broadening factor is even in beta2 and passes under either sign. Verified by flipping the sign in the propagator — 13 unchirped tests still passed while all 5 chirped ones failed. That blind spot is documented in kernels.py. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 301a86d commit 51af6f2

8 files changed

Lines changed: 477 additions & 18 deletions

File tree

README.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,11 @@
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. Four components are
16-
> implemented — CW laser, fiber (attenuation only), combiner/attenuator, power meter — and the
17-
> physics is validated against closed-form results in CI.
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.
1818
>
19-
> **Not implemented yet:** modulators, photodetectors, dispersion, SSFM/nonlinearity, DSP,
19+
> **Not implemented yet:** modulators, photodetectors, SSFM/nonlinearity, PMD, amplifiers, DSP,
2020
> BER/eye analysis, and the GUI. Those are Phases 1–2; see the [roadmap](#roadmap).
2121
>
2222
> This is not yet a useful simulator. It is a foundation with the expensive decisions made and
@@ -222,8 +222,11 @@ Every physics block ships with a test against a closed-form result, run in CI
222222
| Source power | Independent of the simulated time window ||
223223
| Phase noise | Broadens the line, conserves average power ||
224224
| Multi-carrier | Channels stay separate bands; spacing does not drive `Fs` ||
225-
| Gaussian pulse, CD only | `T(z) = T₀·√(1 + (z/L_D)²)`, `L_D = T₀²/\|β₂\|` ||
226-
| Lossless SSFM | Energy conserved (Parseval) ||
225+
| Gaussian pulse, CD only | `T₁/T₀ = √(1 + (z/L_D)²)`, `L_D = T₀²/\|β₂\|` ||
226+
| Chirped Gaussian | `T₁/T₀ = √((1 + Cβ₂z/T₀²)² + (β₂z/T₀²)²)` — pins the sign of β₂ ||
227+
| Dispersion compensation | `+D` then `−D` restores the input sample-for-sample ||
228+
| GVD | Energy conserved (Parseval); β₂ = −Dλ²/2πc per band ||
229+
| Lossless SSFM | Energy conserved with nonlinearity ||
227230
| Fundamental soliton (N=1) | Envelope magnitude invariant along propagation ||
228231
| Ideal push-pull MZM | `P_out/P_in = cos²(πV / 2V_π)` ||
229232
| PIN detector | `I = R·P`; shot `σ² = 2qIB`; thermal `σ² = 4kTB/R_L` ||

src/oosim/analysis.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""Measurements computed from signals.
2+
3+
These are plain functions rather than components on purpose: the same reductions
4+
are needed by the test suite, by scripts, and later by measurement blocks and the
5+
GUI. Keeping them here means one implementation and one set of tests.
6+
7+
They are also where result data gets *reduced*. A browser must never receive a
8+
million raw samples; it receives what these functions return.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import numpy as np
14+
15+
from .signals import Band
16+
17+
18+
def instantaneous_power(band: Band) -> np.ndarray:
19+
"""Instantaneous power [W] per sample, summed over both polarizations."""
20+
return (np.abs(band.Ex.astype(np.complex128)) ** 2) + (
21+
np.abs(band.Ey.astype(np.complex128)) ** 2
22+
)
23+
24+
25+
def rms_time_width(band: Band) -> float:
26+
"""RMS width of the intensity envelope in time [s].
27+
28+
The second central moment of ``|A(t)|**2``. For a Gaussian intensity profile
29+
``exp(-(T/T0)**2)`` this equals ``T0 / sqrt(2)``, so *ratios* of this quantity
30+
track ``T1/T0`` exactly — which is what the dispersion validation compares
31+
against the analytical broadening factor.
32+
33+
The time window is periodic, so this is only meaningful while the pulse stays
34+
well inside it. A pulse that has spread far enough to wrap around will report
35+
a width that is wrong rather than merely imprecise.
36+
"""
37+
power = instantaneous_power(band)
38+
total = float(power.sum())
39+
if total <= 0.0:
40+
raise ValueError("cannot measure the width of a signal carrying no power")
41+
42+
t = np.arange(band.num_samples, dtype=np.float64) / band.fs
43+
mean = float((power * t).sum() / total)
44+
variance = float((power * (t - mean) ** 2).sum() / total)
45+
return float(np.sqrt(variance))
46+
47+
48+
def peak_power(band: Band) -> float:
49+
"""Highest instantaneous power in the window [W]."""
50+
return float(instantaneous_power(band).max())

src/oosim/components/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,6 @@
1010
from .fiber import Fiber
1111
from .meters import PowerMeter
1212
from .passive import Attenuator, Combiner
13-
from .sources import CWLaser
13+
from .sources import CWLaser, GaussianPulse
1414

15-
__all__ = ["Attenuator", "CWLaser", "Combiner", "Fiber", "PowerMeter"]
15+
__all__ = ["Attenuator", "CWLaser", "Combiner", "Fiber", "GaussianPulse", "PowerMeter"]

src/oosim/components/fiber.py

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,45 @@
11
"""Optical fiber.
22
3-
Currently the linear, lossy model only. Chromatic dispersion is the next slice;
4-
Kerr nonlinearity and PMD follow in Phase 1.5 with an adaptive-step SSFM.
3+
Currently the linear model: attenuation and chromatic dispersion. Kerr
4+
nonlinearity and PMD follow in Phase 1.5, with an adaptive-step SSFM built on
5+
the same frequency-domain kernel this module already uses.
6+
7+
Model references: G. P. Agrawal, *Nonlinear Fiber Optics*, ch. 2-3
8+
(NLSE, GVD-induced pulse broadening); ITU-T G.652 for typical parameter values.
59
"""
610

711
from __future__ import annotations
812

913
from ..component import Component, Param, PortType
1014
from ..context import SimulationContext
11-
from ..signals import OpticalSignal, Signal
15+
from ..kernels import dispersion_to_beta2, propagate_dispersion
16+
from ..signals import Band, OpticalSignal, Signal
1217
from ..units import db_to_linear
1318

1419

1520
class Fiber(Component):
16-
"""Single-mode fiber, attenuation only.
21+
"""Single-mode fiber: attenuation and chromatic dispersion.
22+
23+
Attenuation follows ``P_out = P_in * 10**(-alpha_dB_per_km * L_km / 10)``, so
24+
the field amplitude is scaled by the square root of that factor.
25+
26+
Dispersion is applied per band, using each band's *own* centre wavelength to
27+
compute β₂. Two channels a few nanometres apart really do see different
28+
dispersion, and because every band carries its own centre frequency the model
29+
gets that right for free — a single-carrier signal model could not express it.
1730
18-
Power obeys ``P_out = P_in * 10**(-alpha_dB_per_km * L_km / 10)``, so the
19-
field amplitude is scaled by the square root of that factor. Attenuation is
20-
wavelength-independent in this model: every band and every noise bin is
21-
scaled identically. Making loss wavelength-dependent is a later refinement
22-
and does not change the interface.
31+
Attenuation is wavelength-independent here, and the dispersion slope is not
32+
modelled (β₃ = 0); both are refinements that do not change the interface.
2333
"""
2434

2535
display_name = "Optical Fiber"
2636
category = "Fiber"
2737

2838
length = Param(80.0, unit="km", min=0.0, doc="Fiber span length")
2939
attenuation = Param(0.2, unit="dB/km", min=0.0, doc="Attenuation coefficient")
40+
dispersion = Param(
41+
0.0, unit="ps/nm/km", doc="Dispersion parameter D at the band wavelength (0 disables)"
42+
)
3043

3144
inputs = {"in": PortType.OPTICAL}
3245
outputs = {"out": PortType.OPTICAL}
@@ -36,14 +49,33 @@ def loss_db(self) -> float:
3649
# si() gives dB/m and metres, so their product is dB.
3750
return self.si("attenuation") * self.si("length")
3851

52+
def beta2_at(self, wavelength: float) -> float:
53+
"""Group-velocity dispersion β₂ [s²/m] at ``wavelength`` [m]."""
54+
return dispersion_to_beta2(self.si("dispersion"), wavelength)
55+
3956
def run(self, ctx: SimulationContext, inputs: dict[str, Signal]) -> dict[str, Signal]:
4057
signal: OpticalSignal = inputs["in"]
58+
distance = self.si("length")
4159
power_factor = db_to_linear(-self.loss_db())
4260
amplitude_factor = power_factor**0.5
4361

62+
bands = []
63+
for band in signal.bands:
64+
beta2 = self.beta2_at(band.wavelength)
65+
Ex = propagate_dispersion(band.Ex, band.fs, beta2, distance) * amplitude_factor
66+
Ey = propagate_dispersion(band.Ey, band.fs, beta2, distance) * amplitude_factor
67+
bands.append(
68+
Band(
69+
Ex=Ex.astype(ctx.complex_dtype),
70+
Ey=Ey.astype(ctx.complex_dtype),
71+
f0=band.f0,
72+
fs=band.fs,
73+
)
74+
)
75+
4476
return {
4577
"out": OpticalSignal(
46-
bands=tuple(b.scale_amplitude(amplitude_factor) for b in signal.bands),
78+
bands=tuple(bands),
4779
noise=tuple(n.scale_power(power_factor) for n in signal.noise),
4880
)
4981
}

src/oosim/components/sources.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,45 @@ def run(self, ctx: SimulationContext, inputs: dict[str, Signal]) -> dict[str, Si
5454
fs=ctx.sample_rate,
5555
)
5656
return {"out": OpticalSignal(bands=(band,))}
57+
58+
59+
class GaussianPulse(Component):
60+
"""A single chirped Gaussian pulse, centred in the time window.
61+
62+
Follows the standard form (Agrawal, *Nonlinear Fiber Optics*, eq. 3.2.1)::
63+
64+
A(0, T) = sqrt(P0) * exp(-(1 + i*C) / 2 * (T / T0)**2)
65+
66+
so the intensity envelope is ``P0 * exp(-(T/T0)**2)`` and ``width`` is T0, the
67+
1/e half-width of the *intensity* (``T_FWHM = 1.665 * T0``).
68+
69+
This exists because dispersion has an exact analytical solution for a Gaussian
70+
input, which makes it the reference input for validating the fiber model.
71+
"""
72+
73+
display_name = "Gaussian Pulse"
74+
category = "Optical Sources"
75+
76+
peak_power = Param(0.0, unit="dBm", doc="Peak power P0 (not average power)")
77+
width = Param(10.0, unit="ps", min=0.0, doc="T0, the 1/e intensity half-width")
78+
chirp = Param(0.0, doc="Linear chirp parameter C; sign matters against beta2")
79+
wavelength = Param(1550.0, unit="nm", min=1200.0, max=1700.0, doc="Vacuum wavelength")
80+
81+
outputs = {"out": PortType.OPTICAL}
82+
83+
def run(self, ctx: SimulationContext, inputs: dict[str, Signal]) -> dict[str, Signal]:
84+
t0 = self.si("width")
85+
if t0 <= 0.0:
86+
raise ValueError(f"{self.label}: width must be positive, got {self.width}")
87+
88+
tau = (ctx.time_axis() - ctx.time_window / 2.0) / t0
89+
amplitude = np.sqrt(self.si("peak_power"))
90+
Ex = amplitude * np.exp(-(1.0 + 1j * self.chirp) * tau**2 / 2.0)
91+
92+
band = Band(
93+
Ex=Ex.astype(ctx.complex_dtype),
94+
Ey=np.zeros(ctx.num_samples, dtype=ctx.complex_dtype),
95+
f0=C_LIGHT / self.si("wavelength"),
96+
fs=ctx.sample_rate,
97+
)
98+
return {"out": OpticalSignal(bands=(band,))}

src/oosim/kernels.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"""Numerical kernels.
2+
3+
Everything computationally heavy goes through this module. It is deliberately
4+
narrow — a handful of array-in/array-out functions with no knowledge of
5+
components, graphs, or units — so that the back-end can change without touching
6+
any physics code above it. Today that back-end is NumPy; CuPy (`cupy.fft` is a
7+
drop-in for `numpy.fft`) and a native module are the intended next options.
8+
9+
**FFT library.** `numpy.fft` uses pocketfft (BSD). FFTW is *not* used and must
10+
not be introduced: it is GPL-2.0-or-later, and linking it — directly or through
11+
`pyFFTW` — would relicense the whole project.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import numpy as np
17+
18+
from .units import C_LIGHT
19+
20+
21+
def angular_frequency_grid(num_samples: int, sample_rate: float) -> np.ndarray:
22+
"""Angular frequency offsets from the band centre [rad/s], in FFT order.
23+
24+
Returned in `numpy.fft` output order (positive frequencies first, then
25+
negative), so it multiplies an un-shifted spectrum directly.
26+
"""
27+
return 2.0 * np.pi * np.fft.fftfreq(num_samples, d=1.0 / sample_rate)
28+
29+
30+
def dispersion_to_beta2(dispersion: float, wavelength: float) -> float:
31+
"""Convert the dispersion parameter D [s/m²] to the GVD parameter β₂ [s²/m].
32+
33+
``beta2 = -D * lambda**2 / (2*pi*c)``
34+
35+
The sign matters: standard single-mode fiber has D > 0 at 1550 nm and
36+
therefore β₂ < 0 (anomalous dispersion).
37+
"""
38+
return -dispersion * wavelength**2 / (2.0 * np.pi * C_LIGHT)
39+
40+
41+
def propagate_dispersion(
42+
field: np.ndarray, sample_rate: float, beta2: float, distance: float
43+
) -> np.ndarray:
44+
"""Propagate a complex envelope through pure group-velocity dispersion.
45+
46+
Solves the linear part of the NLSE, ``dA/dz = -(i*beta2/2) * d2A/dT2``, which
47+
in the frequency domain is an exact all-pass phase rotation::
48+
49+
A(z, w) = A(0, w) * exp(i * beta2 * w**2 * z / 2)
50+
51+
Because the transfer function has unit magnitude, this conserves energy
52+
exactly (up to floating-point) and is exactly invertible by propagating
53+
``-distance`` — both of which are asserted in the test suite.
54+
55+
Only β₂ is modelled, so the result is insensitive to the sign convention of
56+
the Fourier transform (ω appears squared). That stops being true as soon as
57+
β₃ or a group-delay term is added, so this note is worth keeping.
58+
59+
The sign of β₂ itself, however, is *not* free — and the unchirped broadening
60+
formula cannot detect an error in it, being even in β₂. Only the chirped case
61+
can, which is why ``test_chirped_pulse_compresses_before_broadening`` exists.
62+
63+
The phase argument reaches thousands of radians over a realistic span, so the
64+
transform runs in double precision regardless of the storage precision and
65+
the caller casts the result back. Correctness first; if profiling later shows
66+
this matters, the precision policy belongs here, in one place.
67+
"""
68+
if distance == 0.0 or beta2 == 0.0:
69+
return field.astype(np.complex128, copy=True)
70+
71+
omega = angular_frequency_grid(field.shape[0], sample_rate)
72+
transfer = np.exp(0.5j * beta2 * omega**2 * distance)
73+
return np.fft.ifft(np.fft.fft(field.astype(np.complex128)) * transfer)

src/oosim/units.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ def frequency_to_wavelength(f_hz: float) -> float:
7474
# Ratios
7575
"dB": db_to_linear,
7676
"dB/km": lambda x: x * 1e-3, # -> dB/m (still logarithmic, per metre)
77+
# Dispersion: ps/(nm*km) -> s/m^2
78+
"ps/nm/km": lambda x: x * 1e-6,
7779
# Length
7880
"m": lambda x: x,
7981
"km": lambda x: x * 1e3,
@@ -98,6 +100,7 @@ def frequency_to_wavelength(f_hz: float) -> float:
98100
"dBm": w_to_dbm,
99101
"dB": linear_to_db,
100102
"dB/km": lambda x: x * 1e3,
103+
"ps/nm/km": lambda x: x * 1e6,
101104
"m": lambda x: x,
102105
"km": lambda x: x * 1e-3,
103106
"nm": lambda x: x * 1e9,

0 commit comments

Comments
 (0)