Deux champs optionnels de même forme sur LoadConfig — o:meterThingId, o:sensorThingId — et leur publication en télémétrie. Rien d'autre : l'arbitre ne s'en sert pas pour décider, et le §12 comme le §10 restent à faire. « Aucun effet sur les décisions » est vrai par CONSTRUCTION, pas par discipline : les adaptateurs ne voient jamais ces Things. attachMeasurements() les résout au moment de publier, dans les deux chemins de télémétrie — y compris le mode dégradé, où un exploitant veut précisément voir ce que la charge fait pendant que l'arbitre ne décide plus rien. measuredW est publié À CÔTÉ de allocatedW et jamais fondu dedans : l'un est ce que le waterfall a alloué, l'autre ce qu'un compteur a vu. Les confondre rendrait la somme des allocations irréconciliable avec budget.allocatedW — et injecter une mesure en retard sur la commande réintroduirait l'oscillation que le recrédit a supprimée. claimedThingIds() les exclut, et l'exclusion est maintenant commentée sur place : LM-201 vaut pour ce qui commande, ceux-ci mesurent. Sans ce commentaire, la symétrie avec relays[] invitait à les ajouter — ce qui ferait refuser un compteur divisionnaire partagé ou une sonde de pièce commune. spec 0.2.1 : la précondition de livraison du §10 (extension du délestage au waterfall, même lot ou avant, jamais après) est écrite comme CONDITION et non comme conseil — la protection de surcharge ne déleste que les bornes, et des planchers éco qui s'additionnent un matin sans soleil sur un branchement 6 kVA feraient disjoncter l'installation. Et LM-1008 tranche la cible de confort : saisie en degrés, frontière de l'arbitre en énergie, conversion à la couche domaine — et aucune conversion nécessaire aujourd'hui, un comparateur suffit, c'est l'arrêt qui compte et pas le dosage. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
216 lines
9.7 KiB
C++
216 lines
9.7 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/config/loadconfigstore.h"
|
|
|
|
#include <QTest>
|
|
|
|
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");
|
|
}
|
|
|
|
QTEST_MAIN(TestLoadModel)
|