Patrick Schurig 88c96c2264 ops(banc): .75 passe en Europe/Paris, et la couture est datée
`Europe/London` sur un banc français était une erreur de configuration. La
correction a été différée tant que le décalage n'était pas visible : déplacer les
frontières d'une heure sans rien qui le dise aurait rendu l'historique illisible
de part et d'autre, sans qu'aucun lecteur ne puisse savoir où passait la couture.
`periodStartedAt`, posé hier, a levé cette réserve.

COUTURE : 2026-09-05T06:53:30Z. Avant, journée de reporting de 23:00 à 23:00 UTC ;
après, de 22:00 à 22:00. Toute comparaison jour à jour qui franchit cet instant
compare deux découpages différents.

Méthode : `Configuration.SetTimeZone` en JSON-RPC. `System.SetTimeZone` N'EXISTE
PAS en 1.15.2 — malgré la note de dépréciation de `Configuration.SetTimeZone` qui
dit d'utiliser celui-là. Suivre la note aurait donné « No such method ».

CONFIRMÉ EN VRAI : le moteur a suivi SANS redémarrage de nymead. Ce n'était
jusqu'ici mesuré qu'en conteneur avec un programme Qt6 ; `GetEnergyRatios.timeZone`
et `System.GetTime.timeZone` sont passés à Europe/Paris au cycle suivant, et
`periodStartedAt` a reculé de 23:00 à 22:00 UTC.

ET LA PAIRE A SERVI AU MOMENT EXACT OÙ ELLE SERVAIT. `measuredSince` n'a pas bougé
(05:04:29 UTC) : la frontière publiée a changé, la portée réelle de la mesure non.
Sans le second champ, le bilan du jour aurait paru couvrir une heure de plus qu'il
ne couvre — un décalage introduit par la correction elle-même, le lendemain du
jour où on a posé de quoi le voir.
2026-09-05 08:54:42 +02:00

11 KiB

Fuseau horaire — le silence d'une heure

Ouvert le 2026-09-04, après qu'une obligation éco déclarée à "12:00" se soit résolue à 13:00 sur l'horloge de l'atelier. Le moteur avait raison ; c'est la question qui n'avait jamais été posée.

1. Le constat

.75 tourne en Europe/London. Une obligation dailyDeadline: "12:00" s'y résout donc à 12:00 heure de Londres, soit 13:00 en France. Rien, nulle part, ne le dit.

L'écart est d'une heure et ressemble trait pour trait à un défaut d'heure d'été dans la résolution d'échéance — j'ai failli l'imputer au moteur avant de lire l'horloge de la box. C'est le premier coût du silence : il déplace le soupçon.

Le second coût est celui qui compte. Chez un client français, une box mal fuseau-horée décale toutes ses fenêtres temporelles. Une obligation « avant 7 h » se déclenche à 8 h. Et quand les heures creuses, Tempo et le marché de gros arriveront, elles se décaleront aussi — sauf que là, personne ne le verra avant de compter sa facture.

2. Fait établi — il n'y a qu'UN fuseau, et il vient du système

Vérifié, pas supposé :

  • EnergyArbitrator::update() prend QDateTime::currentDateTime() (energyplugin/etm/energyarbitrator.cpp:329), donc le fuseau du PROCESSUS nymead.
  • resolveDeadline() construit l'échéance avec now.timeZone() (energyplugin/etm/types/ecotarget.cpp). Elle n'introduit aucun fuseau à elle : elle hérite.
  • nymea n'a pas de fuseau propre. System.GetTime renvoie QTimeZone::systemTimeZoneId() (libnymea-core/jsonrpc/systemhandler.cpp), et System.SetTimeZone écrit le fuseau système via le SystemController de la plateforme. Configuration.SetTimeZone est déprécié au profit du premier.

Conséquence : la box et le moteur ne peuvent pas diverger entre eux. Il n'existe pas de réglage nymea qui dirait autre chose que timedatectl. C'était la première question — elle est fermée, et dans le sens rassurant.

3. Fait mesuré — un processus vivant SUIT le changement

