etm-powersync-energy-plugin.../docs/BRIEF_depuis_plugin.md
Patrick Schurig 8927b0033c feat(§12): LM-1209 — insertion en queue, rang unique, et la création se dit
Les trois conditions, d'un bloc.

INSERTION EN QUEUE. provisionEvChargerConfig() posait rang min − 1, écrasé à 1 —
donc à égalité avec la charge que l'installateur avait posée là exprès. C'est
désormais 1 + max(rangs). Queue de LISTE et non de domaine : les domaines ne sont
pas contigus, donc « juste après le dernier ev » exigerait de pousser une charge
d'un autre domaine, ce que la condition 2 interdit. Et comme max(domaine) ≤
max(liste), la queue de liste est toujours au moins aussi reculée — elle coïncide
quand le domaine occupe la fin, et sinon place la nouvelle charge derrière tout
le monde plutôt que de l'intercaler devant une charge classée. Plus fidèle au
principe que la formulation d'origine.

UNICITÉ. validateRanks() refuse une écriture où deux charges portent le même
rang. Avec la mise en garde écrite noir sur blanc : l'égalité ne coûte pas le
déterminisme — le tri est total sur (rang, identifiant) depuis 3g-2 — elle coûte
la gouvernabilité. S'y tromper ferait chercher le défaut dans le tri.

ET LA RÈGLE GÉNÉRALE QUI EN SORT (LM-1209-a-bis) : une vérification qui ÉCARTE et
une vérification qui REFUSE ne peuvent pas être la même fonction. validateSet()
nomme ce qui rend l'ensemble dangereux et load() s'en sert pour écarter ; y
ajouter l'unicité de rang aurait fait perdre des charges au démarrage sur une
configuration héritée — disparues de la configuration ET de l'arbitrage, sans
applySafeState, soit ECS-413 rouvert par une autre porte. Les deux sont
séparées ; validateRanks() n'est appelée que par setConfigs(). Au chargement, un
doublon hérité est signalé bruyamment et CONSERVÉ.

o:rankOrigin. Énumération fermée "auto" | "user", absente par défaut. Une
énumération et non un booléen parce que le troisième état est le point :
`rankIsDefault: false` affirmerait que le rang a été choisi, alors que c'est ce
qu'on ignore d'une configuration héritée. L'absence doit porter le « on ne sait
pas » ; un booléen ne sait pas se taire. Même motif que measurement.source, et la
spec le nomme désormais comme un motif de conception plutôt qu'une décision au
cas par cas.

Et la précision demandée en écrivant : l'effacement porte sur le RANG, jamais sur
la réception d'une écriture. SetLoadConfig remplace en bloc, donc réordonner une
AUTRE charge renvoie forcément l'entrée de celle-ci — l'effacer à cette occasion
perdrait une marque encore vraie. L'omission ne lève rien ; pour lever sans
déplacer, un client envoie "user". Un "auto" fabriqué est refusé, l'aller-retour
verbatim reste licite.

Trois tests. testAutoProvisionedRankGoesToTheQueue vérifié échouant avec
l'insertion en tête — et il échoue en disant que la borne n'entre PAS en
configuration, les deux mécanismes se répondant : le rang libre par construction
et le refus d'écriture en filet. testRankOriginFollowsTheRankNotTheWrite couvre
le cas qui se serait perdu, un client naïf qui omet la clé en déplaçant une autre
charge. testRankUniquenessRefusesWriteButNeverDiscards couvre la moitié qui ne se
voit pas : qu'un chargement conserve ce qu'il aurait pu écarter.

amd64 0 erreur / 0 avertissement. simulation 31/31, charging 16/16, loadmodel
20/20, spotmarket 7/7.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-08-28 15:56:46 +02:00

25 KiB
Raw Blame History

Brief agent app — ce que 3g-2 change pour l'écran

Émis par : l'agent plugin, après 3g-2 · Date : 2026-08-27 Banc : .75, plugin 1.15.2+etm24, nymea 1.15.2+202606191336~trixie1 Contexte : docs/RELEVE_3g2.md (ce dépôt) porte les traces machine, cycle par cycle. Contrat : INTERFACE.md fait autorité — ce brief ne fait qu'en signaler les mouvements.

