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 +42 -0
- dftmodels/dft/__init__.py +15 -0
- dftmodels/dft/config.py +170 -0
- dftmodels/dft/correction.py +19 -0
- dftmodels/dft/series.py +252 -0
- dftmodels/models/__init__.py +11 -0
- dftmodels/models/base.py +198 -0
- dftmodels/models/composite.py +123 -0
- dftmodels/models/sinusoid.py +790 -0
- dftmodels/py.typed +0 -0
- dftmodels/stats.py +86 -0
- dftmodels/utils/__init__.py +11 -0
- dftmodels/utils/math.py +38 -0
- dftmodels-1.0.0.dist-info/METADATA +196 -0
- dftmodels-1.0.0.dist-info/RECORD +18 -0
- dftmodels-1.0.0.dist-info/WHEEL +5 -0
- dftmodels-1.0.0.dist-info/licenses/LICENSE +21 -0
- dftmodels-1.0.0.dist-info/top_level.txt +1 -0
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
|
+
]
|
dftmodels/dft/config.py
ADDED
|
@@ -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
|
dftmodels/dft/series.py
ADDED
|
@@ -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
|
+
)
|