commit 5568651cc319e2a13cf307bd47b38d55185adf52 Author: ws50529 Date: Tue May 5 16:16:34 2026 +0800 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5d990fa --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +.venv/ +venv/ +ENV/ +env/ +.env + +# Distribution / packaging +dist/ +build/ +*.egg-info/ + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ + +# Jupyter Notebook +.ipynb_checkpoints + +# OS generated files +.DS_Store +.DS_Store? +._* +Thumbs.db +ehthumbs.db +Desktop.ini + +# Agent Skills +.agent/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..8f4ad57 --- /dev/null +++ b/README.md @@ -0,0 +1,71 @@ +# Difference Equation Analyzer 專案維護指引 + +本文件提供 `diff-eq-analyzer` 專案的日常維護、環境更新與故障排除指南。 + +## 1. 系統服務管理 (Systemd) + +本專案的後端 API (FastAPI) 以 systemd 服務 (`dea.service`) 的形式在背景執行。 + +### 常用指令 + +* **查看服務狀態:** + ```bash + sudo systemctl status dea.service + ``` +* **啟動服務:** + ```bash + sudo systemctl start dea.service + ``` +* **停止服務:** + ```bash + sudo systemctl stop dea.service + ``` +* **重新啟動服務(更新程式碼後必備):** + ```bash + sudo systemctl restart dea.service + ``` +* **查看即時日誌 (Logs):** + ```bash + sudo journalctl -u dea.service -f + ``` + +## 2. 虛擬環境與套件管理 + +專案使用 Python 虛擬環境 (`.venv`) 隔離依賴套件。若您需要更新套件或是因移動專案路徑導致環境異常,請依下列步驟重建虛擬環境: + +1. **進入專案目錄:** + ```bash + cd /home/wisetop/diff-eq-analyzer + ``` +2. **刪除舊的虛擬環境並重建:** + ```bash + rm -rf .venv + python3 -m venv .venv + ``` +3. **安裝必要套件:** + ```bash + .venv/bin/pip install -r requirements.txt + ``` +4. **重新啟動系統服務以套用新環境:** + ```bash + sudo systemctl restart dea.service + ``` + +## 3. 常見問題排除 + +### 3.1 服務無法啟動 (status=203/EXEC) +* **原因:** 通常是因為 Python 虛擬環境的路徑錯誤,或是啟動腳本 (`uvicorn`) 內部寫死的絕對路徑與實際位置不符(例如專案資料夾被重新命名)。 +* **解法:** 參考上方的「虛擬環境與套件管理」,將 `.venv` 刪除並重新安裝,然後重啟 `dea.service` 即可。 + +### 3.2 網頁無法連線 +* **檢查後端狀態:** 執行 `sudo systemctl status dea.service` 確認 FastAPI 伺服器是否正常運作中(應顯示 `Active: active (running)`)。 +* **檢查防火牆設定:** 預設使用的 Port 為 `8000`,請確認防火牆或安全群組允許 `8000` 埠的入站連線。 +* **檢查綁定 IP:** 服務預設綁定於 `0.0.0.0`,代表接受所有網路介面的連線。如需限制,請修改 `dea.service` 內的 `ExecStart` 參數。 + +## 4. 變更專案設定檔 +若您修改了 `dea.service`(位於專案目錄內或 `/etc/systemd/system/dea.service`),請務必執行下列指令讓系統重新載入設定: + +```bash +sudo systemctl daemon-reload +sudo systemctl restart dea.service +``` diff --git a/dea.service b/dea.service new file mode 100644 index 0000000..e890e84 --- /dev/null +++ b/dea.service @@ -0,0 +1,14 @@ +[Unit] +Description=Difference Equation Analyzer FastAPI Server +After=network.target + +[Service] +User=wisetop +WorkingDirectory=/home/wisetop/diff-eq-analyzer +Environment="PATH=/home/wisetop/diff-eq-analyzer/.venv/bin" +ExecStart=/home/wisetop/diff-eq-analyzer/.venv/bin/uvicorn dea_api:app --host 0.0.0.0 --port 8000 +Restart=always +RestartSec=3 + +[Install] +WantedBy=multi-user.target diff --git a/dea_api.py b/dea_api.py new file mode 100755 index 0000000..dd0b549 --- /dev/null +++ b/dea_api.py @@ -0,0 +1,203 @@ +import numpy as np +import pandas as pd +from scipy import signal +from fastapi import FastAPI, File, UploadFile, Form, HTTPException, Request +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel +from typing import List, Optional +import io +from fastapi.responses import StreamingResponse, RedirectResponse, JSONResponse +import ipaddress + +app = FastAPI(title="Difference Equation Analyzer API") + +@app.middleware("http") +async def restrict_to_lan(request: Request, call_next): + client_ip = request.client.host + if client_ip: + try: + ip_obj = ipaddress.ip_address(client_ip) + if not (ip_obj.is_private or ip_obj.is_loopback): + return JSONResponse(status_code=403, content={"detail": "Access Denied: Only LAN connections are allowed."}) + except ValueError: + pass + return await call_next(request) + +# 掛載靜態網頁檔案,將預設首頁導向到 /ui/ +app.mount("/ui", StaticFiles(directory="static", html=True), name="static") + +@app.get("/") +def redirect_to_ui(): + return RedirectResponse(url="/ui/") + +class DesignParams(BaseModel): + filter_type: str + fs: float + lp_fc: float = 1000.0 + lp_order: int = 1 + hp_fc: float = 1000.0 + hp_order: int = 1 + bp_f_low: float = 500.0 + bp_f_high: float = 2000.0 + bp_order: int = 1 + notch_f0: float = 120.0 + notch_q: float = 1.0 + opoz_fz: float = 15000.0 + opoz_fp: float = 10.0 + tptz_fz1: float = 200.0 + tptz_fz2: float = 25000.0 + tptz_fp1: float = 10.0 + tptz_fp2: float = 5000.0 + pid_kp: float = 0.003 + pid_ki: float = 10.0 + pid_kd: float = 0.000016 + sogi_f0: float = 60.0 + sogi_k: float = 1.414 + +class BodeParams(BaseModel): + b: List[float] + a: List[float] + fs: float + +@app.post("/api/design") +def design_filter(params: DesignParams): + fs_val = params.fs + f_type = params.filter_type + b_new, a_new = [], [] + + try: + if f_type == "Lowpass (低通)": + b_new, a_new = signal.butter(params.lp_order, params.lp_fc, btype='low', fs=fs_val) + elif f_type == "Highpass (高通)": + b_new, a_new = signal.butter(params.hp_order, params.hp_fc, btype='high', fs=fs_val) + elif f_type == "Bandpass (帶通)": + f_low, f_high = params.bp_f_low, params.bp_f_high + if f_low >= f_high: f_low = f_high * 0.99 + b_new, a_new = signal.butter(params.bp_order, [f_low, f_high], btype='bandpass', fs=fs_val) + elif f_type == "Notch (陷波器)": + b_new, a_new = signal.iirnotch(params.notch_f0, params.notch_q, fs=fs_val) + elif f_type == "1P1Z (一極一零)": + b_s = [1.0, 2 * np.pi * params.opoz_fz] + a_s = [1.0, 2 * np.pi * params.opoz_fp] + b_new, a_new = signal.bilinear(b_s, a_s, fs=fs_val) + dc_gain = np.sum(b_new) / np.sum(a_new) + b_new = b_new / dc_gain + elif f_type == "2P2Z (二極二零)": + w_p1, w_p2 = 2 * np.pi * params.tptz_fp1, 2 * np.pi * params.tptz_fp2 + w_z1, w_z2 = 2 * np.pi * params.tptz_fz1, 2 * np.pi * params.tptz_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 = np.sum(b_new) / np.sum(a_new) + b_new = b_new / dc_gain + elif f_type == "PID 控制器": + ki_term = params.pid_ki / (2.0 * fs_val) + kd_term = params.pid_kd * fs_val + b0 = params.pid_kp + ki_term + kd_term + b1 = -params.pid_kp + ki_term - 2.0 * kd_term + b2 = kd_term + b_new = [b0, b1, b2] + a_new = [1.0, -1.0, 0.0] + elif f_type == "SOGI-Alpha (帶通)": + w0 = 2 * np.pi * params.sogi_f0 + b_s = [params.sogi_k * w0, 0.0] + a_s = [1.0, params.sogi_k * w0, w0**2] + b_new, a_new = signal.bilinear(b_s, a_s, fs=fs_val) + elif f_type == "SOGI-Beta (低通)": + w0 = 2 * np.pi * params.sogi_f0 + b_s = [params.sogi_k * (w0**2)] + a_s = [1.0, params.sogi_k * w0, w0**2] + b_new, a_new = signal.bilinear(b_s, a_s, fs=fs_val) + + return {"b": b_new.tolist() if isinstance(b_new, np.ndarray) else b_new, + "a": a_new.tolist() if isinstance(a_new, np.ndarray) else a_new} + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + +@app.post("/api/bode") +def calculate_bode(params: BodeParams): + try: + f_min = params.fs / 50000.0 + f_max = params.fs * 3.162 + f_eval = np.logspace(np.log10(f_min), np.log10(f_max), 500) + + # WorN determines frequencies to evaluate at + w, h = signal.freqz(params.b, params.a, worN=f_eval, fs=params.fs) + + mag = 20 * np.log10(np.abs(h) + 1e-12) + phase = np.angle(h, deg=True) + + return { + "freq": f_eval.tolist(), + "mag": mag.tolist(), + "phase": phase.tolist() + } + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + +@app.post("/api/filter") +async def filter_csv( + file: UploadFile = File(...), + b: str = Form(...), + a: str = Form(...), + col_idx: int = Form(0) +): + try: + b_vals = [float(x.strip()) for x in b.split(',') if x.strip()] + a_vals = [float(x.strip()) for x in a.split(',') if x.strip()] + + contents = await file.read() + df = pd.read_csv(io.BytesIO(contents)) + + if len(df.columns) <= col_idx: + raise HTTPException(status_code=400, detail="欄位索引超出範圍") + + col_to_filter = df.columns[col_idx] + x_signal = df[col_to_filter].values + + # 套用濾波 + y_signal = signal.lfilter(b_vals, a_vals, x_signal) + + # 準備資料回傳前端進行繪圖 (為了防止過大,可以在此決定是否 downsample,但目前保留原貌) + # 前端收到 JSON 後可自行繪製並產生 CSV + return { + "index": df.index.tolist(), + "original": x_signal.tolist(), + "filtered": y_signal.tolist(), + "col_name": col_to_filter + } + except Exception as e: + raise HTTPException(status_code=400, detail=f"CSV處理失敗: {str(e)}") + +@app.post("/api/filter/download") +async def filter_csv_download( + file: UploadFile = File(...), + b: str = Form(...), + a: str = Form(...), + col_idx: int = Form(0) +): + # 此端點專為產生包含輸出結果的 CSV 檔供下載 + try: + b_vals = [float(x.strip()) for x in b.split(',') if x.strip()] + a_vals = [float(x.strip()) for x in a.split(',') if x.strip()] + + contents = await file.read() + df = pd.read_csv(io.BytesIO(contents)) + + col_to_filter = df.columns[col_idx] + x_signal = df[col_to_filter].values + y_signal = signal.lfilter(b_vals, a_vals, x_signal) + + output_col_name = f"{col_to_filter}_filtered" + df[output_col_name] = y_signal + + csv_buffer = io.StringIO() + df.to_csv(csv_buffer, index=False) + + return StreamingResponse( + iter([csv_buffer.getvalue()]), + media_type="text/csv", + headers={"Content-Disposition": "attachment; filename=filtered_output.csv"} + ) + except Exception as e: + raise HTTPException(status_code=400, detail=f"CSV處理失敗: {str(e)}") diff --git a/dea_legacy.py b/dea_legacy.py new file mode 100755 index 0000000..f4bc6e5 --- /dev/null +++ b/dea_legacy.py @@ -0,0 +1,691 @@ +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.") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8783c78 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +fastapi>=0.100.0 +uvicorn>=0.23.0 +python-multipart>=0.0.6 +numpy>=1.24.0 +pandas>=2.0.0 +scipy>=1.10.0 diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..5906843 --- /dev/null +++ b/static/app.js @@ -0,0 +1,392 @@ +const { createApp } = Vue; + +createApp({ + data() { + return { + fs: 100000.0, + b_str: "0.5, 0.5, 0.0, 0.0", + a_str: "1.0, 0.0, 0.0, 0.0", + filterType: "(無) 手動自訂", + filterOptions: [ + "(無) 手動自訂", "Lowpass (低通)", "Highpass (高通)", + "Bandpass (帶通)", "Notch (陷波器)", "1P1Z (一極一零)", + "2P2Z (二極二零)", "PID 控制器", "SOGI-Alpha (帶通)", "SOGI-Beta (低通)" + ], + params: { + fc: 1000.0, order: 1, + bp_f_low: 500.0, bp_f_high: 2000.0, bp_order: 1, + notch_f0: 120.0, notch_q: 1.0, + opoz_fz: 15000.0, opoz_fp: 10.0, + tptz_fz1: 200.0, tptz_fz2: 25000.0, tptz_fp1: 10.0, tptz_fp2: 5000.0, + kp: 0.003, ki: 10.0, kd: 0.000016, + sogi_f0: 60.0, sogi_k: 1.414, + }, + // 模式切換 + isDarkMode: true, + // 係數倍率微調 + baseB: [0.5, 0.5, 0, 0, 0, 0, 0], + baseA: [1, 0, 0, 0, 0, 0, 0], + bSliders: [0, 0, 0, 0, 0, 0, 0], + aSliders: [0, 0, 0, 0, 0, 0], // a1~a6 (index 0 unused for display but maps to a[1]~a[6]) + sense_b: '2x', sense_a: '2x', + + loadingBode: false, bodeTimeout: null, globalError: null, + csvFile: null, csvColumns: [], csvPreview: [], + selectedColumn: 0, loadingFilter: false, filterDone: false, timePlotData: null, + mobileTab: 'settings', // 手機版頁籤:'settings' | 'chart' + shiftBits: 12, + } + }, + computed: { + maxLogB() { + if (this.sense_b === '1%') return Math.log10(1.01); + if (this.sense_b === '2x') return Math.log10(2.0); + return 1.0; + }, + maxLogA() { + if (this.sense_a === '1%') return Math.log10(1.01); + if (this.sense_a === '2x') return Math.log10(2.0); + return 1.0; + }, + fixedPointCoeffs() { + const scale = Math.pow(2, this.shiftBits); + const b_raw = this.parseCoeffs(this.b_str); + const a_raw = this.parseCoeffs(this.a_str); + + const b = b_raw.map((x, i) => ({ label: `b${i}`, val: Math.round(x * scale) })); + const a = a_raw.map((x, i) => ({ label: `a${i}`, val: Math.round(x * scale) })); + + return { b, a }; + }, + floatingCoeffs() { + const b = this.parseCoeffs(this.b_str).map((x, i) => ({ label: `b${i}`, val: x })); + const a = this.parseCoeffs(this.a_str).map((x, i) => ({ label: `a${i}`, val: x })); + return { b, a }; + } + }, + mounted() { + const savedTheme = localStorage.getItem('theme'); + if (savedTheme) { + this.isDarkMode = savedTheme === 'dark'; + } + if (this.isDarkMode) { + document.documentElement.classList.add('dark'); + } else { + document.documentElement.classList.remove('dark'); + } + this.syncBaseFromText(); + this.updateBodePlot(); + }, + methods: { + toggleDarkMode() { + this.isDarkMode = !this.isDarkMode; + localStorage.setItem('theme', this.isDarkMode ? 'dark' : 'light'); + if (this.isDarkMode) { + document.documentElement.classList.add('dark'); + } else { + document.documentElement.classList.remove('dark'); + } + this.updateBodePlot(); + if (this.timePlotData) { + this.drawTimePlot(this.timePlotData); + } + }, + formatK(val) { + if (val >= 1000000) return `(~${(val / 1000000).toFixed(1).replace('.0', '')}M)`; + if (val >= 1000) return `(~${(val / 1000).toFixed(1).replace('.0', '')}k)`; + return ""; + }, + parseCoeffs(str) { + return str.split(',').map(x => parseFloat(x.trim())).filter(x => !isNaN(x)); + }, + padTo7(arr) { + const r = arr.slice(0, 7); + while (r.length < 7) r.push(0); + return r; + }, + syncBaseFromText() { + this.baseB = this.padTo7(this.parseCoeffs(this.b_str)); + this.baseA = this.padTo7(this.parseCoeffs(this.a_str)); + this.baseA[0] = 1.0; + this.bSliders = [0, 0, 0, 0, 0, 0, 0]; + this.aSliders = [0, 0, 0, 0, 0, 0]; + }, + resetSlidersB() { + this.bSliders = [0, 0, 0, 0, 0, 0, 0]; + this.b_str = this.baseB.map(x => parseFloat(x.toPrecision(10))).join(', '); + this.updateBodePlot(); + }, + resetSlidersA() { + this.aSliders = [0, 0, 0, 0, 0, 0]; + this.a_str = this.baseA.map(x => parseFloat(x.toPrecision(10))).join(', '); + this.updateBodePlot(); + }, + updateFromSliders() { + const currentB = this.baseB.map((base, i) => base * Math.pow(10, this.bSliders[i] || 0)); + const currentA = [1.0]; + for (let i = 1; i < 7; i++) { + currentA.push(this.baseA[i] * Math.pow(10, this.aSliders[i] || 0)); + } + this.b_str = currentB.map(x => parseFloat(x.toPrecision(6))).join(', '); + this.a_str = currentA.map(x => parseFloat(x.toPrecision(6))).join(', '); + this.debouncedUpdateBode(); + }, + updateFromText() { + this.syncBaseFromText(); + this.updateBodePlot(); + }, + onFsChange() { + if (this.filterType !== "(無) 手動自訂") { + this.applyFilterDesign(); + } else { + this.updateBodePlot(); + } + }, + debouncedApply() { + clearTimeout(this.bodeTimeout); + this.bodeTimeout = setTimeout(() => { + if (this.filterType !== "(無) 手動自訂") this.applyFilterDesign(); + else this.updateBodePlot(); + }, 300); + }, + debouncedUpdateBode() { + clearTimeout(this.bodeTimeout); + this.bodeTimeout = setTimeout(() => this.updateBodePlot(), 150); + }, + async applyFilterDesign() { + if (this.filterType === "(無) 手動自訂") return; + this.globalError = null; + try { + const payload = { + filter_type: this.filterType, fs: this.fs, + lp_fc: this.params.fc, lp_order: this.params.order, + hp_fc: this.params.fc, hp_order: this.params.order, + bp_f_low: this.params.bp_f_low, bp_f_high: this.params.bp_f_high, bp_order: this.params.bp_order, + notch_f0: this.params.notch_f0, notch_q: this.params.notch_q, + opoz_fz: this.params.opoz_fz, opoz_fp: this.params.opoz_fp, + tptz_fz1: this.params.tptz_fz1, tptz_fz2: this.params.tptz_fz2, + tptz_fp1: this.params.tptz_fp1, tptz_fp2: this.params.tptz_fp2, + pid_kp: this.params.kp, pid_ki: this.params.ki, pid_kd: this.params.kd, + sogi_f0: this.params.sogi_f0, sogi_k: this.params.sogi_k + }; + const res = await fetch('/api/design', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + if (!res.ok) { const err = await res.json(); throw new Error(err.detail || '設計濾波器失敗'); } + const data = await res.json(); + this.b_str = data.b.map(x => parseFloat(x.toPrecision(10))).join(', '); + this.a_str = data.a.map(x => parseFloat(x.toPrecision(10))).join(', '); + this.syncBaseFromText(); + this.updateBodePlot(); + } catch (e) { this.globalError = e.message; } + }, + async updateBodePlot() { + this.loadingBode = true; + this.globalError = null; + try { + let b = this.parseCoeffs(this.b_str); + let a = this.parseCoeffs(this.a_str); + if (b.length === 0) b = [1.0]; + if (a.length === 0) a = [1.0]; + const res = await fetch('/api/bode', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ b, a, fs: this.fs }) + }); + if (!res.ok) throw new Error('無法計算頻率響應'); + const data = await res.json(); + this.drawBodePlot(data.freq, data.mag, data.phase); + // 手機版:計算完成後自動切換到圖表頁籤 + if (window.innerWidth < 1024) this.mobileTab = 'chart'; + } catch (e) { this.globalError = e.message; } + finally { this.loadingBode = false; } + }, + drawBodePlot(freq, mag, phase) { + const fs = this.fs; + const fMin = freq[0], fMax = freq[freq.length - 1]; + const isDark = this.isDarkMode; + const isSmallScreen = window.innerWidth < 768; + + // 產生 1-2-5 刻度 + const xTicks = [], xTexts = []; + const pStart = Math.floor(Math.log10(fMin)); + const pEnd = Math.ceil(Math.log10(fMax)); + for (let p = pStart; p <= pEnd; p++) { + for (const m of [1, 2, 5]) { + const val = m * Math.pow(10, p); + if (val < fMin || val > fMax) continue; + xTicks.push(val); + if (val < 1) xTexts.push(val.toPrecision(3)); + else if (val < 1000) xTexts.push(String(val)); + else if (val < 1000000) xTexts.push((val / 1000) + 'k'); + else xTexts.push((val / 1000000) + 'M'); + } + } + + // Nyquist & fs 標線 (shapes) + const shapes = []; + const annotations = []; + const addVLine = (xVal, text, row) => { + const yref = row === 1 ? 'y' : 'y2'; + shapes.push({ + type: 'line', x0: xVal, x1: xVal, y0: 0, y1: 1, + xref: row === 1 ? 'x' : 'x2', yref: yref + ' domain', + line: { color: isDark ? 'rgba(255,0,0,0.5)' : 'rgba(220,0,0,0.6)', width: 1.5, dash: 'dash' } + }); + if (text) { + annotations.push({ + x: Math.log10(xVal), y: 1, text: text, + xref: row === 1 ? 'x' : 'x2', yref: yref + ' domain', + showarrow: false, font: { color: isDark ? 'rgba(255,100,100,0.8)' : 'rgba(180,50,50,1)', size: isSmallScreen ? 10 : 12 }, + yshift: isSmallScreen ? 15 : 12, + xanchor: 'center', + yanchor: isSmallScreen ? 'middle' : 'bottom', + textangle: isSmallScreen ? -90 : 0 + }); + } + }; + addVLine(fs / 2, 'Nyquist', 1); + addVLine(fs, 'fs', 1); + addVLine(fs / 2, null, 2); + addVLine(fs, null, 2); + + // 10 的倍數強化線 + for (let p = pStart; p <= pEnd; p++) { + const v = Math.pow(10, p); + if (v >= fMin && v <= fMax) { + const gridColor = isDark ? 'rgba(128,128,128,0.5)' : 'rgba(100,100,100,0.3)'; + shapes.push( + { type: 'line', x0: v, x1: v, y0: 0, y1: 1, xref: 'x', yref: 'y domain', line: { color: gridColor, width: 1.5 } }, + { type: 'line', x0: v, x1: v, y0: 0, y1: 1, xref: 'x2', yref: 'y2 domain', line: { color: gridColor, width: 1.5 } } + ); + } + } + + const gridColor = isDark ? 'rgba(128,128,128,0.2)' : 'rgba(0,0,0,0.1)'; + const zeroLineColor = isDark ? '#4b5563' : '#cbd5e1'; + const textColor = isDark ? '#9ca3af' : '#475569'; + const titleColor = isDark ? '#d1d5db' : '#1e293b'; + + const xAxisCommon = { + type: 'log', title: { text: 'Freq (Hz)', font: { size: isSmallScreen ? 12 : 14 } }, + range: [Math.log10(fMin), Math.log10(fMax)], + tickvals: xTicks, ticktext: xTexts, + showgrid: true, gridcolor: gridColor, + tickfont: { color: textColor, size: isSmallScreen ? 11 : 12 }, + nticks: isSmallScreen ? 6 : 10 + }; + + const layout = { + paper_bgcolor: 'transparent', plot_bgcolor: 'transparent', + margin: isSmallScreen ? { t: 80, b: 70, l: 55, r: 15 } : { t: 70, b: 60, l: 70, r: 30 }, + showlegend: false, + grid: { rows: 2, columns: 1, pattern: 'independent', roworder: 'top to bottom', ygap: isSmallScreen ? 0.35 : 0.35 }, + font: { color: textColor, family: 'Inter, sans-serif' }, + annotations: [ + { text: 'Magnitude Response (dB)', xref: 'x domain', yref: 'y domain', x: 0.5, y: isSmallScreen ? 1.22 : 1.18, showarrow: false, font: { size: isSmallScreen ? 14 : 16, color: titleColor, weight: 'bold' } }, + { text: 'Phase Response (Deg)', xref: 'x2 domain', yref: 'y2 domain', x: 0.5, y: isSmallScreen ? 1.22 : 1.18, showarrow: false, font: { size: isSmallScreen ? 14 : 16, color: titleColor, weight: 'bold' } }, + ...annotations + ], + xaxis: { ...xAxisCommon }, + yaxis: { title: { text: 'Mag (dB)', font: { size: isSmallScreen ? 12 : 14 } }, range: [-60, 0], gridcolor: gridColor, zerolinecolor: zeroLineColor, tickfont: { color: textColor, size: isSmallScreen ? 11 : 12 } }, + xaxis2: { ...xAxisCommon }, + yaxis2: { title: { text: 'Phase (°)', font: { size: isSmallScreen ? 12 : 14 } }, range: [-180, 180], tickvals: [-180, -90, 0, 90, 180], gridcolor: gridColor, zerolinecolor: zeroLineColor, tickfont: { color: textColor, size: isSmallScreen ? 11 : 12 } }, + shapes: shapes + }; + + const traceMag = { x: freq, y: mag, name: 'Magnitude (dB)', type: 'scatter', line: { color: isDark ? '#3b82f6' : '#2563eb', width: 2.5 } }; + const tracePhase = { x: freq, y: phase, name: 'Phase (deg)', type: 'scatter', line: { color: isDark ? '#f97316' : '#ea580c', width: 2.5 }, xaxis: 'x2', yaxis: 'y2' }; + + Plotly.react('bodePlot', [traceMag, tracePhase], layout, { responsive: true }); + }, + + handleFileUpload(event) { + const file = event.target.files[0]; + if (!file) return; + this.csvFile = file; + this.filterDone = false; + this.csvPreview = []; + + const reader = new FileReader(); + reader.onload = (e) => { + const text = e.target.result; + const lines = text.split('\n').filter(l => l.trim()); + if (lines.length === 0) return; + this.csvColumns = lines[0].split(',').map(s => s.trim()); + this.selectedColumn = 0; + if (this.csvColumns.length > 1) { + const first = this.csvColumns[0].toLowerCase(); + if (['time', 't', 'sec', 'x', 'index', 'unnamed'].some(kw => first.includes(kw))) { + this.selectedColumn = 1; + } + } + const preview = []; + for (let i = 1; i < Math.min(lines.length, 6); i++) { + preview.push(lines[i].split(',').map(s => s.trim())); + } + this.csvPreview = preview; + }; + reader.readAsText(file); + }, + + async processFilter() { + if (!this.csvFile) return; + this.loadingFilter = true; + this.globalError = null; + const formData = new FormData(); + formData.append('file', this.csvFile); + formData.append('b', this.b_str); + formData.append('a', this.a_str); + formData.append('col_idx', this.selectedColumn); + try { + const res = await fetch('/api/filter', { method: 'POST', body: formData }); + if (!res.ok) { const err = await res.json(); throw new Error(err.detail || '濾波處理失敗'); } + const data = await res.json(); + this.timePlotData = data; + this.drawTimePlot(data); + this.filterDone = true; + } catch (e) { this.globalError = e.message; } + finally { this.loadingFilter = false; } + }, + + drawTimePlot(data) { + const isDark = this.isDarkMode; + const isSmallScreen = window.innerWidth < 768; + const gridColor = isDark ? 'rgba(128,128,128,0.2)' : 'rgba(0,0,0,0.1)'; + const zeroLineColor = isDark ? '#4b5563' : '#cbd5e1'; + const textColor = isDark ? '#9ca3af' : '#475569'; + + const layout = { + paper_bgcolor: 'transparent', plot_bgcolor: 'transparent', + margin: isSmallScreen ? { t: 60, b: 60, l: 45, r: 15 } : { t: 40, b: 55, l: 60, r: 20 }, + font: { color: textColor, family: 'Inter, sans-serif' }, + xaxis: { title: { text: 'Sample Index', font: { size: isSmallScreen ? 10 : 11 } }, gridcolor: gridColor, zerolinecolor: zeroLineColor, tickfont: { color: textColor, size: isSmallScreen ? 9 : 11 } }, + yaxis: { title: { text: 'Amplitude', font: { size: isSmallScreen ? 10 : 11 } }, gridcolor: gridColor, zerolinecolor: zeroLineColor, tickfont: { color: textColor, size: isSmallScreen ? 9 : 11 } }, + legend: { orientation: 'h', y: 1.02, yanchor: 'bottom', x: 1, xanchor: 'right', font: { color: textColor, size: isSmallScreen ? 9 : 10 } } + }; + Plotly.react('timePlot', [ + { x: data.index, y: data.original, name: '原始輸入訊號 (Input)', type: 'scatter', line: { color: isDark ? '#00cc96' : '#059669' }, opacity: 0.7 }, + { x: data.index, y: data.filtered, name: '濾波後輸出 (Output)', type: 'scatter', line: { color: isDark ? '#ef553b' : '#dc2626' }, opacity: 0.9 } + ], layout, { responsive: true }); + }, + + async downloadCsv() { + if (!this.csvFile) return; + const formData = new FormData(); + formData.append('file', this.csvFile); + formData.append('b', this.b_str); + formData.append('a', this.a_str); + formData.append('col_idx', this.selectedColumn); + try { + const res = await fetch('/api/filter/download', { method: 'POST', body: formData }); + if (!res.ok) throw new Error('下載失敗'); + const blob = await res.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = 'filtered_output.csv'; + document.body.appendChild(a); a.click(); + window.URL.revokeObjectURL(url); + } catch (e) { this.globalError = e.message; } + } + } +}).mount('#app') diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..ef36148 --- /dev/null +++ b/static/index.html @@ -0,0 +1,503 @@ + + + + + + + 差分方程式分析 (Difference Equation Analyzer) + + + + + + + + +
+ +
+

