Patrick Schurig 7398b1dd24 feat(§10): sous unmeasurable, la passe éco commande à TAUX CONSTANT
LM-1013-b-v. Tranché le 2026-09-01, jamais écrit — la décision a passé au second
plan quand sessionWh a été alimenté depuis le compteur de charge et que le régime
`unmeasurable` s'est réduit aux trois cas matériels. C'est la première classe
d'erreur d'AGENTS.md : une règle qui expire sans être exécutée. Elle n'était ni
dans le code, ni dans la spec, ni au contrat.

LE PROBLÈME. L'étalement `reste / temps restant` suppose que le NUMÉRATEUR bouge.
Sous `unmeasurable` il ne bouge pas : `remainingWh` est figé à la cible entière,
par contrat, parce qu'on ne sait pas ce qui a été livré et que le rebaser serait
affirmer sur ce qu'on ignore (LM-1009 C1). Un rapport dont seul le dénominateur
décroît ne suit plus un retard : IL SUIT L'HORLOGE. On achetait davantage parce
que le temps passait, jamais parce que quelque chose était en retard.

CHIFFRÉ, et le chiffre a été refait plutôt que repris. Obligation 4 kWh, plafond
7 kW, fenêtre d'un jour : l'ancienne loi achetait 19 010 Wh, soit ×4,75
l'obligation. Le facteur suit ln(T·P/E) + 1 — il grandit avec la fenêtre, ce qui
est le pire sens possible : plus on laisse de temps pour bien faire, plus on paie.
Une charge qu'on mesure mal coûtait ainsi structurellement plus cher qu'une charge
qu'on mesure, l'incitation exactement à l'envers.

