Toolkit for scanning Fabry-Perot cavity measurements — RF Vpi sweeps, sideband extraction, and modulation-index fitting.
cavityscope/
├── core/
│ ├── instruments.py # Generic ScopeInterface / RFSourceInterface protocols
│ ├── config.py # SweepConfig dataclass with all tuneable parameters
│ └── utils.py # Shared helpers (dBm conversion, windowing, I/O)
├── analysis/
│ ├── reference.py # RF-off reference trace analysis (FSR, carrier picking)
│ ├── measurement.py # Per-trace sideband / carrier integration
│ ├── vpi_fitting.py # Beta extraction from Bessel ratios, linear Vpi fit
│ └── plotting.py # Trace + fit plotting
└── sweep.py # Top-level sweep orchestration (hardware-agnostic)
notebooks/
└── rf_vpi_sweep.ipynb # Ready-to-run notebook — just plug in your hardware
The measurement uses a scanning Fabry–Pérot (FP) cavity to resolve the optical sidebands created by an electro-optic modulator (EOM) driven with an RF signal generator. An optional spectrum analyzer (SA) can be inserted into the RF path for power calibration.
flowchart LR
Laser["🔴 Laser"]
EOM["EOM\n(phase modulator)"]
FP["Scanning\nFabry–Pérot\nCavity"]
PD["Photo-\ndetector"]
Scope["Oscilloscope\n(CH 1)"]
RF["RF Signal\nGenerator"]
Amp["RF\nAmplifier\n(optional)"]
SA["Spectrum\nAnalyzer\n(optional)"]
Ramp["Ramp / Function\nGenerator"]
Laser -- "free-space / fiber" --> EOM
EOM -- "modulated beam" --> FP
FP -- "transmission" --> PD
PD -- "voltage" --> Scope
Ramp -- "ramp signal\n(piezo scan)" --> FP
RF -- "RF out" --> Amp
Amp -- "amplified RF" --> EOM
Amp -. "coupler / tap\n(calibration only)" .-> SA
style Laser fill:#d44,color:#fff,stroke:#a00
style EOM fill:#48a,color:#fff,stroke:#269
style FP fill:#48a,color:#fff,stroke:#269
style PD fill:#48a,color:#fff,stroke:#269
style Scope fill:#484,color:#fff,stroke:#262
style RF fill:#a84,color:#fff,stroke:#862
style Amp fill:#a84,color:#fff,stroke:#862
style SA fill:#a84,color:#fff,stroke:#862
style Ramp fill:#a84,color:#fff,stroke:#862
| Instrument | Role | Protocol class | Example driver |
|---|---|---|---|
| Oscilloscope | Records cavity transmission trace (time domain) | ScopeInterface |
RigolDHO4000 |
| RF signal generator | Drives the EOM at swept frequency and power | RFSourceInterface |
WindfreakSynthHD |
| Spectrum analyzer | Measures actual RF power at the fundamental (calibration) | SpectrumAnalyzerInterface |
RigolRSA3000E |
All instrument access goes through the protocol classes in core/instruments.py.
Concrete drivers live in the separate hardwarelib package and
are wired in at notebook level.
The half-wave voltage
---
config:
themeVariables:
fontSize: 12px
---
flowchart TD
A["1 — Acquire reference trace (RF off)"] --> B["2 — Locate cavity peaks, measure FSR"]
B --> C["3 — Turn RF on, acquire modulated trace"]
C --> D["4 — Integrate carrier and sideband areas"]
D --> E["5 — Compute ratio R = A_sb / A_carrier"]
E --> F["6 — Invert Bessel relation → β"]
F --> G["7 — Repeat for each RF power level"]
G --> H["8 — Linear fit β vs V_pk → V_π = π / slope"]
style A fill:#48a,color:#fff
style H fill:#484,color:#fff
The half-wave voltage cavityscope
extracts it from cavity transmission traces in four stages.
With the RF drive off, a scope trace of the scanning Fabry–Pérot
transmission is acquired. Peak-finding on the smoothed, baseline-subtracted
signal locates the cavity resonances (carriers). The free spectral range
(FSR) in time,
For each RF power/frequency setting the modulator is driven and a new scope trace is captured. The first-order sidebands appear at time offsets
from the carrier. Integration windows (configurable in Hz, mapped to time
via the FSR) are placed around the carrier and the
For a pure phase modulator driven at modulation index
This equation is numerically inverted for solve_beta_from_ratio in vpi_fitting.py).
Points are excluded from the subsequent fit when:
- the ratio falls outside
[min_ratio_for_beta, max_ratio_for_beta], - the sideband signal-to-noise ratio is below
min_sideband_area_snr, or - the root-finder returns no finite solution.
By definition of the half-wave voltage,
so fit_include_intercept) of the surviving
The fit quality is reported as vpi_fit_summary.csv output.
Voltage estimation.
$V_{\text{pk}}$ at the modulator is either calculated analytically from the set-point dBm ($V_{\text{rms}} = \sqrt{P \cdot R}$ ,$V_{\text{pk}} = \sqrt{2} \cdot V_{\text{rms}}$ ) or looked up from an optional power-calibration table that maps(power_dbm, frequency_hz)to measured$V_{\text{pk}}$ .
The modulation index
The RF signal is tapped into a second scope channel. For each grid point
a sine fit extracts
Limitation: When the signal generator (or amplifier) produces
significant harmonics, the captured waveform is no longer a pure sine.
The fit either over- or under-estimates the fundamental amplitude,
leading to a biased
A spectrum analyzer measures the power in the fundamental tone and each
harmonic (
where
Because the SA resolves each spectral line individually, the measurement is immune to harmonic distortion.
flowchart LR
subgraph "Scope-based (legacy)"
S1["RF → scope CH 2"] --> S2["Sine fit\non waveform"] --> S3["V_pk"]
end
subgraph "SA-based (recommended)"
A1["RF → spectrum\nanalyzer"] --> A2["Peak search\nat f, 2f, 3f …"] --> A3["P_fund (dBm)"]
A3 --> A4["V_pk =\n√(2·P·R)"]
end
style S3 fill:#a84,color:#fff
style A4 fill:#484,color:#fff
When using the SA method, cavityscope records the power of each
harmonic and computes:
- THD (%) — total harmonic distortion relative to the fundamental.
- dBc levels — each harmonic's power relative to the fundamental. Below −30 dBc is negligible; above −20 dBc indicates significant nonlinearity in the amplifier chain.
- Fundamental power fraction — what share of total output power is in the tone the modulator actually responds to.
These diagnostics are saved as CSVs and plots in the calibration output folder so you can judge amplifier linearity at a glance.
The resulting calibration table is stored in a PowerCalibration object
that maps any (power_dbm, frequency_hz) query to a
- Hardware is generic in the library. The sweep and analysis code depend only on
ScopeInterfaceandRFSourceInterface(PythonProtocolclasses). Concrete drivers live in the separatehardwarelibpackage. - Specific devices are wired in at notebook level. The Jupyter notebook imports the concrete driver (e.g.
RigolDHO4000,WindfreakSynthHD) and passes it to the sweep runner. Swapping to a different scope or signal generator requires changing only the import and one constructor call. - Configuration is a dataclass. All parameters live in
SweepConfig, which has sensible defaults and serialises to JSON.
# Install hardwarelib first (editable)
pip install -e ../hardwarelib
# Then install cavityscope
pip install -e .Open notebooks/rf_vpi_sweep.ipynb, set your instrument addresses, and run all cells.
Or from a script:
from hardwarelib.oscilloscopes.rigol import RigolDHO4000
from hardwarelib.signal_generators.windfreak import WindfreakSynthHD
from cavityscope.core import SweepConfig
from cavityscope.sweep import run_sweep
scope = RigolDHO4000("TCPIP0::192.168.1.50::INSTR")
rf = WindfreakSynthHD("COM12", channel=0)
scope.open()
rf.open()
try:
data = run_sweep(scope, rf, SweepConfig())
finally:
rf.set_output(False)
rf.close()
scope.close()