Huit points. Le sixième répond à vos quatre constats du 28 août, le septième à votre constat d'usage sur le compteur par charge — et celui-là bouge un champ du contrat. Le deuxième retire une mise en garde que vous portiez — du code conditionnel peut disparaître. Le cinquième répond à votre question sur targetPercentage, et c'est celui qui change un écran : l'avancement s'affichera en énergie livrée, pas en pourcentage.


1. loads[] ⊆ GetLoadConfig est RÉTABLI

3g-1 avait rompu l'invariant sans le signaler : les bornes entraient dans loads[] de GetLoadTelemetry sans figurer dans aucune GetLoadConfig. C'était exactement la charge fantôme que le lot B-bis avait supprimée, réapparue par l'autre bout — et vous vous appuyez sur cet invariant pour résoudre libellé, domaine et rang de chaque charge publiée.

Depuis +etm23, toute borne détectée reçoit d'office une entrée LoadConfig. Sur le banc, GetLoadConfig est passé de 2 à 4 entrées au premier cycle après la mise à jour :

chauffe-eau              adapter=relay-router     prio=1  domain=ecs
PAC banc                 adapter=sg-ready         prio=2  domain=heating
Simulated wallbox        adapter=evcharger        prio=1  domain=ev      ← nouvelle
Terra AC Charger (TCP)   adapter=evcharger        prio=1  domain=ev      ← nouvelle

Forme d'une entrée evcharger : id (le ThingId de la borne), adapter: "evcharger", mode: "dynamic", domain: "ev", plus label / priority / enabled. Aucune charge utile — pas de relays, pas de sgReady, pas de powerLevels/maxPowerW/minPowerW, pas de minOnS/minOffS. Les limites d'une borne viennent du Thing et changent avec le véhicule branché ; les déclarer en configuration en ferait une seconde source de vérité.

Rien à faire de votre côté pour que ça marche : l'aller-retour GetLoadConfig → SetLoadConfig verbatim est neutre, vérifié sur machine — l'objet relu est identique et aucun adaptateur n'est reconstruit.

Le sens de l'inclusion reste le même : GetLoadConfig peut contenir des charges absentes de loads[]. Pour une borne, ça veut dire hors arbitrage — pas de voiture assignée, mode manuel, ou aucun véhicule branché (ce dernier cas depuis +etm24). Une borne configurée absente de loads[] se lit « pas arbitrée en ce moment », jamais « perdue ».


2. ⚠️ La mise en garde sur allocatedW des bornes TOMBE

Vous portiez : « pour une borne, allocatedW est la décision de l'arbitre et non l'état de la borne ». C'était juste, et ça ne l'est plus.

Le défaut derrière cette mise en garde : jusqu'à +etm22, l'arbitre décidait et publiait, puis adjustEvChargers() — le chemin d'exécution hérité de l'amont — re-décidait derrière lui et commandait autre chose. La décision publiée n'atteignait pas le matériel.

+etm23 ferme ça. Une charge, un commandeur : les actions EV du waterfall sont dispatchées vers l'adaptateur, et les bornes ainsi commandées sortent du chemin proxy. Mesuré sur machine : 9 commandes émises à la borne sur la fenêtre d'observation, 9 par l'arbitre, 0 par le proxy, et 6 cycles sur 6 portent la ligne « commandée par le waterfall, pas de re-décision ici ».

Conséquence pour vous : allocationIsCommand doit rendre true partout, et les branches conditionnelles qui en dépendaient peuvent disparaître.

Une réserve, et elle est petite mais réelle : l'allocation reste ce que l'arbitre a commandé, pas ce que la borne tire. Un plafond matériel, un véhicule qui refuse ou un câble débranché feront toujours diverger les deux. Ce que vous pouvez enfin affirmer, c'est que personne d'autre ne commande. Pour l'écart commande/réalité, la charge utile mechanism de la borne porte chargingEnabled, currentA, phaseCount et pluggedIn — c'est là qu'il se lit, pas dans allocatedW.


3. Nouvelle clé ARB : EV_GRID_START

EV_GRID_START { budgetW, floorW, gridW }        funding: "grid"

