`sessionEnergy` est l'ACCUMULATEUR du plugin V2C, pas un compteur remis à zéro pour le moteur : l'écart avec `chargeEnergy` est constant au dix-millième (7,2044 kWh sur deux échantillons à deux heures d'écart), parce qu'il porte les incréments d'avant la dernière remise à zéro de la borne. Le lire directement ferait croire toute obligation éco tenue de 7,2 kWh dès le premier cycle — un nombre crédible et faux que rien ne signalerait. Contre-épreuve : lecture directe, le test annonce 7204. La base se prend à l'ouverture de l'OBLIGATION, jamais au démarrage de nymead : sinon un redémarrage en pleine session réinitialise l'avancement sur une valeur déjà accumulée, et la recharge de nuit relue au matin repart de zéro. Elle est RECAPTURÉE sur un recul franc de l'accumulateur — une nouvelle session — mais pas sur un recul infime, qui est du bruit de lecture et coûterait l'avancement acquis à chaque frémissement du compteur. Ce que le moteur ne refait PAS : la détection de remise à zéro de la BORNE vit dans le plugin, qui interroge à 30 s. Le moteur travaille à la minute et raterait des cycles entiers. Lire comment le plugin l'avait écrite a évité d'ajouter une seconde accumulation, moins fine, en concurrence de la première. Trois maillons manquants raccordés, tous déclarés et alimentés par personne : EvCharger n'exposait pas sessionEnergy ; LoadContextTelemetry::sessionWh n'était rempli nulle part ; et toLoadContext() ABANDONNAIT les besoins déclarés, si bien que LoadContext::needs n'était rempli par aucun adaptateur. L'obligation éco n'existait qu'en configuration. Mock : sessionEnergy ajouté à chargerPhaseSwitching UNIQUEMENT — `charger` reste sans, ce qui donne le cas unmeasurable nativement. La suite porte les deux régimes, comme le banc. Le régime est publié alors qu'aucune décision ne s'y appuie encore, et c'est délibéré : cela sépare publier le régime de décider avec, comme measurement l'a été de l'arbitrage, et rend R6 testable le jour où le motif naîtra au lieu d'un invariant posé sur du code que rien n'émet. Suite : simulation 56/56, charging 48/48 identique à la référence ligne à ligne, loadmodel 21/21, spotmarket 32/32, doxygen 0 avertissement. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
723 lines
34 KiB
C++
723 lines
34 KiB
C++
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
|
|
|
#include "relayrouter.h"
|
|
#include "plugininfo.h"
|
|
|
|
#include <QDateTime>
|
|
#include <QMap>
|
|
#include <QSet>
|
|
#include <integrations/thingmanager.h>
|
|
#include <integrations/thing.h>
|
|
#include <types/action.h>
|
|
#include <types/param.h>
|
|
#include <integrations/thingactioninfo.h>
|
|
|
|
namespace {
|
|
//! Au-delà, 2^N combinaisons explose. 16 relais = 65536 combos — bien au-delà de tout ECS réel.
|
|
//! \rule{ECS-308} — le plafond TRONQUE au lieu de refuser : au-delà, les relais
|
|
//! surnuméraires sont ignorés en silence. Connu, non corrigé, et c'est pour cela que la
|
|
//! règle existe — 2^16 sous-ensembles est déjà une table qu'aucune installation n'atteint.
|
|
constexpr int MaxRelays = 16;
|
|
}
|
|
|
|
RelayStageTable RelayRouter::deriveStages(const QList<LoadConfigRelay> &relays)
|
|
{
|
|
int n = relays.size();
|
|
if (n > MaxRelays)
|
|
n = MaxRelays; // l'avertissement est émis à la construction, pas ici
|
|
|
|
// Toutes les sommes de sous-ensembles, dédupliquées par puissance, triées (QMap = clés
|
|
// croissantes), 0 inclus (sous-ensemble vide = masque 0). Pour des puissances identiques
|
|
// (ex. deux relais 1000 W), on garde la PREMIÈRE combinaison rencontrée — d'où le fait
|
|
// que l'ordre de déclaration soit significatif dans ce cas, et lui seul.
|
|
QMap<int, QList<QString>> byPower;
|
|
for (int mask = 0; mask < (1 << n); ++mask) {
|
|
int sum = 0;
|
|
QList<QString> set;
|
|
for (int i = 0; i < n; ++i) {
|
|
if (mask & (1 << i)) {
|
|
sum += relays.at(i).powerW;
|
|
set.append(relays.at(i).thingId);
|
|
}
|
|
}
|
|
if (!byPower.contains(sum))
|
|
byPower.insert(sum, set);
|
|
}
|
|
|
|
RelayStageTable t;
|
|
for (auto it = byPower.constBegin(); it != byPower.constEnd(); ++it) {
|
|
t.levels.append(it.key());
|
|
t.mapping.append(it.value());
|
|
}
|
|
return t;
|
|
}
|
|
|
|
RelayRouter::RelayRouter(ThingManager *thingManager,
|
|
const QString &id,
|
|
const QString &label,
|
|
const QList<LoadConfigRelay> &relays,
|
|
int minOnS,
|
|
int minOffS,
|
|
int priority,
|
|
const LoadNeeds &needs,
|
|
QObject *parent)
|
|
: QObject(parent)
|
|
, m_thingManager(thingManager)
|
|
, m_id(id)
|
|
, m_label(label)
|
|
, m_minOnS(minOnS)
|
|
, m_minOffS(minOffS)
|
|
, m_priority(priority)
|
|
, m_needs(needs)
|
|
{
|
|
int n = relays.size();
|
|
if (n > MaxRelays) {
|
|
qCWarning(dcNymeaEnergy()) << "[RelayRouter]" << m_label << "—" << n
|
|
<< "relais > max" << MaxRelays << ": tronqué.";
|
|
n = MaxRelays;
|
|
}
|
|
|
|
// Nominale par relais, prise à la DÉCLARATION. Voir m_relayNominalW dans l'en-tête :
|
|
// la redéduire des encodages solo la perd pour tout relais dont la puissance est
|
|
// aussi atteignable par une combinaison.
|
|
for (int i = 0; i < n; ++i)
|
|
m_relayNominalW.insert(relays.at(i).thingId, relays.at(i).powerW);
|
|
|
|
// La combinatoire vit dans deriveStages() — une seule implémentation pour les trois
|
|
// usages (construction, comparaison matérielle, publication en télémétrie).
|
|
const RelayStageTable table = deriveStages(relays);
|
|
m_levels = table.levels;
|
|
m_relayMapping = table.mapping;
|
|
// byPower contient toujours la clé 0 (masque vide) → m_levels[0] == 0.
|
|
Q_ASSERT(!m_levels.isEmpty() && m_levels.first() == 0);
|
|
|
|
// ECS-411 — reprise du palier depuis l'état RÉEL des relais. Partir de 0 alors que des
|
|
// contacts sont fermés, c'est le moteur qui croit 0 W pendant que le ballon tire 3 kW :
|
|
// même famille de défaut qu'ECS-410 (annoncer une puissance non appliquée).
|
|
m_currentStage = deduceStageFromThings();
|
|
}
|
|
|
|
LoadDescriptor RelayRouter::descriptor() const
|
|
{
|
|
LoadDescriptor d;
|
|
d.id = m_id;
|
|
d.label = m_label;
|
|
d.adapter = QStringLiteral("relay-router");
|
|
d.priority = m_priority;
|
|
|
|
// 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;
|
|
|
|
d.supportedKinds = { LoadAction::Setpoint };
|
|
return d;
|
|
}
|
|
|
|
LoadTelemetry RelayRouter::telemetry() const
|
|
{
|
|
LoadTelemetry t;
|
|
t.available = !m_faulted; // ECS-410 : en défaut, la charge sort de l'ARBITRAGE…
|
|
t.lastActionAt = m_lastActionAt;
|
|
|
|
// currentPowerW :
|
|
// - MESURÉ dès qu'au moins un relais du palier expose un state "currentPower" (somme).
|
|
// Un thermostat coupé (relais ON mais 0 W) → 0, jamais de puissance fantôme.
|
|
// - NOMINAL commandé (repli) si aucun relais actif ne mesure (GPIO bool nu). Dans ce cas
|
|
// currentPowerW reflète le COMMANDÉ, pas le mesuré (contrat rév. 3 §3.a).
|
|
// \rule{ECS-111} — UN RELAIS DÉCLARÉ QUI MANQUE REND LA CHARGE INDISPONIBLE.
|
|
//
|
|
// Le comportement était « non défini », et son effet dépassait l'affichage. Le relais
|
|
// disparu était SAUTÉ : ou bien plus aucun relais actif ne mesurait, et l'on retombait sur
|
|
// le nominal du PALIER ENTIER — la part du relais fantôme comprise ; ou bien des survivants
|
|
// mesuraient, et l'on publiait une somme SOUS-ÉVALUÉE. Dans les deux cas le chiffre était
|
|
// faux, et il n'était pas seulement affiché : \c currentPowerW alimente le recrédit
|
|
// anti-clignotement, donc le BUDGET. L'arbitre se trompait sur le surplus disponible
|
|
// pendant que \c available restait vrai — un mensonge qui décide, pas un mensonge qui
|
|
// s'affiche.
|
|
//
|
|
// Un relais absent du registre nymea est un Thing supprimé, ou un plugin qui ne le voit
|
|
// plus : **on ne sait pas dans quel état est le contact**. Compter 0 W et servir le reste
|
|
// serait supposer. ECS-410 dit quoi faire d'une charge dont on ne sait plus rien — sortir
|
|
// de l'ARBITRAGE, rester dans la COMPTABILITÉ, figer à la puissance crue engagée — et
|
|
// c'est le traitement appliqué ici, comme \c EtmVariableLoadAdapter et \c EvAdapter le
|
|
// font déjà pour leur Thing. \c deviceMeasurement() le faisait déjà : ceci finit le
|
|
// mouvement au lieu d'inventer une politique.
|
|
const bool relaisManquant = aUnRelaisManquant();
|
|
t.available = t.available && !relaisManquant;
|
|
|
|
double power = 0;
|
|
bool metered = false;
|
|
const QList<QString> activeRelays = m_currentStage < m_relayMapping.size()
|
|
? m_relayMapping.at(m_currentStage)
|
|
: QList<QString>();
|
|
// Câblage incomplet : aucune somme partielle. On annonce le NOMINAL du palier courant —
|
|
// la plus haute puissance qui puisse couler, jamais moins (même direction qu'ECS-410 et
|
|
// ECS-411). Sous-estimer ferait offrir aux charges suivantes un budget qui n'existe pas.
|
|
for (const QString &thingId : relaisManquant ? QList<QString>() : activeRelays) {
|
|
Thing *relay = m_thingManager->findConfiguredThing(ThingId(thingId));
|
|
if (!relay)
|
|
continue;
|
|
if (!relay->thingClass().stateTypes().findByName("currentPower").id().isNull()) {
|
|
metered = true;
|
|
power += relay->stateValue("currentPower").toDouble();
|
|
}
|
|
}
|
|
if (!metered && m_currentStage > 0 && m_currentStage < m_levels.size())
|
|
power = m_levels.at(m_currentStage); // nominal commandé
|
|
|
|
// ECS-410 — INDÉTERMINATION. Tant que des écritures sont en vol, on ne sait pas ce qui est
|
|
// réellement fermé : on annonce la PLUS HAUTE des deux puissances possibles. Même direction
|
|
// qu'ECS-411 (relais injoignable supposé fermé) : ne JAMAIS annoncer moins que ce qui peut
|
|
// être appliqué. Sous-estimer fait sur-allouer les charges suivantes et soutirer au réseau ;
|
|
// surestimer ne fait que retarder une montée.
|
|
if (!metered && m_pending > 0) {
|
|
const int hi = qMax(m_stagePrev, m_stageTarget);
|
|
if (hi >= 0 && hi < m_levels.size())
|
|
power = qMax(power, static_cast<double>(m_levels.at(hi)));
|
|
}
|
|
|
|
// …mais PAS de la COMPTABILITÉ : une puissance qu'on ne sait plus couper reste consommée.
|
|
// La retirer du bilan offrirait aux charges suivantes un budget qui n'existe pas.
|
|
t.currentPowerW = power;
|
|
return t;
|
|
}
|
|
|
|
LoadContext RelayRouter::toLoadContext(const QDateTime &now) const
|
|
{
|
|
LoadContext ctx;
|
|
ctx.id = m_id;
|
|
ctx.adapter = QStringLiteral("relay-router");
|
|
ctx.label = m_label;
|
|
ctx.priority = m_priority;
|
|
ctx.declared = descriptor().declared;
|
|
// §10-C — les BESOINS déclarés atteignent enfin le cycle. Ils étaient posés dans le
|
|
// descripteur et abandonnés ici : `LoadContext::needs` n'était rempli par personne, donc
|
|
// l'obligation éco n'existait qu'en configuration. Transmis tels quels, sans être lus par
|
|
// aucune décision à ce stade — la passe éco est l'étape suivante.
|
|
ctx.needs = descriptor().needs; // powerLevels/maxPowerW dérivés (watts)
|
|
ctx.needs = m_needs;
|
|
|
|
ctx.telemetry.currentPowerW = telemetry().currentPowerW;
|
|
|
|
// ECS-306 — fenêtre de verrou exposée au scheduler, EN WATTS. Aucun index de palier ni
|
|
// identifiant de relais ne franchit la frontière (rév. 3) : on convertit les bornes
|
|
// internes en puissances via la table de paliers. Sans ce canal, le scheduler décrémente
|
|
// le budget d'une valeur que l'adaptateur va écrêter, et les charges suivantes reçoivent
|
|
// un résidu surestimé pendant toute la fenêtre minOn (AGENTS règle 4).
|
|
// \rule{ECS-111} — la disponibilité se lit sur telemetry(), PAS sur m_faulted seul : un
|
|
// relais déclaré qui manque au registre rend la charge indisponible au même titre qu'un
|
|
// défaut d'écriture. Recopier « !m_faulted » ici ferait qu'une charge annoncée
|
|
// indisponible en télémétrie continuerait d'être ALLOUÉE par le waterfall — les deux
|
|
// frontières diraient deux choses de la même charge.
|
|
ctx.telemetry.available = telemetry().available;
|
|
|
|
if (!ctx.telemetry.available) {
|
|
// ECS-410 — charge FIGÉE, ni montée ni descente : plancher ET plafond à la puissance
|
|
// crue engagée. Un plafond nul avec un plancher non nul serait contradictoire et
|
|
// ferait dérailler le qBound du scheduler.
|
|
//
|
|
// C'est ce qui rend le traitement d'ECS-111 juste POUR LE BUDGET : la puissance crue
|
|
// engagée est RETIRÉE du budget des charges suivantes au lieu de leur être recréditée.
|
|
// Avant, le fantôme gonflait le recrédit — il offrait du surplus qui n'existait pas.
|
|
ctx.telemetry.lockMinPowerW = ctx.telemetry.currentPowerW;
|
|
ctx.telemetry.lockMaxPowerW = ctx.telemetry.currentPowerW;
|
|
return ctx;
|
|
}
|
|
|
|
int minStage, maxStage;
|
|
lockWindow(now, minStage, maxStage);
|
|
ctx.telemetry.lockMinPowerW = m_levels.at(minStage);
|
|
ctx.telemetry.lockMaxPowerW = m_levels.at(maxStage);
|
|
return ctx;
|
|
}
|
|
|
|
LoadAction RelayRouter::applyAction(const LoadAction &action, const QDateTime &now)
|
|
{
|
|
if (action.kind != LoadAction::Setpoint)
|
|
return action;
|
|
|
|
if (action.reason.isEmpty()) {
|
|
qCWarning(dcNymeaEnergy()) << "[RelayRouter]" << m_label
|
|
<< "— LoadAction sans reason rejetée.";
|
|
// ECS-415 — un refus se lit comme un refus : le retour décrit le palier TENU, jamais
|
|
// celui qu'on demandait. Rendre l'enveloppe telle quelle ferait conclure au succès.
|
|
LoadAction refuse = action;
|
|
refuse.powerW = refuse.estimatedPowerW = currentSetpointW();
|
|
return refuse;
|
|
}
|
|
|
|
// ECS-410 — en défaut, on CESSE TOUTE COMMANDE, y compris les replis forcés : le matériel
|
|
// n'a pas répondu à trois tentatives, en réémettre n'apporterait rien et masquerait l'état.
|
|
if (m_faulted) {
|
|
LoadAction refused = action;
|
|
refused.powerW = refused.estimatedPowerW = currentSetpointW();
|
|
return refused;
|
|
}
|
|
|
|
// ECS-412 — ARMEMENT PARESSEUX du verrou (démarrage à froid). Au premier \c now reçu,
|
|
// on estampille la « dernière commutation » : le verrou est donc armé pour sa durée
|
|
// configurée, puis EXPIRE naturellement.
|
|
//
|
|
// Sans cela, un \c m_lastSwitch nul faisait office de sentinelle « elapsed = 0 » à chaque
|
|
// cycle, donc un verrou PERMANENT : une charge démarrant au palier 0 avec minOffS > 0 ne
|
|
// pouvait jamais s'enclencher, donc jamais commuter, donc jamais valider m_lastSwitch —
|
|
// blocage circulaire. Constaté au banc le 2026-08-09 (charge sonde figée à 0 W sous
|
|
// 4 kW de surplus disponible).
|
|
//
|
|
// L'armement vit ICI, dans le chemin NON-const qui reçoit le temps de cycle, et non dans
|
|
// lockWindow() qui est const et ne fait que calculer. L'invariant « temps = paramètre,
|
|
// jamais l'horloge » (iloadadapter.h) est préservé : aucune horloge n'est lue.
|
|
if (!m_lastSwitch.isValid())
|
|
m_lastSwitch = now;
|
|
|
|
int newStage = stageForPower(action.powerW);
|
|
|
|
// Verrou anti-rebond INTERNE (clamp), au temps de cycle. Bypass si force==true (repli L2).
|
|
if (!action.force) {
|
|
int minStage, maxStage;
|
|
lockWindow(now, minStage, maxStage);
|
|
newStage = qBound(minStage, newStage, maxStage);
|
|
}
|
|
|
|
LoadAction applied = action;
|
|
applied.powerW = m_levels.at(newStage);
|
|
applied.estimatedPowerW = m_levels.at(newStage);
|
|
|
|
// Règle 7-c — journaliser L'ISSUE, pas seulement l'issue remarquable. Ce retour était MUET :
|
|
// « consigne reçue, palier déjà bon » ne se distinguait pas de « l'action n'est jamais
|
|
// parvenue au routeur », et c'est précisément le cas ordinaire qu'on vient vérifier quand
|
|
// une charge ne bouge pas. Même niveau que la branche qui commute (7-d, symétrie) — et le
|
|
// volume reste borné : une ligne par charge et par cycle, comme l'adaptateur à consigne
|
|
// continue en produit déjà une inconditionnellement.
|
|
if (newStage == m_currentStage) {
|
|
qCInfo(dcNymeaEnergy()) << "[RelayRouter]" << m_label
|
|
<< "→ consigne" << qRound(action.powerW) << "W → palier"
|
|
<< m_levels.at(newStage) << "W INCHANGÉ (aucune commutation)"
|
|
<< (action.force ? "(force)" : "")
|
|
<< "|" << renderFr(action.reason, m_label);
|
|
return applied; // idempotent — pas de re-commutation
|
|
}
|
|
|
|
qCInfo(dcNymeaEnergy()) << "[RelayRouter]" << m_label
|
|
<< "→ consigne" << qRound(action.powerW) << "W → palier"
|
|
<< m_levels.at(newStage) << "W"
|
|
<< (action.force ? "(force)" : "")
|
|
<< "|" << renderFr(action.reason, m_label);
|
|
|
|
applyRelayStage(newStage);
|
|
|
|
m_currentStage = newStage;
|
|
m_lastSwitch = now;
|
|
m_lastActionAt = now;
|
|
return applied;
|
|
}
|
|
|
|
// ---- privé ---------------------------------------------------------------
|
|
|
|
int RelayRouter::stageForPower(double powerW) const
|
|
{
|
|
int stage = 0; // m_levels[0] == 0 garanti
|
|
for (int i = 0; i < m_levels.size(); ++i) {
|
|
if (m_levels.at(i) <= powerW)
|
|
stage = i;
|
|
else
|
|
break; // m_levels triés croissants
|
|
}
|
|
return stage;
|
|
}
|
|
|
|
void RelayRouter::updateSoftConfig(const QString &label, int priority, const LoadNeeds &needs)
|
|
{
|
|
// ECS-412 — mise à jour EN PLACE : ni m_currentStage ni m_lastSwitch ne bougent,
|
|
// aucun relais n'est réécrit.
|
|
//
|
|
// Le libellé en fait partie. Il ne change RIEN au routage — d'où son absence de
|
|
// sameHardware() — mais il nomme la charge dans chaque ligne de journal. Sans cette
|
|
// affectation, renommer « chauffe-eau » en « Ballon 200 L » laissait le journal
|
|
// imprimer l'ancien nom jusqu'à la prochaine reconstruction, alors que GetLoadConfig
|
|
// rendait déjà le nouveau. Pour qui suit journalctl pendant une mise en service, les
|
|
// deux sources se contredisaient.
|
|
m_label = label;
|
|
m_priority = priority;
|
|
m_needs = needs;
|
|
}
|
|
|
|
void RelayRouter::lockWindow(const QDateTime &now, int &minStage, int &maxStage) const
|
|
{
|
|
const int topStage = m_levels.size() - 1;
|
|
|
|
// ECS-412 — DÉMARRAGE À FROID. m_lastSwitch nul signifie « on ne sait pas quand la
|
|
// dernière commutation a eu lieu » (redémarrage de nymead, reconstruction). Le défaut
|
|
// SÛR est d'ARMER le verrou — traiter la commutation comme venant d'avoir lieu, donc
|
|
// elapsed = 0 — et non de le purger. L'écriture naturelle (`valid && elapsed < minOnS`)
|
|
// fait l'inverse et laisserait une boucle de redémarrage court-circuiter la protection
|
|
// compresseur exactement quand elle est la plus nécessaire.
|
|
//
|
|
// Cet armement est TRANSITOIRE : applyAction() estampille m_lastSwitch au premier \c now
|
|
// reçu, si bien que le verrou expire après sa durée configurée. Le repli ci-dessous ne
|
|
// couvre donc que les cycles précédant la première action — typiquement le premier
|
|
// toLoadContext(). Il ne doit JAMAIS devenir un état permanent : c'était le défaut
|
|
// corrigé le 2026-08-09 (cf. testEcsColdStartLockExpires).
|
|
//
|
|
// Armer ne signifie PAS verrouiller inconditionnellement : avec une durée nulle,
|
|
// `0 < 0` est faux et le verrou reste inactif.
|
|
const qint64 elapsed = m_lastSwitch.isValid() ? m_lastSwitch.secsTo(now) : 0;
|
|
const bool onHeld = elapsed < m_minOnS;
|
|
const bool offHeld = elapsed < m_minOffS;
|
|
|
|
// Plancher : si ON et minOn non écoulé → interdit de descendre (puissance engagée non-coupable).
|
|
minStage = (m_currentStage > 0 && onHeld) ? m_currentStage : 0;
|
|
// Plafond : si à l'arrêt et minOff non écoulé → interdit de redémarrer.
|
|
maxStage = (m_currentStage == 0 && offHeld) ? 0 : topStage;
|
|
}
|
|
|
|
int RelayRouter::deduceStageFromThings() const
|
|
{
|
|
// Ensemble des relais réellement FERMÉS, lu une seule fois.
|
|
QSet<QString> allRelays;
|
|
for (const auto &list : m_relayMapping)
|
|
for (const QString &id : list)
|
|
allRelays.insert(id);
|
|
|
|
// Puissance nominale par relais, pour sommer l'état lu. Prise à la DÉCLARATION
|
|
// (m_relayNominalW) et non redéduite des encodages solo : avec 500/1000/1500, le
|
|
// palier 1500 est encodé {500,1000} et le relais 1500 n'apparaît jamais seul. Sa
|
|
// nominale valait alors 0 — donc un démarrage sur ce seul relais donnait sumW = 0,
|
|
// aucune correspondance exacte, et un palier 0 annoncé pendant que 1500 W circulaient.
|
|
// C'est précisément l'annonce en sous-estimation qu'ECS-411 interdit. Trouvé par le
|
|
// relevé d'ECS-411-b, qui affichait « (0W) » pour ce relais.
|
|
const QHash<QString, int> &nominal = m_relayNominalW;
|
|
|
|
// ECS-411-b — journaliser ce qui est LU, pas seulement ce qui en est déduit.
|
|
// « palier repris : 0 » ne dit pas si les relais étaient ouverts ou si l'un d'eux
|
|
// était injoignable et supposé fermé : deux situations qui appellent des gestes
|
|
// opposés. On garde donc la lecture relais par relais, et on la publie avec l'issue.
|
|
QSet<QString> on;
|
|
int sumW = 0;
|
|
bool unreadable = false;
|
|
QStringList lecture;
|
|
for (const QString &thingId : allRelays) {
|
|
const int w = nominal.value(thingId, 0);
|
|
const QString court = thingId.left(8); // assez pour identifier, assez court pour tenir
|
|
Thing *relay = m_thingManager ? m_thingManager->findConfiguredThing(ThingId(thingId)) : nullptr;
|
|
if (!relay) {
|
|
// Thing absent : son état est INCONNU. On le suppose FERMÉ — sous-estimer la
|
|
// puissance appliquée est le défaut qu'ECS-411 corrige, le surestimer ne fait
|
|
// que retarder une montée en puissance.
|
|
unreadable = true;
|
|
on.insert(thingId);
|
|
sumW += w;
|
|
lecture << QStringLiteral("%1(%2W)=INJOIGNABLE→supposé fermé").arg(court).arg(w);
|
|
continue;
|
|
}
|
|
const bool ferme = relay->stateValue("power").toBool();
|
|
if (ferme) {
|
|
on.insert(thingId);
|
|
sumW += w;
|
|
}
|
|
lecture << QStringLiteral("%1(%2W)=%3").arg(court).arg(w)
|
|
.arg(ferme ? QStringLiteral("fermé") : QStringLiteral("ouvert"));
|
|
}
|
|
lecture.sort(); // ordre stable : QSet n'en garantit aucun, et un relevé se compare
|
|
|
|
// Sortie UNIQUE. Les trois issues étaient auparavant journalisées séparément, et la
|
|
// première se taisait au palier 0 — un silence indistinguable d'une reprise non
|
|
// exécutée. Toute issue est désormais annoncée, la nominale comme les remarquables.
|
|
int idx = -1;
|
|
QString issue;
|
|
|
|
// 1) Correspondance EXACTE de l'ensemble de relais avec un palier : cas nominal.
|
|
for (int i = 0; i < m_relayMapping.size() && idx < 0; ++i) {
|
|
const QList<QString> &l = m_relayMapping.at(i);
|
|
if (QSet<QString>(l.begin(), l.end()) == on) {
|
|
idx = i;
|
|
issue = QStringLiteral("correspondance exacte");
|
|
}
|
|
}
|
|
|
|
// 2) Ensemble non retenu dans la table — cas des encodages DÉDUPLIQUÉS (deux
|
|
// combinaisons de même puissance, une seule conservée). On se rabat sur la
|
|
// PUISSANCE : toute somme de sous-ensemble figure nécessairement dans m_levels,
|
|
// puisque m_levels est construit de toutes ces sommes. On annonce donc la bonne
|
|
// puissance, et le premier applyAction() normalisera l'encodage des relais.
|
|
if (idx < 0) {
|
|
const int parPuissance = m_levels.indexOf(sumW);
|
|
if (parPuissance >= 0) {
|
|
idx = parPuissance;
|
|
issue = QStringLiteral("encodage hors table, repris par la puissance "
|
|
"(normalisé au premier cycle)");
|
|
}
|
|
}
|
|
|
|
// 3) Inatteignable sauf troncature à MaxRelays : la somme lue dépasse la table. On prend
|
|
// le palier le plus haut plutôt que 0 — ne JAMAIS annoncer moins que ce qui est appliqué.
|
|
if (idx < 0) {
|
|
idx = m_levels.size() - 1;
|
|
issue = QStringLiteral("puissance hors table (relais tronqués ?) : palier maximal adopté");
|
|
}
|
|
|
|
const QString releve = QStringLiteral("[RelayRouter] %1 — reprise au démarrage : "
|
|
"palier %2 W [%3] ; relais lus : %4 ; somme lue %5 W")
|
|
.arg(m_label).arg(m_levels.at(idx)).arg(issue)
|
|
.arg(lecture.join(QStringLiteral(", "))).arg(sumW);
|
|
|
|
// Un relais injoignable rend la reprise incertaine : elle mérite un avertissement,
|
|
// pas une information. Le relevé est le même, seul le niveau change.
|
|
if (unreadable)
|
|
qCWarning(dcNymeaEnergy()).noquote() << releve << "— état INCERTAIN (relais injoignable).";
|
|
else
|
|
qCInfo(dcNymeaEnergy()).noquote() << releve;
|
|
|
|
return idx;
|
|
}
|
|
|
|
void RelayRouter::writeRelay(const QString &thingId, bool on)
|
|
{
|
|
Thing *relay = m_thingManager ? m_thingManager->findConfiguredThing(ThingId(thingId)) : nullptr;
|
|
if (!relay) {
|
|
qCWarning(dcNymeaEnergy()) << "[RelayRouter]" << m_label << "— relais non trouvé:" << thingId;
|
|
m_writeFailed = true; // compte comme un échec : ECS-410 §1
|
|
return;
|
|
}
|
|
StateType powerStateType = relay->thingClass().stateTypes().findByName("power");
|
|
if (powerStateType.id().isNull()) {
|
|
relay->setStateValue("power", on); // repli mock : synchrone, toujours réussi
|
|
return;
|
|
}
|
|
|
|
Action powerAction(powerStateType.id(), relay->id(), Action::TriggeredByRule);
|
|
powerAction.setParams(ParamList() << Param(powerStateType.id(), on));
|
|
ThingActionInfo *info = m_thingManager->executeAction(powerAction);
|
|
if (!info) {
|
|
m_writeFailed = true;
|
|
return;
|
|
}
|
|
|
|
// ECS-410 — on n'attend PAS : le cycle rend la main immédiatement (AGENTS règle 5).
|
|
// Le verdict arrive par signal, et la conséquence est traitée au cycle suivant.
|
|
// `this` en contexte de connexion : si l'adaptateur meurt, le callback est coupé
|
|
// proprement — motif repris d'EvCharger::setChargingEnabled (evcharger.cpp:293-299).
|
|
++m_pending;
|
|
connect(info, &ThingActionInfo::finished, this, [this, info]() {
|
|
if (info->status() != Thing::ThingErrorNoError) {
|
|
m_writeFailed = true;
|
|
qCWarning(dcNymeaEnergy()) << "[RelayRouter]" << m_label
|
|
<< "— écriture relais en échec, status" << info->status();
|
|
}
|
|
if (--m_pending == 0)
|
|
settleTransition();
|
|
});
|
|
}
|
|
|
|
void RelayRouter::settleTransition()
|
|
{
|
|
// Appelé quand TOUTES les écritures d'une étape sont acquittées. Fait avancer l'échelle
|
|
// d'ECS-410 d'un cran, une tentative par cran, sans jamais boucler.
|
|
if (!m_writeFailed) {
|
|
m_stagePrev = m_currentStage; // transition confirmée : l'indétermination cesse
|
|
m_stageTarget = m_currentStage;
|
|
return;
|
|
}
|
|
m_writeFailed = false;
|
|
|
|
switch (m_phase) {
|
|
case PhaseNominale:
|
|
// Barreau 2 — retour à l'état précédent.
|
|
qCWarning(dcNymeaEnergy()) << "[RelayRouter]" << m_label
|
|
<< "— échec d'écriture : retour au palier" << m_levels.at(m_stagePrev) << "W.";
|
|
m_phase = PhaseRepli;
|
|
m_currentStage = m_stagePrev;
|
|
emitRelayWrites(m_stagePrev);
|
|
break;
|
|
|
|
case PhaseRepli:
|
|
// Barreau 3 — arrêt total. Dernière tentative.
|
|
qCWarning(dcNymeaEnergy()) << "[RelayRouter]" << m_label
|
|
<< "— retour arrière en échec : arrêt total.";
|
|
m_phase = PhaseArretTotal;
|
|
m_currentStage = 0;
|
|
m_stageTarget = 0;
|
|
emitRelayWrites(0);
|
|
break;
|
|
|
|
case PhaseArretTotal:
|
|
case PhaseDefaut:
|
|
// Fin de l'échelle : verdict. Verrou COLLANT — aucune commande jusqu'à
|
|
// ClearLoadFault (ECS-410 §4). Il ne se lève pas tout seul : pas de clignotement.
|
|
qCCritical(dcNymeaEnergy()) << "[RelayRouter]" << m_label
|
|
<< "— arrêt total en échec : charge EN DÉFAUT, plus aucune"
|
|
<< "commande émise. Levée par NymeaEnergy.ClearLoadFault.";
|
|
m_phase = PhaseDefaut;
|
|
m_faulted = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
bool RelayRouter::aUnRelaisManquant() const
|
|
{
|
|
for (auto it = m_relayNominalW.constBegin(); it != m_relayNominalW.constEnd(); ++it)
|
|
if (!m_thingManager->findConfiguredThing(ThingId(it.key())))
|
|
return true;
|
|
return false;
|
|
}
|
|
|
|
LoadMeasurement RelayRouter::deviceMeasurement() const
|
|
{
|
|
LoadMeasurement m;
|
|
if (m_relayNominalW.isEmpty())
|
|
return m;
|
|
|
|
// CAPACITÉ — la question porte sur la ThingClass de TOUS les relais déclarés, jamais sur
|
|
// ceux qui sont fermés maintenant : au palier 0 il n'y en a aucun, et juger là-dessus
|
|
// ferait basculer la source publiée à chaque extinction (LM-1106).
|
|
//
|
|
// TOUS, et pas « au moins un » : une topologie mixte donnerait une somme sous-évaluée
|
|
// présentée comme la mesure de la charge — un écart commandé↔mesuré qui envoie chercher
|
|
// une panne inexistante (LM-1102). Une mesure partielle n'est pas une mesure.
|
|
for (auto it = m_relayNominalW.constBegin(); it != m_relayNominalW.constEnd(); ++it) {
|
|
Thing *relay = m_thingManager->findConfiguredThing(ThingId(it.key()));
|
|
if (!relay || relay->thingClass().stateTypes().findByName("currentPower").id().isNull())
|
|
return m; // un seul relais nu, et la charge n'est pas mesurable
|
|
}
|
|
|
|
// VALEUR — sur les relais FERMÉS. Un thermostat qui a ouvert donne 0 W relais fermé, et
|
|
// c'est une mesure juste : la charge ne consomme réellement rien.
|
|
m.available = true;
|
|
const QList<QString> fermes = m_currentStage < m_relayMapping.size()
|
|
? m_relayMapping.at(m_currentStage)
|
|
: QList<QString>();
|
|
for (const QString &thingId : fermes)
|
|
if (Thing *relay = m_thingManager->findConfiguredThing(ThingId(thingId)))
|
|
m.powerW += relay->stateValue("currentPower").toDouble();
|
|
return m;
|
|
}
|
|
|
|
LoadRuntimeView RelayRouter::runtimeView(const QDateTime &now) const
|
|
{
|
|
LoadRuntimeView v;
|
|
v.mechanismKind = QStringLiteral("relay");
|
|
// Charge utile du mécanisme : des WATTS, jamais un index de palier ni un ThingId de relais.
|
|
// La frontière rév. 3 tient aussi dans le sens sortant.
|
|
v.mechanism.insert(QStringLiteral("stageW"), currentSetpointW());
|
|
v.mechanism.insert(QStringLiteral("maxStageW"),
|
|
m_levels.isEmpty() ? 0.0 : static_cast<double>(m_levels.last()));
|
|
|
|
// Paliers atteignables, publiés en LECTURE SEULE (lot D §4).
|
|
//
|
|
// Ils ne sont déclarés nulle part : ils se dérivent des relais. Sans cette publication,
|
|
// un client qui veut les montrer doit réimplémenter la combinatoire — seconde
|
|
// implémentation, divergence certaine à la première correction d'un seul côté, et c'est
|
|
// le client qui aurait tort sans le savoir.
|
|
//
|
|
// Ici et pas dans GetLoadConfig, à dessein : la télémétrie n'a AUCUN chemin d'écriture,
|
|
// donc « lecture seule » y est vrai par construction. Les publier dans GetLoadConfig
|
|
// aurait cassé l'aller-retour neutre dont l'app dépend pour écrire — elle renvoie
|
|
// verbatim ce qu'elle a lu, le champ serait revenu dans SetLoadConfig, et un refus en
|
|
// bloc aurait emporté toutes les charges.
|
|
QVariantList stagesW;
|
|
for (int w : m_levels)
|
|
stagesW.append(static_cast<double>(w));
|
|
v.mechanism.insert(QStringLiteral("stagesW"), stagesW);
|
|
|
|
// ECS-410 — le défaut sort en CODE. Il n'a aujourd'hui qu'une cause : l'échelle d'écriture
|
|
// épuisée. Une cause nouvelle vaudra un code nouveau, jamais une phrase.
|
|
if (m_faulted)
|
|
v.faultCode = QStringLiteral("WRITE_FAILED");
|
|
// \rule{ECS-111} — THING_MISSING PRIME sur WRITE_FAILED, et ce n'est pas un choix de
|
|
// présentation : un relais absent EXPLIQUE les écritures qui échouent. Annoncer
|
|
// WRITE_FAILED enverrait l'opérateur au bus alors que la cause est dans la configuration,
|
|
// et \c ClearLoadFault — le remède de WRITE_FAILED — n'y peut rien.
|
|
if (aUnRelaisManquant())
|
|
v.faultCode = QStringLiteral("THING_MISSING");
|
|
|
|
// Verrou en SECONDES RESTANTES. Le calcul dérive du MÊME m_lastSwitch et du même \c now que
|
|
// lockWindow() : ce que le client lit est ce qui a borné la décision, pas une seconde vue.
|
|
// m_lastSwitch nul = « commutation venant d'avoir lieu » (ECS-412, armement à froid) —
|
|
// même convention que lockWindow(), sans quoi l'affichage contredirait le comportement.
|
|
const qint64 elapsed = m_lastSwitch.isValid() ? m_lastSwitch.secsTo(now) : 0;
|
|
if (m_currentStage > 0 && elapsed < m_minOnS) {
|
|
v.lockKind = QStringLiteral("minOn");
|
|
v.lockRemainingS = static_cast<int>(m_minOnS - elapsed);
|
|
} else if (m_currentStage == 0 && elapsed < m_minOffS) {
|
|
v.lockKind = QStringLiteral("minOff");
|
|
v.lockRemainingS = static_cast<int>(m_minOffS - elapsed);
|
|
}
|
|
return v;
|
|
}
|
|
|
|
void RelayRouter::applySafeState(const QDateTime &now)
|
|
{
|
|
// ECS-413 — état sûr du routeur : tous relais ouverts. Passe par le chemin d'action
|
|
// NORMAL avec force = true, exactement comme le repli L2 : c'est le mécanisme qui existe
|
|
// déjà pour contourner les verrous quand l'arrêt prime. Rien à inventer.
|
|
LoadAction sur;
|
|
sur.loadId = m_id;
|
|
sur.kind = LoadAction::Setpoint;
|
|
sur.powerW = 0;
|
|
sur.force = true;
|
|
sur.reason = { DecisionCode::SafeStateRelay };
|
|
applyAction(sur, now);
|
|
}
|
|
|
|
bool RelayRouter::clearFault()
|
|
{
|
|
if (!m_faulted)
|
|
return false; // rien à lever — l'arbitre le dira (règle 7-c)
|
|
qCInfo(dcNymeaEnergy()) << "[RelayRouter]" << m_label
|
|
<< "— défaut levé par l'opérateur (ClearLoadFault).";
|
|
m_faulted = false;
|
|
m_phase = PhaseNominale;
|
|
m_writeFailed = false;
|
|
// L'état matériel réel est inconnu après un défaut : on le relit plutôt que de le supposer
|
|
// (même principe qu'ECS-411, et jamais moins que ce qui peut être appliqué).
|
|
m_currentStage = deduceStageFromThings();
|
|
m_stagePrev = m_stageTarget = m_currentStage;
|
|
return true;
|
|
}
|
|
|
|
void RelayRouter::emitRelayWrites(int stage)
|
|
{
|
|
// Set CIBLE du palier (delta complet : chaque relais connu amené à son état on/off cible).
|
|
// Gère les mappings NON-CASCADÉS (monter d'un palier peut éteindre des relais).
|
|
const QSet<QString> wantOn = [&]() {
|
|
QSet<QString> s;
|
|
if (stage >= 0 && stage < m_relayMapping.size())
|
|
for (const QString &id : m_relayMapping.at(stage))
|
|
s.insert(id);
|
|
return s;
|
|
}();
|
|
|
|
QSet<QString> allRelays;
|
|
for (const auto &list : m_relayMapping)
|
|
for (const QString &id : list)
|
|
allRelays.insert(id);
|
|
|
|
// off-before-on : couper d'abord les relais hors-cible, puis enclencher la cible → pas de
|
|
// sur-puissance transitoire (somme des deux paliers) sur une transition non-cascadée.
|
|
for (const QString &id : allRelays)
|
|
if (!wantOn.contains(id))
|
|
writeRelay(id, false);
|
|
for (const QString &id : wantOn)
|
|
writeRelay(id, true);
|
|
|
|
// Chemin entièrement synchrone (mock, ou aucun relais) : aucun acquittement n'arrivera,
|
|
// le verdict se prend donc tout de suite.
|
|
if (m_pending == 0)
|
|
settleTransition();
|
|
}
|
|
|
|
void RelayRouter::applyRelayStage(int stage)
|
|
{
|
|
m_stagePrev = m_currentStage;
|
|
m_stageTarget = stage;
|
|
m_writeFailed = false;
|
|
emitRelayWrites(stage);
|
|
}
|