Patrick Schurig bbb3492c2c feat(lot E §11): compteur et sonde par charge, publiés et sans effet sur les décisions
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
2026-08-26 10:34:41 +02:00

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)