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.
mcp_server_mcsa/server.py CHANGED
@@ -1,955 +1,954 @@
1
- """MCP Server for Motor Current Signature Analysis (MCSA).
2
-
3
- Provides tools for spectral analysis, fault frequency computation,
4
- fault detection, and diagnostic assessment of electric‑motor stator
5
- currents via the Model Context Protocol.
6
- """
7
-
8
- from __future__ import annotations
9
-
10
- import json
11
- from typing import Annotated
12
-
13
- import numpy as np
14
- from mcp.server.fastmcp import FastMCP
15
- from pydantic import Field
16
-
17
- from mcp_server_mcsa.analysis.bearing import (
18
- BearingGeometry,
19
- bearing_current_sidebands,
20
- calculate_bearing_defect_frequencies,
21
- )
22
- from mcp_server_mcsa.analysis.envelope import (
23
- envelope_spectrum,
24
- hilbert_envelope,
25
- )
26
- from mcp_server_mcsa.analysis.fault_detection import (
27
- band_energy_index,
28
- bearing_fault_index,
29
- brb_fault_index,
30
- eccentricity_fault_index,
31
- envelope_statistical_indices,
32
- stator_fault_index,
33
- )
34
- from mcp_server_mcsa.analysis.motor import (
35
- calculate_fault_frequencies,
36
- calculate_motor_parameters,
37
- )
38
- from mcp_server_mcsa.analysis.preprocessing import preprocess_pipeline
39
- from mcp_server_mcsa.analysis.spectral import (
40
- amplitude_at_frequency,
41
- compute_fft_spectrum,
42
- compute_psd,
43
- detect_peaks,
44
- )
45
- from mcp_server_mcsa.analysis.file_io import (
46
- get_signal_file_info,
47
- load_signal,
48
- )
49
- from mcp_server_mcsa.analysis.test_signal import generate_test_signal
50
- from mcp_server_mcsa.analysis.timefreq import (
51
- compute_stft,
52
- track_frequency_over_time,
53
- )
54
-
55
- # ---------------------------------------------------------------------------
56
- # Server instance
57
- # ---------------------------------------------------------------------------
58
-
59
- mcp = FastMCP(
60
- "mcsa",
61
- instructions=(
62
- "Motor Current Signature Analysis (MCSA) server. "
63
- "Provides tools for spectral analysis of electric‑motor stator "
64
- "currents to detect rotor, stator, bearing, and load faults. "
65
- "Typical workflow: "
66
- "(1) load a real signal from file (CSV/WAV/NPY) with load_signal_from_file "
67
- " or generate a synthetic one with generate_test_current_signal, "
68
- "(2) compute motor parameters from nameplate data, "
69
- "(3) preprocess the signal, (4) compute the spectrum, "
70
- "(5) detect faults using the appropriate fault‑detection tools, "
71
- "(6) generate a full diagnostic report with run_full_diagnosis. "
72
- "For real‑world signals use inspect_signal_file first to check "
73
- "the file format, then load_signal_from_file to read the data. "
74
- "For a one‑shot analysis from file use diagnose_from_file."
75
- ),
76
- )
77
-
78
-
79
- # ===================================================================
80
- # RESOURCE: Fault Signatures Reference
81
- # ===================================================================
82
-
83
- FAULT_SIGNATURES_REFERENCE = """# MCSA Fault Signature Reference
84
-
85
- ## Broken Rotor Bars (BRB)
86
- - **Signature**: Sidebands at (1 ± 2s)·f_s around the supply fundamental
87
- - **Index**: dB ratio of sideband amplitude to fundamental
88
- - **Thresholds** (dB below fundamental):
89
- - Healthy: -50 dB
90
- - Incipient: -50 to -45 dB
91
- - Moderate: -45 to -40 dB
92
- - Severe: > -35 dB
93
- - **Notes**: More visible at medium–high load; higher harmonics at (1 ± 2ks)·f_s
94
-
95
- ## Eccentricity (Static / Dynamic)
96
- - **Signature**: Sidebands at f_s ± f_r (rotor frequency multiples)
97
- - **Static eccentricity**: produces components at f_s ± f_r
98
- - **Dynamic eccentricity**: produces components at f_s ± k·f_r, varying with load
99
- - **Mixed eccentricity**: components at n·f_r (pure rotational harmonics)
100
-
101
- ## Stator Inter-Turn Short Circuit
102
- - **Signature**: Sidebands at f_s ± 2k·f_r
103
- - **Notes**: May also increase negative-sequence current component;
104
- distinguish from supply unbalance by checking load dependency
105
-
106
- ## Bearing Defects
107
- - **Signature**: Sidebands at f_s ± k·f_defect where f_defect is BPFO/BPFI/BSF/FTF
108
- - **Defect frequencies** (normalised to shaft speed):
109
- - BPFO = (n/2)·(1 - d/D·cos α)
110
- - BPFI = (n/2)·(1 + d/D·cos α)
111
- - BSF = (D/2d)·(1 - (d/D·cos α)²)
112
- - FTF = (1/2)·(1 - d/D·cos α)
113
- - **Notes**: Weak in stator current; confirm with envelope analysis or vibration data
114
-
115
- ## Load Faults (Cavitation, Misalignment)
116
- - **Signature**: Broadband energy increase around f_s ("foot" pattern in PSD)
117
- - **Index**: Band energy integration around the supply frequency
118
- """
119
-
120
-
121
- @mcp.resource("mcsa://fault-signatures")
122
- def fault_signatures_resource() -> str:
123
- """Reference table of MCSA fault signatures, frequencies, and empirical thresholds."""
124
- return FAULT_SIGNATURES_REFERENCE
125
-
126
-
127
- # ===================================================================
128
- # TOOL 1: Calculate Motor Parameters
129
- # ===================================================================
130
-
131
- @mcp.tool()
132
- def calculate_motor_params(
133
- supply_freq_hz: Annotated[float, Field(description="Supply (line) frequency in Hz, e.g. 50 or 60")],
134
- poles: Annotated[int, Field(description="Number of magnetic poles (even, ≥ 2)")],
135
- rotor_speed_rpm: Annotated[float, Field(description="Measured rotor speed in RPM")],
136
- ) -> str:
137
- """Calculate motor operating parameters from nameplate and measured data.
138
-
139
- Computes synchronous speed, slip, rotor frequency, and slip frequency.
140
- These parameters are required inputs for fault frequency calculations.
141
- """
142
- params = calculate_motor_parameters(supply_freq_hz, poles, rotor_speed_rpm)
143
- return json.dumps(params.to_dict(), indent=2)
144
-
145
-
146
- # ===================================================================
147
- # TOOL 2: Calculate Fault Frequencies
148
- # ===================================================================
149
-
150
- @mcp.tool()
151
- def compute_fault_frequencies(
152
- supply_freq_hz: Annotated[float, Field(description="Supply frequency in Hz")],
153
- poles: Annotated[int, Field(description="Number of magnetic poles")],
154
- rotor_speed_rpm: Annotated[float, Field(description="Rotor speed in RPM")],
155
- harmonics: Annotated[int, Field(description="Number of harmonic orders to compute", default=3)] = 3,
156
- ) -> str:
157
- """Calculate expected fault frequencies for common induction-motor faults.
158
-
159
- Computes characteristic frequencies for broken rotor bars, eccentricity,
160
- stator faults, and mixed eccentricity based on motor operating parameters.
161
- Use these frequencies to know WHERE to look in the current spectrum.
162
- """
163
- params = calculate_motor_parameters(supply_freq_hz, poles, rotor_speed_rpm)
164
- result = calculate_fault_frequencies(params, harmonics)
165
- return json.dumps(result, indent=2, default=str)
166
-
167
-
168
- # ===================================================================
169
- # TOOL 3: Calculate Bearing Defect Frequencies
170
- # ===================================================================
171
-
172
- @mcp.tool()
173
- def compute_bearing_frequencies(
174
- n_balls: Annotated[int, Field(description="Number of rolling elements in the bearing")],
175
- ball_dia_mm: Annotated[float, Field(description="Ball (roller) diameter in mm")],
176
- pitch_dia_mm: Annotated[float, Field(description="Pitch (cage) diameter in mm")],
177
- contact_angle_deg: Annotated[float, Field(description="Contact angle in degrees", default=0.0)] = 0.0,
178
- shaft_speed_rpm: Annotated[float, Field(description="Shaft speed in RPM (optional, for absolute Hz)", default=0.0)] = 0.0,
179
- supply_freq_hz: Annotated[float, Field(description="Supply frequency in Hz (optional, for current sidebands)", default=0.0)] = 0.0,
180
- ) -> str:
181
- """Calculate bearing characteristic defect frequencies (BPFO, BPFI, BSF, FTF).
182
-
183
- Returns normalised frequencies (multiples of shaft speed) and, if shaft
184
- speed is provided, absolute frequencies in Hz. If supply frequency is
185
- also given, computes expected stator-current sidebands.
186
- """
187
- geom = BearingGeometry(n_balls, ball_dia_mm, pitch_dia_mm, contact_angle_deg)
188
- defects = calculate_bearing_defect_frequencies(geom)
189
-
190
- result: dict = {
191
- "bearing_geometry": {
192
- "n_balls": n_balls,
193
- "ball_dia_mm": ball_dia_mm,
194
- "pitch_dia_mm": pitch_dia_mm,
195
- "contact_angle_deg": contact_angle_deg,
196
- },
197
- "normalised_to_shaft_speed": defects.to_dict(),
198
- }
199
-
200
- if shaft_speed_rpm > 0:
201
- shaft_freq = shaft_speed_rpm / 60.0
202
- result["absolute_frequencies_hz"] = defects.absolute(shaft_freq)
203
-
204
- if supply_freq_hz > 0:
205
- result["current_sidebands"] = bearing_current_sidebands(
206
- defects, shaft_freq, supply_freq_hz
207
- )
208
-
209
- return json.dumps(result, indent=2)
210
-
211
-
212
- # ===================================================================
213
- # TOOL 4: Preprocess Signal
214
- # ===================================================================
215
-
216
- @mcp.tool()
217
- def preprocess_signal(
218
- signal: Annotated[list[float], Field(description="Raw current signal as a list of amplitude values")],
219
- sampling_freq_hz: Annotated[float, Field(description="Sampling frequency in Hz")],
220
- nominal_current: Annotated[float | None, Field(description="Nominal current for normalisation (A). Omit for RMS normalisation", default=None)] = None,
221
- window: Annotated[str, Field(description="Window function: hann, hamming, blackman, flattop, rectangular", default="hann")] = "hann",
222
- bandpass_low_hz: Annotated[float | None, Field(description="Lower bandpass cutoff in Hz (optional)", default=None)] = None,
223
- bandpass_high_hz: Annotated[float | None, Field(description="Upper bandpass cutoff in Hz (optional)", default=None)] = None,
224
- notch_freqs_hz: Annotated[list[float] | None, Field(description="Frequencies to notch-filter (optional)", default=None)] = None,
225
- ) -> str:
226
- """Preprocess a stator-current signal for spectral analysis.
227
-
228
- Applies (in order): DC offset removal notch filtering → bandpass
229
- filtering → normalisation → windowing. Returns the preprocessed signal.
230
- """
231
- x = np.array(signal, dtype=np.float64)
232
-
233
- bandpass = None
234
- if bandpass_low_hz is not None and bandpass_high_hz is not None:
235
- bandpass = (bandpass_low_hz, bandpass_high_hz)
236
-
237
- y = preprocess_pipeline(
238
- x, sampling_freq_hz,
239
- nominal_current=nominal_current,
240
- window=window, # type: ignore[arg-type]
241
- bandpass=bandpass,
242
- notch_freqs=notch_freqs_hz,
243
- )
244
-
245
- return json.dumps({
246
- "preprocessed_signal": y.tolist(),
247
- "n_samples": len(y),
248
- "sampling_freq_hz": sampling_freq_hz,
249
- "steps_applied": [
250
- "dc_offset_removal",
251
- *(["notch_filter"] if notch_freqs_hz else []),
252
- *(["bandpass_filter"] if bandpass else []),
253
- "normalisation",
254
- f"window_{window}",
255
- ],
256
- })
257
-
258
-
259
- # ===================================================================
260
- # TOOL 5: Compute FFT Spectrum
261
- # ===================================================================
262
-
263
- @mcp.tool()
264
- def compute_spectrum(
265
- signal: Annotated[list[float], Field(description="Time-domain signal (preprocessed or raw)")],
266
- sampling_freq_hz: Annotated[float, Field(description="Sampling frequency in Hz")],
267
- n_fft: Annotated[int | None, Field(description="FFT length (zero-padding). Omit for auto", default=None)] = None,
268
- max_freq_hz: Annotated[float | None, Field(description="Maximum frequency to return (Hz). Omit for full range", default=None)] = None,
269
- ) -> str:
270
- """Compute the single-sided amplitude spectrum (FFT) of a current signal.
271
-
272
- Returns frequency and amplitude arrays. Optionally limit the maximum
273
- frequency returned to reduce output size.
274
- """
275
- x = np.array(signal, dtype=np.float64)
276
- freqs, amps = compute_fft_spectrum(x, sampling_freq_hz, n_fft=n_fft, sided="one")
277
-
278
- if max_freq_hz is not None:
279
- mask = freqs <= max_freq_hz
280
- freqs = freqs[mask]
281
- amps = amps[mask]
282
-
283
- return json.dumps({
284
- "frequencies_hz": freqs.tolist(),
285
- "amplitudes": amps.tolist(),
286
- "n_bins": len(freqs),
287
- "freq_resolution_hz": round(float(freqs[1] - freqs[0]), 6) if len(freqs) > 1 else 0,
288
- })
289
-
290
-
291
- # ===================================================================
292
- # TOOL 6: Compute PSD
293
- # ===================================================================
294
-
295
- @mcp.tool()
296
- def compute_power_spectral_density(
297
- signal: Annotated[list[float], Field(description="Time-domain signal")],
298
- sampling_freq_hz: Annotated[float, Field(description="Sampling frequency in Hz")],
299
- nperseg: Annotated[int | None, Field(description="FFT segment length. Omit for auto", default=None)] = None,
300
- max_freq_hz: Annotated[float | None, Field(description="Maximum frequency to return (Hz)", default=None)] = None,
301
- ) -> str:
302
- """Compute Power Spectral Density using Welch's method.
303
-
304
- Better for noisy signals and trend analysis than a raw FFT.
305
- Returns frequency and PSD arrays.
306
- """
307
- x = np.array(signal, dtype=np.float64)
308
- freqs, psd = compute_psd(x, sampling_freq_hz, nperseg=nperseg)
309
-
310
- if max_freq_hz is not None:
311
- mask = freqs <= max_freq_hz
312
- freqs = freqs[mask]
313
- psd = psd[mask]
314
-
315
- return json.dumps({
316
- "frequencies_hz": freqs.tolist(),
317
- "psd_values": psd.tolist(),
318
- "n_bins": len(freqs),
319
- "freq_resolution_hz": round(float(freqs[1] - freqs[0]), 6) if len(freqs) > 1 else 0,
320
- })
321
-
322
-
323
- # ===================================================================
324
- # TOOL 7: Detect Spectral Peaks
325
- # ===================================================================
326
-
327
- @mcp.tool()
328
- def find_spectrum_peaks(
329
- frequencies_hz: Annotated[list[float], Field(description="Frequency axis from spectrum/PSD")],
330
- amplitudes: Annotated[list[float], Field(description="Amplitude or PSD values")],
331
- min_height: Annotated[float | None, Field(description="Minimum peak height", default=None)] = None,
332
- min_prominence: Annotated[float | None, Field(description="Minimum peak prominence", default=None)] = None,
333
- min_distance_hz: Annotated[float | None, Field(description="Min distance between peaks in Hz", default=None)] = None,
334
- freq_low_hz: Annotated[float | None, Field(description="Lower frequency bound for search", default=None)] = None,
335
- freq_high_hz: Annotated[float | None, Field(description="Upper frequency bound for search", default=None)] = None,
336
- max_peaks: Annotated[int, Field(description="Maximum number of peaks to return", default=20)] = 20,
337
- ) -> str:
338
- """Detect peaks in a frequency spectrum.
339
-
340
- Returns a list of peaks sorted by amplitude (highest first) with
341
- frequency, amplitude, and prominence values.
342
- """
343
- freqs = np.array(frequencies_hz, dtype=np.float64)
344
- amps = np.array(amplitudes, dtype=np.float64)
345
-
346
- freq_range = None
347
- if freq_low_hz is not None and freq_high_hz is not None:
348
- freq_range = (freq_low_hz, freq_high_hz)
349
-
350
- peaks = detect_peaks(
351
- freqs, amps,
352
- height=min_height,
353
- prominence=min_prominence,
354
- distance_hz=min_distance_hz,
355
- freq_range=freq_range,
356
- max_peaks=max_peaks,
357
- )
358
-
359
- return json.dumps({"peaks": peaks, "n_peaks_found": len(peaks)}, indent=2)
360
-
361
-
362
- # ===================================================================
363
- # TOOL 8: Detect Broken Rotor Bars
364
- # ===================================================================
365
-
366
- @mcp.tool()
367
- def detect_broken_rotor_bars(
368
- frequencies_hz: Annotated[list[float], Field(description="Spectrum frequency axis (Hz)")],
369
- amplitudes: Annotated[list[float], Field(description="Spectrum amplitude values")],
370
- supply_freq_hz: Annotated[float, Field(description="Supply frequency in Hz")],
371
- poles: Annotated[int, Field(description="Number of poles")],
372
- rotor_speed_rpm: Annotated[float, Field(description="Rotor speed in RPM")],
373
- tolerance_hz: Annotated[float, Field(description="Frequency search tolerance in Hz", default=0.5)] = 0.5,
374
- ) -> str:
375
- """Detect broken rotor bar faults from current spectrum.
376
-
377
- Computes the BRB fault index by measuring sidebands at (1 ± 2s)·f_s
378
- relative to the fundamental. Returns severity classification:
379
- healthy / incipient / moderate / severe.
380
- """
381
- params = calculate_motor_parameters(supply_freq_hz, poles, rotor_speed_rpm)
382
- freqs = np.array(frequencies_hz, dtype=np.float64)
383
- amps = np.array(amplitudes, dtype=np.float64)
384
-
385
- result = brb_fault_index(freqs, amps, params, tolerance_hz)
386
- return json.dumps(result, indent=2, default=str)
387
-
388
-
389
- # ===================================================================
390
- # TOOL 9: Detect Eccentricity
391
- # ===================================================================
392
-
393
- @mcp.tool()
394
- def detect_eccentricity(
395
- frequencies_hz: Annotated[list[float], Field(description="Spectrum frequency axis (Hz)")],
396
- amplitudes: Annotated[list[float], Field(description="Spectrum amplitude values")],
397
- supply_freq_hz: Annotated[float, Field(description="Supply frequency in Hz")],
398
- poles: Annotated[int, Field(description="Number of poles")],
399
- rotor_speed_rpm: Annotated[float, Field(description="Rotor speed in RPM")],
400
- harmonics: Annotated[int, Field(description="Number of harmonic orders to check", default=3)] = 3,
401
- tolerance_hz: Annotated[float, Field(description="Frequency search tolerance in Hz", default=0.5)] = 0.5,
402
- ) -> str:
403
- """Detect air-gap eccentricity faults from current spectrum.
404
-
405
- Analyses sidebands at f_s ± k·f_r for static and dynamic eccentricity.
406
- Returns severity classification.
407
- """
408
- params = calculate_motor_parameters(supply_freq_hz, poles, rotor_speed_rpm)
409
- freqs = np.array(frequencies_hz, dtype=np.float64)
410
- amps = np.array(amplitudes, dtype=np.float64)
411
-
412
- result = eccentricity_fault_index(freqs, amps, params, harmonics, tolerance_hz)
413
- return json.dumps(result, indent=2, default=str)
414
-
415
-
416
- # ===================================================================
417
- # TOOL 10: Detect Stator Faults
418
- # ===================================================================
419
-
420
- @mcp.tool()
421
- def detect_stator_faults(
422
- frequencies_hz: Annotated[list[float], Field(description="Spectrum frequency axis (Hz)")],
423
- amplitudes: Annotated[list[float], Field(description="Spectrum amplitude values")],
424
- supply_freq_hz: Annotated[float, Field(description="Supply frequency in Hz")],
425
- poles: Annotated[int, Field(description="Number of poles")],
426
- rotor_speed_rpm: Annotated[float, Field(description="Rotor speed in RPM")],
427
- harmonics: Annotated[int, Field(description="Number of harmonic orders", default=3)] = 3,
428
- tolerance_hz: Annotated[float, Field(description="Frequency search tolerance in Hz", default=0.5)] = 0.5,
429
- ) -> str:
430
- """Detect stator inter-turn short circuit faults from current spectrum.
431
-
432
- Analyses sidebands at f_s ± 2k·f_r caused by stator winding asymmetry.
433
- """
434
- params = calculate_motor_parameters(supply_freq_hz, poles, rotor_speed_rpm)
435
- freqs = np.array(frequencies_hz, dtype=np.float64)
436
- amps = np.array(amplitudes, dtype=np.float64)
437
-
438
- result = stator_fault_index(freqs, amps, params, harmonics, tolerance_hz)
439
- return json.dumps(result, indent=2, default=str)
440
-
441
-
442
- # ===================================================================
443
- # TOOL 11: Detect Bearing Faults
444
- # ===================================================================
445
-
446
- @mcp.tool()
447
- def detect_bearing_faults(
448
- frequencies_hz: Annotated[list[float], Field(description="Spectrum frequency axis (Hz)")],
449
- amplitudes: Annotated[list[float], Field(description="Spectrum amplitude values")],
450
- supply_freq_hz: Annotated[float, Field(description="Supply frequency in Hz")],
451
- bearing_defect_freq_hz: Annotated[float, Field(description="Bearing characteristic defect frequency in Hz (BPFO, BPFI, BSF, or FTF)")],
452
- defect_type: Annotated[str, Field(description="Defect type label: bpfo, bpfi, bsf, or ftf", default="bpfo")] = "bpfo",
453
- harmonics: Annotated[int, Field(description="Number of sideband orders", default=2)] = 2,
454
- tolerance_hz: Annotated[float, Field(description="Frequency search tolerance in Hz", default=0.5)] = 0.5,
455
- ) -> str:
456
- """Detect bearing defect signatures in the stator-current spectrum.
457
-
458
- Bearing faults modulate motor torque, creating sidebands at
459
- f_s ± k·f_defect. Note: bearing signatures in current are typically
460
- weak; envelope analysis or vibration data can improve detection.
461
- """
462
- freqs = np.array(frequencies_hz, dtype=np.float64)
463
- amps = np.array(amplitudes, dtype=np.float64)
464
-
465
- result = bearing_fault_index(
466
- freqs, amps, supply_freq_hz,
467
- bearing_defect_freq_hz, defect_type, harmonics, tolerance_hz,
468
- )
469
- return json.dumps(result, indent=2, default=str)
470
-
471
-
472
- # ===================================================================
473
- # TOOL 12: Compute Envelope Spectrum
474
- # ===================================================================
475
-
476
- @mcp.tool()
477
- def compute_envelope_spectrum(
478
- signal: Annotated[list[float], Field(description="Time-domain current signal")],
479
- sampling_freq_hz: Annotated[float, Field(description="Sampling frequency in Hz")],
480
- bandpass_low_hz: Annotated[float | None, Field(description="Lower bandpass cutoff before envelope (optional)", default=None)] = None,
481
- bandpass_high_hz: Annotated[float | None, Field(description="Upper bandpass cutoff before envelope (optional)", default=None)] = None,
482
- max_freq_hz: Annotated[float | None, Field(description="Max frequency to return (Hz)", default=None)] = None,
483
- ) -> str:
484
- """Compute the envelope spectrum of a current signal.
485
-
486
- Uses the Hilbert transform to extract the amplitude envelope, then
487
- computes its FFT. Useful for detecting bearing and mechanical faults
488
- that modulate the current at low frequencies.
489
- """
490
- x = np.array(signal, dtype=np.float64)
491
-
492
- bp = None
493
- if bandpass_low_hz is not None and bandpass_high_hz is not None:
494
- bp = (bandpass_low_hz, bandpass_high_hz)
495
-
496
- freqs, amps = envelope_spectrum(x, sampling_freq_hz, bandpass=bp)
497
-
498
- if max_freq_hz is not None:
499
- mask = freqs <= max_freq_hz
500
- freqs = freqs[mask]
501
- amps = amps[mask]
502
-
503
- # Also compute statistical indices of the envelope
504
- env = hilbert_envelope(x)
505
- env = env - np.mean(env)
506
- stats = envelope_statistical_indices(env)
507
-
508
- return json.dumps({
509
- "frequencies_hz": freqs.tolist(),
510
- "amplitudes": amps.tolist(),
511
- "n_bins": len(freqs),
512
- "envelope_statistics": stats,
513
- })
514
-
515
-
516
- # ===================================================================
517
- # TOOL 13: Compute Band Energy
518
- # ===================================================================
519
-
520
- @mcp.tool()
521
- def compute_band_energy(
522
- frequencies_hz: Annotated[list[float], Field(description="PSD frequency axis (Hz)")],
523
- psd_values: Annotated[list[float], Field(description="PSD values")],
524
- centre_freq_hz: Annotated[float, Field(description="Centre of the frequency band (Hz)")],
525
- bandwidth_hz: Annotated[float, Field(description="Total bandwidth for energy integration (Hz)", default=5.0)] = 5.0,
526
- ) -> str:
527
- """Compute the integrated spectral energy in a frequency band.
528
-
529
- Useful as a generic fault/cavitation indicator — measures the energy
530
- concentration around a characteristic frequency in the PSD.
531
- """
532
- freqs = np.array(frequencies_hz, dtype=np.float64)
533
- psd = np.array(psd_values, dtype=np.float64)
534
-
535
- result = band_energy_index(freqs, psd, centre_freq_hz, bandwidth_hz)
536
- return json.dumps(result, indent=2)
537
-
538
-
539
- # ===================================================================
540
- # TOOL 14: Compute STFT
541
- # ===================================================================
542
-
543
- @mcp.tool()
544
- def compute_time_frequency(
545
- signal: Annotated[list[float], Field(description="Time-domain current signal")],
546
- sampling_freq_hz: Annotated[float, Field(description="Sampling frequency in Hz")],
547
- nperseg: Annotated[int, Field(description="Window length per segment (samples)", default=256)] = 256,
548
- target_freq_hz: Annotated[float | None, Field(description="Frequency to track over time (Hz). If provided, returns amplitude vs time for that frequency", default=None)] = None,
549
- tolerance_hz: Annotated[float, Field(description="Tolerance for frequency tracking (Hz)", default=2.0)] = 2.0,
550
- ) -> str:
551
- """Compute Short-Time Fourier Transform (STFT) for time-frequency analysis.
552
-
553
- For non-stationary conditions (variable speed/load, start-up transients).
554
- If target_freq_hz is provided, also tracks that frequency's amplitude over time.
555
- Returns a summary (not the full 2D matrix) to keep output manageable.
556
- """
557
- x = np.array(signal, dtype=np.float64)
558
- stft_result = compute_stft(x, sampling_freq_hz, nperseg=nperseg)
559
-
560
- output: dict = {
561
- "n_freq_bins": stft_result["n_freq_bins"],
562
- "n_time_bins": stft_result["n_time_bins"],
563
- "freq_range_hz": [
564
- float(stft_result["frequencies_hz"][0]),
565
- float(stft_result["frequencies_hz"][-1]),
566
- ],
567
- "time_range_s": [
568
- float(stft_result["times_s"][0]),
569
- float(stft_result["times_s"][-1]),
570
- ],
571
- "freq_resolution_hz": round(
572
- float(stft_result["frequencies_hz"][1] - stft_result["frequencies_hz"][0]), 6
573
- ) if stft_result["n_freq_bins"] > 1 else 0,
574
- }
575
-
576
- # Average spectrum over time
577
- avg_spectrum = np.mean(stft_result["magnitude"], axis=1)
578
- output["average_spectrum"] = {
579
- "frequencies_hz": stft_result["frequencies_hz"].tolist(),
580
- "amplitudes": avg_spectrum.tolist(),
581
- }
582
-
583
- if target_freq_hz is not None:
584
- tracking = track_frequency_over_time(stft_result, target_freq_hz, tolerance_hz)
585
- output["frequency_tracking"] = tracking
586
-
587
- return json.dumps(output, indent=2, default=str)
588
-
589
-
590
- # ===================================================================
591
- # TOOL 15: Inspect Signal File
592
- # ===================================================================
593
-
594
- @mcp.tool()
595
- def inspect_signal_file(
596
- file_path: Annotated[str, Field(description="Absolute path to the signal file (CSV, WAV, or NPY)")],
597
- ) -> str:
598
- """Inspect a signal file without fully loading it.
599
-
600
- Returns file metadata: size, format details, estimated number of
601
- samples, sampling frequency (for WAV), column headers (for CSV),
602
- and array shape (for NPY). Use this before load_signal_from_file
603
- to verify the file format and plan the loading parameters.
604
- """
605
- info = get_signal_file_info(file_path)
606
- return json.dumps(info, indent=2)
607
-
608
-
609
- # ===================================================================
610
- # TOOL 16: Load Signal from File
611
- # ===================================================================
612
-
613
- @mcp.tool()
614
- def load_signal_from_file(
615
- file_path: Annotated[str, Field(description="Absolute path to the signal file (CSV, WAV, or NPY)")],
616
- sampling_freq_hz: Annotated[float | None, Field(description="Sampling frequency in Hz. Required for NPY; optional for CSV if a time column exists; auto-detected for WAV", default=None)] = None,
617
- signal_column: Annotated[int | str, Field(description="CSV column containing the current signal (0-based index or header name)", default=1)] = 1,
618
- time_column: Annotated[int | str | None, Field(description="CSV column for time (index or name). Set to null if no time column", default=0)] = 0,
619
- delimiter: Annotated[str | None, Field(description="CSV delimiter. Auto-detected if null (comma for .csv, tab for .tsv/.txt)", default=None)] = None,
620
- channel: Annotated[int, Field(description="WAV channel index (0-based) for multi-channel files", default=0)] = 0,
621
- skip_header: Annotated[int, Field(description="Number of CSV header rows to skip", default=1)] = 1,
622
- max_rows: Annotated[int | None, Field(description="Max data rows to read from CSV (null = all)", default=None)] = None,
623
- ) -> str:
624
- """Load a motor-current signal from a file (CSV, WAV, or NumPy NPY).
625
-
626
- Supports the most common formats used by industrial DAQ systems:
627
- - **CSV/TSV/TXT**: Columnar data with optional time column.
628
- The sampling frequency is inferred from the time column or
629
- must be provided explicitly.
630
- - **WAV**: Audio files from portable recorders or DAQ. Sampling
631
- frequency is read from the WAV header.
632
- - **NPY**: NumPy binary arrays. Sampling frequency must be provided.
633
-
634
- Returns the signal, sampling frequency, number of samples, duration,
635
- and file metadata. The returned signal can then be passed to
636
- preprocess_signal, compute_spectrum, or run_full_diagnosis.
637
- """
638
- result = load_signal(
639
- file_path,
640
- sampling_freq_hz=sampling_freq_hz,
641
- signal_column=signal_column,
642
- time_column=time_column,
643
- delimiter=delimiter,
644
- channel=channel,
645
- skip_header=skip_header,
646
- max_rows=max_rows,
647
- )
648
- return json.dumps({
649
- "signal": result["signal"],
650
- "sampling_freq_hz": result["sampling_freq_hz"],
651
- "n_samples": result["n_samples"],
652
- "duration_s": result["duration_s"],
653
- "file_path": result["file_path"],
654
- "format": result["format"],
655
- "metadata": result.get("metadata"),
656
- })
657
-
658
-
659
- # ===================================================================
660
- # TOOL 17: Generate Test Signal
661
- # ===================================================================
662
-
663
- @mcp.tool()
664
- def generate_test_current_signal(
665
- duration_s: Annotated[float, Field(description="Signal duration in seconds", default=10.0)] = 10.0,
666
- sampling_freq_hz: Annotated[float, Field(description="Sampling frequency in Hz", default=5000.0)] = 5000.0,
667
- supply_freq_hz: Annotated[float, Field(description="Supply frequency in Hz", default=50.0)] = 50.0,
668
- poles: Annotated[int, Field(description="Number of poles", default=4)] = 4,
669
- rotor_speed_rpm: Annotated[float, Field(description="Rotor speed in RPM", default=1470.0)] = 1470.0,
670
- noise_level: Annotated[float, Field(description="Noise standard deviation (0-1 relative)", default=0.01)] = 0.01,
671
- faults: Annotated[list[str] | None, Field(description="Faults to inject: 'brb', 'eccentricity', 'bearing'. Omit for healthy signal", default=None)] = None,
672
- fault_severity: Annotated[float, Field(description="Fault component amplitude (0-1 relative)", default=0.02)] = 0.02,
673
- ) -> str:
674
- """Generate a synthetic motor-current test signal.
675
-
676
- Creates a simulated stator-current waveform with the fundamental,
677
- supply harmonics, noise, and optional fault signatures. Useful for
678
- testing, validation, and demonstration of MCSA analysis tools.
679
- """
680
- result = generate_test_signal(
681
- duration_s=duration_s,
682
- fs_sample=sampling_freq_hz,
683
- supply_freq_hz=supply_freq_hz,
684
- poles=poles,
685
- rotor_speed_rpm=rotor_speed_rpm,
686
- noise_std=noise_level,
687
- faults=faults,
688
- fault_severity=fault_severity,
689
- )
690
-
691
- return json.dumps(result)
692
-
693
-
694
- # ===================================================================
695
- # TOOL 18: Full Diagnostic Report
696
- # ===================================================================
697
-
698
- @mcp.tool()
699
- def run_full_diagnosis(
700
- signal: Annotated[list[float], Field(description="Raw time-domain current signal")],
701
- sampling_freq_hz: Annotated[float, Field(description="Sampling frequency in Hz")],
702
- supply_freq_hz: Annotated[float, Field(description="Supply frequency in Hz")],
703
- poles: Annotated[int, Field(description="Number of poles")],
704
- rotor_speed_rpm: Annotated[float, Field(description="Rotor speed in RPM")],
705
- bearing_defect_freq_hz: Annotated[float | None, Field(description="Bearing defect frequency in Hz (optional, for bearing analysis)", default=None)] = None,
706
- tolerance_hz: Annotated[float, Field(description="Frequency search tolerance in Hz", default=0.5)] = 0.5,
707
- ) -> str:
708
- """Run a comprehensive MCSA diagnostic analysis on a current signal.
709
-
710
- Performs the full pipeline: preprocessing spectrum fault detection
711
- for broken rotor bars, eccentricity, stator faults, and optionally
712
- bearing defects. Returns a complete diagnostic report.
713
- """
714
- x = np.array(signal, dtype=np.float64)
715
-
716
- # Motor parameters
717
- params = calculate_motor_parameters(supply_freq_hz, poles, rotor_speed_rpm)
718
-
719
- # Preprocess
720
- x_proc = preprocess_pipeline(x, sampling_freq_hz, window="hann")
721
-
722
- # Spectrum
723
- freqs, amps = compute_fft_spectrum(x_proc, sampling_freq_hz, sided="one")
724
-
725
- # PSD for band energy
726
- freqs_psd, psd_vals = compute_psd(x, sampling_freq_hz)
727
-
728
- # Fault detection
729
- brb = brb_fault_index(freqs, amps, params, tolerance_hz)
730
- ecc = eccentricity_fault_index(freqs, amps, params, tolerance_hz=tolerance_hz)
731
- stator = stator_fault_index(freqs, amps, params, tolerance_hz=tolerance_hz)
732
-
733
- # Band energy around fundamental
734
- be = band_energy_index(freqs_psd, psd_vals, supply_freq_hz, bandwidth_hz=10.0)
735
-
736
- # Envelope statistics
737
- env = hilbert_envelope(x)
738
- env_dc_removed = env - np.mean(env)
739
- env_stats = envelope_statistical_indices(env_dc_removed)
740
-
741
- # Bearing (if geometry provided)
742
- bearing_result = None
743
- if bearing_defect_freq_hz is not None:
744
- bearing_result = bearing_fault_index(
745
- freqs, amps, supply_freq_hz,
746
- bearing_defect_freq_hz, "bearing", tolerance_hz=tolerance_hz,
747
- )
748
-
749
- # Top peaks
750
- peaks = detect_peaks(freqs, amps, prominence=0.001, max_peaks=10)
751
-
752
- # Assemble report
753
- report = {
754
- "motor_parameters": params.to_dict(),
755
- "signal_info": {
756
- "n_samples": len(signal),
757
- "sampling_freq_hz": sampling_freq_hz,
758
- "duration_s": round(len(signal) / sampling_freq_hz, 3),
759
- "freq_resolution_hz": round(float(freqs[1] - freqs[0]), 6) if len(freqs) > 1 else 0,
760
- },
761
- "top_spectral_peaks": peaks,
762
- "fault_analysis": {
763
- "broken_rotor_bars": brb,
764
- "eccentricity": ecc,
765
- "stator_inter_turn": stator,
766
- "bearing": bearing_result,
767
- },
768
- "band_energy_around_fundamental": be,
769
- "envelope_statistics": env_stats,
770
- "summary": {
771
- "brb_severity": brb["severity"],
772
- "eccentricity_severity": ecc["severity"],
773
- "stator_severity": stator["severity"],
774
- "envelope_kurtosis": env_stats["kurtosis"],
775
- "overall_assessment": _overall_assessment(brb, ecc, stator, env_stats),
776
- },
777
- }
778
-
779
- return json.dumps(report, indent=2, default=str)
780
-
781
-
782
- def _overall_assessment(brb: dict, ecc: dict, stator: dict, env_stats: dict) -> str:
783
- """Generate a brief overall assessment string."""
784
- severities = [brb["severity"], ecc["severity"], stator["severity"]]
785
-
786
- if "severe" in severities:
787
- return "CRITICAL One or more fault indicators at severe level. Immediate inspection recommended."
788
- if "moderate" in severities:
789
- return "WARNING Moderate fault indication detected. Schedule inspection."
790
- if "incipient" in severities:
791
- return "WATCH Incipient fault signatures detected. Increase monitoring frequency."
792
- if env_stats["kurtosis"] > 6.0:
793
- return "WATCHElevated envelope kurtosis may indicate mechanical impulsiveness."
794
- return "NORMAL — No significant fault indicators detected."
795
-
796
-
797
- # ===================================================================
798
- # TOOL 19: Diagnose from File (one-shot)
799
- # ===================================================================
800
-
801
- @mcp.tool()
802
- def diagnose_from_file(
803
- file_path: Annotated[str, Field(description="Absolute path to the signal file (CSV, WAV, or NPY)")],
804
- supply_freq_hz: Annotated[float, Field(description="Supply frequency in Hz")],
805
- poles: Annotated[int, Field(description="Number of poles")],
806
- rotor_speed_rpm: Annotated[float, Field(description="Rotor speed in RPM")],
807
- sampling_freq_hz: Annotated[float | None, Field(description="Sampling frequency in Hz (required for NPY, optional for CSV with time column, auto-detected for WAV)", default=None)] = None,
808
- signal_column: Annotated[int | str, Field(description="CSV column for the current signal", default=1)] = 1,
809
- time_column: Annotated[int | str | None, Field(description="CSV column for time (null if absent)", default=0)] = 0,
810
- channel: Annotated[int, Field(description="WAV channel index", default=0)] = 0,
811
- bearing_defect_freq_hz: Annotated[float | None, Field(description="Bearing defect frequency in Hz (optional)", default=None)] = None,
812
- tolerance_hz: Annotated[float, Field(description="Frequency search tolerance in Hz", default=0.5)] = 0.5,
813
- ) -> str:
814
- """Load a signal from file and run the full MCSA diagnostic pipeline.
815
-
816
- One-shot tool: reads the signal file, preprocesses, computes the
817
- spectrum, runs all fault detectors, and returns a complete diagnostic
818
- report. Ideal for batch or automated condition-monitoring workflows.
819
- """
820
- # Load signal
821
- loaded = load_signal(
822
- file_path,
823
- sampling_freq_hz=sampling_freq_hz,
824
- signal_column=signal_column,
825
- time_column=time_column,
826
- channel=channel,
827
- )
828
-
829
- x = np.array(loaded["signal"], dtype=np.float64)
830
- fs_sample = loaded["sampling_freq_hz"]
831
-
832
- # Motor parameters
833
- params = calculate_motor_parameters(supply_freq_hz, poles, rotor_speed_rpm)
834
-
835
- # Preprocess
836
- x_proc = preprocess_pipeline(x, fs_sample, window="hann")
837
-
838
- # Spectrum
839
- freqs, amps = compute_fft_spectrum(x_proc, fs_sample, sided="one")
840
-
841
- # PSD
842
- freqs_psd, psd_vals = compute_psd(x, fs_sample)
843
-
844
- # Fault detection
845
- brb = brb_fault_index(freqs, amps, params, tolerance_hz)
846
- ecc = eccentricity_fault_index(freqs, amps, params, tolerance_hz=tolerance_hz)
847
- stator = stator_fault_index(freqs, amps, params, tolerance_hz=tolerance_hz)
848
-
849
- # Band energy
850
- be = band_energy_index(freqs_psd, psd_vals, supply_freq_hz, bandwidth_hz=10.0)
851
-
852
- # Envelope
853
- env = hilbert_envelope(x)
854
- env_dc = env - np.mean(env)
855
- env_stats = envelope_statistical_indices(env_dc)
856
-
857
- # Bearing
858
- bearing_result = None
859
- if bearing_defect_freq_hz is not None:
860
- bearing_result = bearing_fault_index(
861
- freqs, amps, supply_freq_hz,
862
- bearing_defect_freq_hz, "bearing", tolerance_hz=tolerance_hz,
863
- )
864
-
865
- # Peaks
866
- peaks = detect_peaks(freqs, amps, prominence=0.001, max_peaks=10)
867
-
868
- report = {
869
- "source_file": loaded["file_path"],
870
- "file_format": loaded["format"],
871
- "motor_parameters": params.to_dict(),
872
- "signal_info": {
873
- "n_samples": loaded["n_samples"],
874
- "sampling_freq_hz": fs_sample,
875
- "duration_s": loaded["duration_s"],
876
- "freq_resolution_hz": round(float(freqs[1] - freqs[0]), 6) if len(freqs) > 1 else 0,
877
- },
878
- "top_spectral_peaks": peaks,
879
- "fault_analysis": {
880
- "broken_rotor_bars": brb,
881
- "eccentricity": ecc,
882
- "stator_inter_turn": stator,
883
- "bearing": bearing_result,
884
- },
885
- "band_energy_around_fundamental": be,
886
- "envelope_statistics": env_stats,
887
- "summary": {
888
- "brb_severity": brb["severity"],
889
- "eccentricity_severity": ecc["severity"],
890
- "stator_severity": stator["severity"],
891
- "envelope_kurtosis": env_stats["kurtosis"],
892
- "overall_assessment": _overall_assessment(brb, ecc, stator, env_stats),
893
- },
894
- }
895
-
896
- return json.dumps(report, indent=2, default=str)
897
-
898
-
899
- # ===================================================================
900
- # PROMPT: Guided analysis
901
- # ===================================================================
902
-
903
- @mcp.prompt()
904
- def analyze_motor_current(
905
- motor_type: str = "induction",
906
- supply_freq_hz: str = "50",
907
- poles: str = "4",
908
- rotor_speed_rpm: str = "1470",
909
- ) -> str:
910
- """Step-by-step guided prompt for MCSA analysis of a motor current signal."""
911
- return f"""You are performing Motor Current Signature Analysis (MCSA) on a {motor_type} motor.
912
-
913
- Motor parameters:
914
- - Supply frequency: {supply_freq_hz} Hz
915
- - Poles: {poles}
916
- - Rotor speed: {rotor_speed_rpm} RPM
917
-
918
- Follow this diagnostic workflow:
919
-
920
- 1. **Load the current signal**:
921
- - From a file: use `inspect_signal_file` to check the format, then `load_signal_from_file`
922
- - Supported formats: CSV, TSV, WAV, NumPy NPY
923
- - Or generate a synthetic signal with `generate_test_current_signal`
924
-
925
- 2. **Calculate motor parameters** with `calculate_motor_params` to get slip, synchronous speed, and rotor frequency.
926
-
927
- 3. **Compute expected fault frequencies** with `compute_fault_frequencies` to know where to look in the spectrum.
928
-
929
- 4. **Preprocess** the signal with `preprocess_signal` (DC removal, windowing, optional filtering).
930
-
931
- 5. **Compute the spectrum** with `compute_spectrum` and/or `compute_power_spectral_density`.
932
-
933
- 6. **Detect faults**:
934
- - `detect_broken_rotor_bars` — checks (1 ± 2s)·f_s sidebands
935
- - `detect_eccentricity` — checks f_s ± k·f_r sidebands
936
- - `detect_stator_faults` — checks f_s ± 2k·f_r sidebands
937
- - `detect_bearing_faults` — checks f_s ± k·f_defect (needs bearing geometry)
938
-
939
- 7. **Envelope analysis** with `compute_envelope_spectrum` for mechanical/bearing signatures.
940
-
941
- 8. Or use **one-shot shortcuts**:
942
- - `run_full_diagnosis` — full pipeline from signal array
943
- - `diagnose_from_file` — full pipeline directly from a file path
944
-
945
- Report findings with severity levels and actionable recommendations.
946
- """
947
-
948
-
949
- # ---------------------------------------------------------------------------
950
- # Server entry
951
- # ---------------------------------------------------------------------------
952
-
953
- def serve(transport: str = "stdio") -> None:
954
- """Start the MCSA MCP server."""
955
- mcp.run(transport=transport)
1
+ """MCP Server for Motor Current Signature Analysis (MCSA).
2
+
3
+ Provides tools for spectral analysis, fault frequency computation,
4
+ fault detection, and diagnostic assessment of electric‑motor stator
5
+ currents via the Model Context Protocol.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from typing import Annotated, Literal
12
+
13
+ import numpy as np
14
+ from mcp.server.fastmcp import FastMCP
15
+ from pydantic import Field
16
+
17
+ from mcp_server_mcsa.analysis.bearing import (
18
+ BearingGeometry,
19
+ bearing_current_sidebands,
20
+ calculate_bearing_defect_frequencies,
21
+ )
22
+ from mcp_server_mcsa.analysis.envelope import (
23
+ envelope_spectrum,
24
+ hilbert_envelope,
25
+ )
26
+ from mcp_server_mcsa.analysis.fault_detection import (
27
+ band_energy_index,
28
+ bearing_fault_index,
29
+ brb_fault_index,
30
+ eccentricity_fault_index,
31
+ envelope_statistical_indices,
32
+ stator_fault_index,
33
+ )
34
+ from mcp_server_mcsa.analysis.file_io import (
35
+ get_signal_file_info,
36
+ load_signal,
37
+ )
38
+ from mcp_server_mcsa.analysis.motor import (
39
+ calculate_fault_frequencies,
40
+ calculate_motor_parameters,
41
+ )
42
+ from mcp_server_mcsa.analysis.preprocessing import preprocess_pipeline
43
+ from mcp_server_mcsa.analysis.spectral import (
44
+ compute_fft_spectrum,
45
+ compute_psd,
46
+ detect_peaks,
47
+ )
48
+ from mcp_server_mcsa.analysis.test_signal import generate_test_signal
49
+ from mcp_server_mcsa.analysis.timefreq import (
50
+ compute_stft,
51
+ track_frequency_over_time,
52
+ )
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # Server instance
56
+ # ---------------------------------------------------------------------------
57
+
58
+ mcp = FastMCP(
59
+ "mcsa",
60
+ instructions=(
61
+ "Motor Current Signature Analysis (MCSA) server. "
62
+ "Provides tools for spectral analysis of electric‑motor stator "
63
+ "currents to detect rotor, stator, bearing, and load faults. "
64
+ "Typical workflow: "
65
+ "(1) load a real signal from file (CSV/WAV/NPY) with load_signal_from_file "
66
+ " or generate a synthetic one with generate_test_current_signal, "
67
+ "(2) compute motor parameters from nameplate data, "
68
+ "(3) preprocess the signal, (4) compute the spectrum, "
69
+ "(5) detect faults using the appropriate fault‑detection tools, "
70
+ "(6) generate a full diagnostic report with run_full_diagnosis. "
71
+ "For real‑world signals use inspect_signal_file first to check "
72
+ "the file format, then load_signal_from_file to read the data. "
73
+ "For a one‑shot analysis from file use diagnose_from_file."
74
+ ),
75
+ )
76
+
77
+
78
+ # ===================================================================
79
+ # RESOURCE: Fault Signatures Reference
80
+ # ===================================================================
81
+
82
+ FAULT_SIGNATURES_REFERENCE = """# MCSA Fault Signature Reference
83
+
84
+ ## Broken Rotor Bars (BRB)
85
+ - **Signature**: Sidebands at (1 ± 2s)·f_s around the supply fundamental
86
+ - **Index**: dB ratio of sideband amplitude to fundamental
87
+ - **Thresholds** (dB below fundamental):
88
+ - Healthy: -50 dB
89
+ - Incipient: -50 to -45 dB
90
+ - Moderate: -45 to -40 dB
91
+ - Severe: > -35 dB
92
+ - **Notes**: More visible at medium–high load; higher harmonics at (1 ± 2ks)·f_s
93
+
94
+ ## Eccentricity (Static / Dynamic)
95
+ - **Signature**: Sidebands at f_s ± k·f_r (rotor frequency multiples)
96
+ - **Static eccentricity**: produces components at f_s ± f_r
97
+ - **Dynamic eccentricity**: produces components at f_s ± f_r, varying with load
98
+ - **Mixed eccentricity**: components at n·f_r (pure rotational harmonics)
99
+
100
+ ## Stator Inter-Turn Short Circuit
101
+ - **Signature**: Sidebands at f_s ± 2k·f_r
102
+ - **Notes**: May also increase negative-sequence current component;
103
+ distinguish from supply unbalance by checking load dependency
104
+
105
+ ## Bearing Defects
106
+ - **Signature**: Sidebands at f_s ± k·f_defect where f_defect is BPFO/BPFI/BSF/FTF
107
+ - **Defect frequencies** (normalised to shaft speed):
108
+ - BPFO = (n/2)·(1 - d/D·cos α)
109
+ - BPFI = (n/2)·(1 + d/D·cos α)
110
+ - BSF = (D/2d)·(1 - (d/D·cos α)²)
111
+ - FTF = (1/2)·(1 - d/D·cos α)
112
+ - **Notes**: Weak in stator current; confirm with envelope analysis or vibration data
113
+
114
+ ## Load Faults (Cavitation, Misalignment)
115
+ - **Signature**: Broadband energy increase around f_s ("foot" pattern in PSD)
116
+ - **Index**: Band energy integration around the supply frequency
117
+ """
118
+
119
+
120
+ @mcp.resource("mcsa://fault-signatures")
121
+ def fault_signatures_resource() -> str:
122
+ """Reference table of MCSA fault signatures, frequencies, and empirical thresholds."""
123
+ return FAULT_SIGNATURES_REFERENCE
124
+
125
+
126
+ # ===================================================================
127
+ # TOOL 1: Calculate Motor Parameters
128
+ # ===================================================================
129
+
130
+ @mcp.tool()
131
+ def calculate_motor_params(
132
+ supply_freq_hz: Annotated[float, Field(description="Supply (line) frequency in Hz, e.g. 50 or 60")],
133
+ poles: Annotated[int, Field(description="Number of magnetic poles (even, 2)")],
134
+ rotor_speed_rpm: Annotated[float, Field(description="Measured rotor speed in RPM")],
135
+ ) -> str:
136
+ """Calculate motor operating parameters from nameplate and measured data.
137
+
138
+ Computes synchronous speed, slip, rotor frequency, and slip frequency.
139
+ These parameters are required inputs for fault frequency calculations.
140
+ """
141
+ params = calculate_motor_parameters(supply_freq_hz, poles, rotor_speed_rpm)
142
+ return json.dumps(params.to_dict(), indent=2)
143
+
144
+
145
+ # ===================================================================
146
+ # TOOL 2: Calculate Fault Frequencies
147
+ # ===================================================================
148
+
149
+ @mcp.tool()
150
+ def compute_fault_frequencies(
151
+ supply_freq_hz: Annotated[float, Field(description="Supply frequency in Hz")],
152
+ poles: Annotated[int, Field(description="Number of magnetic poles")],
153
+ rotor_speed_rpm: Annotated[float, Field(description="Rotor speed in RPM")],
154
+ harmonics: Annotated[int, Field(description="Number of harmonic orders to compute", default=3)] = 3,
155
+ ) -> str:
156
+ """Calculate expected fault frequencies for common induction-motor faults.
157
+
158
+ Computes characteristic frequencies for broken rotor bars, eccentricity,
159
+ stator faults, and mixed eccentricity based on motor operating parameters.
160
+ Use these frequencies to know WHERE to look in the current spectrum.
161
+ """
162
+ params = calculate_motor_parameters(supply_freq_hz, poles, rotor_speed_rpm)
163
+ result = calculate_fault_frequencies(params, harmonics)
164
+ return json.dumps(result, indent=2, default=str)
165
+
166
+
167
+ # ===================================================================
168
+ # TOOL 3: Calculate Bearing Defect Frequencies
169
+ # ===================================================================
170
+
171
+ @mcp.tool()
172
+ def compute_bearing_frequencies(
173
+ n_balls: Annotated[int, Field(description="Number of rolling elements in the bearing")],
174
+ ball_dia_mm: Annotated[float, Field(description="Ball (roller) diameter in mm")],
175
+ pitch_dia_mm: Annotated[float, Field(description="Pitch (cage) diameter in mm")],
176
+ contact_angle_deg: Annotated[float, Field(description="Contact angle in degrees", default=0.0)] = 0.0,
177
+ shaft_speed_rpm: Annotated[float, Field(description="Shaft speed in RPM (optional, for absolute Hz)", default=0.0)] = 0.0,
178
+ supply_freq_hz: Annotated[float, Field(description="Supply frequency in Hz (optional, for current sidebands)", default=0.0)] = 0.0,
179
+ ) -> str:
180
+ """Calculate bearing characteristic defect frequencies (BPFO, BPFI, BSF, FTF).
181
+
182
+ Returns normalised frequencies (multiples of shaft speed) and, if shaft
183
+ speed is provided, absolute frequencies in Hz. If supply frequency is
184
+ also given, computes expected stator-current sidebands.
185
+ """
186
+ geom = BearingGeometry(n_balls, ball_dia_mm, pitch_dia_mm, contact_angle_deg)
187
+ defects = calculate_bearing_defect_frequencies(geom)
188
+
189
+ result: dict = {
190
+ "bearing_geometry": {
191
+ "n_balls": n_balls,
192
+ "ball_dia_mm": ball_dia_mm,
193
+ "pitch_dia_mm": pitch_dia_mm,
194
+ "contact_angle_deg": contact_angle_deg,
195
+ },
196
+ "normalised_to_shaft_speed": defects.to_dict(),
197
+ }
198
+
199
+ if shaft_speed_rpm > 0:
200
+ shaft_freq = shaft_speed_rpm / 60.0
201
+ result["absolute_frequencies_hz"] = defects.absolute(shaft_freq)
202
+
203
+ if supply_freq_hz > 0:
204
+ result["current_sidebands"] = bearing_current_sidebands(
205
+ defects, shaft_freq, supply_freq_hz
206
+ )
207
+
208
+ return json.dumps(result, indent=2)
209
+
210
+
211
+ # ===================================================================
212
+ # TOOL 4: Preprocess Signal
213
+ # ===================================================================
214
+
215
+ @mcp.tool()
216
+ def preprocess_signal(
217
+ signal: Annotated[list[float], Field(description="Raw current signal as a list of amplitude values")],
218
+ sampling_freq_hz: Annotated[float, Field(description="Sampling frequency in Hz")],
219
+ nominal_current: Annotated[float | None, Field(description="Nominal current for normalisation (A). Omit for RMS normalisation", default=None)] = None,
220
+ window: Annotated[str, Field(description="Window function: hann, hamming, blackman, flattop, rectangular", default="hann")] = "hann",
221
+ bandpass_low_hz: Annotated[float | None, Field(description="Lower bandpass cutoff in Hz (optional)", default=None)] = None,
222
+ bandpass_high_hz: Annotated[float | None, Field(description="Upper bandpass cutoff in Hz (optional)", default=None)] = None,
223
+ notch_freqs_hz: Annotated[list[float] | None, Field(description="Frequencies to notch-filter (optional)", default=None)] = None,
224
+ ) -> str:
225
+ """Preprocess a stator-current signal for spectral analysis.
226
+
227
+ Applies (in order): DC offset removal → notch filtering → bandpass
228
+ filtering normalisation windowing. Returns the preprocessed signal.
229
+ """
230
+ x = np.array(signal, dtype=np.float64)
231
+
232
+ bandpass = None
233
+ if bandpass_low_hz is not None and bandpass_high_hz is not None:
234
+ bandpass = (bandpass_low_hz, bandpass_high_hz)
235
+
236
+ y = preprocess_pipeline(
237
+ x, sampling_freq_hz,
238
+ nominal_current=nominal_current,
239
+ window=window, # type: ignore[arg-type]
240
+ bandpass=bandpass,
241
+ notch_freqs=notch_freqs_hz,
242
+ )
243
+
244
+ return json.dumps({
245
+ "preprocessed_signal": y.tolist(),
246
+ "n_samples": len(y),
247
+ "sampling_freq_hz": sampling_freq_hz,
248
+ "steps_applied": [
249
+ "dc_offset_removal",
250
+ *(["notch_filter"] if notch_freqs_hz else []),
251
+ *(["bandpass_filter"] if bandpass else []),
252
+ "normalisation",
253
+ f"window_{window}",
254
+ ],
255
+ })
256
+
257
+
258
+ # ===================================================================
259
+ # TOOL 5: Compute FFT Spectrum
260
+ # ===================================================================
261
+
262
+ @mcp.tool()
263
+ def compute_spectrum(
264
+ signal: Annotated[list[float], Field(description="Time-domain signal (preprocessed or raw)")],
265
+ sampling_freq_hz: Annotated[float, Field(description="Sampling frequency in Hz")],
266
+ n_fft: Annotated[int | None, Field(description="FFT length (zero-padding). Omit for auto", default=None)] = None,
267
+ max_freq_hz: Annotated[float | None, Field(description="Maximum frequency to return (Hz). Omit for full range", default=None)] = None,
268
+ ) -> str:
269
+ """Compute the single-sided amplitude spectrum (FFT) of a current signal.
270
+
271
+ Returns frequency and amplitude arrays. Optionally limit the maximum
272
+ frequency returned to reduce output size.
273
+ """
274
+ x = np.array(signal, dtype=np.float64)
275
+ freqs, amps = compute_fft_spectrum(x, sampling_freq_hz, n_fft=n_fft, sided="one")
276
+
277
+ if max_freq_hz is not None:
278
+ mask = freqs <= max_freq_hz
279
+ freqs = freqs[mask]
280
+ amps = amps[mask]
281
+
282
+ return json.dumps({
283
+ "frequencies_hz": freqs.tolist(),
284
+ "amplitudes": amps.tolist(),
285
+ "n_bins": len(freqs),
286
+ "freq_resolution_hz": round(float(freqs[1] - freqs[0]), 6) if len(freqs) > 1 else 0,
287
+ })
288
+
289
+
290
+ # ===================================================================
291
+ # TOOL 6: Compute PSD
292
+ # ===================================================================
293
+
294
+ @mcp.tool()
295
+ def compute_power_spectral_density(
296
+ signal: Annotated[list[float], Field(description="Time-domain signal")],
297
+ sampling_freq_hz: Annotated[float, Field(description="Sampling frequency in Hz")],
298
+ nperseg: Annotated[int | None, Field(description="FFT segment length. Omit for auto", default=None)] = None,
299
+ max_freq_hz: Annotated[float | None, Field(description="Maximum frequency to return (Hz)", default=None)] = None,
300
+ ) -> str:
301
+ """Compute Power Spectral Density using Welch's method.
302
+
303
+ Better for noisy signals and trend analysis than a raw FFT.
304
+ Returns frequency and PSD arrays.
305
+ """
306
+ x = np.array(signal, dtype=np.float64)
307
+ freqs, psd = compute_psd(x, sampling_freq_hz, nperseg=nperseg)
308
+
309
+ if max_freq_hz is not None:
310
+ mask = freqs <= max_freq_hz
311
+ freqs = freqs[mask]
312
+ psd = psd[mask]
313
+
314
+ return json.dumps({
315
+ "frequencies_hz": freqs.tolist(),
316
+ "psd_values": psd.tolist(),
317
+ "n_bins": len(freqs),
318
+ "freq_resolution_hz": round(float(freqs[1] - freqs[0]), 6) if len(freqs) > 1 else 0,
319
+ })
320
+
321
+
322
+ # ===================================================================
323
+ # TOOL 7: Detect Spectral Peaks
324
+ # ===================================================================
325
+
326
+ @mcp.tool()
327
+ def find_spectrum_peaks(
328
+ frequencies_hz: Annotated[list[float], Field(description="Frequency axis from spectrum/PSD")],
329
+ amplitudes: Annotated[list[float], Field(description="Amplitude or PSD values")],
330
+ min_height: Annotated[float | None, Field(description="Minimum peak height", default=None)] = None,
331
+ min_prominence: Annotated[float | None, Field(description="Minimum peak prominence", default=None)] = None,
332
+ min_distance_hz: Annotated[float | None, Field(description="Min distance between peaks in Hz", default=None)] = None,
333
+ freq_low_hz: Annotated[float | None, Field(description="Lower frequency bound for search", default=None)] = None,
334
+ freq_high_hz: Annotated[float | None, Field(description="Upper frequency bound for search", default=None)] = None,
335
+ max_peaks: Annotated[int, Field(description="Maximum number of peaks to return", default=20)] = 20,
336
+ ) -> str:
337
+ """Detect peaks in a frequency spectrum.
338
+
339
+ Returns a list of peaks sorted by amplitude (highest first) with
340
+ frequency, amplitude, and prominence values.
341
+ """
342
+ freqs = np.array(frequencies_hz, dtype=np.float64)
343
+ amps = np.array(amplitudes, dtype=np.float64)
344
+
345
+ freq_range = None
346
+ if freq_low_hz is not None and freq_high_hz is not None:
347
+ freq_range = (freq_low_hz, freq_high_hz)
348
+
349
+ peaks = detect_peaks(
350
+ freqs, amps,
351
+ height=min_height,
352
+ prominence=min_prominence,
353
+ distance_hz=min_distance_hz,
354
+ freq_range=freq_range,
355
+ max_peaks=max_peaks,
356
+ )
357
+
358
+ return json.dumps({"peaks": peaks, "n_peaks_found": len(peaks)}, indent=2)
359
+
360
+
361
+ # ===================================================================
362
+ # TOOL 8: Detect Broken Rotor Bars
363
+ # ===================================================================
364
+
365
+ @mcp.tool()
366
+ def detect_broken_rotor_bars(
367
+ frequencies_hz: Annotated[list[float], Field(description="Spectrum frequency axis (Hz)")],
368
+ amplitudes: Annotated[list[float], Field(description="Spectrum amplitude values")],
369
+ supply_freq_hz: Annotated[float, Field(description="Supply frequency in Hz")],
370
+ poles: Annotated[int, Field(description="Number of poles")],
371
+ rotor_speed_rpm: Annotated[float, Field(description="Rotor speed in RPM")],
372
+ tolerance_hz: Annotated[float, Field(description="Frequency search tolerance in Hz", default=0.5)] = 0.5,
373
+ ) -> str:
374
+ """Detect broken rotor bar faults from current spectrum.
375
+
376
+ Computes the BRB fault index by measuring sidebands at (1 ± 2s)·f_s
377
+ relative to the fundamental. Returns severity classification:
378
+ healthy / incipient / moderate / severe.
379
+ """
380
+ params = calculate_motor_parameters(supply_freq_hz, poles, rotor_speed_rpm)
381
+ freqs = np.array(frequencies_hz, dtype=np.float64)
382
+ amps = np.array(amplitudes, dtype=np.float64)
383
+
384
+ result = brb_fault_index(freqs, amps, params, tolerance_hz)
385
+ return json.dumps(result, indent=2, default=str)
386
+
387
+
388
+ # ===================================================================
389
+ # TOOL 9: Detect Eccentricity
390
+ # ===================================================================
391
+
392
+ @mcp.tool()
393
+ def detect_eccentricity(
394
+ frequencies_hz: Annotated[list[float], Field(description="Spectrum frequency axis (Hz)")],
395
+ amplitudes: Annotated[list[float], Field(description="Spectrum amplitude values")],
396
+ supply_freq_hz: Annotated[float, Field(description="Supply frequency in Hz")],
397
+ poles: Annotated[int, Field(description="Number of poles")],
398
+ rotor_speed_rpm: Annotated[float, Field(description="Rotor speed in RPM")],
399
+ harmonics: Annotated[int, Field(description="Number of harmonic orders to check", default=3)] = 3,
400
+ tolerance_hz: Annotated[float, Field(description="Frequency search tolerance in Hz", default=0.5)] = 0.5,
401
+ ) -> str:
402
+ """Detect air-gap eccentricity faults from current spectrum.
403
+
404
+ Analyses sidebands at f_s ± k·f_r for static and dynamic eccentricity.
405
+ Returns severity classification.
406
+ """
407
+ params = calculate_motor_parameters(supply_freq_hz, poles, rotor_speed_rpm)
408
+ freqs = np.array(frequencies_hz, dtype=np.float64)
409
+ amps = np.array(amplitudes, dtype=np.float64)
410
+
411
+ result = eccentricity_fault_index(freqs, amps, params, harmonics, tolerance_hz)
412
+ return json.dumps(result, indent=2, default=str)
413
+
414
+
415
+ # ===================================================================
416
+ # TOOL 10: Detect Stator Faults
417
+ # ===================================================================
418
+
419
+ @mcp.tool()
420
+ def detect_stator_faults(
421
+ frequencies_hz: Annotated[list[float], Field(description="Spectrum frequency axis (Hz)")],
422
+ amplitudes: Annotated[list[float], Field(description="Spectrum amplitude values")],
423
+ supply_freq_hz: Annotated[float, Field(description="Supply frequency in Hz")],
424
+ poles: Annotated[int, Field(description="Number of poles")],
425
+ rotor_speed_rpm: Annotated[float, Field(description="Rotor speed in RPM")],
426
+ harmonics: Annotated[int, Field(description="Number of harmonic orders", default=3)] = 3,
427
+ tolerance_hz: Annotated[float, Field(description="Frequency search tolerance in Hz", default=0.5)] = 0.5,
428
+ ) -> str:
429
+ """Detect stator inter-turn short circuit faults from current spectrum.
430
+
431
+ Analyses sidebands at f_s ± 2k·f_r caused by stator winding asymmetry.
432
+ """
433
+ params = calculate_motor_parameters(supply_freq_hz, poles, rotor_speed_rpm)
434
+ freqs = np.array(frequencies_hz, dtype=np.float64)
435
+ amps = np.array(amplitudes, dtype=np.float64)
436
+
437
+ result = stator_fault_index(freqs, amps, params, harmonics, tolerance_hz)
438
+ return json.dumps(result, indent=2, default=str)
439
+
440
+
441
+ # ===================================================================
442
+ # TOOL 11: Detect Bearing Faults
443
+ # ===================================================================
444
+
445
+ @mcp.tool()
446
+ def detect_bearing_faults(
447
+ frequencies_hz: Annotated[list[float], Field(description="Spectrum frequency axis (Hz)")],
448
+ amplitudes: Annotated[list[float], Field(description="Spectrum amplitude values")],
449
+ supply_freq_hz: Annotated[float, Field(description="Supply frequency in Hz")],
450
+ bearing_defect_freq_hz: Annotated[float, Field(description="Bearing characteristic defect frequency in Hz (BPFO, BPFI, BSF, or FTF)")],
451
+ defect_type: Annotated[str, Field(description="Defect type label: bpfo, bpfi, bsf, or ftf", default="bpfo")] = "bpfo",
452
+ harmonics: Annotated[int, Field(description="Number of sideband orders", default=2)] = 2,
453
+ tolerance_hz: Annotated[float, Field(description="Frequency search tolerance in Hz", default=0.5)] = 0.5,
454
+ ) -> str:
455
+ """Detect bearing defect signatures in the stator-current spectrum.
456
+
457
+ Bearing faults modulate motor torque, creating sidebands at
458
+ f_s ± k·f_defect. Note: bearing signatures in current are typically
459
+ weak; envelope analysis or vibration data can improve detection.
460
+ """
461
+ freqs = np.array(frequencies_hz, dtype=np.float64)
462
+ amps = np.array(amplitudes, dtype=np.float64)
463
+
464
+ result = bearing_fault_index(
465
+ freqs, amps, supply_freq_hz,
466
+ bearing_defect_freq_hz, defect_type, harmonics, tolerance_hz,
467
+ )
468
+ return json.dumps(result, indent=2, default=str)
469
+
470
+
471
+ # ===================================================================
472
+ # TOOL 12: Compute Envelope Spectrum
473
+ # ===================================================================
474
+
475
+ @mcp.tool()
476
+ def compute_envelope_spectrum(
477
+ signal: Annotated[list[float], Field(description="Time-domain current signal")],
478
+ sampling_freq_hz: Annotated[float, Field(description="Sampling frequency in Hz")],
479
+ bandpass_low_hz: Annotated[float | None, Field(description="Lower bandpass cutoff before envelope (optional)", default=None)] = None,
480
+ bandpass_high_hz: Annotated[float | None, Field(description="Upper bandpass cutoff before envelope (optional)", default=None)] = None,
481
+ max_freq_hz: Annotated[float | None, Field(description="Max frequency to return (Hz)", default=None)] = None,
482
+ ) -> str:
483
+ """Compute the envelope spectrum of a current signal.
484
+
485
+ Uses the Hilbert transform to extract the amplitude envelope, then
486
+ computes its FFT. Useful for detecting bearing and mechanical faults
487
+ that modulate the current at low frequencies.
488
+ """
489
+ x = np.array(signal, dtype=np.float64)
490
+
491
+ bp = None
492
+ if bandpass_low_hz is not None and bandpass_high_hz is not None:
493
+ bp = (bandpass_low_hz, bandpass_high_hz)
494
+
495
+ freqs, amps = envelope_spectrum(x, sampling_freq_hz, bandpass=bp)
496
+
497
+ if max_freq_hz is not None:
498
+ mask = freqs <= max_freq_hz
499
+ freqs = freqs[mask]
500
+ amps = amps[mask]
501
+
502
+ # Also compute statistical indices of the envelope
503
+ env = hilbert_envelope(x)
504
+ env = env - np.mean(env)
505
+ stats = envelope_statistical_indices(env)
506
+
507
+ return json.dumps({
508
+ "frequencies_hz": freqs.tolist(),
509
+ "amplitudes": amps.tolist(),
510
+ "n_bins": len(freqs),
511
+ "envelope_statistics": stats,
512
+ })
513
+
514
+
515
+ # ===================================================================
516
+ # TOOL 13: Compute Band Energy
517
+ # ===================================================================
518
+
519
+ @mcp.tool()
520
+ def compute_band_energy(
521
+ frequencies_hz: Annotated[list[float], Field(description="PSD frequency axis (Hz)")],
522
+ psd_values: Annotated[list[float], Field(description="PSD values")],
523
+ centre_freq_hz: Annotated[float, Field(description="Centre of the frequency band (Hz)")],
524
+ bandwidth_hz: Annotated[float, Field(description="Total bandwidth for energy integration (Hz)", default=5.0)] = 5.0,
525
+ ) -> str:
526
+ """Compute the integrated spectral energy in a frequency band.
527
+
528
+ Useful as a generic fault/cavitation indicator — measures the energy
529
+ concentration around a characteristic frequency in the PSD.
530
+ """
531
+ freqs = np.array(frequencies_hz, dtype=np.float64)
532
+ psd = np.array(psd_values, dtype=np.float64)
533
+
534
+ result = band_energy_index(freqs, psd, centre_freq_hz, bandwidth_hz)
535
+ return json.dumps(result, indent=2)
536
+
537
+
538
+ # ===================================================================
539
+ # TOOL 14: Compute STFT
540
+ # ===================================================================
541
+
542
+ @mcp.tool()
543
+ def compute_time_frequency(
544
+ signal: Annotated[list[float], Field(description="Time-domain current signal")],
545
+ sampling_freq_hz: Annotated[float, Field(description="Sampling frequency in Hz")],
546
+ nperseg: Annotated[int, Field(description="Window length per segment (samples)", default=256)] = 256,
547
+ target_freq_hz: Annotated[float | None, Field(description="Frequency to track over time (Hz). If provided, returns amplitude vs time for that frequency", default=None)] = None,
548
+ tolerance_hz: Annotated[float, Field(description="Tolerance for frequency tracking (Hz)", default=2.0)] = 2.0,
549
+ ) -> str:
550
+ """Compute Short-Time Fourier Transform (STFT) for time-frequency analysis.
551
+
552
+ For non-stationary conditions (variable speed/load, start-up transients).
553
+ If target_freq_hz is provided, also tracks that frequency's amplitude over time.
554
+ Returns a summary (not the full 2D matrix) to keep output manageable.
555
+ """
556
+ x = np.array(signal, dtype=np.float64)
557
+ stft_result = compute_stft(x, sampling_freq_hz, nperseg=nperseg)
558
+
559
+ output: dict = {
560
+ "n_freq_bins": stft_result["n_freq_bins"],
561
+ "n_time_bins": stft_result["n_time_bins"],
562
+ "freq_range_hz": [
563
+ float(stft_result["frequencies_hz"][0]),
564
+ float(stft_result["frequencies_hz"][-1]),
565
+ ],
566
+ "time_range_s": [
567
+ float(stft_result["times_s"][0]),
568
+ float(stft_result["times_s"][-1]),
569
+ ],
570
+ "freq_resolution_hz": round(
571
+ float(stft_result["frequencies_hz"][1] - stft_result["frequencies_hz"][0]), 6
572
+ ) if stft_result["n_freq_bins"] > 1 else 0,
573
+ }
574
+
575
+ # Average spectrum over time
576
+ avg_spectrum = np.mean(stft_result["magnitude"], axis=1)
577
+ output["average_spectrum"] = {
578
+ "frequencies_hz": stft_result["frequencies_hz"].tolist(),
579
+ "amplitudes": avg_spectrum.tolist(),
580
+ }
581
+
582
+ if target_freq_hz is not None:
583
+ tracking = track_frequency_over_time(stft_result, target_freq_hz, tolerance_hz)
584
+ output["frequency_tracking"] = tracking
585
+
586
+ return json.dumps(output, indent=2, default=str)
587
+
588
+
589
+ # ===================================================================
590
+ # TOOL 15: Inspect Signal File
591
+ # ===================================================================
592
+
593
+ @mcp.tool()
594
+ def inspect_signal_file(
595
+ file_path: Annotated[str, Field(description="Absolute path to the signal file (CSV, WAV, or NPY)")],
596
+ ) -> str:
597
+ """Inspect a signal file without fully loading it.
598
+
599
+ Returns file metadata: size, format details, estimated number of
600
+ samples, sampling frequency (for WAV), column headers (for CSV),
601
+ and array shape (for NPY). Use this before load_signal_from_file
602
+ to verify the file format and plan the loading parameters.
603
+ """
604
+ info = get_signal_file_info(file_path)
605
+ return json.dumps(info, indent=2)
606
+
607
+
608
+ # ===================================================================
609
+ # TOOL 16: Load Signal from File
610
+ # ===================================================================
611
+
612
+ @mcp.tool()
613
+ def load_signal_from_file(
614
+ file_path: Annotated[str, Field(description="Absolute path to the signal file (CSV, WAV, or NPY)")],
615
+ sampling_freq_hz: Annotated[float | None, Field(description="Sampling frequency in Hz. Required for NPY; optional for CSV if a time column exists; auto-detected for WAV", default=None)] = None,
616
+ signal_column: Annotated[int | str, Field(description="CSV column containing the current signal (0-based index or header name)", default=1)] = 1,
617
+ time_column: Annotated[int | str | None, Field(description="CSV column for time (index or name). Set to null if no time column", default=0)] = 0,
618
+ delimiter: Annotated[str | None, Field(description="CSV delimiter. Auto-detected if null (comma for .csv, tab for .tsv/.txt)", default=None)] = None,
619
+ channel: Annotated[int, Field(description="WAV channel index (0-based) for multi-channel files", default=0)] = 0,
620
+ skip_header: Annotated[int, Field(description="Number of CSV header rows to skip", default=1)] = 1,
621
+ max_rows: Annotated[int | None, Field(description="Max data rows to read from CSV (null = all)", default=None)] = None,
622
+ ) -> str:
623
+ """Load a motor-current signal from a file (CSV, WAV, or NumPy NPY).
624
+
625
+ Supports the most common formats used by industrial DAQ systems:
626
+ - **CSV/TSV/TXT**: Columnar data with optional time column.
627
+ The sampling frequency is inferred from the time column or
628
+ must be provided explicitly.
629
+ - **WAV**: Audio files from portable recorders or DAQ. Sampling
630
+ frequency is read from the WAV header.
631
+ - **NPY**: NumPy binary arrays. Sampling frequency must be provided.
632
+
633
+ Returns the signal, sampling frequency, number of samples, duration,
634
+ and file metadata. The returned signal can then be passed to
635
+ preprocess_signal, compute_spectrum, or run_full_diagnosis.
636
+ """
637
+ result = load_signal(
638
+ file_path,
639
+ sampling_freq_hz=sampling_freq_hz,
640
+ signal_column=signal_column,
641
+ time_column=time_column,
642
+ delimiter=delimiter,
643
+ channel=channel,
644
+ skip_header=skip_header,
645
+ max_rows=max_rows,
646
+ )
647
+ return json.dumps({
648
+ "signal": result["signal"],
649
+ "sampling_freq_hz": result["sampling_freq_hz"],
650
+ "n_samples": result["n_samples"],
651
+ "duration_s": result["duration_s"],
652
+ "file_path": result["file_path"],
653
+ "format": result["format"],
654
+ "metadata": result.get("metadata"),
655
+ })
656
+
657
+
658
+ # ===================================================================
659
+ # TOOL 17: Generate Test Signal
660
+ # ===================================================================
661
+
662
+ @mcp.tool()
663
+ def generate_test_current_signal(
664
+ duration_s: Annotated[float, Field(description="Signal duration in seconds", default=10.0)] = 10.0,
665
+ sampling_freq_hz: Annotated[float, Field(description="Sampling frequency in Hz", default=5000.0)] = 5000.0,
666
+ supply_freq_hz: Annotated[float, Field(description="Supply frequency in Hz", default=50.0)] = 50.0,
667
+ poles: Annotated[int, Field(description="Number of poles", default=4)] = 4,
668
+ rotor_speed_rpm: Annotated[float, Field(description="Rotor speed in RPM", default=1470.0)] = 1470.0,
669
+ noise_level: Annotated[float, Field(description="Noise standard deviation (0-1 relative)", default=0.01)] = 0.01,
670
+ faults: Annotated[list[str] | None, Field(description="Faults to inject: 'brb', 'eccentricity', 'bearing'. Omit for healthy signal", default=None)] = None,
671
+ fault_severity: Annotated[float, Field(description="Fault component amplitude (0-1 relative)", default=0.02)] = 0.02,
672
+ ) -> str:
673
+ """Generate a synthetic motor-current test signal.
674
+
675
+ Creates a simulated stator-current waveform with the fundamental,
676
+ supply harmonics, noise, and optional fault signatures. Useful for
677
+ testing, validation, and demonstration of MCSA analysis tools.
678
+ """
679
+ result = generate_test_signal(
680
+ duration_s=duration_s,
681
+ fs_sample=sampling_freq_hz,
682
+ supply_freq_hz=supply_freq_hz,
683
+ poles=poles,
684
+ rotor_speed_rpm=rotor_speed_rpm,
685
+ noise_std=noise_level,
686
+ faults=faults,
687
+ fault_severity=fault_severity,
688
+ )
689
+
690
+ return json.dumps(result)
691
+
692
+
693
+ # ===================================================================
694
+ # TOOL 18: Full Diagnostic Report
695
+ # ===================================================================
696
+
697
+ @mcp.tool()
698
+ def run_full_diagnosis(
699
+ signal: Annotated[list[float], Field(description="Raw time-domain current signal")],
700
+ sampling_freq_hz: Annotated[float, Field(description="Sampling frequency in Hz")],
701
+ supply_freq_hz: Annotated[float, Field(description="Supply frequency in Hz")],
702
+ poles: Annotated[int, Field(description="Number of poles")],
703
+ rotor_speed_rpm: Annotated[float, Field(description="Rotor speed in RPM")],
704
+ bearing_defect_freq_hz: Annotated[float | None, Field(description="Bearing defect frequency in Hz (optional, for bearing analysis)", default=None)] = None,
705
+ tolerance_hz: Annotated[float, Field(description="Frequency search tolerance in Hz", default=0.5)] = 0.5,
706
+ ) -> str:
707
+ """Run a comprehensive MCSA diagnostic analysis on a current signal.
708
+
709
+ Performs the full pipeline: preprocessing → spectrum → fault detection
710
+ for broken rotor bars, eccentricity, stator faults, and optionally
711
+ bearing defects. Returns a complete diagnostic report.
712
+ """
713
+ x = np.array(signal, dtype=np.float64)
714
+
715
+ # Motor parameters
716
+ params = calculate_motor_parameters(supply_freq_hz, poles, rotor_speed_rpm)
717
+
718
+ # Preprocess
719
+ x_proc = preprocess_pipeline(x, sampling_freq_hz, window="hann")
720
+
721
+ # Spectrum
722
+ freqs, amps = compute_fft_spectrum(x_proc, sampling_freq_hz, sided="one")
723
+
724
+ # PSD for band energy
725
+ freqs_psd, psd_vals = compute_psd(x, sampling_freq_hz)
726
+
727
+ # Fault detection
728
+ brb = brb_fault_index(freqs, amps, params, tolerance_hz)
729
+ ecc = eccentricity_fault_index(freqs, amps, params, tolerance_hz=tolerance_hz)
730
+ stator = stator_fault_index(freqs, amps, params, tolerance_hz=tolerance_hz)
731
+
732
+ # Band energy around fundamental
733
+ be = band_energy_index(freqs_psd, psd_vals, supply_freq_hz, bandwidth_hz=10.0)
734
+
735
+ # Envelope statistics
736
+ env = hilbert_envelope(x)
737
+ env_dc_removed = env - np.mean(env)
738
+ env_stats = envelope_statistical_indices(env_dc_removed)
739
+
740
+ # Bearing (if geometry provided)
741
+ bearing_result = None
742
+ if bearing_defect_freq_hz is not None:
743
+ bearing_result = bearing_fault_index(
744
+ freqs, amps, supply_freq_hz,
745
+ bearing_defect_freq_hz, "bearing", tolerance_hz=tolerance_hz,
746
+ )
747
+
748
+ # Top peaks
749
+ peaks = detect_peaks(freqs, amps, prominence=0.001, max_peaks=10)
750
+
751
+ # Assemble report
752
+ report = {
753
+ "motor_parameters": params.to_dict(),
754
+ "signal_info": {
755
+ "n_samples": len(signal),
756
+ "sampling_freq_hz": sampling_freq_hz,
757
+ "duration_s": round(len(signal) / sampling_freq_hz, 3),
758
+ "freq_resolution_hz": round(float(freqs[1] - freqs[0]), 6) if len(freqs) > 1 else 0,
759
+ },
760
+ "top_spectral_peaks": peaks,
761
+ "fault_analysis": {
762
+ "broken_rotor_bars": brb,
763
+ "eccentricity": ecc,
764
+ "stator_inter_turn": stator,
765
+ "bearing": bearing_result,
766
+ },
767
+ "band_energy_around_fundamental": be,
768
+ "envelope_statistics": env_stats,
769
+ "summary": {
770
+ "brb_severity": brb["severity"],
771
+ "eccentricity_severity": ecc["severity"],
772
+ "stator_severity": stator["severity"],
773
+ "envelope_kurtosis": env_stats["kurtosis"],
774
+ "overall_assessment": _overall_assessment(brb, ecc, stator, env_stats),
775
+ },
776
+ }
777
+
778
+ return json.dumps(report, indent=2, default=str)
779
+
780
+
781
+ def _overall_assessment(brb: dict, ecc: dict, stator: dict, env_stats: dict) -> str:
782
+ """Generate a brief overall assessment string."""
783
+ severities = [brb["severity"], ecc["severity"], stator["severity"]]
784
+
785
+ if "severe" in severities:
786
+ return "CRITICAL — One or more fault indicators at severe level. Immediate inspection recommended."
787
+ if "moderate" in severities:
788
+ return "WARNING Moderate fault indication detected. Schedule inspection."
789
+ if "incipient" in severities:
790
+ return "WATCH Incipient fault signatures detected. Increase monitoring frequency."
791
+ if env_stats["kurtosis"] > 6.0:
792
+ return "WATCH — Elevated envelope kurtosis may indicate mechanical impulsiveness."
793
+ return "NORMALNo significant fault indicators detected."
794
+
795
+
796
+ # ===================================================================
797
+ # TOOL 19: Diagnose from File (one-shot)
798
+ # ===================================================================
799
+
800
+ @mcp.tool()
801
+ def diagnose_from_file(
802
+ file_path: Annotated[str, Field(description="Absolute path to the signal file (CSV, WAV, or NPY)")],
803
+ supply_freq_hz: Annotated[float, Field(description="Supply frequency in Hz")],
804
+ poles: Annotated[int, Field(description="Number of poles")],
805
+ rotor_speed_rpm: Annotated[float, Field(description="Rotor speed in RPM")],
806
+ sampling_freq_hz: Annotated[float | None, Field(description="Sampling frequency in Hz (required for NPY, optional for CSV with time column, auto-detected for WAV)", default=None)] = None,
807
+ signal_column: Annotated[int | str, Field(description="CSV column for the current signal", default=1)] = 1,
808
+ time_column: Annotated[int | str | None, Field(description="CSV column for time (null if absent)", default=0)] = 0,
809
+ channel: Annotated[int, Field(description="WAV channel index", default=0)] = 0,
810
+ bearing_defect_freq_hz: Annotated[float | None, Field(description="Bearing defect frequency in Hz (optional)", default=None)] = None,
811
+ tolerance_hz: Annotated[float, Field(description="Frequency search tolerance in Hz", default=0.5)] = 0.5,
812
+ ) -> str:
813
+ """Load a signal from file and run the full MCSA diagnostic pipeline.
814
+
815
+ One-shot tool: reads the signal file, preprocesses, computes the
816
+ spectrum, runs all fault detectors, and returns a complete diagnostic
817
+ report. Ideal for batch or automated condition-monitoring workflows.
818
+ """
819
+ # Load signal
820
+ loaded = load_signal(
821
+ file_path,
822
+ sampling_freq_hz=sampling_freq_hz,
823
+ signal_column=signal_column,
824
+ time_column=time_column,
825
+ channel=channel,
826
+ )
827
+
828
+ x = np.array(loaded["signal"], dtype=np.float64)
829
+ fs_sample = loaded["sampling_freq_hz"]
830
+
831
+ # Motor parameters
832
+ params = calculate_motor_parameters(supply_freq_hz, poles, rotor_speed_rpm)
833
+
834
+ # Preprocess
835
+ x_proc = preprocess_pipeline(x, fs_sample, window="hann")
836
+
837
+ # Spectrum
838
+ freqs, amps = compute_fft_spectrum(x_proc, fs_sample, sided="one")
839
+
840
+ # PSD
841
+ freqs_psd, psd_vals = compute_psd(x, fs_sample)
842
+
843
+ # Fault detection
844
+ brb = brb_fault_index(freqs, amps, params, tolerance_hz)
845
+ ecc = eccentricity_fault_index(freqs, amps, params, tolerance_hz=tolerance_hz)
846
+ stator = stator_fault_index(freqs, amps, params, tolerance_hz=tolerance_hz)
847
+
848
+ # Band energy
849
+ be = band_energy_index(freqs_psd, psd_vals, supply_freq_hz, bandwidth_hz=10.0)
850
+
851
+ # Envelope
852
+ env = hilbert_envelope(x)
853
+ env_dc = env - np.mean(env)
854
+ env_stats = envelope_statistical_indices(env_dc)
855
+
856
+ # Bearing
857
+ bearing_result = None
858
+ if bearing_defect_freq_hz is not None:
859
+ bearing_result = bearing_fault_index(
860
+ freqs, amps, supply_freq_hz,
861
+ bearing_defect_freq_hz, "bearing", tolerance_hz=tolerance_hz,
862
+ )
863
+
864
+ # Peaks
865
+ peaks = detect_peaks(freqs, amps, prominence=0.001, max_peaks=10)
866
+
867
+ report = {
868
+ "source_file": loaded["file_path"],
869
+ "file_format": loaded["format"],
870
+ "motor_parameters": params.to_dict(),
871
+ "signal_info": {
872
+ "n_samples": loaded["n_samples"],
873
+ "sampling_freq_hz": fs_sample,
874
+ "duration_s": loaded["duration_s"],
875
+ "freq_resolution_hz": round(float(freqs[1] - freqs[0]), 6) if len(freqs) > 1 else 0,
876
+ },
877
+ "top_spectral_peaks": peaks,
878
+ "fault_analysis": {
879
+ "broken_rotor_bars": brb,
880
+ "eccentricity": ecc,
881
+ "stator_inter_turn": stator,
882
+ "bearing": bearing_result,
883
+ },
884
+ "band_energy_around_fundamental": be,
885
+ "envelope_statistics": env_stats,
886
+ "summary": {
887
+ "brb_severity": brb["severity"],
888
+ "eccentricity_severity": ecc["severity"],
889
+ "stator_severity": stator["severity"],
890
+ "envelope_kurtosis": env_stats["kurtosis"],
891
+ "overall_assessment": _overall_assessment(brb, ecc, stator, env_stats),
892
+ },
893
+ }
894
+
895
+ return json.dumps(report, indent=2, default=str)
896
+
897
+
898
+ # ===================================================================
899
+ # PROMPT: Guided analysis
900
+ # ===================================================================
901
+
902
+ @mcp.prompt()
903
+ def analyze_motor_current(
904
+ motor_type: str = "induction",
905
+ supply_freq_hz: str = "50",
906
+ poles: str = "4",
907
+ rotor_speed_rpm: str = "1470",
908
+ ) -> str:
909
+ """Step-by-step guided prompt for MCSA analysis of a motor current signal."""
910
+ return f"""You are performing Motor Current Signature Analysis (MCSA) on a {motor_type} motor.
911
+
912
+ Motor parameters:
913
+ - Supply frequency: {supply_freq_hz} Hz
914
+ - Poles: {poles}
915
+ - Rotor speed: {rotor_speed_rpm} RPM
916
+
917
+ Follow this diagnostic workflow:
918
+
919
+ 1. **Load the current signal**:
920
+ - From a file: use `inspect_signal_file` to check the format, then `load_signal_from_file`
921
+ - Supported formats: CSV, TSV, WAV, NumPy NPY
922
+ - Or generate a synthetic signal with `generate_test_current_signal`
923
+
924
+ 2. **Calculate motor parameters** with `calculate_motor_params` to get slip, synchronous speed, and rotor frequency.
925
+
926
+ 3. **Compute expected fault frequencies** with `compute_fault_frequencies` to know where to look in the spectrum.
927
+
928
+ 4. **Preprocess** the signal with `preprocess_signal` (DC removal, windowing, optional filtering).
929
+
930
+ 5. **Compute the spectrum** with `compute_spectrum` and/or `compute_power_spectral_density`.
931
+
932
+ 6. **Detect faults**:
933
+ - `detect_broken_rotor_bars` — checks (1 ± 2s)·f_s sidebands
934
+ - `detect_eccentricity` — checks f_s ± k·f_r sidebands
935
+ - `detect_stator_faults` — checks f_s ± 2k·f_r sidebands
936
+ - `detect_bearing_faults` — checks f_s ± k·f_defect (needs bearing geometry)
937
+
938
+ 7. **Envelope analysis** with `compute_envelope_spectrum` for mechanical/bearing signatures.
939
+
940
+ 8. Or use **one-shot shortcuts**:
941
+ - `run_full_diagnosis` full pipeline from signal array
942
+ - `diagnose_from_file` — full pipeline directly from a file path
943
+
944
+ Report findings with severity levels and actionable recommendations.
945
+ """
946
+
947
+
948
+ # ---------------------------------------------------------------------------
949
+ # Server entry
950
+ # ---------------------------------------------------------------------------
951
+
952
+ def serve(transport: Literal["stdio", "sse", "streamable-http"] = "stdio") -> None:
953
+ """Start the MCSA MCP server."""
954
+ mcp.run(transport=transport)