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.
Files changed (97) hide show
  1. pyofdr-1.0.0/LICENSE +21 -0
  2. pyofdr-1.0.0/LICENSES/colorednoise-MIT.txt +21 -0
  3. pyofdr-1.0.0/PKG-INFO +153 -0
  4. pyofdr-1.0.0/README.md +122 -0
  5. pyofdr-1.0.0/pyproject.toml +47 -0
  6. pyofdr-1.0.0/setup.cfg +4 -0
  7. pyofdr-1.0.0/src/pyofdr/__init__.py +3 -0
  8. pyofdr-1.0.0/src/pyofdr/analysis/__init__.py +1 -0
  9. pyofdr-1.0.0/src/pyofdr/analysis/budget.py +278 -0
  10. pyofdr-1.0.0/src/pyofdr/analysis/demodulation.py +465 -0
  11. pyofdr-1.0.0/src/pyofdr/analysis/spatial_metrics.py +152 -0
  12. pyofdr-1.0.0/src/pyofdr/analysis/spectral.py +93 -0
  13. pyofdr-1.0.0/src/pyofdr/analysis/strain_quality.py +113 -0
  14. pyofdr-1.0.0/src/pyofdr/backends/__init__.py +88 -0
  15. pyofdr-1.0.0/src/pyofdr/cli.py +101 -0
  16. pyofdr-1.0.0/src/pyofdr/core/__init__.py +0 -0
  17. pyofdr-1.0.0/src/pyofdr/core/acquisition.py +47 -0
  18. pyofdr-1.0.0/src/pyofdr/core/campaign.py +95 -0
  19. pyofdr-1.0.0/src/pyofdr/core/config.py +102 -0
  20. pyofdr-1.0.0/src/pyofdr/core/config_models.py +547 -0
  21. pyofdr-1.0.0/src/pyofdr/core/pipeline.py +25 -0
  22. pyofdr-1.0.0/src/pyofdr/detection/__init__.py +0 -0
  23. pyofdr-1.0.0/src/pyofdr/detection/detector.py +184 -0
  24. pyofdr-1.0.0/src/pyofdr/detection/filter.py +42 -0
  25. pyofdr-1.0.0/src/pyofdr/digitizer/__init__.py +0 -0
  26. pyofdr-1.0.0/src/pyofdr/digitizer/adc.py +176 -0
  27. pyofdr-1.0.0/src/pyofdr/fiber/__init__.py +0 -0
  28. pyofdr-1.0.0/src/pyofdr/fiber/attenuation.py +50 -0
  29. pyofdr-1.0.0/src/pyofdr/fiber/bends.py +157 -0
  30. pyofdr-1.0.0/src/pyofdr/fiber/crosstalk.py +75 -0
  31. pyofdr-1.0.0/src/pyofdr/fiber/fbg.py +108 -0
  32. pyofdr-1.0.0/src/pyofdr/fiber/multiple_scattering.py +84 -0
  33. pyofdr-1.0.0/src/pyofdr/fiber/phase.py +5 -0
  34. pyofdr-1.0.0/src/pyofdr/fiber/profile.py +234 -0
  35. pyofdr-1.0.0/src/pyofdr/fiber/reflectors.py +79 -0
  36. pyofdr-1.0.0/src/pyofdr/fiber/strain.py +125 -0
  37. pyofdr-1.0.0/src/pyofdr/fiber/temperature.py +111 -0
  38. pyofdr-1.0.0/src/pyofdr/optics/__init__.py +0 -0
  39. pyofdr-1.0.0/src/pyofdr/optics/aux_mzi.py +97 -0
  40. pyofdr-1.0.0/src/pyofdr/optics/components.py +42 -0
  41. pyofdr-1.0.0/src/pyofdr/optics/mach_zehnder.py +283 -0
  42. pyofdr-1.0.0/src/pyofdr/output/__init__.py +0 -0
  43. pyofdr-1.0.0/src/pyofdr/output/hdf5_writer.py +121 -0
  44. pyofdr-1.0.0/src/pyofdr/source/__init__.py +0 -0
  45. pyofdr-1.0.0/src/pyofdr/source/phase_noise.py +85 -0
  46. pyofdr-1.0.0/src/pyofdr/source/swept_laser.py +149 -0
  47. pyofdr-1.0.0/src/pyofdr/strain_transfer/__init__.py +103 -0
  48. pyofdr-1.0.0/src/pyofdr/strain_transfer/motions.py +121 -0
  49. pyofdr-1.0.0/src/pyofdr/utils/__init__.py +0 -0
  50. pyofdr-1.0.0/src/pyofdr/utils/colorednoise.py +163 -0
  51. pyofdr-1.0.0/src/pyofdr/utils/constants.py +15 -0
  52. pyofdr-1.0.0/src/pyofdr/utils/sampling.py +13 -0
  53. pyofdr-1.0.0/src/pyofdr/utils/seeding.py +40 -0
  54. pyofdr-1.0.0/src/pyofdr/utils/units.py +13 -0
  55. pyofdr-1.0.0/src/pyofdr.egg-info/PKG-INFO +153 -0
  56. pyofdr-1.0.0/src/pyofdr.egg-info/SOURCES.txt +95 -0
  57. pyofdr-1.0.0/src/pyofdr.egg-info/dependency_links.txt +1 -0
  58. pyofdr-1.0.0/src/pyofdr.egg-info/entry_points.txt +2 -0
  59. pyofdr-1.0.0/src/pyofdr.egg-info/requires.txt +14 -0
  60. pyofdr-1.0.0/src/pyofdr.egg-info/top_level.txt +1 -0
  61. pyofdr-1.0.0/tests/test_adc.py +245 -0
  62. pyofdr-1.0.0/tests/test_adc_memory.py +156 -0
  63. pyofdr-1.0.0/tests/test_adc_thresholds.py +109 -0
  64. pyofdr-1.0.0/tests/test_aux_mzi.py +280 -0
  65. pyofdr-1.0.0/tests/test_aux_nyquist.py +120 -0
  66. pyofdr-1.0.0/tests/test_bend_grid.py +111 -0
  67. pyofdr-1.0.0/tests/test_bends.py +191 -0
  68. pyofdr-1.0.0/tests/test_budget.py +210 -0
  69. pyofdr-1.0.0/tests/test_campaign.py +447 -0
  70. pyofdr-1.0.0/tests/test_cli.py +187 -0
  71. pyofdr-1.0.0/tests/test_config.py +140 -0
  72. pyofdr-1.0.0/tests/test_demodulation.py +662 -0
  73. pyofdr-1.0.0/tests/test_detection.py +366 -0
  74. pyofdr-1.0.0/tests/test_detector_saturation.py +136 -0
  75. pyofdr-1.0.0/tests/test_dispersion.py +117 -0
  76. pyofdr-1.0.0/tests/test_dynamic_strain.py +460 -0
  77. pyofdr-1.0.0/tests/test_fbg_nonlinearity.py +145 -0
  78. pyofdr-1.0.0/tests/test_fiber.py +886 -0
  79. pyofdr-1.0.0/tests/test_fractional_lag_validation.py +171 -0
  80. pyofdr-1.0.0/tests/test_integration.py +33 -0
  81. pyofdr-1.0.0/tests/test_main_nyquist.py +128 -0
  82. pyofdr-1.0.0/tests/test_marcuse_bends.py +177 -0
  83. pyofdr-1.0.0/tests/test_optics.py +406 -0
  84. pyofdr-1.0.0/tests/test_phase_noise.py +206 -0
  85. pyofdr-1.0.0/tests/test_reflector_paths.py +271 -0
  86. pyofdr-1.0.0/tests/test_sampling.py +225 -0
  87. pyofdr-1.0.0/tests/test_seeding.py +94 -0
  88. pyofdr-1.0.0/tests/test_source.py +165 -0
  89. pyofdr-1.0.0/tests/test_spatial_metrics.py +152 -0
  90. pyofdr-1.0.0/tests/test_spectral.py +83 -0
  91. pyofdr-1.0.0/tests/test_strain.py +266 -0
  92. pyofdr-1.0.0/tests/test_strain_quality.py +126 -0
  93. pyofdr-1.0.0/tests/test_sweep_nonlinearity.py +171 -0
  94. pyofdr-1.0.0/tests/test_temperature.py +222 -0
  95. pyofdr-1.0.0/tests/test_temperature_output.py +82 -0
  96. pyofdr-1.0.0/tests/test_time_warp_accuracy.py +91 -0
  97. 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
+ [![Tests](https://github.com/ameoli/PyOFDR/actions/workflows/tests.yml/badge.svg)](https://github.com/ameoli/PyOFDR/actions/workflows/tests.yml)
34
+ ![license](https://img.shields.io/github/license/ameoli/PyOFDR)
35
+ ![python](https://img.shields.io/badge/python-3.10%2B-blue)
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
+ ![Distributed strain shift recovered from PyOFDR](https://raw.githubusercontent.com/ameoli/PyOFDR/main/docs/img/strain_shift.png)
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
+ [![Tests](https://github.com/ameoli/PyOFDR/actions/workflows/tests.yml/badge.svg)](https://github.com/ameoli/PyOFDR/actions/workflows/tests.yml)
3
+ ![license](https://img.shields.io/github/license/ameoli/PyOFDR)
4
+ ![python](https://img.shields.io/badge/python-3.10%2B-blue)
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
+ ![Distributed strain shift recovered from PyOFDR](https://raw.githubusercontent.com/ameoli/PyOFDR/main/docs/img/strain_shift.png)
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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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")