Réglage optimizerTrace (energy.conf Optimizer/trace, …RetentionDays, …MaxBytesPerDay), défaut DÉSACTIVÉ : ni objet, ni fil, ni fichier. NymeaEnergy.GetOptimizerTrace / SetOptimizerTrace, PermissionScopeAdmin comme SetOptimizerEndpoint, type contrôlé par le moteur, appliqué à chaud. Une ligne par cycle — rule-based, optimiseur, et L2 (strategy none, sans requête) : t (heure locale), request (params GetPlan envoyés, ou qui l'auraient été), response (ce qui a répondu à CETTE requête : result brut, error.code, no-response ; sinon le plan du rule-based en codes), executed (appliqué, clamped, shed), strategy, latencyMs. La ligne N attend la réponse à la requête N (elle sert au cycle N+1). Toutes les E/S sur un fil dédié (OptimizerTraceWriter) : rotation à minuit heure locale, purge au-delà de la rétention (14 j), plafond par fichier journalier (64 Mio, mesuré : 5 Ko/ligne rule-based, 39 Ko sous un optimiseur à 48 créneaux, 1 cycle/min), file bornée à 256 lignes. Échec d'écriture et plafond dits une fois. update() ne fait que poster. Le token n'est jamais dans une ligne ; recopié par une réponse d'optimiseur, il est masqué. Preuves : decisions IDENTIQUE traces désactivées ET activées (21 132 lignes de trace écrites, ETM_DECISIONS_TRACES=1). socketscheduler 33 fonctions, 114/0 (+10 fonctions T6). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
189 lines
9.4 KiB
C++
189 lines
9.4 KiB
C++
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
|
#pragma once
|
|
|
|
#include <QElapsedTimer>
|
|
#include <QHash>
|
|
#include <QObject>
|
|
#include <QSet>
|
|
#include <QString>
|
|
#include <QVariantMap>
|
|
#include <functional>
|
|
#include "ischeduler.h"
|
|
#include "optimizerclient.h"
|
|
#include "optimizerplan.h"
|
|
#include "optimizerprotocol.h"
|
|
#include "optimizerstatus.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 (§7)** : plan absent, invalide, expiré, abstention, connexion perdue → le rule-based
|
|
* pour le cycle. La vivacité (\c optimizerAlive, brief 4 T4) est tenue par
|
|
* \c OptimizerStatus::Vivacite : deux requêtes consécutives en échec, ou une connexion perdue,
|
|
* déclarent l'optimiseur mort et abandonnent le plan en cache ; le premier plan valide le rend
|
|
* vivant. Chaque changement est signalé par \c etatChange() ; l'arbitre en compose l'état publié.
|
|
*
|
|
* \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; }
|
|
|
|
/*! \brief \return La vivacité de l'optimiseur (§7) : vivant, échecs, dernier code, dernier plan. */
|
|
const OptimizerStatus::Vivacite &vivacite() const { return m_vivacite; }
|
|
|
|
/*! \brief \return Identifiant de la requête envoyée au DERNIER \c getPlan(), 0 si aucune n'est
|
|
* partie (client non connecté) — traces, brief 4 T6. */
|
|
quint64 idRequeteDuCycle() const { return m_idDuCycle; }
|
|
/*! \brief \return Les paramètres \c GetPlan envoyés au dernier \c getPlan() (vide si aucun). */
|
|
QVariantMap requeteDuCycle() const { return m_requeteDuCycle; }
|
|
|
|
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 Ce qui a répondu à la requête \p id, pour les traces (brief 4 T6).
|
|
* \param id Identifiant de la requête. \param reponse \c {"result": …} BRUT (texte libre
|
|
* compris : il ne va qu'aux traces, qui restent sur le site) ou \c {"error": {"code": …}}.
|
|
* \param latenceMs Délai envoi → réponse.
|
|
*/
|
|
void reponseTracee(quint64 id, const QVariantMap &reponse, qint64 latenceMs);
|
|
|
|
/*! \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);
|
|
|
|
/*! \brief La vivacité, les échecs, le dernier code d'erreur ou l'identité de l'optimiseur
|
|
* (handshake) ont pu changer — l'arbitre recompose l'état publié. */
|
|
void etatChange();
|
|
|
|
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);
|
|
void noterVivacite(OptimizerStatus::Vivacite::Evenement e, const QString &code,
|
|
const QDateTime &instant = QDateTime());
|
|
void onNotification(const QString &method);
|
|
|
|
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
|
|
QElapsedTimer m_chronoRequete; // latence de la requête en vol (traces)
|
|
quint64 m_idDuCycle = 0; // requête partie au dernier getPlan() (traces)
|
|
QVariantMap m_requeteDuCycle;
|
|
|
|
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
|
|
OptimizerStatus::Vivacite m_vivacite; // §7 : optimizerAlive, échecs consécutifs par requête
|
|
bool m_updateLearnedSignale = false; // UpdateLearned : dit une fois par session
|
|
};
|