Files
tcad-bodeplot/dea_legacy.py
T

703 lines
30 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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("""
<style>
.block-container {
padding-top: 3.5rem !important;
padding-bottom: 1rem !important;
min-height: 250vh; /* 增加到 250vh 避免長圖表重繪時跳回頂部 */
}
.main-title {
font-size: 22px !important;
font-weight: bold;
margin-bottom: 10px !important;
}
/* 縮小 st.header (h2) 與 st.subheader (h3) 的字體大小 */
h2 {
font-size: 20px !important;
padding-top: 0.5rem !important;
padding-bottom: 0.5rem !important;
}
h3 {
font-size: 16px !important;
}
/* 壓縮全域元件間距 (預設約為 1rem~1.5rem) */
[data-testid="stVerticalBlock"] {
gap: 0.5rem !important;
}
/* 壓縮 Sidebar 內部的元件間距,變得更緊湊 */
[data-testid="stSidebar"] [data-testid="stVerticalBlock"] {
gap: 0.2rem !important;
}
/* 壓縮分隔線 */
hr {
margin-top: 0.5em !important;
margin-bottom: 0.5em !important;
}
/* 壓縮所有段落與標籤底部留白 */
.stMarkdown p {
margin-bottom: 0px !important;
}
/* 壓縮原生標籤與輸入框的距離 */
div[data-testid="stWidgetLabel"] {
margin-bottom: 0px !important;
padding-bottom: 0px !important;
}
/* 為按鈕增加上下留白,避免跟標題或下方輸入框黏在一起 */
div[data-testid="stButton"] {
margin-top: 7px !important;
margin-bottom: 5px !important;
}
/* 暴力壓扁所有文字輸入框、數字框、下拉選單的物理高度 */
[data-baseweb="input"],
[data-baseweb="base-input"],
[data-baseweb="select"],
div[data-testid="stNumberInputContainer"] {
min-height: 32px !important;
height: 32px !important;
}
/* 抽乾內部 Padding 確保文字置中不被切斷 */
input[type="text"],
input[type="number"],
[data-baseweb="select"] span {
padding-top: 0px !important;
padding-bottom: 0px !important;
min-height: 32px !important;
line-height: 32px !important;
}
/* 同樣暴力壓扁折疊選單 (Expander) 的標題列 */
details summary {
min-height: 32px !important;
height: 32px !important;
padding-top: 0px !important;
padding-bottom: 0px !important;
}
details summary p {
font-size: 0.95em !important;
line-height: 32px !important;
}
/* 針對拉桿特別施加「負邊距」,強制吃掉原本用來顯示浮動數值所預留的幽靈空白 */
.stSlider div[data-testid="stWidgetLabel"] {
margin-bottom: -15px !important;
}
.stSlider [data-baseweb="slider"] {
padding-top: 0px !important;
margin-top: 0px !important;
}
/* 徹底隱藏拉桿的所有紅字浮動數值與底部刻度 */
.stSlider [data-baseweb="slider"] * {
color: transparent !important;
font-size: 0px !important;
}
/* 防禦性隱藏 (針對不同版本的 Streamlit) */
[data-testid="stThumbValue"], [data-testid="stTickBar"], .stSliderValue {
display: none !important;
}
/* 縮小面板內的重置按鈕,達到水平與垂直縮減 30% 的視覺效果 */
[data-testid="stExpanderDetails"] button[kind="secondary"] {
padding-top: 0px !important;
padding-bottom: 0px !important;
padding-left: 5px !important;
padding-right: 5px !important;
min-height: 26px !important;
height: 26px !important;
font-size: 0.8em !important;
margin-top: 2px !important;
white-space: nowrap !important;
}
/* 壓縮側邊欄內水平 Radio 按鈕 (靈敏度) 的間距,並強制不換行 */
[data-testid="stSidebar"] div[role="radiogroup"] {
gap: 0.65rem !important;
flex-wrap: nowrap !important;
}
[data-testid="stSidebar"] div[role="radiogroup"] label {
white-space: nowrap !important;
margin-right: 0px !important;
}
</style>
""", unsafe_allow_html=True)
st.markdown('<div class="main-title">差分方程式 (Difference Equation) 試算與分析環境</div>', unsafe_allow_html=True)
# --- 左側欄:濾波器參數設定 ---
st.sidebar.header("濾波器係數設定")
st.sidebar.markdown("<div style='margin-bottom: 5px;'></div>", 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"<div style='margin-top: {margin_top};'><span style='font-size: 0.9em; font-weight: 600;'>{label}</span></div>", 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"<div style='margin-top: {margin_top};'><span style='font-size: 0.9em; font-weight: 600;'>{label}</span></div>", 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" <span style='color: #4CAF50; font-size: 0.9em;'>(~{formatted}k)</span>"
return ""
# --- 左側欄:系統參數 ---
st.sidebar.markdown("---")
st.sidebar.header("系統參數")
fs_val = st.session_state.get('sys_fs', 100000.0)
st.sidebar.markdown(f"<div style='margin-bottom: 15px;'><span style='font-size: 0.9em; font-weight: 600;'>取樣頻率 fs (Hz){format_k_notation(fs_val)}</span></div>", 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"<div style='margin-top: 8px; margin-bottom: 5px;'><span style='font-size: 0.9em; font-weight: 600;'>{label}{k_label}</span></div>", 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()
max_time_s = len(df.index) / fs_val
if max_time_s > 0 and max_time_s < 1e-3:
time_mult = 1e6
time_unit = "μs"
elif max_time_s > 0 and max_time_s < 1:
time_mult = 1e3
time_unit = "ms"
else:
time_mult = 1.0
time_unit = "s"
x_axis = (df.index / fs_val) * time_mult
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=f"Time ({time_unit})",
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("<div style='margin-top: 20px;'></div>", unsafe_allow_html=True)
st.sidebar.markdown("---")
st.sidebar.markdown("<div style='margin-bottom: 20px;'></div>", unsafe_allow_html=True)
st.sidebar.image("WISETOP LOGO-FIN.png", width=150)
st.sidebar.caption("© 2026 喆富創新科技(股). All rights reserved.")