Aller au contenu

Cycle de vie : instanciation et pas de temps

Où vit le when

Depuis le 2026-10-04, la synchro (le when, les étages des broches) est dans Internal/McuCore.mo, le cœur placé à l'intérieur de MCU et de RPi_Pico ; les extraits ci-dessous qui citent MCU.mo s'y appliquent. Les sources des broches sont devenues les conductances de Internal.PinBridge, et le programme ne démarre qu'à la première synchro alimentée : voir Cœur, MCU et carte Pico.

Cette page détaille ce qui se passe concrètement (1) à la construction du modèle, (2) à chaque pas de temps de la simulation, et (3) à sa fin — ainsi que six pièges réels rencontrés pendant l'implémentation, parce qu'ils expliquent pourquoi le mécanisme final est ce qu'il est.

1. Instanciation (t = 0)

MCU déclare Internal.PyRuntime rt = Internal.PyRuntime(scriptPath, pythonHome) comme variable protégée : en Modelica, cette syntaxe (déclaration + appel du constructeur en valeur initiale) construit l'External Object une seule fois, au début de la simulation.

sequenceDiagram
    participant Sim as Exécutable de simulation<br/>(process OS, un par run)
    participant MCU as MCU (équations Modelica)
    participant Ctor as PyRuntime_new (C)
    participant CPy as CPython (interpréteur principal)
    participant Worker as Thread worker

    Sim->>MCU: initialisation du modèle
    MCU->>Ctor: PyRuntime(scriptPath, pythonHome, ...)
    Ctor->>CPy: pyhost_ensure() — LoadLibraryExW(PythonRuntime/python312.dll),<br/>Py_InitializeFromConfig + relais stdout,<br/>sauf si un autre composant l'a déjà fait, puis rend le GIL
    Ctor->>CPy: module machine explicatif (scripts de périphérique), une fois
    Ctor->>Worker: _beginthreadex(worker_main)
    Worker->>CPy: PyGILState_Ensure() puis Py_NewInterpreterFromConfig (GIL partagé)
    Worker->>Worker: dans SON sous-interpréteur : relais stdout,<br/>_pyruntime_native, sys.path, run_source(machine_time_shim.py)
    Worker-->>Ctor: init_state = INIT_OK (ou INIT_FAILED + message)
    Ctor-->>MCU: handle (ou ModelicaFormatError)
    Worker->>Worker: attend son tour (turn == TURN_MODELICA au départ), GIL relâché pendant l'attente
    Note over MCU: le when {initial(), ...} de MCU<br/>va déclencher le premier PyRuntime_sync juste après

Point notable : CPython n'est démarré qu'une fois par process, par le premier composant construit (pyhost_ensure, dans Resources/Include/pyhost.c) — un MCU ou un périphérique scripté, l'ordre de construction des External Objects n'étant pas garanti. Cet interpréteur principal sert aux scripts de périphérique ; chaque MCU crée en plus son sous-interpréteur, sur son propre thread worker (worker_init, pyruntime_module.c), avec ses sys.modules, __main__, sys.path et builtins : plusieurs microcontrôleurs ne partagent rien (cf. requirements.md, décision « Multi-instances »). Le sous-interpréteur naît sur le worker parce qu'un état de thread Python est lié au thread qui le crée, et que tout le Python du MCU s'exécute là ; PyRuntime_new attend son résultat pour signaler une erreur de configuration dès la construction. Le GIL est partagé entre tous les interpréteurs. Au retour de pyhost_ensure, le thread Modelica ne tient pas le GIL : c'est un invariant. Tout code qui appelle Python depuis ce thread (gestionnaires d'un périphérique scripté) l'encadre de PyGILState_Ensure/Release, et chaque worker le relâche chaque fois qu'il se gare — dans son attente initiale comme dans yield_to_modelica. Sans cela, un worker garderait le GIL pendant presque toute la simulation, et aucun autre composant ne pourrait exécuter une ligne de Python. Le protocole de tour garantit par ailleurs qu'un seul thread travaille à un instant donné : le GIL ne fait qu'entériner cette alternance. Les natives retrouvent leur microcontrôleur par g_current, local au thread (__thread) : un worker, un MCU.

2. Le protocole de synchro, à chaque pas de temps

Trois conditions peuvent déclencher un appel à PyRuntime_sync depuis le when de MCU (cf. requirements.md, décision « Interface GPIO côté Modelica ») : le réveil d'un sleep, un tick périodique (tickPeriod), ou la transition d'une broche actuellement en entrée. Dans les trois cas, c'est la même fonction C qui est appelée — c'est elle qui décide, en interne, si le script doit réellement reprendre la main.

sequenceDiagram
    participant Modelica as Solveur Modelica (when)
    participant Sync as PyRuntime_sync (C)
    participant Worker as Thread worker (script Python)

    Modelica->>Sync: PyRuntime_sync(rt, time, pinBoolIn[8])
    activate Sync
    Sync->>Sync: verrouille (CRITICAL_SECTION)
    Sync->>Sync: met à jour sim_time
    Sync->>Sync: détecte si une broche en entrée a changé
    alt réveil dû, OU vraie transition d'entrée, OU 1er appel
        Sync->>Worker: turn = TURN_WORKER, signal (WakeConditionVariable)
        activate Worker
        Worker->>Worker: reprend l'exécution du script<br/>(depuis le sleep()/l'appel shim qui l'avait bloqué)
        Worker->>Worker: ... code du script ...<br/>jusqu'au prochain appel machine.Pin(...)/.value()/.on()/.off()/time.sleep()
        Worker->>Sync: publie wake_requested_at, turn = TURN_MODELICA, signal
        deactivate Worker
    else pas encore l'heure (tick périodique ou reflet de notre propre sortie)
        Note over Sync: aucun handoff, l'état observé est juste rafraîchi
    end
    Sync->>Sync: déverrouille, lit pin_driven_value / pin_is_output / wake_requested_at
    Sync-->>Modelica: pinBoolOut[8], pinIsOutput[8], nextWakeTime
    deactivate Sync
    Note over Modelica: équations électriques (src[i].v, sw[i].control)<br/>mises à jour avec les nouvelles valeurs, échantillonné-bloqué jusqu'au prochain événement

