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

1. Resumen del Proyecto

El objetivo de este proyecto fue desarrollar un sistema automatizado capaz de monitorear en tiempo real el pH, la temperatura y el volumen de gas producido en un biorreactor. El sistema integra un motor para mezclar la solución y un calentador para el control de temperatura, mostrando todos los datos relevantes en una pantalla OLED y en un dashboard web. El cerebro del sistema es un microcontrolador ESP32-WROOM-32D programado en MicroPython.

El proyecto se destaca por el uso de un conversor analógico-digital (ADC) externo de alta precisión (ADS1115) para obtener lecturas de pH confiables y un novedoso medidor de gas automatizado que proporciona datos de producción en tiempo real.

Foto del sistema del reactor montado

2. Componentes Utilizados

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. Calibración del Sensor de pH (Software)

La calibración se realiza por software para obtener la máxima precisión y flexibilidad, calculando la relación lineal entre el voltaje medido por el ADS1115 y el valor de pH real.

4.1. Procedimiento de Medición (Paso a Paso)

  1. Ejecutar el script: Cargar y ejecutar calibracion_ph.py en el ESP32 para ver las lecturas de voltaje en la consola de Thonny.
  2. Medir Punto 1 (pH 7.0): Sumergir la sonda en el buffer de pH 7.0, esperar a que el voltaje se estabilice y anotar el valor. Ejemplo: Voltaje_pH7 = 2.5120 V.
  3. Enjuagar: Limpiar la sonda con agua destilada para no contaminar la siguiente muestra.
  4. Medir Punto 2 (pH 4.0): Sumergir la sonda en el buffer de pH 4.0, esperar a que el voltaje se estabilice y anotar el valor. Ejemplo: Voltaje_pH4 = 3.2560 V.

4.2. Cálculo de la Fórmula de Calibración (Ejemplo)

Utilizamos la ecuación de la recta: pH = m * Voltaje + b

Cálculo de la Pendiente (m):

m = (pH2 - pH1) / (Voltaje2 - Voltaje1)
m = (4.0 - 7.0) / (3.2560 - 2.5120)
m = -3.0 / 0.744
m = -4.032

Cálculo de la Intersección (b):

b = pH1 - (m * Voltaje1)
b = 7.0 - (-4.032 * 2.5120)
b = 7.0 + 10.128
b = 17.128

Las constantes resultantes (PENDIENTE_PH y INTERSECCION_PH) se introducen en el código main.py para su uso en las mediciones reales.

5. Estructura y Ejecución del Software

5.1. Archivos en el ESP32 (MicroPython)

5.2. Archivos en la Computadora (Python)

5.3. Pasos para la Puesta en Marcha

  1. Preparar el Hardware: Ensamblar todo el circuito según la tabla de conexiones.
  2. Preparar el ESP32: Cargar todos los archivos y librerías necesarios en el ESP32 usando Thonny. Realizar la calibración del pH si es necesario.
  3. Preparar la Base de Datos: En la PC, ejecutar python crear_db.py una vez.
  4. Iniciar el Servidor: En la PC, ejecutar python servidor.py y dejar la terminal abierta. Asegurarse de que el firewall permite conexiones en el puerto 5000.
  5. Iniciar el Cliente: Encender o reiniciar el ESP32 con el código main.py. El dispositivo se conectará al WiFi y comenzará a enviar datos.
  6. Visualizar Resultados: Después de un tiempo, detener el servidor (Ctrl+C) y ejecutar python visualizar.py para ver las gráficas del experimento.

6. Códigos Completos del Proyecto

6.1. Código Principal del ESP32 (main.py)

# main.py (Versión Final Integrada)

# --- LIBRERÍAS ---
from machine import Pin, PWM, I2C
import time
import onewire, ds18x20
from ssd1306 import SSD1306_I2C
from ads1x15 import ADS1115
import network
import urequests
import ujson

# --- CONFIGURACIÓN DE PINES Y HARDWARE ---
# Motor
pin_motor_adelante = Pin(19, Pin.OUT)
pin_motor_atras = Pin(18, Pin.OUT)
pin_velocidad_ena = Pin(5)
pwm_motor = PWM(pin_velocidad_ena, freq=1000)

# I2C (Compartido por OLED y ADS1115)
i2c = I2C(1, scl=Pin(22), sda=Pin(21))

# Pantalla OLED
pantalla = SSD1306_I2C(128, 64, i2c)

