feat(config): LM-300 — union discriminée, charge utile sg-ready

Première implémentation réelle du schéma de spec_loadmodel.md §3. Le champ `adapter`
devient le discriminant ; chaque mécanisme a UNE charge utile et valide la sienne :

  relay-router    → relays[]
  etmvariableload → powerLevels[] | maxPowerW
  sg-ready        → sgReady { states[] { state, relays[], estimatedPowerW }, minStateHoldS }

isValid() rejette les charges utiles étrangères dans les deux sens (LM-302 : les états
invalides doivent être inexprimables), et refuse toute configuration sg-ready dépourvue
de l'état 2, avec un message explicite.

estimatedPowerW garde ce nom parce que c'en est une : une PAC ne consomme pas la même
chose à −5 °C et à +12 °C. Le champ sert à ordonner et à budgéter ; la mesure reste la
source de vérité (ECS-500).

C'est un format PERSISTÉ : la forme est ce qui coûte cher à rattraper une fois que le
banc et la beta auront écrit des données. D'où l'union plutôt qu'un troisième cas
particulier.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-08-09 12:34:39 +02:00
parent 42cb2f080b
commit ef67231ed1
2 changed files with 166 additions and 1 deletions

View File

@ -42,8 +42,67 @@ LoadConfigRelay LoadConfigRelay::fromMap(const QVariantMap &map)
return r;
}
// ---- LoadConfigSgReady -----------------------------------------------------
QVariantMap LoadConfigSgReadyState::toMap() const
{
QVariantMap m;
m.insert("state", state);
m.insert("relays", QVariant(relays));
m.insert("estimatedPowerW", estimatedPowerW);
return m;
}
LoadConfigSgReadyState LoadConfigSgReadyState::fromMap(const QVariantMap &map)
{
LoadConfigSgReadyState s;
s.state = map.value("state").toInt();
for (const QVariant &v : map.value("relays").toList())
s.relays.append(v.toString());
s.estimatedPowerW = map.value("estimatedPowerW").toDouble();
return s;
}
QVariantMap LoadConfigSgReady::toMap() const
{
QVariantMap m;
QVariantList l;
for (const LoadConfigSgReadyState &s : states)
l.append(s.toMap());
m.insert("states", l);
m.insert("minStateHoldS", minStateHoldS);
return m;
}
LoadConfigSgReady LoadConfigSgReady::fromMap(const QVariantMap &map)
{
LoadConfigSgReady p;
for (const QVariant &v : map.value("states").toList())
p.states.append(LoadConfigSgReadyState::fromMap(v.toMap()));
p.minStateHoldS = map.value("minStateHoldS").toInt();
return p;
}
bool LoadConfigSgReady::hasState(int s) const
{
for (const LoadConfigSgReadyState &e : states)
if (e.state == s)
return true;
return false;
}
// ---- LoadConfig ------------------------------------------------------------
QVariantMap LoadConfig::sgReady() const
{
return m_sgReady.states.isEmpty() ? QVariantMap() : m_sgReady.toMap();
}
void LoadConfig::setSgReady(const QVariantMap &v)
{
m_sgReady = LoadConfigSgReady::fromMap(v);
}
QVariantList LoadConfig::relays() const
{
QVariantList list;
@ -89,6 +148,53 @@ bool LoadConfig::isValid(QString *error) const
if (m_mode != QStringLiteral("fixed") && m_mode != QStringLiteral("dynamic"))
return fail(QStringLiteral("mode doit être \"fixed\" ou \"dynamic\" (reçu: %1)").arg(m_mode));
// LM-300 — UNION DISCRIMINÉE par mécanisme. Le discriminant est \c adapter ; chaque
// mécanisme a UNE charge utile, et les charges utiles des autres mécanismes DOIVENT être
// absentes (LM-302 : les états invalides doivent être inexprimables). Ajouter un
// mécanisme = un type de charge utile + une branche ici, rien d'autre (LM-301).
const bool aRelays = !m_relays.isEmpty();
const bool aSgReady = !m_sgReady.states.isEmpty();
const bool aLevels = !m_powerLevels.isEmpty() || m_maxPowerW > 0;
if (m_adapter == QStringLiteral("sg-ready")) {
if (aRelays)
return fail(QStringLiteral("sg-ready : relays[] interdit (charge utile d'un autre mécanisme)"));
if (aLevels)
return fail(QStringLiteral("sg-ready : powerLevels/maxPowerW interdits (charge utile d'un autre mécanisme)"));
if (!aSgReady)
return fail(QStringLiteral("sg-ready : charge utile sgReady.states[] requise (non vide)"));
if (m_mode != QStringLiteral("fixed"))
return fail(QStringLiteral("sg-ready : mode doit être \"fixed\" (4 états normés, pas une modulation)"));
if (m_sgReady.minStateHoldS < 0)
return fail(QStringLiteral("sg-ready : minStateHoldS doit être ≥ 0"));
QList<int> vus;
for (const LoadConfigSgReadyState &e : m_sgReady.states) {
if (e.state < 1 || e.state > 4)
return fail(QStringLiteral("sg-ready : état %1 hors plage (1-4 normés)").arg(e.state));
if (vus.contains(e.state))
return fail(QStringLiteral("sg-ready : état %1 déclaré deux fois").arg(e.state));
vus.append(e.state);
if (e.estimatedPowerW < 0)
return fail(QStringLiteral("sg-ready : état %1 a estimatedPowerW < 0").arg(e.state));
for (const QString &r : e.relays)
if (r.isEmpty())
return fail(QStringLiteral("sg-ready : état %1 référence un thingId vide").arg(e.state));
}
// ECS-110 / SAFETY.md — l'état 2 (normal, mains off) est le REPLI SÛR du mode dégradé
// L2. Une PAC dont la configuration ne permet pas de l'exprimer est REFUSÉE, jamais
// construite : c'est le garde-fou qui remplace le Q_ASSERT disparu en release.
if (!m_sgReady.hasState(2))
return fail(QStringLiteral("sg-ready : l'état 2 (normal) est OBLIGATOIRE — c'est le "
"repli sûr du mode dégradé L2, sans lui la charge ne peut "
"pas être mise en sécurité"));
return true;
}
if (aSgReady)
return fail(QStringLiteral("%1 : sgReady interdit (charge utile du mécanisme sg-ready)").arg(m_adapter));
if (m_adapter == QStringLiteral("relay-router")) {
// Cas relais (rév. 3) : liste de relais power. powerLevels/maxPowerW DÉRIVÉS (ignorés ici).
if (m_mode != QStringLiteral("fixed"))
@ -121,7 +227,8 @@ bool LoadConfig::isValid(QString *error) const
}
} else {
return fail(QStringLiteral("adapter inconnu \"%1\" (attendu \"relay-router\" ou \"etmvariableload\")").arg(m_adapter));
return fail(QStringLiteral("mécanisme inconnu \"%1\" (attendu \"relay-router\", "
"\"etmvariableload\" ou \"sg-ready\")").arg(m_adapter));
}
return true;
}
@ -141,6 +248,9 @@ QVariantMap LoadConfig::toMap() const
const QVariantMap needs = m_needs.toMap();
if (!needs.isEmpty())
m.insert("needs", needs);
// LM-300 — charge utile sg-ready, sérialisée seulement pour ce mécanisme.
if (!m_sgReady.states.isEmpty())
m.insert("sgReady", m_sgReady.toMap());
// rév. 3 — relais (cas relay-router) : sérialisés seulement s'ils existent.
if (!m_relays.isEmpty()) {
m.insert("relays", relays());
@ -163,6 +273,7 @@ LoadConfig LoadConfig::fromMap(const QVariantMap &map)
c.m_enabled = map.value("enabled", true).toBool();
c.m_needs = LoadConfigNeeds::fromMap(map.value("needs").toMap());
c.setRelays(map.value("relays").toList());
c.setSgReady(map.value("sgReady").toMap());
c.m_minOnS = map.value("minOnS").toInt();
c.m_minOffS = map.value("minOffS").toInt();
return c;

View File

@ -62,6 +62,47 @@ struct LoadConfigRelay
static LoadConfigRelay fromMap(const QVariantMap &map);
};
/*!
* \brief Un état SG-Ready configuré (charge utile du mécanisme \c sg-ready).
*
* \c estimatedPowerW porte bien son nom : c'est une **ESTIMATION**, pas un engagement ni une
* mesure. Une PAC ne consomme pas la même chose à 5 °C et à +12 °C. Elle sert de base au
* recrédit budget et au choix d'état ; la traiter comme une puissance garantie ferait dériver
* l'allocation de tout le waterfall.
*/
struct LoadConfigSgReadyState
{
int state = 0; //!< 1 = blocage · 2 = normal · 3 = recommandation · 4 = forcé.
QStringList relays; //!< ThingIds \c power à FERMER pour cet état (les autres ouverts).
double estimatedPowerW = 0; //!< Puissance ESTIMÉE (W) de cet état — jamais un engagement.
QVariantMap toMap() const;
//! \brief Construit depuis la forme sérialisée. \param map Map d'un état.
//! \return État ; champs absents = valeurs par défaut, la validation est ailleurs.
static LoadConfigSgReadyState fromMap(const QVariantMap &map);
};
/*!
* \brief Charge utile du mécanisme \c sg-ready (LM-300 : union discriminée par mécanisme).
*
* \invariant L'**état 2** (normal, mains off) DOIT être exprimable : c'est le repli sûr du
* mode dégradé L2 (\c docs/SAFETY.md). Une configuration qui ne le permet pas est REFUSÉE
* par \c LoadConfig::isValid(), jamais construite ECS-110 interdit de faire reposer cette
* garantie sur un \c Q_ASSERT, absent du binaire release.
*/
struct LoadConfigSgReady
{
QList<LoadConfigSgReadyState> states;
int minStateHoldS = 0; //!< Maintien minimal d'état (s) — anti court-cycling.
QVariantMap toMap() const;
//! \brief Construit depuis la forme sérialisée. \param map Map \c sgReady.
//! \return Charge utile NON validée — cf. \c LoadConfig::isValid().
static LoadConfigSgReady fromMap(const QVariantMap &map);
//! \return Vrai si l'état \p s est déclaré avec un encodage.
bool hasState(int s) const;
};
/*!
* \brief Déclaration de config d'une charge pilotée contrat etmvariableload §4.
*
@ -89,6 +130,8 @@ class LoadConfig
Q_PROPERTY(QVariantList relays READ relays WRITE setRelays) //!< [{thingId, powerW}]
Q_PROPERTY(int minOnS READ minOnS WRITE setMinOnS)
Q_PROPERTY(int minOffS READ minOffS WRITE setMinOffS)
//! LM-300 — charge utile du mécanisme \c sg-ready : {states:[{state,relays,estimatedPowerW}], minStateHoldS}
Q_PROPERTY(QVariantMap sgReady READ sgReady WRITE setSgReady)
public:
//! \brief Construit une config vide — \c adapter vaut "etmvariableload", \c enabled vrai.
LoadConfig() {}
@ -156,6 +199,16 @@ public:
//! \param v Durée minimale OFF (s), ≥ 0.
void setMinOffS(int v) { m_minOffS = v; }
//! \return Charge utile \c sg-ready sérialisée ; vide hors de ce mécanisme.
QVariantMap sgReady() const;
//! \param v Charge utile \c sg-ready ; réservée au mécanisme du même nom.
void setSgReady(const QVariantMap &v);
//! \return La même charge utile, typée — source de construction du SgReadyAdapter.
LoadConfigSgReady sgReadyPayload() const { return m_sgReady; }
//! \return Vrai si le mécanisme est \c sg-ready (PAC à 4 états normés).
bool isSgReady() const { return m_adapter == QStringLiteral("sg-ready"); }
//! Vrai si la charge est un routeur de relais (rév. 3 : combinatoire watts→relais côté routeur).
//! \return Vrai si \c adapter == "relay-router" — décide de la classe construite au rebuild.
bool isRelayRouter() const { return m_adapter == QStringLiteral("relay-router"); }
@ -190,6 +243,7 @@ private:
bool m_enabled = true;
LoadConfigNeeds m_needs;
QList<LoadConfigRelay> m_relays; //!< rév. 3 — relais du routeur (cas relay-router).
LoadConfigSgReady m_sgReady; //!< LM-300 — charge utile du mécanisme sg-ready.
int m_minOnS = 0;
int m_minOffS = 0;
};