initial commit

This commit is contained in:
2026-07-05 10:06:21 +02:00
commit f8ed637759
16 changed files with 1795 additions and 0 deletions

85
include/config.h Normal file
View File

@@ -0,0 +1,85 @@
#pragma once
// ---------------------------------------------------------------------------
// I2C — Nano default: A4 (SDA), A5 (SCL). Wire handles pin assignment.
// ---------------------------------------------------------------------------
static const uint8_t TCA9548A_ADDRESS = 0x70;
// Four SHT31 sensors on TCA9548A channels 2, 3, 4, 5
static const uint8_t SENSOR_CHANNELS[] = {2, 3, 4, 5};
static const uint8_t SENSOR_COUNT = sizeof(SENSOR_CHANNELS) / sizeof(SENSOR_CHANNELS[0]);
// SHT31 I2C address (ADDR pin low → 0x44, high → 0x45)
static const uint8_t SHT31_ADDRESS = 0x44;
// ---------------------------------------------------------------------------
// Outputs — D5 has hardware PWM; heater on A2 uses burst control (SSR-friendly)
// ---------------------------------------------------------------------------
static const uint8_t FAN_PIN = 5; // D5 — 24 V fan via N-channel MOSFET
static const uint8_t HEATER_PIN = A2; // heater via solid-state relay
// ---------------------------------------------------------------------------
// Temperature control
// ---------------------------------------------------------------------------
static const float TARGET_TEMP_C = 0.0f; // power-on default: idle (heater off)
static const float AUTOTUNE_DEFAULT_TEMP_C = 40.0f; // autotune when no temp given and idle
static const float TARGET_MIN_C = 0.0f; // 0 = idle (heater off, fan at idle speed)
static const float TARGET_MAX_C = 80.0f;
static const float OVERTEMP_FRACTION = 0.05f; // hard cutoff at target * 1.05
// PID on chamber average
static const float PID_KP = 4.0f;
static const float PID_KI = 0.05f;
static const float PID_KD = 6.0f;
// Tiered heater cap — more power when cold, gentle near setpoint
static const float HEATER_MAX_DUTY_COLD = 65.0f; // avg >10 °C below target
static const float HEATER_MAX_DUTY_MID = 50.0f; // avg 310 °C below target
static const float HEATER_MAX_DUTY_NEAR = 42.0f; // avg <3 °C below target
static const float HEATER_COLD_BELOW_C = 10.0f;
static const float HEATER_WARM_BELOW_C = 3.0f;
// Ramp-up limit (% per second) — still caps sudden jumps
static const float HEATER_SLEW_UP_PER_S = 18.0f;
// Hot-corner limiter: taper heater as max corner approaches stop temperature
static const float MAX_TEMP_HEADROOM_C = 15.0f;
// Average-temp approach: taper only in the last few °C before setpoint
static const float APPROACH_BAND_C = 4.0f;
// When spread is good, allow hottest corner slightly above target so avg can reach setpoint
static const float GOOD_SPREAD_C = 5.0f;
static const float BALANCED_MAX_ABOVE_TARGET_C = 2.0f;
static const uint16_t HEATER_CYCLE_MS = 3000;
// Fan PWM (0255)
static const uint8_t FAN_IDLE_PWM = 77; // ~30 % — optional override via "fan on"
static const float IDLE_AUTO_FAN_OFF_TEMP_C = 40.0f; // idle: fans off when max corner below this
static const uint8_t FAN_MIX_MIN_PWM = 70; // ~27 % — light mixing when spread rises
static const uint8_t FAN_HEAT_MIN_PWM = 100; // ~39 % — floor while heating
static const uint8_t FAN_HEAT_MAX_PWM = 140; // ~55 % — cap during heat-up
static const uint8_t FAN_MIX_MAX_PWM = 200; // ~78 % — cap for spread-driven mixing
static const uint8_t FAN_MAX_PWM = 255; // failsafe / over-temp only
// Corner mixing — moderate airflow; full speed reserved for safety
static const float SPREAD_DEADBAND_C = 0.5f;
static const float SPREAD_FULL_MIX_C = 8.0f;
static const float SPREAD_EMA_ALPHA = 0.45f;
// PID auto-tune (relay method) — run with: autotune 45
static const float AUTOTUNE_HYSTERESIS_C = 0.4f;
static const float AUTOTUNE_PREHEAT_BAND_C = 5.0f;
static const float AUTOTUNE_PREHEAT_DUTY = 80.0f;
static const float AUTOTUNE_ABORT_ABOVE_C = 15.0f;
static const uint8_t AUTOTUNE_CYCLES_REQUIRED = 6;
static const uint32_t AUTOTUNE_TIMEOUT_MS = 1800000UL;
// ---------------------------------------------------------------------------
// Timing
// ---------------------------------------------------------------------------
static const uint32_t SENSOR_READ_INTERVAL_MS = 1000;
static const uint32_t CONTROL_INTERVAL_MS = 500;
static const uint32_t SERIAL_REPORT_INTERVAL_MS = 2000;
static const bool LOG_CSV_DEFAULT = false; // enable with serial command: log on

