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

— Cargando simulación…

pH mínimo predicho

—

en día —

Biogás total predicho (14 días)

—

mL acumulados en el reactor

Foto del sistema del reactor montado

1. Resumen del Proyecto

La gestión de residuos orgánicos en entornos urbanos representa uno de los desafíos ambientales más críticos de la actualidad. La digestión anaerobia (DA) se presenta como una solución tecnológica robusta al permitir la valorización de estos residuos mediante la producción de biogás. La estabilidad del proceso es extremadamente sensible a las variaciones en la carga orgánica y el uso de sustratos con alta densidad energética suele desencadenar la acumulación excesiva de Ácidos Grasos Volátiles (AGV). Este fenómeno provoca una caída drástica del pH, inhibiendo la actividad de las arqueas metanogénicas y comprometiendo la viabilidad del reactor.

El objetivo central de este proyecto consistió en el desarrollo e implementación de un gemelo digital diseñado específicamente para un reactor anaerobio de flujo intermitente (SBR de 1 L). El aporte principal radica en la integración del modelo biológico dinámico AM2, que simplifica la complejidad del ADM1 a seis ecuaciones diferenciales, dentro de una arquitectura de Internet de las Cosas (IoT) basada en el microcontrolador ESP32. El sistema incorpora sensores de pH (PH-4502C), de temperatura (DS18B20) y de medición de biogás, lo que permite detectar de forma temprana estados de inhibición antes de que ocurra un colapso irreversible del sistema biológico.

Los resultados experimentales demostraron que el gemelo digital es capaz de reproducir con precisión la ventana operativa estable (pH 6.7–7.3) y predecir el punto de inflexión hacia la inhibición ante sobrecargas orgánicas. Se identificó un modo de fallo crítico en el que la cinética de crecimiento acidogénico supera a la metanogénica, lo que resulta en el agotamiento de la alcalinidad. Aunque se detectaron limitaciones en la calibración inicial de los parámetros y una sobreestimación en la fase inicial del perfil de biogás, el modelo mostró una concordancia cualitativa sólida con los datos experimentales.

En conclusión, la implementación del gemelo digital basado en el modelo AM2 valida una estructura capaz de anticipar el comportamiento del biorreactor en condiciones críticas. Este trabajo establece las bases para el desarrollo de sistemas de control predictivo más sofisticados, demostrando que la integración de modelado matemático y herramientas IoT accesibles es una vía viable para optimizar la estabilidad y eficiencia en la producción de bioenergía.

2. Componentes del Sistema

El sistema experimental es un Biorreactor de Lote Secuencial (SBR) con un volumen total de 1 L (0.6 L de inóculo de lodo anaerobio proveniente de una planta de tratamiento de aguas residuales). El mezclado se realiza mediante una turbina de aspas inclinadas (PBT) basada en los principios de McCabe et al. (Operaciones Unitarias).

La instrumentación y control utiliza los siguientes componentes:

Régimen de alimentación: 12 pulsos por día de 10 mL cada uno (cada 2 horas) con una solución de azúcar al 100 g/L (DQO del influente ≈ 100 g/L), manteniendo estable el volumen de operación mediante activación simultánea de las bombas de alimentación y purga.

3. Diagrama de Conexiones del Circuito

La correcta conexión de los componentes es crucial para el funcionamiento del sistema. Se utilizó una topología de alimentación aislada para separar los componentes de potencia (motor, calentador) de los componentes de lógica/sensores, minimizando el ruido eléctrico.

