- 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>
150 lines
6.2 KiB
C++
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;
|
|
}
|
|
|
|
}
|