Skip to content

Commit a37e542

Browse files
committed
Show today battery percentage forecast
1 parent 4c5de52 commit a37e542

4 files changed

Lines changed: 113 additions & 68 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ API、PostgreSQL、MQTT 和 TeslaMate 容器端口都只在 Docker 网络内使
1212
- 当前车辆状态、电量、续航、里程、位置、温度、胎压、软件版本
1313
- 默认 7 天数据,支持 30 天、90 天、一年、全部和自定义日期范围
1414
- 概览、趋势、行程、充电、效率、电池、地点、车辆信息报表
15-
- 概览页展示今日分时电量消耗,并按车辆休眠/未休眠状态预测今日剩余电量消耗
15+
- 概览页展示今日实际/预测电量百分比,并按车辆休眠/未休眠状态预测今日结束电量
1616
- 行程统计:里程、时长、最高车速、平均速度、能耗、海拔累计、路线排行
1717
- 充电统计:电量、费用、SOC 增量、功率、AC/DC 月汇总、快充次数、充电地点
1818
- 电池和车辆观测:电量/续航趋势、状态占比、温度、胎压

backend/app/main.py

Lines changed: 83 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,10 @@ def to_float(value: Any) -> float | None:
112112
return None
113113

114114

115+
def clamp(value: float, low: float, high: float) -> float:
116+
return min(max(value, low), high)
117+
118+
115119
def hour_start(value: datetime) -> datetime:
116120
return value.replace(minute=0, second=0, microsecond=0)
117121