# Sensor de Temperatura
pin_onewire = Pin(4)
ds_sensor = ds18x20.DS18X20(onewire.OneWire(pin_onewire))
try:
    roms = ds_sensor.scan()
    sensor_temp_encontrado = True
    print('Sensor de temperatura encontrado:', roms)
except:
    sensor_temp_encontrado = False
    print('Error: No se encontró el sensor de temperatura.')

# Sensor de pH (ADS1115)
try:
    ads = ADS1115(i2c)
    ads.set_gain(1) # Ganancia para ±4.096V
    sensor_ph_encontrado = True
    print('Sensor ADS1115 (pH) encontrado.')
except:
    sensor_ph_encontrado = False
    print('Error: No se encontró el sensor ADS1115.')

# --- CONSTANTES DE CALIBRACIÓN DE PH ---
# !!! IMPORTANTE: REEMPLAZA ESTOS VALORES CON LOS TUYOS !!!
PENDIENTE_PH = -4.032 
INTERSECCION_PH = 17.128

# --- CONFIGURACIÓN DE RED Y SERVIDOR ---
WIFI_SSID = "AAAAAA"
WIFI_PASS = "AAAA"
URL_SERVIDOR = "http://192.168.1.118:5000/datos"

# --- PARÁMETROS DE FUNCIONAMIENTO ---
VELOCIDAD_MEZCLA = 1000
DURACION_MEZCLA_SEG = 30
TIEMPO_ESPERA_SEG = 60 # Tiempo entre ciclos de mezcla

# --- FUNCIONES ---

def conectar_wifi():
    print("Conectando a la red WiFi...")
    wlan = network.WLAN(network.STA_IF)
    wlan.active(True)
    if not wlan.isconnected():
        wlan.connect(WIFI_SSID, WIFI_PASS)
        while not wlan.isconnected():
            print(".", end="")
            time.sleep(1)
    print("\n¡Conexión exitosa!")
    print("Configuración de red:", wlan.ifconfig())

def leer_ph_calibrado():
    if not sensor_ph_encontrado:
        return None
    try:
        raw_value = ads.read(channel=0)
        voltaje = raw_value * (4.096 / 32767.0)
        valor_ph = (PENDIENTE_PH * voltaje) + INTERSECCION_PH
        return valor_ph
    except Exception as e:
        print(f"Error leyendo el pH: {e}")
        return None

def enviar_datos_servidor(temperatura, ph_val):
    if not network.WLAN(network.STA_IF).isconnected():
        print("WiFi desconectado. No se pueden enviar datos.")
        return

    datos_a_enviar = {"temperatura": temperatura, "ph": ph_val}
    json_data = ujson.dumps(datos_a_enviar)
    
    try:
        print(f"Enviando a servidor: {json_data}")
        respuesta = urequests.post(URL_SERVIDOR, headers={'Content-Type': 'application/json'}, data=json_data)
        print(f"Respuesta: [{respuesta.status_code}] {respuesta.text}")
        respuesta.close()
    except Exception as e:
        print(f"Error al enviar datos: {e}")

def actualizar_pantalla(status, temperatura, ph_val):
    pantalla.fill(0)
    pantalla.text("Estado:", 0, 0)
    pantalla.text(status, 0, 10)
    temp_str = f"{temperatura:.1f} C" if temperatura is not None else "Error"
    ph_str = f"{ph_val:.2f}" if ph_val is not None else "Error"
    pantalla.text(f"Temp:{temp_str}", 0, 30)
    pantalla.text(f"pH:  {ph_str}", 0, 45)
    pantalla.show()

def mezclar(duracion, velocidad, temp, ph):
    actualizar_pantalla("Mezclando...", temp, ph)
    pin_motor_adelante.value(1)
    pin_motor_atras.value(0)
    pwm_motor.duty(velocidad)
    time.sleep(duracion)
    pwm_motor.duty(0)
    pin_motor_adelante.value(0)
    print("Mezcla terminada.")

# --- PROGRAMA PRINCIPAL ---
conectar_wifi()
actualizar_pantalla("Iniciando...", None, None)
time.sleep(2)

while True:
    temp_actual = None
    if sensor_temp_encontrado:
        try:
            ds_sensor.convert_temp()
            time.sleep_ms(750)
            temp_actual = ds_sensor.read_temp(roms[0])
        except:
            print("Error al leer la temperatura")
            temp_actual = None
            
    ph_actual = leer_ph_calibrado()

    actualizar_pantalla("En Espera", temp_actual, ph_actual)
    enviar_datos_servidor(temp_actual, ph_actual)
    
    print(f"Estado: En Espera. Temp: {temp_actual:.1f}C. pH: {ph_actual:.2f}. Próxima mezcla en {TIEMPO_ESPERA_SEG}s.")
    time.sleep(TIEMPO_ESPERA_SEG)
    
    mezclar(DURACION_MEZCLA_SEG, VELOCIDAD_MEZCLA, temp_actual, ph_actual)
        