Ce que voit le script entre deux appels shim : rien — il est un thread OS bloqué sur une variable de condition, complètement gelé, jusqu'à ce que PyRuntime_sync décide de le réveiller. C'est ce blocage, combiné au fait que le solveur peut avancer directement son temps simulé jusqu'à l'échéance demandée sans exécuter les instants intermédiaires, qui réalise la compression du sleep() (cf. scénario de vérification 2 : 2 h simulées exécutées en moins d'une seconde de calcul réel).

États du thread worker

stateDiagram-v2
    direction LR
    [*] --> AttentePremierTour
    AttentePremierTour --> Actif: 1er sync (t=0)
    Actif --> BloquéSleep: sleep(x)
    Actif --> BloquéIO: Pin/.value/.on/.off
    BloquéSleep --> Actif: sync, réveil dû
    BloquéIO --> Actif: sync (immédiat)
    Actif --> Terminé: fin du script, sys.exit()
    Actif --> Erreur: exception
    BloquéSleep --> Terminé: fin de simulation (SystemExit)
    BloquéIO --> Terminé: fin de simulation (SystemExit)
    Terminé --> [*]: destruction (Py_EndInterpreter)
    Erreur --> [*]: ModelicaError
Transition Ce qui se passe réellement
[*] → AttentePremierTour Thread créé (_beginthreadex), GIL acquis (PyGILState_Ensure), attend turn == TURN_WORKER
AttentePremierTour → Actif Premier appel à PyRuntime_sync, déclenché par when {initial(), ...} à t=0
Actif → BloquéSleep Le script appelle time.sleep(x) → yield_to_modelica(sim_time + x)
Actif → BloquéIO Le script appelle machine.Pin(...) → yield_to_modelica(sim_time) (yield immédiat) ; ou .value()/.on()/.off() → yield_until(sim_time + gpioOpTime, 0) (processeur occupé pendant la durée d'un accès, 5 µs par défaut ; immédiat si gpioOpTime = 0)
BloquéSleep → Actif PyRuntime_sync rappelé avec currentTime >= wake_requested_at
BloquéIO → Actif PyRuntime_sync rappelé à l'échéance demandée — une transition d'entrée n'écourte pas l'attente d'un accès à une broche (§3bis)
Actif → Terminé Fin normale du script (retour de run_source, ou SystemExit levée par sys.exit()) — script_done=1, nextWakeTime=+inf, plus de handoff ; le worker se gare jusqu'à la destruction (§4)
Actif → Erreur Exception Python non gérée — script_error=1, ModelicaError arrête la simulation
Bloqué* → Terminé Fin de simulation : PyRuntime_destroy pose shutdown, l'attente en cours lève SystemExit et le programme se déroule (§4)

Programme qui ne rend pas la main : pendant que le worker a son tour, le thread Modelica attend dans la boucle de drain de PyRuntime_sync. Avec MCU.hangWarningTime > 0 (10 s par défaut), cette attente se fait par tranches de 250 ms (HANG_CHECK_MS), et hang_check compare au seuil le temps réel écoulé depuis l'entrée dans le drain : au-delà, un ModelicaFormatWarning, répété à seuil doublé (10, 20, 40 s…). Deux cas, deux messages : le worker n'a pas rendu la main (boucle de calcul, attente sur ticks_ms()), ou il la rend sans cesse au même instant simulé (boucle d'accès aux broches avec gpioOpTime = 0). Rien n'est fait au script : un calcul long mais légitime finit normalement (verify_42).

Chaque appel au shim (Pin.value(), .on(), .off(), time.sleep*) passe par yield_to_modelica() ou sa variante yield_until(), qui rendent systématiquement la main à Modelica — c'est le mécanisme concret qui réalise le principe « tout appel au shim est un point de synchro potentiel » (cf. requirements.md). time.ticks_ms()/ticks_us() font exception : ce sont de simples lectures de l'horloge déjà connue, elles ne synchronisent pas (sinon une boucle de polling non bloquante deviendrait très coûteuse).

3. Le trajet d'un appel, couche par couche (extraits de code)

Les diagrammes ci-dessus montrent le protocole ; voici le même mécanisme avec le code réel, en suivant un exemple concret tiré de Resources/Scripts/MCU/demo.py : led.on() puis time.sleep(1).

# Resources/Scripts/MCU/demo.py
from machine import Pin
import time

led = Pin(0, Pin.OUT)

while True:
    led.on()
    time.sleep(1)
    led.off()
    time.sleep(1)

1. Le script appelle le shim. from machine import Pin ne lit rien sur le disque : machine et time ont déjà été injectés dans sys.modules avant l'exécution du script (voir integration-python.md). Pin.on() et time.sleep() ne font que déléguer au module natif :

# Resources/Scripts/_shim/machine_time_shim.py
class Pin:
    def on(self):
        _native.pin_write(self.id, 1)

def sleep(s):
    _native.sleep(float(s))

2. Le module natif C enregistre l'état et se bloque. native_pin_write écrit la valeur pilotée puis rend la main immédiatement (réveil à l'instant courant) ; native_sleep calcule l'échéance avant de rendre la main :

// PyRuntimeImpl.c
static PyObject* native_pin_write(PyObject* self, PyObject* args) {
    int id, value;
    if (!PyArg_ParseTuple(args, "ii", &id, &value)) return NULL;
    EnterCriticalSection(&g_current->cs);
    if (g_current->pin_is_output[id]) {
        g_current->pin_driven_value[id] = value;
    }
    LeaveCriticalSection(&g_current->cs);
    // la broche change tout de suite, puis le processeur reste occupé gpioOpTime
    yield_until(g_current->sim_time + g_current->gpio_op_time, 0);
    Py_RETURN_NONE;
}

static PyObject* native_sleep(PyObject* self, PyObject* args) {
    double seconds;
    if (!PyArg_ParseTuple(args, "d", &seconds)) return NULL;
    double wake_at = g_current->sim_time + (seconds > 0 ? seconds : 0);
    yield_to_modelica(wake_at);                // réveil différé
    Py_RETURN_NONE;
}

3. yield_to_modelica est le seul vrai point de blocage. C'est ici que le thread worker s'arrête, sans consommer de CPU, jusqu'à ce que turn repasse à TURN_WORKER :

static void yield_to_modelica(double wake_at) {
    struct PyRuntimeHandle* h = g_current;
    EnterCriticalSection(&h->cs);
    h->wake_requested_at = wake_at;
    h->wake_pending = 1;
    h->turn = TURN_MODELICA;
    WakeConditionVariable(&h->cv);
    while (h->turn != TURN_WORKER) {
        SleepConditionVariableCS(&h->cv, &h->cs, INFINITE);
    }
    LeaveCriticalSection(&h->cs);
}

4. Le when de MCU.mo déclenche le prochain appel. time >= pre(nextWakeTime) est précisément la condition qui fait que le solveur rappelle PyRuntime_sync à l'échéance du sleep(1) — ni avant (piège b, plus haut), ni beaucoup après (le solveur peut sauter directement à cet instant) :

// MCU.mo
when {initial(), time >= pre(nextWakeTime), sample(0, tickPeriod),
      change(pinBoolIn[1]) and not pre(pinIsOutputD[1]), ...} then
    (pinBoolOutC, pinIsOutputC, pwmFreq, pwmDuty, nextWakeTime) = Internal.PyRuntime_sync(rt, time, pinBoolInC, pinNodeVoltage);
end when;

Les tableaux de broches échangés avec le C sont des entiers 0/1 (pinBoolInC, pinBoolOutC, pinIsOutputC), convertis depuis et vers les booléens pinBoolIn, pinBoolOut et pinIsOutputD hors du when. Un tableau de Boolean serait passé au C tel quel, et la taille d'un booléen change selon la version d'OpenModelica (cf. requirements.md, décision « Tableaux de booléens et fonctions externes »).

// PyRuntime_sync.mo — le pont entre l'appel Modelica ci-dessus et la fonction C
external "C" PyRuntime_sync(handle, currentTime, pinBoolIn, pinAnalogIn, pinBoolOut, pinIsOutput, pwmFreq, pwmDuty, nextWakeTime) annotation(
    Include = "#include \"PyRuntimeImpl.c\"",
    IncludeDirectory = "modelica://MicroPythonMCU/Resources/Include");

pinAnalogIn (pinNodeVoltage côté MCU.mo) est arrivé avec machine.ADC : la tension brute, déjà calculée pour le seuillage numérique, est transmise en plus sous forme continue — Pin.value() lit pinBoolIn (seuillé), ADC.read_u16() lit pinAnalogIn (brut, mis à l'échelle sur 16 bits côté shim). Même point de synchro, même tableau d'index (0-7 = GP0-GP7, 8 = LED embarquée jamais utilisé côté ADC), aucun nouveau déclencheur de when requis (cf. requirements.md, décision « ADC »).

pwmFreq/pwmDuty sont arrivés avec machine.PWM : contrairement aux autres champs (mis à jour à chaque appel), ce sont des paramètres de configuration — machine.PWM.freq()/.duty_u16() les écrivent (points de synchro classiques), mais c'est ensuite Modelica qui génère le créneau réel en continu dans l'équation src[i].v de MCU.mo (mod(time, 1/pwmFreq[i]) < pwmDuty[i]/pwmFreq[i]), sans repasser par PyRuntime_sync à chaque front — hors de portée du mécanisme de synchro par thread pour des fréquences de l'ordre du kHz. Le PWM continue donc de tourner même après que le script se soit terminé (cf. requirements.md, décision « PWM (sorties modulées) »).

5. PyRuntime_sync décide s'il rend la main, puis attend le retour du worker :

// PyRuntimeImpl.c
if (input_changed || !h->wake_pending || currentTime + 1e-9 >= h->wake_requested_at) {
    h->turn = TURN_WORKER;
    WakeConditionVariable(&h->cv);
    while (h->turn != TURN_MODELICA) {
        SleepConditionVariableCS(&h->cv, &h->cs, INFINITE);
    }
}

Le worker reprend exactement là où yield_to_modelica l'avait arrêté (sortie de la boucle while (h->turn != TURN_WORKER)), remonte dans native_sleep, puis dans le shim, puis dans le script — sur led.off(), l'instruction suivant time.sleep(1).

Résumé du trajet complet pour cet exemple : script (led.on()) → shim Python (Pin.on) → module natif C (native_pin_write) → yield_to_modelica (blocage) → when de MCU.mo → PyRuntime_sync (C, réveille le worker) → retour dans yield_to_modelica → shim → script (ligne suivante, time.sleep(1)) → et ainsi de suite jusqu'à la fin du script.

3bis. Callbacks machine.Pin.irq() / machine.Timer : réveil « authentique » vs. « pitstop »

Un callback IRQ/Timer doit s'exécuter sur le thread worker (seul thread qui touche jamais l'interpréteur Python), donc forcément à l'un de ses points de réveil — mais parfois sans laisser l'appel bloquant en cours (typiquement time.sleep(...)) se terminer en avance. Exemple : un Timer périodique à 500 ms doit continuer à se déclencher pendant un sleep(3600), sans jamais écourter ce sleep.

yield_to_modelica distingue donc, à chaque réveil, un réveil authentique (l'échéance wake_at demandée par l'appel bloquant en cours est atteinte, OU une vraie transition d'entrée a eu lieu — auquel cas le script doit reprendre, comme toujours) d'un simple pitstop (aucune des deux raisons ci-dessus : le réveil ne sert qu'à exécuter un callback dû, ex. un Timer périodique pendant un sleep bien plus long). Dans le second cas, les callbacks dus sont exécutés puis le même wake_at est reposté — l'appel bloquant (native_sleep, etc.) ne reprend pas la main, le script ne voit rien.

static int yield_until(double wake_at, int interruptible) {
    struct PyRuntimeHandle* h = g_current;
    for (;;) {
        EnterCriticalSection(&h->cs);
        h->wake_requested_at = wake_at;
        h->wake_pending = 1;
        h->turn = TURN_MODELICA;
        WakeConditionVariable(&h->cv);
        while (h->turn != TURN_WORKER) {
            SleepConditionVariableCS(&h->cv, &h->cs, INFINITE);
        }
        int genuine = (interruptible && h->wake_had_input_change) || h->i2c_done_wake
                      || (h->sim_time + PYRUNTIME_EPS >= wake_at);
        LeaveCriticalSection(&h->cs);

        if (run_due_callbacks(h) != 0) {
            return -1;   /* callback a leve, PyErr deja positionne */
        }
        if (genuine) {
            return 0;    /* reprend l'appel bloquant en cours (native_sleep, etc.) */
        }
        /* pitstop pur : reposte le meme wake_at, rattend le prochain appel de PyRuntime_sync */
    }
}

yield_to_modelica(wake_at) n'est que yield_until(wake_at, 1) : le cas général, où une transition d'entrée rend le réveil authentique (un sleep retourne en avance). interruptible = 0 sert à l'attente « processeur occupé » d'un accès à une broche (MCU.gpioOpTime, cf. requirements.md, décision « Coût temporel des accès GPIO ») : seule l'échéance la termine. Sans cela, un périphérique qui répond au front qu'on vient d'émettre — le HX711 pose DOUT sur le front montant de PD_SCK — rendrait le réveil authentique au même instant, et l'impulsion retomberait à durée nulle. Une transition pendant cette attente n'est qu'un pitstop : les callbacks d'IRQ dus s'exécutent, comme une interruption entre deux instructions.

machine.disable_irq() pose irq_disabled : run_due_callbacks ne consomme alors rien (drapeaux et échéances restent en place), et earliest_timer_deadline ignore les Timer — un Timer échu mais masqué laisserait sinon nextWakeTime dans le passé, et le when time >= pre(nextWakeTime) de MCU.mo ne se redéclencherait plus jamais. enable_irq() appelle directement run_due_callbacks, sans point de synchro.

h->wake_had_input_change est posé par PyRuntime_sync (thread Modelica) juste avant de réveiller le worker — c'est la seule information qui manquait côté worker pour distinguer les deux cas, puisque la notion de « vraie transition d'entrée » n'est calculée que côté PyRuntime_sync.

Un point de verrouillage est impératif, pas une question de style : run_due_callbacks rassemble les callbacks dus sous verrou (cs), relâche complètement ce verrou, puis seulement alors appelle le callback Python. SleepConditionVariableCS ne relâche qu'un seul niveau de section critique — un callback qui touche une broche (pin.value(...), très probable en pratique, ex. un handler qui bascule une LED) ré-entre EnterCriticalSection avant de rappeler yield_to_modelica, ce qui laisserait cs techniquement encore tenu si l'appel Python avait lieu pendant que le verrou était déjà pris — deadlock réel entre le worker et Modelica. Vérifié sans deadlock ni callback manqué par un test isolé (scratchpad, avant intégration) avec un callback volontairement ré-entrant (lit puis écrit une broche depuis l'intérieur d'un callback Timer).

Le déclenchement d'un Timer/Pin.irq() ne nécessite aucun changement à MCU.mo ni à Internal/PyRuntime_sync.mo : le when de MCU.mo appelle déjà PyRuntime_sync sur toute transition d'entrée, et Modelica réagit déjà à n'importe quelle valeur de nextWakeTime — il suffit que PyRuntime_sync intègre les échéances de Timer actifs dans son calcul de nextWakeTime (min avec l'échéance propre du worker) et dans sa condition de réveil du worker.

La liaison série réutilise exactement ce mécanisme : machine.UART programme via nextWakeTime, comme un Timer, les changements de niveau de la trame émise et le milieu du bit de stop de la trame reçue (les autres bits reçus se résolvent aux fronts de la ligne, qui déclenchent déjà un appel), sans réveiller le worker — le script récupère les octets à son rythme par any()/read(). C'est aussi pourquoi la broche affectée à la réception est exclue du calcul de « vraie transition d'entrée » : sans ça, chaque front reçu ferait retourner en avance le sleep() en cours. Détail complet : peripherique-uart.md.

3ter. Le cycle de vie d'un périphérique série externe

Un appareil série externe (dérivé de Internal.PartialUartDevice) suit le même squelette — un External Object construit à t = 0, une fonction de synchro appelée depuis un when, une échéance republiée à chaque appel — mais sans thread, sans GIL et sans handshake, puisqu'il n'a aucun script libre à suspendre. Le détail complet vit dans peripheriques-uart-externes.md ; voici le déroulé.

Construction (t = 0)

Modelica construit les External Objects du modèle dans un ordre non garanti — c'est pourquoi le démarrage de CPython est partagé et idempotent (pyhost_ensure) : un périphérique scripté peut être construit avant le MCU, ou exister sans lui. UartDevice_new borne le débit, découpe la table de commandes une seule fois, arme l'échéance périodique, et laisse délibérément rx_last_level à 0 (cf. le piège de l'octet fantôme). Puis le when initial() tire : premier appel de synchro.

Régime permanent

Le when se déclenche sur quatre conditions :

Déclencheur À quoi il sert
initial() l'appel de t = 0
change(rxBoolIn) voir chaque front d'une trame entrante : le start arme le décodeur, les suivants résolvent les bits dont le milieu est passé
time >= pre(nextWakeTime) honorer l'échéance demandée au tour précédent
sample(0, tickPeriod) filet de sécurité

Le trajet d'un octet, minuté à 1200 bauds

Un bit dure 833 µs, une trame 8N1 dure 8,333 ms.

Instant Ce qui se passe
5,000 ms le script appelle uart.write() ; la tension chute sur la ligne
5,000 ms change(rxBoolIn) → synchro. rx_last_level = 1, niveau lu = 0 ⇒ front descendant. Milieu du bit 0 à 6,250 ms (½ start + ½ bit), milieu du stop à 12,917 ms : c'est le seul réveil programmé
5,833 … 12,500 ms à chaque front de la ligne, change(rxBoolIn) → synchro : les bits dont le milieu est passé se lisent au niveau tenu depuis l'appel précédent, sans réveil dédié
12,917 ms time >= pre(nextWakeTime) → derniers bits résolus, stop au niveau haut ⇒ octet valide, poussé dans la FIFO RX, retour à IDLE
13,333 ms la trame suivante enchaîne sans trou : nouveau front de start
≈ 71,7 ms le terminateur arrive ⇒ la ligne est livrée à uartdev_on_line, et l'accumulateur vidé dans le même geste
+ responseDelay la réponse entre en FIFO d'émission, txActive passe à vrai, txLevel à bas (bit de start)
pendant 8,333 ms un appel C par changement de niveau seulement : nextWakeTime tombe sur le prochain front de la trame, et Modelica tient txLevel entre deux — des bits identiques consécutifs ne coûtent aucun événement
+ 8,333 ms fin de trame ⇒ synchro ⇒ octet suivant, démarré à tx_end_time et non à now, pour enchaîner sans trou

Ce qui diffère du microcontrôleur

Script du MCU Gestionnaire d'un périphérique (mode Script)
Forme programme libre, boucle infinie fonction appelée, qui rend la main
Doit se suspendre en pleine pile d'appels ? oui (sleep) non
Donc pile propre nécessaire ? oui → thread OS + jeton de synchro non
Qui possède le temps ? le script le consomme Modelica le lui donne

C'est cette ligne-là, et non « périphérique contre microcontrôleur », qui sépare le bon marché du coûteux : un second composant qui va au bout de ses appels ne demande ni thread ni second interpréteur ; un second composant qui peut sleep() exige les deux.

4. Fin de simulation

Chaque simulate() d'OpenModelica exécute l'exécutable généré comme un process OS séparé : une relance redémarre le script à zéro sans rien de spécial à coder (scénario de vérification 5). L'arrêt n'en est pas moins propre (lot 2, 2026-10-02), pour que le programme finisse comme un programme interrompu sur la carte et que ses fichiers soient complets :

  1. PyRuntime_destroy pose h->shutdown et réveille le worker (WakeAllConditionVariable), puis l'attend au plus SHUTDOWN_WAIT_MS (2 s).
  2. Déroulement : le programme est presque toujours garé dans yield_until (un sleep()). Celui-ci voit shutdown, n'attend plus et lève SystemExit — à chaque appel suivant aussi, sans jamais rendre la main à Modelica. Les blocs finally/with s'exécutent ; run_source efface la SystemExit sans trace.
  3. Fin du sous-interpréteur : le worker, garé depuis la fin du programme (while (!h->shutdown)), reprend le GIL et appelle Py_EndInterpreter. Les gestionnaires atexit du sous-interpréteur s'exécutent, dont celui du shim qui ferme les fichiers que le programme a laissés ouverts (_fs_close_files, WeakSet tenu par _fs_open) : sans lui, leurs tampons étaient perdus (cf. ci-dessous). Le sous-interpréteur vit jusqu'ici, et non jusqu'à la fin du programme : la mémoire mem= d'I2CTarget, que la synchro lit encore après la fin du programme, reste valide.
  4. Finalisation de CPython : chaque composant qui a obtenu CPython par pyhost_ensure() le rend par pyhost_release() (microcontrôleurs, et périphériques en mode Script, qui libèrent d'abord leurs références) ; le dernier appelle Py_FinalizeEx. Compteur et drapeau sont en __declspec(selectany) : uniques pour le process, même si pyhost.c est compilé dans deux unités.

Replis : un programme qui avale SystemExit (except: nu dans une boucle) n'est pas joint dans le délai ; un thread Python lancé par le programme (_thread) vivrait encore dans le sous-interpréteur, et Py_EndInterpreter arrêterait le process (Py_FatalError). Dans les deux cas : avertissement, pyhost_abandon(), et plus aucun appel à Python dans le process — l'OS récupère tout à la sortie, comme avant que l'arrêt propre n'existe. Vérifié par verify_43 (bloc finally exécuté, fichier jamais fermé complet sur disque) ; les deux replis ont été vérifiés à la main.

Fichiers non fermés : la fermeture est explicite. Constaté en écrivant verify_43 : sans _fs_close_files, un fichier ouvert dans __main__ et jamais fermé restait vide après Py_EndInterpreter, alors que le même code dans un sous-interpréteur créé par _xxsubinterpreters (même CPython 3.12.4, modes isolé et non isolé, depuis un thread secondaire) écrit bien son contenu. L'objet fichier n'est jamais libéré : un close() surchargé n'est pas appelé, alors qu'au moment des atexit le fichier est encore ouvert et son flush() fonctionne. Une simple référence faible sur le module __main__ (sys._k = weakref.ref(sys.modules['__main__'])) suffit à rétablir l'écriture : l'espace de noms __main__, que le programme partage avec le shim (exécuté lui aussi dans __main__), survit à la finalisation des modules dans notre configuration. Cause exacte non élucidée ; plutôt que de dépendre de l'ordre de finalisation, le shim ferme explicitement ce qu'il a ouvert.

5. Débogage (debugpy + VS Code)

Avec MCU.debugEnabled, le programme se débogue depuis VS Code (lot 4, décision « Débogage du programme (debugpy + VS Code) » de requirements.md ; guide utilisateur : Déboguer avec VS Code). Le débogueur est debugpy 1.8.20, vendoré dans Resources/Debugpy/ (make_debugpy.sh). Il tourne dans le sous-interpréteur du MCU, en mode « adaptateur dans le process » (listen(..., in_process_debug_adapter=True)) : pydevd parle DAP directement à VS Code sur 127.0.0.1:debugPort, sans process adaptateur (il le lancerait sinon avec sys.executable, ici l'exécutable de simulation). Ce qui le pilote est dans Resources/Scripts/_shim/debug_host.py, exécuté par le worker dans un module propre, _mcu_debug : pas dans __main__, dont VS Code affiche les variables.

Il n'y a rien à coder pour la pause : un programme arrêté sur un point d'arrêt garde son tour (turn == TURN_WORKER), et PyRuntime_sync l'attend comme il attend n'importe quel calcul. Le temps simulé est donc figé pendant la pause, et les résultats sont ceux d'une simulation sans débogueur. verify_60 contrôle les horodatages après une pause de 2 s.

Initialisation en mode débogage

sequenceDiagram
    participant MCU as MCU (équations Modelica)
    participant Ctor as PyRuntime_new (C)
    participant Worker as Thread worker
    participant Host as debug_host.py<br/>(module _mcu_debug)
    participant Pydevd as pydevd<br/>(threads démons)
    participant VS as VS Code

    MCU->>Ctor: PyRuntime(..., debugEnabled, debugPort)
    Ctor->>Ctor: 2e MCU avec debugEnabled ? ModelicaFormatError<br/>(g_debug_count, verify_61)
    Ctor->>Ctor: lit debug_host.py (voisin du shim),<br/>chemin de Resources/Debugpy
    Ctor->>Worker: _beginthreadex(worker_main)
    Worker->>Worker: Py_NewInterpreterFromConfig(MCU_INTERP_CONFIG_DEBUG)<br/>= threads démons permis
    Worker->>Worker: relais stdout, _pyruntime_native, sys.path
    Worker->>Host: debug_host_load + start(port, debugpy_dir, shim_dir)
    Host->>Host: import debugpy AVANT le shim<br/>(time encore le vrai module)
    Host->>Host: LIBRARY_ROOTS += dossier du shim<br/>(justMyCode n'y entre pas)
    Host->>Pydevd: listen(127.0.0.1:port, in_process_debug_adapter=True)
    Pydevd->>Pydevd: _WaitForConnectionThread : accept()
    Worker->>Worker: run_source(machine_time_shim.py)<br/>(_fs_stdlib += dossier de debugpy)
    Worker-->>Ctor: init_state = INIT_OK
    Ctor-->>MCU: handle
    Note over MCU: tous les composants construits,<br/>puis premier PyRuntime_sync (t = 0)
    MCU->>Worker: turn = TURN_WORKER
    Worker->>Host: wait(port, fsSource, fs_root)
    Host->>Host: setup_client_server_paths(image -> copie),<br/>pathMappings du client ignorés
    Host-->>MCU: journal : « waiting for VS Code on port N »
    Note over MCU: PyRuntime_sync attend (timeout mou coupé)
    VS->>Pydevd: connexion, initialize, attach
    VS->>Pydevd: setBreakpoints, configurationDone
    Pydevd-->>Host: wait_for_client() rend la main
    Host-->>MCU: journal : « VS Code attached »
    Worker->>Worker: boot.py, puis le programme (tracé par sys.monitoring)

L'attente de VS Code a lieu au premier tour du worker (worker_main), et non dans le constructeur. À ce moment, tous les composants du modèle sont construits : un second MCU débogué a donc déjà été refusé. Dans le constructeur, le premier MCU aurait bloqué la construction du second, et l'erreur ne serait jamais venue. La copie de la flash existe aussi, ce qui permet de poser la correspondance des chemins.

Pendant la simulation : point d'arrêt, pas à pas, reprise

sequenceDiagram
    participant Modelica as Solveur Modelica (when)
    participant Sync as PyRuntime_sync (C)
    participant Worker as Thread worker (programme)
    participant Pydevd as pydevd (threads démons)
    participant VS as VS Code

    Modelica->>Sync: PyRuntime_sync(t = 0,8 s)
    Sync->>Worker: turn = TURN_WORKER
    Worker->>Worker: count += 1 → point d'arrêt (sys.monitoring)
    Worker->>Pydevd: suspension : do_wait_suspend<br/>(vraies attentes de time, GIL rendu)
    Pydevd-->>VS: event stopped (reason = breakpoint)
    Note over Sync,Worker: Sync attend turn == TURN_MODELICA :<br/>temps simulé figé, aussi longtemps qu'il faut
    VS->>Pydevd: stackTrace, scopes, variables
    VS->>Pydevd: next / stepIn
    Pydevd->>Worker: une ligne, puis nouvelle suspension
    Pydevd-->>VS: event stopped (reason = step)
    VS->>Pydevd: continue
    Pydevd->>Worker: reprise
    Worker->>Worker: led.toggle() → yield_until(t + gpioOpTime)
    Worker->>Sync: wake_requested_at, turn = TURN_MODELICA
    Sync-->>Modelica: sorties publiées, prochaine échéance

Les callbacks Pin.irq()/Timer, exécutés par run_due_callbacks sur le même worker, s'arrêtent sur leurs points d'arrêt de la même façon. Une évaluation demandée par VS Code s'exécute sur le thread suspendu : si elle appelle une native (led.value()), celle-ci fait un vrai tour de synchro et consomme gpioOpTime.

Fin de simulation

sequenceDiagram
    participant Destroy as PyRuntime_destroy (C)
    participant Worker as Thread worker
    participant Host as debug_host.stop()
    participant Pydevd as pydevd
    participant VS as VS Code

    Destroy->>Worker: shutdown = 1 (attente ≤ SHUTDOWN_WAIT_MS)
    Worker->>Worker: SystemExit déroule le programme,<br/>puis le worker se gare
    Worker->>Host: debug_host_stop(ts)
    Host->>Pydevd: _WaitForConnectionThread : _kill_received,<br/>connexion locale factice pour sortir d'accept()
    Host->>Pydevd: stoptrace()
    Pydevd-->>VS: event terminated (VS Code ferme sa session)
    Pydevd->>Pydevd: arrêt des threads Writer, CheckAlive, Command
    Host->>Pydevd: lecteur encore vivant ? socket.shutdown(SHUT_RDWR)
    Worker->>Worker: attend (≤ 1 s) qu'il ne reste que son état de thread
    Worker->>Worker: worker_end_interpreter → Py_EndInterpreter

Sans cet arrêt explicite, worker_end_interpreter trouverait d'autres threads dans le sous-interpréteur et renoncerait à le terminer (avertissement « other Python threads are still running », Python non finalisé).

Pièges rencontrés, et ce qu'ils ont imposé

  • time du shim : pydevd vérifie que time a mktime (_pydev_saved_modules.py) et s'en sert pour ses attentes en pause. Importé après le shim, il recevrait le time simulé : refus au démarrage, ou pauses qui feraient avancer la simulation. D'où start() avant le shim.
  • Cloisonnement de la flash : _fs_user prend pour du code de l'élève tout ce qui n'est pas sous la stdlib. Une évaluation de pydevd sur le worker aurait reçu le faux os et un open() cloisonné. D'où le dossier de debugpy dans _fs_stdlib.
  • Filtres de pas à pas : à l'attachement, PYDEVD_FILTERS est remplacé par les rules du client (vides par défaut), et le pas entrant sur led.toggle() entrait dans le shim (défaut trouvé par verify_60). Les racines de bibliothèque (LIBRARY_ROOTS), elles, sont conservées : le dossier du shim y est ajouté, et justMyCode n'y entre pas.
  • Threads bloqués sous Windows : fermer une socket depuis un autre thread ne réveille ni accept() ni recv(). Le premier bloque _WaitForConnectionThread, qui attend une nouvelle connexion même après un détachement ; une connexion locale factice le débloque. Le second bloque le lecteur de pydevd quand VS Code ne ferme pas la connexion à temps (programme en erreur, verify_63) ; un shutdown(SHUT_RDWR) le débloque.
  • pathMappings du client : envoyés à l'attachement, ils remplacent ceux que pose le serveur. Le modèle Remote Attach de VS Code en contient un, ${workspaceFolder} → ".", que pydevd résout vers le dossier courant de la simulation (C:\temp\OpenModelica\OMEdit\... sous OMEdit) : plus aucun point d'arrêt n'était atteint (constaté avec le vrai VS Code). VS Code et la simulation étant sur le même poste, ces correspondances n'ont jamais lieu d'être : debug_host.py remplace setup_client_server_paths par une version qui les ignore et ne garde que la correspondance image → copie de la flash. Le client de test envoie ce même pathMappings, avec un dossier de travail distinct du dossier de la simulation (verify_60 reproduit le défaut, verify_62 vérifie que la flash n'en souffre pas).
  • Avertissement « frozen modules » de pydevd au démarrage : sans objet (modules gelés de la stdlib), coupé par PYDEVD_DISABLE_FILE_VALIDATION.

Diagnostic : la variable d'environnement MCU_DEBUGPY_LOG (un dossier) fait écrire à debugpy ses journaux (debugpy.log_to), utiles pour suivre les messages DAP échangés.

Six pièges rencontrés (et pourquoi le mécanisme est ce qu'il est)

Ces six bugs, trouvés pendant l'implémentation, ont directement façonné le protocole ci-dessus — les documenter évite de les réintroduire par inadvertance lors d'une future extension.

a) Boucle de réveil intempestif (auto-déclenchement)

Le premier when déclenchait la synchro sur toute transition de pinBoolIn[i], y compris sur les broches que le script pilote lui-même en sortie. Or écrire une sortie fait changer sa propre tension mesurée, ce qui rentrait dans la condition change(pinBoolIn[i]) → re-déclenchait PyRuntime_sync → qui laissait le script avancer encore → qui écrivait encore → boucle infinie au même instant simulé, sans que le temps n'avance jamais.

Correctif : la condition ne considère que les broches actuellement en entrée (change(pinBoolIn[i]) and not pre(pinIsOutputD[i])).

b) Réveil prématuré d'un sleep()

Même après le correctif (a), PyRuntime_sync réveillait systématiquement le thread worker à chaque appel — y compris ceux causés par le tick périodique, alors que le script avait demandé de dormir jusqu'à une heure ultérieure. Un sleep(3600) pouvait ainsi être écourté à quelques dixièmes de seconde (la période du tick) sans qu'aucune vraie raison ne le justifie.

Correctif : PyRuntime_sync ne rend la main au worker que si (i) son réveil demandé est effectivement atteint, ou (ii) une broche d'entrée a réellement changé de valeur depuis le dernier appel (comparaison explicite en C, pas seulement la condition du when), ou (iii) c'est le tout premier appel.

c) Plantage dans la remontée de traceback

La première version de la gestion d'erreur récupérait la trace Python manuellement (PyErr_Fetch + module traceback) pour produire un message d'erreur unique avec le vrai chemin du script. Ça fonctionnait isolément (testé hors du contexte OpenModelica) mais provoquait un plantage systématique (violation d'accès) une fois exécuté dans l'exécutable de simulation généré — cause précise non identifiée.

Correctif d'alors : PyRun_SimpleString à la place, qui affiche elle-même la trace via sys.stderr (donc relayée vers ModelicaFormatMessage, cf. integration-python.md) avant de rendre la main en cas d'échec ; PyRuntime_sync se contente de détecter l'échec pour déclencher ModelicaError. Limitation : le nom de fichier affiché dans la trace était <string>.

Réexamen (lot 2, 2026-10-02) : le plantage était très probablement le défaut d'OpenModelica contourné depuis par Library = "-lwinpthread" (tout ModelicaError plantait). Les scripts de périphérique compilaient déjà leur source sous son chemin (devscript.c : Py_CompileString(src, path) + PyEval_EvalCode + PyErr_Print) sans incident. Le programme du microcontrôleur passe désormais par le même chemin (run_source, pyruntime_module.c) : la trace nomme le fichier, cite la ligne fautive, et verify_04 le contrôle. PyRun_SimpleString avait un second défaut : sur SystemExit (sys.exit() dans le programme), PyErr_Print appelle exit() — le process de simulation s'arrêtait net, sans « The simulation finished successfully », fichier de résultats tronqué. run_source traite SystemExit comme une fin normale du programme (verify_41).

d) Réveils immédiats chaînés au même instant simulé (script bloqué en silence)

Trouvé en vérifiant la luminosité de l'icône sur Examples.Gpio.LedChaser (script qui construit 8-9 Pin en boucle puis les pilote en séquence, chacun un point de synchro à réveil immédiat, c'est-à-dire wake_requested_at == currentTime) : GP0 ne basculait jamais, alors que checkModel et simulate() se terminaient tous les deux « avec succès », sans aucune erreur. Bissection par nombre de broches pilotées en boucle : 2-3 broches fonctionnent, 4 et plus échouent silencieusement.

Cause identifiée (confirmée via simflags="-lv LOG_EVENTS,LOG_INIT -w", pas une supposition) : quand plusieurs réveils immédiats se chaînent au même instant simulé (ex. Pin(i, Pin.OUT) répété dans une boucle Python, chacun un aller-retour shim → yield_to_modelica → retour), OpenModelica ne ré-invoque pas de façon fiable PyRuntime_sync au-delà de 2-3 itérations d'événement au même instant — l'itération d'événement s'arrête avant que le thread worker n'ait fini de vider tous ses réveils immédiats en attente, qui restent alors bloqués indéfiniment sur la variable de condition.

Correctif : ne plus compter sur l'itération d'événement Modelica pour rappeler PyRuntime_sync autant de fois que nécessaire — la fonction C vide elle-même en interne, dans une seule invocation, toute la chaîne de réveils immédiats en attente (boucle for (;;) autour du réveil du worker, qui ne s'arrête que si le script est terminé/en erreur ou si le prochain réveil demandé n'est plus immédiat) :

if (input_changed || !h->wake_pending || currentTime + 1e-9 >= h->wake_requested_at) {
    for (;;) {
        h->turn = TURN_WORKER;
        WakeConditionVariable(&h->cv);
        while (h->turn != TURN_MODELICA) {
            SleepConditionVariableCS(&h->cv, &h->cs, INFINITE);
        }
        if (h->script_done || h->script_error) {
            break;
        }
        if (!h->wake_pending || h->wake_requested_at > currentTime + 1e-9) {
            break;
        }
    }
}

Bug latent depuis l'ajout du pont à 9 broches (LED embarquée) — potentiellement déclenchable par tout script qui configure plusieurs broches en boucle sans sleep() entre elles, pas seulement Gpio.LedChaser. Vérifié par bissection sur des variantes de MCU.mo, puis re-testé sur Gpio.LedChaser en entier et sur les 5 scénarios de Resources/Verification/ (aucune régression).

e) Tempête d'événements à durée simulée nulle (Timer à période ≤ 0)

Repéré en concevant le mécanisme de « pitstop » (§3bis), pas par un bug reproduit en pratique : un Timer(period=0) (ou une période sous la tolérance numérique) redeviendrait dû immédiatement après son propre déclenchement. Contrairement au piège (d) — borné, drainé en une seule invocation C — celui-ci ne serait pas contenu par la boucle de drain de PyRuntime_sync : cette boucle ne s'applique qu'au sein d'un même currentTime, alors qu'ici chaque pitstop rend la main à Modelica (le sleep() du worker restant lointain) avant le pitstop suivant. Résultat : une suite non bornée d'appels PyRuntime_sync à currentTime inchangé, potentiellement un blocage du solveur ou de sa limite d'itération d'événements.

Correctif : native_timer_init rejette (ValueError) toute période sous un plancher TIMER_MIN_PERIOD = 1 ms, avant même d'armer le minuteur — même style que native_pwm_set_freq, qui rejette déjà les fréquences négatives.

f) Deux microcontrôleurs câblés l'un à l'autre : boucle algébrique entre leurs when

Trouvé avec Examples.MultiMcu.Handshake (A.GP0 → B.GP0, B.GP1 → A.GP1) : omc refuse de construire le modèle (« The language feature non-linear equations within when-equations is not supported »), alors que checkModel passe. La sortie de la synchro de A fixe la tension de sa source, donc, par un simple fil, le niveau lu par B, donc le déclenchement et les sorties du when de B, donc le niveau lu par A : les deux when sont pris dans une même boucle algébrique. C'est le défaut déjà rencontré entre un MCU et un périphérique série, résolu côté périphérique par sa capacité d'entrée CIn — mais le MCU n'en a pas.

Correctif : dans MCU.mo, les sources de tension des broches (src[i].v, sw[i].control) lisent pre() des sorties de la synchro (pinBoolOutC, pinIsOutputC, uartTxPin, uartTxLevel, pwmFreq, pwmDuty). Une écriture publiée par PyRuntime_sync n'apparaît électriquement qu'à l'itération d'événement suivante, au même instant simulé : la boucle algébrique devient une itération d'événements, que Modelica sait résoudre. Les entrées (pinBoolIn, pinNodeVoltage lu par l'ADC) ne sont pas retardées. Aucun scénario existant n'a changé de résultat ; dans la poignée de main, ACK monte 5 µs après REQ : le gpioOpTime de la lecture de REQ par le gestionnaire de B, le passage d'une carte à l'autre ne coûtant aucun temps simulé.