diff --git a/sunspec/tests/tests.pro b/sunspec/tests/tests.pro new file mode 100644 index 0000000..a8981ef --- /dev/null +++ b/sunspec/tests/tests.pro @@ -0,0 +1,30 @@ +include(../../sunspec.pri) + +QT += testlib +QT -= gui +CONFIG += console testcase +CONFIG -= app_bundle +TEMPLATE = app +TARGET = tst_sunspec + +# Les tests pilotent la logique pure du plugin. Ils ne chargent pas le plugin +# nymea lui-meme : cela demanderait un ThingManager, un PluginTimer et un +# NetworkDeviceDiscovery, c'est-a-dire un nymead complet. Ce qui est teste ici, +# c'est ce que le plugin *decide* ; ce qu'il emet reellement sur le bus releve +# du banc (voir "Non verifiable par les tests unitaires" dans +# specs/fronius-sunspec.md). + +INCLUDEPATH += .. + +# Chemin du JSON de definition du plugin, pour les criteres qui portent sur les +# thingClasses et leurs plages de valeurs (AC-1, AC-7, AC-8, AC-10). +DEFINES += PLUGIN_JSON_PATH=\\\"$$PWD/../integrationpluginsunspec.json\\\" + +SOURCES += \ + tst_sunspec.cpp \ + ../sunspecstoragelogic.cpp \ + ../froniusrules.cpp + +HEADERS += \ + ../sunspecstoragelogic.h \ + ../froniusrules.h diff --git a/sunspec/tests/tst_sunspec.cpp b/sunspec/tests/tst_sunspec.cpp new file mode 100644 index 0000000..fd218c8 --- /dev/null +++ b/sunspec/tests/tst_sunspec.cpp @@ -0,0 +1,787 @@ +// 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__. + * + * 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 +#include +#include +#include +#include +#include + +#include + +#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 &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 toRegisters() const + { + QVector r(26, 0); + r[0] = 124; // ID + r[1] = 24; // L + r[2] = wChaMax; + r[3] = static_cast(wChaGra); + r[4] = static_cast(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(outWRte); + r[13] = static_cast(inWRte); + r[14] = winTms; + r[15] = rvrtTms; + r[16] = rmpTms; + r[17] = chaGriSet; + r[18] = static_cast(wChaMaxSf); + r[19] = static_cast(wChaDisChaGraSf); + r[20] = static_cast(vaChaMaxSf); + r[21] = static_cast(minRsvPctSf); + r[22] = static_cast(chaStateSf); + r[23] = static_cast(storAvalSf); + r[24] = static_cast(inBatVSf); + r[25] = static_cast(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(¬DischargingModel).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("mode"); + QTest::addColumn("chargeLimit"); + QTest::addColumn("dischargeLimit"); + QTest::addColumn("chaGriSet"); // -1 = registre non ecrit + QTest::addColumn("storCtlMod"); + QTest::addColumn("inWRte"); // NaN = registre non ecrit + QTest::addColumn("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(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 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 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 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 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"