refactor(plan): le plan rule-based porte strategy « rules », comme activeStrategy (brief 4 T6)

Décision de Patrick du 2026-10-17 : un seul vocabulaire. Plan::strategy du rule-based,
GetPlan.strategy et la doc passent de « rule-based » à « rules » (INTERFACE.md, protocole §6).
Les libellés de journal (« repli rule-based ») ne sont pas des valeurs et restent.

Référence decisions régénérée. Preuve AVANT régénération, moteur de e592618 + ce seul
renommage, contre l'ancienne référence : comparateur --strategie (nouvelle option, qui ne tolère
que plan.strategy rule-based → rules) IDENTIQUE HORS STRATÉGIE — 23 792 lignes, 23 728
identiques à l'octet, 64 qui ne diffèrent que par .plan.strategy (décompte indépendant des
chemins JSON : 64 × .plan.strategy "rule-based" → "rules", rien d'autre). 0 rejeu. Le même
build, relu contre la nouvelle référence : IDENTIQUE ; 6 suites vertes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-17 11:46:36 +02:00
parent e5926189e9
commit 1f6c7ec27a
9 changed files with 33 additions and 20 deletions

View File

@ -431,7 +431,9 @@ Règles d'exécution côté plugin :
protection de surcharge (L4 et son extension au waterfall). Une action écrêtée est protection de surcharge (L4 et son extension au waterfall). Une action écrêtée est
exécutée au plus proche possible et loggée (`clamped`). exécutée au plus proche possible et loggée (`clamped`).
- `reason` absent ou vide → action **rejetée**, log d'avertissement. - `reason` absent ou vide → action **rejetée**, log d'avertissement.
- Un rule-based interne répond au même contrat avec un plan à un seul créneau. - Un rule-based interne répond au même contrat avec un plan à un seul créneau. Côté moteur
(plan publié vers l'app, traces), ce plan porte `strategy: "rules"`, la même valeur que
`activeStrategy` ; le `strategy` d'un optimiseur reste une chaîne libre, informative.
- **Le contournement de verrou (`force`) n'existe pas sur le fil.** Il est réservé au repli - **Le contournement de verrou (`force`) n'existe pas sur le fil.** Il est réservé au repli
de sécurité interne (L2) ; un champ `force` reçu du socket est ignoré. de sécurité interne (L2) ; un champ `force` reçu du socket est ignoré.
- **`validUntil`** : au-delà, le plan est **expiré** → repli rule-based jusqu'au prochain - **`validUntil`** : au-delà, le plan est **expiré** → repli rule-based jusqu'au prochain

View File

@ -23,8 +23,9 @@
*/ */
namespace PublishedPlan { namespace PublishedPlan {
//! Valeur de \c strategy d'un plan du rule-based publié (celle de \c Plan::strategy). //! Valeur de \c strategy d'un plan du rule-based publié (celle de \c Plan::strategy, et la même
constexpr const char *StrategieRegles = "rule-based"; //! que \c activeStrategy : \c OptimizerStatus::StrategieRegles).
constexpr const char *StrategieRegles = "rules";
/*! /*!
* \brief Le plan du cycle, pour l'app. * \brief Le plan du cycle, pour l'app.

View File

@ -559,7 +559,7 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
Plan plan; Plan plan;
plan.planId = QUuid::createUuid().toString(QUuid::WithoutBraces); plan.planId = QUuid::createUuid().toString(QUuid::WithoutBraces);
plan.strategy = QStringLiteral("rule-based"); plan.strategy = QStringLiteral("rules");
plan.timeSlots.append(slot); plan.timeSlots.append(slot);
compta.remainingW = remainingSurplusW; compta.remainingW = remainingSurplusW;

View File

@ -156,7 +156,7 @@ struct PlanDraw {
*/ */
struct Plan { struct Plan {
QString planId; //!< Identifiant unique (UUID string). QString planId; //!< Identifiant unique (UUID string).
//! \c "rule-based" (plan du rule-based, y compris le repli d'une stratégie externe) ou //! \c "rules" (plan du rule-based, y compris le repli d'une stratégie externe) ou
//! \c "optimizer" (créneau courant d'un plan d'optimiseur, \c OptimizerPlan::substituer()). //! \c "optimizer" (créneau courant d'un plan d'optimiseur, \c OptimizerPlan::substituer()).
QString strategy; QString strategy;
//! Créneaux du plan. Nommé \c timeSlots (pas \c slots, mot-clé Qt). //! Créneaux du plan. Nommé \c timeSlots (pas \c slots, mot-clé Qt).

View File

@ -609,13 +609,13 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
description = "The plan IN FORCE at the last planned cycle. Under an optimizer: the current " description = "The plan IN FORCE at the last planned cycle. Under an optimizer: the current "
"slot with the power APPLIED by the adapters, then the following slots with the " "slot with the power APPLIED by the adapters, then the following slots with the "
"power REQUESTED (informative). Under the rule-based: one slot, strategy " "power REQUESTED (informative). Under the rule-based: one slot, strategy "
"rule-based, and fallbackCode when an optimizer is configured but did not command " "rules, and fallbackCode when an optimizer is configured but did not command "
"this cycle. Reasons are codes and params only, never text. inForce is false, " "this cycle. Reasons are codes and params only, never text. inForce is false, "
"and nothing else is returned, before the first cycle and in degraded mode L2 (no " "and nothing else is returned, before the first cycle and in degraded mode L2 (no "
"plan in force). No notification: read it again on LoadTelemetryChanged or " "plan in force). No notification: read it again on LoadTelemetryChanged or "
"OptimizerStatusChanged."; "OptimizerStatusChanged.";
returns.insert("inForce", enumValueName(Bool)); returns.insert("inForce", enumValueName(Bool));
returns.insert("o:strategy", enumValueName(String)); // optimizer | rule-based returns.insert("o:strategy", enumValueName(String)); // optimizer | rules
returns.insert("o:computedAt", enumValueName(String)); returns.insert("o:computedAt", enumValueName(String));
returns.insert("o:validUntil", enumValueName(String)); returns.insert("o:validUntil", enumValueName(String));
returns.insert("o:slots", QVariantList() << planSlot); returns.insert("o:slots", QVariantList() << planSlot);

View File

@ -2,7 +2,7 @@
# Comparateur « IDENTIQUE HORS MOTIFS » (correctifs moteur a et b, etm70). # Comparateur « IDENTIQUE HORS MOTIFS » (correctifs moteur a et b, etm70).
# #
# etm-powersync-compare-hors-motifs.py <attendu.ndjson[.xz]> <obtenu.ndjson[.xz]> [--cas <sortie.md>] # etm-powersync-compare-hors-motifs.py <attendu.ndjson[.xz]> <obtenu.ndjson[.xz]> [--cas <sortie.md>]
# [--achat] [--lignes <sortie.txt>] # [--achat] [--strategie] [--lignes <sortie.txt>]
# #
# Un lot qui ne change QUE des motifs doit laisser tout le reste de la référence `decisions` # Un lot qui ne change QUE des motifs doit laisser tout le reste de la référence `decisions`
# intact à l'octet près. Ce script retire de chaque ligne, et SEULEMENT d'elle : # intact à l'octet près. Ce script retire de chaque ligne, et SEULEMENT d'elle :
@ -13,6 +13,8 @@
# Avec --achat (correctif c, etm71), il retire EN PLUS `progress.purchasedWh` de chaque niveau : # Avec --achat (correctif c, etm71), il retire EN PLUS `progress.purchasedWh` de chaque niveau :
# l'acheté se compte désormais sur l'énergie mesurée, et il est absent sans mesure. Un lot qui # l'acheté se compte désormais sur l'énergie mesurée, et il est absent sans mesure. Un lot qui
# change cette VALEUR PUBLIÉE sans toucher aux consignes doit passer ce mode-là. # change cette VALEUR PUBLIÉE sans toucher aux consignes doit passer ce mode-là.
# Avec --strategie (brief 4 T6, décision du 2026-10-17), il ramène EN PLUS `plan.strategy`
# `rule-based` à `rules` d'une ligne de plan direct : le renommage du plan rule-based, et lui seul.
# Tout le reste — consignes, powerW, estimatedPowerW, états, relais, courants, budget, draw, # Tout le reste — consignes, powerW, estimatedPowerW, états, relais, courants, budget, draw,
# counts, niveaux et leurs motifs, avancement — doit être IDENTIQUE. Code 0 si c'est le cas, # counts, niveaux et leurs motifs, avancement — doit être IDENTIQUE. Code 0 si c'est le cas,
# 1 sinon (et le premier écart est montré). La liste des motifs qui changent est imprimée, ou # 1 sinon (et le premier écart est montré). La liste des motifs qui changent est imprimée, ou
@ -48,11 +50,13 @@ def sans_achat(niveau, charge, achats):
achats.append((charge, prog.pop('purchasedWh', ABSENT))) achats.append((charge, prog.pop('purchasedWh', ABSENT)))
def normaliser(ligne, achat=False): def normaliser(ligne, achat=False, strategie=False):
"""Rend (ligne sans motifs, [(charge, motif retiré)], [(charge, purchasedWh retiré)]).""" """Rend (ligne sans motifs, [(charge, motif retiré)], [(charge, purchasedWh retiré)])."""
d = json.loads(ligne) d = json.loads(ligne)
retires = [] retires = []
achats = [] achats = []
if strategie and d.get('src') == 'plan' and (d.get('plan') or {}).get('strategy') == 'rule-based':
d['plan']['strategy'] = 'rules'
if d.get('src') == 'cycle': if d.get('src') == 'cycle':
for ch in (d.get('telemetry') or {}).get('loads', []) or []: for ch in (d.get('telemetry') or {}).get('loads', []) or []:
retires.append((ch.get('loadId'), ch.pop('decision', None))) retires.append((ch.get('loadId'), ch.pop('decision', None)))
@ -95,6 +99,7 @@ def main():
sortie_cas = sys.argv[sys.argv.index('--cas') + 1] if '--cas' in sys.argv else None sortie_cas = sys.argv[sys.argv.index('--cas') + 1] if '--cas' in sys.argv else None
sortie_lignes = sys.argv[sys.argv.index('--lignes') + 1] if '--lignes' in sys.argv else None sortie_lignes = sys.argv[sys.argv.index('--lignes') + 1] if '--lignes' in sys.argv else None
achat = '--achat' in sys.argv achat = '--achat' in sys.argv
strategie = '--strategie' in sys.argv
lignes_changees = [] lignes_changees = []
if len(attendu) != len(obtenu): if len(attendu) != len(obtenu):
@ -107,11 +112,12 @@ def main():
if a == b: if a == b:
brutes_identiques += 1 brutes_identiques += 1
continue continue
na, ra, aa = normaliser(a, achat) na, ra, aa = normaliser(a, achat, strategie)
nb, rb, ab = normaliser(b, achat) nb, rb, ab = normaliser(b, achat, strategie)
lignes_changees.append((i, a, b)) lignes_changees.append((i, a, b))
if na != nb: if na != nb:
print(f'ÉCART HORS MOTIFS{" ET HORS ACHAT" if achat else ""} à la ligne {i} :') print(f'ÉCART HORS MOTIFS{" ET HORS ACHAT" if achat else ""}'
f'{" ET HORS STRATÉGIE" if strategie else ""} à la ligne {i} :')
print('- ' + a[:3000]) print('- ' + a[:3000])
print('+ ' + b[:3000]) print('+ ' + b[:3000])
return 1 return 1
@ -123,14 +129,18 @@ def main():
for (charge, va), (_, vb) in zip(aa, ab): for (charge, va), (_, vb) in zip(aa, ab):
if va != vb: if va != vb:
cas[(fn, charge, f'purchasedWh {achat_court(va)}', f'purchasedWh {achat_court(vb)}')] += 1 cas[(fn, charge, f'purchasedWh {achat_court(va)}', f'purchasedWh {achat_court(vb)}')] += 1
sa = (json.loads(a).get('plan') or {}).get('strategy')
sb = (json.loads(b).get('plan') or {}).get('strategy')
if sa != sb:
cas[(fn, '(plan)', f'strategy {sa}', f'strategy {sb}')] += 1
# Le manque d'un niveau change sans que le motif de tête change : à dire aussi. # Le manque d'un niveau change sans que le motif de tête change : à dire aussi.
if ra == rb and aa == ab: elif ra == rb and aa == ab:
cas[(fn, '(niveau)', 'missingWh', 'missingWh corrigé')] += 1 cas[(fn, '(niveau)', 'missingWh', 'missingWh corrigé')] += 1
hors = 'HORS MOTIFS ET HORS ACHAT' if achat else 'HORS MOTIFS' hors = 'HORS MOTIFS' + (' ET HORS ACHAT' if achat else '') + (' ET HORS STRATÉGIE' if strategie else '')
print(f'IDENTIQUE {hors} : {len(attendu)} lignes, dont {brutes_identiques} identiques ' print(f'IDENTIQUE {hors} : {len(attendu)} lignes, dont {brutes_identiques} identiques '
f'à l\'octet et {len(attendu) - brutes_identiques} qui ne diffèrent que par ' f'à l\'octet et {len(attendu) - brutes_identiques} qui ne diffèrent que par '
f'{"les motifs ou purchasedWh" if achat else "les motifs"}') f'{"les motifs" + (", purchasedWh" if achat else "") + (" ou plan.strategy" if strategie else "")}')
if sortie_lignes: if sortie_lignes:
with open(sortie_lignes, 'w', encoding='utf-8') as f: with open(sortie_lignes, 'w', encoding='utf-8') as f:
for i, a, b in lignes_changees: for i, a, b in lignes_changees:

View File

@ -323,7 +323,7 @@ void TestLoadModel::testOptimizerUnavailableLoadKeepsTheShadow()
// Plan ombre : une action par charge, dont LOAD_UNAVAILABLE pour la charge en défaut. // Plan ombre : une action par charge, dont LOAD_UNAVAILABLE pour la charge en défaut.
Plan ombre; Plan ombre;
ombre.strategy = "rule-based"; ombre.strategy = "rules";
ombre.planId = "ombre"; ombre.planId = "ombre";
ombre.budget.surplusW = 777; // ne doit JAMAIS être publié sous l'optimiseur ombre.budget.surplusW = 777; // ne doit JAMAIS être publié sous l'optimiseur
ombre.draw.committedW = 333; ombre.draw.committedW = 333;
@ -374,7 +374,7 @@ void TestLoadModel::testOptimizerFinaliseUsesTheAppliedAction()
avecAction(1, [](QVariantMap &a) { a["state"] = 4; }), ctx, {}, false); avecAction(1, [](QVariantMap &a) { a["state"] = 4; }), ctx, {}, false);
QVERIFY(v.issue == OptimizerPlan::Issue::Valide); QVERIFY(v.issue == OptimizerPlan::Issue::Valide);
Plan ombre; Plan ombre;
ombre.strategy = "rule-based"; ombre.strategy = "rules";
Slot s; Slot s;
s.from = kMaintenant; s.from = kMaintenant;
s.to = kMaintenant.addSecs(60); s.to = kMaintenant.addSecs(60);

View File

@ -912,7 +912,7 @@ void TestSocketScheduler::testStatusWithoutEndpoint()
// refusé, il n'y a personne d'autre — c'est ce qui distingue « pas d'optimiseur ». // refusé, il n'y a personne d'autre — c'est ce qui distingue « pas d'optimiseur ».
const QVariantMap p = rpc("GetPlan"); const QVariantMap p = rpc("GetPlan");
QCOMPARE(p.value("inForce").toBool(), true); QCOMPARE(p.value("inForce").toBool(), true);
QCOMPARE(p.value("strategy").toString(), QString("rule-based")); QCOMPARE(p.value("strategy").toString(), QString("rules"));
QCOMPARE(p.value("computedAt").toString(), t0.addSecs(60).toUTC().toString(Qt::ISODate)); QCOMPARE(p.value("computedAt").toString(), t0.addSecs(60).toUTC().toString(Qt::ISODate));
QVERIFY(p.contains("validUntil")); QVERIFY(p.contains("validUntil"));
QVERIFY(!p.contains("fallbackCode") && !p.contains("diagnostics")); QVERIFY(!p.contains("fallbackCode") && !p.contains("diagnostics"));
@ -938,7 +938,7 @@ void TestSocketScheduler::testStatusWithoutEndpoint()
QCOMPARE(triplet(), QString("-|mort|none")); QCOMPARE(triplet(), QString("-|mort|none"));
cycle(t0.addSecs(300), -3000); cycle(t0.addSecs(300), -3000);
QCOMPARE(triplets(spy), (QStringList{"-|mort|none", "-|mort|rules"})); QCOMPARE(triplets(spy), (QStringList{"-|mort|none", "-|mort|rules"}));
QCOMPARE(rpc("GetPlan").value("strategy").toString(), QString("rule-based")); QCOMPARE(rpc("GetPlan").value("strategy").toString(), QString("rules"));
// La notification JSON-RPC porte la charge utile du Get, une fois par transition. // La notification JSON-RPC porte la charge utile du Get, une fois par transition.
const QVariantList notifs = checkNotifications(paquets, "NymeaEnergy.OptimizerStatusChanged"); const QVariantList notifs = checkNotifications(paquets, "NymeaEnergy.OptimizerStatusChanged");
QCOMPARE(notifs.count(), 2); QCOMPARE(notifs.count(), 2);
@ -1293,7 +1293,7 @@ void TestSocketScheduler::testGetPlanPublishesThePlanInForce()
// Cycle 1 : un optimiseur est configuré mais n'a pas commandé — « pas de plan ce cycle ». // Cycle 1 : un optimiseur est configuré mais n'a pas commandé — « pas de plan ce cycle ».
cycle(t0, -3000); cycle(t0, -3000);
QVariantMap p = rpc("GetPlan"); QVariantMap p = rpc("GetPlan");
QCOMPARE(p.value("strategy").toString(), QString("rule-based")); QCOMPARE(p.value("strategy").toString(), QString("rules"));
QCOMPARE(p.value("fallbackCode").toString(), QString("no-plan")); QCOMPARE(p.value("fallbackCode").toString(), QString("no-plan"));
QCOMPARE(p.value("slots").toList().count(), 1); QCOMPARE(p.value("slots").toList().count(), 1);