refactor(domain): 标注 SoH 来源语义
This commit is contained in:
@@ -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
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user