diff --git a/src/app.css b/src/app.css index c8a141d..4f2891d 100644 --- a/src/app.css +++ b/src/app.css @@ -352,6 +352,7 @@ p { } .oven { + pointer-events: none; display: flex; justify-content: center; } @@ -420,7 +421,49 @@ p { border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem; - height: 300px; + height: 340px; + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.chart-toolbar { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; + justify-content: flex-end; +} + +.range-btn { + background: var(--surface-raised); + border: 1px solid var(--border); + border-radius: 7px; + color: var(--text-muted); + font-size: 0.72rem; + font-weight: 600; + padding: 0.25rem 0.55rem; + cursor: pointer; + transition: + background 0.15s, + color 0.15s, + border-color 0.15s; +} + +.range-btn:hover { + color: var(--text); + border-color: var(--mode-color); +} + +.range-btn.active { + background: var(--mode-color); + border-color: var(--mode-color); + color: #fff; +} + +.chart-canvas-wrap { + position: relative; + flex: 1; + min-height: 0; } .chart-section canvas { @@ -548,6 +591,22 @@ p { color: var(--text-muted); } +/* Kp / Ki / Kd grid – three equal columns, inputs wide enough for 0.0001 */ +.k-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.6rem; + width: 100%; +} + +.k-input { + width: 100%; + min-width: 0; + font-size: 0.95rem; + padding-left: 0.45rem; + padding-right: 0.2rem; +} + /* ── Action buttons ────────────────────────────────────── */ .action-btn { display: inline-flex; diff --git a/src/lib/spoof.ts b/src/lib/spoof.ts index 93600c4..de7faba 100644 --- a/src/lib/spoof.ts +++ b/src/lib/spoof.ts @@ -17,10 +17,11 @@ const MAX_HEAT_RATE = 14; // °C/s at peak of S-curve const NOISE_AMP = 0.35; // °C peak random noise // PI-mode parameters – keep in sync with the firmware (main.cpp) -const PAUSE_TEMP = 275; // low keep-warm hold +const PAUSE_TEMP = 250; // low keep-warm hold const BAKE_BOOST = 40; // °C added on top of target while baking const BAKE_WAIT_MS = 60 * 1000; // 1 min warm-up before baking const BAKE_DURATION_MS = 3 * 60 * 1000; // 3 min bake, then back to preheat +const D_FILTER = 0.8; // derivative low-pass smoothing (matches firmware) /** S-curve heating factor: parabolic 4x(1-x), peaks at x = 0.5. */ function heatFactor(temp: number, target: number): number { @@ -63,20 +64,23 @@ export class SpoofSocket { mode: 'preheat', temperature: ROOM_TEMP, relais: 0, - target_temp: 460, + target_temp: 350, pause_temp: PAUSE_TEMP, bake_boost: BAKE_BOOST, pwm_on: 2000, pwm_off: 4000, pid: 0, - kp: 0.6, - ki: 0.03, + kp: 0.55, + ki: 0.005, kd: 0.0, bake_phase: '', bake_remaining: 0 }; private integral = 0; + private dFilt = 0; + private prevTemp = ROOM_TEMP; + private firstRun = true; private lastSwitch = Date.now(); private bakeStart = 0; private ticker: ReturnType | null = null; @@ -136,7 +140,11 @@ export class SpoofSocket { } // Reset integral arriving from a non-PID mode (stale value) or when the // new target is below the current temp (mirrors firmware applyMode). - if (this.isPidMode(m) && (!wasPid || this.activeSetpoint() < s.temperature)) this.integral = 0; + if (this.isPidMode(m) && (!wasPid || this.activeSetpoint() < s.temperature)) { + this.integral = 0; + this.dFilt = 0; + this.firstRun = true; + } } private handle(cmd: string, value?: number | string): void { @@ -213,7 +221,18 @@ export class SpoofSocket { // integral clamped to ±70 to match the firmware (anti-overshoot) this.integral = Math.max(-70, Math.min(70, this.integral + s.ki * error * dt)); } - output = Math.max(0, Math.min(100, pTerm + this.integral)); + + // Filtered derivative on measurement (mirrors PIDController::compute) + let dTerm = 0; + if (!this.firstRun) { + const deriv = (-s.kd * (s.temperature - this.prevTemp)) / dt; + this.dFilt = D_FILTER * this.dFilt + (1 - D_FILTER) * deriv; + dTerm = this.dFilt; + } + this.firstRun = false; + this.prevTemp = s.temperature; + + output = Math.max(0, Math.min(100, pTerm + this.integral + dTerm)); s.pid = output; if (output >= 100) { diff --git a/src/main.cpp b/src/main.cpp index 6ec3bc2..1723e18 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -35,9 +35,9 @@ float pwmSwitchDelayOff = 4000; // 4s float power = 100; -float kp = 0.6; -float ki = 0.03; -float kd = 0.0; +float kp = 0.55; +float ki = 0.005; +float kd = 0.5; // Heating modes. PREHEAT, PAUSE and BAKING are all PI-regulated and only // differ in the setpoint they drive towards. The oven always boots in PREHEAT @@ -46,7 +46,7 @@ enum class Mode { MANUAL, PWM, PREHEAT, PAUSE, BAKING }; Mode mode = Mode::PREHEAT; // Setpoints / timing for the PI-based modes (pauseTemp / bakeBoost are UI-adjustable) -float pauseTemp = 275.0f; // low "keep-warm" hold +float pauseTemp = 250.0f; // low "keep-warm" hold float bakeBoost = 40.0f; // °C added on top of target constexpr unsigned long BAKE_WAIT_MS = 60UL * 1000; // 1 min warm-up before baking constexpr unsigned long BAKE_DURATION_MS = 3UL * 60 * 1000; // 3 min bake, then -> PREHEAT diff --git a/src/pid.cpp b/src/pid.cpp index a49d4ae..c1aedff 100644 --- a/src/pid.cpp +++ b/src/pid.cpp @@ -1,5 +1,9 @@ #include "pid.h" +// Low-pass smoothing for the derivative term (0 = none, →1 = heavy). The +// thermocouple reads in 0.25 °C steps, so the raw derivative is very noisy. +static constexpr double D_FILTER = 0.8; + double PIDController::clamp(double value, double min_val, double max_val) const { if (value < min_val) @@ -12,8 +16,8 @@ double PIDController::clamp(double value, double min_val, double max_val) const PIDController::PIDController(double Kp, double Ki, double Kd, double max_out, double min_out, double integ_max) : kp(Kp), ki(Ki), kd(Kd), setpoint(0.0), integral(0.0), - prev_error(0.0), max_output(max_out), min_output(min_out), - integral_max(integ_max), dt(0.1), first_run(true) {} + prev_measurement(0.0), d_filtered(0.0), max_output(max_out), + min_output(min_out), integral_max(integ_max), dt(0.1), first_run(true) {} void PIDController::setSetpoint(double sp) { @@ -63,16 +67,28 @@ double PIDController::compute(double current_temp) integral = clamp(integral, -integral_max, integral_max); } - // Output - output = clamp(p_term + integral, min_output, max_output); + // Derivative on the *measurement* (not the error) so a setpoint change + // doesn't cause a derivative kick, low-pass filtered to tame sensor noise. + // d(error)/dt = -d(temp)/dt for a fixed setpoint, hence the leading minus. + double d_term = 0.0; + if (!first_run && dt > 0.0) + { + double deriv = -kd * (current_temp - prev_measurement) / dt; + d_filtered = D_FILTER * d_filtered + (1.0 - D_FILTER) * deriv; + d_term = d_filtered; + } + first_run = false; + prev_measurement = current_temp; - prev_error = error; + // Output + output = clamp(p_term + integral + d_term, min_output, max_output); return output; } void PIDController::reset() { integral = 0.0; - prev_error = 0.0; + prev_measurement = 0.0; + d_filtered = 0.0; first_run = true; } \ No newline at end of file diff --git a/src/pid.h b/src/pid.h index caa98ed..7ce533f 100644 --- a/src/pid.h +++ b/src/pid.h @@ -4,7 +4,7 @@ class PIDController { private: - double kp, ki, kd, setpoint, integral, prev_error; + double kp, ki, kd, setpoint, integral, prev_measurement, d_filtered; double max_output, min_output, integral_max, dt; bool first_run; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 2e9ad61..9a9fb6c 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -27,9 +27,19 @@ Tooltip ); - // Rolling window for the chart (~5 min at 2 pts/sec) - const CHART_WINDOW = 600; - // Full buffer kept for CSV export (24h at 2 pts/sec) + // Chart view ranges (minutes); 'all' = the whole stored history. + const POINTS_PER_MIN = 120; // 2 readings/sec × 60 + const MAX_RENDER_POINTS = 1500; // points actually drawn; longer ranges get downsampled + const RANGES = [ + { label: '5m', value: 5 }, + { label: '10m', value: 10 }, + { label: '20m', value: 20 }, + { label: '30m', value: 30 }, + { label: '60m', value: 60 }, + { label: 'All', value: 'all' } + ] as const; + type RangeValue = (typeof RANGES)[number]['value']; + // Full buffer kept for CSV export + as the chart source (24h at 2 pts/sec) const MAX_STORED = 24 * 2 * 60 * 60; let gateway = `ws://${page.url.host}/ws`; @@ -38,21 +48,23 @@ let mode = $state('preheat'); let temp = $state(0); let relais = $state(0); - let targetTemp = $state(460); + let targetTemp = $state(350); let calculatedPidOutput = $state(0); let bakeRemaining = $state(0); let bakePhase = $state(''); - let pauseTemp = $state(275); + let pauseTemp = $state(250); let bakeBoost = $state(40); let pwmOn = $state(2); let pwmOff = $state(4); - let kp = $state(0.6); - let ki = $state(0.03); + let kp = $state(0.55); + let ki = $state(0.005); + let kd = $state(0.0); let pauseGraphUpdate = $state(false); let darkMode = $state(true); + let chartRange = $state(5); // ── Modes ────────────────────────────────────────────── // Preheat / Pause / Baking are all PI-regulated on the firmware; they only @@ -182,10 +194,11 @@ } }; - // Full history – only used for CSV export + // Full history – source for both the chart and the CSV export let temperatureData: Array<[number, number]> = []; let relaisData: Array<[number, number]> = []; let powerData: Array<[number, number]> = []; + let targetData: Array<[number, number]> = []; let canvas: HTMLCanvasElement; let downloadCSVButton: HTMLAnchorElement; @@ -198,6 +211,84 @@ } }; + const pushCapped = (arr: Array<[number, number]>, t: number, v: number) => { + if (arr.length >= MAX_STORED) arr.shift(); + arr.push([t, v]); + }; + + type Pt = { x: number; y: number }; + + // Build a chart series from the raw history slice src[start..], downsampling + // to at most `maxPoints` by keeping each bucket's min AND max so peaks (e.g. + // overshoot spikes) are never hidden. Reads the source directly so only the + // (small) output is allocated, even for the "All" range. + const buildSeries = (src: Array<[number, number]>, start: number, maxPoints: number): Pt[] => { + const count = src.length - start; + if (count <= 0) return []; + if (count <= maxPoints) { + const out: Pt[] = new Array(count); + for (let i = 0; i < count; i++) out[i] = { x: src[start + i][0], y: src[start + i][1] }; + return out; + } + const buckets = Math.max(1, Math.floor(maxPoints / 2)); + const size = count / buckets; + const out: Pt[] = []; + for (let b = 0; b < buckets; b++) { + const s = start + Math.floor(b * size); + const e = start + Math.min(count, Math.floor((b + 1) * size)); + let mnI = s; + let mxI = s; + for (let i = s + 1; i < e; i++) { + if (src[i][1] < src[mnI][1]) mnI = i; + if (src[i][1] > src[mxI][1]) mxI = i; + } + const a = Math.min(mnI, mxI); // emit in time order + const z = Math.max(mnI, mxI); + out.push({ x: src[a][0], y: src[a][1] }); + if (z !== a) out.push({ x: src[z][0], y: src[z][1] }); + } + return out; + }; + + // Rebuild the chart datasets from the stored history for the selected range, + // then zoom the y-axis to the visible temperature so small oscillations are + // readable. + const refreshChart = () => { + if (!chart) return; + const total = temperatureData.length; + const count = chartRange === 'all' ? total : Math.min(total, chartRange * POINTS_PER_MIN); + const start = total - count; + + chart.data.datasets[0].data = buildSeries(temperatureData, start, MAX_RENDER_POINTS); + chart.data.datasets[1].data = buildSeries(targetData, start, MAX_RENDER_POINTS); + chart.data.datasets[2].data = buildSeries(relaisData, start, MAX_RENDER_POINTS); + + // Zoom y tight around the temperature actually on screen (+ a little pad). + let lo = Infinity; + let hi = -Infinity; + for (const p of chart.data.datasets[0].data as Pt[]) { + if (p.y < lo) lo = p.y; + if (p.y > hi) hi = p.y; + } + const yScale = chart.options.scales!.y as { min?: number; max?: number }; + if (lo === Infinity) { + yScale.min = 0; + yScale.max = undefined; + } else { + const span = Math.max(hi - lo, 8); // never zoom tighter than ~8°C + const pad = span * 0.15; + yScale.min = Math.max(0, Math.floor(lo - pad)); + yScale.max = Math.ceil(hi + pad); + } + + chart.update('none'); + }; + + const setRange = (v: RangeValue) => { + chartRange = v; + refreshChart(); + }; + onMount(() => { chart = new Chart(canvas, { type: 'line', @@ -216,13 +307,19 @@ type: 'time', time: { tooltipFormat: 'HH:mm:ss', - displayFormats: { minute: 'HH:mm', hour: 'HH:mm' } + // 24-hour formats for every unit so no am/pm leaks in + displayFormats: { + millisecond: 'HH:mm:ss', + second: 'HH:mm:ss', + minute: 'HH:mm', + hour: 'HH:mm' + } }, ticks: { color: '#7a7f96' }, grid: { color: 'rgba(255,255,255,0.05)' } }, y: { - min: 0, + // min/max are set dynamically in refreshChart() to zoom on the data ticks: { color: '#7a7f96' }, grid: { color: 'rgba(255,255,255,0.05)' } }, @@ -323,6 +420,7 @@ calculatedPidOutput = data.pid; kp = data.kp; ki = data.ki; + kd = data.kd ?? 0; bakeRemaining = data.bake_remaining ?? 0; bakePhase = data.bake_phase ?? ''; pauseTemp = data.pause_temp ?? pauseTemp; @@ -330,30 +428,20 @@ const now = Date.now(); - if (temperatureData.length >= MAX_STORED) temperatureData.shift(); - temperatureData.push([now, temp]); - if (relaisData.length >= MAX_STORED) relaisData.shift(); - relaisData.push([now, relais]); - if (powerData.length >= MAX_STORED) powerData.shift(); - powerData.push([now, calculatedPidOutput]); + // Effective setpoint the oven is actually driving towards (boost / pause). + const effTarget = + data.mode === 'baking' + ? data.target_temp + (data.bake_boost ?? bakeBoost) + : data.mode === 'pause' + ? (data.pause_temp ?? pauseTemp) + : data.target_temp; - if (!pauseGraphUpdate && chart) { - const tempSeries = chart.data.datasets[0].data as { x: number; y: number }[]; - const targetSeries = chart.data.datasets[1].data as { x: number; y: number }[]; - const relaySeries = chart.data.datasets[2].data as { x: number; y: number }[]; + pushCapped(temperatureData, now, temp); + pushCapped(relaisData, now, data.relais); + pushCapped(powerData, now, calculatedPidOutput); + pushCapped(targetData, now, effTarget); - tempSeries.push({ x: now, y: temp }); - targetSeries.push({ x: now, y: targetTemp }); - relaySeries.push({ x: now, y: data.relais }); - - if (tempSeries.length > CHART_WINDOW) { - tempSeries.shift(); - targetSeries.shift(); - relaySeries.shift(); - } - - chart.update('none'); - } + if (!pauseGraphUpdate) refreshChart(); }; const switchRelais = () => send('switchRelais'); @@ -510,7 +598,19 @@
- +
+ {#each RANGES as r (r.value)} + + {/each} +
+
+ +
@@ -631,11 +731,11 @@

ON {pwmOn.toFixed(2)} s  /  OFF {pwmOff ? pwmOff.toFixed(2) : 0} s

-
+
changeKValue(e, 'i')} />
+
+ + changeKValue(e, 'd')} + /> +
{/if}