Patrick Schurig d08cf506ee fix(sunspec): lire les float32 Fronius en big endian, comme le matériel
byteOrder() renvoyait ByteOrderLittleEndian au motif qu'on « supposait le
défaut SunSpec ». La supposition confondait le défaut de la SPÉCIFICATION avec
celui de libnymea-sunspec, qui est son contraire : convertToUInt32 lit
registers.at(1) puis .at(0) en little endian. Garder ce défaut revient à lire
tous les float32 avec leurs deux mots permutés.

Mesuré le 2026-08-07 sur un Symo réel, en lecture seule : le compteur 213 sur
l'unité 200 sert Hz = 4248 0000, soit 50,0000 en big endian et 2,3777e-41 en
mots inversés. L'onduleur 113 concorde.

Le coût n'était pas un décalage mais une absurdité. Reproduit sur banc avec le
simulateur émettant l'ordre du matériel : un compteur important 2,7 kW rapporté
comme exportant 107 MW, et l'arbitre enclenchant toutes les charges sur un
« Surplus PV 107622496 W ». Le thing reste connected, sans erreur Modbus.

Conséquence sur l'ordre des correctifs : meterPowerSign() reste juste mais
n'est observable qu'une fois celui-ci appliqué — corriger le signe d'un
dénormal ne produit rien.

solarEdge force déjà big endian de son côté et le générique garde son paramètre
utilisateur : aucun des deux chemins n'est touché. test_AC_18 fige le
raisonnement plutôt que la seule valeur. Voir DIVERGENCES.md D-8.

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

215 lines
9.3 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);
/* Word order of the float32 points, measured on hardware.
*
* This used to read "INFERRED: not documented by the manufacturer, so the
* SunSpec default is assumed. To be confirmed on the bench, then on hardware."
* It has now been confirmed, and the assumption was wrong: what was assumed to
* be "the SunSpec default" was in fact libnymea-sunspec's own default, which is
* the opposite of the specification.
*
* A float32 spans two registers. SunSpec puts the high word first.
* SunSpecDataPoint::convertToUInt32 reads registers.at(0) then .at(1) for
* ByteOrderBigEndian, and the reverse for ByteOrderLittleEndian — and little
* endian is the library's default. Keeping that default on a Fronius means
* reading every float32 with its two words swapped.
*
* Measured 2026-08-07 on a Fronius Symo, read only:
* meter 213, unit 200 : Hz = 4248 0000 -> 50.0000 big endian
* PhVphA = 436a 8000 -> 234.5000
* inverter 113, unit 1: Hz = 4247 fc48 -> 49.9964
* Swapped, those same registers give 2.4e-41 and -4.2e+36: denormals, the
* signature of a wrong interpretation rather than a measurement.
*
* What it costs to get this wrong is not a small offset. Reproduced on the
* bench with the simulator emitting the hardware's order: a meter importing
* 2.7 kW was reported as exporting 107 MW, and the arbiter switched every load
* on. Correcting the SIGN of a denormal changes nothing, so meterPowerSign()
* only becomes observable once this is right.
*
* The SolarEdge vendor already forces big endian on its own path; the generic
* vendor keeps its user-settable parameter. Neither is affected. */
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