Monitoreo Inteligente de Biorreactor con ESP32
Sistema IoT para la recolección y almacenamiento de datos de pH, temperatura y volumen de gas.
Fecha: 19 de septiembre de 2025
Dashboard en Vivo
Cargando datos iniciales...
Predicción del Modelo AM2
Simulación a 14 días desde el estado actual del reactor con pH tomado del sensor.
pH Predicho
Biogás Acumulado
Concentración de Sustratos
Evolución de Biomasas
Señal de Control
Diagnóstico basado en la predicción del modelo AM2 para los próximos 14 días
pH mínimo predicho
—
en día —
Biogás total predicho (14 días)
—
mL acumulados en el reactor
1. Introducción
La gestión de residuos orgánicos para la obtención de fuentes de energías renovables representa uno de los desafíos ambientales más críticos en las zonas urbanas, donde procesos de digestión anaerobia juegan como una alternativa tecnológica prometedora, permitiendo la valorización de estos residuos mediante la producción de biogás.
Sin embargo, la estabilidad de los sistemas de digestión anaerobia es sensible a las variaciones en la carga orgánica. En procesos con sustratos de alta densidad energética, el sistema es propenso a la acumulación acelerada de Ácidos Grasos Volátiles (AGV), lo que provoca una caída drástica del pH y la inhibición de las bacterias metanogénicas.
Con la existencia de modelos matemáticos robustos como el AM2 (Bernard et al., 2001), su implementación en plantas a pequeña escala o entornos de investigación de bajo costo es limitada debido a la falta de herramientas de monitoreo en tiempo real y la dependencia de análisis de laboratorio lentos. Existe la necesidad de desarrollar sistemas de gemelo digital para el monitoreo predictivo, permitiendo detectar estados de inhibición antes de que ocurra el colapso del biorreactor.
Objetivo
Desarrollar e implementar un gemelo digital de bajo costo para un reactor anaerobio de flujo intermitente (SBR), integrando el modelo dinámico AM2 con un sistema IoT.
2. Materiales y Métodos
2.1. Reactor
El sistema experimental consiste en un Biorreactor de Lote Secuencial (SBR) con un volumen total de 1.8 L. El mezclado se realiza mediante una turbina de aspas inclinadas (PBT), cuyo diseño estequiométrico y dimensiones se basaron en los principios de flujo y potencia para tanques agitados descritos por McCabe et al. en el libro de Operaciones Unitarias. Como inóculo, se emplearon 1.2 L de lodo anaerobio proveniente de una planta de tratamiento de aguas residuales.
Para la automatización y adquisición de datos en tiempo real, se implementó una arquitectura basada en el microcontrolador ESP32-WROOM-32D. El sistema se integra de la siguiente manera:
- Monitoreo Químico y Térmico: El pH se mide mediante una sonda BNC conectada a un módulo PH-4502C, cuya señal analógica es procesada por un convertidor ADC de precisión ADS1115 (16 bits) para minimizar el ruido. La temperatura se monitorea con un sensor sumergible DS18B20 mediante el protocolo One-Wire.
- Actuación y Potencia: La agitación es impulsada por un motorreductor DC gestionado por un controlador L298N. El mantenimiento térmico se logra con una tira calefactora de 24V, y la gestión de fluidos (alimentación y purga) se realiza mediante bombas peristálticas de 24V. El control de estos actuadores se centraliza en un módulo de relevadores de 2 canales.
- Medición de Biogás: Se diseñó un sensor de nivel de gas basado en conductividad eléctrica, empleando cables conductores sumergidos en una solución salina para detectar el desplazamiento de volumen.
- Interfaz y Alimentación: Los estados del sistema se visualizan en una pantalla OLED SSD1306. La potencia del sistema es suministrada por una fuente conmutada de 24V 5A, mientras que la lógica de control se alimenta de una fuente independiente de 5V 5A.
El reactor opera bajo un esquema de pulsos frecuentes para optimizar la degradación del sustrato. Se programaron pulsos de 20 ml cada 2 horas, garantizando un suministro constante de nutrientes y manteniendo la estabilidad del volumen de operación mediante la activación simultánea de las bombas de alimentación y purga.
2.2. Modelo AM2
Para la representación dinámica del proceso anaerobio y el desarrollo del sensor virtual, se empleó el modelo AM2 (Bernard et al., 2001). Este modelo describe la interacción entre las poblaciones bacterianas acidogénicas y metanogénicas mediante un sistema de seis ecuaciones diferenciales ordinarias.
La cinética de las bacterias acidogénicas sigue la ecuación de Monod, mientras que la de las metanogénicas se rige por la ecuación de Haldane, que representa la inhibición por sustrato a concentraciones elevadas de Ácidos Grasos Volátiles (AGV). Las condiciones iniciales se establecieron con base en la caracterización del lodo inoculado: S₁ = 0.5 g/L, S₂ = 6.4 mmol/L, Z = 26.4 mmol/L y C = 23.1 mmol/L.
El gemelo digital implementado en esta página actualiza automáticamente el pH inicial de la simulación usando el último valor medido por el sensor, recalculando el carbono inorgánico disuelto (C) mediante la ecuación de equilibrio carbónico: C = B · (1 + 10−pH / Kb), donde B = Z − S₂.
2.3. Análisis de Laboratorio
La validación de los estados estimados por el gemelo digital se realizó mediante métodos químicos convencionales:
- Alcalinidad Total (Z): Determinada mediante titulación potenciométrica con ácido clorhídrico (HCl) 0.1 M hasta alcanzar un punto final de pH 4.3.
- Ácidos Grasos Volátiles (S₂): Determinados mediante la técnica de titulación con hidróxido de sodio (NaOH) 0.1 M, permitiendo cuantificar la concentración de ácidos orgánicos acumulados.
- Sólidos: Se realizaron pruebas de Sólidos Volátiles (SV) y Sólidos Suspendidos Volátiles (SSV) para la estimación inicial de la biomasa activa presente en el reactor.
3. Código del ESP32
El siguiente código MicroPython controla el sistema completo: ciclo SBR, lectura de sensores (pH y temperatura), control de actuadores (motor, calentador, bomba de gas) y envío de datos a Google Sheets.
import machine
import time
import onewire, ds18x20
from ads1115 import ADS1115
from ssd1306 import SSD1306_I2C
import network
import urequests
import ujson
import gc
# --- 1. CONFIGURACIÓN DE PINES Y COMPONENTES ---
gc.enable()
i2c = machine.I2C(0, scl=machine.Pin(22), sda=machine.Pin(21))
ads = ADS1115(i2c, address=0x48, gain=1)
try:
oled = SSD1306_I2C(128, 64, i2c, addr=0x3C)
pantalla_ok = True
except Exception as e:
print("Error OLED:", e)
pantalla_ok = False
ow_bus = onewire.OneWire(machine.Pin(23))
ds_sensor = ds18x20.DS18X20(ow_bus)
sensor_temp_roms = ds_sensor.scan()
# Actuadores existentes
motor_in1 = machine.Pin(26, machine.Pin.OUT)
motor_in2 = machine.Pin(27, machine.Pin.OUT)
motor_ena = machine.PWM(machine.Pin(14), freq=1000, duty=0)
pin_calentador = machine.Pin(25, machine.Pin.OUT)
pin_bomba_gas = machine.Pin(13, machine.Pin.OUT)
pin_nivel_alto = machine.Pin(32, machine.Pin.IN, machine.Pin.PULL_DOWN)
pin_nivel_bajo = machine.Pin(33, machine.Pin.IN, machine.Pin.PULL_DOWN)
# Bombas Peristálticas (Lógica Inversa: 1 = APAGADO, 0 = ENCENDIDO)
B_ON = 0
B_OFF = 1
bomba_alimentacion = machine.Pin(16, machine.Pin.OUT, value=B_OFF)
bomba_purga = machine.Pin(17, machine.Pin.OUT, value=B_OFF)
bomba_alcalino = machine.Pin(18, machine.Pin.OUT, value=B_OFF) # Relé 3: bomba NaOH/HCO3
# --- 2. PARÁMETROS Y VARIABLES ---
WIFI_SSID = "nombreSSID"
WIFI_PASS = "contraseñaSSID"
URL_SERVIDOR = "url del macro en Apps Script"
VELOCIDAD_MOTOR_SEGURO = 650
MOTOR_TIEMPO_ENCENDIDO = 300000
MOTOR_TIEMPO_APAGADO = 600000
SETPOINT_TEMP = 35
HISTERESIS = 0.5
VOLUMEN_POR_CICLO_ML = 50.0
INTERVALO_SBR_MS = 4 * 3600 * 1000 # 4 horas
DURACION_BOMBAS_SBR_MS = 15000 # Ajustar para bombear ~50 mL
ultimo_ciclo_sbr = time.ticks_ms()
INTERVALO_PH_MS = 90000
INTERVALO_DATOS_MS = 900000
INTERVALO_COMANDO_MS = 900000 # Leer comando del gemelo digital cada 15 min
DURACION_ALCALINO_MS = 10000 # Pulso de bomba NaOH: 10 s (calibrar según flujo)
ultimo_cambio_motor = 0
motor_esta_encendido_por_horario = False
calentador_esta_encendido = False
volumen_total_ml = 0.0
estado_gas = "LLENANDO"
ultimo_tiempo_ph = -INTERVALO_PH_MS
ultimo_tiempo_datos = 0
ultimo_tiempo_comando = 0
# --- 3. FUNCIONES AUXILIARES ---
def detener_bombas_sbr():
bomba_alimentacion.value(B_OFF)
bomba_purga.value(B_OFF)
def ejecutar_ciclo_sbr():
print("Iniciando Ciclo SBR: Alimentación y Purga simultánea.")
bomba_alimentacion.value(B_ON)
bomba_purga.value(B_ON)
time.sleep_ms(DURACION_BOMBAS_SBR_MS)
detener_bombas_sbr()
print("Ciclo SBR finalizado.")
def leer_ph_promedio(num_muestras=10):
v_lects = []
try:
for _ in range(num_muestras):
v_lects.append(ads.read_voltage(0))
time.sleep_ms(10)
if not v_lects: return None, None
v_prom = sum(v_lects) / len(v_lects)
ph_calc = (-0.0866 * (v_prom**2)) - (5.2239 * v_prom) + 25.29
return ph_calc, v_prom
except Exception as e:
print("Error pH:", e)
return None, None
def conectar_wifi():
wlan = network.WLAN(network.STA_IF)
wlan.active(True)
if not wlan.isconnected():
wlan.connect(WIFI_SSID, WIFI_PASS)
timeout = 15
while not wlan.isconnected() and timeout > 0:
time.sleep(1)
timeout -= 1
def enviar_datos_google(temperatura, ph_val, vol_gas):
gc.collect()
if temperatura is None or ph_val is None: return
datos = {"temperatura": float(temperatura), "ph": float(ph_val), "volumen_gas": float(vol_gas)}
try:
json_payload = ujson.dumps(datos)
headers = {'Content-Type': 'application/json'}
res = urequests.post(URL_SERVIDOR, data=json_payload, headers=headers)
res.close()
except: pass
def detener_motor(): motor_ena.duty(0); motor_in1.value(0); motor_in2.value(0)
def encender_motor(): motor_in1.value(1); motor_in2.value(0); motor_ena.duty(VELOCIDAD_MOTOR_SEGURO)
def detener_calentador(): pin_calentador.value(1)
def encender_calentador(): pin_calentador.value(0)
def detener_bomba_gas(): pin_bomba_gas.value(1)
def encender_bomba_gas(): pin_bomba_gas.value(0)
def dosificar_alcalino():
"""Activa la bomba de NaOH/HCO3 por DURACION_ALCALINO_MS milisegundos."""
print("Dosificando solución alcalina...")
bomba_alcalino.value(B_ON)
time.sleep_ms(DURACION_ALCALINO_MS)
bomba_alcalino.value(B_OFF)
print("Dosificación completada.")
def leer_comando_google():
"""Consulta el Apps Script para obtener la señal del gemelo digital."""
gc.collect()
try:
res = urequests.get(URL_SERVIDOR + "?action=getComando")
data = ujson.loads(res.text)
res.close()
return data.get("comando_alcalino", "ESTABLE")
except:
return "ESTABLE"
# --- 4. PROGRAMA PRINCIPAL ---
print("Iniciando sistema...")
conectar_wifi()
detener_motor(); detener_calentador(); detener_bomba_gas(); detener_bombas_sbr()
ph_valor, voltaje_actual, temp_valor = None, None, None
try:
while True:
tiempo_actual = time.ticks_ms()
# Ciclo SBR (Bombas) — cada 4 horas
if time.ticks_diff(tiempo_actual, ultimo_ciclo_sbr) > INTERVALO_SBR_MS:
ejecutar_ciclo_sbr()
ultimo_ciclo_sbr = tiempo_actual
# Temperatura
if sensor_temp_roms:
try:
ds_sensor.convert_temp()
time.sleep_ms(750)
temp_valor = ds_sensor.read_temp(sensor_temp_roms[0])
except: temp_valor = None
# Control actuadores (temperatura y agitación)
if temp_valor is not None:
if temp_valor < (SETPOINT_TEMP - HISTERESIS):
encender_calentador(); calentador_esta_encendido = True
elif temp_valor > (SETPOINT_TEMP + HISTERESIS):
detener_calentador(); calentador_esta_encendido = False
else:
detener_calentador(); calentador_esta_encendido = False
if motor_esta_encendido_por_horario:
if time.ticks_diff(tiempo_actual, ultimo_cambio_motor) > MOTOR_TIEMPO_ENCENDIDO:
motor_esta_encendido_por_horario = False
ultimo_cambio_motor = tiempo_actual
else:
if time.ticks_diff(tiempo_actual, ultimo_cambio_motor) > MOTOR_TIEMPO_APAGADO:
motor_esta_encendido_por_horario = True
ultimo_cambio_motor = tiempo_actual
motor_realmente_encendido = motor_esta_encendido_por_horario or calentador_esta_encendido
if motor_realmente_encendido: encender_motor()
else: detener_motor()
# Gestión de gas
bomba_gas_encendida = (estado_gas == "VACIANDO")
if estado_gas == "LLENANDO":
if pin_nivel_alto.value() == 1:
volumen_total_ml += VOLUMEN_POR_CICLO_ML
encender_bomba_gas(); estado_gas = "VACIANDO"; time.sleep_ms(50)
elif estado_gas == "VACIANDO":
if pin_nivel_bajo.value() == 1:
detener_bomba_gas(); estado_gas = "LLENANDO"; time.sleep_ms(50)
# Muestreo pH (solo en condiciones seguras)
condiciones_seguras_ph = not motor_realmente_encendido and not calentador_esta_encendido and not bomba_gas_encendida
if time.ticks_diff(tiempo_actual, ultimo_tiempo_ph) > INTERVALO_PH_MS:
if condiciones_seguras_ph:
ph_valor, voltaje_actual = leer_ph_promedio()
ultimo_tiempo_ph = tiempo_actual
# Pantalla OLED
if pantalla_ok:
try:
oled.fill(0)
v_txt = "{:.3f}V".format(voltaje_actual) if voltaje_actual is not None else "V:---"
ph_txt = "pH: {:.2f}".format(ph_valor) if ph_valor is not None else "pH:---"
oled.text(ph_txt, 0, 0); oled.text(v_txt, 75, 0)
oled.text("Temp: {:.1f}C".format(temp_valor) if temp_valor is not None else "Temp: ERR", 0, 15)
oled.text("Gas: {:.1f}mL".format(volumen_total_ml), 0, 30)
m_stat = "ON" if motor_realmente_encendido else "OFF"
h_stat = "ON" if calentador_esta_encendido else "OFF"
b_stat = "ON" if bomba_gas_encendida else "OFF"
oled.text("M:{} H:{} B:{}".format(m_stat, h_stat, b_stat), 0, 45)
wifi_stat = "WIFI: OK" if network.WLAN(network.STA_IF).isconnected() else "WIFI: DISCON"
oled.text(wifi_stat, 0, 56)
oled.show()
except: pass
# Envío a Google Sheets
if time.ticks_diff(tiempo_actual, ultimo_tiempo_datos) > INTERVALO_DATOS_MS:
if ph_valor is not None and temp_valor is not None:
enviar_datos_google(temp_valor, ph_valor, volumen_total_ml)
ultimo_tiempo_datos = tiempo_actual
# Lectura del comando del gemelo digital (cada 15 min)
if time.ticks_diff(tiempo_actual, ultimo_tiempo_comando) > INTERVALO_COMANDO_MS:
comando = leer_comando_google()
if comando == "CRÍTICO":
dosificar_alcalino()
ultimo_tiempo_comando = tiempo_actual
time.sleep_ms(500)
finally:
detener_calentador(); detener_motor(); detener_bomba_gas()
detener_bombas_sbr(); bomba_alcalino.value(B_OFF)