From 0b9bab3e56e3ddee7e5be9f62f6dfcbe7688ef07 Mon Sep 17 00:00:00 2001 From: Devin Stickells Date: Mon, 19 Aug 2024 20:43:33 +0200 Subject: [PATCH] feat: rework scoring system --- lib/models/assignment_generator.dart | 372 +++++++++++++++++++++------ 1 file changed, 294 insertions(+), 78 deletions(-) diff --git a/lib/models/assignment_generator.dart b/lib/models/assignment_generator.dart index c64b1d9..d0d3b75 100644 --- a/lib/models/assignment_generator.dart +++ b/lib/models/assignment_generator.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'dart:math'; import 'roster.dart'; @@ -5,10 +6,34 @@ import 'doctor.dart'; import 'shift.dart'; class ScoredRoster { - double score; Roster roster; + double totalScore; + double hoursStdevScore; + double weekendCallsStdevScore; + double weekdayCallsStdevScore; + double weekdayCaesarCoverStdevScore; + double weekdaySecondOnCallStdevScore; + double meanCallSpreadScore; + double meanCallSpreadStdevScore; + double callSpreadStdevScore; + + ScoredRoster( + this.roster, + this.totalScore, [ + this.hoursStdevScore = 0, + this.weekendCallsStdevScore = 0, + this.weekdayCallsStdevScore = 0, + this.weekdayCaesarCoverStdevScore = 0, + this.weekdaySecondOnCallStdevScore = 0, + this.meanCallSpreadScore = 0, + this.meanCallSpreadStdevScore = 0, + this.callSpreadStdevScore = 0, + ]); - ScoredRoster(this.score, this.roster); + @override + String toString() { + return 'ScoredRoster(roster: $roster, totalScore: $totalScore, hoursStdevScore: $hoursStdevScore, weekendCallsStdevScore: $weekendCallsStdevScore, weekdayCallsStdevScore: $weekdayCallsStdevScore, weekdayCaesarCoverStdevScore: $weekdayCaesarCoverStdevScore, weekdaySecondOnCallStdevScore: $weekdaySecondOnCallStdevScore, meanCallSpreadScore: $meanCallSpreadScore, meanCallSpreadStdevScore: $meanCallSpreadStdevScore, callSpreadStdevScore: $callSpreadStdevScore)'; + } } class AssignmentGenerator { @@ -83,7 +108,7 @@ class AssignmentGenerator { List topScoredRosters = List.generate( outputs, (_) => ScoredRoster( - double.infinity, Roster(doctors: doctors, shifts: shifts))); + Roster(doctors: doctors, shifts: shifts), double.infinity)); int validRostersFound = 0; Roster candidateRoster = Roster(doctors: doctors, shifts: shifts); @@ -96,11 +121,11 @@ class AssignmentGenerator { if (filled) { validRostersFound++; - double score = _calculateScore(candidateRoster); - if (score < topScoredRosters[outputs - 1].score) { + double score = _calculateScoreDetailed(candidateRoster).totalScore; + if (score < topScoredRosters[outputs - 1].totalScore) { topScoredRosters[outputs - 1] = - ScoredRoster(score, candidateRoster.copy()); - topScoredRosters.sort((a, b) => a.score.compareTo(b.score)); + ScoredRoster(candidateRoster.copy(), score); + topScoredRosters.sort((a, b) => a.totalScore.compareTo(b.totalScore)); } } } @@ -111,6 +136,10 @@ class AssignmentGenerator { if (validRostersFound / retries < 0.1) { print('< 10% of roster permutations were valid'); } + List detailedScores = topScoredRosters + .map((scoredRoster) => _calculateScoreDetailed(scoredRoster.roster)) + .toList(); + print(detailedScores); return topScoredRosters .map((scoredRoster) => scoredRoster.roster) .toList(); @@ -299,24 +328,20 @@ class AssignmentGenerator { } } - double _calculateScore(Roster roster, - [double hoursVarianceWeight = 1.0, - double weekendCallsVarianceWeight = 1.0, - double weekdayCallsVarianceWeight = 1.0, - double weekdayCaesarCoverVarianceWeight = 1.0, - double weekdaySecondOnCallVarianceWeight = 1.0, - double callSpreadWeight = 1.0]) { - // Calculate the variance of the weekend / PH calls among doctors + num _calculateWeekendCallsStdev(Roster roster) { + // Calculate the standard deviation of the weekend / PH calls among doctors double meanWeekendCalls = roster.doctors.fold(0.0, (sum, doctor) => sum + doctor.weekendCalls) / roster.doctors.length; - double weekendCallsVariance = roster.doctors.fold( - 0.0, - (sum, doctor) => - sum + pow(doctor.weekendCalls - meanWeekendCalls, 2)) / - roster.doctors.length; - - // Normalize the variance by the number of weekend days + num weekendCallsStdev = pow( + roster.doctors.fold( + 0.0, + (sum, doctor) => + sum + pow(doctor.weekendCalls - meanWeekendCalls, 2)) / + roster.doctors.length, + 0.5); + + // Normalize the standard deviation by the number of weekend days int weekendDays = 0; for (Shift shift in roster.shifts) { if (shift.type == 'Weekend' || shift.type == 'Holiday') { @@ -325,20 +350,32 @@ class AssignmentGenerator { } if (weekendDays != 0) { - weekendCallsVariance /= weekendDays; + weekendCallsStdev /= weekendDays; } - // Calculate the variance of overtime hours among doctors + return weekendCallsStdev; + } + + num _calculateHoursStdev(Roster roster) { + // Calculate the standard deviation of overtime hours among doctors double meanHours = roster.doctors.fold(0.0, (sum, doctor) => sum + doctor.overtimeHours) / roster.doctors.length; - double hoursVariance = roster.doctors.fold(0.0, - (sum, doctor) => sum + pow(doctor.overtimeHours - meanHours, 2)) / - roster.doctors.length; + num hoursStdev = pow( + roster.doctors.fold( + 0.0, + (sum, doctor) => + sum + pow(doctor.overtimeHours - meanHours, 2)) / + roster.doctors.length, + 0.5); - hoursVariance /= maxOvertimeHours; + hoursStdev /= maxOvertimeHours; + + return hoursStdev; + } - // Calculate the variance of weekday calls among doctors + // Calculate the standard deviation of weekday calls among doctors + num _calculateWeekdayCallsStdev(Roster roster, int weekendDays) { double meanWeekdayCalls = roster.doctors.fold( 0.0, (sum, doctor) => @@ -348,21 +385,26 @@ class AssignmentGenerator { doctor.secondOnCallWeekdayCalls) / roster.doctors.length; - double weekdayCallsVariance = roster.doctors.fold( - 0.0, - (sum, doctor) => - sum + - pow( - doctor.overnightWeekdayCalls + - doctor.caesarCoverWeekdayCalls + - doctor.secondOnCallWeekdayCalls - - meanWeekdayCalls, - 2)) / - roster.doctors.length; - - weekdayCallsVariance /= 30 - weekendDays; + num weekdayCallsStdev = pow( + roster.doctors.fold( + 0.0, + (sum, doctor) => + sum + + pow( + doctor.overnightWeekdayCalls + + doctor.caesarCoverWeekdayCalls + + doctor.secondOnCallWeekdayCalls - + meanWeekdayCalls, + 2)) / + roster.doctors.length, + 0.5); + + weekdayCallsStdev /= 30 - weekendDays; + return weekdayCallsStdev; + } - // Calculate the variance of weekday Caesar Cover calls among doctors +// Calculate the standard deviation of weekday Caesar Cover calls among doctors + num _calculateCaesarCoverStdev(Roster roster, int weekendDays) { double meanCaesarCoverCalls = roster.doctors.fold( 0.0, (sum, doctor) => @@ -371,37 +413,46 @@ class AssignmentGenerator { doctor.caesarCoverWeekendCalls) / roster.doctors.length; - double weekdayCaesarCoverVariance = roster.doctors.fold( - 0.0, - (sum, doctor) => - sum + - pow( - doctor.caesarCoverWeekdayCalls + - doctor.caesarCoverWeekendCalls - - meanCaesarCoverCalls, - 2)) / - roster.doctors.length; - - weekdayCaesarCoverVariance /= 30 - weekendDays; + num weekdayCaesarCoverStdev = pow( + roster.doctors.fold( + 0.0, + (sum, doctor) => + sum + + pow( + doctor.caesarCoverWeekdayCalls + + doctor.caesarCoverWeekendCalls - + meanCaesarCoverCalls, + 2)) / + roster.doctors.length, + 0.5); + + weekdayCaesarCoverStdev /= 30 - weekendDays; + return weekdayCaesarCoverStdev; + } - // Calculate the variance of weekday Second On Call calls among doctors +// Calculate the standard deviation of weekday Second On Call calls among doctors + num _calculateSecondOnCallStdev(Roster roster, int weekendDays) { double meanSecondOnCallCalls = roster.doctors .fold(0.0, (sum, doctor) => sum + doctor.secondOnCallWeekdayCalls) / roster.doctors.length; - double weekdaySecondOnCallVariance = roster.doctors.fold( - 0.0, - (sum, doctor) => - sum + - pow(doctor.secondOnCallWeekdayCalls - meanSecondOnCallCalls, - 2)) / - roster.doctors.length; - - weekdaySecondOnCallVariance /= 30 - weekendDays; + num weekdaySecondOnCallStdev = pow( + roster.doctors.fold( + 0.0, + (sum, doctor) => + sum + + pow(doctor.secondOnCallWeekdayCalls - meanSecondOnCallCalls, + 2)) / + roster.doctors.length, + 0.5); + + weekdaySecondOnCallStdev /= 30 - weekendDays; + return weekdaySecondOnCallStdev; + } - // Calculate the spread of calls over the month for each doctor (ideally want them as evenly-spaced as possible) - // This is a matter of maximising the space between each call for each doctor - larger is better - double callSpread = 0.0; +// Calculate the spread of calls over the month for each doctor + List _calculateDoctorsCallSpreadStdevs(Roster roster) { + List doctorsCallSpreadStdevs = []; for (Doctor doctor in roster.doctors) { List callDates = []; for (Shift shift in roster.shifts) { @@ -412,23 +463,188 @@ class AssignmentGenerator { callDates.add(shift.date); } } + callDates.sort(); + List doctorCallSpreads = []; + for (int i = 1; i < callDates.length; i++) { + doctorCallSpreads.add(callDates[i].difference(callDates[i - 1]).inDays); + } + double doctorMeanCallSpread = + doctorCallSpreads.fold(0, (sum, spread) => sum + spread) / + doctorCallSpreads.length; + doctorsCallSpreadStdevs.add(pow( + doctorCallSpreads.fold( + 0.0, + (sum, spread) => + sum + pow(spread - doctorMeanCallSpread, 2)) / + doctorCallSpreads.length, + 0.5)); + } + return doctorsCallSpreadStdevs; + } +// Calculate the mean call spread for each doctor + List _calculateDoctorsMeanCallSpreads(Roster roster) { + List doctorsMeanCallSpreads = []; + for (Doctor doctor in roster.doctors) { + List callDates = []; + for (Shift shift in roster.shifts) { + if (shift.mainDoctor == doctor || + shift.caesarCoverDoctor == doctor || + shift.secondOnCallDoctor == doctor || + shift.weekendDayDoctor == doctor) { + callDates.add(shift.date); + } + } callDates.sort(); + List doctorCallSpreads = []; for (int i = 1; i < callDates.length; i++) { - callSpread += callDates[i].difference(callDates[i - 1]).inDays; + doctorCallSpreads.add(callDates[i].difference(callDates[i - 1]).inDays); } + double doctorMeanCallSpread = + doctorCallSpreads.fold(0, (sum, spread) => sum + spread) / + doctorCallSpreads.length; + doctorsMeanCallSpreads.add(doctorMeanCallSpread); } + return doctorsMeanCallSpreads; + } + +// Calculate the maximum possible spread of calls over the roster + double _calculateMaxSpread(Roster roster) { + int weekendDays = _calculateWeekendDays(roster); + int rolesPerShiftWeekend = 4; + int rolesPerShiftWeekday = 3; + int totalRoles = rolesPerShiftWeekend * weekendDays + + rolesPerShiftWeekday * (30 - weekendDays); + double rolesPerShift = totalRoles / roster.doctors.length; + double maxSpread = totalRoles / (roster.doctors.length * rolesPerShift); + return maxSpread; + } + +// Calculate the spread of calls over the month for each doctor + double _calculateMeanDoctorsCallSpreadStdev( + List doctorsCallSpreadStdevs) { + double meanDoctorsCallSpreadStdev = + doctorsCallSpreadStdevs.fold(0.0, (sum, stdev) => sum + stdev) / + doctorsCallSpreadStdevs.length; + return meanDoctorsCallSpreadStdev; + } - // Normalize the call spread, and to make it a score, subtract it from 1 - double maxSpread = 30 * roster.shifts.length / roster.doctors.length; - callSpread = 1 - (callSpread / maxSpread); +// Calculate the mean call spread + double _calculateMeanCallSpread( + List doctorsMeanCallSpreads, double maxSpread) { + double meanDoctorsMeanCallSpread = + doctorsMeanCallSpreads.fold(0.0, (sum, spread) => sum + spread) / + doctorsMeanCallSpreads.length; + double meanCallSpread = (maxSpread - meanDoctorsMeanCallSpread) / maxSpread; + return meanCallSpread; + } + +// Calculate the standard deviation of the mean call spreads + num _calculateMeanCallSpreadStdev( + List doctorsMeanCallSpreads, double meanDoctorsMeanCallSpread) { + num meanCallSpreadStdev = pow( + doctorsMeanCallSpreads.fold( + 0.0, + (sum, meanSpread) => + sum + pow(meanSpread - meanDoctorsMeanCallSpread, 2)) / + doctorsMeanCallSpreads.length, + 0.5); + return meanCallSpreadStdev; + } + +// Calculate the standard deviation of call spread + num _calculateCallSpreadStdev(List doctorsCallSpreadStdevs, + double meanDoctorsCallSpreadStdev, double maxSpread) { + num callSpreadStdev = pow( + doctorsCallSpreadStdevs.fold( + 0.0, + (sum, stdev) => + sum + pow(stdev - meanDoctorsCallSpreadStdev, 2)) / + doctorsCallSpreadStdevs.length, + 0.5); + callSpreadStdev = callSpreadStdev / maxSpread; + return callSpreadStdev; + } + + int _calculateWeekendDays(Roster roster) { + return roster.shifts + .where((shift) => shift.type == 'Weekend' || shift.type == 'Holiday') + .length; + } - return hoursVarianceWeight * hoursVariance + - weekendCallsVarianceWeight * weekendCallsVariance + - weekdayCallsVarianceWeight * weekdayCallsVariance + - weekdayCaesarCoverVarianceWeight * weekdayCaesarCoverVariance + - weekdaySecondOnCallVarianceWeight * weekdaySecondOnCallVariance + - callSpreadWeight * callSpread; + ScoredRoster _calculateScoreDetailed(Roster roster, + [double hoursStdevWeight = 50.0, + double weekendCallsStdevWeight = 1.0, + double weekdayCallsStdevWeight = 1.0, + double weekdayCaesarCoverStdevWeight = 1.0, + double weekdaySecondOnCallStdevWeight = 1.0, + double meanCallSpreadWeight = 0.0, + double meanCallSpreadStdevWeight = 1.0, + double callSpreadStdevWeight = 1.0]) { + num weekendCallsStdev = _calculateWeekendCallsStdev(roster); + num hoursStdev = _calculateHoursStdev(roster); + + int weekendDays = _calculateWeekendDays(roster); + + num weekdayCallsStdev = _calculateWeekdayCallsStdev(roster, weekendDays); + num weekdayCaesarCoverStdev = + _calculateCaesarCoverStdev(roster, weekendDays); + num weekdaySecondOnCallStdev = + _calculateSecondOnCallStdev(roster, weekendDays); + + List doctorsCallSpreadStdevs = + _calculateDoctorsCallSpreadStdevs(roster); + List doctorsMeanCallSpreads = + _calculateDoctorsMeanCallSpreads(roster); + + double maxSpread = _calculateMaxSpread(roster); + + double meanDoctorsCallSpreadStdev = + _calculateMeanDoctorsCallSpreadStdev(doctorsCallSpreadStdevs); + double meanCallSpread = + _calculateMeanCallSpread(doctorsMeanCallSpreads, maxSpread); + + double meanDoctorsMeanCallSpread = + doctorsMeanCallSpreads.fold(0.0, (sum, spread) => sum + spread) / + doctorsMeanCallSpreads.length; + num meanCallSpreadStdev = _calculateMeanCallSpreadStdev( + doctorsMeanCallSpreads, meanDoctorsMeanCallSpread); + + num callSpreadStdev = _calculateCallSpreadStdev( + doctorsCallSpreadStdevs, meanDoctorsCallSpreadStdev, maxSpread); + + double hoursStdevScore = hoursStdevWeight * hoursStdev; + double weekendCallsStdevScore = weekendCallsStdevWeight * weekendCallsStdev; + double weekdayCallsStdevScore = weekdayCallsStdevWeight * weekdayCallsStdev; + double weekdayCaesarCoverStdevScore = + weekdayCaesarCoverStdevWeight * weekdayCaesarCoverStdev; + double weekdaySecondOnCallStdevScore = + weekdaySecondOnCallStdevWeight * weekdaySecondOnCallStdev; + double meanCallSpreadScore = meanCallSpreadWeight * meanCallSpread; + double meanCallSpreadStdevScore = + meanCallSpreadStdevWeight * meanCallSpreadStdev; + double callSpreadStdevScore = callSpreadStdevWeight * callSpreadStdev; + + double totalScore = hoursStdevScore + + weekendCallsStdevScore + + weekdayCallsStdevScore + + weekdayCaesarCoverStdevScore + + weekdaySecondOnCallStdevScore + + meanCallSpreadScore + + meanCallSpreadStdevScore + + callSpreadStdevScore; + + return ScoredRoster( + roster, + totalScore, + hoursStdevScore, + weekendCallsStdevScore, + weekdayCallsStdevScore, + weekdayCaesarCoverStdevScore, + weekdaySecondOnCallStdevScore, + meanCallSpreadScore, + meanCallSpreadStdevScore, + callSpreadStdevScore); } AssignmentGenerator copy() {