// 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"