diff --git a/src/domain/battery.test.ts b/src/domain/battery.test.ts index b932c02..7258f53 100644 --- a/src/domain/battery.test.ts +++ b/src/domain/battery.test.ts @@ -59,4 +59,37 @@ describe('battery domain', () => { 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') + }) }) diff --git a/src/domain/battery.ts b/src/domain/battery.ts index e7a809f..8b4b80e 100644 --- a/src/domain/battery.ts +++ b/src/domain/battery.ts @@ -90,6 +90,19 @@ export const batteriesResponseSchema = z.object({ }) export type BatteriesResponse = z.infer +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 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 '健康' } +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 { if (value === 1 || value === 2) return value return 0 @@ -163,28 +204,35 @@ export function createBatteriesResponse(items: BatteryInfo[], now = new Date()): } } -function toFleetUnit(item: BatteryInfo, index: number): FleetUnit { - const soh = item.power - const status = getDeviceStatus(soh) +function toFleetUnit(item: BatteryInfo, index: number, prediction?: BatteryPrediction): FleetUnit { + const soh = prediction?.nowSoh ?? item.power + const status = prediction ? getDeviceStatusByRisk(prediction) : getDeviceStatus(soh) const riskFactors: string[] = [] if (item.isLowPower || item.power <= 20) riskFactors.push('低电量') if (item.powerStatus === 1) riskFactors.push('充电中') if (status === '预警') riskFactors.push('衰减加速') if (item.remark?.includes('v3.7')) riskFactors.push('旧固件') + if (prediction?.riskLevel) riskFactors.push(`AI风险:${prediction.riskLevel}`) const thermalPressure = index % 3 - const soh30d = round1(clamp(soh - 0.8 - thermalPressure * 0.25, 0, 100)) - const soh60d = round1(clamp(soh - 1.7 - thermalPressure * 0.35, 0, 100)) - const soh90d = round1(clamp(soh - 2.8 - thermalPressure * 0.45, 0, 100)) + const soh30d = prediction + ? round1(clamp(prediction.monthSoh, 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 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 { id: item.devName || item.mac, batch: item.devModel, - firmware: item.remark ?? 'unknown', + firmware: prediction?.modelName ?? item.remark ?? 'unknown', cycles: 120 + index * 17 + Math.round((100 - soh) * 2.2), soh, soh30d, @@ -341,8 +389,12 @@ function createStrategies(devices: FleetUnit[]) { ] satisfies DashboardSnapshot['strategies'] } -export function createDashboardSnapshot(items: BatteryInfo[], now = new Date()): DashboardSnapshot { - const devices = items.map(toFleetUnit) +export function createDashboardSnapshot( + items: BatteryInfo[], + now = new Date(), + predictions: ReadonlyMap = new Map(), +): DashboardSnapshot { + const devices = items.map((item, index) => toFleetUnit(item, index, predictions.get(item.mac))) return { devices, diff --git a/src/server/api/routers/battery.router.ts b/src/server/api/routers/battery.router.ts index e35c9bb..f1f9fcd 100644 --- a/src/server/api/routers/battery.router.ts +++ b/src/server/api/routers/battery.router.ts @@ -1,11 +1,23 @@ import { createBatteriesResponse, createDashboardSnapshot } from '@/domain/battery' 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 () => { 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 }) => { diff --git a/src/server/battery/mysql.ts b/src/server/battery/mysql.ts index c33a2e4..d857114 100644 --- a/src/server/battery/mysql.ts +++ b/src/server/battery/mysql.ts @@ -4,6 +4,7 @@ import { type BatteryInfo, type BatteryInfoSourceRow, toBatteryInfo } from '@/do import { env } from '@/env' const historyLimit = 500 +const predictionHistoryLimit = 10 type BatteryInfoMysqlRow = RowDataPacket & BatteryInfoSourceRow @@ -55,6 +56,21 @@ export async function getBatteryHistory(mac: string): Promise { return rows.map(toBatteryInfo) } +export async function getBatteryPredictionHistory(mac: string): Promise { + const [rows] = await getBatteryPool().query( + ` + 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 { const [rows] = await getBatteryPool().query(` SELECT ${sourceColumns}