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. 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:

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:

  1. 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.
  2. Á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.
  3. 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)