feat(R6): la mesurabilité se déclare à la saisie, et l'inventaire du mock

Le signal vit dans GetLoadConfig, en r:, et non dans la télémétrie.
`measurement` n'existe que pour les charges arbitrées ; or une charge qu'on
DÉCLARE est précisément celle qui n'y est pas encore. L'écran serait muet au
moment exact où il doit parler — celui de la saisie, avant qu'une obligation
n'achète à l'aveugle tous les jours.

Trois causes, trois gestes : noMeter se répare en une manipulation,
meterWithoutEnergy demande de remplacer le compteur, noSessionEnergy ne se
répare pas. Sans la cause, l'avertissement ne peut dire que « ça ne marchera
pas ».

Et meterThingId ne permet pas de le déduire : un compteur peut publier
currentPower sans totalEnergyConsumed — mesuré sur l'ECS du banc, reproduit
au test avec un powerSwitch désigné comme compteur. La puissance ne suffit
pas, il faut une énergie. Contre-épreuve : déduction par meterThingId seul,
le cas du banc passe pour mesurable.

INVENTAIRE À FROID DU MOCK. Trois fois en trois jours qu'un cas réel s'est
révélé inatteignable ; ce n'est plus un incident, c'est une propriété du banc
— il est plus homogène que la réalité, ayant été construit pour faire marcher
des scénarios et non pour représenter la diversité du parc.

Quatre cas restent structurellement introuvables, dont `source: none` pour
une charge SAINE — toutes les classes pilotables portent une puissance, si
bien qu'une charge non mesurée mais en bon état est impossible à fabriquer,
alors qu'un relais sec est banal sur le terrain. Et la branche `temperature`,
écrite et jamais exécutée faute d'une classe qui porte l'état.

La règle : avant d'écrire un test qui exerce une ABSENCE, vérifier que le
mock peut la produire. Une assertion sur un cas inatteignable ne passe pas,
elle NE S'EXÉCUTE PAS — ce qui est pire, car elle compte comme verte. Le
symptôme se reconnaît : la contre-épreuve ne mord pas.

Deux ajouts dans AGENTS.md. L'asymétrie des latences est une PROPRIÉTÉ, pas
une chance : le refus qui coûte de l'argent est celui de la descente, et
c'est le plus vite constaté — ce qui n'est vrai que parce que le seuil vient
du mécanisme. Et la question opératoire appliquée aux trois autres identités,
avec un résultat inégal qui est le but : celle de `draw` repose sur le fait
que le compteur voie toute la maison, ce que rien ne vérifie ;
Σ levels[].targetW == estimatedPowerW est interne au plan et ne dit rien du
matériel — il a fallu un champ hors de la formule pour relier les deux.

Suite : simulation 61/61, loadmodel 22/22, charging 48/48 identique à la
référence, spotmarket 32/32, doxygen 0 avertissement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
This commit is contained in:
Patrick Schurig 2026-09-01 17:27:56 +02:00
parent 2c4d04a53f
commit ccf05cea87
9 changed files with 355 additions and 2 deletions

View File

