5 Commits

Author SHA1 Message Date
Patrick Schurig
3ac61e9b66 docs(règles): les 14 en territoire livré, lues une par une
Sept étaient des trous de classification ou de citation, sept sont réelles.

TROIS CITATIONS AJOUTÉES, portées par du code depuis longtemps :
  LM-400    une machine physique est UNE charge → c'est l'exclusivité d'un Thing
            dans validateSet() qui l'empêche d'être déclarée deux fois
  LM-301-b  « une charge utile, une branche de fabrique » vaut pour CONSTRUIRE,
            pas pour ALIMENTER en actions → le repli L2 lit supportedKinds
  LM-600    les codes d'adapter portent le nom du MÉCANISME, jamais du domaine

QUATRE PASSÉES EN CADRAGE : ECS-004/005 (invariant tenu par ABSENCE — aucun
identifiant de relais ne franchit la frontière, il n'y a rien à pointer), LM-404
(hors périmètre déclaré), LM-501 (justification de NE PAS bâtir les groupes tant
que la liste plate suffit).

RESTENT SEPT, et ce sont de vraies attentes : ECS-111, ECS-302, ECS-305, ECS-307
(les trois dernières déjà listées en « ce qui reste à éprouver », §9 de
spec_ecs.md), LM-401/LM-402 (facettes, couche non bâtie), LM-502 (plafond de
groupe, conditionnel).

ECS-111 mérite d'être remonté : ce n'est pas une attente, c'est un défaut vivant,
et sa conséquence dépasse ce que la spec en dit. Détail dans le rendu de session.

Bilan de l'index après cette passe : 34 « spécifiée seule » (dont 27 en
territoire non construit), 26 cadrage, 24 citées par le code, 17 par un test, 12
vérifiées sur machine. Le nombre de départ était 70.

Aucun changement de comportement. ci-quality 3/3, build 0/0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-08-29 12:57:53 +02:00
Patrick Schurig
d3dfe519d5 docs(règles): sept règles étaient implémentées et non citées
Premier usage réel de l'index, et il donne un résultat inattendu : sur les 70
règles « spécifiée seule », une part notable n'est PAS un trou d'implémentation
mais un trou de CITATION. Vérifié en cherchant le CONTENU dans le code plutôt que
l'identifiant :

  ECS-110-b      un Thing n'appartient qu'à une charge active  → validateSet()
  LM-302-b       l'exclusivité ne se valide pas dans une charge utile → idem
  LM-1209-a-bis  écarter et refuser ne sont pas la même fonction → validateRanks()
  ECS-410-b      l'issue de ClearLoadFault est journalisée par l'arbitre
  LM-303         les verrous appartiennent au mécanisme, pas au domaine
  LM-1208-c      sans voiture, le nombre de phases vient de la borne
  ECS-308        MaxRelays TRONQUE au lieu de refuser

Toutes portées par du code, aucune citée. L'index les comptait donc comme
spécifiées seules — il SOUS-DÉCLARE la couverture, exactement comme il peut la
surdéclarer. C'est la même limite que celle écrite en tête de la page générée
(« cité par n'est pas couvert par »), prise par l'autre bout : un identifiant
absent d'un commentaire ne dit rien de ce que le code fait.

Citations ajoutées avec \rule{}, l'alias posé hier — donc navigables du
commentaire vers la règle. Aucun changement de comportement : ce sont des
commentaires. 63 « spécifiée seule » au lieu de 70, 21 citées par le code au lieu
de 14.

ci-quality 3/3, build 0/0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-08-29 12:44:12 +02:00
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
Patrick Schurig
e37f204047 fix(config): ECS-110-b — un Thing n'appartient qu'à UNE charge active
Le cas général n'était pas couvert, et le trou était complet. isValid() s'arrête au bord
d'une charge : deux charges de configuration pouvaient revendiquer le même Thing — un
relais déclaré à la fois dans la charge ECS et dans un état SG-Ready — avec exactement
les conséquences que la garde de l'arbitre évite pour la PAC codée en dur : deux
commandes contradictoires sur un organe, sa puissance comptée deux fois dans le budget
(règle absolue 1). Rien ne l'empêchait, rien ne l'aurait signalé.

L'exclusivité est une propriété de l'ENSEMBLE, qu'aucune charge ne peut vérifier seule :
LoadConfigStore::validateSet(), appelée par setConfigs() (rejet total, rien persisté) et
au chargement (l'entrée en conflit est écartée, le reste survit — écarter une charge ne
commande rien, la charger commanderait faux).

Deux doublons intra-charge manquaient aussi : deux étages relay-router sur le même Thing,
que le routeur fusionnait en silence en annonçant le double de la puissance réelle, et un
relais répété dans un même état SG-Ready. Un relais présent dans PLUSIEURS états reste
légitime : l'état 4 est l'union des états 1 et 3.

Portée tranchée : seules les charges enabled sont confrontées — une charge désactivée ne
construit aucun adaptateur, et l'interdire empêcherait de préparer un remplacement ;
activer repasse par setConfigs(). ThingIds normalisés via QUuid, la configuration
mélangeant « {uuid} » et « uuid ».

LM-302-b : tout mécanisme futur doit déclarer ses Things via claimedThingIds(), seul
point à étendre — l'oublier le rendrait invisible à la vérification.

Suite complète : 112 tests, 0 échec.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 16:29:28 +02:00
Patrick Schurig
7184fe4e1d feat(etm): config charge pilotée persistée (Get/SetLoadConfig) + repli L2
Pont moteur↔app : déclaration des charges etmvariableload persistée et éditable,
construction des adaptateurs à chaud depuis la config (remplace le registre en dur).

- LoadConfig / LoadConfigNeeds (Q_GADGET typés, introspectables) — forme mot pour mot
  du LoadDescriptor §4 (jonction inter-repos avec l'app).
- LoadConfigStore : persistance atomique /var/lib/nymea/energy-load-configuration.json,
  validation en bloc, signal changed().
- Handler NymeaEnergy.GetLoadConfig / SetLoadConfig (+ notif LoadConfigChanged). GET typé
  objectRef<LoadConfig> ; SET schéma inline o: (powerLevels/needs conditionnels §4).
- EnergyArbitrator : setLoadConfigStore + rebuildEtmVariableLoadAdapters (enabled==true
  seulement, §9) ; repli L2 = setPowerSetpoint(0) force=true sur tout etmvariableload
  (ferme le trou sécurité ouvert en T2).
- Tests simulation : migration ECS→etmvariableload (arrondi fixed/dynamic, recrédit,
  délestage, round-trip powerSetpoint), budget partagé etmvariableload↔PAC, watchdog L2
  réactivé, persistance + construction depuis config + injection RPC end-to-end.

PAC SG-Ready reste hors config (§8, gelée). Les 5 tâches du brief sont couvertes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 09:51:38 +02:00