add fanchars

This commit is contained in:
2026-07-06 22:19:43 +02:00
parent 55cf03c015
commit 8182b9efd2
9 changed files with 296 additions and 439 deletions

View File

@@ -36,7 +36,9 @@ static const float MIX_PI_KP = 40.0f;
static const float MIX_PI_KI = 2.0f;
static const float SPREAD_TARGET_C = 0.5f;
static const float HEAT_UP_BAND_C = 8.0f;
static const uint8_t FAN_COLD_CAP_PWM = 0;
// Mix PI only at/above target; below target use minimum stir only (no mix PI).
// Cap mix fan — high airflow often increases spread / heat loss rather than fixing it.
static const uint8_t FAN_MIX_MAX_PWM = 140;
// Legacy aliases for autotuner relay math only
static const float PID_KP = HEAT_PI_KP;
@@ -56,7 +58,7 @@ static const float MAX_TEMP_HEADROOM_C = 15.0f;
static const uint16_t HEATER_CYCLE_MS = 3000;
// Fan PWM
// Fan PWM — FAN_IDLE_PWM ≈ 30%; off only while chamber avg is below 40°C
static const uint8_t FAN_IDLE_PWM = 77;
static const float IDLE_AUTO_FAN_OFF_TEMP_C = 40.0f;
static const uint8_t FAN_MAX_PWM = 255;
@@ -75,19 +77,22 @@ static const uint32_t AUTOTUNE_RELAY_STALL_MS = 1500000UL;
static const uint32_t AUTOTUNE_SESSION_TIMEOUT_MS = 3600000UL;
static const uint32_t AUTOTUNE_RELAY_PERIOD_MAX_MS = 2400000UL;
// Fan step-response — open-loop heater, fan PWM steps (command: stepresp)
static const float STEPRESP_DEFAULT_TEMP_C = 45.0f;
static const float STEPRESP_DEFAULT_HEATER_PCT = 35.0f;
static const float STEPRESP_MIN_HEATER_PCT = 10.0f;
static const float STEPRESP_MAX_HEATER_PCT = 70.0f;
static const float STEPRESP_PREHEAT_BAND_C = 2.0f;
static const uint32_t STEPRESP_PREHEAT_TIMEOUT_MS = 1200000UL;
static const uint32_t STEPRESP_BASELINE_MS = 120000UL;
static const uint32_t STEPRESP_STEP_HOLD_MS = 300000UL;
static const uint32_t STEPRESP_LOG_INTERVAL_MS = 1000UL;
static const uint8_t STEPRESP_FAN_STEPS[] = {0, 77, 140, 200, 255};
static const uint8_t STEPRESP_FAN_STEP_COUNT =
sizeof(STEPRESP_FAN_STEPS) / sizeof(STEPRESP_FAN_STEPS[0]);
// Fan characterize — fixed heater, sweep fan PWMs, pick lowest spread
static const float FANCHARS_MAX_CORNER_C = 60.0f;
static const float FANCHARS_COOL_AVG_C = 40.0f;
static const float FANCHARS_PRECOOL_MARGIN_C = 2.0f;
static const float FANCHARS_HEATER_PCT = 85.0f;
// Coarse sweep order: 30%, 100%, 60%, 80% fan
static const uint8_t FANCHARS_COARSE_PWM[] = {77, 255, 153, 204};
static const uint8_t FANCHARS_COARSE_COUNT =
sizeof(FANCHARS_COARSE_PWM) / sizeof(FANCHARS_COARSE_PWM[0]);
static const uint8_t FANCHARS_LIMIT_LOW_PWM = 77;
static const uint8_t FANCHARS_LIMIT_HIGH_PWM = 255;
static const uint8_t FANCHARS_MAX_RESULTS = FANCHARS_COARSE_COUNT + 1;
static const uint32_t FANCHARS_HOLD_MS = 60000UL;
static const uint32_t FANCHARS_HEAT_TIMEOUT_MS = 2700000UL;
static const uint32_t FANCHARS_COOLDOWN_TIMEOUT_MS = 2700000UL;
static const uint32_t FANCHARS_LOG_INTERVAL_MS = 1000UL;
// ---------------------------------------------------------------------------
// Timing

