sunspec: add failing tests for every acceptance criterion
One test per AC of specs/fronius-sunspec.md, named test_AC_<n>_<what>. Definition criteria (value ranges, thing classes, defaults) are checked by re-reading integrationpluginsunspec.json. Behaviour criteria run either on a model 124 register dump fed to the real libnymea-sunspec model, or on the pure logic units the plugin is about to grow. All red at this point: sunspecstoragelogic and froniusrules do not exist yet, so the suite does not compile. That is the expected starting state.
This commit is contained in:
parent
93ecfb4ca1
commit
d87a132f23
30
sunspec/tests/tests.pro
Normal file
30
sunspec/tests/tests.pro
Normal file
@ -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
|
||||
787
sunspec/tests/tst_sunspec.cpp
Normal file
787
sunspec/tests/tst_sunspec.cpp
Normal file
@ -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_<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(¬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<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"
|
||||
Loading…
x
Reference in New Issue
Block a user