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,425 +1,424 @@
1
- """Fault detection and severity assessment for MCSA.
2
-
3
- Computes standardised fault indices from current spectra and provides
4
- severity classification based on configurable thresholds.
5
- """
6
-
7
- from __future__ import annotations
8
-
9
- import numpy as np
10
- from numpy.typing import NDArray
11
-
12
- from mcp_server_mcsa.analysis.motor import MotorParameters
13
- from mcp_server_mcsa.analysis.spectral import amplitude_at_frequency
14
-
15
-
16
- # ---------------------------------------------------------------------------
17
- # Severity thresholds (dB below fundamental)
18
- # ---------------------------------------------------------------------------
19
- # These are widely‑used empirical guidelines for induction motors.
20
- # They should be adapted to the specific motor/application.
21
- BRB_THRESHOLDS = {
22
- "healthy": -50.0, # dB — sideband ≤ -50 dB relative to fundamental
23
- "incipient": -45.0, # dB — early-stage fault
24
- "moderate": -40.0, # dB — developing fault
25
- "severe": -35.0, # dB — immediate action recommended
26
- }
27
-
28
- ECCENTRICITY_THRESHOLDS = {
29
- "healthy": -50.0,
30
- "incipient": -44.0,
31
- "moderate": -38.0,
32
- "severe": -30.0,
33
- }
34
-
35
-
36
- def _db_ratio(a: float, ref: float) -> float:
37
- """Compute 20·log10(a / ref), safe for zero values."""
38
- if ref <= 0 or a <= 0:
39
- return -np.inf
40
- return 20.0 * np.log10(a / ref)
41
-
42
-
43
- def _classify_severity(db_value: float, thresholds: dict[str, float]) -> str:
44
- """Classify severity from dB value and ordered thresholds."""
45
- if db_value <= thresholds["healthy"]:
46
- return "healthy"
47
- elif db_value <= thresholds["incipient"]:
48
- return "incipient"
49
- elif db_value <= thresholds["moderate"]:
50
- return "moderate"
51
- else:
52
- return "severe"
53
-
54
-
55
- # ---------------------------------------------------------------------------
56
- # Broken Rotor Bars
57
- # ---------------------------------------------------------------------------
58
-
59
- def brb_fault_index(
60
- freqs: NDArray[np.floating],
61
- amps: NDArray[np.floating],
62
- params: MotorParameters,
63
- tolerance_hz: float = 0.5,
64
- ) -> dict:
65
- """Compute the Broken Rotor Bar (BRB) fault index.
66
-
67
- The index is the ratio of the lower and upper sideband amplitudes
68
- at (1 ± 2s)·f_s to the fundamental amplitude, expressed in dB.
69
-
70
- Args:
71
- freqs: Frequency axis of the spectrum.
72
- amps: Amplitude values of the spectrum.
73
- params: Motor parameters (for slip and supply frequency).
74
- tolerance_hz: Frequency search tolerance.
75
-
76
- Returns:
77
- Dictionary with frequencies found, amplitudes, dB indices,
78
- and severity classification.
79
- """
80
- fs = params.supply_freq_hz
81
- s = params.slip
82
-
83
- f_lower = (1 - 2 * s) * fs
84
- f_upper = (1 + 2 * s) * fs
85
-
86
- fundamental = amplitude_at_frequency(freqs, amps, fs, tolerance_hz)
87
- lower_sb = amplitude_at_frequency(freqs, amps, f_lower, tolerance_hz)
88
- upper_sb = amplitude_at_frequency(freqs, amps, f_upper, tolerance_hz)
89
-
90
- a_fund = fundamental["amplitude"]
91
- a_lower = lower_sb["amplitude"]
92
- a_upper = upper_sb["amplitude"]
93
-
94
- db_lower = _db_ratio(a_lower, a_fund)
95
- db_upper = _db_ratio(a_upper, a_fund)
96
- db_combined = _db_ratio((a_lower + a_upper) / 2.0, a_fund) if a_fund > 0 else -np.inf
97
-
98
- severity = _classify_severity(max(db_lower, db_upper), BRB_THRESHOLDS)
99
-
100
- return {
101
- "fault_type": "broken_rotor_bars",
102
- "fundamental": {
103
- "expected_hz": fs,
104
- **fundamental,
105
- },
106
- "lower_sideband": {
107
- "expected_hz": round(f_lower, 4),
108
- **lower_sb,
109
- "db_relative": round(float(db_lower), 2),
110
- },
111
- "upper_sideband": {
112
- "expected_hz": round(f_upper, 4),
113
- **upper_sb,
114
- "db_relative": round(float(db_upper), 2),
115
- },
116
- "combined_index_db": round(float(db_combined), 2),
117
- "severity": severity,
118
- "thresholds_db": BRB_THRESHOLDS,
119
- }
120
-
121
-
122
- # ---------------------------------------------------------------------------
123
- # Eccentricity
124
- # ---------------------------------------------------------------------------
125
-
126
- def eccentricity_fault_index(
127
- freqs: NDArray[np.floating],
128
- amps: NDArray[np.floating],
129
- params: MotorParameters,
130
- harmonics: int = 3,
131
- tolerance_hz: float = 0.5,
132
- ) -> dict:
133
- """Compute eccentricity fault indices.
134
-
135
- Searches for sidebands at f_s ± k·f_r (k = 1 … harmonics).
136
-
137
- Args:
138
- freqs: Frequency axis.
139
- amps: Amplitude values.
140
- params: Motor parameters.
141
- harmonics: Number of harmonic orders.
142
- tolerance_hz: Frequency tolerance.
143
-
144
- Returns:
145
- Dictionary with sideband amplitudes, dB indices, severity.
146
- """
147
- fs = params.supply_freq_hz
148
- fr = params.rotor_freq_hz
149
- fund = amplitude_at_frequency(freqs, amps, fs, tolerance_hz)
150
- a_fund = fund["amplitude"]
151
-
152
- sidebands = []
153
- worst_db = -np.inf
154
-
155
- for k in range(1, harmonics + 1):
156
- f_lo = fs - k * fr
157
- f_hi = fs + k * fr
158
- sb_lo = amplitude_at_frequency(freqs, amps, f_lo, tolerance_hz)
159
- sb_hi = amplitude_at_frequency(freqs, amps, f_hi, tolerance_hz)
160
-
161
- db_lo = _db_ratio(sb_lo["amplitude"], a_fund)
162
- db_hi = _db_ratio(sb_hi["amplitude"], a_fund)
163
-
164
- worst_db = max(worst_db, db_lo, db_hi)
165
-
166
- sidebands.append({
167
- "harmonic_order": k,
168
- "lower": {
169
- "expected_hz": round(f_lo, 4),
170
- **sb_lo,
171
- "db_relative": round(float(db_lo), 2),
172
- },
173
- "upper": {
174
- "expected_hz": round(f_hi, 4),
175
- **sb_hi,
176
- "db_relative": round(float(db_hi), 2),
177
- },
178
- })
179
-
180
- severity = _classify_severity(float(worst_db), ECCENTRICITY_THRESHOLDS)
181
-
182
- return {
183
- "fault_type": "eccentricity",
184
- "fundamental": {
185
- "expected_hz": fs,
186
- **fund,
187
- },
188
- "sidebands": sidebands,
189
- "worst_sideband_db": round(float(worst_db), 2),
190
- "severity": severity,
191
- "thresholds_db": ECCENTRICITY_THRESHOLDS,
192
- }
193
-
194
-
195
- # ---------------------------------------------------------------------------
196
- # Stator inter-turn short circuit
197
- # ---------------------------------------------------------------------------
198
-
199
- def stator_fault_index(
200
- freqs: NDArray[np.floating],
201
- amps: NDArray[np.floating],
202
- params: MotorParameters,
203
- harmonics: int = 3,
204
- tolerance_hz: float = 0.5,
205
- ) -> dict:
206
- """Compute stator inter‑turn fault indices.
207
-
208
- Looks for sidebands at f_s ± 2k·f_r.
209
-
210
- Args:
211
- freqs: Frequency axis.
212
- amps: Amplitude values.
213
- params: Motor parameters.
214
- harmonics: Number of harmonic orders.
215
- tolerance_hz: Frequency tolerance.
216
-
217
- Returns:
218
- Dictionary with sideband analysis and severity.
219
- """
220
- fs = params.supply_freq_hz
221
- fr = params.rotor_freq_hz
222
- fund = amplitude_at_frequency(freqs, amps, fs, tolerance_hz)
223
- a_fund = fund["amplitude"]
224
-
225
- sidebands = []
226
- worst_db = -np.inf
227
-
228
- for k in range(1, harmonics + 1):
229
- f_lo = fs - 2 * k * fr
230
- f_hi = fs + 2 * k * fr
231
- sb_lo = amplitude_at_frequency(freqs, amps, f_lo, tolerance_hz)
232
- sb_hi = amplitude_at_frequency(freqs, amps, f_hi, tolerance_hz)
233
-
234
- db_lo = _db_ratio(sb_lo["amplitude"], a_fund)
235
- db_hi = _db_ratio(sb_hi["amplitude"], a_fund)
236
- worst_db = max(worst_db, db_lo, db_hi)
237
-
238
- sidebands.append({
239
- "harmonic_order": k,
240
- "lower": {
241
- "expected_hz": round(f_lo, 4),
242
- **sb_lo,
243
- "db_relative": round(float(db_lo), 2),
244
- },
245
- "upper": {
246
- "expected_hz": round(f_hi, 4),
247
- **sb_hi,
248
- "db_relative": round(float(db_hi), 2),
249
- },
250
- })
251
-
252
- severity = _classify_severity(float(worst_db), ECCENTRICITY_THRESHOLDS)
253
-
254
- return {
255
- "fault_type": "stator_inter_turn",
256
- "fundamental": {
257
- "expected_hz": fs,
258
- **fund,
259
- },
260
- "sidebands": sidebands,
261
- "worst_sideband_db": round(float(worst_db), 2),
262
- "severity": severity,
263
- "thresholds_db": ECCENTRICITY_THRESHOLDS,
264
- }
265
-
266
-
267
- # ---------------------------------------------------------------------------
268
- # Bearing faults (via stator current)
269
- # ---------------------------------------------------------------------------
270
-
271
- def bearing_fault_index(
272
- freqs: NDArray[np.floating],
273
- amps: NDArray[np.floating],
274
- supply_freq_hz: float,
275
- bearing_defect_freq_hz: float,
276
- defect_type: str = "bpfo",
277
- harmonics: int = 2,
278
- tolerance_hz: float = 0.5,
279
- ) -> dict:
280
- """Compute bearing fault indices from stator‑current spectrum.
281
-
282
- Bearing defects produce torque oscillations that modulate the current,
283
- creating sidebands at f_s ± k · f_defect.
284
-
285
- Args:
286
- freqs: Frequency axis.
287
- amps: Amplitude values.
288
- supply_freq_hz: Supply frequency in Hz.
289
- bearing_defect_freq_hz: Characteristic defect frequency in Hz
290
- (BPFO, BPFI, BSF, or FTF).
291
- defect_type: Label for the defect type.
292
- harmonics: Number of sideband orders.
293
- tolerance_hz: Frequency tolerance.
294
-
295
- Returns:
296
- Dictionary with sideband analysis.
297
- """
298
- fs = supply_freq_hz
299
- fd = bearing_defect_freq_hz
300
- fund = amplitude_at_frequency(freqs, amps, fs, tolerance_hz)
301
- a_fund = fund["amplitude"]
302
-
303
- sidebands = []
304
- worst_db = -np.inf
305
-
306
- for k in range(1, harmonics + 1):
307
- f_lo = fs - k * fd
308
- f_hi = fs + k * fd
309
- sb_lo = amplitude_at_frequency(freqs, amps, f_lo, tolerance_hz)
310
- sb_hi = amplitude_at_frequency(freqs, amps, f_hi, tolerance_hz)
311
-
312
- db_lo = _db_ratio(sb_lo["amplitude"], a_fund)
313
- db_hi = _db_ratio(sb_hi["amplitude"], a_fund)
314
- worst_db = max(worst_db, db_lo, db_hi)
315
-
316
- sidebands.append({
317
- "order": k,
318
- "lower": {
319
- "expected_hz": round(f_lo, 4),
320
- **sb_lo,
321
- "db_relative": round(float(db_lo), 2),
322
- },
323
- "upper": {
324
- "expected_hz": round(f_hi, 4),
325
- **sb_hi,
326
- "db_relative": round(float(db_hi), 2),
327
- },
328
- })
329
-
330
- return {
331
- "fault_type": f"bearing_{defect_type}",
332
- "defect_frequency_hz": round(fd, 4),
333
- "fundamental": {
334
- "expected_hz": fs,
335
- **fund,
336
- },
337
- "sidebands": sidebands,
338
- "worst_sideband_db": round(float(worst_db), 2),
339
- "note": (
340
- "Bearing signatures in stator current are typically weak. "
341
- "Confirm with envelope analysis or vibration measurements."
342
- ),
343
- }
344
-
345
-
346
- # ---------------------------------------------------------------------------
347
- # Band energy index (cavitation, load faults)
348
- # ---------------------------------------------------------------------------
349
-
350
- def band_energy_index(
351
- freqs: NDArray[np.floating],
352
- psd: NDArray[np.floating],
353
- centre_freq_hz: float,
354
- bandwidth_hz: float = 5.0,
355
- ) -> dict:
356
- """Compute the integrated spectral energy in a frequency band.
357
-
358
- Useful as generic fault/cavitation indicator: the energy in a band
359
- around the supply frequency or other characteristic region.
360
-
361
- Args:
362
- freqs: Frequency axis (from PSD).
363
- psd: PSD values.
364
- centre_freq_hz: Centre of the integration band.
365
- bandwidth_hz: Total bandwidth for integration.
366
-
367
- Returns:
368
- Dictionary with band energy, limits used.
369
- """
370
- low = centre_freq_hz - bandwidth_hz / 2.0
371
- high = centre_freq_hz + bandwidth_hz / 2.0
372
-
373
- mask = (freqs >= low) & (freqs <= high)
374
- if not np.any(mask):
375
- return {
376
- "centre_freq_hz": centre_freq_hz,
377
- "bandwidth_hz": bandwidth_hz,
378
- "band_energy": 0.0,
379
- "found": False,
380
- }
381
-
382
- df = float(freqs[1] - freqs[0]) if len(freqs) > 1 else 1.0
383
- energy = float(np.sum(psd[mask]) * df)
384
-
385
- return {
386
- "centre_freq_hz": centre_freq_hz,
387
- "bandwidth_hz": bandwidth_hz,
388
- "band_low_hz": round(low, 4),
389
- "band_high_hz": round(high, 4),
390
- "band_energy": energy,
391
- "found": True,
392
- }
393
-
394
-
395
- # ---------------------------------------------------------------------------
396
- # Statistical indices on envelope
397
- # ---------------------------------------------------------------------------
398
-
399
- def envelope_statistical_indices(
400
- envelope: NDArray[np.floating],
401
- ) -> dict:
402
- """Compute statistical indices of the envelope signal.
403
-
404
- Kurtosis, skewness, crest factor, and RMS — indicators of impulsive
405
- content from bearing or gear faults.
406
-
407
- Args:
408
- envelope: Amplitude envelope of the current signal.
409
-
410
- Returns:
411
- Dictionary of statistical indices.
412
- """
413
- from scipy.stats import kurtosis, skew
414
-
415
- rms = float(np.sqrt(np.mean(envelope ** 2)))
416
- peak = float(np.max(np.abs(envelope)))
417
- crest = peak / rms if rms > 0 else 0.0
418
-
419
- return {
420
- "rms": round(rms, 6),
421
- "peak": round(peak, 6),
422
- "crest_factor": round(crest, 4),
423
- "kurtosis": round(float(kurtosis(envelope, fisher=True)), 4),
424
- "skewness": round(float(skew(envelope)), 4),
425
- }
1
+ """Fault detection and severity assessment for MCSA.
2
+
3
+ Computes standardised fault indices from current spectra and provides
4
+ severity classification based on configurable thresholds.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import numpy as np
10
+ from numpy.typing import NDArray
11
+
12
+ from mcp_server_mcsa.analysis.motor import MotorParameters
13
+ from mcp_server_mcsa.analysis.spectral import amplitude_at_frequency
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # Severity thresholds (dB below fundamental)
17
+ # ---------------------------------------------------------------------------
18
+ # These are widely‑used empirical guidelines for induction motors.
19
+ # They should be adapted to the specific motor/application.
20
+ BRB_THRESHOLDS = {
21
+ "healthy": -50.0, # dB — sideband ≤ -50 dB relative to fundamental
22
+ "incipient": -45.0, # dB — early-stage fault
23
+ "moderate": -40.0, # dB — developing fault
24
+ "severe": -35.0, # dB — immediate action recommended
25
+ }
26
+
27
+ ECCENTRICITY_THRESHOLDS = {
28
+ "healthy": -50.0,
29
+ "incipient": -44.0,
30
+ "moderate": -38.0,
31
+ "severe": -30.0,
32
+ }
33
+
34
+
35
+ def _db_ratio(a: float, ref: float) -> float:
36
+ """Compute 20·log10(a / ref), safe for zero values."""
37
+ if ref <= 0 or a <= 0:
38
+ return -np.inf
39
+ return 20.0 * np.log10(a / ref)
40
+
41
+
42
+ def _classify_severity(db_value: float, thresholds: dict[str, float]) -> str:
43
+ """Classify severity from dB value and ordered thresholds."""
44
+ if db_value <= thresholds["healthy"]:
45
+ return "healthy"
46
+ elif db_value <= thresholds["incipient"]:
47
+ return "incipient"
48
+ elif db_value <= thresholds["moderate"]:
49
+ return "moderate"
50
+ else:
51
+ return "severe"
52
+
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # Broken Rotor Bars
56
+ # ---------------------------------------------------------------------------
57
+
58
+ def brb_fault_index(
59
+ freqs: NDArray[np.floating],
60
+ amps: NDArray[np.floating],
61
+ params: MotorParameters,
62
+ tolerance_hz: float = 0.5,
63
+ ) -> dict:
64
+ """Compute the Broken Rotor Bar (BRB) fault index.
65
+
66
+ The index is the ratio of the lower and upper sideband amplitudes
67
+ at (1 ± 2s)·f_s to the fundamental amplitude, expressed in dB.
68
+
69
+ Args:
70
+ freqs: Frequency axis of the spectrum.
71
+ amps: Amplitude values of the spectrum.
72
+ params: Motor parameters (for slip and supply frequency).
73
+ tolerance_hz: Frequency search tolerance.
74
+
75
+ Returns:
76
+ Dictionary with frequencies found, amplitudes, dB indices,
77
+ and severity classification.
78
+ """
79
+ fs = params.supply_freq_hz
80
+ s = params.slip
81
+
82
+ f_lower = (1 - 2 * s) * fs
83
+ f_upper = (1 + 2 * s) * fs
84
+
85
+ fundamental = amplitude_at_frequency(freqs, amps, fs, tolerance_hz)
86
+ lower_sb = amplitude_at_frequency(freqs, amps, f_lower, tolerance_hz)
87
+ upper_sb = amplitude_at_frequency(freqs, amps, f_upper, tolerance_hz)
88
+
89
+ a_fund = fundamental["amplitude"]
90
+ a_lower = lower_sb["amplitude"]
91
+ a_upper = upper_sb["amplitude"]
92
+
93
+ db_lower = _db_ratio(a_lower, a_fund)
94
+ db_upper = _db_ratio(a_upper, a_fund)
95
+ db_combined = _db_ratio((a_lower + a_upper) / 2.0, a_fund) if a_fund > 0 else -np.inf
96
+
97
+ severity = _classify_severity(max(db_lower, db_upper), BRB_THRESHOLDS)
98
+
99
+ return {
100
+ "fault_type": "broken_rotor_bars",
101
+ "fundamental": {
102
+ "expected_hz": fs,
103
+ **fundamental,
104
+ },
105
+ "lower_sideband": {
106
+ "expected_hz": round(f_lower, 4),
107
+ **lower_sb,
108
+ "db_relative": round(float(db_lower), 2),
109
+ },
110
+ "upper_sideband": {
111
+ "expected_hz": round(f_upper, 4),
112
+ **upper_sb,
113
+ "db_relative": round(float(db_upper), 2),
114
+ },
115
+ "combined_index_db": round(float(db_combined), 2),
116
+ "severity": severity,
117
+ "thresholds_db": BRB_THRESHOLDS,
118
+ }
119
+
120
+
121
+ # ---------------------------------------------------------------------------
122
+ # Eccentricity
123
+ # ---------------------------------------------------------------------------
124
+
125
+ def eccentricity_fault_index(
126
+ freqs: NDArray[np.floating],
127
+ amps: NDArray[np.floating],
128
+ params: MotorParameters,
129
+ harmonics: int = 3,
130
+ tolerance_hz: float = 0.5,
131
+ ) -> dict:
132
+ """Compute eccentricity fault indices.
133
+
134
+ Searches for sidebands at f_s ± k·f_r (k = 1 … harmonics).
135
+
136
+ Args:
137
+ freqs: Frequency axis.
138
+ amps: Amplitude values.
139
+ params: Motor parameters.
140
+ harmonics: Number of harmonic orders.
141
+ tolerance_hz: Frequency tolerance.
142
+
143
+ Returns:
144
+ Dictionary with sideband amplitudes, dB indices, severity.
145
+ """
146
+ fs = params.supply_freq_hz
147
+ fr = params.rotor_freq_hz
148
+ fund = amplitude_at_frequency(freqs, amps, fs, tolerance_hz)
149
+ a_fund = fund["amplitude"]
150
+
151
+ sidebands = []
152
+ worst_db = -np.inf
153
+
154
+ for k in range(1, harmonics + 1):
155
+ f_lo = fs - k * fr
156
+ f_hi = fs + k * fr
157
+ sb_lo = amplitude_at_frequency(freqs, amps, f_lo, tolerance_hz)
158
+ sb_hi = amplitude_at_frequency(freqs, amps, f_hi, tolerance_hz)
159
+
160
+ db_lo = _db_ratio(sb_lo["amplitude"], a_fund)
161
+ db_hi = _db_ratio(sb_hi["amplitude"], a_fund)
162
+
163
+ worst_db = max(worst_db, db_lo, db_hi)
164
+
165
+ sidebands.append({
166
+ "harmonic_order": k,
167
+ "lower": {
168
+ "expected_hz": round(f_lo, 4),
169
+ **sb_lo,
170
+ "db_relative": round(float(db_lo), 2),
171
+ },
172
+ "upper": {
173
+ "expected_hz": round(f_hi, 4),
174
+ **sb_hi,
175
+ "db_relative": round(float(db_hi), 2),
176
+ },
177
+ })
178
+
179
+ severity = _classify_severity(float(worst_db), ECCENTRICITY_THRESHOLDS)
180
+
181
+ return {
182
+ "fault_type": "eccentricity",
183
+ "fundamental": {
184
+ "expected_hz": fs,
185
+ **fund,
186
+ },
187
+ "sidebands": sidebands,
188
+ "worst_sideband_db": round(float(worst_db), 2),
189
+ "severity": severity,
190
+ "thresholds_db": ECCENTRICITY_THRESHOLDS,
191
+ }
192
+
193
+
194
+ # ---------------------------------------------------------------------------
195
+ # Stator inter-turn short circuit
196
+ # ---------------------------------------------------------------------------
197
+
198
+ def stator_fault_index(
199
+ freqs: NDArray[np.floating],
200
+ amps: NDArray[np.floating],
201
+ params: MotorParameters,
202
+ harmonics: int = 3,
203
+ tolerance_hz: float = 0.5,
204
+ ) -> dict:
205
+ """Compute stator inter‑turn fault indices.
206
+
207
+ Looks for sidebands at f_s ± 2k·f_r.
208
+
209
+ Args:
210
+ freqs: Frequency axis.
211
+ amps: Amplitude values.
212
+ params: Motor parameters.
213
+ harmonics: Number of harmonic orders.
214
+ tolerance_hz: Frequency tolerance.
215
+
216
+ Returns:
217
+ Dictionary with sideband analysis and severity.
218
+ """
219
+ fs = params.supply_freq_hz
220
+ fr = params.rotor_freq_hz
221
+ fund = amplitude_at_frequency(freqs, amps, fs, tolerance_hz)
222
+ a_fund = fund["amplitude"]
223
+
224
+ sidebands = []
225
+ worst_db = -np.inf
226
+
227
+ for k in range(1, harmonics + 1):
228
+ f_lo = fs - 2 * k * fr
229
+ f_hi = fs + 2 * k * fr
230
+ sb_lo = amplitude_at_frequency(freqs, amps, f_lo, tolerance_hz)
231
+ sb_hi = amplitude_at_frequency(freqs, amps, f_hi, tolerance_hz)
232
+
233
+ db_lo = _db_ratio(sb_lo["amplitude"], a_fund)
234
+ db_hi = _db_ratio(sb_hi["amplitude"], a_fund)
235
+ worst_db = max(worst_db, db_lo, db_hi)
236
+
237
+ sidebands.append({
238
+ "harmonic_order": k,
239
+ "lower": {
240
+ "expected_hz": round(f_lo, 4),
241
+ **sb_lo,
242
+ "db_relative": round(float(db_lo), 2),
243
+ },
244
+ "upper": {
245
+ "expected_hz": round(f_hi, 4),
246
+ **sb_hi,
247
+ "db_relative": round(float(db_hi), 2),
248
+ },
249
+ })
250
+
251
+ severity = _classify_severity(float(worst_db), ECCENTRICITY_THRESHOLDS)
252
+
253
+ return {
254
+ "fault_type": "stator_inter_turn",
255
+ "fundamental": {
256
+ "expected_hz": fs,
257
+ **fund,
258
+ },
259
+ "sidebands": sidebands,
260
+ "worst_sideband_db": round(float(worst_db), 2),
261
+ "severity": severity,
262
+ "thresholds_db": ECCENTRICITY_THRESHOLDS,
263
+ }
264
+
265
+
266
+ # ---------------------------------------------------------------------------
267
+ # Bearing faults (via stator current)
268
+ # ---------------------------------------------------------------------------
269
+
270
+ def bearing_fault_index(
271
+ freqs: NDArray[np.floating],
272
+ amps: NDArray[np.floating],
273
+ supply_freq_hz: float,
274
+ bearing_defect_freq_hz: float,
275
+ defect_type: str = "bpfo",
276
+ harmonics: int = 2,
277
+ tolerance_hz: float = 0.5,
278
+ ) -> dict:
279
+ """Compute bearing fault indices from stator‑current spectrum.
280
+
281
+ Bearing defects produce torque oscillations that modulate the current,
282
+ creating sidebands at f_s ± k · f_defect.
283
+
284
+ Args:
285
+ freqs: Frequency axis.
286
+ amps: Amplitude values.
287
+ supply_freq_hz: Supply frequency in Hz.
288
+ bearing_defect_freq_hz: Characteristic defect frequency in Hz
289
+ (BPFO, BPFI, BSF, or FTF).
290
+ defect_type: Label for the defect type.
291
+ harmonics: Number of sideband orders.
292
+ tolerance_hz: Frequency tolerance.
293
+
294
+ Returns:
295
+ Dictionary with sideband analysis.
296
+ """
297
+ fs = supply_freq_hz
298
+ fd = bearing_defect_freq_hz
299
+ fund = amplitude_at_frequency(freqs, amps, fs, tolerance_hz)
300
+ a_fund = fund["amplitude"]
301
+
302
+ sidebands = []
303
+ worst_db = -np.inf
304
+
305
+ for k in range(1, harmonics + 1):
306
+ f_lo = fs - k * fd
307
+ f_hi = fs + k * fd
308
+ sb_lo = amplitude_at_frequency(freqs, amps, f_lo, tolerance_hz)
309
+ sb_hi = amplitude_at_frequency(freqs, amps, f_hi, tolerance_hz)
310
+
311
+ db_lo = _db_ratio(sb_lo["amplitude"], a_fund)
312
+ db_hi = _db_ratio(sb_hi["amplitude"], a_fund)
313
+ worst_db = max(worst_db, db_lo, db_hi)
314
+
315
+ sidebands.append({
316
+ "order": k,
317
+ "lower": {
318
+ "expected_hz": round(f_lo, 4),
319
+ **sb_lo,
320
+ "db_relative": round(float(db_lo), 2),
321
+ },
322
+ "upper": {
323
+ "expected_hz": round(f_hi, 4),
324
+ **sb_hi,
325
+ "db_relative": round(float(db_hi), 2),
326
+ },
327
+ })
328
+
329
+ return {
330
+ "fault_type": f"bearing_{defect_type}",
331
+ "defect_frequency_hz": round(fd, 4),
332
+ "fundamental": {
333
+ "expected_hz": fs,
334
+ **fund,
335
+ },
336
+ "sidebands": sidebands,
337
+ "worst_sideband_db": round(float(worst_db), 2),
338
+ "note": (
339
+ "Bearing signatures in stator current are typically weak. "
340
+ "Confirm with envelope analysis or vibration measurements."
341
+ ),
342
+ }
343
+
344
+
345
+ # ---------------------------------------------------------------------------
346
+ # Band energy index (cavitation, load faults)
347
+ # ---------------------------------------------------------------------------
348
+
349
+ def band_energy_index(
350
+ freqs: NDArray[np.floating],
351
+ psd: NDArray[np.floating],
352
+ centre_freq_hz: float,
353
+ bandwidth_hz: float = 5.0,
354
+ ) -> dict:
355
+ """Compute the integrated spectral energy in a frequency band.
356
+
357
+ Useful as generic fault/cavitation indicator: the energy in a band
358
+ around the supply frequency or other characteristic region.
359
+
360
+ Args:
361
+ freqs: Frequency axis (from PSD).
362
+ psd: PSD values.
363
+ centre_freq_hz: Centre of the integration band.
364
+ bandwidth_hz: Total bandwidth for integration.
365
+
366
+ Returns:
367
+ Dictionary with band energy, limits used.
368
+ """
369
+ low = centre_freq_hz - bandwidth_hz / 2.0
370
+ high = centre_freq_hz + bandwidth_hz / 2.0
371
+
372
+ mask = (freqs >= low) & (freqs <= high)
373
+ if not np.any(mask):
374
+ return {
375
+ "centre_freq_hz": centre_freq_hz,
376
+ "bandwidth_hz": bandwidth_hz,
377
+ "band_energy": 0.0,
378
+ "found": False,
379
+ }
380
+
381
+ df = float(freqs[1] - freqs[0]) if len(freqs) > 1 else 1.0
382
+ energy = float(np.sum(psd[mask]) * df)
383
+
384
+ return {
385
+ "centre_freq_hz": centre_freq_hz,
386
+ "bandwidth_hz": bandwidth_hz,
387
+ "band_low_hz": round(low, 4),
388
+ "band_high_hz": round(high, 4),
389
+ "band_energy": energy,
390
+ "found": True,
391
+ }
392
+
393
+
394
+ # ---------------------------------------------------------------------------
395
+ # Statistical indices on envelope
396
+ # ---------------------------------------------------------------------------
397
+
398
+ def envelope_statistical_indices(
399
+ envelope: NDArray[np.floating],
400
+ ) -> dict:
401
+ """Compute statistical indices of the envelope signal.
402
+
403
+ Kurtosis, skewness, crest factor, and RMS — indicators of impulsive
404
+ content from bearing or gear faults.
405
+
406
+ Args:
407
+ envelope: Amplitude envelope of the current signal.
408
+
409
+ Returns:
410
+ Dictionary of statistical indices.
411
+ """
412
+ from scipy.stats import kurtosis, skew
413
+
414
+ rms = float(np.sqrt(np.mean(envelope ** 2)))
415
+ peak = float(np.max(np.abs(envelope)))
416
+ crest = peak / rms if rms > 0 else 0.0
417
+
418
+ return {
419
+ "rms": round(rms, 6),
420
+ "peak": round(peak, 6),
421
+ "crest_factor": round(crest, 4),
422
+ "kurtosis": round(float(kurtosis(envelope, fisher=True)), 4),
423
+ "skewness": round(float(skew(envelope)), 4),
424
+ }