Migrating from Legacy Systems

Remplacer les callbacks TestStand en Python

Associez chaque callback TestStand à son équivalent Python, et identifiez ceux qu'il faut supprimer plutôt que porter lors d'une migration.

JJulien Buteau
advanced11 min de lecture9 septembre 2026

Remplacer les callbacks TestStand en Python

Les callbacks sont généralement la partie la plus difficile d'une sortie de test executive traditionnelle. Le code de mesure se porte facilement : une lecture de tension reste une lecture de tension. Les callbacks sont plus durs, parce qu'ils encodent un comportement qui vit en dehors de toute séquence de test, et parce qu'une grande partie de ce qu'ils font disparaît au lieu de migrer.

Ce guide associe chaque callback courant à son équivalent Python, et identifie ceux qu'il ne faut pas porter du tout.

Ce que font les callbacks

Dans NI TestStand, un process model définit le cycle de vie autour de votre séquence de test, et les callbacks sont les points nommés où une séquence peut redéfinir le comportement par défaut. PreUUT demande un numéro de série. PostUUT traite le résultat. TestReport met en forme le rapport. Redéfinissez-en un dans une séquence cliente et votre version s'exécute à la place de celle du modèle.

Ce motif résout un vrai problème : personnaliser le cycle de vie pour un produit sans copier tout le modèle. Son coût est l'indirection, puisque comprendre une exécution peut demander d'ouvrir le modèle, la séquence cliente et chaque redéfinition.

La correspondance

CallbackRôleÉquivalent Python
PreUUTLoopUne fois avant le début des testsInitialisation de plug de portée station
PreUUTIdentifier l'unité suivanteBloc unit:, invite ou auto_identify
MainSequenceLe test lui-mêmePhases main:
PostUUTAgir sur le résultat d'une unitéPhases teardown:
PostUUTLoopUne fois après la dernière unitéDestruction de plug de portée station
TestReportMettre en forme le rapportNon porté : intégré
LogToDatabaseStocker les résultatsNon porté : intégré
ProcessModelPostStepAprès chaque étapeNon porté : le moteur enregistre les phases
SequenceFilePreStepAvant chaque étape d'un fichierPhases setup: ou depends_on

Quatre sur neuf ne sont pas portés. C'est le constat principal de la plupart des migrations : une grande part du code de callback existe pour déplacer des résultats vers du stockage, et ce travail n'appartient plus à votre code de test.

PreUUT : identifier l'unité

PreUUT demande généralement un numéro de série et le valide, décidant de continuer ou de sauter.

Dans le TofuPilot Framework, l'identification est déclarée plutôt que codée :

procedure.yaml
unit:  serial_number:    default_value: "SN000001"  part_number:    default_value: "PCB-MAIN-V2"

L'opérateur est sollicité avant le démarrage du test. Pour un poste qui dérive les numéros de série au lieu de les demander, auto_identify supprime l'invite :

procedure.yaml
unit:  auto_identify: true  serial_number:    default_value: "BURNIN-{slot}"  part_number:    default_value: "PCB-MAIN-V2"

Quand l'identification demande une vraie logique — lire un numéro de série en EEPROM, vérifier un ordre de fabrication dans un MES, refuser une unité déjà testée — cela devient la première phase de setup :

phases/identify.py
def identify(phase, unit, scanner, mes):    serial = scanner.read()    if not mes.is_released(serial):        phase.fail(f"{serial} n'a pas d'ordre de fabrication libéré")        return    unit.serial_number = serial

Une phase de setup en échec arrête l'exécution avant toute phase main, ce qui est le comportement que donnait PreUUT en renvoyant False.

PostUUT : agir sur le résultat

PostUUT s'exécute après le test avec le résultat disponible, typiquement pour piloter un voyant réussite/échec, imprimer une étiquette ou orienter l'unité.

Cela devient une phase de teardown. Le teardown s'exécute toujours, que le test ait réussi, échoué, produit une erreur ou expiré :

procedure.yaml
teardown:  - name: Signal Result    python: phases.signal_result
phases/signal_result.py
def signal_result(unit, indicator, printer):    indicator.ready()    printer.print_label(unit.serial_number)