+ 差分方程式分析 (Difference Equation Analyzer)

+
+ + +
+
+ +
+ + +
+
+ + + + +
+
+ + + + {{ globalError }} + +
+ + +
+ +
+

1. 頻率響應 (Frequency + Response)

+
+ + 重新計算中... +
+
+ +
+
+
+
+
+ + +
+
+

2. 時域訊號探討

+
+
+

請上傳包含時域訊號的 CSV 檔案,系統會套用上述設定的差分濾波器。 +

+
+ + {{ + csvFile.name }} + +
+ +
+ 訊號欄位: + +
+ + + + +
+ + +
+

+ 資料預覽 (前五筆):

+
+ + + + + + + + + + + +
{{ col + }}
{{ val }} +
+
+
+ + +

波形比較圖 +

+ +
+
+
+ +
+ + + + +

請上傳包含時間序列的 CSV 檔案進行測試

+
+
+
+
+
+ +
+

© 2026 喆富創新科技股份有限公司. All rights reserved.

+
+
+ + + + \ No newline at end of file diff --git a/static/logo.png b/static/logo.png new file mode 100644 index 0000000..65714db Binary files /dev/null and b/static/logo.png differ diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..4a04ff2 --- /dev/null +++ b/static/style.css @@ -0,0 +1,149 @@ +/* Custom scrollbar for webkit */ +.custom-scrollbar::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +.custom-scrollbar::-webkit-scrollbar-track { + background: transparent; +} + +/* Light mode thumb */ +.custom-scrollbar::-webkit-scrollbar-thumb { + background: #cbd5e1; + border-radius: 3px; +} + +/* Dark mode thumb */ +.dark .custom-scrollbar::-webkit-scrollbar-thumb { + background: #374151; +} + +.custom-scrollbar::-webkit-scrollbar-thumb:hover { + background: #94a3b8; +} + +.dark .custom-scrollbar::-webkit-scrollbar-thumb:hover { + background: #4b5563; +} + +/* Base styles */ +body { + font-family: 'Inter', system-ui, -apple-system, sans-serif; + font-size: 16px; /* 提高基礎字體大小 */ + line-height: 1.6; /* 增加行高,提升閱讀舒適度 */ +} + +/* Range input styling overrides - Touch Optimized */ +input[type=range] { + -webkit-appearance: none; + appearance: none; /* 標準屬性 */ + background: #e2e8f0; + height: 8px; + border-radius: 4px; + transition: background 0.3s; + cursor: pointer; +} + +.dark input[type=range] { + background: #374151; +} + +input[type=range]::-webkit-slider-thumb { + -webkit-appearance: none; + height: 24px; /* 加大滑桿頭,方便手指觸控 */ + width: 24px; + border-radius: 50%; + background: #3b82f6; + cursor: pointer; + transition: background .15s ease-in-out, transform .1s; + box-shadow: 0 2px 5px rgba(0,0,0,0.2); + border: 2px solid white; +} + +.dark input[type=range]::-webkit-slider-thumb { + border: 2px solid #1f2937; +} + +input[type=range]::-webkit-slider-thumb:hover { + background: #2563eb; + transform: scale(1.1); +} + +/* 加大點擊區域與間距 */ +button, select, input[type=number], textarea { + min-height: 44px; /* 符合行動裝置建議的最小點擊高度 */ +} + +/* 小型功能按鈕(靈敏度、重置)排除最小高度限制 */ +.btn-small { + min-height: unset; +} + + +/* Hide number input arrows */ +input[type=number]::-webkit-inner-spin-button, +input[type=number]::-webkit-outer-spin-button { + -webkit-appearance: none; + appearance: none; /* 標準屬性 */ + margin: 0; +} +input[type=number] { + -moz-appearance: textfield; + appearance: textfield; +} + +/* 強制 Plotly modebar 容器定位於圖表外上方 */ +.js-plotly-plot .plotly .modebar-container { + top: -45px !important; + right: 0 !important; + left: 0 !important; + width: 100% !important; + display: block !important; +} + +/* 強制 modebar 內部按鈕橫向排列且靠右 */ +.js-plotly-plot .plotly .modebar { + position: static !important; + display: flex !important; + flex-direction: row !important; + flex-wrap: nowrap !important; + justify-content: flex-end !important; + background: transparent !important; + padding-right: 10px; +} + +/* 確保按鈕群組也是橫向 */ +.js-plotly-plot .plotly .modebar-group { + display: flex !important; + flex-direction: row !important; +} + +/* 增加圖表上方間距給 modebar 使用 */ +#bodePlot, #timePlot { + margin-top: 50px; +} + +/* RWD 調整:在窄螢幕下微調 modebar 位置與間距 */ +@media (max-width: 1024px) { + .js-plotly-plot .plotly .modebar-container { + top: -40px !important; + } +} + +/* details 展開動畫 */ +details > summary { + list-style: none; + user-select: none; +} +details > summary::-webkit-details-marker { + display: none; +} +details > div { + animation: slideDown 0.2s ease-out; + overflow: hidden; +} +@keyframes slideDown { + from { opacity: 0; transform: translateY(-6px); } + to { opacity: 1; transform: translateY(0); } +}