fix: graphe énergie — double axe kW/SOC% visible + simulation SOC réaliste

NymeaService
- batterySOCSource : retourne {'thingId': 'sim-battery', 'stateName': 'soc'}
  en simulation au lieu de null — le SOC est désormais fetchable
- fetchHistory en simulation : données SOC réalistes en % (0-100) avec
  courbe charge solaire 8h-15h (20→85%) puis décharge soir (85→20%)
  au lieu de valeurs sinus 100-900 W incorrectes

EnergyScreen
- initState : appelle startSimulation() si non connecté (comme les autres écrans)
- leftTitles : interval explicite (yMax/4) + SideTitleWidget pour ancrage correct
- gridData : horizontalInterval aligné sur yInterval
- rightTitles (SOC %) : SideTitleWidget + interval aligné
- bottomTitles : SideTitleWidget sur les deux graphes
- barChart leftTitles : interval + SideTitleWidget

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig ETM-Schurig 2026-05-29 22:33:18 +02:00
parent 7d71bea527
commit dd798ae85e
2 changed files with 67 additions and 32 deletions

View File

@ -54,6 +54,7 @@ class _EnergyScreenState extends State<EnergyScreen> {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_nymeaService = context.read<NymeaService>();
if (!_nymeaService!.connected) _nymeaService!.startSimulation();
_nymeaService!.addListener(_onServiceChangedForSoc);
_fetch();
});
@ -298,7 +299,8 @@ class _EnergyScreenState extends State<EnergyScreen> {
}
final xInterval = _xInterval();
final labelStyle = EtmTokens.sans(size: 9, color: EtmTokens.muted);
final labelStyle = EtmTokens.sans(size: 9, color: EtmTokens.muted);
final yInterval = yMax / 4; // 5 graduations : 0, 25%, 50%, 75%, 100% de yMax
return LineChart(
LineChartData(
@ -308,52 +310,67 @@ class _EnergyScreenState extends State<EnergyScreen> {
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: yInterval,
getDrawingHorizontalLine: (_) =>
FlLine(color: EtmTokens.line, strokeWidth: 1),
),
borderData: FlBorderData(show: false),
titlesData: FlTitlesData(
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
// Axe gauche : kW
leftTitles: AxisTitles(
axisNameWidget: Text('kW', style: EtmTokens.sans(size: 9, color: EtmTokens.muted)),
axisNameSize: 14,
sideTitles: SideTitles(
showTitles: true,
reservedSize: 42,
getTitlesWidget: (v, _) => Text(
v.toStringAsFixed(1),
style: labelStyle,
textAlign: TextAlign.right,
),
reservedSize: 46,
interval: yInterval,
getTitlesWidget: (v, meta) {
if (v == meta.max) return const SizedBox.shrink(); // évite label dupliqué au sommet
return SideTitleWidget(
meta: meta,
child: Text(
'${v.toStringAsFixed(1)}kW',
style: labelStyle,
),
);
},
),
),
// Axe droit : SOC %
rightTitles: hasSoc
? AxisTitles(
axisNameWidget: Text('%', style: EtmTokens.sans(size: 9, color: EtmTokens.green)),
axisNameSize: 14,
sideTitles: SideTitles(
showTitles: true,
reservedSize: 34,
interval: yMax / 4,
getTitlesWidget: (v, _) {
reservedSize: 40,
interval: yInterval,
getTitlesWidget: (v, meta) {
if (v == meta.max) return const SizedBox.shrink();
final pct = (v / yMax * 100).round();
if (pct < 0 || pct > 100) return const SizedBox.shrink();
return Text('$pct%',
style: EtmTokens.sans(size: 9, color: EtmTokens.green));
return SideTitleWidget(
meta: meta,
child: Text(
'$pct%',
style: EtmTokens.sans(size: 9, color: EtmTokens.green),
),
);
},
),
)
: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
// Axe bas : temps
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 20,
reservedSize: 22,
interval: xInterval,
getTitlesWidget: (v, _) {
getTitlesWidget: (v, meta) {
final idx = v.round();
if (idx < 0 || idx >= _data.length) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: 4),
return SideTitleWidget(
meta: meta,
child: Text(_fmtTime(_data[idx].timestamp), style: labelStyle),
);
},
@ -457,22 +474,28 @@ class _EnergyScreenState extends State<EnergyScreen> {
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 46,
getTitlesWidget: (v, _) => Text(_fmtWh(v),
style: labelStyle, textAlign: TextAlign.right),
reservedSize: 52,
interval: maxE * 1.15 / 4,
getTitlesWidget: (v, meta) {
if (v == meta.max) return const SizedBox.shrink();
return SideTitleWidget(
meta: meta,
child: Text(_fmtWh(v), style: labelStyle),
);
},
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 20,
getTitlesWidget: (v, _) {
reservedSize: 22,
getTitlesWidget: (v, meta) {
final idx = v.toInt();
if (idx < 0 || idx >= _data.length) return const SizedBox.shrink();
final step = _xInterval().toInt().clamp(1, _data.length);
if (idx % step != 0) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: 4),
return SideTitleWidget(
meta: meta,
child: Text(_fmtTime(_data[idx].timestamp), style: labelStyle),
);
},

View File

@ -125,9 +125,11 @@ class NymeaService extends ChangeNotifier {
/// Retourne {thingId, stateName} de la première batterie trouvée,
/// ou null si aucune batterie configurée / pas encore chargée.
/// Non disponible en simulation (fetchHistory retourne des W, pas des %).
Map<String, String>? get batterySOCSource {
if (_isSimulation) return null;
// En simulation : retourne directement la batterie simulée.
if (_isSimulation) {
return {'thingId': 'sim-battery', 'stateName': 'soc'};
}
if (_things.isEmpty) {
_log('🔋 batterySOCSource: things pas encore chargés', force: true);
return null;
@ -1137,14 +1139,24 @@ class NymeaService extends ChangeNotifier {
String sampleRate = 'SampleRate15Mins',
}) async {
if (_isSimulation) {
// Données sinusoïdales simulées
// SOC simulé : charge dans la journée (solaire) puis décharge le soir.
// Valeurs en % (0-100) pour être normalisables côté graphe.
final entries = <HistoryEntry>[];
const steps = 48;
final step = to.difference(from) ~/ steps;
for (int i = 0; i <= steps; i++) {
final t = from.add(step * i);
final v = 500 + 400 * sin(i * pi / 12);
entries.add(HistoryEntry(timestamp: t, value: v));
final hour = t.hour + t.minute / 60.0;
// Montée solaire 8h-15h 20% 85%, descente soir 20%
final double soc;
if (hour < 8) {
soc = 20 + 5 * (hour / 8);
} else if (hour < 15) {
soc = 25 + 60 * ((hour - 8) / 7);
} else {
soc = 85 - 65 * ((hour - 15) / 9);
}
entries.add(HistoryEntry(timestamp: t, value: soc.clamp(5.0, 95.0)));
}
return entries;
}