Create: Computing AgesDoc Brass
Recettes : vrais projets

Enregistreur de données et graphique

Enregistrer une valeur chaque minute dans un fichier CSV qui ne grossit jamais trop, et tracer la dernière heure en courbe avec des axes.

Un nombre à l'écran dit où en sont les choses ; une courbe dit où elles vont. Le Coffre à objet renforcé du fer se vide-t-il lentement depuis que vous avez ajouté la seconde chaîne de presses ? Le stress saute-t-il chaque fois que le train décharge ? Cette recette enregistre deux valeurs chaque minute (le stock d'un coffre renforcé et le stress du réseau cinétique) dans un fichier, et trace la dernière heure de chacune en courbe.

Vous verrez comment écrire un journal avec fs.append, comment l'empêcher de remplir le disque (la rotation), comment le relire après un redémarrage, et comment transformer une liste de nombres en graphique avec gfx.line.

Ce qu'il vous faut

  • Un Micro-ordinateur (ou un Ordinateur moderne) : des couleurs pour deux courbes, et une disquette (64 Ko) pour le journal. Le programme n'enregistre qu'une fois par minute : la vitesse n'est pas un souci. Sur un Ordinateur moderne, les graphiques s'élargissent d'eux-mêmes.
  • Un coffre renforcé ou un coffre contre la face gauche de l'ordinateur : le stock à suivre.
  • Un bloc cinétique pour lire le stress. Le plus simple est l'arbre qui fait tourner l'ordinateur : c'est un périphérique cinétique sur la face back, et son réseau est celui de l'usine quand l'ordinateur est entraîné par l'usine. Pour surveiller un autre réseau, posez un Stressomètre de ce réseau contre l'ordinateur et changez JAUGE.
  • Si vous voulez, des moniteurs contre l'ordinateur pour voir les graphiques en grand.
Vu de dessus :

   [Coffre]-[PC]==== arbre de l'usine (le dos de l'ordinateur)
                   |
           (moniteurs devant, facultatifs)

Comment ça marche

1. Les réglages

Brass
local COFFRE = "left"                  -- l'inventaire à compter
local OBJET = "minecraft:iron_ingot"   -- l'objet à compter, ou nil pour tout
local JAUGE = "back"                   -- un bloc cinétique : l'arbre de l'ordinateur fait l'affaire
local PERIODE = 60                     -- secondes entre deux relevés
local JOURNAL = "/journaux/usine.csv"
local ANCIEN = "/journaux/usine.ancien.csv"
local OCTETS_MAX = 8000                -- au-delà, le journal devient l'ancien
local POINTS = 60                      -- valeurs sur les graphiques : la dernière heure

Les chemins commencent par / : ils partent de la racine du disque, quel que soit le dossier courant au lancement du programme. fs.write crée le dossier journaux au premier relevé.

2. Lire les valeurs

Un relevé est une petite table : l'heure, et les deux valeurs. Une valeur illisible (un coffre cassé, une jauge absente) reste nil : le relevé est quand même écrit, avec un champ vide, et la courbe montre un trou à cet endroit. Les deux lectures passent par pcall : un bloc retiré au mauvais moment n'arrête jamais l'enregistreur.

Brass
local function lire_valeurs()
  local releve = {heure = heure()}
  local coffre = peripheral.wrap(COFFRE)
  if coffre then
    local r = pcall(coffre.count, OBJET)
    if r.ok then releve.stock = r.value end
  end
  local jauge = peripheral.wrap(JAUGE)
  if jauge then
    local utilise = pcall(jauge.stress)
    local capacite = pcall(jauge.capacity)
    if utilise.ok and capacite.ok and capacite.value > 0 then
      releve.stress = math.round(utilise.value / capacite.value * 100)
    end
  end
  return releve
end

@inventory.count avec un identifiant d'objet ne compte que cet objet, sans identifiant il compte tout. Le stress est enregistré en pourcentage de la capacité, plus parlant que des unités de stress.