Une borne qui DÉMARRE en soutirant au réseau. Le surplus ne paie pas son plancher, mais le réglage acquisitionTolerance (défaut 0,5) autorise l'appoint : à 0,5, une borne qui exige 4 140 W démarre dès 2 070 W de surplus et tire le reste du réseau.

gridW = floorW − budgetW est ce qui est acheté. C'est le chiffre qui fait de cette ligne une décision, et pas un effet de bord : il mérite d'être montré, pas rangé dans un repli.

Comptabilité — c'est le seul cas où l'allocatedW d'une charge se partage entre les deux compteurs du budget : budgetW vient du surplus et entre dans budget.allocatedW, gridW vient du réseau et entre dans budget.evReservedW. budgetW + gridW == allocatedW, toujours. Si vous réconciliez la somme des allocations avec le budget, c'est la ligne à traiter à part.

Borné par construction : le démarrage exige du surplus réel et consomme tout le budget restant, donc au plus une borne par cycle peut soutirer.

Pas encore vu sur machine. Guetté six cycles au banc, jamais fabriqué : le budget de la borne est passé de 385 W à 2 350 W sans s'arrêter dans la fenêtre de tolérance. Le motif est éprouvé par la simulation. Prévoyez la clé, mais ne construisez pas d'écran autour d'une chose que le terrain n'a pas encore montrée.

Seconde clé du même lot, plus rare : PHASE_LIMIT { limitW, requiredW } — c'est la limite de phase qui interdit, pas le budget. Deux causes, deux gestes : l'une se règle en délestant la maison ou en revoyant l'abonnement, l'autre en attendant le soleil.


4. Le rang d'une borne est configurable, et il se classe avec les autres

LoadConfig.priority, comme toute autre charge. Il n'y a pas de second système de priorité et il n'y en aura pas : un classement propre aux bornes ne saurait pas exprimer « VE1 > ECS > VE2 », et deux classements qui ne peuvent pas s'interclasser sont un défaut, pas deux fonctionnalités. ChargingInfo ne porte donc aucun champ de rang.

Le glisser-déposer marche donc sur une seule liste, bornes comprises, et se pose par SetLoadConfig comme le reste. Vérifié sur machine, y compris l'inversion de rang entre deux charges.

Le tri est total : à rang égal, l'identifiant départage. Laquelle est servie est donc reproductible d'un cycle à l'autre, même avant tout réglage.

Réserve sur le rang par défaut, et elle vous concerne. À la création automatique, la borne reçoit « le plus petit rang existant moins un, borné à 1 ». Quand une charge occupe déjà le rang 1 — c'est le cas du banc — l'intention « en tête » dégénère en égalité : les deux bornes et le chauffe-eau sont tous à 1. Sans conséquence sur la reproductibilité, mais l'ordre affiché à l'installateur avant qu'il n'ait rien réglé n'est pas un choix, c'est un artefact. Ne le présentez pas comme un réglage tant que le point n'est pas tranché côté moteur (docs/RELEVE_3g2.md §3.2). Les vrais rangs se posent à la mise en service, par vous.


5. targetPercentage — ta question est tranchée, et elle change ton écran

Tu avais remonté que targetPercentage est une cible sans mesure d'avancement : aucune classe evcharger ne déclare d'état de charge, aucune ne porte d'interface véhicule. C'est exact — vérifié sur les cinq classes du banc.

Et le code fait pire que « il manque une mesure ». targetPercentage est comparé à carBatteryLevel, lu sur le Thing voiture. Or dans le seul cas où ce mécanisme s'active — une voiture manuelle, dont batteryLevel est inscriptible — c'est le moteur qui écrit cette valeur : il intègre la puissance de la borne, applique un facteur de pertes, divise par une capacité déclarée, et réinjecte le résultat dans l'état du Thing. Il compare donc sa cible à sa propre estimation, bâtie sur trois grandeurs dont aucune n'est mesurée.

Ce que ça te coûte aujourd'hui : l'estimation sort par la frontière RPC sous le nom batteryLevel, indistinguable d'une mesure. Un écran qui affiche « 62 % » affiche une intégration. Tu mens de bonne foi, et rien dans la charge utile ne te permet de t'en apercevoir.

