Maintien d'altitude d'un dirigeable
Un pilote automatique pour un navire à ballons de Create Aeronautics, qui tient la hauteur choisie et garde le navire à plat, expliqué pas à pas.
Un navire à ballons de Create Aeronautics flotte à la hauteur où la portance de son air chaud égale son poids. Chargez quelques caisses, montez dans un air plus rare, et cette hauteur change : à la main, vous passez votre temps à tourner les panneaux de réglage des brûleurs. Dans ce projet, un ordinateur de bord le fait pour vous. Il lit la hauteur du navire, la compare à celle que vous avez demandée, et règle le débit de chaque brûleur à air chaud deux fois par seconde. Avec un capteur gyroscopique, il donne en plus davantage d'air chaud au côté bas, pour que le navire reste à plat.
Vous apprendrez à trouver des appareils sur un Câble de données, à transformer des mesures en commande, et surtout à écrire un régulateur PID, l'outil classique pour tenir une valeur stable, réglé pour un système aussi lent qu'un ballon.
Le navire, les brûleurs et les capteurs viennent tous de Create Aeronautics (avec Create Simulated). Voir Capteurs de Create Aeronautics et Brûleur à air chaud pour chaque méthode utilisée ici. Aeronautics n'est pas traduit : en jeu, ses blocs gardent leur nom anglais (Hot Air Burner, Altitude Sensor, Gimbal Sensor...).
Le programme ci-dessous fait environ 150 lignes. Le programme intégré balloon, plus de trois fois plus long, est la version complète de la même idée (une carte des brûleurs, une page des appareils, des réglages enregistrés dans un fichier) : lisez d'abord cette page, son code sera ensuite facile à suivre.
Le montage
Le navire. Partez d'un navire qui vole déjà, avec ses ballons et leurs brûleurs à air chaud dessous (voir « Comment vole un ballon » sur Brûleur à air chaud).
- Pour tenir une hauteur, n'importe quel nombre de brûleurs convient, sous un ou plusieurs ballons.
- Pour garder en plus le navire à plat, répartissez les brûleurs vers les bords du navire (aux quatre coins de préférence, trois au moins), chacun sous son propre ballon. Des brûleurs sous le même ballon remplissent le même air chaud et portent au même endroit : donner plus à l'un d'eux ne soulèverait pas son côté.
Les flammes. Chaque brûleur a besoin de redstone pour brûler : un levier ou un bloc de redstone contre lui, ou une Liaison de redstone. Laissez toutes les flammes à pleine puissance (15). Le programme ne touche jamais aux flammes, il change seulement les débits.
Les capteurs. Un capteur d'altitude et un capteur gyroscopique, n'importe où sur le navire : le gyroscope lit l'inclinaison de toute la coque, où qu'il soit posé.
L'ordinateur. Un Microcontrôleur convient bien à un navire : un écran couleur de 40 caractères sur 12, une prise pour le Câble de données, et 8 000 instructions par seconde à 256 tr/min, bien plus que ce qu'il faut à ce programme. Comme tout ordinateur, il tourne à la rotation (le Microcontrôleur la prend par-dessous) : donnez-lui un arbre venant du moteur du navire. Un Mini-ordinateur ou un Micro-ordinateur conviennent aussi.
Le câble. Un seul réseau de Câble de données qui touche l'ordinateur, chaque brûleur et les deux capteurs. Chaque bloc qui touche le câble devient un périphérique de l'ordinateur. Mettez les brûleurs sur le câble, pas contre l'ordinateur : le programme se sert des coordonnées de leur nom de câble.
Avant d'écrire le pilote, vérifiez que l'ordinateur voit tout. Lancez le programme intégré devices, ou ce petit programme :
for _, nom in ipairs(peripheral.list()) do
print(nom)
end> run verif gimbal_sensor@0,72,0 altitude_sensor@1,72,0 hot_air_burner@-3,70,-3 hot_air_burner@3,70,-3 hot_air_burner@-3,70,3 hot_air_burner@3,70,3
Un brûleur absent, c'est un câble qui ne le touche pas (ou un côté du câble coupé à la clé).
Étape 1 : lire les capteurs
peripheral.find enveloppe le premier appareil d'un type : les capteurs n'ont besoin d'aucun nom.
local alt = peripheral.find("altitude_sensor")
local gyro = peripheral.find("gimbal_sensor")
print("hauteur " .. alt.height())
local g = gyro.read()
print("pente X " .. g.angle_x .. " Z " .. g.angle_z)altitude_sensor.height() est la hauteur du navire dans le monde (son Y). sensor.read() sur le capteur gyroscopique donne les deux angles du même instant, en degrés : angle_x au-dessus de 0 veut dire que le côté sud est bas, angle_z au-dessus de 0 que le côté est est bas.
La vitesse de montée. Le pilote doit savoir si le navire monte ou descend, et à quelle vitesse. Deux hauteurs et le temps écoulé entre elles la donnent : os.clock compte les secondes depuis le démarrage de l'ordinateur.
local alt = peripheral.find("altitude_sensor")
local avant, t0 = alt.height(), os.clock()
sleep(0.5)
local montee = (alt.height() - avant) / (os.clock() - t0)
print(string.format("montée %+.2f blocs/s", montee))Où est chaque brûleur. Le nom d'un appareil sur le câble se termine par ses coordonnées. Sur un navire, ce sont les coordonnées propres du navire : elles ne changent pas pendant le vol, et elles suivent les mêmes axes que le capteur gyroscopique (un X plus grand est le côté est, un Z plus grand le côté sud). Couper le nom au @ les donne :
local nom = "hot_air_burner@-3,70,4"
local at = string.find(nom, "@", 1, true)
local xyz = string.split(string.sub(nom, at + 1), ",")
print(tonumber(xyz[1]), tonumber(xyz[3]))-3 4
Étape 2 : de la poussée aux débits
Le régulateur raisonne en poussée : un nombre de 0 (le moins d'air chaud possible) à 1 (ballons pleins). Le programme la transforme ensuite en débit pour chaque brûleur.
Souvenez-vous que le débit est le volume d'air chaud que le brûleur veut dans son ballon, de 5 à 500 m³, et que tout ce qui dépasse la capacité du ballon est perdu. La poussée 0 est donc le débit minimal, 5, et la poussée 1 la capacité du ballon : aucune poussée n'est perdue, chaque petit bout change la portance.
local MIN, haut = 5, 18 -- un ballon de 18 m3
for _, poussee in ipairs({0, 0.25, 0.5, 1}) do
print(poussee, math.floor(MIN + poussee * (haut - MIN) + 0.5))
end0 5 0.25 8 0.5 12 1 18
set_amount ne prend que des nombres entiers, d'où l'arrondi (math.floor(x + 0.5)).
Le haut d'un brûleur est la capacité de son ballon, mais deux choses peuvent le changer :
- un brûleur sans ballon (
capacity()vautnil) n'a pas d'autre limite que le maximum du serveur.set_amount(100000)le trouve : le brûleur garde la plus grande valeur qu'il accepte, 500 par défaut, et la renvoie ; - une flamme sous 15 demande
débit × signal / 15: il lui faut donc un débit plus grand pour remplir le même ballon.
-- le débit qui remplit le ballon d'un brûleur : plus serait perdu
local function haut(b, r)
local t = b.max
if r.capacity and r.signal > 0 then
t = math.min(t, r.capacity * 15 / r.signal)
end
return math.max(t, MIN + 1)
endIci b est la fiche du brûleur tenue par le programme (avec b.max, le maximum du serveur) et r ce qu'a renvoyé le read() du brûleur. La dernière ligne garde le haut au-dessus du minimum, même pour un minuscule ballon.
Étape 3 : un régulateur PID en mots simples
Le régulateur répond à une seule question, deux fois par seconde : quelle poussée maintenant ? Il additionne trois idées, chacune avec son gain (un nombre qui dit avec quelle force elle agit).
P, la traction. Plus le navire est sous la cible, plus il faut de poussée : P * ecart, où ecart est la cible moins la hauteur. Avec P seul, le navire oscille : le temps que l'air chaud arrive, il a dépassé la cible, alors il coupe la poussée, retombe dessous, et recommence.
D, le frein. Plus le navire monte vite, moins il faut de poussée : - D * montee. Ce terme agit avant d'atteindre la cible : le navire ralentit en approchant, au lieu de la dépasser. Pour un ballon, c'est le terme le plus important.
I, le trim. Même à la bonne hauteur, le navire a besoin d'une certaine poussée pour flotter. Le trim (le même mot qu'en aviation : le réglage qui tient l'appareil sans effort) l'apprend lentement : à chaque pas, il ajoute un peu de l'écart (I * ecart * dt). Si le navire reste un peu trop bas, le trim grandit jusqu'à ce qu'il ne le soit plus. Sans lui, le navire resterait à jamais quelques blocs sous la cible, car P a besoin d'un écart pour donner de la poussée.
poussee = trim + P * ecart - D * monteeUn pas à la main : le navire est à 112, la cible à 120, il monte d'un demi-bloc par seconde, et le trim a appris 0,4 jusqu'ici. Le pas dure une demi-seconde.
local P, I, D = 0.012, 0.003, 0.1
local cible, hauteur, montee, trim = 120, 112, 0.5, 0.4
local ecart = cible - hauteur
trim = trim + I * ecart * 0.5
local poussee = trim + P * ecart - D * montee
print("écart " .. ecart .. " blocs")
print("trim " .. trim)
print("poussée " .. poussee)
print("débit " .. math.floor(5 + poussee * 13 + 0.5))écart 8 blocs trim 0.412 poussée 0.458 débit 11
8 blocs trop bas ajoutent 0,096 de poussée, monter d'un demi-bloc par seconde en retire 0,05 : le navire continue de monter, en douceur.
Étape 4 : régler pour la lenteur du gaz
Un ballon est lent. Loin de sa cible, l'air chaud met environ 9 secondes à combler les deux tiers de l'écart : chaque correction se voit donc des secondes plus tard. Des gains forts aggravent les choses : le régulateur continue de pousser pendant que le gaz est encore en route, et le navire oscille de plus en plus haut et bas. Les gains doux gagnent. Ceux-ci, tirés du pilote intégré, sont un bon départ :
| gain | valeur | ce qu'il veut dire |
|---|---|---|
P | 0.012 | 10 blocs trop bas : +12 % de poussée |
D | 0.1 | une montée de 1 bloc/s : -10 % de poussée |
I | 0.003 | 10 blocs trop bas pendant 10 s : +30 % de trim |
Deux sécurités de plus :
- Bornez l'écart à 15 blocs dans les deux sens. Une cible 100 blocs plus haut mettrait sinon les brûleurs à fond. Borné, P et D s'équilibrent quand le navire monte à environ 2 blocs par seconde (
0.012 × 15 / 0.1 = 1.8) : une montée régulière. - Gardez la poussée et le trim entre 0 et 1. Au-delà, les débits ne peuvent de toute façon pas suivre.
Si votre navire se conduit mal, changez un seul gain à la fois, de moitié ou du double :
| le navire... | essayez |
|---|---|
| oscille au-dessus et au-dessous de la cible | baisser P, ou monter D |
| se traîne vers la cible | monter un peu P |
| reste quelques blocs à côté longtemps | monter un peu I |
| reste à 100 % de poussée et toujours trop bas | rien à régler : c'est le plafond. Des ballons plus grands, ou un navire plus léger |
Étape 5 : garder le navire à plat
Avec un capteur gyroscopique, chaque brûleur reçoit la poussée du navire entier plus une correction pour son côté : plus quand son côté est bas, moins quand il est haut.
La position d'un brûleur, prise depuis le milieu de tous les brûleurs et divisée par la distance du plus éloigné, donne deux nombres entre -1 et 1 : ux (-1 à l'ouest, 1 à l'est) et uz (-1 au nord, 1 au sud). L'inclinaison donne deux corrections, avec leurs propres P et D (en degrés et en degrés par seconde) :
tx = LP * angle_x + LD * (angle_x - dernier_x) / dt -- sud bas : tx > 0
tz = LP * angle_z + LD * (angle_z - dernier_z) / dt -- est bas : tz > 0
poussee_du_bruleur = poussee + ux * tz + uz * txUn brûleur du côté est (ux = 1) reçoit tz de plus quand le côté est est bas ; un brûleur du côté ouest (ux = -1) reçoit tz de moins. Un brûleur au milieu reçoit la poussée telle quelle. Des gains doux, là encore : LP = 0,02 par degré et LD = 0,05, si bien qu'une gîte de 5 degrés change d'au plus 10 % la poussée des brûleurs du bord.
Étape 6 : la sécurité
Un programme qui pilote un navire ne doit pas l'écraser. Voici ce que fait celui-ci :
- Les débits restent entre 5 et le haut de chaque brûleur : la poussée de chaque brûleur est bornée entre 0 et 1 avant de devenir un débit.
- Un capteur perdu n'arrête rien. Chaque mesure passe par
pcall. Si le capteur d'altitude disparaît (cassé, câble coupé), le pas s'arrête avant de changer le moindre débit : les brûleurs gardent leurs derniers débits, et le navire continue de flotter à sa hauteur d'équilibre. L'écran le signale en rouge, et toutes les demi-secondes le programme cherche à nouveau un capteur d'altitude. Un capteur gyroscopique perdu n'arrête que la mise à plat. - Les brûleurs éteints se voient. Un brûleur sans redstone ne donne aucun air chaud, quel que soit son débit : l'écran les compte.
- Si le programme s'arrête (Ctrl+T, une erreur, un ordinateur sans rotation), les brûleurs gardent leurs derniers débits. Le navire ne tombe pas : il se stabilise à la hauteur que tiennent ces débits.
- Les appareils manquants l'arrêtent tout de suite, avec un message clair :
assertvérifie qu'il y a un capteur d'altitude et au moins un brûleur sur le câble avant de rien changer.
Enregistrez le programme sous le nom startup : l'ordinateur le lance tout seul à chaque démarrage.
Étape 7 : un écran d'état
L'écran du Microcontrôleur montre la hauteur, la cible, la vitesse de montée, la poussée (avec une barre) et l'inclinaison. Chaque ligne est effacée puis réécrite, pour qu'il ne reste rien de la ligne d'avant. Avec des mesures tapées à la main, l'écran ressemble à ceci :
local C = term.colors
local hauteur, cible, montee, poussee, ax, az = 112.4, 120, 1.3, 0.62, 0.8, -1.6
local perdu = false
local bruleurs = {{signal = 15}, {signal = 15}, {signal = 15}, {signal = 15}}
local function ligne(y, couleur, texte)
term.set_cursor(1, y)
term.clear_line()
term.set_fg(couleur)
term.write(texte)
end
local function dessiner()
ligne(1, C.yellow, "MAINTIEN D'ALTITUDE")
ligne(3, C.white, string.format("Hauteur %6.1f cible %d", hauteur, cible))
ligne(4, C.white, string.format("Montée %+6.1f blocs/s", montee))
ligne(5, C.white, string.format("Poussée %4.0f %%", poussee * 100))
gfx.rect(1, 56, 240, 6, "gray", true)
gfx.rect(1, 56, math.floor(240 * poussee), 6, "orange", true)
ligne(8, C.white, string.format("Pente X %+.1f Z %+.1f", ax, az))
local eteints = 0
for _, b in ipairs(bruleurs) do
if b.signal == 0 then
eteints = eteints + 1
end
end
if perdu then
ligne(10, C.red, "CAPTEUR D'ALTITUDE PERDU")
elseif eteints > 0 then
ligne(10, C.red, eteints .. " brûleur(s) sans redstone")
else
ligne(10, C.lime, #bruleurs .. " brûleurs allumés")
end
ligne(12, C.light_gray, "Haut/Bas : hauteur visée")
end
dessiner()
Le programme complet
Le voici d'un seul tenant. Tapez edit startup sur l'ordinateur, collez-le avec Ctrl+V, enregistrez, et redémarrez avec Ctrl+R.
-- Maintien d'altitude d'un dirigeable (Create Aeronautics)
-- Sur le Câble de données : les brûleurs à air chaud (allumés à la
-- redstone), un capteur d'altitude, et un capteur gyroscopique pour
-- garder le navire à plat. Haut/Bas : hauteur à tenir.
local P, I, D = 0.012, 0.003, 0.1 -- hauteur : par bloc, par bloc x s, par bloc/s
local LP, LD = 0.02, 0.05 -- assiette : par degré, par degré/s
local MIN = 5 -- le plus petit débit qu'accepte un brûleur
local C = term.colors
local alt = peripheral.find("altitude_sensor")
local gyro = peripheral.find("gimbal_sensor")
assert(alt, "aucun capteur d'altitude sur le Câble de données")
local function borner(x, bas, plafond)
return math.max(bas, math.min(plafond, x))
end
-- le débit qui remplit le ballon d'un brûleur : plus serait perdu
local function haut(b, r)
local t = b.max
if r.capacity and r.signal > 0 then
t = math.min(t, r.capacity * 15 / r.signal)
end
return math.max(t, MIN + 1)
end
-- chaque brûleur, et sa place par rapport au milieu du navire
local bruleurs = {}
local cx, cz = 0, 0
for _, nom in ipairs(peripheral.list("hot_air_burner")) do
local at = string.find(nom, "@", 1, true)
assert(at, nom .. " : mettez ce brûleur sur le Câble de données")
local xyz = string.split(string.sub(nom, at + 1), ",")
local p = peripheral.wrap(nom)
local r = p.read()
local b = {p = p, x = tonumber(xyz[1]), z = tonumber(xyz[3]), signal = r.signal}
b.max = p.set_amount(100000) -- il garde le maximum du serveur
p.set_amount(r.amount)
table.insert(bruleurs, b)
cx = cx + b.x
cz = cz + b.z
end
assert(#bruleurs > 0, "aucun brûleur à air chaud sur le Câble de données")
cx = cx / #bruleurs
cz = cz / #bruleurs
local R = 1
for _, b in ipairs(bruleurs) do
R = math.max(R, math.sqrt((b.x - cx) ^ 2 + (b.z - cz) ^ 2))
end
for _, b in ipairs(bruleurs) do
b.ux = (b.x - cx) / R -- -1 ouest ... 1 est
b.uz = (b.z - cz) / R -- -1 nord ... 1 sud
end
-- l'état du vol
local hauteur = alt.height()
local cible = math.floor(hauteur + 0.5)
local montee, poussee, ax, az = 0, 0, 0, 0
local perdu = false
local dernier = os.clock()
local premier = bruleurs[1].p.read()
local trim = borner((premier.amount - MIN) / (haut(bruleurs[1], premier) - MIN), 0, 1)
if gyro then
local g = gyro.read()
ax, az = g.angle_x, g.angle_z
end
local function etape()
local maintenant = os.clock()
local dt = math.max(0.05, maintenant - dernier)
dernier = maintenant
-- 1. les capteurs : en perdre un ne doit pas écraser le navire
local h = pcall(alt.height)
perdu = not h.ok
if perdu then
return -- les brûleurs gardent leurs débits : le navire flotte
end
montee = (h.value - hauteur) / dt
hauteur = h.value
local tx, tz = 0, 0
local g = gyro and pcall(gyro.read)
if g and g.ok then
tx = LP * g.value.angle_x + LD * (g.value.angle_x - ax) / dt
tz = LP * g.value.angle_z + LD * (g.value.angle_z - az) / dt
ax, az = g.value.angle_x, g.value.angle_z
end
-- 2. la hauteur : le trim apprend ce qui tient le navire, P tire, D freine
local ecart = borner(cible - hauteur, -15, 15)
trim = borner(trim + I * ecart * dt, 0, 1)
poussee = borner(trim + P * ecart - D * montee, 0, 1)
-- 3. chaque brûleur : la poussée, plus un supplément du côté bas
for _, b in ipairs(bruleurs) do
local r = pcall(b.p.read)
if r.ok then
b.signal = r.value.signal
local f = borner(poussee + b.ux * tz + b.uz * tx, 0, 1)
local t = haut(b, r.value)
pcall(b.p.set_amount, math.floor(MIN + f * (t - MIN) + 0.5))
end
end
end
local function ligne(y, couleur, texte)
term.set_cursor(1, y)
term.clear_line()
term.set_fg(couleur)
term.write(texte)
end
local function dessiner()
ligne(1, C.yellow, "MAINTIEN D'ALTITUDE")
ligne(3, C.white, string.format("Hauteur %6.1f cible %d", hauteur, cible))
ligne(4, C.white, string.format("Montée %+6.1f blocs/s", montee))
ligne(5, C.white, string.format("Poussée %4.0f %%", poussee * 100))
gfx.rect(1, 56, 240, 6, "gray", true)
gfx.rect(1, 56, math.floor(240 * poussee), 6, "orange", true)
ligne(8, C.white, string.format("Pente X %+.1f Z %+.1f", ax, az))
local eteints = 0
for _, b in ipairs(bruleurs) do
if b.signal == 0 then
eteints = eteints + 1
end
end
if perdu then
ligne(10, C.red, "CAPTEUR D'ALTITUDE PERDU")
elseif eteints > 0 then
ligne(10, C.red, eteints .. " brûleur(s) sans redstone")
else
ligne(10, C.lime, #bruleurs .. " brûleurs allumés")
end
ligne(12, C.light_gray, "Haut/Bas : hauteur visée")
end
term.clear()
gfx.clear()
dessiner()
local minuteur = os.start_timer(0.5)
while true do
local e = os.pull_event()
if e.name == "timer" and e.id == minuteur then
minuteur = os.start_timer(0.5)
if perdu then
alt = peripheral.find("altitude_sensor") or alt
end
etape()
dessiner()
elseif e.name == "key" and e.key == "up" then
cible = cible + 1
dessiner()
elseif e.name == "key" and e.key == "down" then
cible = cible - 1
dessiner()
end
endComment il tourne : la boucle principale attend des évènements (Évènements). Un minuteur sonne toutes les demi-secondes : le programme lit les capteurs, calcule la poussée, règle les débits et redessine l'écran. Les flèches changent la cible tout de suite, sans attendre le minuteur. Chaque fonction n'utilise que ses propres variables locales et les variables du fichier (hauteur, trim, bruleurs...), jamais les variables locales d'une autre fonction, comme l'exige Brass (Brass pour les habitués de Lua et ComputerCraft).
Pour aller plus loin
- Glisser vers une nouvelle cible. Changez la cible de 50 blocs : l'écart est borné à 15, mais le trim apprend quand même de lui. Le programme intégré
balloondéplace plutôt une consigne vers la cible à 1,5 bloc par seconde, et empêche le trim de grandir quand la poussée est déjà à sa limite. - Mettre aussi du I sur l'assiette. Une charge décentrée laisse une petite gîte constante, car P a besoin d'une pente pour agir. Un trim lent par axe, comme celui de la hauteur, la fait disparaître :
balloonle fait, borné à ±0,4. - Pas de capteur d'altitude ? Sur un Micro-ordinateur, un Microcontrôleur ou un Ordinateur moderne,
vehicle.position().ydonne la hauteur du navire (vehicle). - Retenir la cible. Écrivez-la dans un fichier avec
fs.writeà chaque changement, et relisez-la au démarrage : un redémarrage garde la hauteur choisie. - Une télécommande. Recevez la cible d'un autre ordinateur avec
net: voirTélécommande par le réseau.