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:
parent
7d71bea527
commit
dd798ae85e
@ -54,6 +54,7 @@ class _EnergyScreenState extends State<EnergyScreen> {
|
|||||||
super.initState();
|
super.initState();
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
_nymeaService = context.read<NymeaService>();
|
_nymeaService = context.read<NymeaService>();
|
||||||
|
if (!_nymeaService!.connected) _nymeaService!.startSimulation();
|
||||||
_nymeaService!.addListener(_onServiceChangedForSoc);
|
_nymeaService!.addListener(_onServiceChangedForSoc);
|
||||||
_fetch();
|
_fetch();
|
||||||
});
|
});
|
||||||
@ -298,7 +299,8 @@ class _EnergyScreenState extends State<EnergyScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final xInterval = _xInterval();
|
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(
|
return LineChart(
|
||||||
LineChartData(
|
LineChartData(
|
||||||
@ -308,52 +310,67 @@ class _EnergyScreenState extends State<EnergyScreen> {
|
|||||||
gridData: FlGridData(
|
gridData: FlGridData(
|
||||||
show: true,
|
show: true,
|
||||||
drawVerticalLine: false,
|
drawVerticalLine: false,
|
||||||
|
horizontalInterval: yInterval,
|
||||||
getDrawingHorizontalLine: (_) =>
|
getDrawingHorizontalLine: (_) =>
|
||||||
FlLine(color: EtmTokens.line, strokeWidth: 1),
|
FlLine(color: EtmTokens.line, strokeWidth: 1),
|
||||||
),
|
),
|
||||||
borderData: FlBorderData(show: false),
|
borderData: FlBorderData(show: false),
|
||||||
titlesData: FlTitlesData(
|
titlesData: FlTitlesData(
|
||||||
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||||
|
|
||||||
|
// Axe gauche : kW
|
||||||
leftTitles: AxisTitles(
|
leftTitles: AxisTitles(
|
||||||
axisNameWidget: Text('kW', style: EtmTokens.sans(size: 9, color: EtmTokens.muted)),
|
|
||||||
axisNameSize: 14,
|
|
||||||
sideTitles: SideTitles(
|
sideTitles: SideTitles(
|
||||||
showTitles: true,
|
showTitles: true,
|
||||||
reservedSize: 42,
|
reservedSize: 46,
|
||||||
getTitlesWidget: (v, _) => Text(
|
interval: yInterval,
|
||||||
v.toStringAsFixed(1),
|
getTitlesWidget: (v, meta) {
|
||||||
style: labelStyle,
|
if (v == meta.max) return const SizedBox.shrink(); // évite label dupliqué au sommet
|
||||||
textAlign: TextAlign.right,
|
return SideTitleWidget(
|
||||||
),
|
meta: meta,
|
||||||
|
child: Text(
|
||||||
|
'${v.toStringAsFixed(1)}kW',
|
||||||
|
style: labelStyle,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Axe droit : SOC %
|
||||||
rightTitles: hasSoc
|
rightTitles: hasSoc
|
||||||
? AxisTitles(
|
? AxisTitles(
|
||||||
axisNameWidget: Text('%', style: EtmTokens.sans(size: 9, color: EtmTokens.green)),
|
|
||||||
axisNameSize: 14,
|
|
||||||
sideTitles: SideTitles(
|
sideTitles: SideTitles(
|
||||||
showTitles: true,
|
showTitles: true,
|
||||||
reservedSize: 34,
|
reservedSize: 40,
|
||||||
interval: yMax / 4,
|
interval: yInterval,
|
||||||
getTitlesWidget: (v, _) {
|
getTitlesWidget: (v, meta) {
|
||||||
|
if (v == meta.max) return const SizedBox.shrink();
|
||||||
final pct = (v / yMax * 100).round();
|
final pct = (v / yMax * 100).round();
|
||||||
if (pct < 0 || pct > 100) return const SizedBox.shrink();
|
if (pct < 0 || pct > 100) return const SizedBox.shrink();
|
||||||
return Text('$pct%',
|
return SideTitleWidget(
|
||||||
style: EtmTokens.sans(size: 9, color: EtmTokens.green));
|
meta: meta,
|
||||||
|
child: Text(
|
||||||
|
'$pct%',
|
||||||
|
style: EtmTokens.sans(size: 9, color: EtmTokens.green),
|
||||||
|
),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||||
|
|
||||||
|
// Axe bas : temps
|
||||||
bottomTitles: AxisTitles(
|
bottomTitles: AxisTitles(
|
||||||
sideTitles: SideTitles(
|
sideTitles: SideTitles(
|
||||||
showTitles: true,
|
showTitles: true,
|
||||||
reservedSize: 20,
|
reservedSize: 22,
|
||||||
interval: xInterval,
|
interval: xInterval,
|
||||||
getTitlesWidget: (v, _) {
|
getTitlesWidget: (v, meta) {
|
||||||
final idx = v.round();
|
final idx = v.round();
|
||||||
if (idx < 0 || idx >= _data.length) return const SizedBox.shrink();
|
if (idx < 0 || idx >= _data.length) return const SizedBox.shrink();
|
||||||
return Padding(
|
return SideTitleWidget(
|
||||||
padding: const EdgeInsets.only(top: 4),
|
meta: meta,
|
||||||
child: Text(_fmtTime(_data[idx].timestamp), style: labelStyle),
|
child: Text(_fmtTime(_data[idx].timestamp), style: labelStyle),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -457,22 +474,28 @@ class _EnergyScreenState extends State<EnergyScreen> {
|
|||||||
leftTitles: AxisTitles(
|
leftTitles: AxisTitles(
|
||||||
sideTitles: SideTitles(
|
sideTitles: SideTitles(
|
||||||
showTitles: true,
|
showTitles: true,
|
||||||
reservedSize: 46,
|
reservedSize: 52,
|
||||||
getTitlesWidget: (v, _) => Text(_fmtWh(v),
|
interval: maxE * 1.15 / 4,
|
||||||
style: labelStyle, textAlign: TextAlign.right),
|
getTitlesWidget: (v, meta) {
|
||||||
|
if (v == meta.max) return const SizedBox.shrink();
|
||||||
|
return SideTitleWidget(
|
||||||
|
meta: meta,
|
||||||
|
child: Text(_fmtWh(v), style: labelStyle),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
bottomTitles: AxisTitles(
|
bottomTitles: AxisTitles(
|
||||||
sideTitles: SideTitles(
|
sideTitles: SideTitles(
|
||||||
showTitles: true,
|
showTitles: true,
|
||||||
reservedSize: 20,
|
reservedSize: 22,
|
||||||
getTitlesWidget: (v, _) {
|
getTitlesWidget: (v, meta) {
|
||||||
final idx = v.toInt();
|
final idx = v.toInt();
|
||||||
if (idx < 0 || idx >= _data.length) return const SizedBox.shrink();
|
if (idx < 0 || idx >= _data.length) return const SizedBox.shrink();
|
||||||
final step = _xInterval().toInt().clamp(1, _data.length);
|
final step = _xInterval().toInt().clamp(1, _data.length);
|
||||||
if (idx % step != 0) return const SizedBox.shrink();
|
if (idx % step != 0) return const SizedBox.shrink();
|
||||||
return Padding(
|
return SideTitleWidget(
|
||||||
padding: const EdgeInsets.only(top: 4),
|
meta: meta,
|
||||||
child: Text(_fmtTime(_data[idx].timestamp), style: labelStyle),
|
child: Text(_fmtTime(_data[idx].timestamp), style: labelStyle),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@ -125,9 +125,11 @@ class NymeaService extends ChangeNotifier {
|
|||||||
|
|
||||||
/// Retourne {thingId, stateName} de la première batterie trouvée,
|
/// Retourne {thingId, stateName} de la première batterie trouvée,
|
||||||
/// ou null si aucune batterie configurée / pas encore chargé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 {
|
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) {
|
if (_things.isEmpty) {
|
||||||
_log('🔋 batterySOCSource: things pas encore chargés', force: true);
|
_log('🔋 batterySOCSource: things pas encore chargés', force: true);
|
||||||
return null;
|
return null;
|
||||||
@ -1137,14 +1139,24 @@ class NymeaService extends ChangeNotifier {
|
|||||||
String sampleRate = 'SampleRate15Mins',
|
String sampleRate = 'SampleRate15Mins',
|
||||||
}) async {
|
}) async {
|
||||||
if (_isSimulation) {
|
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>[];
|
final entries = <HistoryEntry>[];
|
||||||
const steps = 48;
|
const steps = 48;
|
||||||
final step = to.difference(from) ~/ steps;
|
final step = to.difference(from) ~/ steps;
|
||||||
for (int i = 0; i <= steps; i++) {
|
for (int i = 0; i <= steps; i++) {
|
||||||
final t = from.add(step * i);
|
final t = from.add(step * i);
|
||||||
final v = 500 + 400 * sin(i * pi / 12);
|
final hour = t.hour + t.minute / 60.0;
|
||||||
entries.add(HistoryEntry(timestamp: t, value: v));
|
// 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;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user