[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:
parent
a471a23aeb
commit
5d67dc943d
@ -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)
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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 été 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;
|
||||
};
|
||||
|
||||
@ -135,8 +135,8 @@ void EnergyArbitrator::update(const QDateTime ¤tDateTime)
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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 ---
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user