From 3edc4702f32ce98621983b975d6ca8bb21893d29 Mon Sep 17 00:00:00 2001 From: ws50529 Date: Tue, 26 May 2026 15:34:24 +0800 Subject: [PATCH] feat: integrate cascade filter workflow --- .gitignore | 4 + dea/bode.py | 38 ++++++- dea/csv_processing.py | 94 ++++++++++++---- dea/schemas.py | 13 +++ dea_api.py | 63 +++++++++-- src/App.vue | 33 +++++- src/app-options.js | 246 ++++++++++++++++++++++++++++++++++++++++-- static/build/app.js | 6 +- tests/test_dea_api.py | 115 ++++++++++++++++++++ 9 files changed, 568 insertions(+), 44 deletions(-) diff --git a/.gitignore b/.gitignore index a49625d..6f9f877 100644 --- a/.gitignore +++ b/.gitignore @@ -38,8 +38,12 @@ ehthumbs.db Desktop.ini # Agent Skills +.agents/ .agents +# Local Tracker & AI Workspace +.scratch/ +.gemini/ # SSL certs (private keys) certs/*.pem diff --git a/dea/bode.py b/dea/bode.py index dea37c1..7fd2e0b 100755 --- a/dea/bode.py +++ b/dea/bode.py @@ -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(), + }, + } diff --git a/dea/csv_processing.py b/dea/csv_processing.py index ab689fb..d57dae6 100755 --- a/dea/csv_processing.py +++ b/dea/csv_processing.py @@ -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() diff --git a/dea/schemas.py b/dea/schemas.py index 0982e91..f4b7d5f 100755 --- a/dea/schemas.py +++ b/dea/schemas.py @@ -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) diff --git a/dea_api.py b/dea_api.py index 9e945ad..f2abc77 100755 --- a/dea_api.py +++ b/dea_api.py @@ -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( diff --git a/src/App.vue b/src/App.vue index dd8d1c2..30b4787 100644 --- a/src/App.vue +++ b/src/App.vue @@ -6,7 +6,7 @@
Logo

- 差分方程式分析 (Difference Equation Analyzer)

+ 級聯差分方程式分析 (Cascade Difference Equation Analyzer)
@@ -103,6 +103,33 @@

{{ mcuStatus }}

+
+
+ Cascade Stages + +
+
+ +
+
+ + +
+
+