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 — calculating the “magic number” (our calibration factor kk) that converts voltage-seconds into liters.

Now we need to wrap that math in a usable device. We’re not logging data to a serial monitor anymore; we’re building a standalone medical instrument with a UI, distinct operation modes, and a specific algorithm to calculate Forced Vital Capacity (FVC). Here’s how I structured the firmware for the Arduino Nano Every to handle the logic flow and the physics of bidirectional airflow.

What You Need

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

Keeping the Device Deterministic: A Simple State Machine

A medical device needs to be predictable. You don’t want the firmware integrating background noise while the user is navigating a menu.

I structured loop() as a basic state machine. The system blocks in a menu function until the user selects a mode, then routes control to the appropriate measurement handler:

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) {
    display_mode(mode);
    measure(mode); 
  }
  else if (mode == 2) {
    measure_FVC(); 
  }
  
  delay(2000); // Debounce/Reset delay
}

I used blocking code here rather than an interrupt-based scheduler. For a single-purpose device like this, it dramatically simplifies the logic without hurting the user experience.

Handling Directional Airflow

The MPX2010 is a differential pressure sensor. Exhaling creates positive pressure (voltage above ambient); inhaling creates negative pressure (voltage below ambient). The measure() function needs to handle both directions:

// Inside measure(int num_mode)...

// CASE 0: EXHALATION
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
while(V_val <= V_amb - 0.05 && i < 300 && num_mode == 1) {
    voltages[i] = V_amb - V_val; // Result is positive
    // ... sampling delay ...
}

Notice V_amb - V_val in the inhalation block. Since our integration function expects positive area values to calculate volume, we have to invert the negative signal in software. Skip this step and the integrator subtracts volume instead of adding it — you get negative liters.

Separate Calibration Constants for Each Direction

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

#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;
  
  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
}

The Clinical Algorithm: Scripting the FVC Maneuver

Forced Vital Capacity isn’t a single breath measurement. It requires a specific sequence of maneuvers — a deep inhale, normal tidal breathing, then a maximal forced exhale. By abstracting the raw sensing logic into measure(), the FVC code reads almost like a clinical script:

void measure_FVC () {
  // Step 1: Deep inhale (Inspiratory Capacity)
  write_oled("Take a deep breath...");
  FVC_vals[0] = measure(1);

  // Steps 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);

  // The math
  float TV = (FVC_vals[1] + FVC_vals[2]) / 2.0;
  FVC = FVC_vals[0] + FVC_vals[3] - TV;
  
  write_oled("FVC: " + String(FVC) + "L");
}

Because measure() handles hysteresis (waiting for the user to start breathing) and ADC sampling, measure_FVC doesn’t need to know anything about voltages or timing. It asks for a volume, waits for the user to comply, and gets a float back. Clean separation of concerns.

What this project makes obvious is how much software compensation is required for analog hardware. We had to calibrate out the DC offset at boot (V_amb), implement hysteresis to ignore noise, invert negative signals for inhalation, and apply different scaling factors based on airflow direction. Once those low-level hurdles are cleared, building the high-level medical logic becomes a straightforward exercise in state management.

We respect your privacy.

← View All Tutorials

Related Projects

    Ask me anything!