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:
parent
2c4d04a53f
commit
ccf05cea87
23
AGENTS.md
23
AGENTS.md
@ -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
|
||||
|
||||
31
INTERFACE.md
31
INTERFACE.md
@ -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
|
||||
|
||||
@ -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
79
docs/INVENTAIRE_MOCK.md
Normal 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.
|
||||
@ -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 ¶m
|
||||
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 ¶ms)
|
||||
{
|
||||
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);
|
||||
|
||||
@ -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 ¶ms);
|
||||
|
||||
Q_INVOKABLE JsonReply *GetLoadConfig(const QVariantMap ¶ms);
|
||||
|
||||
/*!
|
||||
* \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 ¶ms);
|
||||
|
||||
// [Phase 2] Ratios canoniques (autoconsommation / autonomie), % [0,100].
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -198,6 +198,7 @@ private slots:
|
||||
void testEcoPassBuysAndSaysTheTrajectory();
|
||||
void testEcoFloorMissedSaysWhatWasLostAndWhy();
|
||||
void testCommandDivergenceIsPublishedNotDiagnosed();
|
||||
void testProgressMeasurabilityIsDeclaredAtConfigTime();
|
||||
void testRecreditIsALoanAgainstObedience();
|
||||
void testARefusedStateIsDistinguishableFromAnApplied();
|
||||
void testL4ShedsAPacAndSaysItForcedTheLock();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user