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

157 lines
7.5 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#pragma once
#include <QHash>
#include <QObject>
#include <QSet>
#include <QString>
#include <QVariantMap>
#include <functional>
#include "ischeduler.h"
#include "optimizerclient.h"
#include "optimizerplan.h"
#include "optimizerprotocol.h"
/*!
* \brief Stratégie « optimiseur externe » (OPTIMIZER_PROTOCOL.md 1.1, lot 3d, brief 4 T3).
*
* N'existe QUE si un endpoint est configuré (\c Optimizer/endpoint) : sans lui, l'arbitre
* n'instancie ni cette classe ni son \c OptimizerClient, et interroge le rule-based directement.
*
* **À chaque cycle**, \c getPlan() :
* 1. appelle le rule-based — le plan OMBRE. Il tourne toujours, parce que ses effets de bord sur
* l'arbitre (actions du proxy VE, comptabilité éco, réserve batterie) sont dus à chaque cycle,
* et parce qu'il est le repli immédiat ;
* 2. envoie \c GetPlan avec le contexte du cycle, SANS ATTENDRE : la réponse arrive par signal
* et sert au cycle SUIVANT (modèle cache, règle 5) ;
* 3. si le plan en cache est valide pour maintenant (\c OptimizerPlan::valider() sur le contexte
* du cycle : créneau courant, \c validUntil, couverture Q1), substitue ses actions à celles de
* l'ombre (\c OptimizerPlan::substituer()) ; sinon rend l'ombre telle quelle.
*
* Après le dispatch, \c apresDispatch() relit ce que les adaptateurs ont appliqué (Q3, Q4).
*
* **Repli minimal de ce lot** : plan absent, invalide, expiré, abstention, connexion perdue → le
* rule-based pour le cycle. La machine §7 complète (compteur d'échecs, \c optimizerAlive,
* notification) est T4 ; ses points d'accroche sont les signaux \c reponseJugee(),
* \c echecRequete() et \c strategieDuCycle().
*
* \invariant \c getPlan() n'attend jamais : aucune E/S n'y est bloquante.
* \invariant Le plan rendu est toujours valide : au pire, c'est celui du rule-based.
* \invariant Aucun appel à un adaptateur : les actions passent par le dispatch commun de
* l'arbitre (« un seul arbitre »), qui les écrête comme celles du rule-based.
* \invariant Le texte libre d'un optimiseur va au journal, jamais dans une action (invariant 12).
*/
class SocketScheduler : public QObject, public IScheduler
{
Q_OBJECT
public:
//! Ce que la requête porte en plus du contexte (déclencheur excepté) — fourni par l'arbitre.
using FournisseurFil = std::function<OptimizerProtocol::ContexteFil(const SurplusContext &)>;
/*!
* \brief Construit la stratégie et démarre le client.
* \param ombre Le rule-based, appelé à chaque cycle ; non possédé, doit survivre.
* \param client Transport vers l'optimiseur ; POSSÉDÉ (reparenté), démarré ici.
* \param fil Fournisseur du \c ContexteFil de la requête ; vide = tout inconnu.
* \param parent Propriétaire Qt.
*/
SocketScheduler(IScheduler *ombre, OptimizerClient *client, FournisseurFil fil,
QObject *parent = nullptr);
/*!
* \brief Plan du cycle — voir la description de la classe.
* \param ctx Contexte du cycle. \return Le plan d'optimiseur substitué, ou celui de l'ombre.
*/
Plan getPlan(const SurplusContext &ctx) override;
/*!
* \brief Après le dispatch : puissance appliquée, financement, budget et \c clamped (Q3, Q4).
*
* Sans effet si \p plan n'est pas un plan d'optimiseur. À appeler entre
* \c applyActionsToAdapters() et la fusion du délestage L4.
* \param plan Plan du cycle. \param slot Créneau dispatché.
* \param appliquees Retours d'\c applyAction(). \param delestees Charges revendiquées par L4.
* \param ctx Contexte du cycle.
*/
void apresDispatch(Plan &plan, Slot &slot, const QHash<QString, LoadAction> &appliquees,
const QSet<QString> &delestees, const SurplusContext &ctx);
/*!
* \brief Note la cause de la prochaine requête (§5.1 \c trigger).
*
* Plusieurs causes entre deux requêtes : la plus significative l'emporte —
* \c intent > \c load-change > \c config > \c forecast > \c cycle. Consommée par l'envoi.
* \param trigger Une des cinq valeurs ci-dessus ; toute autre est ignorée.
*/
void noterDeclencheur(const QString &trigger);
/*! \brief \return La cause que portera la prochaine requête. */
QString declencheurEnAttente() const { return m_declencheur; }
/*! \brief \return Le transport (lecture : état, capacités). */
OptimizerClient *client() const { return m_client; }
/*! \brief \return Vrai si un plan valide à sa réception est en cache. */
bool aUnPlanEnCache() const { return m_aCache; }
/*! \brief \return Vrai si le plan du dernier cycle était celui de l'optimiseur. */
bool optimiseurAuDernierCycle() const { return m_optimiseurAuCycle; }
/*! \brief \return Code du dernier cycle rendu au rule-based (vide si optimiseur). */
QString dernierCodeDeRepli() const { return m_codeCycle; }
/*!
* \brief Le dernier plan d'optimiseur appliqué, tel que reçu et converti.
* \return \c expected (par créneau) et \c diagnostics y sont CONSERVÉS pour l'app et les
* traces ; rien de ce qu'ils contiennent ne commande quoi que ce soit.
*/
OptimizerPlan::PlanRecu planApplique() const { return m_planApplique; }
signals:
/*!
* \brief Une réponse \c GetPlan a été jugée — accroche du compteur d'échecs §7 (T4).
* \param issue \c valide | \c abstention | \c expiré | \c invalide.
* \param code Code du verdict (\c OptimizerPlan::Verdict), vide si valide.
*/
void reponseJugee(const QString &issue, const QString &code);
/*! \brief La requête attendue n'a pas eu de réponse utilisable. \param code Code du client
* (\c timeout, \c disconnected, \c rpc-error, \c invalid-response). */
void echecRequete(const QString &code);
/*! \brief Stratégie retenue au cycle. \param optimiseur Vrai si le plan de l'optimiseur a
* été substitué. \param code Motif du repli, vide si optimiseur. */
void strategieDuCycle(bool optimiseur, const QString &code);
private:
void envoyer(const SurplusContext &ctx);
void onPlanRecu(quint64 id, const QVariantMap &result);
void onPlanEchoue(quint64 id, const QString &code);
void oublierCache(const QString &pourquoi);
void noterCycle(bool optimiseur, const QString &code, const QString &detail);
void signalerReserves(const QStringList &champs);
void journaliserCreneau(const OptimizerPlan::Verdict &v, const SurplusContext &ctx);
IScheduler *m_ombre = nullptr;
OptimizerClient *m_client = nullptr;
FournisseurFil m_fil;
QString m_declencheur = QStringLiteral("cycle");
quint64 m_idRequete = 0;
SurplusContext m_ctxRequete; // contexte de la requête en vol : juge sa réponse
bool m_aCache = false;
QVariantMap m_cache; // dernier `result` valide à sa réception, brut
bool m_optimiseurAuCycle = false;
QString m_codeCycle = QStringLiteral("no-plan");
bool m_cycleNote = false;
OptimizerPlan::PlanRecu m_planApplique;
QString m_dernierPlanRecu; // planId de la dernière réponse valide annoncée
QHash<QString, QString> m_motifVu; // loadId → dernière ligne de motif annoncée
QSet<QString> m_indisponiblesVus; // charges indisponibles visées, dernier jeu annoncé
QHash<QString, QString> m_ecretageVu; // loadId → dernier écrêtage annoncé
QSet<QString> m_reservesSignales; // une fois par session
};