Browse Source

feat: add model assessment report functionality

- Implemented the assessment report feature in the model training module.
- Added API endpoints for initializing, evaluating, and saving assessment reports.
- Created a new Vue component for displaying assessment reports with detailed results.
- Enhanced data handling for model information and assessment results, including normalization and error handling.
- Introduced user interface elements for managing assessment parameters and displaying results.
- Added navigation to the assessment report from the model training view.
pull/60/head
CJL6015 1 month ago
parent
commit
b7a2ea4645
  1. 58
      src/api/alert/model/assessReport.ts
  2. 123
      src/api/alert/model/model/assessReportModel.ts
  3. 22
      src/router/routes/index.ts
  4. 29
      src/router/routes/modules/model-assess.ts
  5. 846
      src/views/model/AssessReport.vue
  6. 52
      src/views/model/train/data.tsx
  7. 122
      src/views/model/train/index.vue

58
src/api/alert/model/assessReport.ts

@ -0,0 +1,58 @@
import type {
AssessCleanSummary,
AssessEvaluateRequest,
AssessEvaluateResult,
AssessInitQuery,
AssessInitResponse,
AssessReportDetail,
AssessReportSavePayload,
} from './model/assessReportModel'
import { defHttp } from '@/utils/http/axios'
enum Api {
INIT = '/alert/assess-report/init',
REPORT = '/alert/assess-report/report',
EVALUATE = '/alert/assess-report/evaluate',
SAVE = '/alert/assess-report',
CLEAN_SUMMARY = '/alert/assess-report/clean/summary',
CLEAN_DEAD = '/alert/assess-report/clean/dead',
CLEAN_CONDITION_RATE = '/alert/assess-report/clean/condition-rate',
CLEAN_ENVELOPE_RATE = '/alert/assess-report/clean/envelope-rate',
CONDITION = '/alert/assess-report/condition',
}
export function fetchAssessInit(params: AssessInitQuery) {
return defHttp.get<AssessInitResponse>({ url: Api.INIT, params })
}
export function fetchReportDetail(id: number | string) {
return defHttp.get<AssessReportDetail>({ url: `${Api.REPORT}/${id}` })
}
export function evaluateAssess(data: AssessEvaluateRequest) {
return defHttp.post<AssessEvaluateResult>({ url: Api.EVALUATE, data })
}
export function saveAssessReport(data: AssessReportSavePayload) {
return defHttp.post<boolean>({ url: Api.SAVE, data })
}
export function fetchCleanSummary(params: { modelId: number | string; time: string; version: string }) {
return defHttp.get<AssessCleanSummary>({ url: Api.CLEAN_SUMMARY, params })
}
export function fetchDeadCleanDetail(params: { pointIds: string; time: string }) {
return defHttp.get<{ rate: number[]; result: string[] }>({ url: Api.CLEAN_DEAD, params })
}
export function fetchConditionRate(params: { condition: string; st: string; et: string }) {
return defHttp.get<number[]>({ url: Api.CLEAN_CONDITION_RATE, params })
}
export function fetchEnvelopeRate(params: { condition: string; st: string; et: string }) {
return defHttp.get<number[]>({ url: Api.CLEAN_ENVELOPE_RATE, params })
}
export function fetchAssessCondition(algorithm: string) {
return defHttp.get<{ modelScore?: number; condition?: string }>({ url: Api.CONDITION, params: { algorithm } })
}

123
src/api/alert/model/model/assessReportModel.ts

@ -0,0 +1,123 @@
export interface AssessInitQuery {
modelId: number | string
version?: string
reportId?: number | string
sampleRange?: string[]
}
export interface ModeRow {
name: string
content: string | number
}
export interface AssessPointRow {
description: string
pointId: string
alarm?: boolean
lock?: boolean
dead?: boolean
limit?: boolean
lower?: number
upper?: number
unit?: string
tMin?: number
tMax?: number
amplitude?: number | string
index?: number
}
export interface AssessRow extends AssessPointRow {
fdr?: string
far?: string
recon?: string
}
export interface AssessResultRow {
name: string
content: string | number
}
export interface AssessInitResponse {
modelName: string
modelInfo: Record<string, any>
modeRows: ModeRow[]
pointRows: AssessPointRow[]
assessRows: AssessRow[]
assessResult?: AssessResultRow[]
bottomScore?: number
coverage?: number
identifier?: string
timestamp?: string
}
export interface AssessEvaluateRowResult {
pointId: string
index?: number
fdrForward: number
fdrReverse: number
farForward: number
farReverse: number
reconForward?: number
reconReverse?: number
}
export interface AssessEvaluateRequest {
modelId: number | string
version: string
assessRows: AssessRow[]
pointRows: AssessPointRow[]
timeRange?: string[]
assessMode?: 'full' | 'single'
alg?: string
modelInfo?: string
rawJson?: string
}
export interface AssessEvaluateResult {
points: AssessEvaluateRowResult[]
durationSeconds?: number
coverage?: number
cleanDurationSeconds?: number
}
export interface AssessReportSavePayload {
modelId: number | string
version: string
report: Record<string, any>
score: number
coverage: number
valid: boolean
identifier: string
verifier?: string
timestamp: string
conditionName?: string
}
export interface AssessReportDetail {
id: number
modelName: string
report: {
pointRows: AssessPointRow[]
modeRows: ModeRow[]
assessRows: AssessRow[]
assessResult: AssessResultRow[]
identifier?: string
time?: string
}
}
export interface AssessCleanSummaryItem {
type: string
orgNum?: number
sampleNum?: number
sampleRate?: number
message?: string
}
export interface AssessCleanSummary {
summary: AssessCleanSummaryItem[]
deadPoints?: AssessPointRow[]
conditionClauses?: string[]
envelopeClauses?: string[]
timeRange?: string[]
}

