The existing suite only exercises free functions and the plugin JSON, so it cannot see whether a thing class is dispatched at all. A thing class missing from the dispatch in setupThing() falls into the final Q_ASSERT_X: in a debug build the process dies, in a release build the assert is compiled out and setupThing() returns without ever calling info->finish(), leaving the pairing stuck with no message. Split the tests into two suites. The logic one keeps its former scope and its single dependency on libnymea-sunspec. The new one instantiates the plugin behind the real ThingManager of libnymea-core and a faked HardwareManager, so the dispatch of setupThing(), postSetupThing() and thingRemoved() is covered. No network access takes place: the faked monitor is never reachable, so the plugin never attempts a Modbus connection. The four new cases fail: froniusConnection is not dispatched yet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
788 lines
30 KiB
C++
788 lines
30 KiB
C++
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
/* Tests des criteres d'acceptation de specs/fronius-sunspec.md.
|
|
*
|
|
* Chaque test porte le numero de l'AC qu'il valide : test_AC_<n>_<quoi>.
|
|
*
|
|
* Deux mecanismes :
|
|
*
|
|
* - les criteres qui portent sur la *definition* du plugin (plages de valeurs,
|
|
* thingClasses, valeurs par defaut) sont verifies en relisant
|
|
* integrationpluginsunspec.json ;
|
|
* - les criteres qui portent sur le *comportement* sont verifies soit sur un
|
|
* dump de registres passe au vrai modele de libnymea-sunspec, soit sur les
|
|
* fonctions pures de sunspecstoragelogic / froniusrules.
|
|
*
|
|
* Ce qui n'est pas verifiable ici (trames Modbus reellement emises, expiration
|
|
* du timer, auto-scale des SF en fonctionnement) est liste explicitement dans
|
|
* specs/fronius-sunspec.md.
|
|
*/
|
|
|
|
#include <QTest>
|
|
#include <QObject>
|
|
#include <QFile>
|
|
#include <QJsonDocument>
|
|
#include <QJsonObject>
|
|
#include <QJsonArray>
|
|
|
|
#include <models/sunspecstoragemodel.h>
|
|
|
|
#include "sunspecstoragelogic.h"
|
|
#include "froniusrules.h"
|
|
|
|
using namespace SunSpecStorageLogic;
|
|
|
|
/* Donne acces aux entrailles du modele pour lui injecter un dump de registres
|
|
* sans connexion Modbus. Reproduit ce que fait SunSpecModel::handleNewBlockData()
|
|
* apres une lecture reelle : remplir les data points depuis le bloc, puis
|
|
* appeler processBlockData(). */
|
|
class StorageModelFixture : public SunSpecStorageModel
|
|
{
|
|
public:
|
|
explicit StorageModelFixture(const QVector<quint16> &blockData)
|
|
: SunSpecStorageModel(nullptr, 40000, 24, SunSpecDataPoint::ByteOrderLittleEndian)
|
|
{
|
|
m_blockData = blockData;
|
|
foreach (const QString &name, m_dataPoints.keys()) {
|
|
m_dataPoints[name].setRawData(m_blockData.mid(m_dataPoints[name].addressOffset(),
|
|
m_dataPoints[name].size()));
|
|
}
|
|
processBlockData();
|
|
}
|
|
};
|
|
|
|
/* Dump du modele 124, L = 24 : ID + L + 24 registres. Les offsets sont ceux du
|
|
* modele genere (ID a 0), pas des adresses absolues : aucune adresse en dur
|
|
* n'est introduite ici (INV-1). */
|
|
struct StorageBlock
|
|
{
|
|
quint16 wChaMax = 10000;
|
|
qint16 wChaGra = 300; // gradient, % WChaMax/s
|
|
qint16 wDisChaGra = 400; // gradient, % WChaMax/s
|
|
quint16 storCtlMod = 0; // bitfield : bit 0 charge, bit 1 decharge
|
|
quint16 vaChaMax = 0;
|
|
quint16 minRsvPct = 500;
|
|
quint16 chaState = 5000; // 50,00 % avec SF -2
|
|
quint16 storAval = 0xFFFF; // non supporte -> sentinelle
|
|
quint16 inBatV = 0xFFFF; // non supporte -> sentinelle
|
|
quint16 chaSt = 4; // CHARGING
|
|
qint16 outWRte = 1000; // 10,00 % avec SF -2
|
|
qint16 inWRte = -2500; // -25,00 % avec SF -2
|
|
quint16 winTms = 0;
|
|
quint16 rvrtTms = 0;
|
|
quint16 rmpTms = 0;
|
|
quint16 chaGriSet = 0; // 0 = PV, 1 = GRID
|
|
qint16 wChaMaxSf = 0;
|
|
qint16 wChaDisChaGraSf = -2;
|
|
qint16 vaChaMaxSf = 0;
|
|
qint16 minRsvPctSf = -2;
|
|
qint16 chaStateSf = -2;
|
|
qint16 storAvalSf = 0;
|
|
qint16 inBatVSf = 0;
|
|
qint16 inOutWRteSf = -2;
|
|
|
|
QVector<quint16> toRegisters() const
|
|
{
|
|
QVector<quint16> r(26, 0);
|
|
r[0] = 124; // ID
|
|
r[1] = 24; // L
|
|
r[2] = wChaMax;
|
|
r[3] = static_cast<quint16>(wChaGra);
|
|
r[4] = static_cast<quint16>(wDisChaGra);
|
|
r[5] = storCtlMod;
|
|
r[6] = vaChaMax;
|
|
r[7] = minRsvPct;
|
|
r[8] = chaState;
|
|
r[9] = storAval;
|
|
r[10] = inBatV;
|
|
r[11] = chaSt;
|
|
r[12] = static_cast<quint16>(outWRte);
|
|
r[13] = static_cast<quint16>(inWRte);
|
|
r[14] = winTms;
|
|
r[15] = rvrtTms;
|
|
r[16] = rmpTms;
|
|
r[17] = chaGriSet;
|
|
r[18] = static_cast<quint16>(wChaMaxSf);
|
|
r[19] = static_cast<quint16>(wChaDisChaGraSf);
|
|
r[20] = static_cast<quint16>(vaChaMaxSf);
|
|
r[21] = static_cast<quint16>(minRsvPctSf);
|
|
r[22] = static_cast<quint16>(chaStateSf);
|
|
r[23] = static_cast<quint16>(storAvalSf);
|
|
r[24] = static_cast<quint16>(inBatVSf);
|
|
r[25] = static_cast<quint16>(inOutWRteSf);
|
|
return r;
|
|
}
|
|
};
|
|
|
|
class SunSpecFroniusTest : public QObject
|
|
{
|
|
Q_OBJECT
|
|
|
|
private slots:
|
|
void initTestCase();
|
|
|
|
// Bloc 0 - correctifs upstream
|
|
void test_AC_1_charging_rate_range_allows_forced_charge();
|
|
void test_AC_1_discharging_rate_range_allows_negative();
|
|
void test_AC_2_charging_rate_is_read_from_in_w_rte();
|
|
void test_AC_2_discharging_rate_is_read_from_out_w_rte();
|
|
void test_AC_2_gradients_exposed_under_distinct_names();
|
|
void test_AC_3_enable_discharging_reconciled_from_stor_ctl_mod();
|
|
void test_AC_3_enable_charging_still_reconciled();
|
|
void test_AC_4_setpoint_plan_writes_revert_time_last();
|
|
void test_AC_4_revert_time_is_written_even_for_a_single_setpoint();
|
|
void test_AC_4_generic_vendor_defaults_revert_time_to_zero();
|
|
|
|
// Bloc 1 - vendor fronius
|
|
void test_AC_5_manufacturer_filter_matches_fronius_case_insensitively();
|
|
void test_AC_5_manufacturer_filter_rejects_other_manufacturers();
|
|
void test_AC_6_fronius_uses_sunspec_default_byte_order();
|
|
void test_AC_7_fronius_connection_declares_expected_parameters();
|
|
void test_AC_7_fronius_connection_does_not_expose_endianness();
|
|
void test_AC_8_update_interval_floor_is_seven_seconds();
|
|
void test_AC_8_update_interval_above_floor_is_untouched();
|
|
void test_AC_8_fronius_polling_defaults();
|
|
void test_AC_9_forbidden_registers_are_refused();
|
|
void test_AC_9_allowed_registers_are_not_refused();
|
|
void test_AC_10_fronius_revert_time_default_is_300_seconds();
|
|
|
|
// Bloc 2 - pilotage batterie
|
|
void test_AC_11_mode_produces_contract_registers_data();
|
|
void test_AC_11_mode_produces_contract_registers();
|
|
void test_AC_11_write_order_follows_contract();
|
|
void test_AC_12_plan_rereads_scale_factor_before_writing();
|
|
void test_AC_13_plan_verifies_written_registers_by_readback();
|
|
|
|
// Bloc 3 - modele 160
|
|
void test_AC_14_blocks_are_classified_by_id_string();
|
|
void test_AC_14_st_dis_cha_is_not_confused_with_st_cha();
|
|
void test_AC_14_unknown_id_string_is_not_classified();
|
|
void test_AC_15_pv_power_sums_mppt_blocks_only();
|
|
void test_AC_15_battery_power_is_positive_when_discharging();
|
|
void test_AC_15_battery_power_is_negative_when_charging();
|
|
|
|
private:
|
|
QJsonObject m_pluginJson;
|
|
|
|
QJsonObject thingClassByName(const QString &name) const;
|
|
QJsonObject stateByName(const QJsonObject &thingClass, const QString &name) const;
|
|
QJsonObject paramByName(const QJsonObject &thingClass, const QString &name) const;
|
|
static QJsonObject findByName(const QJsonArray &array, const QString &name);
|
|
};
|
|
|
|
void SunSpecFroniusTest::initTestCase()
|
|
{
|
|
QFile file(PLUGIN_JSON_PATH);
|
|
QVERIFY2(file.open(QIODevice::ReadOnly), qPrintable(QString("Cannot open %1").arg(PLUGIN_JSON_PATH)));
|
|
|
|
QJsonParseError error;
|
|
const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &error);
|
|
QVERIFY2(error.error == QJsonParseError::NoError, qPrintable(error.errorString()));
|
|
m_pluginJson = document.object();
|
|
QVERIFY(!m_pluginJson.isEmpty());
|
|
}
|
|
|
|
QJsonObject SunSpecFroniusTest::findByName(const QJsonArray &array, const QString &name)
|
|
{
|
|
foreach (const QJsonValue &value, array) {
|
|
if (value.toObject().value("name").toString() == name)
|
|
return value.toObject();
|
|
}
|
|
return QJsonObject();
|
|
}
|
|
|
|
/* Cherche une thingClass a la racine puis dans chaque vendor. */
|
|
QJsonObject SunSpecFroniusTest::thingClassByName(const QString &name) const
|
|
{
|
|
const QJsonObject direct = findByName(m_pluginJson.value("thingClasses").toArray(), name);
|
|
if (!direct.isEmpty())
|
|
return direct;
|
|
|
|
foreach (const QJsonValue &vendor, m_pluginJson.value("vendors").toArray()) {
|
|
const QJsonObject found = findByName(vendor.toObject().value("thingClasses").toArray(), name);
|
|
if (!found.isEmpty())
|
|
return found;
|
|
}
|
|
return QJsonObject();
|
|
}
|
|
|
|
QJsonObject SunSpecFroniusTest::stateByName(const QJsonObject &thingClass, const QString &name) const
|
|
{
|
|
return findByName(thingClass.value("stateTypes").toArray(), name);
|
|
}
|
|
|
|
QJsonObject SunSpecFroniusTest::paramByName(const QJsonObject &thingClass, const QString &name) const
|
|
{
|
|
return findByName(thingClass.value("paramTypes").toArray(), name);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// AC-1 - plage des consignes
|
|
// ---------------------------------------------------------------------------
|
|
|
|
void SunSpecFroniusTest::test_AC_1_charging_rate_range_allows_forced_charge()
|
|
{
|
|
const QJsonObject storage = thingClassByName("sunspecStorage");
|
|
QVERIFY(!storage.isEmpty());
|
|
|
|
const QJsonObject state = stateByName(storage, "chargingRate");
|
|
QVERIFY(!state.isEmpty());
|
|
|
|
// La plage reelle de InWRte est -100,00 .. +100,00. Bornee a 0, une consigne
|
|
// de charge forcee est rejetee par nymea avant d'atteindre le plugin.
|
|
QCOMPARE(state.value("minValue").toDouble(), -100.0);
|
|
QCOMPARE(state.value("maxValue").toDouble(), 100.0);
|
|
}
|
|
|
|
void SunSpecFroniusTest::test_AC_1_discharging_rate_range_allows_negative()
|
|
{
|
|
const QJsonObject storage = thingClassByName("sunspecStorage");
|
|
const QJsonObject state = stateByName(storage, "dischargingRate");
|
|
QVERIFY(!state.isEmpty());
|
|
|
|
QCOMPARE(state.value("minValue").toDouble(), -100.0);
|
|
QCOMPARE(state.value("maxValue").toDouble(), 100.0);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// AC-2 - coherence lecture/ecriture
|
|
// ---------------------------------------------------------------------------
|
|
|
|
void SunSpecFroniusTest::test_AC_2_charging_rate_is_read_from_in_w_rte()
|
|
{
|
|
StorageBlock block;
|
|
block.inWRte = -2500; // -25,00 % avec SF -2
|
|
block.wChaGra = 300; // gradient : valeur volontairement differente
|
|
StorageModelFixture model(block.toRegisters());
|
|
|
|
const StorageStates states = readStates(&model);
|
|
|
|
// La grandeur ecrite par l'action chargingRate est InWRte. Le state relu
|
|
// doit etre la meme grandeur, pas le gradient WChaGra.
|
|
QCOMPARE(states.chargingRate, -25.0);
|
|
QVERIFY(states.chargingRate != states.chargeGradient);
|
|
}
|
|
|
|
void SunSpecFroniusTest::test_AC_2_discharging_rate_is_read_from_out_w_rte()
|
|
{
|
|
StorageBlock block;
|
|
block.outWRte = 1000; // 10,00 %
|
|
block.wDisChaGra = 400; // gradient different
|
|
StorageModelFixture model(block.toRegisters());
|
|
|
|
const StorageStates states = readStates(&model);
|
|
|
|
QCOMPARE(states.dischargingRate, 10.0);
|
|
QVERIFY(states.dischargingRate != states.dischargeGradient);
|
|
}
|
|
|
|
void SunSpecFroniusTest::test_AC_2_gradients_exposed_under_distinct_names()
|
|
{
|
|
StorageBlock block;
|
|
block.wChaGra = 300; // 3,00 % WChaMax/s avec SF -2
|
|
block.wDisChaGra = 400; // 4,00 % WChaMax/s
|
|
StorageModelFixture model(block.toRegisters());
|
|
|
|
const StorageStates states = readStates(&model);
|
|
|
|
QCOMPARE(states.chargeGradient, 3.0);
|
|
QCOMPARE(states.dischargeGradient, 4.0);
|
|
|
|
// Les gradients restent exposes, mais sous des noms qui ne se confondent
|
|
// pas avec les consignes, et en lecture seule.
|
|
const QJsonObject storage = thingClassByName("sunspecStorage");
|
|
const QJsonObject chargeGradient = stateByName(storage, "chargeGradient");
|
|
const QJsonObject dischargeGradient = stateByName(storage, "dischargeGradient");
|
|
QVERIFY(!chargeGradient.isEmpty());
|
|
QVERIFY(!dischargeGradient.isEmpty());
|
|
QVERIFY(!chargeGradient.value("writable").toBool());
|
|
QVERIFY(!dischargeGradient.value("writable").toBool());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// AC-3 - reconciliation de enableDischarging
|
|
// ---------------------------------------------------------------------------
|
|
|
|
void SunSpecFroniusTest::test_AC_3_enable_discharging_reconciled_from_stor_ctl_mod()
|
|
{
|
|
StorageBlock discharging;
|
|
discharging.storCtlMod = 0x2; // bit 1 = DISCHARGE
|
|
StorageModelFixture dischargingModel(discharging.toRegisters());
|
|
QCOMPARE(readStates(&dischargingModel).enableDischarging, true);
|
|
|
|
StorageBlock notDischarging;
|
|
notDischarging.storCtlMod = 0x1; // seulement CHARGE
|
|
StorageModelFixture notDischargingModel(notDischarging.toRegisters());
|
|
QCOMPARE(readStates(¬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"
|