Skip to content

Commit 376ac2a

Browse files
ehsun-shclaude
andcommitted
Phase 0: engine core, scheduler, and first validated components
Implements the foundation described in docs/ARCHITECTURE.md §3-§4, driven by two physics tests rather than designed in the abstract. Core data model: - SimulationContext holds bit rate, oversampling, sequence length and seed, so blocks cannot silently disagree about the time window. Per-block RNG streams derive from (seed, block identity), so adding a block does not perturb the noise realisation of any other. - OpticalSignal carries a list of independently sampled Bands plus spectral NoiseBins. Multi-band is exercised from the first week — a single-carrier model passes every attenuation test while being unable to represent WDM at all, and retrofitting it later would mean rewriting every block. - Fields are read-only, so metadata-only blocks share buffers instead of copying a span at a time. - Units are declared per parameter and converted in one tested place. Execution engine: - Block-mode: each component runs once over the whole time window, making every block a pure function of its inputs. - Topological scheduling with port-type validation, cycle detection, and release of intermediates once their last consumer has run. Components: CW laser (with Wiener phase noise), fiber (attenuation only), combiner, attenuator, power meter. Validation (66 tests): attenuation against P_out = P_in*10^(-aL/10), loss additivity across cascaded spans, source power independent of window length, phase noise conserving average power, and two carriers surviving as separate bands with THz spacing that never enters the sample rate. Tooling: ruff, mypy (typed, strict-ish), pytest, CI on Python 3.11-3.13. FFTW is deliberately absent — it is GPL and would relicense the project. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 357c638 commit 376ac2a

17 files changed

Lines changed: 2094 additions & 37 deletions

File tree

.github/workflows/ci.yml

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
workflow_dispatch:
8+
9+
concurrency:
10+
group: ${{ github.workflow }}-${{ github.ref }}
11+
cancel-in-progress: true
12+
13+
jobs:
14+
check:
15+
runs-on: ubuntu-latest
16+
strategy:
17+
fail-fast: false
18+
matrix:
19+
python-version: ["3.11", "3.12", "3.13"]
20+
21+
steps:
22+
- uses: actions/checkout@v4
23+
24+
- uses: actions/setup-python@v5
25+
with:
26+
python-version: ${{ matrix.python-version }}
27+
cache: pip
28+
29+
- name: Install
30+
run: |
31+
python -m pip install --upgrade pip
32+
pip install -e ".[dev]"
33+
34+
- name: Lint
35+
run: |
36+
ruff check .
37+
ruff format --check .
38+
39+
- name: Type check
40+
run: mypy
41+
42+
- name: Test
43+
run: pytest

README.md

Lines changed: 108 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,72 @@
22

33
**An open-source, modular simulator for optical communication links and photonic systems.**
44

