Compare commits
3 Commits
c8ea9330e1
...
c936167fc8
| Author | SHA1 | Date | |
|---|---|---|---|
| c936167fc8 | |||
| 1a2ff19cf4 | |||
| b11d37e9d8 |
@@ -1,5 +1,10 @@
|
||||
DATABASE_URL=mysql://user:password@localhost:3306/database
|
||||
|
||||
# Optional: external AI SoH prediction service.
|
||||
# SOH_PREDICTION_API_BASE_URL=http://127.0.0.1:8000
|
||||
# SOH_PREDICTION_CACHE_TTL_SECONDS=86400
|
||||
# SOH_PREDICTION_TIMEOUT_MS=10000
|
||||
|
||||
# Optional logging knobs (defaults are usually fine):
|
||||
# LOG_LEVEL=info # trace|debug|info|warning|error|fatal
|
||||
# LOG_FORMAT=pretty # pretty|json — defaults to TTY ? pretty : json
|
||||
|
||||
@@ -33,6 +33,14 @@ Environment variable:
|
||||
DATABASE_URL=mysql://user:password@host:3306/database
|
||||
```
|
||||
|
||||
Optional AI prediction service:
|
||||
|
||||
```bash
|
||||
SOH_PREDICTION_API_BASE_URL=http://127.0.0.1:8000
|
||||
SOH_PREDICTION_CACHE_TTL_SECONDS=86400
|
||||
SOH_PREDICTION_TIMEOUT_MS=10000
|
||||
```
|
||||
|
||||
Customer table: `ls_battery_info`.
|
||||
|
||||
| Column | Type | Meaning |
|
||||
@@ -56,6 +64,7 @@ Rules:
|
||||
- `power_status` is normalized to `0 | 1 | 2`.
|
||||
- Without `mac`, battery list queries return the latest record per `mac`.
|
||||
- With `mac`, battery list queries return history ordered by `create_time DESC, id DESC`, limited to 500 rows.
|
||||
- Dashboard may call the external prediction API when `SOH_PREDICTION_API_BASE_URL` is configured. Prediction results are cached in memory by `mac` and latest history record.
|
||||
|
||||
## Layout
|
||||
|
||||
@@ -67,7 +76,8 @@ src/
|
||||
│ └── api/ # ORPC handlers
|
||||
├── server/
|
||||
│ ├── api/ # contracts / routers / interceptors
|
||||
│ └── battery/mysql.ts
|
||||
│ ├── battery/mysql.ts
|
||||
│ └── prediction/client.ts
|
||||
├── domain/battery.ts
|
||||
├── client/orpc.ts
|
||||
└── styles.css
|
||||
|
||||
@@ -25,6 +25,16 @@
|
||||
DATABASE_URL=mysql://user:password@host:3306/database
|
||||
```
|
||||
|
||||
可选 AI SoH 预测服务:
|
||||
|
||||
```bash
|
||||
SOH_PREDICTION_API_BASE_URL=http://127.0.0.1:8000
|
||||
SOH_PREDICTION_CACHE_TTL_SECONDS=86400
|
||||
SOH_PREDICTION_TIMEOUT_MS=10000
|
||||
```
|
||||
|
||||
配置后,服务端会向 `${SOH_PREDICTION_API_BASE_URL}/predict` 发起 POST 请求,并把返回的 `now_soh`、`month_soh`、`trmonth_soh`、`risk_score` 等字段用于看板展示。预测结果按设备和最新采集记录做内存 TTL 缓存,默认 24 小时;如果未配置或预测服务失败,看板仍使用 MySQL 采集数据生成展示,不写入数据库。
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
@@ -66,7 +76,8 @@ src/
|
||||
├── routes/ # TanStack Start 文件路由:页面 + API 端点
|
||||
├── server/
|
||||
│ ├── api/ # ORPC contract / router
|
||||
│ └── battery/mysql.ts # 甲方 MySQL 只读查询
|
||||
│ ├── battery/mysql.ts # 甲方 MySQL 只读查询
|
||||
│ └── prediction/client.ts # AI SoH 预测客户端与缓存
|
||||
├── domain/battery.ts # 电池领域类型、归一化、展示聚合
|
||||
├── client/orpc.ts # isomorphic ORPC client
|
||||
└── styles.css # Tailwind v4 entry
|
||||
|
||||
@@ -59,4 +59,37 @@ describe('battery domain', () => {
|
||||
snapshot.devices.length,
|
||||
)
|
||||
})
|
||||
|
||||
test('uses AI prediction values when available', () => {
|
||||
const now = new Date('2026-05-11T00:00:00.000Z')
|
||||
const items = rows.map(toBatteryInfo)
|
||||
const snapshot = createDashboardSnapshot(
|
||||
items,
|
||||
now,
|
||||
new Map([
|
||||
[
|
||||
'RING-A03',
|
||||
{
|
||||
mac: 'RING-A03',
|
||||
nowSoh: 60,
|
||||
monthSoh: 58,
|
||||
trmonthSoh: 52,
|
||||
riskScore: 40,
|
||||
riskLevel: 'high',
|
||||
status: '危险',
|
||||
modelName: 'XGBoost',
|
||||
cyclesUsed: 6,
|
||||
updatedAt: '2026-05-11T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
]),
|
||||
)
|
||||
const predicted = snapshot.devices.find((device) => device.id === 'RING-A03')
|
||||
|
||||
expect(predicted?.soh).toBe(60)
|
||||
expect(predicted?.soh30d).toBe(58)
|
||||
expect(predicted?.soh90d).toBe(52)
|
||||
expect(predicted?.status).toBe('预警')
|
||||
expect(predicted?.firmware).toBe('XGBoost')
|
||||
})
|
||||
})
|
||||
|
||||
+62
-10
@@ -90,6 +90,19 @@ export const batteriesResponseSchema = z.object({
|
||||
})
|
||||
export type BatteriesResponse = z.infer<typeof batteriesResponseSchema>
|
||||
|
||||
export type BatteryPrediction = {
|
||||
mac: string
|
||||
nowSoh: number
|
||||
monthSoh: number
|
||||
trmonthSoh: number
|
||||
riskScore: number | null
|
||||
riskLevel: string | null
|
||||
status: string | null
|
||||
modelName: string | null
|
||||
cyclesUsed: number | null
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
const round1 = (value: number) => Math.round(value * 10) / 10
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value))
|
||||
@@ -115,6 +128,34 @@ export function getDeviceStatus(soh: number): DeviceStatus {
|
||||
return '健康'
|
||||
}
|
||||
|
||||
function getDeviceStatusByRisk(prediction: BatteryPrediction): DeviceStatus {
|
||||
const riskText = `${prediction.riskLevel ?? ''} ${prediction.status ?? ''}`.toLowerCase()
|
||||
|
||||
if (
|
||||
riskText.includes('high') ||
|
||||
riskText.includes('danger') ||
|
||||
riskText.includes('危险') ||
|
||||
riskText.includes('高')
|
||||
) {
|
||||
return '预警'
|
||||
}
|
||||
if (
|
||||
riskText.includes('medium') ||
|
||||
riskText.includes('warning') ||
|
||||
riskText.includes('关注') ||
|
||||
riskText.includes('中')
|
||||
) {
|
||||
return '关注'
|
||||
}
|
||||
|
||||
if (prediction.riskScore !== null) {
|
||||
if (prediction.riskScore >= 70) return '预警'
|
||||
if (prediction.riskScore >= 40) return '关注'
|
||||
}
|
||||
|
||||
return getDeviceStatus(prediction.nowSoh)
|
||||
}
|
||||
|
||||
export function normalizePowerStatus(value: number): PowerStatus {
|
||||
if (value === 1 || value === 2) return value
|
||||
return 0
|
||||
@@ -163,28 +204,35 @@ export function createBatteriesResponse(items: BatteryInfo[], now = new Date()):
|
||||
}
|
||||
}
|
||||
|
||||
function toFleetUnit(item: BatteryInfo, index: number): FleetUnit {
|
||||
const soh = item.power
|
||||
const status = getDeviceStatus(soh)
|
||||
function toFleetUnit(item: BatteryInfo, index: number, prediction?: BatteryPrediction): FleetUnit {
|
||||
const soh = prediction?.nowSoh ?? item.power
|
||||
const status = prediction ? getDeviceStatusByRisk(prediction) : getDeviceStatus(soh)
|
||||
const riskFactors: string[] = []
|
||||
|
||||
if (item.isLowPower || item.power <= 20) riskFactors.push('低电量')
|
||||
if (item.powerStatus === 1) riskFactors.push('充电中')
|
||||
if (status === '预警') riskFactors.push('衰减加速')
|
||||
if (item.remark?.includes('v3.7')) riskFactors.push('旧固件')
|
||||
if (prediction?.riskLevel) riskFactors.push(`AI风险:${prediction.riskLevel}`)
|
||||
|
||||
const thermalPressure = index % 3
|
||||
const soh30d = round1(clamp(soh - 0.8 - thermalPressure * 0.25, 0, 100))
|
||||
const soh60d = round1(clamp(soh - 1.7 - thermalPressure * 0.35, 0, 100))
|
||||
const soh90d = round1(clamp(soh - 2.8 - thermalPressure * 0.45, 0, 100))
|
||||
const soh30d = prediction
|
||||
? round1(clamp(prediction.monthSoh, 0, 100))
|
||||
: round1(clamp(soh - 0.8 - thermalPressure * 0.25, 0, 100))
|
||||
const soh90d = prediction
|
||||
? round1(clamp(prediction.trmonthSoh, 0, 100))
|
||||
: round1(clamp(soh - 2.8 - thermalPressure * 0.45, 0, 100))
|
||||
const soh60d = prediction ? round1((soh30d + soh90d) / 2) : round1(clamp(soh - 1.7 - thermalPressure * 0.35, 0, 100))
|
||||
const temperature = round1(29.5 + thermalPressure * 2.1 + (item.isLowPower ? 1.4 : 0))
|
||||
const chargeEfficiency = round1(clamp(91 + item.power / 12 - riskFactors.length * 1.8, 80, 98))
|
||||
const riskScore = Math.round(clamp(12 + (100 - soh) * 1.45 + riskFactors.length * 8 + thermalPressure * 4, 8, 96))
|
||||
const riskScore = Math.round(
|
||||
clamp(prediction?.riskScore ?? 12 + (100 - soh) * 1.45 + riskFactors.length * 8 + thermalPressure * 4, 8, 96),
|
||||
)
|
||||
|
||||
return {
|
||||
id: item.devName || item.mac,
|
||||
batch: item.devModel,
|
||||
firmware: item.remark ?? 'unknown',
|
||||
firmware: prediction?.modelName ?? item.remark ?? 'unknown',
|
||||
cycles: 120 + index * 17 + Math.round((100 - soh) * 2.2),
|
||||
soh,
|
||||
soh30d,
|
||||
@@ -341,8 +389,12 @@ function createStrategies(devices: FleetUnit[]) {
|
||||
] satisfies DashboardSnapshot['strategies']
|
||||
}
|
||||
|
||||
export function createDashboardSnapshot(items: BatteryInfo[], now = new Date()): DashboardSnapshot {
|
||||
const devices = items.map(toFleetUnit)
|
||||
export function createDashboardSnapshot(
|
||||
items: BatteryInfo[],
|
||||
now = new Date(),
|
||||
predictions: ReadonlyMap<string, BatteryPrediction> = new Map(),
|
||||
): DashboardSnapshot {
|
||||
const devices = items.map((item, index) => toFleetUnit(item, index, predictions.get(item.mac)))
|
||||
|
||||
return {
|
||||
devices,
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
import { createBatteriesResponse, createDashboardSnapshot } from '@/domain/battery'
|
||||
import { os } from '@/server/api/server'
|
||||
import { getBatteryHistory, getLatestBatteryPerDevice } from '@/server/battery/mysql'
|
||||
import { getBatteryHistory, getBatteryPredictionHistory, getLatestBatteryPerDevice } from '@/server/battery/mysql'
|
||||
import { isPredictionEnabled, predictSoh } from '@/server/prediction/client'
|
||||
|
||||
export const dashboard = os.battery.dashboard.handler(async () => {
|
||||
const items = await getLatestBatteryPerDevice()
|
||||
const predictionEntries = isPredictionEnabled()
|
||||
? await Promise.all(
|
||||
items.map(async (item) => {
|
||||
const history = await getBatteryPredictionHistory(item.mac)
|
||||
const prediction = await predictSoh(item, history)
|
||||
|
||||
return createDashboardSnapshot(items)
|
||||
return prediction ? ([item.mac, prediction] as const) : null
|
||||
}),
|
||||
)
|
||||
: []
|
||||
const predictions = new Map(predictionEntries.filter((entry) => entry !== null))
|
||||
|
||||
return createDashboardSnapshot(items, new Date(), predictions)
|
||||
})
|
||||
|
||||
export const batteries = os.battery.batteries.handler(async ({ input }) => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { type BatteryInfo, type BatteryInfoSourceRow, toBatteryInfo } from '@/do
|
||||
import { env } from '@/env'
|
||||
|
||||
const historyLimit = 500
|
||||
const predictionHistoryLimit = 10
|
||||
|
||||
type BatteryInfoMysqlRow = RowDataPacket & BatteryInfoSourceRow
|
||||
|
||||
@@ -55,6 +56,21 @@ export async function getBatteryHistory(mac: string): Promise<BatteryInfo[]> {
|
||||
return rows.map(toBatteryInfo)
|
||||
}
|
||||
|
||||
export async function getBatteryPredictionHistory(mac: string): Promise<BatteryInfo[]> {
|
||||
const [rows] = await getBatteryPool().query<BatteryInfoMysqlRow[]>(
|
||||
`
|
||||
SELECT ${sourceColumns}
|
||||
FROM ls_battery_info
|
||||
WHERE mac = :mac
|
||||
ORDER BY create_time DESC, id DESC
|
||||
LIMIT :limit
|
||||
`,
|
||||
{ mac, limit: predictionHistoryLimit },
|
||||
)
|
||||
|
||||
return rows.map(toBatteryInfo).reverse()
|
||||
}
|
||||
|
||||
export async function getLatestBatteryPerDevice(): Promise<BatteryInfo[]> {
|
||||
const [rows] = await getBatteryPool().query<BatteryInfoMysqlRow[]>(`
|
||||
SELECT ${sourceColumns}
|
||||
|
||||
@@ -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<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
|
||||
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<string, CacheEntry>()
|
||||
|
||||
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<typeof predictionResponseSchema>): 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<SohPrediction | null> {
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user