Files
fullstack-starter/src/domain/battery.ts
T

424 lines
14 KiB
TypeScript

import { z } from 'zod'
export const powerStatusSchema = z.union([z.literal(0), z.literal(1), z.literal(2)])
export type PowerStatus = z.infer<typeof powerStatusSchema>
export const deviceStatusSchema = z.union([z.literal('健康'), z.literal('关注'), z.literal('预警')])
export type DeviceStatus = z.infer<typeof deviceStatusSchema>
export const batteryInfoSchema = z.object({
id: z.number().int(),
userId: z.number().int(),
mac: z.string(),
devModel: z.string(),
devName: z.string(),
isLowPower: z.boolean(),
powerStatus: powerStatusSchema,
power: z.number().int().min(0).max(100),
createTime: z.string(),
remark: z.string().nullable(),
})
export type BatteryInfo = z.infer<typeof batteryInfoSchema>
export const fleetUnitSchema = z.object({
id: z.string(),
batch: z.string(),
firmware: z.string(),
cycles: z.number().int(),
soh: z.number(),
sohSource: z.union([z.literal('prediction'), z.literal('unavailable')]),
soh30d: z.number(),
soh60d: z.number(),
soh90d: z.number(),
temperature: z.number(),
riskScore: z.number().int(),
chargeEfficiency: z.number(),
status: deviceStatusSchema,
riskFactors: z.array(z.string()),
})
export type FleetUnit = z.infer<typeof fleetUnitSchema>
export const sohPointSchema = z.object({ month: z.string(), value: z.number() })
export const sohResponseSchema = z.object({
history: z.array(sohPointSchema),
forecast: z.array(sohPointSchema),
})
export const eventItemSchema = z.object({
time: z.string(),
title: z.string(),
detail: z.string(),
severity: z.union([z.literal('高'), z.literal('中'), z.literal('低')]),
})
export const strategyItemSchema = z.object({
name: z.string(),
impact: z.string(),
scope: z.string(),
eta: z.string(),
})
export const summaryResponseSchema = z.object({
totalDevices: z.number().int(),
avgSoh: z.number(),
avgSoh30d: z.number(),
avgSoh90d: z.number(),
warningCount: z.number().int(),
watchCount: z.number().int(),
healthyCount: z.number().int(),
batchPerformance: z.array(z.object({ batch: z.string(), avgSoh: z.number() })),
riskFactorCounts: z.array(z.object({ factor: z.string(), count: z.number().int() })),
firmwareHealth: z.array(z.object({ firmware: z.string(), avgSoh: z.number(), count: z.number().int() })),
updatedAt: z.string(),
executiveSummary: z.string(),
})
export const dashboardSnapshotSchema = z.object({
devices: z.array(fleetUnitSchema),
soh: sohResponseSchema,
events: z.array(eventItemSchema),
strategies: z.array(strategyItemSchema),
summary: summaryResponseSchema,
})
export type DashboardSnapshot = z.infer<typeof dashboardSnapshotSchema>
export const batteriesResponseSchema = z.object({
updatedAt: z.string(),
total: z.number().int(),
lowPower: z.number().int(),
charging: z.number().int(),
items: z.array(batteryInfoSchema),
nextCursor: z.string().nullable(),
})
export type BatteriesResponse = z.infer<typeof batteriesResponseSchema>
export type BatteriesPageSummary = {
total?: number
lowPower?: number
charging?: number
}
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))
const pad2 = (value: number) => value.toString().padStart(2, '0')
const addMonths = (date: Date, months: number) =>
new Date(date.getFullYear(), date.getMonth() + months, date.getDate(), date.getHours(), 0, 0, 0)
const addHours = (date: Date, hours: number) => new Date(date.getTime() + hours * 60 * 60 * 1000)
const formatMonthLabel = (date: Date) => `${date.getFullYear().toString().slice(-2)}.${pad2(date.getMonth() + 1)}`
const formatDateTime = (date: Date) =>
`${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`
const formatEventTime = (date: Date) =>
`${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ${pad2(date.getHours())}:${pad2(date.getMinutes())}`
export function getDeviceStatus(soh: number): DeviceStatus {
if (soh <= 85) return '预警'
if (soh <= 90) 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 {
if (value === 1 || value === 2) return value
return 0
}
export function normalizeLowPower(value: string | boolean): boolean {
if (typeof value === 'boolean') return value
return value.toLowerCase() === 'true'
}
export type BatteryInfoSourceRow = {
id: number
userId: number
mac: string
devModel: string
devName: string
isLowPower: string | boolean
powerStatus: number
power: number
createTime: Date | string
remark: string | null
}
export function toBatteryInfo(row: BatteryInfoSourceRow): BatteryInfo {
return {
id: row.id,
userId: row.userId,
mac: row.mac,
devModel: row.devModel,
devName: row.devName,
isLowPower: normalizeLowPower(row.isLowPower),
powerStatus: normalizePowerStatus(row.powerStatus),
power: row.power,
createTime: row.createTime instanceof Date ? row.createTime.toISOString() : String(row.createTime),
remark: row.remark,
}
}
export function createBatteriesResponse(
items: BatteryInfo[],
now = new Date(),
summary: BatteriesPageSummary = {},
nextCursor: string | null = null,
): BatteriesResponse {
return {
updatedAt: now.toISOString(),
total: summary.total ?? items.length,
lowPower: summary.lowPower ?? items.filter((item) => item.isLowPower).length,
charging: summary.charging ?? items.filter((item) => item.powerStatus === 1).length,
items,
nextCursor,
}
}
function toFleetUnit(item: BatteryInfo, index: number, prediction?: BatteryPrediction): FleetUnit {
const hasPrediction = Boolean(prediction)
const soh = prediction ? round1(clamp(prediction.nowSoh, 0, 100)) : 0
const status = prediction ? getDeviceStatusByRisk(prediction) : item.isLowPower || item.power <= 20 ? '关注' : '健康'
const riskFactors: string[] = []
if (item.isLowPower || item.power <= 20) riskFactors.push('低电量')
if (item.powerStatus === 1) riskFactors.push('充电中')
if (!hasPrediction) riskFactors.push('SoH预测不可用')
if (prediction && 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 = prediction ? round1(clamp(prediction.monthSoh, 0, 100)) : 0
const soh90d = prediction ? round1(clamp(prediction.trmonthSoh, 0, 100)) : 0
const soh60d = prediction ? round1((soh30d + soh90d) / 2) : 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 riskScore = Math.round(
clamp(
prediction?.riskScore ?? 18 + riskFactors.length * 10 + thermalPressure * 4 + (item.isLowPower ? 18 : 0),
8,
96,
),
)
return {
id: item.devName || item.mac,
batch: item.devModel,
firmware: prediction?.modelName ?? item.remark ?? 'unknown',
cycles: 120 + index * 17 + Math.round((100 - soh) * 2.2),
soh,
sohSource: prediction ? 'prediction' : 'unavailable',
soh30d,
soh60d,
soh90d,
temperature,
riskScore,
chargeEfficiency,
status,
riskFactors,
}
}
function createSohResponse(devices: FleetUnit[], now: Date) {
if (devices.length === 0) return { history: [], forecast: [] }
const avgSoh = devices.reduce((sum, unit) => sum + unit.soh, 0) / devices.length
const monthlyDrop = 0.45 + devices.reduce((sum, unit) => sum + unit.riskScore, 0) / devices.length / 160
const history = Array.from({ length: 12 }, (_, index) => {
const monthOffset = index - 11
return {
month: formatMonthLabel(addMonths(now, monthOffset)),
value: round1(clamp(avgSoh + Math.abs(monthOffset) * monthlyDrop, avgSoh, 100)),
}
})
const currentValue = history.at(-1)?.value ?? round1(avgSoh)
const forecast = Array.from({ length: 4 }, (_, index) => ({
month: formatMonthLabel(addMonths(now, index)),
value: index === 0 ? currentValue : round1(clamp(avgSoh - monthlyDrop * index, 0, 100)),
}))
return { history, forecast }
}
function summarizeBy<T extends string>(items: FleetUnit[], getKey: (item: FleetUnit) => T) {
return Object.entries(
items.reduce<Record<string, { sum: number; count: number }>>((acc, item) => {
const key = getKey(item)
const entry = acc[key] ?? { sum: 0, count: 0 }
entry.sum += item.soh
entry.count += 1
acc[key] = entry
return acc
}, {}),
)
}
function createSummary(devices: FleetUnit[], now: Date) {
const totalDevices = devices.length
if (totalDevices === 0) {
return {
totalDevices,
avgSoh: 0,
avgSoh30d: 0,
avgSoh90d: 0,
warningCount: 0,
watchCount: 0,
healthyCount: 0,
batchPerformance: [],
riskFactorCounts: [],
firmwareHealth: [],
updatedAt: formatDateTime(now),
executiveSummary: '当前没有可用于电池健康分析的真实设备记录。',
}
}
const avgSoh = devices.reduce((sum, unit) => sum + unit.soh, 0) / totalDevices
const avgSoh30d = devices.reduce((sum, unit) => sum + unit.soh30d, 0) / totalDevices
const avgSoh90d = devices.reduce((sum, unit) => sum + unit.soh90d, 0) / totalDevices
const warningCount = devices.filter((unit) => unit.status === '预警').length
const watchCount = devices.filter((unit) => unit.status === '关注').length
const healthyCount = devices.filter((unit) => unit.status === '健康').length
const batchPerformance = summarizeBy(devices, (unit) => unit.batch)
.map(([batch, data]) => ({ batch, avgSoh: round1(data.sum / data.count) }))
.sort((a, b) => b.avgSoh - a.avgSoh)
const firmwareHealth = summarizeBy(devices, (unit) => unit.firmware)
.map(([firmware, data]) => ({ firmware, avgSoh: round1(data.sum / data.count), count: data.count }))
.sort((a, b) => b.avgSoh - a.avgSoh)
const riskFactorCounts = Object.entries(
devices.reduce<Record<string, number>>((acc, unit) => {
for (const factor of unit.riskFactors) {
acc[factor] = (acc[factor] ?? 0) + 1
}
return acc
}, {}),
)
.map(([factor, count]) => ({ factor, count }))
.sort((a, b) => b.count - a.count)
const weakestBatch = batchPerformance.at(-1)?.batch ?? '当前设备'
const weakestFirmware = firmwareHealth.at(-1)?.firmware ?? 'unknown'
return {
totalDevices,
avgSoh: round1(avgSoh),
avgSoh30d: round1(avgSoh30d),
avgSoh90d: round1(avgSoh90d),
warningCount,
watchCount,
healthyCount,
batchPerformance,
riskFactorCounts,
firmwareHealth,
updatedAt: formatDateTime(now),
executiveSummary: `当前电池健康度总体可控,重点风险集中在 ${weakestBatch} 批次与 ${weakestFirmware} 固件设备。建议优先跟踪低电量、充电状态与未来 90 天 SoH 变化。`,
}
}
function createEvents(devices: FleetUnit[], now: Date) {
const sortedDevices = devices.slice().sort((a, b) => b.riskScore - a.riskScore)
const first = sortedDevices[0]
if (!first) return []
const second = sortedDevices[1] ?? first
return [
{
time: formatEventTime(addHours(now, -2)),
title: `${first.id} 进入重点观察队列`,
detail: `${first.id} 当前 SoH 为 ${first.soh.toFixed(1)}%,综合风险评分 ${first.riskScore}。`,
severity: first.status === '预警' ? '高' : '中',
},
{
time: formatEventTime(addHours(now, -5)),
title: `${second.batch} 批次健康度趋势更新`,
detail: `${second.batch} 批次未来 90 天预测 SoH 为 ${second.soh90d.toFixed(1)}%。`,
severity: second.status === '健康' ? '低' : '中',
},
] satisfies DashboardSnapshot['events']
}
function createStrategies(devices: FleetUnit[]) {
if (devices.length === 0) return []
const warningDevices = devices.filter((unit) => unit.status === '预警')
const chargingDevices = devices.filter((unit) => unit.riskFactors.includes('充电中'))
const first = devices.slice().sort((a, b) => b.riskScore - a.riskScore)[0]
return [
{
name: '预警设备优先维护',
impact: `预计高风险设备减少 ${Math.max(10, warningDevices.length * 12)}%`,
scope: first ? `${first.id}${Math.max(1, warningDevices.length)} 台设备` : '当前设备',
eta: '48 小时内完成',
},
{
name: '充电策略复核',
impact: `覆盖 ${Math.max(1, chargingDevices.length)} 台充电中设备`,
scope: '充电中与低电量设备',
eta: '本周完成首轮验证',
},
] satisfies DashboardSnapshot['strategies']
}
export function createDashboardSnapshot(
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 {
devices,
soh: createSohResponse(devices, now),
events: createEvents(devices, now),
strategies: createStrategies(devices),
summary: createSummary(devices, now),
}
}