Brief 3 T4. Une minute = consommation du site (échantillon d'une minute du bilan, jamais ses cumuls) moins chaque charge pilotée selon sa source ; sous none, sa puissance commandée, et la minute le garde. Deux tables dédiées dans energy-forecast.sqlite (baseloadMinute, baseloadMinuteLoad), fil d'écriture propre, lots de 5 min, lecture par journée, rétention 35 jours. forecastPv et forecastLearning ne sont pas touchées. Profil : médiane par case locale et type de jour (ouvré/week-end) sur 28 jours ; none sous 7 jours couverts à 90 %. Qualité : minutes comptées à la commande, négatives, écartées, et par charge source et constantMeasurement (la constante déguisée du banc). Tests loadmodel : soustraction par source, profil synthétique (médiane, jours de 23/25 h), seuil des 7 jours, fichier etm67 réel intact, jamais bloquant, redémarrage, rétention, fichier illisible. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2464 lines
127 KiB
C++
2464 lines
127 KiB
C++
// 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 "etm/forecast/baseloadlog.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 ¶ms)
|
||
{
|
||
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}));
|
||
}
|
||
|
||
// ============================================================================================
|
||
// Brief 3 T4 — la consommation de base
|
||
// ============================================================================================
|
||
|
||
namespace {
|
||
//! La base SYNTHÉTIQUE : ouvré 300 + 10·h W, week-end 500 + 10·h W, h = heure MURALE locale.
|
||
double baseSynthetique(const QDateTime &locale)
|
||
{
|
||
return (locale.date().dayOfWeek() >= 6 ? 500.0 : 300.0) + 10.0 * locale.time().hour();
|
||
}
|
||
|
||
/*!
|
||
* Des minutes `]debut, fin]`, chacune avec TROIS charges de trois sources :
|
||
* - `ecs` sans mesure (none) : 1 000 W COMMANDÉS de 12:00 à 14:00 locales, 0 sinon ;
|
||
* - `pac` sur un compteur à valeur FIXE (meter, 800 W) — la constante déguisée du banc ;
|
||
* - `borne` mesurée par l'appareil (device), 7 000 W de 18:00 à 20:00.
|
||
* Le site voit base + charges : retrancher selon la source doit rendre la base.
|
||
*/
|
||
QList<BaseloadMinute> minutesSynthetiques(qint64 debut, qint64 fin, const QTimeZone &fuseau)
|
||
{
|
||
QList<BaseloadMinute> out;
|
||
for (qint64 f = debut + 60; f <= fin; f += 60) {
|
||
const QDateTime locale = QDateTime::fromSecsSinceEpoch(f - 60, QTimeZone::utc()).toTimeZone(fuseau);
|
||
const int h = locale.time().hour();
|
||
BaseloadMinute m;
|
||
m.finMinute = f;
|
||
const double ecs = (h >= 12 && h < 14) ? 1000.0 : 0.0;
|
||
const double borne = (h >= 18 && h < 20) ? 7000.0 : 0.0;
|
||
m.charges << BaseloadLoadPart{QStringLiteral("borne"), QStringLiteral("device"), borne}
|
||
<< BaseloadLoadPart{QStringLiteral("ecs"), QStringLiteral("none"), ecs}
|
||
<< BaseloadLoadPart{QStringLiteral("pac"), QStringLiteral("meter"), 800.0};
|
||
m.siteW = baseSynthetique(locale) + ecs + borne + 800.0;
|
||
out << m;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
qint64 minuitLocal(int a, int mo, int j, const QTimeZone &fuseau)
|
||
{
|
||
return QDateTime(QDate(a, mo, j), QTime(0, 0), fuseau).toSecsSinceEpoch();
|
||
}
|
||
}
|
||
|
||
void TestLoadModel::testTheBaseloadMinuteSubtractsEachLoadBySource()
|
||
{
|
||
BaseloadMinute m;
|
||
m.siteW = 2500;
|
||
m.charges << BaseloadLoadPart{"ecs", "none", 1000.0}
|
||
<< BaseloadLoadPart{"pac", "meter", 800.0}
|
||
<< BaseloadLoadPart{"borne", "device", 0.0};
|
||
QCOMPARE(m.baseW().value_or(-1), 700.0);
|
||
QCOMPARE(m.chargesCommandees(), 1);
|
||
|
||
// NÉGATIVE, rendue telle quelle : le compteur racine voit moins que les charges. L'écrêter
|
||
// à 0 cacherait exactement ce que la qualité doit montrer.
|
||
m.siteW = 1500;
|
||
QCOMPARE(m.baseW().value_or(1e9), -300.0);
|
||
|
||
// Une commande INCONNUE : la base l'est aussi — jamais comptée à 0.
|
||
m.charges[0].powerW.reset();
|
||
QVERIFY(!m.baseW().has_value());
|
||
|
||
// Aucune charge pilotée : la base EST la consommation du site.
|
||
BaseloadMinute seule;
|
||
seule.siteW = 420;
|
||
QCOMPARE(seule.baseW().value_or(-1), 420.0);
|
||
QCOMPARE(seule.chargesCommandees(), 0);
|
||
}
|
||
|
||
void TestLoadModel::testTheBaseloadProfileIsTheMedianPerSlotAndDayType()
|
||
{
|
||
const QTimeZone paris("Europe/Paris");
|
||
QVERIFY(paris.isValid());
|
||
// Fenêtre de 28 jours finissant au minuit local du mardi 2026-11-10 : elle CONTIENT le jour
|
||
// de 25 h (dimanche 2026-10-25). Données dès le 1er octobre — la fenêtre trie.
|
||
const qint64 fin = minuitLocal(2026, 11, 10, paris);
|
||
QList<BaseloadMinute> minutes = minutesSynthetiques(minuitLocal(2026, 10, 1, paris), fin, paris);
|
||
|
||
// Les ACCIDENTS que la médiane doit absorber, et que la qualité doit compter :
|
||
// - un mercredi entier à +3 000 W (une journée hors norme) ;
|
||
// - une heure où le compteur racine ne voit RIEN (site 0 W : base négative) ;
|
||
// - 30 minutes où la commande de l'ECS est INCONNUE (minute écartée).
|
||
const qint64 mer0 = minuitLocal(2026, 10, 21, paris), mer1 = minuitLocal(2026, 10, 22, paris);
|
||
const qint64 aveugle = minuitLocal(2026, 10, 27, paris) + 10 * 3600;
|
||
const qint64 inconnu = minuitLocal(2026, 10, 28, paris) + 15 * 3600;
|
||
int negatives = 0, incompletes = 0;
|
||
for (BaseloadMinute &m : minutes) {
|
||
if (m.finMinute > mer0 && m.finMinute <= mer1)
|
||
m.siteW += 3000;
|
||
if (m.finMinute > aveugle && m.finMinute <= aveugle + 3600) {
|
||
m.siteW = 0;
|
||
++negatives;
|
||
}
|
||
if (m.finMinute > inconnu && m.finMinute <= inconnu + 1800) {
|
||
m.charges[1].powerW.reset();
|
||
++incompletes;
|
||
}
|
||
}
|
||
|
||
const BaseloadProfile p = BaseloadLog::calculerProfil(
|
||
minutes, QDateTime::fromSecsSinceEpoch(fin, QTimeZone::utc()), paris, 3600);
|
||
QCOMPARE(p.model, QStringLiteral("profile"));
|
||
QCOMPARE(p.resolutionS, 3600);
|
||
// 28 jours locaux, du mar. 13 oct. (23 h de données dans la fenêtre, 96 % : compté) au
|
||
// lun. 9 nov. — 20 ouvrés, 8 de week-end. Le dimanche de 25 h compte sur SES 1 500 minutes.
|
||
QCOMPARE(p.joursOuvres, 20);
|
||
QCOMPARE(p.joursWeekEnd, 8);
|
||
QCOMPARE(p.minutesNegatives, negatives);
|
||
QCOMPARE(p.minutesIncompletes, incompletes);
|
||
// L'ECS est sous `none` à CHAQUE minute : toutes les minutes connues sont des estimations.
|
||
QCOMPARE(p.minutesEstimees, p.minutes);
|
||
|
||
// LA QUALITÉ PAR CHARGE : la source, le décompte, et la constante déguisée.
|
||
QHash<QString, BaseloadLoadQuality> q;
|
||
for (const BaseloadLoadQuality &c : p.charges)
|
||
q.insert(c.loadId, c);
|
||
QCOMPARE(q.value("pac").source, QStringLiteral("meter"));
|
||
QCOMPARE(q.value("pac").constante, std::optional<bool>(true)); // 800 W fixes : le banc
|
||
QCOMPARE(q.value("borne").constante, std::optional<bool>(false)); // 0 puis 7 000 : vivante
|
||
QVERIFY2(!q.value("ecs").constante.has_value(), "aucune minute mesurée : rien à dire, pas « false »");
|
||
QCOMPARE(q.value("ecs").source, QStringLiteral("none"));
|
||
QCOMPARE(q.value("ecs").minutesParSource.value("none"), p.minutes + p.minutesIncompletes);
|
||
const QVariantMap qv = p.qualiteVariant();
|
||
QCOMPARE(qv.value("daysCovered").toMap().value("workday").toInt(), 20);
|
||
QCOMPARE(qv.value("commandedMinutes").toInt(), p.minutes);
|
||
QVERIFY(!qv.value("loads").toList().isEmpty());
|
||
|
||
// LES VALEURS : la base synthétique EXACTE, malgré le mercredi hors norme et l'heure
|
||
// aveugle — c'est ce que la médiane achète. Les attendus sont des CONSTANTES.
|
||
QList<std::optional<double>> attendu;
|
||
for (int h = 0; h < 24; ++h)
|
||
attendu << 300.0 + 10 * h;
|
||
QCOMPARE(p.serie(QDateTime::fromSecsSinceEpoch(fin, QTimeZone::utc()), 3600, 24, paris), attendu);
|
||
|
||
// JOURS DE 25 H ET DE 23 H (deux dimanches) — heures murales tirées de l'oracle Python :
|
||
// 2026-10-25 : 0, 1, 2, 2, 3 … 23 ; 2026-03-29 : 0, 1, 3 … 23.
|
||
QList<std::optional<double>> automne, printemps;
|
||
for (int h : {0, 1, 2, 2})
|
||
automne << 500.0 + 10 * h;
|
||
for (int h = 3; h < 24; ++h)
|
||
automne << 500.0 + 10 * h;
|
||
for (int h : {0, 1})
|
||
printemps << 500.0 + 10 * h;
|
||
for (int h = 3; h < 24; ++h)
|
||
printemps << 500.0 + 10 * h;
|
||
QCOMPARE(p.serie(QDateTime::fromSecsSinceEpoch(1792879200, QTimeZone::utc()), 3600, 25, paris), automne);
|
||
QCOMPARE(p.serie(QDateTime::fromSecsSinceEpoch(1774738800, QTimeZone::utc()), 3600, 23, paris), printemps);
|
||
|
||
// Une grille d'une AUTRE résolution ne lit pas un profil horaire : tout absent.
|
||
for (const std::optional<double> &v : p.serie(QDateTime::fromSecsSinceEpoch(fin, QTimeZone::utc()), 900, 8, paris))
|
||
QVERIFY(!v.has_value());
|
||
}
|
||
|
||
void TestLoadModel::testTheBaseloadIsNoneUnderSevenDays()
|
||
{
|
||
const QTimeZone paris("Europe/Paris");
|
||
const qint64 fin = minuitLocal(2026, 11, 10, paris);
|
||
const QDateTime now = QDateTime::fromSecsSinceEpoch(fin, QTimeZone::utc());
|
||
|
||
// SIX jours pleins : none, et la série est entièrement ABSENTE — jamais un profil maigre.
|
||
BaseloadProfile p = BaseloadLog::calculerProfil(
|
||
minutesSynthetiques(minuitLocal(2026, 11, 4, paris), fin, paris), now, paris, 3600);
|
||
QCOMPARE(p.model, QStringLiteral("none"));
|
||
QCOMPARE(p.joursOuvres + p.joursWeekEnd, 6);
|
||
for (const std::optional<double> &v : p.serie(now, 3600, 24, paris))
|
||
QVERIFY(!v.has_value());
|
||
QCOMPARE(p.qualiteVariant().value("minDays").toInt(), 7);
|
||
|
||
// Six jours pleins + un SEPTIÈME couvert à 85 % : toujours none (seuil 90 %).
|
||
QList<BaseloadMinute> m = minutesSynthetiques(minuitLocal(2026, 11, 3, paris), fin, paris);
|
||
const qint64 j7 = minuitLocal(2026, 11, 3, paris);
|
||
m.erase(std::remove_if(m.begin(), m.end(), [&](const BaseloadMinute &x) {
|
||
return x.finMinute > j7 && x.finMinute <= j7 + 216 * 60; // 216 min = 15 % de 1 440
|
||
}), m.end());
|
||
p = BaseloadLog::calculerProfil(m, now, paris, 3600);
|
||
QCOMPARE(p.joursOuvres + p.joursWeekEnd, 6);
|
||
QCOMPARE(p.model, QStringLiteral("none"));
|
||
|
||
// SEPT jours pleins : profile.
|
||
p = BaseloadLog::calculerProfil(minutesSynthetiques(j7, fin, paris), now, paris, 3600);
|
||
QCOMPARE(p.joursOuvres + p.joursWeekEnd, 7);
|
||
QCOMPARE(p.model, QStringLiteral("profile"));
|
||
QCOMPARE(p.serie(now, 3600, 1, paris).first().value_or(-1), 300.0);
|
||
}
|
||
|
||
void TestLoadModel::testTheBaseloadLogWritesWithoutTouchingTheOtherTables()
|
||
{
|
||
// Le VRAI fichier etm67 (forecastLearning, 4 lignes) : nos tables s'y ajoutent, et la sienne
|
||
// ne bouge pas — ni forme, ni lignes. C'est l'engagement envers les données du banc (T3).
|
||
QTemporaryDir dir;
|
||
const QString chemin = dir.filePath(QStringLiteral("energy-forecast.sqlite"));
|
||
QVERIFY(QFile::copy(QStringLiteral(":/data/energy-forecast-etm67.sqlite"), chemin));
|
||
QFile::setPermissions(chemin, QFile::ReadOwner | QFile::WriteOwner);
|
||
auto lire = [&](const QString &sql) {
|
||
QVariantList r;
|
||
const QString nom = QUuid::createUuid().toString();
|
||
{
|
||
QSqlDatabase db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), nom);
|
||
db.setDatabaseName(chemin);
|
||
if (db.open()) {
|
||
QSqlQuery q(db);
|
||
if (q.exec(sql))
|
||
while (q.next())
|
||
r << q.value(0);
|
||
db.close();
|
||
}
|
||
}
|
||
QSqlDatabase::removeDatabase(nom);
|
||
return r;
|
||
};
|
||
const QVariantList schemaAvant = lire("SELECT sql FROM sqlite_master WHERE name = 'forecastLearning'");
|
||
const QVariantList lignesAvant = lire("SELECT COUNT(*) FROM forecastLearning");
|
||
QCOMPARE(lignesAvant.value(0).toInt(), 4);
|
||
|
||
const QTimeZone paris("Europe/Paris");
|
||
QDateTime maintenant = QDateTime::currentDateTimeUtc();
|
||
maintenant.setTime(QTime(maintenant.time().hour(), maintenant.time().minute()));
|
||
const qint64 fin = maintenant.toSecsSinceEpoch();
|
||
const QList<BaseloadMinute> huitJours = minutesSynthetiques(fin - 8 * 86400, fin, paris);
|
||
{
|
||
BaseloadLog log(chemin, paris);
|
||
QTRY_VERIFY(log.profilsCalcules() >= 1); // le premier calcul, à l'ouverture
|
||
QVERIFY(log.isValid());
|
||
QCOMPARE(log.profil().model, QStringLiteral("none"));
|
||
|
||
// JAMAIS BLOQUANT, même fichier verrouillé en écriture par une autre connexion.
|
||
const QString nom = QUuid::createUuid().toString();
|
||
{
|
||
QSqlDatabase verrou = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), nom);
|
||
verrou.setDatabaseName(chemin);
|
||
QVERIFY(verrou.open());
|
||
QSqlQuery q(verrou);
|
||
QVERIFY(q.exec(QStringLiteral("BEGIN EXCLUSIVE")));
|
||
QElapsedTimer chrono;
|
||
chrono.start();
|
||
for (int i = 0; i < 20; ++i)
|
||
log.observerMinute(huitJours.at(i));
|
||
QVERIFY2(chrono.elapsed() < 50, qPrintable(QString::number(chrono.elapsed())));
|
||
QTest::qWait(200);
|
||
QVERIFY(q.exec(QStringLiteral("COMMIT")));
|
||
verrou.close();
|
||
}
|
||
QSqlDatabase::removeDatabase(nom);
|
||
|
||
for (int i = 20; i < huitJours.count(); ++i)
|
||
log.observerMinute(huitJours.at(i));
|
||
const int calcules = log.profilsCalcules();
|
||
log.recalculer(maintenant);
|
||
QTRY_VERIFY_WITH_TIMEOUT(log.profilsCalcules() > calcules, 20000);
|
||
QCOMPARE(log.minutesEcrites(), huitJours.count());
|
||
QCOMPARE(log.profil().model, QStringLiteral("profile"));
|
||
}
|
||
// NOS tables : une ligne par minute, une par minute et par charge.
|
||
QCOMPARE(lire("SELECT COUNT(*) FROM baseloadMinute").value(0).toInt(), huitJours.count());
|
||
QCOMPARE(lire("SELECT COUNT(*) FROM baseloadMinuteLoad").value(0).toInt(), 3 * huitJours.count());
|
||
QCOMPARE(lire("SELECT COUNT(*) FROM baseloadMinuteLoad WHERE source = 'none'").value(0).toInt(),
|
||
huitJours.count());
|
||
// LA SIENNE : intacte.
|
||
QCOMPARE(lire("SELECT sql FROM sqlite_master WHERE name = 'forecastLearning'"), schemaAvant);
|
||
QCOMPARE(lire("SELECT COUNT(*) FROM forecastLearning"), lignesAvant);
|
||
|
||
// REDÉMARRAGE : le profil est recalculé à l'ouverture, depuis la base — pas après 15 min.
|
||
{
|
||
BaseloadLog relance(chemin, paris);
|
||
QTRY_VERIFY_WITH_TIMEOUT(relance.profilsCalcules() >= 1, 20000);
|
||
QCOMPARE(relance.profil().model, QStringLiteral("profile"));
|
||
QCOMPARE(relance.profil().serie(QDateTime::fromSecsSinceEpoch(fin, QTimeZone::utc()), 3600, 1, paris).size(), 1);
|
||
|
||
// RÉTENTION : une minute de plus de 35 jours disparaît au calcul suivant.
|
||
BaseloadMinute vieille;
|
||
vieille.finMinute = fin - 36 * 86400;
|
||
vieille.siteW = 100;
|
||
relance.observerMinute(vieille);
|
||
const int c = relance.profilsCalcules();
|
||
relance.recalculer(maintenant);
|
||
QTRY_VERIFY_WITH_TIMEOUT(relance.profilsCalcules() > c, 20000);
|
||
}
|
||
QCOMPARE(lire(QStringLiteral("SELECT COUNT(*) FROM baseloadMinute WHERE at <= %1").arg(fin - 35 * 86400))
|
||
.value(0).toInt(), 0);
|
||
|
||
// FICHIER QUI N'EST PAS UNE BASE : hors service, et l'appelant continue.
|
||
const QString faux = dir.filePath(QStringLiteral("pas-une-base.sqlite"));
|
||
{
|
||
QFile f(faux);
|
||
QVERIFY(f.open(QIODevice::WriteOnly));
|
||
f.write(QByteArray(4096, 'x'));
|
||
}
|
||
BaseloadLog horsService(faux, paris);
|
||
QTRY_VERIFY(horsService.profilsCalcules() >= 0 && !horsService.isValid());
|
||
horsService.observerMinute(huitJours.first());
|
||
QVERIFY(!horsService.profil().calcule);
|
||
}
|