Un gemelo digital de cinta transportadora muestra de un vistazo si una línea se mueve, a qué velocidad y dónde está atascada. En este tutorial construirás uno en el navegador con Three.js: los rodillos giran y los paquetes avanzan a la velocidad real de la cinta informada por MQTT, una señal de atasco detiene los paquetes y pone la cinta en rojo, y los operarios pueden enviar comandos de arranque, parada y reset a la línea. Un simulador en Python publica datos realistas de la cinta en el servidor MQTT público gratuito broker.connectmq.com, así que puedes construirlo y probarlo todo antes de conectar un PLC.
Por qué una cinta transportadora es una buena candidata a gemelo digital
Las cintas transportadoras están en todas partes en manufactura, almacenes, minería y logística, y fallan de formas visibles y costosas: una caja atascada en un punto de confluencia puede detener todo lo que hay aguas arriba. Las señales suelen estar ya disponibles en el PLC y se traducen de forma natural en movimiento:
| Señal | Origen típico | Unidad | Comportamiento del gemelo |
|---|---|---|---|
| Velocidad de la cinta | Realimentación del variador o encoder | m/s | Los rodillos giran, los paquetes avanzan |
| En marcha | Contactor del motor o estado del variador | booleano | Color de la cinta, movimiento sí/no |
| Atasco | Fotocélula bloqueada más tiempo que un umbral | booleano más zona | La cinta se pone roja, los paquetes se detienen en la zona del atasco |
| Contador de paquetes | Fotocélula en la descarga | recuento | Contador de producción |
| Corriente del motor | Variador | A | Opcional, aviso temprano de sobrecarga |
Como el comportamiento visual se deriva directamente de la física (un rodillo de radio r gira a v / r radianes por segundo), un gemelo de cinta transportadora es también una buena forma de aprender a controlar animaciones con unidades reales en lugar de números arbitrarios. Para una visión más amplia, consulta nuestra guía de tecnología de gemelo digital.
Estructura de topics
Elige un prefijo único; el ejemplo usa connectmq/demo/conv-7f3a:
| Topic | Dirección | Payload |
|---|---|---|
.../line1/conv1/telemetry |
De la línea al gemelo | {"speed":0.8,"running":true,"jam":false,"jamZone":null,"count":1532,"current":4.1} |
.../line1/conv1/cmd |
Del gemelo a la línea | START, STOP, RESET |
.../line1/conv1/status |
Línea, retenido, last will | online / offline |
Enviar un único objeto de telemetría combinado, en lugar de un topic por señal, garantiza que el gemelo nunca vea una mezcla incoherente (por ejemplo, running: true de un mensaje y speed: 0 de otro más antiguo). Las ventajas e inconvenientes de los topics combinados frente a los topics por señal se analizan en MQTT para gemelos digitales.
Paso 1: el simulador de la cinta
El simulador modela una cinta de tres zonas que funciona a 0,8 m/s, cuenta paquetes, se atasca de vez en cuando en una zona aleatoria y obedece comandos. Usa paho-mqtt 2.x (pip install "paho-mqtt>=2.0").
import json
import random
import time
import paho.mqtt.client as mqtt
P = "connectmq/demo/conv-7f3a/line1/conv1"
NOMINAL_SPEED = 0.8 # m/s
state = {"running": True, "jam": False, "jamZone": None, "speed": 0.0, "count": 0}
def on_connect(client, userdata, flags, reason_code, properties):
print("connected:", reason_code)
client.publish(f"{P}/status", "online", qos=1, retain=True)
client.subscribe(f"{P}/cmd", qos=1)
def on_message(client, userdata, msg):
cmd = msg.payload.decode().strip().upper()
print("command:", cmd)
if cmd == "START" and not state["jam"]:
state["running"] = True
elif cmd == "STOP":
state["running"] = False
elif cmd == "RESET":
state["jam"], state["jamZone"] = False, None
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id="conv-sim-7f3a")
client.will_set(f"{P}/status", "offline", qos=1, retain=True)
client.on_connect = on_connect
client.on_message = on_message
client.connect("broker.connectmq.com", 1883, keepalive=30)
client.loop_start()
while True:
if state["running"] and not state["jam"] and random.random() < 0.01:
state["jam"], state["jamZone"] = True, random.choice([1, 2, 3])
state["running"] = False # el PLC detiene la cinta ante un atasco
target = NOMINAL_SPEED if state["running"] else 0.0
state["speed"] += (target - state["speed"]) * 0.5 # rampa como haría un variador
if state["speed"] > 0.05 and random.random() < state["speed"] / 1.6:
state["count"] += 1
payload = {
"speed": round(state["speed"], 3),
"running": state["running"],
"jam": state["jam"],
"jamZone": state["jamZone"],
"count": state["count"],
"current": round(1.0 + state["speed"] * 4 + random.uniform(-0.1, 0.1), 2),
}
client.publish(f"{P}/telemetry", json.dumps(payload), qos=0, retain=True)
time.sleep(0.5)
Dos mensajes por segundo son holgados para el servidor público, que permite hasta 10 mensajes por segundo por cliente.
Paso 2: construye la escena del gemelo digital de la cinta
Guárdalo como index.html y sírvelo con npx serve .. Los rodillos se renderizan con un InstancedMesh, lo que hace que dibujar una cinta larga sea barato.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Conveyor Digital Twin</title>
<style>
body { margin: 0; overflow: hidden; background: #0f172a; font-family: system-ui, sans-serif; }
#ui { position: absolute; top: 10px; left: 10px; color: #fff; background: rgba(0,0,0,.6);
padding: 10px 12px; border-radius: 6px; font-size: 14px; }
#ui button { margin-right: 6px; margin-top: 8px; }
</style>
<script type="importmap">
{ "imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/",
"mqtt": "https://unpkg.com/mqtt@5.10.1/dist/mqtt.esm.js"
} }
</script>
</head>
<body>
<div id="ui">
<div id="txt">Connecting...</div>
<button data-cmd="START">Start</button><button data-cmd="STOP">Stop</button><button data-cmd="RESET">Reset jam</button>
</div>
<script type="module">
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import mqtt from "mqtt";
const P = "connectmq/demo/conv-7f3a/line1/conv1";
const LENGTH = 12, WIDTH = 1.2, ROLLER_R = 0.06, ROLLER_PITCH = 0.25, ZONES = 3;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0f172a);
const camera = new THREE.PerspectiveCamera(45, innerWidth / innerHeight, 0.1, 100);
camera.position.set(4, 5, 9);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
document.body.appendChild(renderer.domElement);
new OrbitControls(camera, renderer.domElement);
scene.add(new THREE.HemisphereLight(0xffffff, 0x334155, 1.3));
const sun = new THREE.DirectionalLight(0xffffff, 1.2); sun.position.set(3, 10, 4); scene.add(sun);
// Bastidor
const frameMat = new THREE.MeshStandardMaterial({ color: 0x64748b });
for (const z of [-WIDTH / 2 - 0.05, WIDTH / 2 + 0.05]) {
const rail = new THREE.Mesh(new THREE.BoxGeometry(LENGTH, 0.15, 0.08), frameMat);
rail.position.set(0, 0.8, z); scene.add(rail);
}
// Rodillos como instancias
const count = Math.floor(LENGTH / ROLLER_PITCH);
const rollerGeo = new THREE.CylinderGeometry(ROLLER_R, ROLLER_R, WIDTH, 16);
rollerGeo.rotateX(Math.PI / 2); // eje a lo largo de Z
const rollerMat = new THREE.MeshStandardMaterial({ color: 0x94a3b8, metalness: 0.6, roughness: 0.4 });
const rollers = new THREE.InstancedMesh(rollerGeo, rollerMat, count);
const dummy = new THREE.Object3D();
const rollerX = (i) => -LENGTH / 2 + ROLLER_PITCH / 2 + i * ROLLER_PITCH;
scene.add(rollers);
// Marcadores de zona (se iluminan en rojo ante un atasco)
const zoneMats = [];
for (let z = 0; z < ZONES; z++) {
const m = new THREE.MeshStandardMaterial({ color: 0x1e293b, emissive: 0x000000 });
const strip = new THREE.Mesh(new THREE.BoxGeometry(LENGTH / ZONES - 0.1, 0.02, 0.1), m);
strip.position.set(-LENGTH / 2 + (z + 0.5) * (LENGTH / ZONES), 0.89, WIDTH / 2 + 0.05);
scene.add(strip); zoneMats.push(m);
}
// Paquetes
const parcelMat = new THREE.MeshStandardMaterial({ color: 0xc08a4a });
const parcels = [];
for (let i = 0; i < 8; i++) {
const p = new THREE.Mesh(new THREE.BoxGeometry(0.6, 0.4, 0.6), parcelMat);
p.position.set(-LENGTH / 2 + i * 1.5, 0.8 + ROLLER_R + 0.2, 0);
scene.add(p); parcels.push(p);
}
// ---------- Estado ----------
const s = { speed: 0, running: false, jam: false, jamZone: null, count: 0, online: false, at: 0 };
let shownSpeed = 0, rollerAngle = 0;
// ---------- MQTT ----------
const client = mqtt.connect("wss://broker.connectmq.com:8084/mqtt", {
clientId: "conv-twin-" + Math.random().toString(16).slice(2, 10),
protocolVersion: 5,
});
const dec = new TextDecoder();
client.on("connect", () => client.subscribe(`${P}/#`, { qos: 1 }));
client.on("message", (topic, payload) => {
const text = dec.decode(payload);
if (topic.endsWith("/status")) { s.online = text === "online"; return; }
if (!topic.endsWith("/telemetry")) return;
try { Object.assign(s, JSON.parse(text), { at: performance.now() }); } catch {}
});
document.querySelectorAll("button").forEach((b) =>
b.addEventListener("click", () => client.publish(`${P}/cmd`, b.dataset.cmd, { qos: 1 })));
// ---------- Animación ----------
const clock = new THREE.Clock();
const txt = document.getElementById("txt");
renderer.setAnimationLoop(() => {
const dt = Math.min(clock.getDelta(), 0.1);
const stale = performance.now() - s.at > 3000;
const v = stale ? 0 : s.speed;
shownSpeed += (v - shownSpeed) * (1 - Math.exp(-dt * 6));
// Rodillos: velocidad angular = v / r
rollerAngle -= (shownSpeed / ROLLER_R) * dt;
for (let i = 0; i < count; i++) {
dummy.position.set(rollerX(i), 0.8, 0);
dummy.rotation.set(0, 0, rollerAngle);
dummy.updateMatrix();
rollers.setMatrixAt(i, dummy.matrix);
}
rollers.instanceMatrix.needsUpdate = true;
// Los paquetes avanzan a la velocidad de la cinta y vuelven al inicio al llegar al final
for (const p of parcels) {
p.position.x += shownSpeed * dt;
if (p.position.x > LENGTH / 2) p.position.x -= LENGTH;
}
// Visualización del atasco
zoneMats.forEach((m, i) => {
const jammed = s.jam && s.jamZone === i + 1;
m.emissive.setHex(jammed ? 0xff2222 : 0x000000);
m.emissiveIntensity = jammed ? 0.6 + 0.4 * Math.sin(performance.now() / 150) : 0;
});
rollerMat.color.setHex(!s.online || stale ? 0x475569 : s.jam ? 0xdc2626 : 0x94a3b8);
txt.textContent = `${s.online ? "Online" : "Offline"} | ${shownSpeed.toFixed(2)} m/s | ` +
`${s.jam ? `JAM in zone ${s.jamZone}` : s.running ? "Running" : "Stopped"} | ` +
`count ${s.count} | ${s.current ?? "-"} A${stale ? " | no data" : ""}`;
renderer.render(scene, camera);
});
addEventListener("resize", () => {
camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
</script>
</body>
</html>
Arranca el simulador, abre la página y observa cómo los paquetes avanzan a 0,8 m/s. En uno o dos minutos se produce un atasco: la cinta desacelera, los rodillos se ponen rojos y la franja de la zona afectada parpadea. Haz clic en Reset jam y después en Start, y la línea vuelve a acelerar.
Controlar el movimiento con unidades reales
La línea más importante del gemelo es rollerAngle -= (shownSpeed / ROLLER_R) * dt. Como la escena usa metros y el radio del rodillo coincide con el modelo, la velocidad superficial de los rodillos es igual a la de los paquetes, que a su vez es igual a la velocidad medida de la cinta. Quien mira la escena percibe de forma inconsciente cuándo los rodillos giran "demasiado rápido" para los paquetes; un movimiento físicamente correcto es lo que hace que un gemelo inspire confianza.
La velocidad se sigue suavizando, porque los variadores reales aceleran y deceleran en rampa y la telemetría solo llega dos veces por segundo. El factor de suavizado es corto (alrededor de una sexta parte de segundo), así que una parada brusca sigue pareciendo una parada. Observa que el simulador también detiene la cinta ante un atasco, igual que haría un PLC: el gemelo no decide por su cuenta detener los paquetes, muestra lo que informa la línea.
Lista de comprobación del gemelo digital de cinta transportadora para líneas reales
Cuando sustituyas el simulador por datos reales del PLC, revisa estos puntos:
- Origen de la velocidad. Prefiere la realimentación del variador o un encoder antes que la consigna. Una cinta que patina sobre un tambor desgastado va más lenta de lo ordenado, y el gemelo debe mostrarlo.
- Definición de atasco. Un atasco suele ser una fotocélula bloqueada durante más tiempo del que necesita un paquete para pasar. Impleméntalo en el PLC o en el gateway, no en el navegador, para que todos los consumidores coincidan.
- Numeración de zonas. Acuerda los números de zona con el ingeniero de control y rotúlalos físicamente.
- Seguridad de los comandos. El arranque remoto de una cinta es una cuestión de seguridad. Muchas plantas solo permiten parada y reset desde un gemelo, o exigen confirmación local. Nunca eludas el sistema de seguridad de la máquina.
- Gateway. Un gateway de PLC o un flujo de Node-RED suele leer las variables y publicar el JSON mostrado arriba; Node-RED con Modbus es una vía habitual.
- Frecuencia de actualización. De dos a cinco mensajes por segundo por cinta es más que suficiente para la visualización. Frecuencias mayores añaden carga sin beneficio visible.
Resolución de problemas
Los paquetes se mueven pero los rodillos parecen congelados o parpadean. A altas velocidades, la rotación del rodillo en cada fotograma puede acercarse a un segmento completo del cilindro, lo que crea un efecto de rueda de carreta. Añade una textura con una franja visible o reduce el número de segmentos para que el movimiento se perciba correctamente.
El gemelo sigue moviéndose después de que la línea se detiene. Probablemente tu gateway publica la consigna de velocidad en lugar de la realimentación, o ha dejado de publicar por completo. La comprobación de datos obsoletos pone la velocidad a cero tras tres segundos sin mensajes, y por eso la línea de estado muestra "no data".
Los botones no hacen nada. Comprueba que el simulador o el gateway se suscriben exactamente al topic cmd y obsérvalo con mosquitto_sub para confirmar que el comando llega.
La página se conecta y luego se cae cada pocos segundos. Dos pestañas o dos usuarios comparten client ID. El sufijo aleatorio del ejemplo lo evita en el broker público; en un broker privado donde los client ID están ligados a las credenciales emitidas, da a cada usuario sus propias credenciales.
Ampliar el gemelo
- Construye una línea completa repitiendo la cinta con distintos prefijos y añadiendo confluencias y desviadores; las vinculaciones basadas en configuración de vincular datos IoT a un modelo 3D lo mantienen manejable.
- Añade junto a la vista 3D tarjetas de KPI con la producción por hora, el número de atascos por turno y la disponibilidad, como se muestra en el tutorial del dashboard de gemelo digital.
- Sustituye las cajas por un modelo glTF de tu cinta real, usando la estructura de la guía de gemelo digital 3D web.
- Combínalo con un robot o un paletizador para construir una célula, similar a la escena de la demo web de gemelo digital 3D de ConnectMQ, que incluye una cinta transportadora junto a una máquina CNC, un brazo robótico, un horno, un tanque y una bomba.
Llevarlo a producción
Los datos de una línea no deben viajar por un broker público: el servidor público gratuito es solo para pruebas y prototipos. Crea un broker privado con credenciales y TLS, añade username, password y el clientId emitido a las opciones de mqtt.connect (YOUR_USERNAME, YOUR_PASSWORD, YOUR_CLIENT_ID) y usa el puerto 8883 con TLS en el gateway. Un broker MQTT gestionado como ConnectMQ se ocupa de la parte del broker, incluido el WebSocket seguro en el puerto 8084 para los gemelos en el navegador, de modo que tu equipo se centre en la integración con el PLC y en la visualización.
Empieza con ConnectMQ
Construye hoy tu gemelo digital de cinta transportadora sobre el servidor MQTT público gratuito y después trasládalo a tu propio broker privado en ConnectMQ. El plan Serverless gratuito, sin tarjeta de crédito, admite hasta 25 conexiones e incluye reglas de alerta con un registro de alertas, de modo que un atasco también puede disparar una alerta por umbral en el portal. Crea tu broker gratuito en app.connectmq.com. Si prefieres seguir experimentando antes, sigue usando el servidor público.
Preguntas frecuentes
¿Con qué frecuencia debe publicar telemetría una cinta transportadora para un gemelo digital?
Normalmente bastan de dos a cinco mensajes por segundo. El gemelo interpola entre mensajes, así que frecuencias mayores añaden carga de red con poco beneficio visual.
¿La detección de atascos debe hacerse en el gemelo o en el PLC?
En el PLC o en el gateway. El gemelo debe mostrar el estado de atasco que recibe, para que todos los sistemas, desde los dashboards hasta las alertas, vean el mismo evento.
¿Puedo controlar la cinta transportadora desde el gemelo digital?
Técnicamente sí, publicando comandos que el gateway reenvía al PLC. Que debas hacerlo depende de las normas de seguridad de tu planta; la parada y el reset son habituales, mientras que el arranque remoto suele exigir confirmación local.
¿Cómo muestro muchas cintas sin ralentizar el navegador?
Usa mallas instanciadas para rodillos y paquetes, comparte materiales y mantén una sola conexión MQTT con una suscripción con comodín para toda la línea.



