fix(prediction): 缓存不可用预测结果
This commit is contained in:
@@ -0,0 +1,77 @@
|
|||||||
|
import { beforeAll, describe, expect, test } from 'bun:test'
|
||||||
|
import type { BatteryInfo } from '@/domain/battery'
|
||||||
|
import { MYSQL_BOOLEAN, POWER_STATUS, toBatteryInfo } from '@/domain/battery'
|
||||||
|
import type { normalizePrediction as normalizePredictionType } from './client'
|
||||||
|
|
||||||
|
type PredictionClientModule = typeof import('./client')
|
||||||
|
|
||||||
|
const battery = toBatteryInfo({
|
||||||
|
id: 10,
|
||||||
|
userId: 7,
|
||||||
|
mac: 'RING-A03',
|
||||||
|
devModel: '2401-A',
|
||||||
|
devName: 'RING-A03',
|
||||||
|
isLowPower: MYSQL_BOOLEAN.FALSE,
|
||||||
|
powerStatus: POWER_STATUS.FULL,
|
||||||
|
power: 94,
|
||||||
|
createTime: '2026-05-10 23:00:00',
|
||||||
|
remark: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const predictionResponse = {
|
||||||
|
battery_id: 10,
|
||||||
|
mac: 'RING-A03',
|
||||||
|
now_soh: 91,
|
||||||
|
month_soh: 89,
|
||||||
|
trmonth_soh: 84,
|
||||||
|
risk_score: null,
|
||||||
|
risk_level: null,
|
||||||
|
status: null,
|
||||||
|
model_name: 'xgboost',
|
||||||
|
cycles_used: 6,
|
||||||
|
updated_at: '2026-05-11T00:00:00.000Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
let createPredictionRequest: PredictionClientModule['createPredictionRequest']
|
||||||
|
let isPredictionForBattery: PredictionClientModule['isPredictionForBattery']
|
||||||
|
let normalizePrediction: typeof normalizePredictionType
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
process.env.DATABASE_URL = 'mysql://user:password@localhost:3306/database'
|
||||||
|
process.env.SOH_PREDICTION_API_BASE_URL = 'http://127.0.0.1:8000'
|
||||||
|
|
||||||
|
const client = await import('./client')
|
||||||
|
createPredictionRequest = client.createPredictionRequest
|
||||||
|
isPredictionForBattery = client.isPredictionForBattery
|
||||||
|
normalizePrediction = client.normalizePrediction
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('prediction client helpers', () => {
|
||||||
|
test('normalizes prediction response shape without fake fallback values', () => {
|
||||||
|
const prediction = normalizePrediction(predictionResponse)
|
||||||
|
|
||||||
|
expect(prediction).toEqual({
|
||||||
|
batteryId: 10,
|
||||||
|
mac: 'RING-A03',
|
||||||
|
nowSoh: 91,
|
||||||
|
monthSoh: 89,
|
||||||
|
trmonthSoh: 84,
|
||||||
|
riskScore: null,
|
||||||
|
riskLevel: null,
|
||||||
|
status: null,
|
||||||
|
modelName: 'xgboost',
|
||||||
|
cyclesUsed: 6,
|
||||||
|
updatedAt: '2026-05-11T00:00:00.000Z',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('requires prediction responses to belong to the requested battery', () => {
|
||||||
|
expect(isPredictionForBattery(normalizePrediction(predictionResponse), battery)).toBe(true)
|
||||||
|
expect(isPredictionForBattery({ batteryId: 11, mac: battery.mac }, battery as BatteryInfo)).toBe(false)
|
||||||
|
expect(isPredictionForBattery({ batteryId: battery.id, mac: 'RING-B11' }, battery as BatteryInfo)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns null for history-insufficient prediction requests', () => {
|
||||||
|
expect(createPredictionRequest(battery, [battery, battery, battery, battery])).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -72,6 +72,10 @@ const cache = new LRUCache<string, SohPrediction>({
|
|||||||
max: 5_000,
|
max: 5_000,
|
||||||
ttl: env.SOH_PREDICTION_CACHE_TTL_SECONDS * 1000,
|
ttl: env.SOH_PREDICTION_CACHE_TTL_SECONDS * 1000,
|
||||||
})
|
})
|
||||||
|
const negativeCache = new LRUCache<string, true>({
|
||||||
|
max: 5_000,
|
||||||
|
ttl: env.SOH_PREDICTION_NEGATIVE_CACHE_TTL_SECONDS * 1000,
|
||||||
|
})
|
||||||
const inFlightRequests = new Map<string, Promise<SohPrediction | null>>()
|
const inFlightRequests = new Map<string, Promise<SohPrediction | null>>()
|
||||||
|
|
||||||
const round2 = (value: number) => Math.round(value * 100) / 100
|
const round2 = (value: number) => Math.round(value * 100) / 100
|
||||||
@@ -133,7 +137,7 @@ export function createPredictionRequest(battery: BatteryInfo, history: BatteryIn
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizePrediction(response: z.infer<typeof predictionResponseSchema>): SohPrediction {
|
export function normalizePrediction(response: z.infer<typeof predictionResponseSchema>): SohPrediction {
|
||||||
return {
|
return {
|
||||||
batteryId: response.battery_id,
|
batteryId: response.battery_id,
|
||||||
mac: response.mac,
|
mac: response.mac,
|
||||||
@@ -149,15 +153,24 @@ function normalizePrediction(response: z.infer<typeof predictionResponseSchema>)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isPredictionForBattery(prediction: Pick<SohPrediction, 'batteryId' | 'mac'>, battery: BatteryInfo) {
|
||||||
|
return prediction.batteryId === battery.id && prediction.mac === battery.mac
|
||||||
|
}
|
||||||
|
|
||||||
export function isPredictionEnabled() {
|
export function isPredictionEnabled() {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function predictSoh(battery: BatteryInfo, history: BatteryInfo[]): Promise<SohPrediction | null> {
|
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 cacheKey = createCacheKey(battery, history)
|
||||||
|
if (negativeCache.has(cacheKey)) return null
|
||||||
|
|
||||||
|
const request = createPredictionRequest(battery, history)
|
||||||
|
if (!request) {
|
||||||
|
negativeCache.set(cacheKey, true)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const cached = cache.get(cacheKey)
|
const cached = cache.get(cacheKey)
|
||||||
if (cached) return cached
|
if (cached) return cached
|
||||||
const pendingRequest = inFlightRequests.get(cacheKey)
|
const pendingRequest = inFlightRequests.get(cacheKey)
|
||||||
@@ -191,24 +204,41 @@ async function requestPrediction(
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
logger.warn('SOH prediction request failed', { mac: battery.mac, status: response.status })
|
logger.warn('SOH prediction request failed', { mac: battery.mac, status: response.status })
|
||||||
return null
|
return cacheNegativePrediction(cacheKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
const json = await response.json()
|
const json = await response.json()
|
||||||
const prediction = normalizePrediction(predictionResponseSchema.parse(json))
|
const prediction = normalizePrediction(predictionResponseSchema.parse(json))
|
||||||
|
if (!isPredictionForBattery(prediction, battery)) {
|
||||||
|
logger.warn('SOH prediction response mismatched requested battery', {
|
||||||
|
requestedBatteryId: battery.id,
|
||||||
|
requestedMac: battery.mac,
|
||||||
|
responseBatteryId: prediction.batteryId,
|
||||||
|
responseMac: prediction.mac,
|
||||||
|
})
|
||||||
|
return cacheNegativePrediction(cacheKey)
|
||||||
|
}
|
||||||
|
|
||||||
cache.set(cacheKey, prediction)
|
cache.set(cacheKey, prediction)
|
||||||
|
negativeCache.delete(cacheKey)
|
||||||
|
|
||||||
return prediction
|
return prediction
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn('SOH prediction request errored', { mac: battery.mac, error })
|
logger.warn('SOH prediction request errored', { mac: battery.mac, error })
|
||||||
return null
|
return cacheNegativePrediction(cacheKey)
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timeout)
|
clearTimeout(timeout)
|
||||||
inFlightRequests.delete(cacheKey)
|
inFlightRequests.delete(cacheKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cacheNegativePrediction(cacheKey: string) {
|
||||||
|
negativeCache.set(cacheKey, true)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
export function clearPredictionCache() {
|
export function clearPredictionCache() {
|
||||||
cache.clear()
|
cache.clear()
|
||||||
|
negativeCache.clear()
|
||||||
inFlightRequests.clear()
|
inFlightRequests.clear()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user