pyofdr 1.0.0__tar.gz
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-1.0.0/LICENSE +21 -0
- pyofdr-1.0.0/LICENSES/colorednoise-MIT.txt +21 -0
- pyofdr-1.0.0/PKG-INFO +153 -0
- pyofdr-1.0.0/README.md +122 -0
- pyofdr-1.0.0/pyproject.toml +47 -0
- pyofdr-1.0.0/setup.cfg +4 -0
- pyofdr-1.0.0/src/pyofdr/__init__.py +3 -0
- pyofdr-1.0.0/src/pyofdr/analysis/__init__.py +1 -0
- pyofdr-1.0.0/src/pyofdr/analysis/budget.py +278 -0
- pyofdr-1.0.0/src/pyofdr/analysis/demodulation.py +465 -0
- pyofdr-1.0.0/src/pyofdr/analysis/spatial_metrics.py +152 -0
- pyofdr-1.0.0/src/pyofdr/analysis/spectral.py +93 -0
- pyofdr-1.0.0/src/pyofdr/analysis/strain_quality.py +113 -0
- pyofdr-1.0.0/src/pyofdr/backends/__init__.py +88 -0
- pyofdr-1.0.0/src/pyofdr/cli.py +101 -0
- pyofdr-1.0.0/src/pyofdr/core/__init__.py +0 -0
- pyofdr-1.0.0/src/pyofdr/core/acquisition.py +47 -0
- pyofdr-1.0.0/src/pyofdr/core/campaign.py +95 -0
- pyofdr-1.0.0/src/pyofdr/core/config.py +102 -0
- pyofdr-1.0.0/src/pyofdr/core/config_models.py +547 -0
- pyofdr-1.0.0/src/pyofdr/core/pipeline.py +25 -0
- pyofdr-1.0.0/src/pyofdr/detection/__init__.py +0 -0
- pyofdr-1.0.0/src/pyofdr/detection/detector.py +184 -0
- pyofdr-1.0.0/src/pyofdr/detection/filter.py +42 -0
- pyofdr-1.0.0/src/pyofdr/digitizer/__init__.py +0 -0
- pyofdr-1.0.0/src/pyofdr/digitizer/adc.py +176 -0
- pyofdr-1.0.0/src/pyofdr/fiber/__init__.py +0 -0
- pyofdr-1.0.0/src/pyofdr/fiber/attenuation.py +50 -0
- pyofdr-1.0.0/src/pyofdr/fiber/bends.py +157 -0
- pyofdr-1.0.0/src/pyofdr/fiber/crosstalk.py +75 -0
- pyofdr-1.0.0/src/pyofdr/fiber/fbg.py +108 -0
- pyofdr-1.0.0/src/pyofdr/fiber/multiple_scattering.py +84 -0
- pyofdr-1.0.0/src/pyofdr/fiber/phase.py +5 -0
- pyofdr-1.0.0/src/pyofdr/fiber/profile.py +234 -0
- pyofdr-1.0.0/src/pyofdr/fiber/reflectors.py +79 -0
- pyofdr-1.0.0/src/pyofdr/fiber/strain.py +125 -0
- pyofdr-1.0.0/src/pyofdr/fiber/temperature.py +111 -0
- pyofdr-1.0.0/src/pyofdr/optics/__init__.py +0 -0
- pyofdr-1.0.0/src/pyofdr/optics/aux_mzi.py +97 -0
- pyofdr-1.0.0/src/pyofdr/optics/components.py +42 -0
- pyofdr-1.0.0/src/pyofdr/optics/mach_zehnder.py +283 -0
- pyofdr-1.0.0/src/pyofdr/output/__init__.py +0 -0
- pyofdr-1.0.0/src/pyofdr/output/hdf5_writer.py +121 -0
- pyofdr-1.0.0/src/pyofdr/source/__init__.py +0 -0
- pyofdr-1.0.0/src/pyofdr/source/phase_noise.py +85 -0
- pyofdr-1.0.0/src/pyofdr/source/swept_laser.py +149 -0
- pyofdr-1.0.0/src/pyofdr/strain_transfer/__init__.py +103 -0
- pyofdr-1.0.0/src/pyofdr/strain_transfer/motions.py +121 -0
- pyofdr-1.0.0/src/pyofdr/utils/__init__.py +0 -0
- pyofdr-1.0.0/src/pyofdr/utils/colorednoise.py +163 -0
- pyofdr-1.0.0/src/pyofdr/utils/constants.py +15 -0
- pyofdr-1.0.0/src/pyofdr/utils/sampling.py +13 -0
- pyofdr-1.0.0/src/pyofdr/utils/seeding.py +40 -0
- pyofdr-1.0.0/src/pyofdr/utils/units.py +13 -0
- pyofdr-1.0.0/src/pyofdr.egg-info/PKG-INFO +153 -0
- pyofdr-1.0.0/src/pyofdr.egg-info/SOURCES.txt +95 -0
- pyofdr-1.0.0/src/pyofdr.egg-info/dependency_links.txt +1 -0
- pyofdr-1.0.0/src/pyofdr.egg-info/entry_points.txt +2 -0
- pyofdr-1.0.0/src/pyofdr.egg-info/requires.txt +14 -0
- pyofdr-1.0.0/src/pyofdr.egg-info/top_level.txt +1 -0
- pyofdr-1.0.0/tests/test_adc.py +245 -0
- pyofdr-1.0.0/tests/test_adc_memory.py +156 -0
- pyofdr-1.0.0/tests/test_adc_thresholds.py +109 -0
- pyofdr-1.0.0/tests/test_aux_mzi.py +280 -0
- pyofdr-1.0.0/tests/test_aux_nyquist.py +120 -0
- pyofdr-1.0.0/tests/test_bend_grid.py +111 -0
- pyofdr-1.0.0/tests/test_bends.py +191 -0
- pyofdr-1.0.0/tests/test_budget.py +210 -0
- pyofdr-1.0.0/tests/test_campaign.py +447 -0
- pyofdr-1.0.0/tests/test_cli.py +187 -0
- pyofdr-1.0.0/tests/test_config.py +140 -0
- pyofdr-1.0.0/tests/test_demodulation.py +662 -0
- pyofdr-1.0.0/tests/test_detection.py +366 -0
- pyofdr-1.0.0/tests/test_detector_saturation.py +136 -0
- pyofdr-1.0.0/tests/test_dispersion.py +117 -0
- pyofdr-1.0.0/tests/test_dynamic_strain.py +460 -0
- pyofdr-1.0.0/tests/test_fbg_nonlinearity.py +145 -0
- pyofdr-1.0.0/tests/test_fiber.py +886 -0
- pyofdr-1.0.0/tests/test_fractional_lag_validation.py +171 -0
- pyofdr-1.0.0/tests/test_integration.py +33 -0
- pyofdr-1.0.0/tests/test_main_nyquist.py +128 -0
- pyofdr-1.0.0/tests/test_marcuse_bends.py +177 -0
- pyofdr-1.0.0/tests/test_optics.py +406 -0
- pyofdr-1.0.0/tests/test_phase_noise.py +206 -0
- pyofdr-1.0.0/tests/test_reflector_paths.py +271 -0
- pyofdr-1.0.0/tests/test_sampling.py +225 -0
- pyofdr-1.0.0/tests/test_seeding.py +94 -0
- pyofdr-1.0.0/tests/test_source.py +165 -0
- pyofdr-1.0.0/tests/test_spatial_metrics.py +152 -0
- pyofdr-1.0.0/tests/test_spectral.py +83 -0
- pyofdr-1.0.0/tests/test_strain.py +266 -0
- pyofdr-1.0.0/tests/test_strain_quality.py +126 -0
- pyofdr-1.0.0/tests/test_sweep_nonlinearity.py +171 -0
- pyofdr-1.0.0/tests/test_temperature.py +222 -0
- pyofdr-1.0.0/tests/test_temperature_output.py +82 -0
- pyofdr-1.0.0/tests/test_time_warp_accuracy.py +91 -0
- pyofdr-1.0.0/tests/test_validation.py +320 -0
pyofdr-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 A. Meoli
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2014-2017 Felix Patzelt
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
pyofdr-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyofdr
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: OFDR simulator for distributed fiber sensing
|
|
5
|
+
Author-email: "A. Meoli" <alessandro.meoli@unicampania.it>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/ameoli/PyOFDR
|
|
8
|
+
Project-URL: Issues, https://github.com/ameoli/PyOFDR/issues
|
|
9
|
+
Keywords: ofdr,fiber sensing,rayleigh,interferometry,simulation
|
|
10
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering :: Physics
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
License-File: LICENSES/colorednoise-MIT.txt
|
|
18
|
+
Requires-Dist: numpy>=1.24
|
|
19
|
+
Requires-Dist: scipy>=1.10
|
|
20
|
+
Requires-Dist: pyyaml>=6.0
|
|
21
|
+
Requires-Dist: pydantic>=2.0
|
|
22
|
+
Requires-Dist: pint>=0.22
|
|
23
|
+
Requires-Dist: h5py>=3.8
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
26
|
+
Provides-Extra: tutorials
|
|
27
|
+
Requires-Dist: matplotlib; extra == "tutorials"
|
|
28
|
+
Requires-Dist: jupyterlab; extra == "tutorials"
|
|
29
|
+
Requires-Dist: ipykernel; extra == "tutorials"
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# PyOFDR
|
|
33
|
+
[](https://github.com/ameoli/PyOFDR/actions/workflows/tests.yml)
|
|
34
|
+

|
|
35
|
+

|
|
36
|
+
|
|
37
|
+
PyOFDR simulates optical frequency domain reflectometry (OFDR) in Python. Give it a fiber, a laser sweep and a receiver. It returns the digitized beat signal for each fiber core. You can use these traces to check a strain recovery algorithm against a known input, study receiver noise or try different sweep and sampling settings.
|
|
38
|
+
|
|
39
|
+

|
|
40
|
+
|
|
41
|
+
*1000 microstrain applied to a 1 m segment of a 10 m fiber, recovered from a single PyOFDR run by sub-spectrum cross-correlation against an unstrained baseline.*
|
|
42
|
+
|
|
43
|
+
## Status
|
|
44
|
+
|
|
45
|
+
PyOFDR runs on the NumPy backend and requires Python 3.10 or later. CI runs on Windows and Ubuntu with Python 3.10, 3.11 and 3.12. The [pipeline validation section](https://github.com/ameoli/PyOFDR/blob/main/docs/theory.md#pipeline-level-validation) gives the tested cases and their numerical tolerances.
|
|
46
|
+
|
|
47
|
+
## What's simulated
|
|
48
|
+
| Stage | Modelled |
|
|
49
|
+
|---|---|
|
|
50
|
+
| **Source** | linear sweep + nonlinearity + ripple, phase noise (Wiener + flicker + RW), RIN, coherence roll-off |
|
|
51
|
+
| **Interferometer** | main Mach-Zehnder, auxiliary MZI for k-clock resampling, optical circulator, weak FBG arrays |
|
|
52
|
+
| **Fiber** | Rayleigh backscatter, attenuation, macrobend loss, discrete reflectors, chromatic dispersion (GVD), stochastic n(z), multiple-scattering ghosts, MCF core-to-core crosstalk |
|
|
53
|
+
| **Perturbations** | static + dynamic strain (harmonic, impulsive, thermal relaxation, random vibration), Cox shear-lag coating coupling, distributed temperature with strain-T cross-sensitivity |
|
|
54
|
+
| **Detection** | single-ended or balanced photodetection, shot, thermal and dark-current noise, photodiode saturation and nonlinearity, Butterworth low-pass filter |
|
|
55
|
+
| **ADC** | clip, quantize, jitter, DNL/INL, ENOB |
|
|
56
|
+
| **Output** | HDF5 streaming, multi-sweep campaigns |
|
|
57
|
+
|
|
58
|
+
For equations and model limits, see the [theory guide](https://github.com/ameoli/PyOFDR/blob/main/docs/theory.md). The [configuration reference](https://github.com/ameoli/PyOFDR/blob/main/docs/config_reference.md) describes the YAML fields and units. The [architecture guide](https://github.com/ameoli/PyOFDR/blob/main/docs/architecture.md) follows a sweep through the simulation and describes the output arrays.
|
|
59
|
+
|
|
60
|
+
## Install
|
|
61
|
+
|
|
62
|
+
To install the published package from PyPI, run `python -m pip install pyofdr` in a virtual environment.
|
|
63
|
+
|
|
64
|
+
For the basic YAML configuration, notebooks and tests, clone the repository and create a virtual environment.
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
git clone https://github.com/ameoli/PyOFDR.git
|
|
68
|
+
cd PyOFDR
|
|
69
|
+
python -m venv .venv
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Activate it with `.\.venv\Scripts\Activate.ps1` in PowerShell or `source .venv/bin/activate` on Linux and macOS. Then install the package from the repository root.
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
python -m pip install -e .
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Quick start
|
|
79
|
+
|
|
80
|
+
From the repository root, run the [basic configuration](https://github.com/ameoli/PyOFDR/blob/main/configs/ofdr_basic.yaml). It simulates a 10 m fiber and a 40 nm sweep in 10 ms at 200 MS/s. `info` shows the spatial spacing and Nyquist limit. `validate` checks the configuration, and `run` writes the simulated sweep to HDF5.
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
pyofdr info configs/ofdr_basic.yaml
|
|
84
|
+
pyofdr validate configs/ofdr_basic.yaml
|
|
85
|
+
pyofdr run configs/ofdr_basic.yaml -o out.h5
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
In `out.h5`, `sweeps/0000/digital_main` holds the ADC counts with shape `(1, 2000000)` for this configuration. To work in Python, run the same sweep in memory and compute its reflectogram. The helper uses the FFT spacing stored in the acquisition.
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from pyofdr.analysis.demodulation import reflectogram_from_acq
|
|
92
|
+
from pyofdr.core.campaign import run_campaign
|
|
93
|
+
from pyofdr.core.config import load_config
|
|
94
|
+
|
|
95
|
+
cfg = load_config("configs/ofdr_basic.yaml")
|
|
96
|
+
acq = run_campaign(cfg)[0]
|
|
97
|
+
H, z = reflectogram_from_acq(acq, core=0)
|
|
98
|
+
print(acq.digital_main.shape) # (1, 2000000)
|
|
99
|
+
print(z[1] - z[0]) # FFT bin spacing in metres
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Examples
|
|
103
|
+
From the repository root, install the tutorials and start JupyterLab in the same environment:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
python -m pip install -e ".[tutorials]"
|
|
107
|
+
python -m jupyterlab examples/
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Open a notebook and run all cells from the beginning. The examples load the basic YAML configuration relative to the notebook directory.
|
|
111
|
+
|
|
112
|
+
- [00: Fiber signature](https://github.com/ameoli/PyOFDR/blob/main/examples/00_fiber_signature.ipynb) compares Rayleigh statistics with theory.
|
|
113
|
+
- [01: Reflectogram basics](https://github.com/ameoli/PyOFDR/blob/main/examples/01_reflectogram_basics.ipynb) maps ADC samples to distance and compares repeated sweeps.
|
|
114
|
+
- [02: Reflector loss and linewidth](https://github.com/ameoli/PyOFDR/blob/main/examples/02_reflector_loss_and_linewidth.ipynb) measures round-trip attenuation, coherence loss and peak width.
|
|
115
|
+
- [03: Strain recovery](https://github.com/ameoli/PyOFDR/blob/main/examples/03_strain_recovery.ipynb) recovers an imposed strain from two Rayleigh traces.
|
|
116
|
+
- [04: Noise budget](https://github.com/ameoli/PyOFDR/blob/main/examples/04_noise_budget.ipynb) compares predicted and measured shot, thermal and quantization noise.
|
|
117
|
+
|
|
118
|
+
## Tests
|
|
119
|
+
|
|
120
|
+
Install the test dependencies in the same environment and run the suite from the repository root.
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
python -m pip install -e ".[dev]"
|
|
124
|
+
python -m pytest tests/ -q
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Citing
|
|
128
|
+
If you use PyOFDR in published work, please cite it.
|
|
129
|
+
|
|
130
|
+
```bibtex
|
|
131
|
+
@software{pyofdr,
|
|
132
|
+
author = {Meoli, Alessandro},
|
|
133
|
+
title = {PyOFDR: an end-to-end Python simulator for Optical Frequency Domain Reflectometry},
|
|
134
|
+
year = {2026},
|
|
135
|
+
url = {https://github.com/ameoli/PyOFDR},
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## References
|
|
140
|
+
- Froggatt and Moore, *High-spatial-resolution distributed strain measurement in optical fiber with Rayleigh scatter*, Appl Opt 1998.
|
|
141
|
+
- Soller et al., *High resolution OFDR for characterization of components and assemblies*, Opt Express 2005.
|
|
142
|
+
- Hartog, *An Introduction to Distributed Optical Fibre Sensors*, CRC 2017.
|
|
143
|
+
|
|
144
|
+
## License
|
|
145
|
+
PyOFDR is distributed under the [MIT license](https://github.com/ameoli/PyOFDR/blob/main/LICENSE).
|
|
146
|
+
|
|
147
|
+
### Third-party
|
|
148
|
+
The laser noise generator uses [colorednoise](https://github.com/felixpatzelt/colorednoise) by Felix Patzelt. Its [MIT license](https://github.com/ameoli/PyOFDR/blob/main/LICENSES/colorednoise-MIT.txt) is included in the source and wheel distributions.
|
|
149
|
+
|
|
150
|
+
## Issues and contributions
|
|
151
|
+
Report bugs and feature requests in [GitHub Issues](https://github.com/ameoli/PyOFDR/issues). For simulation bugs, include the YAML configuration and the command that reproduces the problem. Pull requests are welcome.
|
|
152
|
+
|
|
153
|
+
Maintainers can follow the [release instructions](https://github.com/ameoli/PyOFDR/blob/main/docs/releasing.md) to publish a tagged version on PyPI.
|
pyofdr-1.0.0/README.md
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# PyOFDR
|
|
2
|
+
[](https://github.com/ameoli/PyOFDR/actions/workflows/tests.yml)
|
|
3
|
+

|
|
4
|
+

|
|
5
|
+
|
|
6
|
+
PyOFDR simulates optical frequency domain reflectometry (OFDR) in Python. Give it a fiber, a laser sweep and a receiver. It returns the digitized beat signal for each fiber core. You can use these traces to check a strain recovery algorithm against a known input, study receiver noise or try different sweep and sampling settings.
|
|
7
|
+
|
|
8
|
+

|
|
9
|
+
|
|
10
|
+
*1000 microstrain applied to a 1 m segment of a 10 m fiber, recovered from a single PyOFDR run by sub-spectrum cross-correlation against an unstrained baseline.*
|
|
11
|
+
|
|
12
|
+
## Status
|
|
13
|
+
|
|
14
|
+
PyOFDR runs on the NumPy backend and requires Python 3.10 or later. CI runs on Windows and Ubuntu with Python 3.10, 3.11 and 3.12. The [pipeline validation section](https://github.com/ameoli/PyOFDR/blob/main/docs/theory.md#pipeline-level-validation) gives the tested cases and their numerical tolerances.
|
|
15
|
+
|
|
16
|
+
## What's simulated
|
|
17
|
+
| Stage | Modelled |
|
|
18
|
+
|---|---|
|
|
19
|
+
| **Source** | linear sweep + nonlinearity + ripple, phase noise (Wiener + flicker + RW), RIN, coherence roll-off |
|
|
20
|
+
| **Interferometer** | main Mach-Zehnder, auxiliary MZI for k-clock resampling, optical circulator, weak FBG arrays |
|
|
21
|
+
| **Fiber** | Rayleigh backscatter, attenuation, macrobend loss, discrete reflectors, chromatic dispersion (GVD), stochastic n(z), multiple-scattering ghosts, MCF core-to-core crosstalk |
|
|
22
|
+
| **Perturbations** | static + dynamic strain (harmonic, impulsive, thermal relaxation, random vibration), Cox shear-lag coating coupling, distributed temperature with strain-T cross-sensitivity |
|
|
23
|
+
| **Detection** | single-ended or balanced photodetection, shot, thermal and dark-current noise, photodiode saturation and nonlinearity, Butterworth low-pass filter |
|
|
24
|
+
| **ADC** | clip, quantize, jitter, DNL/INL, ENOB |
|
|
25
|
+
| **Output** | HDF5 streaming, multi-sweep campaigns |
|
|
26
|
+
|
|
27
|
+
For equations and model limits, see the [theory guide](https://github.com/ameoli/PyOFDR/blob/main/docs/theory.md). The [configuration reference](https://github.com/ameoli/PyOFDR/blob/main/docs/config_reference.md) describes the YAML fields and units. The [architecture guide](https://github.com/ameoli/PyOFDR/blob/main/docs/architecture.md) follows a sweep through the simulation and describes the output arrays.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
To install the published package from PyPI, run `python -m pip install pyofdr` in a virtual environment.
|
|
32
|
+
|
|
33
|
+
For the basic YAML configuration, notebooks and tests, clone the repository and create a virtual environment.
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
git clone https://github.com/ameoli/PyOFDR.git
|
|
37
|
+
cd PyOFDR
|
|
38
|
+
python -m venv .venv
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Activate it with `.\.venv\Scripts\Activate.ps1` in PowerShell or `source .venv/bin/activate` on Linux and macOS. Then install the package from the repository root.
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
python -m pip install -e .
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Quick start
|
|
48
|
+
|
|
49
|
+
From the repository root, run the [basic configuration](https://github.com/ameoli/PyOFDR/blob/main/configs/ofdr_basic.yaml). It simulates a 10 m fiber and a 40 nm sweep in 10 ms at 200 MS/s. `info` shows the spatial spacing and Nyquist limit. `validate` checks the configuration, and `run` writes the simulated sweep to HDF5.
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pyofdr info configs/ofdr_basic.yaml
|
|
53
|
+
pyofdr validate configs/ofdr_basic.yaml
|
|
54
|
+
pyofdr run configs/ofdr_basic.yaml -o out.h5
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
In `out.h5`, `sweeps/0000/digital_main` holds the ADC counts with shape `(1, 2000000)` for this configuration. To work in Python, run the same sweep in memory and compute its reflectogram. The helper uses the FFT spacing stored in the acquisition.
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from pyofdr.analysis.demodulation import reflectogram_from_acq
|
|
61
|
+
from pyofdr.core.campaign import run_campaign
|
|
62
|
+
from pyofdr.core.config import load_config
|
|
63
|
+
|
|
64
|
+
cfg = load_config("configs/ofdr_basic.yaml")
|
|
65
|
+
acq = run_campaign(cfg)[0]
|
|
66
|
+
H, z = reflectogram_from_acq(acq, core=0)
|
|
67
|
+
print(acq.digital_main.shape) # (1, 2000000)
|
|
68
|
+
print(z[1] - z[0]) # FFT bin spacing in metres
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Examples
|
|
72
|
+
From the repository root, install the tutorials and start JupyterLab in the same environment:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
python -m pip install -e ".[tutorials]"
|
|
76
|
+
python -m jupyterlab examples/
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Open a notebook and run all cells from the beginning. The examples load the basic YAML configuration relative to the notebook directory.
|
|
80
|
+
|
|
81
|
+
- [00: Fiber signature](https://github.com/ameoli/PyOFDR/blob/main/examples/00_fiber_signature.ipynb) compares Rayleigh statistics with theory.
|
|
82
|
+
- [01: Reflectogram basics](https://github.com/ameoli/PyOFDR/blob/main/examples/01_reflectogram_basics.ipynb) maps ADC samples to distance and compares repeated sweeps.
|
|
83
|
+
- [02: Reflector loss and linewidth](https://github.com/ameoli/PyOFDR/blob/main/examples/02_reflector_loss_and_linewidth.ipynb) measures round-trip attenuation, coherence loss and peak width.
|
|
84
|
+
- [03: Strain recovery](https://github.com/ameoli/PyOFDR/blob/main/examples/03_strain_recovery.ipynb) recovers an imposed strain from two Rayleigh traces.
|
|
85
|
+
- [04: Noise budget](https://github.com/ameoli/PyOFDR/blob/main/examples/04_noise_budget.ipynb) compares predicted and measured shot, thermal and quantization noise.
|
|
86
|
+
|
|
87
|
+
## Tests
|
|
88
|
+
|
|
89
|
+
Install the test dependencies in the same environment and run the suite from the repository root.
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
python -m pip install -e ".[dev]"
|
|
93
|
+
python -m pytest tests/ -q
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Citing
|
|
97
|
+
If you use PyOFDR in published work, please cite it.
|
|
98
|
+
|
|
99
|
+
```bibtex
|
|
100
|
+
@software{pyofdr,
|
|
101
|
+
author = {Meoli, Alessandro},
|
|
102
|
+
title = {PyOFDR: an end-to-end Python simulator for Optical Frequency Domain Reflectometry},
|
|
103
|
+
year = {2026},
|
|
104
|
+
url = {https://github.com/ameoli/PyOFDR},
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## References
|
|
109
|
+
- Froggatt and Moore, *High-spatial-resolution distributed strain measurement in optical fiber with Rayleigh scatter*, Appl Opt 1998.
|
|
110
|
+
- Soller et al., *High resolution OFDR for characterization of components and assemblies*, Opt Express 2005.
|
|
111
|
+
- Hartog, *An Introduction to Distributed Optical Fibre Sensors*, CRC 2017.
|
|
112
|
+
|
|
113
|
+
## License
|
|
114
|
+
PyOFDR is distributed under the [MIT license](https://github.com/ameoli/PyOFDR/blob/main/LICENSE).
|
|
115
|
+
|
|
116
|
+
### Third-party
|
|
117
|
+
The laser noise generator uses [colorednoise](https://github.com/felixpatzelt/colorednoise) by Felix Patzelt. Its [MIT license](https://github.com/ameoli/PyOFDR/blob/main/LICENSES/colorednoise-MIT.txt) is included in the source and wheel distributions.
|
|
118
|
+
|
|
119
|
+
## Issues and contributions
|
|
120
|
+
Report bugs and feature requests in [GitHub Issues](https://github.com/ameoli/PyOFDR/issues). For simulation bugs, include the YAML configuration and the command that reproduces the problem. Pull requests are welcome.
|
|
121
|
+
|
|
122
|
+
Maintainers can follow the [release instructions](https://github.com/ameoli/PyOFDR/blob/main/docs/releasing.md) to publish a tagged version on PyPI.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77.0.3", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pyofdr"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "OFDR simulator for distributed fiber sensing"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE", "LICENSES/colorednoise-MIT.txt"]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
authors = [
|
|
14
|
+
{name = "A. Meoli", email = "alessandro.meoli@unicampania.it"},
|
|
15
|
+
]
|
|
16
|
+
keywords = ["ofdr", "fiber sensing", "rayleigh", "interferometry", "simulation"]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 5 - Production/Stable",
|
|
19
|
+
"Intended Audience :: Science/Research",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Topic :: Scientific/Engineering :: Physics",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"numpy>=1.24",
|
|
25
|
+
"scipy>=1.10",
|
|
26
|
+
"pyyaml>=6.0",
|
|
27
|
+
"pydantic>=2.0",
|
|
28
|
+
"pint>=0.22",
|
|
29
|
+
"h5py>=3.8",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://github.com/ameoli/PyOFDR"
|
|
34
|
+
Issues = "https://github.com/ameoli/PyOFDR/issues"
|
|
35
|
+
|
|
36
|
+
[project.scripts]
|
|
37
|
+
pyofdr = "pyofdr.cli:main"
|
|
38
|
+
|
|
39
|
+
[project.optional-dependencies]
|
|
40
|
+
dev = ["pytest>=7.0"]
|
|
41
|
+
tutorials = ["matplotlib", "jupyterlab", "ipykernel"]
|
|
42
|
+
|
|
43
|
+
[tool.setuptools.packages.find]
|
|
44
|
+
where = ["src"]
|
|
45
|
+
|
|
46
|
+
[tool.pytest.ini_options]
|
|
47
|
+
testpaths = ["tests"]
|
pyofdr-1.0.0/setup.cfg
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")
|