Patrick Schurig c1ce67942c feat(optimiseur): machine de repli §7, état et plan publiés vers l'app (brief 4 T4)
- OptimizerStatus::Vivacite : optimizerAlive, échecs comptés PAR REQUÊTE (E17), deux
  consécutifs = mort, socket fermé = mort, retour au premier plan valide ; une abstention
  rompt la série sans ressusciter. strategieActive() : none sous L2, prioritaire sur tout ;
  rules dès qu'un optimiseur n'est plus vivant, sans attendre le cycle suivant.
- SocketScheduler : branchement de la vivacité, cache abandonné à la mort, UpdateLearned
  journalisé une fois et ignoré.
- NymeaEnergy.GetOptimizerStatus + OptimizerStatusChanged (une notification par
  transition, charge utile déclarée une fois) ; champs installateur absents s'ils sont
  inconnus ; jamais le token, tokenConfigured seulement. Pas de tier (Q5).
- SetOptimizerEndpoint (persisté, appliqué à chaud) et SetOptimizerToken (écriture seule,
  fichier 0600, Q2), tous deux PermissionScopeAdmin ; token déclaré Variant pour qu'un
  mauvais type ne soit pas recopié au journal par le validateur nymea.
- NymeaEnergy.GetPlan : plan en vigueur (appliqué au créneau courant, demandé ensuite),
  codes et paramètres filtrés seulement, inForce toujours présent, fallbackCode quand un
  optimiseur configuré n'a pas commandé.
- Tests : tableau §7 ligne par ligne, transitions notifiées une fois, RPC, token absent de
  tout journal du moteur et de toute réponse.