Les décisions (Patrick, 2026-08-27) — specs/spec_loadmodel.md LM-1009

D1 — L'avancement s'affiche en ÉNERGIE LIVRÉE, jamais en pourcentage. « 8,4 kWh livrés depuis le branchement » est vérifiable ; « 62 % » suppose une capacité que personne ne connaît. Le pourcentage garde sa place dans la saisie de l'intention — l'utilisateur pense sa voiture en pourcentage — mais sort de l'affichage de l'avancement.

D2 — Obligation par SESSION d'abord. La source est sessionEnergy, l'énergie livrée depuis le branchement, publiée par la borne. C'est aussi l'usage réel : on branche le soir pour partir le matin. L'obligation quotidienne (minEnergyWhPerDay) est un lot à part, qui attend l'historique du §11 — ne l'anticipe pas dans l'écran.

D3 — L'estimation sera ÉTIQUETÉE à la publication. Tant que le chemin carBatteryLevel survit, il ne franchira plus la frontière RPC sous le même nom qu'une mesure. Le précédent existe déjà côté moteur : estimatedPowerW porte son préfixe exprès.

Ce que ça implique pour toi, concrètement

  • Deux régimes de vérité, et il faut pouvoir dire lequel s'applique (condition C2 de la spec) : une obligation en énergie peut annoncer son atteinte ; une cible en pourcentage publie une intention et un avancement estimé — jamais « tenue ». Un avancement mesuré et un avancement estimé ne se distinguent par aucune de leurs valeurs : les deux sont un nombre qui monte. Si le régime ne se lit pas, le second se fait passer pour le premier.
  • Certaines bornes ne publient pas sessionEnergy (condition C1) — mesuré : wallboxNoMeter et l'une des deux classes wallbox. Sur celles-là l'avancement n'est pas mesurable, et l'écran doit le montrer : une échéance qui cesse silencieusement d'être mesurable est pire qu'une échéance absente, parce qu'elle continue de s'afficher. Surtout, ne pas afficher zéro — « pas mesurable » et « rien livré » sont deux états différents.
  • Rien n'est encore codé côté moteur : sessionEnergy n'est lue nulle part et LoadContextTelemetry::sessionWh est déclarée mais jamais remplie. Le §10 le fera. Ce brief te donne la forme d'arrivée pour que tu ne bâtisses pas sur le pourcentage entre-temps.


6. Réponses à vos quatre constats du 2026-08-28

A — la conclusion est retenue, le mécanisme ne l'est pas

Votre conclusion est juste, elle est adoptée, et elle est LIVRÉE (spec_loadmodel.md LM-1208-b, moteur 1.15.2+etm25) : « pas de voiture assignée » cesse d'être un critère d'exclusion. Vos trois arguments tiennent, et le troisième est décisif — le critère contredit LM-1206, qui donne le rang et le plancher à la borne et non au véhicule.

Mais le budget n'était pas faussé, et c'est votre propre banc qui le montre. Le budget part de la mesure brute du compteur (invariant 8) : la consommation d'une borne exclue y est déjà déduite. Journal du 28 à 10:49, pendant que la V2C tirait :

Current load on root meter: 3531.4W   (avant : −2915 W, en export)
[Arbitre] "chauffe-eau" → "Surplus insuffisant (−3531 W) — chauffe-eau à 0 W"

L'ECS n'a pas été servi par-dessus — l'arbitre a vu l'import et a refusé. L'exclusion est conservatrice : la charge exclue retire du budget aux autres, et personne ne le lui rend.

Votre règle générale est néanmoins retenue, parce qu'elle a un porteur étroit et réel : une borne exclue du contexte mais commandée par le proxy ne reçoit aucun evReservedW. Sa puissance commandée et pas encore mesurée n'est réservée par personne. Transitoire — un cycle — et sans effet aujourd'hui, mais elle mordra au §10, où la passe éco décide d'acheter.

