dftmodels 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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 Sven Bodenstedt
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,196 @@
1
+ Metadata-Version: 2.4
2
+ Name: dftmodels
3
+ Version: 1.0.0
4
+ Summary: Analytical DFT models with closed-form correction terms for bias-free spectral parameter estimation.
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.12
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: numpy>=2.0
10
+ Requires-Dist: scipy>=1.15
11
+ Requires-Dist: lmfit>=1.0
12
+ Dynamic: license-file
13
+
14
+ # dftmodels
15
+
16
+ Analytical DFT models for spectral parameter estimation.
17
+
18
+ ## Overview
19
+
20
+ Fitting peaks in DFT spectra with continuous Fourier transform (CFT) models (sinc functions, Lorentzians, and their window-convolved variants) introduces systematic errors in the estimated amplitude, frequency, phase, and linewidth. Three effects are responsible: the DFT sum gives full weight to the boundary samples where the trapezoidal-rule approximation gives half weight (boundary mismatch); sampling makes the spectrum periodic, introducing aliased copies at f ± k·f_s (aliasing); and the finite window modifies the spectral shape in a way that depends on both the window coefficients and the signal frequency (windowing). Each effect produces a deterministic, structured residual in the DFT.
21
+
22
+ `dftmodels` provides closed-form analytical models that include these effects as additive correction terms: WINDOW (the CFT of the windowed signal), BASELINE (the boundary weight mismatch), and SAMPLING (aliased spectral copies up to a configurable order). Combined with least-squares fitting, these models form estimators whose variance can approach the Cramér–Rao bound (CRB).
23
+
24
+ **When corrections matter.** Systematic bias is only the limiting factor when SNR is high and records are short. The SAMPLING correction becomes negligible above a crossover acquisition length that depends on window and noise level (see [Notebook 02](examples/02_dft_corrections.ipynb)). For long records or when noise dominates, a CFT-based sinc or Lorentzian model is adequate. The gains shown in the examples below are large by design — the signal parameters were chosen so that systematic bias is the limiting factor, not noise. The package is most relevant when that condition holds.
25
+
26
+ ## Installation
27
+
28
+ ```bash
29
+ pip install dftmodels
30
+ ```
31
+
32
+ Dependencies: Python ≥ 3.12, `numpy` ≥ 2.0, `scipy` ≥ 1.15, `lmfit` ≥ 1.0.
33
+
34
+ ## Examples
35
+
36
+ Six notebooks in [examples/](examples/) work through the main topics in sequence.
37
+
38
+ | Notebook | Topic |
39
+ |---|---|
40
+ | [01 — Normalizations](examples/01_dft_normalization.ipynb) | ASD, PSD, and CFT normalizations; Parseval's theorem |
41
+ | [02 — DFT corrections](examples/02_dft_corrections.ipynb) | Origin and convergence of the three correction terms |
42
+ | [03 — Precision fitting](examples/03_precision_fitting.ipynb) | Sinusoid parameter estimation; bias elimination; Cramér–Rao bound |
43
+ | [04 — Decaying signals](examples/04_decaying_signals.ipynb) | Lorentzian fitting; exponential windows; line broadening |
44
+ | [05 — Composite models](examples/05_composite_model.ipynb) | Simultaneous multi-peak fitting; spectral background |
45
+ | [06 — Window comparison](examples/06_window_comparison.ipynb) | Window choice: Fisher information loss vs. sidelobe suppression |
46
+
47
+ ---
48
+
49
+ ### Normalizations
50
+
51
+ Three normalizations are provided. **ASD** and **PSD** satisfy Parseval's theorem — ∫|ASD(f)|² df = ∫PSD(f) df = RMS² — and are appropriate for stationary signals. The normalization factor includes the window RMS (w_rms) to compensate for amplitude attenuation; this correction is exact in the limit N → ∞ and introduces a residual error of order 1/N. **CFT** normalization satisfies ∫X_CFT(f) df = x(0) and is appropriate for decaying signals, where total power depends on acquisition length and ASD peak height grows with T.
52
+
53
+ ![CFT vs ASD normalization](examples/figures/01_dft_normalization_fig01.svg)
54
+
55
+ *Top: CFT normalization — the spectral integral converges to x(0) = A for both decaying (left cluster, 8–12 Hz) and stationary sinusoids (right cluster, 18–22 Hz), independent of T. Bottom: ASD normalization — for stationary sinusoids √∫|ASD|² = RMS is stable; for decaying sinusoids the integral grows with T.*
56
+
57
+ *[Notebook 01](examples/01_dft_normalization.ipynb). Implementation: `NormType`, `DFTConfig.norm_factor` in [dft/config.py](dftmodels/dft/config.py).*
58
+
59
+ ---
60
+
61
+ ### DFT corrections
62
+
63
+ An off-bin sinusoid produces a deterministic, structured residual in the DFT that is not captured by the WINDOW-only model. The figure below shows the residual reduction as corrections are applied in sequence. Without corrections, the RMS residual for a rectangular window at N = 100 samples is ~7×10⁻² V/√Hz; with all corrections at order 100 it drops to ~3×10⁻⁵ V/√Hz. For Hann and Nuttall windows, which taper to zero at both boundaries, the BASELINE correction is zero and the SAMPLING correction converges in a few orders; for rectangular and Bartlett windows, higher orders are required (see window sweep table in Notebook 02).
64
+
65
+ ![Correction convergence](examples/figures/02_dft_corrections_fig01.svg)
66
+
67
+ The corrections become irrelevant above a crossover acquisition length where the noise floor dominates. The figure below sweeps N at fixed f_s and shows the normalized model error for each correction level alongside reference noise floors at fixed σ/A ratios. Below the crossover, the fit is model-limited; above it, noise-limited.
68
+
69
+ ![Corrections vs acquisition length](examples/figures/02_dft_corrections_fig02.svg)
70
+
71
+ *[Notebook 02](examples/02_dft_corrections.ipynb). Implementation: `DFTCorrection`, `DFTCorrectionMode` in [dft/correction.py](dftmodels/dft/correction.py).*
72
+
73
+ ---
74
+
75
+ ### Precision fitting — sinusoid
76
+
77
+ 300 noise realizations of a rectangular-windowed sinusoid at f₀ = 10.42 Hz (0.42 bins from the nearest bin), T = 1 s, σ = 0.01 V. The table below compares four correction levels; the violin plots show the full error distribution.
78
+
79
+ | | freq RMSE (mHz) | amp RMSE (mV) | phase RMSE (mrad) |
80
+ |---|---|---|---|
81
+ | CRB | 0.39 | 1.41 | 0.98 |
82
+ | ★ None | 9.90 | 117.4 | 27.18 |
83
+ | ★ All (order 10) | 0.41 | 1.47 | 1.43 |
84
+
85
+ Frequency and amplitude RMSE approach the CRB once corrections eliminate the systematic bias.
86
+
87
+ ![Precision fitting violin plots](examples/figures/03_precision_fitting_fig01.svg)
88
+
89
+ *[Notebook 03](examples/03_precision_fitting.ipynb). Implementation: `SineFourier` in [models/sinusoid.py](dftmodels/models/sinusoid.py).*
90
+
91
+ ---
92
+
93
+ ### Decaying signals
94
+
95
+ For a decaying sinusoid x(t) = A·cos(2πf₀t + φ)·e^{−γt}, the CFT near f₀ is a complex Lorentzian: X(f) ≈ (Ae^{jφ}/2) / (γ + 2πj(f − f₀)). `SineFourier` with a nonzero `decay` parameter implements the exact analytical DFT expression and fits four parameters (frequency f₀, quadrature amplitudes Aᵢ = A cos φ and A_q = A sin φ, and decay rate γ); amplitude A, phase φ, and FWHM = γ/π are derived quantities. The Fisher information matrix for a decaying sinusoid has non-zero off-diagonal coupling between f and γ — the two parameters are not decoupled, unlike the pure sinusoid case.
96
+
97
+ 300 realizations at σ = 0.05 V, T = 5 s, γ = 0.5 s⁻¹:
98
+
99
+ | | amp RMSE (mV) | phase RMSE (mrad) | freq RMSE (mHz) | decay RMSE (ms⁻¹) |
100
+ |---|---|---|---|---|
101
+ | CRB | 10.20 | 5.13 | 0.62 | 3.87 |
102
+ | ★ None | 59.98 | 5.93 | 0.67 | 34.41 |
103
+ | ★ All (order 10) | 10.03 | 5.14 | 0.61 | 3.83 |
104
+
105
+ ![Lorentzian bare vs corrected](examples/figures/04_decaying_signals_fig02.svg)
106
+
107
+ Applying an exponential window w(t_n) = e^{−α·t_n/T} before the DFT shifts the effective decay to γ_eff = γ + α/T (line broadening). `LorentzianComplex` with `WindowType.EXPONENTIAL_ASYM` corrects for the window-induced decay shift and recovers the true γ from the broadened spectrum. A bare fit converges to γ_eff, biased by α/T.
108
+
109
+ *[Notebook 04](examples/04_decaying_signals.ipynb). Implementation: `LorentzianComplex` in [models/lorentzian.py](dftmodels/models/lorentzian.py).*
110
+
111
+ ---
112
+
113
+ ### Composite models
114
+
115
+ `CompositeModel` fits a weighted sum of named components simultaneously, with all parameters coupled through a shared covariance matrix. For overlapping spectral lines, sequential peak-by-peak fitting propagates subtraction errors; joint fitting avoids this. Each component's parameters are prefixed by name (`p1_frequency`, `p2_decay`, ...).
116
+
117
+ A J-coupled quartet — four Lorentzian lines with 1:3:3:1 binomial amplitudes at spacing J = 2.5 Hz — illustrates the setup. A linear complex background b₀ + b₁f is included as a fifth component; fitting without it forces the peaks to absorb the baseline, distorting amplitude ratios.
118
+
119
+ ![Composite fit with decomposition](examples/figures/05_composite_model_fig01.svg)
120
+
121
+ *Top: magnitude spectrum (grey), total fit (dashed), individual peak contributions (dotted), and background (dash-dot). Bottom: residuals with and without a background component in the model.*
122
+
123
+ The corrected composite estimator approaches the CRB; outer peaks (A = 1 V) have proportionally larger uncertainty than inner peaks (A = 3 V).
124
+
125
+ *[Notebook 05](examples/05_composite_model.ipynb). Implementation: `CompositeModel` in [models/composite.py](dftmodels/models/composite.py).*
126
+
127
+ ---
128
+
129
+ ### Window choice
130
+
131
+ The CRB is determined by the raw, unweighted time-domain data and is independent of the window. Any non-rectangular window downweights samples near the record boundaries, discarding Fisher information. This loss cannot be recovered by corrections. For an isolated peak, the rectangular window is the only window whose RMSE approaches the CRB; all others inflate variance proportionally to their effective bandwidth.
132
+
133
+ ![Window RMSE comparison](examples/figures/06_window_comparison_fig00.svg)
134
+
135
+ *RMSE for frequency (left) and amplitude (right) estimation across six windows at two fractional bin offsets δ = 0 and δ = 0.5. The CRB (dotted line) is the same for both offsets and all windows. N = 1000, f_s = 1000 Hz, σ = 0.05 V, 300 realizations.*
136
+
137
+ The tradeoff reverses when a nearby peak is absent from the model. Its spectral sidelobes leak into the fit region; the rectangular window has the broadest sidelobe structure (∝ sinc), so its estimator is most sensitive to this. At worst-case interferer offset (δ = 0.484, +5 bins), the rectangular window has a frequency RMSE of 13.5 mHz vs. 2.3 mHz for Hamming.
138
+
139
+ Window choice therefore depends on context: isolated peaks favour rectangular; peaks with unmodeled nearby components favour windowed estimators.
140
+
141
+ *[Notebook 06](examples/06_window_comparison.ipynb).*
142
+
143
+ ---
144
+
145
+ ## Usage
146
+
147
+ ```python
148
+ import numpy as np
149
+ from dftmodels import (
150
+ SignalSeries, NormType, WindowType, DFTRange,
151
+ DFTCorrection, DFTCorrectionMode, SineFourier,
152
+ )
153
+
154
+ t = np.arange(0, 1.0, 1 / 100.0)
155
+ y = 2.0 * np.cos(2 * np.pi * 10.42 * t + 0.5)
156
+ y += np.random.normal(scale=0.01, size=len(t))
157
+
158
+ fourier = SignalSeries(x=t, y=y).calculate_dft(
159
+ norm=NormType.ASD, window=WindowType.RECTANGULAR,
160
+ dft_range=DFTRange.SINGLE_SIDED, pad=10.0,
161
+ )
162
+
163
+ model = SineFourier(fourier.dft_config, DFTCorrection(DFTCorrectionMode.ALL, order=10))
164
+ params = model.make_params(
165
+ amplitude_i=2.0, amplitude_q=0.0, frequency=10.0,
166
+ frequency_min=8.0, frequency_max=13.0,
167
+ )
168
+ result = model.fit(fourier, params, mask=(fourier.x >= 8.0) & (fourier.x <= 13.0))
169
+
170
+ print(f"Amplitude : {model.amplitude(result.params):.6f} V")
171
+ print(f"Frequency : {model.center(result.params):.6f} Hz")
172
+ print(f"Phase : {model.phase(result.params):.6f} rad")
173
+ ```
174
+
175
+ For decaying signals use `LorentzianComplex` with `NormType.CFT`. For simultaneous multi-peak fitting use `CompositeModel`. See the notebooks for detailed examples of each.
176
+
177
+ ## Models
178
+
179
+ | Signal type | Model class | Windows |
180
+ |---|---|---|
181
+ | Sinusoid | `SineFourier` | All cosine-sum windows, Bartlett |
182
+ | Decaying sinusoid | `LorentzianComplex` | Rectangular, Exponential |
183
+ | Time-domain (decaying) sinusoid | `Sinusoid` | N/A |
184
+ | Custom | `ModelBase.build_model(fn)` | N/A |
185
+
186
+ ## Limitations
187
+
188
+ **Scope of corrections.** The correction terms reduce systematic bias from the finite, sampled, windowed DFT. An alternative that achieves the same result is to define the signal model in the time domain and compute the DFT numerically at each evaluation. The analytical corrections are a closed-form shortcut to that approach; they are most efficient when the signal model is simple and the number of evaluations is large. For short-duration signals with few samples, the time-domain approach may be simpler.
189
+
190
+ **Noise model.** The Cramér–Rao bounds in the examples assume additive white Gaussian noise. Colored noise or non-Gaussian distributions will produce different variance floors; the model residuals remain unbiased but the optimality guarantee does not carry over.
191
+
192
+ **Model vs. estimator.** The classes in this package are models, not estimators. Combined with a least-squares fitting procedure (`lmfit.Minimizer`), they form estimators. The CRB is a property of the estimator (model + fitting algorithm + noise model), not of the model alone.
193
+
194
+ ## License
195
+
196
+ MIT License. See [LICENSE](LICENSE) for details.
@@ -0,0 +1,183 @@
1
+ # dftmodels
2
+
3
+ Analytical DFT models for spectral parameter estimation.
4
+
5
+ ## Overview
6
+
7
+ Fitting peaks in DFT spectra with continuous Fourier transform (CFT) models (sinc functions, Lorentzians, and their window-convolved variants) introduces systematic errors in the estimated amplitude, frequency, phase, and linewidth. Three effects are responsible: the DFT sum gives full weight to the boundary samples where the trapezoidal-rule approximation gives half weight (boundary mismatch); sampling makes the spectrum periodic, introducing aliased copies at f ± k·f_s (aliasing); and the finite window modifies the spectral shape in a way that depends on both the window coefficients and the signal frequency (windowing). Each effect produces a deterministic, structured residual in the DFT.
8
+
9
+ `dftmodels` provides closed-form analytical models that include these effects as additive correction terms: WINDOW (the CFT of the windowed signal), BASELINE (the boundary weight mismatch), and SAMPLING (aliased spectral copies up to a configurable order). Combined with least-squares fitting, these models form estimators whose variance can approach the Cramér–Rao bound (CRB).
10
+
11
+ **When corrections matter.** Systematic bias is only the limiting factor when SNR is high and records are short. The SAMPLING correction becomes negligible above a crossover acquisition length that depends on window and noise level (see [Notebook 02](examples/02_dft_corrections.ipynb)). For long records or when noise dominates, a CFT-based sinc or Lorentzian model is adequate. The gains shown in the examples below are large by design — the signal parameters were chosen so that systematic bias is the limiting factor, not noise. The package is most relevant when that condition holds.
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ pip install dftmodels
17
+ ```
18
+
19
+ Dependencies: Python ≥ 3.12, `numpy` ≥ 2.0, `scipy` ≥ 1.15, `lmfit` ≥ 1.0.
20
+
21
+ ## Examples
22
+
23
+ Six notebooks in [examples/](examples/) work through the main topics in sequence.
24
+
25
+ | Notebook | Topic |
26
+ |---|---|
27
+ | [01 — Normalizations](examples/01_dft_normalization.ipynb) | ASD, PSD, and CFT normalizations; Parseval's theorem |
28
+ | [02 — DFT corrections](examples/02_dft_corrections.ipynb) | Origin and convergence of the three correction terms |
29
+ | [03 — Precision fitting](examples/03_precision_fitting.ipynb) | Sinusoid parameter estimation; bias elimination; Cramér–Rao bound |
30
+ | [04 — Decaying signals](examples/04_decaying_signals.ipynb) | Lorentzian fitting; exponential windows; line broadening |
31
+ | [05 — Composite models](examples/05_composite_model.ipynb) | Simultaneous multi-peak fitting; spectral background |
32
+ | [06 — Window comparison](examples/06_window_comparison.ipynb) | Window choice: Fisher information loss vs. sidelobe suppression |
33
+
34
+ ---
35
+
36
+ ### Normalizations
37
+
38
+ Three normalizations are provided. **ASD** and **PSD** satisfy Parseval's theorem — ∫|ASD(f)|² df = ∫PSD(f) df = RMS² — and are appropriate for stationary signals. The normalization factor includes the window RMS (w_rms) to compensate for amplitude attenuation; this correction is exact in the limit N → ∞ and introduces a residual error of order 1/N. **CFT** normalization satisfies ∫X_CFT(f) df = x(0) and is appropriate for decaying signals, where total power depends on acquisition length and ASD peak height grows with T.
39
+
40
+ ![CFT vs ASD normalization](examples/figures/01_dft_normalization_fig01.svg)
41
+
42
+ *Top: CFT normalization — the spectral integral converges to x(0) = A for both decaying (left cluster, 8–12 Hz) and stationary sinusoids (right cluster, 18–22 Hz), independent of T. Bottom: ASD normalization — for stationary sinusoids √∫|ASD|² = RMS is stable; for decaying sinusoids the integral grows with T.*
43
+
44
+ *[Notebook 01](examples/01_dft_normalization.ipynb). Implementation: `NormType`, `DFTConfig.norm_factor` in [dft/config.py](dftmodels/dft/config.py).*
45
+
46
+ ---
47
+
48
+ ### DFT corrections
49
+
50
+ An off-bin sinusoid produces a deterministic, structured residual in the DFT that is not captured by the WINDOW-only model. The figure below shows the residual reduction as corrections are applied in sequence. Without corrections, the RMS residual for a rectangular window at N = 100 samples is ~7×10⁻² V/√Hz; with all corrections at order 100 it drops to ~3×10⁻⁵ V/√Hz. For Hann and Nuttall windows, which taper to zero at both boundaries, the BASELINE correction is zero and the SAMPLING correction converges in a few orders; for rectangular and Bartlett windows, higher orders are required (see window sweep table in Notebook 02).
51
+
52
+ ![Correction convergence](examples/figures/02_dft_corrections_fig01.svg)
53
+
54
+ The corrections become irrelevant above a crossover acquisition length where the noise floor dominates. The figure below sweeps N at fixed f_s and shows the normalized model error for each correction level alongside reference noise floors at fixed σ/A ratios. Below the crossover, the fit is model-limited; above it, noise-limited.
55
+
56
+ ![Corrections vs acquisition length](examples/figures/02_dft_corrections_fig02.svg)
57
+
58
+ *[Notebook 02](examples/02_dft_corrections.ipynb). Implementation: `DFTCorrection`, `DFTCorrectionMode` in [dft/correction.py](dftmodels/dft/correction.py).*
59
+
60
+ ---
61
+
62
+ ### Precision fitting — sinusoid
63
+
64
+ 300 noise realizations of a rectangular-windowed sinusoid at f₀ = 10.42 Hz (0.42 bins from the nearest bin), T = 1 s, σ = 0.01 V. The table below compares four correction levels; the violin plots show the full error distribution.
65
+
66
+ | | freq RMSE (mHz) | amp RMSE (mV) | phase RMSE (mrad) |
67
+ |---|---|---|---|
68
+ | CRB | 0.39 | 1.41 | 0.98 |
69
+ | ★ None | 9.90 | 117.4 | 27.18 |
70
+ | ★ All (order 10) | 0.41 | 1.47 | 1.43 |
71
+
72
+ Frequency and amplitude RMSE approach the CRB once corrections eliminate the systematic bias.
73
+
74
+ ![Precision fitting violin plots](examples/figures/03_precision_fitting_fig01.svg)
75
+
76
+ *[Notebook 03](examples/03_precision_fitting.ipynb). Implementation: `SineFourier` in [models/sinusoid.py](dftmodels/models/sinusoid.py).*
77
+
78
+ ---
79
+
80
+ ### Decaying signals
81
+
82
+ For a decaying sinusoid x(t) = A·cos(2πf₀t + φ)·e^{−γt}, the CFT near f₀ is a complex Lorentzian: X(f) ≈ (Ae^{jφ}/2) / (γ + 2πj(f − f₀)). `SineFourier` with a nonzero `decay` parameter implements the exact analytical DFT expression and fits four parameters (frequency f₀, quadrature amplitudes Aᵢ = A cos φ and A_q = A sin φ, and decay rate γ); amplitude A, phase φ, and FWHM = γ/π are derived quantities. The Fisher information matrix for a decaying sinusoid has non-zero off-diagonal coupling between f and γ — the two parameters are not decoupled, unlike the pure sinusoid case.
83
+
84
+ 300 realizations at σ = 0.05 V, T = 5 s, γ = 0.5 s⁻¹:
85
+
86
+ | | amp RMSE (mV) | phase RMSE (mrad) | freq RMSE (mHz) | decay RMSE (ms⁻¹) |
87
+ |---|---|---|---|---|
88
+ | CRB | 10.20 | 5.13 | 0.62 | 3.87 |
89
+ | ★ None | 59.98 | 5.93 | 0.67 | 34.41 |
90
+ | ★ All (order 10) | 10.03 | 5.14 | 0.61 | 3.83 |
91
+
92
+ ![Lorentzian bare vs corrected](examples/figures/04_decaying_signals_fig02.svg)
93
+
94
+ Applying an exponential window w(t_n) = e^{−α·t_n/T} before the DFT shifts the effective decay to γ_eff = γ + α/T (line broadening). `LorentzianComplex` with `WindowType.EXPONENTIAL_ASYM` corrects for the window-induced decay shift and recovers the true γ from the broadened spectrum. A bare fit converges to γ_eff, biased by α/T.
95
+
96
+ *[Notebook 04](examples/04_decaying_signals.ipynb). Implementation: `LorentzianComplex` in [models/lorentzian.py](dftmodels/models/lorentzian.py).*
97
+
98
+ ---
99
+
100
+ ### Composite models
101
+
102
+ `CompositeModel` fits a weighted sum of named components simultaneously, with all parameters coupled through a shared covariance matrix. For overlapping spectral lines, sequential peak-by-peak fitting propagates subtraction errors; joint fitting avoids this. Each component's parameters are prefixed by name (`p1_frequency`, `p2_decay`, ...).
103
+
104
+ A J-coupled quartet — four Lorentzian lines with 1:3:3:1 binomial amplitudes at spacing J = 2.5 Hz — illustrates the setup. A linear complex background b₀ + b₁f is included as a fifth component; fitting without it forces the peaks to absorb the baseline, distorting amplitude ratios.
105
+
106
+ ![Composite fit with decomposition](examples/figures/05_composite_model_fig01.svg)
107
+
108
+ *Top: magnitude spectrum (grey), total fit (dashed), individual peak contributions (dotted), and background (dash-dot). Bottom: residuals with and without a background component in the model.*
109
+
110
+ The corrected composite estimator approaches the CRB; outer peaks (A = 1 V) have proportionally larger uncertainty than inner peaks (A = 3 V).
111
+
112
+ *[Notebook 05](examples/05_composite_model.ipynb). Implementation: `CompositeModel` in [models/composite.py](dftmodels/models/composite.py).*
113
+
114
+ ---
115
+
116
+ ### Window choice
117
+
118
+ The CRB is determined by the raw, unweighted time-domain data and is independent of the window. Any non-rectangular window downweights samples near the record boundaries, discarding Fisher information. This loss cannot be recovered by corrections. For an isolated peak, the rectangular window is the only window whose RMSE approaches the CRB; all others inflate variance proportionally to their effective bandwidth.
119
+
120
+ ![Window RMSE comparison](examples/figures/06_window_comparison_fig00.svg)
121
+
122
+ *RMSE for frequency (left) and amplitude (right) estimation across six windows at two fractional bin offsets δ = 0 and δ = 0.5. The CRB (dotted line) is the same for both offsets and all windows. N = 1000, f_s = 1000 Hz, σ = 0.05 V, 300 realizations.*
123
+
124
+ The tradeoff reverses when a nearby peak is absent from the model. Its spectral sidelobes leak into the fit region; the rectangular window has the broadest sidelobe structure (∝ sinc), so its estimator is most sensitive to this. At worst-case interferer offset (δ = 0.484, +5 bins), the rectangular window has a frequency RMSE of 13.5 mHz vs. 2.3 mHz for Hamming.
125
+
126
+ Window choice therefore depends on context: isolated peaks favour rectangular; peaks with unmodeled nearby components favour windowed estimators.
127
+
128
+ *[Notebook 06](examples/06_window_comparison.ipynb).*
129
+
130
+ ---
131
+
132
+ ## Usage
133
+
134
+ ```python
135
+ import numpy as np
136
+ from dftmodels import (
137
+ SignalSeries, NormType, WindowType, DFTRange,
138
+ DFTCorrection, DFTCorrectionMode, SineFourier,
139
+ )
140
+
141
+ t = np.arange(0, 1.0, 1 / 100.0)
142
+ y = 2.0 * np.cos(2 * np.pi * 10.42 * t + 0.5)
143
+ y += np.random.normal(scale=0.01, size=len(t))
144
+
145
+ fourier = SignalSeries(x=t, y=y).calculate_dft(
146
+ norm=NormType.ASD, window=WindowType.RECTANGULAR,
147
+ dft_range=DFTRange.SINGLE_SIDED, pad=10.0,
148
+ )
149
+
150
+ model = SineFourier(fourier.dft_config, DFTCorrection(DFTCorrectionMode.ALL, order=10))
151
+ params = model.make_params(
152
+ amplitude_i=2.0, amplitude_q=0.0, frequency=10.0,
153
+ frequency_min=8.0, frequency_max=13.0,
154
+ )
155
+ result = model.fit(fourier, params, mask=(fourier.x >= 8.0) & (fourier.x <= 13.0))
156
+
157
+ print(f"Amplitude : {model.amplitude(result.params):.6f} V")
158
+ print(f"Frequency : {model.center(result.params):.6f} Hz")
159
+ print(f"Phase : {model.phase(result.params):.6f} rad")
160
+ ```
161
+
162
+ For decaying signals use `LorentzianComplex` with `NormType.CFT`. For simultaneous multi-peak fitting use `CompositeModel`. See the notebooks for detailed examples of each.
163
+
164
+ ## Models
165
+
166
+ | Signal type | Model class | Windows |
167
+ |---|---|---|
168
+ | Sinusoid | `SineFourier` | All cosine-sum windows, Bartlett |
169
+ | Decaying sinusoid | `LorentzianComplex` | Rectangular, Exponential |
170
+ | Time-domain (decaying) sinusoid | `Sinusoid` | N/A |
171
+ | Custom | `ModelBase.build_model(fn)` | N/A |
172
+
173
+ ## Limitations
174
+
175
+ **Scope of corrections.** The correction terms reduce systematic bias from the finite, sampled, windowed DFT. An alternative that achieves the same result is to define the signal model in the time domain and compute the DFT numerically at each evaluation. The analytical corrections are a closed-form shortcut to that approach; they are most efficient when the signal model is simple and the number of evaluations is large. For short-duration signals with few samples, the time-domain approach may be simpler.
176
+
177
+ **Noise model.** The Cramér–Rao bounds in the examples assume additive white Gaussian noise. Colored noise or non-Gaussian distributions will produce different variance floors; the model residuals remain unbiased but the optimality guarantee does not carry over.
178
+
179
+ **Model vs. estimator.** The classes in this package are models, not estimators. Combined with a least-squares fitting procedure (`lmfit.Minimizer`), they form estimators. The CRB is a property of the estimator (model + fitting algorithm + noise model), not of the model alone.
180
+
181
+ ## License
182
+
183
+ MIT License. See [LICENSE](LICENSE) for details.
@@ -0,0 +1,42 @@
1
+ from importlib.metadata import version
2
+ from .stats import cramer_rao_bound
3
+
4
+ __version__ = version("dftmodels")
5
+ from .dft import (
6
+ DFTConfig,
7
+ NormType,
8
+ DFTRange,
9
+ WindowType,
10
+ DFTCorrection,
11
+ DFTCorrectionMode,
12
+ SignalSeries,
13
+ FourierSeries,
14
+ )
15
+ from .models import (
16
+ FourierModelBase,
17
+ Sinusoid,
18
+ SineFourier,
19
+ CompositeModel,
20
+ ModelBase,
21
+ )
22
+
23
+ __all__ = [
24
+ "__version__",
25
+ # Stats
26
+ "cramer_rao_bound",
27
+ # DFT
28
+ "DFTConfig",
29
+ "NormType",
30
+ "DFTRange",
31
+ "WindowType",
32
+ "DFTCorrection",
33
+ "DFTCorrectionMode",
34
+ "SignalSeries",
35
+ "FourierSeries",
36
+ # Models
37
+ "FourierModelBase",
38
+ "ModelBase",
39
+ "Sinusoid",
40
+ "SineFourier",
41
+ "CompositeModel",
42
+ ]
@@ -0,0 +1,15 @@
1
+ from .config import DFTConfig, NormType, DFTRange, WindowType, get_window_array
2
+ from .correction import DFTCorrection, DFTCorrectionMode
3
+ from .series import SignalSeries, FourierSeries
4
+
5
+ __all__ = [
6
+ "DFTConfig",
7
+ "NormType",
8
+ "DFTRange",
9
+ "WindowType",
10
+ "get_window_array",
11
+ "DFTCorrection",
12
+ "DFTCorrectionMode",
13
+ "SignalSeries",
14
+ "FourierSeries",
15
+ ]
@@ -0,0 +1,170 @@
1
+ from dataclasses import dataclass, replace, field
2
+ from enum import Enum
3
+ from functools import cached_property
4
+
5
+ import numpy as np
6
+ from numpy.typing import NDArray
7
+
8
+
9
+ class WindowType(str, Enum):
10
+ # Cosine-sum windows
11
+ RECTANGULAR = "rectangular"
12
+ HAMMING = "hamming"
13
+ HANN = "hann"
14
+ DIRICHLET = "rectangular" # alias for RECTANGULAR
15
+ BLACKMAN = "blackman"
16
+ NUTTAL = "nuttal"
17
+ BLACKMAN_NUTTAL = "blackman-nuttal"
18
+ BLACKMAN_HARRIS = "blackman-harris"
19
+ FLAT_TOP = "flat-top"
20
+
21
+ # For Debug Only
22
+ # COS4N = "cos-4n"
23
+ # COS6N = "cos-6n"
24
+ # COS8N = "cos-8n"
25
+
26
+ # Other
27
+ EXPONENTIAL_ASYM = "exponential-asymmetric"
28
+ BARTLETT = "bartlett"
29
+
30
+
31
+
32
+ def get_window_array(window: WindowType, n: int, **params) -> NDArray[np.floating]:
33
+ match window:
34
+ case WindowType.RECTANGULAR:
35
+ return np.ones(n)
36
+ case WindowType.HAMMING:
37
+ return 25/46 - 21/46 * np.cos(2 * np.pi * np.arange(n) / (n - 1))
38
+ case WindowType.HANN:
39
+ return 0.5 - 0.5 * np.cos(2 * np.pi * np.arange(n) / (n - 1))
40
+ case WindowType.BLACKMAN:
41
+ return (7938
42
+ - 9240 * np.cos(2 * np.pi * np.arange(n) / (n - 1))
43
+ + 1430 * np.cos(4 * np.pi * np.arange(n) / (n - 1))
44
+ ) / 18608
45
+ case WindowType.NUTTAL:
46
+ return (355_768 \
47
+ - 487_396 * np.cos(2 * np.pi * np.arange(n) / (n - 1))
48
+ + 144_232 * np.cos(4 * np.pi * np.arange(n) / (n - 1))
49
+ - 12_604 * np.cos(6 * np.pi * np.arange(n) / (n - 1))
50
+ ) / 1_000_000
51
+ case WindowType.BLACKMAN_NUTTAL:
52
+ return (3_635_819 \
53
+ - 4_891_775 * np.cos(2 * np.pi * np.arange(n) / (n - 1))
54
+ + 1_365_995 * np.cos(4 * np.pi * np.arange(n) / (n - 1))
55
+ - 106_411 * np.cos(6 * np.pi * np.arange(n) / (n - 1))
56
+ ) / 10_000_000
57
+ case WindowType.BLACKMAN_HARRIS:
58
+ return (4_243_801 \
59
+ - 4_973_406 * np.cos(2 * np.pi * np.arange(n) / (n - 1))
60
+ + 782_793 * np.cos(4 * np.pi * np.arange(n) / (n - 1))
61
+ ) / 10_000_000
62
+ case WindowType.FLAT_TOP:
63
+ return (215_578_950 \
64
+ - 416_631_580 * np.cos(2 * np.pi * np.arange(n) / (n - 1))
65
+ + 277_263_158 * np.cos(4 * np.pi * np.arange(n) / (n - 1))
66
+ - 83_578_947 * np.cos(6 * np.pi * np.arange(n) / (n - 1))
67
+ + 6_947_368 * np.cos(8 * np.pi * np.arange(n) / (n - 1))
68
+ ) / 1_000_000_000
69
+ case WindowType.BARTLETT:
70
+ return np.bartlett(n)
71
+ # case WindowType.COS6N:
72
+ # return .5 - .5 * np.cos(6 * np.pi * np.arange(n) / (n - 1))
73
+ # case WindowType.COS8N:
74
+ # return .5 - .5 * np.cos(8 * np.pi * np.arange(n) / (n - 1))
75
+ case WindowType.EXPONENTIAL_ASYM:
76
+ return np.exp(-np.arange(n) * params.get("alpha", 1.0) / (n - 1))
77
+ case _:
78
+ raise ValueError(f"Unsupported window type: {window}")
79
+
80
+
81
+ class NormType(str, Enum):
82
+ CFT = "continuous_fourier_transform"
83
+ ASD = "amplitude_spectral_density"
84
+ ASD_ABS = "amplitude_spectral_density_absolute"
85
+ PSD = "power_spectral_density"
86
+
87
+
88
+ class DFTRange(str, Enum):
89
+ DOUBLE_SIDED = "double_sided"
90
+ SINGLE_SIDED = "single_sided"
91
+
92
+
93
+ @dataclass(frozen=True)
94
+ class DFTConfig:
95
+ number_of_samples: int
96
+ sample_rate: float
97
+ pad: float = 1.0
98
+ window: WindowType = WindowType.RECTANGULAR
99
+ window_params: dict = field(default_factory=dict) # <--- NEW
100
+ norm_type: NormType = NormType.ASD
101
+ dft_range: DFTRange = DFTRange.DOUBLE_SIDED
102
+
103
+ def __post_init__(self):
104
+ if self.number_of_samples <= 0:
105
+ raise ValueError("number_of_samples must be a positive integer")
106
+ if self.sample_rate <= 0:
107
+ raise ValueError("sample_rate must be positive")
108
+ if self.pad < 1.0:
109
+ raise ValueError("pad cannot be less than 1.0")
110
+ if self.window not in WindowType:
111
+ raise ValueError(f"Unsupported window type: {self.window}")
112
+ if self.norm_type not in NormType:
113
+ raise ValueError(f"Unsupported norm type: {self.norm_type}")
114
+ if self.dft_range not in DFTRange:
115
+ raise ValueError(f"Unsupported DFT range: {self.dft_range}")
116
+
117
+ @cached_property
118
+ def number_of_samples_fft(self) -> int:
119
+ return round(self.number_of_samples * self.pad)
120
+
121
+ @cached_property
122
+ def window_array(self) -> NDArray[np.floating]:
123
+ return get_window_array(self.window, self.number_of_samples, **self.window_params)
124
+
125
+ @cached_property
126
+ def norm_factor(self) -> float:
127
+ if self.norm_type in (NormType.ASD, NormType.ASD_ABS):
128
+ return self._asd_norm()
129
+ elif self.norm_type == NormType.PSD:
130
+ return self._asd_norm() ** 2
131
+ elif self.norm_type == NormType.CFT:
132
+ return self._cft_norm()
133
+ raise ValueError(f"Unsupported norm type: {self.norm_type}")
134
+
135
+ def _cft_norm(self) -> float:
136
+ wa = self.window_array
137
+ if wa[0] == 0.0:
138
+ raise ValueError('Zero-valued window is incompatible with CFT normalization')
139
+ factor = 2.0 if self.dft_range == DFTRange.SINGLE_SIDED else 1.0
140
+ return factor / self.sample_rate / wa[0]
141
+
142
+ def _asd_norm(self) -> float:
143
+ wa = self.window_array
144
+ n = self.number_of_samples
145
+ window_rms = np.sqrt(np.sum(wa ** 2) / n)
146
+ factor = np.sqrt(2.0) if self.dft_range == DFTRange.SINGLE_SIDED else 1.0
147
+ return factor / np.sqrt(self.sample_rate * n) / window_rms
148
+
149
+ @cached_property
150
+ def frequency_step(self) -> float:
151
+ return self.sample_rate / self.number_of_samples_fft
152
+
153
+ @cached_property
154
+ def frequency_min(self) -> float:
155
+ if self.dft_range == DFTRange.SINGLE_SIDED:
156
+ return 0.0
157
+ n = self.number_of_samples_fft
158
+ df = self.frequency_step
159
+ return -(self.sample_rate + df) / 2 if n % 2 == 0 else -self.sample_rate / 2
160
+
161
+ @cached_property
162
+ def frequency_max(self) -> float:
163
+ if self.dft_range == DFTRange.SINGLE_SIDED:
164
+ return self.sample_rate / 2
165
+ n = self.number_of_samples_fft
166
+ df = self.frequency_step
167
+ return (self.sample_rate - df) / 2 if n % 2 == 0 else self.sample_rate / 2
168
+
169
+ def copy(self) -> 'DFTConfig':
170
+ return replace(self)
@@ -0,0 +1,19 @@
1
+ from dataclasses import dataclass
2
+ from enum import IntFlag, auto
3
+
4
+
5
+ class DFTCorrectionMode(IntFlag):
6
+ NONE = 0
7
+ BASELINE_ONLY = auto()
8
+ SAMPLING_ONLY = auto()
9
+ WINDOW_ONLY = auto()
10
+
11
+ WINDOW = WINDOW_ONLY
12
+ BASELINE = WINDOW_ONLY | BASELINE_ONLY
13
+ ALL = WINDOW_ONLY | BASELINE_ONLY | SAMPLING_ONLY
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class DFTCorrection:
18
+ mode: DFTCorrectionMode
19
+ order: int = 10