featured image

Digital Spirometer: State Machines & FVC Algorithms

Learn how to implement a state machine and FVC algorithm for a digital spirometer.

Published

Wed Jul 16 2025

Technologies Used

Arduino
Intermediate 28 minutes

In Part 1, we did the heavy lifting of calibrating our analog front-end. We calculated the “magic number” (our calibration factor kk) that converts voltage-seconds into liters.

Now, we need to wrap that math in a usable application. We aren’t just logging data to a serial monitor anymore; we are building a standalone medical device with a User Interface (UI), distinct operation modes, and a specific algorithm to calculate Forced Vital Capacity (FVC).

Here is how I structured the firmware for the Arduino Nano Every to handle the logic flow and the physics of bidirectional airflow.

Prerequisites

  • Libraries: Adafruit_SSD1306 and Adafruit_GFX (standard drivers for the OLED).
  • Hardware: 128x64 I2C OLED, 3 Push buttons (Up, Down, Confirm).

1. The Architecture: A Simple State Machine

A medical device needs to be deterministic. You don’t want the device integrating background noise while the user is trying to navigate a menu.

I structured the loop() as a basic state machine. The system sits in a blocking menu function until a mode is selected, then hands off control to the specific measurement logic.

void loop() {
  // 1. Blocking call: Wait here until user selects a mode
  mode = main_menu(); 

  // 2. Route to the correct logic handler
  if (mode == 0 || mode == 1) {
    // Single Measurement Modes (Exhale or Inhale)
    display_mode(mode);
    measure(mode); 
  }
  else if (mode == 2) {
    // Complex Sequence Mode
    measure_FVC(); 
  }
  
  delay(2000); // Debounce/Reset delay
}

Design Choice: I used blocking code here rather than an interrupt-based scheduler. For a single-purpose device like this, it simplifies the logic significantly without sacrificing UX.

2. Handling Directional Airflow (The Physics Problem)

Here is where things get interesting. The MPX2010 is a differential pressure sensor.

  • Exhaling creates positive pressure (Voltage > Ambient).
  • Inhaling creates negative pressure (Voltage < Ambient).

Our measure() function needs to handle both.

// Inside measure(int num_mode)...

// CASE 0: EXHALATION
// We wait for voltage to rise ABOVE the noise floor (+0.05V)
while(V_val >= V_amb + 0.05 && i < 300 && num_mode == 0) {
    voltages[i] = V_val - V_amb; // Result is Positive
    // ... sampling delay ...
}

// CASE 1: INHALATION
// We wait for voltage to drop BELOW the noise floor (-0.05V)
while(V_val <= V_amb - 0.05 && i < 300 && num_mode == 1) {
    voltages[i] = V_amb - V_val; // Result is Positive
    // ... sampling delay ...
}

The “Gotcha”: Notice the math in the Inhalation block: V_amb - V_val. Since our integration function expects positive area values to calculate volume, we have to invert the negative signal in software. If we didn’t, the integrator would subtract volume, giving us negative liters!

3. Applying the Calibration Factors

In testing, we found that the aerodynamics of pushing air into the sensor versus pulling it out resulted in slightly different flow characteristics. To account for this, I defined two separate calibration constants derived from Part 1.

#define EX_VOLT_TO_L_RATIO 2.94 // Exhale Factor
#define IN_VOLT_TO_L_RATIO 2.57 // Inhale Factor

float integrate_vol(float vals[], float dt, int factor) {
  float cal_fac;
  
  // Select the correct physics constant based on mode
  if(factor == 0) cal_fac = EX_VOLT_TO_L_RATIO;
  if(factor == 1) cal_fac = IN_VOLT_TO_L_RATIO;

  // ... [Standard Trapezoidal Integration Loop] ...

  return sum * cal_fac; // Returns Liters
}

4. The Clinical Algorithm: FVC

Forced Vital Capacity (FVC) is a key metric for lung health, but you can’t measure it with a single breath. It requires a sequence of maneuvers.

By abstracting the raw sensing logic into the measure() function, the FVC code reads almost like a script:

  1. Inspiratory Capacity (IC): Deep breath in.
  2. Tidal Volume (TV): Normal breathing (In/Out).
  3. The Blast: Maximal exhalation.
void measure_FVC () {
  // Step 1: Deep Inhale (IC)
  write_oled("Take a deep breath...");
  FVC_vals[0] = measure(1); // Mode 1 is Inhale

  // Step 2 & 3: Normal breathing to find Tidal Volume
  write_oled("Breathe normally...");
  FVC_vals[1] = measure(1); // TV Inhale
  FVC_vals[2] = measure(0); // TV Exhale

  // Step 4: The Forceful Exhale
  write_oled("Exhale as hard as possible...");
  FVC_vals[3] = measure(0); // Mode 0 is Exhale

  // The Math: Calculating the derived metric
  float TV = (FVC_vals[1] + FVC_vals[2]) / 2.0; // Average the Tidal Volumes
  FVC = FVC_vals[0] + FVC_vals[3] - TV;         // The FVC Formula
  
  // Display Results
  write_oled("FVC: " + String(FVC) + "L");
}

Why this works: Because measure() handles the hysteresis (waiting for the user to start breathing) and the ADC sampling, the measure_FVC function doesn’t need to know anything about voltages or timing. It just asks for a volume, waits for the user to comply, and gets a float back. This creates a very clean separation of concerns.

5. Final Thoughts

This project highlights how much software compensation is required for analog hardware. We had to:

  1. Calibrate out the DC offset at boot (V_amb).
  2. Implement hysteresis to ignore noise.
  3. Invert negative signals for inhalation.
  4. Apply different scaling factors based on airflow direction.

Once those low-level hurdles were cleared, building the high-level medical logic became a straightforward exercise in state management.

We respect your privacy.

← View All Tutorials

Related Projects

    Ask me anything!