mcp-server-mcsa 0.1.0__py3-none-any.whl → 0.1.1__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.
@@ -1,145 +1,147 @@
1
- """Motor parameter calculations for MCSA.
2
-
3
- Computes synchronous speed, slip, rotor frequency, and expected fault
4
- frequencies from motor nameplate data and operating conditions.
5
- """
6
-
7
- from __future__ import annotations
8
-
9
- from dataclasses import asdict, dataclass
10
-
11
-
12
- @dataclass(frozen=True)
13
- class MotorParameters:
14
- """Computed operating parameters for an induction motor.
15
-
16
- Attributes:
17
- supply_freq_hz: Supply (line) frequency in Hz.
18
- poles: Number of magnetic poles.
19
- sync_speed_rpm: Synchronous speed in RPM.
20
- rotor_speed_rpm: Measured rotor speed in RPM.
21
- slip: Per-unit slip (0–1).
22
- rotor_freq_hz: Mechanical rotational frequency in Hz.
23
- slip_freq_hz: Slip frequency in Hz (s × f_supply).
24
- """
25
-
26
- supply_freq_hz: float
27
- poles: int
28
- sync_speed_rpm: float
29
- rotor_speed_rpm: float
30
- slip: float
31
- rotor_freq_hz: float
32
- slip_freq_hz: float
33
-
34
- def to_dict(self) -> dict:
35
- return asdict(self)
36
-
37
-
38
- def calculate_motor_parameters(
39
- supply_freq_hz: float,
40
- poles: int,
41
- rotor_speed_rpm: float,
42
- ) -> MotorParameters:
43
- """Calculate motor operating parameters from nameplate / measured data.
44
-
45
- Args:
46
- supply_freq_hz: Supply frequency in Hz (e.g. 50 or 60).
47
- poles: Number of poles (must be even, ≥ 2).
48
- rotor_speed_rpm: Measured rotor speed in RPM.
49
-
50
- Returns:
51
- MotorParameters with all derived quantities.
52
-
53
- Raises:
54
- ValueError: If inputs are physically invalid.
55
- """
56
- if supply_freq_hz <= 0:
57
- raise ValueError(f"Supply frequency must be > 0, got {supply_freq_hz}")
58
- if poles < 2 or poles % 2 != 0:
59
- raise ValueError(f"Poles must be even and ≥ 2, got {poles}")
60
- if rotor_speed_rpm < 0:
61
- raise ValueError(f"Rotor speed must be ≥ 0, got {rotor_speed_rpm}")
62
-
63
- sync_speed_rpm = 120.0 * supply_freq_hz / poles
64
-
65
- if rotor_speed_rpm > sync_speed_rpm:
66
- raise ValueError(
67
- f"Rotor speed ({rotor_speed_rpm} RPM) exceeds synchronous speed "
68
- f"({sync_speed_rpm} RPM) — not valid for a motor (generator mode)."
69
- )
70
-
71
- slip = (sync_speed_rpm - rotor_speed_rpm) / sync_speed_rpm
72
- rotor_freq_hz = rotor_speed_rpm / 60.0
73
- slip_freq_hz = slip * supply_freq_hz
74
-
75
- return MotorParameters(
76
- supply_freq_hz=supply_freq_hz,
77
- poles=poles,
78
- sync_speed_rpm=sync_speed_rpm,
79
- rotor_speed_rpm=rotor_speed_rpm,
80
- slip=slip,
81
- rotor_freq_hz=rotor_freq_hz,
82
- slip_freq_hz=slip_freq_hz,
83
- )
84
-
85
-
86
- def calculate_fault_frequencies(
87
- params: MotorParameters,
88
- harmonics: int = 3,
89
- ) -> dict:
90
- """Calculate expected fault frequencies for common induction‑motor faults.
91
-
92
- Computes characteristic harmonic frequencies for:
93
- - Broken Rotor Bars (BRB): sidebands at (1 ± 2ks)·f_s
94
- - Eccentricity (static/dynamic): f_s ± k·f_r
95
- - Stator inter‑turn faults: f_s ± 2k·f_r
96
- - Mixed eccentricity: n·f_r (for n = 1 … harmonics)
97
-
98
- Args:
99
- params: Motor operating parameters.
100
- harmonics: Number of harmonic orders to compute (default 3).
101
-
102
- Returns:
103
- Dictionary with fault type keys → lists of expected frequencies [Hz].
104
- """
105
- fs = params.supply_freq_hz
106
- s = params.slip
107
- fr = params.rotor_freq_hz
108
-
109
- # Broken Rotor Bars — sidebands (1 ± 2ks)·fs
110
- brb_lower = [(1 - 2 * k * s) * fs for k in range(1, harmonics + 1)]
111
- brb_upper = [(1 + 2 * k * s) * fs for k in range(1, harmonics + 1)]
112
-
113
- # Eccentricity — fs ± k·fr
114
- ecc_freqs = []
115
- for k in range(1, harmonics + 1):
116
- ecc_freqs.append({"harmonic_order": k, "lower": fs - k * fr, "upper": fs + k * fr})
117
-
118
- # Stator inter‑turn — fs ± 2k·fr
119
- stator_freqs = []
120
- for k in range(1, harmonics + 1):
121
- stator_freqs.append({"harmonic_order": k, "lower": fs - 2 * k * fr, "upper": fs + 2 * k * fr})
122
-
123
- # Mixed eccentricity — n·fr
124
- mixed_ecc = [k * fr for k in range(1, harmonics + 1)]
125
-
126
- return {
127
- "motor_parameters": params.to_dict(),
128
- "broken_rotor_bars": {
129
- "description": "Sidebands at (1 ± 2k·s)·f_supply due to rotor asymmetry",
130
- "lower_sidebands_hz": brb_lower,
131
- "upper_sidebands_hz": brb_upper,
132
- },
133
- "eccentricity": {
134
- "description": "Sidebands at f_supply ± k·f_rotor due to air‑gap non‑uniformity",
135
- "sidebands": ecc_freqs,
136
- },
137
- "stator_faults": {
138
- "description": "Sidebands at f_supply ± 2k·f_rotor due to stator winding asymmetry",
139
- "sidebands": stator_freqs,
140
- },
141
- "mixed_eccentricity": {
142
- "description": "Components at n·f_rotor (pure rotational harmonics)",
143
- "frequencies_hz": mixed_ecc,
144
- },
145
- }
1
+ """Motor parameter calculations for MCSA.
2
+
3
+ Computes synchronous speed, slip, rotor frequency, and expected fault
4
+ frequencies from motor nameplate data and operating conditions.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import asdict, dataclass
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class MotorParameters:
14
+ """Computed operating parameters for an induction motor.
15
+
16
+ Attributes:
17
+ supply_freq_hz: Supply (line) frequency in Hz.
18
+ poles: Number of magnetic poles.
19
+ sync_speed_rpm: Synchronous speed in RPM.
20
+ rotor_speed_rpm: Measured rotor speed in RPM.
21
+ slip: Per-unit slip (0–1).
22
+ rotor_freq_hz: Mechanical rotational frequency in Hz.
23
+ slip_freq_hz: Slip frequency in Hz (s × f_supply).
24
+ """
25
+
26
+ supply_freq_hz: float
27
+ poles: int
28
+ sync_speed_rpm: float
29
+ rotor_speed_rpm: float
30
+ slip: float
31
+ rotor_freq_hz: float
32
+ slip_freq_hz: float
33
+
34
+ def to_dict(self) -> dict:
35
+ return asdict(self)
36
+
37
+
38
+ def calculate_motor_parameters(
39
+ supply_freq_hz: float,
40
+ poles: int,
41
+ rotor_speed_rpm: float,
42
+ ) -> MotorParameters:
43
+ """Calculate motor operating parameters from nameplate / measured data.
44
+
45
+ Args:
46
+ supply_freq_hz: Supply frequency in Hz (e.g. 50 or 60).
47
+ poles: Number of poles (must be even, ≥ 2).
48
+ rotor_speed_rpm: Measured rotor speed in RPM.
49
+
50
+ Returns:
51
+ MotorParameters with all derived quantities.
52
+
53
+ Raises:
54
+ ValueError: If inputs are physically invalid.
55
+ """
56
+ if supply_freq_hz <= 0:
57
+ raise ValueError(f"Supply frequency must be > 0, got {supply_freq_hz}")
58
+ if poles < 2 or poles % 2 != 0:
59
+ raise ValueError(f"Poles must be even and ≥ 2, got {poles}")
60
+ if rotor_speed_rpm < 0:
61
+ raise ValueError(f"Rotor speed must be ≥ 0, got {rotor_speed_rpm}")
62
+
63
+ sync_speed_rpm = 120.0 * supply_freq_hz / poles
64
+
65
+ if rotor_speed_rpm > sync_speed_rpm:
66
+ raise ValueError(
67
+ f"Rotor speed ({rotor_speed_rpm} RPM) exceeds synchronous speed "
68
+ f"({sync_speed_rpm} RPM) — not valid for a motor (generator mode)."
69
+ )
70
+
71
+ slip = (sync_speed_rpm - rotor_speed_rpm) / sync_speed_rpm
72
+ rotor_freq_hz = rotor_speed_rpm / 60.0
73
+ slip_freq_hz = slip * supply_freq_hz
74
+
75
+ return MotorParameters(
76
+ supply_freq_hz=supply_freq_hz,
77
+ poles=poles,
78
+ sync_speed_rpm=sync_speed_rpm,
79
+ rotor_speed_rpm=rotor_speed_rpm,
80
+ slip=slip,
81
+ rotor_freq_hz=rotor_freq_hz,
82
+ slip_freq_hz=slip_freq_hz,
83
+ )
84
+
85
+
86
+ def calculate_fault_frequencies(
87
+ params: MotorParameters,
88
+ harmonics: int = 3,
89
+ ) -> dict:
90
+ """Calculate expected fault frequencies for common induction‑motor faults.
91
+
92
+ Computes characteristic harmonic frequencies for:
93
+ - Broken Rotor Bars (BRB): sidebands at (1 ± 2ks)·f_s
94
+ - Eccentricity (static/dynamic): f_s ± k·f_r
95
+ - Stator inter‑turn faults: f_s ± 2k·f_r
96
+ - Mixed eccentricity: n·f_r (for n = 1 … harmonics)
97
+
98
+ Args:
99
+ params: Motor operating parameters.
100
+ harmonics: Number of harmonic orders to compute (default 3).
101
+
102
+ Returns:
103
+ Dictionary with fault type keys → lists of expected frequencies [Hz].
104
+ """
105
+ fs = params.supply_freq_hz
106
+ s = params.slip
107
+ fr = params.rotor_freq_hz
108
+
109
+ # Broken Rotor Bars — sidebands (1 ± 2ks)·fs
110
+ brb_lower = [(1 - 2 * k * s) * fs for k in range(1, harmonics + 1)]
111
+ brb_upper = [(1 + 2 * k * s) * fs for k in range(1, harmonics + 1)]
112
+
113
+ # Eccentricity — fs ± k·fr
114
+ ecc_freqs = []
115
+ for k in range(1, harmonics + 1):
116
+ ecc_freqs.append({"harmonic_order": k, "lower": fs - k * fr, "upper": fs + k * fr})
117
+
118
+ # Stator inter‑turn — fs ± 2k·fr
119
+ stator_freqs = []
120
+ for k in range(1, harmonics + 1):
121
+ stator_freqs.append(
122
+ {"harmonic_order": k, "lower": fs - 2 * k * fr, "upper": fs + 2 * k * fr}
123
+ )
124
+
125
+ # Mixed eccentricity — n·fr
126
+ mixed_ecc = [k * fr for k in range(1, harmonics + 1)]
127
+
128
+ return {
129
+ "motor_parameters": params.to_dict(),
130
+ "broken_rotor_bars": {
131
+ "description": "Sidebands at (1 ± 2k·s)·f_supply due to rotor asymmetry",
132
+ "lower_sidebands_hz": brb_lower,
133
+ "upper_sidebands_hz": brb_upper,
134
+ },
135
+ "eccentricity": {
136
+ "description": "Sidebands at f_supply ± k·f_rotor due to air‑gap non‑uniformity",
137
+ "sidebands": ecc_freqs,
138
+ },
139
+ "stator_faults": {
140
+ "description": "Sidebands at f_supply ± 2k·f_rotor due to stator winding asymmetry",
141
+ "sidebands": stator_freqs,
142
+ },
143
+ "mixed_eccentricity": {
144
+ "description": "Components at n·f_rotor (pure rotational harmonics)",
145
+ "frequencies_hz": mixed_ecc,
146
+ },
147
+ }
@@ -1,180 +1,180 @@
1
- """Signal pre‑processing for MCSA.
2
-
3
- Offset removal, normalisation, windowing, and digital filtering of
4
- stator‑current time‑domain signals prior to spectral analysis.
5
- """
6
-
7
- from __future__ import annotations
8
-
9
- from typing import Literal
10
-
11
- import numpy as np
12
- from numpy.typing import NDArray
13
- from scipy import signal as sig
14
-
15
-
16
- def remove_dc_offset(x: NDArray[np.floating]) -> NDArray[np.floating]:
17
- """Remove the DC (mean) component from a signal."""
18
- return x - np.mean(x)
19
-
20
-
21
- def normalize_signal(
22
- x: NDArray[np.floating],
23
- nominal_current: float | None = None,
24
- ) -> NDArray[np.floating]:
25
- """Normalise signal amplitude.
26
-
27
- Args:
28
- x: Input signal.
29
- nominal_current: If provided, normalise to this value (p.u.).
30
- Otherwise normalise by RMS.
31
-
32
- Returns:
33
- Normalised signal array.
34
- """
35
- if nominal_current is not None and nominal_current > 0:
36
- return x / nominal_current
37
- rms = np.sqrt(np.mean(x ** 2))
38
- if rms == 0:
39
- return x
40
- return x / rms
41
-
42
-
43
- def apply_window(
44
- x: NDArray[np.floating],
45
- window: Literal["hann", "hamming", "blackman", "flattop", "rectangular"] = "hann",
46
- ) -> NDArray[np.floating]:
47
- """Apply a window function to the signal.
48
-
49
- Args:
50
- x: Input signal array.
51
- window: Window type name.
52
-
53
- Returns:
54
- Windowed signal.
55
- """
56
- n = len(x)
57
- if window == "rectangular":
58
- return x.copy()
59
- w = sig.get_window(window, n)
60
- return x * w
61
-
62
-
63
- def bandpass_filter(
64
- x: NDArray[np.floating],
65
- fs: float,
66
- low_hz: float,
67
- high_hz: float,
68
- order: int = 5,
69
- ) -> NDArray[np.floating]:
70
- """Apply a Butterworth bandpass filter.
71
-
72
- Args:
73
- x: Input signal.
74
- fs: Sampling frequency in Hz.
75
- low_hz: Lower cutoff frequency.
76
- high_hz: Upper cutoff frequency.
77
- order: Filter order.
78
-
79
- Returns:
80
- Bandpass‑filtered signal.
81
- """
82
- nyq = fs / 2.0
83
- if low_hz <= 0 or high_hz >= nyq or low_hz >= high_hz:
84
- raise ValueError(
85
- f"Invalid cutoff frequencies: low={low_hz}, high={high_hz}, Nyquist={nyq}"
86
- )
87
- sos = sig.butter(order, [low_hz / nyq, high_hz / nyq], btype="bandpass", output="sos")
88
- return sig.sosfiltfilt(sos, x)
89
-
90
-
91
- def notch_filter(
92
- x: NDArray[np.floating],
93
- fs: float,
94
- notch_freq_hz: float,
95
- quality_factor: float = 30.0,
96
- ) -> NDArray[np.floating]:
97
- """Apply a notch (band‑reject) filter at a specific frequency.
98
-
99
- Useful for removing strong supply harmonics or converter switching noise.
100
-
101
- Args:
102
- x: Input signal.
103
- fs: Sampling frequency in Hz.
104
- notch_freq_hz: Centre frequency to reject.
105
- quality_factor: Q factor (higher = narrower notch).
106
-
107
- Returns:
108
- Filtered signal.
109
- """
110
- b, a = sig.iirnotch(notch_freq_hz, quality_factor, fs)
111
- return sig.filtfilt(b, a, x)
112
-
113
-
114
- def lowpass_filter(
115
- x: NDArray[np.floating],
116
- fs: float,
117
- cutoff_hz: float,
118
- order: int = 5,
119
- ) -> NDArray[np.floating]:
120
- """Apply a Butterworth lowpass filter (anti‑aliasing or smoothing).
121
-
122
- Args:
123
- x: Input signal.
124
- fs: Sampling frequency in Hz.
125
- cutoff_hz: Cutoff frequency in Hz.
126
- order: Filter order.
127
-
128
- Returns:
129
- Lowpass‑filtered signal.
130
- """
131
- nyq = fs / 2.0
132
- if cutoff_hz <= 0 or cutoff_hz >= nyq:
133
- raise ValueError(f"Cutoff must be in (0, {nyq}), got {cutoff_hz}")
134
- sos = sig.butter(order, cutoff_hz / nyq, btype="low", output="sos")
135
- return sig.sosfiltfilt(sos, x)
136
-
137
-
138
- def preprocess_pipeline(
139
- x: NDArray[np.floating],
140
- fs: float,
141
- nominal_current: float | None = None,
142
- window: Literal["hann", "hamming", "blackman", "flattop", "rectangular"] = "hann",
143
- bandpass: tuple[float, float] | None = None,
144
- notch_freqs: list[float] | None = None,
145
- notch_q: float = 30.0,
146
- ) -> NDArray[np.floating]:
147
- """Full pre‑processing pipeline for a stator‑current signal.
148
-
149
- Steps (in order):
150
- 1. DC offset removal
151
- 2. Optional notch filtering (e.g. supply harmonics)
152
- 3. Optional bandpass filtering
153
- 4. Normalisation
154
- 5. Windowing
155
-
156
- Args:
157
- x: Raw current signal.
158
- fs: Sampling frequency in Hz.
159
- nominal_current: Nominal current for normalisation (A). None → RMS.
160
- window: Window function name.
161
- bandpass: Optional (low, high) Hz tuple for bandpass filtering.
162
- notch_freqs: Optional list of frequencies to notch out.
163
- notch_q: Q factor for notch filters.
164
-
165
- Returns:
166
- Pre‑processed signal ready for spectral analysis.
167
- """
168
- y = remove_dc_offset(x)
169
-
170
- if notch_freqs:
171
- for nf in notch_freqs:
172
- y = notch_filter(y, fs, nf, quality_factor=notch_q)
173
-
174
- if bandpass is not None:
175
- y = bandpass_filter(y, fs, bandpass[0], bandpass[1])
176
-
177
- y = normalize_signal(y, nominal_current)
178
- y = apply_window(y, window)
179
-
180
- return y
1
+ """Signal pre‑processing for MCSA.
2
+
3
+ Offset removal, normalisation, windowing, and digital filtering of
4
+ stator‑current time‑domain signals prior to spectral analysis.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Literal
10
+
11
+ import numpy as np
12
+ from numpy.typing import NDArray
13
+ from scipy import signal as sig
14
+
15
+
16
+ def remove_dc_offset(x: NDArray[np.floating]) -> NDArray[np.floating]:
17
+ """Remove the DC (mean) component from a signal."""
18
+ return x - np.mean(x)
19
+
20
+
21
+ def normalize_signal(
22
+ x: NDArray[np.floating],
23
+ nominal_current: float | None = None,
24
+ ) -> NDArray[np.floating]:
25
+ """Normalise signal amplitude.
26
+
27
+ Args:
28
+ x: Input signal.
29
+ nominal_current: If provided, normalise to this value (p.u.).
30
+ Otherwise normalise by RMS.
31
+
32
+ Returns:
33
+ Normalised signal array.
34
+ """
35
+ if nominal_current is not None and nominal_current > 0:
36
+ return x / nominal_current
37
+ rms = np.sqrt(np.mean(x ** 2))
38
+ if rms == 0:
39
+ return x
40
+ return x / rms
41
+
42
+
43
+ def apply_window(
44
+ x: NDArray[np.floating],
45
+ window: Literal["hann", "hamming", "blackman", "flattop", "rectangular"] = "hann",
46
+ ) -> NDArray[np.floating]:
47
+ """Apply a window function to the signal.
48
+
49
+ Args:
50
+ x: Input signal array.
51
+ window: Window type name.
52
+
53
+ Returns:
54
+ Windowed signal.
55
+ """
56
+ n = len(x)
57
+ if window == "rectangular":
58
+ return x.copy()
59
+ w = sig.get_window(window, n)
60
+ return x * w # type: ignore[return-value]
61
+
62
+
63
+ def bandpass_filter(
64
+ x: NDArray[np.floating],
65
+ fs: float,
66
+ low_hz: float,
67
+ high_hz: float,
68
+ order: int = 5,
69
+ ) -> NDArray[np.floating]:
70
+ """Apply a Butterworth bandpass filter.
71
+
72
+ Args:
73
+ x: Input signal.
74
+ fs: Sampling frequency in Hz.
75
+ low_hz: Lower cutoff frequency.
76
+ high_hz: Upper cutoff frequency.
77
+ order: Filter order.
78
+
79
+ Returns:
80
+ Bandpass‑filtered signal.
81
+ """
82
+ nyq = fs / 2.0
83
+ if low_hz <= 0 or high_hz >= nyq or low_hz >= high_hz:
84
+ raise ValueError(
85
+ f"Invalid cutoff frequencies: low={low_hz}, high={high_hz}, Nyquist={nyq}"
86
+ )
87
+ sos = sig.butter(order, [low_hz / nyq, high_hz / nyq], btype="bandpass", output="sos")
88
+ return sig.sosfiltfilt(sos, x)
89
+
90
+
91
+ def notch_filter(
92
+ x: NDArray[np.floating],
93
+ fs: float,
94
+ notch_freq_hz: float,
95
+ quality_factor: float = 30.0,
96
+ ) -> NDArray[np.floating]:
97
+ """Apply a notch (band‑reject) filter at a specific frequency.
98
+
99
+ Useful for removing strong supply harmonics or converter switching noise.
100
+
101
+ Args:
102
+ x: Input signal.
103
+ fs: Sampling frequency in Hz.
104
+ notch_freq_hz: Centre frequency to reject.
105
+ quality_factor: Q factor (higher = narrower notch).
106
+
107
+ Returns:
108
+ Filtered signal.
109
+ """
110
+ b, a = sig.iirnotch(notch_freq_hz, quality_factor, fs)
111
+ return sig.filtfilt(b, a, x)
112
+
113
+
114
+ def lowpass_filter(
115
+ x: NDArray[np.floating],
116
+ fs: float,
117
+ cutoff_hz: float,
118
+ order: int = 5,
119
+ ) -> NDArray[np.floating]:
120
+ """Apply a Butterworth lowpass filter (anti‑aliasing or smoothing).
121
+
122
+ Args:
123
+ x: Input signal.
124
+ fs: Sampling frequency in Hz.
125
+ cutoff_hz: Cutoff frequency in Hz.
126
+ order: Filter order.
127
+
128
+ Returns:
129
+ Lowpass‑filtered signal.
130
+ """
131
+ nyq = fs / 2.0
132
+ if cutoff_hz <= 0 or cutoff_hz >= nyq:
133
+ raise ValueError(f"Cutoff must be in (0, {nyq}), got {cutoff_hz}")
134
+ sos = sig.butter(order, cutoff_hz / nyq, btype="low", output="sos")
135
+ return sig.sosfiltfilt(sos, x)
136
+
137
+
138
+ def preprocess_pipeline(
139
+ x: NDArray[np.floating],
140
+ fs: float,
141
+ nominal_current: float | None = None,
142
+ window: Literal["hann", "hamming", "blackman", "flattop", "rectangular"] = "hann",
143
+ bandpass: tuple[float, float] | None = None,
144
+ notch_freqs: list[float] | None = None,
145
+ notch_q: float = 30.0,
146
+ ) -> NDArray[np.floating]:
147
+ """Full pre‑processing pipeline for a stator‑current signal.
148
+
149
+ Steps (in order):
150
+ 1. DC offset removal
151
+ 2. Optional notch filtering (e.g. supply harmonics)
152
+ 3. Optional bandpass filtering
153
+ 4. Normalisation
154
+ 5. Windowing
155
+
156
+ Args:
157
+ x: Raw current signal.
158
+ fs: Sampling frequency in Hz.
159
+ nominal_current: Nominal current for normalisation (A). None → RMS.
160
+ window: Window function name.
161
+ bandpass: Optional (low, high) Hz tuple for bandpass filtering.
162
+ notch_freqs: Optional list of frequencies to notch out.
163
+ notch_q: Q factor for notch filters.
164
+
165
+ Returns:
166
+ Pre‑processed signal ready for spectral analysis.
167
+ """
168
+ y = remove_dc_offset(x)
169
+
170
+ if notch_freqs:
171
+ for nf in notch_freqs:
172
+ y = notch_filter(y, fs, nf, quality_factor=notch_q)
173
+
174
+ if bandpass is not None:
175
+ y = bandpass_filter(y, fs, bandpass[0], bandpass[1])
176
+
177
+ y = normalize_signal(y, nominal_current)
178
+ y = apply_window(y, window)
179
+
180
+ return y