6.2. Script de Calibración (calibracion_ph.py)

# calibracion_ph.py
from machine import Pin, I2C
from ads1x15 import ADS1115
import time

i2c = I2C(1, scl=Pin(22), sda=Pin(21))

try:
    ads = ADS1115(i2c)
    sensor_ph_encontrado = True
    print("Sensor ADS1115 encontrado. Listo para calibrar.")
    ads.set_gain(1) 
except Exception as e:
    sensor_ph_encontrado = False
    print(f"Error: No se encontró el sensor ADS1115. Verifica las conexiones. {e}")

if sensor_ph_encontrado:
    while True:
        try:
            raw_value = ads.read(channel=0)
            voltaje = raw_value * (4.096 / 32767.0)
            print(f"Valor Crudo ADC: {raw_value}, Voltaje Calculado: {voltaje:.4f} V")
            time.sleep(2)
        except KeyboardInterrupt:
            print("Calibración detenida.")
            break
        except Exception as e:
            print(f"Ocurrió un error: {e}")
            break
        

6.3. Scripts de la Computadora

crear_db.py

# crear_db.py
import sqlite3

DB_FILE = "sensores.db"

try:
    conn = sqlite3.connect(DB_FILE)
    cursor = conn.cursor()
    cursor.execute('''
    CREATE TABLE IF NOT EXISTS lecturas (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
        temperatura_c REAL,
        ph_valor REAL 
    );
    ''')
    print(f"Base de datos '{DB_FILE}' y tabla 'lecturas' verificadas/creadas exitosamente.")
except Exception as e:
    print(f"Ocurrió un error al crear la base de datos: {e}")
finally:
    if 'conn' in locals() and conn:
        conn.commit()
        conn.close()
        

servidor.py

# servidor.py
from flask import Flask, request, jsonify
import sqlite3

DB_FILE = "sensores.db"
app = Flask(__name__)

@app.route('/datos', methods=['POST'])
def recibir_datos():
    try:
        datos = request.get_json()
        temp = datos.get('temperatura')
        ph = datos.get('ph')
        print(f"Dato recibido -> Temp: {temp} °C, pH: {ph}")
        conn = sqlite3.connect(DB_FILE)
        cursor = conn.cursor()
        cursor.execute("INSERT INTO lecturas (temperatura_c, ph_valor) VALUES (?, ?)", (temp, ph))
        conn.commit()
        conn.close()
        return jsonify({"status": "ok"}), 200
    except Exception as e:
        print(f"Error procesando la petición: {e}")
        return jsonify({"status": "error en el servidor"}), 500

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000, debug=True)
        

visualizar.py

# visualizar.py
import sqlite3
import pandas as pd
import matplotlib.pyplot as plt

DB_FILE = "sensores.db"

try:
    conn = sqlite3.connect(DB_FILE)
    query = "SELECT timestamp, temperatura_c, ph_valor FROM lecturas ORDER BY timestamp"
    df = pd.read_sql_query(query, conn)
    print("Datos cargados exitosamente desde la base de datos.")
    print("Últimas 5 lecturas:")
    print(df.tail())
except Exception as e:
    print(f"Error al leer la base de datos: {e}")
    exit()
finally:
    if 'conn' in locals() and conn:
        conn.close()

if not df.empty:
    print("Generando la gráfica...")
    plt.style.use('ggplot')
    fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 8), sharex=True)
    fig.suptitle('Monitoreo del Biorreactor', fontsize=16)

    ax1.plot(df['timestamp'], df['temperatura_c'], marker='.', linestyle='-', color='r', label='Temperatura')
    ax1.set_ylabel('Temperatura (°C)')
    ax1.legend()
    ax1.grid(True)
    
    ax2.plot(df['timestamp'], df['ph_valor'], marker='.', linestyle='-', color='b', label='pH')
    ax2.set_ylabel('Valor de pH')
    ax2.set_xlabel('Fecha y Hora')
    ax2.legend()
    ax2.grid(True)
    
    fig.autofmt_xdate()
    
    plt.tight_layout(rect=[0, 0, 1, 0.96])
    plt.show()
else:
    print("No hay datos en la base de datos para graficar.")