feat(prévision PV): migration — 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 silencieusement. Règle proposée et
implémentée :

  0 candidat   rien, et RIEN au journal — cas normal d'une installation sans
               prévision
  1 candidat   DÉSIGNÉ D'OFFICE, persisté, une ligne au journal
  >= 2         rien de désigné, une ligne au journal, candidats publiés au
               contrat

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 une régression que NOUS introduisons —
les confondre ferait passer notre propre rupture pour un cas normal, ce que la
règle du silence existe précisément pour ne pas couvrir.

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. Dès deux, 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 pour qu'un écran pose la question
sans second appel ni heuristique côté client.

« JAMAIS CONFIGURÉ » N'EST PAS « EXPLICITEMENT MIS À RIEN ». Un second réglage,
forecastSiteChosen, est posé par TOUT appel à SetForecastSite, identifiant vide
compris. Sans lui, un utilisateur ayant délibérément coupé la prévision la
verrait se rallumer au prochain démarrage, sa décision écrasée en silence.

Deux détails qui ont failli être faux : un site candidat se reconnaît à une
propriété OBSERVABLE (un enfant publie forecastCurveTransport), jamais à un nom
de ThingClass ; et la migration tourne au PREMIER CYCLE, pas au constructeur —
là, le ThingManager n'a pas chargé les Things, « aucun candidat » serait
toujours vrai, et le défaut ne se déclencherait jamais. Un mécanisme inerte qui
aurait l'air posé.

loadmodel 32/32, charging 48/48, spotmarket 32/32, simulation 69/69,
ci-quality 4/4, build 0 erreur 0 warning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
This commit is contained in:
Patrick Schurig 2026-09-09 06:34:02 +02:00
parent 2564ba1f16
commit 17a222756b
4 changed files with 152 additions and 6 deletions

View File

@ -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é.

View File

@ -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 &currentDateTime)
// 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;

View File

@ -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<QString, QString> 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é.

View File

@ -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 &params)
// 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);