Ce qui change pour vous, concrètement : une borne en mode éco, véhicule branché, sans voiture déclarée, est désormais arbitrée — elle reçoit une enveloppe, un rang, et un motif publié dans GetLoadTelemetry comme n'importe quelle autre charge. Ce qu'elle n'a pas : l'échéance et la cible d'énergie, qui restent au proxy et supposent un véhicule connu. C'est une fonction dégradée, à montrer comme telle — pas une borne en défaut.

Et une conséquence trouvée en implantant, qui vous concerne (LM-1208-c) : sans voiture, le moteur ne connaît pas la capacité du véhicule, donc il ne bascule plus les phases et retient le phaseCount de la borne. Une borne triphasée reste arbitrée en triphasé — c'est volontaire et conservateur pour le budget. La bascule 1↔3 revient dès qu'une voiture est déclarée.

⚠️ Un écart de MESURE mérite votre attention, et il est distinct : le compteur racine lit 3 250 W quand la V2C annonce 3 980 W à elle seule (plus la PAC à 800 W). Environ 1,5 kW que le compteur racine ne voit pas. Ce n'est pas un problème d'arbitrage — c'est une question de câblage ou de vue SunSpec, de la même famille que l'aveuglement du compteur aux résistances ECS documenté dans AGENTS.md. À investiguer pour lui-même : un budget juste suppose un compteur qui voit tout.

B — même cause, mais le mode manuel n'est pas en cause

Corriger A ne suffira pas : la V2C est en ChargingModeNormal, et le mode manuel reste un critère d'exclusion délibéré — l'utilisateur pilote, l'arbitre s'abstient.

Le vrai point est ailleurs, et il vous concerne : le mode par défaut d'une borne fraîchement détectée est ChargingModeNormal. Une borne neuve n'est donc jamais arbitrée tant que personne n'a choisi un mode éco. C'est correct par construction, mais invisible — et « la borne ne charge pas toute seule » se lira comme un défaut. C'est un geste de mise en service à montrer, pas un correctif moteur.

C — vous pouvez déjà la retirer, aujourd'hui, sans rien ajouter

L'entrée résiduelle de la Terra AC se supprime depuis l'app : SetLoadConfig remplace l'ensemble, donc renvoyer la liste sans cette entrée la retire. Et elle ne sera pas recréée : l'auto-provisionnement ne tourne que pour les bornes présentes dans nymea, et le Thing a été supprimé.

C'est exactement le « geste, pas une suppression d'office » que vous demandez, et il existe déjà.

La limite à connaître : si vous supprimez l'entrée d'une borne dont le Thing existe encore, elle sera recréée au cycle suivant — c'est l'auto-provisionnement, et c'est voulu. Retirer une borne de l'arbitrage sans supprimer son Thing se fait par enabled: false, pas par suppression.

D — instruction reçue, rien ne bouge côté moteur

Aucun cas particulier V2C dans le moteur : le contrat reste celui des autres bornes, et C1 de LM-1009 est maintenue — d'autres bornes n'auront ni sessionEnergy ni valeur accumulable.

Votre distinction est plus fine que ce que le brief avait anticipé et elle est notée : un champ absent se voit ; un champ qui s'efface se lit comme un zéro. C'est un troisième régime, entre « mesuré » et « pas mesurable » — et c'est précisément pourquoi C2 exige que le régime soit publié et non déduit d'une valeur.

Et targetPercentage = 80 observé sur la V2C confirme sur machine le défaut corrigé en +etm23 : le champ omis retombait à son défaut. Ce n'est plus une déduction.


7. Le compteur par charge : vous avez raison, et le modèle bouge

(Votre constat d'usage du 2026-08-28. Tranché : spec_loadmodel.md LM-1105/1106. Contrat : INTERFACE.md. LIVRÉ en 1.15.2+etm26 — measuredW n'existe plus.)

Le constat est retenu tel quel

Désigner une borne comme son propre compteur pour qu'elle affiche ce qu'elle mesure déjà n'est devinable par personne. Une note d'écran qui explique un réglage est le signe que le défaut est dans le modèle, pas dans l'app — et c'est de la configuration demandée à l'installateur pour compenser ce que le moteur peut déduire. Le moteur lira l'appareil qu'il pilote dès qu'il porte lui-même l'état de puissance.