22
src/router/routes/index.ts

@ -118,6 +118,28 @@ export const basicRoutes = [
RootRoute,
ProfileRoute,
CodegenRoute,
{
path: '/model/assess-report/:id?',
name: 'AssessReportBasic',
component: LAYOUT,
meta: {
title: '评估报告',
hideMenu: true,
hideBreadcrumb: true,
},
children: [
{
path: '',
name: 'AssessReport',
component: () => import('@/views/model/AssessReport.vue'),
meta: {
title: '评估报告',
hideMenu: true,
currentActiveMenu: '/model',
},
},
],
},
REDIRECT_ROUTE,
PAGE_NOT_FOUND_ROUTE,
]

29
src/router/routes/modules/model-assess.ts

@ -0,0 +1,29 @@
import type { AppRouteModule } from '@/router/types'
import { LAYOUT } from '@/router/constant'
const assessReport: AppRouteModule = {
path: '/model',
name: 'ModelAssess',
component: LAYOUT,
redirect: '/model/assess-report',
meta: {
orderNo: 25,
icon: 'ant-design:file-protect-outlined',
title: '模型评估',
},
children: [
{
path: 'assess-report/:id?',
name: 'AssessReport',
component: () => import('@/views/model/AssessReport.vue'),
meta: {
title: '评估报告',
currentActiveMenu: '/model',
hideMenu: true,
hideBreadcrumb: true,
},
},
],
}
export default assessReport

846
src/views/model/AssessReport.vue

