pyofdr 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyofdr/__init__.py +3 -0
- pyofdr/analysis/__init__.py +1 -0
- pyofdr/analysis/budget.py +278 -0
- pyofdr/analysis/demodulation.py +465 -0
- pyofdr/analysis/spatial_metrics.py +152 -0
- pyofdr/analysis/spectral.py +93 -0
- pyofdr/analysis/strain_quality.py +113 -0
- pyofdr/backends/__init__.py +88 -0
- pyofdr/cli.py +101 -0
- pyofdr/core/__init__.py +0 -0
- pyofdr/core/acquisition.py +47 -0
- pyofdr/core/campaign.py +95 -0
- pyofdr/core/config.py +102 -0
- pyofdr/core/config_models.py +547 -0
- pyofdr/core/pipeline.py +25 -0
- pyofdr/detection/__init__.py +0 -0
- pyofdr/detection/detector.py +184 -0
- pyofdr/detection/filter.py +42 -0
- pyofdr/digitizer/__init__.py +0 -0
- pyofdr/digitizer/adc.py +176 -0
- pyofdr/fiber/__init__.py +0 -0
- pyofdr/fiber/attenuation.py +50 -0
- pyofdr/fiber/bends.py +157 -0
- pyofdr/fiber/crosstalk.py +75 -0
- pyofdr/fiber/fbg.py +108 -0
- pyofdr/fiber/multiple_scattering.py +84 -0
- pyofdr/fiber/phase.py +5 -0
- pyofdr/fiber/profile.py +234 -0
- pyofdr/fiber/reflectors.py +79 -0
- pyofdr/fiber/strain.py +125 -0
- pyofdr/fiber/temperature.py +111 -0
- pyofdr/optics/__init__.py +0 -0
- pyofdr/optics/aux_mzi.py +97 -0
- pyofdr/optics/components.py +42 -0
- pyofdr/optics/mach_zehnder.py +283 -0
- pyofdr/output/__init__.py +0 -0
- pyofdr/output/hdf5_writer.py +121 -0
- pyofdr/source/__init__.py +0 -0
- pyofdr/source/phase_noise.py +85 -0
- pyofdr/source/swept_laser.py +149 -0
- pyofdr/strain_transfer/__init__.py +103 -0
- pyofdr/strain_transfer/motions.py +121 -0
- pyofdr/utils/__init__.py +0 -0
- pyofdr/utils/colorednoise.py +163 -0
- pyofdr/utils/constants.py +15 -0
- pyofdr/utils/sampling.py +13 -0
- pyofdr/utils/seeding.py +40 -0
- pyofdr/utils/units.py +13 -0
- pyofdr-1.0.0.dist-info/METADATA +153 -0
- pyofdr-1.0.0.dist-info/RECORD +55 -0
- pyofdr-1.0.0.dist-info/WHEEL +5 -0
- pyofdr-1.0.0.dist-info/entry_points.txt +2 -0
- pyofdr-1.0.0.dist-info/licenses/LICENSE +21 -0
- pyofdr-1.0.0.dist-info/licenses/LICENSES/colorednoise-MIT.txt +21 -0
- pyofdr-1.0.0.dist-info/top_level.txt +1 -0
pyofdr/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Analytical tools that derive system figures of merit from the config."""
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""Analytical OFDR budget calculator.
|
|
2
|
+
|
|
3
|
+
Config-based estimates -- no simulation. Covers:
|
|
4
|
+
- geometric quantities (dz, max beat frequency, Nyquist headroom)
|
|
5
|
+
- optical power chain (laser -> splitter -> circulator -> fiber)
|
|
6
|
+
- Rayleigh backscatter at near/far end
|
|
7
|
+
- receiver noise floor (shot, thermal, dark, RIN, quantization)
|
|
8
|
+
- NEP referred to optical input
|
|
9
|
+
- dynamic range
|
|
10
|
+
- phase-noise RMS at the far end (kept separate from the current RSS)
|
|
11
|
+
- strain / temperature sensitivity + max unambiguous strain
|
|
12
|
+
|
|
13
|
+
Noise quantities are input-referred current standard deviations. Receiver
|
|
14
|
+
noise is integrated over the digital Butterworth noise bandwidth; ADC noise
|
|
15
|
+
is added afterwards over the full Nyquist band. RIN in the current pipeline
|
|
16
|
+
is multiplicative on the beat, so its estimate needs the noiseless pre-filter
|
|
17
|
+
beat RMS. Missing RIN input yields NaN rather than a reference-arm DC estimate.
|
|
18
|
+
|
|
19
|
+
See issue #43.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import math
|
|
25
|
+
from functools import lru_cache
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from scipy.integrate import quad
|
|
29
|
+
|
|
30
|
+
from pyofdr.core.config import compute_derived
|
|
31
|
+
from pyofdr.core.config_models import RootConfig
|
|
32
|
+
from pyofdr.utils.constants import C, E_CHARGE
|
|
33
|
+
from pyofdr.utils.units import dB_to_linear
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@lru_cache(maxsize=128)
|
|
37
|
+
def _noise_bandwidth(fs: float, bandwidth: float, order: int) -> float:
|
|
38
|
+
"""One-sided integral of the digital Butterworth |H(f)|^2, in Hz.
|
|
39
|
+
|
|
40
|
+
Use the same 0.99-Nyquist cutoff clamp as AntiAliasFilter. The bilinear
|
|
41
|
+
substitution x = tan(pi*f/fs)/tan(pi*fc/fs) resolves even narrow filters
|
|
42
|
+
without a frequency grid tied to fs. Split at x=1 (the cutoff).
|
|
43
|
+
"""
|
|
44
|
+
cutoff = min(bandwidth, 0.99 * fs / 2.0)
|
|
45
|
+
a = math.tan(math.pi * cutoff / fs)
|
|
46
|
+
|
|
47
|
+
def integrand(x):
|
|
48
|
+
if x <= 1.0:
|
|
49
|
+
gain_sq = 1.0 / (1.0 + x ** (2 * order))
|
|
50
|
+
else:
|
|
51
|
+
inverse_power = x ** (-2 * order)
|
|
52
|
+
gain_sq = inverse_power / (1.0 + inverse_power)
|
|
53
|
+
return gain_sq / (1.0 + (a * x) ** 2)
|
|
54
|
+
|
|
55
|
+
area = quad(integrand, 0.0, 1.0, epsabs=1e-10)[0]
|
|
56
|
+
area += quad(integrand, 1.0, math.inf, epsabs=1e-10)[0]
|
|
57
|
+
return fs * a / math.pi * area
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def compute_budget(
|
|
61
|
+
cfg: dict[str, Any], *, beat_rms_current: float | None = None,
|
|
62
|
+
) -> dict[str, float]:
|
|
63
|
+
"""Return budget quantities in SI for the configured receiver mode.
|
|
64
|
+
|
|
65
|
+
``beat_rms_current`` is sqrt(mean(I_beat**2)) in A, before the receiver
|
|
66
|
+
filter, from a noiseless linear run (including any optical leakage).
|
|
67
|
+
It is required only when source RIN is enabled. Then sigma_rin, sigma_analog,
|
|
68
|
+
sigma_total, nep_total and dynamic_range_dB are NaN if it is omitted;
|
|
69
|
+
sigma_receiver still reports the known additive receiver contribution.
|
|
70
|
+
|
|
71
|
+
Estimates assume reference-dominated shot noise, a linear unsaturated
|
|
72
|
+
detector, weak unclipped white RIN, and settled filter statistics. ADC
|
|
73
|
+
quantization uses the uniform-error approximation; ENOB extra noise is
|
|
74
|
+
included, but jitter and DNL/INL are not. No post-ADC filtering is assumed.
|
|
75
|
+
Phase-noise RMS is a separate theoretical quantity, not a prediction of
|
|
76
|
+
the MZI's stochastic white-FM pedestal (which is not simulated).
|
|
77
|
+
"""
|
|
78
|
+
if beat_rms_current is not None and (
|
|
79
|
+
not math.isfinite(beat_rms_current) or beat_rms_current < 0
|
|
80
|
+
):
|
|
81
|
+
raise ValueError("beat_rms_current must be finite and nonnegative (A)")
|
|
82
|
+
cfg = RootConfig(**cfg).model_dump()
|
|
83
|
+
|
|
84
|
+
src_ = cfg["source"]
|
|
85
|
+
opt = cfg["optics"]
|
|
86
|
+
fib = cfg["fiber"]
|
|
87
|
+
det = cfg["detection"]
|
|
88
|
+
adc = cfg["adc"]
|
|
89
|
+
|
|
90
|
+
P_laser = src_["power"]
|
|
91
|
+
wl = src_["center_wavelength"]
|
|
92
|
+
lw = src_["linewidth"]
|
|
93
|
+
rin = src_["rin_dB_per_Hz"]
|
|
94
|
+
eta = opt["splitting_ratio"]
|
|
95
|
+
IL_circ = opt["circulator"]["insertion_loss_dB"]
|
|
96
|
+
|
|
97
|
+
# use the homogeneous terms only -- segments are a refinement that
|
|
98
|
+
# this first pass doesn't try to account for
|
|
99
|
+
alpha_dB_km = fib["attenuation_dB_per_km"]
|
|
100
|
+
R_dB = fib["rayleigh_coefficient_dB"]
|
|
101
|
+
L = fib["length"]
|
|
102
|
+
n_core = fib["n_core"]
|
|
103
|
+
|
|
104
|
+
R_resp = det["responsivity"]
|
|
105
|
+
NEP = det["thermal_nep"]
|
|
106
|
+
I_dark = det["dark_current"]
|
|
107
|
+
B = det["bandwidth"]
|
|
108
|
+
I_sat = det["saturation_current"]
|
|
109
|
+
|
|
110
|
+
bits = adc["bits"]
|
|
111
|
+
V_fs = adc["voltage_range"]
|
|
112
|
+
Z = adc["input_impedance"]
|
|
113
|
+
fs = adc["sample_rate"]
|
|
114
|
+
noise_bandwidth = _noise_bandwidth(fs, B, det["filter_order"])
|
|
115
|
+
|
|
116
|
+
# --- optical power chain (all linear [W]) --------------------------
|
|
117
|
+
il_circ_lin = dB_to_linear(-IL_circ)
|
|
118
|
+
P_ref_arm = eta * P_laser
|
|
119
|
+
P_to_fiber = (1.0 - eta) * P_laser * il_circ_lin
|
|
120
|
+
|
|
121
|
+
# one-way power attenuation coefficient [1/m]
|
|
122
|
+
alpha_m = alpha_dB_km * math.log(10.0) / 10.0 / 1000.0
|
|
123
|
+
R_per_m = dB_to_linear(R_dB) # backscattered power per metre of fiber
|
|
124
|
+
|
|
125
|
+
def _back_from(z):
|
|
126
|
+
# power backscattered by a 1-m slab at z, through the circulator back
|
|
127
|
+
return P_to_fiber * R_per_m * math.exp(-2.0 * alpha_m * z) * il_circ_lin
|
|
128
|
+
|
|
129
|
+
P_back_near = _back_from(0.0)
|
|
130
|
+
P_back_far = _back_from(L)
|
|
131
|
+
|
|
132
|
+
# --- receiver -------------------------------------------------------
|
|
133
|
+
# Keep I_dc_ref as the pre-recombiner reference-arm equivalent current.
|
|
134
|
+
# The 50/50 recombiner sends half to each PD, in both receiver modes.
|
|
135
|
+
I_dc = R_resp * P_ref_arm
|
|
136
|
+
I_dc_pd = 0.5 * I_dc
|
|
137
|
+
n_pd = 2 if det["balanced"] else 1
|
|
138
|
+
|
|
139
|
+
sigma_shot = (
|
|
140
|
+
math.sqrt(2.0 * E_CHARGE * n_pd * I_dc_pd * noise_bandwidth)
|
|
141
|
+
if det["shot_noise"] else 0.0
|
|
142
|
+
)
|
|
143
|
+
# One TIA after the subtraction node; dark current is specified per PD.
|
|
144
|
+
sigma_thermal = R_resp * NEP * math.sqrt(noise_bandwidth)
|
|
145
|
+
sigma_dark = math.sqrt(2.0 * E_CHARGE * n_pd * I_dark * noise_bandwidth)
|
|
146
|
+
|
|
147
|
+
# The simulator propagates RIN on the beat in BOTH modes. Reference-arm
|
|
148
|
+
# common-mode DC RIN is not propagated, so I_dc*sqrt(RIN*B) is not its floor.
|
|
149
|
+
if rin is not None:
|
|
150
|
+
rin_lin = 10.0 ** (rin / 10.0)
|
|
151
|
+
sigma_rin = (
|
|
152
|
+
beat_rms_current * math.sqrt(rin_lin * noise_bandwidth)
|
|
153
|
+
if beat_rms_current is not None else math.nan
|
|
154
|
+
)
|
|
155
|
+
else:
|
|
156
|
+
sigma_rin = 0.0
|
|
157
|
+
|
|
158
|
+
# ADC is downstream of the analog filter: its error has NOT been filtered.
|
|
159
|
+
sigma_q_V_tot = V_fs / (2 ** bits) / math.sqrt(12.0)
|
|
160
|
+
sigma_quant = sigma_q_V_tot / Z
|
|
161
|
+
enob = adc["enob"]
|
|
162
|
+
sigma_adc_extra = 0.0
|
|
163
|
+
if enob is not None and enob < bits:
|
|
164
|
+
sigma_enob = V_fs / (2.0 ** enob * math.sqrt(12.0)) / Z
|
|
165
|
+
sigma_adc_extra = math.sqrt(sigma_enob ** 2 - sigma_quant ** 2)
|
|
166
|
+
|
|
167
|
+
sigma_receiver = math.sqrt(sigma_shot ** 2 + sigma_thermal ** 2 + sigma_dark ** 2)
|
|
168
|
+
sigma_analog = math.hypot(sigma_receiver, sigma_rin)
|
|
169
|
+
sigma_total = math.sqrt(sigma_analog ** 2 + sigma_quant ** 2 + sigma_adc_extra ** 2)
|
|
170
|
+
|
|
171
|
+
nep_total = sigma_total / R_resp # integrated sampled noise, W RMS
|
|
172
|
+
|
|
173
|
+
# A reference-current proxy unless a saturation limit is supplied;
|
|
174
|
+
# this does not account for ADC clipping or nonlinear detector gain.
|
|
175
|
+
I_max = I_sat if I_sat is not None else n_pd * I_dc_pd
|
|
176
|
+
if math.isnan(sigma_total):
|
|
177
|
+
dynamic_range_dB = math.nan
|
|
178
|
+
elif sigma_total > 0:
|
|
179
|
+
dynamic_range_dB = 20.0 * math.log10(I_max / sigma_total)
|
|
180
|
+
else:
|
|
181
|
+
dynamic_range_dB = math.inf
|
|
182
|
+
|
|
183
|
+
# --- phase noise at the far end ------------------------------------
|
|
184
|
+
# Lorentzian source, round-trip delay tau = 2 n L / c:
|
|
185
|
+
# <Dphi^2> = 2*pi*lw*tau. Meaningful only when tau << 1/lw.
|
|
186
|
+
tau_far = 2.0 * n_core * L / C
|
|
187
|
+
sigma_phi = math.sqrt(2.0 * math.pi * lw * tau_far) if lw > 0 else 0.0
|
|
188
|
+
|
|
189
|
+
# --- strain / temperature sensitivity ------------------------------
|
|
190
|
+
# Rayleigh spectral shift (Froggatt-Moore):
|
|
191
|
+
# Dnu / nu = - ((1-p_e)*eps + (alpha_L + xi)*DT)
|
|
192
|
+
nu_c = C / wl
|
|
193
|
+
p_e = cfg["strain"]["photoelastic_coefficient"]
|
|
194
|
+
alpha_L = cfg["temperature"]["thermal_expansion"]
|
|
195
|
+
xi = cfg["temperature"]["thermo_optic"]
|
|
196
|
+
d_nu_d_eps = -(1.0 - p_e) * nu_c # Hz per unit strain
|
|
197
|
+
d_nu_d_T = -(alpha_L + xi) * nu_c # Hz per K
|
|
198
|
+
|
|
199
|
+
# --- max unambiguous strain ----------------------------------------
|
|
200
|
+
# Absolute ceiling from the spectral shift method: the cross-correlation
|
|
201
|
+
# window can track a shift up to +/- delta_nu/2. Processing with a finite
|
|
202
|
+
# gauge length makes this tighter in practice.
|
|
203
|
+
derived = compute_derived(cfg)
|
|
204
|
+
delta_nu = derived["delta_nu"]
|
|
205
|
+
eps_max = (delta_nu / 2.0) / abs(d_nu_d_eps)
|
|
206
|
+
|
|
207
|
+
out = {
|
|
208
|
+
"P_laser": P_laser,
|
|
209
|
+
"P_ref_arm": P_ref_arm,
|
|
210
|
+
"P_to_fiber": P_to_fiber,
|
|
211
|
+
"P_back_near": P_back_near,
|
|
212
|
+
"P_back_far": P_back_far,
|
|
213
|
+
"I_dc_ref": I_dc,
|
|
214
|
+
"I_dc_pd": I_dc_pd,
|
|
215
|
+
"sigma_shot": sigma_shot,
|
|
216
|
+
"sigma_thermal": sigma_thermal,
|
|
217
|
+
"sigma_dark": sigma_dark,
|
|
218
|
+
"sigma_rin": sigma_rin,
|
|
219
|
+
"sigma_quant": sigma_quant,
|
|
220
|
+
"sigma_adc_extra": sigma_adc_extra,
|
|
221
|
+
"sigma_receiver": sigma_receiver,
|
|
222
|
+
"sigma_analog": sigma_analog,
|
|
223
|
+
"sigma_total": sigma_total,
|
|
224
|
+
"nep_total": nep_total,
|
|
225
|
+
"dynamic_range_dB": dynamic_range_dB,
|
|
226
|
+
"bandwidth": B,
|
|
227
|
+
"filter_cutoff": min(B, 0.99 * fs / 2.0),
|
|
228
|
+
"noise_bandwidth": noise_bandwidth,
|
|
229
|
+
"sigma_phi_far": sigma_phi,
|
|
230
|
+
"tau_far": tau_far,
|
|
231
|
+
"d_nu_d_eps": d_nu_d_eps,
|
|
232
|
+
"d_nu_d_T": d_nu_d_T,
|
|
233
|
+
"eps_max": eps_max,
|
|
234
|
+
}
|
|
235
|
+
out.update(derived)
|
|
236
|
+
return out
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _dBm(p):
|
|
240
|
+
return 10.0 * math.log10(p * 1000.0) if p > 0 else float("-inf")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def print_budget(cfg: dict[str, Any], *, beat_rms_current: float | None = None) -> None:
|
|
244
|
+
"""Pretty-print the budget for a config dict."""
|
|
245
|
+
b = compute_budget(cfg, beat_rms_current=beat_rms_current)
|
|
246
|
+
print("PyOFDR power & noise budget:")
|
|
247
|
+
print(f" Spatial resolution: {b['dz']*1e3:.3f} mm")
|
|
248
|
+
print(f" Max beat freq: {b['f_beat_max']*1e-6:.1f} MHz "
|
|
249
|
+
f"(Nyquist {b['f_nyquist']*1e-6:.0f} MHz)")
|
|
250
|
+
print(f" Laser power: {_dBm(b['P_laser']):+.1f} dBm")
|
|
251
|
+
print(f" Reference arm: {_dBm(b['P_ref_arm']):+.1f} dBm")
|
|
252
|
+
print(f" Into fiber: {_dBm(b['P_to_fiber']):+.1f} dBm")
|
|
253
|
+
print(f" Backscatter /m near: {_dBm(b['P_back_near']):+.1f} dBm")
|
|
254
|
+
print(f" Backscatter /m far: {_dBm(b['P_back_far']):+.1f} dBm")
|
|
255
|
+
print(f" Ref current per PD: {b['I_dc_pd']*1e3:.3f} mA")
|
|
256
|
+
print(f" Filter cutoff: {b['filter_cutoff']*1e-6:.3f} MHz")
|
|
257
|
+
print(f" Receiver noise bandwidth: {b['noise_bandwidth']*1e-6:.3f} MHz")
|
|
258
|
+
print(" Noise estimates (input-referred current):")
|
|
259
|
+
print(f" shot: {b['sigma_shot']*1e9:.3f} nA")
|
|
260
|
+
print(f" thermal: {b['sigma_thermal']*1e9:.3f} nA")
|
|
261
|
+
print(f" dark: {b['sigma_dark']*1e9:.3f} nA")
|
|
262
|
+
if math.isnan(b["sigma_rin"]):
|
|
263
|
+
print(" RIN: unavailable; supply beat_rms_current (A)")
|
|
264
|
+
else:
|
|
265
|
+
print(f" RIN (beat): {b['sigma_rin']*1e9:.3f} nA")
|
|
266
|
+
print(f" receiver additive: {b['sigma_receiver']*1e9:.3f} nA")
|
|
267
|
+
print(f" quantization: {b['sigma_quant']*1e9:.3f} nA")
|
|
268
|
+
print(f" ADC ENOB extra: {b['sigma_adc_extra']*1e9:.3f} nA")
|
|
269
|
+
if math.isnan(b["sigma_total"]):
|
|
270
|
+
print(" total / NEP / Dynamic range: unavailable without beat RMS")
|
|
271
|
+
else:
|
|
272
|
+
print(f" total (sampled): {b['sigma_total']*1e9:.3f} nA")
|
|
273
|
+
print(f" NEP (sampled total): {b['nep_total']*1e9:.2f} nW")
|
|
274
|
+
print(f" Dynamic range (proxy): {b['dynamic_range_dB']:.1f} dB")
|
|
275
|
+
print(f" Phase noise (far, theoretical): {b['sigma_phi_far']*1e3:.2f} mrad RMS")
|
|
276
|
+
print(f" Strain sensitivity: {b['d_nu_d_eps']*1e-12:.2f} MHz/ustrain")
|
|
277
|
+
print(f" Temp sensitivity: {b['d_nu_d_T']*1e-9:.3f} GHz/K")
|
|
278
|
+
print(f" Max |strain|: {b['eps_max']*1e6:.0f} ustrain")
|