diff --git a/docs/DESIGN_PREVISION_PV.md b/docs/DESIGN_PREVISION_PV.md index bd4c15d..4a39f0d 100644 --- a/docs/DESIGN_PREVISION_PV.md +++ b/docs/DESIGN_PREVISION_PV.md @@ -751,3 +751,55 @@ lirait comme un identifiant qu'on n'a pas su rendre. identifiant inconnu rend donc `InvalidParameter`, et la description le dit. Inventer une valeur d'énumération ferait diverger le contrat de celui que publie `nymea-energy`, et un client 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 + +`+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 : + +| candidats | 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 | + +### 18.1 La distinction qui la fonde + +> **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**. Les confondre ferait passer notre propre rupture pour un cas normal — exactement +ce que la règle du silence existe pour ne pas couvrir. + +### 18.2 Pourquoi le défaut d'office ne devine rien + +**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. + +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.3 « Jamais configuré » n'est pas « explicitement mis à rien » + +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. + +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 +`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é. diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index 9e4a111..377ba00 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -78,7 +78,11 @@ EnergyArbitrator::EnergyArbitrator(EnergyManager *em, ThingManager *tm, // La purge tourne une fois par jour ; une table qui ne dit pas quand elle purge finit par // le décider toute seule un jour de disque plein. m_forecastStore = new ForecastStore(ForecastStore::cheminParDefaut(), this); - m_forecastSiteId = EnergySettings().value(QStringLiteral("forecastSiteId")).toString(); + { + EnergySettings reglages; + m_forecastSiteId = reglages.value(QStringLiteral("forecastSiteId")).toString(); + m_forecastSiteChoisi = reglages.contains(QStringLiteral("forecastSiteChosen")); + } if (m_forecastStore->isValid()) { m_forecastPurge = new QTimer(this); m_forecastPurge->setInterval(24 * 3600 * 1000); @@ -425,6 +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(); lireCourbePrevision(); SurplusContext ctx = buildContext(currentDateTime); @@ -2247,12 +2255,71 @@ void EnergyArbitrator::buildDegradedTelemetry(const QDateTime &now) m_telemetry = snap; } +QStringList EnergyArbitrator::forecastSiteCandidates() const +{ + QStringList sites; + if (!m_tm) + return sites; + // Un site CANDIDAT est un Thing dont au moins un enfant publie une courbe. C'est une + // propriété OBSERVABLE, pas un nom de classe : le producteur peut renommer la sienne ou en + // ajouter une seconde sans que cela nous concerne. + for (Thing *thing : m_tm->configuredThings()) { + if (!thing->thingClass().hasStateType("forecastCurveTransport")) + continue; + const QString parent = thing->parentId().toString(); + if (!parent.isEmpty() && !sites.contains(parent)) + sites.append(parent); + } + sites.sort(); // ordre STABLE : un instantané se compare + return sites; +} + +void EnergyArbitrator::designerSiteAuPremierDemarrage() +{ + // MIGRATION — ne tourne QUE si aucun choix n'a jamais été exprimé. + // + // 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"; + 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. +} + bool EnergyArbitrator::setForecastSiteId(const QString &siteId) { if (!siteId.isEmpty() && (!m_tm || !m_tm->findConfiguredThing(ThingId(siteId)))) return false; - m_forecastSiteId = siteId; - EnergySettings().setValue(QStringLiteral("forecastSiteId"), siteId); + m_forecastSiteId = siteId; + m_forecastSiteChoisi = true; + EnergySettings reglages; + reglages.setValue(QStringLiteral("forecastSiteId"), 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); 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 610bc0c..9b28d99 100644 --- a/energyplugin/etm/energyarbitrator.h +++ b/energyplugin/etm/energyarbitrator.h @@ -94,6 +94,15 @@ public: */ bool setForecastSiteId(const QString &siteId); + /*! + * \brief Les sites CANDIDATS — ceux dont au moins un enfant publie une courbe. + * \return Liste de ThingId, vide s'il n'y en a aucun. + * \note Détectés par une PROPRIÉTÉ OBSERVABLE (un enfant porte \c forecastCurveTransport), + * jamais par un nom de ThingClass : le producteur peut renommer sa classe sans que cela + * nous concerne. + */ + QStringList forecastSiteCandidates() const; + /*! * \brief Construit l'arbitre central. Mêmes dépendances que \c SmartChargingManager amont, * dont il hérite (justification : AGENTS.md, « 3b-iii »). @@ -883,6 +892,15 @@ private: QHash m_forecastRefusVu; //! Site désigné ; vide = aucune prévision retenue, et c'est un cas NORMAL, jamais un défaut. QString m_forecastSiteId; + /*! + * \brief Vrai dès qu'un choix a été EXPRIMÉ, fût-il « aucun site ». + * + * Distingue « jamais configuré » de « explicitement mis à rien » — sans quoi la + * désignation d'office se réarmerait à chaque démarrage et écraserait une décision de + * l'utilisateur, en silence. + */ + bool m_forecastSiteChoisi = false; + void designerSiteAuPremierDemarrage(); 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/nymeaenergyjsonhandler.cpp b/energyplugin/nymeaenergyjsonhandler.cpp index 63b114b..c2586c6 100644 --- a/energyplugin/nymeaenergyjsonhandler.cpp +++ b/energyplugin/nymeaenergyjsonhandler.cpp @@ -340,10 +340,15 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana // d'une charge : il n'y a qu'un ciel au-dessus de l'installation, et un réglage par charge // offrirait un choix dont aucune valeur différenciée n'a de sens. params.clear(); returns.clear(); - description = "Read which site's PV forecast is kept. Empty means none is designated - a " - "NORMAL state, not a fault: an installation without a forecast is a legitimate " - "configuration and nothing is logged about it."; + description = "Read which site's PV forecast is kept, and which sites COULD be. " + "'forecastSiteThingId' is omitted when none is designated - a NORMAL state, " + "not a fault. 'candidateSiteThingIds' lists every site with at least one plane " + "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."; returns.insert("o:forecastSiteThingId", enumValueName(Uuid)); + returns.insert("candidateSiteThingIds", QVariantList() << enumValueName(Uuid)); registerMethod("GetForecastSite", description, params, returns, Types::PermissionScopeControlThings); params.clear(); returns.clear(); @@ -1123,6 +1128,10 @@ JsonReply *NymeaEnergyJsonHandler::GetForecastSite(const QVariantMap ¶ms) // se lirait comme un identifiant qu'on n'a pas su rendre. if (!id.isEmpty()) 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. + ret.insert(QStringLiteral("candidateSiteThingIds"), + QVariant::fromValue(arb->forecastSiteCandidates()).toList()); } #endif return createReply(ret);