215 lines
6.4 KiB
TypeScript
215 lines
6.4 KiB
TypeScript
import { LRUCache } from 'lru-cache'
|
|
import { z } from 'zod'
|
|
import {
|
|
type BatteryInfo,
|
|
type BatteryPrediction,
|
|
POWER_STATUS,
|
|
type PowerStatus,
|
|
toMysqlBoolean,
|
|
} 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: PowerStatus
|
|
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(),
|
|
})
|
|
|
|
const logger = getLogger(['prediction'])
|
|
const cache = new LRUCache<string, SohPrediction>({
|
|
max: 5_000,
|
|
ttl: env.SOH_PREDICTION_CACHE_TTL_SECONDS * 1000,
|
|
})
|
|
const inFlightRequests = new Map<string, Promise<SohPrediction | null>>()
|
|
|
|
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 === POWER_STATUS.CHARGING ? '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: toMysqlBoolean(battery.isLowPower),
|
|
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 true
|
|
}
|
|
|
|
export async function predictSoh(battery: BatteryInfo, history: BatteryInfo[]): Promise<SohPrediction | null> {
|
|
const request = createPredictionRequest(battery, history)
|
|
if (!request) return null
|
|
|
|
const cacheKey = createCacheKey(battery, history)
|
|
const cached = cache.get(cacheKey)
|
|
if (cached) return cached
|
|
const pendingRequest = inFlightRequests.get(cacheKey)
|
|
if (pendingRequest) return pendingRequest
|
|
|
|
const requestPromise = requestPrediction(cacheKey, battery, request)
|
|
inFlightRequests.set(cacheKey, requestPromise)
|
|
|
|
return requestPromise
|
|
}
|
|
|
|
async function requestPrediction(
|
|
cacheKey: string,
|
|
battery: BatteryInfo,
|
|
request: PredictionRequest,
|
|
): Promise<SohPrediction | null> {
|
|
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, prediction)
|
|
|
|
return prediction
|
|
} catch (error) {
|
|
logger.warn('SOH prediction request errored', { mac: battery.mac, error })
|
|
return null
|
|
} finally {
|
|
clearTimeout(timeout)
|
|
inFlightRequests.delete(cacheKey)
|
|
}
|
|
}
|
|
|
|
export function clearPredictionCache() {
|
|
cache.clear()
|
|
inFlightRequests.clear()
|
|
}
|