diff --git a/static/app.js b/static/app.js index b252f19..d9c619c 100644 --- a/static/app.js +++ b/static/app.js @@ -6,13 +6,13 @@ createApp({ fs: 100000.0, b_str: "0.5, 0.5, 0.0, 0.0", a_str: "1.0, 0.0, 0.0, 0.0", - filterType: "(無) 手動自訂", + filterType: "Lowpass (低通)", filterOptions: [ - "(無) 手動自訂", "Lowpass (低通)", "Highpass (高通)", - "Bandpass (帶通)", "Notch (陷波器)", "1P1Z (一極一零)", - "2P2Z (二極二零)", "PID 控制器", "SOGI-Alpha (帶通)", "SOGI-Beta (低通)" + "Lowpass (低通)", "Highpass (高通)", "Bandpass (帶通)", + "Notch (陷波器)", "1P1Z (一極一零)", "2P2Z (二極二零)", + "PID 控制器", "SOGI-Alpha (帶通)", "SOGI-Beta (低通)", "(無) 手動自訂" ], - systemGainDB: 0.0, + systemGain: 1.0, params: { fc: 1000.0, order: 1, bp_f_low: 500.0, bp_f_high: 2000.0, bp_order: 1, @@ -35,13 +35,14 @@ createApp({ csvFile: null, csvColumns: [], csvPreview: [], selectedColumn: 0, loadingFilter: false, filterDone: false, timePlotData: null, mobileTab: 'settings', // 手機版頁籤:'settings' | 'chart' - shiftBits: 12, + shiftBits: 14, + fixedOverrides: { a: {}, b: {} }, + outOfRangeB: [false, false, false, false, false, false, false], + outOfRangeA: [false, false, false, false, false, false, false], } }, + computed: { - linearSystemGain() { - return Math.pow(10, this.systemGainDB / 20.0); - }, maxLogB() { if (this.sense_b === '1%') return Math.log10(1.01); if (this.sense_b === '2x') return Math.log10(2.0); @@ -50,22 +51,68 @@ createApp({ maxLogA() { if (this.sense_a === '1%') return Math.log10(1.01); if (this.sense_a === '2x') return Math.log10(2.0); - return 1.0; + if (this.sense_a === '10x') return Math.log10(10.0); + return Math.log10(2.0); + }, + b_int_str: { + get() { + return this.fixedPointCoeffs.b.map(item => item.val).join(', '); + }, + set(val) { + const cleaned = val.replace(/[^0-9,\-\s]/g, ''); + const nums = this.parseCoeffs(cleaned); + nums.forEach((n, i) => { + this.fixedOverrides.b[i] = Math.round(n); + }); + } + }, + a_int_str: { + get() { + return this.fixedPointCoeffs.a.map(item => item.val).join(', '); + }, + set(val) { + const cleaned = val.replace(/[^0-9,\-\s]/g, ''); + const nums = this.parseCoeffs(cleaned); + nums.forEach((n, i) => { + this.fixedOverrides.a[i] = Math.round(n); + }); + } }, fixedPointCoeffs() { const scale = Math.pow(2, this.shiftBits); - const b_raw = this.parseCoeffs(this.b_str).map(x => x * this.linearSystemGain); + const b_raw = this.parseCoeffs(this.b_str); const a_raw = this.parseCoeffs(this.a_str); - const b = b_raw.map((x, i) => ({ label: `b${i}`, val: Math.round(x * scale) })); - const a = a_raw.map((x, i) => ({ label: `a${i}`, val: Math.round(x * scale) })); + const b = b_raw.map((x, i) => { + const autoVal = Math.round(x * scale); + const val = (this.fixedOverrides.b[i] !== undefined) ? this.fixedOverrides.b[i] : autoVal; + return { label: `b${i}`, val, isOverridden: this.fixedOverrides.b[i] !== undefined }; + }); + const a = a_raw.map((x, i) => { + const autoVal = Math.round(x * scale); + const val = (this.fixedOverrides.a[i] !== undefined) ? this.fixedOverrides.a[i] : autoVal; + return { label: `a${i}`, val, isOverridden: this.fixedOverrides.a[i] !== undefined }; + }); return { b, a }; }, floatingCoeffs() { - const b = this.parseCoeffs(this.b_str).map((x, i) => ({ label: `b${i}`, val: parseFloat((x * this.linearSystemGain).toPrecision(6)) })); + const b = this.parseCoeffs(this.b_str).map((x, i) => ({ label: `b${i}`, val: parseFloat(x.toPrecision(6)) })); const a = this.parseCoeffs(this.a_str).map((x, i) => ({ label: `a${i}`, val: parseFloat(x.toPrecision(6)) })); return { b, a }; + }, + isManualDirty() { + if (this.bSliders.some(v => v !== 0) || this.aSliders.some(v => v !== 0)) return true; + const currentB = this.padTo7(this.parseCoeffs(this.b_str)); + const currentA = this.padTo7(this.parseCoeffs(this.a_str)); + const targetB = this.baseB.map(x => x * (this.systemGain || 1.0)); + const targetA = this.baseA; + + for (let i = 0; i < 7; i++) { + if (Math.abs((currentB[i] || 0) - (targetB[i] || 0)) > 1e-8) return true; + if (Math.abs((currentA[i] || 0) - (targetA[i] || 0)) > 1e-8) return true; + } + return false; } }, mounted() { @@ -78,8 +125,7 @@ createApp({ } else { document.documentElement.classList.remove('dark'); } - this.syncBaseFromText(); - this.updateBodePlot(); + this.applyFilterDesign(); }, methods: { toggleDarkMode() { @@ -109,36 +155,95 @@ createApp({ return r; }, syncBaseFromText() { - this.baseB = this.padTo7(this.parseCoeffs(this.b_str)); + const gain = this.systemGain || 1.0; + this.baseB = this.padTo7(this.parseCoeffs(this.b_str)).map(x => x / gain); this.baseA = this.padTo7(this.parseCoeffs(this.a_str)); this.baseA[0] = 1.0; + this.fixedOverrides = { a: {}, b: {} }; this.bSliders = [0, 0, 0, 0, 0, 0, 0]; this.aSliders = [0, 0, 0, 0, 0, 0]; + this.outOfRangeB = [false, false, false, false, false, false, false]; + this.outOfRangeA = [false, false, false, false, false, false, false]; }, resetSlidersB() { this.bSliders = [0, 0, 0, 0, 0, 0, 0]; this.b_str = this.baseB.map(x => parseFloat(x.toPrecision(10))).join(', '); - this.updateBodePlot(); }, resetSlidersA() { this.aSliders = [0, 0, 0, 0, 0, 0]; this.a_str = this.baseA.map(x => parseFloat(x.toPrecision(10))).join(', '); - this.updateBodePlot(); }, - updateFromSliders() { - const currentB = this.baseB.map((base, i) => base * Math.pow(10, this.bSliders[i] || 0)); + clearManualAdjustments() { + this.bSliders = [0, 0, 0, 0, 0, 0, 0]; + this.aSliders = [0, 0, 0, 0, 0, 0]; + const gain = this.systemGain || 1.0; + this.b_str = this.baseB.map(x => parseFloat((x * gain).toPrecision(10))).join(', '); + this.a_str = this.baseA.map(x => parseFloat(x.toPrecision(10))).join(', '); + }, + updateFromControls() { + const currentB = this.baseB.map((base, i) => base * (this.systemGain || 1.0) * Math.pow(10, this.bSliders[i] || 0)); const currentA = [1.0]; for (let i = 1; i < 7; i++) { currentA.push(this.baseA[i] * Math.pow(10, this.aSliders[i] || 0)); } - this.b_str = currentB.map(x => parseFloat(x.toPrecision(6))).join(', '); - this.a_str = currentA.map(x => parseFloat(x.toPrecision(6))).join(', '); - this.debouncedUpdateBode(); + this.b_str = currentB.map(x => parseFloat(x.toPrecision(10))).join(', '); + this.a_str = currentA.map(x => parseFloat(x.toPrecision(10))).join(', '); }, updateFromText() { - this.syncBaseFromText(); + // 不再呼叫 syncBaseFromText(),保留基礎設計 (baseB/baseA) 作為參考點 + // 只更新圖表,讓滑桿維持在相對應的偏差位置 this.updateBodePlot(); }, + calcSlidersFromText() { + const gain = this.systemGain || 1.0; + const currentB = this.parseCoeffs(this.b_str); + const currentA = this.parseCoeffs(this.a_str); + + // 計算 b 係數滑桿 + for (let i = 0; i < 7; i++) { + const val = currentB[i] || 0; + const base = this.baseB[i] || 0; + this.outOfRangeB[i] = false; + if (base !== 0 && val !== 0 && (val/(base*gain)) > 0) { + const logVal = Math.log10(val / (base * gain)); + if (Math.abs(logVal) > this.maxLogB) this.outOfRangeB[i] = true; + this.bSliders[i] = Math.max(-this.maxLogB, Math.min(this.maxLogB, logVal)); + } else { + this.bSliders[i] = 0; + if (val !== 0 && base !== 0) this.outOfRangeB[i] = true; // 符號不同或極端 + } + } + // 計算 a 係數滑桿 (a0 恆為 1, 從 a1 開始) + for (let i = 1; i < 7; i++) { + const val = currentA[i] || 0; + const base = this.baseA[i] || 0; + this.outOfRangeA[i] = false; + if (base !== 0 && val !== 0 && (val/base) > 0) { + const logVal = Math.log10(val / base); + if (Math.abs(logVal) > this.maxLogA) this.outOfRangeA[i] = true; + this.aSliders[i] = Math.max(-this.maxLogA, Math.min(this.maxLogA, logVal)); + } else { + this.aSliders[i] = 0; + if (val !== 0 && base !== 0) this.outOfRangeA[i] = true; + } + } + }, + debouncedUpdateFromText(type) { + if (type === 'b') this.b_str = this.b_str.replace(/[^0-9,.\-\s]/g, ''); + if (type === 'a') this.a_str = this.a_str.replace(/[^0-9,.\-\s]/g, ''); + + // 當文字改變時,反向計算滑桿位置 + this.calcSlidersFromText(); + }, + updateFromFixed(idx, type, val) { + const intVal = parseInt(val); + if (isNaN(intVal)) return; + this.fixedOverrides[type][idx] = intVal; + this.updateBodePlot(); + }, + clearFixedOverrides() { + this.fixedOverrides = { a: {}, b: {} }; + }, onFsChange() { if (this.filterType !== "(無) 手動自訂") { this.applyFilterDesign(); @@ -157,6 +262,45 @@ createApp({ clearTimeout(this.bodeTimeout); this.bodeTimeout = setTimeout(() => this.updateBodePlot(), 150); }, + onGainChange() { + this.updateFromControls(); + this.debouncedUpdateBode(); + }, + onFilterTypeChange() { + this.systemGain = 1.0; + // 這裡可以選擇是否重設所有設計參數 (fc, Q 等) + // 根據使用者要求「載入預設值」,我們呼叫重設邏輯 + if (this.filterType !== "(無) 手動自訂") { + this.params = { + fc: 1000.0, order: 1, + bp_f_low: 500.0, bp_f_high: 2000.0, bp_order: 1, + notch_f0: 120.0, notch_q: 1.0, + opoz_fz: 15000.0, opoz_fp: 10.0, + tptz_fz1: 200.0, tptz_fz2: 25000.0, tptz_fp1: 10.0, tptz_fp2: 5000.0, + kp: 0.003, ki: 10.0, kd: 0.000016, + sogi_f0: 60.0, sogi_k: 1.414, + }; + } + this.applyFilterDesign(); + }, + resetFilterParams() { + if (this.filterType === "(無) 手動自訂") { + this.b_str = "1.0, 0.0, 0.0, 0.0"; + this.a_str = "1.0, 0.0, 0.0, 0.0"; + this.updateFromText(); + } else { + this.params = { + fc: 1000.0, order: 1, + bp_f_low: 500.0, bp_f_high: 2000.0, bp_order: 1, + notch_f0: 120.0, notch_q: 1.0, + opoz_fz: 15000.0, opoz_fp: 10.0, + tptz_fz1: 200.0, tptz_fz2: 25000.0, tptz_fp1: 10.0, tptz_fp2: 5000.0, + kp: 0.003, ki: 10.0, kd: 0.000016, + sogi_f0: 60.0, sogi_k: 1.414, + }; + this.applyFilterDesign(); + } + }, async applyFilterDesign() { if (this.filterType === "(無) 手動自訂") return; this.globalError = null; @@ -179,9 +323,17 @@ createApp({ }); if (!res.ok) { const err = await res.json(); throw new Error(err.detail || '設計濾波器失敗'); } const data = await res.json(); - this.b_str = data.b.map(x => parseFloat(x.toPrecision(10))).join(', '); - this.a_str = data.a.map(x => parseFloat(x.toPrecision(10))).join(', '); - this.syncBaseFromText(); + // 1. 將後端傳回的單位增益係數存入 baseB + this.baseB = this.padTo7(data.b); + this.baseA = this.padTo7(data.a); + this.baseA[0] = 1.0; + + // 2. 重新計算含增益的文字框顯示與滑桿位置 + this.bSliders = [0, 0, 0, 0, 0, 0, 0]; + this.aSliders = [0, 0, 0, 0, 0, 0]; + this.updateFromControls(); + + // 3. 更新圖表 this.updateBodePlot(); } catch (e) { this.globalError = e.message; } }, @@ -189,23 +341,44 @@ createApp({ this.loadingBode = true; this.globalError = null; try { - let b = this.parseCoeffs(this.b_str).map(val => val * this.linearSystemGain); - let a = this.parseCoeffs(this.a_str); - if (b.length === 0) b = [1.0]; - if (a.length === 0) a = [1.0]; - const res = await fetch('/api/bode', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ b, a, fs: this.fs }) + let b_ideal = this.parseCoeffs(this.b_str); + let a_ideal = this.parseCoeffs(this.a_str); + if (b_ideal.length === 0) b_ideal = [1.0]; + if (a_ideal.length === 0) a_ideal = [1.0]; + + const scale = Math.pow(2, this.shiftBits); + const b_fixed = b_ideal.map((x, i) => { + const intVal = (this.fixedOverrides.b[i] !== undefined) ? this.fixedOverrides.b[i] : Math.round(x * scale); + return intVal / scale; }); - if (!res.ok) throw new Error('無法計算頻率響應'); - const data = await res.json(); - this.drawBodePlot(data.freq, data.mag, data.phase); + const a_fixed = a_ideal.map((x, i) => { + const intVal = (this.fixedOverrides.a[i] !== undefined) ? this.fixedOverrides.a[i] : Math.round(x * scale); + return intVal / scale; + }); + + const [resIdeal, resFixed] = await Promise.all([ + fetch('/api/bode', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ b: b_ideal, a: a_ideal, fs: this.fs }) + }), + fetch('/api/bode', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ b: b_fixed, a: a_fixed, fs: this.fs }) + }) + ]); + + if (!resIdeal.ok || !resFixed.ok) throw new Error('無法計算頻率響應'); + + const dataIdeal = await resIdeal.json(); + const dataFixed = await resFixed.json(); + + this.drawBodePlot(dataIdeal.freq, dataIdeal.mag, dataIdeal.phase, dataFixed.mag, dataFixed.phase); // 手機版:計算完成後自動切換到圖表頁籤 if (window.innerWidth < 1024) this.mobileTab = 'chart'; } catch (e) { this.globalError = e.message; } finally { this.loadingBode = false; } }, - drawBodePlot(freq, mag, phase) { + drawBodePlot(freq, magIdeal, phaseIdeal, magFixed, phaseFixed) { const fs = this.fs; const fMin = freq[0], fMax = freq[freq.length - 1]; const isDark = this.isDarkMode; @@ -283,12 +456,19 @@ createApp({ const layout = { paper_bgcolor: 'transparent', plot_bgcolor: 'transparent', margin: isSmallScreen ? { t: 80, b: 70, l: 55, r: 15 } : { t: 70, b: 60, l: 70, r: 30 }, - showlegend: false, + showlegend: true, + legend: { + orientation: 'h', + y: 1.1, + x: 0.5, + xanchor: 'center', + font: { size: isSmallScreen ? 10 : 12, color: textColor } + }, grid: { rows: 2, columns: 1, pattern: 'independent', roworder: 'top to bottom', ygap: isSmallScreen ? 0.35 : 0.35 }, font: { color: textColor, family: 'Inter, sans-serif' }, annotations: [ - { text: 'Magnitude Response (dB)', xref: 'x domain', yref: 'y domain', x: 0.5, y: isSmallScreen ? 1.22 : 1.18, showarrow: false, font: { size: isSmallScreen ? 14 : 16, color: titleColor, weight: 'bold' } }, - { text: 'Phase Response (Deg)', xref: 'x2 domain', yref: 'y2 domain', x: 0.5, y: isSmallScreen ? 1.22 : 1.18, showarrow: false, font: { size: isSmallScreen ? 14 : 16, color: titleColor, weight: 'bold' } }, + { text: 'Magnitude Response (dB)', xref: 'x domain', yref: 'y domain', x: 0.5, y: isSmallScreen ? 1.25 : 1.2, showarrow: false, font: { size: isSmallScreen ? 14 : 16, color: titleColor, weight: 'bold' } }, + { text: 'Phase Response (Deg)', xref: 'x2 domain', yref: 'y2 domain', x: 0.5, y: isSmallScreen ? 1.25 : 1.2, showarrow: false, font: { size: isSmallScreen ? 14 : 16, color: titleColor, weight: 'bold' } }, ...annotations ], xaxis: { ...xAxisCommon }, @@ -298,10 +478,13 @@ createApp({ shapes: shapes }; - const traceMag = { x: freq, y: mag, name: 'Magnitude (dB)', type: 'scatter', line: { color: isDark ? '#3b82f6' : '#2563eb', width: 2.5 } }; - const tracePhase = { x: freq, y: phase, name: 'Phase (deg)', type: 'scatter', line: { color: isDark ? '#f97316' : '#ea580c', width: 2.5 }, xaxis: 'x2', yaxis: 'y2' }; + const traceMagIdeal = { x: freq, y: magIdeal, name: 'Ideal (Float)', type: 'scatter', line: { color: isDark ? '#3b82f6' : '#2563eb', width: 2.5 } }; + const traceMagFixed = { x: freq, y: magFixed, name: `Fixed (Q${this.shiftBits})`, type: 'scatter', line: { color: isDark ? '#10b981' : '#059669', width: 2, dash: 'dot' } }; + + const tracePhaseIdeal = { x: freq, y: phaseIdeal, name: 'Ideal Phase', type: 'scatter', line: { color: isDark ? '#f97316' : '#ea580c', width: 2.5 }, xaxis: 'x2', yaxis: 'y2', showlegend: false }; + const tracePhaseFixed = { x: freq, y: phaseFixed, name: 'Fixed Phase', type: 'scatter', line: { color: isDark ? '#f43f5e' : '#e11d48', width: 2, dash: 'dot' }, xaxis: 'x2', yaxis: 'y2', showlegend: false }; - Plotly.react('bodePlot', [traceMag, tracePhase], layout, { responsive: true }); + Plotly.react('bodePlot', [traceMagIdeal, traceMagFixed, tracePhaseIdeal, tracePhaseFixed], layout, { responsive: true }); }, handleFileUpload(event) { @@ -338,9 +521,8 @@ createApp({ this.loadingFilter = true; this.globalError = null; const formData = new FormData(); - const final_b_str = this.parseCoeffs(this.b_str).map(val => val * this.linearSystemGain).join(', '); formData.append('file', this.csvFile); - formData.append('b', final_b_str); + formData.append('b', this.b_str); formData.append('a', this.a_str); formData.append('col_idx', this.selectedColumn); try { @@ -378,9 +560,8 @@ createApp({ async downloadCsv() { if (!this.csvFile) return; const formData = new FormData(); - const final_b_str = this.parseCoeffs(this.b_str).map(val => val * this.linearSystemGain).join(', '); formData.append('file', this.csvFile); - formData.append('b', final_b_str); + formData.append('b', this.b_str); formData.append('a', this.a_str); formData.append('col_idx', this.selectedColumn); try { diff --git a/static/index.html b/static/index.html index 8136a5c..e7f242f 100644 --- a/static/index.html +++ b/static/index.html @@ -82,17 +82,21 @@

- - - - - - 濾波器設計 + class="text-sm font-semibold text-slate-500 dark:text-gray-400 uppercase tracking-wider mb-3 flex items-center justify-between"> +
+ + + + + + 濾波器設計 +
+

- @@ -230,12 +234,12 @@ -
- - 系統增益 K (System Gain){{ systemGain }}x +
@@ -248,8 +252,13 @@

濾波器係數設定 - +
+ + +

@@ -260,7 +269,7 @@ {{ item.val }}
- @@ -281,9 +290,11 @@ @@ -298,7 +309,7 @@ {{ item.val }} - @@ -319,49 +330,76 @@ - -
-

+

+ +
+ + +
+

+
定點數係數轉換 Q{{ shiftBits }} -

-
- -
- - Scaling: 2^{{ shiftBits }} ({{ Math.pow(2, shiftBits) }}) -
-
-
- -
-
- {{ item.label }}: - {{ item.val }} -
-
-
-
- -
-
- {{ item.label }}: - {{ item.val }} -
-
-
+
+ +
-

註:此數值為原始係數乘以 2^{{ shiftBits }} 後四捨五入之結果,可用於定點數 DSP 實作。

+ + +
+ +
+ +
+

Scaling: 2^{{ shiftBits }} ({{ Math.pow(2, shiftBits) }})

+ +
+
+ +
+
+ {{ item.label }}: + {{ item.val }} +
+
+ +
+
+ +
+
+ {{ item.label }}: + {{ item.val }} +
+
+ +
+
+

註:此數值為原始係數乘以 2^{{ shiftBits }} 後四捨五入之結果,可用於定點數 DSP 實作。