58
include/csv_logger.h Normal file
View File

@@ -0,0 +1,58 @@
#pragma once
#include <Arduino.h>
#include "config.h"
#include "thermal_controller.h"
struct SensorReading {
float temperatureC = NAN;
float humidityPct = NAN;
bool valid = false;
};
void printCsvHeader() {
Serial.println(
F("csv_hdr,ms,target_c,avg_c,min_c,max_c,spread_c,heatlim_pct,heater_pct,fan_pct,"
"cutoff,failsafe,ch2_t,ch2_h,ch3_t,ch3_h,ch4_t,ch4_h,ch5_t,ch5_h"));
}
inline void printCsvField(float value, uint8_t decimals) { Serial.print(value, decimals); }
void printCsvRow(uint32_t nowMs, const ThermalController &thermal, const SensorReading *sensors,
uint8_t sensorCount, float avgTemp, float minTemp, float maxTemp) {
Serial.print(F("csv,"));
Serial.print(nowMs);
Serial.print(',');
Serial.print(thermal.target(), 1);
Serial.print(',');
printCsvField(avgTemp, 2);
Serial.print(',');
printCsvField(minTemp, 2);
Serial.print(',');
printCsvField(maxTemp, 2);
Serial.print(',');
printCsvField(thermal.cornerSpread(), 2);
Serial.print(',');
Serial.print(thermal.heaterAllowance(), 0);
Serial.print(',');
printCsvField(thermal.heaterDutyPercent(), 1);
Serial.print(',');
Serial.print((thermal.fanPwm() * 100) / 255);
Serial.print(',');
Serial.print(thermal.isCutoffActive() ? 1 : 0);
Serial.print(',');
Serial.print(thermal.isFailSafeActive() ? 1 : 0);
for (uint8_t i = 0; i < sensorCount; ++i) {
Serial.print(',');
if (sensors[i].valid) {
printCsvField(sensors[i].temperatureC, 2);
Serial.print(',');
printCsvField(sensors[i].humidityPct, 1);
} else {
Serial.print(',');
}
}
Serial.println();
}

251
include/pid_autotuner.h Normal file
View File

