From 5d9aa660d8ac3f3077362e803097c63a8f64e400 Mon Sep 17 00:00:00 2001 From: imbytecat Date: Tue, 12 May 2026 00:07:15 +0800 Subject: [PATCH] =?UTF-8?q?fix(domain):=20=E7=A7=BB=E9=99=A4=E8=99=9A?= =?UTF-8?q?=E6=9E=84=20SoH=20=E8=B6=8B=E5=8A=BF=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/domain/battery.test.ts | 17 ++++- src/domain/battery.ts | 138 +++++++++++++++++-------------------- 2 files changed, 80 insertions(+), 75 deletions(-) diff --git a/src/domain/battery.test.ts b/src/domain/battery.test.ts index b5cbdf0..a92d0da 100644 --- a/src/domain/battery.test.ts +++ b/src/domain/battery.test.ts @@ -65,6 +65,12 @@ describe('battery domain', () => { 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.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.forecast).toHaveLength(0) expect(snapshot.summary.totalDevices).toBe(snapshot.devices.length) @@ -106,7 +112,16 @@ describe('battery domain', () => { expect(predicted?.sohSource).toBe('prediction') expect(predicted?.soh30d).toBe(58) 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?.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 }) }) }) diff --git a/src/domain/battery.ts b/src/domain/battery.ts index 6bad090..7499b74 100644 --- a/src/domain/battery.ts +++ b/src/domain/battery.ts @@ -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 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 <= SOH_THRESHOLDS.WARNING) return DEVICE_STATUS.WARNING 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 soh = prediction ? round1(clamp(prediction.nowSoh, 0, 100)) : null const status = prediction @@ -266,25 +256,21 @@ function toFleetUnit(item: BatteryInfo, index: number, prediction?: BatteryPredi 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)) : null const soh90d = prediction ? round1(clamp(prediction.trmonthSoh, 0, 100)) : null - 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 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, - ), - ) + const soh60d = null + const temperature = 0 + const chargeEfficiency = 0 + const fallbackRiskScore = + (item.isLowPower || item.power <= SOH_THRESHOLDS.LOW_POWER ? 60 : 0) + + (item.powerStatus === POWER_STATUS.CHARGING ? 20 : 0) + const riskScore = Math.round(clamp(prediction?.riskScore ?? fallbackRiskScore, 0, 100)) return { id: item.devName || item.mac, batch: item.devModel, - firmware: prediction?.modelName ?? item.remark ?? 'unknown', - cycles: 120 + index * 17 + Math.round((100 - (soh ?? item.power)) * 2.2), + firmware: item.remark ?? '未提供', + cycles: prediction?.cyclesUsed ?? 0, soh, sohSource: prediction ? 'prediction' : 'unavailable', 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) if (predictedDevices.length === 0) return { history: [], forecast: [] } - const avgSoh = averageNullable(predictedDevices.map((unit) => unit.soh)) ?? 0 - const monthlyDrop = - 0.45 + predictedDevices.reduce((sum, unit) => sum + unit.riskScore, 0) / predictedDevices.length / 160 + const avgNow = averageNullable(predictedDevices.map((unit) => unit.soh)) + const avgMonth = averageNullable(predictedDevices.map((unit) => unit.soh30d)) + const avgTrmonth = averageNullable(predictedDevices.map((unit) => unit.soh90d)) - 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)), - })) + 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) - return { history, forecast } + return { + history: [], + forecast, + } } function summarizeBy(items: FleetUnit[], getKey: (item: FleetUnit) => T) { @@ -391,8 +373,10 @@ function createSummary(devices: FleetUnit[], now: Date) { ) .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' + const weakestModel = batchPerformance.at(-1)?.batch ?? '当前设备型号' + const weakestRemark = firmwareHealth.at(-1)?.firmware ?? '未提供备注' + const predictedDevices = devices.filter((unit) => unit.soh !== null).length + const missingPredictionDevices = totalDevices - predictedDevices return { totalDevices, @@ -409,36 +393,32 @@ function createSummary(devices: FleetUnit[], now: Date) { executiveSummary: avgSoh === null ? '当前 AI SoH 预测不可用,页面仅展示 MySQL 采集电量、充电状态与低电量风险。请检查预测服务配置或历史数据量。' - : `当前电池健康度总体可控,重点风险集中在 ${weakestBatch} 批次与 ${weakestFirmware} 固件设备。建议优先跟踪低电量、充电状态与未来 90 天 SoH 变化。`, + : `当前共有 ${predictedDevices} 台设备返回 SoH 预测,${missingPredictionDevices} 台设备暂无预测。重点关注 ${weakestModel} 型号与 ${weakestRemark} 备注设备,优先处理低电量和充电中的设备,并在下次同步后复查缺失预测与未来 30/90 天模型预测。`, } } function createEvents(devices: FleetUnit[], now: Date) { - const sortedDevices = devices.slice().sort((a, b) => b.riskScore - a.riskScore) - const first = sortedDevices[0] + if (devices.length === 0) return [] - if (!first) return [] - - const second = sortedDevices[1] ?? first + const predictedDevices = devices.filter((unit) => unit.soh !== null) + const warningDevices = devices.filter((unit) => unit.status === DEVICE_STATUS.WARNING) + const missingPredictionDevices = devices.length - predictedDevices.length return [ { - time: formatEventTime(addHours(now, -2)), - title: `${first.id} 进入重点观察队列`, - detail: - 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: formatDateTime(now), + title: '风险快照', + detail: `本次快照包含 ${devices.length} 台设备,其中 ${predictedDevices.length} 台返回真实 SoH 预测,${warningDevices.length} 台处于预警状态。`, + severity: warningDevices.length > 0 ? EVENT_SEVERITY.HIGH : EVENT_SEVERITY.LOW, }, { - time: formatEventTime(addHours(now, -5)), - title: `${second.batch} 批次健康度趋势更新`, + time: formatDateTime(now), + title: '预测可用性快照', detail: - 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, + missingPredictionDevices > 0 + ? `当前有 ${missingPredictionDevices} 台设备暂无 SoH 预测,对应图表与卡片保留为空值。` + : '当前所有设备均已返回 SoH 预测,图表仅展示真实预测点。', + severity: missingPredictionDevices > 0 ? EVENT_SEVERITY.MEDIUM : EVENT_SEVERITY.LOW, }, ] satisfies DashboardSnapshot['events'] } @@ -447,21 +427,31 @@ function createStrategies(devices: FleetUnit[]) { if (devices.length === 0) return [] const warningDevices = devices.filter((unit) => unit.status === DEVICE_STATUS.WARNING) - const chargingDevices = devices.filter((unit) => unit.riskFactors.includes('充电中')) - const first = devices.slice().sort((a, b) => b.riskScore - a.riskScore)[0] + const powerAttentionDevices = devices.filter( + (unit) => unit.riskFactors.includes('充电中') || unit.riskFactors.includes('低电量'), + ) + const missingPredictionDevices = devices.filter((unit) => unit.soh === null) return [ { - name: '预警设备优先维护', - impact: `预计高风险设备减少 ${Math.max(10, warningDevices.length * 12)}%`, - scope: first ? `${first.id} 等 ${Math.max(1, warningDevices.length)} 台设备` : '当前设备', - eta: '48 小时内完成', + name: '优先处理预警设备', + impact: `当前有 ${warningDevices.length} 台设备处于预警状态,建议先复核供电、连接与预测结果。`, + scope: warningDevices.length > 0 ? `${warningDevices.length} 台预警设备` : '当前设备', + eta: '本次巡检周期内', }, { - name: '充电策略复核', - impact: `覆盖 ${Math.max(1, chargingDevices.length)} 台充电中设备`, - scope: '充电中与低电量设备', - eta: '本周完成首轮验证', + name: '补齐预测覆盖', + impact: + missingPredictionDevices.length > 0 + ? `当前有 ${missingPredictionDevices.length} 台设备暂无 SoH 预测,建议在下次同步后复查。` + : `当前已有 ${devices.length} 台设备返回预测结果,可继续观察真实变化。`, + scope: + powerAttentionDevices.length > 0 + ? `${powerAttentionDevices.length} 台充电中或低电量设备` + : missingPredictionDevices.length > 0 + ? `${missingPredictionDevices.length} 台缺失预测设备` + : '当前设备', + eta: '下次同步后复查', }, ] satisfies DashboardSnapshot['strategies'] } @@ -471,11 +461,11 @@ export function createDashboardSnapshot( now = new Date(), predictions: ReadonlyMap = new Map(), ): 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 { devices, - soh: createSohResponse(devices, now), + soh: createSohResponse(devices), events: createEvents(devices, now), strategies: createStrategies(devices), summary: createSummary(devices, now),