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.
Files changed (55) hide show
  1. pyofdr/__init__.py +3 -0
  2. pyofdr/analysis/__init__.py +1 -0
  3. pyofdr/analysis/budget.py +278 -0
  4. pyofdr/analysis/demodulation.py +465 -0
  5. pyofdr/analysis/spatial_metrics.py +152 -0
  6. pyofdr/analysis/spectral.py +93 -0
  7. pyofdr/analysis/strain_quality.py +113 -0
  8. pyofdr/backends/__init__.py +88 -0
  9. pyofdr/cli.py +101 -0
  10. pyofdr/core/__init__.py +0 -0
  11. pyofdr/core/acquisition.py +47 -0
  12. pyofdr/core/campaign.py +95 -0
  13. pyofdr/core/config.py +102 -0
  14. pyofdr/core/config_models.py +547 -0
  15. pyofdr/core/pipeline.py +25 -0
  16. pyofdr/detection/__init__.py +0 -0
  17. pyofdr/detection/detector.py +184 -0
  18. pyofdr/detection/filter.py +42 -0
  19. pyofdr/digitizer/__init__.py +0 -0
  20. pyofdr/digitizer/adc.py +176 -0
  21. pyofdr/fiber/__init__.py +0 -0
  22. pyofdr/fiber/attenuation.py +50 -0
  23. pyofdr/fiber/bends.py +157 -0
  24. pyofdr/fiber/crosstalk.py +75 -0
  25. pyofdr/fiber/fbg.py +108 -0
  26. pyofdr/fiber/multiple_scattering.py +84 -0
  27. pyofdr/fiber/phase.py +5 -0
  28. pyofdr/fiber/profile.py +234 -0
  29. pyofdr/fiber/reflectors.py +79 -0
  30. pyofdr/fiber/strain.py +125 -0
  31. pyofdr/fiber/temperature.py +111 -0
  32. pyofdr/optics/__init__.py +0 -0
  33. pyofdr/optics/aux_mzi.py +97 -0
  34. pyofdr/optics/components.py +42 -0
  35. pyofdr/optics/mach_zehnder.py +283 -0
  36. pyofdr/output/__init__.py +0 -0
  37. pyofdr/output/hdf5_writer.py +121 -0
  38. pyofdr/source/__init__.py +0 -0
  39. pyofdr/source/phase_noise.py +85 -0
  40. pyofdr/source/swept_laser.py +149 -0
  41. pyofdr/strain_transfer/__init__.py +103 -0
  42. pyofdr/strain_transfer/motions.py +121 -0
  43. pyofdr/utils/__init__.py +0 -0
  44. pyofdr/utils/colorednoise.py +163 -0
  45. pyofdr/utils/constants.py +15 -0
  46. pyofdr/utils/sampling.py +13 -0
  47. pyofdr/utils/seeding.py +40 -0
  48. pyofdr/utils/units.py +13 -0
  49. pyofdr-1.0.0.dist-info/METADATA +153 -0
  50. pyofdr-1.0.0.dist-info/RECORD +55 -0
  51. pyofdr-1.0.0.dist-info/WHEEL +5 -0
  52. pyofdr-1.0.0.dist-info/entry_points.txt +2 -0
  53. pyofdr-1.0.0.dist-info/licenses/LICENSE +21 -0
  54. pyofdr-1.0.0.dist-info/licenses/LICENSES/colorednoise-MIT.txt +21 -0
  55. pyofdr-1.0.0.dist-info/top_level.txt +1 -0
pyofdr/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """PyOFDR -- OFDR simulator for distributed fiber sensing."""
2
+
3
+ __version__ = "1.0.0"
@@ -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")