Constat `drop` de T7 : SocketScheduler notait ConnexionPerdue à CHAQUE état non connecté du client (Connecting, Waiting d'une tentative), réécrivait lastErrorCode avec celui du client et émettait etatChange() à chaque tentative au backoff (une par minute au plafond). La perte se note désormais à la transition : sortie de Ready ou de Handshaking (fermeture, handshake refusé), ou premier échec d'un client qui n'a jamais été connecté (sinon un optimiseur absent dès le départ n'aurait jamais de lastErrorCode). Les tentatives suivantes d'une panne déjà notée ne touchent ni au dernier code, ni aux échecs, ni à l'état publié. Publication seule : aucune décision ne change. Test socketscheduler::testAReconnectionAttemptIsNotANewLoss : optimiseur disparu, 3 tentatives au backoff → 1 seul etatChange, 1 seule notification, lastErrorCode `connection-lost` (le client en est à `connect-failed`) ; au retour, les transitions du §7 et rien d'autre. Ligne `drop` de refoptimizer : le dernier code reste `disconnected`. Contre-épreuve M2 (ancien comportement) : mord sur les deux. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
357 lines
17 KiB
C++
357 lines
17 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)
|
|
{
|
|
static const QStringList ordre{QStringLiteral("cycle"), QStringLiteral("forecast"),
|
|
QStringLiteral("config"), QStringLiteral("load-change"),
|
|
QStringLiteral("intent")};
|
|
return ordre.indexOf(t);
|
|
}
|
|
}
|
|
|
|
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"));
|
|
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) {
|
|
noterCycle(false, QStringLiteral("no-plan"), QStringLiteral("aucun plan en cache"));
|
|
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;
|
|
}
|
|
|
|
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);
|
|
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";
|
|
}
|