L'heure est celle du monde, tirée de os.day_time et mise sous la forme hh:mm : 0 vaut 6 h du matin, 1000 ticks font une heure. Gardez en tête qu'une journée de Minecraft dure 20 minutes réelles : un relevé par minute réelle, c'est 1 h 12 de temps du monde entre deux lignes du journal, et les 60 relevés des graphiques couvrent trois journées de Minecraft. L'horloge du monde n'a pas non plus de numéro de jour. C'est pourquoi les graphiques comptent le temps à rebours depuis le dernier relevé (« -15MIN », « MAINTENANT ») au lieu d'afficher des heures. Pour un compteur qui ne fait que grandir, utilisez os.clock, les secondes depuis le démarrage de l'ordinateur (il repart de 0 à chaque redémarrage).

3. Le fichier CSV et sa rotation

Le fichier est au format CSV (« valeurs séparées par des virgules ») : une ligne d'en-tête, puis une ligne par relevé. N'importe quel programme, ou vous avec cat, peut le lire.

Écran
heure,stock,stress
18:02,1462,58
19:14,1440,61
20:26,,61

fs.append ajoute une ligne à la fin du fichier, et le crée s'il le faut. Mais un journal qui ne fait que grandir finit par remplir le disque : à un relevé par minute, environ 20 Ko par jour réel. Avant chaque relevé, rotation regarde donc la taille du journal (fs.size, nil tant que le fichier n'existe pas). Au-delà de OCTETS_MAX, le journal est renommé en ancien avec fs.move, après suppression de l'ancien précédent. Deux fichiers de 8 Ko au plus : environ neuf heures de relevés chacun, 16 Ko sur le disque pour toujours.

Brass
local function rotation()
  local taille = fs.size(JOURNAL)
  if taille and taille > OCTETS_MAX then
    fs.delete(ANCIEN)
    fs.move(JOURNAL, ANCIEN)
  end
end

local function enregistrer(r)
  rotation()
  if not fs.exists(JOURNAL) then fs.write(JOURNAL, "heure,stock,stress\n") end
  fs.append(JOURNAL, r.heure .. "," .. champ(r.stock) .. "," .. champ(r.stress) .. "\n")
end

champ écrit nil comme un champ vide plutôt que le mot nil. Quand l'écriture échoue (not enough space, ou pas de disque dans le lecteur), l'erreur est rattrapée par le pcall autour d'enregistrer : l'écran affiche ÉCRITURE DU JOURNAL IMPOSSIBLE, l'ancien journal est supprimé pour faire de la place, et l'enregistreur continue.

4. L'historique en mémoire

Les graphiques ont besoin des 60 derniers relevés, pas du fichier entier. retenir les garde dans une liste, du plus ancien au plus récent, et oublie le plus ancien dès qu'il y en a plus que POINTS. Au lancement, charger_fichier lit l'ancien journal puis le courant et passe chaque ligne à retenir : après un redémarrage, les courbes reviennent comme elles étaient.

Brass
local function charger_fichier(chemin)
  local texte = fs.read(chemin)
  if texte == nil then return end
  for _, ligne in ipairs(string.split(texte, "\n")) do
    local c = string.split(ligne, ",")
    if #c == 3 and c[1] ~= "heure" then
      retenir({heure = c[1], stock = tonumber(c[2]), stress = tonumber(c[3])})
    end
  end
end

string.split garde les champs vides ("20:26,,61" donne trois champs, le deuxième vide), et tonumber("") vaut nil : un trou dans le fichier devient un trou dans la courbe. Le test écarte la ligne d'en-tête et la ligne vide qui suit le dernier saut de ligne.

5. Tracer un graphique

dessiner_graphe trace un graphique dans une bande de l'écran : son titre et sa dernière valeur, les axes, une ligne pointillée au milieu, l'échelle, et la courbe.

L'échelle vient des valeurs : la plus basse et la plus haute de l'historique. Un graphique peut demander une plage minimale : celui du stress montre toujours 0 à 100 %, pour qu'un réseau calme n'ait pas l'air de s'affoler. Quand toutes les valeurs sont égales, la plage est élargie de un pour éviter une division par zéro.

Chaque valeur devient ensuite un point. Le plus récent est toujours sur le bord droit, les autres partent vers la gauche, d'un pas par relevé :

