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();
|
||||
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),
|
||||
);
|
||||
},
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user