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