5-
![status](https://img.shields.io/badge/status-design%20phase-orange)
5+
[![CI](https://github.com/ehsun-sh/OpenOptisim/actions/workflows/ci.yml/badge.svg)](https://github.com/ehsun-sh/OpenOptisim/actions/workflows/ci.yml)
6+
![status](https://img.shields.io/badge/status-pre--alpha-orange)
67
![license](https://img.shields.io/badge/license-Apache--2.0-blue)
78
![python](https://img.shields.io/badge/python-3.11%2B-blue)
89

910
---
1011

11-
> ### ⚠️ Project status: design phase — no code yet
12+
> ### ⚠️ Project status: pre-alpha, Phase 0
1213
>
13-
> This repository currently contains **the architecture and roadmap only**. There is no working
14-
> simulator here. It is published early so that the design can be reviewed and criticised before
15-
> implementation starts — which is the cheapest possible time to find out that something is wrong.
14+
> 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.
1618
>
17-
> If you are here to evaluate the idea, the document to read is
18-
> **[the architecture & roadmap](docs/ARCHITECTURE.md)**.
19-
> Feedback on §3 (data model) and §4 (execution engine) is worth more than feedback on anything else.
19+
> **Not implemented yet:** modulators, photodetectors, dispersion, SSFM/nonlinearity, DSP,
20+
> BER/eye analysis, and the GUI. Those are Phases 1–2; see the [roadmap](#roadmap).
21+
>
22+
> This is not yet a useful simulator. It is a foundation with the expensive decisions made and
23+
> tested. Criticism of those decisions is worth more right now than any feature —
24+
> **[the architecture document](docs/ARCHITECTURE.md)** is where they are argued out.
2025
2126
---
2227

28+
## Try it
29+
30+
```bash
31+
pip install -e ".[dev]" && pytest
32+
```
33+
34+
```python
35+
from oosim import SimulationContext, Graph
36+
from oosim.components import CWLaser, Combiner, Fiber, PowerMeter
37+
38+
ctx = SimulationContext(bit_rate=10e9, samples_per_symbol=16, sequence_length=64)
39+
g = Graph(ctx)
40+
41+
laser = g.add(CWLaser(power=0.0, wavelength=1550.0)) # 0 dBm
42+
fiber = g.add(Fiber(length=80.0, attenuation=0.2)) # 80 km, 0.2 dB/km
43+
meter = g.add(PowerMeter())
44+
g.chain(laser, fiber, meter)
45+
46+
print(g.run()[meter]) # PowerReading(-16.000 dBm; 1550.00nm=-16.000dBm)
47+
```
48+
49+
Two carriers stay two independently sampled bands, which is the point of the signal model:
50+
51+
```python
52+
g = Graph(ctx)
53+
ch1 = g.add(CWLaser(wavelength=1550.0, label="ch1"))
54+
ch2 = g.add(CWLaser(wavelength=1551.0, label="ch2"))
55+
mux = g.add(Combiner(2))
56+
fiber = g.add(Fiber(length=80.0, attenuation=0.2))
57+
meter = g.add(PowerMeter())
58+
59+
g.connect(ch1, mux["in0"])
60+
g.connect(ch2, mux["in1"])
61+
g.chain(mux, fiber, meter)
62+
63+
print(g.run()[meter])
64+
# PowerReading(-12.990 dBm; 1551.00nm=-16.000dBm, 1550.00nm=-16.000dBm)
65+
```
66+
67+
Each band carries its own centre frequency, so channel spacing never enters the sample rate.
68+
Put those two lasers 6 THz apart instead of 125 GHz and nothing about the run changes — which is
69+
exactly what a single-carrier signal model cannot do.
70+
2371
## What this is
2472

2573
A block-diagram simulator for optical systems: drop components on a canvas, wire a link, run it,
@@ -109,29 +157,39 @@ reachable from Python, it does not exist.**
109157

110158
## The core data model
111159

112-
The part most worth reviewing. An optical signal is not one array of numbers:
160+
The part most worth reviewing — see [`src/oosim/signals.py`](src/oosim/signals.py). An optical
161+
signal is not one array of numbers:
113162

114163
```python
115-
@dataclass
164+
@dataclass(frozen=True)
116165
class Band:
117166
"""One sampled band: complex envelope in two orthogonal polarizations (Jones vector)."""
118-
Ex: np.ndarray # complex64, shape (N,)
167+
168+
Ex: np.ndarray # complex64, shape (N,), read-only
119169
Ey: np.ndarray
120-
f0: float # band center frequency [Hz]
121-
fs: float # band sample rate [Hz]
170+
f0: float # band centre frequency [Hz]
171+
fs: float # band sample rate [Hz]
172+
122173

123-
@dataclass
174+
@dataclass(frozen=True)
124175
class NoiseBin:
125176
"""Spectrally-resolved noise, carried separately from the sampled bands."""
126-
f_start: float; f_end: float
127-
psd_x: float; psd_y: float # [W/Hz] per polarization
128177

129-
@dataclass
178+
f_start: float
179+
f_end: float
180+
psd_x: float # [W/Hz] per polarization
181+
psd_y: float
182+
183+
184+
@dataclass(frozen=True)
130185
class OpticalSignal:
131-
bands: list[Band]
132-
noise: list[NoiseBin]
186+
bands: tuple[Band, ...]
187+
noise: tuple[NoiseBin, ...]
133188
```
134189

190+
Fields are `sqrt(W)`, so instantaneous power is `|Ex|**2 + |Ey|**2`. Arrays are read-only, which
191+
is what lets metadata-only blocks share buffers instead of copying a span at a time.
192+
135193
Global run parameters (bit rate, oversampling, sequence length, RNG seed) live in a shared
136194
`SimulationContext`, not in individual signals — so blocks cannot silently disagree about the
137195
time window, and results are reproducible.
@@ -140,7 +198,7 @@ time window, and results are reproducible.
140198

141199
| Phase | Scope | Estimate¹ |
142200
| :--- | :--- | :--- |
143-
| **0 — Foundations** | Signal model, context, port types, component base, scheduler, project format, CI | ~1 month |
201+
| **0 — Foundations** *(in progress)* |Signal model, context, port types, component base, scheduler, CI · ⬜ project file format, sweeps | ~1 month |
144202
| **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 |
145203
| **1.5 — Nonlinear & amplified** | Adaptive-step SSFM, Kerr, PMD, EDFA (gain/NF/saturation/ASE), APD | ~2 months |
146204
| **2 — GUI & DSP** | Graph editor, plots, pulse shaping, FIR, equalizers (LMS/CMA), OSA, constellation, sweeps | ~3–4 months |
@@ -154,38 +212,51 @@ are all pushed out of it. Shipping a *validated* linear link quickly matters mor
154212

155213
## Validation
156214

157-
Every physics block ships with a test against a closed-form result, run in CI:
158-
159-
| Case | Expected |
160-
| :--- | :--- |
161-
| Lossless, dispersionless, linear fiber | Output bit-identical to input |
162-
| Attenuation only | `P_out = P_in · exp(-αL)` |
163-
| Gaussian pulse, CD only | `T(z) = T₀·√(1 + (z/L_D)²)`, `L_D = T₀²/\|β₂\|` |
164-
| Lossless SSFM | Energy conserved (Parseval) |
165-
| Fundamental soliton (N=1) | Envelope magnitude invariant along propagation |
166-
| Ideal push-pull MZM | `P_out/P_in = cos²(πV / 2V_π)` |
167-
| PIN detector | `I = R·P`; shot `σ² = 2qIB`; thermal `σ² = 4kTB/R_L` |
168-
| Ideal OOK, Gaussian noise | `BER = ½·erfc(Q/√2)` |
169-
| EDFA | `P_ASE = 2·n_sp·hν·(G−1)·B_o` |
215+
Every physics block ships with a test against a closed-form result, run in CI
216+
([`tests/test_physics.py`](tests/test_physics.py)):
217+
218+
| Case | Expected | |
219+
| :--- | :--- | :-- |
220+
| Attenuation | `P_out = P_in · 10^(-αL/10)` ||
221+
| Cascaded spans | Loss is additive in dB ||
222+
| Source power | Independent of the simulated time window ||
223+
| Phase noise | Broadens the line, conserves average power ||
224+
| 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) ||
227+
| Fundamental soliton (N=1) | Envelope magnitude invariant along propagation ||
228+
| Ideal push-pull MZM | `P_out/P_in = cos²(πV / 2V_π)` ||
229+
| PIN detector | `I = R·P`; shot `σ² = 2qIB`; thermal `σ² = 4kTB/R_L` ||
230+
| Ideal OOK, Gaussian noise | `BER = ½·erfc(Q/√2)` ||
231+
| EDFA | `P_ASE = 2·n_sp·hν·(G−1)·B_o` ||
170232

171233
Component models are derived from published literature and standards (Agrawal, *Nonlinear Fiber
172234
Optics*; ITU-T G.652 / G.694.1; relevant IEEE 802.3 clauses), cited in each component's
173235
docstring — never from inspection of commercial tools.
174236

175237
## Contributing
176238

177-
Not open for code contributions yet — there is no code. What is genuinely useful right now:
239+
The core is small enough that changing it is still cheap, which makes right now the most useful
240+
time to push back on it. Most valuable first:
178241

179-
* **Review the [architecture document](docs/ARCHITECTURE.md)**, especially the
180-
signal data model (§3) and the execution engine (§4). If something there is wrong, now is when
181-
it is cheap to fix.
242+
* **Review the signal model and scheduler**[`src/oosim/signals.py`](src/oosim/signals.py),
243+
[`src/oosim/graph.py`](src/oosim/graph.py), and §3–§4 of the
244+
[architecture document](docs/ARCHITECTURE.md). If something there is wrong, it is far cheaper
245+
to fix now than after fifty components depend on it.
182246
* **Tell us if this duplicates existing work.** If a project already does this well, that is worth
183247
knowing before several months go into it.
184248
* **Describe your use case.** Which components, which measurements, what you currently use and
185249
what frustrates you about it.
250+
* **Add a component.** A component is a Python class with declared parameters and typed ports —
251+
see [`src/oosim/components/`](src/oosim/components/) for the pattern. Every physics block needs
252+
a test against a closed-form result; a component without one will not be merged.
186253

187254
Open an issue for any of the above.
188255

256+
```bash
257+
pip install -e ".[dev]" && ruff check . && ruff format --check . && mypy && pytest
258+
```
259+
189260
## License
190261

191262
[Apache-2.0](LICENSE) — permissive enough for industrial adoption, with an explicit patent grant.

pyproject.toml

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
[build-system]
2+
requires = ["hatchling"]
3+
build-backend = "hatchling.build"
4+
5+
[project]
6+
name = "oosim"
7+
version = "0.0.1.dev0"
8+
description = "Open-source simulator for optical communication links and photonic systems"
9+
readme = "README.md"
10+
requires-python = ">=3.11"
11+
license = "Apache-2.0"
12+
license-files = ["LICENSE"]
13+
authors = [{ name = "Ehsan Shahmohammadi" }]
14+
keywords = ["photonics", "optical-communications", "simulation", "fiber-optics", "dsp"]
15+
classifiers = [
16+
"Development Status :: 2 - Pre-Alpha",
17+
"Intended Audience :: Science/Research",
18+
"Programming Language :: Python :: 3.11",
19+
"Programming Language :: Python :: 3.12",
20+
"Programming Language :: Python :: 3.13",
21+
"Topic :: Scientific/Engineering :: Physics",
22+
]
23+
24+
# NOTE: FFTW is deliberately absent. It is GPL-2.0-or-later; linking it (directly
25+
# or via pyFFTW) would force this project to GPL. numpy/scipy use pocketfft (BSD).
26+
dependencies = ["numpy>=1.26"]
27+
28+
[project.optional-dependencies]
29+
dev = ["pytest>=8", "mypy>=1.11", "ruff>=0.6"]
30+
31+
[project.urls]
32+
Homepage = "https://github.com/ehsun-sh/OpenOptisim"
33+
Documentation = "https://github.com/ehsun-sh/OpenOptisim/blob/main/docs/ARCHITECTURE.md"
34+
35+
[tool.hatch.build.targets.wheel]
36+
packages = ["src/oosim"]
37+
38+
[tool.pytest.ini_options]
39+
testpaths = ["tests"]
40+
addopts = "-q --strict-markers"
41+
42+
[tool.ruff]
43+
line-length = 100
44+
src = ["src", "tests"]
45+
# Prose documents are written for readers, not for the formatter: aligned comments
46+
# in illustrative snippets are deliberate. Ruff owns the code, not the docs.
47+
extend-exclude = ["*.md"]
48+
49+
[tool.ruff.lint]
50+
select = ["E", "F", "W", "I", "N", "UP", "B", "SIM", "RUF"]
51+
52+
[tool.ruff.lint.per-file-ignores]
53+
# N803/N806: physics code uses standard symbol names (Ex, Ey, N, T0, L_D) that do
54+
# not fit PEP 8 casing. Readability against the literature wins over convention.
55+
# RUF012: `inputs`/`outputs` are class-level port declarations that Component.__init__
56+
# copies onto each instance, so a component with a configurable port count can
57+
# rebind its own. Annotating them ClassVar would forbid exactly that.
58+
"src/oosim/**" = ["N803", "N806", "RUF012"]
59+
"tests/**" = ["N802", "N803", "N806"]
60+
61+
[tool.mypy]
62+
python_version = "3.11"
63+
files = ["src", "tests"]
64+
disallow_untyped_defs = true
65+
disallow_incomplete_defs = true
66+
warn_redundant_casts = true
67+
warn_unused_ignores = true
68+
warn_unreachable = true
69+
no_implicit_optional = true
70+
strict_equality = true

src/oosim/__init__.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""OpenOptiSim — simulation of optical communication links and photonic systems.
2+
3+
The public surface is deliberately small. Everything the GUI will eventually do
4+
goes through it: if a feature is not reachable from here, it does not exist.
5+
6+
>>> from oosim import SimulationContext, Graph
7+
>>> from oosim.components import CWLaser, Fiber, PowerMeter
8+
>>> ctx = SimulationContext(bit_rate=10e9, samples_per_symbol=16, sequence_length=64)
9+
>>> g = Graph(ctx)
10+
>>> laser = g.add(CWLaser(power=0.0, wavelength=1550.0))
11+
>>> fiber = g.add(Fiber(length=80.0, attenuation=0.2))
12+
>>> meter = g.add(PowerMeter())
13+
>>> g.chain(laser, fiber, meter)
14+
>>> round(g.run()[meter].power_dbm, 3)
15+
-16.0
16+
"""
17+
18+
from __future__ import annotations
19+
20+
from .component import Component, Param, Port, PortType
21+
from .context import SimulationContext
22+
from .graph import CycleError, Graph, GraphError, Results
23+
from .signals import (
24+
Band,
25+
BandPower,
26+
ElectricalSignal,
27+
NoiseBin,
28+
OpticalSignal,
29+
PowerReading,
30+
)
31+
32+
__version__ = "0.0.1.dev0"
33+
34+
__all__ = [
35+
"Band",
36+
"BandPower",
37+
"Component",
38+
"CycleError",
39+
"ElectricalSignal",
40+
"Graph",
41+
"GraphError",
42+
"NoiseBin",
43+
"OpticalSignal",
44+
"Param",
45+
"Port",
46+
"PortType",
47+
"PowerReading",
48+
"Results",
49+
"SimulationContext",
50+
"__version__",
51+
]

0 commit comments

Comments
 (0)