Patrick Schurig ff7c8011e2 feat(optimiseur): SocketScheduler — le plan d'un optimiseur appliqué par le chemin commun (brief 4 T3)
Réglage `Optimizer/endpoint` (energy.conf), lu au démarrage par
EnergyArbitrator::relireReglageOptimiseur() — lecture seule, la RPC viendra
en T4. ABSENT : aucun SocketScheduler ni OptimizerClient n'existe, update()
interroge le rule-based comme avant (decisions IDENTIQUE, 23 772 lignes).

SocketScheduler (IScheduler) : à chaque cycle, le rule-based en OMBRE (effets
de bord conservés), puis GetPlan envoyé sans attendre (getPlanParams + fil de
l'arbitre : compteur racine, PV, phases, réserve, domaines, mesures,
prévision réelle) ; si le plan en cache est valide pour CE cycle, ses actions
remplacent celles de l'ombre (OptimizerPlan::substituer). Sinon rule-based :
plan absent, invalide, expiré, abstention, connexion perdue. Un plan invalide
vide le cache. Les actions passent par applyActionsToAdapters comme celles du
rule-based ; apresDispatch() relit l'appliqué (Q3, Q4, clamped), avant la
fusion L4. L4 et L2 inchangés : L4 garde le dernier mot, L2 suspend tout.

Déclencheur réel (§5.1 trigger) : intent (ChargingInfo), load-change (Thing
ajouté/retiré), config (SetLoadConfig, limites, réserve), forecast (révision
nouvelle) ; le plus significatif l'emporte, consommé à l'envoi.

Journal : le texte libre de l'optimiseur y va (assaini), jamais dans la
télémétrie ; lignes en clair au changement seulement (stratégie, motifs,
écrêtage). Version du moteur du handshake lue dans debian/changelog.

Points d'accroche T4 : reponseJugee(), echecRequete(), strategieDuCycle().
INTERFACE.md : niveau unique « optimizer », budget/draw sous l'optimiseur,
préséance L4 > indisponible/dégradé > optimiseur > éco|confort.

Tests : suite etm-powersync-socketscheduler (12 fonctions, 43 passés) —
faux optimiseur unix et tcp dans le processus, arbitre réel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 22:06:45 +02:00

271 lines
12 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 » : un plan ne survit pas à la connexion qui l'a porté.
connect(m_client, &OptimizerClient::stateChanged, this, [this](OptimizerClient::State s) {
if (s != OptimizerClient::Ready && s != OptimizerClient::Handshaking)
oublierCache(QStringLiteral("connexion perdue"));
});
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);
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 quint64 id = m_client->requestPlan(OptimizerProtocol::getPlanParams(ctx, fil));
if (id == 0)
return;
m_idRequete = id;
m_ctxRequete = ctx;
m_declencheur = QStringLiteral("cycle");
}
void SocketScheduler::onPlanRecu(quint64 id, const QVariantMap &result)
{
if (id != m_idRequete)
return;
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);
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 (§7, T4).
emit echecRequete(code);
}
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";
}