@ -889,6 +889,29 @@ aucune, la décision est une intention, et elle se périmera à la vitesse du co
> la rendre vérifiable. Une hypothèse ne se teste pas par recoupement interne : elle se teste
> en la confrontant au monde, ou elle ne se teste pas.
> **Corollaire de conception : l'asymétrie des latences joue dans le bon sens, et c'est une
> PROPRIÉTÉ, pas une chance.** Le refus dangereux est celui de la DESCENTE — commander la baisse
> et ne pas être suivi — et c'est celui dont la latence est la plus courte (V2C : 7 s contre
> 30 s à la montée). **Le refus qui coûte de l'argent se constate donc plus vite que celui qui
> n'en coûte pas.** Ce n'est vrai que parce que le seuil vient du mécanisme : une constante
> unique perdrait exactement cette propriété, en traitant les deux sens pareil.
>
> ### La question appliquée aux identités déjà publiées
>
> *« Sur quoi cette identité repose-t-elle qui ne soit pas dans ses propres termes ? »* — passée
> aux trois autres, elle donne trois réponses inégales, et c'est le but :
>
> | Identité | Ce sur quoi elle repose, hors de ses termes | Vérifié ? |
> |---|---|---|
> | `Σ counts == targetW` | qu'**aucun site d'émission n'oublie** de renseigner `counts` | **oui** — `testCountsSumToTarget` relit la charge utile et ne fait confiance à aucun site ; c'est ainsi qu'un huitième site a été trouvé |
> | `authorisedW − committedW == remainingW` | que **le compteur voie toute la maison** — une charge en amont du compteur laisse l'identité vraie et sans objet | **non** — rien ne le vérifie, et rien ne le peut depuis le moteur |
> | `Σ levels[].targetW == estimatedPowerW` | que **le décidé soit l'appliqué** : elle est interne au PLAN et ne dit rien du matériel, que l'adaptateur écrête après coup | **depuis peu** — c'est exactement ce que `command.divergent` mesure |
>
> **La troisième est la leçon.** Une identité peut être parfaitement vraie *et* ne parler que
> d'elle-même. Celle-là décrit ce que le moteur a décidé, pas ce que la maison fait — et il a
> fallu un champ HORS de la formule pour relier les deux. La deuxième reste ouverte, et il faut
> le savoir plutôt que de croire qu'elle prouve quelque chose sur l'installation.
> **Piège de build, 2026-09-01** : changer la DISPOSITION d'une structure dans un en-tête inclus
> transitivement (ici un `QDateTime` ajouté à `EcoProgress`, atteint par `LoadAction`) peut
> laisser des objets compilés contre l'ancienne. Le symptôme est un **plantage dans un

View File

@ -699,6 +699,37 @@ C'est un **cumul sur la fenêtre d'obligation** : remis à zéro à la bascule d
> `purchasedWh` couvre la période d'avant. **Les deux se répondent**, et ensemble ils disent la
> vérité complète. Le champ est **absent** tant que rien n'a jamais été mesuré dans la période.
### `progressMeasurable` / `progressUnmeasurableCause` — dans `GetLoadConfig`, pas dans la télémétrie
```jsonc
// GetLoadConfig → loadConfigs[]
{ "id": "…", "adapter": "relay-router", …,
"progressMeasurable": false, "progressUnmeasurableCause": "meterWithoutEnergy" }
```
**Pourquoi ici et pas dans `loads[]`.** `measurement` n'existe que pour les charges arbitrées.
Or une charge qu'on **déclare** est précisément celle qui n'y est pas encore — pas arbitrée, ou
créée à l'instant. L'écran serait muet **au moment exact où il doit parler** : celui où
l'installateur saisit une obligation qui, sans mesure, achètera à l'aveugle tous les jours.
| Cause | Ce qu'elle veut dire | Le geste |
|---|---|---|
| `noMeter` | aucun compteur rattaché | **réparable en une manipulation** |
| `meterWithoutEnergy` | le compteur ne cumule pas l'énergie | **à remplacer** |
| `noSessionEnergy` | la borne ne compte pas sa session | **rien à faire — c'est le matériel** |
Sans la cause, l'avertissement ne peut dire que « ça ne marchera pas ». Même structure que les
trois sources de `DRAW_CAP` : trois causes, trois gestes.
> **`meterThingId` ne permet PAS de le déduire.** Un compteur peut publier `currentPower` **sans**
> `totalEnergyConsumed` — mesuré le 2026-09-01 sur l'ECS du banc. Le champ est renseigné, le
> compteur existe, et pourtant rien ne cumule : **la puissance ne suffit pas, il faut une
> énergie**. Un client qui déduirait la mesurabilité de la présence du champ se tromperait
> exactement sur ce cas.
>
> `progressMeasurable` est en `r:` — **lecture seule**. C'est un constat sur le matériel, pas un
> réglage : un client qui l'écrirait affirmerait quelque chose sur une ThingClass.
### `command` — l'écart entre le commandé et le mesuré
Publié **seulement** quand une source mesure (`measurement.source` ≠ `none`) **et** qu'une

View File

