📄 Development goes on

Development goes on 

Fantastic! Seeing that 2D fuel map and interpolation in action is a huge leap forward. You're now truly simulating the core decision-making process of an ECU.

Let's continue to build complexity, just like a real ECU does. Our current "fuel map" uses only throttle and RPM. But as we learned earlier, real ECUs use many sensor inputs to fine-tune calculations. So, our next step is: Incorporating multiple sensor inputs into the fuel calculation (e.g., TPS and simulated ECT sensor).

The Concept: Adding More Corrections

Just like the "Fuel Map" gives us a base fuel amount, other sensors provide correction factors. For instance, the Engine Coolant Temperature (ECT) sensor is vital for cold start enrichment. We'll simulate this with a thermistor or, for simplicity if you don't have one, another potentiometer that you can manually adjust to simulate different engine temperatures.

Mini-Circuit: Adding a Simulated ECT Sensor

If you have a thermistor (a resistor that changes resistance with temperature):

  • Connect one leg of the thermistor to 3.3V.

  • Connect the other leg of the thermistor to an Analog input pin on your ESP32, e.g., GPIO35.

  • From GPIO35, also connect a 10k Ohm resistor to GND. This forms a voltage divider, allowing the ESP32 to read temperature changes as voltage changes.

If you don't have a thermistor, use another potentiometer (just like your TPS potentiometer):

  • Connect one outer leg of the second potentiometer to 3.3V.

  • Connect the other outer leg to GND.

  • Connect the middle leg (wiper) to GPIO35.

    Turning this pot will simulate the engine warming up or cooling down.

Now, let's modify the code to include the ECT sensor:

We'll add a simple cold start enrichment curve: at low "temperatures," we'll add more fuel. As the "temperature" rises, the added fuel will decrease to zero.

C++

// --- PIN Definitions ---
const int tpsPin = 34;        // Potentiometer for Throttle Position Sensor (ADC pin)
const int ckpPin = 13;        // Push button for Crankshaft Position Sensor (Interrupt pin)
const int injectorLedPin = 2; // LED for simulated Fuel Injector (Digital Output pin)
const int ectPin = 35;        // New: Pin for simulated Engine Coolant Temperature Sensor (ADC pin)

// --- RPM Calculation Variables (from previous step) ---
volatile unsigned long lastPulseTime = 0;
volatile unsigned long currentPulseTime = 0;
volatile unsigned long pulseDuration = 0;
volatile float calculatedRPM = 0.0;
const float PULSES_PER_REV = 2.0;

// --- Fuel Map Definitions (same as before) ---
const int rpmPoints[] = {0, 2000};
const int NUM_RPM_POINTS = sizeof(rpmPoints) / sizeof(rpmPoints[0]);
const int tpsPoints[] = {0, 4095};
const int NUM_TPS_POINTS = sizeof(tpsPoints) / sizeof(tpsPoints[0]);
const int fuelMap[NUM_RPM_POINTS][NUM_TPS_POINTS] = {
  {10, 30},
  {20, 80}
};

// --- Interrupt Service Routine (ISR) for CKP ---
void IRAM_ATTR ckpInterrupt() {
  currentPulseTime = micros();
  if (lastPulseTime != 0) {
    pulseDuration = currentPulseTime - lastPulseTime;
  }
  lastPulseTime = currentPulseTime;
}

