11 Commits

21 changed files with 1705 additions and 500326 deletions
+11
View File
@@ -37,8 +37,19 @@ Thumbs.db
ehthumbs.db
Desktop.ini
# Data and Reports
*.csv
*.png
!static/logo.png
*.docx
# Agent Skills
.agents/
.agents
# Local Tracker & AI Workspace
.scratch/
.gemini/
# SSL certs (private keys)
certs/*.pem
+101 -21
View File
@@ -8,10 +8,12 @@ Backend 使用 FastAPI、NumPy、SciPy 與 PandasFrontend 使用 Vue 3、Vite
- 設計 Lowpass、Highpass、Bandpass、Notch、1P1Z、2P1Z、2P2Z、PID、SOGI 濾波器。
- 比較 Ideal Float 與 Fixed-Point 的 Bode 頻率響應。
- 支援多個 cascade stages 的頻率響應與時域模擬,並可個別啟用或 bypass。
- 手動微調 b/a 係數、a1/a2、δ/r、Q-format 位元數與 fixed-point 整數係數。
- 上傳 CSV 時域資料,繪製輸入、Ideal Float 輸出與 Mimic Integer 輸出。
- 匯出包含理想浮點與定點整數模擬輸出的 CSV。
- 匯出包含理想浮點與定點整數模擬輸出的 CSVcascade workflow 可匯出乾淨的 4 欄 CSV。
- 使用瀏覽器 Web Serial API 將 fixed-point 係數寫入 MCU。
- 時域圖表支援 zoom 後以 `start_idx` / `end_idx` 向後端重取可視區間資料,保留 IIR 完整歷史狀態。
- 支援 Dark Mode 與響應式 UI。
## Quick Start
@@ -29,16 +31,12 @@ bash certs/generate_cert.sh
開啟:
```text
https://localhost:8000/ui/
https://192.168.2.58:8000/ui/
```
若從其他主機連線,改用:
本專案固定從 FastAPI 提供的 HTTPS UI 使用。不要用 Vite dev server URL 測試 CSV 或 MCU 功能,否則 `/api/...` 可能會回 `Not Found`
```text
https://<server-ip>:8000/ui/
```
自簽憑證會讓瀏覽器顯示安全性警告,需手動信任或選擇繼續前往。
自簽憑證會讓瀏覽器顯示安全性警告,第一次開啟時需手動信任或選擇繼續前往。
## Project Layout
@@ -47,7 +45,7 @@ dea_api.py FastAPI 入口與 HTTP route
dea/ 後端核心模組
bode.py 頻率響應計算
config.py 限制值與伺服器端 serial 設定
csv_processing.py CSV 快取、驗證、濾波與匯出
csv_processing.py CSV 快取、驗證、單濾波器/cascade 濾波與匯出
filter_design.py 濾波器設計
mcu.py MCU serial port 列表與命令寫入
schemas.py Pydantic request schema
@@ -66,15 +64,17 @@ chirp_signal.csv CSV 上傳測試範例
| Layer | Responsibility |
| --- | --- |
| FastAPI | 提供 API、serve `/ui/`、套用 LAN 存取限制與安全標頭 |
| `dea/` | 濾波器設計、Bode plot、CSV 處理、fixed-point simulation、驗證 |
| `dea/` | 濾波器設計、單濾波器/cascade Bode plot、CSV 處理、fixed-point simulation、驗證 |
| Vue UI | 管理係數、Q-format、CSV workflow、Plotly 圖表與 Web Serial 狀態 |
| Vite build | 將 `src/` 打包到 `static/build/`,由 FastAPI 在 production 提供 |
CSV workflow
1. 前端選擇 CSV。
2. 前端呼叫 `POST /api/csv/upload`,後端將檔案暫存並回傳 `file_id`
3. 後續 `POST /api/filter``POST /api/filter/download` 使用 `file_id`,避免反覆傳大檔
2. 前端只讀取檔案前段內容建立欄位與 preview,避免為了 preview 把大型 CSV 全部載入瀏覽器記憶體
3. 前端呼叫 `POST /api/csv/upload`,後端將檔案暫存並回傳 `file_id`
4. 上傳完成前,filter 按鈕會保持 disabled,避免尚未取得 `file_id` 就送出處理。
5. 後續 `POST /api/filter``POST /api/filter/download` 使用 `file_id`,避免反覆傳大檔。
CSV 預設暫存在:
@@ -88,6 +88,12 @@ CSV 預設暫存在:
DEA_TEMP_CSV_DIR=/path/to/csv-cache
```
暫存 CSV 預設保留 24 小時。可用環境變數調整,設為 `0` 可停用清理:
```bash
DEA_TEMP_CSV_TTL_SECONDS=86400
```
## Usage
### Filter Design
@@ -120,6 +126,24 @@ PID, SOGI-Alpha, SOGI-Beta
桌機可用 Shift + 滑鼠滾輪微調;觸控裝置可按住數值面板上下拖曳。
### Cascade Stages
UI 以 cascade stage accordion 管理多段濾波器。每個 stage 顯示序號、濾波器型態、Active/Bypass 狀態、係數摘要與 Q-format 摘要;可用上移/下移調整串接順序。
完整的濾波器設計、浮點係數、fixed-point 係數與 MCU 寫值控制只會出現在目前選取的 active stage accordion 內。同時間只有一個 active stage 承載完整 editor,避免多份表單同時修改同一組全域 active-stage state。展開非 active stage 時,UI 會先切換該 stage 為 active stage。
送出 Bode 或 CSV 時,前端會先同步目前 active stage,再把所有 stages 依畫面順序打包送到後端。單一 stage 時仍等同 legacy 單濾波器 workflow。
後端相容兩種路徑:
| Mode | API Behavior |
| --- | --- |
| Legacy single filter | `/api/bode``/api/bode/compare``/api/filter` 仍接受原本的 `b/a/b_int/a_int` 參數 |
| Cascade | `/api/bode/compare_cascade` 接受 stages`/api/filter``/api/filter/download` 可接受 `stages` form payload |
Cascade 設計刻意不直接 merge `origin/cascadedfilters` 的整包檔案,而是在 `main` 上重作功能,避免帶入大型 CSV、暫存檔、log、舊文件與 build noise。
### Fixed-Point Time Simulation
時域分析同時計算兩條路徑:
@@ -141,6 +165,13 @@ Q-format 參數:
整數模擬採高精度狀態變數架構:前饋項保留在 `Q_in+b` 精度,回授項除以 `A0` 對齊,再於最終輸出依 `Q_out` 縮放。
### Time-Domain LOD Zoom
時域圖表使用 Plotly zoom / pan 時,前端會把目前可視時間範圍換算成 sample index,透過 `/api/filter` 傳送 `start_idx``end_idx`。後端仍先用完整訊號計算 IIR 輸出,保留濾波器歷史狀態正確性,再只切出可視區間並 downsample 回傳,避免 zoom in 後只能看到粗略的全域降採樣資料。
時間軸單位會在首次完整載入時鎖定為 `s``ms``μs`,避免 zoom 後重新換單位造成 Plotly `uirevision` 座標錯位。前端也使用 `isRedrawingTimePlot` 避免 `Plotly.react()` 觸發 relayout callback 造成重繪迴圈。
### CSV Workflow
限制與行為:
@@ -154,18 +185,50 @@ Q-format 參數:
若第一欄像 `time``t``sec``x``index`,前端會預設選第二欄作為訊號欄位。
範例檔案
首頁會自動嘗試載入 `.scratch/examples/preset_signals.csv` 作為 preset waveform;若檔案不存在,UI 仍可正常手動上傳 CSV。preset 路徑可用環境變數覆寫
```text
chirp_signal.csv
```bash
DEA_PRESET_CSV_PATH=/path/to/preset_signals.csv
```
欄位
範例 CSV 不再放在 Git 追蹤路徑。大型範例請放在本機 ignored 目錄
```text
.scratch/examples/
```
常用範例欄位格式:
```text
time,value
```
可從歷史 commit 匯出範例到本機 ignored 路徑使用:
```bash
mkdir -p .scratch/examples
git show 075f20d:chirp_signal.csv > .scratch/examples/chirp_signal.csv
git show 0140b049:chirp_signal_small_s.csv > .scratch/examples/chirp_signal_small_s.csv
git show 0140b049:step_signal.csv > .scratch/examples/step_signal.csv
git show 8caa8918:static/preset_signals.csv > .scratch/examples/preset_signals.csv
```
`.scratch/` 已被 `.gitignore` 忽略,適合放大型 CSV、手動測試資料與暫存分析檔。
### CSV Export
`/api/filter/download` 預設保留 legacy 行為:輸出原始 CSV 欄位,並新增 `[欄位]_filtered_ideal``[欄位]_filtered_fixed`
若前端傳送 `compact=true`,後端會輸出專供分析用的 4 欄 CSV:
| Column | Meaning |
| --- | --- |
| `Time (s)` | 由 sample index 與前端傳入的 `fs` 計算 |
| 原輸入欄位 | 選定訊號欄位原始值 |
| `[欄位]_filtered_ideal` | Ideal Float 輸出 |
| `[欄位]_filtered_fixed` | Mimic Integer / fixed-point 輸出 |
### MCU Web Serial
Web Serial 是主要 MCU 寫入方式,伺服器不需要 serial 權限。
@@ -188,10 +251,12 @@ bodeplot=b0,b1,b2,a1,a2
| --- | --- | --- |
| `POST` | `/api/design` | 依濾波器參數產生 b/a 係數 |
| `POST` | `/api/bode` | 計算單組 b/a 頻率響應 |
| `POST` | `/api/bode/compare` | 比較 Ideal Float 與 Fixed-Point 頻率響應 |
| `POST` | `/api/bode/compare` | 比較單一濾波器 Ideal Float 與 Fixed-Point 頻率響應 |
| `POST` | `/api/bode/compare_cascade` | 比較 cascade stages 的 Ideal Float 與 Fixed-Point 頻率響應 |
| `GET` | `/api/csv/preset` | 讀取本機 ignored preset CSV metadata,供首頁自動載入 |
| `POST` | `/api/csv/upload` | 上傳 CSV 並回傳 `file_id` |
| `POST` | `/api/filter` | 使用 `file_id` 回傳時域圖表資料 |
| `POST` | `/api/filter/download` | 使用 `file_id` 匯出結果 CSV |
| `POST` | `/api/filter` | 使用 `file_id` 回傳單濾波器或 cascade 時域圖表資料;可傳 `start_idx/end_idx` 做 LOD zoom |
| `POST` | `/api/filter/download` | 使用 `file_id` 匯出結果 CSV;可傳 `compact=true` 匯出 4 欄 CSV |
| `GET` | `/api/mcu/ports` | 列出伺服器端 serial ports |
| `POST` | `/api/mcu/write` | 後端模式寫入 MCU 命令 |
@@ -201,6 +266,7 @@ bodeplot=b0,b1,b2,a1,a2
```bash
npm install
npm run test:frontend
npm run build
```
@@ -210,7 +276,11 @@ npm run build
npm run dev
```
目前 `vite.config.js` 保留 `main` 設定,不在 Vite 內設定 `/api` proxy。
CSV 與 API 流程保留 `dev-v2` 的兩段式設計:先 upload 取得 `file_id`,再用 `file_id` filter 或 download。`vite.config.js` 保留 `main` 的 HTTPS deployment 設定,不在 Vite 內設定 `/api` proxy。因此 `npm run dev` 只適合檢查前端畫面;CSV upload、filter API、MCU API 請固定使用:
```text
https://192.168.2.58:8000/ui/
```
### Backend
@@ -309,9 +379,19 @@ HTTP responses 會加上 CSP、`Referrer-Policy`、`X-Content-Type-Options: nosn
## Troubleshooting
### CSV 上傳顯示 `Not Found`
請確認目前網址是:
```text
https://192.168.2.58:8000/ui/
```
如果從 `http://localhost:5173` 或其他 Vite dev server URL 開啟,`/api/csv/upload` 會送到前端 dev server,而不是 FastAPI,因此會回 `Not Found`
### 無法連線 MCU
1. 確認使用 HTTPS 或 localhost
1. 確認使用 `https://192.168.2.58:8000/ui/`
2. 確認瀏覽器支援 Web Serial API。
3. 使用 Chrome 或 Edge。
4. 檢查 USB 線與 MCU 裝置狀態。
-500001
View File
File diff suppressed because it is too large Load Diff
+21 -4
View File
@@ -1,12 +1,29 @@
# =============================================================
# dea.service — Difference Equation Analyzer systemd 服務設定
# =============================================================
#
# 部署步驟:
# 1. 修改 User / WorkingDirectory / 路徑
# 2. sudo cp dea.service /etc/systemd/system/
# 3. sudo systemctl daemon-reload
# 4. sudo systemctl enable --now dea
# 5. sudo systemctl restart dea.service
#
# 防火牆(若手機或其他裝置無法連線):
# sudo ufw allow 8000
#
# =============================================================
[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 --ssl-keyfile=/home/wisetop/diff-eq-analyzer/certs/key.pem --ssl-certfile=/home/wisetop/diff-eq-analyzer/certs/cert.pem
User=roy
WorkingDirectory=/home/roy/zData/WTPCode/bodeplot
Environment="PATH=/home/roy/zData/WTPCode/bodeplot/.venv/bin"
ExecStart=/home/roy/zData/WTPCode/bodeplot/.venv/bin/uvicorn dea_api:app --host 0.0.0.0 --port 8000 --ssl-keyfile=/home/roy/zData/WTPCode/bodeplot/certs/key.pem --ssl-certfile=/home/roy/zData/WTPCode/bodeplot/certs/cert.pem
Restart=always
RestartSec=3
+37 -1
View File
@@ -3,7 +3,7 @@ from fastapi import HTTPException
from scipy import signal
from .config import BODE_MAX_MULTIPLIER, BODE_POINTS
from .schemas import BodeCompareParams, BodeParams
from .schemas import BodeCascadeParams, BodeCompareParams, BodeParams
from .validation import require_finite, validate_coefficients
@@ -47,3 +47,39 @@ def calculate_bode_compare_response(params: BodeCompareParams):
"ideal": ideal,
"fixed": fixed,
}
def calculate_bode_cascade_response(params: BodeCascadeParams):
fs_val, f_eval = _frequency_axis(params.fs)
h_total_ideal = np.ones(len(f_eval), dtype=complex)
h_total_fixed = np.ones(len(f_eval), dtype=complex)
any_active = False
for i, stage in enumerate(params.stages):
if not stage.isActive:
continue
any_active = True
b_vals = validate_coefficients(stage.b, f"stages[{i}].b")
a_vals = validate_coefficients(stage.a, f"stages[{i}].a")
b_fixed_vals = validate_coefficients(stage.b_fixed, f"stages[{i}].b_fixed")
a_fixed_vals = validate_coefficients(stage.a_fixed, f"stages[{i}].a_fixed")
_, h_ideal = signal.freqz(b_vals, a_vals, worN=f_eval, fs=fs_val)
_, h_fixed = signal.freqz(b_fixed_vals, a_fixed_vals, worN=f_eval, fs=fs_val)
h_total_ideal *= h_ideal
h_total_fixed *= h_fixed
if not any_active:
h_total_ideal = np.ones(len(f_eval), dtype=complex)
h_total_fixed = np.ones(len(f_eval), dtype=complex)
return {
"freq": f_eval.tolist(),
"ideal": {
"mag": (20 * np.log10(np.abs(h_total_ideal) + 1e-12)).tolist(),
"phase": np.angle(h_total_ideal, deg=True).tolist(),
},
"fixed": {
"mag": (20 * np.log10(np.abs(h_total_fixed) + 1e-12)).tolist(),
"phase": np.angle(h_total_fixed, deg=True).tolist(),
},
}
+144 -27
View File
@@ -3,6 +3,7 @@ import os
import uuid
import re
import tempfile
import time
import numpy as np
import pandas as pd
@@ -16,9 +17,33 @@ TEMP_CSV_DIR = os.environ.get(
"DEA_TEMP_CSV_DIR",
os.path.join(tempfile.gettempdir(), "diff-eq-analyzer-csv"),
)
TEMP_CSV_TTL_SECONDS = int(os.environ.get("DEA_TEMP_CSV_TTL_SECONDS", str(24 * 60 * 60)))
PRESET_FILE_ID = "00000000-0000-0000-0000-000000000000"
PRESET_CSV_PATH = os.environ.get("DEA_PRESET_CSV_PATH", ".scratch/examples/preset_signals.csv")
os.makedirs(TEMP_CSV_DIR, exist_ok=True)
def prune_expired_csv_uploads(now=None):
if TEMP_CSV_TTL_SECONDS <= 0:
return
os.makedirs(TEMP_CSV_DIR, exist_ok=True)
cutoff = (now or time.time()) - TEMP_CSV_TTL_SECONDS
for filename in os.listdir(TEMP_CSV_DIR):
if not filename.endswith(".csv"):
continue
path = os.path.join(TEMP_CSV_DIR, filename)
try:
if os.path.isfile(path) and os.path.getmtime(path) < cutoff:
os.remove(path)
except OSError:
pass
def get_cached_file_path(file_id):
if file_id == PRESET_FILE_ID:
if not os.path.exists(PRESET_CSV_PATH):
raise HTTPException(status_code=404, detail="找不到 preset CSV 檔案")
return PRESET_CSV_PATH
if not re.match(r'^[0-9a-f\-]{36}$', file_id):
raise HTTPException(status_code=400, detail="無效的檔案ID")
file_path = os.path.join(TEMP_CSV_DIR, f"{file_id}.csv")
@@ -27,6 +52,34 @@ def get_cached_file_path(file_id):
return file_path
def preset_csv_metadata(preview_rows=5):
if not os.path.exists(PRESET_CSV_PATH):
return {"available": False}
preview_df = pd.read_csv(PRESET_CSV_PATH, nrows=preview_rows)
columns = preview_df.columns.tolist()
with open(PRESET_CSV_PATH, "rb") as f:
rows = max(sum(1 for _ in f) - 1, 0)
default_col_idx = 0
if len(columns) > 1:
first = columns[0].lower()
if any(kw in first for kw in ("time", "t", "sec", "x", "index", "unnamed")):
default_col_idx = 1
return {
"available": True,
"file_id": PRESET_FILE_ID,
"name": os.path.basename(PRESET_CSV_PATH),
"columns": columns,
"preview": preview_df.fillna("").astype(str).values.tolist(),
"rows": rows,
"columns_count": len(columns),
"size": os.path.getsize(PRESET_CSV_PATH),
"default_col_idx": default_col_idx,
}
def downsample_for_plot(index, original, filtered_float, filtered_fixed):
total = len(index)
if total <= MAX_PLOT_POINTS:
@@ -35,7 +88,22 @@ def downsample_for_plot(index, original, filtered_float, filtered_fixed):
return index[::step], original[::step], filtered_float[::step], filtered_fixed[::step], step
def _numeric_signal(series, col_name):
original_missing = series.isna()
numeric_signal = pd.to_numeric(series, errors="coerce")
if (numeric_signal.isna() & ~original_missing).any():
raise HTTPException(status_code=400, detail=f"欄位 {col_name} 含有非數值資料")
numeric_signal = numeric_signal.dropna()
if numeric_signal.empty:
raise HTTPException(status_code=400, detail=f"欄位 {col_name} 沒有可用的數值資料")
x_values = numeric_signal.to_numpy(dtype=float)
if not np.isfinite(x_values).all():
raise HTTPException(status_code=400, detail=f"欄位 {col_name} 含有非有限數值")
return numeric_signal, x_values
async def save_csv_upload(file):
prune_expired_csv_uploads()
filename = (file.filename or "").lower()
if filename and not filename.endswith(".csv"):
raise HTTPException(status_code=400, detail="請上傳 CSV 檔案")
@@ -118,7 +186,43 @@ def integer_lfilter(b_int, a_int, x_float, shift_in, shift_out, shift_b, shift_a
return y_out.astype(float) / (2**shift_out)
def filter_preview_response(file_id, b_vals, a_vals, col_idx, b_int=None, a_int=None, shift_in=14, shift_out=14, shift_b=14, shift_a=14, use_round=False):
def _run_filter_paths(x_values, b_vals, a_vals, b_int, a_int, shift_in, shift_out, shift_b, shift_a, use_round, stages=None):
if stages is None:
y_float = signal.lfilter(b_vals, a_vals, x_values)
if b_int is not None and a_int is not None:
y_fixed = integer_lfilter(b_int, a_int, x_values, shift_in, shift_out, shift_b, shift_a, use_round)
else:
y_fixed = y_float
return y_float, y_fixed
y_float = x_values
y_fixed = x_values
any_active = False
for stage in stages:
if not stage.get("isActive", True):
continue
any_active = True
y_float = signal.lfilter(stage["b"], stage["a"], y_float)
if stage.get("b_int") is not None and stage.get("a_int") is not None:
y_fixed = integer_lfilter(
stage["b_int"],
stage["a_int"],
y_fixed,
stage["shift_in"],
stage["shift_out"],
stage["shift_b"],
stage["shift_a"],
stage["use_round"],
)
else:
y_fixed = y_float
if not any_active:
return x_values, x_values
return y_float, y_fixed
def filter_preview_response(file_id, b_vals, a_vals, col_idx, b_int=None, a_int=None, shift_in=14, shift_out=14, shift_b=14, shift_a=14, use_round=False, stages=None, start_idx=None, end_idx=None):
path = get_cached_file_path(file_id)
# 預先讀取欄位名稱,避免用 usecols 讀取後找不到原始索引
@@ -129,24 +233,29 @@ def filter_preview_response(file_id, b_vals, a_vals, col_idx, b_int=None, a_int=
# 精準讀取單一欄位,並加上筆數限制 (極大降低記憶體用量與時間)
df = pd.read_csv(path, usecols=[col_idx], nrows=MAX_ROWS)
x_signal = pd.to_numeric(df[col_to_filter], errors="coerce")
x_signal, x_values = _numeric_signal(df[col_to_filter], col_to_filter)
if x_signal.isna().any():
raise HTTPException(status_code=400, detail=f"欄位 {col_to_filter} 含有非數值資料")
x_values = x_signal.to_numpy(dtype=float)
if not np.isfinite(x_values).all():
raise HTTPException(status_code=400, detail=f"欄位 {col_to_filter} 含有非有限數值")
y_float, y_fixed = _run_filter_paths(
x_values, b_vals, a_vals, b_int, a_int,
shift_in, shift_out, shift_b, shift_a, use_round, stages=stages,
)
# 路徑 1: 理想浮點數路徑
y_float = signal.lfilter(b_vals, a_vals, x_values)
total_points = len(x_signal)
start_idx_clean = start_idx if isinstance(start_idx, (int, float, str)) else None
end_idx_clean = end_idx if isinstance(end_idx, (int, float, str)) else None
s_idx = max(0, int(start_idx_clean)) if start_idx_clean is not None else 0
e_idx = min(total_points, int(end_idx_clean)) if end_idx_clean is not None else total_points
if s_idx >= e_idx:
s_idx = 0
e_idx = total_points
# 路徑 2: 整數模擬路徑
if b_int is not None and a_int is not None:
y_fixed = integer_lfilter(b_int, a_int, x_values, shift_in, shift_out, shift_b, shift_a, use_round)
else:
y_fixed = y_float
index, original, filtered_float, filtered_fixed, step = downsample_for_plot(df.index.to_numpy(), x_signal, y_float, y_fixed)
full_index = x_signal.index.to_numpy()
index, original, filtered_float, filtered_fixed, step = downsample_for_plot(
full_index[s_idx:e_idx],
x_values[s_idx:e_idx],
y_float[s_idx:e_idx],
y_fixed[s_idx:e_idx],
)
return {
"index": index.tolist(),
@@ -154,13 +263,13 @@ def filter_preview_response(file_id, b_vals, a_vals, col_idx, b_int=None, a_int=
"filtered": filtered_float.tolist(),
"filtered_fixed": filtered_fixed.tolist(),
"col_name": col_to_filter,
"total_points": int(len(df.index)),
"total_points": int(total_points),
"plot_points": int(len(index)),
"downsample_step": int(step),
}
def filtered_csv_text(file_id, b_vals, a_vals, col_idx, b_int=None, a_int=None, shift_in=14, shift_out=14, shift_b=14, shift_a=14, use_round=False):
def filtered_csv_text(file_id, b_vals, a_vals, col_idx, b_int=None, a_int=None, shift_in=14, shift_out=14, shift_b=14, shift_a=14, use_round=False, stages=None, fs=100000.0, compact=False):
path = get_cached_file_path(file_id)
# 匯出時需要原始所有欄位,但仍受限於 MAX_ROWS
df = pd.read_csv(path, nrows=MAX_ROWS)
@@ -168,17 +277,25 @@ def filtered_csv_text(file_id, b_vals, a_vals, col_idx, b_int=None, a_int=None,
if col_idx < 0 or col_idx >= len(df.columns):
raise HTTPException(status_code=400, detail="欄位索引超出範圍")
col_to_filter = df.columns[col_idx]
x_signal = pd.to_numeric(df[col_to_filter], errors="coerce")
x_values = x_signal.to_numpy(dtype=float)
x_signal, x_values = _numeric_signal(df[col_to_filter], col_to_filter)
y_float = signal.lfilter(b_vals, a_vals, x_values)
if b_int is not None and a_int is not None:
y_fixed = integer_lfilter(b_int, a_int, x_values, shift_in, shift_out, shift_b, shift_a, use_round)
y_float, y_fixed = _run_filter_paths(
x_values, b_vals, a_vals, b_int, a_int,
shift_in, shift_out, shift_b, shift_a, use_round, stages=stages,
)
if compact:
export_df = pd.DataFrame({
"Time (s)": x_signal.index.to_numpy(dtype=float) / fs,
col_to_filter: x_values,
f"{col_to_filter}_filtered_ideal": y_float,
f"{col_to_filter}_filtered_fixed": y_fixed,
})
else:
y_fixed = y_float
export_df = df.copy()
export_df[f"{col_to_filter}_filtered_ideal"] = pd.Series(y_float, index=x_signal.index)
export_df[f"{col_to_filter}_filtered_fixed"] = pd.Series(y_fixed, index=x_signal.index)
df[f"{col_to_filter}_filtered_ideal"] = y_float
df[f"{col_to_filter}_filtered_fixed"] = y_fixed
csv_buffer = io.StringIO()
df.to_csv(csv_buffer, index=False)
export_df.to_csv(csv_buffer, index=False)
return csv_buffer.getvalue()
+13
View File
@@ -47,3 +47,16 @@ class BodeCompareParams(BaseModel):
class MCUWriteParams(BaseModel):
command: str = Field(min_length=1, max_length=128)
port: Optional[str] = Field(default=None, max_length=256)
class CascadeStageParams(BaseModel):
b: List[float] = Field(min_length=1, max_length=MAX_COEFFICIENTS)
a: List[float] = Field(min_length=1, max_length=MAX_COEFFICIENTS)
b_fixed: List[float] = Field(min_length=1, max_length=MAX_COEFFICIENTS)
a_fixed: List[float] = Field(min_length=1, max_length=MAX_COEFFICIENTS)
isActive: bool = True
class BodeCascadeParams(BaseModel):
stages: List[CascadeStageParams] = Field(min_length=1)
fs: float = Field(gt=0)
+67 -8
View File
@@ -2,7 +2,7 @@ from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import RedirectResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from dea.bode import calculate_bode_compare_response, calculate_bode_response
from dea.bode import calculate_bode_cascade_response, calculate_bode_compare_response, calculate_bode_response
from dea.config import (
BODE_MAX_MULTIPLIER,
BODE_POINTS,
@@ -15,11 +15,12 @@ from dea.csv_processing import (
downsample_for_plot,
filter_preview_response,
filtered_csv_text,
preset_csv_metadata,
save_csv_upload,
)
from dea.filter_design import design_response
from dea.mcu import list_mcu_ports_response, write_mcu_command_response
from dea.schemas import BodeCompareParams, BodeParams, DesignParams, MCUWriteParams
from dea.schemas import BodeCascadeParams, BodeCompareParams, BodeParams, DesignParams, MCUWriteParams
from dea.security import (
SECURITY_HEADERS,
add_security_headers,
@@ -87,6 +88,27 @@ def calculate_bode_compare(params: BodeCompareParams):
raise HTTPException(status_code=400, detail=str(e))
@app.post("/api/bode/compare_cascade")
def calculate_bode_compare_cascade(params: BodeCascadeParams):
try:
return calculate_bode_cascade_response(params)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@app.get("/api/csv/preset")
def preset_csv():
try:
return preset_csv_metadata()
except HTTPException:
raise
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=400, detail=f"preset CSV 讀取失敗: {str(e)}")
@app.post("/api/csv/upload")
async def upload_csv(file: UploadFile = File(...)):
try:
@@ -99,6 +121,28 @@ async def upload_csv(file: UploadFile = File(...)):
raise HTTPException(status_code=400, detail=f"CSV上傳失敗: {str(e)}")
def parse_stages_list(stages_str: str):
if not isinstance(stages_str, str) or not stages_str:
return None
import json
stages_raw = json.loads(stages_str)
stages_list = []
for i, stage in enumerate(stages_raw):
stages_list.append({
"b": parse_coefficients(stage.get("b_str", ""), f"stages[{i}].b"),
"a": parse_coefficients(stage.get("a_str", ""), f"stages[{i}].a"),
"b_int": parse_int_coefficients(stage.get("b_int_str", ""), f"stages[{i}].b_int") if stage.get("b_int_str") else None,
"a_int": parse_int_coefficients(stage.get("a_int_str", ""), f"stages[{i}].a_int") if stage.get("a_int_str") else None,
"shift_in": int(stage.get("shift_in", 14)),
"shift_out": int(stage.get("shift_out", 14)),
"shift_b": int(stage.get("shift_b", 14)),
"shift_a": int(stage.get("shift_a", 14)),
"use_round": bool(stage.get("use_round", False)),
"isActive": bool(stage.get("isActive", True)),
})
return stages_list
@app.post("/api/filter")
async def filter_csv(
file_id: str = Form(...),
@@ -112,20 +156,27 @@ async def filter_csv(
shift_b: int = Form(14),
shift_a: int = Form(14),
use_round: bool = Form(False),
stages: str = Form(None),
start_idx: int = Form(None),
end_idx: int = Form(None),
):
try:
b_vals = parse_coefficients(b, "b")
a_vals = parse_coefficients(a, "a")
b_int_vals = parse_int_coefficients(b_int, "b_int") if b_int else None
a_int_vals = parse_int_coefficients(a_int, "a_int") if a_int else None
b_int_vals = parse_int_coefficients(b_int, "b_int") if isinstance(b_int, str) and b_int else None
a_int_vals = parse_int_coefficients(a_int, "a_int") if isinstance(a_int, str) and a_int else None
stages_list = parse_stages_list(stages)
return filter_preview_response(
file_id, b_vals, a_vals, col_idx,
b_int=b_int_vals, a_int=a_int_vals,
shift_in=shift_in, shift_out=shift_out,
shift_b=shift_b, shift_a=shift_a,
use_round=use_round
use_round=use_round,
stages=stages_list,
start_idx=start_idx,
end_idx=end_idx
)
except HTTPException:
raise
@@ -167,20 +218,28 @@ async def filter_csv_download(
shift_b: int = Form(14),
shift_a: int = Form(14),
use_round: bool = Form(False),
stages: str = Form(None),
fs: float = Form(100000.0),
compact: bool = Form(False),
):
try:
b_vals = parse_coefficients(b, "b")
a_vals = parse_coefficients(a, "a")
b_int_vals = parse_int_coefficients(b_int, "b_int") if b_int else None
a_int_vals = parse_int_coefficients(a_int, "a_int") if a_int else None
b_int_vals = parse_int_coefficients(b_int, "b_int") if isinstance(b_int, str) and b_int else None
a_int_vals = parse_int_coefficients(a_int, "a_int") if isinstance(a_int, str) and a_int else None
stages_list = parse_stages_list(stages)
compact_value = compact if isinstance(compact, bool) else False
csv_text = filtered_csv_text(
file_id, b_vals, a_vals, col_idx,
b_int=b_int_vals, a_int=a_int_vals,
shift_in=shift_in, shift_out=shift_out,
shift_b=shift_b, shift_a=shift_a,
use_round=use_round
use_round=use_round,
stages=stages_list,
fs=fs,
compact=compact_value
)
return StreamingResponse(
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>差分方程式分析 (Difference Equation Analyzer)</title>
<title>級聯差分方程式分析 (Cascade Difference Equation Analyzer)</title>
<script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>
<link rel="icon" type="image/png" href="/ui/logo.png">
</head>
+2 -1
View File
@@ -2,7 +2,8 @@
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite --host 0.0.0.0"
"dev": "vite --host 0.0.0.0",
"test:frontend": "node tests/frontend/cascade-stage-smoke.mjs"
},
"dependencies": {
"@vitejs/plugin-vue": "^6.0.6",
+239 -105
View File
@@ -1,35 +1,11 @@
<template>
<div id="app" class="flex flex-col h-screen overflow-hidden">
<!-- 頂部導覽列 -->
<header
class="bg-white dark:bg-dark border-b border-slate-200 dark:border-gray-800 p-4 shadow-md flex justify-between items-center z-10">
<div class="flex items-center gap-3">
<img :src="'/ui/logo.png'" alt="Logo" class="h-10 w-10 object-contain rounded-lg shadow-sm">
<h1 class="text-xl font-bold text-slate-900 dark:text-gray-100">
差分方程式分析 (Difference Equation Analyzer)</h1>
</div>
<div class="flex items-center gap-4">
<!-- 模式切換按鈕 -->
<button @click="toggleDarkMode"
class="p-2 rounded-lg bg-slate-100 dark:bg-gray-800 hover:bg-slate-200 dark:hover:bg-gray-700 transition-colors shadow-sm">
<svg v-if="isDarkMode" class="w-5 h-5 role-theme-icon" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z" />
<path fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-13a1 1 0 10-2 0 1 1 0 002 0zM2 10a1 1 0 011-1h1a1 1 0 110 2H3a1 1 0 01-1-1zm14 0a1 1 0 011-1h1a1 1 0 110 2h-1a1 1 0 01-1-1zM5.05 5.05a1 1 0 011.414 0l.707.707a1 1 0 11-1.414 1.414l-.707-.707a1 1 0 010-1.414zm9.9 9.9a1 1 0 011.414 0l.707.707a1 1 0 11-1.414 1.414l-.707-.707a1 1 0 010-1.414zM5.05 14.95a1 1 0 010-1.414l.707-.707a1 1 0 011.414 1.414l-.707.707a1 1 0 01-1.414 0zm9.9-9.9a1 1 0 010-1.414l.707-.707a1 1 0 011.414 1.414l-.707.707a1 1 0 01-1.414 0z"
clip-rule="evenodd" />
</svg>
<svg v-else class="w-5 h-5 text-slate-800" fill="currentColor" viewBox="0 0 20 20">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
</svg>
</button>
</div>
</header>
<!-- 手機版頁籤列 (桌面版隱藏) -->
<div
class="lg:hidden flex border-b border-slate-200 dark:border-gray-800 bg-white dark:bg-dark z-10 flex-shrink-0">
<button @click="mobileTab = 'settings'" :class="mobileTab === 'settings'
? 'border-b-2 role-border-primary role-text-primary bg-transparent'
: 'text-slate-600 dark:text-gray-400 hover:text-slate-800 dark:hover:text-gray-200'"
: 'text-slate-500 dark:text-gray-400 hover:text-slate-700 dark:hover:text-gray-200'"
class="flex-1 flex items-center justify-center gap-2 py-3 text-sm font-semibold transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
@@ -37,25 +13,95 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
設定
串聯設定
</button>
<button @click="mobileTab = 'chart'" :class="mobileTab === 'chart'
? 'border-b-2 role-border-primary role-text-primary bg-transparent'
: 'text-slate-600 dark:text-gray-400 hover:text-slate-800 dark:hover:text-gray-200'"
: 'text-slate-500 dark:text-gray-400 hover:text-slate-700 dark:hover:text-gray-200'"
class="flex-1 flex items-center justify-center gap-2 py-3 text-sm font-semibold transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z">
</path>
</svg>
圖表
串聯響應
<span v-if="loadingBode" class="w-2 h-2 rounded-full role-dot-primary animate-ping"></span>
</button>
</div>
<div class="flex flex-col lg:flex-row flex-1 overflow-hidden">
<!-- 左側控制面板 (RWD: 手機版=設定頁籤全螢幕桌面版=左側邊欄) -->
<aside :class="mobileTab === 'settings' ? 'flex' : 'hidden'"
class="lg:flex flex-col w-full lg:w-96 h-full bg-white dark:bg-dark border-b lg:border-b-0 lg:border-r border-slate-200 dark:border-gray-800 overflow-y-auto p-5 gap-5 custom-scrollbar transition-colors duration-300">
<div class="flex flex-col lg:flex-row flex-1 overflow-hidden relative">
<!-- 浮動展開側邊欄按鈕 (僅在桌面版且收折時顯示) -->
<button v-show="isSidebarCollapsed" @click="toggleSidebar"
class="hidden lg:flex absolute top-6 left-6 z-30 p-2 rounded-lg bg-white dark:bg-dark border border-slate-200/80 dark:border-gray-800 shadow-md text-slate-500 hover:text-slate-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-slate-100 dark:hover:bg-gray-800 transition-all flex-shrink-0"
title="展開側邊欄">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
</button>
<aside :class="[mobileTab === 'settings' ? 'flex' : 'hidden', isSidebarCollapsed ? 'sidebar-collapsed' : '']"
class="lg:flex flex-col w-full lg:w-96 h-full bg-white dark:bg-dark border-b lg:border-b-0 lg:border-r border-slate-200 dark:border-gray-800 transition-all duration-300 ease-in-out select-none flex-shrink-0 overflow-y-auto p-5 lg:p-4 gap-5 lg:gap-4 custom-scrollbar">
<!-- 側邊欄頂部 Logo 與標題及收折按鈕 -->
<div class="flex items-center justify-between pb-3 border-b border-slate-200 dark:border-gray-800/80 flex-shrink-0">
<div class="flex items-center gap-2 overflow-hidden">
<img :src="'/ui/logo.png'" alt="Logo" style="width: 24px; height: 24px;" class="object-contain rounded-md shadow-sm flex-shrink-0">
<h1 class="text-sm font-extrabold text-slate-800 dark:text-gray-200 tracking-wide truncate">
級聯差分方程式分析
</h1>
</div>
<button @click="toggleSidebar"
class="p-1.5 rounded-lg text-slate-500 hover:text-slate-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-slate-100 dark:hover:bg-gray-800/60 transition-colors flex-shrink-0"
title="收折側邊欄">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
</svg>
</button>
</div>
<!-- 系統級參數與 MCU 連線 -->
<section class="bg-slate-50 dark:bg-gray-900/40 p-3.5 rounded-xl border border-slate-200 dark:border-gray-800 space-y-3 shadow-sm">
<h2 class="text-sm font-semibold text-slate-600 dark:text-gray-400 uppercase tracking-wider mb-3 flex items-center gap-2">
<svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z">
</path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
全域系統設定
</h2>
<div class="grid grid-cols-1 gap-3">
<div>
<label class="text-xs text-slate-500 dark:text-gray-400 flex justify-between mb-1">
<span>系統取樣頻率 fs</span>
<span class="role-text-primary font-mono font-bold">{{ formatK(fs) }}</span>
</label>
<input type="number" v-model.number="fs" @input="debouncedApply" @keydown="onlyNumberKey"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-2 text-sm outline-none transition-all text-slate-900 dark:text-gray-100 font-mono">
</div>
<div class="flex flex-col justify-end">
<div v-if="webSerialSupported" class="w-full">
<button v-if="!mcuConnected" @click="connectMCUPort"
class="w-full text-xs font-bold bg-slate-200 hover:bg-slate-300 dark:bg-gray-800 dark:hover:bg-gray-700 text-slate-700 dark:text-gray-200 px-3 py-2 rounded-lg transition-colors flex items-center justify-center gap-1.5 border border-slate-300 dark:border-gray-700 shadow-sm">
連線硬體 MCU
</button>
<div v-else class="flex items-center justify-between bg-emerald-500/10 dark:bg-emerald-500/5 border border-emerald-500/20 px-3 py-2 rounded-lg">
<span class="flex items-center gap-1.5 text-xs font-semibold text-emerald-600 dark:text-emerald-400">
<span class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span>
MCU 已連線
</span>
<button @click="disconnectMCUPort" class="text-[10px] font-bold text-red-500 hover:underline">
中斷
</button>
</div>
</div>
<p v-else class="text-[10px] text-amber-600 dark:text-amber-400 leading-relaxed">
此瀏覽器不支援 Web Serial
</p>
</div>
</div>
<p v-if="mcuStatus" class="text-xs text-slate-600 dark:text-gray-400">{{ mcuStatus }}</p>
</section>
<!-- 濾波器設計工具 -->
<section>
@@ -69,40 +115,74 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
濾波器設計
</div>
<div class="flex items-center gap-2">
<button @click="writeToMCU"
:disabled="writingMCU || !mcuConnected"
class="primary-action text-[10px] font-bold role-bg-primary role-hover-primary role-text-on-fill role-border-primary border px-3 py-1 rounded-full transition-colors uppercase disabled:opacity-50 disabled:cursor-not-allowed">
{{ writingMCU ? '寫入中' : '寫值到 MCU' }}
</button>
<button @click="resetFilterParams"
class="text-[10px] font-bold border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-700 dark:text-gray-300 hover:bg-slate-100 dark:hover:bg-gray-700 px-3 py-1 rounded-full transition-colors uppercase">重設設計</button>
串聯濾波器設計
</div>
<div class="flex items-center gap-2"></div>
</h2>
<div class="mb-3 space-y-2">
<div v-if="webSerialSupported" class="flex items-center gap-2">
<button v-if="!mcuConnected" @click="connectMCUPort"
class="flex-1 text-xs font-bold role-bg-primary role-hover-primary role-text-on-fill role-border-primary border px-3 py-2 rounded transition-colors uppercase">
選擇連接埠並連線
<div class="mb-3 p-3 rounded-lg border border-slate-200 dark:border-gray-800 bg-slate-50 dark:bg-gray-900/50 space-y-2">
<div class="flex items-center justify-between gap-2">
<span class="text-xs font-semibold text-slate-600 dark:text-gray-400 uppercase tracking-wide">Cascade Stages</span>
<button @click="addCascadeStage"
class="text-[10px] font-bold role-bg-primary role-hover-primary role-text-on-fill px-3 py-1.5 rounded-full transition-colors">
新增 Stage
</button>
<template v-else>
<span class="flex items-center gap-1.5 flex-1 text-xs font-semibold text-emerald-600 dark:text-emerald-400">
<span class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span>
MCU 已連線
</span>
<button @click="disconnectMCUPort"
class="text-[10px] font-bold bg-slate-200 dark:bg-gray-800 text-slate-700 dark:text-gray-300 hover:bg-slate-300 dark:hover:bg-gray-700 px-2 py-2 rounded transition-colors uppercase">
斷線
</button>
</template>
</div>
<p v-if="!webSerialSupported" class="text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg px-3 py-2">
此瀏覽器不支援 Web SerialMCU 寫值功能需使用 Chrome Edge且透過 HTTPS 連線
</p>
<p v-if="mcuStatus" class="text-xs text-slate-600 dark:text-gray-400">{{ mcuStatus }}</p>
</div>
<div class="space-y-2">
<div v-for="(stage, idx) in cascadeStages" :key="stage.id"
:data-cascade-stage-index="idx"
:style="cascadeStageDragStyle(idx)"
:class="{
'stage-dragging': draggingCascadeStageIndex === idx,
'stage-drag-target': dragOverCascadeStageIndex === idx && draggingCascadeStageIndex !== null && draggingCascadeStageIndex !== idx,
}"
class="rounded-lg border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 overflow-hidden transition-all">
<div class="flex items-center gap-2 px-2 py-2">
<button type="button" @pointerdown.stop.prevent="startCascadeStageDrag(idx, $event)"
title="拖曳排序"
class="stage-drag-handle px-2 py-1 rounded bg-slate-200 dark:bg-gray-700 text-slate-700 dark:text-gray-300 text-xs touch-none select-none cursor-grab active:cursor-grabbing">
</button>
<span @click.stop="toggleCascadeStage(idx)"
class="h-[2.25rem] rounded text-[11px] font-bold transition-all duration-200 border cursor-pointer inline-flex items-center justify-center w-16 flex-shrink-0 select-none"
:class="stage.isActive
? 'z-10'
: 'bg-slate-200 dark:bg-gray-800 text-slate-400 dark:text-gray-500 border-slate-300 dark:border-gray-700 shadow-inner'"
:style="stage.isActive ? 'color: #052e16 !important; background-color: #4ade80 !important; border-color: #86efac !important;' : ''"
:title="stage.isActive ? '設為 Bypass' : '設為 Active'">
{{ stage.isActive ? 'Active' : 'Bypass' }}
</span>
<button type="button" @click="selectCascadeStage(idx)"
:class="idx === activeCascadeStageIndex ? 'role-bg-primary role-text-on-fill' : 'text-slate-700 dark:text-gray-300'"
class="flex-1 min-w-0 px-3 py-1.5 rounded text-left text-xs font-semibold transition-colors">
<span class="block truncate">{{ idx + 1 }}. {{ stageTitle(idx) }}</span>
</button>
<button type="button" @click="toggleCascadeStageExpanded(idx)"
class="px-2 py-1 rounded bg-slate-200 dark:bg-gray-700 text-slate-700 dark:text-gray-300 text-xs">
{{ stage.isExpanded ? '收合' : '展開' }}
</button>
</div>
<div v-show="stage.isExpanded && draggingCascadeStageIndex === null" class="px-3 pb-3 text-xs text-slate-600 dark:text-gray-400 border-t border-slate-100 dark:border-gray-700">
<div class="flex items-center justify-between gap-2 pt-2">
<span class="truncate">{{ cascadeStageSummary(stage) }}</span>
</div>
<div class="flex flex-wrap items-center gap-2 pt-2">
<button v-if="idx === activeCascadeStageIndex" type="button" @click="writeToMCU"
:disabled="writingMCU || !mcuConnected"
class="px-3 py-1 rounded-full role-bg-primary role-hover-primary role-text-on-fill disabled:opacity-50 disabled:cursor-not-allowed">
{{ writingMCU ? '寫入中' : `寫入 Stage ${idx + 1}` }}
</button>
<button v-if="idx === activeCascadeStageIndex" type="button" @click="resetFilterParams"
class="px-3 py-1 rounded-full bg-slate-200 dark:bg-gray-700 text-slate-700 dark:text-gray-300">
重設
</button>
<button type="button" @click="removeCascadeStage(idx)" :disabled="cascadeStages.length <= 1"
class="px-3 py-1 rounded-full bg-slate-200 dark:bg-gray-700 text-slate-700 dark:text-gray-300 disabled:opacity-50 disabled:cursor-not-allowed">
刪除
</button>
</div>
<StageEditor v-if="idx === activeCascadeStageIndex" :stage="stage" :stage-index="idx"
class="mt-3 pt-3 border-t border-slate-100 dark:border-gray-700">
<select v-model="filterType" @change="onFilterTypeChange"
class="w-full bg-slate-50 dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-base role-focus-primary focus:ring-1 role-focus-ring-primary outline-none transition-all mb-3 text-slate-900 dark:text-gray-100 truncate">
<option v-for="opt in filterOptions" :key="opt" :value="opt">{{ opt }}</option>
@@ -110,7 +190,7 @@
<!-- 動態參數區 -->
<div v-if="filterType !== '(無) 手動自訂'"
class="space-y-3 bg-slate-100 dark:bg-gray-900 p-4 rounded-lg border border-slate-200 dark:border-gray-800 shadow-inner">
class="stage-design-params space-y-3 mb-4">
<!-- Lowpass / Highpass -->
<div v-if="['Lowpass (低通)', 'Highpass (高通)'].includes(filterType)">
<label class="text-sm text-slate-600 dark:text-gray-400 flex justify-between mb-1"><span>截止頻率
@@ -241,11 +321,10 @@
</div>
</div>
</div>
</section>
<hr class="border-slate-200 dark:border-gray-800">
<hr class="my-4 border-slate-200 dark:border-gray-800">
<!-- 系統參數 -->
<!-- Stage 增益 -->
<section>
<h2 class="text-sm font-semibold text-slate-600 dark:text-gray-400 uppercase tracking-wider mb-3 flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -255,16 +334,8 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
系統參數</h2>
Stage 增益</h2>
<div class="space-y-3">
<div>
<label class="text-sm text-slate-600 dark:text-gray-400 flex justify-between mb-1"><span>取樣頻率
fs
(Hz)</span><span class="role-text-primary">{{ formatK(fs)
}}</span></label>
<input type="number" v-model.number="fs" @input="debouncedApply" @keydown="onlyNumberKey"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-base role-focus-primary outline-none transition-all text-slate-900 dark:text-gray-100">
</div>
<div>
<label class="text-sm text-slate-600 dark:text-gray-400 flex justify-between mb-1"><span>系統增益
K (System Gain)</span><span class="role-text-primary">{{ systemGain
@@ -276,7 +347,7 @@
</div>
</section>
<hr class="border-slate-200 dark:border-gray-800">
<hr class="my-4 border-slate-200 dark:border-gray-800">
<!-- 係數手動設定 -->
<section class="coeff-section">
@@ -477,7 +548,7 @@
</details>
</section>
<hr class="border-slate-200 dark:border-gray-800">
<hr class="my-4 border-slate-200 dark:border-gray-800">
<!-- 定點數轉換 -->
<section class="fixed-section">
@@ -660,13 +731,19 @@
</div>
<p class="text-[10px] text-slate-500 dark:text-gray-500 mt-3 italic">b 係數乘以 2^{{ shiftBitsB }}a
係數乘以 2^{{ shiftBitsA }} 後四捨五入可用於定點數 DSP 實作</p>
</section>
</StageEditor>
</div>
</div>
</div>
</div>
</section>
</aside>
<!-- 右側主視窗 (RWD: 手機版=圖表頁籤全螢幕桌面版=右側主區) -->
<main :class="mobileTab === 'chart' ? 'flex' : 'hidden'"
class="lg:flex flex-1 flex-col overflow-y-auto bg-slate-50 dark:bg-[#0f0f0f] lg:p-6 gap-0 lg:gap-6 custom-scrollbar relative transition-colors duration-300">
class="lg:flex flex-1 flex-col overflow-y-auto bg-slate-50 dark:bg-[#0f0f0f] lg:py-6 lg:px-20 gap-0 lg:gap-6 custom-scrollbar relative transition-colors duration-300">
<div v-if="globalError"
class="absolute top-4 right-6 role-bg-error-soft border role-border-error role-text-error role-text-on-fill px-4 py-3 rounded-lg shadow-lg text-base z-50 flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -691,7 +768,7 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 19V5m0 14h16M7 16l3-4 3 2 4-7"></path>
</svg>
頻率響應 (Frequency Response)</h3>
串聯濾波器總頻率響應 (Cascade Bode Plot)</h3>
<div v-if="loadingBode"
class="text-sm role-text-primary role-bg-primary-soft px-3 py-1.5 rounded border role-border-primary-soft flex items-center gap-2">
<span class="w-2 h-2 rounded-full role-dot-primary animate-ping"></span>
@@ -709,17 +786,44 @@
<div
class="chart-panel bg-white dark:bg-dark lg:rounded-xl border-b lg:border border-slate-100 lg:border-slate-200 dark:border-gray-100 lg:dark:border-gray-800 lg:shadow-md p-0 lg:p-1 flex-shrink-0 relative transition-colors duration-300">
<div
class="chart-panel-header px-5 py-4 border-b border-slate-100 dark:border-gray-800 bg-white dark:bg-dark backdrop-blur-sm z-10 relative">
class="chart-panel-header px-5 py-4 border-b border-slate-100 dark:border-gray-800 bg-white dark:bg-dark backdrop-blur-sm z-10 relative flex justify-between items-center">
<h3 class="font-bold text-slate-800 dark:text-gray-200 tracking-wide flex items-center gap-2">
<svg class="w-5 h-5 role-text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 12h4l2-6 4 12 2-6h4"></path>
</svg>
時域訊號探討</h3>
級聯時域訊號模擬 (Multi-Stage Time-Domain Simulation)</h3>
</div>
<div class="p-6 z-10 relative bg-white dark:bg-dark transition-colors duration-300">
<p class="text-base text-slate-600 dark:text-gray-400 mb-4">上傳包含時域訊號的 CSV 檔案系統會使用上述設定的差分濾波器
<p class="text-base text-slate-600 dark:text-gray-400 mb-4">上傳時域輸入訊號的 CSV 檔案訊號會依序通過所有啟用的 cascade stages並比較原始輸入Ideal Float Mimic Integer 輸出
</p>
<div v-if="csvPreview.length > 0"
class="csv-preview-panel mb-6 rounded-lg border border-slate-100 dark:border-gray-800 bg-slate-50 dark:bg-gray-900/50 overflow-hidden">
<button type="button" @click="isCsvPreviewExpanded = !isCsvPreviewExpanded"
class="csv-preview-toggle w-full flex items-center justify-between gap-3 px-4 py-3 text-left text-sm font-semibold text-slate-700 dark:text-gray-300 bg-transparent hover:bg-slate-100 dark:hover:bg-gray-800 transition-colors">
<span class="flex items-center gap-2">
<svg class="w-4 h-4 role-text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16"></path>
</svg>
CSV Preview
</span>
<span class="text-xs text-slate-500 dark:text-gray-400">{{ isCsvPreviewExpanded ? '收合' : '展開' }}</span>
</button>
<div v-show="isCsvPreviewExpanded" class="overflow-x-auto border-t border-slate-100 dark:border-gray-800">
<table class="min-w-full text-xs text-left">
<thead class="bg-white dark:bg-gray-800 text-slate-500 dark:text-gray-400">
<tr>
<th v-for="(col, idx) in csvColumns" :key="idx" class="px-3 py-2 font-semibold whitespace-nowrap">{{ col }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-gray-800 text-slate-700 dark:text-gray-300 font-mono">
<tr v-for="(row, rowIdx) in csvPreview" :key="rowIdx">
<td v-for="(cell, cellIdx) in row" :key="cellIdx" class="px-3 py-2 whitespace-nowrap">{{ cell }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div
class="flex items-center flex-wrap gap-4 mb-6 p-4 bg-white dark:bg-gray-800 rounded-lg border border-slate-100 dark:border-gray-700 shadow-sm">
<label
@@ -732,11 +836,15 @@
<input type="file" ref="fileInput" accept=".csv" @change="handleFileUpload"
class="hidden">
</label>
<span v-if="csvFile" class="text-base role-text-primary font-mono">{{
csvFile.name }}</span>
<span v-if="csvFile || usingPresetCsv" class="text-base role-text-primary font-mono">{{
csvFile ? csvFile.name : presetCsvName }}</span>
<span v-if="csvInfo"
class="text-sm text-slate-600 dark:text-gray-400 font-mono">
{{ csvInfo.rows }} / {{ csvInfo.columns }}
{{ csvInfo.rowsLabel || csvInfo.rows }} / {{ csvInfo.columns }}
</span>
<span v-if="csvUploading"
class="text-sm role-text-primary font-semibold">
上傳中...
</span>
<span v-if="csvParseError"
class="text-sm role-text-error font-semibold">
@@ -754,19 +862,15 @@
</select>
</div>
<button v-if="csvFile" @click="processFilter"
<button v-if="csvFile || usingPresetCsv" @click="processFilter"
class="role-bg-primary role-hover-primary role-text-on-fill px-6 py-3 rounded-lg text-base font-semibold transition-all shadow-md role-shadow-primary disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="loadingFilter || csvColumns.length === 0">
{{ loadingFilter ? '處理中...' : '執行當前濾波器' }}
:disabled="csvUploading || loadingFilter || csvColumns.length === 0">
{{ csvUploading ? '上傳中...' : (loadingFilter ? '處理中...' : '執行當前濾波器') }}
</button>
<button v-if="filterDone" @click="downloadCsv"
class="ml-auto bg-slate-700 hover:bg-slate-600 role-text-on-fill px-5 py-3 rounded-lg text-base font-semibold transition-all flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
匯出結果
<button v-if="csvFile && presetCsvAvailable" @click="restorePresetWaveforms"
class="bg-slate-200 dark:bg-gray-700 hover:bg-slate-300 dark:hover:bg-gray-600 text-slate-700 dark:text-gray-200 px-4 py-3 rounded-lg text-base font-semibold transition-all">
使用 preset
</button>
</div>
@@ -780,7 +884,7 @@
d="M9 7h6M9 12h6M9 17h3M5 5a2 2 0 012-2h7l5 5v11a2 2 0 01-2 2H7a2 2 0 01-2-2V5z">
</path>
</svg>
定點數模擬設定 (Fixed-Point Simulation Settings)
級聯量化與捨去設定 (Cascade Simulation Settings)
</h4>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 lg:gap-6">
<div>
@@ -852,12 +956,23 @@
</div>
<!-- 波形比較圖 -->
<h4 v-if="filterDone" class="text-base font-bold text-slate-800 dark:text-gray-200 mb-3">波形比較圖
</h4>
<!-- 只有圖表繪圖區可水平滾動卡片外框不動 -->
<div class="overflow-x-auto bg-white dark:bg-dark">
<div id="timePlot" class="min-w-[580px] w-full h-[400px] bg-white dark:bg-dark"
v-show="filterDone"></div>
<h4 v-if="filterDone" class="text-base font-bold text-slate-800 dark:text-gray-200 mb-3">波形比較圖</h4>
<div class="relative" v-if="filterDone">
<!-- 只有圖表繪圖區可水平滾動卡片外框不動 -->
<div class="overflow-x-auto bg-white dark:bg-dark rounded-lg">
<div id="timePlot" class="min-w-[580px] w-full h-[400px] bg-white dark:bg-dark"></div>
</div>
<!-- 匯出 CSV 按鈕 -->
<button @click="downloadCsv"
class="absolute bottom-4 right-4 z-20 bg-slate-700 hover:bg-slate-600 active:scale-95 text-white px-4 py-2 rounded-lg text-sm font-semibold transition-all flex items-center gap-2 shadow-md border border-slate-600/50"
title="匯出 CSV">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
匯出 CSV
</button>
</div>
<div v-if="!filterDone && !loadingFilter && csvPreview.length === 0"
@@ -868,7 +983,7 @@
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z">
</path>
</svg>
<p>請上傳包含時間序列的 CSV 檔案進行測試</p>
<p>請上傳包含時間序列的 CSV 檔案以測試時域級聯濾波效果</p>
</div>
</div>
</div>
@@ -876,8 +991,27 @@
</div>
<!-- 全域置底版權宣告 -->
<footer
class="bg-white dark:bg-dark border-t border-slate-200 dark:border-gray-800 py-2 text-center z-20 transition-colors duration-300">
<p class="text-xs text-slate-500 dark:text-gray-600">© 2026 喆富創新科技股份有限公司. All rights reserved.</p>
class="bg-white dark:bg-dark border-t border-slate-200 dark:border-gray-800 py-2 px-4 z-20 transition-colors duration-300 flex items-center justify-between relative min-h-[44px]">
<button @click="toggleDarkMode"
class="p-2 rounded-lg bg-slate-100 hover:bg-slate-200 dark:bg-gray-800 dark:hover:bg-gray-700 text-slate-700 dark:text-gray-200 transition-colors shadow-sm border border-slate-200/50 dark:border-gray-700/50 flex items-center gap-1.5"
:title="isDarkMode ? '切換淺色模式' : '切換深色模式'">
<svg v-if="isDarkMode" class="w-4 h-4 text-yellow-500" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z" />
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-13a1 1 0 10-2 0 1 1 0 002 0zM2 10a1 1 0 011-1h1a1 1 0 110 2H3a1 1 0 01-1-1zm14 0a1 1 0 011-1h1a1 1 0 110 2h-1a1 1 0 01-1-1zM5.05 5.05a1 1 0 011.414 0l.707.707a1 1 0 11-1.414 1.414l-.707-.707a1 1 0 010-1.414zm9.9 9.9a1 1 0 011.414 0l.707.707a1 1 0 11-1.414 1.414l-.707-.707a1 1 0 010-1.414zM5.05 14.95a1 1 0 010-1.414l.707-.707a1 1 0 011.414 1.414l-.707.707a1 1 0 01-1.414 0zm9.9-9.9a1 1 0 010-1.414l.707-.707a1 1 0 011.414 1.414l-.707.707a1 1 0 01-1.414 0z" clip-rule="evenodd" />
</svg>
<svg v-else class="w-4 h-4 text-slate-700" fill="currentColor" viewBox="0 0 20 20">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
</svg>
<span class="text-xs font-semibold hidden md:inline">{{ isDarkMode ? '切換淺色模式' : '切換深色模式' }}</span>
</button>
<!-- 置中的版權宣告 -->
<p class="text-xs text-slate-500 dark:text-gray-600 absolute left-1/2 transform -translate-x-1/2 pointer-events-none">
© 2026 喆富創新科技股份有限公司. All rights reserved.
</p>
<!-- 右側佔位 -->
<div class="w-[32px] md:w-[120px]"></div>
</footer>
</div>
</template>
+498 -21
View File
@@ -1,3 +1,5 @@
import StageEditor from './components/StageEditor.vue';
import { computeStageFixedCoeffs, createCascadeFilterPayload, normalizeCascadeStage } from './cascade-stage.js';
const DEFAULT_FILTER_TYPE = "Lowpass (低通)";
const MANUAL_FILTER_TYPE = "(無) 手動自訂";
const DEFAULT_FS = 100000.0;
@@ -18,14 +20,32 @@ const DEFAULT_PARAMS = {
const cloneDefaultParams = () => ({ ...DEFAULT_PARAMS });
const zero7 = () => [...ZERO_7];
const createStageId = () => `${Date.now()}-${Math.random().toString(36).slice(2)}`;
export default {
components: {
StageEditor,
},
data() {
return {
fs: DEFAULT_FS,
b_str: DEFAULT_B_STR,
a_str: DEFAULT_A_STR,
filterType: DEFAULT_FILTER_TYPE,
cascadeStages: [],
activeCascadeStageIndex: 0,
draggingCascadeStageIndex: null,
dragOverCascadeStageIndex: null,
stageDragPointerId: null,
draggedCascadeStageId: null,
stageDragStartX: 0,
stageDragStartY: 0,
stageDragCurrentX: 0,
stageDragCurrentY: 0,
zoomStartIdx: null, zoomEndIdx: null,
timePlotUnit: null,
timePlotMultiplier: null,
isRedrawingTimePlot: false,
filterOptions: [
"Lowpass (低通)", "Highpass (高通)", "Bandpass (帶通)",
"Notch (陷波器)", "1P1Z (一極一零)", "2P1Z (二極一零)", "2P2Z (二極二零)",
@@ -44,6 +64,7 @@ export default {
loadingBode: false, bodeTimeout: null, globalError: null,
csvFile: null, csvFileId: null, csvColumns: [], csvPreview: [], csvInfo: null, csvParseError: null,
csvUploading: false, presetCsvAvailable: false, presetCsvName: '', usingPresetCsv: false,
isCsvPreviewExpanded: true,
selectedColumn: 0, loadingFilter: false, filterDone: false, timePlotData: null,
mobileTab: 'settings', // 手機版頁籤:'settings' | 'chart'
@@ -81,6 +102,7 @@ export default {
writingMCU: false,
mcuStatus: '',
saveSettingsTimeout: null,
isSidebarCollapsed: localStorage.getItem('dea_sidebar_collapsed') === 'true',
bodeMagRange: null, // [min, max] for Y-axis
}
},
@@ -265,7 +287,16 @@ export default {
aFineStep: 'debouncedSaveSettings',
fixedAFineStep: 'debouncedSaveSettings',
b_int_str() { this.debouncedProcessFilter(); },
a_int_str() { this.debouncedProcessFilter(); }
a_int_str() { this.debouncedProcessFilter(); },
selectedColumn(newVal, oldVal) {
if (newVal !== oldVal) {
this.zoomStartIdx = null;
this.zoomEndIdx = null;
this.timePlotUnit = null;
this.timePlotMultiplier = null;
this.debouncedProcessFilter();
}
}
},
mounted() {
this.isTouchInput = window.matchMedia?.('(pointer: coarse)').matches || false;
@@ -285,8 +316,10 @@ export default {
if (!this.webSerialSupported) {
this.mcuStatus = '此瀏覽器不支援 Web Serial(需使用 Chrome 或 Edge,且透過 HTTPS 連線)';
}
this.ensureCascadeStages();
this.updateBodeMagRange();
this.applyFilterDesign();
this.loadPresetWaveforms();
},
beforeUnmount() {
window.removeEventListener('pointerdown', this.rememberPointerType);
@@ -302,6 +335,325 @@ export default {
this.stopShiftOutDrag();
},
methods: {
toggleSidebar() {
this.isSidebarCollapsed = !this.isSidebarCollapsed;
localStorage.setItem('dea_sidebar_collapsed', this.isSidebarCollapsed ? 'true' : 'false');
const startTime = Date.now();
const interval = setInterval(() => {
window.dispatchEvent(new Event('resize'));
if (Date.now() - startTime > 400) {
clearInterval(interval);
window.dispatchEvent(new Event('resize'));
this.resizePlots();
}
}, 30);
},
resizePlots() {
const bode = document.getElementById('bodePlot');
const time = document.getElementById('timePlot');
if (bode && window.Plotly) window.Plotly.Plots.resize(bode);
if (time && window.Plotly) window.Plotly.Plots.resize(time);
},
stageTitle(index) {
const stage = this.cascadeStages[index];
return stage?.filterType || `Stage ${index + 1}`;
},
cloneStage(stage) {
return JSON.parse(JSON.stringify(stage));
},
captureCurrentStage() {
return {
id: this.cascadeStages[this.activeCascadeStageIndex]?.id || createStageId(),
isActive: this.cascadeStages[this.activeCascadeStageIndex]?.isActive ?? true,
isExpanded: this.cascadeStages[this.activeCascadeStageIndex]?.isExpanded ?? true,
filterType: this.filterType,
b_str: this.b_str,
a_str: this.a_str,
systemGain: this.systemGain,
params: { ...this.params },
baseB: [...this.baseB],
baseA: [...this.baseA],
bSliders: [...this.bSliders],
aSliders: [...this.aSliders],
shiftBitsB: this.shiftBitsB,
shiftBitsA: this.shiftBitsA,
fixedOverrides: {
a: { ...this.fixedOverrides.a },
b: { ...this.fixedOverrides.b },
},
outOfRangeB: [...this.outOfRangeB],
outOfRangeA: [...this.outOfRangeA],
aFineStep: this.aFineStep,
fixedAFineStep: this.fixedAFineStep,
activeCoeffAdjustment: this.activeCoeffAdjustment,
};
},
applyStageToControls(stage) {
stage = normalizeCascadeStage(stage);
this.filterType = stage.filterType;
this.b_str = stage.b_str;
this.a_str = stage.a_str;
this.systemGain = stage.systemGain;
this.params = { ...stage.params };
this.baseB = [...stage.baseB];
this.baseA = [...stage.baseA];
this.bSliders = [...stage.bSliders];
this.aSliders = [...stage.aSliders];
this.shiftBitsB = stage.shiftBitsB;
this.shiftBitsA = stage.shiftBitsA;
this.fixedOverrides = {
a: { ...stage.fixedOverrides.a },
b: { ...stage.fixedOverrides.b },
};
this.outOfRangeB = [...stage.outOfRangeB];
this.outOfRangeA = [...stage.outOfRangeA];
this.aFineStep = stage.aFineStep;
this.fixedAFineStep = stage.fixedAFineStep;
this.activeCoeffAdjustment = stage.activeCoeffAdjustment;
},
ensureCascadeStages() {
if (!Array.isArray(this.cascadeStages)) this.cascadeStages = [];
if (this.cascadeStages.length === 0) {
this.cascadeStages = [this.captureCurrentStage()];
this.activeCascadeStageIndex = 0;
}
this.cascadeStages = this.cascadeStages.map((stage, index) => normalizeCascadeStage(stage, {
isExpanded: index === this.activeCascadeStageIndex,
filterType: this.filterType,
b_str: this.b_str,
a_str: this.a_str,
systemGain: this.systemGain,
params: this.params,
baseB: this.baseB,
baseA: this.baseA,
bSliders: this.bSliders,
aSliders: this.aSliders,
shiftBitsB: this.shiftBitsB,
shiftBitsA: this.shiftBitsA,
fixedOverrides: this.fixedOverrides,
outOfRangeB: this.outOfRangeB,
outOfRangeA: this.outOfRangeA,
aFineStep: this.aFineStep,
fixedAFineStep: this.fixedAFineStep,
activeCoeffAdjustment: this.activeCoeffAdjustment,
}));
if (this.activeCascadeStageIndex < 0 || this.activeCascadeStageIndex >= this.cascadeStages.length) {
this.activeCascadeStageIndex = 0;
}
},
saveActiveCascadeStage() {
this.ensureCascadeStages();
this.cascadeStages.splice(this.activeCascadeStageIndex, 1, this.captureCurrentStage());
},
selectCascadeStage(index) {
if (index < 0 || index >= this.cascadeStages.length || index === this.activeCascadeStageIndex) return;
this.saveActiveCascadeStage();
this.activeCascadeStageIndex = index;
this.cascadeStages.forEach((stage, stageIndex) => {
stage.isExpanded = stageIndex === index;
});
this.applyStageToControls(this.cloneStage(this.cascadeStages[index]));
this.updateBodeMagRange();
this.updateBodePlot({ switchToChart: false });
},
addCascadeStage() {
this.saveActiveCascadeStage();
const newStage = this.cloneStage(this.cascadeStages[this.activeCascadeStageIndex]);
newStage.id = createStageId();
newStage.isActive = true;
newStage.isExpanded = true;
this.cascadeStages.push(newStage);
this.selectCascadeStage(this.cascadeStages.length - 1);
},
removeCascadeStage(index) {
if (this.cascadeStages.length <= 1) {
this.globalError = '至少需要保留一個濾波器階段';
return;
}
this.saveActiveCascadeStage();
this.cascadeStages.splice(index, 1);
this.activeCascadeStageIndex = Math.min(this.activeCascadeStageIndex, this.cascadeStages.length - 1);
this.cascadeStages[this.activeCascadeStageIndex].isExpanded = true;
this.applyStageToControls(this.cloneStage(this.cascadeStages[this.activeCascadeStageIndex]));
this.updateBodePlot({ switchToChart: false });
},
toggleCascadeStage(index) {
this.saveActiveCascadeStage();
const stage = this.cascadeStages[index];
stage.isActive = !stage.isActive;
this.updateBodePlot({ switchToChart: false });
this.debouncedProcessFilter();
},
toggleCascadeStageExpanded(index) {
if (index < 0 || index >= this.cascadeStages.length) return;
if (index !== this.activeCascadeStageIndex) {
this.selectCascadeStage(index);
return;
}
this.saveActiveCascadeStage();
this.cascadeStages.forEach((stage, stageIndex) => {
stage.isExpanded = stageIndex === index ? !stage.isExpanded : false;
});
},
cascadeStageSummary(stage) {
const bCount = this.parseCoeffs(stage.b_str || '').length;
const aCount = this.parseCoeffs(stage.a_str || '').length;
return `${bCount}b / ${aCount}a, Qb${stage.shiftBitsB}, Qa${stage.shiftBitsA}`;
},
moveCascadeStageUp(index) {
this.moveCascadeStage(index, index - 1);
},
moveCascadeStageDown(index) {
this.moveCascadeStage(index, index + 1);
},
startCascadeStageDrag(index, event) {
if (index < 0 || index >= this.cascadeStages.length) return;
this.saveActiveCascadeStage();
this.draggingCascadeStageIndex = index;
this.dragOverCascadeStageIndex = index;
this.stageDragPointerId = event.pointerId;
this.draggedCascadeStageId = this.cascadeStages[index]?.id || null;
this.stageDragStartX = event.clientX;
this.stageDragStartY = event.clientY;
this.stageDragCurrentX = event.clientX;
this.stageDragCurrentY = event.clientY;
event.currentTarget?.setPointerCapture?.(event.pointerId);
window.addEventListener('pointermove', this.moveCascadeStageDrag, { passive: false });
window.addEventListener('pointerup', this.endCascadeStageDrag);
window.addEventListener('pointercancel', this.cancelCascadeStageDrag);
},
cascadeStageDragStyle(index) {
if (this.draggingCascadeStageIndex !== index) return null;
const dx = this.stageDragCurrentX - this.stageDragStartX;
const dy = this.stageDragCurrentY - this.stageDragStartY;
return {
transform: `translate3d(${dx}px, ${dy}px, 0) scale(1.015)`,
};
},
moveCascadeStageDrag(event) {
if (this.draggingCascadeStageIndex === null) return;
event.preventDefault();
this.stageDragCurrentX = event.clientX;
this.stageDragCurrentY = event.clientY;
const target = document
.elementsFromPoint(event.clientX, event.clientY)
.find(element => {
const stageEl = element.closest?.('[data-cascade-stage-index]');
return stageEl && Number(stageEl.dataset.cascadeStageIndex) !== this.draggingCascadeStageIndex;
})
?.closest?.('[data-cascade-stage-index]');
if (!target) return;
const index = Number(target.dataset.cascadeStageIndex);
if (Number.isInteger(index) && index >= 0 && index < this.cascadeStages.length) {
this.dragOverCascadeStageIndex = index;
this.reorderCascadeStageDuringDrag(index);
}
},
reorderCascadeStageDuringDrag(toIndex) {
const fromIndex = this.draggingCascadeStageIndex;
if (fromIndex === null || fromIndex === toIndex || !this.draggedCascadeStageId) return;
const draggedEl = document.querySelector(`[data-cascade-stage-index="${fromIndex}"]`);
const beforeRect = draggedEl?.getBoundingClientRect();
const activeStageId = this.cascadeStages[this.activeCascadeStageIndex]?.id;
const [stage] = this.cascadeStages.splice(fromIndex, 1);
this.cascadeStages.splice(toIndex, 0, stage);
this.draggingCascadeStageIndex = toIndex;
this.dragOverCascadeStageIndex = toIndex;
this.activeCascadeStageIndex = Math.max(0, this.cascadeStages.findIndex(item => item.id === activeStageId));
this.$nextTick(() => {
const nextEl = document.querySelector(`[data-cascade-stage-index="${toIndex}"]`);
const afterRect = nextEl?.getBoundingClientRect();
if (!beforeRect || !afterRect) return;
this.stageDragStartX += afterRect.left - beforeRect.left;
this.stageDragStartY += afterRect.top - beforeRect.top;
});
},
endCascadeStageDrag() {
const activeStageId = this.cascadeStages[this.activeCascadeStageIndex]?.id;
this.clearCascadeStageDrag();
this.collapseCascadeStages({ skipSave: true });
this.activeCascadeStageIndex = Math.max(0, this.cascadeStages.findIndex(item => item.id === activeStageId));
this.applyStageToControls(this.cloneStage(this.cascadeStages[this.activeCascadeStageIndex]));
this.updateBodePlot({ switchToChart: false });
this.debouncedProcessFilter();
},
cancelCascadeStageDrag() {
this.clearCascadeStageDrag();
this.collapseCascadeStages();
},
clearCascadeStageDrag() {
window.removeEventListener('pointermove', this.moveCascadeStageDrag);
window.removeEventListener('pointerup', this.endCascadeStageDrag);
window.removeEventListener('pointercancel', this.cancelCascadeStageDrag);
this.draggingCascadeStageIndex = null;
this.dragOverCascadeStageIndex = null;
this.stageDragPointerId = null;
this.draggedCascadeStageId = null;
this.stageDragStartX = 0;
this.stageDragStartY = 0;
this.stageDragCurrentX = 0;
this.stageDragCurrentY = 0;
},
collapseCascadeStages({ skipSave = false } = {}) {
if (!skipSave) this.saveActiveCascadeStage();
this.cascadeStages.forEach(stage => {
stage.isExpanded = false;
});
},
moveCascadeStage(fromIndex, toIndex, { preserveExpansion = false, collapseAll = false } = {}) {
if (fromIndex < 0 || fromIndex >= this.cascadeStages.length || toIndex < 0 || toIndex >= this.cascadeStages.length) return;
this.saveActiveCascadeStage();
const activeStageId = this.cascadeStages[this.activeCascadeStageIndex]?.id;
const expansionById = new Map(this.cascadeStages.map(stage => [stage.id, !!stage.isExpanded]));
const [stage] = this.cascadeStages.splice(fromIndex, 1);
this.cascadeStages.splice(toIndex, 0, stage);
this.activeCascadeStageIndex = Math.max(0, this.cascadeStages.findIndex(item => item.id === activeStageId));
this.cascadeStages.forEach((item, index) => {
if (collapseAll) {
item.isExpanded = false;
} else {
item.isExpanded = preserveExpansion ? !!expansionById.get(item.id) : index === this.activeCascadeStageIndex;
}
});
this.applyStageToControls(this.cloneStage(this.cascadeStages[this.activeCascadeStageIndex]));
this.updateBodePlot({ switchToChart: false });
this.debouncedProcessFilter();
},
getStageFixedCoeffs(stage) {
return computeStageFixedCoeffs(stage, this.parseCoeffs);
},
getCascadeStagesSnapshot() {
this.saveActiveCascadeStage();
return this.cascadeStages.map(stage => this.cloneStage(stage));
},
getCascadeBodePayload() {
return {
fs: this.fs,
stages: this.getCascadeStagesSnapshot().map(stage => {
const fixed = this.getStageFixedCoeffs(stage);
const scaleB = Math.pow(2, stage.shiftBitsB);
const scaleA = Math.pow(2, stage.shiftBitsA);
return {
b: this.parseCoeffs(stage.b_str),
a: this.parseCoeffs(stage.a_str),
b_fixed: fixed.b.map(v => v / scaleB),
a_fixed: fixed.a.map(v => v / scaleA),
isActive: stage.isActive,
};
}),
};
},
getCascadeFilterPayload() {
return createCascadeFilterPayload({
stages: this.getCascadeStagesSnapshot(),
fs: this.fs,
shiftBitsIn: this.shiftBitsIn,
shiftBitsOut: this.shiftBitsOut,
useRound: this.useRound,
parseCoeffs: this.parseCoeffs,
getStageFixedCoeffs: this.getStageFixedCoeffs,
});
},
loadSettings() {
try {
const saved = localStorage.getItem('dea_settings');
@@ -311,7 +663,7 @@ export default {
'fs', 'b_str', 'a_str', 'filterType', 'systemGain', 'params',
'baseB', 'baseA', 'bSliders', 'aSliders', 'sense_b', 'sense_a',
'shiftBitsB', 'shiftBitsA', 'shiftBitsIn', 'shiftBitsOut', 'useRound',
'fixedOverrides', 'aFineStep', 'fixedAFineStep'
'fixedOverrides', 'aFineStep', 'fixedAFineStep', 'cascadeStages', 'activeCascadeStageIndex'
];
keys.forEach(k => {
if (parsed[k] !== undefined) {
@@ -324,11 +676,12 @@ export default {
}
},
saveSettings() {
this.saveActiveCascadeStage();
const keys = [
'fs', 'b_str', 'a_str', 'filterType', 'systemGain', 'params',
'baseB', 'baseA', 'bSliders', 'aSliders', 'sense_b', 'sense_a',
'shiftBitsB', 'shiftBitsA', 'shiftBitsIn', 'shiftBitsOut', 'useRound',
'fixedOverrides', 'aFineStep', 'fixedAFineStep'
'fixedOverrides', 'aFineStep', 'fixedAFineStep', 'cascadeStages', 'activeCascadeStageIndex'
];
const settings = {};
keys.forEach(k => settings[k] = this[k]);
@@ -1005,13 +1358,11 @@ export default {
return intVal / scaleA;
});
const res = await fetch('/api/bode/compare', {
const cascadePayload = this.getCascadeBodePayload();
const res = await fetch('/api/bode/compare_cascade', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ideal: { b: b_ideal, a: a_ideal, fs: this.fs },
fixed: { b: b_fixed, a: a_fixed, fs: this.fs }
})
body: JSON.stringify(cascadePayload)
});
let data;
@@ -1137,9 +1488,9 @@ export default {
// 手動 Magnitude 圖例 (位於上方圖表下方)
{ xref: 'paper', yref: 'paper', x: 0.35, y: isSmallScreen ? 0.52 : 0.54, text: '一一一', font: { color: isDark ? '#c8cee0' : '#566075', size: 10 }, showarrow: false },
{ xref: 'paper', yref: 'paper', x: 0.42, y: isSmallScreen ? 0.52 : 0.54, text: 'Ideal (Float)', font: { color: textColor, size: 11 }, showarrow: false, xanchor: 'left' },
{ xref: 'paper', yref: 'paper', x: 0.42, y: isSmallScreen ? 0.52 : 0.54, text: 'Ideal Cascade (Float)', font: { color: textColor, size: 11 }, showarrow: false, xanchor: 'left' },
{ xref: 'paper', yref: 'paper', x: 0.58, y: isSmallScreen ? 0.52 : 0.54, text: '· · · ·', font: { color: isDark ? '#b9b2ff' : '#7a6689', size: 14 }, showarrow: false },
{ xref: 'paper', yref: 'paper', x: 0.63, y: isSmallScreen ? 0.52 : 0.54, text: `Fixed (b Q${this.shiftBitsB}, a Q${this.shiftBitsA})`, font: { color: textColor, size: 11 }, showarrow: false, xanchor: 'left' },
{ xref: 'paper', yref: 'paper', x: 0.63, y: isSmallScreen ? 0.52 : 0.54, text: 'Fixed Cascade (Mimic)', font: { color: textColor, size: 11 }, showarrow: false, xanchor: 'left' },
// 手動 Phase 圖例 (位於下方圖表下方)
{ xref: 'paper', yref: 'paper', x: 0.35, y: -0.15, text: '一一一', font: { color: isDark ? '#64d2ff' : '#2d6f8f', size: 10 }, showarrow: false },
@@ -1182,8 +1533,8 @@ export default {
shapes: shapes
};
const traceMagIdeal = { x: freq, y: magIdeal, name: 'Ideal (Float)', type: 'scatter', line: { color: isDark ? '#c8cee0' : '#566075', width: 2.8 } };
const traceMagFixed = { x: freq, y: magFixed, name: `Fixed (b Q${this.shiftBitsB}, a Q${this.shiftBitsA})`, type: 'scatter', line: { color: isDark ? '#b9b2ff' : '#7a6689', width: 2.4, dash: 'dot' } };
const traceMagIdeal = { x: freq, y: magIdeal, name: 'Ideal Cascade (Float)', type: 'scatter', line: { color: isDark ? '#c8cee0' : '#566075', width: 2.8 } };
const traceMagFixed = { x: freq, y: magFixed, name: 'Fixed Cascade (Mimic)', type: 'scatter', line: { color: isDark ? '#b9b2ff' : '#7a6689', width: 2.4, dash: 'dot' } };
const tracePhaseIdeal = { x: freq, y: phaseIdeal, name: 'Ideal Phase', type: 'scatter', line: { color: isDark ? '#64d2ff' : '#2d6f8f', width: 2.8 }, xaxis: 'x2', yaxis: 'y2' };
const tracePhaseFixed = { x: freq, y: phaseFixed, name: 'Fixed Phase', type: 'scatter', line: { color: isDark ? '#ff9bd8' : '#96527b', width: 2.4, dash: 'dot' }, xaxis: 'x2', yaxis: 'y2' };
@@ -1242,10 +1593,56 @@ export default {
this.csvPreview = [];
this.csvInfo = null;
this.csvParseError = null;
this.csvUploading = false;
this.usingPresetCsv = false;
this.isCsvPreviewExpanded = true;
this.selectedColumn = 0;
this.filterDone = false;
this.timePlotData = null;
this.zoomStartIdx = null;
this.zoomEndIdx = null;
this.timePlotUnit = null;
this.timePlotMultiplier = null;
},
async loadPresetWaveforms() {
try {
const res = await fetch('/api/csv/preset');
if (!res.ok) return;
const data = await res.json();
this.presetCsvAvailable = !!data.available;
if (!data.available) return;
this.csvFile = null;
this.csvFileId = data.file_id;
this.csvColumns = data.columns || [];
this.csvPreview = data.preview || [];
this.csvInfo = {
rows: data.rows || 0,
columns: data.columns_count || (data.columns || []).length,
size: data.size || 0,
};
this.csvParseError = null;
this.csvUploading = false;
this.presetCsvName = data.name || 'preset_signals.csv';
this.usingPresetCsv = true;
this.selectedColumn = Number.isInteger(data.default_col_idx) ? data.default_col_idx : 0;
this.filterDone = false;
this.timePlotData = null;
this.zoomStartIdx = null;
this.zoomEndIdx = null;
this.timePlotUnit = null;
this.timePlotMultiplier = null;
this.isCsvPreviewExpanded = true;
this.$nextTick(() => this.processFilter());
} catch (_) {
this.presetCsvAvailable = false;
}
},
restorePresetWaveforms() {
if (this.$refs.fileInput) this.$refs.fileInput.value = '';
this.loadPresetWaveforms();
},
handleFileUpload(event) {
@@ -1266,6 +1663,7 @@ export default {
return;
}
const previewBytes = Math.min(file.size, 1024 * 1024);
const reader = new FileReader();
reader.onload = async (e) => {
try {
@@ -1288,6 +1686,7 @@ export default {
});
this.csvInfo = {
rows: Math.max(totalRows - 1, 0),
rowsLabel: file.size > previewBytes ? `預覽 ${Math.max(rows.length - 1, 0)}+` : `${Math.max(totalRows - 1, 0)}`,
columns: columns.length,
size: file.size,
};
@@ -1300,10 +1699,18 @@ export default {
}
// 背景上傳至暫存區
this.csvUploading = true;
const formData = new FormData();
formData.append('file', file);
const res = await fetch('/api/csv/upload', { method: 'POST', body: formData });
if (!res.ok) { const err = await res.json(); throw new Error(err.detail || '背景上傳失敗'); }
if (!res.ok) {
const errText = await res.text();
let detail = errText || '背景上傳失敗';
try {
detail = JSON.parse(errText).detail || detail;
} catch (_) {}
throw new Error(detail);
}
const data = await res.json();
this.csvFileId = data.file_id;
@@ -1314,13 +1721,16 @@ export default {
this.csvInfo = null;
this.csvFileId = null;
event.target.value = '';
} finally {
this.csvUploading = false;
}
};
reader.onerror = () => {
this.csvParseError = 'CSV 讀取失敗';
this.csvUploading = false;
event.target.value = '';
};
reader.readAsText(file);
reader.readAsText(file.slice(0, previewBytes));
},
debouncedProcessFilter() {
@@ -1332,10 +1742,18 @@ export default {
},
async processFilter() {
if (this.csvUploading) {
this.globalError = "CSV 還在上傳中,請稍候...";
return;
}
if (!this.csvFileId) {
if (this.csvFile) this.globalError = "檔案還在上傳中,請稍候...";
return;
}
if (this.zoomStartIdx === null && this.zoomEndIdx === null) {
this.timePlotUnit = null;
this.timePlotMultiplier = null;
}
this.loadingFilter = true;
this.globalError = null;
const formData = new FormData();
@@ -1350,6 +1768,11 @@ export default {
formData.append('shift_b', this.shiftBitsB);
formData.append('shift_a', this.shiftBitsA);
formData.append('use_round', this.useRound);
formData.append('stages', JSON.stringify(this.getCascadeFilterPayload()));
if (this.zoomStartIdx !== null && this.zoomEndIdx !== null) {
formData.append('start_idx', this.zoomStartIdx);
formData.append('end_idx', this.zoomEndIdx);
}
try {
const res = await fetch('/api/filter', { method: 'POST', body: formData });
if (!res.ok) { const err = await res.json(); throw new Error(err.detail || '濾波處理失敗'); }
@@ -1373,19 +1796,66 @@ export default {
const textColor = isDark ? '#b4b4bd' : '#525663';
const plotBgColor = isDark ? '#090a0d' : '#ffffff';
if (!this.timePlotUnit) {
const totalDurationS = (data.total_points || (data.index.length > 0 ? Math.max(...data.index) : 0)) / this.fs;
if (totalDurationS > 0 && totalDurationS < 1e-3) {
this.timePlotMultiplier = 1e6;
this.timePlotUnit = 'μs';
} else if (totalDurationS > 0 && totalDurationS < 1) {
this.timePlotMultiplier = 1e3;
this.timePlotUnit = 'ms';
} else {
this.timePlotMultiplier = 1;
this.timePlotUnit = 's';
}
}
const timeMultiplier = this.timePlotMultiplier || 1;
const timeUnit = this.timePlotUnit || 's';
const xData = data.index.map(i => (i / this.fs) * timeMultiplier);
const layout = {
paper_bgcolor: plotBgColor, plot_bgcolor: plotBgColor,
uirevision: 'time-domain-lod',
margin: isSmallScreen ? { t: 60, b: 60, l: 45, r: 15 } : { t: 40, b: 55, l: 60, r: 20 },
font: { color: textColor, family: 'Google Sans, Roboto, sans-serif' },
xaxis: { title: { text: 'Sample Index', font: { size: isSmallScreen ? 10 : 11 } }, gridcolor: gridColor, zerolinecolor: zeroLineColor, tickfont: { color: textColor, size: isSmallScreen ? 9 : 11 } },
xaxis: { title: { text: `Time (${timeUnit})`, 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 } }
};
this.isRedrawingTimePlot = true;
Plotly.react('timePlot', [
{ x: data.index, y: data.original, name: '原始輸入 (Input)', type: 'scatter', line: { color: isDark ? '#c8cee0' : '#566075', width: 2.4 }, opacity: 0.5 },
{ x: data.index, y: data.filtered, name: '理想路徑 (Ideal Float)', type: 'scatter', line: { color: isDark ? '#64d2ff' : '#2d6f8f', width: 2.8 }, opacity: 0.95 },
{ x: data.index, y: data.filtered_fixed, name: '定點路徑 (Mimic Integer)', type: 'scatter', line: { color: isDark ? '#ff7c7c' : '#c0392b', width: 2.8, dash: 'dot' }, opacity: 0.95 }
{ x: xData, y: data.original, name: '原始輸入 (Input)', type: 'scatter', line: { color: isDark ? '#c8cee0' : '#566075', width: 2.4 }, opacity: 0.5 },
{ x: xData, y: data.filtered, name: '理想串聯路徑 (Cascade Ideal)', type: 'scatter', line: { color: isDark ? '#64d2ff' : '#2d6f8f', width: 2.8 }, opacity: 0.95 },
{ x: xData, y: data.filtered_fixed, name: '定點串聯路徑 (Cascade Fixed)', type: 'scatter', line: { color: isDark ? '#ff7c7c' : '#c0392b', width: 2.8, dash: 'dot' }, opacity: 0.95 }
], layout, { responsive: true });
this.$nextTick(() => setTimeout(() => { this.isRedrawingTimePlot = false; }, 50));
const gd = document.getElementById('timePlot');
if (gd && !gd._hasRelayoutListener) {
gd.on('plotly_relayout', this.handleTimePlotRelayout);
gd._hasRelayoutListener = true;
}
},
handleTimePlotRelayout(eventData) {
if (this.isRedrawingTimePlot) return;
if (!this.timePlotData || !this.timePlotData.total_points) return;
let startIdx = null;
let endIdx = null;
if (eventData['xaxis.range[0]'] !== undefined && eventData['xaxis.range[1]'] !== undefined) {
const timeMultiplier = this.timePlotMultiplier || 1;
startIdx = Math.max(0, Math.floor((eventData['xaxis.range[0]'] * this.fs) / timeMultiplier));
endIdx = Math.min(this.timePlotData.total_points, Math.ceil((eventData['xaxis.range[1]'] * this.fs) / timeMultiplier));
if (endIdx - startIdx <= 10) return;
} else if (eventData['xaxis.autorange'] === true) {
startIdx = null;
endIdx = null;
} else {
return;
}
this.zoomStartIdx = startIdx;
this.zoomEndIdx = endIdx;
if (this._zoomTimeout) clearTimeout(this._zoomTimeout);
this._zoomTimeout = setTimeout(() => this.processFilter(), 150);
},
async downloadCsv() {
@@ -1402,13 +1872,19 @@ export default {
formData.append('shift_b', this.shiftBitsB);
formData.append('shift_a', this.shiftBitsA);
formData.append('use_round', this.useRound);
formData.append('stages', JSON.stringify(this.getCascadeFilterPayload()));
formData.append('compact', 'true');
if (this.zoomStartIdx !== null && this.zoomEndIdx !== null) {
formData.append('start_idx', this.zoomStartIdx);
formData.append('end_idx', this.zoomEndIdx);
}
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';
a.href = url; a.download = 'cascade_filtered_output.csv';
document.body.appendChild(a); a.click();
window.URL.revokeObjectURL(url);
} catch (e) { this.globalError = e.message; }
@@ -1457,6 +1933,7 @@ export default {
},
async writeToMCU() {
const stageNumber = this.activeCascadeStageIndex + 1;
if (!this.mcuSerialPort || !this.mcuConnected) {
this.mcuStatus = '請先連線 MCU 連接埠';
return;
@@ -1474,10 +1951,10 @@ export default {
} finally {
writer.releaseLock();
}
this.mcuStatus = `已送出 ${command}`;
this.mcuStatus = `已送出 Stage ${stageNumber}: ${command}`;
} catch (e) {
this.globalError = e.message;
this.mcuStatus = `寫入失敗: ${e.message}`;
this.mcuStatus = `Stage ${stageNumber} 寫入失敗: ${e.message}`;
} finally {
this.writingMCU = false;
}
+75
View File
@@ -0,0 +1,75 @@
const DEFAULT_FILTER_TYPE = "Lowpass (低通)";
const DEFAULT_B_STR = "0.5, 0.5, 0.0, 0.0";
const DEFAULT_A_STR = "1.0, 0.0, 0.0, 0.0";
const DEFAULT_SHIFT_BITS = 14;
const ZERO_7 = [0, 0, 0, 0, 0, 0, 0];
const DEFAULT_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,
tp1z_fz: 200.0, tp1z_fp1: 10.0, tp1z_fp2: 5000.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,
};
const cloneDefaultParams = () => ({ ...DEFAULT_PARAMS });
const zero7 = () => [...ZERO_7];
const createStageId = () => `${Date.now()}-${Math.random().toString(36).slice(2)}`;
export const normalizeCascadeStage = (stage = {}, fallback = {}) => ({
id: stage.id ?? fallback.id ?? createStageId(),
isActive: stage.isActive ?? fallback.isActive ?? true,
isExpanded: stage.isExpanded ?? fallback.isExpanded ?? false,
filterType: stage.filterType ?? fallback.filterType ?? DEFAULT_FILTER_TYPE,
b_str: stage.b_str ?? fallback.b_str ?? DEFAULT_B_STR,
a_str: stage.a_str ?? fallback.a_str ?? DEFAULT_A_STR,
systemGain: stage.systemGain ?? fallback.systemGain ?? 1.0,
params: { ...cloneDefaultParams(), ...(fallback.params || {}), ...(stage.params || {}) },
baseB: [...(stage.baseB || fallback.baseB || [0.5, 0.5, 0, 0, 0, 0, 0])],
baseA: [...(stage.baseA || fallback.baseA || [1, 0, 0, 0, 0, 0, 0])],
bSliders: [...(stage.bSliders || fallback.bSliders || zero7())],
aSliders: [...(stage.aSliders || fallback.aSliders || zero7())],
shiftBitsB: stage.shiftBitsB ?? fallback.shiftBitsB ?? DEFAULT_SHIFT_BITS,
shiftBitsA: stage.shiftBitsA ?? fallback.shiftBitsA ?? DEFAULT_SHIFT_BITS,
fixedOverrides: {
a: { ...((fallback.fixedOverrides || {}).a || {}), ...((stage.fixedOverrides || {}).a || {}) },
b: { ...((fallback.fixedOverrides || {}).b || {}), ...((stage.fixedOverrides || {}).b || {}) },
},
outOfRangeB: [...(stage.outOfRangeB || fallback.outOfRangeB || [false, false, false, false, false, false, false])],
outOfRangeA: [...(stage.outOfRangeA || fallback.outOfRangeA || [false, false, false, false, false, false, false])],
aFineStep: stage.aFineStep ?? fallback.aFineStep ?? 0.01,
fixedAFineStep: stage.fixedAFineStep ?? fallback.fixedAFineStep ?? 1,
activeCoeffAdjustment: stage.activeCoeffAdjustment ?? fallback.activeCoeffAdjustment ?? null,
});
export const computeStageFixedCoeffs = (stage, parseCoeffs) => {
const normalized = normalizeCascadeStage(stage);
const scaleB = Math.pow(2, normalized.shiftBitsB);
const scaleA = Math.pow(2, normalized.shiftBitsA);
const bRaw = parseCoeffs(normalized.b_str);
const aRaw = parseCoeffs(normalized.a_str);
return {
b: bRaw.map((x, i) => ((normalized.fixedOverrides.b[i] !== undefined) ? normalized.fixedOverrides.b[i] : Math.round(x * scaleB))),
a: aRaw.map((x, i) => ((normalized.fixedOverrides.a[i] !== undefined) ? normalized.fixedOverrides.a[i] : Math.round(x * scaleA))),
};
};
export const createCascadeFilterPayload = ({ stages, fs, shiftBitsIn, shiftBitsOut, useRound, parseCoeffs, getStageFixedCoeffs }) => (
stages.map((stage, index) => {
const fixed = getStageFixedCoeffs(stage);
return {
b_str: stage.b_str,
a_str: stage.a_str,
b_int_str: fixed.b.join(', '),
a_int_str: fixed.a.join(', '),
shift_in: index === 0 ? shiftBitsIn : stages[index - 1].shiftBitsB,
shift_out: index === stages.length - 1 ? shiftBitsOut : stage.shiftBitsB,
shift_b: stage.shiftBitsB,
shift_a: stage.shiftBitsA,
use_round: useRound,
isActive: stage.isActive,
};
})
);
+21
View File
@@ -0,0 +1,21 @@
<template>
<div class="stage-editor" :data-stage-index="stageIndex" :data-stage-id="stage?.id || ''">
<slot></slot>
</div>
</template>
<script>
export default {
name: 'StageEditor',
props: {
stage: {
type: Object,
default: null,
},
stageIndex: {
type: Number,
default: 0,
},
},
};
</script>
+207 -108
View File
@@ -123,7 +123,7 @@ body {
header,
aside,
footer,
main > div,
main>div,
section,
details,
.js-plotly-plot,
@@ -142,17 +142,17 @@ footer.dark\:bg-dark {
}
header {
min-height: 72px;
min-height: 56px;
padding-inline: clamp(1rem, 2vw, 1.75rem) !important;
}
header img {
background: var(--m3-surface-container-high);
border-radius: 18px !important;
border-radius: 10px !important;
}
header h1 {
font-size: clamp(1.35rem, 1.8vw, 2rem) !important;
font-size: clamp(1.05rem, 1.4vw, 1.5rem) !important;
line-height: 1.15;
}
@@ -227,7 +227,7 @@ details,
}
aside section,
main > div {
main>div {
background: transparent !important;
border-color: transparent !important;
}
@@ -239,20 +239,29 @@ aside section {
@media (min-width: 1024px) {
aside.lg\:w-96 {
width: 28rem !important;
width: 26.25rem !important;
transition: width 0.3s ease-in-out, padding 0.3s ease-in-out, border-color 0.3s ease-in-out, opacity 0.2s ease-in-out !important;
}
aside.lg\:w-96.sidebar-collapsed {
width: 0 !important;
padding: 0 !important;
border-right-color: transparent !important;
overflow: hidden !important;
opacity: 0;
pointer-events: none;
}
}
main > div {
main>div {
border-radius: 30px !important;
overflow: hidden;
}
main > div > div:first-child {
main>div>div:first-child {
border-radius: 30px 30px 0 0;
}
main > div > div:last-child,
main>div>div:last-child,
main .overflow-x-auto:has(#bodePlot),
main .overflow-x-auto:has(#timePlot),
#bodePlot,
@@ -280,7 +289,7 @@ main .overflow-x-auto:has(#timePlot) {
.border-l,
.border-r,
.lg\:border,
.divide-y > :not([hidden]) ~ :not([hidden]) {
.divide-y> :not([hidden])~ :not([hidden]) {
border-color: var(--m3-divider) !important;
}
@@ -724,14 +733,12 @@ label.role-bg-primary {
}
.role-surface-gradient {
background-image: linear-gradient(
135deg,
color-mix(in srgb, var(--m3-primary) 7%, transparent),
color-mix(in srgb, var(--m3-tertiary) 6%, transparent)
) !important;
background-image: linear-gradient(135deg,
color-mix(in srgb, var(--m3-primary) 7%, transparent),
color-mix(in srgb, var(--m3-tertiary) 6%, transparent)) !important;
}
details .grid.grid-cols-\[3\.25rem_1fr_3\.25rem\] > div,
details .grid.grid-cols-\[3\.25rem_1fr_3\.25rem\]>div,
details .grid.grid-cols-\[2rem_1fr_2rem\] input,
details button.touch-none {
background: var(--m3-surface-container-lowest) !important;
@@ -743,8 +750,8 @@ details .grid.grid-cols-\[2rem_1fr_2rem\] button {
color: var(--m3-on-secondary-container) !important;
}
details .grid.grid-cols-\[3\.25rem_1fr_3\.25rem\] > button,
details .grid.grid-cols-\[2rem_1fr_2rem\] > button,
details .grid.grid-cols-\[3\.25rem_1fr_3\.25rem\]>button,
details .grid.grid-cols-\[2rem_1fr_2rem\]>button,
details button.touch-none {
background: var(--m3-secondary-container) !important;
border: 1px solid color-mix(in srgb, var(--m3-secondary) 28%, var(--m3-outline-variant)) !important;
@@ -753,29 +760,29 @@ details button.touch-none {
min-height: 42px;
}
details .grid.grid-cols-\[3\.25rem_1fr_3\.25rem\] > button:hover,
details .grid.grid-cols-\[2rem_1fr_2rem\] > button:hover,
details .grid.grid-cols-\[3\.25rem_1fr_3\.25rem\]>button:hover,
details .grid.grid-cols-\[2rem_1fr_2rem\]>button:hover,
details button.touch-none:hover {
background: color-mix(in srgb, var(--m3-secondary-container) 82%, var(--m3-primary) 18%) !important;
color: var(--m3-on-secondary-container) !important;
}
details .grid.grid-cols-\[3\.25rem_1fr_3\.25rem\] > div,
details .grid.grid-cols-\[3\.25rem_1fr_3\.25rem\]>div,
details .grid.grid-cols-\[2rem_1fr_2rem\] input,
details button.touch-none,
details .grid.grid-cols-2.gap-2 > div {
details .grid.grid-cols-2.gap-2>div {
background: var(--m3-surface-container-lowest) !important;
border-color: var(--m3-soft-outline) !important;
}
details .grid.grid-cols-\[2rem_1fr_2rem\] input,
details button.touch-none span.font-mono,
details .grid.grid-cols-2.gap-2 > div span.font-mono {
details .grid.grid-cols-2.gap-2>div span.font-mono {
color: var(--m3-on-surface) !important;
-webkit-text-fill-color: var(--m3-on-surface);
}
details .grid.grid-cols-\[3\.25rem_1fr_3\.25rem\] > div span.font-mono {
details .grid.grid-cols-\[3\.25rem_1fr_3\.25rem\]>div span.font-mono {
color: var(--m3-on-secondary-container) !important;
-webkit-text-fill-color: var(--m3-on-secondary-container);
}
@@ -785,8 +792,8 @@ details .grid.grid-cols-\[2rem_1fr_2rem\] input:focus {
box-shadow: 0 0 0 3px var(--m3-primary-state) !important;
}
.grid.grid-cols-\[3\.25rem_1fr_3\.25rem\] > button,
.grid.grid-cols-\[2rem_1fr_2rem\] > button,
.grid.grid-cols-\[3\.25rem_1fr_3\.25rem\]>button,
.grid.grid-cols-\[2rem_1fr_2rem\]>button,
button.touch-none {
background: var(--m3-secondary-container) !important;
border: 1px solid color-mix(in srgb, var(--m3-secondary) 28%, var(--m3-outline-variant)) !important;
@@ -798,21 +805,21 @@ button.touch-none {
min-height: 42px;
}
.grid.grid-cols-\[3\.25rem_1fr_3\.25rem\] > button:hover,
.grid.grid-cols-\[2rem_1fr_2rem\] > button:hover,
.grid.grid-cols-\[3\.25rem_1fr_3\.25rem\]>button:hover,
.grid.grid-cols-\[2rem_1fr_2rem\]>button:hover,
button.touch-none:hover {
background: color-mix(in srgb, var(--m3-secondary-container) 82%, var(--m3-primary) 18%) !important;
color: var(--m3-on-secondary-container) !important;
}
.grid.grid-cols-\[2rem_1fr_2rem\] > input,
.grid.grid-cols-\[2rem_1fr_2rem\]>input,
button.touch-none,
.grid.grid-cols-\[3\.25rem_1fr_3\.25rem\] > div {
.grid.grid-cols-\[3\.25rem_1fr_3\.25rem\]>div {
background: var(--m3-surface-container-lowest) !important;
border-color: var(--m3-soft-outline) !important;
}
.grid.grid-cols-\[2rem_1fr_2rem\] > input,
.grid.grid-cols-\[2rem_1fr_2rem\]>input,
button.touch-none span.font-mono,
.grid.grid-cols-\[3\.25rem_1fr_3\.25rem\] span.font-mono,
.grid.grid-cols-2.gap-2 span.font-mono {
@@ -826,13 +833,13 @@ button.touch-none span.font-mono,
font-weight: 400 !important;
}
.grid.grid-cols-\[2rem_1fr_2rem\] > input {
.grid.grid-cols-\[2rem_1fr_2rem\]>input {
text-align: center;
font-size: 0.95rem !important;
min-height: 42px;
}
.grid.grid-cols-\[2rem_1fr_2rem\] > input:focus {
.grid.grid-cols-\[2rem_1fr_2rem\]>input:focus {
border-color: var(--m3-primary) !important;
box-shadow: 0 0 0 3px var(--m3-primary-state) !important;
}
@@ -851,18 +858,18 @@ button.touch-none span.font-mono,
letter-spacing: 0 !important;
}
#app section .flex.flex-wrap.gap-1.mb-2 > div,
#app section .grid.grid-cols-2.gap-2 > div,
#app section .grid.grid-cols-\[3\.25rem_1fr_3\.25rem\] > div,
#app section .flex.flex-wrap.gap-1.mb-2>div,
#app section .grid.grid-cols-2.gap-2>div,
#app section .grid.grid-cols-\[3\.25rem_1fr_3\.25rem\]>div,
#app section button.touch-none {
background: var(--m3-surface-container-lowest) !important;
border-color: color-mix(in srgb, var(--m3-outline) 34%, var(--m3-outline-variant)) !important;
color: var(--m3-on-surface) !important;
}
#app section .flex.flex-wrap.gap-1.mb-2 > div span,
#app section .grid.grid-cols-2.gap-2 > div span,
#app section .grid.grid-cols-\[3\.25rem_1fr_3\.25rem\] > div span,
#app section .flex.flex-wrap.gap-1.mb-2>div span,
#app section .grid.grid-cols-2.gap-2>div span,
#app section .grid.grid-cols-\[3\.25rem_1fr_3\.25rem\]>div span,
#app section button.touch-none span {
color: var(--m3-on-surface) !important;
-webkit-text-fill-color: var(--m3-on-surface);
@@ -871,43 +878,43 @@ button.touch-none span.font-mono,
line-height: 1.25 !important;
}
#app section .flex.flex-wrap.gap-1.mb-2 > div span:first-child,
#app section .grid.grid-cols-2.gap-2 > div span:first-child,
#app section .grid.grid-cols-\[3\.25rem_1fr_3\.25rem\] > div span:first-child,
#app section .flex.flex-wrap.gap-1.mb-2>div span:first-child,
#app section .grid.grid-cols-2.gap-2>div span:first-child,
#app section .grid.grid-cols-\[3\.25rem_1fr_3\.25rem\]>div span:first-child,
#app section button.touch-none span:first-child {
color: var(--m3-on-surface-variant) !important;
-webkit-text-fill-color: var(--m3-on-surface-variant);
font-weight: 400 !important;
}
#app section .flex.flex-wrap.gap-1.mb-2 > div {
#app section .flex.flex-wrap.gap-1.mb-2>div {
gap: 0.3rem !important;
min-height: 1.85rem;
padding: 0.25rem 0.45rem !important;
}
#app section .flex.flex-wrap.gap-1.mb-2 > div span:first-child {
#app section .flex.flex-wrap.gap-1.mb-2>div span:first-child {
font-size: 0.68rem !important;
}
#app section .flex.flex-wrap.gap-1.mb-2 > div span.font-mono {
#app section .flex.flex-wrap.gap-1.mb-2>div span.font-mono {
font-size: 0.78rem !important;
}
#app section .grid.grid-cols-2.gap-2 > div {
#app section .grid.grid-cols-2.gap-2>div {
padding: 0.7rem 0.65rem !important;
}
#app section .grid.grid-cols-2.gap-2 > div span.font-mono {
#app section .grid.grid-cols-2.gap-2>div span.font-mono {
font-size: clamp(0.92rem, 1vw, 1.08rem) !important;
}
#app section .grid.grid-cols-\[3\.25rem_1fr_3\.25rem\] > div span.font-mono,
#app section .grid.grid-cols-\[3\.25rem_1fr_3\.25rem\]>div span.font-mono,
#app section button.touch-none span.font-mono {
font-size: 1rem !important;
}
#app section .grid.grid-cols-\[2rem_1fr_2rem\] > input {
#app section .grid.grid-cols-\[2rem_1fr_2rem\]>input {
color: var(--m3-on-surface) !important;
font-size: 1rem !important;
font-variant-numeric: tabular-nums;
@@ -945,8 +952,8 @@ button.touch-none span.font-mono,
min-width: 0;
}
#app aside label.flex > span:first-child,
#app aside .flex.justify-between > span:first-child {
#app aside label.flex>span:first-child,
#app aside .flex.justify-between>span:first-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
@@ -960,38 +967,38 @@ button.touch-none span.font-mono,
}
#app aside .space-y-3,
#app aside details > div,
#app aside details>div,
#app aside section {
min-width: 0;
}
#app aside .coeff-section > div.mb-3,
#app aside .fixed-section > div.mb-4,
#app aside .fixed-section > .space-y-4 > div {
#app aside .coeff-section>div.mb-3,
#app aside .fixed-section>div.mb-4,
#app aside .fixed-section>.space-y-4>div {
padding: 1rem !important;
}
#app aside .coeff-section > details,
#app aside .coeff-section>details,
#app aside .fixed-section details {
overflow: hidden;
}
#app aside .coeff-section > details > summary,
#app aside .fixed-section details > summary {
#app aside .coeff-section>details>summary,
#app aside .fixed-section details>summary {
background: transparent !important;
border-bottom: 1px solid transparent;
padding: 0.95rem 1rem !important;
}
#app aside .coeff-section > details[open] > summary,
#app aside .fixed-section details[open] > summary {
#app aside .coeff-section>details[open]>summary,
#app aside .fixed-section details[open]>summary {
border-bottom-color: var(--m3-divider);
}
#app aside .coeff-section > details > div,
#app aside .fixed-section details > div {
#app aside .coeff-section>details>div,
#app aside .fixed-section details>div {
padding: 1rem !important;
}
@@ -1007,10 +1014,10 @@ button.touch-none span.font-mono,
.dark #app aside .coeff-section .flex.flex-wrap.gap-1.mb-2 > div,
.dark #app aside .fixed-section .flex.flex-wrap.gap-1.mb-2 > div,
.dark #app aside .grid.grid-cols-2.gap-2 > div,
.dark #app aside .grid.grid-cols-\[3\.25rem_1fr_3\.25rem\] > div,
.dark #app aside .coeff-section .flex.flex-wrap.gap-1.mb-2>div,
.dark #app aside .fixed-section .flex.flex-wrap.gap-1.mb-2>div,
.dark #app aside .grid.grid-cols-2.gap-2>div,
.dark #app aside .grid.grid-cols-\[3\.25rem_1fr_3\.25rem\]>div,
.dark #app aside button.touch-none {
background: #292a2d !important;
border-color: color-mix(in srgb, var(--m3-outline-variant) 78%, transparent) !important;
@@ -1072,43 +1079,43 @@ button.touch-none span.font-mono,
-webkit-text-fill-color: var(--m3-on-surface);
}
#app aside .fixed-section > div.mb-4 {
#app aside .fixed-section>div.mb-4 {
border-radius: 24px 24px 4px 4px !important;
margin: 0 0 2px !important;
overflow: hidden;
}
#app aside .fixed-section > .space-y-4 {
#app aside .fixed-section>.space-y-4 {
display: flex !important;
flex-direction: column;
gap: 0 !important;
margin-top: 0 !important;
}
#app aside .fixed-section > .space-y-4 > :not([hidden]) ~ :not([hidden]) {
#app aside .fixed-section>.space-y-4> :not([hidden])~ :not([hidden]) {
margin-top: 0 !important;
}
#app aside .fixed-section > .space-y-4 > :is(div, details) {
#app aside .fixed-section>.space-y-4> :is(div, details) {
border-radius: 4px !important;
margin: 0 0 2px !important;
overflow: hidden;
}
#app aside .fixed-section > .space-y-4 > :is(div, details):first-child {
#app aside .fixed-section>.space-y-4> :is(div, details):first-child {
border-radius: 4px !important;
}
#app aside .fixed-section > .space-y-4 > :is(div, details):last-child {
#app aside .fixed-section>.space-y-4> :is(div, details):last-child {
border-radius: 4px 4px 24px 24px !important;
margin-bottom: 0 !important;
}
#app aside .fixed-section > .space-y-4 > :is(div, details):first-child:last-child {
#app aside .fixed-section>.space-y-4> :is(div, details):first-child:last-child {
border-radius: 4px 4px 24px 24px !important;
}
#app aside .fixed-section > div.mb-4 + .space-y-4 {
#app aside .fixed-section>div.mb-4+.space-y-4 {
margin-top: 0 !important;
}
@@ -1186,7 +1193,7 @@ button.touch-none span.font-mono,
align-items: center;
}
#app aside .selector-button-group > span {
#app aside .selector-button-group>span {
flex: 0 0 auto;
color: var(--m3-on-surface-variant) !important;
}
@@ -1249,7 +1256,7 @@ button.touch-none span.font-mono,
box-shadow: none !important;
}
#app main .chart-panel > .role-surface-gradient {
#app main .chart-panel>.role-surface-gradient {
border-radius: inherit !important;
}
@@ -1272,14 +1279,14 @@ button.touch-none span.font-mono,
}
@media (max-width: 1023px) {
#app > header + .lg\:hidden {
#app>header+.lg\:hidden {
gap: 0.25rem !important;
padding: 0.35rem 0.75rem 0.45rem !important;
border-bottom: 0 !important;
background: var(--m3-background) !important;
}
#app > header + .lg\:hidden > button {
#app>header+.lg\:hidden>button {
position: relative;
min-height: 2.75rem;
border: 0 !important;
@@ -1289,12 +1296,12 @@ button.touch-none span.font-mono,
color: var(--m3-on-surface-variant) !important;
}
#app > header + .lg\:hidden > button.role-text-primary {
#app>header+.lg\:hidden>button.role-text-primary {
color: var(--m3-primary) !important;
-webkit-text-fill-color: var(--m3-primary);
}
#app > header + .lg\:hidden > button::after {
#app>header+.lg\:hidden>button::after {
content: "";
position: absolute;
left: 50%;
@@ -1308,7 +1315,7 @@ button.touch-none span.font-mono,
transition: opacity 160ms ease, transform 160ms ease;
}
#app > header + .lg\:hidden > button.role-text-primary::after {
#app>header+.lg\:hidden>button.role-text-primary::after {
opacity: 1;
transform: translateX(-50%) scaleX(1);
}
@@ -1323,25 +1330,25 @@ button.touch-none span.font-mono,
}
}
.dark #app aside .fixed-section > div.mb-4 {
.dark #app aside .fixed-section>div.mb-4 {
border-radius: 24px 24px 4px 4px !important;
margin: 0 0 2px !important;
overflow: hidden;
}
.dark #app aside .fixed-section > div.mb-4 + .space-y-4,
.dark #app aside .fixed-section > .space-y-4 {
.dark #app aside .fixed-section>div.mb-4+.space-y-4,
.dark #app aside .fixed-section>.space-y-4 {
display: flex !important;
flex-direction: column;
gap: 0 !important;
margin-top: 0 !important;
}
.dark #app aside .fixed-section > .space-y-4 > :not([hidden]) ~ :not([hidden]) {
.dark #app aside .fixed-section>.space-y-4> :not([hidden])~ :not([hidden]) {
margin-top: 0 !important;
}
.dark #app aside .fixed-section > .space-y-4 > :is(div, details) {
.dark #app aside .fixed-section>.space-y-4> :is(div, details) {
background: var(--m3-surface-container-high) !important;
border: 0 !important;
border-radius: 4px !important;
@@ -1349,20 +1356,20 @@ button.touch-none span.font-mono,
overflow: hidden;
}
.dark #app aside .fixed-section > .space-y-4 > :is(div, details):first-child {
.dark #app aside .fixed-section>.space-y-4> :is(div, details):first-child {
border-radius: 4px !important;
}
.dark #app aside .fixed-section > .space-y-4 > :is(div, details):last-child {
.dark #app aside .fixed-section>.space-y-4> :is(div, details):last-child {
border-radius: 4px 4px 24px 24px !important;
margin-bottom: 0 !important;
}
.dark #app aside .fixed-section > .space-y-4 > :is(div, details):first-child:last-child {
.dark #app aside .fixed-section>.space-y-4> :is(div, details):first-child:last-child {
border-radius: 4px 4px 24px 24px !important;
}
.dark #app aside .fixed-section > .space-y-4 > details.mt-3 {
.dark #app aside .fixed-section>.space-y-4>details.mt-3 {
margin-top: 0 !important;
}
@@ -1373,11 +1380,11 @@ button.touch-none span.font-mono,
border-color: var(--m3-outline-variant) !important;
}
#app aside .coeff-section > div.mb-3,
#app aside .coeff-section > details,
#app aside .fixed-section > div.mb-4,
#app aside .fixed-section > .space-y-4 > :is(div, details),
#app aside section > .space-y-3.bg-slate-100 {
#app aside .coeff-section>div.mb-3,
#app aside .coeff-section>details,
#app aside .fixed-section>div.mb-4,
#app aside .fixed-section>.space-y-4> :is(div, details),
#app aside section>.space-y-3.bg-slate-100 {
background: var(--m3-surface-container-high) !important;
border: 0 !important;
border-radius: 4px !important;
@@ -1386,38 +1393,38 @@ button.touch-none span.font-mono,
overflow: hidden;
}
#app aside section > .space-y-3.bg-slate-100 {
#app aside section>.space-y-3.bg-slate-100 {
border-radius: 24px !important;
}
#app aside .fixed-section > .space-y-4 {
#app aside .fixed-section>.space-y-4 {
display: flex !important;
flex-direction: column;
gap: 0 !important;
margin-top: 0 !important;
}
#app aside .fixed-section > .space-y-4 > :not([hidden]) ~ :not([hidden]) {
#app aside .fixed-section>.space-y-4> :not([hidden])~ :not([hidden]) {
margin-top: 0 !important;
}
#app aside .coeff-section > div.mb-3:first-of-type,
#app aside .fixed-section > div.mb-4 {
#app aside .coeff-section>div.mb-3:first-of-type,
#app aside .fixed-section>div.mb-4 {
border-radius: 24px 24px 4px 4px !important;
}
#app aside .coeff-section > details:last-of-type,
#app aside .fixed-section > .space-y-4 > :is(div, details):last-child {
#app aside .coeff-section>details:last-of-type,
#app aside .fixed-section>.space-y-4> :is(div, details):last-child {
border-radius: 4px 4px 24px 24px !important;
margin-bottom: 0 !important;
}
#app aside .fixed-section > .space-y-4 > :is(div, details):first-child {
#app aside .fixed-section>.space-y-4> :is(div, details):first-child {
border-radius: 4px !important;
}
#app aside .coeff-section > details.mt-3,
#app aside .fixed-section > .space-y-4 > details.mt-3 {
#app aside .coeff-section>details.mt-3,
#app aside .fixed-section>.space-y-4>details.mt-3 {
margin-top: 0 !important;
}
@@ -1451,25 +1458,117 @@ button.touch-none span.font-mono,
border-radius: 999px !important;
}
button.touch-none, details button.touch-none {
button.touch-none,
details button.touch-none {
background: var(--m3-secondary-container) !important;
border: 1px solid color-mix(in srgb, var(--m3-secondary) 28%, var(--m3-outline-variant)) !important;
color: var(--m3-on-secondary-container) !important;
border-radius: 999px !important;
min-height: 42px;
}
button.touch-none:hover, details button.touch-none:hover {
button.touch-none:hover,
details button.touch-none:hover {
background: color-mix(in srgb, var(--m3-secondary-container) 82%, var(--m3-primary) 18%) !important;
color: var(--m3-on-secondary-container) !important;
}
#app .stage-drag-handle {
touch-action: none;
user-select: none;
min-width: 2.25rem;
}
#app .stage-dragging {
opacity: 0.96;
border-color: var(--m3-primary) !important;
box-shadow: var(--m3-elevated-shadow) !important;
position: relative;
z-index: 5;
pointer-events: none;
transition: box-shadow 140ms ease, opacity 140ms ease;
}
#app .stage-drag-target {
border-color: var(--m3-primary) !important;
box-shadow: 0 0 0 2px var(--m3-primary-state) inset !important;
transform: translateY(2px);
transition: transform 140ms ease, box-shadow 140ms ease, border-color 140ms ease;
}
/* Control geometry consistency: keep buttons and input panes visually aligned. */
#app {
--control-radius: 12px;
--control-height-sm: 2.25rem;
}
#app aside input:not([type="range"]),
#app aside select,
#app aside textarea,
#app main input:not([type="range"]),
#app main select,
#app main textarea {
border-radius: var(--control-radius) !important;
}
#app aside input[type="range"],
#app main input[type="range"] {
display: block;
width: calc(100% - 28px) !important;
max-width: calc(100% - 28px);
margin-inline: 14px;
box-sizing: border-box;
}
#app aside button:not(.modebar-btn),
#app main button:not(.modebar-btn),
#app aside [role="button"],
#app main [role="button"],
#app aside label.cursor-pointer,
#app main label.cursor-pointer {
border-radius: var(--control-radius) !important;
min-height: var(--control-height-sm);
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1.1;
}
#app aside .selector-button,
#app aside .stepper-button,
#app main .stepper-button,
#app aside button.touch-none,
#app main button.touch-none {
border-radius: var(--control-radius) !important;
}
#app main button.csv-preview-toggle {
border: 0 !important;
border-radius: 0 !important;
box-shadow: none !important;
background: transparent !important;
}
#app main button.csv-preview-toggle:hover {
background: var(--m3-surface-container-low) !important;
}
.dark #app main button.csv-preview-toggle:hover {
background: var(--m3-surface-container-high) !important;
}
/* Fix Plotly Modebar Layout under Tailwind Preflight */
.modebar-container {
position: absolute !important;
top: 0 !important;
right: 0 !important;
}
.modebar-container svg {
display: inline-block !important;
}
@@ -1486,4 +1585,4 @@ button.touch-none:hover, details button.touch-none:hover {
background: transparent !important;
border-color: transparent !important;
box-shadow: none !important;
}
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+3 -3
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>差分方程式分析 (Difference Equation Analyzer)</title>
<title>級聯差分方程式分析 (Cascade Difference Equation Analyzer)</title>
<script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>
<link rel="icon" type="image/png" href="/ui/logo.png">
<script type="module" crossorigin src="./app.js"></script>
+3 -3
View File
@@ -4,15 +4,15 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>差分方程式分析 (Difference Equation Analyzer)</title>
<title>級聯差分方程式分析 (Cascade Difference Equation Analyzer)</title>
<script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>
<link rel="stylesheet" href="build/app.css">
<link rel="stylesheet" href="build/app.css?v=cascade_ui_low_risk">
<link rel="icon" type="image/png" href="logo.png">
</head>
<body class="bg-slate-50 dark:bg-darker text-slate-900 dark:text-gray-200 min-h-screen transition-colors duration-300">
<div id="dea-root"></div>
<script type="module" src="build/app.js"></script>
<script type="module" src="build/app.js?v=cascade_ui_low_risk"></script>
</body>
</html>
+54
View File
@@ -0,0 +1,54 @@
import assert from 'node:assert/strict';
import { computeStageFixedCoeffs, createCascadeFilterPayload, normalizeCascadeStage } from '../../src/cascade-stage.js';
const legacyStage = normalizeCascadeStage({
id: 'legacy-1',
filterType: 'Lowpass (低通)',
b_str: '1, 0',
a_str: '1',
});
assert.equal(legacyStage.id, 'legacy-1');
assert.equal(legacyStage.isActive, true);
assert.equal(legacyStage.isExpanded, false);
assert.equal(legacyStage.shiftBitsB, 14);
assert.deepEqual(Object.keys(legacyStage.fixedOverrides), ['a', 'b']);
const fixedStage = normalizeCascadeStage({
b_str: '0.5, -0.25',
a_str: '1, -0.5',
shiftBitsB: 4,
shiftBitsA: 5,
fixedOverrides: { b: { 1: -9 }, a: {} },
});
const fixed = computeStageFixedCoeffs(fixedStage, (value) => value.split(',').map(Number));
assert.deepEqual(fixed.b, [8, -9]);
assert.deepEqual(fixed.a, [32, -16]);
const stages = [
normalizeCascadeStage({ id: 'a', b_str: '1', a_str: '1', shiftBitsB: 12, shiftBitsA: 13, isActive: true }),
normalizeCascadeStage({ id: 'b', b_str: '2', a_str: '1', shiftBitsB: 10, shiftBitsA: 11, isActive: false }),
];
const payload = createCascadeFilterPayload({
stages,
fs: 100000,
shiftBitsIn: 9,
shiftBitsOut: 8,
useRound: true,
parseCoeffs: (value) => value.split(',').map(Number),
getStageFixedCoeffs: (stage) => ({
b: stage.b_str.split(',').map(Number),
a: stage.a_str.split(',').map(Number),
}),
});
assert.equal(payload.length, 2);
assert.equal(payload[0].b_str, '1');
assert.equal(payload[1].b_str, '2');
assert.equal(payload[0].shift_in, 9);
assert.equal(payload[0].shift_out, 12);
assert.equal(payload[1].shift_in, 12);
assert.equal(payload[1].shift_out, 8);
assert.equal(payload[1].isActive, false);
assert.equal(payload[0].use_round, true);
+206 -20
View File
@@ -1,11 +1,17 @@
import asyncio
import io
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from fastapi import HTTPException, UploadFile
from fastapi import HTTPException
from fastapi.responses import JSONResponse
import dea.csv_processing as csv_processing
from dea.csv_processing import PRESET_FILE_ID
from dea_api import (
BodeCascadeParams,
BodeCompareParams,
BodeParams,
DesignParams,
@@ -13,13 +19,47 @@ from dea_api import (
add_security_headers,
calculate_bode,
calculate_bode_compare,
calculate_bode_compare_cascade,
design_filter,
filter_csv,
filter_csv_download,
preset_csv,
upload_csv,
write_mcu_command,
)
class InMemoryUpload:
def __init__(self, content, filename):
self.content = content
self.filename = filename
async def read(self, _size=None):
return self.content
class DeaApiTest(unittest.TestCase):
async def upload_and_filter_csv(self, upload, b="1", a="1", col_idx=0, **kwargs):
upload_body = await upload_csv(upload)
self.assertIn("file_id", upload_body)
params = {
"b_int": None,
"a_int": None,
"shift_in": 14,
"shift_out": 14,
"shift_b": 14,
"shift_a": 14,
"use_round": False,
}
params.update(kwargs)
return await filter_csv(
file_id=upload_body["file_id"],
b=b,
a=a,
col_idx=col_idx,
**params,
)
def test_design_returns_normalized_coefficients_for_default_lowpass(self):
body = design_filter(
DesignParams(
@@ -88,6 +128,33 @@ class DeaApiTest(unittest.TestCase):
self.assertEqual(len(body["freq"]), len(body["fixed"]["mag"]))
self.assertEqual(len(body["ideal"]["phase"]), len(body["fixed"]["phase"]))
def test_bode_compare_cascade_combines_active_stages(self):
body = calculate_bode_compare_cascade(
BodeCascadeParams(
fs=1000,
stages=[
{
"b": [1.0],
"a": [1.0],
"b_fixed": [1.0],
"a_fixed": [1.0],
"isActive": True,
},
{
"b": [0.5, 0.5],
"a": [1.0],
"b_fixed": [0.5, 0.5],
"a_fixed": [1.0],
"isActive": True,
},
],
)
)
self.assertEqual(len(body["freq"]), len(body["ideal"]["mag"]))
self.assertEqual(len(body["freq"]), len(body["fixed"]["mag"]))
self.assertEqual(len(body["ideal"]["phase"]), len(body["fixed"]["phase"]))
def test_mcu_write_rejects_invalid_command_format(self):
try:
write_mcu_command(MCUWriteParams(command="hello"))
@@ -98,14 +165,52 @@ class DeaApiTest(unittest.TestCase):
raise AssertionError("Expected invalid MCU command validation to fail")
def test_preset_csv_endpoint_returns_local_ignored_file_metadata(self):
with tempfile.TemporaryDirectory() as tmp:
preset_path = Path(tmp) / "preset_signals.csv"
preset_path.write_text("time,value\n0,1\n1,2\n", encoding="utf-8")
with patch.object(csv_processing, "PRESET_CSV_PATH", str(preset_path)):
body = preset_csv()
self.assertTrue(body["available"])
self.assertEqual(body["file_id"], PRESET_FILE_ID)
self.assertEqual(body["columns"], ["time", "value"])
self.assertEqual(body["default_col_idx"], 1)
self.assertEqual(body["rows"], 2)
def test_filter_can_use_preset_file_id_and_skip_blank_signal_cells(self):
with tempfile.TemporaryDirectory() as tmp:
preset_path = Path(tmp) / "preset_signals.csv"
preset_path.write_text("time,value\n0,1\n1,\n2,3\n", encoding="utf-8")
with patch.object(csv_processing, "PRESET_CSV_PATH", str(preset_path)):
body = asyncio.run(
filter_csv(
file_id=PRESET_FILE_ID,
b="1",
a="1",
col_idx=1,
b_int=None,
a_int=None,
shift_in=14,
shift_out=14,
shift_b=14,
shift_a=14,
use_round=False,
)
)
self.assertEqual(body["total_points"], 2)
self.assertEqual(body["index"], [0, 2])
self.assertEqual(body["original"], [1.0, 3.0])
def test_filter_downsamples_plot_response_for_large_csv(self):
rows = ["value"] + [str(i) for i in range(6001)]
upload = UploadFile(
io.BytesIO(("\n".join(rows) + "\n").encode("utf-8")),
filename="input.csv",
)
upload = InMemoryUpload(("\n".join(rows) + "\n").encode("utf-8"), "input.csv")
body = asyncio.run(filter_csv(file=upload, b="1", a="1", col_idx=0))
body = asyncio.run(self.upload_and_filter_csv(upload, b="1", a="1", col_idx=0))
self.assertEqual(body["total_points"], 6001)
self.assertLessEqual(body["plot_points"], 5000)
@@ -113,10 +218,10 @@ class DeaApiTest(unittest.TestCase):
def test_filter_rejects_non_numeric_signal_column(self):
upload = UploadFile(io.BytesIO(b"value\n1\nbad\n3\n"), filename="input.csv")
upload = InMemoryUpload(b"value\n1\nbad\n3\n", "input.csv")
try:
asyncio.run(filter_csv(file=upload, b="1", a="1", col_idx=0))
asyncio.run(self.upload_and_filter_csv(upload, b="1", a="1", col_idx=0))
except HTTPException as exc:
self.assertEqual(exc.status_code, 400)
self.assertIn("非數值", exc.detail)
@@ -124,22 +229,103 @@ class DeaApiTest(unittest.TestCase):
raise AssertionError("Expected non-numeric column validation to fail")
def test_filter_accepts_quoted_csv_fields_and_filters_selected_column(self):
upload = UploadFile(
io.BytesIO('label,value\n"a,1",1\n"a,2",2\n'.encode("utf-8")),
filename="input.csv",
)
upload = InMemoryUpload('label,value\n"a,1",1\n"a,2",2\n'.encode("utf-8"), "input.csv")
body = asyncio.run(filter_csv(file=upload, b="1", a="1", col_idx=1))
body = asyncio.run(self.upload_and_filter_csv(upload, b="1", a="1", col_idx=1))
self.assertEqual(body["col_name"], "value")
self.assertEqual(body["original"], [1.0, 2.0])
self.assertEqual(body["filtered"], [1.0, 2.0])
def test_filter_preview_accepts_index_window_for_lod_zoom(self):
upload = InMemoryUpload(b"value\n0\n1\n2\n3\n4\n", "input.csv")
body = asyncio.run(
self.upload_and_filter_csv(
upload,
b="1",
a="1",
col_idx=0,
start_idx=1,
end_idx=4,
)
)
self.assertEqual(body["total_points"], 5)
self.assertEqual(body["index"], [1, 2, 3])
self.assertEqual(body["original"], [1.0, 2.0, 3.0])
def test_filter_accepts_cascade_stages_payload(self):
upload = InMemoryUpload(b"value\n1\n2\n3\n", "input.csv")
stages = [
{
"b_str": "1",
"a_str": "1",
"b_int_str": "16384",
"a_int_str": "16384",
"shift_in": 14,
"shift_out": 14,
"shift_b": 14,
"shift_a": 14,
"use_round": False,
"isActive": True,
},
{
"b_str": "1",
"a_str": "1",
"b_int_str": "16384",
"a_int_str": "16384",
"shift_in": 14,
"shift_out": 14,
"shift_b": 14,
"shift_a": 14,
"use_round": False,
"isActive": True,
},
]
body = asyncio.run(
self.upload_and_filter_csv(
upload,
b="1",
a="1",
col_idx=0,
stages=json.dumps(stages),
)
)
self.assertEqual(body["original"], [1.0, 2.0, 3.0])
self.assertEqual(body["filtered"], [1.0, 2.0, 3.0])
self.assertEqual(body["filtered_fixed"], [1.0, 2.0, 3.0])
def test_filter_download_can_export_compact_four_column_csv(self):
async def run_case():
upload = InMemoryUpload(b"value\n1\n2\n", "input.csv")
upload_body = await upload_csv(upload)
response = await filter_csv_download(
file_id=upload_body["file_id"],
b="1",
a="1",
col_idx=0,
fs=1000.0,
compact=True,
)
chunks = []
async for chunk in response.body_iterator:
chunks.append(chunk)
return b"".join(chunk if isinstance(chunk, bytes) else chunk.encode() for chunk in chunks).decode()
csv_text = asyncio.run(run_case())
self.assertTrue(csv_text.startswith("Time (s),value,value_filtered_ideal,value_filtered_fixed\n"))
self.assertIn("0.0,1.0,1.0,1.0", csv_text)
self.assertIn("0.001,2.0,2.0,2.0", csv_text)
def test_filter_rejects_non_csv_filename(self):
upload = UploadFile(io.BytesIO(b"value\n1\n"), filename="input.txt")
upload = InMemoryUpload(b"value\n1\n", "input.txt")
try:
asyncio.run(filter_csv(file=upload, b="1", a="1", col_idx=0))
asyncio.run(upload_csv(upload))
except HTTPException as exc:
self.assertEqual(exc.status_code, 400)
self.assertIn("CSV", exc.detail)
@@ -147,10 +333,10 @@ class DeaApiTest(unittest.TestCase):
raise AssertionError("Expected non-CSV upload validation to fail")
def test_filter_rejects_empty_csv_upload(self):
upload = UploadFile(io.BytesIO(b" \n"), filename="input.csv")
upload = InMemoryUpload(b" \n", "input.csv")
try:
asyncio.run(filter_csv(file=upload, b="1", a="1", col_idx=0))
asyncio.run(upload_csv(upload))
except HTTPException as exc:
self.assertEqual(exc.status_code, 400)
self.assertIn("CSV", exc.detail)
@@ -158,10 +344,10 @@ class DeaApiTest(unittest.TestCase):
raise AssertionError("Expected empty CSV validation to fail")
def test_filter_rejects_infinite_signal_values(self):
upload = UploadFile(io.BytesIO(b"value\n1\ninf\n3\n"), filename="input.csv")
upload = InMemoryUpload(b"value\n1\ninf\n3\n", "input.csv")
try:
asyncio.run(filter_csv(file=upload, b="1", a="1", col_idx=0))
asyncio.run(self.upload_and_filter_csv(upload, b="1", a="1", col_idx=0))
except HTTPException as exc:
self.assertEqual(exc.status_code, 400)
self.assertIn("有限", exc.detail)