Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5bd3043580 | |||
| 5413062eff | |||
| 6766df8f5e | |||
| b8ec600673 | |||
| 63382f6cf5 | |||
| 3edc4702f3 | |||
| 8caa8918d0 | |||
| 0140b0495c | |||
| 9182220c1d | |||
| 075f20dd81 | |||
| 2f392b13c6 | |||
| 345b4a789a | |||
| 8009a0dd78 | |||
| 2762d0da86 | |||
| da2cbfe8da |
+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
|
||||
|
||||
-137
@@ -1,137 +0,0 @@
|
||||
# Difference Equation Analyzer (DEA) - Architecture & Functionality
|
||||
|
||||
## Overview
|
||||
DEA is a specialized tool for designing, visualizing, and validating discrete-time filters. It bridges the gap between theoretical filter design (floating-point) and hardware implementation (fixed-point/MCU).
|
||||
|
||||
## 🏗 System Architecture
|
||||
|
||||
### 1. Backend (Python 3.12 + FastAPI)
|
||||
The backend is responsible for heavy mathematical computations and data processing.
|
||||
|
||||
- **API Entry (`dea_api.py`)**: Routes requests to appropriate modules and serves the Vue.js frontend from `static/`.
|
||||
- **Computation Engine (`dea/`)**:
|
||||
- `filter_design.py`: Uses `scipy.signal` to calculate coefficients for various filter topologies.
|
||||
- `bode.py`: Computes magnitude and phase response. Supports comparing "Ideal" (float) vs "Fixed" (quantized) responses.
|
||||
- `csv_processing.py`: Processes time-domain CSV data. It applies the current filter to the signal and downsamples the result for efficient visualization.
|
||||
- `validation.py`: Protects against unstable filter designs and invalid inputs.
|
||||
|
||||
### 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.
|
||||
- Handles Fixed-Point conversion logic (Q-format shifting).
|
||||
- **UI Components (`src/App.vue`)**:
|
||||
- **Control Sidebar**: Interactive inputs for all filter parameters.
|
||||
- **Visualization**: Dual-plot system using Plotly.js for Bode plots and Time-domain waveforms.
|
||||
- **Hardware Bridge**: Uses the **Web Serial API** to send commands directly to connected MCUs (requires HTTPS).
|
||||
|
||||
## 🚀 Execution Flow
|
||||
1. **Design**: User selects a filter type or enters coefficients manually.
|
||||
2. **Quantize**: User adjusts Q-format bits to see how quantization affects the frequency response.
|
||||
3. **Verify**: User uploads a CSV to see how the filter behaves with real-world signals.
|
||||
4. **Deploy**: User clicks "Write to MCU" to send the coefficients to their hardware.
|
||||
|
||||
## 🛠 Tech Stack
|
||||
- **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 補償)」選項中,開啟後可大幅提升信噪比與波形穩定性。
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Deployment & Development Notes (部署與開發環境注意事項)
|
||||
|
||||
為了支援 **Web Serial API** 直接與外部 MCU 通訊,本專案在生產與正式使用中**全面採用 HTTPS 加密傳輸**。這對於開發代理(Proxy)與存取路徑有以下影響:
|
||||
|
||||
### 1. Vite Proxy 與 SSL 協定對齊
|
||||
當 Uvicorn 以 **HTTPS** 模式啟動時(綁定 `key.pem` 與 `cert.pem`),Vite Dev Server 的 `/api` 代理目標**必須對齊為 `https://`**,並加上 `secure: false` 以允許自簽憑證:
|
||||
|
||||
```js
|
||||
// ✅ 正確 (HTTPS 模式下)
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'https://127.0.0.1:8000',
|
||||
changeOrigin: true,
|
||||
secure: false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 生產部署流程(純使用者使用)
|
||||
如果您是**純使用者**,或者需要進行系統正式展示,**完全不需要啟動 Vite Dev Server (5173)**!
|
||||
您只需要在修改前端後進行一次編譯,然後使用 FastAPI 單獨運行 8000 連接埠即可:
|
||||
|
||||
```bash
|
||||
# 1. 前端編譯 (只需在修改 src/ 代碼後執行一次,若無修改則免)
|
||||
npm run build # 將 Vue 代碼打包至 static/build/
|
||||
|
||||
# 2. 啟動後端 HTTPS 服務 (同時提供 API 與網頁 UI)
|
||||
.venv/bin/uvicorn dea_api:app --host 0.0.0.0 --port 8000 --ssl-keyfile certs/key.pem --ssl-certfile certs/cert.pem
|
||||
```
|
||||
或直接利用 systemd 後台服務啟動(開機自動拉起):
|
||||
```bash
|
||||
sudo systemctl restart dea.service
|
||||
```
|
||||
|
||||
### 3. 開發模式 vs 生產模式對照表
|
||||
| 項目 | 開發模式 (Vite Dev Server) | 生產模式 (FastAPI Static Serve) |
|
||||
|------|----------------|---------------------|
|
||||
| **運行連接埠** | `http://localhost:5173` | `https://localhost:8000/ui/` |
|
||||
| **後端通訊** | 透過 Vite Proxy 轉發至 8000 (HTTPS) | 同一 Port 直接連線,極致穩定且高效 |
|
||||
| **修改後生效** | 自動熱更新 (HMR),不需手動 build | 必須執行 `npm run build`,並在瀏覽器進行硬重新整理 |
|
||||
| **主要用途** | 適合前端網頁排版、UI 除錯開發 | **正式使用、Demo 展示、MCU Web Serial 通訊** |
|
||||
| **是否需 5173**| **是** | **否,完全不需啟用!** |
|
||||
|
||||
@@ -1,293 +0,0 @@
|
||||
# Difference Equation Analyzer (DEA)
|
||||
## 定點數整數差分方程式實作規格說明書
|
||||
|
||||
本文件旨在為嵌入式韌體、DSP 與硬體描述語言(HDL)開發工程師,提供本專案「定點數整數濾波模擬(`integer_lfilter`)」的完整實作細節與移植指引。
|
||||
|
||||
本系統所採用的定點數架構為**高精度狀態變數架構 (Extended Precision State Variables)**,並支援 **1-Clock 高效能硬體四捨五入 (Rounding)**,能有效消除直流偏移(DC Bias)並最大化訊噪比(SNR)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 定點數 Q 格式 (Q-Format) 定義
|
||||
|
||||
在實作中,所有浮點數(實數)均以帶符號的整數進行二進位縮放表示:
|
||||
$$\text{整數值} = \text{round}\left( \text{浮點數值} \times 2^{Q} \right)$$
|
||||
|
||||
本專案定義了以下四個獨立的 Q 格式位移參數:
|
||||
|
||||
| 參數名稱 | 代號 | 說明 | 格式描述 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `shift_in` | $Q_{in}$ | 輸入訊號 $x[n]$ 的量化位元數 | $x_{\text{int}}[n] \in Q_{in}$ |
|
||||
| `shift_out` | $Q_{out}$ | 最終輸出訊號 $y[n]$ 的量化位元數 | $y_{\text{out}}[n] \in Q_{out}$ |
|
||||
| `shift_b` | $Q_b$ | 前饋(Feedforward)係數 $b$ 的量化位元數 | $b_{\text{int}}[i] \in Q_b$ |
|
||||
| `shift_a` | $Q_a$ | 回授(Feedback)係數 $a$ 的量化位元數 | $a_{\text{int}}[j] \in Q_a$ |
|
||||
|
||||
---
|
||||
|
||||
## 2. 核心架構:高精度狀態變數 (Extended Precision State Variables)
|
||||
|
||||
為了極小化乘加運算中的截斷誤差 (Truncation Error) 並防止 IIR 濾波器極點附近因量化產生的極限環震盪(Limit Cycles),本架構採用**前饋完全不右移**的策略:
|
||||
|
||||
1. **狀態變數 $y_{\text{hist}}$ 精度保持**:歷史狀態變數 $y_{\text{hist}}[n-j]$ 並非儲存低精度的最終輸出 $y_{\text{out}}$,而是直接儲存前饋與回授累加後、尚未進行輸出位移的**高精度狀態值**。其格式固定為:
|
||||
$$y_{\text{hist}} \in Q_{in + b}$$
|
||||
2. **免除中間位移**:在前饋運算中,$b_{\text{int}} \times x_{\text{int}}$ 的結果為 $Q_b \times Q_{in} = Q_{in+b}$,完全不需進行任何位移,即可直接累加至 `sum_b` 中。
|
||||
3. **回授對齊縮放(歸一化 $Q_a$)**:回授運算中,係數 $a_{\text{int}} \in Q_a$,歷史狀態 $y_{\text{hist}} \in Q_{in+b}$,兩者相乘後格式為 $Q_{in+b+a}$。
|
||||
|
||||
> 💡 **重要前提與物理意義:假設 $A_0 = 1$**
|
||||
> 濾波器公式通常已正規化使得回授係數 $A_0 = 1$。當我們使用 $Q_a$ 將 $a$ 係數放大時,$A_0$ 的整數值實際上就是 $2^{Q_a}$。
|
||||
>
|
||||
> 在運算完畢後,這項「因為 $a$ 係數被放大了 $Q_a$ 倍」而產生的額外增益必須被**歸一(Normalize)**,否則數值會以指數級別爆炸!因此,我們將回授乘加總和除以 $A_0$(在二進位中等同於**算術右移 `shift_a` 位元**),藉此消去 $Q_a$ 的放大效應,使其完美對齊回 $Q_{in+b}$ 格式,才能與前饋結果相減。
|
||||
>
|
||||
> 💡 **關於輸入訊號 $x$ 與前饋係數 $b$ 的歸一化討論**
|
||||
> 許多工程師會問:既然回授部分需要被歸一以防爆炸,那輸入訊號 $x$(放大了 $Q_{in}$ 倍)與前饋係數 $b$(放大了 $Q_b$ 倍)在運算過程中是否也需要被歸一化?
|
||||
> **答案是:完全不需要,而且我們也故意沒有做!**
|
||||
>
|
||||
> 這是因為:
|
||||
> 1. **避免資訊截斷(Truncation Noise)**:如果我們在累加到 `sum_b` 時就進行右移歸一,會直接把低位元的小數精度給丟棄,造成無法挽回的量化雜訊。
|
||||
> 2. **最優化運算效能**:在迴圈內部,`sum_b`($Q_{in+b}$)與被歸一後的 `sum_a_scaled`($Q_{in+b}$)可以直接進行減法運算。整個迴圈內部不需要對 $x$ 與 $b$ 進行 any 移位,這在硬體實作中極大提升了運算速度。
|
||||
> 3. **延遲至最終輸出對齊**:輸入與前饋所累積下來的放大倍率(也就是 $Q_{in+b}$),將會完完整整地保留在狀態變數中,一路以高精度參與未來的回授迭代。直到濾波運算結束、準備將訊號寫入硬體輸出端(如 DAC)時,我們才會一次性地移去這段增益(即右移 $S_{\text{out}} = Q_{in} + Q_b - Q_{out}$ 位元)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 核心演算法步驟與數學推導
|
||||
|
||||
對於每個輸入採樣 $x_{\text{float}}[n]$,整數濾波器的計算流程如下:
|
||||
|
||||
### 步驟一:輸入量化 (Input Quantization)
|
||||
將浮點數輸入訊號量化為整數 $x_{\text{int}}[n]$:
|
||||
$$x_{\text{int}}[n] = \text{round}\left( x_{\text{float}}[n] \times 2^{Q_{in}} \right)$$
|
||||
|
||||
### 步驟二:前饋累加 (Feedforward Accumulation - 處理 $b$ 係數)
|
||||
計算前饋部分,累積至高精度累加器 `sum_b`(規格使用 32-bit 帶符號整數 `int32_t`):
|
||||
$$sum\_b = \sum_{i=0}^{N_b-1} b_{\text{int}}[i] \cdot x_{\text{int}}[n-i]$$
|
||||
*此時 $sum\_b \in Q_{in+b}$。*
|
||||
|
||||
### 步驟三:回授累加與歸一化 (Feedback Accumulation - 處理 $a$ 係數)
|
||||
1. 計算高精度的回授乘加值:
|
||||
$$sum\_a = \sum_{j=1}^{N_a-1} a_{\text{int}}[j] \cdot y_{\text{hist}}[n-j]$$
|
||||
*此時 $sum\_a \in Q_{in+b+a}$。*
|
||||
|
||||
2. **歸一化 $Q_a$ 放大倍數**:將回授項除以 $A_0$(即 $a_{\text{int}}[0]$,通常為 $2^{Q_a}$)以對齊至 $Q_{in+b}$:
|
||||
* **Floor 模式(無條件捨去,非常不建議)**:
|
||||
$$sum\_a\_scaled = \lfloor \frac{sum\_a}{A_0} \rfloor \approx sum\_a \gg Q_a$$
|
||||
* **Round 模式(四捨五入,專案預設與推薦)**:
|
||||
$$round\_offset\_a = A_0 \gg 1$$
|
||||
$$sum\_a\_scaled = \lfloor \frac{sum\_a + round\_offset\_a}{A_0} \rfloor \approx \left( sum\_a + (1 \ll (Q_a - 1)) \right) \gg Q_a$$
|
||||
|
||||
### 步驟四:更新狀態變數 (State Variable Update)
|
||||
從前饋累加值中減去對齊後的回授值,並直接存入歷史狀態變數:
|
||||
$$acc = sum\_b - sum\_a\_scaled$$
|
||||
$$y_{\text{hist}}[n] = acc$$
|
||||
*歷史狀態變數 $y_{\text{hist}}$ 格式為 $Q_{in+b}$。*
|
||||
|
||||
### 步驟五:最終輸出縮放 (Output Quantization & Scale-Out)
|
||||
因為 $y$ 的實際物理數值與系統增益(System Gain)主要由 $b$ 和 $x$ 決定,這時內部變數已經穩定保持在 $Q_{in+b}$ 格式。
|
||||
`shift_out` 的作用是將內部的高精度數值轉換為符合實體硬體接口(如 12-bit DAC)的位元寬度。
|
||||
計算所需的總位移量 $S_{\text{out}}$:
|
||||
$$S_{\text{out}} = Q_{in} + Q_b - Q_{out}$$
|
||||
|
||||
* **當 $S_{\text{out}} > 0$(需要右移)**:
|
||||
* **Round 模式**:
|
||||
$$round\_offset\_out = 1 \ll (S_{\text{out}} - 1)$$
|
||||
$$y_{\text{out}}[n] = (acc + round\_offset\_out) \gg S_{\text{out}}$$
|
||||
* **當 $S_{\text{out}} < 0$(需要左移)**:
|
||||
$$y_{\text{out}}[n] = acc \ll (-S_{\text{out}})$$
|
||||
* **當 $S_{\text{out}} == 0$(不需位移)**:
|
||||
$$y_{\text{out}}[n] = acc$$
|
||||
|
||||
---
|
||||
|
||||
## 4. 消除直流偏移的 1-Clock Rounding 演算法秘技
|
||||
|
||||
傳統的算術右移(Arithmetic Right Shift, `SRA`)在二進位中代表的是 **Floor(向負無窮大取整)**。
|
||||
這會帶來平均 $-0.5\text{ LSB}$ 的系統性截斷誤差,在時域訊號中會累積成顯著的**直流偏移(DC Bias)**,在 IIR 濾波器中甚至會導致輸出不斷漂移。
|
||||
|
||||
在 C 語言中,千萬不要呼叫 `round()` 等浮點數函式,請使用以下純整數運算:
|
||||
|
||||
```c
|
||||
// 完美的 1-clock 四捨五入右移寫法:
|
||||
// (1 << (shift - 1)) 在編譯時期會被編譯器直接優化為一個立即常數,
|
||||
// 整體運算只比普通右移多消耗一個 ADD 指令,完美消除 DC Bias!
|
||||
y_out = (acc + (1 << (shift - 1))) >> shift;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 完整 C 語言 (MCU 32-bit 最佳化) 實作程式碼範例
|
||||
|
||||
在 32-bit 微處理器(如 ARM Cortex-M 或是 RISC-V RV32I)上,`int32_t` 運算是效能最高、最節省時鐘週期的核心格式(避免了昂貴的 64-bit 軟體乘除模擬)。
|
||||
|
||||
```c
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
#define FILTER_ORDER_MAX 4 // 最大支援濾波器階數
|
||||
|
||||
typedef struct {
|
||||
int32_t b[FILTER_ORDER_MAX + 1]; // Q_b 格式的前饋係數
|
||||
int32_t a[FILTER_ORDER_MAX + 1]; // Q_a 格式的回授係數 (a[0] 通常為 1<<Q_a)
|
||||
int32_t nb; // b 係數個數
|
||||
int32_t na; // a 係數個數
|
||||
|
||||
int16_t shift_in; // Q_in
|
||||
int16_t shift_out; // Q_out
|
||||
int16_t shift_b; // Q_b
|
||||
int16_t shift_a; // Q_a
|
||||
bool use_round; // 是否啟用四捨五入補償
|
||||
|
||||
// 歷史狀態變數 (循環緩衝區)
|
||||
int32_t x_hist[FILTER_ORDER_MAX + 1]; // Q_in 格式的輸入歷史
|
||||
int32_t y_hist[FILTER_ORDER_MAX + 1]; // Q_{in+b} 格式的高精度狀態歷史
|
||||
int32_t x_index; // x 歷史緩衝區指針
|
||||
int32_t y_index; // y 歷史緩衝區指針
|
||||
} FixedFilter_t;
|
||||
|
||||
/**
|
||||
* @brief 初始化濾波器狀態與係數
|
||||
*/
|
||||
void FixedFilter_Init(FixedFilter_t *filter,
|
||||
const int32_t *b_coeffs, int32_t nb,
|
||||
const int32_t *a_coeffs, int32_t na,
|
||||
int16_t shift_in, int16_t shift_out,
|
||||
int16_t shift_b, int16_t shift_a,
|
||||
bool use_round)
|
||||
{
|
||||
filter->nb = (nb > FILTER_ORDER_MAX + 1) ? FILTER_ORDER_MAX + 1 : nb;
|
||||
filter->na = (na > FILTER_ORDER_MAX + 1) ? FILTER_ORDER_MAX + 1 : na;
|
||||
|
||||
for (int32_t i = 0; i < filter->nb; i++) filter->b[i] = b_coeffs[i];
|
||||
for (int32_t j = 0; j < filter->na; j++) filter->a[j] = a_coeffs[j];
|
||||
|
||||
filter->shift_in = shift_in;
|
||||
filter->shift_out = shift_out;
|
||||
filter->shift_b = shift_b;
|
||||
filter->shift_a = shift_a;
|
||||
filter->use_round = use_round;
|
||||
|
||||
filter->x_index = 0;
|
||||
filter->y_index = 0;
|
||||
|
||||
for (int32_t i = 0; i < FILTER_ORDER_MAX + 1; i++) {
|
||||
filter->x_hist[i] = 0;
|
||||
filter->y_hist[i] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 核心定點數差分方程式執行 (32-bit 最佳化版本)
|
||||
* @param x_in Q_in 格式的整數輸入值
|
||||
* @return Q_out 格式的整數輸出值
|
||||
*/
|
||||
int32_t FixedFilter_Process(FixedFilter_t *filter, int32_t x_in)
|
||||
{
|
||||
// 1. 將新輸入寫入循環緩衝區
|
||||
filter->x_hist[filter->x_index] = x_in;
|
||||
|
||||
// 2. 計算前饋 (Feedforward): 處理 b 係數 (Q_b * Q_in -> Q_{in+b})
|
||||
int32_t sum_b = 0;
|
||||
int32_t x_ptr = filter->x_index;
|
||||
for (int32_t i = 0; i < filter->nb; i++) {
|
||||
sum_b += filter->b[i] * filter->x_hist[x_ptr];
|
||||
if (--x_ptr < 0) x_ptr = filter->nb - 1; // 循環指針回繞
|
||||
}
|
||||
|
||||
// 3. 計算回授 (Feedback): 處理 a 係數 (Q_a * Q_{in+b} -> Q_{in+b+a})
|
||||
int32_t sum_a = 0;
|
||||
int32_t y_ptr = filter->y_index;
|
||||
for (int32_t j = 1; j < filter->na; j++) {
|
||||
// y_hist 儲存的是歷史中的高精度狀態變數 (Q_{in+b})
|
||||
sum_a += filter->a[j] * filter->y_hist[y_ptr];
|
||||
if (--y_ptr < 0) y_ptr = filter->na - 1;
|
||||
}
|
||||
|
||||
// 4. 歸一化 a 係數的放大倍數 (除以 A0,即算術右移 shift_a)
|
||||
int32_t A0 = filter->a[0];
|
||||
if (A0 <= 0) A0 = 1; // 防呆
|
||||
|
||||
int32_t sum_a_scaled = 0;
|
||||
if (filter->use_round) {
|
||||
int32_t round_offset_a = A0 >> 1;
|
||||
sum_a_scaled = (sum_a + round_offset_a) / A0;
|
||||
} else {
|
||||
sum_a_scaled = sum_a / A0;
|
||||
}
|
||||
|
||||
// 如果 A0 是 2 的冪次方 (例如 2^shift_a),上述除法可被編譯器優化為算術右移:
|
||||
// sum_a_scaled = (sum_a + (1 << (shift_a - 1))) >> shift_a;
|
||||
|
||||
// 5. 更新狀態變數並寫入歷史緩衝區
|
||||
int32_t acc = sum_b - sum_a_scaled;
|
||||
|
||||
if (++(filter->y_index) >= filter->na) filter->y_index = 0;
|
||||
filter->y_hist[filter->y_index] = acc;
|
||||
|
||||
// 6. 計算最終輸出量化 Q_{in+b} -> Q_{out}
|
||||
int32_t out_shift = filter->shift_in + filter->shift_b - filter->shift_out;
|
||||
int32_t y_out = 0;
|
||||
|
||||
if (out_shift > 0) {
|
||||
if (filter->use_round) {
|
||||
int32_t round_offset_out = (1 << (out_shift - 1));
|
||||
y_out = (acc + round_offset_out) >> out_shift;
|
||||
} else {
|
||||
y_out = acc >> out_shift;
|
||||
}
|
||||
} else if (out_shift < 0) {
|
||||
y_out = acc << (-out_shift);
|
||||
} else {
|
||||
y_out = acc;
|
||||
}
|
||||
|
||||
if (++(filter->x_index) >= filter->nb) filter->x_index = 0;
|
||||
|
||||
return y_out;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 與 Python 模擬的一致性驗證
|
||||
|
||||
本專案後端 `dea/csv_processing.py` 中的 `integer_lfilter` 函數提供完全一致的參考實作。工程師在撰寫 C 語言硬體代碼時,可隨時將硬體輸出的暫存器數值與本專案的 Python 數值進行對齊測試:
|
||||
|
||||
```python
|
||||
# 專案後端 Python 核心運算對照
|
||||
def integer_lfilter(b_int, a_int, x_float, shift_in, shift_out, shift_b, shift_a, use_round=False):
|
||||
x_int = np.round(x_float * (2**shift_in)).astype(np.int64)
|
||||
y_hist = np.zeros(len(x_int), dtype=np.int64) # Q_{in+b} 狀態變數
|
||||
y_out = np.zeros(len(x_int), dtype=np.int64) # Q_out 輸出變數
|
||||
|
||||
out_shift = shift_in + shift_b - shift_out
|
||||
A0 = int(a_int[0])
|
||||
|
||||
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)):
|
||||
sum_b = 0
|
||||
# 前饋累積處理 b 係數 (Q_{in+b})
|
||||
for i in range(len(b_int)):
|
||||
if n - i >= 0:
|
||||
sum_b += b_int[i] * x_int[n - i]
|
||||
|
||||
sum_a = 0
|
||||
# 回授累積處理 a 係數 (Q_{in+b+a})
|
||||
for j in range(1, len(a_int)):
|
||||
if n - j >= 0:
|
||||
sum_a += a_int[j] * y_hist[n - j]
|
||||
|
||||
# 歸一化 a 係數放大倍數,對齊回 Q_{in+b}
|
||||
sum_a_scaled = (sum_a + round_offset_a) // A0
|
||||
|
||||
acc = sum_b - sum_a_scaled
|
||||
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)
|
||||
```
|
||||
@@ -1,114 +0,0 @@
|
||||
# DSP Bode Plot 專案進度總結 (2026-05-15)
|
||||
|
||||
## 1. 核心功能優化 (DSP & 定點數運算)
|
||||
- **細部微調 (Fine-tuning)**:實施了基於 $\delta$ (Delta) 與 $r$ (Radius) 的參數化微調。
|
||||
- **定點數精確度修正**:採用「兩倍值整數運算」邏輯,解決了 $0.5$ 步階在浮點數圓整時產生的「按鍵沒反應」Bug。
|
||||
- **防止漂移 (Oscillation Logic)**:針對數位微調,實施了奇偶震盪平衡邏輯。當調整 $\delta$ 導致 $r$ 必須隨動時,採用震盪補償防止 $r$ 產生單向累積誤差。
|
||||
- **參數連動策略**:在 $0.5$ 步階時,採取「優先滿足目標變動」原則。若調整 $\delta$,則 $r$ 隨動;反之亦然。
|
||||
|
||||
## 2. UI/UX 介面調整
|
||||
- **側邊欄優化**:
|
||||
- 將 $r$ 與 $\delta$ 的調整區域改為**垂直堆疊**,適應 21rem 的窄邊欄。
|
||||
- 移除冗餘的 $a1, a2$ 預覽方塊,僅保留帶有數學公式標籤的 $r, \delta$ 預覽。
|
||||
- **步階序列**:數位微調步階改為自訂序列:`[0.5, 1, 2, 4, 8, 32, 256, 1024, 4096, 16384, 65536]`。
|
||||
- **微調量顯示**:統一使用 `±` 符號並帶空格(例如 `± 0.5`)。
|
||||
- **智能禁用**:當數位步階為 $0.5$ 時,自動禁用 $r$ 的控制項,確保以 $\delta$ 為主導。
|
||||
|
||||
## 3. 繪圖系統 (Bode Plot)
|
||||
- **幅值圖 (Magnitude)**:
|
||||
- 初始範圍:基於 $systemGain \times 3$,涵蓋 $70 \text{ dB}$。
|
||||
- 保留手動彈性:設定為 `fixedrange: false`,支援滑鼠縮放與 Auto Scale。
|
||||
- 狀態保持:使用 `uirevision`,確保微調參數時不會跳回初始縮放。
|
||||
- **相位圖 (Phase)**:
|
||||
- 絕對鎖死:固定範圍為 $[-180^\circ, 180^\circ]$。
|
||||
- 禁止縮放:設定為 `fixedrange: true`,提供穩定的絕對參考。
|
||||
- **座標軸連動**:幅值圖與相位圖的 **X 軸(頻率)已完全同步**。不論在哪一張圖進行橫向縮放,兩者都會連動。
|
||||
|
||||
## 4. 數學公式參考
|
||||
- **理想空間**:
|
||||
- $a1 = -1 - r + \delta$
|
||||
- $a2 = r + \delta$
|
||||
- **定點數空間 (Q14 為例)**:
|
||||
- $a0 = 2^{14} = 16384$
|
||||
- $a1 = -a0 - r + \delta$
|
||||
- $a2 = r + \delta$
|
||||
- $2\delta = a1 + a2 + a0$
|
||||
- $2r = a2 - a1 - a0$
|
||||
|
||||
## 5. 目前 Git 狀態
|
||||
- **當前分支**:`dev-v2-rounding-fix-20260517` (已推送到 origin)。
|
||||
- **同步說明**:此版本包含了 Rounding 功能、Vite proxy 修復與 production build。
|
||||
- **同事試用指令**:
|
||||
```bash
|
||||
git fetch
|
||||
git checkout dev-v2-rounding-fix-20260517
|
||||
```
|
||||
|
||||
---
|
||||
*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*
|
||||
|
||||
---
|
||||
|
||||
# DSP Bode Plot 專案進度更新 (2026-05-17 - 第二階段重構與優化)
|
||||
|
||||
## 8. 回授變數命名重構與物理意義釐清
|
||||
- **變數命名精確化**:為消除舊版回授項以 `fb_` 開頭容易讓工程師誤解為 `b` 係數的命名混淆,將 `dea/csv_processing.py` 的變數全面更名:
|
||||
- `fb_sum` $\rightarrow$ **`sum_a`** (回授 a 累加項)
|
||||
- `fb_shifted` $\rightarrow$ **`sum_a_scaled`** (已除以 $A_0$ 歸一化後的回授項)
|
||||
- `acc` 運算直接更新為直觀的 `acc = sum_b - sum_a_scaled`,完全與數學公式鏡像對齊。
|
||||
- **明確白話文檔指引**:在根目錄與 bodeplot 目錄下的 `INTEGER_FILTER_IMPLEMENTATION.md` 文件中,新增了兩項白話物理意義說明:
|
||||
1. **為什麼回授部分除以 $A_0$?**(假設 $A_0 = 1$,放大後即為 $2^{Q_a}$,除以 $A_0$ 等同於算術右移 `shift_a`,旨在消去 $Q_a$ 放大的效應以防數值爆炸)。
|
||||
2. **為什麼輸入訊號 $x$ 與前饋 $b$ 不用中間歸一化?**(說明為了防止小數精度因中間右移而被截斷丟棄,故意將高精度一路鎖在歷史狀態變數中,延遲至最終輸出時才進行一次性對齊,藉此獲得最大 SNR 與最佳效能)。
|
||||
3. **加註 Floor 模式的警告**:加註 `(無條件捨去,非常不建議)`,指引工程師優先使用 Round 補償。
|
||||
|
||||
## 9. 韌體 C 語言實作最佳化 (32-bit MCU Optimization)
|
||||
- **32位元整數重構**:為避免 64 位元除法/乘法在 32 位元微處理器(如 ARM Cortex-M 或是 RISC-V RV32I)上引發昂貴的軟體模擬消耗,將 `INTEGER_FILTER_IMPLEMENTATION.md` 中提供的 C 語言 Process 範例全部改為由 **`int32_t`** 為核心的高效能版本。
|
||||
|
||||
## 10. Vite HTTPS Proxy & SSL 完全對齊
|
||||
- **HTTPS 代理修復**:為同時支援 Web Serial API(瀏覽器實體連接埠,要求 HTTPS 協議)與 Vite 5173 的自動熱更新開發,將 `vite.config.js` 的 proxy target 更新為 **`https://127.0.0.1:8000`**,並加上 **`secure: false`**(允許繞過自簽憑證限制),徹底解決了前端對代理伺服器發送明文請求所導致的 `Failed to execute 'json' on 'Response': Unexpected end of JSON input` 報錯。
|
||||
|
||||
## 11. 目前 Git 狀態與 GitLab 同步
|
||||
- **當前分支**:`dev-v2-integretimedomain`
|
||||
- **提交與推送**:已經將最新的變數重構、32-bit C 代碼優化、Vite HTTPS Proxy 更新成功 commit 並 Push 至 GitLab 遠端儲存庫,分支版控狀態極其乾淨。
|
||||
|
||||
---
|
||||
*Last Updated: 2026-05-17 14:26*
|
||||
@@ -1,109 +1,326 @@
|
||||
# Difference Equation Analyzer
|
||||
|
||||
Difference Equation Analyzer 是一個用來設計、檢視與驗證離散時間濾波器的工具。後端使用 FastAPI、NumPy、SciPy 與 Pandas;前端使用 Vue 3、Vite、Tailwind CSS 與 Plotly。
|
||||
Difference Equation Analyzer 是用來設計、檢視與驗證離散時間濾波器的工具。它同時支援浮點係數、fixed-point 係數、時域 CSV 驗證,以及透過 Web Serial 將係數寫入 MCU。
|
||||
|
||||
主要功能:
|
||||
Backend 使用 FastAPI、NumPy、SciPy 與 Pandas;Frontend 使用 Vue 3、Vite、Tailwind CSS 與 Plotly。
|
||||
|
||||
## Features
|
||||
|
||||
- 設計 Lowpass、Highpass、Bandpass、Notch、1P1Z、2P1Z、2P2Z、PID、SOGI 濾波器。
|
||||
- 比較浮點係數與 fixed-point 係數的頻率響應。
|
||||
- 手動微調 b/a 係數、a1/a2 組合(支援 δ/r 參數微調)、Q 格式位元數與 fixed-point 整數係數。
|
||||
- 透過瀏覽器 Web Serial API 直接將 fixed-point 係數命令送到 MCU(需透過 HTTPS 連線)。
|
||||
- 上傳 CSV 時域資料,套用目前差分方程並繪製輸入/輸出波形。
|
||||
- 匯出包含濾波輸出欄位的 CSV。
|
||||
- 支援深色模式 (Dark Mode) 與響應式 UI。
|
||||
- 比較 Ideal Float 與 Fixed-Point 的 Bode 頻率響應。
|
||||
- 支援多個 cascade stages 的頻率響應與時域模擬,並可個別啟用或 bypass。
|
||||
- 手動微調 b/a 係數、a1/a2、δ/r、Q-format 位元數與 fixed-point 整數係數。
|
||||
- 上傳 CSV 時域資料,繪製輸入、Ideal Float 輸出與 Mimic Integer 輸出。
|
||||
- 匯出包含理想浮點與定點整數模擬輸出的 CSV;cascade workflow 可匯出乾淨的 4 欄 CSV。
|
||||
- 使用瀏覽器 Web Serial API 將 fixed-point 係數寫入 MCU。
|
||||
- 時域圖表支援 zoom 後以 `start_idx` / `end_idx` 向後端重取可視區間資料,保留 IIR 完整歷史狀態。
|
||||
- 支援 Dark Mode 與響應式 UI。
|
||||
|
||||
## 專案結構
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
cd /home/wisetop/diff-eq-analyzer
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install -r requirements.txt
|
||||
npm install
|
||||
npm run build
|
||||
bash certs/generate_cert.sh
|
||||
.venv/bin/uvicorn dea_api:app --host 0.0.0.0 --port 8000 --ssl-keyfile certs/key.pem --ssl-certfile certs/cert.pem
|
||||
```
|
||||
|
||||
開啟:
|
||||
|
||||
```text
|
||||
https://192.168.2.58:8000/ui/
|
||||
```
|
||||
|
||||
本專案固定從 FastAPI 提供的 HTTPS UI 使用。不要用 Vite dev server URL 測試 CSV 或 MCU 功能,否則 `/api/...` 可能會回 `Not Found`。
|
||||
|
||||
自簽憑證會讓瀏覽器顯示安全性警告,第一次開啟時需手動信任或選擇繼續前往。
|
||||
|
||||
## Project Layout
|
||||
|
||||
```text
|
||||
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 列表與命令寫入 (後端備用支援)
|
||||
mcu.py MCU serial port 列表與命令寫入
|
||||
schemas.py Pydantic request schema
|
||||
security.py LAN 存取限制與安全標頭
|
||||
validation.py 係數、頻率與有限值驗證
|
||||
src/ Vue 3 前端原始碼
|
||||
certs/ HTTPS 自簽憑證與產生腳本
|
||||
static/ FastAPI 對外提供的靜態 UI
|
||||
static/build/ Vite build 輸出
|
||||
static/build/ Vite production build 輸出
|
||||
tests/ unittest 測試
|
||||
dea.service systemd service 範例
|
||||
chirp_signal.csv CSV 上傳測試範例
|
||||
```
|
||||
|
||||
## 安裝
|
||||
## Architecture
|
||||
|
||||
在目標主機上建立 Python venv 並安裝依賴:
|
||||
| Layer | Responsibility |
|
||||
| --- | --- |
|
||||
| FastAPI | 提供 API、serve `/ui/`、套用 LAN 存取限制與安全標頭 |
|
||||
| `dea/` | 濾波器設計、單濾波器/cascade Bode plot、CSV 處理、fixed-point simulation、驗證 |
|
||||
| Vue UI | 管理係數、Q-format、CSV workflow、Plotly 圖表與 Web Serial 狀態 |
|
||||
| Vite build | 將 `src/` 打包到 `static/build/`,由 FastAPI 在 production 提供 |
|
||||
|
||||
```bash
|
||||
cd /home/wisetop/diff-eq-analyzer
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install -r requirements.txt
|
||||
CSV workflow:
|
||||
|
||||
1. 前端選擇 CSV。
|
||||
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 預設暫存在:
|
||||
|
||||
```text
|
||||
/tmp/diff-eq-analyzer-csv/
|
||||
```
|
||||
|
||||
安裝前端依賴並建置:
|
||||
可用環境變數覆寫:
|
||||
|
||||
```bash
|
||||
DEA_TEMP_CSV_DIR=/path/to/csv-cache
|
||||
```
|
||||
|
||||
暫存 CSV 預設保留 24 小時。可用環境變數調整,設為 `0` 可停用清理:
|
||||
|
||||
```bash
|
||||
DEA_TEMP_CSV_TTL_SECONDS=86400
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Filter Design
|
||||
|
||||
在左側控制面板選擇濾波器類型並調整參數。系統會更新 b/a 係數,並繪製 Ideal Float 與 Fixed-Point 的頻率響應。
|
||||
|
||||
支援濾波器:
|
||||
|
||||
```text
|
||||
Lowpass, Highpass, Bandpass, Notch,
|
||||
1P1Z, 2P1Z, 2P2Z,
|
||||
PID, SOGI-Alpha, SOGI-Beta
|
||||
```
|
||||
|
||||
### a1/a2 Fine Tuning
|
||||
|
||||
針對二階濾波器,UI 支援 δ/r 參數化微調。
|
||||
|
||||
| Mode | Formula |
|
||||
| --- | --- |
|
||||
| Float | `a1 = -1 - r`, `a2 = r + δ` |
|
||||
| Fixed | `a1 = -a0 - r`, `a2 = r + δ` |
|
||||
|
||||
反推關係:
|
||||
|
||||
| Mode | δ | r |
|
||||
| --- | --- | --- |
|
||||
| Float | `a1 + a2 + 1` | `-1 - a1` |
|
||||
| Fixed | `a1 + a2 + a0` | `-a0 - a1` |
|
||||
|
||||
桌機可用 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
|
||||
|
||||
時域分析同時計算兩條路徑:
|
||||
|
||||
| Path | Description |
|
||||
| --- | --- |
|
||||
| Ideal Float | 使用 SciPy `signal.lfilter()` 作為理想浮點參考 |
|
||||
| Mimic Integer | 將輸入與係數量化為整數,模擬 MCU / DSP fixed-point 差分方程 |
|
||||
|
||||
Q-format 參數:
|
||||
|
||||
| Parameter | Meaning |
|
||||
| --- | --- |
|
||||
| `shift_in` | 輸入訊號量化位元數 |
|
||||
| `shift_out` | 輸出訊號量化位元數 |
|
||||
| `shift_b` | 前饋 b 係數量化位元數 |
|
||||
| `shift_a` | 回授 a 係數量化位元數 |
|
||||
| `use_round` | 右移時使用 Floor (SRA) 或 Round (+0.5) |
|
||||
|
||||
整數模擬採高精度狀態變數架構:前饋項保留在 `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
|
||||
|
||||
限制與行為:
|
||||
|
||||
| Item | Value |
|
||||
| --- | --- |
|
||||
| 檔案大小上限 | 300 MB |
|
||||
| 時域運算列數上限 | 1,200,000 rows |
|
||||
| 圖表點數上限 | 5000 points |
|
||||
| 格式要求 | 必須有標題列與至少一筆資料 |
|
||||
|
||||
若第一欄像 `time`、`t`、`sec`、`x`、`index`,前端會預設選第二欄作為訊號欄位。
|
||||
|
||||
首頁會自動嘗試載入 `.scratch/examples/preset_signals.csv` 作為 preset waveform;若檔案不存在,UI 仍可正常手動上傳 CSV。preset 路徑可用環境變數覆寫:
|
||||
|
||||
```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 權限。
|
||||
|
||||
| Action | Description |
|
||||
| --- | --- |
|
||||
| 連線 | 點擊「選擇連接埠並連線」,在瀏覽器彈窗選擇 MCU |
|
||||
| 寫值 | 點擊「寫值到 MCU」 |
|
||||
| 限制 | Chrome / Edge,且必須透過 HTTPS 或 localhost |
|
||||
|
||||
寫入命令格式:
|
||||
|
||||
```text
|
||||
bodeplot=b0,b1,b2,a1,a2
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `POST` | `/api/design` | 依濾波器參數產生 b/a 係數 |
|
||||
| `POST` | `/api/bode` | 計算單組 b/a 頻率響應 |
|
||||
| `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` 回傳單濾波器或 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 命令 |
|
||||
|
||||
## Development
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run test:frontend
|
||||
npm run build
|
||||
```
|
||||
|
||||
## HTTPS 設定
|
||||
|
||||
為了支援 Web Serial API(瀏覽器直接存取本機連接埠),後端必須透過 HTTPS 連線提供服務。
|
||||
|
||||
1. **產生自簽憑證**:
|
||||
```bash
|
||||
bash certs/generate_cert.sh
|
||||
```
|
||||
這會在 `certs/` 目錄下產生 `key.pem` 與 `cert.pem`。
|
||||
|
||||
2. **信任憑證**:
|
||||
由於是自簽憑證,瀏覽器會顯示安全性警告。請在「進階」中選擇「繼續前往」或將其加入信任清單。
|
||||
|
||||
## 執行
|
||||
|
||||
### 手動啟動 (HTTPS)
|
||||
開發模式:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
```bash
|
||||
.venv/bin/pip install -r requirements.txt
|
||||
.venv/bin/uvicorn dea_api:app --host 0.0.0.0 --port 8000 --ssl-keyfile certs/key.pem --ssl-certfile certs/cert.pem
|
||||
```
|
||||
|
||||
瀏覽器開啟:`https://<server-ip>:8000/ui/` 或本機 `https://localhost:8000/ui/`
|
||||
### HTTPS Certificate
|
||||
|
||||
根路徑 `/` 會自動導向 `/ui/`。
|
||||
```bash
|
||||
bash certs/generate_cert.sh
|
||||
```
|
||||
|
||||
### 使用 systemd
|
||||
會在 `certs/` 產生:
|
||||
|
||||
1. 檢查 `dea.service` 中的路徑是否正確(需確認 `ExecStart` 已加上 `--ssl-keyfile` 等參數)。
|
||||
2. 啟用並啟動服務:
|
||||
```bash
|
||||
sudo cp dea.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable dea.service
|
||||
sudo systemctl start dea.service
|
||||
```
|
||||
```text
|
||||
cert.pem
|
||||
key.pem
|
||||
```
|
||||
|
||||
### systemd
|
||||
|
||||
```bash
|
||||
sudo cp dea.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable dea.service
|
||||
sudo systemctl start dea.service
|
||||
```
|
||||
|
||||
查看狀態:
|
||||
|
||||
查看狀態與 Log:
|
||||
```bash
|
||||
sudo systemctl status dea.service
|
||||
sudo journalctl -u dea.service -f
|
||||
```
|
||||
|
||||
## MCU 連接埠 (Web Serial)
|
||||
### Backend Serial Fallback
|
||||
|
||||
本工具優先使用瀏覽器的 Web Serial API 直接與 MCU 通訊,無需伺服器端具備 serial 權限。
|
||||
|
||||
- **連線**:點擊 UI 上的「選擇連接埠並連線」,在瀏覽器彈出視窗中選擇正確的 MCU 裝置。
|
||||
- **寫值**:點擊「寫值到 MCU」,系統會將目前定點數係數格式化為 `bodeplot=b0,b1,b2,a1,a2` 並送出。
|
||||
- **限制**:需使用 Chrome 或 Edge 瀏覽器,且必須透過 HTTPS (或 localhost) 連線。
|
||||
|
||||
## 後端 MCU Serial 設定 (備用)
|
||||
|
||||
若無法使用 Web Serial,後端仍保留透過伺服器端實體連線寫入的 API。預設值在 `dea/config.py`,也可用環境變數覆蓋:
|
||||
若無法使用 Web Serial,可改用後端 serial API。環境變數:
|
||||
|
||||
```bash
|
||||
MCU_SERIAL_PORT=/dev/ttyUSB2
|
||||
@@ -111,7 +328,7 @@ MCU_BAUD_RATE=115200
|
||||
MCU_WRITE_TIMEOUT=1.0
|
||||
```
|
||||
|
||||
若使用 systemd,可在 `dea.service` 的 `[Service]` 區塊加入:
|
||||
systemd 可在 `[Service]` 加入:
|
||||
|
||||
```ini
|
||||
Environment="MCU_SERIAL_PORT=/dev/ttyUSB2"
|
||||
@@ -119,70 +336,27 @@ Environment="MCU_BAUD_RATE=115200"
|
||||
Environment="MCU_WRITE_TIMEOUT=1.0"
|
||||
```
|
||||
|
||||
## API
|
||||
## Testing
|
||||
|
||||
目前主要 API:
|
||||
|
||||
- `POST /api/design`:依濾波器參數產生 b/a 係數。
|
||||
- `POST /api/bode`:計算單組 b/a 係數的頻率響應。
|
||||
- `POST /api/bode/compare`:一次計算浮點與 fixed-point 兩組頻率響應。
|
||||
- `POST /api/filter`:上傳 CSV 並回傳圖表用的濾波結果。
|
||||
- `POST /api/filter/download`:上傳 CSV 並下載包含 `<欄位>_filtered` 的結果 CSV。
|
||||
- `GET /api/mcu/ports`:列出伺服器端可用 serial ports (後端模式)。
|
||||
- `POST /api/mcu/write`:送出伺服器端 MCU 命令 (後端模式)。
|
||||
|
||||
MCU 寫入命令格式固定為:
|
||||
|
||||
```text
|
||||
bodeplot=b0,b1,b2,a1,a2
|
||||
```
|
||||
|
||||
## a1/a2 微調 (δ/r 參數)
|
||||
|
||||
針對二階濾波器 (2P1Z/2P2Z),UI 提供了進階的步階微調功能:
|
||||
|
||||
- **δ (Delta)**:控制極點的頻率/阻尼平衡點。
|
||||
- **r**:控制極點的半徑/頻寬。
|
||||
- 公式:`a1 = -a0 - r + δ`,`a2 = r + δ` (定點數模式) 或 `a1 = -1 - r + δ`,`a2 = r + δ` (浮點數模式)。
|
||||
- 支援透過 Shift + 滑鼠滾輪或觸控拖曳進行高精度微調。
|
||||
|
||||
## CSV 上傳
|
||||
|
||||
限制與行為:
|
||||
|
||||
- CSV 檔案大小上限為 32 MB。
|
||||
- CSV 必須有標題列與至少一筆資料。
|
||||
- 可選擇要濾波的訊號欄位;若第一欄像 `time`、`t`、`sec`、`x`、`index`,前端會預設選第二欄。
|
||||
- 圖表回應最多回傳 5000 點,避免大型 CSV 讓前端過重。
|
||||
- 下載 CSV 會保留完整資料,不套用圖表降採樣。
|
||||
|
||||
專案根目錄的 `chirp_signal.csv` 是上傳測試範例,約 19 MB、500001 行,欄位為:
|
||||
|
||||
```text
|
||||
time,value
|
||||
```
|
||||
|
||||
## 測試與檢查
|
||||
|
||||
Python 語法檢查:
|
||||
Python syntax check:
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m py_compile dea_api.py tests/test_dea_api.py
|
||||
```
|
||||
|
||||
後端測試使用 Python 內建 `unittest`,不需要安裝或執行 `pytest`:
|
||||
Backend tests 使用 Python 內建 `unittest`:
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m unittest discover -s tests
|
||||
```
|
||||
|
||||
前端建置檢查:
|
||||
Frontend build check:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
建議完整驗證順序:
|
||||
建議完整驗證:
|
||||
|
||||
```bash
|
||||
.venv/bin/pip install -r requirements.txt
|
||||
@@ -191,29 +365,47 @@ npm run build
|
||||
sudo systemctl restart dea.service
|
||||
```
|
||||
|
||||
## 安全與限制
|
||||
## Security And Limits
|
||||
|
||||
- API middleware 只允許 private / loopback IP 存取。
|
||||
- 回應會加上 CSP、`Referrer-Policy`、`X-Content-Type-Options: nosniff`、`X-Frame-Options: DENY`。
|
||||
- CSP 的 script 來源允許本機與 Plotly CDN。
|
||||
- 係數數量上限為 64。
|
||||
- CSV 上限為 32 MB。
|
||||
- 頻率、係數、`a[0]` 與 CSV 訊號欄位都有後端驗證。
|
||||
| Item | Value |
|
||||
| --- | --- |
|
||||
| Network access | 只允許 private / loopback IP |
|
||||
| CSV size | 300 MB |
|
||||
| Time-domain rows | 1,200,000 rows |
|
||||
| Plot points | 5000 points |
|
||||
| Coefficients | 最多 64 個 |
|
||||
|
||||
## 常見問題
|
||||
HTTP responses 會加上 CSP、`Referrer-Policy`、`X-Content-Type-Options: nosniff`、`X-Frame-Options: DENY`。
|
||||
|
||||
### 為什麼無法連線連接埠?
|
||||
1. 確認是否使用 HTTPS 連線。
|
||||
2. 確認瀏覽器是否支援 Web Serial API (推薦使用 Chrome/Edge)。
|
||||
3. 檢查 USB 傳輸線是否連接正確。
|
||||
## 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://192.168.2.58:8000/ui/`。
|
||||
2. 確認瀏覽器支援 Web Serial API。
|
||||
3. 使用 Chrome 或 Edge。
|
||||
4. 檢查 USB 線與 MCU 裝置狀態。
|
||||
|
||||
### 頁面沒有更新
|
||||
1. 瀏覽器按 `Ctrl+F5` 強制重新整理。
|
||||
2. 執行 `npm run build` 重新建置前端。
|
||||
3. 執行 `sudo systemctl restart dea.service`。
|
||||
|
||||
1. 執行 `npm run build`。
|
||||
2. 重啟服務:`sudo systemctl restart dea.service`。
|
||||
3. 瀏覽器按 `Ctrl+F5` 強制重新整理。
|
||||
|
||||
### systemd 顯示 `status=203/EXEC`
|
||||
|
||||
通常是 `.venv` 或 `uvicorn` 路徑不正確。可重建 venv:
|
||||
|
||||
```bash
|
||||
rm -rf .venv
|
||||
python3 -m venv .venv
|
||||
@@ -222,7 +414,9 @@ sudo systemctl restart dea.service
|
||||
```
|
||||
|
||||
### 找不到 pytest
|
||||
|
||||
本專案不使用 pytest。請改用:
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m unittest discover -s tests
|
||||
```
|
||||
|
||||
-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(),
|
||||
},
|
||||
}
|
||||
|
||||
+167
-47
@@ -2,6 +2,8 @@ import io
|
||||
import os
|
||||
import uuid
|
||||
import re
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
@@ -11,10 +13,37 @@ import math
|
||||
|
||||
from .config import MAX_CSV_BYTES, MAX_PLOT_POINTS, MAX_ROWS
|
||||
|
||||
TEMP_CSV_DIR = "temp_csv"
|
||||
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")
|
||||
@@ -23,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:
|
||||
@@ -31,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 檔案")
|
||||
@@ -40,7 +112,7 @@ async def save_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 不可為空")
|
||||
|
||||
|
||||
file_id = str(uuid.uuid4())
|
||||
file_path = os.path.join(TEMP_CSV_DIR, f"{file_id}.csv")
|
||||
with open(file_path, "wb") as f:
|
||||
@@ -57,20 +129,20 @@ def integer_lfilter(b_int, a_int, x_float, shift_in, shift_out, shift_b, shift_a
|
||||
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。
|
||||
@@ -87,22 +159,22 @@ def integer_lfilter(b_int, a_int, x_float, shift_in, shift_out, shift_b, shift_a
|
||||
for i in range(nb):
|
||||
if n - i >= 0:
|
||||
sum_b += b_int[i] * x_int[n - i]
|
||||
|
||||
|
||||
# Feedback: 歷史紀錄為 Q_{in + b},係數為 Q_a,乘積為 Q_{in + b + a}
|
||||
sum_a = 0
|
||||
for j in range(1, na):
|
||||
if n - j >= 0:
|
||||
sum_a += a_int[j] * y_hist[n - j]
|
||||
|
||||
|
||||
# Feedback 縮放:前提 A0 = 1 (或者代表放大了 Q_a 倍的常數),除以 A0 (即 >> shift_a) 歸一化
|
||||
# Python 的 // 等同於硬體的 SRA (Arithmetic Right Shift),會向負無窮大 Floor
|
||||
sum_a_scaled = (sum_a + round_offset_a) // A0
|
||||
|
||||
|
||||
acc = sum_b - sum_a_scaled
|
||||
|
||||
|
||||
# 將超高精度的 acc 直接存入歷史變數
|
||||
y_hist[n] = acc
|
||||
|
||||
|
||||
# 最終輸出再針對 Q_out 進行位移縮放
|
||||
if out_shift > 0:
|
||||
y_out[n] = (acc + round_offset_out) >> out_shift
|
||||
@@ -114,35 +186,76 @@ 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 讀取後找不到原始索引
|
||||
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 = 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} 含有非有限數值")
|
||||
x_signal, x_values = _numeric_signal(df[col_to_filter], col_to_filter)
|
||||
|
||||
# 路徑 1: 理想浮點數路徑
|
||||
y_float = signal.lfilter(b_vals, a_vals, x_values)
|
||||
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,
|
||||
)
|
||||
|
||||
# 路徑 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
|
||||
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
|
||||
|
||||
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(),
|
||||
@@ -150,32 +263,39 @@ 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)
|
||||
|
||||
|
||||
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()
|
||||
x_signal, x_values = _numeric_signal(df[col_to_filter], 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,
|
||||
)
|
||||
|
||||
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:
|
||||
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)
|
||||
|
||||
csv_buffer = io.StringIO()
|
||||
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)
|
||||
|
||||
+73
-14
@@ -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,
|
||||
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_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(
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
}
|
||||
]
|
||||
}
|
||||
+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>
|
||||
|
||||
Generated
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "bodeplot",
|
||||
"name": "diff-eq-analyzer",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
|
||||
+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",
|
||||
|
||||
+444
-308
File diff suppressed because it is too large
Load Diff
+598
-48
@@ -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'
|
||||
@@ -80,6 +101,8 @@ export default {
|
||||
webSerialSupported: false,
|
||||
writingMCU: false,
|
||||
mcuStatus: '',
|
||||
saveSettingsTimeout: null,
|
||||
isSidebarCollapsed: localStorage.getItem('dea_sidebar_collapsed') === 'true',
|
||||
bodeMagRange: null, // [min, max] for Y-axis
|
||||
}
|
||||
},
|
||||
@@ -221,6 +244,60 @@ export default {
|
||||
return this.fixedCurrentA1 + this.fixedCurrentA2;
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
fs: 'debouncedSaveSettings',
|
||||
b_str() {
|
||||
this.debouncedSaveSettings();
|
||||
this.debouncedProcessFilter();
|
||||
},
|
||||
a_str() {
|
||||
this.debouncedSaveSettings();
|
||||
this.debouncedProcessFilter();
|
||||
},
|
||||
filterType: 'debouncedSaveSettings',
|
||||
systemGain: 'debouncedSaveSettings',
|
||||
params: { handler: 'debouncedSaveSettings', deep: true },
|
||||
baseB: { handler: 'debouncedSaveSettings', deep: true },
|
||||
baseA: { handler: 'debouncedSaveSettings', deep: true },
|
||||
bSliders: { handler: 'debouncedSaveSettings', deep: true },
|
||||
aSliders: { handler: 'debouncedSaveSettings', deep: true },
|
||||
sense_b: 'debouncedSaveSettings',
|
||||
sense_a: 'debouncedSaveSettings',
|
||||
shiftBitsB() {
|
||||
this.debouncedSaveSettings();
|
||||
this.debouncedProcessFilter();
|
||||
},
|
||||
shiftBitsA() {
|
||||
this.debouncedSaveSettings();
|
||||
this.debouncedProcessFilter();
|
||||
},
|
||||
shiftBitsIn() {
|
||||
this.debouncedSaveSettings();
|
||||
this.debouncedProcessFilter();
|
||||
},
|
||||
shiftBitsOut() {
|
||||
this.debouncedSaveSettings();
|
||||
this.debouncedProcessFilter();
|
||||
},
|
||||
useRound() {
|
||||
this.debouncedSaveSettings();
|
||||
this.debouncedProcessFilter();
|
||||
},
|
||||
fixedOverrides: { handler: 'debouncedSaveSettings', deep: true },
|
||||
aFineStep: 'debouncedSaveSettings',
|
||||
fixedAFineStep: 'debouncedSaveSettings',
|
||||
b_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;
|
||||
window.addEventListener('pointerdown', this.rememberPointerType);
|
||||
@@ -232,12 +309,17 @@ export default {
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
|
||||
this.loadSettings();
|
||||
|
||||
this.webSerialSupported = ('serial' in navigator);
|
||||
if (!this.webSerialSupported) {
|
||||
this.mcuStatus = '此瀏覽器不支援 Web Serial(需使用 Chrome 或 Edge,且透過 HTTPS 連線)';
|
||||
}
|
||||
this.ensureCascadeStages();
|
||||
this.updateBodeMagRange();
|
||||
this.applyFilterDesign();
|
||||
this.loadPresetWaveforms();
|
||||
},
|
||||
beforeUnmount() {
|
||||
window.removeEventListener('pointerdown', this.rememberPointerType);
|
||||
@@ -253,6 +335,362 @@ 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');
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
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', 'cascadeStages', 'activeCascadeStageIndex'
|
||||
];
|
||||
keys.forEach(k => {
|
||||
if (parsed[k] !== undefined) {
|
||||
this[k] = parsed[k];
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load settings:', e);
|
||||
}
|
||||
},
|
||||
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', 'cascadeStages', 'activeCascadeStageIndex'
|
||||
];
|
||||
const settings = {};
|
||||
keys.forEach(k => settings[k] = this[k]);
|
||||
localStorage.setItem('dea_settings', JSON.stringify(settings));
|
||||
},
|
||||
debouncedSaveSettings() {
|
||||
clearTimeout(this.saveSettingsTimeout);
|
||||
this.saveSettingsTimeout = setTimeout(() => this.saveSettings(), 500);
|
||||
},
|
||||
rememberPointerType(event) {
|
||||
if (event.pointerType === 'touch') {
|
||||
this.isTouchInput = true;
|
||||
@@ -734,7 +1172,7 @@ export default {
|
||||
this.outOfRangeB[i] = false;
|
||||
if (base !== 0 && val !== 0 && (val / (base * gain)) > 0) {
|
||||
let logVal = Math.log10(val / (base * gain));
|
||||
if (Math.abs(logVal) < 1e-12) logVal = 0; // 消除浮點數微小誤差
|
||||
if (Math.abs(logVal) < 1e-8) logVal = 0; // 消除浮點數微小誤差
|
||||
if (Math.abs(logVal) > this.maxLogB) this.outOfRangeB[i] = true;
|
||||
this.bSliders[i] = Math.max(-this.maxLogB, Math.min(this.maxLogB, logVal));
|
||||
} else {
|
||||
@@ -749,7 +1187,7 @@ export default {
|
||||
this.outOfRangeA[i] = false;
|
||||
if (base !== 0 && val !== 0 && (val / base) > 0) {
|
||||
let logVal = Math.log10(val / base);
|
||||
if (Math.abs(logVal) < 1e-12) logVal = 0; // 消除浮點數微小誤差
|
||||
if (Math.abs(logVal) < 1e-8) logVal = 0; // 消除浮點數微小誤差
|
||||
if (Math.abs(logVal) > this.maxLogA) this.outOfRangeA[i] = true;
|
||||
this.aSliders[i] = Math.max(-this.maxLogA, Math.min(this.maxLogA, logVal));
|
||||
} else {
|
||||
@@ -920,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;
|
||||
@@ -1052,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 },
|
||||
@@ -1067,38 +1503,38 @@ export default {
|
||||
xaxis: { ...xAxisCommon, anchor: 'y' },
|
||||
yaxis: {
|
||||
title: { text: 'Mag (dB)', font: { size: isSmallScreen ? 12 : 14 } },
|
||||
range: this.bodeMagRange || [-70, 0],
|
||||
range: this.bodeMagRange || [-70, 0],
|
||||
gridcolor: gridColor,
|
||||
zerolinecolor: zeroLineColor,
|
||||
tickfont: { color: textColor, size: isSmallScreen ? 11 : 12 },
|
||||
fixedrange: false // 允許使用者手動縮放或點擊 Auto Scale
|
||||
},
|
||||
xaxis2: {
|
||||
type: 'log',
|
||||
xaxis2: {
|
||||
type: 'log',
|
||||
title: { text: 'Freq (Hz)', font: { size: isSmallScreen ? 12 : 14 } },
|
||||
tickvals: xTicks,
|
||||
tickvals: xTicks,
|
||||
ticktext: xTexts,
|
||||
showgrid: true,
|
||||
showgrid: true,
|
||||
gridcolor: gridColor,
|
||||
tickfont: { color: textColor, size: isSmallScreen ? 11 : 12 },
|
||||
nticks: isSmallScreen ? 6 : 10,
|
||||
matches: 'x',
|
||||
anchor: 'y2'
|
||||
},
|
||||
yaxis2: {
|
||||
title: { text: 'Phase (°)', font: { size: isSmallScreen ? 12 : 14 } },
|
||||
range: [-180, 180],
|
||||
tickvals: [-180, -90, 0, 90, 180],
|
||||
gridcolor: gridColor,
|
||||
zerolinecolor: zeroLineColor,
|
||||
matches: 'x',
|
||||
anchor: 'y2'
|
||||
},
|
||||
yaxis2: {
|
||||
title: { text: 'Phase (°)', font: { size: isSmallScreen ? 12 : 14 } },
|
||||
range: [-180, 180],
|
||||
tickvals: [-180, -90, 0, 90, 180],
|
||||
gridcolor: gridColor,
|
||||
zerolinecolor: zeroLineColor,
|
||||
tickfont: { color: textColor, size: isSmallScreen ? 11 : 12 },
|
||||
fixedrange: true // 相位縱軸鎖死,不可縮放
|
||||
},
|
||||
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' };
|
||||
@@ -1157,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) {
|
||||
@@ -1181,6 +1663,7 @@ export default {
|
||||
return;
|
||||
}
|
||||
|
||||
const previewBytes = Math.min(file.size, 1024 * 1024);
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
try {
|
||||
@@ -1203,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,
|
||||
};
|
||||
@@ -1213,15 +1697,23 @@ export default {
|
||||
this.selectedColumn = 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 背景上傳至暫存區
|
||||
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;
|
||||
|
||||
|
||||
} catch (err) {
|
||||
this.csvParseError = err.message || 'CSV 解析失敗';
|
||||
this.csvColumns = [];
|
||||
@@ -1229,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() {
|
||||
@@ -1247,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();
|
||||
@@ -1265,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 || '濾波處理失敗'); }
|
||||
@@ -1288,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() {
|
||||
@@ -1317,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; }
|
||||
@@ -1372,6 +1933,7 @@ export default {
|
||||
},
|
||||
|
||||
async writeToMCU() {
|
||||
const stageNumber = this.activeCascadeStageIndex + 1;
|
||||
if (!this.mcuSerialPort || !this.mcuConnected) {
|
||||
this.mcuStatus = '請先連線 MCU 連接埠';
|
||||
return;
|
||||
@@ -1389,25 +1951,13 @@ 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;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
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(); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
+1588
-1668
File diff suppressed because it is too large
Load Diff
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)
|
||||
|
||||
@@ -4,15 +4,6 @@ import vue from '@vitejs/plugin-vue'
|
||||
export default defineConfig({
|
||||
base: './',
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'https://127.0.0.1:8000',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'static/build',
|
||||
emptyOutDir: true,
|
||||
|
||||
Reference in New Issue
Block a user