@@ -0,0 +1,251 @@
#pragma once
#include <Arduino.h>
#include "config.h"
class PidAutotuner {
public:
enum class Phase : uint8_t { Idle, Preheat, Relay, Done, Failed };
PidAutotuner()
: phase_(Phase::Idle),
setpointC_(AUTOTUNE_DEFAULT_TEMP_C),
relayHigh_(0.0f),
relayLow_(0.0f),
peakSinceCross_(0.0f),
valleySinceCross_(0.0f),
lastCrossMs_(0),
periodSumMs_(0),
periodCount_(0),
amplitudeSum_(0.0f),
amplitudeCount_(0),
spreadSum_(0.0f),
spreadSamples_(0),
cycleCount_(0),
aboveSetpoint_(false),
phaseStartMs_(0),
resultKp_(PID_KP),
resultKi_(PID_KI),
resultKd_(PID_KD),
resultFanMixMax_(FAN_MIX_MAX_PWM) {}
Phase phase() const { return phase_; }
bool isActive() const { return phase_ == Phase::Preheat || phase_ == Phase::Relay; }
bool start(float setpointC) {
if (setpointC < 25.0f || setpointC > TARGET_MAX_C) {
return false;
}
setpointC_ = setpointC;
relayHigh_ = setpointC + AUTOTUNE_HYSTERESIS_C;
relayLow_ = setpointC - AUTOTUNE_HYSTERESIS_C;
resetMeasurements();
phase_ = Phase::Preheat;
phaseStartMs_ = millis();
Serial.print(F("autotune: preheat to "));
Serial.print(setpointC_, 1);
Serial.println(F("C"));
return true;
}
void abort() {
if (phase_ == Phase::Preheat || phase_ == Phase::Relay) {
Serial.println(F("autotune: cancelled"));
}
phase_ = Phase::Idle;
}
void reset() { phase_ = Phase::Idle; }
float setpoint() const { return setpointC_; }
float resultKp() const { return resultKp_; }
float resultKi() const { return resultKi_; }
float resultKd() const { return resultKd_; }
uint8_t resultFanMixMax() const { return resultFanMixMax_; }
Phase update(float avgTempC, float maxTempC, float spreadC, uint32_t nowMs, float &heaterDutyOut,
uint8_t &fanPwmOut) {
heaterDutyOut = 0.0f;
fanPwmOut = FAN_HEAT_MIN_PWM;
if (phase_ == Phase::Idle || phase_ == Phase::Done || phase_ == Phase::Failed) {
return phase_;
}
if (maxTempC >= setpointC_ + AUTOTUNE_ABORT_ABOVE_C) {
fail(F("autotune: abort — temperature too high"));
return phase_;
}
if (nowMs - phaseStartMs_ > AUTOTUNE_TIMEOUT_MS) {
fail(F("autotune: abort — timeout"));
return phase_;
}
if (phase_ == Phase::Preheat) {
if (avgTempC >= setpointC_ - AUTOTUNE_PREHEAT_BAND_C) {
enterRelay(avgTempC, nowMs);
} else {
heaterDutyOut = AUTOTUNE_PREHEAT_DUTY;
}
return phase_;
}
spreadSum_ += spreadC;
++spreadSamples_;
if (avgTempC > peakSinceCross_) {
peakSinceCross_ = avgTempC;
}
if (avgTempC < valleySinceCross_) {
valleySinceCross_ = avgTempC;
}
bool heatOn = false;
if (avgTempC <= relayLow_) {
heatOn = true;
} else if (avgTempC >= relayHigh_) {
heatOn = false;
} else {
heatOn = !aboveSetpoint_;
}
heaterDutyOut = heatOn ? 100.0f : 0.0f;
const bool nowAbove = avgTempC >= setpointC_;
if (nowAbove != aboveSetpoint_) {
onSetpointCrossing(nowMs);
aboveSetpoint_ = nowAbove;
}
return phase_;
}
private:
void enterRelay(float avgTempC, uint32_t nowMs) {
phase_ = Phase::Relay;
phaseStartMs_ = nowMs;
aboveSetpoint_ = avgTempC >= setpointC_;
peakSinceCross_ = avgTempC;
valleySinceCross_ = avgTempC;
lastCrossMs_ = 0;
Serial.println(F("autotune: relay test started"));
}
void resetMeasurements() {
peakSinceCross_ = 0.0f;
valleySinceCross_ = 0.0f;
lastCrossMs_ = 0;
periodSumMs_ = 0;
periodCount_ = 0;
amplitudeSum_ = 0.0f;
amplitudeCount_ = 0;
spreadSum_ = 0.0f;
spreadSamples_ = 0;
cycleCount_ = 0;
aboveSetpoint_ = false;
}
void onSetpointCrossing(uint32_t nowMs) {
const float amplitude = peakSinceCross_ - valleySinceCross_;
if (amplitude >= 0.3f) {
amplitudeSum_ += amplitude;
++amplitudeCount_;
++cycleCount_;
Serial.print(F("autotune: cycle "));
Serial.print(cycleCount_);
Serial.print(F(" amp="));
Serial.println(amplitude, 2);
}
if (lastCrossMs_ > 0) {
const uint32_t period = nowMs - lastCrossMs_;
if (period > 8000 && period < 900000) {
periodSumMs_ += period;
++periodCount_;
}
}
lastCrossMs_ = nowMs;
peakSinceCross_ = valleySinceCross_;
if (cycleCount_ >= AUTOTUNE_CYCLES_REQUIRED && periodCount_ >= 3 && amplitudeCount_ >= 3) {
finish();
}
}
void finish() {
const float avgPeriodSec =
static_cast<float>(periodSumMs_ / periodCount_) / 1000.0f;
const float avgAmplitude = amplitudeSum_ / static_cast<float>(amplitudeCount_);
if (avgAmplitude < 0.3f || avgPeriodSec < 8.0f) {
fail(F("autotune: failed — oscillation too small"));
return;
}
const float ku = (4.0f * 100.0f) / (PI * avgAmplitude);
resultKp_ = 0.45f * ku;
resultKi_ = resultKp_ / (2.2f * avgPeriodSec);
resultKd_ = resultKp_ * avgPeriodSec / 6.3f;
if (resultKp_ < 0.5f) {
resultKp_ = 0.5f;
}
if (resultKi_ > resultKp_ / 3.0f) {
resultKi_ = resultKp_ / 3.0f;
}
resultFanMixMax_ = FAN_MIX_MAX_PWM;
const float spreadAvg =
spreadSamples_ > 0 ? spreadSum_ / static_cast<float>(spreadSamples_) : GOOD_SPREAD_C;
if (spreadAvg > GOOD_SPREAD_C) {
const float boost = 1.0f + ((spreadAvg - GOOD_SPREAD_C) / 10.0f);
int boosted = static_cast<int>(static_cast<float>(FAN_MIX_MAX_PWM) * boost);
if (boosted > static_cast<int>(FAN_MAX_PWM) - 20) {
boosted = FAN_MAX_PWM - 20;
}
resultFanMixMax_ = static_cast<uint8_t>(boosted);
}
phase_ = Phase::Done;
Serial.println(F("autotune: done"));
Serial.print(F(" Kp="));
Serial.print(resultKp_, 3);
Serial.print(F(" Ki="));
Serial.print(resultKi_, 4);
Serial.print(F(" Kd="));
Serial.print(resultKd_, 3);
Serial.print(F(" fanMixMax="));
Serial.println(resultFanMixMax_);
}
void fail(const __FlashStringHelper *reason) {
Serial.println(reason);
phase_ = Phase::Failed;
}
Phase phase_;
float setpointC_;
float relayHigh_;
float relayLow_;
float peakSinceCross_;
float valleySinceCross_;
uint32_t lastCrossMs_;
uint32_t periodSumMs_;
uint8_t periodCount_;
float amplitudeSum_;
uint8_t amplitudeCount_;
float spreadSum_;
uint16_t spreadSamples_;
uint8_t cycleCount_;
bool aboveSetpoint_;
uint32_t phaseStartMs_;
float resultKp_;
float resultKi_;
float resultKd_;
uint8_t resultFanMixMax_;
};

