fix(accueil, énergie): charges en attente regroupées, titres lisibles, axes propres

Vu sur le téléphone le 2026-10-16 (première vérification à l'écran).
- « En ce moment » : une ligne complète seulement pour une charge qui AGIT
  (puissance portée sous un motif qui n'est pas une attente, puissance
  mesurée, dérogation, défaut, manque retenu) ; les autres sur une ligne
  discrète « En attente : … » (Patrick). La PAC SG-Ready qui porte 1 200 W
  en état normal maintenu sous BELOW_MIN_POWER attend.
- Motif affiché = celui du niveau qui porte la plus grosse part (règle R7
  d'etm70) : en etm69 la racine disait « rien n'est commandé » pendant que
  l'éco achetait 83 W. LoadDecision lit désormais `level`.
- Écran Énergie : titre de l'écran et titres des cartes de graphe
  invisibles en sombre (sans() = bleu marine par défaut) → inkOf ;
  étiquettes des bords du graphe « Qui consomme » qui se chevauchaient
  (« 09:4510:00 ») retirées ; axe de la prévision qui affichait
  « -0.0kW » pour quelques watts nocturnes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-16 22:12:00 +02:00
parent ae454b703e
commit 250c0fcb6b
10 changed files with 135 additions and 17 deletions

View File

@ -114,10 +114,16 @@ class EnCeMomentView extends StatelessWidget {
String nu(String x) => x.replaceAll(RegExp(r'[{}]'), '').toLowerCase();
final presentes = {for (final e in tel.loads) nu(e.loadId)};
final actives =
tel.loads.where((e) => agit(e, manque: derniersManques[nu(e.loadId)] != null));
final enAttente = [
for (final e in tel.loads)
if (!agit(e, manque: derniersManques[nu(e.loadId)] != null)) libelle(e.loadId)
];
final lignes = <Widget>[
if (tel.degradedMode) note(t.enCeMomentDegrade),
if (tel.loads.isEmpty && !tel.degradedMode) note(t.enCeMomentAucune),
for (final e in tel.loads) _ligne(context, t, e),
for (final e in actives) _ligne(context, t, e),
// Un manque retenu pour une charge qui a quitté `loads[]` (borne débranchée) reste
// visible : c'est précisément le cas où on voudrait savoir.
for (final m in derniersManques.entries)
@ -129,9 +135,61 @@ class EnCeMomentView extends StatelessWidget {
if (i > 0) out.add(Divider(height: 18, color: EtmTokens.lineOf(context)));
out.add(lignes[i]);
}
// Les charges qui ne font rien ce cycle : une ligne discrète, pas une action affichée
// pour dire qu'il n'y en a pas (Patrick, 2026-10-16).
if (enAttente.isNotEmpty) {
if (out.isNotEmpty) out.add(const SizedBox(height: 10));
out.add(Text(t.enCeMomentEnAttente(enAttente.join(', ')),
style: EtmTokens.sans(size: 11.5, color: EtmTokens.faintOf(context))));
}
return out;
}
/// Motifs qui disent « rien de piloté ce cycle » : la charge attend.
static const motifsDAttente = {
'SURPLUS_INSUFFICIENT',
'BELOW_MIN_POWER',
'SG_NORMAL',
'LOCK_MIN_OFF',
'LOCK_MIN_STATE_HOLD',
'EV_IDLE',
};
/// Une charge **agit** quand un de ses niveaux porte de la puissance sous un motif qui
/// n'est pas une attente, qu'elle tire réellement de la puissance, ou qu'il y a quelque
/// chose à dire : dérogation, défaut, objectif manqué retenu.
///
/// La puissance seule ne suffit pas : sur le banc, la PAC SG-Ready porte 1 200 W en état
/// « normal » maintenu sous `BELOW_MIN_POWER` (« le budget passe à la charge suivante ») —
/// rien n'est piloté, elle attend (exemple de Patrick, 2026-10-16).
static bool agit(LoadTelemetryEntry e, {bool manque = false}) {
if (manque || !e.available || e.enDerogation) return true;
if (e.measurement.mesuree && e.measurement.powerW! > 50) return true;
if (e.levels.isEmpty) {
return e.allocatedW > 0 && !motifsDAttente.contains(e.decision.code);
}
return e.levels.any((l) =>
l.targetW > 0 && !motifsDAttente.contains((l.decision ?? e.decision).code));
}
/// Le motif qui DÉCRIT L'ACTION : celui du niveau qui porte la plus grosse part de la
/// consigne (égalité → éco). C'est la règle R7 d'etm70 ; sur un moteur antérieur, la
/// racine portait le motif du DERNIER niveau parcouru — vu sur le banc en etm69 : le
/// chauffe-eau achetait 83 W en éco pendant que la racine disait « rien n'est commandé ».
/// Sur etm70, ce choix rend exactement la racine.
static LoadDecision motifDeLAction(LoadTelemetryEntry e) {
LoadLevel? porteur;
for (final l in e.levels) {
if (l.decision == null || l.targetW <= 0) continue;
if (porteur == null ||
l.targetW > porteur.targetW ||
(l.targetW == porteur.targetW && l.estEco)) {
porteur = l;
}
}
return porteur?.decision ?? e.decision;
}
Widget _ligne(BuildContext context, L10n t, LoadTelemetryEntry e) {
final ink = EtmTokens.inkOf(context);
final muted = EtmTokens.mutedOf(context);
@ -171,7 +229,7 @@ class EnCeMomentView extends StatelessWidget {
Text(libelle(e.loadId),
style: EtmTokens.sans(size: 13.5, weight: FontWeight.w600, color: ink)),
const SizedBox(height: 2),
Text(decisionText(t, e.decision),
Text(decisionText(t, motifDeLAction(e)),
style: EtmTokens.sans(size: 11.5, color: muted).copyWith(height: 1.35)),
if (ventilation != null)
Text(ventilation,

View File

@ -559,5 +559,10 @@
"description": "OPTIMIZER_DECISION (etm70) : motif hors catalogue, sans paramètre. Aucun nom de marque."
},
"ventilationEco": "éco",
"ventilationConfort": "confort"
"ventilationConfort": "confort",
"enCeMomentEnAttente": "En attente : {noms}",
"@enCeMomentEnAttente": {
"description": "Ligne discrète regroupant les charges qui ne font rien ce cycle (Patrick, 2026-10-16 : ne pas afficher une action qui ne fait rien).",
"placeholders": { "noms": { "type": "String" } }
}
}

View File

@ -1058,6 +1058,12 @@ abstract class L10n {
/// In fr, this message translates to:
/// **'confort'**
String get ventilationConfort;
/// Ligne discrète regroupant les charges qui ne font rien ce cycle (Patrick, 2026-10-16 : ne pas afficher une action qui ne fait rien).
///
/// In fr, this message translates to:
/// **'En attente : {noms}'**
String enCeMomentEnAttente(String noms);
}
class _L10nDelegate extends LocalizationsDelegate<L10n> {

View File

@ -817,4 +817,9 @@ class L10nDe extends L10n {
@override
String get ventilationConfort => 'confort';
@override
String enCeMomentEnAttente(String noms) {
return 'En attente : $noms';
}
}

View File

@ -817,4 +817,9 @@ class L10nEn extends L10n {
@override
String get ventilationConfort => 'confort';
@override
String enCeMomentEnAttente(String noms) {
return 'En attente : $noms';
}
}

View File

@ -817,4 +817,9 @@ class L10nFr extends L10n {
@override
String get ventilationConfort => 'confort';
@override
String enCeMomentEnAttente(String noms) {
return 'En attente : $noms';
}
}

View File

@ -352,7 +352,10 @@ class LoadDecision {
final String code;
final Map<String, dynamic> params;
const LoadDecision({required this.code, required this.params});
/// Niveau dont ce motif est publié (`eco`, `comfort`), R7 — `null` si absent.
final String? level;
const LoadDecision({required this.code, required this.params, this.level});
factory LoadDecision.fromJson(Map<String, dynamic>? m) {
if (m == null) return const LoadDecision(code: '', params: {});
@ -360,6 +363,7 @@ class LoadDecision {
return LoadDecision(
code: m['code'] as String? ?? '',
params: p is Map ? Map<String, dynamic>.from(p) : const {},
level: m['level'] as String?,
);
}

View File

@ -543,7 +543,10 @@ class _EnergyScreenState extends State<EnergyScreen> {
leading: const DrawerMenuButton(),
leadingWidth: 64,
title: Text('Énergie',
style: EtmTokens.sans(size: 20, weight: FontWeight.w600)),
style: EtmTokens.sans(
size: 20,
weight: FontWeight.w600,
color: EtmTokens.inkOf(context))),
actions: [
IconButton(
icon: Icon(
@ -1595,7 +1598,12 @@ class _EnergyScreenState extends State<EnergyScreen> {
reservedSize: 22,
interval: _xInterval(),
getTitlesWidget: (v, meta) {
if (v < 0) return const SizedBox.shrink();
// Pas d'étiquette sur les BORDS, même règle que le graphe du dessus :
// fl_chart en pose une à min et à max, qui chevauche sa voisine
// (« 09:4510:00 », vu sur le téléphone le 2026-10-16).
if (v < 0 || v <= meta.min || v >= meta.max) {
return const SizedBox.shrink();
}
return SideTitleWidget(
meta: meta,
child: Text(_fmtTime(_tDe(v.roundToDouble())), style: labelStyle));
@ -2018,8 +2026,13 @@ class _EtmChartCard extends StatelessWidget {
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Couleur du THÈME : `sans()` prend le bleu marine par défaut, invisible
// sur la carte sombre (vu sur le téléphone le 2026-10-16).
Text(title,
style: EtmTokens.sans(size: 16, weight: FontWeight.w600)),
style: EtmTokens.sans(
size: 16,
weight: FontWeight.w600,
color: EtmTokens.inkOf(context))),
Text(subtitle,
style: EtmTokens.sans(size: 11, color: EtmTokens.mutedOf(context))),
],

View File

@ -441,7 +441,10 @@ class PvForecastLineChart extends StatelessWidget {
if (s != FlSpot.nullSpot && s.x >= minX - 1 && s.x <= maxX + 1) s.y
];
final yMax = max(0.5, (valeurs.isEmpty ? 0.0 : valeurs.reduce(max)) * 1.15);
final yMin = min(0.0, valeurs.isEmpty ? 0.0 : valeurs.reduce(min));
// Quelques watts négatifs la nuit (consommation propre de l'onduleur) ne méritent pas
// un axe sous zéro : « -0.0kW » s'affichait. Sous −50 W, c'est une vraie information.
final plusBas = valeurs.isEmpty ? 0.0 : valeurs.reduce(min);
final yMin = plusBas < -0.05 ? plusBas : 0.0;
final label = EtmTokens.sans(size: 9, color: EtmTokens.mutedOf(context));
final solaire = EtmTokens.solarOf(context);
// Le solaire est JAUNE (Patrick, 2026-10-15) : la mesure en doré plein, la prévision

View File

@ -51,19 +51,23 @@ LoadDecision _d(String code, Map<String, dynamic> params) =>
void main() {
final fr = lookupL10n(const Locale('fr'));
testWidgets('trame réelle : un motif par charge, rien d\'inventé, aucune marque',
testWidgets('trame réelle : une ligne par charge qui AGIT, les autres « en attente »',
(t) async {
await t.pumpWidget(_app(_vue(_banc())));
expect(find.text('En ce moment'), findsOneWidget);
// Le chauffe-eau achète 83 W en éco : il agit, et son motif dit l'ACHAT — pas
// « rien n'est commandé », que la racine etm69 publiait (dernier niveau parcouru).
expect(find.text('Chauffe-eau'), findsOneWidget);
expect(find.text('PAC banc'), findsOneWidget);
for (final e in LoadTelemetry.fromJson(_banc()).loads) {
expect(find.text(decisionText(fr, e.decision)), findsOneWidget);
}
expect(find.text('Plancher servi — 83 W achetés au réseau'), findsOneWidget);
expect(find.textContaining('rien n\'est commandé'), findsNothing);
// La PAC SG-Ready porte 1 200 W en état normal maintenu sous BELOW_MIN_POWER : rien de
// piloté — une ligne discrète, pas une action (Patrick, 2026-10-16).
expect(find.text('En attente : PAC banc'), findsOneWidget);
expect(find.text('PAC banc'), findsNothing);
// Les décisions inventées de l'ancienne démo ne peuvent plus sortir.
expect(find.textContaining('Batterie en charge'), findsNothing);
// Le nom de marque, écrit en échappement pour que le grep de la consigne reste vide.
expect(find.textContaining('H\u00e9os'), findsNothing);
expect(find.textContaining('Héos'), findsNothing);
});
testWidgets('la ventilation des niveaux se lit (83 W éco + 0 W confort)', (t) async {
@ -72,9 +76,19 @@ void main() {
});
testWidgets('puissance MESURÉE seulement — jamais l\'allocation à sa place', (t) async {
await t.pumpWidget(_app(_vue(_banc())));
// La PAC mesure 0 W (compteur) ; le chauffe-eau est `source: none`, sans chiffre.
expect(find.text('0,0 kW'), findsOneWidget);
final b = _banc();
final loads = [
for (final l in (b['loads'] as List).cast<Map<String, dynamic>>())
if (l['loadId'] == 'pac-terrain')
{...l, 'measurement': {'source': 'meter', 'powerW': 2100.0}}
else
l,
];
await t.pumpWidget(_app(_vue({...b, 'loads': loads})));
// La PAC qui TIRE 2,1 kW agit, et c'est sa mesure qui s'affiche ; le chauffe-eau
// (`source: none`) n'a pas de chiffre, même s'il a 83 W décidés.
expect(find.text('2,1 kW'), findsOneWidget);
expect(find.text('0,1 kW'), findsNothing);
});
test('etm70 : un motif éco publié à la racine (part majoritaire) se rend en clair', () {