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

190 lines
8.0 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/>.
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
#ifndef FRONIUSRULES_H
#define FRONIUSRULES_H
#include <QList>
#include <QString>
#include <QStringList>
#include <sunspecdatapoint.h>
#include "sunspecstoragelogic.h"
/* Vendor specific rules for Fronius GEN24 inverters.
*
* Everything here is a pure decision: no Modbus, no thing, no timer. The
* plugin asks these functions what to do; the answers are what the unit tests
* pin down. See specs/fronius-sunspec.md.
*/
namespace FroniusRules {
/* Polling. The manufacturer's support recommends staying above seven seconds
* for third party applications. Below that, communication load can push the
* inverter offline towards the manufacturer's portal, and recovery needs an
* AC+DC power cycle on site. This floor is a guard in the code, not a
* configurable default. */
const int MinimumUpdateIntervalSeconds = 7;
const int DefaultUpdateIntervalSeconds = 10;
const int DefaultTimeoutMilliSeconds = 2000;
const int DefaultNumberOfRetries = 3;
/* Revert timer armed with every setpoint, so that a setpoint left behind by a
* stopped or crashed HEMS expires instead of holding the battery forever.
*
* WORKING HYPOTHESIS: the timer semantics are documented in the manual's
* general chapter and illustrated on model 123, not on model 124. This must be
* verified experimentally on model 124 before being frozen. If it does not
* hold, fall back to 0 plus an application level watchdog in the HEMS. */
const int DefaultRevertTimeSeconds = 300;
/* Default unit IDs. The inverter is fixed at 1 over TCP; the meter defaults to
* 200, with 201, 202... for secondary meters. 0 means "no meter on this
* connection". */
const int InverterSlaveId = 1;
const int DefaultMeterSlaveId = 200;
const int MaximumSlaveId = 247;
/* Whether a second SunSpecConnection should be opened for the Smart Meter, and
* why not when it should not. The caller logs the reason; keeping it out of
* this function is what makes the decision testable without nymea. */
enum MeterConnectionDecision {
MeterConnectionCreate,
/* 0 is the documented way of saying "no meter behind this inverter". */
MeterConnectionDisabled,
/* A second socket on the unit ID already served by the inverter would
* rediscover the very same models and duplicate every child thing. */
MeterConnectionCollidesWithInverter,
MeterConnectionInvalidSlaveId
};
MeterConnectionDecision decideMeterConnection(int meterSlaveId, int inverterSlaveId);
bool matchesManufacturer(const QStringList &manufacturers);
/* Sign to apply to the raw SunSpec meter power registers (W, WphA...) to get
* the nymea smartmeter convention, where positive is import and negative is
* export.
*
* The Fronius Smart Meter already reports in that convention: negative W is
* feed-in. The generic path negates, so on a Fronius the surplus came out with
* the wrong sign and the arbiter refused to run loads while the site was
* exporting.
*
* Established on hardware, not from the manufacturer's documentation. On a
* Primo GEN24 3.0, a *single phase* inverter feeding phase A only, the plugin
* reported phase A at +1003 W (import) while phases B and C read -33 W and
* -262 W (export). Exporting on two phases carrying no source is impossible.
* With the sign flipped the balance closes exactly: production 1190 W = phase A
* load 187 W + phase A export 1003 W, B and C import 294 W, so the house draws
* 481 W and the site exports 709 W against 708 W measured. The meter's own
* energy counters agree: over the same window its import counter stayed at
* zero while its export counter advanced.
*
* Only the instantaneous power is affected. The energy counters map correctly
* already and must not be swapped.
*
* The generic default is left untouched: other vendors keep the negation. */
double meterPowerSign(bool froniusMeter);
/* Whether a model 124 block is backed by a physically installed battery.
*
* A hybrid GEN24 publishes model 124 whether or not a battery is wired to it,
* so the presence of the model proves nothing. Creating a storage child anyway
* gives the customer a phantom battery and a permanent batteryCritical alarm:
* observed on hardware, a GEN24 with no battery reported ChaSt = Off and a
* state of charge of 0, and nymea's own energy engine then logged "There is no
* energy storage available to consider" while the child stayed visible.
*
* The discriminator is deliberately a *physical* quantity. The state of charge
* is refused because 0% cannot be told apart from a real but empty battery, and
* the setpoint registers (WChaMax and friends) are refused because they keep
* their defaults with no battery attached.
*
* INFÉRÉ: an installed battery carries a DC voltage at rest, so InBatV or
* StorAval is expected to be strictly positive whenever one is present, and
* both zero when none is. The absent side matches what the battery-less GEN24
* showed and is reproduced on the simulator. The present side could NOT be
* confirmed: no GEN24 with a battery was reachable. Confirm InBatV/StorAval on
* a GEN24 with a battery before this ships. See DIVERGENCES.md. */
bool hasBattery(double batteryVoltage, double availableCharge);
/* INFERRED: the byte order of the GEN24 is not documented by the manufacturer,
* so the SunSpec default is assumed. The forced big endian of the SolarEdge
* vendor is an anomaly specific to SolarEdge and must not be extended here.
* To be confirmed on the bench, then on hardware. */
SunSpecDataPoint::ByteOrder byteOrder();
int clampUpdateInterval(int requestedSeconds);
/* Registers the GEN24 accepts and then ignores without raising a Modbus
* exception. Writing them fails invisibly, so they are refused before being
* put on the wire. */
bool isWriteForbidden(const QString &registerName);
enum BatteryMode {
BatteryModeNormal,
BatteryModeNoDischarge,
BatteryModeNoCharge,
BatteryModeFrozen,
BatteryModeForceCharge,
BatteryModeLimit
};
/* Sign convention of the manufacturer: negative is charging, positive is
* discharging. chargeLimit and dischargeLimit are percentages of WChaMax and
* are only used by the modes that need them. */
SunSpecStorageLogic::SetpointPlan planBatteryMode(BatteryMode mode,
double chargeLimit,
double dischargeLimit,
quint16 revertTime);
/* Model 160 repeating blocks are identified by their IDStr, never by their
* index: on a three or four MPPT inverter the battery blocks are not in
* position 3 and 4. */
enum BlockRole {
BlockRolePvString,
BlockRoleBatteryCharge,
BlockRoleBatteryDischarge,
BlockRoleUnknown
};
BlockRole classifyBlock(const QString &inputIdString);
struct ModuleReading {
QString inputIdString;
double dcPower;
};
/* Sum of the DC power of the PV string blocks. */
double pvPower(const QList<ModuleReading> &readings);
/* Discharge minus charge, so positive means discharging. DCW is a uint16 and
* therefore always positive: the sign is carried by whichever block is active,
* the inactive one reading zero. */
double batteryPower(const QList<ModuleReading> &readings);
}
#endif // FRONIUSRULES_H