@ -1,3 +1,39 @@
powersync-energy-plugin-nymea (1.15.2+etm47) trixie; urgency=medium
* R6 — LA MESURABILITÉ DE L'AVANCEMENT SE DÉCLARE DANS `GetLoadConfig`, en `r:`, et non dans
la télémétrie. `measurement` n'existe que pour les charges arbitrées ; or une charge qu'on
DÉCLARE est celle qui n'y est pas encore. L'écran serait muet au moment exact où il doit
parler — celui de la saisie, avant qu'une obligation n'achète à l'aveugle tous les jours.
* TROIS CAUSES, TROIS GESTES : `noMeter` (réparable en une manipulation), `meterWithoutEnergy`
(compteur à remplacer), `noSessionEnergy` (rien à faire, c'est le matériel). Même structure
que les trois sources de DRAW_CAP.
* ET `meterThingId` NE PERMET PAS DE LE DÉDUIRE : un compteur peut publier `currentPower` sans
`totalEnergyConsumed` — mesuré sur l'ECS du banc, et reproduit au test. La puissance ne
suffit pas, il faut une énergie. Contre-épreuve : déduction par `meterThingId` seul, le cas
du banc passe pour mesurable.
* INVENTAIRE À FROID DU MOCK (docs/INVENTAIRE_MOCK.md). Trois fois en trois jours qu'un cas
réel s'est révélé inatteignable par la suite ; ce n'est plus un incident, c'est une propriété
du banc d'essai — il est PLUS HOMOGÈNE QUE LA RÉALITÉ. La table des classes dit ce qui est
discriminant et ce qui ne l'est pas, et quatre cas restent structurellement introuvables,
dont `source: none` pour une charge SAINE (toutes les classes pilotables portent une
puissance) et la branche `temperature`, écrite et jamais exécutée.
* La règle qui en sort : avant d'écrire un test qui exerce une ABSENCE, vérifier que le mock
peut la produire — une assertion sur un cas inatteignable ne passe pas, elle NE S'EXÉCUTE
PAS, ce qui est pire, car elle compte comme verte. Symptôme reconnaissable : la
contre-épreuve ne mord pas. Parade : compter les cas examinés et échouer si le compte est nul.
* AGENTS.md — l'asymétrie des latences notée comme une PROPRIÉTÉ et non une chance : le refus
qui coûte de l'argent est celui de la descente, et c'est le plus vite constaté. Ce n'est vrai
que parce que le seuil vient du mécanisme ; une constante unique perdrait la propriété.
* Et la question opératoire appliquée aux trois autres identités du contrat. Résultat inégal,
et c'est le but : Σ counts == targetW est vérifiée hors de ses termes ; l'identité de `draw`
repose sur le fait que le compteur voie toute la maison, ce que RIEN ne vérifie ;
Σ levels[].targetW == estimatedPowerW est INTERNE AU PLAN et ne dit rien du matériel — il a
fallu `command.divergent`, hors de la formule, pour relier les deux.
* Suite : simulation 61/61, loadmodel 22/22, charging 48/48 identique à la référence,
spotmarket 32/32, doxygen 0 avertissement.
-- Patrick Schurig <etm.schurig@gmail.com> Tue, 01 Sep 2026 14:00:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm46) trixie; urgency=medium
* LE RECRÉDIT EST CONDITIONNÉ À L'OBÉISSANCE CONSTATÉE. Il rendait au budget la conso d'une

79
docs/INVENTAIRE_MOCK.md Normal file
View File

