AS
All work

Project 03 · July 2023

Neonatal Incubator with IoT Monitoring

A low cost incubator built from salvage, held to a certified envelope

Most preterm deaths happen where a certified incubator costs more than the ward can spend. So the brief was not a better incubator, it was one built out of parts anyone can get, that still behaves inside the ranges a certified device is held to.

ArduinoESP32C++PID ControlSensor CalibrationMQTTThingsBoardIoTEmbedded Systems
The assembled neonatal incubator prototype
Built from
Consumer and salvaged parts
Control
PID humidity, on-off heat
Stability
0.6 °C air, 3 % RH ripple
Telemetry
MQTT to a ward dashboard

Why build one out of salvage

Fifteen million babies are born preterm every year, and the deaths concentrate in places where a commercial incubator is out of reach. The hood here is a cut water cooler carboy, the body a hand built insulated box, the heat a resistive element pulled from a fan heater, and the humidifier a household ultrasonic unit whose push button circuit was intercepted so the Arduino could press it instead of a person.

What could not be improvised was the envelope. Set points, tolerances and alarm limits were copied from a MEDIX Natal Care ST LX, an ANMAT registered incubator, so a device built from salvage is still held to the ranges a certified one has to meet.

Sensing, and the calibration that makes it trustworthy

One sensor per variable, picked on error rather than on price

The DHT11 reads both temperature and humidity, but only its humidity channel is used, at 5 %RH. Air temperature goes to a DS18B20 over 1-Wire at 0.5 °C, skin temperature to an MCP9700 analog sensor at 10 mV per degree, and air quality to an MQ135 read as CO2 in ppm.

Calibrated against physics, not against another thermometer

Two point calibration at the triple point and the boiling point of water. Boiling is pressure dependent, so local pressure came from the ISA barometric formula at 12 m above sea level, 1011.81 hPa, and Clausius-Clapeyron put boiling at 99.6 °C rather than 100. The sensor read 99.96 and -0.19 against those references, and the linear correction lives in the firmware.

The humidity curve that was deliberately not applied

Saturated salt solutions gave two points, magnesium chloride at 32.9 %RH and sodium chloride at 75.9 %RH. Both fall outside the 40 to 80 % band the incubator actually works in, so applying that curve would have meant extrapolating across the whole operating range.

Two control loops, tuned two different ways

Humidity: PID, tuned from the open loop step response

The step response gave dead time, rise time and gain, and Ziegler-Nichols turned those into Kp 136.3, Ki 50.27, Kd 92.30. The humidifier is a button and not a proportional actuator, so the PID output drives a slow duty cycle of on and off pulses instead of an analog level. Ripple settles at 3 % RH, inside the 10 % the reference device tolerates and above the sensor's own 2 % precision.

Temperature: on-off, because PID was unsafe to tune

The heating element never reached steady state before the air passed a temperature that is dangerous for a neonate, so there was no usable step response to tune against. Bang bang control on the relay holds 0.6 °C of ripple, with thermal inertia carrying the overshoot. The relay switches the 220 V element through its normally open contact, so losing control power turns the heat off rather than leaving it on.

Circuit diagram of the incubator electronics
220 V in through a switch, a 12 V switching supply for the fans, and an XL4005 step down to the 5 V rail shared by sensors, logic, relay and humidifier.

The firmware

C++ on a Mega 2560, no RTOS. Every task owns a millis() window and checks it on each pass through the loop, so control, display, buttons, alarms and telemetry interleave without any of them waiting on the others: 2500 ms for the screen rotation, 150 ms for button debounce, 60 ms for the alarm LED blink, 60 and 100 s for the trend windows, and 300000 ms for the alarm lockout during warm up.

The PWM window is the sensor's own refresh rate

The PID output limits are not a constant, they are set to PeriodoDHT, which is read from the DHT driver's min_delay at startup. You cannot modulate the humidifier faster than you can measure the humidity, so the duty cycle window is the measurement period by construction and the PID output maps directly onto it.