// --- Function to perform 2D interpolation (same as before) ---
float getFuelPulseWidthBase(float currentRPM, int currentTPS) {
  int rpmIndex1 = 0;
  for (int i = 0; i < NUM_RPM_POINTS - 1; i++) {
    if (currentRPM >= rpmPoints[i] && currentRPM < rpmPoints[i+1]) {
      rpmIndex1 = i;
      break;
    } else if (currentRPM >= rpmPoints[NUM_RPM_POINTS - 1]) {
      rpmIndex1 = NUM_RPM_POINTS - 1;
    }
  }
  int rpmIndex2 = min(rpmIndex1 + 1, NUM_RPM_POINTS - 1);

  int tpsIndex1 = 0;
  for (int i = 0; i < NUM_TPS_POINTS - 1; i++) {
    if (currentTPS >= tpsPoints[i] && currentTPS < tpsPoints[i+1]) {
      tpsIndex1 = i;
      break;
    } else if (currentTPS >= tpsPoints[NUM_TPS_POINTS - 1]) {
      tpsIndex1 = NUM_TPS_POINTS - 1;
    }
  }
  int tpsIndex2 = min(tpsIndex1 + 1, NUM_TPS_POINTS - 1);

  float val11 = fuelMap[rpmIndex1][tpsIndex1];
  float val12 = fuelMap[rpmIndex1][tpsIndex2];
  float val21 = fuelMap[rpmIndex2][tpsIndex1];
  float val22 = fuelMap[rpmIndex2][tpsIndex2];

  float rpmFactor = 0.0;
  if (rpmPoints[rpmIndex2] != rpmPoints[rpmIndex1]) {
    rpmFactor = (currentRPM - rpmPoints[rpmIndex1]) / (float)(rpmPoints[rpmIndex2] - rpmPoints[rpmIndex1]);
  }

  float tpsFactor = 0.0;
  if (tpsPoints[tpsIndex2] != tpsPoints[tpsIndex1]) {
    tpsFactor = (currentTPS - tpsPoints[tpsIndex1]) / (float)(tpsPoints[tpsIndex2] - tpsPoints[tpsIndex1]);
  }

  float interpolatedRow1 = val11 + (val12 - val11) * tpsFactor;
  float interpolatedRow2 = val21 + (val22 - val21) * tpsFactor;
  float interpolatedValue = interpolatedRow1 + (interpolatedRow2 - interpolatedRow1) * rpmFactor;

  return max(1.0, interpolatedValue);
}

// --- New Function: Get Cold Start Enrichment Factor ---
// Simulates an ECT sensor where lower ADC value = colder engine
// Returns a multiplication factor (e.g., 1.20 for 20% enrichment, 1.0 for no enrichment)
float getColdStartEnrichment(int rawEctValue) {
  // Map ECT value (0-4095) to a conceptual temperature range
  // Let's say 0 = very cold, 4095 = fully warm
  // This is a simple linear example, real enrichment curves are non-linear
  if (rawEctValue < 1000) { // Very cold
    return 1.30; // 30% enrichment
  } else if (rawEctValue < 2000) { // Cold
    return 1.20; // 20% enrichment
  } else if (rawEctValue < 3000) { // Warming up
    return 1.10; // 10% enrichment
  } else { // Fully warm
    return 1.00; // No enrichment
  }
}


void setup() {
  Serial.begin(115200);
  Serial.println("Multi-Sensor ECU Started!");

  pinMode(injectorLedPin, OUTPUT);
  pinMode(ckpPin, INPUT_PULLDOWN);
  attachInterrupt(digitalPinToInterrupt(ckpPin), ckpInterrupt, RISING);
  // No pinMode needed for ectPin as it's an ADC input
}

