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
This commit is contained in:
Patrick Schurig 2026-08-26 10:48:51 +02:00
parent 0e15203a67
commit bb674e52a7
15 changed files with 235 additions and 1 deletions

View File

@ -1,3 +1,31 @@
powersync-energy-plugin-nymea (1.15.2+etm18) trixie; urgency=medium
* Lot E §12 — le plancher de modulation. Le domaine d'une consigne cesse d'être
[0, maxPowerW] pour devenir {0} ∪ [minPowerW, maxPowerW], un ensemble DISCONTINU —
parce que c'est ce que fait le matériel : une borne ne charge pas sous 6 A, une PAC n'a
pas d'« état 3 à moitié ».
* LM-1203, la règle qui fait le travail : sous le plancher, la charge reçoit 0 W et le
budget PASSE à la suivante. Sans elle, une charge de rang 1 à plancher élevé retiendrait
en otage un budget qu'elle ne peut pas utiliser, et tout le waterfall en aval resterait à
sec pendant qu'elle-même ne démarre pas.
* Le plancher est DÉCLARÉ pour etmvariableload dynamic et DÉRIVÉ ailleurs — plus petit
palier non nul chez relay-router, plus petite estimation non nulle chez sg-ready. Même
règle que stagesW au lot D : ne pas déclarer ce qui se dérive. SetLoadConfig refuse donc
o:minPowerW sur relay-router et en mode fixed, et le refuse aussi s'il dépasse maxPowerW —
le domaine serait réduit à {0} et la charge ne démarrerait jamais sans que rien ne dise
pourquoi.
* Motif BELOW_MIN_POWER (params budgetW, minPowerW), distinct de SURPLUS_INSUFFICIENT.
Les confondre enverrait diagnostiquer un manque de soleil là où il faut lire une fiche
technique : le budget peut être confortable et rester sous le minimum de CE matériel.
C'est ECS-309 appliqué — le motif nomme le mécanisme qui a produit l'issue.
* Le waterfall satisfaisait DÉJÀ LM-1203 pour les charges à paliers : le plus haut palier
≤ budget vaut 0 quand le budget est sous le premier palier, et le budget cascade. Le trou
n'était que dans la branche continue, où qBound(0, budget, max) acceptait n'importe quelle
miette.
* tests/auto/loadmodel : 15/15 en 3 ms.
-- Patrick Schurig <etm.schurig@gmail.com> Wed, 26 Aug 2026 13:00:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm17) trixie; urgency=medium
* Lot E §11 — compteur et sonde par charge. Deux champs optionnels de même forme sur

View File

