feat: integrate cascade workflow

This commit is contained in:
ws50529
2026-05-27 08:38:40 +08:00
parent 63382f6cf5
commit b8ec600673
17 changed files with 572 additions and 500096 deletions
+6
View File
@@ -37,6 +37,12 @@ Thumbs.db
ehthumbs.db
Desktop.ini
# Data and Reports
*.csv
*.png
!static/logo.png
*.docx
# Agent Skills
.agents/
.agents
+72 -10
View File
@@ -8,10 +8,12 @@ Backend 使用 FastAPI、NumPy、SciPy 與 PandasFrontend 使用 Vue 3、Vite
- 設計 Lowpass、Highpass、Bandpass、Notch、1P1Z、2P1Z、2P2Z、PID、SOGI 濾波器。
- 比較 Ideal Float 與 Fixed-Point 的 Bode 頻率響應。
- 支援多個 cascade stages 的頻率響應與時域模擬,並可個別啟用或 bypass。
- 手動微調 b/a 係數、a1/a2、δ/r、Q-format 位元數與 fixed-point 整數係數。
- 上傳 CSV 時域資料,繪製輸入、Ideal Float 輸出與 Mimic Integer 輸出。
- 匯出包含理想浮點與定點整數模擬輸出的 CSV。
- 匯出包含理想浮點與定點整數模擬輸出的 CSVcascade workflow 可匯出乾淨的 4 欄 CSV。
- 使用瀏覽器 Web Serial API 將 fixed-point 係數寫入 MCU。
- 時域圖表支援 zoom 後以 `start_idx` / `end_idx` 向後端重取可視區間資料,保留 IIR 完整歷史狀態。
- 支援 Dark Mode 與響應式 UI。
## Quick Start
@@ -43,7 +45,7 @@ dea_api.py FastAPI 入口與 HTTP route
dea/ 後端核心模組
bode.py 頻率響應計算
config.py 限制值與伺服器端 serial 設定
csv_processing.py CSV 快取、驗證、濾波與匯出
csv_processing.py CSV 快取、驗證、單濾波器/cascade 濾波與匯出
filter_design.py 濾波器設計
mcu.py MCU serial port 列表與命令寫入
schemas.py Pydantic request schema
@@ -62,7 +64,7 @@ chirp_signal.csv CSV 上傳測試範例
| Layer | Responsibility |
| --- | --- |
| FastAPI | 提供 API、serve `/ui/`、套用 LAN 存取限制與安全標頭 |
| `dea/` | 濾波器設計、Bode plot、CSV 處理、fixed-point simulation、驗證 |
| `dea/` | 濾波器設計、單濾波器/cascade Bode plot、CSV 處理、fixed-point simulation、驗證 |
| Vue UI | 管理係數、Q-format、CSV workflow、Plotly 圖表與 Web Serial 狀態 |
| Vite build | 將 `src/` 打包到 `static/build/`,由 FastAPI 在 production 提供 |
@@ -124,6 +126,24 @@ PID, SOGI-Alpha, SOGI-Beta
桌機可用 Shift + 滑鼠滾輪微調;觸控裝置可按住數值面板上下拖曳。
### Cascade Stages
UI 以 cascade stage accordion 管理多段濾波器。每個 stage 顯示序號、濾波器型態、Active/Bypass 狀態、係數摘要與 Q-format 摘要;可用上移/下移調整串接順序。
完整的濾波器設計、浮點係數、fixed-point 係數與 MCU 寫值控制只會出現在目前選取的 active stage accordion 內。同時間只有一個 active stage 承載完整 editor,避免多份表單同時修改同一組全域 active-stage state。展開非 active stage 時,UI 會先切換該 stage 為 active stage。
送出 Bode 或 CSV 時,前端會先同步目前 active stage,再把所有 stages 依畫面順序打包送到後端。單一 stage 時仍等同 legacy 單濾波器 workflow。
後端相容兩種路徑:
| Mode | API Behavior |
| --- | --- |
| Legacy single filter | `/api/bode``/api/bode/compare``/api/filter` 仍接受原本的 `b/a/b_int/a_int` 參數 |
| Cascade | `/api/bode/compare_cascade` 接受 stages`/api/filter``/api/filter/download` 可接受 `stages` form payload |
Cascade 設計刻意不直接 merge `origin/cascadedfilters` 的整包檔案,而是在 `main` 上重作功能,避免帶入大型 CSV、暫存檔、log、舊文件與 build noise。
### Fixed-Point Time Simulation
時域分析同時計算兩條路徑:
@@ -145,6 +165,13 @@ Q-format 參數:
整數模擬採高精度狀態變數架構:前饋項保留在 `Q_in+b` 精度,回授項除以 `A0` 對齊,再於最終輸出依 `Q_out` 縮放。
### Time-Domain LOD Zoom
時域圖表使用 Plotly zoom / pan 時,前端會把目前可視時間範圍換算成 sample index,透過 `/api/filter` 傳送 `start_idx``end_idx`。後端仍先用完整訊號計算 IIR 輸出,保留濾波器歷史狀態正確性,再只切出可視區間並 downsample 回傳,避免 zoom in 後只能看到粗略的全域降採樣資料。
時間軸單位會在首次完整載入時鎖定為 `s``ms``μs`,避免 zoom 後重新換單位造成 Plotly `uirevision` 座標錯位。前端也使用 `isRedrawingTimePlot` 避免 `Plotly.react()` 觸發 relayout callback 造成重繪迴圈。
### CSV Workflow
限制與行為:
@@ -158,18 +185,50 @@ Q-format 參數:
若第一欄像 `time``t``sec``x``index`,前端會預設選第二欄作為訊號欄位。
範例檔案
首頁會自動嘗試載入 `.scratch/examples/preset_signals.csv` 作為 preset waveform;若檔案不存在,UI 仍可正常手動上傳 CSV。preset 路徑可用環境變數覆寫
```text
chirp_signal.csv
```bash
DEA_PRESET_CSV_PATH=/path/to/preset_signals.csv
```
欄位
範例 CSV 不再放在 Git 追蹤路徑。大型範例請放在本機 ignored 目錄
```text
.scratch/examples/
```
常用範例欄位格式:
```text
time,value
```
可從歷史 commit 匯出範例到本機 ignored 路徑使用:
```bash
mkdir -p .scratch/examples
git show 075f20d:chirp_signal.csv > .scratch/examples/chirp_signal.csv
git show 0140b049:chirp_signal_small_s.csv > .scratch/examples/chirp_signal_small_s.csv
git show 0140b049:step_signal.csv > .scratch/examples/step_signal.csv
git show 8caa8918:static/preset_signals.csv > .scratch/examples/preset_signals.csv
```
`.scratch/` 已被 `.gitignore` 忽略,適合放大型 CSV、手動測試資料與暫存分析檔。
### CSV Export
`/api/filter/download` 預設保留 legacy 行為:輸出原始 CSV 欄位,並新增 `[欄位]_filtered_ideal``[欄位]_filtered_fixed`
若前端傳送 `compact=true`,後端會輸出專供分析用的 4 欄 CSV:
| Column | Meaning |
| --- | --- |
| `Time (s)` | 由 sample index 與前端傳入的 `fs` 計算 |
| 原輸入欄位 | 選定訊號欄位原始值 |
| `[欄位]_filtered_ideal` | Ideal Float 輸出 |
| `[欄位]_filtered_fixed` | Mimic Integer / fixed-point 輸出 |
### MCU Web Serial
Web Serial 是主要 MCU 寫入方式,伺服器不需要 serial 權限。
@@ -192,10 +251,12 @@ bodeplot=b0,b1,b2,a1,a2
| --- | --- | --- |
| `POST` | `/api/design` | 依濾波器參數產生 b/a 係數 |
| `POST` | `/api/bode` | 計算單組 b/a 頻率響應 |
| `POST` | `/api/bode/compare` | 比較 Ideal Float 與 Fixed-Point 頻率響應 |
| `POST` | `/api/bode/compare` | 比較單一濾波器 Ideal Float 與 Fixed-Point 頻率響應 |
| `POST` | `/api/bode/compare_cascade` | 比較 cascade stages 的 Ideal Float 與 Fixed-Point 頻率響應 |
| `GET` | `/api/csv/preset` | 讀取本機 ignored preset CSV metadata,供首頁自動載入 |
| `POST` | `/api/csv/upload` | 上傳 CSV 並回傳 `file_id` |
| `POST` | `/api/filter` | 使用 `file_id` 回傳時域圖表資料 |
| `POST` | `/api/filter/download` | 使用 `file_id` 匯出結果 CSV |
| `POST` | `/api/filter` | 使用 `file_id` 回傳單濾波器或 cascade 時域圖表資料;可傳 `start_idx/end_idx` 做 LOD zoom |
| `POST` | `/api/filter/download` | 使用 `file_id` 匯出結果 CSV;可傳 `compact=true` 匯出 4 欄 CSV |
| `GET` | `/api/mcu/ports` | 列出伺服器端 serial ports |
| `POST` | `/api/mcu/write` | 後端模式寫入 MCU 命令 |
@@ -205,6 +266,7 @@ bodeplot=b0,b1,b2,a1,a2
```bash
npm install
npm run test:frontend
npm run build
```
-500001
View File
File diff suppressed because it is too large Load Diff
+55 -14
View File
@@ -18,6 +18,8 @@ TEMP_CSV_DIR = os.environ.get(
os.path.join(tempfile.gettempdir(), "diff-eq-analyzer-csv"),
)
TEMP_CSV_TTL_SECONDS = int(os.environ.get("DEA_TEMP_CSV_TTL_SECONDS", str(24 * 60 * 60)))
PRESET_FILE_ID = "00000000-0000-0000-0000-000000000000"
PRESET_CSV_PATH = os.environ.get("DEA_PRESET_CSV_PATH", ".scratch/examples/preset_signals.csv")
os.makedirs(TEMP_CSV_DIR, exist_ok=True)
@@ -38,6 +40,10 @@ def prune_expired_csv_uploads(now=None):
def get_cached_file_path(file_id):
if file_id == PRESET_FILE_ID:
if not os.path.exists(PRESET_CSV_PATH):
raise HTTPException(status_code=404, detail="找不到 preset CSV 檔案")
return PRESET_CSV_PATH
if not re.match(r'^[0-9a-f\-]{36}$', file_id):
raise HTTPException(status_code=400, detail="無效的檔案ID")
file_path = os.path.join(TEMP_CSV_DIR, f"{file_id}.csv")
@@ -46,6 +52,34 @@ def get_cached_file_path(file_id):
return file_path
def preset_csv_metadata(preview_rows=5):
if not os.path.exists(PRESET_CSV_PATH):
return {"available": False}
preview_df = pd.read_csv(PRESET_CSV_PATH, nrows=preview_rows)
columns = preview_df.columns.tolist()
with open(PRESET_CSV_PATH, "rb") as f:
rows = max(sum(1 for _ in f) - 1, 0)
default_col_idx = 0
if len(columns) > 1:
first = columns[0].lower()
if any(kw in first for kw in ("time", "t", "sec", "x", "index", "unnamed")):
default_col_idx = 1
return {
"available": True,
"file_id": PRESET_FILE_ID,
"name": os.path.basename(PRESET_CSV_PATH),
"columns": columns,
"preview": preview_df.fillna("").astype(str).values.tolist(),
"rows": rows,
"columns_count": len(columns),
"size": os.path.getsize(PRESET_CSV_PATH),
"default_col_idx": default_col_idx,
}
def downsample_for_plot(index, original, filtered_float, filtered_fixed):
total = len(index)
if total <= MAX_PLOT_POINTS:
@@ -54,6 +88,20 @@ def downsample_for_plot(index, original, filtered_float, filtered_fixed):
return index[::step], original[::step], filtered_float[::step], filtered_fixed[::step], step
def _numeric_signal(series, col_name):
original_missing = series.isna()
numeric_signal = pd.to_numeric(series, errors="coerce")
if (numeric_signal.isna() & ~original_missing).any():
raise HTTPException(status_code=400, detail=f"欄位 {col_name} 含有非數值資料")
numeric_signal = numeric_signal.dropna()
if numeric_signal.empty:
raise HTTPException(status_code=400, detail=f"欄位 {col_name} 沒有可用的數值資料")
x_values = numeric_signal.to_numpy(dtype=float)
if not np.isfinite(x_values).all():
raise HTTPException(status_code=400, detail=f"欄位 {col_name} 含有非有限數值")
return numeric_signal, x_values
async def save_csv_upload(file):
prune_expired_csv_uploads()
filename = (file.filename or "").lower()
@@ -185,20 +233,14 @@ def filter_preview_response(file_id, b_vals, a_vals, col_idx, b_int=None, a_int=
# 精準讀取單一欄位,並加上筆數限制 (極大降低記憶體用量與時間)
df = pd.read_csv(path, usecols=[col_idx], nrows=MAX_ROWS)
x_signal = pd.to_numeric(df[col_to_filter], errors="coerce")
if x_signal.isna().any():
raise HTTPException(status_code=400, detail=f"欄位 {col_to_filter} 含有非數值資料")
x_values = x_signal.to_numpy(dtype=float)
if not np.isfinite(x_values).all():
raise HTTPException(status_code=400, detail=f"欄位 {col_to_filter} 含有非有限數值")
x_signal, x_values = _numeric_signal(df[col_to_filter], col_to_filter)
y_float, y_fixed = _run_filter_paths(
x_values, b_vals, a_vals, b_int, a_int,
shift_in, shift_out, shift_b, shift_a, use_round, stages=stages,
)
total_points = len(df.index)
total_points = len(x_signal)
start_idx_clean = start_idx if isinstance(start_idx, (int, float, str)) else None
end_idx_clean = end_idx if isinstance(end_idx, (int, float, str)) else None
s_idx = max(0, int(start_idx_clean)) if start_idx_clean is not None else 0
@@ -207,7 +249,7 @@ def filter_preview_response(file_id, b_vals, a_vals, col_idx, b_int=None, a_int=
s_idx = 0
e_idx = total_points
full_index = df.index.to_numpy()
full_index = x_signal.index.to_numpy()
index, original, filtered_float, filtered_fixed, step = downsample_for_plot(
full_index[s_idx:e_idx],
x_values[s_idx:e_idx],
@@ -235,8 +277,7 @@ def filtered_csv_text(file_id, b_vals, a_vals, col_idx, b_int=None, a_int=None,
if col_idx < 0 or col_idx >= len(df.columns):
raise HTTPException(status_code=400, detail="欄位索引超出範圍")
col_to_filter = df.columns[col_idx]
x_signal = pd.to_numeric(df[col_to_filter], errors="coerce")
x_values = x_signal.to_numpy(dtype=float)
x_signal, x_values = _numeric_signal(df[col_to_filter], col_to_filter)
y_float, y_fixed = _run_filter_paths(
x_values, b_vals, a_vals, b_int, a_int,
@@ -245,15 +286,15 @@ def filtered_csv_text(file_id, b_vals, a_vals, col_idx, b_int=None, a_int=None,
if compact:
export_df = pd.DataFrame({
"Time (s)": df.index.to_numpy(dtype=float) / fs,
"Time (s)": x_signal.index.to_numpy(dtype=float) / fs,
col_to_filter: x_values,
f"{col_to_filter}_filtered_ideal": y_float,
f"{col_to_filter}_filtered_fixed": y_fixed,
})
else:
export_df = df.copy()
export_df[f"{col_to_filter}_filtered_ideal"] = y_float
export_df[f"{col_to_filter}_filtered_fixed"] = y_fixed
export_df[f"{col_to_filter}_filtered_ideal"] = pd.Series(y_float, index=x_signal.index)
export_df[f"{col_to_filter}_filtered_fixed"] = pd.Series(y_fixed, index=x_signal.index)
csv_buffer = io.StringIO()
export_df.to_csv(csv_buffer, index=False)
+12
View File
@@ -15,6 +15,7 @@ from dea.csv_processing import (
downsample_for_plot,
filter_preview_response,
filtered_csv_text,
preset_csv_metadata,
save_csv_upload,
)
from dea.filter_design import design_response
@@ -97,6 +98,17 @@ def calculate_bode_compare_cascade(params: BodeCascadeParams):
raise HTTPException(status_code=400, detail=str(e))
@app.get("/api/csv/preset")
def preset_csv():
try:
return preset_csv_metadata()
except HTTPException:
raise
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=400, detail=f"preset CSV 讀取失敗: {str(e)}")
@app.post("/api/csv/upload")
async def upload_csv(file: UploadFile = File(...)):
try:
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>差分方程式分析 (Difference Equation Analyzer)</title>
<title>級聯差分方程式分析 (Cascade Difference Equation Analyzer)</title>
<script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>
<link rel="icon" type="image/png" href="/ui/logo.png">
</head>
+2 -1
View File
@@ -2,7 +2,8 @@
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite --host 0.0.0.0"
"dev": "vite --host 0.0.0.0",
"test:frontend": "node tests/frontend/cascade-stage-smoke.mjs"
},
"dependencies": {
"@vitejs/plugin-vue": "^6.0.6",
+89 -34
View File
@@ -111,24 +111,43 @@
新增 Stage
</button>
</div>
<div class="flex flex-wrap gap-2">
<button v-for="(stage, idx) in cascadeStages" :key="stage.id" @click="selectCascadeStage(idx)"
:class="idx === activeCascadeStageIndex ? 'role-bg-primary role-text-on-fill' : 'bg-white dark:bg-gray-800 text-slate-700 dark:text-gray-300 border border-slate-200 dark:border-gray-700'"
class="px-3 py-1.5 rounded-full text-xs font-semibold transition-colors">
{{ idx + 1 }}. {{ stageTitle(idx) }}
</button>
</div>
<div class="flex items-center gap-2 text-xs text-slate-600 dark:text-gray-400">
<button @click="toggleCascadeStage(activeCascadeStageIndex)"
class="px-3 py-1 rounded-full bg-slate-200 dark:bg-gray-800 text-slate-700 dark:text-gray-300">
{{ cascadeStages[activeCascadeStageIndex]?.isActive ? '參與計算' : 'Bypass' }}
</button>
<button @click="removeCascadeStage(activeCascadeStageIndex)" :disabled="cascadeStages.length <= 1"
class="px-3 py-1 rounded-full bg-slate-200 dark:bg-gray-800 text-slate-700 dark:text-gray-300 disabled:opacity-50">
刪除 Stage
</button>
</div>
</div>
<div class="space-y-2">
<div v-for="(stage, idx) in cascadeStages" :key="stage.id"
class="rounded-lg border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 overflow-hidden">
<div class="flex items-center gap-2 px-2 py-2">
<button type="button" @click="selectCascadeStage(idx)"
:class="idx === activeCascadeStageIndex ? 'role-bg-primary role-text-on-fill' : 'text-slate-700 dark:text-gray-300'"
class="flex-1 min-w-0 px-3 py-1.5 rounded text-left text-xs font-semibold transition-colors">
<span class="block truncate">{{ idx + 1 }}. {{ stageTitle(idx) }}</span>
</button>
<button type="button" @click="moveCascadeStageUp(idx)" :disabled="idx === 0"
class="px-2 py-1 rounded bg-slate-200 dark:bg-gray-700 text-slate-700 dark:text-gray-300 disabled:opacity-40 text-xs"></button>
<button type="button" @click="moveCascadeStageDown(idx)" :disabled="idx === cascadeStages.length - 1"
class="px-2 py-1 rounded bg-slate-200 dark:bg-gray-700 text-slate-700 dark:text-gray-300 disabled:opacity-40 text-xs"></button>
<button type="button" @click="toggleCascadeStageExpanded(idx)"
class="px-2 py-1 rounded bg-slate-200 dark:bg-gray-700 text-slate-700 dark:text-gray-300 text-xs">
{{ stage.isExpanded ? '收合' : '展開' }}
</button>
</div>
<div v-show="stage.isExpanded" class="px-3 pb-3 text-xs text-slate-600 dark:text-gray-400 border-t border-slate-100 dark:border-gray-700">
<div class="flex items-center justify-between gap-2 pt-2">
<span class="truncate">{{ cascadeStageSummary(stage) }}</span>
<span :class="stage.isActive ? 'text-emerald-600 dark:text-emerald-400' : 'text-slate-500 dark:text-gray-500'">
{{ stage.isActive ? 'Active' : 'Bypass' }}
</span>
</div>
<div class="flex items-center gap-2 pt-2">
<button type="button" @click="toggleCascadeStage(idx)"
class="px-3 py-1 rounded-full bg-slate-200 dark:bg-gray-700 text-slate-700 dark:text-gray-300">
{{ stage.isActive ? '設為 Bypass' : '設為 Active' }}
</button>
<button type="button" @click="removeCascadeStage(idx)" :disabled="cascadeStages.length <= 1"
class="px-3 py-1 rounded-full bg-slate-200 dark:bg-gray-700 text-slate-700 dark:text-gray-300 disabled:opacity-50">
刪除
</button>
</div>
<StageEditor v-if="idx === activeCascadeStageIndex" :stage="stage" :stage-index="idx"
class="mt-3 pt-3 border-t border-slate-100 dark:border-gray-700">
<select v-model="filterType" @change="onFilterTypeChange"
class="w-full bg-slate-50 dark:bg-gray-800 border border-slate-200 dark:border-gray-700 rounded-lg p-3 text-base role-focus-primary focus:ring-1 role-focus-ring-primary outline-none transition-all mb-3 text-slate-900 dark:text-gray-100 truncate">
@@ -268,7 +287,6 @@
</div>
</div>
</div>
</section>
<hr class="border-slate-200 dark:border-gray-800">
@@ -687,6 +705,12 @@
</div>
<p class="text-[10px] text-slate-500 dark:text-gray-500 mt-3 italic">b 係數乘以 2^{{ shiftBitsB }}a
係數乘以 2^{{ shiftBitsA }} 後四捨五入可用於定點數 DSP 實作</p>
</section>
</StageEditor>
</div>
</div>
</div>
</div>
</section>
</aside>
@@ -718,7 +742,7 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 19V5m0 14h16M7 16l3-4 3 2 4-7"></path>
</svg>
頻率響應 (Frequency Response)</h3>
串聯濾波器總頻率響應 (Cascade Bode Plot)</h3>
<div v-if="loadingBode"
class="text-sm role-text-primary role-bg-primary-soft px-3 py-1.5 rounded border role-border-primary-soft flex items-center gap-2">
<span class="w-2 h-2 rounded-full role-dot-primary animate-ping"></span>
@@ -742,11 +766,46 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 12h4l2-6 4 12 2-6h4"></path>
</svg>
時域訊號探討</h3>
級聯時域訊號模擬 (Multi-Stage Time-Domain Simulation)</h3>
<button v-if="filterDone" @click="downloadCsv"
class="bg-slate-700 hover:bg-slate-600 role-text-on-fill px-4 py-2 rounded-lg text-sm font-semibold transition-all 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 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
匯出 4 CSV
</button>
</div>
<div class="p-6 z-10 relative bg-white dark:bg-dark transition-colors duration-300">
<p class="text-base text-slate-600 dark:text-gray-400 mb-4">上傳包含時域訊號的 CSV 檔案系統會讓訊號依序通過啟用的 cascade stages
<p class="text-base text-slate-600 dark:text-gray-400 mb-4">上傳時域輸入訊號的 CSV 檔案訊號依序通過所有啟用的 cascade stages並比較原始輸入Ideal Float Mimic Integer 輸出
</p>
<div v-if="csvPreview.length > 0"
class="mb-6 rounded-lg border border-slate-100 dark:border-gray-800 bg-slate-50 dark:bg-gray-900/50 overflow-hidden">
<button type="button" @click="isCsvPreviewExpanded = !isCsvPreviewExpanded"
class="w-full flex items-center justify-between gap-3 px-4 py-3 text-left text-sm font-semibold text-slate-700 dark:text-gray-300 bg-transparent hover:bg-slate-100 dark:hover:bg-gray-800 transition-colors">
<span class="flex items-center gap-2">
<svg class="w-4 h-4 role-text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16"></path>
</svg>
CSV Preview
</span>
<span class="text-xs text-slate-500 dark:text-gray-400">{{ isCsvPreviewExpanded ? '收合' : '展開' }}</span>
</button>
<div v-show="isCsvPreviewExpanded" class="overflow-x-auto border-t border-slate-100 dark:border-gray-800">
<table class="min-w-full text-xs text-left">
<thead class="bg-white dark:bg-gray-800 text-slate-500 dark:text-gray-400">
<tr>
<th v-for="(col, idx) in csvColumns" :key="idx" class="px-3 py-2 font-semibold whitespace-nowrap">{{ col }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-gray-800 text-slate-700 dark:text-gray-300 font-mono">
<tr v-for="(row, rowIdx) in csvPreview" :key="rowIdx">
<td v-for="(cell, cellIdx) in row" :key="cellIdx" class="px-3 py-2 whitespace-nowrap">{{ cell }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div
class="flex items-center flex-wrap gap-4 mb-6 p-4 bg-white dark:bg-gray-800 rounded-lg border border-slate-100 dark:border-gray-700 shadow-sm">
<label
@@ -759,8 +818,8 @@
<input type="file" ref="fileInput" accept=".csv" @change="handleFileUpload"
class="hidden">
</label>
<span v-if="csvFile" class="text-base role-text-primary font-mono">{{
csvFile.name }}</span>
<span v-if="csvFile || usingPresetCsv" class="text-base role-text-primary font-mono">{{
csvFile ? csvFile.name : presetCsvName }}</span>
<span v-if="csvInfo"
class="text-sm text-slate-600 dark:text-gray-400 font-mono">
{{ csvInfo.rowsLabel || csvInfo.rows }} / {{ csvInfo.columns }}
@@ -785,19 +844,15 @@
</select>
</div>
<button v-if="csvFile" @click="processFilter"
<button v-if="csvFile || usingPresetCsv" @click="processFilter"
class="role-bg-primary role-hover-primary role-text-on-fill px-6 py-3 rounded-lg text-base font-semibold transition-all shadow-md role-shadow-primary disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="csvUploading || loadingFilter || csvColumns.length === 0">
{{ csvUploading ? '上傳中...' : (loadingFilter ? '處理中...' : '執行當前濾波器') }}
</button>
<button v-if="filterDone" @click="downloadCsv"
class="ml-auto bg-slate-700 hover:bg-slate-600 role-text-on-fill px-5 py-3 rounded-lg text-base font-semibold transition-all flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
匯出 4 CSV
<button v-if="csvFile && presetCsvAvailable" @click="restorePresetWaveforms"
class="bg-slate-200 dark:bg-gray-700 hover:bg-slate-300 dark:hover:bg-gray-600 text-slate-700 dark:text-gray-200 px-4 py-3 rounded-lg text-base font-semibold transition-all">
使用 preset
</button>
</div>
@@ -811,7 +866,7 @@
d="M9 7h6M9 12h6M9 17h3M5 5a2 2 0 012-2h7l5 5v11a2 2 0 01-2 2H7a2 2 0 01-2-2V5z">
</path>
</svg>
定點數模擬設定 (Fixed-Point Simulation Settings)
級聯量化與捨去設定 (Cascade Simulation Settings)
</h4>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 lg:gap-6">
<div>
@@ -899,7 +954,7 @@
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z">
</path>
</svg>
<p>請上傳包含時間序列的 CSV 檔案進行測試</p>
<p>請上傳包含時間序列的 CSV 檔案以測試時域級聯濾波效果</p>
</div>
</div>
</div>
+130 -27
View File
@@ -1,3 +1,5 @@
import StageEditor from './components/StageEditor.vue';
import { computeStageFixedCoeffs, createCascadeFilterPayload, normalizeCascadeStage } from './cascade-stage.js';
const DEFAULT_FILTER_TYPE = "Lowpass (低通)";
const MANUAL_FILTER_TYPE = "(無) 手動自訂";
const DEFAULT_FS = 100000.0;
@@ -18,8 +20,12 @@ const DEFAULT_PARAMS = {
const cloneDefaultParams = () => ({ ...DEFAULT_PARAMS });
const zero7 = () => [...ZERO_7];
const createStageId = () => `${Date.now()}-${Math.random().toString(36).slice(2)}`;
export default {
components: {
StageEditor,
},
data() {
return {
fs: DEFAULT_FS,
@@ -50,7 +56,7 @@ export default {
loadingBode: false, bodeTimeout: null, globalError: null,
csvFile: null, csvFileId: null, csvColumns: [], csvPreview: [], csvInfo: null, csvParseError: null,
csvUploading: false,
csvUploading: false, presetCsvAvailable: false, presetCsvName: '', usingPresetCsv: false,
isCsvPreviewExpanded: true,
selectedColumn: 0, loadingFilter: false, filterDone: false, timePlotData: null,
mobileTab: 'settings', // 手機版頁籤:'settings' | 'chart'
@@ -304,6 +310,7 @@ export default {
this.ensureCascadeStages();
this.updateBodeMagRange();
this.applyFilterDesign();
this.loadPresetWaveforms();
},
beforeUnmount() {
window.removeEventListener('pointerdown', this.rememberPointerType);
@@ -328,8 +335,9 @@ export default {
},
captureCurrentStage() {
return {
id: this.cascadeStages[this.activeCascadeStageIndex]?.id || Date.now() + Math.random(),
id: this.cascadeStages[this.activeCascadeStageIndex]?.id || createStageId(),
isActive: this.cascadeStages[this.activeCascadeStageIndex]?.isActive ?? true,
isExpanded: this.cascadeStages[this.activeCascadeStageIndex]?.isExpanded ?? true,
filterType: this.filterType,
b_str: this.b_str,
a_str: this.a_str,
@@ -353,6 +361,7 @@ export default {
};
},
applyStageToControls(stage) {
stage = normalizeCascadeStage(stage);
this.filterType = stage.filterType;
this.b_str = stage.b_str;
this.a_str = stage.a_str;
@@ -375,10 +384,34 @@ export default {
this.activeCoeffAdjustment = stage.activeCoeffAdjustment;
},
ensureCascadeStages() {
if (!Array.isArray(this.cascadeStages)) this.cascadeStages = [];
if (this.cascadeStages.length === 0) {
this.cascadeStages = [this.captureCurrentStage()];
this.activeCascadeStageIndex = 0;
}
this.cascadeStages = this.cascadeStages.map((stage, index) => normalizeCascadeStage(stage, {
isExpanded: index === this.activeCascadeStageIndex,
filterType: this.filterType,
b_str: this.b_str,
a_str: this.a_str,
systemGain: this.systemGain,
params: this.params,
baseB: this.baseB,
baseA: this.baseA,
bSliders: this.bSliders,
aSliders: this.aSliders,
shiftBitsB: this.shiftBitsB,
shiftBitsA: this.shiftBitsA,
fixedOverrides: this.fixedOverrides,
outOfRangeB: this.outOfRangeB,
outOfRangeA: this.outOfRangeA,
aFineStep: this.aFineStep,
fixedAFineStep: this.fixedAFineStep,
activeCoeffAdjustment: this.activeCoeffAdjustment,
}));
if (this.activeCascadeStageIndex < 0 || this.activeCascadeStageIndex >= this.cascadeStages.length) {
this.activeCascadeStageIndex = 0;
}
},
saveActiveCascadeStage() {
this.ensureCascadeStages();
@@ -388,6 +421,9 @@ export default {
if (index < 0 || index >= this.cascadeStages.length || index === this.activeCascadeStageIndex) return;
this.saveActiveCascadeStage();
this.activeCascadeStageIndex = index;
this.cascadeStages.forEach((stage, stageIndex) => {
stage.isExpanded = stageIndex === index;
});
this.applyStageToControls(this.cloneStage(this.cascadeStages[index]));
this.updateBodeMagRange();
this.updateBodePlot({ switchToChart: false });
@@ -395,8 +431,9 @@ export default {
addCascadeStage() {
this.saveActiveCascadeStage();
const newStage = this.cloneStage(this.cascadeStages[this.activeCascadeStageIndex]);
newStage.id = Date.now() + Math.random();
newStage.id = createStageId();
newStage.isActive = true;
newStage.isExpanded = true;
this.cascadeStages.push(newStage);
this.selectCascadeStage(this.cascadeStages.length - 1);
},
@@ -408,6 +445,7 @@ export default {
this.saveActiveCascadeStage();
this.cascadeStages.splice(index, 1);
this.activeCascadeStageIndex = Math.min(this.activeCascadeStageIndex, this.cascadeStages.length - 1);
this.cascadeStages[this.activeCascadeStageIndex].isExpanded = true;
this.applyStageToControls(this.cloneStage(this.cascadeStages[this.activeCascadeStageIndex]));
this.updateBodePlot({ switchToChart: false });
},
@@ -418,15 +456,44 @@ export default {
this.updateBodePlot({ switchToChart: false });
this.debouncedProcessFilter();
},
toggleCascadeStageExpanded(index) {
if (index < 0 || index >= this.cascadeStages.length) return;
if (index !== this.activeCascadeStageIndex) {
this.selectCascadeStage(index);
return;
}
this.saveActiveCascadeStage();
this.cascadeStages.forEach((stage, stageIndex) => {
stage.isExpanded = stageIndex === index ? !stage.isExpanded : false;
});
},
cascadeStageSummary(stage) {
const bCount = this.parseCoeffs(stage.b_str || '').length;
const aCount = this.parseCoeffs(stage.a_str || '').length;
return `${bCount}b / ${aCount}a, Qb${stage.shiftBitsB}, Qa${stage.shiftBitsA}`;
},
moveCascadeStageUp(index) {
this.moveCascadeStage(index, index - 1);
},
moveCascadeStageDown(index) {
this.moveCascadeStage(index, index + 1);
},
moveCascadeStage(fromIndex, toIndex) {
if (fromIndex < 0 || fromIndex >= this.cascadeStages.length || toIndex < 0 || toIndex >= this.cascadeStages.length) return;
this.saveActiveCascadeStage();
const activeStageId = this.cascadeStages[this.activeCascadeStageIndex]?.id;
const [stage] = this.cascadeStages.splice(fromIndex, 1);
this.cascadeStages.splice(toIndex, 0, stage);
this.activeCascadeStageIndex = Math.max(0, this.cascadeStages.findIndex(item => item.id === activeStageId));
this.cascadeStages.forEach((item, index) => {
item.isExpanded = index === this.activeCascadeStageIndex;
});
this.applyStageToControls(this.cloneStage(this.cascadeStages[this.activeCascadeStageIndex]));
this.updateBodePlot({ switchToChart: false });
this.debouncedProcessFilter();
},
getStageFixedCoeffs(stage) {
const scaleB = Math.pow(2, stage.shiftBitsB);
const scaleA = Math.pow(2, stage.shiftBitsA);
const bRaw = this.parseCoeffs(stage.b_str);
const aRaw = this.parseCoeffs(stage.a_str);
return {
b: bRaw.map((x, i) => ((stage.fixedOverrides.b[i] !== undefined) ? stage.fixedOverrides.b[i] : Math.round(x * scaleB))),
a: aRaw.map((x, i) => ((stage.fixedOverrides.a[i] !== undefined) ? stage.fixedOverrides.a[i] : Math.round(x * scaleA))),
};
return computeStageFixedCoeffs(stage, this.parseCoeffs);
},
getCascadeStagesSnapshot() {
this.saveActiveCascadeStage();
@@ -450,20 +517,14 @@ export default {
};
},
getCascadeFilterPayload() {
return this.getCascadeStagesSnapshot().map((stage, index, stages) => {
const fixed = this.getStageFixedCoeffs(stage);
return {
b_str: stage.b_str,
a_str: stage.a_str,
b_int_str: fixed.b.join(', '),
a_int_str: fixed.a.join(', '),
shift_in: index === 0 ? this.shiftBitsIn : stages[index - 1].shiftBitsB,
shift_out: index === stages.length - 1 ? this.shiftBitsOut : stage.shiftBitsB,
shift_b: stage.shiftBitsB,
shift_a: stage.shiftBitsA,
use_round: this.useRound,
isActive: stage.isActive,
};
return createCascadeFilterPayload({
stages: this.getCascadeStagesSnapshot(),
fs: this.fs,
shiftBitsIn: this.shiftBitsIn,
shiftBitsOut: this.shiftBitsOut,
useRound: this.useRound,
parseCoeffs: this.parseCoeffs,
getStageFixedCoeffs: this.getStageFixedCoeffs,
});
},
loadSettings() {
@@ -475,7 +536,7 @@ export default {
'fs', 'b_str', 'a_str', 'filterType', 'systemGain', 'params',
'baseB', 'baseA', 'bSliders', 'aSliders', 'sense_b', 'sense_a',
'shiftBitsB', 'shiftBitsA', 'shiftBitsIn', 'shiftBitsOut', 'useRound',
'fixedOverrides', 'aFineStep', 'fixedAFineStep'
'fixedOverrides', 'aFineStep', 'fixedAFineStep', 'cascadeStages', 'activeCascadeStageIndex'
];
keys.forEach(k => {
if (parsed[k] !== undefined) {
@@ -488,11 +549,12 @@ export default {
}
},
saveSettings() {
this.saveActiveCascadeStage();
const keys = [
'fs', 'b_str', 'a_str', 'filterType', 'systemGain', 'params',
'baseB', 'baseA', 'bSliders', 'aSliders', 'sense_b', 'sense_a',
'shiftBitsB', 'shiftBitsA', 'shiftBitsIn', 'shiftBitsOut', 'useRound',
'fixedOverrides', 'aFineStep', 'fixedAFineStep'
'fixedOverrides', 'aFineStep', 'fixedAFineStep', 'cascadeStages', 'activeCascadeStageIndex'
];
const settings = {};
keys.forEach(k => settings[k] = this[k]);
@@ -1405,6 +1467,7 @@ export default {
this.csvInfo = null;
this.csvParseError = null;
this.csvUploading = false;
this.usingPresetCsv = false;
this.isCsvPreviewExpanded = true;
this.selectedColumn = 0;
this.filterDone = false;
@@ -1415,6 +1478,46 @@ export default {
this.timePlotMultiplier = null;
},
async loadPresetWaveforms() {
try {
const res = await fetch('/api/csv/preset');
if (!res.ok) return;
const data = await res.json();
this.presetCsvAvailable = !!data.available;
if (!data.available) return;
this.csvFile = null;
this.csvFileId = data.file_id;
this.csvColumns = data.columns || [];
this.csvPreview = data.preview || [];
this.csvInfo = {
rows: data.rows || 0,
columns: data.columns_count || (data.columns || []).length,
size: data.size || 0,
};
this.csvParseError = null;
this.csvUploading = false;
this.presetCsvName = data.name || 'preset_signals.csv';
this.usingPresetCsv = true;
this.selectedColumn = Number.isInteger(data.default_col_idx) ? data.default_col_idx : 0;
this.filterDone = false;
this.timePlotData = null;
this.zoomStartIdx = null;
this.zoomEndIdx = null;
this.timePlotUnit = null;
this.timePlotMultiplier = null;
this.isCsvPreviewExpanded = true;
this.$nextTick(() => this.processFilter());
} catch (_) {
this.presetCsvAvailable = false;
}
},
restorePresetWaveforms() {
if (this.$refs.fileInput) this.$refs.fileInput.value = '';
this.loadPresetWaveforms();
},
handleFileUpload(event) {
const file = event.target.files[0];
this.resetCsvState();
+75
View File
@@ -0,0 +1,75 @@
const DEFAULT_FILTER_TYPE = "Lowpass (低通)";
const DEFAULT_B_STR = "0.5, 0.5, 0.0, 0.0";
const DEFAULT_A_STR = "1.0, 0.0, 0.0, 0.0";
const DEFAULT_SHIFT_BITS = 14;
const ZERO_7 = [0, 0, 0, 0, 0, 0, 0];
const DEFAULT_PARAMS = {
fc: 1000.0, order: 1,
bp_f_low: 500.0, bp_f_high: 2000.0, bp_order: 1,
notch_f0: 120.0, notch_q: 1.0,
opoz_fz: 15000.0, opoz_fp: 10.0,
tp1z_fz: 200.0, tp1z_fp1: 10.0, tp1z_fp2: 5000.0,
tptz_fz1: 200.0, tptz_fz2: 25000.0, tptz_fp1: 10.0, tptz_fp2: 5000.0,
kp: 0.003, ki: 10.0, kd: 0.000016,
sogi_f0: 60.0, sogi_k: 1.414,
};
const cloneDefaultParams = () => ({ ...DEFAULT_PARAMS });
const zero7 = () => [...ZERO_7];
const createStageId = () => `${Date.now()}-${Math.random().toString(36).slice(2)}`;
export const normalizeCascadeStage = (stage = {}, fallback = {}) => ({
id: stage.id ?? fallback.id ?? createStageId(),
isActive: stage.isActive ?? fallback.isActive ?? true,
isExpanded: stage.isExpanded ?? fallback.isExpanded ?? false,
filterType: stage.filterType ?? fallback.filterType ?? DEFAULT_FILTER_TYPE,
b_str: stage.b_str ?? fallback.b_str ?? DEFAULT_B_STR,
a_str: stage.a_str ?? fallback.a_str ?? DEFAULT_A_STR,
systemGain: stage.systemGain ?? fallback.systemGain ?? 1.0,
params: { ...cloneDefaultParams(), ...(fallback.params || {}), ...(stage.params || {}) },
baseB: [...(stage.baseB || fallback.baseB || [0.5, 0.5, 0, 0, 0, 0, 0])],
baseA: [...(stage.baseA || fallback.baseA || [1, 0, 0, 0, 0, 0, 0])],
bSliders: [...(stage.bSliders || fallback.bSliders || zero7())],
aSliders: [...(stage.aSliders || fallback.aSliders || zero7())],
shiftBitsB: stage.shiftBitsB ?? fallback.shiftBitsB ?? DEFAULT_SHIFT_BITS,
shiftBitsA: stage.shiftBitsA ?? fallback.shiftBitsA ?? DEFAULT_SHIFT_BITS,
fixedOverrides: {
a: { ...((fallback.fixedOverrides || {}).a || {}), ...((stage.fixedOverrides || {}).a || {}) },
b: { ...((fallback.fixedOverrides || {}).b || {}), ...((stage.fixedOverrides || {}).b || {}) },
},
outOfRangeB: [...(stage.outOfRangeB || fallback.outOfRangeB || [false, false, false, false, false, false, false])],
outOfRangeA: [...(stage.outOfRangeA || fallback.outOfRangeA || [false, false, false, false, false, false, false])],
aFineStep: stage.aFineStep ?? fallback.aFineStep ?? 0.01,
fixedAFineStep: stage.fixedAFineStep ?? fallback.fixedAFineStep ?? 1,
activeCoeffAdjustment: stage.activeCoeffAdjustment ?? fallback.activeCoeffAdjustment ?? null,
});
export const computeStageFixedCoeffs = (stage, parseCoeffs) => {
const normalized = normalizeCascadeStage(stage);
const scaleB = Math.pow(2, normalized.shiftBitsB);
const scaleA = Math.pow(2, normalized.shiftBitsA);
const bRaw = parseCoeffs(normalized.b_str);
const aRaw = parseCoeffs(normalized.a_str);
return {
b: bRaw.map((x, i) => ((normalized.fixedOverrides.b[i] !== undefined) ? normalized.fixedOverrides.b[i] : Math.round(x * scaleB))),
a: aRaw.map((x, i) => ((normalized.fixedOverrides.a[i] !== undefined) ? normalized.fixedOverrides.a[i] : Math.round(x * scaleA))),
};
};
export const createCascadeFilterPayload = ({ stages, fs, shiftBitsIn, shiftBitsOut, useRound, parseCoeffs, getStageFixedCoeffs }) => (
stages.map((stage, index) => {
const fixed = getStageFixedCoeffs(stage);
return {
b_str: stage.b_str,
a_str: stage.a_str,
b_int_str: fixed.b.join(', '),
a_int_str: fixed.a.join(', '),
shift_in: index === 0 ? shiftBitsIn : stages[index - 1].shiftBitsB,
shift_out: index === stages.length - 1 ? shiftBitsOut : stage.shiftBitsB,
shift_b: stage.shiftBitsB,
shift_a: stage.shiftBitsA,
use_round: useRound,
isActive: stage.isActive,
};
})
);
+21
View File
@@ -0,0 +1,21 @@
<template>
<div class="stage-editor" :data-stage-index="stageIndex" :data-stage-id="stage?.id || ''">
<slot></slot>
</div>
</template>
<script>
export default {
name: 'StageEditor',
props: {
stage: {
type: Object,
default: null,
},
stageIndex: {
type: Number,
default: 0,
},
},
};
</script>
+1 -1
View File
File diff suppressed because one or more lines are too long
+3 -3
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>差分方程式分析 (Difference Equation Analyzer)</title>
<title>級聯差分方程式分析 (Cascade Difference Equation Analyzer)</title>
<script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>
<link rel="icon" type="image/png" href="/ui/logo.png">
<script type="module" crossorigin src="./app.js"></script>
+3 -3
View File
@@ -4,15 +4,15 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>差分方程式分析 (Difference Equation Analyzer)</title>
<title>級聯差分方程式分析 (Cascade Difference Equation Analyzer)</title>
<script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>
<link rel="stylesheet" href="build/app.css">
<link rel="stylesheet" href="build/app.css?v=cascade_ui_low_risk">
<link rel="icon" type="image/png" href="logo.png">
</head>
<body class="bg-slate-50 dark:bg-darker text-slate-900 dark:text-gray-200 min-h-screen transition-colors duration-300">
<div id="dea-root"></div>
<script type="module" src="build/app.js"></script>
<script type="module" src="build/app.js?v=cascade_ui_low_risk"></script>
</body>
</html>
+54
View File
@@ -0,0 +1,54 @@
import assert from 'node:assert/strict';
import { computeStageFixedCoeffs, createCascadeFilterPayload, normalizeCascadeStage } from '../../src/cascade-stage.js';
const legacyStage = normalizeCascadeStage({
id: 'legacy-1',
filterType: 'Lowpass (低通)',
b_str: '1, 0',
a_str: '1',
});
assert.equal(legacyStage.id, 'legacy-1');
assert.equal(legacyStage.isActive, true);
assert.equal(legacyStage.isExpanded, false);
assert.equal(legacyStage.shiftBitsB, 14);
assert.deepEqual(Object.keys(legacyStage.fixedOverrides), ['a', 'b']);
const fixedStage = normalizeCascadeStage({
b_str: '0.5, -0.25',
a_str: '1, -0.5',
shiftBitsB: 4,
shiftBitsA: 5,
fixedOverrides: { b: { 1: -9 }, a: {} },
});
const fixed = computeStageFixedCoeffs(fixedStage, (value) => value.split(',').map(Number));
assert.deepEqual(fixed.b, [8, -9]);
assert.deepEqual(fixed.a, [32, -16]);
const stages = [
normalizeCascadeStage({ id: 'a', b_str: '1', a_str: '1', shiftBitsB: 12, shiftBitsA: 13, isActive: true }),
normalizeCascadeStage({ id: 'b', b_str: '2', a_str: '1', shiftBitsB: 10, shiftBitsA: 11, isActive: false }),
];
const payload = createCascadeFilterPayload({
stages,
fs: 100000,
shiftBitsIn: 9,
shiftBitsOut: 8,
useRound: true,
parseCoeffs: (value) => value.split(',').map(Number),
getStageFixedCoeffs: (stage) => ({
b: stage.b_str.split(',').map(Number),
a: stage.a_str.split(',').map(Number),
}),
});
assert.equal(payload.length, 2);
assert.equal(payload[0].b_str, '1');
assert.equal(payload[1].b_str, '2');
assert.equal(payload[0].shift_in, 9);
assert.equal(payload[0].shift_out, 12);
assert.equal(payload[1].shift_in, 12);
assert.equal(payload[1].shift_out, 8);
assert.equal(payload[1].isActive, false);
assert.equal(payload[0].use_round, true);
+47
View File
@@ -1,10 +1,15 @@
import asyncio
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from fastapi import HTTPException
from fastapi.responses import JSONResponse
import dea.csv_processing as csv_processing
from dea.csv_processing import PRESET_FILE_ID
from dea_api import (
BodeCascadeParams,
BodeCompareParams,
@@ -18,6 +23,7 @@ from dea_api import (
design_filter,
filter_csv,
filter_csv_download,
preset_csv,
upload_csv,
write_mcu_command,
)
@@ -159,6 +165,47 @@ class DeaApiTest(unittest.TestCase):
raise AssertionError("Expected invalid MCU command validation to fail")
def test_preset_csv_endpoint_returns_local_ignored_file_metadata(self):
with tempfile.TemporaryDirectory() as tmp:
preset_path = Path(tmp) / "preset_signals.csv"
preset_path.write_text("time,value\n0,1\n1,2\n", encoding="utf-8")
with patch.object(csv_processing, "PRESET_CSV_PATH", str(preset_path)):
body = preset_csv()
self.assertTrue(body["available"])
self.assertEqual(body["file_id"], PRESET_FILE_ID)
self.assertEqual(body["columns"], ["time", "value"])
self.assertEqual(body["default_col_idx"], 1)
self.assertEqual(body["rows"], 2)
def test_filter_can_use_preset_file_id_and_skip_blank_signal_cells(self):
with tempfile.TemporaryDirectory() as tmp:
preset_path = Path(tmp) / "preset_signals.csv"
preset_path.write_text("time,value\n0,1\n1,\n2,3\n", encoding="utf-8")
with patch.object(csv_processing, "PRESET_CSV_PATH", str(preset_path)):
body = asyncio.run(
filter_csv(
file_id=PRESET_FILE_ID,
b="1",
a="1",
col_idx=1,
b_int=None,
a_int=None,
shift_in=14,
shift_out=14,
shift_b=14,
shift_a=14,
use_round=False,
)
)
self.assertEqual(body["total_points"], 2)
self.assertEqual(body["index"], [0, 2])
self.assertEqual(body["original"], [1.0, 3.0])
def test_filter_downsamples_plot_response_for_large_csv(self):
rows = ["value"] + [str(i) for i in range(6001)]
upload = InMemoryUpload(("\n".join(rows) + "\n").encode("utf-8"), "input.csv")