Desde Pin/Terminal Hacia Pin/Terminal Notas y Propósito
Alimentación de Potencia (24V)
Fuente 24V+ (Positivo)Relé (Calentador)COM1Entrada de 24V para el calentador.
Fuente 24V+ (Positivo)Relé (Bomba Gas)COM2Entrada de 24V para la bomba de gas.
Fuente 24V+ (Positivo)L298NTerminal 12VAlimentación de POTENCIA para el motor.
Fuente 24V- (Negativo)ESP32GNDPunto de Tierra Común Central.
Alimentación de Lógica (5V)
Fuente 5V+ (Positivo)ESP32VINAlimentación principal para ESP32 y sensores.
Fuente 5V+ (Positivo)L298NTerminal 5VAlimentación de LÓGICA para el L298N.
Fuente 5V- (Negativo)ESP32GNDConecta la tierra de 5V al punto común.
Actuadores y Señales de Control
Relé (Calentador)NO1Calentador (+)PositivoSalida de 24V controlada al calentador.
Relé (Bomba Gas)NO2Bomba Gas (+)PositivoSalida de 24V controlada a la bomba.
L298NOUT1 / OUT2Motor DCTerminalesSalidas de potencia para el motor.
ESP32GPIO 25Relé (Calentador)IN1Señal de control para el calentador.
ESP32GPIO 13Relé (Bomba Gas)IN2Señal de control para la bomba de gas.
ESP32GPIO 26L298NIN1Control de dirección del motor.
ESP32GPIO 27L298NIN2Control de dirección del motor.
ESP32GPIO 14L298NENAControl de velocidad del motor (PWM).
Sensores y Periféricos
ESP32GPIO 32Sensor Nivel AltoPin de SeñalDetecta cuando el gas ha llenado el volumen.
ESP32GPIO 33Sensor Nivel BajoPin de SeñalDetecta cuando la bomba ha vaciado el gas.
ESP323V3Sensores NivelPin ComúnAlimentación para cerrar el circuito de los sensores de nivel.
ESP32GPIO 23Sensor TempDATALínea de datos One-Wire.
Sensor pH (Módulo)PoADS1115A0Señal de voltaje del pH al ADC.
ESP32GPIO 22ADS1115 / OLEDSCLLínea de reloj del bus I2C (compartida).
ESP32GPIO 21ADS1115 / OLEDSDALínea de datos del bus I2C (compartida).

4. El modelo AM2

El modelo se basa en el trabajo de Bernard et al. (2001) y describe la interacción entre las poblaciones bacterianas acidogénicas y metanogénicas mediante un sistema de 6 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 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 actualiza automáticamente el pH inicial usando el último valor medido por el sensor y recalcula el carbono inorgánico disuelto (C) mediante el equilibrio carbónico: C = B · (1 + 10−pH / Kb), donde B = Z − S₂.

El reactor opera en modo SBR discreto: entre pulsos solo ocurren las reacciones biológicas; en cada pulso se aplica una mezcla instantánea con factor de dilución f = V_pulso / V_total. Se incluye inhibición por pH sobre las metanogénicas (activa bajo pH 7, nula bajo pH 5.5). Integración numérica RK4 con paso h = 0.001 días, horizonte de 14 días.

5. Análisis de laboratorio

La validación de los estados estimados por el gemelo digital se realizó mediante métodos químicos convencionales:

  1. Alcalinidad Total (Z): titulación potenciométrica con ácido clorhídrico (HCl) 0.1 M hasta pH final 4.3.
  2. Ácidos Grasos Volátiles (S₂): titulación con hidróxido de sodio (NaOH) 0.1 M para cuantificar ácidos orgánicos acumulados.
  3. Sólidos: pruebas de Sólidos Volátiles (SV) y Sólidos Suspendidos Volátiles (SSV) para estimar la biomasa activa inicial.

6. Integración software

La arquitectura conecta el ESP32 con este dashboard mediante Google Sheets. El ESP32 envía telemetría (pH, temperatura, volumen de gas) por HTTP POST; un Apps Script la registra y guarda el comando de control en una columna adicional. Este dashboard, escrito en JavaScript puro, ejecuta la simulación AM2 en tiempo real con cuatro gráficas (pH, biogás, sustratos, biomasas) y recalibra desde la hoja "Condiciones" cada vez que se carga.

Existe además una versión en Python del mismo modelo (AM2py.py) con SciPy solve_ivp y método LSODA, útil para validación numérica y ajuste de parámetros. El estado "CRÍTICO" generado por el modelo se envía de vuelta a Google Sheets; el ESP32 lo lee cada 15 minutos y, si está activo, dispara la bomba alcalina por 10 segundos.

7. 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 = 2 * 3600 * 1000   # 2 horas (12 pulsos/día)
DURACION_BOMBAS_SBR_MS = 15000        # Calibración manual: 15 s entregan 10 mL de pulso
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 2 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)