From bb674e52a78fdd5b703a4b8af9e5eff04d7ebfaf Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Wed, 26 Aug 2026 10:48:51 +0200 Subject: [PATCH] =?UTF-8?q?feat(lot=20E=20=C2=A712):=20le=20plancher=20de?= =?UTF-8?q?=20modulation=20=E2=80=94=20rien,=20ou=20au=20moins=20X?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H --- debian-qt5/changelog | 28 ++++++++ .../etm/adapters/etmvariableloadadapter.cpp | 9 +++ .../etm/adapters/etmvariableloadadapter.h | 6 ++ energyplugin/etm/adapters/relayrouter.cpp | 3 + energyplugin/etm/adapters/sgreadyadapter.cpp | 8 +++ energyplugin/etm/energyarbitrator.cpp | 3 +- .../etm/scheduler/rulebasedscheduler.cpp | 20 ++++++ energyplugin/etm/types/decisionreason.cpp | 6 ++ energyplugin/etm/types/decisionreason.h | 10 +++ energyplugin/etm/types/loadconfig.cpp | 20 ++++++ energyplugin/etm/types/loadconfig.h | 16 +++++ energyplugin/etm/types/loaddescriptor.h | 19 +++++ energyplugin/nymeaenergyjsonhandler.cpp | 6 ++ tests/auto/loadmodel/testloadmodel.cpp | 72 +++++++++++++++++++ tests/auto/loadmodel/testloadmodel.h | 10 +++ 15 files changed, 235 insertions(+), 1 deletion(-) 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