Patrick Schurig 89f7bf0199 fix(sunspec): signe du compteur Fronius et batterie fantôme
Deux défauts constatés sur le GEN24 des beaux-parents (Primo GEN24 3.0,
Smart Meter TS 65A-3, sans batterie).

Signe du compteur. onMeterBlockUpdated() négociait le signe de tous les
modèles compteur sans distinction de fabricant. Sur Fronius la négation est
fausse : le Smart Meter émet déjà dans la convention nymea. L'onduleur étant
monophasé, il sert de référence de direction : le plugin rapportait la phase
portant l'onduleur en soutirage et les deux autres en injection, ce qui est
impossible sans source. L'arbitre en concluait « Surplus insuffisant
(-842 W) » et laissait chauffe-eau et PAC éteints en plein export.

La décision vit dans FroniusRules::meterPowerSign() et n'est annulée que sous
une froniusConnection. Les vendors generic et solarEdge sont inchangés, ce
qu'un antidote verrouille. Les compteurs d'énergie sont corrects et ne sont
pas permutés.

Batterie fantôme. Un GEN24 hybride publie le modèle 124 sans batterie câblée,
d'où un enfant storage bloqué sur batteryCritical.
FroniusRules::hasBattery() exige une grandeur physique non nulle (InBatV ou
StorAval), le SoC étant écarté car 0 % ne se distingue pas d'une batterie
vide. Le côté « batterie présente » reste INFÉRÉ : voir D-6, à valider sur un
GEN24 équipé avant tout déploiement.

Suite logique : 50 tests verts, dont six antidotes AC-16 et AC-17.

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

205 lines
6.4 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
*
* Copyright (C) 2026, ETM-PowerSync
*
* This file is part of nymea.
*
* nymea is free software: you can redistribute it and/or modify it under the
* terms of the GNU General Public License as published by the Free Software
* Foundation, either version 3 of the License, or (at your option) any later
* version.
*
* nymea is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with
* nymea. If not, see <https://www.gnu.org/licenses/>.
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
#include "froniusrules.h"
using namespace SunSpecStorageLogic;
namespace FroniusRules {
bool matchesManufacturer(const QStringList &manufacturers)
{
foreach (const QString &name, manufacturers) {
if (name.contains(QStringLiteral("fronius"), Qt::CaseInsensitive))
return true;
}
return false;
}
double meterPowerSign(bool froniusMeter)
{
/* The Fronius meter is already in the nymea convention, so the generic
* negation has to be undone for it, and only for it. */
return froniusMeter ? 1.0 : -1.0;
}
bool hasBattery(double batteryVoltage, double availableCharge)
{
/* Strictly positive only: zero is the no-battery reading, and a negative
* value is the library's way of surfacing a not-implemented point rather
* than a real measurement. */
return batteryVoltage > 0.0 || availableCharge > 0.0;
}
SunSpecDataPoint::ByteOrder byteOrder()
{
return SunSpecDataPoint::ByteOrderLittleEndian;
}
MeterConnectionDecision decideMeterConnection(int meterSlaveId, int inverterSlaveId)
{
if (meterSlaveId == 0)
return MeterConnectionDisabled;
if (meterSlaveId < 0 || meterSlaveId > MaximumSlaveId)
return MeterConnectionInvalidSlaveId;
/* Not a theoretical case: the slave ID of the thing is written by the
* discovery from the slave ID it was asked to scan, so an inverter found
* with a discovery slave ID of 200 ends up with both values at 200. */
if (meterSlaveId == inverterSlaveId)
return MeterConnectionCollidesWithInverter;
return MeterConnectionCreate;
}
int clampUpdateInterval(int requestedSeconds)
{
return qMax(requestedSeconds, MinimumUpdateIntervalSeconds);
}
bool isWriteForbidden(const QString &registerName)
{
static const QStringList forbidden = {
QStringLiteral("WChaMax"),
QStringLiteral("WChaGra"),
QStringLiteral("WDisChaGra"),
QStringLiteral("InOutWRte_WinTms"),
QStringLiteral("InOutWRte_RmpTms"),
/* VAChaMax is doubly unusable: the write is ignored and its scale
* factor is not supported either. */
QStringLiteral("VAChaMax")
};
foreach (const QString &name, forbidden) {
if (registerName.compare(name, Qt::CaseInsensitive) == 0)
return true;
}
return false;
}
SetpointPlan planBatteryMode(BatteryMode mode, double chargeLimit, double dischargeLimit, quint16 revertTime)
{
/* StorCtl_Mod is a bitfield: bit 0 CHARGE, bit 1 DISCHARGE. ChaGriSet is
* 0 PV, 1 GRID. A register absent from the vector below is deliberately not
* written for that mode.
*
* planSetpoint() puts these in the mandated order and appends the revert
* timer, so the order they are listed in here does not matter. */
QVector<Write> setpoints;
switch (mode) {
case BatteryModeNormal:
setpoints.append({RegisterStorCtlMod, 0});
setpoints.append({RegisterOutWRte, 100});
break;
case BatteryModeNoDischarge:
setpoints.append({RegisterStorCtlMod, 2});
setpoints.append({RegisterOutWRte, 0});
break;
case BatteryModeNoCharge:
setpoints.append({RegisterStorCtlMod, 1});
setpoints.append({RegisterInWRte, 0});
break;
case BatteryModeFrozen:
setpoints.append({RegisterStorCtlMod, 3});
setpoints.append({RegisterInWRte, 0});
setpoints.append({RegisterOutWRte, 0});
break;
case BatteryModeForceCharge:
/* Charging from the grid has to be allowed on the inverter as well;
* ChaGriSet alone is ANDed with a setting of the component
* configuration and fails silently if that one is off. */
setpoints.append({RegisterChaGriSet, 1});
setpoints.append({RegisterStorCtlMod, 2});
/* Negative is charging. */
setpoints.append({RegisterOutWRte, -chargeLimit});
break;
case BatteryModeLimit:
setpoints.append({RegisterStorCtlMod, 3});
setpoints.append({RegisterInWRte, chargeLimit});
setpoints.append({RegisterOutWRte, dischargeLimit});
break;
}
return planSetpoint(setpoints, revertTime);
}
BlockRole classifyBlock(const QString &inputIdString)
{
const QString id = inputIdString.trimmed();
if (id.isEmpty())
return BlockRoleUnknown;
/* Prefix match, not contains: the role is decided by the string, never by
* the block index. StDisCha is checked first so that no cascade can ever
* swallow it into the charge case. */
if (id.startsWith(QStringLiteral("StDisCha"), Qt::CaseInsensitive))
return BlockRoleBatteryDischarge;
if (id.startsWith(QStringLiteral("StCha"), Qt::CaseInsensitive))
return BlockRoleBatteryCharge;
if (id.startsWith(QStringLiteral("MPPT"), Qt::CaseInsensitive))
return BlockRolePvString;
return BlockRoleUnknown;
}
double pvPower(const QList<ModuleReading> &readings)
{
double total = 0;
foreach (const ModuleReading &reading, readings) {
if (classifyBlock(reading.inputIdString) == BlockRolePvString)
total += reading.dcPower;
}
return total;
}
double batteryPower(const QList<ModuleReading> &readings)
{
double charging = 0;
double discharging = 0;
foreach (const ModuleReading &reading, readings) {
switch (classifyBlock(reading.inputIdString)) {
case BlockRoleBatteryCharge:
charging += reading.dcPower;
break;
case BlockRoleBatteryDischarge:
discharging += reading.dcPower;
break;
default:
break;
}
}
return discharging - charging;
}
}