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:
parent
ae454b703e
commit
250c0fcb6b
@ -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,
|
||||
|
||||
@ -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" } }
|
||||
}
|
||||
}
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -817,4 +817,9 @@ class L10nDe extends L10n {
|
||||
|
||||
@override
|
||||
String get ventilationConfort => 'confort';
|
||||
|
||||
@override
|
||||
String enCeMomentEnAttente(String noms) {
|
||||
return 'En attente : $noms';
|
||||
}
|
||||
}
|
||||
|
||||
@ -817,4 +817,9 @@ class L10nEn extends L10n {
|
||||
|
||||
@override
|
||||
String get ventilationConfort => 'confort';
|
||||
|
||||
@override
|
||||
String enCeMomentEnAttente(String noms) {
|
||||
return 'En attente : $noms';
|
||||
}
|
||||
}
|
||||
|
||||
@ -817,4 +817,9 @@ class L10nFr extends L10n {
|
||||
|
||||
@override
|
||||
String get ventilationConfort => 'confort';
|
||||
|
||||
@override
|
||||
String enCeMomentEnAttente(String noms) {
|
||||
return 'En attente : $noms';
|
||||
}
|
||||
}
|
||||
|
||||
@ -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?,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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))),
|
||||
],
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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', () {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user