Compare commits
5 Commits
e6b351e39c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b2ae856b7 | |||
| c00e04dfb0 | |||
| 305ed1b692 | |||
| 1126fad2c2 | |||
| 76854fe23b |
@@ -90,9 +90,9 @@ describe('battery domain', () => {
|
|||||||
expect(snapshot.devices.every((device) => device.soh30d === null)).toBe(true)
|
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.soh90d === null)).toBe(true)
|
||||||
expect(snapshot.devices.every((device) => device.soh60d === 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.cycles === null)).toBe(true)
|
||||||
expect(snapshot.devices.every((device) => device.temperature === 0)).toBe(true)
|
expect(snapshot.devices.every((device) => device.temperature === null)).toBe(true)
|
||||||
expect(snapshot.devices.every((device) => device.chargeEfficiency === 0)).toBe(true)
|
expect(snapshot.devices.every((device) => device.chargeEfficiency === null)).toBe(true)
|
||||||
expect(snapshot.devices[0]?.firmware).toBe('v3.8.2')
|
expect(snapshot.devices[0]?.firmware).toBe('v3.8.2')
|
||||||
expect(snapshot.devices[1]?.firmware).toBe('未提供')
|
expect(snapshot.devices[1]?.firmware).toBe('未提供')
|
||||||
expect(snapshot.soh.history).toHaveLength(0)
|
expect(snapshot.soh.history).toHaveLength(0)
|
||||||
@@ -140,8 +140,8 @@ describe('battery domain', () => {
|
|||||||
expect(predicted?.cycles).toBe(6)
|
expect(predicted?.cycles).toBe(6)
|
||||||
expect(predicted?.firmware).toBe('v3.8.2')
|
expect(predicted?.firmware).toBe('v3.8.2')
|
||||||
expect(predicted?.status).toBe(DEVICE_STATUS.WARNING)
|
expect(predicted?.status).toBe(DEVICE_STATUS.WARNING)
|
||||||
expect(predicted?.temperature).toBe(0)
|
expect(predicted?.temperature).toBeNull()
|
||||||
expect(predicted?.chargeEfficiency).toBe(0)
|
expect(predicted?.chargeEfficiency).toBeNull()
|
||||||
expect(snapshot.soh.history).toHaveLength(0)
|
expect(snapshot.soh.history).toHaveLength(0)
|
||||||
expect(snapshot.soh.forecast).toHaveLength(3)
|
expect(snapshot.soh.forecast).toHaveLength(3)
|
||||||
expect(snapshot.soh.forecast[0]).toEqual({ month: '当前', value: 60 })
|
expect(snapshot.soh.forecast[0]).toEqual({ month: '当前', value: 60 })
|
||||||
|
|||||||
@@ -57,15 +57,15 @@ export const fleetUnitSchema = z.object({
|
|||||||
displayName: z.string(),
|
displayName: z.string(),
|
||||||
batch: z.string(),
|
batch: z.string(),
|
||||||
firmware: z.string(),
|
firmware: z.string(),
|
||||||
cycles: z.number().int(),
|
cycles: z.number().int().nullable(),
|
||||||
soh: z.number().nullable(),
|
soh: z.number().nullable(),
|
||||||
sohSource: z.union([z.literal('prediction'), z.literal('unavailable')]),
|
sohSource: z.union([z.literal('prediction'), z.literal('unavailable')]),
|
||||||
soh30d: z.number().nullable(),
|
soh30d: z.number().nullable(),
|
||||||
soh60d: z.number().nullable(),
|
soh60d: z.number().nullable(),
|
||||||
soh90d: z.number().nullable(),
|
soh90d: z.number().nullable(),
|
||||||
temperature: z.number(),
|
temperature: z.number().nullable(),
|
||||||
riskScore: z.number().int(),
|
riskScore: z.number().int(),
|
||||||
chargeEfficiency: z.number(),
|
chargeEfficiency: z.number().nullable(),
|
||||||
status: deviceStatusSchema,
|
status: deviceStatusSchema,
|
||||||
riskFactors: z.array(z.string()),
|
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 soh30d = prediction ? round1(clamp(prediction.monthSoh, 0, 100)) : null
|
||||||
const soh90d = prediction ? round1(clamp(prediction.trmonthSoh, 0, 100)) : null
|
const soh90d = prediction ? round1(clamp(prediction.trmonthSoh, 0, 100)) : null
|
||||||
const soh60d = null
|
const soh60d = null
|
||||||
const temperature = 0
|
const temperature = null
|
||||||
const chargeEfficiency = 0
|
const chargeEfficiency = null
|
||||||
const fallbackRiskScore =
|
const fallbackRiskScore =
|
||||||
(item.isLowPower || item.power <= SOH_THRESHOLDS.LOW_POWER ? 60 : 0) +
|
(item.isLowPower || item.power <= SOH_THRESHOLDS.LOW_POWER ? 60 : 0) +
|
||||||
(item.powerStatus === POWER_STATUS.CHARGING ? 20 : 0)
|
(item.powerStatus === POWER_STATUS.CHARGING ? 20 : 0)
|
||||||
@@ -272,7 +272,7 @@ function toFleetUnit(item: BatteryInfo, prediction?: BatteryPrediction): FleetUn
|
|||||||
displayName: item.devName || item.mac,
|
displayName: item.devName || item.mac,
|
||||||
batch: item.devModel,
|
batch: item.devModel,
|
||||||
firmware: item.remark ?? '未提供',
|
firmware: item.remark ?? '未提供',
|
||||||
cycles: prediction?.cyclesUsed ?? 0,
|
cycles: prediction?.cyclesUsed ?? null,
|
||||||
soh,
|
soh,
|
||||||
sohSource: prediction ? 'prediction' : 'unavailable',
|
sohSource: prediction ? 'prediction' : 'unavailable',
|
||||||
soh30d,
|
soh30d,
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ export const Route = createFileRoute('/api/$')({
|
|||||||
server: {
|
server: {
|
||||||
handlers: {
|
handlers: {
|
||||||
ANY: async ({ request }) => {
|
ANY: async ({ request }) => {
|
||||||
|
if (!env.ENABLE_API_DOCS) return new Response('Not Found', { status: 404 })
|
||||||
|
|
||||||
const { response } = await handler.handle(request, {
|
const { response } = await handler.handle(request, {
|
||||||
prefix: '/api',
|
prefix: '/api',
|
||||||
context: {
|
context: {
|
||||||
|
|||||||
@@ -74,4 +74,24 @@ describe('prediction client helpers', () => {
|
|||||||
test('returns null for history-insufficient prediction requests', () => {
|
test('returns null for history-insufficient prediction requests', () => {
|
||||||
expect(createPredictionRequest(battery, [battery, battery, battery, battery])).toBeNull()
|
expect(createPredictionRequest(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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
import { LRUCache } from 'lru-cache'
|
import { LRUCache } from 'lru-cache'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import {
|
import { type BatteryInfo, type BatteryPrediction, type PowerStatus, toMysqlBoolean } from '@/domain/battery'
|
||||||
type BatteryInfo,
|
|
||||||
type BatteryPrediction,
|
|
||||||
POWER_STATUS,
|
|
||||||
type PowerStatus,
|
|
||||||
toMysqlBoolean,
|
|
||||||
} from '@/domain/battery'
|
|
||||||
import { env } from '@/env'
|
import { env } from '@/env'
|
||||||
import { getLogger } from '@/server/logger'
|
import { getLogger } from '@/server/logger'
|
||||||
|
|
||||||
@@ -29,11 +23,6 @@ type PredictionHistoryItem = {
|
|||||||
cycle: number
|
cycle: number
|
||||||
charge_capacity_ah: number
|
charge_capacity_ah: number
|
||||||
discharge_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
|
timestamp: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +66,7 @@ const negativeCache = new LRUCache<string, true>({
|
|||||||
ttl: env.SOH_PREDICTION_NEGATIVE_CACHE_TTL_SECONDS * 1000,
|
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 nominalCapacityAh = 3.2
|
||||||
|
|
||||||
const round2 = (value: number) => Math.round(value * 100) / 100
|
const round2 = (value: number) => Math.round(value * 100) / 100
|
||||||
|
|
||||||
@@ -95,22 +85,13 @@ function createCacheKey(battery: BatteryInfo, history: BatteryInfo[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createHistoryItem(item: BatteryInfo, index: number): PredictionHistoryItem {
|
function createHistoryItem(item: BatteryInfo, index: number): PredictionHistoryItem {
|
||||||
const sohRatio = Math.max(0.5, Math.min(1, item.power / 100))
|
const observedCapacityRatio = Math.max(0.5, Math.min(1, item.power / 100))
|
||||||
const chargeCapacity = round2(3.2 * sohRatio)
|
const chargeCapacityAh = round2(nominalCapacityAh * observedCapacityRatio)
|
||||||
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 {
|
return {
|
||||||
cycle: index + 1,
|
cycle: index + 1,
|
||||||
charge_capacity_ah: chargeCapacity,
|
charge_capacity_ah: chargeCapacityAh,
|
||||||
discharge_capacity_ah: dischargeCapacity,
|
discharge_capacity_ah: round2(chargeCapacityAh * 0.96),
|
||||||
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),
|
timestamp: normalizeMysqlDateTime(item.createTime),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user