diff --git a/docs/INVENTAIRE_MOCK.md b/docs/INVENTAIRE_MOCK.md index bbc1dfe..5328ddc 100644 --- a/docs/INVENTAIRE_MOCK.md +++ b/docs/INVENTAIRE_MOCK.md @@ -25,12 +25,18 @@ rien sur les cas qu'il ne sait pas produire. | `charger` | ✅ | ✅ | ❌ | | `chargerPhaseSwitching` | ✅ | ✅ | ✅ *(ajouté le 2026-08-31)* | | `simpleCharger` | ❌ | ❌ | ❌ | +| `chargerUnmetered` *(lot délestage V2C, 2026-10-27)* | ✅ | ✅ | ❌ | | `powerSwitch` | ✅ | ❌ | — | | `etmVariableLoad` | ✅ *(`currentPowerW`)* | ❌ | — | | `energyStorage` | ✅ | ❌ | — | | `controllableStorage` *(lot e1, 2026-10-24)* | ✅ | ❌ | — | | `car`, `notification` | ❌ | ❌ | — | +`chargerUnmetered` mesure sa puissance mais publie ses courants sous `currentL1..3`, comme le +plugin V2C Trydan, et jamais sous `currentPhaseX` : `EvCharger::meteredPhases()` y est vide. Les +autres bornes du mock publiaient toutes leurs phases, si bien qu'une borne **non mesurée par +phase** — le cas d'un V2C Trydan — était introuvable par la suite. + **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. diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index 2b96929..38c39de 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -17,6 +17,7 @@ #include "scheduler/optimizerstatus.h" #include "scheduler/publishedplan.h" #include "scheduler/optimizertrace.h" +#include "scheduler/optimizerplan.h" #include "types/decisionreason.h" #include "types/surpluscontext.h" #include "types/plan.h" @@ -532,6 +533,12 @@ void EnergyArbitrator::update(const QDateTime ¤tDateTime) // adjustEvChargers() juste après, et c'est ce qui empêche le proxy de re-commander. m_waterfallEvIds = plan.waterfallEvIds; + // Lot délestage triphasé — la marge de soutirage s'oppose aussi aux consignes d'un OPTIMISEUR, + // avant qu'elles partent : ce que la protection a retiré ne revient pas tant que le compteur + // ne lui en laisse pas la place. Le rule-based, lui, est déjà borné dans sa cascade. + if (plan.strategy == QLatin1String(OptimizerPlan::StrategieOptimiseur)) + bornerOptimiseurAuSoutirage(slot, ctx); + // 7 : dispatch matériel (même position que l'amont — m_chargingActions rempli par getPlan()) applyActionsToAdapters(slot, currentDateTime); // charges arbitrées (Setpoint W, State) ET bornes du waterfall adjustEvChargers(currentDateTime); // bornes RESTÉES au proxy : échéance, spot market @@ -979,6 +986,82 @@ void EnergyArbitrator::shedWaterfallForOverload(const QDateTime &now) "ce qui pouvait l'être. La cause est hors du waterfall."; } +void EnergyArbitrator::bornerOptimiseurAuSoutirage(Slot &slot, const SurplusContext &ctx) +{ + const DrawMargin marge = ctx.drawMargin; + if (!marge.borne) + return; // aucun plafond en vigueur : rien à opposer + + QHash charges; + for (const LoadContext &lc : ctx.loads) + charges.insert(lc.id, &lc); + + double libreParPhaseW = qMax(0.0, marge.watts); + for (LoadAction &a : slot.actions) { + if (!OptimizerPlan::estActionOptimiseur(a) || m_shedActions.contains(a.loadId)) + continue; + const LoadContext *lc = charges.value(a.loadId, nullptr); + if (!lc || !lc->telemetry.available) + continue; + const bool borne = lc->adapter == QLatin1String("evcharger"); + const int phases = qMax(1, lc->declared.phases); + + // Ce que la charge TIENT déjà, et qu'on ne lui retire pas ici. + double engageW = qMax(0.0, lc->telemetry.currentPowerW); + if (borne) + if (const std::optional commandee = puissanceCommandee(a.loadId)) + engageW = qMax(0.0, *commandee); + + // Ce que l'action poserait, écrêtée au matériel comme l'adaptateur le fera. Pour une borne, + // le matériel est son courant maximal sur ses phases : `declared.maxPowerW` est l'enveloppe + // de la cascade, que l'EvAdapter ne réapplique pas. + const double materielW = borne ? lc->declared.maxA * 230.0 * phases : lc->declared.maxPowerW; + double demandeW = 0; + if (a.kind == LoadAction::Setpoint) + demandeW = materielW > 0 ? qMin(a.powerW, materielW) : a.powerW; + else if (a.kind == LoadAction::State) + demandeW = lc->declared.estimatedPowerW.value(a.state, 0.0); + else + continue; + + const double plafondW = engageW + libreParPhaseW * phases; + if (demandeW <= plafondW + 0.5) { + libreParPhaseW = qMax(0.0, libreParPhaseW - qMax(0.0, demandeW - engageW) / phases); + continue; + } + + double accordeW = plafondW; + if (a.kind == LoadAction::Setpoint) { + if (borne) // l'adaptateur arrondit au plus proche : on lui donne un ampère entier + accordeW = std::floor(plafondW / (230.0 * phases) + 1e-6) * 230.0 * phases; + a.powerW = accordeW; + a.estimatedPowerW = accordeW; + } else { + // L'état le plus haut qui tient sous le plafond. Les états 1 et 2 valent 0 W : c'est 2 + // qui est retenu, jamais 1 (ECS-413). + int etat = 2; + for (int e = 3; e <= 4; ++e) + if (lc->declared.estimatedPowerW.value(e, 0.0) <= plafondW + 0.5) + etat = e; + a.state = etat; + accordeW = lc->declared.estimatedPowerW.value(etat, 0.0); + } + libreParPhaseW = qMax(0.0, libreParPhaseW - qMax(0.0, accordeW - engageW) / phases); + + // DÉFAUT 5 — le motif est celui du mécanisme qui a produit l'issue, pas celui que + // l'optimiseur avait donné à sa demande. Le niveau unique `optimizer` reste : c'est + // l'optimiseur qui a décidé, la sécurité qui a borné. + a.reason = { DecisionCode::DrawCap, drawCapReasonParams(marge, false, 0, false) }; + a.clamped = true; + if (!a.levels.isEmpty()) + a.levels.first().reason = a.reason; + qCInfo(dcNymeaEnergy()).noquote() + << "[Arbitre] Garde de soutirage" << a.loadId << "— l'optimiseur demandait" + << qRound(demandeW) << "W, la charge tient" << qRound(engageW) << "W, accordé" + << qRound(accordeW) << "W |" << renderFr(a.reason, lc->label); + } +} + void EnergyArbitrator::fusionnerDelestageDansSlot(Slot &slot) const { for (auto it = m_shedActions.constBegin(); it != m_shedActions.constEnd(); ++it) { @@ -1068,6 +1151,11 @@ DecisionReason EnergyArbitrator::motifTenue(const BorneTenue &t) const {QStringLiteral("cut"), t.cut}, {QStringLiteral("overshootW"), qRound(t.overshootW)}, {QStringLiteral("limitA"), phasePowerLimit()}}; + // Lot délestage triphasé — le SEUIL où la protection agit, sous la limite de la marge de + // sécurité. Absent quand la marge est nulle : seuil et limite se confondent alors, et la + // charge utile reste celle d'avant ce lot. + if (phaseSafetyMarginPercent() > 0) + params.insert(QStringLiteral("thresholdA"), qRound(effectivePhaseLimit() * 10) / 10.0); // Absente pour une borne triphasée : la protection la juge sur la phase la plus chargée, // aucune phase n'est à elle seule coupable. Même règle que DRAW_CAP. if (!t.phase.isEmpty()) @@ -1174,11 +1262,11 @@ bool EnergyArbitrator::noterGesteBorne(EvCharger *evCharger, ChargingAction::Cha // jugé comme elle : sur la phase de la borne si elle est monophasée, sur la plus chargée sinon. QString phase; double depassementW = 0; - if (RootMeter *compteur = internalRootMeter()) { - const double limiteW = 230.0 * phasePowerLimit(); - const QHash parPhase{{QStringLiteral("A"), compteur->currentPowerPhaseA()}, - {QStringLiteral("B"), compteur->currentPowerPhaseB()}, - {QStringLiteral("C"), compteur->currentPowerPhaseC()}}; + if (internalRootMeter()) { + // Les mêmes lectures et le même seuil que la protection : phases cohérentes avec le total, + // limite moins la marge de sécurité. Le dépassement publié est celui qui a décidé. + const double limiteW = 230.0 * effectivePhaseLimit(); + const QHash parPhase = phaseConsumptionW(); if (evCharger->phaseCount() == 1) { phase = chargerPhaseFor(evCharger); depassementW = qMax(0.0, parPhase.value(phase) - limiteW); @@ -1378,7 +1466,10 @@ QList EnergyArbitrator::drawCaps() const if (limiteA > 0) { DrawCap disjoncteur; disjoncteur.source = DrawCapSource::Breaker; - disjoncteur.perPhaseW = limiteA * 230.0; + // Lot délestage triphasé (décision du 2026-10-27) — le plafond est le SEUIL, la limite + // moins la marge de sécurité : 27 A pour 30 A à 10 %. La cascade alloue sous lui, et la + // protection agit au-dessus (SmartChargingManager::verifyOverloadProtection()). + disjoncteur.perPhaseW = effectivePhaseLimit() * 230.0; plafonds.append(disjoncteur); } @@ -1401,17 +1492,22 @@ QList EnergyArbitrator::phaseDrawW(Electricity::Phases masque, uint limi const QList toutes = { Electricity::PhaseA, Electricity::PhaseB, Electricity::PhaseC }; - for (Electricity::Phase p : toutes) { - if (masque != Electricity::PhaseNone && !masque.testFlag(p)) + // Tirage de chaque phase, en watts, obtenu en INVERSANT calculateAllowanceAmpere plutôt + // qu'en relisant les états du compteur. Relire serait dupliquer des lectures qui + // diffèrent d'une phase à l'autre côté amont (rootmeter.cpp : la phase A se lit en + // ampères, B et C en watts/230), et laisser les deux copies dériver. L'inversion rend + // l'égalité au watt VRAIE PAR CONSTRUCTION, quelles que soient ces lectures. + QList lus; + for (Electricity::Phase p : toutes) + lus.append((limiteA - meter->calculateAllowanceAmpere(p, limiteA)) * 230.0); + // Lot délestage triphasé — les TROIS phases d'abord, rendues cohérentes avec le total, et le + // masque ensuite : des phases en retard sur le total ont retardé la protection de 1 min 45 + // (relevé du 2026-10-27). Voir tiragesCoherentsAvecLeTotal(), qui ne fait que relever. + lus = tiragesCoherentsAvecLeTotal(lus, meter->currentPower()); + for (int i = 0; i < toutes.size(); ++i) { + if (masque != Electricity::PhaseNone && !masque.testFlag(toutes.at(i))) continue; - - // Tirage de la phase, en watts, obtenu en INVERSANT calculateAllowanceAmpere plutôt - // qu'en relisant les états du compteur. Relire serait dupliquer des lectures qui - // diffèrent d'une phase à l'autre côté amont (rootmeter.cpp : la phase A se lit en - // ampères, B et C en watts/230), et laisser les deux copies dériver. L'inversion rend - // l'égalité au watt VRAIE PAR CONSTRUCTION, quelles que soient ces lectures. - const double margeA = meter->calculateAllowanceAmpere(p, limiteA); - tirages.append((limiteA - margeA) * 230.0); + tirages.append(lus.at(i)); } return tirages; } @@ -1485,7 +1581,20 @@ double EnergyArbitrator::evPhaseAllowanceW(EvCharger *ev, uint phaseCount) const // marge PORTE le dépassement (elle vaut -1200 quand on dépasse de 1200 W) parce que // l'étape 4 en a besoin pour dire de combien délester. Ce qu'une borne peut prendre // n'est jamais négatif ; ce qu'il faut retirer à la maison, si. - return qMax(0.0, marge.watts) * phaseCount; + const double libreW = qMax(0.0, marge.watts) * phaseCount; + + // Lot délestage V2C (question 5 du lot délestage triphasé) — HORS DÉPASSEMENT, la place libre + // seule fait alterner une borne qui tient une bonne part de sa phase : pleine, la place tombe ; + // au plancher, elle remonte (16, 9, 16, 9 A sous 25 A et 10 % de marge, maison à 4 A). Le + // plafond qui lui rend son tirage, moins 1 A, se pose en un cycle — c'est la règle des heures + // creuses (lot e2, D8). EN DÉPASSEMENT, la place libre reste la règle, et la raison de sécurité + // ci-dessus avec elle : on ne rend rien à une borne sur une phase au-dessus du seuil. + // Le MAXIMUM des deux garde la règle d'avant quand le tirage n'est pas attribuable (le plafond + // rendu vaut alors la place libre moins 1 A) ou quand la borne tire moins d'un ampère. + const PlafondRendu rendu = evCeilingGivingBackDraw(ev, phaseCount); + if (!rendu.attribuable || rendu.depasse || rendu.watts < 0) + return libreW; + return qMax(libreW, rendu.watts); } namespace { @@ -1497,38 +1606,90 @@ constexpr double MargeHeuresCreusesA = 1.0; double EnergyArbitrator::evOffPeakAllowanceW(EvCharger *ev, uint phaseCount) const { + return evCeilingGivingBackDraw(ev, phaseCount).watts; +} + +EnergyArbitrator::TirageBorne EnergyArbitrator::evAttributableDraw(EvCharger *ev, uint phaseCount) const +{ + TirageBorne tirage; + if (!ev || phaseCount == 0) + return tirage; + + // Ce que la borne tire, MESURÉ. Pas la consigne : une voiture qui prend 8 A d'une borne + // commandée à 16 n'occupe que 8 A de la phase, et lui rendre 16 ouvrirait 8 A qu'elle peut + // prendre d'un coup. Une borne sans mesure ne rend rien. + if (ev->thing()->thingClass().stateTypes().findByName("currentPower").id().isNull()) + return tirage; + + const Electricity::Phases mesurees = ev->meteredPhases(); + if (mesurees != Electricity::PhaseNone) { + // Rendre sur une phase que la borne n'occupe pas créerait de la place qui n'existe pas — + // et la borne, en la prenant ailleurs, ne la refermerait jamais. Pendant une bascule + // (phases mesurées ≠ phases visées), on ne rend donc rien. + if (Electricity::getPhaseCount(mesurees) != phaseCount) + return tirage; + tirage.phases = mesurees; + } else { + // Lot délestage V2C — une borne qui ne publie PAS ses courants par phase (le V2C Trydan + // publie `currentL1..3`, que l'interface ne connaît pas). meteredPhases() est vide, et + // jusqu'ici rien n'était rendu : le plafond lisait la borne comme une charge de la maison + // sur sa phase, et la consigne alternait chaque nuit (16, 6, 13, 6 A sous 25 A). + // + // Ses phases sont celles que le moteur lui suppose DÉJÀ — phasesForCount(), la phase A en + // monophasé : c'est le masque de ce plafond, celui de L4 (chargerPhaseKey()) et celui de + // l'amont (prepareInformation()). Attribuer son tirage ailleurs ferait deux hypothèses. + // + // Seulement quand c'est sûr : + // - une borne qui publie des états par phase, tous nuls alors qu'elle tire, a une mesure + // qui se contredit — elle n'est pas « non mesurée » ; + // - elle doit annoncer autant de phases que le cycle en vise. Une triphasée qui charge + // une voiture monophasée (le cycle vise 1, elle annonce 3), ou une borne en cours de + // bascule, tire sur des phases que rien ne dit ; + // - une ou trois phases : la monophasée sur sa phase, la triphasée un tiers par phase. + // En biphasé, le masque A+B est une convention de l'amont, pas une observation : rien + // n'est rendu (décision du 2026-10-27 : mono et tri seulement). + for (const char *etat : {"currentPhaseA", "currentPhaseB", "currentPhaseC", + "currentPowerPhaseA", "currentPowerPhaseB", "currentPowerPhaseC"}) { + if (ev->thing()->hasState(etat)) + return tirage; + } + if ((phaseCount != 1 && phaseCount != 3) || ev->phaseCount() != phaseCount) + return tirage; + tirage.phases = const_cast(this)->phasesForCount(phaseCount); + } + tirage.watts = qMax(0.0, ev->currentPower()); + return tirage; +} + +EnergyArbitrator::PlafondRendu EnergyArbitrator::evCeilingGivingBackDraw(EvCharger *ev, uint phaseCount) const +{ + PlafondRendu plafond; RootMeter *meter = internalRootMeter(); if (!ev || phaseCount == 0 || !meter) - return -1; + return plafond; const QList plafonds = drawCaps(); if (plafonds.isEmpty()) - return -1; + return plafond; - // Ce que la borne tire, MESURÉ, et sur quelles phases. Pas la consigne : une voiture qui - // prend 8 A d'une borne commandée à 16 n'occupe que 8 A de la phase, et lui rendre 16 - // ouvrirait 8 A qu'elle peut prendre d'un coup. Une borne sans mesure ne rend rien. - const bool mesuree = !ev->thing()->thingClass().stateTypes().findByName("currentPower").id().isNull(); - const Electricity::Phases tirees = mesuree ? ev->meteredPhases() : Electricity::PhaseNone; - // Rendre sur une phase que la borne n'occupe pas créerait de la place qui n'existe pas — et - // la borne, en la prenant ailleurs, ne la refermerait jamais. Pendant une bascule (phases - // mesurées ≠ phases visées), on ne rend donc rien. - const bool attribuable = tirees != Electricity::PhaseNone - && Electricity::getPhaseCount(tirees) == phaseCount; - const Electricity::Phases masque = attribuable - ? tirees : const_cast(this)->phasesForCount(phaseCount); - const double tireW = attribuable ? qMax(0.0, ev->currentPower()) : 0.0; + const TirageBorne tirage = evAttributableDraw(ev, phaseCount); + plafond.attribuable = tirage.phases != Electricity::PhaseNone; + const Electricity::Phases masque = plafond.attribuable + ? tirage.phases : const_cast(this)->phasesForCount(phaseCount); + const double tireW = plafond.attribuable ? tirage.watts : 0.0; // Le tirage de la maison SANS la borne, phase par phase, écrêté à zéro : une borne qui // annoncerait plus que ce que le compteur voit ne se fabrique pas de place au-delà de la // limite. Au mieux, une phase vaut sa limite. - QList sansLaBorne = phaseDrawW(masque, phasePowerLimit()); + const QList avecLaBorne = phaseDrawW(masque, phasePowerLimit()); + plafond.depasse = resolveDrawMargin(plafonds, avecLaBorne, meter->currentPower()).watts < 0; + QList sansLaBorne = avecLaBorne; for (double &w : sansLaBorne) w = qMax(0.0, w - tireW / phaseCount); const DrawMargin marge = resolveDrawMargin(plafonds, sansLaBorne, qMax(0.0, meter->currentPower() - tireW)); if (!marge.borne) - return -1; + return plafond; // Moins la marge, à l'ampère INFÉRIEUR : l'adaptateur arrondit la consigne au plus proche, // et un plafond de 13,6 A deviendrait 14 A — la marge serait mangée de moitié. @@ -1538,7 +1699,8 @@ double EnergyArbitrator::evOffPeakAllowanceW(EvCharger *ev, uint phaseCount) con // vaut limite − (I − E) − 1 < E − 1. La consigne descend d'au moins 1 A par cycle, jusqu'à // sortir du dépassement ou jusqu'au plancher, où L4 décide. const double parPhaseA = std::floor(marge.watts / 230.0 - MargeHeuresCreusesA); - return qMax(0.0, parPhaseA) * 230.0 * phaseCount; + plafond.watts = qMax(0.0, parPhaseA) * 230.0 * phaseCount; + return plafond; } bool EnergyArbitrator::evCommandedByWaterfall(EvCharger *evCharger) const @@ -3647,8 +3809,11 @@ OptimizerProtocol::ContexteFil EnergyArbitrator::contexteFil(const SurplusContex // producteur ? » de l'apprentissage (observerPourApprentissage()). if (m_em && m_tm && !m_tm->configuredThings().filterByInterface(QStringLiteral("smartmeterproducer")).isEmpty()) fil.pvCurrentW = qAbs(m_em->currentPowerProduction()); + // Lot délestage triphasé — l'optimiseur reçoit le SEUIL que le moteur fera respecter (limite + // moins la marge de sécurité), pas la limite : un plan bâti sur la limite serait écrêté à + // chaque cycle par la garde de soutirage. if (phasePowerLimit() > 0) - fil.phaseLimitA = {double(phasePowerLimit()), double(phasePowerLimit()), double(phasePowerLimit())}; + fil.phaseLimitA = {effectivePhaseLimit(), effectivePhaseLimit(), effectivePhaseLimit()}; if (ctx.battery.present) fil.reserveSocPercent = qRound(batteryLevelConsideration() * 100.0); if (m_loadConfigStore) diff --git a/energyplugin/etm/energyarbitrator.h b/energyplugin/etm/energyarbitrator.h index ce3a69e..564c625 100644 --- a/energyplugin/etm/energyarbitrator.h +++ b/energyplugin/etm/energyarbitrator.h @@ -803,6 +803,39 @@ public: */ void shedWaterfallForOverload(const QDateTime &now); + /*! + * \brief Garde de soutirage : une consigne d'OPTIMISEUR ne fait monter une charge que de la + * marge que le compteur laisse sous le seuil (lot délestage triphasé, défauts 4 et 5). + * + * \param slot Créneau du cycle, modifié en place AVANT le dispatch. Seules les actions + * d'optimiseur (niveau unique \c optimizer) sont lues ; celles que L4 revendique ce cycle + * (\c m_shedActions) sont laissées au délestage. + * \param ctx Contexte du cycle : \c drawMargin (seuil, phase la plus chargée, signée) et + * la télémétrie des charges. + * + * \par Pourquoi + * La marge de soutirage bornait la cascade du rule-based, et RIEN d'autre : un optimiseur qui + * demande le maximum voyait ses consignes écrêtées au matériel seulement. Relevé le 2026-10-27 : + * la protection déleste, le compteur repasse juste sous la limite, le délestage se relâche, + * l'optimiseur redemande 3 kW, et la surcharge reprend une minute. La règle de sécurité était + * celle du disjoncteur ; elle devient celle du moteur, quel que soit le décideur. + * + * \par Contrat + * - Seule une HAUSSE est bornée, jamais une baisse : réduire est le geste de la protection, et + * la garde ne réduit rien sous ce que la charge tient déjà. Sous un compteur en dépassement + * (marge négative), la charge garde ce qu'elle tient. + * - Plafond = engagé + marge libre × phases de la charge. L'engagé d'une borne est sa dernière + * commande TENUE (la protection l'a peut-être rabaissée dans ce même cycle) ; sinon sa mesure. + * Celui des autres charges est leur télémétrie. + * - La marge est la pire phase, et chaque hausse servie la consomme pour les suivantes, dans + * l'ordre du créneau. + * - Une consigne écrêtée porte \c DRAW_CAP (le mécanisme qui a produit l'issue, règle 7-b) et + * \c clamped ; une borne est écrêtée à l'ampère entier inférieur, une PAC à l'état le plus haut + * qui tient (jamais 1). + * - Sans plafond en vigueur (limite non réglée), rien n'est borné. + */ + void bornerOptimiseurAuSoutirage(Slot &slot, const SurplusContext &ctx); + //! \brief Replace dans \c slot les décisions des charges délestées par L4. //! \param[in,out] slot Actions du cycle. Une charge délestée porte le motif du DÉLESTAGE : //! le plan ne l'a pas commandée, publier sa consigne montrerait ce qui n'a pas eu lieu. @@ -884,12 +917,17 @@ public: * waterfall pourrait allouer un surplus que le branchement ne supporte pas, et la seule * chose qui rattraperait serait \c verifyOverloadProtection() **après coup**. * - * \note Le plafond est la place LIBRE, et on n'y rend **pas** ce que la borne tire déjà. - * C'est délibéré et c'est de sécurité : sur une phase en dépassement la marge est - * négative, et c'est ce signe qui ramène la borne à son minimum. Rendre sa propre + * \note EN DÉPASSEMENT, le plafond est la place LIBRE, et on n'y rend **pas** ce que la + * borne tire déjà. C'est délibéré et c'est de sécurité : sur une phase en dépassement la + * marge est négative, et c'est ce signe qui ramène la borne à son minimum. Rendre sa propre * consommation lui rendrait exactement la puissance qui cause le dépassement - * (`testOverloadProtectionEcoMode`). La formulation inverse figurait ici et était - * fausse : le code ne l'a jamais fait. + * (`testOverloadProtectionEcoMode`, dont le compteur ne voit pas la borne baisser). + * \note HORS DÉPASSEMENT (lot délestage V2C, question 5), le plafond vaut au moins celui qui + * rend à la borne son tirage attribuable, moins 1 A (\c evCeilingGivingBackDraw()). Sans + * lui, une borne qui tient une bonne part de sa phase alternait : pleine, la place libre + * tombait ; au plancher, elle remontait (16, 9, 16, 9 A sous 25 A et 10 % de marge). Le + * maximum des deux garde la règle d'avant quand rien n'est attribuable, ou quand la borne + * tire moins d'un ampère. * \note Valeur de CYCLE : elle dépend de ce que le reste de la maison consomme à cet * instant. Elle est donc relue à chaque \c toLoadContext(), jamais mémorisée. */ @@ -912,9 +950,11 @@ public: * * \par Contrat * - Le courant rendu est MESURÉ sur la borne (état \c currentPower de sa classe), jamais - * déduit de sa consigne. Il n'est rendu que sur les phases où la borne le mesure, et - * seulement si elles sont aussi nombreuses que les phases visées. Sinon — borne sans - * mesure, à l'arrêt, ou qui bascule — rien n'est rendu, et la marge s'applique quand même. + * déduit de sa consigne. Il n'est rendu que sur les phases où la borne le tire, quand c'est + * sûr (\c evAttributableDraw()) : mesurées, et aussi nombreuses que les phases visées ; ou, + * pour une borne qui ne publie pas ses phases, celles que le moteur lui suppose (lot + * délestage V2C). Sinon — borne sans mesure, à l'arrêt, ou qui bascule — rien n'est rendu, + * et la marge s'applique quand même. * - Une phase ne peut pas valoir plus que sa limite, moins la marge : ce que la maison tire * sans la borne est écrêté à zéro. Une borne qui annoncerait plus que ce que le compteur * voit ne se fabrique donc pas de place au-delà de la limite. @@ -927,7 +967,8 @@ public: * mange jamais la marge. * * \note Réservée aux heures creuses et à la pleine puissance du lot e2. Hors de là, - * \c evPhaseAllowanceW() reste la règle, et les décisions sans tarif ne changent pas. + * \c evPhaseAllowanceW() reste la règle ; depuis le lot délestage V2C, elle rend elle aussi + * le tirage hors dépassement. */ double evOffPeakAllowanceW(EvCharger *ev, uint phaseCount) const; @@ -1010,6 +1051,53 @@ protected: const QDateTime ¤tDateTime) override; private: + //! Lot délestage V2C — ce qu'une borne tire, et sur quelles phases. \c phases vaut + //! \c PhaseNone quand l'attribution n'est pas sûre : rien n'est alors rendu. + struct TirageBorne { + Electricity::Phases phases = Electricity::PhaseNone; + double watts = 0; + }; + + /*! + * \brief Lot délestage V2C — le tirage MESURÉ d'une borne, attribué à ses phases quand c'est sûr. + * + * \param ev Borne. \param phaseCount Nombre de phases VISÉ par le cycle. + * \return Les phases et la puissance (\c currentPower de la borne, jamais sa consigne) ; un + * tirage vide si la borne n'a pas de \c currentPower ou si l'attribution n'est pas sûre. + * + * \par Deux cas + * - Borne MESURÉE par phase (\c meteredPhases() non vide) : ses phases mesurées, si elles + * sont aussi nombreuses que les phases visées. Sinon elle bascule, et rien n'est attribué. + * C'est la règle du lot e2, inchangée. + * - Borne NON MESURÉE par phase (aucun état \c currentPhaseX ni \c currentPowerPhaseX : le + * V2C Trydan publie \c currentL1..3) : les phases que le moteur lui suppose déjà + * (\c phasesForCount() — la phase A en monophasé, comme \c chargerPhaseKey() pour L4 et + * \c prepareInformation() pour l'amont), à condition que la borne annonce autant de phases + * que le cycle en vise (\c EvCharger::phaseCount()), et que ce soit une ou trois. Une + * triphasée qui charge une voiture monophasée, ou une borne en cours de bascule, n'est donc + * pas attribuée. + * \note Une borne qui PUBLIE des courants par phase, tous nuls alors qu'elle tire, n'est pas + * « non mesurée » : sa mesure se contredit, et rien n'est attribué. + * \note L'hypothèse de la phase A est celle de tout le moteur. Une monophasée câblée sur une + * autre phase la met en défaut, ici comme dans L4 : c'est le plugin qui peut la lever, en + * publiant ses courants par phase. + */ + TirageBorne evAttributableDraw(EvCharger *ev, uint phaseCount) const; + + //! Lot délestage V2C — le plafond d'une borne qui lui rend ce qu'elle tire (règle du lot e2). + struct PlafondRendu { + double watts = -1; //!< Plafond en W, multiple de 230 W par phase ; −1 sans limite. + bool depasse = false; //!< Une phase de la borne, borne comprise, est au-dessus du seuil. + bool attribuable = false;//!< Le tirage de la borne a été rendu (\c evAttributableDraw()). + }; + + /*! + * \brief Lot délestage V2C — limite − (tirage de la phase − tirage attribué à la borne) − 1 A, + * à l'ampère inférieur, × phases. Le calcul d'\c evOffPeakAllowanceW(), mis en commun avec + * \c evPhaseAllowanceW() ; son contrat est écrit sur la première. + */ + PlafondRendu evCeilingGivingBackDraw(EvCharger *ev, uint phaseCount) const; + /*! * \brief Construit le SurplusContext §5 : meter brut + loads EV + SG-Ready. * diff --git a/energyplugin/etm/scheduler/optimizerplan.cpp b/energyplugin/etm/scheduler/optimizerplan.cpp index dab0ea8..edbb257 100644 --- a/energyplugin/etm/scheduler/optimizerplan.cpp +++ b/energyplugin/etm/scheduler/optimizerplan.cpp @@ -555,7 +555,9 @@ QList finaliser(Plan &plan, Slot &slot, const QHash 0 && lc.declared.maxPowerW > 0) { + const double ecoW = niveauxEco.value(lc.id).targetW; + lcConfort.declared.maxPowerW = qMax(0.0, lc.declared.maxPowerW - ecoW); + lcConfort.declared.minPowerW = qMax(0.0, lc.declared.minPowerW - ecoW); + } // Même règle pour la puissance ENGAGÉE par un verrou : elle borne la SOMME éco + confort, // qui est la consigne. Relevée en entier sur la part confort, elle s'ajoutait à un achat // éco qui la couvrait déjà : une borne tenue à 6 A par son verrou d'arrêt, servie à 6 A diff --git a/energyplugin/etm/types/decisionreason.cpp b/energyplugin/etm/types/decisionreason.cpp index 7bbd312..4c1faf0 100644 --- a/energyplugin/etm/types/decisionreason.cpp +++ b/energyplugin/etm/types/decisionreason.cpp @@ -126,7 +126,16 @@ QString renderFr(const DecisionReason &reason, const QString &label) // La protection de surcharge de l'amont tient la borne. La phrase dit ce que la borne // reçoit, puis pourquoi : le branchement, la phase, sa limite. if (c == QLatin1String(DecisionCode::OverloadThrottled)) { - const QString pourquoi = p.contains(QStringLiteral("phase")) + // Avec une marge de sécurité, la protection agit au SEUIL (`thresholdA`), sous la limite : + // le dire « à sa limite » enverrait vérifier un disjoncteur qui n'a rien vu. + const QString ou = p.contains(QStringLiteral("phase")) + ? QStringLiteral("phase %1, ").arg(p.value(QStringLiteral("phase")).toString()) + : QString(); + const QString pourquoi = p.contains(QStringLiteral("thresholdA")) + ? QStringLiteral("le branchement atteignait son seuil de protection (%1%2 A sous une limite de %3 A%4)") + .arg(ou, QLocale(QLocale::French).toString(p.value(QStringLiteral("thresholdA")).toDouble())) + .arg(w("limitA")).arg(ou.isEmpty() ? QStringLiteral(" par phase") : QString()) + : p.contains(QStringLiteral("phase")) ? QStringLiteral("le branchement atteignait sa limite (phase %1, %2 A)") .arg(p.value(QStringLiteral("phase")).toString()).arg(w("limitA")) : QStringLiteral("le branchement atteignait sa limite (%1 A par phase)").arg(w("limitA")); diff --git a/energyplugin/etm/types/decisionreason.h b/energyplugin/etm/types/decisionreason.h index edcfb76..85aea17 100644 --- a/energyplugin/etm/types/decisionreason.h +++ b/energyplugin/etm/types/decisionreason.h @@ -183,7 +183,9 @@ constexpr const char *DrawCap = "DRAW_CAP"; * \c cut (booléen), \c overshootW (le plus fort dépassement constaté pendant la tenue), * \c limitA (la limite par phase du branchement, \c phasePowerLimit), \c phase (code opaque * \c A | \c B | \c C — **absent** pour une borne triphasée : la protection la rabaisse alors sur - * la phase la plus chargée, sans qu'une phase soit à elle seule coupable). + * la phase la plus chargée, sans qu'une phase soit à elle seule coupable), \c thresholdA (le seuil + * où la protection agit : la limite moins la marge de sécurité, au dixième d'ampère — **absent** + * quand la marge est nulle ; \c overshootW est mesuré au-dessus de lui). * * \par Ce qu'il publie, et ce qu'il ne décide pas * \c SmartChargingManager::verifyOverloadProtection() rabaisse la borne du dépassement arrondi diff --git a/energyplugin/etm/types/drawcap.cpp b/energyplugin/etm/types/drawcap.cpp index 17db774..45bcca0 100644 --- a/energyplugin/etm/types/drawcap.cpp +++ b/energyplugin/etm/types/drawcap.cpp @@ -85,3 +85,24 @@ QVariantMap drawCapReasonParams(const DrawMargin &marge, bool shed, double shedW params.insert(QStringLiteral("minOnBypassed"), true); return params; } + +QList tiragesCoherentsAvecLeTotal(const QList &parPhaseW, double totalW) +{ + QList tirages = parPhaseW; + if (tirages.isEmpty()) + return tirages; + double somme = 0; + double importe = 0; + for (double w : tirages) { + somme += w; + importe += qMax(0.0, w); + } + const double manque = totalW - somme; + // Sous le demi-watt, c'est l'arrondi d'une somme de flottants, pas une lecture en retard : la + // corriger changerait des décisions au douzième chiffre sans rien protéger. + if (manque <= 0.5) + return tirages; // les phases disent au moins ce que dit le total : rien à corriger + for (double &w : tirages) + w += importe > 0 ? manque * qMax(0.0, w) / importe : manque / tirages.size(); + return tirages; +} diff --git a/energyplugin/etm/types/drawcap.h b/energyplugin/etm/types/drawcap.h index a9232af..6524b72 100644 --- a/energyplugin/etm/types/drawcap.h +++ b/energyplugin/etm/types/drawcap.h @@ -111,6 +111,30 @@ DrawMargin resolveDrawMargin(const QList &caps, //! \param s Source. \return \c "connection" · \c "gridOperator" · \c "selfImposed". QString drawCapSourceCode(DrawCapSource s); +/*! + * \brief Tirages par phase rendus COHÉRENTS avec le total du compteur — jamais abaissés. + * + * \param parPhaseW Tirage lu sur chaque phase, en watts (import > 0, export < 0). + * \param totalW Puissance totale du même compteur, en watts (import > 0). + * \return Les mêmes tirages si leur somme atteint le total, à un demi-watt près. Sinon, le + * MANQUE (total − somme) est ajouté aux phases qui importent, au prorata de ce qu'elles lisent + * — ou à parts égales si aucune n'importe. Aucune valeur n'est jamais diminuée. Liste vide : + * rendue telle quelle. + * + * \par Pourquoi + * Les phases d'un compteur triphasé somment à son total : la phase la plus chargée porte AU MOINS + * le tiers de ce que la maison importe. Une somme des phases sous le total ne peut donc venir que + * de lectures en retard, et c'est le total qu'il faut croire pour la sécurité. Relevé le + * 2026-10-27 sur une installation d'essai : 22,3 kW importés (32,3 A par phase) pendant que les + * phases publiaient 29,1 A, puis 30,4 A — la protection a attendu 1 min 45 qu'elles franchissent + * la limite. Le sens de l'erreur commande la règle : relever une phase ne peut que faire agir la + * protection plus tôt ou plus fort ; c'est le seul sens admissible dans une couche de sécurité. + * + * \note Le prorata est une HYPOTHÈSE de répartition ; la seule garantie est sur le maximum, qui ne + * tombe jamais sous le tiers du total importé. + */ +QList tiragesCoherentsAvecLeTotal(const QList &parPhaseW, double totalW); + /*! * \brief Paramètres du motif \c DRAW_CAP — construits ICI, en un seul endroit. * diff --git a/energyplugin/nymeaenergyjsonhandler.cpp b/energyplugin/nymeaenergyjsonhandler.cpp index e32adb8..7c74725 100644 --- a/energyplugin/nymeaenergyjsonhandler.cpp +++ b/energyplugin/nymeaenergyjsonhandler.cpp @@ -81,13 +81,18 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana // Table figée par TestCharging::testNymeaEnergyPermissionScopes. params.clear(); returns.clear(); - description = "Get the phase power consumption limit. 0 if unset. This needs to be set in order for the smart charging to do anything."; - returns.insert("phasePowerLimit", enumValueName(Uint)); + description = "Get the phase power consumption limit. 0 if unset. This needs to be set in order for the smart charging to do anything. " + "[ETM] Also the safety margin under it (percent, 10 by default) and the resulting effective limit (A): " + "the engine allocates under the effective limit and its overload protection acts above it."; + returns = phasePowerLimitSchema(); registerMethod("GetPhasePowerLimit", description, params, returns, Types::PermissionScopeNone); params.clear(); returns.clear(); - description = "Set the phase power consumption limit. This is the maximum allowed Ampere per phase. A value of 0 means unset. All smart charging will be disabled."; + description = "Set the phase power consumption limit. This is the maximum allowed Ampere per phase. A value of 0 means unset. All smart charging will be disabled. " + "[ETM] Optional phaseSafetyMarginPercent, 0 to 25: absent, the margin is kept; out of range, " + "EnergyErrorInvalidParameter and nothing changes."; params.insert("phasePowerLimit", enumValueName(Uint)); + params.insert("o:phaseSafetyMarginPercent", enumValueName(Uint)); returns.insert("energyError", enumRef()); registerMethod("SetPhasePowerLimit", description, params, returns, Types::PermissionScopeAdmin); @@ -725,11 +730,15 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana // Notifications params.clear(); - description = "Emitted whenever the phase power limit configuration changes."; - params.insert("phasePowerLimit", enumValueName(Uint)); + description = "Emitted whenever the phase power limit configuration changes — the limit or [ETM] its safety margin."; + params = phasePowerLimitSchema(); registerNotification("PhasePowerLimitChanged", description, params); - connect(m_smartChargingManager, &SmartChargingManager::phasePowerLimitChanged, this, [=](int phasePowerLimit){ - emit PhasePowerLimitChanged({{"phasePowerLimit", phasePowerLimit}}); + // [ETM] Même charge utile que GetPhasePowerLimit, émise pour l'une ou l'autre des deux grandeurs. + connect(m_smartChargingManager, &SmartChargingManager::phasePowerLimitChanged, this, [=](int){ + emit PhasePowerLimitChanged(phasePowerLimitPayload()); + }); + connect(m_smartChargingManager, &SmartChargingManager::phaseSafetyMarginPercentChanged, this, [=](uint){ + emit PhasePowerLimitChanged(phasePowerLimitPayload()); }); params.clear(); @@ -934,14 +943,38 @@ void NymeaEnergyJsonHandler::registerMethod(const QString &name, const QString & JsonReply *NymeaEnergyJsonHandler::GetPhasePowerLimit(const QVariantMap ¶ms) { Q_UNUSED(params) - QVariantMap returns; - returns.insert("phasePowerLimit", m_smartChargingManager->phasePowerLimit()); - return createReply(returns); + return createReply(phasePowerLimitPayload()); +} + +QVariantMap NymeaEnergyJsonHandler::phasePowerLimitSchema() +{ + // [ETM] UNE déclaration pour GetPhasePowerLimit et PhasePowerLimitChanged (règle 11). + QVariantMap schema; + schema.insert("phasePowerLimit", enumValueName(Uint)); + schema.insert("phaseSafetyMarginPercent", enumValueName(Uint)); + schema.insert("effectivePhaseLimit", enumValueName(Double)); + return schema; +} + +QVariantMap NymeaEnergyJsonHandler::phasePowerLimitPayload() const +{ + return {{"phasePowerLimit", m_smartChargingManager->phasePowerLimit()}, + {"phaseSafetyMarginPercent", m_smartChargingManager->phaseSafetyMarginPercent()}, + {"effectivePhaseLimit", m_smartChargingManager->effectivePhaseLimit()}}; } JsonReply *NymeaEnergyJsonHandler::SetPhasePowerLimit(const QVariantMap ¶ms) { int phasePowerLimit = params.value("phasePowerLimit").toUInt(); + // [ETM] La marge est jugée AVANT toute écriture : refusée, rien ne change — pas même la limite. + if (params.contains("phaseSafetyMarginPercent") + && params.value("phaseSafetyMarginPercent").toUInt() > SmartChargingManager::PhaseSafetyMarginMaxPercent) { + QVariantMap returns; + returns.insert("energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)); + return createReply(returns); + } + if (params.contains("phaseSafetyMarginPercent")) + m_smartChargingManager->setPhaseSafetyMarginPercent(params.value("phaseSafetyMarginPercent").toUInt()); m_smartChargingManager->setPhasePowerLimit(phasePowerLimit); QVariantMap returns; returns.insert("energyError", enumValueName(EnergyManager::EnergyErrorNoError)); diff --git a/energyplugin/nymeaenergyjsonhandler.h b/energyplugin/nymeaenergyjsonhandler.h index f5b8748..208c50f 100644 --- a/energyplugin/nymeaenergyjsonhandler.h +++ b/energyplugin/nymeaenergyjsonhandler.h @@ -239,6 +239,14 @@ private: * celui qu'on ne teste pas tous les jours. */ static QVariantMap loadTelemetrySchema(); + /*! + * \brief [ETM] Charge utile commune de \c GetPhasePowerLimit et \c PhasePowerLimitChanged : + * la limite, la marge de sécurité et le seuil effectif qui en résulte. + * \return Le schéma (\c phasePowerLimitSchema) ou les valeurs courantes (\c phasePowerLimitPayload). + */ + static QVariantMap phasePowerLimitSchema(); + //! \brief [ETM] Valeurs courantes, dans la forme de \c phasePowerLimitSchema(). \return La charge utile. + QVariantMap phasePowerLimitPayload() const; //! \brief Charge utile de l'état de l'optimiseur — déclarée une fois, pour \c GetOptimizerStatus //! et \c OptimizerStatusChanged. \return Le schéma des champs. diff --git a/energyplugin/smartchargingmanager.cpp b/energyplugin/smartchargingmanager.cpp index ae453df..443967e 100644 --- a/energyplugin/smartchargingmanager.cpp +++ b/energyplugin/smartchargingmanager.cpp @@ -23,6 +23,7 @@ * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ #include "smartchargingmanager.h" +#include "etm/types/drawcap.h" // [ETM] tiragesCoherentsAvecLeTotal() #include "evcharger.h" #include "rootmeter.h" #include "energysettings.h" @@ -43,6 +44,11 @@ SmartChargingManager::SmartChargingManager(EnergyManager *energyManager, ThingMa { EnergySettings settings; m_phasePowerConsumptionLimit = settings.value("phasePowerConsumptionLimit", 0).toInt(); + // [ETM] Marge de sécurité (2026-10-27) : absente du réglage, elle vaut 10 % ; hors bornes (un + // fichier édité à la main), elle reprend 10 % plutôt qu'une valeur qu'on refuserait par RPC. + m_phaseSafetyMarginPercent = settings.value("phaseSafetyMarginPercent", 10).toUInt(); + if (m_phaseSafetyMarginPercent > PhaseSafetyMarginMaxPercent) + m_phaseSafetyMarginPercent = 10; m_acquisitionTolerance = settings.value("acquisitionTolerance", 0.5).toDouble(); // 3g-1 — DÉFAUT ABAISSÉ de 0,9 à 0,2, et la raison n'est pas cosmétique. // @@ -186,6 +192,39 @@ void SmartChargingManager::setPhasePowerLimit(uint phasePowerLimit) } } +bool SmartChargingManager::setPhaseSafetyMarginPercent(uint percent) +{ + // [ETM] Hors bornes : refus, et l'état ne bouge pas. + if (percent > PhaseSafetyMarginMaxPercent) + return false; + if (m_phaseSafetyMarginPercent != percent) { + m_phaseSafetyMarginPercent = percent; + emit phaseSafetyMarginPercentChanged(m_phaseSafetyMarginPercent); + + EnergySettings settings; + settings.setValue("phaseSafetyMarginPercent", m_phaseSafetyMarginPercent); + +#ifndef ENERGY_SIMULATION + // Même raison que setPhasePowerLimit() : le seuil change, le cycle le prend tout de suite. + update(QDateTime::currentDateTime()); +#endif + } + return true; +} + +QHash SmartChargingManager::phaseConsumptionW() const +{ + if (!m_rootMeter) + return {}; + // [ETM] Les phases relevées au niveau du total si elles sont en retard sur lui — voir + // tiragesCoherentsAvecLeTotal() : la phase la plus chargée porte au moins le tiers du total. + const QList w = tiragesCoherentsAvecLeTotal({m_rootMeter->currentPowerPhaseA(), + m_rootMeter->currentPowerPhaseB(), + m_rootMeter->currentPowerPhaseC()}, + m_rootMeter->currentPower()); + return {{"A", w.at(0)}, {"B", w.at(1)}, {"C", w.at(2)}}; +} + double SmartChargingManager::acquisitionTolerance() const { return m_acquisitionTolerance; @@ -368,13 +407,13 @@ void SmartChargingManager::verifyOverloadProtection(const QDateTime ¤tDate return; } - QHash currentPhaseConsumption = {{"A", m_rootMeter->currentPowerPhaseA()}, - {"B", m_rootMeter->currentPowerPhaseB()}, - {"C", m_rootMeter->currentPowerPhaseC()}}; + // [ETM] Phases cohérentes avec le total (phaseConsumptionW()), et seuil à la limite moins la + // marge de sécurité (effectivePhaseLimit()) — lot délestage triphasé, 2026-10-27. + QHash currentPhaseConsumption = phaseConsumptionW(); bool limitExceeded = false; - double consumptionLimitInWatt = 230 * m_phasePowerConsumptionLimit; + double consumptionLimitInWatt = 230 * effectivePhaseLimit(); // Note: for now we assume the phases are always connected this way: // EV phase A -> house phase A @@ -394,7 +433,7 @@ void SmartChargingManager::verifyOverloadProtection(const QDateTime ¤tDate foreach (const QString &phase, currentPhaseConsumption.keys()) { if (currentPhaseConsumption.value(phase) > consumptionLimitInWatt) { - qCInfo(dcNymeaEnergy()) << "Overload protection: Phase" << phase << "exceeding limit:" << currentPhaseConsumption.value(phase) << "W (" << (currentPhaseConsumption.value(phase) / 230) << "A). Maximum allowance:" << consumptionLimitInWatt << "W (" << m_phasePowerConsumptionLimit << "A)"; + qCInfo(dcNymeaEnergy()) << "Overload protection: Phase" << phase << "exceeding limit:" << currentPhaseConsumption.value(phase) << "W (" << (currentPhaseConsumption.value(phase) / 230) << "A). Maximum allowance:" << consumptionLimitInWatt << "W (" << effectivePhaseLimit() << "A)"; limitExceeded = true; double overshot = currentPhaseConsumption.value(phase) - consumptionLimitInWatt; requiredThrottlePower[phase] = overshot; @@ -476,11 +515,10 @@ void SmartChargingManager::verifyOverloadProtectionRecovery(const QDateTime &cur << manualChargingEnabled(evCharger->id()) << manualMaxChargingCurrent(evCharger->id()) << "[A]"; - QHash currentPhaseConsumption = {{"A", m_rootMeter->currentPowerPhaseA()}, - {"B", m_rootMeter->currentPowerPhaseB()}, - {"C", m_rootMeter->currentPowerPhaseC()}}; + // [ETM] Mêmes lectures et même seuil que verifyOverloadProtection(). + QHash currentPhaseConsumption = phaseConsumptionW(); - double consumptionLimitInWatt = 230 * m_phasePowerConsumptionLimit; + double consumptionLimitInWatt = 230 * effectivePhaseLimit(); QHash availablePhasePower = {{"A", 0}, {"B", 0}, @@ -583,7 +621,7 @@ void SmartChargingManager::verifyOverloadProtectionRecovery(const QDateTime &cur void SmartChargingManager::prepareInformation(const QDateTime ¤tDateTime) { // We don't assume we have the entire phase power for charging... - double housePhaseLimitPower = m_phasePowerConsumptionLimit * 230; + double housePhaseLimitPower = effectivePhaseLimit() * 230; // [ETM] marge de sécurité qCDebug(dcNymeaEnergy()) << "---------------- Prepare information ---------------"; @@ -637,6 +675,12 @@ void SmartChargingManager::prepareInformation(const QDateTime ¤tDateTime) evChargers.append(evCharger); } + // [ETM] Les bornes préparées CE cycle — exactement celles que l'étape 2 va traiter. La table + // m_processInfos ne le dit pas : elle n'est jamais vidée, et operator[] y crée une entrée par + // défaut (1 phase, pas de bascule) dès qu'on la lit ainsi, dans verifyOverloadProtection() par + // exemple. Lue par internalHasProcessInfo(), voir sa documentation. + m_preparedChargers = QSet(evChargers.constBegin(), evChargers.constEnd()); + // 2. Prepare all information avaialble for each charger foreach (EvCharger *evCharger, evChargers) { @@ -1314,7 +1358,9 @@ void SmartChargingManager::planSurplusCharging(const QDateTime ¤tDateTime) bool chargingEnabled = newRawValue >= minimumRequiredAmps; // Adjust down to not overload any phases. - double allowanceOnRootMeterAmpere = m_rootMeter->calculateAllowanceAmpere(desiredPhases, m_phasePowerConsumptionLimit); + // [ETM] Marge de sécurité : la place sous le seuil, pas sous la limite. + double allowanceOnRootMeterAmpere = m_rootMeter->calculateAllowanceAmpere(desiredPhases, m_phasePowerConsumptionLimit) + - (m_phasePowerConsumptionLimit - effectivePhaseLimit()); double newValue = qMin(newRawValue, allowanceOnRootMeterAmpere); uint newValueInt = newValue >= 0 ? qRound(newValue) : 0; @@ -1396,7 +1442,9 @@ void SmartChargingManager::adjustEvChargers(const QDateTime ¤tDateTime) double currentPower = evCharger->currentPower() / processInfo.effectivePhaseCount; qCDebug(dcNymeaEnergy()).nospace() << "Charger " << evCharger->name() << " is connected on phase(s): " << processInfo.effectivePhaseCount << " and uses " << currentPower << " W (" << (currentPower / 230) << "A) per phase"; - double allowancePerPhase = m_rootMeter->calculateAllowanceAmpere(processInfo.effectivePhases, m_phasePowerConsumptionLimit); + // [ETM] Marge de sécurité : la place sous le seuil, pas sous la limite. + double allowancePerPhase = m_rootMeter->calculateAllowanceAmpere(processInfo.effectivePhases, m_phasePowerConsumptionLimit) + - (m_phasePowerConsumptionLimit - effectivePhaseLimit()); qCDebug(dcNymeaEnergy()).nospace() << "Current load on root meter: " << m_rootMeter->currentPower() << "[W] ( " << (m_rootMeter->currentPower() / 230) << "[A] ) Phases: A: " << m_rootMeter->currentPhaseA() << "[A], B: " << m_rootMeter->currentPhaseB() << "[A], C: " << m_rootMeter->currentPhaseC() << "[A]"; // Reducing the max allowance by 1A (230W) so that we're not super close to the limit and are hitting the ovreload protection diff --git a/energyplugin/smartchargingmanager.h b/energyplugin/smartchargingmanager.h index 45c0cf3..3b81f60 100644 --- a/energyplugin/smartchargingmanager.h +++ b/energyplugin/smartchargingmanager.h @@ -27,6 +27,7 @@ #include #include +#include #include "energymanagerconfiguration.h" #include "types/charginginfo.h" @@ -63,6 +64,30 @@ public: uint phasePowerLimit() const; void setPhasePowerLimit(uint phasePowerLimit); + /*! + * \brief [ETM] Marge de sécurité sous la limite de phase, en pourcents (décision du 2026-10-27). + * \return 10 par défaut ; persistée avec la limite (clé \c phaseSafetyMarginPercent). + * \note Le moteur vise \c effectivePhaseLimit(), pas \c phasePowerLimit() : pour le plafond + * qu'il déclare et alloue, et pour le seuil où la protection de surcharge agit. + */ + uint phaseSafetyMarginPercent() const { return m_phaseSafetyMarginPercent; } + /*! + * \brief [ETM] Règle la marge de sécurité. + * \param percent Entre 0 et \c PhaseSafetyMarginMaxPercent inclus. + * \return Faux, sans rien changer, hors de ces bornes. + * \note 0 est admis : c'est le comportement d'avant la marge, et la bonne valeur pour une + * installation dont la limite saisie est déjà une limite réduite. + */ + bool setPhaseSafetyMarginPercent(uint percent); + //! [ETM] Borne haute de la marge : au-delà, la maison perd plus d'un quart de son branchement. + static constexpr uint PhaseSafetyMarginMaxPercent = 25; + /*! + * \brief [ETM] Le seuil effectif par phase, en ampères : limite × (1 − marge). + * \return 0 quand la limite n'est pas réglée (protection désactivée, contrat amont). + */ + double effectivePhaseLimit() const + { return m_phasePowerConsumptionLimit * (100.0 - m_phaseSafetyMarginPercent) / 100.0; } + double acquisitionTolerance() const; void setAcquisitionTolerance(double acquisitionTolerance); @@ -99,6 +124,7 @@ public: signals: void phasePowerLimitChanged(int phasePowerLimit); + void phaseSafetyMarginPercentChanged(uint percent); // [ETM] void acquisitionToleranceChanged(double acquisitionTolerance); void batteryLevelConsiderationChanged(double batteryLevelConsideration); void lockOnUnplugChanged(bool lockOnUnplug); @@ -113,7 +139,9 @@ signals: // [ETM] BEGIN — SmartChargingManager protected API for EnergyArbitrator (etm/). // All changes below are visibility-only (private → protected / virtual added). -// Zero logic change. Revert by deleting this block and restoring private slots. +// Zero logic change — EXCEPT items 9 to 11 of the inventory (lot délestage triphasé, +// 2026-10-27), which change the overload protection on purpose and say so. +// Revert by deleting this block and restoring private slots. // // INVENTAIRE des ajouts ETM de ce fichier — ils vivent HORS de energyplugin/etm/ et // échappent donc au périmètre du Doxyfile ; ils sont documentés à la main, et la @@ -139,6 +167,19 @@ signals: // protection de surcharge. Documenté ci-dessous. // 7. setEvChargingState() — écriture ETM d'un état RUNTIME, documentée // ci-dessous. Pas un réglage : jamais persistée. +// 9. m_preparedChargers — membre privé ETM, posé par prepareInformation() : +// les bornes préparées au dernier cycle. Seul +// lecteur : internalHasProcessInfo(). +// 10. phaseSafetyMarginPercent(), setPhaseSafetyMarginPercent(), effectivePhaseLimit(), +// PhaseSafetyMarginMaxPercent, phaseSafetyMarginPercentChanged() +// — la marge de sécurité (décision du 2026-10-27), +// documentée en tête de la classe. Elle CHANGE la +// logique amont, en connaissance de cause : le seuil +// de verifyOverloadProtection() et de sa reprise, la +// limite de prepareInformation() et les deux +// allowances du proxy (marques [ETM] dans le .cpp). +// 11. phaseConsumptionW() — lecture des phases cohérente avec le total, seule +// lecture de la protection et de sa reprise. // 6. evSurplusPlannedByWaterfall(), evCommandedByWaterfall() // — crochets ETM, virtuels, documentés ci-dessous. // Ce sont EUX qui garantissent « une charge, un @@ -311,8 +352,27 @@ protected: * \c EnergyArbitrator::evTargetPhases() doit le SAVOIR plutôt que de le déduire de * valeurs par défaut — sans quoi une borne triphasée est annoncée monophasée et le * budget du cycle est divisé par trois. + * \note **Lu sur l'ensemble des bornes préparées ce cycle, pas sur la table.** La table + * \c m_processInfos n'est jamais vidée, et l'amont la lit aussi par \c operator[], qui y + * insère une entrée par défaut : \c verifyOverloadProtection() le fait pour toute borne qu'il + * rabaisse. Tester la table rendait donc VRAI pour une borne sans voiture après sa première + * surcharge, et la borne triphasée devenait monophasée dans les comptes (relevé du + * 2026-10-27, \c testOverloadLeavesTheChargerItsPhases). De même, une voiture désassignée + * laissait son entrée de la veille (\c testAnUnassignedCarTakesItsPhasesAway). */ - bool internalHasProcessInfo(EvCharger *evCharger) const { return m_processInfos.contains(evCharger); } // [ETM] new + /*! + * \brief [ETM] Tirage de chaque phase au compteur principal, en watts, cohérent avec le total. + * \return Clés \c "A", \c "B", \c "C" : \c currentPowerPhaseX relevés par + * \c tiragesCoherentsAvecLeTotal() si leur somme est sous \c currentPower — jamais abaissés. + * Vide sans compteur principal. + * \note Seule lecture des phases par la protection de surcharge et sa reprise, et par la + * publication de ses gestes (\c EnergyArbitrator::noterGesteBorne()) : le dépassement publié + * est celui qui a décidé. + */ + QHash phaseConsumptionW() const; + + bool internalHasProcessInfo(EvCharger *evCharger) const // [ETM] new + { return m_preparedChargers.contains(evCharger) && m_processInfos.contains(evCharger); } // [ETM] END private slots: @@ -344,6 +404,7 @@ private: QHash m_chargingSchedules; QHash m_processInfos; + QSet m_preparedChargers; // [ETM] préparées au dernier prepareInformation() QHash m_chargingActions; QDateTime m_lastSpotMarketPlanning; @@ -352,6 +413,7 @@ private: QHash m_overloadProtectionActive; uint m_phasePowerConsumptionLimit = 25; + uint m_phaseSafetyMarginPercent = 10; // [ETM] décision du 2026-10-27 double m_acquisitionTolerance = 0.5; double m_batteryLevelConsideration = 0.9; diff --git a/tests/auto/charging/testcharging.cpp b/tests/auto/charging/testcharging.cpp index ca1706a..f1eb36b 100644 --- a/tests/auto/charging/testcharging.cpp +++ b/tests/auto/charging/testcharging.cpp @@ -332,7 +332,8 @@ void TestCharging::addChargingInfo() // Set phase power limit uint phasePowerLimit = 25000; - response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}})); + // Lot délestage triphasé — algorithme AMONT, figé à marge de sécurité nulle (seuil = limite). + response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}, {"phaseSafetyMarginPercent", 0}})); verifyEnergyError(response); removeDevices(); @@ -691,7 +692,8 @@ void TestCharging::testEcoMode() // Set phase power limit params.clear(); response.clear(); notification.clear(); notificationSpy.clear(); - response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}})); + // Lot délestage triphasé — algorithme AMONT, figé à marge de sécurité nulle (seuil = limite). + response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}, {"phaseSafetyMarginPercent", 0}})); QVERIFY(response.toMap().value("params").toMap().value("energyError").toString() == "EnergyErrorNoError"); // Add mock meter @@ -1069,7 +1071,8 @@ void TestCharging::testEcoModePhaseSwitching() // Set phase power limit params.clear(); response.clear(); notification.clear(); notificationSpy.clear(); - response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}})); + // Lot délestage triphasé — algorithme AMONT, figé à marge de sécurité nulle (seuil = limite). + response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}, {"phaseSafetyMarginPercent", 0}})); QVERIFY(response.toMap().value("params").toMap().value("energyError").toString() == "EnergyErrorNoError"); // Add mock meter @@ -1381,7 +1384,8 @@ void TestCharging::testBatteryLevelConsideration() // Set phase power limit params.clear(); response.clear(); notification.clear(); notificationSpy.clear(); - response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}})); + // Lot délestage triphasé — algorithme AMONT, figé à marge de sécurité nulle (seuil = limite). + response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}, {"phaseSafetyMarginPercent", 0}})); QVERIFY(response.toMap().value("params").toMap().value("energyError").toString() == "EnergyErrorNoError"); // Add mock meter @@ -1602,7 +1606,8 @@ void TestCharging::batteryLevelConsiderationStopCharging() // Set phase power limit params.clear(); response.clear(); notification.clear(); notificationSpy.clear(); - response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}})); + // Lot délestage triphasé — algorithme AMONT, figé à marge de sécurité nulle (seuil = limite). + response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}, {"phaseSafetyMarginPercent", 0}})); QVERIFY(response.toMap().value("params").toMap().value("energyError").toString() == "EnergyErrorNoError"); // Add mock meter @@ -1840,7 +1845,8 @@ void TestCharging::testOverloadProtectionManualMode() // Set phase power limit params.clear(); response.clear(); notification.clear(); notificationSpy.clear(); - response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}})); + // Lot délestage triphasé — algorithme AMONT, figé à marge de sécurité nulle (seuil = limite). + response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}, {"phaseSafetyMarginPercent", 0}})); QVERIFY(response.toMap().value("params").toMap().value("energyError").toString() == "EnergyErrorNoError"); // Add mock meter @@ -2011,7 +2017,8 @@ void TestCharging::testOverloadProtectionEcoMode() // Set phase power limit params.clear(); response.clear(); notification.clear(); notificationSpy.clear(); - response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}})); + // Lot délestage triphasé — algorithme AMONT, figé à marge de sécurité nulle (seuil = limite). + response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}, {"phaseSafetyMarginPercent", 0}})); QVERIFY(response.toMap().value("params").toMap().value("energyError").toString() == "EnergyErrorNoError"); // Add mock meter diff --git a/tests/auto/common/energytestbase.cpp b/tests/auto/common/energytestbase.cpp index 9f012e0..b00160f 100644 --- a/tests/auto/common/energytestbase.cpp +++ b/tests/auto/common/energytestbase.cpp @@ -451,6 +451,23 @@ QUuid EnergyTestBase::addSimpleCharger(double maxChargingCurrentUpperLimit, quin return response.toMap().value("params").toMap().value("thingId").toUuid(); } +QUuid EnergyTestBase::addUnmeteredCharger(const QString &phases, double maxChargingCurrentUpperLimit, quint16 port) +{ + const QVariantList thingParams = { + QVariantMap{{"paramTypeId", "{4c0132c6-e1a8-4bd0-964b-8e9827a0a1c5}"}, {"value", port}}, + QVariantMap{{"paramTypeId", "{ec3e73a0-cca9-415a-bc64-9e841d666853}"}, {"value", phases}}, + QVariantMap{{"paramTypeId", "{803ba501-ae0f-4d58-8097-6ebb0928b3d8}"}, {"value", maxChargingCurrentUpperLimit}}, + }; + QVariantMap params; + params.insert("thingClassId", mockChargerUnmeteredThingClassId.toString()); + params.insert("name", "Unmetered charger"); + params.insert("thingParams", thingParams); + + QVariant response = injectAndWait("Integrations.AddThing", params); + verifyThingError(response); + return response.toMap().value("params").toMap().value("thingId").toUuid(); +} + QUuid EnergyTestBase::addEnergyStorage(uint capacity, double maxChargingPowerUpperLimit, double maxDischargingPowerUpperLimit, quint16 port) { QVariantList thingParams; diff --git a/tests/auto/common/energytestbase.h b/tests/auto/common/energytestbase.h index e15b0cf..be90cd1 100644 --- a/tests/auto/common/energytestbase.h +++ b/tests/auto/common/energytestbase.h @@ -51,6 +51,9 @@ static QUuid mockMeterPowerOnlyThingClassId = QUuid("3ac91e57-08b4-4f26-9d31-7e5 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"); +//! \brief Borne qui mesure sa PUISSANCE mais pas ses phases au sens du moteur — `currentL1..3` +//! comme le plugin V2C Trydan, jamais `currentPhaseX`. `EvCharger::meteredPhases()` y est vide. +static QUuid mockChargerUnmeteredThingClassId = QUuid("e297f8c4-934f-4af3-a655-29b5b799289b"); static QUuid mockCarThingClassId = QUuid("4513f801-836e-40a7-8784-c02650a9bdc6"); static QUuid mockEnergyStorageThingClassId = QUuid("d0d5bbf0-249c-46ed-ac6a-5f271b2b0b0f"); static QUuid mockPowerSwitchThingClassId = QUuid("841f8905-d1d7-4053-909f-01123b497747"); @@ -110,6 +113,9 @@ public: QUuid addCharger(const QString &phases = "All", double maxChargingCurrentUpperLimit = 32, quint16 port = 26657); QUuid addChargerWithPhaseCountSwitching(const QString &phases = "All", double maxChargingCurrentUpperLimit = 32, quint16 port = 26658); QUuid addSimpleCharger(double maxChargingCurrentUpperLimit = 32, quint16 port = 26659); + //! \brief Ajoute une borne sans courants par phase lisibles par le moteur (façon V2C Trydan). + //! \param phases Câblage, `A`, `B`, `C` ou `ABC` — fixe, sans bascule. + QUuid addUnmeteredCharger(const QString &phases = "A", double maxChargingCurrentUpperLimit = 16, quint16 port = 26677); QUuid addEnergyStorage(uint capacity = 10, double maxChargingPowerUpperLimit = 5000, double maxDischargingPowerUpperLimit = 11500, quint16 port = 26660); QUuid addPowerSwitch(double nominalPower = 2000, quint16 port = 26661); diff --git a/tests/auto/decisions/reference/decisions.ndjson.xz b/tests/auto/decisions/reference/decisions.ndjson.xz index 7c9b54e..1484ff0 100644 Binary files a/tests/auto/decisions/reference/decisions.ndjson.xz and b/tests/auto/decisions/reference/decisions.ndjson.xz differ diff --git a/tests/auto/loadmodel/testloadmodel.cpp b/tests/auto/loadmodel/testloadmodel.cpp index dc21b93..d90aad1 100644 --- a/tests/auto/loadmodel/testloadmodel.cpp +++ b/tests/auto/loadmodel/testloadmodel.cpp @@ -3070,3 +3070,32 @@ void TestLoadModel::testTheBaseloadLogWritesWithoutTouchingTheOtherTables() horsService.observerMinute(huitJours.first()); QVERIFY(!horsService.profil().calcule); } + +void TestLoadModel::testPhaseDrawsAreNeverBelowTheTotal() +{ + // Le cas relevé le 2026-10-27 : 29,1 A lus sur chaque phase pour 22 277 W au total. Le manque + // est réparti au prorata : trois phases égales, trois tiers du total. + QList w = tiragesCoherentsAvecLeTotal({6700, 6700, 6700}, 22277); + for (double x : w) + QVERIFY(qAbs(x - 22277.0 / 3) < 0.01); + + // Cohérentes, ou AU-DESSUS du total (lecture plus fraîche que le total) : rien ne bouge. + QCOMPARE(tiragesCoherentsAvecLeTotal({1000, 2000, 3000}, 6000), (QList{1000, 2000, 3000})); + QCOMPARE(tiragesCoherentsAvecLeTotal({1000, 2000, 3000}, 5000), (QList{1000, 2000, 3000})); + + // Une phase qui EXPORTE n'est jamais relevée vers l'import, et ne se voit jamais abaissée : + // le manque va aux phases qui importent, au prorata de ce qu'elles lisent. + w = tiragesCoherentsAvecLeTotal({-1000, 1000, 3000}, 4000); + QCOMPARE(w.at(0), -1000.0); + QCOMPARE(w.at(1), 1250.0); + QCOMPARE(w.at(2), 3750.0); + + // Aucune phase n'importe (compteur sans courants par phase : tout à 0) : le total se répartit à + // parts égales — la phase la plus chargée en porte au moins le tiers, c'est la seule garantie. + w = tiragesCoherentsAvecLeTotal({0, 0, 0}, 9000); + QCOMPARE(w, (QList{3000, 3000, 3000})); + + // En export, rien à relever. + QCOMPARE(tiragesCoherentsAvecLeTotal({-500, -500, -500}, -1500), (QList{-500, -500, -500})); + QVERIFY(tiragesCoherentsAvecLeTotal({}, 1000).isEmpty()); +} diff --git a/tests/auto/loadmodel/testloadmodel.h b/tests/auto/loadmodel/testloadmodel.h index 51d14bb..c95e3d4 100644 --- a/tests/auto/loadmodel/testloadmodel.h +++ b/tests/auto/loadmodel/testloadmodel.h @@ -108,6 +108,8 @@ private slots: void testStoredInstantsSurviveATimeZoneChange(); void testReportingDayBoundaryIsPublishedNotDeducible(); void testDrawMarginComparesHeadroomsNotCaps(); + //! Lot délestage triphasé — des phases en retard sur le total sont RELEVÉES, jamais abaissées. + void testPhaseDrawsAreNeverBelowTheTotal(); //! Une révision n'existe que si la PRÉVISION a changé — et le contenu seul en décide. void testTheContentIsTheRevisionIdentity(); diff --git a/tests/auto/refoptimizer/testrefoptimizer.cpp b/tests/auto/refoptimizer/testrefoptimizer.cpp index 30bfe7a..bd6d033 100644 --- a/tests/auto/refoptimizer/testrefoptimizer.cpp +++ b/tests/auto/refoptimizer/testrefoptimizer.cpp @@ -223,6 +223,7 @@ private slots: void testAProtocol10OptimizerIsAccepted(); void testTracesReplayWithZeroDivergence(); void testSafetyWinsOverTheOptimizer(); + void testTheDrawCapHoldsAgainstAnOptimizerAtMaximum(); void testAnEchoedTokenLeaksNowhereEndToEnd(); void cleanupTestCase(); @@ -936,6 +937,116 @@ void TestRefOptimizer::testSafetyWinsOverTheOptimizer() QCOMPARE(triplet(), QString("attendu|vivant|optimizer")); } +void TestRefOptimizer::testTheDrawCapHoldsAgainstAnOptimizerAtMaximum() +{ + // DÉFAUTS 4 et 5 — relevés le 2026-10-27 sur une installation d'essai, sous un optimiseur qui + // demande le MAXIMUM à tout. La protection déleste ; au cycle suivant, le compteur est revenu + // juste sous le seuil, le délestage se relâche, l'optimiseur redemande tout, et la surcharge + // reprend une minute. Aucune mémoire entre la protection et le plan suivant : la marge que le + // compteur laisse n'était opposée à AUCUNE consigne d'optimiseur — seulement à la cascade du + // rule-based. Ici la borne (triphasée, sans voiture) et la charge variable 2 jouent le rôle de + // la borne et du chauffe-eau de l'essai. + monter(Banc::Restreint); + if (QTest::currentTestFailed()) + return; + ThingManager *tm = NymeaCore::instance()->thingManager(); + const QUuid borneId = addCharger("ABC", 16, 26818); + Thing *borne = tm->findConfiguredThing(borneId); + QVERIFY(borne); + for (const char *e : {"connected", "pluggedIn"}) + borne->setStateValue(e, true); + borne->setStateValue("power", false); + borne->setStateValue("maxChargingCurrent", 6); + QCOMPARE(injectAndWait("NymeaEnergy.SetChargingInfo", + QVariantMap{{"chargingInfo", QVariantMap{{"evChargerId", borneId}, + {"chargingMode", "ChargingModeEco"}}}}) + .toMap().value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + m_arb->setPhasePowerLimit(25); + RefOptimizer ref; + brancher(ref, Transport::Unix, {"--mode", "over-limit"}); + if (QTest::currentTestFailed()) + return; + + // Un pas : le compteur est POSÉ (total et phases), la réponse de l'optimiseur attendue, puis + // les charges variables consomment leur consigne. La borne, elle, mesure ce qu'elle tient. + QDateTime t = utcDateTime(QDate(2026, 6, 8), QTime(9, 0, 0)); + const auto pas = [&](double totalW, double phaseA, int dtS = 60) { + t = t.addSecs(dtS); + m_compteur->setStateValue("currentPower", totalW); + for (const char *ph : {"A", "B", "C"}) { + m_compteur->setStateValue(QStringLiteral("currentPhase%1").arg(ph), phaseA); + m_compteur->setStateValue(QStringLiteral("currentPowerPhase%1").arg(ph), phaseA * 230); + } + const int avant = m_jugees; + m_arb->recordMeterUpdate(t); + m_arb->simulationCallUpdate(t); + QVERIFY2(waitFor([&] { return m_jugees > avant; }, 15000), "réponse de l'optimiseur jamais jugée"); + settle(30); + for (const QString &v : {m_var1, m_var2}) { + Thing *th = tm->findConfiguredThing(ThingId(v)); + th->setStateValue("currentPowerW", th->stateValue("powerSetpoint").toDouble()); + } + }; + const auto var2W = [&]() { + return tm->findConfiguredThing(ThingId(m_var2))->stateValue("powerSetpoint").toDouble(); + }; + const auto borneA = [&]() { + return borne->stateValue("power").toBool() ? borne->stateValue("maxChargingCurrent").toInt() : 0; + }; + const auto decision = [&](const QString &id) { + for (const QVariant &v : m_arb->loadTelemetry().value("loads").toList()) + if (v.toMap().value("loadId").toString() == id) + return v.toMap().value("decision").toMap(); + return QVariantMap(); + }; + + // 1. Export : l'optimiseur demande 50 kW à tout, les adaptateurs écrêtent au matériel. + for (int k = 0; k < 3; ++k) + pas(-20000, -20); + QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle()); + QCOMPARE(borneA(), 16); + QCOMPARE(var2W(), 3000.0); + + // 2. LA CHARGE EN WATTS (le chauffe-eau de l'essai). 40 A par phase : la protection déleste + // la cascade en ordre inverse du rang, la PAC puis la charge 2. + pas(40 * 230 * 3, 40); + const double var2Apres = var2W(); + QVERIFY2(var2Apres < 3000, qUtf8Printable(QString::number(var2Apres))); + + // 3. Le compteur revient JUSTE sous le seuil : 22 A par phase (seuil 22,5 A, 25 A − 10 %). + // La marge ne laisse que 0,5 A par phase : ce qui a été retiré ne peut pas revenir. + pas(22 * 230 * 3, 22); + QVERIFY2(var2W() <= var2Apres + 0.5 * 230 + 1, + qUtf8Printable(QStringLiteral("la charge 2 est remontée de %1 à %2 W").arg(var2Apres).arg(var2W()))); + // DÉFAUT 5 — la consigne écrêtée par la sécurité porte le motif de la sécurité, pas celui que + // l'optimiseur avait donné à sa demande. + QCOMPARE(decision(m_var2).value("code").toString(), QString(DecisionCode::DrawCap)); + + // 4. La place revient : tout ce que l'optimiseur demande tient sous le seuil, il est servi. + // Six minutes : le verrou de réenclenchement de la borne, coupée en 2, est expiré. + pas(-20000, -20, 360); + pas(-20000, -20, 360); + QCOMPARE(borneA(), 16); + QCOMPARE(var2W(), 3000.0); + + // 5. LA BORNE. 26 A par phase : la protection la rabaisse sans la couper. (Le harnais rappelle + // la protection après le cycle sur le MÊME compteur : au-delà, deux gestes la coupent.) + pas(26 * 230 * 3, 26); + const int borneApres = borneA(); + QVERIFY2(borneApres > 0 && borneApres < 16, qUtf8Printable(QString::number(borneApres))); + + // 6. Juste sous le seuil : la borne ne remonte pas au-delà de ce que la marge laisse. + pas(22 * 230 * 3, 22); + QVERIFY2(borneA() <= borneApres, + qUtf8Printable(QStringLiteral("la borne est remontée de %1 à %2 A").arg(borneApres).arg(borneA()))); + + // 7. La place revient : la borne retrouve ses 16 A. + pas(-20000, -20); + pas(-20000, -20); + QCOMPARE(borneA(), 16); +} + void TestRefOptimizer::testAnEchoedTokenLeaksNowhereEndToEnd() { // Correctif A, de bout en bout : l'optimiseur de référence, en `echo-token`, recopie le token diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index a811613..d605d31 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -2560,16 +2560,22 @@ void Simulation::run_data() }, { 520, { + // [ETM lot délestage V2C] 80 % → 81 % (et 85 → 86 au pas 700, 87 → 88 + // au pas 900). Vers 10:10 UTC la borne est bornée par la limite de + // phase (32 A) et le compteur la voit : sa place libre alternait 19 et + // 20 A d'un cycle à l'autre. Le plafond lui rend désormais son tirage + // mesuré hors dépassement (evPhaseAllowanceW()) ; elle tient 20-21 A, + // borné par le surplus, et la voiture prend un point d'avance. SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6) , SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false), - SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 80) + SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 81) } }, { 700, { SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6) , SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false), - SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 85) + SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 86) } }, { @@ -2579,8 +2585,10 @@ void Simulation::run_data() // l'écart s'accumule sur 900 pas de rétroaction. Ce qui compte est // au pas 960, et c'est inchangé : la voiture est à 100 % à // l'échéance. Les 14 autres scénarios sont identiques au bit près. + // [ETM lot délestage V2C] 87 % → 88 %, la valeur de l'amont : le point + // gagné vers 10:10 (cf. pas 520) se retrouve ici. SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true), - SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 87) + SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 88) } }, { @@ -3495,7 +3503,9 @@ void Simulation::run() Electricity::Phases chargerPhasesConverted = Electricity::convertPhasesFromString(chargerPhases); // Set phase power limit - response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}})); + // Lot délestage triphasé — scénarios ENREGISTRÉS sous la règle amont : marge de sécurité + // nulle, le seuil est la limite. La marge est éprouvée par testPhaseSafetyMargin. + response = injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", phasePowerLimit}, {"phaseSafetyMarginPercent", 0}})); QVERIFY(response.toMap().value("params").toMap().value("energyError").toString() == "EnergyErrorNoError"); // Add mock spotmarket provider @@ -6896,8 +6906,10 @@ void Simulation::testL4ShedsAPacAndSaysItForcedTheLock() QCOMPARE(p.value("shed").toBool(), true); QCOMPARE(p.value("source").toString(), QString("connection")); QCOMPARE(p.value("phase").toString(), QString("A")); - QCOMPARE(p.value("overshootW").toInt(), 1150); - QCOMPARE(p.value("marginW").toInt(), -1150); + // (30 − 22,5) × 230 : le dépassement se mesure au SEUIL, limite de 25 A moins la marge de + // sécurité de 10 % (lot délestage triphasé) — 1 150 W au-dessus de la limite avant lui. + QCOMPARE(p.value("overshootW").toInt(), 1725); + QCOMPARE(p.value("marginW").toInt(), -1725); // Les watts RÉELLEMENT rendus : la puissance allouée de l'état quitté. QCOMPARE(p.value("shedW").toInt(), 3000); @@ -10257,9 +10269,11 @@ void Simulation::testWorstPhaseBindsWhenPhasesAreUnbalanced() const DrawMargin marge = arb->houseDrawMargin(); QVERIFY2(marge.borne, "un plafond est réglé : la marge doit exister"); - // 1. LA PIRE PHASE L'EMPORTE — et c'est bien B, pas le total. 25 A − 30 A = −5 A, soit - // −1150 W. Le total, lui, afficherait un confort trompeur : la maison exporte. - QCOMPARE(qRound(marge.watts), -1150); + // 1. LA PIRE PHASE L'EMPORTE — et c'est bien B, pas le total. 22,5 A − 30 A = −7,5 A, soit + // −1725 W : le seuil est la limite de 25 A moins la marge de sécurité de 10 % (lot délestage + // triphasé ; −1150 W avant lui). Le total, lui, afficherait un confort trompeur : la maison + // exporte. + QCOMPARE(qRound(marge.watts), -1725); 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, @@ -10294,7 +10308,9 @@ void Simulation::testWorstPhaseBindsWhenPhasesAreUnbalanced() // (« une phase qui exporte a une marge plus grande que le plafond »), et la phrase a été // corrigée. La conclusion, elle, tient : une phase qui exporte est au maximum de la marge, // donc elle ne borne jamais. - QCOMPARE(qRound(equilibree.watts), qRound(25.0 * 230.0)); + // Le plafond est le SEUIL : 25 A moins la marge de sécurité de 10 %, 22,5 A (lot délestage + // triphasé). + QCOMPARE(qRound(equilibree.watts), qRound(22.5 * 230.0)); QVERIFY2(equilibree.watts > 0, "rééquilibrée, la marge redevient positive"); #endif } @@ -11423,9 +11439,11 @@ Simulation::ScenarioHc Simulation::preparerScenarioHc(const QString &nom, Batter s.meter = tm->findConfiguredThing(meterId); s.meter->setStateValue("connected", true); + const bool monophasee = typeBorne == BorneHc::Monophasee || typeBorne == BorneHc::MonophaseeNonMesuree; s.borneId = typeBorne == BorneHc::Monophasee ? addCharger("A", 16, 26802) : typeBorne == BorneHc::Triphasee ? addCharger("ABC", 16, 26802) - : addChargerWithPhaseCountSwitching("ABC", 16, 26802); + : typeBorne == BorneHc::Bascule ? addChargerWithPhaseCountSwitching("ABC", 16, 26802) + : addUnmeteredCharger(monophasee ? "A" : "ABC", 16, 26802); const QUuid voitureId = addCar(26803); s.borne = tm->findConfiguredThing(s.borneId); Thing *voiture = tm->findConfiguredThing(voitureId); @@ -11434,7 +11452,7 @@ Simulation::ScenarioHc Simulation::preparerScenarioHc(const QString &nom, Batter voiture->setStateValue("batteryLevel", 30); voiture->setStateValue("capacity", 60); voiture->setStateValue("minChargingCurrent", 6); - voiture->setStateValue("phaseCount", typeBorne == BorneHc::Monophasee ? 1 : 3); + voiture->setStateValue("phaseCount", monophasee ? 1 : 3); QVariantMap info{{"evChargerId", s.borneId}, {"assignedCarId", voitureId}, {"chargingMode", "ChargingModeEcoWithMinCurrent"}, {"targetPercentage", 100}}; injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}})); @@ -12244,7 +12262,10 @@ void Simulation::testOffPeakFullPowerYieldsToThePhaseLimit() BorneHc::Triphasee); QVERIFY(s.arb && s.borne && s.batterie); const QString borne = s.borneId.toString(); - QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", 25}})) + // Lot délestage triphasé — ce test fige le plafond d'heures creuses contre la LIMITE ; + // marge de sécurité nulle, pour que ses séries restent les siennes. La marge déplace le + // seuil sans toucher à ce calcul (effectivePhaseLimit()), éprouvée par testPhaseSafetyMargin. + QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", 25}, {"phaseSafetyMarginPercent", 0}})) .toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorNoError")); s.borne->setStateValue("pluggedIn", true); @@ -12390,7 +12411,10 @@ void Simulation::testOffPeakFullPowerSettlesUnderThePhaseLimit() .arg(qRound(c.maisonA * 10)), BatterieHc::Aucune, hphcT4(), c.borne); QVERIFY2(s.arb && s.borne, c.quoi); - QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", c.limiteA}})) + // Lot délestage triphasé — ce test fige le plafond d'heures creuses contre la LIMITE ; + // marge de sécurité nulle, pour que ses séries restent les siennes. La marge déplace le + // seuil sans toucher à ce calcul (effectivePhaseLimit()), éprouvée par testPhaseSafetyMargin. + QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", c.limiteA}, {"phaseSafetyMarginPercent", 0}})) .toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorNoError")); s.borne->setStateValue("pluggedIn", true); @@ -12452,7 +12476,10 @@ void Simulation::testOffPeakFullPowerStaysUnderTheLimitOnABlindMeter() ScenarioHc s = preparerScenarioHc(QStringLiteral("compteur-aveugle-%1").arg(totalVoitLaBorne), BatterieHc::Aucune, hphcT4(), BorneHc::Monophasee); QVERIFY2(s.arb && s.borne, quoi); - QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", 16}})) + // Lot délestage triphasé — ce test fige le plafond d'heures creuses contre la LIMITE ; + // marge de sécurité nulle, pour que ses séries restent les siennes. La marge déplace le + // seuil sans toucher à ce calcul (effectivePhaseLimit()), éprouvée par testPhaseSafetyMargin. + QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", 16}, {"phaseSafetyMarginPercent", 0}})) .toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorNoError")); s.borne->setStateValue("pluggedIn", true); @@ -12495,7 +12522,10 @@ void Simulation::testOffPeakFullPowerAtDawnStaysInTheCeiling() ScenarioHc s = preparerScenarioHc(QStringLiteral("aube"), BatterieHc::Aucune, hphcT4(), BorneHc::Monophasee); QVERIFY(s.arb && s.borne); - QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", 16}})) + // Lot délestage triphasé — ce test fige le plafond d'heures creuses contre la LIMITE ; + // marge de sécurité nulle, pour que ses séries restent les siennes. La marge déplace le + // seuil sans toucher à ce calcul (effectivePhaseLimit()), éprouvée par testPhaseSafetyMargin. + QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", 16}, {"phaseSafetyMarginPercent", 0}})) .toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorNoError")); s.borne->setStateValue("pluggedIn", true); @@ -12590,7 +12620,10 @@ void Simulation::testOffPeakFullPowerGivesNothingBackWhileSwitchingPhases() ScenarioHc s = preparerScenarioHc(QStringLiteral("garde-bascule"), BatterieHc::Aucune, hphcT4(), BorneHc::Bascule); QVERIFY(s.arb && s.borne); - QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", 25}})) + // Lot délestage triphasé — ce test fige le plafond d'heures creuses contre la LIMITE ; + // marge de sécurité nulle, pour que ses séries restent les siennes. La marge déplace le + // seuil sans toucher à ce calcul (effectivePhaseLimit()), éprouvée par testPhaseSafetyMargin. + QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", 25}, {"phaseSafetyMarginPercent", 0}})) .toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorNoError")); const QString borne = s.borneId.toString(); @@ -12646,6 +12679,319 @@ void Simulation::testOffPeakFullPowerGivesNothingBackWhileSwitchingPhases() #endif } +// ============================================================================================ +// Lot délestage V2C (2026-10-27) — la borne qui ne publie pas ses courants par phase +// ============================================================================================ + +namespace { + +//! Le compteur voit la borne, quelle que soit la façon dont elle publie ses courants : +//! `currentPhaseX` (mock `charger`) ou `currentL1..3` (mock `chargerUnmetered`, façon V2C +//! Trydan). Chaque phase porte la maison, plus ce que la borne y tire. Rend le total. +double compteurMaisonEtBorne(Thing *compteur, Thing *borne, const QList &maisonA) +{ + double totalW = 0; + const QStringList phases = {"A", "B", "C"}; + for (int i = 0; i < 3; ++i) { + const QString etat = borne->hasState("currentL1") ? QStringLiteral("currentL%1").arg(i + 1) + : QStringLiteral("currentPhase%1").arg(phases.at(i)); + const double a = maisonA.at(i) + borne->stateValue(etat).toDouble(); + compteur->setStateValue(QStringLiteral("currentPhase%1").arg(phases.at(i)), a); + compteur->setStateValue(QStringLiteral("currentPowerPhase%1").arg(phases.at(i)), a * 230); + totalW += a * 230; + } + return totalW; +} + +} // namespace + +void Simulation::testOffPeakUnmeteredChargerSettlesUnderThePhaseLimit() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // LE DÉFAUT. Le V2C Trydan publie `currentL1..3`, pas `currentPhaseX` : meteredPhases() est + // vide, et le plafond d'heures creuses ne rendait rien de ce que la borne tire. Il lisait donc + // la borne comme une charge de la maison sur sa phase, et la consigne alternait : à 16 A la + // place tombait au plancher, à 6 A elle remontait. + // + // Limite 25 A, marge 10 % (seuil 22,5 A). Une maison de nuit : 2,3 A sur A, 0,5 A sur B et + // C ; puis un lave-vaisselle sur A (+8,3 A). Attendu : la place que la maison + // laisse, moins 1 A, à l'ampère inférieur — 22,5 − 2,3 − 1 = 19,2 A, donc 16 A (le maximum de + // la borne) ; avec le lave-vaisselle, 22,5 − 10,6 − 1 = 10,9 A, donc 10 A. En triphasé, la + // phase A est la plus chargée et décide, de la même façon. + FuseauDeTest fuseau("Europe/Paris"); + struct Cas { const char *quoi; BorneHc borne; }; + const QList cas = { + {"monophasée non mesurée", BorneHc::MonophaseeNonMesuree}, + {"triphasée non mesurée", BorneHc::TriphaseeNonMesuree}, + }; + struct Segment { const char *quoi; QList maisonA; int cycles; int attenduA; }; + const QList segments = { + {"maison seule", {2.3, 0.5, 0.5}, 8, 16}, + {"lave-vaisselle", {10.6, 0.5, 0.5}, 8, 10}, + {"maison seule", {2.3, 0.5, 0.5}, 6, 16}, + }; + // Tous les cas sont MESURÉS avant d'être jugés : le journal porte les séries complètes. + QList>> toutesConsignes; + QList>> toutesPires; + QStringList traces; + for (const Cas &c : cas) { + ScenarioHc s = preparerScenarioHc(QStringLiteral("non-mesuree-%1").arg(static_cast(c.borne)), + BatterieHc::Aucune, hphcT4(), c.borne); + QVERIFY2(s.arb && s.borne, c.quoi); + QVERIFY2(!s.borne->hasState("currentPhaseA") && s.borne->hasState("currentL1"), c.quoi); + QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", 25}, {"phaseSafetyMarginPercent", 10}})) + .toMap().value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + s.borne->setStateValue("pluggedIn", true); + + QList> consignesCas; + QList> piresCas; + QStringList trace; + QDateTime a = utcDateTime(QDate(2026, 6, 8), QTime(21, 0)); // 23:00 à Paris + for (const Segment &seg : segments) { + QList consignes; + QList pires; + QStringList t; + for (int k = 0; k < seg.cycles; ++k, a = a.addSecs(360)) { + cycleHc(s, a, compteurMaisonEtBorne(s.meter, s.borne, seg.maisonA)); + consignes << consigneA(s.borne); + compteurMaisonEtBorne(s.meter, s.borne, seg.maisonA); // ce que la consigne produit + pires << pirePhaseA(s.meter); + t << QStringLiteral("%1 A (%2)").arg(consignes.last()).arg(pires.last()); + } + trace << QStringLiteral("%1 : %2").arg(seg.quoi, t.join(QStringLiteral(", "))); + consignesCas << consignes; + piresCas << pires; + } + traces << trace.join(QStringLiteral(" | ")); + qInfo().noquote() << "[non mesurée]" << c.quoi << ":" << traces.last(); + toutesConsignes << consignesCas; + toutesPires << piresCas; + } + + for (int i = 0; i < cas.size(); ++i) { + for (int j = 0; j < segments.size(); ++j) { + const QList &consignes = toutesConsignes.at(i).at(j); + const QList &pires = toutesPires.at(i).at(j); + // Le premier cycle d'un segment juge un compteur qui vient de changer (le lave-vaisselle + // s'allume sous une borne à 16 A : 26,6 A au compteur, L4 tranche). La consigne se pose + // au cycle suivant et TIENT. + for (int k = 1; k < consignes.size(); ++k) { + const QByteArray ctx = QStringLiteral("%1 — %2, cycle %3 : %4") + .arg(cas.at(i).quoi, segments.at(j).quoi).arg(k).arg(traces.at(i)).toUtf8(); + QVERIFY2(consignes.at(k) == segments.at(j).attenduA, ctx.constData()); + QVERIFY2(pires.at(k) <= 22.5 - 1 + 1e-6, ctx.constData()); + } + } + } +#endif +} + +void Simulation::testUnmeteredChargerGivesNothingBackWhenUnsure() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // LA RÈGLE NE REND QUE CE QUI EST SÛR. Deux cas où elle ne rend rien, comme avant le lot : + // + // 1. Une borne TRIPHASÉE non mesurée, avec une voiture MONOPHASÉE : le cycle vise une phase, + // la borne en annonce trois. Sur laquelle la voiture tire, et combien sur chacune ? Rien ne + // le dit — et répartir un tiers par phase sous-estimerait la phase qui porte tout. + // 2. Une borne qui PUBLIE des courants par phase, mais nuls alors qu'elle tire 2 300 W (mesure + // figée, ou incohérente). Elle n'est pas « non mesurée » : sa mesure se contredit. + // + // Limite 25 A, marge 10 % ; la maison tire 2 A par phase ; la voiture tire 10 A sur A, et le + // compteur le voit. Rien rendu : 22,5 − 12 − 1 = 9,5 A, donc 9 A. + FuseauDeTest fuseau("Europe/Paris"); + struct Cas { const char *quoi; BorneHc borne; }; + const QList cas = { + {"triphasée non mesurée, voiture monophasée", BorneHc::TriphaseeNonMesuree}, + {"courants par phase publiés et nuls", BorneHc::Monophasee}, + }; + for (const Cas &c : cas) { + ScenarioHc s = preparerScenarioHc(QStringLiteral("pas-sur-%1").arg(static_cast(c.borne)), + BatterieHc::Aucune, hphcT4(), c.borne); + QVERIFY2(s.arb && s.borne, c.quoi); + QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", 25}, {"phaseSafetyMarginPercent", 10}})) + .toMap().value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + ThingManager *tm = NymeaCore::instance()->thingManager(); + if (c.borne == BorneHc::TriphaseeNonMesuree) { + const Things voitures = tm->configuredThings().filterByThingClassId(ThingClassId(mockCarThingClassId)); + QCOMPARE(voitures.count(), 1); + voitures.first()->setStateValue("phaseCount", 1); + } + const QString borne = s.borneId.toString(); + + // La voiture charge à 10 A sur A : 2 300 W, et c'est ce que la borne mesure. + s.borne->setStateValue("pluggedIn", true); + s.borne->setStateValue("power", true); + s.borne->setStateValue("charging", true); + s.borne->setStateValue("maxChargingCurrent", 10); + for (const QString &etat : {QStringLiteral("currentPhase"), QStringLiteral("currentPowerPhase")}) + for (const char *ph : {"A", "B", "C"}) + if (s.borne->hasState(etat + ph)) + s.borne->setStateValue(etat + ph, 0.0); + for (const char *l : {"currentL1", "currentL2", "currentL3"}) + if (s.borne->hasState(l)) + s.borne->setStateValue(l, 0.0); + s.borne->setStateValue("currentPower", 2300.0); + const QList phasesA = {12, 2, 2}; // maison 2 A partout, voiture 10 A sur A + double totalW = 0; + const QStringList phases = {"A", "B", "C"}; + for (int i = 0; i < 3; ++i) { + s.meter->setStateValue(QStringLiteral("currentPhase%1").arg(phases.at(i)), phasesA.at(i)); + s.meter->setStateValue(QStringLiteral("currentPowerPhase%1").arg(phases.at(i)), phasesA.at(i) * 230); + totalW += phasesA.at(i) * 230; + } + + const QVariantMap snap = cycleHc(s, utcDateTime(QDate(2026, 6, 8), QTime(21, 0)), totalW); + const QVariantMap d = telemetrieParId(snap).value(borne).value("decision").toMap(); + const QString trace = QStringLiteral("%1 : motif %2, retenu %3 W, consigne %4 A") + .arg(c.quoi, d.value("code").toString()) + .arg(d.value("params").toMap().value("gridW").toInt()).arg(consigneA(s.borne)); + qInfo().noquote() << "[pas sûr]" << trace; + QVERIFY2(d.value("code").toString() == QString(DecisionCode::EvOffPeakFullPower), qUtf8Printable(trace)); + QVERIFY2(d.value("params").toMap().value("gridW").toInt() == 9 * 230, qUtf8Printable(trace)); + QVERIFY2(consigneA(s.borne) == 9, qUtf8Printable(trace)); + } +#endif +} + +void Simulation::testWaterfallChargerSettlesUnderThePhaseLimit() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // QUESTION 5 DU LOT DÉLESTAGE TRIPHASÉ. Hors heures creuses, sous le rule-based, le plafond + // de phase d'une borne est la place LIBRE au compteur (evPhaseAllowanceW()), sans lui rendre + // ce qu'elle tire. Une borne qui tient une bonne part de sa phase devrait donc alterner : + // pleine, la place libre tombe ; au plancher, elle remonte. + // + // Midi, sans tarif, surplus abondant : B et C exportent E A chacune, la maison tire H A sur A + // (déjà net du solaire de A). Limite 25 A, marge 10 % (seuil 22,5 A). Le surplus offert à la + // borne vaut 2E − H ≥ 16 A : c'est la phase qui borne, jamais le surplus. + // Attendu : 22,5 − H − 1 à l'ampère inférieur, plafonné à 16 A. + // + // La maison chargée tire 10,3 A, et non 10 : au premier cycle la borne est à l'arrêt, rien ne + // lui est rendu, et son plafond est la place libre ARRONDIE AU PLUS PROCHE par l'adaptateur + // (règle d'avant ce lot). À 10 A, 12,5 A deviennent 13 A, la phase passe à 23 A pour un seuil + // de 22,5 A, et L4 rabaisse d'un ampère. En production, la consigne se pose ensuite à 11 A ; + // ici, le harnais rappelle la protection sur le MÊME compteur (simulationCallUpdate()) et + // coupe la borne déjà au plancher, qui repart au cycle suivant — une boucle du harnais, pas + // du moteur. À 10,3 A, 12,2 A deviennent 12 A, sous le seuil, et la règle se lit seule. + FuseauDeTest fuseau("Europe/Paris"); + struct Cas { const char *quoi; BorneHc borne; double maisonA; double exportA; int attenduA; }; + const QList cas = { + {"mesurée, maison légère", BorneHc::Monophasee, 4, 12, 16}, + {"mesurée, maison chargée", BorneHc::Monophasee, 10.3, 14, 11}, + {"non mesurée, maison chargée", BorneHc::MonophaseeNonMesuree, 10.3, 14, 11}, + }; + constexpr int Cycles = 10; + QList> toutesConsignes; + QList> toutesPires; + QStringList traces; + for (const Cas &c : cas) { + ScenarioHc s = preparerScenarioHc(QStringLiteral("jour-phase-%1-%2").arg(static_cast(c.borne)).arg(qRound(c.maisonA * 10)), + BatterieHc::Aucune, QVariantList(), c.borne); + QVERIFY2(s.arb && s.borne, c.quoi); + QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", 25}, {"phaseSafetyMarginPercent", 10}})) + .toMap().value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + s.borne->setStateValue("pluggedIn", true); + const QList maison = {c.maisonA, -c.exportA, -c.exportA}; + + QList consignes; + QList pires; + QDateTime a = utcDateTime(QDate(2026, 6, 8), QTime(10, 0)); // midi à Paris + for (int k = 0; k < Cycles; ++k, a = a.addSecs(360)) { + cycleHc(s, a, compteurMaisonEtBorne(s.meter, s.borne, maison)); + consignes << consigneA(s.borne); + compteurMaisonEtBorne(s.meter, s.borne, maison); + pires << pirePhaseA(s.meter); + } + QStringList trace; + for (int k = 0; k < Cycles; ++k) + trace << QStringLiteral("%1 A (%2)").arg(consignes.at(k)).arg(pires.at(k)); + traces << trace.join(QStringLiteral(", ")); + qInfo().noquote() << "[rule-based, phase]" << c.quoi << ":" << traces.last(); + toutesConsignes << consignes; + toutesPires << pires; + } + + for (int i = 0; i < cas.size(); ++i) { + // Les deux premiers cycles posent la borne : le premier juge une borne à l'arrêt, qui + // n'a rien à se voir rendre. Ensuite la consigne TIENT, sous le seuil moins 1 A. + for (int k = 2; k < Cycles; ++k) { + const QByteArray ctx = QStringLiteral("%1 — cycle %2 : %3").arg(cas.at(i).quoi).arg(k).arg(traces.at(i)).toUtf8(); + QVERIFY2(toutesConsignes.at(i).at(k) == cas.at(i).attenduA, ctx.constData()); + QVERIFY2(toutesPires.at(i).at(k) <= 22.5 - 1 + 1e-6, ctx.constData()); + } + } +#endif +} + +void Simulation::testWaterfallChargerGetsNothingBackInOverload() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // LA GARDE DE DÉPASSEMENT de la règle de jour. Hors heures creuses, evPhaseAllowanceW() rend + // à la borne son tirage — mais pas sur une phase au-dessus du seuil. Le cas qui la justifie est + // celui de `testOverloadProtectionEcoMode` : des phases qui ne voient pas la borne. La maison + // sans la borne s'y calcule trop basse, et la place rendue maintiendrait la borne pendant le + // dépassement. + // + // Midi, sans tarif, limite 25 A, marge 10 % (seuil 22,5 A). La borne tire 16 A sur A ; la + // phase A du compteur lit 24 A, sans la borne ; B et C exportent 14 A. L4 la rabaisse + // d'abord, puis la stratégie décide sur ce même compteur. Avec la garde : la place libre, + // négative — la borne est tenue à son plancher, 6 A (1 380 W). Sans elle : 22,5 − (24 − E) − 1, + // soit 11 A avec E = 14 A après L4 (2 530 W), en plein dépassement. + FuseauDeTest fuseau("Europe/Paris"); + ScenarioHc s = preparerScenarioHc(QStringLiteral("jour-depassement"), BatterieHc::Aucune, + QVariantList(), BorneHc::Monophasee); + QVERIFY(s.arb && s.borne); + QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", 25}, {"phaseSafetyMarginPercent", 10}})) + .toMap().value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + const QString borne = s.borneId.toString(); + s.borne->setStateValue("pluggedIn", true); + s.borne->setStateValue("power", true); + s.borne->setStateValue("charging", true); + s.borne->setStateValue("maxChargingCurrent", 16); + s.borne->setStateValue("currentPhaseA", 16.0); + s.borne->setStateValue("currentPowerPhaseA", 3680.0); + s.borne->setStateValue("currentPower", 3680.0); + const QList phasesA = {24, -14, -14}; // aveugles à la borne + double totalW = 0; + const QStringList phases = {"A", "B", "C"}; + for (int i = 0; i < 3; ++i) { + s.meter->setStateValue(QStringLiteral("currentPhase%1").arg(phases.at(i)), phasesA.at(i)); + s.meter->setStateValue(QStringLiteral("currentPowerPhase%1").arg(phases.at(i)), phasesA.at(i) * 230); + totalW += phasesA.at(i) * 230; + } + + // La décision de la STRATÉGIE, lue dans la trame que publie le cycle : le harnais rappelle + // ensuite L4 sur le même compteur (simulationCallUpdate()), qui coupe la borne et republie. + QSignalSpy notifs(s.arb, &EnergyArbitrator::loadTelemetryChanged); + cycleHc(s, utcDateTime(QDate(2026, 6, 8), QTime(10, 0)), totalW); + QVariantMap strategie; + QStringList trace; + for (const QList &n : notifs) { + const QVariantMap t = telemetrieParId(n.first().toMap()).value(borne); + trace << QJsonDocument::fromVariant(t.value("decision")).toJson(QJsonDocument::Compact) + + QStringLiteral(" alloué %1 W").arg(t.value("allocatedW").toDouble()); + if (t.value("decision").toMap().value("code").toString() != QString(DecisionCode::OverloadThrottled)) + strategie = t; + } + qInfo().noquote() << "[dépassement, jour]" << trace.join(QStringLiteral(" ; ")); + QVERIFY2(!strategie.isEmpty(), qUtf8Printable(trace.join(" ; "))); + QVERIFY2(qRound(strategie.value("allocatedW").toDouble()) == 1380, qUtf8Printable(trace.join(" ; "))); +#endif +} + // ============================================================================================ // Lot délestage visible (2026-10-25) — la protection PUBLIE ce qu'elle fait, elle ne décide rien // de plus. Question de Patrick : « Si le délestage coupe une charge, est-ce que le client voit une @@ -12711,10 +13057,12 @@ void Simulation::testOverloadProtectionReasonOnTheCharger() QVERIFY2(!snap.contains("shedding"), "aucun délestage : le bloc doit être absent, pas à zéro"); QVERIFY(journal().isEmpty()); - // 1. RABAISSÉE — un four s'allume : 12 A de maison, 28 A sur la phase A pour 25. La - // protection rabaisse la borne du dépassement arrondi à l'ampère supérieur. + // 1. RABAISSÉE — un four s'allume : 9 A de maison, 25 A sur la phase A pour un seuil de + // 22,5 A (25 A moins la marge de sécurité de 10 %). La protection rabaisse la borne du + // dépassement arrondi à l'ampère supérieur. (12 A de maison avant la marge : 5,5 A de trop + // au seuil, deux gestes sur un compteur figé, et la borne passait sous son plancher.) QSignalSpy notifs(s.arb, &EnergyArbitrator::loadTelemetryChanged); - cycle(12); + cycle(9); const int tenusA = consigneA(s.borne); QVERIFY2(tenusA > 0 && tenusA < 16, qUtf8Printable(QString::number(tenusA))); QCOMPARE(decision().value("code").toString(), QString(DecisionCode::OverloadThrottled)); @@ -12724,15 +13072,16 @@ void Simulation::testOverloadProtectionReasonOnTheCharger() QCOMPARE(p.value("cut").toBool(), false); QCOMPARE(p.value("phase").toString(), QStringLiteral("A")); QCOMPARE(p.value("limitA").toInt(), 25); - QCOMPARE(p.value("overshootW").toInt(), 690); // (28 − 25) × 230 + QCOMPARE(p.value("thresholdA").toDouble(), 22.5); // le seuil, sous la marge de 10 % + QCOMPARE(p.value("overshootW").toInt(), 575); // (25 − 22,5) × 230 // Préséance L4 : l'action entière est celle de la protection — sans niveaux, et l'allocation // dit ce qu'elle laisse à la borne. QVERIFY(!entree().contains("levels")); QVERIFY(!decision().contains("level")); QCOMPARE(qRound(entree().value("allocatedW").toDouble()), tenusA * 230); QCOMPARE(renderFr(DecisionReason(DecisionCode::OverloadThrottled, p), "Borne"), - QStringLiteral("Borne abaissée à %1 A : le branchement atteignait sa limite (phase A, " - "25 A), dépassement de 690 W").arg(tenusA)); + QStringLiteral("Borne abaissée à %1 A : le branchement atteignait son seuil de protection " + "(phase A, 22,5 A sous une limite de 25 A), dépassement de 575 W").arg(tenusA)); // La notification est partie, et elle porte le résumé du journal. QVERIFY(!notifs.isEmpty()); QVariantMap resume = notifs.last().first().toMap().value("shedding").toMap(); @@ -12764,8 +13113,9 @@ void Simulation::testOverloadProtectionReasonOnTheCharger() QCOMPARE(p.value("fromA").toInt(), 16); QCOMPARE(qRound(entree().value("allocatedW").toDouble()), 0); const QString coupee = renderFr(DecisionReason(DecisionCode::OverloadThrottled, p), "Borne"); - QVERIFY2(coupee.startsWith(QStringLiteral("Borne coupée : le branchement atteignait sa limite " - "(phase A, 25 A)")), qUtf8Printable(coupee)); + QVERIFY2(coupee.startsWith(QStringLiteral("Borne coupée : le branchement atteignait son seuil de " + "protection (phase A, 22,5 A sous une limite de 25 A)")), + qUtf8Printable(coupee)); evts = journal(); QCOMPARE(evts.size(), 1); e = evts.first().toMap(); @@ -12868,10 +13218,11 @@ void Simulation::testOverloadProtectionOnAManualChargerIsLogged() QVERIFY(!telemetrieParId(snap).contains(borne)); // hors arbitrage : hors de loads[] QVERIFY(!snap.contains("shedding")); - // 1. 28 A sur la phase A : la protection rabaisse la borne, deux fois dans l'appel (dans le - // cycle, puis sur powerBalanceChanged — le compteur n'a pas encore bougé). + // 1. 25 A sur la phase A, seuil à 22,5 A (marge de sécurité de 10 %) : la protection rabaisse + // la borne, deux fois dans l'appel (dans le cycle, puis sur powerBalanceChanged — le + // compteur n'a pas encore bougé). À 28 A, 5,5 A de trop deux fois la passaient sous 6 A. QSignalSpy notifs(s.arb, &EnergyArbitrator::loadTelemetryChanged); - cycle(28); + cycle(25); const int tenusA = consigneA(s.borne); QVERIFY2(tenusA > 0 && tenusA < 16, qUtf8Printable(QString::number(tenusA))); QVERIFY(!telemetrieParId(snap).contains(borne)); // toujours hors de loads[] @@ -12886,7 +13237,7 @@ void Simulation::testOverloadProtectionOnAManualChargerIsLogged() QCOMPARE(e.value("before").toMap().value("mechanism").toMap().value("currentA").toInt(), 16); QCOMPARE(e.value("after").toMap().value("mechanism").toMap().value("currentA").toInt(), tenusA); QCOMPARE(e.value("phase").toString(), QStringLiteral("A")); - QCOMPARE(e.value("overshootW").toInt(), 690); + QCOMPARE(e.value("overshootW").toInt(), 575); // (25 − 22,5) × 230 QVERIFY(!e.contains("endedAt")); // 2. La maison redescend : verifyOverloadProtectionRecovery() rend les 16 A manuels. La tenue @@ -12992,7 +13343,8 @@ void Simulation::testSheddingLogCoversEveryMechanism() QCOMPARE(qRound(tele.value("ecs-delestee").value("mechanism").toMap().value("stageW").toDouble()), 2000); QVERIFY(!snap.contains("shedding")); - // DÉPASSEMENT de 2 300 W sur la phase A (35 A pour 25) : L4 déleste en ordre inverse du rang, + // DÉPASSEMENT de 2 875 W sur la phase A (35 A pour un seuil de 22,5 A — 25 A moins la marge + // de sécurité de 10 %) : L4 déleste en ordre inverse du rang, // le chauffe-eau (rang 3, 2 000 W) puis la PAC (rang 2, 3 000 W). QSignalSpy notifs(arb, &EnergyArbitrator::loadTelemetryChanged); const QDateTime t2 = t0.addSecs(3610); @@ -13025,7 +13377,7 @@ void Simulation::testSheddingLogCoversEveryMechanism() for (const QVariantMap &ev : {ecs, pac}) { QCOMPARE(ev.value("source").toString(), QStringLiteral("connection")); QCOMPARE(ev.value("phase").toString(), QStringLiteral("A")); - QCOMPARE(ev.value("overshootW").toInt(), 2300); + QCOMPARE(ev.value("overshootW").toInt(), 2875); // (35 − 22,5) × 230 : au seuil, marge de 10 % QCOMPARE(ev.value("startedAt").toString(), t2.toUTC().toString(Qt::ISODate)); QVERIFY(!ev.contains("endedAt")); } @@ -13129,3 +13481,471 @@ void Simulation::testSheddingLogKeepsTheLastEpisodes() qUtf8Printable(QJsonDocument::fromVariant(v).toJson(QJsonDocument::Compact))); #endif } + +// ============================================================================================ +// Lot délestage triphasé (2026-10-27). Essai de surcharge sur une installation d'essai : borne +// triphasée comptée en monophasé après un délestage, protection lente, chauffe-eau délesté qui ne +// coupe pas, délestage relâché sur une surcharge qui revient. Chaque test de ce bloc a été vu +// ROUGE sur 1.15.2+etm76, sauf celui du chauffe-eau, qui établit que le moteur n'y est pour rien. +// ============================================================================================ + +Simulation::BancTriphase Simulation::preparerBancTriphase(const QString &nom, const QString &phases, bool bascule) +{ + BancTriphase b; + const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-triphase-" + nom + ".json"; + QFile::remove(cfgPath); + qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); + cleanupTestCase(); + m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; + initTestCase(); + + b.arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); + if (!b.arb) + return b; + ThingManager *tm = NymeaCore::instance()->thingManager(); + const QUuid meterId = addMeter(26811); + m_experiencePlugin->energyManager()->setRootMeter(meterId); + b.compteur = tm->findConfiguredThing(meterId); + b.compteur->setStateValue("connected", true); + + const QUuid borneId = bascule ? addChargerWithPhaseCountSwitching(phases, 16, 26812) + : addCharger(phases, 16, 26812); + b.borneId = borneId.toString(); + b.borne = tm->findConfiguredThing(borneId); + armerBorne(b.borne); + if (bascule && phases == QLatin1String("ABC")) { + // Le mock d'une borne à bascule démarre en monophasé (desiredPhaseCount = 1). + b.borne->setStateValue("desiredPhaseCount", 3); + b.borne->setStateValue("usedPhases", QStringLiteral("ABC")); + b.borne->setStateValue("phaseCount", 3); + } + // AUCUNE voiture assignée : c'est le cas relevé, et celui que le manager ne prépare pas. + const QVariantMap info{{"evChargerId", borneId}, {"chargingMode", "ChargingModeEco"}}; + injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}})); + // Aucun démarrage sous tolérance : la lecture des puissances reste celle du budget. + injectAndWait("NymeaEnergy.SetAcquisitionTolerance", QVariantMap({{"acquisitionTolerance", 1.0}})); + injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", 30}})); + return b; +} + +void Simulation::testOverloadLeavesTheChargerItsPhases_data() +{ + QTest::addColumn("phases"); + QTest::addColumn("bascule"); + QTest::addColumn("phasesAttendues"); + // Le cas relevé : une borne triphasée qui ne sait PAS changer de phases. + QTest::newRow("triphasée, sans bascule") << "ABC" << false << 3; + QTest::newRow("triphasée, à bascule") << "ABC" << true << 3; + QTest::newRow("monophasée") << "A" << false << 1; +} + +void Simulation::testOverloadLeavesTheChargerItsPhases() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // DÉFAUT 1. Borne triphasée 16 A sans voiture assignée, limite de phase 30 A. Avant le + // délestage : 11 040 W alloués, 16 A × 3, 11 040 W mesurés. Après : 3 680 W alloués et + // attendus, 16 A commandés, 11 040 W mesurés, jusqu'à la fin de l'essai. La borne était + // devenue monophasée dans les comptes du moteur, et nulle part ailleurs. + // + // Ce qui est tenu est une CONCORDANCE entre trois sources qui ne se recopient pas : ce que la + // cascade alloue, ce que le moteur dit avoir commandé (`command.expectedW`), et ce que la + // borne publie (`mechanism.currentA` × 230 × `mechanism.phaseCount`). + QFETCH(QString, phases); + QFETCH(bool, bascule); + QFETCH(int, phasesAttendues); + BancTriphase b = preparerBancTriphase(QStringLiteral("phases-%1-%2").arg(phases).arg(bascule), + phases, bascule); + QVERIFY(b.arb && b.compteur && b.borne); + QCOMPARE(b.borne->stateValue("phaseCount").toInt(), phasesAttendues); + + QDateTime a = utcDateTime(QDate(2026, 6, 8), QTime(10, 0)); + QVariantMap snap; + const auto cycle = [&](double maisonA) { + a = a.addSecs(360); // au-delà des verrous de la borne + b.compteur->setStateValue("currentPower", compteurVoitLaBorne(b.compteur, b.borne, maisonA)); + b.arb->recordMeterUpdate(a); + b.arb->simulationCallUpdate(a); + QCoreApplication::processEvents(); + snap = b.arb->loadTelemetry(); + }; + const auto entree = [&]() { return telemetrieParId(snap).value(b.borneId); }; + const int pleineW = 16 * 230 * phasesAttendues; + const auto concordance = [&](const char *quand) { + const QVariantMap e = entree(); + const QVariantMap meca = e.value("mechanism").toMap(); + const int courant = meca.value("currentA").toInt(); + const QString ctx = QStringLiteral("%1 — %2").arg(QString::fromUtf8(quand), + QString::fromUtf8(QJsonDocument::fromVariant(e).toJson(QJsonDocument::Compact))); + QVERIFY2(meca.value("phaseCount").toInt() == phasesAttendues, qUtf8Printable(ctx)); + QVERIFY2(qRound(e.value("allocatedW").toDouble()) == courant * 230 * phasesAttendues, + qUtf8Printable(ctx)); + QVERIFY2(qRound(e.value("command").toMap().value("expectedW").toDouble()) + == courant * 230 * phasesAttendues, qUtf8Printable(ctx)); + }; + + // 0. TÉMOIN — la maison exporte 25 A par phase : la borne prend tout ce qu'elle peut. + cycle(-25); + cycle(-25); + QCOMPARE(consigneA(b.borne), 16); + QCOMPARE(qRound(entree().value("allocatedW").toDouble()), pleineW); + concordance("avant le délestage"); + if (QTest::currentTestFailed()) + return; + + // 1. SURCHARGE — 17 A de maison s'ajoutent aux 16 A de la borne : 33 A pour 30. La protection + // rabaisse la borne. Sur 1.15.2+etm76, ce geste laissait dans la table du manager un + // ChargingProcessInfo PAR DÉFAUT (1 phase, pas de bascule) pour une borne qu'il ne prépare + // pas, et `evTargetPhases()` le prenait pour une préparation de ce cycle. + cycle(17); + QVERIFY2(consigneA(b.borne) < 16, qUtf8Printable(QString::number(consigneA(b.borne)))); + + // 2. RETOUR — la maison exporte de nouveau : la borne retrouve sa pleine puissance, et ses + // phases dans les comptes. + cycle(-25); + cycle(-25); + cycle(-25); + QCOMPARE(consigneA(b.borne), 16); + concordance("après le délestage"); + QCOMPARE(qRound(entree().value("allocatedW").toDouble()), pleineW); + QCOMPARE(entree().value("command").toMap().value("divergent").toBool(), false); +#endif +} + +void Simulation::testAnUnassignedCarTakesItsPhasesAway() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // DÉFAUT 1, second chemin vers la même erreur, sans aucune surcharge. Une voiture MONOPHASÉE + // est assignée à une borne triphasée sans bascule : le manager prépare la borne et retient + // min(3, 1) = 1 phase, à juste titre. Puis la voiture est désassignée — un invité branche une + // triphasée. Le manager cesse de préparer la borne ; son ChargingProcessInfo de la veille, lui, + // reste dans la table, et « 1 phase » se lisait comme une préparation de ce cycle. + BancTriphase b = preparerBancTriphase(QStringLiteral("voiture-mono"), QStringLiteral("ABC"), false); + QVERIFY(b.arb && b.compteur && b.borne); + ThingManager *tm = NymeaCore::instance()->thingManager(); + const QUuid voitureId = addCar(26813); + Thing *voiture = tm->findConfiguredThing(voitureId); + QVERIFY(voiture); + voiture->setStateValue("batteryLevel", 30); + voiture->setStateValue("capacity", 60); + voiture->setStateValue("minChargingCurrent", 6); + voiture->setStateValue("phaseCount", 1); + QVariantMap info{{"evChargerId", b.borneId}, {"assignedCarId", voitureId}, + {"chargingMode", "ChargingModeEco"}, {"targetPercentage", 100}}; + QCOMPARE(injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}})) + .toMap().value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + + QDateTime a = utcDateTime(QDate(2026, 6, 8), QTime(10, 0)); + QVariantMap snap; + const auto cycle = [&](double maisonA) { + a = a.addSecs(360); + b.compteur->setStateValue("currentPower", compteurVoitLaBorne(b.compteur, b.borne, maisonA)); + b.arb->recordMeterUpdate(a); + b.arb->simulationCallUpdate(a); + QCoreApplication::processEvents(); + snap = b.arb->loadTelemetry(); + }; + const auto entree = [&]() { return telemetrieParId(snap).value(b.borneId); }; + + // La voiture monophasée : la borne est comptée sur UNE phase. C'est juste, et c'est le témoin. + cycle(-25); + cycle(-25); + QCOMPARE(qRound(entree().value("allocatedW").toDouble()), 16 * 230); + + // L'invité : plus de voiture assignée. La borne tire sur ses trois phases. + info.remove("assignedCarId"); + QCOMPARE(injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}})) + .toMap().value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + cycle(-25); + cycle(-25); + const QVariantMap e = entree(); + QVERIFY2(qRound(e.value("allocatedW").toDouble()) + == e.value("mechanism").toMap().value("currentA").toInt() * 230 * 3, + qUtf8Printable(QJsonDocument::fromVariant(e).toJson(QJsonDocument::Compact))); + QCOMPARE(qRound(e.value("allocatedW").toDouble()), 16 * 230 * 3); +#endif +} + +void Simulation::testCommandDivergenceSeesAPhaseCountError() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // `command.divergent` est le filet qui aurait dû signaler le défaut 1 : 3 680 W attendus, + // 11 040 W mesurés. Ici l'erreur de phases est posée par le MATÉRIEL, pas par le moteur : une + // borne déclarée monophasée tire 16 A sur trois phases. Un écart de ce facteur doit se lire + // divergent dès le cycle suivant — c'est une descente non suivie, jugée sur 7 s. + BancTriphase b = preparerBancTriphase(QStringLiteral("divergence-x3"), QStringLiteral("A"), false); + QVERIFY(b.arb && b.compteur && b.borne); + QDateTime a = utcDateTime(QDate(2026, 6, 8), QTime(10, 0)); + QVariantMap snap; + bool ment = false; + const auto cycle = [&](double maisonA) { + a = a.addSecs(360); + if (ment) { + // La borne tire sur TROIS phases ce qu'elle publie comme monophasé. + for (const char *ph : {"A", "B", "C"}) { + b.borne->setStateValue(QStringLiteral("currentPhase%1").arg(ph), 16.0); + b.borne->setStateValue(QStringLiteral("currentPowerPhase%1").arg(ph), 16.0 * 230); + } + b.borne->setStateValue("currentPower", 16.0 * 230 * 3); + } + b.compteur->setStateValue("currentPower", compteurVoitLaBorne(b.compteur, b.borne, maisonA)); + b.arb->recordMeterUpdate(a); + b.arb->simulationCallUpdate(a); + QCoreApplication::processEvents(); + snap = b.arb->loadTelemetry(); + }; + const auto commande = [&]() { + return telemetrieParId(snap).value(b.borneId).value("command").toMap(); + }; + + cycle(-25); + cycle(-25); + QCOMPARE(consigneA(b.borne), 16); + QCOMPARE(commande().value("divergent").toBool(), false); // témoin : concordance + + ment = true; + cycle(-25); + cycle(-25); + const QVariantMap c = commande(); + QVERIFY2(c.value("divergent").toBool(), + qUtf8Printable(QJsonDocument::fromVariant(c).toJson(QJsonDocument::Compact))); + QCOMPARE(qRound(c.value("expectedW").toDouble()), 16 * 230); +#endif +} + +void Simulation::testOverloadReactsToTheTotalWhenPhasesLag() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // DÉFAUT 2. Sur l'installation d'essai, la maison est montée à 22,3 kW importés — 32,3 A par + // phase — et le délestage ne s'est ouvert que 1 min 45 plus tard. Les courants PAR PHASE que + // le moteur recevait étaient en retard sur le total : 29,1 A, puis 30,4 A au cycle suivant, + // pour 32,3 A réels. La protection jugeait sur eux seuls et attendait qu'ils franchissent la limite. + // + // Or le total suffit à prouver la surcharge : la phase la plus chargée porte AU MOINS le tiers + // du total importé. Ici, 26 A lus sur chaque phase pour 22 277 W au total : 32,3 A en moyenne, + // au-delà de la limite, quoi que disent les phases. + BancTriphase b = preparerBancTriphase(QStringLiteral("phases-en-retard"), QStringLiteral("ABC"), false); + QVERIFY(b.arb && b.compteur && b.borne); + ThingManager *tm = NymeaCore::instance()->thingManager(); + const QUuid r1 = addPowerSwitch(1000, 26814); + const QUuid r2 = addPowerSwitch(2000, 26815); + QVERIFY(tm->findConfiguredThing(r1) && tm->findConfiguredThing(r2)); + QVariantList l = injectAndWait("NymeaEnergy.GetLoadConfig").toMap() + .value("params").toMap().value("loadConfigs").toList(); + for (int i = 0; i < l.count(); ++i) { + QVariantMap m = l.at(i).toMap(); + if (m.value("id").toString() == b.borneId) m.insert("priority", 1); + l[i] = m; + } + l.append(QVariantMap{{"id", "ecs-retard"}, {"label", "Chauffe-eau"}, {"adapter", "relay-router"}, + {"mode", "fixed"}, {"priority", 2}, {"enabled", true}, {"minOnS", 0}, {"minOffS", 0}, + {"relays", QVariantList{QVariantMap{{"thingId", r1.toString()}, {"powerW", 1000}}, + QVariantMap{{"thingId", r2.toString()}, {"powerW", 2000}}}}}); + QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}})) + .toMap().value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + + QDateTime a = utcDateTime(QDate(2026, 6, 8), QTime(10, 0)); + QVariantMap snap; + const auto lancer = [&]() { + b.arb->recordMeterUpdate(a); + b.arb->simulationCallUpdate(a); + QTest::qWait(20); + QCoreApplication::processEvents(); + snap = b.arb->loadTelemetry(); + }; + const auto ecsW = [&]() { + return qRound(telemetrieParId(snap).value("ecs-retard").value("mechanism").toMap() + .value("stageW").toDouble()); + }; + // Le surplus sert les deux : la borne à 16 A × 3, le chauffe-eau à 3 kW sur la phase A. + for (int k = 0; k < 3; ++k) { + a = a.addSecs(360); + double totalW = compteurVoitLaBorne(b.compteur, b.borne, -25); + const double ecsA = (k == 0 ? 0 : ecsW()) / 230.0; + b.compteur->setStateValue("currentPhaseA", b.compteur->stateValue("currentPhaseA").toDouble() + ecsA); + b.compteur->setStateValue("currentPowerPhaseA", b.compteur->stateValue("currentPhaseA").toDouble() * 230); + totalW += ecsA * 230; + b.compteur->setStateValue("currentPower", totalW); + lancer(); + } + QCOMPARE(consigneA(b.borne), 16); + QCOMPARE(ecsW(), 3000); + + // La surcharge arrive : le TOTAL la porte (22 277 W, 32,3 A en moyenne), les PHASES pas + // encore (26 A lus sur chacune, sous la limite de 30 A comme sous le seuil de 27 A). + a = a.addSecs(60); + for (const char *ph : {"A", "B", "C"}) { + b.compteur->setStateValue(QStringLiteral("currentPhase%1").arg(ph), 26.0); + b.compteur->setStateValue(QStringLiteral("currentPowerPhase%1").arg(ph), 26.0 * 230); + } + b.compteur->setStateValue("currentPower", 22277.0); + lancer(); + // Ce sont les GESTES DE LA PROTECTION qui sont jugés, au journal des délestages : sous le + // rule-based, l'import coupe aussi la cascade faute de surplus, et la consigne finale ne dirait + // pas qui a agi. La protection a rabaissé la borne et délesté le chauffe-eau, sur le dépassement + // que le total PROUVE : (32,3 − 27) × 230 ≈ 1 215 W par phase. + const QVariantList evts = evenementsDelestage(injectAndWait("NymeaEnergy.GetSheddingLog")); + QHash parCharge; + for (const QVariant &v : evts) + parCharge.insert(v.toMap().value("loadId").toString(), v.toMap()); + const QString journal = QString::fromUtf8(QJsonDocument::fromVariant(evts).toJson(QJsonDocument::Compact)); + QVERIFY2(parCharge.contains(b.borneId), qUtf8Printable(journal)); + QVERIFY2(parCharge.contains(QStringLiteral("ecs-retard")), qUtf8Printable(journal)); + QVERIFY2(parCharge.value(b.borneId).value("after").toMap().value("mechanism").toMap() + .value("currentA").toInt() <= 10, qUtf8Printable(journal)); + QVERIFY2(qAbs(parCharge.value("ecs-retard").value("overshootW").toInt() - 1215) <= 5, qUtf8Printable(journal)); +#endif +} + +void Simulation::testSheddingOpensTheRelaysUnderMinOn() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // DÉFAUT 3 — à PROUVER, pas à corriger d'office. Sur l'installation d'essai, le chauffe-eau + // (1 + 2 kW, minOn 60 s, minOff 60 s) a été délesté à 2 000 puis 1 000 W, le journal disait + // « after stageW 1000 », et la mesure est restée à 3 000 W plus de deux minutes, `lock: minOn` + // affiché. Deux questions : la protection franchit-elle le verrou de marche minimale, et la + // commande des relais part-elle ? Ce test répond aux deux sur les relais eux-mêmes. + BancTriphase b = preparerBancTriphase(QStringLiteral("ecs-minon"), QStringLiteral("A"), false); + QVERIFY(b.arb && b.compteur); + b.borne->setStateValue("pluggedIn", false); // la borne hors jeu : seul le chauffe-eau compte + ThingManager *tm = NymeaCore::instance()->thingManager(); + const QUuid r1 = addPowerSwitch(1000, 26816); + const QUuid r2 = addPowerSwitch(2000, 26817); + Thing *relais1 = tm->findConfiguredThing(r1); + Thing *relais2 = tm->findConfiguredThing(r2); + QVERIFY(relais1 && relais2); + QVariantList l = injectAndWait("NymeaEnergy.GetLoadConfig").toMap() + .value("params").toMap().value("loadConfigs").toList(); + l.append(QVariantMap{{"id", "ecs-minon"}, {"label", "Chauffe-eau"}, {"adapter", "relay-router"}, + {"mode", "fixed"}, {"priority", 2}, {"enabled", true}, {"minOnS", 60}, {"minOffS", 60}, + {"relays", QVariantList{QVariantMap{{"thingId", r1.toString()}, {"powerW", 1000}}, + QVariantMap{{"thingId", r2.toString()}, {"powerW", 2000}}}}}); + QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}})) + .toMap().value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + + QVariantMap snap; + const auto cycle = [&](const QDateTime &at, double phaseA, double totalW) { + poserPhaseA(b.compteur, phaseA); + b.compteur->setStateValue("currentPower", totalW); + b.arb->recordMeterUpdate(at); + b.arb->simulationCallUpdate(at); + QTest::qWait(20); + QCoreApplication::processEvents(); + snap = b.arb->loadTelemetry(); + return telemetrieParId(snap).value("ecs-minon"); + }; + const auto fermesW = [&]() { + return (relais1->stateValue("power").toBool() ? 1000 : 0) + + (relais2->stateValue("power").toBool() ? 2000 : 0); + }; + + // Armement à froid (ECS-412), puis le surplus enclenche les deux relais. + const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(10, 0)); + cycle(t0, -20, -6000); + QVariantMap e = cycle(t0.addSecs(120), -20, -6000); + QCOMPARE(qRound(e.value("mechanism").toMap().value("stageW").toDouble()), 3000); + QCOMPARE(fermesW(), 3000); + + // 20 s plus tard, minOn armé : 33 A sur la phase A. La protection doit franchir le verrou. + e = cycle(t0.addSecs(140), 33, 33 * 230 + 4 * 230); + const QVariantMap motif = e.value("decision").toMap(); + QCOMPARE(motif.value("code").toString(), QString(DecisionCode::DrawCap)); + QVERIFY2(motif.value("params").toMap().value("minOnBypassed").toBool(), + qUtf8Printable(QJsonDocument::fromVariant(motif).toJson(QJsonDocument::Compact))); + const int palierW = qRound(e.value("mechanism").toMap().value("stageW").toDouble()); + QVERIFY2(palierW < 3000, qUtf8Printable(QString::number(palierW))); + // …et la commande est PARTIE : les relais disent le palier publié. + QCOMPARE(fermesW(), palierW); + + // La surcharge dure : le cycle suivant continue de délester, sur les relais aussi. + e = cycle(t0.addSecs(160), 33, 33 * 230 + 4 * 230); + QCOMPARE(fermesW(), qRound(e.value("mechanism").toMap().value("stageW").toDouble())); +#endif +} + +void Simulation::testPhaseSafetyMargin() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // DÉCISION DE PATRICK (2026-10-27) — le moteur vise 90 % de la limite de phase : 27 A pour + // 30 A. Pour le plafond qu'il déclare et alloue, et pour le seuil où la protection agit. + // Réglable, persistée avec la limite, publiée en lecture. + BancTriphase b = preparerBancTriphase(QStringLiteral("marge"), QStringLiteral("ABC"), false); + QVERIFY(b.arb && b.compteur && b.borne); + const auto lire = [&]() { + return injectAndWait("NymeaEnergy.GetPhasePowerLimit").toMap().value("params").toMap(); + }; + const auto ecrire = [&](const QVariantMap &p) { + return injectAndWait("NymeaEnergy.SetPhasePowerLimit", p).toMap().value("params").toMap() + .value("energyError").toString(); + }; + + // 1. Lecture : 10 % par défaut, et le seuil qui en résulte. + QVariantMap g = lire(); + QCOMPARE(g.value("phasePowerLimit").toUInt(), 30u); + QCOMPARE(g.value("phaseSafetyMarginPercent").toUInt(), 10u); + QCOMPARE(g.value("effectivePhaseLimit").toDouble(), 27.0); + + // 2. La protection agit à 90 % : 28 A par phase, sous la limite de 30, au-dessus du seuil. + QDateTime a = utcDateTime(QDate(2026, 6, 8), QTime(10, 0)); + const auto cycle = [&](double maisonA) { + a = a.addSecs(360); + b.compteur->setStateValue("currentPower", compteurVoitLaBorne(b.compteur, b.borne, maisonA)); + b.arb->recordMeterUpdate(a); + b.arb->simulationCallUpdate(a); + QCoreApplication::processEvents(); + }; + cycle(-25); + cycle(-25); + QCOMPARE(consigneA(b.borne), 16); + cycle(12); // 12 + 16 = 28 A + // Le geste de la protection, lu au journal : la cascade, elle, coupe aussi la borne faute de + // surplus, et la consigne finale ne dirait pas qui a agi. À 30 A, il n'y aurait aucun geste. + const QVariantList evts = evenementsDelestage(injectAndWait("NymeaEnergy.GetSheddingLog")); + QCOMPARE(evts.size(), 1); + QCOMPARE(evts.first().toMap().value("loadId").toString(), b.borneId); + QCOMPARE(evts.first().toMap().value("overshootW").toInt(), 230); // (28 − 27) × 230 + + // 3. Écriture : la marge seule ne bouge pas la limite ; notifiée, persistée. + enableNotifications({"NymeaEnergy"}); + QSignalSpy notif(m_mockTcpServer, &MockTcpServer::outgoingData); + QCOMPARE(ecrire({{"phasePowerLimit", 30}, {"phaseSafetyMarginPercent", 15}}), QString("EnergyErrorNoError")); + if (notif.isEmpty()) + notif.wait(); + g = lire(); + QCOMPARE(g.value("phaseSafetyMarginPercent").toUInt(), 15u); + QCOMPARE(g.value("effectivePhaseLimit").toDouble(), 25.5); + QCOMPARE(EnergySettings().value("phaseSafetyMarginPercent").toUInt(), 15u); + const QVariantList notifs = checkNotifications(notif, "NymeaEnergy.PhasePowerLimitChanged"); + QVERIFY(!notifs.isEmpty()); + QCOMPARE(notifs.last().toMap().value("params").toMap().value("phaseSafetyMarginPercent").toUInt(), 15u); + QCOMPARE(notifs.last().toMap().value("params").toMap().value("effectivePhaseLimit").toDouble(), 25.5); + + // 4. Bornes : 0 et 25 acceptés, au-delà refusé et sans effet. + QCOMPARE(ecrire({{"phasePowerLimit", 30}, {"phaseSafetyMarginPercent", 0}}), QString("EnergyErrorNoError")); + QCOMPARE(lire().value("effectivePhaseLimit").toDouble(), 30.0); + QCOMPARE(ecrire({{"phasePowerLimit", 30}, {"phaseSafetyMarginPercent", 25}}), QString("EnergyErrorNoError")); + QCOMPARE(ecrire({{"phasePowerLimit", 30}, {"phaseSafetyMarginPercent", 26}}), + QString("EnergyErrorInvalidParameter")); + QCOMPARE(lire().value("phaseSafetyMarginPercent").toUInt(), 25u); + // Sans la marge, l'écriture de la limite garde la marge réglée. + QCOMPARE(ecrire({{"phasePowerLimit", 32}}), QString("EnergyErrorNoError")); + QCOMPARE(lire().value("phaseSafetyMarginPercent").toUInt(), 25u); + QCOMPARE(lire().value("effectivePhaseLimit").toDouble(), 24.0); + QCOMPARE(ecrire({{"phasePowerLimit", 30}, {"phaseSafetyMarginPercent", 10}}), QString("EnergyErrorNoError")); +#endif +} diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index 874a70f..94557ca 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -74,13 +74,27 @@ private: }; //! Quelle borne pose le scénario (lot e2) : 16 A, monophasée, triphasée, ou triphasée à //! bascule de phases. La voiture suit : monophasée avec la première, triphasée sinon. - enum class BorneHc { Monophasee, Triphasee, Bascule }; + //! Lot délestage V2C : les mêmes, monophasée sur A et triphasée, SANS courants par phase + //! lisibles par le moteur (`currentL1..3`, façon V2C Trydan). + enum class BorneHc { Monophasee, Triphasee, Bascule, MonophaseeNonMesuree, TriphaseeNonMesuree }; //! Serveur neuf, compteur, borne 16 A « Éco avec minimum » (voiture débranchée), batterie. ScenarioHc preparerScenarioHc(const QString &nom, BatterieHc batterie, const QVariantList &tarif, BorneHc borne = BorneHc::Monophasee); //! Un cycle à l'instant \p a, compteur à \p compteurW (import > 0) ; rend la trame publiée. QVariantMap cycleHc(ScenarioHc &s, const QDateTime &a, double compteurW); + // ── Lot délestage triphasé — une borne SANS voiture assignée, limite de phase 30 A ──────── + struct BancTriphase { + EnergyArbitrator *arb = nullptr; + Thing *compteur = nullptr; + Thing *borne = nullptr; + QString borneId; + }; + //! Serveur neuf, compteur, borne 16 A « Éco » branchée et sans voiture (le cas de l'invité, + //! LM-1208-b). \p phases : câblage du mock ; \p bascule : la borne porte `desiredPhaseCount` + //! (mise en triphasé si elle est câblée sur trois). + BancTriphase preparerBancTriphase(const QString &nom, const QString &phases, bool bascule); + // ── Recrédit — une borne mesurée par un compteur dédié, cycle par cycle ────────────────── //! Ce que la télémétrie dit de la borne à un cycle, et ce qu'elle a reçu. struct CycleBorne { @@ -339,6 +353,20 @@ private slots: void testOffPeakFullPowerAtDawnStaysInTheCeiling(); //! Lot e2, suite — pendant une bascule de phases, le plafond ne rend rien du courant de la borne. void testOffPeakFullPowerGivesNothingBackWhileSwitchingPhases(); + //! Lot délestage V2C — borne SANS courants par phase lisibles (façon V2C Trydan), limite 25 A, + //! marge 10 % : la consigne se pose au plafond qui tient compte de la borne, sans alterner + //! (monophasée sur A, triphasée ; maison seule, puis lave-vaisselle sur A). + void testOffPeakUnmeteredChargerSettlesUnderThePhaseLimit(); + //! Lot délestage V2C — quand l'attribution n'est pas sûre, rien n'est rendu : triphasée avec + //! une voiture monophasée (phases visées ≠ phases annoncées), borne qui publie des courants + //! par phase nuls alors qu'elle tire. + void testUnmeteredChargerGivesNothingBackWhenUnsure(); + //! Lot délestage V2C (question 5) — hors heures creuses, sous le rule-based, une borne limitée + //! par sa phase se pose sous la limite sans alterner, maison légère ou chargée. + void testWaterfallChargerSettlesUnderThePhaseLimit(); + //! Lot délestage V2C (question 5) — EN DÉPASSEMENT, la règle de jour ne rend rien : phases du + //! compteur aveugles à la borne, la stratégie la tient à son plancher. + void testWaterfallChargerGetsNothingBackInOverload(); //! TOUTE méthode Get* du namespace rend ce que sa déclaration promet. void testEveryGetterReturnsWhatItDeclares(); //! Le snapshot sert la DÉCISION : le canal, jamais la table — et l'absence n'est pas zéro. @@ -385,6 +413,23 @@ private slots: //! N + 1 épisodes : le plus ancien sort, les identifiants ne sont jamais réutilisés. void testSheddingLogKeepsTheLastEpisodes(); + // ── Lot délestage triphasé (2026-10-27) ───────────────────────────────── + //! Après une surcharge, la borne garde ses phases dans les comptes : allocation, commande et + //! matériel disent le même nombre de phases — triphasée sans bascule, à bascule, monophasée. + void testOverloadLeavesTheChargerItsPhases_data(); + void testOverloadLeavesTheChargerItsPhases(); + //! Une voiture monophasée désassignée ne laisse pas « 1 phase » à une borne triphasée. + void testAnUnassignedCarTakesItsPhasesAway(); + //! Une borne qui tire sur trois phases ce que le moteur croit commandé sur une : divergente. + void testCommandDivergenceSeesAPhaseCountError(); + //! Des courants par phase en retard sur le total ne retardent pas la protection. + void testOverloadReactsToTheTotalWhenPhasesLag(); + //! Délesté sous son minOn, le chauffe-eau franchit le verrou ET ses relais s'ouvrent. + void testSheddingOpensTheRelaysUnderMinOn(); + //! La marge de sécurité : 10 % par défaut, réglable et bornée, persistée, et c'est à 90 % + //! de la limite que la protection agit. + void testPhaseSafetyMargin(); + void printStates(Thing *thing); void updateChargerMeter(Thing *thing); diff --git a/tests/mocks/plugins/energymocks/integrationpluginenergymocks.cpp b/tests/mocks/plugins/energymocks/integrationpluginenergymocks.cpp index e90a4ab..54fb5b9 100644 --- a/tests/mocks/plugins/energymocks/integrationpluginenergymocks.cpp +++ b/tests/mocks/plugins/energymocks/integrationpluginenergymocks.cpp @@ -187,6 +187,43 @@ void IntegrationPluginEnergyMocks::setupThing(ThingSetupInfo *info) return; + } else if (thing->thingClassId() == chargerUnmeteredThingClassId) { + // Lot délestage V2C — une borne qui mesure sa puissance mais PAS ses phases au sens du + // moteur : `currentL1..3` (noms du plugin V2C Trydan), jamais `currentPhaseX`. Son nombre + // de phases est celui de son câblage, fixe : aucune bascule. + EnergyMockController *controller = new EnergyMockController(thing, this); + ParamType paramType = thing->thingClass().paramTypes().findByName("port"); + quint16 port = thing->paramValue(paramType.id()).toUInt(); + if (!controller->listen(QHostAddress::Any, port)) { + qCWarning(dcEnergyMocks()) << "Failed to start mock controller on port" << controller->errorString(); + delete controller; + info->finish(Thing::ThingErrorThingInUse); + return; + } + + connect(controller, &EnergyMockController::updateStateRequestReceived, thing, [=](const QUrlQuery &query){ + if (query.hasQueryItem("connected")) + thing->setStateValue("connected", QVariant(query.queryItemValue("connected")).toBool()); + + if (query.hasQueryItem("power")) + thing->setStateValue("power", QVariant(query.queryItemValue("power")).toBool()); + + if (query.hasQueryItem("pluggedIn")) + thing->setStateValue("pluggedIn", QVariant(query.queryItemValue("pluggedIn")).toBool()); + + if (query.hasQueryItem("maxChargingCurrent")) + thing->setStateValue("maxChargingCurrent", QVariant(query.queryItemValue("maxChargingCurrent")).toInt()); + + updateUnmeteredChargerMeter(thing); + }); + + m_controllers.insert(thing, controller); + info->finish(Thing::ThingErrorNoError); + + thing->setStateMaxValue("maxChargingCurrent", QVariant(thing->paramValue("maxChargingCurrentUpperLimit")).toInt()); + updateUnmeteredChargerMeter(thing); + return; + } else if (thing->thingClassId() == simpleChargerThingClassId) { EnergyMockController *controller = new EnergyMockController(thing, this); ParamType paramType = thing->thingClass().paramTypes().findByName("port"); @@ -699,6 +736,15 @@ void IntegrationPluginEnergyMocks::executeAction(ThingActionInfo *info) } + if (thing->thingClassId() == chargerUnmeteredThingClassId) { + if (actionType.name() == "maxChargingCurrent") + thing->setStateValue("maxChargingCurrent", + action.paramValue(actionType.paramTypes().findByName("maxChargingCurrent").id()).toUInt()); + if (actionType.name() == "power") + thing->setStateValue("power", action.paramValue(actionType.paramTypes().findByName("power").id()).toBool()); + updateUnmeteredChargerMeter(thing); + } + if (thing->thingClassId() == simpleChargerThingClassId) { if (actionType.name() == "maxChargingCurrent") { uint maxChargingCurrent = action.paramValue(actionType.paramTypes().findByName("maxChargingCurrent").id()).toUInt(); @@ -751,6 +797,24 @@ void IntegrationPluginEnergyMocks::executeAction(ThingActionInfo *info) info->finish(Thing::ThingErrorNoError); } +void IntegrationPluginEnergyMocks::updateUnmeteredChargerMeter(Thing *thing) +{ + // Le courant commandé, tiré sur les phases câblées — comme updateChargerMeter(), mais publié + // sous `currentL1..3`, que le moteur ne lit pas : EvCharger::meteredPhases() reste vide. + const Electricity::Phases cablees = Electricity::convertPhasesFromString(thing->paramValue("phases").toString()); + const uint phaseCount = Electricity::getPhaseCount(cablees); + thing->setStateValue("phaseCount", phaseCount); + + const bool tire = thing->stateValue("power").toBool() && thing->stateValue("connected").toBool() + && thing->stateValue("pluggedIn").toBool(); + const double courantA = tire ? thing->stateValue("maxChargingCurrent").toDouble() : 0.0; + thing->setStateValue("currentL1", cablees.testFlag(Electricity::PhaseA) ? courantA : 0.0); + thing->setStateValue("currentL2", cablees.testFlag(Electricity::PhaseB) ? courantA : 0.0); + thing->setStateValue("currentL3", cablees.testFlag(Electricity::PhaseC) ? courantA : 0.0); + thing->setStateValue("currentPower", courantA * 230 * phaseCount); + thing->setStateValue("charging", tire); +} + void IntegrationPluginEnergyMocks::updateChargerMeter(Thing *thing) { Electricity::Phases connectedPhases = Electricity::convertPhasesFromString(thing->paramValue("phases").toString()); diff --git a/tests/mocks/plugins/energymocks/integrationpluginenergymocks.h b/tests/mocks/plugins/energymocks/integrationpluginenergymocks.h index e6653d3..b53c444 100644 --- a/tests/mocks/plugins/energymocks/integrationpluginenergymocks.h +++ b/tests/mocks/plugins/energymocks/integrationpluginenergymocks.h @@ -78,6 +78,7 @@ private: // Update the meter data of the charger based on the current max charging current, connected, power and plugged in state void updateChargerMeter(Thing *thing); + void updateUnmeteredChargerMeter(Thing *thing); }; diff --git a/tests/mocks/plugins/energymocks/integrationpluginenergymocks.json b/tests/mocks/plugins/energymocks/integrationpluginenergymocks.json index bdfd49b..9f3abc5 100644 --- a/tests/mocks/plugins/energymocks/integrationpluginenergymocks.json +++ b/tests/mocks/plugins/energymocks/integrationpluginenergymocks.json @@ -717,6 +717,146 @@ } ] }, + { + "name": "chargerUnmetered", + "displayName": "Mocked charger without per-phase metering", + "id": "e297f8c4-934f-4af3-a655-29b5b799289b", + "createMethods": [ + "user" + ], + "interfaces": [ + "evcharger", + "smartmeterconsumer", + "connectable" + ], + "paramTypes": [ + { + "id": "4c0132c6-e1a8-4bd0-964b-8e9827a0a1c5", + "name": "port", + "displayName": "Port", + "type": "uint", + "defaultValue": 6658 + }, + { + "id": "ec3e73a0-cca9-415a-bc64-9e841d666853", + "name": "phases", + "displayName": "Phases connected", + "type": "QString", + "allowedValues": [ + "A", + "B", + "C", + "ABC" + ], + "defaultValue": "A" + }, + { + "id": "803ba501-ae0f-4d58-8097-6ebb0928b3d8", + "name": "maxChargingCurrentUpperLimit", + "displayName": "Maximum charging current", + "type": "double", + "unit": "Ampere", + "defaultValue": 32 + } + ], + "stateTypes": [ + { + "id": "b35e0da3-7fc1-4c42-b6b4-2c43f9cb937a", + "name": "connected", + "displayName": "Connected", + "type": "bool", + "defaultValue": false, + "cached": false + }, + { + "id": "6ce62b4f-39e1-4693-b204-e399ea4e6637", + "name": "power", + "displayName": "Charging enabled", + "displayNameAction": "Enable/disable charging", + "type": "bool", + "defaultValue": true, + "writable": true + }, + { + "id": "1b1c75b1-362e-4341-960d-4024b2f28341", + "name": "maxChargingCurrent", + "displayName": "Maximum charging current", + "displayNameAction": "Set maximum charging current", + "type": "double", + "defaultValue": 6, + "minValue": 6, + "maxValue": 32, + "unit": "Ampere", + "writable": true + }, + { + "id": "4406b3d3-0102-4932-94ee-e678cc850106", + "name": "totalEnergyConsumed", + "displayName": "Total consumed energy", + "type": "double", + "unit": "KiloWattHour", + "defaultValue": 0, + "cached": true + }, + { + "id": "06b4ceea-5893-4776-b854-4e6c67469b47", + "name": "pluggedIn", + "displayName": "Car is plugged in", + "type": "bool", + "defaultValue": false, + "cached": false + }, + { + "id": "cca231ab-9fb5-4565-90b4-56ac33ccbcd4", + "name": "charging", + "displayName": "Charging", + "type": "bool", + "defaultValue": false, + "cached": false + }, + { + "id": "79601cfe-433b-4fcb-b61f-5b6a0b8b0292", + "name": "phaseCount", + "displayName": "Active phases", + "type": "uint", + "minValue": 1, + "maxValue": 3, + "defaultValue": 1 + }, + { + "id": "923e31e9-04be-43fc-a79d-10098f7aadc1", + "name": "currentPower", + "displayName": "Current power consumption", + "type": "double", + "unit": "Watt", + "defaultValue": 0 + }, + { + "id": "edfb0a5d-6897-4b27-9ae4-2f636ab700a0", + "name": "currentL1", + "displayName": "Current L1", + "type": "double", + "unit": "Ampere", + "defaultValue": 0 + }, + { + "id": "8bf7ff7b-78a3-4917-a4bd-1e9b7d95ca53", + "name": "currentL2", + "displayName": "Current L2", + "type": "double", + "unit": "Ampere", + "defaultValue": 0 + }, + { + "id": "20292cd6-dd59-4608-9d38-18096f484cbf", + "name": "currentL3", + "displayName": "Current L3", + "type": "double", + "unit": "Ampere", + "defaultValue": 0 + } + ] + }, { "name": "simpleCharger", "displayName": "Mocked charger simple", diff --git a/tests/tools/etm-powersync-schema-check-test.py b/tests/tools/etm-powersync-schema-check-test.py index 2a42582..2764e2c 100644 --- a/tests/tools/etm-powersync-schema-check-test.py +++ b/tests/tools/etm-powersync-schema-check-test.py @@ -65,6 +65,8 @@ TABLE_MODES = { 'old-data': ('—', 'valide'), 'echo-value': ('—', 'valide'), 'major-first': ('—', 'valide'), + # Une consigne hors des bornes est un plan VALIDE : c'est au moteur de l'écrêter, pas au contrat. + 'over-limit': ('—', 'valide'), } # Une valeur que nul chemin conforme ne peut contenir (tiret, deux-points) : si elle apparaît dans diff --git a/tools/etm-powersync-ref-optimizer/README.md b/tools/etm-powersync-ref-optimizer/README.md index 880a655..6fdb995 100644 --- a/tools/etm-powersync-ref-optimizer/README.md +++ b/tools/etm-powersync-ref-optimizer/README.md @@ -83,7 +83,8 @@ Le rejeu compare, cycle par cycle, les actions du créneau courant au plan enreg l'accepte, le contrat OpenRPC le refuse — contre-épreuve de la suite de conformité) · `old-data` (contexte invalide : `error.data` dans la forme d'avant 1.3, `{"problemes": [phrases]}`) · `echo-value` (contexte invalide : `error.data` recopie la valeur reçue) · `major-first` (la majeure -jugée avant le token). +jugée avant le token) · `over-limit` (toute consigne en watts portée à 50 kW, au-delà des bornes +de la charge et du branchement : le moteur doit l'écrêter). `--announce VERSION` : la version annoncée au handshake, et dont la majeure est exigée du moteur (défaut `1.3`). Avec `--announce 1.2`, il se présente en optimiseur 1.2 : la suite de conformité diff --git a/tools/etm-powersync-ref-optimizer/etm-powersync-ref-optimizer b/tools/etm-powersync-ref-optimizer/etm-powersync-ref-optimizer index 1227ef2..7b33caf 100755 --- a/tools/etm-powersync-ref-optimizer/etm-powersync-ref-optimizer +++ b/tools/etm-powersync-ref-optimizer/etm-powersync-ref-optimizer @@ -117,7 +117,7 @@ PROBLEMES_MAX = 50 # §7, 1.3 : au-delà, les 50 premiers et `trun MODES = ['normal', 'abstain', 'slow', 'invalid', 'expired', 'wrong-major', 'drop', 'unknown-load', 'forbidden-kind', 'missing-load', 'echo-token', 'protocol-1.0', 'deviate', 'wrong-type', - 'old-data', 'echo-value', 'major-first'] + 'old-data', 'echo-value', 'major-first', 'over-limit'] # Catalogue (energyplugin/etm/types/decisionreason.cpp) : codes qu'un optimiseur peut émettre et # leurs clés de paramètres documentées (INTERFACE.md). Le test d'intégration les confronte au @@ -389,6 +389,13 @@ def plan(ctx, mode, horizon_s, token): if a['loadId'] in disponibles: del actions[i] break + elif mode == 'over-limit': + # Un optimiseur qui demande le MAXIMUM à tout ce qui se règle en watts, au-delà des bornes + # de la charge et du branchement. Le moteur doit l'écrêter (invariant 2), jamais le poser. + for a in actions: + if a.get('kind') == 'setpoint': + a['powerW'] = 50000.0 + a['reason'] = 'mode over-limit (test) : 50 kW demandés' elif mode == 'echo-token' and token: for a in actions: a['reason'] = f'{a["reason"]} — écho {token}'