@ -0,0 +1,79 @@
# INVENTAIRE À FROID DU MOCK — ce qu'il rend structurellement introuvable
*(Fait le 2026-09-01, après trois occurrences en trois jours d'un cas réel impossible à
reproduire. Ce n'est plus un incident, c'est une propriété du banc d'essai.)*
## Pourquoi cet inventaire
Trois fois cette semaine, un défaut réel s'est révélé **inatteignable par la suite** :
| Occurrence | Ce qui était introuvable | Comment on l'a su |
|---|---|---|
| Correctif amont `calculateAllowanceAmpere` | un compteur publiant les courants **sans** les puissances par phase | le mock pose `currentPhaseB = currentPowerPhaseB / 230`, cohérent **par construction** |
| Test `command` sous `source: none` | une charge dont rien ne mesure la puissance | **toutes** les classes pilotables portent `currentPower` |
| Test `ECO_FLOOR_*` en régime `unmeasurable` | une borne sans `sessionEnergy` | il a fallu **ajouter** l'état à une seule classe pour avoir les deux cas |
Le point commun : **le mock est plus homogène que la réalité.** Il a été construit pour faire
marcher des scénarios, pas pour représenter la diversité du parc — et une suite verte ne dit alors
rien sur les cas qu'il ne sait pas produire.
## Les classes, et ce qu'elles portent
| Classe | `currentPower` | `totalEnergyConsumed` | `sessionEnergy` |
|---|---|---|---|
| `meter` | ✅ | ✅ | — |
| `charger` | ✅ | ✅ | ❌ |
| `chargerPhaseSwitching` | ✅ | ✅ | ✅ *(ajouté le 2026-08-31)* |
| `simpleCharger` | ❌ | ❌ | ❌ |
| `powerSwitch` | ✅ | ❌ | — |
| `etmVariableLoad` | ✅ *(`currentPowerW`)* | ❌ | — |
| `energyStorage` | ✅ | ❌ | — |
| `car`, `notification` | ❌ | ❌ | — |
**Ce que la table donne, et c'est la bonne nouvelle** : `sessionEnergy` et `totalEnergyConsumed`
sont désormais **discriminants** — il existe des classes avec et sans. Les régimes `measured` /
`unmeasurable` et les causes `noSessionEnergy` / `meterWithoutEnergy` sont donc exerçables.
## Ce qui reste STRUCTURELLEMENT introuvable
**1. `measurement.source: none` pour une charge saine.** Toutes les classes pilotables portent une
puissance. Le seul chemin est une charge dont le **Thing n'existe pas** (câblage manquant) — cas
réel, mais qui traîne avec lui `available: false`. **Une charge saine et non mesurée est
impossible à fabriquer ici**, alors qu'elle est banale sur le terrain (un relais sec).
**2. Un compteur par phase incohérent.** `currentPhaseX` et `currentPowerPhaseX` sont posés
ensemble par le mock, dans un rapport fixe de 230. Aucun scénario ne peut produire une tension
réelle ≠ 230 V, ni un compteur qui ne publierait qu'une des deux familles.
**3. `temperature`.** Le code teste `hasState("temperature")` et **aucune classe du mock ne le
porte** : la branche « sonde présente » n'a jamais été exécutée. Elle est écrite, elle n'est pas
testée.
**4. Une borne triphasée à phases déséquilibrées.** Les états existent, mais aucun helper de test
ne les pose indépendamment — d'où le pessimisme de `LM-1210-a` qui n'a jamais été exercé sur un
vrai déséquilibre.
## La règle qui en sort
> **Avant d'écrire un test qui exerce une ABSENCE, vérifier que le mock peut la produire.**
> Une assertion sur un cas inatteignable ne passe pas : elle **ne s'exécute pas**, ce qui est
> pire — elle compte comme verte.
>
> Le symptôme se reconnaît : **la contre-épreuve ne mord pas**. C'est le corollaire fin
> d'`AGENTS.md` appliqué au banc d'essai plutôt qu'au code.
**Et la parade tient en une ligne de test** : quand on itère pour trouver des cas, **compter
combien on en a examinés**, et échouer si le compte est nul. C'est ce qui a rattrapé le test de
`command`, et ce qui garde celui de la mesurabilité.
## Ce qu'il faudrait ajouter, par ordre d'utilité
1. **Une classe pilotable SANS aucune puissance** — un relais sec. Débloque `source: none` sur
une charge saine, et sépare enfin « non mesurée » de « en panne ».
2. **Un compteur sans `totalEnergyConsumed`** en tant que classe déclarée, plutôt qu'un
`powerSwitch` détourné comme aujourd'hui.
3. **Une tension par phase réglable** indépendamment des puissances — pour exercer le correctif
amont ailleurs qu'en inversant la fonction.
> Aucune de ces trois n'est urgente. Ce qui l'est, c'est de **savoir qu'elles manquent** : sans
> cette liste, chaque lot repaiera le même temps à découvrir qu'un cas est introuvable.

View File

@ -221,6 +221,23 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
// plancher se DÉRIVE des paliers, et LoadConfig::isValid() refuse de le déclarer en
// double (LM-302 : l'exigence réelle n'est jamais dans le schéma).
loadItem.insert("o:minPowerW", enumValueName(Uint));
// §10 / R6 — LA MESURABILITÉ DE L'AVANCEMENT, en LECTURE SEULE, et ICI plutôt que dans la
// télémétrie. `measurement` n'existe que pour les charges de `loads[]` ; or une charge qu'on
// DÉCLARE est précisément celle qui n'y est pas encore — pas arbitrée, ou créée à l'instant.
// L'écran serait muet au moment exact où il doit parler : celui de la saisie.
//
// Trois causes, et chacune appelle un geste DIFFÉRENT — même structure que les trois sources
// de DRAW_CAP :
// · `noMeter` aucun compteur rattaché → réparable en une manipulation
// · `meterWithoutEnergy` compteur sans énergie totale → à remplacer
// · `noSessionEnergy` borne sans sessionEnergy → rien à faire, c'est le matériel
// Sans la cause, l'avertissement de saisie ne peut dire que « ça ne marchera pas ».
//
// ET `meterThingId` NE PERMET PAS DE LE DÉDUIRE : un compteur peut publier `currentPower`
// sans `totalEnergyConsumed` — mesuré le 2026-09-01 sur l'ECS du banc. Un client qui
// déduirait la mesurabilité de la présence du champ se tromperait sur ce cas précis.
loadItem.insert("r:progressMeasurable", enumValueName(Bool));
loadItem.insert("o:progressUnmeasurableCause", enumValueName(String));
loadItem.insert("o:needs", needsItem);
loadItem.insert("o:relays", QVariantList() << relayItem); // relay-router (rév. 3)
// LM-300 — charge utile du mécanisme sg-ready. estimatedPowerW porte son nom : c'est une
@ -643,14 +660,63 @@ JsonReply *NymeaEnergyJsonHandler::GetChargingSchedules(const QVariantMap &param
return createReply(returns);
}
bool NymeaEnergyJsonHandler::progressMesurable(const LoadConfig &c, QString &cause) const
{
cause.clear();
ThingManager *tm = m_smartChargingManager ? m_smartChargingManager->thingManager() : nullptr;
if (!tm)
return false;
// 1. Une BORNE se mesure par sa session. Sans `sessionEnergy`, rien à faire : c'est le
// matériel, et aucune manipulation ne le changera. Le dire évite qu'un installateur
// cherche un réglage qui n'existe pas.
if (c.adapter() == QLatin1String("evcharger")) {
Thing *t = tm->findConfiguredThing(ThingId(c.id()));
if (t && t->thingClass().hasStateType("sessionEnergy"))
return true;
cause = QStringLiteral("noSessionEnergy");
return false;
}
// 2. Une charge en watts se mesure par un compteur DÉSIGNÉ. Aucun : réparable en une
// manipulation, et c'est la cause la plus fréquente à la saisie.
if (c.meterThingId().isEmpty()) {
cause = QStringLiteral("noMeter");
return false;
}
// 3. Le compteur existe-t-il, et compte-t-il l'ÉNERGIE ? Un compteur peut publier
// `currentPower` sans `totalEnergyConsumed` — mesuré le 2026-09-01 sur l'ECS du banc.
// C'est précisément pourquoi la présence de `meterThingId` ne permet pas de déduire la
// mesurabilité : la puissance ne suffit pas, il faut un cumul.
Thing *compteur = tm->findConfiguredThing(ThingId(c.meterThingId()));
if (!compteur) {
cause = QStringLiteral("noMeter");
return false;
}
if (!compteur->thingClass().hasStateType("totalEnergyConsumed")) {
cause = QStringLiteral("meterWithoutEnergy");
return false;
}
return true;
}
JsonReply *NymeaEnergyJsonHandler::GetLoadConfig(const QVariantMap &params)
{
Q_UNUSED(params)
QVariantMap returns;
QVariantList list;
if (m_loadConfigStore) {
foreach (const LoadConfig &c, m_loadConfigStore->configs())
list << pack<LoadConfig>(c); // inclut les charges enabled==false (éditables par l'app)
foreach (const LoadConfig &c, m_loadConfigStore->configs()) {
QVariantMap m = pack<LoadConfig>(c).toMap(); // inclut enabled==false (éditables par l'app)
// R6 — la MESURABILITÉ, calculée ici parce que c'est ici que l'app en a besoin :
// au moment de la SAISIE, avant que la charge existe dans loads[].
QString cause;
m.insert(QStringLiteral("progressMeasurable"), progressMesurable(c, cause));
if (!cause.isEmpty())
m.insert(QStringLiteral("progressUnmeasurableCause"), cause);
list << m;
}
}
returns.insert("loadConfigs", list);
return createReply(returns);

View File

@ -35,6 +35,7 @@
class SmartChargingManager;
class SpotMarketManager;
class LoadConfigStore;
class LoadConfig;
class EnergyManager;
class NymeaEnergyJsonHandler : public JsonHandler
@ -68,6 +69,26 @@ public:
Q_INVOKABLE JsonReply *GetChargingSchedules(const QVariantMap &params);
Q_INVOKABLE JsonReply *GetLoadConfig(const QVariantMap &params);
/*!
* \brief L'avancement d'une obligation est-il MESURABLE pour cette charge ?
* \param c Configuration de la charge. \param[out] cause Code opaque si non mesurable.
* \return Vrai si un cumul d'énergie existe pour juger l'avancement.
*
* \par Pourquoi ce signal vit dans GetLoadConfig et non dans la télémétrie
* \c measurement n'existe que pour les charges de \c loads[] ; or une charge qu'on DÉCLARE
* est précisément celle qui n'y est pas encore — pas arbitrée, ou créée à l'instant.
* L'écran serait muet au moment exact où il doit parler : celui de la saisie.
*
* \par Trois causes, trois gestes
* \c noMeter (réparable en une manipulation), \c meterWithoutEnergy (compteur à remplacer),
* \c noSessionEnergy (rien à faire, c'est le matériel). Sans la cause, l'avertissement ne
* peut dire que « ça ne marchera pas ».
*
* \note \c meterThingId ne permet PAS de le déduire : un compteur peut publier
* \c currentPower sans \c totalEnergyConsumed (mesuré sur l'ECS du banc).
*/
bool progressMesurable(const LoadConfig &c, QString &cause) const;
Q_INVOKABLE JsonReply *SetLoadConfig(const QVariantMap &params);
// [Phase 2] Ratios canoniques (autoconsommation / autonomie), % [0,100].

View File

@ -54,6 +54,12 @@ class SmartChargingManager : public QObject
public:
explicit SmartChargingManager(EnergyManager *energyManager, ThingManager *thingManager, SpotMarketManager *spotMarketManager, EnergyManagerConfiguration *configuration, QObject *parent = nullptr);
//! \brief [ETM] Gestionnaire de Things, pour les consommateurs qui doivent interroger une
//! ThingClass sans passer par NymeaCore (frontière RPC notamment).
//! \return Pointeur non-propriétaire ; jamais nul après construction.
ThingManager *thingManager() const { return m_thingManager; }
uint phasePowerLimit() const;
void setPhasePowerLimit(uint phasePowerLimit);

View File

@ -7292,6 +7292,96 @@ void Simulation::testRecreditIsALoanAgainstObedience()
#endif
}
/*!
* \brief [R6] La MESURABILITÉ de l'avancement se déclare à la SAISIE, avec sa cause.
*
* \par Pourquoi ce signal ne peut pas vivre dans la télémétrie
* `measurement` n'existe que pour les charges de `loads[]`. Or une charge qu'on **déclare** est
* précisément celle qui n'y est pas encore — pas arbitrée, ou créée à l'instant. L'écran serait
* muet **au moment exact où il doit parler** : celui où l'installateur saisit une obligation qui
* achètera à l'aveugle tous les jours si rien ne la mesure.
*
* \par Trois causes, trois gestes
* `noMeter` se répare en une manipulation, `meterWithoutEnergy` demande de remplacer le
* compteur, `noSessionEnergy` ne se répare pas — c'est le matériel. Sans la cause, l'avertissement
* ne peut dire que « ça ne marchera pas ».
*
* \par Et `meterThingId` ne permet pas de le déduire
* Un compteur peut publier `currentPower` **sans** `totalEnergyConsumed` : mesuré le 2026-09-01
* sur l'ECS du banc, et reproduit ici avec un `powerSwitch` désigné comme compteur. Un client qui
* déduirait la mesurabilité de la présence du champ se tromperait sur ce cas précis.
*/
void Simulation::testProgressMeasurabilityIsDeclaredAtConfigTime()
{
#ifndef ETM_ARBITRATOR
QSKIP("nécessite ETM_ARBITRATOR.");
#else
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-mes.json";
QFile::remove(cfgPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
ThingManager *tm = NymeaCore::instance()->thingManager();
QUuid compteur = addMeter(26702); // meter : porte totalEnergyConsumed
QUuid r1 = addPowerSwitch(1000, 26703); // powerSwitch : power + currentPower SEULEMENT
QUuid r2 = addPowerSwitch(1000, 26704);
QUuid r3 = addPowerSwitch(1000, 26705);
QVERIFY(tm->findConfiguredThing(compteur) && tm->findConfiguredThing(r1));
// Écrit par la FRONTIÈRE RPC, comme l'app le ferait — c'est le magasin que le handler
// connaît. Un store créé à côté ne serait pas celui qu'il interroge : constaté ici même,
// GetLoadConfig rendait une liste VIDE et le test passait sans rien vérifier.
int rang = 0;
QVariantList aPoser;
auto relais = [&](const QString &id, const QUuid &thing, const QString &meter) {
++rang; // rangs UNIQUES : le store refuse deux charges de même rang
QVariantMap m{{"id", id}, {"label", id}, {"adapter", "relay-router"}, {"mode", "fixed"},
{"priority", rang}, {"enabled", true}, {"domain", "ecs"},
{"relays", QVariantList() << QVariantMap{{"thingId", thing.toString()},
{"powerW", 1000}}}};
if (!meter.isEmpty()) m.insert("meterThingId", meter);
return m;
};
aPoser << relais("sans-compteur", r1, QString());
aPoser << relais("compteur-ok", r2, compteur.toString());
aPoser << relais("compteur-sans-energie", r3, r1.toString());
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig",
QVariantMap({{"loadConfigs", aPoser}})).toMap()
.value("params").toMap().value("energyError").toString(),
QString("EnergyErrorNoError"));
QVariantList l = injectAndWait("NymeaEnergy.GetLoadConfig").toMap()
.value("params").toMap().value("loadConfigs").toList();
QHash<QString, QVariantMap> parId;
for (const QVariant &v : l) parId.insert(v.toMap().value("id").toString(), v.toMap());
// Garde contre l'assertion VACANTE : sans les trois entrées, tout ce qui suit passerait
// sans rien vérifier. Le piège a déjà été rencontré deux fois dans ce lot.
QCOMPARE(parId.count(), 3);
// 1. AUCUN COMPTEUR — réparable en une manipulation, et c'est la cause la plus fréquente.
QCOMPARE(parId.value("sans-compteur").value("progressMeasurable").toBool(), false);
QCOMPARE(parId.value("sans-compteur").value("progressUnmeasurableCause").toString(),
QString("noMeter"));
// 2. COMPTEUR AVEC ÉNERGIE — mesurable, et AUCUNE cause publiée : il n'y a rien à réparer.
QCOMPARE(parId.value("compteur-ok").value("progressMeasurable").toBool(), true);
QVERIFY2(!parId.value("compteur-ok").contains("progressUnmeasurableCause"),
"une charge mesurable ne porte pas de cause — l'absence est l'information");
// 3. COMPTEUR SANS ÉNERGIE — le cas qui interdit de déduire la mesurabilité de la seule
// présence de `meterThingId`. Le champ est là, le compteur existe, et pourtant rien ne
// cumule : la puissance ne suffit pas, il faut une énergie.
QCOMPARE(parId.value("compteur-sans-energie").value("progressMeasurable").toBool(), false);
QCOMPARE(parId.value("compteur-sans-energie").value("progressUnmeasurableCause").toString(),
QString("meterWithoutEnergy"));
QVERIFY2(!parId.value("compteur-sans-energie").value("meterThingId").toString().isEmpty(),
"le compteur EST désigné — c'est bien la déduction par meterThingId qui échoue");
#endif
}
void Simulation::testCountsSumToTarget()
{
#ifndef ETM_ARBITRATOR

View File

@ -198,6 +198,7 @@ private slots:
void testEcoPassBuysAndSaysTheTrajectory();
void testEcoFloorMissedSaysWhatWasLostAndWhy();
void testCommandDivergenceIsPublishedNotDiagnosed();
void testProgressMeasurabilityIsDeclaredAtConfigTime();
void testRecreditIsALoanAgainstObedience();
void testARefusedStateIsDistinguishableFromAnApplied();
void testL4ShedsAPacAndSaysItForcedTheLock();