fix(domain): 移除虚构 SoH 趋势语义
This commit is contained in:
@@ -65,6 +65,12 @@ describe('battery domain', () => {
|
|||||||
expect(snapshot.devices.every((device) => device.soh === null)).toBe(true)
|
expect(snapshot.devices.every((device) => device.soh === null)).toBe(true)
|
||||||
expect(snapshot.devices.every((device) => device.soh30d === null)).toBe(true)
|
expect(snapshot.devices.every((device) => device.soh30d === null)).toBe(true)
|
||||||
expect(snapshot.devices.every((device) => device.soh90d === null)).toBe(true)
|
expect(snapshot.devices.every((device) => device.soh90d === null)).toBe(true)
|
||||||
|
expect(snapshot.devices.every((device) => device.soh60d === null)).toBe(true)
|
||||||
|
expect(snapshot.devices.every((device) => device.cycles === 0)).toBe(true)
|
||||||
|
expect(snapshot.devices.every((device) => device.temperature === 0)).toBe(true)
|
||||||
|
expect(snapshot.devices.every((device) => device.chargeEfficiency === 0)).toBe(true)
|
||||||
|
expect(snapshot.devices[0]?.firmware).toBe('v3.8.2')
|
||||||
|
expect(snapshot.devices[1]?.firmware).toBe('未提供')
|
||||||
expect(snapshot.soh.history).toHaveLength(0)
|
expect(snapshot.soh.history).toHaveLength(0)
|
||||||
expect(snapshot.soh.forecast).toHaveLength(0)
|
expect(snapshot.soh.forecast).toHaveLength(0)
|
||||||
expect(snapshot.summary.totalDevices).toBe(snapshot.devices.length)
|
expect(snapshot.summary.totalDevices).toBe(snapshot.devices.length)
|
||||||
@@ -106,7 +112,16 @@ 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?.soh60d).toBeNull()
|
||||||
|
expect(predicted?.cycles).toBe(6)
|
||||||
|
expect(predicted?.firmware).toBe('v3.8.2')
|
||||||
expect(predicted?.status).toBe(DEVICE_STATUS.WARNING)
|
expect(predicted?.status).toBe(DEVICE_STATUS.WARNING)
|
||||||
expect(predicted?.firmware).toBe('XGBoost')
|
expect(predicted?.temperature).toBe(0)
|
||||||
|
expect(predicted?.chargeEfficiency).toBe(0)
|
||||||
|
expect(snapshot.soh.history).toHaveLength(0)
|
||||||
|
expect(snapshot.soh.forecast).toHaveLength(3)
|
||||||
|
expect(snapshot.soh.forecast[0]).toEqual({ month: '当前', value: 60 })
|
||||||
|
expect(snapshot.soh.forecast[1]).toEqual({ month: '30天', value: 58 })
|
||||||
|
expect(snapshot.soh.forecast[2]).toEqual({ month: '90天', value: 52 })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+63
-73
@@ -149,19 +149,9 @@ const clamp = (value: number, min: number, max: number) => Math.min(max, Math.ma
|
|||||||
|
|
||||||
const pad2 = (value: number) => value.toString().padStart(2, '0')
|
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) =>
|
const formatDateTime = (date: Date) =>
|
||||||
`${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`
|
`${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 {
|
export function getDeviceStatus(soh: number): DeviceStatus {
|
||||||
if (soh <= SOH_THRESHOLDS.WARNING) return DEVICE_STATUS.WARNING
|
if (soh <= SOH_THRESHOLDS.WARNING) return DEVICE_STATUS.WARNING
|
||||||
if (soh <= SOH_THRESHOLDS.WATCH) return DEVICE_STATUS.WATCH
|
if (soh <= SOH_THRESHOLDS.WATCH) return DEVICE_STATUS.WATCH
|
||||||
@@ -249,7 +239,7 @@ export function createBatteriesResponse(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toFleetUnit(item: BatteryInfo, index: number, prediction?: BatteryPrediction): FleetUnit {
|
function toFleetUnit(item: BatteryInfo, prediction?: BatteryPrediction): FleetUnit {
|
||||||
const hasPrediction = Boolean(prediction)
|
const hasPrediction = Boolean(prediction)
|
||||||
const soh = prediction ? round1(clamp(prediction.nowSoh, 0, 100)) : null
|
const soh = prediction ? round1(clamp(prediction.nowSoh, 0, 100)) : null
|
||||||
const status = prediction
|
const status = prediction
|
||||||
@@ -266,25 +256,21 @@ function toFleetUnit(item: BatteryInfo, index: number, prediction?: BatteryPredi
|
|||||||
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 soh30d = prediction ? round1(clamp(prediction.monthSoh, 0, 100)) : null
|
const soh30d = prediction ? round1(clamp(prediction.monthSoh, 0, 100)) : null
|
||||||
const soh90d = prediction ? round1(clamp(prediction.trmonthSoh, 0, 100)) : null
|
const soh90d = prediction ? round1(clamp(prediction.trmonthSoh, 0, 100)) : null
|
||||||
const soh60d = soh30d !== null && soh90d !== null ? round1((soh30d + soh90d) / 2) : null
|
const soh60d = null
|
||||||
const temperature = round1(29.5 + thermalPressure * 2.1 + (item.isLowPower ? 1.4 : 0))
|
const temperature = 0
|
||||||
const chargeEfficiency = round1(clamp(91 + item.power / 12 - riskFactors.length * 1.8, 80, 98))
|
const chargeEfficiency = 0
|
||||||
const riskScore = Math.round(
|
const fallbackRiskScore =
|
||||||
clamp(
|
(item.isLowPower || item.power <= SOH_THRESHOLDS.LOW_POWER ? 60 : 0) +
|
||||||
prediction?.riskScore ?? 18 + riskFactors.length * 10 + thermalPressure * 4 + (item.isLowPower ? 18 : 0),
|
(item.powerStatus === POWER_STATUS.CHARGING ? 20 : 0)
|
||||||
8,
|
const riskScore = Math.round(clamp(prediction?.riskScore ?? fallbackRiskScore, 0, 100))
|
||||||
96,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: item.devName || item.mac,
|
id: item.devName || item.mac,
|
||||||
batch: item.devModel,
|
batch: item.devModel,
|
||||||
firmware: prediction?.modelName ?? item.remark ?? 'unknown',
|
firmware: item.remark ?? '未提供',
|
||||||
cycles: 120 + index * 17 + Math.round((100 - (soh ?? item.power)) * 2.2),
|
cycles: prediction?.cyclesUsed ?? 0,
|
||||||
soh,
|
soh,
|
||||||
sohSource: prediction ? 'prediction' : 'unavailable',
|
sohSource: prediction ? 'prediction' : 'unavailable',
|
||||||
soh30d,
|
soh30d,
|
||||||
@@ -298,28 +284,24 @@ function toFleetUnit(item: BatteryInfo, index: number, prediction?: BatteryPredi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSohResponse(devices: FleetUnit[], now: Date) {
|
function createSohResponse(devices: FleetUnit[]) {
|
||||||
const predictedDevices = devices.filter((unit) => unit.soh !== null)
|
const predictedDevices = devices.filter((unit) => unit.soh !== null)
|
||||||
if (predictedDevices.length === 0) return { history: [], forecast: [] }
|
if (predictedDevices.length === 0) return { history: [], forecast: [] }
|
||||||
|
|
||||||
const avgSoh = averageNullable(predictedDevices.map((unit) => unit.soh)) ?? 0
|
const avgNow = averageNullable(predictedDevices.map((unit) => unit.soh))
|
||||||
const monthlyDrop =
|
const avgMonth = averageNullable(predictedDevices.map((unit) => unit.soh30d))
|
||||||
0.45 + predictedDevices.reduce((sum, unit) => sum + unit.riskScore, 0) / predictedDevices.length / 160
|
const avgTrmonth = averageNullable(predictedDevices.map((unit) => unit.soh90d))
|
||||||
|
|
||||||
|
const forecast = [
|
||||||
|
avgNow === null ? null : { month: '当前', value: round1(clamp(avgNow, 0, 100)) },
|
||||||
|
avgMonth === null ? null : { month: '30天', value: round1(clamp(avgMonth, 0, 100)) },
|
||||||
|
avgTrmonth === null ? null : { month: '90天', value: round1(clamp(avgTrmonth, 0, 100)) },
|
||||||
|
].filter((point): point is { month: string; value: number } => point !== null)
|
||||||
|
|
||||||
const history = Array.from({ length: 12 }, (_, index) => {
|
|
||||||
const monthOffset = index - 11
|
|
||||||
return {
|
return {
|
||||||
month: formatMonthLabel(addMonths(now, monthOffset)),
|
history: [],
|
||||||
value: round1(clamp(avgSoh + Math.abs(monthOffset) * monthlyDrop, avgSoh, 100)),
|
forecast,
|
||||||
}
|
}
|
||||||
})
|
|
||||||
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) {
|
function summarizeBy<T extends string>(items: FleetUnit[], getKey: (item: FleetUnit) => T) {
|
||||||
@@ -391,8 +373,10 @@ function createSummary(devices: FleetUnit[], now: Date) {
|
|||||||
)
|
)
|
||||||
.map(([factor, count]) => ({ factor, count }))
|
.map(([factor, count]) => ({ factor, count }))
|
||||||
.sort((a, b) => b.count - a.count)
|
.sort((a, b) => b.count - a.count)
|
||||||
const weakestBatch = batchPerformance.at(-1)?.batch ?? '当前设备'
|
const weakestModel = batchPerformance.at(-1)?.batch ?? '当前设备型号'
|
||||||
const weakestFirmware = firmwareHealth.at(-1)?.firmware ?? 'unknown'
|
const weakestRemark = firmwareHealth.at(-1)?.firmware ?? '未提供备注'
|
||||||
|
const predictedDevices = devices.filter((unit) => unit.soh !== null).length
|
||||||
|
const missingPredictionDevices = totalDevices - predictedDevices
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalDevices,
|
totalDevices,
|
||||||
@@ -409,36 +393,32 @@ function createSummary(devices: FleetUnit[], now: Date) {
|
|||||||
executiveSummary:
|
executiveSummary:
|
||||||
avgSoh === null
|
avgSoh === null
|
||||||
? '当前 AI SoH 预测不可用,页面仅展示 MySQL 采集电量、充电状态与低电量风险。请检查预测服务配置或历史数据量。'
|
? '当前 AI SoH 预测不可用,页面仅展示 MySQL 采集电量、充电状态与低电量风险。请检查预测服务配置或历史数据量。'
|
||||||
: `当前电池健康度总体可控,重点风险集中在 ${weakestBatch} 批次与 ${weakestFirmware} 固件设备。建议优先跟踪低电量、充电状态与未来 90 天 SoH 变化。`,
|
: `当前共有 ${predictedDevices} 台设备返回 SoH 预测,${missingPredictionDevices} 台设备暂无预测。重点关注 ${weakestModel} 型号与 ${weakestRemark} 备注设备,优先处理低电量和充电中的设备,并在下次同步后复查缺失预测与未来 30/90 天模型预测。`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createEvents(devices: FleetUnit[], now: Date) {
|
function createEvents(devices: FleetUnit[], now: Date) {
|
||||||
const sortedDevices = devices.slice().sort((a, b) => b.riskScore - a.riskScore)
|
if (devices.length === 0) return []
|
||||||
const first = sortedDevices[0]
|
|
||||||
|
|
||||||
if (!first) return []
|
const predictedDevices = devices.filter((unit) => unit.soh !== null)
|
||||||
|
const warningDevices = devices.filter((unit) => unit.status === DEVICE_STATUS.WARNING)
|
||||||
const second = sortedDevices[1] ?? first
|
const missingPredictionDevices = devices.length - predictedDevices.length
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
time: formatEventTime(addHours(now, -2)),
|
time: formatDateTime(now),
|
||||||
title: `${first.id} 进入重点观察队列`,
|
title: '风险快照',
|
||||||
detail:
|
detail: `本次快照包含 ${devices.length} 台设备,其中 ${predictedDevices.length} 台返回真实 SoH 预测,${warningDevices.length} 台处于预警状态。`,
|
||||||
first.soh === null
|
severity: warningDevices.length > 0 ? EVENT_SEVERITY.HIGH : EVENT_SEVERITY.LOW,
|
||||||
? `${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: formatDateTime(now),
|
||||||
title: `${second.batch} 批次健康度趋势更新`,
|
title: '预测可用性快照',
|
||||||
detail:
|
detail:
|
||||||
second.soh90d === null
|
missingPredictionDevices > 0
|
||||||
? `${second.batch} 批次未来 90 天 SoH 预测不可用。`
|
? `当前有 ${missingPredictionDevices} 台设备暂无 SoH 预测,对应图表与卡片保留为空值。`
|
||||||
: `${second.batch} 批次未来 90 天预测 SoH 为 ${second.soh90d.toFixed(1)}%。`,
|
: '当前所有设备均已返回 SoH 预测,图表仅展示真实预测点。',
|
||||||
severity: second.status === DEVICE_STATUS.HEALTHY ? EVENT_SEVERITY.LOW : EVENT_SEVERITY.MEDIUM,
|
severity: missingPredictionDevices > 0 ? EVENT_SEVERITY.MEDIUM : EVENT_SEVERITY.LOW,
|
||||||
},
|
},
|
||||||
] satisfies DashboardSnapshot['events']
|
] satisfies DashboardSnapshot['events']
|
||||||
}
|
}
|
||||||
@@ -447,21 +427,31 @@ function createStrategies(devices: FleetUnit[]) {
|
|||||||
if (devices.length === 0) return []
|
if (devices.length === 0) return []
|
||||||
|
|
||||||
const warningDevices = devices.filter((unit) => unit.status === DEVICE_STATUS.WARNING)
|
const warningDevices = devices.filter((unit) => unit.status === DEVICE_STATUS.WARNING)
|
||||||
const chargingDevices = devices.filter((unit) => unit.riskFactors.includes('充电中'))
|
const powerAttentionDevices = devices.filter(
|
||||||
const first = devices.slice().sort((a, b) => b.riskScore - a.riskScore)[0]
|
(unit) => unit.riskFactors.includes('充电中') || unit.riskFactors.includes('低电量'),
|
||||||
|
)
|
||||||
|
const missingPredictionDevices = devices.filter((unit) => unit.soh === null)
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
name: '预警设备优先维护',
|
name: '优先处理预警设备',
|
||||||
impact: `预计高风险设备减少 ${Math.max(10, warningDevices.length * 12)}%`,
|
impact: `当前有 ${warningDevices.length} 台设备处于预警状态,建议先复核供电、连接与预测结果。`,
|
||||||
scope: first ? `${first.id} 等 ${Math.max(1, warningDevices.length)} 台设备` : '当前设备',
|
scope: warningDevices.length > 0 ? `${warningDevices.length} 台预警设备` : '当前设备',
|
||||||
eta: '48 小时内完成',
|
eta: '本次巡检周期内',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: '充电策略复核',
|
name: '补齐预测覆盖',
|
||||||
impact: `覆盖 ${Math.max(1, chargingDevices.length)} 台充电中设备`,
|
impact:
|
||||||
scope: '充电中与低电量设备',
|
missingPredictionDevices.length > 0
|
||||||
eta: '本周完成首轮验证',
|
? `当前有 ${missingPredictionDevices.length} 台设备暂无 SoH 预测,建议在下次同步后复查。`
|
||||||
|
: `当前已有 ${devices.length} 台设备返回预测结果,可继续观察真实变化。`,
|
||||||
|
scope:
|
||||||
|
powerAttentionDevices.length > 0
|
||||||
|
? `${powerAttentionDevices.length} 台充电中或低电量设备`
|
||||||
|
: missingPredictionDevices.length > 0
|
||||||
|
? `${missingPredictionDevices.length} 台缺失预测设备`
|
||||||
|
: '当前设备',
|
||||||
|
eta: '下次同步后复查',
|
||||||
},
|
},
|
||||||
] satisfies DashboardSnapshot['strategies']
|
] satisfies DashboardSnapshot['strategies']
|
||||||
}
|
}
|
||||||
@@ -471,11 +461,11 @@ export function createDashboardSnapshot(
|
|||||||
now = new Date(),
|
now = new Date(),
|
||||||
predictions: ReadonlyMap<string, BatteryPrediction> = new Map(),
|
predictions: ReadonlyMap<string, BatteryPrediction> = new Map(),
|
||||||
): DashboardSnapshot {
|
): DashboardSnapshot {
|
||||||
const devices = items.map((item, index) => toFleetUnit(item, index, predictions.get(item.mac)))
|
const devices = items.map((item) => toFleetUnit(item, predictions.get(item.mac)))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
devices,
|
devices,
|
||||||
soh: createSohResponse(devices, now),
|
soh: createSohResponse(devices),
|
||||||
events: createEvents(devices, now),
|
events: createEvents(devices, now),
|
||||||
strategies: createStrategies(devices),
|
strategies: createStrategies(devices),
|
||||||
summary: createSummary(devices, now),
|
summary: createSummary(devices, now),
|
||||||
|
|||||||
Reference in New Issue
Block a user