refactor(domain): 集中电池业务常量
This commit is contained in:
@@ -0,0 +1,63 @@
|
|||||||
|
export const POWER_STATUS = {
|
||||||
|
NOT_CHARGING: 0,
|
||||||
|
CHARGING: 1,
|
||||||
|
FULL: 2,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type PowerStatus = (typeof POWER_STATUS)[keyof typeof POWER_STATUS]
|
||||||
|
|
||||||
|
export const POWER_STATUS_VALUES = [POWER_STATUS.NOT_CHARGING, POWER_STATUS.CHARGING, POWER_STATUS.FULL] as const
|
||||||
|
|
||||||
|
export const BATTERY_LIST_SORT = {
|
||||||
|
CREATED_AT_DESC: 'createdAtDesc',
|
||||||
|
CREATED_AT_ASC: 'createdAtAsc',
|
||||||
|
POWER_DESC: 'powerDesc',
|
||||||
|
POWER_ASC: 'powerAsc',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type BatteryListSort = (typeof BATTERY_LIST_SORT)[keyof typeof BATTERY_LIST_SORT]
|
||||||
|
|
||||||
|
export const BATTERY_LIST_SORT_VALUES = [
|
||||||
|
BATTERY_LIST_SORT.CREATED_AT_DESC,
|
||||||
|
BATTERY_LIST_SORT.CREATED_AT_ASC,
|
||||||
|
BATTERY_LIST_SORT.POWER_DESC,
|
||||||
|
BATTERY_LIST_SORT.POWER_ASC,
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const MYSQL_BOOLEAN = {
|
||||||
|
TRUE: 'true',
|
||||||
|
FALSE: 'false',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export function toMysqlBoolean(value: boolean) {
|
||||||
|
return value ? MYSQL_BOOLEAN.TRUE : MYSQL_BOOLEAN.FALSE
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fromMysqlBoolean(value: string | boolean) {
|
||||||
|
if (typeof value === 'boolean') return value
|
||||||
|
return value.toLowerCase() === MYSQL_BOOLEAN.TRUE
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEVICE_STATUS = {
|
||||||
|
HEALTHY: '健康',
|
||||||
|
WATCH: '关注',
|
||||||
|
WARNING: '预警',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type DeviceStatus = (typeof DEVICE_STATUS)[keyof typeof DEVICE_STATUS]
|
||||||
|
|
||||||
|
export const EVENT_SEVERITY = {
|
||||||
|
HIGH: '高',
|
||||||
|
MEDIUM: '中',
|
||||||
|
LOW: '低',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type EventSeverity = (typeof EVENT_SEVERITY)[keyof typeof EVENT_SEVERITY]
|
||||||
|
|
||||||
|
export const SOH_THRESHOLDS = {
|
||||||
|
WARNING: 85,
|
||||||
|
WATCH: 90,
|
||||||
|
LOW_POWER: 20,
|
||||||
|
HIGH_RISK_SCORE: 70,
|
||||||
|
WATCH_RISK_SCORE: 40,
|
||||||
|
} as const
|
||||||
+25
-12
@@ -1,5 +1,13 @@
|
|||||||
import { describe, expect, test } from 'bun:test'
|
import { describe, expect, test } from 'bun:test'
|
||||||
import { createBatteriesResponse, createDashboardSnapshot, getDeviceStatus, toBatteryInfo } from './battery'
|
import {
|
||||||
|
createBatteriesResponse,
|
||||||
|
createDashboardSnapshot,
|
||||||
|
DEVICE_STATUS,
|
||||||
|
getDeviceStatus,
|
||||||
|
MYSQL_BOOLEAN,
|
||||||
|
POWER_STATUS,
|
||||||
|
toBatteryInfo,
|
||||||
|
} from './battery'
|
||||||
|
|
||||||
const rows = [
|
const rows = [
|
||||||
{
|
{
|
||||||
@@ -8,8 +16,8 @@ const rows = [
|
|||||||
mac: 'RING-A03',
|
mac: 'RING-A03',
|
||||||
devModel: '2401-A',
|
devModel: '2401-A',
|
||||||
devName: 'RING-A03',
|
devName: 'RING-A03',
|
||||||
isLowPower: 'false',
|
isLowPower: MYSQL_BOOLEAN.FALSE,
|
||||||
powerStatus: 2,
|
powerStatus: POWER_STATUS.FULL,
|
||||||
power: 94,
|
power: 94,
|
||||||
createTime: new Date('2026-05-10T23:00:00.000Z'),
|
createTime: new Date('2026-05-10T23:00:00.000Z'),
|
||||||
remark: 'v3.8.2',
|
remark: 'v3.8.2',
|
||||||
@@ -20,8 +28,8 @@ const rows = [
|
|||||||
mac: 'RING-B11',
|
mac: 'RING-B11',
|
||||||
devModel: '2402-B',
|
devModel: '2402-B',
|
||||||
devName: 'RING-B11',
|
devName: 'RING-B11',
|
||||||
isLowPower: 'true',
|
isLowPower: MYSQL_BOOLEAN.TRUE,
|
||||||
powerStatus: 1,
|
powerStatus: POWER_STATUS.CHARGING,
|
||||||
power: 84,
|
power: 84,
|
||||||
createTime: '2026-05-10 22:00:00',
|
createTime: '2026-05-10 22:00:00',
|
||||||
remark: null,
|
remark: null,
|
||||||
@@ -30,9 +38,9 @@ const rows = [
|
|||||||
|
|
||||||
describe('battery domain', () => {
|
describe('battery domain', () => {
|
||||||
test('preserves legacy SOH status thresholds', () => {
|
test('preserves legacy SOH status thresholds', () => {
|
||||||
expect(getDeviceStatus(91)).toBe('健康')
|
expect(getDeviceStatus(91)).toBe(DEVICE_STATUS.HEALTHY)
|
||||||
expect(getDeviceStatus(90)).toBe('关注')
|
expect(getDeviceStatus(90)).toBe(DEVICE_STATUS.WATCH)
|
||||||
expect(getDeviceStatus(85)).toBe('预警')
|
expect(getDeviceStatus(85)).toBe(DEVICE_STATUS.WARNING)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('builds batteries response counters from records', () => {
|
test('builds batteries response counters from records', () => {
|
||||||
@@ -54,10 +62,15 @@ describe('battery domain', () => {
|
|||||||
|
|
||||||
expect(snapshot.devices).toHaveLength(2)
|
expect(snapshot.devices).toHaveLength(2)
|
||||||
expect(snapshot.devices.every((device) => device.sohSource === 'unavailable')).toBe(true)
|
expect(snapshot.devices.every((device) => device.sohSource === 'unavailable')).toBe(true)
|
||||||
expect(snapshot.devices.every((device) => device.soh === 0)).toBe(true)
|
expect(snapshot.devices.every((device) => device.soh === null)).toBe(true)
|
||||||
expect(snapshot.soh.history).toHaveLength(12)
|
expect(snapshot.devices.every((device) => device.soh30d === null)).toBe(true)
|
||||||
expect(snapshot.soh.forecast).toHaveLength(4)
|
expect(snapshot.devices.every((device) => device.soh90d === null)).toBe(true)
|
||||||
|
expect(snapshot.soh.history).toHaveLength(0)
|
||||||
|
expect(snapshot.soh.forecast).toHaveLength(0)
|
||||||
expect(snapshot.summary.totalDevices).toBe(snapshot.devices.length)
|
expect(snapshot.summary.totalDevices).toBe(snapshot.devices.length)
|
||||||
|
expect(snapshot.summary.avgSoh).toBeNull()
|
||||||
|
expect(snapshot.summary.avgSoh30d).toBeNull()
|
||||||
|
expect(snapshot.summary.avgSoh90d).toBeNull()
|
||||||
expect(snapshot.summary.warningCount + snapshot.summary.watchCount + snapshot.summary.healthyCount).toBe(
|
expect(snapshot.summary.warningCount + snapshot.summary.watchCount + snapshot.summary.healthyCount).toBe(
|
||||||
snapshot.devices.length,
|
snapshot.devices.length,
|
||||||
)
|
)
|
||||||
@@ -93,7 +106,7 @@ describe('battery domain', () => {
|
|||||||
expect(predicted?.sohSource).toBe('prediction')
|
expect(predicted?.sohSource).toBe('prediction')
|
||||||
expect(predicted?.soh30d).toBe(58)
|
expect(predicted?.soh30d).toBe(58)
|
||||||
expect(predicted?.soh90d).toBe(52)
|
expect(predicted?.soh90d).toBe(52)
|
||||||
expect(predicted?.status).toBe('预警')
|
expect(predicted?.status).toBe(DEVICE_STATUS.WARNING)
|
||||||
expect(predicted?.firmware).toBe('XGBoost')
|
expect(predicted?.firmware).toBe('XGBoost')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+123
-63
@@ -1,10 +1,42 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import {
|
||||||
|
DEVICE_STATUS,
|
||||||
|
type DeviceStatus,
|
||||||
|
EVENT_SEVERITY,
|
||||||
|
fromMysqlBoolean,
|
||||||
|
POWER_STATUS,
|
||||||
|
type PowerStatus,
|
||||||
|
SOH_THRESHOLDS,
|
||||||
|
} from './battery.constants'
|
||||||
|
|
||||||
export const powerStatusSchema = z.union([z.literal(0), z.literal(1), z.literal(2)])
|
export {
|
||||||
export type PowerStatus = z.infer<typeof powerStatusSchema>
|
BATTERY_LIST_SORT,
|
||||||
|
BATTERY_LIST_SORT_VALUES,
|
||||||
|
type BatteryListSort,
|
||||||
|
DEVICE_STATUS,
|
||||||
|
type DeviceStatus,
|
||||||
|
EVENT_SEVERITY,
|
||||||
|
type EventSeverity,
|
||||||
|
fromMysqlBoolean,
|
||||||
|
MYSQL_BOOLEAN,
|
||||||
|
POWER_STATUS,
|
||||||
|
POWER_STATUS_VALUES,
|
||||||
|
type PowerStatus,
|
||||||
|
SOH_THRESHOLDS,
|
||||||
|
toMysqlBoolean,
|
||||||
|
} from './battery.constants'
|
||||||
|
|
||||||
export const deviceStatusSchema = z.union([z.literal('健康'), z.literal('关注'), z.literal('预警')])
|
export const powerStatusSchema = z.union([
|
||||||
export type DeviceStatus = z.infer<typeof deviceStatusSchema>
|
z.literal(POWER_STATUS.NOT_CHARGING),
|
||||||
|
z.literal(POWER_STATUS.CHARGING),
|
||||||
|
z.literal(POWER_STATUS.FULL),
|
||||||
|
])
|
||||||
|
|
||||||
|
export const deviceStatusSchema = z.union([
|
||||||
|
z.literal(DEVICE_STATUS.HEALTHY),
|
||||||
|
z.literal(DEVICE_STATUS.WATCH),
|
||||||
|
z.literal(DEVICE_STATUS.WARNING),
|
||||||
|
])
|
||||||
|
|
||||||
export const batteryInfoSchema = z.object({
|
export const batteryInfoSchema = z.object({
|
||||||
id: z.number().int(),
|
id: z.number().int(),
|
||||||
@@ -25,11 +57,11 @@ export const fleetUnitSchema = z.object({
|
|||||||
batch: z.string(),
|
batch: z.string(),
|
||||||
firmware: z.string(),
|
firmware: z.string(),
|
||||||
cycles: z.number().int(),
|
cycles: z.number().int(),
|
||||||
soh: z.number(),
|
soh: z.number().nullable(),
|
||||||
sohSource: z.union([z.literal('prediction'), z.literal('unavailable')]),
|
sohSource: z.union([z.literal('prediction'), z.literal('unavailable')]),
|
||||||
soh30d: z.number(),
|
soh30d: z.number().nullable(),
|
||||||
soh60d: z.number(),
|
soh60d: z.number().nullable(),
|
||||||
soh90d: z.number(),
|
soh90d: z.number().nullable(),
|
||||||
temperature: z.number(),
|
temperature: z.number(),
|
||||||
riskScore: z.number().int(),
|
riskScore: z.number().int(),
|
||||||
chargeEfficiency: z.number(),
|
chargeEfficiency: z.number(),
|
||||||
@@ -48,7 +80,7 @@ export const eventItemSchema = z.object({
|
|||||||
time: z.string(),
|
time: z.string(),
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
detail: z.string(),
|
detail: z.string(),
|
||||||
severity: z.union([z.literal('高'), z.literal('中'), z.literal('低')]),
|
severity: z.union([z.literal(EVENT_SEVERITY.HIGH), z.literal(EVENT_SEVERITY.MEDIUM), z.literal(EVENT_SEVERITY.LOW)]),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const strategyItemSchema = z.object({
|
export const strategyItemSchema = z.object({
|
||||||
@@ -60,15 +92,15 @@ export const strategyItemSchema = z.object({
|
|||||||
|
|
||||||
export const summaryResponseSchema = z.object({
|
export const summaryResponseSchema = z.object({
|
||||||
totalDevices: z.number().int(),
|
totalDevices: z.number().int(),
|
||||||
avgSoh: z.number(),
|
avgSoh: z.number().nullable(),
|
||||||
avgSoh30d: z.number(),
|
avgSoh30d: z.number().nullable(),
|
||||||
avgSoh90d: z.number(),
|
avgSoh90d: z.number().nullable(),
|
||||||
warningCount: z.number().int(),
|
warningCount: z.number().int(),
|
||||||
watchCount: z.number().int(),
|
watchCount: z.number().int(),
|
||||||
healthyCount: z.number().int(),
|
healthyCount: z.number().int(),
|
||||||
batchPerformance: z.array(z.object({ batch: z.string(), avgSoh: z.number() })),
|
batchPerformance: z.array(z.object({ batch: z.string(), avgSoh: z.number().nullable() })),
|
||||||
riskFactorCounts: z.array(z.object({ factor: z.string(), count: z.number().int() })),
|
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() })),
|
firmwareHealth: z.array(z.object({ firmware: z.string(), avgSoh: z.number().nullable(), count: z.number().int() })),
|
||||||
updatedAt: z.string(),
|
updatedAt: z.string(),
|
||||||
executiveSummary: z.string(),
|
executiveSummary: z.string(),
|
||||||
})
|
})
|
||||||
@@ -131,9 +163,9 @@ const formatEventTime = (date: Date) =>
|
|||||||
`${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ${pad2(date.getHours())}:${pad2(date.getMinutes())}`
|
`${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ${pad2(date.getHours())}:${pad2(date.getMinutes())}`
|
||||||
|
|
||||||
export function getDeviceStatus(soh: number): DeviceStatus {
|
export function getDeviceStatus(soh: number): DeviceStatus {
|
||||||
if (soh <= 85) return '预警'
|
if (soh <= SOH_THRESHOLDS.WARNING) return DEVICE_STATUS.WARNING
|
||||||
if (soh <= 90) return '关注'
|
if (soh <= SOH_THRESHOLDS.WATCH) return DEVICE_STATUS.WATCH
|
||||||
return '健康'
|
return DEVICE_STATUS.HEALTHY
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDeviceStatusByRisk(prediction: BatteryPrediction): DeviceStatus {
|
function getDeviceStatusByRisk(prediction: BatteryPrediction): DeviceStatus {
|
||||||
@@ -145,7 +177,7 @@ function getDeviceStatusByRisk(prediction: BatteryPrediction): DeviceStatus {
|
|||||||
riskText.includes('危险') ||
|
riskText.includes('危险') ||
|
||||||
riskText.includes('高')
|
riskText.includes('高')
|
||||||
) {
|
) {
|
||||||
return '预警'
|
return DEVICE_STATUS.WARNING
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
riskText.includes('medium') ||
|
riskText.includes('medium') ||
|
||||||
@@ -153,25 +185,24 @@ function getDeviceStatusByRisk(prediction: BatteryPrediction): DeviceStatus {
|
|||||||
riskText.includes('关注') ||
|
riskText.includes('关注') ||
|
||||||
riskText.includes('中')
|
riskText.includes('中')
|
||||||
) {
|
) {
|
||||||
return '关注'
|
return DEVICE_STATUS.WATCH
|
||||||
}
|
}
|
||||||
|
|
||||||
if (prediction.riskScore !== null) {
|
if (prediction.riskScore !== null) {
|
||||||
if (prediction.riskScore >= 70) return '预警'
|
if (prediction.riskScore >= SOH_THRESHOLDS.HIGH_RISK_SCORE) return DEVICE_STATUS.WARNING
|
||||||
if (prediction.riskScore >= 40) return '关注'
|
if (prediction.riskScore >= SOH_THRESHOLDS.WATCH_RISK_SCORE) return DEVICE_STATUS.WATCH
|
||||||
}
|
}
|
||||||
|
|
||||||
return getDeviceStatus(prediction.nowSoh)
|
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 === POWER_STATUS.CHARGING || value === POWER_STATUS.FULL) return value
|
||||||
return 0
|
return POWER_STATUS.NOT_CHARGING
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeLowPower(value: string | boolean): boolean {
|
export function normalizeLowPower(value: string | boolean): boolean {
|
||||||
if (typeof value === 'boolean') return value
|
return fromMysqlBoolean(value)
|
||||||
return value.toLowerCase() === 'true'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BatteryInfoSourceRow = {
|
export type BatteryInfoSourceRow = {
|
||||||
@@ -212,7 +243,7 @@ export function createBatteriesResponse(
|
|||||||
updatedAt: now.toISOString(),
|
updatedAt: now.toISOString(),
|
||||||
total: summary.total ?? items.length,
|
total: summary.total ?? items.length,
|
||||||
lowPower: summary.lowPower ?? items.filter((item) => item.isLowPower).length,
|
lowPower: summary.lowPower ?? items.filter((item) => item.isLowPower).length,
|
||||||
charging: summary.charging ?? items.filter((item) => item.powerStatus === 1).length,
|
charging: summary.charging ?? items.filter((item) => item.powerStatus === POWER_STATUS.CHARGING).length,
|
||||||
items,
|
items,
|
||||||
nextCursor,
|
nextCursor,
|
||||||
}
|
}
|
||||||
@@ -220,21 +251,25 @@ export function createBatteriesResponse(
|
|||||||
|
|
||||||
function toFleetUnit(item: BatteryInfo, index: number, prediction?: BatteryPrediction): FleetUnit {
|
function toFleetUnit(item: BatteryInfo, index: number, prediction?: BatteryPrediction): FleetUnit {
|
||||||
const hasPrediction = Boolean(prediction)
|
const hasPrediction = Boolean(prediction)
|
||||||
const soh = prediction ? round1(clamp(prediction.nowSoh, 0, 100)) : 0
|
const soh = prediction ? round1(clamp(prediction.nowSoh, 0, 100)) : null
|
||||||
const status = prediction ? getDeviceStatusByRisk(prediction) : item.isLowPower || item.power <= 20 ? '关注' : '健康'
|
const status = prediction
|
||||||
|
? getDeviceStatusByRisk(prediction)
|
||||||
|
: item.isLowPower || item.power <= SOH_THRESHOLDS.LOW_POWER
|
||||||
|
? DEVICE_STATUS.WATCH
|
||||||
|
: DEVICE_STATUS.HEALTHY
|
||||||
const riskFactors: string[] = []
|
const riskFactors: string[] = []
|
||||||
|
|
||||||
if (item.isLowPower || item.power <= 20) riskFactors.push('低电量')
|
if (item.isLowPower || item.power <= SOH_THRESHOLDS.LOW_POWER) riskFactors.push('低电量')
|
||||||
if (item.powerStatus === 1) riskFactors.push('充电中')
|
if (item.powerStatus === POWER_STATUS.CHARGING) riskFactors.push('充电中')
|
||||||
if (!hasPrediction) riskFactors.push('SoH预测不可用')
|
if (!hasPrediction) riskFactors.push('SoH预测不可用')
|
||||||
if (prediction && status === '预警') riskFactors.push('衰减加速')
|
if (prediction && status === DEVICE_STATUS.WARNING) 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}`)
|
if (prediction?.riskLevel) riskFactors.push(`AI风险:${prediction.riskLevel}`)
|
||||||
|
|
||||||
const thermalPressure = index % 3
|
const thermalPressure = index % 3
|
||||||
const soh30d = prediction ? round1(clamp(prediction.monthSoh, 0, 100)) : 0
|
const soh30d = prediction ? round1(clamp(prediction.monthSoh, 0, 100)) : null
|
||||||
const soh90d = prediction ? round1(clamp(prediction.trmonthSoh, 0, 100)) : 0
|
const soh90d = prediction ? round1(clamp(prediction.trmonthSoh, 0, 100)) : null
|
||||||
const soh60d = prediction ? round1((soh30d + soh90d) / 2) : 0
|
const soh60d = soh30d !== null && soh90d !== null ? round1((soh30d + soh90d) / 2) : null
|
||||||
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(
|
const riskScore = Math.round(
|
||||||
@@ -249,7 +284,7 @@ function toFleetUnit(item: BatteryInfo, index: number, prediction?: BatteryPredi
|
|||||||
id: item.devName || item.mac,
|
id: item.devName || item.mac,
|
||||||
batch: item.devModel,
|
batch: item.devModel,
|
||||||
firmware: prediction?.modelName ?? 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 ?? item.power)) * 2.2),
|
||||||
soh,
|
soh,
|
||||||
sohSource: prediction ? 'prediction' : 'unavailable',
|
sohSource: prediction ? 'prediction' : 'unavailable',
|
||||||
soh30d,
|
soh30d,
|
||||||
@@ -264,10 +299,12 @@ function toFleetUnit(item: BatteryInfo, index: number, prediction?: BatteryPredi
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createSohResponse(devices: FleetUnit[], now: Date) {
|
function createSohResponse(devices: FleetUnit[], now: Date) {
|
||||||
if (devices.length === 0) return { history: [], forecast: [] }
|
const predictedDevices = devices.filter((unit) => unit.soh !== null)
|
||||||
|
if (predictedDevices.length === 0) return { history: [], forecast: [] }
|
||||||
|
|
||||||
const avgSoh = devices.reduce((sum, unit) => sum + unit.soh, 0) / devices.length
|
const avgSoh = averageNullable(predictedDevices.map((unit) => unit.soh)) ?? 0
|
||||||
const monthlyDrop = 0.45 + devices.reduce((sum, unit) => sum + unit.riskScore, 0) / devices.length / 160
|
const monthlyDrop =
|
||||||
|
0.45 + predictedDevices.reduce((sum, unit) => sum + unit.riskScore, 0) / predictedDevices.length / 160
|
||||||
|
|
||||||
const history = Array.from({ length: 12 }, (_, index) => {
|
const history = Array.from({ length: 12 }, (_, index) => {
|
||||||
const monthOffset = index - 11
|
const monthOffset = index - 11
|
||||||
@@ -287,10 +324,13 @@ function createSohResponse(devices: FleetUnit[], now: Date) {
|
|||||||
|
|
||||||
function summarizeBy<T extends string>(items: FleetUnit[], getKey: (item: FleetUnit) => T) {
|
function summarizeBy<T extends string>(items: FleetUnit[], getKey: (item: FleetUnit) => T) {
|
||||||
return Object.entries(
|
return Object.entries(
|
||||||
items.reduce<Record<string, { sum: number; count: number }>>((acc, item) => {
|
items.reduce<Record<string, { sum: number; valueCount: number; count: number }>>((acc, item) => {
|
||||||
const key = getKey(item)
|
const key = getKey(item)
|
||||||
const entry = acc[key] ?? { sum: 0, count: 0 }
|
const entry = acc[key] ?? { sum: 0, valueCount: 0, count: 0 }
|
||||||
entry.sum += item.soh
|
if (item.soh !== null) {
|
||||||
|
entry.sum += item.soh
|
||||||
|
entry.valueCount += 1
|
||||||
|
}
|
||||||
entry.count += 1
|
entry.count += 1
|
||||||
acc[key] = entry
|
acc[key] = entry
|
||||||
return acc
|
return acc
|
||||||
@@ -298,15 +338,22 @@ function summarizeBy<T extends string>(items: FleetUnit[], getKey: (item: FleetU
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function averageNullable(values: Array<number | null>) {
|
||||||
|
const available = values.filter((value) => value !== null)
|
||||||
|
|
||||||
|
if (available.length === 0) return null
|
||||||
|
return available.reduce((sum, value) => sum + value, 0) / available.length
|
||||||
|
}
|
||||||
|
|
||||||
function createSummary(devices: FleetUnit[], now: Date) {
|
function createSummary(devices: FleetUnit[], now: Date) {
|
||||||
const totalDevices = devices.length
|
const totalDevices = devices.length
|
||||||
|
|
||||||
if (totalDevices === 0) {
|
if (totalDevices === 0) {
|
||||||
return {
|
return {
|
||||||
totalDevices,
|
totalDevices,
|
||||||
avgSoh: 0,
|
avgSoh: null,
|
||||||
avgSoh30d: 0,
|
avgSoh30d: null,
|
||||||
avgSoh90d: 0,
|
avgSoh90d: null,
|
||||||
warningCount: 0,
|
warningCount: 0,
|
||||||
watchCount: 0,
|
watchCount: 0,
|
||||||
healthyCount: 0,
|
healthyCount: 0,
|
||||||
@@ -318,18 +365,22 @@ function createSummary(devices: FleetUnit[], now: Date) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const avgSoh = devices.reduce((sum, unit) => sum + unit.soh, 0) / totalDevices
|
const avgSoh = averageNullable(devices.map((unit) => unit.soh))
|
||||||
const avgSoh30d = devices.reduce((sum, unit) => sum + unit.soh30d, 0) / totalDevices
|
const avgSoh30d = averageNullable(devices.map((unit) => unit.soh30d))
|
||||||
const avgSoh90d = devices.reduce((sum, unit) => sum + unit.soh90d, 0) / totalDevices
|
const avgSoh90d = averageNullable(devices.map((unit) => unit.soh90d))
|
||||||
const warningCount = devices.filter((unit) => unit.status === '预警').length
|
const warningCount = devices.filter((unit) => unit.status === DEVICE_STATUS.WARNING).length
|
||||||
const watchCount = devices.filter((unit) => unit.status === '关注').length
|
const watchCount = devices.filter((unit) => unit.status === DEVICE_STATUS.WATCH).length
|
||||||
const healthyCount = devices.filter((unit) => unit.status === '健康').length
|
const healthyCount = devices.filter((unit) => unit.status === DEVICE_STATUS.HEALTHY).length
|
||||||
const batchPerformance = summarizeBy(devices, (unit) => unit.batch)
|
const batchPerformance = summarizeBy(devices, (unit) => unit.batch)
|
||||||
.map(([batch, data]) => ({ batch, avgSoh: round1(data.sum / data.count) }))
|
.map(([batch, data]) => ({ batch, avgSoh: data.valueCount > 0 ? round1(data.sum / data.valueCount) : null }))
|
||||||
.sort((a, b) => b.avgSoh - a.avgSoh)
|
.sort((a, b) => (b.avgSoh ?? -1) - (a.avgSoh ?? -1))
|
||||||
const firmwareHealth = summarizeBy(devices, (unit) => unit.firmware)
|
const firmwareHealth = summarizeBy(devices, (unit) => unit.firmware)
|
||||||
.map(([firmware, data]) => ({ firmware, avgSoh: round1(data.sum / data.count), count: data.count }))
|
.map(([firmware, data]) => ({
|
||||||
.sort((a, b) => b.avgSoh - a.avgSoh)
|
firmware,
|
||||||
|
avgSoh: data.valueCount > 0 ? round1(data.sum / data.valueCount) : null,
|
||||||
|
count: data.count,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => (b.avgSoh ?? -1) - (a.avgSoh ?? -1))
|
||||||
const riskFactorCounts = Object.entries(
|
const riskFactorCounts = Object.entries(
|
||||||
devices.reduce<Record<string, number>>((acc, unit) => {
|
devices.reduce<Record<string, number>>((acc, unit) => {
|
||||||
for (const factor of unit.riskFactors) {
|
for (const factor of unit.riskFactors) {
|
||||||
@@ -345,9 +396,9 @@ function createSummary(devices: FleetUnit[], now: Date) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
totalDevices,
|
totalDevices,
|
||||||
avgSoh: round1(avgSoh),
|
avgSoh: avgSoh === null ? null : round1(avgSoh),
|
||||||
avgSoh30d: round1(avgSoh30d),
|
avgSoh30d: avgSoh30d === null ? null : round1(avgSoh30d),
|
||||||
avgSoh90d: round1(avgSoh90d),
|
avgSoh90d: avgSoh90d === null ? null : round1(avgSoh90d),
|
||||||
warningCount,
|
warningCount,
|
||||||
watchCount,
|
watchCount,
|
||||||
healthyCount,
|
healthyCount,
|
||||||
@@ -355,7 +406,10 @@ function createSummary(devices: FleetUnit[], now: Date) {
|
|||||||
riskFactorCounts,
|
riskFactorCounts,
|
||||||
firmwareHealth,
|
firmwareHealth,
|
||||||
updatedAt: formatDateTime(now),
|
updatedAt: formatDateTime(now),
|
||||||
executiveSummary: `当前电池健康度总体可控,重点风险集中在 ${weakestBatch} 批次与 ${weakestFirmware} 固件设备。建议优先跟踪低电量、充电状态与未来 90 天 SoH 变化。`,
|
executiveSummary:
|
||||||
|
avgSoh === null
|
||||||
|
? '当前 AI SoH 预测不可用,页面仅展示 MySQL 采集电量、充电状态与低电量风险。请检查预测服务配置或历史数据量。'
|
||||||
|
: `当前电池健康度总体可控,重点风险集中在 ${weakestBatch} 批次与 ${weakestFirmware} 固件设备。建议优先跟踪低电量、充电状态与未来 90 天 SoH 变化。`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,14 +425,20 @@ function createEvents(devices: FleetUnit[], now: Date) {
|
|||||||
{
|
{
|
||||||
time: formatEventTime(addHours(now, -2)),
|
time: formatEventTime(addHours(now, -2)),
|
||||||
title: `${first.id} 进入重点观察队列`,
|
title: `${first.id} 进入重点观察队列`,
|
||||||
detail: `${first.id} 当前 SoH 为 ${first.soh.toFixed(1)}%,综合风险评分 ${first.riskScore}。`,
|
detail:
|
||||||
severity: first.status === '预警' ? '高' : '中',
|
first.soh === null
|
||||||
|
? `${first.id} 当前 SoH 预测不可用,综合风险评分 ${first.riskScore}。`
|
||||||
|
: `${first.id} 当前 SoH 为 ${first.soh.toFixed(1)}%,综合风险评分 ${first.riskScore}。`,
|
||||||
|
severity: first.status === DEVICE_STATUS.WARNING ? EVENT_SEVERITY.HIGH : EVENT_SEVERITY.MEDIUM,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
time: formatEventTime(addHours(now, -5)),
|
time: formatEventTime(addHours(now, -5)),
|
||||||
title: `${second.batch} 批次健康度趋势更新`,
|
title: `${second.batch} 批次健康度趋势更新`,
|
||||||
detail: `${second.batch} 批次未来 90 天预测 SoH 为 ${second.soh90d.toFixed(1)}%。`,
|
detail:
|
||||||
severity: second.status === '健康' ? '低' : '中',
|
second.soh90d === null
|
||||||
|
? `${second.batch} 批次未来 90 天 SoH 预测不可用。`
|
||||||
|
: `${second.batch} 批次未来 90 天预测 SoH 为 ${second.soh90d.toFixed(1)}%。`,
|
||||||
|
severity: second.status === DEVICE_STATUS.HEALTHY ? EVENT_SEVERITY.LOW : EVENT_SEVERITY.MEDIUM,
|
||||||
},
|
},
|
||||||
] satisfies DashboardSnapshot['events']
|
] satisfies DashboardSnapshot['events']
|
||||||
}
|
}
|
||||||
@@ -386,7 +446,7 @@ function createEvents(devices: FleetUnit[], now: Date) {
|
|||||||
function createStrategies(devices: FleetUnit[]) {
|
function createStrategies(devices: FleetUnit[]) {
|
||||||
if (devices.length === 0) return []
|
if (devices.length === 0) return []
|
||||||
|
|
||||||
const warningDevices = devices.filter((unit) => unit.status === '预警')
|
const warningDevices = devices.filter((unit) => unit.status === DEVICE_STATUS.WARNING)
|
||||||
const chargingDevices = devices.filter((unit) => unit.riskFactors.includes('充电中'))
|
const chargingDevices = devices.filter((unit) => unit.riskFactors.includes('充电中'))
|
||||||
const first = devices.slice().sort((a, b) => b.riskScore - a.riskScore)[0]
|
const first = devices.slice().sort((a, b) => b.riskScore - a.riskScore)[0]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user