Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5bd3043580 | |||
| 5413062eff | |||
| 6766df8f5e | |||
| b8ec600673 | |||
| 63382f6cf5 | |||
| 3edc4702f3 | |||
| 8caa8918d0 | |||
| 0140b0495c | |||
| 9182220c1d | |||
| 075f20dd81 | |||
| 2f392b13c6 |
+11
@@ -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
|
||||
|
||||
@@ -8,10 +8,12 @@ Backend 使用 FastAPI、NumPy、SciPy 與 Pandas;Frontend 使用 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。
|
||||
- 匯出包含理想浮點與定點整數模擬輸出的 CSV;cascade 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
File diff suppressed because it is too large
Load Diff
+21
-4
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 Serial。MCU 寫值功能需使用 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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
})
|
||||
);
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
+3
-3
File diff suppressed because one or more lines are too long
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user