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
135 lines
4.3 KiB
C++
135 lines
4.3 KiB
C++
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
|
|
*
|
|
* Copyright (C) 2013 - 2024, nymea GmbH
|
|
* Copyright (C) 2024 - 2025, chargebyte austria GmbH
|
|
*
|
|
* 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 "sunspecstoragelogic.h"
|
|
|
|
#include <QtMath>
|
|
|
|
namespace SunSpecStorageLogic {
|
|
|
|
StorageStates readStates(SunSpecStorageModel *storage)
|
|
{
|
|
StorageStates states;
|
|
if (!storage)
|
|
return states;
|
|
|
|
/* InWRte / OutWRte are what the chargingRate / dischargingRate actions
|
|
* write. Reading the gradients here instead would mean the user writes one
|
|
* quantity and reads back another. */
|
|
states.chargingRate = storage->inWRte();
|
|
states.dischargingRate = storage->outWRte();
|
|
|
|
/* The gradients stay available, under names that cannot be mistaken for the
|
|
* setpoints above. */
|
|
states.chargeGradient = storage->wChaGra();
|
|
states.dischargeGradient = storage->wDisChaGra();
|
|
|
|
const SunSpecStorageModel::Storctl_modFlags controlMode = storage->storCtlMod();
|
|
states.enableCharging = controlMode.testFlag(SunSpecStorageModel::Storctl_modCharge);
|
|
states.enableDischarging = controlMode.testFlag(SunSpecStorageModel::Storctl_modDiScharge);
|
|
|
|
states.gridCharging = (storage->chaGriSet() == SunSpecStorageModel::ChagrisetGrid);
|
|
|
|
states.batteryLevel = qRound(storage->chaState());
|
|
states.batteryCritical = storage->chaState() < 5;
|
|
|
|
return states;
|
|
}
|
|
|
|
double nameplateCapacityKwh(double ratedEnergyWh)
|
|
{
|
|
if (ratedEnergyWh <= 0)
|
|
return -1;
|
|
|
|
return ratedEnergyWh / 1000.0;
|
|
}
|
|
|
|
bool isScaledRate(Register reg)
|
|
{
|
|
return reg == RegisterInWRte || reg == RegisterOutWRte;
|
|
}
|
|
|
|
QString registerName(Register reg)
|
|
{
|
|
switch (reg) {
|
|
case RegisterChaGriSet: return QStringLiteral("ChaGriSet");
|
|
case RegisterStorCtlMod: return QStringLiteral("StorCtl_Mod");
|
|
case RegisterInWRte: return QStringLiteral("InWRte");
|
|
case RegisterOutWRte: return QStringLiteral("OutWRte");
|
|
case RegisterRvrtTms: return QStringLiteral("InOutWRte_RvrtTms");
|
|
}
|
|
return QString();
|
|
}
|
|
|
|
/* Rank within the write order: ChaGriSet, then StorCtl_Mod, then the rates,
|
|
* then the revert timer. Arming the timer first would start it counting against
|
|
* a setpoint that has not been written yet. */
|
|
static int writeOrderRank(Register reg)
|
|
{
|
|
switch (reg) {
|
|
case RegisterChaGriSet: return 0;
|
|
case RegisterStorCtlMod: return 1;
|
|
case RegisterInWRte:
|
|
case RegisterOutWRte: return 2;
|
|
case RegisterRvrtTms: return 3;
|
|
}
|
|
return 3;
|
|
}
|
|
|
|
SetpointPlan planSetpoint(const QVector<Write> &setpoints, quint16 revertTime)
|
|
{
|
|
SetpointPlan plan;
|
|
|
|
QVector<Write> writes;
|
|
foreach (const Write &write, setpoints) {
|
|
/* The revert time is appended below, once, in last position. A caller
|
|
* passing it explicitly must not end up with two of them. */
|
|
if (write.reg == RegisterRvrtTms)
|
|
continue;
|
|
|
|
if (isScaledRate(write.reg))
|
|
plan.rereadScaleFactor = true;
|
|
|
|
writes.append(write);
|
|
}
|
|
|
|
std::stable_sort(writes.begin(), writes.end(), [](const Write &left, const Write &right) {
|
|
return writeOrderRank(left.reg) < writeOrderRank(right.reg);
|
|
});
|
|
|
|
/* Every register that carries the intent is read back afterwards. The
|
|
* revert timer is excluded: it is a countdown, so its read-back value is
|
|
* not expected to match what was written. */
|
|
foreach (const Write &write, writes) {
|
|
if (!plan.verifyReadback.contains(write.reg))
|
|
plan.verifyReadback.append(write.reg);
|
|
}
|
|
|
|
plan.writes = writes;
|
|
plan.writes.append({RegisterRvrtTms, static_cast<double>(revertTime)});
|
|
|
|
return plan;
|
|
}
|
|
|
|
}
|