sunspecStorage déclarait `battery` mais pas `energystorage`. Les trois consommateurs qui filtrent par cette interface ne la trouvaient donc pas : le bilan de puissance de nymea (`currentPowerStorage`, celui que le CLIENT lit sur son tableau de bord), la réserve de stockage de la recharge VE, et celle du waterfall. Vérifié sans authentification dans `energylogs.sqlite` de .75 : la colonne `storage` de `powerBalance` est à 0.0 sur toutes les lignes, pendant que la batterie affiche 50 % et « Holding ». Elle n'était pas à zéro, elle était invisible. Ce n'était pas une ligne de JSON. `energystorage` exige `currentPower` (W, signé) et `capacity` (kWh), qu'aucun des quatorze états ne portait, et les déclarer sans les câbler aurait donné une batterie éternellement inerte — pire que son absence, puisque la réserve lirait « ni charge ni décharge » au lieu de « je ne sais pas ». D'où viennent les deux états. Le modèle 124 ne porte AUCUNE puissance : WChaMax est un maximum, StorAval est en ampères-heures. La puissance instantanée vit dans le modèle 160, réparti sur deux blocs `StCha` et `StDisCha` dont le `DCW` est un uint16 toujours positif, l'inactif à zéro. D'où `currentPower = DCW[StCha] - DCW[StDisCha]`, positif en charge, conformément à libnymea/interfaces/energystorage.json (« positive is 'consumed' »). C'est l'INVERSE de la convention constructeur du contrat §4, et `FroniusRules::batteryPower()` est réécrite comme la négation stricte de la nouvelle `storageCurrentPower()` pour qu'aucune des deux ne dérive seule. `capacity` vient de WHRtg du modèle 120, et l'état est laissé tranquille plutôt que mis à zéro quand le nameplate ne l'implémente pas. Le signe est MESURÉ, pas déduit. Banc .211 unité 1, consigne batterie imposée par MQTT, registres relus en lecture seule : +2000 W -> StCha 2000,0 W StDisCha 0,0 W ChaSt Charging -1500 W -> StCha 0,0 W StDisCha 1500,0 W ChaSt Discharging Le même relevé a montré `DCW_SF` passant de -2 à -1 entre repos et charge : le facteur d'échelle n'est jamais mis en cache d'un relevé à l'autre. Écart assumé, D-9 dans DIVERGENCES.md : `sunspecStorage` est partagé avec le vendor générique, et les interfaces se déclarent par thingClass. Un appareil générique publiant le 124 sans le 160 porte désormais `energystorage` avec un `currentPower` figé à zéro. Les trois alternatives sont pires — une thingClass Fronius distincte détruirait le Thing existant et son historique. Le plugin avertit donc UNE FOIS par thing quand il ne trouve pas de source de puissance, en distinguant « pas de modèle 160 » de « modèle 160 sans bloc batterie ». Ça ne remplace pas la mesure, mais ça retire le silence. La cohérence des unités avec l'interface est vérifiée à la COMPILATION par nymea-plugininfocompiler : mettre `KiloWattHour` sur `currentPower` fait échouer le build. Onze tests `test_D9_*` figent le reste. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y3gzsuwENJfJtvuNDT3xGn
241 lines
11 KiB
C++
241 lines
11 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 ®isterName);
|
|
|
|
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.
|
|
*
|
|
* This is the MANUFACTURER convention, the one the contract speaks in. It is
|
|
* the negation of storageCurrentPower() below, and is written as such so the
|
|
* two can never drift apart. */
|
|
double batteryPower(const QList<ModuleReading> &readings);
|
|
|
|
/* Charge minus discharge, so positive means charging. This is the NYMEA
|
|
* convention and the opposite of batteryPower() above.
|
|
*
|
|
* libnymea/interfaces/energystorage.json: "The currentPower state follows the
|
|
* usual convention that positive is 'consumed' energy and negative values
|
|
* represent 'produced' or in this case 'returned' energy." A battery that is
|
|
* charging consumes, so charging is positive.
|
|
*
|
|
* Getting this backwards is not a cosmetic error: the storage reserve of the
|
|
* arbiter would read a discharging battery as charging and free budget it does
|
|
* not have. Measured on the bench rather than deduced - see DIVERGENCES.md D-9. */
|
|
double storageCurrentPower(const QList<ModuleReading> &readings);
|
|
|
|
/* Whether the model 160 blocks carry a battery channel at all. A GEN24 with no
|
|
* battery publishes N=2 (the two PV strings) and no StCha/StDisCha at all, so
|
|
* the absence of both blocks is what "not measurable" looks like on the wire.
|
|
*
|
|
* The distinction matters because storageCurrentPower() answers 0 both for a
|
|
* battery sitting idle and for an inverter that never reports one, and those
|
|
* two zeros mean opposite things to a reserve rule. */
|
|
bool hasBatteryBlocks(const QList<ModuleReading> &readings);
|
|
|
|
}
|
|
|
|
#endif // FRONIUSRULES_H
|