Compare commits
3 Commits
c936167fc8
...
cf6f91651d
| Author | SHA1 | Date | |
|---|---|---|---|
| cf6f91651d | |||
| 29e70fea9a | |||
| 6ff9dbe772 |
@@ -18,8 +18,11 @@
|
|||||||
"@tanstack/react-router": "^1.168.24",
|
"@tanstack/react-router": "^1.168.24",
|
||||||
"@tanstack/react-router-ssr-query": "^1.166.11",
|
"@tanstack/react-router-ssr-query": "^1.166.11",
|
||||||
"@tanstack/react-start": "^1.167.48",
|
"@tanstack/react-start": "^1.167.48",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"@tanstack/react-virtual": "^3.13.24",
|
||||||
"citty": "^0.2.2",
|
"citty": "^0.2.2",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
|
"lru-cache": "^11.3.6",
|
||||||
"mysql2": "^3.22.3",
|
"mysql2": "^3.22.3",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
@@ -338,6 +341,10 @@
|
|||||||
|
|
||||||
"@tanstack/react-store": ["@tanstack/react-store@0.9.3", "", { "dependencies": { "@tanstack/store": "0.9.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg=="],
|
"@tanstack/react-store": ["@tanstack/react-store@0.9.3", "", { "dependencies": { "@tanstack/store": "0.9.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg=="],
|
||||||
|
|
||||||
|
"@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="],
|
||||||
|
|
||||||
|
"@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.24", "", { "dependencies": { "@tanstack/virtual-core": "3.14.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg=="],
|
||||||
|
|
||||||
"@tanstack/router-core": ["@tanstack/router-core@1.168.15", "", { "dependencies": { "@tanstack/history": "1.161.6", "cookie-es": "^3.0.0", "seroval": "^1.5.0", "seroval-plugins": "^1.5.0" }, "bin": { "intent": "bin/intent.js" } }, "sha512-Wr0424NDtD8fT/uALobMZ9DdcfsTyXtW5IPR++7zvW8/7RaIOeaqXpVDId8ywaGtqPWLWOfaUg2zUtYtukoXYA=="],
|
"@tanstack/router-core": ["@tanstack/router-core@1.168.15", "", { "dependencies": { "@tanstack/history": "1.161.6", "cookie-es": "^3.0.0", "seroval": "^1.5.0", "seroval-plugins": "^1.5.0" }, "bin": { "intent": "bin/intent.js" } }, "sha512-Wr0424NDtD8fT/uALobMZ9DdcfsTyXtW5IPR++7zvW8/7RaIOeaqXpVDId8ywaGtqPWLWOfaUg2zUtYtukoXYA=="],
|
||||||
|
|
||||||
"@tanstack/router-devtools-core": ["@tanstack/router-devtools-core@1.167.3", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16" }, "peerDependencies": { "@tanstack/router-core": "^1.168.11", "csstype": "^3.0.10" }, "optionalPeers": ["csstype"] }, "sha512-fJ1VMhyQgnoashTrP763c2HRc9kofgF61L7Jb3F6eTHAmCKtGVx8BRtiFt37sr3U0P0jmaaiiSPGP6nT5JtVNg=="],
|
"@tanstack/router-devtools-core": ["@tanstack/router-devtools-core@1.167.3", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16" }, "peerDependencies": { "@tanstack/router-core": "^1.168.11", "csstype": "^3.0.10" }, "optionalPeers": ["csstype"] }, "sha512-fJ1VMhyQgnoashTrP763c2HRc9kofgF61L7Jb3F6eTHAmCKtGVx8BRtiFt37sr3U0P0jmaaiiSPGP6nT5JtVNg=="],
|
||||||
@@ -362,6 +369,10 @@
|
|||||||
|
|
||||||
"@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="],
|
"@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="],
|
||||||
|
|
||||||
|
"@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="],
|
||||||
|
|
||||||
|
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.14.0", "", {}, "sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q=="],
|
||||||
|
|
||||||
"@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.161.7", "", { "bin": { "intent": "bin/intent.js" } }, "sha512-olW33+Cn+bsCsZKPwEGhlkqS6w3M2slFv11JIobdnCFKMLG97oAI2kWKdx5/zsywTL8flpnoIgaZZPlQTFYhdQ=="],
|
"@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.161.7", "", { "bin": { "intent": "bin/intent.js" } }, "sha512-olW33+Cn+bsCsZKPwEGhlkqS6w3M2slFv11JIobdnCFKMLG97oAI2kWKdx5/zsywTL8flpnoIgaZZPlQTFYhdQ=="],
|
||||||
|
|
||||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||||
@@ -604,7 +615,7 @@
|
|||||||
|
|
||||||
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
|
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
|
||||||
|
|
||||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
"lru-cache": ["lru-cache@11.3.6", "", {}, "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A=="],
|
||||||
|
|
||||||
"lru.min": ["lru.min@1.1.4", "", {}, "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA=="],
|
"lru.min": ["lru.min@1.1.4", "", {}, "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA=="],
|
||||||
|
|
||||||
@@ -762,6 +773,8 @@
|
|||||||
|
|
||||||
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||||
|
|
||||||
|
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||||
|
|
||||||
"@reduxjs/toolkit/immer": ["immer@11.1.8", "", {}, "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA=="],
|
"@reduxjs/toolkit/immer": ["immer@11.1.8", "", {}, "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
|
||||||
|
|||||||
@@ -39,8 +39,11 @@
|
|||||||
"@tanstack/react-router": "^1.168.24",
|
"@tanstack/react-router": "^1.168.24",
|
||||||
"@tanstack/react-router-ssr-query": "^1.166.11",
|
"@tanstack/react-router-ssr-query": "^1.166.11",
|
||||||
"@tanstack/react-start": "^1.167.48",
|
"@tanstack/react-start": "^1.167.48",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"@tanstack/react-virtual": "^3.13.24",
|
||||||
"citty": "^0.2.2",
|
"citty": "^0.2.2",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
|
"lru-cache": "^11.3.6",
|
||||||
"mysql2": "^3.22.3",
|
"mysql2": "^3.22.3",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
|
|||||||
@@ -44,14 +44,17 @@ describe('battery domain', () => {
|
|||||||
expect(response.total).toBe(items.length)
|
expect(response.total).toBe(items.length)
|
||||||
expect(response.lowPower).toBe(1)
|
expect(response.lowPower).toBe(1)
|
||||||
expect(response.charging).toBe(1)
|
expect(response.charging).toBe(1)
|
||||||
|
expect(response.nextCursor).toBeNull()
|
||||||
expect(response.items[0]?.createTime).toBe('2026-05-10T23:00:00.000Z')
|
expect(response.items[0]?.createTime).toBe('2026-05-10T23:00:00.000Z')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('creates old dashboard aggregate shape from deterministic records', () => {
|
test('creates dashboard aggregate shape without using power as fake SOH', () => {
|
||||||
const now = new Date('2026-05-11T00:00:00.000Z')
|
const now = new Date('2026-05-11T00:00:00.000Z')
|
||||||
const snapshot = createDashboardSnapshot(rows.map(toBatteryInfo), now)
|
const snapshot = createDashboardSnapshot(rows.map(toBatteryInfo), now)
|
||||||
|
|
||||||
expect(snapshot.devices).toHaveLength(2)
|
expect(snapshot.devices).toHaveLength(2)
|
||||||
|
expect(snapshot.devices.every((device) => device.sohSource === 'unavailable')).toBe(true)
|
||||||
|
expect(snapshot.devices.every((device) => device.soh === 0)).toBe(true)
|
||||||
expect(snapshot.soh.history).toHaveLength(12)
|
expect(snapshot.soh.history).toHaveLength(12)
|
||||||
expect(snapshot.soh.forecast).toHaveLength(4)
|
expect(snapshot.soh.forecast).toHaveLength(4)
|
||||||
expect(snapshot.summary.totalDevices).toBe(snapshot.devices.length)
|
expect(snapshot.summary.totalDevices).toBe(snapshot.devices.length)
|
||||||
@@ -87,6 +90,7 @@ describe('battery domain', () => {
|
|||||||
const predicted = snapshot.devices.find((device) => device.id === 'RING-A03')
|
const predicted = snapshot.devices.find((device) => device.id === 'RING-A03')
|
||||||
|
|
||||||
expect(predicted?.soh).toBe(60)
|
expect(predicted?.soh).toBe(60)
|
||||||
|
expect(predicted?.sohSource).toBe('prediction')
|
||||||
expect(predicted?.soh30d).toBe(58)
|
expect(predicted?.soh30d).toBe(58)
|
||||||
expect(predicted?.soh90d).toBe(52)
|
expect(predicted?.soh90d).toBe(52)
|
||||||
expect(predicted?.status).toBe('预警')
|
expect(predicted?.status).toBe('预警')
|
||||||
|
|||||||
+28
-15
@@ -26,6 +26,7 @@ export const fleetUnitSchema = z.object({
|
|||||||
firmware: z.string(),
|
firmware: z.string(),
|
||||||
cycles: z.number().int(),
|
cycles: z.number().int(),
|
||||||
soh: z.number(),
|
soh: z.number(),
|
||||||
|
sohSource: z.union([z.literal('prediction'), z.literal('unavailable')]),
|
||||||
soh30d: z.number(),
|
soh30d: z.number(),
|
||||||
soh60d: z.number(),
|
soh60d: z.number(),
|
||||||
soh90d: z.number(),
|
soh90d: z.number(),
|
||||||
@@ -87,9 +88,16 @@ export const batteriesResponseSchema = z.object({
|
|||||||
lowPower: z.number().int(),
|
lowPower: z.number().int(),
|
||||||
charging: z.number().int(),
|
charging: z.number().int(),
|
||||||
items: z.array(batteryInfoSchema),
|
items: z.array(batteryInfoSchema),
|
||||||
|
nextCursor: z.string().nullable(),
|
||||||
})
|
})
|
||||||
export type BatteriesResponse = z.infer<typeof batteriesResponseSchema>
|
export type BatteriesResponse = z.infer<typeof batteriesResponseSchema>
|
||||||
|
|
||||||
|
export type BatteriesPageSummary = {
|
||||||
|
total?: number
|
||||||
|
lowPower?: number
|
||||||
|
charging?: number
|
||||||
|
}
|
||||||
|
|
||||||
export type BatteryPrediction = {
|
export type BatteryPrediction = {
|
||||||
mac: string
|
mac: string
|
||||||
nowSoh: number
|
nowSoh: number
|
||||||
@@ -194,39 +202,43 @@ export function toBatteryInfo(row: BatteryInfoSourceRow): BatteryInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createBatteriesResponse(items: BatteryInfo[], now = new Date()): BatteriesResponse {
|
export function createBatteriesResponse(
|
||||||
|
items: BatteryInfo[],
|
||||||
|
now = new Date(),
|
||||||
|
summary: BatteriesPageSummary = {},
|
||||||
|
nextCursor: string | null = null,
|
||||||
|
): BatteriesResponse {
|
||||||
return {
|
return {
|
||||||
updatedAt: now.toISOString(),
|
updatedAt: now.toISOString(),
|
||||||
total: items.length,
|
total: summary.total ?? items.length,
|
||||||
lowPower: items.filter((item) => item.isLowPower).length,
|
lowPower: summary.lowPower ?? items.filter((item) => item.isLowPower).length,
|
||||||
charging: items.filter((item) => item.powerStatus === 1).length,
|
charging: summary.charging ?? items.filter((item) => item.powerStatus === 1).length,
|
||||||
items,
|
items,
|
||||||
|
nextCursor,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toFleetUnit(item: BatteryInfo, index: number, prediction?: BatteryPrediction): FleetUnit {
|
function toFleetUnit(item: BatteryInfo, index: number, prediction?: BatteryPrediction): FleetUnit {
|
||||||
const soh = prediction?.nowSoh ?? item.power
|
const hasPrediction = Boolean(prediction)
|
||||||
const status = prediction ? getDeviceStatusByRisk(prediction) : getDeviceStatus(soh)
|
const soh = prediction ? round1(clamp(prediction.nowSoh, 0, 100)) : 0
|
||||||
|
const status = prediction ? getDeviceStatusByRisk(prediction) : item.isLowPower || item.power <= 20 ? '关注' : '健康'
|
||||||
const riskFactors: string[] = []
|
const riskFactors: string[] = []
|
||||||
|
|
||||||
if (item.isLowPower || item.power <= 20) riskFactors.push('低电量')
|
if (item.isLowPower || item.power <= 20) riskFactors.push('低电量')
|
||||||
if (item.powerStatus === 1) riskFactors.push('充电中')
|
if (item.powerStatus === 1) riskFactors.push('充电中')
|
||||||
if (status === '预警') riskFactors.push('衰减加速')
|
if (!hasPrediction) riskFactors.push('SoH预测不可用')
|
||||||
|
if (prediction && status === '预警') riskFactors.push('衰减加速')
|
||||||
if (item.remark?.includes('v3.7')) riskFactors.push('旧固件')
|
if (item.remark?.includes('v3.7')) riskFactors.push('旧固件')
|
||||||
if (prediction?.riskLevel) riskFactors.push(`AI风险:${prediction.riskLevel}`)
|
if (prediction?.riskLevel) riskFactors.push(`AI风险:${prediction.riskLevel}`)
|
||||||
|
|
||||||
const thermalPressure = index % 3
|
const thermalPressure = index % 3
|
||||||
const soh30d = prediction
|
const soh30d = prediction ? round1(clamp(prediction.monthSoh, 0, 100)) : 0
|
||||||
? round1(clamp(prediction.monthSoh, 0, 100))
|
const soh90d = prediction ? round1(clamp(prediction.trmonthSoh, 0, 100)) : 0
|
||||||
: round1(clamp(soh - 0.8 - thermalPressure * 0.25, 0, 100))
|
const soh60d = prediction ? round1((soh30d + soh90d) / 2) : 0
|
||||||
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 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 chargeEfficiency = round1(clamp(91 + item.power / 12 - riskFactors.length * 1.8, 80, 98))
|
||||||
const riskScore = Math.round(
|
const riskScore = Math.round(
|
||||||
clamp(prediction?.riskScore ?? 12 + (100 - soh) * 1.45 + riskFactors.length * 8 + thermalPressure * 4, 8, 96),
|
clamp(prediction?.riskScore ?? 18 + riskFactors.length * 10 + thermalPressure * 4 + (item.isLowPower ? 18 : 0), 8, 96),
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -235,6 +247,7 @@ function toFleetUnit(item: BatteryInfo, index: number, prediction?: BatteryPredi
|
|||||||
firmware: prediction?.modelName ?? item.remark ?? 'unknown',
|
firmware: prediction?.modelName ?? item.remark ?? 'unknown',
|
||||||
cycles: 120 + index * 17 + Math.round((100 - soh) * 2.2),
|
cycles: 120 + index * 17 + Math.round((100 - soh) * 2.2),
|
||||||
soh,
|
soh,
|
||||||
|
sohSource: prediction ? 'prediction' : 'unavailable',
|
||||||
soh30d,
|
soh30d,
|
||||||
soh60d,
|
soh60d,
|
||||||
soh90d,
|
soh90d,
|
||||||
|
|||||||
@@ -4,10 +4,23 @@ import { batteriesResponseSchema, dashboardSnapshotSchema } from '@/domain/batte
|
|||||||
|
|
||||||
export const dashboard = oc.input(z.void()).output(dashboardSnapshotSchema)
|
export const dashboard = oc.input(z.void()).output(dashboardSnapshotSchema)
|
||||||
|
|
||||||
|
const batteryListInputSchema = z.object({
|
||||||
|
pageSize: z.number().int().min(1).max(100).default(50),
|
||||||
|
cursor: z.string().min(1).optional(),
|
||||||
|
search: z.string().trim().min(1).max(100).optional(),
|
||||||
|
lowPower: z.boolean().optional(),
|
||||||
|
powerStatus: z.union([z.literal(0), z.literal(1), z.literal(2)]).optional(),
|
||||||
|
sort: z.enum(['createdAtDesc', 'createdAtAsc', 'powerDesc', 'powerAsc']).default('createdAtDesc'),
|
||||||
|
})
|
||||||
|
|
||||||
export const batteries = oc
|
export const batteries = oc
|
||||||
|
.input(batteryListInputSchema)
|
||||||
|
.output(batteriesResponseSchema)
|
||||||
|
|
||||||
|
export const history = oc
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
mac: z.string().min(1).optional(),
|
mac: z.string().min(1),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.output(batteriesResponseSchema)
|
.output(batteriesResponseSchema)
|
||||||
|
|||||||
@@ -1,19 +1,44 @@
|
|||||||
import { createBatteriesResponse, createDashboardSnapshot } from '@/domain/battery'
|
import { createBatteriesResponse, createDashboardSnapshot } from '@/domain/battery'
|
||||||
import { os } from '@/server/api/server'
|
import { os } from '@/server/api/server'
|
||||||
import { getBatteryHistory, getBatteryPredictionHistory, getLatestBatteryPerDevice } from '@/server/battery/mysql'
|
import {
|
||||||
|
getBatteryHistory,
|
||||||
|
getBatteryPredictionHistories,
|
||||||
|
getLatestBatteryPage,
|
||||||
|
getLatestBatteryPerDevice,
|
||||||
|
} from '@/server/battery/mysql'
|
||||||
import { isPredictionEnabled, predictSoh } from '@/server/prediction/client'
|
import { isPredictionEnabled, predictSoh } from '@/server/prediction/client'
|
||||||
|
|
||||||
|
const dashboardPredictionConcurrency = 5
|
||||||
|
|
||||||
|
async function mapWithConcurrency<T, R>(items: T[], concurrency: number, handler: (item: T) => Promise<R>): Promise<R[]> {
|
||||||
|
const results: R[] = []
|
||||||
|
let nextIndex = 0
|
||||||
|
|
||||||
|
async function worker() {
|
||||||
|
while (nextIndex < items.length) {
|
||||||
|
const index = nextIndex
|
||||||
|
nextIndex += 1
|
||||||
|
const item = items[index]
|
||||||
|
if (item !== undefined) results[index] = await handler(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker))
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
export const dashboard = os.battery.dashboard.handler(async () => {
|
export const dashboard = os.battery.dashboard.handler(async () => {
|
||||||
const items = await getLatestBatteryPerDevice()
|
const items = await getLatestBatteryPerDevice()
|
||||||
|
const predictionHistories = isPredictionEnabled()
|
||||||
|
? await getBatteryPredictionHistories(items.map((item) => item.mac))
|
||||||
|
: new Map()
|
||||||
const predictionEntries = isPredictionEnabled()
|
const predictionEntries = isPredictionEnabled()
|
||||||
? await Promise.all(
|
? await mapWithConcurrency(items, dashboardPredictionConcurrency, async (item) => {
|
||||||
items.map(async (item) => {
|
const prediction = await predictSoh(item, predictionHistories.get(item.mac) ?? [])
|
||||||
const history = await getBatteryPredictionHistory(item.mac)
|
|
||||||
const prediction = await predictSoh(item, history)
|
|
||||||
|
|
||||||
return prediction ? ([item.mac, prediction] as const) : null
|
return prediction ? ([item.mac, prediction] as const) : null
|
||||||
}),
|
})
|
||||||
)
|
|
||||||
: []
|
: []
|
||||||
const predictions = new Map(predictionEntries.filter((entry) => entry !== null))
|
const predictions = new Map(predictionEntries.filter((entry) => entry !== null))
|
||||||
|
|
||||||
@@ -21,7 +46,18 @@ export const dashboard = os.battery.dashboard.handler(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const batteries = os.battery.batteries.handler(async ({ input }) => {
|
export const batteries = os.battery.batteries.handler(async ({ input }) => {
|
||||||
const items = input.mac ? await getBatteryHistory(input.mac) : await getLatestBatteryPerDevice()
|
const page = await getLatestBatteryPage(input)
|
||||||
|
|
||||||
|
return createBatteriesResponse(
|
||||||
|
page.items,
|
||||||
|
new Date(),
|
||||||
|
{ total: page.total, lowPower: page.lowPower, charging: page.charging },
|
||||||
|
page.nextCursor,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const history = os.battery.history.handler(async ({ input }) => {
|
||||||
|
const items = await getBatteryHistory(input.mac)
|
||||||
|
|
||||||
return createBatteriesResponse(items)
|
return createBatteriesResponse(items)
|
||||||
})
|
})
|
||||||
|
|||||||
+238
-12
@@ -5,8 +5,40 @@ import { env } from '@/env'
|
|||||||
|
|
||||||
const historyLimit = 500
|
const historyLimit = 500
|
||||||
const predictionHistoryLimit = 10
|
const predictionHistoryLimit = 10
|
||||||
|
const dashboardLatestLimit = 100
|
||||||
|
|
||||||
type BatteryInfoMysqlRow = RowDataPacket & BatteryInfoSourceRow
|
type BatteryInfoMysqlRow = RowDataPacket & BatteryInfoSourceRow
|
||||||
|
type CountMysqlRow = RowDataPacket & {
|
||||||
|
total: number
|
||||||
|
lowPower: number | string | null
|
||||||
|
charging: number | string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BatteryListSort = 'createdAtDesc' | 'createdAtAsc' | 'powerDesc' | 'powerAsc'
|
||||||
|
|
||||||
|
export type LatestBatteryPageInput = {
|
||||||
|
pageSize: number
|
||||||
|
cursor?: string
|
||||||
|
search?: string
|
||||||
|
lowPower?: boolean
|
||||||
|
powerStatus?: 0 | 1 | 2
|
||||||
|
sort?: BatteryListSort
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LatestBatteryPage = {
|
||||||
|
items: BatteryInfo[]
|
||||||
|
nextCursor: string | null
|
||||||
|
total?: number
|
||||||
|
lowPower?: number
|
||||||
|
charging?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type PageCursor = {
|
||||||
|
sort: BatteryListSort
|
||||||
|
createTime: string
|
||||||
|
id: number
|
||||||
|
power?: number
|
||||||
|
}
|
||||||
|
|
||||||
let pool: Pool | undefined
|
let pool: Pool | undefined
|
||||||
|
|
||||||
@@ -41,6 +73,130 @@ const sourceColumns = `
|
|||||||
remark
|
remark
|
||||||
`
|
`
|
||||||
|
|
||||||
|
const normalizedColumns = `
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
mac,
|
||||||
|
devModel,
|
||||||
|
devName,
|
||||||
|
isLowPower,
|
||||||
|
powerStatus,
|
||||||
|
power,
|
||||||
|
createTime,
|
||||||
|
remark
|
||||||
|
`
|
||||||
|
|
||||||
|
const latestRecordPredicate = `
|
||||||
|
NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM ls_battery_info AS newer_record
|
||||||
|
WHERE newer_record.mac = current_record.mac
|
||||||
|
AND (
|
||||||
|
newer_record.create_time > current_record.create_time
|
||||||
|
OR (newer_record.create_time = current_record.create_time AND newer_record.id > current_record.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
`
|
||||||
|
|
||||||
|
const orderByBySort: Record<BatteryListSort, string> = {
|
||||||
|
createdAtDesc: 'current_record.create_time DESC, current_record.id DESC',
|
||||||
|
createdAtAsc: 'current_record.create_time ASC, current_record.id ASC',
|
||||||
|
powerDesc: 'current_record.power DESC, current_record.create_time DESC, current_record.id DESC',
|
||||||
|
powerAsc: 'current_record.power ASC, current_record.create_time DESC, current_record.id DESC',
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNumber(value: number | string | null | undefined) {
|
||||||
|
if (value === null || value === undefined) return 0
|
||||||
|
return Number(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeCursor(item: BatteryInfo, sort: BatteryListSort) {
|
||||||
|
const cursor: PageCursor = {
|
||||||
|
sort,
|
||||||
|
createTime: item.createTime,
|
||||||
|
id: item.id,
|
||||||
|
power: sort === 'powerAsc' || sort === 'powerDesc' ? item.power : undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
return Buffer.from(JSON.stringify(cursor)).toString('base64url')
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeCursor(value: string | undefined, sort: BatteryListSort): PageCursor | null {
|
||||||
|
if (!value) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = JSON.parse(Buffer.from(value, 'base64url').toString('utf8')) as Partial<PageCursor>
|
||||||
|
if (decoded.sort !== sort || typeof decoded.createTime !== 'string' || typeof decoded.id !== 'number') return null
|
||||||
|
if ((sort === 'powerAsc' || sort === 'powerDesc') && typeof decoded.power !== 'number') return null
|
||||||
|
|
||||||
|
return decoded as PageCursor
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeLike(value: string) {
|
||||||
|
return value.replace(/[\\%_]/g, (match) => `\\${match}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCursorDateTime(value: string) {
|
||||||
|
return value.includes('T') ? value.slice(0, 19).replace('T', ' ') : value
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLatestWhere(input: LatestBatteryPageInput, cursor: PageCursor | null) {
|
||||||
|
const clauses = [latestRecordPredicate]
|
||||||
|
const params: Record<string, string | number> = {}
|
||||||
|
|
||||||
|
if (input.search) {
|
||||||
|
clauses.push(
|
||||||
|
'(current_record.mac LIKE :search ESCAPE \'\\\\\' OR current_record.dev_name LIKE :search ESCAPE \'\\\\\' OR current_record.dev_model LIKE :search ESCAPE \'\\\\\')',
|
||||||
|
)
|
||||||
|
params.search = `%${escapeLike(input.search)}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.lowPower !== undefined) {
|
||||||
|
clauses.push('current_record.is_low_power = :lowPower')
|
||||||
|
params.lowPower = input.lowPower ? 'true' : 'false'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.powerStatus !== undefined) {
|
||||||
|
clauses.push('current_record.power_status = :powerStatus')
|
||||||
|
params.powerStatus = input.powerStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cursor) {
|
||||||
|
params.cursorCreateTime = normalizeCursorDateTime(cursor.createTime)
|
||||||
|
params.cursorId = cursor.id
|
||||||
|
|
||||||
|
switch (input.sort ?? 'createdAtDesc') {
|
||||||
|
case 'createdAtAsc':
|
||||||
|
clauses.push(
|
||||||
|
'(current_record.create_time > :cursorCreateTime OR (current_record.create_time = :cursorCreateTime AND current_record.id > :cursorId))',
|
||||||
|
)
|
||||||
|
break
|
||||||
|
case 'powerDesc':
|
||||||
|
params.cursorPower = cursor.power ?? 0
|
||||||
|
clauses.push(
|
||||||
|
'(current_record.power < :cursorPower OR (current_record.power = :cursorPower AND (current_record.create_time < :cursorCreateTime OR (current_record.create_time = :cursorCreateTime AND current_record.id < :cursorId))))',
|
||||||
|
)
|
||||||
|
break
|
||||||
|
case 'powerAsc':
|
||||||
|
params.cursorPower = cursor.power ?? 0
|
||||||
|
clauses.push(
|
||||||
|
'(current_record.power > :cursorPower OR (current_record.power = :cursorPower AND (current_record.create_time < :cursorCreateTime OR (current_record.create_time = :cursorCreateTime AND current_record.id < :cursorId))))',
|
||||||
|
)
|
||||||
|
break
|
||||||
|
case 'createdAtDesc':
|
||||||
|
clauses.push(
|
||||||
|
'(current_record.create_time < :cursorCreateTime OR (current_record.create_time = :cursorCreateTime AND current_record.id < :cursorId))',
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { whereSql: clauses.map((clause) => `(${clause})`).join(' AND '), params }
|
||||||
|
}
|
||||||
|
|
||||||
export async function getBatteryHistory(mac: string): Promise<BatteryInfo[]> {
|
export async function getBatteryHistory(mac: string): Promise<BatteryInfo[]> {
|
||||||
const [rows] = await getBatteryPool().query<BatteryInfoMysqlRow[]>(
|
const [rows] = await getBatteryPool().query<BatteryInfoMysqlRow[]>(
|
||||||
`
|
`
|
||||||
@@ -71,21 +227,91 @@ export async function getBatteryPredictionHistory(mac: string): Promise<BatteryI
|
|||||||
return rows.map(toBatteryInfo).reverse()
|
return rows.map(toBatteryInfo).reverse()
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getLatestBatteryPerDevice(): Promise<BatteryInfo[]> {
|
export async function getBatteryPredictionHistories(macAddresses: string[]): Promise<Map<string, BatteryInfo[]>> {
|
||||||
const [rows] = await getBatteryPool().query<BatteryInfoMysqlRow[]>(`
|
if (macAddresses.length === 0) return new Map()
|
||||||
|
|
||||||
|
const params = Object.fromEntries(macAddresses.map((mac, index) => [`mac${index}`, mac]))
|
||||||
|
const placeholders = macAddresses.map((_, index) => `:mac${index}`).join(', ')
|
||||||
|
const [rows] = await getBatteryPool().query<BatteryInfoMysqlRow[]>(
|
||||||
|
`
|
||||||
|
SELECT ${normalizedColumns}
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
${sourceColumns},
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY mac ORDER BY create_time DESC, id DESC) AS history_rank
|
||||||
|
FROM ls_battery_info
|
||||||
|
WHERE mac IN (${placeholders})
|
||||||
|
) AS ranked_history
|
||||||
|
WHERE ranked_history.history_rank <= :limit
|
||||||
|
ORDER BY ranked_history.mac ASC, ranked_history.createTime ASC, ranked_history.id ASC
|
||||||
|
`,
|
||||||
|
{ ...params, limit: predictionHistoryLimit },
|
||||||
|
)
|
||||||
|
|
||||||
|
const histories = new Map<string, BatteryInfo[]>()
|
||||||
|
for (const item of rows.map(toBatteryInfo)) {
|
||||||
|
histories.set(item.mac, [...(histories.get(item.mac) ?? []), item])
|
||||||
|
}
|
||||||
|
|
||||||
|
return histories
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLatestBatteryPage(input: LatestBatteryPageInput): Promise<LatestBatteryPage> {
|
||||||
|
const sort = input.sort ?? 'createdAtDesc'
|
||||||
|
const pageSize = Math.min(Math.max(input.pageSize, 1), 100)
|
||||||
|
const cursor = decodeCursor(input.cursor, sort)
|
||||||
|
const { whereSql, params } = createLatestWhere({ ...input, sort, pageSize }, cursor)
|
||||||
|
const countWhere = createLatestWhere({ ...input, sort, pageSize }, null)
|
||||||
|
const queryLimit = pageSize + 1
|
||||||
|
|
||||||
|
const [rows] = await getBatteryPool().query<BatteryInfoMysqlRow[]>(
|
||||||
|
`
|
||||||
|
SELECT ${sourceColumns}
|
||||||
|
FROM ls_battery_info AS current_record
|
||||||
|
WHERE ${whereSql}
|
||||||
|
ORDER BY ${orderByBySort[sort]}
|
||||||
|
LIMIT :limit
|
||||||
|
`,
|
||||||
|
{ ...params, limit: queryLimit },
|
||||||
|
)
|
||||||
|
|
||||||
|
const pageItems = rows.slice(0, pageSize).map(toBatteryInfo)
|
||||||
|
const lastPageItem = pageItems.at(-1)
|
||||||
|
const nextCursor = rows.length > pageSize && lastPageItem ? encodeCursor(lastPageItem, sort) : null
|
||||||
|
|
||||||
|
const [countRows] = await getBatteryPool().query<CountMysqlRow[]>(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) AS total,
|
||||||
|
COALESCE(SUM(CASE WHEN current_record.is_low_power = 'true' THEN 1 ELSE 0 END), 0) AS lowPower,
|
||||||
|
COALESCE(SUM(CASE WHEN current_record.power_status = 1 THEN 1 ELSE 0 END), 0) AS charging
|
||||||
|
FROM ls_battery_info AS current_record
|
||||||
|
WHERE ${countWhere.whereSql}
|
||||||
|
`,
|
||||||
|
countWhere.params,
|
||||||
|
)
|
||||||
|
const counts = countRows[0]
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: pageItems,
|
||||||
|
nextCursor,
|
||||||
|
total: toNumber(counts?.total),
|
||||||
|
lowPower: toNumber(counts?.lowPower),
|
||||||
|
charging: toNumber(counts?.charging),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLatestBatteryPerDevice(limit = dashboardLatestLimit): Promise<BatteryInfo[]> {
|
||||||
|
const [rows] = await getBatteryPool().query<BatteryInfoMysqlRow[]>(
|
||||||
|
`
|
||||||
SELECT ${sourceColumns}
|
SELECT ${sourceColumns}
|
||||||
FROM ls_battery_info AS current_record
|
FROM ls_battery_info AS current_record
|
||||||
WHERE NOT EXISTS (
|
WHERE ${latestRecordPredicate}
|
||||||
SELECT 1
|
|
||||||
FROM ls_battery_info AS newer_record
|
|
||||||
WHERE newer_record.mac = current_record.mac
|
|
||||||
AND (
|
|
||||||
newer_record.create_time > current_record.create_time
|
|
||||||
OR (newer_record.create_time = current_record.create_time AND newer_record.id > current_record.id)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
ORDER BY current_record.create_time DESC, current_record.id DESC
|
ORDER BY current_record.create_time DESC, current_record.id DESC
|
||||||
`)
|
LIMIT :limit
|
||||||
|
`,
|
||||||
|
{ limit: Math.min(Math.max(limit, 1), dashboardLatestLimit) },
|
||||||
|
)
|
||||||
|
|
||||||
return rows.map(toBatteryInfo)
|
return rows.map(toBatteryInfo)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { LRUCache } from 'lru-cache'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import type { BatteryInfo, BatteryPrediction } from '@/domain/battery'
|
import type { BatteryInfo, BatteryPrediction } from '@/domain/battery'
|
||||||
import { env } from '@/env'
|
import { env } from '@/env'
|
||||||
@@ -60,13 +61,12 @@ const predictionResponseSchema = z.object({
|
|||||||
updated_at: z.string().nullable().optional(),
|
updated_at: z.string().nullable().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
type CacheEntry = {
|
|
||||||
expiresAt: number
|
|
||||||
value: SohPrediction
|
|
||||||
}
|
|
||||||
|
|
||||||
const logger = getLogger(['prediction'])
|
const logger = getLogger(['prediction'])
|
||||||
const cache = new Map<string, CacheEntry>()
|
const cache = new LRUCache<string, SohPrediction>({
|
||||||
|
max: 5_000,
|
||||||
|
ttl: env.SOH_PREDICTION_CACHE_TTL_SECONDS * 1000,
|
||||||
|
})
|
||||||
|
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
|
||||||
|
|
||||||
@@ -155,7 +155,22 @@ export async function predictSoh(battery: BatteryInfo, history: BatteryInfo[]):
|
|||||||
|
|
||||||
const cacheKey = createCacheKey(battery, history)
|
const cacheKey = createCacheKey(battery, history)
|
||||||
const cached = cache.get(cacheKey)
|
const cached = cache.get(cacheKey)
|
||||||
if (cached && cached.expiresAt > Date.now()) return cached.value
|
if (cached) return cached
|
||||||
|
const pendingRequest = inFlightRequests.get(cacheKey)
|
||||||
|
if (pendingRequest) return pendingRequest
|
||||||
|
|
||||||
|
const requestPromise = requestPrediction(cacheKey, battery, request)
|
||||||
|
inFlightRequests.set(cacheKey, requestPromise)
|
||||||
|
|
||||||
|
return requestPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestPrediction(
|
||||||
|
cacheKey: string,
|
||||||
|
battery: BatteryInfo,
|
||||||
|
request: PredictionRequest,
|
||||||
|
): Promise<SohPrediction | null> {
|
||||||
|
if (!env.SOH_PREDICTION_API_BASE_URL) return null
|
||||||
|
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
const timeout = setTimeout(() => controller.abort(), env.SOH_PREDICTION_TIMEOUT_MS)
|
const timeout = setTimeout(() => controller.abort(), env.SOH_PREDICTION_TIMEOUT_MS)
|
||||||
@@ -179,10 +194,7 @@ export async function predictSoh(battery: BatteryInfo, history: BatteryInfo[]):
|
|||||||
|
|
||||||
const json = await response.json()
|
const json = await response.json()
|
||||||
const prediction = normalizePrediction(predictionResponseSchema.parse(json))
|
const prediction = normalizePrediction(predictionResponseSchema.parse(json))
|
||||||
cache.set(cacheKey, {
|
cache.set(cacheKey, prediction)
|
||||||
expiresAt: Date.now() + env.SOH_PREDICTION_CACHE_TTL_SECONDS * 1000,
|
|
||||||
value: prediction,
|
|
||||||
})
|
|
||||||
|
|
||||||
return prediction
|
return prediction
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -190,9 +202,11 @@ export async function predictSoh(battery: BatteryInfo, history: BatteryInfo[]):
|
|||||||
return null
|
return null
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timeout)
|
clearTimeout(timeout)
|
||||||
|
inFlightRequests.delete(cacheKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearPredictionCache() {
|
export function clearPredictionCache() {
|
||||||
cache.clear()
|
cache.clear()
|
||||||
|
inFlightRequests.clear()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user