Brass
local function x_de(i, n)
  return GRAPHE_X + (POINTS - n + i - 1) * (GRAPHE_L - 1) // (POINTS - 1)
end

et sa hauteur est sa place entre bas et haut, à l'envers puisque les lignes de pixels descendent :

Brass
local y = py + ph - 1 - math.round((v - bas) / (haut - bas) * (ph - 1))

gfx.line relie chaque point au précédent. Une valeur manquante remet avant à nil : la ligne s'arrête là et reprend à la valeur suivante. Le point le plus récent reçoit un point blanc, gfx.pixel avec une taille de 3.

Sous les graphiques, dessiner écrit l'axe du temps : une marque tous les 15 relevés, comptée à rebours depuis le plus récent, « -15MIN » pour il y a 15 minutes. Les positions ne dépendent pas de l'horloge, seulement de PERIODE : l'axe suppose un relevé par période, si bien qu'après un long arrêt de l'ordinateur, les plus vieux points sont plus anciens que ce que dit l'axe.

6. La boucle principale

mesurer fait un relevé : lire, écrire, retenir, dessiner. La boucle attend sa minuterie avec os.pull_event("timer") : ce programme n'a rien d'autre à attendre. Comme il ne dessine qu'une fois par minute, tout le graphique est redessiné à chaque fois ; quelques centaines d'instructions, rien pour un Micro-ordinateur.

Le programme complet

startup
-- Enregistreur : chaque minute, écrit le stock d'un coffre renforcé et le stress du réseau
-- cinétique dans un fichier CSV, et trace la dernière heure en deux graphiques.
-- Micro-ordinateur, un coffre renforcé (ou un coffre) à sa gauche, entraîné par l'arbre de l'usine.

-- 1. Réglages
local COFFRE = "left"                  -- l'inventaire à compter
local OBJET = "minecraft:iron_ingot"   -- l'objet à compter, ou nil pour tout
local JAUGE = "back"                   -- un bloc cinétique : l'arbre de l'ordinateur fait l'affaire
local PERIODE = 60                     -- secondes entre deux relevés
local JOURNAL = "/journaux/usine.csv"
local ANCIEN = "/journaux/usine.ancien.csv"
local OCTETS_MAX = 8000                -- au-delà, le journal devient l'ancien
local POINTS = 60                      -- valeurs sur les graphiques : la dernière heure

local GRAPHES = {
  {cle = "stock", titre = "FER", couleur = "light_blue"},
  {cle = "stress", titre = "STRESS %", couleur = "orange", bas = 0, haut = 100},
}

local historique = {}   -- les POINTS derniers relevés, du plus ancien au plus récent
local probleme = nil    -- affiché en bas de l'écran quand le journal ne peut pas être écrit