(Le ×2,5 retenu au moment de trancher valait pour une fenêtre bien plus courte.
Sur la fenêtre quotidienne, qui est celle du modèle, c'est ×4,75. La conclusion ne
change pas ; le chiffre, si — et un chiffre faux dans une spec finit par servir de
prémisse à quelqu'un.)

LA LOI. Cible ÷ fenêtre de l'obligation, constante, et `atMaximum` jamais vrai. La
fenêtre est le JOUR parce que l'obligation est déclarée par jour — ce n'est pas un
réglage, c'est l'unité de la grandeur qu'on étale. Le total acheté sur la fenêtre
vaut alors EXACTEMENT la cible. On garde « viser l'obligation entière », le choix
qui sert ; on perd l'escalade, qui ne reposait sur rien.

CE QUE LE TEST TIENT, et il a été vu mordre — désarmé, il rend 667 W au lieu de
167 W à six heures de l'échéance :
  · la cadence est identique à 6 h, 2 h, 1 h, 30 min et 5 min de l'échéance ;
  · `atMaximum` reste faux jusqu'à la dernière minute ;
  · l'intégrale sur les 1440 minutes de la fenêtre vaut la cible à 5 Wh près —
    la propriété qui justifie la loi ne se lit sur aucune valeur isolée ;
  · CONTRE-ÉPREUVE : même obligation, même instant, même numérateur (4000 non
    livrés) sous `measured` — durcissement et point de non-retour intacts. Sans
    elle, avoir supprimé l'étalement POUR TOUT LE MONDE passerait inaperçu.

AU CONTRAT. La loi de commande dépend désormais du régime, et un écran qui
suppose la même dans les deux se trompera sur la trajectoire — qui est ce qu'il
vend. INTERFACE.md porte la table des deux lois, et dit ce que l'écran ne peut
plus affirmer sous `unmeasurable` : ni que l'achat va s'intensifier, ni que
`atMaximum: false` veut dire que tout va bien — il veut dire qu'il n'y a pas
d'étalement du tout, donc rien qui puisse cesser.

Suites : loadmodel 23/23, simulation 66/66, charging 48/48, spotmarket 32/32.
2026-09-04 19:18:27 +02:00

88 lines
3.9 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
#ifndef TESTLOADMODEL_H
#define TESTLOADMODEL_H
#include <QObject>
/*!
* \brief Logique pure du modèle de charge : table de paliers et identité matérielle.
*
* Aucun serveur nymea ici. Ce qui est éprouvé ne dépend ni d'un ThingManager ni d'un
* cycle d'arbitrage : la dérivation des paliers est une combinatoire, et
* \c EnergyArbitrator::sameHardware() une comparaison.
*/
class TestLoadModel : public QObject
{
Q_OBJECT
private slots:
//! La table dérive de TOUTES les sommes de sous-ensembles, triées, 0 compris.
void testStageTableIsDerivedFromSubsetSums();
//! Puissances distinctes : l'ordre de déclaration n'a aucun effet observable.
void testRelayOrderIsInertWhenPowersAreDistinct();
//! Puissances égales : l'ordre décide QUEL contact sert le palier — et ça se voit.
void testRelayOrderDecidesWhichContactServesATiedStage();
//! Un réordonnancement sans effet ne doit PAS coûter une reconstruction.
void testReorderingDistinctRelaysKeepsTheAdapter();
//! Mais un échange de contacts à puissance égale, si.
void testSwappingTiedRelaysRebuilds();
//! Libellé, rang et besoins se mettent à jour en place : jamais de reconstruction.
void testSoftFieldsNeverRebuild();
// ── §11 — compteur et sonde par charge ─────────────────────────────────
//! Les deux champs traversent l'aller-retour, et sont OMIS quand ils sont vides.
void testMeasurementThingsRoundTrip();
//! Ils ne sont pas des canaux de commande : ils n'entrent pas dans claimedThingIds().
void testMeasurementThingsAreNotClaimed();
//! Donc deux charges peuvent partager le même compteur et la même sonde.
void testTwoLoadsMayShareAMeter();
//! Et les changer ne reconstruit rien : ils n'ont aucun effet sur le matériel.
void testMeasurementThingsAreSoftFields();
// ── §12 — le plancher de modulation ────────────────────────────────────
//! Déclaré en dynamic, et il traverse l'aller-retour.
void testFloorIsDeclaredForContinuousLoads();
//! Interdit là où il se DÉRIVE : deux sources d'une même vérité divergent.
void testFloorIsRefusedWhereItWouldBeDerived();
//! Un plancher au-dessus du plafond rendrait la charge inatteignable.
void testFloorAboveCeilingIsRefused();
// ── 3g-2 — le rang de la borne vit dans LoadConfig (LM-1206/LM-1207) ───
//! Une entrée evcharger est valide SANS charge utile, et traverse l'aller-retour.
void testEvChargerConfigIsRankOnly();
//! Toute charge utile d'un autre mécanisme y est refusée (LM-300, union discriminée).
void testEvChargerConfigRefusesPayloads();
//! Elle revendique son Thing : une borne ne peut pas être aussi une charge pilotée.
void testEvChargerClaimsItsThing();
// ── §12 / LM-1209 — l'auto-provisionnement remplit un trou, il ne décide pas ───
//! L'unicité de rang REFUSE une écriture, mais n'écarte JAMAIS au chargement : les deux
//! vérifications sont séparées parce que leurs gestes le sont (LM-1209-a).
void testRankUniquenessRefusesWriteButNeverDiscards();
//! La marque de rang s'efface sur un RANG changé, pas sur une écriture reçue — et un
//! client ne peut pas fabriquer un "auto" (LM-1209-c).
void testRankOriginFollowsTheRankNotTheWrite();
// ── LM-1006-1 — le plafond de soutirage, étape 1 ───────────────────────
//! On compare des MARGES, jamais des plafonds : le plus contraignant mord, et on sait
//! lequel. Aucun plafond déclaré ≠ marge nulle. Départage stable à marge égale.
void testEcoSpreadHardensAndSaysSo();
void testEcoUnmeasurableBuysAtAConstantRate();
void testDrawMarginComparesHeadroomsNotCaps();
};
#endif // TESTLOADMODEL_H