diff --git a/docs/OPTIMIZER_PROTOCOL.md b/docs/OPTIMIZER_PROTOCOL.md index b2ae842..be5088d 100644 --- a/docs/OPTIMIZER_PROTOCOL.md +++ b/docs/OPTIMIZER_PROTOCOL.md @@ -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 exécutée au plus proche possible et loggée (`clamped`). - `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 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 diff --git a/energyplugin/etm/scheduler/publishedplan.h b/energyplugin/etm/scheduler/publishedplan.h index c8f6c87..6d78230 100644 --- a/energyplugin/etm/scheduler/publishedplan.h +++ b/energyplugin/etm/scheduler/publishedplan.h @@ -23,8 +23,9 @@ */ namespace PublishedPlan { -//! Valeur de \c strategy d'un plan du rule-based publié (celle de \c Plan::strategy). -constexpr const char *StrategieRegles = "rule-based"; +//! Valeur de \c strategy d'un plan du rule-based publié (celle de \c Plan::strategy, et la même +//! que \c activeStrategy : \c OptimizerStatus::StrategieRegles). +constexpr const char *StrategieRegles = "rules"; /*! * \brief Le plan du cycle, pour l'app. diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.cpp b/energyplugin/etm/scheduler/rulebasedscheduler.cpp index 5bc3854..9261f18 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.cpp +++ b/energyplugin/etm/scheduler/rulebasedscheduler.cpp @@ -559,7 +559,7 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx) Plan plan; plan.planId = QUuid::createUuid().toString(QUuid::WithoutBraces); - plan.strategy = QStringLiteral("rule-based"); + plan.strategy = QStringLiteral("rules"); plan.timeSlots.append(slot); compta.remainingW = remainingSurplusW; diff --git a/energyplugin/etm/types/plan.h b/energyplugin/etm/types/plan.h index d2f0141..c2048f2 100644 --- a/energyplugin/etm/types/plan.h +++ b/energyplugin/etm/types/plan.h @@ -156,7 +156,7 @@ struct PlanDraw { */ struct Plan { 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()). QString strategy; //! Créneaux du plan. Nommé \c timeSlots (pas \c slots, mot-clé Qt). diff --git a/energyplugin/nymeaenergyjsonhandler.cpp b/energyplugin/nymeaenergyjsonhandler.cpp index f4949c3..23b6005 100644 --- a/energyplugin/nymeaenergyjsonhandler.cpp +++ b/energyplugin/nymeaenergyjsonhandler.cpp @@ -609,13 +609,13 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana 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 " "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, " "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 " "OptimizerStatusChanged."; 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:validUntil", enumValueName(String)); returns.insert("o:slots", QVariantList() << planSlot); diff --git a/tests/auto/decisions/etm-powersync-compare-hors-motifs.py b/tests/auto/decisions/etm-powersync-compare-hors-motifs.py index fd62e18..5da79fb 100755 --- a/tests/auto/decisions/etm-powersync-compare-hors-motifs.py +++ b/tests/auto/decisions/etm-powersync-compare-hors-motifs.py @@ -2,7 +2,7 @@ # Comparateur « IDENTIQUE HORS MOTIFS » (correctifs moteur a et b, etm70). # # etm-powersync-compare-hors-motifs.py [--cas ] -# [--achat] [--lignes ] +# [--achat] [--strategie] [--lignes ] # # 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 : @@ -13,6 +13,8 @@ # 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 # 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, # 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 @@ -48,11 +50,13 @@ def sans_achat(niveau, charge, achats): 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é)]).""" d = json.loads(ligne) retires = [] 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': for ch in (d.get('telemetry') or {}).get('loads', []) or []: 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_lignes = sys.argv[sys.argv.index('--lignes') + 1] if '--lignes' in sys.argv else None achat = '--achat' in sys.argv + strategie = '--strategie' in sys.argv lignes_changees = [] if len(attendu) != len(obtenu): @@ -107,11 +112,12 @@ def main(): if a == b: brutes_identiques += 1 continue - na, ra, aa = normaliser(a, achat) - nb, rb, ab = normaliser(b, achat) + na, ra, aa = normaliser(a, achat, strategie) + nb, rb, ab = normaliser(b, achat, strategie) lignes_changees.append((i, a, b)) 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('+ ' + b[:3000]) return 1 @@ -123,14 +129,18 @@ def main(): for (charge, va), (_, vb) in zip(aa, ab): if va != vb: 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. - if ra == rb and aa == ab: + elif ra == rb and aa == ab: 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 ' 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: with open(sortie_lignes, 'w', encoding='utf-8') as f: for i, a, b in lignes_changees: diff --git a/tests/auto/decisions/reference/decisions.ndjson.xz b/tests/auto/decisions/reference/decisions.ndjson.xz index 81b0732..421c0ea 100644 Binary files a/tests/auto/decisions/reference/decisions.ndjson.xz and b/tests/auto/decisions/reference/decisions.ndjson.xz differ diff --git a/tests/auto/loadmodel/testoptimizerplan.cpp b/tests/auto/loadmodel/testoptimizerplan.cpp index cf681eb..ca66dac 100644 --- a/tests/auto/loadmodel/testoptimizerplan.cpp +++ b/tests/auto/loadmodel/testoptimizerplan.cpp @@ -323,7 +323,7 @@ void TestLoadModel::testOptimizerUnavailableLoadKeepsTheShadow() // Plan ombre : une action par charge, dont LOAD_UNAVAILABLE pour la charge en défaut. Plan ombre; - ombre.strategy = "rule-based"; + ombre.strategy = "rules"; ombre.planId = "ombre"; ombre.budget.surplusW = 777; // ne doit JAMAIS être publié sous l'optimiseur ombre.draw.committedW = 333; @@ -374,7 +374,7 @@ void TestLoadModel::testOptimizerFinaliseUsesTheAppliedAction() avecAction(1, [](QVariantMap &a) { a["state"] = 4; }), ctx, {}, false); QVERIFY(v.issue == OptimizerPlan::Issue::Valide); Plan ombre; - ombre.strategy = "rule-based"; + ombre.strategy = "rules"; Slot s; s.from = kMaintenant; s.to = kMaintenant.addSecs(60); diff --git a/tests/auto/socketscheduler/testsocketscheduler.cpp b/tests/auto/socketscheduler/testsocketscheduler.cpp index 63232c0..e5bff4c 100644 --- a/tests/auto/socketscheduler/testsocketscheduler.cpp +++ b/tests/auto/socketscheduler/testsocketscheduler.cpp @@ -912,7 +912,7 @@ void TestSocketScheduler::testStatusWithoutEndpoint() // refusé, il n'y a personne d'autre — c'est ce qui distingue « pas d'optimiseur ». const QVariantMap p = rpc("GetPlan"); 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)); QVERIFY(p.contains("validUntil")); QVERIFY(!p.contains("fallbackCode") && !p.contains("diagnostics")); @@ -938,7 +938,7 @@ void TestSocketScheduler::testStatusWithoutEndpoint() QCOMPARE(triplet(), QString("-|mort|none")); cycle(t0.addSecs(300), -3000); 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. const QVariantList notifs = checkNotifications(paquets, "NymeaEnergy.OptimizerStatusChanged"); 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(t0, -3000); 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("slots").toList().count(), 1);