diff --git a/docs/DESIGN_PREVISION_PV.md b/docs/DESIGN_PREVISION_PV.md index 4a39f0d..2d87c91 100644 --- a/docs/DESIGN_PREVISION_PV.md +++ b/docs/DESIGN_PREVISION_PV.md @@ -755,51 +755,72 @@ recevant une valeur hors catalogue ne saurait pas la rendre.)* --- -## 18. Migration vers `+etm63` — désigner d'office quand il n'y a rien à choisir +## 18. Aucune adoption d'office — le motif rend le silence acceptable -`+etm62` lit **sans désignation** ; `+etm63` en exige une. Une installation qui avait commencé à -remplir sa table s'arrêterait donc **silencieusement**, par construction. La règle proposée : +> **Décision du 2026-09-09 : rien n'est jamais désigné automatiquement.** La proposition +> précédente — adopter le site unique — est **écartée**, et la raison porte plus loin qu'elle. -| candidats | conduite | +### 18.1 Pourquoi « il n'y a rien à choisir » ne suffit pas + +Adopter le site unique paraissait sans risque : avec un seul candidat, désigner n'est pas +choisir. **Mais la désignation se PERSISTE.** Le jour où un second site apparaît, elle devient un +**choix que personne n'a fait et qui a l'air fait** — et plus rien ne distinguera « l'utilisateur +a préféré celui-ci » de « le moteur a pris le seul qu'il voyait en 2026 ». + +**Le précédent du rootmeter va dans ce sens** : `Energy.SetRootMeter` exige une désignation +explicite, et une adoption d'office sur la seule présence d'une interface a déjà coûté (§14). + +**Et la distinction avec l'auto-provisionnement des bornes (`+etm23`) tient** : celui-là +**enregistre ce que le moteur pilote déjà**, il ne choisit rien parmi plusieurs. Ici, il y aurait +choix — fût-il unique aujourd'hui. + +### 18.2 `MISSING_FORECAST_SITE` — ce qui rend le silence acceptable + +La lecture s'arrête, **mais elle le dit**. L'écran peut donc nommer le geste qui manque au lieu +d'afficher un vide. + +**Le motif dit QUOI FAIRE, pas seulement ce qui manque** — rendu français : + +> *« Aucun site de prévision désigné — choisissez-en un dans la configuration »* + +« Il en manque une » laisserait l'utilisateur devant un constat ; la phrase lui donne la marche à +suivre. Même exigence que les trois causes d'`ECO_FLOOR_MISSED` : **trois causes, trois gestes.** +Au contrat c'est un **code** (`fault: "MISSING_FORECAST_SITE"`), jamais une phrase — le client la +fabrique dans sa langue. + +### 18.3 Il ne se publie QUE si un site existe + +| état | conduite | |---|---| -| **0** | rien, **et rien au journal** — c'est le cas normal d'une installation sans prévision | -| **1** | **désigné d'office**, persisté, **une** ligne au journal | -| **≥ 2** | rien de désigné, **une** ligne au journal, et les candidats publiés au contrat | +| aucun site candidat | **silence** — rien à désigner, et l'annoncer serait un avertissement qu'on ne peut pas satisfaire | +| site présent, aucun désigné | **le motif**, journalisé **une fois**, et publié dans `GetForecastSite` | +| site désigné | rien | -### 18.1 La distinction qui la fonde +Un avertissement permanent qu'aucun geste ne peut lever est du **bruit**, et le bruit finit par +couvrir le signal. C'est la même règle que le silence sur les bornes hors waterfall. -> **Le silence est la bonne réponse à « cette installation n'a pas de prévision ». Il n'est PAS -> la bonne réponse à « cette installation en avait une et nous venons de la lui retirer ».** +### 18.4 Pas de parc, donc pas de migration — mais une DATE -La première est une configuration légitime ; la seconde est une **régression que nous -introduisons**. Les confondre ferait passer notre propre rupture pour un cas normal — exactement -ce que la règle du silence existe pour ne pas couvrir. +`.75` est le seul déploiement, et sa table sera reconstruite en une journée. **Aucun mécanisme de +migration n'est écrit**, et c'est délibéré : un mécanisme sans parc à migrer serait du code +jamais exercé, donc du code faux qu'on découvrirait le jour où il servirait. -### 18.2 Pourquoi le défaut d'office ne devine rien +> **Date limite : si un parc existe avant le 2026-12-31**, la question se rouvre — et la réponse +> ne sera pas l'adoption d'office pour autant. Ce qui manquera alors est un **chemin de +> configuration à la mise en service**, pas un défaut deviné au démarrage. -**Avec un seul candidat, désigner n'est pas CHOISIR** — il n'y a rien à choisir. C'est la seule -condition sous laquelle un défaut d'office ne suppose rien à la place de l'utilisateur. +Écrit maintenant plutôt que repris dans six mois sans contexte. -Dès **deux** candidats, il y a un vrai choix, et le faire serait deviner. Le moteur ne désigne -rien, le dit **une fois**, et `GetForecastSite` publie `candidateSiteThingIds` — ce qui permet à -un écran de poser la question **sans second appel ni heuristique côté client**. +### 18.5 « Jamais configuré » n'est pas « explicitement mis à rien » -### 18.3 « Jamais configuré » n'est pas « explicitement mis à rien » +`forecastSiteChosen` est posé par **tout** appel à `SetForecastSite`, identifiant vide compris. +Il ne sert plus à armer une adoption — il n'y en a plus — mais il reste utile : il distingue une +installation **jamais configurée** d'une installation dont l'utilisateur a **délibérément coupé** +la prévision. Les deux se taisent de la même façon ; elles ne veulent pas dire la même chose, et +un futur mécanisme qui les confondrait rallumerait une fonction que quelqu'un avait éteinte. -La désignation d'office ne tourne que si **aucun choix n'a jamais été exprimé**. Un second -réglage, `forecastSiteChosen`, est posé par **tout** appel à `SetForecastSite` — y compris avec -un identifiant vide. +### 18.6 Un détail qui a failli être faux -Sans lui, un utilisateur qui aurait délibérément coupé la prévision la verrait **se rallumer au -prochain démarrage**, et sa décision serait écrasée en silence. C'est la même distinction -absence/valeur que partout ici, appliquée à un réglage plutôt qu'à une mesure. - -### 18.4 Deux détails d'implémentation qui ont failli être faux - -**Un site candidat se reconnaît à une propriété OBSERVABLE** — un enfant qui publie -`forecastCurveTransport` — jamais à un nom de ThingClass. Le producteur peut renommer la sienne -ou en ajouter une seconde sans que cela nous concerne. - -**Et la migration tourne au PREMIER CYCLE, pas au constructeur.** Au constructeur, le +Le signalement tourne **au premier cycle, pas au constructeur**. Au constructeur, le `ThingManager` n'a pas encore chargé les Things : « aucun candidat » y serait toujours vrai, et -le défaut d'office ne se déclencherait **jamais** — un mécanisme inerte qui aurait l'air posé. +l'avertissement **ne se déclencherait jamais** — un mécanisme inerte qui aurait l'air posé. diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index 377ba00..82f1f79 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -429,10 +429,10 @@ void EnergyArbitrator::update(const QDateTime ¤tDateTime) // La prévision se lit AVANT le contexte, et elle n'y entre pas : ce lot est un AFFICHAGE. // Elle ne pondère aucune échéance et ne déplace aucun plan — le jour où l'étalement éco la // lira, ce sera un lot à part, avec sa propre décision. - // La désignation d'office attend le PREMIER CYCLE, pas le constructeur : au constructeur, - // le ThingManager n'a pas encore chargé les Things, et « aucun candidat » y serait toujours - // vrai — un défaut d'office qui ne se déclenche jamais. - designerSiteAuPremierDemarrage(); + // Le signalement attend le PREMIER CYCLE, pas le constructeur : au constructeur, le + // ThingManager n'a pas encore chargé les Things, et « aucun candidat » y serait toujours + // vrai — un avertissement qui ne se déclencherait jamais. + signalerSiteManquant(); lireCourbePrevision(); SurplusContext ctx = buildContext(currentDateTime); @@ -2274,39 +2274,30 @@ QStringList EnergyArbitrator::forecastSiteCandidates() const return sites; } -void EnergyArbitrator::designerSiteAuPremierDemarrage() +void EnergyArbitrator::signalerSiteManquant() { - // MIGRATION — ne tourne QUE si aucun choix n'a jamais été exprimé. + // AUCUNE ADOPTION D'OFFICE — décision de Patrick, 2026-09-09, et la raison porte loin. // - // Le silence est la bonne réponse à « cette installation n'a pas de prévision ». Il n'est - // PAS la bonne réponse à « cette installation en avait une et nous venons de la lui - // retirer » : la première est une configuration légitime, la seconde est une régression que - // nous introduisons. Confondre les deux ferait passer notre propre rupture pour un cas - // normal — exactement ce que la règle du silence existe pour ne pas couvrir. - if (m_forecastSiteChoisi) - return; // un choix existe, fût-il « aucun » — on n'y touche pas - const QStringList candidats = forecastSiteCandidates(); - - // UN SEUL CANDIDAT : désigner n'est pas CHOISIR — il n'y a rien à choisir. C'est la seule - // condition sous laquelle un défaut d'office ne devine rien. - if (candidats.count() == 1) { - m_forecastSiteId = candidats.first(); - m_forecastSiteChoisi = true; - EnergySettings reglages; - reglages.setValue(QStringLiteral("forecastSiteId"), m_forecastSiteId); - reglages.setValue(QStringLiteral("forecastSiteChosen"), true); - qCInfo(dcNymeaEnergy()) << "[Prévision] site unique désigné d'office :" << m_forecastSiteId - << "— une seule candidature, donc aucun choix fait à votre place"; + // Adopter le site unique paraîtrait sans risque : il n'y aurait « rien à choisir ». Mais la + // désignation se PERSISTE, et le jour où un second site apparaît, elle devient un CHOIX QUE + // PERSONNE N'A FAIT ET QUI A L'AIR FAIT. Rien ne distinguera plus « l'utilisateur a préféré + // celui-ci » de « le moteur a pris le seul qu'il voyait en 2026 ». + // + // La distinction avec l'auto-provisionnement des bornes (+etm23) tient : celui-là ENREGISTRE + // ce que le moteur pilote DÉJÀ, il ne choisit rien parmi plusieurs. Et le précédent du + // rootmeter va dans ce sens — `SetRootMeter` exige une désignation explicite. + if (!m_forecastSiteId.isEmpty() || m_forecastSiteSignale) return; - } - // PLUSIEURS CANDIDATS : là, il y a un vrai choix, et le faire serait deviner. On ne désigne - // rien — mais on le DIT une fois, et GetForecastSite publie les candidats pour que l'écran - // puisse poser la question. Un silence ici cacherait une fonction qui vient de s'arrêter. - if (candidats.count() > 1) - qCInfo(dcNymeaEnergy()) << "[Prévision]" << candidats.count() - << "sites candidats et aucun désigné — la prévision reste " - "inactive jusqu'à un SetForecastSite (voir GetForecastSite)"; - // AUCUN CANDIDAT : rien à dire. C'est le cas normal d'une installation sans prévision. + // SILENCE S'IL N'Y A RIEN À DÉSIGNER. Une installation sans plugin météo appairé n'a rien à + // choisir : lui annoncer qu'il manque une désignation serait un avertissement qu'on ne peut + // pas satisfaire, donc du bruit permanent. + if (forecastSiteCandidates().isEmpty()) + return; + m_forecastSiteSignale = true; + qCInfo(dcNymeaEnergy()).noquote() + << QStringLiteral("[Prévision] %1 — la lecture reste arrêtée (motif %2)") + .arg(renderFr({ DecisionCode::MissingForecastSite, {} })) + .arg(QLatin1String(DecisionCode::MissingForecastSite)); } bool EnergyArbitrator::setForecastSiteId(const QString &siteId) @@ -2320,6 +2311,7 @@ bool EnergyArbitrator::setForecastSiteId(const QString &siteId) // LE CHOIX EST MARQUÉ MÊME QUAND IL EST « AUCUN ». Sans cela, la désignation d'office se // réarmerait au prochain démarrage et écraserait une décision explicite, en silence. reglages.setValue(QStringLiteral("forecastSiteChosen"), true); + m_forecastSiteSignale = false; // un nouveau vide se resignalera une fois qCInfo(dcNymeaEnergy()) << "[Prévision] site retenu :" << (siteId.isEmpty() ? QStringLiteral("(aucun)") : siteId); return true; diff --git a/energyplugin/etm/energyarbitrator.h b/energyplugin/etm/energyarbitrator.h index 9b28d99..31e9cde 100644 --- a/energyplugin/etm/energyarbitrator.h +++ b/energyplugin/etm/energyarbitrator.h @@ -900,7 +900,10 @@ private: * l'utilisateur, en silence. */ bool m_forecastSiteChoisi = false; - void designerSiteAuPremierDemarrage(); + //! Le manque a-t-il déjà été signalé ? Un avertissement par cycle noierait le journal. + bool m_forecastSiteSignale = false; + //! \brief Signale UNE FOIS qu'aucun site n'est désigné alors qu'il en existe. + void signalerSiteManquant(); QTimer *m_forecastPurge = nullptr; //! loadId → config AYANT SERVI à construire l'adaptateur. Permet le rebuild incrémental //! (ECS-412) : on ne reconstruit que si le matériel a changé. diff --git a/energyplugin/etm/types/decisionreason.cpp b/energyplugin/etm/types/decisionreason.cpp index 9d5495d..42dbb85 100644 --- a/energyplugin/etm/types/decisionreason.cpp +++ b/energyplugin/etm/types/decisionreason.cpp @@ -212,6 +212,13 @@ QString renderFr(const DecisionReason &reason, const QString &label) return QStringLiteral("Dérogation terminée sur %1 — %2").arg(label).arg(pourquoi); } + if (c == QLatin1String(DecisionCode::MissingForecastSite)) + // LE GESTE, pas seulement le manque. « Il en manque une » laisse l'utilisateur devant un + // constat ; « choisissez-en un dans la configuration » lui donne la marche à suivre. + // Même exigence que les trois causes d'ECO_FLOOR_MISSED : trois causes, trois gestes. + return QStringLiteral("Aucun site de prévision désigné — choisissez-en un dans la " + "configuration"); + // --- Sécurité et cycle de vie ------------------------------------------ if (c == QLatin1String(DecisionCode::DegradedL2)) return QStringLiteral("Compteur muet depuis >90 s — consigne de repli (L2 watchdog)"); diff --git a/energyplugin/etm/types/decisionreason.h b/energyplugin/etm/types/decisionreason.h index a4ff65d..d44d895 100644 --- a/energyplugin/etm/types/decisionreason.h +++ b/energyplugin/etm/types/decisionreason.h @@ -280,6 +280,20 @@ constexpr const char *DerogationActive = "DEROGATION_ACTIVE"; */ constexpr const char *DerogationEnded = "DEROGATION_ENDED"; +/*! + * \brief Des plans de prévision existent, mais AUCUN SITE n'est désigné — la lecture est arrêtée. + * params : aucun. + * + * \note **Publié seulement si un site existe.** Une installation sans plugin météo appairé n'a + * rien à désigner : lui annoncer qu'il manque une désignation serait un avertissement qu'on ne + * peut pas satisfaire, donc du bruit permanent. Absence de site : silence. Site présent, aucun + * désigné : ce motif. + * + * \note C'est lui qui rend le silence ACCEPTABLE : la lecture s'arrête, mais elle le dit — donc + * l'écran peut nommer le geste qui manque au lieu d'afficher un vide. + */ +constexpr const char *MissingForecastSite = "MISSING_FORECAST_SITE"; + // --- SG-Ready (PAC) --------------------------------------------------------- //! params: \c state — le verrou minStateHold maintient l'état (court-cycling compresseur). constexpr const char *LockMinStateHold = "LOCK_MIN_STATE_HOLD"; diff --git a/energyplugin/nymeaenergyjsonhandler.cpp b/energyplugin/nymeaenergyjsonhandler.cpp index c2586c6..c81805b 100644 --- a/energyplugin/nymeaenergyjsonhandler.cpp +++ b/energyplugin/nymeaenergyjsonhandler.cpp @@ -26,6 +26,7 @@ #ifdef ETM_ARBITRATOR #include "etm/energyarbitrator.h" #include "etm/forecast/forecaststore.h" +#include "etm/types/decisionreason.h" #endif #include "types/charginginfo.h" #include "smartchargingmanager.h" @@ -346,9 +347,19 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana "publishing a curve; it is what a configuration screen offers. When exactly " "one candidate exists and nothing has ever been chosen, the engine designates " "it by itself - designating is not CHOOSING when there is nothing to choose. " - "With two or more, it designates nothing and the choice is yours."; + "NOTHING is ever designated automatically: the choice is persisted, and the " + "day a second site appears it would look deliberate when nobody made it. " + "'fault' carries MISSING_FORECAST_SITE when planes publish but no site is " + "designated - it is ABSENT when no site exists at all, since announcing a " + "missing designation to an installation that has nothing to designate would " + "be an unsatisfiable warning."; returns.insert("o:forecastSiteThingId", enumValueName(Uuid)); returns.insert("candidateSiteThingIds", QVariantList() << enumValueName(Uuid)); + // Le MOTIF, présent seulement quand il y a un geste à faire : des plans existent et aucun + // site n'est désigné. Un CODE, jamais une phrase — le client la fabrique dans sa langue. + // ABSENT quand aucun site n'existe : annoncer une désignation manquante à une installation + // sans plugin météo serait un avertissement qu'on ne peut pas satisfaire. + returns.insert("o:fault", enumValueName(String)); // MISSING_FORECAST_SITE registerMethod("GetForecastSite", description, params, returns, Types::PermissionScopeControlThings); params.clear(); returns.clear(); @@ -1130,8 +1141,15 @@ JsonReply *NymeaEnergyJsonHandler::GetForecastSite(const QVariantMap ¶ms) ret.insert(QStringLiteral("forecastSiteThingId"), id); // LES CANDIDATS, toujours rendus — même vides. C'est ce qui permet à un écran de poser // la question quand il y a un choix, sans un second appel ni une heuristique côté client. + const QStringList candidats = arb->forecastSiteCandidates(); ret.insert(QStringLiteral("candidateSiteThingIds"), - QVariant::fromValue(arb->forecastSiteCandidates()).toList()); + QVariant::fromValue(candidats).toList()); + // Le motif ne sort que s'il y a un GESTE POSSIBLE : des plans publient, et personne n'a + // désigné. Sans candidat, l'absence de désignation n'est pas un manque — c'est une + // installation qui n'a pas de prévision, et c'est normal. + if (id.isEmpty() && !candidats.isEmpty()) + ret.insert(QStringLiteral("fault"), + QLatin1String(DecisionCode::MissingForecastSite)); } #endif return createReply(ret);