692 lines
30 KiB
Python
692 lines
30 KiB
Python
|
|
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()
|
|||
|
|
|
|||
|
|
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("<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.")
|