local function heure()
  local t = (os.day_time() + 6000) % 24000
  return string.format("%02d:%02d", t // 1000, t % 1000 * 60 // 1000)
end

-- 2. Lecture : un relevé est {heure =, stock =, stress =}, nil là où un bloc manque
local function lire_valeurs()
  local releve = {heure = heure()}
  local coffre = peripheral.wrap(COFFRE)
  if coffre then
    local r = pcall(coffre.count, OBJET)
    if r.ok then releve.stock = r.value end
  end
  local jauge = peripheral.wrap(JAUGE)
  if jauge then
    local utilise = pcall(jauge.stress)
    local capacite = pcall(jauge.capacity)
    if utilise.ok and capacite.ok and capacite.value > 0 then
      releve.stress = math.round(utilise.value / capacite.value * 100)
    end
  end
  return releve
end

-- 3. Le fichier : un en-tête, puis une ligne par relevé, "18:30,1234,75"
local function champ(v)
  if v == nil then return "" end
  return tostring(v)
end

-- Un journal plein devient l'ancien ; l'ancien est supprimé
local function rotation()
  local taille = fs.size(JOURNAL)
  if taille and taille > OCTETS_MAX then
    fs.delete(ANCIEN)
    fs.move(JOURNAL, ANCIEN)
  end
end

local function enregistrer(r)
  rotation()
  if not fs.exists(JOURNAL) then fs.write(JOURNAL, "heure,stock,stress\n") end
  fs.append(JOURNAL, r.heure .. "," .. champ(r.stock) .. "," .. champ(r.stress) .. "\n")
end

-- L'historique en mémoire : les graphiques n'ont besoin que des POINTS derniers relevés
local function retenir(r)
  table.insert(historique, r)
  if #historique > POINTS then table.remove(historique, 1) end
end

local function charger_fichier(chemin)
  local texte = fs.read(chemin)
  if texte == nil then return end
  for _, ligne in ipairs(string.split(texte, "\n")) do
    local c = string.split(ligne, ",")
    if #c == 3 and c[1] ~= "heure" then
      retenir({heure = c[1], stock = tonumber(c[2]), stress = tonumber(c[3])})
    end
  end
end

-- 4. Dessin
local taille = gfx.size()
local W, H = taille.w, taille.h
local GRAPHE_X = 26                      -- bord gauche des courbes, après l'échelle
local GRAPHE_L = W - GRAPHE_X - 6
local GRAPHE_H = (H - 12) // #GRAPHES    -- un graphique et son titre ; 12 px restent pour le temps

local function court(n)
  if math.abs(n) >= 10000 then return math.round(n / 1000) .. "K" end
  return tostring(n)
end

-- x de la i-ème de n valeurs : la plus récente est toujours sur le bord droit
local function x_de(i, n)
  return GRAPHE_X + (POINTS - n + i - 1) * (GRAPHE_L - 1) // (POINTS - 1)
end

local function dessiner_graphe(g, haut_g)
  local py, ph = haut_g + 14, GRAPHE_H - 18
  gfx.rect(1, haut_g, W, GRAPHE_H, "black", true)
  -- la plage des valeurs, au moins bas..haut quand le graphique en a
  local bas, haut, recente = math.huge, -math.huge, nil
  for _, r in ipairs(historique) do
    local v = r[g.cle]
    if v then
      bas, haut, recente = math.min(bas, v), math.max(haut, v), v
    end
  end
  if g.bas then bas, haut = math.min(bas, g.bas), math.max(haut, g.haut) end
  gfx.text(4, haut_g, g.titre, "white", 2)
  if recente == nil then
    gfx.text(GRAPHE_X, py + ph // 2 - 2, "PAS ENCORE DE DONNÉES", "gray")
    return
  end
  if haut == bas then haut = bas + 1 end
  local valeur = court(recente)
  gfx.text(W - 6 - #valeur * 8 + 2, haut_g, valeur, g.couleur, 2)
  -- les axes, une ligne pointillée au milieu, l'échelle
  gfx.line(GRAPHE_X - 1, py, GRAPHE_X - 1, py + ph, "gray")
  gfx.line(GRAPHE_X - 1, py + ph, GRAPHE_X + GRAPHE_L, py + ph, "gray")
  for x = GRAPHE_X, GRAPHE_X + GRAPHE_L, 4 do gfx.pixel(x, py + ph // 2, "gray") end
  gfx.text(2, py, court(haut), "light_gray")
  gfx.text(2, py + ph - 5, court(bas), "light_gray")
  -- la courbe : une ligne de chaque valeur à la suivante, coupée là où il en manque une
  local px, avant = nil, nil
  for i, r in ipairs(historique) do
    local v = r[g.cle]
    if v then
      local x = x_de(i, #historique)
      local y = py + ph - 1 - math.round((v - bas) / (haut - bas) * (ph - 1))
      if avant then gfx.line(px, avant, x, y, g.couleur) end
      px, avant = x, y
    else
      avant = nil
    end
  end
  if avant then gfx.pixel(px, avant, "white", 3) end
end

-- "15MIN" pour 900 secondes, "30S" pour 30
local function il_y_a(secondes)
  if secondes >= 60 then return (secondes // 60) .. "MIN" end
  return secondes .. "S"
end

local function dessiner()
  for i, g in ipairs(GRAPHES) do dessiner_graphe(g, (i - 1) * GRAPHE_H + 2) end
  -- l'axe du temps : une marque tous les 15 relevés, à rebours depuis le plus récent
  gfx.rect(1, H - 8, W, 8, "black", true)
  for k = 0, POINTS - 1, 15 do
    local etiquette = "MAINTENANT"
    if k > 0 then etiquette = "-" .. il_y_a(k * PERIODE) end
    local x = x_de(POINTS - k, POINTS)
    gfx.text(math.min(x - #etiquette * 2, W - #etiquette * 4), H - 6, etiquette, "gray")
  end
  if probleme then
    gfx.rect(1, H - 8, W, 8, "black", true)
    gfx.text(W // 2 - #probleme * 2, H - 6, probleme, "red")
  end
end

-- 5. Un relevé : lire, écrire dans le fichier, ajouter aux graphiques
local function mesurer()
  local r = lire_valeurs()
  local ecrit = pcall(enregistrer, r)
  if ecrit.ok then
    probleme = nil
  else
    probleme = "ÉCRITURE DU JOURNAL IMPOSSIBLE"
    pcall(fs.delete, ANCIEN)   -- faire de la place pour le prochain essai
  end
  retenir(r)
  dessiner()
end

-- 6. Boucle principale : l'historique des fichiers, puis un relevé par PERIODE
term.clear()
gfx.clear("black")
charger_fichier(ANCIEN)
charger_fichier(JOURNAL)
mesurer()
local minuterie = os.start_timer(PERIODE)
while true do
  local e = os.pull_event("timer")
  if e.id == minuterie then
    mesurer()
    minuterie = os.start_timer(PERIODE)
  end
end

À quoi ça ressemble

Cette démonstration garde le code de dessin du programme et remplit l'historique d'une heure inventée pour l'occasion : une chaîne de presses mange le fer, une seconde chaîne a démarré il y a 40 minutes et charge le réseau, un train a rempli le coffre il y a 25 minutes. Un relevé manque il y a 15 minutes (on agrandissait le coffre) : la courbe s'interrompt à cet endroit.

démo du graphique
local POINTS, PERIODE = 60, 60
local GRAPHES = {
  {cle = "stock", titre = "FER", couleur = "light_blue"},
  {cle = "stress", titre = "STRESS %", couleur = "orange", bas = 0, haut = 100},
}
local probleme = nil

-- une heure de relevés d'exemple, du plus ancien au plus récent
local historique = {}
for i = 1, POINTS do
  local r = {stock = 1500 - i * 16 + math.round(30 * math.sin(i)), stress = 52 + math.round(4 * math.sin(i / 2))}
  if i > 20 then r.stress = r.stress + 22 end
  if i > 35 then r.stock = r.stock + 900 end
  if i == 45 then r.stock = nil end
  table.insert(historique, r)
end

local taille = gfx.size()
local W, H = taille.w, taille.h
local GRAPHE_X = 26
local GRAPHE_L = W - GRAPHE_X - 6
local GRAPHE_H = (H - 12) // #GRAPHES

local function court(n)
  if math.abs(n) >= 10000 then return math.round(n / 1000) .. "K" end
  return tostring(n)
end

local function x_de(i, n)
  return GRAPHE_X + (POINTS - n + i - 1) * (GRAPHE_L - 1) // (POINTS - 1)
end

local function dessiner_graphe(g, haut_g)
  local py, ph = haut_g + 14, GRAPHE_H - 18
  gfx.rect(1, haut_g, W, GRAPHE_H, "black", true)
  local bas, haut, recente = math.huge, -math.huge, nil
  for _, r in ipairs(historique) do
    local v = r[g.cle]
    if v then
      bas, haut, recente = math.min(bas, v), math.max(haut, v), v
    end
  end
  if g.bas then bas, haut = math.min(bas, g.bas), math.max(haut, g.haut) end
  gfx.text(4, haut_g, g.titre, "white", 2)
  if recente == nil then
    gfx.text(GRAPHE_X, py + ph // 2 - 2, "PAS ENCORE DE DONNÉES", "gray")
    return
  end
  if haut == bas then haut = bas + 1 end
  local valeur = court(recente)
  gfx.text(W - 6 - #valeur * 8 + 2, haut_g, valeur, g.couleur, 2)
  gfx.line(GRAPHE_X - 1, py, GRAPHE_X - 1, py + ph, "gray")
  gfx.line(GRAPHE_X - 1, py + ph, GRAPHE_X + GRAPHE_L, py + ph, "gray")
  for x = GRAPHE_X, GRAPHE_X + GRAPHE_L, 4 do gfx.pixel(x, py + ph // 2, "gray") end
  gfx.text(2, py, court(haut), "light_gray")
  gfx.text(2, py + ph - 5, court(bas), "light_gray")
  local px, avant = nil, nil
  for i, r in ipairs(historique) do
    local v = r[g.cle]
    if v then
      local x = x_de(i, #historique)
      local y = py + ph - 1 - math.round((v - bas) / (haut - bas) * (ph - 1))
      if avant then gfx.line(px, avant, x, y, g.couleur) end
      px, avant = x, y
    else
      avant = nil
    end
  end
  if avant then gfx.pixel(px, avant, "white", 3) end
end

local function il_y_a(secondes)
  if secondes >= 60 then return (secondes // 60) .. "MIN" end
  return secondes .. "S"
end

local function dessiner()
  for i, g in ipairs(GRAPHES) do dessiner_graphe(g, (i - 1) * GRAPHE_H + 2) end
  gfx.rect(1, H - 8, W, 8, "black", true)
  for k = 0, POINTS - 1, 15 do
    local etiquette = "MAINTENANT"
    if k > 0 then etiquette = "-" .. il_y_a(k * PERIODE) end
    local x = x_de(POINTS - k, POINTS)
    gfx.text(math.min(x - #etiquette * 2, W - #etiquette * 4), H - 6, etiquette, "gray")
  end
  if probleme then
    gfx.rect(1, H - 8, W, 8, "black", true)
    gfx.text(W // 2 - #probleme * 2, H - 6, probleme, "red")
  end
end

gfx.clear("black")
dessiner()
Écran
Écran

Le tester

  1. Mettez PERIODE = 2 pour l'essai : un relevé toutes les deux secondes remplit vite les graphiques, et l'axe affiche « -30S », « -1MIN »... Remettez 60 ensuite.
  2. Lancez-le, arrêtez-le avec Ctrl+T après quelques relevés et regardez le fichier :

    Terminal
    > cat /journaux/usine.csv
    heure,stock,stress
    18:02,1462,58
    18:04,1462,58
    18:07,1440,61

    (Deux secondes réelles font 40 ticks, environ deux minutes et demie du temps du monde.)

  3. Relancez-le : les courbes reviennent tout de suite, lues dans le fichier.
  4. Retirez des objets du coffre : la courbe du fer descend au relevé suivant. Cassez le coffre : les relevés suivants ont un champ vide, la courbe s'interrompt, et elle reprend quand le coffre revient.
  5. La rotation. Mettez OCTETS_MAX = 200 et laissez tourner : quand le journal dépasse 200 octets, il devient usine.ancien.csv et un nouveau journal commence avec son en-tête. ls /journaux montre les deux fichiers.

Variantes

  • D'autres valeurs. Tout ce qu'un périphérique lit peut être enregistré : le niveau d'un réservoir (@tank.tanks, amount sur capacity), les wagons comptés par une Boucle de détection (@inductive_loop.count), l'altitude d'un dirigeable (@altitude_sensor.height). Ajoutez un champ au relevé, une colonne à l'en-tête, un graphique à GRAPHES.
  • Minimum, maximum, moyenne. Parcourez historique et écrivez les trois nombres sous le titre : la moyenne de la dernière heure en dit plus que la dernière valeur sur une chaîne qui tourne par à-coups.
  • Un compteur de jours. os.day_time revient à un petit nombre à chaque nouveau matin : retenez la valeur précédente et, quand la nouvelle est plus petite, ajoutez un à un compteur de jours sauvé dans un fichier. Les relevés se lisent alors jour 12, 18:30.
  • Un enregistreur central. Envoyez chaque relevé avec net.send à un ordinateur de la salle de contrôle qui enregistre toutes les usines de la base ; Télécommande par le réseau montre comment s'assurer que chaque message arrive.
  • Sur le tableau de bord. Le Tableau de bord d'usine peut montrer ces graphiques sur une seconde page.