83
include/pid_controller.h Normal file
View File

@@ -0,0 +1,83 @@
#pragma once
class PidController {
public:
PidController(float kp, float ki, float kd, float outputMin, float outputMax)
: kp_(kp),
ki_(ki),
kd_(kd),
outputMin_(outputMin),
outputMax_(outputMax) {}
void setTunings(float kp, float ki, float kd) {
kp_ = kp;
ki_ = ki;
kd_ = kd;
}
void setSetpoint(float setpoint) { setpoint_ = setpoint; }
void reset() {
integral_ = 0.0f;
prevInput_ = 0.0f;
firstSample_ = true;
}
float compute(float input, uint32_t nowMs) {
if (firstSample_) {
prevInput_ = input;
prevTimeMs_ = nowMs;
firstSample_ = false;
return outputMin_;
}
const float dt = static_cast<float>(nowMs - prevTimeMs_) / 1000.0f;
if (dt <= 0.0f) {
return lastOutput_;
}
const float error = setpoint_ - input;
integral_ += error * dt;
// Anti-windup: clamp integral so output cannot exceed limits
const float integralMax = (outputMax_ - outputMin_) / (ki_ > 0.0f ? ki_ : 1.0f);
if (integral_ > integralMax) {
integral_ = integralMax;
} else if (integral_ < 0.0f) {
integral_ = 0.0f;
}
const float derivative = (input - prevInput_) / dt;
float output = kp_ * error + ki_ * integral_ - kd_ * derivative;
if (output < outputMin_) {
output = outputMin_;
} else if (output > outputMax_) {
output = outputMax_;
}
prevInput_ = input;
prevTimeMs_ = nowMs;
lastOutput_ = output;
return output;
}
float lastOutput() const { return lastOutput_; }
float kp() const { return kp_; }
float ki() const { return ki_; }
float kd() const { return kd_; }
private:
float kp_;
float ki_;
float kd_;
float outputMin_;
float outputMax_;
float setpoint_ = 0.0f;
float integral_ = 0.0f;
float prevInput_ = 0.0f;
float lastOutput_ = 0.0f;
uint32_t prevTimeMs_ = 0;
bool firstSample_ = true;
};

44
include/tca9548a.h Normal file
View File

@@ -0,0 +1,44 @@
#pragma once
#include <Wire.h>
class Tca9548a {
public:
explicit Tca9548a(uint8_t address) : address_(address), activeChannel_(0xFF) {}
bool begin() {
Wire.beginTransmission(address_);
return Wire.endTransmission() == 0;
}
bool selectChannel(uint8_t channel) {
if (channel > 7) {
return false;
}
const uint8_t mask = static_cast<uint8_t>(1u << channel);
if (mask == activeChannel_) {
return true;
}
Wire.beginTransmission(address_);
Wire.write(mask);
if (Wire.endTransmission() != 0) {
return false;
}
activeChannel_ = mask;
return true;
}
void disableAll() {
Wire.beginTransmission(address_);
Wire.write(0x00);
Wire.endTransmission();
activeChannel_ = 0x00;
}
private:
uint8_t address_;
uint8_t activeChannel_;
};

