diff --git a/INTERFACE.md b/INTERFACE.md index baf751e..f00cd83 100644 --- a/INTERFACE.md +++ b/INTERFACE.md @@ -571,7 +571,8 @@ le cas normal. |---|---| | `level` | `"eco"` ou `"comfort"` — quel BESOIN cette entrée sert | | `targetW` | ce que le niveau a **DÉCIDÉ** (R4) — à ne pas confondre avec `mechanism.*`, qui dit ce qui a été **appliqué** | -| `funding` | `"surplus"` ou `"grid"` — l'origine des watts de CE niveau | +| `funding` | `"surplus"` ou `"grid"` — l'ORIGINE des watts de ce niveau. `"grid"` **dès qu'un seul watt est acheté** (LM-1013) | +| `counts` | `{ destination → watts }` — le REGISTRE où ils sont comptés. **`Σ counts == targetW`, toujours** | > **Aujourd'hui il y a exactement UN niveau, et c'est `"comfort"`.** La passe unique > d'aujourd'hui est celle qui, au §10, servira le confort depuis le surplus ; la passe éco est @@ -673,14 +674,53 @@ choses différentes, et **aucun ne se déduit d'un autre**.)* > encore de `measurement.source` (§11) : sous `device` il accuse la charge, sous `meter` il > ouvre trois hypothèses, sous `none` il n'est pas calculable. +#### `counts` — la ventilation, et pourquoi elle ne se déduit pas de `funding` + +```jsonc +"levels": [ + { "level": "comfort", "targetW": 1380, "funding": "grid", + "counts": { "budget.allocatedW": 900, "draw.committedW": 480 } } +] +``` + +**`funding` dit d'où les watts VIENNENT, `counts` dit dans quel registre ils TOMBENT.** Ce sont +deux questions, et aucune ne se dérive de l'autre : ci-dessus, `funding` vaut `"grid"` — un watt +est acheté, la règle suffit — pendant que `counts` porte **deux** destinations. + +| Clé | Ce qu'elle compte | +|---|---| +| `budget.allocatedW` | watts de **surplus** alloués par la cascade | +| `draw.committedW` | watts **achetés** au réseau | + +> **Clés OPAQUES, jamais recyclées.** Une clé inconnue de votre version s'affiche « destination +> inconnue » et laisse `Σ counts == targetW` vérifiable ; elle ne vaut **jamais** zéro. Le moteur +> s'interdit de réattribuer un identifiant à un sens nouveau — même règle que les numéros de +> règle retirés : recycler ferait ranger des watts dans le mauvais compteur **sans qu'aucune +> erreur ne le signale**. +> +> **Toujours publié : à une seule destination, et à zéro.** `counts: {}` satisferait l'identité +> en **perdant la destination**, sur le cas le plus fréquent. Un zéro **avec** sa destination dit +> « cette grandeur existe et vaut 0 » ; l'absence dirait « elle n'existe pas ». C'est la même +> distinction que `draw: {"committedW": 0}` — zéro est une valeur quand la grandeur existe, +> l'absence en est une quand elle n'existe pas. +> +> **Qui fait foi** : `counts` pour la **comptabilité**, `decision.params` pour la **phrase** du +> motif. `params.budgetW`/`gridW` d'un `EV_GRID_START` expliquent *pourquoi* la charge démarre +> malgré un budget insuffisant ; `counts` dit *où* les watts sont comptés. Si les deux +> divergeaient, c'est `counts` qui décrit le bilan. + ### L'identité de réconciliation — écrite ici, jamais reconstruite ``` -Σ sur toutes les charges de loads[] -Σ sur tous les niveaux dont funding == "surplus" - niveau.targetW == budget.allocatedW +Σ charges Σ niveaux counts["budget.allocatedW"] == budget.allocatedW +Σ charges Σ niveaux counts["draw.committedW"] == draw.committedW +Σ counts d'un niveau == niveau.targetW ``` +**Elle ne porte plus sur `funding`.** Sommer les `targetW` des niveaux `funding == "surplus"` +était juste tant qu'un niveau n'avait qu'une destination ; un `EV_GRID_START` la rendait fausse. +La ventilation supprime le détour : on somme des **compteurs**, pas des origines. + Elle a changé de forme avec R2 : elle portait sur `allocatedW` des charges dont le `funding` valait `"surplus"`, elle porte maintenant sur les **niveaux**. Un client qui la reconstruirait depuis l'ancienne forme divergerait **au premier cas mixte** — c'est-à-dire au premier cycle du diff --git a/docs/BRIEF_depuis_plugin.md b/docs/BRIEF_depuis_plugin.md index 83bcaa2..99a4e85 100644 --- a/docs/BRIEF_depuis_plugin.md +++ b/docs/BRIEF_depuis_plugin.md @@ -813,9 +813,53 @@ compteurs de **même nature** (900 → `budget.allocatedW`, 480 → `draw.commit demeure — c'est un fait — mais il ne s'énonce plus comme une entorse. **Votre sonde peut aller le voir sur `.75` dès que nous aurons posé `+etm31`.** -**`counts` suit maintenant**, dans la forme arbitrée : clés opaques jamais recyclées, publié même -à zéro avec sa destination, et faisant foi pour la comptabilité — les `params` gardant la phrase -du motif. +### ✅ LIVRÉ — `counts`, et `GetChargingInfos` filtre enfin (`1.15.2+etm32`) + +```jsonc +"levels": [ { "level": "comfort", "targetW": 1380, "funding": "grid", + "counts": { "budget.allocatedW": 900, "draw.committedW": 480 } } ] +``` + +La forme arbitrée, sans écart : `Σ counts == targetW` toujours · publié **même à une seule +destination** · publié **même à zéro, avec sa destination** · **clés opaques, jamais recyclées**. +`counts` fait foi pour la comptabilité, `decision.params` pour la phrase du motif. + +**Et l'identité ne passe plus par `funding`** — elle somme des **compteurs**, plus des origines : + +``` +Σ charges Σ niveaux counts["budget.allocatedW"] == budget.allocatedW +Σ charges Σ niveaux counts["draw.committedW"] == draw.committedW +``` + +`EV_GRID_START` y entre **sans exception** : 900 → allocation, 480 → soutirage. + +> **La garantie n'est pas dans sept sites de construction, elle est dans une machine.** +> `testCountsSumToTarget` **relit la charge utile** — toutes les charges, trois régimes dont +> l'import où tout vaut 0 — et vérifie l'identité, la non-vacuité de `counts` et l'appartenance +> des clés au catalogue. Un huitième site de construction qui oublierait `counts` échouerait ici, +> pas en relecture de code. +> +> **Et la contre-épreuve a trouvé un trou dans le test lui-même** : amputer le partage +> d'`EV_GRID_START` le laissait **passer**, parce que la borne, auto-provisionnée en queue, ne +> voyait jamais de budget. Corrigé — rang 1, régime `EV_GRID_START` en premier (une fois la +> borne en charge, son recrédit porte le budget au-dessus du plancher et le régime ne peut plus +> se produire), et une assertion qui **exige** que le régime ait été exercé. + +### `GetChargingInfos` filtre — et c'était le seul cas du namespace + +Votre relevé était juste : paramètre déclaré au schéma, documenté « for all or a single EV +charger », et `Q_UNUSED`. **Des trois issues, celle-là était la pire** — un paramètre absent se +voit, un paramètre refusé se voit, un paramètre ignoré ne se voit pas. + +Il filtre. Un **identifiant inconnu rend une liste VIDE, jamais une erreur** : toute borne +configurée a une entrée, donc une liste vide ne peut vouloir dire qu'une chose. La description du +schéma le dit désormais. + +> **Balayage du namespace fait**, et le résultat est rassurant : sur les 20 méthodes, 9 +> déclarent des paramètres et **`GetChargingInfos` était la seule** à en ignorer un. La famille +> était petite. *(Mon premier balayage avait conclu « aucune méthode ne déclare de paramètre » — +> la regex était fausse. Refait proprement en croisant, pour chaque méthode, les +> `params.insert` de sa déclaration avec les `params.value` de son corps.)* > Et deux fils antérieurs de votre brief sont **clos**, au cas où ils ne vous seraient pas > parvenus : l'**entrelacement des domaines** est tranché — grouper à l'affichage, **jamais à diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index f7dc789..d5eb93f 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -1066,7 +1066,9 @@ void EnergyArbitrator::buildTelemetry(const Plan &plan, const Slot &slot, const orphan.insert(QStringLiteral("levels"), QVariantList() << QVariantMap{{QStringLiteral("level"), QStringLiteral("comfort")}, {QStringLiteral("targetW"), 0.0}, - {QStringLiteral("funding"), QStringLiteral("surplus")}}); + {QStringLiteral("funding"), QStringLiteral("surplus")}, + {QStringLiteral("counts"), + QVariantMap{{QString::fromLatin1(CountKey::BudgetAllocated), 0.0}}}}); orphan.insert(QStringLiteral("available"), false); orphan.insert(QStringLiteral("decision"), action.reason.toMap()); orphan.insert(QStringLiteral("faultCode"), QStringLiteral("THING_MISSING")); @@ -1114,6 +1116,18 @@ void EnergyArbitrator::buildTelemetry(const Plan &plan, const Slot &slot, const niveau.insert(QStringLiteral("funding"), action.funding == LoadAction::Grid ? QStringLiteral("grid") : QStringLiteral("surplus")); + // R3 — LA VENTILATION : dans quel compteur chaque watt tombe. Distincte de « funding », + // qui dit d'où ils VIENNENT — les deux ne se dérivent pas l'un de l'autre, et sur un + // EV_GRID_START l'un vaut « grid » quand l'autre porte deux destinations. + // + // Publiée TOUJOURS, même à une seule destination et même à zéro : un « counts » vide + // satisferait l'identité en perdant la destination, sur le cas le plus fréquent. Un + // zéro AVEC sa destination dit « cette grandeur existe et vaut 0 » ; l'absence dirait + // « elle n'existe pas », ce qui est une autre affirmation. + QVariantMap counts; + for (auto it = action.counts.constBegin(); it != action.counts.constEnd(); ++it) + counts.insert(it.key(), it.value()); + niveau.insert(QStringLiteral("counts"), counts); entry.insert(QStringLiteral("levels"), QVariantList() << niveau); entry.insert(QStringLiteral("available"), adapter->telemetry().available); entry.insert(QStringLiteral("decision"), action.reason.toMap()); diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.cpp b/energyplugin/etm/scheduler/rulebasedscheduler.cpp index 93b805b..c84a69e 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.cpp +++ b/energyplugin/etm/scheduler/rulebasedscheduler.cpp @@ -59,6 +59,7 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx) la.phaseCount = ca.desiredPhaseCount(); la.reason = { DecisionCode::EvSpotMarket }; la.estimatedPowerW = la.currentA * 230.0 * la.phaseCount; + la.counts[CountKey::DrawCommitted] = la.estimatedPowerW; // R3 — acheté } else { // 3g-1 — LE SURPLUS N'EST PLUS DÉCIDÉ ICI. La borne descend dans le waterfall, où @@ -259,6 +260,7 @@ LoadAction RuleBasedScheduler::buildTimeRequirementAction(EvCharger *ev, la.phaseCount = ca.desiredPhaseCount(); la.reason = { DecisionCode::EvDeadline }; la.estimatedPowerW = la.currentA * 230.0 * la.phaseCount; + la.counts[CountKey::DrawCommitted] = la.estimatedPowerW; // R3 — acheté (échéance) return la; } @@ -310,6 +312,9 @@ LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc, la.chargingEnabled = false; la.powerW = 0; la.estimatedPowerW = 0; + // R3 — zéro AVEC sa destination : la grandeur existe et vaut 0. Un counts vide + // dirait qu'elle n'existe pas, ce qui est une autre affirmation. + la.counts[CountKey::BudgetAllocated] = 0.0; la.reason = { DecisionCode::PhaseLimit, {{"limitW", qRound(maxW)}, {"requiredW", qRound(lc.declared.minPowerW)}} }; remainingSurplusW = budgetW; // rien d'engagé : le recrédit repart intact @@ -359,6 +364,10 @@ LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc, la.phaseCount = static_cast(qMax(1, lc.declared.phases)); la.powerW = plancherW; la.estimatedPowerW = plancherW; + // R3 — LE PARTAGE, publié plutôt que déduit du motif : la part de surplus dans + // le registre d'allocation, la part achetée dans celui du soutirage. + la.counts[CountKey::BudgetAllocated] = budgetW; + la.counts[CountKey::DrawCommitted] = soutireW; la.reason = { DecisionCode::EvGridStart, {{"budgetW", qRound(budgetW)}, {"floorW", qRound(plancherW)}, {"gridW", qRound(soutireW)}} }; @@ -418,6 +427,7 @@ LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc, la.funding = LoadAction::Surplus; la.powerW = gele; la.estimatedPowerW = gele; + la.counts[CountKey::BudgetAllocated] = gele; // R3 — gelée, mais toujours allouée // Le LIBELLÉ n'entre pas dans les paramètres : c'est de la langue naturelle saisie // par l'installateur, et la charge utile RPC doit en rester exempte. Il est substitué // au rendu, par renderFr(reason, label). @@ -448,6 +458,7 @@ LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc, la.funding = LoadAction::Surplus; la.powerW = setpointW; la.estimatedPowerW = setpointW; + la.counts[CountKey::BudgetAllocated] = setpointW; // R3 // 3g-2 — une borne lit `chargingEnabled` avant tout le reste (EvAdapter::applyAction) ; // laissé à sa valeur par défaut, il faisait refuser TOUTE consigne issue du waterfall, // consigne non nulle comprise. Sans objet pour les autres mécanismes, qui l'ignorent. @@ -564,6 +575,7 @@ LoadAction RuleBasedScheduler::buildSgReadyStateAction(const LoadContext &lc, la.funding = LoadAction::Surplus; la.state = bestState; la.estimatedPowerW = lc.declared.estimatedPowerW.value(bestState, 0.0); + la.counts[CountKey::BudgetAllocated] = la.estimatedPowerW; // R3 if (bestState != targetState) la.reason = { DecisionCode::LockMinStateHold, {{"state", bestState}} }; diff --git a/energyplugin/etm/types/loadaction.h b/energyplugin/etm/types/loadaction.h index 83d2eec..08f2ed8 100644 --- a/energyplugin/etm/types/loadaction.h +++ b/energyplugin/etm/types/loadaction.h @@ -3,8 +3,24 @@ #pragma once #include +#include #include "decisionreason.h" +//! \brief Clés de \c LoadAction::counts — identifiants OPAQUES du contrat R3. +//! +//! \warning **Une clé retirée ne revient JAMAIS avec un sens nouveau.** Le client les traite +//! comme opaques : une clé qu'il ne connaît pas s'affiche « destination inconnue de cette +//! version » et laisse \c Σ counts == targetW vérifiable. Recycler un identifiant lui ferait +//! ranger des watts dans le mauvais compteur sans qu'aucune erreur ne le signale. Même règle +//! que les numéros de règle retirés (\c tools/rules-retired.txt). +namespace CountKey { +//! Watts de SURPLUS alloués par la cascade → \c budget.allocatedW. +inline constexpr const char *BudgetAllocated = "budget.allocatedW"; +//! Watts ACHETÉS au réseau → \c draw.committedW. +inline constexpr const char *DrawCommitted = "draw.committedW"; +} + + /*! * \brief Action typée émise par l'arbitre vers un ILoadAdapter. * @@ -66,6 +82,23 @@ struct LoadAction { */ double estimatedPowerW = 0; + /*! + * \brief Ventilation des watts DÉCIDÉS par compteur — R3. Clés : \c CountKey. + * + * \invariant **`Σ counts == estimatedPowerW`, toujours.** C'est l'identité que le client + * réconcilie ; elle est vérifiée à chaque cycle par `testCountsSumToTarget`, qui la relit + * sur TOUTES les charges publiées plutôt que de faire confiance à chaque site de + * construction. + * \note **Toujours renseignée, même à une seule destination, même à zéro.** `counts` vide + * satisferait l'identité en perdant la destination — sur le cas le plus fréquent. Un zéro + * avec sa destination dit « cette grandeur existe et vaut 0 » ; l'absence dirait « elle + * n'existe pas », ce qui est une autre affirmation. + * \note **`funding` ne s'en déduit pas, et réciproquement** : \c funding dit l'ORIGINE des + * watts (« ils sont achetés »), \c counts dit le REGISTRE où ils sont comptés. Sur un + * `EV_GRID_START`, l'un vaut « grid » quand l'autre porte deux destinations. + */ + QMap counts; + /*! * \brief Forçage sécurité — bypasse les verrous anti-rebond (minOn/minOff). * diff --git a/energyplugin/nymeaenergyjsonhandler.cpp b/energyplugin/nymeaenergyjsonhandler.cpp index 659aab3..af488ee 100644 --- a/energyplugin/nymeaenergyjsonhandler.cpp +++ b/energyplugin/nymeaenergyjsonhandler.cpp @@ -105,7 +105,10 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana registerMethod("SetLockOnUnplug", description, params, returns, Types::PermissionScopeControlThings); params.clear(); returns.clear(); - description = "Get the charging info for all or a single EV charger"; + description = "Get the charging info for all EV chargers, or for a single one when " + "evChargerId is given. An unknown id yields an EMPTY list, never an error: " + "every configured charger has an entry, so an empty list means 'no such " + "charger' and nothing else."; params.insert("o:evChargerId", enumValueName(Uuid)); returns.insert("chargingInfos", QVariantList() << objectRef()); registerMethod("GetChargingInfos", description, params, returns, Types::PermissionScopeControlThings); @@ -517,9 +520,23 @@ JsonReply *NymeaEnergyJsonHandler::SetBatteryLevelConsideration(const QVariantMa JsonReply* NymeaEnergyJsonHandler::GetChargingInfos(const QVariantMap ¶ms) { - Q_UNUSED(params) + // Le paramètre était DÉCLARÉ au schéma, DOCUMENTÉ (« for all or a single EV charger ») et + // Q_UNUSED : accepté, sans effet, sans erreur. Un client qui s'y fiait lisait la + // configuration d'une AUTRE borne en croyant lire la sienne — et rien ne le lui disait. + // + // Des trois issues possibles, celle-là était la pire : un paramètre absent se voit, un + // paramètre refusé se voit, un paramètre ignoré ne se voit pas. Il filtre désormais. + ChargingInfos infos = m_smartChargingManager->chargingInfos(); + if (params.contains("evChargerId")) { + const ThingId demande(params.value("evChargerId").toUuid()); + ChargingInfos retenues; + for (const ChargingInfo &info : infos) + if (info.evChargerId() == demande) + retenues.append(info); + infos = retenues; + } QVariantMap returns; - returns.insert("chargingInfos", pack(m_smartChargingManager->chargingInfos())); + returns.insert("chargingInfos", pack(infos)); return createReply(returns); } @@ -766,6 +783,12 @@ QVariantMap NymeaEnergyJsonHandler::loadTelemetrySchema() levelItem.insert("level", enumValueName(String)); // eco | comfort levelItem.insert("targetW", enumValueName(Double)); levelItem.insert("funding", enumValueName(String)); // surplus | grid + // R3 — ventilation { destination → watts }. Clés OPAQUES, jamais recyclées pour un sens + // nouveau : une clé inconnue du client s'affiche « destination inconnue de cette version » + // et laisse Σ counts == targetW vérifiable. Typée Variant parce que ses CLÉS sont le + // contenu — les figer au schéma reviendrait à republier le catalogue à chaque destination + // ajoutée. Le catalogue fait autorité dans INTERFACE.md et dans etm/types/loadaction.h. + levelItem.insert("counts", enumValueName(Variant)); QVariantMap loadItem; loadItem.insert("loadId", enumValueName(String)); diff --git a/tests/auto/charging/testcharging.cpp b/tests/auto/charging/testcharging.cpp index b87e52f..c610c14 100644 --- a/tests/auto/charging/testcharging.cpp +++ b/tests/auto/charging/testcharging.cpp @@ -2112,3 +2112,62 @@ void TestCharging::testOverloadProtectionEcoMode() QTEST_MAIN(TestCharging) + +void TestCharging::getChargingInfosFiltersOnCharger() +{ + // Le paramètre `evChargerId` était DÉCLARÉ au schéma, DOCUMENTÉ (« for all or a single EV + // charger ») et `Q_UNUSED` dans l'implémentation : accepté, sans effet, sans erreur. Un + // client qui s'y fiait lisait la configuration d'une AUTRE borne en croyant lire la sienne. + // + // Des trois issues possibles — filtrer, disparaître du schéma, rester ignoré — la dernière + // était la pire : un paramètre absent se voit, un paramètre refusé se voit, un paramètre + // ignoré ne se voit pas. + QVariant response; + response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", 25000}})); + verifyEnergyError(response); + removeDevices(); + + QUuid meterThingId = addMeter(); + QVERIFY(!meterThingId.isNull()); + QUuid borneA = addCharger("ABC", 16, 26657); + QUuid borneB = addCharger("A", 32, 26658); + QUuid voiture = addCar(); + QVERIFY(!borneA.isNull() && !borneB.isNull() && !voiture.isNull()); + + for (const QUuid &b : {borneA, borneB}) { + QVariantMap info; + info.insert("evChargerId", b); + info.insert("assignedCarId", voiture); + info.insert("chargingMode", b == borneA ? "ChargingModeEco" : "ChargingModeNormal"); + response = injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}})); + verifyEnergyError(response); + } + + auto lire = [&](const QVariant &id) { + QVariantMap p; + if (id.isValid()) p.insert("evChargerId", id); + return injectAndWait("NymeaEnergy.GetChargingInfos", p) + .toMap().value("params").toMap().value("chargingInfos").toList(); + }; + + // ---- Sans paramètre : les deux, comme avant. La correction n'enlève rien. --------------- + QCOMPARE(lire(QVariant()).count(), 2); + + // ---- Avec paramètre : UNE seule, et c'est la bonne ------------------------------------- + // Le test porte sur le CONTENU, pas sur le compte : un filtre qui rendrait toujours la + // première entrée passerait un test qui ne compte que les lignes. + const QVariantList uneA = lire(borneA); + QCOMPARE(uneA.count(), 1); + QCOMPARE(uneA.first().toMap().value("evChargerId").toUuid(), borneA); + QCOMPARE(uneA.first().toMap().value("chargingMode").toString(), QString("ChargingModeEco")); + + const QVariantList uneB = lire(borneB); + QCOMPARE(uneB.count(), 1); + QCOMPARE(uneB.first().toMap().value("evChargerId").toUuid(), borneB); + QCOMPARE(uneB.first().toMap().value("chargingMode").toString(), QString("ChargingModeNormal")); + + // ---- Identifiant inconnu : liste VIDE, jamais une erreur -------------------------------- + // Non ambigu, et c'est ce qui autorise à ne pas lever d'erreur : toute borne configurée a + // une entrée, donc une liste vide ne peut vouloir dire qu'une chose — « pas cette borne ». + QCOMPARE(lire(QUuid::createUuid()).count(), 0); +} diff --git a/tests/auto/charging/testcharging.h b/tests/auto/charging/testcharging.h index c8ed30a..c7d9d64 100644 --- a/tests/auto/charging/testcharging.h +++ b/tests/auto/charging/testcharging.h @@ -59,6 +59,10 @@ private slots: // COMPORTEMENT qu'elle annonce, pas sur la non-vacuite d'une reponse. void setChargingInfoReplacesWholesale(); + //! GetChargingInfos FILTRE sur evChargerId. Le paramètre était déclaré au schéma, documenté + //! « for all or a single EV charger », et Q_UNUSED : accepté, sans effet, sans erreur. + void getChargingInfosFiltersOnCharger(); + // Test individual logic void testEcoMode_data(); void testEcoMode(); diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index d995460..72de0b6 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -6131,3 +6131,169 @@ void Simulation::testVanishedRelayFreezesTheLoad() "une charge au câblage incomplet ne doit pas publier de puissance mesurée"); #endif } + +void Simulation::testCountsSumToTarget() +{ +#ifndef ETM_ARBITRATOR + QSKIP("testCountsSumToTarget nécessite ETM_ARBITRATOR."); +#else + // [R3] « Chaque watt publié tombe dans exactement un compteur, et le mapping est publié. » + // + // Sept endroits construisent une LoadAction, et chacun doit renseigner `counts`. Vérifier + // cela en RELISANT le code se referait à chaque site ajouté, et échouerait le jour où + // quelqu'un en ajoute un huitième. Ce test relit la CHARGE UTILE : il ne fait confiance à + // aucun site, il constate le résultat sur toutes les charges du cycle. + const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-counts.json"; + QFile::remove(cfgPath); + qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); + + cleanupTestCase(); + m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; + initTestCase(); + + EnergyArbitrator *arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); + QVERIFY(arb); + ThingManager *tm = NymeaCore::instance()->thingManager(); + + QUuid meterId = addMeter(); + m_experiencePlugin->energyManager()->setRootMeter(meterId); + Thing *meter = tm->findConfiguredThing(meterId); + QVERIFY(meter); + meter->setStateValue("connected", true); + QCOMPARE(injectAndWait("NymeaEnergy.SetAcquisitionTolerance", + QVariantMap({{"acquisitionTolerance", 0.5}})).toMap() + .value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + + // Trois mécanismes + une borne : le plus de chemins de construction possible en un cycle. + QUuid rA = addPowerSwitch(1000, 26661); + QUuid k1 = addPowerSwitch(0, 26662); + QUuid k2 = addPowerSwitch(0, 26663); + QUuid varId = addEtmVariableLoad(27044); + QUuid borneId = addCharger("A", 16, 26657); + Thing *borne = tm->findConfiguredThing(borneId); + Thing *var = tm->findConfiguredThing(varId); + QVERIFY(borne && var && tm->findConfiguredThing(rA)); + armerBorne(borne); + var->setStateValue("currentPowerW", 0); + + QVariantMap info; + info.insert("evChargerId", borneId); + info.insert("chargingMode", "ChargingModeEco"); + injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}})); + + const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); + meter->setStateValue("currentPower", 0); + arb->simulationCallUpdate(t0); + QCoreApplication::processEvents(); + + QVariantList l = injectAndWait("NymeaEnergy.GetLoadConfig").toMap() + .value("params").toMap().value("loadConfigs").toList(); + // La borne au rang 1 — sinon, auto-provisionnée EN QUEUE (LM-1209), elle ne voit jamais de + // budget et le régime EV_GRID_START n'est pas exercé. C'est la contre-épreuve qui l'a + // montré : retirer la ventilation du partage laissait le test PASSER. + for (int i = 0; i < l.count(); ++i) { + QVariantMap m = l.at(i).toMap(); + if (m.value("id").toString() == borneId.toString()) m.insert("priority", 1); + l[i] = m; + } + l.append(QVariantMap{{"id", varId.toString()}, {"label", "Routeur"}, + {"adapter", "etmvariableload"}, {"mode", "dynamic"}, + {"maxPowerW", 3000}, {"priority", 8}, {"enabled", true}}); + l.append(QVariantMap{{"id", "ecs-counts"}, {"label", "ECS"}, {"adapter", "relay-router"}, + {"mode", "fixed"}, {"priority", 9}, {"enabled", true}, + {"relays", QVariantList() << QVariantMap{{"thingId", rA.toString()}, + {"powerW", 1000}}}, + {"minOnS", 0}, {"minOffS", 0}}); + l.append(sgReadyConfigMap("pac-counts", "PAC", k1, k2, 800.0, 1600.0, 0, 10)); + QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}})) + .toMap().value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + + // L'identité doit tenir dans TOUS les régimes, pas seulement quand tout est servi : un + // surplus large, un surplus qui force le démarrage sous tolérance, et un import qui + // déleste. Le troisième est celui où les allocations valent 0 — donc celui où un `counts` + // vide passerait inaperçu. + struct Regime { int compteurW; const char *quoi; bool attendEvGridStart; }; + // EV_GRID_START EN PREMIER, et l'ordre est un fait du test, pas une préférence : dès qu'un + // cycle a fait charger la borne, son RECRÉDIT (correction B) porte le budget bien au-dessus + // du plancher, et le démarrage sous tolérance ne peut plus se produire. Placé en second, le + // régime n'était jamais exercé — la contre-épreuve l'a montré en laissant PASSER un partage + // amputé de moitié. + const QList regimes = { {-900, "démarrage sous tolérance (EV_GRID_START)", true}, + {-9000, "surplus large", false}, + {2000, "import — délestage, allocations nulles", false} }; + int cycle = 0; + for (const Regime &r : regimes) { + meter->setStateValue("currentPower", r.compteurW); + arb->simulationCallUpdate(t0.addSecs(60 * ++cycle)); + QCoreApplication::processEvents(); + const QVariantMap snap = arb->loadTelemetry(); + const QVariantList charges = snap.value("loads").toList(); + QVERIFY2(!charges.isEmpty(), r.quoi); + + if (r.attendEvGridStart) { + bool vu = false; + for (const QVariant &v : charges) + if (v.toMap().value("decision").toMap().value("code").toString() + == QLatin1String(DecisionCode::EvGridStart)) + vu = true; + QVERIFY2(vu, "le régime EV_GRID_START n'a PAS été exercé — le test ne prouverait " + "alors rien du partage entre les deux registres"); + } + + for (const QVariant &v : charges) { + const QVariantMap ch = v.toMap(); + const QVariantList niveaux = ch.value("levels").toList(); + QVERIFY2(!niveaux.isEmpty(), + qUtf8Printable(QStringLiteral("%1 : %2 sans levels[]") + .arg(r.quoi, ch.value("loadId").toString()))); + for (const QVariant &nv : niveaux) { + const QVariantMap niveau = nv.toMap(); + const QVariantMap counts = niveau.value("counts").toMap(); + + // 1. TOUJOURS présent, et jamais vide — même quand tout vaut 0. Un counts vide + // satisferait l'identité en perdant la destination, sur le cas le plus + // fréquent : c'est exactement la panne que R3 existe pour empêcher. + QVERIFY2(!counts.isEmpty(), + qUtf8Printable(QStringLiteral("%1 : %2 publie un counts VIDE") + .arg(r.quoi, ch.value("loadId").toString()))); + + // 2. Σ counts == targetW, à l'arrondi du watt près. + double somme = 0; + for (const QVariant &c : counts) + somme += c.toDouble(); + QVERIFY2(qAbs(somme - niveau.value("targetW").toDouble()) < 1.0, + qUtf8Printable(QStringLiteral("%1 : %2 — Σ counts = %3, targetW = %4") + .arg(r.quoi, ch.value("loadId").toString()) + .arg(somme).arg(niveau.value("targetW").toDouble()))); + + // 3. Les clés appartiennent au catalogue : une clé inventée ne se glisse pas + // dans la charge utile sans que quelqu'un l'ait décidée. + for (const QString &k : counts.keys()) + QVERIFY2(k == QLatin1String(CountKey::BudgetAllocated) + || k == QLatin1String(CountKey::DrawCommitted), + qUtf8Printable(QStringLiteral("clé de counts hors catalogue : %1").arg(k))); + } + } + + // 4. Et la ventilation se RÉCONCILIE avec les compteurs du cycle — c'est l'identité que + // l'app calcule, relue ici sur la charge utile publiée. + double surplus = 0, achete = 0; + for (const QVariant &v : charges) + for (const QVariant &nv : v.toMap().value("levels").toList()) { + const QVariantMap c = nv.toMap().value("counts").toMap(); + surplus += c.value(QString::fromLatin1(CountKey::BudgetAllocated)).toDouble(); + achete += c.value(QString::fromLatin1(CountKey::DrawCommitted)).toDouble(); + } + QVERIFY2(qAbs(surplus - snap.value("budget").toMap().value("allocatedW").toDouble()) < 1.0, + qUtf8Printable(QStringLiteral("%1 : Σ counts(surplus) = %2, budget.allocatedW = %3") + .arg(r.quoi).arg(surplus) + .arg(snap.value("budget").toMap().value("allocatedW").toDouble()))); + QVERIFY2(qAbs(achete - snap.value("draw").toMap().value("committedW").toDouble()) < 1.0, + qUtf8Printable(QStringLiteral("%1 : Σ counts(acheté) = %2, draw.committedW = %3") + .arg(r.quoi).arg(achete) + .arg(snap.value("draw").toMap().value("committedW").toDouble()))); + } +#endif +} diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index 6b9369c..a3c3088 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -179,6 +179,13 @@ private slots: // mais un compteur injoignable fait retomber sur l'appareil au lieu de taire la mesure. void testMeasurementSourceIsPublished(); + // [R3] Σ counts == targetW sur TOUTES les charges publiées, à chaque cycle, quel que soit + // le chemin qui a construit l'action. C'est une relecture de la charge utile, pas une + // confiance faite à sept sites de construction : un site oublié se voit ici, jamais en + // relisant le code. Couvre aussi le partage d'EV_GRID_START et la présence de counts à + // zéro avec sa destination. + void testCountsSumToTarget(); + // ── §12 / LM-1209 ────────────────────────────────────────────────────── // Une borne détectée après la mise en service entre EN QUEUE : elle ne passe devant // aucune charge qu'un humain a classée, aucun autre rang ne bouge, le rang produit est