Patrick Schurig 639689baa9 feat(lot E §12): le plancher de modulation — rien, ou au moins X
Le domaine d'une consigne devient {0} ∪ [minPowerW, maxPowerW], un ensemble discontinu.
LM-1203 est la règle qui fait le travail : sous le plancher, allouer 0 et TRANSMETTRE le
budget à la charge suivante.

Constat en lisant le waterfall : il satisfaisait déjà LM-1203 pour les charges à
paliers — le plus haut palier ≤ budget vaut 0 quand le budget est sous le premier, et
le résidu cascade. Le trou n'était que dans la branche continue, où
qBound(0, budget, max) acceptait n'importe quelle miette. Le correctif est donc plus
petit que la spec ne le laissait craindre, et localisé.

Le plancher est déclaré pour etmvariableload dynamic et dérivé partout ailleurs — plus
petit palier non nul chez relay-router, plus petite estimation non nulle chez sg-ready.
SetLoadConfig refuse de le déclarer là où il se dérive, et refuse aussi minPowerW >
maxPowerW : le domaine serait réduit à {0}, la charge ne démarrerait jamais, et rien
dans le journal ne dirait pourquoi.

Motif BELOW_MIN_POWER distinct de SURPLUS_INSUFFICIENT : les confondre enverrait
chercher du soleil là où il faut lire une fiche technique.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
2026-08-26 10:48:51 +02:00

288 lines
13 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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");
}
// ─────────────────────────────────────────────────────────────────────────────
// §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)