/*
* ============================================================
* ALUMINIUM FURNACE AIR-FUEL RATIO (AFR) CONTROL SYSTEM
* Platform : ESP32 (38-pin or 30-pin DevKit v1)
* IDE : Arduino IDE 2.x (ESP32 board package 2.0+)
* Author : Generated Control System
* Version : 2.0
* ============================================================
*
* SYSTEM OVERVIEW
* ---------------
* This firmware implements closed-loop Air-Fuel Ratio (AFR)
* control for an aluminium melting furnace using:
* - O2 / Lambda (wideband) sensor feedback
* - Thermocouple temperature monitoring (K-type + MAX6675)
* - Two proportional servo/actuator outputs (air valve, fuel valve)
* - PID control algorithm
* - 16x2 LCD status display
* - UART serial logging at 115200 baud
* - Safety interlocks (over-temp, flame-out, sensor fault)
*
* TARGET AFR FOR ALUMINIUM MELTING
* ---------------------------------
* Stoichiometric AFR for natural gas ~ 15.7 : 1 (lambda = 1.0)
* Optimal for Al melting: slightly oxidising (lambda = 1.05–1.10)
* to prevent hydrogen pick-up and reduce dross formation.
*
* ============================================================
*/
// ─────────────────────────────────────────────
// LIBRARIES
// ─────────────────────────────────────────────
#include <Arduino.h>
#include <Wire.h>
#include <LiquidCrystal_I2C.h> // I2C LCD (PCF8574 backpack)
#include <max6675.h> // MAX6675 thermocouple library
#include <ESP32Servo.h> // ESP32-compatible servo library
// ─────────────────────────────────────────────
// PIN DEFINITIONS
// ─────────────────────────────────────────────
// --- MAX6675 Thermocouple (SPI bit-bang) ---
#define TC_CLK_PIN 18 // SCK
#define TC_CS_PIN 5 // Chip Select
#define TC_DO_PIN 19 // MISO / data out
// --- O2 / Lambda Sensor (wideband analog) ---
// Uses 0-5 V output from Bosch LSU 4.9 controller board
// ESP32 ADC is 0-3.3 V, so voltage divider (3.3k / 6.8k) scales 5V→3.3V
#define O2_SENSOR_PIN 34 // ADC1 CH6 (input-only GPIO)
// --- Servo/Actuator Outputs ---
#define AIR_SERVO_PIN 25 // Air butterfly valve servo
#define FUEL_SERVO_PIN 26 // Fuel control valve servo
// --- Flame Detection (UV flame sensor / thermocouple proxy) ---
#define FLAME_SENSOR_PIN 35 // Digital HIGH = flame present
// --- Status LED ---
#define LED_ALARM_PIN 2 // Built-in LED / alarm indicator
// --- Manual Override Potentiometers (optional operator inputs) ---
#define POT_AFR_SETPOINT_PIN 32 // ADC1 CH4 – target lambda setpoint trim
#define POT_MANUAL_AIR_PIN 33 // ADC1 CH5 – manual air valve override
// ─────────────────────────────────────────────
// CONSTANTS & TUNING PARAMETERS
// ─────────────────────────────────────────────
// --- PID Gains (tune on-site with Ziegler-Nichols method) ---
const float KP = 12.0f; // Proportional gain
const float KI = 0.8f; // Integral gain
const float KD = 1.5f; // Derivative gain
// --- Setpoints ---
const float LAMBDA_SETPOINT = 1.07f; // Target lambda (slightly lean/oxidising)
const float TEMP_TARGET_C = 760.0f; // Aluminium melt target temperature (°C)
const float TEMP_MAX_C = 900.0f; // Absolute over-temperature limit (°C)
const float TEMP_MIN_FIRE_C = 200.0f; // Minimum temp to consider burner lit
// --- Sensor Calibration (adjust per actual sensor board) ---
// Wideband O2 controller: 0.5 V = lambda 0.68, 4.5 V = lambda 1.36
// After 3.3/5 V divider the ADC sees 0.33 V – 2.97 V
const float O2_V_AT_LAMBDA_STOICH = 1.65f; // Volts at ADC pin for lambda=1.0
const float O2_V_SPAN = 2.64f; // Volt span for full lambda range
const float LAMBDA_AT_V_MIN = 0.68f;
const float LAMBDA_AT_V_MAX = 1.36f;
// --- Servo ranges (microseconds) ---
const int AIR_SERVO_MIN_US = 700; // Fully closed
const int AIR_SERVO_MAX_US = 2300; // Fully open
const int FUEL_SERVO_MIN_US = 700;
const int FUEL_SERVO_MAX_US = 2300;
// --- Servo initial / safe positions ---
const int AIR_SERVO_IDLE = 1050; // ~25% open at idle
const int FUEL_SERVO_IDLE = 950; // ~20% open at idle
// --- PID output clamp ---
const float PID_OUT_MIN = -100.0f;
const float PID_OUT_MAX = 100.0f;
// --- Loop timing ---
const unsigned long CONTROL_LOOP_MS = 200; // 5 Hz control loop
const unsigned long DISPLAY_UPDATE_MS = 500; // LCD refresh rate
const unsigned long SERIAL_LOG_MS = 1000; // Serial log interval
// ─────────────────────────────────────────────
// GLOBAL OBJECTS
// ─────────────────────────────────────────────
MAX6675 thermocouple(TC_CLK_PIN, TC_CS_PIN, TC_DO_PIN);
LiquidCrystal_I2C lcd(0x27, 16, 2); // I2C address 0x27, 16 cols, 2 rows
Servo airServo;
Servo fuelServo;
// ─────────────────────────────────────────────
// STATE VARIABLES
// ─────────────────────────────────────────────
float g_lambdaMeasured = 1.0f;
float g_tempC = 20.0f;
float g_pidOutput = 0.0f;
// PID internals
float g_pidError = 0.0f;
float g_pidIntegral = 0.0f;
float g_pidPrevError = 0.0f;
unsigned long g_lastPidTime = 0;
// Servo positions (µs)
int g_airServoPulse = AIR_SERVO_IDLE;
int g_fuelServoPulse = FUEL_SERVO_IDLE;
// System state flags
bool g_flamePresent = false;
bool g_overTemp = false;
bool g_sensorFault = false;
bool g_systemRunning = false;
bool g_manualOverride = false; // set by serial command 'M'
// Timing
unsigned long g_lastControlTime = 0;
unsigned long g_lastDisplayTime = 0;
unsigned long g_lastLogTime = 0;
// ─────────────────────────────────────────────
// FUNCTION PROTOTYPES
// ─────────────────────────────────────────────
float readLambda();
float readTemperature();
bool readFlame();
float computePID(float setpoint, float measured, float dt);
void applyControl(float pidOut);
void updateDisplay();
void logSerial();
void checkSafety();
void handleSerialCommands();
void emergencyShutdown(const char* reason);
int lambdaToAirPulse(float lambda);
int lambdaToFuelPulse(float lambda, float pidOut);
// ═══════════════════════════════════════════════════════════
// SETUP
// ═══════════════════════════════════════════════════════════
void setup() {
Serial.begin(115200);
Serial.println(F("\n===================================="));
Serial.println(F(" Al-Furnace AFR Controller v2.0"));
Serial.println(F("===================================="));
// --- GPIO ---
pinMode(FLAME_SENSOR_PIN, INPUT);
pinMode(LED_ALARM_PIN, OUTPUT);
digitalWrite(LED_ALARM_PIN, LOW);
// --- I2C LCD ---
Wire.begin(21, 22); // SDA=21, SCL=22
lcd.init();
lcd.backlight();
lcd.setCursor(0, 0);
lcd.print(F("Al-Furnace AFR"));
lcd.setCursor(0, 1);
lcd.print(F("Initialising..."));
delay(1500);
// --- Servos ---
ESP32PWM::allocateTimer(0);
ESP32PWM::allocateTimer(1);
airServo.setPeriodHertz(50);
fuelServo.setPeriodHertz(50);
airServo.attach(AIR_SERVO_PIN, AIR_SERVO_MIN_US, AIR_SERVO_MAX_US);
fuelServo.attach(FUEL_SERVO_PIN, FUEL_SERVO_MIN_US, FUEL_SERVO_MAX_US);
// Move to safe/idle positions
airServo.writeMicroseconds(AIR_SERVO_IDLE);
fuelServo.writeMicroseconds(FUEL_SERVO_IDLE);
delay(500);
// --- ADC ---
analogReadResolution(12); // 12-bit ADC (0–4095)
analogSetAttenuation(ADC_11db); // Full-scale ~3.3 V
// --- Timing init ---
g_lastPidTime = millis();
g_lastControlTime = millis();
g_lastDisplayTime = millis();
g_lastLogTime = millis();
// --- Warm-up read ---
delay(500); // MAX6675 needs 250 ms after power-up
g_tempC = readTemperature();
g_lambdaMeasured = readLambda();
g_systemRunning = true;
lcd.clear();
lcd.setCursor(0, 0);
lcd.print(F("System Ready"));
lcd.setCursor(0, 1);
lcd.print(F("Awaiting ignite"));
Serial.println(F("System initialised. Type H for command help."));
}
// ═══════════════════════════════════════════════════════════
// MAIN LOOP
// ═══════════════════════════════════════════════════════════
void loop() {
unsigned long now = millis();
// ── Serial command handler (non-blocking) ──
handleSerialCommands();
// ── Control loop @ 5 Hz ──
if (now - g_lastControlTime >= CONTROL_LOOP_MS) {
g_lastControlTime = now;
float dt = (float)(now - g_lastPidTime) / 1000.0f;
g_lastPidTime = now;
// 1. Read sensors
g_tempC = readTemperature();
g_lambdaMeasured = readLambda();
g_flamePresent = readFlame();
// 2. Safety checks
checkSafety();
// 3. PID & output (only when running and not in fault)
if (g_systemRunning && !g_overTemp && !g_sensorFault) {
if (!g_manualOverride) {
g_pidOutput = computePID(LAMBDA_SETPOINT, g_lambdaMeasured, dt);
applyControl(g_pidOutput);
}
// else: manual override – servos set directly by serial command
}
}
// ── LCD update @ 2 Hz ──
if (now - g_lastDisplayTime >= DISPLAY_UPDATE_MS) {
g_lastDisplayTime = now;
updateDisplay();
}
// ── Serial log @ 1 Hz ──
if (now - g_lastLogTime >= SERIAL_LOG_MS) {
g_lastLogTime = now;
logSerial();
}
}
// ═══════════════════════════════════════════════════════════
// SENSOR READING
// ═══════════════════════════════════════════════════════════
/**
* Read O2 / Lambda from wideband sensor controller analog output.
* Returns lambda value (0.68 – 1.36 typical range).
*/
float readLambda() {
// Average 8 ADC samples to reduce noise
long sum = 0;
for (int i = 0; i < 8; i++) {
sum += analogRead(O2_SENSOR_PIN);
delayMicroseconds(200);
}
float adcRaw = (float)(sum / 8);
float voltage = adcRaw * (3.3f / 4095.0f); // Convert to volts
// Fault detection: sensor wire-off or controller off
if (voltage < 0.15f || voltage > 3.1f) {
g_sensorFault = true;
return 1.0f; // Return stoichiometric as safe default
}
g_sensorFault = false;
// Linear interpolation to lambda
float lambda = LAMBDA_AT_V_MIN + (voltage / 3.3f) * (LAMBDA_AT_V_MAX - LAMBDA_AT_V_MIN);
return constrain(lambda, 0.5f, 2.0f);
}
/**
* Read furnace temperature from MAX6675 (K-type thermocouple).
* Returns degrees Celsius.
*/
float readTemperature() {
float t = thermocouple.readCelsius();
if (isnan(t) || t < -10.0f || t > 1350.0f) {
Serial.println(F("[WARN] Thermocouple read error"));
return g_tempC; // Return last valid reading
}
return t;
}
/**
* Check flame sensor digital input.
* Returns true if flame/pilot is detected.
*/
bool readFlame() {
return (digitalRead(FLAME_SENSOR_PIN) == HIGH);
}
// ═══════════════════════════════════════════════════════════
// PID CONTROLLER
// ═══════════════════════════════════════════════════════════
/**
* Standard PID with anti-windup clamping.
* Returns output in range [-100, +100]:
* Positive → mixture too rich (increase air / decrease fuel)
* Negative → mixture too lean (decrease air / increase fuel)
*/
float computePID(float setpoint, float measured, float dt) {
if (dt <= 0.0f) dt = 0.001f;
g_pidError = setpoint - measured;
// Integral with anti-windup
g_pidIntegral += g_pidError * dt;
g_pidIntegral = constrain(g_pidIntegral, PID_OUT_MIN / KI, PID_OUT_MAX / KI);
float derivative = (g_pidError - g_pidPrevError) / dt;
g_pidPrevError = g_pidError;
float output = KP * g_pidError
+ KI * g_pidIntegral
+ KD * derivative;
return constrain(output, PID_OUT_MIN, PID_OUT_MAX);
}
// ═══════════════════════════════════════════════════════════
// ACTUATOR CONTROL
// ═══════════════════════════════════════════════════════════
/**
* Translate PID output to air and fuel valve servo positions.
*
* Strategy:
* - When lambda > setpoint (too lean / too much air): reduce air
* - When lambda < setpoint (too rich / too much fuel): increase air OR reduce fuel
*
* The fuel servo is driven as the inverse complement so total heat
* input stays approximately constant while ratio is corrected.
*/
void applyControl(float pidOut) {
// Map PID output (-100 to +100) onto servo correction
// Positive pidOut → lambda too lean → reduce air (or increase fuel)
// We primarily modulate the AIR valve; fuel is a mild inverse trim
// Base air position: map temperature stage to base opening
int baseAir = map((int)g_tempC, 20, (int)TEMP_TARGET_C,
AIR_SERVO_IDLE, 1900);
baseAir = constrain(baseAir, AIR_SERVO_IDLE, AIR_SERVO_MAX_US - 100);
int baseFuel = map((int)g_tempC, 20, (int)TEMP_TARGET_C,
FUEL_SERVO_IDLE, 1800);
baseFuel = constrain(baseFuel, FUEL_SERVO_IDLE, FUEL_SERVO_MAX_US - 100);
// PID correction: +1 unit pid → -5 µs air, +3 µs fuel
int airCorrection = (int)(-pidOut * 5.0f);
int fuelCorrection = (int)(pidOut * 3.0f);
g_airServoPulse = constrain(baseAir + airCorrection,
AIR_SERVO_MIN_US + 50,
AIR_SERVO_MAX_US - 50);
g_fuelServoPulse = constrain(baseFuel + fuelCorrection,
FUEL_SERVO_MIN_US + 50,
FUEL_SERVO_MAX_US - 50);
airServo.writeMicroseconds(g_airServoPulse);
fuelServo.writeMicroseconds(g_fuelServoPulse);
}
// ═══════════════════════════════════════════════════════════
// SAFETY INTERLOCKS
// ═══════════════════════════════════════════════════════════
void checkSafety() {
// Over-temperature shutdown
if (g_tempC >= TEMP_MAX_C) {
emergencyShutdown("OVER-TEMP");
return;
}
g_overTemp = (g_tempC >= TEMP_MAX_C);
// Flame-out detection after burner should be lit
if (!g_flamePresent && g_tempC > TEMP_MIN_FIRE_C) {
emergencyShutdown("FLAME-OUT");
return;
}
// O2 sensor fault – switch to safe open-loop mode
if (g_sensorFault) {
digitalWrite(LED_ALARM_PIN, HIGH);
Serial.println(F("[ALARM] O2 sensor fault – open-loop fallback"));
// Drive to safe stoichiometric preset
airServo.writeMicroseconds(AIR_SERVO_IDLE + 200);
fuelServo.writeMicroseconds(FUEL_SERVO_IDLE + 150);
} else {
if (!g_overTemp) digitalWrite(LED_ALARM_PIN, LOW);
}
}
void emergencyShutdown(const char* reason) {
g_systemRunning = false;
// Close both valves
airServo.writeMicroseconds(AIR_SERVO_MIN_US + 50);
fuelServo.writeMicroseconds(FUEL_SERVO_MIN_US + 50);
digitalWrite(LED_ALARM_PIN, HIGH);
lcd.clear();
lcd.setCursor(0, 0);
lcd.print(F("!! SHUTDOWN !!"));
lcd.setCursor(0, 1);
lcd.print(reason);
Serial.print(F("[EMERGENCY] Shutdown: "));
Serial.println(reason);
}
// ═══════════════════════════════════════════════════════════
// DISPLAY
// ═══════════════════════════════════════════════════════════
void updateDisplay() {
// Line 0: Temperature + Lambda
lcd.setCursor(0, 0);
lcd.print(F("T:"));
lcd.print((int)g_tempC);
lcd.print((char)223); // degree symbol
lcd.print(F("C L:"));
// Lambda to 2 decimal places
char lBuf[6];
dtostrf(g_lambdaMeasured, 4, 2, lBuf);
lcd.print(lBuf);
// Line 1: Status / PID output
lcd.setCursor(0, 1);
if (!g_systemRunning) {
lcd.print(F("SHUTDOWN "));
} else if (g_sensorFault) {
lcd.print(F("SENSOR FAULT "));
} else if (g_overTemp) {
lcd.print(F("OVER TEMP! "));
} else if (!g_flamePresent) {
lcd.print(F("NO FLAME "));
} else {
lcd.print(F("PID:"));
char pBuf[8];
dtostrf(g_pidOutput, 6, 1, pBuf);
lcd.print(pBuf);
lcd.print(g_manualOverride ? F(" MAN") : F(" "));
}
}
// ═══════════════════════════════════════════════════════════
// SERIAL LOGGING
// ═══════════════════════════════════════════════════════════
void logSerial() {
Serial.print(F("[LOG] T:"));
Serial.print(g_tempC, 1);
Serial.print(F("C L:"));
Serial.print(g_lambdaMeasured, 3);
Serial.print(F(" PID:"));
Serial.print(g_pidOutput, 1);
Serial.print(F(" Air_us:"));
Serial.print(g_airServoPulse);
Serial.print(F(" Fuel_us:"));
Serial.print(g_fuelServoPulse);
Serial.print(F(" Flame:"));
Serial.print(g_flamePresent ? "Y" : "N");
Serial.print(F(" Fault:"));
Serial.println(g_sensorFault ? "Y" : "N");
}
// ═══════════════════════════════════════════════════════════
// SERIAL COMMAND HANDLER
// ═══════════════════════════════════════════════════════════
void handleSerialCommands() {
if (!Serial.available()) return;
char cmd = toupper(Serial.read());
switch (cmd) {
case 'H':
Serial.println(F("Commands: H=Help S=Start E=EmergencyStop"));
Serial.println(F(" M=ManualMode A=ManualToggle"));
Serial.println(F(" +/-=Trim lambda setpoint by 0.01"));
Serial.println(F(" R=Reset/Restart L=Status log"));
break;
case 'S':
g_systemRunning = true;
g_overTemp = false;
g_sensorFault = false;
Serial.println(F("System started."));
break;
case 'E':
emergencyShutdown("USER CMD");
break;
case 'R':
g_pidIntegral = 0;
g_pidPrevError = 0;
g_systemRunning = true;
g_overTemp = false;
g_sensorFault = false;
Serial.println(F("PID reset. System restarted."));
break;
case 'M':
g_manualOverride = !g_manualOverride;
Serial.print(F("Manual override: "));
Serial.println(g_manualOverride ? "ON" : "OFF");
break;
case 'L':
logSerial();
break;
default:
break;
}
// Flush remaining characters
while (Serial.available()) Serial.read();
}