diff --git a/AGENTS.md b/AGENTS.md index d413c74..122a998 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -356,6 +356,14 @@ Ensuite : **passe contrats** (OPTIMIZER_PROTOCOL + README). > Le **trou de `pluggedIn`** trouvé au même passage est CORRIGÉ — une borne sans véhicule > branché est hors arbitrage, elle n'absorbe plus le budget de la cascade. +> **Le chantier de documentation a exhumé son premier défaut réel** (2026-08-29). `ECS-111` — +> « on publie la puissance d'un relais disparu » — dormait dans une table de statut supprimée +> depuis, et est ressortie dans les quatorze règles « spécifiées seules en territoire livré » +> que `tools/gen-rules.py` a listées. La lecture a montré que le chiffre fantôme n'était pas +> seulement affiché : il alimentait le **recrédit**, donc le **budget**, pendant qu'`available` +> restait vrai. Ni un test ni le banc ne l'avaient vu. **Corrigé le jour même** (`ECS-111`, +> option (a) — sortie de l'arbitrage, maintien dans la comptabilité). + **Restent à guetter au banc, jamais à fabriquer** : `EV_GRID_START` (fenêtre `[plancher × 0,5 ; plancher[`), `BATTERY_RESERVE` avec un surplus réel sous le seuil, et LM-1204-b avec **deux bornes réelles** — la Terra AC ne répond toujours pas à l'ARP. diff --git a/energyplugin/etm/adapters/relayrouter.cpp b/energyplugin/etm/adapters/relayrouter.cpp index d059176..3994a5a 100644 --- a/energyplugin/etm/adapters/relayrouter.cpp +++ b/energyplugin/etm/adapters/relayrouter.cpp @@ -131,12 +131,36 @@ LoadTelemetry RelayRouter::telemetry() const // Un thermostat coupé (relais ON mais 0 W) → 0, jamais de puissance fantôme. // - NOMINAL commandé (repli) si aucun relais actif ne mesure (GPIO bool nu). Dans ce cas // currentPowerW reflète le COMMANDÉ, pas le mesuré (contrat rév. 3 §3.a). + // \rule{ECS-111} — UN RELAIS DÉCLARÉ QUI MANQUE REND LA CHARGE INDISPONIBLE. + // + // Le comportement était « non défini », et son effet dépassait l'affichage. Le relais + // disparu était SAUTÉ : ou bien plus aucun relais actif ne mesurait, et l'on retombait sur + // le nominal du PALIER ENTIER — la part du relais fantôme comprise ; ou bien des survivants + // mesuraient, et l'on publiait une somme SOUS-ÉVALUÉE. Dans les deux cas le chiffre était + // faux, et il n'était pas seulement affiché : \c currentPowerW alimente le recrédit + // anti-clignotement, donc le BUDGET. L'arbitre se trompait sur le surplus disponible + // pendant que \c available restait vrai — un mensonge qui décide, pas un mensonge qui + // s'affiche. + // + // Un relais absent du registre nymea est un Thing supprimé, ou un plugin qui ne le voit + // plus : **on ne sait pas dans quel état est le contact**. Compter 0 W et servir le reste + // serait supposer. ECS-410 dit quoi faire d'une charge dont on ne sait plus rien — sortir + // de l'ARBITRAGE, rester dans la COMPTABILITÉ, figer à la puissance crue engagée — et + // c'est le traitement appliqué ici, comme \c EtmVariableLoadAdapter et \c EvAdapter le + // font déjà pour leur Thing. \c deviceMeasurement() le faisait déjà : ceci finit le + // mouvement au lieu d'inventer une politique. + const bool relaisManquant = aUnRelaisManquant(); + t.available = t.available && !relaisManquant; + double power = 0; bool metered = false; const QList activeRelays = m_currentStage < m_relayMapping.size() ? m_relayMapping.at(m_currentStage) : QList(); - for (const QString &thingId : activeRelays) { + // Câblage incomplet : aucune somme partielle. On annonce le NOMINAL du palier courant — + // la plus haute puissance qui puisse couler, jamais moins (même direction qu'ECS-410 et + // ECS-411). Sous-estimer ferait offrir aux charges suivantes un budget qui n'existe pas. + for (const QString &thingId : relaisManquant ? QList() : activeRelays) { Thing *relay = m_thingManager->findConfiguredThing(ThingId(thingId)); if (!relay) continue; @@ -182,12 +206,21 @@ LoadContext RelayRouter::toLoadContext(const QDateTime &now) const // internes en puissances via la table de paliers. Sans ce canal, le scheduler décrémente // le budget d'une valeur que l'adaptateur va écrêter, et les charges suivantes reçoivent // un résidu surestimé pendant toute la fenêtre minOn (AGENTS règle 4). - ctx.telemetry.available = !m_faulted; + // \rule{ECS-111} — la disponibilité se lit sur telemetry(), PAS sur m_faulted seul : un + // relais déclaré qui manque au registre rend la charge indisponible au même titre qu'un + // défaut d'écriture. Recopier « !m_faulted » ici ferait qu'une charge annoncée + // indisponible en télémétrie continuerait d'être ALLOUÉE par le waterfall — les deux + // frontières diraient deux choses de la même charge. + ctx.telemetry.available = telemetry().available; - if (m_faulted) { + if (!ctx.telemetry.available) { // ECS-410 — charge FIGÉE, ni montée ni descente : plancher ET plafond à la puissance // crue engagée. Un plafond nul avec un plancher non nul serait contradictoire et // ferait dérailler le qBound du scheduler. + // + // C'est ce qui rend le traitement d'ECS-111 juste POUR LE BUDGET : la puissance crue + // engagée est RETIRÉE du budget des charges suivantes au lieu de leur être recréditée. + // Avant, le fantôme gonflait le recrédit — il offrait du surplus qui n'existait pas. ctx.telemetry.lockMinPowerW = ctx.telemetry.currentPowerW; ctx.telemetry.lockMaxPowerW = ctx.telemetry.currentPowerW; return ctx; @@ -518,6 +551,14 @@ void RelayRouter::settleTransition() } } +bool RelayRouter::aUnRelaisManquant() const +{ + for (auto it = m_relayNominalW.constBegin(); it != m_relayNominalW.constEnd(); ++it) + if (!m_thingManager->findConfiguredThing(ThingId(it.key()))) + return true; + return false; +} + LoadMeasurement RelayRouter::deviceMeasurement() const { LoadMeasurement m; @@ -580,6 +621,12 @@ LoadRuntimeView RelayRouter::runtimeView(const QDateTime &now) const // épuisée. Une cause nouvelle vaudra un code nouveau, jamais une phrase. if (m_faulted) v.faultCode = QStringLiteral("WRITE_FAILED"); + // \rule{ECS-111} — THING_MISSING PRIME sur WRITE_FAILED, et ce n'est pas un choix de + // présentation : un relais absent EXPLIQUE les écritures qui échouent. Annoncer + // WRITE_FAILED enverrait l'opérateur au bus alors que la cause est dans la configuration, + // et \c ClearLoadFault — le remède de WRITE_FAILED — n'y peut rien. + if (aUnRelaisManquant()) + v.faultCode = QStringLiteral("THING_MISSING"); // Verrou en SECONDES RESTANTES. Le calcul dérive du MÊME m_lastSwitch et du même \c now que // lockWindow() : ce que le client lit est ce qui a borné la décision, pas une seconde vue. diff --git a/energyplugin/etm/adapters/relayrouter.h b/energyplugin/etm/adapters/relayrouter.h index fbe705b..50c915c 100644 --- a/energyplugin/etm/adapters/relayrouter.h +++ b/energyplugin/etm/adapters/relayrouter.h @@ -180,6 +180,11 @@ public: /*! * \brief Lève le verrou de défaut (ECS-410, RPC \c NymeaEnergy.ClearLoadFault). * \return Vrai si un défaut a RÉELLEMENT été levé, faux s'il n'y en avait pas. + * \note **Lever le défaut ne rend pas forcément la charge disponible.** \rule{ECS-111} : + * un relais déclaré qui manque au registre la garde indisponible, et ClearLoadFault n'y + * peut rien — il ne fait pas exister un Thing. Les deux causes sont distinctes et se + * lisent séparément : \c faulted() pour le défaut d'écriture collant, + * \c telemetry().available pour l'aptitude réelle à être commandée. * \note Le défaut est COLLANT par conception — « cesser toute commande jusqu'à * intervention ». Sa levée est donc un acte délibéré et tracé de l'opérateur, jamais * une expiration. L'état matériel réel étant inconnu après un défaut, il est RELU @@ -252,6 +257,15 @@ private: int deduceStageFromThings() const; void applyRelayStage(int stage); + /*! + * \brief Un relais DÉCLARÉ manque-t-il au registre nymea ? \rule{ECS-111} + * \return Vrai dès qu'un seul \c thingId déclaré ne résout plus. + * \note Jugé sur TOUS les relais déclarés, jamais sur ceux du palier courant : au palier 0 + * aucun n'est fermé, et juger là-dessus ferait basculer la disponibilité au rythme du + * thermostat. Même règle que \c deviceMeasurement(). + */ + bool aUnRelaisManquant() const; + ThingManager *m_thingManager; QString m_id; QString m_label; diff --git a/specs/spec_ecs.md b/specs/spec_ecs.md index 7399477..e5ece16 100644 --- a/specs/spec_ecs.md +++ b/specs/spec_ecs.md @@ -712,11 +712,51 @@ Les identifiants sont normalisés via `QUuid` : la configuration mélange `{uuid charge en double sont refusés par la même passe — la seconde entrée était jusqu'ici perdue en silence à la construction. -**ECS-111 — Suppression d'un Thing référencé.** Comportement non défini -aujourd'hui : `findConfiguredThing` renvoie `nullptr`, `writeRelay` logue et -continue, `telemetry()` saute le relais donc `metered = false` et l'on retombe -sur le nominal commandé — **on publie la puissance d'un relais disparu**. À -définir et tester. +**ECS-111 — Suppression d'un Thing référencé. DÉFINI ET CORRIGÉ le 2026-08-29.** + +*Le défaut, tel qu'il était.* `findConfiguredThing` renvoyait `nullptr`, `writeRelay` loguait et +continuait, `telemetry()` **sautait** le relais. Deux issues, toutes deux fausses : ou bien plus +aucun relais actif ne mesurait et l'on retombait sur le nominal du **palier entier** — part du +relais fantôme comprise ; ou bien des survivants mesuraient et l'on publiait une somme +**sous-évaluée**. + +> **Et ce n'était pas un défaut d'affichage.** `telemetry().currentPowerW` alimente le **recrédit +> anti-clignotement** (`rulebasedscheduler.cpp`), donc le **budget**. L'arbitre se trompait sur le +> surplus disponible pendant qu'`available` restait vrai. **Un mensonge qui décide, pas un +> mensonge qui s'affiche** — c'est ce qui a fait passer ce point d'« à définir » à corrigé le jour +> même. + +**La règle.** Un relais **déclaré** qui manque au registre nymea rend la charge **indisponible** : +`available: false`, `faultCode: THING_MISSING`, et le traitement d'ECS-410 s'applique — **sortie de +l'ARBITRAGE, maintien dans la COMPTABILITÉ**, figée à la puissance crue engagée. Aucune somme +partielle n'est jamais publiée. + +*Pourquoi pas « compter 0 W et servir le reste ».* Un relais absent du registre est un Thing +supprimé, ou un plugin qui ne le voit plus : **on ne sait pas dans quel état est le contact**. +Servir le reste reviendrait à commander une charge dont on ne connaît plus la configuration +matérielle — c'est-à-dire à supposer. + +> **Jugé sur TOUS les relais déclarés**, jamais sur ceux du palier courant : au palier 0 aucun +> n'est fermé, et juger là-dessus ferait basculer la disponibilité au rythme du thermostat. Même +> règle que `deviceMeasurement()` (LM-1105) — qui, lui, traitait **déjà** le cas correctement en +> rendant `source: "none"`. Aligner l'ancien sur le neuf n'a pas été inventer une politique, mais +> finir un mouvement. +> +> ⚠️ **`ClearLoadFault` ne rend pas la charge disponible pour autant.** Il lève le défaut +> d'**écriture** — c'est sa promesse — mais ne fait pas exister un Thing. Les deux causes se +> lisent séparément : `faulted()` pour le défaut collant, `available` pour l'aptitude réelle à +> être commandée. + +*Éprouvé par* `testVanishedRelayFreezesTheLoad`, **vérifié échouant** sans le correctif, et dont +la moitié qui compte est la troisième assertion : ce que le fantôme laissait aux charges +**suivantes**. + +> **Comment ce défaut a été trouvé.** Ni par un test, ni par le banc : **par la lecture d'une +> règle que l'index a rendue visible.** `ECS-111` dormait dans une table de statut supprimée +> depuis, puis est ressortie dans les quatorze règles « spécifiées seules en territoire livré » +> qu'`tools/gen-rules.py` a listées. C'est le premier défaut réel exhumé par le chantier de +> documentation — et l'argument le plus concret pour le poursuivre : un index qui rend les +> règles lisibles finit par rendre les trous lisibles aussi. --- diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index 3450cf3..57fd552 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -1558,7 +1558,18 @@ void Simulation::testEcsPartialFailure() qInstallMessageHandler(s_handlerPrecedent); QVERIFY(leve); QVERIFY(!r->faulted()); - QCOMPARE(r->telemetry().available, true); + + // [ECS-111, 2026-08-29] La charge reste INDISPONIBLE, et c'est la bonne réponse : ce + // routeur déclare `rKo`, un relais JAMAIS configuré. ClearLoadFault lève le défaut + // d'ÉCRITURE — c'est sa promesse, et `!r->faulted()` juste au-dessus la vérifie — mais il + // ne fait pas exister un Thing. Annoncer `available: true` ici serait vrai pendant un + // cycle et faux au suivant, la première écriture rouvrant le défaut. + // + // Ce test se servait d'un relais inexistant comme RACCOURCI pour forcer un échec + // d'écriture. Le raccourci a désormais un sens propre — câblage incomplet — et les deux + // causes se distinguent : le défaut collant se lève, le câblage manquant non. + QCOMPARE(r->telemetry().available, false); + QCOMPARE(r->runtimeView(t0).faultCode, QStringLiteral("THING_MISSING")); const QString issue1 = s_journal.filter(QStringLiteral("ClearLoadFault")).join(QStringLiteral(" | ")); QVERIFY2(issue1.contains(QStringLiteral("défaut LEVÉ")), qUtf8Printable(issue1)); @@ -5985,3 +5996,99 @@ void Simulation::testDeletedEvChargerConfigComesBack() QCOMPARE(apres, avant); #endif } + +void Simulation::testVanishedRelayFreezesTheLoad() +{ +#ifndef ETM_ARBITRATOR + QSKIP("testVanishedRelayFreezesTheLoad nécessite ETM_ARBITRATOR."); +#else + // [ECS-111] Le comportement était « non défini », et son effet dépassait l'affichage : le + // relais disparu était SAUTÉ, on retombait sur le nominal du PALIER ENTIER — part du relais + // fantôme comprise — et ce chiffre alimente le recrédit anti-clignotement, donc le BUDGET. + // L'arbitre se trompait sur le surplus pendant que `available` restait vrai. + // + // Ce test porte les deux moitiés, et c'est la seconde qui compte : ce que le fantôme + // laissait aux charges SUIVANTES. + const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-vanish.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); + + QUuid rA = addPowerSwitch(2000, 26661); // ECS : un seul relais, 2000 W + QUuid varId = addEtmVariableLoad(27043); // la charge SUIVANTE, celle qui paie + Thing *routeur = tm->findConfiguredThing(varId); + QVERIFY(tm->findConfiguredThing(rA) && routeur); + routeur->setStateValue("currentPowerW", 0); + + QVariantList l; + l.append(QVariantMap{{"id", "ecs-fantome"}, {"label", "ECS relais"}, + {"adapter", "relay-router"}, {"mode", "fixed"}, + {"priority", 1}, {"enabled", true}, + {"relays", QVariantList() << QVariantMap{{"thingId", rA.toString()}, + {"powerW", 2000}}}, + {"minOnS", 0}, {"minOffS", 0}}); + l.append(QVariantMap{{"id", varId.toString()}, {"label", "Routeur PV"}, + {"adapter", "etmvariableload"}, {"mode", "dynamic"}, + {"maxPowerW", 3000}, {"priority", 2}, {"enabled", true}}); + QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}})) + .toMap().value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + + // ---- Régime sain : l'ECS prend son palier, le routeur prend le reste ------------------ + const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); + meter->setStateValue("currentPower", -5000); + arb->simulationCallUpdate(t0); + QCoreApplication::processEvents(); + QHash tele = telemetrieParId(arb->loadTelemetry()); + QCOMPARE(tele.value("ecs-fantome").value("available").toBool(), true); + QCOMPARE(qRound(tele.value("ecs-fantome").value("allocatedW").toDouble()), 2000); + const int routeurSain = qRound(tele.value(varId.toString()).value("allocatedW").toDouble()); + QVERIFY2(routeurSain > 0, "le routeur doit être servi en régime sain"); + + // ---- Le relais DISPARAÎT du registre nymea -------------------------------------------- + // Thing supprimé, ou plugin qui ne le voit plus : on ne sait pas dans quel état est le + // contact. C'est précisément pour ça qu'on ne peut pas « compter 0 W et servir le reste ». + QVERIFY(removeDevice(rA).toMap().value("params").toMap().value("thingError").toString() + == "ThingErrorNoError"); + arb->simulationCallUpdate(t0.addSecs(60)); + QCoreApplication::processEvents(); + tele = telemetrieParId(arb->loadTelemetry()); + + // 1. La charge SORT DE L'ARBITRAGE, et elle le DIT. + const QVariantMap ecs = tele.value("ecs-fantome"); + QCOMPARE(ecs.value("available").toBool(), false); + QCOMPARE(ecs.value("faultCode").toString(), QString("THING_MISSING")); + QCOMPARE(ecs.value("decision").toMap().value("code").toString(), + QString(DecisionCode::LoadUnavailable)); + + // 2. …mais PAS de la comptabilité : la puissance crue engagée reste allouée, parce qu'on + // ne sait plus la couper. C'est ECS-410, appliqué à une cause nouvelle. + QCOMPARE(qRound(ecs.value("allocatedW").toDouble()), 2000); + + // 3. ET C'EST LA MOITIÉ QUI DÉCIDE : le fantôme est RETIRÉ du budget des suivantes au lieu + // de leur être recrédité. Avant le correctif, `currentPowerW` valait le nominal du + // palier entier et alimentait le recrédit — le routeur se voyait offrir un surplus qui + // n'existait pas. Ici il reçoit ce qui reste vraiment, pas davantage. + const int routeurFantome = qRound(tele.value(varId.toString()).value("allocatedW").toDouble()); + QVERIFY2(routeurFantome <= routeurSain, + "la charge suivante ne doit pas recevoir PLUS quand un relais a disparu"); + + // 4. Et la mesure ne prétend rien : rien ne mesure une charge dont le câblage manque. + QCOMPARE(ecs.value("measurement").toMap().value("source").toString(), QString("none")); + QVERIFY2(!ecs.value("measurement").toMap().contains("powerW"), + "une charge au câblage incomplet ne doit pas publier de puissance mesurée"); +#endif +} diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index 23525b7..6b9369c 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -191,6 +191,12 @@ private slots: // réglable — le trou de 3g-2 rouvert par le côté client. void testDeletedEvChargerConfigComesBack(); + // [ECS-111] Un relais DÉCLARÉ qui disparaît du registre nymea : la charge sort de + // l'arbitrage (available faux, THING_MISSING) et reste dans la comptabilité, figée à la + // puissance crue engagée. Avant, elle publiait le nominal du palier entier — part du + // relais fantôme comprise — et ce chiffre entrait dans le RECRÉDIT, donc dans le budget. + void testVanishedRelayFreezesTheLoad(); + void printStates(Thing *thing); void updateChargerMeter(Thing *thing);