Compare commits

...

3 Commits

5 changed files with 38 additions and 44 deletions
+5 -5
View File
@@ -90,9 +90,9 @@ describe('battery domain', () => {
expect(snapshot.devices.every((device) => device.soh30d === null)).toBe(true)
expect(snapshot.devices.every((device) => device.soh90d === null)).toBe(true)
expect(snapshot.devices.every((device) => device.soh60d === null)).toBe(true)
expect(snapshot.devices.every((device) => device.cycles === 0)).toBe(true)
expect(snapshot.devices.every((device) => device.temperature === 0)).toBe(true)
expect(snapshot.devices.every((device) => device.chargeEfficiency === 0)).toBe(true)
expect(snapshot.devices.every((device) => device.cycles === null)).toBe(true)
expect(snapshot.devices.every((device) => device.temperature === null)).toBe(true)
expect(snapshot.devices.every((device) => device.chargeEfficiency === null)).toBe(true)
expect(snapshot.devices[0]?.firmware).toBe('v3.8.2')
expect(snapshot.devices[1]?.firmware).toBe('未提供')
expect(snapshot.soh.history).toHaveLength(0)
@@ -140,8 +140,8 @@ describe('battery domain', () => {
expect(predicted?.cycles).toBe(6)
expect(predicted?.firmware).toBe('v3.8.2')
expect(predicted?.status).toBe(DEVICE_STATUS.WARNING)
expect(predicted?.temperature).toBe(0)
expect(predicted?.chargeEfficiency).toBe(0)
expect(predicted?.temperature).toBeNull()
expect(predicted?.chargeEfficiency).toBeNull()
expect(snapshot.soh.history).toHaveLength(0)
expect(snapshot.soh.forecast).toHaveLength(3)
expect(snapshot.soh.forecast[0]).toEqual({ month: '当前', value: 60 })
+6 -6
View File
@@ -57,15 +57,15 @@ export const fleetUnitSchema = z.object({
displayName: z.string(),
batch: z.string(),
firmware: z.string(),
cycles: z.number().int(),
cycles: z.number().int().nullable(),
soh: z.number().nullable(),
sohSource: z.union([z.literal('prediction'), z.literal('unavailable')]),
soh30d: z.number().nullable(),
soh60d: z.number().nullable(),
soh90d: z.number().nullable(),
temperature: z.number(),
temperature: z.number().nullable(),
riskScore: z.number().int(),
chargeEfficiency: z.number(),
chargeEfficiency: z.number().nullable(),
status: deviceStatusSchema,
riskFactors: z.array(z.string()),
})
@@ -260,8 +260,8 @@ function toFleetUnit(item: BatteryInfo, prediction?: BatteryPrediction): FleetUn
const soh30d = prediction ? round1(clamp(prediction.monthSoh, 0, 100)) : null
const soh90d = prediction ? round1(clamp(prediction.trmonthSoh, 0, 100)) : null
const soh60d = null
const temperature = 0
const chargeEfficiency = 0
const temperature = null
const chargeEfficiency = null
const fallbackRiskScore =
(item.isLowPower || item.power <= SOH_THRESHOLDS.LOW_POWER ? 60 : 0) +
(item.powerStatus === POWER_STATUS.CHARGING ? 20 : 0)
@@ -272,7 +272,7 @@ function toFleetUnit(item: BatteryInfo, prediction?: BatteryPrediction): FleetUn
displayName: item.devName || item.mac,
batch: item.devModel,
firmware: item.remark ?? '未提供',
cycles: prediction?.cyclesUsed ?? 0,
cycles: prediction?.cyclesUsed ?? null,
soh,
sohSource: prediction ? 'prediction' : 'unavailable',
soh30d,
+2
View File
@@ -33,6 +33,8 @@ export const Route = createFileRoute('/api/$')({
server: {
handlers: {
ANY: async ({ request }) => {
if (!env.ENABLE_API_DOCS) return new Response('Not Found', { status: 404 })
const { response } = await handler.handle(request, {
prefix: '/api',
context: {
+15
View File
@@ -74,4 +74,19 @@ describe('prediction client helpers', () => {
test('returns null for history-insufficient prediction requests', () => {
expect(createPredictionRequest(battery, [battery, battery, battery, battery])).toBeNull()
})
test('uses only real battery history fields in prediction requests', () => {
const request = createPredictionRequest(battery, [battery, battery, battery, battery, battery])
const firstHistory = request?.history[0]
expect(firstHistory).toEqual({
id: battery.id,
power: battery.power,
power_status: battery.powerStatus,
is_low_power: MYSQL_BOOLEAN.FALSE,
timestamp: battery.createTime,
})
expect(Object.hasOwn(firstHistory ?? {}, 'charge_capacity_ah')).toBe(false)
expect(Object.hasOwn(firstHistory ?? {}, 'coulombic_efficiency_pct')).toBe(false)
})
})
+10 -33
View File
@@ -1,12 +1,6 @@
import { LRUCache } from 'lru-cache'
import { z } from 'zod'
import {
type BatteryInfo,
type BatteryPrediction,
POWER_STATUS,
type PowerStatus,
toMysqlBoolean,
} from '@/domain/battery'
import { type BatteryInfo, type BatteryPrediction, type PowerStatus, toMysqlBoolean } from '@/domain/battery'
import { env } from '@/env'
import { getLogger } from '@/server/logger'
@@ -26,14 +20,10 @@ export const sohPredictionSchema = z.object({
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
id: number
power: number
power_status: PowerStatus
is_low_power: string
timestamp: string
}
@@ -78,8 +68,6 @@ const negativeCache = new LRUCache<string, true>({
})
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
@@ -94,23 +82,12 @@ function createCacheKey(battery: BatteryInfo, history: BatteryInfo[]) {
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)
function createHistoryItem(item: BatteryInfo): PredictionHistoryItem {
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,
id: item.id,
power: item.power,
power_status: item.powerStatus,
is_low_power: toMysqlBoolean(item.isLowPower),
timestamp: normalizeMysqlDateTime(item.createTime),
}
}