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>
271 lines
12 KiB
C++
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";
|
|
}
|