Patrick Schurig e3816ee031 feat(tarif): TariffConfig — tarif saisi validé, plages murales, moyenne pondérée
Brief 3 T4. Une zone = {label, priceCtkWh, ranges[]}, plusieurs plages par
zone ; tarif unique = une zone 00:00-24:00 (source fixed), sinon tou.
Validation stricte et codée (prix ≥ 0, "HH:MM", 24 h sans trou ni
chevauchement, minute en cause rendue). importSeries() lit les plages en
heure murale, minute absolue par minute : jours de 23 h et 25 h exacts ; un
créneau à cheval reçoit la moyenne pondérée par la durée. Aucune décision ne
le lit (§11).

Tests loadmodel : validation, jours de 23/25 h et créneau à cheval sur
oracles Python zoneinfo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:51:17 +02:00

2171 lines
113 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// SPDX-License-Identifier: GPL-3.0-or-later
#include "etm/forecast/forecaststore.h"
#include "etm/forecast/forecastlearninglog.h"
#include <QElapsedTimer>
#include <QFileInfo>
#include <QSqlDatabase>
#include <QSqlQuery>
#include <QSqlRecord>
#include <QUuid>
#include <QTemporaryDir>
#include "testloadmodel.h"
#include "etm/adapters/relayrouter.h"
#include "etm/energyarbitrator.h"
#include "etm/types/loadconfig.h"
#include "etm/types/drawcap.h"
#include "etm/types/ecotarget.h"
#include "etm/config/loadconfigstore.h"
#include "etm/ratios/energyratioscalculator.h"
#include "etm/tariff/tariffconfig.h"
#include <QTest>
#include <QDir>
#include <QFile>
#include <QJsonDocument>
#include <QJsonObject>
#include <QTimeZone>
#include <QJsonArray>
namespace {
//! Une config relay-router minimale, valide, dont on ne fait varier que les relais.
LoadConfig relayLoad(const QList<QPair<QString, int>> &relays,
const QString &label = QStringLiteral("chauffe-eau"),
int priority = 1,
const QString &meter = QString(),
const QString &sensor = QString())
{
QVariantList list;
for (const auto &r : relays) {
QVariantMap m;
m.insert("thingId", r.first);
m.insert("powerW", r.second);
list.append(m);
}
QVariantMap map;
map.insert("id", "chauffe-eau");
map.insert("label", label);
map.insert("adapter", "relay-router");
map.insert("mode", "fixed");
map.insert("priority", priority);
map.insert("enabled", true);
map.insert("minOnS", 60);
map.insert("minOffS", 60);
map.insert("relays", list);
if (!meter.isEmpty())
map.insert("meterThingId", meter);
if (!sensor.isEmpty())
map.insert("sensorThingId", sensor);
return LoadConfig::fromMap(map);
}
const QString K1 = QStringLiteral("{aaaaaaaa-0000-0000-0000-000000000001}");
const QString K2 = QStringLiteral("{bbbbbbbb-0000-0000-0000-000000000002}");
const QString K3 = QStringLiteral("{cccccccc-0000-0000-0000-000000000003}");
} // namespace
void TestLoadModel::testStageTableIsDerivedFromSubsetSums()
{
// La configuration réelle du banc .75 : trois contacteurs 500 / 1 000 / 2 000 W.
const RelayStageTable t = RelayRouter::deriveStages(
relayLoad({{K1, 500}, {K2, 1000}, {K3, 2000}}).relaysList());
QCOMPARE(t.levels, (QList<int>{0, 500, 1000, 1500, 2000, 2500, 3000, 3500}));
QCOMPARE(t.mapping.size(), t.levels.size());
// Le palier 0 est le sous-ensemble vide : aucun contact fermé. C'est ce qui garantit
// qu'un routeur sait toujours s'arrêter.
QCOMPARE(t.levels.first(), 0);
QVERIFY(t.mapping.first().isEmpty());
// Et le maximum est bien la somme de tout — c'est le maxStageW publié en télémétrie.
QCOMPARE(t.levels.last(), 3500);
QCOMPARE(t.mapping.last().size(), 3);
}
void TestLoadModel::testRelayOrderIsInertWhenPowersAreDistinct()
{
const RelayStageTable direct = RelayRouter::deriveStages(
relayLoad({{K1, 500}, {K2, 1000}, {K3, 2000}}).relaysList());
const RelayStageTable inverse = RelayRouter::deriveStages(
relayLoad({{K3, 2000}, {K2, 1000}, {K1, 500}}).relaysList());
// Chaque somme n'a qu'une seule combinaison possible : la déduplication n'arbitre rien,
// donc l'ordre de déclaration ne peut rien changer d'ÉLECTRIQUE.
QCOMPARE(direct.levels, inverse.levels);
// Les listes de ThingIds, elles, diffèrent : elles sont collectées dans l'ordre de
// déclaration, donc le palier 1 500 W sort {K1,K2} d'un côté et {K2,K1} de l'autre.
// C'est le même geste électrique — d'où la comparaison par ENSEMBLE dans la table.
// Ce cas a fait échouer la première version de operator==, qui comparait les listes.
QVERIFY2(direct == inverse,
"un réordonnancement à puissances distinctes doit rendre la même table");
}
void TestLoadModel::testRelayOrderDecidesWhichContactServesATiedStage()
{
// Deux relais de MÊME puissance : le palier 1 000 W est atteignable par K1 seul comme
// par K2 seul. La table retient la PREMIÈRE combinaison rencontrée.
const RelayStageTable a = RelayRouter::deriveStages(
relayLoad({{K1, 1000}, {K2, 1000}}).relaysList());
const RelayStageTable b = RelayRouter::deriveStages(
relayLoad({{K2, 1000}, {K1, 1000}}).relaysList());
// Mêmes puissances atteignables…
QCOMPARE(a.levels, (QList<int>{0, 1000, 2000}));
QCOMPARE(a.levels, b.levels);
// …mais pas le même contacteur pour le palier 1 000 W. C'est un vrai changement
// matériel : autre contact qui s'use, autre résistance qui chauffe.
QCOMPARE(a.mapping.at(1), QList<QString>{K1});
QCOMPARE(b.mapping.at(1), QList<QString>{K2});
QVERIFY(a != b);
}
void TestLoadModel::testReorderingDistinctRelaysKeepsTheAdapter()
{
const LoadConfig avant = relayLoad({{K1, 500}, {K2, 1000}, {K3, 2000}});
const LoadConfig apres = relayLoad({{K3, 2000}, {K1, 500}, {K2, 1000}});
// Le geste que l'installateur fait dans l'app : remonter une ligne. Mêmes Things,
// mêmes puissances, même table — donc aucune reconstruction, donc pas de contacts
// ouverts par l'état sûr ni de verrous réarmés à froid.
QVERIFY2(EnergyArbitrator::sameHardware(avant, apres),
"un réordonnancement sans effet sur la table coupait la charge");
}
void TestLoadModel::testSwappingTiedRelaysRebuilds()
{
const LoadConfig avant = relayLoad({{K1, 1000}, {K2, 1000}});
const LoadConfig apres = relayLoad({{K2, 1000}, {K1, 1000}});
// Ici la table change : le palier 1 000 W passe d'un contacteur à l'autre. Comparer les
// relais comme un ENSEMBLE aurait manqué ce changement et laissé l'adaptateur commander
// le mauvais contact.
QVERIFY2(!EnergyArbitrator::sameHardware(avant, apres),
"un échange de contacts à puissance égale doit reconstruire");
}
void TestLoadModel::testSoftFieldsNeverRebuild()
{
const QList<QPair<QString, int>> relays{{K1, 500}, {K2, 1000}};
const LoadConfig avant = relayLoad(relays, QStringLiteral("chauffe-eau"), 1);
const LoadConfig apres = relayLoad(relays, QStringLiteral("Ballon 200 L"), 3);
// Renommer et changer de rang se met à jour EN PLACE (ECS-412). Le libellé part
// désormais à l'adaptateur par updateSoftConfig() — sans quoi le journal de la box
// continuait d'imprimer l'ancien nom alors que GetLoadConfig rendait le nouveau.
QVERIFY(EnergyArbitrator::sameHardware(avant, apres));
}
// ─────────────────────────────────────────────────────────────────────────────
// §11 — compteur et sonde par charge
// ─────────────────────────────────────────────────────────────────────────────
void TestLoadModel::testMeasurementThingsRoundTrip()
{
const QString meter = QStringLiteral("{dddddddd-0000-0000-0000-00000000000m}");
const QString sensor = QStringLiteral("{eeeeeeee-0000-0000-0000-00000000000s}");
const LoadConfig avec = relayLoad({{K1, 500}}, QStringLiteral("ECS"), 1, meter, sensor);
QCOMPARE(avec.meterThingId(), meter);
QCOMPARE(avec.sensorThingId(), sensor);
const QVariantMap m = avec.toMap();
QCOMPARE(m.value("meterThingId").toString(), meter);
QCOMPARE(m.value("sensorThingId").toString(), sensor);
// Omis quand vides — jamais une chaîne vide persistée, même règle que « domain ».
const QVariantMap sans = relayLoad({{K1, 500}}).toMap();
QVERIFY2(!sans.contains("meterThingId"), "un compteur absent ne doit pas sortir à \"\"");
QVERIFY2(!sans.contains("sensorThingId"), "une sonde absente ne doit pas sortir à \"\"");
}
void TestLoadModel::testMeasurementThingsAreNotClaimed()
{
const QString meter = QStringLiteral("{dddddddd-0000-0000-0000-00000000000m}");
const QString sensor = QStringLiteral("{eeeeeeee-0000-0000-0000-00000000000s}");
const LoadConfig c = relayLoad({{K1, 500}, {K2, 1000}}, QStringLiteral("ECS"), 1,
meter, sensor);
// LM-1104 : ces Things MESURENT, ils ne commandent pas. LM-201 ne s'applique donc pas.
const QSet<QString> claimed = c.claimedThingIds();
QCOMPARE(claimed.size(), 2); // les deux relais, et eux seuls
QVERIFY2(!claimed.contains(meter.toLower()), "le compteur ne doit pas être revendiqué");
QVERIFY2(!claimed.contains(sensor.toLower()), "la sonde ne doit pas être revendiquée");
}
void TestLoadModel::testTwoLoadsMayShareAMeter()
{
const QString meter = QStringLiteral("{dddddddd-0000-0000-0000-00000000000m}");
const QString sensor = QStringLiteral("{eeeeeeee-0000-0000-0000-00000000000s}");
LoadConfig a = relayLoad({{K1, 500}}, QStringLiteral("ECS haut"), 1, meter, sensor);
LoadConfig b = relayLoad({{K2, 1000}}, QStringLiteral("ECS bas"), 2, meter, sensor);
b.setId(QStringLiteral("ecs-bas"));
// Un compteur de tableau divisionnaire couvrant deux charges, une sonde de pièce
// partagée : deux configurations parfaitement correctes, que la symétrie avec relays[]
// aurait fait refuser.
LoadConfigs ensemble;
ensemble << a << b;
QString err;
QVERIFY2(LoadConfigStore::validateSet(ensemble, &err), qPrintable(err));
}
void TestLoadModel::testMeasurementThingsAreSoftFields()
{
const QList<QPair<QString, int>> relays{{K1, 500}, {K2, 1000}};
const LoadConfig avant = relayLoad(relays, QStringLiteral("ECS"), 1);
const LoadConfig apres = relayLoad(relays, QStringLiteral("ECS"), 1,
QStringLiteral("{dddddddd-0000-0000-0000-00000000000m}"),
QStringLiteral("{eeeeeeee-0000-0000-0000-00000000000s}"));
// Rattacher un compteur ne touche à AUCUN matériel commandé : pas de reconstruction,
// donc pas de contacts ouverts ni de verrous réarmés à froid pour un geste de mesure.
QVERIFY2(EnergyArbitrator::sameHardware(avant, apres),
"rattacher un compteur ne doit pas reconstruire la charge");
}
// ─────────────────────────────────────────────────────────────────────────────
// §12 — le plancher de modulation
// ─────────────────────────────────────────────────────────────────────────────
namespace {
//! Une charge continue minimale : un seul Thing, un plafond, éventuellement un plancher.
LoadConfig continuousLoad(int maxPowerW, int minPowerW)
{
QVariantMap map;
map.insert("id", "{11111111-2222-3333-4444-555555555555}");
map.insert("label", "Routeur PV salon");
map.insert("adapter", "etmvariableload");
map.insert("mode", "dynamic");
map.insert("priority", 1);
map.insert("enabled", true);
map.insert("maxPowerW", maxPowerW);
if (minPowerW > 0)
map.insert("minPowerW", minPowerW);
return LoadConfig::fromMap(map);
}
} // namespace
void TestLoadModel::testFloorIsDeclaredForContinuousLoads()
{
const LoadConfig c = continuousLoad(3000, 300);
QString err;
QVERIFY2(c.isValid(&err), qPrintable(err));
QCOMPARE(c.minPowerW(), 300u);
QCOMPARE(c.toMap().value("minPowerW").toUInt(), 300u);
// Omis quand nul — un plancher de 0 W se lirait « accepte n'importe quelle miette ».
QVERIFY(!continuousLoad(3000, 0).toMap().contains("minPowerW"));
}
void TestLoadModel::testFloorIsRefusedWhereItWouldBeDerived()
{
// relay-router : le plancher est le plus petit palier dérivé des relais.
QVariantMap relais = relayLoad({{K1, 500}, {K2, 1000}}).toMap();
relais.insert("minPowerW", 500);
QString err;
QVERIFY2(!LoadConfig::fromMap(relais).isValid(&err),
"un plancher déclaré sur relay-router doit être refusé");
QVERIFY(err.contains("minPowerW"));
// etmvariableload *fixed* : idem, le plancher est le plus petit palier non nul.
QVariantMap fixe;
fixe.insert("id", "{11111111-2222-3333-4444-555555555555}");
fixe.insert("label", "ECS multipalier");
fixe.insert("adapter", "etmvariableload");
fixe.insert("mode", "fixed");
fixe.insert("priority", 1);
fixe.insert("enabled", true);
fixe.insert("powerLevels", QVariantList() << 0 << 600 << 1200);
fixe.insert("minPowerW", 600);
QVERIFY2(!LoadConfig::fromMap(fixe).isValid(&err),
"un plancher déclaré en mode fixed doit être refusé");
}
void TestLoadModel::testFloorAboveCeilingIsRefused()
{
// {0} ∪ [min, max] serait réduit à {0} : la charge ne démarrerait jamais, et rien
// dans le journal ne dirait pourquoi.
QString err;
QVERIFY2(!continuousLoad(2000, 3000).isValid(&err),
"un plancher au-dessus du plafond doit être refusé");
QVERIFY(err.contains("minPowerW"));
// Égal au plafond : la charge est tout ou rien, mais elle EST atteignable.
QVERIFY2(continuousLoad(3000, 3000).isValid(&err), qPrintable(err));
}
QTEST_MAIN(TestLoadModel)
namespace {
//! Une entrée de borne telle que l'auto-provisionnement la crée : un rang, rien d'autre.
QVariantMap borneMap(const QString &id, int priority = 1)
{
QVariantMap m;
m.insert("id", id);
m.insert("label", "Borne du garage");
m.insert("adapter", "evcharger");
m.insert("mode", "dynamic");
m.insert("domain", "ev");
m.insert("priority", priority);
m.insert("enabled", true);
return m;
}
const QString BORNE = QStringLiteral("{dddddddd-0000-0000-0000-00000000000e}");
} // namespace
void TestLoadModel::testEvChargerConfigIsRankOnly()
{
QString err;
const LoadConfig c = LoadConfig::fromMap(borneMap(BORNE, 2));
QVERIFY2(c.isValid(&err), qUtf8Printable(err));
QVERIFY(c.isEvCharger());
QCOMPARE(c.priority(), 2);
// LM-302 — aller-retour NEUTRE. C'est le geste ordinaire de l'app : lire, changer un rang,
// renvoyer l'objet entier. Une entrée qui ne survivrait pas à sa propre relecture rendrait
// le réordonnancement impossible depuis l'écran.
const LoadConfig relu = LoadConfig::fromMap(c.toMap());
QVERIFY2(relu.isValid(&err), qUtf8Printable(err));
QCOMPARE(relu.toMap(), c.toMap());
QCOMPARE(relu.priority(), 2);
QCOMPARE(relu.adapter(), QStringLiteral("evcharger"));
QCOMPARE(relu.domain(), QStringLiteral("ev"));
// Le mode est celui d'une charge qui module. « fixed » n'aurait pas de sens : une borne
// n'a pas de paliers, et l'accepter ouvrirait une forme que rien ne construit.
QVariantMap fixe = borneMap(BORNE);
fixe.insert("mode", "fixed");
QVERIFY2(!LoadConfig::fromMap(fixe).isValid(&err), "evcharger en mode fixed doit être refusé");
QVERIFY(err.contains("dynamic"));
}
void TestLoadModel::testEvChargerConfigRefusesPayloads()
{
QString err;
// Les limites d'une borne viennent du THING et changent avec la voiture branchée. Les
// déclarer ici en ferait une seconde source de vérité, périmée dès le premier débranchement.
QVariantMap avecPlafond = borneMap(BORNE);
avecPlafond.insert("maxPowerW", 11000);
QVERIFY2(!LoadConfig::fromMap(avecPlafond).isValid(&err), "maxPowerW doit être refusé");
QVERIFY(err.contains("maxPowerW"));
QVariantMap avecPlancher = borneMap(BORNE);
avecPlancher.insert("minPowerW", 4140);
QVERIFY2(!LoadConfig::fromMap(avecPlancher).isValid(&err), "minPowerW doit être refusé");
QVERIFY(err.contains("minPowerW"));
QVariantMap avecRelais = borneMap(BORNE);
QVariantMap relais;
relais.insert("thingId", K1);
relais.insert("powerW", 2000);
avecRelais.insert("relays", QVariantList() << relais);
QVERIFY2(!LoadConfig::fromMap(avecRelais).isValid(&err), "relays[] doit être refusé");
QVariantMap avecVerrous = borneMap(BORNE);
avecVerrous.insert("minOnS", 300);
QVERIFY2(!LoadConfig::fromMap(avecVerrous).isValid(&err), "minOnS doit être refusé");
QVERIFY(err.contains("minOnS"));
}
void TestLoadModel::testEvChargerClaimsItsThing()
{
// LM-201 — l'id de la charge EST le ThingId de la borne, donc elle le revendique. Sans
// cela, on pourrait déclarer la même borne une seconde fois comme charge pilotée : deux
// commandeurs sur un même organe, et sa puissance comptée deux fois au budget.
const LoadConfig borne = LoadConfig::fromMap(borneMap(BORNE));
QVERIFY(borne.claimedThingIds().contains(QUuid(BORNE).toString()));
QVariantMap doublon;
doublon.insert("id", BORNE); // le MÊME Thing, par un autre mécanisme
doublon.insert("label", "Borne pilotée en watts");
doublon.insert("adapter", "etmvariableload");
doublon.insert("mode", "dynamic");
doublon.insert("maxPowerW", 11000);
doublon.insert("priority", 3);
doublon.insert("enabled", true);
LoadConfigs ensemble;
ensemble << borne << LoadConfig::fromMap(doublon);
QString err;
QVERIFY2(!LoadConfigStore::validateSet(ensemble, &err),
"deux charges actives sur le Thing d'une borne doivent être refusées");
}
void TestLoadModel::testRankUniquenessRefusesWriteButNeverDiscards()
{
// [LM-1209-a] Deux vérifications, deux gestes — et les confondre convertirait une règle de
// contrat en PERTE DE CONFIGURATION au démarrage.
//
// validateSet() nomme ce qui rend l'ensemble DANGEREUX (deux charges sur un organe) et
// load() s'en sert pour ÉCARTER l'entrée fautive ;
// validateRanks() nomme ce qui le rend MAL FORMÉ, et le seul geste correct est de
// REFUSER l'écriture entière.
QString err;
LoadConfigs doublon;
doublon.append(LoadConfig::fromMap(borneMap(BORNE, 1)));
QVariantMap autre = borneMap(QStringLiteral("{dddddddd-0000-0000-0000-00000000000f}"), 1);
autre.insert("label", "Seconde borne");
doublon.append(LoadConfig::fromMap(autre));
// Chaque entrée est valide isolément, et l'ensemble n'est PAS dangereux : aucune des deux
// ne commande le Thing de l'autre. C'est pour cela que validateSet() le laisse passer —
// et pour cela qu'il fallait une seconde vérification, pas un durcissement de celle-ci.
for (const LoadConfig &c : doublon)
QVERIFY2(c.isValid(&err), qUtf8Printable(err));
QVERIFY2(LoadConfigStore::validateSet(doublon, &err),
"un doublon de RANG n'est pas un conflit d'organe : validateSet() ne doit pas le voir");
// La bonne forme, elle, refuse — et le motif doit NOMMER ce qui est perdu. Ce n'est pas le
// déterminisme : depuis 3g-2 le tri est total sur (rang, identifiant). C'est que l'ordre
// obéirait à des identifiants, donc à personne.
QVERIFY2(!LoadConfigStore::validateRanks(doublon, &err),
"deux charges au même rang doivent être refusées à l'écriture");
QVERIFY2(err.contains(QStringLiteral("RÉGLABLE")), qUtf8Printable(err));
// …et une écriture entière est rejetée, pas amputée : rien n'est persisté à moitié.
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-ranks.json";
QFile::remove(cfgPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
{
LoadConfigStore store;
QVERIFY2(!store.setConfigs(doublon, &err), "setConfigs doit refuser un doublon de rang");
QCOMPARE(store.configs().count(), 0);
}
// ---- Et au CHARGEMENT : conservé, jamais écarté --------------------------------------
// C'est la moitié qui compte. Une configuration héritée à rangs doublés existe (le banc en
// a porté une) ; l'écarter ferait disparaître des charges de la configuration ET de
// l'arbitrage, sans applySafeState — sur la seule foi d'une règle resserrée après coup.
QFile f(cfgPath);
QVERIFY(f.open(QIODevice::WriteOnly | QIODevice::Text));
QVariantList brut;
for (const LoadConfig &c : doublon)
brut.append(c.toMap());
f.write(QJsonDocument(QJsonObject{{"loads", QJsonArray::fromVariantList(brut)}}).toJson());
f.close();
LoadConfigStore relu;
QCOMPARE(relu.configs().count(), 2); // LES DEUX survivent
QCOMPARE(relu.configs().at(0).priority(), 1);
QCOMPARE(relu.configs().at(1).priority(), 1); // aucun rang n'a été « réparé » d'office
// Et la première écriture cliente répare, sans que rien n'ait été perdu entre-temps.
LoadConfigs repare = relu.configs();
repare[1].setPriority(2);
QVERIFY2(relu.setConfigs(repare, &err), qUtf8Printable(err));
QCOMPARE(relu.configs().count(), 2);
}
void TestLoadModel::testRankOriginFollowsTheRankNotTheWrite()
{
// [LM-1209-c] La marque dit « ce rang est un défaut, pas un choix ». Ce qui la lève est
// qu'on CLASSE la charge — pas qu'on écrive dans le magasin.
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-rankorigin.json";
QFile::remove(cfgPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
QString err;
LoadConfigStore store;
// Deux charges : « posee » classée par un humain au rang 1, « auto » créée d'office en 2.
LoadConfigs init;
QVariantMap posee = borneMap(QStringLiteral("{dddddddd-0000-0000-0000-0000000000a1}"), 1);
posee.insert("label", "Borne posée");
init.append(LoadConfig::fromMap(posee));
LoadConfig creee = LoadConfig::fromMap(borneMap(BORNE, 2));
creee.setRankOrigin(QStringLiteral("auto")); // le MOTEUR l'écrit, lui en a le droit
init.append(creee);
QVERIFY2(store.setConfigs(init, &err), qUtf8Printable(err));
// ---- Réordonner une AUTRE charge ne lève PAS la marque ---------------------------------
// C'est le cas qui se serait perdu. SetLoadConfig remplace en bloc : déplacer « posee »
// renvoie forcément l'entrée de la borne créée d'office. Si l'effacement portait sur
// « écriture reçue » plutôt que sur « rang changé », la marque tomberait ici — alors que
// la charge n'a toujours été classée par personne.
LoadConfigs bougeLautre = store.configs();
bougeLautre[0].setPriority(3); // on déplace « posee », pas la borne
bougeLautre[1].setRankOrigin(QString()); // et le client OMET la marque, comme un
// client naïf qui reconstruit sa charge utile
LoadConfigs reconciliee;
QVERIFY2(store.reconcileRankOrigin(bougeLautre, &reconciliee, &err), qUtf8Printable(err));
QVERIFY2(store.setConfigs(reconciliee, &err), qUtf8Printable(err));
QCOMPARE(store.configs().at(1).rankOrigin(), QStringLiteral("auto"));
QCOMPARE(store.configs().at(1).priority(), 2);
// ---- Déplacer LA charge lève la marque -------------------------------------------------
LoadConfigs bougeLaBonne = store.configs();
bougeLaBonne[1].setPriority(9);
QVERIFY2(store.reconcileRankOrigin(bougeLaBonne, &reconciliee, &err), qUtf8Printable(err));
QVERIFY2(store.setConfigs(reconciliee, &err), qUtf8Printable(err));
// Vers le VIDE, pas vers "user" : le moteur constate un déplacement, il ne sait pas si un
// humain ou un script l'a voulu. Ne rien affirmer est le minimum honnête.
QVERIFY2(store.configs().at(1).rankOrigin().isEmpty(),
"un rang changé doit lever la marque, sans affirmer autre chose à la place");
// ---- Un client ne FABRIQUE pas un "auto" -----------------------------------------------
LoadConfigs fabrique = store.configs();
fabrique[1].setRankOrigin(QStringLiteral("auto"));
QVERIFY2(!store.reconcileRankOrigin(fabrique, &reconciliee, &err),
"un client ne doit pas pouvoir affirmer que le moteur a créé ce rang");
QVERIFY2(err.contains(QStringLiteral("moteur")), qUtf8Printable(err));
// ---- …mais l'aller-retour VERBATIM reste licite ----------------------------------------
// Sans cette tolérance, tout client qui renvoie ce qu'il a lu serait refusé — la
// neutralité Get→Set du contrat serait rompue.
LoadConfigs init2;
LoadConfig c2 = LoadConfig::fromMap(borneMap(BORNE, 2));
c2.setRankOrigin(QStringLiteral("auto"));
init2.append(c2);
LoadConfigStore store2;
QVERIFY2(store2.setConfigs(init2, &err), qUtf8Printable(err));
LoadConfigs verbatim = store2.configs(); // relu tel quel, marque comprise
QVERIFY2(store2.reconcileRankOrigin(verbatim, &reconciliee, &err), qUtf8Printable(err));
QCOMPARE(reconciliee.at(0).rankOrigin(), QStringLiteral("auto"));
// ---- « user » est la voie EXPLICITE pour lever sans déplacer ---------------------------
LoadConfigs assume = store2.configs();
assume[0].setRankOrigin(QStringLiteral("user"));
QVERIFY2(store2.reconcileRankOrigin(assume, &reconciliee, &err), qUtf8Printable(err));
QCOMPARE(reconciliee.at(0).rankOrigin(), QStringLiteral("user"));
// Et l'énumération est fermée — une valeur libre serait une affirmation qu'on devrait
// supporter le jour où quelqu'un la lit.
LoadConfig hors = LoadConfig::fromMap(borneMap(BORNE, 1));
hors.setRankOrigin(QStringLiteral("installateur"));
QVERIFY2(!hors.isValid(&err), "rankOrigin hors énumération doit être refusé");
}
/*!
* \brief [§10 étape D] L'étalement uniforme, et le durcissement qu'il faut savoir DIRE.
*
* \par Règles couvertes
* \rule{LM-1013-b-i} (recalculé à chaque cycle), \rule{LM-1013-b-ii} (le durcissement se dit),
* \rule{LM-1013-b-iii} (point de non-retour nommé), \rule{LM-1013-b-iv} (l'heure quotidienne
* vise la prochaine occurrence).
*
* \par Ce que la mesure a montré avant que rien ne soit asserté
* L'étape D est la seule des quatre qui change le comportement, et aucun scénario existant ne
* portait d'obligation éco — `LoadContext::needs` n'était rempli par aucun adaptateur. Il n'y
* avait donc **aucune référence** à laquelle se comparer : les valeurs ci-dessous ont été
* relevées d'abord, épinglées ensuite. C'est ce relevé qui a révélé la bascule de période, que
* la lecture du code ne montrait pas.
*
* \par Le durcissement est mécanique, et c'est pourquoi le motif doit le porter
* 2 kWh restants font **333 W à six heures** de l'échéance, **2 kW à une heure**, et **7 kW —
* le plafond — dès seize minutes**. La fenêtre où l'achat devient massif n'arrive pas
* progressivement : elle arrive d'un coup. Un client qui voit sa facture grimper à six heures du
* matin sans cause lisible cherchera une panne.
*/
void TestLoadModel::testEcoSpreadHardensAndSaysSo()
{
LoadNeeds needs;
needs.minEnergyWhPerDay = 2000;
needs.dailyDeadline = "07:00";
EcoProgress p;
p.delivered = true;
p.deliveredWh = 0;
p.targetWh = 2000;
const QDate j(2026, 8, 31);
auto cible = [&](int h, int m) {
return computeEcoTarget(needs, p, 7000, QDateTime(j, QTime(h, m), QTimeZone::utc()));
};
// 1. LE DURCISSEMENT, relevé point par point. C'est la courbe que le motif doit rendre
// lisible — pas une valeur isolée, une progression.
QCOMPARE(qRound(cible(1, 0).watts), 333); // 6 h avant
QCOMPARE(qRound(cible(5, 0).watts), 1000); // 2 h avant
QCOMPARE(qRound(cible(6, 0).watts), 2000); // 1 h avant
QCOMPARE(qRound(cible(6, 30).watts), 4000); // 30 min
QCOMPARE(qRound(cible(6, 44).watts), 7000); // 16 min — déjà au plafond physique
// 2. Le POINT DE NON-RETOUR est franchi sous le seuil, et il se DIT. Sans lui, la cadence
// tend vers l'infini sans que rien ne décide : un seuil se discute, une asymptote se subit.
QVERIFY2(!cible(6, 44).pointDeNonRetour, "à 16 min le seuil n'est pas encore franchi");
QVERIFY2(cible(6, 50).pointDeNonRetour, "à 10 min il doit l'être");
QCOMPARE(qRound(cible(6, 50).watts), 7000);
// 3. La cadence est RECALCULÉE, jamais figée : un rattrapage après une heure sans surplus
// doit monter. Figée au départ, elle manquerait l'échéance en ayant l'air de travailler.
p.deliveredWh = 0;
const double aSixHeures = cible(1, 0).watts;
const double aDeuxHeures = cible(5, 0).watts;
QVERIFY2(aDeuxHeures > aSixHeures * 2.5,
"sans rien livré, la cadence doit avoir monté d'elle-même");
// 4. Ce qui est DÉJÀ LIVRÉ allège la cadence — l'étalement porte sur le RESTE.
p.deliveredWh = 1500;
QCOMPARE(qRound(cible(5, 0).watts), 250); // 500 Wh en 2 h
QCOMPARE(qRound(cible(5, 0).remainingWh), 500);
// 5. Obligation TENUE : plus rien à acheter pour l'éco.
p.deliveredWh = 2000;
QVERIFY2(cible(5, 0).tenue, "2000 sur 2000 : l'obligation est tenue");
QCOMPARE(qRound(cible(5, 0).watts), 0);
// 6. R6 — SOUS `unmeasurable`, ON NE CONCLUT JAMAIS « TENUE ». On ne sait pas ce qui a été
// livré ; l'affirmer serait affirmer sur ce qu'on ignore. On vise l'obligation entière,
// qui est le choix qui SERT plutôt que celui qui suppose acquis.
EcoProgress inconnu;
inconnu.delivered = false;
inconnu.targetWh = 2000;
const EcoTarget ti = computeEcoTarget(needs, inconnu, 7000,
QDateTime(j, QTime(5, 0), QTimeZone::utc()));
QVERIFY2(!ti.tenue, "jamais « tenue » sur un avancement qu'on ne mesure pas (LM-1009 C1)");
QCOMPARE(qRound(ti.remainingWh), 2000);
// 7. L'heure quotidienne lue le SOIR vise le LENDEMAIN. La lire comme dépassée ferait
// franchir le point de non-retour toutes les nuits, et acheter à pleine puissance chaque
// soir sans qu'aucune échéance ne l'exige.
const EcoTarget soir = computeEcoTarget(needs, p, 7000,
QDateTime(j, QTime(23, 0), QTimeZone::utc()));
QVERIFY2(!soir.pointDeNonRetour, "23:00 vers 07:00 : huit heures, pas un retard");
QCOMPARE(soir.remainingS, qint64(8 * 3600));
}
void TestLoadModel::testEcoUnmeasurableBuysAtAConstantRate()
{
// \rule{LM-1013-b-v} — sous `unmeasurable`, `remainingWh` est FIGÉ à la cible par contrat.
// Un rapport dont seul le dénominateur décroît ne suit plus un retard : il suit l'horloge.
LoadNeeds needs;
needs.minEnergyWhPerDay = 4000;
needs.dailyDeadline = "07:00";
EcoProgress inconnu;
inconnu.delivered = false; // le régime `unmeasurable`
inconnu.targetWh = 4000;
EcoProgress mesure; // même obligation, même instant, régime MESURÉ
mesure.delivered = true;
mesure.targetWh = 4000;
mesure.deliveredWh = 0; // rien livré : le numérateur vaut AUSSI 4000
const QDate j(2026, 9, 4);
const double PLAFOND = 7000;
auto inconnue = [&](int h, int m) {
return computeEcoTarget(needs, inconnu, PLAFOND, QDateTime(j, QTime(h, m), QTimeZone::utc()));
};
auto mesuree = [&](int h, int m) {
return computeEcoTarget(needs, mesure, PLAFOND, QDateTime(j, QTime(h, m), QTimeZone::utc()));
};
// 1. CONSTANTE sur toute la fenêtre. C'est l'assertion qui mord : sous l'ancienne loi ces
// cinq valeurs montaient de 167 W au plafond.
const int attendu = qRound(4000.0 / 24.0); // 167 W
QCOMPARE(qRound(inconnue(1, 0).watts), attendu); // 6 h avant
QCOMPARE(qRound(inconnue(5, 0).watts), attendu); // 2 h avant
QCOMPARE(qRound(inconnue(6, 0).watts), attendu); // 1 h avant
QCOMPARE(qRound(inconnue(6, 30).watts), attendu); // 30 min
QCOMPARE(qRound(inconnue(6, 55).watts), attendu); // 5 min — DANS le seuil de non-retour
// 2. `atMaximum` JAMAIS vrai, y compris sous le seuil. Le drapeau annonce que l'étalement a
// cessé ; sans étalement il n'a rien à annoncer, et le publier dirait qu'on est en retard
// sur un avancement qu'on ne mesure pas.
QVERIFY2(!inconnue(6, 55).pointDeNonRetour, "5 min avant : pas de point de non-retour");
QVERIFY2(!inconnue(6, 59).pointDeNonRetour, "1 min avant non plus");
// 3. LE TOTAL ACHETÉ VAUT EXACTEMENT LA CIBLE. C'est la propriété qui justifie la loi, et
// elle ne se lit sur aucune valeur isolée : il faut intégrer la fenêtre entière.
double total = 0;
QDateTime t(j, QTime(7, 1), QTimeZone::utc());
for (int minute = 0; minute < 24 * 60; ++minute) {
total += computeEcoTarget(needs, inconnu, PLAFOND, t).watts / 60.0; // Wh sur 1 min
t = t.addSecs(60);
}
QVERIFY2(qAbs(total - 4000.0) < 5.0,
qUtf8Printable(QString("total acheté sur la fenêtre : %1 Wh, attendu 4000")
.arg(total, 0, 'f', 1)));
// 4. CONTRE-ÉPREUVE — la loi n'a changé QUE pour ce régime. Même obligation, même instant,
// même numérateur (4000 non livrés) : sous `measured`, le durcissement est intact. Sans
// cette épreuve, avoir supprimé l'étalement POUR TOUT LE MONDE passerait inaperçu.
QCOMPARE(qRound(mesuree(1, 0).watts), 667); // 4 kWh en 6 h
QCOMPARE(qRound(mesuree(6, 0).watts), 4000); // 4 kWh en 1 h
QVERIFY2(mesuree(6, 55).pointDeNonRetour, "sous `measured`, le point de non-retour tient");
QCOMPARE(qRound(mesuree(6, 55).watts), qRound(PLAFOND));
// 5. L'ÉCART, au même instant, dit tout le lot : à cinq minutes de l'échéance, le régime
// mesuré prend 7000 W parce qu'il SAIT qu'il reste 4 kWh à livrer ; le régime inconnu en
// prend 167 parce qu'il ne sait rien — et ne pas savoir n'est pas une raison d'acheter.
QVERIFY2(mesuree(6, 55).watts > inconnue(6, 55).watts * 40,
"au même instant, les deux régimes doivent diverger d'un ordre de grandeur");
// 6. R6 tient toujours : on ne conclut jamais « tenue » sur un avancement qu'on ne mesure
// pas, et la cible reste INTACTE — ce que l'écran affiche comme reste dû.
QVERIFY2(!inconnue(5, 0).tenue, "jamais « tenue » sous `unmeasurable` (LM-1009 C1)");
QCOMPARE(qRound(inconnue(5, 0).remainingWh), 4000);
}
void TestLoadModel::testMinimumIsServedEvenWhenTheObligationIsMet()
{
// \rule{LM-1013-b-vi} — « Surplus + minimum réseau ». Une PUISSANCE demandée tant que la
// charge est là, distincte de l'obligation en ÉNERGIE : on ne sait pas combien de temps une
// voiture reste branchée, donc rien ne fournit le dénominateur d'une cadence.
const QDate j(2026, 9, 7);
const QDateTime cinqHeuresAvant(j, QTime(2, 0), QTimeZone::utc()); // échéance à 07:00
EcoProgress mesure;
mesure.delivered = true;
mesure.targetWh = 2000;
LoadNeeds nu; // aucun besoin déclaré du tout
LoadNeeds obligationSeule;
obligationSeule.minEnergyWhPerDay = 2000;
obligationSeule.dailyDeadline = "07:00";
LoadNeeds minimumSeul;
minimumSeul.minPowerWAlways = 4140; // 6 A x 3 phases x 230 V
LoadNeeds lesDeux = obligationSeule;
lesDeux.minPowerWAlways = 4140;
// 1. AUCUN MINIMUM DEMANDÉ — comportement STRICTEMENT inchangé. C'est la garantie que
// l'ajout ne déplace rien pour les charges qui n'en veulent pas, et c'est la première
// chose à vérifier : un correctif qui change ce qu'il ne devait pas toucher est un
// défaut, pas une amélioration.
mesure.deliveredWh = 0;
const EcoTarget sansMin = computeEcoTarget(obligationSeule, mesure, 7000, cinqHeuresAvant);
QCOMPARE(qRound(sansMin.watts), 400); // 2000 Wh en 5 h
QCOMPARE(qRound(sansMin.minimumW), 0);
QVERIFY(!sansMin.borneParLeMinimum);
QVERIFY2(!computeEcoTarget(nu, mesure, 7000, cinqHeuresAvant).applicable,
"sans aucun besoin, la passe éco n'émet rien — R1");
// 2. MINIMUM SEUL, sans obligation ni échéance. C'est le cas de la borne : rien ne permet
// de calculer une cadence, et le minimum n'en a pas besoin.
const EcoTarget minSeul = computeEcoTarget(minimumSeul, EcoProgress(), 7000, cinqHeuresAvant);
QVERIFY2(minSeul.applicable, "un minimum suffit à rendre la charge éligible à la passe éco");
QCOMPARE(qRound(minSeul.watts), 4140);
QCOMPARE(qRound(minSeul.minimumW), 4140);
QVERIFY(minSeul.borneParLeMinimum);
QVERIFY2(!minSeul.tenue, "un minimum ne se « tient » jamais : il n'a pas de cible");
// 3. LE CŒUR DU LOT — obligation TENUE et minimum encore demandé. La cadence vaut 0 et la
// consigne doit retomber SUR LE MINIMUM, pas sur zéro. C'est ici, et seulement ici, que
// la différence de nature entre une énergie et une puissance se voit dans le code.
mesure.deliveredWh = 2000; // obligation entièrement livrée
const EcoTarget tenu = computeEcoTarget(lesDeux, mesure, 7000, cinqHeuresAvant);
QVERIFY2(tenu.tenue, "l'obligation en énergie est bien tenue");
QCOMPARE(qRound(tenu.watts), 4140);
QVERIFY2(tenu.borneParLeMinimum, "c'est le minimum qui fixe la consigne, et le motif doit le dire");
// Contre-épreuve : la MÊME situation sans minimum retombe à zéro. Sans elle, on ne saurait
// pas si 4 140 vient du minimum ou d'un reliquat d'obligation mal calculé.
QCOMPARE(qRound(computeEcoTarget(obligationSeule, mesure, 7000, cinqHeuresAvant).watts), 0);
// 4. L'OBLIGATION PLUS EXIGEANTE L'EMPORTE, et le motif dit alors « obligation ». À une
// heure de l'échéance, 2 000 Wh demandent 2 000 W — sous le minimum, donc c'est encore
// lui qui mord ; à dix minutes, le point de non-retour prend le plafond physique.
mesure.deliveredWh = 0;
const EcoTarget uneHeure = computeEcoTarget(lesDeux, mesure, 7000, QDateTime(j, QTime(6, 0), QTimeZone::utc()));
QCOMPARE(qRound(uneHeure.watts), 4140);
QVERIFY2(uneHeure.borneParLeMinimum, "2 000 W de cadence sont SOUS le minimum de 4 140");
const EcoTarget dixMin = computeEcoTarget(lesDeux, mesure, 7000, QDateTime(j, QTime(6, 50), QTimeZone::utc()));
QCOMPARE(qRound(dixMin.watts), 7000);
QVERIFY2(!dixMin.borneParLeMinimum, "au point de non-retour, c'est l'obligation qui commande");
// 5. ÉCRÊTÉ AU PLAFOND PHYSIQUE, comme l'obligation. Demander plus que ce que la charge sait
// prendre n'inventerait qu'un chiffre.
LoadNeeds minTropGrand;
minTropGrand.minPowerWAlways = 9000;
QCOMPARE(qRound(computeEcoTarget(minTropGrand, EcoProgress(), 7000, cinqHeuresAvant).watts), 7000);
}
void TestLoadModel::testStoredInstantsSurviveATimeZoneChange()
{
// Un instant ABSOLU ne bouge pas quand l'installation change de fuseau ; une FRONTIÈRE
// locale, si. Les deux vivent dans la même charge utile et se ressemblent — c'est par là
// que le défaut est passé (mesuré le 2026-09-06, bascule Paris→London sans redémarrage :
// measuredSince a bougé de +3600 s alors qu'il datait une pose déjà faite).
const QTimeZone paris("Europe/Paris"), londres("Europe/London");
QVERIFY2(paris.isValid() && londres.isValid(), "sans base de fuseaux ce test ne prouve rien");
// L'instant de pose, tel que le code le capture désormais : ancré à la capture.
const QDateTime pose = QDateTime(QDate(2026, 9, 6), QTime(15, 36, 42), paris).toUTC();
const qint64 epoquePose = pose.toSecsSinceEpoch();
// 1. L'ANCRAGE TIENT : re-rendre dans un autre fuseau ne déplace pas l'instant.
QCOMPARE(pose.toTimeZone(londres).toSecsSinceEpoch(), epoquePose);
QCOMPARE(pose.toUTC().toSecsSinceEpoch(), epoquePose);
// 2. CE QUI ARRIVAIT SANS L'ANCRAGE, et c'est la contre-épreuve : un QDateTime en
// spécification LocalTime ne garde que des composantes d'horloge murale, donc son
// époque se résout AVEC LE FUSEAU COURANT. Reproduit ici en construisant la même
// lecture murale dans les deux fuseaux — c'est exactement ce que fait Qt quand
// /etc/localtime bascule sous un objet déjà stocké.
const QDateTime muraleParis(QDate(2026, 9, 6), QTime(15, 36, 42), paris);
const QDateTime muraleLondres(QDate(2026, 9, 6), QTime(15, 36, 42), londres);
QCOMPARE(muraleLondres.toSecsSinceEpoch() - muraleParis.toSecsSinceEpoch(), qint64(3600));
QVERIFY2(muraleLondres.toSecsSinceEpoch() != epoquePose,
"sans ancrage, la même lecture murale rend une autre époque — c'est le défaut");
// 3. LA FRONTIÈRE, ELLE, DOIT BOUGER. Minuit local n'est pas un instant absolu : c'est une
// propriété du fuseau, et la publier fixe serait l'autre erreur, symétrique.
const QDateTime minuitParis(QDate(2026, 9, 6), QTime(0, 0), paris);
const QDateTime minuitLondres(QDate(2026, 9, 6), QTime(0, 0), londres);
QCOMPARE(minuitLondres.toSecsSinceEpoch() - minuitParis.toSecsSinceEpoch(), qint64(3600));
// 4. Et c'est là toute la distinction, en une assertion : le même décalage de +3600 s est
// un DÉFAUT sur l'instant de pose et le COMPORTEMENT ATTENDU sur la frontière. Le nombre
// ne dit pas lequel des deux on regarde — seule la nature du champ le dit.
QCOMPARE(muraleLondres.toSecsSinceEpoch() - muraleParis.toSecsSinceEpoch(),
minuitLondres.toSecsSinceEpoch() - minuitParis.toSecsSinceEpoch());
}
void TestLoadModel::testReportingDayBoundaryIsPublishedNotDeducible()
{
// \rule{LM-1005-b-iv} — la frontière de la période de reporting se PUBLIE. Ce test dit
// POURQUOI : sur deux jours de l'année, la journée locale ne fait pas 24 heures, et tout
// client qui la recalcule en supposant 24 h se trompe d'une heure sur un chiffre de facture.
const QTimeZone paris("Europe/Paris");
QVERIFY2(paris.isValid(), "sans base de fuseaux, ce test ne prouverait rien — il passerait");
auto frontiere = [&](const QDate &jour) {
// Un calculateur NEUF par appel : on interroge la frontière, pas l'état d'une baseline.
EnergyRatiosCalculator calc;
return calc.compute(0, 0, 0, 0, QDateTime(jour, QTime(12, 0), paris)).debutPeriode;
};
// 1. La frontière est bien MINUIT LOCAL — comparé à un oracle calculé indépendamment
// (Python/zoneinfo), et non re-dérivé avec les mêmes primitives Qt que le code testé.
// Une identité qui se referme sur elle-même ne vérifierait rien.
QCOMPARE(frontiere(QDate(2026, 9, 5)).toSecsSinceEpoch(), qint64(1788559200));
QCOMPARE(frontiere(QDate(2026, 3, 29)).toSecsSinceEpoch(), qint64(1774738800));
QCOMPARE(frontiere(QDate(2026, 3, 30)).toSecsSinceEpoch(), qint64(1774821600));
QCOMPARE(frontiere(QDate(2026,10, 25)).toSecsSinceEpoch(), qint64(1792879200));
QCOMPARE(frontiere(QDate(2026,10, 26)).toSecsSinceEpoch(), qint64(1792969200));
auto dureeH = [&](const QDate &jour) {
return (frontiere(jour.addDays(1)).toSecsSinceEpoch()
- frontiere(jour).toSecsSinceEpoch()) / 3600;
};
// 2. LA RAISON DE PUBLIER, en trois nombres. Un client qui poserait « la journée fait
// 86 400 s » serait juste 363 jours sur 365, et faux les deux autres — dont l'un tombe en
// plein hiver de chauffage. C'est l'erreur silencieuse par excellence : elle ne se voit
// pas à l'écran, elle se voit sur la facture, un mois plus tard.
QCOMPARE(dureeH(QDate(2026, 9, 5)), qint64(24));
QCOMPARE(dureeH(QDate(2026, 3, 29)), qint64(23));
QCOMPARE(dureeH(QDate(2026,10, 25)), qint64(25));
// 3. Et le recalcul naïf DIVERGE, explicitement : « frontière + 24 h » ne retombe pas sur la
// frontière suivante ces jours-là. L'écrire ainsi évite qu'on lise le point 2 comme une
// curiosité de calendrier plutôt que comme un défaut de client.
QVERIFY2(frontiere(QDate(2026, 3, 29)).addSecs(24 * 3600) != frontiere(QDate(2026, 3, 30)),
"au printemps, +24 h dépasse la frontière suivante d'une heure");
QVERIFY2(frontiere(QDate(2026,10, 25)).addSecs(24 * 3600) != frontiere(QDate(2026,10, 26)),
"à l'automne, +24 h reste une heure en deçà");
// 4. Le fuseau publié est celui QUI A SERVI, pas celui du système. Sans cette assertion, un
// changement de fuseau de la machine de test ferait passer ou échouer le test pour une
// raison qui n'a rien à voir avec le code.
QCOMPARE(frontiere(QDate(2026, 3, 29)).timeZone().id(), QByteArray("Europe/Paris"));
}
void TestLoadModel::testDrawMarginComparesHeadroomsNotCaps()
{
// [LM-1006-1] Un plafond PAR PHASE et un plafond TOTAL n'ont pas la même forme et ne se
// convertissent pas : une maison peut tirer 20 A sur une phase et 2 A sur les deux autres,
// et « un tiers du total » supposerait un équilibre que rien ne garantit. Ce qui se compare,
// ce sont les MARGES — des watts dans les deux cas.
// ---- Aucun plafond : la marge est SANS OBJET, pas nulle -------------------------------
// La distinction n'est pas cosmétique : une marge nulle bornerait tout à 0 W et couperait
// l'installation. C'est le « zéro forgé » du contrat, appliqué à une couche de sécurité.
const DrawMargin rien = resolveDrawMargin({}, {0, 0, 0}, 0);
QVERIFY2(!rien.borne, "aucun plafond déclaré doit rendre une marge SANS OBJET");
// ---- Une seule source, par phase : c'est la phase la PLUS CHARGÉE qui décide -----------
const DrawCap disjoncteur{DrawCapSource::Breaker, 5000, -1};
DrawMargin m = resolveDrawMargin({disjoncteur}, {1000, 4500, 200}, 5700);
QVERIFY(m.borne);
QCOMPARE(qRound(m.watts), 500); // 5000 − 4500, la phase B
QCOMPARE(m.source, DrawCapSource::Breaker);
QCOMPARE(m.phase, 1); // et on DIT laquelle
// ---- Deux formes en concurrence : la plus petite marge mord ---------------------------
// 3000 W restants au total contre 500 W sur la phase B : c'est la phase qui borne, alors
// même que son plafond (5000) est plus BAS que le total (8700). Comparer les plafonds
// aurait donné la réponse inverse.
const DrawCap gestionnaire{DrawCapSource::GridOperator, -1, 8700};
m = resolveDrawMargin({gestionnaire, disjoncteur}, {1000, 4500, 200}, 5700);
QCOMPARE(qRound(m.watts), 500);
QCOMPARE(m.source, DrawCapSource::Breaker);
// …et l'inverse, sur la même mesure : un ordre du gestionnaire plus serré prend la main, et
// le motif publié doit alors dire « gestionnaire de réseau », jamais « surcharge ».
const DrawCap ordre{DrawCapSource::GridOperator, -1, 6000};
m = resolveDrawMargin({disjoncteur, ordre}, {1000, 4500, 200}, 5700);
QCOMPARE(qRound(m.watts), 300); // 6000 − 5700
QCOMPARE(m.source, DrawCapSource::GridOperator);
QCOMPARE(m.phase, -1); // un plafond total n'a pas de phase
// ---- Marge NÉGATIVE : le plafond est déjà dépassé, et de combien ----------------------
// C'est ce que la couche L4 doit délester. Écrêter à 0 perdrait l'ampleur du dépassement.
m = resolveDrawMargin({disjoncteur}, {1000, 6200, 200}, 7400);
QCOMPARE(qRound(m.watts), -1200);
QCOMPARE(m.phase, 1);
// ---- Marges ÉGALES : le départage est STABLE, pas dépendant de l'ordre du tableau -----
// Sans règle, le motif publié changerait au gré de l'ordre d'insertion — donc sans qu'aucune
// décision n'ait changé, ce qui est exactement ce qu'un motif ne doit jamais faire.
const DrawCap eco{DrawCapSource::EcoSelfImposed, -1, 6000};
const DrawCap ordre2{DrawCapSource::GridOperator, -1, 6000};
const DrawMargin a = resolveDrawMargin({eco, ordre2}, {2000, 2000, 2000}, 5700);
const DrawMargin b = resolveDrawMargin({ordre2, eco}, {2000, 2000, 2000}, 5700);
QCOMPARE(a.source, b.source);
QCOMPARE(a.source, DrawCapSource::GridOperator); // imposé > auto-imposé
// ---- Les codes franchissent la frontière RPC, et ils sont fermés ----------------------
QCOMPARE(drawCapSourceCode(DrawCapSource::Breaker), QStringLiteral("connection"));
QCOMPARE(drawCapSourceCode(DrawCapSource::GridOperator), QStringLiteral("gridOperator"));
QCOMPARE(drawCapSourceCode(DrawCapSource::EcoSelfImposed), QStringLiteral("selfImposed"));
}
namespace {
//! Fabrique une révision : créneaux consécutifs à partir de \a depuis, une valeur par créneau.
ForecastRevision revision(const QDateTime &age, const QDateTime &depuis, const QList<double> &wh,
const QString &site = QStringLiteral("site-A"))
{
ForecastRevision r;
r.issuedAt = age; // ATTRIBUT, jamais la clé
r.siteId = site;
for (int i = 0; i < wh.count(); ++i) {
ForecastPoint p;
p.targetAt = depuis.addSecs(i * 3600);
p.wh = wh.at(i);
r.points.append(p);
}
return r;
}
}
void TestLoadModel::testTheContentIsTheRevisionIdentity()
{
// \rule{LM-1500} n'a pas bougé : « une révision n'existe que si la PRÉVISION a changé ».
// C'est la CLÉ qui a changé, parce que la prémisse a été mesurée fausse le 2026-09-09 :
// `issuedAt` vient d'un domaine qui ne fournit AUCUNE valeur de la courbe, et le serveur
// recompose les premières heures sans le faire bouger. Regarder le contenu est donc PLUS
// fidèle à la règle que regarder l'étiquette d'émission.
QTemporaryDir dir;
ForecastStore magasin(dir.filePath("forecast.sqlite"));
QVERIFY(magasin.isValid());
const QDateTime run00 = QDateTime(QDate(2026, 9, 9), QTime(0, 0), QTimeZone::utc());
const QDateTime t0 = QDateTime(QDate(2026, 9, 9), QTime(10, 0), QTimeZone::utc());
QVERIFY(magasin.enregistrer(revision(run00, t0, {100, 200, 300})));
QCOMPARE(magasin.revisions(t0).count(), 1);
// 1. MÊME CONTENU republié — pas une révision, quoi qu'ait fait l'âge. La comparaison porte
// sur la courbe, pas sur la charge utile : deux charges utiles différentes portant la
// même courbe sont la même prévision, ce qu'aucune égalité de charge utile ne peut voir.
QVERIFY(!magasin.enregistrer(revision(run00.addSecs(6 * 3600), t0, {100, 200, 300})));
QCOMPARE(magasin.revisions(t0).count(), 1);
// 2. LE CŒUR DE LA CORRECTION — un contenu NEUF sous un `issuedAt` INCHANGÉ **EST** une
// révision. La version précédente la refusait : elle gelait la courbe sur les valeurs du
// premier cycle, et l'écran aurait montré une prévision périmée sans le dire — un
// instrument qui continue d'indiquer une ancienne mesure, pire que pas d'instrument.
QTest::qWait(5);
QVERIFY2(magasin.enregistrer(revision(run00, t0, {100, 168, 300})),
"un contenu neuf sous le MÊME issuedAt est une révision — sinon la courbe gèle");
QCOMPARE(magasin.revisions(t0).count(), 2);
const QList<ForecastPoint> vue = magasin.courbe(t0, t0.addSecs(2 * 3600));
QCOMPARE(qRound(vue.at(1).wh), 168); // la valeur FRAÎCHE, pas celle de 06:03
// 3. UN ÂGE INCONNU N'EST PLUS UN REFUS. `best_match` publie `issuedAt = 0` : jeter une
// prévision utilisable parce qu'un ATTRIBUT manque, ce serait jeter la donnée pour son
// étiquette.
QTest::qWait(5);
QVERIFY2(magasin.enregistrer(revision(QDateTime(), t0, {111, 222, 333})),
"une courbe sans âge connu reste enregistrable");
QCOMPARE(magasin.revisions(t0).count(), 3);
// 4. DEUX SITES NE SE MÊLENT PAS. Sans le site dans la clé, changer de site désigné
// mêlerait deux historiques que rien ne distinguerait plus — et deux plans publient
// des courbes différentes sous le même issuedAt, donc le besoin est réel.
QTest::qWait(5);
QVERIFY(magasin.enregistrer(revision(run00, t0, {100, 200, 300}, QStringLiteral("site-B"))));
QCOMPARE(magasin.revisions(t0).count(), 4); // la même courbe, mais d'un AUTRE site
// 5. Une révision VIDE n'en est pas une : l'absence est un trou, pas une courbe de zéros.
ForecastRevision vide; vide.issuedAt = run00; vide.siteId = QStringLiteral("site-A");
QVERIFY(!magasin.enregistrer(vide));
}
void TestLoadModel::testTheCurveIsReproducibleAndKeepsItsHoles()
{
QTemporaryDir dir;
ForecastStore magasin(dir.filePath("forecast.sqlite"));
QVERIFY(magasin.isValid());
const QDateTime t0 = QDateTime(QDate(2026, 9, 9), QTime(12, 0), QTimeZone::utc());
QVERIFY(magasin.enregistrer(revision(t0, t0, {1000, 1100, 1200})));
const QDateTime avant = QDateTime::currentDateTimeUtc();
QTest::qWait(5); // les instants d'OBSERVATION sont en ms
QVERIFY(magasin.enregistrer(revision(t0, t0, {700, 800, 900})));
// « CE QU'ON PRÉVOYAIT À TEL MOMENT » se demande en fixant l'instant de connaissance. Sans
// ce paramètre, la comparaison prévu/réalisé n'aurait pas d'horizon nommé, et deux écrans
// montreraient deux courbes pour le même jour.
const QList<ForecastPoint> tot = magasin.courbe(t0, t0.addSecs(7200), avant);
QCOMPARE(tot.count(), 3);
QCOMPARE(qRound(tot.first().wh), 1000);
const QList<ForecastPoint> tard = magasin.courbe(t0, t0.addSecs(7200));
QCOMPARE(qRound(tard.first().wh), 700);
// Les DEUX révisions coexistent pour le même créneau — c'est l'objet de la table.
QCOMPARE(magasin.revisions(t0).count(), 2);
// LES TROUS RESTENT DES TROUS : six heures demandées, trois connues, trois rendues. Jamais
// six dont trois à zéro — un zéro se lirait « on prévoit une nuit ».
QCOMPARE(magasin.courbe(t0, t0.addSecs(6 * 3600)).count(), 3);
QVERIFY(magasin.courbe(t0.addDays(5), t0.addDays(5).addSecs(7200)).isEmpty());
// La FRAÎCHEUR n'est jamais absente, et jamais antérieure à l'apparition.
for (const ForecastPoint &p : tard) {
QVERIFY(p.lastSeenAt.isValid());
QVERIFY2(p.lastSeenAt >= p.firstSeenAt,
"une prévision ne peut pas avoir été revue avant d'être apparue");
}
}
void TestLoadModel::testRetentionKeepsBothEndsOfEachRevision()
{
QTemporaryDir dir;
ForecastStore magasin(dir.filePath("forecast.sqlite"));
QVERIFY(magasin.isValid());
const QDateTime cible = QDateTime(QDate(2026, 9, 1), QTime(12, 0), QTimeZone::utc());
for (int i = 0; i < 4; ++i) {
QVERIFY(magasin.enregistrer(revision(cible, cible, {1000.0 + i})));
QTest::qWait(5); // quatre apparitions distinctes
}
QCOMPARE(magasin.revisions(cible).count(), 4);
// Purge bien au-delà de la fenêtre pleine : seuls les DEUX BOUTS survivent — la première
// apparition et la dernière. Les deux répondent aux deux seules questions qui se posent ;
// n'en garder qu'une détruirait la révision, c'est-à-dire l'objet de la table.
QCOMPARE(magasin.purger(cible.addDays(30)), 2);
const QList<QPair<QDateTime, double>> restant = magasin.revisions(cible);
QCOMPARE(restant.count(), 2);
QCOMPARE(qRound(restant.first().second), 1000);
QCOMPARE(qRound(restant.last().second), 1003);
// CONTRE-ÉPREUVE — un créneau RÉCENT garde toutes ses révisions. Sans elle, une purge qui
// viderait tout passerait pour une purge correcte.
const QDateTime recent = QDateTime::currentDateTimeUtc().addSecs(3600);
for (int i = 0; i < 3; ++i) {
QVERIFY(magasin.enregistrer(revision(recent, recent, {500.0 + i})));
QTest::qWait(5);
}
magasin.purger();
QCOMPARE(magasin.revisions(recent).count(), 3);
}
void TestLoadModel::testAPayloadIsRefusedOnFormNeverOnAMissingAttribute()
{
// La charge utile traverse une frontière de PROCESSUS : aucun compilateur ne vérifie sa
// forme. Mais la FORME et les ATTRIBUTS ne se refusent pas de la même façon — refuser sur un
// attribut manquant jetterait la donnée pour son étiquette.
const qint64 t0 = QDateTime(QDate(2026, 9, 9), QTime(0, 0), QTimeZone::utc()).toSecsSinceEpoch();
auto charge = [&](int v, const QString &unite, qint64 age, const QVariantList &p) {
return QVariantMap{{"v", v}, {"t0", t0}, {"step", 3600}, {"unit", unite},
{"convention", "mean over [t, t+step)"},
{"model", "meteofrance_seamless"}, {"issuedAt", age}, {"p", p}};
};
QString refus;
// 1. NOMINAL — la conversion suit la convention LUE : p[i] est une moyenne en W sur le
// créneau, donc l'énergie vaut w x step / 3600, sans supposer que le pas vaut une heure.
const ForecastRevision ok = ForecastStore::depuisTransport(
charge(1, "W", t0, QVariantList{0, 250, 1200}), refus);
QVERIFY2(refus.isEmpty(), qUtf8Printable(refus));
QCOMPARE(ok.points.count(), 3);
QCOMPARE(qRound(ok.points.at(1).wh), 250);
QCOMPARE(ok.points.at(1).targetAt.toSecsSinceEpoch(), t0 + 3600);
// 2. LA FORME se refuse, et le motif la NOMME.
// (v2 est LUE depuis le brief 3 T1 : la version inconnue est désormais la 3.)
QVERIFY(ForecastStore::depuisTransport(charge(3, "W", t0, QVariantList{0, 250}), refus)
.points.isEmpty());
QCOMPARE(refus, QString("VERSION_3"));
QVERIFY(ForecastStore::depuisTransport(charge(1, "Wh", t0, QVariantList{0, 250}), refus)
.points.isEmpty());
QCOMPARE(refus, QString("UNIT_Wh")); // lire des Wh comme des W : plausible et faux
QVERIFY(ForecastStore::depuisTransport(QVariantMap(), refus).points.isEmpty());
QCOMPARE(refus, QString("EMPTY"));
QVERIFY(ForecastStore::depuisTransport(charge(1, "W", t0, QVariantList()), refus).points.isEmpty());
QCOMPARE(refus, QString("MALFORMED"));
// 3. UN ATTRIBUT MANQUANT NE SE REFUSE PAS. `best_match` publie `issuedAt = 0` : la courbe
// est parfaitement exploitable, seul son ÂGE est inconnu — et un âge inconnu se dit par
// une absence, jamais par un refus de la donnée qu'il accompagne.
const ForecastRevision sansAge = ForecastStore::depuisTransport(
charge(1, "W", 0, QVariantList{10, 20, 30}), refus);
QVERIFY2(refus.isEmpty(), "un âge inconnu ne refuse pas la courbe");
QCOMPARE(sansAge.points.count(), 3);
QVERIFY2(!sansAge.issuedAt.isValid(), "et aucun instant n'est INVENTÉ à sa place");
// 4. UN CRÉNEAU ILLISIBLE EST UN TROU, pas un zéro.
const ForecastRevision troue = ForecastStore::depuisTransport(
charge(1, "W", t0, QVariantList{100, QString("bruit"), 300}), refus);
QVERIFY(refus.isEmpty());
QCOMPARE(troue.points.count(), 2);
}
void TestLoadModel::testTransportV2IsReadAndAnUnknownCorrectionRefused()
{
// Brief 3 T1 — `ems` lit v1 ET v2 (prérequis du déploiement d'openmeteo v2). v2 garde tous
// les champs v1 et ajoute `corrections` (liste), `gti[]`, `tempC[]`.
const qint64 t0 = QDateTime(QDate(2026, 10, 25), QTime(0, 0), QTimeZone::utc()).toSecsSinceEpoch();
const QVariantList p{0, 412.5, 1768.25};
auto v1 = [&]() {
return QVariantMap{{"v", 1}, {"t0", t0}, {"step", 3600}, {"unit", "W"},
{"convention", "mean over [t, t+step)"}, {"regime", "x"},
{"model", "meteofrance_seamless"}, {"issuedAt", t0}, {"p", p},
{"corrections", "none"}};
};
auto v2 = [&](const QVariant &corrections) {
QVariantMap m = v1();
m["v"] = 2;
if (corrections.isValid()) m["corrections"] = corrections; else m.remove("corrections");
m["gti"] = QVariantList{0, 500, 1000};
m["tempC"] = QVariantList{5.0, 12.5, QVariant()}; // un null : T2 le lira, pas T1
return m;
};
QString refus;
// 1. v1 : lue comme avant, corrections VIDES — la v1 n'a jamais corrigé, par contrat.
const ForecastRevision a = ForecastStore::depuisTransport(v1(), refus);
QVERIFY2(refus.isEmpty(), qUtf8Printable(refus));
QVERIFY(a.corrections.isEmpty());
QCOMPARE(a.stepS, 3600);
// 2. v2 sans correction : MÊME courbe que v1, au bit près (le brief 2 l'exige du producteur ;
// ici, que le lecteur ne la déforme pas).
const ForecastRevision b = ForecastStore::depuisTransport(v2(QVariantList()), refus);
QVERIFY2(refus.isEmpty(), qUtf8Printable(refus));
QVERIFY(b.corrections.isEmpty());
QCOMPARE(b.points.count(), a.points.count());
for (int i = 0; i < a.points.count(); ++i) {
QCOMPARE(b.points.at(i).targetAt, a.points.at(i).targetAt);
QVERIFY(b.points.at(i).wh == a.points.at(i).wh);
}
// 3. v2 corrigée en température : lue, et la correction est DITE.
const ForecastRevision c = ForecastStore::depuisTransport(v2(QVariantList{"temperature"}), refus);
QVERIFY2(refus.isEmpty(), qUtf8Printable(refus));
QCOMPARE(c.corrections, QStringList{"temperature"});
// 4. Les REFUS, chacun nommé — jamais une lecture au mieux.
QVERIFY(ForecastStore::depuisTransport(v2(QVariant()), refus).points.isEmpty());
QCOMPARE(refus, QString("MALFORMED")); // corrections absente en v2
QVERIFY(ForecastStore::depuisTransport(v2(QString("none")), refus).points.isEmpty());
QCOMPARE(refus, QString("MALFORMED")); // la chaîne v1 n'est plus une forme v2
QVERIFY(ForecastStore::depuisTransport(v2(QVariantList{"temperature", "soiling"}), refus)
.points.isEmpty());
QCOMPARE(refus, QString("CORRECTION_soiling")); // un effet qu'on ne sait pas nommer
QVERIFY(ForecastStore::depuisTransport(v2(QVariantList{"ac-limit"}), refus).points.isEmpty());
QCOMPARE(refus, QString("CORRECTION_ac-limit")); // le plafond est au SITE, pas au plan
QVariantMap sansV = v1(); sansV.remove("v");
QVERIFY(ForecastStore::depuisTransport(sansV, refus).points.isEmpty());
QCOMPARE(refus, QString("VERSION_")); // v absent : jamais deviné
}
void TestLoadModel::testTheSiteCurveIsClippedAfterTheSum()
{
// Brief 3 T1 — plafond AC de SITE, appliqué APRÈS la somme des plans (§28 décision 2).
const QDateTime t0(QDate(2026, 10, 12), QTime(10, 0), QTimeZone::utc());
auto plan = [&](const QList<double> &w, const QStringList &corr, int step = 3600,
const QDateTime &run = QDateTime(QDate(2026, 10, 12), QTime(6, 0), QTimeZone::utc())) {
ForecastRevision r;
r.issuedAt = run; r.stepS = step; r.corrections = corr; r.model = "m";
for (int i = 0; i < w.count(); ++i) {
if (w.at(i) < 0) continue; // < 0 : créneau ABSENT du plan
ForecastPoint p;
p.targetAt = t0.addSecs(qint64(i) * step);
p.wh = w.at(i) * step / 3600.0;
r.points << p;
}
return r;
};
const QStringList T{"temperature"};
const QList<ForecastRevision> deux{plan({1500, 800, -1}, T), plan({1200, 300, 900}, T)};
// 1. SANS plafond : la somme, rien d'autre, et pas d'`ac-limit` annoncé.
ForecastRevision s = ForecastStore::sommerPlans(deux, std::nullopt);
QCOMPARE(s.points.count(), 3);
QCOMPARE(s.points.at(0).wh, 2700.0);
QCOMPARE(s.corrections, T);
// 2. AVEC plafond 2000 W : 2700 → 2000 ; 1100 intact ; et le plafond porte sur la SOMME —
// aucun des deux plans ne dépasse 2000 seul, l'écrêtage par plan n'aurait rien retiré.
bool melangees = true;
s = ForecastStore::sommerPlans(deux, 2000, &melangees);
QCOMPARE(s.points.count(), 3);
QCOMPARE(s.points.at(0).wh, 2000.0);
QCOMPARE(s.points.at(1).wh, 1100.0);
QCOMPARE(s.points.at(2).wh, 900.0); // présent dans UN plan : la somme de ce qui existe
QCOMPARE(s.corrections, (QStringList{"temperature", "ac-limit"}));
QVERIFY(!melangees);
// 3. Le plafond suit le PAS lu : au quart d'heure, 2000 W font 500 Wh.
s = ForecastStore::sommerPlans({plan({3000}, {}, 900)}, 2000);
QCOMPARE(s.points.at(0).wh, 500.0);
QCOMPARE(s.corrections, QStringList{"ac-limit"});
// 4. Un créneau absent RESTE absent : le plafond ne fabrique pas de point (jamais 0).
s = ForecastStore::sommerPlans({plan({-1, 400}, {})}, 2000);
QCOMPARE(s.points.count(), 1);
QCOMPARE(s.points.at(0).targetAt, t0.addSecs(3600));
// 5. Corrections DIVERGENTES : la plus faible commune, et c'est signalé.
s = ForecastStore::sommerPlans({plan({100}, T), plan({100}, {})}, std::nullopt, &melangees);
QVERIFY(s.corrections.isEmpty());
QVERIFY(melangees);
s = ForecastStore::sommerPlans({plan({100}, T), plan({100}, {})}, 5000, &melangees);
QCOMPARE(s.corrections, QStringList{"ac-limit"});
// 6. Runs ou pas différents : RIEN, plutôt qu'un mélange.
QVERIFY(ForecastStore::sommerPlans({plan({100}, T), plan({100}, T, 3600, t0)}, 2000).points.isEmpty());
QVERIFY(ForecastStore::sommerPlans({plan({100}, T), plan({100}, T, 900)}, 2000).points.isEmpty());
QVERIFY(ForecastStore::sommerPlans({}, 2000).points.isEmpty());
}
void TestLoadModel::testTheCurrentCurveIsTheLastSeenNotTheLastNew()
{
// Cas mordu sur .75 le 2026-09-09 : un plan de test ajouté puis retiré fait RÉAPPARAÎTRE un
// contenu déjà connu. Son firstSeenAt ne bouge pas — c'est le principe du champ — si bien
// qu'un tri sur MAX(firstSeenAt) continuait de servir la courbe de l'installation FICTIVE
// quatre minutes après sa disparition.
QTemporaryDir dir;
ForecastStore magasin(dir.filePath("forecast.sqlite"));
QVERIFY(magasin.isValid());
const QDateTime t0 = QDateTime(QDate(2026, 9, 9), QTime(10, 0), QTimeZone::utc());
QVERIFY(magasin.enregistrer(revision(t0, t0, {100, 200}))); // A — un plan
QTest::qWait(5);
QVERIFY(magasin.enregistrer(revision(t0, t0, {180, 360}))); // B — deux plans (fiction)
QTest::qWait(5);
QCOMPARE(qRound(magasin.courbe(t0, t0.addSecs(3600)).first().wh), 180); // B a cours
// LE RETOUR À A : contenu déjà connu, donc AUCUNE ligne neuve — seule la fraîcheur avance.
QVERIFY2(!magasin.enregistrer(revision(t0, t0, {100, 200})),
"un contenu déjà connu ne crée pas de révision, même en revenant");
QCOMPARE(magasin.revisions(t0).count(), 2);
// ET C'EST A QUI A COURS, parce qu'il a été vu EN DERNIER. Avec un tri sur l'apparition, B
// resterait servi indéfiniment : une courbe qui ne décrit plus rien, sans que rien ne le dise.
const QList<ForecastPoint> vue = magasin.courbe(t0, t0.addSecs(3600));
QCOMPARE(vue.count(), 2);
QCOMPARE(qRound(vue.first().wh), 100);
// L'apparition de A reste la PREMIÈRE : le retour ne réécrit pas son identité.
QVERIFY2(vue.first().firstSeenAt < vue.first().lastSeenAt,
"revenir à un contenu avance sa fraîcheur, jamais son apparition");
}
// ============================================================================================
// Brief 3 T2 — l'enregistrement pour l'apprentissage futur
// ============================================================================================
namespace {
const QTimeZone Paris("Europe/Paris");
QDateTime utc(int y, int mo, int d, int h, int mi = 0, int s = 0)
{
return QDateTime(QDate(y, mo, d), QTime(h, mi, s), QTimeZone::utc());
}
//! Lit la table par une connexion À NOUS, pas celle du fil d'écriture : on vérifie ce qui est
//! sur le disque, pas ce que l'écrivain croit avoir écrit.
QList<QVariantMap> lignesApprentissage(const QString &chemin, const QString &where = QString())
{
QList<QVariantMap> sortie;
const QString nom = QStringLiteral("lecture-%1").arg(QUuid::createUuid().toString());
{
QSqlDatabase db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), nom);
db.setDatabaseName(chemin);
if (db.open()) {
QSqlQuery q(db);
if (q.exec(QStringLiteral("SELECT * FROM forecastLearning %1 ORDER BY targetAt, planId")
.arg(where.isEmpty() ? QString() : QStringLiteral("WHERE ") + where))) {
while (q.next()) {
QVariantMap m;
const QSqlRecord r = q.record();
for (int i = 0; i < r.count(); ++i)
m.insert(r.fieldName(i), q.value(i));
sortie << m;
}
}
db.close();
}
}
QSqlDatabase::removeDatabase(nom);
return sortie;
}
bool executer(const QString &chemin, const QStringList &requetes)
{
const QString nom = QStringLiteral("ecriture-%1").arg(QUuid::createUuid().toString());
bool ok = true;
{
QSqlDatabase db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), nom);
db.setDatabaseName(chemin);
ok = db.open();
QSqlQuery q(db);
for (const QString &r : requetes)
ok = ok && q.exec(r);
db.close();
}
QSqlDatabase::removeDatabase(nom);
return ok;
}
//! Une observation de plan, telle que l'arbitre la remet : charge utile + paramètres du Thing.
ForecastPlanObservation planObserve(const QString &id, int v, const QDateTime &t0,
const QVariantList &p, const QVariantMap &params)
{
QVariantMap c{{"v", v}, {"t0", t0.toSecsSinceEpoch()}, {"step", 3600}, {"unit", "W"},
{"convention", "mean over [t, t+step)"}, {"model", "meteofrance_seamless"},
{"issuedAt", t0.toSecsSinceEpoch() - 3600}, {"p", p}};
if (v >= 2) {
c["corrections"] = QVariantList{"temperature"};
QVariantList gti, temp;
for (int i = 0; i < p.count(); ++i) { gti << 100.0 * (i + 1); temp << 10.0 + i; }
temp[1] = QVariant(); // un trou : NULL, jamais 0
c["gti"] = gti; c["tempC"] = temp;
}
QString refus;
ForecastPlanObservation o = ForecastLearningLog::lirePlan(c, params, refus);
o.planId = id;
return o;
}
//! Injecte une minute de production par minute sur ]de, a] — convention nymea, NÉGATIVE.
void minutes(ForecastLearningLog &log, const QDateTime &de, const QDateTime &a, double produitW)
{
for (QDateTime t = de.addSecs(60); t <= a; t = t.addSecs(60))
log.observerProduction(t, -produitW);
}
}
void TestLoadModel::testTheLearningLogWritesOneRowPerElapsedStepAndPlan()
{
QTemporaryDir dir;
const QString chemin = dir.filePath("energy-forecast.sqlite");
ForecastStore magasin(chemin); // même fichier, comme en production
QVERIFY(magasin.isValid());
ForecastLearningLog log(chemin, Paris);
const QDateTime t0 = utc(2026, 10, 13, 9); // grille : 09:00, 10:00, 11:00
const QVariantMap params{{"kwp", 2.0}, {"performanceRatio", 0.9},
{"tempCoeffPctPerK", -0.37}, {"noctC", 45.0}};
const QList<ForecastPlanObservation> plans{
planObserve("{plan-v2}", 2, t0, {300, 1200, 1500}, params),
planObserve("{plan-v1}", 1, t0, {250, 1000, 1400}, params), // MÊMES paramètres posés
};
QCOMPARE(plans.at(0).pW.count(), 3);
QCOMPARE(plans.at(1).pW.count(), 3);
const QString site = QStringLiteral("{site}");
// Premier cycle à 10:05 : le pas de 09:00 est ÉCOULÉ AVANT qu'on regarde — jamais écrit.
log.observer(utc(2026, 10, 13, 10, 5), site, plans, 2, 3000, std::nullopt, true);
minutes(log, utc(2026, 10, 13, 10), utc(2026, 10, 13, 11), 1500);
log.observer(utc(2026, 10, 13, 10, 40), site, plans, 2, 3000, std::nullopt, true);
// 11:01 : le pas est fini, mais la grâce (dernière minute en route) ne l'est pas.
log.observer(utc(2026, 10, 13, 11, 1), site, plans, 2, 3000, std::nullopt, true);
QCOMPARE(log.lotsTraites(), 0);
log.observer(utc(2026, 10, 13, 11, 3), site, plans, 2, 3000, std::nullopt, true);
QTRY_COMPARE(log.lotsTraites(), 1);
QCOMPARE(log.lignesEcrites(), 2);
const QList<QVariantMap> l = lignesApprentissage(chemin);
QCOMPARE(l.count(), 2); // UN pas, DEUX plans — rien d'autre
const QVariantMap v1 = l.at(0), v2 = l.at(1); // tri par planId : v1 < v2
QCOMPARE(v1.value("planId").toString(), QString("{plan-v1}"));
QCOMPARE(v2.value("planId").toString(), QString("{plan-v2}"));
for (const QVariantMap &r : l) {
QCOMPARE(r.value("targetAt").toLongLong(), qint64(1791885600)); // oracle hors Qt
QCOMPARE(r.value("stepS").toInt(), 3600);
QCOMPARE(r.value("siteId").toString(), site);
QCOMPARE(r.value("kwp").toDouble(), 2.0);
QCOMPARE(r.value("performanceRatio").toDouble(), 0.9);
QCOMPARE(r.value("acLimitW").toInt(), 3000);
QCOMPARE(r.value("measuredW").toDouble(), 1500.0); // positive : le site PRODUIT
QCOMPARE(r.value("measuredSource").toString(), QString("power-balance-1min"));
QCOMPARE(r.value("measuredMinutes").toInt(), 60);
QCOMPARE(r.value("curtailed").toInt(), 0); // plafond déclaré, jamais atteint
QVERIFY2(r.value("snow").isNull(), "aucune lecture de neige : NULL, pas « pas de neige »");
QCOMPARE(r.value("sitePlans").toInt(), 2);
QCOMPARE(r.value("sitePlansExpected").toInt(), 2);
QCOMPARE(r.value("valid").toInt(), 1);
// J−1 18:00 à PARIS pour un pas de 10:00Z le 13 : le 12 à 16:00Z (oracle hors Qt).
QCOMPARE(r.value("dayAheadAt").toLongLong(), qint64(1791820800));
QCOMPARE(r.value("issuedAt").toLongLong(), t0.toSecsSinceEpoch() - 3600);
QCOMPARE(r.value("observedAt").toLongLong(), utc(2026, 10, 13, 11, 3).toMSecsSinceEpoch());
}
// v2 : la valeur DU PAS (index 1), pas celle d'un voisin.
QCOMPARE(v2.value("pW").toDouble(), 1200.0);
QCOMPARE(v2.value("gti").toDouble(), 200.0);
QVERIFY2(v2.value("tempC").isNull(), "un tempC null dans la charge utile reste NULL");
QCOMPARE(v2.value("corrections").toString(), QString("temperature"));
QCOMPARE(v2.value("transportV").toInt(), 2);
QCOMPARE(v2.value("tempCoeffPctPerK").toDouble(), -0.37);
QCOMPARE(v2.value("noctC").toDouble(), 45.0);
// v1 : gti, tempC et paramètres v2 NULL — JAMAIS 0 — alors que le Thing les porte.
QCOMPARE(v1.value("pW").toDouble(), 1000.0);
QCOMPARE(v1.value("transportV").toInt(), 1);
QCOMPARE(v1.value("corrections").toString(), QString());
for (const char *c : {"gti", "tempC", "tempCoeffPctPerK", "noctC"})
QVERIFY2(v1.value(c).isNull(), c);
// Le pas de 11:00 n'est pas écoulé : rien.
QCOMPARE(lignesApprentissage(chemin, "targetAt = 1791889200").count(), 0);
// Les cycles SUIVANTS revoient le pas de 10:00 dans la charge utile — elle garde les heures
// passées du jour. Un fait écrit ne se reprend pas : aucun lot, aucune ligne de journal par
// minute. (Trouvé par le test de la table abîmée : sans filigrane, chaque cycle le réécrivait.)
log.observer(utc(2026, 10, 13, 11, 10), site, plans, 2, 3000, std::nullopt, true);
log.observer(utc(2026, 10, 13, 11, 20), site, plans, 2, 3000, std::nullopt, true);
QTest::qWait(100);
QCOMPARE(log.lotsTraites(), 1);
// Un SECOND enregistreur sur le même fichier — un redémarrage qui revoit la même heure —
// ne réécrit pas le fait clos : la première écriture, qui a vu le pas, l'emporte.
{
ForecastLearningLog relance(chemin, Paris);
QList<ForecastPlanObservation> autres = plans;
autres[0].pW[1] = 9999.0;
relance.observer(utc(2026, 10, 13, 10, 59), site, autres, 2, 3000, std::nullopt, true);
relance.observer(utc(2026, 10, 13, 11, 3), site, autres, 2, 3000, std::nullopt, true);
QTRY_COMPARE(relance.lotsTraites(), 1);
QCOMPARE(relance.lignesEcrites(), 0);
}
QCOMPARE(lignesApprentissage(chemin, "planId = '{plan-v2}'").first().value("pW").toDouble(), 1200.0);
}
void TestLoadModel::testTheLearningLogReadsMeasureCurtailmentAndSnow()
{
const QDateTime t0 = utc(2026, 10, 13, 10);
const QVariantMap params{{"kwp", 2.0}, {"performanceRatio", 0.9}};
struct Cas {
const char *nom;
int minutesRecues; // minutes injectées à partir de 10:01
double produitW; // production de chaque minute
double picW; // production de la minute 10:30 (le pic)
std::optional<int> plafond;
std::optional<double> neige;
bool producteur;
int plansVus, plansAttendus;
// attendus
bool mesure; QVariant curtailed; QVariant snow; int valid;
};
const QList<Cas> cas{
// couverture complète, plafond non atteint : la référence
{"nominal", 60, 1200, 1200, 3000, 0.0, true, 1, 1, true, 0, 0, 1},
// 30 minutes sur 60 : pas de moyenne, et l'écrêtage n'a pas pu être cherché
{"couverture partielle", 30, 1200, 1200, 3000, 0.0, true, 1, 1, false, {}, 0, 0},
// 54 minutes sur 60 = 90 % : publiée, avec le compte
{"couverture au seuil", 54, 1200, 1200, 3000, 0.0, true, 1, 1, true, 0, 0, 1},
// une minute à 98 % du plafond : l'onduleur bornait, la mesure sous-estime
{"écrêtage détecté", 60, 1200, 2941, 3000, 0.0, true, 1, 1, true, 1, 0, 0},
// juste sous le seuil : rien de DÉTECTABLE
{"sous le seuil", 60, 1200, 2930, 3000, 0.0, true, 1, 1, true, 0, 0, 1},
// sans plafond déclaré, rien n'est cherché : NULL, jamais « non écrêté »
{"sans plafond", 60, 1200, 2999, {}, 0.0, true, 1, 1, true, {}, 0, 1},
// sans producteur, la « production » est un zéro de construction : NULL
{"sans producteur", 60, 1200, 1200, 3000, 0.0, false, 1, 1, false, {}, 0, 0},
// une chute de neige lue pendant le pas
{"neige", 60, 1200, 1200, 3000, 0.4, true, 1, 1, true, 0, 1, 0},
// le site n'a pas d'état snowfall : NULL
{"neige non lue", 60, 1200, 1200, 3000, {}, true, 1, 1, true, 0, {}, 1},
// un plan du site refusé : la somme mesurée n'est pas comparable à ce plan seul
{"plan manquant", 60, 1200, 1200, 3000, 0.0, true, 1, 2, true, 0, 0, 0},
};
for (const Cas &c : cas) {
QTemporaryDir dir;
const QString chemin = dir.filePath("energy-forecast.sqlite");
ForecastLearningLog log(chemin, Paris);
const QList<ForecastPlanObservation> plans{
planObserve("{p}", 2, t0, {1000, 1000}, params)};
log.observer(t0.addSecs(30), "{s}", plans, c.plansAttendus, c.plafond, c.neige, c.producteur);
for (int m = 1; m <= c.minutesRecues; ++m)
log.observerProduction(t0.addSecs(60 * m), -(m == 30 ? c.picW : c.produitW));
log.observer(t0.addSecs(1800), "{s}", plans, c.plansAttendus, c.plafond, c.neige, c.producteur);
log.observer(t0.addSecs(3600 + 150), "{s}", plans, c.plansAttendus, c.plafond, std::nullopt,
c.producteur);
QTRY_COMPARE(log.lotsTraites(), 1);
const QList<QVariantMap> l = lignesApprentissage(chemin);
QVERIFY2(l.count() == 1, c.nom);
const QVariantMap r = l.first();
QVERIFY2(r.value("measuredMinutes").toInt() == c.minutesRecues, c.nom);
QVERIFY2(r.value("measuredW").isNull() == !c.mesure, c.nom);
QVERIFY2(r.value("measuredSource").isNull() == !c.mesure, c.nom);
QVERIFY2(r.value("curtailed").isNull() == c.curtailed.isNull()
&& (c.curtailed.isNull() || r.value("curtailed").toInt() == c.curtailed.toInt()),
qPrintable(QStringLiteral("%1 : curtailed %2").arg(c.nom, r.value("curtailed").toString())));
QVERIFY2(r.value("snow").isNull() == c.snow.isNull()
&& (c.snow.isNull() || r.value("snow").toInt() == c.snow.toInt()), c.nom);
QVERIFY2(r.value("valid").toInt() == c.valid, c.nom);
QVERIFY2(r.value("sitePlans").toInt() == c.plansVus, c.nom);
// Une v2 dont le Thing ne porte PAS encore tempCoeff/noct (openmeteo d'avant le brief 2) :
// NULL, jamais un défaut inventé ici.
QVERIFY2(r.value("tempCoeffPctPerK").isNull() && r.value("noctC").isNull(), c.nom);
QVERIFY2(r.value("kwp").toDouble() == 2.0, c.nom);
}
}
void TestLoadModel::testTheLearningLogPointsAtTheRevisionsInForce()
{
// La table RENVOIE aux révisions du site par leur CLÉ DE CONTENU (§20), jamais par
// `issuedAt`. Les révisions sont posées à la main, avec des instants d'observation choisis :
// c'est la règle « en vigueur à X » qu'on éprouve, pas l'horloge.
QTemporaryDir dir;
const QString chemin = dir.filePath("energy-forecast.sqlite");
{ ForecastStore magasin(chemin); QVERIFY(magasin.isValid()); } // crée forecastPv
const QString site = QStringLiteral("{site}");
const qint64 pas10 = utc(2026, 10, 13, 10).toSecsSinceEpoch();
const qint64 pas9 = utc(2026, 10, 13, 9).toSecsSinceEpoch();
auto ms = [](const QDateTime &d) { return QString::number(d.toMSecsSinceEpoch()); };
auto rev = [&](qint64 cible, const QString &id, const QDateTime &premier, const QDateTime &dernier,
const QString &s = QStringLiteral("{site}")) {
return QStringLiteral("INSERT INTO forecastPv (targetAt, siteId, contentId, firstSeenAt, "
"lastSeenAt, forecastWh) VALUES (%1, '%2', '%3', %4, %5, 1000)")
.arg(cible).arg(s, id, ms(premier), ms(dernier));
};
QVERIFY(executer(chemin, {
// Pas de 10:00. Veille = 12/10 16:00Z ; H−1 = 13/10 09:00Z.
// A : vue de 06:00 la veille, REVUE le 13 à 07:00 (un retour, cas du §23.2).
rev(pas10, "A", utc(2026, 10, 12, 6), utc(2026, 10, 13, 7)),
// B : apparue 14:30 la veille, vue jusqu'à 17:00 — c'est ELLE qui avait cours à 16:00.
rev(pas10, "B", utc(2026, 10, 12, 14, 30), utc(2026, 10, 12, 17)),
// C : apparue 08:30 le 13, vue jusqu'à 11:00 — en vigueur à 09:00.
rev(pas10, "C", utc(2026, 10, 13, 8, 30), utc(2026, 10, 13, 11)),
// Un AUTRE site, apparu plus tard : ne doit jamais être désigné.
rev(pas10, "Z", utc(2026, 10, 13, 8, 45), utc(2026, 10, 13, 11), "{autre}"),
// Pas de 09:00. H−1 = 08:00Z : AUCUNE révision n'était vue à cet instant (B finit à
// 07:00, C commence à 08:30) — la dernière vue avant, B, est celle qui avait cours.
rev(pas9, "B9", utc(2026, 10, 12, 14), utc(2026, 10, 13, 7)),
rev(pas9, "C9", utc(2026, 10, 13, 8, 30), utc(2026, 10, 13, 10)),
}));
ForecastLearningLog log(chemin, Paris);
const QDateTime t0 = utc(2026, 10, 13, 9);
const QList<ForecastPlanObservation> plans{
planObserve("{p}", 1, t0, {800, 900, 1000}, {{"kwp", 2.0}})};
log.observer(utc(2026, 10, 13, 9, 30), site, plans, 1, std::nullopt, std::nullopt, true);
log.observer(utc(2026, 10, 13, 11, 5), site, plans, 1, std::nullopt, std::nullopt, true);
QTRY_COMPARE(log.lotsTraites(), 1);
const QList<QVariantMap> l = lignesApprentissage(chemin);
QCOMPARE(l.count(), 2);
QCOMPARE(l.at(0).value("targetAt").toLongLong(), pas9);
QCOMPARE(l.at(0).value("contentIdHourAhead").toString(), QString("B9"));
// Veille du pas de 09:00 = 12/10 16:00Z : seule B9 était apparue (14:00) et encore vue.
QCOMPARE(l.at(0).value("contentIdDayAhead").toString(), QString("B9"));
QCOMPARE(l.at(1).value("targetAt").toLongLong(), pas10);
// `MAX(lastSeenAt)` — la règle de la courbe COURANTE — aurait rendu A ici.
QCOMPARE(l.at(1).value("contentIdDayAhead").toString(), QString("B"));
QCOMPARE(l.at(1).value("contentIdHourAhead").toString(), QString("C"));
// Aucune révision pour un pas : NULL — jamais un identifiant deviné.
QTemporaryDir vide;
const QString cheminVide = vide.filePath("energy-forecast.sqlite");
ForecastLearningLog log2(cheminVide, Paris);
log2.observer(utc(2026, 10, 13, 9, 30), site, plans, 1, std::nullopt, std::nullopt, true);
log2.observer(utc(2026, 10, 13, 10, 5), site, plans, 1, std::nullopt, std::nullopt, true);
QTRY_COMPARE(log2.lotsTraites(), 1);
const QList<QVariantMap> l2 = lignesApprentissage(cheminVide);
QCOMPARE(l2.count(), 1);
QVERIFY(l2.first().value("contentIdDayAhead").isNull());
QVERIFY(l2.first().value("contentIdHourAhead").isNull());
// « J−1 18:00 locale » autour des changements d'heure — oracles calculés hors de Qt.
QCOMPARE(ForecastLearningLog::instantVeille(utc(2026, 10, 12, 22, 30), Paris).toSecsSinceEpoch(),
qint64(1791820800)); // 00:30 locale le 13 : la veille est le 12, pas le 11
QCOMPARE(ForecastLearningLog::instantVeille(utc(2026, 10, 25, 10), Paris).toSecsSinceEpoch(),
qint64(1792857600)); // jour de 25 h : la veille est encore en heure d'été
QCOMPARE(ForecastLearningLog::instantVeille(utc(2026, 10, 25, 23, 30), Paris).toSecsSinceEpoch(),
qint64(1792947600)); // le 26 local : 18:00 du 25, déjà en heure d'hiver
QCOMPARE(ForecastLearningLog::instantVeille(utc(2026, 3, 29, 10), Paris).toSecsSinceEpoch(),
qint64(1774717200)); // jour de 23 h : la veille est encore en heure d'hiver
}
void TestLoadModel::testTheLearningLogSurvivesADamagedTable()
{
const QDateTime t0 = utc(2026, 10, 13, 10);
const QList<ForecastPlanObservation> plans{
planObserve("{p}", 1, t0, {800, 900, 1000}, {{"kwp", 2.0}})};
auto pas = [&](ForecastLearningLog &log, int i) {
const QDateTime d = t0.addSecs(3600 * i);
log.observer(d.addSecs(60), "{s}", plans, 1, std::nullopt, std::nullopt, true);
log.observer(d.addSecs(3600 + 150), "{s}", plans, 1, std::nullopt, std::nullopt, true);
};
// 1. UNE TABLE DE FORME INATTENDUE — une version future, une écriture étrangère : REFAITE.
{
QTemporaryDir dir;
const QString chemin = dir.filePath("energy-forecast.sqlite");
QVERIFY(executer(chemin, {"CREATE TABLE forecastLearning (x INTEGER, y TEXT)",
"INSERT INTO forecastLearning VALUES (1, 'étranger')"}));
ForecastLearningLog log(chemin, Paris);
pas(log, 0);
QTRY_COMPARE(log.lotsTraites(), 1);
QVERIFY(log.isValid());
const QList<QVariantMap> l = lignesApprentissage(chemin);
QCOMPARE(l.count(), 1);
QVERIFY(l.first().contains("contentIdDayAhead")); // la forme attendue, refaite
// 2. LA TABLE DISPARAÎT entre deux lots : recréée à l'écriture, lot REJOUÉ, rien perdu
// de ce lot-là — et le service n'a rien vu.
QVERIFY(executer(chemin, {"DROP TABLE forecastLearning"}));
pas(log, 1);
QTRY_COMPARE(log.lotsTraites(), 2);
QCOMPARE(lignesApprentissage(chemin).count(), 1);
QCOMPARE(log.lignesEcrites(), 2);
// 3. RÉTENTION 400 jours — sur l'instant du CYCLE, pas sur l'horloge murale.
const qint64 vieux = t0.addDays(-401).toSecsSinceEpoch();
const qint64 garde = t0.addDays(-399).toSecsSinceEpoch();
QString ins = QStringLiteral(
"INSERT INTO forecastLearning (targetAt, stepS, siteId, planId, corrections, transportV, "
"observedAt, sitePlans, sitePlansExpected, dayAheadAt, measuredMinutes, valid, writtenAt) "
"VALUES (%1, 3600, '{s}', '{p}', '', 1, 0, 1, 1, 0, 0, 0, 0)");
QVERIFY(executer(chemin, {ins.arg(vieux), ins.arg(garde)}));
pas(log, 2);
QTRY_COMPARE(log.lotsTraites(), 3);
QCOMPARE(lignesApprentissage(chemin, QStringLiteral("targetAt = %1").arg(vieux)).count(), 0);
QCOMPARE(lignesApprentissage(chemin, QStringLiteral("targetAt = %1").arg(garde)).count(), 1);
}
// 4. UNE TABLE ABÎMÉE sur le disque : la vérification à l'ouverture la voit, elle est refaite.
{
QTemporaryDir dir;
const QString chemin = dir.filePath("energy-forecast.sqlite");
QStringList remplir{"PRAGMA page_size = 4096", QString::fromLatin1(
"CREATE TABLE forecastLearning (targetAt INTEGER NOT NULL, stepS INTEGER NOT NULL, "
"siteId TEXT NOT NULL, planId TEXT NOT NULL, gti REAL, tempC REAL, pW REAL, "
"corrections TEXT NOT NULL, transportV INTEGER NOT NULL, kwp REAL, performanceRatio REAL, "
"tempCoeffPctPerK REAL, noctC REAL, acLimitW INTEGER, issuedAt INTEGER, model TEXT, "
"observedAt INTEGER NOT NULL, sitePlans INTEGER NOT NULL, sitePlansExpected INTEGER NOT NULL, "
"dayAheadAt INTEGER NOT NULL, contentIdDayAhead TEXT, contentIdHourAhead TEXT, "
"measuredW REAL, measuredSource TEXT, measuredMinutes INTEGER NOT NULL, curtailed INTEGER, "
"snow INTEGER, valid INTEGER NOT NULL, writtenAt INTEGER NOT NULL, "
"PRIMARY KEY (targetAt, siteId, planId))")};
for (int i = 0; i < 400; ++i)
remplir << QStringLiteral(
"INSERT INTO forecastLearning (targetAt, stepS, siteId, planId, corrections, "
"transportV, observedAt, sitePlans, sitePlansExpected, dayAheadAt, measuredMinutes, "
"valid, writtenAt) VALUES (%1, 3600, '{s}', '{p%2}', '', 1, 0, 1, 1, 0, 0, 0, 0)")
.arg(i).arg(i);
QVERIFY(executer(chemin, remplir));
// On écrase une page de DONNÉES de la table — pas l'en-tête du fichier, ni le schéma.
QFile f(chemin);
QVERIFY(f.open(QIODevice::ReadWrite));
QVERIFY(f.size() >= 4 * 4096);
f.seek(3 * 4096);
f.write(QByteArray(4096, char(0x5a)));
f.close();
ForecastLearningLog log(chemin, Paris);
pas(log, 0);
QTRY_COMPARE(log.lotsTraites(), 1);
QVERIFY(log.isValid());
QCOMPARE(log.lignesEcrites(), 1);
const QList<QVariantMap> l = lignesApprentissage(chemin);
QCOMPARE(l.count(), 1); // l'ancien contenu est perdu, et dit
QCOMPARE(l.first().value("planId").toString(), QString("{p}"));
}
// 5. UN FICHIER QUI N'EST PAS UNE BASE : l'enregistrement s'arrête, rien ne plante, le
// cycle continue d'appeler sans effet.
{
QTemporaryDir dir;
const QString chemin = dir.filePath("energy-forecast.sqlite");
QFile f(chemin);
QVERIFY(f.open(QIODevice::WriteOnly));
f.write(QByteArray(8192, char(0x42)));
f.close();
ForecastLearningLog log(chemin, Paris);
pas(log, 0);
QTRY_COMPARE(log.lotsTraites(), 1);
QVERIFY(!log.isValid());
QCOMPARE(log.lignesEcrites(), 0);
}
}
void TestLoadModel::testTheLearningLogNeverBlocksTheCycle()
{
// La base est VERROUILLÉE en écriture par une autre connexion pendant que le cycle remet un
// lot. L'appel rend la main tout de suite ; la ligne arrive quand le verrou tombe.
QTemporaryDir dir;
const QString chemin = dir.filePath("energy-forecast.sqlite");
const QDateTime t0 = utc(2026, 10, 13, 10);
const QList<ForecastPlanObservation> plans{
planObserve("{p}", 1, t0, {800, 900}, {{"kwp", 2.0}})};
ForecastLearningLog log(chemin, Paris);
log.observer(t0.addSecs(60), "{s}", plans, 1, std::nullopt, std::nullopt, true);
// Le fil a ouvert la base et créé la table : sinon le verrou ci-dessous gênerait l'ouverture,
// pas l'écriture, et le test mesurerait autre chose.
QTRY_VERIFY(executer(chemin, {"SELECT 1 FROM forecastLearning LIMIT 0"}));
const QString nom = QStringLiteral("verrou");
{
QSqlDatabase db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), nom);
db.setDatabaseName(chemin);
QVERIFY(db.open());
QSqlQuery q(db);
QVERIFY(q.exec(QStringLiteral("BEGIN EXCLUSIVE")));
QElapsedTimer chrono;
chrono.start();
log.observer(t0.addSecs(3600 + 150), "{s}", plans, 1, std::nullopt, std::nullopt, true);
const qint64 duree = chrono.elapsed();
QVERIFY2(duree < 50, qPrintable(QStringLiteral("observer() a bloqué %1 ms").arg(duree)));
QTest::qWait(300);
QCOMPARE(log.lignesEcrites(), 0); // le fil attend le verrou, pas nous
QVERIFY(q.exec(QStringLiteral("COMMIT")));
db.close();
}
QSqlDatabase::removeDatabase(nom);
QTRY_COMPARE(log.lignesEcrites(), 1);
}
namespace {
//! LA TABLE etm67, TELLE QUE SQLite L'A ENREGISTRÉE dans `sqlite_master` — la chaîne exacte de
//! `Creation` à b7317f3 (1.15.2+etm67, déployée sur le banc). Elle sert à PROUVER que la base
//! de référence ci-dessous est bien une base etm67, pas une imitation qui lui ressemble.
const char *SchemaEtm67 =
"CREATE TABLE forecastLearning ( targetAt INTEGER NOT NULL, stepS "
"INTEGER NOT NULL, siteId TEXT NOT NULL, planId TEXT NOT NULL,"
" gti REAL, tempC REAL, pW REAL, corrections "
" TEXT NOT NULL, transportV INTEGER NOT NULL, kwp REAL, "
"performanceRatio REAL, tempCoeffPctPerK REAL, noctC REAL, acLimitW "
" INTEGER, issuedAt INTEGER, model TEXT, observedAt INTEGER "
"NOT NULL, sitePlans INTEGER NOT NULL, sitePlansExpected INTEGER NOT NULL, "
"dayAheadAt INTEGER NOT NULL, contentIdDayAhead TEXT, contentIdHourAhead TEXT, "
"measuredW REAL, measuredSource TEXT, measuredMinutes INTEGER NOT NULL, "
"curtailed INTEGER, snow INTEGER, valid INTEGER NOT NULL,"
" writtenAt INTEGER NOT NULL, PRIMARY KEY (targetAt, siteId, planId))";
QStringList colonnesApprentissage(const QString &chemin)
{
QStringList sortie;
const QString nom = QStringLiteral("colonnes-%1").arg(QUuid::createUuid().toString());
{
QSqlDatabase db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), nom);
db.setDatabaseName(chemin);
if (db.open()) {
QSqlQuery q(db);
if (q.exec(QStringLiteral("PRAGMA table_info(forecastLearning)")))
while (q.next())
sortie << q.value(1).toString();
db.close();
}
}
QSqlDatabase::removeDatabase(nom);
return sortie;
}
QStringList tables(const QString &chemin)
{
QStringList sortie;
const QString nom = QStringLiteral("tables-%1").arg(QUuid::createUuid().toString());
{
QSqlDatabase db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), nom);
db.setDatabaseName(chemin);
if (db.open()) {
QSqlQuery q(db);
if (q.exec(QStringLiteral("SELECT name, sql FROM sqlite_master WHERE type = 'table' "
"ORDER BY name")))
while (q.next())
sortie << q.value(0).toString() + QLatin1Char('\t') + q.value(1).toString();
db.close();
}
}
QSqlDatabase::removeDatabase(nom);
return sortie;
}
}
void TestLoadModel::testTheLearningLogMigratesAnEtm67TableInPlace()
{
// UNE BASE ÉCRITE PAR LE CODE etm67 LUI-MÊME (b7317f3, test de simulation du câblage), pas
// reconstituée à la main : c'est elle que le banc porte depuis 09:24 le 2026-10-13.
QTemporaryDir dir;
const QString chemin = dir.filePath("energy-forecast.sqlite");
QVERIFY(QFile::copy(QStringLiteral(":/data/energy-forecast-etm67.sqlite"), chemin));
QVERIFY(QFile::setPermissions(chemin, QFile::ReadOwner | QFile::WriteOwner));
const QStringList avantTables = tables(chemin);
QVERIFY2(avantTables.contains(QStringLiteral("forecastLearning\t") + QString::fromLatin1(SchemaEtm67)),
qPrintable(avantTables.join('\n')));
const QList<QVariantMap> avant = lignesApprentissage(chemin);
QVERIFY2(avant.count() >= 2, "la base de référence doit porter des lignes etm67");
QCOMPARE(avant.first().count(), 29);
{
ForecastLearningLog log(chemin, Paris);
// L'ouverture migre : on attend les colonnes par `PRAGMA table_info`, dont la forme ne
// change pas. Un `SELECT *` préparé PENDANT l'ALTER fait planter le pilote Qt SQLite
// (29 colonnes à la préparation, 32 à la lecture) — vu une fois ; aucun lecteur de cette
// table n'existe hors des tests.
QTRY_VERIFY(colonnesApprentissage(chemin).contains(QStringLiteral("gtiObservedAt")));
QVERIFY(log.isValid());
const QList<QVariantMap> apres = lignesApprentissage(chemin);
QCOMPARE(apres.count(), avant.count()); // AUCUNE ligne perdue
for (int i = 0; i < avant.count(); ++i) {
QCOMPARE(apres.at(i).count(), 32);
for (auto c = avant.at(i).constBegin(); c != avant.at(i).constEnd(); ++c)
QVERIFY2(apres.at(i).value(c.key()) == c.value(), qPrintable(c.key()));
for (const char *c : {"gtiObserved", "gtiObservedSource", "gtiObservedAt"})
QVERIFY2(apres.at(i).value(c).isNull(), c); // inconnu, JAMAIS 0
}
// Migrée, pas refaite ni mise de côté : une seule table forecastLearning, aucune autre.
int apprentissage = 0;
for (const QString &t : tables(chemin))
apprentissage += t.startsWith(QLatin1String("forecastLearning")) ? 1 : 0;
QCOMPARE(apprentissage, 1);
// Et la table migrée s'ÉCRIT : un pas neuf, observation comprise.
const QDateTime t0 = utc(2027, 1, 5, 10);
QList<ForecastPlanObservation> plans{planObserve("{p}", 1, t0, {800, 900}, {{"kwp", 2.0}})};
log.observer(t0.addSecs(60), "{s}", plans, 1, std::nullopt, std::nullopt, true);
plans[0].gtiObserved = 310.0;
plans[0].gtiObservedAt = t0.addSecs(3600).toSecsSinceEpoch();
// Le pas est encore en tampon à ce cycle : l'observation entre par l'INSERT.
log.observer(t0.addSecs(3600 + 150), "{s}", plans, 1, std::nullopt, std::nullopt, true);
QTRY_COMPARE(log.lotsTraites(), 1);
const QList<QVariantMap> neuve =
lignesApprentissage(chemin, QStringLiteral("targetAt = %1").arg(t0.toSecsSinceEpoch()));
QCOMPARE(neuve.count(), 1);
QCOMPARE(neuve.first().value("gtiObserved").toDouble(), 310.0);
}
// Un second démarrage sur la table migrée : forme attendue, rien à faire, rien de perdu.
{
ForecastLearningLog relance(chemin, Paris);
QTest::qWait(200);
QVERIFY(relance.isValid());
QCOMPARE(lignesApprentissage(chemin).count(), avant.count() + 1);
}
}
void TestLoadModel::testTheLearningLogAttachesTheObservedGtiToItsStep()
{
QTemporaryDir dir;
const QString chemin = dir.filePath("energy-forecast.sqlite");
const QString site = QStringLiteral("{s}");
const QDateTime t0 = utc(2026, 10, 13, 9); // grille : 09, 10, 11, 12, 13 h
QList<ForecastPlanObservation> plans{
planObserve("{p}", 2, t0, {300, 1200, 1500, 1400, 900}, {{"kwp", 2.0}})};
auto observe = [&](double gti, const QDateTime &fin) {
plans[0].gtiObserved = gti;
plans[0].gtiObservedAt = fin.toSecsSinceEpoch();
};
auto ligne = [&](const QDateTime &debut) {
const QList<QVariantMap> l =
lignesApprentissage(chemin, QStringLiteral("targetAt = %1").arg(debut.toSecsSinceEpoch()));
return l.isEmpty() ? QVariantMap() : l.first();
};
auto cycle = [&](ForecastLearningLog &log, const QDateTime &n) {
log.observer(n, site, plans, 1, 3000, std::nullopt, true);
};
ForecastLearningLog log(chemin, Paris);
// 1. observedGtiTime == 0 — le défaut de l'état : RIEN, même si la valeur vaut 0.
observe(0.0, QDateTime::fromSecsSinceEpoch(0, QTimeZone::utc()));
cycle(log, utc(2026, 10, 13, 10, 5)); // premier cycle : le pas de 09 h est perdu
minutes(log, utc(2026, 10, 13, 10), utc(2026, 10, 13, 11), 1100);
cycle(log, utc(2026, 10, 13, 11, 3)); // écrit le pas de 10 h
QTRY_COMPARE(log.lotsTraites(), 1);
QTest::qWait(50);
QCOMPARE(log.complementsTraites(), 0);
const QVariantMap avant = ligne(utc(2026, 10, 13, 10));
QVERIFY(!avant.isEmpty());
for (const char *c : {"gtiObserved", "gtiObservedSource", "gtiObservedAt"})
QVERIFY2(avant.value(c).isNull(), c);
QCOMPARE(avant.value("valid").toInt(), 1);
// 2. LE RETARD : la valeur de 11:00 — moyenne de (10:00, 11:00] — arrive à 12:30, la ligne
// du pas de 10 h est écrite depuis 11:03. Elle la COMPLÈTE, elle et rien d'autre.
minutes(log, utc(2026, 10, 13, 11), utc(2026, 10, 13, 12), 1100);
observe(420.0, utc(2026, 10, 13, 11));
cycle(log, utc(2026, 10, 13, 12, 30)); // écrit aussi le pas de 11 h
QTRY_COMPARE(log.lotsTraites(), 2);
QTRY_VERIFY2(!ligne(utc(2026, 10, 13, 10)).value("gtiObserved").isNull(),
"la valeur publiée pour 11:00 moyenne 10:00-11:00 : elle va au pas de 10 h");
QCOMPARE(log.complementsTraites(), 1);
QCOMPARE(log.gtiObservesCompletes(), 1);
const QVariantMap apres = ligne(utc(2026, 10, 13, 10));
QCOMPARE(apres.value("gtiObserved").toDouble(), 420.0);
QCOMPARE(apres.value("gtiObservedSource").toString(), QString("openmeteo-satellite-state"));
QCOMPARE(apres.value("gtiObservedAt").toLongLong(), utc(2026, 10, 13, 11).toSecsSinceEpoch());
for (auto c = avant.constBegin(); c != avant.constEnd(); ++c) {
if (!c.key().startsWith(QLatin1String("gtiObserved")))
QVERIFY2(apres.value(c.key()) == c.value(),
qPrintable(QStringLiteral("%1 réécrit par le complément").arg(c.key())));
}
// Le pas de 11 h — celui que DÉSIGNERAIT l'instant publié sans le décalage — n'a rien reçu.
const QVariantMap onze = ligne(utc(2026, 10, 13, 11));
QVERIFY(!onze.isEmpty());
QVERIFY2(onze.value("gtiObserved").isNull(), "la valeur de 11:00 moyenne 10:00-11:00, pas 11:00-12:00");
// 3. L'état est relu à chaque cycle : la MÊME publication n'est pas renvoyée.
cycle(log, utc(2026, 10, 13, 12, 40));
QTest::qWait(100);
QCOMPARE(log.complementsTraites(), 1);
// 4. LA NUIT : 0 W/m² PUBLIÉ est une observation, pas une absence.
observe(0.0, utc(2026, 10, 13, 12));
cycle(log, utc(2026, 10, 13, 12, 50));
QTRY_COMPARE(log.complementsTraites(), 2);
QVERIFY(!ligne(utc(2026, 10, 13, 11)).value("gtiObserved").isNull());
QCOMPARE(ligne(utc(2026, 10, 13, 11)).value("gtiObserved").toDouble(), 0.0);
// 5. Une observation dont le pas n'a PAS de ligne (09 h, écoulé avant le premier cycle) :
// rien n'est créé.
observe(150.0, utc(2026, 10, 13, 10));
cycle(log, utc(2026, 10, 13, 12, 55));
QTRY_COMPARE(log.complementsTraites(), 3);
QCOMPARE(log.gtiObservesCompletes(), 2);
QVERIFY(ligne(utc(2026, 10, 13, 9)).isEmpty());
// 6. Le pas est ENCORE en tampon (fin 13:00, grâce jusqu'à 13:02) : l'observation entre dans
// la ligne avant qu'elle soit écrite — par l'INSERT, sans complément.
minutes(log, utc(2026, 10, 13, 12), utc(2026, 10, 13, 13), 1100);
observe(555.0, utc(2026, 10, 13, 13));
cycle(log, utc(2026, 10, 13, 13, 1));
cycle(log, utc(2026, 10, 13, 13, 3));
QTRY_COMPARE(log.lotsTraites(), 3);
QCOMPARE(ligne(utc(2026, 10, 13, 12)).value("gtiObserved").toDouble(), 555.0);
QCOMPARE(log.complementsTraites(), 3);
// 7. UNE VALEUR ÉCRITE N'EST JAMAIS REMPLACÉE — un redémarrage relit l'état mis en cache,
// revoit l'instant de 11:00 avec une autre valeur : la ligne garde la sienne.
{
ForecastLearningLog relance(chemin, Paris);
observe(999.0, utc(2026, 10, 13, 11));
relance.observer(utc(2026, 10, 13, 13, 10), site, plans, 1, 3000, std::nullopt, true);
QTRY_COMPARE(relance.complementsTraites(), 1);
QCOMPARE(relance.gtiObservesCompletes(), 0);
}
QCOMPARE(ligne(utc(2026, 10, 13, 10)).value("gtiObserved").toDouble(), 420.0);
QCOMPARE(ligne(utc(2026, 10, 13, 10)).value("valid").toInt(), 1); // valid n'en dépend pas
}
// ============================================================================================
// Brief 3 T4 — le tarif saisi par l'installateur
// ============================================================================================
namespace {
QVariantMap zoneTarif(const QString &label, const QVariant &prix,
const QList<QPair<QString, QString>> &plages)
{
QVariantList r;
for (const auto &p : plages)
r << QVariantMap{{"from", p.first}, {"to", p.second}};
return QVariantMap{{"label", label}, {"priceCtkWh", prix}, {"ranges", r}};
}
QVariantMap saisieTarif(const QVariantList &zones)
{
return QVariantMap{{"zones", zones}};
}
//! HP/HC « 22:00-06:00 » à 15, le reste à 25 — le cas d'école des oracles.
QVariantMap hphc()
{
return saisieTarif({zoneTarif("HC", 15.0, {{"22:00", "06:00"}}),
zoneTarif("HP", 25.0, {{"06:00", "22:00"}})});
}
}
void TestLoadModel::testTheTariffIsValidatedStrictly()
{
QString cause;
QVariantMap ou;
// 1. ACCEPTÉS — le tarif unique, les HP/HC, l'HC en DEUX fenêtres.
TariffConfig t = TariffConfig::fromVariant(
saisieTarif({zoneTarif("Base", 20.16, {{"00:00", "24:00"}})}), cause, ou);
QVERIFY2(cause.isEmpty(), qPrintable(cause));
QCOMPARE(t.source(), QStringLiteral("fixed"));
QCOMPARE(t.exportCtkWh(), 0.0); // revente non saisie → 0 (brief)
QVariantMap deuxFenetres = saisieTarif({zoneTarif("HC", 15.0, {{"12:00", "14:00"}, {"22:00", "06:00"}}),
zoneTarif("HP", 25.0, {{"06:00", "12:00"}, {"14:00", "22:00"}})});
deuxFenetres.insert("exportCtkWh", 4); // un ENTIER est un nombre
t = TariffConfig::fromVariant(deuxFenetres, cause, ou);
QVERIFY2(cause.isEmpty(), qPrintable(cause));
QCOMPARE(t.source(), QStringLiteral("tou"));
QCOMPARE(t.exportCtkWh(), 4.0);
// L'aller-retour : la forme publiée est celle qu'on relit, sans perte.
const TariffConfig relu = TariffConfig::fromVariant(t.toVariant(), cause, ou);
QVERIFY(cause.isEmpty());
QVERIFY(relu == t);
QCOMPARE(relu.toVariant(), t.toVariant());
// Une plage qui se termine à « 00:00 » passe minuit et vaut « 24:00 ».
t = TariffConfig::fromVariant(saisieTarif({zoneTarif("Soir", 30.0, {{"22:00", "00:00"}}),
zoneTarif("Jour", 20.0, {{"00:00", "22:00"}})}), cause, ou);
QVERIFY2(cause.isEmpty(), qPrintable(cause));
// 2. L'EFFACEMENT est accepté et VIDE — distinct d'un refus par sa cause vide.
t = TariffConfig::fromVariant(saisieTarif({}), cause, ou);
QVERIFY(cause.isEmpty());
QVERIFY(t.isEmpty());
QVERIFY(t.source().isEmpty());
QVERIFY(t.importSeries(utc(2026, 6, 8, 0), 3600, 24, QTimeZone("Europe/Paris")).isEmpty());
// 3. REFUSÉS — chacun avec SON code et l'endroit en cause.
struct Cas { const char *quoi; QVariantMap saisie; const char *cause; QVariantMap ou; };
QVariantMap exportNegatif = hphc();
exportNegatif.insert("exportCtkWh", -1);
QVariantMap exportTexte = hphc();
exportTexte.insert("exportCtkWh", "6");
QVariantList treize;
for (int i = 0; i < 13; ++i)
treize << zoneTarif(QString::number(i), 1.0, {{"00:00", "01:00"}});
const QList<Cas> cas = {
{"zones absent", QVariantMap{}, "MALFORMED", {{"field", "zones"}}},
{"zones non liste", QVariantMap{{"zones", "HC"}}, "MALFORMED", {{"field", "zones"}}},
{"trop de zones", saisieTarif(treize), "TOO_MANY_ZONES", {{"max", 12}}},
{"trou", saisieTarif({zoneTarif("HC", 15.0, {{"22:00", "06:00"}}),
zoneTarif("HP", 25.0, {{"06:00", "21:00"}})}),
"GAP", {{"at", "21:00"}}},
{"chevauchement", saisieTarif({zoneTarif("HC", 15.0, {{"22:00", "06:00"}}),
zoneTarif("HP", 25.0, {{"05:00", "22:00"}})}),
"OVERLAP", {{"at", "05:00"}, {"zones", QVariantList{0, 1}}}},
{"chevauchement dans une zone", saisieTarif({zoneTarif("Base", 15.0, {{"00:00", "24:00"}, {"10:00", "11:00"}})}),
"OVERLAP", {{"at", "10:00"}, {"zones", QVariantList{0, 0}}}},
{"prix négatif", saisieTarif({zoneTarif("HC", 15.0, {{"22:00", "06:00"}}),
zoneTarif("HP", -0.01, {{"06:00", "22:00"}})}),
"PRICE", {{"zone", 1}}},
{"prix en texte", saisieTarif({zoneTarif("Base", "20.16", {{"00:00", "24:00"}})}), "PRICE", {{"zone", 0}}},
{"prix booléen", saisieTarif({zoneTarif("Base", true, {{"00:00", "24:00"}})}), "PRICE", {{"zone", 0}}},
{"prix absent", saisieTarif({QVariantMap{{"label", "Base"}, {"ranges", QVariantList{QVariantMap{{"from", "00:00"}, {"to", "24:00"}}}}}}),
"PRICE", {{"zone", 0}}},
{"libellé vide", saisieTarif({zoneTarif(" ", 20.0, {{"00:00", "24:00"}})}), "LABEL", {{"zone", 0}}},
{"libellé en double", saisieTarif({zoneTarif("HC", 15.0, {{"22:00", "06:00"}}),
zoneTarif("HC", 25.0, {{"06:00", "22:00"}})}),
"LABEL", {{"zone", 1}}},
{"aucune plage", saisieTarif({zoneTarif("Base", 20.0, {})}), "NO_RANGE", {{"zone", 0}}},
{"heure sans zéro", saisieTarif({zoneTarif("Base", 20.0, {{"0:00", "24:00"}})}),
"RANGE_FORMAT", {{"zone", 0}, {"range", 0}}},
{"secondes", saisieTarif({zoneTarif("Base", 20.0, {{"00:00:00", "24:00"}})}),
"RANGE_FORMAT", {{"zone", 0}, {"range", 0}}},
{"24:00 en début", saisieTarif({zoneTarif("Base", 20.0, {{"24:00", "24:00"}})}),
"RANGE_FORMAT", {{"zone", 0}, {"range", 0}}},
{"25:00", saisieTarif({zoneTarif("Base", 20.0, {{"00:00", "25:00"}})}),
"RANGE_FORMAT", {{"zone", 0}, {"range", 0}}},
{"from == to", saisieTarif({zoneTarif("Base", 20.0, {{"06:00", "06:00"}})}),
"RANGE_EMPTY", {{"zone", 0}, {"range", 0}}},
{"00:00 → 00:00", saisieTarif({zoneTarif("Base", 20.0, {{"00:00", "00:00"}})}),
"RANGE_EMPTY", {{"zone", 0}, {"range", 0}}},
{"revente négative", exportNegatif, "EXPORT_PRICE", {}},
{"revente en texte", exportTexte, "EXPORT_PRICE", {}},
};
for (const Cas &c : cas) {
t = TariffConfig::fromVariant(c.saisie, cause, ou);
QVERIFY2(t.isEmpty(), c.quoi);
QVERIFY2(cause == QLatin1String(c.cause),
qPrintable(QStringLiteral("%1 : %2 attendu, %3 obtenu").arg(c.quoi, c.cause, cause)));
QVERIFY2(ou == c.ou,
qPrintable(QStringLiteral("%1 : où = %2").arg(c.quoi,
QString::fromUtf8(QJsonDocument::fromVariant(ou).toJson(QJsonDocument::Compact)))));
}
}
void TestLoadModel::testTheTariffFollowsLocalTimeAcrossDstDays()
{
// ORACLES CALCULÉS HORS DE Qt (Python zoneinfo, minute par minute — script dans le rapport
// T4) : la grille va du minuit LOCAL au minuit local suivant. L'attendu ne se fabrique pas
// avec les primitives du code testé.
QString cause;
QVariantMap ou;
const TariffConfig t = TariffConfig::fromVariant(hphc(), cause, ou);
QVERIFY(cause.isEmpty());
const QTimeZone paris("Europe/Paris");
QVERIFY(paris.isValid());
QList<double> hc5hp16hc2, hc7hp16hc2, hc6hp16hc2;
for (int i = 0; i < 5; ++i) hc5hp16hc2 << 15.0;
for (int i = 0; i < 7; ++i) hc7hp16hc2 << 15.0;
for (int i = 0; i < 6; ++i) hc6hp16hc2 << 15.0;
for (QList<double> *l : {&hc5hp16hc2, &hc7hp16hc2, &hc6hp16hc2}) {
for (int i = 0; i < 16; ++i) *l << 25.0;
*l << 15.0 << 15.0;
}
struct Jour { const char *quoi; qint64 minuitLocal; int pas; QList<double> attendu; };
const QList<Jour> jours = {
{"2026-03-29, 23 h", 1774738800, 23, hc5hp16hc2},
{"2026-10-25, 25 h", 1792879200, 25, hc7hp16hc2},
{"2026-06-08, 24 h", 1780869600, 24, hc6hp16hc2},
};
for (const Jour &j : jours) {
QCOMPARE(j.attendu.count(), j.pas);
const QList<double> s = t.importSeries(QDateTime::fromSecsSinceEpoch(j.minuitLocal, QTimeZone::utc()),
3600, j.pas, paris);
QVERIFY2(s == j.attendu, j.quoi);
}
// zoneAt() — la même lecture, pour un instant : 05:59 locale est HC, 06:00 est HP.
QCOMPARE(t.zoneAt(QDateTime(QDate(2026, 6, 8), QTime(5, 59), paris), paris), 0);
QCOMPARE(t.zoneAt(QDateTime(QDate(2026, 6, 8), QTime(6, 0), paris), paris), 1);
// Le MÊME instant lu dans un autre fuseau change de zone : c'est un réglage MURAL.
const QDateTime instant = QDateTime(QDate(2026, 6, 8), QTime(4, 30), QTimeZone::utc()); // 06:30 Paris
QCOMPARE(t.zoneAt(instant, paris), 1);
QCOMPARE(t.zoneAt(instant, QTimeZone::utc()), 0);
}
void TestLoadModel::testAStraddlingSlotGetsTheTimeWeightedMean()
{
QString cause;
QVariantMap ou;
const QTimeZone paris("Europe/Paris");
// HC 22:30-06:30 à 15, HP à 25 : les créneaux 06:00 et 22:00 sont MI-HC, MI-HP → 20.
const TariffConfig demi = TariffConfig::fromVariant(
saisieTarif({zoneTarif("HC", 15.0, {{"22:30", "06:30"}}),
zoneTarif("HP", 25.0, {{"06:30", "22:30"}})}), cause, ou);
QVERIFY(cause.isEmpty());
QList<double> attendu;
for (int i = 0; i < 6; ++i) attendu << 15.0;
attendu << 20.0;
for (int i = 0; i < 15; ++i) attendu << 25.0;
attendu << 20.0 << 15.0;
QCOMPARE(demi.importSeries(QDateTime::fromSecsSinceEpoch(1780869600, QTimeZone::utc()), 3600, 24, paris),
attendu);
// À CHEVAL **ET** SUR LE CHANGEMENT D'HEURE — zone A 02:30-03:30 à 10, B à 30 (oracle hors Qt).
// 25 h : 02:00-03:00 existe DEUX fois (mi-A chaque fois), 03:00-04:00 une fois (mi-A) → 20, 20, 20.
// 23 h : 02:00-03:00 n'existe pas ; le créneau qui suit minuit+2 h est 03:00-04:00 → 20.
const TariffConfig dst = TariffConfig::fromVariant(
saisieTarif({zoneTarif("A", 10.0, {{"02:30", "03:30"}}),
zoneTarif("B", 30.0, {{"03:30", "02:30"}})}), cause, ou);
QVERIFY(cause.isEmpty());
QList<double> automne{30.0, 30.0, 20.0, 20.0, 20.0};
while (automne.count() < 25) automne << 30.0;
QList<double> printemps{30.0, 30.0, 20.0};
while (printemps.count() < 23) printemps << 30.0;
QCOMPARE(dst.importSeries(QDateTime::fromSecsSinceEpoch(1792879200, QTimeZone::utc()), 3600, 25, paris), automne);
QCOMPARE(dst.importSeries(QDateTime::fromSecsSinceEpoch(1774738800, QTimeZone::utc()), 3600, 23, paris), printemps);
// Un pas de 15 min tombe sur les plages : pas de mélange, la grille suit (brief : pas lu).
const QList<double> quarts = demi.importSeries(QDateTime(QDate(2026, 6, 8), QTime(6, 0), paris), 900, 4, paris);
QCOMPARE(quarts, (QList<double>{15.0, 15.0, 25.0, 25.0}));
}