Deux différences avec PostUUT méritent d'être connues.

Le teardown s'exécute par slot. Sur un poste multi-sockets, le teardown d'un slot peut s'exécuter pendant que d'autres slots testent encore : tout ce qui touche du matériel partagé a donc besoin de scope: execution pour ne s'exécuter qu'une fois, après la fin de tous les slots.

Brancher sur le résultat est généralement la mauvaise forme ici. Un PostUUT qui mettait en forme un rapport ou écrivait une ligne en base n'a pas d'équivalent, puisque le moteur envoie l'exécution lui-même. Ce qui reste est physique : allumer un voyant, imprimer une étiquette, libérer un banc. Quand une action doit réellement différer selon réussite ou échec, pilotez-la depuis la phase qui a rendu le verdict plutôt que de reconstituer ce verdict en teardown.

PreUUTLoop et PostUUTLoop : durée de vie du poste

Ceux-ci s'exécutent une fois autour de toute la session de test, généralement pour ouvrir les connexions d'instruments et les fermer à la fin.

Il n'y a pas d'équivalent en phase, parce que ce n'est pas de la logique de test. C'est de la durée de vie de ressource, et cela appartient au plug :

procedure.yaml
plugs:  - name: power_supply    python: plugs.psu:PowerSupply    scope: station    config:      address: "192.168.1.100"

Un plug de portée station est créé à la première utilisation et conservé entre les exécutions, donc les unités successives ne paient pas le coût de reconnexion. L'ouverture de connexion va dans __init__, le nettoyage dans __del__ :

plugs/psu.py
import pyvisaclass PowerSupply:    def __init__(self, address: str):        self._rm = pyvisa.ResourceManager()        self._inst = self._rm.open_resource(f"TCPIP::{address}::INSTR")    def read_voltage(self) -> float:        return float(self._inst.query("MEAS:VOLT?"))    def __del__(self):        self._inst.close()

Le moteur termine le sous-processus du plug à la destruction plutôt que de compter sur le ramasse-miettes, donc le nettoyage est déterministe. Il vérifie aussi la santé d'un processus de plug conservé avant chaque réutilisation et le relance s'il est mort — un des modes de défaillance qu'un PreUUTLoop écrit à la main ne couvrait généralement pas.

Notez la limite de portée : un plug station est libéré quand un nouveau déploiement est appliqué, donc les changements de code de plug prennent toujours effet à la première exécution après un déploiement. Dans un tofupilot run ponctuel, station se comporte comme execution.

TestReport et LogToDatabase : ne pas porter

Ce sont les deux callbacks dans lesquels les équipes investissent le plus, et les deux qui ne devraient pas survivre à la migration.

Dans une test executive classique, les résultats sont votre problème. Vous écrivez un callback de rapport pour produire du XML, un callback de base de données pour insérer des lignes, et un schéma pour les accueillir. Il est normal que cela représente des milliers de lignes, et normal que ce soit le code le moins aimé du dépôt.

Dans le TofuPilot Framework, le moteur envoie l'exécution : procédure, unité, phases, mesures, journaux, pièces jointes, verdict. Les postes mettent en file hors ligne et vident la file à la reconnexion, avec l'horodatage d'origine.

L'instinct de porter quand même un callback de rapport mérite d'être combattu. Si l'objectif était un fichier XML pour un client, c'est un rapport généré à partir de données stockées, pas une étape dans le test. Le garder dans le test signifie que chaque poste a besoin du code de mise en forme, que chaque changement de schéma est un redéploiement, et qu'un bug de formatage fait échouer une unité conforme.

Le compromis honnête : vous ne contrôlez plus le format de stockage. Si vous avez une vraie exigence d'écrire un schéma précis dans un système de référence, c'est un export depuis l'API, exécuté une fois, en dehors du poste.

ProcessModelPostStep : ne pas porter

ProcessModelPostStep s'exécute après chaque étape, typiquement pour journaliser les résultats d'étape ou accumuler des statistiques.

