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
+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)