feat(dashboard): 接入预测结果聚合
This commit is contained in:
@@ -59,4 +59,37 @@ describe('battery domain', () => {
|
|||||||
snapshot.devices.length,
|
snapshot.devices.length,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('uses AI prediction values when available', () => {
|
||||||
|
const now = new Date('2026-05-11T00:00:00.000Z')
|
||||||
|
const items = rows.map(toBatteryInfo)
|
||||||
|
const snapshot = createDashboardSnapshot(
|
||||||
|
items,
|
||||||
|
now,
|
||||||
|
new Map([
|
||||||
|
[
|
||||||
|
'RING-A03',
|
||||||
|
{
|
||||||
|
mac: 'RING-A03',
|
||||||
|
nowSoh: 60,
|
||||||
|
monthSoh: 58,
|
||||||
|
trmonthSoh: 52,
|
||||||
|
riskScore: 40,
|
||||||
|
riskLevel: 'high',
|
||||||
|
status: '危险',
|
||||||
|
modelName: 'XGBoost',
|
||||||
|
cyclesUsed: 6,
|
||||||
|
updatedAt: '2026-05-11T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
const predicted = snapshot.devices.find((device) => device.id === 'RING-A03')
|
||||||
|
|
||||||
|
expect(predicted?.soh).toBe(60)
|
||||||
|
expect(predicted?.soh30d).toBe(58)
|
||||||
|
expect(predicted?.soh90d).toBe(52)
|
||||||
|
expect(predicted?.status).toBe('预警')
|
||||||
|
expect(predicted?.firmware).toBe('XGBoost')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+62
-10
@@ -90,6 +90,19 @@ export const batteriesResponseSchema = z.object({
|
|||||||
})
|
})
|
||||||
export type BatteriesResponse = z.infer<typeof batteriesResponseSchema>
|
export type BatteriesResponse = z.infer<typeof batteriesResponseSchema>
|
||||||
|
|
||||||
|
export type BatteryPrediction = {
|
||||||
|
mac: string
|
||||||
|
nowSoh: number
|
||||||
|
monthSoh: number
|
||||||
|
trmonthSoh: number
|
||||||
|
riskScore: number | null
|
||||||
|
riskLevel: string | null
|
||||||
|
status: string | null
|
||||||
|
modelName: string | null
|
||||||
|
cyclesUsed: number | null
|
||||||
|
updatedAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
const round1 = (value: number) => Math.round(value * 10) / 10
|
const round1 = (value: number) => Math.round(value * 10) / 10
|
||||||
|
|
||||||
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value))
|
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value))
|
||||||
@@ -115,6 +128,34 @@ export function getDeviceStatus(soh: number): DeviceStatus {
|
|||||||
return '健康'
|
return '健康'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getDeviceStatusByRisk(prediction: BatteryPrediction): DeviceStatus {
|
||||||
|
const riskText = `${prediction.riskLevel ?? ''} ${prediction.status ?? ''}`.toLowerCase()
|
||||||
|
|
||||||
|
if (
|
||||||
|
riskText.includes('high') ||
|
||||||
|
riskText.includes('danger') ||
|
||||||
|
riskText.includes('危险') ||
|
||||||
|
riskText.includes('高')
|
||||||
|
) {
|
||||||
|
return '预警'
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
riskText.includes('medium') ||
|
||||||
|
riskText.includes('warning') ||
|
||||||
|
riskText.includes('关注') ||
|
||||||
|
riskText.includes('中')
|
||||||
|
) {
|
||||||
|
return '关注'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prediction.riskScore !== null) {
|
||||||
|
if (prediction.riskScore >= 70) return '预警'
|
||||||
|
if (prediction.riskScore >= 40) return '关注'
|
||||||
|
}
|
||||||
|
|
||||||
|
return getDeviceStatus(prediction.nowSoh)
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizePowerStatus(value: number): PowerStatus {
|
export function normalizePowerStatus(value: number): PowerStatus {
|
||||||
if (value === 1 || value === 2) return value
|
if (value === 1 || value === 2) return value
|
||||||
return 0
|
return 0
|
||||||
@@ -163,28 +204,35 @@ export function createBatteriesResponse(items: BatteryInfo[], now = new Date()):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toFleetUnit(item: BatteryInfo, index: number): FleetUnit {
|
function toFleetUnit(item: BatteryInfo, index: number, prediction?: BatteryPrediction): FleetUnit {
|
||||||
const soh = item.power
|
const soh = prediction?.nowSoh ?? item.power
|
||||||
const status = getDeviceStatus(soh)
|
const status = prediction ? getDeviceStatusByRisk(prediction) : getDeviceStatus(soh)
|
||||||
const riskFactors: string[] = []
|
const riskFactors: string[] = []
|
||||||
|
|
||||||
if (item.isLowPower || item.power <= 20) riskFactors.push('低电量')
|
if (item.isLowPower || item.power <= 20) riskFactors.push('低电量')
|
||||||
if (item.powerStatus === 1) riskFactors.push('充电中')
|
if (item.powerStatus === 1) riskFactors.push('充电中')
|
||||||
if (status === '预警') riskFactors.push('衰减加速')
|
if (status === '预警') riskFactors.push('衰减加速')
|
||||||
if (item.remark?.includes('v3.7')) riskFactors.push('旧固件')
|
if (item.remark?.includes('v3.7')) riskFactors.push('旧固件')
|
||||||
|
if (prediction?.riskLevel) riskFactors.push(`AI风险:${prediction.riskLevel}`)
|
||||||
|
|
||||||
const thermalPressure = index % 3
|
const thermalPressure = index % 3
|
||||||
const soh30d = round1(clamp(soh - 0.8 - thermalPressure * 0.25, 0, 100))
|
const soh30d = prediction
|
||||||
const soh60d = round1(clamp(soh - 1.7 - thermalPressure * 0.35, 0, 100))
|
? round1(clamp(prediction.monthSoh, 0, 100))
|
||||||
const soh90d = round1(clamp(soh - 2.8 - thermalPressure * 0.45, 0, 100))
|
: round1(clamp(soh - 0.8 - thermalPressure * 0.25, 0, 100))
|
||||||
|
const soh90d = prediction
|
||||||
|
? round1(clamp(prediction.trmonthSoh, 0, 100))
|
||||||
|
: round1(clamp(soh - 2.8 - thermalPressure * 0.45, 0, 100))
|
||||||
|
const soh60d = prediction ? round1((soh30d + soh90d) / 2) : round1(clamp(soh - 1.7 - thermalPressure * 0.35, 0, 100))
|
||||||
const temperature = round1(29.5 + thermalPressure * 2.1 + (item.isLowPower ? 1.4 : 0))
|
const temperature = round1(29.5 + thermalPressure * 2.1 + (item.isLowPower ? 1.4 : 0))
|
||||||
const chargeEfficiency = round1(clamp(91 + item.power / 12 - riskFactors.length * 1.8, 80, 98))
|
const chargeEfficiency = round1(clamp(91 + item.power / 12 - riskFactors.length * 1.8, 80, 98))
|
||||||
const riskScore = Math.round(clamp(12 + (100 - soh) * 1.45 + riskFactors.length * 8 + thermalPressure * 4, 8, 96))
|
const riskScore = Math.round(
|
||||||
|
clamp(prediction?.riskScore ?? 12 + (100 - soh) * 1.45 + riskFactors.length * 8 + thermalPressure * 4, 8, 96),
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: item.devName || item.mac,
|
id: item.devName || item.mac,
|
||||||
batch: item.devModel,
|
batch: item.devModel,
|
||||||
firmware: item.remark ?? 'unknown',
|
firmware: prediction?.modelName ?? item.remark ?? 'unknown',
|
||||||
cycles: 120 + index * 17 + Math.round((100 - soh) * 2.2),
|
cycles: 120 + index * 17 + Math.round((100 - soh) * 2.2),
|
||||||
soh,
|
soh,
|
||||||
soh30d,
|
soh30d,
|
||||||
@@ -341,8 +389,12 @@ function createStrategies(devices: FleetUnit[]) {
|
|||||||
] satisfies DashboardSnapshot['strategies']
|
] satisfies DashboardSnapshot['strategies']
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createDashboardSnapshot(items: BatteryInfo[], now = new Date()): DashboardSnapshot {
|
export function createDashboardSnapshot(
|
||||||
const devices = items.map(toFleetUnit)
|
items: BatteryInfo[],
|
||||||
|
now = new Date(),
|
||||||
|
predictions: ReadonlyMap<string, BatteryPrediction> = new Map(),
|
||||||
|
): DashboardSnapshot {
|
||||||
|
const devices = items.map((item, index) => toFleetUnit(item, index, predictions.get(item.mac)))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
devices,
|
devices,
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
import { createBatteriesResponse, createDashboardSnapshot } from '@/domain/battery'
|
import { createBatteriesResponse, createDashboardSnapshot } from '@/domain/battery'
|
||||||
import { os } from '@/server/api/server'
|
import { os } from '@/server/api/server'
|
||||||
import { getBatteryHistory, getLatestBatteryPerDevice } from '@/server/battery/mysql'
|
import { getBatteryHistory, getBatteryPredictionHistory, getLatestBatteryPerDevice } from '@/server/battery/mysql'
|
||||||
|
import { isPredictionEnabled, predictSoh } from '@/server/prediction/client'
|
||||||
|
|
||||||
export const dashboard = os.battery.dashboard.handler(async () => {
|
export const dashboard = os.battery.dashboard.handler(async () => {
|
||||||
const items = await getLatestBatteryPerDevice()
|
const items = await getLatestBatteryPerDevice()
|
||||||
|
const predictionEntries = isPredictionEnabled()
|
||||||
|
? await Promise.all(
|
||||||
|
items.map(async (item) => {
|
||||||
|
const history = await getBatteryPredictionHistory(item.mac)
|
||||||
|
const prediction = await predictSoh(item, history)
|
||||||
|
|
||||||
return createDashboardSnapshot(items)
|
return prediction ? ([item.mac, prediction] as const) : null
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
: []
|
||||||
|
const predictions = new Map(predictionEntries.filter((entry) => entry !== null))
|
||||||
|
|
||||||
|
return createDashboardSnapshot(items, new Date(), predictions)
|
||||||
})
|
})
|
||||||
|
|
||||||
export const batteries = os.battery.batteries.handler(async ({ input }) => {
|
export const batteries = os.battery.batteries.handler(async ({ input }) => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { type BatteryInfo, type BatteryInfoSourceRow, toBatteryInfo } from '@/do
|
|||||||
import { env } from '@/env'
|
import { env } from '@/env'
|
||||||
|
|
||||||
const historyLimit = 500
|
const historyLimit = 500
|
||||||
|
const predictionHistoryLimit = 10
|
||||||
|
|
||||||
type BatteryInfoMysqlRow = RowDataPacket & BatteryInfoSourceRow
|
type BatteryInfoMysqlRow = RowDataPacket & BatteryInfoSourceRow
|
||||||
|
|
||||||
@@ -55,6 +56,21 @@ export async function getBatteryHistory(mac: string): Promise<BatteryInfo[]> {
|
|||||||
return rows.map(toBatteryInfo)
|
return rows.map(toBatteryInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getBatteryPredictionHistory(mac: string): Promise<BatteryInfo[]> {
|
||||||
|
const [rows] = await getBatteryPool().query<BatteryInfoMysqlRow[]>(
|
||||||
|
`
|
||||||
|
SELECT ${sourceColumns}
|
||||||
|
FROM ls_battery_info
|
||||||
|
WHERE mac = :mac
|
||||||
|
ORDER BY create_time DESC, id DESC
|
||||||
|
LIMIT :limit
|
||||||
|
`,
|
||||||
|
{ mac, limit: predictionHistoryLimit },
|
||||||
|
)
|
||||||
|
|
||||||
|
return rows.map(toBatteryInfo).reverse()
|
||||||
|
}
|
||||||
|
|
||||||
export async function getLatestBatteryPerDevice(): Promise<BatteryInfo[]> {
|
export async function getLatestBatteryPerDevice(): Promise<BatteryInfo[]> {
|
||||||
const [rows] = await getBatteryPool().query<BatteryInfoMysqlRow[]>(`
|
const [rows] = await getBatteryPool().query<BatteryInfoMysqlRow[]>(`
|
||||||
SELECT ${sourceColumns}
|
SELECT ${sourceColumns}
|
||||||
|
|||||||
Reference in New Issue
Block a user