feat(material): Ypt 计算与全库三分位健康档工具 + 单测
This commit is contained in:
parent
5631f2b1a0
commit
7027a7748f
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,63 @@
|
|||
{
|
||||
"name": "iapip-svr",
|
||||
"version": "1.0.0",
|
||||
"description": "Indoor Air Pollutant Index Prediction - Server",
|
||||
"repository": "https://gitlab.com/iapips/iapip-svr.git",
|
||||
"private": true,
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start:dev": "tsc && dotenv -e .env.development -v NODE_ENV=development -- node -r source-map-support/register .",
|
||||
"start:prod": "tsc && dotenv -e .env -v NODE_ENV=production -- node -r source-map-support/register .",
|
||||
"pm2start:dev": "pm2 start ecosystem.config.js --env development & pm2 save",
|
||||
"pm2stop:dev": "pm2 stop ecosystem.config.js --env development & pm2 save",
|
||||
"pm2start:prod": "pm2 start ecosystem.config.js --env production & pm2 save",
|
||||
"pm2stop:prod": "pm2 stop ecosystem.config.js --env production & pm2 save",
|
||||
"dbpush:dev": "dotenv -e .env.development -v NODE_ENV=development -- npx prisma db push",
|
||||
"dbpush:prod": "dotenv -e .env -v NODE_ENV=production -- npx prisma db push",
|
||||
"dbseed:dev": "npx dotenv -e .env.development -- ts-node prisma/seed.ts",
|
||||
"dbseed:prod": "npx dotenv -e .env -- ts-node prisma/seed.ts",
|
||||
"dbrepo": "npx prisma generate",
|
||||
"lint": "eslint . --ext .ts",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"author": "harvey_7",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@koa/cors": "^4.0.0",
|
||||
"@prisma/client": "^5.6.0",
|
||||
"ajv": "^8.12.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"crypto-js": "^4.2.0",
|
||||
"exceljs": "^4.4.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jspdf": "^2.5.1",
|
||||
"jspdf-autotable": "^3.8.2",
|
||||
"koa": "^2.14.2",
|
||||
"koa-body": "^6.0.1",
|
||||
"koa-router": "^12.0.1",
|
||||
"log4js": "^6.9.1",
|
||||
"moment": "^2.29.4",
|
||||
"node-schedule": "^2.1.1",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"source-map-support": "^0.5.21"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jsonwebtoken": "^9.0.5",
|
||||
"@types/koa": "^2.13.11",
|
||||
"@types/koa__cors": "^4.0.3",
|
||||
"@types/koa-logger": "^3.1.5",
|
||||
"@types/koa-router": "^7.4.7",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/node-schedule": "^2.1.7",
|
||||
"@typescript-eslint/eslint-plugin": "^6.10.0",
|
||||
"@typescript-eslint/parser": "^6.10.0",
|
||||
"dotenv-cli": "^7.3.0",
|
||||
"eslint": "^8.53.0",
|
||||
"prisma": "^5.6.0",
|
||||
"ts-node": "^10.9.1",
|
||||
"tslib": "^2.6.2",
|
||||
"typescript": "^5.2.2",
|
||||
"vitest": "^1.6.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
export type BeAreaSource = {
|
||||
methanal_be_area?: number | null
|
||||
tvoc_be_area?: number | null
|
||||
benzene_be_area?: number | null
|
||||
toluene_be_area?: number | null
|
||||
p_xylene_be_area?: number | null
|
||||
}
|
||||
|
||||
export type Tier = 'A' | 'B' | 'C'
|
||||
|
||||
// Ypt 权重(固定,勿改)
|
||||
export const POLL_WEIGHTS = {
|
||||
methanal: 1.8,
|
||||
tvoc: 0.25,
|
||||
benzene: 1,
|
||||
toluene: 0.45,
|
||||
p_xylene: 0.38
|
||||
} as const
|
||||
|
||||
// 综合 Ypt;任一 be_area 为 null/undefined 返回 null
|
||||
export function computeYpt(m: BeAreaSource): number | null {
|
||||
const vals = [
|
||||
[m.methanal_be_area, POLL_WEIGHTS.methanal],
|
||||
[m.tvoc_be_area, POLL_WEIGHTS.tvoc],
|
||||
[m.benzene_be_area, POLL_WEIGHTS.benzene],
|
||||
[m.toluene_be_area, POLL_WEIGHTS.toluene],
|
||||
[m.p_xylene_be_area, POLL_WEIGHTS.p_xylene]
|
||||
] as [number | null | undefined, number][]
|
||||
let sum = 0
|
||||
for (const [v, w] of vals) {
|
||||
if (v === null || v === undefined) return null
|
||||
sum += v * w
|
||||
}
|
||||
return sum
|
||||
}
|
||||
|
||||
// 用全库材料建三分位分类器;缺值材料不参与
|
||||
export function buildTierClassifier(all: BeAreaSource[]): (ypt: number | null) => Tier | null {
|
||||
const sorted = all
|
||||
.map(computeYpt)
|
||||
.filter((v): v is number => v !== null)
|
||||
.sort((a, b) => a - b)
|
||||
const n = sorted.length
|
||||
if (n === 0) {
|
||||
return () => null
|
||||
}
|
||||
// 边界索引:前 ceil(n/3) 为 A,接着到 ceil(2n/3) 为 B,其余 C
|
||||
const aEndIdx = Math.ceil(n / 3) // [0, aEndIdx) => A
|
||||
const bEndIdx = Math.ceil((2 * n) / 3) // [aEndIdx, bEndIdx) => B
|
||||
// 用边界处的 Ypt 值作阈值,保证并列同档
|
||||
const aMax = sorted[Math.min(aEndIdx, n) - 1]
|
||||
const bMax = sorted[Math.min(bEndIdx, n) - 1]
|
||||
return (ypt: number | null): Tier | null => {
|
||||
if (ypt === null) return null
|
||||
if (ypt <= aMax) return 'A'
|
||||
if (ypt <= bMax) return 'B'
|
||||
return 'C'
|
||||
}
|
||||
}
|
||||
|
||||
// 给材料数组挂上 ypt 与 health_tier
|
||||
export function annotate<T extends BeAreaSource>(
|
||||
materials: T[],
|
||||
classify: (ypt: number | null) => Tier | null
|
||||
): (T & { ypt: number | null; health_tier: Tier | null })[] {
|
||||
return materials.map(m => {
|
||||
const ypt = computeYpt(m)
|
||||
return { ...m, ypt, health_tier: classify(ypt) }
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { computeYpt, buildTierClassifier, annotate } from '../src/common/material-ranking'
|
||||
|
||||
const M = (methanal: number|null, tvoc: number|null, benzene: number|null, toluene: number|null, pxylene: number|null) => ({
|
||||
methanal_be_area: methanal, tvoc_be_area: tvoc, benzene_be_area: benzene, toluene_be_area: toluene, p_xylene_be_area: pxylene
|
||||
})
|
||||
|
||||
describe('computeYpt', () => {
|
||||
it('按权重求和', () => {
|
||||
// 1*1.8 + 2*0.25 + 3*1 + 4*0.45 + 5*0.38 = 1.8+0.5+3+1.8+1.9 = 9
|
||||
expect(computeYpt(M(1, 2, 3, 4, 5))).toBeCloseTo(9, 6)
|
||||
})
|
||||
it('任一缺值返回 null', () => {
|
||||
expect(computeYpt(M(1, null, 3, 4, 5))).toBeNull()
|
||||
expect(computeYpt(M(1, 2, 3, 4, undefined as never))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildTierClassifier', () => {
|
||||
it('全库三分位: 6 个材料 -> 2A 2B 2C', () => {
|
||||
// ypt 分别为 1..6(每个只用 methanal 权重 1.8,但相对顺序即 methanal 顺序)
|
||||
const all = [1, 2, 3, 4, 5, 6].map(v => M(v, 0, 0, 0, 0))
|
||||
const classify = buildTierClassifier(all)
|
||||
const ypts = all.map(computeYpt)
|
||||
expect(ypts.map(classify)).toEqual(['A', 'A', 'B', 'B', 'C', 'C'])
|
||||
})
|
||||
it('缺值材料不参与分位、classify(null)=null', () => {
|
||||
const all = [M(1,0,0,0,0), M(2,0,0,0,0), M(3,0,0,0,0), M(1,null,0,0,0)]
|
||||
const classify = buildTierClassifier(all)
|
||||
expect(classify(null)).toBeNull()
|
||||
})
|
||||
it('相同 Ypt 落同档(并列不强拆)', () => {
|
||||
const all = [M(1,0,0,0,0), M(1,0,0,0,0), M(1,0,0,0,0)]
|
||||
const classify = buildTierClassifier(all)
|
||||
const tiers = all.map(m => classify(computeYpt(m)))
|
||||
expect(new Set(tiers).size).toBe(1) // 全相等 -> 同一档
|
||||
})
|
||||
it('小 n=1 -> 该材料为 A', () => {
|
||||
const all = [M(5,0,0,0,0)]
|
||||
const classify = buildTierClassifier(all)
|
||||
expect(classify(computeYpt(all[0]))).toBe('A')
|
||||
})
|
||||
})
|
||||
|
||||
describe('annotate', () => {
|
||||
it('挂上 ypt 与 health_tier', () => {
|
||||
const all = [M(1,0,0,0,0), M(2,0,0,0,0), M(3,0,0,0,0)]
|
||||
const classify = buildTierClassifier(all)
|
||||
const out = annotate(all, classify)
|
||||
expect(out[0].health_tier).toBe('A')
|
||||
expect(out[0].ypt).toBeCloseTo(1.8, 6)
|
||||
expect(out[2].health_tier).toBe('C')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue