oceansen commited on
Commit
2062f38
·
verified ·
1 Parent(s): e1fd6c1

Update src/streamlit_app.py

Browse files

added code for machine vibration analysis

Files changed (1) hide show
  1. src/streamlit_app.py +660 -38
src/streamlit_app.py CHANGED
@@ -1,40 +1,662 @@
1
- import altair as alt
2
- import numpy as np
3
- import pandas as pd
4
  import streamlit as st
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
- """
7
- # Welcome to Streamlit!
8
-
9
- Edit `/streamlit_app.py` to customize this app to your heart's desire :heart:.
10
- If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
11
- forums](https://discuss.streamlit.io).
12
-
13
- In the meantime, below is an example of what you can do with just a few lines of code:
14
- """
15
-
16
- num_points = st.slider("Number of points in spiral", 1, 10000, 1100)
17
- num_turns = st.slider("Number of turns in spiral", 1, 300, 31)
18
-
19
- indices = np.linspace(0, 1, num_points)
20
- theta = 2 * np.pi * num_turns * indices
21
- radius = indices
22
-
23
- x = radius * np.cos(theta)
24
- y = radius * np.sin(theta)
25
-
26
- df = pd.DataFrame({
27
- "x": x,
28
- "y": y,
29
- "idx": indices,
30
- "rand": np.random.randn(num_points),
31
- })
32
-
33
- st.altair_chart(alt.Chart(df, height=700, width=700)
34
- .mark_point(filled=True)
35
- .encode(
36
- x=alt.X("x", axis=None),
37
- y=alt.Y("y", axis=None),
38
- color=alt.Color("idx", legend=None, scale=alt.Scale()),
39
- size=alt.Size("rand", legend=None, scale=alt.Scale(range=[1, 150])),
40
- ))
 
 
 
 
1
  import streamlit as st
