Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 345b4a789a | |||
| 8009a0dd78 | |||
| 2762d0da86 | |||
| da2cbfe8da |
-137
@@ -1,137 +0,0 @@
|
||||
# Difference Equation Analyzer (DEA) - Architecture & Functionality
|
||||
|
||||
## Overview
|
||||
DEA is a specialized tool for designing, visualizing, and validating discrete-time filters. It bridges the gap between theoretical filter design (floating-point) and hardware implementation (fixed-point/MCU).
|
||||
|
||||
## 🏗 System Architecture
|
||||
|
||||
### 1. Backend (Python 3.12 + FastAPI)
|
||||
The backend is responsible for heavy mathematical computations and data processing.
|
||||
|
||||
- **API Entry (`dea_api.py`)**: Routes requests to appropriate modules and serves the Vue.js frontend from `static/`.
|
||||
- **Computation Engine (`dea/`)**:
|
||||
- `filter_design.py`: Uses `scipy.signal` to calculate coefficients for various filter topologies.
|
||||
- `bode.py`: Computes magnitude and phase response. Supports comparing "Ideal" (float) vs "Fixed" (quantized) responses.
|
||||
- `csv_processing.py`: Processes time-domain CSV data. It applies the current filter to the signal and downsamples the result for efficient visualization.
|
||||
- `validation.py`: Protects against unstable filter designs and invalid inputs.
|
||||
|
||||
### 2. Frontend (Vue 3 + Vite + Tailwind)
|
||||
A reactive "Single Page Application" (SPA) approach.
|
||||
|
||||
> ⚠️ **開發 vs 部署 (Development vs Production)**
|
||||
> - **開發模式 (Development)**: 透過 `npm run dev` 啟動 Vite dev server (預設 `http://localhost:5173`)。前端原始碼修改後會自動熱更新 (HMR),**不需要** 手動 build。API 請求透過 `vite.config.js` 的 proxy 設定轉發至後端。
|
||||
> - **生產模式 (Production)**: 透過 `http://localhost:8000/ui/` 存取。FastAPI 直接 serve `static/build/` 裡的 production bundle。**修改 `src/` 原始碼後,必須執行 `npm run build` 才會生效**。忘記 build 會導致前端程式碼與後端不同步,所有新功能看起來都「沒有作用」。
|
||||
|
||||
- **Reactive Logic (`src/app-options.js`)**:
|
||||
- Manages the state of all coefficients.
|
||||
- Implements "Fine-tuning" sliders that allow real-time adjustment of poles/zeros.
|
||||
- Handles Fixed-Point conversion logic (Q-format shifting).
|
||||
- **UI Components (`src/App.vue`)**:
|
||||
- **Control Sidebar**: Interactive inputs for all filter parameters.
|
||||
- **Visualization**: Dual-plot system using Plotly.js for Bode plots and Time-domain waveforms.
|
||||
- **Hardware Bridge**: Uses the **Web Serial API** to send commands directly to connected MCUs (requires HTTPS).
|
||||
|
||||
## 🚀 Execution Flow
|
||||
1. **Design**: User selects a filter type or enters coefficients manually.
|
||||
2. **Quantize**: User adjusts Q-format bits to see how quantization affects the frequency response.
|
||||
3. **Verify**: User uploads a CSV to see how the filter behaves with real-world signals.
|
||||
4. **Deploy**: User clicks "Write to MCU" to send the coefficients to their hardware.
|
||||
|
||||
## 🛠 Tech Stack
|
||||
- **Backend**: FastAPI, NumPy, SciPy, Pandas, Uvicorn.
|
||||
- **Frontend**: Vue 3, Vite, Tailwind CSS, Plotly.js.
|
||||
- **Security**: Restricted to LAN/Loopback; implemented security headers and HTTPS support.
|
||||
|
||||
---
|
||||
|
||||
## 💎 V2 Feature: Parallel Fixed-Point Simulation
|
||||
本分支 (`dev-v2-integretimedomain`) 的核心任務是在時域分析中實作「雙路徑對照系統」:
|
||||
|
||||
### 1. 雙路徑模擬邏輯 (Dual-Path Logic)
|
||||
- **理想路徑 (Float Path)**: 使用 64-bit 浮點數進行運算,作為效能基準。
|
||||
- **硬體模擬路徑 (Integer Path)**:
|
||||
- **輸入量化**: 將原始訊號依 $Q_{in}$ 轉為整數。
|
||||
- **整數運算**: 使用整數係數 ($Q_{coeff}$) 進行差分方程式計算。
|
||||
- **位移校準**: 運算過程中考慮累加器位元數,並依 $Q_{out}$ 進行最終縮放。
|
||||
- **效果**: 模擬真實 DSP 的捨入誤差 (Rounding Error) 與量化雜訊。
|
||||
|
||||
### 2. 資料流更新
|
||||
- **API `/api/filter`**: 接收檔案 ID 與係數參數,返回兩組數列(Float / Fixed)。
|
||||
- **UI 圖表**: 同時繪製兩條曲線,讓開發者直觀判斷量化是否導致不穩定 (Instability) 或顯著失真。
|
||||
|
||||
### 3. 高效能資料處理架構 (High-Performance Data Processing)
|
||||
- **檔案快取與防呆**:
|
||||
- 支援高達 **300 MB** 的 CSV 檔案上傳。
|
||||
- 當檔案被選擇後,立即背景上傳至後端暫存區 (`temp_csv/`),避免重複傳輸。
|
||||
- 嚴格限制時域運算最多處理 **1,200,000 筆** 資料點,保護伺服器不因 O(N) 的純 Python 整數運算而死當。
|
||||
- **精準記憶體讀取**:
|
||||
- 利用 Pandas 的 `usecols` 參數,僅載入使用者選定的單一訊號欄位,極大幅度降低記憶體佔用。
|
||||
- **即時預覽 (Live Preview)**:
|
||||
- 透過前端的 Debounce 機制 (500ms),當使用者點擊 +/- 按鈕微調 Q-format 參數或係數時,系統會自動使用快取的檔案呼叫 API 並重繪圖表,達成無縫且直覺的實驗體驗。
|
||||
|
||||
---
|
||||
|
||||
## 🛠 Firmware Implementation Guidelines (韌體實作指引)
|
||||
|
||||
在將此工具設計出來的濾波器係數移植到 C 語言或 MCU (如 RISC-V) 時,請務必遵守以下實作準則,以確保硬體行為與本系統的模擬結果 100% 一致:
|
||||
|
||||
### 1. 高精度狀態變數架構 (Extended Precision State Variables)
|
||||
為了極小化截斷誤差 (Truncation Error),硬體實作的迴圈請**不要**對前饋 (Feed-forward) 進行位移。
|
||||
- **作法**:讓歷史陣列 `y_history[]` 保存前饋乘加後的高精度格式 ($Q_{in+b}$)。僅在計算回授 (Feedback) 的乘積後,才對回授項進行右移對齊,並在最終輸出給硬體腳位時做最後的右移。這等於讓 IIR 濾波器內部默默保留了額外的 $Q_b$ bits 小數精度。
|
||||
|
||||
### 2. 消除直流偏移 (DC Bias) 的 1-Clock Rounding 演算法秘技
|
||||
傳統的右移 (`>>`) 會造成無條件捨去向下取整 (Floor),這會引入永遠為負的平均誤差 ($-0.5$ LSB),導致濾波器產生直流偏移。必須改用四捨五入 (Rounding)。
|
||||
- **硬體現狀**:標準的 RISC-V 指令集 (RV32I / RV32IMAC) **沒有**硬體的 round off shift 指令 (其 `SRA` 指令是純 Floor)。除非晶片具備 DSP 擴充指令集 ('P' Extension)。
|
||||
- **C 語言實作秘技**:絕對**不要**為了四捨五入去呼叫 C standard library 的 `round()`,這會啟動浮點數運算,吃掉上百個 Clock。請用純整數實作:
|
||||
```c
|
||||
// 完美的 1-clock 整數四捨五入寫法 (Round to nearest):
|
||||
// 編譯器會將 (1 << (shift_bits - 1)) 編譯為常數,只會多消耗一個 ADD 指令。
|
||||
y_out = (acc + (1 << (shift_bits - 1))) >> shift_bits;
|
||||
```
|
||||
這個技巧已實作於本工具前端的「Round (+0.5 補償)」選項中,開啟後可大幅提升信噪比與波形穩定性。
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Deployment & Development Notes (部署與開發環境注意事項)
|
||||
|
||||
為了支援 **Web Serial API** 直接與外部 MCU 通訊,本專案在生產與正式使用中**全面採用 HTTPS 加密傳輸**。這對於開發代理(Proxy)與存取路徑有以下影響:
|
||||
|
||||
### 1. Vite Proxy 與 SSL 協定對齊
|
||||
當 Uvicorn 以 **HTTPS** 模式啟動時(綁定 `key.pem` 與 `cert.pem`),Vite Dev Server 的 `/api` 代理目標**必須對齊為 `https://`**,並加上 `secure: false` 以允許自簽憑證:
|
||||
|
||||
```js
|
||||
// ✅ 正確 (HTTPS 模式下)
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'https://127.0.0.1:8000',
|
||||
changeOrigin: true,
|
||||
secure: false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 生產部署流程(純使用者使用)
|
||||
如果您是**純使用者**,或者需要進行系統正式展示,**完全不需要啟動 Vite Dev Server (5173)**!
|
||||
您只需要在修改前端後進行一次編譯,然後使用 FastAPI 單獨運行 8000 連接埠即可:
|
||||
|
||||
```bash
|
||||
# 1. 前端編譯 (只需在修改 src/ 代碼後執行一次,若無修改則免)
|
||||
npm run build # 將 Vue 代碼打包至 static/build/
|
||||
|
||||
# 2. 啟動後端 HTTPS 服務 (同時提供 API 與網頁 UI)
|
||||
.venv/bin/uvicorn dea_api:app --host 0.0.0.0 --port 8000 --ssl-keyfile certs/key.pem --ssl-certfile certs/cert.pem
|
||||
```
|
||||
或直接利用 systemd 後台服務啟動(開機自動拉起):
|
||||
```bash
|
||||
sudo systemctl restart dea.service
|
||||
```
|
||||
|
||||
### 3. 開發模式 vs 生產模式對照表
|
||||
| 項目 | 開發模式 (Vite Dev Server) | 生產模式 (FastAPI Static Serve) |
|
||||
|------|----------------|---------------------|
|
||||
| **運行連接埠** | `http://localhost:5173` | `https://localhost:8000/ui/` |
|
||||
| **後端通訊** | 透過 Vite Proxy 轉發至 8000 (HTTPS) | 同一 Port 直接連線,極致穩定且高效 |
|
||||
| **修改後生效** | 自動熱更新 (HMR),不需手動 build | 必須執行 `npm run build`,並在瀏覽器進行硬重新整理 |
|
||||
| **主要用途** | 適合前端網頁排版、UI 除錯開發 | **正式使用、Demo 展示、MCU Web Serial 通訊** |
|
||||
| **是否需 5173**| **是** | **否,完全不需啟用!** |
|
||||
|
||||
@@ -1,293 +0,0 @@
|
||||
# Difference Equation Analyzer (DEA)
|
||||
## 定點數整數差分方程式實作規格說明書
|
||||
|
||||
本文件旨在為嵌入式韌體、DSP 與硬體描述語言(HDL)開發工程師,提供本專案「定點數整數濾波模擬(`integer_lfilter`)」的完整實作細節與移植指引。
|
||||
|
||||
本系統所採用的定點數架構為**高精度狀態變數架構 (Extended Precision State Variables)**,並支援 **1-Clock 高效能硬體四捨五入 (Rounding)**,能有效消除直流偏移(DC Bias)並最大化訊噪比(SNR)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 定點數 Q 格式 (Q-Format) 定義
|
||||
|
||||
在實作中,所有浮點數(實數)均以帶符號的整數進行二進位縮放表示:
|
||||
$$\text{整數值} = \text{round}\left( \text{浮點數值} \times 2^{Q} \right)$$
|
||||
|
||||
本專案定義了以下四個獨立的 Q 格式位移參數:
|
||||
|
||||
| 參數名稱 | 代號 | 說明 | 格式描述 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `shift_in` | $Q_{in}$ | 輸入訊號 $x[n]$ 的量化位元數 | $x_{\text{int}}[n] \in Q_{in}$ |
|
||||
| `shift_out` | $Q_{out}$ | 最終輸出訊號 $y[n]$ 的量化位元數 | $y_{\text{out}}[n] \in Q_{out}$ |
|
||||
| `shift_b` | $Q_b$ | 前饋(Feedforward)係數 $b$ 的量化位元數 | $b_{\text{int}}[i] \in Q_b$ |
|
||||
| `shift_a` | $Q_a$ | 回授(Feedback)係數 $a$ 的量化位元數 | $a_{\text{int}}[j] \in Q_a$ |
|
||||
|
||||
---
|
||||
|
||||
## 2. 核心架構:高精度狀態變數 (Extended Precision State Variables)
|
||||
|
||||
為了極小化乘加運算中的截斷誤差 (Truncation Error) 並防止 IIR 濾波器極點附近因量化產生的極限環震盪(Limit Cycles),本架構採用**前饋完全不右移**的策略:
|
||||
|
||||
1. **狀態變數 $y_{\text{hist}}$ 精度保持**:歷史狀態變數 $y_{\text{hist}}[n-j]$ 並非儲存低精度的最終輸出 $y_{\text{out}}$,而是直接儲存前饋與回授累加後、尚未進行輸出位移的**高精度狀態值**。其格式固定為:
|
||||
$$y_{\text{hist}} \in Q_{in + b}$$
|
||||
2. **免除中間位移**:在前饋運算中,$b_{\text{int}} \times x_{\text{int}}$ 的結果為 $Q_b \times Q_{in} = Q_{in+b}$,完全不需進行任何位移,即可直接累加至 `sum_b` 中。
|
||||
3. **回授對齊縮放(歸一化 $Q_a$)**:回授運算中,係數 $a_{\text{int}} \in Q_a$,歷史狀態 $y_{\text{hist}} \in Q_{in+b}$,兩者相乘後格式為 $Q_{in+b+a}$。
|
||||
|
||||
> 💡 **重要前提與物理意義:假設 $A_0 = 1$**
|
||||
> 濾波器公式通常已正規化使得回授係數 $A_0 = 1$。當我們使用 $Q_a$ 將 $a$ 係數放大時,$A_0$ 的整數值實際上就是 $2^{Q_a}$。
|
||||
>
|
||||
> 在運算完畢後,這項「因為 $a$ 係數被放大了 $Q_a$ 倍」而產生的額外增益必須被**歸一(Normalize)**,否則數值會以指數級別爆炸!因此,我們將回授乘加總和除以 $A_0$(在二進位中等同於**算術右移 `shift_a` 位元**),藉此消去 $Q_a$ 的放大效應,使其完美對齊回 $Q_{in+b}$ 格式,才能與前饋結果相減。
|
||||
>
|
||||
> 💡 **關於輸入訊號 $x$ 與前饋係數 $b$ 的歸一化討論**
|
||||
> 許多工程師會問:既然回授部分需要被歸一以防爆炸,那輸入訊號 $x$(放大了 $Q_{in}$ 倍)與前饋係數 $b$(放大了 $Q_b$ 倍)在運算過程中是否也需要被歸一化?
|
||||
> **答案是:完全不需要,而且我們也故意沒有做!**
|
||||
>
|
||||
> 這是因為:
|
||||
> 1. **避免資訊截斷(Truncation Noise)**:如果我們在累加到 `sum_b` 時就進行右移歸一,會直接把低位元的小數精度給丟棄,造成無法挽回的量化雜訊。
|
||||
> 2. **最優化運算效能**:在迴圈內部,`sum_b`($Q_{in+b}$)與被歸一後的 `sum_a_scaled`($Q_{in+b}$)可以直接進行減法運算。整個迴圈內部不需要對 $x$ 與 $b$ 進行 any 移位,這在硬體實作中極大提升了運算速度。
|
||||
> 3. **延遲至最終輸出對齊**:輸入與前饋所累積下來的放大倍率(也就是 $Q_{in+b}$),將會完完整整地保留在狀態變數中,一路以高精度參與未來的回授迭代。直到濾波運算結束、準備將訊號寫入硬體輸出端(如 DAC)時,我們才會一次性地移去這段增益(即右移 $S_{\text{out}} = Q_{in} + Q_b - Q_{out}$ 位元)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 核心演算法步驟與數學推導
|
||||
|
||||
對於每個輸入採樣 $x_{\text{float}}[n]$,整數濾波器的計算流程如下:
|
||||
|
||||
### 步驟一:輸入量化 (Input Quantization)
|
||||
將浮點數輸入訊號量化為整數 $x_{\text{int}}[n]$:
|
||||
$$x_{\text{int}}[n] = \text{round}\left( x_{\text{float}}[n] \times 2^{Q_{in}} \right)$$
|
||||
|
||||
### 步驟二:前饋累加 (Feedforward Accumulation - 處理 $b$ 係數)
|
||||
計算前饋部分,累積至高精度累加器 `sum_b`(規格使用 32-bit 帶符號整數 `int32_t`):
|
||||
$$sum\_b = \sum_{i=0}^{N_b-1} b_{\text{int}}[i] \cdot x_{\text{int}}[n-i]$$
|
||||
*此時 $sum\_b \in Q_{in+b}$。*
|
||||
|
||||
### 步驟三:回授累加與歸一化 (Feedback Accumulation - 處理 $a$ 係數)
|
||||
1. 計算高精度的回授乘加值:
|
||||
$$sum\_a = \sum_{j=1}^{N_a-1} a_{\text{int}}[j] \cdot y_{\text{hist}}[n-j]$$
|
||||
*此時 $sum\_a \in Q_{in+b+a}$。*
|
||||
|
||||
2. **歸一化 $Q_a$ 放大倍數**:將回授項除以 $A_0$(即 $a_{\text{int}}[0]$,通常為 $2^{Q_a}$)以對齊至 $Q_{in+b}$:
|
||||
* **Floor 模式(無條件捨去,非常不建議)**:
|
||||
$$sum\_a\_scaled = \lfloor \frac{sum\_a}{A_0} \rfloor \approx sum\_a \gg Q_a$$
|
||||
* **Round 模式(四捨五入,專案預設與推薦)**:
|
||||
$$round\_offset\_a = A_0 \gg 1$$
|
||||
$$sum\_a\_scaled = \lfloor \frac{sum\_a + round\_offset\_a}{A_0} \rfloor \approx \left( sum\_a + (1 \ll (Q_a - 1)) \right) \gg Q_a$$
|
||||
|
||||
### 步驟四:更新狀態變數 (State Variable Update)
|
||||
從前饋累加值中減去對齊後的回授值,並直接存入歷史狀態變數:
|
||||
$$acc = sum\_b - sum\_a\_scaled$$
|
||||
$$y_{\text{hist}}[n] = acc$$
|
||||
*歷史狀態變數 $y_{\text{hist}}$ 格式為 $Q_{in+b}$。*
|
||||
|
||||
### 步驟五:最終輸出縮放 (Output Quantization & Scale-Out)
|
||||
因為 $y$ 的實際物理數值與系統增益(System Gain)主要由 $b$ 和 $x$ 決定,這時內部變數已經穩定保持在 $Q_{in+b}$ 格式。
|
||||
`shift_out` 的作用是將內部的高精度數值轉換為符合實體硬體接口(如 12-bit DAC)的位元寬度。
|
||||
計算所需的總位移量 $S_{\text{out}}$:
|
||||
$$S_{\text{out}} = Q_{in} + Q_b - Q_{out}$$
|
||||
|
||||
* **當 $S_{\text{out}} > 0$(需要右移)**:
|
||||
* **Round 模式**:
|
||||
$$round\_offset\_out = 1 \ll (S_{\text{out}} - 1)$$
|
||||
$$y_{\text{out}}[n] = (acc + round\_offset\_out) \gg S_{\text{out}}$$
|
||||
* **當 $S_{\text{out}} < 0$(需要左移)**:
|
||||
$$y_{\text{out}}[n] = acc \ll (-S_{\text{out}})$$
|
||||
* **當 $S_{\text{out}} == 0$(不需位移)**:
|
||||
$$y_{\text{out}}[n] = acc$$
|
||||
|
||||
---
|
||||
|
||||
## 4. 消除直流偏移的 1-Clock Rounding 演算法秘技
|
||||
|
||||
傳統的算術右移(Arithmetic Right Shift, `SRA`)在二進位中代表的是 **Floor(向負無窮大取整)**。
|
||||
這會帶來平均 $-0.5\text{ LSB}$ 的系統性截斷誤差,在時域訊號中會累積成顯著的**直流偏移(DC Bias)**,在 IIR 濾波器中甚至會導致輸出不斷漂移。
|
||||
|
||||
在 C 語言中,千萬不要呼叫 `round()` 等浮點數函式,請使用以下純整數運算:
|
||||
|
||||
```c
|
||||
// 完美的 1-clock 四捨五入右移寫法:
|
||||
// (1 << (shift - 1)) 在編譯時期會被編譯器直接優化為一個立即常數,
|
||||
// 整體運算只比普通右移多消耗一個 ADD 指令,完美消除 DC Bias!
|
||||
y_out = (acc + (1 << (shift - 1))) >> shift;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 完整 C 語言 (MCU 32-bit 最佳化) 實作程式碼範例
|
||||
|
||||
在 32-bit 微處理器(如 ARM Cortex-M 或是 RISC-V RV32I)上,`int32_t` 運算是效能最高、最節省時鐘週期的核心格式(避免了昂貴的 64-bit 軟體乘除模擬)。
|
||||
|
||||
```c
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
#define FILTER_ORDER_MAX 4 // 最大支援濾波器階數
|
||||
|
||||
typedef struct {
|
||||
int32_t b[FILTER_ORDER_MAX + 1]; // Q_b 格式的前饋係數
|
||||
int32_t a[FILTER_ORDER_MAX + 1]; // Q_a 格式的回授係數 (a[0] 通常為 1<<Q_a)
|
||||
int32_t nb; // b 係數個數
|
||||
int32_t na; // a 係數個數
|
||||
|
||||
int16_t shift_in; // Q_in
|
||||
int16_t shift_out; // Q_out
|
||||
int16_t shift_b; // Q_b
|
||||
int16_t shift_a; // Q_a
|
||||
bool use_round; // 是否啟用四捨五入補償
|
||||
|
||||
// 歷史狀態變數 (循環緩衝區)
|
||||
int32_t x_hist[FILTER_ORDER_MAX + 1]; // Q_in 格式的輸入歷史
|
||||
int32_t y_hist[FILTER_ORDER_MAX + 1]; // Q_{in+b} 格式的高精度狀態歷史
|
||||
int32_t x_index; // x 歷史緩衝區指針
|
||||
int32_t y_index; // y 歷史緩衝區指針
|
||||
} FixedFilter_t;
|
||||
|
||||
/**
|
||||
* @brief 初始化濾波器狀態與係數
|
||||
*/
|
||||
void FixedFilter_Init(FixedFilter_t *filter,
|
||||
const int32_t *b_coeffs, int32_t nb,
|
||||
const int32_t *a_coeffs, int32_t na,
|
||||
int16_t shift_in, int16_t shift_out,
|
||||
int16_t shift_b, int16_t shift_a,
|
||||
bool use_round)
|
||||
{
|
||||
filter->nb = (nb > FILTER_ORDER_MAX + 1) ? FILTER_ORDER_MAX + 1 : nb;
|
||||
filter->na = (na > FILTER_ORDER_MAX + 1) ? FILTER_ORDER_MAX + 1 : na;
|
||||
|
||||
for (int32_t i = 0; i < filter->nb; i++) filter->b[i] = b_coeffs[i];
|
||||
for (int32_t j = 0; j < filter->na; j++) filter->a[j] = a_coeffs[j];
|
||||
|
||||
filter->shift_in = shift_in;
|
||||
filter->shift_out = shift_out;
|
||||
filter->shift_b = shift_b;
|
||||
filter->shift_a = shift_a;
|
||||
filter->use_round = use_round;
|
||||
|
||||
filter->x_index = 0;
|
||||
filter->y_index = 0;
|
||||
|
||||
for (int32_t i = 0; i < FILTER_ORDER_MAX + 1; i++) {
|
||||
filter->x_hist[i] = 0;
|
||||
filter->y_hist[i] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 核心定點數差分方程式執行 (32-bit 最佳化版本)
|
||||
* @param x_in Q_in 格式的整數輸入值
|
||||
* @return Q_out 格式的整數輸出值
|
||||
*/
|
||||
int32_t FixedFilter_Process(FixedFilter_t *filter, int32_t x_in)
|
||||
{
|
||||
// 1. 將新輸入寫入循環緩衝區
|
||||
filter->x_hist[filter->x_index] = x_in;
|
||||
|
||||
// 2. 計算前饋 (Feedforward): 處理 b 係數 (Q_b * Q_in -> Q_{in+b})
|
||||
int32_t sum_b = 0;
|
||||
int32_t x_ptr = filter->x_index;
|
||||
for (int32_t i = 0; i < filter->nb; i++) {
|
||||
sum_b += filter->b[i] * filter->x_hist[x_ptr];
|
||||
if (--x_ptr < 0) x_ptr = filter->nb - 1; // 循環指針回繞
|
||||
}
|
||||
|
||||
// 3. 計算回授 (Feedback): 處理 a 係數 (Q_a * Q_{in+b} -> Q_{in+b+a})
|
||||
int32_t sum_a = 0;
|
||||
int32_t y_ptr = filter->y_index;
|
||||
for (int32_t j = 1; j < filter->na; j++) {
|
||||
// y_hist 儲存的是歷史中的高精度狀態變數 (Q_{in+b})
|
||||
sum_a += filter->a[j] * filter->y_hist[y_ptr];
|
||||
if (--y_ptr < 0) y_ptr = filter->na - 1;
|
||||
}
|
||||
|
||||
// 4. 歸一化 a 係數的放大倍數 (除以 A0,即算術右移 shift_a)
|
||||
int32_t A0 = filter->a[0];
|
||||
if (A0 <= 0) A0 = 1; // 防呆
|
||||
|
||||
int32_t sum_a_scaled = 0;
|
||||
if (filter->use_round) {
|
||||
int32_t round_offset_a = A0 >> 1;
|
||||
sum_a_scaled = (sum_a + round_offset_a) / A0;
|
||||
} else {
|
||||
sum_a_scaled = sum_a / A0;
|
||||
}
|
||||
|
||||
// 如果 A0 是 2 的冪次方 (例如 2^shift_a),上述除法可被編譯器優化為算術右移:
|
||||
// sum_a_scaled = (sum_a + (1 << (shift_a - 1))) >> shift_a;
|
||||
|
||||
// 5. 更新狀態變數並寫入歷史緩衝區
|
||||
int32_t acc = sum_b - sum_a_scaled;
|
||||
|
||||
if (++(filter->y_index) >= filter->na) filter->y_index = 0;
|
||||
filter->y_hist[filter->y_index] = acc;
|
||||
|
||||
// 6. 計算最終輸出量化 Q_{in+b} -> Q_{out}
|
||||
int32_t out_shift = filter->shift_in + filter->shift_b - filter->shift_out;
|
||||
int32_t y_out = 0;
|
||||
|
||||
if (out_shift > 0) {
|
||||
if (filter->use_round) {
|
||||
int32_t round_offset_out = (1 << (out_shift - 1));
|
||||
y_out = (acc + round_offset_out) >> out_shift;
|
||||
} else {
|
||||
y_out = acc >> out_shift;
|
||||
}
|
||||
} else if (out_shift < 0) {
|
||||
y_out = acc << (-out_shift);
|
||||
} else {
|
||||
y_out = acc;
|
||||
}
|
||||
|
||||
if (++(filter->x_index) >= filter->nb) filter->x_index = 0;
|
||||
|
||||
return y_out;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 與 Python 模擬的一致性驗證
|
||||
|
||||
本專案後端 `dea/csv_processing.py` 中的 `integer_lfilter` 函數提供完全一致的參考實作。工程師在撰寫 C 語言硬體代碼時,可隨時將硬體輸出的暫存器數值與本專案的 Python 數值進行對齊測試:
|
||||
|
||||
```python
|
||||
# 專案後端 Python 核心運算對照
|
||||
def integer_lfilter(b_int, a_int, x_float, shift_in, shift_out, shift_b, shift_a, use_round=False):
|
||||
x_int = np.round(x_float * (2**shift_in)).astype(np.int64)
|
||||
y_hist = np.zeros(len(x_int), dtype=np.int64) # Q_{in+b} 狀態變數
|
||||
y_out = np.zeros(len(x_int), dtype=np.int64) # Q_out 輸出變數
|
||||
|
||||
out_shift = shift_in + shift_b - shift_out
|
||||
A0 = int(a_int[0])
|
||||
|
||||
round_offset_a = (A0 >> 1) if use_round else 0
|
||||
round_offset_out = (1 << (out_shift - 1)) if (use_round and out_shift > 0) else 0
|
||||
|
||||
for n in range(len(x_int)):
|
||||
sum_b = 0
|
||||
# 前饋累積處理 b 係數 (Q_{in+b})
|
||||
for i in range(len(b_int)):
|
||||
if n - i >= 0:
|
||||
sum_b += b_int[i] * x_int[n - i]
|
||||
|
||||
sum_a = 0
|
||||
# 回授累積處理 a 係數 (Q_{in+b+a})
|
||||
for j in range(1, len(a_int)):
|
||||
if n - j >= 0:
|
||||
sum_a += a_int[j] * y_hist[n - j]
|
||||
|
||||
# 歸一化 a 係數放大倍數,對齊回 Q_{in+b}
|
||||
sum_a_scaled = (sum_a + round_offset_a) // A0
|
||||
|
||||
acc = sum_b - sum_a_scaled
|
||||
y_hist[n] = acc # 存入高精度狀態變數
|
||||
|
||||
# 最終輸出量化至 Q_out
|
||||
if out_shift > 0:
|
||||
y_out[n] = (acc + round_offset_out) >> out_shift
|
||||
elif out_shift < 0:
|
||||
y_out[n] = acc << (-out_shift)
|
||||
else:
|
||||
y_out[n] = acc
|
||||
|
||||
return y_out.astype(float) / (2**shift_out)
|
||||
```
|
||||
@@ -1,114 +0,0 @@
|
||||
# DSP Bode Plot 專案進度總結 (2026-05-15)
|
||||
|
||||
## 1. 核心功能優化 (DSP & 定點數運算)
|
||||
- **細部微調 (Fine-tuning)**:實施了基於 $\delta$ (Delta) 與 $r$ (Radius) 的參數化微調。
|
||||
- **定點數精確度修正**:採用「兩倍值整數運算」邏輯,解決了 $0.5$ 步階在浮點數圓整時產生的「按鍵沒反應」Bug。
|
||||
- **防止漂移 (Oscillation Logic)**:針對數位微調,實施了奇偶震盪平衡邏輯。當調整 $\delta$ 導致 $r$ 必須隨動時,採用震盪補償防止 $r$ 產生單向累積誤差。
|
||||
- **參數連動策略**:在 $0.5$ 步階時,採取「優先滿足目標變動」原則。若調整 $\delta$,則 $r$ 隨動;反之亦然。
|
||||
|
||||
## 2. UI/UX 介面調整
|
||||
- **側邊欄優化**:
|
||||
- 將 $r$ 與 $\delta$ 的調整區域改為**垂直堆疊**,適應 21rem 的窄邊欄。
|
||||
- 移除冗餘的 $a1, a2$ 預覽方塊,僅保留帶有數學公式標籤的 $r, \delta$ 預覽。
|
||||
- **步階序列**:數位微調步階改為自訂序列:`[0.5, 1, 2, 4, 8, 32, 256, 1024, 4096, 16384, 65536]`。
|
||||
- **微調量顯示**:統一使用 `±` 符號並帶空格(例如 `± 0.5`)。
|
||||
- **智能禁用**:當數位步階為 $0.5$ 時,自動禁用 $r$ 的控制項,確保以 $\delta$ 為主導。
|
||||
|
||||
## 3. 繪圖系統 (Bode Plot)
|
||||
- **幅值圖 (Magnitude)**:
|
||||
- 初始範圍:基於 $systemGain \times 3$,涵蓋 $70 \text{ dB}$。
|
||||
- 保留手動彈性:設定為 `fixedrange: false`,支援滑鼠縮放與 Auto Scale。
|
||||
- 狀態保持:使用 `uirevision`,確保微調參數時不會跳回初始縮放。
|
||||
- **相位圖 (Phase)**:
|
||||
- 絕對鎖死:固定範圍為 $[-180^\circ, 180^\circ]$。
|
||||
- 禁止縮放:設定為 `fixedrange: true`,提供穩定的絕對參考。
|
||||
- **座標軸連動**:幅值圖與相位圖的 **X 軸(頻率)已完全同步**。不論在哪一張圖進行橫向縮放,兩者都會連動。
|
||||
|
||||
## 4. 數學公式參考
|
||||
- **理想空間**:
|
||||
- $a1 = -1 - r + \delta$
|
||||
- $a2 = r + \delta$
|
||||
- **定點數空間 (Q14 為例)**:
|
||||
- $a0 = 2^{14} = 16384$
|
||||
- $a1 = -a0 - r + \delta$
|
||||
- $a2 = r + \delta$
|
||||
- $2\delta = a1 + a2 + a0$
|
||||
- $2r = a2 - a1 - a0$
|
||||
|
||||
## 5. 目前 Git 狀態
|
||||
- **當前分支**:`dev-v2-rounding-fix-20260517` (已推送到 origin)。
|
||||
- **同步說明**:此版本包含了 Rounding 功能、Vite proxy 修復與 production build。
|
||||
- **同事試用指令**:
|
||||
```bash
|
||||
git fetch
|
||||
git checkout dev-v2-rounding-fix-20260517
|
||||
```
|
||||
|
||||
---
|
||||
*Last Updated: 2026-05-15 17:47*
|
||||
|
||||
---
|
||||
|
||||
# DSP Bode Plot 專案進度更新 (2026-05-17)
|
||||
|
||||
## 6. 定點數 Rounding 模擬 (V2 Feature)
|
||||
- **Round (+0.5 補償) 功能**:在 `integer_lfilter()` 中實作了硬體級四捨五入模擬,使用 `(acc + (1 << (shift - 1))) >> shift` 取代傳統的 `acc >> shift` (Floor)。
|
||||
- **UI 控制**:於「定點數模擬設定」區塊新增 **Floor (SRA) / Round (+0.5)** 互斥按鈕(與「階數 Order」相同風格),放置於 Q_in / Q_out 同列。預設為 Round。
|
||||
- **API 參數**:`/api/filter` 與 `/api/filter/download` 端點已支援 `use_round` 參數。
|
||||
- **高精度狀態變數架構**:前饋 (Feed-forward) 不右移,保留完整 $Q_{in+b}$ 精度;僅回授 (Feedback) 與最終輸出做位移縮放。
|
||||
|
||||
## 7. Vite Proxy 修正
|
||||
- **Bug 修正**:`vite.config.js` 的 proxy target 從 `https://` 修正為 `http://`,與 uvicorn 的實際 HTTP 協議一致。
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 踩雷紀錄 (Lessons Learned)
|
||||
|
||||
### 🔴 Production Build 未同步 (2026-05-17)
|
||||
**症狀**:修改了 `src/app-options.js` 與 `src/App.vue` 後,UI 按鈕有出現但功能完全不生效(Round/Floor 切換後濾波器輸出完全沒變化)。花了大量時間除錯。
|
||||
|
||||
**根因**:使用者透過 `http://localhost:8000/ui/` 存取系統。FastAPI 直接 serve `static/build/app.js`,而該檔案停留在 5 月 15 日的舊版(距今超過一天)。所有前端修改都只存在於 `src/` 原始碼,從未被 `npm run build` 編譯輸出。
|
||||
|
||||
**解法**:修改 `src/` 後務必執行 `npm run build`,再 `Ctrl+Shift+R` 硬重新整理瀏覽器。
|
||||
|
||||
**預防措施**:
|
||||
1. 開發時優先使用 `http://localhost:5173/` (Vite dev server,自動 HMR)。
|
||||
2. 若使用 port 8000,每次修改前端程式碼後必須 build。
|
||||
3. 詳見 `ARCHITECTURE.md` 的「Development Environment Notes」章節。
|
||||
|
||||
### 🟡 Vite Proxy https/http 不匹配 (2026-05-17)
|
||||
**症狀**:Vite dev server 的 API 轉發全部失敗 (502),uvicorn 日誌大量出現 `Invalid HTTP request received.`。
|
||||
|
||||
**根因**:`vite.config.js` 設定了 `target: 'https://127.0.0.1:8000'`,但 uvicorn 以 HTTP 模式啟動。Vite proxy 嘗試發送 TLS 握手封包到純 HTTP 伺服器。
|
||||
|
||||
**解法**:將 proxy target 改為 `http://127.0.0.1:8000`,移除 `secure: false`。
|
||||
|
||||
---
|
||||
*Last Updated: 2026-05-17 00:43*
|
||||
|
||||
---
|
||||
|
||||
# DSP Bode Plot 專案進度更新 (2026-05-17 - 第二階段重構與優化)
|
||||
|
||||
## 8. 回授變數命名重構與物理意義釐清
|
||||
- **變數命名精確化**:為消除舊版回授項以 `fb_` 開頭容易讓工程師誤解為 `b` 係數的命名混淆,將 `dea/csv_processing.py` 的變數全面更名:
|
||||
- `fb_sum` $\rightarrow$ **`sum_a`** (回授 a 累加項)
|
||||
- `fb_shifted` $\rightarrow$ **`sum_a_scaled`** (已除以 $A_0$ 歸一化後的回授項)
|
||||
- `acc` 運算直接更新為直觀的 `acc = sum_b - sum_a_scaled`,完全與數學公式鏡像對齊。
|
||||
- **明確白話文檔指引**:在根目錄與 bodeplot 目錄下的 `INTEGER_FILTER_IMPLEMENTATION.md` 文件中,新增了兩項白話物理意義說明:
|
||||
1. **為什麼回授部分除以 $A_0$?**(假設 $A_0 = 1$,放大後即為 $2^{Q_a}$,除以 $A_0$ 等同於算術右移 `shift_a`,旨在消去 $Q_a$ 放大的效應以防數值爆炸)。
|
||||
2. **為什麼輸入訊號 $x$ 與前饋 $b$ 不用中間歸一化?**(說明為了防止小數精度因中間右移而被截斷丟棄,故意將高精度一路鎖在歷史狀態變數中,延遲至最終輸出時才進行一次性對齊,藉此獲得最大 SNR 與最佳效能)。
|
||||
3. **加註 Floor 模式的警告**:加註 `(無條件捨去,非常不建議)`,指引工程師優先使用 Round 補償。
|
||||
|
||||
## 9. 韌體 C 語言實作最佳化 (32-bit MCU Optimization)
|
||||
- **32位元整數重構**:為避免 64 位元除法/乘法在 32 位元微處理器(如 ARM Cortex-M 或是 RISC-V RV32I)上引發昂貴的軟體模擬消耗,將 `INTEGER_FILTER_IMPLEMENTATION.md` 中提供的 C 語言 Process 範例全部改為由 **`int32_t`** 為核心的高效能版本。
|
||||
|
||||
## 10. Vite HTTPS Proxy & SSL 完全對齊
|
||||
- **HTTPS 代理修復**:為同時支援 Web Serial API(瀏覽器實體連接埠,要求 HTTPS 協議)與 Vite 5173 的自動熱更新開發,將 `vite.config.js` 的 proxy target 更新為 **`https://127.0.0.1:8000`**,並加上 **`secure: false`**(允許繞過自簽憑證限制),徹底解決了前端對代理伺服器發送明文請求所導致的 `Failed to execute 'json' on 'Response': Unexpected end of JSON input` 報錯。
|
||||
|
||||
## 11. 目前 Git 狀態與 GitLab 同步
|
||||
- **當前分支**:`dev-v2-integretimedomain`
|
||||
- **提交與推送**:已經將最新的變數重構、32-bit C 代碼優化、Vite HTTPS Proxy 更新成功 commit 並 Push 至 GitLab 遠端儲存庫,分支版控狀態極其乾淨。
|
||||
|
||||
---
|
||||
*Last Updated: 2026-05-17 14:26*
|
||||
@@ -1,109 +1,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 與 Pandas;Frontend 使用 Vue 3、Vite、Tailwind CSS 與 Plotly。
|
||||
|
||||
## Features
|
||||
|
||||
- 設計 Lowpass、Highpass、Bandpass、Notch、1P1Z、2P1Z、2P2Z、PID、SOGI 濾波器。
|
||||
- 比較浮點係數與 fixed-point 係數的頻率響應。
|
||||
- 手動微調 b/a 係數、a1/a2 組合(支援 δ/r 參數微調)、Q 格式位元數與 fixed-point 整數係數。
|
||||
- 透過瀏覽器 Web Serial API 直接將 fixed-point 係數命令送到 MCU(需透過 HTTPS 連線)。
|
||||
- 上傳 CSV 時域資料,套用目前差分方程並繪製輸入/輸出波形。
|
||||
- 匯出包含濾波輸出欄位的 CSV。
|
||||
- 支援深色模式 (Dark Mode) 與響應式 UI。
|
||||
- 比較 Ideal Float 與 Fixed-Point 的 Bode 頻率響應。
|
||||
- 手動微調 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
@@ -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
@@ -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,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "bodeplot",
|
||||
"name": "diff-eq-analyzer",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
|
||||
+231
-229
@@ -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 Serial。MCU 寫值功能需使用 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 + 1,r = -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 - r,a2 = 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
@@ -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
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
@@ -4,15 +4,6 @@ import vue from '@vitejs/plugin-vue'
|
||||
export default defineConfig({
|
||||
base: './',
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'https://127.0.0.1:8000',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'static/build',
|
||||
emptyOutDir: true,
|
||||
|
||||
Reference in New Issue
Block a user