feat: integrate cascade filter workflow
This commit is contained in:
+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('下載失敗');
|
||||
|
||||
Reference in New Issue
Block a user