feat: integrate cascade filter workflow
This commit is contained in:
@@ -38,8 +38,12 @@ ehthumbs.db
|
||||
Desktop.ini
|
||||
|
||||
# Agent Skills
|
||||
.agents/
|
||||
.agents
|
||||
|
||||
# Local Tracker & AI Workspace
|
||||
.scratch/
|
||||
.gemini/
|
||||
|
||||
# SSL certs (private keys)
|
||||
certs/*.pem
|
||||
|
||||
+37
-1
@@ -3,7 +3,7 @@ from fastapi import HTTPException
|
||||
from scipy import signal
|
||||
|
||||
from .config import BODE_MAX_MULTIPLIER, BODE_POINTS
|
||||
from .schemas import BodeCompareParams, BodeParams
|
||||
from .schemas import BodeCascadeParams, BodeCompareParams, BodeParams
|
||||
from .validation import require_finite, validate_coefficients
|
||||
|
||||
|
||||
@@ -47,3 +47,39 @@ def calculate_bode_compare_response(params: BodeCompareParams):
|
||||
"ideal": ideal,
|
||||
"fixed": fixed,
|
||||
}
|
||||
|
||||
|
||||
def calculate_bode_cascade_response(params: BodeCascadeParams):
|
||||
fs_val, f_eval = _frequency_axis(params.fs)
|
||||
h_total_ideal = np.ones(len(f_eval), dtype=complex)
|
||||
h_total_fixed = np.ones(len(f_eval), dtype=complex)
|
||||
any_active = False
|
||||
|
||||
for i, stage in enumerate(params.stages):
|
||||
if not stage.isActive:
|
||||
continue
|
||||
any_active = True
|
||||
b_vals = validate_coefficients(stage.b, f"stages[{i}].b")
|
||||
a_vals = validate_coefficients(stage.a, f"stages[{i}].a")
|
||||
b_fixed_vals = validate_coefficients(stage.b_fixed, f"stages[{i}].b_fixed")
|
||||
a_fixed_vals = validate_coefficients(stage.a_fixed, f"stages[{i}].a_fixed")
|
||||
_, h_ideal = signal.freqz(b_vals, a_vals, worN=f_eval, fs=fs_val)
|
||||
_, h_fixed = signal.freqz(b_fixed_vals, a_fixed_vals, worN=f_eval, fs=fs_val)
|
||||
h_total_ideal *= h_ideal
|
||||
h_total_fixed *= h_fixed
|
||||
|
||||
if not any_active:
|
||||
h_total_ideal = np.ones(len(f_eval), dtype=complex)
|
||||
h_total_fixed = np.ones(len(f_eval), dtype=complex)
|
||||
|
||||
return {
|
||||
"freq": f_eval.tolist(),
|
||||
"ideal": {
|
||||
"mag": (20 * np.log10(np.abs(h_total_ideal) + 1e-12)).tolist(),
|
||||
"phase": np.angle(h_total_ideal, deg=True).tolist(),
|
||||
},
|
||||
"fixed": {
|
||||
"mag": (20 * np.log10(np.abs(h_total_fixed) + 1e-12)).tolist(),
|
||||
"phase": np.angle(h_total_fixed, deg=True).tolist(),
|
||||
},
|
||||
}
|
||||
|
||||
+75
-19
@@ -138,7 +138,43 @@ def integer_lfilter(b_int, a_int, x_float, shift_in, shift_out, shift_b, shift_a
|
||||
return y_out.astype(float) / (2**shift_out)
|
||||
|
||||
|
||||
def filter_preview_response(file_id, b_vals, a_vals, col_idx, b_int=None, a_int=None, shift_in=14, shift_out=14, shift_b=14, shift_a=14, use_round=False):
|
||||
def _run_filter_paths(x_values, b_vals, a_vals, b_int, a_int, shift_in, shift_out, shift_b, shift_a, use_round, stages=None):
|
||||
if stages is None:
|
||||
y_float = signal.lfilter(b_vals, a_vals, x_values)
|
||||
if b_int is not None and a_int is not None:
|
||||
y_fixed = integer_lfilter(b_int, a_int, x_values, shift_in, shift_out, shift_b, shift_a, use_round)
|
||||
else:
|
||||
y_fixed = y_float
|
||||
return y_float, y_fixed
|
||||
|
||||
y_float = x_values
|
||||
y_fixed = x_values
|
||||
any_active = False
|
||||
for stage in stages:
|
||||
if not stage.get("isActive", True):
|
||||
continue
|
||||
any_active = True
|
||||
y_float = signal.lfilter(stage["b"], stage["a"], y_float)
|
||||
if stage.get("b_int") is not None and stage.get("a_int") is not None:
|
||||
y_fixed = integer_lfilter(
|
||||
stage["b_int"],
|
||||
stage["a_int"],
|
||||
y_fixed,
|
||||
stage["shift_in"],
|
||||
stage["shift_out"],
|
||||
stage["shift_b"],
|
||||
stage["shift_a"],
|
||||
stage["use_round"],
|
||||
)
|
||||
else:
|
||||
y_fixed = y_float
|
||||
|
||||
if not any_active:
|
||||
return x_values, x_values
|
||||
return y_float, y_fixed
|
||||
|
||||
|
||||
def filter_preview_response(file_id, b_vals, a_vals, col_idx, b_int=None, a_int=None, shift_in=14, shift_out=14, shift_b=14, shift_a=14, use_round=False, stages=None, start_idx=None, end_idx=None):
|
||||
path = get_cached_file_path(file_id)
|
||||
|
||||
# 預先讀取欄位名稱,避免用 usecols 讀取後找不到原始索引
|
||||
@@ -157,16 +193,27 @@ def filter_preview_response(file_id, b_vals, a_vals, col_idx, b_int=None, a_int=
|
||||
if not np.isfinite(x_values).all():
|
||||
raise HTTPException(status_code=400, detail=f"欄位 {col_to_filter} 含有非有限數值")
|
||||
|
||||
# 路徑 1: 理想浮點數路徑
|
||||
y_float = signal.lfilter(b_vals, a_vals, x_values)
|
||||
y_float, y_fixed = _run_filter_paths(
|
||||
x_values, b_vals, a_vals, b_int, a_int,
|
||||
shift_in, shift_out, shift_b, shift_a, use_round, stages=stages,
|
||||
)
|
||||
|
||||
# 路徑 2: 整數模擬路徑
|
||||
if b_int is not None and a_int is not None:
|
||||
y_fixed = integer_lfilter(b_int, a_int, x_values, shift_in, shift_out, shift_b, shift_a, use_round)
|
||||
else:
|
||||
y_fixed = y_float
|
||||
total_points = len(df.index)
|
||||
start_idx_clean = start_idx if isinstance(start_idx, (int, float, str)) else None
|
||||
end_idx_clean = end_idx if isinstance(end_idx, (int, float, str)) else None
|
||||
s_idx = max(0, int(start_idx_clean)) if start_idx_clean is not None else 0
|
||||
e_idx = min(total_points, int(end_idx_clean)) if end_idx_clean is not None else total_points
|
||||
if s_idx >= e_idx:
|
||||
s_idx = 0
|
||||
e_idx = total_points
|
||||
|
||||
index, original, filtered_float, filtered_fixed, step = downsample_for_plot(df.index.to_numpy(), x_signal, y_float, y_fixed)
|
||||
full_index = df.index.to_numpy()
|
||||
index, original, filtered_float, filtered_fixed, step = downsample_for_plot(
|
||||
full_index[s_idx:e_idx],
|
||||
x_values[s_idx:e_idx],
|
||||
y_float[s_idx:e_idx],
|
||||
y_fixed[s_idx:e_idx],
|
||||
)
|
||||
|
||||
return {
|
||||
"index": index.tolist(),
|
||||
@@ -174,13 +221,13 @@ def filter_preview_response(file_id, b_vals, a_vals, col_idx, b_int=None, a_int=
|
||||
"filtered": filtered_float.tolist(),
|
||||
"filtered_fixed": filtered_fixed.tolist(),
|
||||
"col_name": col_to_filter,
|
||||
"total_points": int(len(df.index)),
|
||||
"total_points": int(total_points),
|
||||
"plot_points": int(len(index)),
|
||||
"downsample_step": int(step),
|
||||
}
|
||||
|
||||
|
||||
def filtered_csv_text(file_id, b_vals, a_vals, col_idx, b_int=None, a_int=None, shift_in=14, shift_out=14, shift_b=14, shift_a=14, use_round=False):
|
||||
def filtered_csv_text(file_id, b_vals, a_vals, col_idx, b_int=None, a_int=None, shift_in=14, shift_out=14, shift_b=14, shift_a=14, use_round=False, stages=None, fs=100000.0, compact=False):
|
||||
path = get_cached_file_path(file_id)
|
||||
# 匯出時需要原始所有欄位,但仍受限於 MAX_ROWS
|
||||
df = pd.read_csv(path, nrows=MAX_ROWS)
|
||||
@@ -191,14 +238,23 @@ def filtered_csv_text(file_id, b_vals, a_vals, col_idx, b_int=None, a_int=None,
|
||||
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
|
||||
y_float, y_fixed = _run_filter_paths(
|
||||
x_values, b_vals, a_vals, b_int, a_int,
|
||||
shift_in, shift_out, shift_b, shift_a, use_round, stages=stages,
|
||||
)
|
||||
|
||||
if compact:
|
||||
export_df = pd.DataFrame({
|
||||
"Time (s)": df.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
|
||||
|
||||
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)
|
||||
export_df.to_csv(csv_buffer, index=False)
|
||||
return csv_buffer.getvalue()
|
||||
|
||||
@@ -47,3 +47,16 @@ class BodeCompareParams(BaseModel):
|
||||
class MCUWriteParams(BaseModel):
|
||||
command: str = Field(min_length=1, max_length=128)
|
||||
port: Optional[str] = Field(default=None, max_length=256)
|
||||
|
||||
|
||||
class CascadeStageParams(BaseModel):
|
||||
b: List[float] = Field(min_length=1, max_length=MAX_COEFFICIENTS)
|
||||
a: List[float] = Field(min_length=1, max_length=MAX_COEFFICIENTS)
|
||||
b_fixed: List[float] = Field(min_length=1, max_length=MAX_COEFFICIENTS)
|
||||
a_fixed: List[float] = Field(min_length=1, max_length=MAX_COEFFICIENTS)
|
||||
isActive: bool = True
|
||||
|
||||
|
||||
class BodeCascadeParams(BaseModel):
|
||||
stages: List[CascadeStageParams] = Field(min_length=1)
|
||||
fs: float = Field(gt=0)
|
||||
|
||||
+55
-8
@@ -2,7 +2,7 @@ from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import RedirectResponse, StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from dea.bode import calculate_bode_compare_response, calculate_bode_response
|
||||
from dea.bode import calculate_bode_cascade_response, calculate_bode_compare_response, calculate_bode_response
|
||||
from dea.config import (
|
||||
BODE_MAX_MULTIPLIER,
|
||||
BODE_POINTS,
|
||||
@@ -19,7 +19,7 @@ from dea.csv_processing import (
|
||||
)
|
||||
from dea.filter_design import design_response
|
||||
from dea.mcu import list_mcu_ports_response, write_mcu_command_response
|
||||
from dea.schemas import BodeCompareParams, BodeParams, DesignParams, MCUWriteParams
|
||||
from dea.schemas import BodeCascadeParams, BodeCompareParams, BodeParams, DesignParams, MCUWriteParams
|
||||
from dea.security import (
|
||||
SECURITY_HEADERS,
|
||||
add_security_headers,
|
||||
@@ -87,6 +87,16 @@ def calculate_bode_compare(params: BodeCompareParams):
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/api/bode/compare_cascade")
|
||||
def calculate_bode_compare_cascade(params: BodeCascadeParams):
|
||||
try:
|
||||
return calculate_bode_cascade_response(params)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/api/csv/upload")
|
||||
async def upload_csv(file: UploadFile = File(...)):
|
||||
try:
|
||||
@@ -99,6 +109,28 @@ async def upload_csv(file: UploadFile = File(...)):
|
||||
raise HTTPException(status_code=400, detail=f"CSV上傳失敗: {str(e)}")
|
||||
|
||||
|
||||
def parse_stages_list(stages_str: str):
|
||||
if not isinstance(stages_str, str) or not stages_str:
|
||||
return None
|
||||
import json
|
||||
stages_raw = json.loads(stages_str)
|
||||
stages_list = []
|
||||
for i, stage in enumerate(stages_raw):
|
||||
stages_list.append({
|
||||
"b": parse_coefficients(stage.get("b_str", ""), f"stages[{i}].b"),
|
||||
"a": parse_coefficients(stage.get("a_str", ""), f"stages[{i}].a"),
|
||||
"b_int": parse_int_coefficients(stage.get("b_int_str", ""), f"stages[{i}].b_int") if stage.get("b_int_str") else None,
|
||||
"a_int": parse_int_coefficients(stage.get("a_int_str", ""), f"stages[{i}].a_int") if stage.get("a_int_str") else None,
|
||||
"shift_in": int(stage.get("shift_in", 14)),
|
||||
"shift_out": int(stage.get("shift_out", 14)),
|
||||
"shift_b": int(stage.get("shift_b", 14)),
|
||||
"shift_a": int(stage.get("shift_a", 14)),
|
||||
"use_round": bool(stage.get("use_round", False)),
|
||||
"isActive": bool(stage.get("isActive", True)),
|
||||
})
|
||||
return stages_list
|
||||
|
||||
|
||||
@app.post("/api/filter")
|
||||
async def filter_csv(
|
||||
file_id: str = Form(...),
|
||||
@@ -112,20 +144,27 @@ async def filter_csv(
|
||||
shift_b: int = Form(14),
|
||||
shift_a: int = Form(14),
|
||||
use_round: bool = Form(False),
|
||||
stages: str = Form(None),
|
||||
start_idx: int = Form(None),
|
||||
end_idx: int = Form(None),
|
||||
):
|
||||
try:
|
||||
b_vals = parse_coefficients(b, "b")
|
||||
a_vals = parse_coefficients(a, "a")
|
||||
|
||||
b_int_vals = parse_int_coefficients(b_int, "b_int") if b_int else None
|
||||
a_int_vals = parse_int_coefficients(a_int, "a_int") if a_int else None
|
||||
b_int_vals = parse_int_coefficients(b_int, "b_int") if isinstance(b_int, str) and b_int else None
|
||||
a_int_vals = parse_int_coefficients(a_int, "a_int") if isinstance(a_int, str) and a_int else None
|
||||
stages_list = parse_stages_list(stages)
|
||||
|
||||
return filter_preview_response(
|
||||
file_id, b_vals, a_vals, col_idx,
|
||||
b_int=b_int_vals, a_int=a_int_vals,
|
||||
shift_in=shift_in, shift_out=shift_out,
|
||||
shift_b=shift_b, shift_a=shift_a,
|
||||
use_round=use_round
|
||||
use_round=use_round,
|
||||
stages=stages_list,
|
||||
start_idx=start_idx,
|
||||
end_idx=end_idx
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -167,20 +206,28 @@ async def filter_csv_download(
|
||||
shift_b: int = Form(14),
|
||||
shift_a: int = Form(14),
|
||||
use_round: bool = Form(False),
|
||||
stages: str = Form(None),
|
||||
fs: float = Form(100000.0),
|
||||
compact: bool = Form(False),
|
||||
):
|
||||
try:
|
||||
b_vals = parse_coefficients(b, "b")
|
||||
a_vals = parse_coefficients(a, "a")
|
||||
|
||||
b_int_vals = parse_int_coefficients(b_int, "b_int") if b_int else None
|
||||
a_int_vals = parse_int_coefficients(a_int, "a_int") if a_int else None
|
||||
b_int_vals = parse_int_coefficients(b_int, "b_int") if isinstance(b_int, str) and b_int else None
|
||||
a_int_vals = parse_int_coefficients(a_int, "a_int") if isinstance(a_int, str) and a_int else None
|
||||
stages_list = parse_stages_list(stages)
|
||||
compact_value = compact if isinstance(compact, bool) else False
|
||||
|
||||
csv_text = filtered_csv_text(
|
||||
file_id, b_vals, a_vals, col_idx,
|
||||
b_int=b_int_vals, a_int=a_int_vals,
|
||||
shift_in=shift_in, shift_out=shift_out,
|
||||
shift_b=shift_b, shift_a=shift_a,
|
||||
use_round=use_round
|
||||
use_round=use_round,
|
||||
stages=stages_list,
|
||||
fs=fs,
|
||||
compact=compact_value
|
||||
)
|
||||
|
||||
return StreamingResponse(
|
||||
|
||||
+30
-3
@@ -6,7 +6,7 @@
|
||||
<div class="flex items-center gap-3">
|
||||
<img :src="'/ui/logo.png'" alt="Logo" class="h-10 w-10 object-contain rounded-lg shadow-sm">
|
||||
<h1 class="text-xl font-bold text-slate-900 dark:text-gray-100">
|
||||
差分方程式分析 (Difference Equation Analyzer)</h1>
|
||||
級聯差分方程式分析 (Cascade Difference Equation Analyzer)</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- 模式切換按鈕 -->
|
||||
@@ -103,6 +103,33 @@
|
||||
</p>
|
||||
<p v-if="mcuStatus" class="text-xs text-slate-600 dark:text-gray-400">{{ mcuStatus }}</p>
|
||||
</div>
|
||||
<div class="mb-3 p-3 rounded-lg border border-slate-200 dark:border-gray-800 bg-slate-50 dark:bg-gray-900/50 space-y-2">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-xs font-semibold text-slate-600 dark:text-gray-400 uppercase tracking-wide">Cascade Stages</span>
|
||||
<button @click="addCascadeStage"
|
||||
class="text-[10px] font-bold role-bg-primary role-hover-primary role-text-on-fill px-3 py-1.5 rounded-full transition-colors">
|
||||
新增 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>
|
||||
|
||||
<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">
|
||||
<option v-for="opt in filterOptions" :key="opt" :value="opt">{{ opt }}</option>
|
||||
@@ -718,7 +745,7 @@
|
||||
時域訊號探討</h3>
|
||||
</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 檔案,系統會使用上述設定的差分濾波器。
|
||||
<p class="text-base text-slate-600 dark:text-gray-400 mb-4">請上傳包含時域訊號的 CSV 檔案,系統會讓訊號依序通過啟用的 cascade stages。
|
||||
</p>
|
||||
<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">
|
||||
@@ -770,7 +797,7 @@
|
||||
<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>
|
||||
|
||||
|
||||
+236
-10
@@ -26,6 +26,12 @@ export default {
|
||||
b_str: DEFAULT_B_STR,
|
||||
a_str: DEFAULT_A_STR,
|
||||
filterType: DEFAULT_FILTER_TYPE,
|
||||
cascadeStages: [],
|
||||
activeCascadeStageIndex: 0,
|
||||
zoomStartIdx: null, zoomEndIdx: null,
|
||||
timePlotUnit: null,
|
||||
timePlotMultiplier: null,
|
||||
isRedrawingTimePlot: false,
|
||||
filterOptions: [
|
||||
"Lowpass (低通)", "Highpass (高通)", "Bandpass (帶通)",
|
||||
"Notch (陷波器)", "1P1Z (一極一零)", "2P1Z (二極一零)", "2P2Z (二極二零)",
|
||||
@@ -266,7 +272,16 @@ export default {
|
||||
aFineStep: 'debouncedSaveSettings',
|
||||
fixedAFineStep: 'debouncedSaveSettings',
|
||||
b_int_str() { this.debouncedProcessFilter(); },
|
||||
a_int_str() { this.debouncedProcessFilter(); }
|
||||
a_int_str() { this.debouncedProcessFilter(); },
|
||||
selectedColumn(newVal, oldVal) {
|
||||
if (newVal !== oldVal) {
|
||||
this.zoomStartIdx = null;
|
||||
this.zoomEndIdx = null;
|
||||
this.timePlotUnit = null;
|
||||
this.timePlotMultiplier = null;
|
||||
this.debouncedProcessFilter();
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.isTouchInput = window.matchMedia?.('(pointer: coarse)').matches || false;
|
||||
@@ -286,6 +301,7 @@ export default {
|
||||
if (!this.webSerialSupported) {
|
||||
this.mcuStatus = '此瀏覽器不支援 Web Serial(需使用 Chrome 或 Edge,且透過 HTTPS 連線)';
|
||||
}
|
||||
this.ensureCascadeStages();
|
||||
this.updateBodeMagRange();
|
||||
this.applyFilterDesign();
|
||||
},
|
||||
@@ -303,6 +319,153 @@ export default {
|
||||
this.stopShiftOutDrag();
|
||||
},
|
||||
methods: {
|
||||
stageTitle(index) {
|
||||
const stage = this.cascadeStages[index];
|
||||
return stage?.filterType || `Stage ${index + 1}`;
|
||||
},
|
||||
cloneStage(stage) {
|
||||
return JSON.parse(JSON.stringify(stage));
|
||||
},
|
||||
captureCurrentStage() {
|
||||
return {
|
||||
id: this.cascadeStages[this.activeCascadeStageIndex]?.id || Date.now() + Math.random(),
|
||||
isActive: this.cascadeStages[this.activeCascadeStageIndex]?.isActive ?? true,
|
||||
filterType: this.filterType,
|
||||
b_str: this.b_str,
|
||||
a_str: this.a_str,
|
||||
systemGain: this.systemGain,
|
||||
params: { ...this.params },
|
||||
baseB: [...this.baseB],
|
||||
baseA: [...this.baseA],
|
||||
bSliders: [...this.bSliders],
|
||||
aSliders: [...this.aSliders],
|
||||
shiftBitsB: this.shiftBitsB,
|
||||
shiftBitsA: this.shiftBitsA,
|
||||
fixedOverrides: {
|
||||
a: { ...this.fixedOverrides.a },
|
||||
b: { ...this.fixedOverrides.b },
|
||||
},
|
||||
outOfRangeB: [...this.outOfRangeB],
|
||||
outOfRangeA: [...this.outOfRangeA],
|
||||
aFineStep: this.aFineStep,
|
||||
fixedAFineStep: this.fixedAFineStep,
|
||||
activeCoeffAdjustment: this.activeCoeffAdjustment,
|
||||
};
|
||||
},
|
||||
applyStageToControls(stage) {
|
||||
this.filterType = stage.filterType;
|
||||
this.b_str = stage.b_str;
|
||||
this.a_str = stage.a_str;
|
||||
this.systemGain = stage.systemGain;
|
||||
this.params = { ...stage.params };
|
||||
this.baseB = [...stage.baseB];
|
||||
this.baseA = [...stage.baseA];
|
||||
this.bSliders = [...stage.bSliders];
|
||||
this.aSliders = [...stage.aSliders];
|
||||
this.shiftBitsB = stage.shiftBitsB;
|
||||
this.shiftBitsA = stage.shiftBitsA;
|
||||
this.fixedOverrides = {
|
||||
a: { ...stage.fixedOverrides.a },
|
||||
b: { ...stage.fixedOverrides.b },
|
||||
};
|
||||
this.outOfRangeB = [...stage.outOfRangeB];
|
||||
this.outOfRangeA = [...stage.outOfRangeA];
|
||||
this.aFineStep = stage.aFineStep;
|
||||
this.fixedAFineStep = stage.fixedAFineStep;
|
||||
this.activeCoeffAdjustment = stage.activeCoeffAdjustment;
|
||||
},
|
||||
ensureCascadeStages() {
|
||||
if (this.cascadeStages.length === 0) {
|
||||
this.cascadeStages = [this.captureCurrentStage()];
|
||||
this.activeCascadeStageIndex = 0;
|
||||
}
|
||||
},
|
||||
saveActiveCascadeStage() {
|
||||
this.ensureCascadeStages();
|
||||
this.cascadeStages.splice(this.activeCascadeStageIndex, 1, this.captureCurrentStage());
|
||||
},
|
||||
selectCascadeStage(index) {
|
||||
if (index < 0 || index >= this.cascadeStages.length || index === this.activeCascadeStageIndex) return;
|
||||
this.saveActiveCascadeStage();
|
||||
this.activeCascadeStageIndex = index;
|
||||
this.applyStageToControls(this.cloneStage(this.cascadeStages[index]));
|
||||
this.updateBodeMagRange();
|
||||
this.updateBodePlot({ switchToChart: false });
|
||||
},
|
||||
addCascadeStage() {
|
||||
this.saveActiveCascadeStage();
|
||||
const newStage = this.cloneStage(this.cascadeStages[this.activeCascadeStageIndex]);
|
||||
newStage.id = Date.now() + Math.random();
|
||||
newStage.isActive = true;
|
||||
this.cascadeStages.push(newStage);
|
||||
this.selectCascadeStage(this.cascadeStages.length - 1);
|
||||
},
|
||||
removeCascadeStage(index) {
|
||||
if (this.cascadeStages.length <= 1) {
|
||||
this.globalError = '至少需要保留一個濾波器階段';
|
||||
return;
|
||||
}
|
||||
this.saveActiveCascadeStage();
|
||||
this.cascadeStages.splice(index, 1);
|
||||
this.activeCascadeStageIndex = Math.min(this.activeCascadeStageIndex, this.cascadeStages.length - 1);
|
||||
this.applyStageToControls(this.cloneStage(this.cascadeStages[this.activeCascadeStageIndex]));
|
||||
this.updateBodePlot({ switchToChart: false });
|
||||
},
|
||||
toggleCascadeStage(index) {
|
||||
this.saveActiveCascadeStage();
|
||||
const stage = this.cascadeStages[index];
|
||||
stage.isActive = !stage.isActive;
|
||||
this.updateBodePlot({ switchToChart: false });
|
||||
this.debouncedProcessFilter();
|
||||
},
|
||||
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))),
|
||||
};
|
||||
},
|
||||
getCascadeStagesSnapshot() {
|
||||
this.saveActiveCascadeStage();
|
||||
return this.cascadeStages.map(stage => this.cloneStage(stage));
|
||||
},
|
||||
getCascadeBodePayload() {
|
||||
return {
|
||||
fs: this.fs,
|
||||
stages: this.getCascadeStagesSnapshot().map(stage => {
|
||||
const fixed = this.getStageFixedCoeffs(stage);
|
||||
const scaleB = Math.pow(2, stage.shiftBitsB);
|
||||
const scaleA = Math.pow(2, stage.shiftBitsA);
|
||||
return {
|
||||
b: this.parseCoeffs(stage.b_str),
|
||||
a: this.parseCoeffs(stage.a_str),
|
||||
b_fixed: fixed.b.map(v => v / scaleB),
|
||||
a_fixed: fixed.a.map(v => v / scaleA),
|
||||
isActive: stage.isActive,
|
||||
};
|
||||
}),
|
||||
};
|
||||
},
|
||||
getCascadeFilterPayload() {
|
||||
return 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,
|
||||
};
|
||||
});
|
||||
},
|
||||
loadSettings() {
|
||||
try {
|
||||
const saved = localStorage.getItem('dea_settings');
|
||||
@@ -1006,13 +1169,11 @@ export default {
|
||||
return intVal / scaleA;
|
||||
});
|
||||
|
||||
const res = await fetch('/api/bode/compare', {
|
||||
const cascadePayload = this.getCascadeBodePayload();
|
||||
const res = await fetch('/api/bode/compare_cascade', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
ideal: { b: b_ideal, a: a_ideal, fs: this.fs },
|
||||
fixed: { b: b_fixed, a: a_fixed, fs: this.fs }
|
||||
})
|
||||
body: JSON.stringify(cascadePayload)
|
||||
});
|
||||
|
||||
let data;
|
||||
@@ -1248,6 +1409,10 @@ export default {
|
||||
this.selectedColumn = 0;
|
||||
this.filterDone = false;
|
||||
this.timePlotData = null;
|
||||
this.zoomStartIdx = null;
|
||||
this.zoomEndIdx = null;
|
||||
this.timePlotUnit = null;
|
||||
this.timePlotMultiplier = null;
|
||||
},
|
||||
|
||||
handleFileUpload(event) {
|
||||
@@ -1355,6 +1520,10 @@ export default {
|
||||
if (this.csvFile) this.globalError = "檔案還在上傳中,請稍候...";
|
||||
return;
|
||||
}
|
||||
if (this.zoomStartIdx === null && this.zoomEndIdx === null) {
|
||||
this.timePlotUnit = null;
|
||||
this.timePlotMultiplier = null;
|
||||
}
|
||||
this.loadingFilter = true;
|
||||
this.globalError = null;
|
||||
const formData = new FormData();
|
||||
@@ -1369,6 +1538,11 @@ export default {
|
||||
formData.append('shift_b', this.shiftBitsB);
|
||||
formData.append('shift_a', this.shiftBitsA);
|
||||
formData.append('use_round', this.useRound);
|
||||
formData.append('stages', JSON.stringify(this.getCascadeFilterPayload()));
|
||||
if (this.zoomStartIdx !== null && this.zoomEndIdx !== null) {
|
||||
formData.append('start_idx', this.zoomStartIdx);
|
||||
formData.append('end_idx', this.zoomEndIdx);
|
||||
}
|
||||
try {
|
||||
const res = await fetch('/api/filter', { method: 'POST', body: formData });
|
||||
if (!res.ok) { const err = await res.json(); throw new Error(err.detail || '濾波處理失敗'); }
|
||||
@@ -1392,19 +1566,66 @@ export default {
|
||||
const textColor = isDark ? '#b4b4bd' : '#525663';
|
||||
const plotBgColor = isDark ? '#090a0d' : '#ffffff';
|
||||
|
||||
if (!this.timePlotUnit) {
|
||||
const totalDurationS = (data.total_points || (data.index.length > 0 ? Math.max(...data.index) : 0)) / this.fs;
|
||||
if (totalDurationS > 0 && totalDurationS < 1e-3) {
|
||||
this.timePlotMultiplier = 1e6;
|
||||
this.timePlotUnit = 'μs';
|
||||
} else if (totalDurationS > 0 && totalDurationS < 1) {
|
||||
this.timePlotMultiplier = 1e3;
|
||||
this.timePlotUnit = 'ms';
|
||||
} else {
|
||||
this.timePlotMultiplier = 1;
|
||||
this.timePlotUnit = 's';
|
||||
}
|
||||
}
|
||||
const timeMultiplier = this.timePlotMultiplier || 1;
|
||||
const timeUnit = this.timePlotUnit || 's';
|
||||
const xData = data.index.map(i => (i / this.fs) * timeMultiplier);
|
||||
|
||||
const layout = {
|
||||
paper_bgcolor: plotBgColor, plot_bgcolor: plotBgColor,
|
||||
uirevision: 'time-domain-lod',
|
||||
margin: isSmallScreen ? { t: 60, b: 60, l: 45, r: 15 } : { t: 40, b: 55, l: 60, r: 20 },
|
||||
font: { color: textColor, family: 'Google Sans, Roboto, sans-serif' },
|
||||
xaxis: { title: { text: 'Sample Index', font: { size: isSmallScreen ? 10 : 11 } }, gridcolor: gridColor, zerolinecolor: zeroLineColor, tickfont: { color: textColor, size: isSmallScreen ? 9 : 11 } },
|
||||
xaxis: { title: { text: `Time (${timeUnit})`, font: { size: isSmallScreen ? 10 : 11 } }, gridcolor: gridColor, zerolinecolor: zeroLineColor, tickfont: { color: textColor, size: isSmallScreen ? 9 : 11 } },
|
||||
yaxis: { title: { text: 'Amplitude', font: { size: isSmallScreen ? 10 : 11 } }, gridcolor: gridColor, zerolinecolor: zeroLineColor, tickfont: { color: textColor, size: isSmallScreen ? 9 : 11 } },
|
||||
legend: { orientation: 'h', y: 1.02, yanchor: 'bottom', x: 1, xanchor: 'right', font: { color: textColor, size: isSmallScreen ? 9 : 10 } }
|
||||
};
|
||||
this.isRedrawingTimePlot = true;
|
||||
Plotly.react('timePlot', [
|
||||
{ x: data.index, y: data.original, name: '原始輸入 (Input)', type: 'scatter', line: { color: isDark ? '#c8cee0' : '#566075', width: 2.4 }, opacity: 0.5 },
|
||||
{ x: data.index, y: data.filtered, name: '理想路徑 (Ideal Float)', type: 'scatter', line: { color: isDark ? '#64d2ff' : '#2d6f8f', width: 2.8 }, opacity: 0.95 },
|
||||
{ x: data.index, y: data.filtered_fixed, name: '定點路徑 (Mimic Integer)', type: 'scatter', line: { color: isDark ? '#ff7c7c' : '#c0392b', width: 2.8, dash: 'dot' }, opacity: 0.95 }
|
||||
{ x: xData, y: data.original, name: '原始輸入 (Input)', type: 'scatter', line: { color: isDark ? '#c8cee0' : '#566075', width: 2.4 }, opacity: 0.5 },
|
||||
{ x: xData, y: data.filtered, name: '理想路徑 (Ideal Float)', type: 'scatter', line: { color: isDark ? '#64d2ff' : '#2d6f8f', width: 2.8 }, opacity: 0.95 },
|
||||
{ x: xData, y: data.filtered_fixed, name: '定點路徑 (Mimic Integer)', type: 'scatter', line: { color: isDark ? '#ff7c7c' : '#c0392b', width: 2.8, dash: 'dot' }, opacity: 0.95 }
|
||||
], layout, { responsive: true });
|
||||
this.$nextTick(() => setTimeout(() => { this.isRedrawingTimePlot = false; }, 50));
|
||||
const gd = document.getElementById('timePlot');
|
||||
if (gd && !gd._hasRelayoutListener) {
|
||||
gd.on('plotly_relayout', this.handleTimePlotRelayout);
|
||||
gd._hasRelayoutListener = true;
|
||||
}
|
||||
},
|
||||
|
||||
handleTimePlotRelayout(eventData) {
|
||||
if (this.isRedrawingTimePlot) return;
|
||||
if (!this.timePlotData || !this.timePlotData.total_points) return;
|
||||
let startIdx = null;
|
||||
let endIdx = null;
|
||||
if (eventData['xaxis.range[0]'] !== undefined && eventData['xaxis.range[1]'] !== undefined) {
|
||||
const timeMultiplier = this.timePlotMultiplier || 1;
|
||||
startIdx = Math.max(0, Math.floor((eventData['xaxis.range[0]'] * this.fs) / timeMultiplier));
|
||||
endIdx = Math.min(this.timePlotData.total_points, Math.ceil((eventData['xaxis.range[1]'] * this.fs) / timeMultiplier));
|
||||
if (endIdx - startIdx <= 10) return;
|
||||
} else if (eventData['xaxis.autorange'] === true) {
|
||||
startIdx = null;
|
||||
endIdx = null;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
this.zoomStartIdx = startIdx;
|
||||
this.zoomEndIdx = endIdx;
|
||||
if (this._zoomTimeout) clearTimeout(this._zoomTimeout);
|
||||
this._zoomTimeout = setTimeout(() => this.processFilter(), 150);
|
||||
},
|
||||
|
||||
async downloadCsv() {
|
||||
@@ -1421,6 +1642,11 @@ export default {
|
||||
formData.append('shift_b', this.shiftBitsB);
|
||||
formData.append('shift_a', this.shiftBitsA);
|
||||
formData.append('use_round', this.useRound);
|
||||
formData.append('stages', JSON.stringify(this.getCascadeFilterPayload()));
|
||||
if (this.zoomStartIdx !== null && this.zoomEndIdx !== null) {
|
||||
formData.append('start_idx', this.zoomStartIdx);
|
||||
formData.append('end_idx', this.zoomEndIdx);
|
||||
}
|
||||
try {
|
||||
const res = await fetch('/api/filter/download', { method: 'POST', body: formData });
|
||||
if (!res.ok) throw new Error('下載失敗');
|
||||
|
||||
+3
-3
File diff suppressed because one or more lines are too long
@@ -1,10 +1,12 @@
|
||||
import asyncio
|
||||
import json
|
||||
import unittest
|
||||
|
||||
from fastapi import HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from dea_api import (
|
||||
BodeCascadeParams,
|
||||
BodeCompareParams,
|
||||
BodeParams,
|
||||
DesignParams,
|
||||
@@ -12,8 +14,10 @@ from dea_api import (
|
||||
add_security_headers,
|
||||
calculate_bode,
|
||||
calculate_bode_compare,
|
||||
calculate_bode_compare_cascade,
|
||||
design_filter,
|
||||
filter_csv,
|
||||
filter_csv_download,
|
||||
upload_csv,
|
||||
write_mcu_command,
|
||||
)
|
||||
@@ -118,6 +122,33 @@ class DeaApiTest(unittest.TestCase):
|
||||
self.assertEqual(len(body["freq"]), len(body["fixed"]["mag"]))
|
||||
self.assertEqual(len(body["ideal"]["phase"]), len(body["fixed"]["phase"]))
|
||||
|
||||
def test_bode_compare_cascade_combines_active_stages(self):
|
||||
body = calculate_bode_compare_cascade(
|
||||
BodeCascadeParams(
|
||||
fs=1000,
|
||||
stages=[
|
||||
{
|
||||
"b": [1.0],
|
||||
"a": [1.0],
|
||||
"b_fixed": [1.0],
|
||||
"a_fixed": [1.0],
|
||||
"isActive": True,
|
||||
},
|
||||
{
|
||||
"b": [0.5, 0.5],
|
||||
"a": [1.0],
|
||||
"b_fixed": [0.5, 0.5],
|
||||
"a_fixed": [1.0],
|
||||
"isActive": True,
|
||||
},
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(len(body["freq"]), len(body["ideal"]["mag"]))
|
||||
self.assertEqual(len(body["freq"]), len(body["fixed"]["mag"]))
|
||||
self.assertEqual(len(body["ideal"]["phase"]), len(body["fixed"]["phase"]))
|
||||
|
||||
def test_mcu_write_rejects_invalid_command_format(self):
|
||||
try:
|
||||
write_mcu_command(MCUWriteParams(command="hello"))
|
||||
@@ -159,6 +190,90 @@ class DeaApiTest(unittest.TestCase):
|
||||
self.assertEqual(body["original"], [1.0, 2.0])
|
||||
self.assertEqual(body["filtered"], [1.0, 2.0])
|
||||
|
||||
def test_filter_preview_accepts_index_window_for_lod_zoom(self):
|
||||
upload = InMemoryUpload(b"value\n0\n1\n2\n3\n4\n", "input.csv")
|
||||
|
||||
body = asyncio.run(
|
||||
self.upload_and_filter_csv(
|
||||
upload,
|
||||
b="1",
|
||||
a="1",
|
||||
col_idx=0,
|
||||
start_idx=1,
|
||||
end_idx=4,
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(body["total_points"], 5)
|
||||
self.assertEqual(body["index"], [1, 2, 3])
|
||||
self.assertEqual(body["original"], [1.0, 2.0, 3.0])
|
||||
|
||||
def test_filter_accepts_cascade_stages_payload(self):
|
||||
upload = InMemoryUpload(b"value\n1\n2\n3\n", "input.csv")
|
||||
stages = [
|
||||
{
|
||||
"b_str": "1",
|
||||
"a_str": "1",
|
||||
"b_int_str": "16384",
|
||||
"a_int_str": "16384",
|
||||
"shift_in": 14,
|
||||
"shift_out": 14,
|
||||
"shift_b": 14,
|
||||
"shift_a": 14,
|
||||
"use_round": False,
|
||||
"isActive": True,
|
||||
},
|
||||
{
|
||||
"b_str": "1",
|
||||
"a_str": "1",
|
||||
"b_int_str": "16384",
|
||||
"a_int_str": "16384",
|
||||
"shift_in": 14,
|
||||
"shift_out": 14,
|
||||
"shift_b": 14,
|
||||
"shift_a": 14,
|
||||
"use_round": False,
|
||||
"isActive": True,
|
||||
},
|
||||
]
|
||||
|
||||
body = asyncio.run(
|
||||
self.upload_and_filter_csv(
|
||||
upload,
|
||||
b="1",
|
||||
a="1",
|
||||
col_idx=0,
|
||||
stages=json.dumps(stages),
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(body["original"], [1.0, 2.0, 3.0])
|
||||
self.assertEqual(body["filtered"], [1.0, 2.0, 3.0])
|
||||
self.assertEqual(body["filtered_fixed"], [1.0, 2.0, 3.0])
|
||||
|
||||
def test_filter_download_can_export_compact_four_column_csv(self):
|
||||
async def run_case():
|
||||
upload = InMemoryUpload(b"value\n1\n2\n", "input.csv")
|
||||
upload_body = await upload_csv(upload)
|
||||
response = await filter_csv_download(
|
||||
file_id=upload_body["file_id"],
|
||||
b="1",
|
||||
a="1",
|
||||
col_idx=0,
|
||||
fs=1000.0,
|
||||
compact=True,
|
||||
)
|
||||
chunks = []
|
||||
async for chunk in response.body_iterator:
|
||||
chunks.append(chunk)
|
||||
return b"".join(chunk if isinstance(chunk, bytes) else chunk.encode() for chunk in chunks).decode()
|
||||
|
||||
csv_text = asyncio.run(run_case())
|
||||
|
||||
self.assertTrue(csv_text.startswith("Time (s),value,value_filtered_ideal,value_filtered_fixed\n"))
|
||||
self.assertIn("0.0,1.0,1.0,1.0", csv_text)
|
||||
self.assertIn("0.001,2.0,2.0,2.0", csv_text)
|
||||
|
||||
def test_filter_rejects_non_csv_filename(self):
|
||||
upload = InMemoryUpload(b"value\n1\n", "input.txt")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user