@ -17,6 +17,7 @@ EtmVariableLoadAdapter::EtmVariableLoadAdapter(ThingManager *thingManager,
const QString &label,
const QList<int> &powerLevels,
int maxPowerW,
int minPowerW,
int priority,
const LoadNeeds &needs,
QObject *parent)
@ -26,6 +27,7 @@ EtmVariableLoadAdapter::EtmVariableLoadAdapter(ThingManager *thingManager,
, m_label(label)
, m_powerLevels(powerLevels)
, m_maxPowerW(maxPowerW)
, m_minPowerW(minPowerW)
, m_priority(priority)
, m_needs(needs)
{
@ -45,6 +47,9 @@ LoadDescriptor EtmVariableLoadAdapter::descriptor() const
d.declared.powerLevels = m_powerLevels;
d.declared.maxPowerW = m_maxPowerW;
// §12 / LM-1202 — seul mécanisme où le plancher est DÉCLARÉ : ni les paliers ni le
// plafond ne le portent, et le thing ne l'expose pas.
d.declared.minPowerW = m_minPowerW;
d.needs = m_needs;
d.supportedKinds = { LoadAction::Setpoint };
@ -141,6 +146,10 @@ LoadRuntimeView EtmVariableLoadAdapter::runtimeView(const QDateTime &now) const
v.mechanism.insert(QStringLiteral("percent"),
qRound(1000.0 * m_currentSetpointW / m_maxPowerW) / 10.0);
v.mechanism.insert(QStringLiteral("maxPowerW"), static_cast<double>(m_maxPowerW));
// §12 — publié seulement s'il existe : « omis, jamais nul ». Un plancher de 0 W se lirait
// « cette charge accepte n'importe quelle miette », ce qui est justement ce qu'on corrige.
if (m_minPowerW > 0)
v.mechanism.insert(QStringLiteral("minPowerW"), static_cast<double>(m_minPowerW));
// ECS-414 — deux causes DISTINCTES d'indisponibilité, deux codes. Les confondre enverrait
// diagnostiquer un matériel muet là où c'est un Thing absent de la configuration.

View File

@ -58,6 +58,10 @@ public:
* \param powerLevels Paliers atteignables en W (DÉCLARÉS, triés croissants, \c 0 inclus)
* en mode *fixed* ; liste vide ⇒ *dynamic* (modulation 0..maxPowerW).
* \param maxPowerW Plafond physique (W) — contrat §2.
* \param minPowerW Plancher de modulation (W) — §12 / LM-1201. `0` = aucun plancher.
* Sous cette valeur la charge ne prend RIEN : le domaine de la
* consigne est `{0} ∪ [minPowerW, maxPowerW]`. **Déclaré**, parce
* qu'aucune donnée du thing ne permet de le deviner.
* \param priority Rang dans le waterfall (contrat §5 / OPTIMIZER_PROTOCOL §5) : valeur
* plus BASSE = servi en premier (rang 1 = premier servi).
* \param needs Besoins déclarés (échéances, énergie min) — exposés au scheduler
@ -69,6 +73,7 @@ public:
const QString &label,
const QList<int> &powerLevels,
int maxPowerW,
int minPowerW,
int priority,
const LoadNeeds &needs = LoadNeeds(),
QObject *parent = nullptr);
@ -163,6 +168,7 @@ private:
QString m_label;
QList<int> m_powerLevels; //!< Paliers W déclarés (vide = dynamic).
int m_maxPowerW;
int m_minPowerW; //!< Plancher de modulation (W) ; 0 = aucun (§12).
int m_priority;
LoadNeeds m_needs;

View File

@ -106,6 +106,9 @@ LoadDescriptor RelayRouter::descriptor() const
// Paliers DÉRIVÉS exposés à l'optimiseur (watts) — JAMAIS les relais.
d.declared.powerLevels = m_levels;
d.declared.maxPowerW = m_levels.isEmpty() ? 0 : m_levels.last();
// §12 / LM-1202 — plancher DÉRIVÉ : le plus petit palier non nul. m_levels[0] vaut
// toujours 0 (sous-ensemble vide), donc le plancher est le suivant s'il existe.
d.declared.minPowerW = m_levels.size() > 1 ? m_levels.at(1) : 0;
d.limits.minOnS = m_minOnS;
d.limits.minOffS = m_minOffS;
d.needs = m_needs;

View File

@ -148,6 +148,14 @@ LoadDescriptor SgReadyAdapter::descriptor() const
d.declared.states = m_states;
d.declared.estimatedPowerW = m_estimatedPowerW;
// §12 / LM-1202 — plancher DÉRIVÉ : la plus petite estimation non nulle des états
// commandables. Le mapping d'états l'applique déjà implicitement (un état se prend
// entier ou pas du tout) ; le déclarer rend le modèle uniforme et lisible de l'extérieur.
double plancher = 0;
for (auto it = m_estimatedPowerW.constBegin(); it != m_estimatedPowerW.constEnd(); ++it)
if (it.value() > 0 && (plancher == 0 || it.value() < plancher))
plancher = it.value();
d.declared.minPowerW = plancher;
d.limits.minStateHoldS = m_minStateHoldS;
d.supportedKinds = { LoadAction::State };

View File

@ -294,7 +294,8 @@ void EnergyArbitrator::rebuildLoadAdapters()
<< c.label() << "(" << c.relaysList().count() << "relais)";
} else {
adapter = new EtmVariableLoadAdapter(m_tm, c.id(), c.label(), c.powerLevelsInt(),
c.maxPowerW(), c.priority(), needs, this);
c.maxPowerW(), c.minPowerW(), c.priority(),
needs, this);
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] etmvariableload construit depuis config:"
<< c.label() << "(" << (c.isDynamic() ? "dynamic" : "fixed") << ")";
}

View File