Une précision de rédaction : la règle porte sur le Thing, pas sur le mécanisme. « Les bornes mesurent, les relais non » est vrai en pratique et faux en droit — certains powerswitch portent currentPower, et RelayRouter sait déjà les sommer.

Vos deux points, tranchés

1. Le compteur explicite reste prioritaire — oui, et c'est écrit. Mais la raison principale n'est pas la précision : c'est qu'un réglage sans effet est pire que pas de réglage. Si le moteur préférait l'appareil au compteur que l'installateur a pris la peine de désigner, le champ deviendrait décoratif. La priorité porte sur la source, pas sur la valeur : compteur déclaré mais Thing absent → on retombe sur l'appareil, et la charge utile dit lequel a servi.

2. Votre second point est le plus important, et il décide de la FORME. En le vérifiant, on a trouvé que le moteur porte déjà trois régimes sous un seul nom — et qu'il jette la distinction : une borne rend une mesure ; un relay-router rend une mesure ou le palier nominal selon que ses relais mesurent (le booléen est calculé, puis perdu) ; un sg-ready rend toujours une puissance déclarée, jamais mesurée.

Donc le régime se publie, il ne se déduit pas d'une valeur — c'est votre C2 de LM-1009, mot pour mot. measuredW disparaît au profit de :

"measurement": { "source": "meter" | "device" | "none", "powerW": 3980.0 }

powerW est absent sous none. Et none est un état positif : « cette charge n'est pas mesurable ». C'est lui qui vous permet de masquer le bloc de mesure au lieu d'afficher un vide sur un sg-ready qui ne le sera jamais — et de distinguer « pas mesurable » de « compteur en panne », que rien ne séparait.

Ce que la source change pour vos écrans de diagnostic

source Ce qu'un écart commandé↔mesuré autorise à dire
device La charge n'obéit pas — véhicule qui refuse, thermostat ouvert, plafond interne
meter La charge, le compteur, ou le câblage — trois hypothèses, l'écart n'en tranche aucune
none Rien. Pas d'écart affichable, et surtout pas un « 0 W d'écart »

Une asymétrie qui va plus loin que « le compteur mesure mieux », et qui vaut d'être dite à l'écran : sous device, une concordance ne prouve rien — plusieurs wallbox renvoient leur consigne en guise de puissance. Sous meter, elle est une preuve. device détecte la désobéissance ; meter est le seul à pouvoir détecter un mensonge. C'est pourquoi meterThingId reste autorisé sur une borne qui publie déjà : à masquer par défaut, pas à interdire.

Et une mise en garde sur la façon d'écrire cette règle chez vous : décidez sur measurement.source, jamais sur adapter. « Masqué dès que la charge mesure, sauf SG-Ready » paraît équivalent et ne l'est pas — un relay-router à topologie mixte publie lui aussi none, et vous masqueriez le champ sur un ECS qui ne peut être mesuré que par un compteur dédié. La règle sûre tient en une ligne, et elle couvre l'exception SG-Ready sans que vous ayez à la connaître :

si measurement.source == "device"  → compteur dédié en réglage avancé (replié)
sinon                              → compteur dédié proposé, visible

C'est toute la raison d'être de source : le régime est publié pour ne pas être déduit. Le redéduire du mécanisme côté app remettrait chez vous la déduction qu'on vient de retirer du moteur — et vous la maintiendriez à chaque mécanisme ajouté.

Deux choses trouvées en implantant, qui changent ce que vous afficherez

sg-ready est none par CONSTRUCTION, pas par accident de matériel. Un contact SG-Ready est un contact de signal : la PAC est alimentée ailleurs, son courant ne traverse jamais K1/K2. Même montée sur des relais qui mesurent — c'est le cas de notre banc — la charge reste non mesurable par son organe de commande. Pour une PAC, meterThingId n'est pas un réglage avancé : c'est la seule voie vers une mesure, et l'écran devrait le proposer, pas le masquer.

Une topologie de relais MIXTE est none aussi. Un relais mesurant et deux nus donneraient une somme partielle — 500 W annoncés là où 1 500 W coulent — et l'écart vous ferait afficher une panne qui n'existe pas. Une mesure partielle n'est pas une mesure.

