Patrick Schurig c2eccd96e5 sunspec: add a failing test that sets up a Fronius connection
The existing suite only exercises free functions and the plugin JSON, so it
cannot see whether a thing class is dispatched at all. A thing class missing
from the dispatch in setupThing() falls into the final Q_ASSERT_X: in a debug
build the process dies, in a release build the assert is compiled out and
setupThing() returns without ever calling info->finish(), leaving the pairing
stuck with no message.

Split the tests into two suites. The logic one keeps its former scope and its
single dependency on libnymea-sunspec. The new one instantiates the plugin
behind the real ThingManager of libnymea-core and a faked HardwareManager, so
the dispatch of setupThing(), postSetupThing() and thingRemoved() is covered.
No network access takes place: the faked monitor is never reachable, so the
plugin never attempts a Modbus connection.

The four new cases fail: froniusConnection is not dispatched yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 16:18:41 +02:00

788 lines
30 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
/* Tests des criteres d'acceptation de specs/fronius-sunspec.md.
*
* Chaque test porte le numero de l'AC qu'il valide : test_AC_<n>_<quoi>.
*
* Deux mecanismes :
*
* - les criteres qui portent sur la *definition* du plugin (plages de valeurs,
* thingClasses, valeurs par defaut) sont verifies en relisant
* integrationpluginsunspec.json ;
* - les criteres qui portent sur le *comportement* sont verifies soit sur un
* dump de registres passe au vrai modele de libnymea-sunspec, soit sur les
* fonctions pures de sunspecstoragelogic / froniusrules.
*
* Ce qui n'est pas verifiable ici (trames Modbus reellement emises, expiration
* du timer, auto-scale des SF en fonctionnement) est liste explicitement dans
* specs/fronius-sunspec.md.
*/
#include <QTest>
#include <QObject>
#include <QFile>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonArray>
#include <models/sunspecstoragemodel.h>
#include "sunspecstoragelogic.h"
#include "froniusrules.h"
using namespace SunSpecStorageLogic;
/* Donne acces aux entrailles du modele pour lui injecter un dump de registres
* sans connexion Modbus. Reproduit ce que fait SunSpecModel::handleNewBlockData()
* apres une lecture reelle : remplir les data points depuis le bloc, puis
* appeler processBlockData(). */
class StorageModelFixture : public SunSpecStorageModel
{
public:
explicit StorageModelFixture(const QVector<quint16> &blockData)
: SunSpecStorageModel(nullptr, 40000, 24, SunSpecDataPoint::ByteOrderLittleEndian)
{
m_blockData = blockData;
foreach (const QString &name, m_dataPoints.keys()) {
m_dataPoints[name].setRawData(m_blockData.mid(m_dataPoints[name].addressOffset(),
m_dataPoints[name].size()));
}
processBlockData();
}
};
/* Dump du modele 124, L = 24 : ID + L + 24 registres. Les offsets sont ceux du
* modele genere (ID a 0), pas des adresses absolues : aucune adresse en dur
* n'est introduite ici (INV-1). */
struct StorageBlock
{
quint16 wChaMax = 10000;
qint16 wChaGra = 300; // gradient, % WChaMax/s
qint16 wDisChaGra = 400; // gradient, % WChaMax/s
quint16 storCtlMod = 0; // bitfield : bit 0 charge, bit 1 decharge
quint16 vaChaMax = 0;
quint16 minRsvPct = 500;
quint16 chaState = 5000; // 50,00 % avec SF -2
quint16 storAval = 0xFFFF; // non supporte -> sentinelle
quint16 inBatV = 0xFFFF; // non supporte -> sentinelle
quint16 chaSt = 4; // CHARGING
qint16 outWRte = 1000; // 10,00 % avec SF -2
qint16 inWRte = -2500; // -25,00 % avec SF -2
quint16 winTms = 0;
quint16 rvrtTms = 0;
quint16 rmpTms = 0;
quint16 chaGriSet = 0; // 0 = PV, 1 = GRID
qint16 wChaMaxSf = 0;
qint16 wChaDisChaGraSf = -2;
qint16 vaChaMaxSf = 0;
qint16 minRsvPctSf = -2;
qint16 chaStateSf = -2;
qint16 storAvalSf = 0;
qint16 inBatVSf = 0;
qint16 inOutWRteSf = -2;
QVector<quint16> toRegisters() const
{
QVector<quint16> r(26, 0);
r[0] = 124; // ID
r[1] = 24; // L
r[2] = wChaMax;
r[3] = static_cast<quint16>(wChaGra);
r[4] = static_cast<quint16>(wDisChaGra);
r[5] = storCtlMod;
r[6] = vaChaMax;
r[7] = minRsvPct;
r[8] = chaState;
r[9] = storAval;
r[10] = inBatV;
r[11] = chaSt;
r[12] = static_cast<quint16>(outWRte);
r[13] = static_cast<quint16>(inWRte);
r[14] = winTms;
r[15] = rvrtTms;
r[16] = rmpTms;
r[17] = chaGriSet;
r[18] = static_cast<quint16>(wChaMaxSf);
r[19] = static_cast<quint16>(wChaDisChaGraSf);
r[20] = static_cast<quint16>(vaChaMaxSf);
r[21] = static_cast<quint16>(minRsvPctSf);
r[22] = static_cast<quint16>(chaStateSf);
r[23] = static_cast<quint16>(storAvalSf);
r[24] = static_cast<quint16>(inBatVSf);
r[25] = static_cast<quint16>(inOutWRteSf);
return r;
}
};
class SunSpecFroniusTest : public QObject
{
Q_OBJECT
private slots:
void initTestCase();
// Bloc 0 - correctifs upstream
void test_AC_1_charging_rate_range_allows_forced_charge();
void test_AC_1_discharging_rate_range_allows_negative();
void test_AC_2_charging_rate_is_read_from_in_w_rte();
void test_AC_2_discharging_rate_is_read_from_out_w_rte();
void test_AC_2_gradients_exposed_under_distinct_names();
void test_AC_3_enable_discharging_reconciled_from_stor_ctl_mod();
void test_AC_3_enable_charging_still_reconciled();
void test_AC_4_setpoint_plan_writes_revert_time_last();
void test_AC_4_revert_time_is_written_even_for_a_single_setpoint();
void test_AC_4_generic_vendor_defaults_revert_time_to_zero();
// Bloc 1 - vendor fronius
void test_AC_5_manufacturer_filter_matches_fronius_case_insensitively();
void test_AC_5_manufacturer_filter_rejects_other_manufacturers();
void test_AC_6_fronius_uses_sunspec_default_byte_order();
void test_AC_7_fronius_connection_declares_expected_parameters();
void test_AC_7_fronius_connection_does_not_expose_endianness();
void test_AC_8_update_interval_floor_is_seven_seconds();
void test_AC_8_update_interval_above_floor_is_untouched();
void test_AC_8_fronius_polling_defaults();
void test_AC_9_forbidden_registers_are_refused();
void test_AC_9_allowed_registers_are_not_refused();
void test_AC_10_fronius_revert_time_default_is_300_seconds();
// Bloc 2 - pilotage batterie
void test_AC_11_mode_produces_contract_registers_data();
void test_AC_11_mode_produces_contract_registers();
void test_AC_11_write_order_follows_contract();
void test_AC_12_plan_rereads_scale_factor_before_writing();
void test_AC_13_plan_verifies_written_registers_by_readback();
// Bloc 3 - modele 160
void test_AC_14_blocks_are_classified_by_id_string();
void test_AC_14_st_dis_cha_is_not_confused_with_st_cha();
void test_AC_14_unknown_id_string_is_not_classified();
void test_AC_15_pv_power_sums_mppt_blocks_only();
void test_AC_15_battery_power_is_positive_when_discharging();
void test_AC_15_battery_power_is_negative_when_charging();
private:
QJsonObject m_pluginJson;
QJsonObject thingClassByName(const QString &name) const;
QJsonObject stateByName(const QJsonObject &thingClass, const QString &name) const;
QJsonObject paramByName(const QJsonObject &thingClass, const QString &name) const;
static QJsonObject findByName(const QJsonArray &array, const QString &name);
};
void SunSpecFroniusTest::initTestCase()
{
QFile file(PLUGIN_JSON_PATH);
QVERIFY2(file.open(QIODevice::ReadOnly), qPrintable(QString("Cannot open %1").arg(PLUGIN_JSON_PATH)));
QJsonParseError error;
const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &error);
QVERIFY2(error.error == QJsonParseError::NoError, qPrintable(error.errorString()));
m_pluginJson = document.object();
QVERIFY(!m_pluginJson.isEmpty());
}
QJsonObject SunSpecFroniusTest::findByName(const QJsonArray &array, const QString &name)
{
foreach (const QJsonValue &value, array) {
if (value.toObject().value("name").toString() == name)
return value.toObject();
}
return QJsonObject();
}
/* Cherche une thingClass a la racine puis dans chaque vendor. */
QJsonObject SunSpecFroniusTest::thingClassByName(const QString &name) const
{
const QJsonObject direct = findByName(m_pluginJson.value("thingClasses").toArray(), name);
if (!direct.isEmpty())
return direct;
foreach (const QJsonValue &vendor, m_pluginJson.value("vendors").toArray()) {
const QJsonObject found = findByName(vendor.toObject().value("thingClasses").toArray(), name);
if (!found.isEmpty())
return found;
}
return QJsonObject();
}
QJsonObject SunSpecFroniusTest::stateByName(const QJsonObject &thingClass, const QString &name) const
{
return findByName(thingClass.value("stateTypes").toArray(), name);
}
QJsonObject SunSpecFroniusTest::paramByName(const QJsonObject &thingClass, const QString &name) const
{
return findByName(thingClass.value("paramTypes").toArray(), name);
}
// ---------------------------------------------------------------------------
// AC-1 - plage des consignes
// ---------------------------------------------------------------------------
void SunSpecFroniusTest::test_AC_1_charging_rate_range_allows_forced_charge()
{
const QJsonObject storage = thingClassByName("sunspecStorage");
QVERIFY(!storage.isEmpty());
const QJsonObject state = stateByName(storage, "chargingRate");
QVERIFY(!state.isEmpty());
// La plage reelle de InWRte est -100,00 .. +100,00. Bornee a 0, une consigne
// de charge forcee est rejetee par nymea avant d'atteindre le plugin.
QCOMPARE(state.value("minValue").toDouble(), -100.0);
QCOMPARE(state.value("maxValue").toDouble(), 100.0);
}
void SunSpecFroniusTest::test_AC_1_discharging_rate_range_allows_negative()
{
const QJsonObject storage = thingClassByName("sunspecStorage");
const QJsonObject state = stateByName(storage, "dischargingRate");
QVERIFY(!state.isEmpty());
QCOMPARE(state.value("minValue").toDouble(), -100.0);
QCOMPARE(state.value("maxValue").toDouble(), 100.0);
}
// ---------------------------------------------------------------------------
// AC-2 - coherence lecture/ecriture
// ---------------------------------------------------------------------------
void SunSpecFroniusTest::test_AC_2_charging_rate_is_read_from_in_w_rte()
{
StorageBlock block;
block.inWRte = -2500; // -25,00 % avec SF -2
block.wChaGra = 300; // gradient : valeur volontairement differente
StorageModelFixture model(block.toRegisters());
const StorageStates states = readStates(&model);
// La grandeur ecrite par l'action chargingRate est InWRte. Le state relu
// doit etre la meme grandeur, pas le gradient WChaGra.
QCOMPARE(states.chargingRate, -25.0);
QVERIFY(states.chargingRate != states.chargeGradient);
}
void SunSpecFroniusTest::test_AC_2_discharging_rate_is_read_from_out_w_rte()
{
StorageBlock block;
block.outWRte = 1000; // 10,00 %
block.wDisChaGra = 400; // gradient different
StorageModelFixture model(block.toRegisters());
const StorageStates states = readStates(&model);
QCOMPARE(states.dischargingRate, 10.0);
QVERIFY(states.dischargingRate != states.dischargeGradient);
}
void SunSpecFroniusTest::test_AC_2_gradients_exposed_under_distinct_names()
{
StorageBlock block;
block.wChaGra = 300; // 3,00 % WChaMax/s avec SF -2
block.wDisChaGra = 400; // 4,00 % WChaMax/s
StorageModelFixture model(block.toRegisters());
const StorageStates states = readStates(&model);
QCOMPARE(states.chargeGradient, 3.0);
QCOMPARE(states.dischargeGradient, 4.0);
// Les gradients restent exposes, mais sous des noms qui ne se confondent
// pas avec les consignes, et en lecture seule.
const QJsonObject storage = thingClassByName("sunspecStorage");
const QJsonObject chargeGradient = stateByName(storage, "chargeGradient");
const QJsonObject dischargeGradient = stateByName(storage, "dischargeGradient");
QVERIFY(!chargeGradient.isEmpty());
QVERIFY(!dischargeGradient.isEmpty());
QVERIFY(!chargeGradient.value("writable").toBool());
QVERIFY(!dischargeGradient.value("writable").toBool());
}
// ---------------------------------------------------------------------------
// AC-3 - reconciliation de enableDischarging
// ---------------------------------------------------------------------------
void SunSpecFroniusTest::test_AC_3_enable_discharging_reconciled_from_stor_ctl_mod()
{
StorageBlock discharging;
discharging.storCtlMod = 0x2; // bit 1 = DISCHARGE
StorageModelFixture dischargingModel(discharging.toRegisters());
QCOMPARE(readStates(&dischargingModel).enableDischarging, true);
StorageBlock notDischarging;
notDischarging.storCtlMod = 0x1; // seulement CHARGE
StorageModelFixture notDischargingModel(notDischarging.toRegisters());
QCOMPARE(readStates(&notDischargingModel).enableDischarging, false);
}
void SunSpecFroniusTest::test_AC_3_enable_charging_still_reconciled()
{
// Garde-fou : la correction de enableDischarging ne doit pas casser
// enableCharging, qui etait deja correct en amont.
StorageBlock block;
block.storCtlMod = 0x1;
StorageModelFixture model(block.toRegisters());
const StorageStates states = readStates(&model);
QCOMPARE(states.enableCharging, true);
QCOMPARE(states.enableDischarging, false);
}
// ---------------------------------------------------------------------------
// AC-4 - ecriture de InOutWRte_RvrtTms
// ---------------------------------------------------------------------------
void SunSpecFroniusTest::test_AC_4_setpoint_plan_writes_revert_time_last()
{
const SetpointPlan plan = planSetpoint({{RegisterInWRte, 20.0},
{RegisterStorCtlMod, 3.0}}, 300);
QVERIFY(!plan.writes.isEmpty());
QCOMPARE(plan.writes.last().reg, RegisterRvrtTms);
QCOMPARE(plan.writes.last().value, 300.0);
// RvrtTms n'apparait qu'une fois, et en derniere position seulement.
int revertCount = 0;
foreach (const Write &write, plan.writes) {
if (write.reg == RegisterRvrtTms)
revertCount++;
}
QCOMPARE(revertCount, 1);
}
void SunSpecFroniusTest::test_AC_4_revert_time_is_written_even_for_a_single_setpoint()
{
// Une consigne isolee doit elle aussi armer le timer : c'est justement le
// cas ou une valeur persistante apres un arret du HEMS immobilise la
// batterie.
const SetpointPlan plan = planSetpoint({{RegisterOutWRte, 0.0}}, 300);
QCOMPARE(plan.writes.size(), 2);
QCOMPARE(plan.writes.at(0).reg, RegisterOutWRte);
QCOMPARE(plan.writes.at(1).reg, RegisterRvrtTms);
}
void SunSpecFroniusTest::test_AC_4_generic_vendor_defaults_revert_time_to_zero()
{
// Le vendor generique ne change pas de comportement par defaut : c'est un
// correctif proposable en amont, pas un changement de produit.
const QJsonObject storage = thingClassByName("sunspecStorage");
const QJsonObject param = paramByName(storage, "revertTime");
QVERIFY2(!param.isEmpty(), "sunspecStorage doit exposer un parametre revertTime");
QCOMPARE(param.value("defaultValue").toInt(), 0);
QCOMPARE(param.value("minValue").toInt(), 0);
QCOMPARE(param.value("maxValue").toInt(), 28800);
}
// ---------------------------------------------------------------------------
// AC-5 - filtre fabricant
// ---------------------------------------------------------------------------
void SunSpecFroniusTest::test_AC_5_manufacturer_filter_matches_fronius_case_insensitively()
{
QVERIFY(FroniusRules::matchesManufacturer({"Fronius"}));
QVERIFY(FroniusRules::matchesManufacturer({"fronius"}));
QVERIFY(FroniusRules::matchesManufacturer({"FRONIUS International GmbH"}));
// Le fabricant peut n'apparaitre que sur l'un des modeles Common.
QVERIFY(FroniusRules::matchesManufacturer({"", "Fronius"}));
}
void SunSpecFroniusTest::test_AC_5_manufacturer_filter_rejects_other_manufacturers()
{
QVERIFY(!FroniusRules::matchesManufacturer({"SolarEdge"}));
QVERIFY(!FroniusRules::matchesManufacturer({"Kostal"}));
QVERIFY(!FroniusRules::matchesManufacturer({}));
QVERIFY(!FroniusRules::matchesManufacturer({""}));
}
// ---------------------------------------------------------------------------
// AC-6 - ordre des octets
// ---------------------------------------------------------------------------
void SunSpecFroniusTest::test_AC_6_fronius_uses_sunspec_default_byte_order()
{
// INFERE : non documente par le constructeur, on suppose le defaut SunSpec.
// Le ByteOrderBigEndian force du vendor solarEdge est une anomalie propre a
// SolarEdge et ne doit pas etre etendue.
QCOMPARE(FroniusRules::byteOrder(), SunSpecDataPoint::ByteOrderLittleEndian);
}
// ---------------------------------------------------------------------------
// AC-7 - parametres de froniusConnection
// ---------------------------------------------------------------------------
void SunSpecFroniusTest::test_AC_7_fronius_connection_declares_expected_parameters()
{
const QJsonObject connection = thingClassByName("froniusConnection");
QVERIFY2(!connection.isEmpty(), "la thingClass froniusConnection doit exister");
foreach (const QString &name, QStringList({"address", "hostName", "macAddress", "port", "slaveId", "meterSlaveId"})) {
QVERIFY2(!paramByName(connection, name).isEmpty(), qPrintable("parametre manquant : " + name));
}
QCOMPARE(paramByName(connection, "port").value("defaultValue").toInt(), 502);
// Fixe en TCP sur GEN24.
QCOMPARE(paramByName(connection, "slaveId").value("defaultValue").toInt(), 1);
// 0 = pas de compteur sur cette connexion.
const QJsonObject meterSlaveId = paramByName(connection, "meterSlaveId");
QCOMPARE(meterSlaveId.value("defaultValue").toInt(), 200);
QCOMPARE(meterSlaveId.value("minValue").toInt(), 0);
QCOMPARE(meterSlaveId.value("maxValue").toInt(), 247);
}
void SunSpecFroniusTest::test_AC_7_fronius_connection_does_not_expose_endianness()
{
const QJsonObject connection = thingClassByName("froniusConnection");
QVERIFY(paramByName(connection, "endianness").isEmpty());
}
// ---------------------------------------------------------------------------
// AC-8 - plancher de cadence d'interrogation
// ---------------------------------------------------------------------------
void SunSpecFroniusTest::test_AC_8_update_interval_floor_is_seven_seconds()
{
// Descendre sous le plancher peut faire passer l'onduleur du client hors
// ligne, avec retablissement seulement par coupure AC+DC sur site. C'est un
// garde-fou dans le code, pas une valeur par defaut modifiable.
QCOMPARE(FroniusRules::clampUpdateInterval(1), 7);
QCOMPARE(FroniusRules::clampUpdateInterval(6), 7);
QCOMPARE(FroniusRules::clampUpdateInterval(0), 7);
QCOMPARE(FroniusRules::clampUpdateInterval(-5), 7);
}
void SunSpecFroniusTest::test_AC_8_update_interval_above_floor_is_untouched()
{
QCOMPARE(FroniusRules::clampUpdateInterval(7), 7);
QCOMPARE(FroniusRules::clampUpdateInterval(10), 10);
QCOMPARE(FroniusRules::clampUpdateInterval(60), 60);
}
void SunSpecFroniusTest::test_AC_8_fronius_polling_defaults()
{
const QJsonObject connection = thingClassByName("froniusConnection");
QVERIFY(!connection.isEmpty());
QCOMPARE(FroniusRules::DefaultUpdateIntervalSeconds, 10);
QCOMPARE(FroniusRules::DefaultTimeoutMilliSeconds, 2000);
QCOMPARE(FroniusRules::DefaultNumberOfRetries, 3);
const QJsonObject updateInterval = paramByName(connection, "updateInterval");
if (!updateInterval.isEmpty()) {
QCOMPARE(updateInterval.value("defaultValue").toInt(), 10);
QCOMPARE(updateInterval.value("minValue").toInt(), 7);
}
}
// ---------------------------------------------------------------------------
// AC-9 - registres interdits en ecriture
// ---------------------------------------------------------------------------
void SunSpecFroniusTest::test_AC_9_forbidden_registers_are_refused()
{
// Ces ecritures sont acceptees puis ignorees sans code d'exception Modbus :
// l'echec est invisible cote HEMS. On refuse donc avant emission.
foreach (const QString &name, QStringList({"WChaMax", "WChaGra", "WDisChaGra",
"InOutWRte_WinTms", "InOutWRte_RmpTms",
"VAChaMax"})) {
QVERIFY2(FroniusRules::isWriteForbidden(name), qPrintable("doit etre refuse : " + name));
}
}
void SunSpecFroniusTest::test_AC_9_allowed_registers_are_not_refused()
{
foreach (const QString &name, QStringList({"StorCtl_Mod", "InWRte", "OutWRte",
"InOutWRte_RvrtTms", "ChaGriSet", "MinRsvPct"})) {
QVERIFY2(!FroniusRules::isWriteForbidden(name), qPrintable("ne doit pas etre refuse : " + name));
}
}
// ---------------------------------------------------------------------------
// AC-10 - revertTime a 300 s sur fronius
// ---------------------------------------------------------------------------
void SunSpecFroniusTest::test_AC_10_fronius_revert_time_default_is_300_seconds()
{
// HYPOTHESE DE TRAVAIL (D4 non validee) : une consigne persistante apres un
// crash du HEMS immobilise la batterie du client. On arme donc le timer.
QCOMPARE(FroniusRules::DefaultRevertTimeSeconds, 300);
// Plage du registre.
QVERIFY(FroniusRules::DefaultRevertTimeSeconds > 0);
QVERIFY(FroniusRules::DefaultRevertTimeSeconds <= 28800);
}
// ---------------------------------------------------------------------------
// AC-11 - table intention HEMS -> registres
// ---------------------------------------------------------------------------
void SunSpecFroniusTest::test_AC_11_mode_produces_contract_registers_data()
{
QTest::addColumn<int>("mode");
QTest::addColumn<double>("chargeLimit");
QTest::addColumn<double>("dischargeLimit");
QTest::addColumn<int>("chaGriSet"); // -1 = registre non ecrit
QTest::addColumn<int>("storCtlMod");
QTest::addColumn<double>("inWRte"); // NaN = registre non ecrit
QTest::addColumn<double>("outWRte");
const double none = qQNaN();
QTest::newRow("NORMAL")
<< int(FroniusRules::BatteryModeNormal) << 0.0 << 0.0
<< -1 << 0 << none << 100.0;
QTest::newRow("NO_DISCHARGE")
<< int(FroniusRules::BatteryModeNoDischarge) << 0.0 << 0.0
<< -1 << 2 << none << 0.0;
QTest::newRow("NO_CHARGE")
<< int(FroniusRules::BatteryModeNoCharge) << 0.0 << 0.0
<< -1 << 1 << 0.0 << none;
QTest::newRow("FROZEN")
<< int(FroniusRules::BatteryModeFrozen) << 0.0 << 0.0
<< -1 << 3 << 0.0 << 0.0;
// Convention de signe du constructeur : negatif = charge.
QTest::newRow("FORCE_CHARGE_60")
<< int(FroniusRules::BatteryModeForceCharge) << 60.0 << 0.0
<< 1 << 2 << none << -60.0;
QTest::newRow("LIMIT_40_70")
<< int(FroniusRules::BatteryModeLimit) << 40.0 << 70.0
<< -1 << 3 << 40.0 << 70.0;
}
void SunSpecFroniusTest::test_AC_11_mode_produces_contract_registers()
{
QFETCH(int, mode);
QFETCH(double, chargeLimit);
QFETCH(double, dischargeLimit);
QFETCH(int, chaGriSet);
QFETCH(int, storCtlMod);
QFETCH(double, inWRte);
QFETCH(double, outWRte);
const SetpointPlan plan = FroniusRules::planBatteryMode(
static_cast<FroniusRules::BatteryMode>(mode), chargeLimit, dischargeLimit,
FroniusRules::DefaultRevertTimeSeconds);
auto valueOf = [&plan](Register reg, bool *found) {
*found = false;
double value = 0;
foreach (const Write &write, plan.writes) {
if (write.reg == reg) {
*found = true;
value = write.value;
}
}
return value;
};
bool found = false;
double value = 0;
value = valueOf(RegisterChaGriSet, &found);
if (chaGriSet < 0) {
QVERIFY2(!found, "ChaGriSet ne doit pas etre ecrit pour ce mode");
} else {
QVERIFY(found);
QCOMPARE(int(value), chaGriSet);
}
value = valueOf(RegisterStorCtlMod, &found);
QVERIFY2(found, "StorCtl_Mod est ecrit pour tous les modes");
QCOMPARE(int(value), storCtlMod);
value = valueOf(RegisterInWRte, &found);
if (qIsNaN(inWRte)) {
QVERIFY2(!found, "InWRte ne doit pas etre ecrit pour ce mode");
} else {
QVERIFY(found);
QCOMPARE(value, inWRte);
}
value = valueOf(RegisterOutWRte, &found);
if (qIsNaN(outWRte)) {
QVERIFY2(!found, "OutWRte ne doit pas etre ecrit pour ce mode");
} else {
QVERIFY(found);
QCOMPARE(value, outWRte);
}
// RvrtTms est arme pour tous les modes.
value = valueOf(RegisterRvrtTms, &found);
QVERIFY(found);
QCOMPARE(int(value), 300);
}
void SunSpecFroniusTest::test_AC_11_write_order_follows_contract()
{
// Ordre impose : ChaGriSet -> StorCtl_Mod -> InWRte / OutWRte -> RvrtTms.
const SetpointPlan plan = FroniusRules::planBatteryMode(
FroniusRules::BatteryModeLimit, 40.0, 70.0, 300);
QVector<int> ranks;
foreach (const Write &write, plan.writes) {
switch (write.reg) {
case RegisterChaGriSet: ranks.append(0); break;
case RegisterStorCtlMod: ranks.append(1); break;
case RegisterInWRte:
case RegisterOutWRte: ranks.append(2); break;
case RegisterRvrtTms: ranks.append(3); break;
}
}
QVERIFY(!ranks.isEmpty());
for (int i = 1; i < ranks.size(); i++) {
QVERIFY2(ranks.at(i) >= ranks.at(i - 1), "ordre d'ecriture non respecte");
}
QCOMPARE(ranks.last(), 3);
// Le mode FORCE_CHARGE ecrit ChaGriSet : il doit venir en premier.
const SetpointPlan forceCharge = FroniusRules::planBatteryMode(
FroniusRules::BatteryModeForceCharge, 60.0, 0.0, 300);
QCOMPARE(forceCharge.writes.first().reg, RegisterChaGriSet);
}
// ---------------------------------------------------------------------------
// AC-12 - relecture du scale factor avant ecriture
// ---------------------------------------------------------------------------
void SunSpecFroniusTest::test_AC_12_plan_rereads_scale_factor_before_writing()
{
// Les SF changent avec le firmware et en fonctionnement (auto-scale). Un SF
// mis en cache lors d'un cycle anterieur produit une consigne d'un facteur
// 10 a cote.
const SetpointPlan withRate = FroniusRules::planBatteryMode(
FroniusRules::BatteryModeLimit, 40.0, 70.0, 300);
QVERIFY2(withRate.rereadScaleFactor,
"toute ecriture de InWRte/OutWRte doit etre precedee d'une relecture de InOutWRte_SF");
const SetpointPlan generic = planSetpoint({{RegisterInWRte, 20.0}}, 300);
QVERIFY(generic.rereadScaleFactor);
// Un plan qui n'ecrit aucune grandeur mise a l'echelle n'a pas besoin de
// relire le SF.
const SetpointPlan noRate = planSetpoint({{RegisterStorCtlMod, 3.0}}, 300);
QVERIFY(!noRate.rereadScaleFactor);
}
// ---------------------------------------------------------------------------
// AC-13 - verification par relecture
// ---------------------------------------------------------------------------
void SunSpecFroniusTest::test_AC_13_plan_verifies_written_registers_by_readback()
{
// Seule parade aux echecs silencieux : pilotage Modbus non autorise, charge
// reseau non autorisee, restriction d'IP, assistant tiers qui ecrase.
const SetpointPlan plan = FroniusRules::planBatteryMode(
FroniusRules::BatteryModeForceCharge, 60.0, 0.0, 300);
QVERIFY(plan.verifyReadback.contains(RegisterStorCtlMod));
QVERIFY(plan.verifyReadback.contains(RegisterOutWRte));
QVERIFY(plan.verifyReadback.contains(RegisterChaGriSet));
// Tout registre ecrit, sauf RvrtTms, est relu.
foreach (const Write &write, plan.writes) {
if (write.reg == RegisterRvrtTms)
continue;
QVERIFY2(plan.verifyReadback.contains(write.reg),
"tout registre ecrit doit etre relu");
}
}
// ---------------------------------------------------------------------------
// AC-14 - classement des blocs du modele 160 par IDStr
// ---------------------------------------------------------------------------
void SunSpecFroniusTest::test_AC_14_blocks_are_classified_by_id_string()
{
using namespace FroniusRules;
QCOMPARE(classifyBlock("MPPT 1"), BlockRolePvString);
QCOMPARE(classifyBlock("MPPT 2"), BlockRolePvString);
QCOMPARE(classifyBlock("StCha 3"), BlockRoleBatteryCharge);
QCOMPARE(classifyBlock("StDisCha 4"), BlockRoleBatteryDischarge);
// Insensible a la casse et aux espaces de bord.
QCOMPARE(classifyBlock(" mppt 1 "), BlockRolePvString);
QCOMPARE(classifyBlock("STDISCHA 4"), BlockRoleBatteryDischarge);
// Sur un onduleur a 3 ou 4 MPPT, les blocs batterie ne sont pas en
// position 3 et 4 : c'est la chaine qui decide, jamais l'index.
QCOMPARE(classifyBlock("MPPT 3"), BlockRolePvString);
QCOMPARE(classifyBlock("MPPT 4"), BlockRolePvString);
QCOMPARE(classifyBlock("StCha 5"), BlockRoleBatteryCharge);
QCOMPARE(classifyBlock("StDisCha 6"), BlockRoleBatteryDischarge);
}
void SunSpecFroniusTest::test_AC_14_st_dis_cha_is_not_confused_with_st_cha()
{
// Piege : une implementation par contains() classerait StDisCha en StCha.
QCOMPARE(FroniusRules::classifyBlock("StDisCha 4"), FroniusRules::BlockRoleBatteryDischarge);
QVERIFY(FroniusRules::classifyBlock("StDisCha 4") != FroniusRules::BlockRoleBatteryCharge);
}
void SunSpecFroniusTest::test_AC_14_unknown_id_string_is_not_classified()
{
QCOMPARE(FroniusRules::classifyBlock(""), FroniusRules::BlockRoleUnknown);
QCOMPARE(FroniusRules::classifyBlock("Something else"), FroniusRules::BlockRoleUnknown);
}
// ---------------------------------------------------------------------------
// AC-15 - agregats du modele 160
// ---------------------------------------------------------------------------
void SunSpecFroniusTest::test_AC_15_pv_power_sums_mppt_blocks_only()
{
using namespace FroniusRules;
const QList<ModuleReading> readings = {
{"MPPT 1", 1200.0},
{"MPPT 2", 800.0},
{"StCha 3", 500.0}, // batterie : ne compte pas dans le PV
{"StDisCha 4", 0.0},
{"Something else", 999.0} // non classe : ne compte nulle part
};
QCOMPARE(pvPower(readings), 2000.0);
}
void SunSpecFroniusTest::test_AC_15_battery_power_is_positive_when_discharging()
{
using namespace FroniusRules;
// DCW est un uint16, toujours positif : le signe est porte par le bloc
// actif, le bloc inactif etant a zero.
const QList<ModuleReading> readings = {
{"MPPT 1", 1200.0},
{"StCha 3", 0.0},
{"StDisCha 4", 750.0}
};
QCOMPARE(batteryPower(readings), 750.0);
}
void SunSpecFroniusTest::test_AC_15_battery_power_is_negative_when_charging()
{
using namespace FroniusRules;
const QList<ModuleReading> readings = {
{"MPPT 1", 1200.0},
{"StCha 3", 600.0},
{"StDisCha 4", 0.0}
};
QCOMPARE(batteryPower(readings), -600.0);
}
QTEST_MAIN(SunSpecFroniusTest)
#include "tst_sunspec.moc"