@ -202,10 +202,24 @@ LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc,
const bool dynamic = levels.isEmpty();
double setpointW;
bool sousPlancher = false;
if (dynamic) {
// Dynamic (routeur PV / triac) : modulation continue bornée au plafond déclaré.
const double maxW = lc.declared.maxPowerW > 0 ? lc.declared.maxPowerW : budgetW;
setpointW = qBound(0.0, budgetW, maxW);
// §12 / LM-1203 — le domaine n'est pas [0, max] mais {0} ∪ [min, max]. Sous le
// plancher, la charge ne prend RIEN, et le budget PASSE à la suivante.
//
// Ne rien transmettre serait le vrai défaut : une charge de rang 1 au plancher élevé
// retiendrait en otage un budget qu'elle ne peut pas utiliser, et tout le waterfall en
// aval resterait à sec pendant qu'elle-même ne démarre pas. C'est le cas des deux
// bornes — 2 kW chacune sous un plancher de 1,38 kW n'en fait démarrer aucune, quand
// servir la première à fond en fait charger une.
if (lc.declared.minPowerW > 0 && setpointW > 0 && setpointW < lc.declared.minPowerW) {
setpointW = 0;
sousPlancher = true;
}
} else {
// Fixed : plus haut palier déclaré ≤ budget (levels triés croissants, 0 inclus →
// garantit une consigne valide même budget ≤ 0). Granularité connue → zéro cycle de retard.
@ -294,6 +308,12 @@ LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc,
la.reason = { DecisionCode::SurplusSetpoint,
{{"budgetW", qRound(budgetW)}, {"setpointW", qRound(setpointW)},
{"stepped", !dynamic}} };
else if (sousPlancher)
// §12 — dire QUE le budget est sous le plancher, pas qu'il est insuffisant : la
// seconde phrase enverrait chercher du soleil là où il faut lire une fiche technique.
la.reason = { DecisionCode::BelowMinPower,
{{"budgetW", qRound(budgetW)},
{"minPowerW", qRound(lc.declared.minPowerW)}} };
else
la.reason = { DecisionCode::SurplusInsufficient, {{"budgetW", qRound(budgetW)}} };

View File

@ -64,6 +64,12 @@ QString renderFr(const DecisionReason &reason, const QString &label)
if (c == QLatin1String(DecisionCode::SurplusInsufficient))
return QStringLiteral("Surplus insuffisant (%1 W) — %2 à 0 W")
.arg(w("budgetW")).arg(label);
// §12 — la phrase nomme le PLANCHER, pas le manque de surplus : le budget peut être
// confortable et rester sous le minimum que ce matériel accepte.
if (c == QLatin1String(DecisionCode::BelowMinPower))
return QStringLiteral("Budget %1 W sous le plancher de %2 (%3 W) — 0 W, "
"le budget passe à la charge suivante")
.arg(w("budgetW")).arg(label).arg(w("minPowerW"));
// --- SG-Ready (PAC) -----------------------------------------------------
if (c == QLatin1String(DecisionCode::LockMinStateHold))

View File

@ -61,6 +61,16 @@ constexpr const char *LockMinOffCapped = "LOCK_MIN_OFF_CAPPED";
constexpr const char *SurplusSetpoint = "SURPLUS_SETPOINT";
//! params: \c budgetW — le budget ne payait aucun palier ; aucun verrou n'a mordu.
constexpr const char *SurplusInsufficient = "SURPLUS_INSUFFICIENT";
/*!
* \brief params: \c budgetW, \c minPowerW — le budget est sous le PLANCHER de la charge.
*
* §12 / LM-1203. Distinct de \c SURPLUS_INSUFFICIENT, qui dit « le budget ne payait aucun
* palier ». Ici le budget est peut-être confortable dans l'absolu — il est simplement sous le
* minimum que CE matériel accepte, et la charge a donc reçu 0 pendant que le budget partait à
* la suivante. Les confondre enverrait diagnostiquer un manque de soleil là où il faut lire
* une fiche technique (ECS-309 : le motif nomme le mécanisme qui a produit l'issue).
*/
constexpr const char *BelowMinPower = "BELOW_MIN_POWER";
// --- SG-Ready (PAC) ---------------------------------------------------------
//! params: \c state — le verrou minStateHold maintient l'état (court-cycling compresseur).

View File

@ -280,6 +280,11 @@ bool LoadConfig::isValid(QString *error) const
}
if (m_minOnS < 0 || m_minOffS < 0)
return fail(QStringLiteral("relay-router : minOnS/minOffS doivent être ≥ 0"));
// §12 / LM-1202 — même raison qu'en etmvariableload fixed : ici le plancher est le
// plus petit palier atteignable, donc il se dérive de relays[].
if (m_minPowerW > 0)
return fail(QStringLiteral("relay-router : minPowerW interdit — le plancher est le "
"plus petit palier dérivé des relais"));
} else if (m_adapter == QStringLiteral("etmvariableload")) {
// Cas thing unique : continu (dynamic) ou multipalier natif (fixed + powerLevels, legacy).
@ -290,11 +295,23 @@ bool LoadConfig::isValid(QString *error) const
return fail(QStringLiteral("etmvariableload fixed : powerLevels requis (non vide)"));
if (m_powerLevels.first() != 0)
return fail(QStringLiteral("etmvariableload fixed : powerLevels doit inclure 0"));
// §12 / LM-1202 — en fixed le plancher est le plus petit palier non nul : il se
// DÉRIVE. Le déclarer en plus ouvrirait deux sources d'une même vérité, et rien
// ne dirait laquelle fait foi le jour où elles divergent.
if (m_minPowerW > 0)
return fail(QStringLiteral("etmvariableload fixed : minPowerW interdit — le "
"plancher est le plus petit palier non nul"));
} else { // dynamic
if (m_maxPowerW <= 0)
return fail(QStringLiteral("etmvariableload dynamic : maxPowerW requis (> 0)"));
if (!m_powerLevels.isEmpty())
return fail(QStringLiteral("etmvariableload dynamic : powerLevels doit être absent"));
// Un plancher au-dessus du plafond rendrait la charge inatteignable : le domaine
// {0} ∪ [min, max] serait réduit à {0}, et la charge ne démarrerait jamais.
if (m_minPowerW > static_cast<uint>(m_maxPowerW))
return fail(QStringLiteral("etmvariableload dynamic : minPowerW (%1 W) dépasse "
"maxPowerW (%2 W) — la charge ne pourrait jamais démarrer")
.arg(m_minPowerW).arg(m_maxPowerW));
}
} else {
@ -316,6 +333,8 @@ QVariantMap LoadConfig::toMap() const
if (!m_domain.isEmpty())
m.insert("domain", m_domain);
// Mêmes règles que le domaine : omis quand vide, jamais une chaîne vide persistée.
if (m_minPowerW > 0)
m.insert("minPowerW", m_minPowerW);
if (!m_meterThingId.isEmpty())
m.insert("meterThingId", m_meterThingId);
if (!m_sensorThingId.isEmpty())
@ -352,6 +371,7 @@ LoadConfig LoadConfig::fromMap(const QVariantMap &map)
// ThingManager. Et refuser un ThingId encore inconnu imposerait un ORDRE aux opérations —
// déclarer le compteur avant la charge — que rien ne justifie. Un id qui ne résout pas
// produit une absence de mesure, pas une configuration invalide.
c.m_minPowerW = map.value("minPowerW").toUInt();
c.m_meterThingId = map.value("meterThingId").toString();
c.m_sensorThingId = map.value("sensorThingId").toString();
c.setPowerLevels(map.value("powerLevels").toList()); // trie + déduplique

View File

@ -124,6 +124,7 @@ class LoadConfig
Q_GADGET
Q_PROPERTY(QString id READ id WRITE setId)
Q_PROPERTY(QString label READ label WRITE setLabel)
Q_PROPERTY(uint minPowerW READ minPowerW WRITE setMinPowerW)
Q_PROPERTY(QString meterThingId READ meterThingId WRITE setMeterThingId)
Q_PROPERTY(QString sensorThingId READ sensorThingId WRITE setSensorThingId)
Q_PROPERTY(QString adapter READ adapter WRITE setAdapter)
@ -192,6 +193,20 @@ public:
* moyennage du compteur), et la charge est **déjà comptée** dans le compteur racine : ce
* compteur-ci en est une décomposition, pas une source à additionner.
*/
/*!
* \brief Plancher de modulation (W) — §12 / LM-1201. `0` = aucun plancher.
*
* La charge ne prend **rien**, ou **au moins ceci** : le domaine de la consigne est
* `{0} ∪ [minPowerW, maxPowerW]`, un ensemble discontinu. C'est ce que fait le matériel
* réel — une borne ne charge pas sous 6 A.
*
* **N'a de sens qu'en mode `dynamic`.** En `fixed`, le plancher est le plus petit palier
* non nul : il se **dérive**, et le déclarer en double ouvrirait deux sources d'une même
* vérité. `isValid()` refuse donc la combinaison.
*/
uint minPowerW() const { return m_minPowerW; }
void setMinPowerW(uint v) { m_minPowerW = v; }
QString meterThingId() const { return m_meterThingId; }
void setMeterThingId(const QString &v) { m_meterThingId = v; }
@ -317,6 +332,7 @@ private:
QString m_domain; //!< Code d'énumération fermée ; vide = charge non classée.
//! Compteur de vérification ; vide = aucun. N'entre PAS dans claimedThingIds() : il
//! mesure, il ne commande pas (LM-1104), donc deux charges peuvent le partager.
uint m_minPowerW = 0; //!< Plancher de modulation (W) ; 0 = aucun (§12).
QString m_meterThingId;
//! Sonde de température ; vide = aucune. Même règle d'exclusivité que le compteur.
QString m_sensorThingId;