@ -0,0 +1,846 @@
<script lang="ts">
import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs'
import { computed, defineComponent, h, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
Button,
Card,
Col,
Descriptions,
Form,
Input,
InputNumber,
Modal,
RangePicker,
Row,
Space,
Switch,
Table,
Tag,
} from 'ant-design-vue'
import type { ColumnProps } from 'ant-design-vue/es/table'
import {
evaluateAssess,
fetchAssessCondition,
fetchCleanSummary,
fetchReportDetail,
saveAssessReport,
} from '@/api/alert/model/assessReport'
import type {
AssessCleanSummary,
AssessPointRow,
AssessResultRow,
AssessRow,
ModeRow,
} from '@/api/alert/model/model/assessReportModel'
import { PageWrapper } from '@/components/Page'
import { useMessage } from '@/hooks/web/useMessage'
import { useUserStore } from '@/store/modules/user'
import { modelInfoApi } from '@/api/alert/model/models'
type RangeValue = [Dayjs, Dayjs]
function defaultAssessResult(): AssessResultRow[] {
return [
{ name: '得分', content: '--' },
{ name: '工况覆盖率', content: '--' },
{ name: '计算时长(s)', content: '--' },
{ name: '是否投入运用', content: '--' },
]
}
export default defineComponent({
name: 'AssessReport',
components: {
PageWrapper,
ACard: Card,
AForm: Form,
AFormItem: Form.Item,
AInput: Input,
AInputNumber: InputNumber,
AButton: Button,
ASpace: Space,
ATable: Table,
ASwitch: Switch,
ADescriptions: Descriptions,
ADescriptionsItem: Descriptions.Item,
ARangePicker: RangePicker,
ATag: Tag,
ARow: Row,
ACol: Col,
AModal: Modal,
},
setup() {
const userStore = useUserStore()
const route = useRoute()
const router = useRouter()
const { createMessage } = useMessage()
const modelId = computed(() => route.params.id || route.query.id)
const reportId = computed(() => route.query.reportId)
const algorithm = computed(() => (route.query.algorithm as string) || '')
const loading = ref(false)
const evaluateLoading = ref(false)
const submitLoading = ref(false)
const modelName = ref('')
const bottomScore = ref<number | undefined>()
const coverage = ref<number | undefined>()
const identifier = ref<string>(
userStore.getUserInfo?.username || userStore.getUserInfo?.nickName || userStore.getUserInfo?.userName || '',
)
const timestamp = ref<string>('')
const baseInfo = ref<Record<string, any>>({})
const modeRows = ref<ModeRow[]>([])
const pointRows = ref<AssessPointRow[]>([])
const assessRows = ref<AssessRow[]>([])
const assessResult = ref<AssessResultRow[]>(defaultAssessResult())
const cleanSummary = ref<AssessCleanSummary>()
const cleanModalVisible = ref(false)
const formModel = reactive<{
version: string
timeRange?: RangeValue
description?: string
}>({
version: ((route.query.version as string) || 'v-test').replace('%20', ' '),
timeRange: undefined,
description: '',
})
const biasForm = reactive({
absolute: undefined as number | undefined,
relative: 0 as number | undefined,
percent: 15,
})
const normalizePointRow = (item: any, index: number): AssessPointRow => {
const tMax = item.TMax ?? item.tMax
const tMin = item.TMin ?? item.tMin
const locked = item.lock === true
return {
...item,
description: item.description ?? item.Description,
pointId: item.pointId ?? item.PointId,
index: item.index ?? index,
TMax: tMax,
tMax,
TMin: tMin,
tMin,
Lower: item.Lower ?? item.lower,
Upper: item.Upper ?? item.upper,
Unit: item.Unit ?? item.unit,
lock: locked,
//
alarm: !locked,
}
}
const buildAssessRow = (point: AssessPointRow): AssessRow => {
const tMax = point.TMax ?? point.tMax
const tMin = point.TMin ?? point.tMin
const amplitude
= point.amplitude
?? Number(((0.15 * ((Number(tMax) || 0) - (Number(tMin) || 0))) || 0).toFixed(2))
return {
...point,
TMax: tMax,
tMax,
TMin: tMin,
tMin,
amplitude: Number.isNaN(amplitude) ? 0 : amplitude,
}
}
const hydrateAssessRow = (row: any, index: number): AssessRow => {
const point = pointRows.value.find(p => p.pointId === (row.pointId ?? row.PointId))
const tMax = row.TMax ?? row.tMax ?? point?.TMax ?? point?.tMax
const tMin = row.TMin ?? row.tMin ?? point?.TMin ?? point?.tMin
const lower = row.Lower ?? row.lower ?? point?.Lower
const upper = row.Upper ?? row.upper ?? point?.Upper
const unit = row.Unit ?? row.unit ?? point?.Unit ?? point?.unit
const description = row.description ?? row.Description ?? point?.description ?? point?.Description
return buildAssessRow({
...row,
index: row.index ?? index,
TMax: tMax,
tMax,
TMin: tMin,
tMin,
Lower: lower,
Upper: upper,
Unit: unit,
description,
})
}
const loadInit = async () => {
if (!modelId.value) {
createMessage.error('缺少模型ID')
return
}
loading.value = true
try {
if (reportId.value) {
const detail = await fetchReportDetail(reportId.value as string)
modelName.value = detail.modelName
modeRows.value = detail.report.modeRows || []
pointRows.value = (detail.report.pointRows || []).map((item, index) => normalizePointRow(item, index))
assessRows.value = (detail.report.assessRows || []).map((item, index) => hydrateAssessRow(item, index))
assessResult.value = detail.report.assessResult || defaultAssessResult()
identifier.value = detail.report.identifier || identifier.value
timestamp.value = detail.report.time || ''
}
else {
const modelInfo = await modelInfoApi(modelId.value as string)
baseInfo.value = modelInfo || {}
modelName.value = modelInfo?.name || modelInfo?.Model_Name || ''
pointRows.value = (modelInfo?.pointInfo || []).map((item: any, index: number) => normalizePointRow(item, index))
modeRows.value = [
{ name: '主元个数', content: modelInfo?.principal ?? '' },
{ name: '运行模式', content: modelInfo?.alarmmodelset?.alarmname || '全工况运行' },
{ name: '模式编码', content: modelInfo?.alarmmodelset?.alarmcondition || '1=1' },
{ name: '样本类型', content: '训练样本' },
{ name: '样本数量', content: modelInfo?.trainTime?.length || '--' },
]
assessRows.value
= pointRows.value
.filter(item => item.alarm)
.map((item, index) => buildAssessRow({ ...item, index })) || []
assessResult.value = defaultAssessResult()
bottomScore.value = modelInfo?.model_score
coverage.value = modelInfo?.load_coverage
timestamp.value = dayjs().format('YYYY-MM-DD HH:mm:ss')
if (!identifier.value) {
identifier.value = userStore.getUserInfo?.username
|| userStore.getUserInfo?.nickName
|| userStore.getUserInfo?.userName
|| ''
}
}
if (algorithm.value) {
const condition = await fetchAssessCondition(algorithm.value)
if (condition?.modelScore !== undefined && condition.modelScore !== null)
bottomScore.value = Number(condition.modelScore)
}
}
catch (error) {
createMessage.error('评估信息加载失败')
}
finally {
loading.value = false
}
}
const toggleAlarm = (row: AssessPointRow, checked: boolean) => {
row.alarm = checked
assessRows.value = assessRows.value.filter(item => item.pointId !== row.pointId)
if (checked)
assessRows.value.push(buildAssessRow(row))
}
const applyRound = () => {
assessRows.value = assessRows.value.map(item => ({
...item,
amplitude: Math.round(Number(item.amplitude) || 0),
}))
}
const applyAbsoluteBias = () => {
if (biasForm.absolute === undefined)
return
assessRows.value = assessRows.value.map(item => ({
...item,
amplitude: biasForm.absolute,
}))
}
const applyRelativeBias = () => {
if (biasForm.relative === undefined)
return
assessRows.value = assessRows.value.map(item => ({
...item,
amplitude: Number((Number(item.amplitude || 0) + biasForm.relative!).toFixed(2)),
}))
}
const applyPercentBias = () => {
assessRows.value = assessRows.value.map((item) => {
const point = pointRows.value.find(p => p.pointId === item.pointId)
const delta = point ? Number(point.TMax || point.tMax || 0) - Number(point.TMin || point.tMin || 0) : 0
const amplitude = Number(((biasForm.percent / 100) * delta).toFixed(2))
return {
...item,
amplitude: Number.isNaN(amplitude) ? 0 : amplitude,
}
})
}
const updateAssessResult = (durationSeconds?: number, coverRange?: number) => {
const scoreSummary = computeScore()
const cover = coverRange ?? coverage.value
assessResult.value = [
{ name: '得分', content: scoreSummary.score },
{ name: '工况覆盖率', content: cover !== undefined ? `${(Number(cover) * 100).toFixed(2)}%` : '--' },
{ name: '计算时长(s)', content: durationSeconds !== undefined ? durationSeconds : '--' },
{ name: '是否投入运用', content: scoreSummary.state },
]
coverage.value = coverRange ?? coverage.value
}
const computeScore = () => {
let forward = 0
let backward = 0
let forwardBase = 0
let backwardBase = 0
assessRows.value.forEach((item) => {
if (!item.fdr || !item.far)
return
const fdr = item.fdr.split('/')
const far = item.far.split('/')
const fwdFdr = Number((fdr[0] || '').replace('%', '')) / 100
const fwdFar = 1 - Number((far[0] || '').replace('%', '')) / 100
const bwdFdr = Number((fdr[1] || '').replace('%', '')) / 100
const bwdFar = 1 - Number((far[1] || '').replace('%', '')) / 100
forward += (1 / (1 + Math.exp(-(fwdFdr ** 3 - 0.5) * 11)) + 1 / (1 + Math.exp(-(fwdFar ** 5 - 0.5) * 8)) - 1) * 100
backward += (1 / (1 + Math.exp(-(bwdFdr ** 3 - 0.5) * 11)) + 1 / (1 + Math.exp(-(bwdFar ** 5 - 0.5) * 8)) - 1) * 100
forwardBase += 100
backwardBase += 100
})
const forwardScore = forwardBase ? (forward / forwardBase) * 100 : 0
const backwardScore = backwardBase ? (backward / backwardBase) * 100 : 0
const avg = (forwardScore + backwardScore) / 2
const beta = 1 / (1 + Math.exp(-((assessRows.value.length / 100) ** 2))) - 0.5
const finalScore = Number((avg + (100 - avg) * beta * 2).toFixed(2))
const bottom = bottomScore.value ?? 0
return {
score: finalScore,
state: finalScore > bottom ? '是' : '否',
}
}
const buildLegacyPayload = (row?: AssessRow) => {
const points = pointRows.value
const total = points.length
const hi: number[] = Array(total).fill(0)
const dead: number[] = []
const limit: number[] = []
const uplow: string[] = []
points.forEach((p) => {
dead.push(p.dead ? 0 : 1)
limit.push(p.limit ? 1 : 0)
uplow.push(`${p.Lower ?? p.lower ?? ''},${p.Upper ?? p.upper ?? ''}`)
})
const selected = row ? [row] : assessRows.value
selected.forEach((item) => {
const idx = item.index ?? points.findIndex(p => p.pointId === item.pointId)
if (idx >= 0)
hi[idx] = Number(item.amplitude) || 0
})
const intervalMs = baseInfo.value?.sampling ? Number(baseInfo.value.sampling) * 1000 : 0
let time = ''
if (formModel.timeRange && formModel.timeRange.length === 2) {
time = formModel.timeRange.join(',')
}
else {
const trainTime = baseInfo.value?.trainTime || []
const pieces: string[] = []
trainTime.forEach((t: any) => {
if (Number(t.mode) > 0 && t.st && t.et)
pieces.push(`${t.st},${t.et}`)
})
time = pieces.join(';')
}
const limitValue = (() => {
try {
const m = baseInfo.value?.para?.Model_info
if (m && m.Kesi_99 !== undefined)
return Number(m.Kesi_99)
return 0
}
catch (e) {
return 0
}
})()
const condition = baseInfo.value?.alarmmodelset?.alarmcondition || '1=1'
return {
Test_Data: {
time,
points: points.map(p => p.pointId).join(','),
interval: intervalMs,
},
Model_id: modelId.value,
number_sample: selected.length || 0,
number_fault_variable: selected.length || 0,
dead: dead.join(','),
limit: limit.join(','),
low_f: hi.map(v => Number(v || 0).toFixed(4)).join(','),
high_f: hi.map(v => Number(v || 0).toFixed(4)).join(','),
condition,
Test_Type: 'FAI',
Limit_Value: limitValue,
uplow: uplow.join(';'),
number: 0,
expand: !(formModel.timeRange && formModel.timeRange.length === 2),
k: baseInfo.value?.principal,
version: formModel.version,
coverage: coverage.value,
}
}
const handleEvaluate = async (row?: AssessRow) => {
if (!assessRows.value.length) {
createMessage.warning('请先选择参与预警的参数')
return
}
evaluateLoading.value = true
try {
const legacyPayload = buildLegacyPayload(row)
const payload = {
modelId: modelId.value as string,
version: formModel.version,
assessRows: row ? [row] : assessRows.value,
pointRows: pointRows.value,
timeRange: formModel.timeRange
? formModel.timeRange.map(t => dayjs(t).format('YYYY-MM-DD HH:mm:ss'))
: undefined,
assessMode: row ? 'single' : 'full',
alg: algorithm.value,
modelInfo: baseInfo.value ? JSON.stringify(baseInfo.value) : undefined,
rawJson: JSON.stringify(legacyPayload),
}
const res = await evaluateAssess(payload)
if (res?.points) {
res.points.forEach((result) => {
assessRows.value = assessRows.value.map((item) => {
const match = item.pointId === result.pointId || item.index === result.index
if (!match)
return item
const fdrForward = (result.fdrForward * 100).toFixed(2)
const fdrReverse = (result.fdrReverse * 100).toFixed(2)
const farForward = (result.farForward * 100).toFixed(2)
const farReverse = (result.farReverse * 100).toFixed(2)
const reconForward = result.reconForward !== undefined ? result.reconForward.toFixed(2) : '--'
const reconReverse = result.reconReverse !== undefined ? result.reconReverse.toFixed(2) : '--'
return {
...item,
fdr: `${fdrForward}%/${fdrReverse}%`,
far: `${farForward}%/${farReverse}%`,
recon: `${reconForward}/${reconReverse}`,
}
})
})
}
updateAssessResult(res?.durationSeconds, res?.coverage)
if (!timestamp.value)
timestamp.value = dayjs().format('YYYY-MM-DD HH:mm:ss')
createMessage.success('评估完成')
}
catch (error) {
createMessage.error('评估请求失败')
}
finally {
evaluateLoading.value = false
}
}
const handleSubmit = async () => {
if (!identifier.value) {
createMessage.warning('验证人不能为空')
return
}
if (!assessRows.value.length) {
createMessage.warning('请至少保留一个参与预警的参数')
return
}
submitLoading.value = true
try {
const scoreSummary = computeScore()
const payload = {
modelId: modelId.value as string,
version: formModel.version,
report: {
pointRows: pointRows.value,
modeRows: modeRows.value,
assessRows: assessRows.value,
assessResult: assessResult.value,
identifier: identifier.value,
time: timestamp.value || dayjs().format('YYYY-MM-DD HH:mm:ss'),
},
score: scoreSummary.score,
coverage: coverage.value || 0,
valid: scoreSummary.state === '是',
identifier: identifier.value,
verifier: '',
timestamp: timestamp.value || dayjs().format('YYYY-MM-DD HH:mm:ss'),
conditionName: modeRows.value[1]?.content as string,
}
await saveAssessReport(payload)
createMessage.success('评估报告已提交')
router.back()
}
catch (error) {
createMessage.error('提交失败')
}
finally {
submitLoading.value = false
}
}
const handleCleanSummary = async () => {
if (!formModel.timeRange) {
createMessage.warning('请选择时间范围后再查看清洗信息')
return
}
try {
const params = {
modelId: modelId.value as string,
version: formModel.version,
time: formModel.timeRange.map(t => dayjs(t).format('YYYY-MM-DD HH:mm:ss')).join(','),
}
cleanSummary.value = await fetchCleanSummary(params)
cleanModalVisible.value = true
}
catch (error) {
createMessage.error('获取清洗信息失败')
}
}
const pointColumns = computed<ColumnProps<AssessPointRow>[]>(() => [
{
title: ' ',
dataIndex: 'group',
width: 120,
align: 'center',
customRender: () => '模型参数',
},
{
title: '序号',
dataIndex: 'index',
width: 80,
align: 'center',
customRender: ({ record, index }) => (record.index ?? index) + 1,
},
{ title: '参数名称', dataIndex: 'description', width: 220, align: 'center' },
{ title: '参数KKS码', dataIndex: 'pointId', width: 200, align: 'center' },
{
title: '是否锁定',
dataIndex: 'lock',
width: 120,
align: 'center',
customRender: ({ text }) => (text ? '是' : '否'),
},
{
title: '是否参与预警',
dataIndex: 'alarm',
width: 160,
align: 'center',
customRender: ({ record }) =>
h(Switch, {
checked: record.alarm,
disabled: record.lock,
onChange: (checked: boolean) => toggleAlarm(record, checked),
}),
},
])
const assessColumns = computed<ColumnProps<AssessRow>[]>(() => [
{ title: '参与预警的参数', dataIndex: 'description', width: 200, align: 'center' },
{ title: '单位', dataIndex: 'unit', width: 80, align: 'center' },
{
title: '目标故障幅度',
dataIndex: 'amplitude',
width: 140,
align: 'center',
customRender: ({ record }) =>
h(InputNumber, {
value: Number(record.amplitude) || 0,
min: -999999,
max: 999999,
onChange: (val: number) => {
record.amplitude = val
},
}),
},
{ title: '诊出率', dataIndex: 'fdr', width: 160, align: 'center', customRender: ({ text }) => (text || '--') },
{ title: '误诊率', dataIndex: 'far', width: 160, align: 'center', customRender: ({ text }) => (text || '--') },
{ title: '重构偏差', dataIndex: 'recon', width: 140, align: 'center', customRender: ({ text }) => (text || '--') },
{ title: '训练样本最大值', dataIndex: 'tMax', width: 140, align: 'center' },
{ title: '训练样本最小值', dataIndex: 'tMin', width: 140, align: 'center' },
{
title: '操作',
key: 'action',
width: 120,
align: 'center',
customRender: ({ record }) =>
h(
Button,
{
type: 'link',
size: 'small',
loading: evaluateLoading.value,
onClick: () => handleEvaluate(record),
},
{ default: () => '单项评估' },
),
},
])
onMounted(loadInit)
return {
assessColumns,
assessResult,
assessRows,
applyAbsoluteBias,
applyPercentBias,
applyRelativeBias,
applyRound,
baseInfo,
biasForm,
bottomScore,
cleanModalVisible,
cleanSummary,
computeScore,
coverage,
formModel,
handleCleanSummary,
handleEvaluate,
handleSubmit,
identifier,
loading,
modeRows,
modelName,
pointColumns,
pointRows,
toggleAlarm,
updateAssessResult,
evaluateLoading,
submitLoading,
timestamp,
}
},
})
</script>
<template>
<PageWrapper :title="`${modelName || '模型'}评估报告`" content-full-height>
<a-card class="mb-4" bordered>
<h3 class="section-title">
1. 模型基本信息
</h3>
<a-table
:columns="pointColumns" :data-source="pointRows" row-key="pointId" size="small" :pagination="false"
bordered align="center"
/>
<a-table
class="mode-table"
:columns="[
{ title: '', dataIndex: 'name', width: 200, align: 'center' },
{ title: '', dataIndex: 'content', align: 'center' },
]"
:data-source="modeRows"
:pagination="false"
size="small"
bordered
row-key="name"
align="center"
/>
</a-card>
<a-card class="mb-4" bordered>
<h3 class="section-title">
2. 模型验证结果诊出率>98%误诊率&lt;5%
</h3>
<div class="bias-card">
<div class="bias-actions">
<a-button size="small" type="default" @click="applyRound">
取整
</a-button>
<div class="bias-group">
<span class="bias-label">绝对偏置</span>
<a-input-number v-model:value="biasForm.absolute" size="small" :controls="false" />
<a-button size="small" type="primary" ghost @click="applyAbsoluteBias">
应用
</a-button>
</div>
<div class="bias-group">
<span class="bias-label">相对偏置</span>
<a-input-number v-model:value="biasForm.relative" size="small" :controls="false" />
<a-button size="small" type="primary" ghost @click="applyRelativeBias">
应用
</a-button>
</div>
<div class="bias-group">
<span class="bias-label">偏置百分比</span>
<a-input-number v-model:value="biasForm.percent" size="small" :controls="false" />
<span class="bias-suffix">%</span>
<a-button size="small" type="primary" ghost @click="applyPercentBias">
应用
</a-button>
</div>
</div>
</div>
<a-table
:columns="assessColumns" :data-source="assessRows" row-key="pointId" size="small" bordered
:pagination="{ pageSize: 10 }" align="center"
/>
</a-card>
<a-card class="mb-4" bordered>
<h3 class="section-title">
3. 模型评估结果
</h3>
<a-table
class="result-table"
:columns="[
{ title: '指标', dataIndex: 'name', width: 200, align: 'center' },
{ title: '结果', dataIndex: 'content', align: 'center' },
]"
:data-source="assessResult"
:pagination="false"
size="small"
bordered
row-key="name"
align="center"
/>
<div class="footer-actions">
<div class="footer-inputs">
<label class="footer-label">
验证人
<a-input v-model:value="identifier" disabled />
</label>
<label class="footer-label">
时间
<a-input v-model:value="timestamp" disabled />
</label>
</div>
<div class="footer-buttons">
<a-space>
<a-button type="primary" size="small" :loading="evaluateLoading" @click="handleEvaluate()">
评估
</a-button>
<a-button type="primary" size="small" :loading="submitLoading" @click="handleSubmit">
提交
</a-button>
</a-space>
</div>
</div>
</a-card>
<a-modal v-model:open="cleanModalVisible" title="数据清洗测试" width="900px" :footer="null">
<a-table
v-if="cleanSummary"
:data-source="cleanSummary.summary"
:pagination="false"
size="small"
row-key="type"
:columns="[
{ title: '类型', dataIndex: 'type', align: 'center' },
{ title: '采样数量', dataIndex: 'orgNum', align: 'center' },
{ title: '有效样本数量', dataIndex: 'sampleNum', align: 'center' },
{
title: '有效样本占比',
dataIndex: 'sampleRate',
align: 'center',
customRender: ({ text }) => (text !== undefined ? `${Number(text * 100).toFixed(2)}%` : '--'),
},
{
title: '清洗结果',
dataIndex: 'message',
align: 'center',
customRender: ({ text }) => (text === '清洗失败' ? h(Tag, { color: 'red' }, () => text) : text),
},
]"
align="center"
/>
</a-modal>
</PageWrapper>
</template>
<style scoped>
.section-title {
margin-bottom: 12px;
font-weight: 600;
}
.bias-card {
padding: 10px 12px;
margin-bottom: 10px;
background: #fafafa;
border: 1px solid #f0f0f0;
border-radius: 6px;
}
.bias-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
}
.bias-group {
display: flex;
gap: 6px;
align-items: center;
}
.bias-label,
.bias-suffix {
color: #666;
}
.bias-group .ant-input-number {
width: 120px;
}
.footer-actions {
display: flex;
flex-direction: column;
gap: 12px;
align-items: center;
margin-top: 16px;
}
.footer-inputs {
display: flex;
gap: 20px;
align-items: center;
justify-content: center;
width: 100%;
}
.footer-label {
display: flex;
gap: 6px;
align-items: center;
color: #666;
}
.footer-inputs .ant-input {
width: 200px;
}
.footer-buttons {
display: flex;
justify-content: center;
width: 100%;
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.mode-table .ant-table-tbody > tr > td:first-child,
.result-table .ant-table-tbody > tr > td:first-child {
font-weight: 600;
background: #fafafa;
}
</style>

52
src/views/model/train/data.tsx

@ -103,6 +103,58 @@ export const pointTableSchema: BasicColumn[] = [
dataIndex: 'Lower',
edit: true,
},
{
title: '残差上限',
width: 150,
dataIndex: 'upperBound',
edit: true,
},
{
title: '残差下限',
width: 150,
dataIndex: 'lowerBound',
edit: true,
},
{
title: '训练样本最大值',
width: 150,
dataIndex: 'TMax',
},
{
title: '训练样本最小值',
width: 150,
dataIndex: 'TMin',
},
{
title: '训练样本平均值',
width: 150,
dataIndex: 'TAvg',
},
{
title: '训练最大偏差',
width: 150,
dataIndex: 'DMax',
},
{
title: '训练最小偏差',
width: 150,
dataIndex: 'DMin',
},
{
title: '训练平均偏差',
width: 150,
dataIndex: 'DAvg',
},
{
title: '死区清洗',
width: 120,
dataIndex: 'dead',
},
{
title: '限值清洗',
width: 120,
dataIndex: 'limit',
},
{
title: '编辑',
width: 100,

122
src/views/model/train/index.vue

@ -39,6 +39,7 @@ import { getExaHistorys } from '@/api/alert/exa/index'
import { useECharts } from '@/hooks/web/useECharts'
import { useMessage } from '@/hooks/web/useMessage'
import { pointListApi } from '@/api/alert/model/select'
import { useGo } from '@/hooks/web/usePage'
export default defineComponent({
components: {
@ -69,6 +70,7 @@ export default defineComponent({
setup() {
const { createMessage } = useMessage()
const route = useRoute()
const go = useGo()
const id = route.params.id
const model = ref(null)
const brushActivated = ref<Set<number>>(new Set())
@ -76,12 +78,31 @@ export default defineComponent({
let trainTimeCopy = ''
const fetchModelInfo = async () => {
const modelInfo = await modelInfoApi(id)
model.value = modelInfo
model.value = normalizeTrainTime(modelInfo)
trainTimeCopy = JSON.stringify(model.value.trainTime)
getHistory()
}
const pointData = computed(() => model.value?.pointInfo || [])
const pointData = computed(() => {
const list = model.value?.pointInfo || []
return list.map((p: any) => ({
description: p.description ?? p.Description,
pointId: p.pointId ?? p.PointId,
unit: p.unit ?? p.Unit,
Upper: p.Upper ?? p.upper,
Lower: p.Lower ?? p.lower,
dead: p.dead === true ? '是' : '否',
limit: p.limit === true ? '是' : '否',
upperBound: p.upperBound ?? p.upperbound ?? p.upperBound,
lowerBound: p.lowerBound ?? p.lowerbound ?? p.lowerBound,
TMax: p.TMax ?? p.tMax,
TMin: p.TMin ?? p.tMin,
TAvg: p.TAvg ?? p.tAvg,
DMax: p.DMax ?? p.dMax,
DMin: p.DMin ?? p.dMin,
DAvg: p.DAvg ?? p.dAvg,
}))
})
const [pointTable] = useTable({
columns: pointTableSchema,
pagination: true,
@ -181,6 +202,26 @@ export default defineComponent({
return Array.from({ length: count }, (_, i) => t1.add(i * intervalMs, 'millisecond').valueOf())
}
function normalizeTrainTime(modelInfo: any) {
if (!modelInfo?.trainTime || !Array.isArray(modelInfo.trainTime))
return modelInfo
const samplingMs = (modelInfo.sampling || 0) * 1000
modelInfo.trainTime = modelInfo.trainTime.map((item: any) => {
const durationSec = item.duration || Math.max(0, (dayjs(item.et).valueOf() - dayjs(item.st).valueOf()) / 1000)
const number = item.number !== undefined && item.number !== '' ? item.number : (samplingMs > 0 ? Math.floor((durationSec * 1000) / samplingMs) : '')
const filter = item.filter !== undefined && item.filter !== '' ? item.filter : 0
const mode = item.mode !== undefined && item.mode !== '' ? item.mode : (number !== '' ? Math.max(0, number - filter) : '')
return {
...item,
duration: durationSec,
number,
filter,
mode,
}
})
return modelInfo
}
function getOption(item: any) {
const name = ['测量值', '模型值', '']
const color = ['blue', '#ff6f00', 'red']
@ -308,8 +349,8 @@ export default defineComponent({
console.log('Selected train time:', trainTime)
if (trainTimeCopy === JSON.stringify(trainTime))
return
model.value.trainTime = trainTime
trainTimeCopy = JSON.stringify(trainTime)
model.value = normalizeTrainTime({ ...model.value, trainTime })
trainTimeCopy = JSON.stringify(model.value.trainTime)
echartsRefs.value.forEach((chart, index) => {
if (chart) {
chart.dispatchAction({
@ -435,7 +476,60 @@ export default defineComponent({
try {
const response = await trainModelApi(params)
model.value.para = response
model.value.principal = response.Model_info.K
const modelInfoDetail = response.Model_info || response.modelInfo
if (modelInfoDetail) {
model.value.principal = modelInfoDetail.K ?? modelInfoDetail.k ?? model.value.principal
model.value.precision = modelInfoDetail.R ? `${(modelInfoDetail.R * 100).toFixed(2)}%` : model.value.precision
const pxMax = modelInfoDetail.Train_X_max || modelInfoDetail.trainXMax || []
const pxMin = modelInfoDetail.Train_X_min || modelInfoDetail.trainXMin || []
const pxMean = modelInfoDetail.Train_X_mean || modelInfoDetail.trainXMean || []
const pbMax = (modelInfoDetail.Train_X_bais_max || modelInfoDetail.trainXBaisMax || [])[0] || []
const pbMin = (modelInfoDetail.Train_X_bais_min || modelInfoDetail.trainXBaisMin || [])[0] || []
const pbMean = (modelInfoDetail.Train_X_bais_mean || modelInfoDetail.trainXBaisMean || [])[0] || []
const qcul99Line = modelInfoDetail.QCUL_99_line || modelInfoDetail.qcul99Line || []
model.value.pointInfo = (model.value.pointInfo || []).map((p: any, idx: number) => {
const q99 = qcul99Line[idx]
const upperB = parseFloat(p.upperBound)
const lowerB = parseFloat(p.lowerBound)
return {
...p,
TMax: pxMax[idx] !== undefined ? Number(pxMax[idx]).toFixed(2) : p.TMax,
TMin: pxMin[idx] !== undefined ? Number(pxMin[idx]).toFixed(2) : p.TMin,
TAvg: pxMean[idx] !== undefined ? Number(pxMean[idx]).toFixed(2) : p.TAvg,
DMax: pbMax[idx] !== undefined ? Number(pbMax[idx]).toFixed(2) : p.DMax,
DMin: pbMin[idx] !== undefined ? Number(pbMin[idx]).toFixed(2) : p.DMin,
DAvg: pbMean[idx] !== undefined ? Number(pbMean[idx]).toFixed(2) : p.DAvg,
upperBound:
Number.isNaN(upperB) || upperB === undefined
? (q99 !== undefined ? Number(q99).toFixed(2) : p.upperBound)
: p.upperBound,
lowerBound:
Number.isNaN(lowerB) || lowerB === undefined
? (q99 !== undefined ? (-Number(q99)).toFixed(2) : p.lowerBound)
: p.lowerBound,
}
})
}
// //
if (Array.isArray(model.value.trainTime) && model.value.trainTime.length) {
const beforeTotal = response.BeforeCleanSamNum ?? response.beforeCleanSamNum
const afterTotal = response.AfterCleanSamNum ?? response.afterCleanSamNum
if (beforeTotal !== undefined && afterTotal !== undefined) {
const avgBefore = Math.floor(beforeTotal / model.value.trainTime.length)
const avgAfter = Math.floor(afterTotal / model.value.trainTime.length)
model.value.trainTime = model.value.trainTime.map((t: any) => ({
...t,
number: t.number || avgBefore,
filter: t.filter || Math.max(0, avgBefore - avgAfter),
mode: t.mode || avgAfter,
}))
}
}
updateModelInfoDebounced()
getTestData()
createMessage.success('模型训练成功')
@ -618,6 +712,12 @@ export default defineComponent({
}
}
function goAssessReport() {
const version = model.value?.Cur_Version || 'v-test'
const algorithm = model.value?.algorithm || 'PCA'
go(`/model/assess-report/${id}?version=${encodeURIComponent(version)}&algorithm=${algorithm}`)
}
return {
pointTable,
model,
@ -653,6 +753,7 @@ export default defineComponent({
handleEditModelOk,
handleEditModelCancel,
bottomModel,
goAssessReport,
}
},
})
@ -673,7 +774,9 @@ export default defineComponent({
{{ model?.Cur_Version || "v-test" }}
</a-descriptions-item>
<a-descriptions-item label="评估报告">
暂无
<a-button type="link" size="small" @click="goAssessReport">
新增/查看
</a-button>
</a-descriptions-item>
<a-descriptions-item label="创建人">
{{ model?.founder }}
@ -785,6 +888,13 @@ export default defineComponent({
>
清除训练结果
</a-button>
<a-button
type="primary"
style="margin-left: 10px"
@click="goAssessReport"
>
评估报告
</a-button>
<a-button type="primary" style="margin-left: 6px" @click="openEditModel">
修改模型
</a-button>

Loading…
Cancel
Save