From b11d37e9d85e027be9df04d04cebae46ac095042 Mon Sep 17 00:00:00 2001 From: imbytecat Date: Mon, 11 May 2026 22:21:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(prediction):=20=E6=96=B0=E5=A2=9E=20AI=20S?= =?UTF-8?q?oH=20=E9=A2=84=E6=B5=8B=E5=AE=A2=E6=88=B7=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/env.ts | 3 + src/server/prediction/client.ts | 198 ++++++++++++++++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 src/server/prediction/client.ts diff --git a/src/env.ts b/src/env.ts index ab7a276..ff614d1 100644 --- a/src/env.ts +++ b/src/env.ts @@ -7,6 +7,9 @@ export const env = createEnv({ LOG_DB: z.stringbool().default(false), LOG_FORMAT: z.enum(['pretty', 'json']).optional(), LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warning', 'error', 'fatal']).default('info'), + SOH_PREDICTION_API_BASE_URL: z.url({ protocol: /^https?$/ }).optional(), + SOH_PREDICTION_CACHE_TTL_SECONDS: z.coerce.number().int().positive().default(86_400), + SOH_PREDICTION_TIMEOUT_MS: z.coerce.number().int().positive().default(10_000), }, clientPrefix: 'VITE_', client: {}, diff --git a/src/server/prediction/client.ts b/src/server/prediction/client.ts new file mode 100644 index 0000000..670e90d --- /dev/null +++ b/src/server/prediction/client.ts @@ -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 + +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() + +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): 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 { + 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() +}