View File

@ -28,6 +28,25 @@ struct LoadDeclared {
//! Plafond physique de la charge (W) — contrat etmvariableload §2 \c maxPowerW. REQUIS en
//! *dynamic* ; sert aussi de plafond en *fixed*.
double maxPowerW = 0;
/*!
* \brief Plancher de modulation (W) : la charge ne prend RIEN, ou au moins ceci. 0 = aucun.
*
* §12 / LM-1200. Le domaine d'une consigne cesse d'être `[0, maxPowerW]` pour devenir
* **`{0} ∪ [minPowerW, maxPowerW]`** — un ensemble discontinu, parce que c'est ce que fait
* le matériel réel : une borne ne charge pas sous 6 A, une PAC n'a pas d'« état 3 à
* moitié ».
*
* **Déclaré pour un seul mécanisme, dérivé pour les deux autres** (LM-1202) — même règle
* que \c stagesW au lot D : ne pas déclarer ce qui se dérive, deux sources d'une même
* vérité divergent.
*
* | mécanisme | origine |
* |---|---|
* | `etmvariableload` | **déclaré** — rien ne permet de le deviner |
* | `relay-router` | dérivé : plus petit palier non nul |
* | `sg-ready` | dérivé : estimation de l'état 3 |
*/
double minPowerW = 0;
// --- battery ---
double maxChargeW = 0; //!< Puissance max de charge (W).

View File

@ -198,6 +198,11 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
loadItem.insert("enabled", enumValueName(Bool));
loadItem.insert("o:powerLevels", QVariantList() << enumValueName(Uint)); // etmvariableload fixed
loadItem.insert("o:maxPowerW", enumValueName(Uint)); // etmvariableload dynamic
// §12 (LM-1201) — plancher de modulation : la charge ne prend RIEN, ou au moins ceci.
// N'a de sens qu'en etmvariableload *dynamic* ; sur relay-router et en *fixed* le
// plancher se DÉRIVE des paliers, et LoadConfig::isValid() refuse de le déclarer en
// double (LM-302 : l'exigence réelle n'est jamais dans le schéma).
loadItem.insert("o:minPowerW", enumValueName(Uint));
loadItem.insert("o:needs", needsItem);
loadItem.insert("o:relays", QVariantList() << relayItem); // relay-router (rév. 3)
// LM-300 — charge utile du mécanisme sg-ready. estimatedPowerW porte son nom : c'est une
@ -705,6 +710,7 @@ QVariantMap NymeaEnergyJsonHandler::loadTelemetrySchema()
mechItem.insert("o:setpointW", enumValueName(Double)); // variable
mechItem.insert("o:percent", enumValueName(Double)); // variable
mechItem.insert("o:maxPowerW", enumValueName(Double)); // variable
mechItem.insert("o:minPowerW", enumValueName(Double)); // variable — plancher (§12)
mechItem.insert("o:state", enumValueName(Int)); // sgReady
mechItem.insert("o:estimatedPowerW", enumValueName(Double)); // sgReady
mechItem.insert("o:chargingEnabled", enumValueName(Bool)); // evcharger (réservé 3g)

View File

@ -212,4 +212,76 @@ void TestLoadModel::testMeasurementThingsAreSoftFields()
"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)

View File

@ -46,6 +46,16 @@ private slots:
//! Et les changer ne reconstruit rien : ils n'ont aucun effet sur le matériel.
void testMeasurementThingsAreSoftFields();
// ── §12 — le plancher de modulation ────────────────────────────────────
//! Déclaré en dynamic, et il traverse l'aller-retour.
void testFloorIsDeclaredForContinuousLoads();
//! Interdit là où il se DÉRIVE : deux sources d'une même vérité divergent.
void testFloorIsRefusedWhereItWouldBeDerived();
//! Un plancher au-dessus du plafond rendrait la charge inatteignable.
void testFloorAboveCeilingIsRefused();
};
#endif // TESTLOADMODEL_H