diff --git a/rota/leave.py b/leave.py
similarity index 100%
rename from rota/leave.py
rename to leave.py
diff --git a/output/rota.csv b/output/rota.csv
new file mode 100644
index 0000000..e36cc4e
--- /dev/null
+++ b/output/rota.csv
@@ -0,0 +1,95 @@
+"Name","Madalina Drumea","Layla Bell","Alex wood","Max Ireland","Seren Peters","Charles Finan","Paul Ward","Delilah Trimmer","Tom Welsh","Kyaw Tint","Maththew O'Brien","Max Finzel","Zainab Sharaf","Jean Skumar","Michael Tomek","Dhuvresh Patel","Nicolas Dziadulewicz","Vilim Kalamar","Georgina Edwards","Jenn Haw fong","Paul Jenkins","Amoolya Mannava","Rebecca Murphy","Ross Kruger","Fiona Lyall","Wijhdan Abusrewil","Andrew MacCormick","Joel Lim","Mark Haley","Alexander Sanchez - cabello","Chong Yew Ng","Matthew Thorley","Sayed Hashim Alqarooni","Harriet Conley","Jon Skinner","Alexander Wijnberg","Cameron Bullock","Alan Eccles","Christian Greer","Hannah Lewis","Amina Odeh","Sarath Vennam","Mo Babsail","Richard Chaytor","Sherafghan PROC"
+"Site","barnstaple","exeter","exeter","exeter","exeter","exeter","exeter","exeter","exeter","plymouth","plymouth","plymouth","plymouth","plymouth","plymouth","plymouth","plymouth","plymouth","plymouth","plymouth","plymouth","plymouth","plymouth","plymouth","plymouth_twilights","torbay","torbay","torbay","torbay","torbay","torbay","torbay","torbay","torbay","torbay twilights","truro","truro","truro","truro","truro","truro","truro","truro","truro","truro no proc"
+"Grade","3","2","3","3","3","4","4","5","5","2","2","2","2","3","3","3","3","3","4","4","4","5","5","5","6","2","2","2","2","3","4","4","4","5","6","2","2","3","3","3","4","4","5","5","5"
+"FTE","80","100","100","100","100","100","100","80","100","100","100","100","100","60","80","100","100","100","80","100","100","100","100","100","60","100","100","100","100","100","100","100","100","60","100","100","100","80","100","100","100","100","100","100","100"
+"Week 1 Day Mon","","","","","","exeter_twilight","","","","","night_weekday","","","","","","night_weekday","","","","","plymouth_twilight","","","","","","","torbay_twilight","","","","","","","","","","","","","night_weekday","","","truro_twilight"
+"Week 1 Day Tue","","","","","","","","exeter_twilight","","","night_weekday","","plymouth_twilight","","","","night_weekday","","","","","","","","","","","","","","","","","torbay_twilight","","","","","","","","night_weekday","","","truro_twilight"
+"Week 1 Day Wed","","","","","","exeter_twilight","","","","","night_weekday","","plymouth_twilight","","","","night_weekday","","","","","","","","","","","","torbay_twilight","","","","","","","","","","","","","night_weekday","truro_twilight","",""
+"Week 1 Day Thu","","","","","exeter_twilight","","","","","","night_weekday","","","","","","night_weekday","","","","plymouth_twilight","","","","","","","torbay_twilight","","","","","","","","","","","","","","night_weekday","truro_twilight","",""
+"Week 1 Day Fri","","","night_weekend","weekend_exeter","","","","","","","","","","plymouth_twilight","","night_weekend","","","","","","","","","","","","","","","","","","weekend_torbay","","","","night_weekend","","","weekend_truro","","","",""
+"Week 1 Day Sat","","","night_weekend","weekend_exeter","","","","","","","","","weekend_plymouth1","","weekend_plymouth2","night_weekend","","","","","","","","","","","","","","","","","","weekend_torbay","","","","night_weekend","","","weekend_truro","","","",""
+"Week 1 Day Sun","","","night_weekend","weekend_exeter","","","","","","","","","weekend_plymouth1","","weekend_plymouth2","night_weekend","","","","","","","","","","","","","","","","","","weekend_torbay","","","","night_weekend","","","weekend_truro","","","",""
+"Week 2 Day Mon","","","","","","","exeter_twilight","","","","","","","","","","","plymouth_twilight","night_weekday","","","","","","","","night_weekday","night_weekday","torbay_twilight","","","","","","","","","","","","","truro_twilight","","",""
+"Week 2 Day Tue","","","","","","","exeter_twilight","","","","","","","","","","plymouth_twilight","","night_weekday","","","","","","","","night_weekday","night_weekday","torbay_twilight","","","","","","","","","","","","","","truro_twilight","",""
+"Week 2 Day Wed","","","exeter_twilight","","","","","","","","","","","","","","","","night_weekday","","plymouth_twilight","","","","","","night_weekday","night_weekday","torbay_twilight","","","","","","","","","","","","","","","truro_twilight",""
+"Week 2 Day Thu","","","exeter_twilight","","","","","","","","","","","","","","","","night_weekday","","","","","","plymouth_twilight","","night_weekday","night_weekday","torbay_twilight","","","","","","","","truro_twilight","","","","","","","",""
+"Week 2 Day Fri","","","","","","night_weekend","weekend_exeter","","","","","","","","","plymouth_twilight","","night_weekend","","","","","","","","","","","","","weekend_torbay","","night_weekend","","","","","","","","","weekend_truro","","",""
+"Week 2 Day Sat","","","","","","night_weekend","weekend_exeter","","","","weekend_plymouth2","","","","","","","night_weekend","","","","","weekend_plymouth1","","","","","","","","weekend_torbay","","night_weekend","","","","","","","","","weekend_truro","","",""
+"Week 2 Day Sun","","","","","","night_weekend","weekend_exeter","","","","weekend_plymouth2","","","","","","","night_weekend","","","","","weekend_plymouth1","","","","","","","","weekend_torbay","","night_weekend","","","","","","","","","weekend_truro","","",""
+"Week 3 Day Mon","","","exeter_twilight","","","","","","","night_weekday","","","","","plymouth_twilight","night_weekday","","","","","","","","","","","","torbay_twilight","","","","","","","","night_weekday","","","","truro_twilight","","","","",""
+"Week 3 Day Tue","","","exeter_twilight","","","","","","","night_weekday","","plymouth_twilight","","","","night_weekday","","","","","","","","","","","","torbay_twilight","","","","","","","","night_weekday","","","truro_twilight","","","","","",""
+"Week 3 Day Wed","","","","","","exeter_twilight","","","","night_weekday","","","plymouth_twilight","","","night_weekday","","","","","","","","","","","","torbay_twilight","","","","","","","","night_weekday","","","","","","","truro_twilight","",""
+"Week 3 Day Thu","","","","","exeter_twilight","","","","","night_weekday","","","","","","night_weekday","","","plymouth_twilight","","","","","","","","","torbay_twilight","","","","","","","","night_weekday","","","","","","","truro_twilight","",""
+"Week 3 Day Fri","","weekend_exeter","","","","","","","","","","","","","","","","plymouth_twilight","","night_weekend","","night_weekend","","","","","","","night_weekend","weekend_torbay","","","","","","","","","","","","","","","weekend_truro"
+"Week 3 Day Sat","","weekend_exeter","","","","","","","","","","","","weekend_plymouth2","weekend_plymouth1","","","","","night_weekend","","night_weekend","","","","","","","night_weekend","weekend_torbay","","","","","","","","","","","","","","","weekend_truro"
+"Week 3 Day Sun","","weekend_exeter","","","","","","","","","","","","weekend_plymouth2","weekend_plymouth1","","","","","night_weekend","","night_weekend","","","","","","","night_weekend","weekend_torbay","","","","","","","","","","","","","","","weekend_truro"
+"Week 4 Day Mon","","","","","","exeter_twilight","","night_weekday","","","","","","","","","","","","","","","plymouth_twilight","","","","","","","","","","night_weekday","","torbay_twilight","truro_twilight","","","","night_weekday","","","","",""
+"Week 4 Day Tue","","","exeter_twilight","","","","","night_weekday","","","","","","","","plymouth_twilight","","","","","","","","","","","","","","","","torbay_twilight","night_weekday","","","","","","","night_weekday","","truro_twilight","","",""
+"Week 4 Day Wed","","","","exeter_twilight","","","","night_weekday","","","","","","","","plymouth_twilight","","","","","","","","","","","","","torbay_twilight","","","","night_weekday","","","","","","","night_weekday","","","","","truro_twilight"
+"Week 4 Day Thu","","","","","","","","night_weekday","exeter_twilight","plymouth_twilight","","","","","","","","","","","","","","","","","","","","","","","night_weekday","","torbay_twilight","","","","","night_weekday","","","","","truro_twilight"
+"Week 4 Day Fri","","","","","weekend_exeter","","","","","","","","plymouth_twilight","","","","","","","","","","","night_weekend","","","weekend_torbay","","","","","","","","","weekend_truro","","","night_weekend","","","night_weekend","","",""
+"Week 4 Day Sat","","","","","weekend_exeter","","","","","","weekend_plymouth1","","","","","","","","","","","","weekend_plymouth2","night_weekend","","","weekend_torbay","","","","","","","","","weekend_truro","","","night_weekend","","","night_weekend","","",""
+"Week 4 Day Sun","","","","","weekend_exeter","","","","","","weekend_plymouth1","","","","","","","","","","","","weekend_plymouth2","night_weekend","","","weekend_torbay","","","","","","","","","weekend_truro","","","night_weekend","","","night_weekend","","",""
+"Week 5 Day Mon","","exeter_twilight","","","","","night_weekday","","","plymouth_twilight","","","","","","","","","","","","","","","","","","","night_weekday","","","","torbay_twilight","night_weekday","","","","truro_twilight","","","","","","",""
+"Week 5 Day Tue","","","","","","","night_weekday","","exeter_twilight","","","","","","","","","","","","plymouth_twilight","","","","","","","","night_weekday","","","","torbay_twilight","night_weekday","","","","","","","","","truro_twilight","",""
+"Week 5 Day Wed","","","","","","","night_weekday","","exeter_twilight","","","","","","","","","","","","plymouth_twilight","","","","","","","","night_weekday","","","","torbay_twilight","night_weekday","","","","","","","","","","truro_twilight",""
+"Week 5 Day Thu","","","","","exeter_twilight","","night_weekday","","","","","","","","","","","","","","","plymouth_twilight","","","","","","","night_weekday","","","","torbay_twilight","night_weekday","","","","","truro_twilight","","","","","",""
+"Week 5 Day Fri","","","","night_weekend","","","","weekend_exeter","","","","","","","","","","","","","","","","plymouth_twilight","","","","night_weekend","","weekend_torbay","night_weekend","","","","","","","","","","weekend_truro","","","",""
+"Week 5 Day Sat","","","","night_weekend","","","","weekend_exeter","","","","","","","weekend_plymouth2","","","","weekend_plymouth1","","","","","","","","","night_weekend","","weekend_torbay","night_weekend","","","","","","","","","","weekend_truro","","","",""
+"Week 5 Day Sun","","","","night_weekend","","","","weekend_exeter","","","","","","","weekend_plymouth2","","","","weekend_plymouth1","","","","","","","","","night_weekend","","weekend_torbay","night_weekend","","","","","","","","","","weekend_truro","","","",""
+"Week 6 Day Mon","","night_weekday","","","exeter_twilight","","","","night_weekday","","","","","","","","","","","","","plymouth_twilight","night_weekday","","","","torbay_twilight","","","","","","","","","","","truro_twilight","","","","","","",""
+"Week 6 Day Tue","","night_weekday","","","","","exeter_twilight","","night_weekday","","","","","","","","","","","plymouth_twilight","","","night_weekday","","","","torbay_twilight","","","","","","","","","","truro_twilight","","","","","","","",""
+"Week 6 Day Wed","","night_weekday","","","","","exeter_twilight","","night_weekday","","","","","","","","","","","","","","night_weekday","plymouth_twilight","","","torbay_twilight","","","","","","","","","truro_twilight","","","","","","","","",""
+"Week 6 Day Thu","","night_weekday","","","","","","exeter_twilight","night_weekday","","","","","","","","","","","","","plymouth_twilight","night_weekday","","","","torbay_twilight","","","","","","","","","","","","","","","","","truro_twilight",""
+"Week 6 Day Fri","night_weekend","","","","","weekend_exeter","","","","","night_weekend","plymouth_twilight","","","","","plymouth_bank_holidays","","","","night_weekend","","","","","","","","","","","","","weekend_torbay","","","weekend_truro","","","","","","","",""
+"Week 6 Day Sat","night_weekend","","","","","weekend_exeter","","","","weekend_plymouth2","night_weekend","","","","","","","","","","night_weekend","","","weekend_plymouth1","","","","","","","","","","weekend_torbay","","","weekend_truro","","","","","","","",""
+"Week 6 Day Sun","night_weekend","","","","","weekend_exeter","","","","weekend_plymouth2","night_weekend","","","","","","","","","","night_weekend","","","weekend_plymouth1","","","","","","","","","","weekend_torbay","","","weekend_truro","","","","","","","",""
+"Week 7 Day Mon","","","exeter_twilight","","night_weekday","","","","","","","","","","","","","plymouth_bank_holidays","","plymouth_twilight","","","","","","","torbay_twilight","","","night_weekday","night_weekday","","","","","","","truro_twilight","","","","","","",""
+"Week 7 Day Tue","","","","","night_weekday","","exeter_twilight","","","","","","","","","plymouth_twilight","","","","","","","","","","","","","","night_weekday","night_weekday","","","","torbay_twilight","","","truro_twilight","","","","","","",""
+"Week 7 Day Wed","","","","","night_weekday","exeter_twilight","","","","","","","","","","","","plymouth_twilight","","","","","","","","","torbay_twilight","","","night_weekday","night_weekday","","","","","","","","","","truro_twilight","","","",""
+"Week 7 Day Thu","","exeter_twilight","","","night_weekday","","","","","","plymouth_twilight","","","","","","","","","","","","","","","","torbay_twilight","","","night_weekday","night_weekday","","","","","","","","truro_twilight","","","","","",""
+"Week 7 Day Fri","","","","","","","","","weekend_exeter","","","night_weekend","","","","","","","","","","night_weekend","","plymouth_twilight","","","","","weekend_torbay","","","","","","","","","","","weekend_truro","","","","night_weekend",""
+"Week 7 Day Sat","","","","","","","","","weekend_exeter","","","night_weekend","","","","","weekend_plymouth1","","","weekend_plymouth2","","night_weekend","","","","","","","weekend_torbay","","","","","","","","","","","weekend_truro","","","","night_weekend",""
+"Week 7 Day Sun","","","","","","","","","weekend_exeter","","","night_weekend","","","","","weekend_plymouth1","","","weekend_plymouth2","","night_weekend","","","","","","","weekend_torbay","","","","","","","","","","","weekend_truro","","","","night_weekend",""
+"Week 8 Day Mon","night_weekday","","","exeter_twilight","","night_weekday","","","","","","","","night_weekday","plymouth_twilight","","","","","","","","","","","","","","","","torbay_twilight","","","","","","","","truro_twilight","","","","","",""
+"Week 8 Day Tue","night_weekday","","","exeter_twilight","","night_weekday","","","","","","","","night_weekday","","","","","","","","","","plymouth_twilight","","","","","","","torbay_twilight","","","","","","","truro_twilight","","","","","","",""
+"Week 8 Day Wed","night_weekday","","","","","night_weekday","","","exeter_twilight","","","","","night_weekday","","","","","","","","","","plymouth_twilight","","","","torbay_twilight","","","","","","","","","","","","","truro_twilight","","","",""
+"Week 8 Day Thu","night_weekday","","","","exeter_twilight","night_weekday","","","","","","plymouth_twilight","","night_weekday","","","","","","","","","","","","","","torbay_twilight","","","","","","","","","","","","","truro_twilight","","","",""
+"Week 8 Day Fri","","weekend_exeter","","","","","","night_weekend","","","night_weekend","plymouth_twilight","night_weekend","","","","","","","","","","","","","","","","","","","weekend_torbay","","","","","weekend_truro","","","","","","","",""
+"Week 8 Day Sat","","weekend_exeter","","","","","","night_weekend","","","night_weekend","","night_weekend","","","","","","","","weekend_plymouth1","","","weekend_plymouth2","","","","","","","","weekend_torbay","","","","","weekend_truro","","","","","","","",""
+"Week 8 Day Sun","","weekend_exeter","","","","","","night_weekend","","","night_weekend","","night_weekend","","","","","","","","weekend_plymouth1","","","weekend_plymouth2","","","","","","","","weekend_torbay","","","","","weekend_truro","","","","","","","",""
+"Week 9 Day Mon","","","night_weekday","night_weekday","exeter_twilight","","","","","","","plymouth_twilight","","","night_weekday","","","","","","","","","","plymouth_bank_holidays","torbay_twilight","","","","","","","","","","","","","","truro_twilight","","","","",""
+"Week 9 Day Tue","","","night_weekday","night_weekday","","","","","exeter_twilight","plymouth_twilight","","","","","night_weekday","","","","","","","","","","","","","","","","torbay_twilight","","","","","","","","","","truro_twilight","","","",""
+"Week 9 Day Wed","","","night_weekday","night_weekday","","","","","exeter_twilight","","","","","","night_weekday","","","","","","","","","plymouth_twilight","","","","","","","torbay_twilight","","","","","","truro_twilight","","","","","","","",""
+"Week 9 Day Thu","","","night_weekday","night_weekday","exeter_twilight","","","","","","","","","","night_weekday","","","","","","plymouth_twilight","","","","","","","","","","torbay_twilight","","","","","","","","","","","","","truro_twilight",""
+"Week 9 Day Fri","","","","","","","weekend_exeter","","","night_weekend","","","","","","","","plymouth_twilight","","","","","","","","weekend_torbay","night_weekend","","","","","","","","","","","","","weekend_truro","night_weekend","","","",""
+"Week 9 Day Sat","","","","","","","weekend_exeter","","","night_weekend","","","","","","","weekend_plymouth1","","weekend_plymouth2","","","","","","","weekend_torbay","night_weekend","","","","","","","","","","","","","weekend_truro","night_weekend","","","",""
+"Week 9 Day Sun","","","","","","","weekend_exeter","","","night_weekend","","","","","","","weekend_plymouth1","","weekend_plymouth2","","","","","","","weekend_torbay","night_weekend","","","","","","","","","","","","","weekend_truro","night_weekend","","","",""
+"Week 10 Day Mon","","","","","","","","exeter_twilight","","","","","night_weekday","","","","","plymouth_twilight","","","","","night_weekday","","","","","","","","torbay_twilight","night_weekday","","","","","","","","","","","truro_twilight","",""
+"Week 10 Day Tue","","","exeter_twilight","","","","","","","","","","night_weekday","","","","","","","plymouth_twilight","","","night_weekday","","","","","","","","","night_weekday","","","torbay_twilight","truro_twilight","","","","","","","","",""
+"Week 10 Day Wed","","exeter_twilight","","","","","","","","plymouth_twilight","","","night_weekday","","","","","","","","","","night_weekday","","","","","","","","","night_weekday","","","torbay_twilight","","","","","","","truro_twilight","","",""
+"Week 10 Day Thu","","exeter_twilight","","","","","","","","","","","night_weekday","","","","","","","","","plymouth_twilight","night_weekday","","","","","","","","","night_weekday","","","torbay_twilight","","","","","","","","truro_twilight","",""
+"Week 10 Day Fri","","","weekend_exeter","","","","","","","plymouth_twilight","","","","","","night_weekend","","","","","","","","","","","","","","","weekend_torbay","","","","","night_weekend","night_weekend","weekend_truro","","","","","","",""
+"Week 10 Day Sat","","","weekend_exeter","","","","","","","","","weekend_plymouth1","","","","night_weekend","","weekend_plymouth2","","","","","","","","","","","","","weekend_torbay","","","","","night_weekend","night_weekend","weekend_truro","","","","","","",""
+"Week 10 Day Sun","","","weekend_exeter","","","","","","","","","weekend_plymouth1","","","","night_weekend","","weekend_plymouth2","","","","","","","","","","","","","weekend_torbay","","","","","night_weekend","night_weekend","weekend_truro","","","","","","",""
+"Week 11 Day Mon","","","","","","","exeter_twilight","","","","","","","","","","","","","","","plymouth_twilight","","night_weekday","","","","","","night_weekday","","torbay_twilight","","","","","","","night_weekday","","truro_twilight","","","",""
+"Week 11 Day Tue","","exeter_twilight","","","","","","","","","","","","","plymouth_twilight","","","","","","","","","night_weekday","","","","","","night_weekday","","torbay_twilight","","","","","","","night_weekday","","","","","","truro_twilight"
+"Week 11 Day Wed","","","exeter_twilight","","","","","","","","","","","","","","","","","plymouth_twilight","","","","night_weekday","","","","","","night_weekday","","torbay_twilight","","","","","","","night_weekday","","","truro_twilight","","",""
+"Week 11 Day Thu","","exeter_twilight","","","","","","","","","","plymouth_twilight","","","","","","","","","","","","night_weekday","","","","","","night_weekday","","torbay_twilight","","","","","","","night_weekday","","","truro_twilight","","",""
+"Week 11 Day Fri","","","","","weekend_exeter","","","","","","","","","","","","night_weekend","","","","night_weekend","plymouth_twilight","","","","","","weekend_torbay","","","","","","","","","","","","night_weekend","","","","weekend_truro",""
+"Week 11 Day Sat","","","","","weekend_exeter","","","","","","weekend_plymouth2","","","","","","night_weekend","","weekend_plymouth1","","night_weekend","","","","","","","weekend_torbay","","","","","","","","","","","","night_weekend","","","","weekend_truro",""
+"Week 11 Day Sun","","","","","weekend_exeter","","","","","","weekend_plymouth2","","","","","","night_weekend","","weekend_plymouth1","","night_weekend","","","","","","","weekend_torbay","","","","","","","","","","","","night_weekend","","","","weekend_truro",""
+"Week 12 Day Mon","","","","","","","","exeter_twilight","","plymouth_twilight","","","","","","","","","","","","","","","","night_weekday","","","","torbay_twilight","","","","","","","","","truro_twilight","","night_weekday","","night_weekday","",""
+"Week 12 Day Tue","","","","exeter_twilight","","","","","","","","","","","","","","plymouth_twilight","","","","","","","","night_weekday","","","","torbay_twilight","","","","","","","","truro_twilight","","","night_weekday","","night_weekday","",""
+"Week 12 Day Wed","","","","","","","","","exeter_twilight","","","","","","","","","","plymouth_twilight","","","","","","","night_weekday","","","","torbay_twilight","","","","","","","","","","truro_twilight","night_weekday","","night_weekday","",""
+"Week 12 Day Thu","","","","","","","","","exeter_twilight","","","","","","","","","","","","","","","","plymouth_twilight","night_weekday","","","","torbay_twilight","","","","","","","","","","","night_weekday","","night_weekday","","truro_twilight"
+"Week 12 Day Fri","","night_weekend","","weekend_exeter","","","","","","","","night_weekend","","","","","","","","plymouth_twilight","","","","","","","","","","","","night_weekend","weekend_torbay","","","","","","weekend_truro","","","","","",""
+"Week 12 Day Sat","","night_weekend","","weekend_exeter","","","","","","","","night_weekend","","weekend_plymouth1","","","","","","","","","weekend_plymouth2","","","","","","","","","night_weekend","weekend_torbay","","","","","","weekend_truro","","","","","",""
+"Week 12 Day Sun","","night_weekend","","weekend_exeter","","","","","","","","night_weekend","","weekend_plymouth1","","","","","","","","","weekend_plymouth2","","","","","","","","","night_weekend","weekend_torbay","","","","","","weekend_truro","","","","","",""
+"Week 13 Day Mon","","","","","","","exeter_twilight","","","","","","","","","","","","","plymouth_twilight","","","","","","torbay_twilight","night_weekday","","","","","","","","","","night_weekday","","","","","","truro_twilight","night_weekday",""
+"Week 13 Day Tue","","","","","","","exeter_twilight","","","","","","","","","","","","","plymouth_twilight","","","","","","torbay_twilight","night_weekday","","","","","","","","","","night_weekday","","","","truro_twilight","","","night_weekday",""
+"Week 13 Day Wed","","","","exeter_twilight","","","","","","","","plymouth_twilight","","","","","","","","","","","","","","","night_weekday","","","","","","torbay_twilight","","","","night_weekday","","","","","","","night_weekday","truro_twilight"
+"Week 13 Day Thu","","exeter_twilight","","","","","","","","","","","","","","","","","","","plymouth_bank_holidays","","plymouth_twilight","","","","night_weekday","","","","","","torbay_twilight","","","","night_weekday","","","","","truro_twilight","","night_weekday",""
+"Week 13 Day Fri","","","","","","weekend_exeter","","","night_weekend","","","","","","","","","night_weekend","plymouth_bank_holidays","night_weekend","","","plymouth_twilight","","","weekend_torbay","","","","","","","","","","","","","","","","","weekend_truro","",""
+"Week 13 Day Sat","","","","","","weekend_exeter","","","night_weekend","","","","","","","weekend_plymouth2","","night_weekend","","night_weekend","","weekend_plymouth1","","","","weekend_torbay","","","","","","","","","","","","","","","","","weekend_truro","",""
+"Week 13 Day Sun","","","","","","weekend_exeter","","","night_weekend","","","","","","","weekend_plymouth2","","night_weekend","","night_weekend","","weekend_plymouth1","","","","weekend_torbay","","","","","","","","","","","","","","","","","weekend_truro","",""
diff --git a/output/timetable.css b/output/timetable.css
index db629d3..555bece 100644
--- a/output/timetable.css
+++ b/output/timetable.css
@@ -85,6 +85,11 @@ th {
}
+th.bank-holiday {
+ color: darkblue;
+
+}
+
/* th.worker {
max-width: 100%;
width: 200px;
@@ -224,4 +229,10 @@ summary h2 {
.derriford-night.shift-highlight {
background-color: yellow;
+}
+
+.large-shift-diff::before {
+ content: "*";
+ color: red;
+
}
\ No newline at end of file
diff --git a/output/timetable.js b/output/timetable.js
index a919049..c17a6c0 100644
--- a/output/timetable.js
+++ b/output/timetable.js
@@ -104,19 +104,24 @@ function generateExtra() {
// Also 1 day for a whole weekend
days_lost = days_lost + whole_weekends_worked
- //days_lost = days_lost + whole_weekends_worked;
- //console.log(shift_counts);
+ // Highlight non working days
+ nwds = JSON.parse(worker_td.attr("data-nwds"));
+ nwds.forEach(element => {
+ // Restrict to date ranges
+ start_date = new Date(Date.parse(element[1]))
+ end_date = new Date(Date.parse(element[2]))
+ $(shift_tds).filter(`.${element[0]}`).each(
+ (n, td_el) => {
+ td_date = new Date(Date.parse(td_el.dataset.date));
- nwds = worker_td.attr("data-nwds");
+ if (td_date >= start_date && td_date <= end_date) {
+ $(td_el).addClass("nwd");
+ }
- console.log(nwds);
+ }
- if (nwds != "None") {
- nwds.split(", ").forEach((nwd) => {
- console.log(worker, nwd, shift_tds);
- $(shift_tds).filter(`.${nwd}`).addClass("nwd");
- })
- }
+ )
+ });
workers[worker] = {
"site": worker_td.attr("data-site"),
@@ -145,14 +150,20 @@ function generateExtra() {
shift_diff = JSON.parse(worker_td.attr("data-shift-diff"));
summed_shift_diff = Object.values(shift_diff).reduce((a, b) => a + b);
+
worker_td.after(`
Total shifts: ${total_shifts},
Weekends: ${weekends_worked},
Bank holidays: ${bank_holidays_worked},
- Summed shift diff: ${summed_shift_diff},
+ Summed shift diff: ${summed_shift_diff},
`)
+ if (Math.abs(summed_shift_diff) >= 1) {
+ worker_td.addClass("large-shift-diff")
+ $(tr).find(".shift-diff-span").addClass("large-shift-diff")
+ }
+
})
sites = {};
@@ -344,7 +355,8 @@ function viewWorker(worker) {
dlg = $(`
Site: ${ds.site}
FTE: ${ds.fte} (${ds.fte_adj})
- End date: ${ds.end_date}
+ End date: ${ds.end_date}
+ Non working days: ${ds.nwds}
`)
$("body").append(dlg)
diff --git a/rota/runfixnights.py b/rota/runfixnights.py
index 32f137f..3142f84 100644
--- a/rota/runfixnights.py
+++ b/rota/runfixnights.py
@@ -323,7 +323,7 @@ else:
oop,
not_available_to_work=leave_requests,
work_requests=work_requests,
- home_site=site_pref,
+ remote_site=site_pref,
previous_shifts=previous_shifts,
pair=pair,
shift_balance_extra=w["shift_balance_extra"],
diff --git a/rota/shifts.py b/rota/shifts.py
index 43bebcf..7108a98 100644
--- a/rota/shifts.py
+++ b/rota/shifts.py
@@ -10,7 +10,7 @@ import datetime
from pyomo.environ import *
from pyomo.opt import SolverFactory
-from .workers import Worker
+from rota.workers import Worker
import json
@@ -100,10 +100,14 @@ class SingleShift(object):
self.workers_required = workers_required
self.rota_on_nwds = rota_on_nwds
- self.assign_as_block = assign_as_block
self.force_as_block = force_as_block
self.force_as_block_unless_nwd = force_as_block_unless_nwd
+ if force_as_block_unless_nwd:
+ self.assign_as_block = True
+
+ self.assign_as_block = assign_as_block
+
self.constraint_options = {}
self.constraints = []
for c in constraints:
@@ -207,9 +211,11 @@ class RotaBuilder(object):
"max_weekends": 100,
"max_shifts_per_week": 7,
"max_shifts_per_month": 40,
+ # The following will cause an unsolvable problem if a shift
+ # is forced as a block and spans the time peroid
"prevent_monday_after_full_weekends": [],
"prevent_monday_and_tuesday_after_full_weekends": [],
- "prevent_fridays_before_full_weekends": [],
+ "prevent_fridays_before_full_weekends": [],
"prevent_thursdays_before_full_weekends": [],
# This may be better defined on the shift?
"require_presence_at_site_overnight": [],
@@ -804,9 +810,9 @@ class RotaBuilder(object):
# rule=nightShiftDerrifordRule,
# )
- def presenceAtHomeSite(model, week, shift, required_site, required_number):
+ def presenceAtRemoteSite(model, week, shift, required_site, required_number):
required_site_workers = [
- w for w in self.workers if required_site == w.home_site
+ w for w in self.workers if required_site == w.remote_site
]
return (
@@ -817,20 +823,20 @@ class RotaBuilder(object):
>= required_number
)
- for shift in self.get_shifts_with_constraint("require_home_site_presence"):
+ for shift in self.get_shifts_with_constraint("require_remote_site_presence"):
site, required_number = shift.constraint_options[
- "require_home_site_presence"
+ "require_remote_site_presence"
]
# self.model.require_presence_at_site_overnight_rule = Constraint(
setattr(
self.model,
- f"require_home_site_presence_{shift.name}",
+ f"require_remote_site_presence_{shift.name}",
Constraint(
[week for week in self.weeks],
[shift.name],
[site],
[required_number],
- rule=presenceAtHomeSite,
+ rule=presenceAtRemoteSite,
),
)
@@ -905,74 +911,35 @@ class RotaBuilder(object):
)
)
- if shift.force_as_block:
+ if shift.force_as_block_unless_nwd:
+ workers = self.get_workers_for_shift(shift)
+ # Get workers who have a nwd on the shift
+ nwd_workers = []
+ full_workers = []
- if shift.force_as_block_unless_nwd:
- workers = self.get_workers_for_shift(shift)
- # Get workers who have a nwd on the shift
- nwd_workers = []
- full_workers = []
-
- for w in workers:
- if w.nwd is not None:
- l = []
- # This should take into account dates!
- for nwd, d1, d2 in w.nwd:
- if nwd in shift.shift_days:
- l.append(w)
-
- if l:
- nwd_workers.append(w)
- else:
- full_workers.append(w)
+ for w in workers:
+ if w.nwd:
+ l = []
+ # This should take into account dates!
+ for nwd, start_nwd_date, end_nwd_date in w.nwd:
+ if nwd in shift.shift_days:
+ if start_nwd_date > self.get_week_start_date(week):
+ continue
+ if end_nwd_date < self.get_week_start_date(week):
+ continue
+ l.append(w)
+ if l:
+ nwd_workers.append(w)
else:
full_workers.append(w)
- if not nwd_workers: # Just do the usual
- self.model.constraints.add(
- sum(
- self.model.blocks_worker_shift_assigned[
- worker.id, week, shift.name
- ]
- for worker in self.workers
- )
- <= 1
- )
-
else:
- self.model.constraints.add(
- sum(
- self.model.blocks_worker_shift_assigned[
- worker.id, week, shift.name
- ]
- for worker in full_workers
- )
- <= 1
- )
+ full_workers.append(w)
- self.model.constraints.add(
- sum(
- self.model.blocks_worker_shift_assigned[
- worker.id, week, shift.name
- ]
- for worker in workers
- )
- <= 2
- )
+ print(nwd_workers)
- # self.model.constraints.add(
- # self.model.worker_block_shift_split[week, shift.name] <= 2 - sum(self.model.blocks_worker_shift_assigned[worker.id,
- # week,
- # shift.name]
- # for worker in workers))
- #
- # self.model.constraints.add(
- # sum(self.model.works[w.id, week, day,shift.name]
- # for w in nwd_workers for day in shift.shift_days) - sum(self.model.works[w.id, week, day,shift.name]
- # for w in full_workers for day in shift.shift_days) >= self.model.worker_block_shift_split[week, shift.name] )
-
- else:
+ if not nwd_workers: # Just do the usual
self.model.constraints.add(
sum(
self.model.blocks_worker_shift_assigned[
@@ -983,6 +950,28 @@ class RotaBuilder(object):
<= shift.workers_required
)
+ else:
+ self.model.constraints.add(
+ sum(
+ self.model.blocks_worker_shift_assigned[
+ worker.id, week, shift.name
+ ]
+ for worker in full_workers
+ )
+ <= shift.workers_required + 1
+ )
+
+ elif shift.force_as_block:
+ self.model.constraints.add(
+ sum(
+ self.model.blocks_worker_shift_assigned[
+ worker.id, week, shift.name
+ ]
+ for worker in self.workers
+ )
+ <= shift.workers_required
+ )
+
# Most of our constraints apply per worker
for worker in self.workers:
@@ -1355,7 +1344,7 @@ class RotaBuilder(object):
for n, start, end in worker.nwd:
if not shift.rota_on_nwds and day == n:
# print(start, self.week_day_date_map[(week, day)], end)
- if start <= self.week_day_date_map[(week, day)] <= end:
+ if start <= self.week_day_date_map[(week, day)] < end:
self.model.constraints.add(
0
== self.model.works[
@@ -1405,16 +1394,7 @@ class RotaBuilder(object):
)
)
- # TODO: generic implementation
- # for shift in self.get_shifts_with_constraint("max_2_shifts_per_week"):
- # self.model.constraints.add(
- # 2
- # >= sum(
- # self.model.works[worker.id, week, day, shift.name]
- # for day in self.days
- # )
- # )
-
+ # TODO: consider excluding shifts that span relevant period
if (
worker.site
in self.constraint_options["prevent_monday_after_full_weekends"]
@@ -1463,21 +1443,18 @@ class RotaBuilder(object):
for shift in self.get_shift_names_by_week_day(
week, "Sun"
)
- # for shift in self.get_shifts()
)
+ sum(
self.model.works[worker.id, week + 1, "Mon", shift]
for shift in self.get_shift_names_by_week_day(
week + 1, "Mon"
)
- # for shift in self.get_shifts()
)
+ sum(
self.model.works[worker.id, week + 1, "Tue", shift]
for shift in self.get_shift_names_by_week_day(
week + 1, "Tue"
)
- # for shift in self.get_shifts()
)
)
if (
@@ -1488,18 +1465,21 @@ class RotaBuilder(object):
full_weekend_count
>= sum(
self.model.works[worker.id, week, "Sat", shift.name]
- for shift in self.get_shifts()
- if "night" not in shift.constraints
+ for shift in self.get_shift_names_by_week_day(
+ week, "Sat"
+ )
)
+ sum(
self.model.works[worker.id, week, "Sun", shift.name]
- for shift in self.get_shifts()
- if "night" not in shift.constraints
+ for shift in self.get_shift_names_by_week_day(
+ week, "Sat"
+ )
)
+ sum(
self.model.works[worker.id, week, "Fri", shift.name]
- for shift in self.get_shifts()
- if "night" not in shift.constraints
+ for shift in self.get_shift_names_by_week_day(
+ week, "Sat"
+ )
)
)
if (
@@ -1510,18 +1490,21 @@ class RotaBuilder(object):
full_weekend_count
>= sum(
self.model.works[worker.id, week, "Sat", shift.name]
- for shift in self.get_shifts()
- if "night" not in shift.constraints
+ for shift in self.get_shift_names_by_week_day(
+ week, "Sat"
+ )
)
+ sum(
self.model.works[worker.id, week, "Sun", shift.name]
- for shift in self.get_shifts()
- if "night" not in shift.constraints
+ for shift in self.get_shift_names_by_week_day(
+ week, "Sun"
+ )
)
+ sum(
self.model.works[worker.id, week, "Thu", shift.name]
- for shift in self.get_shifts()
- if "night" not in shift.constraints
+ for shift in self.get_shift_names_by_week_day(
+ week, "Thu"
+ )
)
)
@@ -2243,7 +2226,7 @@ class RotaBuilder(object):
return [shift.name for shift in self.get_shifts() if shift.assign_as_block]
def shifts_to_force_as_blocks(self):
- return [shift.name for shift in self.get_shifts() if shift.force_as_block]
+ return [shift.name for shift in self.get_shifts() if (shift.force_as_block or shift.force_as_block_unless_nwd)]
def shifts_to_assign_or_force_as_blocks(self):
s = self.shifts_to_assign_as_blocks()
@@ -2293,6 +2276,9 @@ class RotaBuilder(object):
if self.week_day_date_map[(week, day)] in bank_holiday_map
]
+ def get_week_start_date(self, week: int):
+ return self.start_date + datetime.timedelta(weeks=week)
+
# RESULTS
def export_rota_to_html(self, filename: str = "rota"):
output_file = Path("output", f"{filename}.html")
@@ -2300,7 +2286,7 @@ class RotaBuilder(object):
f.write(self.get_worker_timetable_html(True))
def export_rota_to_csv(self, filename: str = "rota"):
- output_file = Path("output", f"{filename}.html")
+ output_file = Path("output", f"{filename}.csv")
works = self.model.works
with open(output_file, "w", newline="") as f:
wr = csv.writer(f, quoting=csv.QUOTE_ALL)
@@ -2436,7 +2422,12 @@ class RotaBuilder(object):
n = 0
for week, day in self.get_week_day_combinations():
d = self.start_date + datetime.timedelta(n)
- date_row.append(f"Week {week}: {day} | ")
+
+ th_class = ""
+ if d in bank_holiday_map:
+ th_class = "bank-holiday"
+
+ date_row.append(f"Week {week}: {day} | ")
n = n + 1
timetable.append(f"{''.join(date_row)}
")
@@ -2453,11 +2444,12 @@ class RotaBuilder(object):
current_site = worker.site
shifts = []
- if worker.nwd is not None:
- # TODO: limit to dates
- nwds = ", ".join([i[0] for i in worker.nwd])
- else:
- nwds = None
+ nwds = json.dumps(worker.nwd, default=str)
+ #if worker.nwd:
+ # # TODO: limit to dates
+ # nwds = ", ".join([i[0] for i in worker.nwd])
+ #else:
+ # nwds = None
worker_targets = json.dumps(worker.shift_target_number)
@@ -2501,6 +2493,7 @@ class RotaBuilder(object):
bank_holiday = ""
if d in bank_holiday_map:
+ title = f"{title} [Bank Holiday]"
css_class = " ".join((css_class, "bank-holiday"))
bank_holiday = f" data-bank-holiday='{bank_holiday_map[d]}'"
@@ -2536,7 +2529,7 @@ class RotaBuilder(object):
data-shift-balance-extra='{shift_balance_extra}'
data-bank-holiday-extra='{bank_holiday_extra}'
data-shift-diff='{shift_diff}'
- data-shift-diff_summed='{shift_diff_summed}'
+ data-shift-diff-summed='{shift_diff_summed}'
>
{name} ({grade}) [{fte}]""".format(
site=worker.site,
diff --git a/rota/workers.py b/rota/workers.py
index e048922..7d4c46e 100644
--- a/rota/workers.py
+++ b/rota/workers.py
@@ -1,26 +1,33 @@
import datetime
from collections import defaultdict
+from typing import List
-# from shifts import RotaBuilder, days, sites
+#from .shifts import RotaBuilder, days, sites
import uuid
+#from rota.shifts import RotaBuilder
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from rota.shifts import RotaBuilder
+
class Worker:
def __init__(
self,
- Rota, # Aim to remove (workers should be independent of rotas)
+ Rota: "RotaBuilder", # Aim to remove (workers should be independent of rotas)
name: str,
site: str,
grade: int,
id=None,
fte: int = 100,
- nwd=None,
- end_date=None,
+ nwd: list[str] | list[str, datetime.datetime, datetime.datetime]=[],
+ end_date: datetime.datetime | None =None,
oop=None,
not_available_to_work=None,
pref_not_to_work=None,
work_requests=None,
- home_site: str = "plymouth", # We set a default proc_site
+ remote_site: str = "plymouth", # We set a default proc_site
previous_shifts: dict = {},
shift_balance_extra: dict = {},
bank_holiday_extra: int = 0,
@@ -38,14 +45,14 @@ class Worker:
# rules is easier.
self.grade = grade
self.fte = fte
- self.nwd = nwd
+ self.nwd = []
self.proportion_rota_to_work = 1
self.pair = pair
self.shift_balance_extra = shift_balance_extra
self.bank_holiday_extra = bank_holiday_extra
- self.home_site = home_site
- if home_site == "plymouth" or home_site == "":
+ self.remote_site = remote_site
+ if remote_site == "plymouth" or remote_site == "":
self.night_at_derriford = 1
else:
self.night_at_derriford = 0
@@ -155,7 +162,13 @@ class Worker:
if self.fte_adj > 100:
raise ValueError("{} : fte_ajd = {}".format(self.name, self.fte_adj))
- # print(self.name, self.nwd)
+ assert type(nwd) == list, f"nwd must be a list: {nwd=}"
+ for item in nwd:
+ match item:
+ case (nwd_day, start_date, end_date):
+ self.nwd.append((nwd_day, start_date, end_date))
+ case nwd_day:
+ self.nwd.append((nwd_day, Rota.start_date, Rota.rota_end_date))
def __lt__(self, other) -> bool:
return (self.site, self.grade, self.fte_adj, self.name) < (
@@ -172,7 +185,7 @@ class Worker:
self.name,
(self.site, self.grade, self.fte_adj),
nwd,
- self.home_site,
+ self.remote_site,
self.night_at_derriford,
)
diff --git a/rota/run.py b/run.py
similarity index 98%
rename from rota/run.py
rename to run.py
index f8e3c85..43ee67f 100644
--- a/rota/run.py
+++ b/run.py
@@ -3,8 +3,9 @@ from pyomo.opt import SolverFactory
from pyomo.opt.results import SolverStatus
-from shifts import SingleShift, RotaBuilder, days
-from workers import Worker
+from rota.shifts import SingleShift, RotaBuilder, days
+from rota.workers import Worker
+
import datetime
@@ -261,9 +262,8 @@ else:
if fte == 0 or fte == "0" or fte == "":
continue
- nwds = None
+ nwds = []
if nwd:
- nwds = []
for i in nwd.split(","):
nwd_start_date = Rota.start_date
nwd_end_date = Rota.rota_end_date
@@ -327,7 +327,7 @@ else:
oop,
not_available_to_work=leave_requests,
work_requests=work_requests,
- home_site=site_pref,
+ remote_site=site_pref,
previous_shifts=previous_shifts,
pair=pair,
shift_balance_extra=w["shift_balance_extra"],
diff --git a/test/test_nwds.py b/test/test_nwds.py
new file mode 100644
index 0000000..35ac66e
--- /dev/null
+++ b/test/test_nwds.py
@@ -0,0 +1,405 @@
+from copy import deepcopy
+from black import main
+import pytest
+from rota.shifts import NoWorkers, RotaBuilder, SingleShift, days
+
+import datetime
+
+from rota.workers import Worker
+
+
+def weeks_from_list(lst):
+ """Yield successive n-sized chunks from a lst."""
+ for i in range(0, len(lst), 7):
+ yield lst[i : i + 7]
+
+
+class TestDemoRota:
+ def test_nwd(self):
+ # Set up rota
+ weeks_to_rota = 10
+ start_date = datetime.date(2022, 3, 7)
+
+ self.Rota = RotaBuilder(
+ start_date,
+ weeks_to_rota=weeks_to_rota,
+ )
+
+ # Add a few workers
+ worker1 = Worker(self.Rota, "worker1", "group1", 1, nwd=["Mon"])
+ worker2 = Worker(self.Rota, "worker2", "group1", 1, nwd=["Fri"])
+
+ self.Rota.add_workers((worker1, worker2))
+
+ # Add a weekday and weekend shift
+ self.Rota.add_shifts(
+ SingleShift(
+ ("group1",),
+ "d",
+ 12.5,
+ days[:5],
+ balance_offset=10,
+ workers_required=1,
+ ),
+ SingleShift(
+ ("group1",),
+ "w",
+ 12.5,
+ days[5:],
+ workers_required=2,
+ ),
+ )
+
+ self.Rota.build_and_solve(options={"ratio": 0.00})
+
+ self.Rota.export_rota_to_html("nwd")
+
+ assert self.Rota.results.solver.status == "ok"
+ assert self.Rota.results.solver.termination_condition == "optimal"
+
+ for worker in self.Rota.workers:
+ shifts = self.Rota.get_worker_shift_list(worker)
+
+ # Convert shift to a string representation
+ shifts_string = "".join([i if i != "" else "-" for i in shifts])
+
+ if worker.name == "worker1":
+ assert "wwd" not in shifts_string
+
+ if worker.name == "worker2":
+ assert "dww" not in shifts_string
+
+ def test_nwd_simple_fail(self):
+ # Set up rota
+ weeks_to_rota = 10
+ start_date = datetime.date(2022, 3, 7)
+
+ self.Rota = RotaBuilder(
+ start_date,
+ weeks_to_rota=weeks_to_rota,
+ )
+
+ # Add a few workers
+ worker1 = Worker(self.Rota, "worker1", "group1", 1, nwd=["Mon"])
+ worker2 = Worker(self.Rota, "worker2", "group1", 1, nwd=["Mon"])
+
+ self.Rota.add_workers((worker1, worker2))
+
+ # Add a weekday and weekend shift
+ self.Rota.add_shifts(
+ SingleShift(
+ ("group1",),
+ "d",
+ 12.5,
+ days[:5],
+ balance_offset=10,
+ workers_required=1,
+ ),
+ )
+
+ self.Rota.build_and_solve(options={"ratio": 0.00})
+
+ self.Rota.export_rota_to_html("nwd")
+
+ assert self.Rota.results.solver.status == "warning"
+
+ def test_nwd_partial_rota(self):
+ # Set up rota
+ weeks_to_rota = 10
+ start_date = datetime.date(2022, 3, 7)
+
+ self.Rota = RotaBuilder(
+ start_date,
+ weeks_to_rota=weeks_to_rota,
+ )
+
+ d = start_date + datetime.timedelta(weeks=5)
+ # Add a few workers
+ worker1 = Worker(
+ self.Rota, "worker1", "group1", 1, nwd=[("Mon", start_date, d)]
+ )
+ worker2 = Worker(
+ self.Rota, "worker2", "group1", 1, nwd=[("Mon", d, self.Rota.rota_end_date)]
+ )
+
+ self.Rota.add_workers((worker1, worker2))
+
+ # Add a weekday and weekend shift
+ self.Rota.add_shifts(
+ SingleShift(
+ ("group1",),
+ "d",
+ 12.5,
+ days[:5],
+ balance_offset=10,
+ workers_required=1,
+ ),
+ )
+
+ self.Rota.build_and_solve(options={"ratio": 0.00})
+
+ self.Rota.export_rota_to_html("nwd")
+
+ assert self.Rota.results.solver.status == "ok"
+
+ def test_nwd_partial_overlay(self):
+ # Set up rota
+ weeks_to_rota = 10
+ start_date = datetime.date(2022, 3, 7)
+
+ self.Rota = RotaBuilder(
+ start_date,
+ weeks_to_rota=weeks_to_rota,
+ )
+
+ d = start_date + datetime.timedelta(weeks=5)
+ d2 = start_date + datetime.timedelta(weeks=6)
+ # Add a few workers
+ worker1 = Worker(
+ self.Rota, "worker1", "group1", 1, nwd=[("Mon", start_date, d2)]
+ )
+ worker2 = Worker(
+ self.Rota, "worker2", "group1", 1, nwd=[("Mon", d, self.Rota.rota_end_date)]
+ )
+
+ self.Rota.add_workers((worker1, worker2))
+
+ # Add a weekday and weekend shift
+ self.Rota.add_shifts(
+ SingleShift(
+ ("group1",),
+ "d",
+ 12.5,
+ days[:5],
+ balance_offset=10,
+ workers_required=1,
+ ),
+ )
+
+ self.Rota.build_and_solve(options={"ratio": 0.00})
+
+ assert self.Rota.results.solver.status == "warning"
+
+ def test_nwd_force_as_block(self):
+ # Set up rota
+ weeks_to_rota = 10
+ start_date = datetime.date(2022, 3, 7)
+
+ self.Rota = RotaBuilder(
+ start_date,
+ weeks_to_rota=weeks_to_rota,
+ )
+
+ d = start_date + datetime.timedelta(weeks=4)
+ worker1 = Worker(
+ self.Rota, "worker1", "group1", 1, nwd=[("Mon", start_date, d)]
+ )
+ worker2 = Worker(
+ self.Rota,
+ "worker2",
+ "group1",
+ 1,
+ )
+ worker3 = Worker(
+ self.Rota,
+ "worker3",
+ "group1",
+ 1,
+ )
+ worker4 = Worker(
+ self.Rota,
+ "worker4",
+ "group1",
+ 1,
+ )
+
+ self.Rota.add_workers((worker1, worker2, worker3, worker4))
+
+ # Add a weekday and weekend shift
+ self.Rota.add_shifts(
+ SingleShift(
+ ("group1",),
+ "d",
+ 12.5,
+ days[:5],
+ balance_offset=10,
+ workers_required=2,
+ force_as_block_unless_nwd=True,
+ assign_as_block=True,
+ ),
+ )
+
+ self.Rota.build_and_solve(options={"ratio": 0.00})
+ self.Rota.export_rota_to_html("nwd")
+
+ assert self.Rota.results.solver.status == "ok"
+
+ for worker in self.Rota.workers:
+ shifts = self.Rota.get_worker_shift_list(worker)
+
+ # Convert shift to a string representation
+ shifts_string = "".join([i if i != "" else "-" for i in shifts])
+
+ for week in weeks_from_list(shifts_string):
+ assert week in ("-------", "ddddd--")
+
+ assert shifts_string.count("d") == 25
+
+ def test_nwd_force_as_block_force_split(self):
+ # Set up rota
+ weeks_to_rota = 10
+ start_date = datetime.date(2022, 3, 7)
+
+ self.Rota = RotaBuilder(
+ start_date,
+ weeks_to_rota=weeks_to_rota,
+ )
+
+ d = start_date + datetime.timedelta(weeks=5)
+ worker1 = Worker(
+ self.Rota, "worker1", "group1", 1, nwd=[("Mon", start_date, d)]
+ )
+ worker2 = Worker(
+ self.Rota,
+ "worker2",
+ "group1",
+ 1,
+ )
+ worker3 = Worker(
+ self.Rota,
+ "worker3",
+ "group1",
+ 1,
+ )
+ worker4 = Worker(
+ self.Rota,
+ "worker4",
+ "group1",
+ 1,
+ )
+
+ self.Rota.add_workers((worker1, worker2, worker3, worker4))
+
+ # Add a weekday and weekend shift
+ self.Rota.add_shifts(
+ SingleShift(
+ ("group1",),
+ "d",
+ 12.5,
+ days[:5],
+ balance_offset=10,
+ workers_required=2,
+ force_as_block_unless_nwd=True,
+ assign_as_block=True,
+ ),
+ SingleShift(
+ ("group1",),
+ "w",
+ 12.5,
+ days[5:],
+ balance_offset=10,
+ workers_required=4,
+ force_as_block_unless_nwd=True,
+ assign_as_block=True,
+ ),
+ )
+
+ self.Rota.build_and_solve(options={"ratio": 0.00})
+ self.Rota.export_rota_to_html("nwd")
+
+ assert self.Rota.results.solver.status == "ok"
+
+ for worker in self.Rota.workers:
+ shifts = self.Rota.get_worker_shift_list(worker)
+
+ # Convert shift to a string representation
+ shifts_string = "".join([i if i != "" else "-" for i in shifts])
+
+ for week in weeks_from_list(shifts_string[7 * 5 :]):
+ assert week in ("-----ww", "dddddww")
+
+ if worker.name == "worker1":
+ assert shifts_string[: 7 * 5].count("ww-") == 4
+
+ assert shifts_string.count("d") == 25
+ assert shifts_string.count("w") == 20
+
+ def test_nwd_testing(self):
+ # Set up rota
+ weeks_to_rota = 10
+ start_date = datetime.date(2022, 3, 7)
+
+ self.Rota = RotaBuilder(
+ start_date,
+ weeks_to_rota=weeks_to_rota,
+ )
+
+ d = start_date + datetime.timedelta(weeks=5)
+ worker1 = Worker(
+ self.Rota,
+ "worker1",
+ "group1",
+ 1,
+ nwd=[
+ ("Mon", d, self.Rota.rota_end_date),
+ ("Tue", d, self.Rota.rota_end_date),
+ ("Wed", d, self.Rota.rota_end_date),
+ ("Thu", d, self.Rota.rota_end_date),
+ ("Fri", d, self.Rota.rota_end_date),
+ ],
+ )
+ worker2 = Worker(
+ self.Rota,
+ "worker2",
+ "group1",
+ 1,
+ )
+
+ self.Rota.add_workers((worker1, worker2))
+
+ # Add a weekday and weekend shift
+ self.Rota.add_shifts(
+ SingleShift(
+ ("group1",),
+ "d",
+ 12.5,
+ days[:5],
+ balance_offset=10,
+ workers_required=1,
+ force_as_block_unless_nwd=True,
+ assign_as_block=True,
+ ),
+ SingleShift(
+ ("group1",),
+ "w",
+ 12.5,
+ days[5:],
+ balance_offset=10,
+ workers_required=2,
+ force_as_block_unless_nwd=True,
+ assign_as_block=True,
+ ),
+ )
+
+ self.Rota.build_and_solve(options={"ratio": 0.00})
+ self.Rota.export_rota_to_html("nwd")
+
+ assert self.Rota.results.solver.status == "ok"
+
+ for worker in self.Rota.workers:
+ shifts = self.Rota.get_worker_shift_list(worker)
+
+ # Convert shift to a string representation
+ shifts_string = "".join([i if i != "" else "-" for i in shifts])
+
+ for week in weeks_from_list(shifts_string[7 * 5 :]):
+ if worker.name == "worker1":
+ assert week == "-----ww"
+ else:
+ assert week == "dddddww"
+
+ for week in weeks_from_list(shifts_string[:7 * 5 ]):
+ if worker.name == "worker1":
+ assert week == "dddddww"
+ else:
+ assert week == "-----ww"
\ No newline at end of file
diff --git a/test/test_rota.py b/test/test_rota.py
index 13aa478..1ce7c81 100644
--- a/test/test_rota.py
+++ b/test/test_rota.py
@@ -49,7 +49,7 @@ class TestDemoRota:
"weekday",
12.5,
days[:5],
- balance_offset=10,
+ balance_offset=1,
workers_required=1,
# We use a different balance weighting for each shift as otherwise
# the shifts can be treated equally (and no target diff)
@@ -60,7 +60,7 @@ class TestDemoRota:
"weekend",
12.5,
days[5:],
- balance_offset=40,
+ balance_offset=4,
# balance_weighting=0.5
),
)
@@ -595,12 +595,12 @@ class TestLimitConstraints:
)
# Rota.constraint_options["max_shifts_per_week"] = 5
# Rota.constraint_options["max_shifts_per_month"] = 20
- worker1 = Worker(Rota, "worker1", "group1", 2, home_site="group1")
- worker2 = Worker(Rota, "worker2", "group1", 2, home_site="group1")
- worker3 = Worker(Rota, "worker3", "group1", 2, home_site="group1")
- worker4 = Worker(Rota, "worker4", "group3", 3, home_site="group2")
- worker5 = Worker(Rota, "worker5", "group3", 3, home_site="group2")
- worker6 = Worker(Rota, "worker6", "group3", 3, home_site="group2")
+ worker1 = Worker(Rota, "worker1", "group1", 2, remote_site="group1")
+ worker2 = Worker(Rota, "worker2", "group1", 2, remote_site="group1")
+ worker3 = Worker(Rota, "worker3", "group1", 2, remote_site="group1")
+ worker4 = Worker(Rota, "worker4", "group3", 3, remote_site="group2")
+ worker5 = Worker(Rota, "worker5", "group3", 3, remote_site="group2")
+ worker6 = Worker(Rota, "worker6", "group3", 3, remote_site="group2")
Rota.add_workers((worker1, worker2, worker3, worker4))
Rota.add_workers((worker5, worker6))
@@ -853,7 +853,7 @@ class TestLimitConstraints:
with pytest.raises(ValueError):
self.Rota.build_and_solve(options={"ratio": 0.1})
- def test_constraint_require_home_site_presence(self):
+ def test_constraint_require_remote_site_presence(self):
self.Rota.shifts = []
self.Rota.add_shifts(
SingleShift(
@@ -864,7 +864,7 @@ class TestLimitConstraints:
balance_offset=40,
workers_required=2,
force_as_block=True,
- constraints=[("require_home_site_presence", ("group1", 2))],
+ constraints=[("require_remote_site_presence", ("group1", 2))],
),
)
self.Rota.build_and_solve(options={"ratio": 0.00})
@@ -887,7 +887,7 @@ class TestLimitConstraints:
assert self.Rota.results.solver.status == "ok"
assert self.Rota.results.solver.termination_condition == "optimal"
- def test_constraint_require_home_site_presence2(self):
+ def test_constraint_require_remote_site_presence2(self):
self.Rota.shifts = []
self.Rota.add_shifts(
SingleShift(
@@ -898,7 +898,7 @@ class TestLimitConstraints:
balance_offset=40,
workers_required=3,
force_as_block=True,
- constraints=[("require_home_site_presence", ("group2", 2))],
+ constraints=[("require_remote_site_presence", ("group2", 2))],
),
)
self.Rota.build_and_solve(options={"ratio": 0.00})
@@ -1058,4 +1058,4 @@ class TestNightUnavailable:
if __name__ == "__main__":
t = TestLimitConstraints()
- t.test_constraint_require_home_site_presence()
+ t.test_constraint_require_remote_site_presence()
diff --git a/test/test_weekend_padding.py b/test/test_weekend_padding.py
index 38e5598..e43065e 100644
--- a/test/test_weekend_padding.py
+++ b/test/test_weekend_padding.py
@@ -7,8 +7,140 @@ import datetime
from rota.workers import Worker
+def weeks_from_list(lst):
+ """Yield successive n-sized chunks from a lst."""
+ for i in range(0, len(lst), 7):
+ yield lst[i : i + 7]
class TestDemoRota:
+ def test_balance_weekend(self):
+ # Set up rota
+ weeks_to_rota = 10
+ start_date = datetime.date(2022, 3, 7)
+
+ self.Rota = RotaBuilder(
+ start_date,
+ weeks_to_rota=weeks_to_rota,
+ )
+ self.Rota.constraint_options["balance_weekends"] = True
+
+ # Add a few workers
+ worker1 = Worker(self.Rota, "worker1", "group1", 1)
+ worker2 = Worker(self.Rota, "worker2", "group1", 1)
+ worker3 = Worker(self.Rota, "worker3", "group1", 1)
+ worker4 = Worker(self.Rota, "worker4", "group1", 1)
+
+ self.Rota.add_workers((worker1, worker2, worker3, worker4))
+
+ # Add a weekday and weekend shift
+ self.Rota.add_shifts(
+ SingleShift(
+ ("group1",),
+ "a",
+ 12.5,
+ days,
+ balance_offset=1,
+ workers_required=1,
+ ),
+ SingleShift(
+ ("group1",),
+ "b",
+ 12.5,
+ days[5:],
+ balance_offset=0,
+ workers_required=1
+ ),
+ )
+
+ self.Rota.build_and_solve(options={"ratio": 0.00})
+
+ self.Rota.export_rota_to_html("balance_weekend")
+
+ assert self.Rota.results.solver.status == "ok"
+ assert self.Rota.results.solver.termination_condition == "optimal"
+
+ for worker in self.Rota.workers:
+ shifts = self.Rota.get_worker_shift_list(worker)
+
+ # Convert shift to a string representation
+ shifts_string = "".join([i if i != "" else "-" for i in shifts])
+
+ assert shifts_string.count("b") == 5
+ assert shifts_string.count("a") in (17,18)
+
+ weekend_count = 0
+ for week in weeks_from_list(shifts_string):
+ if week[5:] == "--":
+ continue
+ weekend_count = weekend_count + 1
+
+ assert weekend_count == 5 == self.Rota.model.worker_weekend_count[worker.id].value
+
+ def test_balance_weekend2(self):
+ # Set up rota
+ weeks_to_rota = 10
+ start_date = datetime.date(2022, 3, 7)
+
+ self.Rota = RotaBuilder(
+ start_date,
+ weeks_to_rota=weeks_to_rota,
+ )
+ self.Rota.constraint_options["balance_weekends"] = True
+
+ # Add a few workers
+ worker1 = Worker(self.Rota, "worker1", "group1", 1)
+ worker2 = Worker(self.Rota, "worker2", "group1", 1)
+ worker3 = Worker(self.Rota, "worker3", "group1", 1)
+ worker4 = Worker(self.Rota, "worker4", "group1", 1)
+ worker5 = Worker(self.Rota, "worker5", "group1", 1)
+
+ self.Rota.add_workers((worker1, worker2, worker3, worker4, worker5))
+
+ # Add a weekday and weekend shift
+ self.Rota.add_shifts(
+ SingleShift(
+ ("group1",),
+ "a",
+ 12.5,
+ days,
+ balance_offset=5,
+ workers_required=1,
+ force_as_block=True
+ ),
+ SingleShift(
+ ("group1",),
+ "b",
+ 12.5,
+ days[5:],
+ balance_offset=0,
+ workers_required=2
+ ),
+ )
+
+ self.Rota.build_and_solve(options={"ratio": 0.00})
+
+ self.Rota.export_rota_to_html("balance_weekend2")
+
+ assert self.Rota.results.solver.status == "ok"
+ assert self.Rota.results.solver.termination_condition == "optimal"
+
+ for worker in self.Rota.workers:
+ shifts = self.Rota.get_worker_shift_list(worker)
+
+ # Convert shift to a string representation
+ shifts_string = "".join([i if i != "" else "-" for i in shifts])
+
+ assert shifts_string.count("b") == 8
+ assert shifts_string.count("a") == 14
+
+ weekend_count = 0
+ for week in weeks_from_list(shifts_string):
+ if week[5:] == "--":
+ continue
+ weekend_count = weekend_count + 1
+
+ assert weekend_count == 6 == self.Rota.model.worker_weekend_count[worker.id].value
+
def test_post_mon_tue(self):
# Set up rota
weeks_to_rota = 10