import streamlit as st import numpy as np import pandas as pd from scipy import signal import plotly.graph_objects as go from plotly.subplots import make_subplots import io # 設定網頁標題與佈局 st.set_page_config(page_title="Difference Equation Analyzer", layout="wide") # 透過 CSS 縮小頂部留白與標題大小 st.markdown(""" """, unsafe_allow_html=True) st.markdown('
差分方程式 (Difference Equation) 試算與分析環境
', unsafe_allow_html=True) # --- 左側欄:濾波器參數設定 --- st.sidebar.header("濾波器係數設定") st.sidebar.markdown("
", unsafe_allow_html=True) # st.sidebar.markdown("請輸入差分方程式的係數,以逗號分隔。") # 1. 初始化 Session State 與邊界條件 if 'b_str' not in st.session_state: st.session_state.b_str = "0.5, 0.5, 0.0, 0.0" if 'a_str' not in st.session_state: st.session_state.a_str = "1.0, 0.0, 0.0, 0.0" def apply_constraints(val): # 放寬限制,回歸純數學運算的 Float64 本質 # 真正的 Dynamic Range 與 Quantization Error 會在 v1 的 fixed-point 模組中處理 return val def parse_to_padded_list(coeff_str, length=4): try: vals = [float(x.strip()) for x in coeff_str.split(',') if x.strip()] except ValueError: vals = [] # 套用限制 vals = [apply_constraints(v) for v in vals] while len(vals) < length: vals.append(0.0) return vals[:length] def format_exact_val(x): # 用於還原文字框或濾波器設計,提供 10 位有效數字確保數學精度 return f"{x:.10g}" def format_slider_val(x): # 用於拉桿微調時,只需顯示 6 位有效數字避免畫面過度冗長 return f"{x:g}" def update_base_b(): b_vals = parse_to_padded_list(st.session_state.b_str, length=7) st.session_state.base_b = b_vals for i in range(7): st.session_state[f'b_slider_{i}'] = 0.0 st.session_state.b_str = ", ".join(map(format_exact_val, b_vals)) def update_base_a(): a_vals = parse_to_padded_list(st.session_state.a_str, length=7) a_vals[0] = 1.0 st.session_state.base_a = a_vals for i in range(1, 7): st.session_state[f'a_slider_{i}'] = 0.0 st.session_state.a_str = ", ".join(map(format_exact_val, a_vals)) def update_base_from_text(): update_base_b() update_base_a() def reset_sliders_b(): for i in range(7): st.session_state[f'b_slider_{i}'] = 0.0 st.session_state.b_str = ", ".join(map(format_exact_val, st.session_state.base_b)) def reset_sliders_a(): for i in range(1, 7): st.session_state[f'a_slider_{i}'] = 0.0 st.session_state.a_str = ", ".join(map(format_exact_val, st.session_state.base_a)) def update_text_from_sliders(): current_b = [] current_a = [1.0] # a0 固定為 1.0 for i in range(7): log_val = st.session_state.get(f'b_slider_{i}', 0.0) m_b = 10.0 ** log_val val_b = apply_constraints(st.session_state.base_b[i] * m_b) current_b.append(val_b) for i in range(1, 7): log_val = st.session_state.get(f'a_slider_{i}', 0.0) m_a = 10.0 ** log_val val_a = apply_constraints(st.session_state.base_a[i] * m_a) current_a.append(val_a) st.session_state.b_str = ", ".join(map(format_slider_val, current_b)) st.session_state.a_str = ", ".join(map(format_slider_val, current_a)) def apply_filter_design(): f_type = st.session_state.get('filter_type', "(無) 手動自訂") if f_type == "(無) 手動自訂": return fs_val = st.session_state.get('sys_fs', 100000.0) b_new, a_new = [], [] try: if f_type == "Lowpass (低通)": order = st.session_state.get('lp_order', 1) fc = st.session_state.get('lp_fc', 1000.0) b_new, a_new = signal.butter(order, fc, btype='low', fs=fs_val) elif f_type == "Highpass (高通)": order = st.session_state.get('hp_order', 1) fc = st.session_state.get('hp_fc', 1000.0) b_new, a_new = signal.butter(order, fc, btype='high', fs=fs_val) elif f_type == "Bandpass (帶通)": order = st.session_state.get('bp_order', 1) f_low = st.session_state.get('bp_f_low', 500.0) f_high = st.session_state.get('bp_f_high', 2000.0) # 防呆:確保 f_low 必定小於 f_high if f_low >= f_high: f_low = f_high * 0.99 b_new, a_new = signal.butter(order, [f_low, f_high], btype='bandpass', fs=fs_val) elif f_type == "Notch (陷波器)": f0 = st.session_state.get('notch_f0', 120.0) q = st.session_state.get('notch_q', 1.0) b_new, a_new = signal.iirnotch(f0, q, fs=fs_val) elif f_type == "1P1Z (一極一零)": fp = st.session_state.get('opoz_fp', 10.0) fz = st.session_state.get('opoz_fz', 15000.0) # s-domain: H(s) = (s + 2*pi*fz) / (s + 2*pi*fp) b_s = [1.0, 2 * np.pi * fz] a_s = [1.0, 2 * np.pi * fp] b_new, a_new = signal.bilinear(b_s, a_s, fs=fs_val) # 歸一化 DC Gain dc_gain = np.sum(b_new) / np.sum(a_new) b_new = b_new / dc_gain elif f_type == "2P2Z (二極二零)": fp1 = st.session_state.get('tptz_fp1', 10.0) fp2 = st.session_state.get('tptz_fp2', 5000.0) fz1 = st.session_state.get('tptz_fz1', 200.0) fz2 = st.session_state.get('tptz_fz2', 25000.0) w_p1, w_p2 = 2 * np.pi * fp1, 2 * np.pi * fp2 w_z1, w_z2 = 2 * np.pi * fz1, 2 * np.pi * fz2 b_s = [1.0, w_z1 + w_z2, w_z1 * w_z2] a_s = [1.0, w_p1 + w_p2, w_p1 * w_p2] b_new, a_new = signal.bilinear(b_s, a_s, fs=fs_val) # 歸一化 DC Gain dc_gain = np.sum(b_new) / np.sum(a_new) b_new = b_new / dc_gain elif f_type == "PID 控制器": kp = st.session_state.get('pid_kp', 0.003) ki = st.session_state.get('pid_ki', 10.0) kd = st.session_state.get('pid_kd', 0.000016) # 工業界標準數位 PID 實作 (Position Form): # 積分項採用 Tustin (Bilinear):Ki * (Ts/2) * (z+1)/(z-1) # 微分項採用 後向歐拉 (Backward Euler) 以避免 Nyquist 震盪:Kd * (1/Ts) * (z-1)/z # 為了得到整齊的差分方程式 y[n] = y[n-1] + ...,我們將分母通分為 (z-1) # H(z) = Kp + Ki*(Ts/2)*(1+z^-1)/(1-z^-1) + Kd*fs*(1-z^-1) ki_term = ki / (2.0 * fs_val) kd_term = kd * fs_val b0 = kp + ki_term + kd_term b1 = -kp + ki_term - 2.0 * kd_term b2 = kd_term b_new = [b0, b1, b2] a_new = [1.0, -1.0, 0.0] # 分母為 1 - z^-1 (標準積分器 y[n] = y[n-1] + ...) elif f_type == "SOGI-Alpha (帶通)": f0 = st.session_state.get('sogi_f0', 60.0) k = st.session_state.get('sogi_k', 1.414) w0 = 2 * np.pi * f0 b_s = [k * w0, 0.0] a_s = [1.0, k * w0, w0**2] b_new, a_new = signal.bilinear(b_s, a_s, fs=fs_val) elif f_type == "SOGI-Beta (低通)": f0 = st.session_state.get('sogi_f0', 60.0) k = st.session_state.get('sogi_k', 1.414) w0 = 2 * np.pi * f0 b_s = [k * (w0**2)] a_s = [1.0, k * w0, w0**2] b_new, a_new = signal.bilinear(b_s, a_s, fs=fs_val) if len(b_new) > 0 and len(a_new) > 0: st.session_state.b_str = ", ".join([f"{x:.10g}" for x in b_new]) st.session_state.a_str = ", ".join([f"{x:.10g}" for x in a_new]) update_base_from_text() except Exception as e: pass if 'base_b' not in st.session_state or len(st.session_state.base_b) < 7: update_base_from_text() # b 係數區塊 st.sidebar.text_input("前饋分子係數對應輸入訊號 (b0 ~ b6)", key='b_str', on_change=update_base_b) with st.sidebar.expander("b 係數倍率微調", expanded=False): col1, col2 = st.columns([3, 1]) with col1: sense_b = st.radio("靈敏度", options=["1%", "2x", "10x"], index=1, horizontal=True, label_visibility="collapsed", key="sense_b", on_change=update_base_b) with col2: st.button("重置", on_click=reset_sliders_b, key="btn_reset_b", use_container_width=True) max_log_b = np.log10(1.01) if sense_b == "1%" else (np.log10(2.0) if sense_b == "2x" else 1.0) first_b = True for i in range(7): if st.session_state.base_b[i] == 0.0: continue label = f"b{i} (基準: {format_exact_val(st.session_state.base_b[i])})" margin_top = "0px" if first_b else "15px" first_b = False st.markdown(f"
{label}
", unsafe_allow_html=True) st.slider( f"_{label}", min_value=float(-max_log_b), max_value=float(max_log_b), step=float(max_log_b / 100.0), key=f'b_slider_{i}', on_change=update_text_from_sliders, label_visibility="collapsed" ) st.sidebar.markdown("---") # a 係數區塊 st.sidebar.text_input("回饋分母係數對應輸出訊號 (a0=1, a1 ~ a6)", key='a_str', on_change=update_base_a) with st.sidebar.expander("a 係數倍率微調", expanded=False): col1, col2 = st.columns([3, 1]) with col1: sense_a = st.radio("靈敏度", options=["1%", "2x", "10x"], index=1, horizontal=True, label_visibility="collapsed", key="sense_a", on_change=update_base_a) with col2: st.button("重置", on_click=reset_sliders_a, key="btn_reset_a", use_container_width=True) max_log_a = np.log10(1.01) if sense_a == "1%" else (np.log10(2.0) if sense_a == "2x" else 1.0) first_a = True for i in range(1, 7): if st.session_state.base_a[i] == 0.0: continue label = f"a{i} (基準: {format_exact_val(st.session_state.base_a[i])})" margin_top = "0px" if first_a else "15px" first_a = False st.markdown(f"
{label}
", unsafe_allow_html=True) st.slider( f"_{label}", min_value=float(-max_log_a), max_value=float(max_log_a), step=float(max_log_a / 100.0), key=f'a_slider_{i}', on_change=update_text_from_sliders, label_visibility="collapsed" ) # --- 輔助函式:動態轉換為 k 單位標示 --- def format_k_notation(val): if val >= 1000: k_val = val / 1000.0 # 保留一位小數,若是 .0 則安全去除 (如 100.0k -> 100k) formatted = f"{k_val:.1f}".rstrip('0').rstrip('.') return f" (~{formatted}k)" return "" # --- 左側欄:系統參數 --- st.sidebar.markdown("---") st.sidebar.header("系統參數") fs_val = st.session_state.get('sys_fs', 100000.0) st.sidebar.markdown(f"
取樣頻率 fs (Hz){format_k_notation(fs_val)}
", unsafe_allow_html=True) fs = st.sidebar.number_input("_fs", min_value=1.0, value=100000.0, step=1000.0, format="%g", key='sys_fs', on_change=apply_filter_design, label_visibility="collapsed") # --- 輔助函式:對數滑桿 + 數值輸入框 (微調模式) --- # 拉桿作為微調工具,範圍固定為當前錨點值的 1/2x ~ 2x (對數等距) # 滿右 = 錨點 × 2, 中央 = 錨點 × 1, 滿左 = 錨點 × 0.5 # 拉桿滑動 → 數字框即時更新;數字框 Enter → 拉桿歸中並更新錨點 def log_slider_input(label, min_val, max_val, default_val, key, on_change=None): if key not in st.session_state: st.session_state[key] = default_val # 邊界保護 current_val = st.session_state[key] if current_val < min_val: current_val = min_val if current_val > max_val: current_val = max_val st.session_state[key] = current_val # base_log:拉桿中心錨點,只在數字框確認時更新,拉桿滑動時維持不動 base_log_key = key + '_base_log' if base_log_key not in st.session_state: st.session_state[base_log_key] = float(np.log10(current_val)) base_log = st.session_state[base_log_key] # 固定微調範圍 ±log10(2):右滿 = ×2, 左滿 = ×0.5 FINE_RANGE = np.log10(2.0) # ≈ 0.30103 slider_min = base_log - FINE_RANGE slider_max = base_log + FINE_RANGE slider_key = key + '_slider' if slider_key not in st.session_state: st.session_state[slider_key] = base_log # 夾回邊界(base_log 更新後保護) sv = st.session_state[slider_key] if sv < slider_min: st.session_state[slider_key] = slider_min if sv > slider_max: st.session_state[slider_key] = slider_max def update_from_slider(): raw_val = 10.0 ** st.session_state[slider_key] raw_val = max(min_val, min(max_val, raw_val)) st.session_state[key] = float(f"{raw_val:.6g}") # 拉桿滑動時不更新 base_log,讓拉桿能真實偏離中心 if on_change: on_change() def update_from_num(): val = st.session_state[key + '_num'] if val < min_val: val = min_val if val > max_val: val = max_val st.session_state[key] = val # 數字框確認後:更新錨點,拉桿歸中 new_log = float(np.log10(val)) if val > 0 else base_log st.session_state[base_log_key] = new_log st.session_state[slider_key] = new_log if on_change: on_change() dynamic_step = 10.0 ** (np.floor(np.log10(current_val)) - 1) if current_val > 0 else 0.1 k_label = format_k_notation(current_val) st.sidebar.markdown(f"
{label}{k_label}
", unsafe_allow_html=True) col1, col2 = st.sidebar.columns([5, 3]) with col1: st.slider( f"_{label}_slider", min_value=float(slider_min), max_value=float(slider_max), step=float(FINE_RANGE / 100.0), key=slider_key, on_change=update_from_slider, label_visibility="collapsed" ) with col2: st.number_input( f"_{label}_num", min_value=float(min_val), max_value=float(max_val), value=float(current_val), step=float(dynamic_step), format="%g", key=key + '_num', on_change=update_from_num, label_visibility="collapsed" ) return st.session_state[key] # --- 濾波器設計工具 --- st.sidebar.markdown("---") st.sidebar.header("濾波器設計工具") st.sidebar.selectbox("選擇設計範本", ["(無) 手動自訂", "Lowpass (低通)", "Highpass (高通)", "Bandpass (帶通)", "Notch (陷波器)", "1P1Z (一極一零)", "2P2Z (二極二零)", "PID 控制器", "SOGI-Alpha (帶通)", "SOGI-Beta (低通)"], key='filter_type', on_change=apply_filter_design) f_type = st.session_state.get('filter_type', "(無) 手動自訂") nyq = fs / 2.0 f_min_limit = max(1e-6, fs * 1e-6) nyq_limit = nyq * 0.999 if 'lp_order' not in st.session_state: st.session_state['lp_order'] = 1 if 'hp_order' not in st.session_state: st.session_state['hp_order'] = 1 if 'bp_order' not in st.session_state: st.session_state['bp_order'] = 1 if f_type == "Lowpass (低通)": log_slider_input("截止頻率 f_c (Hz)", min_val=f_min_limit, max_val=nyq_limit, default_val=min(1000.0, nyq_limit), key='lp_fc', on_change=apply_filter_design) st.sidebar.radio("階數 Order", options=[1, 2, 3], horizontal=True, key='lp_order', on_change=apply_filter_design) elif f_type == "Highpass (高通)": log_slider_input("截止頻率 f_c (Hz)", min_val=f_min_limit, max_val=nyq_limit, default_val=min(1000.0, nyq_limit), key='hp_fc', on_change=apply_filter_design) st.sidebar.radio("階數 Order", options=[1, 2, 3], horizontal=True, key='hp_order', on_change=apply_filter_design) elif f_type == "Bandpass (帶通)": log_slider_input("下截止頻率 f_low (Hz)", min_val=f_min_limit, max_val=nyq_limit, default_val=min(500.0, nyq_limit), key='bp_f_low', on_change=apply_filter_design) log_slider_input("上截止頻率 f_high (Hz)", min_val=f_min_limit, max_val=nyq_limit, default_val=min(2000.0, nyq_limit), key='bp_f_high', on_change=apply_filter_design) st.sidebar.radio("階數 Order", options=[1, 2, 3], horizontal=True, key='bp_order', on_change=apply_filter_design) elif f_type == "Notch (陷波器)": log_slider_input("中心頻率 f_0 (Hz)", min_val=f_min_limit, max_val=nyq_limit, default_val=min(120.0, nyq_limit), key='notch_f0', on_change=apply_filter_design) log_slider_input("品質因數 Q", min_val=0.01, max_val=100.0, default_val=1.0, key='notch_q', on_change=apply_filter_design) elif f_type == "1P1Z (一極一零)": log_slider_input("零點頻率 Freq_z (Hz)", min_val=f_min_limit, max_val=fs, default_val=min(15000.0, fs), key='opoz_fz', on_change=apply_filter_design) log_slider_input("極點頻率 Freq_p (Hz)", min_val=f_min_limit, max_val=fs, default_val=min(10.0, fs), key='opoz_fp', on_change=apply_filter_design) elif f_type == "2P2Z (二極二零)": log_slider_input("零點頻率 1 Freq_z1 (Hz)", min_val=f_min_limit, max_val=fs, default_val=min(200.0, fs), key='tptz_fz1', on_change=apply_filter_design) log_slider_input("零點頻率 2 Freq_z2 (Hz)", min_val=f_min_limit, max_val=fs, default_val=min(25000.0, fs), key='tptz_fz2', on_change=apply_filter_design) log_slider_input("極點頻率 1 Freq_p1 (Hz)", min_val=f_min_limit, max_val=fs, default_val=min(10.0, fs), key='tptz_fp1', on_change=apply_filter_design) log_slider_input("極點頻率 2 Freq_p2 (Hz)", min_val=f_min_limit, max_val=fs, default_val=min(5000.0, fs), key='tptz_fp2', on_change=apply_filter_design) elif f_type == "PID 控制器": log_slider_input("比例增益 K_p", min_val=1e-6, max_val=1e3, default_val=0.003, key='pid_kp', on_change=apply_filter_design) log_slider_input("積分增益 K_i", min_val=1e-6, max_val=1e3, default_val=10.0, key='pid_ki', on_change=apply_filter_design) log_slider_input("微分增益 K_d", min_val=1e-6, max_val=1e3, default_val=0.000016, key='pid_kd', on_change=apply_filter_design) elif f_type in ["SOGI-Alpha (帶通)", "SOGI-Beta (低通)"]: log_slider_input("中心頻率 f_0 (Hz)", min_val=f_min_limit, max_val=nyq_limit, default_val=min(60.0, nyq_limit), key='sogi_f0', on_change=apply_filter_design) log_slider_input("阻尼因數 k", min_val=0.01, max_val=10.0, default_val=1.414, key='sogi_k', on_change=apply_filter_design) if f_type != "(無) 手動自訂": st.sidebar.info("💡 調整上方參數會自動覆寫 a, b 係數。你也可以隨時上去手動修改係數拉桿進行微調!") # 解析文字框內最後確認的數值供後續計算使用 def parse_coeffs(coeff_str, force_a0_one=False): try: vals = [float(x.strip()) for x in coeff_str.split(',') if x.strip()] vals = [apply_constraints(v) for v in vals] if force_a0_one and len(vals) > 0: vals[0] = 1.0 return vals except ValueError: return [] b = parse_coeffs(st.session_state.b_str) a = parse_coeffs(st.session_state.a_str, force_a0_one=True) if not b: b = [1.0] if not a: a = [1.0] # --- 主畫面區塊一:Bode Plot --- st.header("1. 頻率響應 (Frequency Response)") if b and a: try: # 依據取樣頻率動態決定 X 軸範圍 (下限: fs/50000, 上限: fs*3.162) f_min = fs / 50000.0 f_max = fs * 3.162 f_eval = np.logspace(np.log10(f_min), np.log10(f_max), 500) # 計算頻率響應 (直接給定頻率點 f_eval 與 fs) w, h = signal.freqz(b, a, worN=f_eval, fs=fs) mag = 20 * np.log10(np.abs(h) + 1e-12) # 轉換為 dB phase = np.angle(h, deg=True) # 計算相位 (度),自動 Wrap 至 [-180, 180] # 使用 Plotly 繪製可互動的圖表 (取消 shared_xaxes,讓上下圖都標示 X 軸) fig_bode = make_subplots(rows=2, cols=1, shared_xaxes=False, vertical_spacing=0.15, subplot_titles=("Magnitude Response (大小響應)", "Phase Response (相位響應)")) fig_bode.add_trace(go.Scatter(x=f_eval, y=mag, name='Magnitude (dB)', line=dict(color='#1f77b4')), row=1, col=1) fig_bode.add_trace(go.Scatter(x=f_eval, y=phase, name='Phase (deg)', line=dict(color='#ff7f0e')), row=2, col=1) fig_bode.update_layout(height=650, showlegend=False, template="plotly_dark") # 產生 1, 2, 5, 10 序列的刻度,動態適應 f_min 和 f_max x_ticks = [] x_texts = [] p_start = int(np.floor(np.log10(f_min))) p_end = int(np.ceil(np.log10(f_max))) for p in range(p_start, p_end + 1): for m in [1, 2, 5]: val = m * (10**p) if val < f_min or val > f_max: continue x_ticks.append(val) # 格式化標籤文字 if val < 1: x_texts.append(f"{val:.3g}") elif val < 1000: x_texts.append(f"{val:g}") elif val < 1000000: x_texts.append(f"{val/1000:g}k") else: x_texts.append(f"{val/1000000:g}M") # 設定 X 軸為對數座標與 1-2-5 刻度線 for row in [1, 2]: fig_bode.update_xaxes( title_text="Frequency (Hz)", type="log", range=[np.log10(f_min), np.log10(f_max)], tickvals=x_ticks, ticktext=x_texts, showgrid=True, gridcolor="rgba(128,128,128,0.2)", row=row, col=1 ) # 強化 10 的倍數 (1, 10, 100...) 刻度線顏色 (不加太寬以維持美觀) for p in range(p_start, p_end + 1): v_line = 10**p if f_min <= v_line <= f_max: fig_bode.add_vline(x=v_line, line_width=1.5, line_color="rgba(128,128,128,0.5)", layer="below", row=row, col=1) # 標示 Nyquist 頻率和 fs,方便觀察混疊現象 fig_bode.add_vline(x=fs/2, line_dash="dash", line_color="rgba(255,0,0,0.5)", annotation_text="Nyquist", row=1, col=1) fig_bode.add_vline(x=fs, line_dash="dash", line_color="rgba(255,0,0,0.5)", annotation_text="fs", row=1, col=1) fig_bode.add_vline(x=fs/2, line_dash="dash", line_color="rgba(255,0,0,0.5)", row=2, col=1) fig_bode.add_vline(x=fs, line_dash="dash", line_color="rgba(255,0,0,0.5)", row=2, col=1) # 設定 Y 軸 (固定幅度範圍為 -80dB ~ 0dB) fig_bode.update_yaxes(title_text="Magnitude (dB)", range=[-60, 0], row=1, col=1) fig_bode.update_yaxes(title_text="Phase (Degrees)", range=[-180, 180], tickvals=[-180, -90, 0, 90, 180], row=2, col=1) st.plotly_chart(fig_bode, use_container_width=True) except Exception as e: st.error(f"計算 Bode Plot 時發生錯誤,請檢查係數設定。錯誤訊息: {e}") # --- 主畫面區塊二:時域訊號探討 --- st.header("2. 時域訊號探討") st.markdown("請上傳包含時域訊號的 CSV 檔案,系統會套用上述設定的差分濾波器。") uploaded_file = st.file_uploader("上傳輸入訊號 (CSV)", type=["csv"]) if uploaded_file is not None: try: # 讀取 CSV 檔案 df = pd.read_csv(uploaded_file) st.write("資料預覽 (前五筆):") st.dataframe(df.head()) # 讓使用者選擇要濾波的欄位 (智慧預設:若第一欄看起來像時間戳記,則預設選第二欄) default_idx = 0 if len(df.columns) > 1: first_col = str(df.columns[0]).lower() if any(kw in first_col for kw in ['time', 't', 'sec', 'x', 'index', 'Unnamed']): default_idx = 1 col_to_filter = st.selectbox("請選擇要套用濾波器的訊號欄位:", df.columns, index=default_idx) x_signal = df[col_to_filter].values # 執行差分方程式濾波 y_signal = signal.lfilter(b, a, x_signal) # 將結果合併回 DataFrame 以便下載 df_out = df.copy() output_col_name = f"{col_to_filter}_filtered" df_out[output_col_name] = y_signal # 繪製時域訊號比較圖 st.subheader("波形比較圖") fig_time = go.Figure() x_axis = df.index fig_time.add_trace(go.Scatter(x=x_axis, y=x_signal, name='原始輸入訊號 (Input)', opacity=0.7, line=dict(color='#00cc96'))) fig_time.add_trace(go.Scatter(x=x_axis, y=y_signal, name='濾波後輸出 (Output)', opacity=0.9, line=dict(color='#ef553b'))) fig_time.update_layout( height=500, xaxis_title="Sample Index", yaxis_title="Amplitude", template="plotly_dark", legend=dict( orientation="h", # 水平排列 yanchor="bottom", y=1.02, # 置於圖表正上方 xanchor="right", x=1 ) ) st.plotly_chart(fig_time, use_container_width=True) # 提供下載按鈕 st.subheader("匯出結果") csv_buffer = io.StringIO() df_out.to_csv(csv_buffer, index=False) st.download_button( label="下載包含輸出訊號的 CSV", data=csv_buffer.getvalue(), file_name="filtered_output.csv", mime="text/csv" ) except Exception as e: st.error(f"處理 CSV 檔案時發生錯誤: {e}") st.sidebar.markdown("
", unsafe_allow_html=True) st.sidebar.markdown("---") st.sidebar.markdown("
", unsafe_allow_html=True) st.sidebar.image("WISETOP LOGO-FIN.png", width=150) st.sidebar.caption("© 2026 喆富創新科技(股). All rights reserved.")