View File

@@ -1,268 +0,0 @@
#pragma once
#include <Arduino.h>
#include "config.h"
class FanStepResponse {
public:
enum class Phase : uint8_t { Idle, Preheat, Baseline, StepHold, Done, Failed };
FanStepResponse()
: phase_(Phase::Idle),
targetC_(STEPRESP_DEFAULT_TEMP_C),
heaterPct_(STEPRESP_DEFAULT_HEATER_PCT),
stepIndex_(0),
sessionStartMs_(0),
phaseStartMs_(0),
lastLogMs_(0),
lastAvgC_(0.0f) {}
Phase phase() const { return phase_; }
bool isActive() const {
return phase_ == Phase::Preheat || phase_ == Phase::Baseline || phase_ == Phase::StepHold;
}
uint32_t elapsedMs(uint32_t nowMs) const {
if (sessionStartMs_ == 0) {
return 0;
}
return nowMs - sessionStartMs_;
}
uint8_t stepIndex() const { return stepIndex_; }
uint8_t stepCount() const { return STEPRESP_FAN_STEP_COUNT; }
float targetC() const { return targetC_; }
float heaterPct() const { return heaterPct_; }
uint8_t currentFanPwm() const {
if (stepIndex_ >= STEPRESP_FAN_STEP_COUNT) {
return 0;
}
return STEPRESP_FAN_STEPS[stepIndex_];
}
const char *phaseName() const {
switch (phase_) {
case Phase::Preheat:
return "preheat";
case Phase::Baseline:
return "baseline";
case Phase::StepHold:
return "step";
default:
return "";
}
}
bool start(float targetC, float heaterPct) {
if (targetC < 25.0f || targetC > TARGET_MAX_C) {
return false;
}
if (heaterPct < STEPRESP_MIN_HEATER_PCT || heaterPct > STEPRESP_MAX_HEATER_PCT) {
return false;
}
targetC_ = targetC;
heaterPct_ = heaterPct;
stepIndex_ = 0;
sessionStartMs_ = millis();
phaseStartMs_ = sessionStartMs_;
lastLogMs_ = 0;
lastAvgC_ = 0.0f;
phase_ = Phase::Preheat;
Serial.print(F("stepresp: preheat to "));
Serial.print(targetC_ - STEPRESP_PREHEAT_BAND_C, 1);
Serial.print(F("-"));
Serial.print(targetC_, 1);
Serial.print(F("C avg, heater="));
Serial.print(heaterPct_, 0);
Serial.println(F("% fan=0"));
return true;
}
void abort() {
if (isActive()) {
Serial.println(F("stepresp: cancelled"));
}
phase_ = Phase::Idle;
sessionStartMs_ = 0;
}
void reset() {
phase_ = Phase::Idle;
sessionStartMs_ = 0;
}
bool update(float avgTempC, float maxTempC, float spreadC, uint32_t nowMs, float &heaterDutyOut,
uint8_t &fanPwmOut) {
heaterDutyOut = 0.0f;
fanPwmOut = 0;
if (phase_ == Phase::Idle || phase_ == Phase::Done || phase_ == Phase::Failed) {
return false;
}
if (maxTempC >= EMERGENCY_MAX_TEMP_C) {
fail(F("stepresp: abort — max sensor at safety limit"));
return false;
}
if (phase_ == Phase::Preheat) {
fanPwmOut = 0;
if (nowMs - phaseStartMs_ > STEPRESP_PREHEAT_TIMEOUT_MS) {
fail(F("stepresp: abort — preheat timeout"));
return false;
}
if (avgTempC >= targetC_ - STEPRESP_PREHEAT_BAND_C) {
enterBaseline(nowMs);
} else {
heaterDutyOut = heaterPct_;
}
return true;
}
heaterDutyOut = heaterPct_;
fanPwmOut = currentFanPwm();
if (phase_ == Phase::Baseline) {
if (nowMs - phaseStartMs_ >= STEPRESP_BASELINE_MS) {
advanceStep(nowMs);
}
return true;
}
if (phase_ == Phase::StepHold) {
if (nowMs - phaseStartMs_ >= STEPRESP_STEP_HOLD_MS) {
if (stepIndex_ + 1 >= STEPRESP_FAN_STEP_COUNT) {
finish(nowMs, avgTempC, spreadC);
} else {
++stepIndex_;
enterStepHold(nowMs, true);
}
}
return true;
}
return false;
}
void logIfDue(const float *sensorTemps, const bool *sensorValid, uint8_t sensorCount,
float avgTempC, float minTempC, float maxTempC, float spreadC, uint32_t nowMs) {
if (!isActive()) {
return;
}
if (lastLogMs_ != 0 && nowMs - lastLogMs_ < STEPRESP_LOG_INTERVAL_MS) {
return;
}
lastLogMs_ = nowMs;
lastAvgC_ = avgTempC;
Serial.print(F("sr,"));
Serial.print(nowMs);
Serial.print(',');
Serial.print(phaseName());
Serial.print(',');
Serial.print(stepIndex_);
Serial.print('/');
Serial.print(STEPRESP_FAN_STEP_COUNT);
Serial.print(',');
Serial.print(heaterPct_, 0);
Serial.print(',');
Serial.print(currentFanPwm());
Serial.print(',');
Serial.print(avgTempC, 2);
Serial.print(',');
Serial.print(minTempC, 2);
Serial.print(',');
Serial.print(maxTempC, 2);
Serial.print(',');
Serial.print(spreadC, 2);
for (uint8_t i = 0; i < sensorCount; ++i) {
Serial.print(',');
if (sensorValid[i]) {
Serial.print(sensorTemps[i], 2);
}
}
Serial.println();
}
private:
void enterBaseline(uint32_t nowMs) {
phase_ = Phase::Baseline;
phaseStartMs_ = nowMs;
stepIndex_ = 0;
Serial.print(F("stepresp: baseline fan="));
Serial.print(currentFanPwm());
Serial.print(F(" for "));
Serial.print(STEPRESP_BASELINE_MS / 1000UL);
Serial.println(F("s"));
}
void enterStepHold(uint32_t nowMs, bool isStep) {
phase_ = Phase::StepHold;
phaseStartMs_ = nowMs;
Serial.print(F("stepresp: "));
if (isStep) {
Serial.print(F("step "));
}
Serial.print(stepIndex_ + 1);
Serial.print(F("/"));
Serial.print(STEPRESP_FAN_STEP_COUNT);
Serial.print(F(" fan="));
Serial.print(currentFanPwm());
Serial.print(F(" ("));
Serial.print((currentFanPwm() * 100) / 255);
Serial.print(F("%) hold "));
Serial.print(STEPRESP_STEP_HOLD_MS / 1000UL);
Serial.println(F("s"));
}
void advanceStep(uint32_t nowMs) {
if (STEPRESP_FAN_STEP_COUNT <= 1) {
finish(nowMs, lastAvgC_, 0.0f);
return;
}
stepIndex_ = 1;
enterStepHold(nowMs, true);
}
void finish(uint32_t nowMs, float avgTempC, float spreadC) {
phase_ = Phase::Done;
Serial.print(F("stepresp: done in "));
Serial.print((nowMs - sessionStartMs_) / 1000UL);
Serial.println(F("s"));
Serial.print(F(" target="));
Serial.print(targetC_, 1);
Serial.print(F("C heater="));
Serial.print(heaterPct_, 0);
Serial.print(F("% final avg="));
Serial.print(avgTempC, 1);
Serial.print(F("C spread="));
Serial.print(spreadC, 1);
Serial.println(F("C"));
Serial.println(F(" parse sr,... lines for step response (fan PWM vs temp)"));
}
void fail(const __FlashStringHelper *reason) {
Serial.println(reason);
phase_ = Phase::Failed;
sessionStartMs_ = 0;
}
Phase phase_;
float targetC_;
float heaterPct_;
uint8_t stepIndex_;
uint32_t sessionStartMs_;
uint32_t phaseStartMs_;
uint32_t lastLogMs_;
float lastAvgC_;
};

