← All projects

ISCInstrumentation & Signal Conditioning

Portable OD600 Instrument for Microbial Growth Analysis

A portable, low-cost spectrophotometer measuring OD600 directly in 19 mm culture tubes. Built across two iterations — from a prototype validating the optical path (analog photodiodes vs. a TSL2591 digital sensor) to a standalone smart device (Arduino Nano 33 IoT, capacitive TFT UI, light-baffled 3D-printed chassis). Beer-Lambert + a 4-Parameter Logistic regression map raw absorbance to a SpectraMax benchmark.

  • Bioprocess Automation
  • Optoelectronics
  • Optical CAD
  • 4PL Modeling
  • Embedded C/C++

Hardware & Architecture

  • Narrow-band 600 nm LED source directed at a TSL2591 high-sensitivity digital light sensor over I²C.
  • Arduino Nano 33 IoT running the state machine, driving a capacitive TFT touchscreen for guided dark/blank calibration.
  • SolidWorks/Fusion 360 chassis with internal baffling and tight-tolerance tube holders aligning LED and sensor along the tube's diametric axis.
  • Iteration 1 (Arduino Micro, 16x2 LCD) established the optical path; Iteration 2 refined it into a standalone smart device.

Key Highlights

  • Beer-Lambert absorbance from dark/blank/sample intensities acquired on-device.
  • Python 4-Parameter Logistic (4PL) regression corrects for non-linear light scattering at high cell densities, mapping raw absorbance to SpectraMax ground truth.
  • Fitted A/B/C/D constants are hardcoded back into firmware for real-time lab-grade OD600.

Images

Code

od600_measurement.inoNano 33 IoT Measurement Logiccpp
#include <Wire.h>
#include <Adafruit_Sensor.h>
#include <Adafruit_TSL2591.h>
#include <math.h>

Adafruit_TSL2591 tsl = Adafruit_TSL2591(2591);

// Calibration variables stored in RAM/EEPROM
float darkIntensity = 0.0;
float blankIntensity = 0.0;

void setup() {
  Serial.begin(115200);
  if (!tsl.begin()) {
    Serial.println("Error: TSL2591 not detected.");
    while (1);
  }
  // Configure for high sensitivity measuring low-transmission liquids
  tsl.setGain(TSL2591_GAIN_MED);
  tsl.setTiming(TSL2591_INTEGRATIONTIME_300MS);
}

// Function triggered via TFT UI during "Dark Calibration"
void calibrateDark() {
  // LED OFF, read ambient/stray light
  darkIntensity = readSensorAverage(10);
  Serial.print("Dark Intensity Set: ");
  Serial.println(darkIntensity);
}

// Function triggered via TFT UI during "Blank Calibration"
void calibrateBlank() {
  // LED ON with sterile media in the tube
  blankIntensity = readSensorAverage(10) - darkIntensity;
  Serial.print("Blank Intensity Set: ");
  Serial.println(blankIntensity);
}

// Real-time measurement routine
float measureRawAbsorbance() {
  float sampleIntensity = readSensorAverage(5) - darkIntensity;
  if (sampleIntensity <= 0 || blankIntensity <= 0) return 0.0;
  // Beer-Lambert Law Calculation
  float transmission = sampleIntensity / blankIntensity;
  float rawAbsorbance = log10(1.0 / transmission);
  return rawAbsorbance;
}

float readSensorAverage(int samples) {
  float sum = 0;
  for (int i = 0; i < samples; i++) {
    uint32_t lum = tsl.getFullLuminosity();
    uint16_t visible = lum & 0xFFFF; // Extract visible spectrum
    sum += visible;
    delay(50);
  }
  return sum / samples;
}
od600_4pl_calibration.py4PL Calibrationpython
import numpy as np
import matplotlib.pyplot as plt
from scipy.optimize import curve_fit

# Raw Absorbance from Arduino (Independent Variable)
x_raw_abs = np.array([0.05, 0.12, 0.28, 0.55, 0.85, 1.2, 1.6])
# Ground Truth OD600 from SpectraMax (Dependent Variable)
y_true_od = np.array([0.1, 0.25, 0.6, 1.2, 2.0, 3.2, 4.5])


# 4-Parameter Logistic (4PL) Function
# A: Minimum asymptote, B: Hill's slope, C: Inflection point, D: Maximum asymptote
def four_param_logistic(x, A, B, C, D):
    return D + (A - D) / (1.0 + (x / C) ** B)


# Initial guess for the parameters [A, B, C, D]
initial_guess = [0.0, 1.5, 0.8, 6.0]

# Perform non-linear curve fitting
popt, pcov = curve_fit(four_param_logistic, x_raw_abs, y_true_od, p0=initial_guess)
A, B, C, D = popt
print(f"[CALIBRATION CONSTANTS] A: {A:.4f}, B: {B:.4f}, C: {C:.4f}, D: {D:.4f}")

# Visualization of the Calibration Curve
x_fit = np.linspace(0, 2.0, 100)
y_fit = four_param_logistic(x_fit, *popt)
plt.figure(figsize=(8, 6))
plt.scatter(x_raw_abs, y_true_od, color='red', label='Empirical Data Pairs', zorder=5)
plt.plot(x_fit, y_fit, color='blue', linestyle='--', label='4PL Regression Fit')
plt.title('Portable Instrument Calibration: Raw Absorbance vs. SpectraMax OD600')
plt.xlabel('Arduino Raw Absorbance (A)')
plt.ylabel('True OD600 (SpectraMax)')
plt.grid(True, alpha=0.5)
plt.legend()
plt.show()
# Note: The resulting A, B, C, D constants are then hardcoded into the Arduino firmware
# to convert measureRawAbsorbance() into accurate lab-grade OD600 in real-time.