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

178 lines
11 KiB
Markdown

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