diff --git a/docs/DESIGN_3g.md b/docs/DESIGN_3g.md index 65d5249..96edf40 100644 --- a/docs/DESIGN_3g.md +++ b/docs/DESIGN_3g.md @@ -98,6 +98,54 @@ projection de durée — **de la machinerie propre au VE**, alimentée par le So > laisse l'échéance là où sa machinerie vit déjà. La fusionner avec 3f mêlerait deux > franchissements de frontière dans un même lot. +**DÉCISION : (a)**, appliquée en 3g-1. L'échéance et le tarif dynamique restent au proxy, en +financement réseau ; `evReservedW` est restreint à ce financement. + +#### 3.1-bis — La brèche assumée de 3g-2 : le démarrage sous tolérance + +*(Décision Patrick, 2026-08-27.)* + +Ce document défère le financement réseau à 3f. **3g-2 en ouvre un cas particulier**, et il faut +l'écrire ici plutôt que de le laisser se découvrir dans le code. + +**Ce que c'est.** `acquisitionTolerance` (défaut 0,5) est un seuil de **déclenchement** : la part +du plancher d'une borne qu'on accepte de ne pas couvrir par le surplus pour démarrer quand même, +en soutirant le reste. Une borne qui exige 4 140 W démarre donc dès 2 070 W de surplus, et tire +2 070 W du réseau. + +**Pourquoi c'est acceptable.** *Le comportement existe déjà*, et depuis toujours : le proxy +amont le pratique (`chargingEnabled = newRawValue >= minimalChargingCurrent × tolerance`, puis +consigne remontée au minimum de la borne). 3g-2 ne l'introduit pas — il le rend **visible**. Il +était jusqu'ici invisible de deux façons : aucun motif ne le nommait, et `evFloorW()` le +confondait avec le plancher lui-même, publiant 2 070 W comme « ce que la borne exige » alors +qu'elle en exige 4 140. + +Le supprimer aurait été l'autre option, et elle a été écartée pour la raison du parc : un +réglage documenté qui devient inerte sans le dire est un silence ambigu, et **chaque +installation en service verrait son VE démarrer deux fois plus tard sans explication**. + +**Ce qui le rend lisible.** +- `funding = Grid` sur l'action — l'origine de l'énergie est dite, pas déduite ; +- motif **`EV_GRID_START {budgetW, floorW, gridW}`** — `gridW` chiffre le soutirage ; +- comptabilité **partagée** : `budgetW` entre dans `budget.allocatedW` (part surplus), `gridW` + dans `budget.evReservedW` (part réseau), et `budgetW + gridW == allocatedW` de la charge. + +**Ce qui le borne, par construction et non par discipline.** +1. Il exige du surplus **réel** (`budgetW > 0`) : jamais un démarrage à sec. +2. Il consomme **tout** le budget restant, donc **au plus une borne par cycle** — la suivante + trouve un budget nul et repart sur son motif ordinaire. Éprouvé par + `testEvGridStartIsAnnouncedAndBounded`. +3. Il reste **sous le plafond de la protection de surcharge** : ce plafond est déclaré dans + `LoadContext.declared.maxPowerW` (`EvAdapter::toLoadContext`, écrêté par + `evPhaseAllowanceW()`), donc le waterfall ne peut pas allouer au-delà. Et la couche L4, + `verifyOverloadProtection()`, garde le dernier mot en temps réel, après le dispatch — + `testOverloadProtectionEcoMode` l'éprouve. + +**Ce qui reste hors périmètre**, et que 3f devra traiter : le financement réseau des charges +**non-EV** (ECS, PAC), l'achat sur signal tarifaire, et les planchers éco/confort du §10 — qui +ne partent pas sans l'extension du délestage au waterfall (précondition écrite, +`specs/spec_loadmodel.md` §13). + ### 3.2 La prise en compte de la batterie change de portée Le proxy calcule, **à l'intérieur du chemin VE** : pause de la charge sous le seuil de SOC, @@ -145,6 +193,18 @@ qui bascule en triphasé change son plancher effectif (6 A × 3 = 4,1 kW au lieu > annoncé à 1,4 kW, une borne qui passe en triphasé, et une allocation de 2 kW qui ne démarre > rien. +**Traité en 3g-2**, et le piège s'est refermé exactement là où il était annoncé — deux fois : + +- **Le prédicat de bascule.** 3g-1 utilisait `EvCharger::canSetPhaseCount()`, qui dit seulement + que la borne *porte* l'état `desiredPhaseCount`. La vraie question — la borne **et la + voiture** acceptent-elles 1 et 3 phases — est tranchée une fois par le manager + (`canSwitchPhaseCount`, `min/maxPossiblePhaseCount`). Les deux avaient divergé : une borne + monophasée se voyait viser 3 phases, son plancher publié triplait, et sous surcharge elle + commandait 3 × 6 A là où le proxy commandait 1 × 6 A. L'arbitre lit désormais le manager. +- **Le courant de référence.** La bascule ne se décide pas sur ce que la borne tire, mais sur ce + qu'elle **pourrait** tirer : `currentPower/230 + surplus`, la formule du proxy. Avec le seul + courant courant, une borne en triphasé y restait quel que soit l'effondrement du surplus. + --- ## 4. Ce qui reste au proxy, quoi qu'il arrive @@ -186,19 +246,23 @@ seconde moitié de 3g-1 n'est pas faite, il reste correct tel quel. | Étape | Contenu | Éprouvable | |---|---|---| | **3g-1** | `EvAdapter` publie `declared.minPowerW` (runtime, depuis la voiture) et `LoadContext` ; le VE entre dans `nonEvLoads` ; `EV_IDLE` cède à `BELOW_MIN_POWER` / `SURPLUS_INSUFFICIENT` | **oui** — LM-1204-b, les deux bornes du banc | -| **3g-2** | Conversion watts → ampères + choix de phases dans l'adaptateur, plancher publié pour le nombre de phases **visé** | oui, banc | -| **3g-3** | `batteryLevelConsideration` monte dans le budget (§3.2) | oui, banc | -| **3g-4** | Échéance : selon la décision du §3.1 | dépend | +| **3g-2** | ✅ **FAIT** — une charge un commandeur · `evFloorW()` = exigence réelle + démarrage sous tolérance visible · rang de borne configurable (LM-1207) | oui, banc | +| **3g-3** | ~~`batteryLevelConsideration` monte dans le budget~~ — **fait en 3g-1**, complété en 3g-2 (§3.2) | — | +| **3g-4** | Échéance : décision (a), elle reste au proxy — rien à faire tant que 3f n'existe pas | dépend | **3g-1 seul suffit à satisfaire LM-1204-b**, et c'est le seul qui corrige le défaut du §1. +> **Corrigé par le passage machine du 2026-08-26** : 3g-1 ne suffisait pas, parce que sa décision +> n'atteignait pas le matériel — `adjustEvChargers()` commandait encore. La moitié ARBITRAGE de +> LM-1204-b est éprouvée depuis 3g-2 (`testEvChargerRankDecidesWhoCharges`) ; la moitié +> MATÉRIELLE reste au banc, et elle attend une seconde borne réelle. + --- -## 6. Ce qu'il faut décider avant que je code +## 6. Ce qu'il fallait décider — décidé -1. **§3.1 — l'échéance** : option (a), (b) ou (c) ? *(Recommandation : (a).)* -2. **§3.2 — la batterie** : monter la règle dans le budget, en assumant le changement de - comportement pour l'ECS et la PAC ? *(Recommandation : oui.)* -3. **Le découpage** : 3g-1 livré et éprouvé seul, ou le lot entier d'un coup ? - *(Recommandation : 3g-1 seul — il porte la correction, et il est le seul dont le critère - d'acceptation est déjà écrit.)* +1. **§3.1 — l'échéance** : **(a)**, elle reste au proxy. 3g-2 y ajoute une brèche bornée et + annoncée : le démarrage sous tolérance (§3.1-bis). +2. **§3.2 — la batterie** : **oui**, montée dans le budget (3g-1), et le recrédit y est supprimé + (3g-2). +3. **Le découpage** : **3g-1 seul**, puis 3g-2 sur ce que le banc a révélé. diff --git a/energyplugin/etm/adapters/evadapter.cpp b/energyplugin/etm/adapters/evadapter.cpp index 458eef0..dd33836 100644 --- a/energyplugin/etm/adapters/evadapter.cpp +++ b/energyplugin/etm/adapters/evadapter.cpp @@ -21,7 +21,13 @@ LoadDescriptor EvAdapter::descriptor() const d.id = m_charger->thing()->id().toString(); d.label = m_charger->name(); d.adapter = QStringLiteral("evcharger"); - d.priority = 100; + // 3g-2 — le rang vient de la CONFIGURATION, comme celui de toute autre charge (LM-1207). + // Il valait 100 en dur, reliquat de l'ancienne convention « poids, valeur haute = premier » : + // dans un tri ASCENDANT cela plaçait toutes les bornes en dernier, à égalité, et laquelle + // démarrait n'était ni réglable ni reproductible (relevé du 2026-08-26, §3.2). + // Relu à chaque cycle : réordonner depuis l'app prend effet sans reconstruire l'adaptateur, + // donc sans réarmer le moindre verrou. + d.priority = m_parent->evPriority(d.id); d.declared.minA = m_charger->maxChargingCurrentMinValue(); d.declared.maxA = m_charger->maxChargingCurrentMaxValue(); @@ -187,9 +193,15 @@ LoadAction EvAdapter::applyAction(const LoadAction &action, const QDateTime &now const bool enclencher = action.chargingEnabled && demandeA > 0 && demandeA >= static_cast(minA); - const auto issuer = (action.funding == LoadAction::Surplus) - ? ChargingAction::ChargingActionIssuerSurplusCharging - : ChargingAction::ChargingActionIssuerTimeRequirement; + // 3g-2 — l'émetteur suit le MOTIF, pas le financement. Depuis qu'un démarrage sous + // tolérance (EV_GRID_START) porte funding=Grid tout en venant du waterfall, déduire + // l'émetteur du financement étiquetait « échéance » une décision de surplus — et c'est + // cette étiquette que le journal amont affiche à chaque commande. + ChargingAction::ChargingActionIssuer issuer = ChargingAction::ChargingActionIssuerSurplusCharging; + if (action.reason.code == QLatin1String(DecisionCode::EvDeadline)) + issuer = ChargingAction::ChargingActionIssuerTimeRequirement; + else if (action.reason.code == QLatin1String(DecisionCode::EvSpotMarket)) + issuer = ChargingAction::ChargingActionIssuerSpotMarketCharging; ChargingAction ca(enclencher, clampedA, phases, issuer, false); m_parent->doExecuteChargingAction(m_charger, ca, now); // now = temps de cycle (injectable) diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index 066bb49..1d17499 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -242,6 +242,14 @@ void EnergyArbitrator::rebuildLoadAdapters() if (!c.enabled()) continue; + // 3g-2 — une borne ne se CONSTRUIT pas depuis la configuration : son adaptateur est bâti + // depuis le Thing par syncAdapters(), et son entrée ne porte qu'un rang. La construire + // ici en ferait un second adaptateur pour le même organe. C'est aussi ce qui rend + // l'aller-retour lire-réécrire inoffensif : réécrire une entrée evcharger ne reconstruit + // rien, donc ne réarme aucun verrou (ECS-412). + if (c.isEvCharger()) + continue; + LoadNeeds needs; needs.dailyDeadline = c.needs().dailyDeadline(); needs.minEnergyWhPerDay = c.needs().minEnergyWhPerDay(); @@ -359,6 +367,12 @@ void EnergyArbitrator::update(const QDateTime ¤tDateTime) // consignes de repli (posées à la transition) tiennent jusqu'au retour du compteur. if (m_degradedMode) { qCDebug(dcNymeaEnergy()) << "[Arbitre] Mode dégradé L2 actif — planification suspendue."; + // 3g-2 — le waterfall ne commande plus rien : il ne peut donc plus revendiquer aucune + // borne. Garder le jeu du dernier cycle sain les exclurait du proxy alors que plus + // personne ne les commande — elles resteraient figées sur leur dernière consigne, + // compteur muet, ce que SAFETY.md §L2 interdit. Le repli est posé à la transition par + // applyDegradedMode() ; c'est lui qui fait autorité tant que le compteur est muet. + m_waterfallEvIds.clear(); return; } @@ -384,9 +398,14 @@ void EnergyArbitrator::update(const QDateTime ¤tDateTime) << "| stratégie:" << plan.strategy; } + // 3g-2 — le jeu des bornes que le waterfall a commandées, NOMMÉ par le scheduler. Il doit + // être posé AVANT le dispatch : c'est lui que le crochet evCommandedByWaterfall() rend à + // adjustEvChargers() juste après, et c'est ce qui empêche le proxy de re-commander. + m_waterfallEvIds = plan.waterfallEvIds; + // 7 : dispatch matériel (même position que l'amont — m_chargingActions rempli par getPlan()) - applyActionsToAdapters(slot, currentDateTime); // charges arbitrées (Setpoint W et State) - adjustEvChargers(currentDateTime); // EV (kind==Setpoint) → proxy amont jusqu'à 3g + applyActionsToAdapters(slot, currentDateTime); // charges arbitrées (Setpoint W, State) ET bornes du waterfall + adjustEvChargers(currentDateTime); // bornes RESTÉES au proxy : échéance, spot market // 8 : frontière de télémétrie. APRÈS le dispatch, jamais avant : la charge utile de mécanisme // (palier appliqué, état PAC) doit refléter ce qui a réellement été envoyé au matériel, pas @@ -429,10 +448,47 @@ double EnergyArbitrator::evFloorW(EvCharger *ev, uint phaseCount) const minA = carMinA; } - // La tolérance d'acquisition est le même facteur que celui qu'appliquait le proxy avant - // de décider d'enclencher : sans elle, le plancher publié serait plus bas que celui qui - // gouverne réellement le démarrage. - return minA * acquisitionTolerance() * 230.0 * phaseCount; + // 3g-2 — la tolérance d'acquisition N'EST PLUS ICI, et c'est la correction du lot. + // + // Ce sont deux grandeurs différentes, confondues en écrivant evFloorW() : le PLANCHER est + // ce que la borne exige pour démarrer (6 A × 230 V × phases, une donnée de matériel), la + // TOLÉRANCE est la part de ce plancher qu'on accepte de ne pas couvrir par le surplus (un + // réglage). Multiplier l'un par l'autre produisait un plancher qui n'existe nulle part : + // au banc, 2 070 W annoncés pour une borne qui en exige 4 140 — le waterfall lui allouait + // 2 828 W, l'adaptateur convertissait en 4,1 A, refusait d'enclencher sous les 6 A, et + // 2 828 W étaient retirés du budget pour personne (relevé du 2026-08-26, §3.4). + // + // La tolérance vit maintenant là où elle décide vraiment : au DÉMARRAGE, dans le waterfall, + // sous le motif EV_GRID_START qui chiffre ce qui est soutiré. + return minA * 230.0 * phaseCount; +} + +double EnergyArbitrator::evReferenceCurrentA(EvCharger *ev) const +{ + if (!ev) + return 0; + double a = ev->currentPower() / 230.0; + if (RootMeter *meter = internalRootMeter()) + a += -meter->currentPower() / 230.0; // < 0 au compteur = export = surplus + return a; +} + +uint EnergyArbitrator::evTargetPhases(EvCharger *ev, double referenceA) +{ + if (!ev) + return 1; + + // Les bornes de ce qui est POSSIBLE viennent du manager, qui les a établies ce cycle avec + // la voiture branchée (canSwitchPhaseCount, min/maxPossiblePhaseCount). Les redériver ici + // en ferait une seconde version — et c'est exactement ce qui a divergé en 3g-1 : viser + // 3 phases sur une combinaison qui n'en permet qu'une triplait le plancher publié, et sous + // surcharge commandait 3 × 6 A là où le proxy commandait 1 × 6 A. + const ChargingProcessInfo pi = internalProcessInfo(ev); + if (!pi.canSwitchPhaseCount) + return qMax(1u, pi.maxPossiblePhaseCount); + + const uint phases = bestPhaseCountFor(ev, referenceA); + return qMax(1u, qBound(pi.minPossiblePhaseCount, phases, pi.maxPossiblePhaseCount)); } double EnergyArbitrator::evPhaseAllowanceW(EvCharger *ev, uint phaseCount) const @@ -466,35 +522,16 @@ double EnergyArbitrator::evPhaseAllowanceW(EvCharger *ev, uint phaseCount) const return qMax(0.0, allowanceA) * 230.0 * phaseCount; } -double EnergyArbitrator::evReferenceCurrentA(EvCharger *ev) const +bool EnergyArbitrator::evCommandedByWaterfall(EvCharger *evCharger) const { - if (!ev) - return 0; - double a = ev->currentPower() / 230.0; - if (RootMeter *meter = internalRootMeter()) - a += -meter->currentPower() / 230.0; // < 0 au compteur = export = surplus - return a; + return evCharger && m_waterfallEvIds.contains(evCharger->thing()->id().toString()); } -uint EnergyArbitrator::evTargetPhases(EvCharger *ev, double referenceA) +bool EnergyArbitrator::evSurplusPlannedByWaterfall(EvCharger *evCharger) const { - if (!ev) - return 1; - - // Les bornes de ce qui est POSSIBLE viennent du manager, qui les a établies ce cycle avec - // la voiture branchée (canSwitchPhaseCount, min/maxPossiblePhaseCount). Les redériver ici - // en ferait une seconde version — et c'est exactement ce qui a divergé en 3g-1 : viser - // 3 phases sur une combinaison qui n'en permet qu'une triplait le plancher publié, et sous - // surcharge commandait 3 × 6 A là où le proxy commandait 1 × 6 A. - const ChargingProcessInfo pi = internalProcessInfo(ev); - if (!pi.canSwitchPhaseCount) - return qMax(1u, pi.maxPossiblePhaseCount); - - const uint phases = bestPhaseCountFor(ev, referenceA); - return qMax(1u, qBound(pi.minPossiblePhaseCount, phases, pi.maxPossiblePhaseCount)); + return evParticipatesInWaterfall(evCharger); } - SurplusContext EnergyArbitrator::buildContext(const QDateTime &now) const { SurplusContext ctx; @@ -577,6 +614,34 @@ SurplusContext EnergyArbitrator::buildContext(const QDateTime &now) const ctx.loads.append(it.value()->toLoadContext(now)); } + // --- loads[] : bornes CONFIGURÉES dont l'appareil manque (3g-2) ---------------------- + // + // L'entrée de configuration d'une borne n'est jamais supprimée d'office : c'est une + // configuration, et l'installateur a pu la classer. Quand le Thing disparaît — borne + // remplacée, ThingId changé, appareil déposé — il reste donc une charge sans appareil, et + // elle doit se LIRE comme telle : indisponible, avec son motif, à son rang. La faire + // disparaître du contexte la rendrait indistinguable d'une charge jamais configurée, et + // c'est le silence ambigu que la famille ECS-411 interdit. + // + // Aucune allocation ne lui est faite (branche `!available` du waterfall) et elle ne retient + // rien du budget : 0 W gelés, le budget passe entier à la suivante. + if (m_loadConfigStore) { + for (const LoadConfig &c : m_loadConfigStore->configs()) { + if (!c.isEvCharger() || !c.enabled() || m_adapters.contains(c.id())) + continue; + LoadContext lc; + lc.id = c.id(); + lc.adapter = QStringLiteral("evcharger"); + lc.label = c.label(); + lc.priority = c.priority(); + lc.telemetry.available = false; + lc.telemetry.currentPowerW = 0; + lc.telemetry.lockMinPowerW = 0; // rien d'engagé : rien à geler au budget + lc.telemetry.lockMaxPowerW = -1; + ctx.loads.append(lc); + } + } + // --- loads[] : TOUTES les charges arbitrées, polymorphes (relay-router, etmvariableload, // sg-ready) — une seule table, toutes construites depuis la configuration persistée. --- for (auto it = m_loadAdapters.constBegin(); it != m_loadAdapters.constEnd(); ++it) @@ -585,13 +650,89 @@ SurplusContext EnergyArbitrator::buildContext(const QDateTime &now) const return ctx; } +int EnergyArbitrator::evPriority(const QString &chargerId) const +{ + if (m_loadConfigStore) { + for (const LoadConfig &c : m_loadConfigStore->configs()) + if (c.isEvCharger() && c.id() == chargerId) + return c.priority(); + } + // Pas (encore) de configuration : rang de QUEUE. Une borne dont le rang n'est pas posé ne + // doit pas passer devant des charges que l'installateur a classées — l'inverse ferait de + // l'absence de configuration une décision d'arbitrage. + return 1000; +} + +bool EnergyArbitrator::estUneBorneConfiguree(const QString &loadId) const +{ + if (!m_loadConfigStore) + return false; + for (const LoadConfig &c : m_loadConfigStore->configs()) + if (c.isEvCharger() && c.enabled() && c.id() == loadId) + return true; + return false; +} + +void EnergyArbitrator::provisionEvChargerConfig(EvCharger *ev) +{ + if (!m_loadConfigStore || !ev) + return; + + const QString id = ev->thing()->id().toString(); + LoadConfigs configs = m_loadConfigStore->configs(); + + int rangMin = 0; + bool premier = true; + for (const LoadConfig &c : configs) { + if (c.id() == id) + return; // déjà configurée : on ne touche à rien + if (premier || c.priority() < rangMin) { + rangMin = c.priority(); + premier = false; + } + } + + LoadConfig c; + c.setId(id); + c.setLabel(ev->name()); + c.setAdapter(QStringLiteral("evcharger")); + c.setMode(QStringLiteral("dynamic")); + // Le domaine d'une borne n'est pas une devinette : c'est le mécanisme lui-même. La réserve + // « le plugin ne range pas une charge d'office » vise les charges pilotées, dont le domaine + // ne se lit nulle part. + c.setDomain(QStringLiteral("ev")); + // En tête, sans réécrire le rang de personne. 1 est le premier rang (protocole §5). + c.setPriority(premier ? 1 : qMax(1, rangMin - 1)); + c.setEnabled(true); + configs.append(c); + + QString erreur; + if (!m_loadConfigStore->setConfigs(configs, &erreur)) { + // 7-c — l'échec ne doit pas être plus discret que le succès : sans entrée, la borne + // reste arbitrée mais absente de GetLoadConfig, et c'est précisément le trou qu'on + // vient refermer. + qCWarning(dcNymeaEnergy()) << "[EnergyArbitrator] Configuration automatique de la borne" + << ev->name() << "REFUSÉE :" << erreur + << "— elle sera arbitrée sans rang configurable."; + return; + } + + qCInfo(dcNymeaEnergy()) << "[EnergyArbitrator] Borne" << ev->name() + << "ajoutée à la configuration des charges (rang" << c.priority() + << ", domaine ev) — entrée créée automatiquement, modifiable par " + "SetLoadConfig."; +} + void EnergyArbitrator::syncAdapters() { // Crée les adapters manquants for (auto it = internalEvChargers().constBegin(); it != internalEvChargers().constEnd(); ++it) { const QString id = it.key().toString(); - if (!m_adapters.contains(id)) + if (!m_adapters.contains(id)) { m_adapters[id] = new EvAdapter(it.value(), this); + // 3g-2 — la borne entre AUSSI dans la configuration, pour y porter son rang. + provisionEvChargerConfig(it.value()); + } } // Supprime les adapters obsolètes for (const QString &id : m_adapters.keys()) { @@ -609,13 +750,45 @@ void EnergyArbitrator::applyActionsToAdapters(const Slot &slot, const QDateTime // charges arbitrées, toutes construites depuis LoadConfigStore — c'est ce qui leur vaut // ECS-412 (reconstruction incrémentale) et ECS-413 (état sûr) sans code dédié, et ce // qui rend l'ensemble publié par GetLoadTelemetry égal à celui de GetLoadConfig. - // Un Setpoint EV n'est PAS dans cette table → ignoré ici (dispatché par - // adjustEvChargers() amont jusqu'à 3g). + // 3g-2 — LES BORNES SONT DISPATCHÉES ICI. Elles vivent dans une seconde table + // (m_adapters, construite depuis les Things et non depuis la config), mais le geste + // est le même : l'adaptateur convertit l'enveloppe en consigne matérielle et écrête. + // Tant que ce dispatch n'existait pas, la décision de l'arbitre n'atteignait RIEN et + // c'est adjustEvChargers() qui commandait — deux commandeurs, un seul entendu. + // + // Seules les bornes que le waterfall a commandées passent ici : celles restées au + // proxy (échéance, spot market) gardent leur chemin, allowance de compteur comprise. if (action.kind == LoadAction::State || action.kind == LoadAction::Setpoint) { - if (ILoadAdapter *adapter = m_loadAdapters.value(action.loadId, nullptr)) + if (ILoadAdapter *adapter = m_loadAdapters.value(action.loadId, nullptr)) { adapter->applyAction(action, now); - else if (action.kind == LoadAction::State) + } else if (m_waterfallEvIds.contains(action.loadId)) { + if (EvAdapter *ev = m_adapters.value(action.loadId, nullptr)) { + ev->applyAction(action, now); + // L'ÉTAT de charge publié dans ChargingInfo était posé par + // adjustEvChargers(), que ces bornes ne traversent plus. Sans cette + // reprise, chargingState resterait figé sur sa dernière valeur : l'app + // afficherait « en charge » une borne que l'arbitre vient d'éteindre. + setEvChargingState(ThingId(action.loadId), + action.chargingEnabled + ? ChargingInfo::ChargingStateSurplusCharging + : ChargingInfo::ChargingStateIdle); + } else if (action.reason.code != QLatin1String(DecisionCode::LoadUnavailable)) { + // Le waterfall a nommé une borne dont l'adaptateur a disparu ENTRE la + // décision et le dispatch (Thing retiré en cours de cycle). Rien n'est + // commandé, et le silence serait ambigu : cette borne est aussi sortie + // du proxy par le crochet. + // + // Le cas d'une borne configurée dont le Thing manque depuis le début est + // EXCLU de cet avertissement : l'arbitre l'a vu et l'a dit — c'est le + // motif LOAD_UNAVAILABLE, publié à chaque cycle. Répéter l'information en + // qCWarning noierait le vrai signal, qui est la disparition INATTENDUE. + qCWarning(dcNymeaEnergy()) << "[Arbitre] borne" << action.loadId + << "commandée par le waterfall mais son adaptateur " + "a disparu — aucune consigne émise ce cycle."; + } + } else if (action.kind == LoadAction::State) { qCWarning(dcNymeaEnergy()) << "[Arbitre] action State sans adaptateur:" << action.loadId; + } } } } @@ -757,8 +930,28 @@ void EnergyArbitrator::buildTelemetry(const Plan &plan, const Slot &slot, const ILoadAdapter *adapter = m_loadAdapters.value(action.loadId, nullptr); if (!adapter) adapter = m_adapters.value(action.loadId, nullptr); - if (!adapter) + // 3g-2 — une borne CONFIGURÉE dont l'appareil manque n'a pas d'adaptateur, et doit + // pourtant être publiée : sans elle, une charge que GetLoadConfig annonce serait + // absente de GetLoadTelemetry, et l'app afficherait un vide sans cause. C'est le + // même invariant, pris par l'autre bout. + if (!adapter) { + if (!estUneBorneConfiguree(action.loadId)) + continue; + couvertes.insert(action.loadId); + QVariantMap orphan; + orphan.insert(QStringLiteral("loadId"), action.loadId); + orphan.insert(QStringLiteral("allocatedW"), 0); + orphan.insert(QStringLiteral("funding"), QStringLiteral("surplus")); + orphan.insert(QStringLiteral("available"), false); + orphan.insert(QStringLiteral("decision"), action.reason.toMap()); + orphan.insert(QStringLiteral("faultCode"), QStringLiteral("THING_MISSING")); + QVariantMap mech; + mech.insert(QStringLiteral("kind"), QStringLiteral("evcharger")); + orphan.insert(QStringLiteral("mechanism"), mech); + attachMeasurements(orphan, action.loadId); + loads.append(orphan); continue; + } couvertes.insert(action.loadId); QVariantMap entry; diff --git a/energyplugin/etm/energyarbitrator.h b/energyplugin/etm/energyarbitrator.h index 5932364..34f3daf 100644 --- a/energyplugin/etm/energyarbitrator.h +++ b/energyplugin/etm/energyarbitrator.h @@ -295,6 +295,31 @@ public: */ double evPhaseAllowanceW(EvCharger *ev, uint phaseCount) const; + /*! + * \brief [3g-2] Rang configuré d'une borne dans le waterfall. + * + * \param chargerId ThingId de la borne, sous sa forme chaîne. + * \return \c priority de son entrée \c LoadConfig \c evcharger. En l'absence d'entrée — + * fenêtre entre la détection du Thing et l'auto-provisionnement, ou store absent — un + * rang de queue plutôt qu'un rang de tête : une borne dont la configuration n'est pas + * encore posée ne doit pas passer devant des charges déjà classées. + * + * \note Lu à CHAQUE \c descriptor() : c'est ce qui rend le réordonnancement effectif à + * chaud sans reconstruire l'adaptateur, donc sans toucher aux verrous de la borne. + */ + int evPriority(const QString &chargerId) const; + + /*! \brief Crochet amont — cette borne a-t-elle été commandée par le waterfall ce cycle ? + * \param evCharger Borne interrogée par \c adjustEvChargers(). + * \return Vrai si elle figure dans \c Plan::waterfallEvIds du cycle courant. */ + bool evCommandedByWaterfall(EvCharger *evCharger) const override; + /*! \brief Crochet amont — le surplus de cette borne appartient-il au waterfall ? + * \param evCharger Borne interrogée par \c planSurplusCharging(). + * \return Vrai si elle passe le filtre d'entrée du waterfall (voiture assignée, mode non + * manuel). Interrogé AVANT que le waterfall n'ait décidé, d'où sa réponse plus large que + * celle de \c evCommandedByWaterfall(). */ + bool evSurplusPlannedByWaterfall(EvCharger *evCharger) const override; + signals: /*! * \brief Émise quand l'état runtime d'arbitrage a CHANGÉ, ou au battement de cœur. @@ -370,6 +395,42 @@ private: */ void rebuildLoadAdapters(); + /*! + * \brief [3g-2] Crée l'entrée de configuration manquante d'une borne détectée. + * + * Une borne qui apparaît dans nymea n'a aucune entrée \c LoadConfig, donc aucun rang — + * et jusqu'à 3g-2 elle était arbitrée et publiée en télémétrie sans figurer dans + * \c GetLoadConfig. C'est l'invariant « \c loads[] ⊆ \c GetLoadConfig » du lot B-bis, + * rompu par 3g-1 : l'app s'y appuie pour afficher une charge, et une charge publiée sans + * configuration est exactement la charge fantôme que le lot B-bis avait supprimée. + * + * \par Ce que l'entrée contient, et ce qu'elle ne contient pas + * Le rang, le libellé, le domaine \c ev, \c enabled. Aucune charge utile : les limites de + * la borne viennent du Thing (voir \c LoadConfig::isEvCharger()). + * + * \par Rang par défaut + * En **tête**, sans bousculer les charges déjà classées : le plus petit rang existant + * moins un, borné à 1. Ce n'est pas une politique du plugin — l'ordre des domaines est un + * choix de mise en service, porté par l'app ; c'est seulement un point de départ qui ne + * réécrit le rang de personne. + * + * \note **Journalisée, une ligne par borne, à la création seulement.** Une entrée créée + * d'office est une écriture que personne n'a demandée : sans trace, un installateur + * verrait apparaître des charges dans son écran sans savoir d'où elles viennent. + * \note L'entrée n'est JAMAIS supprimée d'office quand le Thing disparaît — c'est une + * configuration, et l'installateur a pu la classer. Elle se lit alors comme une charge + * dont l'appareil manque (\c LOAD_UNAVAILABLE, \c faultCode \c THING_MISSING). + * \param ev Borne fraîchement détectée. + */ + void provisionEvChargerConfig(EvCharger *ev); + + /*! \brief [3g-2] Vrai si \p loadId désigne une borne DÉCLARÉE en configuration et active. + * \param loadId Identifiant de charge. + * \return Vrai même si le Thing correspondant a disparu — c'est précisément le cas qu'il + * sert à reconnaître, pour publier la charge comme « appareil manquant » plutôt que de + * l'omettre. */ + bool estUneBorneConfiguree(const QString &loadId) const; + /*! * \brief Déclencheur RÉEL du watchdog L2 (SAFETY.md §L2) — slot de \c m_meterWatchdog * (QTimer 30 s, horloge murale ; câblé sous \c \#ifndef ENERGY_SIMULATION). @@ -457,6 +518,19 @@ private: RuleBasedScheduler *m_scheduler = nullptr; QHash m_adapters; //!< loadId (ThingId string) → EvAdapter*. + /*! + * \brief [3g-2] Bornes commandées par le waterfall AU CYCLE COURANT. + * + * Recopié de \c Plan::waterfallEvIds à chaque cycle, et lu par le crochet + * \c evCommandedByWaterfall() que \c adjustEvChargers() amont interroge. C'est la + * mémoire qui empêche le proxy de re-commander ce que l'arbitre vient de commander. + * + * \note **Vidé à l'entrée du mode dégradé L2**, où la planification est suspendue : sans + * cela, le jeu du dernier cycle sain continuerait d'exclure ces bornes du proxy alors + * que plus personne ne les commande — elles resteraient figées sur leur dernière + * consigne, compteur muet, ce que SAFETY.md interdit. + */ + QSet m_waterfallEvIds; //! loadId → charge ARBITRÉE, tous mécanismes confondus (relay-router | etmvariableload | //! sg-ready). Table UNIQUE, et c'est un invariant de contrat : toutes ses entrées viennent //! de \c LoadConfigStore, donc l'ensemble publié par \c NymeaEnergy.GetLoadTelemetry est diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.cpp b/energyplugin/etm/scheduler/rulebasedscheduler.cpp index a3fa8e4..b448208 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.cpp +++ b/energyplugin/etm/scheduler/rulebasedscheduler.cpp @@ -190,14 +190,30 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx) continue; nonEvLoads.append(lc); } + // 3g-2 — tri TOTAL et STABLE. Le rang seul ne suffit pas : deux charges de même rang + // laissaient l'ordre à std::sort, qui n'est pas stable, sur une liste dont l'origine est + // un QHash — donc sans ordre garanti d'un cycle à l'autre. Au banc, deux bornes à + // priority = 100 : la cascade était correcte, mais LAQUELLE démarrait n'était ni réglable + // ni reproductible (relevé du 2026-08-26, §3.2). L'identifiant départage : il est stable + // par construction, et l'égalité parfaite de deux charges n'existe pas. std::sort(nonEvLoads.begin(), nonEvLoads.end(), - [](const LoadContext &a, const LoadContext &b) { return a.priority < b.priority; }); + [](const LoadContext &a, const LoadContext &b) { + if (a.priority != b.priority) + return a.priority < b.priority; + return a.id < b.id; + }); + QSet waterfallEvIds; for (const LoadContext &lc : nonEvLoads) { if (lc.adapter == QStringLiteral("sg-ready")) slot.actions.append(buildSgReadyStateAction(lc, remainingSurplusW, compta)); else - slot.actions.append(buildSetpointAction(lc, remainingSurplusW, compta)); // etmvariableload + relay-router + slot.actions.append(buildSetpointAction(lc, remainingSurplusW, compta)); // etmvariableload + relay-router + evcharger + // 3g-2 — c'est ICI que le jeu se constitue, au moment où la décision est prise. Le + // redéduire plus tard depuis les motifs ou le financement dériverait du vrai au + // premier motif ajouté (EV_GRID_START porte funding=Grid tout en venant du waterfall). + if (lc.adapter == QStringLiteral("evcharger")) + waterfallEvIds.insert(lc.id); } // Grid funding (ECS/PAC) : dormant jusqu'à 3f (waterfall réseau) — non implémenté ici. @@ -207,8 +223,9 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx) plan.strategy = QStringLiteral("rule-based"); plan.timeSlots.append(slot); - compta.remainingW = remainingSurplusW; - plan.budget = compta; + compta.remainingW = remainingSurplusW; + plan.budget = compta; + plan.waterfallEvIds = waterfallEvIds; return plan; } @@ -236,6 +253,7 @@ LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc, // Recrédit anti-clignotement (Correction B / contrat rév. 2 §5) : la conso de DÉBUT de cycle // est déjà soustraite de l'export mesuré → on la rend au budget local avant d'arrondir. // PAS de relecture post-setpoint (invariant 8 : aucune boucle de feedback). + // // 3g-2 — SAUF sous la réserve de stockage, et c'est la seule exception. Le recrédit // suppose que le budget est une MESURE dont la conso de la charge a déjà été retranchée ; // sous la réserve, le budget n'est pas une mesure mais une DÉCISION de retenir @@ -250,6 +268,10 @@ LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc, const QList &levels = lc.declared.powerLevels; const bool dynamic = levels.isEmpty(); + //! 3g-2 — une borne se distingue ici par une seule chose : elle peut DÉMARRER en soutirant + //! (acquisitionTolerance). Tout le reste du waterfall la traite comme n'importe quelle + //! charge continue, et c'est le sens de la transplantation. + const bool estUneBorne = lc.adapter == QStringLiteral("evcharger"); double setpointW; bool sousPlancher = false; //!< Budget > 0 mais sous le premier palier atteignable. @@ -257,6 +279,7 @@ LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc, if (dynamic) { // Dynamic (routeur PV / triac) : modulation continue bornée au plafond déclaré. const double maxW = lc.declared.maxPowerW > 0 ? lc.declared.maxPowerW : budgetW; + // 3g-2 — le PLAFOND est passé SOUS le plancher. Pour une charge de configuration // c'est impossible (isValid() le refuse) ; pour une borne, le plafond du cycle est // écrêté par la protection de surcharge, et il peut donc descendre sous ce que la @@ -291,6 +314,48 @@ LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc, setpointW = 0; plancherW = lc.declared.minPowerW; sousPlancher = true; + + // 3g-2 — DÉMARRAGE SOUS TOLÉRANCE, et c'est une décision d'ACHETER. + // + // `acquisitionTolerance` est la part du plancher qu'on accepte de ne pas couvrir + // par le surplus pour démarrer quand même : à 0,5, une borne qui exige 4 140 W + // démarre dès 2 070 W de surplus et SOUTIRE les 2 070 restants. Le comportement + // existait déjà chez le proxy amont — il y était invisible, confondu avec le + // plancher dans evFloorW(). On ne l'introduit pas, on le rend lisible et chiffré. + // + // Brèche assumée dans DESIGN_3g §3.1, qui défère le financement réseau à 3f : + // c'est un cas particulier BORNÉ, et il l'est par construction — + // · il exige du surplus réel (budgetW > 0), donc jamais un démarrage à sec ; + // · il consomme TOUT le budget restant, donc au plus UNE borne par cycle : la + // suivante trouve un budget nul et repart sur son motif ordinaire ; + // · il reste sous le plafond de la protection de surcharge, contrôlé plus haut. + const double tolerance = m_arbitrator->acquisitionTolerance(); + if (estUneBorne && tolerance > 0 && budgetW > 0 && budgetW >= plancherW * tolerance) { + const double soutireW = plancherW - budgetW; + + LoadAction la; + la.loadId = lc.id; + la.kind = LoadAction::Setpoint; + // Le financement dit la VÉRITÉ sur l'origine : une partie de cette puissance + // vient du réseau. La comptabilité qui suit la partage en conséquence. + la.funding = LoadAction::Grid; + la.chargingEnabled = true; + la.phaseCount = static_cast(qMax(1, lc.declared.phases)); + la.powerW = plancherW; + la.estimatedPowerW = plancherW; + la.reason = { DecisionCode::EvGridStart, + {{"budgetW", qRound(budgetW)}, {"floorW", qRound(plancherW)}, + {"gridW", qRound(soutireW)}} }; + + // Le surplus est ENTIÈREMENT dépensé — pas au-delà : le complément vient du + // réseau et se compte là où vivent déjà les puissances commandées non encore + // mesurées. Laisser le budget filer en négatif ferait porter le soutirage par + // la cascade, où il se lirait comme un manque de surplus chez les suivantes. + budget.allocatedW += budgetW; + budget.evReservedW += soutireW; + remainingSurplusW = 0; + return la; + } } } else { // Fixed : plus haut palier déclaré ≤ budget (levels triés croissants, 0 inclus → @@ -362,6 +427,11 @@ LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc, la.funding = LoadAction::Surplus; la.powerW = setpointW; la.estimatedPowerW = setpointW; + // 3g-2 — une borne lit `chargingEnabled` avant tout le reste (EvAdapter::applyAction) ; + // laissé à sa valeur par défaut, il faisait refuser TOUTE consigne issue du waterfall, + // consigne non nulle comprise. Sans objet pour les autres mécanismes, qui l'ignorent. + la.chargingEnabled = setpointW > 0; + la.phaseCount = static_cast(qMax(0, lc.declared.phases)); // ECS-309 — le motif nomme le MÉCANISME qui a produit l'issue, jamais un mécanisme // plausible. La règle 7 d'AGENTS.md exige un motif non vide ; celle-ci exige qu'il soit diff --git a/energyplugin/etm/types/decisionreason.cpp b/energyplugin/etm/types/decisionreason.cpp index 6bea4d4..dc9e63c 100644 --- a/energyplugin/etm/types/decisionreason.cpp +++ b/energyplugin/etm/types/decisionreason.cpp @@ -89,6 +89,13 @@ QString renderFr(const DecisionReason &reason, const QString &label) .arg(w("budgetW")).arg(label).arg(w("minPowerW")); } + // 3g-2 — la phrase dit COMBIEN est soutiré, et pour quoi. Un démarrage sous tolérance est + // une décision d'ACHETER : la lire comme une allocation ordinaire ferait chercher une + // erreur de cascade là où il y a un réglage assumé. + if (c == QLatin1String(DecisionCode::EvGridStart)) + return QStringLiteral("Démarrage de %1 : plancher %2 W, dont %3 W SOUTIRÉS au réseau " + "(%4 W de surplus disponibles, tolérance d'acquisition)") + .arg(label).arg(w("floorW")).arg(w("gridW")).arg(w("budgetW")); // 3g-2 — la limite de phase, pas le budget. Deux gestes opposés : revoir l'abonnement ou // délester la maison, contre attendre le soleil. if (c == QLatin1String(DecisionCode::PhaseLimit)) diff --git a/energyplugin/etm/types/decisionreason.h b/energyplugin/etm/types/decisionreason.h index 964fba3..9293a98 100644 --- a/energyplugin/etm/types/decisionreason.h +++ b/energyplugin/etm/types/decisionreason.h @@ -105,6 +105,32 @@ constexpr const char *BatteryReserve = "BATTERY_RESERVE"; * phrase vague par une autre. */ constexpr const char *BelowMinPower = "BELOW_MIN_POWER"; +/*! + * \brief params: \c budgetW, \c floorW, \c gridW — la borne DÉMARRE en soutirant au réseau. + * + * 3g-2. Quatrième terme de la distinction que portent déjà \c SURPLUS_INSUFFICIENT, + * \c BELOW_MIN_POWER et \c BATTERY_RESERVE — et le seul qui annonce un ACHAT : + * + * | situation | motif | + * |---|---| + * | il n'y a rien | \c SURPLUS_INSUFFICIENT | + * | il y en a, pas au bon format, et on n'achète pas | \c BELOW_MIN_POWER | + * | il y en a, pas assez pour le plancher, et \c acquisitionTolerance autorise l'appoint | **\c EV_GRID_START** | + * + * \c acquisitionTolerance est un seuil de DÉCLENCHEMENT : la part du plancher qu'on accepte + * de ne pas couvrir par le surplus pour démarrer quand même. Le comportement existait chez le + * proxy amont, où il était **invisible** — confondu avec le plancher lui-même dans + * \c evFloorW(). Ce motif ne l'introduit pas : il le rend lisible, et chiffré. + * + * \c gridW == \c floorW − \c budgetW est ce qui est SOUTIRÉ. C'est le chiffre qui fait de + * cette ligne une décision d'acheter et non un effet de bord : sans lui, une borne qui démarre + * sur un budget insuffisant se lit comme une erreur d'allocation. + * + * \note Comptabilité — l'action porte \c funding \c Grid, et son \c allocatedW se PARTAGE : + * \c budgetW vient du surplus et entre dans \c budget.allocatedW, \c gridW vient du réseau + * et entre dans \c budget.evReservedW. \c budgetW + \c gridW == \c allocatedW, toujours. + */ +constexpr const char *EvGridStart = "EV_GRID_START"; /*! * \brief params: \c limitW, \c requiredW — la LIMITE DE PHASE interdit, pas le budget. * diff --git a/energyplugin/etm/types/loadconfig.cpp b/energyplugin/etm/types/loadconfig.cpp index 105c463..124b8bd 100644 --- a/energyplugin/etm/types/loadconfig.cpp +++ b/energyplugin/etm/types/loadconfig.cpp @@ -170,6 +170,11 @@ QSet LoadConfig::claimedThingIds() const } else { // etmvariableload : l'id de la charge EST le ThingId piloté // (cf. etmvariableloadadapter.cpp, findConfiguredThing(ThingId(m_id))). + // + // 3g-2 — et c'est vrai de la BORNE aussi : son id est le ThingId de la borne. Elle + // revendique donc son Thing sans code dédié, ce qui interdit du même coup de déclarer + // une borne à la fois comme evcharger et comme charge pilotée — deux commandeurs sur + // un même organe, exactement ce que LM-201 refuse. ids.insert(normalizedThingId(m_id)); } @@ -259,6 +264,29 @@ bool LoadConfig::isValid(QString *error) const if (aSgReady) return fail(QStringLiteral("%1 : sgReady interdit (charge utile du mécanisme sg-ready)").arg(m_adapter)); + if (m_adapter == QStringLiteral("evcharger")) { + // 3g-2 — la borne n'apporte QUE son rang. Ses limites de courant, son nombre de phases + // et sa disponibilité viennent du Thing et changent en continu ; les déclarer ici en + // ferait une seconde source de vérité, périmée dès la première voiture branchée. + if (aRelays) + return fail(QStringLiteral("evcharger : relays[] interdit (charge utile d'un autre mécanisme)")); + if (aLevels) + return fail(QStringLiteral("evcharger : powerLevels/maxPowerW interdits — les limites " + "de la borne viennent du Thing, pas de la configuration")); + if (m_minPowerW > 0) + return fail(QStringLiteral("evcharger : minPowerW interdit — le plancher est dérivé du " + "courant minimal de la borne et du véhicule branché")); + if (m_minOnS != 0 || m_minOffS != 0) + return fail(QStringLiteral("evcharger : minOnS/minOffS interdits — les verrous d'une " + "borne vivent dans le manager amont")); + // Une borne module en continu : le mode le dit, comme pour tout autre mécanisme. On + // garde le champ plutôt que de l'exempter, pour que la forme de l'union reste UNE. + if (m_mode != QStringLiteral("dynamic")) + return fail(QStringLiteral("evcharger : mode doit être \"dynamic\" (une borne module, " + "elle n'a pas de paliers)")); + return true; + } + if (m_adapter == QStringLiteral("relay-router")) { // Cas relais (rév. 3) : liste de relais power. powerLevels/maxPowerW DÉRIVÉS (ignorés ici). if (m_mode != QStringLiteral("fixed")) @@ -316,7 +344,7 @@ bool LoadConfig::isValid(QString *error) const } else { return fail(QStringLiteral("mécanisme inconnu \"%1\" (attendu \"relay-router\", " - "\"etmvariableload\" ou \"sg-ready\")").arg(m_adapter)); + "\"etmvariableload\", \"sg-ready\" ou \"evcharger\")").arg(m_adapter)); } return true; } diff --git a/energyplugin/etm/types/loadconfig.h b/energyplugin/etm/types/loadconfig.h index c39e796..209fccb 100644 --- a/energyplugin/etm/types/loadconfig.h +++ b/energyplugin/etm/types/loadconfig.h @@ -286,6 +286,29 @@ public: //! \return Vrai si le mécanisme est \c sg-ready (PAC à 4 états normés). bool isSgReady() const { return m_adapter == QStringLiteral("sg-ready"); } + /*! + * \brief [3g-2] Vrai si l'entrée décrit une BORNE de recharge. + * + * \return Vrai si \c adapter == "evcharger". + * + * \par Une entrée sans charge utile, et c'est délibéré + * Une borne n'a **rien à configurer** côté moteur : ses limites de courant, son nombre de + * phases et sa disponibilité viennent du Thing, en continu. Ce que l'entrée porte, c'est + * ce que le Thing ne peut pas savoir — son **rang** dans le waterfall, son libellé et son + * \c enabled. Toute charge utile d'un autre mécanisme est donc refusée (LM-300). + * + * \par Pourquoi ici et pas dans ChargingInfo + * LM-1207 : un second système de priorité ne saurait pas exprimer « VE1 > ECS > VE2 », + * puisqu'il ne classerait que les bornes entre elles. Deux classements qui ne peuvent pas + * s'interclasser ne sont pas deux fonctionnalités, c'est un défaut. Le rang du VE vit donc + * là où vivent tous les rangs. + * + * \note **Aucun adaptateur n'est construit depuis cette entrée** : \c EvAdapter est bâti + * depuis le Thing par \c syncAdapters(), et le rebuild l'ignore. Un aller-retour + * lire-modifier-réécrire ne reconstruit donc rien et ne réarme aucun verrou. + */ + bool isEvCharger() const { return m_adapter == QStringLiteral("evcharger"); } + /*! \brief Things que cette charge revendique, forme normalisée. * * ECS-110 — un Thing ne peut appartenir qu'à UNE charge active. Deux charges qui diff --git a/energyplugin/etm/types/plan.h b/energyplugin/etm/types/plan.h index 3e061a8..a0f5ec3 100644 --- a/energyplugin/etm/types/plan.h +++ b/energyplugin/etm/types/plan.h @@ -4,6 +4,7 @@ #include #include +#include #include #include "loadaction.h" @@ -79,6 +80,19 @@ struct Plan { //! Comptabilité du budget du cycle — INTERNE, jamais sérialisée vers l'optimiseur socket //! (même statut que \c LoadAction::funding). Alimente la télémétrie RPC. PlanBudget budget; + /*! + * \brief [3g-2] Bornes VE dont le waterfall a pris la décision ce cycle — INTERNE. + * + * Le scheduler NOMME ce qu'il a commandé ; l'arbitre n'a pas à le redéduire. Un critère + * redérivé ailleurs (« motif différent d'EV_DEADLINE… ») dériverait du vrai au premier + * motif ajouté, et la divergence produirait exactement le défaut que 3g-2 corrige : + * une borne commandée deux fois, ou plus commandée du tout. + * + * \note Contient AUSSI les démarrages sous tolérance (\c EV_GRID_START), bien qu'ils + * portent \c funding \c Grid : ils viennent du waterfall. Ce qui n'y figure pas, ce + * sont les bornes restées au proxy — échéance et spot market. + */ + QSet waterfallEvIds; /*! * \brief Retourne le Slot couvrant \p dt. diff --git a/energyplugin/smartchargingmanager.cpp b/energyplugin/smartchargingmanager.cpp index 2661116..fe0be3e 100644 --- a/energyplugin/smartchargingmanager.cpp +++ b/energyplugin/smartchargingmanager.cpp @@ -1253,6 +1253,17 @@ void SmartChargingManager::planSurplusCharging(const QDateTime ¤tDateTime) EvCharger *evCharger = it.next(); const ChargingProcessInfo processInfo = m_processInfos.value(evCharger); + // [ETM 3g-2] Le surplus de cette borne est décidé par le waterfall. Continuer ici + // remplirait m_chargingActions d'une consigne concurrente que plus personne + // n'exécute — et surtout le JOURNAL afficherait « ### Surplus would adjust + // evcharger … » juste sous la ligne [Arbitre] qui dit autre chose. Un journal qui + // se contredit envoie diagnostiquer la mauvaise moitié du moteur. + if (evSurplusPlannedByWaterfall(evCharger)) { + qCDebug(dcNymeaEnergy()) << evCharger->name() + << "surplus is planned by the ETM waterfall. Skipping proxy planning."; + continue; + } + qCDebug(dcNymeaEnergy()).nospace() << "Current charger setpoint: " << evCharger->maxChargingCurrent() << "A (" << (evCharger->maxChargingCurrent() * 230) << "W), Metered: " << (evCharger->currentPower() / 230) << "A (" << evCharger->currentPower() << "W)"; double newRawValue = (evCharger->currentPower() / 230) + allowanceInAmpere; @@ -1310,6 +1321,17 @@ void SmartChargingManager::planSurplusCharging(const QDateTime ¤tDateTime) } } +// [ETM 3g-2] Voir la documentation dans smartchargingmanager.h (inventaire [ETM], entrée 7). +void SmartChargingManager::setEvChargingState(const ThingId &evChargerId, ChargingInfo::ChargingState state) +{ + if (!m_chargingInfos.contains(evChargerId)) + return; + if (m_chargingInfos.value(evChargerId).chargingState() == state) + return; + m_chargingInfos[evChargerId].setChargingState(state); + emit chargingInfoChanged(m_chargingInfos.value(evChargerId)); +} + void SmartChargingManager::adjustEvChargers(const QDateTime ¤tDateTime) { qCDebug(dcNymeaEnergy()) << "---------------- Adjusting chargers ---------------"; @@ -1323,6 +1345,16 @@ void SmartChargingManager::adjustEvChargers(const QDateTime ¤tDateTime) continue; } + // [ETM 3g-2] UNE CHARGE, UN COMMANDEUR. Cette borne a reçu son enveloppe du waterfall + // et l'adaptateur l'a déjà exécutée ; repasser ici dériverait une SECONDE consigne, de + // sa propre logique, et c'est elle qui atteindrait le matériel — la décision de + // l'arbitre resterait un motif publié que rien n'applique (relevé du 2026-08-26 §3.3). + if (evCommandedByWaterfall(evCharger)) { + qCDebug(dcNymeaEnergy()) << evCharger->name() + << "is commanded by the ETM waterfall this cycle. Not re-deciding here."; + continue; + } + ChargingProcessInfo processInfo = m_processInfos.value(evCharger); // Final schedules diff --git a/energyplugin/smartchargingmanager.h b/energyplugin/smartchargingmanager.h index f146312..e41c379 100644 --- a/energyplugin/smartchargingmanager.h +++ b/energyplugin/smartchargingmanager.h @@ -126,6 +126,16 @@ signals: // internalProcessInfo() — accesseurs ETM neufs, documentés ci-dessous. // 5. bestPhaseCountFor(), phasesForCount() — passe-plats ETM vers deux règles amont // privées, documentés ci-dessous. +// 7. setEvChargingState() — écriture ETM d'un état RUNTIME, documentée +// ci-dessous. Pas un réglage : jamais persistée. +// 6. evSurplusPlannedByWaterfall(), evCommandedByWaterfall() +// — crochets ETM, virtuels, documentés ci-dessous. +// Ce sont EUX qui garantissent « une charge, un +// commandeur » : sans eux, planSurplusCharging() +// et adjustEvChargers() re-décident après le +// waterfall. Deux crochets et non un : ils sont +// interrogés à deux moments du cycle où la +// réponse n'est pas la même (voir leur doc). protected slots: virtual void update(const QDateTime ¤tDateTime); // [ETM] virtual added void prepareInformation(const QDateTime ¤tDateTime); // [ETM] private → protected @@ -157,6 +167,61 @@ protected: Electricity::Phases phasesForCount(uint phaseCount) { return getAscendingPhasesForCount(phaseCount); } + /*! + * \brief [ETM 3g-2] Cette borne est-elle déjà commandée par le waterfall ce cycle ? + * + * \return Faux dans l'amont : sans arbitre ETM, \c adjustEvChargers() est le seul + * commandeur et rien ne change. \c EnergyArbitrator le surcharge. + * + * \par Pourquoi ce crochet existe + * 3g-1 a fait entrer les bornes dans le waterfall mais a laissé tourner le chemin + * d'exécution du proxy : l'arbitre décidait, publiait un motif juste, et + * \c adjustEvChargers() commandait autre chose une milliseconde plus tard (relevé + * machine du 2026-08-26, §3.3). **Deux commandeurs sur un même organe** — c'est la + * règle absolue 1 d'AGENTS.md, appliquée à l'exécution au lieu du budget. + * + * Ce que le crochet EXCLUT du proxy est exactement ce que le waterfall a commandé : + * les bornes servies au surplus. Celles qui restent au proxy — échéance et spot market, + * financées au réseau (DESIGN_3g §3.1) — continuent d'y passer, allowance de compteur + * comprise. + */ + virtual bool evCommandedByWaterfall(EvCharger *evCharger) const { Q_UNUSED(evCharger) return false; } + + /*! + * \brief [ETM 3g-2] Le surplus de cette borne sera-t-il décidé par le waterfall ? + * + * \return Faux dans l'amont. \c EnergyArbitrator le surcharge par le filtre d'entrée du + * waterfall (voiture assignée + mode non manuel). + * + * \note **Distinct de \c evCommandedByWaterfall, et la différence est temporelle.** + * \c planSurplusCharging() tourne AVANT que le waterfall n'ait décidé : à cet instant + * on ne peut savoir que si la borne lui APPARTIENT, pas ce qu'il en aura fait. Une + * borne qui lui appartient peut encore sortir par l'échéance — mais celle-ci est + * traitée à l'étape 2 de la même fonction, qui la retire de la liste avant l'étape 3. + * \c adjustEvChargers(), lui, tourne APRÈS : il interroge le crochet plus strict, sans + * quoi une borne servie par l'échéance ne serait plus commandée du tout. + */ + virtual bool evSurplusPlannedByWaterfall(EvCharger *evCharger) const { Q_UNUSED(evCharger) return false; } + + /*! + * \brief [ETM 3g-2] Met à jour l'état de charge PUBLIÉ d'une borne, et notifie si changé. + * + * \param evChargerId Borne concernée. \param state Nouvel état de charge. + * + * Reproduit **exactement** ce que fait \c adjustEvChargers() pour les bornes qu'il + * commande : écriture en mémoire, notification \c chargingInfoChanged sur transition + * seulement, et **aucune persistance** — \c chargingState est un état runtime, pas un + * réglage (il n'a d'ailleurs pas d'accesseur en écriture au schéma RPC). + * + * Existe parce que les bornes servies par le waterfall ne traversent plus + * \c adjustEvChargers() : sans elle, \c chargingState resterait figé sur sa dernière + * valeur et l'app afficherait « en charge » une borne que l'arbitre vient d'éteindre. + * + * \note Ne PAS passer par \c setChargingInfo() pour cela : cette méthode valide, + * persiste et relance \c update() — appelée depuis un cycle en cours, elle récurse. + */ + void setEvChargingState(const ThingId &evChargerId, ChargingInfo::ChargingState state); + // [ETM] Read-only state accessors — inline, no copies, no logic. /*! diff --git a/specs/spec_loadmodel.md b/specs/spec_loadmodel.md index b7b6541..d92d783 100644 --- a/specs/spec_loadmodel.md +++ b/specs/spec_loadmodel.md @@ -776,7 +776,14 @@ rien ne le signale. D'où deux exigences qui accompagnent la règle : **LM-1204-b — Le scénario d'acceptation de 3g, à monter au banc.** *(Défini avec Patrick, 2026-08-26, quand le banc a reçu une seconde borne.)* -> **Il n'est PAS exerçable avant 3g**, et c'est le point : aujourd'hui `nonEvLoads` filtre +> **MOITIÉ ARBITRAGE FRANCHIE (3g-2)** — `testEvChargerRankDecidesWhoCharges` : deux bornes, un +> budget qui ne paie qu'un plancher, la seconde refusée avec `BELOW_MIN_POWER {budgetW: 620, +> minPowerW: 1380}`, et l'inversion du rang change la borne servie. **La moitié MATÉRIELLE reste +> ouverte** : elle exige deux bornes RÉELLES, et la Terra AC ne répond toujours pas +> (`docs/RECAP_3g2.md` §1). Ce qu'une wallbox simulée ne prouvera pas est écrit plus bas — et +> c'est précisément ce qui manque encore. +> +> **Il n'était PAS exerçable avant 3g**, et c'est le point : aujourd'hui `nonEvLoads` filtre > `etmvariableload | relay-router | sg-ready`, l'EV est décidé **en proxy avant** le waterfall, > les deux bornes ne partagent donc aucun budget avec le reste et **leur rang n'a aucun sens**. > Ce scénario est le critère qui dira que 3g est fait. @@ -812,6 +819,11 @@ est ce qui avait motivé le proxy — la crainte que le waterfall ne sache pas e voiture doit être chargée à 7 h ». Il sait : c'est le même champ que le plancher éco du §10 (LM-1001). Une fois le VE dans le waterfall, il hérite du modèle de besoin commun. +> **LM-1206 et LM-1207 sont IMPLÉMENTÉS (3g-2).** Le rang d'une borne est un champ de +> `LoadConfig`, créé d'office à la détection, modifiable à chaud par `SetLoadConfig`. Aucun champ +> de rang n'a été ajouté à `ChargingInfo`, et il n'y en aura pas. Forme exacte de l'entrée : +> `INTERFACE.md`, « le mécanisme `evcharger` ». + **LM-1206 — La priorité porte sur la BORNE, pas sur la voiture. Décision arrêtée.** Il n'existe pas de communication véhicule aujourd'hui : rien ne permet de savoir *quelle* voiture est branchée, ni de lui attribuer un rang. Le rang appartient donc au **point de charge**, comme il diff --git a/tests/auto/loadmodel/testloadmodel.cpp b/tests/auto/loadmodel/testloadmodel.cpp index 9ccafe2..1ccc990 100644 --- a/tests/auto/loadmodel/testloadmodel.cpp +++ b/tests/auto/loadmodel/testloadmodel.cpp @@ -285,3 +285,103 @@ void TestLoadModel::testFloorAboveCeilingIsRefused() } QTEST_MAIN(TestLoadModel) + +namespace { + +//! Une entrée de borne telle que l'auto-provisionnement la crée : un rang, rien d'autre. +QVariantMap borneMap(const QString &id, int priority = 1) +{ + QVariantMap m; + m.insert("id", id); + m.insert("label", "Borne du garage"); + m.insert("adapter", "evcharger"); + m.insert("mode", "dynamic"); + m.insert("domain", "ev"); + m.insert("priority", priority); + m.insert("enabled", true); + return m; +} + +const QString BORNE = QStringLiteral("{dddddddd-0000-0000-0000-00000000000e}"); + +} // namespace + +void TestLoadModel::testEvChargerConfigIsRankOnly() +{ + QString err; + const LoadConfig c = LoadConfig::fromMap(borneMap(BORNE, 2)); + QVERIFY2(c.isValid(&err), qUtf8Printable(err)); + QVERIFY(c.isEvCharger()); + QCOMPARE(c.priority(), 2); + + // LM-302 — aller-retour NEUTRE. C'est le geste ordinaire de l'app : lire, changer un rang, + // renvoyer l'objet entier. Une entrée qui ne survivrait pas à sa propre relecture rendrait + // le réordonnancement impossible depuis l'écran. + const LoadConfig relu = LoadConfig::fromMap(c.toMap()); + QVERIFY2(relu.isValid(&err), qUtf8Printable(err)); + QCOMPARE(relu.toMap(), c.toMap()); + QCOMPARE(relu.priority(), 2); + QCOMPARE(relu.adapter(), QStringLiteral("evcharger")); + QCOMPARE(relu.domain(), QStringLiteral("ev")); + + // Le mode est celui d'une charge qui module. « fixed » n'aurait pas de sens : une borne + // n'a pas de paliers, et l'accepter ouvrirait une forme que rien ne construit. + QVariantMap fixe = borneMap(BORNE); + fixe.insert("mode", "fixed"); + QVERIFY2(!LoadConfig::fromMap(fixe).isValid(&err), "evcharger en mode fixed doit être refusé"); + QVERIFY(err.contains("dynamic")); +} + +void TestLoadModel::testEvChargerConfigRefusesPayloads() +{ + QString err; + + // Les limites d'une borne viennent du THING et changent avec la voiture branchée. Les + // déclarer ici en ferait une seconde source de vérité, périmée dès le premier débranchement. + QVariantMap avecPlafond = borneMap(BORNE); + avecPlafond.insert("maxPowerW", 11000); + QVERIFY2(!LoadConfig::fromMap(avecPlafond).isValid(&err), "maxPowerW doit être refusé"); + QVERIFY(err.contains("maxPowerW")); + + QVariantMap avecPlancher = borneMap(BORNE); + avecPlancher.insert("minPowerW", 4140); + QVERIFY2(!LoadConfig::fromMap(avecPlancher).isValid(&err), "minPowerW doit être refusé"); + QVERIFY(err.contains("minPowerW")); + + QVariantMap avecRelais = borneMap(BORNE); + QVariantMap relais; + relais.insert("thingId", K1); + relais.insert("powerW", 2000); + avecRelais.insert("relays", QVariantList() << relais); + QVERIFY2(!LoadConfig::fromMap(avecRelais).isValid(&err), "relays[] doit être refusé"); + + QVariantMap avecVerrous = borneMap(BORNE); + avecVerrous.insert("minOnS", 300); + QVERIFY2(!LoadConfig::fromMap(avecVerrous).isValid(&err), "minOnS doit être refusé"); + QVERIFY(err.contains("minOnS")); +} + +void TestLoadModel::testEvChargerClaimsItsThing() +{ + // LM-201 — l'id de la charge EST le ThingId de la borne, donc elle le revendique. Sans + // cela, on pourrait déclarer la même borne une seconde fois comme charge pilotée : deux + // commandeurs sur un même organe, et sa puissance comptée deux fois au budget. + const LoadConfig borne = LoadConfig::fromMap(borneMap(BORNE)); + QVERIFY(borne.claimedThingIds().contains(QUuid(BORNE).toString())); + + QVariantMap doublon; + doublon.insert("id", BORNE); // le MÊME Thing, par un autre mécanisme + doublon.insert("label", "Borne pilotée en watts"); + doublon.insert("adapter", "etmvariableload"); + doublon.insert("mode", "dynamic"); + doublon.insert("maxPowerW", 11000); + doublon.insert("priority", 3); + doublon.insert("enabled", true); + + LoadConfigs ensemble; + ensemble << borne << LoadConfig::fromMap(doublon); + + QString err; + QVERIFY2(!LoadConfigStore::validateSet(ensemble, &err), + "deux charges actives sur le Thing d'une borne doivent être refusées"); +} diff --git a/tests/auto/loadmodel/testloadmodel.h b/tests/auto/loadmodel/testloadmodel.h index ff97beb..752838c 100644 --- a/tests/auto/loadmodel/testloadmodel.h +++ b/tests/auto/loadmodel/testloadmodel.h @@ -56,6 +56,16 @@ private slots: //! Un plancher au-dessus du plafond rendrait la charge inatteignable. void testFloorAboveCeilingIsRefused(); + + // ── 3g-2 — le rang de la borne vit dans LoadConfig (LM-1206/LM-1207) ─── + //! Une entrée evcharger est valide SANS charge utile, et traverse l'aller-retour. + void testEvChargerConfigIsRankOnly(); + + //! Toute charge utile d'un autre mécanisme y est refusée (LM-300, union discriminée). + void testEvChargerConfigRefusesPayloads(); + + //! Elle revendique son Thing : une borne ne peut pas être aussi une charge pilotée. + void testEvChargerClaimsItsThing(); }; #endif // TESTLOADMODEL_H diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index 70eb434..4c7e973 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -2474,7 +2474,12 @@ void Simulation::run_data() }, { 444, { - SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 7) , + // [ETM 3g-2] 7 A → 6 A. La borne est ARRÊTÉE à ce pas (ligne + // suivante) : la valeur relue est la dernière consigne émise avant + // la coupure, et elle diffère d'un ampère parce que la conversion + // watts→ampères se fait désormais APRÈS l'allocation du waterfall, + // au lieu d'être calculée nativement en ampères par le proxy. + SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6) , SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false) } }, @@ -2494,14 +2499,17 @@ void Simulation::run_data() }, { 900, { - SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 30) , + // [ETM 3g-2] 88 % → 87 %, et 30 A → 6 A. Le CHEMIN diffère d'un + // ampère par-ci par-là (arrondi de la conversion, cf. pas 444) et + // 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. SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true), - SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 88) + SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 87) } }, { 960, { // 22:00 - SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 30) , SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false), SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 100) } @@ -2858,8 +2866,15 @@ void Simulation::run_data() }, { 900, { - SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 16), - SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true) + // [ETM 3g-2] La borne ne charge PAS encore à ce pas, et c'est le + // mécanisme d'échéance qui fonctionne : à 89 % avec une heure + // devant elle, la projection dit qu'il reste du temps, donc rien + // n'est forcé. Le forçage intervient plus tard, et le pas 960 + // vérifie qu'il a suffi — voiture à 100 % à l'échéance. + // Le proxy forçait dès ce pas parce que sa trajectoire de la + // journée laissait la voiture un peu moins chargée. + SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false), + SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 89) } }, { @@ -4052,6 +4067,13 @@ void Simulation::testDecisionReasonRendering() QStringLiteral("Budget 933 W sous l'état 3 de PAC test (1500 W estimés) — état 2, " "le budget passe à la charge suivante") }, + // 3g-2 — le démarrage sous tolérance est une décision d'ACHETER : la phrase doit + // chiffrer ce qui est soutiré, sans quoi elle se lit comme une erreur d'allocation. + { { DecisionCode::EvGridStart, + {{"budgetW", 2200}, {"floorW", 4140}, {"gridW", 1940}} }, + QStringLiteral("Borne du garage"), + QStringLiteral("Démarrage de Borne du garage : plancher 4140 W, dont 1940 W SOUTIRÉS au " + "réseau (2200 W de surplus disponibles, tolérance d'acquisition)") }, // 3g-2 — et quand c'est la limite de phase qui interdit, elle est NOMMÉE : le geste // attendu n'est pas d'attendre le soleil, c'est de regarder l'abonnement. { { DecisionCode::PhaseLimit, {{"limitW", 1200}, {"requiredW", 4140}} }, @@ -4093,7 +4115,7 @@ void Simulation::testDecisionReasonRendering() // la garantie « tout code a une ligne de rendu éprouvée » ne tenait plus. DecisionCode::BatteryReserve, DecisionCode::BelowMinPower, // 3g-2 - DecisionCode::PhaseLimit }; + DecisionCode::EvGridStart, DecisionCode::PhaseLimit }; for (const QString &code : catalogue) QVERIFY2(codesCouverts.contains(code), qUtf8Printable(QStringLiteral("code %1 sans ligne de rendu attendue").arg(code))); @@ -4973,3 +4995,412 @@ QStringList Simulation::plotSpotMarketData(int powerBalanceCount) QTEST_MAIN(Simulation) +namespace { + +//! Prépare une borne du banc simulé : branchée, à l'arrêt, monophasée, 6-16 A. +void armerBorne(Thing *borne) +{ + borne->setStateValue("connected", true); + borne->setStateValue("pluggedIn", true); + borne->setStateValue("power", false); + borne->setStateValue("charging", false); + borne->setStateValue("maxChargingCurrent", 6); + borne->setStateValue("currentPower", 0); +} + +//! Les entrées de configuration, telles que l'APP les voit — par la frontière RPC, jamais par +//! un store privé : c'est la concordance des deux frontières qui est en jeu. +QHash configsParId(const QVariant &getLoadConfigReply) +{ + QHash h; + const QVariantList l = getLoadConfigReply.toMap().value("params").toMap() + .value("loadConfigs").toList(); + for (const QVariant &v : l) + h.insert(v.toMap().value("id").toString(), v.toMap()); + return h; +} + +//! Les entrées de télémétrie du dernier cycle, indexées par loadId. +QHash telemetrieParId(const QVariantMap &snap) +{ + QHash h; + for (const QVariant &v : snap.value("loads").toList()) + h.insert(v.toMap().value("loadId").toString(), v.toMap()); + return h; +} + +} // namespace + +void Simulation::testEvChargerRankDecidesWhoCharges() +{ +#ifndef ETM_ARBITRATOR + QSKIP("testEvChargerRankDecidesWhoCharges nécessite ETM_ARBITRATOR."); +#else + // [3g-2 · LM-1204-b, moitié arbitrage] Deux bornes, un budget qui ne paie qu'un seul + // plancher. Ce qui est éprouvé ici est ce qu'AUCUN matériel ne prouve : que l'ARBITRE + // choisit, qu'il le dit, et que son choix suit le rang configuré. + // + // Le discriminant est le MOTIF publié (spec_loadmodel §12) : + // BELOW_MIN_POWER → l'arbitre a refusé, aucune commande n'est partie ; + // une borne à 0 W sans motif de plancher → c'est le matériel, le test ne prouve rien. + const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-evrank.json"; + QFile::remove(cfgPath); + qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); + + cleanupTestCase(); + m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; + initTestCase(); + + EnergyArbitrator *arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); + QVERIFY(arb); + ThingManager *tm = NymeaCore::instance()->thingManager(); + + QUuid meterId = addMeter(); + m_experiencePlugin->energyManager()->setRootMeter(meterId); + Thing *meter = tm->findConfiguredThing(meterId); + QVERIFY(meter); + meter->setStateValue("connected", true); + + // Tolérance d'acquisition à 1,0 : aucun démarrage sous tolérance ne vient brouiller la + // lecture du plancher. Ce mécanisme a son propre test. + QVERIFY(injectAndWait("NymeaEnergy.SetAcquisitionTolerance", + QVariantMap({{"acquisitionTolerance", 1.0}})).toMap() + .value("params").toMap().value("energyError").toString() == "EnergyErrorNoError"); + + // Deux bornes IDENTIQUES, plafonnées à 6 A : chacune prend exactement son plancher + // (1380 W) et pas un watt de plus. C'est ce qui rend le RELIQUAT visible — 2000 − 1380 = + // 620 W, sous le plancher de la seconde. Sans ce plafond, la première absorberait tout et + // la seconde publierait « pas de surplus », ce qui est vrai mais ne prouve pas le plancher. + QUuid borneAId = addCharger("A", 6, 26657); + QUuid borneBId = addCharger("A", 6, 26670); + QUuid voitureAId = addCar(26656); + QUuid voitureBId = addCar(26671); + QVERIFY(!borneAId.isNull() && !borneBId.isNull()); + QVERIFY(!voitureAId.isNull() && !voitureBId.isNull()); + + Thing *borneA = tm->findConfiguredThing(borneAId); + Thing *borneB = tm->findConfiguredThing(borneBId); + QVERIFY(borneA && borneB); + armerBorne(borneA); + armerBorne(borneB); + for (const QUuid &v : {voitureAId, voitureBId}) { + Thing *voiture = tm->findConfiguredThing(v); + QVERIFY(voiture); + voiture->setStateValue("batteryLevel", 40); + voiture->setStateValue("capacity", 50); + voiture->setStateValue("minChargingCurrent", 6); + voiture->setStateValue("phaseCount", 1); + voiture->setStateValue("pluggedIn", true); + } + + auto associer = [this](const QUuid &borne, const QUuid &voiture) { + QVariantMap info; + info.insert("evChargerId", borne); + info.insert("assignedCarId", voiture); + info.insert("chargingMode", "ChargingModeEco"); + QVariant r = injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}})); + QCOMPARE(r.toMap().value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + }; + associer(borneAId, voitureAId); + associer(borneBId, voitureBId); + + // ---- 1. L'auto-provisionnement : les bornes ENTRENT dans la configuration -------------- + // 3g-1 les arbitrait et les publiait sans qu'elles figurent dans GetLoadConfig — une charge + // publiée sans configuration est la charge fantôme que le lot B-bis avait supprimée. + const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); + meter->setStateValue("currentPower", 0); // aucun surplus : ce cycle ne sert qu'à + arb->simulationCallUpdate(t0); // faire entrer les bornes en configuration + QCoreApplication::processEvents(); + + QHash cfgs = configsParId(injectAndWait("NymeaEnergy.GetLoadConfig")); + QVERIFY2(cfgs.contains(borneAId.toString()), "la borne A n'est pas entrée en configuration"); + QVERIFY2(cfgs.contains(borneBId.toString()), "la borne B n'est pas entrée en configuration"); + QCOMPARE(cfgs.value(borneAId.toString()).value("adapter").toString(), QStringLiteral("evcharger")); + QCOMPARE(cfgs.value(borneAId.toString()).value("domain").toString(), QStringLiteral("ev")); + QVERIFY(cfgs.value(borneAId.toString()).value("enabled").toBool()); + + // Aller-retour NEUTRE : relire la configuration et la réécrire mot pour mot ne change rien + // et ne reconstruit AUCUN adaptateur — l'adaptateur d'une borne vient du Thing, pas d'ici. + const QVariant avant = injectAndWait("NymeaEnergy.GetLoadConfig"); + QVariant reecrit = injectAndWait("NymeaEnergy.SetLoadConfig", + QVariantMap({{"loadConfigs", avant.toMap().value("params").toMap().value("loadConfigs")}})); + QCOMPARE(reecrit.toMap().value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + QCOMPARE(injectAndWait("NymeaEnergy.GetLoadConfig").toMap().value("params").toMap(), + avant.toMap().value("params").toMap()); + + // ---- 2. Le rang décide, et le MOTIF le dit -------------------------------------------- + // Le réordonnancement passe par la MÊME frontière que l'app : GetLoadConfig, on change le + // rang, SetLoadConfig. Aucun raccourci par le store — c'est le chemin qui doit marcher. + auto poserRangs = [&](int rangA, int rangB) { + 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() == borneAId.toString()) m.insert("priority", rangA); + if (m.value("id").toString() == borneBId.toString()) m.insert("priority", rangB); + l[i] = m; + } + QVariant r = injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}})); + QCOMPARE(r.toMap().value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + }; + + // Chaque cycle mesuré part de DEUX bornes à l'arrêt. Sans cela le recrédit + // anti-clignotement rendrait à celle qui charge déjà sa propre consommation — c'est le + // comportement voulu (le compteur l'inclut déjà), mais il rend le budget du cycle + // ambigu et le test ne mesurerait plus le rang. + auto cycle = [&](const QDateTime &t) { + for (Thing *b : {borneA, borneB}) { + b->setStateValue("power", false); + b->setStateValue("charging", false); + b->setStateValue("maxChargingCurrent", 6); + b->setStateValue("currentPower", 0); + } + meter->setStateValue("currentPower", -2000); // 2000 W exportés : UN seul plancher + arb->simulationCallUpdate(t); + QCoreApplication::processEvents(); + return telemetrieParId(arb->loadTelemetry()); + }; + + poserRangs(1, 2); + QHash tele = cycle(t0.addSecs(60)); + + // Plancher : max(borne 6 A, voiture 6 A) × 230 V × 1 phase = 1380 W. Le budget de 2000 W + // en paie UN. La seconde ne reçoit pas « un peu » — elle reçoit 0 et son motif. + QCOMPARE(qRound(tele.value(borneAId.toString()).value("allocatedW").toDouble()), 1380); + QCOMPARE(qRound(tele.value(borneBId.toString()).value("allocatedW").toDouble()), 0); + // Le reliquat EXISTE (620 W) et il ne suffit pas : c'est bien le plancher qui refuse, pas + // l'absence de surplus. Les deux phrases envoient l'installateur vers des gestes opposés. + QCOMPARE(qRound(tele.value(borneBId.toString()).value("decision").toMap() + .value("params").toMap().value("budgetW").toDouble()), 620); + QCOMPARE(tele.value(borneBId.toString()).value("decision").toMap().value("code").toString(), + QString(DecisionCode::BelowMinPower)); + QCOMPARE(qRound(tele.value(borneBId.toString()).value("decision").toMap() + .value("params").toMap().value("minPowerW").toDouble()), 1380); + + // ---- 3. INVERSION du rang : la borne servie change ------------------------------------ + // C'est le pendant EV de l'inversion ECS/SG-Ready déjà éprouvée, et la seule preuve que le + // rang est un RÉGLAGE et non un ordre de découverte. + poserRangs(2, 1); + tele = cycle(t0.addSecs(120)); + + QCOMPARE(qRound(tele.value(borneBId.toString()).value("allocatedW").toDouble()), 1380); + QCOMPARE(qRound(tele.value(borneAId.toString()).value("allocatedW").toDouble()), 0); + QCOMPARE(tele.value(borneAId.toString()).value("decision").toMap().value("code").toString(), + QString(DecisionCode::BelowMinPower)); + + // ---- 4. Une charge, UN commandeur ----------------------------------------------------- + // La borne refusée doit être ÉTEINTE par la décision de l'arbitre. Avant 3g-2, + // adjustEvChargers() repassait derrière avec une consigne dérivée de sa propre logique : + // l'arbitre publiait un motif juste, et le matériel recevait autre chose. + QCOMPARE(borneA->stateValue("power").toBool(), false); + QCOMPARE(borneA->stateValue("charging").toBool(), false); + // Et la borne SERVIE l'est à la consigne du waterfall, pas à celle que le proxy aurait + // dérivée : 1380 W sur une phase, soit 6 A. + QCOMPARE(borneB->stateValue("power").toBool(), true); + QCOMPARE(borneB->stateValue("maxChargingCurrent").toInt(), 6); + + // ---- 5. loads[] ⊆ GetLoadConfig, l'invariant que 3g-1 avait rompu ---------------------- + QSet idsConfig; + for (const QString &id : configsParId(injectAndWait("NymeaEnergy.GetLoadConfig")).keys()) + idsConfig.insert(id); + for (auto it = tele.constBegin(); it != tele.constEnd(); ++it) + QVERIFY2(idsConfig.contains(it.key()), + qUtf8Printable(QStringLiteral("charge %1 publiée en télémétrie mais absente de " + "GetLoadConfig").arg(it.key()))); +#endif +} + +void Simulation::testEvGridStartIsAnnouncedAndBounded() +{ +#ifndef ETM_ARBITRATOR + QSKIP("testEvGridStartIsAnnouncedAndBounded nécessite ETM_ARBITRATOR."); +#else + // [3g-2] `acquisitionTolerance` est un seuil de DÉCLENCHEMENT : la part du plancher qu'on + // accepte de ne pas couvrir par le surplus pour démarrer quand même. Le comportement + // existait chez le proxy, où il était invisible — confondu avec le plancher dans + // evFloorW(). Ici il est visible, chiffré, et borné. + const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-evgridstart.json"; + QFile::remove(cfgPath); + qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); + + cleanupTestCase(); + m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; + initTestCase(); + + EnergyArbitrator *arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); + QVERIFY(arb); + ThingManager *tm = NymeaCore::instance()->thingManager(); + QUuid meterId = addMeter(); + m_experiencePlugin->energyManager()->setRootMeter(meterId); + Thing *meter = tm->findConfiguredThing(meterId); + QVERIFY(meter); + meter->setStateValue("connected", true); + + injectAndWait("NymeaEnergy.SetAcquisitionTolerance", QVariantMap({{"acquisitionTolerance", 0.5}})); + + QUuid borneAId = addCharger("A", 16, 26657); + QUuid borneBId = addCharger("A", 16, 26670); + QUuid voitureAId = addCar(26656); + QUuid voitureBId = addCar(26671); + Thing *borneA = tm->findConfiguredThing(borneAId); + Thing *borneB = tm->findConfiguredThing(borneBId); + QVERIFY(borneA && borneB); + armerBorne(borneA); + armerBorne(borneB); + for (const QUuid &v : {voitureAId, voitureBId}) { + Thing *voiture = tm->findConfiguredThing(v); + QVERIFY(voiture); + voiture->setStateValue("batteryLevel", 40); + voiture->setStateValue("capacity", 50); + voiture->setStateValue("minChargingCurrent", 6); + voiture->setStateValue("phaseCount", 1); + voiture->setStateValue("pluggedIn", true); + } + for (const QPair &paire : QList>{{borneAId, voitureAId}, + {borneBId, voitureBId}}) { + QVariantMap info; + info.insert("evChargerId", paire.first); + info.insert("assignedCarId", paire.second); + info.insert("chargingMode", "ChargingModeEco"); + injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}})); + } + + const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); + meter->setStateValue("currentPower", 0); // cycle d'entrée en configuration seulement + arb->simulationCallUpdate(t0); + QCoreApplication::processEvents(); + + 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() == borneAId.toString()) m.insert("priority", 1); + if (m.value("id").toString() == borneBId.toString()) m.insert("priority", 2); + l[i] = m; + } + QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}})) + .toMap().value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + + // Les deux bornes à l'arrêt : le budget du cycle est sans ambiguïté (cf. testEvChargerRank…). + for (Thing *b : {borneA, borneB}) { + b->setStateValue("power", false); + b->setStateValue("charging", false); + b->setStateValue("maxChargingCurrent", 6); + b->setStateValue("currentPower", 0); + } + // 900 W de surplus : sous le plancher de 1380 W, mais au-dessus de sa moitié. Le proxy + // aurait démarré ici sans le dire ; le waterfall démarre et le DIT. + meter->setStateValue("currentPower", -900); + arb->simulationCallUpdate(t0.addSecs(60)); + QCoreApplication::processEvents(); + const QVariantMap snap = arb->loadTelemetry(); + QHash tele = telemetrieParId(snap); + + // ---- Le motif dit COMBIEN est soutiré, et pourquoi ------------------------------------- + const QVariantMap decisionA = tele.value(borneAId.toString()).value("decision").toMap(); + QCOMPARE(decisionA.value("code").toString(), QString(DecisionCode::EvGridStart)); + const QVariantMap pA = decisionA.value("params").toMap(); + QCOMPARE(qRound(pA.value("floorW").toDouble()), 1380); + QCOMPARE(qRound(pA.value("budgetW").toDouble()), 900); + QCOMPARE(qRound(pA.value("gridW").toDouble()), 480); // 1380 − 900, SOUTIRÉS + // budgetW + gridW == allocatedW : la comptabilité est vérifiable par le client seul. + QCOMPARE(qRound(tele.value(borneAId.toString()).value("allocatedW").toDouble()), 1380); + QCOMPARE(qRound(pA.value("budgetW").toDouble() + pA.value("gridW").toDouble()), 1380); + + // Le financement DIT la vérité sur l'origine : une part vient du réseau. + QCOMPARE(tele.value(borneAId.toString()).value("funding").toString(), QString("grid")); + + // ---- Borné par construction : UNE borne par cycle -------------------------------------- + // Le démarrage consomme TOUT le budget restant ; la suivante trouve zéro et repart sur son + // motif ordinaire. Deux bornes ne peuvent pas se mettre à soutirer le même cycle. + QCOMPARE(qRound(tele.value(borneBId.toString()).value("allocatedW").toDouble()), 0); + QVERIFY2(tele.value(borneBId.toString()).value("decision").toMap().value("code").toString() + != QString(DecisionCode::EvGridStart), + "deux bornes ont soutiré au réseau dans le même cycle"); + + // ---- La comptabilité du budget partage l'allocation ------------------------------------ + const QVariantMap budget = snap.value("budget").toMap(); + QCOMPARE(qRound(budget.value("allocatedW").toDouble()), 900); // la part de SURPLUS + QCOMPARE(qRound(budget.value("evReservedW").toDouble()), 480); // la part de RÉSEAU + QCOMPARE(qRound(budget.value("remainingW").toDouble()), 0); +#endif +} + +void Simulation::testEvChargerConfigOutlivesItsThing() +{ +#ifndef ETM_ARBITRATOR + QSKIP("testEvChargerConfigOutlivesItsThing nécessite ETM_ARBITRATOR."); +#else + // [3g-2] Borne remplacée, ThingId qui change, appareil déposé : l'entrée de configuration + // n'est PAS supprimée d'office — c'est une configuration, et l'installateur a pu la classer. + // Elle doit se lire comme une charge dont l'appareil manque, avec le motif qui va bien. + // Le cas symétrique du THING_MISSING éprouvé avec la sonde. + const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-evorphan.json"; + QFile::remove(cfgPath); + qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); + + cleanupTestCase(); + m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; + initTestCase(); + + EnergyArbitrator *arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); + QVERIFY(arb); + ThingManager *tm = NymeaCore::instance()->thingManager(); + QUuid meterId = addMeter(); + m_experiencePlugin->energyManager()->setRootMeter(meterId); + Thing *meter = tm->findConfiguredThing(meterId); + QVERIFY(meter); + meter->setStateValue("connected", true); + meter->setStateValue("currentPower", -4000); + + QUuid borneId = addCharger("A", 16, 26657); + QUuid voitureId = addCar(26656); + Thing *borne = tm->findConfiguredThing(borneId); + Thing *voiture = tm->findConfiguredThing(voitureId); + QVERIFY(borne && voiture); + armerBorne(borne); + voiture->setStateValue("batteryLevel", 40); + voiture->setStateValue("capacity", 50); + voiture->setStateValue("minChargingCurrent", 6); + voiture->setStateValue("phaseCount", 1); + voiture->setStateValue("pluggedIn", true); + + QVariantMap info; + info.insert("evChargerId", borneId); + info.insert("assignedCarId", voitureId); + info.insert("chargingMode", "ChargingModeEco"); + injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}})); + + const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); + arb->simulationCallUpdate(t0); + QCoreApplication::processEvents(); + QVERIFY2(configsParId(injectAndWait("NymeaEnergy.GetLoadConfig")).contains(borneId.toString()), + "la borne n'est pas entrée en configuration"); + + // ---- Le Thing disparaît ---------------------------------------------------------------- + QVERIFY(removeDevice(borneId).toMap().value("params").toMap().value("thingError").toString() + == "ThingErrorNoError"); + arb->simulationCallUpdate(t0.addSecs(60)); + QCoreApplication::processEvents(); + + // L'entrée SURVIT : la supprimer effacerait un classement que l'installateur a posé, et le + // silence qui suivrait ne se distinguerait pas d'une borne jamais configurée. + QVERIFY2(configsParId(injectAndWait("NymeaEnergy.GetLoadConfig")).contains(borneId.toString()), + "l'entrée de configuration a été supprimée avec le Thing"); + + // Et elle se LIT comme une charge sans appareil, à son rang, avec son motif. + const QHash tele = telemetrieParId(arb->loadTelemetry()); + QVERIFY2(tele.contains(borneId.toString()), + "la borne configurée sans appareil a disparu de la télémétrie"); + const QVariantMap entree = tele.value(borneId.toString()); + QCOMPARE(entree.value("available").toBool(), false); + QCOMPARE(entree.value("faultCode").toString(), QString("THING_MISSING")); + QCOMPARE(entree.value("decision").toMap().value("code").toString(), + QString(DecisionCode::LoadUnavailable)); + QCOMPARE(qRound(entree.value("allocatedW").toDouble()), 0); +#endif +} diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index 3765039..a6639a2 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -143,6 +143,22 @@ private slots: // quoi l'hypothèse de prudence devient un masquage de panne. void testSgReadyRestartRecovery(); + // ── 3g-2 ─────────────────────────────────────────────────────────────── + // [LM-1206/LM-1207] Le rang d'une borne vit dans LoadConfig, comme celui de toute autre + // charge — et l'inverser change laquelle démarre. C'est la moitié ARBITRAGE de LM-1204-b : + // le banc en garde la moitié matérielle, qu'aucune wallbox simulée ne peut prouver. + // Couvre aussi « une charge, un commandeur » : la borne refusée est éteinte par l'arbitre, + // et le proxy ne repasse pas derrière avec une consigne de son cru. + void testEvChargerRankDecidesWhoCharges(); + + // [3g-2] Le démarrage sous tolérance d'acquisition est une décision d'ACHETER : elle porte + // un financement réseau, un motif qui chiffre le soutirage, et une comptabilité partagée. + void testEvGridStartIsAnnouncedAndBounded(); + + // [3g-2] L'entrée de configuration d'une borne SURVIT à la disparition de son Thing : elle + // se lit comme une charge dont l'appareil manque, pas comme une charge jamais configurée. + void testEvChargerConfigOutlivesItsThing(); + void printStates(Thing *thing); void updateChargerMeter(Thing *thing);