Monitoreo Inteligente de Biorreactor con ESP32

Sistema IoT para la recolección y almacenamiento de datos de pH y temperatura.

Fecha: 3 de julio de 2024

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 y la temperatura de una solución acuosa. El sistema integra un motor para mezclar la solución a intervalos programados y muestra todos los datos relevantes en una pantalla OLED para una fácil visualización. 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 una calibración personalizada mediante una regresión polinómica para maximizar la exactitud de las mediciones.

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 "GND Común en Estrella", donde el terminal de tierra (GND) del módulo L298N sirve como punto de unión central para todas las tierras del circuito, garantizando una referencia de voltaje estable.

Desde Pin de Origen Conectado a... Pin de Destino Notas y Propósito
Adaptador 9V+ (Positivo)L298NTerminal 12VAlimentación principal del sistema.
Adaptador 9V- (Negativo)L298NTerminal GNDPunto de GND Común Central.
L298NTerminal 5VESP32VINAlimenta el ESP32 con 5V regulados por el L298N.
L298NTerminal GNDESP32GNDConecta la tierra del ESP32 al GND común.
L298NOUT1 / OUT2Motor DCTerminales 1 y 2Salidas de potencia para el motor.
L298NIN1ESP32GPIO 26Control de dirección del motor.
L298NIN2ESP32GPIO 27Control de dirección del motor.
L298NENAESP32GPIO 14Control de velocidad y encendido del motor (PWM).
Sensor pHV+L298NTerminal 5VAlimentación de 5V para el sensor de pH.
Sensor pHGNDL298NTerminal GNDConexión al GND común.
Sensor pHPoADS1115A0Envía la señal de voltaje analógica al ADC.
ADS1115VDDESP323V3Alimentación para la lógica del ADC.
ADS1115GNDESP32GNDConexión al GND común.
ADS1115SCLESP32GPIO 22Línea de reloj del bus I2C.
ADS1115SDAESP32GPIO 21Línea de datos del bus I2C.
OLED SSD1306VCCESP323V3Alimentación para la pantalla.
OLED SSD1306GNDESP32GNDConexión al GND común.
OLED SSD1306SCLESP32GPIO 22Comparte el bus I2C con el ADS1115.
OLED SSD1306SDAESP32GPIO 21Comparte el bus I2C con el ADS1115.
DS18B20 ModuleVCCESP323V3Alimentación para el sensor de temperatura.
DS18B20 ModuleGNDESP32GNDConexión al GND común.
DS18B20 ModuleDATAESP32GPIO 23Línea de datos del bus One-Wire.

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 = "INFINITUM4CA5_5"
WIFI_PASS = "B0l1graf0_N3gr0&2023"
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.")