feat: complete fixed-point Rounding simulation, fix Vite proxy, and update documentation
This commit is contained in:
@@ -18,6 +18,10 @@ The backend is responsible for heavy mathematical computations and data processi
|
||||
### 2. Frontend (Vue 3 + Vite + Tailwind)
|
||||
A reactive "Single Page Application" (SPA) approach.
|
||||
|
||||
> ⚠️ **開發 vs 部署 (Development vs Production)**
|
||||
> - **開發模式 (Development)**: 透過 `npm run dev` 啟動 Vite dev server (預設 `http://localhost:5173`)。前端原始碼修改後會自動熱更新 (HMR),**不需要** 手動 build。API 請求透過 `vite.config.js` 的 proxy 設定轉發至後端。
|
||||
> - **生產模式 (Production)**: 透過 `http://localhost:8000/ui/` 存取。FastAPI 直接 serve `static/build/` 裡的 production bundle。**修改 `src/` 原始碼後,必須執行 `npm run build` 才會生效**。忘記 build 會導致前端程式碼與後端不同步,所有新功能看起來都「沒有作用」。
|
||||
|
||||
- **Reactive Logic (`src/app-options.js`)**:
|
||||
- Manages the state of all coefficients.
|
||||
- Implements "Fine-tuning" sliders that allow real-time adjustment of poles/zeros.
|
||||
@@ -37,3 +41,82 @@ A reactive "Single Page Application" (SPA) approach.
|
||||
- **Backend**: FastAPI, NumPy, SciPy, Pandas, Uvicorn.
|
||||
- **Frontend**: Vue 3, Vite, Tailwind CSS, Plotly.js.
|
||||
- **Security**: Restricted to LAN/Loopback; implemented security headers and HTTPS support.
|
||||
|
||||
---
|
||||
|
||||
## 💎 V2 Feature: Parallel Fixed-Point Simulation
|
||||
本分支 (`dev-v2-integretimedomain`) 的核心任務是在時域分析中實作「雙路徑對照系統」:
|
||||
|
||||
### 1. 雙路徑模擬邏輯 (Dual-Path Logic)
|
||||
- **理想路徑 (Float Path)**: 使用 64-bit 浮點數進行運算,作為效能基準。
|
||||
- **硬體模擬路徑 (Integer Path)**:
|
||||
- **輸入量化**: 將原始訊號依 $Q_{in}$ 轉為整數。
|
||||
- **整數運算**: 使用整數係數 ($Q_{coeff}$) 進行差分方程式計算。
|
||||
- **位移校準**: 運算過程中考慮累加器位元數,並依 $Q_{out}$ 進行最終縮放。
|
||||
- **效果**: 模擬真實 DSP 的捨入誤差 (Rounding Error) 與量化雜訊。
|
||||
|
||||
### 2. 資料流更新
|
||||
- **API `/api/filter`**: 接收檔案 ID 與係數參數,返回兩組數列(Float / Fixed)。
|
||||
- **UI 圖表**: 同時繪製兩條曲線,讓開發者直觀判斷量化是否導致不穩定 (Instability) 或顯著失真。
|
||||
|
||||
### 3. 高效能資料處理架構 (High-Performance Data Processing)
|
||||
- **檔案快取與防呆**:
|
||||
- 支援高達 **300 MB** 的 CSV 檔案上傳。
|
||||
- 當檔案被選擇後,立即背景上傳至後端暫存區 (`temp_csv/`),避免重複傳輸。
|
||||
- 嚴格限制時域運算最多處理 **1,200,000 筆** 資料點,保護伺服器不因 O(N) 的純 Python 整數運算而死當。
|
||||
- **精準記憶體讀取**:
|
||||
- 利用 Pandas 的 `usecols` 參數,僅載入使用者選定的單一訊號欄位,極大幅度降低記憶體佔用。
|
||||
- **即時預覽 (Live Preview)**:
|
||||
- 透過前端的 Debounce 機制 (500ms),當使用者點擊 +/- 按鈕微調 Q-format 參數或係數時,系統會自動使用快取的檔案呼叫 API 並重繪圖表,達成無縫且直覺的實驗體驗。
|
||||
|
||||
---
|
||||
|
||||
## 🛠 Firmware Implementation Guidelines (韌體實作指引)
|
||||
|
||||
在將此工具設計出來的濾波器係數移植到 C 語言或 MCU (如 RISC-V) 時,請務必遵守以下實作準則,以確保硬體行為與本系統的模擬結果 100% 一致:
|
||||
|
||||
### 1. 高精度狀態變數架構 (Extended Precision State Variables)
|
||||
為了極小化截斷誤差 (Truncation Error),硬體實作的迴圈請**不要**對前饋 (Feed-forward) 進行位移。
|
||||
- **作法**:讓歷史陣列 `y_history[]` 保存前饋乘加後的高精度格式 ($Q_{in+b}$)。僅在計算回授 (Feedback) 的乘積後,才對回授項進行右移對齊,並在最終輸出給硬體腳位時做最後的右移。這等於讓 IIR 濾波器內部默默保留了額外的 $Q_b$ bits 小數精度。
|
||||
|
||||
### 2. 消除直流偏移 (DC Bias) 的 1-Clock Rounding 演算法秘技
|
||||
傳統的右移 (`>>`) 會造成無條件捨去向下取整 (Floor),這會引入永遠為負的平均誤差 ($-0.5$ LSB),導致濾波器產生直流偏移。必須改用四捨五入 (Rounding)。
|
||||
- **硬體現狀**:標準的 RISC-V 指令集 (RV32I / RV32IMAC) **沒有**硬體的 round off shift 指令 (其 `SRA` 指令是純 Floor)。除非晶片具備 DSP 擴充指令集 ('P' Extension)。
|
||||
- **C 語言實作秘技**:絕對**不要**為了四捨五入去呼叫 C standard library 的 `round()`,這會啟動浮點數運算,吃掉上百個 Clock。請用純整數實作:
|
||||
```c
|
||||
// 完美的 1-clock 整數四捨五入寫法 (Round to nearest):
|
||||
// 編譯器會將 (1 << (shift_bits - 1)) 編譯為常數,只會多消耗一個 ADD 指令。
|
||||
y_out = (acc + (1 << (shift_bits - 1))) >> shift_bits;
|
||||
```
|
||||
這個技巧已實作於本工具前端的「Round (+0.5 補償)」選項中,開啟後可大幅提升信噪比與波形穩定性。
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Development Environment Notes (開發環境注意事項)
|
||||
|
||||
### 1. Vite Proxy 設定
|
||||
`vite.config.js` 中的 proxy target **必須**使用 `http://`(而非 `https://`),因為 uvicorn 預設以 HTTP 模式啟動。若設定為 `https://`,Vite 會嘗試發送 TLS 握手封包到純 HTTP 的 uvicorn,導致所有 API 請求失敗 (502 Bad Gateway),uvicorn 日誌會大量出現 `Invalid HTTP request received.` 警告。
|
||||
|
||||
```js
|
||||
// ✅ 正確
|
||||
proxy: { '/api': { target: 'http://127.0.0.1:8000', changeOrigin: true } }
|
||||
|
||||
// ❌ 錯誤 — 會導致 API 全部失敗
|
||||
proxy: { '/api': { target: 'https://127.0.0.1:8000', secure: false } }
|
||||
```
|
||||
|
||||
### 2. Production Build 流程
|
||||
若使用者透過 `http://localhost:8000/ui/` 存取系統(FastAPI serve 靜態檔),則前端程式碼修改後**必須**執行以下命令:
|
||||
```bash
|
||||
npm run build # 將 src/ 編譯輸出至 static/build/
|
||||
```
|
||||
然後在瀏覽器中按 `Ctrl+Shift+R` (硬重新整理) 以清除快取載入最新版本。
|
||||
|
||||
### 3. 兩種存取方式對照表
|
||||
| 項目 | Vite Dev Server | Production (FastAPI) |
|
||||
|------|----------------|---------------------|
|
||||
| URL | `http://localhost:5173` | `http://localhost:8000/ui/` |
|
||||
| 修改後生效 | 自動 (HMR) | 需 `npm run build` |
|
||||
| 適用場景 | 開發 / 除錯 | 正式使用 / Demo |
|
||||
| API 轉發 | 透過 Vite proxy | 直接打同一 port |
|
||||
|
||||
|
||||
@@ -46,3 +46,42 @@
|
||||
|
||||
---
|
||||
*Last Updated: 2026-05-15 17:47*
|
||||
|
||||
---
|
||||
|
||||
# DSP Bode Plot 專案進度更新 (2026-05-17)
|
||||
|
||||
## 6. 定點數 Rounding 模擬 (V2 Feature)
|
||||
- **Round (+0.5 補償) 功能**:在 `integer_lfilter()` 中實作了硬體級四捨五入模擬,使用 `(acc + (1 << (shift - 1))) >> shift` 取代傳統的 `acc >> shift` (Floor)。
|
||||
- **UI 控制**:於「定點數模擬設定」區塊新增 **Floor (SRA) / Round (+0.5)** 互斥按鈕(與「階數 Order」相同風格),放置於 Q_in / Q_out 同列。預設為 Round。
|
||||
- **API 參數**:`/api/filter` 與 `/api/filter/download` 端點已支援 `use_round` 參數。
|
||||
- **高精度狀態變數架構**:前饋 (Feed-forward) 不右移,保留完整 $Q_{in+b}$ 精度;僅回授 (Feedback) 與最終輸出做位移縮放。
|
||||
|
||||
## 7. Vite Proxy 修正
|
||||
- **Bug 修正**:`vite.config.js` 的 proxy target 從 `https://` 修正為 `http://`,與 uvicorn 的實際 HTTP 協議一致。
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 踩雷紀錄 (Lessons Learned)
|
||||
|
||||
### 🔴 Production Build 未同步 (2026-05-17)
|
||||
**症狀**:修改了 `src/app-options.js` 與 `src/App.vue` 後,UI 按鈕有出現但功能完全不生效(Round/Floor 切換後濾波器輸出完全沒變化)。花了大量時間除錯。
|
||||
|
||||
**根因**:使用者透過 `http://localhost:8000/ui/` 存取系統。FastAPI 直接 serve `static/build/app.js`,而該檔案停留在 5 月 15 日的舊版(距今超過一天)。所有前端修改都只存在於 `src/` 原始碼,從未被 `npm run build` 編譯輸出。
|
||||
|
||||
**解法**:修改 `src/` 後務必執行 `npm run build`,再 `Ctrl+Shift+R` 硬重新整理瀏覽器。
|
||||
|
||||
**預防措施**:
|
||||
1. 開發時優先使用 `http://localhost:5173/` (Vite dev server,自動 HMR)。
|
||||
2. 若使用 port 8000,每次修改前端程式碼後必須 build。
|
||||
3. 詳見 `ARCHITECTURE.md` 的「Development Environment Notes」章節。
|
||||
|
||||
### 🟡 Vite Proxy https/http 不匹配 (2026-05-17)
|
||||
**症狀**:Vite dev server 的 API 轉發全部失敗 (502),uvicorn 日誌大量出現 `Invalid HTTP request received.`。
|
||||
|
||||
**根因**:`vite.config.js` 設定了 `target: 'https://127.0.0.1:8000'`,但 uvicorn 以 HTTP 模式啟動。Vite proxy 嘗試發送 TLS 握手封包到純 HTTP 伺服器。
|
||||
|
||||
**解法**:將 proxy target 改為 `http://127.0.0.1:8000`,移除 `secure: false`。
|
||||
|
||||
---
|
||||
*Last Updated: 2026-05-17 00:43*
|
||||
|
||||
+2
-1
@@ -1,7 +1,8 @@
|
||||
import os
|
||||
|
||||
MAX_COEFFICIENTS = 64
|
||||
MAX_CSV_BYTES = 32 * 1024 * 1024
|
||||
MAX_CSV_BYTES = 300 * 1024 * 1024
|
||||
MAX_ROWS = 1200000
|
||||
MAX_PLOT_POINTS = 5000
|
||||
BODE_POINTS = 500
|
||||
BODE_MAX_MULTIPLIER = 3.162
|
||||
|
||||
+133
-24
@@ -1,22 +1,37 @@
|
||||
import io
|
||||
import os
|
||||
import uuid
|
||||
import re
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from fastapi import HTTPException
|
||||
from scipy import signal
|
||||
import math
|
||||
|
||||
from .config import MAX_CSV_BYTES, MAX_PLOT_POINTS
|
||||
from .config import MAX_CSV_BYTES, MAX_PLOT_POINTS, MAX_ROWS
|
||||
|
||||
TEMP_CSV_DIR = "temp_csv"
|
||||
os.makedirs(TEMP_CSV_DIR, exist_ok=True)
|
||||
|
||||
def get_cached_file_path(file_id):
|
||||
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")
|
||||
if not os.path.exists(file_path):
|
||||
raise HTTPException(status_code=404, detail="找不到快取的 CSV 檔案,請重新上傳")
|
||||
return file_path
|
||||
|
||||
|
||||
def downsample_for_plot(index, original, filtered):
|
||||
def downsample_for_plot(index, original, filtered_float, filtered_fixed):
|
||||
total = len(index)
|
||||
if total <= MAX_PLOT_POINTS:
|
||||
return index, original, filtered, 1
|
||||
return index, original, filtered_float, filtered_fixed, 1
|
||||
step = int(np.ceil(total / MAX_PLOT_POINTS))
|
||||
return index[::step], original[::step], filtered[::step], step
|
||||
return index[::step], original[::step], filtered_float[::step], filtered_fixed[::step], step
|
||||
|
||||
|
||||
async def read_csv_upload(file):
|
||||
async def save_csv_upload(file):
|
||||
filename = (file.filename or "").lower()
|
||||
if filename and not filename.endswith(".csv"):
|
||||
raise HTTPException(status_code=400, detail="請上傳 CSV 檔案")
|
||||
@@ -25,37 +40,115 @@ async def read_csv_upload(file):
|
||||
raise HTTPException(status_code=413, detail=f"CSV 檔案不可超過 {MAX_CSV_BYTES // (1024 * 1024)}MB")
|
||||
if not contents.strip():
|
||||
raise HTTPException(status_code=400, detail="CSV 不可為空")
|
||||
try:
|
||||
df = pd.read_csv(io.BytesIO(contents))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"CSV 讀取失敗: {str(e)}")
|
||||
if df.empty:
|
||||
raise HTTPException(status_code=400, detail="CSV 不可為空")
|
||||
return df
|
||||
|
||||
file_id = str(uuid.uuid4())
|
||||
file_path = os.path.join(TEMP_CSV_DIR, f"{file_id}.csv")
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(contents)
|
||||
return file_id
|
||||
|
||||
|
||||
def filtered_csv_data(df, b_vals, a_vals, col_idx):
|
||||
if col_idx < 0 or len(df.columns) <= col_idx:
|
||||
def integer_lfilter(b_int, a_int, x_float, shift_in, shift_out, shift_b, shift_a, use_round=False):
|
||||
"""
|
||||
全整數差分方程式模擬 (Mimic DSP hardware)
|
||||
高精度狀態變數架構:前饋不位移,保留小數精度於狀態變數中。
|
||||
支援硬體 Rounding (四捨五入) vs Floor (無條件捨去向下取整)。
|
||||
"""
|
||||
b_int = np.asarray(b_int, dtype=np.int64)
|
||||
a_int = np.asarray(a_int, dtype=np.int64)
|
||||
x_int = np.round(x_float * (2**shift_in)).astype(np.int64)
|
||||
|
||||
# y_hist 將保留在 Q_{in + b} 格式以降低 Truncation Error
|
||||
y_hist = np.zeros(len(x_int), dtype=np.int64)
|
||||
y_out = np.zeros(len(x_int), dtype=np.int64)
|
||||
|
||||
nb = len(b_int)
|
||||
na = len(a_int)
|
||||
|
||||
A0 = int(a_int[0])
|
||||
if A0 == 0: A0 = 1
|
||||
|
||||
# 輸出所需的總位移量
|
||||
out_shift = shift_in + shift_b - shift_out
|
||||
|
||||
# 預先計算四捨五入的補償值 (+0.5)
|
||||
# 韌體開發提示 (C Implementation / RISC-V):
|
||||
# 1. 標準 RISC-V (RV32I/IMAC) 的 SRA 指令是純 Floor (無條件捨去),沒有硬體 Rounding shift。
|
||||
# (除非具備 'P' DSP Extension 才可能有 1-cycle 的硬體 rounding shift)。
|
||||
# 2. 演算法秘技:在 C 語言中要實現 1-clock 的四捨五入,不要呼叫 float 的 round()。
|
||||
# 請使用 `y = (acc + (1 << (shift - 1))) >> shift`。
|
||||
# 編譯器會將 (1 << (shift - 1)) 編譯為常數,整體只消耗 1 個 ADD 指令,極大消除了 DC Bias。
|
||||
round_offset_a = (A0 >> 1) if use_round else 0
|
||||
round_offset_out = (1 << (out_shift - 1)) if (use_round and out_shift > 0) else 0
|
||||
|
||||
for n in range(len(x_int)):
|
||||
acc = 0
|
||||
# Feedforward: 前饋完全不位移,結果為 Q_{in + b}
|
||||
for i in range(nb):
|
||||
if n - i >= 0:
|
||||
acc += b_int[i] * x_int[n - i]
|
||||
|
||||
# Feedback: 歷史紀錄為 Q_{in + b},係數為 Q_a,乘積為 Q_{in + b + a}
|
||||
fb_sum = 0
|
||||
for j in range(1, na):
|
||||
if n - j >= 0:
|
||||
fb_sum += a_int[j] * y_hist[n - j]
|
||||
|
||||
# Feedback 縮放:除以 A0 (即 >> shift_a),結果退回 Q_{in + b} 完美對齊
|
||||
# Python 的 // 等同於硬體的 SRA (Arithmetic Right Shift),會向負無窮大 Floor
|
||||
fb_shifted = (fb_sum + round_offset_a) // A0
|
||||
|
||||
acc -= fb_shifted
|
||||
|
||||
# 將超高精度的 acc 直接存入歷史變數
|
||||
y_hist[n] = acc
|
||||
|
||||
# 最終輸出再針對 Q_out 進行位移縮放
|
||||
if out_shift > 0:
|
||||
y_out[n] = (acc + round_offset_out) >> out_shift
|
||||
elif out_shift < 0:
|
||||
y_out[n] = acc << (-out_shift)
|
||||
else:
|
||||
y_out[n] = acc
|
||||
|
||||
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):
|
||||
path = get_cached_file_path(file_id)
|
||||
|
||||
# 預先讀取欄位名稱,避免用 usecols 讀取後找不到原始索引
|
||||
cols = pd.read_csv(path, nrows=0).columns.tolist()
|
||||
if col_idx < 0 or col_idx >= len(cols):
|
||||
raise HTTPException(status_code=400, detail="欄位索引超出範圍")
|
||||
col_to_filter = df.columns[col_idx]
|
||||
col_to_filter = cols[col_idx]
|
||||
|
||||
# 精準讀取單一欄位,並加上筆數限制 (極大降低記憶體用量與時間)
|
||||
df = pd.read_csv(path, usecols=[col_idx], nrows=MAX_ROWS)
|
||||
x_signal = pd.to_numeric(df[col_to_filter], errors="coerce")
|
||||
|
||||
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_signal = signal.lfilter(b_vals, a_vals, x_values)
|
||||
return col_to_filter, x_values, y_signal
|
||||
|
||||
# 路徑 1: 理想浮點數路徑
|
||||
y_float = signal.lfilter(b_vals, a_vals, x_values)
|
||||
|
||||
def filter_preview_response(df, b_vals, a_vals, col_idx):
|
||||
col_to_filter, x_signal, y_signal = filtered_csv_data(df, b_vals, a_vals, col_idx)
|
||||
index, original, filtered, step = downsample_for_plot(df.index.to_numpy(), x_signal, y_signal)
|
||||
# 路徑 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)
|
||||
|
||||
return {
|
||||
"index": index.tolist(),
|
||||
"original": original.tolist(),
|
||||
"filtered": filtered.tolist(),
|
||||
"filtered": filtered_float.tolist(),
|
||||
"filtered_fixed": filtered_fixed.tolist(),
|
||||
"col_name": col_to_filter,
|
||||
"total_points": int(len(df.index)),
|
||||
"plot_points": int(len(index)),
|
||||
@@ -63,9 +156,25 @@ def filter_preview_response(df, b_vals, a_vals, col_idx):
|
||||
}
|
||||
|
||||
|
||||
def filtered_csv_text(df, b_vals, a_vals, col_idx):
|
||||
col_to_filter, _, y_signal = filtered_csv_data(df, b_vals, a_vals, col_idx)
|
||||
df[f"{col_to_filter}_filtered"] = y_signal
|
||||
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):
|
||||
path = get_cached_file_path(file_id)
|
||||
# 匯出時需要原始所有欄位,但仍受限於 MAX_ROWS
|
||||
df = pd.read_csv(path, nrows=MAX_ROWS)
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
return csv_buffer.getvalue()
|
||||
|
||||
@@ -27,6 +27,19 @@ def parse_coefficients(raw, name):
|
||||
return validate_coefficients(values, name)
|
||||
|
||||
|
||||
def parse_int_coefficients(raw, name):
|
||||
try:
|
||||
# 先轉 float 再轉 int 以處理 1.0 這種字串
|
||||
values = [int(float(x.strip())) for x in raw.replace(",", " ").split() if x.strip()]
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail=f"{name} 係數格式錯誤 (預期為整數)")
|
||||
if len(values) == 0:
|
||||
raise HTTPException(status_code=400, detail=f"{name} 係數不可為空")
|
||||
if len(values) > MAX_COEFFICIENTS:
|
||||
raise HTTPException(status_code=400, detail=f"{name} 係數最多 {MAX_COEFFICIENTS} 個")
|
||||
return values
|
||||
|
||||
|
||||
def validate_coefficients(values, name):
|
||||
if len(values) == 0:
|
||||
raise HTTPException(status_code=400, detail=f"{name} 係數不可為空")
|
||||
|
||||
+55
-8
@@ -14,9 +14,8 @@ from dea.config import (
|
||||
from dea.csv_processing import (
|
||||
downsample_for_plot,
|
||||
filter_preview_response,
|
||||
filtered_csv_data,
|
||||
filtered_csv_text,
|
||||
read_csv_upload,
|
||||
save_csv_upload,
|
||||
)
|
||||
from dea.filter_design import design_response
|
||||
from dea.mcu import list_mcu_ports_response, write_mcu_command_response
|
||||
@@ -27,9 +26,11 @@ from dea.security import (
|
||||
is_lan_or_loopback,
|
||||
lan_denied_response,
|
||||
)
|
||||
import traceback
|
||||
from dea.validation import (
|
||||
normalize_coefficients,
|
||||
parse_coefficients,
|
||||
parse_int_coefficients,
|
||||
require_finite,
|
||||
validate_coefficients,
|
||||
validate_frequency,
|
||||
@@ -86,21 +87,50 @@ def calculate_bode_compare(params: BodeCompareParams):
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/api/csv/upload")
|
||||
async def upload_csv(file: UploadFile = File(...)):
|
||||
try:
|
||||
file_id = await save_csv_upload(file)
|
||||
return {"file_id": file_id}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
raise HTTPException(status_code=400, detail=f"CSV上傳失敗: {str(e)}")
|
||||
|
||||
|
||||
@app.post("/api/filter")
|
||||
async def filter_csv(
|
||||
file: UploadFile = File(...),
|
||||
file_id: str = Form(...),
|
||||
b: str = Form(...),
|
||||
a: str = Form(...),
|
||||
col_idx: int = Form(0),
|
||||
b_int: str = Form(None),
|
||||
a_int: str = Form(None),
|
||||
shift_in: int = Form(14),
|
||||
shift_out: int = Form(14),
|
||||
shift_b: int = Form(14),
|
||||
shift_a: int = Form(14),
|
||||
use_round: bool = Form(False),
|
||||
):
|
||||
try:
|
||||
b_vals = parse_coefficients(b, "b")
|
||||
a_vals = parse_coefficients(a, "a")
|
||||
df = await read_csv_upload(file)
|
||||
return filter_preview_response(df, b_vals, a_vals, col_idx)
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
raise HTTPException(status_code=400, detail=f"CSV處理失敗: {str(e)}")
|
||||
|
||||
|
||||
@@ -126,16 +156,32 @@ def write_mcu_command(params: MCUWriteParams):
|
||||
|
||||
@app.post("/api/filter/download")
|
||||
async def filter_csv_download(
|
||||
file: UploadFile = File(...),
|
||||
file_id: str = Form(...),
|
||||
b: str = Form(...),
|
||||
a: str = Form(...),
|
||||
col_idx: int = Form(0),
|
||||
b_int: str = Form(None),
|
||||
a_int: str = Form(None),
|
||||
shift_in: int = Form(14),
|
||||
shift_out: int = Form(14),
|
||||
shift_b: int = Form(14),
|
||||
shift_a: int = Form(14),
|
||||
use_round: bool = Form(False),
|
||||
):
|
||||
try:
|
||||
b_vals = parse_coefficients(b, "b")
|
||||
a_vals = parse_coefficients(a, "a")
|
||||
df = await read_csv_upload(file)
|
||||
csv_text = filtered_csv_text(df, b_vals, a_vals, col_idx)
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
return StreamingResponse(
|
||||
iter([csv_text]),
|
||||
@@ -145,4 +191,5 @@ async def filter_csv_download(
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
raise HTTPException(status_code=400, detail=f"CSV處理失敗: {str(e)}")
|
||||
|
||||
+90
-24
@@ -706,6 +706,40 @@
|
||||
<div class="p-6 z-10 relative bg-white dark:bg-dark transition-colors duration-300">
|
||||
<p class="text-base text-slate-500 dark:text-gray-400 mb-4">請上傳包含時域訊號的 CSV 檔案,系統會使用上述設定的差分濾波器。
|
||||
</p>
|
||||
|
||||
<!-- 資料預覽 -->
|
||||
<div v-if="csvPreview.length > 0" class="mb-6 border border-slate-100 dark:border-gray-800 rounded-xl overflow-hidden shadow-sm">
|
||||
<button type="button" @click="isCsvPreviewExpanded = !isCsvPreviewExpanded"
|
||||
class="w-full flex items-center justify-between p-3 bg-slate-50 dark:bg-gray-800/50 hover:bg-slate-100 dark:hover:bg-gray-800 transition-colors">
|
||||
<h4 class="text-sm font-semibold text-slate-600 dark:text-gray-300 uppercase tracking-tight flex items-center gap-2">
|
||||
<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 6h16M4 10h16M4 14h16M4 18h16"></path>
|
||||
</svg>
|
||||
資料預覽 (前五筆)
|
||||
</h4>
|
||||
<svg class="w-5 h-5 text-slate-400 transform transition-transform duration-200" :class="{ 'rotate-180': isCsvPreviewExpanded }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<div v-show="isCsvPreviewExpanded" class="overflow-x-auto bg-white dark:bg-gray-900 border-t border-slate-100 dark:border-gray-800">
|
||||
<table class="w-full text-sm text-left">
|
||||
<thead>
|
||||
<tr class="bg-slate-50/50 dark:bg-gray-800/30">
|
||||
<th v-for="col in csvColumns" :key="col"
|
||||
class="px-4 py-3 font-semibold text-slate-700 dark:text-gray-300 border-b border-slate-100 dark:border-gray-800">{{ col }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100 dark:divide-gray-800">
|
||||
<tr v-for="(row, ri) in csvPreview" :key="ri"
|
||||
class="hover:bg-slate-50 dark:hover:bg-gray-800/50 transition-colors">
|
||||
<td v-for="(val, ci) in row" :key="ci"
|
||||
class="px-4 py-2 font-mono text-slate-600 dark:text-gray-400">{{ val }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center flex-wrap gap-4 mb-6 p-4 bg-slate-50 dark:bg-gray-900/50 rounded-lg border border-slate-100 dark:border-gray-800">
|
||||
<label
|
||||
@@ -756,33 +790,65 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 資料預覽 -->
|
||||
<div v-if="csvPreview.length > 0" class="mb-6">
|
||||
<h4
|
||||
class="text-sm font-semibold text-slate-500 dark:text-gray-400 mb-2 uppercase tracking-tight">
|
||||
資料預覽 (前五筆):</h4>
|
||||
<div
|
||||
class="overflow-x-auto rounded-xl border border-slate-100 dark:border-gray-800 shadow-inner">
|
||||
<table class="w-full text-sm text-left">
|
||||
<thead>
|
||||
<tr class="bg-slate-50 dark:bg-gray-800/50">
|
||||
<th v-for="col in csvColumns" :key="col"
|
||||
class="px-4 py-3 font-semibold text-slate-700 dark:text-gray-300">{{ col
|
||||
}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100 dark:divide-gray-800">
|
||||
<tr v-for="(row, ri) in csvPreview" :key="ri"
|
||||
class="hover:bg-slate-50/50 dark:hover:bg-gray-800/30 transition-colors">
|
||||
<td v-for="(val, ci) in row" :key="ci"
|
||||
class="px-4 py-2 font-mono text-slate-600 dark:text-gray-400">{{ val }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- V2: 定點數時域設定 -->
|
||||
<div class="mb-6 p-4 bg-slate-50 dark:bg-gray-900/50 rounded-lg border border-slate-100 dark:border-gray-800">
|
||||
<h4 class="text-sm font-semibold text-slate-500 dark:text-gray-400 mb-4 uppercase tracking-tight flex items-center gap-2">
|
||||
<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 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)
|
||||
</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 lg:gap-6">
|
||||
<div>
|
||||
<label class="text-xs text-slate-500 dark:text-gray-400 mb-2 block font-bold">輸入訊號量化 (Q_in): 2^{{ shiftBitsIn }}</label>
|
||||
<div v-if="!isTouchInput" class="grid grid-cols-[2.5rem_1fr_2.5rem] gap-2 items-center">
|
||||
<button type="button" @pointerdown.prevent="startShiftInRepeat(-1)" @pointerup="stopShiftInRepeat" @pointerleave="stopShiftInRepeat" @pointercancel="stopShiftInRepeat"
|
||||
class="stepper-button h-10 flex items-center justify-center rounded-lg role-bg-primary-soft role-text-primary transition-all active:scale-95 text-lg font-bold border role-border-primary-soft">-</button>
|
||||
<input type="number" :value="shiftBitsIn" @change="setShiftBitsIn($event.target.value)"
|
||||
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-2 text-center font-mono text-sm role-text-primary outline-none">
|
||||
<button type="button" @pointerdown.prevent="startShiftInRepeat(1)" @pointerup="stopShiftInRepeat" @pointerleave="stopShiftInRepeat" @pointercancel="stopShiftInRepeat"
|
||||
class="stepper-button h-10 flex items-center justify-center rounded-lg role-bg-primary-soft role-text-primary transition-all active:scale-95 text-lg font-bold border role-border-primary-soft">+</button>
|
||||
</div>
|
||||
<button v-else type="button" @pointerdown.prevent="startShiftInDrag($event)"
|
||||
class="touch-none select-none w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-center role-text-primary font-mono font-bold">
|
||||
{{ shiftBitsIn }} bits
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-slate-500 dark:text-gray-400 mb-2 block font-bold">輸出訊號量化 (Q_out): 2^{{ shiftBitsOut }}</label>
|
||||
<div v-if="!isTouchInput" class="grid grid-cols-[2.5rem_1fr_2.5rem] gap-2 items-center">
|
||||
<button type="button" @pointerdown.prevent="startShiftOutRepeat(-1)" @pointerup="stopShiftOutRepeat" @pointerleave="stopShiftOutRepeat" @pointercancel="stopShiftOutRepeat"
|
||||
class="stepper-button h-10 flex items-center justify-center rounded-lg role-bg-secondary-soft role-text-secondary transition-all active:scale-95 text-lg font-bold border role-border-secondary-soft">-</button>
|
||||
<input type="number" :value="shiftBitsOut" @change="setShiftBitsOut($event.target.value)"
|
||||
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-2 text-center font-mono text-sm role-text-secondary outline-none">
|
||||
<button type="button" @pointerdown.prevent="startShiftOutRepeat(1)" @pointerup="stopShiftOutRepeat" @pointerleave="stopShiftOutRepeat" @pointercancel="stopShiftOutRepeat"
|
||||
class="stepper-button h-10 flex items-center justify-center rounded-lg role-bg-secondary-soft role-text-secondary transition-all active:scale-95 text-lg font-bold border role-border-secondary-soft">+</button>
|
||||
</div>
|
||||
<button v-else type="button" @pointerdown.prevent="startShiftOutDrag($event)"
|
||||
class="touch-none select-none w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-center role-text-secondary font-mono font-bold">
|
||||
{{ shiftBitsOut }} bits
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-slate-500 dark:text-gray-400 mb-2 block font-bold">位移捨去模式 (Truncation)</label>
|
||||
<div class="selector-button-group flex gap-1 h-10">
|
||||
<button type="button" @click="setUseRound(false)"
|
||||
:class="!useRound ? 'primary-action role-bg-primary role-text-on-fill role-border-primary' : 'bg-slate-200 dark:bg-gray-800 text-slate-600 dark:text-gray-400 border-slate-300 dark:border-gray-700'"
|
||||
class="selector-button flex-1 rounded py-0.5 text-xs font-medium border transition-colors">
|
||||
Floor (SRA)
|
||||
</button>
|
||||
<button type="button" @click="setUseRound(true)"
|
||||
:class="useRound ? 'primary-action role-bg-primary role-text-on-fill role-border-primary' : 'bg-slate-200 dark:bg-gray-800 text-slate-600 dark:text-gray-400 border-slate-300 dark:border-gray-700'"
|
||||
class="selector-button flex-1 rounded py-0.5 text-xs font-medium border transition-colors">
|
||||
Round (+0.5)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<!-- 波形比較圖 -->
|
||||
<h4 v-if="filterDone" class="text-base font-bold text-slate-800 dark:text-gray-200 mb-3">波形比較圖
|
||||
</h4>
|
||||
|
||||
+174
-10
@@ -43,11 +43,14 @@ export default {
|
||||
sense_b: '2x', sense_a: '2x',
|
||||
|
||||
loadingBode: false, bodeTimeout: null, globalError: null,
|
||||
csvFile: null, csvColumns: [], csvPreview: [], csvInfo: null, csvParseError: null,
|
||||
csvFile: null, csvFileId: null, csvColumns: [], csvPreview: [], csvInfo: null, csvParseError: null,
|
||||
isCsvPreviewExpanded: true,
|
||||
selectedColumn: 0, loadingFilter: false, filterDone: false, timePlotData: null,
|
||||
mobileTab: 'settings', // 手機版頁籤:'settings' | 'chart'
|
||||
shiftBitsB: DEFAULT_SHIFT_BITS,
|
||||
shiftBitsA: DEFAULT_SHIFT_BITS,
|
||||
useRound: true, // 是否使用 Rounding 取代 Floor
|
||||
sense_in: '2x', sense_out: '2x',
|
||||
fixedOverrides: { a: {}, b: {} },
|
||||
outOfRangeB: [false, false, false, false, false, false, false],
|
||||
outOfRangeA: [false, false, false, false, false, false, false],
|
||||
@@ -62,6 +65,14 @@ export default {
|
||||
fixedAFineDrag: null,
|
||||
fixedAFineRepeatDelayTimer: null,
|
||||
fixedAFineRepeatTimer: null,
|
||||
shiftBitsIn: 14,
|
||||
shiftBitsOut: 14,
|
||||
shiftInDrag: null,
|
||||
shiftInRepeatDelayTimer: null,
|
||||
shiftInRepeatTimer: null,
|
||||
shiftOutDrag: null,
|
||||
shiftOutRepeatDelayTimer: null,
|
||||
shiftOutRepeatTimer: null,
|
||||
isTouchInput: false,
|
||||
activeCoeffAdjustment: null,
|
||||
mcuSerialPort: null,
|
||||
@@ -236,6 +247,10 @@ export default {
|
||||
this.stopShiftBitDrag();
|
||||
this.stopFixedA1A2Repeat();
|
||||
this.stopFixedAFineDrag();
|
||||
this.stopShiftInRepeat();
|
||||
this.stopShiftInDrag();
|
||||
this.stopShiftOutRepeat();
|
||||
this.stopShiftOutDrag();
|
||||
},
|
||||
methods: {
|
||||
rememberPointerType(event) {
|
||||
@@ -502,6 +517,9 @@ export default {
|
||||
if (!Number.isFinite(nextValue)) return 0;
|
||||
return Math.max(0, nextValue);
|
||||
},
|
||||
setUseRound(val) {
|
||||
this.useRound = val;
|
||||
},
|
||||
setShiftBits(type, rawValue) {
|
||||
const nextValue = this.normalizeShiftBits(rawValue);
|
||||
if (type === 'b') {
|
||||
@@ -566,6 +584,101 @@ export default {
|
||||
window.removeEventListener('pointerup', this.stopShiftBitDrag);
|
||||
window.removeEventListener('pointercancel', this.stopShiftBitDrag);
|
||||
},
|
||||
// 新增:處理輸入輸出位移
|
||||
setShiftBitsIn(rawValue) {
|
||||
this.shiftBitsIn = this.normalizeShiftBits(rawValue);
|
||||
this.debouncedUpdateBode();
|
||||
},
|
||||
adjustShiftBitsIn(steps) {
|
||||
this.setShiftBitsIn(this.shiftBitsIn + steps);
|
||||
},
|
||||
startShiftInRepeat(direction) {
|
||||
this.stopShiftInRepeat();
|
||||
this.adjustShiftBitsIn(direction);
|
||||
this.shiftInRepeatDelayTimer = setTimeout(() => {
|
||||
this.shiftInRepeatTimer = setInterval(() => {
|
||||
this.adjustShiftBitsIn(direction);
|
||||
}, 80);
|
||||
}, 350);
|
||||
},
|
||||
stopShiftInRepeat() {
|
||||
clearTimeout(this.shiftInRepeatDelayTimer);
|
||||
clearInterval(this.shiftInRepeatTimer);
|
||||
this.shiftInRepeatDelayTimer = null;
|
||||
this.shiftInRepeatTimer = null;
|
||||
},
|
||||
startShiftInDrag(event) {
|
||||
if (event.pointerType !== 'touch') return;
|
||||
event.preventDefault();
|
||||
this.shiftInDrag = { lastY: event.clientY, remainder: 0 };
|
||||
window.addEventListener('pointermove', this.moveShiftInDrag, { passive: false });
|
||||
window.addEventListener('pointerup', this.stopShiftInDrag);
|
||||
window.addEventListener('pointercancel', this.stopShiftInDrag);
|
||||
},
|
||||
moveShiftInDrag(event) {
|
||||
if (!this.shiftInDrag) return;
|
||||
event.preventDefault();
|
||||
const delta = this.shiftInDrag.lastY - event.clientY;
|
||||
this.shiftInDrag.lastY = event.clientY;
|
||||
this.shiftInDrag.remainder += delta;
|
||||
const steps = Math.trunc(this.shiftInDrag.remainder / 18);
|
||||
if (steps === 0) return;
|
||||
this.shiftInDrag.remainder -= steps * 18;
|
||||
this.adjustShiftBitsIn(steps);
|
||||
},
|
||||
stopShiftInDrag() {
|
||||
this.shiftInDrag = null;
|
||||
window.removeEventListener('pointermove', this.moveShiftInDrag);
|
||||
window.removeEventListener('pointerup', this.stopShiftInDrag);
|
||||
window.removeEventListener('pointercancel', this.stopShiftInDrag);
|
||||
},
|
||||
setShiftBitsOut(rawValue) {
|
||||
this.shiftBitsOut = this.normalizeShiftBits(rawValue);
|
||||
this.debouncedUpdateBode();
|
||||
},
|
||||
adjustShiftBitsOut(steps) {
|
||||
this.setShiftBitsOut(this.shiftBitsOut + steps);
|
||||
},
|
||||
startShiftOutRepeat(direction) {
|
||||
this.stopShiftOutRepeat();
|
||||
this.adjustShiftBitsOut(direction);
|
||||
this.shiftOutRepeatDelayTimer = setTimeout(() => {
|
||||
this.shiftOutRepeatTimer = setInterval(() => {
|
||||
this.adjustShiftBitsOut(direction);
|
||||
}, 80);
|
||||
}, 350);
|
||||
},
|
||||
stopShiftOutRepeat() {
|
||||
clearTimeout(this.shiftOutRepeatDelayTimer);
|
||||
clearInterval(this.shiftOutRepeatTimer);
|
||||
this.shiftOutRepeatDelayTimer = null;
|
||||
this.shiftOutRepeatTimer = null;
|
||||
},
|
||||
startShiftOutDrag(event) {
|
||||
if (event.pointerType !== 'touch') return;
|
||||
event.preventDefault();
|
||||
this.shiftOutDrag = { lastY: event.clientY, remainder: 0 };
|
||||
window.addEventListener('pointermove', this.moveShiftOutDrag, { passive: false });
|
||||
window.addEventListener('pointerup', this.stopShiftOutDrag);
|
||||
window.addEventListener('pointercancel', this.stopShiftOutDrag);
|
||||
},
|
||||
moveShiftOutDrag(event) {
|
||||
if (!this.shiftOutDrag) return;
|
||||
event.preventDefault();
|
||||
const delta = this.shiftOutDrag.lastY - event.clientY;
|
||||
this.shiftOutDrag.lastY = event.clientY;
|
||||
this.shiftOutDrag.remainder += delta;
|
||||
const steps = Math.trunc(this.shiftOutDrag.remainder / 18);
|
||||
if (steps === 0) return;
|
||||
this.shiftOutDrag.remainder -= steps * 18;
|
||||
this.adjustShiftBitsOut(steps);
|
||||
},
|
||||
stopShiftOutDrag() {
|
||||
this.shiftOutDrag = null;
|
||||
window.removeEventListener('pointermove', this.moveShiftOutDrag);
|
||||
window.removeEventListener('pointerup', this.stopShiftOutDrag);
|
||||
window.removeEventListener('pointercancel', this.stopShiftOutDrag);
|
||||
},
|
||||
setA1A2(a1, a2) {
|
||||
const a = this.padTo7(this.parseCoeffs(this.a_str));
|
||||
a[0] = 1.0;
|
||||
@@ -1039,10 +1152,12 @@ export default {
|
||||
|
||||
resetCsvState() {
|
||||
this.csvFile = null;
|
||||
this.csvFileId = null;
|
||||
this.csvColumns = [];
|
||||
this.csvPreview = [];
|
||||
this.csvInfo = null;
|
||||
this.csvParseError = null;
|
||||
this.isCsvPreviewExpanded = true;
|
||||
this.selectedColumn = 0;
|
||||
this.filterDone = false;
|
||||
this.timePlotData = null;
|
||||
@@ -1053,7 +1168,7 @@ export default {
|
||||
this.resetCsvState();
|
||||
if (!file) return;
|
||||
|
||||
const maxBytes = 32 * 1024 * 1024;
|
||||
const maxBytes = 300 * 1024 * 1024;
|
||||
const fileName = file.name || '';
|
||||
if (!fileName.toLowerCase().endsWith('.csv')) {
|
||||
this.csvParseError = '請選擇 .csv 檔案';
|
||||
@@ -1061,13 +1176,13 @@ export default {
|
||||
return;
|
||||
}
|
||||
if (file.size > maxBytes) {
|
||||
this.csvParseError = 'CSV 檔案不可超過 32MB';
|
||||
this.csvParseError = 'CSV 檔案不可超過 300MB';
|
||||
event.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
reader.onload = async (e) => {
|
||||
try {
|
||||
const text = String(e.target.result || '').replace(/^\uFEFF/, '');
|
||||
const { rows, totalRows } = this.parseCsvText(text, 6);
|
||||
@@ -1098,11 +1213,21 @@ export default {
|
||||
this.selectedColumn = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 背景上傳至暫存區
|
||||
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 || '背景上傳失敗'); }
|
||||
const data = await res.json();
|
||||
this.csvFileId = data.file_id;
|
||||
|
||||
} catch (err) {
|
||||
this.csvParseError = err.message || 'CSV 解析失敗';
|
||||
this.csvColumns = [];
|
||||
this.csvPreview = [];
|
||||
this.csvInfo = null;
|
||||
this.csvFileId = null;
|
||||
event.target.value = '';
|
||||
}
|
||||
};
|
||||
@@ -1113,21 +1238,40 @@ export default {
|
||||
reader.readAsText(file);
|
||||
},
|
||||
|
||||
debouncedProcessFilter() {
|
||||
if (!this.filterDone || !this.csvFileId) return;
|
||||
if (this._filterTimeout) clearTimeout(this._filterTimeout);
|
||||
this._filterTimeout = setTimeout(() => {
|
||||
this.processFilter();
|
||||
}, 500);
|
||||
},
|
||||
|
||||
async processFilter() {
|
||||
if (!this.csvFile) return;
|
||||
if (!this.csvFileId) {
|
||||
if (this.csvFile) this.globalError = "檔案還在上傳中,請稍候...";
|
||||
return;
|
||||
}
|
||||
this.loadingFilter = true;
|
||||
this.globalError = null;
|
||||
const formData = new FormData();
|
||||
formData.append('file', this.csvFile);
|
||||
formData.append('file_id', this.csvFileId);
|
||||
formData.append('b', this.b_str);
|
||||
formData.append('a', this.a_str);
|
||||
formData.append('col_idx', this.selectedColumn);
|
||||
formData.append('b_int', this.b_int_str);
|
||||
formData.append('a_int', this.a_int_str);
|
||||
formData.append('shift_in', this.shiftBitsIn);
|
||||
formData.append('shift_out', this.shiftBitsOut);
|
||||
formData.append('shift_b', this.shiftBitsB);
|
||||
formData.append('shift_a', this.shiftBitsA);
|
||||
formData.append('use_round', this.useRound);
|
||||
try {
|
||||
const res = await fetch('/api/filter', { method: 'POST', body: formData });
|
||||
if (!res.ok) { const err = await res.json(); throw new Error(err.detail || '濾波處理失敗'); }
|
||||
const data = await res.json();
|
||||
this.timePlotData = data;
|
||||
this.filterDone = true;
|
||||
this.isCsvPreviewExpanded = false; // 運算成功後自動收合預覽
|
||||
// 確保在可見狀態下繪圖,避免 Plotly 計算尺寸錯誤
|
||||
this.$nextTick(() => {
|
||||
this.drawTimePlot(data);
|
||||
@@ -1153,18 +1297,26 @@ export default {
|
||||
legend: { orientation: 'h', y: 1.02, yanchor: 'bottom', x: 1, xanchor: 'right', font: { color: textColor, size: isSmallScreen ? 9 : 10 } }
|
||||
};
|
||||
Plotly.react('timePlot', [
|
||||
{ x: data.index, y: data.original, name: '原始輸入訊號 (Input)', type: 'scatter', line: { color: isDark ? '#c8cee0' : '#566075', width: 2.4 }, opacity: 0.9 },
|
||||
{ x: data.index, y: data.filtered, name: '濾波後輸出 (Output)', type: 'scatter', line: { color: isDark ? '#64d2ff' : '#2d6f8f', width: 2.4 }, opacity: 0.95 }
|
||||
{ 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 }
|
||||
], layout, { responsive: true });
|
||||
},
|
||||
|
||||
async downloadCsv() {
|
||||
if (!this.csvFile) return;
|
||||
if (!this.csvFileId) return;
|
||||
const formData = new FormData();
|
||||
formData.append('file', this.csvFile);
|
||||
formData.append('file_id', this.csvFileId);
|
||||
formData.append('b', this.b_str);
|
||||
formData.append('a', this.a_str);
|
||||
formData.append('col_idx', this.selectedColumn);
|
||||
formData.append('b_int', this.b_int_str);
|
||||
formData.append('a_int', this.a_int_str);
|
||||
formData.append('shift_in', this.shiftBitsIn);
|
||||
formData.append('shift_out', this.shiftBitsOut);
|
||||
formData.append('shift_b', this.shiftBitsB);
|
||||
formData.append('shift_a', this.shiftBitsA);
|
||||
formData.append('use_round', this.useRound);
|
||||
try {
|
||||
const res = await fetch('/api/filter/download', { method: 'POST', body: formData });
|
||||
if (!res.ok) throw new Error('下載失敗');
|
||||
@@ -1245,5 +1397,17 @@ export default {
|
||||
this.writingMCU = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
useRound() { this.debouncedProcessFilter(); },
|
||||
shiftBitsIn() { this.debouncedProcessFilter(); },
|
||||
shiftBitsOut() { this.debouncedProcessFilter(); },
|
||||
shiftBitsB() { this.debouncedProcessFilter(); },
|
||||
shiftBitsA() { this.debouncedProcessFilter(); },
|
||||
b_int_str() { this.debouncedProcessFilter(); },
|
||||
a_int_str() { this.debouncedProcessFilter(); },
|
||||
b_str() { this.debouncedProcessFilter(); },
|
||||
a_str() { this.debouncedProcessFilter(); }
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
+3
-3
File diff suppressed because one or more lines are too long
+1
-2
@@ -7,9 +7,8 @@ export default defineConfig({
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'https://127.0.0.1:8000',
|
||||
target: 'http://127.0.0.1:8000',
|
||||
changeOrigin: true,
|
||||
secure: false, // 允許自簽憑證
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user