add back in Kd

This commit is contained in:
Vincent van der Wal
2026-06-10 20:34:59 +02:00
parent 5422ef1154
commit 8802f631e5
6 changed files with 262 additions and 55 deletions
+60 -1
View File
@@ -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;
+25 -6
View File
@@ -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<typeof setInterval> | 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) {
+4 -4
View File
@@ -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
+22 -6
View File
@@ -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;
}
+1 -1
View File
@@ -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;
+150 -37
View File
@@ -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<RangeValue>(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 @@
<!-- Chart -->
<div class="chart-section">
<canvas bind:this={canvas} id="crusty"></canvas>
<div class="chart-toolbar">
{#each RANGES as r (r.value)}
<button
class="range-btn {chartRange === r.value ? 'active' : ''}"
onclick={() => setRange(r.value)}
>
{r.label}
</button>
{/each}
</div>
<div class="chart-canvas-wrap">
<canvas bind:this={canvas} id="crusty"></canvas>
</div>
</div>
<!-- Controls -->
@@ -631,11 +731,11 @@
<p class="subinfo mt-3">
ON {pwmOn.toFixed(2)} s &nbsp;/&nbsp; OFF {pwmOff ? pwmOff.toFixed(2) : 0} s
</p>
<div class="subcontrol-grid mt-3">
<div class="k-grid mt-3">
<div class="subcontrol">
<label class="sublabel" for="kp">Kp</label>
<input
class="num-input"
class="num-input k-input"
type="number"
min="0"
max="50"
@@ -648,16 +748,29 @@
<div class="subcontrol">
<label class="sublabel" for="ki">Ki</label>
<input
class="num-input"
class="num-input k-input"
type="number"
min="0"
max="50"
step="0.1"
step="0.0001"
value={ki}
id="ki"
onchange={(e) => changeKValue(e, 'i')}
/>
</div>
<div class="subcontrol">
<label class="sublabel" for="kd">Kd</label>
<input
class="num-input k-input"
type="number"
min="0"
max="50"
step="0.0001"
value={kd}
id="kd"
onchange={(e) => changeKValue(e, 'd')}
/>
</div>
</div>
{/if}
</div>