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
bf4072cd3e
commit
86e1fbd0fc
@ -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
|
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
|
* 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
|
// 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).
|
// double (LM-302 : l'exigence réelle n'est jamais dans le schéma).
|
||||||
loadItem.insert("o:minPowerW", enumValueName(Uint));
|
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:needs", needsItem);
|
||||||
loadItem.insert("o:relays", QVariantList() << relayItem); // relay-router (rév. 3)
|
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
|
// 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);
|
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)
|
JsonReply *NymeaEnergyJsonHandler::GetLoadConfig(const QVariantMap ¶ms)
|
||||||
{
|
{
|
||||||
Q_UNUSED(params)
|
Q_UNUSED(params)
|
||||||
QVariantMap returns;
|
QVariantMap returns;
|
||||||
QVariantList list;
|
QVariantList list;
|
||||||
if (m_loadConfigStore) {
|
if (m_loadConfigStore) {
|
||||||
foreach (const LoadConfig &c, m_loadConfigStore->configs())
|
foreach (const LoadConfig &c, m_loadConfigStore->configs()) {
|
||||||
list << pack<LoadConfig>(c); // inclut les charges enabled==false (éditables par l'app)
|
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);
|
returns.insert("loadConfigs", list);
|
||||||
return createReply(returns);
|
return createReply(returns);
|
||||||
|
|||||||
@ -35,6 +35,7 @@
|
|||||||
class SmartChargingManager;
|
class SmartChargingManager;
|
||||||
class SpotMarketManager;
|
class SpotMarketManager;
|
||||||
class LoadConfigStore;
|
class LoadConfigStore;
|
||||||
|
class LoadConfig;
|
||||||
class EnergyManager;
|
class EnergyManager;
|
||||||
|
|
||||||
class NymeaEnergyJsonHandler : public JsonHandler
|
class NymeaEnergyJsonHandler : public JsonHandler
|
||||||
@ -68,6 +69,26 @@ public:
|
|||||||
Q_INVOKABLE JsonReply *GetChargingSchedules(const QVariantMap ¶ms);
|
Q_INVOKABLE JsonReply *GetChargingSchedules(const QVariantMap ¶ms);
|
||||||
|
|
||||||
Q_INVOKABLE JsonReply *GetLoadConfig(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);
|
Q_INVOKABLE JsonReply *SetLoadConfig(const QVariantMap ¶ms);
|
||||||
|
|
||||||
// [Phase 2] Ratios canoniques (autoconsommation / autonomie), % [0,100].
|
// [Phase 2] Ratios canoniques (autoconsommation / autonomie), % [0,100].
|
||||||
|
|||||||
@ -54,6 +54,12 @@ class SmartChargingManager : public QObject
|
|||||||
public:
|
public:
|
||||||
explicit SmartChargingManager(EnergyManager *energyManager, ThingManager *thingManager, SpotMarketManager *spotMarketManager, EnergyManagerConfiguration *configuration, QObject *parent = nullptr);
|
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;
|
uint phasePowerLimit() const;
|
||||||
void setPhasePowerLimit(uint phasePowerLimit);
|
void setPhasePowerLimit(uint phasePowerLimit);
|
||||||
|
|
||||||
|
|||||||
@ -7292,6 +7292,96 @@ void Simulation::testRecreditIsALoanAgainstObedience()
|
|||||||
#endif
|
#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()
|
void Simulation::testCountsSumToTarget()
|
||||||
{
|
{
|
||||||
#ifndef ETM_ARBITRATOR
|
#ifndef ETM_ARBITRATOR
|
||||||
|
|||||||
@ -198,6 +198,7 @@ private slots:
|
|||||||
void testEcoPassBuysAndSaysTheTrajectory();
|
void testEcoPassBuysAndSaysTheTrajectory();
|
||||||
void testEcoFloorMissedSaysWhatWasLostAndWhy();
|
void testEcoFloorMissedSaysWhatWasLostAndWhy();
|
||||||
void testCommandDivergenceIsPublishedNotDiagnosed();
|
void testCommandDivergenceIsPublishedNotDiagnosed();
|
||||||
|
void testProgressMeasurabilityIsDeclaredAtConfigTime();
|
||||||
void testRecreditIsALoanAgainstObedience();
|
void testRecreditIsALoanAgainstObedience();
|
||||||
void testARefusedStateIsDistinguishableFromAnApplied();
|
void testARefusedStateIsDistinguishableFromAnApplied();
|
||||||
void testL4ShedsAPacAndSaysItForcedTheLock();
|
void testL4ShedsAPacAndSaysItForcedTheLock();
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user