refactor(domain): 标注 SoH 来源语义

This commit is contained in:
2026-05-11 22:39:05 +08:00
parent 6ff9dbe772
commit 29e70fea9a
2 changed files with 33 additions and 16 deletions
+5 -1
View File
@@ -44,14 +44,17 @@ describe('battery domain', () => {
expect(response.total).toBe(items.length) expect(response.total).toBe(items.length)
expect(response.lowPower).toBe(1) expect(response.lowPower).toBe(1)
expect(response.charging).toBe(1) expect(response.charging).toBe(1)
expect(response.nextCursor).toBeNull()
expect(response.items[0]?.createTime).toBe('2026-05-10T23:00:00.000Z') expect(response.items[0]?.createTime).toBe('2026-05-10T23:00:00.000Z')
}) })
test('creates old dashboard aggregate shape from deterministic records', () => { test('creates dashboard aggregate shape without using power as fake SOH', () => {
const now = new Date('2026-05-11T00:00:00.000Z') const now = new Date('2026-05-11T00:00:00.000Z')
const snapshot = createDashboardSnapshot(rows.map(toBatteryInfo), now) const snapshot = createDashboardSnapshot(rows.map(toBatteryInfo), now)
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.soh === 0)).toBe(true)
expect(snapshot.soh.history).toHaveLength(12) expect(snapshot.soh.history).toHaveLength(12)
expect(snapshot.soh.forecast).toHaveLength(4) expect(snapshot.soh.forecast).toHaveLength(4)
expect(snapshot.summary.totalDevices).toBe(snapshot.devices.length) expect(snapshot.summary.totalDevices).toBe(snapshot.devices.length)
@@ -87,6 +90,7 @@ describe('battery domain', () => {
const predicted = snapshot.devices.find((device) => device.id === 'RING-A03') const predicted = snapshot.devices.find((device) => device.id === 'RING-A03')
expect(predicted?.soh).toBe(60) expect(predicted?.soh).toBe(60)
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('预警')
+28 -15
View File
@@ -26,6 +26,7 @@ export const fleetUnitSchema = z.object({
firmware: z.string(), firmware: z.string(),
cycles: z.number().int(), cycles: z.number().int(),
soh: z.number(), soh: z.number(),
sohSource: z.union([z.literal('prediction'), z.literal('unavailable')]),
soh30d: z.number(), soh30d: z.number(),
soh60d: z.number(), soh60d: z.number(),
soh90d: z.number(), soh90d: z.number(),
@@ -87,9 +88,16 @@ export const batteriesResponseSchema = z.object({
lowPower: z.number().int(), lowPower: z.number().int(),
charging: z.number().int(), charging: z.number().int(),
items: z.array(batteryInfoSchema), items: z.array(batteryInfoSchema),
nextCursor: z.string().nullable(),
}) })
export type BatteriesResponse = z.infer<typeof batteriesResponseSchema> export type BatteriesResponse = z.infer<typeof batteriesResponseSchema>
export type BatteriesPageSummary = {
total?: number
lowPower?: number
charging?: number
}
export type BatteryPrediction = { export type BatteryPrediction = {
mac: string mac: string
nowSoh: number nowSoh: number
@@ -194,39 +202,43 @@ export function toBatteryInfo(row: BatteryInfoSourceRow): BatteryInfo {
} }
} }
export function createBatteriesResponse(items: BatteryInfo[], now = new Date()): BatteriesResponse { export function createBatteriesResponse(
items: BatteryInfo[],
now = new Date(),
summary: BatteriesPageSummary = {},
nextCursor: string | null = null,
): BatteriesResponse {
return { return {
updatedAt: now.toISOString(), updatedAt: now.toISOString(),
total: items.length, total: summary.total ?? items.length,
lowPower: items.filter((item) => item.isLowPower).length, lowPower: summary.lowPower ?? items.filter((item) => item.isLowPower).length,
charging: items.filter((item) => item.powerStatus === 1).length, charging: summary.charging ?? items.filter((item) => item.powerStatus === 1).length,
items, items,
nextCursor,
} }
} }
function toFleetUnit(item: BatteryInfo, index: number, prediction?: BatteryPrediction): FleetUnit { function toFleetUnit(item: BatteryInfo, index: number, prediction?: BatteryPrediction): FleetUnit {
const soh = prediction?.nowSoh ?? item.power const hasPrediction = Boolean(prediction)
const status = prediction ? getDeviceStatusByRisk(prediction) : getDeviceStatus(soh) const soh = prediction ? round1(clamp(prediction.nowSoh, 0, 100)) : 0
const status = prediction ? getDeviceStatusByRisk(prediction) : item.isLowPower || item.power <= 20 ? '关注' : '健康'
const riskFactors: string[] = [] const riskFactors: string[] = []
if (item.isLowPower || item.power <= 20) riskFactors.push('低电量') if (item.isLowPower || item.power <= 20) riskFactors.push('低电量')
if (item.powerStatus === 1) riskFactors.push('充电中') if (item.powerStatus === 1) riskFactors.push('充电中')
if (status === '预警') riskFactors.push('衰减加速') if (!hasPrediction) riskFactors.push('SoH预测不可用')
if (prediction && status === '预警') 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 const soh30d = prediction ? round1(clamp(prediction.monthSoh, 0, 100)) : 0
? round1(clamp(prediction.monthSoh, 0, 100)) const soh90d = prediction ? round1(clamp(prediction.trmonthSoh, 0, 100)) : 0
: round1(clamp(soh - 0.8 - thermalPressure * 0.25, 0, 100)) const soh60d = prediction ? round1((soh30d + soh90d) / 2) : 0
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 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(
clamp(prediction?.riskScore ?? 12 + (100 - soh) * 1.45 + riskFactors.length * 8 + thermalPressure * 4, 8, 96), clamp(prediction?.riskScore ?? 18 + riskFactors.length * 10 + thermalPressure * 4 + (item.isLowPower ? 18 : 0), 8, 96),
) )
return { return {
@@ -235,6 +247,7 @@ function toFleetUnit(item: BatteryInfo, index: number, prediction?: BatteryPredi
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) * 2.2),
soh, soh,
sohSource: prediction ? 'prediction' : 'unavailable',
soh30d, soh30d,
soh60d, soh60d,
soh90d, soh90d,