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 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({ max: 5_000, ttl: env.SOH_PREDICTION_CACHE_TTL_SECONDS * 1000, }) const inFlightRequests = new Map>() 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): 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 { 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 { 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() }