Et un point d'affichage qui vous évitera un faux bug : {source: "device", powerW: 0} est une mesure, pas une absence. Un routeur de relais au palier 0 mesure réellement 0 W et reste mesurable — la source ne bascule pas vers none quand la charge s'éteint. Si elle basculait, votre bloc de mesure apparaîtrait et disparaîtrait au rythme du thermostat.

Votre coût, et il est d'un écran

measuredW: X → measurement: {source, powerW: X} ; son absence → source: "none". La clé n'est pas doublée pendant une transition, délibérément : deux noms pour une chose divergent (c'est la règle qui a fait naître renderFr). Prévenez-nous si le calendrier de l'app impose l'inverse.


8. Le rang d'une charge créée d'office — un champ de plus, et un piège d'écriture

(Décision Patrick, 2026-08-28 : spec_loadmodel.md LM-1209. Contrat : INTERFACE.md. Livré en 1.15.2+etm27.)

Ce qui change dans le moteur : une borne détectée entre désormais en queue de la liste, plus en tête. Elle ne passe devant aucune charge que quelqu'un a classée — une détection ne réordonne pas ce qu'un humain a décidé. Corollaire pratique pour vous : le rang produit est toujours libre, et priority est maintenant unique, garanti par le contrat au lieu d'être supposé.

⚠️ SetLoadConfig REFUSE désormais en bloc un ensemble à rangs doublés. Si votre écran de réordonnancement peut produire deux charges au même rang — ne serait-ce qu'un instant, pendant un glisser-déposer — l'écriture entière sera rejetée. À vérifier chez vous.

Une configuration héritée à rangs doublés, elle, n'est pas amputée : elle est conservée au chargement et signalée au journal. Votre première écriture la répare.

Le champ nouveau : o:rankOrigin. "auto" dit « cette entrée a été créée d'office et personne n'a jamais classé cette charge » — c'est ce qui vous permet d'afficher « rang par défaut » au lieu de laisser croire à un choix. "user" dit que le rang est assumé. Absent n'affirme rien, et c'est volontaire : une configuration héritée ne prétend pas que ses rangs ont été décidés. N'affichez le badge que sur un "auto" explicite.

C'est le même motif de conception que measurement.source, un cran plus loin : publier le régime, et ne jamais laisser un silence passer pour une affirmation. Une énumération plutôt qu'un booléen précisément pour ça — rankIsDefault: false affirmerait que le rang a été choisi, alors que c'est ce qu'on ignore.

Le piège d'écriture, et c'est le point à retenir

SetLoadConfig remplace en bloc. Quand l'installateur déplace la charge B, vous renvoyez aussi l'entrée de la charge A — et A n'a toujours été classée par personne.

Le moteur protège ce cas : la marque tombe quand le rang de cette charge-là change, jamais parce qu'une écriture est arrivée. Vous pouvez donc omettre la clé sans rien casser — l'omission ne lève rien.

Deux conséquences pour vous :

  • Pour lever la marque sans déplacer la charge — « j'ai vu ce défaut, il me va » — envoyez "user" explicitement. C'est la seule voie.
  • N'écrivez jamais "auto". Le moteur refuse l'écriture entière : ce serait affirmer un geste qu'il n'a pas fait. Renvoyer verbatim une entrée qui le porte déjà reste licite — l'aller-retour Get → Set n'est pas cassé.

Ce qui n'a PAS changé, et qu'il ne faut pas déduire

  • L'échéance de départ et le tarif dynamique restent au proxy, financés au réseau (funding: "grid", motifs EV_DEADLINE / EV_SPOT_MARKET). Leur puissance vit dans budget.evReservedW, pas dans budget.allocatedW.
  • chargingState de ChargingInfo garde son sens et reste publié par l'arbitre pour les bornes qu'il commande.
  • SetChargingInfo n'est toujours pas partiel — l'objet remplace le stocké, et un champ writable omis retombe sur son défaut. La description publiée le dit désormais, et INTERFACE.md en donne la table. Lire → modifier → renvoyer complet.
  • repeatDays va de 1 (lundi) à 7 (dimanche), pas de 0 à 6. La documentation disait le contraire ; le code a toujours refusé le 0.