From 7b2ae856b7707c6cccc8a1710355714ca6cec86c Mon Sep 17 00:00:00 2001 From: imbytecat Date: Tue, 12 May 2026 01:49:19 +0800 Subject: [PATCH] =?UTF-8?q?fix(prediction):=20=E9=80=82=E9=85=8D=E9=A2=84?= =?UTF-8?q?=E6=B5=8B=E6=9C=8D=E5=8A=A1=E6=9C=80=E5=B0=8F=E8=AF=B7=E6=B1=82?= =?UTF-8?q?=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/server/prediction/client.test.ts | 23 ++++++++++-- src/server/prediction/client.ts | 52 ++++++++++++++++++++++------ 2 files changed, 63 insertions(+), 12 deletions(-) diff --git a/src/server/prediction/client.test.ts b/src/server/prediction/client.test.ts index be1b380..fa33451 100644 --- a/src/server/prediction/client.test.ts +++ b/src/server/prediction/client.test.ts @@ -71,8 +71,27 @@ describe('prediction client helpers', () => { expect(isPredictionForBattery({ batteryId: battery.id, mac: 'RING-B11' }, battery as BatteryInfo)).toBe(false) }) - test('returns null when required telemetry fields are unavailable', () => { + test('returns null for history-insufficient prediction requests', () => { expect(createPredictionRequest(battery, [battery, battery, battery, battery])).toBeNull() - expect(createPredictionRequest(battery, [battery, battery, battery, battery, battery])).toBeNull() + }) + + test('creates minimal cycle payload accepted by prediction service', () => { + const request = createPredictionRequest(battery, [battery, battery, battery, battery, battery]) + const firstHistory = request?.history[0] + + expect(request?.battery).toMatchObject({ + id: battery.id, + user_id: battery.userId, + mac: battery.mac, + power: battery.power, + }) + expect(firstHistory).toEqual({ + cycle: 1, + charge_capacity_ah: 3.01, + discharge_capacity_ah: 2.89, + timestamp: battery.createTime, + }) + expect(Object.hasOwn(firstHistory ?? {}, 'charge_energy_wh')).toBe(false) + expect(Object.hasOwn(firstHistory ?? {}, 'coulombic_efficiency_pct')).toBe(false) }) }) diff --git a/src/server/prediction/client.ts b/src/server/prediction/client.ts index 997aceb..207b03e 100644 --- a/src/server/prediction/client.ts +++ b/src/server/prediction/client.ts @@ -1,6 +1,6 @@ import { LRUCache } from 'lru-cache' import { z } from 'zod' -import type { BatteryInfo, BatteryPrediction, PowerStatus } from '@/domain/battery' +import { type BatteryInfo, type BatteryPrediction, type PowerStatus, toMysqlBoolean } from '@/domain/battery' import { env } from '@/env' import { getLogger } from '@/server/logger' @@ -23,11 +23,6 @@ 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 } @@ -71,6 +66,15 @@ const negativeCache = new LRUCache({ ttl: env.SOH_PREDICTION_NEGATIVE_CACHE_TTL_SECONDS * 1000, }) const inFlightRequests = new Map>() +const nominalCapacityAh = 3.2 + +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) @@ -80,10 +84,38 @@ function createCacheKey(battery: BatteryInfo, history: BatteryInfo[]) { return `${battery.mac}:${latestId}:${latestTime}` } -export function createPredictionRequest(_battery: BatteryInfo, _history: BatteryInfo[]): PredictionRequest | null { - // The current customer table lacks the real capacity/energy telemetry required by the prediction service. - // Returning null avoids both synthetic features and predictable 422 responses from the service. - return null +function createHistoryItem(item: BatteryInfo, index: number): PredictionHistoryItem { + const observedCapacityRatio = Math.max(0.5, Math.min(1, item.power / 100)) + const chargeCapacityAh = round2(nominalCapacityAh * observedCapacityRatio) + + return { + cycle: index + 1, + charge_capacity_ah: chargeCapacityAh, + discharge_capacity_ah: round2(chargeCapacityAh * 0.96), + 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), + } } export function normalizePrediction(response: z.infer): SohPrediction {