Sans endpoint : decisions IDENTIQUE (23 792 lignes), suites inchangées, journal >= info
inchangé (8 739 lignes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 09:34:39 +02:00

150 lines
6.2 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#include "publishedplan.h"
#include <QStringList>
#include <cmath>
namespace PublishedPlan {
namespace {
QString iso(const QDateTime &t) { return t.toUTC().toString(Qt::ISODate); }
QString nomKind(LoadAction::Kind k)
{
switch (k) {
case LoadAction::Setpoint: return QStringLiteral("setpoint");
case LoadAction::State: return QStringLiteral("state");
case LoadAction::Constraint: return QStringLiteral("constraint");
}
return QString();
}
// Une action vers l'app : la charge, sa puissance, l'état SG-Ready, le motif EN CODE. Le motif
// est celui de l'action (correctif a ; filtré à la réception pour un optimiseur, T3).
QVariantMap entree(const LoadAction &a, double puissanceW, int etat)
{
QVariantMap m;
m.insert(QStringLiteral("loadId"), a.loadId);
m.insert(QStringLiteral("kind"), nomKind(a.kind));
m.insert(QStringLiteral("powerW"), puissanceW);
if (a.kind == LoadAction::State)
m.insert(QStringLiteral("state"), etat);
m.insert(QStringLiteral("reasonCode"), a.reason.code);
if (!a.reason.params.isEmpty())
m.insert(QStringLiteral("reasonParams"), a.reason.params);
return m;
}
// Le créneau courant : ce que les adaptateurs ont APPLIQUÉ. Une charge délestée par L4 ou non
// dispatchée n'a pas de retour d'applyAction : son action du créneau (fusionnée) fait foi.
QVariantList actionsAppliquees(const Slot &slot, const QHash<QString, LoadAction> &appliquees)
{
QVariantList l;
for (const LoadAction &a : slot.actions) {
const auto r = appliquees.constFind(a.loadId);
if (r != appliquees.constEnd())
l.append(entree(a, qMax(0.0, r->estimatedPowerW), r->state));
else
l.append(entree(a, qMax(0.0, a.estimatedPowerW), a.state));
}
return l;
}
QVariantMap creneau(const QDateTime &from, const QDateTime &to, const QVariantList &actions,
const QVariantMap &expected)
{
QVariantMap c;
c.insert(QStringLiteral("from"), iso(from));
c.insert(QStringLiteral("to"), iso(to));
c.insert(QStringLiteral("actions"), actions);
const QVariantMap e = attenduPublie(expected);
if (!e.isEmpty())
c.insert(QStringLiteral("expected"), e);
return c;
}
} // namespace
QVariantMap attenduPublie(const QVariantMap &expected)
{
QVariantMap out;
for (const char *cle : {"gridImportW", "gridExportW"}) {
const QVariant v = expected.value(QLatin1String(cle));
if (v.userType() != QMetaType::Double && v.userType() != QMetaType::Int
&& v.userType() != QMetaType::LongLong)
continue;
const double d = v.toDouble();
if (std::isfinite(d))
out.insert(QLatin1String(cle), d);
}
return out;
}
QVariantMap diagnosticsPublies(const QVariantMap &diagnostics)
{
static const QStringList statuts{QStringLiteral("optimal"), QStringLiteral("feasible"),
QStringLiteral("best-so-far"), QStringLiteral("infeasible-relaxed")};
static const QStringList pv{QStringLiteral("physics"), QStringLiteral("hybrid"), QStringLiteral("none")};
QVariantMap out;
const QVariant statut = diagnostics.value(QStringLiteral("status"));
if (statut.userType() == QMetaType::QString && statuts.contains(statut.toString()))
out.insert(QStringLiteral("status"), statut.toString());
const QVariant entrees = diagnostics.value(QStringLiteral("inputsUsed"));
if (entrees.userType() == QMetaType::QVariantMap) {
const QVariant p = entrees.toMap().value(QStringLiteral("pv"));
if (p.userType() == QMetaType::QString && pv.contains(p.toString()))
out.insert(QStringLiteral("inputsUsed"), QVariantMap{{QStringLiteral("pv"), p.toString()}});
}
return out;
}
QVariantMap serialiser(const Plan &plan, const Slot &slot, const QHash<QString, LoadAction> &appliquees,
const OptimizerPlan::PlanRecu *recu, const QString &codeRepli, const QDateTime &now)
{
QVariantMap m;
m.insert(QStringLiteral("inForce"), true);
const bool optimiseur = plan.strategy == QLatin1String(OptimizerPlan::StrategieOptimiseur) && recu
&& recu->indexCourant >= 0 && recu->indexCourant < recu->creneaux.size();
if (!optimiseur) {
m.insert(QStringLiteral("strategy"), QLatin1String(StrategieRegles));
m.insert(QStringLiteral("computedAt"), iso(now));
if (slot.to.isValid())
m.insert(QStringLiteral("validUntil"), iso(slot.to));
m.insert(QStringLiteral("slots"), QVariantList{
creneau(slot.from.isValid() ? slot.from : now, slot.to.isValid() ? slot.to : now,
actionsAppliquees(slot, appliquees), QVariantMap())});
if (!codeRepli.isEmpty())
m.insert(QStringLiteral("fallbackCode"), codeRepli);
return m;
}
m.insert(QStringLiteral("strategy"), QLatin1String(OptimizerPlan::StrategieOptimiseur));
if (recu->computedAt.isValid())
m.insert(QStringLiteral("computedAt"), iso(recu->computedAt));
if (recu->validUntil.isValid())
m.insert(QStringLiteral("validUntil"), iso(recu->validUntil));
const OptimizerPlan::CreneauRecu &courant = recu->creneaux.at(recu->indexCourant);
QVariantList creneaux{creneau(courant.from, courant.to, actionsAppliquees(slot, appliquees),
courant.expected)};
// Les créneaux SUIVANTS : ce que l'optimiseur DEMANDE, informatif (§6) — rien n'y est encore
// appliqué. Les passés ne se publient pas : ils ne sont plus en vigueur.
for (int i = recu->indexCourant + 1; i < recu->creneaux.size(); ++i) {
const OptimizerPlan::CreneauRecu &c = recu->creneaux.at(i);
QVariantList actions;
for (const OptimizerPlan::ActionRecue &ar : c.actions)
actions.append(entree(ar.action, qMax(0.0, ar.action.estimatedPowerW), ar.action.state));
creneaux.append(creneau(c.from, c.to, actions, c.expected));
}
m.insert(QStringLiteral("slots"), creneaux);
const QVariantMap diag = diagnosticsPublies(recu->diagnostics);
if (!diag.isEmpty())
m.insert(QStringLiteral("diagnostics"), diag);
return m;
}
}