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.")