diff --git a/AGENTS.md b/AGENTS.md index 2558e17..6da0f43 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -924,6 +924,27 @@ aucune, la décision est une intention, et elle se périmera à la vitesse du co > **ce que ce contrôle aurait attrapé s'il avait échoué** — s'il n'y a pas de réponse, il ne > contrôle rien. +> ### Une conclusion JUSTE posée sur un raisonnement FAUX survit jusqu'à ce qu'on bâtisse dessus +> +> Deux occurrences, de deux côtés de la frontière : `allocatedW` côté app, la marge par phase côté +> moteur. Dans les deux cas la conclusion était bonne, la raison non — et **personne ne relit le +> pourquoi tant que le résultat est bon.** +> +> Cas moteur : le design affirmait qu'« une phase qui exporte a une marge PLUS GRANDE que le +> plafond, donc elle ne borne personne ». La conclusion est vraie ; la raison est fausse — la +> marge est **écrêtée au plafond**, l'export ne permettant pas de tirer plus que le disjoncteur. +> Le texte a vécu ainsi jusqu'à ce qu'un test bâtisse **sur le raisonnement** et attende +> `(25 + 10) × 230` : il a échoué, et c'est ce qui a révélé l'erreur. +> +> **C'est le mécanisme qui rend cette classe durable : la conclusion PROTÈGE le raisonnement de +> l'examen.** Un résultat faux se voit ; un résultat juste ferme la question. La faute ne se +> révèle qu'au moment où quelqu'un s'appuie sur le *pourquoi* plutôt que sur le *quoi* — et ce +> moment peut ne jamais venir, ou venir des mois plus tard, dans un lot qui n'a plus rien à voir. +> +> **La parade est celle des récidives, transposée** : quand une décision est écrite avec sa +> raison, la raison mérite son propre contrôle. Un test qui vérifie la conclusion ne vérifie pas +> le raisonnement — et le raisonnement est ce sur quoi le lot SUIVANT s'appuiera. + > ### Quand un contrôle attrape quatre fois la même erreur, c'est le GESTE qui est piégeux > > Le piège d'insertion Doxygen — un bloc glissé **entre un commentaire et sa déclaration**, si diff --git a/INTERFACE.md b/INTERFACE.md index 2b7382e..1455097 100644 --- a/INTERFACE.md +++ b/INTERFACE.md @@ -665,6 +665,24 @@ afficherait un plafond de soutirage **qui n'existe pas, avec sa source**. | `remainingW` | `authorisedW − committedW`. **L'identité tient toujours** — c'est ce qui rend la charge utile vérifiable. Peut être négative : les bornes VE engagent du soutirage borné par LEUR phase, pas par la marge de la maison. | | `binding` | Code opaque de la source qui borne : `connection` \| `gridOperator` \| `selfImposed`. Ce qu'on a le droit de faire d'un dépassement en dépend. | | `perPhaseBound` | Le plafond qui borne est-il **par phase** ? Un plafond total n'a pas de phase coupable, et le dire évite d'envoyer mesurer au hasard sur une triphasée. | +| `bindingPhase` | **Laquelle** — `A`, `B` ou `C`. Absent si le plafond est un total. Sans lui, l'écran ne peut dire que « bridé » **sans pouvoir dire par quoi**. | + +> ### ⚠️ Une installation qui EXPORTE peut être bridée — et l'écran ne doit jamais l'agréger +> +> Cas mesuré (`LM-1210-a`) : phase B saturée à 30 A sous un plafond de 25, phases A et C en +> export, **compteur global en export**. La marge retenue est celle de B — **négative** — pendant +> que le total afficherait un confort trompeur. +> +> C'est contre-intuitif au point qu'un installateur **cherchera l'erreur ailleurs**. D'où deux +> interdits pour l'écran : +> +> 1. **ne jamais présenter une marge globale** — elle rassurerait à tort ; +> 2. **ne jamais présenter la meilleure phase** — c'est la pire qui décide. +> +> `bindingPhase` est ce qui rend la phrase juste possible : « bridé sur la phase B », et non +> « bridé » tout court. La phase figure aussi dans le motif `DRAW_CAP`, mais celui-ci n'est émis +> **que si une charge est refusée** : un cycle qui borde sans rien refuser laisserait l'écran +> muet. **Les deux endroits sont nécessaires et ne se recouvrent pas.** ### `progress.purchasedWh` et `progress.measuredSince` diff --git a/debian-qt5/changelog b/debian-qt5/changelog index 171ca6c..210143a 100644 --- a/debian-qt5/changelog +++ b/debian-qt5/changelog @@ -1,3 +1,39 @@ +powersync-energy-plugin-nymea (1.15.2+etm53) trixie; urgency=medium + + * [upstream-fix] rootmeter — LA TENSION EST DÉSORMAIS LOCALE À CHAQUE PHASE. `voltage` était + déclarée une seule fois hors des trois blocs et mutée par l'un d'eux : une phase sans tension + déclarée était calculée avec celle d'une autre. + * ET LE DÉFAUT N'ÉTAIT PAS CELUI QU'ON CROYAIT. L'interface `energymeter` rend `currentPhaseA` + et `voltagePhaseA` OBLIGATOIRES, B et C optionnels : la branche « puissance ÷ tension » de A + est MORTE PAR CONSTRUCTION. La fuite n'allait donc pas de A vers B, comme la note d'origine + l'affirmait, mais de B vers C. Plus étroit, et bien réel. + * DÉMONTRÉ : B à 200 V, C à 4 000 W sans tension déclarée → 5,0 A de marge annoncés au lieu de + 7,6. Deux conditions, dont aucune n'était évidente avant d'écrire le test : le masque doit + couvrir B ET C (interroger C seule n'exécute pas le bloc de B), et C doit être la phase la + plus contrainte, sinon le `qMin` retient B et masque l'écart. + * MOCK — classe `meterPowerOnly` : puissances par phase et tension de B, SANS les courants de + B/C ni la tension de C. Sans elle le défaut restait inatteignable. TROISIÈME fois qu'ouvrir + le mock révèle un défaut plutôt que de seulement débloquer un test, après `dryRelay` et après + la correction du raisonnement sur l'écrêtage de la marge. + * `draw.bindingPhase` — LAQUELLE des phases borne. `perPhaseBound` disait qu'une phase borne, + jamais laquelle : l'écran ne pouvait dire que « bridé » sans dire par quoi, et enverrait + mesurer au hasard sur trois phases dont deux vont bien. Le motif DRAW_CAP la porte aussi, + mais il n'est émis que si une charge est REFUSÉE — un cycle qui borde sans rien refuser + laisserait l'écran muet. Les deux endroits ne se recouvrent pas. + * CONTRAT — une installation qui EXPORTE peut être bridée, et l'écran ne doit jamais présenter + une marge globale ni la meilleure phase : c'est la PIRE qui décide. Contre-intuitif au point + qu'un installateur cherchera l'erreur ailleurs. + * AGENTS.md — une conclusion JUSTE posée sur un raisonnement FAUX survit jusqu'à ce qu'on + bâtisse dessus, parce que la conclusion PROTÈGE le raisonnement de l'examen : un résultat + faux se voit, un résultat juste ferme la question. Deux occurrences de deux côtés de la + frontière. Parade : quand une décision est écrite avec sa raison, la raison mérite son propre + contrôle — un test qui vérifie la conclusion ne vérifie pas le raisonnement, et c'est le + raisonnement que le lot suivant utilisera. + * Suite : simulation 65/65, loadmodel 22/22, charging 48/48 identique à la référence, + spotmarket 32/32, doxygen 0 avertissement. + + -- Patrick Schurig Fri, 04 Sep 2026 11:00:00 +0200 + powersync-energy-plugin-nymea (1.15.2+etm52) trixie; urgency=medium * MOCK — tension et courant PAR PHASE réglables indépendamment des puissances. Le mock posait diff --git a/docs/SAFETY.md b/docs/SAFETY.md index 06a0ee6..1a1264b 100644 --- a/docs/SAFETY.md +++ b/docs/SAFETY.md @@ -214,7 +214,28 @@ elle se pose : **quand un code lit un état d'appareil, demander ce qu'il fait s absent** — et se souvenir que `stateValue()` d'un état absent rend `0`, valeur qui, dans un calcul de marge, se confond avec « rien ne consomme ». -### Défaut amont voisin, NON corrigé — la tension fuit d'une phase à l'autre +### Défaut amont voisin, CORRIGÉ le 2026-09-04 — la tension fuyait d'une phase à l'autre + +`voltage` était déclarée **une seule fois** hors des trois blocs, et mutée par celui d'une phase : +une phase qui ne publie pas sa propre tension était donc calculée avec **celle d'une autre**. + +**Mais le défaut n'était pas celui qu'on croyait, et le mesurer l'a montré.** L'interface +`energymeter` rend `currentPhaseA` et `voltagePhaseA` **obligatoires** ; `currentPhaseB/C` et +`voltagePhaseB/C` sont **optionnels**. La branche « puissance ÷ tension » de la phase A est donc +**morte par construction** — la fuite n'allait pas de A vers B, comme la note d'origine +l'affirmait, mais **de B vers C**. Plus étroit, et bien réel. + +**Démontré** : B à 200 V, C à 4 000 W sans tension déclarée → **5,0 A de marge annoncés au lieu de +7,6**. Le masque doit couvrir B *et* C — interroger C seule n'exécute pas le bloc de B et ne +révèle rien — et C doit être la phase la plus contrainte, sinon le `qMin` retient B et masque +l'écart. **Deux conditions, dont aucune n'était évidente avant d'avoir écrit le test.** + +**Et il n'était pas atteignable au banc** : le mock ne savait produire qu'un compteur publiant +tous les états. Il a fallu une classe `meterPowerOnly` — puissances et tension de B, sans les +courants de B/C ni la tension de C. **Troisième fois** qu'ouvrir le mock révèle un défaut plutôt +que de seulement débloquer un test (`docs/INVENTAIRE_MOCK.md`). + +### Note d'origine, conservée — elle décrivait mal le défaut Dans la même fonction, `voltage` est déclarée une fois **hors** des trois blocs et mutée par celui de la phase A. Un compteur ayant `voltagePhaseA` mais pas `voltagePhaseB` verrait donc la diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index a670ac7..5d506d9 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -1577,6 +1577,11 @@ void EnergyArbitrator::buildTelemetry(const Plan &plan, const Slot &slot, const draw.insert(QStringLiteral("remainingW"), plan.draw.remainingW); draw.insert(QStringLiteral("binding"), plan.draw.bindingCode); draw.insert(QStringLiteral("perPhaseBound"), plan.draw.perPhaseBound); + // LAQUELLE borne — présent seulement s'il y en a une. `perPhaseBound` dit qu'une phase + // borne, jamais laquelle : l'écran ne pourrait dire que « bridé » sans dire par quoi, et + // enverrait mesurer au hasard sur trois phases dont deux vont bien. + if (!plan.draw.bindingPhase.isEmpty()) + draw.insert(QStringLiteral("bindingPhase"), plan.draw.bindingPhase); } snap.insert(QStringLiteral("draw"), draw); diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.cpp b/energyplugin/etm/scheduler/rulebasedscheduler.cpp index 098139a..c347821 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.cpp +++ b/energyplugin/etm/scheduler/rulebasedscheduler.cpp @@ -184,6 +184,8 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx) draw.authorisedW = ctx.drawMargin.watts; draw.bindingCode = drawCapSourceCode(ctx.drawMargin.source); draw.perPhaseBound = ctx.drawMargin.phase >= 0; + if (draw.perPhaseBound && ctx.drawMargin.phase <= 2) + draw.bindingPhase = QString(QChar('A' + ctx.drawMargin.phase)); } compta.surplusW = remainingSurplusW; compta.evReservedW = evReservedW; diff --git a/energyplugin/etm/types/plan.h b/energyplugin/etm/types/plan.h index b878dcb..7f8373c 100644 --- a/energyplugin/etm/types/plan.h +++ b/energyplugin/etm/types/plan.h @@ -127,6 +127,22 @@ struct PlanDraw { //! aucun plafond. Ce qu'on a le droit de faire d'un dépassement en dépend (\rule{LM-1211-c}). QString bindingCode; + + /*! + * \brief LAQUELLE des phases borne — \c A, \c B ou \c C. Vide si le plafond est un total. + * + * \par Pourquoi \c perPhaseBound ne suffisait pas + * Il dit qu'une phase borne, jamais laquelle. Or une installation qui **exporte globalement** + * peut être bridée par une seule phase saturée (\rule{LM-1210-a}) — contre-intuitif au point + * qu'un installateur cherchera l'erreur ailleurs. Sans ce champ, l'écran ne peut dire que + * « bridé » **sans pouvoir dire par quoi**, et il enverrait mesurer au hasard sur trois phases + * dont deux vont bien. + * + * \note La phase figure aussi dans le motif \c DRAW_CAP — mais celui-ci n'est émis que si une + * charge est réellement REFUSÉE. Un cycle où le plafond borde sans rien refuser laisserait + * l'écran muet. Les deux endroits sont nécessaires, et ils ne se recouvrent pas. + */ + QString bindingPhase; //! \brief Le plafond qui borne est-il exprimé PAR PHASE ? Un plafond total n'a pas de phase //! coupable, et le dire évite d'envoyer mesurer au hasard sur une triphasée. bool perPhaseBound = false; diff --git a/energyplugin/rootmeter.cpp b/energyplugin/rootmeter.cpp index 9176aac..634cbf1 100644 --- a/energyplugin/rootmeter.cpp +++ b/energyplugin/rootmeter.cpp @@ -78,16 +78,26 @@ double RootMeter::currentPhaseC() const double RootMeter::calculateAllowanceAmpere(Electricity::Phases phases, uint phasePowerLimit) { double allowance = phasePowerLimit; - double voltage = 230; + + // [upstream-fix] La tension est LOCALE À CHAQUE PHASE. Elle était déclarée une seule fois + // ici et mutée par le bloc de la phase B, si bien que la phase C — quand elle ne publie pas + // sa propre tension — était calculée avec celle de B. Démontré : B à 200 V et C à 4 000 W + // donnaient 5,0 A de marge au lieu de 7,6. + // + // Le défaut n'était pas atteignable depuis la phase A : l'interface `energymeter` rend + // `currentPhaseA` et `voltagePhaseA` OBLIGATOIRES, donc la branche « puissance ÷ tension » + // de A est morte par construction. Seules B et C sont optionnelles, et la fuite allait donc + // de B vers C — plus étroit que « la tension de A sert à calculer B », mais bien réel. + auto tensionDe = [this](const char *nomTension) { + return m_thing->hasState(nomTension) ? m_thing->stateValue(nomTension).toDouble() : 230.0; + }; if (phases.testFlag(Electricity::PhaseA) || phases == Electricity::PhaseNone) { if (m_thing->hasState("currentPhaseA")) { allowance = qMin(allowance, 1.0 * phasePowerLimit - m_thing->stateValue("currentPhaseA").toDouble()); } else if (m_thing->hasState("currentPowerPhaseA")) { - if (m_thing->hasState("voltagePhaseA")) - voltage = m_thing->stateValue("voltagePhaseA").toDouble(); - - allowance = qMin(allowance, 1.0 * phasePowerLimit - (m_thing->stateValue("currentPowerPhaseA").toDouble() / voltage)); + allowance = qMin(allowance, 1.0 * phasePowerLimit + - (m_thing->stateValue("currentPowerPhaseA").toDouble() / tensionDe("voltagePhaseA"))); } else { // No currentPhaseA state... Assume single phase and deduct from currentPower double current = m_thing->stateValue("currentPower").toDouble() / 230; @@ -103,10 +113,8 @@ double RootMeter::calculateAllowanceAmpere(Electricity::Phases phases, uint phas // entière, donc aucun écrêtage. allowance = qMin(allowance, 1.0 * phasePowerLimit - m_thing->stateValue("currentPhaseB").toDouble()); } else if (m_thing->hasState("currentPowerPhaseB")) { - if (m_thing->hasState("voltagePhaseB")) - voltage = m_thing->stateValue("voltagePhaseB").toDouble(); - - allowance = qMin(allowance, 1.0 * phasePowerLimit - (m_thing->stateValue("currentPowerPhaseB").toDouble() / voltage)); + allowance = qMin(allowance, 1.0 * phasePowerLimit + - (m_thing->stateValue("currentPowerPhaseB").toDouble() / tensionDe("voltagePhaseB"))); } else { qCDebug(dcNymeaEnergy()) << "Asked to calculate allowence for Phase B on a single phase root meter."; } @@ -121,10 +129,8 @@ double RootMeter::calculateAllowanceAmpere(Electricity::Phases phases, uint phas // entière, donc aucun écrêtage. allowance = qMin(allowance, 1.0 * phasePowerLimit - m_thing->stateValue("currentPhaseC").toDouble()); } else if (m_thing->hasState("currentPowerPhaseC")) { - if (m_thing->hasState("voltagePhaseC")) - voltage = m_thing->stateValue("voltagePhaseC").toDouble(); - - allowance = qMin(allowance, 1.0 * phasePowerLimit - (m_thing->stateValue("currentPowerPhaseC").toDouble() / voltage)); + allowance = qMin(allowance, 1.0 * phasePowerLimit + - (m_thing->stateValue("currentPowerPhaseC").toDouble() / tensionDe("voltagePhaseC"))); } else { qCDebug(dcNymeaEnergy()) << "Asked to calculate allowence for Phase C on a single phase root meter."; } diff --git a/tests/auto/common/energytestbase.cpp b/tests/auto/common/energytestbase.cpp index c0a25c9..f6e8c07 100644 --- a/tests/auto/common/energytestbase.cpp +++ b/tests/auto/common/energytestbase.cpp @@ -332,6 +332,25 @@ QUuid EnergyTestBase::addMeter(quint16 port) return response.toMap().value("params").toMap().value("thingId").toUuid(); } +QUuid EnergyTestBase::addMeterPowerOnly(quint16 port) +{ + QVariantList thingParams; + QVariantMap portParam; + portParam.insert("paramTypeId", "{c1108d3a-6391-428f-8d75-91e8986657d6}"); + portParam.insert("value", port); + thingParams.append(portParam); + + QVariantMap params; + params.insert("thingClassId", mockMeterPowerOnlyThingClassId.toString()); + params.insert("name", "Meter"); + params.insert("thingParams", thingParams); + + QVariant response = injectAndWait("Integrations.AddThing", params); + verifyThingError(response); + + return response.toMap().value("params").toMap().value("thingId").toUuid(); +} + QUuid EnergyTestBase::addCar(quint16 port) { QVariantList thingParams; diff --git a/tests/auto/common/energytestbase.h b/tests/auto/common/energytestbase.h index a1e110f..2b4fdf3 100644 --- a/tests/auto/common/energytestbase.h +++ b/tests/auto/common/energytestbase.h @@ -43,6 +43,11 @@ #include static QUuid mockMeterThingClassId = QUuid("2721a051-6e12-471a-baba-21d87c4cebc9"); +//! \brief Compteur publiant les PUISSANCES par phase mais pas tous les COURANTS ni toutes les +//! TENSIONS. `energymeter` exige `currentPhaseA` et `voltagePhaseA` ; B et C sont OPTIONNELS — +//! c'est cette classe qui rend atteignable la branche « puissance ÷ tension » de +//! `calculateAllowanceAmpere()`, et donc la fuite de tension d'une phase vers la suivante. +static QUuid mockMeterPowerOnlyThingClassId = QUuid("3ac91e57-08b4-4f26-9d31-7e5a2c60b4f8"); static QUuid mockChargerThingClassId = QUuid("5a3ae99f-c7da-46df-9104-f477be4606b7"); static QUuid mockChargerWithPhaseSwitchingThingClassId = QUuid("9208d9f0-280c-469d-a145-106f3277470c"); static QUuid mockSimpleChargerThingClassId = QUuid("29bcf255-b654-4764-be92-399bc26fe7c3"); @@ -93,6 +98,10 @@ public: QNetworkReply *clearActionHistroy(quint16 port); QUuid addMeter(quint16 port = 26655); + + //! \brief Ajoute un compteur SANS `currentPhaseB/C` ni `voltagePhaseC`. + //! \param port Port du contrôleur mock. \return L'identifiant du Thing créé. + QUuid addMeterPowerOnly(quint16 port = 26690); QUuid addCar(quint16 port = 26656); QUuid addCharger(const QString &phases = "All", double maxChargingCurrentUpperLimit = 32, quint16 port = 26657); QUuid addChargerWithPhaseCountSwitching(const QString &phases = "All", double maxChargingCurrentUpperLimit = 32, quint16 port = 26658); diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index 8f9a385..e98a7bd 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -7690,6 +7690,19 @@ void Simulation::testWorstPhaseBindsWhenPhasesAreUnbalanced() QCOMPARE(qRound(marge.watts), -1150); QCOMPARE(marge.phase, 1); // phase B (0 = A, 1 = B, 2 = C) + // 1-bis. ET L'ÉCRAN DOIT POUVOIR NOMMER CETTE PHASE. `perPhaseBound` dit qu'une phase borne, + // jamais laquelle : l'écran ne pourrait dire que « bridé » sans dire par quoi, et + // enverrait mesurer au hasard sur trois phases dont deux vont bien. Le motif `DRAW_CAP` + // la porte aussi, mais il n'est émis que si une charge est REFUSÉE — un cycle qui borde + // sans rien refuser laisserait l'écran muet. Les deux endroits ne se recouvrent pas. + SurplusContext ctxPhase; + ctxPhase.timestamp = utcDateTime(QDate(2026, 9, 4), QTime(13, 0, 0)); + ctxPhase.drawMargin = marge; + RuleBasedScheduler schedPhase(arb); + const Plan planPhase = schedPhase.getPlan(ctxPhase); + QCOMPARE(planPhase.draw.perPhaseBound, true); + QCOMPARE(planPhase.draw.bindingPhase, QString("B")); + // 2. ET LA MARGE EST NÉGATIVE alors que le compteur global est en EXPORT. C'est tout l'objet // du choix : sans la lecture par phase, l'arbitre croirait disposer de 2 300 W à dépenser // pendant qu'une phase est déjà au-delà de son disjoncteur. @@ -7714,6 +7727,70 @@ void Simulation::testWorstPhaseBindsWhenPhasesAreUnbalanced() #endif } +/*! + * \brief [upstream-fix] La tension d'une phase ne doit pas servir à calculer une AUTRE phase. + * + * \par Le défaut + * `calculateAllowanceAmpere()` déclare `voltage` **une seule fois, hors des trois blocs**, et le + * bloc de la phase A la mute. Un compteur qui publie `voltagePhaseA` mais pas `voltagePhaseB` + * verrait donc la phase B calculée **avec la tension de A**. + * + * \par Pourquoi ce test n'existait pas + * Le mock posait `currentPhaseX = currentPowerPhaseX / 230` : tension implicite, identique + * partout, et le défaut invisible. C'est la **troisième fois** que l'ouverture du mock révèle un + * défaut plutôt que de seulement débloquer un test — après `dryRelay` et après la correction du + * raisonnement sur l'écrêtage de la marge. + */ +void Simulation::testPhaseVoltageDoesNotLeakBetweenPhases() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + cleanupTestCase(); + m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; + initTestCase(); + + ThingManager *tm = NymeaCore::instance()->thingManager(); + QUuid meterId = addMeterPowerOnly(26691); + Thing *meter = tm->findConfiguredThing(meterId); + QVERIFY(meter); + meter->setStateValue("connected", true); + QVERIFY2(!meter->thingClass().hasStateType("currentPhaseC"), + "ce compteur ne publie PAS les courants de B et C — c'est ce qui rend la branche " + "« puissance ÷ tension » atteignable"); + QVERIFY2(meter->thingClass().hasStateType("voltagePhaseB"), + "il publie la tension de B…"); + QVERIFY2(!meter->thingClass().hasStateType("voltagePhaseC"), + "…mais PAS celle de C : c'est exactement le cas où la tension de B fuirait vers C"); + + RootMeter *rm = new RootMeter(meter); + + // LE MASQUE DOIT COUVRIR B **ET** C — c'est la condition de la fuite : le bloc de B écrit + // `voltage`, celui de C le relit. Interroger C SEULE ne révèle rien, puisque le bloc de B ne + // s'exécute pas. (Première version de ce test, qui passait pour cette raison.) + // + // Et C doit être la phase la plus CONTRAINTE, sinon le `qMin` de la fonction retiendrait B + // et masquerait l'écart. B : 1000 W sous 200 V = 5 A, marge 20 A. C : 4000 W sous une + // tension INCONNUE — donc 230 V par défaut, 17,4 A, marge 7,6 A. Avec la fuite, C serait + // calculée sous les 200 V de B : 20 A tirés, marge 5 A. + meter->setStateValue("voltagePhaseB", 200); + meter->setStateValue("currentPowerPhaseB", 1000); + meter->setStateValue("currentPowerPhaseC", 4000); + + const uint plafond = 25; + const double marge = rm->calculateAllowanceAmpere( + Electricity::PhaseB | Electricity::PhaseC, plafond); + + // Sans fuite : min(20 ; 25 − 4000/230) = 7,6 A. Avec la fuite : min(20 ; 25 − 4000/200) = 5. + QCOMPARE(qRound(marge * 10), qRound((25.0 - 4000.0 / 230.0) * 10)); + + // Et l'ORDRE ne doit rien changer : le résultat de C ne doit pas dépendre de ce qu'on a + // demandé avant. C'est la propriété que la variable partagée détruisait. + QCOMPARE(qRound(rm->calculateAllowanceAmpere(Electricity::PhaseC, plafond) * 10), + qRound((25.0 - 4000.0 / 230.0) * 10)); +#endif +} + void Simulation::testCountsSumToTarget() { #ifndef ETM_ARBITRATOR diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index 44d59b6..5756f18 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -202,6 +202,7 @@ private slots: void testHealthyButUnmeasuredDiffersFromFaulty(); void testLifetimeCounterDropNeverRebasesTheProgress(); void testWorstPhaseBindsWhenPhasesAreUnbalanced(); + void testPhaseVoltageDoesNotLeakBetweenPhases(); void testRecreditIsALoanAgainstObedience(); void testARefusedStateIsDistinguishableFromAnApplied(); void testL4ShedsAPacAndSaysItForcedTheLock(); diff --git a/tests/mocks/plugins/energymocks/integrationpluginenergymocks.cpp b/tests/mocks/plugins/energymocks/integrationpluginenergymocks.cpp index c79f611..2f0a135 100644 --- a/tests/mocks/plugins/energymocks/integrationpluginenergymocks.cpp +++ b/tests/mocks/plugins/energymocks/integrationpluginenergymocks.cpp @@ -193,7 +193,11 @@ void IntegrationPluginEnergyMocks::setupThing(ThingSetupInfo *info) info->finish(Thing::ThingErrorNoError); return; - } else if (thing->thingClassId() == meterThingClassId) { + } else if (thing->thingClassId() == meterThingClassId + || thing->thingClassId() == meterPowerOnlyThingClassId) { + // `meterPowerOnly` partage tout le comportement du compteur : seule sa ThingClass diffère + // (pas de `currentPhaseB/C`, pas de `voltagePhaseC`). C'est l'ABSENCE d'états qui est + // l'objet de la classe, pas un comportement particulier. EnergyMockController *controller = new EnergyMockController(thing, this); ParamType paramType = thing->thingClass().paramTypes().findByName("port"); @@ -250,17 +254,22 @@ void IntegrationPluginEnergyMocks::setupThing(ThingSetupInfo *info) const double uA = tension("voltagePhaseA"); const double uB = tension("voltagePhaseB"); const double uC = tension("voltagePhaseC"); - thing->setStateValue("voltagePhaseA", uA); - thing->setStateValue("voltagePhaseB", uB); - thing->setStateValue("voltagePhaseC", uC); + // N'écrire que ce que la ThingClass PORTE : `meterPowerOnly` n'a ni + // `voltagePhaseC` ni les courants de B et C, et c'est tout son objet. + auto poser = [&](const char *n, double v) { + if (thing->thingClass().hasStateType(n)) thing->setStateValue(n, v); + }; + poser("voltagePhaseA", uA); + poser("voltagePhaseB", uB); + poser("voltagePhaseC", uC); // Le COURANT peut être imposé directement — un compteur réel le mesure, il ne le // dérive pas de la puissance. - thing->setStateValue("currentPhaseA", query.hasQueryItem("currentPhaseA") + poser("currentPhaseA", query.hasQueryItem("currentPhaseA") ? query.queryItemValue("currentPhaseA").toDouble() : currentPowerPhaseA / uA); - thing->setStateValue("currentPhaseB", query.hasQueryItem("currentPhaseB") + poser("currentPhaseB", query.hasQueryItem("currentPhaseB") ? query.queryItemValue("currentPhaseB").toDouble() : currentPowerPhaseB / uB); - thing->setStateValue("currentPhaseC", query.hasQueryItem("currentPhaseC") + poser("currentPhaseC", query.hasQueryItem("currentPhaseC") ? query.queryItemValue("currentPhaseC").toDouble() : currentPowerPhaseC / uC); thing->setStateValue("currentPowerPhaseA", currentPowerPhaseA); diff --git a/tests/mocks/plugins/energymocks/integrationpluginenergymocks.json b/tests/mocks/plugins/energymocks/integrationpluginenergymocks.json index 80cb120..ff71356 100644 --- a/tests/mocks/plugins/energymocks/integrationpluginenergymocks.json +++ b/tests/mocks/plugins/energymocks/integrationpluginenergymocks.json @@ -145,6 +145,119 @@ } ] }, + { + "name": "meterPowerOnly", + "displayName": "Mocked Meter (puissances par phase SANS courants)", + "id": "3ac91e57-08b4-4f26-9d31-7e5a2c60b4f8", + "createMethods": [ + "user" + ], + "interfaces": [ + "energymeter", + "connectable" + ], + "paramTypes": [ + { + "id": "c1108d3a-6391-428f-8d75-91e8986657d6", + "name": "port", + "displayName": "Port", + "type": "uint", + "defaultValue": 6655 + } + ], + "stateTypes": [ + { + "id": "08aef3a2-311e-4371-85bc-0e8258d4b351", + "name": "connected", + "displayName": "Connected", + "type": "bool", + "cached": false, + "defaultValue": false + }, + { + "id": "f4461fff-2582-47c0-8950-e16cd11a6cf3", + "name": "voltagePhaseA", + "displayName": "Voltage phase A", + "type": "double", + "unit": "Volt", + "defaultValue": 230 + }, + { + "id": "cfc8a027-b926-4ffc-8204-c7e34c5af689", + "name": "voltagePhaseB", + "displayName": "Voltage phase B", + "type": "double", + "unit": "Volt", + "defaultValue": 230 + }, + { + "id": "415358dc-d6e5-40a6-8be1-cd9a42bffad0", + "name": "currentPower", + "displayName": "Current power", + "type": "double", + "unit": "Watt", + "defaultValue": 0 + }, + { + "id": "7f50b83a-8537-4b6c-8353-9d4f93fc7c29", + "name": "originalPower", + "displayName": "Original current power (simulation)", + "displayNameAction": "Set original current power (simulation)", + "type": "double", + "unit": "Watt", + "writable": "true", + "defaultValue": 0 + }, + { + "id": "d32eaccd-f7c4-4c6a-86c8-52f114f3b077", + "name": "currentPowerPhaseA", + "displayName": "Current power phase A", + "type": "double", + "unit": "Watt", + "defaultValue": 0 + }, + { + "id": "d29283a9-c7ee-4cee-8789-c5ed6d8564ca", + "name": "currentPowerPhaseB", + "displayName": "Current power phase B", + "type": "double", + "unit": "Watt", + "defaultValue": 0 + }, + { + "id": "d1c26b51-3938-47c6-8d4a-ead0ca20a5a2", + "name": "currentPowerPhaseC", + "displayName": "Current power phase C", + "type": "double", + "unit": "Watt", + "defaultValue": 0 + }, + { + "id": "89a95691-961a-42b8-83b6-81574239251e", + "name": "totalEnergyConsumed", + "displayName": "Total energy consumed", + "type": "double", + "unit": "KiloWattHour", + "defaultValue": 0.0 + }, + { + "id": "5a8f841d-60d2-443f-81bf-61b678268307", + "name": "totalEnergyProduced", + "displayName": "Total energy produced", + "type": "double", + "unit": "KiloWattHour", + "defaultValue": 0.0 + }, + { + "id": "c5b2b28b-11b4-4274-84ea-17786281201a", + "name": "currentPhaseA", + "displayName": "Current phase A", + "type": "double", + "unit": "Ampere", + "defaultValue": 0 + } + ] + }, { "name": "charger", "displayName": "Mocked charger",