Create: Computing AgesDoc Brass
Recettes : vrais projets

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.

Il faut Create Aeronautics

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 :

Brass
for _, nom in ipairs(peripheral.list()) do
  print(nom)
end
Terminal
> 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.

Brass
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.

Brass
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 :

Brass
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]))
Écran
-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.

Brass
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))
end
Écran
0   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() vaut nil) 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.
Brass
-- 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

Ici 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.

Brass
poussee = trim + P * ecart - D * montee

Un 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.

Brass
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))
Écran
é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 :

gainvaleurce qu'il veut dire
P0.01210 blocs trop bas : +12 % de poussée
D0.1une montée de 1 bloc/s : -10 % de poussée
I0.00310 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 ciblebaisser P, ou monter D
se traîne vers la ciblemonter un peu P
reste quelques blocs à côté longtempsmonter un peu I
reste à 100 % de poussée et toujours trop basrien à 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) :

Brass
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 * tx

Un 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 : assert vé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 :

Brass
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()
Écran
Écran

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.

startup
-- 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
end

Comment 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é balloon dé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 : balloon le fait, borné à ±0,4.
  • Pas de capteur d'altitude ? Sur un Micro-ordinateur, un Microcontrôleur ou un Ordinateur moderne, vehicle.position().y donne 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 : voir Télécommande par le réseau.