4 Commits

Author SHA1 Message Date
ws50529 345b4a789a feat: add VS Code workspace configuration file 2026-05-20 13:47:52 +08:00
ws50529 8009a0dd78 Merge remote-tracking branch 'origin/dev-v2-integretimedomain' into integrate/dev-v2-into-main 2026-05-20 13:44:31 +08:00
ws50529 2762d0da86 chore: update code 2026-05-15 16:12:47 +08:00
ws50529 da2cbfe8da refactor: update UI components theme color 2026-05-15 14:50:54 +08:00
14 changed files with 2098 additions and 2631 deletions
-137
View File
@@ -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**| **是** | **否,完全不需啟用!** |
-293
View File
@@ -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)
```
-114
View File
@@ -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*
Executable → Regular
+236 -122
View File
@@ -1,109 +1,256 @@
# 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 與 PandasFrontend 使用 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 頻率響應。
- 手動微調 b/a 係數、a1/a2、δ/r、Q-format 位元數與 fixed-point 整數係數。
- 上傳 CSV 時域資料,繪製輸入、Ideal Float 輸出與 Mimic Integer 輸出
- 匯出包含理想浮點與定點整數模擬輸出的 CSV
- 使用瀏覽器 Web Serial API 將 fixed-point 係數寫入 MCU
- 支援 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://localhost:8000/ui/
```
若從其他主機連線,改用:
```text
https://<server-ip>:8000/ui/
```
自簽憑證會讓瀏覽器顯示安全性警告,需手動信任或選擇繼續前往。
## Project Layout
```text
dea_api.py FastAPI 入口與 HTTP route
dea/ 後端核心模組
bode.py 頻率響應計算
config.py 限制值與伺服器端 serial 設定
csv_processing.py CSV 取、驗證、濾波與匯出
csv_processing.py CSV 取、驗證、濾波與匯出
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/` | 濾波器設計、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. 前端呼叫 `POST /api/csv/upload`,後端將檔案暫存並回傳 `file_id`
3. 後續 `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
```
## 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 + 滑鼠滾輪微調;觸控裝置可按住數值面板上下拖曳。
### 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` 縮放。
### CSV Workflow
限制與行為:
| Item | Value |
| --- | --- |
| 檔案大小上限 | 300 MB |
| 時域運算列數上限 | 1,200,000 rows |
| 圖表點數上限 | 5000 points |
| 格式要求 | 必須有標題列與至少一筆資料 |
若第一欄像 `time``t``sec``x``index`,前端會預設選第二欄作為訊號欄位。
範例檔案:
```text
chirp_signal.csv
```
欄位:
```text
time,value
```
### 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/csv/upload` | 上傳 CSV 並回傳 `file_id` |
| `POST` | `/api/filter` | 使用 `file_id` 回傳時域圖表資料 |
| `POST` | `/api/filter/download` | 使用 `file_id` 匯出結果 CSV |
| `GET` | `/api/mcu/ports` | 列出伺服器端 serial ports |
| `POST` | `/api/mcu/write` | 後端模式寫入 MCU 命令 |
## Development
### Frontend
```bash
npm install
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
```
目前 `vite.config.js` 保留 `main` 設定,不在 Vite 內設定 `/api` proxy。
### 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 +258,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 +266,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 +295,37 @@ 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
### 無法連線 MCU
1. 確認使用 HTTPS 或 localhost。
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 +334,9 @@ sudo systemctl restart dea.service
```
### 找不到 pytest
本專案不使用 pytest。請改用:
```bash
.venv/bin/python -m unittest discover -s tests
```
+22 -19
View File
@@ -2,6 +2,7 @@ import io
import os
import uuid
import re
import tempfile
import numpy as np
import pandas as pd
@@ -11,7 +12,10 @@ 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"),
)
os.makedirs(TEMP_CSV_DIR, exist_ok=True)
def get_cached_file_path(file_id):
@@ -40,7 +44,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 +61,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 +91,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
@@ -116,17 +120,17 @@ def integer_lfilter(b_int, a_int, x_float, shift_in, shift_out, shift_b, shift_a
def filter_preview_response(file_id, b_vals, a_vals, col_idx, b_int=None, a_int=None, shift_in=14, shift_out=14, shift_b=14, shift_a=14, use_round=False):
path = get_cached_file_path(file_id)
# 預先讀取欄位名稱,避免用 usecols 讀取後找不到原始索引
cols = pd.read_csv(path, nrows=0).columns.tolist()
if col_idx < 0 or col_idx >= len(cols):
raise HTTPException(status_code=400, detail="欄位索引超出範圍")
col_to_filter = 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)
@@ -160,22 +164,21 @@ def filtered_csv_text(file_id, b_vals, a_vals, col_idx, b_int=None, a_int=None,
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()
+6 -6
View File
@@ -116,14 +116,14 @@ async def filter_csv(
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
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
)
@@ -171,10 +171,10 @@ async def filter_csv_download(
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
csv_text = filtered_csv_text(
file_id, b_vals, a_vals, col_idx,
b_int=b_int_vals, a_int=a_int_vals,
+7
View File
@@ -0,0 +1,7 @@
{
"folders": [
{
"path": "."
}
]
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "bodeplot",
"name": "diff-eq-analyzer",
"lockfileVersion": 3,
"requires": true,
"packages": {
+231 -229
View File
@@ -18,7 +18,7 @@
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-13a1 1 0 10-2 0 1 1 0 002 0zM2 10a1 1 0 011-1h1a1 1 0 110 2H3a1 1 0 01-1-1zm14 0a1 1 0 011-1h1a1 1 0 110 2h-1a1 1 0 01-1-1zM5.05 5.05a1 1 0 011.414 0l.707.707a1 1 0 11-1.414 1.414l-.707-.707a1 1 0 010-1.414zm9.9 9.9a1 1 0 011.414 0l.707.707a1 1 0 11-1.414 1.414l-.707-.707a1 1 0 010-1.414zM5.05 14.95a1 1 0 010-1.414l.707-.707a1 1 0 011.414 1.414l-.707.707a1 1 0 01-1.414 0zm9.9-9.9a1 1 0 010-1.414l.707-.707a1 1 0 011.414 1.414l-.707.707a1 1 0 01-1.414 0z"
clip-rule="evenodd" />
</svg>
<svg v-else class="w-5 h-5 text-slate-700" fill="currentColor" viewBox="0 0 20 20">
<svg v-else class="w-5 h-5 text-slate-800" fill="currentColor" viewBox="0 0 20 20">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
</svg>
</button>
@@ -29,7 +29,7 @@
class="lg:hidden flex border-b border-slate-200 dark:border-gray-800 bg-white dark:bg-dark z-10 flex-shrink-0">
<button @click="mobileTab = 'settings'" :class="mobileTab === 'settings'
? 'border-b-2 role-border-primary role-text-primary bg-transparent'
: 'text-slate-500 dark:text-gray-400 hover:text-slate-700 dark:hover:text-gray-200'"
: 'text-slate-600 dark:text-gray-400 hover:text-slate-800 dark:hover:text-gray-200'"
class="flex-1 flex items-center justify-center gap-2 py-3 text-sm font-semibold transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
@@ -41,7 +41,7 @@
</button>
<button @click="mobileTab = 'chart'" :class="mobileTab === 'chart'
? 'border-b-2 role-border-primary role-text-primary bg-transparent'
: 'text-slate-500 dark:text-gray-400 hover:text-slate-700 dark:hover:text-gray-200'"
: 'text-slate-600 dark:text-gray-400 hover:text-slate-800 dark:hover:text-gray-200'"
class="flex-1 flex items-center justify-center gap-2 py-3 text-sm font-semibold transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
@@ -55,12 +55,12 @@
<div class="flex flex-col lg:flex-row flex-1 overflow-hidden">
<!-- 左側控制面板 (RWD: 手機版=設定頁籤全螢幕桌面版=左側邊欄) -->
<aside :class="mobileTab === 'settings' ? 'flex' : 'hidden'"
class="lg:flex flex-col w-full lg:w-96 h-full bg-white dark:bg-dark border-b lg:border-b-0 lg:border-r border-slate-200 dark:border-gray-800 overflow-y-auto p-4 gap-3 custom-scrollbar transition-colors duration-300">
class="lg:flex flex-col w-full lg:w-96 h-full bg-white dark:bg-dark border-b lg:border-b-0 lg:border-r border-slate-200 dark:border-gray-800 overflow-y-auto p-5 gap-5 custom-scrollbar transition-colors duration-300">
<!-- 濾波器設計工具 -->
<section>
<h2
class="text-sm font-semibold text-slate-500 dark:text-gray-400 uppercase tracking-wider mb-2 flex items-center justify-between">
class="text-sm font-semibold text-slate-600 dark:text-gray-400 uppercase tracking-wider mb-3 flex items-center justify-between">
<div class="flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
@@ -74,14 +74,14 @@
<div class="flex items-center gap-2">
<button @click="writeToMCU"
:disabled="writingMCU || !mcuConnected"
class="primary-action text-[10px] font-bold role-bg-primary role-hover-primary role-text-on-fill role-border-primary border px-2 py-1 rounded transition-colors uppercase disabled:opacity-50 disabled:cursor-not-allowed">
class="primary-action text-[10px] font-bold role-bg-primary role-hover-primary role-text-on-fill role-border-primary border px-3 py-1 rounded-full transition-colors uppercase disabled:opacity-50 disabled:cursor-not-allowed">
{{ writingMCU ? '寫入中' : '寫值到 MCU' }}
</button>
<button @click="resetFilterParams"
class="text-[10px] font-bold role-bg-secondary-soft role-border-secondary-soft role-text-secondary role-hover-secondary-soft border px-2 py-1 rounded transition-colors uppercase">重設設計</button>
class="text-[10px] font-bold border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-700 dark:text-gray-300 hover:bg-slate-100 dark:hover:bg-gray-700 px-3 py-1 rounded-full transition-colors uppercase">重設設計</button>
</div>
</h2>
<div class="mb-2 space-y-1.5">
<div class="mb-3 space-y-2">
<div v-if="webSerialSupported" class="flex items-center gap-2">
<button v-if="!mcuConnected" @click="connectMCUPort"
class="flex-1 text-xs font-bold role-bg-primary role-hover-primary role-text-on-fill role-border-primary border px-3 py-2 rounded transition-colors uppercase">
@@ -93,7 +93,7 @@
MCU 已連線
</span>
<button @click="disconnectMCUPort"
class="text-[10px] font-bold bg-slate-200 dark:bg-gray-800 text-slate-600 dark:text-gray-300 hover:bg-slate-300 dark:hover:bg-gray-700 px-2 py-2 rounded transition-colors uppercase">
class="text-[10px] font-bold bg-slate-200 dark:bg-gray-800 text-slate-700 dark:text-gray-300 hover:bg-slate-300 dark:hover:bg-gray-700 px-2 py-2 rounded transition-colors uppercase">
斷線
</button>
</template>
@@ -101,73 +101,73 @@
<p v-if="!webSerialSupported" class="text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg px-3 py-2">
此瀏覽器不支援 Web SerialMCU 寫值功能需使用 Chrome Edge且透過 HTTPS 連線
</p>
<p v-if="mcuStatus" class="text-xs text-slate-500 dark:text-gray-400">{{ mcuStatus }}</p>
<p v-if="mcuStatus" class="text-xs text-slate-600 dark:text-gray-400">{{ mcuStatus }}</p>
</div>
<select v-model="filterType" @change="onFilterTypeChange"
class="w-full bg-slate-50 dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-2 text-base role-focus-primary focus:ring-1 role-focus-ring-primary outline-none transition-all mb-2 text-slate-900 dark:text-gray-100 truncate">
class="w-full bg-slate-50 dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-base role-focus-primary focus:ring-1 role-focus-ring-primary outline-none transition-all mb-3 text-slate-900 dark:text-gray-100 truncate">
<option v-for="opt in filterOptions" :key="opt" :value="opt">{{ opt }}</option>
</select>
<!-- 動態參數區 -->
<div v-if="filterType !== '() 手動自訂'"
class="space-y-2 bg-slate-100 dark:bg-gray-900 p-3 rounded-lg border border-slate-200 dark:border-gray-800 shadow-inner">
class="space-y-3 bg-slate-100 dark:bg-gray-900 p-4 rounded-lg border border-slate-200 dark:border-gray-800 shadow-inner">
<!-- Lowpass / Highpass -->
<div v-if="['Lowpass (低通)', 'Highpass (高通)'].includes(filterType)">
<label class="text-sm text-slate-500 dark:text-gray-400 flex justify-between mb-1"><span>截止頻率
<label class="text-sm text-slate-600 dark:text-gray-400 flex justify-between mb-1"><span>截止頻率
f_c (Hz)</span><span class="role-text-primary">{{
formatK(params.fc) }}</span></label>
<input type="range" v-model.number="params.fc" @input="debouncedApply" :min="1" :max="fs/2"
class="w-full role-range-primary mb-2">
<input type="number" v-model.number="params.fc" @change="applyFilterDesign"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-2 text-base role-focus-primary outline-none text-slate-900 dark:text-gray-100">
<label class="text-sm text-slate-500 dark:text-gray-400 mt-1.5 mb-1 block">階數 Order</label>
<div class="selector-button-group flex gap-1">
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-3 text-base role-focus-primary outline-none text-slate-900 dark:text-gray-100">
<label class="text-sm text-slate-600 dark:text-gray-400 mt-3 mb-2 block">階數 Order</label>
<div class="selector-button-group flex gap-2">
<button v-for="o in [1,2,3]" :key="o" @click="params.order = o; applyFilterDesign()"
:class="params.order === o ? 'primary-action role-bg-primary role-text-on-fill role-border-primary' : 'bg-slate-200 dark:bg-gray-800 text-slate-600 dark:text-gray-400 border-slate-300 dark:border-gray-700'"
class="selector-button flex-1 rounded py-0.5 text-sm font-medium border transition-colors">{{ o
:class="params.order === o ? 'primary-action role-bg-primary role-text-on-fill role-border-primary' : 'bg-slate-200 dark:bg-gray-800 text-slate-700 dark:text-gray-400 border-slate-300 dark:border-gray-700'"
class="selector-button flex-1 rounded py-2.5 text-base font-medium border transition-colors">{{ o
}}</button>
</div>
</div>
<!-- Bandpass -->
<div v-if="filterType === 'Bandpass (帶通)'">
<label class="text-sm text-slate-500 dark:text-gray-400 flex justify-between mb-1"><span>下截止頻率
<label class="text-sm text-slate-600 dark:text-gray-400 flex justify-between mb-1"><span>下截止頻率
f_low (Hz)</span><span class="role-text-primary">{{
formatK(params.bp_f_low) }}</span></label>
<input type="number" v-model.number="params.bp_f_low" @change="applyFilterDesign"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-3 text-base mb-2 text-slate-900 dark:text-gray-100">
<label class="text-sm text-slate-500 dark:text-gray-400 flex justify-between mb-1"><span>上截止頻率
<label class="text-sm text-slate-600 dark:text-gray-400 flex justify-between mb-1"><span>上截止頻率
f_high (Hz)</span><span class="role-text-primary">{{
formatK(params.bp_f_high) }}</span></label>
<input type="number" v-model.number="params.bp_f_high" @change="applyFilterDesign"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-2 text-base mb-1 text-slate-900 dark:text-gray-100">
<label class="text-sm text-slate-500 dark:text-gray-400 mt-1.5 mb-1 block">階數 Order</label>
<div class="selector-button-group flex gap-1">
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-3 text-base mb-2 text-slate-900 dark:text-gray-100">
<label class="text-sm text-slate-600 dark:text-gray-400 mt-2 mb-2 block">階數 Order</label>
<div class="selector-button-group flex gap-2">
<button v-for="o in [1,2,3]" :key="o" @click="params.bp_order = o; applyFilterDesign()"
:class="params.bp_order === o ? 'primary-action role-bg-primary role-text-on-fill role-border-primary' : 'bg-slate-200 dark:bg-gray-800 text-slate-600 dark:text-gray-400 border-slate-300 dark:border-gray-700'"
class="selector-button flex-1 rounded py-0.5 text-sm border transition-colors">{{ o }}</button>
:class="params.bp_order === o ? 'primary-action role-bg-primary role-text-on-fill role-border-primary' : 'bg-slate-200 dark:bg-gray-800 text-slate-700 dark:text-gray-400 border-slate-300 dark:border-gray-700'"
class="selector-button flex-1 rounded py-1.5 text-sm border transition-colors">{{ o }}</button>
</div>
</div>
<!-- Notch -->
<div v-if="filterType === 'Notch (陷波器)'" class="space-y-2">
<div><label class="text-sm text-slate-500 dark:text-gray-400 mb-1 block">中心頻率 f_0
<div v-if="filterType === 'Notch (陷波器)'" class="space-y-3">
<div><label class="text-sm text-slate-600 dark:text-gray-400 mb-2 block">中心頻率 f_0
(Hz)</label><input type="number" v-model.number="params.notch_f0"
@change="applyFilterDesign"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-2 text-base text-slate-900 dark:text-gray-100">
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-3 text-base text-slate-900 dark:text-gray-100">
</div>
<div><label class="text-sm text-slate-500 dark:text-gray-400 mb-1 block">品質因數
<div><label class="text-sm text-slate-600 dark:text-gray-400 mb-2 block">品質因數
Q</label><input type="number" v-model.number="params.notch_q"
@change="applyFilterDesign" step="0.1"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-2 text-base text-slate-900 dark:text-gray-100">
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-3 text-base text-slate-900 dark:text-gray-100">
</div>
</div>
<!-- 1P1Z -->
<div v-if="filterType === '1P1Z (一極一零)'" class="space-y-3">
<div><label class="text-sm text-slate-500 dark:text-gray-400 mb-2 block">零點頻率 Freq_z
<div><label class="text-sm text-slate-600 dark:text-gray-400 mb-2 block">零點頻率 Freq_z
(Hz)</label><input type="number" v-model.number="params.opoz_fz"
@change="applyFilterDesign"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-3 text-base text-slate-900 dark:text-gray-100">
</div>
<div><label class="text-sm text-slate-500 dark:text-gray-400 mb-2 block">極點頻率 Freq_p
<div><label class="text-sm text-slate-600 dark:text-gray-400 mb-2 block">極點頻率 Freq_p
(Hz)</label><input type="number" v-model.number="params.opoz_fp"
@change="applyFilterDesign"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-3 text-base text-slate-900 dark:text-gray-100">
@@ -175,17 +175,17 @@
</div>
<!-- 2P1Z -->
<div v-if="filterType === '2P1Z (二極一零)'" class="space-y-3">
<div><label class="text-sm text-slate-500 dark:text-gray-400 mb-2 block">零點頻率 Freq_z
<div><label class="text-sm text-slate-600 dark:text-gray-400 mb-2 block">零點頻率 Freq_z
(Hz)</label><input type="number" v-model.number="params.tp1z_fz"
@change="applyFilterDesign"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-3 text-base text-slate-900 dark:text-gray-100">
</div>
<div><label class="text-sm text-slate-500 dark:text-gray-400 mb-2 block">極點頻率 1 Freq_p1
<div><label class="text-sm text-slate-600 dark:text-gray-400 mb-2 block">極點頻率 1 Freq_p1
(Hz)</label><input type="number" v-model.number="params.tp1z_fp1"
@change="applyFilterDesign"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-3 text-base text-slate-900 dark:text-gray-100">
</div>
<div><label class="text-sm text-slate-500 dark:text-gray-400 mb-2 block">極點頻率 2 Freq_p2
<div><label class="text-sm text-slate-600 dark:text-gray-400 mb-2 block">極點頻率 2 Freq_p2
(Hz)</label><input type="number" v-model.number="params.tp1z_fp2"
@change="applyFilterDesign"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-3 text-base text-slate-900 dark:text-gray-100">
@@ -193,22 +193,22 @@
</div>
<!-- 2P2Z -->
<div v-if="filterType === '2P2Z (二極二零)'" class="space-y-3">
<div><label class="text-sm text-slate-500 dark:text-gray-400 mb-2 block">零點頻率 1 Freq_z1
<div><label class="text-sm text-slate-600 dark:text-gray-400 mb-2 block">零點頻率 1 Freq_z1
(Hz)</label><input type="number" v-model.number="params.tptz_fz1"
@change="applyFilterDesign"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-3 text-base text-slate-900 dark:text-gray-100">
</div>
<div><label class="text-sm text-slate-500 dark:text-gray-400 mb-2 block">零點頻率 2 Freq_z2
<div><label class="text-sm text-slate-600 dark:text-gray-400 mb-2 block">零點頻率 2 Freq_z2
(Hz)</label><input type="number" v-model.number="params.tptz_fz2"
@change="applyFilterDesign"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-3 text-base text-slate-900 dark:text-gray-100">
</div>
<div><label class="text-sm text-slate-500 dark:text-gray-400 mb-2 block">極點頻率 1 Freq_p1
<div><label class="text-sm text-slate-600 dark:text-gray-400 mb-2 block">極點頻率 1 Freq_p1
(Hz)</label><input type="number" v-model.number="params.tptz_fp1"
@change="applyFilterDesign"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-3 text-base text-slate-900 dark:text-gray-100">
</div>
<div><label class="text-sm text-slate-500 dark:text-gray-400 mb-2 block">極點頻率 2 Freq_p2
<div><label class="text-sm text-slate-600 dark:text-gray-400 mb-2 block">極點頻率 2 Freq_p2
(Hz)</label><input type="number" v-model.number="params.tptz_fp2"
@change="applyFilterDesign"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-3 text-base text-slate-900 dark:text-gray-100">
@@ -218,7 +218,7 @@
<div v-if="filterType === 'PID 控制器'" class="space-y-3">
<div v-for="k in [{key:'kp',label:'比例增益 Kp'},{key:'ki',label:'積分增益 Ki'},{key:'kd',label:'微分增益 Kd'}]"
:key="k.key">
<label class="text-sm font-medium text-slate-500 dark:text-gray-400 mb-1 block">{{
<label class="text-sm font-medium text-slate-600 dark:text-gray-400 mb-1 block">{{
k.label
}}</label>
<input type="number" v-model.number="params[k.key]" @change="applyFilterDesign"
@@ -229,12 +229,12 @@
<!-- SOGI -->
<div v-if="filterType === 'SOGI-Alpha (帶通)' || filterType === 'SOGI-Beta (低通)'"
class="space-y-3">
<div><label class="text-xs text-slate-500 dark:text-gray-400 mb-1 block">中心頻率 f_0
<div><label class="text-xs text-slate-600 dark:text-gray-400 mb-1 block">中心頻率 f_0
(Hz)</label><input type="number" v-model.number="params.sogi_f0"
@change="applyFilterDesign"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-2 text-sm text-slate-900 dark:text-gray-100">
</div>
<div><label class="text-xs text-slate-500 dark:text-gray-400 mb-1 block">阻尼因數
<div><label class="text-xs text-slate-600 dark:text-gray-400 mb-1 block">阻尼因數
k</label><input type="number" v-model.number="params.sogi_k"
@change="applyFilterDesign" step="0.001"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded p-2 text-sm text-slate-900 dark:text-gray-100">
@@ -247,7 +247,7 @@
<!-- 系統參數 -->
<section>
<h2 class="text-sm font-semibold text-slate-500 dark:text-gray-400 uppercase tracking-wider mb-2 flex items-center gap-2">
<h2 class="text-sm font-semibold text-slate-600 dark:text-gray-400 uppercase tracking-wider mb-3 flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z">
@@ -258,20 +258,20 @@
系統參數</h2>
<div class="space-y-3">
<div>
<label class="text-sm text-slate-500 dark:text-gray-400 flex justify-between mb-1"><span>取樣頻率
<label class="text-sm text-slate-600 dark:text-gray-400 flex justify-between mb-1"><span>取樣頻率
fs
(Hz)</span><span class="role-text-primary">{{ formatK(fs)
}}</span></label>
<input type="number" v-model.number="fs" @input="debouncedApply" @keydown="onlyNumberKey"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-2 text-base role-focus-primary outline-none transition-all text-slate-900 dark:text-gray-100">
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-base role-focus-primary outline-none transition-all text-slate-900 dark:text-gray-100">
</div>
<div>
<label class="text-sm text-slate-500 dark:text-gray-400 flex justify-between mb-1"><span>系統增益
<label class="text-sm text-slate-600 dark:text-gray-400 flex justify-between mb-1"><span>系統增益
K (System Gain)</span><span class="role-text-primary">{{ systemGain
}}x</span></label>
<input type="number" v-model.number="systemGain" @input="onGainChange" step="0.1"
@keydown="onlyNumberKey"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-2 text-base role-focus-primary outline-none transition-all text-slate-900 dark:text-gray-100">
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-base role-focus-primary outline-none transition-all text-slate-900 dark:text-gray-100">
</div>
</div>
</section>
@@ -281,7 +281,7 @@
<!-- 係數手動設定 -->
<section class="coeff-section">
<h2
class="text-sm font-semibold text-slate-500 dark:text-gray-400 uppercase tracking-wider mb-2 flex justify-between items-center">
class="text-sm font-semibold text-slate-600 dark:text-gray-400 uppercase tracking-wider mb-3 flex justify-between items-center">
<span class="flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
@@ -290,41 +290,41 @@
濾波器係數設定</span>
<div class="flex items-center gap-2">
<button v-if="isManualDirty" @click="clearManualAdjustments"
class="text-[10px] font-bold role-bg-secondary-soft role-border-secondary-soft role-text-secondary role-hover-secondary-soft border px-2 py-1 rounded transition-colors uppercase mr-1">重設</button>
class="text-[10px] font-bold border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-700 dark:text-gray-300 hover:bg-slate-100 dark:hover:bg-gray-700 px-3 py-1 rounded-full transition-colors uppercase mr-1">重設</button>
</div>
</h2>
<div class="mb-3">
<label class="text-sm text-slate-500 dark:text-gray-400 mb-1 block">前饋分子係數 b (b0 ~ b6)</label>
<label class="text-sm text-slate-600 dark:text-gray-400 mb-1 block">前饋分子係數 b (b0 ~ b6)</label>
<!-- b 係數標記顯示 -->
<div class="flex flex-wrap gap-1 mb-2">
<div v-for="item in floatingCoeffs.b" :key="item.label"
class="role-bg-primary-soft px-1.5 py-0.5 rounded border role-border-primary-soft flex gap-1 items-center">
<span class="text-[9px] role-text-primary-muted font-bold">{{ item.label }}:</span>
<span class="text-[10px] font-mono role-text-primary">{{ item.val
class="bg-slate-700 dark:bg-gray-300 px-1.5 py-0.5 rounded border border-transparent flex gap-1 items-center shadow-sm">
<span class="text-[9px] text-white dark:text-gray-900 font-bold">{{ item.label }}:</span>
<span class="text-[10px] font-mono text-white dark:text-gray-900 font-bold">{{ item.val
}}</span>
</div>
</div>
<textarea v-model="b_str" @input="debouncedUpdateFromText('b')" @keydown="onlyNumberKey"
rows="2"
class="w-full bg-slate-50 dark:bg-gray-900 border border-slate-200 dark:border-gray-700 rounded-lg p-2 text-sm role-focus-primary outline-none font-mono resize-none leading-relaxed role-text-primary"></textarea>
class="w-full bg-slate-50 dark:bg-gray-900 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-sm role-focus-primary outline-none font-mono resize-none leading-relaxed role-text-primary"></textarea>
</div>
<!-- b 係數倍率微調 -->
<details :open="activeCoeffAdjustment === 'bScale'"
class="mb-3 bg-slate-50 dark:bg-gray-900 border border-slate-200 dark:border-gray-800 rounded-lg">
<summary @click.prevent="toggleCoeffAdjustment('bScale')"
class="text-sm text-slate-500 dark:text-gray-400 px-3 py-3 cursor-pointer hover:text-slate-600 dark:hover:text-gray-300">
class="text-sm text-slate-600 dark:text-gray-400 px-3 py-3 cursor-pointer hover:text-slate-700 dark:hover:text-gray-300">
b 係數倍率微調</summary>
<div class="px-3 pb-3">
<div class="selector-button-group flex items-center gap-1 mb-2 mt-1">
<span class="text-xs text-slate-400 dark:text-gray-500">靈敏度:</span>
<div class="selector-button-group flex items-center gap-2 mb-2 mt-1">
<span class="text-xs text-slate-500 dark:text-gray-500">靈敏度:</span>
<button v-for="s in ['1%','2x','10x']" :key="s" @click="sense_b = s"
:class="sense_b === s ? 'primary-action role-bg-primary role-text-on-fill role-border-primary' : 'bg-slate-200 dark:bg-gray-800 text-slate-500 dark:text-gray-400'"
class="selector-button btn-small px-3 py-0.5 rounded text-[10px] font-medium border border-slate-300 dark:border-gray-700">{{
:class="sense_b === s ? 'primary-action role-bg-primary role-text-on-fill role-border-primary' : 'bg-slate-200 dark:bg-gray-800 text-slate-600 dark:text-gray-400'"
class="selector-button btn-small px-3 py-1.5 rounded text-xs font-medium border border-slate-300 dark:border-gray-700">{{
s }}</button>
</div>
<template v-for="i in 7" :key="'bs'+i">
<div v-if="baseB[i-1] !== 0">
<label class="text-xs text-slate-400 dark:text-gray-500 flex justify-between mt-1">
<label class="text-xs text-slate-500 dark:text-gray-500 flex justify-between mt-1">
<span>b{{ i-1 }} (基準: {{ (baseB[i-1] * (systemGain || 1.0)).toPrecision(6)
}})</span>
<span v-if="outOfRangeB[i-1]" class="role-text-warning font-bold animate-pulse">超出滑桿範圍</span>
@@ -337,44 +337,44 @@
</div>
</details>
<div class="mb-3">
<label class="text-sm text-slate-500 dark:text-gray-400 mb-1 block">回饋分母係數 a (a0=1, a1 ~
<label class="text-sm text-slate-600 dark:text-gray-400 mb-1 block">回饋分母係數 a (a0=1, a1 ~
a6)</label>
<!-- a 係數標記顯示 -->
<div class="flex flex-wrap gap-1 mb-2">
<div v-for="item in floatingCoeffs.a" :key="item.label"
class="role-bg-secondary-soft px-1.5 py-0.5 rounded border role-border-secondary-soft flex gap-1 items-center">
<span class="text-[9px] role-text-secondary-muted font-bold">{{ item.label }}:</span>
<span class="text-[10px] font-mono role-text-secondary">{{ item.val
class="bg-slate-700 dark:bg-gray-300 px-1.5 py-0.5 rounded border border-transparent flex gap-1 items-center shadow-sm">
<span class="text-[9px] text-white dark:text-gray-900 font-bold">{{ item.label }}:</span>
<span class="text-[10px] font-mono text-white dark:text-gray-900 font-bold">{{ item.val
}}</span>
</div>
</div>
<textarea v-model="a_str" @input="debouncedUpdateFromText('a')" @keydown="onlyNumberKey"
rows="2"
class="w-full bg-slate-50 dark:bg-gray-900 border border-slate-200 dark:border-gray-700 rounded-lg p-2 text-sm role-focus-primary outline-none font-mono resize-none leading-relaxed role-text-secondary"></textarea>
class="w-full bg-slate-50 dark:bg-gray-900 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-sm role-focus-primary outline-none font-mono resize-none leading-relaxed role-text-primary"></textarea>
</div>
<!-- a 係數倍率微調 -->
<details :open="activeCoeffAdjustment === 'aScale'"
class="bg-slate-50 dark:bg-gray-900 border border-slate-200 dark:border-gray-800 rounded-lg">
<summary @click.prevent="toggleCoeffAdjustment('aScale')"
class="text-sm text-slate-500 dark:text-gray-400 px-3 py-3 cursor-pointer hover:text-slate-600 dark:hover:text-gray-300">
class="text-sm text-slate-600 dark:text-gray-400 px-3 py-3 cursor-pointer hover:text-slate-700 dark:hover:text-gray-300">
a 係數倍率微調</summary>
<div class="px-3 pb-3">
<div class="selector-button-group flex items-center gap-2 mb-2 mt-1">
<span class="text-xs text-slate-400 dark:text-gray-500">靈敏度:</span>
<span class="text-xs text-slate-500 dark:text-gray-500">靈敏度:</span>
<button v-for="s in ['1%','2x','10x']" :key="s" @click="sense_a = s"
:class="sense_a === s ? 'primary-action role-bg-primary role-text-on-fill role-border-primary' : 'bg-slate-200 dark:bg-gray-800 text-slate-500 dark:text-gray-400'"
class="selector-button px-3 py-0.5 rounded text-[10px] font-medium border border-slate-300 dark:border-gray-700">{{
:class="sense_a === s ? 'primary-action role-bg-primary role-text-on-fill role-border-primary' : 'bg-slate-200 dark:bg-gray-800 text-slate-600 dark:text-gray-400'"
class="selector-button px-3 py-1.5 rounded text-xs font-medium border border-slate-300 dark:border-gray-700">{{
s }}</button>
</div>
<template v-for="i in 6" :key="'as'+i">
<div v-if="baseA[i] !== 0">
<label class="text-xs text-slate-400 dark:text-gray-500 flex justify-between mt-1">
<label class="text-xs text-slate-500 dark:text-gray-500 flex justify-between mt-1">
<span>a{{ i }} (基準: {{ baseA[i].toPrecision(6) }})</span>
<span v-if="outOfRangeA[i]" class="role-text-warning font-bold animate-pulse">超出滑桿範圍</span>
</label>
<input type="range" v-model.number="aSliders[i]" @input="updateFromControls"
:min="-maxLogA" :max="maxLogA" :step="maxLogA/100"
class="w-full role-range-secondary">
class="w-full role-range-primary">
</div>
</template>
</div>
@@ -384,86 +384,93 @@
<details :open="activeCoeffAdjustment === 'a1a2'"
class="mt-3 bg-slate-50 dark:bg-gray-900 border border-slate-200 dark:border-gray-800 rounded-lg">
<summary @click.prevent="toggleCoeffAdjustment('a1a2')"
class="text-sm text-slate-500 dark:text-gray-400 px-3 py-3 cursor-pointer hover:text-slate-600 dark:hover:text-gray-300">
class="text-sm text-slate-600 dark:text-gray-400 px-3 py-3 cursor-pointer hover:text-slate-700 dark:hover:text-gray-300">
a1/a2 步階微調</summary>
<div class="px-3 pb-3">
<div class="mb-4">
<div class="grid grid-cols-[3.25rem_1fr_3.25rem] gap-2 mb-3">
<button type="button" @click="adjustAFineStep(-1)" title="調粗"
class="rounded-lg border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-600 dark:text-gray-300 py-1 text-xs font-bold transition-colors hover:bg-slate-100 dark:hover:bg-gray-700">
class="stepper-button w-full h-[42px] flex items-center justify-center rounded-full border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-700 dark:text-gray-300 hover:bg-slate-100 dark:hover:bg-gray-700 transition-all duration-150 active:scale-90 text-sm font-bold shadow-sm">
</button>
<div class="min-w-0 rounded-lg border role-border-secondary-soft role-bg-secondary-soft px-3 py-2 text-center">
<span class="block text-[9px] role-text-secondary-muted role-text-secondary font-bold">微調量</span>
<span class="block truncate font-mono text-sm font-bold role-text-secondary" :title="formatAFineDelta()">
± {{ formatAFineDelta() }}
<div class="min-w-0 rounded-lg border role-border-primary-soft role-bg-primary-soft px-3 h-[42px] flex flex-col justify-center text-center">
<span class="block text-[9px] role-text-primary-muted role-text-primary font-bold">微調量</span>
<span class="block truncate font-mono text-sm font-bold role-text-primary leading-none mt-0.5" :title="formatAFineDelta()">
+/-{{ formatAFineDelta() }}
</span>
</div>
<button type="button" @click="adjustAFineStep(1)" title="調細"
class="rounded-lg border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-600 dark:text-gray-300 py-1 text-xs font-bold transition-colors hover:bg-slate-100 dark:hover:bg-gray-700">
class="stepper-button w-full h-[42px] flex items-center justify-center rounded-full border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-700 dark:text-gray-300 hover:bg-slate-100 dark:hover:bg-gray-700 transition-all duration-150 active:scale-90 text-sm font-bold shadow-sm">
</button>
</div>
<div class="text-[11px] font-semibold text-slate-400 dark:text-gray-500 mb-2">
<div class="text-[11px] font-semibold text-slate-500 dark:text-gray-500 mb-2">
{{ isTouchInput ? '觸控拖曳微調' : '直接輸入 / shift + 滾輪微調' }}
</div>
<div class="flex flex-col gap-3">
<div class="grid grid-cols-2 gap-2">
<article v-if="!isTouchInput" class="block">
<span class="text-[9px] text-slate-400 block font-bold mb-1">δ</span>
<span class="text-[9px] text-slate-500 block font-bold mb-1">δ</span>
<div class="grid grid-cols-[2rem_1fr_2rem] gap-1">
<button type="button" @pointerdown.prevent="startA1A2Repeat('delta', -1)" @pointerup="stopA1A2Repeat" @pointerleave="stopA1A2Repeat" @pointercancel="stopA1A2Repeat"
class="stepper-button w-full h-[42px] flex items-center justify-center rounded-full role-bg-primary-soft role-hover-primary-soft role-text-primary transition-all duration-150 active:scale-90 text-lg font-bold shadow-sm">-</button>
class="stepper-button w-full h-[42px] flex items-center justify-center rounded-full border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-700 dark:text-gray-300 hover:bg-slate-100 dark:hover:bg-gray-700 transition-all duration-150 active:scale-90 text-lg font-bold shadow-sm">-</button>
<input type="number" :value="formatFineCoeff(currentDelta)" :step="aFineStep"
@change="setAFineDirect('delta', $event.target.value)" @keydown="onlyNumberKey"
@wheel="handleAFineWheel('delta', $event)"
class="min-w-0 w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-2 text-xs font-mono text-center role-text-secondary">
class="min-w-0 w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg px-2 h-[42px] text-xs font-mono text-center role-text-primary">
<button type="button" @pointerdown.prevent="startA1A2Repeat('delta', 1)" @pointerup="stopA1A2Repeat" @pointerleave="stopA1A2Repeat" @pointercancel="stopA1A2Repeat"
class="stepper-button w-full h-[42px] flex items-center justify-center rounded-full role-bg-primary-soft role-hover-primary-soft role-text-primary transition-all duration-150 active:scale-90 text-lg font-bold shadow-sm">+</button>
class="stepper-button w-full h-[42px] flex items-center justify-center rounded-full border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-700 dark:text-gray-300 hover:bg-slate-100 dark:hover:bg-gray-700 transition-all duration-150 active:scale-90 text-lg font-bold shadow-sm">+</button>
</div>
</article>
<button v-else type="button" @pointerdown.prevent="startAFineDrag('delta', $event)"
class="touch-none select-none w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-center role-active-border-secondary active:ring-2 role-active-ring-secondary">
<span class="text-[9px] text-slate-400 block font-bold mb-1">δ</span>
<span class="block max-w-full truncate text-base font-mono font-bold role-text-secondary" :title="formatFineCoeff(currentDelta)">{{ formatFineCoeff(currentDelta) }}</span>
class="touch-none select-none w-28 mx-auto bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-center role-active-border-primary active:ring-2 role-active-ring-primary">
<span class="text-[9px] text-slate-500 block font-bold mb-1">δ</span>
<span class="block max-w-full truncate text-base font-mono font-bold role-text-primary" :title="formatFineCoeff(currentDelta)">{{ formatFineCoeff(currentDelta) }}</span>
</button>
<article v-if="!isTouchInput" class="block">
<span class="text-[9px] text-slate-400 block font-bold mb-1">r</span>
<span class="text-[9px] text-slate-500 block font-bold mb-1">r</span>
<div class="grid grid-cols-[2rem_1fr_2rem] gap-1">
<button type="button" @pointerdown.prevent="startA1A2Repeat('r', -1)" @pointerup="stopA1A2Repeat" @pointerleave="stopA1A2Repeat" @pointercancel="stopA1A2Repeat"
class="stepper-button w-full h-[42px] flex items-center justify-center rounded-full role-bg-primary-soft role-hover-primary-soft role-text-primary transition-all duration-150 active:scale-90 text-lg font-bold shadow-sm">-</button>
class="stepper-button w-full h-[42px] flex items-center justify-center rounded-full border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-700 dark:text-gray-300 hover:bg-slate-100 dark:hover:bg-gray-700 transition-all duration-150 active:scale-90 text-lg font-bold shadow-sm">-</button>
<input type="number" :value="formatFineCoeff(currentR)" :step="aFineStep"
@change="setAFineDirect('r', $event.target.value)" @keydown="onlyNumberKey"
@wheel="handleAFineWheel('r', $event)"
class="min-w-0 w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-2 text-xs font-mono text-center text-slate-700 dark:text-gray-300">
class="min-w-0 w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg px-2 h-[42px] text-xs font-mono text-center role-text-primary">
<button type="button" @pointerdown.prevent="startA1A2Repeat('r', 1)" @pointerup="stopA1A2Repeat" @pointerleave="stopA1A2Repeat" @pointercancel="stopA1A2Repeat"
class="stepper-button w-full h-[42px] flex items-center justify-center rounded-full role-bg-primary-soft role-hover-primary-soft role-text-primary transition-all duration-150 active:scale-90 text-lg font-bold shadow-sm">+</button>
class="stepper-button w-full h-[42px] flex items-center justify-center rounded-full border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-700 dark:text-gray-300 hover:bg-slate-100 dark:hover:bg-gray-700 transition-all duration-150 active:scale-90 text-lg font-bold shadow-sm">+</button>
</div>
</article>
<button v-else type="button" @pointerdown.prevent="startAFineDrag('r', $event)"
class="touch-none select-none w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-center active:border-slate-400 active:ring-2 active:ring-slate-400/20">
<span class="text-[9px] text-slate-400 block font-bold mb-1">r</span>
<span class="block max-w-full truncate text-base font-mono font-bold text-slate-700 dark:text-gray-300" :title="formatFineCoeff(currentR)">{{ formatFineCoeff(currentR) }}</span>
class="touch-none select-none w-28 mx-auto bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-center active:border-slate-400 active:ring-2 active:ring-slate-400/20">
<span class="text-[9px] text-slate-500 block font-bold mb-1">r</span>
<span class="block max-w-full truncate text-base font-mono font-bold role-text-primary" :title="formatFineCoeff(currentR)">{{ formatFineCoeff(currentR) }}</span>
</button>
</div>
</div>
<div class="grid grid-cols-2 gap-2 mb-4">
<div class="min-w-0 bg-white dark:bg-gray-800 border border-slate-100 dark:border-gray-700 rounded-lg p-2 text-center shadow-sm">
<span class="text-[9px] text-slate-400 block font-bold mb-1">r = -1 - a1</span>
<span class="block max-w-full truncate text-sm font-mono font-bold role-text-secondary" :title="formatFineCoeff(currentR)">{{ formatFineCoeff(currentR) }}</span>
<span class="text-[9px] text-slate-500 block font-bold mb-1">a1 = -1 - r</span>
<span class="block max-w-full truncate text-sm font-mono font-bold role-text-primary-muted" :title="formatFineCoeff(currentA1)">{{ formatFineCoeff(currentA1) }}</span>
</div>
<div class="min-w-0 bg-white dark:bg-gray-800 border border-slate-100 dark:border-gray-700 rounded-lg p-2 text-center shadow-sm">
<span class="text-[9px] text-slate-400 block font-bold mb-1">δ = a1 + a2 + 1</span>
<span class="block max-w-full truncate text-sm font-mono font-bold role-text-secondary" :title="formatFineCoeff(currentDelta)">{{ formatFineCoeff(currentDelta) }}</span>
<span class="text-[9px] text-slate-500 block font-bold mb-1">a2 = r + δ</span>
<span class="block max-w-full truncate text-sm font-mono font-bold role-text-primary-muted" :title="formatFineCoeff(currentA2)">{{ formatFineCoeff(currentA2) }}</span>
</div>
<div class="min-w-0 bg-white dark:bg-gray-800 border border-slate-100 dark:border-gray-700 rounded-lg p-2 text-center shadow-sm">
<span class="text-[9px] text-slate-500 block font-bold mb-1">差值 a1-a2</span>
<span class="block max-w-full truncate text-xs font-mono text-slate-700 dark:text-gray-300" :title="formatFineCoeff(currentA1A2Diff)">{{ formatFineCoeff(currentA1A2Diff) }}</span>
</div>
<div class="min-w-0 bg-white dark:bg-gray-800 border border-slate-100 dark:border-gray-700 rounded-lg p-2 text-center shadow-sm">
<span class="text-[9px] text-slate-500 block font-bold mb-1">總和 a1+a2</span>
<span class="block max-w-full truncate text-xs font-mono text-slate-700 dark:text-gray-300" :title="formatFineCoeff(currentA1A2Sum)">{{ formatFineCoeff(currentA1A2Sum) }}</span>
</div>
</div>
<div class="role-bg-warning-soft border role-border-warning-soft rounded-lg p-3">
<p class="text-[10px] role-text-warning leading-relaxed">
<span class="font-bold">運算說明</span><br>
a1 = -1 - r, a2 = r + δ更改數值會自動更新圖表桌機可用 ± shift + 滾輪觸控時按住數值面板上下拖曳
δ = a1 + a2 + 1r = -1 - a1更改數值會自動更新圖表桌機可用 +/- shift +
</p>
</div>
</div>
@@ -475,176 +482,183 @@
<!-- 定點數轉換 -->
<section class="fixed-section">
<h2
class="text-sm font-semibold text-slate-500 dark:text-gray-400 uppercase tracking-wider mb-2 flex justify-between items-center">
class="text-sm font-semibold text-slate-600 dark:text-gray-400 uppercase tracking-wider mb-3 flex justify-between items-center">
<div class="flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 7h6M9 12h6M9 17h3M5 5a2 2 0 012-2h7l5 5v11a2 2 0 01-2 2H7a2 2 0 01-2-2V5z"></path>
</svg>
<span>定點數係數轉換</span>
<span
<span v-if="shiftBitsB > 0"
class="text-xs role-bg-primary-soft role-text-primary px-2 py-1 rounded">b Q{{
shiftBitsB }}</span>
<span
class="text-xs role-bg-secondary-soft role-text-secondary px-2 py-1 rounded">a Q{{
<span v-if="shiftBitsA > 0"
class="text-xs role-bg-primary-soft role-text-primary px-2 py-1 rounded">a Q{{
shiftBitsA }}</span>
</div>
<div class="flex items-center gap-2">
<button v-if="isFixedPointDirty" @click="resetFixedPointSettings"
class="text-[10px] font-bold role-bg-secondary-soft role-border-secondary-soft role-text-secondary role-hover-secondary-soft border px-2 py-1 rounded transition-colors uppercase mr-1">重設</button>
class="text-[10px] font-bold border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-700 dark:text-gray-300 hover:bg-slate-100 dark:hover:bg-gray-700 px-3 py-1 rounded-full transition-colors uppercase mr-1">重設</button>
</div>
</h2>
<div class="mb-4 grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="text-sm text-slate-500 dark:text-gray-400 mb-1 block">b 左移位元數 (Shift Bits)</label>
<label class="text-sm text-slate-600 dark:text-gray-400 mb-1 block">b 左移位元數 (Shift Bits)</label>
<div v-if="!isTouchInput" class="grid grid-cols-[2rem_1fr_2rem] gap-1">
<button type="button" @pointerdown.prevent="startShiftBitRepeat('b', -1)" @pointerup="stopShiftBitRepeat" @pointerleave="stopShiftBitRepeat" @pointercancel="stopShiftBitRepeat"
class="stepper-button w-full h-[42px] flex items-center justify-center rounded-full role-bg-primary-soft role-hover-primary-soft role-text-primary transition-all duration-150 active:scale-90 text-lg font-bold shadow-sm">-</button>
class="stepper-button w-full h-[42px] flex items-center justify-center rounded-full border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-700 dark:text-gray-300 hover:bg-slate-100 dark:hover:bg-gray-700 transition-all duration-150 active:scale-90 text-lg font-bold shadow-sm">-</button>
<input type="number" :value="shiftBitsB" min="0"
@change="setShiftBits('b', $event.target.value)" @keydown="onlyNumberKey"
@wheel="handleShiftBitWheel('b', $event)"
class="min-w-0 w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-2 text-base role-focus-primary outline-none transition-all text-slate-900 dark:text-gray-100">
class="min-w-0 w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg px-2 h-[42px] text-center text-base role-focus-primary outline-none transition-all text-slate-900 dark:text-gray-100">
<button type="button" @pointerdown.prevent="startShiftBitRepeat('b', 1)" @pointerup="stopShiftBitRepeat" @pointerleave="stopShiftBitRepeat" @pointercancel="stopShiftBitRepeat"
class="stepper-button w-full h-[42px] flex items-center justify-center rounded-full role-bg-primary-soft role-hover-primary-soft role-text-primary transition-all duration-150 active:scale-90 text-lg font-bold shadow-sm">+</button>
class="stepper-button w-full h-[42px] flex items-center justify-center rounded-full border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-700 dark:text-gray-300 hover:bg-slate-100 dark:hover:bg-gray-700 transition-all duration-150 active:scale-90 text-lg font-bold shadow-sm">+</button>
</div>
<button v-else type="button" @pointerdown.prevent="startShiftBitDrag('b', $event)"
class="touch-none select-none block w-24 mx-auto bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-center role-active-border-primary active:ring-2 role-active-ring-primary">
<span class="text-lg font-mono font-bold role-text-primary">{{ shiftBitsB }}</span>
</button>
<p class="text-[10px] text-slate-400 mt-1">b scaling: 2^{{ shiftBitsB }} ({{
<p class="text-[10px] text-slate-500 mt-1">b scaling: 2^{{ shiftBitsB }} ({{
Math.pow(2, shiftBitsB) }})</p>
</div>
<div>
<label class="text-sm text-slate-500 dark:text-gray-400 mb-1 block">a 左移位元數 (Shift Bits)</label>
<label class="text-sm text-slate-600 dark:text-gray-400 mb-1 block">a 左移位元數 (Shift Bits)</label>
<div v-if="!isTouchInput" class="grid grid-cols-[2rem_1fr_2rem] gap-1">
<button type="button" @pointerdown.prevent="startShiftBitRepeat('a', -1)" @pointerup="stopShiftBitRepeat" @pointerleave="stopShiftBitRepeat" @pointercancel="stopShiftBitRepeat"
class="stepper-button w-full h-[42px] flex items-center justify-center rounded-full role-bg-primary-soft role-hover-primary-soft role-text-primary transition-all duration-150 active:scale-90 text-lg font-bold shadow-sm">-</button>
class="stepper-button w-full h-[42px] flex items-center justify-center rounded-full border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-700 dark:text-gray-300 hover:bg-slate-100 dark:hover:bg-gray-700 transition-all duration-150 active:scale-90 text-lg font-bold shadow-sm">-</button>
<input type="number" :value="shiftBitsA" min="0"
@change="setShiftBits('a', $event.target.value)" @keydown="onlyNumberKey"
@wheel="handleShiftBitWheel('a', $event)"
class="min-w-0 w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-2 text-base role-focus-primary outline-none transition-all text-slate-900 dark:text-gray-100">
class="min-w-0 w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg px-2 h-[42px] text-center text-base role-focus-primary outline-none transition-all text-slate-900 dark:text-gray-100">
<button type="button" @pointerdown.prevent="startShiftBitRepeat('a', 1)" @pointerup="stopShiftBitRepeat" @pointerleave="stopShiftBitRepeat" @pointercancel="stopShiftBitRepeat"
class="stepper-button w-full h-[42px] flex items-center justify-center rounded-full role-bg-primary-soft role-hover-primary-soft role-text-primary transition-all duration-150 active:scale-90 text-lg font-bold shadow-sm">+</button>
class="stepper-button w-full h-[42px] flex items-center justify-center rounded-full border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-700 dark:text-gray-300 hover:bg-slate-100 dark:hover:bg-gray-700 transition-all duration-150 active:scale-90 text-lg font-bold shadow-sm">+</button>
</div>
<button v-else type="button" @pointerdown.prevent="startShiftBitDrag('a', $event)"
class="touch-none select-none block w-24 mx-auto bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-center role-active-border-secondary active:ring-2 role-active-ring-secondary">
<span class="text-lg font-mono font-bold role-text-secondary">{{ shiftBitsA }}</span>
class="touch-none select-none block w-24 mx-auto bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-center role-active-border-primary active:ring-2 role-active-ring-primary">
<span class="text-lg font-mono font-bold role-text-primary">{{ shiftBitsA }}</span>
</button>
<p class="text-[10px] text-slate-400 mt-1">a scaling: 2^{{ shiftBitsA }} ({{
<p class="text-[10px] text-slate-500 mt-1">a scaling: 2^{{ shiftBitsA }} ({{
Math.pow(2, shiftBitsA) }})</p>
</div>
</div>
<div class="space-y-4">
<div>
<label class="text-sm text-slate-500 dark:text-gray-400 mb-1 block">定點分子係數 b
<label class="text-sm text-slate-600 dark:text-gray-400 mb-1 block">定點分子係數 b
(Integers)</label>
<div class="flex flex-wrap gap-1 mb-2">
<div v-for="(item, idx) in fixedPointCoeffs.b" :key="item.label"
:class="item.isOverridden ? 'role-border-primary ring-1 role-ring-primary' : 'role-border-primary-soft'"
class="role-bg-primary-soft px-1.5 py-0.5 rounded border flex items-center gap-1 transition-all">
<span class="text-[9px] role-text-primary font-bold">{{ item.label }}:</span>
<span class="text-[10px] font-mono role-text-primary font-bold"
:class="item.isOverridden ? 'border-amber-400 dark:border-amber-500 ring-1 ring-amber-400 dark:ring-amber-500' : 'border-transparent'"
class="bg-slate-700 dark:bg-gray-300 px-1.5 py-0.5 rounded border flex items-center gap-1 transition-all shadow-sm">
<span class="text-[9px] text-white dark:text-gray-900 font-bold">{{ item.label }}:</span>
<span class="text-[10px] font-mono text-white dark:text-gray-900 font-bold"
:class="{ 'italic': !item.isOverridden }">{{ item.val }}</span>
</div>
</div>
<textarea v-model="b_int_str" @keydown="onlyNumberKey($event, false)" rows="2"
class="w-full bg-slate-50 dark:bg-gray-900 border border-slate-200 dark:border-gray-700 rounded-lg p-2 text-sm role-focus-primary outline-none font-mono resize-none leading-relaxed role-text-primary"></textarea>
class="w-full bg-slate-50 dark:bg-gray-900 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-sm role-focus-primary outline-none font-mono resize-none leading-relaxed role-text-primary"></textarea>
</div>
<div>
<label class="text-sm text-slate-500 dark:text-gray-400 mb-1 block">定點分母係數 a
<label class="text-sm text-slate-600 dark:text-gray-400 mb-1 block">定點分母係數 a
(Integers)</label>
<div class="flex flex-wrap gap-1 mb-2">
<div v-for="(item, idx) in fixedPointCoeffs.a" :key="item.label"
:class="item.isOverridden ? 'role-border-secondary ring-1 role-ring-secondary' : 'role-border-secondary-soft'"
class="role-bg-secondary-soft px-1.5 py-0.5 rounded border flex items-center gap-1 transition-all">
<span class="text-[9px] role-text-secondary-muted font-bold">{{ item.label }}:</span>
<span class="text-[10px] font-mono role-text-secondary font-bold"
:class="item.isOverridden ? 'border-amber-400 dark:border-amber-500 ring-1 ring-amber-400 dark:ring-amber-500' : 'border-transparent'"
class="bg-slate-700 dark:bg-gray-300 px-1.5 py-0.5 rounded border flex items-center gap-1 transition-all shadow-sm">
<span class="text-[9px] text-white dark:text-gray-900 font-bold">{{ item.label }}:</span>
<span class="text-[10px] font-mono text-white dark:text-gray-900 font-bold"
:class="{ 'italic': !item.isOverridden }">{{ item.val }}</span>
</div>
</div>
<textarea v-model="a_int_str" @keydown="onlyNumberKey($event, false)" rows="2"
class="w-full bg-slate-50 dark:bg-gray-900 border border-slate-200 dark:border-gray-700 rounded-lg p-2 text-sm role-focus-primary outline-none font-mono resize-none leading-relaxed role-text-secondary"></textarea>
class="w-full bg-slate-50 dark:bg-gray-900 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-sm role-focus-primary outline-none font-mono resize-none leading-relaxed role-text-primary"></textarea>
</div>
<details class="mt-3 bg-slate-50 dark:bg-gray-900 border border-slate-200 dark:border-gray-800 rounded-lg">
<summary class="text-sm text-slate-500 dark:text-gray-400 px-3 py-3 cursor-pointer hover:text-slate-600 dark:hover:text-gray-300">
<summary class="text-sm text-slate-600 dark:text-gray-400 px-3 py-3 cursor-pointer hover:text-slate-700 dark:hover:text-gray-300">
a1/a2 步階微調</summary>
<div class="px-3 pb-3">
<div class="mb-4">
<div class="grid grid-cols-[3.25rem_1fr_3.25rem] gap-2 mb-3">
<button type="button" @click="adjustFixedAFineStep(-1)" title="調粗"
class="rounded-lg border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-600 dark:text-gray-300 py-2 text-xs font-bold transition-colors hover:bg-slate-100 dark:hover:bg-gray-700"></button>
<div class="min-w-0 rounded-lg border role-border-secondary-soft role-bg-secondary-soft px-3 py-2 text-center">
<span class="block text-[9px] role-text-secondary-muted role-text-secondary font-bold">微調量</span>
<span class="block truncate font-mono text-sm font-bold role-text-secondary" :title="formatFixedAFineDelta()">± {{ formatFixedAFineDelta() }}</span>
class="stepper-button w-full h-[42px] flex items-center justify-center rounded-full border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-700 dark:text-gray-300 hover:bg-slate-100 dark:hover:bg-gray-700 transition-all duration-150 active:scale-90 text-sm font-bold shadow-sm"></button>
<div class="min-w-0 rounded-lg border role-border-primary-soft role-bg-primary-soft px-3 h-[42px] flex flex-col justify-center text-center">
<span class="block text-[9px] role-text-primary-muted role-text-primary font-bold">微調量</span>
<span class="block truncate font-mono text-sm font-bold role-text-primary leading-none mt-0.5" :title="formatFixedAFineDelta()">+/-{{ formatFixedAFineDelta() }}</span>
</div>
<button type="button" @click="adjustFixedAFineStep(1)" title="調細"
class="rounded-lg border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-600 dark:text-gray-300 py-2 text-xs font-bold transition-colors hover:bg-slate-100 dark:hover:bg-gray-700"></button>
class="stepper-button w-full h-[42px] flex items-center justify-center rounded-full border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-700 dark:text-gray-300 hover:bg-slate-100 dark:hover:bg-gray-700 transition-all duration-150 active:scale-90 text-sm font-bold shadow-sm"></button>
</div>
<div class="text-[11px] font-semibold text-slate-400 dark:text-gray-500 mb-2">
<div class="text-[11px] font-semibold text-slate-500 dark:text-gray-500 mb-2">
{{ isTouchInput ? '觸控拖曳微調' : '直接輸入 / shift + 滾輪微調' }}
</div>
<div class="flex flex-col gap-3">
<div class="grid grid-cols-2 gap-2">
<article v-if="!isTouchInput" class="block">
<span class="text-[9px] text-slate-400 block font-bold mb-1">δ</span>
<span class="text-[9px] text-slate-500 block font-bold mb-1">δ</span>
<div class="grid grid-cols-[2rem_1fr_2rem] gap-1">
<button type="button" @pointerdown.prevent="startFixedA1A2Repeat('delta', -1)" @pointerup="stopFixedA1A2Repeat" @pointerleave="stopFixedA1A2Repeat" @pointercancel="stopFixedA1A2Repeat"
class="stepper-button w-full h-[42px] flex items-center justify-center rounded-full role-bg-primary-soft role-hover-primary-soft role-text-primary transition-all duration-150 active:scale-90 text-lg font-bold shadow-sm">-</button>
class="stepper-button w-full h-[42px] flex items-center justify-center rounded-full border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-700 dark:text-gray-300 hover:bg-slate-100 dark:hover:bg-gray-700 transition-all duration-150 active:scale-90 text-lg font-bold shadow-sm">-</button>
<input type="number" :value="formatFixedFineCoeff(fixedCurrentDelta)" :step="fixedAFineStep"
@change="setFixedAFineDirect('delta', $event.target.value)" @keydown="onlyNumberKey"
@wheel="handleFixedAFineWheel('delta', $event)"
class="min-w-0 w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-xs font-mono text-center role-text-secondary">
class="min-w-0 w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg px-2 h-[42px] text-xs font-mono text-center role-text-primary">
<button type="button" @pointerdown.prevent="startFixedA1A2Repeat('delta', 1)" @pointerup="stopFixedA1A2Repeat" @pointerleave="stopFixedA1A2Repeat" @pointercancel="stopFixedA1A2Repeat"
class="stepper-button w-full h-[42px] flex items-center justify-center rounded-full role-bg-primary-soft role-hover-primary-soft role-text-primary transition-all duration-150 active:scale-90 text-lg font-bold shadow-sm">+</button>
class="stepper-button w-full h-[42px] flex items-center justify-center rounded-full border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-700 dark:text-gray-300 hover:bg-slate-100 dark:hover:bg-gray-700 transition-all duration-150 active:scale-90 text-lg font-bold shadow-sm">+</button>
</div>
</article>
<button v-else type="button" @pointerdown.prevent="startFixedAFineDrag('delta', $event)"
class="touch-none select-none w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-center role-active-border-secondary active:ring-2 role-active-ring-secondary">
<span class="text-[9px] text-slate-400 block font-bold mb-1">δ</span>
<span class="block max-w-full truncate text-base font-mono font-bold role-text-secondary" :title="formatFixedFineCoeff(fixedCurrentDelta)">{{ formatFixedFineCoeff(fixedCurrentDelta) }}</span>
class="touch-none select-none w-28 mx-auto bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-center role-active-border-primary active:ring-2 role-active-ring-primary">
<span class="text-[9px] text-slate-500 block font-bold mb-1">δ</span>
<span class="block max-w-full truncate text-base font-mono font-bold role-text-primary" :title="formatFixedFineCoeff(fixedCurrentDelta)">{{ formatFixedFineCoeff(fixedCurrentDelta) }}</span>
</button>
<article v-if="!isTouchInput" class="block">
<span class="text-[9px] text-slate-400 block font-bold mb-1">r</span>
<span class="text-[9px] text-slate-500 block font-bold mb-1">r</span>
<div class="grid grid-cols-[2rem_1fr_2rem] gap-1">
<button type="button" @pointerdown.prevent="startFixedA1A2Repeat('r', -1)" @pointerup="stopFixedA1A2Repeat" @pointerleave="stopFixedA1A2Repeat" @pointercancel="stopFixedA1A2Repeat"
class="stepper-button w-full h-[42px] flex items-center justify-center rounded-full role-bg-primary-soft role-hover-primary-soft role-text-primary transition-all duration-150 active:scale-90 text-lg font-bold shadow-sm">-</button>
class="stepper-button w-full h-[42px] flex items-center justify-center rounded-full border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-700 dark:text-gray-300 hover:bg-slate-100 dark:hover:bg-gray-700 transition-all duration-150 active:scale-90 text-lg font-bold shadow-sm">-</button>
<input type="number" :value="formatFixedFineCoeff(fixedCurrentR)" :step="fixedAFineStep"
@change="setFixedAFineDirect('r', $event.target.value)" @keydown="onlyNumberKey"
@wheel="handleFixedAFineWheel('r', $event)"
class="min-w-0 w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-xs font-mono text-center text-slate-700 dark:text-gray-300">
class="min-w-0 w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-xs font-mono text-center role-text-primary">
<button type="button" @pointerdown.prevent="startFixedA1A2Repeat('r', 1)" @pointerup="stopFixedA1A2Repeat" @pointerleave="stopFixedA1A2Repeat" @pointercancel="stopFixedA1A2Repeat"
class="stepper-button w-full h-[42px] flex items-center justify-center rounded-full role-bg-primary-soft role-hover-primary-soft role-text-primary transition-all duration-150 active:scale-90 text-lg font-bold shadow-sm">+</button>
class="stepper-button w-full h-[42px] flex items-center justify-center rounded-full border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-700 dark:text-gray-300 hover:bg-slate-100 dark:hover:bg-gray-700 transition-all duration-150 active:scale-90 text-lg font-bold shadow-sm">+</button>
</div>
</article>
<button v-else type="button" @pointerdown.prevent="startFixedAFineDrag('r', $event)"
class="touch-none select-none w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-center active:border-slate-400 active:ring-2 active:ring-slate-400/20">
<span class="text-[9px] text-slate-400 block font-bold mb-1">r</span>
<span class="block max-w-full truncate text-base font-mono font-bold text-slate-700 dark:text-gray-300" :title="formatFixedFineCoeff(fixedCurrentR)">{{ formatFixedFineCoeff(fixedCurrentR) }}</span>
class="touch-none select-none w-28 mx-auto bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-center active:border-slate-400 active:ring-2 active:ring-slate-400/20">
<span class="text-[9px] text-slate-500 block font-bold mb-1">r</span>
<span class="block max-w-full truncate text-base font-mono font-bold role-text-primary" :title="formatFixedFineCoeff(fixedCurrentR)">{{ formatFixedFineCoeff(fixedCurrentR) }}</span>
</button>
</div>
</div>
<div class="grid grid-cols-2 gap-2 mb-4">
<div class="min-w-0 bg-white dark:bg-gray-800 border border-slate-100 dark:border-gray-700 rounded-lg p-2 text-center shadow-sm">
<span class="text-[9px] text-slate-400 block font-bold mb-1">r = -a0 - a1</span>
<span class="block max-w-full truncate text-sm font-mono font-bold role-text-secondary" :title="formatFixedFineCoeff(fixedCurrentR)">{{ formatFixedFineCoeff(fixedCurrentR) }}</span>
<span class="text-[9px] text-slate-500 block font-bold mb-1">a1 = -a0 - r</span>
<span class="block max-w-full truncate text-sm font-mono font-bold role-text-primary-muted" :title="formatFixedFineCoeff(fixedCurrentA1)">{{ formatFixedFineCoeff(fixedCurrentA1) }}</span>
</div>
<div class="min-w-0 bg-white dark:bg-gray-800 border border-slate-100 dark:border-gray-700 rounded-lg p-2 text-center shadow-sm">
<span class="text-[9px] text-slate-400 block font-bold mb-1">δ = a1 + a2 + a0</span>
<span class="block max-w-full truncate text-sm font-mono font-bold role-text-secondary" :title="formatFixedFineCoeff(fixedCurrentDelta)">{{ formatFixedFineCoeff(fixedCurrentDelta) }}</span>
<span class="text-[9px] text-slate-500 block font-bold mb-1">a2 = r + δ</span>
<span class="block max-w-full truncate text-sm font-mono font-bold role-text-primary-muted" :title="formatFixedFineCoeff(fixedCurrentA2)">{{ formatFixedFineCoeff(fixedCurrentA2) }}</span>
</div>
<div class="min-w-0 bg-white dark:bg-gray-800 border border-slate-100 dark:border-gray-700 rounded-lg p-2 text-center shadow-sm">
<span class="text-[9px] text-slate-500 block font-bold mb-1">差值 a1-a2</span>
<span class="block max-w-full truncate text-xs font-mono text-slate-700 dark:text-gray-300" :title="formatFixedFineCoeff(fixedCurrentA1A2Diff)">{{ formatFixedFineCoeff(fixedCurrentA1A2Diff) }}</span>
</div>
<div class="min-w-0 bg-white dark:bg-gray-800 border border-slate-100 dark:border-gray-700 rounded-lg p-2 text-center shadow-sm">
<span class="text-[9px] text-slate-500 block font-bold mb-1">總和 a1+a2</span>
<span class="block max-w-full truncate text-xs font-mono text-slate-700 dark:text-gray-300" :title="formatFixedFineCoeff(fixedCurrentA1A2Sum)">{{ formatFixedFineCoeff(fixedCurrentA1A2Sum) }}</span>
</div>
</div>
<div class="role-bg-warning-soft border role-border-warning-soft rounded-lg p-3">
<p class="text-[10px] role-text-warning leading-relaxed">
<span class="font-bold">運算說明</span><br>
a0 = 2^{{ shiftBitsA }}a1 = -a0 - r, a2 = r + δ
a0 = 2^{{ shiftBitsA }}a1 = -a0 - ra2 = r + δ
</p>
</div>
</div>
</details>
</div>
<p class="text-[10px] text-slate-400 dark:text-gray-500 mt-3 italic">b 係數乘以 2^{{ shiftBitsB }}a
<p class="text-[10px] text-slate-500 dark:text-gray-500 mt-3 italic">b 係數乘以 2^{{ shiftBitsB }}a
係數乘以 2^{{ shiftBitsA }} 後四捨五入可用於定點數 DSP 實作</p>
</section>
@@ -704,44 +718,10 @@
時域訊號探討</h3>
</div>
<div class="p-6 z-10 relative bg-white dark:bg-dark transition-colors duration-300">
<p class="text-base text-slate-500 dark:text-gray-400 mb-4">請上傳包含時域訊號的 CSV 檔案系統會使用上述設定的差分濾波器
<p class="text-base text-slate-600 dark:text-gray-400 mb-4">請上傳包含時域訊號的 CSV 檔案系統會使用上述設定的差分濾波器
</p>
<!-- 資料預覽 -->
<div v-if="csvPreview.length > 0" class="mb-6 border border-slate-100 dark:border-gray-800 rounded-xl overflow-hidden shadow-sm">
<button type="button" @click="isCsvPreviewExpanded = !isCsvPreviewExpanded"
class="w-full flex items-center justify-between p-3 bg-slate-50 dark:bg-gray-800/50 hover:bg-slate-100 dark:hover:bg-gray-800 transition-colors">
<h4 class="text-sm font-semibold text-slate-600 dark:text-gray-300 uppercase tracking-tight flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16"></path>
</svg>
資料預覽 (前五筆)
</h4>
<svg class="w-5 h-5 text-slate-400 transform transition-transform duration-200" :class="{ 'rotate-180': isCsvPreviewExpanded }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</button>
<div v-show="isCsvPreviewExpanded" class="overflow-x-auto bg-white dark:bg-gray-900 border-t border-slate-100 dark:border-gray-800">
<table class="w-full text-sm text-left">
<thead>
<tr class="bg-slate-50/50 dark:bg-gray-800/30">
<th v-for="col in csvColumns" :key="col"
class="px-4 py-3 font-semibold text-slate-700 dark:text-gray-300 border-b border-slate-100 dark:border-gray-800">{{ col }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-gray-800">
<tr v-for="(row, ri) in csvPreview" :key="ri"
class="hover:bg-slate-50 dark:hover:bg-gray-800/50 transition-colors">
<td v-for="(val, ci) in row" :key="ci"
class="px-4 py-2 font-mono text-slate-600 dark:text-gray-400">{{ val }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div
class="flex items-center flex-wrap gap-4 mb-6 p-4 bg-slate-50 dark:bg-gray-900/50 rounded-lg border border-slate-100 dark:border-gray-800">
class="flex items-center flex-wrap gap-4 mb-6 p-4 bg-white dark:bg-gray-800 rounded-lg border border-slate-100 dark:border-gray-700 shadow-sm">
<label
class="cursor-pointer role-bg-primary role-hover-primary role-text-on-fill px-5 py-3 rounded-lg text-base font-semibold transition-all shadow-md role-shadow-primary flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -755,7 +735,7 @@
<span v-if="csvFile" class="text-base role-text-primary font-mono">{{
csvFile.name }}</span>
<span v-if="csvInfo"
class="text-sm text-slate-500 dark:text-gray-400 font-mono">
class="text-sm text-slate-600 dark:text-gray-400 font-mono">
{{ csvInfo.rows }} / {{ csvInfo.columns }}
</span>
<span v-if="csvParseError"
@@ -767,7 +747,7 @@
class="h-8 border-l border-slate-200 dark:border-gray-700 mx-2"></div>
<div v-if="csvColumns.length > 0" class="flex items-center gap-2">
<span class="text-sm text-slate-400">訊號欄位:</span>
<span class="text-sm text-slate-500">訊號欄位:</span>
<select v-model="selectedColumn"
class="bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-base role-focus-primary outline-none text-slate-900 dark:text-gray-100">
<option v-for="(col, idx) in csvColumns" :key="idx" :value="idx">{{ col }}</option>
@@ -791,55 +771,79 @@
</div>
<!-- V2: 定點數時域設定 -->
<div class="mb-6 p-4 bg-slate-50 dark:bg-gray-900/50 rounded-lg border border-slate-100 dark:border-gray-800">
<h4 class="text-sm font-semibold text-slate-500 dark:text-gray-400 mb-4 uppercase tracking-tight flex items-center gap-2">
<div
class="mb-6 p-4 bg-slate-50 dark:bg-gray-900/50 rounded-lg border border-slate-100 dark:border-gray-800">
<h4
class="text-sm font-semibold text-slate-600 dark:text-gray-400 mb-4 uppercase tracking-tight flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 7h6M9 12h6M9 17h3M5 5a2 2 0 012-2h7l5 5v11a2 2 0 01-2 2H7a2 2 0 01-2-2V5z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 7h6M9 12h6M9 17h3M5 5a2 2 0 012-2h7l5 5v11a2 2 0 01-2 2H7a2 2 0 01-2-2V5z">
</path>
</svg>
定點數模擬設定 (Fixed-Point Simulation Settings)
</h4>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 lg:gap-6">
<div>
<label class="text-xs text-slate-500 dark:text-gray-400 mb-2 block font-bold">輸入訊號量化 (Q_in): 2^{{ shiftBitsIn }}</label>
<div v-if="!isTouchInput" class="grid grid-cols-[2.5rem_1fr_2.5rem] gap-2 items-center">
<button type="button" @pointerdown.prevent="startShiftInRepeat(-1)" @pointerup="stopShiftInRepeat" @pointerleave="stopShiftInRepeat" @pointercancel="stopShiftInRepeat"
class="stepper-button h-10 flex items-center justify-center rounded-lg role-bg-primary-soft role-text-primary transition-all active:scale-95 text-lg font-bold border role-border-primary-soft">-</button>
<input type="number" :value="shiftBitsIn" @change="setShiftBitsIn($event.target.value)"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-2 text-center font-mono text-sm role-text-primary outline-none">
<button type="button" @pointerdown.prevent="startShiftInRepeat(1)" @pointerup="stopShiftInRepeat" @pointerleave="stopShiftInRepeat" @pointercancel="stopShiftInRepeat"
class="stepper-button h-10 flex items-center justify-center rounded-lg role-bg-primary-soft role-text-primary transition-all active:scale-95 text-lg font-bold border role-border-primary-soft">+</button>
<label
class="text-sm text-slate-600 dark:text-gray-400 mb-2 block font-bold">輸入訊號量化
(Q_in): 2^{{ shiftBitsIn }}</label>
<div v-if="!isTouchInput"
class="grid grid-cols-[2.5rem_1fr_2.5rem] gap-2 items-center">
<button type="button" @pointerdown.prevent="startShiftInRepeat(-1)"
@pointerup="stopShiftInRepeat" @pointerleave="stopShiftInRepeat"
@pointercancel="stopShiftInRepeat"
class="stepper-button h-[42px] flex items-center justify-center rounded-full border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-700 dark:text-gray-300 hover:bg-slate-100 dark:hover:bg-gray-700 transition-all duration-150 active:scale-90 text-lg font-bold shadow-sm">-</button>
<input type="number" :value="shiftBitsIn"
@change="setShiftBitsIn($event.target.value)"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg px-2 h-[42px] text-center font-mono text-sm role-text-primary outline-none">
<button type="button" @pointerdown.prevent="startShiftInRepeat(1)"
@pointerup="stopShiftInRepeat" @pointerleave="stopShiftInRepeat"
@pointercancel="stopShiftInRepeat"
class="stepper-button h-[42px] flex items-center justify-center rounded-full border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-700 dark:text-gray-300 hover:bg-slate-100 dark:hover:bg-gray-700 transition-all duration-150 active:scale-90 text-lg font-bold shadow-sm">+</button>
</div>
<button v-else type="button" @pointerdown.prevent="startShiftInDrag($event)"
class="touch-none select-none w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-center role-text-primary font-mono font-bold">
{{ shiftBitsIn }} bits
class="touch-none select-none w-28 mx-auto bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-center role-active-border-primary active:ring-2 role-active-ring-primary">
<span class="text-base font-mono font-bold role-text-primary">{{ shiftBitsIn }}
bits</span>
</button>
</div>
<div>
<label class="text-xs text-slate-500 dark:text-gray-400 mb-2 block font-bold">輸出訊號量化 (Q_out): 2^{{ shiftBitsOut }}</label>
<div v-if="!isTouchInput" class="grid grid-cols-[2.5rem_1fr_2.5rem] gap-2 items-center">
<button type="button" @pointerdown.prevent="startShiftOutRepeat(-1)" @pointerup="stopShiftOutRepeat" @pointerleave="stopShiftOutRepeat" @pointercancel="stopShiftOutRepeat"
class="stepper-button h-10 flex items-center justify-center rounded-lg role-bg-secondary-soft role-text-secondary transition-all active:scale-95 text-lg font-bold border role-border-secondary-soft">-</button>
<input type="number" :value="shiftBitsOut" @change="setShiftBitsOut($event.target.value)"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-2 text-center font-mono text-sm role-text-secondary outline-none">
<button type="button" @pointerdown.prevent="startShiftOutRepeat(1)" @pointerup="stopShiftOutRepeat" @pointerleave="stopShiftOutRepeat" @pointercancel="stopShiftOutRepeat"
class="stepper-button h-10 flex items-center justify-center rounded-lg role-bg-secondary-soft role-text-secondary transition-all active:scale-95 text-lg font-bold border role-border-secondary-soft">+</button>
<label
class="text-sm text-slate-600 dark:text-gray-400 mb-2 block font-bold">輸出訊號量化
(Q_out): 2^{{ shiftBitsOut }}</label>
<div v-if="!isTouchInput"
class="grid grid-cols-[2.5rem_1fr_2.5rem] gap-2 items-center">
<button type="button" @pointerdown.prevent="startShiftOutRepeat(-1)"
@pointerup="stopShiftOutRepeat" @pointerleave="stopShiftOutRepeat"
@pointercancel="stopShiftOutRepeat"
class="stepper-button h-[42px] flex items-center justify-center rounded-full border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-700 dark:text-gray-300 hover:bg-slate-100 dark:hover:bg-gray-700 transition-all duration-150 active:scale-90 text-lg font-bold shadow-sm">-</button>
<input type="number" :value="shiftBitsOut"
@change="setShiftBitsOut($event.target.value)"
class="w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg px-2 h-[42px] text-center font-mono text-sm role-text-primary outline-none">
<button type="button" @pointerdown.prevent="startShiftOutRepeat(1)"
@pointerup="stopShiftOutRepeat" @pointerleave="stopShiftOutRepeat"
@pointercancel="stopShiftOutRepeat"
class="stepper-button h-[42px] flex items-center justify-center rounded-full border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-slate-700 dark:text-gray-300 hover:bg-slate-100 dark:hover:bg-gray-700 transition-all duration-150 active:scale-90 text-lg font-bold shadow-sm">+</button>
</div>
<button v-else type="button" @pointerdown.prevent="startShiftOutDrag($event)"
class="touch-none select-none w-full bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-center role-text-secondary font-mono font-bold">
{{ shiftBitsOut }} bits
class="touch-none select-none w-28 mx-auto bg-white dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-center role-active-border-primary active:ring-2 role-active-ring-primary">
<span class="text-base font-mono font-bold role-text-primary">{{ shiftBitsOut }}
bits</span>
</button>
</div>
<div>
<label class="text-xs text-slate-500 dark:text-gray-400 mb-2 block font-bold">位移捨去模式 (Truncation)</label>
<div class="selector-button-group flex gap-1 h-10">
<label
class="text-sm text-slate-600 dark:text-gray-400 mb-2 block font-bold">位移捨去模式
(Truncation)</label>
<div class="selector-button-group flex gap-2 h-[42px]">
<button type="button" @click="setUseRound(false)"
:class="!useRound ? 'primary-action role-bg-primary role-text-on-fill role-border-primary' : 'bg-slate-200 dark:bg-gray-800 text-slate-600 dark:text-gray-400 border-slate-300 dark:border-gray-700'"
class="selector-button flex-1 rounded py-0.5 text-xs font-medium border transition-colors">
:class="!useRound ? 'primary-action role-bg-primary role-text-on-fill role-border-primary' : 'bg-slate-200 dark:bg-gray-800 text-slate-700 dark:text-gray-400 border-slate-300 dark:border-gray-700'"
class="selector-button flex-1 rounded py-2 text-sm font-medium border transition-colors">
Floor (SRA)
</button>
<button type="button" @click="setUseRound(true)"
:class="useRound ? 'primary-action role-bg-primary role-text-on-fill role-border-primary' : 'bg-slate-200 dark:bg-gray-800 text-slate-600 dark:text-gray-400 border-slate-300 dark:border-gray-700'"
class="selector-button flex-1 rounded py-0.5 text-xs font-medium border transition-colors">
:class="useRound ? 'primary-action role-bg-primary role-text-on-fill role-border-primary' : 'bg-slate-200 dark:bg-gray-800 text-slate-700 dark:text-gray-400 border-slate-300 dark:border-gray-700'"
class="selector-button flex-1 rounded py-2 text-sm font-medium border transition-colors">
Round (+0.5)
</button>
</div>
@@ -847,8 +851,6 @@
</div>
</div>
<!-- 波形比較圖 -->
<h4 v-if="filterDone" class="text-base font-bold text-slate-800 dark:text-gray-200 mb-3">波形比較圖
</h4>
@@ -859,7 +861,7 @@
</div>
<div v-if="!filterDone && !loadingFilter && csvPreview.length === 0"
class="h-[300px] flex flex-col items-center justify-center text-slate-400 dark:text-gray-500 border-2 border-dashed border-slate-200 dark:border-gray-800 rounded-xl bg-slate-50/50 dark:bg-gray-900/30">
class="h-[300px] flex flex-col items-center justify-center text-slate-500 dark:text-gray-500 border-2 border-dashed border-slate-200 dark:border-gray-800 rounded-xl bg-slate-50/50 dark:bg-gray-900/30">
<svg class="w-12 h-12 mb-3 opacity-50" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
@@ -875,7 +877,7 @@
<!-- 全域置底版權宣告 -->
<footer
class="bg-white dark:bg-dark border-t border-slate-200 dark:border-gray-800 py-2 text-center z-20 transition-colors duration-300">
<p class="text-xs text-slate-400 dark:text-gray-600">© 2026 喆富創新科技股份有限公司. All rights reserved.</p>
<p class="text-xs text-slate-500 dark:text-gray-600">© 2026 喆富創新科技股份有限公司. All rights reserved.</p>
</footer>
</div>
</template>
+103 -30
View File
@@ -80,6 +80,7 @@ export default {
webSerialSupported: false,
writingMCU: false,
mcuStatus: '',
saveSettingsTimeout: null,
bodeMagRange: null, // [min, max] for Y-axis
}
},
@@ -221,6 +222,51 @@ 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(); }
},
mounted() {
this.isTouchInput = window.matchMedia?.('(pointer: coarse)').matches || false;
window.addEventListener('pointerdown', this.rememberPointerType);
@@ -232,6 +278,9 @@ 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 連線)';
@@ -253,6 +302,42 @@ export default {
this.stopShiftOutDrag();
},
methods: {
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'
];
keys.forEach(k => {
if (parsed[k] !== undefined) {
this[k] = parsed[k];
}
});
}
} catch (e) {
console.error('Failed to load settings:', e);
}
},
saveSettings() {
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'
];
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 +819,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 +834,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 {
@@ -1067,30 +1152,30 @@ 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 // 相位縱軸鎖死,不可縮放
},
@@ -1213,7 +1298,7 @@ export default {
this.selectedColumn = 1;
}
}
// 背景上傳至暫存區
const formData = new FormData();
formData.append('file', file);
@@ -1221,7 +1306,7 @@ export default {
if (!res.ok) { const err = await res.json(); throw new Error(err.detail || '背景上傳失敗'); }
const data = await res.json();
this.csvFileId = data.file_id;
} catch (err) {
this.csvParseError = err.message || 'CSV 解析失敗';
this.csvColumns = [];
@@ -1397,17 +1482,5 @@ export default {
this.writingMCU = false;
}
}
},
watch: {
useRound() { this.debouncedProcessFilter(); },
shiftBitsIn() { this.debouncedProcessFilter(); },
shiftBitsOut() { this.debouncedProcessFilter(); },
shiftBitsB() { this.debouncedProcessFilter(); },
shiftBitsA() { this.debouncedProcessFilter(); },
b_int_str() { this.debouncedProcessFilter(); },
a_int_str() { this.debouncedProcessFilter(); },
b_str() { this.debouncedProcessFilter(); },
a_str() { this.debouncedProcessFilter(); }
}
}
+1489 -1668
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
File diff suppressed because one or more lines are too long
-9
View File
@@ -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,