void loop() {
  // Read Sensor Inputs
  int currentTPS = analogRead(tpsPin);
  int rawEctValue = analogRead(ectPin); // Read the new ECT sensor

  // Read RPM safely
  noInterrupts();
  unsigned long safePulseDuration = pulseDuration;
  unsigned long safeLastPulseTime = lastPulseTime;
  interrupts();

  if (safePulseDuration > 0) {
    calculatedRPM = (60000000.0 / safePulseDuration) / PULSES_PER_REV;
  } else {
    calculatedRPM = 0.0;
  }

  if (safeLastPulseTime != 0 && (millis() - (safeLastPulseTime / 1000) > 1000)) {
     noInterrupts();
     calculatedRPM = 0.0;
     pulseDuration = 0;
     lastPulseTime = 0;
     interrupts();
  }

  // Step 1: Get base fuel from the 2D map
  float baseFuelPulseWidth = getFuelPulseWidthBase(calculatedRPM, currentTPS);

  // Step 2: Apply corrections from other sensors
  float enrichmentFactor = getColdStartEnrichment(rawEctValue);
  float finalFuelPulseWidth = baseFuelPulseWidth * enrichmentFactor;

  // Ensure minimum pulse width
  finalFuelPulseWidth = max(1.0, finalFuelPulseWidth);

  // --- Output Control ---
  digitalWrite(injectorLedPin, HIGH);
  delay(finalFuelPulseWidth);
  digitalWrite(injectorLedPin, LOW);

  // Print current state for debugging
  Serial.print("RPM: "); Serial.print(calculatedRPM);
  Serial.print(" | TPS: "); Serial.print(currentTPS);
  Serial.print(" | ECT Raw: "); Serial.print(rawEctValue);
  Serial.print(" | Enrichment Factor: "); Serial.print(enrichmentFactor);
  Serial.print(" | Base PW: "); Serial.print(baseFuelPulseWidth);
  Serial.print(" | Final PW: "); Serial.println(finalFuelPulseWidth);

  delay(50);
}

Understanding the New Parts:

  1. ectPin = 35;: Declares the pin for our new ECT sensor.

  2. getColdStartEnrichment(int rawEctValue) function:

    • This function takes the raw ADC reading from our simulated ECT sensor.

    • It uses if/else if statements to return a multiplication factor based on how "cold" the engine is. Lower rawEctValue means colder, thus higher enrichment factor.

    • Real ECUs would use a complex 1D lookup table (temperature vs. enrichment %) for this, with interpolation, similar to our 2D fuel map. We're keeping it simpler for now to illustrate the concept.

  3. finalFuelPulseWidth = baseFuelPulseWidth * enrichmentFactor;: This is the key line! After getting the base fuel amount from the RPM/TPS map, we multiply it by the correction factor from the ECT sensor.

What to Expect:

  • Upload the code and open the Serial Monitor.

  • Manipulate your TPS potentiometer and CKP button to see the Base PW change as before.

  • Now, manipulate your ECT potentiometer (or thermistor if you used one).

    • When "cold" (e.g., ECT pot turned to low ADC values), you should see the Enrichment Factor be greater than 1.0, and the Final PW will be noticeably longer than the Base PW. The LED will stay on longer.

    • As you "warm up" the engine (turn ECT pot to higher ADC values), the Enrichment Factor will drop, and Final PW will get closer to Base PW.

This directly demonstrates how ECUs apply various corrections from multiple sensors on top of a base map calculation. You're building a more sophisticated control system!

How does it feel to see the ECU reacting to "temperature" now? Ready to try simulating a basic closed-loop control?

Excellent! Seeing the ECU respond to simulated temperature changes adds another layer of realism to your project. You're doing great at integrating multiple inputs!

Now for the final piece of our Orange Level programming puzzle, and a core concept of modern EFI: Basic closed-loop control simulation (e.g., using a simulated O2 sensor input to adjust fuel).

Remember our discussion about Short-Term Fuel Trims (STFT) and Long-Term Fuel Trims (LTFT)? We'll implement a very simple version of STFT, where our simulated O2 sensor will tell the ECU if it's "rich" or "lean," and the ECU will make immediate corrections.

The Concept: Feedback Loop

