Files
NextEdu/src/modules/attendance/correlation-compute.ts
SpecialX 048fc1c386 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
2026-07-03 10:24:16 +08:00

211 lines
6.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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
}