Un jumeau numérique de convoyeur montre d'un coup d'œil si une ligne avance, à quelle vitesse et où elle est bloquée. Dans ce tutoriel, vous allez en construire un dans le navigateur avec Three.js : les rouleaux tournent et les colis avancent à la vitesse réelle de la bande transmise par MQTT, un signal de bourrage arrête les colis et fait virer la bande au rouge, et les opérateurs peuvent renvoyer des commandes de marche, d'arrêt et de réarmement à la ligne. Un simulateur Python publie des données de convoyeur réalistes sur broker.connectmq.com, le serveur MQTT public gratuit réservé aux tests et aux prototypes : vous pouvez donc tout construire et tester avant de connecter un automate.
Pourquoi un convoyeur est un bon candidat au jumeau numérique
Les convoyeurs sont omniprésents dans l'industrie, l'entreposage et la logistique, et leurs pannes sont visibles et coûteuses : un carton coincé à un point de convergence peut arrêter tout l'amont. Les signaux sont généralement déjà disponibles dans l'automate et se traduisent naturellement en mouvement :
| Signal | Source typique | Unité | Comportement du jumeau |
|---|---|---|---|
| Vitesse de la bande | Retour du variateur ou codeur | m/s | Les rouleaux tournent, les colis avancent |
| En marche | Contacteur moteur ou état du variateur | booléen | Couleur de la bande, mouvement activé/désactivé |
| Bourrage | Cellule photoélectrique occultée au-delà d'un seuil | booléen plus zone | La bande vire au rouge, les colis s'arrêtent dans la zone de bourrage |
| Nombre de colis | Cellule photoélectrique en sortie | compteur | Compteur de débit |
| Courant moteur | Variateur | A | Facultatif, alerte précoce de surcharge |
Comme le comportement visuel découle directement de la physique (un rouleau de rayon r tourne à v / r radians par seconde), un jumeau de convoyeur est aussi un bon moyen d'apprendre à piloter une animation avec des unités réelles plutôt qu'avec des nombres arbitraires. Pour une vue d'ensemble, consultez notre guide de la technologie du jumeau numérique.
Organisation des topics
Choisissez un préfixe unique ; l'exemple utilise connectmq/demo/conv-7f3a :
| Topic | Sens | Payload |
|---|---|---|
.../line1/conv1/telemetry |
Ligne vers jumeau | {"speed":0.8,"running":true,"jam":false,"jamZone":null,"count":1532,"current":4.1} |
.../line1/conv1/cmd |
Jumeau vers ligne | START, STOP, RESET |
.../line1/conv1/status |
Ligne, retenu, last will | online / offline |
Envoyer un seul objet de télémétrie combiné, plutôt qu'un topic par signal, garantit que le jumeau ne voit jamais un mélange incohérent (par exemple running: true d'un message et speed: 0 d'un message plus ancien). Les compromis entre topics combinés et topics par signal sont abordés dans MQTT pour les jumeaux numériques.
Étape 1 : le simulateur de convoyeur
Le simulateur modélise un convoyeur à trois zones tournant à 0,8 m/s, compte les colis, se bloque de temps en temps dans une zone aléatoire et obéit aux commandes. Il utilise 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 # l'automate arrête la bande en cas de bourrage
target = NOMINAL_SPEED if state["running"] else 0.0
state["speed"] += (target - state["speed"]) * 0.5 # rampe comme le ferait un variateur
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)
Deux messages par seconde, c'est confortable pour le serveur MQTT public gratuit, qui autorise jusqu'à 10 messages par seconde et par client.
Étape 2 : construire la scène du jumeau numérique de convoyeur
Enregistrez sous index.html et servez avec npx serve .. Les rouleaux sont rendus avec un InstancedMesh, ce qui permet d'afficher un long convoyeur à moindre coût.
<!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);
// Châssis
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);
}
// Rouleaux sous forme d'instances
const count = Math.floor(LENGTH / ROLLER_PITCH);
const rollerGeo = new THREE.CylinderGeometry(ROLLER_R, ROLLER_R, WIDTH, 16);
rollerGeo.rotateX(Math.PI / 2); // axe selon 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);
// Repères de zone (s'allument en rouge en cas de bourrage)
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);
}
// Colis
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);
}
// ---------- État ----------
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 })));
// ---------- Animation ----------
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));
// Rouleaux : vitesse angulaire = 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;
// Les colis avancent à la vitesse de la bande et reviennent au début
for (const p of parcels) {
p.position.x += shownSpeed * dt;
if (p.position.x > LENGTH / 2) p.position.x -= LENGTH;
}
// Visualisation du bourrage
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>
Lancez le simulateur, ouvrez la page et regardez les colis avancer à 0,8 m/s. En une minute ou deux, un bourrage survient : la bande ralentit, les rouleaux virent au rouge et la bande de la zone concernée clignote. Cliquez sur Reset jam, puis sur Start, et la ligne reprend progressivement sa vitesse.
Piloter le mouvement avec des unités réelles
La ligne la plus importante du jumeau est rollerAngle -= (shownSpeed / ROLLER_R) * dt. Comme la scène utilise des mètres et que le rayon du rouleau correspond au modèle, la vitesse de surface des rouleaux est égale à celle des colis, elle-même égale à la vitesse mesurée de la bande. L'œil remarque inconsciemment quand des rouleaux tournent « trop vite » par rapport aux colis ; c'est un mouvement physiquement juste qui rend un jumeau digne de confiance.
La vitesse reste lissée, car les variateurs réels accélèrent et décélèrent progressivement et la télémétrie n'arrive que deux fois par seconde. Le facteur de lissage est court (environ un sixième de seconde) : un arrêt brutal ressemble donc toujours à un arrêt. Notez que le simulateur arrête aussi la bande en cas de bourrage, exactement comme le ferait un automate : le jumeau ne décide pas seul d'arrêter les colis, il montre ce que la ligne rapporte.
Check-list du jumeau numérique de convoyeur pour les vraies lignes
Lorsque vous remplacez le simulateur par de vraies données d'automate, vérifiez ces points :
- La source de vitesse. Préférez le retour du variateur ou un codeur à la consigne. Une bande qui patine sur un tambour usé tourne moins vite que commandé, et le jumeau doit le montrer.
- La définition du bourrage. Un bourrage correspond généralement à une cellule photoélectrique occultée plus longtemps que le temps nécessaire au passage d'un colis. Implémentez-le dans l'automate ou la passerelle, pas dans le navigateur, afin que tous les consommateurs soient d'accord.
- La numérotation des zones. Convenez des numéros de zone avec l'automaticien et étiquetez-les physiquement.
- La sécurité des commandes. Le démarrage à distance d'un convoyeur est une question de sécurité. De nombreux sites n'autorisent que l'arrêt et le réarmement depuis un jumeau, ou exigent une confirmation locale. Ne contournez jamais le système de sécurité de la machine.
- La passerelle. Une passerelle d'automate ou un flux Node-RED lit généralement les variables et publie le JSON présenté plus haut ; Node-RED avec Modbus est une voie courante.
- La fréquence de mise à jour. Deux à cinq messages par seconde et par convoyeur suffisent largement pour la visualisation. Des fréquences plus élevées ajoutent de la charge sans bénéfice visible.
Dépannage
Les colis avancent mais les rouleaux semblent figés ou stroboscopiques. À grande vitesse, la rotation d'un rouleau par image peut approcher un segment complet du cylindre, ce qui crée un effet de roue de chariot. Ajoutez une texture rayée visible ou réduisez le nombre de segments pour que le mouvement se lise correctement.
Le jumeau continue de bouger après l'arrêt de la ligne. Votre passerelle publie probablement la consigne de vitesse au lieu du retour, ou elle a complètement cessé de publier. La vérification des données obsolètes ramène la vitesse à zéro après trois secondes sans message, d'où l'affichage « no data » dans la ligne d'état.
Les boutons ne font rien. Vérifiez que le simulateur ou la passerelle est abonné exactement au topic cmd, et surveillez-le avec mosquitto_sub pour confirmer que la commande arrive.
La page se connecte puis décroche toutes les quelques secondes. Deux onglets ou deux spectateurs partagent le même client ID. Le suffixe aléatoire de l'exemple l'évite sur le serveur MQTT public gratuit ; sur un broker privé où les client IDs sont liés aux identifiants délivrés, donnez à chaque spectateur ses propres identifiants.
Enrichir le jumeau
- Construisez une ligne entière en répétant le convoyeur avec des préfixes différents et en ajoutant convergences et aiguillages ; les liaisons pilotées par configuration décrites dans lier des données IoT à un modèle 3D gardent l'ensemble gérable.
- Ajoutez à côté de la vue 3D des tuiles d'indicateurs pour le débit horaire, le nombre de bourrages par poste et la disponibilité, comme dans le tutoriel tableau de bord de jumeau numérique.
- Remplacez les boîtes par un modèle glTF de votre vrai convoyeur, en reprenant la structure de créer un jumeau numérique 3D web.
- Associez-le à un robot ou un palettiseur pour construire une cellule, à l'image de la scène de la démo web de jumeau numérique 3D de ConnectMQ, qui inclut un convoyeur aux côtés d'une machine CNC, d'un bras robotique, d'un four, d'une cuve et d'une pompe.
Passer en production
Les données de ligne ne doivent pas transiter par un broker public. Créez un broker privé avec identifiants et TLS, puis ajoutez username, password et le clientId délivré aux options de mqtt.connect (YOUR_USERNAME, YOUR_PASSWORD, YOUR_CLIENT_ID), et utilisez le port 8883 avec TLS dans la passerelle. Un broker MQTT managé comme ConnectMQ prend en charge le broker, y compris le WebSocket sécurisé sur le port 8084 pour les jumeaux dans le navigateur, afin que votre équipe se concentre sur l'intégration de l'automate et la visualisation.
Démarrer avec ConnectMQ
Construisez dès aujourd'hui votre jumeau numérique de convoyeur sur le serveur MQTT public gratuit, puis migrez-le vers votre propre broker privé sur ConnectMQ. Le plan Serverless gratuit, sans carte bancaire, prend en charge jusqu'à 25 connexions et inclut des règles d'alerte avec un journal des alertes : un bourrage peut ainsi aussi déclencher une alerte de seuil dans le portail. Créez votre broker gratuit sur app.connectmq.com. Pour continuer d'expérimenter d'abord, restez sur le serveur MQTT public gratuit.
Questions fréquentes
À quelle fréquence un convoyeur doit-il publier sa télémétrie pour un jumeau numérique ?
Deux à cinq messages par seconde suffisent généralement. Le jumeau interpole entre les messages : des fréquences plus élevées ajoutent de la charge réseau pour un bénéfice visuel minime.
La détection de bourrage doit-elle se faire dans le jumeau ou dans l'automate ?
Dans l'automate ou la passerelle. Le jumeau doit afficher l'état de bourrage qu'il reçoit, afin que tous les systèmes, des tableaux de bord aux alertes, voient le même événement.
Peut-on piloter le convoyeur depuis le jumeau numérique ?
Techniquement oui, en publiant des commandes que la passerelle transmet à l'automate. Savoir si vous devez le faire dépend des règles de sécurité de votre site ; l'arrêt et le réarmement sont courants, tandis que le démarrage à distance exige souvent une confirmation locale.
Comment afficher de nombreux convoyeurs sans ralentir le navigateur ?
Utilisez des maillages instanciés pour les rouleaux et les colis, partagez les matériaux et conservez une seule connexion MQTT avec un abonnement générique pour toute la ligne.



