mirror of
https://github.com/vincentvdwal/crust-craft.git
synced 2026-08-18 09:26:37 +02:00
add back in Kd
This commit is contained in:
+60
-1
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
+149
-36
@@ -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,8 +598,20 @@
|
||||
|
||||
<!-- Chart -->
|
||||
<div class="chart-section">
|
||||
<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 -->
|
||||
<div class="controls-grid">
|
||||
@@ -631,11 +731,11 @@
|
||||
<p class="subinfo mt-3">
|
||||
ON {pwmOn.toFixed(2)} s / 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>
|
||||
|
||||
Reference in New Issue
Block a user