OptimizerPlan::kinds() (désormais appliquée par convertirAction) et verdictCodes(), dans l'ordre des contrôles ; SocketScheduler::knownTriggers() (appliquée par rangDeclencheur), knownFallbackCodes(), knownLastErrorCodes() ; OptimizerClient::knownErrorCodes(). Le contrat OpenRPC en régénère ses énumérations. Aucun comportement changé : mêmes listes, mêmes ordres. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
383 lines
18 KiB
C++
383 lines
18 KiB
C++
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
|
|
|
#include "socketscheduler.h"
|
|
|
|
#include <QLoggingCategory>
|
|
|
|
Q_DECLARE_LOGGING_CATEGORY(dcNymeaEnergy)
|
|
|
|
namespace {
|
|
// Rang d'un déclencheur : le plus significatif l'emporte quand plusieurs se cumulent.
|
|
int rangDeclencheur(const QString &t)
|
|
{
|
|
return SocketScheduler::knownTriggers().indexOf(t);
|
|
}
|
|
}
|
|
|
|
QStringList SocketScheduler::knownTriggers()
|
|
{
|
|
return {QStringLiteral("cycle"), QStringLiteral("forecast"), QStringLiteral("config"),
|
|
QStringLiteral("load-change"), QStringLiteral("intent")};
|
|
}
|
|
|
|
QStringList SocketScheduler::knownFallbackCodes()
|
|
{
|
|
using OptimizerPlan::Issue;
|
|
return QStringList{QStringLiteral("no-plan")} + OptimizerPlan::verdictCodes(Issue::Abstention)
|
|
+ OptimizerPlan::verdictCodes(Issue::Expire) + OptimizerPlan::verdictCodes(Issue::Invalide)
|
|
+ QStringList{QStringLiteral("no-shadow-slot")};
|
|
}
|
|
|
|
QStringList SocketScheduler::knownLastErrorCodes()
|
|
{
|
|
return OptimizerClient::knownErrorCodes() + OptimizerPlan::verdictCodes(OptimizerPlan::Issue::Invalide);
|
|
}
|
|
|
|
SocketScheduler::SocketScheduler(IScheduler *ombre, OptimizerClient *client, FournisseurFil fil,
|
|
QObject *parent)
|
|
: QObject(parent)
|
|
, m_ombre(ombre)
|
|
, m_client(client)
|
|
, m_fil(std::move(fil))
|
|
{
|
|
m_client->setParent(this);
|
|
connect(m_client, &OptimizerClient::planReceived, this, &SocketScheduler::onPlanRecu);
|
|
connect(m_client, &OptimizerClient::planFailed, this, &SocketScheduler::onPlanEchoue);
|
|
// §7 « socket absent / fermé → rules, optimizerAlive = false » : un plan ne survit pas à la
|
|
// connexion qui l'a porté, et un optimiseur qu'on n'atteint pas n'est pas vivant.
|
|
//
|
|
// La perte se note à la TRANSITION : sortie de Ready ou de Handshaking (fermeture, handshake
|
|
// refusé), ou premier échec d'un client qui n'a pas encore été connecté (optimiseur absent dès
|
|
// le départ : sans cela, lastErrorCode n'aurait jamais de valeur). Une tentative de reconnexion
|
|
// qui échoue pendant une panne déjà notée n'en est pas une nouvelle : ni etatChange(), ni
|
|
// dernier code réécrit (constat `drop`, brief 4 T7).
|
|
connect(m_client, &OptimizerClient::stateChanged, this, [this](OptimizerClient::State s) {
|
|
const bool etaitConnecte = m_etatClient == OptimizerClient::Ready
|
|
|| m_etatClient == OptimizerClient::Handshaking;
|
|
m_etatClient = s;
|
|
if (s == OptimizerClient::Ready) {
|
|
m_perteNotee = false;
|
|
return;
|
|
}
|
|
if (s == OptimizerClient::Connecting || s == OptimizerClient::Handshaking)
|
|
return; // une tentative en cours n'est pas une perte
|
|
if (!etaitConnecte && m_perteNotee)
|
|
return; // tentative échouée pendant une panne déjà notée
|
|
m_perteNotee = true;
|
|
oublierCache(QStringLiteral("connexion perdue"));
|
|
m_codeReception.clear(); // plus rien ne sera reçu sur cette connexion : `no-plan`
|
|
noterVivacite(OptimizerStatus::Vivacite::Evenement::ConnexionPerdue, m_client->lastErrorCode());
|
|
});
|
|
// Handshake accepté : nom, version et capacités ont changé — l'état publié se recompose.
|
|
connect(m_client, &OptimizerClient::ready, this, &SocketScheduler::etatChange);
|
|
connect(m_client, &OptimizerClient::notificationReceived, this,
|
|
[this](const QString &method, const QVariantMap &) { onNotification(method); });
|
|
m_client->start();
|
|
}
|
|
|
|
Plan SocketScheduler::getPlan(const SurplusContext &ctx)
|
|
{
|
|
// L'OMBRE D'ABORD, et toujours : ses effets de bord (proxy VE, comptabilité éco, réserve)
|
|
// sont dus à chaque cycle, quelle que soit la stratégie qui commandera.
|
|
const Plan ombre = m_ombre->getPlan(ctx);
|
|
m_idDuCycle = 0;
|
|
m_requeteDuCycle.clear();
|
|
envoyer(ctx);
|
|
|
|
if (!m_aCache) {
|
|
// Pourquoi il n'y a pas de plan : le verdict de la dernière réponse non appliquée
|
|
// (abstention, plan expiré ou refusé à la réception), sinon « rien reçu ».
|
|
if (m_codeReception.isEmpty())
|
|
noterCycle(false, QStringLiteral("no-plan"), QStringLiteral("aucun plan en cache"));
|
|
else
|
|
noterCycle(false, m_codeReception, QStringLiteral("aucun plan en cache — dernière réponse non appliquée"));
|
|
return ombre;
|
|
}
|
|
// Rejugé sur le contexte DE CE CYCLE : maintenant, charges, disponibilités ont pu changer
|
|
// depuis la requête.
|
|
const OptimizerPlan::Verdict v = OptimizerPlan::valider(m_cache, ctx, m_client->capabilities(),
|
|
m_client->hasCapabilities());
|
|
if (v.issue != OptimizerPlan::Issue::Valide) {
|
|
noterCycle(false, v.code, v.detail);
|
|
if (v.issue == OptimizerPlan::Issue::Expire)
|
|
oublierCache(QStringLiteral("plan expiré"));
|
|
return ombre;
|
|
}
|
|
const Plan plan = OptimizerPlan::substituer(ombre, v.plan, ctx);
|
|
if (plan.strategy != QLatin1String(OptimizerPlan::StrategieOptimiseur)) {
|
|
noterCycle(false, QStringLiteral("no-shadow-slot"), QStringLiteral("l'ombre n'a pas de créneau courant"));
|
|
return ombre;
|
|
}
|
|
m_planApplique = v.plan;
|
|
journaliserCreneau(v, ctx);
|
|
noterCycle(true, QString(), QString());
|
|
return plan;
|
|
}
|
|
|
|
void SocketScheduler::apresDispatch(Plan &plan, Slot &slot, const QHash<QString, LoadAction> &appliquees,
|
|
const QSet<QString> &delestees, const SurplusContext &ctx)
|
|
{
|
|
if (plan.strategy != QLatin1String(OptimizerPlan::StrategieOptimiseur))
|
|
return;
|
|
const QList<OptimizerPlan::Ecretage> ecretages =
|
|
OptimizerPlan::finaliser(plan, slot, appliquees, delestees, ctx);
|
|
QSet<QString> vus;
|
|
for (const OptimizerPlan::Ecretage &e : ecretages) {
|
|
vus.insert(e.loadId);
|
|
const QString texte = e.demande + QStringLiteral(" → ") + e.applique;
|
|
// Annoncé au CHANGEMENT : un palier qui arrondit la même demande à chaque cycle ne doit
|
|
// pas produire une ligne par minute ; le premier écrêtage, lui, se dit en clair.
|
|
if (m_ecretageVu.value(e.loadId) == texte) {
|
|
qCDebug(dcNymeaEnergy()).noquote() << "[Optimiseur]" << e.loadId << "écrêtée (inchangé) :" << texte;
|
|
continue;
|
|
}
|
|
m_ecretageVu.insert(e.loadId, texte);
|
|
qCInfo(dcNymeaEnergy()).noquote() << "[Optimiseur]" << e.loadId
|
|
<< "ÉCRÊTÉE par l'adaptateur — demandé" << e.demande
|
|
<< ", appliqué" << e.applique << "(clamped)";
|
|
}
|
|
for (auto it = m_ecretageVu.begin(); it != m_ecretageVu.end();) {
|
|
if (vus.contains(it.key())) { ++it; continue; }
|
|
qCInfo(dcNymeaEnergy()).noquote() << "[Optimiseur]" << it.key() << "n'est plus écrêtée";
|
|
it = m_ecretageVu.erase(it);
|
|
}
|
|
}
|
|
|
|
void SocketScheduler::noterDeclencheur(const QString &trigger)
|
|
{
|
|
const int rang = rangDeclencheur(trigger);
|
|
if (rang > rangDeclencheur(m_declencheur))
|
|
m_declencheur = trigger;
|
|
}
|
|
|
|
void SocketScheduler::envoyer(const SurplusContext &ctx)
|
|
{
|
|
if (m_client->state() != OptimizerClient::Ready)
|
|
return; // le déclencheur attend la prochaine requête qui partira vraiment
|
|
OptimizerProtocol::ContexteFil fil = m_fil ? m_fil(ctx) : OptimizerProtocol::ContexteFil();
|
|
fil.trigger = m_declencheur;
|
|
const QVariantMap params = OptimizerProtocol::getPlanParams(ctx, fil);
|
|
const quint64 id = m_client->requestPlan(params);
|
|
if (id == 0)
|
|
return;
|
|
m_idRequete = id;
|
|
m_ctxRequete = ctx;
|
|
m_chronoRequete.start();
|
|
m_idDuCycle = id;
|
|
m_requeteDuCycle = params;
|
|
m_declencheur = QStringLiteral("cycle");
|
|
}
|
|
|
|
void SocketScheduler::onPlanRecu(quint64 id, const QVariantMap &result)
|
|
{
|
|
if (id != m_idRequete)
|
|
return;
|
|
emit reponseTracee(id, QVariantMap{{QStringLiteral("result"), result}}, m_chronoRequete.elapsed());
|
|
const OptimizerPlan::Verdict v = OptimizerPlan::valider(result, m_ctxRequete,
|
|
m_client->capabilities(),
|
|
m_client->hasCapabilities());
|
|
signalerReserves(v.champsReserves);
|
|
emit reponseJugee(OptimizerPlan::nomIssue(v.issue), v.code);
|
|
// §7 : un plan valide rend la vie ; une abstention ou un plan déjà expiré rompent la série
|
|
// d'échecs sans la rendre ; un plan invalide est un échec de la requête.
|
|
switch (v.issue) {
|
|
case OptimizerPlan::Issue::Valide:
|
|
noterVivacite(OptimizerStatus::Vivacite::Evenement::PlanValide, QString(), m_ctxRequete.timestamp);
|
|
break;
|
|
case OptimizerPlan::Issue::Abstention:
|
|
case OptimizerPlan::Issue::Expire:
|
|
noterVivacite(OptimizerStatus::Vivacite::Evenement::Abstention, QString());
|
|
break;
|
|
case OptimizerPlan::Issue::Invalide:
|
|
noterVivacite(OptimizerStatus::Vivacite::Evenement::Echec, v.code);
|
|
break;
|
|
}
|
|
|
|
// Le verdict d'une réponse non appliquée devient le code de repli publié (fallbackCode),
|
|
// jusqu'à l'issue de la requête suivante ; un plan valide l'efface.
|
|
m_codeReception = v.issue == OptimizerPlan::Issue::Valide ? QString() : v.code;
|
|
switch (v.issue) {
|
|
case OptimizerPlan::Issue::Valide: {
|
|
m_cache = result;
|
|
m_aCache = true;
|
|
// Un optimiseur qui répond à chaque cycle redonne souvent le même plan : la ligne n'est en
|
|
// clair qu'au CHANGEMENT de plan (ou au retour après un repli), sinon en debug.
|
|
const bool nouveau = v.plan.planId != m_dernierPlanRecu || v.plan.planId.isEmpty();
|
|
m_dernierPlanRecu = v.plan.planId;
|
|
const QString ligne = QStringLiteral("[Optimiseur] plan %1 reçu (GetPlan %2) — %3 créneau(x), %4 ; "
|
|
"appliqué au cycle suivant")
|
|
.arg(v.plan.planId.isEmpty() ? QStringLiteral("(sans id)") : v.plan.planId).arg(id)
|
|
.arg(v.plan.creneaux.count())
|
|
.arg(v.plan.validUntil.isValid() ? QStringLiteral("valable jusqu'à ") + v.plan.validUntil.toString(Qt::ISODate)
|
|
: QStringLiteral("sans validUntil"));
|
|
if (nouveau)
|
|
qCInfo(dcNymeaEnergy()).noquote() << ligne;
|
|
else
|
|
qCDebug(dcNymeaEnergy()).noquote() << ligne;
|
|
return;
|
|
}
|
|
case OptimizerPlan::Issue::Invalide:
|
|
m_dernierPlanRecu.clear();
|
|
// 7-d : un refus au moins aussi visible qu'une acceptation. Jamais appliqué en partie.
|
|
qCWarning(dcNymeaEnergy()).noquote()
|
|
<< "[Optimiseur] plan REFUSÉ (GetPlan" << id << ") :" << v.code << "—" << v.detail
|
|
<< "— repli rule-based";
|
|
oublierCache(QString());
|
|
return;
|
|
case OptimizerPlan::Issue::Abstention:
|
|
case OptimizerPlan::Issue::Expire:
|
|
m_dernierPlanRecu.clear();
|
|
qCInfo(dcNymeaEnergy()).noquote()
|
|
<< "[Optimiseur] pas de plan applicable (GetPlan" << id << ") :" << v.code << "—" << v.detail
|
|
<< "— repli rule-based";
|
|
oublierCache(QString());
|
|
return;
|
|
}
|
|
}
|
|
|
|
void SocketScheduler::onPlanEchoue(quint64 id, const QString &code)
|
|
{
|
|
if (id != m_idRequete)
|
|
return;
|
|
// Le client a déjà journalisé la cause. Le plan en cache, s'il y en a un, reste valable
|
|
// jusqu'à son échéance : un échec ISOLÉ n'est pas un optimiseur mort. Le second l'est (§7).
|
|
emit reponseTracee(id, QVariantMap{{QStringLiteral("error"), QVariantMap{{QStringLiteral("code"), code}}}},
|
|
m_chronoRequete.elapsed());
|
|
emit echecRequete(code);
|
|
m_codeReception.clear(); // aucune réponse utilisable : le repli, s'il vient, dit `no-plan`
|
|
noterVivacite(OptimizerStatus::Vivacite::Evenement::Echec, code);
|
|
}
|
|
|
|
void SocketScheduler::noterVivacite(OptimizerStatus::Vivacite::Evenement e, const QString &code,
|
|
const QDateTime &instant)
|
|
{
|
|
const bool change = m_vivacite.noter(e, code, instant);
|
|
if (change && m_vivacite.vivant()) {
|
|
qCInfo(dcNymeaEnergy()) << "[Optimiseur] optimiseur VIVANT (optimizerAlive = true) : plan valide reçu";
|
|
} else if (change) {
|
|
// Mort : le plan en cache n'est plus cru, même s'il n'a pas expiré — le tableau §7 dit
|
|
// « rules », pas « le dernier plan jusqu'à son échéance ».
|
|
oublierCache(QString());
|
|
if (e == OptimizerStatus::Vivacite::Evenement::Echec)
|
|
qCWarning(dcNymeaEnergy()).noquote()
|
|
<< "[Optimiseur] optimiseur déclaré MORT (optimizerAlive = false) :"
|
|
<< m_vivacite.echecsConsecutifs() << "requêtes consécutives en échec, dernière :"
|
|
<< m_vivacite.dernierCodeErreur() << "— repli rule-based jusqu'au prochain plan valide";
|
|
else
|
|
qCInfo(dcNymeaEnergy()).noquote()
|
|
<< "[Optimiseur] optimizerAlive = false : connexion absente ou fermée ("
|
|
+ (m_vivacite.dernierCodeErreur().isEmpty() ? QStringLiteral("-") : m_vivacite.dernierCodeErreur())
|
|
+ ") — repli rule-based";
|
|
}
|
|
emit etatChange();
|
|
}
|
|
|
|
void SocketScheduler::onNotification(const QString &method)
|
|
{
|
|
if (method != QLatin1String("UpdateLearned"))
|
|
return; // le client l'a déjà notée en debug ; aucune autre notification n'est définie (§8)
|
|
// §8 : reçu, ignoré — la persistance des estimations apprises est un lot ultérieur. Le
|
|
// contenu n'est jamais journalisé : il vient d'ailleurs et peut être volumineux.
|
|
if (m_updateLearnedSignale) {
|
|
qCDebug(dcNymeaEnergy()) << "[Optimiseur] UpdateLearned reçu et ignoré";
|
|
return;
|
|
}
|
|
m_updateLearnedSignale = true;
|
|
qCInfo(dcNymeaEnergy()) << "[Optimiseur] UpdateLearned reçu et IGNORÉ (persistance hors de ce lot)"
|
|
<< "— signalé une fois par session";
|
|
}
|
|
|
|
void SocketScheduler::oublierCache(const QString &pourquoi)
|
|
{
|
|
if (!m_aCache)
|
|
return;
|
|
m_aCache = false;
|
|
m_cache.clear();
|
|
if (!pourquoi.isEmpty())
|
|
qCInfo(dcNymeaEnergy()).noquote() << "[Optimiseur] plan en cache abandonné :" << pourquoi;
|
|
}
|
|
|
|
void SocketScheduler::noterCycle(bool optimiseur, const QString &code, const QString &detail)
|
|
{
|
|
// 7-c : l'issue est dite à chaque CHANGEMENT de stratégie, en clair ; le cycle ordinaire
|
|
// reste en debug — une ligne par minute noierait le reste.
|
|
const bool change = !m_cycleNote || optimiseur != m_optimiseurAuCycle || code != m_codeCycle;
|
|
m_cycleNote = true;
|
|
m_optimiseurAuCycle = optimiseur;
|
|
if (!optimiseur) {
|
|
// Au retour de l'optimiseur, ce qu'il dit se redit en clair.
|
|
m_motifVu.clear();
|
|
m_indisponiblesVus.clear();
|
|
}
|
|
m_codeCycle = code;
|
|
if (change) {
|
|
if (optimiseur)
|
|
qCInfo(dcNymeaEnergy()) << "[Optimiseur] stratégie du cycle : optimiseur";
|
|
else
|
|
qCInfo(dcNymeaEnergy()).noquote() << "[Optimiseur] stratégie du cycle : rule-based (" + code + ") —" << detail;
|
|
} else {
|
|
qCDebug(dcNymeaEnergy()).noquote() << "[Optimiseur] cycle :" << (optimiseur ? QStringLiteral("optimiseur") : code);
|
|
}
|
|
emit strategieDuCycle(optimiseur, code);
|
|
}
|
|
|
|
void SocketScheduler::signalerReserves(const QStringList &champs)
|
|
{
|
|
for (const QString &c : champs) {
|
|
if (m_reservesSignales.contains(c))
|
|
continue;
|
|
m_reservesSignales.insert(c);
|
|
qCWarning(dcNymeaEnergy()).noquote()
|
|
<< "[Optimiseur] champ" << c << "reçu et IGNORÉ (réservé ou interdit sur le fil, §6 / annexe F)"
|
|
<< "— signalé une fois par session";
|
|
}
|
|
}
|
|
|
|
void SocketScheduler::journaliserCreneau(const OptimizerPlan::Verdict &v, const SurplusContext &ctx)
|
|
{
|
|
const OptimizerPlan::CreneauRecu &c = v.plan.creneaux.at(v.plan.indexCourant);
|
|
QHash<QString, QString> libelles;
|
|
for (const LoadContext &lc : ctx.loads)
|
|
libelles.insert(lc.id, lc.label);
|
|
|
|
// Le TEXTE de l'optimiseur vit ici, au journal — jamais dans l'action (invariant 12). Une
|
|
// ligne en clair par charge quand ce que l'optimiseur DIT change (code, paramètres retirés,
|
|
// texte) ; un plan recalculé chaque minute à l'identique ne répète rien.
|
|
QStringList lignes;
|
|
QSet<QString> vues;
|
|
for (const OptimizerPlan::ActionRecue &ar : c.actions) {
|
|
const QString ligne = ar.action.loadId + QStringLiteral(" → ")
|
|
+ renderFr(ar.action.reason, libelles.value(ar.action.loadId))
|
|
+ QStringLiteral(" | reçu : ") + (ar.codeRecu.isEmpty() ? QStringLiteral("(sans code)") : ar.codeRecu)
|
|
+ (ar.codeSubstitue ? QStringLiteral(" → OPTIMIZER_DECISION") : QString())
|
|
+ (ar.parametresSupprimes.isEmpty() ? QString()
|
|
: QStringLiteral(" | paramètres supprimés : ") + ar.parametresSupprimes.join(QLatin1Char(',')))
|
|
+ QStringLiteral(" | « ") + ar.texte + QStringLiteral(" »");
|
|
vues.insert(ar.action.loadId);
|
|
if (m_motifVu.value(ar.action.loadId) != ligne) {
|
|
m_motifVu.insert(ar.action.loadId, ligne);
|
|
lignes << ligne;
|
|
}
|
|
}
|
|
for (auto it = m_motifVu.begin(); it != m_motifVu.end();)
|
|
it = vues.contains(it.key()) ? std::next(it) : m_motifVu.erase(it);
|
|
|
|
const QString entete = QStringLiteral("[Optimiseur] créneau %1 → %2 du plan %3 appliqué : %4 action(s)")
|
|
.arg(c.from.toString(Qt::ISODate), c.to.toString(Qt::ISODate), v.plan.planId).arg(c.actions.count());
|
|
const QSet<QString> indisponibles(v.actionsSurIndisponibles.cbegin(), v.actionsSurIndisponibles.cend());
|
|
const bool indisponiblesChangent = indisponibles != m_indisponiblesVus;
|
|
m_indisponiblesVus = indisponibles;
|
|
if (lignes.isEmpty() && !indisponiblesChangent) {
|
|
qCDebug(dcNymeaEnergy()).noquote() << entete << "(motifs inchangés)";
|
|
return;
|
|
}
|
|
qCInfo(dcNymeaEnergy()).noquote() << entete;
|
|
for (const QString &l : lignes)
|
|
qCInfo(dcNymeaEnergy()).noquote() << "[Optimiseur] " << l;
|
|
for (const QString &id : (indisponiblesChangent ? v.actionsSurIndisponibles : QStringList()))
|
|
qCInfo(dcNymeaEnergy()).noquote() << "[Optimiseur] " << id
|
|
<< ": charge INDISPONIBLE — action de l'optimiseur rejetée, "
|
|
"celle du rule-based reste";
|
|
}
|