Mesuré le 2026-09-04 dans le conteneur build-cross-arm64, avec un programme Qt6 qui lit QTimeZone::systemTimeZoneId() et QDateTime::currentDateTime() en boucle pendant qu'on bascule /etc/localtime sous lui :

  systemTimeZoneId=Europe/London  currentDateTime=06:48:50 BST   zone(now)=Europe/London
  --- /etc/localtime bascule vers Europe/Paris ---
  systemTimeZoneId=Europe/Paris   currentDateTime=07:48:53 CEST  zone(now)=Europe/Paris

Changer le fuseau ne demande donc PAS de redémarrer nymead. Ce n'est pas une évidence : la mise en cache du fuseau par processus est un comportement classique de la libc, et si Qt l'avait fait, une correction de fuseau serait restée sans effet jusqu'au prochain redéploiement — une correction qui ne corrige rien étant pire que pas de correction.

Corollaire à ne pas perdre : une échéance en cours de résolution peut donc changer d'heure sous le moteur, sans redémarrage et sans notification. Cf. §6, étape 3.

4. La vraie divergence n'est pas là où on la cherchait

Box et moteur s'accordent par construction. Ce qui peut diverger, c'est :

(a) L'installation et le client. dailyDeadline est une chaîne "HH:mm" sans fuseau. Le client saisit « 7:00 » dans l'app, sur un téléphone qui est dans SON fuseau, en pensant 7:00 chez lui. Le moteur la lit dans le fuseau de la box. Si les deux diffèrent, l'écart est silencieux des deux côtés — l'app affiche ce qu'elle a saisi, le moteur exécute autre chose, et les deux ont raison dans leur référentiel.

Un fuseau implicite est une absence qui signifie « le défaut s'applique ». C'est exactement ce qu'on s'interdit ailleurs (measurement.source, rankOrigin, progress.regime).

(b) L'installation et le CALENDRIER RÉGLEMENTÉ. Et c'est le point neuf.

5. La distinction qui décide : deux natures d'heure murale

Elles ne prennent pas leur fuseau au même endroit, et les confondre est le piège.

nature fuseau qui fait foi qui le vérifie
dailyDeadline, « avant 7 h » habitude du client celui de l'installation l'app, à la saisie
heures creuses, Tempo, EPEX calendrier réglementé celui où le tarif est DÉFINI LM-1005-b, au lot tarifaire
taux journaliers (« minuit local ») période de reporting celui de l'installation personne

La troisième s'est trouvée en cherchant autre chose : GetEnergyRatios dérive ses Δ depuis minuit local (docs/INTERFACE_energyratios.md §2). Elle suit l'installation, ce qui est le bon comportement — mais elle repose sur la même prémisse non vérifiée que la première : que le fuseau de la box SOIT celui de l'installation. Sur .75, le bilan journalier de l'app se remet à zéro à 01:00 heure française, et rien nulle part ne le dit.

Ce que la troisième nature ajoute au raisonnement : elle montre que la prémisse « la box est bien réglée » ne porte pas seulement une saisie, elle porte des grandeurs publiées. Une échéance mal fuseau-horée se corrige d'un réglage ; un bilan journalier découpé au mauvais endroit a déjà été lu, affiché, et peut-être facturé.

Une plage d'heures creuses française est définie en Europe/Paris par le fournisseur, pas « à l'heure locale de la box ». Une box française mal réglée ne décale pas seulement l'affichage : elle achète au mauvais moment, et le tarif, lui, ne s'est pas décalé.

Aujourd'hui les deux natures prendraient silencieusement le fuseau de la box. Pour la première c'est correct si la box est bien réglée ; pour la seconde c'est faux dès qu'elle ne l'est pas. Une grandeur réglementée ne doit donc pas hériter du fuseau système : elle doit porter le sien.

Le marché de gros amont travaille en instants ABSOLUS (les tests construisent leurs horodatages via EnergyTestBase::utcDateTime), donc il est déjà à l'abri. Le risque naît le jour où l'on écrira une plage tarifaire en heures murales — et pas un jour avant.

La règle opposable ne vit donc PAS ici. Elle est écrite là où le risque naît : specs/spec_loadmodel.md, LM-1005-b, en précondition de livraison du lot tarifaire — au même titre que le délestage était précondition du §10. Une distinction rangée dans le document qui l'a découverte est une note ; rangée dans le lot qu'elle contraint, c'est une condition d'entrée. Ce document n'en garde que l'établissement des faits.

