`computeEcoTarget()` convertit une obligation en énergie en une cible en watts : cadence = reste / temps restant, recalculée à chaque cycle. Une cadence figée au départ ne rattrape pas — après une heure sans surplus elle reste à la cadence initiale et manque l'échéance en ayant l'air de travailler. Mesurer d'abord était nécessaire, pas rituel : aucun scénario existant ne portait d'obligation éco, donc aucune référence. Les valeurs ont été relevées par sonde avant toute assertion — et c'est le relevé qui a trouvé un défaut que la lecture du code ne montrait pas. LA BASCULE DE PÉRIODE (LM-1013-c). À l'instant de l'échéance quotidienne, resolveDeadline() vise la prochaine occurrence : une nouvelle obligation s'ouvre. Sans reprise de la base d'avancement, le lendemain hérite de l'énergie de la veille et se croit tenu sans avoir rien reçu. La sonde affichait 83 W et 86400 s restantes à 07:00 pile, ce qui n'avait aucun sens tant qu'on ne cherchait pas la période. Contre-épreuve : 1800 Wh portés au crédit du jour suivant. Limite assumée et écrite : l'obligation précédente, si elle n'était pas tenue, est abandonnée SANS motif dédié. Le dire demande un ECO_FLOOR_MISSED que rien n'émet ; le journal le dit, l'écran non. Le durcissement est relevé point par point — 333 W à six heures, 2 kW à une heure, le plafond dès seize minutes — parce que c'est la courbe que le motif devra rendre lisible, pas une valeur isolée. Point de non-retour nommé plutôt que subi : un seuil se discute, une asymptote non. R6 armée dans le calcul : sous `unmeasurable`, jamais « tenue ». On vise l'obligation entière, le choix qui sert plutôt que celui qui suppose acquis. Suite : loadmodel 22/22, simulation 56/56, charging 48/48 identique à la référence ligne à ligne, spotmarket 32/32, doxygen 0 avertissement. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
683 lines
33 KiB
C++
683 lines
33 KiB
C++
// SPDX-License-Identifier: GPL-3.0-or-later
|
||
|
||
#include "testloadmodel.h"
|
||
|
||
#include "etm/adapters/relayrouter.h"
|
||
#include "etm/energyarbitrator.h"
|
||
#include "etm/types/loadconfig.h"
|
||
#include "etm/types/drawcap.h"
|
||
#include "etm/types/ecotarget.h"
|
||
#include "etm/config/loadconfigstore.h"
|
||
|
||
#include <QTest>
|
||
#include <QDir>
|
||
#include <QFile>
|
||
#include <QJsonDocument>
|
||
#include <QJsonObject>
|
||
#include <QJsonArray>
|
||
|
||
namespace {
|
||
|
||
//! Une config relay-router minimale, valide, dont on ne fait varier que les relais.
|
||
LoadConfig relayLoad(const QList<QPair<QString, int>> &relays,
|
||
const QString &label = QStringLiteral("chauffe-eau"),
|
||
int priority = 1,
|
||
const QString &meter = QString(),
|
||
const QString &sensor = QString())
|
||
{
|
||
QVariantList list;
|
||
for (const auto &r : relays) {
|
||
QVariantMap m;
|
||
m.insert("thingId", r.first);
|
||
m.insert("powerW", r.second);
|
||
list.append(m);
|
||
}
|
||
QVariantMap map;
|
||
map.insert("id", "chauffe-eau");
|
||
map.insert("label", label);
|
||
map.insert("adapter", "relay-router");
|
||
map.insert("mode", "fixed");
|
||
map.insert("priority", priority);
|
||
map.insert("enabled", true);
|
||
map.insert("minOnS", 60);
|
||
map.insert("minOffS", 60);
|
||
map.insert("relays", list);
|
||
if (!meter.isEmpty())
|
||
map.insert("meterThingId", meter);
|
||
if (!sensor.isEmpty())
|
||
map.insert("sensorThingId", sensor);
|
||
return LoadConfig::fromMap(map);
|
||
}
|
||
|
||
const QString K1 = QStringLiteral("{aaaaaaaa-0000-0000-0000-000000000001}");
|
||
const QString K2 = QStringLiteral("{bbbbbbbb-0000-0000-0000-000000000002}");
|
||
const QString K3 = QStringLiteral("{cccccccc-0000-0000-0000-000000000003}");
|
||
|
||
} // namespace
|
||
|
||
void TestLoadModel::testStageTableIsDerivedFromSubsetSums()
|
||
{
|
||
// La configuration réelle du banc .75 : trois contacteurs 500 / 1 000 / 2 000 W.
|
||
const RelayStageTable t = RelayRouter::deriveStages(
|
||
relayLoad({{K1, 500}, {K2, 1000}, {K3, 2000}}).relaysList());
|
||
|
||
QCOMPARE(t.levels, (QList<int>{0, 500, 1000, 1500, 2000, 2500, 3000, 3500}));
|
||
QCOMPARE(t.mapping.size(), t.levels.size());
|
||
|
||
// Le palier 0 est le sous-ensemble vide : aucun contact fermé. C'est ce qui garantit
|
||
// qu'un routeur sait toujours s'arrêter.
|
||
QCOMPARE(t.levels.first(), 0);
|
||
QVERIFY(t.mapping.first().isEmpty());
|
||
|
||
// Et le maximum est bien la somme de tout — c'est le maxStageW publié en télémétrie.
|
||
QCOMPARE(t.levels.last(), 3500);
|
||
QCOMPARE(t.mapping.last().size(), 3);
|
||
}
|
||
|
||
void TestLoadModel::testRelayOrderIsInertWhenPowersAreDistinct()
|
||
{
|
||
const RelayStageTable direct = RelayRouter::deriveStages(
|
||
relayLoad({{K1, 500}, {K2, 1000}, {K3, 2000}}).relaysList());
|
||
const RelayStageTable inverse = RelayRouter::deriveStages(
|
||
relayLoad({{K3, 2000}, {K2, 1000}, {K1, 500}}).relaysList());
|
||
|
||
// Chaque somme n'a qu'une seule combinaison possible : la déduplication n'arbitre rien,
|
||
// donc l'ordre de déclaration ne peut rien changer d'ÉLECTRIQUE.
|
||
QCOMPARE(direct.levels, inverse.levels);
|
||
|
||
// Les listes de ThingIds, elles, diffèrent : elles sont collectées dans l'ordre de
|
||
// déclaration, donc le palier 1 500 W sort {K1,K2} d'un côté et {K2,K1} de l'autre.
|
||
// C'est le même geste électrique — d'où la comparaison par ENSEMBLE dans la table.
|
||
// Ce cas a fait échouer la première version de operator==, qui comparait les listes.
|
||
QVERIFY2(direct == inverse,
|
||
"un réordonnancement à puissances distinctes doit rendre la même table");
|
||
}
|
||
|
||
void TestLoadModel::testRelayOrderDecidesWhichContactServesATiedStage()
|
||
{
|
||
// Deux relais de MÊME puissance : le palier 1 000 W est atteignable par K1 seul comme
|
||
// par K2 seul. La table retient la PREMIÈRE combinaison rencontrée.
|
||
const RelayStageTable a = RelayRouter::deriveStages(
|
||
relayLoad({{K1, 1000}, {K2, 1000}}).relaysList());
|
||
const RelayStageTable b = RelayRouter::deriveStages(
|
||
relayLoad({{K2, 1000}, {K1, 1000}}).relaysList());
|
||
|
||
// Mêmes puissances atteignables…
|
||
QCOMPARE(a.levels, (QList<int>{0, 1000, 2000}));
|
||
QCOMPARE(a.levels, b.levels);
|
||
|
||
// …mais pas le même contacteur pour le palier 1 000 W. C'est un vrai changement
|
||
// matériel : autre contact qui s'use, autre résistance qui chauffe.
|
||
QCOMPARE(a.mapping.at(1), QList<QString>{K1});
|
||
QCOMPARE(b.mapping.at(1), QList<QString>{K2});
|
||
QVERIFY(a != b);
|
||
}
|
||
|
||
void TestLoadModel::testReorderingDistinctRelaysKeepsTheAdapter()
|
||
{
|
||
const LoadConfig avant = relayLoad({{K1, 500}, {K2, 1000}, {K3, 2000}});
|
||
const LoadConfig apres = relayLoad({{K3, 2000}, {K1, 500}, {K2, 1000}});
|
||
|
||
// Le geste que l'installateur fait dans l'app : remonter une ligne. Mêmes Things,
|
||
// mêmes puissances, même table — donc aucune reconstruction, donc pas de contacts
|
||
// ouverts par l'état sûr ni de verrous réarmés à froid.
|
||
QVERIFY2(EnergyArbitrator::sameHardware(avant, apres),
|
||
"un réordonnancement sans effet sur la table coupait la charge");
|
||
}
|
||
|
||
void TestLoadModel::testSwappingTiedRelaysRebuilds()
|
||
{
|
||
const LoadConfig avant = relayLoad({{K1, 1000}, {K2, 1000}});
|
||
const LoadConfig apres = relayLoad({{K2, 1000}, {K1, 1000}});
|
||
|
||
// Ici la table change : le palier 1 000 W passe d'un contacteur à l'autre. Comparer les
|
||
// relais comme un ENSEMBLE aurait manqué ce changement et laissé l'adaptateur commander
|
||
// le mauvais contact.
|
||
QVERIFY2(!EnergyArbitrator::sameHardware(avant, apres),
|
||
"un échange de contacts à puissance égale doit reconstruire");
|
||
}
|
||
|
||
void TestLoadModel::testSoftFieldsNeverRebuild()
|
||
{
|
||
const QList<QPair<QString, int>> relays{{K1, 500}, {K2, 1000}};
|
||
const LoadConfig avant = relayLoad(relays, QStringLiteral("chauffe-eau"), 1);
|
||
const LoadConfig apres = relayLoad(relays, QStringLiteral("Ballon 200 L"), 3);
|
||
|
||
// Renommer et changer de rang se met à jour EN PLACE (ECS-412). Le libellé part
|
||
// désormais à l'adaptateur par updateSoftConfig() — sans quoi le journal de la box
|
||
// continuait d'imprimer l'ancien nom alors que GetLoadConfig rendait le nouveau.
|
||
QVERIFY(EnergyArbitrator::sameHardware(avant, apres));
|
||
}
|
||
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// §11 — compteur et sonde par charge
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
void TestLoadModel::testMeasurementThingsRoundTrip()
|
||
{
|
||
const QString meter = QStringLiteral("{dddddddd-0000-0000-0000-00000000000m}");
|
||
const QString sensor = QStringLiteral("{eeeeeeee-0000-0000-0000-00000000000s}");
|
||
|
||
const LoadConfig avec = relayLoad({{K1, 500}}, QStringLiteral("ECS"), 1, meter, sensor);
|
||
QCOMPARE(avec.meterThingId(), meter);
|
||
QCOMPARE(avec.sensorThingId(), sensor);
|
||
|
||
const QVariantMap m = avec.toMap();
|
||
QCOMPARE(m.value("meterThingId").toString(), meter);
|
||
QCOMPARE(m.value("sensorThingId").toString(), sensor);
|
||
|
||
// Omis quand vides — jamais une chaîne vide persistée, même règle que « domain ».
|
||
const QVariantMap sans = relayLoad({{K1, 500}}).toMap();
|
||
QVERIFY2(!sans.contains("meterThingId"), "un compteur absent ne doit pas sortir à \"\"");
|
||
QVERIFY2(!sans.contains("sensorThingId"), "une sonde absente ne doit pas sortir à \"\"");
|
||
}
|
||
|
||
void TestLoadModel::testMeasurementThingsAreNotClaimed()
|
||
{
|
||
const QString meter = QStringLiteral("{dddddddd-0000-0000-0000-00000000000m}");
|
||
const QString sensor = QStringLiteral("{eeeeeeee-0000-0000-0000-00000000000s}");
|
||
const LoadConfig c = relayLoad({{K1, 500}, {K2, 1000}}, QStringLiteral("ECS"), 1,
|
||
meter, sensor);
|
||
|
||
// LM-1104 : ces Things MESURENT, ils ne commandent pas. LM-201 ne s'applique donc pas.
|
||
const QSet<QString> claimed = c.claimedThingIds();
|
||
QCOMPARE(claimed.size(), 2); // les deux relais, et eux seuls
|
||
QVERIFY2(!claimed.contains(meter.toLower()), "le compteur ne doit pas être revendiqué");
|
||
QVERIFY2(!claimed.contains(sensor.toLower()), "la sonde ne doit pas être revendiquée");
|
||
}
|
||
|
||
void TestLoadModel::testTwoLoadsMayShareAMeter()
|
||
{
|
||
const QString meter = QStringLiteral("{dddddddd-0000-0000-0000-00000000000m}");
|
||
const QString sensor = QStringLiteral("{eeeeeeee-0000-0000-0000-00000000000s}");
|
||
|
||
LoadConfig a = relayLoad({{K1, 500}}, QStringLiteral("ECS haut"), 1, meter, sensor);
|
||
LoadConfig b = relayLoad({{K2, 1000}}, QStringLiteral("ECS bas"), 2, meter, sensor);
|
||
b.setId(QStringLiteral("ecs-bas"));
|
||
|
||
// Un compteur de tableau divisionnaire couvrant deux charges, une sonde de pièce
|
||
// partagée : deux configurations parfaitement correctes, que la symétrie avec relays[]
|
||
// aurait fait refuser.
|
||
LoadConfigs ensemble;
|
||
ensemble << a << b;
|
||
QString err;
|
||
QVERIFY2(LoadConfigStore::validateSet(ensemble, &err), qPrintable(err));
|
||
}
|
||
|
||
void TestLoadModel::testMeasurementThingsAreSoftFields()
|
||
{
|
||
const QList<QPair<QString, int>> relays{{K1, 500}, {K2, 1000}};
|
||
const LoadConfig avant = relayLoad(relays, QStringLiteral("ECS"), 1);
|
||
const LoadConfig apres = relayLoad(relays, QStringLiteral("ECS"), 1,
|
||
QStringLiteral("{dddddddd-0000-0000-0000-00000000000m}"),
|
||
QStringLiteral("{eeeeeeee-0000-0000-0000-00000000000s}"));
|
||
|
||
// Rattacher un compteur ne touche à AUCUN matériel commandé : pas de reconstruction,
|
||
// donc pas de contacts ouverts ni de verrous réarmés à froid pour un geste de mesure.
|
||
QVERIFY2(EnergyArbitrator::sameHardware(avant, apres),
|
||
"rattacher un compteur ne doit pas reconstruire la charge");
|
||
}
|
||
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// §12 — le plancher de modulation
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
namespace {
|
||
//! Une charge continue minimale : un seul Thing, un plafond, éventuellement un plancher.
|
||
LoadConfig continuousLoad(int maxPowerW, int minPowerW)
|
||
{
|
||
QVariantMap map;
|
||
map.insert("id", "{11111111-2222-3333-4444-555555555555}");
|
||
map.insert("label", "Routeur PV salon");
|
||
map.insert("adapter", "etmvariableload");
|
||
map.insert("mode", "dynamic");
|
||
map.insert("priority", 1);
|
||
map.insert("enabled", true);
|
||
map.insert("maxPowerW", maxPowerW);
|
||
if (minPowerW > 0)
|
||
map.insert("minPowerW", minPowerW);
|
||
return LoadConfig::fromMap(map);
|
||
}
|
||
} // namespace
|
||
|
||
void TestLoadModel::testFloorIsDeclaredForContinuousLoads()
|
||
{
|
||
const LoadConfig c = continuousLoad(3000, 300);
|
||
QString err;
|
||
QVERIFY2(c.isValid(&err), qPrintable(err));
|
||
QCOMPARE(c.minPowerW(), 300u);
|
||
QCOMPARE(c.toMap().value("minPowerW").toUInt(), 300u);
|
||
|
||
// Omis quand nul — un plancher de 0 W se lirait « accepte n'importe quelle miette ».
|
||
QVERIFY(!continuousLoad(3000, 0).toMap().contains("minPowerW"));
|
||
}
|
||
|
||
void TestLoadModel::testFloorIsRefusedWhereItWouldBeDerived()
|
||
{
|
||
// relay-router : le plancher est le plus petit palier dérivé des relais.
|
||
QVariantMap relais = relayLoad({{K1, 500}, {K2, 1000}}).toMap();
|
||
relais.insert("minPowerW", 500);
|
||
QString err;
|
||
QVERIFY2(!LoadConfig::fromMap(relais).isValid(&err),
|
||
"un plancher déclaré sur relay-router doit être refusé");
|
||
QVERIFY(err.contains("minPowerW"));
|
||
|
||
// etmvariableload *fixed* : idem, le plancher est le plus petit palier non nul.
|
||
QVariantMap fixe;
|
||
fixe.insert("id", "{11111111-2222-3333-4444-555555555555}");
|
||
fixe.insert("label", "ECS multipalier");
|
||
fixe.insert("adapter", "etmvariableload");
|
||
fixe.insert("mode", "fixed");
|
||
fixe.insert("priority", 1);
|
||
fixe.insert("enabled", true);
|
||
fixe.insert("powerLevels", QVariantList() << 0 << 600 << 1200);
|
||
fixe.insert("minPowerW", 600);
|
||
QVERIFY2(!LoadConfig::fromMap(fixe).isValid(&err),
|
||
"un plancher déclaré en mode fixed doit être refusé");
|
||
}
|
||
|
||
void TestLoadModel::testFloorAboveCeilingIsRefused()
|
||
{
|
||
// {0} ∪ [min, max] serait réduit à {0} : la charge ne démarrerait jamais, et rien
|
||
// dans le journal ne dirait pourquoi.
|
||
QString err;
|
||
QVERIFY2(!continuousLoad(2000, 3000).isValid(&err),
|
||
"un plancher au-dessus du plafond doit être refusé");
|
||
QVERIFY(err.contains("minPowerW"));
|
||
|
||
// Égal au plafond : la charge est tout ou rien, mais elle EST atteignable.
|
||
QVERIFY2(continuousLoad(3000, 3000).isValid(&err), qPrintable(err));
|
||
}
|
||
|
||
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");
|
||
}
|
||
|
||
void TestLoadModel::testRankUniquenessRefusesWriteButNeverDiscards()
|
||
{
|
||
// [LM-1209-a] Deux vérifications, deux gestes — et les confondre convertirait une règle de
|
||
// contrat en PERTE DE CONFIGURATION au démarrage.
|
||
//
|
||
// validateSet() nomme ce qui rend l'ensemble DANGEREUX (deux charges sur un organe) et
|
||
// load() s'en sert pour ÉCARTER l'entrée fautive ;
|
||
// validateRanks() nomme ce qui le rend MAL FORMÉ, et le seul geste correct est de
|
||
// REFUSER l'écriture entière.
|
||
QString err;
|
||
LoadConfigs doublon;
|
||
doublon.append(LoadConfig::fromMap(borneMap(BORNE, 1)));
|
||
QVariantMap autre = borneMap(QStringLiteral("{dddddddd-0000-0000-0000-00000000000f}"), 1);
|
||
autre.insert("label", "Seconde borne");
|
||
doublon.append(LoadConfig::fromMap(autre));
|
||
|
||
// Chaque entrée est valide isolément, et l'ensemble n'est PAS dangereux : aucune des deux
|
||
// ne commande le Thing de l'autre. C'est pour cela que validateSet() le laisse passer —
|
||
// et pour cela qu'il fallait une seconde vérification, pas un durcissement de celle-ci.
|
||
for (const LoadConfig &c : doublon)
|
||
QVERIFY2(c.isValid(&err), qUtf8Printable(err));
|
||
QVERIFY2(LoadConfigStore::validateSet(doublon, &err),
|
||
"un doublon de RANG n'est pas un conflit d'organe : validateSet() ne doit pas le voir");
|
||
|
||
// La bonne forme, elle, refuse — et le motif doit NOMMER ce qui est perdu. Ce n'est pas le
|
||
// déterminisme : depuis 3g-2 le tri est total sur (rang, identifiant). C'est que l'ordre
|
||
// obéirait à des identifiants, donc à personne.
|
||
QVERIFY2(!LoadConfigStore::validateRanks(doublon, &err),
|
||
"deux charges au même rang doivent être refusées à l'écriture");
|
||
QVERIFY2(err.contains(QStringLiteral("RÉGLABLE")), qUtf8Printable(err));
|
||
|
||
// …et une écriture entière est rejetée, pas amputée : rien n'est persisté à moitié.
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-ranks.json";
|
||
QFile::remove(cfgPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||
{
|
||
LoadConfigStore store;
|
||
QVERIFY2(!store.setConfigs(doublon, &err), "setConfigs doit refuser un doublon de rang");
|
||
QCOMPARE(store.configs().count(), 0);
|
||
}
|
||
|
||
// ---- Et au CHARGEMENT : conservé, jamais écarté --------------------------------------
|
||
// C'est la moitié qui compte. Une configuration héritée à rangs doublés existe (le banc en
|
||
// a porté une) ; l'écarter ferait disparaître des charges de la configuration ET de
|
||
// l'arbitrage, sans applySafeState — sur la seule foi d'une règle resserrée après coup.
|
||
QFile f(cfgPath);
|
||
QVERIFY(f.open(QIODevice::WriteOnly | QIODevice::Text));
|
||
QVariantList brut;
|
||
for (const LoadConfig &c : doublon)
|
||
brut.append(c.toMap());
|
||
f.write(QJsonDocument(QJsonObject{{"loads", QJsonArray::fromVariantList(brut)}}).toJson());
|
||
f.close();
|
||
|
||
LoadConfigStore relu;
|
||
QCOMPARE(relu.configs().count(), 2); // LES DEUX survivent
|
||
QCOMPARE(relu.configs().at(0).priority(), 1);
|
||
QCOMPARE(relu.configs().at(1).priority(), 1); // aucun rang n'a été « réparé » d'office
|
||
|
||
// Et la première écriture cliente répare, sans que rien n'ait été perdu entre-temps.
|
||
LoadConfigs repare = relu.configs();
|
||
repare[1].setPriority(2);
|
||
QVERIFY2(relu.setConfigs(repare, &err), qUtf8Printable(err));
|
||
QCOMPARE(relu.configs().count(), 2);
|
||
}
|
||
|
||
void TestLoadModel::testRankOriginFollowsTheRankNotTheWrite()
|
||
{
|
||
// [LM-1209-c] La marque dit « ce rang est un défaut, pas un choix ». Ce qui la lève est
|
||
// qu'on CLASSE la charge — pas qu'on écrive dans le magasin.
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-rankorigin.json";
|
||
QFile::remove(cfgPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||
|
||
QString err;
|
||
LoadConfigStore store;
|
||
|
||
// Deux charges : « posee » classée par un humain au rang 1, « auto » créée d'office en 2.
|
||
LoadConfigs init;
|
||
QVariantMap posee = borneMap(QStringLiteral("{dddddddd-0000-0000-0000-0000000000a1}"), 1);
|
||
posee.insert("label", "Borne posée");
|
||
init.append(LoadConfig::fromMap(posee));
|
||
LoadConfig creee = LoadConfig::fromMap(borneMap(BORNE, 2));
|
||
creee.setRankOrigin(QStringLiteral("auto")); // le MOTEUR l'écrit, lui en a le droit
|
||
init.append(creee);
|
||
QVERIFY2(store.setConfigs(init, &err), qUtf8Printable(err));
|
||
|
||
// ---- Réordonner une AUTRE charge ne lève PAS la marque ---------------------------------
|
||
// C'est le cas qui se serait perdu. SetLoadConfig remplace en bloc : déplacer « posee »
|
||
// renvoie forcément l'entrée de la borne créée d'office. Si l'effacement portait sur
|
||
// « écriture reçue » plutôt que sur « rang changé », la marque tomberait ici — alors que
|
||
// la charge n'a toujours été classée par personne.
|
||
LoadConfigs bougeLautre = store.configs();
|
||
bougeLautre[0].setPriority(3); // on déplace « posee », pas la borne
|
||
bougeLautre[1].setRankOrigin(QString()); // et le client OMET la marque, comme un
|
||
// client naïf qui reconstruit sa charge utile
|
||
LoadConfigs reconciliee;
|
||
QVERIFY2(store.reconcileRankOrigin(bougeLautre, &reconciliee, &err), qUtf8Printable(err));
|
||
QVERIFY2(store.setConfigs(reconciliee, &err), qUtf8Printable(err));
|
||
QCOMPARE(store.configs().at(1).rankOrigin(), QStringLiteral("auto"));
|
||
QCOMPARE(store.configs().at(1).priority(), 2);
|
||
|
||
// ---- Déplacer LA charge lève la marque -------------------------------------------------
|
||
LoadConfigs bougeLaBonne = store.configs();
|
||
bougeLaBonne[1].setPriority(9);
|
||
QVERIFY2(store.reconcileRankOrigin(bougeLaBonne, &reconciliee, &err), qUtf8Printable(err));
|
||
QVERIFY2(store.setConfigs(reconciliee, &err), qUtf8Printable(err));
|
||
// Vers le VIDE, pas vers "user" : le moteur constate un déplacement, il ne sait pas si un
|
||
// humain ou un script l'a voulu. Ne rien affirmer est le minimum honnête.
|
||
QVERIFY2(store.configs().at(1).rankOrigin().isEmpty(),
|
||
"un rang changé doit lever la marque, sans affirmer autre chose à la place");
|
||
|
||
// ---- Un client ne FABRIQUE pas un "auto" -----------------------------------------------
|
||
LoadConfigs fabrique = store.configs();
|
||
fabrique[1].setRankOrigin(QStringLiteral("auto"));
|
||
QVERIFY2(!store.reconcileRankOrigin(fabrique, &reconciliee, &err),
|
||
"un client ne doit pas pouvoir affirmer que le moteur a créé ce rang");
|
||
QVERIFY2(err.contains(QStringLiteral("moteur")), qUtf8Printable(err));
|
||
|
||
// ---- …mais l'aller-retour VERBATIM reste licite ----------------------------------------
|
||
// Sans cette tolérance, tout client qui renvoie ce qu'il a lu serait refusé — la
|
||
// neutralité Get→Set du contrat serait rompue.
|
||
LoadConfigs init2;
|
||
LoadConfig c2 = LoadConfig::fromMap(borneMap(BORNE, 2));
|
||
c2.setRankOrigin(QStringLiteral("auto"));
|
||
init2.append(c2);
|
||
LoadConfigStore store2;
|
||
QVERIFY2(store2.setConfigs(init2, &err), qUtf8Printable(err));
|
||
LoadConfigs verbatim = store2.configs(); // relu tel quel, marque comprise
|
||
QVERIFY2(store2.reconcileRankOrigin(verbatim, &reconciliee, &err), qUtf8Printable(err));
|
||
QCOMPARE(reconciliee.at(0).rankOrigin(), QStringLiteral("auto"));
|
||
|
||
// ---- « user » est la voie EXPLICITE pour lever sans déplacer ---------------------------
|
||
LoadConfigs assume = store2.configs();
|
||
assume[0].setRankOrigin(QStringLiteral("user"));
|
||
QVERIFY2(store2.reconcileRankOrigin(assume, &reconciliee, &err), qUtf8Printable(err));
|
||
QCOMPARE(reconciliee.at(0).rankOrigin(), QStringLiteral("user"));
|
||
|
||
// Et l'énumération est fermée — une valeur libre serait une affirmation qu'on devrait
|
||
// supporter le jour où quelqu'un la lit.
|
||
LoadConfig hors = LoadConfig::fromMap(borneMap(BORNE, 1));
|
||
hors.setRankOrigin(QStringLiteral("installateur"));
|
||
QVERIFY2(!hors.isValid(&err), "rankOrigin hors énumération doit être refusé");
|
||
}
|
||
|
||
/*!
|
||
* \brief [§10 étape D] L'étalement uniforme, et le durcissement qu'il faut savoir DIRE.
|
||
*
|
||
* \par Ce que la mesure a montré avant que rien ne soit asserté
|
||
* L'étape D est la seule des quatre qui change le comportement, et aucun scénario existant ne
|
||
* portait d'obligation éco — `LoadContext::needs` n'était rempli par aucun adaptateur. Il n'y
|
||
* avait donc **aucune référence** à laquelle se comparer : les valeurs ci-dessous ont été
|
||
* relevées d'abord, épinglées ensuite. C'est ce relevé qui a révélé la bascule de période, que
|
||
* la lecture du code ne montrait pas.
|
||
*
|
||
* \par Le durcissement est mécanique, et c'est pourquoi le motif doit le porter
|
||
* 2 kWh restants font **333 W à six heures** de l'échéance, **2 kW à une heure**, et **7 kW —
|
||
* le plafond — dès seize minutes**. La fenêtre où l'achat devient massif n'arrive pas
|
||
* progressivement : elle arrive d'un coup. Un client qui voit sa facture grimper à six heures du
|
||
* matin sans cause lisible cherchera une panne.
|
||
*/
|
||
void TestLoadModel::testEcoSpreadHardensAndSaysSo()
|
||
{
|
||
LoadNeeds needs;
|
||
needs.minEnergyWhPerDay = 2000;
|
||
needs.dailyDeadline = "07:00";
|
||
|
||
EcoProgress p;
|
||
p.delivered = true;
|
||
p.deliveredWh = 0;
|
||
p.targetWh = 2000;
|
||
|
||
const QDate j(2026, 8, 31);
|
||
auto cible = [&](int h, int m) {
|
||
return computeEcoTarget(needs, p, 7000, QDateTime(j, QTime(h, m), QTimeZone::utc()));
|
||
};
|
||
|
||
// 1. LE DURCISSEMENT, relevé point par point. C'est la courbe que le motif doit rendre
|
||
// lisible — pas une valeur isolée, une progression.
|
||
QCOMPARE(qRound(cible(1, 0).watts), 333); // 6 h avant
|
||
QCOMPARE(qRound(cible(5, 0).watts), 1000); // 2 h avant
|
||
QCOMPARE(qRound(cible(6, 0).watts), 2000); // 1 h avant
|
||
QCOMPARE(qRound(cible(6, 30).watts), 4000); // 30 min
|
||
QCOMPARE(qRound(cible(6, 44).watts), 7000); // 16 min — déjà au plafond physique
|
||
|
||
// 2. Le POINT DE NON-RETOUR est franchi sous le seuil, et il se DIT. Sans lui, la cadence
|
||
// tend vers l'infini sans que rien ne décide : un seuil se discute, une asymptote se subit.
|
||
QVERIFY2(!cible(6, 44).pointDeNonRetour, "à 16 min le seuil n'est pas encore franchi");
|
||
QVERIFY2(cible(6, 50).pointDeNonRetour, "à 10 min il doit l'être");
|
||
QCOMPARE(qRound(cible(6, 50).watts), 7000);
|
||
|
||
// 3. La cadence est RECALCULÉE, jamais figée : un rattrapage après une heure sans surplus
|
||
// doit monter. Figée au départ, elle manquerait l'échéance en ayant l'air de travailler.
|
||
p.deliveredWh = 0;
|
||
const double aSixHeures = cible(1, 0).watts;
|
||
const double aDeuxHeures = cible(5, 0).watts;
|
||
QVERIFY2(aDeuxHeures > aSixHeures * 2.5,
|
||
"sans rien livré, la cadence doit avoir monté d'elle-même");
|
||
|
||
// 4. Ce qui est DÉJÀ LIVRÉ allège la cadence — l'étalement porte sur le RESTE.
|
||
p.deliveredWh = 1500;
|
||
QCOMPARE(qRound(cible(5, 0).watts), 250); // 500 Wh en 2 h
|
||
QCOMPARE(qRound(cible(5, 0).remainingWh), 500);
|
||
|
||
// 5. Obligation TENUE : plus rien à acheter pour l'éco.
|
||
p.deliveredWh = 2000;
|
||
QVERIFY2(cible(5, 0).tenue, "2000 sur 2000 : l'obligation est tenue");
|
||
QCOMPARE(qRound(cible(5, 0).watts), 0);
|
||
|
||
// 6. R6 — SOUS `unmeasurable`, ON NE CONCLUT JAMAIS « TENUE ». On ne sait pas ce qui a été
|
||
// livré ; l'affirmer serait affirmer sur ce qu'on ignore. On vise l'obligation entière,
|
||
// qui est le choix qui SERT plutôt que celui qui suppose acquis.
|
||
EcoProgress inconnu;
|
||
inconnu.delivered = false;
|
||
inconnu.targetWh = 2000;
|
||
const EcoTarget ti = computeEcoTarget(needs, inconnu, 7000,
|
||
QDateTime(j, QTime(5, 0), QTimeZone::utc()));
|
||
QVERIFY2(!ti.tenue, "jamais « tenue » sur un avancement qu'on ne mesure pas (LM-1009 C1)");
|
||
QCOMPARE(qRound(ti.remainingWh), 2000);
|
||
|
||
// 7. L'heure quotidienne lue le SOIR vise le LENDEMAIN. La lire comme dépassée ferait
|
||
// franchir le point de non-retour toutes les nuits, et acheter à pleine puissance chaque
|
||
// soir sans qu'aucune échéance ne l'exige.
|
||
const EcoTarget soir = computeEcoTarget(needs, p, 7000,
|
||
QDateTime(j, QTime(23, 0), QTimeZone::utc()));
|
||
QVERIFY2(!soir.pointDeNonRetour, "23:00 vers 07:00 : huit heures, pas un retard");
|
||
QCOMPARE(soir.remainingS, qint64(8 * 3600));
|
||
}
|
||
|
||
void TestLoadModel::testDrawMarginComparesHeadroomsNotCaps()
|
||
{
|
||
// [LM-1006-1] Un plafond PAR PHASE et un plafond TOTAL n'ont pas la même forme et ne se
|
||
// convertissent pas : une maison peut tirer 20 A sur une phase et 2 A sur les deux autres,
|
||
// et « un tiers du total » supposerait un équilibre que rien ne garantit. Ce qui se compare,
|
||
// ce sont les MARGES — des watts dans les deux cas.
|
||
|
||
// ---- Aucun plafond : la marge est SANS OBJET, pas nulle -------------------------------
|
||
// La distinction n'est pas cosmétique : une marge nulle bornerait tout à 0 W et couperait
|
||
// l'installation. C'est le « zéro forgé » du contrat, appliqué à une couche de sécurité.
|
||
const DrawMargin rien = resolveDrawMargin({}, {0, 0, 0}, 0);
|
||
QVERIFY2(!rien.borne, "aucun plafond déclaré doit rendre une marge SANS OBJET");
|
||
|
||
// ---- Une seule source, par phase : c'est la phase la PLUS CHARGÉE qui décide -----------
|
||
const DrawCap disjoncteur{DrawCapSource::Breaker, 5000, -1};
|
||
DrawMargin m = resolveDrawMargin({disjoncteur}, {1000, 4500, 200}, 5700);
|
||
QVERIFY(m.borne);
|
||
QCOMPARE(qRound(m.watts), 500); // 5000 − 4500, la phase B
|
||
QCOMPARE(m.source, DrawCapSource::Breaker);
|
||
QCOMPARE(m.phase, 1); // et on DIT laquelle
|
||
|
||
// ---- Deux formes en concurrence : la plus petite marge mord ---------------------------
|
||
// 3000 W restants au total contre 500 W sur la phase B : c'est la phase qui borne, alors
|
||
// même que son plafond (5000) est plus BAS que le total (8700). Comparer les plafonds
|
||
// aurait donné la réponse inverse.
|
||
const DrawCap gestionnaire{DrawCapSource::GridOperator, -1, 8700};
|
||
m = resolveDrawMargin({gestionnaire, disjoncteur}, {1000, 4500, 200}, 5700);
|
||
QCOMPARE(qRound(m.watts), 500);
|
||
QCOMPARE(m.source, DrawCapSource::Breaker);
|
||
|
||
// …et l'inverse, sur la même mesure : un ordre du gestionnaire plus serré prend la main, et
|
||
// le motif publié doit alors dire « gestionnaire de réseau », jamais « surcharge ».
|
||
const DrawCap ordre{DrawCapSource::GridOperator, -1, 6000};
|
||
m = resolveDrawMargin({disjoncteur, ordre}, {1000, 4500, 200}, 5700);
|
||
QCOMPARE(qRound(m.watts), 300); // 6000 − 5700
|
||
QCOMPARE(m.source, DrawCapSource::GridOperator);
|
||
QCOMPARE(m.phase, -1); // un plafond total n'a pas de phase
|
||
|
||
// ---- Marge NÉGATIVE : le plafond est déjà dépassé, et de combien ----------------------
|
||
// C'est ce que la couche L4 doit délester. Écrêter à 0 perdrait l'ampleur du dépassement.
|
||
m = resolveDrawMargin({disjoncteur}, {1000, 6200, 200}, 7400);
|
||
QCOMPARE(qRound(m.watts), -1200);
|
||
QCOMPARE(m.phase, 1);
|
||
|
||
// ---- Marges ÉGALES : le départage est STABLE, pas dépendant de l'ordre du tableau -----
|
||
// Sans règle, le motif publié changerait au gré de l'ordre d'insertion — donc sans qu'aucune
|
||
// décision n'ait changé, ce qui est exactement ce qu'un motif ne doit jamais faire.
|
||
const DrawCap eco{DrawCapSource::EcoSelfImposed, -1, 6000};
|
||
const DrawCap ordre2{DrawCapSource::GridOperator, -1, 6000};
|
||
const DrawMargin a = resolveDrawMargin({eco, ordre2}, {2000, 2000, 2000}, 5700);
|
||
const DrawMargin b = resolveDrawMargin({ordre2, eco}, {2000, 2000, 2000}, 5700);
|
||
QCOMPARE(a.source, b.source);
|
||
QCOMPARE(a.source, DrawCapSource::GridOperator); // imposé > auto-imposé
|
||
|
||
// ---- Les codes franchissent la frontière RPC, et ils sont fermés ----------------------
|
||
QCOMPARE(drawCapSourceCode(DrawCapSource::Breaker), QStringLiteral("connection"));
|
||
QCOMPARE(drawCapSourceCode(DrawCapSource::GridOperator), QStringLiteral("gridOperator"));
|
||
QCOMPARE(drawCapSourceCode(DrawCapSource::EcoSelfImposed), QStringLiteral("selfImposed"));
|
||
}
|