Metadata-Version: 2.4
Name: phoq
Version: 0.4.0
Summary: Quantum optics and quantum-communication simulation with an interactive 3D virtual lab
Keywords: photonics,quantum optics,QKD,quantum communication,simulation,oscilloscope,fiber optics
Author: Ammar Alfaifi
Author-email: Ammar Alfaifi <me@ammarf.sa>
License-Expression: AGPL-3.0-or-later
License-File: LICENSE
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Science/Research
Classifier: Intended Audience :: Education
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Scientific/Engineering :: Physics
Classifier: Operating System :: OS Independent
Requires-Dist: fastapi>=0.143.1
Requires-Dist: numpy>=2.5.4
Requires-Dist: scipy>=1.18.1
Requires-Dist: uvicorn[standard]>=0.54.0
Requires-Dist: matplotlib>=3.11.2 ; extra == 'plot'
Requires-Python: >=3.14
Project-URL: Homepage, https://github.com/ammar-faifi/phoq
Project-URL: Source, https://github.com/ammar-faifi/phoq
Project-URL: Issues, https://github.com/ammar-faifi/phoq/issues
Provides-Extra: plot
Description-Content-Type: text/markdown

# phoq

[![CI](https://github.com/ammar-faifi/phoq/actions/workflows/ci.yml/badge.svg)](https://github.com/ammar-faifi/phoq/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/phoq.svg)](https://pypi.org/project/phoq/)

**phoq** (photon + q, it sounds like Fock) is a quantum optics and quantum-communication lab for Python. Build fiber-optic experiments from parts — lasers, modulators, couplers, attenuators, photodiodes, single-photon
detectors, function generators, time taggers, oscilloscopes — run them, post-process the clicks
with your own scripts, and run multi-party protocols such as QKD with every party in its own
process talking over real sockets. A 3D virtual lab in the browser shows the same Python objects
live and lets you edit them.

![The phoq virtual lab running the MP-QKD example: Alice's and Bob's transmitters, Charlie's coupler and detectors, the instrument shelf, and one laptop per party wired to a switch](https://raw.githubusercontent.com/ammar-faifi/phoq/main/docs/images/virtual-lab.webp)

*`phoq serve --protocol mpqkd`: Alice and Bob's transmitters, Charlie's coupler and single-photon
detectors, the generators on the shelf, and a laptop per party on the classical network. The
inspector shows the selected detector's parameters and the live click statistics computed in Python.*

## Install

```bash
pip install phoq
```

`pip install "phoq[plot]"` adds matplotlib for the plotting helpers. Python 3.14 or newer.
Inside this repository, `uv sync` installs everything and commands are run with `uv run …`.

## Quick start

```python
from phoq import Setup, Laser, Fiber, Photodiode, Oscilloscope

laser = Laser(power_dbm=-7, mode="pulsed", rep_rate=10e6, pulse_width=5e-9)
setup = Setup.from_chain(laser, Fiber(length=2), Photodiode(bandwidth=1e9), Oscilloscope(timebase=20e-9))

print(setup.run().summary())     # OSC-1: ch1 ← PD-1: mean 163 µA, pk-pk 2.99 mA, f 10 MHz
laser.power_dbm = 0              # parameters are plain, validated attributes
setup.serve()                    # optional: http://127.0.0.1:8000 shows it live in 3D
```

```bash
phoq run examples/delay_line.py              # headless
```

```bash
phoq serve examples/basic_link.py            # the virtual lab at http://127.0.0.1:8000
```

```bash
phoq serve --protocol examples/mpqkd         # MP-QKD: Alice, Bob and Charlie as processes
```

```bash
phoq run --protocol bb84 --party examples/bb84/eve.py   # decoy-state BB84 with an eavesdropper
```

## Concepts

| | |
| --- | --- |
| **Component** | A piece of equipment. Settings are `Param` class attributes, so `laser.wavelength = 1310e-9` is validated against the type, range and choices, and is observable. |
| **Port** | `laser.out`, `pd.inp`, `pd.rf`, … Each is `optical` or `electrical`. |
| **Setup** | Owns named components and port connections. `connect(a, b)` joins two ports. Two bulkhead optical ports automatically get a 1 m `Fiber` patch cord between them. `chain(...)` wires items one after another. |
| **Runner** | `setup.run()` acquires every instrument. `Runner(setup).sweep(comp, "param", values, measure)` sweeps a parameter. |
| **Instruments** | `Oscilloscope` (traces + measurements; selectable analog `bandwidth`, `sample_rate` and record memory `max_points`; `Runner.single(osc)` waits for the first trigger and holds that sweep), `PhotonCounter` (click record per pulse), `PowerMeter`. |

Units are SI throughout: meters, seconds, hertz and watts. Powers use `power_dbm` and losses `*_db`.

### Components

| Category | Classes |
| --- | --- |
| Sources | `Laser` (CW / pulsed / sine / OOK, linewidth, detuning, relative intensity noise, per-pulse phase randomization) |
| Fiber | `Fiber` (patch cord), `FiberSpool` |
| Passive | `Attenuator`, `Coupler` (2×2 fused fiber coupler; `BeamSplitter` is its deprecated old name), `PhaseShifter`, `Isolator`, `MatingSleeve` (FC–FC adapter joining two cords), `FiberTerminator` (cap for an unused port: plugs straight in, absorbs), `OpticalDelayLine` (variable free-space delay: dial, carrier phase, Gaussian-beam coupling loss) |
| Modulators | `PhaseModulator` (φ = π·V/Vπ), `IntensityModulator` (Mach–Zehnder, extinction) — voltage comes only through their `rf` and `bias` inputs; `IQModulator` (dual-parallel MZM: `rf_i`, `rf_q` and three DC biases, `bias_i`, `bias_q`, `bias_p`); `OpticalSwitch` (2×2, bar below the threshold voltage on `ctrl`, cross above: a pattern routes pulse by pulse) |
| Detectors | `Photodiode`, `BalancedPhotodetector` (two FC inputs whose photocurrents subtract before a switchable-gain amplifier: RF OUTPUT, and a MONITOR output per diode), `SinglePhotonDetector`, `PowerMeter` |
| Instruments | `Oscilloscope` (with an optional ADC resolution, `adc_bits`), `SpectrumAnalyzer` (RF spectrum in dBm: RBW, averaging, detectors, trace average and max hold, Lorentzian and short-delay linewidth fits), `PhotonCounter` (time tagger: per-channel input delays, sub-bins for pulses shorter than a bin), `FunctionGenerator` (2 outputs: dc / sine / square / triangle / ramp / loaded pattern), `DCPowerSupply` (2 outputs, ±30 V) |

Run `phoq catalog` for every port and parameter.

### Fiber connectors

Every fiber port has a connector type, following the usual color code: **FC/UPC blue**, **FC/APC
green**, and FC/PC (the older flat polish, as many datasheets still list it) blue too. A part's
`connector` sets all its fiber ports; a `Fiber` patch cord has `connector_a` and `connector_b`, so
a cord with one of each is a hybrid cord. `setup.connect()` between two bulkheads picks the cord a
lab would take, with ends that match the two ports. In the 3D lab connector housings and boots,
and the collar of every bulkhead, show the color. While you carry a connector, free ports where
it would make an angled-to-flat mating light up amber instead of cyan.

| Mated pair | Loss | Reflected back |
| --- | --- | --- |
| FC/UPC ↔ FC/UPC | 0.3 dB | −50 dB |
| FC/APC ↔ FC/APC | 0.3 dB | −60 dB |
| FC/PC ↔ FC/PC or FC/UPC | 0.3 dB | −40 dB (the worse face) |
| FC/APC ↔ FC/PC or FC/UPC | ≈ 5 dB, assumed | 3.6 % (−14.4 dB) into the flat side, −60 dB into the angled side |
| open FC/UPC or FC/PC end (a cord's or a part's) | — | 3.6 % back into the fiber |
| open FC/APC end | — | −60 dB |

The matching-pair values are typical specifications (`phoq.units.RETURN_LOSS_DB`,
`CONNECTOR_LOSS_DB`). An angled face against a flat one fits mechanically in an FC adaptor but
leaves an air gap. The flat face then reflects the full glass–air Fresnel 3.6 % straight back,
and the loss (`MISMATCH_LOSS_DB`) depends on the ferrule geometry. The 5 dB is an assumed value
to replace with your own measurement; the lab warns about any such mating whatever the number.

Reflections are traced, up to two per path: the inspector of every laser shows the setup's return
loss as the laser sees it and names the strongest reflection, e.g. `open FC/UPC end FIB-3.b
(−15.0 dB)`. Light reflected into a detector through a coupler, as in a reflectometer, is counted
like any other path, and interferes with it. A second reflection sends light on again, a double
bounce. These are kept above −90 dB, so two matching pairs (−100 dB) never count, but an open flat
end or an air gap does. Any unmated connector is open, a part's port as much as a cord's end: a
coupler's spare input left open reflects 3.6 %, and the report warns when what it reflects reaches
a detector within −70 dB of the direct light.

**Terminators.** A `FiberTerminator` caps an unused port. It plugs straight in, with no cord
(`setup.terminate(port)` adds one of the port's own type; `setup.terminate_spares()`, or **Cap spare
ports** in the lab's setup overview, caps every unused port of the couplers and other parts at once), and absorbs
the light it takes, so its only reflection is that of the mated pair: −60 dB for an FC/APC cap on
an FC/APC port. A green cap
on a blue port is an air gap, with the 3.6 % and the warning of any APC↔UPC pair. Cap the vacuum
input of a homodyne and the spare outputs of couplers. Left open, the vacuum input of
`examples/laser_noise.py` adds 5.2 dB of noise above the shot noise at 1.1 mA: LO light reflected
at a coupler-output mating leaves by it, comes back 10 ns late, and turns the laser's 100 kHz of
phase noise into intensity noise.

**Mating sleeves.** A `MatingSleeve` is an FC–FC adapter: plug a cord into each side and the two
cords' connectors mate inside it as one pair, with the loss and reflection of their two types. It
has no polish of its own, so a FC/APC cord meeting a FC/UPC one in a sleeve gets the same warning
as anywhere else. With one side empty, the other cord's end is an open connector. Cords plug
straight into a sleeve; between a sleeve and a part's bulkhead, or between two sleeves,
`connect()` inserts a cord, since two female sides cannot mate. Cascading cords shows what every
connection costs:

```bash
python examples/connector_cascade.py
```

```
                                       matings   at the PD   current back to LD  strongest reflection
1 cord                                       2   -0.60 dBm    827 µA   -47.3 dB  LD-1.out (FC/UPC) ↔ FIB-1.a (FC/UPC) (-50.0 dB)
4 cords, 3 sleeves                           5   -1.50 dBm    672 µA   -44.1 dB  LD-1.out (FC/UPC) ↔ FIB-1.a (FC/UPC) (-50.0 dB)
8 cords, 7 sleeves                           9   -2.70 dBm    510 µA   -42.6 dB  LD-1.out (FC/UPC) ↔ FIB-1.a (FC/UPC) (-50.0 dB)
4 FC/APC cords, APC laser and PD             5   -1.50 dBm    672 µA   -54.1 dB  LD-1.out (FC/APC) ↔ FIB-1.a (FC/APC) (-60.0 dB)
4 cords, one FC/APC among FC/UPC             5  -10.91 dBm   77.1 µA   -15.6 dB  FIB-2.b (FC/UPC) ↔ FIB-3.a (FC/APC) in SLV-2 (-15.6 dB)
APC laser → UPC PD, last cord hybrid         4   -1.20 dBm    721 µA   -50.3 dB  FIB-3.b (FC/UPC) ↔ PD-1.inp (FC/UPC) (-51.8 dB)
4 cords, last end unplugged                  4           —      1 nA   -16.8 dB  open FC/UPC end FIB-4.b (-16.8 dB)
```

Each mating costs 0.3 dB on the way out and returns its reflection through all the matings
before it, so the back-reflection grows more slowly than the loss. FC/APC buys 10 dB on every
reflection. One wrong cord costs two air gaps (2 × 5 dB) and a −15 dB reflection, the same as
an unplugged end. `phoq serve examples/connector_cascade.py` puts the bench on the table:
change a cord's connectors in the inspector, or pull a cord out of a sleeve, and watch the
photodiode current and the laser's back-reflection follow.

### Part presets

A preset sets a component up as a specific product, with every number traced to its datasheet:

```python
im = IntensityModulator("IM-1", preset="exail/mx-ln-10")   # Vπ 5.5 V (RF) / 4.2 V (DC), 22 dB ER, 3.5 dB
pd = Photodiode(preset="thorlabs/det08cfc", load=50)        # explicit arguments override the preset
setup["DET-L"].apply_preset("mpd/pdm-ir")                   # on an existing part; "generic" goes back to the defaults
print(im.preset, im.provenance()["v_pi"])                   # exail/mx-ln-10 {'kind': 'datasheet-typ', 'spec': 'Vπ RF …', 'max': 6.0, …}
```

Only the parameters a preset lists are set; operating settings (a laser's power, a scope's
timebase) are left alone. Every class has a `generic` preset — its defaults — and a part stays
`generic` until you give it one. Saved JSON and **Export .py** keep the preset id plus your edits.
In the virtual lab the inspector has a part picker, and each datasheet-backed value carries a
badge (`typ`, `max`, `derived`, … and `edited` once you change it) whose tooltip quotes the
datasheet row. Absolute maximum ratings from the preset are checked live: too much optical
input, a bias outside its range or an RF drive above the rated power shows up as a warning.

```bash
phoq presets                       # every preset: id, class, title
```

```bash
phoq presets exail/mx-ln-10        # values with provenance, limits, and what the model cannot represent
```

Shipped presets, each citing the datasheet document and revision it was read from:

| Preset | Part | Class | Sets |
| --- | --- | --- | --- |
| `exail/mx-ln-0.1`, `-05`, `-10`, `-20`, `-40` | Exail MX-LN series LiNbO₃ intensity modulators, 400 MHz – 40 GHz | `IntensityModulator` | Vπ of the RF and of the DC electrodes, insertion loss, extinction ratio; maximum optical, RF and bias inputs |
| `exail/mxiq-ln-10`, `mxiq-ln-30` | Exail MXIQ-LN-10 and -30 LiNbO₃ IQ modulators, 12 and 25 GHz | `IQModulator` | Vπ of the RF electrodes, of DC1/DC2 and of DC3, insertion loss; maximum optical, RF and bias inputs |
| `hamamatsu/koheras-mikro-e15`, `-a15` | Hamamatsu (formerly NKT Photonics) Koheras MIKRO E15 and A15 single-frequency DFB fiber lasers, 40 and 10 mW at 1550.12 nm | `Laser` | wavelength, power, CW, the 100 Hz linewidth (120 µs), RIN at 10 MHz (−135 and −120 dBc/Hz), FC/APC; the 0.7 MHz RIN peak and the 1/f-like frequency noise kept as unsupported |
| `mpd/pdm-ir` | MPD PDM-IR InGaAs/InP SPAD module | `SinglePhotonDetector` | PDE at 1550 nm with the PDE-vs-wavelength curve, dark-count rate, hold-off |
| `excelitas/spcm-aqrh-10-fc` … `-16-fc` | Excelitas SPCM-AQRH Si SPAD modules with FC adaptor, one per dark-count grade (< 1500 … < 25 c/s) | `SinglePhotonDetector` | PDE at 650 nm with the 400–1060 nm curve, dark counts, 22 ns dead time; maximum 10⁴ photons per pulse |
| `thorlabs/det08cfc` | Thorlabs DET08CFC 5 GHz InGaAs detector | `Photodiode` | bandwidth, responsivity at 1550 nm with the spectral curve, load, saturation from the 2 V output limit |
| `thorlabs/dx25cf` | Thorlabs DX25CF 25 GHz GaAs detector | `Photodiode` | GaAs response, 0.5 A/W at 850 nm, 25 GHz, 25 Ω load; 10 dBm input limit |
| `thorlabs/dx25hf`, `dx25ha` | Thorlabs DX25HF (FC/PC) and DX25HA (FC/APC) 25 GHz high-linearity InGaAs detectors | `Photodiode` | 0.45 A/W at 1550 nm with the 0.915–1.625 µm curve, 25 GHz, 25 Ω load, linear to 50 mA; 20 dBm input limit |
| `thorlabs/pdb450c`, `pdb450a`, `pdb440c`, `pdb440a` | Thorlabs PDB450C/A (switchable gain 10³–10⁷ V/A, DC–150 MHz … 0.1 MHz) and PDB440C/A (51 × 10³ V/A, 15 MHz) balanced amplified photodetectors, InGaAs (C) and Si (A) | `BalancedPhotodetector` | each gain's bandwidth, NEP and swing; CMRR; responsivity with the spectral curve; monitor gain, bandwidth, swing, impedance and noise; 20 mW per photodiode |
| `fs/smfcsx-upc-upc`, `-apc-apc`, `-apc-upc` | FS SMFCSX 1 m OS2 (G.657.A1) simplex patch cords, SKUs 40740, 173580 and the 173581 hybrid | `Fiber` | connector A and B, 1 m length; ≤ 0.3 dB and UPC ≥ 50 / APC ≥ 60 dB, the mating table's values |

Every preset also sets the part's connector from its datasheet: FC/PC for the Thorlabs, MPD and
Excelitas detectors (the balanced detectors' receptacles also take FC/APC, but phoq mates connectors
as fiber to fiber), FC/APC for the Exail modulators, intensity and IQ (their FA ordering option; the default is
bare fiber).

**Writing one.** A preset is a TOML file whose id is `vendor/part` (the vendor slug and the file
name, unless `id` is given):

```toml
component = "IntensityModulator"
title = "Exail MX-LN-10"
vendor = "Exail"
part_number = "MX-LN-10"
description = "10 GHz LiNbO₃ X-cut Mach–Zehnder intensity modulator"
tier = "datasheet"                        # datasheet | validated (checked against a unit) | vendor

[source]
title = "MX-LN series datasheet"
document = "exail-datasheet-mx-ln-series-linbo3-optic-modulators.pdf"
revision = "ED15 (05/2026)"
accessed = 2026-10-16
conditions = "25 °C, 1550 nm"

[params]                                  # one entry per Param of the class, validated like any edit
v_pi = { value = 5.5, from = "datasheet-typ", spec = "Vπ RF electrodes @ 50 kHz", max = 6.0 }
extinction_db = 22.0                      # a bare value is recorded as "unstated"

[limits]                                  # absolute maximum ratings: the lab warns when exceeded
optical_input_dbm = 20.0
rf_input_dbm = 28.0
bias_voltage_v = [-20.0, 20.0]

[unsupported]                             # datasheet figures the model cannot use, kept on purpose
eo_bandwidth_hz = { typ = 12e9, min = 10e9, note = "no RF roll-off in the model" }
```

`from` is one of `datasheet-typ`, `datasheet-min`, `datasheet-max`, `datasheet-graph` (read off a
plot), `datasheet-condition` (the condition a figure is quoted at, such as its wavelength),
`derived` (say how in `note`), `measured` or `assumed`. Quote the datasheet as it states the
number, with the conditions in `spec`; a figure the model has no parameter for belongs in
`[unsupported]`, not in a parameter it merely resembles.

Detectors also take a spectral curve from their datasheet plot — `efficiency` for a
`SinglePhotonDetector`, `responsivity` (A/W) for a `Photodiode` — zero outside its range:

```toml
[params]
efficiency = { value = 0.62, from = "derived", spec = "PDE at 650 nm, × 0.95 for the FC adaptor" }
ref_wavelength = { value = 650e-9, from = "datasheet-condition", spec = "Table 1 PDE row at 650 nm" }

[curves.efficiency]
from = "datasheet-graph"
spec = "Figure 3, typical PDE vs wavelength"
wavelength_nm = [400, 420, 440, …, 1060]
value = [0.085, 0.119, 0.179, …, 0.020]
```

The curve gives the shape; the table value at `ref_wavelength` fixes the scale, so the datasheet's
headline number is reproduced exactly where it was specified and the plot fills in the rest.
Where the plot and the table disagree (they often do by 5–20 %), the preset's `note` says so. The
shipped curves were digitized from the vendor PDFs at 300 dpi by tracing the series color against
the plot's gridlines.

**Your own presets.** phoq looks, in this order, in the package, in packages that register the
`phoq.presets` entry point, in `~/.config/phoq/presets`, in the directories of `$PHOQ_PRESETS`
(`:`-separated), and in what you pass to `phoq.presets.add_path(dir)` or `--presets DIR`. A later
directory overrides an earlier one with the same id, so your lab's measured values can replace
shipped ones. `phoq presets --paths` prints the search path. To publish a pack, a package adds

```toml
[project.entry-points."phoq.presets"]
mylab = "mylab_presets"        # a package whose directory holds the .toml files, or a callable returning a path
```

What the datasheets made the models learn:

- Modulators have `v_pi_bias`, the Vπ of the DC electrodes. A supply on `bias` is referred to the
  RF electrode by Vπ/Vπ_bias, so quadrature sits at Vπ_bias/2 on the supply, as on the bench.
- Photodiodes have `ref_responsivity` at `ref_wavelength`, which scales the spectral curve (the
  preset's, else the material's typical one), and a `GaAs` material.
- Single-photon detectors have a wavelength-dependent efficiency η(λ) = efficiency ·
  PDE(λ)/PDE(λ_ref) when the preset gives a curve. A silicon SPAD on a 1550 nm link does not
  click, and the lab says so; the inspector lists the PDE at each incident wavelength. A generic
  detector keeps a flat efficiency.

## Command line

```bash
phoq run examples/delay_line.py --plot traces.png   # headless
```

```bash
phoq serve examples/mpqkd                  # API + 3D visualizer
```

```bash
phoq export examples/basic_link.json --format python
```

`run`, `serve` and `export` accept a `.py` file that defines `setup` (or `build()`), or a saved
`.json`. While serving, every edit made in the browser sets the attribute on the Python
instance and is autosaved. The default target is `<script>.json` next to the script; change it
with `--save PATH`, or turn it off with `--no-save`. Serve that JSON to carry on, or press
**Export .py** to get the setup back as a Python script (`setup.to_python()`).

### Driving modulators

Modulators have no drive settings of their own. Each has two electrical inputs whose voltages
add on the electrodes: `rf` for a function-generator output and `bias` for a DC supply output.

```python
psu = setup.add(DCPowerSupply(ch1_voltage=im.v_pi / 2))                      # quadrature bias
setup.connect(psu.ch1, im.bias)
fg = setup.add(FunctionGenerator())
setup.connect(fg.ch1, im.rf)
fg.set(ch1_shape="sine", ch1_freq=10e6, ch1_vpp=1.0)                           # linear modulation
fg.set_pattern("ch2", pm.voltage_for(2 * np.pi * k / 16), rate=laser.rep_rate)   # one voltage per pulse
```

For the intensity modulator 0 V is maximum transmission, Vπ/2 quadrature and Vπ the null. Real
LiNbO₃ devices have separate RF and DC electrodes with different Vπ: set `v_pi_bias` to the
datasheet's DC-electrode value and the supply on `bias` is referred to the RF electrode, so
quadrature is at Vπ_bias/2 on the supply, as on the bench.
`im.voltage_for(T)` and `pm.voltage_for(phase)` convert a wanted transmission or phase into the
rf voltage needed on top of whatever bias is connected, so protocol code works in optical terms.
An `IQModulator` has an input per electrode: `rf_i` and `rf_q` for the two generator channels, and
`bias_i`, `bias_q` and `bias_p` for three supply outputs. `iqm.operating_bias()` gives the
supply voltages, and `iqm.voltage_for(symbols)` gives both channels' patterns (see
[IQ modulator](#iq-modulator)). `set_pattern` sets the channel's amplitude
and offset from the pattern's extremes, and `chN_freq` is the symbol rate. The generator's time
base (`trigger`) is *aligned* by default: symbol k reaches the modulator together with pulse k of
the laser feeding it, as a calibrated AWG would. Use *laser* to set `chN_delay` yourself, or
*free* for a free-running generator. A generator output can also go straight to an oscilloscope
channel to look at the drive. Patterns are data, not parameters: they are not saved in the JSON
setup and have to be loaded by code.

![The modulated-link example: a 10 MHz sine from the function generator modulates the laser through the intensity modulator, and the oscilloscope shows the photocurrent next to the drive](https://raw.githubusercontent.com/ammar-faifi/phoq/main/docs/images/oscilloscope.webp)

*`examples/modulated_link.py`: the DC supply holds the intensity modulator at quadrature, the
generator's CH1 drives it with a 10 MHz sine, and the scope shows the photocurrent (CH1, yellow)
next to a copy of the drive (CH2, cyan) with its measurements.*

## Post-processing clicks with your own script

Write the analysis in a file of its own, decorate a function, and point the lab at it. Every
batch a time tagger records is handed to the function, which can keep state between batches:

```python
# my_analysis.py
import numpy as np
from phoq.analysis import task

@task("TAGGER")                         # the counter(s) feeding it; bare @task means every counter
def coincidences(batch):
    t_l, t_r = batch.timestamps("ch1"), batch.timestamps("ch2")   # absolute seconds, continuous
    print(f"batch {batch.seq}: {t_l.size} L, {t_r.size} R, {batch.coincidences('ch1', 'ch2')} both")
    return {"L rate [cps]": batch.rates["ch1"], "R rate [cps]": batch.rates["ch2"]}   # live read-outs
```

```bash
phoq serve examples/mpqkd --analysis my_analysis.py      # live, with a console
```

```bash
phoq run examples/mpqkd --analysis my_analysis.py --batches 20   # headless
```

`@task("TAGGER", party="CHARLIE")` puts a task's console on that party's laptop. A batch is a
`CounterResult`: `clicks[ch]` (one flag per bin), `timestamps(ch)`, `events()`
(time-ordered `(t, ch)` records), `counts`, `rates`, `coincidences(a, b)`, `t0`, `seq`. Bins run
on from one batch to the next, so time stamps are absolute. From Python, `Session(setup).run(10)`
does the same, and a script can run itself with `analysis.main("setup.py")` under
`if __name__ == "__main__":`. `examples/analysis_pairing.py` computes Charlie's click statistics
and the mode-pairing rate against Eq. (4) of the MP-QKD paper.

**How tasks run.** Each task runs in its own worker process (`multiprocessing`, spawn): a thread
would share the interpreter lock with the simulation and could not be stopped if the user's code
hung, and `print` cannot be captured per thread; Python 3.14's sub-interpreters would give
parallelism in one process, but NumPy's support for them is still immature. A process gives full
isolation, its own interpreter lock, a plain pipe for stdout/stderr that feeds the console, and a
clean kill. **Restart** re-imports the script, so edits take effect without restarting the lab.
A task that falls more than two batches behind has the next ones dropped, with a note in its
console (the tagger's buffer overflowing). An exception is printed to the console and the worker
goes on with the next batch.

In the virtual lab every process lives on a laptop (its party's, or the lab PC for plain tasks):
the **Laptops** drawer shows one tab per laptop with its processes' output, status, read-outs,
Stop / Restart / Clear, and "Load script" to attach another file by path; clicking a laptop on
the table opens it. The API is `GET /api/tasks`, `POST /api/tasks {path}` and
`POST /api/tasks/{name}/{restart|stop|start|clear}`; the WebSocket streams `console` messages.

## Parties and the classical channel

A protocol is more than hardware: Alice, Bob and Charlie are *computers* that program their own
instruments, read their own detectors and talk to each other. `phoq.parties` models that
split explicitly, following the `[CLASSICAL #N]` annotations in `examples/mpqkd/reference.py`.

| Layer | What | How it is simulated |
| --- | --- | --- |
| **Physical** | lasers, modulators, VOAs, fibers, Charlie's coupler, SPDs, the time tagger | one `Setup` in the lab process, because the fields of both stations interfere at Charlie |
| **Hardware link** | a station's computer ↔ its own instruments (program the AWG, read the tagger) | `self.hw` — request/reply over the party's pipe, limited to the instruments the party `owns`; a USB/PCIe-style local link, not a public channel |
| **Classical channel** | everything the parties tell each other | `self.net` — real TCP sockets on localhost, one listening port per party, messages pickled, every connection HMAC-authenticated with a pre-shared key: an *authenticated but public* channel, exactly what QKD assumes |

Each party is a `Party` subclass in a file of its own and runs in its own process (the same
reasons as for analysis tasks). The framework drives them block by block (`Experiment`):

```
prepare(block, n)   every station programs its transmitter for the next n pulses   [physical]
      ↓             the lab records the block
measure(batch)      the party owning the tagger gets the clicks                      [physical]
      ↓
receive(message)    whatever the parties say to each other                           [classical]
```

`examples/mpqkd` is split accordingly — `common.py` (public parameters, the
hardware, the pairing/sifting math), `station.py` (what Alice and Bob share), `alice.py`,
`bob.py`, `charlie.py` — and the annotations map onto messages:

| `examples/mpqkd/reference.py` | Message | Who |
| --- | --- | --- |
| #1 announce C_i and L/R | `clicks` (single-click rounds, detector, block) | Charlie → all |
| #2 pairing with public l | no message: both stations run the same `simple_pairing` on #1 | — |
| #3 basis announcement | `basis` (one label per pair and how bright it was — vacuum, ν or μ — never which pulse) | Alice ↔ Bob |
| #4 alignment angle | `theta` (per pair) | Alice ↔ Bob |
| #5 parameter estimation | `xbits` (all sifted X bits, for E_X); `sample` / `sample-reply` (a 10 % sample of the key bits, dropped from the key, for E_Z) | Alice ↔ Bob |
| #6 error correction, verification | `ec-start` / `ec-query` / `ec-parity` / `ec-done`: Cascade on chunks of 1 000 sifted bits, every parity Alice discloses counted as leaked; `verified`: a 32-bit universal-hash tag of Bob's corrected chunk matched Alice's | Alice ↔ Bob |
| #6 privacy amplification | `pa`: Toeplitz seed and output length — corrected bits × q₁₁ [1 − h(e₁₁)] minus the leaked bits, from the MP-QKD key-rate formula (`keyrate.py`) | Alice → Bob |

Each station's console shows every block's pairs and sifted bits, each chunk's Cascade run
(errors corrected, parities leaked, tag check) and every privacy-amplification step with the
final key in hex — the same hex on both laptops. The read-outs give sifted, corrected and final
key bits, the key rate per pulse, the leak, q₁₁, e₁₁, E_Z, E_X and the key's fingerprint.

**How the key is sized.** The paper's rate is `R = r_p r_s {q₁₁ [1 − h(e₁₁ˣ)] − f h(E_Z)}`. Pulses
take one of three intensities (vacuum, decoy ν, signal μ); the key comes from Z pairs whose
bright pulses were μ, every other pair class only feeds the decoy statistics. Both stations tally
the public class of every pair (basis, intensity level, θ match), so they compute the same
numbers: q₁₁ from the vacuum + weak decoy lower bound on the single-photon-pair yield of the Z-type
classes (Appendix C of the paper, the standard MDI two-decoy formula), and e₁₁ˣ — by default —
from the honest-channel model (laser linewidths and detuning read from the stations' own lasers,
dark counts, the observed pairing intervals; Eq. (D15)), checked against the measured E_X of the
signal X pairs. The rigorous decoy bound on e₁₁ˣ is also computed and shown as *unresolved*: it
is a small difference of X-basis error gains of the weakest decoy class and needs ~10⁹–10¹²
pulses (what experiments collect, and why the paper's curves are asymptotic; a 20-block run has
4 × 10⁶), and at this showcase's X intensities (2ν = 0.2, 2μ = 0.8 per pair) the two-intensity
bound on the X single-photon-pair yield does not close even asymptotically — the paper's own
simulation takes e₁₁ˣ from the model too (its Eq. D15). `MPQKDParams(estimator="decoy")` sizes
with the rigorous bound instead and then distils no key here. All sizing is asymptotic; a
simplified finite-key length (Chernoff/Hoeffding terms, ε = 10⁻¹⁰) is reported next to it,
clearly labeled, and is zero at this scale.

Run it with the three parties as processes, headless or live:

```bash
phoq run --protocol mpqkd --blocks 20
```

```bash
uv run python examples/mpqkd/sweep.py --plot sweep.png     # key rate vs fiber length, against the theory curve
```

```bash
phoq serve --protocol mpqkd
```

`--protocol` takes a package exposing `PARTIES` and `build()`; `--party my_party.py` adds a file
that defines a `Party` subclass (an eavesdropper on the switch, for instance). In the virtual lab
every party gets a laptop on the table showing its console, read-outs and TCP port, wired with
an Ethernet cable to a (purely visual) layer-3 switch whose port LEDs blink with the traffic;
clicking a laptop opens its console.

A station that re-encodes what it detects — a relay, a trusted node, an eavesdropper — sets
`stage = 1`. The lab then records its counters first, hands it the batch, waits for its
`measure()` to return (that is where it programs its own transmitter through `self.hw`) and only
then records the stations further down the line, so the feed-forward lands in the same block.
That is what a real station does with some latency, which the receiver's clock calibration
absorbs. `examples/bb84/eve.py` is one.

![Laptops for Alice, Charlie and Bob on the optical table, with Alice's console open showing block-by-block pairing, sifting and key growth, and her classical-channel traffic](https://raw.githubusercontent.com/ammar-faifi/phoq/main/docs/images/laptops.webp)

*Alice's console: each block's pulses, pairs and sifted Z/X bits with their error rates, the key
growing, and the messages exchanged with Charlie and Bob over the classical channel (screenshot
from before error correction and privacy amplification were added).*

## Physics model

[docs/engine.md](docs/engine.md) walks through the engine step by step (tracing, field synthesis,
detectors, each instrument's acquisition) and lists its current limitations.
[docs/photon-counter.md](docs/photon-counter.md) goes deeper into the time tagger: its acquisition,
detector dead time across records, and what long dead times do to the clicks.

- **Fields, not just powers.** Light is traced as complex amplitudes through every path:
  connector loss and reflection by connector type (see [Fiber connectors](#fiber-connectors)),
  fiber loss vs λ, propagation phase, a 4.9 ns/m delay, the beam-splitter unitary, attenuators
  and modulators. Paths from one laser interfere, and different lasers add coherently only if
  their wavelengths match. One reflection is followed per path; a second (a Fabry–Pérot pass
  between two connectors) is ≤ −80 dB for matching ones.
- **Lasers:** the envelope is averaged over each sample bin, so a 100 ps pulse is exact at any
  timebase. Phase is a Wiener process from the linewidth, drawn once per acquisition and shared
  by all paths. That makes interferometer visibility and Alice/Bob phase drift physical. A white
  excess RIN (`rin_db_hz`, **Excess RIN** in the lab) can be added, also one realization shared by
  all paths; shot noise comes on top at detection. See
  [examples/laser_noise.md](examples/laser_noise.md).
- **Photodiode:** I = Σ R(λ)·P + I_dark plus white shot noise (density 2qI) and thermal noise
  (4kT/R), all through a one-pole bandwidth: the noise RMS is √(2qIB) and √(4kTB/R) with
  B = (π/2)·f₃dB. It saturates softly above `saturation`.
- **Balanced photodetector:** U_RF = G·(ℜ₊P₊ − ℜ₋P₋), with the CMRR as a responsivity mismatch,
  through the selected gain's pole and swing; both diodes' shot noise plus the amplifier's NEP;
  a monitor per diode. See [Balanced photodetector](#balanced-photodetector).
- **Single-photon detector:** p(click) = 1 − (1 − p_dark)·exp(−η·n̄) per bin, plus dead time.
- **Modulators, oscilloscope and spectrum analyzer:** see
  [Theory](#theory-modulators-delay-lines-balanced-detection-and-the-instruments) below.
- **Steady-state report:** `phoq.report(setup)` gives mean powers, currents, open-connector
  emission and warnings. It uses the mutual coherence γ(Δτ) = exp(−πΔν|Δτ|) × envelope overlap.
- **Not modeled:** polarization, dispersion, nonlinearity, afterpulsing.

## Theory: modulators, delay lines, balanced detection and the instruments

The virtual lab's Help (?) window links three animated walkthroughs of the instruments' code.
Each steps through one acquisition line by line, beside a screen that draws the array each line
produces on a real bench: the RF spectrum analyzer's sweep, the oscilloscope's front end, edge
trigger, ADC and measurements, and the photon counter's clicks, dead time and coincidences.
`phoq serve` serves them at `/walkthroughs/`. After changing those methods, rebuild the pages with
`uv run python tools/walkthroughs/build.py`; `tests/test_walkthroughs.py` fails until you do.

### Electro-optic modulators

A modulator is a passive device with two electrical inputs. The electrode voltage is the sum of
what is connected to them, and an open input is 0 V:

```math
V(t) = V_\mathrm{rf}(t) + V_\mathrm{bias}(t)
```

Both modulators also have an insertion loss $L$ (dB), applied to the field as
$\sqrt{10^{-L/10}}$.

**Phase modulator** (`PhaseModulator`). The electro-optic effect shifts the phase in proportion to
the voltage; $V_\pi$ is the voltage for a shift of π:

```math
E_\mathrm{out}(t) = E_\mathrm{in}(t)\,\sqrt{10^{-L/10}}\;e^{\,i\pi V(t)/V_\pi}
```

With $V_\pi = 4$ V, 1 V gives π/4. `pm.voltage_for(phase)` returns $\phi V_\pi/\pi - V_\mathrm{bias}$,
the rf voltage needed on top of the connected bias. This is the linear (Pockels) electro-optic
effect: the index change $\Delta n = \tfrac12 n_0^3 r E$ over a length $l$ with electrode gap $d$
gives $`V_\pi = d\lambda_0/(l\,n_0^3 r)`$ (Fraunhofer IIS 2024, Eqs. 1–3), so $V_\pi$ grows with the
wavelength; here it is a device parameter at its design wavelength. The paper writes the shift as
$\varphi_0 - \pi V/V_\pi$; the sign is a crystal-orientation convention and the code uses $+$.

**Intensity modulator** (`IntensityModulator`). A Mach–Zehnder modulator splits the light into two
arms carrying $P_1 + P_2 = P_\mathrm{in}$, phase-shifts them in push–pull by $\pm\pi V/2V_\pi$ and
recombines them. With the arms' phase difference $\varphi = \pi V/V_\pi + \varphi_0$ the output power
is $P = \tfrac12 P_\mathrm{in} + \sqrt{P_1P_2}\cos\varphi$ (Fraunhofer IIS 2024, Eq. 6), so

```math
T(V) = \tfrac12\left[1 + k\cos\!\left(\frac{\pi V}{V_\pi} + \varphi_0\right)\right],
\qquad k = \frac{2\sqrt{P_1P_2}}{P_\mathrm{in}} = \frac{\mathrm{ER}-1}{\mathrm{ER}+1}
```

A perfectly balanced device ($k = 1$) is the textbook $\cos^2(\pi V/2V_\pi)$ and goes fully dark;
real devices split slightly unequally, which rounds the null off at $T_\min = 1/(\mathrm{ER}+1)$ and
caps the maximum at $T_\max = \mathrm{ER}/(\mathrm{ER}+1)$, where ER is the (linear) extinction
ratio set by `extinction_db`. $\varphi_0$ (`phi0`) is the arms' intrinsic path difference: it
shifts the whole curve along $V$, and in a real device it drifts with temperature and charge, which
is what a bias controller nulls (the drift itself is not modeled). The three bias points for
$\varphi_0 = 0$:

| $V$ | $T$ | Use |
| --- | --- | --- |
| 0 | $\mathrm{ER}/(\mathrm{ER}+1)$ | maximum transmission (default, nothing connected) |
| $V_\pi/2$ | ½ | *quadrature*: $dT/dV = -\pi k/2V_\pi$, the steepest and most linear point, for analog modulation |
| $V_\pi$ | $1/(\mathrm{ER}+1)$ | *null*: pulse carving, vacuum / decoy states |

At quadrature a small drive $v$ gives $T \approx \tfrac12 - \tfrac{\pi}{2V_\pi}v$, i.e. a modulation
depth of $\pi v_\mathrm{pp}/2V_\pi$ for a peak-to-peak drive $v_\mathrm{pp}$ (a 1 Vpp sine on a
device with $V_\pi$ = 4 V swings $T$ between about 0.30 and 0.70). `im.voltage_for(T)` inverts the curve,
$`V = (V_\pi/\pi)\left[\arccos\!\big((2T - 1)/k\big) - \varphi_0\right] - V_\mathrm{bias}`$, so protocol
code can ask for a transmission. Asking for 0 or 1 gives the null or the maximum.

**Time base.** A function-generator pattern has one value per symbol, symbol $k$ centered on
$t = k/f_\mathrm{sym}$. With the default *aligned* time base the generator is treated as a
calibrated AWG locked to the laser clock: the voltage a pulse sees is evaluated at the pulse's
emission time, so pulse $k$ meets symbol $k$ in whichever modulator the channel drives, whatever
the fiber length before it. The *laser* time base uses the time of arrival at the modulator minus
`chN_delay` (you calibrate the delay), and *free* adds a random start each acquisition.

**Steady-state report.** The live power read-outs use the time-averaged transmission
$\langle T\rangle$ over one period of the drive (or over all symbols of a pattern) with the bias
taken as DC; `IntensityModulator.info()` shows it. Paths through a time-varying modulator are
treated as mutually incoherent in that report, which is only an approximation of the full
time-domain synthesis used for the oscilloscope.

### IQ modulator

`IQModulator` is a dual-parallel Mach–Zehnder: the light splits into an in-phase (I) arm and a
quadrature (Q) arm. Each arm is a push–pull MZM like the intensity modulator above. The arms
recombine after a relative phase set on a third DC electrode (Seimetz 2009, §2.1.2, Fig. 2.3) [1].
It has two RF inputs, `rf_i` and `rf_q`, which are RF1 and RF2 on the Exail MXIQ-LN [2]. It has
three bias inputs, `bias_i`, `bias_q` and `bias_p`, which are DC1, DC2 and DC3. In this section
$j$ is the imaginary unit, as in Seimetz, because $i$ is the in-phase coordinate.

**Field.** Each MZM is the intensity modulator above, written for its field rather than its power.
The field keeps its sign, and an MZM at its null flips that sign as the drive crosses it
(Seimetz Fig. 2.2b):

```math
e(\varphi) = \sqrt{\tfrac{1+k}{2}}\,\cos\tfrac{\varphi}{2} + j\sqrt{\tfrac{1-k}{2}}\,\sin\tfrac{\varphi}{2},
\qquad |e|^2 = \tfrac12\left[1 + k\cos\varphi\right] = T,
```

where $`\varphi_I = \pi V_{\mathrm{rf},I}/V_\pi + \pi V_\mathrm{DC1}/V_{\pi,\mathrm{DC}} + \varphi_{0,I}`$, and
likewise for Q. The outer interferometer adds the two arms (Seimetz Eq. 2.11):

```math
\frac{E_\mathrm{out}}{E_\mathrm{in}} = \sqrt{10^{-L/10}}\;\tfrac12\left[e(\varphi_I) + e^{j\varphi_P}\,e(\varphi_Q)\right],
\qquad \varphi_P = \frac{\pi V_\mathrm{DC3}}{V_{\pi,\mathrm{DC3}}} + \varphi_{0,P}.
```

**Operating point.** `operating_bias()` puts both MZMs at their null
($`V_\mathrm{DC1} = V_\mathrm{DC2} = -V_{\pi,\mathrm{DC}}`$) and the arms in quadrature
($`V_\mathrm{DC3} = V_{\pi,\mathrm{DC3}}/2`$). There the field is Seimetz Eq. (2.16):

```math
\frac{E_\mathrm{out}}{E_\mathrm{in}} = \tfrac12\cos\frac{\Delta\varphi_I}{2} + j\,\tfrac12\cos\frac{\Delta\varphi_Q}{2},
\qquad \Delta\varphi_{I,Q} = \frac{\pi u_{I,Q}}{V_\pi},
```

with $u$ the RF drive on top of the $-V_\pi$ bias. Each arm sweeps the field along its own axis, so
together they reach every point of the square. `voltage_for(i, q)` returns the drive of the
conventional IQ transmitter (Eqs. 2.46–2.47):

```math
u_I = \frac{2V_\pi}{\pi}\arcsin i, \qquad u_Q = \frac{2V_\pi}{\pi}\arcsin q,
```

which puts the field exactly at $`\tfrac12(i + jq)`$ for $i, q \in [-1, 1]$. The arcsin undoes the
MZMs' cosine, so the 16-QAM levels ±1/3 and ±1 come out equidistant. A linear drive with the same
±Vπ swing puts the inner level at $\sin(\pi/6) = \tfrac12$ instead of ⅓, and the outer-to-inner
ratio drops from 3 to 2.

Seimetz sets his phase electrode to $-V_\pi/2$. phoq's phases have the opposite sign, so quadrature
is at $+V_{\pi,\mathrm{DC3}}/2$. At the other null, $+V_\pi$, that axis comes out mirrored.

**Extinction and drift.** The MZMs' finite extinction leaves a carrier at the operating point of
$`|E|^2 = (1-k)/4 = 1/[2(\mathrm{ER}+1)]`$ of full transmission. For 30 dB MZMs that is 33 dB below
full. It also adds a quadrature term $`-j\sqrt{(1-k)/2}\,\sqrt{1-i^2}`$ that moves only the inner
levels; at 30 dB it adds about 2 % EVM to 16-QAM. The $\varphi_0$ are path imbalances that move
every bias point. A bias controller such as Exail's MBC-IQ would track them; phoq holds them fixed.

**Demo.** `examples/iq_modulator.py` sends 16-QAM through a 20 dB channel into a homodyne receiver:

```
                  FG-1 CH1, CH2: I and Q levels      PSU-1: DC1, DC2   PSU-2: DC3
                             │                               │
laser ─ TAP ─┬─ IQM (MXIQ-LN-30) ─ VOA (channel) ─ HYB-S ─┬──────────────── MIX-I ═ BPD-I → CH1
             │                                             └──────────────── MIX-Q ═ BPD-Q → CH2
             └─ 2 m ──────────────────────────────── HYB-L ─┬─ PS-I ───────── MIX-I
                                                            └─ PS-Q ───────── MIX-Q
```

The receiver is a 90° hybrid built from four 50:50 couplers and a phase shifter in each LO branch.
Both branches therefore lose the same; with unequal LO powers, the two quadratures would read the
square as a rectangle seen at an angle, which looks like a bias error. Each balanced detector reads
one quadrature of $`E_S E_\mathrm{LO}^*`$. The shifters are set as a homodyne receiver's phase lock
would set them, so those quadratures are the I and Q axes. The LO is the same laser, and every path
is 4 m of cord, so the laser's phase noise cancels.

The DSP finds the frame from the known symbols and fits $`\mathrm{CH1} + j\,\mathrm{CH2} = a\,i + b\,q + c`$:

- the angle between $a$ and $b$ is the I/Q angle;
- $|b/a|$ is the Q/I gain;
- $c$ is the carrier left on the light.

In brackets, the model gives the same fit applied to `IQModulator.field()` of every symbol:

```
python examples/iq_modulator.py

  case                                   EVM        I/Q angle       Q/I gain            carrier  outer/inner
  operating point             5.5 % ( 5.5 %)    90.0° ( 90.0°)  1.002 (1.000)  -34.9 dB (-34.3 dB)  3.01 (3.00)
  DC3 10° off quadrature     10.3 % (10.3 %)    99.9° (100.0°)  1.002 (1.000)  -35.7 dB (-35.1 dB)  3.04 (3.03)
  DC1 0.5 V off the null      8.1 % ( 8.0 %)    89.8° ( 89.7°)  1.012 (1.010)  -23.1 dB (-23.0 dB)  2.37 (2.36)
  linear drive, no arcsin    14.9 % (15.0 %)    90.2° ( 90.1°)  1.003 (1.002)  -35.2 dB (-34.9 dB)  2.01 (2.00)

  receiver noise alone: 5.1 % EVM.
```

Each bias error moves its own number:

- **DC3:** the I/Q angle.
- **DC1:** the carrier, and the inner I levels shift sideways.
- **A linear drive:** the outer-to-inner ratio.

That tells you which knob to turn before the EVM alone could. `--plot` saves the four
constellations. In `phoq serve examples/iq_modulator.py`, the scope shows the four I and Q levels at 200 ns/div,
and IQM-1's inspector shows the bias point in degrees and the carrier it leaves.

**Validation.** `tests/test_iq_modulator.py`:

| Check | Theory | phoq |
| --- | --- | --- |
| Field at the operating point over a 21 × 21 grid of drives | Seimetz Eqs. 2.16–2.18 | to 2·10⁻⁴ (80 dB MZMs) |
| 16-QAM and 64-QAM with the arcsin drive | ½(i + jq), Eqs. 2.46–2.47 | to 2·10⁻⁴ |
| One MZM: the arms in phase and driven alike | the intensity modulator's T(V) | to 10⁻¹² |
| Carrier at the operating point, MZMs of 20, 30 and 40 dB | (1 − k)/4 | exact |
| DC3 alone, both MZMs open | cos²(φ_P/2) | to 10⁻⁹, in the steady-state report |
| 16-QAM mean power: report against synthesized fields | 5/18 of the input | to 1 % |
| Through the receiver against the model, four cases | the fit on `field()` | angle 0.5°, gain 0.02, carrier 1.5 dB, outer/inner 0.06, EVM 8 % |

Not modeled:

- The RF bandwidth (25 GHz for the MXIQ-LN-30): drives act instantly, so keep the symbol rate well
  below it.
- Bias drift and bias control.
- The internal monitor photodiodes (option PD).
- Crosstalk between RF1 and RF2.
- Any imbalance of the outer splitter and combiner, which are taken as 50:50.
- Vπ against wavelength.
- Polarization: the device has PM fibers.

[1] M. Seimetz, *High-Order Modulation for Optical Fiber Transmission*, Springer Series in Optical
Sciences 143 (Springer, 2009), ch. 2 "Transmitter Design",
[doi:10.1007/978-3-540-93771-5_2](https://doi.org/10.1007/978-3-540-93771-5_2).
[2] Exail, *MXIQ-LN series: 1550 nm wide bandwidth IQ modulator*, datasheet ED8 (04/2026); presets
`exail/mxiq-ln-10` and `exail/mxiq-ln-30`.

### Optical delay line

`OpticalDelayLine` is a fiber-coupled free-space delay: a collimator, an air gap set by a
translation stage, and a second collimator. Patch cords come in 0.5 m steps (2.45 ns), so cords set
the coarse delay and the delay line trims it to a fraction of a pulse. Its settings:

- the dial `delay` τ, from 0 to `max_delay` (330 ps);
- the fixed `insertion_delay` τ₀ (100 ps: internal fiber and the shortest gap);
- the stage `resolution`;
- `insertion_loss_db` at the best-coupled gap (1 dB);
- the collimated `beam_waist` w₀ (250 µm).

These generic values are assumed, not from a datasheet; a preset replaces them.

**Delay and phase.** Air is nearly dispersionless (n ≈ 1.00027), so one τ sets both the group
delay and the carrier phase:

```math
\tau_g = \tau_0 + \tau, \qquad \varphi = \varphi_0 + 2\pi\nu\tau .
```

One fringe passes per optical period $1/\nu = \lambda/c$: 5.17 fs at 1550 nm, or 1.55 µm of air
path. A move of a few femtoseconds therefore scrambles an interferometer's phase. The delay line
sets timing; a `PhaseShifter` (a fiber stretcher or heater on a bench) sets and holds the phase,
and is relocked after every move of the dial. A dial set beyond the travel stops at its end, and
`report()` warns.

**Coherence.** In a Mach–Zehnder whose arms differ by δ, a laser of Lorentzian linewidth Δν gives
fringe visibility $`V = V_0\,e^{-\pi\Delta\nu|\delta|}`$. Here
$V_0 = 2|a_1a_2|/(|a_1|^2+|a_2|^2)$ is the ceiling left by unequal arm losses. Scanning the delay
line maps out the coherence time $1/(\pi\Delta\nu)$ of a broad source.

**Pulse overlap.** In a delay-line interferometer the arms differ by $T+\delta$, so an output slot
holds pulse $k$ and pulse $k-1$ offset by δ. The interference term counted over the slot is the
overlap of their fields, $`\int E_k(t)\,E_{k-1}(t-\delta)\,dt`$. For Gaussian pulses of intensity FWHM
$w$:

```math
V(\delta) = V_0\; e^{-\pi\Delta\nu\,(T+\delta)}\; e^{-\ln 2\,\delta^2/w^2} .
```

V ≥ 0.99 needs $`|\delta| \le 0.12\,w`$: ±120 ps for 1 ns pulses, but ±12 ps (2.4 mm of fiber) for
100 ps pulses. At 100 MHz a 3 m arm against a 1 m arm leaves δ = −205 ps. That costs 1 ns pulses
3 % of their visibility and leaves 100 ps pulses with 5 %, which is why the DLI's long arm carries a
delay line.

**Coupling loss.** The collimated beam (Rayleigh range $z_R = \pi w_0^2/\lambda$) couples into
the output fiber with the Gaussian mode overlap $1/[1+(d/2z_R)^2]$, where $d$ is how far the gap is
from the best-coupled one at mid-travel. The loss therefore rises toward both ends of the travel:

```math
\mathrm{IL}(\tau) = \mathrm{IL} + 10\log_{10}\!\left[1+\left(\frac{c\,(\tau-\tau_\text{max}/2)}{2\,n_\text{air}\,z_R}\right)^{2}\right].
```

With w₀ = 250 µm that is 0.16 dB at either end of the 330 ps travel at 1550 nm (0.12 dB at
1310 nm). Power changes while you scan for the visibility peak, so watch the visibility, not the
power.

The coupling law is the one Wang et al. (2024) derive for a fiber delay line, and phoq reproduces
their computed curves (see [Checked against the literature](#checked-against-the-literature)).

Not modeled: stage pitch and yaw (beam walk-off adds loss across the travel: for 12 mm lenses 1 m
apart, Wang et al. find that a 0.0085° tilt or a 0.86 mm offset of the coupling lens halves the
coupling), back-reflection from the collimator faces, thermal drift, backlash and repeatability.

**Counting pulses that miss each other.** The photon counter averages the light over each bin, and
two pulses inside one bin then interfere as if they overlapped. `PhotonCounter(oversample=m)`
synthesizes $m$ sub-bins per bin and adds their photons. A sub-bin of a quarter of the pulse width
gets the overlap right to 2 %, an eighth to 0.5 %.

**Validation.** `python examples/odl_validation.py` checks each equation above against phoq's
steady-state `report()` and against fields synthesized by the engine; `tests/test_odl.py` asserts
the same checks. The DLI is the one in `examples/dli.py`, balanced at 105 ps on the dial:

| Check | Theory | phoq |
| --- | --- | --- |
| Group delay at dial 0 / 105 / 330 ps, and 400 ps (beyond the travel) | 100 / 205 / 430 / 430 ps | 100 / 205 / 430 / 430 ps, with a warning at 400 |
| 1 fs resolution, dial 123.4567 ps | 223.457 ps | 223.457 ps |
| Fringe period at 1550 / 1310 nm | 5.17024 / 4.36969 fs | 5.17024 / 4.36969 fs |
| MZI visibility, 1 GHz laser, δ = 100 / 200 / 300 / 430 ps | 0.725 / 0.530 / 0.387 / 0.257 | report 0.725 / 0.530 / 0.388 / 0.257; fields 0.725 / 0.532 / 0.390 / 0.255 |
| DLI visibility, 100 ps pulses, δ = −105 / −50 / 0 / +50 / +100 / +225 ps | 0.459 / 0.832 / 0.991 / 0.835 / 0.497 / 0.030 | report: identical; fields 0.460 / 0.832 / 0.991 / 0.835 / 0.498 / 0.030 |
| Standard cords alone (δ = −205 ps), 1 ns / 100 ps pulses | 0.961 / 0.053 | 0.961 / 0.053 |
| Time tagger, 1 ns bins, δ = +100 ps: oversample 1 / 40 | 0.496 | 0.983 (whole bins) / 0.501 |
| Loss at dial 0 / 82.5 / 165 / 330 ps re mid-travel, 1550 nm | 0.162 / 0.041 / 0 / 0.162 dB | 0.162 / 0.041 / 0.000 / 0.162 dB |
| The same at 1310 nm | 0.117 / 0.029 / 0 / 0.117 dB | 0.117 / 0.029 / 0.000 / 0.117 dB |

The steady-state report computes the pulse overlap in closed form; the synthesized fields reach the
same value from bin-averaged envelopes and one shared phase walk, which is the check on the engine.
`phoq serve examples/odl_validation.py` opens the 100 ps DLI with 20 GHz photodiodes on a 1 ns/div
scope. Turn `DLI-ODL`'s dial away from 105 ps and each port shows the two arms' pulses side by side,
no longer interfering.

### Balanced photodetector

`BalancedPhotodetector` is two matched photodiodes behind two FC receptacles, `INPUT+` (`inp_plus`)
and `INPUT−` (`inp_minus`). Their photocurrents subtract before a single transimpedance amplifier.
It has three SMA outputs: `RF OUTPUT` (`rf`) from that amplifier, and `MONITOR+` and `MONITOR−`
(`mon_plus`, `mon_minus`), each following one photodiode alone. The presets are Thorlabs' PDB450C
and PDB450A (switchable gain, 10³–10⁷ V/A) and PDB440C and PDB440A (fixed, 51 × 10³ V/A), taken from
their operation manual [1].

**RF OUTPUT.** Manual §3.3 gives the output into a high-impedance load:

```math
U_\mathrm{RF} = G\,\bigl(\mathcal R_+(\lambda)\,P_+ - \mathcal R_-(\lambda)\,P_-\bigr).
```

The output impedance is 50 Ω, so a 50 Ω load (a spectrum analyzer) gets half. The gain switch
(`gain`) selects one row of `gain_settings`, and each row carries its own −3 dB bandwidth, NEP and
output swing. A gain the unit does not offer is reported as an error. The output follows one pole at
that bandwidth and runs into its rails at ±swing, staying linear to 0.03 % up to 60 % of the swing.

Light that reaches only one input drives the output alone, so the CW saturation power is
$`U_\mathrm{swing}/(G\,\mathcal R)`$: 4.6 mW at 10³ V/A and 46 µW at 10⁵ V/A for the PDB450C. The
manual asks that the *difference* between the inputs stay below it.

**Common-mode rejection.** Light that reaches both inputs equally cancels, except for the CMRR
residue. phoq models the CMRR as the INPUT− diode's responsivity being short by
$`10^{-\mathrm{CMRR}/20}`$ (3.2 % at 30 dB). Equal powers therefore leave
$`U_\mathrm{RF} = G\,\mathcal R P\,10^{-\mathrm{CMRR}/20}`$, and every common-mode fluctuation is
reduced by the same factor: the laser's intensity noise, or a modulation both inputs share.

If the paths from the splitter to the two inputs differ by Δτ, a common-mode component at
frequency $f$ arrives at the two diodes with different phases. The cancellation then becomes
$`|1 - e^{-i2\pi f\Delta\tau}| = 2|\sin(\pi f\Delta\tau)|`$:

```math
\mathrm{CMRR}_\mathrm{path}(f) = -20\log_{10}\bigl(2\sin(\pi f\,\Delta\tau)\bigr).
```

This gives the curves of the manual's §3.7 figure, which is drawn for a path difference in air; in
fiber the allowed difference is 1.5 times smaller. 30 dB at 1 MHz allows 1 m of fiber between the
two cords; at 100 MHz it allows 1 cm. phoq's paths carry their own delays, so this needs no
parameter: a 1 m and a 2 m cord give 30.2 dB at 1 MHz and 10.3 dB at 10 MHz.

**Noise.** Each diode's photocurrent carries shot noise of density $`2q\,i_\pm`$. Behind a 50:50
coupler the two are uncorrelated, because each photon's path is random (RP Photonics, "Balanced
photodetection"). They therefore add in power on RF OUTPUT, even though the mean currents subtract.
The amplifier adds its NEP,
referred to the peak responsivity, and all of it passes through the same pole:

```math
\sigma_\mathrm{RF}^2 = G^2\bigl[2q\,(i_+ + i_-) + (\mathrm{NEP}\,\mathcal R_\mathrm{peak})^2\bigr]\,B,
\qquad B = \tfrac{\pi}{2}\,f_\mathrm{3dB}.
```

This is why balanced detection reaches the shot-noise limit with a strong local oscillator: the
oscillator's noise above the shot noise limit cancels, and its shot noise does not.

**Monitors.** Each monitor reads its own diode's current at 10 V/mW at peak responsivity
(`monitor_gain`), from DC to 1 MHz, up to 10 V into a high-impedance load. They therefore saturate
above 1 mW, as the manual warns. Each adds 180 µV rms of its own noise and has a 220 Ω output
impedance. The manual uses them to balance the two input powers before a measurement.

**Report.** `report()` lists for the detector:

- the power at INPUT+ and at INPUT−;
- RF OUTPUT, both into a high impedance and into 50 Ω;
- both monitors;
- the balance $`(P_+ - P_-)/(P_+ + P_-)`$;
- a status: Balanced, Single input, Saturated or No such gain.

It warns when RF OUTPUT or a monitor would pass its swing, and when one diode receives more than the
preset's 20 mW damage threshold.

**Demo.** `examples/balanced_detection.py` is a heterodyne receiver whose local oscillator is noisy:

```
LO laser, 8 dBm → IM (5 % rms, < 30 kHz) ───────┐            ┌─ 1 m → INPUT+    RF OUTPUT → CH1
                                                ├─ 50:50 ────┤                   MONITOR+  → CH2
signal laser, −30 dBm, +400 kHz ────────────────┘            └─ 1 m → INPUT−    MONITOR−  → CH3
```

The coupler's outputs carry $`P_\pm = \tfrac12(P_\mathrm{LO} + P_S) \pm \sqrt{P_\mathrm{LO}P_S}\cos(2\pi\Delta f\,t + \varphi)`$.
The LO and its noise are the same on both inputs, while the beat has opposite signs:

```
python examples/balanced_detection.py

  INPUT+                       505 µW · -3.0 dBm
  INPUT−                       505 µW · -3.0 dBm
  RF OUTPUT                    +0.1597 V (high-Z), +0.07983 V into 50 Ω
  MONITOR+ / MONITOR−          5.049 V / 4.889 V
  Status                       Balanced

Scope, 100 µs sweeps:
  RF beat             0.535 V amplitude, expected 0.535 V (-0.1 %)
  MONITOR± beat       0.248 V on (MON+ − MON−)/2, expected 0.248 V (-0.2 %): opposite on the two monitors
  signal blocked      LO noise 260 mV rms on MONITOR+ (5.2 % of 5.05 V), the same on MONITOR− (correlation +1.00), 8.4 mV rms on RF
  LO noise rejection  30.0 dB (CMRR 30 dB)
  INPUT− dark         RF would sit at 5.05 V, beyond the ±4.6 V swing
```

RF OUTPUT shows the two diodes' beats added and the LO noise reduced by the CMRR. Without INPUT− the
half milliwatt of LO would saturate the amplifier at this gain. The script ends with the gain
switch: one step up, at 10⁵ V/A, the 5.3 V beat on a 1.6 V residue clips at the +4.6 V rail.
`phoq serve examples/balanced_detection.py` shows the three traces on the scope: the monitors move
together with the LO noise, while the beat on them is in antiphase.

**Validation.** `tests/test_balanced.py` checks the model against the manual and the equations
above:

| Check | Manual or theory | phoq |
| --- | --- | --- |
| RF OUTPUT for a 70:30 split: PDB450C at 1550 nm, PDB450A at 820 nm, PDB440C at 1310 nm | §3.3 | to 10⁻¹² in the report; the scope trace's mean to 1 % |
| Sign in single detector mode (§3.2) | + from INPUT+, − from INPUT− | + / − |
| CW saturation: PDB440C, PDB440A, PDB450C and PDB450A at 10³ V/A | 70 µW, 130 µW, 4.5 mW, 9 mW | 70.6 µW, 134 µW, 4.60 mW, 8.73 mW |
| Monitor conversion gain; monitor saturation | 10 V/mW; above 1 mW | 10 V/mW; 1.00 mW |
| CMRR measured as in §3.6 (one input against both, a sine at 1 and 30 MHz), CMRR 30 dB | 30 dB | 29.99, 30.06 dB |
| 1 m of fiber between the cords (Δτ = 4.90 ns) at 1, 3 and 10 MHz (§3.7) | 30.24, 20.70, 10.27 dB | 30.22, 20.69, 10.27 dB |
| RF noise at 10⁴ V/A: dark, then 50 µW and 400 µW on each input | 1.251, 1.337, 1.829 mV | 1.253, 1.337, 1.827 mV |
| Monitor noise, dark | 180 µV rms | 180 µV rms (±2 %) |
| A 1 MHz tone into the spectrum analyzer's 50 Ω | −0.760 dBm | −0.754 dBm |
| Demo: RF beat, monitor beat, LO noise rejection | 0.535 V, 0.248 V, 30 dB | 0.535 V, 0.248 V, 30.0 dB |

The model leaves out:

- AC-coupled versions (the -AC suffix).
- How a real unit's CMRR falls toward its bandwidth. phoq's mismatch is flat, so attenuating INPUT+
  by 3 % would cancel it at every frequency.
- The amplifier offset and its warm-up drift (§3.3).
- Spot size and power density on the diodes.
- Etalons, polarization and residual frequency modulation, which §3.7 lists as limits on the
  rejection.
- The monitors' swing into 50 Ω: phoq divides 10 V across 220 Ω and 50 Ω, giving 1.85 V, where the
  manual quotes 1.5 V.

The PDB440 has INPUT+ and INPUT− on opposite connectors to the PDB450 (§3.2). In phoq only the port
names matter.

[1] Thorlabs, *PDB440x and PDB450x Balanced Amplified Photodetectors, Operation Manual*, version 2.4
(8 April 2019), document 21709-D02, item M0009-510-1043. The product page,
[Balanced Amplified Photodetectors with Fast Monitor Output](https://www.thorlabs.com/balanced-amplified-photodetectors-with-fast-monitor-output),
lists the same gains, bandwidths and NEPs, and notes that the gain halves into 50 Ω loads. Its
headline CMRR for the PDB450 is ">25 dB"; the presets use the manual's typical 30 dB. The page lists
heterodyne detection, spectroscopy, OCT, optical delay measurements and THz detection as typical
applications. YB Photonics,
[Introduction and application of balance detector](https://www.ybphotonics.com/specials/introduction-and-application-balance-detector),
describes the same principle, two photodiodes followed by a differential amplifier, in coherent
wind lidar, distributed fiber vibration sensing and OCT.

### Oscilloscope

The signal chain is detector → analog front end → sampler → record → display and measurements.

**Front end.** The input bandwidth $f_\mathrm{BW}$ (`bandwidth`) is modeled as two identical
first-order poles at $`f_1 = 1.554\,f_\mathrm{BW}`$, chosen so that $|H(f_\mathrm{BW})|^2 = \tfrac12$:

```math
|H(f)| = \frac{1}{1 + (f/f_1)^2}, \qquad t_\mathrm{rise,10\text{–}90} \approx \frac{0.34}{f_\mathrm{BW}}
```

A 20 MHz front end passes a 10 MHz fundamental at 0.91, its third harmonic at 0.52 and its fifth at
0.28, so a 10 MHz square wave keeps most of its swing but loses its corners, and an SPD click
pulse of 5 ns is smeared to about 13 ns. The photodiode's own one-pole bandwidth acts before the
scope's; the slower of the two dominates.

**Sampling.** For a timebase of $s$ s/div the window is $W = 10s$. The effective sample rate is
limited by the record memory $N_\max$ (`max_points`):

```math
f_s = \min\!\left(f_\mathrm{set},\; N_\max / W\right), \qquad N = f_s W, \qquad \Delta t = 1/f_s
```

so a 5 GS/s scope with 20 000 points runs at 5 GS/s up to 400 ns/div and drops to 2 MS/s at
1 ms/div, as a real instrument does. Internally the analog signal is computed on a grid
$\Delta t/k$ with $k = \lceil 6 f_\mathrm{BW}\Delta t\rceil$ (capped) so the front end is resolved,
then *point-sampled* at the sample instants with no anti-alias filter. A component at frequency
$f$ therefore appears at the folded frequency

```math
f_\mathrm{alias} = \left|f - f_s\,\mathrm{round}(f/f_s)\right|
```

A 9 MHz sine sampled at 10 MS/s reads 1 MHz; a 10 MHz sine sampled at 10 MS/s collapses to a flat
line. Keep $f_s \gt 2 f_\mathrm{BW}$ for faithful traces.

**Noise.** The photodiode's shot noise (density $2qI$) and thermal noise ($4k_BT/R$) pass through
its bandwidth like the signal, so their RMS is $\sqrt{2qIB}$ and $\sqrt{4k_BTB/R}$ with $B$ its
noise bandwidth; the scope's front end then narrows it, so the displayed noise RMS falls
roughly as $\sqrt{f_\mathrm{BW}}$ when the bandwidth is reduced — the usual reason to switch a
20 MHz limit in.

**Triggering.** *sync* places $t = 0$ on a laser pulse (one division of pre-trigger shows), *edge*
searches a window twice the record for the first rising crossing of the level on the source
channel, and *free* starts anywhere. The display marks the trigger instant with a triangle at the
top and, for edge mode, the level with a "T" arrow on the right edge. **Single shot** (`arm()`,
`single()`, the SINGLE button) waits for the first trigger, keeps that one record and stops.

**Measurements.** Mean, min, max, peak-to-peak and RMS come from the full record. Frequency is
measured from hysteretic mid-level crossings (±20 % of the swing) when the signal is clean and
well sampled (swing > 8× the sample-to-sample noise estimate); otherwise from the spectral peak
with parabolic interpolation, accepted only if that line carries over 20 % of the AC power — so a
coarsely sampled or aliased sine still reads its (aliased) frequency, while flat noise reads none.
Records longer than 2 000 points are drawn as a min/max envelope per screen column; the numbers
always use every sample.

**ADC.** With `adc_bits` > 0, each channel is digitized: 2^bits levels across the screen's 8
divisions about its offset, and samples off the screen read the end codes. `ScopeResult.codes(ch)`
returns the integer codes. At 0, the default, samples are exact.

**Not modeled:** ADC non-linearity and interleaving errors, equivalent-time sampling, trigger jitter
and holdoff, probe loading. On the modulators: the electrodes' capacitance and velocity mismatch, which make a
real EOM a low-pass on its drive (the generator's voltage is applied instantaneously); bias drift
and its controller; chirp of single-drive devices.

### RF spectrum analyzer

**What it shows.** At every frequency $f$ of the span, an electrical spectrum analyzer (ESA)
displays the power of its input inside the resolution bandwidth (RBW) around $f$, in dBm into its
50 Ω input:

```math
L(f) = 10\log_{10}\frac{\int S(f')\,|H_\mathrm{RBW}(f'-f)|^2\,df'}{1\ \mathrm{mW}}
```

with $S$ the one-sided power spectral density of the input [W/Hz]. A photodiode drives the input
with a current, so $S = R_\mathrm{in} S_i$; a generator drives it with a voltage, $S = S_v/R_\mathrm{in}$.
Two consequences an experimentalist uses all the time:

- a sine of amplitude $A$ shows a line of $A^2/2R_\mathrm{in}$, whatever the RBW;
- noise shows $S \cdot B_N$, with $B_N$ the RBW filter's noise bandwidth, so the noise floor moves
  10 dB per decade of RBW, and the density is $L - 10\log_{10}\mathrm{RBW}$ in dBm/Hz.

`units` picks what the screen shows: **dBm**, the power in the RBW above; **dBm/Hz**, that density
(a tone then moves with the RBW); or **dBc/Hz**, the density over the DC power at the input,
$S/P_\mathrm{DC}$ with $P_\mathrm{DC} = V_\mathrm{DC}^2/R_\mathrm{in}$. On a photodetector's output,
noise and DC pass through the same gain and divider, so dBc/Hz is the RIN of the detected light:

```math
\frac{S}{P_\mathrm{DC}} = \left(\mathrm{RIN} + \frac{2q}{I} + \frac{S_\mathrm{dark}}{I^2}\right)|H(f)|^2 + \frac{\mathrm{DANL}}{P_\mathrm{DC}}
```

the excess RIN, the shot-noise RIN, the detector's floor and the analyzer's, not corrected for the
detector's response $H(f)$ (Obarski and Hale's $C(f)$). An RF analyzer blocks DC, so on the bench
$V_\mathrm{DC}$ comes from a voltmeter or the detector's readout; phoq's analyzer reads it ahead of
its DC block. A DC smaller than the input's AC (the light off, a generator's sine) is no reference,
and the screen says so. The reference level is in the display units, and a change of units moves it so the
trace keeps its place. `SpectrumResult.level` stays dBm in the RBW and `psd()` W/Hz whatever the
units; `dc_power` is the dBc/Hz reference, so `psd()[1] / dc_power` is the RIN spectrum.

**How phoq computes it.** A swept-tuned analyzer mixes the input down with a swept local
oscillator, filters it with the RBW filter, detects the envelope and smooths it with the video
bandwidth. An FFT analyzer samples, windows, transforms and averages. For stationary signals both
estimate the same $L(f)$; phoq takes the FFT route, as modern analyzers do below a few MHz of
span:

1. **Input.** AC-coupled (the DC photocurrent is blocked), impedance $R_\mathrm{in}$ (`input_impedance`,
   50 Ω). A photodiode's current becomes $v = i R_\mathrm{in}$.
2. **Anti-alias and sampling.** The signal is computed at $4f_s$, low-passed by a polyphase FIR
   and decimated to $`f_s = 2.56\,f_\mathrm{stop}`$. Unlike the oscilloscope's point sampling,
   signals above the span do not fold into it.
3. **RBW is the record length.** Each FFT uses $N$ samples under a Hann window, whose
   equivalent noise bandwidth is $1.5 f_s/N$; phoq sets $N = 1.5 f_s/\mathrm{RBW}$, so one record
   lasts $1.5/\mathrm{RBW}$. The FFT is zero-padded four times, keeping a tone's level within
   0.1 dB wherever it falls between bins.
4. **Scaling.** $P_k = 2|X_k|^2 / (R_\mathrm{in}(\sum w_n)^2)$ gives a tone its true power and noise
   $S\cdot\mathrm{RBW}$: the RBW here is a noise bandwidth (an analog Gaussian RBW filter's noise
   bandwidth is about 1.06× its −3 dB width).
5. **Averaging.** `averages` records, 50 % overlapped (Welch), are power-averaged in every sweep;
   the noise trace then fluctuates by about $4.3/\sqrt{K}$ dB. The trace can also be written fresh
   each sweep, averaged over sweeps, or held at its maximum (`trace`).
6. **Display.** `points` frequencies from `start_freq` to `stop_freq`. Each point takes the bins in
   its bucket through the `detector`: *peak* (the largest: tones read right even when a bucket is
   wider than the RBW), *average* (mean power: right for noise) or *sample*. The top line is
   `ref_level`, in the display units (`units`), with `db_per_div` over ten divisions.
7. **Instrument noise.** The displayed average noise level (`noise_floor`, dBm/Hz, at 0 dB input
   attenuation) is added at the input as white noise; the photodiode's shot noise $2qI$ and thermal
   noise $4k_BT/R$ come on top, as on the bench. The input attenuator ahead of the first mixer
   raises that floor dB for dB, and the IF gain makes up for its loss, so a signal keeps its level.
   With `auto_attenuation`, as on an analyzer, the attenuation follows the reference level: the
   least of 0, 10, … 70 dB that keeps a tone at the top of the screen (in dBm, whatever the units)
   at or below `max_mixer_level`, −10 dBm, at the mixer. At the default Ref 0 dBm that is 10 dB,
   so the floor sits 10 dB above `noise_floor`. A reference level far above the signal can bury a
   detector's noise under the analyzer's: lower it, or set `attenuation` by hand.
   `SpectrumResult.attenuation`, the screen's header and the read-outs show the attenuation in use.
8. **Measurements.** The peak (frequency, level), the median of the trace (the noise floor when
   signals cover little of the span), the −3 dB width around the peak, the frequency where the
   trace has fallen by $N$ dB, the power integrated over the span (in dBc/Hz, relative to the DC:
   the RIN integrated over the span, in dBc), and, when the peak sits at the
   start of the span as a self-homodyne beat does, a Lorentzian fit (`fit_lorentzian()`).
   `fit_self_homodyne(tau_d)` fits the self-homodyne spectrum for any delay, below. Several
   sweeps combine with `SpectrumResult.average(results)`, and `Runner.average_spectrum(esa,
   component, param, values)` steps a parameter (a phase shifter) and averages the sweeps.

The cost of a sweep is about $`3.84\,f_\mathrm{stop}/\mathrm{RBW}\times(K+1)/2`$ samples (×4 before
decimation), so a narrow RBW over a wide span is slow, as on a real analyzer.

### Laser linewidth by delayed self-homodyne

phoq's laser phase is a Wiener process, $\langle(\phi(t+\tau)-\phi(t))^2\rangle = 2\pi\Delta\nu|\tau|$,
which gives a Lorentzian optical spectrum of full width $\Delta\nu$ (`linewidth`) and a coherence
time $\tau_c = 1/\pi\Delta\nu$. A delayed self-homodyne measures $\Delta\nu$ without a narrower
reference laser: a coupler splits the light, one arm runs through a delay $\tau_d$ (a fiber spool),
a second coupler recombines the arms on a photodiode, and the ESA looks at the photocurrent. The
review by J.-Q. Chen et al., *Sensors* 24, 3656 (2024), [doi:10.3390/s24113656](https://doi.org/10.3390/s24113656),
covers this method (§2.2, Fig. 1b) and its relatives; the section references below are to it.
With $P_1$ and $P_2$ the powers each arm brings to the detector:

```math
i(t) = \mathcal{R}\left[P_1 + P_2 + 2\sqrt{P_1P_2}\cos\!\big(\theta + \phi(t) - \phi(t-\tau_d)\big)\right]
```

For $|\tau| \lt \tau_d$ the two phase increments in $\phi(t+\tau)-\phi(t)$ and
$\phi(t+\tau-\tau_d)-\phi(t-\tau_d)$ cover disjoint intervals, so they are independent. When
$\tau_d \gg \tau_c$ the arms are mutually incoherent, the bias phase $\theta$ washes out, and the
photocurrent's autocovariance and one-sided spectrum are

```math
C_i(\tau) = 2\mathcal{R}^2P_1P_2\,e^{-2\pi\Delta\nu|\tau|}, \qquad
S_i(f) = \frac{4\mathcal{R}^2P_1P_2}{\pi}\,\frac{\Delta\nu}{\Delta\nu^2 + f^2}
```

The RF spectrum is the optical lineshape convolved with itself, a Lorentzian of half width
$\Delta\nu$ centered at 0 Hz. In homodyne (no frequency shift) it folds about DC, so **the −3 dB
frequency equals the laser linewidth**. The heterodyne version, with an acousto-optic shift
$f_\mathrm{AOM}$, shows the same Lorentzian around $f_\mathrm{AOM}$ with a full −3 dB width of $2\Delta\nu$.
The level falls 20 dB at $`f = \sqrt{99}\,\Delta\nu \approx 9.95\,\Delta\nu`$. This is the review's rule
that the beat is twice as wide as the laser (§2.2; §2.3.1 for the heterodyne case), and its
"−20 dB width divided by $2\sqrt{99}$" (§2.3.4), which is $\sqrt{99}$ on a one-sided homodyne trace.
For phoq's laser, which has white frequency noise only, a Lorentzian fit
$S_0/(1+(f/\Delta\nu)^2) + S_\mathrm{floor}$ uses the whole trace and is the most robust estimate. On
a real laser, 1/f frequency noise adds a Gaussian component that widens the −3 dB width most and
the −20 dB width least; the review therefore measures at −20 dB, or fits a Voigt profile that
separates the Lorentzian part (§2.3.4, Fig. 3, after M. Chen et al., *Opt. Express* 23, 6803 (2015)).
The integral of $S_i$, $2\mathcal{R}^2P_1P_2$, checks the arm balance: it is largest for $P_1 = P_2$.

**With a reference laser.** The heterodyne method beats the laser under test against a second,
frequency-offset laser (§2.1, Eq. (4), Fig. 2). Lorentzians convolve, so the beat is a Lorentzian
at the offset with full width $\nu_t + \nu_r$: twice the linewidth for two equal lasers, the tested
linewidth for a much narrower reference. Two phoq `Laser`s at the same wavelength with different
`detuning` reproduce it: fitted full widths of 197, 100 and 79 kHz for 100 + 100, 100 + 1 and
60 + 20 kHz (`fit_lorentzian(center=None)`).

**Choosing the delay.** For any delay, averaged over the bias phase $\theta$ (or around
$f_\mathrm{AOM}$ in the heterodyne version), the spectrum is the Lorentzian above times a fringe term
(§4.2.1, Eqs. (28)–(31), after L. Richter et al., *IEEE J. Quantum Electron.* 22, 2070 (1986)):

```math
S_i(f) = \frac{4\mathcal{R}^2P_1P_2}{\pi}\,\frac{\Delta\nu}{\Delta\nu^2+f^2}\Big\lbrace 1 - e^{-2\pi\Delta\nu\tau_d}\Big[\cos 2\pi f\tau_d + \frac{\Delta\nu}{f}\sin 2\pi f\tau_d\Big]\Big\rbrace
```

plus a coherent line at the center of weight $e^{-2\pi\Delta\nu\tau_d}$ (at 0 Hz in homodyne, where the
AC-coupled input blocks it). The fringes have period $1/\tau_d$ and depth $e^{-2\pi\Delta\nu\tau_d}$, the
coherence left between the arms. Averaged over eight settings of a `PhaseShifter` in the short arm,
phoq's spectrum matches this expression in absolute level to 0.07 dB rms from 20 kHz to 1.5 MHz,
for 0.3 km (fringe depth 0.40) and for 1 km (0.046). Below about 1 %
($`\tau_d \gt 0.73/\Delta\nu \approx 2.3\,\tau_c`$) the fringes are lost in the averaging noise and the
lineshape is the Lorentzian; the review asks for a delay above six coherence times (§2.3.3), which
keeps the depth below 0.25 % in any of the conventions for $\tau_c$. Conventions differ: here
$\tau_c = 1/\pi\Delta\nu$, the decay time of the field correlation; the review writes $\Delta\nu = 1/\tau_c$
in §2.2 and $e^{-\tau_0/\tau_c}$ in Eq. (10). The depth $e^{-2\pi\Delta\nu\tau_d}$ is convention-free.

In the limit $\tau_d \ll \tau_c$ the interferometer is a frequency discriminator: it turns frequency
noise into intensity noise, $`\phi(t)-\phi(t-\tau_d) \approx 2\pi\tau_d\,\delta\nu(t)`$, and with the
review's Eq. (20), $S_{\Delta\varphi}(f) = 4[\sin(\pi f\tau_d)/f]^2 S_F(f)$ for white frequency noise
$S_F = \Delta\nu/\pi$, the beat spectrum at quadrature becomes

```math
S_i(f) \propto \Delta\nu\,\frac{\sin^2(\pi f\tau_d)}{f^2}
```

a sinc² with nulls at multiples of $1/\tau_d$ whose width is set by the delay, not by the laser:
the −3 dB point sits at $0.443/\tau_d$, and the linewidth is overestimated. For 100 kHz the delay
must exceed 7.3 µs, about 1.5 km of SMF-28 (19 µs, 3.9 km, by the six-coherence-time rule); 10 km
gives $e^{-31}$.

**Choosing the analyzer settings.** RBW ≤ Δν/10, so the RBW filter does not widen the line; a
span of at least 20 Δν, so the wings and the floor are both on screen; the *average* detector and
≥ 100 averages, because the beat is noise, not a tone. The review makes the same points about the
analyzer's resolution and sweep time, and asks for repeated measurements reported with their mean
and standard deviation (§4.1).

**Try it.** `examples/linewidth_homodyne.py` builds the bench above — a 100 kHz laser, two 50:50
couplers, a 10 km spool, an attenuator that gives the short arm the spool's loss, a photodiode and
the analyzer at 0–2 MHz, RBW 5 kHz, average detector — and measures:

```bash
python examples/linewidth_homodyne.py --plot dsh.png
```

```
laser linewidth 100 kHz (coherence time 3.18 µs); delay 49 µs = 15 τc, coherence left e^(−2πΔν·τd) = 4.3e-14

  Lorentzian fit       Δν =  99.7 kHz  (-0.3 %)
  −3 dB point          Δν =    96 kHz  (-4.0 %)
  −20 dB point / √99   Δν =  97.4 kHz  (-2.6 %)

The delay must beat the coherence time (1000 averages each):
    spool        τd  τd/τc coherence        fit      −3 dB
    0.3 km   1.47 µs    0.5       0.4    137 kHz    297 kHz
    0.6 km   2.94 µs    0.9      0.16    141 kHz    202 kHz
      1 km    4.9 µs    1.5     0.046    101 kHz    122 kHz
      2 km   9.79 µs    3.1    0.0021    100 kHz     99 kHz
     10 km     49 µs   15.4   4.3e-14    100 kHz   94.5 kHz
     25 km    122 µs   38.5   3.9e-34    101 kHz   88.7 kHz
```

The measured spectrum matches $S_i(f)$ above in absolute level to within about 0.3 dB from 1 kHz
to 1 MHz. Two lessons from the numbers: the −3 dB point of a noise-like trace scatters by several
per cent even after thousands of averages (it is one crossing on a flat top), so fit the whole
trace; and a delay shorter than about two coherence times reports the delay's sinc² instead of the
laser (0.3 km: the −3 dB point sits at $0.443/\tau_d$ = 301 kHz). `phoq serve
examples/linewidth_homodyne.py` shows the same bench live: the analyzer's trace keeps averaging,
the window marks the −3 dB point and reports the Lorentzian half width.

**Narrow lines: 1 kHz and 100 Hz.** Five coherence times are 1.6 ms of delay for 1 kHz, 330 km of
fiber: one spool, and the Lorentzian fit works. For 100 Hz they are 16 ms, 3,300 km and over 600 dB
of loss, beyond any spool (500 km at most here, and the tracer drops paths below −180 dB) and any
bench without amplifiers. The way out is a delay shorter than the coherence time and a fit of the
expression above with the known delay, `fit_self_homodyne(tau_d)` (the review's coherent-envelope
methods, §4.2, rest on the same Eqs. (28)–(31)). Two conditions make it work:

- **Average over the bias phase.** The expression is the average over $\theta$, and phoq's fiber
  phase does not drift as a bench's does. The θ-dependent part varies as $\cos 2\theta$ and
  $\sin 2\theta$, so spectra taken at θ and θ + π/2 (a `PhaseShifter` in the short arm,
  `Runner.average_spectrum`) cancel it. One phase alone is biased either way, by tens of per cent.
- **Show several fringes.** For $\tau_d \ll \tau_c$ the linewidth only sets the fringe depth
  $e^{-2\pi\Delta\nu\tau_d}$, so the span must cover several periods $1/\tau_d$.

`examples/linewidth_narrow.py` measures both lasers. The short arm is left unattenuated: over
hundreds of km a balanced pair would sit below the analyzer's floor, while the beat scales as
$\sqrt{P_\mathrm{short}P_\mathrm{long}}$.

```bash
python examples/linewidth_narrow.py --plot narrow.png
```

```
1 kHz laser, coherence time τc = 318 µs
    spool   loss  τd/τc  depth     RBW     span  short-delay fit     one θ Lorentzian     −3 dB
    100 km   19 dB   1.54   0.05  100 Hz   20 kHz           995 Hz    977 Hz   1.03 kHz  1.21 kHz
    200 km   38 dB   3.08   0.00  100 Hz   20 kHz           999 Hz     1 kHz     999 Hz    961 Hz
    400 km   76 dB   6.15   0.00  100 Hz   20 kHz           992 Hz     1 kHz     992 Hz    971 Hz

100 Hz laser, coherence time τc = 3.18 ms
    spool   loss  τd/τc  depth     RBW     span  short-delay fit     one θ Lorentzian     −3 dB
     50 km   10 dB   0.08   0.86   10 Hz 20.4 kHz           100 Hz   33.3 Hz     654 Hz  1.76 kHz
    100 km   19 dB   0.15   0.74   10 Hz 10.2 kHz          99.1 Hz   69.2 Hz     369 Hz    867 Hz
    200 km   38 dB   0.31   0.54   10 Hz  5.1 kHz          99.6 Hz   35.5 Hz     217 Hz    463 Hz
    500 km   95 dB   0.77   0.22   10 Hz 2.04 kHz          99.5 Hz   63.4 Hz     129 Hz    192 Hz
```

At 1 kHz every column agrees once the delay passes a few $\tau_c$. At 100 Hz only the short-delay
fit on phase-averaged spectra finds the laser, from 50 km (a tenth of a coherence time) up; the
Lorentzian and the −3 dB point report the delay instead. In `phoq serve
examples/linewidth_narrow.py` the analyzer window still shows the plain Lorentzian half width,
which assumes $\tau_d \gg \tau_c$; the short-delay fit runs from Python.

**What the model leaves out.** Only white frequency noise: real lasers also have 1/f (flicker)
frequency noise, which adds a Gaussian part that grows with the delay and turns the lineshape into
a Voigt profile (§2.3.4, §4.1). Perfect polarization overlap: a real setup needs a polarization
controller in one arm (or Faraday mirrors, §2.3.2), and a mismatch scales the beat by the overlap.
No low-frequency environmental noise: on a bench, temperature and vibration add noise near 0 Hz,
right under the homodyne beat. That is why the self-heterodyne, which moves the beat to
$f_\mathrm{AOM}$, is the more common method (Table 1, §3.2, §4.2); phoq has no acousto-optic frequency
shifter yet, so only the homodyne version can be built. No dispersion, nonlinearity or Rayleigh
backscattering in the spool (§5 notes double Rayleigh scattering distorts the lineshape).

### Laser intensity noise and shot noise

`Laser(rin_db_hz=…)` gives a laser relative intensity noise above the shot noise limit, its excess
RIN (**Excess RIN** in the lab): a white one-sided PSD of δP/P̄ in dBc/Hz, one realization per
laser shared by all its paths. Shot noise is drawn at
detection, so a photodiode reads $`S_I = \mathcal R^2\bar P^2\,\mathrm{RIN} + 2q\bar I + 4k_BT/R`$.
`examples/laser_noise.py` measures both with a PDB450C at 10⁵ V/A and the spectrum analyzer:

```
laser (excess RIN −135 dBc/Hz) → VOA → 50:50 ─┬─ 1 m → INPUT+    BPD-1 (PDB450C, 10⁵ V/A)
                                              └─ 1 m → INPUT−    RF OUTPUT → spectrum analyzer
```

| Measurement | Expected | phoq |
| --- | --- | --- |
| Dark | (NEP·ℜ)² = −236.2 dB re 1 A²/Hz | −236.2 dB |
| One input: fit of a·I + b·I², 1.8–28 µA | b = RIN = −135 dBc/Hz, a = 2q | −134.95 dBc/Hz; q to −3.6 % |
| Both inputs, 4 µA to 1.1 mA | the difference at 2q(I₊ + I₋) | within ±0.4 dB; q to +0.2 % |
| The same 1.1 mA on one diode | 20 dB above the shot noise | 20.1 dB |
| Dark-noise clearance at 1.1 mA | shot over amplifier noise | 21.8 dB |

One input bends from the shot noise to the laser's intensity noise at 2q/RIN = 10 µA. The
difference current stays at the shot noise, because the RIN reaches both diodes alike and cancels
to the CMRR residue. The two diodes' shot noises are uncorrelated and do not cancel.
[examples/laser_noise.md](examples/laser_noise.md) explains where each noise enters phoq's code,
checks the terms against the RP Photonics articles, and has the full tables and plot.
[docs/tutorial-laser-noise.md](docs/tutorial-laser-noise.md) does the same measurements by hand in
the 3D lab, with no Python: the floor, shot noise, RIN read in dBc/Hz, balanced detection and its
CMRR limit, the analyzer's RBW, and an open spare port.
`tests/test_laser_noise.py` checks the photodiode against $`2qI + 4k_BT/R + \mathrm{RIN}\,I^2`$
to 0.1 dB, the variance of δ per bin, and that the noise is shared by every path.

### Quantum random numbers from vacuum fluctuations

`examples/vacuum_noise_qrng.py` builds the vacuum-noise quantum random number generator of Gehring et al. (2021)
and Park et al. (2025), with the oscilloscope as its ADC:

```
laser (LO, 4 dBm) → BS-1 ─┬─ 1 m → INPUT+    BPD-1 (PDB450C, 10⁴ V/A, 45 MHz)
 FC/APC cap: vacuum ───────┘─ 1 m → INPUT−    RF OUTPUT → OSC-1 CH1 (adc_bits=8)
```

Only the vacuum enters the coupler's second input, which is capped with an angled terminator. The balanced detector's difference current
keeps the vacuum's quadrature noise amplified by the LO, which is the shot noise of the previous
section, while the LO's own noise cancels. Each 8-bit sample is Gaussian, with variance
$`\sigma^2 = \sigma_Q^2 + \sigma_E^2`$: the vacuum's share plus the classical excess (the amplifier,
any leaked laser noise).
Only $\sigma_Q$ is unpredictable. The script estimates three things:

- **Min-entropy against classical side information** (Park Eqs. 7–8): the adversary knows each
  sample's classical part and guesses its bin, $`H = -\log_2\max\{c_1, \mathrm{erf}(\delta/2\sqrt2\sigma_Q)\}`$.
  Here c₁ is the end bin, reached when the classical noise pushes a sample up to 5σ_E toward it.
- **Min-entropy against quantum side information** (Gehring Eq. 67). The excess noise is a thermal
  state of n photons on a vacuum of variance g², so σ² = g²(1+2n). The bound is maximized over δ;
  for n = 0 it is the classical bound.
- **Correlations** (Gehring Eqs. 14, 22, 24). Only what the past cannot predict counts:
  $`\sigma_X^2 = \exp\langle\ln f(\lambda)\rangle`$, the geometric mean of the samples' PSD. Likewise
  $\sigma_U^2$ for the excess noise alone. Then g² = σ_X² − σ_U² and n = ½σ²/g² − ½.

A Toeplitz hash then compresses N samples to $`\ell = N H - \log_2(1/2\varepsilon^2)`$ bits
(Gehring Eq. 1).

The detector's DC output matters. phoq's balanced detector is DC-coupled, with INPUT− 3.2 % less
responsive, so `build()` trims the coupler to 0.4920 so that the photocurrents match. That is Park's
precision balancing, and it also cancels the LO's −140 dBc/Hz RIN. At 0.5000 the 0.29 V of DC puts
every sample on an end code of the ±9 mV screen.

| Check | Result |
| --- | --- |
| Shot noise: σ² − σ_E² against LO power, −2 to 4 dBm | ∝ I: 401–406 codes² per mA, against 404 from 2q·I through the detector's and the scope's filters; R² = 1.0000 (Park: 0.9957) |
| Dark noise σ_E² | 279 codes², against 280 from the NEP through the same filters (and 1/12 of quantization) |
| Quantum-to-classical noise ratio at 1.8 mA | +4.2 dB = 2q(I₊ + I₋) over the NEP noise (+4.17 dB) |
| Sample rate against the 45 MHz detector | acf(1) 0.002, 0.005, 0.072, 0.290 at 20, 50, 100, 200 MS/s, against 0.000, 0.004, 0.069, 0.292 from the filters' impulse response; H per sample 5.57, 5.63, 5.57, 5.43 bits; rate 0.11, 0.28, 0.56, 1.09 Gb/s |
| 8-bit min-entropy against the ADC range, ±3σ, ±4σ, ±6σ, ±8σ | classical 1.56, 4.14, 5.50, 5.08 bits; quantum 5.78, 5.57, 4.98, 4.57 bits (best range 5.80) |
| 12 and 16 bits at ±8σ | classical 9.08 and 13.09 bits; quantum 8.57 and 12.57 bits |
| Extraction at 100 MS/s, 8 bits | 512 samples → 2726 bits, 532 Mb/s; output passes the frequency, runs and byte-χ² checks |
| Vacuum input left open (flat FC/UPC), or an FC/APC cap on that flat port | RF OUTPUT at 37.9 mV, past the ±8.9 mV screen: every sample an end code; +36 to +41 % classical noise on top of the shot noise; capped FC/APC on FC/APC: none |

Every record draws its own noise: sweep k of record j comes from numpy's stream (seed, j, k), and
`--seed` changes them all. With seeds 1, 2 and 3 the shot noise per mA stays within 401–410
codes², acf(1) within 0.006 of the filters' values, the min-entropies within ±0.05 bit (±0.02 at the best range), and
every extraction passes.

The two side-information models want different ADC ranges. The classical worst case lets the
adversary push a sample toward an end bin, so it needs a wide range. The quantum bound takes the
excess noise as Gaussian and is best near ±4σ at 8 bits. Sampling faster than the detector's
bandwidth still adds bits per second, but each sample carries fewer.

The vacuum input must stay dark and must not reflect. Left open, its flat end sends back the LO
light that the matings at the coupler's outputs reflect (−50 dB each), about 10 ns late. That
copy beats with the LO, so RF OUTPUT leaves the screen, and the delay turns the laser's phase
noise into intensity noise. The LO-off calibration cannot see that noise, so it would count it
as quantum. The report names the port: `Open connector BS-1.a2 reflects light on to BPD-1, -58 dB
under the light that reaches it directly`.

The bounds reproduce the papers' own numbers (`tests/test_qrng.py`):

| Paper | Their value | phoq |
| --- | --- | --- |
| Park Eq. (8): σ_Q = 4212 and σ_E = 2107 codes, 16 bits | 13.37 bits | 13.366 bits |
| Gehring Table 1: σ², σ_X², σ_U² at the worst corner of their confidence intervals | 10.74 bits | 10.78 bits |
| Gehring Eq. (1): 352 samples at 3.51 bits, ε_hash = 10⁻³² | 1024 bits extracted | 1023 bits |

phoq's "quantum" noise is numpy's pseudo-random generator, so this models a QRNG's design (power,
balance, bandwidth against sample rate, ADC range and resolution, extraction) and not its
randomness. Not modeled: the detector's AC coupling, the ADC's non-linearity and interleaving
errors (which cost Gehring 7.23 of their 10.74 bits), and drifts that would break stationarity.
[examples/laser_noise.md](examples/laser_noise.md) connects this to the shot-noise measurement.

### Checked against the literature

The models above were compared with twelve references (October 2026):

- *S. J. van Enk, "Coherent states, beam splitters and photons".* §8 and §12–14: a laser mode is
  a coherent state with Poissonian photon number $P(n) = e^{-|\alpha|^2}|\alpha|^{2n}/n!$ and mean
  $|\alpha|^2$, its initial phase is unknown, and a coherent state with a random phase equals a
  Poissonian mixture of number states. The `Laser` follows this: `mean_photon_number` $= E/h\nu$, a
  uniformly random start phase per acquisition, a Wiener-process phase whose correlation decays
  with the coherence time $1/\pi\Delta\nu$, and `phase_randomized` for weak-coherent-pulse
  sources. §16: a beam splitter maps
  $(\alpha_1, \alpha_2) \to ((\alpha_1 + i\alpha_2)/\sqrt2, (i\alpha_1 + \alpha_2)/\sqrt2)$,
  the convention `Coupler` uses, and §20 the Mach–Zehnder
  fringe $(1 - \cos\varphi)/2$ that the MZI tests reproduce.
- *J. A. T. Santiago, "Photon statistics for Fock and coherent states interfering in a beamsplitter"
  (arXiv:2508.08223).* Eq. (1.1) is the same beam-splitter transformation with $|t|^2$ equal to `ratio`;
  §4, Eqs. (4.3)–(4.7): two coherent inputs give product coherent outputs
  $|t\alpha + ir\beta\rangle|ir\alpha + t\beta\rangle$, still Poissonian ($Q = 0$,
  $g^{(2)}(0) = 1$). That is why the engine may
  add fields and then treat each detector's photon number as Poissonian with mean
  $\eta|E|^2\Delta t/h\nu$: the SPD's $p(\mathrm{click}) = 1 - (1 - p_\mathrm{dark})e^{-\eta\bar n}$ is
  exact for this light, and Charlie's two detectors in MP-QKD click independently. Fock-state
  inputs (§2–3), including Hong–Ou–Mandel interference, are outside the model: there are no
  single-photon sources, and a threshold detector facing $|1\rangle$ would click with probability
  $\eta$, not $1 - e^{-\eta}$.
- *C. Neumüller et al., "Bias control and linearization of the transfer function of electro-optic
  and acousto-optic modulators" (Fraunhofer IIS, 2024), §3.1.* Eqs. (1)–(3) give the phase
  modulator's $\varphi = \pi V/V_\pi$ and the origin of $V_\pi$; Eqs. (4)–(5) the MZM transmission
  $\cos^2(\varphi/2)$; Eq. (6) the finite extinction ratio from unequal arm powers, which replaced an
  earlier clamp in `IntensityModulator`; Eq. (7) the sum $V = V_\mathrm{RF} + V_\mathrm{DC}$ that the
  `rf` and `bias` inputs implement; Eq. (8) the drift term $\varphi_\mathrm{drift}$ behind the new
  `phi0`; and Eqs. (9)–(10) the push–pull drive. The paper's bias-control and predistortion
  sections (§4–5) are not modeled. AOMs are not part of the lab.
- *J.-Q. Chen et al., "Linewidth measurement of a narrow-linewidth laser: principles, methods, and
  systems", Sensors 24, 3656 (2024), [doi:10.3390/s24113656](https://doi.org/10.3390/s24113656).*
  §2.2 and Fig. 1b: the delayed self-homodyne interferometer that `examples/linewidth_homodyne.py`
  builds, and a beat twice as wide as the laser (here: the one-sided −3 dB point at $\Delta\nu$);
  §2.3.1 and Eq. (10): a Lorentzian once $\tau_d \gg \tau_c$; §2.3.4: the −20 dB width over $2\sqrt{99}$
  and Voigt fitting against 1/f noise. Eqs. (28)–(31), the spectrum for any delay: phoq averaged
  over the bias phase matches it to 0.07 dB rms at 0.3 and 1 km. §2.1, Eq. (4) and Fig. 2: a
  two-laser beat of width $\nu_t + \nu_r$, reproduced to within 2 %. Eq. (20): the frequency-
  discriminator limit behind the sinc² at short delays. Both checks are tests in
  `tests/test_spectrum.py`. Outside the model: 1/f noise, the self-heterodyne (no AOM), the
  recirculating, Brillouin and phase-noise (β-separation line) methods of §2.3.3–2.5.
- *D. J. Blumenthal, "Photodetection and Photodetectors", ECE228B Lecture 2 (UC Santa Barbara).*
  Slides 6–7: shot noise σ² = 2q(I_p + I_d)Δf, Poissonian but treated as Gaussian, with a flat
  density 2qI over the detector bandwidth. Checking this showed that phoq's photodiode had the
  right variance but spread it flat up to the sampling Nyquist frequency: at 50× oversampling the
  density inside the detector bandwidth was 15 dB too low. The noise now passes through the
  detector's pole with the signal, and its density is 2qI·|H(f)|² to within 1.5 %. Slide 10: the
  shot-noise-limited SNR ηP/(2hνB): phoq 1889, formula 1888 at 1 µW (η = 0.76, B = 1.57 GHz).
  Slides 11–12: the heterodyne beat 2R√(P_lo·P_s), reproduced to 10⁻⁴, and with a strong local
  oscillator SNR = ηP_s/(hνB), to 1 %; homodyne's further 3 dB (slide 14) follows from the same
  beat. Slides 17–18: R = ηq/hν with η ≤ 1 (the material tables peak at η = 0.85–0.86 for Si,
  GaAs and InGaAs, 0.58 for Ge) and the band-gap cut-offs (Si 1110, Ge 1850, GaAs 870 nm), which
  the tables respect up to the absorption tail of the datasheet curves. Tests in
  `tests/test_detector_noise.py`. Outside the model: photoconductive gain (slides 19–20).
- *I. S. Kolev, T. V. Karadzhov and E. N. Petkova, "Noises in Photodetectors", ICEST 2005, Niš,
  pp. 725–728.* Eqs. (20)–(21) and (28)–(29): thermal noise 4kTΔf/R and shot noise
  2q(I_ph + I_D)Δf, the two terms of phoq's photodiode. In the dark, with 50 Ω at 300 K, phoq gives
  (2qI_d + 4kT/R)·B to 0.2 %; there thermal noise is 10⁶ times the dark current's shot noise.
  §II.H: photon noise is Poissonian, √N, which is the single-photon detector's click statistics.
  Outside the model: 1/f (flicker) noise, which the paper finds insignificant above 10 kHz (its
  cut-off is at most 20 Hz for a PIN photodiode, and none at zero bias), and avalanche excess noise
  2qIΔf·M^X (Eq. 37).
- *K. Wang et al., "Design of optical fiber delay line with large delay range and low insertion
  loss", Front. Phys. 12:1362101 (2024),
  [doi:10.3389/fphy.2024.1362101](https://doi.org/10.3389/fphy.2024.1362101).* §2.2–2.3: the
  fiber's LP₀₁ mode as a Gaussian beam, collimated and refocused, with the insertion loss set by the
  Gaussian mode overlap (Eq. 7), which is the coupling phoq's `OpticalDelayLine` uses. Fig. 5 gives
  the coupling against a 0–1 m gap for 4, 8, 12 and 16 mm lenses at 633 nm. Traced from the figure
  and fitted with one parameter, the 4 mm curve's Rayleigh range (0.64 m, a 4.5 µm mode diameter),
  phoq matches it to 0.001 rms. Through z_R ∝ f² it predicts the 8 mm curve to 0.003 rms, and a
  0.8 % and 0.2 % drop over 1 m for 12 and 16 mm, where the paper finds less than 1 %; a test in
  `tests/test_odl.py` repeats this. Eq. (2): the rest of the insertion loss is the fiber end faces
  (4 %) and twelve lens surfaces, about 0.5 dB in all for their design, against phoq's assumed
  1 dB. Outside the model: the lens and fiber-end misalignments of Figs. 6–10, and the aberrations
  behind their measured 95.3 → 92.7 % over 0–1 m (Fig. 12), more than either diffraction model
  predicts.
- *Thorlabs, "PDB440x and PDB450x Balanced Amplified Photodetectors", operation manual, version 2.4
  (2019).* §3.3: RF OUTPUT $`= (\mathcal R P_1 - \mathcal R P_2)\,G`$, half of it into 50 Ω. The
  Technical Data give the CW saturation powers, the monitors' 10 V/mW and their saturation above
  1 mW. §3.6 measures the CMRR as one input against both, and §3.7 plots the path-length limit. phoq's
  `BalancedPhotodetector` reproduces each of them, mostly to better than 0.1 dB or 1 %; the table is
  in [Balanced photodetector](#balanced-photodetector), and the checks are in
  `tests/test_balanced.py`.
- *M. Seimetz, "High-Order Modulation for Optical Fiber Transmission" (Springer, 2009), ch. 2.*
  Eqs. (2.11)–(2.14): the MZM's field and power transfer, which phoq's MZMs follow with a finite
  extinction added. Eqs. (2.15)–(2.18): the IQ modulator at its operating point, reproduced to
  2·10⁻⁴. Eqs. (2.46)–(2.47): the arcsin drive of the conventional IQ transmitter, which places
  16-QAM and 64-QAM at ½(i + jq). Figs. 2.27–2.28: undershoots and chirp at the symbol
  transitions, which need the RF bandwidth phoq does not model. See
  [IQ modulator](#iq-modulator) and `tests/test_iq_modulator.py`. Eqs. (2.3)–(2.8): laser phase
  noise as a random walk with a Lorentzian line, which is `Laser.phase`, and RIN modeled as white
  Gaussian noise, which is `Laser.rin_db_hz`.
- *R. Paschotta, RP Photonics Encyclopedia:
  [phase noise](https://www.rp-photonics.com/phase_noise.html),
  [intensity noise](https://www.rp-photonics.com/intensity_noise.html),
  [shot noise](https://www.rp-photonics.com/shot_noise.html),
  [balanced photodetection](https://www.rp-photonics.com/balanced_photodetection.html).*
  - Phase noise: white frequency noise means $`S_\varphi \propto 1/f^2`$, specified by a linewidth or
    a coherence time; phoq's laser has exactly that. The beat and delayed-beat measurements are
    `linewidth_homodyne.py` and the two-laser test.
  - Intensity noise: RIN is a PSD in dBc/Hz, or an rms value over a stated band. That led to the new
    `Laser.rin_db_hz`, and to giving the balanced demo's 5 % rms its 30 kHz band.
  - Shot noise: $2q\bar I$, white and Gaussian, unchanged by η < 1. It is a property of the light,
    which phoq draws at detection; that is exact for coherent light, but excludes squeezed light.
    The docs now say "carries" where they said a diode "adds" shot noise.
  - Balanced photodetection: common noise cancels to the CMRR, while the two halves' shot noises are
    uncorrelated and add. `examples/laser_noise.py` reproduces 2q(I₊ + I₋) to ±0.4 dB and q to
    0.2 %.
  - [examples/laser_noise.md](examples/laser_noise.md) maps each article onto the code.
- *T. Gehring et al., "Homodyne-based quantum random number generator at 2.9 Gbps secure against
  quantum side-information", Nat. Commun. 12, 605 (2021),
  [doi:10.1038/s41467-020-20813-w](https://doi.org/10.1038/s41467-020-20813-w).*
  - Eqs. (3) and (49)–(51): Gaussian outcomes of variance g²(1+2n), and the ADC's d bins with end
    bins beyond ±R.
  - Eq. (67), maximized over δ: the min-entropy against quantum side information.
  - Eqs. (9)–(11): the same at the best ADC range.
  - Eqs. (14), (22), (24): correlated samples, through conditional variances from the PSD.
  - Eq. (1): the leftover hash lemma.
  - `examples/vacuum_noise_qrng.py` implements all of these and reproduces Table 1's 10.74 bits to 0.04 bit, and
    the 1024 extracted bits.
  - Not modeled: their ADC's non-linearity, measured at 7.23 bits of loss, and the transfer-function
    calibration of the vacuum level (in phoq the dark noise is measured directly).
- *S. Park et al., "Precision-balanced homodyne quantum random number generator with uncorrelated
  IID raw data and NIST-compliant full-entropy extraction", Phys. Scr. 100, 095117 (2025),
  [doi:10.1088/1402-4896/ae052e](https://doi.org/10.1088/1402-4896/ae052e).*
  - §3.1: quantum noise linear in LO power above the electronic noise. phoq: R² = 1.0000 (seeds
    1–3: 0.9999–1.0000), against their 0.9957.
  - §3.2: balancing the two photocurrents to keep the LO's noise out. phoq trims the coupler, as
    they trim the split with a half-wave plate.
  - §3.3: correlations falling as the bandwidth exceeds the ADC's Nyquist frequency.
  - Eqs. (7)–(8): the min-entropy against classical side information, which reproduces their 13.37
    bits exactly.
  - Not modeled: their NIST SP 800-90B assessment, SHA3-384 conditioning and SP 800-22 suite, which
    phoq replaces with a Toeplitz extractor and three quick checks.

## Quantum protocols

`examples/mpqkd` builds the MP-QKD hardware: two lasers → intensity and phase
modulators (each station's AWG drives them with vacuum / ν / μ and one of D phases) → VOAs
calibrated to μ → fiber spools → Charlie's coupler → two SPDs → time tagger. The parties
(`alice.py`, `bob.py`, `charlie.py`) run the whole protocol on the simulated clicks — pairing,
sifting, parameter estimation, Cascade, verification and Toeplitz privacy amplification sized by
the MP-QKD key-rate formula (`keyrate.py`, `postprocess.py`) — and `sweep.py` plots the key rate
against fiber length next to the paper's theory curve. The single-process reference `run()`
stops after sifting:

```python
from phoq.loading import import_source

mpqkd = import_source("examples/mpqkd")       # the showcase package
print(mpqkd.run(mpqkd.MPQKDParams(distance=100e3, mu=0.4), rounds=2_000_000, seed=1).summary())
```

AWG patterns are indexed by pulse number. `PhotonCounter(compensate_delay=True)` removes the
time of flight, so `clicks[k]` belongs to pulse `k` of the patterns; `chN_delay` lines up a
channel whose detector sits behind a different length of fiber.

### BB84 with decoy states, and an eavesdropper

`examples/bb84` is the second protocol on the same party framework — two parties, no relay —
and shows that the framework is protocol-agnostic. It is phase-coding BB84 with weak coherent
pulses and three decoy intensities: Alice's pulsed laser → intensity modulator (μ, ν or vacuum
per pulse) → unbalanced Mach–Zehnder interferometer with a phase modulator in the long arm
(φ ∈ {0, π} for Z, {π/2, 3π/2} for X between the early and the late time slot) → VOA calibrated to
μ → fiber spool → Bob's matching interferometer (phase shifter for the stabilization, phase
modulator for θ = 0 or π/2) → two SPDs → time tagger with three bins per pulse. In the middle
bin the late half of the early slot interferes with the early half of the late slot: DET-0 clicks
for φ − θ = 0, DET-1 for π, and the satellite bins are ignored. `build()` calibrates μ at the
fiber input and the interferometer phase, then adds the residual phase error of an imperfectly
stabilized interferometer (0.2 rad ≈ 1 % QBER).

```bash
phoq run --protocol bb84 --blocks 20
```

```bash
phoq serve --protocol bb84
```

Per block Bob announces which pulses clicked and his basis string; Alice keeps the detections
whose bases match, discloses every detection's intensity label, all her X-basis bits and a 10 %
sample of her Z-μ bits; Bob answers with his bits at those positions. From these public counts
both consoles and result dicts report the QBER per basis, the decoy-state bounds on the vacuum
and single-photon yields Y₀, Y₁ and on the single-photon error e₁ (Ma, Qi, Zhao & Lo 2005, with
the vacuum decoy's true leak through the intensity modulator), the single-photon gain Q₁ and the
asymptotic GLLP key rate in secret bits per laser pulse:
R = (N_{μ,Z}/N)·[Q₁(1 − H₂(e₁)) − Q_μ f H₂(E_μ)]. The Z-μ detections minus the sample are the
raw key; error correction and privacy amplification are not implemented.

```python
bb84 = import_source("examples/bb84")
print(bb84.run(bb84.BB84Params(distance=20e3, mu=0.5, nu=0.1), pulses=300_000, seed=1).summary())
```

Eve is a `--party` file: `phoq run --protocol bb84 --party examples/bb84/eve.py`. A party file
cannot add hardware, so when an Eve party has been loaded `build()` cuts the fiber at
`eve_position` and splices in her station (`build(eve=True)` does the same by hand): a 2×2
`OpticalSwitch` whose bar state lets Alice's pulse pass and whose cross state sends it into Eve's
receiver — a copy of Bob's — while her own transmitter's pulse, delayed to arrive in the same
time slot, takes its place. She throws the switch for a random fraction of the pulses
(`PHOQ_EVE_FRACTION`, default 1), measures them in a random basis and re-sends what she found,
brighter than Alice to hide the loss of her own detection (`PHOQ_EVE_MU` overrides). She is a
`stage = 1` party, so the lab records her tagger before Bob's and lets her program her
transmitter from the clicks within the block. With everything intercepted the QBER rises to about
25 %, e₁ reaches ½ and the key rate drops to zero; the decoy bounds also register her
multi-photon re-sends. In the virtual lab her station appears in the fiber and her laptop next to
Alice's and Bob's.

### Delay-line interferometer: DPSK and the COW monitoring line

A delay-line interferometer (DLI) is an unbalanced Mach–Zehnder whose long arm is exactly one pulse
period $T$ longer. It is built from two `Coupler`s and standard cords: 0.5 m + 0.5 m around a
`PhaseShifter` (the interferometer phase $\theta$) in the short arm, 1 m + 2 m around an
`OpticalDelayLine` in the long arm. The cords give 2 m (9.795 ns), and the delay line adds the last
205 ps (100 ps insertion delay and 105 ps on the dial; see [Optical delay line](#optical-delay-line)). Each output slot then
holds pulse $k$ on top of pulse $k-1$, so the constructive port receives
$\tfrac12[1 + V\cos(\varphi_k - \varphi_{k-1} + \theta)]$ of the light and the destructive port the
rest. Here $V = e^{-\pi\Delta\nu T}$ is the coherence between neighboring pulses. This is the
demodulator of differential phase-shift keying (DPSK) and the monitoring line of coherent one-way
(COW) QKD, which checks that neighboring non-empty pulses keep their phase relation: an attack
that measures and re-prepares pulses breaks it, and the visibility drops.

The interferometer lives in `examples/dli.py`, a helper module, and two benches use it, each
with its own `build()` so `phoq serve` opens it on its own.

**DPSK demodulator: `examples/dli_for_dpsk.py`.** A 100 MHz, 1 ns pulse train and a phase modulator
writing $\varphi_k = \varphi_{k-1} + \pi b_k$, with the DLI and photodiodes on both ports. This is
the receiver of DPS-QKD:

```bash
python examples/dli_for_dpsk.py --plot dpsk.png
```

```
DLI: cords 3 m (long arm) − 1 m (short arm) = 9.79 ns; delay line 100 ps + 105 ps on the dial → one slot T = 10 ns

DPSK demodulation (photodiodes, one 2 µs scope sweep)
   linewidth  100 kHz: 180 slots decoded, 0 errors, mean contrast +0.991  (e^(−πΔνT) = 0.997)
   linewidth   10 MHz: 180 slots decoded, 6 errors, mean contrast +0.731  (e^(−πΔνT) = 0.730)
   linewidth  100 MHz: 180 slots decoded, 82 errors, mean contrast +0.025  (e^(−πΔνT) = 0.043)
```

`phoq serve examples/dli_for_dpsk.py` opens this bench. The scope shows the constructive port
(CH1), the destructive port (CH2) and the bits (CH3, the generator's second channel delayed into
the decoding slot); every "1" leaves by the destructive port. In the inspector, turn `DLI-PS`'s
phase to swap the ports, raise `LD-1`'s linewidth to 10 or 100 MHz to watch neighboring pulses
lose their phase relation, or tick *Random phase per pulse* to break it altogether.

**COW monitoring line: `examples/dli_for_cow.py`.** Bits as a pulse in one of two slots, 10 %
decoys (two pulses), μ = 0.5, 25 km, a 90:10 tap at Bob with the data detector on the 90 % side
and the DLI on the 10 % side, three SNSPD-like detectors and a time tagger. The full protocol, with
Alice's and Bob's parties, is [`examples/cow`](#coherent-one-way-qkd-cow-up-to-the-sifted-key).

```bash
python examples/dli_for_cow.py
```

```
COW monitoring line (μ = 0.5, 25 km, 10 % tap to the DLI, SNSPD-like detectors)
   source                                      V expected    DM1    DM2  data QBER
   coherent pulse train, 100 kHz           0.989    0.997   5300     28  0.0011 (128952 bits)
   coherent pulse train, 10 MHz            0.716    0.730   4613    762  0.0011 (128952 bits)
   coherent pulse train, 30 MHz            0.379    0.390   3740   1683  0.0011 (128952 bits)
   no phase relation between pulses        0.018    0.000   2809   2707  0.0013 (128604 bits)
```

Both the DPSK contrast and the COW visibility follow $e^{-\pi\Delta\nu T}$, because the engine shares
one phase walk between the two arms (see [docs/engine.md](docs/engine.md)). The data line's
error rate does not move at all: only the monitoring line sees coherence. The visibility is counted
on slots where pulse $k$ and pulse $k-1$ were both sent, across bit boundaries and inside decoys.
The delay line loses 0.92 dB more than the phase shifter, which caps the visibility at
$2\sqrt{t}/(1+t) = 0.994$ for that transmission ratio $t$; a bench would balance the arms with an
attenuator in the short one. The monitoring detectors sit behind 2 m more fiber than the data
detector, so DB's light arrives 9.8 ns earlier; the tagger's `ch1_delay` adds 9.8 ns to its time
stamps, as one sets a tagger's input delay, and bin $k$ holds pulse $k$ on all three channels.
Without it the visibility, counted on the wrong pulse pairs, falls to 0.43.

`phoq serve examples/dli_for_cow.py` opens this bench. The tagger counts DB on CH1, DM1 on CH2 and
DM2 on CH3. With the 100 kHz laser, a 2 ms run gives about 30,100, 2,160 and 920 clicks. DM1 leads
DM2 by about 2.4× rather than more, because output slots that hold only one pulse split 50/50
between the ports. Tick *Random phase per pulse* on `LD-A` and DM1 and DM2 even out (about 1,510
and 1,420) while DB stays where it was.

### Coherent one-way QKD (COW), up to the sifted key

`examples/cow` runs the protocol of Stucki, Brunner, Gisin, Scarani & Zbinden, *Fast and simple
one-way quantum key distribution*, Appl. Phys. Lett. **87**, 194108 (2005), quant-ph/0506097, on
the same party framework. It stops at the sifted key, its QBER and the visibility. Error
correction, privacy amplification and the secret-key rate (the paper's Eq. 4, which needs a model
of Eve's information) are not implemented.

**The source is a CW laser.** Alice's intensity modulator carves the CW light into equally spaced
pulses of μ photons, or blocks a slot: bit 0 is (μ, 0), bit 1 is (0, μ) and a decoy (fraction f)
is (μ, μ). Because the laser runs continuously, every pair of non-empty pulses keeps a fixed
phase, inside a decoy and across a 1-0 bit boundary, and that coherence is what Bob's monitoring
line checks. A gain-switched laser restarts from spontaneous emission at every pulse, so its
pulses have random phases and the visibility is zero; the paper's footnote also allows a
mode-locked laser with a pulse picker. `COWParams(source="gain-switched")` swaps in such a laser
to show it.

**The bench.** The defaults follow the paper:

- Alice: CW 1550 nm DFB (1 MHz linewidth, assumed), intensity modulator (30 dB extinction) on an AWG
  running at twice the 434 MHz pulse clock, so each pulse fills the middle half of its 2.30 ns slot;
  a VOA calibrated to μ = 0.5 at the fiber input.
- 25 km of fiber, about 5 dB with the splices and connectors.
- Bob: a 50/50 tap (as in the paper's experiment). The data detector DB reads the bit from the time
  of arrival. The other half crosses a delay-line interferometer one slot (0.47 m) unbalanced. Its
  cords are standard: 1 m + 1 m in the long arm, 3 × 0.5 m in the short one, where an
  `OpticalDelayLine` takes back the 145 ps those cords give too much. A VOA in the long arm evens
  out the arms' losses, and the phase shifter locks DM1 to the constructive port.
- Detectors with η = 10 % and 2.5·10⁻⁵ dark counts per ns, as in the paper; no dead time (theirs
  was 10 µs against afterpulsing, which phoq does not model). The time tagger runs one bin per slot
  with 4 sub-bins to resolve the carved pulses, and its DB channel is delayed 12.4 ns to line up
  with the monitoring detectors.

**The protocol, per block** (the paper's steps 2–4):

1. Bob announces which bits gave a data-line click, but not in which slot, and the slots where DM1
   and DM2 fired.
2. Alice flags the detections that were decoys, which leave the key, and discloses her bits at a
   random 10 % of the rest. She counts DM1 and DM2 clicks where pulses j−1 and j were both sent:
   V_d inside decoys, V₁₋₀ across 1-0 boundaries. She measures each detector's dark-count
   probability where neither pulse was sent (a 0-1 boundary leaves two empty slots), which gives
   the paper's net visibility next to the raw one.
3. Bob answers with his bits at the sample. Both laptops show the same tally: sifted bits, sampled
   QBER, V raw, V net, V_d and V₁₋₀.

```bash
phoq run --protocol cow --blocks 20
```

```bash
phoq serve --protocol cow
```

The single-process reference `run()` keeps both records in one place, so it counts the QBER on the
whole sifted key and compares every figure with `theory()`. The theory computes per-pulse photon
numbers from the traced losses, click probabilities $1 − (1 − p_d)\cdot e^{−ηm}$, and the dark counts'
share of the raw visibility:

```python
cow = import_source("examples/cow")
print(cow.run(blocks=24, seed=5).summary())
```

```
COW over 25 km, cw source, μ = 0.5, f = 0.1, t_B = 0.5, 4,800,000 bits (9,600,000 slots at 434 MHz)
detected 6.566e-03 per bit (expected 6.549e-03) · sifted 25,733 (5.361e-03 per bit, expected 5.372e-03)
QBER 0.0119 on the whole sifted key (expected 0.0116)
V raw 0.9484 (expected 0.9471) · net 0.9920 (decoy 0.9923, 1-0 0.9941; expected 0.9928)
```

The net visibility is the coherence of neighboring pulses, $e^{-\pi\Delta\nu\tau}$ = 0.993 for
1 MHz over one slot. Dark counts on DM2 bring the raw value down to 0.947. With the gain-switched
source, the data line is unchanged (QBER 1.1 %) but V averages −0.003 ± 0.034 over 12 runs. The
paper measured raw V ≥ 92 % and net V ≈ 98 %. Its QBER was 5.2 %, but 4 % of that came from
detector noise and afterpulses and 1 % from slow modulator electronics, neither of which phoq
models; here the QBER is the dark counts plus the modulator's 30 dB leak.

### COW under attack: intercept-resend in the time basis

The monitoring line exists for attacks that leave the data line alone. `examples/cow/eve.py` is the
simplest one: Eve measures the time of arrival, which is the data line's own measurement, on a
fraction of the bits and re-prepares what she saw. She is a `--party` file:

```bash
phoq run --protocol cow --party examples/cow/eve.py --blocks 20
```

```bash
PHOQ_EVE_FRACTION=0.5 phoq serve --protocol cow --party examples/cow/eve.py
```

**Her station.** A party file cannot add hardware, so once an Eve party is loaded `build()` cuts
the 25 km spool in the middle and splices her station in (`build(eve=True)` does it by hand,
`build(eve=EveParams(...))` with other parts):

- a 2×2 `OpticalSwitch`. In the bar state Alice's light goes on to Bob; in the cross state it goes
  into Eve's detector, and her transmitter's light takes its place on the line;
- a time-of-arrival detector (η = 90 %, 100 Hz dark counts) on her own tagger, one bin per slot
  with 4 sub-bins like Bob's;
- a copy of Alice's transmitter: her own CW laser at Alice's wavelength (1 MHz linewidth, phase
  unrelated to Alice's), an intensity modulator with 40 dB extinction, and a VOA;
- one AWG on the clock she recovers from Alice's light (time base `laser`). CH2 throws the switch,
  CH1 carves her laser.

The AWG's channel delays do the timing, not fiber. CH2 is delayed by the 61.24 µs that Alice's
pulse 0 takes to reach the switch, so symbol k of the switch pattern takes Alice's pulse k. CH1 is
delayed so that Eve's pulse k passes the switch with Alice's pulse k, and so reaches DB, DM1 and DM2
in the same slot and sub-bins. Her VOA is calibrated by a loopback: in the bar state her
transmitter feeds her own detector, and she sets a full pulse there to μ photons. The μ she reads
off her click rate and the intensity she re-sends then refer to the same point, and the switch's
loss cancels because both routes have it. Every cord is a standard 1 m one.

**Her attack, per block.** She is a `stage = 1` party: the lab records her tagger first and waits
for her to program her transmitter before it records Bob's. She picks the bits to intercept
(`PHOQ_EVE_FRACTION`, default 1) and switches both of their slots to her detector, with her
transmitter dark. From her clicks she re-prepares (`intercept()`) bit 0 or bit 1 for a click in one
slot, a decoy for clicks in both (she then knows the sequence, as the paper assumes), and nothing
where her detector stayed dark (`PHOQ_EVE_INCONCLUSIVE=decoy` sends a decoy there instead). About
0.17 photons per pulse reach her detector, which clicks on q = 14 % of Alice's pulses, so she
re-sends rare, bright pulses matched in photon flux to what she took out: μ_E = 0.17/q ≈ 1.2 at
her detector (`resend_mu()`; `PHOQ_EVE_MU` sets it by hand). In the virtual lab her station sits in
the fiber and her laptop next to Alice's and Bob's, showing the fraction she is intercepting, her
click probability and μ_E, then the bits and decoys she has re-sent.

To take her off the line and put her back, select EVE-AWG and untick, then tick, **CH2 output**.
With its drive off the switch rests in the bar state, Alice's light passes untouched, and Eve
stands down until it returns. Alice's and Bob's laptops lead with running figures over the last
10 blocks (`QBER now`, `V_d now`, `V_1-0 now`; the totals follow), so the change shows within
about 30 s: in one run V_d went 0.09 → 0.95 → 0.22 and V₁₋₀ 0.08 → 1.00 → 0.12, while the
QBER stayed between 1.2 % and 2.4 %.

**What Alice and Bob see.** Each row is 40 blocks (8·10⁶ bits) of `run(eve=p_IR)`, with the expected
values from `theory()` and `eve_theory()` in brackets:

| p_IR | detected / bit [10⁻³] | QBER [%] | V_d (net) | V₁₋₀ (net) | paper, 1 − p_IR·ξ |
| --- | --- | --- | --- | --- | --- |
| 0, station idle | 3.84 (3.82) | 1.72 ± 0.08 (1.84) | 0.995 ± 0.003 (0.993) | 0.990 ± 0.003 (0.993) | 1 |
| 0.25 | 3.84 (3.82) | 1.81 ± 0.08 (1.88) | 0.767 ± 0.018 (0.783) | 0.602 ± 0.016 (0.572) | 0.769 |
| 0.5 | 3.82 (3.81) | 1.99 ± 0.09 (1.92) | 0.543 ± 0.023 (0.574) | 0.322 ± 0.019 (0.292) | 0.537 |
| 0.75 | 3.82 (3.80) | 2.03 ± 0.09 (1.97) | 0.312 ± 0.027 (0.365) | 0.150 ± 0.020 (0.153) | 0.306 |
| 1 | 3.81 (3.79) | 2.06 ± 0.09 (2.01) | 0.113 ± 0.029 (0.155) | 0.169 ± 0.020 (0.155) | 0.075 |
| 1, her modulator at 30 dB | 3.90 (3.87) | 3.32 ± 0.11 (3.19) | 0.152 ± 0.029 (0.192) | 0.214 ± 0.020 (0.192) | 0.075 |

The ± is one standard deviation of the counts: about 1000 DM clicks per row land inside decoys
and 2400 on 1-0 boundaries. Without the station (one 25 km spool) the same 40 blocks give
6.58·10⁻³ detections per bit and a QBER of 1.19 %.

The data line cannot tell. Bob's detection rate stays where it was with the station idle and his
QBER moves by a few tenths of a percent (her leak, below), because a time-of-arrival measurement
disturbs nothing the data line reads. The
visibilities fall with the intercepted fraction p_IR (the paper's notation; f stays the decoy
fraction). Coherence survives only where both neighboring pulses come from one
laser. Across a 1-0 boundary that means both bits were left alone, probability (1 − p_IR)², or
both were re-sent by Eve (p_IR²·q). Inside a decoy it means the decoy was left alone, or Eve
recognized it and re-sent a decoy (p_IR·q). Weighted by Bob's monitoring clicks,

```math
V_d = (1-p_{IR})\,V + p_{IR}\,c\,V_E, \qquad V_{1-0} = (1-p_{IR})^2\,V + p_{IR}^2\,c\,V_E, \qquad c = q + 2(1-q)\sqrt{\varepsilon_E},
```

with V = V_E = 0.993 the coherence of either laser over one slot. The √ε_E term is Eve's own
modulator leak: next to a pulse she re-sends alone, the empty slot still holds her laser's light
at √ε_E of the pulse's amplitude, coherent with it. That adds 0.017 at 40 dB extinction and
0.054 at 30 dB. (`eve_theory()` also keeps the switch's −40 dB crosstalk, which lets a diverted
pulse of Alice's interfere with an untouched neighbor: +0.005 at most.) V₁₋₀ falls faster than
V_d, because intercepting a fraction of the bits breaks every boundary between an intercepted bit
and one left alone. This is why the paper requires V₁₋₀ = V_d.

The paper estimates the damage as 1 − V = I·ξ. Here I = p_IR: with the flux matched, every detection
Bob gets in an attacked pair is a bit Eve re-sent and knows. ξ = 2e^{−μt}/(1 + e^{−μt}) is the
chance that Eve saw one pulse of a decoy and not the other; with e^{−μt} → 1 − q, ξ = 0.925. That
estimate counts decoy events, while Bob's clicks weight them by photons, and a decoy Eve
recognizes carries two pulses. The visibility therefore drops by p_IR·(1 − c)·V = 0.84 p_IR rather
than p_IR·ξ = 0.925 p_IR. The two agree for small q and a dark modulator; the table's last column
gives the paper's V.

**What she cannot hide on this bench.** Her station costs 2.4 dB on the bar route (the switch's
1 dB, four more connector pairs, one more splice). Bob's rate is therefore 2.4 dB below the no-Eve
value whether she attacks or not, and with fewer signal photons against the same dark counts the
QBER rises from 1.2 % to 1.8 %. A link sees that as channel loss, which COW's security analysis
gives to Eve anyway. The QBER her attack adds comes from her modulator's leak into the pairs she
leaves empty: her pulses are 2.4 times brighter, and so is their leak. With a 30 dB modulator like
Alice's (`EveParams(extinction_db=30)`) the QBER at p_IR = 1 is 3.3 %, the table's last row.

Sending a decoy when inconclusive (`PHOQ_EVE_INCONCLUSIVE=decoy`, 12 blocks at p_IR = 1) trades
errors for visibility. Her decoys are coherent, so the raw V_d stays at 0.77 and V₁₋₀ at 0.90, but
Bob's bit from one of her decoys is a coin toss and the QBER is 46 %. The net visibility stops
meaning anything as well: her decoys fill the slots where Alice sent nothing, the dark reference
rises 7.5-fold, and net V₁₋₀ comes out near 2.

### Clock and frame synchronization from time stamps

Before any QKD post-processing, Bob must know which of Alice's symbols each click belongs to. His
time tagger runs on its own clock: its rate is off from Alice's by some ppm, and its time zero is
unrelated to hers. Alice and Bob agreed beforehand on a random string and its length, and Alice
sends it over and over. `examples/clock_sync.py` recovers Alice's clock and the delay from the
clicks alone.

**The bench.**

- Alice: a CW laser and an intensity modulator. Her AWG runs at 100 MHz + 23.4 ppm (Bob does not
  know the offset) with four samples per symbol, and plays each bit of an L = 1000-bit string as
  (s_k, 0, 0, 0). A 1 lights a 2.5 ns pulse centered on her clock tick, a 0 stays dark, and a frame
  is P = 10 µs. A VOA sets 0.2 photons per "1" pulse at the detector.
- Bob: a single-photon detector (η = 25 %, 1 µs dead time) and a free-running time tagger with
  100 ps bins: no delay compensation, no lock to Alice, and an unknown 3.21 µs clock epoch, set as
  the tagger's `ch1_delay`.

The analysis uses only the time stamps, the agreed string and the nominal rate:

1. **Clock.** The click train has a spectral line at Alice's symbol rate. The FFT of the click
   histogram finds it to one FFT bin (1/T = 500 Hz for a 2 ms record). The periodogram of the time
   stamps themselves, $Z(f) = |\sum_i e^{2\pi i f t_i}|^2$, maximized around that bin, then fits the
   clicks' phase against time. Its precision is $`\sigma_f \approx \sqrt{12}\,f\sigma_t/(T\sqrt N)`$
   for N clicks spread by $\sigma_t$ around the clock ticks, here the 2.5 ns pulse's 0.72 ns rms:
   tens of ppb.
2. **A check of the agreed frame length, by epoch folding.** Fold the clicks' symbol indices at
   each trial length ℓ. At ℓ = L they pile onto the string's 1s and leave its 0s empty, so the
   folded histogram's χ² exceeds its ℓ − 1 degrees of freedom by about N. A length sharing a factor
   g with L keeps about N·g/L of that excess, and 2L keeps all of it but over twice the degrees of
   freedom. So the significance $(\chi^2-\ell+1)/\sqrt{2(\ell-1)}$ peaks at L if the light really
   repeats every L symbols. The delay step uses the agreed L either way.
3. **Delay.** The phase of $Z(f_A)$ places the clicks inside a symbol, $\theta = \mathrm{frac}(\Delta f_A)$.
   Each click then gets a symbol index, the indices are folded modulo L, and they are circularly
   cross-correlated with the string as ±1 by FFT. The peak's lag D is the whole-symbol offset, and
   $\Delta = (D+\theta)/f_A$ modulo the frame. The string is random so that the peak is unique: its
   ±1 autocorrelation is L at zero lag and only about ±√L elsewhere. A periodic 1010… pattern would
   give Δ only modulo two symbols.

```bash
python examples/clock_sync.py --plot sync.png
```

| | patch cords only | 25 km of fiber |
| --- | --- | --- |
| Clicks in 2 ms | 1,434 | 1,432 |
| Clock: FFT peak → periodogram | 100.002313 → 100.0023396 MHz | 100.002314 → 100.0023430 MHz |
| Clock error against Alice's | −4 ppb | +30 ppb |
| Frame check (epoch folding) | L = 1000 at 33 σ; best coprime length 3.2 σ | L = 1000 at 30 σ; 4.5 σ |
| Cross-correlation peak | D = 322 symbols, 24 σ | D = 566 symbols, 25 σ |
| Δ mod P against the traced flight time + epoch | 3.224683 µs against 3.224692 µs (−9 ps) | 5.667150 µs against 5.667099 µs (+52 ps) |
| Clicks given their true symbol index | 100 % | 100 % |

With 25 km the true delay is 125.664 µs, more than twelve frames. Time stamps alone give it only
modulo P, so a real link resolves the rest with a longer frame or a coarse classical timestamp.
The delay error has two sources: the per-click spread over √N (about 19 ps), and the clock error
times the 1 ms between Alice's frame origin and the middle of the record (the 30 ppb of the
25 km run is 30 ps).

**1 ns time bins.** `build(rate=, resolution=, record=, dead_time=, mu=)` scales the bench, and
`GHZ = dict(rate=1e9, resolution=20e-12)` is the 1 GHz case. There the AWG runs at 4 GS/s playing
(1, 0, 0, 0), so each 1 is a 250 ps pulse, a frame is 1 µs and the tagger bins are 20 ps (phoq's
finest):

| Link | Record | Dead time | Clicks | Click spacing | Clock error | Frame check (σ; best coprime) | Delay error | Assigned |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 0 km | 200 µs | 1 µs | 193 | 1022 symbols | +10 ppb | 1034, wrong (9 σ; 6.8 σ) | +6.3 ps | 100 % |
| 0 km | 2 ms | 1 µs | 1928 | 1026 | −0 ppb | 1000 (38 σ; 10.1 σ) | −0.3 ps | 100 % |
| 0 km | 200 µs | 50 ns | 2250 | 76 | −22 ppb | 1000 (46 σ; 2.8 σ) | −2.1 ps | 100 % |
| 25 km | 200 µs | 50 ns | 2227 | 77 | −19 ppb | 1000 (47 σ; 3.3 σ) | −4.8 ps | 100 % |

At 1 GHz the detector, not the line, sets the statistics. The flux asks for 24 million clicks per
second; a 1 µs dead time allows about one per microsecond, whatever the symbol rate. Saturated,
the detector clicks once per dead time plus a short wait (the median spacing is about 1025
symbols), a rhythm of its own right next to the 1000-symbol frame:

- With 200 clicks, the blind frame check scores that rhythm (1034) above the true L. The true L
  only reaches about N/√(2L) ≈ 4.5 σ there.
- With 1900 clicks the true L wins at 38 σ, but the rhythm still shows at 10 σ.
- A 50 ns detector, as with SNSPDs, gives ten times the clicks in the same 200 µs and takes the
  artifact away (a 2.8 σ floor).

The clock and the delay never needed the blind check, since they use the agreed L.
`tests/test_sync.py` runs the 50 ns case and the saturated 1 µs case.

**Long dead times.** A free-running InGaAs SPAD is often held off 40–100 µs to keep afterpulsing
down. phoq has no afterpulsing, so here the dead time only costs clicks: a record T holds about
$`N = T/(\tau_d + W)`$ of them, where W is a live detector's mean wait for its next click. At 1 GHz
with 250 ps tagger bins (`LONG`):

| Dead time | μ | Record | Clicks | T/(τd + W) | W/τd | Runner-up | Clock error | Frame check (σ; best coprime) | Delay error | Assigned |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 40 µs | 0.2 | 20 ms | 500 | 500 | 0.001 | 1.00 | +0 ppb | 4004, wrong (19 σ; 16.2 σ) | −0.1 ps | 100 % |
| 40 µs | 0.002 | 2 ms | 45 | 46 | 0.097 | 0.38 | +43 ppb | 3271, wrong (7 σ; 6.1 σ) | +48 ps | 100 % |
| 40 µs | 0.002 | 20 ms | 456 | 456 | 0.097 | 0.13 | −1 ppb | 1000 (11 σ; 4.3 σ) | −14 ps | 100 % |
| 100 µs | 0.2 | 50 ms | 500 | 500 | 0.000 | 1.00 | −19 991 ppb: lost | 2565, wrong (23 σ; 19.0 σ) | lost | 0 % |
| 100 µs | 0.0008 | 50 ms | 455 | 456 | 0.097 | 0.13 | +0 ppb | 1000 (8 σ; 3.9 σ) | +1.0 ps | 100 % |

Saturated (μ = 0.2, W ≈ 40 ns), the detector clicks almost exactly once per dead time, and this
regular rhythm causes two problems:

- **The clock is ambiguous.** The clicks strobe Alice's clock at a rate of about $1/\tau_d$, so the
  line at $f_A$ has copies every $1/\tau_d$: 25 ppm of the clock at 40 µs and 10 ppm at 100 µs.
  Bob's ±100 ppm search holds several of them, almost equally tall. "Runner-up" is the tallest
  other peak in the search window over the chosen one; Bob can compute it without the truth, and
  at 1.00 it says the clock cannot be trusted. Which copy wins is chance: the 40 µs run happened
  to pick the right one, while the 100 µs run landed two copies off (−20 ppm) and lost the delay.
- **The frame check is fooled.** The rhythm (40,040 symbols at 40 µs) piles the clicks up at
  divisors of itself, such as 4004.

More clicks do not cure this: N grows with T, but the clicks stay just as regular. Attenuating
until the detector waits about a tenth of its dead time does, here μ = 0.002 and 0.0008. Each wait
then adds about $W$ of jitter to the rhythm, so the copies wash out within a few clicks (runner-up
0.13), while the count barely drops.

From there:

- Clock and delay come right from the 45 clicks of 2 ms.
- The blind frame check needs $N/\sqrt{2L}$ well above its ≈ 4 σ noise: about 450 clicks, which is
  20 ms at 40 µs and 50 ms at 100 µs.
- An oscillator known to better than half the copy spacing (±12 ppm at 40 µs, ±5 ppm at 100 µs)
  would also pick the right copy.

[docs/photon-counter.md](docs/photon-counter.md) derives these results and walks through the
tagger's acquisition. Two engine details matter here:

- **Dead time across chunks.** The tagger synthesizes a record in chunks of a million bins (20 µs
  at 20 ps), and the detector's dead time carries from one chunk to the next, and into the next
  acquisition when it continues the record. `build()` splits records over the tagger's 10⁸ bins
  into back-to-back acquisitions.
- **Tagger bins must be shorter than half a symbol.** With 500 ps bins at 1 GHz the tagger's
  Nyquist frequency is the clock itself. The time stamps' periodogram then mirrors the line about
  it: Alice's +23.4 ppm and a −23.4 ppm image stand equally tall.

**Why the AWG carves pulses.** With a CW laser the modulator alone shapes the light. A pulse
filling a fraction d of the symbol spreads its clicks over a phase 2πd of the clock, so the line at
f_A keeps a depth sinc(πd). NRZ (d = 1, the string played one sample per symbol) has its spectral
null exactly at the symbol rate, and step 1 has nothing to lock to:

| Pulse | Line depth (sinc(πd)) | Clock error | Delay error | Clicks assigned |
| --- | --- | --- | --- | --- |
| 2.5 ns (d = ¼) | 0.895 (0.900) | −4 ppb | −9 ps | 100 % |
| 5 ns (d = ½) | 0.645 (0.637) | +42 ppb | −19 ps | 100 % |
| NRZ (d = 1) | 0.008 (0) | +50.5 ppm: lost | lost | 10.2 % |

The quarter-symbol depth sits just under sinc(πd) because the CW light leaks through the modulator
(30 dB extinction) during the dark samples. With NRZ, the clock would have to come from the string
itself, through a joint search of frequency and delay that the example does not implement.
phoq's AWG and modulator are ideal, with instant edges and no rise time, and its detectors have no
timing jitter; on a bench both add to σ_t and widen the errors in proportion.
`tests/test_sync.py` also repeats the patch-cord run and the 25 km run (with a −57 ppm clock and a
different epoch), checks the carving table's depths, and runs the analysis on hand-made clicks.

## Adding a component

Subclass `Component`, declare `Param`s, create ports in `_build`, and describe the optics in
`scatter`. The class registers itself, shows up in the visualizer's toolbox and inspector, and
gets a generic 3D model until you add one in `src/phoq/web/js/parts.js`.

```python
import math
from phoq import Component, Param, Transfer

class Mirror(Component):
    category, prefix, label = "passive", "M", "Fiber mirror"
    reflectivity = Param(0.99, min=0.0, max=1.0)

    def _build(self):
        self.add_port("p1")

    def scatter(self, port, wavelength):
        return [Transfer(port, math.sqrt(self.reflectivity))]
```

## Server API

`phoq serve …` starts FastAPI. Interactive docs are at `/docs`.

| Method | Path | |
| --- | --- | --- |
| GET | `/api/catalog` | component types, ports, parameter specs, part presets per type |
| GET | `/api/presets`, `/api/presets/{id}` | part presets (`?component=`, `?vendor=`, `?q=`); one in full with provenance, limits and unsupported specs |
| POST | `/api/instruments/{name}/clear` | restart a spectrum analyzer's trace averaging or max hold |
| POST | `/api/components/{name}/preset` | `{id}` applies a preset (`generic` for the class defaults) |
| GET | `/api/state` | the setup: components, parameters, connections |
| GET | `/api/report` | mean powers, currents, warnings, and per laser the back-reflection and its strongest source |
| POST | `/api/components` | `{type, name?, params?}` adds a part |
| PATCH | `/api/components/{name}` | `{params}` sets attributes (422 on invalid values) |
| DELETE | `/api/components/{name}` | removes it and its connections |
| POST | `/api/connections` | `{a, b}` connects ports (409 if either is in use) |
| POST | `/api/disconnect` | `{port}` |
| POST | `/api/instruments/{name}/autoset` | oscilloscope autoset |
| POST | `/api/run` | acquire all instruments once |
| POST | `/api/save` | write the autosave file now |
| GET | `/api/export/python`, `/api/export/json` | the setup as code or JSON |
| GET / POST | `/api/tasks` | analysis tasks; POST `{path}` loads a script's `@task` functions |
| POST | `/api/tasks/{name}/{action}` | `restart`, `stop`, `start` or `clear` a task |
| WS | `/ws` | streams `state`, `report`, `frame` (~15/s), `counter` (~1/s) and `console` (task output) |

## Repository layout

```
src/phoq/            the package; src/phoq/web is the virtual lab, shipped with it
src/phoq/presets/    part presets (.toml) from vendor datasheets, one file per part
examples/            setups, analysis scripts and the MP-QKD showcase
examples/mpqkd/      Alice, Bob, Charlie (+ station/common), the original single-process
                     reference.py with its [CLASSICAL #N] notes, and a marimo notebook
examples/bb84/       decoy-state BB84: Alice, Bob (+ common), Eve as a --party file
examples/cow/        coherent one-way QKD up to the sifted key: Alice, Bob (+ common), Eve as a --party file
tests/               pytest suite
docs/engine.md       how the engine computes: tracing, field synthesis, detectors, instruments
docs/photon-counter.md  the time tagger's acquisition, dead time across records, long dead times
docs/images/         README screenshots of the virtual lab
tools/walkthroughs/  builds src/phoq/web/walkthroughs, the instrument walkthroughs the lab's Help links
.github/workflows/   ci.yml (tests on push / PR), publish.yml (PyPI on a version tag)
```

## Development and releases

```bash
uv sync --all-extras && uv run pytest -q
```

To release: set `version` in `pyproject.toml`, commit, tag it `vX.Y.Z` and push the tag. The
publish workflow checks that the tag matches the version, runs the tests, builds the sdist and
wheel with `uv build` and uploads them with PyPI *trusted publishing*, so no token is stored in
GitHub. One-time setup: on PyPI (Account → Publishing) add a trusted publisher for the project
`phoq` with this repository, workflow `publish.yml` and environment `pypi`, and create the `pypi`
environment under the repository's Settings → Environments.

## License

phoq is free software under the [GNU Affero General Public License v3.0 or later](LICENSE)
(SPDX: `AGPL-3.0-or-later`). If you run a modified phoq as a network service, the AGPL asks you to
offer its users the corresponding source.
