diff --git a/debian-qt5/changelog b/debian-qt5/changelog index 0d4e47d..1ab463c 100644 --- a/debian-qt5/changelog +++ b/debian-qt5/changelog @@ -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 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 diff --git a/energyplugin/etm/adapters/etmvariableloadadapter.cpp b/energyplugin/etm/adapters/etmvariableloadadapter.cpp index 240ddbc..e9b9d02 100644 --- a/energyplugin/etm/adapters/etmvariableloadadapter.cpp +++ b/energyplugin/etm/adapters/etmvariableloadadapter.cpp @@ -17,6 +17,7 @@ EtmVariableLoadAdapter::EtmVariableLoadAdapter(ThingManager *thingManager, const QString &label, const QList &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(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(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. diff --git a/energyplugin/etm/adapters/etmvariableloadadapter.h b/energyplugin/etm/adapters/etmvariableloadadapter.h index 01367f8..9782241 100644 --- a/energyplugin/etm/adapters/etmvariableloadadapter.h +++ b/energyplugin/etm/adapters/etmvariableloadadapter.h @@ -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 &powerLevels, int maxPowerW, + int minPowerW, int priority, const LoadNeeds &needs = LoadNeeds(), QObject *parent = nullptr); @@ -163,6 +168,7 @@ private: QString m_label; QList 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; diff --git a/energyplugin/etm/adapters/relayrouter.cpp b/energyplugin/etm/adapters/relayrouter.cpp index 06fa77e..dda1580 100644 --- a/energyplugin/etm/adapters/relayrouter.cpp +++ b/energyplugin/etm/adapters/relayrouter.cpp @@ -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; diff --git a/energyplugin/etm/adapters/sgreadyadapter.cpp b/energyplugin/etm/adapters/sgreadyadapter.cpp index aa65c3d..d4c8e4a 100644 --- a/energyplugin/etm/adapters/sgreadyadapter.cpp +++ b/energyplugin/etm/adapters/sgreadyadapter.cpp @@ -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 }; diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index 9690f14..050550e 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -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") << ")"; } diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.cpp b/energyplugin/etm/scheduler/rulebasedscheduler.cpp index 165c4d2..fb7413c 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.cpp +++ b/energyplugin/etm/scheduler/rulebasedscheduler.cpp @@ -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)}} }; diff --git a/energyplugin/etm/types/decisionreason.cpp b/energyplugin/etm/types/decisionreason.cpp index 7354d94..00915b2 100644 --- a/energyplugin/etm/types/decisionreason.cpp +++ b/energyplugin/etm/types/decisionreason.cpp @@ -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)) diff --git a/energyplugin/etm/types/decisionreason.h b/energyplugin/etm/types/decisionreason.h index 0ae0baa..72a5f89 100644 --- a/energyplugin/etm/types/decisionreason.h +++ b/energyplugin/etm/types/decisionreason.h @@ -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). diff --git a/energyplugin/etm/types/loadconfig.cpp b/energyplugin/etm/types/loadconfig.cpp index d352e1f..105c463 100644 --- a/energyplugin/etm/types/loadconfig.cpp +++ b/energyplugin/etm/types/loadconfig.cpp @@ -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(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 diff --git a/energyplugin/etm/types/loadconfig.h b/energyplugin/etm/types/loadconfig.h index 5765319..c39e796 100644 --- a/energyplugin/etm/types/loadconfig.h +++ b/energyplugin/etm/types/loadconfig.h @@ -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; diff --git a/energyplugin/etm/types/loaddescriptor.h b/energyplugin/etm/types/loaddescriptor.h index c18fc73..632fa7a 100644 --- a/energyplugin/etm/types/loaddescriptor.h +++ b/energyplugin/etm/types/loaddescriptor.h @@ -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). diff --git a/energyplugin/nymeaenergyjsonhandler.cpp b/energyplugin/nymeaenergyjsonhandler.cpp index 5c0ba4a..8710ebf 100644 --- a/energyplugin/nymeaenergyjsonhandler.cpp +++ b/energyplugin/nymeaenergyjsonhandler.cpp @@ -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) diff --git a/tests/auto/loadmodel/testloadmodel.cpp b/tests/auto/loadmodel/testloadmodel.cpp index 672f852..9ccafe2 100644 --- a/tests/auto/loadmodel/testloadmodel.cpp +++ b/tests/auto/loadmodel/testloadmodel.cpp @@ -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) diff --git a/tests/auto/loadmodel/testloadmodel.h b/tests/auto/loadmodel/testloadmodel.h index 9b582a0..ff97beb 100644 --- a/tests/auto/loadmodel/testloadmodel.h +++ b/tests/auto/loadmodel/testloadmodel.h @@ -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