View File

@@ -0,0 +1,536 @@
#pragma once
#include <Arduino.h>
#include "config.h"
#include "pid_autotuner.h"
#include "pid_controller.h"
#include "tuning_store.h"
class ThermalController {
public:
ThermalController()
: pid_(PID_KP, PID_KI, PID_KD, 0.0f, 100.0f),
autotuner_(),
targetTempC_(TARGET_TEMP_C),
heaterDutyPercent_(0.0f),
heaterAllowancePercent_(100.0f),
cornerSpreadC_(0.0f),
lastMaxTempC_(0.0f),
fanPwm_(FAN_MAX_PWM),
fanMixMax_(FAN_MIX_MAX_PWM),
adaptiveEnabled_(false),
fanIdleOverride_(false),
sensorWarmValid_(false),
cutoffActive_(false),
failSafeActive_(true),
heaterCycleStartMs_(0),
lastHeaterUpdateMs_(0),
heaterOn_(false) {}
void begin() {
pinMode(FAN_PIN, OUTPUT);
pinMode(HEATER_PIN, OUTPUT);
digitalWrite(HEATER_PIN, LOW);
pid_.setSetpoint(targetTempC_);
pid_.reset();
heaterCycleStartMs_ = millis();
lastHeaterUpdateMs_ = 0;
failSafeActive_ = true;
fanPwm_ = FAN_MAX_PWM;
cornerSpreadC_ = 0.0f;
lastMaxTempC_ = 0.0f;
sensorWarmValid_ = false;
heaterAllowancePercent_ = 100.0f;
if (isIdle()) {
forceHeaterOff();
}
applyFan();
TuningData stored;
if (tuningLoad(stored)) {
applyTuning(stored);
Serial.println(F("Loaded learned PID from EEPROM"));
printTuning();
}
}
void applyTuning(const TuningData &data) {
pid_.setTunings(data.kp, data.ki, data.kd);
fanMixMax_ = data.fanMixMax;
adaptiveEnabled_ = true;
}
void clearTuning() {
adaptiveEnabled_ = false;
fanMixMax_ = FAN_MIX_MAX_PWM;
pid_.setTunings(PID_KP, PID_KI, PID_KD);
tuningClear();
pid_.reset();
Serial.println(F("PID reset to defaults"));
}
void printTuning() const {
Serial.print(F("PID Kp="));
Serial.print(pidKp(), 3);
Serial.print(F(" Ki="));
Serial.print(pidKi(), 4);
Serial.print(F(" Kd="));
Serial.print(pidKd(), 3);
Serial.print(F(" fanMixMax="));
Serial.print(fanMixMax_);
Serial.print(F(" adaptive="));
Serial.println(adaptiveEnabled_ ? F("yes") : F("no"));
}
float pidKp() const { return pid_.kp(); }
float pidKi() const { return pid_.ki(); }
float pidKd() const { return pid_.kd(); }
bool isAdaptive() const { return adaptiveEnabled_; }
bool startAutotune(float setpointC) {
if (autotuner_.isActive()) {
return false;
}
adaptiveEnabled_ = false;
cutoffActive_ = false;
pid_.reset();
return autotuner_.start(setpointC);
}
void stopAutotune() { autotuner_.abort(); }
bool isAutotuning() const { return autotuner_.isActive(); }
bool commitAutotuneIfDone() {
if (autotuner_.phase() != PidAutotuner::Phase::Done) {
return false;
}
TuningData data;
data.magic = TUNING_MAGIC;
data.kp = autotuner_.resultKp();
data.ki = autotuner_.resultKi();
data.kd = autotuner_.resultKd();
data.fanMixMax = autotuner_.resultFanMixMax();
tuningSave(data);
applyTuning(data);
autotuner_.reset();
Serial.println(F("Saved learned PID to EEPROM"));
return true;
}
void setTarget(float targetC) {
targetTempC_ = targetC;
pid_.setSetpoint(targetC);
pid_.reset();
cutoffActive_ = false;
if (targetC > 0.0f) {
fanIdleOverride_ = false;
} else {
forceHeaterOff();
fanIdleOverride_ = false;
}
applyFan();
}
void noteSensorMax(float maxTempC) {
lastMaxTempC_ = maxTempC;
sensorWarmValid_ = true;
}
bool setFanOff() {
if (!isIdle()) {
return false;
}
fanIdleOverride_ = false;
applyFan();
return true;
}
void setFanIdle() {
if (!isIdle()) {
return;
}
if (!sensorWarmValid_ || lastMaxTempC_ >= IDLE_AUTO_FAN_OFF_TEMP_C) {
return;
}
fanIdleOverride_ = true;
applyFan();
}
bool isFanOff() const {
return isIdle() && sensorWarmValid_ && lastMaxTempC_ < IDLE_AUTO_FAN_OFF_TEMP_C &&
!fanIdleOverride_;
}
bool isIdleCooling() const {
return isIdle() &&
(!sensorWarmValid_ || lastMaxTempC_ >= IDLE_AUTO_FAN_OFF_TEMP_C);
}
float target() const { return targetTempC_; }
bool isIdle() const { return targetTempC_ <= 0.0f; }
float cutoffThreshold() const {
if (isIdle()) {
return INFINITY;
}
return targetTempC_ * (1.0f + OVERTEMP_FRACTION);
}
bool isCutoffActive() const { return cutoffActive_; }
bool isFailSafeActive() const { return failSafeActive_; }
float heaterDutyPercent() const { return heaterDutyPercent_; }
float heaterAllowance() const { return heaterAllowancePercent_; }
float cornerSpread() const { return cornerSpreadC_; }
uint8_t fanPwm() const { return fanPwm_; }
void update(float avgTempC, float maxTempC, float cornerSpreadC, uint32_t nowMs) {
failSafeActive_ = false;
noteSensorMax(maxTempC);
cornerSpreadC_ =
SPREAD_EMA_ALPHA * cornerSpreadC +
(1.0f - SPREAD_EMA_ALPHA) * cornerSpreadC_;
if (autotuner_.isActive()) {
updateAutotune(avgTempC, maxTempC, nowMs);
return;
}
if (isIdle()) {
forceHeaterOff();
cutoffActive_ = false;
pid_.reset();
lastHeaterUpdateMs_ = nowMs;
applyFan();
return;
}
if (adaptiveEnabled_) {
updateAdaptive(avgTempC, maxTempC, nowMs);
} else {
updateLegacy(avgTempC, maxTempC, nowMs);
}
lastHeaterUpdateMs_ = nowMs;
applyHeaterBurst(nowMs);
applyFan();
}
void enterFailSafe() {
failSafeActive_ = true;
cutoffActive_ = false;
forceHeaterOff();
applyFan();
pid_.reset();
autotuner_.abort();
}
void forceHeaterOff() {
heaterDutyPercent_ = 0.0f;
heaterAllowancePercent_ = 0.0f;
heaterOn_ = false;
digitalWrite(HEATER_PIN, LOW);
}
void writeFan(uint8_t pwm) {
fanPwm_ = pwm;
if (pwm == 0) {
// Re-assert output and stop Timer0 PWM on D5 — analogWrite(0) can leave the pin driving
pinMode(FAN_PIN, OUTPUT);
digitalWrite(FAN_PIN, LOW);
} else {
analogWrite(FAN_PIN, pwm);
}
}
private:
void updateAutotune(float avgTempC, float maxTempC, uint32_t nowMs) {
float duty = 0.0f;
uint8_t fan = FAN_HEAT_MIN_PWM;
autotuner_.update(avgTempC, maxTempC, cornerSpreadC_, nowMs, duty, fan);
heaterDutyPercent_ = duty;
heaterAllowancePercent_ = duty;
heaterOn_ = duty >= 50.0f;
digitalWrite(HEATER_PIN, heaterOn_ ? HIGH : LOW);
writeFan(fan);
lastHeaterUpdateMs_ = nowMs;
commitAutotuneIfDone();
}
void updateAdaptive(float avgTempC, float maxTempC, uint32_t nowMs) {
const float cutoff = cutoffThreshold();
if (maxTempC >= cutoff) {
cutoffActive_ = true;
heaterDutyPercent_ = 0.0f;
heaterAllowancePercent_ = 0.0f;
heaterOn_ = false;
pid_.reset();
return;
}
if (cutoffActive_ && maxTempC <= targetTempC_) {
cutoffActive_ = false;
pid_.reset();
}
if (cutoffActive_) {
return;
}
const float maxHeatStopC = maxHeatStopTemp(avgTempC);
if (maxTempC >= maxHeatStopC) {
heaterDutyPercent_ = 0.0f;
heaterAllowancePercent_ = 0.0f;
pid_.reset();
return;
}
float duty = pid_.compute(avgTempC, nowMs);
const float maxDuty = heaterMaxDuty(avgTempC);
if (duty > maxDuty) {
duty = maxDuty;
}
heaterAllowancePercent_ = maxDuty;
heaterDutyPercent_ = duty;
}
void updateLegacy(float avgTempC, float maxTempC, uint32_t nowMs) {
const float cutoff = cutoffThreshold();
if (maxTempC >= cutoff) {
cutoffActive_ = true;
heaterDutyPercent_ = 0.0f;
heaterAllowancePercent_ = 0.0f;
heaterOn_ = false;
pid_.reset();
return;
}
if (cutoffActive_ && maxTempC <= targetTempC_) {
cutoffActive_ = false;
pid_.reset();
}
if (cutoffActive_) {
return;
}
const float maxHeatStopC = maxHeatStopTemp(avgTempC);
if (maxTempC >= maxHeatStopC) {
heaterDutyPercent_ = 0.0f;
heaterAllowancePercent_ = 0.0f;
pid_.reset();
return;
}
const float pidOut = pid_.compute(avgTempC, nowMs);
heaterAllowancePercent_ = heaterAllowancePercent(avgTempC, maxTempC);
float duty = pidOut;
if (duty > heaterAllowancePercent_) {
duty = heaterAllowancePercent_;
}
heaterDutyPercent_ = applyHeaterRamp(duty, avgTempC, nowMs);
}
static float clampPercent(float value) {
if (value < 0.0f) {
return 0.0f;
}
if (value > 100.0f) {
return 100.0f;
}
return value;
}
bool isBalancedChamber() const { return cornerSpreadC_ <= GOOD_SPREAD_C; }
float maxHeatStopTemp(float avgTempC) const {
if (isBalancedChamber() && avgTempC < targetTempC_) {
return targetTempC_ + BALANCED_MAX_ABOVE_TARGET_C;
}
return targetTempC_;
}
float allowanceFromMaxCorner(float maxTempC, float avgTempC) const {
if (isBalancedChamber() && avgTempC < targetTempC_) {
return 100.0f;
}
const float stopAt = maxHeatStopTemp(avgTempC);
if (maxTempC >= stopAt) {
return 0.0f;
}
const float headroom = stopAt - maxTempC;
if (headroom >= MAX_TEMP_HEADROOM_C) {
return 100.0f;
}
return clampPercent((headroom / MAX_TEMP_HEADROOM_C) * 100.0f);
}
float allowanceFromAverage(float avgTempC) const {
if (avgTempC >= targetTempC_) {
return 0.0f;
}
const float below = targetTempC_ - avgTempC;
if (below >= APPROACH_BAND_C) {
return 100.0f;
}
return clampPercent((below / APPROACH_BAND_C) * 100.0f);
}
float heaterMaxDuty(float avgTempC) const {
if (avgTempC >= targetTempC_) {
return HEATER_MAX_DUTY_NEAR;
}
const float below = targetTempC_ - avgTempC;
if (below > HEATER_COLD_BELOW_C) {
return HEATER_MAX_DUTY_COLD;
}
if (below > HEATER_WARM_BELOW_C) {
return HEATER_MAX_DUTY_MID;
}
return HEATER_MAX_DUTY_NEAR;
}
float heaterAllowancePercent(float avgTempC, float maxTempC) const {
const float fromMax = allowanceFromMaxCorner(maxTempC, avgTempC);
const float fromAvg = allowanceFromAverage(avgTempC);
float allowance = fromMax < fromAvg ? fromMax : fromAvg;
const float maxDuty = heaterMaxDuty(avgTempC);
if (allowance > maxDuty) {
allowance = maxDuty;
}
return allowance;
}
float applyHeaterRamp(float requestedDuty, float avgTempC, uint32_t nowMs) {
const float maxDuty = heaterMaxDuty(avgTempC);
if (requestedDuty > maxDuty) {
requestedDuty = maxDuty;
}
if (lastHeaterUpdateMs_ > 0 && requestedDuty > heaterDutyPercent_) {
const float dt = static_cast<float>(nowMs - lastHeaterUpdateMs_) / 1000.0f;
const float maxUp = heaterDutyPercent_ + HEATER_SLEW_UP_PER_S * dt;
if (requestedDuty > maxUp) {
requestedDuty = maxUp;
}
}
return requestedDuty;
}
uint8_t fanPwmForHeaterDemand() const {
if (heaterDutyPercent_ <= 0.0f) {
return 0;
}
const uint8_t span = FAN_HEAT_MAX_PWM - FAN_HEAT_MIN_PWM;
return FAN_HEAT_MIN_PWM +
static_cast<uint8_t>((heaterDutyPercent_ / 100.0f) * static_cast<float>(span));
}
uint8_t fanPwmForCornerSpread() const {
if (cornerSpreadC_ <= SPREAD_DEADBAND_C) {
return 0;
}
float spread = cornerSpreadC_;
if (spread > SPREAD_FULL_MIX_C) {
spread = SPREAD_FULL_MIX_C;
}
const float t =
(spread - SPREAD_DEADBAND_C) / (SPREAD_FULL_MIX_C - SPREAD_DEADBAND_C);
const uint8_t mixMax = fanMixMax_;
const uint8_t mixMin = FAN_MIX_MIN_PWM;
const uint8_t span = mixMax > mixMin ? mixMax - mixMin : 0;
return mixMin + static_cast<uint8_t>(t * static_cast<float>(span));
}
void applyHeaterBurst(uint32_t nowMs) {
if (heaterDutyPercent_ <= 0.0f) {
forceHeaterOff();
return;
}
if (nowMs - heaterCycleStartMs_ >= HEATER_CYCLE_MS) {
heaterCycleStartMs_ = nowMs;
}
const float onFraction = heaterDutyPercent_ / 100.0f;
const uint32_t onTimeMs = static_cast<uint32_t>(HEATER_CYCLE_MS * onFraction);
const bool shouldHeat = (nowMs - heaterCycleStartMs_) < onTimeMs;
if (shouldHeat != heaterOn_) {
heaterOn_ = shouldHeat;
digitalWrite(HEATER_PIN, heaterOn_ ? HIGH : LOW);
}
}
void applyFan() {
if (isIdle()) {
if (!sensorWarmValid_ || lastMaxTempC_ >= IDLE_AUTO_FAN_OFF_TEMP_C) {
writeFan(FAN_MAX_PWM);
} else if (fanIdleOverride_) {
writeFan(FAN_IDLE_PWM);
} else {
writeFan(0);
}
return;
}
if (failSafeActive_ || cutoffActive_) {
writeFan(FAN_MAX_PWM);
return;
}
uint8_t duty = fanPwmForHeaterDemand();
const uint8_t mixFan = fanPwmForCornerSpread();
if (mixFan > duty) {
duty = mixFan;
}
if (lastMaxTempC_ > targetTempC_ && duty < FAN_MAX_PWM) {
duty = FAN_MAX_PWM;
}
writeFan(duty);
}
PidController pid_;
PidAutotuner autotuner_;
float targetTempC_;
float heaterDutyPercent_;
float heaterAllowancePercent_;
float cornerSpreadC_;
float lastMaxTempC_;
uint8_t fanPwm_;
uint8_t fanMixMax_;
bool adaptiveEnabled_;
bool fanIdleOverride_;
bool sensorWarmValid_;
bool cutoffActive_;
bool failSafeActive_;
uint32_t heaterCycleStartMs_;
uint32_t lastHeaterUpdateMs_;
bool heaterOn_;
};

