[3c-3-fix] waterfall ECS : surplus net signé + clamp lock-aware (protection compresseur)

Bug : exportW clampé à max(0,-p) AVANT recrédit → sur-crédit en import (ECS
restait allumé sur le réseau, ne délestait jamais). Fix : surplus net SIGNÉ
(exportW - importW). Régime export inchangé.

Le délestage strict est borné par minOn/minOff (protection compresseur, pas confort) :
l'adaptateur expose minStage/maxStage (fenêtre de verrou évaluée au temps de cycle),
le scheduler clampe bestStage et décrémente au palier réel → budget correct pour les
charges suivantes (puissance verrouillée = engagée non-coupable).

Seam de temps unifié : now=ctx.timestamp partagé par toLoadContext()/applyAction() ;
lockWindow() est l'unique calcul, lockActive() en dérive (décision==exécution).
Interface ILoadAdapter étendue (now) + contrat "temps=paramètre, jamais l'horloge"
documenté pour les futurs adaptateurs. EvAdapter aligné. Build 0 erreur / 0 warning.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-06-09 21:25:22 +02:00
parent a471a23aeb
commit 5d67dc943d
9 changed files with 106 additions and 49 deletions

View File

@ -84,7 +84,7 @@ LoadTelemetry EcsRelayAdapter::telemetry() const
return t;
}
LoadContext EcsRelayAdapter::toLoadContext() const
LoadContext EcsRelayAdapter::toLoadContext(const QDateTime &now) const
{
LoadContext ctx;
ctx.id = m_id;
@ -98,10 +98,13 @@ LoadContext EcsRelayAdapter::toLoadContext() const
ctx.telemetry.currentPowerW = tel.currentPowerW;
ctx.telemetry.stage = m_currentStage;
ctx.telemetry.lastSwitch = m_lastSwitch;
// Fenêtre de verrou évaluée au temps de cycle : le scheduler y borne son choix
// (plancher = puissance engagée non-coupable sous minOn ; plafond = redémarrage minOff).
lockWindow(now, ctx.telemetry.minStage, ctx.telemetry.maxStage);
return ctx;
}
LoadAction EcsRelayAdapter::applyAction(const LoadAction &action)
LoadAction EcsRelayAdapter::applyAction(const LoadAction &action, const QDateTime &now)
{
if (action.kind != LoadAction::Stage)
return action;
@ -117,8 +120,9 @@ LoadAction EcsRelayAdapter::applyAction(const LoadAction &action)
if (newStage == m_currentStage)
return action; // Aucun changement → idempotent
// Verrous anti-rebond — bypassés si force == true (L2 watchdog)
if (!action.force && lockActive(newStage)) {
// Verrous anti-rebond évalués au temps de cycle (même fenêtre que le scheduler) —
// bypassés si force == true (L2 watchdog).
if (!action.force && lockActive(newStage, now)) {
qCDebug(dcNymeaEnergy()) << "[EcsRelayAdapter]" << m_label
<< "— verrou anti-rebond actif, stage" << newStage << "ignoré.";
return action;
@ -133,8 +137,8 @@ LoadAction EcsRelayAdapter::applyAction(const LoadAction &action)
applyRelayStage(newStage);
m_currentStage = newStage;
m_lastSwitch = QDateTime::currentDateTime();
m_lastActionAt = m_lastSwitch;
m_lastSwitch = now; // estampille au temps de cycle (cohérent avec la fenêtre de verrou)
m_lastActionAt = now;
LoadAction applied = action;
applied.stage = newStage;
@ -144,24 +148,26 @@ LoadAction EcsRelayAdapter::applyAction(const LoadAction &action)
// ---- privé ---------------------------------------------------------------
bool EcsRelayAdapter::lockActive(int newStage) const
void EcsRelayAdapter::lockWindow(const QDateTime &now, int &minStage, int &maxStage) const
{
if (!m_lastSwitch.isValid())
return false;
const int topStage = m_stages.size() - 1;
const bool valid = m_lastSwitch.isValid();
const qint64 elapsed = valid ? m_lastSwitch.secsTo(now) : 0;
const int elapsed = static_cast<int>(m_lastSwitch.secsTo(QDateTime::currentDateTime()));
// Plancher : si ON et minOn non écoulé → interdit de descendre sous le palier courant
// (puissance engagée non-coupable, protection compresseur / anti court-cycling).
minStage = (m_currentStage > 0 && valid && elapsed < m_minOnS) ? m_currentStage : 0;
if (newStage > m_currentStage) {
// Passage à un palier supérieur : minOffS si l'on quitte l'état OFF (stage 0→n)
// ou simplement le délai de stabilisation
if (m_currentStage == 0 && elapsed < m_minOffS)
return true;
} else {
// Réduction de palier : minOnS depuis le dernier ON
if (m_currentStage > 0 && elapsed < m_minOnS)
return true;
}
return false;
// Plafond : si à l'arrêt et minOff non écoulé → interdit de redémarrer.
maxStage = (m_currentStage == 0 && valid && elapsed < m_minOffS) ? 0 : topStage;
}
bool EcsRelayAdapter::lockActive(int newStage, const QDateTime &now) const
{
// MÊME calcul que la fenêtre exposée au scheduler → décision et exécution coïncident.
int minStage, maxStage;
lockWindow(now, minStage, maxStage);
return newStage < minStage || newStage > maxStage;
}
void EcsRelayAdapter::applyRelayStage(int stage)

View File

@ -70,28 +70,42 @@ public:
/*!
* \brief Construit l'entrée loads[] §5 du SurplusContext.
* \return LoadContext incluant declared, limits et télémétrie ECS (stage, currentPowerW, lastSwitch).
* \param now Temps de cycle (\c ctx.timestamp) source unique pour minStage/maxStage.
* \return LoadContext incluant declared, limits et télémétrie ECS (stage, currentPowerW,
* lastSwitch, et la fenêtre de verrou \c minStage/maxStage évaluée à \p now).
*/
LoadContext toLoadContext() const override;
LoadContext toLoadContext(const QDateTime &now) const override;
/*!
* \brief Applique un changement de palier sur les relais.
*
* \param action LoadAction de kind Stage. Autres kinds : retour sans effet.
* \param now Temps de cycle (\c ctx.timestamp) MÊME source que toLoadContext(),
* utilisée pour l'évaluation des verrous et l'estampille \c m_lastSwitch.
* \return L'action après écrêtage (stage borné à [0, stages.size()-1]).
*
* \invariant Si \c action.reason est vide retour sans effet (log warning).
* \invariant Si verrous anti-rebond actifs ET \c action.force == false retour sans effet (log).
* \invariant Si \c action.force == true bypass verrous (L2 watchdog uniquement).
* \invariant Toute modification de stage met à jour \c m_lastSwitch.
* \invariant Toute modification de stage met à jour \c m_lastSwitch (= \p now).
*/
LoadAction applyAction(const LoadAction &action) override;
LoadAction applyAction(const LoadAction &action, const QDateTime &now) override;
/*! \brief Stage courant (0 = off). */
int currentStage() const { return m_currentStage; }
private:
bool lockActive(int newStage) const;
/*!
* \brief Fenêtre de paliers autorisée à l'instant \p now par les verrous minOn/minOff.
* \param now Temps de cycle.
* \param[out] minStage Plancher : palier courant si minOn non écoulé (puissance engagée
* non-coupable), sinon 0.
* \param[out] maxStage Plafond : 0 si à l'arrêt et minOff non écoulé (redémarrage interdit),
* sinon le palier le plus haut.
*/
void lockWindow(const QDateTime &now, int &minStage, int &maxStage) const;
bool lockActive(int newStage, const QDateTime &now) const;
void applyRelayStage(int stage);
ThingManager *m_thingManager;

View File

@ -43,8 +43,9 @@ LoadTelemetry EvAdapter::telemetry() const
return t;
}
LoadContext EvAdapter::toLoadContext() const
LoadContext EvAdapter::toLoadContext(const QDateTime &now) const
{
Q_UNUSED(now) // L'EV n'a pas de verrou de palier (pas de waterfall ECS) — now inutilisé ici.
LoadContext ctx;
ctx.id = m_charger->thing()->id().toString();
ctx.adapter = QStringLiteral("evcharger");
@ -58,7 +59,7 @@ LoadContext EvAdapter::toLoadContext() const
return ctx;
}
LoadAction EvAdapter::applyAction(const LoadAction &action)
LoadAction EvAdapter::applyAction(const LoadAction &action, const QDateTime &now)
{
if (action.kind != LoadAction::Setpoint)
return action;
@ -83,8 +84,7 @@ LoadAction EvAdapter::applyAction(const LoadAction &action)
: ChargingAction::ChargingActionIssuerTimeRequirement;
ChargingAction ca(action.chargingEnabled, clampedA, phases, issuer, false);
const QDateTime now = QDateTime::currentDateTimeUtc();
m_parent->doExecuteChargingAction(m_charger, ca, now);
m_parent->doExecuteChargingAction(m_charger, ca, now); // now = temps de cycle (injectable)
m_lastActionAt = now;
LoadAction applied = action;

View File

@ -48,7 +48,7 @@ public:
* \brief Construit l'entrée loads[] §5 du SurplusContext.
* \return LoadContext incluant declared, limits, needs et télémétrie EV.
*/
LoadContext toLoadContext() const override;
LoadContext toLoadContext(const QDateTime &now) const override;
/*!
* \brief Applique une consigne Setpoint sur la borne VE.
@ -65,7 +65,7 @@ public:
* \invariant currentA écrêté à [minValue, maxValue] avant envoi à executeChargingAction.
* \invariant phaseCount ajusté selon canSetPhaseCount() du EvCharger.
*/
LoadAction applyAction(const LoadAction &action) override;
LoadAction applyAction(const LoadAction &action, const QDateTime &now) override;
/*! \brief Borne VE sous-jacente (lecture). */
EvCharger *evCharger() const { return m_charger; }

View File

@ -27,6 +27,12 @@ struct LoadTelemetry {
* (second filet après l'écrêtage de l'arbitre).
* \invariant applyAction() avec \c reason vide doit être rejetée silencieusement.
* \invariant Les méthodes non-applyAction() retournent immédiatement (pas de I/O bloquant).
* \invariant **Temps = paramètre, jamais l'horloge.** Toute logique temporelle d'un
* adaptateur (verrous minOn/minOff, fenêtres, fraîcheur) utilise EXCLUSIVEMENT le
* \c now (= \c ctx.timestamp) reçu en paramètre de \c toLoadContext()/applyAction().
* JAMAIS \c QDateTime::currentDateTime(). C'est cette source unique, partagée avec le
* scheduler, qui rend impossible toute divergence décision/exécution et qui rend la
* logique injectable en simulation. Contrat pour tout futur adaptateur (SgReady, Battery).
*/
class ILoadAdapter {
public:
@ -47,9 +53,12 @@ public:
/*!
* \brief Construit l'entrée §5 loads[] pour le SurplusContext.
* \param now Temps de cycle (\c ctx.timestamp). Source unique pour l'évaluation des
* verrous (minStage/maxStage) JAMAIS \c QDateTime::currentDateTime() côté adaptateur,
* afin que décision (scheduler) et exécution (applyAction) partagent le même temps.
* \return LoadContext incluant declared, limits, needs et télémétrie type-spécifique.
*/
virtual LoadContext toLoadContext() const = 0;
virtual LoadContext toLoadContext(const QDateTime &now) const = 0;
/*!
* \brief Applique l'action et retourne ce qui a réellement é envoyé au matériel.
@ -57,8 +66,10 @@ public:
* L'arbitre a déjà écrêté selon les limites et le budget ceci est le second filet.
*
* \param action Action à appliquer. Doit avoir \c reason non vide.
* \param now Temps de cycle (\c ctx.timestamp) MÊME source que toLoadContext(),
* pour que l'évaluation des verrous coïncide avec celle vue par le scheduler.
* \return L'action après écrêtage matériel (peut différer de l'entrée).
* \note Retour silencieux sans effet si \c action.reason est vide.
*/
virtual LoadAction applyAction(const LoadAction &action) = 0;
virtual LoadAction applyAction(const LoadAction &action, const QDateTime &now) = 0;
};

View File

@ -135,8 +135,8 @@ void EnergyArbitrator::update(const QDateTime &currentDateTime)
}
// 7 : dispatch matériel (même position que l'amont — m_chargingActions rempli par getPlan())
applyActionsToAdapters(slot); // ECS (kind==Stage) → m_ecsAdapters
adjustEvChargers(currentDateTime); // EV (kind==Setpoint) → proxy amont jusqu'à 3g
applyActionsToAdapters(slot, currentDateTime); // ECS (kind==Stage) → m_ecsAdapters
adjustEvChargers(currentDateTime); // EV (kind==Setpoint) → proxy amont jusqu'à 3g
}
SurplusContext EnergyArbitrator::buildContext(const QDateTime &now) const
@ -160,13 +160,13 @@ SurplusContext EnergyArbitrator::buildContext(const QDateTime &now) const
// SurplusPv : interface inverter — déféré (remplissage prévu en 3d)
// SurplusBattery : déféré 3f
// --- loads[] : EV adapters ---
// --- loads[] : EV adapters --- (now = ctx.timestamp : source unique des verrous)
for (auto it = m_adapters.constBegin(); it != m_adapters.constEnd(); ++it)
ctx.loads.append(it.value()->toLoadContext());
ctx.loads.append(it.value()->toLoadContext(now));
// --- loads[] : ECS relay adapters ---
for (auto it = m_ecsAdapters.constBegin(); it != m_ecsAdapters.constEnd(); ++it)
ctx.loads.append(it.value()->toLoadContext());
ctx.loads.append(it.value()->toLoadContext(now));
return ctx;
}
@ -186,7 +186,7 @@ void EnergyArbitrator::syncAdapters()
}
}
void EnergyArbitrator::applyActionsToAdapters(const Slot &slot)
void EnergyArbitrator::applyActionsToAdapters(const Slot &slot, const QDateTime &now)
{
for (const LoadAction &action : slot.actions) {
// EV (Setpoint) : dispatché par adjustEvChargers() amont jusqu'à 3g.
@ -199,7 +199,7 @@ void EnergyArbitrator::applyActionsToAdapters(const Slot &slot)
continue;
}
// L'adaptateur applique, écrête et verrouille (anti-rebond) — il ne décide pas.
adapter->applyAction(action);
adapter->applyAction(action, now);
}
}
@ -236,7 +236,7 @@ void EnergyArbitrator::applyDegradedMode(const QDateTime &now)
la.stage = 0;
la.force = true;
la.reason = reason;
adapter->applyAction(la);
adapter->applyAction(la, now); // force=true → bypass verrous ; now = temps de cycle
}
// EV : repli CONSERVATEUR — n'initie aucune charge. On clampe seulement une charge

