dftmodels 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dftmodels/__init__.py ADDED
@@ -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
@@ -0,0 +1,252 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ import numpy as np
5
+ from numpy.typing import NDArray
6
+
7
+ from .config import DFTConfig, DFTRange, NormType, WindowType
8
+
9
+ @dataclass
10
+ class DataSeries:
11
+ x: NDArray[np.floating]
12
+ y: NDArray
13
+
14
+ @dataclass
15
+ class SignalSeries(DataSeries):
16
+ def __post_init__(self):
17
+ x = np.asarray(self.x)
18
+ if np.iscomplexobj(x):
19
+ raise ValueError("x must be real-valued")
20
+ self.x = x.astype(float)
21
+ self.y = np.asarray(self.y)
22
+
23
+ if self.x.ndim != 1:
24
+ raise ValueError("x must be 1-dimensional")
25
+ if len(self.x) != len(self.y):
26
+ raise ValueError("x and y must have the same length")
27
+
28
+ def __len__(self) -> int:
29
+ return len(self.x)
30
+
31
+ @property
32
+ def sample_rate(self) -> float:
33
+ if len(self.x) < 2:
34
+ raise ValueError("Need at least 2 samples to determine sample rate")
35
+ return 1.0 / (self.x[1] - self.x[0])
36
+
37
+ def copy(self) -> SignalSeries:
38
+ return SignalSeries(
39
+ x=self.x.copy(),
40
+ y=self.y.copy(),
41
+ )
42
+
43
+ def calculate_rms(self) -> float:
44
+ return float(np.sqrt(np.mean(np.abs(self.y) ** 2)))
45
+
46
+ def calculate_dft(
47
+ self,
48
+ pad: float = 1.0,
49
+ norm: NormType = NormType.CFT,
50
+ window: WindowType = WindowType.RECTANGULAR,
51
+ window_params: None | dict = None,
52
+ dft_range: DFTRange = DFTRange.DOUBLE_SIDED,
53
+ ) -> FourierSeries:
54
+ if len(self) == 0:
55
+ raise ValueError("Signal is empty")
56
+
57
+ if dft_range == DFTRange.SINGLE_SIDED and np.iscomplexobj(self.y):
58
+ raise ValueError("Single-sided DFT requires a real-valued signal")
59
+
60
+ n = len(self)
61
+ sr = self.sample_rate
62
+
63
+ if window_params is None:
64
+ window_params = dict()
65
+
66
+ dft_config = DFTConfig(
67
+ number_of_samples=n,
68
+ sample_rate=sr,
69
+ pad=pad,
70
+ window=window,
71
+ window_params=window_params,
72
+ norm_type=norm,
73
+ dft_range=dft_range,
74
+ )
75
+
76
+ windowed = self.y * dft_config.window_array
77
+ n_fft = dft_config.number_of_samples_fft
78
+ t_step = 1.0 / sr
79
+
80
+ if dft_range == DFTRange.DOUBLE_SIDED:
81
+ amplitude = np.fft.fftshift(np.fft.fft(windowed, n=n_fft))
82
+ frequency = np.fft.fftshift(np.fft.fftfreq(n_fft, t_step))
83
+ else:
84
+ amplitude = np.fft.rfft(windowed, n=n_fft)
85
+ frequency = np.fft.rfftfreq(n_fft, t_step)
86
+
87
+ if norm == NormType.PSD:
88
+ amplitude = np.abs(amplitude) ** 2
89
+ elif norm == NormType.ASD_ABS:
90
+ amplitude = np.abs(amplitude)
91
+
92
+ amplitude = amplitude * dft_config.norm_factor
93
+
94
+ return FourierSeries(
95
+ x=frequency,
96
+ y=amplitude,
97
+ dft_config=dft_config,
98
+ )
99
+
100
+
101
+ @dataclass
102
+ class FourierSeries(DataSeries):
103
+ dft_config: DFTConfig
104
+
105
+ def __post_init__(self):
106
+ self.x = np.asarray(self.x, dtype=float)
107
+ self.y = np.asarray(self.y)
108
+
109
+ if len(self.x) != len(self.y):
110
+ raise ValueError("x and y must have the same length")
111
+
112
+ def __len__(self) -> int:
113
+ return len(self.x)
114
+
115
+ def copy(self) -> FourierSeries:
116
+ return FourierSeries(
117
+ x=self.x.copy(),
118
+ y=self.y.copy(),
119
+ dft_config=self.dft_config.copy(),
120
+ )
121
+
122
+ @property
123
+ def real(self) -> FourierSeries:
124
+ result = self.copy()
125
+ result.y = np.real(self.y).copy()
126
+ return result
127
+
128
+ @property
129
+ def imag(self) -> FourierSeries:
130
+ result = self.copy()
131
+ result.y = np.imag(self.y).copy()
132
+ return result
133
+
134
+ @property
135
+ def abs(self) -> FourierSeries:
136
+ result = self.copy()
137
+ result.y = np.abs(self.y).copy()
138
+ return result
139
+
140
+ def calculate_integral(
141
+ self,
142
+ f_min: float | None = None,
143
+ f_max: float | None = None,
144
+ ):
145
+ """Integrate the spectrum using the rectangular (midpoint) rule.
146
+
147
+ The rectangular rule is exact for DFT spectra: Parseval's theorem
148
+ holds precisely as ``df * sum(PSD) == signal_power``.
149
+
150
+ When f_min/f_max are given, boundary bins are weighted by their
151
+ fractional overlap with the integration window rather than included
152
+ or excluded wholesale.
153
+ """
154
+ x, y = self.x, self.y
155
+
156
+ if len(x) == 0:
157
+ return 0.0
158
+ if len(x) == 1:
159
+ return y[0]
160
+
161
+ df = x[1] - x[0]
162
+
163
+ if f_min is None and f_max is None and self.dft_config.dft_range == DFTRange.SINGLE_SIDED:
164
+ lo = x[0]
165
+ hi = x[-1]
166
+ else:
167
+ lo = f_min if f_min is not None else x[0] - df / 2
168
+ hi = f_max if f_max is not None else x[-1] + df / 2
169
+
170
+ bin_starts = x - df / 2
171
+ bin_ends = x + df / 2
172
+
173
+ overlap = np.maximum(0.0, np.minimum(bin_ends, hi) - np.maximum(bin_starts, lo))
174
+ proportions = overlap / df
175
+
176
+ return df * np.sum(proportions * y)
177
+
178
+ def calculate_idft(self, remove_padding: bool = True, remove_window: bool = True) -> SignalSeries:
179
+ cfg = self.dft_config
180
+ n_fft = cfg.number_of_samples_fft
181
+ n = cfg.number_of_samples
182
+
183
+ window_array = cfg.window_array
184
+ if np.any(window_array == 0.0):
185
+ raise ValueError("Zero-valued window is incompatible with IDFT")
186
+
187
+ if cfg.norm_type in (NormType.ASD_ABS, NormType.PSD):
188
+ raise ValueError("Cannot compute IDFT of ASD_ABS or PSD data")
189
+
190
+ expected_len = n_fft if cfg.dft_range == DFTRange.DOUBLE_SIDED else n_fft // 2 + 1
191
+ if len(self) != expected_len:
192
+ raise ValueError(
193
+ f"Expected {expected_len} frequency bins for IDFT, got {len(self)}"
194
+ )
195
+
196
+ amplitude = self.y / cfg.norm_factor
197
+
198
+ if cfg.dft_range == DFTRange.DOUBLE_SIDED:
199
+ value = np.fft.ifft(np.fft.ifftshift(amplitude), n=n_fft)
200
+ else:
201
+ value = np.fft.irfft(amplitude, n=n_fft)
202
+
203
+ if remove_padding:
204
+ value = value[:n]
205
+
206
+ if remove_window:
207
+ value[:n] = value[:n] / window_array
208
+
209
+ sr = cfg.sample_rate
210
+ n_out = n if remove_padding else n_fft
211
+ time = np.linspace(0, n_out / sr, n_out, endpoint=False)
212
+
213
+ return SignalSeries(x=time, y=value)
214
+
215
+ def convert_to_psd(self) -> FourierSeries:
216
+ if self.dft_config.norm_type not in (NormType.ASD, NormType.ASD_ABS):
217
+ raise ValueError("convert_to_psd requires ASD or ASD_ABS normalization")
218
+
219
+ cfg = DFTConfig(
220
+ number_of_samples=self.dft_config.number_of_samples,
221
+ sample_rate=self.dft_config.sample_rate,
222
+ pad=self.dft_config.pad,
223
+ window=self.dft_config.window,
224
+ window_params=self.dft_config.window_params,
225
+ norm_type=NormType.PSD,
226
+ dft_range=self.dft_config.dft_range,
227
+ )
228
+
229
+ return FourierSeries(
230
+ x=self.x.copy(),
231
+ y=np.abs(self.y) ** 2,
232
+ dft_config=cfg,
233
+ )
234
+
235
+ def convert_to_asd(self) -> FourierSeries:
236
+ if self.dft_config.norm_type != NormType.PSD:
237
+ raise ValueError("convert_to_asd requires PSD normalization")
238
+
239
+ cfg = DFTConfig(
240
+ number_of_samples=self.dft_config.number_of_samples,
241
+ sample_rate=self.dft_config.sample_rate,
242
+ pad=self.dft_config.pad,
243
+ window=self.dft_config.window,
244
+ window_params=self.dft_config.window_params,
245
+ norm_type=NormType.ASD_ABS,
246
+ dft_range=self.dft_config.dft_range,
247
+ )
248
+ return FourierSeries(
249
+ x=self.x.copy(),
250
+ y=np.sqrt(np.abs(self.y)),
251
+ dft_config=cfg,
252
+ )
@@ -0,0 +1,11 @@
1
+ from .base import FourierModelBase, ModelBase
2
+ from .sinusoid import Sinusoid, SineFourier
3
+ from .composite import CompositeModel
4
+
5
+ __all__ = [
6
+ "FourierModelBase",
7
+ "Sinusoid",
8
+ "SineFourier",
9
+ "ModelBase",
10
+ "CompositeModel",
11
+ ]