Le moteur enregistre déjà chaque phase : nom, verdict, durée, mesures avec leurs validateurs, nombre de reprises. Cela arrive dans le tableau de bord sans callback, et l'analyse au niveau phase, comme un Pareto des défaillances, est une vue sur ces données.

Ne portez ceci que s'il faisait quelque chose de réellement inhabituel, comme basculer un chien de garde entre les étapes. Cela devient du code ordinaire dans les phases concernées, pas un hook global.

Hooks par étape : depends_on et setup

SequenceFilePreStep et les hooks par étape similaires servent souvent à imposer un ordre ou des préconditions.

L'ordre se déclare directement :

procedure.yaml
main:  - name: Power On    python: phases.power_on    key: power_on  - name: Measure Rails    python: phases.measure_rails    depends_on: [power_on]

Les phases sans dépendances s'exécutent en parallèle sur un pool de workers ; depends_on sérialise ce qui doit l'être. Cela remplace un hook qui existait pour vérifier « l'étape précédente a-t-elle réussi » par l'énoncé de la vraie dépendance.

Les préconditions qui s'appliquent à tout vont dans setup, qui doit réussir avant toute phase main. Les préconditions d'une seule phase vont dans la phase, avec phase.skip() :

phases/advanced_calibration.py
def advanced_calibration(phase, device):    if not device.supports_advanced_mode():        phase.skip()        return    device.calibrate()

Comportement en cas d'échec

Beaucoup de code de callback existe pour contrôler ce qui se passe après un échec. C'est de la configuration :

procedure.yaml
execution:  on_first_failure: continue

Le stop par défaut annule les phases non démarrées. continue les exécute quand même, ce que vous voulez quand un profil d'échec complet vaut plus que le temps de cycle.

Pour les échecs intermittents, la reprise est par phase et explicite :

procedure.yaml
main:  - name: Network Connect    python: phases.network    retry:      limit: 5      delay: 1s

Les reprises ne se déclenchent pas automatiquement sur échec. La phase doit renvoyer une action de reprise, ou then: doit associer un verdict à une reprise. Chaque tentative est conservée et envoyée avec un retry_count, donc une unité passée à la quatrième tentative est visible comme telle au lieu de paraître propre.

Un ordre de migration qui fonctionne

  1. Inventoriez les callbacks et marquez chacun. La plupart tombent dans : rapport ou base de données (supprimer), cycle de vie (devient une portée de plug), ou vraie logique (devient une phase).
  2. Portez les plugs d'abord. Les connexions d'instruments sont la fondation, et bien les faire rend tout le reste testable.
  3. Portez MainSequence vers des phases main, une mesure à la fois, avec les validateurs en YAML plutôt que dans le code.
  4. Déplacez PreUUT dans le bloc unit:, en ajoutant une phase de setup seulement si l'identification a une vraie logique.
  5. Déplacez PostUUT dans le teardown, en vérifiant la portée de tout ce qui touche du matériel partagé.
  6. Supprimez les callbacks de rapport et de base de données. Faites tourner les deux systèmes en parallèle sur un lot et comparez les résultats stockés avant de retirer l'ancien chemin.
  7. Ajoutez les slots en dernier, une fois qu'une unité passe de façon fiable.

L'étape 6 est celle qui bloque. Il vaut la peine de s'accorder tôt avec le responsable des enregistrements qualité sur ce que signifie « les résultats sont dans le nouveau système », parce que cette conversation, et non le code, est généralement le chemin critique.

Points clés

  • Les callbacks se répartissent en trois groupes : cycle de vie, vraie logique, et plomberie de résultats.
  • La plomberie de résultats — rapports, écritures en base, journalisation par étape — n'est pas portée ; le moteur enregistre et envoie les exécutions.
  • Les callbacks de cycle de vie deviennent des portées de plug : station pour les connexions conservées entre exécutions, execution pour les ressources partagées, slot par unité.
  • La vraie logique devient des phases : setup pour les préconditions, teardown pour les actions après test qui s'exécutent toujours.
  • La gestion des échecs et l'ordre deviennent de la configuration : on_first_failure, retry et depends_on.

Plus de guides

Mettez ce guide en pratique