6. La décision : ni imposer, ni refuser — DÉCLARER

Ne pas imposer. Le moteur ne connaît pas le fuseau « vrai » de l'installation ; il ne connaît que celui de la box. Le forcer à Europe/Paris serait poser une affirmation qu'on ne sait pas vérifier, et serait faux par construction pour toute box vendue hors de France.

Ne pas refuser de fonctionner. Un HEMS qui s'arrête parce qu'un téléphone est en itinérance laisse un chauffe-eau sans pilotage et une protection de disjoncteur sans arbitre. Les couches L0 à L4 ne doivent dépendre d'aucun fuseau : elles raisonnent en durées, pas en heures murales. Le refus est disproportionné, et il déplacerait un problème d'affichage vers la sécurité.

Déclarer, donc — la seule réponse conforme à la doctrine maison : publier le régime, ne jamais le laisser déduire. Le moteur dit dans quel fuseau il résout les heures murales ; c'est au client de rapprocher cela de l'heure de son utilisateur, et de l'avertir.

Les étapes, à comportement constant d'abord

  1. Documenter le contrat (aucun changement de code). dailyDeadline est une heure murale du fuseau de la box, tel que publié par System.GetTime.timeZone. Écrit dans INTERFACE.md, à côté du champ.
  2. Publier le fuseau avec la configuration, en lecture seule, plutôt que d'exiger du client qu'il croise deux espaces de noms pour interpréter un de NOS champs. Un client qui doit appeler System.GetTime pour donner un sens à dailyDeadline finira par ne pas le faire.
  3. Dire le changement. Le fuseau peut bouger sous le moteur sans redémarrage (§3). Une échéance résolue à 12:00 hier peut valoir 13:00 aujourd'hui sans qu'aucun champ n'ait changé. À traiter comme tout élément runtime : un Get* ET un *Changed.
  4. Séparer les deux natures — porté par LM-1005-b, précondition du lot tarifaire, et non par ce lot-ci. Rien à faire ici : la condition est posée à l'entrée de l'autre.
  5. Côté app (lot séparé, à elle) : comparer le fuseau de la box à celui de l'appareil au moment de la SAISIE d'une échéance, et avertir. C'est l'écran de saisie qui doit parler, pas l'écran de résultat — au moment du résultat, l'heure est déjà passée.

Le fuseau de .75 — corrigé le 2026-09-05, et la date compte

Europe/London sur un banc français était une erreur de configuration. La correction a été différée tant que le décalage n'était pas visible : la déplacer d'une heure sans rien qui le dise aurait rendu l'historique illisible de part et d'autre, sans qu'aucun lecteur ne puisse savoir où passait la couture. periodStartedAt a levé cette réserve — le décalage se lit désormais dans la charge utile.

LA COUTURE, à retenir pour toute lecture de l'historique énergie de .75 :

Instant du changement 2026-09-05T06:53:30Z — soit 07:53:30 heure box AVANT, 08:53:30 APRÈS
Avant Europe/London — journée de reporting de 23:00 UTC à 23:00 UTC
Après Europe/Paris — journée de reporting de 22:00 UTC à 22:00 UTC
Méthode Configuration.SetTimeZone en JSON-RPC (System.SetTimeZone n'existe pas en 1.15.2, malgré ce que dit sa propre note de dépréciation)

Tout enregistrement antérieur à cet instant a ses frontières de journée une heure plus tard que les suivants. Une comparaison jour à jour qui franchit la couture compare deux découpages différents.

Ce que la correction a confirmé, en vrai. Le moteur a suivi sans redémarrage de nymead — ce qui n'était jusque-là mesuré qu'en conteneur (§3). GetEnergyRatios.timeZone et System.GetTime.timeZone sont passés à Europe/Paris au cycle suivant, et periodStartedAt a reculé de 23:00 à 22:00 UTC.

Et la paire a joué son rôle au moment exact où elle servait. measuredSince n'a PAS bougé (05:04:29 UTC, le redémarrage du déploiement de la veille) : la frontière publiée a changé, la portée réelle de la mesure non. Sans le second champ, le bilan du jour aurait paru couvrir une heure de plus qu'il ne couvre — un décalage introduit par la correction elle-même.