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
+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('下載失敗');