etm-powersync-app/test/consommateurs_test.dart
Patrick Schurig 9c19791e9a feat(énergie): les deux graphes — flux corrigé, et l'empilement par consommateur
Maquette validée par Patrick (couleurs comprises). Deux graphes, et trois
défauts d'affichage trouvés en chemin — dont deux à l'écran seulement.

GRAPHE 1 — les trois « on ne voit pas » avaient trois causes.

  production : publiée NÉGATIVE (−103 W). La magnitude est prise AU RENDU, pas
  au transport : le champ reste brut (§7.1), et `.abs()` est licite sur une
  production, dont le signe ne porte aucune information — contrairement au
  réseau, où le signe dit le sens (d'où gridImportW/gridExportW là-bas).

  autoconsommation : `production − export` clampé à 0, donc TOUJOURS zéro avec
  une production négative. Même racine, corrigée par la même magnitude.

  SOC : correctement absent — aucune batterie sur l'installation. Ce qui
  manquait, c'était de le DIRE ; c'est maintenant une note sous le graphe.

  ⚠️ Et un deuxième empêchement, vu seulement à l'écran : le signe réparé, la
  production restait invisible SOUS l'autoconsommation. Tant que rien n'est
  exporté les deux sont égales au pixel près, et la verte était tracée par
  dessus. L'autoconso passe en pointillé — des tirets sur la bande ambre disent
  « tout est consommé sur place », leur décrochage dit l'export. Deux courbes
  qui coïncident doivent pouvoir se VOIR coïncider.

  ⚠️ Ce correctif en créait un autre : le SOC était aussi vert pointillé. Sur
  une installation AVEC batterie les deux auraient été indiscernables — collision
  qu'aucun banc sans batterie ne pouvait montrer. Le SOC passe en indigo.

GRAPHE 2 — qui consomme, empilé.

  `Energy.GetThingPowerLogs`, et la liste des appareils vient de GetLoadConfig :
  chaque charge déclarée porte son `meterThingId`, son label et son domaine —
  vérifié renseigné sur les quatre charges de .75. Rien n'est deviné d'un nom
  d'appareil.

  Un appareil demandé sans réponse sort dans `sansHistorique` et est NOMMÉ sous
  le graphe, jamais tracé à zéro : la V2C ne déclare pas `smartmeter`, donc elle
  n'a pas de passé — ce qui n'est pas « elle n'a rien tiré ».

  Le RESTE du foyer est licite ici, contrairement au Sankey qui l'avait fait
  interdire : chaque part est un compteur dédié et le total est le compteur du
  foyer, donc la soustraction ne porte que du mesuré. Et un reste NÉGATIF n'est
  pas raboté — c'est la preuve qu'un sous-compteur n'est pas dans le périmètre
  du total, et l'écraser effacerait la seule chose qui le dise.

  🔴 Le signe des sous-compteurs n'est PAS vérifié : les trois lisent 0 W à tous
  les pas, donc aucun échantillon non nul n'existe. Le compteur PV publie en
  négatif — rien n'autorise à supposer. La valeur est transportée brute et
  `signeIndetermine` le dit à l'écran.

Couleurs par consommateur ajoutées au thème (pac ambre, ecs sarcelle, ev
indigo) : distinctes des couleurs de FLUX, qui disent d'où vient l'énergie, là
où celles-ci disent qui la prend.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-08 10:25:16 +02:00

128 lines
5.4 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/// **Le graphe par consommateur — ce qu'il a le droit de dire, et ce qu'il doit taire.**
///
/// Trois faits mesurés sur `.75` le 2026-09-08 tiennent tout ce fichier :
///
/// - six Things demandés à `Energy.GetThingPowerLogs`, **cinq répondent** — la V2C Trydan
/// ne déclare pas l'interface `smartmeter` ;
/// - les trois sous-compteurs (PAC, ECS, borne simulée) lisent **0 W à tous les pas** ;
/// - `production` est publiée **négative** par la box.
///
/// Chacun crée une manière différente de mentir à l'écran, et aucune ne se voit sans y
/// penser : une série absente tracée à zéro, un empilement présenté comme établi alors
/// qu'aucune valeur non nulle ne l'a validé, et deux courbes invisibles pour une raison
/// de signe.
library;
import 'package:etm_powersync_app/models/consumer_series.dart';
import 'package:etm_powersync_app/services/energy_ratios.dart';
import 'package:flutter_test/flutter_test.dart';
ConsumerSeries _serie(String id, String label, String? dom, List<double> w) =>
ConsumerSeries(
thingId: id,
label: label,
domaine: dom,
points: [
for (int i = 0; i < w.length; i++)
ConsumerPoint(DateTime(2026, 9, 8, 0, 15 * i), w[i])
],
);
void main() {
group('une série ABSENTE n\'est pas une série à zéro', () {
test('l\'appareil sans historique est NOMMÉ, pas escamoté', () {
final h = ConsumerHistory(
series: [_serie('ecs', 'chauffe-eau', 'ecs', [0, 0, 0])],
sansHistorique: const ['V2C Trydan'],
);
expect(h.series, hasLength(1));
expect(h.sansHistorique, ['V2C Trydan']);
// Le contrôle qui MORD : la borne ne doit jamais devenir une série de zéros.
expect(h.series.any((s) => s.label == 'V2C Trydan'), isFalse,
reason: 'une ligne plate affirmerait « elle n\'a rien tiré », '
'quand la vérité est « elle n\'a pas de passé »');
});
test('une série à zéro est une MESURE, et elle reste tracée', () {
final s = _serie('pac', 'PAC banc', 'heating', [0, 0, 0, 0]);
expect(s.toutAZero, isTrue);
expect(s.points, hasLength(4),
reason: 'un compteur dédié qui lit 0 W dit « rien ne chauffe » — '
'c\'est exactement ce qu\'il faut montrer');
expect(s.maxW, 0);
});
test('une série vide n\'est pas « toute à zéro »', () {
// Deux états distincts sous une même apparence à l'écran : aucun point du tout
// (rien demandé, rien reçu) et des points tous nuls (mesurés).
const vide = ConsumerSeries(
thingId: 'x', label: 'x', domaine: null, points: []);
expect(vide.toutAZero, isFalse);
});
});
group('le signe des sous-compteurs reste INVÉRIFIÉ tant que tout vaut zéro', () {
test('trois compteurs à zéro : la question est ouverte, et le dit', () {
final h = ConsumerHistory(series: [
_serie('pac', 'PAC banc', 'heating', [0, 0]),
_serie('ecs', 'chauffe-eau', 'ecs', [0, 0]),
], sansHistorique: const []);
expect(h.signeIndetermine, isTrue,
reason: 'le compteur PV publie sa puissance en NÉGATIF sur cette box — '
'rien n\'autorise à supposer la convention des consommateurs');
});
test('une seule valeur non nulle referme la question', () {
final h = ConsumerHistory(series: [
_serie('pac', 'PAC banc', 'heating', [0, 0]),
_serie('ecs', 'chauffe-eau', 'ecs', [0, 1500]),
], sansHistorique: const []);
expect(h.signeIndetermine, isFalse);
});
});
group('l\'axe des instants ne s\'invente pas', () {
test('il vient de la série la plus longue, sans interpolation', () {
final h = ConsumerHistory(series: [
_serie('a', 'a', null, [1, 2]),
_serie('b', 'b', null, [1, 2, 3, 4]),
], sansHistorique: const []);
expect(h.instants, hasLength(4));
});
test('sans série, aucun instant — et pas une liste de secours', () {
expect(ConsumerHistory.vide.instants, isEmpty);
expect(ConsumerHistory.vide.estVide, isTrue);
});
});
group('la production NÉGATIVE, et les deux courbes qu\'elle effaçait', () {
// Signalé par Patrick le 2026-09-08 : « dans le premier chart on ne voit pas la
// production ni l'autoconsommation ». Une seule cause pour les deux.
const productionPubliee = -103.0; // relevé sur .75
const acquisition = 3000.0; // soutirage, donc pas d'export
test('telle quelle, l\'autoconsommation est clouée à zéro', () {
final self = EnergyRatiosInterim.selfConsumptionPower(
productionPubliee, acquisition);
expect(self, 0,
reason: '`production − export` puis clamp à 0 : avec une production '
'négative le résultat ne peut JAMAIS être autre chose');
});
test('sur la magnitude, elle rend la vraie valeur', () {
final self = EnergyRatiosInterim.selfConsumptionPower(
productionPubliee.abs(), acquisition);
expect(self, 103,
reason: 'rien n\'est exporté, donc tout le produit est autoconsommé');
});
test('et l\'export reste soustrait quand il y en a', () {
// Le `.abs()` porte sur la PRODUCTION seule. Le réseau garde son signe, parce que
// là le signe dit le SENS — c'est ce qui distingue ce cas de celui du réseau.
final self = EnergyRatiosInterim.selfConsumptionPower(5000, -1200);
expect(self, 3800);
});
});
}