45
include/tuning_store.h Normal file
View File

@@ -0,0 +1,45 @@
#pragma once
#include <Arduino.h>
#include <EEPROM.h>
#include "config.h"
static const uint16_t TUNING_MAGIC = 0xDA7A;
static const int TUNING_EEPROM_ADDR = 0;
struct TuningData {
uint16_t magic = 0;
float kp = PID_KP;
float ki = PID_KI;
float kd = PID_KD;
uint8_t fanMixMax = FAN_MIX_MAX_PWM;
};
inline uint8_t tuningChecksum(const TuningData &data) {
const uint8_t *bytes = reinterpret_cast<const uint8_t *>(&data);
uint8_t sum = 0;
for (uint8_t i = 0; i < sizeof(TuningData) - 1; ++i) {
sum ^= bytes[i];
}
return sum;
}
inline bool tuningLoad(TuningData &out) {
EEPROM.get(TUNING_EEPROM_ADDR, out);
const uint8_t stored = EEPROM.read(TUNING_EEPROM_ADDR + sizeof(TuningData));
if (out.magic != TUNING_MAGIC) {
return false;
}
return tuningChecksum(out) == stored;
}
inline void tuningSave(const TuningData &data) {
EEPROM.put(TUNING_EEPROM_ADDR, data);
EEPROM.write(TUNING_EEPROM_ADDR + sizeof(TuningData), tuningChecksum(data));
}
inline void tuningClear() {
TuningData cleared;
tuningSave(cleared);
}