Patrick Schurig 89a5c06f7f fix: ancrer les quatre instants stockés à la capture, pas au rendu
DÉFAUT DE CLASSE, trouvé par l'audit demandé après la bascule de fuseau. Quatre
instants étaient stockés depuis QDateTime::currentDateTime(), donc en
spécification Qt::LocalTime : un tel objet ne garde que des composantes d'HORLOGE
MURALE et résout son époque au moment de la CONVERSION, avec le fuseau courant.

  m_baselinePosee        → GetEnergyRatios.measuredSince
  m_ecoMesureDepuis      → progress.measuredSince
  m_divergenceDepuis     → command.since
  m_lastTelemetryPublish → alimente le précédent

Mesuré sur la box le 2026-09-06, bascule Paris→London sans redémarrage :
measuredSince 1788645607 → 1788649207, +3600 s. Le champ posé pour rendre une
couture lisible était lui-même déplacé par la couture.

ET `.toUTC()` AU RENDU NE PROTÉGEAIT DE RIEN — c'est le piège, parce que les
quatre rendus l'appelaient déjà et avaient l'air corrects. Démontré par un banc Qt
avec bascule de /etc/localtime sous un objet déjà stocké :

  AVANT  stocké-local → 1788755728    stocké-UTC → 1788755728  (Europe/Paris)
  APRÈS  stocké-local → 1788759328    stocké-UTC → 1788755728  (Europe/London)

Correctif : `.toUTC()` À LA CAPTURE. periodStartedAt reste construit dans le
fuseau courant — c'est une frontière locale, elle DOIT bouger. Les deux champs
n'ont pas la même nature et le stockage le reflète maintenant.

TEST avec son oracle : l'ancrage tient sous re-rendu, la contre-épreuve reproduit
la dérive de +3600 s, et une quatrième assertion dit ce qui compte — le même
décalage est un DÉFAUT sur l'instant de pose et le COMPORTEMENT ATTENDU sur la
frontière. Le nombre ne dit pas lequel on regarde ; seule la nature du champ le
dit.

SIXIÈME FORME D'HOMOGÉNÉITÉ (AGENTS.md) : ce n'est plus le mock, c'est
L'ENVIRONNEMENT DE VÉRIFICATION. Box et téléphone en Europe/Paris pendant toute la
campagne : à décalage nul, un instant rendu en UTC, en heure box ou en heure
téléphone donne le MÊME TEXTE. Aucun défaut de conversion ne pouvait se voir, et
rien dans le code ne pouvait le signaler — la prémisse fausse n'est pas dans le
banc, elle est dans la coïncidence de deux réglages extérieurs.

D'où le geste : basculer le fuseau de temps en temps est un RÉVÉLATEUR, pas
seulement une manipulation de capture. Quinze minutes en Europe/London ont dénoncé
deux défauts, dont un qui avait traversé toute la campagne.

Suites : loadmodel 25/25 (+1), simulation 67/67, charging 48/48, spotmarket 32/32.
NON DÉPLOYÉ.
2026-09-07 06:44:05 +02:00

113 lines
4.9 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à.
//
// ANCRÉ EN UTC À LA CAPTURE, et c'est indispensable. Un QDateTime en spécification
// `Qt::LocalTime` ne garde que des composantes d'HORLOGE MURALE : son époque se résout
// au moment de la CONVERSION, avec le fuseau courant. Stocké tel quel, cet instant
// se déplaçait d'une heure au changement de fuseau — mesuré le 2026-09-06, bascule
// Paris→London : 1788645607 → 1788649207. Le champ posé pour rendre une couture
// lisible était lui-même déplacé par la couture.
//
// Un `.toUTC()` AU RENDU n'y aurait rien changé : il interprète les composantes murales
// avec le fuseau du moment. Seul l'ancrage à la capture tient.
m_baselinePosee = now.toUTC();
}
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();
}