/** * 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 = { 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 }