View File

@ -134,8 +134,10 @@ private:
* L'adaptateur écrête/verrouille lui-même (anti-rebond) et ignore toute action sans
* \c reason ou de kind non supporté aucune décision ici (règle 2).
* \param slot Créneau courant retourné par le scheduler.
* \param now Temps de cycle (\c ctx.timestamp) transmis aux adaptateurs pour une
* évaluation des verrous cohérente avec celle vue par le scheduler.
*/
void applyActionsToAdapters(const Slot &slot);
void applyActionsToAdapters(const Slot &slot, const QDateTime &now);
/*!
* \brief Tick du watchdog L2 (SAFETY.md §L2) piloté par \c m_meterWatchdog (QTimer 30 s).

View File

@ -91,7 +91,10 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
const double measuredW = ev ? ev->currentPower() : 0.0;
evReservedW += qMax(0.0, la.estimatedPowerW - measuredW);
}
double remainingSurplusW = qMax(0.0, ctx.meter.exportW - evReservedW);
// Surplus net SIGNÉ : exportW importW = puissance compteur. Négatif en import →
// l'ECS déleste (budget < palier) au lieu de rester allumé sur le réseau. (Le maintien
// transitoire sous minOn est géré par le verrou de l'adaptateur, pas par le budget.)
double remainingSurplusW = (ctx.meter.exportW - ctx.meter.importW) - evReservedW;
// Charges ECS triées par priorité ASCENDANTE : rang 1 = premier servi
// (OPTIMIZER_PROTOCOL §5 + annexe C — la priorité est un rang, pas un poids).
@ -171,22 +174,38 @@ LoadAction RuleBasedScheduler::buildEcsStageAction(const LoadContext &lc,
const double budgetChargeW = remainingSurplusW + lc.telemetry.currentPowerW;
// Palier déclaré le plus haut qui tient dans le budget. stages[0] == 0 par construction
// (Q_ASSERT EcsRelayAdapter) → bestStage ≥ 0 toujours, donc reason toujours définie.
// (Q_ASSERT EcsRelayAdapter).
const QList<int> &stages = lc.declared.stages;
int bestStage = 0;
const int topStage = stages.size() - 1;
int budgetStage = 0;
for (int i = 0; i < stages.size(); ++i) {
if (stages.at(i) <= budgetChargeW)
bestStage = i;
budgetStage = i;
}
// Verrou (minOn/minOff) : le palier choisi est borné par la fenêtre déclarée par
// l'adaptateur (calculée au MÊME ctx.timestamp). Une charge verrouillée ON garde son
// palier (puissance engagée non-coupable) ; à l'arrêt sous minOff elle ne redémarre pas.
const int lo = qBound(0, lc.telemetry.minStage, topStage);
const int hi = qBound(lo, lc.telemetry.maxStage, topStage);
const int bestStage = qBound(lo, budgetStage, hi);
LoadAction la;
la.loadId = lc.id;
la.kind = LoadAction::Stage;
la.funding = LoadAction::Surplus;
la.stage = bestStage;
la.estimatedPowerW = bestStage < stages.size() ? stages.at(bestStage) : 0;
la.estimatedPowerW = stages.at(bestStage);
if (bestStage > 0)
if (bestStage > budgetStage)
// Maintenu au-dessus du budget : protection compresseur (minOn non écoulé).
la.reason = QStringLiteral("Verrou minOn — %1 maintenu palier %2 (%3 W, surplus %4 W)")
.arg(lc.label).arg(bestStage).arg(stages.at(bestStage)).arg(qRound(budgetChargeW));
else if (bestStage < budgetStage)
// Empêché de monter/redémarrer par minOff malgré le surplus.
la.reason = QStringLiteral("Verrou minOff — %1 maintenu palier %2 (redémarrage trop tôt)")
.arg(lc.label).arg(bestStage);
else if (bestStage > 0)
la.reason = QStringLiteral("Surplus PV %1 W — %2 palier %3 (%4 W)")
.arg(qRound(budgetChargeW)).arg(lc.label)
.arg(bestStage).arg(stages.at(bestStage));

View File

@ -61,6 +61,11 @@ struct LoadContextTelemetry {
double sessionWh = 0; //!< Énergie chargée dans la session courante (Wh).
// --- relay-stages ---
int stage = 0;
//! Fenêtre de paliers autorisée MAINTENANT par les verrous (calculée au temps de cycle).
//! Le scheduler y borne son choix : minStage = plancher (verrou minOn, puissance
//! engagée non-coupable) ; maxStage = plafond (verrou minOff, redémarrage interdit).
int minStage = 0;
int maxStage = 0;
// --- sg-ready ---
int state = 0;
// --- battery / electricvehicle ---