@@ -48,6 +48,40 @@ data class GradeTrend(
4848 val averageLine: String = points.joinToString(" → " ) { " %.1f" .format(it.weightedAverage) }
4949}
5050
51+ data class HistoryExamRef (
52+ val yearTerm : YearTermOption ,
53+ val exam : ExamOption ,
54+ ) {
55+ val yearValue: String = yearTerm.value
56+ val examValue: String = exam.value
57+ val examName: String = exam.text
58+ }
59+
60+ data class SimulationHistorySource (
61+ val yearTerm : YearTermOption ,
62+ val exams : List <ExamOption >,
63+ val usesPreviousTerm : Boolean ,
64+ val examRefs : List <HistoryExamRef >? = null ,
65+ ) {
66+ val historyExams: List <HistoryExamRef > = examRefs ? : exams.map { HistoryExamRef (yearTerm, it) }
67+
68+ val label: String = if (usesPreviousTerm) {
69+ " 近 ${historyExams.size} 次段考"
70+ } else {
71+ " 同學期前 ${exams.size} 次考試"
72+ }
73+ }
74+
75+ data class ScoreSimulationResult (
76+ val adjustedAverage : Double ,
77+ val projectedAverage : Double ,
78+ val trendDelta : Double ,
79+ val estimatedClassRank : Int? ,
80+ val classCount : Int? ,
81+ val historyCount : Int ,
82+ val hasRankProjection : Boolean ,
83+ )
84+
5185data class RankProjection (
5286 val subjectName : String ,
5387 val suggestedIncrease : Double ,
@@ -258,6 +292,55 @@ fun YearTermOption.previousExamsOf(examValue: String?, limit: Int = 2): List<Exa
258292 return exams.subList(max(0 , index - limit), index)
259293}
260294
295+ fun List<YearTermOption>.sameTermHistorySource (
296+ yearValue : String? ,
297+ examValue : String? ,
298+ ): SimulationHistorySource ? {
299+ if (yearValue.isNullOrBlank()) return null
300+ val current = firstOrNull { it.value == yearValue } ? : return null
301+ val sameTermExams = current.previousExamsOf(examValue, limit = Int .MAX_VALUE )
302+ if (sameTermExams.isEmpty()) return null
303+ return SimulationHistorySource (
304+ yearTerm = current,
305+ exams = sameTermExams,
306+ usesPreviousTerm = false ,
307+ )
308+ }
309+
310+ fun List<YearTermOption>.simulationHistorySource (
311+ yearValue : String? ,
312+ examValue : String? ,
313+ ): SimulationHistorySource ? {
314+ if (yearValue.isNullOrBlank() || examValue.isNullOrBlank()) return null
315+ val orderedExams = sortedWith(
316+ compareBy<YearTermOption >({ it.sortKey().first }, { it.sortKey().second }),
317+ ).flatMap { yearTerm ->
318+ yearTerm.exams.map { exam -> HistoryExamRef (yearTerm, exam) }
319+ }
320+ val currentIndex = orderedExams.indexOfFirst {
321+ it.yearValue == yearValue && it.examValue == examValue
322+ }
323+ if (currentIndex <= 0 ) return null
324+ val historyExams = orderedExams.subList(max(0 , currentIndex - 3 ), currentIndex)
325+ if (historyExams.isEmpty()) return null
326+ return SimulationHistorySource (
327+ yearTerm = historyExams.first().yearTerm,
328+ exams = historyExams.map { it.exam },
329+ usesPreviousTerm = historyExams.any { it.yearValue != yearValue },
330+ examRefs = historyExams,
331+ )
332+ }
333+
334+ fun List<YearTermOption>.latestYearTerm (): YearTermOption ? =
335+ maxWithOrNull(
336+ compareBy<YearTermOption >(
337+ { option -> parseYearTerm(option.value, defaultYear = " 0" , defaultTerm = " 0" ).first.toIntOrNull() ? : 0 },
338+ { option -> parseYearTerm(option.value, defaultYear = " 0" , defaultTerm = " 0" ).second.toIntOrNull() ? : 0 },
339+ ),
340+ )
341+
342+ fun YearTermOption.latestExam (): ExamOption ? = exams.lastOrNull()
343+
261344fun buildGradeTrend (
262345 currentExamName : String ,
263346 currentReport : GradeReport ,
@@ -269,6 +352,46 @@ fun buildGradeTrend(
269352 return GradeTrend (points = previousPoints + currentReport.toTrendPoint(currentExamName))
270353}
271354
355+ fun simulateScores (
356+ currentReport : GradeReport ,
357+ historyReports : List <GradeReport >,
358+ adjustedScores : Map <String , Double >,
359+ ): ScoreSimulationResult {
360+ val adjustedAverage = weightedAverageFor(currentReport.subjects, adjustedScores)
361+ val historyPoints = historyReports + currentReport
362+ val trendDelta = historyPoints
363+ .zipWithNext { previous, next -> next.weightedAverage() - previous.weightedAverage() }
364+ .takeIf { it.isNotEmpty() }
365+ ?.average()
366+ ? : 0.0
367+ val projectedAverage = (adjustedAverage + trendDelta).coerceIn(0.0 , 100.0 )
368+ val currentRank = currentReport.examSummary?.classRank?.toInt()
369+ val classCount = currentReport.examSummary?.classCount
370+ val estimatedRank = if (! hasAdjustedScores(currentReport, adjustedScores)) {
371+ currentRank?.let { rank ->
372+ if (classCount != null && classCount > 0 ) rank.coerceIn(1 , classCount) else rank
373+ }
374+ } else if (classCount != null && classCount > 0 ) {
375+ relativeRegressionRank(
376+ historyReports = historyReports,
377+ currentReport = currentReport,
378+ adjustedScores = adjustedScores,
379+ classCount = classCount,
380+ )
381+ } else {
382+ null
383+ }
384+ return ScoreSimulationResult (
385+ adjustedAverage = adjustedAverage,
386+ projectedAverage = projectedAverage,
387+ trendDelta = trendDelta,
388+ estimatedClassRank = estimatedRank,
389+ classCount = classCount,
390+ historyCount = historyReports.size,
391+ hasRankProjection = estimatedRank != null ,
392+ )
393+ }
394+
272395fun deltaText (label : String , delta : Double , unit : String = ""): String {
273396 val direction = when {
274397 delta > 0.05 -> " +"
@@ -289,6 +412,93 @@ private fun rankDelta(current: Double?, previous: Double?): Int? {
289412 return previous.toInt() - current.toInt()
290413}
291414
415+ private fun YearTermOption.sortKey (): Pair <Int , Int > {
416+ val (year, term) = parseYearTerm(value, defaultYear = " 0" , defaultTerm = " 0" )
417+ return (year.toIntOrNull() ? : 0 ) to (term.toIntOrNull() ? : 0 )
418+ }
419+
420+ private fun weightedAverageFor (
421+ subjects : List <SubjectScore >,
422+ adjustedScores : Map <String , Double >,
423+ ): Double {
424+ val totalWeight = subjects.sumOf { subjectWeight(it.subjectName) }
425+ if (totalWeight <= 0 ) return 0.0
426+ val weightedTotal = subjects.sumOf { subject ->
427+ val score = adjustedScores[cleanSubjectName(subject.subjectName)] ? : subject.scoreValue
428+ score.coerceIn(0.0 , 100.0 ) * subjectWeight(subject.subjectName)
429+ }
430+ return weightedTotal / totalWeight
431+ }
432+
433+ private fun hasAdjustedScores (
434+ report : GradeReport ,
435+ adjustedScores : Map <String , Double >,
436+ ): Boolean = report.subjects.any { subject ->
437+ val adjusted = adjustedScores[cleanSubjectName(subject.subjectName)] ? : subject.scoreValue
438+ abs(adjusted - subject.scoreValue) > 0.05
439+ }
440+
441+ private fun relativeRegressionRank (
442+ historyReports : List <GradeReport >,
443+ currentReport : GradeReport ,
444+ adjustedScores : Map <String , Double >,
445+ classCount : Int ,
446+ ): Int? {
447+ val points = historyReports.mapNotNull { report ->
448+ val rank = report.examSummary?.classRank ? : return @mapNotNull null
449+ val relativeScore = report.examRelativeScore() ? : return @mapNotNull null
450+ relativeScore to rank
451+ }
452+ if (points.size < 3 ) return null
453+ val adjustedRelativeScore = currentReport.examRelativeScore(adjustedScores) ? : return null
454+ val relativeMean = points.map { it.first }.average()
455+ val rankMean = points.map { it.second }.average()
456+ val denominator = points.sumOf { (relativeScore, _) ->
457+ val delta = relativeScore - relativeMean
458+ delta * delta
459+ }
460+ if (denominator <= 0.0001 ) return null
461+ val slope = points.sumOf { (relativeScore, rank) ->
462+ (relativeScore - relativeMean) * (rank - rankMean)
463+ } / denominator
464+ val intercept = rankMean - slope * relativeMean
465+ return (slope * adjustedRelativeScore + intercept)
466+ .roundToInt()
467+ .coerceIn(1 , classCount)
468+ }
469+
470+ private fun GradeReport.examRelativeScore (
471+ adjustedScores : Map <String , Double > = emptyMap(),
472+ ): Double? {
473+ var weightedTotal = 0.0
474+ var totalWeight = 0
475+ subjects.forEachIndexed { index, subject ->
476+ val standard = standardFor(subject, index) ? : return @forEachIndexed
477+ val classAverage = subject.classAverageOrNull() ? : return @forEachIndexed
478+ val spread = standard.relativeSpread() ? : return @forEachIndexed
479+ val score = adjustedScores[cleanSubjectName(subject.subjectName)] ? : subject.scoreValue
480+ val weight = subjectWeight(subject.subjectName)
481+ weightedTotal + = ((score.coerceIn(0.0 , 100.0 ) - classAverage) / spread) * weight
482+ totalWeight + = weight
483+ }
484+ if (totalWeight <= 0 ) return null
485+ return weightedTotal / totalWeight
486+ }
487+
488+ private fun SubjectScore.classAverageOrNull (): Double? =
489+ classAverageDisplay.toDoubleOrNull() ? : classAverage
490+
491+ private fun GradeStandard.relativeSpread (): Double? {
492+ val topBottom = top?.let { topValue ->
493+ bottom?.let { bottomValue -> topValue - bottomValue }
494+ }
495+ if (topBottom != null && topBottom > 0.0 ) return topBottom
496+ val frontBack = front?.let { frontValue ->
497+ back?.let { backValue -> frontValue - backValue }
498+ }
499+ return frontBack?.takeIf { it > 0.0 }
500+ }
501+
292502private fun GradeReport.toTrendPoint (examName : String ): GradeTrendPoint = GradeTrendPoint (
293503 examName = examName.ifBlank { examSummary?.examName.orEmpty().ifBlank { " 考試" } },
294504 weightedAverage = weightedAverage(),
0 commit comments