@@ -173,7 +177,11 @@ def today_energy(car_id: int, car: dict[str, Any]) -> dict[str, Any]:
173177
where p.car_id = %s
174178
and p.date >= %s::timestamp
175179
and p.date < least(%s::timestamp, %s::timestamp)
176-
and (p.rated_battery_range_km is not null or p.ideal_battery_range_km is not null)
180+
and (
181+
p.battery_level is not null
182+
or p.rated_battery_range_km is not null
183+
or p.ideal_battery_range_km is not null
184+
)
177185
order by p.date asc
178186
""",
179187
(car_id, history_start_utc, tomorrow_start_utc, now_utc + timedelta(minutes=5)),
@@ -203,6 +211,11 @@ def today_energy(car_id: int, car: dict[str, Any]) -> dict[str, Any]:
203211
def range_km(row: dict[str, Any]) -> float | None:
204212
return to_float(row.get("rated_battery_range_km")) or to_float(row.get("ideal_battery_range_km"))
205213

214+
def battery_level(row: dict[str, Any] | None) -> float | None:
215+
if not row:
216+
return None
217+
return to_float(row.get("battery_level")) or to_float(row.get("usable_battery_level"))
218+
206219
def is_moving(row: dict[str, Any]) -> bool:
207220
speed = to_float(row.get("speed")) or 0
208221
return row.get("drive_id") is not None or speed > 1
@@ -342,23 +355,79 @@ def parked_rate(hour: int) -> tuple[str, float | None]:
342355
else:
343356
predicted_unknown[hour] = rate * remaining_hours
344357

358+
latest_battery_level = battery_level(latest_row) or to_float(car.get("battery_level")) or to_float(car.get("usable_battery_level"))
359+
latest_range_km = (range_km(latest_row) if latest_row else None) or to_float(car.get("rated_battery_range_km")) or to_float(car.get("ideal_battery_range_km"))
360+
kwh_per_percent = None
361+
if efficiency and latest_range_km and latest_battery_level and latest_battery_level > 0:
362+
kwh_per_percent = latest_range_km * efficiency / latest_battery_level
363+
364+
battery_samples = [
365+
{
366+
"date": row["date"],
367+
"level": battery_level(row),
368+
}
369+
for row in rows
370+
if isinstance(row.get("date"), datetime) and battery_level(row) is not None
371+
]
372+
373+
def actual_level_at(marker_local: datetime) -> float | None:
374+
if not battery_samples:
375+
return None
376+
marker_utc = utc_naive(marker_local)
377+
level = None
378+
for sample in battery_samples:
379+
if sample["date"] <= marker_utc:
380+
level = sample["level"]
381+
else:
382+
break
383+
return level
384+
385+
current_hour = now_local.hour
386+
387+
def predicted_energy_until(hour: int) -> float:
388+
end_hour = min(max(hour, 0), 24)
389+
if end_hour <= current_hour:
390+
return 0.0
391+
return sum(
392+
predicted_drive[index] + predicted_asleep[index] + predicted_awake[index] + predicted_unknown[index]
393+
for index in range(current_hour, end_hour)
394+
)
395+
396+
def predicted_level_at(hour: int) -> float | None:
397+
if latest_battery_level is None or hour < current_hour:
398+
return None
399+
predicted_kwh = predicted_energy_until(hour)
400+
if not kwh_per_percent:
401+
return latest_battery_level if predicted_kwh == 0 else None
402+
return clamp(latest_battery_level - predicted_kwh / kwh_per_percent, 0, 100)
403+
345404
points = []
346-
for hour in range(24):
347-
predicted_kwh = predicted_drive[hour] + predicted_asleep[hour] + predicted_awake[hour] + predicted_unknown[hour]
405+
for hour in range(25):
406+
predicted_kwh = (
407+
predicted_drive[hour] + predicted_asleep[hour] + predicted_awake[hour] + predicted_unknown[hour]
408+
if hour < 24
409+
else 0.0
410+
)
411+
marker_local = today_start_local + timedelta(hours=hour)
412+
actual_battery_level = latest_battery_level if hour == current_hour else actual_level_at(marker_local)
413+
if marker_local > now_local and hour != current_hour:
414+
actual_battery_level = None
348415
points.append(
349416
{
350417
"hour": hour,
351418
"label": f"{hour:02d}:00",
352-
"actual_kwh": actual_by_hour[hour],
353-
"actual_drive_kwh": actual_drive_by_hour[hour],
354-
"actual_asleep_kwh": actual_asleep_by_hour[hour],
355-
"actual_awake_kwh": actual_awake_by_hour[hour],
356-
"actual_unknown_kwh": actual_unknown_by_hour[hour],
419+
"actual_battery_level": actual_battery_level,
420+
"predicted_battery_level": predicted_level_at(hour),
421+
"actual_kwh": actual_by_hour[hour] if hour < 24 else 0.0,
422+
"actual_drive_kwh": actual_drive_by_hour[hour] if hour < 24 else 0.0,
423+
"actual_asleep_kwh": actual_asleep_by_hour[hour] if hour < 24 else 0.0,
424+
"actual_awake_kwh": actual_awake_by_hour[hour] if hour < 24 else 0.0,
425+
"actual_unknown_kwh": actual_unknown_by_hour[hour] if hour < 24 else 0.0,
357426
"predicted_kwh": predicted_kwh,
358-
"predicted_drive_kwh": predicted_drive[hour],
359-
"predicted_asleep_kwh": predicted_asleep[hour],
360-
"predicted_awake_kwh": predicted_awake[hour],
361-
"predicted_unknown_kwh": predicted_unknown[hour],
427+
"predicted_drive_kwh": predicted_drive[hour] if hour < 24 else 0.0,
428+
"predicted_asleep_kwh": predicted_asleep[hour] if hour < 24 else 0.0,
429+
"predicted_awake_kwh": predicted_awake[hour] if hour < 24 else 0.0,
430+
"predicted_unknown_kwh": predicted_unknown[hour] if hour < 24 else 0.0,
362431
}
363432
)
364433

@@ -388,6 +457,8 @@ def parked_rate(hour: int) -> tuple[str, float | None]:
388457
"state": latest_state or None,
389458
"prediction_basis": prediction_basis,
390459
"prediction_confidence": "normal" if has_basis_samples else "low",
460+
"current_battery_level": latest_battery_level,
461+
"estimated_end_battery_level": predicted_level_at(24),
391462
"actual_kwh": actual_total,
392463
"predicted_remaining_kwh": predicted_total,
393464
"estimated_total_kwh": actual_total + predicted_total,

frontend/src/main.tsx

Lines changed: 25 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,8 @@ type RangePoint = {
162162
type TodayEnergyPoint = {
163163
hour: number;
164164
label: string;
165+
actual_battery_level?: number | null;
166+
predicted_battery_level?: number | null;
165167
actual_kwh?: number | null;
166168
actual_drive_kwh?: number | null;
167169
actual_asleep_kwh?: number | null;
@@ -182,6 +184,8 @@ type TodayEnergy = {
182184
state?: string | null;
183185
prediction_basis?: string | null;
184186
prediction_confidence?: string | null;
187+
current_battery_level?: number | null;
188+
estimated_end_battery_level?: number | null;
185189
actual_kwh?: number | null;
186190
predicted_remaining_kwh?: number | null;
187191
estimated_total_kwh?: number | null;
@@ -390,11 +394,6 @@ function kwh(value: number | null | undefined): string {
390394
return value === null || value === undefined ? '—' : `${numberFormat.format(value)} kWh`;
391395
}
392396

393-
function preciseKwh(value: number | null | undefined): string {
394-
if (value === null || value === undefined) return '—';
395-
return `${new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 2 }).format(value)} kWh`;
396-
}
397-
398397
function percent(value: number | null | undefined): string {
399398
return value === null || value === undefined ? '—' : `${integerFormat.format(value)}%`;
400399
}
@@ -895,29 +894,16 @@ function App() {
895894
() =>
896895
(dashboard?.today_energy?.points ?? []).map((item) => ({
897896
...item,
898-
hourLabel: String(item.hour).padStart(2, '0'),
899-
actual_kwh: Number((item.actual_kwh ?? 0).toFixed(2)),
900-
actual_drive_kwh: Number((item.actual_drive_kwh ?? 0).toFixed(2)),
901-
actual_asleep_kwh: Number((item.actual_asleep_kwh ?? 0).toFixed(2)),
902-
actual_awake_kwh: Number((item.actual_awake_kwh ?? 0).toFixed(2)),
903-
actual_unknown_kwh: Number((item.actual_unknown_kwh ?? 0).toFixed(2)),
904-
predicted_drive_kwh: Number((item.predicted_drive_kwh ?? 0).toFixed(2)),
905-
predicted_asleep_kwh: Number((item.predicted_asleep_kwh ?? 0).toFixed(2)),
906-
predicted_awake_kwh: Number((item.predicted_awake_kwh ?? 0).toFixed(2)),
907-
predicted_unknown_kwh: Number((item.predicted_unknown_kwh ?? 0).toFixed(2))
897+
hourLabel: item.hour === 24 ? '24' : String(item.hour).padStart(2, '0'),
898+
actual_battery_level:
899+
item.actual_battery_level === null || item.actual_battery_level === undefined ? null : Number(item.actual_battery_level.toFixed(1)),
900+
predicted_battery_level:
901+
item.predicted_battery_level === null || item.predicted_battery_level === undefined ? null : Number(item.predicted_battery_level.toFixed(1))
908902
})),
909903
[dashboard]
910904
);
911905
const todayEnergyHasData = todayEnergyPoints.some(
912-
(item) =>
913-
item.actual_drive_kwh ||
914-
item.actual_asleep_kwh ||
915-
item.actual_awake_kwh ||
916-
item.actual_unknown_kwh ||
917-
item.predicted_drive_kwh ||
918-
item.predicted_asleep_kwh ||
919-
item.predicted_awake_kwh ||
920-
item.predicted_unknown_kwh
906+
(item) => item.actual_battery_level !== null || item.predicted_battery_level !== null
921907
);
922908
const reportItems: Array<{ key: ReportKey; label: string; icon: React.ReactNode }> = [
923909
{ key: 'overview', label: '概览', icon: <Activity size={17} /> },
@@ -1039,57 +1025,41 @@ function App() {
10391025
const aside = `${basis} · 最近 ${energy?.history_days ?? 30} 天均值${energy?.prediction_confidence === 'low' ? ' · 样本较少' : ''}`;
10401026

10411027
return (
1042-
<Section title="今日电量消耗" icon={<BatteryCharging size={18} />} aside={aside}>
1043-
<div className="today-energy-kpis">
1044-
<div>
1045-
<span>已消耗</span>
1046-
<strong>{preciseKwh(energy?.actual_kwh)}</strong>
1047-
</div>
1048-
<div>
1049-
<span>预计剩余</span>
1050-
<strong>{preciseKwh(energy?.predicted_remaining_kwh)}</strong>
1051-
</div>
1028+
<Section title="今日电量预测" icon={<BatteryCharging size={18} />} aside={aside}>
1029+
<div className="today-energy-kpis today-energy-kpis-single">
10521030
<div>
1053-
<span>预计全天</span>
1054-
<strong>{preciseKwh(energy?.estimated_total_kwh)}</strong>
1031+
<span>预计今日结束电量</span>
1032+
<strong>{percent(energy?.estimated_end_battery_level)}</strong>
10551033
</div>
10561034
</div>
10571035

10581036
{todayEnergyHasData ? (
10591037
<>
10601038
<div className="chart chart-compact today-energy-chart">
10611039
<ResponsiveContainer width="100%" height="100%">
1062-
<BarChart data={todayEnergyPoints} margin={{ top: 10, right: 8, bottom: 0, left: -20 }}>
1040+
<LineChart data={todayEnergyPoints} margin={{ top: 10, right: 10, bottom: 0, left: -16 }}>
10631041
<CartesianGrid strokeDasharray="3 3" vertical={false} />
10641042
<XAxis dataKey="hourLabel" tickLine={false} axisLine={false} interval={2} />
1065-
<YAxis tickLine={false} axisLine={false} width={40} />
1043+
<YAxis tickLine={false} axisLine={false} width={38} domain={[0, 100]} tickFormatter={(value) => `${value}%`} />
10661044
<Tooltip
10671045
formatter={chartTooltipFormatter({
1068-
actual_kwh: { label: '已消耗', format: preciseKwh },
1069-
predicted_drive_kwh: { label: '预计行驶', format: preciseKwh },
1070-
predicted_asleep_kwh: { label: '预计休眠', format: preciseKwh },
1071-
predicted_awake_kwh: { label: '预计未休眠', format: preciseKwh },
1072-
predicted_unknown_kwh: { label: '预计未知', format: preciseKwh }
1046+
actual_battery_level: { label: '实际电量', format: percent },
1047+
predicted_battery_level: { label: '预测电量', format: percent }
10731048
})}
10741049
labelFormatter={(label) => `${label}:00`}
10751050
/>
1076-
<Bar dataKey="actual_kwh" stackId="energy" fill="#171a20" radius={[3, 3, 0, 0]} name="actual_kwh" />
1077-
<Bar dataKey="predicted_drive_kwh" stackId="energy" fill="#8da2fb" radius={[3, 3, 0, 0]} name="predicted_drive_kwh" />
1078-
<Bar dataKey="predicted_asleep_kwh" stackId="energy" fill="#94a3b8" radius={[3, 3, 0, 0]} name="predicted_asleep_kwh" />
1079-
<Bar dataKey="predicted_awake_kwh" stackId="energy" fill="#3e6ae1" fillOpacity={0.62} radius={[3, 3, 0, 0]} name="predicted_awake_kwh" />
1080-
<Bar dataKey="predicted_unknown_kwh" stackId="energy" fill="#d8d9da" radius={[3, 3, 0, 0]} name="predicted_unknown_kwh" />
1081-
</BarChart>
1051+
<Line type="monotone" dataKey="actual_battery_level" stroke="#171a20" strokeWidth={2.4} dot={false} connectNulls={false} name="实际电量" />
1052+
<Line type="monotone" dataKey="predicted_battery_level" stroke="#3e6ae1" strokeWidth={2.4} strokeDasharray="6 4" dot={false} connectNulls={false} name="预测电量" />
1053+
</LineChart>
10821054
</ResponsiveContainer>
10831055
</div>
1084-
<div className="energy-legend" aria-label="今日电量消耗图例">
1085-
<span><i style={{ background: '#171a20' }} />已消耗</span>
1086-
<span><i style={{ background: '#8da2fb' }} />预计行驶</span>
1087-
<span><i style={{ background: '#94a3b8' }} />预计休眠</span>
1088-
<span><i style={{ background: '#3e6ae1' }} />预计未休眠</span>
1056+
<div className="energy-legend" aria-label="今日电量预测图例">
1057+
<span><i style={{ background: '#171a20' }} />实际电量</span>
1058+
<span><i style={{ background: '#3e6ae1' }} />预测电量</span>
10891059
</div>
10901060
</>
10911061
) : (
1092-
<Empty text="暂无可计算的今日能耗采样" />
1062+
<Empty text="暂无可计算的今日电量采样" />
10931063
)}
10941064
</Section>
10951065
);

frontend/src/styles.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -580,6 +580,10 @@ main {
580580
margin-bottom: 12px;
581581
}
582582

583+
.today-energy-kpis-single {
584+
grid-template-columns: minmax(0, 1fr);
585+
}
586+
583587
.today-energy-kpis div {
584588
min-width: 0;
585589
padding: 12px 14px;

0 commit comments

Comments
 (0)