feat: integrate cascade filter workflow

This commit is contained in:
ws50529
2026-05-26 15:34:24 +08:00
parent 075f20dd81
commit 3edc4702f3
9 changed files with 568 additions and 44 deletions
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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()
+13
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because one or more lines are too long
+115
View File
@@ -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")