Patrick Schurig 5d67dc943d [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>
2026-06-09 21:25:22 +02:00

124 lines
5.6 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#pragma once
#include <QObject>
#include <QDateTime>
#include <QList>
#include <QString>
#include "iloadadapter.h"
class Thing;
class ThingManager;
/*!
* \brief Adaptateur pour chauffe-eau ou tout relais N paliers (interface relay-stages).
*
* Pilote en production N Things powerswitch nymea : \c m_relayMapping[stage] contient
* la liste des ThingIds à mettre ON pour ce palier (les autres sont mis OFF).
*
* Exemple — chauffe-eau 2400W, 2 résistances Waveshare :
* stage 0 : {} → A=OFF, B=OFF
* stage 1 : {"thingId-A"} → A=ON, B=OFF (1200 W)
* stage 2 : {"thingId-A", "thingId-B"} → A=ON, B=ON (2400 W)
*
* \invariant applyAction() rejette silencieusement toute action dont \c reason est vide.
* \invariant applyAction() applique les verrous anti-rebond \c minOnS / \c minOffS
* SAUF si \c action.force == true (réservé L2 watchdog).
* \invariant Le stage est écrêté à [0, stages().size()-1] avant envoi matériel.
* \invariant Seul le kind Stage est traité ; les autres kinds retournent sans effet.
*/
class EcsRelayAdapter : public QObject, public ILoadAdapter
{
Q_OBJECT
public:
/*!
* \brief Constructeur.
* \param thingManager Gestionnaire nymea pour résoudre les ThingIds en Things.
* \param id Identifiant logique de la charge (ThingId de l'objet ECS dans nymea,
* ou identifiant arbitraire unique pour le mock).
* \param label Nom lisible affiché dans les logs et l'app.
* \param stages Puissances en W par palier, index 0 = off : [0, 1200, 2400].
* \param relayMapping relayMapping[i] = liste de ThingIds powerswitch ON pour le palier i.
* \param minOnS Durée minimale ON (s) — anti-rebond.
* \param minOffS Durée minimale OFF (s) — anti-rebond.
* \param priority Rang dans le waterfall (OPTIMIZER_PROTOCOL §5) : valeur plus BASSE
* = servi en premier (rang 1 = premier servi).
* \param parent Propriétaire Qt.
*/
explicit EcsRelayAdapter(ThingManager *thingManager,
const QString &id,
const QString &label,
const QList<int> &stages,
const QList<QList<QString>> &relayMapping,
int minOnS,
int minOffS,
int priority,
QObject *parent = nullptr);
/*!
* \brief Description statique de la charge.
* \return LoadDescriptor avec adapter="relay-stages", stages, minOnS/minOffS, priority.
*/
LoadDescriptor descriptor() const override;
/*!
* \brief Télémétrie runtime : puissance mesurée, stage courant, lastSwitch.
* \return LoadTelemetry avec currentPowerW issu de la somme des Things actifs.
*/
LoadTelemetry telemetry() const override;
/*!
* \brief Construit l'entrée loads[] §5 du SurplusContext.
* \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 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 (= \p now).
*/
LoadAction applyAction(const LoadAction &action, const QDateTime &now) override;
/*! \brief Stage courant (0 = off). */
int currentStage() const { return m_currentStage; }
private:
/*!
* \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;
QString m_id;
QString m_label;
QList<int> m_stages; //!< Puissances W par palier, [0]=off.
QList<QList<QString>> m_relayMapping; //!< ThingIds ON par palier.
int m_minOnS;
int m_minOffS;
int m_priority;
int m_currentStage = 0;
QDateTime m_lastSwitch; //!< Dernier changement de palier (null = jamais).
QDateTime m_lastActionAt;
};