feat(prediction): 新增 AI SoH 预测客户端

This commit is contained in:
2026-05-11 22:21:57 +08:00
parent c8ea9330e1
commit b11d37e9d8
2 changed files with 201 additions and 0 deletions
+198
View File
@@ -0,0 +1,198 @@
import { z } from 'zod'
import type { BatteryInfo, BatteryPrediction } from '@/domain/battery'
import { env } from '@/env'
import { getLogger } from '@/server/logger'
export const sohPredictionSchema = z.object({
batteryId: z.number().int(),
mac: z.string(),
nowSoh: z.number(),
monthSoh: z.number(),
trmonthSoh: z.number(),
riskScore: z.number().nullable(),
riskLevel: z.string().nullable(),
status: z.string().nullable(),
modelName: z.string().nullable(),
cyclesUsed: z.number().int().nullable(),
updatedAt: z.string().nullable(),
})
export type SohPrediction = BatteryPrediction & z.infer<typeof sohPredictionSchema>
type PredictionHistoryItem = {
cycle: number
charge_capacity_ah: number
discharge_capacity_ah: number
charge_energy_wh: number
discharge_energy_wh: number
charge_time: string
discharge_time: string
coulombic_efficiency_pct: number
timestamp: string
}
type PredictionRequest = {
battery: {
id: number
user_id: number
mac: string
dev_model: string
dev_name: string
is_low_power: string
power_status: 0 | 1 | 2
power: number
create_time: string
remark: string
}
history: PredictionHistoryItem[]
}
const predictionResponseSchema = z.object({
now_soh: z.number(),
month_soh: z.number(),
trmonth_soh: z.number(),
risk_score: z.number().nullable().optional(),
risk_level: z.string().nullable().optional(),
status: z.string().nullable().optional(),
battery_id: z.number().int(),
mac: z.string(),
model_name: z.string().nullable().optional(),
cycles_used: z.number().int().nullable().optional(),
updated_at: z.string().nullable().optional(),
})
type CacheEntry = {
expiresAt: number
value: SohPrediction
}
const logger = getLogger(['prediction'])
const cache = new Map<string, CacheEntry>()
const round2 = (value: number) => Math.round(value * 100) / 100
function normalizeMysqlDateTime(value: string) {
if (!value.includes('T')) return value
return value.slice(0, 19).replace('T', ' ')
}
function createCacheKey(battery: BatteryInfo, history: BatteryInfo[]) {
const latestHistory = history.at(-1)
const latestId = latestHistory?.id ?? battery.id
const latestTime = latestHistory?.createTime ?? battery.createTime
return `${battery.mac}:${latestId}:${latestTime}`
}
function createHistoryItem(item: BatteryInfo, index: number): PredictionHistoryItem {
const sohRatio = Math.max(0.5, Math.min(1, item.power / 100))
const chargeCapacity = round2(3.2 * sohRatio)
const efficiency = round2(Math.max(80, Math.min(99, 92 + item.power / 12 - (item.isLowPower ? 4 : 0))))
const dischargeCapacity = round2(chargeCapacity * (efficiency / 100))
const chargeEnergy = round2(chargeCapacity * 3.75)
const dischargeEnergy = round2(dischargeCapacity * 3.7)
return {
cycle: index + 1,
charge_capacity_ah: chargeCapacity,
discharge_capacity_ah: dischargeCapacity,
charge_energy_wh: chargeEnergy,
discharge_energy_wh: dischargeEnergy,
charge_time: item.powerStatus === 1 ? '01:20:00' : '01:18:00',
discharge_time: item.isLowPower ? '00:58:00' : '01:10:00',
coulombic_efficiency_pct: efficiency,
timestamp: normalizeMysqlDateTime(item.createTime),
}
}
export function createPredictionRequest(battery: BatteryInfo, history: BatteryInfo[]): PredictionRequest | null {
const sourceHistory = history.length > 0 ? history : [battery]
if (sourceHistory.length < 5) return null
return {
battery: {
id: battery.id,
user_id: battery.userId,
mac: battery.mac,
dev_model: battery.devModel,
dev_name: battery.devName,
is_low_power: battery.isLowPower ? 'true' : 'false',
power_status: battery.powerStatus,
power: battery.power,
create_time: normalizeMysqlDateTime(battery.createTime),
remark: battery.remark ?? '',
},
history: sourceHistory.map(createHistoryItem),
}
}
function normalizePrediction(response: z.infer<typeof predictionResponseSchema>): SohPrediction {
return {
batteryId: response.battery_id,
mac: response.mac,
nowSoh: response.now_soh,
monthSoh: response.month_soh,
trmonthSoh: response.trmonth_soh,
riskScore: response.risk_score ?? null,
riskLevel: response.risk_level ?? null,
status: response.status ?? null,
modelName: response.model_name ?? null,
cyclesUsed: response.cycles_used ?? null,
updatedAt: response.updated_at ?? null,
}
}
export function isPredictionEnabled() {
return Boolean(env.SOH_PREDICTION_API_BASE_URL)
}
export async function predictSoh(battery: BatteryInfo, history: BatteryInfo[]): Promise<SohPrediction | null> {
if (!env.SOH_PREDICTION_API_BASE_URL) return null
const request = createPredictionRequest(battery, history)
if (!request) return null
const cacheKey = createCacheKey(battery, history)
const cached = cache.get(cacheKey)
if (cached && cached.expiresAt > Date.now()) return cached.value
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), env.SOH_PREDICTION_TIMEOUT_MS)
const baseUrl = env.SOH_PREDICTION_API_BASE_URL.endsWith('/')
? env.SOH_PREDICTION_API_BASE_URL
: `${env.SOH_PREDICTION_API_BASE_URL}/`
const endpoint = new URL('predict', baseUrl)
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(request),
signal: controller.signal,
})
if (!response.ok) {
logger.warn('SOH prediction request failed', { mac: battery.mac, status: response.status })
return null
}
const json = await response.json()
const prediction = normalizePrediction(predictionResponseSchema.parse(json))
cache.set(cacheKey, {
expiresAt: Date.now() + env.SOH_PREDICTION_CACHE_TTL_SECONDS * 1000,
value: prediction,
})
return prediction
} catch (error) {
logger.warn('SOH prediction request errored', { mac: battery.mac, error })
return null
} finally {
clearTimeout(timeout)
}
}
export function clearPredictionCache() {
cache.clear()
}