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