Panel de instrumentos en tiempo real del equipo: transmisión del estado de la incubadora mediante WebSocket
Al finalizar este tema
Podrán crear directamente un panel de instrumentos que transmita en tiempo real los valores de temperatura, CO₂ y pH que genera cada segundo una incubadora de cultivo celular, integrando el WebSocket, el estado de React y el búfer de series temporales aprendidos en el libro de texto. Pasarán del mundo en el que solo conocían las solicitudes y respuestas HTTP al ámbito donde se procesan los datos enviados primero por el servidor.
Este texto es un ejemplo genérico con fines educativos. El monitoreo de cultivos celulares se ha tomado como tema porque es una tarea de gestión común en los laboratorios.
"¡Lo supe hasta la mañana siguiente!" — Las limitaciones del sondeo
Imaginen que han colocado una cepa celular valiosa en una incubadora de cultivo celular. La concentración de CO₂ de la incubadora debe mantenerse en un 5 %. Sin embargo, durante la noche, alguien dejó entreabierta la puerta y la concentración de CO₂ disminuyó bruscamente. A la mañana siguiente, descubren que todas las células han muerto.
Para evitar este tipo de accidentes, se necesita un monitoreo en tiempo real: un sistema que actualice los valores cada segundo y envíe una alerta inmediata si detecta anomalías.
Su primer intento podría ser el siguiente:
// Enfoque ingenuo mediante polling
setInterval(async () => {
const res = await fetch("/api/incubator");
const data = await res.json();
updateChart(data);
}, 1000);Consulta al servidor cada segundo. Este método funciona, pero presenta problemas importantes.
- Carga de red: cada segundo se realiza una solicitud y respuesta HTTP, lo que implica un intercambio constante de encabezados. La mayoría de las solicitudes se desperdician porque no hay "datos nuevos".
- Latencia: aunque el incubador detecte una caída brusca de CO₂ 0,1 segundos antes, la pantalla mostrará valores normales hasta el siguiente momento de consulta (un máximo de 1 segundo después).
- Dificultad para escalar a varios dispositivos: el monitoreo de 5 incubadoras implica 5 solicitudes por segundo. Al aumentar la escala, el servidor se verá sobrecargado por la frecuencia de las consultas.
La solución a estos problemas es WebSocket. Se trata de una comunicación en la que el servidor envía los datos al cliente cada vez que hay datos nuevos, sin necesidad de un intercambio de solicitud y respuesta. En este artículo, construiremos este principio desde cero.
Primero, veamos el producto terminado (ejecutemos la caja negra)
El sistema que vamos a construir tiene la siguiente apariencia:
┌────────────────┐ WebSocket ┌──────────────────┐
│ Servidor Python │ ═══════════════════════════ │ Navegador React │
│ (incubadora sim.)│ {temp, co2, pH, timestamp} │ (dashboard) │
│ │ ← varios envíos por segundo │ │
└────────────────┘ └──────────────────┘El servidor envía este mensaje varias veces por segundo.
{"temp": 37.02, "co2": 4.98, "pH": 7.35, "timestamp": 1720000000.5}
{"temp": 37.03, "co2": 4.97, "pH": 7.35, "timestamp": 1720000000.7}
{"temp": 37.01, "co2": 4.98, "pH": 7.36, "timestamp": 1720000000.9}El navegador recibe estos valores y mantiene una ventana de los últimos 60 segundos, mostrando en tiempo real tres gráficos de líneas. Si el nivel de CO₂ supera el rango de seguridad (4.5~5.5), aparece un banner de advertencia en la parte superior de la pantalla.
Sin necesidad de sondeos ni latencia: la pantalla reacciona en cuanto el servidor genera los datos.
¿De qué componentes está compuesta esta herramienta? (despiece)
Dashboard de equipos en tiempo real (full stack)
┌──────────────────────────────────────────────────────┐
│ │
│ [Lado del servidor] [Lado del cliente] │
│ │
│ ┌─────────────────┐ ┌────────────────┐ │
│ │ Simulación incubadora │ │ Conexión WebSocket │ │
│ │ (initial full) │ │ (recepción → React)│ │
│ └─────────┬───────┘ └────────┬───────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────┐ ┌────────────────┐ │
│ │ Servidor WebSocket │ ────────► │ Búfer temporal │ │
│ │ pieza: websocket │ │ (últimos 60 s) │ │
│ └─────────────────┘ └────────┬───────┘ │
│ ★ Lo construyes tú │ │
│ ▼ │
│ ┌────────────────┐ │
│ │ Gráfico React │ │
│ │ + aviso │ │
│ └────────────────┘ │
│ ★ Lo construyes tú│
└──────────────────────────────────────────────────────┘| Component | Where you learned it | What this tool does |
|---|---|---|
| HTML/CSS/React basics | html-css-react-basics | Page structure and component rendering |
| WebSocket | websocket-basics | Comunicación push del servidor al cliente |
| Estado de React | react-state | Conecta los datos recibidos con la pantalla |
| Búfer de series temporales | time-series-buffer | Conserva solo la ventana reciente y descarta los valores antiguos |
📌 Si es la primera vez que ves estos conceptos (enlaces introductorios anteriores)
Los conceptos nuevos que implementarás directamente son tres: un servidor WebSocket, el estado de React y un búfer circular de series temporales. La estructura básica de HTML/CSS se proporciona ya preparada. Son solo tres elementos, dentro del límite de carga cognitiva.
Step 1: Build — Mock Incubator Server ★ (WebSocket server)
✍️ Section to fill in directly. Component = WebSocket server (Python). The mock incubator pushes status data multiple times per second.
Use Python’s websockets library. This is a complete server that can run locally.
# server.pyimport asyncioimport jsonimport timeimport random
import websockets # pip install websockets
class MockIncubator: """Incubadora simulada: genera temperatura, CO2 y pH con ruido.""" def __init__(self, target_temp=37.0, target_co2=5.0, target_ph=7.35): self.target_temp = target_temp self.target_co2 = target_co2 self.target_ph = target_ph # Estado que simula una puerta ligeramente abierta self.door_open = False
def sample(self) -> dict: # Estado normal: valor objetivo ± ruido pequeño temp_noise = 0.15 co2_noise = 0.10 if not self.door_open else 0.8 ph_noise = 0.02 return { "temp": round(self.target_temp + random.gauss(0, temp_noise), 3), "co2": round(self.target_co2 + random.gauss(0, co2_noise), 3), "pH": round(self.target_ph + random.gauss(0, ph_noise), 3), "timestamp": round(time.time(), 3), "door_open": self.door_open, }
incubator = MockIncubator()
async def stream_handler(websocket): """Esta función se invoca por cada cliente nuevo.""" print(f"[+] Cliente conectado: {websocket.remote_address}") try: while True: data = incubator.sample() await websocket.send(json.dumps(data)) await asyncio.sleep(0.5) # Dos envíos por segundo except websockets.ConnectionClosed: print("[-] Cliente desconectado")
async def main(): async with websockets.serve(stream_handler, "localhost", 8765): print("Servidor iniciado: ws://localhost:8765") await asyncio.Future() # forever
if __name__ == "__main__": asyncio.run(main())El aspecto clave de este código es la combinación de sleep y while True dentro de send. En cada iteración, se genera una nueva muestra y se envía al cliente. No se espera una solicitud, a diferencia de HTTP.
Validación mediante pruebas locales:
# test_server.py — simula un cliente para comprobar que el servidor envía correctamenteimport asyncioimport jsonimport websockets
async def sanity_check(): async with websockets.connect("ws://localhost:8765") as ws: # Recibe cinco mensajes messages = [] for _ in range(5): raw = await ws.recv() messages.append(json.loads(raw)) # Verificación assert len(messages) == 5 for m in messages: assert set(m.keys()) >= {"temp", "co2", "pH", "timestamp"} assert 35 < m["temp"] < 39 # Intervalo normal assert 3 < m["co2"] < 7 assert 6.8 < m["pH"] < 8.0 # Los timestamps aumentan en orden cronológico times = [m["timestamp"] for m in messages] assert all(times[i] <= times[i+1] for i in range(len(times)-1))
# asyncio.run(sanity_check()) # Ejecutar cuando el servidor esté activo🔎 ¿En qué se diferencia WebSocket del sondeo? (Drawee — websocket-basics) HTTP repite cada vez el ciclo de solicitud → respuesta → cierre de conexión. WebSocket es un canal bidireccional que permanece activo una vez establecida la conexión. El servidor puede enviar datos en cualquier momento y el cliente también puede hacerlo en cualquier instante. Es el estándar para chat, juegos y monitoreo en tiempo real.
🤔 Prompt de autoexplicación He incluido
await asyncio.sleep(0.5)dentro dewhile Truea propósito. ¿Qué ocurriría si lo eliminamos? ¿Cuál sería el uso de CPU del servidor? ¿Cuántos mensajes recibiría el cliente por segundo? (Pista: sinsleep, la CPU alcanza el 100% y se reciben miles de mensajes por milisegundo)
Paso 2 de creación — Búfer de serie temporal ★ (time-series-buffer)
✍️ Sección para completar manualmente. Componente = búfer de serie temporal circular. Mantiene solo los últimos N puntos; los valores antiguos se descartan automáticamente.
Los paneles de control en tiempo real no necesitan almacenar todos los datos históricos. Basta con observar la ventana de los últimos 60 segundos. Si llegan 2 datos por segundo, una ventana de 60 segundos equivale a un máximo de 120 puntos.
El enfoque más simple: seguir utilizando push en un array de JavaScript y cortar el inicio ocasionalmente con shift.
// Búfer ingenuo: tiene problemas
let buffer = [];
function push(point) {
buffer.push(point);
if (buffer.length > 120) buffer.shift();
}Este enfoque funciona, pero tiene un problema. Array.shift() es O(n): al eliminar elementos del inicio del arreglo, todos los elementos restantes se desplazan una posición. Con el tiempo, este costo de desplazamiento de O(n) en cada operación de inserción se acumula.
Se logra una operación de inserción/eliminación (push/pop) de O(1) mediante un búfer circular.
🔎 ¿Qué es un búfer circular? (de Drauer — time-series-buffer) Se utiliza un arreglo de tamaño fijo como si fuera un círculo. Un único índice de escritura realiza un seguimiento de dónde se debe almacenar el siguiente valor. Al llegar al final del arreglo, se vuelve al inicio y sobrescribe los datos antiguos. Esta estructura permite que tanto la inserción como la eliminación sean operaciones de O(1). Se utiliza con frecuencia en el procesamiento de audio, los registros circulares y los gráficos en tiempo real.
// buffer.ts
export class TimeSeriesBuffer<T> {
private data: (T | null)[];
private writeIdx = 0;
private size = 0;
constructor(public capacity: number) {
this.data = new Array(capacity).fill(null);
}
push(value: T): void {
this.data[this.writeIdx] = value;
this.writeIdx = (this.writeIdx + 1) % this.capacity;
this.size = Math.min(this.size + 1, this.capacity);
}
toArray(): T[] {
// Devuelve un array ordenado cronológicamente
if (this.size < this.capacity) {
// Aún no está lleno: toma size elementos desde el principio
return this.data.slice(0, this.size) as T[];
}
// Está lleno: devuelve desde writeIdx y continúa desde el principio
return [
...this.data.slice(this.writeIdx),
...this.data.slice(0, this.writeIdx),
] as T[];
}
}Verificación:
// buffer.test.ts
import { TimeSeriesBuffer } from "./buffer";
const buf = new TimeSeriesBuffer<number>(5);
[1, 2, 3].forEach(v => buf.push(v));
console.assert(JSON.stringify(buf.toArray()) === "[1,2,3]");
[4, 5].forEach(v => buf.push(v));
console.assert(JSON.stringify(buf.toArray()) === "[1,2,3,4,5]");
// Capacidad superada: sobrescribe el elemento más antiguo
[6, 7].forEach(v => buf.push(v));
console.assert(JSON.stringify(buf.toArray()) === "[3,4,5,6,7]");Se ha cambiado O(n) shift() a O(1) incremento de writeIdx. El coste de la operación push se mantiene constante, incluso después de varias horas de procesamiento de datos.
🤔 Pregunta para la autoexplicación ¿Por qué
toArray()es O(n)? Si la operación push es O(1), ¿por qué la operación de retorno es O(n)? ¿Podría esto ser un problema en la renderización de gráficos en tiempo real? (Pista: dado que la renderización se realiza a 60 Hz, un coste de O(120) por fotograma es insignificante).
Paso 3: Creación — Hooks de estado de React ★ (react-state)
✍️ Sección para completar directamente. Componente = Estado de React + useEffect. Almacenar los valores recibidos a través de WebSocket en un búfer y mostrarlos en la pantalla.
El principio clave para gestionar datos en tiempo real con React es la combinación de useState + useEffect.
- useState: La "instantánea" de la "ventana actual" que se va a renderizar en la pantalla.
- useEffect: Gestión del ciclo de vida de la conexión y desconexión de WebSocket.
// useIncubatorStream.ts
import { useEffect, useState, useRef } from "react";
import { TimeSeriesBuffer } from "./buffer";
interface Sample {
temp: number;
co2: number;
pH: number;
timestamp: number;
door_open?: boolean;
}
export function useIncubatorStream(wsUrl: string, windowSize: number = 120) {
const [samples, setSamples] = useState<Sample[]>([]);
const [connected, setConnected] = useState(false);
const bufferRef = useRef(new TimeSeriesBuffer<Sample>(windowSize));
useEffect(() => {
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log("WebSocket conectado");
setConnected(true);
};
ws.onmessage = (event) => {
const data: Sample = JSON.parse(event.data);
bufferRef.current.push(data);
// Entrega el nuevo snapshot al estado de React
setSamples(bufferRef.current.toArray());
};
ws.onclose = () => {
console.log("WebSocket cerrado");
setConnected(false);
};
// Limpieza: cierra la conexión cuando se desmonta el componente
return () => {
ws.close();
};
}, [wsUrl, windowSize]);
return { samples, connected };
}Las características clave de este hook son las siguientes:
bufferRef(useRef): El búfer circular debe mantenerse independiente de las re-renderizaciones del componente. Se envuelve conuseRef.setSamples: Devuelve una nueva matriz en cada mensaje para provocar que React realice una re-renderización. La referencia de la matriz debe cambiar para que React detecte la modificación.- Función de limpieza: Cuando el componente se desmonta, es obligatorio llamar a
ws.close(). De lo contrario, se acumulan conexiones inactivas.
🔎 useRef vs useState: cuándo usar cada uno (drawer — react-state) useState cuando afecta a la visualización (provoca una re-renderización al cambiar). useRef cuando solo se necesita para cálculos internos o mantener referencias (no provoca una re-renderización al cambiar). El búfer circular en sí utiliza useRef, mientras que las instantáneas que son el objetivo de renderizado del gráfico utilizan useState; esta separación es clave para el rendimiento.
🤔 Pregunta para la autoexplicación ¿Qué ocurriría si colocara
bufferRefdirectamente dentro del componente comolet buffer = new TimeSeriesBuffer(...)? (Pista: se crea un nuevo búfer en cada renderizado, por lo que todos los datos históricos se perderían).
Paso 4: creación — ensamblaje de la interfaz de usuario del panel de control ★
Ahora, la última capa. Dibujamos las muestras obtenidas del hook en el gráfico real y en la barra de alertas.
// IncubatorDashboard.tsx
import { useIncubatorStream } from "./useIncubatorStream";
function isCritical(latest: any): string | null {
if (!latest) return null;
if (latest.co2 < 4.5 || latest.co2 > 5.5) return `CO2 anómalo: ${latest.co2}%`;
if (latest.temp < 36.5 || latest.temp > 37.5) return `Temperatura anómala: ${latest.temp}°C`;
if (latest.pH < 7.2 || latest.pH > 7.5) return `pH anómalo: ${latest.pH}`;
return null;
}
export function IncubatorDashboard() {
const { samples, connected } = useIncubatorStream("ws://localhost:8765");
const latest = samples[samples.length - 1];
const warning = isCritical(latest);
return (
<div className="dashboard">
<header>
<h1>Incubadora A</h1>
<span className={connected ? "connected" : "disconnected"}>
{connected ? "Conectado en tiempo real" : "Desconectado"}
</span>
</header>
{warning && (
<div className="warning-banner">
⚠️ Peligro: {warning}
</div>
)}
<section className="metrics">
<MetricCard label="Temperatura" value={latest?.temp} unit="°C" target={37.0} />
<MetricCard label="CO2" value={latest?.co2} unit="%" target={5.0} />
<MetricCard label="pH" value={latest?.pH} unit="" target={7.35} />
</section>
<section className="charts">
<LineChart data={samples} field="temp" color="#ff6b6b" />
<LineChart data={samples} field="co2" color="#4ecdc4" />
<LineChart data={samples} field="pH" color="#95e1d3" />
</section>
</div>
);
}MetricCard y LineChart son componentes estándar, por lo que se omite su implementación detallada. Lo importante es que este único hook funciona como fuente de datos para todo el panel.
Verificación (resumen de las pruebas de integración):
// integration.test.ts (concepto de prueba de integración con Playwright/Cypress)
async function test_dashboard_flow() {
// 1. Iniciar el servidor (fixture de prueba)
// 2. Cargar el dashboard en el navegador
// 3. Deben aparecer al menos cinco puntos en el gráfico en cinco segundos
await waitFor(() => expect(chart.pointCount()).toBeGreaterThan(5));
// 4. La conexión WebSocket sigue activa
expect(document.querySelector(".connected")).toBeInTheDocument();
// 5. Al establecer artificialmente door_open=true aparece el aviso
await triggerDoorOpen(server);
await waitFor(() => expect(document.querySelector(".warning-banner")).toBeInTheDocument());
}Unificar los componentes: estructura del panel de control completo
Al organizar el sistema en su conjunto, se obtiene lo siguiente.
Lado del servidor (Python):
MockIncubator → repetición de sample() → WebSocket.send()
Archivo: server.py
Lado del cliente (React/TypeScript):
useIncubatorStream(wsUrl)
├── Conexión WebSocket (useEffect)
├── TimeSeriesBuffer (useRef, O(1) push)
└── Estado samples (useState, activa el renderizado)
IncubatorDashboard
├── Indicador del estado de conexión
├── Aviso (función isCritical)
├── MetricCard × 3 (valor actual)
└── LineChart × 3 (ventana de 60 segundos)
Archivos: useIncubatorStream.ts, buffer.ts, IncubatorDashboard.tsxEsta herramienta es una versión simplificada de los paneles de control en tiempo real que se utilizan en sistemas SCADA (Supervisión y Adquisición de Datos) industriales, LIMS (Sistemas de Gestión de la Información de Laboratorio) y otros. Las herramientas prácticas simplemente añaden las siguientes funciones: autenticación, enrutamiento a múltiples dispositivos, almacenamiento en bases de datos de series temporales (InfluxDB) y enrutamiento de alertas (Slack/correo electrónico).
Análisis exhaustivo del rendimiento: ¿por qué esta arquitectura?
Método de polling (fetch cada segundo):
- Carga del servidor: N clientes × 1 solicitud por segundo = N req/s
- Latencia: 0,5 segundos de media (la mitad del intervalo de polling)
- Ancho de banda: sobrecarga de cabeceras HTTP en cada solicitud (~500 bytes)
Método WebSocket (push continuo):
- Carga del servidor: una conexión persistente por cliente (un socket TCP)
- Latencia: del orden de milisegundos (solo el viaje de ida y vuelta por la red)
- Ancho de banda: solo el payload, sin cabeceras (~80 bytes)Comparación numérica:
| Escenario | Sondeo (cada 1 s) | WebSocket |
|---|---|---|
| 100 clientes · solicitudes/seg | 100 solicitudes/seg | 0 solicitudes/seg |
| Retraso en la detección de anomalías (promedio) | 500 ms | ~10 ms |
| Ancho de banda de carga útil (por segundo) | 100 × 500 B = 50 KB/s | 100 × 80 B × 2 = 16 KB/s |
Con WebSocket, el retraso de detección se reduce a una quincuagésima parte y el ancho de banda, aproximadamente a un tercio. A medida que se escalan más dispositivos, la diferencia aumenta.
Existen otras opciones (reflexión en varios pasos)
- Eventos enviados por el servidor (SSE): una alternativa más sencilla que WebSocket. Solo admite la comunicación unidireccional, del servidor al cliente, pero al ejecutarse sobre HTTP, es fácil atravesar los servidores proxy y los firewalls. Si el cliente no necesita enviar comandos al servidor (monitoreo simple), SSE es suficiente.
- Broker MQTT: protocolo estándar para IoT. Conecta varios dispositivos a un broker y el panel de control también se suscribe al broker. Es preferible cuando hay decenas o cientos de dispositivos. Existen soluciones de código abierto como Mosquitto y HiveMQ.
- Uso combinado con bases de datos de series temporales: un panel en tiempo real no conserva los registros históricos. Al registrar simultáneamente en InfluxDB, TimescaleDB, Prometheus, etc., se obtienen tanto la vista en tiempo real como el análisis histórico.
- Lógica de reconexión: nuestro hook no realiza reconexiones si el servidor se desconecta. En la práctica, es esencial implementar una lógica de reconexión con retroceso exponencial. Se incluye un programa de reintentos dentro de
ws.onclose. - Ampliación de la entrega de alertas: mostrar solo banners en la pantalla no permite responder a incidentes durante la noche. Para que sea realmente útil, se deben enviar alertas a canales externos como Slack webhook, SMS, PagerDuty, etc.
Clave: "El servidor envía datos, el búfer mantiene la ventana y el estado conecta la pantalla". Cuando estas tres capas conocen su propia responsabilidad, se revela la arquitectura de un sistema en tiempo real. Lo que acabas de crear es la materialización de esa arquitectura.
Siguientes pasos (enlaces de salida en la parte inferior)
- Detalles del protocolo WebSocket → Principios de WebSocket
- Variaciones del búfer circular → Búfer de series temporales
- Combinación con la visualización creada anteriormente → Mapa de calor RNA-seq
Pruébalo tú mismo (problema independiente)
- Lógica de reconexión: agrega al hook una lógica que reintente la conexión cada 1, 2, 4 y 8 segundos (retroceso exponencial) si se interrumpe el WebSocket.
- Múltiples dispositivos: Amplíe el panel de control para supervisar 5 incubadoras. Establezca una conexión WebSocket independiente para cada dispositivo. Observe de dónde proviene la carga en el rendimiento.
- Submuestreo: Si llegan 20 mensajes por segundo, desea limitar la renderización de la pantalla a 5 veces por segundo. Agregue debounce/throttle al hook.
- Desafío: Migración a SSE: Reimplemente el mismo sistema utilizando Server-Sent Events en lugar de WebSocket. ¿Cómo cambia la complejidad del código?
Resumen
Hemos resuelto el problema de "mostrar los datos en tiempo real de los dispositivos en la pantalla de forma inmediata" mediante tres componentes clave:
- WebSocket abrió un canal de comunicación donde el servidor envía los datos (sin necesidad de sondeos).
- El búfer circular de series temporales mantuvo la ventana más reciente con una inserción de complejidad O(1).
- React state (+ useRef) actuó como el puente preciso que conecta el flujo de datos con la renderización.
Los valores que emiten las incubadoras cada segundo ahora se muestran en la pantalla con una latencia de milisegundos. Se acabó la época en la que se descubrían incidentes al día siguiente. La advertencia aparece en la pantalla antes de que transcurran 5 segundos desde que se abre la puerta.
Este artículo es un ejemplo educativo general. Los paneles de control de laboratorios prácticos requieren la adición de autenticación, enrutamiento de múltiples dispositivos, bases de datos de series temporales y sistemas de notificación. La versión detallada puede ser construida sobre este esqueleto por ustedes mismos o delegarse a herramientas SCADA/LIMS validadas.