feat(material): Ypt 计算与全库三分位健康档工具 + 单测

This commit is contained in:
zty 2026-07-10 02:26:03 -04:00
parent 5631f2b1a0
commit 7027a7748f
4 changed files with 6080 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@ -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"
}
}

View File

@ -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) }
})
}

View File

@ -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')
})
})