feat(attendance): add correlation, trend, warnings, report print, and services
- Add attendance-grade-correlation-card and data-access-correlation, correlation-compute - Add attendance-trend-chart and trend-compute for trend analysis - Add attendance-warnings-card and warning-compute for attendance warnings - Add attendance-report-print for printable reports - Add class-comparison-card for class attendance comparison - Add notifications and services directory
This commit is contained in:
210
src/modules/attendance/correlation-compute.ts
Normal file
210
src/modules/attendance/correlation-compute.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* L-9 考勤与成绩关联分析:纯函数实现。
|
||||
*
|
||||
* 与 IO 解耦,便于单测。包含:
|
||||
* - Pearson 相关系数计算
|
||||
* - 风险等级分类
|
||||
* - 相关系数解释
|
||||
* - 班级汇总聚合
|
||||
*
|
||||
* 参考 warning-compute.ts / trend-compute.ts 的纯函数模式。
|
||||
*/
|
||||
|
||||
import type {
|
||||
AttendanceGradeCorrelationItem,
|
||||
AttendanceGradeCorrelationSummary,
|
||||
AttendanceGradeRiskLevel,
|
||||
} from "./types"
|
||||
|
||||
/** 风险阈值:出勤率(百分比)。 */
|
||||
export const RISK_ATTENDANCE_HIGH_THRESHOLD = 80
|
||||
export const RISK_ATTENDANCE_MEDIUM_THRESHOLD = 90
|
||||
|
||||
/** 风险阈值:成绩(0-100 归一化分数)。 */
|
||||
export const RISK_SCORE_HIGH_THRESHOLD = 60
|
||||
export const RISK_SCORE_MEDIUM_THRESHOLD = 75
|
||||
|
||||
/** Pearson 相关系数解释阈值(绝对值)。 */
|
||||
const CORRELATION_STRONG_THRESHOLD = 0.7
|
||||
const CORRELATION_WEAK_THRESHOLD = 0.3
|
||||
|
||||
/** 相关系数解释类型(与 types.ts 中保持一致)。 */
|
||||
export type CorrelationInterpretation =
|
||||
| "strong_negative"
|
||||
| "weak_negative"
|
||||
| "negligible"
|
||||
| "weak_positive"
|
||||
| "strong_positive"
|
||||
| "insufficient_data"
|
||||
|
||||
/**
|
||||
* 计算 Pearson 相关系数。
|
||||
*
|
||||
* @param x 自变量数组(如出勤率)
|
||||
* @param y 因变量数组(如平均成绩)
|
||||
* @returns r ∈ [-1, +1];数据不足(<2 个点)或方差为 0 时返回 null
|
||||
*/
|
||||
export function computePearsonCorrelation(
|
||||
x: readonly number[],
|
||||
y: readonly number[]
|
||||
): number | null {
|
||||
if (x.length !== y.length) return null
|
||||
if (x.length < 2) return null
|
||||
|
||||
const n = x.length
|
||||
let sumX = 0
|
||||
let sumY = 0
|
||||
let sumXY = 0
|
||||
let sumX2 = 0
|
||||
let sumY2 = 0
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const xi = x[i]
|
||||
const yi = y[i]
|
||||
if (!Number.isFinite(xi) || !Number.isFinite(yi)) return null
|
||||
sumX += xi
|
||||
sumY += yi
|
||||
sumXY += xi * yi
|
||||
sumX2 += xi * xi
|
||||
sumY2 += yi * yi
|
||||
}
|
||||
|
||||
const numerator = n * sumXY - sumX * sumY
|
||||
const denominator = Math.sqrt(
|
||||
(n * sumX2 - sumX * sumX) * (n * sumY2 - sumY * sumY)
|
||||
)
|
||||
|
||||
if (denominator === 0) return null
|
||||
// 限制到 [-1, 1] 防止浮点误差溢出
|
||||
return Math.max(-1, Math.min(1, numerator / denominator))
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据出勤率和平均成绩判定风险等级。
|
||||
* - high:低出勤(<80%)且低分(<60%)
|
||||
* - medium:中低出勤(<90%)且中低分(<75%),但未达 high
|
||||
* - low:其余(正常)
|
||||
*/
|
||||
export function classifyRiskLevel(
|
||||
attendanceRate: number,
|
||||
averageScore: number
|
||||
): AttendanceGradeRiskLevel {
|
||||
const isLowAttendance = attendanceRate < RISK_ATTENDANCE_HIGH_THRESHOLD
|
||||
const isMediumAttendance =
|
||||
attendanceRate < RISK_ATTENDANCE_MEDIUM_THRESHOLD && !isLowAttendance
|
||||
const isLowScore = averageScore < RISK_SCORE_HIGH_THRESHOLD
|
||||
const isMediumScore =
|
||||
averageScore < RISK_SCORE_MEDIUM_THRESHOLD && !isLowScore
|
||||
|
||||
if (isLowAttendance && isLowScore) return "high"
|
||||
if ((isLowAttendance || isMediumAttendance) && (isLowScore || isMediumScore))
|
||||
return "medium"
|
||||
return "low"
|
||||
}
|
||||
|
||||
/**
|
||||
* 解释 Pearson 相关系数。
|
||||
* - |r| >= 0.7:强相关
|
||||
* - 0.3 <= |r| < 0.7:弱相关
|
||||
* - |r| < 0.3:可忽略
|
||||
*/
|
||||
export function interpretCorrelation(
|
||||
r: number | null
|
||||
): CorrelationInterpretation {
|
||||
if (r === null) return "insufficient_data"
|
||||
const abs = Math.abs(r)
|
||||
if (abs >= CORRELATION_STRONG_THRESHOLD) {
|
||||
return r > 0 ? "strong_positive" : "strong_negative"
|
||||
}
|
||||
if (abs >= CORRELATION_WEAK_THRESHOLD) {
|
||||
return r > 0 ? "weak_positive" : "weak_negative"
|
||||
}
|
||||
return "negligible"
|
||||
}
|
||||
|
||||
/** 风险等级排序权重(用于降序排列:high > medium > low)。 */
|
||||
const RISK_ORDER: Record<AttendanceGradeRiskLevel, number> = {
|
||||
high: 0,
|
||||
medium: 1,
|
||||
low: 2,
|
||||
}
|
||||
|
||||
/**
|
||||
* 聚合班级考勤-成绩关联汇总(纯函数)。
|
||||
*
|
||||
* 输入:每个学生的原始数据(studentId, studentName, attendanceRate, averageScore, 计数)。
|
||||
* 输出:含 Pearson 相关系数、风险分级、排序后的 items。
|
||||
*
|
||||
* 排除无考勤或无成绩记录的学生(视为数据不全,不参与关联分析)。
|
||||
*/
|
||||
export function computeCorrelationSummary(
|
||||
classId: string,
|
||||
className: string,
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
rawItems: ReadonlyArray<{
|
||||
studentId: string
|
||||
studentName: string
|
||||
attendanceRate: number
|
||||
attendanceRecordCount: number
|
||||
absentCount: number
|
||||
averageScore: number
|
||||
gradeRecordCount: number
|
||||
}>
|
||||
): AttendanceGradeCorrelationSummary {
|
||||
// 过滤掉无考勤或无成绩记录的学生
|
||||
const validItems = rawItems.filter(
|
||||
(it) => it.attendanceRecordCount > 0 && it.gradeRecordCount > 0
|
||||
)
|
||||
|
||||
const items: AttendanceGradeCorrelationItem[] = validItems.map((it) => ({
|
||||
studentId: it.studentId,
|
||||
studentName: it.studentName,
|
||||
attendanceRate: round2(it.attendanceRate),
|
||||
attendanceRecordCount: it.attendanceRecordCount,
|
||||
absentCount: it.absentCount,
|
||||
averageScore: round2(it.averageScore),
|
||||
gradeRecordCount: it.gradeRecordCount,
|
||||
riskLevel: classifyRiskLevel(it.attendanceRate, it.averageScore),
|
||||
}))
|
||||
|
||||
// 排序:风险等级降序 → 出勤率升序(高风险学生排在最前)
|
||||
items.sort((a, b) => {
|
||||
const riskDiff = RISK_ORDER[a.riskLevel] - RISK_ORDER[b.riskLevel]
|
||||
if (riskDiff !== 0) return riskDiff
|
||||
return a.attendanceRate - b.attendanceRate
|
||||
})
|
||||
|
||||
// Pearson 相关系数:x=出勤率,y=平均成绩
|
||||
const correlation = computePearsonCorrelation(
|
||||
items.map((it) => it.attendanceRate),
|
||||
items.map((it) => it.averageScore)
|
||||
)
|
||||
|
||||
const riskCounts = {
|
||||
high: items.filter((it) => it.riskLevel === "high").length,
|
||||
medium: items.filter((it) => it.riskLevel === "medium").length,
|
||||
low: items.filter((it) => it.riskLevel === "low").length,
|
||||
}
|
||||
|
||||
return {
|
||||
classId,
|
||||
className,
|
||||
startDate,
|
||||
endDate,
|
||||
items,
|
||||
correlation: correlation !== null ? round4(correlation) : null,
|
||||
correlationInterpretation: interpretCorrelation(correlation),
|
||||
riskCounts,
|
||||
}
|
||||
}
|
||||
|
||||
/** 保留两位小数。 */
|
||||
function round2(n: number): number {
|
||||
return Math.round(n * 100) / 100
|
||||
}
|
||||
|
||||
/** 保留四位小数(相关系数精度)。 */
|
||||
function round4(n: number): number {
|
||||
return Math.round(n * 10000) / 10000
|
||||
}
|
||||
Reference in New Issue
Block a user