View File

@@ -6,12 +6,13 @@
#include "config.h"
// After TuningData (15 bytes) + checksum (1 byte) at address 0
static const uint16_t SETTINGS_MAGIC = 0xDA7E;
static const uint16_t SETTINGS_MAGIC = 0xDA7F;
static const int SETTINGS_EEPROM_ADDR = 16;
struct SettingsData {
uint16_t magic = 0;
float targetC = TARGET_TEMP_C;
uint8_t stirFanPwm = 0; // 0 = use FAN_IDLE_PWM (~30%)
};
inline uint8_t settingsChecksum(const SettingsData &data) {
@@ -39,8 +40,24 @@ inline void settingsSave(const SettingsData &data) {
inline void settingsSaveTarget(float targetC) {
SettingsData data;
data.magic = SETTINGS_MAGIC;
data.targetC = targetC;
if (settingsLoad(data)) {
data.targetC = targetC;
} else {
data.magic = SETTINGS_MAGIC;
data.targetC = targetC;
data.stirFanPwm = 0;
}
settingsSave(data);
}
inline void settingsSaveStirFan(uint8_t stirFanPwm) {
SettingsData data;
if (settingsLoad(data)) {
data.stirFanPwm = stirFanPwm;
} else {
data.magic = SETTINGS_MAGIC;
data.stirFanPwm = stirFanPwm;
}
settingsSave(data);
}

View File

@@ -3,7 +3,7 @@
#include <Arduino.h>
#include "config.h"
#include "fan_step_response.h"
#include "fan_characterize.h"
#include "pid_autotuner.h"
#include "pid_controller.h"
#include "settings_store.h"
@@ -11,19 +11,20 @@
class ThermalController {
public:
enum class HeaterBlock : uint8_t { None, Cutoff, Corner, Autotune, StepResp };
enum class HeaterBlock : uint8_t { None, Cutoff, Corner, Autotune, FanChars };
ThermalController()
: heatPi_(HEAT_PI_KP, HEAT_PI_KI, 0.0f, 0.0f, 100.0f),
mixPi_(MIX_PI_KP, MIX_PI_KI, 0.0f, 0.0f, 255.0f),
autotuner_(),
stepresp_(),
fanchars_(),
targetTempC_(TARGET_TEMP_C),
heaterDutyPercent_(0.0f),
heaterAllowancePercent_(100.0f),
cornerSpreadC_(0.0f),
lastMaxTempC_(0.0f),
regulatingFanPwm_(0),
stirFanPwm_(FAN_IDLE_PWM),
fanPwm_(0),
tuningLoaded_(false),
fanIdleOverride_(false),
@@ -70,9 +71,13 @@ public:
}
SettingsData settings;
if (settingsLoad(settings) && settings.targetC >= TARGET_MIN_C &&
settings.targetC <= TARGET_MAX_C) {
setTarget(settings.targetC, false);
if (settingsLoad(settings)) {
if (settings.stirFanPwm > 0) {
stirFanPwm_ = settings.stirFanPwm;
}
if (settings.targetC >= TARGET_MIN_C && settings.targetC <= TARGET_MAX_C) {
setTarget(settings.targetC, false);
}
}
}
@@ -140,7 +145,7 @@ public:
bool isTuningLoaded() const { return tuningLoaded_; }
bool startAutotune(float setpointC) {
if (autotuner_.isActive() || stepresp_.isActive()) {
if (autotuner_.isActive() || fanchars_.isActive()) {
return false;
}
cutoffActive_ = false;
@@ -163,46 +168,65 @@ public:
float autotunePreheatTargetC() const { return autotuner_.preheatTargetC(); }
bool startStepResponse(float targetC, float heaterPct) {
if (autotuner_.isActive() || stepresp_.isActive()) {
bool startFanCharacterize(float maxCornerC, float avgTempC) {
if (autotuner_.isActive() || fanchars_.isActive()) {
return false;
}
stopFanTest();
cutoffActive_ = false;
heatPi_.reset();
mixPi_.reset();
setTarget(targetC, false);
if (!stepresp_.start(targetC, heaterPct)) {
setTarget(0.0f, false);
return fanchars_.start(maxCornerC, avgTempC);
}
void stopFanCharacterize() {
fanchars_.abort();
forceHeaterOff();
writeFan(0);
}
bool isFanCharacterizeActive() const { return fanchars_.isActive(); }
uint32_t fanCharacterizeElapsedMs(uint32_t nowMs) const { return fanchars_.elapsedMs(nowMs); }
const char *fanCharacterizePhaseName() const { return fanchars_.phaseName(); }
uint8_t fanCharacterizeProfileIndex() const { return fanchars_.profileIndex(); }
uint8_t fanCharacterizeProfileCount() const { return fanchars_.profileCount(); }
uint8_t fanCharacterizeFanPwm() const { return fanchars_.currentFanPwm(); }
bool isFanCharacterizeRefineRun() const { return fanchars_.isRefineRun(); }
float fanCharacterizeHeaterPct() const { return fanchars_.heaterPct(); }
uint8_t stirFanPwm() const { return stirFanPwm_; }
void logFanCharacterizeIfDue(const float *sensorTemps, const bool *sensorValid, uint8_t sensorCount,
float avgTempC, float minTempC, float maxTempC, float spreadC,
uint32_t nowMs) {
fanchars_.logIfDue(sensorTemps, sensorValid, sensorCount, avgTempC, minTempC, maxTempC,
spreadC, nowMs);
}
bool saveStirFanFromCharacterize() {
if (fanchars_.phase() != FanCharacterize::Phase::Done) {
return false;
}
writeFan(0);
const uint8_t winner = fanchars_.winnerFanPwm();
if (winner == 0) {
return false;
}
stirFanPwm_ = winner;
settingsSaveStirFan(winner);
Serial.print(F("stir fan "));
Serial.println(winner);
fanchars_.reset();
return true;
}
void stopStepResponse() {
stepresp_.abort();
writeFan(0);
}
bool isStepResponseActive() const { return stepresp_.isActive(); }
uint32_t stepResponseElapsedMs(uint32_t nowMs) const { return stepresp_.elapsedMs(nowMs); }
const char *stepResponsePhaseName() const { return stepresp_.phaseName(); }
uint8_t stepResponseStepIndex() const { return stepresp_.stepIndex(); }
uint8_t stepResponseStepCount() const { return stepresp_.stepCount(); }
float stepResponseHeaterPct() const { return stepresp_.heaterPct(); }
void logStepResponseIfDue(const float *sensorTemps, const bool *sensorValid, uint8_t sensorCount,
float avgTempC, float minTempC, float maxTempC, float spreadC,
uint32_t nowMs) {
stepresp_.logIfDue(sensorTemps, sensorValid, sensorCount, avgTempC, minTempC, maxTempC,
spreadC, nowMs);
}
bool commitAutotuneIfDone() {
if (autotuner_.phase() != PidAutotuner::Phase::Done) {
return false;
@@ -331,8 +355,8 @@ public:
return "corner";
case HeaterBlock::Autotune:
return "autotune";
case HeaterBlock::StepResp:
return "stepresp";
case HeaterBlock::FanChars:
return "fanchars";
default:
return "none";
}
@@ -352,8 +376,8 @@ public:
SPREAD_EMA_ALPHA * cornerSpreadC_ +
(1.0f - SPREAD_EMA_ALPHA) * cornerSpreadC;
if (stepresp_.isActive()) {
updateStepResponse(avgTempC, maxTempC, nowMs);
if (fanchars_.isActive()) {
updateFanCharacterize(avgTempC, maxTempC, nowMs);
return;
}
@@ -386,7 +410,7 @@ public:
heatPi_.reset();
mixPi_.reset();
autotuner_.abort();
stepresp_.abort();
fanchars_.abort();
}
void forceHeaterOff() {
@@ -425,22 +449,17 @@ public:
}
private:
void updateStepResponse(float avgTempC, float maxTempC, uint32_t nowMs) {
void updateFanCharacterize(float avgTempC, float maxTempC, uint32_t nowMs) {
float duty = 0.0f;
uint8_t fan = 0;
stepresp_.update(avgTempC, maxTempC, cornerSpreadC_, nowMs, duty, fan);
fanchars_.update(avgTempC, maxTempC, cornerSpreadC_, nowMs, duty, fan);
heaterDutyPercent_ = duty;
heaterAllowancePercent_ = duty;
heaterBlock_ = duty > 0.0f ? HeaterBlock::StepResp : HeaterBlock::None;
heaterBlock_ = duty > 0.0f ? HeaterBlock::FanChars : HeaterBlock::None;
applyHeaterBurst(nowMs);
writeFan(fan);
lastHeaterUpdateMs_ = nowMs;
if (stepresp_.phase() == FanStepResponse::Phase::Done ||
stepresp_.phase() == FanStepResponse::Phase::Failed) {
stepresp_.reset();
}
}
void updateAutotune(float avgTempC, float maxTempC, uint32_t nowMs) {
@@ -504,13 +523,28 @@ private:
}
heaterDutyPercent_ = applyHeaterRamp(duty, avgTempC, nowMs);
const float mixInput = SPREAD_TARGET_C - cornerSpreadC_;
float fanOut = mixPi_.compute(mixInput, nowMs);
uint8_t fanPwm = static_cast<uint8_t>(fanOut + 0.5f);
if (avgTempC < targetTempC_ - HEAT_UP_BAND_C && fanPwm > FAN_COLD_CAP_PWM) {
fanPwm = FAN_COLD_CAP_PWM;
uint8_t fanPwm = stirFanPwm_;
if (avgTempC < targetTempC_) {
mixPi_.reset();
} else {
const float mixInput = SPREAD_TARGET_C - cornerSpreadC_;
float fanOut = mixPi_.compute(mixInput, nowMs);
fanPwm = static_cast<uint8_t>(fanOut + 0.5f);
if (fanPwm > FAN_MIX_MAX_PWM) {
fanPwm = FAN_MIX_MAX_PWM;
}
}
regulatingFanPwm_ = fanPwm;
regulatingFanPwm_ = fanWithMinStir(avgTempC, fanPwm);
}
uint8_t fanWithMinStir(float avgTempC, uint8_t pwm) const {
if (avgTempC < IDLE_AUTO_FAN_OFF_TEMP_C) {
return 0;
}
if (pwm < stirFanPwm_) {
return stirFanPwm_;
}
return pwm;
}
static float clampPercent(float value) {
@@ -633,13 +667,14 @@ private:
PidController heatPi_;
PidController mixPi_;
PidAutotuner autotuner_;
FanStepResponse stepresp_;
FanCharacterize fanchars_;
float targetTempC_;
float heaterDutyPercent_;
float heaterAllowancePercent_;
float cornerSpreadC_;
float lastMaxTempC_;
uint8_t regulatingFanPwm_;
uint8_t stirFanPwm_;
uint8_t fanPwm_;
bool tuningLoaded_;
bool fanIdleOverride_;