From 3788d82bdf9afb5f6e6bfc330ef6987af463fe36 Mon Sep 17 00:00:00 2001 From: 0xHarbs Date: Fri, 19 Dec 2025 08:10:34 +0000 Subject: [PATCH] fix: greedy allocation of on calls --- .gitignore | 15 +++++ generate_rota_excel.py | 105 ++++++++++++++++++++++++----- on_call_rota_16people_colored.xlsx | Bin 10054 -> 9892 bytes requirements.txt | 2 + 4 files changed, 106 insertions(+), 16 deletions(-) create mode 100644 .gitignore create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..138b3df --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# Virtual environment +venv/ +env/ +ENV/ + +# Python cache +__pycache__/ +*.py[cod] +*$py.class + +# Excel output files (optional - remove if you want to track them) +*.xlsx + +# OS files +.DS_Store diff --git a/generate_rota_excel.py b/generate_rota_excel.py index 9934c38..3390d67 100644 --- a/generate_rota_excel.py +++ b/generate_rota_excel.py @@ -4,6 +4,7 @@ import openpyxl from openpyxl.styles import PatternFill import random +import math random.seed(42) # Maintain reproducibility across runs # ------------------------------- @@ -105,7 +106,7 @@ ("2026-07-31", "2026-08-05"): "UA" }, "Morgan": { - ("2026-03-16", "2026-06-20"): "UA", + ("2026-03-16", "2026-03-20"): "UA", ("2026-04-10", "2026-04-25"): "UA", ("2026-05-08", "2026-05-10"): "UA" }, @@ -265,11 +266,30 @@ def worked_last_weekend(person, current_saturday): weekend_assigned = defaultdict(int) # ------------------------------- -# 4️⃣ Weekend allocation (improved) +# Helper function for proportional allocation +# ------------------------------- +def calculate_minimum_allocation(target_dict): + """ + Calculate minimum allocation (rounded up) for each person. + This ensures everyone gets at least their proportional share. + """ + return {person: math.ceil(target) for person, target in target_dict.items()} + +def has_reached_minimum(person, assigned_count, minimum_dict): + """Check if person has reached their minimum allocation.""" + return assigned_count >= minimum_dict.get(person, 0) + +def all_have_minimum(people_list, assigned_dict, minimum_dict): + """Check if all people in the list have reached their minimum allocation.""" + return all(assigned_dict.get(p, 0) >= minimum_dict.get(p, 0) for p in people_list) + +# ------------------------------- +# 4️⃣ Weekend allocation (improved with two-phase approach) # ------------------------------- weekend_blocks = [(d, d + timedelta(days=1)) for d in all_dates if d.weekday() == 5 and (d + timedelta(days=1)) <= end_date] total_weekends = len(weekend_blocks) * 2 target_weekends = {p: total_weekends * (fte[p] / total_fte) for p in people} +minimum_weekends = calculate_minimum_allocation(target_weekends) weekend_assigned = defaultdict(int) last_weekend_assigned = {} @@ -278,20 +298,38 @@ def worked_last_weekend(person, current_saturday): for sat, sun in weekend_blocks: - # Recompute eligibility dynamically for this weekend + # Phase 1: Prioritize people below minimum allocation + # Phase 2: Once everyone has minimum, distribute fairly by ratio + + # Check if all in each group have reached minimum + sm_all_have_min = all_have_minimum(southmead_group, weekend_assigned, minimum_weekends) + uh_all_have_min = all_have_minimum(uhbw_group, weekend_assigned, minimum_weekends) + + # Southmead eligibility eligible_sm = [ p for p in southmead_group if all(d.strftime("%Y-%m-%d") not in unavailable.get(p, {}) for d in [sat, sun]) and last_weekend_assigned.get(p) != sat - timedelta(days=7) - and weekend_assigned[p] < target_weekends[p] ] + + # Filter by minimum if not everyone has reached it + if not sm_all_have_min: + below_min_sm = [p for p in eligible_sm if not has_reached_minimum(p, weekend_assigned[p], minimum_weekends)] + if below_min_sm: + eligible_sm = below_min_sm + # UHBW eligibility eligible_uh = [ p for p in uhbw_group if all(d.strftime("%Y-%m-%d") not in unavailable.get(p, {}) for d in [sat, sun]) and last_weekend_assigned.get(p) != sat - timedelta(days=7) - and weekend_assigned[p] < target_weekends[p] ] + + # Filter by minimum if not everyone has reached it + if not uh_all_have_min: + below_min_uh = [p for p in eligible_uh if not has_reached_minimum(p, weekend_assigned[p], minimum_weekends)] + if below_min_uh: + eligible_uh = below_min_uh # Fallback: use cannot_swap_weekend_site if no eligible people if not eligible_sm: @@ -316,7 +354,7 @@ def worked_last_weekend(person, current_saturday): # ------------------------------- -# 5a️⃣ Bank holiday allocation (separate from weekdays) +# 5a️⃣ Bank holiday allocation (with two-phase approach) # ------------------------------- bank_holiday_rota = {} bank_holiday_assigned_sm = defaultdict(int) @@ -324,14 +362,24 @@ def worked_last_weekend(person, current_saturday): total_bh = len(bank_holidays)*2 target_bh = {p: total_bh*(fte[p]/total_fte) for p in people} +minimum_bh = calculate_minimum_allocation(target_bh) for bh_date_str, bh_name in bank_holidays.items(): bh_date = datetime.strptime(bh_date_str, "%Y-%m-%d") + # Check if all in each group have reached minimum + sm_all_have_min_bh = all_have_minimum(southmead_group, bank_holiday_assigned_sm, minimum_bh) + uh_all_have_min_bh = all_have_minimum(uhbw_group, bank_holiday_assigned_uhbw, minimum_bh) + # Southmead available_sm = [p for p in southmead_group if bh_date_str not in unavailable.get(p, {}) and bh_date_str not in oncall_protection.get(p, set())] + # Phase 1: Prioritize people below minimum + if not sm_all_have_min_bh: + below_min_sm = [p for p in available_sm if not has_reached_minimum(p, bank_holiday_assigned_sm[p], minimum_bh)] + if below_min_sm: + available_sm = below_min_sm # Use tie-breaker: fewest BH shifts / FTE, then fewest weekend shifts / FTE, then random to avoid systematic bias chosen_sm = min( @@ -343,11 +391,17 @@ def worked_last_weekend(person, current_saturday): ) # UHBW - available_uh = [p for p in uhbw_group if bh_date_str not in unavailable.get(p, {}) and bh_date_str not in oncall_protection.get(p, set()) and p != chosen_sm] + + # Phase 1: Prioritize people below minimum + if not uh_all_have_min_bh: + below_min_uh = [p for p in available_uh if not has_reached_minimum(p, bank_holiday_assigned_uhbw[p], minimum_bh)] + if below_min_uh: + available_uh = below_min_uh + chosen_uh = min( available_uh, key=lambda x: (bank_holiday_assigned_uhbw[x]/fte[x], @@ -366,12 +420,13 @@ def worked_last_weekend(person, current_saturday): #------------- -# 5.7 Friday allocation +# 5.7 Friday allocation (with two-phase approach) #--------------- total_fridays = sum(1 for d in all_dates if d.weekday() == 4) full_time_target_friday = total_fridays / total_fte target_fridays = {p: full_time_target_friday*fte[p] for p in people} +minimum_fridays = calculate_minimum_allocation(target_fridays) assigned_friday_counts = defaultdict(int) rota = {} @@ -385,22 +440,30 @@ def worked_last_weekend(person, current_saturday): week_num = ((d - start_date).days // 7) % 2 week_key = "week1" if week_num == 0 else "week2" + # Check if everyone has reached minimum + all_have_min_friday = all_have_minimum(people, assigned_friday_counts, minimum_fridays) + available_people = [ p for p in people if ( - assigned_friday_counts[p] < target_fridays[p] #\Anyone can do Friday even if not in working pattern - and date_str not in unavailable.get(p,{}) + date_str not in unavailable.get(p,{}) and date_str not in oncall_protection.get(p,set()) ) ] + # Phase 1: Prioritize people below minimum + if not all_have_min_friday: + below_min = [p for p in available_people if not has_reached_minimum(p, assigned_friday_counts[p], minimum_fridays)] + if below_min: + available_people = below_min + if not available_people: - working_people = [ + # Extreme fallback + available_people = [ p for p in people - if date_str not in unavailable.get(p,{}) #anyone can do Friday even if not working pattern + if date_str not in unavailable.get(p,{}) and date_str not in oncall_protection.get(p,set()) ] - available_people = sorted(working_people, key=lambda x: assigned_friday_counts[x]/fte[x]) chosen = min(available_people, key=lambda x: (assigned_friday_counts[x]/fte[x], @@ -418,11 +481,12 @@ def worked_last_weekend(person, current_saturday): # ------------------------------- -# 5️⃣ Weekday allocation (two-week rolling) +# 5️⃣ Weekday allocation (with two-phase approach) # ------------------------------- total_weekdays = sum(1 for d in all_dates if d.weekday() < 4) full_time_target = total_weekdays / total_fte target_shifts = {p: full_time_target*fte[p] for p in people} +minimum_shifts = calculate_minimum_allocation(target_shifts) assigned_weekday_counts = defaultdict(int) @@ -435,17 +499,26 @@ def worked_last_weekend(person, current_saturday): week_num = ((d - start_date).days // 7) % 2 week_key = "week1" if week_num == 0 else "week2" + # Check if everyone has reached minimum + all_have_min_weekday = all_have_minimum(people, assigned_weekday_counts, minimum_shifts) + available_people = [ p for p in people if ( d.weekday() in work_schedule[p][week_key] - and assigned_weekday_counts[p] < target_shifts[p] and date_str not in unavailable.get(p,{}) and date_str not in oncall_protection.get(p,set()) ) ] + # Phase 1: Prioritize people below minimum + if not all_have_min_weekday: + below_min = [p for p in available_people if not has_reached_minimum(p, assigned_weekday_counts[p], minimum_shifts)] + if below_min: + available_people = below_min + if not available_people: + # Fallback: ignore minimum requirement if no one available working_people = [ p for p in people if d.weekday() in work_schedule[p][week_key] @@ -454,7 +527,7 @@ def worked_last_weekend(person, current_saturday): ] available_people = sorted(working_people, key=lambda x: assigned_weekday_counts[x]/fte[x]) - if not available_people: # Extreme case: no one available, mark date - HANNAH - this was causing a crash for some reason + if not available_people: # Extreme case: no one available, mark date rota[date_str] = "No One Available" print(f"⚠️ No one available for {date_str} (weekday)") continue diff --git a/on_call_rota_16people_colored.xlsx b/on_call_rota_16people_colored.xlsx index ff85622da6992063a70a5e9dbeaca892315dafbe..655c9bea0b202e9a8a246c35fa5e5bdc55ae9fb7 100644 GIT binary patch delta 5886 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 E08oti<^TWy delta 6043 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 Jw&)+d{{z5QKC%D+ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5f59617 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +pandas>=2.3.3 +openpyxl>=3.1.5