2
+ import pandas as pd
3
+ import numpy as np
4
+ import json
5
+ import plotly.graph_objects as go
6
+ from datetime import datetime
7
+ import re
8
+ from typing import Tuple, Optional, Dict, List
9
+
10
+ # --------------------------------------------------
11
+ # Page setup
12
+ # --------------------------------------------------
13
+ st.set_page_config(page_title="Machine Vibration Analysis App", layout="wide")
14
+
15
+ # Title and description
16
+ st.title("Machine Vibration Analysis App")
17
+ st.markdown(
18
+ "Upload a JSON file to see variables with clear descriptions and per-channel plots. \n"
19
+ "**Memory (natural text)** now explains the tool-break result using *key frequencies* and their amplitudes. \n"
20
+ "**ML Training (Key‑freq only)** exports features built strictly from key frequencies (fr, ft, k·ft and sidebands) to predict tool breakage. \n"
21
+ "Key Frequencies tab shows spindle (fr), tooth‑passing (ft), TPF harmonics, and once‑per‑rev sidebands (± n·fr) with amplitude markers."
22
+ )
23
+
24
+ # --------------------------------------------------
25
+ # Sidebar: upload and settings
26
+ # --------------------------------------------------
27
+ st.sidebar.header("Settings")
28
+ uploaded_file = st.sidebar.file_uploader("Upload JSON file", type="json")
29
+
30
+ harmonics_count = st.sidebar.number_input(
31
+ "Number of harmonics to compute (for RPM-based analysis)", min_value=1, max_value=200, value=10, step=1
32
+ )
33
+
34
+ top_n = st.sidebar.number_input(
35
+ "Top-N harmonics to list (for text)", min_value=1, max_value=int(harmonics_count), value=min(5, int(harmonics_count)), step=1
36
+ )
37
+
38
+ first_harmonic_threshold = st.sidebar.number_input(
39
+ "List Top-N only if 1st harm. amplitude ≥", min_value=0.0, value=1000.0, step=100.0
40
+ )
41
+
42
+ # (this still governs how many TPF harmonics to consider in Key Frequencies)
43
+ k_tpf = st.sidebar.number_input(
44
+ "TPF harmonics K (for Key Frequencies)", min_value=1, max_value=200, value=10, step=1
45
+ )
46
+ include_sidebands = st.sidebar.checkbox(
47
+ "Add once-per-rev sidebands (± n·fr) around TPF harmonics", value=True
48
+ )
49
+ max_sideband_order = st.sidebar.number_input(
50
+ "Max sideband order n (0 = none)", min_value=0, max_value=10, value=1, step=1
51
+ )
52
+ annotate_amplitudes = st.sidebar.checkbox("Annotate amplitudes at key frequencies", value=True)
53
+ annotation_min_amp = st.sidebar.number_input(
54
+ "Annotation min amplitude (hide labels below)", min_value=0.0, value=0.0, step=1.0
55
+ )
56
+ apply_hann = st.sidebar.checkbox("Apply Hann window before FFT (recommended)", value=True)
57
+
58
+ # --------------------------------------------------
59
+ # Variable descriptions
60
+ # --------------------------------------------------
61
+ VAR_DESCRIPTIONS = {
62
+ "d": "Tool diameter [mm]",
63
+ "z": "Number of teeth [-]",
64
+ "ap": "Axial depth of cut [mm]",
65
+ "ae": "Radial depth of cut [mm]",
66
+ "n": "Turning speed [rpm]",
67
+ "f": "Feed per tooth [mm/z]",
68
+ "type": "Type of machining (down=in accordance, up=in opposition)",
69
+ "break": "Tool breakage (true=broken, false=intact)",
70
+ "sample_frequency": "Sampling frequency [Hz]",
71
+ "acel_x": "Accelerometer X-axis [m/s^2]",
72
+ "acel_y": "Accelerometer Y-axis [m/s^2]",
73
+ }
74
+
75
+ def describe_key(k):
76
+ return VAR_DESCRIPTIONS.get(k, k.replace("_", " ").capitalize())
77
+
78
+
79
+ def slug(s: str) -> str:
80
+ """Safe feature name component."""
81
+ return re.sub(r"[^A-Za-z0-9]+", "_", str(s)).strip("_").lower()
82
+
83
+
84
+ # --------------------------------------------------
85
+ # Utility helpers
86
+ # --------------------------------------------------
87
+
88
+ def nearest_bin_amplitude(xf: np.ndarray, amp: np.ndarray, freq: float) -> Tuple[float, float]:
89
+ """Return (bin_freq, amplitude) nearest to freq. If out of range, (nan, nan)."""
90
+ if freq is None or freq <= 0 or len(xf) == 0 or np.isnan(freq) or freq > xf[-1]:
91
+ return (float("nan"), float("nan"))
92
+ idx = int(np.argmin(np.abs(xf - freq)))
93
+ return (float(xf[idx]), float(amp[idx]))
94
+
95
+
96
+ def fmt_float(x, sig=4):
97
+ try:
98
+ if x is None or (isinstance(x, float) and not np.isfinite(x)):
99
+ return "n/a"
100
+ if isinstance(x, (int, np.integer)) or (isinstance(x, float) and x.is_integer()):
101
+ return f"{int(x)}"
102
+ return f"{x:.{sig}g}"
103
+ except Exception:
104
+ return str(x)
105
+
106
+
107
+ @st.cache_data(show_spinner=False)
108
+ def compute_fft(signal: np.ndarray, fs: float, apply_hann: bool = True) -> Tuple[np.ndarray, np.ndarray]:
109
+ """Compute single-sided FFT amplitude spectrum."""
110
+ if signal.size == 0 or fs <= 0:
111
+ return np.array([]), np.array([])
112
+ sig = signal.astype(float)
113
+ if apply_hann:
114
+ w = np.hanning(sig.size)
115
+ sig = sig * w
116
+ yf = np.fft.rfft(sig)
117
+ xf = np.fft.rfftfreq(sig.size, 1.0 / fs)
118
+ amp = np.abs(yf)
119
+ return xf, amp
120
+
121
+
122
+ # --------------------------------------------------
123
+ # File upload and processing
124
+ # --------------------------------------------------
125
+ if uploaded_file:
126
+ data = json.load(uploaded_file)
127
+
128
+ # ---- Normalize channels ---------------------------------------------------
129
+ channels = [k for k in data.keys() if k.startswith("Channel_")]
130
+ axis_keys = [k for k in data.keys() if k.lower() in ("acel_x", "acel_y")]
131
+ if axis_keys and not channels:
132
+ for k in axis_keys:
133
+ v = data.get(k, [])
134
+ data[f"Channel_{k.upper()}"] = {
135
+ "SignalName": describe_key(k),
136
+ "Signal": v,
137
+ "Unit": "m/s^2",
138
+ }
139
+ channels = [k for k in data.keys() if k.startswith("Channel_")]
140
+
141
+ selected_channels = st.sidebar.multiselect(
142
+ "Select Channels to Display (default: all)", channels, default=channels
143
+ )
144
+
145
+ # ---- Breakage flag --------------------------------------------------------
146
+ broke = bool(data.get("break", False))
147
+ st.sidebar.error("Tool Breakage: Yes" if broke else "Tool Breakage: No")
148
+
149
+ # ---- Variables & Header ---------------------------------------------------
150
+ blacklist = {"__header__", "__version__", "__globals__", "File_Header"}
151
+ root_scalars = {
152
+ k: v
153
+ for k, v in data.items()
154
+ if not isinstance(v, dict)
155
+ and k not in blacklist
156
+ and not isinstance(v, (list, tuple))
157
+ }
158
+ file_header = data.get("File_Header", {})
159
+
160
+ col1, col2 = st.columns(2)
161
+ with col1:
162
+ st.subheader("File Variables")
163
+ if root_scalars:
164
+ df_vars = (
165
+ pd.DataFrame({"Key": list(root_scalars.keys()), "Value": list(root_scalars.values())})
166
+ .assign(Description=lambda d: d["Key"].map(describe_key))
167
+ .set_index("Key")
168
+ )
169
+ st.table(df_vars[["Description", "Value"]])
170
+ else:
171
+ st.caption("No scalar variables found in the root of the JSON.")
172
+ with col2:
173
+ st.subheader("File Header")
174
+ if file_header:
175
+ df_header = pd.DataFrame(file_header, index=[0]).T.rename(columns={0: "Value"})
176
+ st.table(df_header)
177
+ else:
178
+ st.caption("No 'File_Header' found.")
179
+
180
+ # ---- Sample frequency & fundamental --------------------------------------
181
+ fs = float(data.get("sample_frequency") or file_header.get("SampleFrequency", 1.0) or 1.0)
182
+
183
+ f_fund, n_rpm = None, None
184
+ if isinstance(data.get("n"), (int, float)) and data["n"] != 0:
185
+ n_rpm = float(data["n"])
186
+ f_fund = n_rpm / 60.0
187
+ else:
188
+ st.warning("Fundamental frequency not found: expected numeric key 'n' (RPM).")
189
+
190
+ # ---- Teeth / TPF ----------------------------------------------------------
191
+ z_teeth: Optional[int] = None
192
+ if isinstance(data.get("z"), (int, float)) and data["z"] > 0:
193
+ z_teeth = int(data["z"]) # number of flutes/teeth
194
+
195
+ fr = f_fund if f_fund else None # spindle rotational frequency
196
+ ft = (z_teeth * fr) if (z_teeth and fr) else None # tooth-passing frequency
197
+
198
+ # --------------------------------------------------
199
+ # Pre-pass: compute harmonics & quick stats (RPM-based)
200
+ # --------------------------------------------------
201
+ harmonic_tables: Dict[str, Tuple[str, str, Optional[pd.DataFrame]]] = {}
202
+ bin_res_by_ch: Dict[str, float] = {}
203
+ stats_by_ch: Dict[str, Dict[str, float]] = {}
204
+ dom_by_ch: Dict[str, str] = {}
205
+
206
+ for ch in selected_channels:
207
+ ch_data = data.get(ch, {})
208
+ label = ch_data.get("SignalName", ch)
209
+ signal = np.asarray(ch_data.get("Signal", []), dtype=float)
210
+ unit = ch_data.get("Unit", "")
211
+
212
+ if signal.size == 0 or fs <= 0:
213
+ harmonic_tables[ch] = (label, unit, None)
214
+ continue
215
+
216
+ xf, amp = compute_fft(signal, fs, apply_hann)
217
+ bin_res = xf[1] - xf[0] if len(xf) > 1 else float("nan")
218
+ bin_res_by_ch[ch] = bin_res
219
+
220
+ # stats
221
+ rms = float(np.sqrt(np.mean(signal ** 2))) if signal.size > 0 else np.nan
222
+ peak = float(np.max(np.abs(signal))) if signal.size > 0 else np.nan
223
+ stats_by_ch[ch] = {"rms": rms, "peak": peak, "unit": unit}
224
+
225
+ df_h = None
226
+ dom_text = "n/a"
227
+
228
+ if f_fund and np.isfinite(f_fund) and len(xf) > 0:
229
+ harmonics_idx = np.arange(1, int(harmonics_count) + 1)
230
+ harmonics_freqs = harmonics_idx * f_fund
231
+
232
+ harm_amps, bin_freqs = [], []
233
+ for f_h in harmonics_freqs:
234
+ bfreq, a = nearest_bin_amplitude(xf, amp, f_h)
235
+ harm_amps.append(a)
236
+ bin_freqs.append(bfreq)
237
+
238
+ df_h = pd.DataFrame(
239
+ {
240
+ "Harmonic #": harmonics_idx,
241
+ "Target f [Hz]": np.round(harmonics_freqs, 6),
242
+ "Bin f [Hz]": np.round(bin_freqs, 6),
243
+ "Amplitude": harm_amps,
244
+ }
245
+ )
246
+
247
+ if np.isfinite(df_h["Amplitude"]).any():
248
+ idx_dom = df_h["Amplitude"].astype(float).idxmax()
249
+ dom_row = df_h.loc[idx_dom]
250
+ dom_text = (
251
+ f"{int(dom_row['Harmonic #'])}× @ {dom_row['Bin f [Hz]']:.2f} Hz (amp {dom_row['Amplitude']:.3g}{(' ' + unit) if unit else ''})"
252
+ )
253
+
254
+ harmonic_tables[ch] = (label, unit, df_h)
255
+ dom_by_ch[ch] = dom_text
256
+
257
+ # --------------------------------------------------
258
+ # Helper: compute per‑channel key‑frequency amplitudes & sidebands
259
+ # --------------------------------------------------
260
+ def compute_keyfreqs_for_channel(xf, amp, fr, ft, k_tpf: int, include_sb: bool, sb_orders: int):
261
+ """Return dict with fr amplitude, list of k*ft amplitudes, and primary sideband ratios (n=1) per k."""
262
+ out = {
263
+ "fr": {"target_hz": fr, "bin_hz": float("nan"), "amp": float("nan")},
264
+ "tpf": [], # list of {k, target_hz, bin_hz, amp, sbr_n1}
265
+ }
266
+ if xf is None or len(xf) == 0:
267
+ return out
268
+ # spindle
269
+ if fr:
270
+ bfreq_fr, a_fr = nearest_bin_amplitude(xf, amp, fr)
271
+ out["fr"] = {"target_hz": fr, "bin_hz": bfreq_fr, "amp": a_fr}
272
+ # TPF harmonics
273
+ if ft:
274
+ fmax = xf[-1]
275
+ for k in range(1, int(k_tpf) + 1):
276
+ target = k * ft
277
+ if target > fmax:
278
+ break
279
+ bfreq_k, a_k = nearest_bin_amplitude(xf, amp, target)
280
+ sbr = float("nan")
281
+ if include_sb and fr and sb_orders >= 1 and np.isfinite(a_k) and a_k > 0:
282
+ # n=1 sidebands only for SBR metric
283
+ _, a_m = nearest_bin_amplitude(xf, amp, max(0.0, target - fr))
284
+ _, a_p = nearest_bin_amplitude(xf, amp, target + fr)
285
+ if np.isfinite(a_m) and np.isfinite(a_p):
286
+ sbr = (a_m + a_p) / a_k if a_k else float("nan")
287
+ out["tpf"].append({"k": k, "target_hz": target, "bin_hz": bfreq_k, "amp": a_k, "sbr_n1": sbr})
288
+ return out
289
+
290
+ # spectra cache for key‑freq computations
291
+ spectra_cache: Dict[str, Tuple[np.ndarray, np.ndarray]] = {}
292
+ for ch in selected_channels:
293
+ ch_data = data.get(ch, {})
294
+ signal = np.asarray(ch_data.get("Signal", []), dtype=float)
295
+ if signal.size > 0 and fs > 0:
296
+ xf, amp = compute_fft(signal, fs, apply_hann)
297
+ spectra_cache[ch] = (xf, amp)
298
+ else:
299
+ spectra_cache[ch] = (np.array([]), np.array([]))
300
+
301
+ keyfreq_by_channel: Dict[str, dict] = {}
302
+ for ch in selected_channels:
303
+ label = data.get(ch, {}).get("SignalName", ch)
304
+ xf, amp = spectra_cache.get(ch, (np.array([]), np.array([])))
305
+ keyfreq_by_channel[label] = compute_keyfreqs_for_channel(
306
+ xf, amp, fr, ft, int(k_tpf), bool(include_sidebands), int(max_sideband_order)
307
+ )
308
+
309
+ # --------------------------------------------------
310
+ # Memory (natural language) – EXPLANATION based on key frequencies
311
+ # --------------------------------------------------
312
+ st.subheader("Memory (natural text)")
313
+
314
+ header_context_text = "; ".join([f"{k}={file_header[k]}" for k in file_header]) or "no header context"
315
+ n_text = f"{fmt_float(n_rpm)} RPM" if n_rpm else "n/a"
316
+ f0_text = f"{fmt_float(f_fund)} Hz" if f_fund else "n/a"
317
+ fs_text = f"{fmt_float(fs)} Hz" if np.isfinite(fs) else "n/a"
318
+ break_text = "YES" if broke else "NO"
319
+
320
+ # Build channel-specific interpretations from key‑frequency amplitudes
321
+ channel_summaries: List[str] = []
322
+ for ch in selected_channels:
323
+ label, unit, _ = harmonic_tables[ch]
324
+ s = stats_by_ch.get(ch, {})
325
+ rms = s.get("rms", np.nan)
326
+ kf = keyfreq_by_channel.get(label, {})
327
+ fr_amp = kf.get("fr", {}).get("amp", np.nan)
328
+ fr_bin = kf.get("fr", {}).get("bin_hz", np.nan)
329
+ tpf_list = kf.get("tpf", [])
330
+ if tpf_list:
331
+ # metrics: max TPF amp and mean SBR (n=1)
332
+ max_tpf = max(tpf_list, key=lambda r: (r.get("amp") if np.isfinite(r.get("amp", np.nan)) else -1))
333
+ mean_sbr = np.nan
334
+ if any(np.isfinite(r.get("sbr_n1", np.nan)) for r in tpf_list):
335
+ vals = [r.get("sbr_n1") for r in tpf_list if np.isfinite(r.get("sbr_n1", np.nan))]
336
+ mean_sbr = float(np.mean(vals)) if len(vals) else np.nan
337
+ summary = (
338
+ f"**{label}**: spindle fr≈{fmt_float(fr_bin)} Hz has amplitude {fmt_float(fr_amp)}{(' ' + unit) if unit else ''}; "
339
+ f"TPF harmonics peak at k={max_tpf.get('k')} (f≈{fmt_float(max_tpf.get('bin_hz'))} Hz) "
340
+ f"with amp {fmt_float(max_tpf.get('amp'))}{(' ' + unit) if unit else ''}. "
341
+ f"Primary sideband ratio (±fr) ≈ {fmt_float(mean_sbr)}."
342
+ )
343
+ else:
344
+ summary = (
345
+ f"**{label}**: spindle fr≈{fmt_float(fr_bin)} Hz amp {fmt_float(fr_amp)}{(' ' + unit) if unit else ''}; "
346
+ "TPF harmonics not within spectrum range."
347
+ )
348
+ if np.isfinite(rms):
349
+ summary += f" RMS ≈ {fmt_float(rms)}{(' ' + unit) if unit else ''}."
350
+ channel_summaries.append(summary)
351
+
352
+ # Overall qualitative cue (non-binding heuristic for narrative only)
353
+ # Heuristic: if many TPF harmonics are strong and sidebands are pronounced, narrative highlights possible damage.
354
+ def heuristic_break_signal(channel_kf: Dict[str, dict]) -> str:
355
+ flags = 0
356
+ for label, kf in channel_kf.items():
357
+ fr_amp = kf.get("fr", {}).get("amp", np.nan)
358
+ tpf_list = kf.get("tpf", [])
359
+ strong_tpf = sum(1 for r in tpf_list if np.isfinite(r.get("amp", np.nan)) and r["amp"] > (fr_amp if np.isfinite(fr_amp) else 0))
360
+ sbr_vals = [r.get("sbr_n1") for r in tpf_list if np.isfinite(r.get("sbr_n1", np.nan))]
361
+ mean_sbr = (np.mean(sbr_vals) if sbr_vals else 0)
362
+ if strong_tpf >= 3:
363
+ flags += 1
364
+ if mean_sbr and mean_sbr > 0.7:
365
+ flags += 1
366
+ if flags >= 2:
367
+ return "Key‑frequency pattern shows strong TPF content and pronounced sidebands, which often accompanies tool damage or chipping."
368
+ elif flags == 1:
369
+ return "Key‑frequency content shows some TPF/sideband prominence; monitor for degradation."
370
+ else:
371
+ return "Key‑frequency content is modest; spectra are consistent with an intact tool during stable cutting."
372
+
373
+ narrative_hint = heuristic_break_signal(keyfreq_by_channel)
374
+
375
+ mem_text = (
376
+ "Machine vibration snapshot — tool break label: "
377
+ f"{break_text}. Spindle speed n = {n_text}, fundamental f₀ = {f0_text}, sampling fs = {fs_text}. "
378
+ + (f"Key frequencies: spindle f_r={fmt_float(fr)} Hz" if fr else "")
379
+ + (f", tooth‑passing f_t={fmt_float(ft)} Hz (Z={z_teeth}). " if ft else ". ")
380
+ + f"File header context: {header_context_text}. "
381
+ + narrative_hint + " "
382
+ + " ".join(channel_summaries)
383
+ )
384
+
385
+ st.write(mem_text)
386
+
387
+ # Memory payload (keeps full amplitudes for downstream use)
388
+ memory_payload = {
389
+ "type": "vibration_memory_text",
390
+ "schema_version": 8, # bumped for key‑freq explanation text
391
+ "created_at": datetime.utcnow().isoformat() + "Z",
392
+ "tool_break": broke,
393
+ "n_rpm": n_rpm,
394
+ "f0_hz": f_fund,
395
+ "sample_frequency_hz": fs,
396
+ "z_teeth": z_teeth,
397
+ "fr_hz": fr,
398
+ "ft_hz": ft,
399
+ "file_header": file_header,
400
+ "text": mem_text,
401
+ "key_frequencies_by_channel": keyfreq_by_channel,
402
+ }
403
+
404
+ colmj, colmt = st.columns(2)
405
+ with colmj:
406
+ st.download_button(
407
+ "⬇️ Download Memory (JSON)",
408
+ data=json.dumps(memory_payload, ensure_ascii=False, indent=2).encode("utf-8"),
409
+ file_name="machine_vibration_memory_text.json",
410
+ mime="application/json",
411
+ )
412
+ with colmt:
413
+ st.download_button(
414
+ "⬇️ Download Memory (TXT)",
415
+ data=mem_text.encode("utf-8"),
416
+ file_name="machine_vibration_memory.txt",
417
+ mime="text/plain",
418
+ )
419
+
420
+ st.divider()
421
+
422
+ # --------------------------------------------------
423
+ # ML Training (Key‑freq only)
424
+ # --------------------------------------------------
425
+ st.subheader("ML Training (Key‑freq only)")
426
+ st.caption(
427
+ "Single input row using only amplitudes from key frequencies: spindle fr and TPF harmonics k·ft (with optional sideband ratio SBR at ±fr). Target is `break` (boolean) provided separately."
428
+ )
429
+
430
+ # Build a single feature row composed *only* of key‑frequency features
431
+ feature_row = {}
432
+
433
+ # Global context — optionally include fr and ft as numeric context features
434
+ if np.isfinite(fr) if fr is not None else False:
435
+ feature_row["global_fr_hz"] = float(fr)
436
+ if np.isfinite(ft) if ft is not None else False:
437
+ feature_row["global_ft_hz"] = float(ft)
438
+
439
+ # Per‑channel key‑frequency features
440
+ for ch in selected_channels:
441
+ label = data.get(ch, {}).get("SignalName", ch)
442
+ prefix = slug(label) or slug(ch)
443
+ kf = keyfreq_by_channel.get(label, {})
444
+ fr_amp = kf.get("fr", {}).get("amp", np.nan)
445
+ fr_bin = kf.get("fr", {}).get("bin_hz", np.nan)
446
+ feature_row[f"{prefix}_fr_amp"] = float(fr_amp) if np.isfinite(fr_amp) else None
447
+ feature_row[f"{prefix}_fr_bin_hz"] = float(fr_bin) if np.isfinite(fr_bin) else None
448
+
449
+ tpf_list = kf.get("tpf", [])
450
+ for r in tpf_list:
451
+ k_idx = int(r.get("k", 0))
452
+ a = r.get("amp", np.nan)
453
+ b = r.get("bin_hz", np.nan)
454
+ sbr = r.get("sbr_n1", np.nan)
455
+ feature_row[f"{prefix}_tpf_h{k_idx}_amp"] = float(a) if np.isfinite(a) else None
456
+ feature_row[f"{prefix}_tpf_h{k_idx}_bin_hz"] = float(b) if np.isfinite(b) else None
457
+ # Sideband ratio (n=1)
458
+ feature_row[f"{prefix}_tpf_h{k_idx}_sbr"] = float(sbr) if np.isfinite(sbr) else None
459
+
460
+ # Lightweight summary stats for learning stability (still key‑freq derived)
461
+ if tpf_list:
462
+ amps = [r.get("amp", np.nan) for r in tpf_list]
463
+ sbrs = [r.get("sbr_n1", np.nan) for r in tpf_list]
464
+ if any(np.isfinite(amps)):
465
+ feature_row[f"{prefix}_tpf_amp_max"] = float(np.nanmax(amps))
466
+ feature_row[f"{prefix}_tpf_amp_mean"] = float(np.nanmean(amps))
467
+ if any(np.isfinite(sbrs)):
468
+ feature_row[f"{prefix}_tpf_sbr_mean"] = float(np.nanmean(sbrs))
469
+
470
+ # One‑row DataFrame for editing; target kept separately
471
+ df_feat = pd.DataFrame([feature_row])
472
+
473
+ col_left, col_right = st.columns([3, 1])
474
+ with col_left:
475
+ edited_df_feat = st.data_editor(
476
+ df_feat,
477
+ use_container_width=True,
478
+ num_rows="fixed",
479
+ column_config={c: st.column_config.NumberColumn(format="%.6g") for c in df_feat.columns},
480
+ )
481
+ with col_right:
482
+ st.metric("Target: break", "YES" if broke else "NO")
483
+ st.caption("Provided separately from features")
484
+
485
+ # Export JSON & CSV
486
+ ebm_payload = {
487
+ "schema_version": 5, # bumped — key‑freq‑only features
488
+ "created_at": datetime.utcnow().isoformat() + "Z",
489
+ "task": "tool_breakage_detection",
490
+ "target": {"break": broke},
491
+ "features": edited_df_feat.to_dict(orient="records")[0],
492
+ }
493
+
494
+ colj, colc = st.columns(2)
495
+ with colj:
496
+ st.download_button(
497
+ "⬇️ Download ML input (JSON)",
498
+ data=json.dumps(ebm_payload, ensure_ascii=False, indent=2).encode("utf-8"),
499
+ file_name="machine_vibration_keyfreq_input.json",
500
+ mime="application/json",
501
+ )
502
+ with colc:
503
+ st.download_button(
504
+ "⬇️ Download ML input (CSV)",
505
+ data=edited_df_feat.to_csv(index=False).encode("utf-8"),
506
+ file_name="machine_vibration_keyfreq_input.csv",
507
+ mime="text/csv",
508
+ )
509
+
510
+ st.divider()
511
+
512
+ # --------------------------------------------------
513
+ # Per-channel plots (time, freq, Key Frequencies)
514
+ # --------------------------------------------------
515
+ if selected_channels:
516
+ tabs = st.tabs([harmonic_tables[ch][0] for ch in selected_channels])
517
+ for tab, ch in zip(tabs, selected_channels):
518
+ with tab:
519
+ label, unit, _ = harmonic_tables[ch]
520
+ ch_data = data.get(ch, {})
521
+ signal = np.asarray(ch_data.get("Signal", []), dtype=float)
522
+ if signal.size == 0:
523
+ st.error("No signal data found for this channel.")
524
+ continue
525
+
526
+ N = len(signal)
527
+ t = np.arange(N) / fs
528
+ xf, amp = compute_fft(signal, fs, apply_hann)
529
+
530
+ st.markdown(
531
+ f"**Channel:** `{ch}` \n"
532
+ f"**Name:** **{label}** \n"
533
+ f"**Samples:** {N} \n"
534
+ f"**fs:** {fs:g} Hz \n"
535
+ f"**Bin Δf:** {fmt_float(xf[1]-xf[0] if len(xf)>1 else float('nan'))} Hz"
536
+ )
537
+
538
+ t_tab, f_tab, key_tab = st.tabs(["Time Domain", "Frequency Domain", "Key Frequencies"]) # improved
539
+ with t_tab:
540
+ fig = go.Figure(go.Scatter(x=t, y=signal, mode="lines", name=label))
541
+ fig.update_layout(xaxis_title="Time [s]", yaxis_title=unit or "Amplitude")
542
+ st.plotly_chart(fig, use_container_width=True)
543
+
544
+ with f_tab:
545
+ fig = go.Figure(go.Scatter(x=xf, y=amp, mode="lines", name=label))
546
+ if f_fund:
547
+ for f_h in np.arange(1, int(harmonics_count) + 1) * (f_fund or 0):
548
+ if f_h <= (xf[-1] if len(xf) else 0):
549
+ fig.add_vline(x=f_h, line_width=1, line_dash="dash", opacity=0.35)
550
+ fig.update_layout(xaxis_title="Frequency [Hz]", yaxis_title="Amplitude")
551
+ st.plotly_chart(fig, use_container_width=True)
552
+
553
+ # --- Key Frequencies tab ---
554
+ with key_tab:
555
+ if fr is None and ft is None:
556
+ st.info("Key Frequencies require 'n' (RPM) and 'z' (number of teeth). Provide these in the JSON.")
557
+ else:
558
+ # Base spectrum
559
+ figkf = go.Figure()
560
+ figkf.add_trace(go.Scatter(x=xf, y=amp, mode="lines", name=label, opacity=0.45))
561
+
562
+ rows = []
563
+ x_spindle, y_spindle, txt_spindle = [], [], []
564
+ x_tpf, y_tpf, txt_tpf = [], [], []
565
+ x_sb, y_sb, txt_sb = [], [], []
566
+
567
+ # Helper to maybe annotate
568
+ def _maybe_text(a: float, prefix: str) -> str:
569
+ if not annotate_amplitudes or not np.isfinite(a) or a < float(annotation_min_amp):
570
+ return ""
571
+ return f"{prefix}{fmt_float(a, sig=4)}"
572
+
573
+ fmax = xf[-1] if len(xf) else 0
574
+
575
+ # Spindle line & marker
576
+ if fr:
577
+ bfreq_fr, a_fr = nearest_bin_amplitude(xf, amp, fr)
578
+ rows.append({"Type": "Spindle (fr)", "k": 1, "Target f [Hz]": fr, "Bin f [Hz]": bfreq_fr, "Amplitude": a_fr})
579
+ if np.isfinite(bfreq_fr) and np.isfinite(a_fr):
580
+ figkf.add_vline(x=bfreq_fr, line_width=2, line_dash="dot", opacity=0.7)
581
+ x_spindle.append(bfreq_fr); y_spindle.append(a_fr); txt_spindle.append(_maybe_text(a_fr, "A= "))
582
+
583
+ # TPF harmonics and sidebands
584
+ if ft:
585
+ for k in range(1, int(k_tpf) + 1):
586
+ target = k * ft
587
+ if target > fmax:
588
+ break
589
+ bfreq_k, a_k = nearest_bin_amplitude(xf, amp, target)
590
+ rows.append({"Type": "TPF", "k": k, "Target f [Hz]": target, "Bin f [Hz]": bfreq_k, "Amplitude": a_k})
591
+ if np.isfinite(bfreq_k):
592
+ figkf.add_vline(x=bfreq_k, line_width=1, line_dash="dash", opacity=0.6)
593
+ x_tpf.append(bfreq_k); y_tpf.append(a_k); txt_tpf.append(_maybe_text(a_k, "A= "))
594
+ # multiple sideband orders: ± n·fr
595
+ if include_sidebands and fr and int(max_sideband_order) > 0:
596
+ for n_sb in range(1, int(max_sideband_order) + 1):
597
+ f_minus = max(0.0, target - n_sb * fr)
598
+ f_plus = target + n_sb * fr
599
+ if f_minus <= fmax:
600
+ bfreq_m, a_m = nearest_bin_amplitude(xf, amp, f_minus)
601
+ rows.append({"Type": f"Sideband -{n_sb}", "k": k, "Target f [Hz]": f_minus, "Bin f [Hz]": bfreq_m, "Amplitude": a_m})
602
+ if np.isfinite(bfreq_m):
603
+ figkf.add_vline(x=bfreq_m, line_width=1, line_dash="dot", opacity=0.35)
604
+ x_sb.append(bfreq_m); y_sb.append(a_m); txt_sb.append(_maybe_text(a_m, f"A= "))
605
+ if f_plus <= fmax:
606
+ bfreq_p, a_p = nearest_bin_amplitude(xf, amp, f_plus)
607
+ rows.append({"Type": f"Sideband +{n_sb}", "k": k, "Target f [Hz]": f_plus, "Bin f [Hz]": bfreq_p, "Amplitude": a_p})
608
+ if np.isfinite(bfreq_p):
609
+ figkf.add_vline(x=bfreq_p, line_width=1, line_dash="dot", opacity=0.35)
610
+ x_sb.append(bfreq_p); y_sb.append(a_p); txt_sb.append(_maybe_text(a_p, f"A= "))
611
+
612
+ # Add markers with optional labels
613
+ if x_spindle:
614
+ figkf.add_trace(
615
+ go.Scatter(
616
+ x=x_spindle, y=y_spindle, mode="markers+text" if annotate_amplitudes else "markers",
617
+ text=txt_spindle if annotate_amplitudes else None, textposition="top center",
618
+ name="Spindle fr", marker_symbol="diamond", marker_size=10,
619
+ )
620
+ )
621
+ if x_tpf:
622
+ figkf.add_trace(
623
+ go.Scatter(
624
+ x=x_tpf, y=y_tpf, mode="markers+text" if annotate_amplitudes else "markers",
625
+ text=txt_tpf if annotate_amplitudes else None, textposition="top center",
626
+ name="TPF harmonics k·ft", marker_symbol="x", marker_size=9,
627
+ )
628
+ )
629
+ if x_sb:
630
+ figkf.add_trace(
631
+ go.Scatter(
632
+ x=x_sb, y=y_sb, mode="markers+text" if annotate_amplitudes else "markers",
633
+ text=txt_sb if annotate_amplitudes else None, textposition="top center",
634
+ name="Sidebands ± n·fr", marker_size=8,
635
+ )
636
+ )
637
+
638
+ figkf.update_layout(xaxis_title="Frequency [Hz]", yaxis_title=f"Amplitude{(' [' + unit + ']') if unit else ''}")
639
+ st.plotly_chart(figkf, use_container_width=True)
640
+
641
+ # Table of key frequencies
642
+ if rows:
643
+ df_kf = pd.DataFrame(rows)
644
+ st.dataframe(df_kf, use_container_width=True)
645
+ st.download_button(
646
+ label="⬇️ Download Key Frequencies (CSV)",
647
+ data=df_kf.to_csv(index=False).encode("utf-8"),
648
+ file_name=f"key_frequencies_{slug(label)}.csv",
649
+ mime="text/csv",
650
+ )
651
+ else:
652
+ st.caption("No key frequency data available for this channel.")
653
+ else:
654
+ st.info("Please upload a JSON file to get started.")
655
+
656
+ # --------------------------------------------------
657
+ # Footer
658
+ # --------------------------------------------------
659
+ st.markdown("<hr>", unsafe_allow_html=True)
660
+ st.caption("© Sagar Sen 2025 — Machine Vibration Analysis App")
661
+
662