Patrick Schurig 1cfa9b68b1 feat: journaliser la divergence, et publier la frontière de période de reporting
DIVERGENCE JOURNALISÉE. `divergent` et `since` ne vivaient que dans la charge
utile de télémétrie et dans un QHash en mémoire — donc nulle part. Rien ne
comptait les épisodes : chaque redémarrage de nymead effaçait l'état, et le hash
est PURGÉ dès que l'écart cesse, si bien qu'une divergence qui s'ouvre et se
referme entre deux lectures ne laissait aucune trace. Un observateur externe
échantillonnant en continu aurait raté exactement les épisodes courts, qui sont
probablement les plus nombreux.

Conséquence : le critère « attendre que les traces parlent » attendait
indéfiniment. Le silence du journal était garanti par construction, pas par
l'absence du phénomène — l'instrument dont le silence ressemble à un résultat.
Deux `qCInfo`, aux deux transitions, avec la durée à la fermeture. Le journal de
.75 est persistant depuis le 2026-08-13 (200 Mo, un mois) : le comptage devient
trivial, et l'étape 4 pourra se décider sur des faits.

FRONTIÈRE DE PÉRIODE PUBLIÉE (LM-1005-b-iv). `System.GetTime` donne un NOM de
fuseau ; un décalage ne s'en déduit pas sans base de fuseaux, et embarquer une
base pour recalculer une frontière que le moteur connaît déjà resterait faux les
jours de changement d'heure, où la journée fait 23 ou 25 heures. Trois clients la
recalculeraient de trois façons, et la seule qui compte est celle qui a servi au
calcul. Même règle que `counts`.

C'est aussi la parade à la troisième nature d'heure murale : la période de
reporting suit l'installation, correctement, mais repose sur la prémisse que le
fuseau de la box SOIT celui de l'installation — et c'était le seul des trois cas
où personne ne vérifiait. `timeZone` rend la vérification possible.

DEUX CHAMPS, PAS UN (LM-1005-b-v), et c'est un ajout à la demande. La baseline se
repose à chaque redémarrage de nymead et à chaque recul de compteur, donc EN
PLEIN JOUR. Un jour où nymead a redémarré à 14 h, les ratios ne couvrent pas la
journée : ils couvrent depuis 14 h. Publier la seule frontière ferait écrire
« aujourd'hui » sur des chiffres qui ne parlent que de l'après-midi — un silence
de la même famille que celui qu'on ferme. `measuredSince` dit la portée réelle ;
quand les deux coïncident, le cas normal, il n'y a rien à dire de plus, et c'est
ce qui rend l'écart lisible le jour où il apparaît. Même paire, même raison que
`progress.measuredSince` / `targetWh`.

Les trois champs sont TOUJOURS présents, même quand les deux taux sont omis :
« la période a commencé à minuit et rien n'est encore mesurable » est une phrase
vraie et utile, là où une réponse vide ne se distingue pas d'une panne.

INVARIANT 11 au passage : le schéma du Get et celui du Changed étaient déclarés
DEUX fois. Ajouter trois champs à deux endroits est exactement la manière dont
ils divergent — d'où `energyRatiosSchema()`, déclaré une fois et servi aux deux,
comme `loadTelemetrySchema()`.

TEST vu mordre : remplacer `measuredSince` par la frontière — l'implémentation
naïve que la demande aurait pu produire — fait échouer l'assertion sur la repose
en plein jour (attendu 10:00, obtenu 00:00).

Suites : simulation 66/66, loadmodel 23/23, charging 48/48, spotmarket 32/32.
2026-09-05 08:01:16 +02:00

103 lines
4.2 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-energy-plugin-nymea.
*
* nymea-energy-plugin-nymea.s 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-energy-plugin-nymea.s 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-energy-plugin-nymea. If not, see <https://www.gnu.org/licenses/>.
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
#include "energyratioscalculator.h"
#include <QTimeZone>
#include <algorithm>
void EnergyRatiosCalculator::reset()
{
m_hasBaseline = false;
m_baseProduction = 0.0;
m_baseReturn = 0.0;
m_baseConsumption = 0.0;
m_baseAcquisition = 0.0;
m_baseDay = 0;
// Sans cette ligne, un `mesureDepuis` périmé survivrait au reset et daterait la mesure
// d'avant l'installation qu'on vient de changer.
m_baselinePosee = QDateTime();
}
EnergyRatiosCalculator::Ratios EnergyRatiosCalculator::compute(double totalProduction,
double totalReturn,
double totalConsumption,
double totalAcquisition,
const QDateTime &now)
{
const qint64 day = localDay(now);
// Reseed si : 1er appel, nouveau jour local, OU compteur non monotone
// (Δ<0 → restart nymead / re-add thing / rollover). Jamais de ratio négatif.
const bool nonMonotone = m_hasBaseline &&
(totalProduction < m_baseProduction ||
totalReturn < m_baseReturn ||
totalConsumption < m_baseConsumption ||
totalAcquisition < m_baseAcquisition);
if (!m_hasBaseline || m_baseDay != day || nonMonotone) {
m_hasBaseline = true;
m_baseDay = day;
m_baseProduction = totalProduction;
m_baseReturn = totalReturn;
m_baseConsumption = totalConsumption;
m_baseAcquisition = totalAcquisition;
// L'instant de pose, distinct de la frontière de journée : un redémarrage de nymead
// repose la baseline en plein jour, et les ratios ne couvrent alors que depuis là.
m_baselinePosee = now;
}
const double dProduction = totalProduction - m_baseProduction;
const double dReturn = totalReturn - m_baseReturn;
const double dConsumption = totalConsumption - m_baseConsumption;
const double dAcquisition = totalAcquisition - m_baseAcquisition;
Ratios result;
result.autoconsommationValid = ratio(dProduction - dReturn, dProduction, &result.autoconsommation);
result.autonomieValid = ratio(dConsumption - dAcquisition, dConsumption, &result.autonomie);
// La frontière se construit dans le fuseau de `now` — le même que celui qui a servi à
// décider du reseed. En prendre un autre ferait publier une frontière que le calcul
// n'a pas utilisée.
result.debutPeriode = QDateTime(now.date(), QTime(0, 0), now.timeZone());
result.mesureDepuis = m_baselinePosee;
return result;
}
// Ratio en % borné [0, 100]. Dénominateur ≤ 0 (nuit sans prod, etc.) → n/a
// (retourne false) — jamais de NaN.
bool EnergyRatiosCalculator::ratio(double numerator, double denominator, double *outPercent)
{
if (denominator <= 0)
return false;
*outPercent = std::clamp(numerator / denominator * 100.0, 0.0, 100.0);
return true;
}
qint64 EnergyRatiosCalculator::localDay(const QDateTime &now)
{
// Index de jour local stable (minuit local) — équivalent au _localDay interim.
return now.date().toJulianDay();
}