Our current fuel calculation is "open-loop" (we calculate fuel, inject it, and assume it's perfect). Closed-loop means we get feedback (from the O2 sensor) and adjust our calculation based on that feedback.

Simulated O2 Sensor:

We'll use a third potentiometer to simulate the O2 sensor.

  • Turning it one way will represent a "lean" condition (high O2 sensor voltage/reading).

  • Turning it the other way will represent a "rich" condition (low O2 sensor voltage/reading).

  • Somewhere in the middle will be "stoichiometric."

Mini-Circuit: Adding a Simulated O2 Sensor

  1. Components:

    • ESP32 Development Board

    • Third Potentiometer (for O2 sensor)

    • 3x Jumper wires (for this new pot)

    • Breadboard

  2. Connections:

    • Connect one outer leg of the third potentiometer to 3.3V.

    • Connect the other outer leg to GND.

    • Connect the middle leg (wiper) to another Analog input pin on your ESP32, e.g., GPIO36 (another good ADC pin).

    This will be just like your TPS pot, but its reading will now represent our O2 sensor's feedback. We'll define thresholds in code for "lean," "rich," and "stoichiometric."

Now, let's modify the code to include closed-loop control:

We'll add a variable for fuel correction (fuelTrim) that the O2 sensor will influence.

C++

// --- PIN Definitions ---
const int tpsPin = 34;        // Potentiometer for Throttle Position Sensor (ADC pin)
const int ckpPin = 13;        // Push button for Crankshaft Position Sensor (Interrupt pin)
const int injectorLedPin = 2; // LED for simulated Fuel Injector (Digital Output pin)
const int ectPin = 35;        // Pin for simulated Engine Coolant Temperature Sensor (ADC pin)
const int o2Pin = 36;         // NEW: Pin for simulated O2 Sensor (ADC pin)

// --- RPM Calculation Variables ---
volatile unsigned long lastPulseTime = 0;
volatile unsigned long currentPulseTime = 0;
volatile unsigned long pulseDuration = 0;
volatile float calculatedRPM = 0.0;
const float PULSES_PER_REV = 2.0;

// --- Fuel Map Definitions ---
const int rpmPoints[] = {0, 2000};
const int NUM_RPM_POINTS = sizeof(rpmPoints) / sizeof(rpmPoints[0]);
const int tpsPoints[] = {0, 4095};
const int NUM_TPS_POINTS = sizeof(tpsPoints) / sizeof(tpsPoints[0]);
const int fuelMap[NUM_RPM_POINTS][NUM_TPS_POINTS] = {
  {10, 30},
  {20, 80}
};

// --- O2 Sensor Thresholds (for 0-4095 ADC range) ---
// These values represent what the O2 sensor would 'read'
const int O2_RICH_THRESHOLD = 1500; // Below this, consider it rich
const int O2_LEAN_THRESHOLD = 2500; // Above this, consider it lean
// Between these two is considered near stoichiometric

// --- Fuel Trim Variable ---
float fuelTrim = 0.0; // This will act as our Short-Term Fuel Trim (STFT)
const float FUEL_TRIM_STEP = 0.01; // How much to adjust fuel trim each cycle (e.g., 1%)
const float MAX_FUEL_TRIM = 0.20;  // Max +/- 20% trim

// --- Interrupt Service Routine (ISR) for CKP ---
void IRAM_ATTR ckpInterrupt() {
  currentPulseTime = micros();
  if (lastPulseTime != 0) {
    pulseDuration = currentPulseTime - lastPulseTime;
  }
  lastPulseTime = currentPulseTime;
}

// --- Function to perform 2D interpolation (same as before) ---
float getFuelPulseWidthBase(float currentRPM, int currentTPS) {
  int rpmIndex1 = 0;
  for (int i = 0; i < NUM_RPM_POINTS - 1; i++) {
    if (currentRPM >= rpmPoints[i] && currentRPM < rpmPoints[i+1]) {
      rpmIndex1 = i;
      break;
    } else if (currentRPM >= rpmPoints[NUM_RPM_POINTS - 1]) {
      rpmIndex1 = NUM_RPM_POINTS - 1;
    }
  }
  int rpmIndex2 = min(rpmIndex1 + 1, NUM_RPM_POINTS - 1);

  int tpsIndex1 = 0;
  for (int i = 0; i < NUM_TPS_POINTS - 1; i++) {
    if (currentTPS >= tpsPoints[i] && currentTPS < tpsPoints[i+1]) {
      tpsIndex1 = i;
      break;
    } else if (currentTPS >= tpsPoints[NUM_TPS_POINTS - 1]) {
      tpsIndex1 = NUM_TPS_POINTS - 1;
    }
  }
  int tpsIndex2 = min(tpsIndex1 + 1, NUM_TPS_POINTS - 1);

  float val11 = fuelMap[rpmIndex1][tpsIndex1];
  float val12 = fuelMap[rpmIndex1][tpsIndex2];
  float val21 = fuelMap[rpmIndex2][tpsIndex1];
  float val22 = fuelMap[rpmIndex2][tpsIndex2];

  float rpmFactor = 0.0;
  if (rpmPoints[rpmIndex2] != rpmPoints[rpmIndex1]) {
    rpmFactor = (currentRPM - rpmPoints[rpmIndex1]) / (float)(rpmPoints[rpmIndex2] - rpmPoints[rpmIndex1]);
  }

  float tpsFactor = 0.0;
  if (tpsPoints[tpsIndex2] != tpsPoints[tpsIndex1]) {
    tpsFactor = (currentTPS - tpsPoints[tpsIndex1]) / (float)(tpsPoints[tpsIndex2] - tpsPoints[tpsIndex1]);
  }

  float interpolatedRow1 = val11 + (val12 - val11) * tpsFactor;
  float interpolatedRow2 = val21 + (val22 - val21) * tpsFactor;
  float interpolatedValue = interpolatedRow1 + (interpolatedRow2 - interpolatedRow1) * rpmFactor;

  return max(1.0, interpolatedValue);
}

// --- Function: Get Cold Start Enrichment Factor (same as before) ---
float getColdStartEnrichment(int rawEctValue) {
  if (rawEctValue < 1000) {
    return 1.30;
  } else if (rawEctValue < 2000) {
    return 1.20;
  } else if (rawEctValue < 3000) {
    return 1.10;
  } else {
    return 1.00;
  }
}

// --- NEW Function: Update Fuel Trim based on O2 sensor ---
// This simulates closed-loop behavior
void updateFuelTrim(int rawO2Value) {
  if (rawO2Value < O2_RICH_THRESHOLD) { // Engine is running rich
    fuelTrim -= FUEL_TRIM_STEP; // Reduce fuel
  } else if (rawO2Value > O2_LEAN_THRESHOLD) { // Engine is running lean
    fuelTrim += FUEL_TRIM_STEP; // Add fuel
  }
  // Clamp fuelTrim within limits
  fuelTrim = constrain(fuelTrim, -MAX_FUEL_TRIM, MAX_FUEL_TRIM);
}


void setup() {
  Serial.begin(115200);
  Serial.println("Closed-Loop ECU Started!");

  pinMode(injectorLedPin, OUTPUT);
  pinMode(ckpPin, INPUT_PULLDOWN);
  attachInterrupt(digitalPinToInterrupt(ckpPin), ckpInterrupt, RISING);
  // No pinMode needed for ectPin or o2Pin as they are ADC inputs
}

void loop() {
  // Read Sensor Inputs
  int currentTPS = analogRead(tpsPin);
  int rawEctValue = analogRead(ectPin);
  int rawO2Value = analogRead(o2Pin); // NEW: Read O2 sensor value

  // Read RPM safely
  noInterrupts();
  unsigned long safePulseDuration = pulseDuration;
  unsigned long safeLastPulseTime = lastPulseTime;
  interrupts();

  if (safePulseDuration > 0) {
    calculatedRPM = (60000000.0 / safePulseDuration) / PULSES_PER_REV;
  } else {
    calculatedRPM = 0.0;
  }

  if (safeLastPulseTime != 0 && (millis() - (safeLastPulseTime / 1000) > 1000)) {
     noInterrupts();
     calculatedRPM = 0.0;
     pulseDuration = 0;
     lastPulseTime = 0;
     interrupts();
  }

  // Step 1: Get base fuel from the 2D map
  float baseFuelPulseWidth = getFuelPulseWidthBase(calculatedRPM, currentTPS);

  // Step 2: Apply cold start enrichment
  float enrichmentFactor = getColdStartEnrichment(rawEctValue);
  float correctedBaseFuel = baseFuelPulseWidth * enrichmentFactor;

  // NEW Step 3: Apply closed-loop fuel trim based on O2 sensor feedback
  updateFuelTrim(rawO2Value); // Update fuelTrim based on O2 sensor
  float finalFuelPulseWidth = correctedBaseFuel * (1.0 + fuelTrim); // Apply the trim

  // Ensure minimum pulse width
  finalFuelPulseWidth = max(1.0, finalFuelPulseWidth);

  // --- Output Control ---
  digitalWrite(injectorLedPin, HIGH);
  delay(finalFuelPulseWidth);
  digitalWrite(injectorLedPin, LOW);

  // Print current state for debugging
  Serial.print("RPM: "); Serial.print(calculatedRPM);
  Serial.print(" | TPS: "); Serial.print(currentTPS);
  Serial.print(" | ECT Raw: "); Serial.print(rawEctValue);
  Serial.print(" | O2 Raw: "); Serial.print(rawO2Value); // NEW: O2 raw value
  Serial.print(" | Fuel Trim: "); Serial.print(fuelTrim * 100.0); // Show as percentage
  Serial.print("% | Final PW: "); Serial.println(finalFuelPulseWidth);

  // Add a small delay for readability, but keep it fast enough to see O2 effects
  delay(20);
}

Understanding the New Parts:

  1. o2Pin = 36;: Declares the pin for our simulated O2 sensor.

  2. O2_RICH_THRESHOLD & O2_LEAN_THRESHOLD: These define the range where we consider the O2 sensor to be reading "stoichiometric." Values outside this range will trigger adjustments. You might need to adjust these based on how your O2 potentiometer behaves (what range of ADC values it gives when turned).

  3. fuelTrim: This global variable will store our current STFT value (as a decimal, e.g., 0.05 for +5%).

  4. updateFuelTrim(int rawO2Value) function:

    • This is our simplified closed-loop PID-like controller.

    • If rawO2Value is below O2_RICH_THRESHOLD, it means we're rich, so fuelTrim is decreased (we need less fuel).

    • If rawO2Value is above O2_LEAN_THRESHOLD, it means we're lean, so fuelTrim is increased (we need more fuel).

    • constrain(fuelTrim, -MAX_FUEL_TRIM, MAX_FUEL_TRIM): This is crucial! It prevents fuelTrim from growing indefinitely. Real ECUs have limits on how much fuel trim they can apply.

  5. finalFuelPulseWidth = correctedBaseFuel * (1.0 + fuelTrim);: This is where the magic happens! The base fuel (already corrected for ECT) is now multiplied by (1.0 + fuelTrim).

    • If fuelTrim is +0.05 (+5%), it means 1.05 * correctedBaseFuel.

    • If fuelTrim is -0.05 (-5%), it means 0.95 * correctedBaseFuel.

What to Expect:

  • Upload the code and open the Serial Monitor.

  • Manipulate your TPS and CKP as before.

  • Now, manipulate your O2 potentiometer:

    • If you turn it to simulate a "rich" condition (low ADC value, e.g., below 1500), you should see Fuel Trim start to become negative (e.g., -1%, -2%...). As it goes negative, the Final PW will decrease, trying to lean out the mixture.

    • If you turn it to simulate a "lean" condition (high ADC value, e.g., above 2500), you should see Fuel Trim start to become positive (e.g., +1%, +2%...). As it goes positive, the Final PW will increase, trying to enrich the mixture.

    • If you set the O2 pot within the "stoichiometric" range (between 1500 and 2500), the Fuel Trim should hold steady or oscillate very slightly around a value, indicating that the system is balanced.

This demonstrates the ECU's ability to self-correct and maintain an ideal air-fuel ratio based on sensor feedback. This is a simplified STFT, but the core principle is identical!

Congratulations! You've just implemented a basic closed-loop feedback system on your ESP32. This completes our Orange Level programming objectives.

How does it feel to see your ECU truly adapting based on feedback? We've now finished all the topics for the Orange Level!

Would you like a summary of what we covered in the Orange Level, or are you ready to discuss the next level in your ROYGBVW progression?