Driving an appliance that only has a button

pulso() pulls the line LOW for 300 ms and releases it, which is exactly what pressing the humidifier's button did. apagar() calls it twice with 50 ms between, because a double press is what that appliance reads as off. The relay is active low as well, so the heater is turned off by writing HIGH, which is why the disconnection path can force it off with a single write.

The ESP32 is a pipe, and that is a choice

It reads one line off Serial2 at 9600 baud and republishes the string unchanged to the incubator's MQTT topic. Nothing is parsed, so adding a field on the Mega needs no change on the radio at all. The cost is that a malformed line reaches the broker intact, and that reconnection is only handled at startup.

Most of the work is in what happens when something breaks

Two alarm tiers: a deviation from the chosen set point, and a value outside the absolute permitted range, both with limits taken from the reference device. The harder problem sits underneath them, because a disconnected sensor does not look like an alarm, it looks like a plausible number.

  • Disconnection is detected per sensor from its own failure signature: humidity returns NaN, air temperature collapses below -120, skin temperature jumps more than 20 degrees between consecutive samples or reads above 50, and CO2 jumps more than 30.
  • Losing the skin sensor while controlling on skin falls back to air mode and says so on the display, rather than closing the loop on a dead reading.
  • Losing the air sensor holds the heater off, since nothing is left to regulate against.
  • Alarms stay silenced for the first five minutes while the box warms up, a window derived from the slope of its own heating curve.
  • Changing a set point or a mode needs a separate safety button held down, a two hand interlock so nothing moves by brushing the panel.

From one incubator to a ward

An Arduino Mega runs control, an ESP32 on a serial link runs Wi-Fi, and the two speak JSON. Telemetry goes out over MQTT to ThingsBoard, chosen because it self hosts, so the ward's data never has to leave the hospital network.

Three classes of message, because 2.5 s is too slow for an alarm

Sensor values go out every 2.5 s in sync with the display. Trends are computed at the edge over 60 and 100 s windows, sending the range rather than the samples. Everything that matters, alarms, mode changes, set point edits and relay transitions, is sent by exception the moment it happens.

The cloud only computes what needs history

Rule chains turn those events into uptime, time in the current mode, and a daily disconnection count per sensor type, which needs the previous state precisely because disconnections arrive by exception. Alarms fan out to Telegram with the incubator name, the severity, and the checks to run for that specific alarm.

MQTT communication topology
Edge, broker and platform: where each piece of processing runs.
Ward dashboard showing six incubators with their live values and set points
Ward overview: every incubator with its mode, set points and water level, and active alarms counted by severity. Inactive units render as dashes so a stale value is never read as a live one.
Ward floor plan with a marker per incubator coloured by alarm severity
The same ward on its floor plan, each bay coloured by its highest active alarm, beside the daily disconnection count per sensor type.
Per incubator dashboard with live values, sensor connection state and trend charts
Per incubator detail: live values, sensor connection state, uptime, and humidity and air temperature plotted against their alarm limits.

What I would fix

This is a proof of concept, and the honest reading of it is that the control and failure logic are further along than the metrology underneath them.

  • Nothing validates the device against an independent instrument. Every number it reports comes from the same sensors it controls with, so there is no evidence that the air inside the hood is where the display says it is. A calibrated reference probe, and a thermal map across the mattress rather than one measurement point, is how a real incubator is specified.
  • The skin sensor is coarser than the alarm it feeds: an MCP9700 at 2 °C driving a 1 °C deviation band. And 1 °C is itself wide for neonatal thermal control, where the point of skin mode is to hold a baby within a few tenths. A clinical version needs a platinum RTD or a calibrated thermistor, not an analog part chosen for cost.
  • pulso() blocks the loop for 300 ms on every humidifier transition, which stalls the alarm checks with it. It belongs on the same millis() scheduler as everything else, as a state machine rather than a delay.
  • Transport is a prototype's transport: a public broker, no TLS or authentication, and reconnection handled only at startup.

Next project

Machine Learning Algorithms