Compare commits

..
26 Commits
Author SHA1 Message Date
Ross d577b2cdf8 feat: add time limit parsing function and update time_to_run parameter to accept string inputs 2026-07-14 22:00:26 +01:00
Ross 0874f05126 feat: add "ir nights" to the list of sites 2026-07-14 21:52:39 +01:00
Ross 6ca3db86cb add prtial shifts 2026-07-14 21:38:59 +01:00
Ross 96e6a0d625 feat: update CSV URL in load_leave and load_academy functions 2026-07-14 21:05:20 +01:00
Ross fc2413538c feat: add TODO to update time_to_run for string input conversion to seconds 2026-07-14 21:03:33 +01:00
Ross 5ff78faa07 feat: implement sub-block handling for shifts and update constraints in RotaBuilder 2026-07-10 08:08:51 +01:00
Ross d1fbbe2dc9 feat: add condition to check for week-day combinations before adding constraints in RotaBuilder 2026-07-09 22:39:38 +01:00
Ross b5525bd3fb feat: add group management for workers and shifts, including validation for overlapping groups 2026-07-09 22:14:30 +01:00
Ross 33f50c7278 feat: adjust max days per week block constraint from 4 to 3 2026-07-09 21:32:26 +01:00
Ross 981cc314f0 feat: update worker shift conditions for specific email addresses 2026-07-09 20:49:44 +01:00
Ross 0861e371e9 feat: update weeks parameter to 26 and adjust constraint options in RotaBuilder 2026-07-09 12:06:20 +01:00
Ross 13c5ae598a feat: add exact shifts display in worker details and update HTML export functionality 2026-07-08 22:38:38 +01:00
Ross 0f31dfe1a9 feat: add exact shifts functionality to Worker class and update related logic in RotaBuilder 2026-07-08 22:33:33 +01:00
Ross 02598cb665 refactor: update default parameters and improve CSV download links in gen_proc and leave modules 2026-07-08 21:34:13 +01:00
Ross 09c8124cdd add workers check 2026-06-03 20:38:10 +01:00
Ross 2949327d39 match cons export format 2026-06-03 20:29:33 +01:00
Ross ea68885ff3 start rota export in grid format 2026-06-03 20:15:34 +01:00
Ross e288585cd3 feat: add max days per week block constraint to Worker class
- Introduced a new method `add_max_days_per_week_block_constraint` to the Worker class.
- This method allows for the addition of constraints that limit the number of days a worker can be assigned within a specified sliding block of weeks.
- The method accepts parameters for maximum days, the week block size, and optional dates to ignore.
2026-06-02 13:50:31 +01:00
Ross 4cad1158c6 use cons sync view 2026-05-20 21:12:42 +01:00
Ross f90e6895b4 Add functionality to export timetable.js and enhance worker list rendering with detailed information 2026-05-19 22:13:00 +01:00
Ross 827753644f Add debug logging to renderWorkerList for improved troubleshooting 2026-05-19 21:38:12 +01:00
Ross 935b4baaba Add ignore_dates field to worker constraints and implement related tests 2026-05-19 21:31:05 +01:00
Ross dee80ed4fa Add constraints for maximum days and unique shifts per week block in worker model 2026-05-19 21:23:09 +01:00
Ross a071b1b027 Update worker scheduling constraints in load_workers for improved shift management 2026-05-19 21:01:18 +01:00
Ross ee263aade1 Refactor worker scheduling constraints in load_workers and main functions for improved clarity and functionality 2026-05-19 20:45:23 +01:00
Ross 68b80ace23 Refactor date parsing and configuration constants in gen_cons.py for improved clarity and maintainability 2026-05-19 20:37:06 +01:00
14 changed files with 2436 additions and 875 deletions
+46 -1
View File
@@ -16,10 +16,13 @@ try:
WorkRequests,
HardDayDependency,
HardDayExclusion,
ShiftStartDate,
ShiftEndDate,
)
except Exception:
NonWorkingDays = OutOfProgramme = NotAvailableToWork = PreferenceNotToWork = None
WorkRequests = HardDayDependency = HardDayExclusion = None
ShiftStartDate = ShiftEndDate = None
class WorkerForm(forms.ModelForm):
@@ -45,6 +48,10 @@ class WorkerForm(forms.ModelForm):
# Complex structured options edited as JSON
assign_as_block_preferences = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON mapping shift_name->weight", label="Block preferences (JSON)")
shift_fte_overrides = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON mapping shift_name->fte", label="Shift FTE overrides (JSON)")
exact_shifts = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON mapping shift_name->exact_count", label="Exact shifts (JSON)")
shift_start_dates = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON mapping shift_name->start_date (YYYY-MM-DD)", label="Shift start dates (JSON)")
shift_end_dates = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON mapping shift_name->end_date (YYYY-MM-DD)", label="Shift end dates (JSON)")
groups = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON list of group names (e.g. [\"group1\", \"group2\"])", label="Groups (JSON)")
previous_shifts = forms.CharField(required=False, widget=forms.Textarea, help_text="Free JSON structure for previous shifts", label="Previous shifts (JSON)")
shift_balance_extra = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON structure for extra shift-balance penalties", label="Shift balance extra (JSON)")
allowed_multi_shift_sets = forms.CharField(required=False, widget=forms.Textarea, help_text="JSON list of sets (e.g. [[\"a\",\"b\"], ...])", label="Allowed multi-shift sets (JSON)")
@@ -94,6 +101,10 @@ class WorkerForm(forms.ModelForm):
for json_fld in [
"assign_as_block_preferences",
"shift_fte_overrides",
"exact_shifts",
"shift_start_dates",
"shift_end_dates",
"groups",
"previous_shifts",
"shift_balance_extra",
"allowed_multi_shift_sets",
@@ -146,6 +157,10 @@ class WorkerForm(forms.ModelForm):
json_fields = [
"assign_as_block_preferences",
"shift_fte_overrides",
"exact_shifts",
"shift_start_dates",
"shift_end_dates",
"groups",
"previous_shifts",
"shift_balance_extra",
"allowed_multi_shift_sets",
@@ -266,9 +281,10 @@ class WorkerForm(forms.ModelForm):
cleaned["work_requests"] = _parse_and_validate("work_requests", WorkRequests)
cleaned["locum_availability"] = _parse_and_validate("locum_availability", WorkRequests)
# Hard day dependency/exclusion structures
cleaned["hard_day_dependencies"] = _parse_and_validate("hard_day_dependencies", HardDayDependency)
cleaned["hard_day_exclusions"] = _parse_and_validate("hard_day_exclusions", HardDayExclusion)
cleaned["shift_start_dates"] = _parse_and_validate("shift_start_dates", ShiftStartDate)
cleaned["shift_end_dates"] = _parse_and_validate("shift_end_dates", ShiftEndDate)
# forced assignments: expect list of tuples [week, day, shift]
fa = cleaned.get("forced_assignments")
@@ -797,6 +813,16 @@ class ShiftForm(forms.Form):
widget=forms.Textarea(attrs={"rows": 4, "class": "textarea"}),
help_text="Optional JSON list of constraint objects for this shift (e.g. [{'name':'night','options':{}}])",
)
include_groups = forms.CharField(
required=False,
help_text="Comma separated list of groups that can work this shift (leave empty for all)",
label="Include groups",
)
exclude_groups = forms.CharField(
required=False,
help_text="Comma separated list of groups that cannot work this shift",
label="Exclude groups",
)
def clean_sites(self):
val = self.cleaned_data["sites"]
@@ -838,3 +864,22 @@ class ShiftForm(forms.Form):
raise
except Exception as exc:
raise forms.ValidationError(f"Invalid JSON for constraints: {exc}")
def clean_include_groups(self):
val = self.cleaned_data.get("include_groups") or ""
return [g.strip() for g in val.split(",") if g.strip()]
def clean_exclude_groups(self):
val = self.cleaned_data.get("exclude_groups") or ""
return [g.strip() for g in val.split(",") if g.strip()]
def clean(self):
cleaned_data = super().clean()
include = cleaned_data.get("include_groups") or []
exclude = cleaned_data.get("exclude_groups") or []
overlap = set(include).intersection(set(exclude))
if overlap:
raise forms.ValidationError(
f"Include groups and Exclude groups cannot overlap. Overlapping groups: {', '.join(overlap)}"
)
return cleaned_data
+14
View File
@@ -229,6 +229,7 @@ class Worker(models.Model):
"max_shifts_per_week_by_shift_name",
"assign_as_block_preferences",
"shift_fte_overrides",
"exact_shifts",
]
list_keys = [
"oop",
@@ -241,6 +242,8 @@ class Worker(models.Model):
"hard_day_exclusions",
"forced_assignments",
"forced_assignments_by_date",
"shift_start_dates",
"shift_end_dates",
]
for k in int_keys:
@@ -273,6 +276,17 @@ class Worker(models.Model):
else:
pyd_kwargs[k] = []
set_keys = ["groups"]
for k in set_keys:
v = pyd_kwargs.get(k, None)
try:
if v is None:
pyd_kwargs[k] = set()
else:
pyd_kwargs[k] = set(v)
except Exception:
pyd_kwargs[k] = set()
# allowed_multi_shift_sets is expected to be a list of sets; if the
# DB contains lists-of-lists convert inner lists to sets.
ams = pyd_kwargs.get("allowed_multi_shift_sets")
+7
View File
@@ -1690,6 +1690,8 @@ def shift_add(request, rota_id):
"workers_required": int(data["workers_required"]),
"assign_as_block": bool(data["assign_as_block"]),
"balance_offset": data.get("balance_offset"),
"include_groups": data.get("include_groups", []),
"exclude_groups": data.get("exclude_groups", []),
"constraints": data.get("constraints", []),
}
@@ -1738,6 +1740,9 @@ def shift_edit(request, rota_id, idx):
"days": data["days"],
"workers_required": int(data["workers_required"]),
"assign_as_block": bool(data["assign_as_block"]),
"balance_offset": data.get("balance_offset"),
"include_groups": data.get("include_groups", []),
"exclude_groups": data.get("exclude_groups", []),
"constraints": data.get("constraints", []),
}
current = rota.shifts or []
@@ -1766,6 +1771,8 @@ def shift_edit(request, rota_id, idx):
"workers_required": shift.get("workers_required"),
"assign_as_block": shift.get("assign_as_block", False),
"balance_offset": shift.get("balance_offset", None),
"include_groups": ",".join(shift.get("include_groups") or []),
"exclude_groups": ",".join(shift.get("exclude_groups") or []),
"constraints": json.dumps(shift.get("constraints", [])) if shift.get("constraints") is not None else "",
}
form = ShiftForm(initial=initial)
+261 -189
View File
@@ -2,6 +2,7 @@ from pyomo.opt.results.container import ignore
from collections import defaultdict
import datetime
import os
import re
import sys
import time
from rota_generator.shifts import MaxShiftsPerWeekConstraint, NoWorkers, PostShiftConstraint, PreShiftConstraint, RotaBuilder, SingleShift, WorkerRequirement, days, RotaConstraintOptions, MaxShiftsPerWeekBlockConstraint
@@ -15,23 +16,168 @@ from rota_generator.workers import (
NonWorkingDays,
WorkRequests,
PreferenceNotToWork,
OutOfProgramme,
OutOfProgramme, MaxUniqueShiftsPerWeekBlockConstraint,
)
from loguru import logger
app = typer.Typer()
ROTA_REQUESTS = "/home/ross/Sync/cons/requests.ods"
ROTA_B_PATH = "/home/ross/Sync/cons/rota_b.xlsx"
ROTA_START_DATE = "2026-08-03"
sites = ("rota a", "rota b", "rota c")
def _normalize_worker_key(value):
text = str(value or "").strip()
if not text:
return ""
return re.sub(r"\s+", " ", text).lower()
def _validate_worker_alignment(base_workers, other_workers, base_label, other_label):
"""Validate that two worker collections contain the same members.
Comparison is case-insensitive and whitespace-normalized.
Raises ValueError with a clear diff when the sets do not match.
"""
base_clean = [str(w).strip() for w in base_workers if str(w).strip()]
other_clean = [str(w).strip() for w in other_workers if str(w).strip()]
base_norm_to_raw = {_normalize_worker_key(w): w for w in base_clean}
other_norm_to_raw = {_normalize_worker_key(w): w for w in other_clean}
base_norm = set(base_norm_to_raw.keys())
other_norm = set(other_norm_to_raw.keys())
only_in_base_norm = sorted(base_norm - other_norm)
only_in_other_norm = sorted(other_norm - base_norm)
if not only_in_base_norm and not only_in_other_norm:
return
only_in_base = [base_norm_to_raw[k] for k in only_in_base_norm]
only_in_other = [other_norm_to_raw[k] for k in only_in_other_norm]
raise ValueError(
"Worker mismatch detected between sources. "
f"{base_label} only: {only_in_base}. "
f"{other_label} only: {only_in_other}."
)
def _parse_sheet_date(value):
if pd.isna(value):
return None
parsed = pd.to_datetime(value, errors="coerce")
if pd.isna(parsed):
return None
return parsed.date()
def _parse_worked_target_cell(value):
"""Parse values like '5 (6.88)' into (worked, target)."""
if pd.isna(value):
return None
text = str(value).strip()
if not text or text.lower() in {"nan", "-"}:
return None
match = re.match(r"^\s*(-?\d+(?:\.\d+)?)\s*\(([^)]+)\)\s*$", text)
if match:
try:
worked = float(match.group(1).strip())
target = float(match.group(2).strip())
return worked, target
except Exception:
return None
# Fallback for cells that contain only one number.
try:
target = float(text)
return 0.0, target
except Exception:
return None
def _load_priors_from_running_totals(running_totals_df):
"""Extract previous shifts map from Running totals tab.
Reads only oncall nights, twilights and weekend A values.
"""
priors_map = {}
if running_totals_df is None or running_totals_df.empty:
return priors_map
# Find the header row that starts with 'Worker'.
header_row_index = None
for i in range(len(running_totals_df)):
first_cell = str(running_totals_df.iloc[i, 0]).strip().lower()
if first_cell == "worker":
header_row_index = i
break
if header_row_index is None:
return priors_map
header_values = [
str(c).strip().lower() if not pd.isna(c) else ""
for c in running_totals_df.iloc[header_row_index].tolist()
]
def _find_col(header_name):
for idx, col in enumerate(header_values):
if col == header_name:
return idx
return None
worker_col = _find_col("worker")
oncall_col = _find_col("oncall night")
twilight_col = _find_col("twilight")
weekend_a_col = _find_col("weekend a")
if worker_col is None:
return priors_map
for i in range(header_row_index + 1, len(running_totals_df)):
row = running_totals_df.iloc[i]
worker_raw = row.iloc[worker_col] if worker_col < len(row) else None
worker = str(worker_raw).strip() if not pd.isna(worker_raw) else ""
if not worker or worker.lower() in {"nan", "total"}:
continue
prev = {}
if oncall_col is not None and oncall_col < len(row):
parsed = _parse_worked_target_cell(row.iloc[oncall_col])
if parsed is not None:
prev["oncall"] = parsed
if twilight_col is not None and twilight_col < len(row):
parsed = _parse_worked_target_cell(row.iloc[twilight_col])
if parsed is not None:
prev["twilight"] = parsed
if weekend_a_col is not None and weekend_a_col < len(row):
parsed = _parse_worked_target_cell(row.iloc[weekend_a_col])
if parsed is not None:
prev["weekend"] = parsed
if prev:
priors_map[worker] = prev
return priors_map
def extract_leave_and_rota_from_calender(calender_df):
"""
Extract leave and rota assignments from calender_df.
- The first row contains worker names, starting from the 5th column (index 4).
- The second column (index 1) contains dates in dd/mm/yyyy format.
- The second column (index 1) also contains the rota/leave code for that date.
Returns a list of dicts: {"date": ..., "worker": ..., "rota": ...}
Returns a list of dicts: {"date": ..., "/worker": ..., "rota": ...}
"""
# Get worker names from the first row, starting from column 5 (index 4)
worker_names = [str(x).strip() for x in calender_df.columns[5:]]
@@ -49,15 +195,11 @@ def extract_leave_and_rota_from_calender(calender_df):
work_requests = []
# Process leave data from row 3 onwards (index 2 and up)
for idx, row in calender_df.iloc[3:].iterrows():
date_str = str(row.iloc[1]).strip().split(" ")[0] # Get the date string from the second column (index 1)
if not date_str or date_str.lower() == "nan":
date = _parse_sheet_date(row.iloc[1])
if date is None:
continue
try:
date = datetime.datetime.strptime(date_str, "%Y-%m-%d").date()
except Exception:
continue # skip rows with invalid date
if date < datetime.date(2026, 2, 16):
if date < datetime.date.fromisoformat(ROTA_START_DATE):
continue # skip rows before rota start date
for i, worker in enumerate(worker_names):
if not worker or worker.lower() == "nan":
@@ -81,7 +223,7 @@ def extract_leave_and_rota_from_calender(calender_df):
#print(leave_requests)
return leave_requests, work_requests#, rota_data
return leave_requests, work_requests, worker_names
def extract_shift_assignments_from_rota(rota_df):
"""
@@ -96,29 +238,30 @@ def extract_shift_assignments_from_rota(rota_df):
assignments = []
skipped_rows = 0
rota_start = datetime.date.fromisoformat(ROTA_START_DATE)
for idx, row in rota_df.iloc[2:].iterrows():
week_start_str = str(row.iloc[0]).strip().split(" ")[0] # Get the week start date from the first column (index 0)
try:
week_start = datetime.datetime.strptime(week_start_str, "%Y-%m-%d").date()
except Exception:
print(f"Invalid date format in row {idx}: {week_start_str}")
week_start = _parse_sheet_date(row.iloc[0])
if week_start is None:
print(f"Invalid date format in row {idx}: {row.iloc[0]}")
continue # skip rows with invalid date
if week_start < datetime.date(2026, 2, 16):
if week_start < rota_start:
skipped_rows += 1
continue # skip rows before rota start date
week = ((week_start - rota_start).days // 7) + 1
for i, day in enumerate(days):
for j, shift in enumerate(shifts):
if i < 5:
# Mon-Fri: two columns per day (twilight, oncall)
col_idx = 1 + i * 2 + j # 1 for Mon twilight, 2 for Mon oncall, etc.
col_idx = 2 + i * 2 + j # 2 for Mon twilight, 3 for Mon oncall, etc.
weekend = False
else:
# Sat/Sun: only one column for "weekend" shift (twilight), skip "night"
weekend = True
if j == 0:
col_idx = 11 + (i - 5) # 11 for Sat, 12 for Sun
col_idx = 12 + (i - 5) # 12 for Sat, 13 for Sun
else:
continue # No night shift column for Sat/Sun
if col_idx >= len(row):
@@ -131,7 +274,7 @@ def extract_shift_assignments_from_rota(rota_df):
shift = "weekend"
assignments.append({
"week_start": week_start,
"week": idx-1-skipped_rows, # 1-indexed week number
"week": week,
"day": day,
"date": week_start + datetime.timedelta(days=i),
"shift": shift,
@@ -149,7 +292,7 @@ def extract_shift_assignments_from_rota(rota_df):
def load_workers():
# Path to the ODS file
ods_path = "cons/CONSULTANT TWILIGHT & ONCALL ROTA.ods"
ods_path = ROTA_REQUESTS
# Read all sheets
xls = pd.read_excel(ods_path, engine="odf", sheet_name=None)
@@ -158,15 +301,22 @@ def load_workers():
days_df = xls["Days"]
calender_df = xls["Calendar"]
rota_df = xls["Rota"]
running_totals_df = xls.get("Running totals")
rota_b_path = "cons/Rota B Feb -July 2026.xlsx"
rota_b_path = ROTA_B_PATH
rota_b_df = pd.read_excel(rota_b_path, engine="openpyxl", sheet_name="Sheet1", header=None)
# Extract rota_b assignments: date in column A, worker in column B
rota_b_assignments = defaultdict(list)
for idx, row in rota_b_df.iterrows():
logger.debug(f"{idx}: {row}")
date_val = row.iloc[0]
worker_val = row.iloc[1].upper() if len(row) > 1 else None
try:
worker_val = row.iloc[1].upper() if len(row) > 1 else None
except AttributeError:
# end of file
break
if pd.isna(date_val) or pd.isna(worker_val):
continue
try:
@@ -180,53 +330,9 @@ def load_workers():
print(rota_b_assignments)
# You can now use rota_b_assignments as needed
# --- Load prior allocations (targets) from CSV ---
priors_path = "cons/sep2025priors.csv"
priors_map = {}
try:
priors_df = pd.read_csv(priors_path)
for _, prow in priors_df.iterrows():
initial = str(prow.get("Worker", "")).strip()
if not initial or initial.lower() == "nan":
continue
prev = {}
# expected columns: oncall, twilight, weekend
for col in ("oncall", "twilight", "weekend"):
raw = prow.get(col, "")
if pd.isna(raw) or raw == "" or str(raw).strip() in ("-", "nan"):
continue
s = str(raw).strip()
# Expect formats like: '4 (2.50)' or '3 (2.86)'
worked = 0.0
allocated = 0.0
if "(" in s:
try:
worked_part = s.split("(")[0].strip()
worked = float(worked_part)
except Exception:
worked = 0.0
try:
inner = s.split("(", 1)[1].split(")", 1)[0]
allocated = float(inner.strip())
except Exception:
allocated = worked
else:
try:
allocated = float(s)
except Exception:
continue
worked = 0.0
# store as tuple (worked, allocated) as expected by RotaBuilder
prev[col] = (worked, allocated)
if prev:
priors_map[initial] = prev
logger.debug("priors_map: {}", priors_map)
except FileNotFoundError:
print(f"Prior allocations file not found: {priors_path}")
except Exception as e:
print(f"Error reading prior allocations: {e}")
# --- Load prior allocations from requests.ods -> Running totals tab ---
priors_map = _load_priors_from_running_totals(running_totals_df)
logger.debug("priors_map from Running totals: {}", priors_map)
# --- Example: Print the first few rows of each sheet ---
#print("Days sheet:")
@@ -236,132 +342,67 @@ def load_workers():
rota_data = {}
for idx, row in days_df.iloc[7:].iterrows():
# Extract initials from the name column (assumed to be in brackets at the end)
text = str(row.iloc[1]).strip()
if "(" in text and ")" in text:
initial = text.split("(")[-1].split(")")[0].strip()
else:
raise ValueError(f"Invalid format in row {idx}: {text}")
name = text.split("(")[0].strip()
name = str(row.iloc[1]).strip()
initial = str(row.iloc[2]).strip()
if not name or name.lower() == "nan":
continue
availability = {}
requests = {}
rota_data[name] = f"rota {str(row.iloc[2]).strip().lower()}"
rota_data[name] = f"rota {str(row.iloc[3]).strip().lower()}"
for i, day in enumerate(weekday_cols):
val = row.iloc[3 + i]
val = row.iloc[4 + i]
# You can adjust the logic below depending on your marking scheme (e.g. "Y", "Yes", "1", etc.)
available = not ("no" in str(val).strip().lower() or str(val).strip().lower() == "yes*")
availability[day] = available
workers_days.append({"initial": initial, "name": name, "availability": availability, "requests": requests})
leave_requests, work_requests = extract_leave_and_rota_from_calender(calender_df)
leave_requests, work_requests, calendar_workers = extract_leave_and_rota_from_calender(calender_df)
assignments, assignments_by_worker = extract_shift_assignments_from_rota(rota_df)
print(assignments)
# --- Load per-day prior worked data (Sat/Sun) from sep2025priordays.csv for Rota A workers ---
# Build set of initials who are on Rota A
rota_a_initials = set()
for wd in workers_days:
name = wd["name"]
initial = wd["initial"]
if rota_data.get(name, "") == "rota a":
rota_a_initials.add(initial)
# Validate cross-source worker matching before constructing Worker objects.
days_workers = [w["name"] for w in workers_days]
days_initials = [w["initial"] for w in workers_days]
priordays_path = "cons/sep2025priordays.csv"
try:
import csv
_validate_worker_alignment(
base_workers=days_workers,
other_workers=calendar_workers,
base_label="Days tab worker names",
other_label="Calendar tab worker names",
)
perday_counts = defaultdict(lambda: defaultdict(int))
with open(priordays_path, newline="", encoding="utf-8") as f:
reader = csv.reader(f)
for row in reader:
if len(row) < 4:
continue
day = row[2].strip()
if day not in ("Sat", "Sun"):
continue
# fields from index 3 onwards are assignments; some may be empty
for field in row[3:]:
if not field:
continue
val = field.strip()
if not val:
continue
# entries may be like: RK (oncall, weekend) or CK (weekend b)
import re
_validate_worker_alignment(
base_workers=days_initials,
other_workers=list(assignments_by_worker.keys()),
base_label="Days tab worker initials",
other_label="Rota tab assigned initials",
)
m = re.match(r"\s*([A-Za-z0-9]+)\s*\(([^)]*)\)", val)
if not m:
# sometimes multiple assignments are concatenated in a single cell separated by commas
parts = [p.strip() for p in re.split(r",\s*", val) if p.strip()]
for p in parts:
m2 = re.match(r"\s*([A-Za-z0-9]+)\s*\(([^)]*)\)", p)
if m2:
initial = m2.group(1).strip()
paren = m2.group(2).lower()
if initial in rota_a_initials and "weekend" in paren:
perday_counts[initial][day] += 1
continue
_validate_worker_alignment(
base_workers=days_initials,
other_workers=list(rota_b_assignments.keys()),
base_label="Days tab worker initials",
other_label="Rota B assignments initials",
)
initial = m.group(1).strip()
paren = m.group(2).lower()
if initial in rota_a_initials and "weekend" in paren:
perday_counts[initial][day] += 1
_validate_worker_alignment(
base_workers=days_initials,
other_workers=list(priors_map.keys()),
base_label="Days tab worker initials",
other_label="Running totals worker initials",
)
# Merge per-day counts into priors_map under 'weekend' per-day keys
for initial, daymap in perday_counts.items():
prev = priors_map.get(initial, {})
existing = prev.get("weekend")
# If existing is a tuple, convert to dict preserving overall tuple under '__all__'
if existing and not isinstance(existing, dict):
prev["weekend"] = {"__all__": existing}
existing = prev["weekend"]
for day, worked_count in daymap.items():
# Determine allocated value for this per-day entry
allocated_day = worked_count
if existing:
# prefer an explicit per-day allocated if present
if isinstance(existing, dict) and day in existing:
try:
allocated_day = float(existing[day][1])
except Exception:
allocated_day = worked_count
elif isinstance(existing, dict) and "__all__" in existing:
try:
overall_alloc = float(existing["__all__"][1])
allocated_day = overall_alloc / 2.0
except Exception:
allocated_day = worked_count
elif isinstance(existing, tuple):
try:
allocated_day = float(existing[1]) / 2.0
except Exception:
allocated_day = worked_count
# ensure structure
if "weekend" not in prev or not isinstance(prev["weekend"], dict):
prev.setdefault("weekend", {})
prev["weekend"][day] = (float(worked_count), float(allocated_day))
if prev:
priors_map[initial] = prev
logger.debug("priors_map after per-day merge: {}", priors_map)
except FileNotFoundError:
print(f"Per-day prior file not found: {priordays_path}")
except Exception as e:
print(f"Error reading per-day priors: {e}")
logger.debug("Using Running totals priors for oncall/twilight/weekend A only")
workers = []
for worker_day in workers_days:
worker = worker_day["name"]
initial = worker_day["initial"]
start_date = "2026-02-16" # Default start date, can be adjusted later
start_date = ROTA_START_DATE
#if initial == "ND":
# start_date = "2025-09-29" # Non-working day worker starts later
@@ -402,10 +443,9 @@ def load_workers():
],
nwds=non_working_days,
previous_shifts=priors_map.get(initial, {}),
max_days_worked_per_week=2
)
if initial == "LB":
w.start_date = datetime.date(2026, 6, 15)
#if initial == "L1":
# w.oop = [OutOfProgramme(
@@ -440,6 +480,7 @@ def load_workers():
shift_name="weekend b",
)
w.add_hard_day_exclusion({"Sun", "Mon"})
if w.site == "rota a":
w.prefer_multi_shift_together = 10
@@ -447,19 +488,18 @@ def load_workers():
#w.add_force_assign_with("twilight", "oncall")
w.add_force_assign_with("weekend", "oncall")
if w.name in ("DS",):
if w.name in ("DS","RG","MOB","TB"):
w.add_hard_day_dependency({"Fri", "Sat", "Sun"}, ignore_dates=[datetime.date(2026, 5, 2)])
w.max_days_worked_per_week = 3
else:
w.max_days_worked_per_week = 2
w.add_max_days_per_week_block_constraint(max_days=2, week_block=2)
w.add_hard_day_dependency({"Fri", "Sun"})
w.add_hard_day_exclusion({"Sat", "Sun"})
else:
w.max_unique_shifts_per_week_block = [MaxUniqueShiftsPerWeekBlockConstraint(week_block=1, max_unique_shifts=1)]
#if w.name == "TB":
# w.add_hard_day_exclusion({"Sat", "Sun"}, start_date="2025-11-17")
#w.set_max_shifts
#w.set_max_shifts_per_week("weekend", 1)
workers.append(w)
@@ -467,14 +507,37 @@ def load_workers():
def parse_time_limit(t) -> int:
"""Parses a time limit string (e.g. '10h', '30m', '45s') or an integer to seconds."""
if isinstance(t, int):
return t
if isinstance(t, float):
return int(t)
t = str(t).strip().lower()
if not t:
return 0
if t.isdigit():
return int(t)
if t.endswith("h"):
return int(float(t[:-1]) * 3600)
if t.endswith("m"):
return int(float(t[:-1]) * 60)
if t.endswith("s"):
return int(float(t[:-1]))
try:
return int(float(t))
except ValueError:
raise ValueError(f"Invalid time format: {t}. Expected format like '10h', '30m', '45s' or a number of seconds.")
@app.command()
def main(
suspend: bool = False,
solve: bool = True,
time_to_run: int = 60 * 60,
ratio: float = 0.001,
start_date: datetime.datetime = "2026-02-16",
weeks: int = 24,
time_to_run: str = "1h",
ratio: float = 0.1,
start_date: datetime.datetime = ROTA_START_DATE,
weeks: int = 22,
bom: int = 1,
):
rota_start_date = start_date.date()
@@ -485,13 +548,13 @@ def main(
weeks_to_rota=weeks,
balance_offset_modifier=bom,
use_previous_shifts=True,
name="cons rota feb 2026 run 5",
name="cons rota test",
allow_force_assignment_with_leave_conflict=True,
constraint_options=RotaConstraintOptions(
balance_weekends=True,
max_shifts_per_week=6,
max_weekend_frequency=3,
max_days_per_week_block=[(4, 2), (4, 4)],
max_days_per_week_block=[(3, 2), (4, 4)],
#balance_weekend_days=True,
balance_weekend_days_quadratic=True,
weekend_day_hard_max_deviation=2,
@@ -512,8 +575,8 @@ def main(
balance_offset=2,
assign_as_block=False,
constraints=[
PreShiftConstraint(days=2, start_date="2025-11-17", exclude_days=("Fri", "Sat", "Sun"), exclude_dates=["2026-04-06", "2026-05-04", "2026-05-25"]),
PreShiftConstraint(days=1, start_date="2025-11-17", exclude_days=("Fri", "Sat", "Sun"), allow_self=False),
#PreShiftConstraint(days=2, start_date="2025-11-17", exclude_days=("Fri", "Sat", "Sun"), exclude_dates=["2026-04-06", "2026-05-04", "2026-05-25"]),
#PreShiftConstraint(days=1, start_date="2025-11-17", exclude_days=("Fri", "Sat", "Sun"), allow_self=False),
MaxShiftsPerWeekConstraint(max_shifts=1, days=days[:5]),
],
),
@@ -526,6 +589,8 @@ def main(
balance_offset=1,
constraints=[
PostShiftConstraint(days=1, start_date="2025-11-17", ignore_shifts=["oncall"]),
#PreShiftConstraint(days=1),
#PostShiftConstraint(days=3),
],
display_char="a",
#force_assign_with=["oncall"]
@@ -533,12 +598,16 @@ def main(
SingleShift(
sites=("rota b", "rota c"),
name="weekend b",
end_date="2026-07-20",
#end_date="2026-07-20",
workers_required=1,
length=12.5,
days=days[5:],
balance_offset=2,
display_char="b",
constraints=[
PreShiftConstraint(days=2),
PostShiftConstraint(days=2),
],
),
SingleShift(
sites=(
@@ -652,8 +721,8 @@ def main(
# Rota.build_workers()
# Rota.build_model()
solver_options = {"ratio": ratio, "seconds": time_to_run, "threads": 10}
# solver_options = {"seconds": time_to_run, "threads": 10}
seconds_to_run = parse_time_limit(time_to_run)
solver_options = {"ratio": ratio, "seconds": seconds_to_run, "threads": 10}
# start_time = time.time()
Rota.build_and_solve(solver_options, export=True, export_with_timestamp=False, solve=solve, solver="appsi_highs")
@@ -685,6 +754,9 @@ def main(
subprocess.run(
["scp", Rota.exported_rota_file, "ross@46.101.13.46:proc/proc-rota/output/"]
)
subprocess.run(
["scp", "output/timetable.js", "ross@46.101.13.46:proc/proc-rota/output/"]
)
if suspend_on_finish:
os.system("systemctl suspend")
+127 -47
View File
@@ -30,6 +30,11 @@ sites = (
"plymouth",
"taunton",
"proc",
"plymouth ir",
"truro ir",
"exeter ir",
"torbay ir",
"ir nights",
)
from rota_generator.workers import (
@@ -41,15 +46,37 @@ from rota_generator.workers import (
OutOfProgramme,
)
def parse_time_limit(t) -> int:
"""Parses a time limit string (e.g. '10h', '30m', '45s') or an integer to seconds."""
if isinstance(t, int):
return t
if isinstance(t, float):
return int(t)
t = str(t).strip().lower()
if not t:
return 0
if t.isdigit():
return int(t)
if t.endswith("h"):
return int(float(t[:-1]) * 3600)
if t.endswith("m"):
return int(float(t[:-1]) * 60)
if t.endswith("s"):
return int(float(t[:-1]))
try:
return int(float(t))
except ValueError:
raise ValueError(f"Invalid time format: {t}. Expected format like '10h', '30m', '45s' or a number of seconds.")
@app.command()
def main(
suspend: bool = False,
solve: bool = True,
time_to_run: int = 60 * 60 * 10,
ratio: float = 0.0001,
start_date: datetime.datetime = "2026-03-02",
weeks: int = 27,
time_to_run: str = "10h",
ratio: float = 0.1,
start_date: datetime.datetime = "2026-09-07",
weeks: int = 26,
bom: float = 1,
):
rota_start_date = start_date.date()
@@ -59,20 +86,20 @@ def main(
rota_start_date,
weeks_to_rota=weeks,
balance_offset_modifier=bom,
name="proc_rota3",
name="proc_rota_sep_2026",
)
# Rota = RotaBuilder(start_date, weeks_to_rota=20, balance_offset_modifier=1)
Rota.constraint_options["balance_shifts"] = False
Rota.constraint_options["balance_shifts_quadratic"] = True
Rota.constraint_options["balance_weekends"] = True
Rota.constraint_options["max_night_frequency"] = 4 # 3
Rota.constraint_options["max_night_frequency_week_exclusions"] = []
Rota.constraint_options["max_night_frequency"] = 3 # 3
#Rota.constraint_options["max_night_frequency_week_exclusions"] = []
Rota.constraint_options["max_weekend_frequency"] = 3
Rota.constraint_options["hard_constrain_pair_separation"] = True
Rota.constraint_options["max_days_per_week_block"] = [(8,4)]
Rota.constraint_options["max_days_per_week_block"] = [(8,3)]
Rota.constraint_options["maximum_allowed_shift_diff"] = 1
#wr = [
# wr = [
# WorkerRequirement(
# end_date=datetime.datetime.strptime("2025-06-02", "%Y-%m-%d").date(),
# number=3,
@@ -81,7 +108,7 @@ def main(
# start_date=datetime.datetime.strptime("2025-06-02", "%Y-%m-%d").date(),
# number=4,
# ),
#]
# ]
Rota.add_shifts(
SingleShift(
@@ -90,6 +117,7 @@ def main(
"exeter twilights and weekends",
"exeter no nights",
"exeter twilights",
"exeter ir",
),
name="exeter_twilight",
length=12.5,
@@ -105,6 +133,7 @@ def main(
"truro twilights and weekends",
"truro twilights and weekend nights",
"truro twilights, weekends and weekend nights",
"truro ir",
),
name="truro_twilight",
length=12.5,
@@ -113,7 +142,12 @@ def main(
constraints=[MaxShiftsPerWeekConstraint(max_shifts=1)],
),
SingleShift(
sites=("torbay", "torbay twilights", "torbay twilights and weekends"),
sites=(
"torbay",
"torbay twilights",
"torbay twilights and weekends",
"torbay ir",
),
name="torbay_twilight",
length=12.5,
days=days[:4],
@@ -126,6 +160,7 @@ def main(
"plymouth twilights",
"plymouth twilights and weekends",
"plymouth twilights, weekends and weekend nights",
"plymouth ir",
),
name="plymouth_twilight",
length=12.5,
@@ -140,12 +175,12 @@ def main(
"exeter twilights and weekends",
"exeter no nights",
"exeter weekends",
#"exeter twilights",
# "exeter twilights",
),
name="weekend_exeter",
length=12.5,
days=days[5:],
#balance_offset=2,
# balance_offset=2,
rota_on_nwds=True,
force_as_block=True,
constraints=[PostShiftConstraint(days=2), PreShiftConstraint(days=2)],
@@ -162,19 +197,19 @@ def main(
name="weekend_truro",
length=12.5,
days=days[4:],
#balance_offset=3,
rota_on_nwds=True,
force_as_block=True,
#assign_as_block=True,
# balance_offset=3,
#rota_on_nwds=True,
#force_as_block=True,
# assign_as_block=True,
constraints=[PreShiftConstraint(days=2), PostShiftConstraint(days=2)],
# force_as_block_unless_nwd=True
force_as_block_unless_nwd=[["Fri"], ["Sat", "Sun"]],
),
SingleShift(
sites=("torbay", "torbay twilights and weekends"),
name="weekend_torbay",
length=12.5,
days=days[4:],
#balance_offset=2,
# balance_offset=2,
rota_on_nwds=True,
force_as_block=True,
constraints=[PreShiftConstraint(days=2), PostShiftConstraint(days=2)],
@@ -190,7 +225,7 @@ def main(
name="weekend_plymouth1",
length=8,
days=days[5:],
#balance_offset=2.9,
# balance_offset=2.9,
workers_required=1,
rota_on_nwds=True,
force_as_block=True,
@@ -205,7 +240,7 @@ def main(
name="weekend_plymouth2",
length=8,
days=days[5:],
#balance_offset=2.9,
# balance_offset=2.9,
workers_required=1,
rota_on_nwds=True,
force_as_block=True,
@@ -217,6 +252,7 @@ def main(
"plymouth twilights",
"plymouth twilights and weekends",
"plymouth twilights, weekends and weekend nights",
"plymouth ir",
),
name="plymouth_bank_holidays",
length=8,
@@ -228,11 +264,13 @@ def main(
bank_holidays_only=True,
),
SingleShift(
sites=sites,
sites=[
*sites,
],
name="night_weekday",
length=12.25,
days=days[:4],
#balance_offset=3.9,
# balance_offset=3.9,
balance_weighting=1,
# hard_constrain_shift=False,
workers_required=4,
@@ -246,7 +284,7 @@ def main(
LimitGradeNumberConstraint(grade=2, max_number=2),
MinimumGradeNumberConstraint(grade=4, min_number=1),
],
#end_date=datetime.datetime.strptime("2025-06-01", "%Y-%m-%d").date(),
# end_date=datetime.datetime.strptime("2025-06-01", "%Y-%m-%d").date(),
),
SingleShift(
sites=[
@@ -259,7 +297,7 @@ def main(
name="night_weekend",
length=12.25,
days=days[4:],
#balance_offset=2.9,
# balance_offset=2.9,
balance_weighting=1,
# hard_constrain_shift=False,
workers_required=4,
@@ -273,15 +311,19 @@ def main(
LimitGradeNumberConstraint(grade=2, max_number=2),
MinimumGradeNumberConstraint(grade=4, min_number=1),
],
#end_date=datetime.datetime.strptime("2025-06-01", "%Y-%m-%d").date(),
# end_date=datetime.datetime.strptime("2025-06-01", "%Y-%m-%d").date(),
),
)
# Prevent shifts for ST2s for 2 weeks
#Rota.add_grade_constraint_by_week([2], [1, 2], [shift.name for shift in Rota.shifts])
# Rota.add_grade_constraint_by_week([2], [1, 2], [shift.name for shift in Rota.shifts])
Rota.add_min_summed_grade_by_shifts_per_day_constraint(["weekend_plymouth1", "weekend_plymouth2"], 6)
Rota.add_min_summed_grade_by_shifts_per_day_constraint(["plymouth_twilight", "plymouth_bank_holidays"], 6)
Rota.add_min_summed_grade_by_shifts_per_day_constraint(
["weekend_plymouth1", "weekend_plymouth2"], 6
)
Rota.add_min_summed_grade_by_shifts_per_day_constraint(
["plymouth_twilight", "plymouth_bank_holidays"], 6
)
Rota.add_min_summed_grade_by_shifts_per_day_constraint(["night_weekday"], 12)
Rota.add_min_summed_grade_by_shifts_per_day_constraint(["night_weekend"], 12)
@@ -328,7 +370,7 @@ def main(
after_count = len(filtered)
if after_count != before_count:
print(
f"Removed {before_count-after_count} leave entries for '{name}' between {_from} and {_to}"
f"Removed {before_count - after_count} leave entries for '{name}' between {_from} and {_to}"
)
w["leave"] = filtered
@@ -376,9 +418,9 @@ def main(
nwd_end_date = Rota.rota_end_date
if "[" in i:
print("Split nwd", worker_name, i)
# print("Split nwd", worker_name, i)
a, b = i.split("[")[1][:-1].split("-")
print(f"{a=} {b=}")
# print(f"{a=} {b=}")
nwd_start_date = datetime.datetime.strptime(
a, "%d/%m/%Y"
).date()
@@ -407,7 +449,9 @@ def main(
elif "to" in raw:
s, e = raw.split("to", 1)
else:
raise ValueError(f"Cannot parse OOP dates: '{oop}' for {worker}")
raise ValueError(
f"Cannot parse OOP dates: '{oop}' for {worker}"
)
s = s.strip().strip("()[]\"' ")
e = e.strip().strip("()[]\"' ")
@@ -417,7 +461,9 @@ def main(
try:
start_dt = datetime.datetime.strptime(s, fmt).date()
end_dt = datetime.datetime.strptime(e, fmt).date()
formatted_oops.append({"start_date": start_dt, "end_date": end_dt})
formatted_oops.append(
{"start_date": start_dt, "end_date": end_dt}
)
parsed = True
break
except ValueError:
@@ -426,13 +472,15 @@ def main(
if not parsed:
print("WORKER", worker)
print("DATES", s, e)
raise ValueError(f"Cannot parse OOP dates: '{oop}' for {worker}")
raise ValueError(
f"Cannot parse OOP dates: '{oop}' for {worker}"
)
oop = formatted_oops
else:
oop = []
print(f"{worker} OOP {oop}")
# print(f"{worker} OOP {oop}")
previous_shifts = {}
# if twi:
@@ -466,16 +514,47 @@ def main(
shift_fte_overrides = {}
if worker_name == "Hadi Mohamed":
shift_fte_overrides = {
"weekend_exeter": 50,
exact_shifts = {}
if worker in ["hannah.lewis29@nhs.net", "shruti.bodapati@nhs.net", "a.abouelatta@nnhs.net"]:
exact_shifts = {
"night_weekday": 4,
"night_weekend": 3,
}
#elif worker_name == "Joel Lim":
elif worker in ["g.abdelhalim@nhs.net", "nang.thiriphoo@nhs.net"]:
exact_shifts = {
"night_weekend": 3,
"night_weekday": 0,
}
override_shift_start_dates = []
if worker in ["Anushka Kulkarni"]:
override_shift_start_dates = [
{
"shift": "night_weekday",
"start_date": datetime.datetime.strptime(
"2026-11-01", "%Y-%m-%d"
).date(),
},
{
"shift": "night_weekend",
"start_date": datetime.datetime.strptime(
"2026-11-01", "%Y-%m-%d"
).date(),
},
]
# if worker_name == "Hadi Mohamed":
# shift_fte_overrides = {
# "weekend_exeter": 50,
# }
# elif worker_name == "Joel Lim":
# shift_fte_overrides = {
# "plymouth_twilight": 100,
# "weekend_exeter": 50,
# }
#if worker_name == "Nang Thiriphoo":
# if worker_name == "Nang Thiriphoo":
# shift_fte_overrides = {
# "weekend_exeter": 40,
# }
@@ -498,9 +577,11 @@ def main(
shift_balance_extra=w["shift_balance_extra"],
bank_holiday_extra=w["bank_holiday_extra"],
shift_fte_overrides=shift_fte_overrides,
exact_shifts=exact_shifts,
shift_start_dates=override_shift_start_dates,
)
print(w)
# print(w)
Rota.add_worker(w)
leave.load_academy(Rota)
@@ -508,12 +589,11 @@ def main(
# Rota.build_workers()
# Rota.build_model()
solver_options = {"ratio": ratio, "seconds": time_to_run, "threads": 10}
# solver_options = {"seconds": time_to_run, "threads": 10}
seconds_to_run = parse_time_limit(time_to_run)
solver_options = {"ratio": ratio, "seconds": seconds_to_run, "threads": 10}
# start_time = time.time()
Rota.build_and_solve(solver_options, export=True, solve=solve, solver="appsi_highs")
Rota.build_and_solve(solver_options, export=True, solve=solve, solver="appsi_highs", export_with_timestamp=True)
# Rota.solve_shifts_by_block(solver_options, block_length=13)
# Rota.solve_shifts_individually(solver_options)
-387
View File
@@ -1,387 +0,0 @@
Rota 2021/2022,,, (POSSIBLE IDT),,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,80% until dec 1st,,,,,,,,,,,,,,,,
Name,,Stephanie Bailey,Luciana Calovi Motschenbacher,Layla Bell,Seren Peters,Alex wood,Salma Aslam,Max Ireland,Charles Finan,Paul Ward,Tom Welsh,Delilah Trimmer,Max Finzel,Zainab Sharaf,Maththew O'Brien,Kyaw Tint,Nicolas Dziadulewicz,Jean Skumar,Michael Tomek,Vilim Kalamar,Dhuvresh Patel,Khalil Madbak,Georgina Edwards,Jenn Haw fong,Paul Jenkins,Jenna Millington,Amoolya Mannava,Ross Kruger,Sophie McGlade,Rebecca Murphy,Fiona Lyall,Joel Lim,Wijhdan Abusrewil,Mark Haley,Andrew MacCormick,Alexander Sanchez - cabello,Sayed Hashim Alqarooni,Chong Yew Ng,Matthew Thorley,Harriet Conley,Jon Skinner,Cameron Bullock,Alexander Wijnberg,Christian Greer,Alan Eccles,Hannah Lewis,Sarath Vennam,Amina Odeh,Mo Babsail,Richard Chaytor,Sherafghan PROC,Madalina Drumea,Mark Fellows,Clement Leung
Rotation,,Exeter,Exeter,Exeter,Exeter,Exeter,Exeter,Exeter,Exeter,Exeter,Exeter,Exeter,Derriford,Derriford,Derriford,Derriford,Derriford,Derriford,Derriford,Derriford,Derriford,Derriford,Derriford,Derriford,Derriford,Derriford no proc,Derriford,Derriford,Derriford,Derriford,Derriford Twilights,Torbay,Torbay,Torbay,Torbay,Torbay,Torbay,Torbay,Torbay,Torbay,Torbay Twilights,Truro,Truro,Truro,Truro,Truro,Truro,Truro,Truro,Truro,Truro no PROC,Barnstaple,Taunton,Taunton
Grade,,ST2,ST2,ST2,ST3,ST3,ST3,ST3,ST4,ST4,ST5,ST5,ST2,ST2,ST2,ST2,ST3,ST3,ST3,ST3,ST3,ST4,ST4,ST4,ST4,ST4,ST5,ST5,ST5,ST5,ST6,ST2,ST2,ST2,ST2,ST3,ST4,ST4,ST4,ST5,ST6,ST2,ST2,ST3,ST3,ST3,ST4,ST4,ST5,ST5,ST5 (until dec),ST3,ST2,ST4
PROC site,,Derriford,,exeter,Torbay,Exeter,,Derriford,Derriford,Exeter,Exeter,Derriford,Derriford,torbay,Torbay,derriford,,derriford,exeter,,Exeter,,,derriford,Derriford,,derriford,Derriford,Derriford,Exeter,NA,derriford,Torbay,Derriford,Derriford,,torbay,Derriford,Derriford,Torbay,NA,Exeter,Derriford,Truro,Truro,Derriford,Truro,Truro,Derriford,derriford,Derriford,Barnstaple,NA,NA
%FTE,,0,0,100,100,100,0,100,100,100,100,80,100,100,100,100,100,60,80,100,100,0,80,100,100,0,100,100,0,100,60,100,100,100,100,100,100,100,100,60,100,100,100,100,80,100,100,100,100,100,100,80,0,0
NWD,,,,,,,"Thursday,Friday",,"Tuesday,Thursday",,,wednesday,,,,,,"Wednesday,thursday",Wednesday,,,,Friday,,,"thursday, friday",,,Friday,,"monday,friday",,"thursday, friday",,,,,,,"monday,thursday",,,,,"wednesday, thursday,Friday",,,,,,,,,
Flexible NWD,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
End Date,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
OOP,,,,,,,6/9/21-31/3/22,,,,,,,,,,,,,,,,,,,6/9/21-2/5/22,,,1/1/22-5/9/22,6/9/21-28/2/22,,,1/3/22-31/3/22,,,,,,,6/9/21-20/9/21,,,,,,,,,1/8/22-4/9/22,23/7/22-4/9/22,,,,
Group,,Y1G1,Y1G1,Y1G2,Y3G1,Y3G1,Y3G2,Y3G2,,,,,Y2G1,Y2G2,Y2G2,Y2G2,Y3G1,Y3G1,Y3G2,Y3G2,Y3G2,,,,,,,,,,,Y2G1,Y2G1,Y2G2,Y2G2,Y3G2,,,,,,Y2G1,Y2G2,Y3G1,Y3G1,Y3G2,,,,,,,Y2G1,
?PROC,,,,,,,,,,,,,,,,,,,,,,NO,,,,WEEKEND ONLY,,,,,No,,,,,,,,,,No,,,,,,,,,,Yes - until ST6 in dec,???,NO,NO
Pair,,,,,,,,,,,,,,1,,,,,,,,,,,,,,,,,,,,,,,1,,,,,,,,,,,,,,2,,,
Extra_night_weekday,,3.72,,-0.28,-0.28,-0.28,,-0.28,-0.28,-0.28,-0.28,0.63,-0.28,-0.28,-0.28,-0.28,-0.28,4.63,2.17,-0.28,3.72,,2.17,-0.28,-0.28,,-0.28,-0.28,,0.3,0,-0.28,-0.28,-0.28,3.72,-0.28,-0.28,-1.06,-0.28,0.27,,-0.28,-0.28,-0.28,-1.83,-0.28,-0.28,-0.28,-0.28,-0.28,0,-0.28,,
Extra_night_weekend,,-0.21,,-0.21,-0.21,-0.21,,-0.21,-0.21,-0.21,-0.21,0.47,-0.21,2.79,2.79,2.79,-0.21,-2.53,-1.37,-0.21,-0.21,,-1.37,-0.21,-0.21,,-0.21,-0.21,,0.22,0,-0.21,-0.21,-0.21,-0.21,-0.21,-0.21,2.21,-0.21,0.21,,-0.21,-0.21,-0.21,1.63,-0.21,-0.21,-0.21,-0.21,-0.21,0,2.79,,
Extra_twilight,,-1.97,,-0.97,1.03,1.03,,-0.97,-1.97,1.03,1.03,-0.38,0.47,0.47,-1.53,0.47,-0.53,-3.92,-2.23,0.47,-2.53,,-2.23,0.47,0.47,,0.47,0.47,,0.33,0.08,,,,,,,,,,,-1.39,-1.39,0.61,0.49,-1.39,-0.39,1.61,2.61,-1.39,0.61,0,,
Extra_weekend,,-0.73,,2.27,-0.73,-0.73,,2.27,2.27,-0.73,-0.73,-1.04,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,1.96,-1.04,-1.04,-0.63,1.96,0.96,-1.04,-2.04,1.96,-1.04,0,,
Extra_weekend_plymouth1,,,,,,,,,,,,,-2.22,-0.22,-2.22,-0.22,-0.22,2.27,-0.98,-2.22,-0.22,,1.02,1.78,-0.22,,1.78,1.78,,0.15,0,,,,,,,,,,,,,,,,,,,,,,,
Extra_weekend_plymouth2,,,,,,,,,,,,,1.78,-2.22,1.78,-2.22,-0.22,0.27,3.02,1.78,-0.22,,1.02,-2.22,-0.22,,-2.22,-0.22,,0.15,0,,,,,,,,,,,,,,,,,,,,,,,
Extra_plymouth_bank_holiday,,,,,,,,,,,,,0.2,0.2,0.2,0.2,0.2,0.12,-0.84,0.2,0.2,,0.16,0.2,0.2,,0.2,-1.8,,0.01,0.12,,,,,,,,,,,,,,,,,,,,,,,
bank_holidays_worked,,,,1,,,,1,,2,1,3,,2,1,2,,2,1,,3,,,,,,2,2,,,,2,,2,,1,,1,2,,,,2,2,,,,2,,,1,,,
,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
Date,Exams,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
6/9/21,,,NO ONCALL,,,POST ONCALL,Mat leave,,exeter_twilight,,exeter_twilight,,,A/L,,,,,A/L,,,No Oncall,POST ONCALL,POST ONCALL,POST ONCALL,Mat leave,,,plymouth_twilight,OOPT,NO oncals,,POST ONCALL,,,,A/L,torbay_twilight,torbay_twilight,Mat leave,POST ONCALL,,,,,truro_twilight,truro_twilight,,A/L,POST ONCALL,,,,
7/9/21,,,NO ONCALL,,,POST ONCALL,Mat leave,,exeter_twilight,,exeter_twilight,,,A/L,,,,,A/L,,,No Oncall,POST ONCALL,POST ONCALL,POST ONCALL,Mat leave,,,plymouth_twilight,OOPT,NO oncals,,POST ONCALL,,,,A/L,torbay_twilight,torbay_twilight,Mat leave,POST ONCALL,,,,,truro_twilight,truro_twilight,,A/L,POST ONCALL,,,,
8/9/21,,,NO ONCALL,,,,Mat leave,,2b course,2b course,,,,,,,,,A/L,,,No Oncall,2b course,2b course,,Mat leave,,2b course,,OOPT,NO oncals,,,,,,A/L,2b course,2b course,Mat leave,,,,,,,2b course,2b course,A/L,,,,,
9/9/21,,,NO ONCALL,,,,Mat leave,,2b course,2b course,,,,,,,,,A/L,,,No Oncall,2b course,2b course,,Mat leave,,2b course,,OOPT,NO oncals,,,,,,A/L,2b course,2b course,Mat leave,,,,,,,2b course,2b course,A/L,,,,,
10/9/21,,,NO ONCALL,,A/L,,Mat leave,,,,,,,,,,,,A/L,,,No Oncall,,,,Mat leave,,,A/L,OOPT,NO oncals,,,,,,,,,Mat leave,,,,,,,,,A/L,,,,,
11/9/21,,,NO ONCALL,,A/L,,Mat leave,,,,,,,,,,,,A/L,,,No Oncall,,,,Mat leave,,,A/L,OOPT,NO oncals,,,,,,,,,Mat leave,,,,,,,,,A/L,,,,,
12/9/21,,,NO ONCALL,,A/L,,Mat leave,,,,,,,,,,,,A/L,,,No Oncall,,,,Mat leave,,,A/L,OOPT,NO oncals,,,,,,,,,Mat leave,,,,,,,,,A/L,,,,,
13/9/21,,,NO ONCALL,,A/L,,Mat leave,,,,,,,,,,,,A/L,,,No Oncall,,,,Mat leave,,,A/L,OOPT,NO oncals,,,,,,,,,Mat leave,,,,,,,,,,,,,,
14/9/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,A/L,,,No Oncall,,,,Mat leave,,,,OOPT,NO oncals,,,,,,,,,Mat leave,,,,,,,,,,,,,,
15/9/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,A/L,,,No Oncall,,,,Mat leave,,,,OOPT,NO oncals,,,,,,,,,Mat leave,,,,,,,,,,,,,,
16/9/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,A/L,,,No Oncall,,,,Mat leave,,,,OOPT,NO oncals,,,,,,,,,Mat leave,,,,,,,,,,,,,,
17/9/21,Pre 2B,,NO ONCALL,,,,Mat leave,,Pre 2B,Pre 2B,A/L,Pre 2B,,,,,,,A/L,,,No Oncall,,Pre 2B,,Mat leave,,,,OOPT,NO oncals,,,,,,Pre 2B,Pre 2B,Pre 2B,Mat leave,,,,,Pre 2B,,Pre 2B,Pre 2B,,,,,,
18/9/21,Pre 2B,,NO ONCALL,,,,Mat leave,,Pre 2B,Pre 2B,A/L,Pre 2B,,,,,,,,,,No Oncall,,Pre 2B,,Mat leave,,,,OOPT,NO oncals,,,,,,Pre 2B,Pre 2B,Pre 2B,Mat leave,,,,,Pre 2B,,Pre 2B,Pre 2B,,,,,,
19/9/21,Pre 2B,,NO ONCALL,,,,Mat leave,,Pre 2B,Pre 2B,A/L,Pre 2B,,,,,,,,,,No Oncall,,Pre 2B,,Mat leave,,,,OOPT,NO oncals,,,,,,Pre 2B,Pre 2B,Pre 2B,Mat leave,,,,,Pre 2B,,Pre 2B,Pre 2B,,,,,,
20/9/21,Pre 2B,,NO ONCALL,,,,Mat leave,,Pre 2B,Pre 2B,A/L,Pre 2B,,,,,,,,,,No Oncall,,Pre 2B,,Mat leave,,,,OOPT,NO oncals,,,,,,Pre 2B,Pre 2B,Pre 2B,Mat leave,,,,,Pre 2B,,Pre 2B,Pre 2B,,,,,,
21/9/21,Pre 2B,,NO ONCALL,,,,Mat leave,,Pre 2B,Pre 2B,,Pre 2B,,,,,,,,,,No Oncall,,Pre 2B,,Mat leave,,,,OOPT,NO oncals,,,,,,Pre 2B,Pre 2B,Pre 2B,No nights,,,,,Pre 2B,,Pre 2B,Pre 2B,,,,,,
22/9/21,Pre 2B,,NO ONCALL,,,,Mat leave,,Pre 2B,Pre 2B,,Pre 2B,,,,,,,,,,No Oncall,,Pre 2B,,Mat leave,,,,OOPT,NO oncals,,,,,,Pre 2B,Pre 2B,Pre 2B,No nights,,,,,Pre 2B,,Pre 2B,Pre 2B,,,,,,
23/9/21,Pre 2B,,NO ONCALL,,,,Mat leave,,Pre 2B,Pre 2B,,Pre 2B,,,,,,,,,,No Oncall,,Pre 2B,,Mat leave,,,,OOPT,NO oncals,,A/L,,,,Pre 2B,Pre 2B,Pre 2B,No nights,,,,,Pre 2B,,Pre 2B,Pre 2B,,,,,,
24/9/21,Pre 2B,,NO ONCALL,,,,Mat leave,,Pre 2B,Pre 2B,,Pre 2B,,,,,,,,,,No Oncall,,Pre 2B,,Mat leave,,,,OOPT,NO oncals,,A/L,,,,Pre 2B,Pre 2B,Pre 2B,A/L,,,,,Pre 2B,,Pre 2B,Pre 2B,,,,,,
25/9/21,Pre 2B,,NO ONCALL,,,,Mat leave,,Pre 2B,Pre 2B,,Pre 2B,,,,,,,,,,No Oncall,,Pre 2B,,Mat leave,,,,OOPT,NO oncals,,A/L,,,,Pre 2B,Pre 2B,Pre 2B,A/L,,,,,Pre 2B,,Pre 2B,Pre 2B,,,,,,
26/9/21,Pre 2B,,NO ONCALL,,,,Mat leave,,Pre 2B,Pre 2B,,Pre 2B,,,,,,,,,,No Oncall,,Pre 2B,,Mat leave,,,,OOPT,NO oncals,,A/L,,,,Pre 2B,Pre 2B,Pre 2B,A/L,,,,,Pre 2B,,Pre 2B,Pre 2B,,,,,,
27/9/21,Pre 2B,,NO ONCALL,,,,Mat leave,,Pre 2B,Pre 2B,,Pre 2B,,,,,,,,,,No Oncall,,Pre 2B,,Mat leave,,,,OOPT,,,,,,,Pre 2B,Pre 2B,Pre 2B,No nights,,,,,Pre 2B,,Pre 2B,Pre 2B,,,,,,
28/9/21,Pre 2B,,NO ONCALL,,,,Mat leave,,Pre 2B,Pre 2B,,Pre 2B,,,,,,,,,,No Oncall,,Pre 2B,,Mat leave,,,,OOPT,,,,,,,Pre 2B,Pre 2B,Pre 2B,No nights,,,,,Pre 2B,,Pre 2B,Pre 2B,,,,,,
29/9/21,Pre 2B,,NO ONCALL,,,,Mat leave,,Pre 2B,Pre 2B,,Pre 2B,,,,,,,,,,No Oncall,,Pre 2B,,Mat leave,,,,OOPT,,,,,,,Pre 2B,Pre 2B,Pre 2B,No nights,,,,,Pre 2B,,Pre 2B,Pre 2B,,,,,,
30/9/21,Pre 2B,,NO ONCALL,,,,Mat leave,,Pre 2B,Pre 2B,,Pre 2B,,,,,,,,,,No Oncall,,Pre 2B,,Mat leave,,,,OOPT,,,,,,,Pre 2B,Pre 2B,Pre 2B,No nights,,,,,Pre 2B,,Pre 2B,Pre 2B,,,,,,
1/10/21,2B,,NO ONCALL,,,,Mat leave,,2B,2B,A/L,2B,A/L,,,,,,,,,No Oncall,,2B,,Mat leave,,,,OOPT,,,,,,,2B,2B,2B,No nights,,,,,2B,,2B,2B,,,,,,
2/10/21,2B,,NO ONCALL,,,,Mat leave,,2B,2B,A/L,2B,A/L,,,,,,,,,No Oncall,,2B,,Mat leave,,,,OOPT,,,,,,,2B,2B,2B,No nights,,,,,2B,,2B,2B,,,,,,
3/10/21,2B,,NO ONCALL,,,,Mat leave,,2B,2B,A/L,2B,A/L,,,,,,,,,No Oncall,,2B,,Mat leave,,,,OOPT,,,,,,,2B,2B,2B,No nights,,,,,2B,,2B,2B,,,,,,
4/10/21,2B,,NO ONCALL,,,,Mat leave,,2B,2B,A/L,2B,,,,,,,,,,No Oncall,,2B,,Mat leave,,,,OOPT,,,,,,,2B,2B,2B,No nights,,,,,2B,,2B,2B,,,,,,
5/10/21,2B,,NO ONCALL,,,,Mat leave,,2B,2B,,2B,,,,,,,,,,No Oncall,,2B,,Mat leave,,,,OOPT,,,,,,,2B,2B,2B,No nights,,,,,2B,,2B,2B,,,,,,
6/10/21,2B,,NO ONCALL,,,,Mat leave,,2B,2B,,2B,,,,,,,,,,No Oncall,,2B,,Mat leave,,,,OOPT,,,,,,,2B,2B,2B,No nights,,,,,2B,,2B,2B,,,,,,
7/10/21,2B,,NO ONCALL,,,,Mat leave,,2B,2B,,2B,,,,,,,,,,No Oncall,,2B,,Mat leave,,,,OOPT,,,,,,,2B,2B,2B,No nights,,,,,2B,,2B,2B,,,,,,
8/10/21,2B,,NO ONCALL,,,,Mat leave,,2B,2B,,2B,,,,,,,,,,No Oncall,,2B,,Mat leave,,,A/L,OOPT,,,,,,,2B,2B,2B,No nights,,,,,2B,,2B,2B,,,,,,
9/10/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,A/L,OOPT,,,,,,,,,,No nights,,,,,,,,,,,,,,
10/10/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,A/L,OOPT,,,,,,,,,,No nights,,,,,,,,,,,,,,
11/10/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,A/L,OOPT,,,,,,,,,,No nights,,,,,,,,,,,,,,
12/10/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,No nights,,,,,,,,,,,,,,
13/10/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,No nights,,,,,,,,,,,,,,
14/10/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,No nights,,,,,,,,,,,,,,
15/10/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,No nights,,,,,,,,,,,,,,
16/10/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,No nights,,,,,,,,,,,,,,
17/10/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,No nights,,,,,,,,,,,,,,
18/10/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,No nights,,,,,,,,,,,,,,
19/10/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,No nights,,,,,,,,,,,,,,
20/10/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,No nights,,,,,,,,,,,,,,
21/10/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,No nights,,,,,,,,,,,,,,
22/10/21,,,NO ONCALL,,,,Mat leave,,A/L,,A/L,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,A/L,,,,No nights,,,,,,A/L,,,,,,,,
23/10/21,,,NO ONCALL,,,,Mat leave,,A/L,,A/L,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,A/L,,,,,A/L,A/L,,,,,,,,,A/L,,,,,,,,
24/10/21,,,NO ONCALL,,,,Mat leave,,A/L,,A/L,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,A/L,,,,,A/L,A/L,,,,,,,,,A/L,,,,,,,,
25/10/21,,,NO ONCALL,,,,Mat leave,,A/L,,A/L,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,A/L,,,,,,A/L,,,,,,,,,,,,,,,,,
26/10/21,,,NO ONCALL,,,,Mat leave,,,,A/L,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,A/L,,,,,,A/L,,,,,,,,,,,,,,,,,
27/10/21,,,NO ONCALL,,,,Mat leave,,,,A/L,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,A/L,,,,,,A/L,,,,,,,,,,,,,,,,,
28/10/21,,,NO ONCALL,,,,Mat leave,,,,A/L,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,A/L,,,,,,A/L,,,,,,,,,,,,,,,,,
29/10/21,,,NO ONCALL,,,,Mat leave,,,,A/L,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,A/L,,,,,,A/L,,,,,,,,,,,,,,,,,
30/10/21,,,NO ONCALL,,,A/L,Mat leave,,,,A/L,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,A/L,,A/L,,,,A/L,,,,,,,,,,,,,,,,,
31/10/21,,,NO ONCALL,,,A/L,Mat leave,,,,A/L,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,A/L,,A/L,,,,A/L,,,,,,,,,,,,,,,,,
1/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,,OOPT,,,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,,,,Pre 2a,,Pre 2a
2/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,,OOPT,,,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,,,,Pre 2a,,Pre 2a
3/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,,OOPT,,,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,A/L,,,Pre 2a,,Pre 2a
4/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,,OOPT,,,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,A/L,,,Pre 2a,,Pre 2a
5/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,A/L,OOPT,,,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,A/L,,,Pre 2a,,Pre 2a
6/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,A/L,OOPT,,,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,A/L,,,Pre 2a,,Pre 2a
7/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,A/L,OOPT,,,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,A/L,,,Pre 2a,,Pre 2a
8/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,,OOPT,,,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,,,,Pre 2a,,Pre 2a
9/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,,OOPT,,,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,,,,Pre 2a,,Pre 2a
10/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,,OOPT,,,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,A/L,,,Pre 2a,,Pre 2a
11/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,,OOPT,,,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,A/L,,,Pre 2a,,Pre 2a
12/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,,OOPT,,,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,A/L,,,Pre 2a,,Pre 2a
13/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,,OOPT,,A/L,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,A/L,,,Pre 2a,,Pre 2a
14/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,,OOPT,,A/L,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,A/L,,,Pre 2a,,Pre 2a
15/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,,OOPT,,A/L,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,,,,Pre 2a,,Pre 2a
16/11/21,Pre 2a,,NO ONCALL,,Pre 2a,Pre 2a,Mat leave,Pre 2a,,Pre 2a,,,,,,,Pre 2a,Pre 2a,Pre 2a,Pre 2a,Pre 2a,No Oncall,Pre 2a,Pre 2a,,Mat leave,,,,OOPT,,A/L,Pre 2a,,,Pre 2a,,Pre 2a,,,,,,Pre 2a,,Pre 2a,Pre 2a,,,,,Pre 2a,,Pre 2a
17/11/21,2A,,NO ONCALL,,2A,2A,Mat leave,2A,,2A,,,,,,,2A,2A,2A,2A,2A,No Oncall,2A,2A,,Mat leave,,,,OOPT,,A/L,2A,,,2A,,2A,,,,,,2A,,2A,2A,,,,,2A,,2A
18/11/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,A/L,,,,A/L,,,,,,,,,,A/L,,,,,,,,
19/11/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,A/L,,,,A/L,,,,,,,,,,A/L,,,,,,,,
20/11/21,,,NO ONCALL,,,A/L,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,A/L,,,,A/L,,,,,,,,,,A/L,,,A/L,,,,,
21/11/21,,,NO ONCALL,,,A/L,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,A/L,,,,A/L,,,,,,,,,,A/L,,,A/L,,,,,
22/11/21,,,NO ONCALL,,,A/L,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,A/L,,,,A/L,,,,,,,,,,A/L,,,A/L,,,,,
23/11/21,,,NO ONCALL,,,A/L,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,A/L,,,,A/L,,,,,,,,,,A/L,,,A/L,,,,,
24/11/21,,,NO ONCALL,,,A/L,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,A/L,,,,A/L,,,,,,,,,,A/L,,,A/L,,,,,
25/11/21,,,NO ONCALL,,,A/L,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,A/L,,,,A/L,,,,,,,,,,A/L,,,A/L,,,,,
26/11/21,,,NO ONCALL,,,A/L,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,A/L,,,,A/L,,,,,,,,,,A/L,,,A/L,,,,,
27/11/21,,,NO ONCALL,,,A/L,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,A/L,,,,A/L,,,,,,,,,,A/L,,,A/L,,,,,
28/11/21,,,NO ONCALL,,,A/L,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,A/L,,,,A/L,,,,,,,,,,A/L,,,A/L,,,,,
29/11/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,A/L,,,,,,,,,,A/L,,,A/L,,,,,
30/11/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,A/L,,,,,,,,,,A/L,,,A/L,,,,,
1/12/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,A/L,,,,,,,,,,A/L,,,A/L,,,,,
2/12/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,A/L,,,,,,,,,,A/L,,,A/L,,,,,
3/12/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,OFF,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,A/L,,,,,,,,,,A/L,,,A/L,,,,,
4/12/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,OFF,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,,,,,,,A/L,,,A/L,,,,,
5/12/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,OFF,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,,,,,,,A/L,,,A/L,,,,,
6/12/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
7/12/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
8/12/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
9/12/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,,,,,,,,,,,A/L,,,,
10/12/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,,,,,,,,,,,A/L,,,,
11/12/21,,,NO ONCALL,,,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,A/L,,,,,,,,,,,,,,,,A/L,,,,
12/12/21,,,NO ONCALL,,,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,A/L,,,,,,,,,,,,,,,,A/L,,,,
13/12/21,,,NO ONCALL,,,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,A/L,,,,,,,,,,,,,,,,,,,,
14/12/21,,,NO ONCALL,,,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,A/L,,,,,,,,,,,,,,,,,,,,
15/12/21,,,NO ONCALL,,,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,A/L,,,,,,,,,,,,,,,,,,,,
16/12/21,,,NO ONCALL,,,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,A/L,,,,,,,,,,,,,,,,,,,,
17/12/21,,,NO ONCALL,,,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,A/L,OOPT,,,,A/L,,,,,,,,,,,,,,,,,,,,
18/12/21,,,NO ONCALL,,,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,A/L,OOPT,,,,,,,,,,,,,,,,,,,,,,,,A/L
19/12/21,,,NO ONCALL,,,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,A/L,OOPT,,,,,,,,,,,,,,,,,,,,,,,,A/L
20/12/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,A/L
21/12/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,A/L
22/12/21,,,NO ONCALL,,,,Mat leave,,,,,,A/L,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,A/L
23/12/21,,,NO ONCALL,,OFF,,Mat leave,,,,,,A/L,,,,,,,OFF,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,OFF,OFF,OFF,,,,,,,,OFF,OFF,,OFF,,,OFF,,A/L
24/12/21,,,NO ONCALL,,OFF,,Mat leave,,,,,,A/L,,,,,,,OFF,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,OFF,OFF,OFF,,,,,,,,OFF,OFF,,OFF,,,OFF,,A/L
25/12/21,,,NO ONCALL,,OFF,,Mat leave,,,,,,A/L,,,,,,,OFF,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,OFF,OFF,OFF,,,,,,,,OFF,OFF,,OFF,,,OFF,,A/L
26/12/21,,,NO ONCALL,,OFF,,Mat leave,,,,,,A/L,,,,,,,OFF,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,OFF,OFF,OFF,,,,,,,,OFF,OFF,,OFF,,,OFF,,A/L
27/12/21,,,NO ONCALL,,OFF,,Mat leave,,,,,,A/L,,,,,,,OFF,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,OFF,OFF,OFF,,,,,,,,OFF,OFF,,OFF,,,OFF,,A/L
28/12/21,,,NO ONCALL,,,,Mat leave,,,,,,A/L,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,OFF,,,,,,,,,,,,,,,,,A/L
29/12/21,,,NO ONCALL,,,,Mat leave,,,,,,A/L,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,OFF,,,,,,,,,,,,,,,,,A/L
30/12/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,OFF,,,,,,,,,,,,,,,,,A/L
31/12/21,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,,OOPT,,,,,,,OFF,,,,,,,,,,,,,,,,,A/L
1/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,OFF,,,,,,,,,,,,,,,,,A/L
2/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,OFF,,,,,,,,,,,,,,,,,A/L
3/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,A/L,,,,OFF,,,,,,,,,,,,,,,,,A/L
4/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,A/L,,,,OFF,,,,,,,,,,,,,,,,,A/L
5/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,A/L,,,,,,,,,,,,,,,,,,,,,A/L
6/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,A/L,,,,,,,,,,,,,,,,,,,,,A/L
7/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,A/L,,,,,,,,,,,,,,,,,,,,,A/L
8/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,A/L,,,,,,,,,,,,,,,,A/L,,,,,A/L
9/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,A/L,,,,,,,,,,,,,,,,A/L,,,,,A/L
10/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,A/L,,,,,
11/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,A/L,,,,,
12/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,A/L,,,,,
13/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,A/L,,,,,
14/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,A/L,,,,,
15/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,A/L,,,,,
16/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,A/L,,,,,
17/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
18/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
19/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
20/1/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
21/1/22,,,NO ONCALL,,A/L,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,A/L,,,,,,,,,,,,,,
22/1/22,,,NO ONCALL,,A/L,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,,,,,,,,,A/L,,,,,,,,,,,,,,
23/1/22,,,NO ONCALL,,A/L,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,,,,,,,,,A/L,,,,,,,,,,,,,,
24/1/22,,,NO ONCALL,,A/L,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
25/1/22,,,NO ONCALL,,,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
26/1/22,,,NO ONCALL,,,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
27/1/22,,,NO ONCALL,,,,Mat leave,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
28/1/22,,,NO ONCALL,,,,Mat leave,,A/L,,A/L,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
29/1/22,,,NO ONCALL,,,,Mat leave,,A/L,,A/L,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,,,,,,,,,
30/1/22,,,NO ONCALL,,,,Mat leave,,A/L,,A/L,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,,,,,,,,,
31/1/22,,,NO ONCALL,,,,Mat leave,,,,A/L,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,,,,,,,,,
1/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,,,,A/L,,,,,
2/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,A/L,,,A/L,,,,,
3/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,A/L,,,A/L,,,,,
4/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,A/L,,,A/L,,,,,
5/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,A/L,,,,A/L,,,,
6/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,A/L,,,,A/L,,,,
7/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,,,,,A/L,,,,
8/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,,,,,A/L,,,,
9/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,,,,,A/L,,,,
10/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,,,,,A/L,,,,
11/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,,,,,A/L,,,,
12/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,,,,,A/L,,,,
13/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,OOPT,,A/L,,,,,,,,,,,,,,,,,,A/L,,,,
14/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
15/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
16/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
17/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,,,,,,
18/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,A/L,,A/L,,A/L,,,,,,,,
19/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,A/L,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,OOPT,A/L,,,,,,A/L,,,,,A/L,,A/L,,A/L,,,,,,,,
20/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,A/L,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,OOPT,A/L,,,,,,A/L,,,,,A/L,,A/L,,A/L,,,,,,,,
21/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,A/L,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,OOPT,A/L,,,,,,A/L,,,,,,,A/L,,,,,,,,,,
22/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,A/L,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,OOPT,A/L,,,,,,A/L,,,,,,,A/L,,,,,,,,,,
23/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,A/L,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,OOPT,A/L,,,,,,A/L,,,,,,,A/L,,,,,,,,,,
24/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,A/L,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,OOPT,A/L,,,,,,A/L,,,,,,,A/L,,,,,,,,,,
25/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,A/L,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,OOPT,A/L,,,,,,A/L,,,,,,,,,,,,A/L,,,,,
26/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,A/L,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,OOPT,A/L,,,,,,A/L,,,,,,,,,,,,A/L,,,,,
27/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,A/L,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,OOPT,A/L,,,,,,A/L,,,,,,,,,,,,A/L,,,,,
28/2/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,OOPT,,,,,,,,,,,,,,,,,,,A/L,,,,,
1/3/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,,,,OOPC,,,,,,,,,,,,,,,A/L,A/L,,,,,
2/3/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,,,,OOPC,A/L,,,,,,,,,,,,,,A/L,A/L,,,,,
3/3/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,,,,OOPC,A/L,,,,,,A/L,,,,,,,,A/L,A/L,,,,,
4/3/22,,,NO ONCALL,,,,Mat leave,,,,,,,,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,,,,OOPC,A/L,,,,,,,,,,,,,,A/L,A/L,,,,,
5/3/22,,,NO ONCALL,,,,Mat leave,,,,OOPT,,,,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,,IR oncall,,OOPC,,,,,,A/L,,,,,,,,,A/L,A/L,A/L,,,,
6/3/22,,,NO ONCALL,,,,Mat leave,,,,OOPT,,,,,,,,,A/L,,No Oncall,,,,Mat leave,,,OOPT,,IR oncall,,OOPC,,,,,,A/L,,,,,,,,,A/L,A/L,A/L,,,,
7/3/22,,OFF ONCALL,IDT,POST ONCALL,POST ONCALL,,OOPT,,,,OOPT,,,,,,,POST ONCALL,,,,No Oncall,,POST ONCALL,,Mat leave,,POST ONCALL,OOPT,,,,OOPC,,,POST ONCALL,POST ONCALL,,A/L,,,,,A/L,POST ONCALL,,,,,A/L,,,,
8/3/22,,OFF ONCALL,IDT,POST ONCALL,POST ONCALL,,OOPT,,,,OOPT,,,,,,,POST ONCALL,,,,No Oncall,,POST ONCALL,,Mat leave,,POST ONCALL,OOPT,,,,OOPC,,,POST ONCALL,POST ONCALL,,A/L,,,,,A/L,POST ONCALL,,,,,A/L,,,,
9/3/22,,OFF ONCALL,IDT,,,,OOPT,,,,OOPT,,,,,,,,,,,No Oncall,,,,Mat leave,POST ONCALL,A/L,OOPT,,,,OOPC,,,,,,A/L,,,,,A/L,,,,,,A/L,,,,
10/3/22,,OFF ONCALL,IDT,,,,OOPT,,,,OOPT,,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,,,,A/L,,,,,A/L,,,,,,A/L,,,,
11/3/22,,OFF ONCALL,IDT,,,,OOPT,,,,OOPT,,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,,,,A/L,,,,,A/L,,,,,,A/L,,,,
12/3/22,,OFF ONCALL,IDT,POST ONCALL,,,OOPT,,,,OOPT,A/L,,,,,,POST ONCALL,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,A/L,POST ONCALL,,A/L,,,,,A/L,,A/L,,,,A/L,,,,
13/3/22,,OFF ONCALL,IDT,,,,OOPT,,,,OOPT,A/L,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,A/L,,,A/L,,,,,A/L,,A/L,,,,A/L,,,,
14/3/22,pre 2b,OFF ONCALL,IDT,,,,OOPT,,,,OOPT,A/L,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,A/L,,,,,,,,A/L,,A/L,,pre 2b,,,,,,
15/3/22,pre 2b,OFF ONCALL,IDT,,,,OOPT,,,,OOPT,A/L,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,A/L,,,,,,,,A/L,,A/L,,pre 2b,,,,,,
16/3/22,pre 2b,OFF ONCALL,IDT,,,,OOPT,,,,OOPT,A/L,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,A/L,,,,,,,POST ONCALL,A/L,,A/L,,pre 2b,A/L,,,,,
17/3/22,pre 2b,OFF ONCALL,IDT,,,,OOPT,,A/L,,OOPT,A/L,,,,A/L,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,A/L,,,,,,,,A/L,,A/L,,pre 2b,,,,,,
18/3/22,pre 2b,OFF ONCALL,IDT,,,,OOPT,,,,OOPT,A/L,,,,A/L,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,A/L,,,,,,,,A/L,,A/L,,pre 2b,,,,,,
19/3/22,pre 2b,OFF ONCALL,IDT,,,,OOPT,A/L,,,OOPT,A/L,,,,A/L,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,A/L,,,,,IR ONCALL,,,A/L,,A/L,,pre 2b,,A/L,,,,
20/3/22,pre 2b,OFF ONCALL,IDT,,,,OOPT,A/L,,,OOPT,A/L,,,,A/L,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,A/L,,,,,IR ONCALL,,,A/L,,A/L,,pre 2b,,A/L,,,,
21/3/22,pre 2b,OFF ONCALL,IDT,,,,OOPT,A/L,,,OOPT,A/L,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,,,,,,,,,A/L,,,,pre 2b,,A/L,,,,
22/3/22,pre 2b,OFF ONCALL,IDT,,,,OOPT,A/L,,,OOPT,A/L,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,A/L,,,OOPC,,,,,,,,,,,,,,,pre 2b,,A/L,,,,
23/3/22,pre 2b,OFF ONCALL,IDT,,,,OOPT,A/L,,,OOPT,A/L,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,,,,,,,,,,,,,pre 2b,,A/L,,,,
24/3/22,pre 2b,OFF ONCALL,IDT,,,,OOPT,A/L,,,OOPT,A/L,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,,,,,,,,,,,,,pre 2b,,A/L,,,,
25/3/22,2b,OFF ONCALL,IDT,,,,OOPT,A/L,,,OOPT,A/L,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,,,,,,,,,,,,,2b,,A/L,,,,
26/3/22,2b,OFF ONCALL,IDT,,,,OOPT,A/L,,,OOPT,A/L,,,,A/L,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,,,,,,,,,,,,,2b,,A/L,,,,
27/3/22,2b,OFF ONCALL,IDT,,,,OOPT,A/L,,,OOPT,A/L,,,,A/L,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,,,,,,,,,,,,,2b,,A/L,,,,
28/3/22,2b,OFF ONCALL,IDT,,,,OOPT,A/L,,,,,,,,A/L,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,,,,,,,,,,,,,2b,,,,,,
29/3/22,2b,OFF ONCALL,IDT,,,,OOPT,A/L,,,,,,,,A/L,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,,,,,,,,,,,,,2b,,A/L,,,,
30/3/22,2b,OFF ONCALL,IDT,,,,OOPT,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,,,,,,,,,,,,,2b,,A/L,,,,
31/3/22,2b,OFF ONCALL,IDT,,,,OOPT,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,OOPC,,,,,,,,,,,,,,,2b,,A/L,,,,
1/4/22,2b,OFF ONCALL,IDT,,,,OOPT,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,No nights or twiligts,,,,,,,,,,,,,,,2b,,,Ramadan,A/L,,
2/4/22,,OFF ONCALL,IDT,,,A/L,OOPT,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,No nights or twiligts,,,,,,,A/L,,,,,,,,,,,Ramadan,A/L,,
3/4/22,,OFF ONCALL,IDT,,,A/L,OOPT,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,No nights or twiligts,,,,,,,A/L,,,,,,,,,,,Ramadan,A/L,,
4/4/22,,OFF ONCALL,IDT,,,A/L,OOPT,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,No nights or twiligts,,,,,,,,,,,,,,,,,,Ramadan,A/L,,
5/4/22,,OFF ONCALL,IDT,,,A/L,OOPT,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,No nights or twiligts,,,,,,,,,,,,,,,,,,Ramadan,A/L,,
6/4/22,,OFF ONCALL,IDT,,,A/L,OOPT,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,No nights or twiligts,,,,,,,,,,,,,,,,,,Ramadan,A/L,,
7/4/22,,OFF ONCALL,IDT,,,A/L,OOPT,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,S/L,,,No nights or twiligts,,,,,,,,,,,,,,,,,,Ramadan,A/L,,
8/4/22,,OFF ONCALL,IDT,,,A/L,OOPT,,,,,,,A/L,A/L,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,No nights or twiligts,,,,A/L,,,,,,,,,,,,,,Ramadan,A/L,,
9/4/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,,A/L,A/L,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,No nights or twiligts,,,,A/L,,,,,,,,,,,,,,Ramadan,A/L,,
10/4/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,,A/L,A/L,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,No nights or twiligts,,,,A/L,,,,,,,,,,,,,,Ramadan,A/L,,
11/4/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,A/L,A/L,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,No nights or twiligts,,,,A/L,,,,,,,,,,,,,,Ramadan,,,
12/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,No nights or twiligts,,,A/L,A/L,,,A/L,,,,,,A/L,,,,,Ramadan,,,
13/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,No nights or twiligts,,,,A/L,,,A/L,,,,,,,,,,,Ramadan,,,
14/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,No nights or twiligts,,,,A/L,,,,,,,,,,,,,,Ramadan,,,
15/4/22,,OFF ONCALL,IDT,,,,OOPT,,,Worked Xmas,,Worked Xmas,,A/L,,,,Worked Xmas,,,Worked Xmas,No Oncall,,,,Mat leave,Worked Xmas,,OOPT,,,Worked Xmas,No nights or twiligts,Worked Xmas,,,A/L,,,,,,Worked Xmas,,,,,,,,Ramadan,,,
16/4/22,,OFF ONCALL,IDT,,,,OOPT,,,Worked Xmas,,Worked Xmas,,A/L,,,,Worked Xmas,,,Worked Xmas,No Oncall,,,,Mat leave,Worked Xmas,,OOPT,,,Worked Xmas,No nights,Worked Xmas,,,A/L,,,,,,Worked Xmas,,,,,,,,Ramadan,,,
17/4/22,,OFF ONCALL,IDT,,,,OOPT,,,Worked Xmas,,Worked Xmas,,A/L,,,,Worked Xmas,,,Worked Xmas,No Oncall,,,,Mat leave,Worked Xmas,,OOPT,,,Worked Xmas,No nights,Worked Xmas,,,A/L,,,,,,Worked Xmas,,,,,,,,Ramadan,,,
18/4/22,,OFF ONCALL,IDT,,,,OOPT,,,Worked Xmas,,Worked Xmas,,A/L,,,,Worked Xmas,,,Worked Xmas,No Oncall,,,,Mat leave,Worked Xmas,,OOPT,,A/L,Worked Xmas,No nights,Worked Xmas,,,A/L,,,,,,Worked Xmas,,,,,,,,Ramadan,,,
19/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,A/L,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,A/L,,No nights,,,,A/L,,,A/L,,,,,,,,,A/L,,Ramadan,,,
20/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,A/L,,,,,,,,No Oncall,,,A/L,Mat leave,,,OOPT,,A/L,,No nights,,,,A/L,,,,,,,,,,,,A/L,,Ramadan,,,
21/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,A/L,,,,,,,,No Oncall,,,A/L,Mat leave,,,OOPT,,A/L,,No nights,,,,A/L,,,,,,,,,,,,A/L,,Ramadan,,,
22/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,A/L,,,,,,,,No Oncall,,,A/L,Mat leave,,,OOPT,,A/L,,No nights,,,,A/L,,,,,,,,,,,,A/L,,Ramadan,,,
23/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,A/L,A/L,,,,,,,No Oncall,,,A/L,Mat leave,,,OOPT,,A/L,,No nights,,,,A/L,,,,IR ONCALL,,,,,,,,A/L,,Ramadan,,,
24/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,A/L,A/L,,,,,,,No Oncall,,,A/L,Mat leave,,,OOPT,,A/L,,No nights,,,,A/L,,,,IR ONCALL,,,,,,,,A/L,,Ramadan,,,
25/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,,,,,,,,,No Oncall,,,A/L,Mat leave,,,OOPT,,,,No nights,,,,,,,,,,,,,,,,A/L,,Ramadan,,,
26/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,,,,,,,,,No Oncall,,,A/L,Mat leave,,,OOPT,,,,No nights,,,,,,,,,,,,,,,,A/L,,Ramadan,,,
27/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,,,,,,,,,No Oncall,,,A/L,Mat leave,,,OOPT,,S/L,,No nights,,,,,,,,,,,,,,,,A/L,,Ramadan,,,
28/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,A/L,S/L,,No nights,,,,,,,A/L,,,,,,,,,A/L,,Ramadan,,,
29/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,No nights,,,,,,,A/L,,,,,,,,,A/L,,Ramadan,,,
30/4/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,,,A/L,,,,,,A/L,,,,,,,,,,,A//L,,Ramadan,,,
1/5/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,,,A/L,,,,,,A/L,,,,,,,,,,,A//L,,Ramadan,,,
2/5/22,,OFF ONCALL,IDT,,,,OOPT,,,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,,,A/L,,,,,,A/L,,,,,,,,,,,A//L,,Ramadan,,,
3/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,,,A/L,,,,,,,,,,,,,,,,,A/L,,Ramadan,,,
4/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,,,A/L,,,,,,,,,,,,,,,,,A/L,,Ramadan,,,
5/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,,,A/L,,,,,,,,,,,,,,,,,A/L,,Ramadan,,,
6/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,,,A/L,,,,,,,,,IR ONCALL,,,,,,,,A/L,,Ramadan,,,
7/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,,,A/L,,,,,,,,,IR ONCALL,,,,,,,,,,Ramadan,,,
8/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,Mat leave,,,OOPT,,,A/L,,,,,,,,,IR ONCALL,,,,,,,,,,Ramadan,,,
9/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,A/L,,,,,,,,,,,,,,,,,,,Ramadan,,,
10/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,A/L,,,,,,,,,,,,,,,,,,,Ramadan,,,
11/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,A/L,,,,,,,,,,,,,,,,,,,Ramadan,,,
12/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,A/L,,,,,,,,,,,,,,,,,,,Ramadan,,,
13/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,A/L,,,,,,,,,,,,,,,,,,,Ramadan,,,
14/5/22,,OFF ONCALL,IDT,,,,OOPT,A/L,A/L,,,,,,,,,,A/L,,,No Oncall,,,,Mat leave,,,OOPT,,,A/L,A/L,,,,,,,,,,,,,,A/L,,,,,,,
15/5/22,,OFF ONCALL,IDT,,,,OOPT,A/L,A/L,,,,,,,,,,A/L,,,No Oncall,,,,Mat leave,,,OOPT,,,A/L,A/L,,,,,,,,,,,,,,A/L,,,,,,,
16/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,A/L,,,,,,,,,,,,,,,,A/L,,,,,
17/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,A/L,,,,,,,,,,,,,,,,A/L,,,,,
18/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,A/L,,,,,,,,,,,,,,,,A/L,,,,,
19/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,A/L,,,,,,,,,,,,,,,,A/L,,,,,
20/5/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,,,A/L,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,A/L,A/L,,,,,,,,,,,,,,,,,,,,
21/5/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,A/L,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,A/L,A/L,,,,,,,,,,,,,,,,,,,,
22/5/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,A/L,,,,,,,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,A/L,A/L,,,,,,,,,,,,,,,,,,,,
23/5/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,,,A/L,,,,A/L,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,,,,,,,,,IR ONCALL,,,,,,,,,,,A/L,,
24/5/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,,,A/L,,,,A/L,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,,,,,,,,,IR ONCALL,,,,,,,,,,,A/L,,
25/5/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,,,A/L,,,,A/L,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,,,,,,,,,IR ONCALL,,,,,,,,,,,A/L,,
26/5/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,,,A/L,,,,A/L,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,,,,,,,,,IR ONCALL,,,,,,,,,,,A/L,,
27/5/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,,,A/L,,,,A/L,,,No Oncall,,,A/L,Mat leave,,A/L,OOPT,,,,,,,,,,,,IR ONCALL,,,,,,,,,,,A/L,,
28/5/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,A/L,,,,A/L,,,,A/L,,,No Oncall,,,A/L,Mat leave,,A/L,OOPT,,,,,,,A/L,,,,,,,,,,A/L,,,,,,A/L,,
29/5/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,A/L,,,,A/L,,,,A/L,,,No Oncall,,,A/L,Mat leave,,A/L,OOPT,,,,,,,A/L,,,,,,,,,,A/L,,,,,,A/L,,
30/5/22,Pre 2a,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,,,A/L,,,Pre 2a,Pre 2a,,,No Oncall,,,A/L,Mat leave,,A/L,OOPT,,A/L,,,,,,,,,,,,,,,Pre 2a,,,,,,Pre 2a,,Pre 2a
31/5/22,Pre 2a,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,,,A/L,,,Pre 2a,Pre 2a,,,No Oncall,,,A/L,Mat leave,,A/L,OOPT,,A/L,,,,,,,,,,,,,,,Pre 2a,,,,,,Pre 2a,,Pre 2a
1/6/22,Pre 2a,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,,,A/L,,,Pre 2a,Pre 2a,,,No Oncall,,,A/L,Mat leave,,A/L,OOPT,,A/L,,,,,,,,,,,,,,,Pre 2a,,,,,,Pre 2a,,Pre 2a
2/6/22,Pre 2a,OFF ONCALL,IDT,,,A/L,OOPT,,,,,,,,A/L,,,Pre 2a,Pre 2a,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,,,,,,,,,,,,,,Pre 2a,,,,,,Pre 2a,,Pre 2a
3/6/22,Pre 2a,OFF ONCALL,IDT,,,A/L,OOPT,,,,,,,,A/L,,,Pre 2a,Pre 2a,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,,,,,,,,,,,,,,Pre 2a,,,,,,Pre 2a,,Pre 2a
4/6/22,Pre 2a,OFF ONCALL,IDT,,,,OOPT,,,,,,,,A/L,,,Pre 2a,Pre 2a,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,,,,,,,,,,,,,,Pre 2a,,,,,,Pre 2a,,Pre 2a
5/6/22,Pre 2a,OFF ONCALL,IDT,,,,OOPT,,,,,,,,A/L,,,Pre 2a,Pre 2a,,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,,,,,,,,,,,,,,Pre 2a,,,,,,Pre 2a,,Pre 2a
6/6/22,Pre 2a,OFF ONCALL,IDT,,,,OOPT,,,,,pre 2b,,,A/L,,,Pre 2a,Pre 2a,A/L,,No Oncall,,,,Mat leave,,A/L,OOPT,A/L,,,,,,,,,,,IR ONCALL,,,,,Pre 2a,,A/L,,,,Pre 2a,,Pre 2a
7/6/22,Pre 2a,OFF ONCALL,IDT,,,,OOPT,,,,,pre 2b,,,A/L,,,Pre 2a,Pre 2a,A/L,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,,,,,,,,,IR ONCALL,,,,,Pre 2a,,A/L,,,,Pre 2a,,Pre 2a
8/6/22,Pre 2a,OFF ONCALL,IDT,,,,OOPT,,,,,pre 2b,,,A/L,,,Pre 2a,Pre 2a,A/L,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,,,,,,,,,IR ONCALL,,,,,Pre 2a,,,,,,Pre 2a,,Pre 2a
9/6/22,Pre 2a,OFF ONCALL,IDT,,,,OOPT,,A/L,,,pre 2b,,,A/L,,,Pre 2a,Pre 2a,A/L,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,,,,,,,,,IR ONCALL,,,,,Pre 2a,,,,,,Pre 2a,,Pre 2a
10/6/22,Pre 2a,OFF ONCALL,IDT,,,,OOPT,,A/L,,,pre 2b,,,,,,Pre 2a,Pre 2a,A/L,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,,,,,,,,,IR ONCALL,,,,,Pre 2a,,,,,,Pre 2a,,Pre 2a
11/6/22,Pre 2a,OFF ONCALL,IDT,,,,OOPT,A/L,,,,pre 2b,,,,,,Pre 2a,Pre 2a,A/L,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,,,,,,,,,IR ONCALL,,,,,Pre 2a,,,,,,Pre 2a,,Pre 2a
12/6/22,Pre 2a,OFF ONCALL,IDT,,,,OOPT,A/L,,,,pre 2b,,,,,,Pre 2a,Pre 2a,A/L,,No Oncall,,,,Mat leave,,A/L,OOPT,,,,,,,,,,,,IR ONCALL,,,,,Pre 2a,,,,,,Pre 2a,,Pre 2a
13/6/22,Pre 2a,OFF ONCALL,IDT,,,,OOPT,,,,,pre 2b,,,,,,Pre 2a,Pre 2a,A/L,,No Oncall,,,,Mat leave,,,OOPT,,,,,,,,,,,,,,,,,Pre 2a,,,,,,Pre 2a,,Pre 2a
14/6/22,Pre 2a,OFF ONCALL,IDT,,,,OOPT,,,,,pre 2b,,,,,,Pre 2a,Pre 2a,A/L,,No Oncall,,,,Mat leave,,,OOPT,,,,,,,,,,,,,,,,,Pre 2a,,,,,,Pre 2a,,Pre 2a
15/6/22,2a,OFF ONCALL,IDT,,,,OOPT,,,,,pre 2b,,,,,,2a,2a,A/L,,No Oncall,,,,Mat leave,,,OOPT,,,,,,,,,,,,,,,,,2a,,,,,,2a,,2a
16/6/22,,OFF ONCALL,IDT,,,,OOPT,,,,,pre 2b,,,A/L,,,A/L,,A/L,,No Oncall,,,,Mat leave,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,,
17/6/22,,OFF ONCALL,IDT,,,,OOPT,,,,,2b,,,A/L,,,A/L,,A/L,,No Oncall,,,,Mat leave,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,,
18/6/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,2b,,,A/L,,,A/L,A/L,A/L,,No Oncall,,,,Mat leave,,,OOPT,,,,,A/L,,,,,,,,,,,,,,,,,,,,
19/6/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,2b,,,A/L,,,A/L,A/L,A/L,,No Oncall,,,,Mat leave,,,OOPT,,,,,A/L,,,,,,,,,,,,,,,,,,,,
20/6/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,2b,,,,,,A/L,A/L,,,No Oncall,,,,Mat leave,,,OOPT,,,,,A/L,,,,,,,,,,,,,,,A/L,,,,,
21/6/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,2b,,,,,,A/L,A/L,,,No Oncall,,,,Mat leave,,,OOPT,,,,,A/L,,,,,,,,,,,,,,,A/L,,,,,
22/6/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,2b,,,,,,A/L,A/L,,,No Oncall,,,,Mat leave,,,OOPT,,,,,A/L,,,,,,,,,,,,,,,,,,,,
23/6/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,2b,,,,,,A/L,A/L,,,No Oncall,,,,Mat leave,,,OOPT,,,,,A/L,,,,,,,,,,,,,,,,,,,,
24/6/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,2b,,,,,,A/L,A/L,,,No Oncall,,,,Mat leave,,,OOPT,,,,,A/L,,,,,,,,,,,,,,,,,,,,
25/6/22,,OFF ONCALL,IDT,,,,OOPT,A/L,,,A/L,,,,,,,,A/L,,,No Oncall,,,,Mat leave,,,OOPT,,,,,A/L,,,,,,,,,,,,,,,,,,,,
26/6/22,,OFF ONCALL,IDT,,,,OOPT,A/L,,,A/L,,,,,,,,A/L,,,No Oncall,,,,Mat leave,,,OOPT,,,,,A/L,,,,,,,,,,,,,,,,,,,,
27/6/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,,,,,,,,A/L,,,No Oncall,,,,Mat leave,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,,
28/6/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,,,,,,,,A/L,,,No Oncall,,,,Mat leave,,,OOPT,A/L,,,,,,,,,,,,,,,,,,,,,,,,
29/6/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,A/L,,,,,,,,,,,,,,,,,,,,,,,,
30/6/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,,,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,,
1/7/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,,A/L,,,,,,,,,No Oncall,,,,Mat leave,,,OOPT,,,,,,,A/L,,,,,,,,,,A/L,,,,,,,,
2/7/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,A/L,,,,,,,,,,A/L,,,,,,,,
3/7/22,,OFF ONCALL,IDT,,,,OOPT,,A/L,,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,A/L,,,,,,,,,,A/L,,,,,,,,
4/7/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,,,A/L,,,,,,,
5/7/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,,,A/L,,,,,,,
6/7/22,,OFF ONCALL,IDT,,,,OOPT,A/L,,,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,,,A/L,,,,,,,
7/7/22,,OFF ONCALL,IDT,,,,OOPT,A/L,,,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,,
8/7/22,,OFF ONCALL,IDT,,,,OOPT,A/L,,,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,,
9/7/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,,
10/7/22,,OFF ONCALL,IDT,,,,OOPT,,,,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,,
11/7/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,,
12/7/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,,
13/7/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,,
14/7/22,,OFF ONCALL,IDT,,,,OOPT,A/L,,A/L,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,,,,,,,,,,
15/7/22,,OFF ONCALL,IDT,,,,OOPT,A/L,,A/L,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,A/L,,,,,,,,,A/L,A/L,,,,,,,,
16/7/22,,OFF ONCALL,IDT,,,,OOPT,A/L,,A/L,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,A/L,,,,,,,,,A/L,A/L,,,,,,,,
17/7/22,,OFF ONCALL,IDT,,,,OOPT,A/L,,A/L,A/L,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,A/L,,,,,,,,,A/L,A/L,,,,,,,,
18/7/22,,OFF ONCALL,IDT,,,,OOPT,A/L,,A/L,,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,A/L,,,,,,,,,
19/7/22,,OFF ONCALL,IDT,,,,OOPT,A/L,,A/L,,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,A/L,,,,,,,,,
20/7/22,,OFF ONCALL,IDT,,,,OOPT,A/L,,A/L,,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,A/L,,,,,,,,,
21/7/22,,OFF ONCALL,IDT,,,,OOPT,A/L,,A/L,,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,A/L,,,,,,,,,
22/7/22,,OFF ONCALL,IDT,,,,OOPT,A/L,,A/L,,,,,,,,,,,,No Oncall,,,,,,,OOPT,,,,,,,,,,,,,,,,A/L,,,,,,,,,
23/7/22,,OFF ONCALL,IDT,,,A/L,OOPT,A/L,,A/L,,,,A/L,,,,,,,,No Oncall,,,,,,,OOPT,,A/L,,A/L,,,,,,,,,,,,A/L,,,A/L,,OOPT,,,,
24/7/22,,OFF ONCALL,IDT,,,A/L,OOPT,A/L,,A/L,,,,A/L,,,,,,,,No Oncall,,,,,,,OOPT,,A/L,,A/L,,,,,,,,,,,,A/L,,,A/L,,OOPT,,,,
25/7/22,,OFF ONCALL,IDT,,,A/L,OOPT,,,A/L,,,,A/L,,,,,,A/L,,No Oncall,,,,,,,OOPT,,A/L,,A/L,,,,,,,,,,,,A/L,,,A/L,,OOPT,,,,
26/7/22,,OFF ONCALL,IDT,,,A/L,OOPT,,,A/L,,,,A/L,,,,,,A/L,,No Oncall,,,,,,,OOPT,,A/L,,A/L,,,,,,,,,,,,A/L,,,A/L,,OOPT,,,,
27/7/22,,OFF ONCALL,IDT,,,A/L,OOPT,,,A/L,,,,A/L,,,,,,A/L,,No Oncall,,,,,,,OOPT,,A/L,,A/L,,,,,,,,,,,,A/L,,,A/L,,OOPT,,,,
28/7/22,,OFF ONCALL,IDT,,,A/L,OOPT,,,A/L,,,,A/L,,,,,,A/L,,No Oncall,,,,,,,OOPT,,A/L,,A/L,,,,,,,,,,,,A/L,,,A/L,,OOPT,,,,
29/7/22,,OFF ONCALL,IDT,,,A/L,OOPT,,,A/L,,,,A/L,,,,,,A/L,,No Oncall,,,,,,,OOPT,,A/L,,A/L,,,,,,,,,A/L,,,A/L,,,A/L,,OOPT,,,,
30/7/22,,OFF ONCALL,IDT,,,A/L,OOPT,,,A/L,,,,A/L,,,,,,A/L,,No Oncall,,A/L,,,,,OOPT,,A/L,,A/L,,,,,,,,,A/L,,A/L,A/L,,,A/L,,OOPT,,,,
31/7/22,,OFF ONCALL,IDT,,,A/L,OOPT,,,A/L,,,,A/L,,,,,,A/L,,No Oncall,,A/L,,,,,OOPT,,A/L,,A/L,,,,,,,,,A/L,,A/L,A/L,,,A/L,,OOPT,,,,
1/8/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,,,,,,,,,,A/L,,No Oncall,,A/L,,,,,OOPT,,A/L,,,,,,,,,,,,,A/L,,,,A/L,OOPE,OOPT,,,,
2/8/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,,,,,,,,,,A/L,,No Oncall,,A/L,,,,,OOPT,,A/L,,,,,,,,,,,,,A/L,,,,A/L,OOPE,OOPT,,,,
3/8/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,,,,,,,,,,A/L,,No Oncall,,A/L,,,,,OOPT,,A/L,,,,,,,,,,,,,A/L,,,,A/L,OOPE,OOPT,,,,
4/8/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,,,,,,,,,,A/L,,No Oncall,,A/L,,,,,OOPT,,A/L,,,,,,,,,,,,,A/L,,,,A/L,OOPE,OOPT,,,,
5/8/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,,,,,,,,,,A/L,,No Oncall,,A/L,,,,,OOPT,,A/L,,,,,A/L,,,,,,,,A/L,,A/L,,A/L,OOPE,OOPT,,A/L,,
6/8/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,,,,,,,,,,A/L,,No Oncall,,A/L,,,,,OOPT,,A/L,,,,,A/L,,,,,,,,A/L,,A/L,,A/L,OOPE,OOPT,,A/L,,
7/8/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,,,,,,,,,,A/L,,No Oncall,,A/L,,,,,OOPT,,A/L,,,,,A/L,,,,,,,,A/L,,A/L,,A/L,OOPE,OOPT,,A/L,,
8/8/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,,,,,,,,,A/L,OOPE,OOPT,,A/L,,
9/8/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,,,,,,,,,A/L,OOPE,OOPT,,A/L,,
10/8/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,,,,,,,,,,OOPE,OOPT,,A/L,,
11/8/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,,,,,,,,,,OOPE,OOPT,,A/L,,
12/8/22,,OFF ONCALL,IDT,,,,OOPT,,,A/L,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,,,,,,,,,,OOPE,OOPT,,A/L,,
13/8/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,A/L,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,A/L,,,,,,,,,OOPE,OOPT,,A/L,,
14/8/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,A/L,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,A/L,,,,,,,,,OOPE,OOPT,,A/L,,
15/8/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,A/L,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,A/L,,,,,,,,,OOPE,OOPT,,,,
16/8/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,A/L,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,A/L,,,,,,,,,OOPE,OOPT,,,,
17/8/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,A/L,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,A/L,,,,,,,,,OOPE,OOPT,,,,
18/8/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,A/L,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,A/L,,,,,,,,,OOPE,OOPT,,,,
19/8/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,A/L,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,A/L,,,,,,,,,OOPE,OOPT,,,,
20/8/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,A/L,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,A/L,,,,,,,,,OOPE,OOPT,,,,
21/8/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,A/L,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,A/L,,,,,,,,,OOPE,OOPT,,,,
22/8/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,A/L,,,,,,,,,,,A/L,OOPE,OOPT,,,,
23/8/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,A/L,,,A/L,,,,,,,,A/L,OOPE,OOPT,,,,
24/8/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,A/L,,,A/L,,,,,,,,A/L,OOPE,OOPT,,,,
25/8/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,A/L,,,,,,,,,,,A/L,OOPE,OOPT,,,,
26/8/22,,OFF ONCALL,IDT,,,A/L,OOPT,,A/L,,,,A/L,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,A/L,,,A/L,,,,,,,,A/L,OOPE,OOPT,,,,
27/8/22,,OFF ONCALL,IDT,A/L,,,OOPT,,A/L,,,,A/L,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,,,,,,,,,A/L,OOPE,OOPT,,,,
28/8/22,,OFF ONCALL,IDT,A/L,,,OOPT,,A/L,,,,A/L,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,,,,,,,,,A/L,OOPE,OOPT,,,,
29/8/22,,OFF ONCALL,IDT,A/L,,,OOPT,,A/L,,,,A/L,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,,,,,,,,,,,,A/L,OOPE,OOPT,,,,
30/8/22,,OFF ONCALL,IDT,A/L,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,A/L,,,,,,A/L,,,A/L,,,,,,,,,OOPE,OOPT,,,,
31/8/22,,OFF ONCALL,IDT,A/L,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,A/L,,,,,,A/L,,,A/L,,,,,,,,,OOPE,OOPT,,,,
1/9/22,,OFF ONCALL,IDT,A/L,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,A/L,,,,,,A/L,,,,,,,,,,,A/L,OOPE,OOPT,,,,
2/9/22,,OFF ONCALL,IDT,A/L,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,A/L,,,,,,,,,,,A/L,OOPE,OOPT,,,,
3/9/22,,OFF ONCALL,IDT,A/L,,,OOPT,,A/L,,,,,,,,,,,,,No Oncall,,A/L,,,,,OOPT,,,,,,,,A/L,,,,,,,,,,,,OOPE,OOPT,,,,
4/9/22,,OFF ONCALL,IDT,A/L,,,OOPT,,A/L,,OFF,,,,,,,,,,,No Oncall,,A/L,,,OFF,OFF,OOPT,OFF,,,,,,,A/L,,,,OFF,,,,,,,,OOPE,OOPT,OFF,,,
1 Rota 2021/2022 (POSSIBLE IDT) 80% until dec 1st
2 Name Stephanie Bailey Luciana Calovi Motschenbacher Layla Bell Seren Peters Alex wood Salma Aslam Max Ireland Charles Finan Paul Ward Tom Welsh Delilah Trimmer Max Finzel Zainab Sharaf Maththew O'Brien Kyaw Tint Nicolas Dziadulewicz Jean Skumar Michael Tomek Vilim Kalamar Dhuvresh Patel Khalil Madbak Georgina Edwards Jenn Haw fong Paul Jenkins Jenna Millington Amoolya Mannava Ross Kruger Sophie McGlade Rebecca Murphy Fiona Lyall Joel Lim Wijhdan Abusrewil Mark Haley Andrew MacCormick Alexander Sanchez - cabello Sayed Hashim Alqarooni Chong Yew Ng Matthew Thorley Harriet Conley Jon Skinner Cameron Bullock Alexander Wijnberg Christian Greer Alan Eccles Hannah Lewis Sarath Vennam Amina Odeh Mo Babsail Richard Chaytor Sherafghan PROC Madalina Drumea Mark Fellows Clement Leung
3 Rotation Exeter Exeter Exeter Exeter Exeter Exeter Exeter Exeter Exeter Exeter Exeter Derriford Derriford Derriford Derriford Derriford Derriford Derriford Derriford Derriford Derriford Derriford Derriford Derriford Derriford no proc Derriford Derriford Derriford Derriford Derriford Twilights Torbay Torbay Torbay Torbay Torbay Torbay Torbay Torbay Torbay Torbay Twilights Truro Truro Truro Truro Truro Truro Truro Truro Truro Truro no PROC Barnstaple Taunton Taunton
4 Grade ST2 ST2 ST2 ST3 ST3 ST3 ST3 ST4 ST4 ST5 ST5 ST2 ST2 ST2 ST2 ST3 ST3 ST3 ST3 ST3 ST4 ST4 ST4 ST4 ST4 ST5 ST5 ST5 ST5 ST6 ST2 ST2 ST2 ST2 ST3 ST4 ST4 ST4 ST5 ST6 ST2 ST2 ST3 ST3 ST3 ST4 ST4 ST5 ST5 ST5 (until dec) ST3 ST2 ST4
5 PROC site Derriford exeter Torbay Exeter Derriford Derriford Exeter Exeter Derriford Derriford torbay Torbay derriford derriford exeter Exeter derriford Derriford derriford Derriford Derriford Exeter NA derriford Torbay Derriford Derriford torbay Derriford Derriford Torbay NA Exeter Derriford Truro Truro Derriford Truro Truro Derriford derriford Derriford Barnstaple NA NA
6 %FTE 0 0 100 100 100 0 100 100 100 100 80 100 100 100 100 100 60 80 100 100 0 80 100 100 0 100 100 0 100 60 100 100 100 100 100 100 100 100 60 100 100 100 100 80 100 100 100 100 100 100 80 0 0
7 NWD Thursday,Friday Tuesday,Thursday wednesday Wednesday,thursday Wednesday Friday thursday, friday Friday monday,friday thursday, friday monday,thursday wednesday, thursday,Friday
8 Flexible NWD
9 End Date
10 OOP 6/9/21-31/3/22 6/9/21-2/5/22 1/1/22-5/9/22 6/9/21-28/2/22 1/3/22-31/3/22 6/9/21-20/9/21 1/8/22-4/9/22 23/7/22-4/9/22
11 Group Y1G1 Y1G1 Y1G2 Y3G1 Y3G1 Y3G2 Y3G2 Y2G1 Y2G2 Y2G2 Y2G2 Y3G1 Y3G1 Y3G2 Y3G2 Y3G2 Y2G1 Y2G1 Y2G2 Y2G2 Y3G2 Y2G1 Y2G2 Y3G1 Y3G1 Y3G2 Y2G1
12 ?PROC NO WEEKEND ONLY No No Yes - until ST6 in dec ??? NO NO
13 Pair 1 1 2
14 Extra_night_weekday 3.72 -0.28 -0.28 -0.28 -0.28 -0.28 -0.28 -0.28 0.63 -0.28 -0.28 -0.28 -0.28 -0.28 4.63 2.17 -0.28 3.72 2.17 -0.28 -0.28 -0.28 -0.28 0.3 0 -0.28 -0.28 -0.28 3.72 -0.28 -0.28 -1.06 -0.28 0.27 -0.28 -0.28 -0.28 -1.83 -0.28 -0.28 -0.28 -0.28 -0.28 0 -0.28
15 Extra_night_weekend -0.21 -0.21 -0.21 -0.21 -0.21 -0.21 -0.21 -0.21 0.47 -0.21 2.79 2.79 2.79 -0.21 -2.53 -1.37 -0.21 -0.21 -1.37 -0.21 -0.21 -0.21 -0.21 0.22 0 -0.21 -0.21 -0.21 -0.21 -0.21 -0.21 2.21 -0.21 0.21 -0.21 -0.21 -0.21 1.63 -0.21 -0.21 -0.21 -0.21 -0.21 0 2.79
16 Extra_twilight -1.97 -0.97 1.03 1.03 -0.97 -1.97 1.03 1.03 -0.38 0.47 0.47 -1.53 0.47 -0.53 -3.92 -2.23 0.47 -2.53 -2.23 0.47 0.47 0.47 0.47 0.33 0.08 -1.39 -1.39 0.61 0.49 -1.39 -0.39 1.61 2.61 -1.39 0.61 0
17 Extra_weekend -0.73 2.27 -0.73 -0.73 2.27 2.27 -0.73 -0.73 -1.04 1.96 -1.04 -1.04 -0.63 1.96 0.96 -1.04 -2.04 1.96 -1.04 0
18 Extra_weekend_plymouth1 -2.22 -0.22 -2.22 -0.22 -0.22 2.27 -0.98 -2.22 -0.22 1.02 1.78 -0.22 1.78 1.78 0.15 0
19 Extra_weekend_plymouth2 1.78 -2.22 1.78 -2.22 -0.22 0.27 3.02 1.78 -0.22 1.02 -2.22 -0.22 -2.22 -0.22 0.15 0
20 Extra_plymouth_bank_holiday 0.2 0.2 0.2 0.2 0.2 0.12 -0.84 0.2 0.2 0.16 0.2 0.2 0.2 -1.8 0.01 0.12
21 bank_holidays_worked 1 1 2 1 3 2 1 2 2 1 3 2 2 2 2 1 1 2 2 2 2 1
22
23 Date Exams
24 6/9/21 NO ONCALL POST ONCALL Mat leave exeter_twilight exeter_twilight A/L A/L No Oncall POST ONCALL POST ONCALL POST ONCALL Mat leave plymouth_twilight OOPT NO oncals POST ONCALL A/L torbay_twilight torbay_twilight Mat leave POST ONCALL truro_twilight truro_twilight A/L POST ONCALL
25 7/9/21 NO ONCALL POST ONCALL Mat leave exeter_twilight exeter_twilight A/L A/L No Oncall POST ONCALL POST ONCALL POST ONCALL Mat leave plymouth_twilight OOPT NO oncals POST ONCALL A/L torbay_twilight torbay_twilight Mat leave POST ONCALL truro_twilight truro_twilight A/L POST ONCALL
26 8/9/21 NO ONCALL Mat leave 2b course 2b course A/L No Oncall 2b course 2b course Mat leave 2b course OOPT NO oncals A/L 2b course 2b course Mat leave 2b course 2b course A/L
27 9/9/21 NO ONCALL Mat leave 2b course 2b course A/L No Oncall 2b course 2b course Mat leave 2b course OOPT NO oncals A/L 2b course 2b course Mat leave 2b course 2b course A/L
28 10/9/21 NO ONCALL A/L Mat leave A/L No Oncall Mat leave A/L OOPT NO oncals Mat leave A/L
29 11/9/21 NO ONCALL A/L Mat leave A/L No Oncall Mat leave A/L OOPT NO oncals Mat leave A/L
30 12/9/21 NO ONCALL A/L Mat leave A/L No Oncall Mat leave A/L OOPT NO oncals Mat leave A/L
31 13/9/21 NO ONCALL A/L Mat leave A/L No Oncall Mat leave A/L OOPT NO oncals Mat leave
32 14/9/21 NO ONCALL Mat leave A/L No Oncall Mat leave OOPT NO oncals Mat leave
33 15/9/21 NO ONCALL Mat leave A/L No Oncall Mat leave OOPT NO oncals Mat leave
34 16/9/21 NO ONCALL Mat leave A/L No Oncall Mat leave OOPT NO oncals Mat leave
35 17/9/21 Pre 2B NO ONCALL Mat leave Pre 2B Pre 2B A/L Pre 2B A/L No Oncall Pre 2B Mat leave OOPT NO oncals Pre 2B Pre 2B Pre 2B Mat leave Pre 2B Pre 2B Pre 2B
36 18/9/21 Pre 2B NO ONCALL Mat leave Pre 2B Pre 2B A/L Pre 2B No Oncall Pre 2B Mat leave OOPT NO oncals Pre 2B Pre 2B Pre 2B Mat leave Pre 2B Pre 2B Pre 2B
37 19/9/21 Pre 2B NO ONCALL Mat leave Pre 2B Pre 2B A/L Pre 2B No Oncall Pre 2B Mat leave OOPT NO oncals Pre 2B Pre 2B Pre 2B Mat leave Pre 2B Pre 2B Pre 2B
38 20/9/21 Pre 2B NO ONCALL Mat leave Pre 2B Pre 2B A/L Pre 2B No Oncall Pre 2B Mat leave OOPT NO oncals Pre 2B Pre 2B Pre 2B Mat leave Pre 2B Pre 2B Pre 2B
39 21/9/21 Pre 2B NO ONCALL Mat leave Pre 2B Pre 2B Pre 2B No Oncall Pre 2B Mat leave OOPT NO oncals Pre 2B Pre 2B Pre 2B No nights Pre 2B Pre 2B Pre 2B
40 22/9/21 Pre 2B NO ONCALL Mat leave Pre 2B Pre 2B Pre 2B No Oncall Pre 2B Mat leave OOPT NO oncals Pre 2B Pre 2B Pre 2B No nights Pre 2B Pre 2B Pre 2B
41 23/9/21 Pre 2B NO ONCALL Mat leave Pre 2B Pre 2B Pre 2B No Oncall Pre 2B Mat leave OOPT NO oncals A/L Pre 2B Pre 2B Pre 2B No nights Pre 2B Pre 2B Pre 2B
42 24/9/21 Pre 2B NO ONCALL Mat leave Pre 2B Pre 2B Pre 2B No Oncall Pre 2B Mat leave OOPT NO oncals A/L Pre 2B Pre 2B Pre 2B A/L Pre 2B Pre 2B Pre 2B
43 25/9/21 Pre 2B NO ONCALL Mat leave Pre 2B Pre 2B Pre 2B No Oncall Pre 2B Mat leave OOPT NO oncals A/L Pre 2B Pre 2B Pre 2B A/L Pre 2B Pre 2B Pre 2B
44 26/9/21 Pre 2B NO ONCALL Mat leave Pre 2B Pre 2B Pre 2B No Oncall Pre 2B Mat leave OOPT NO oncals A/L Pre 2B Pre 2B Pre 2B A/L Pre 2B Pre 2B Pre 2B
45 27/9/21 Pre 2B NO ONCALL Mat leave Pre 2B Pre 2B Pre 2B No Oncall Pre 2B Mat leave OOPT Pre 2B Pre 2B Pre 2B No nights Pre 2B Pre 2B Pre 2B
46 28/9/21 Pre 2B NO ONCALL Mat leave Pre 2B Pre 2B Pre 2B No Oncall Pre 2B Mat leave OOPT Pre 2B Pre 2B Pre 2B No nights Pre 2B Pre 2B Pre 2B
47 29/9/21 Pre 2B NO ONCALL Mat leave Pre 2B Pre 2B Pre 2B No Oncall Pre 2B Mat leave OOPT Pre 2B Pre 2B Pre 2B No nights Pre 2B Pre 2B Pre 2B
48 30/9/21 Pre 2B NO ONCALL Mat leave Pre 2B Pre 2B Pre 2B No Oncall Pre 2B Mat leave OOPT Pre 2B Pre 2B Pre 2B No nights Pre 2B Pre 2B Pre 2B
49 1/10/21 2B NO ONCALL Mat leave 2B 2B A/L 2B A/L No Oncall 2B Mat leave OOPT 2B 2B 2B No nights 2B 2B 2B
50 2/10/21 2B NO ONCALL Mat leave 2B 2B A/L 2B A/L No Oncall 2B Mat leave OOPT 2B 2B 2B No nights 2B 2B 2B
51 3/10/21 2B NO ONCALL Mat leave 2B 2B A/L 2B A/L No Oncall 2B Mat leave OOPT 2B 2B 2B No nights 2B 2B 2B
52 4/10/21 2B NO ONCALL Mat leave 2B 2B A/L 2B No Oncall 2B Mat leave OOPT 2B 2B 2B No nights 2B 2B 2B
53 5/10/21 2B NO ONCALL Mat leave 2B 2B 2B No Oncall 2B Mat leave OOPT 2B 2B 2B No nights 2B 2B 2B
54 6/10/21 2B NO ONCALL Mat leave 2B 2B 2B No Oncall 2B Mat leave OOPT 2B 2B 2B No nights 2B 2B 2B
55 7/10/21 2B NO ONCALL Mat leave 2B 2B 2B No Oncall 2B Mat leave OOPT 2B 2B 2B No nights 2B 2B 2B
56 8/10/21 2B NO ONCALL Mat leave 2B 2B 2B No Oncall 2B Mat leave A/L OOPT 2B 2B 2B No nights 2B 2B 2B
57 9/10/21 NO ONCALL Mat leave No Oncall Mat leave A/L OOPT No nights
58 10/10/21 NO ONCALL Mat leave No Oncall Mat leave A/L OOPT No nights
59 11/10/21 NO ONCALL Mat leave No Oncall Mat leave A/L OOPT No nights
60 12/10/21 NO ONCALL Mat leave No Oncall Mat leave OOPT No nights
61 13/10/21 NO ONCALL Mat leave No Oncall Mat leave OOPT No nights
62 14/10/21 NO ONCALL Mat leave No Oncall Mat leave OOPT No nights
63 15/10/21 NO ONCALL Mat leave No Oncall Mat leave OOPT No nights
64 16/10/21 NO ONCALL Mat leave No Oncall Mat leave OOPT No nights
65 17/10/21 NO ONCALL Mat leave No Oncall Mat leave OOPT No nights
66 18/10/21 NO ONCALL Mat leave No Oncall Mat leave OOPT No nights
67 19/10/21 NO ONCALL Mat leave No Oncall Mat leave OOPT No nights
68 20/10/21 NO ONCALL Mat leave No Oncall Mat leave OOPT No nights
69 21/10/21 NO ONCALL Mat leave No Oncall Mat leave OOPT No nights
70 22/10/21 NO ONCALL Mat leave A/L A/L No Oncall Mat leave OOPT A/L No nights A/L
71 23/10/21 NO ONCALL Mat leave A/L A/L A/L No Oncall Mat leave OOPT A/L A/L A/L A/L
72 24/10/21 NO ONCALL Mat leave A/L A/L A/L No Oncall Mat leave OOPT A/L A/L A/L A/L
73 25/10/21 NO ONCALL Mat leave A/L A/L A/L No Oncall Mat leave OOPT A/L A/L
74 26/10/21 NO ONCALL Mat leave A/L A/L No Oncall Mat leave OOPT A/L A/L
75 27/10/21 NO ONCALL Mat leave A/L A/L No Oncall Mat leave OOPT A/L A/L
76 28/10/21 NO ONCALL Mat leave A/L A/L No Oncall Mat leave OOPT A/L A/L
77 29/10/21 NO ONCALL Mat leave A/L A/L No Oncall Mat leave OOPT A/L A/L
78 30/10/21 NO ONCALL A/L Mat leave A/L A/L No Oncall Mat leave OOPT A/L A/L A/L
79 31/10/21 NO ONCALL A/L Mat leave A/L A/L No Oncall Mat leave OOPT A/L A/L A/L
80 1/11/21 Pre 2a NO ONCALL Pre 2a Pre 2a Mat leave Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a No Oncall Pre 2a Pre 2a Mat leave OOPT Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a
81 2/11/21 Pre 2a NO ONCALL Pre 2a Pre 2a Mat leave Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a No Oncall Pre 2a Pre 2a Mat leave OOPT Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a
82 3/11/21 Pre 2a NO ONCALL Pre 2a Pre 2a Mat leave Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a No Oncall Pre 2a Pre 2a Mat leave OOPT Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a A/L Pre 2a Pre 2a
83 4/11/21 Pre 2a NO ONCALL Pre 2a Pre 2a Mat leave Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a No Oncall Pre 2a Pre 2a Mat leave OOPT Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a A/L Pre 2a Pre 2a
84 5/11/21 Pre 2a NO ONCALL Pre 2a Pre 2a Mat leave Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a No Oncall Pre 2a Pre 2a Mat leave A/L OOPT Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a A/L Pre 2a Pre 2a
85 6/11/21 Pre 2a NO ONCALL Pre 2a Pre 2a Mat leave Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a No Oncall Pre 2a Pre 2a Mat leave A/L OOPT Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a A/L Pre 2a Pre 2a
86 7/11/21 Pre 2a NO ONCALL Pre 2a Pre 2a Mat leave Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a No Oncall Pre 2a Pre 2a Mat leave A/L OOPT Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a A/L Pre 2a Pre 2a
87 8/11/21 Pre 2a NO ONCALL Pre 2a Pre 2a Mat leave Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a No Oncall Pre 2a Pre 2a Mat leave OOPT Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a
88 9/11/21 Pre 2a NO ONCALL Pre 2a Pre 2a Mat leave Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a No Oncall Pre 2a Pre 2a Mat leave OOPT Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a
89 10/11/21 Pre 2a NO ONCALL Pre 2a Pre 2a Mat leave Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a No Oncall Pre 2a Pre 2a Mat leave OOPT Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a A/L Pre 2a Pre 2a
90 11/11/21 Pre 2a NO ONCALL Pre 2a Pre 2a Mat leave Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a No Oncall Pre 2a Pre 2a Mat leave OOPT Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a A/L Pre 2a Pre 2a
91 12/11/21 Pre 2a NO ONCALL Pre 2a Pre 2a Mat leave Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a No Oncall Pre 2a Pre 2a Mat leave OOPT Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a A/L Pre 2a Pre 2a
92 13/11/21 Pre 2a NO ONCALL Pre 2a Pre 2a Mat leave Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a No Oncall Pre 2a Pre 2a Mat leave OOPT A/L Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a A/L Pre 2a Pre 2a
93 14/11/21 Pre 2a NO ONCALL Pre 2a Pre 2a Mat leave Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a No Oncall Pre 2a Pre 2a Mat leave OOPT A/L Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a A/L Pre 2a Pre 2a
94 15/11/21 Pre 2a NO ONCALL Pre 2a Pre 2a Mat leave Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a No Oncall Pre 2a Pre 2a Mat leave OOPT A/L Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a
95 16/11/21 Pre 2a NO ONCALL Pre 2a Pre 2a Mat leave Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a No Oncall Pre 2a Pre 2a Mat leave OOPT A/L Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a Pre 2a
96 17/11/21 2A NO ONCALL 2A 2A Mat leave 2A 2A 2A 2A 2A 2A 2A No Oncall 2A 2A Mat leave OOPT A/L 2A 2A 2A 2A 2A 2A 2A 2A
97 18/11/21 NO ONCALL Mat leave No Oncall Mat leave OOPT A/L A/L A/L
98 19/11/21 NO ONCALL Mat leave No Oncall Mat leave OOPT A/L A/L A/L
99 20/11/21 NO ONCALL A/L Mat leave No Oncall Mat leave OOPT A/L A/L A/L A/L
100 21/11/21 NO ONCALL A/L Mat leave No Oncall Mat leave OOPT A/L A/L A/L A/L
101 22/11/21 NO ONCALL A/L Mat leave No Oncall Mat leave OOPT A/L A/L A/L A/L
102 23/11/21 NO ONCALL A/L Mat leave No Oncall Mat leave OOPT A/L A/L A/L A/L
103 24/11/21 NO ONCALL A/L Mat leave No Oncall Mat leave OOPT A/L A/L A/L A/L
104 25/11/21 NO ONCALL A/L Mat leave No Oncall Mat leave OOPT A/L A/L A/L A/L
105 26/11/21 NO ONCALL A/L Mat leave No Oncall Mat leave OOPT A/L A/L A/L A/L
106 27/11/21 NO ONCALL A/L Mat leave No Oncall Mat leave OOPT A/L A/L A/L A/L
107 28/11/21 NO ONCALL A/L Mat leave No Oncall Mat leave OOPT A/L A/L A/L A/L
108 29/11/21 NO ONCALL Mat leave No Oncall Mat leave OOPT A/L A/L A/L
109 30/11/21 NO ONCALL Mat leave No Oncall Mat leave OOPT A/L A/L A/L
110 1/12/21 NO ONCALL Mat leave No Oncall Mat leave OOPT A/L A/L A/L
111 2/12/21 NO ONCALL Mat leave No Oncall Mat leave OOPT A/L A/L A/L
112 3/12/21 NO ONCALL Mat leave OFF No Oncall Mat leave OOPT A/L A/L A/L
113 4/12/21 NO ONCALL Mat leave OFF No Oncall Mat leave OOPT A/L A/L
114 5/12/21 NO ONCALL Mat leave OFF No Oncall Mat leave OOPT A/L A/L
115 6/12/21 NO ONCALL Mat leave No Oncall Mat leave OOPT
116 7/12/21 NO ONCALL Mat leave No Oncall Mat leave OOPT
117 8/12/21 NO ONCALL Mat leave No Oncall Mat leave OOPT
118 9/12/21 NO ONCALL Mat leave No Oncall Mat leave OOPT A/L
119 10/12/21 NO ONCALL Mat leave No Oncall Mat leave OOPT A/L
120 11/12/21 NO ONCALL Mat leave A/L No Oncall Mat leave OOPT A/L A/L
121 12/12/21 NO ONCALL Mat leave A/L No Oncall Mat leave OOPT A/L A/L
122 13/12/21 NO ONCALL Mat leave A/L No Oncall Mat leave OOPT A/L
123 14/12/21 NO ONCALL Mat leave A/L No Oncall Mat leave OOPT A/L
124 15/12/21 NO ONCALL Mat leave A/L No Oncall Mat leave OOPT A/L
125 16/12/21 NO ONCALL Mat leave A/L No Oncall Mat leave OOPT A/L
126 17/12/21 NO ONCALL Mat leave A/L No Oncall Mat leave A/L OOPT A/L
127 18/12/21 NO ONCALL Mat leave A/L No Oncall Mat leave A/L OOPT A/L
128 19/12/21 NO ONCALL Mat leave A/L No Oncall Mat leave A/L OOPT A/L
129 20/12/21 NO ONCALL Mat leave No Oncall Mat leave OOPT A/L
130 21/12/21 NO ONCALL Mat leave No Oncall Mat leave OOPT A/L
131 22/12/21 NO ONCALL Mat leave A/L No Oncall Mat leave OOPT A/L
132 23/12/21 NO ONCALL OFF Mat leave A/L OFF No Oncall Mat leave OOPT OFF OFF OFF OFF OFF OFF OFF A/L
133 24/12/21 NO ONCALL OFF Mat leave A/L OFF No Oncall Mat leave OOPT OFF OFF OFF OFF OFF OFF OFF A/L
134 25/12/21 NO ONCALL OFF Mat leave A/L OFF No Oncall Mat leave OOPT OFF OFF OFF OFF OFF OFF OFF A/L
135 26/12/21 NO ONCALL OFF Mat leave A/L OFF No Oncall Mat leave OOPT OFF OFF OFF OFF OFF OFF OFF A/L
136 27/12/21 NO ONCALL OFF Mat leave A/L OFF No Oncall Mat leave OOPT OFF OFF OFF OFF OFF OFF OFF A/L
137 28/12/21 NO ONCALL Mat leave A/L No Oncall Mat leave OOPT OFF A/L
138 29/12/21 NO ONCALL Mat leave A/L No Oncall Mat leave OOPT OFF A/L
139 30/12/21 NO ONCALL Mat leave No Oncall Mat leave OOPT OFF A/L
140 31/12/21 NO ONCALL Mat leave No Oncall Mat leave OOPT OFF A/L
141 1/1/22 NO ONCALL Mat leave No Oncall Mat leave OOPT OOPT OFF A/L
142 2/1/22 NO ONCALL Mat leave No Oncall Mat leave OOPT OOPT OFF A/L
143 3/1/22 NO ONCALL Mat leave A/L No Oncall Mat leave OOPT OOPT A/L OFF A/L
144 4/1/22 NO ONCALL Mat leave A/L No Oncall Mat leave OOPT OOPT A/L OFF A/L
145 5/1/22 NO ONCALL Mat leave No Oncall Mat leave OOPT OOPT A/L A/L
146 6/1/22 NO ONCALL Mat leave No Oncall Mat leave OOPT OOPT A/L A/L
147 7/1/22 NO ONCALL Mat leave No Oncall Mat leave OOPT OOPT A/L A/L
148 8/1/22 NO ONCALL Mat leave No Oncall Mat leave OOPT OOPT A/L A/L A/L
149 9/1/22 NO ONCALL Mat leave No Oncall Mat leave OOPT OOPT A/L A/L A/L
150 10/1/22 NO ONCALL Mat leave No Oncall Mat leave OOPT OOPT A/L
151 11/1/22 NO ONCALL Mat leave No Oncall Mat leave OOPT OOPT A/L
152 12/1/22 NO ONCALL Mat leave No Oncall Mat leave OOPT OOPT A/L
153 13/1/22 NO ONCALL Mat leave No Oncall Mat leave OOPT OOPT A/L
154 14/1/22 NO ONCALL Mat leave No Oncall Mat leave OOPT OOPT A/L
155 15/1/22 NO ONCALL Mat leave No Oncall Mat leave OOPT OOPT A/L
156 16/1/22 NO ONCALL Mat leave No Oncall Mat leave OOPT OOPT A/L
157 17/1/22 NO ONCALL Mat leave No Oncall Mat leave OOPT OOPT
158 18/1/22 NO ONCALL Mat leave No Oncall Mat leave OOPT OOPT
159 19/1/22 NO ONCALL Mat leave No Oncall Mat leave OOPT OOPT
160 20/1/22 NO ONCALL Mat leave No Oncall Mat leave OOPT OOPT
161 21/1/22 NO ONCALL A/L Mat leave No Oncall Mat leave OOPT OOPT A/L
162 22/1/22 NO ONCALL A/L Mat leave A/L No Oncall A/L Mat leave OOPT OOPT A/L
163 23/1/22 NO ONCALL A/L Mat leave A/L No Oncall A/L Mat leave OOPT OOPT A/L
164 24/1/22 NO ONCALL A/L Mat leave A/L No Oncall A/L Mat leave OOPT OOPT
165 25/1/22 NO ONCALL Mat leave A/L No Oncall A/L Mat leave OOPT OOPT
166 26/1/22 NO ONCALL Mat leave A/L No Oncall A/L Mat leave OOPT OOPT
167 27/1/22 NO ONCALL Mat leave A/L No Oncall A/L Mat leave OOPT OOPT
168 28/1/22 NO ONCALL Mat leave A/L A/L No Oncall A/L Mat leave OOPT OOPT
169 29/1/22 NO ONCALL Mat leave A/L A/L No Oncall A/L Mat leave OOPT OOPT A/L
170 30/1/22 NO ONCALL Mat leave A/L A/L No Oncall A/L Mat leave OOPT OOPT A/L
171 31/1/22 NO ONCALL Mat leave A/L No Oncall A/L Mat leave OOPT OOPT A/L
172 1/2/22 NO ONCALL Mat leave No Oncall A/L Mat leave OOPT OOPT A/L A/L
173 2/2/22 NO ONCALL Mat leave No Oncall A/L Mat leave OOPT OOPT A/L A/L A/L
174 3/2/22 NO ONCALL Mat leave No Oncall A/L Mat leave OOPT OOPT A/L A/L A/L
175 4/2/22 NO ONCALL Mat leave No Oncall A/L Mat leave OOPT OOPT A/L A/L A/L
176 5/2/22 NO ONCALL Mat leave No Oncall A/L Mat leave OOPT OOPT A/L A/L A/L
177 6/2/22 NO ONCALL Mat leave No Oncall A/L Mat leave OOPT OOPT A/L A/L A/L
178 7/2/22 NO ONCALL Mat leave No Oncall A/L Mat leave OOPT OOPT A/L A/L
179 8/2/22 NO ONCALL Mat leave No Oncall A/L Mat leave OOPT OOPT A/L A/L
180 9/2/22 NO ONCALL Mat leave No Oncall A/L Mat leave OOPT OOPT A/L A/L
181 10/2/22 NO ONCALL Mat leave No Oncall A/L Mat leave OOPT OOPT A/L A/L
182 11/2/22 NO ONCALL Mat leave No Oncall A/L Mat leave OOPT OOPT A/L A/L
183 12/2/22 NO ONCALL Mat leave No Oncall A/L Mat leave OOPT OOPT A/L A/L
184 13/2/22 NO ONCALL Mat leave No Oncall A/L Mat leave OOPT OOPT A/L A/L
185 14/2/22 NO ONCALL Mat leave No Oncall Mat leave OOPT OOPT
186 15/2/22 NO ONCALL Mat leave No Oncall Mat leave OOPT OOPT
187 16/2/22 NO ONCALL Mat leave No Oncall Mat leave OOPT OOPT
188 17/2/22 NO ONCALL Mat leave No Oncall Mat leave OOPT OOPT
189 18/2/22 NO ONCALL Mat leave No Oncall Mat leave OOPT OOPT A/L A/L A/L
190 19/2/22 NO ONCALL Mat leave A/L A/L No Oncall Mat leave OOPT OOPT A/L A/L A/L A/L A/L
191 20/2/22 NO ONCALL Mat leave A/L A/L No Oncall Mat leave OOPT OOPT A/L A/L A/L A/L A/L
192 21/2/22 NO ONCALL Mat leave A/L A/L No Oncall Mat leave OOPT OOPT A/L A/L A/L
193 22/2/22 NO ONCALL Mat leave A/L A/L No Oncall Mat leave OOPT OOPT A/L A/L A/L
194 23/2/22 NO ONCALL Mat leave A/L A/L No Oncall Mat leave OOPT OOPT A/L A/L A/L
195 24/2/22 NO ONCALL Mat leave A/L A/L No Oncall Mat leave OOPT OOPT A/L A/L A/L
196 25/2/22 NO ONCALL Mat leave A/L A/L No Oncall Mat leave OOPT OOPT A/L A/L A/L
197 26/2/22 NO ONCALL Mat leave A/L A/L No Oncall Mat leave OOPT OOPT A/L A/L A/L
198 27/2/22 NO ONCALL Mat leave A/L A/L No Oncall Mat leave OOPT OOPT A/L A/L A/L
199 28/2/22 NO ONCALL Mat leave A/L No Oncall Mat leave OOPT OOPT A/L
200 1/3/22 NO ONCALL Mat leave A/L No Oncall Mat leave OOPT OOPC A/L A/L
201 2/3/22 NO ONCALL Mat leave A/L No Oncall Mat leave OOPT OOPC A/L A/L A/L
202 3/3/22 NO ONCALL Mat leave A/L No Oncall Mat leave OOPT OOPC A/L A/L A/L A/L
203 4/3/22 NO ONCALL Mat leave A/L No Oncall Mat leave OOPT OOPC A/L A/L A/L
204 5/3/22 NO ONCALL Mat leave OOPT A/L No Oncall Mat leave OOPT IR oncall OOPC A/L A/L A/L A/L
205 6/3/22 NO ONCALL Mat leave OOPT A/L No Oncall Mat leave OOPT IR oncall OOPC A/L A/L A/L A/L
206 7/3/22 OFF ONCALL IDT POST ONCALL POST ONCALL OOPT OOPT POST ONCALL No Oncall POST ONCALL Mat leave POST ONCALL OOPT OOPC POST ONCALL POST ONCALL A/L A/L POST ONCALL A/L
207 8/3/22 OFF ONCALL IDT POST ONCALL POST ONCALL OOPT OOPT POST ONCALL No Oncall POST ONCALL Mat leave POST ONCALL OOPT OOPC POST ONCALL POST ONCALL A/L A/L POST ONCALL A/L
208 9/3/22 OFF ONCALL IDT OOPT OOPT No Oncall Mat leave POST ONCALL A/L OOPT OOPC A/L A/L A/L
209 10/3/22 OFF ONCALL IDT OOPT OOPT No Oncall Mat leave A/L OOPT OOPC A/L A/L A/L
210 11/3/22 OFF ONCALL IDT OOPT OOPT No Oncall Mat leave A/L OOPT OOPC A/L A/L A/L
211 12/3/22 OFF ONCALL IDT POST ONCALL OOPT OOPT A/L POST ONCALL No Oncall Mat leave A/L OOPT OOPC A/L POST ONCALL A/L A/L A/L A/L
212 13/3/22 OFF ONCALL IDT OOPT OOPT A/L No Oncall Mat leave A/L OOPT OOPC A/L A/L A/L A/L A/L
213 14/3/22 pre 2b OFF ONCALL IDT OOPT OOPT A/L No Oncall Mat leave A/L OOPT OOPC A/L A/L A/L pre 2b
214 15/3/22 pre 2b OFF ONCALL IDT OOPT OOPT A/L No Oncall Mat leave A/L OOPT OOPC A/L A/L A/L pre 2b
215 16/3/22 pre 2b OFF ONCALL IDT OOPT OOPT A/L No Oncall Mat leave A/L OOPT OOPC A/L POST ONCALL A/L A/L pre 2b A/L
216 17/3/22 pre 2b OFF ONCALL IDT OOPT A/L OOPT A/L A/L No Oncall Mat leave A/L OOPT OOPC A/L A/L A/L pre 2b
217 18/3/22 pre 2b OFF ONCALL IDT OOPT OOPT A/L A/L No Oncall Mat leave A/L OOPT OOPC A/L A/L A/L pre 2b
218 19/3/22 pre 2b OFF ONCALL IDT OOPT A/L OOPT A/L A/L No Oncall Mat leave A/L OOPT OOPC A/L IR ONCALL A/L A/L pre 2b A/L
219 20/3/22 pre 2b OFF ONCALL IDT OOPT A/L OOPT A/L A/L No Oncall Mat leave A/L OOPT OOPC A/L IR ONCALL A/L A/L pre 2b A/L
220 21/3/22 pre 2b OFF ONCALL IDT OOPT A/L OOPT A/L No Oncall Mat leave A/L OOPT OOPC A/L pre 2b A/L
221 22/3/22 pre 2b OFF ONCALL IDT OOPT A/L OOPT A/L No Oncall Mat leave A/L OOPT A/L OOPC pre 2b A/L
222 23/3/22 pre 2b OFF ONCALL IDT OOPT A/L OOPT A/L No Oncall Mat leave A/L OOPT OOPC pre 2b A/L
223 24/3/22 pre 2b OFF ONCALL IDT OOPT A/L OOPT A/L No Oncall Mat leave A/L OOPT OOPC pre 2b A/L
224 25/3/22 2b OFF ONCALL IDT OOPT A/L OOPT A/L No Oncall Mat leave A/L OOPT OOPC 2b A/L
225 26/3/22 2b OFF ONCALL IDT OOPT A/L OOPT A/L A/L No Oncall Mat leave A/L OOPT OOPC 2b A/L
226 27/3/22 2b OFF ONCALL IDT OOPT A/L OOPT A/L A/L No Oncall Mat leave A/L OOPT OOPC 2b A/L
227 28/3/22 2b OFF ONCALL IDT OOPT A/L A/L No Oncall Mat leave A/L OOPT OOPC 2b
228 29/3/22 2b OFF ONCALL IDT OOPT A/L A/L No Oncall Mat leave A/L OOPT OOPC 2b A/L
229 30/3/22 2b OFF ONCALL IDT OOPT No Oncall Mat leave A/L OOPT OOPC 2b A/L
230 31/3/22 2b OFF ONCALL IDT OOPT No Oncall Mat leave A/L OOPT OOPC 2b A/L
231 1/4/22 2b OFF ONCALL IDT OOPT No Oncall Mat leave OOPT No nights or twiligts 2b Ramadan A/L
232 2/4/22 OFF ONCALL IDT A/L OOPT No Oncall Mat leave OOPT No nights or twiligts A/L Ramadan A/L
233 3/4/22 OFF ONCALL IDT A/L OOPT No Oncall Mat leave OOPT No nights or twiligts A/L Ramadan A/L
234 4/4/22 OFF ONCALL IDT A/L OOPT No Oncall Mat leave OOPT No nights or twiligts Ramadan A/L
235 5/4/22 OFF ONCALL IDT A/L OOPT No Oncall Mat leave OOPT No nights or twiligts Ramadan A/L
236 6/4/22 OFF ONCALL IDT A/L OOPT No Oncall Mat leave OOPT No nights or twiligts Ramadan A/L
237 7/4/22 OFF ONCALL IDT A/L OOPT No Oncall Mat leave OOPT S/L No nights or twiligts Ramadan A/L
238 8/4/22 OFF ONCALL IDT A/L OOPT A/L A/L No Oncall Mat leave OOPT No nights or twiligts A/L Ramadan A/L
239 9/4/22 OFF ONCALL IDT A/L OOPT A/L A/L A/L No Oncall Mat leave OOPT No nights or twiligts A/L Ramadan A/L
240 10/4/22 OFF ONCALL IDT A/L OOPT A/L A/L A/L No Oncall Mat leave OOPT No nights or twiligts A/L Ramadan A/L
241 11/4/22 OFF ONCALL IDT OOPT A/L A/L A/L No Oncall Mat leave OOPT No nights or twiligts A/L Ramadan
242 12/4/22 OFF ONCALL IDT OOPT A/L No Oncall Mat leave OOPT No nights or twiligts A/L A/L A/L A/L Ramadan
243 13/4/22 OFF ONCALL IDT OOPT A/L No Oncall Mat leave OOPT No nights or twiligts A/L A/L Ramadan
244 14/4/22 OFF ONCALL IDT OOPT A/L No Oncall Mat leave OOPT No nights or twiligts A/L Ramadan
245 15/4/22 OFF ONCALL IDT OOPT Worked Xmas Worked Xmas A/L Worked Xmas Worked Xmas No Oncall Mat leave Worked Xmas OOPT Worked Xmas No nights or twiligts Worked Xmas A/L Worked Xmas Ramadan
246 16/4/22 OFF ONCALL IDT OOPT Worked Xmas Worked Xmas A/L Worked Xmas Worked Xmas No Oncall Mat leave Worked Xmas OOPT Worked Xmas No nights Worked Xmas A/L Worked Xmas Ramadan
247 17/4/22 OFF ONCALL IDT OOPT Worked Xmas Worked Xmas A/L Worked Xmas Worked Xmas No Oncall Mat leave Worked Xmas OOPT Worked Xmas No nights Worked Xmas A/L Worked Xmas Ramadan
248 18/4/22 OFF ONCALL IDT OOPT Worked Xmas Worked Xmas A/L Worked Xmas Worked Xmas No Oncall Mat leave Worked Xmas OOPT A/L Worked Xmas No nights Worked Xmas A/L Worked Xmas Ramadan
249 19/4/22 OFF ONCALL IDT OOPT A/L No Oncall Mat leave OOPT A/L No nights A/L A/L A/L Ramadan
250 20/4/22 OFF ONCALL IDT OOPT A/L No Oncall A/L Mat leave OOPT A/L No nights A/L A/L Ramadan
251 21/4/22 OFF ONCALL IDT OOPT A/L No Oncall A/L Mat leave OOPT A/L No nights A/L A/L Ramadan
252 22/4/22 OFF ONCALL IDT OOPT A/L No Oncall A/L Mat leave OOPT A/L No nights A/L A/L Ramadan
253 23/4/22 OFF ONCALL IDT OOPT A/L A/L No Oncall A/L Mat leave OOPT A/L No nights A/L IR ONCALL A/L Ramadan
254 24/4/22 OFF ONCALL IDT OOPT A/L A/L No Oncall A/L Mat leave OOPT A/L No nights A/L IR ONCALL A/L Ramadan
255 25/4/22 OFF ONCALL IDT OOPT No Oncall A/L Mat leave OOPT No nights A/L Ramadan
256 26/4/22 OFF ONCALL IDT OOPT No Oncall A/L Mat leave OOPT No nights A/L Ramadan
257 27/4/22 OFF ONCALL IDT OOPT No Oncall A/L Mat leave OOPT S/L No nights A/L Ramadan
258 28/4/22 OFF ONCALL IDT OOPT No Oncall Mat leave A/L OOPT A/L S/L No nights A/L A/L Ramadan
259 29/4/22 OFF ONCALL IDT OOPT No Oncall Mat leave A/L OOPT No nights A/L A/L Ramadan
260 30/4/22 OFF ONCALL IDT OOPT No Oncall A/L Mat leave OOPT A/L A/L A//L Ramadan
261 1/5/22 OFF ONCALL IDT OOPT No Oncall A/L Mat leave OOPT A/L A/L A//L Ramadan
262 2/5/22 OFF ONCALL IDT OOPT No Oncall A/L Mat leave OOPT A/L A/L A//L Ramadan
263 3/5/22 OFF ONCALL IDT OOPT A/L No Oncall A/L Mat leave OOPT A/L A/L Ramadan
264 4/5/22 OFF ONCALL IDT OOPT A/L No Oncall A/L Mat leave OOPT A/L A/L Ramadan
265 5/5/22 OFF ONCALL IDT OOPT A/L No Oncall A/L Mat leave OOPT A/L A/L Ramadan
266 6/5/22 OFF ONCALL IDT OOPT A/L No Oncall A/L Mat leave OOPT A/L IR ONCALL A/L Ramadan
267 7/5/22 OFF ONCALL IDT OOPT A/L No Oncall A/L Mat leave OOPT A/L IR ONCALL Ramadan
268 8/5/22 OFF ONCALL IDT OOPT A/L No Oncall A/L Mat leave OOPT A/L IR ONCALL Ramadan
269 9/5/22 OFF ONCALL IDT OOPT A/L No Oncall Mat leave A/L OOPT A/L Ramadan
270 10/5/22 OFF ONCALL IDT OOPT A/L No Oncall Mat leave A/L OOPT A/L Ramadan
271 11/5/22 OFF ONCALL IDT OOPT A/L No Oncall Mat leave A/L OOPT A/L Ramadan
272 12/5/22 OFF ONCALL IDT OOPT A/L No Oncall Mat leave A/L OOPT A/L Ramadan
273 13/5/22 OFF ONCALL IDT OOPT A/L No Oncall Mat leave A/L OOPT A/L Ramadan
274 14/5/22 OFF ONCALL IDT OOPT A/L A/L A/L No Oncall Mat leave OOPT A/L A/L A/L
275 15/5/22 OFF ONCALL IDT OOPT A/L A/L A/L No Oncall Mat leave OOPT A/L A/L A/L
276 16/5/22 OFF ONCALL IDT OOPT A/L No Oncall Mat leave OOPT A/L A/L
277 17/5/22 OFF ONCALL IDT OOPT A/L No Oncall Mat leave OOPT A/L A/L
278 18/5/22 OFF ONCALL IDT OOPT A/L No Oncall Mat leave OOPT A/L A/L
279 19/5/22 OFF ONCALL IDT OOPT A/L No Oncall Mat leave OOPT A/L A/L
280 20/5/22 OFF ONCALL IDT OOPT A/L A/L No Oncall Mat leave OOPT A/L A/L
281 21/5/22 OFF ONCALL IDT A/L OOPT A/L A/L No Oncall Mat leave A/L OOPT A/L A/L
282 22/5/22 OFF ONCALL IDT A/L OOPT A/L A/L No Oncall Mat leave A/L OOPT A/L A/L
283 23/5/22 OFF ONCALL IDT A/L OOPT A/L A/L A/L No Oncall Mat leave A/L OOPT IR ONCALL A/L
284 24/5/22 OFF ONCALL IDT A/L OOPT A/L A/L A/L No Oncall Mat leave A/L OOPT IR ONCALL A/L
285 25/5/22 OFF ONCALL IDT A/L OOPT A/L A/L A/L No Oncall Mat leave A/L OOPT IR ONCALL A/L
286 26/5/22 OFF ONCALL IDT A/L OOPT A/L A/L A/L No Oncall Mat leave A/L OOPT IR ONCALL A/L
287 27/5/22 OFF ONCALL IDT A/L OOPT A/L A/L A/L No Oncall A/L Mat leave A/L OOPT IR ONCALL A/L
288 28/5/22 OFF ONCALL IDT A/L OOPT A/L A/L A/L A/L No Oncall A/L Mat leave A/L OOPT A/L A/L A/L
289 29/5/22 OFF ONCALL IDT A/L OOPT A/L A/L A/L A/L No Oncall A/L Mat leave A/L OOPT A/L A/L A/L
290 30/5/22 Pre 2a OFF ONCALL IDT A/L OOPT A/L A/L Pre 2a Pre 2a No Oncall A/L Mat leave A/L OOPT A/L Pre 2a Pre 2a Pre 2a
291 31/5/22 Pre 2a OFF ONCALL IDT A/L OOPT A/L A/L Pre 2a Pre 2a No Oncall A/L Mat leave A/L OOPT A/L Pre 2a Pre 2a Pre 2a
292 1/6/22 Pre 2a OFF ONCALL IDT A/L OOPT A/L A/L Pre 2a Pre 2a No Oncall A/L Mat leave A/L OOPT A/L Pre 2a Pre 2a Pre 2a
293 2/6/22 Pre 2a OFF ONCALL IDT A/L OOPT A/L Pre 2a Pre 2a No Oncall Mat leave A/L OOPT Pre 2a Pre 2a Pre 2a
294 3/6/22 Pre 2a OFF ONCALL IDT A/L OOPT A/L Pre 2a Pre 2a No Oncall Mat leave A/L OOPT Pre 2a Pre 2a Pre 2a
295 4/6/22 Pre 2a OFF ONCALL IDT OOPT A/L Pre 2a Pre 2a No Oncall Mat leave A/L OOPT Pre 2a Pre 2a Pre 2a
296 5/6/22 Pre 2a OFF ONCALL IDT OOPT A/L Pre 2a Pre 2a No Oncall Mat leave A/L OOPT Pre 2a Pre 2a Pre 2a
297 6/6/22 Pre 2a OFF ONCALL IDT OOPT pre 2b A/L Pre 2a Pre 2a A/L No Oncall Mat leave A/L OOPT A/L IR ONCALL Pre 2a A/L Pre 2a Pre 2a
298 7/6/22 Pre 2a OFF ONCALL IDT OOPT pre 2b A/L Pre 2a Pre 2a A/L No Oncall Mat leave A/L OOPT IR ONCALL Pre 2a A/L Pre 2a Pre 2a
299 8/6/22 Pre 2a OFF ONCALL IDT OOPT pre 2b A/L Pre 2a Pre 2a A/L No Oncall Mat leave A/L OOPT IR ONCALL Pre 2a Pre 2a Pre 2a
300 9/6/22 Pre 2a OFF ONCALL IDT OOPT A/L pre 2b A/L Pre 2a Pre 2a A/L No Oncall Mat leave A/L OOPT IR ONCALL Pre 2a Pre 2a Pre 2a
301 10/6/22 Pre 2a OFF ONCALL IDT OOPT A/L pre 2b Pre 2a Pre 2a A/L No Oncall Mat leave A/L OOPT IR ONCALL Pre 2a Pre 2a Pre 2a
302 11/6/22 Pre 2a OFF ONCALL IDT OOPT A/L pre 2b Pre 2a Pre 2a A/L No Oncall Mat leave A/L OOPT IR ONCALL Pre 2a Pre 2a Pre 2a
303 12/6/22 Pre 2a OFF ONCALL IDT OOPT A/L pre 2b Pre 2a Pre 2a A/L No Oncall Mat leave A/L OOPT IR ONCALL Pre 2a Pre 2a Pre 2a
304 13/6/22 Pre 2a OFF ONCALL IDT OOPT pre 2b Pre 2a Pre 2a A/L No Oncall Mat leave OOPT Pre 2a Pre 2a Pre 2a
305 14/6/22 Pre 2a OFF ONCALL IDT OOPT pre 2b Pre 2a Pre 2a A/L No Oncall Mat leave OOPT Pre 2a Pre 2a Pre 2a
306 15/6/22 2a OFF ONCALL IDT OOPT pre 2b 2a 2a A/L No Oncall Mat leave OOPT 2a 2a 2a
307 16/6/22 OFF ONCALL IDT OOPT pre 2b A/L A/L A/L No Oncall Mat leave OOPT
308 17/6/22 OFF ONCALL IDT OOPT 2b A/L A/L A/L No Oncall Mat leave OOPT
309 18/6/22 OFF ONCALL IDT OOPT A/L 2b A/L A/L A/L A/L No Oncall Mat leave OOPT A/L
310 19/6/22 OFF ONCALL IDT OOPT A/L 2b A/L A/L A/L A/L No Oncall Mat leave OOPT A/L
311 20/6/22 OFF ONCALL IDT OOPT A/L 2b A/L A/L No Oncall Mat leave OOPT A/L A/L
312 21/6/22 OFF ONCALL IDT OOPT A/L 2b A/L A/L No Oncall Mat leave OOPT A/L A/L
313 22/6/22 OFF ONCALL IDT OOPT A/L 2b A/L A/L No Oncall Mat leave OOPT A/L
314 23/6/22 OFF ONCALL IDT OOPT A/L 2b A/L A/L No Oncall Mat leave OOPT A/L
315 24/6/22 OFF ONCALL IDT OOPT A/L 2b A/L A/L No Oncall Mat leave OOPT A/L
316 25/6/22 OFF ONCALL IDT OOPT A/L A/L A/L No Oncall Mat leave OOPT A/L
317 26/6/22 OFF ONCALL IDT OOPT A/L A/L A/L No Oncall Mat leave OOPT A/L
318 27/6/22 OFF ONCALL IDT OOPT A/L A/L No Oncall Mat leave OOPT
319 28/6/22 OFF ONCALL IDT OOPT A/L A/L No Oncall Mat leave OOPT A/L
320 29/6/22 OFF ONCALL IDT OOPT A/L No Oncall Mat leave OOPT A/L
321 30/6/22 OFF ONCALL IDT OOPT A/L No Oncall Mat leave OOPT
322 1/7/22 OFF ONCALL IDT OOPT A/L A/L No Oncall Mat leave OOPT A/L A/L
323 2/7/22 OFF ONCALL IDT OOPT A/L A/L No Oncall OOPT A/L A/L
324 3/7/22 OFF ONCALL IDT OOPT A/L A/L No Oncall OOPT A/L A/L
325 4/7/22 OFF ONCALL IDT OOPT A/L No Oncall OOPT A/L
326 5/7/22 OFF ONCALL IDT OOPT A/L No Oncall OOPT A/L
327 6/7/22 OFF ONCALL IDT OOPT A/L A/L No Oncall OOPT A/L
328 7/7/22 OFF ONCALL IDT OOPT A/L A/L No Oncall OOPT
329 8/7/22 OFF ONCALL IDT OOPT A/L A/L No Oncall OOPT
330 9/7/22 OFF ONCALL IDT OOPT A/L No Oncall OOPT
331 10/7/22 OFF ONCALL IDT OOPT A/L No Oncall OOPT
332 11/7/22 OFF ONCALL IDT OOPT A/L A/L No Oncall OOPT
333 12/7/22 OFF ONCALL IDT OOPT A/L A/L No Oncall OOPT
334 13/7/22 OFF ONCALL IDT OOPT A/L A/L No Oncall OOPT
335 14/7/22 OFF ONCALL IDT OOPT A/L A/L A/L No Oncall OOPT
336 15/7/22 OFF ONCALL IDT OOPT A/L A/L A/L No Oncall OOPT A/L A/L A/L
337 16/7/22 OFF ONCALL IDT OOPT A/L A/L A/L No Oncall OOPT A/L A/L A/L
338 17/7/22 OFF ONCALL IDT OOPT A/L A/L A/L No Oncall OOPT A/L A/L A/L
339 18/7/22 OFF ONCALL IDT OOPT A/L A/L No Oncall OOPT A/L
340 19/7/22 OFF ONCALL IDT OOPT A/L A/L No Oncall OOPT A/L
341 20/7/22 OFF ONCALL IDT OOPT A/L A/L No Oncall OOPT A/L
342 21/7/22 OFF ONCALL IDT OOPT A/L A/L No Oncall OOPT A/L
343 22/7/22 OFF ONCALL IDT OOPT A/L A/L No Oncall OOPT A/L
344 23/7/22 OFF ONCALL IDT A/L OOPT A/L A/L A/L No Oncall OOPT A/L A/L A/L A/L OOPT
345 24/7/22 OFF ONCALL IDT A/L OOPT A/L A/L A/L No Oncall OOPT A/L A/L A/L A/L OOPT
346 25/7/22 OFF ONCALL IDT A/L OOPT A/L A/L A/L No Oncall OOPT A/L A/L A/L A/L OOPT
347 26/7/22 OFF ONCALL IDT A/L OOPT A/L A/L A/L No Oncall OOPT A/L A/L A/L A/L OOPT
348 27/7/22 OFF ONCALL IDT A/L OOPT A/L A/L A/L No Oncall OOPT A/L A/L A/L A/L OOPT
349 28/7/22 OFF ONCALL IDT A/L OOPT A/L A/L A/L No Oncall OOPT A/L A/L A/L A/L OOPT
350 29/7/22 OFF ONCALL IDT A/L OOPT A/L A/L A/L No Oncall OOPT A/L A/L A/L A/L A/L OOPT
351 30/7/22 OFF ONCALL IDT A/L OOPT A/L A/L A/L No Oncall A/L OOPT A/L A/L A/L A/L A/L A/L OOPT
352 31/7/22 OFF ONCALL IDT A/L OOPT A/L A/L A/L No Oncall A/L OOPT A/L A/L A/L A/L A/L A/L OOPT
353 1/8/22 OFF ONCALL IDT OOPT A/L A/L No Oncall A/L OOPT A/L A/L A/L OOPE OOPT
354 2/8/22 OFF ONCALL IDT OOPT A/L A/L No Oncall A/L OOPT A/L A/L A/L OOPE OOPT
355 3/8/22 OFF ONCALL IDT OOPT A/L A/L No Oncall A/L OOPT A/L A/L A/L OOPE OOPT
356 4/8/22 OFF ONCALL IDT OOPT A/L A/L No Oncall A/L OOPT A/L A/L A/L OOPE OOPT
357 5/8/22 OFF ONCALL IDT OOPT A/L A/L No Oncall A/L OOPT A/L A/L A/L A/L A/L OOPE OOPT A/L
358 6/8/22 OFF ONCALL IDT OOPT A/L A/L No Oncall A/L OOPT A/L A/L A/L A/L A/L OOPE OOPT A/L
359 7/8/22 OFF ONCALL IDT OOPT A/L A/L No Oncall A/L OOPT A/L A/L A/L A/L A/L OOPE OOPT A/L
360 8/8/22 OFF ONCALL IDT OOPT A/L No Oncall A/L OOPT A/L OOPE OOPT A/L
361 9/8/22 OFF ONCALL IDT OOPT A/L No Oncall A/L OOPT A/L OOPE OOPT A/L
362 10/8/22 OFF ONCALL IDT OOPT A/L No Oncall A/L OOPT OOPE OOPT A/L
363 11/8/22 OFF ONCALL IDT OOPT A/L No Oncall A/L OOPT OOPE OOPT A/L
364 12/8/22 OFF ONCALL IDT OOPT A/L No Oncall A/L OOPT OOPE OOPT A/L
365 13/8/22 OFF ONCALL IDT A/L OOPT A/L A/L No Oncall A/L OOPT A/L OOPE OOPT A/L
366 14/8/22 OFF ONCALL IDT A/L OOPT A/L A/L No Oncall A/L OOPT A/L OOPE OOPT A/L
367 15/8/22 OFF ONCALL IDT A/L OOPT A/L A/L No Oncall A/L OOPT A/L OOPE OOPT
368 16/8/22 OFF ONCALL IDT A/L OOPT A/L A/L No Oncall A/L OOPT A/L OOPE OOPT
369 17/8/22 OFF ONCALL IDT A/L OOPT A/L A/L No Oncall A/L OOPT A/L OOPE OOPT
370 18/8/22 OFF ONCALL IDT A/L OOPT A/L A/L No Oncall A/L OOPT A/L OOPE OOPT
371 19/8/22 OFF ONCALL IDT A/L OOPT A/L A/L No Oncall A/L OOPT A/L OOPE OOPT
372 20/8/22 OFF ONCALL IDT A/L OOPT A/L A/L No Oncall A/L OOPT A/L OOPE OOPT
373 21/8/22 OFF ONCALL IDT A/L OOPT A/L A/L No Oncall A/L OOPT A/L OOPE OOPT
374 22/8/22 OFF ONCALL IDT A/L OOPT A/L No Oncall A/L OOPT A/L A/L OOPE OOPT
375 23/8/22 OFF ONCALL IDT A/L OOPT A/L No Oncall A/L OOPT A/L A/L A/L OOPE OOPT
376 24/8/22 OFF ONCALL IDT A/L OOPT A/L No Oncall A/L OOPT A/L A/L A/L OOPE OOPT
377 25/8/22 OFF ONCALL IDT A/L OOPT A/L No Oncall A/L OOPT A/L A/L OOPE OOPT
378 26/8/22 OFF ONCALL IDT A/L OOPT A/L A/L No Oncall A/L OOPT A/L A/L A/L OOPE OOPT
379 27/8/22 OFF ONCALL IDT A/L OOPT A/L A/L No Oncall A/L OOPT A/L OOPE OOPT
380 28/8/22 OFF ONCALL IDT A/L OOPT A/L A/L No Oncall A/L OOPT A/L OOPE OOPT
381 29/8/22 OFF ONCALL IDT A/L OOPT A/L A/L No Oncall A/L OOPT A/L OOPE OOPT
382 30/8/22 OFF ONCALL IDT A/L OOPT A/L No Oncall A/L OOPT A/L A/L A/L OOPE OOPT
383 31/8/22 OFF ONCALL IDT A/L OOPT A/L No Oncall A/L OOPT A/L A/L A/L OOPE OOPT
384 1/9/22 OFF ONCALL IDT A/L OOPT A/L No Oncall A/L OOPT A/L A/L A/L OOPE OOPT
385 2/9/22 OFF ONCALL IDT A/L OOPT A/L No Oncall A/L OOPT A/L A/L OOPE OOPT
386 3/9/22 OFF ONCALL IDT A/L OOPT A/L No Oncall A/L OOPT A/L OOPE OOPT
387 4/9/22 OFF ONCALL IDT A/L OOPT A/L OFF No Oncall A/L OFF OFF OOPT OFF A/L OFF OOPE OOPT OFF
+79 -40
View File
@@ -33,7 +33,7 @@ def load_leave(Rota):
if live_rota:
download = s.get(
"https://docs.google.com/spreadsheets/d/e/2PACX-1vQtNrp9F1fjtAh8vFX-W_R3RlD9H-2P_vCLMoHrVKR0e24yabbsdrD65VjwxoHlV1Qnatmw1NeghQHG/pub?gid=1199196967&single=true&output=csv",
"https://docs.google.com/spreadsheets/d/e/2PACX-1vSh64r9uUbPsCdhcV7zmZz10gVqIy6rhXFUvDgJxRD5W45IYEaZpKMxAqDsD1ph6dob4AiRzJXVVtOu/pub?gid=396859373&single=true&output=csv",
headers=headers,
)
# download = s.get("https://docs.google.com/spreadsheets/d/e/2PACX-1vSRx9VWXSlRubPyA0RhiI-Oqf5eHNYYEc6rFzlraDbR5_8qqr5g13-4uV-gn4u-TjZxiSMv1fBUaESq/pub?gid=814517272&single=true&output=csv", headers=headers)
@@ -55,10 +55,12 @@ def load_leave(Rota):
n = 1
for row in reader:
if n < 10:
print(row)
pass
#print(row)
row_title = row[0]
print(row_title)
#print(row_title)
row_date = row[0]
row_extra = row[1]
r = row[2:]
# header, extract names
if n == 1:
@@ -210,6 +212,29 @@ def load_leave(Rota):
if lower_item:
worker["bank_holiday_extra"] = int(lower_item)
elif row_title == "Weekends/Twilights":
if lower_item:
worker["site"] = lower_item
#elif row_title == "Academy":
# pass
#elif "night specific" in row_title:
# if lower_item:
# for shift in lower_item.split(","):
# shift = shift.strip()
# if shift == "nights":
# shift = "night_weekday"
# elif shift == "nights only":
# if date.weekday() < 5:
# shift = "night_weekday"
# else:
# shift = "night_weekend"
# worker["single_nights"].append(
# WorkRequests(date=date, shift=shift)
# )
elif re.match(date_re, row_date) is not None:
if lower_item != "":
try:
@@ -258,70 +283,79 @@ def load_leave(Rota):
return workers
# load_leave()
def load_academy(Rota):
"""Load academy month blocks from the main published CSV and register avoids.
Spreadsheet layout expected:
- Column A: row title (should be 'Academy' for relevant rows)
- Column B: start date for the month (e.g. '07/09/2026')
- Columns C...: worker columns matching the header row (first CSV row's columns C...)
Cells containing the word 'academy' (case-insensitive) mark that worker as being in academy
for that month.
"""
Some trainees spend month blocks in the academy. This function loads that information from a google sheet that published it as a csv file.
"""
url = "https://docs.google.com/spreadsheets/d/e/2PACX-1vQtNrp9F1fjtAh8vFX-W_R3RlD9H-2P_vCLMoHrVKR0e24yabbsdrD65VjwxoHlV1Qnatmw1NeghQHG/pub?gid=1113837782&single=true&output=csv"
# Use the same published CSV as load_leave (main sheet)
url = (
"https://docs.google.com/spreadsheets/d/e/2PACX-1vSh64r9uUbPsCdhcV7zmZz10gVqIy6rhXFUvDgJxRD5W45IYEaZpKMxAqDsD1ph6dob4AiRzJXVVtOu/pub?gid=396859373&single=true&output=csv"
)
with Session() as s:
headers = {"Cache-Control": "no-cache", "Pragma": "no-cache"}
download = s.get(url, headers=headers)
decoded = download.content.decode("utf-8")
reader = csv.reader(decoded.splitlines(), delimiter=',')
reader = csv.reader(decoded.splitlines(), delimiter=",")
names = []
month_starts: list[datetime.date] = []
month_cells: list[list[str]] = []
n = 0
names = []
# collect date rows (these are the month start boundaries) and their cells
date_rows: list[datetime.date] = []
date_row_cells: list[list[str]] = []
for row in reader:
n += 1
if n == 1:
# header row: first cell is column title, remaining are worker names
names = [c.strip() for c in row[1:]]
continue
if not row:
continue
row_title = row[0].strip()
cells = row[1:]
# header row: collect worker names from column C onwards
if n == 1:
# some files may have two leading columns (like 'Email Address' and blank/extra), so take from index 2
names = [c.strip() for c in (row[2:] if len(row) > 2 else row[1:])]
continue
# If this row is a date row like '02/03/2026' then treat it as a month-start boundary
if re.match(date_re, row_title):
row_title = row[0].strip() if len(row) > 0 else ""
start_cell = row[1].strip() if len(row) > 1 else ""
cells = row[2:] if len(row) > 2 else []
# Only treat rows where the first column mentions 'Academy' (some sheets may include other rows)
if row_title and "academy" in row_title.lower():
# parse start date
try:
try:
d = datetime.strptime(row_title, "%d/%m/%Y").date()
start_date = datetime.strptime(start_cell, "%d/%m/%Y").date()
except ValueError:
d = datetime.strptime(row_title, "%d/%m/%y").date()
start_date = datetime.strptime(start_cell, "%d/%m/%y").date()
except Exception:
# skip rows without a valid date
continue
date_rows.append(d)
# normalize cells length to match names
row_cells = [ (cells[i] if i < len(cells) else "").strip() for i in range(len(names)) ]
date_row_cells.append(row_cells)
month_starts.append(start_date)
# normalize cell list to names length
row_cells = [(cells[i] if i < len(cells) else "").strip() for i in range(len(names))]
month_cells.append(row_cells)
# If no date rows found, nothing to do
if not date_rows:
if not month_starts:
return
# Identify twilight shift names available in the rota
# determine twilight shifts available
twilight_shift_names = [s.name for s in Rota.get_shifts() if "twilight" in s.name]
# For each date boundary, compute its end (next boundary - 1 day) and register avoids where cell=='academy'
for idx, start_date in enumerate(date_rows):
if idx + 1 < len(date_rows):
end_date = date_rows[idx + 1] - timedelta(days=1)
# for each month start, compute end date and register avoids
for idx, start_date in enumerate(month_starts):
if idx + 1 < len(month_starts):
end_date = month_starts[idx + 1] - timedelta(days=1)
else:
end_date = Rota.rota_end_date
cells = date_row_cells[idx]
cells = month_cells[idx]
for i, cell in enumerate(cells):
if i >= len(names):
break
@@ -341,7 +375,7 @@ def load_academy(Rota):
sh = Rota.get_shift_by_name(sh_name)
except Exception:
continue
if hasattr(worker, "site") and worker.site in sh.sites:
if hasattr(worker, "site") and worker.site in getattr(sh, "sites", []):
candidate_shifts.append(sh_name)
elif sh_name.startswith(f"{worker.site}_"):
candidate_shifts.append(sh_name)
@@ -349,7 +383,12 @@ def load_academy(Rota):
if not candidate_shifts:
candidate_shifts = twilight_shift_names.copy()
# register as a date range with a reason 'academy'
#print(f"Academy: {worker.name} on academy from {start_date} to {end_date}, avoiding shifts: {candidate_shifts}")
Rota.add_avoid_shifts_for_workers_on_dates(
names=[worker.name], shifts=candidate_shifts, start_date=start_date, end_date=end_date, reason="academy"
names=[worker.name],
shifts=candidate_shifts,
start_date=start_date,
end_date=end_date,
reason="academy",
)
+10
View File
@@ -445,9 +445,19 @@ $("#rota-table tr td:first-child").each((n, td) => {
function viewWorker(worker) {
ds = worker.dataset;
let exactShiftsText = "(none)";
if (ds.exactShifts) {
try {
let parsed = JSON.parse(ds.exactShifts);
if (Object.keys(parsed).length > 0) {
exactShiftsText = Object.entries(parsed).map(([s, c]) => `${s}: ${c}`).join(", ");
}
} catch(e) {}
}
dlg = $(`<div class='dialog' title='${ds.worker}'>
Site: ${ds.site}</br>
FTE: ${ds.fte} (${ds.fte_adj})</br>
Exact shifts: ${exactShiftsText}</br>
Start date: ${ds.start_date}</br>
End date: ${ds.end_date}</br>
Non working days: ${ds.nwds}</br>
+471 -9
View File
@@ -410,17 +410,43 @@ function renderWorkerList() {
const filter = ($("#worker-filter").val() || '').toLowerCase();
const shiftFilter = ($("#worker-shift-select").val() || '');
// Parse the original pre block into per-worker text blocks so rich details are preserved.
const detailsByName = {};
const preText = (($wd.find("pre").first().text() || '') + '').trim();
const escapeHtml = (text) => String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
if (preText.length > 0) {
preText.split(/\n\s*\n(?=Name:\s*)/g).forEach(block => {
const trimmed = (block || '').trim();
if (!trimmed) return;
const nameMatch = trimmed.match(/^Name:\s*(.+)$/m);
if (!nameMatch) return;
detailsByName[nameMatch[1].trim()] = trimmed;
});
}
let $list = $("#worker-list");
if ($list.length === 0) {
$list = $("<div id='worker-list' style='margin-top:8px;'></div>");
$("#worker_details").append($list);
}
// Hide the static pre block since we render interactive cards
$("#worker_details pre").hide();
$list.show();
$list.empty();
if (!workers) return;
workers.forEach(w => {
const name = (w.name || '') + '';
const site = (w.site || '') + '';
if (filter && name.toLowerCase().indexOf(filter) === -1 && site.toLowerCase().indexOf(filter) === -1) return;
if (filter.length > 0) {
const matchesName = name.toLowerCase().indexOf(filter) !== -1;
const matchesSite = site.toLowerCase().indexOf(filter) !== -1;
if (!matchesName && !matchesSite) return;
}
// if shiftFilter, ensure worker has target or assigned for that shift
// attempt to read per-worker targets and counts from DOM if not present in JSON
let targets = w.shift_target_number || {};
@@ -443,21 +469,242 @@ function renderWorkerList() {
if (!hasTarget && !hasAssigned) return;
}
const fte = w.fte || '';
const card = $(`<div class='worker-card' style='border:1px solid #ccc;padding:8px;margin-bottom:6px;'><strong>${name}</strong> <span style='color:#666'>${site}</span><div>FTE: ${fte}</div></div>`);
const grade = w.grade || '';
const id = w.id ? String(w.id).substring(0, 8) : '';
const card = $(`<div class='worker-card'><div class='worker-header'><strong>${name}</strong> <span class='worker-id'>${id}</span></div><div class='worker-meta'><span class='worker-site'>${site}</span> • <span class='worker-grade'>Grade: ${grade}</span> • <span class='worker-fte'>FTE: ${fte}%</span></div></div>`);
// show shift targets summary
if (w.shift_target_number) {
const tbl = $("<table style='margin-top:6px; width:100%;'></table>");
Object.keys(w.shift_target_number).forEach(s => {
const t = w.shift_target_number[s];
const c = (w.shift_counts && w.shift_counts[s]) ? w.shift_counts[s] : 0;
tbl.append(`<tr><td style='width:60%'>${s}</td><td style='text-align:right'>${c} assigned</td><td style='text-align:right'>target ${parseFloat(t).toFixed(2)}</td></tr>`);
if (targets && Object.keys(targets).length > 0) {
const tbl = $("<table class='worker-shifts-table'></table>");
const header = $("<thead><tr><th>Shift</th><th class='text-right'>Assigned</th><th class='text-right'>Target</th></tr></thead>");
tbl.append(header);
const tbody = $("<tbody></tbody>");
Object.keys(targets).forEach(s => {
const t = targets[s];
const c = (counts && counts[s]) ? counts[s] : 0;
tbody.append(`<tr><td>${s}</td><td class='text-right'>${c}</td><td class='text-right'>${parseFloat(t).toFixed(2)}</td></tr>`);
});
tbl.append(tbody);
card.append(tbl);
}
// Include the original full worker detail text block so no information is lost.
const fullDetails = detailsByName[name] || '';
if (fullDetails) {
card.append(`<details class='worker-full-details'><summary>Full details</summary><pre>${escapeHtml(fullDetails)}</pre></details>`);
}
$list.append(card);
});
}
// Add CSS for worker details styling and comprehensive dark mode support
if ($('#worker-details-style').length === 0) {
$('head').append(`<style id='worker-details-style'>
/* ===== Light mode defaults ===== */
body {
background-color: #fff;
color: #000;
}
#worker_details {
background: #fff;
color: #000;
}
#worker-filter, #worker-shift-select {
background: #fff;
color: #000;
border: 1px solid #ccc;
}
#worker-filter:focus, #worker-shift-select:focus {
outline: none;
border-color: #0066cc;
box-shadow: 0 0 4px rgba(0,102,204,0.3);
}
.worker-card {
border: 1px solid #ccc;
border-radius: 4px;
padding: 8px;
margin-bottom: 8px;
background: #fff;
color: #000;
transition: background-color 0.2s, border-color 0.2s;
}
.worker-card:hover {
background: #f5f5f5;
border-color: #999;
}
.worker-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.worker-header strong {
flex: 1;
margin: 0;
color: #000;
}
.worker-id {
font-size: 0.8em;
color: #999;
font-family: monospace;
}
.worker-meta {
font-size: 0.9em;
color: #666;
margin-bottom: 6px;
}
.worker-site, .worker-grade, .worker-fte {
display: inline;
}
.worker-shifts-table {
margin-top: 6px;
width: 100%;
border-collapse: collapse;
font-size: 0.9em;
background: #fff;
color: #000;
}
.worker-shifts-table thead {
background: #f0f0f0;
font-weight: bold;
color: #000;
}
.worker-shifts-table th, .worker-shifts-table td {
padding: 4px 6px;
border: 1px solid #ddd;
color: #000;
}
.worker-shifts-table tr:nth-child(even) {
background: #fafafa;
}
.worker-full-details {
margin-top: 8px;
}
.worker-full-details summary {
cursor: pointer;
font-weight: 600;
}
.worker-full-details pre {
margin-top: 6px;
white-space: pre-wrap;
}
.text-right {
text-align: right;
}
pre {
background: #f5f5f5;
color: #000;
border: 1px solid #ddd;
}
input, select, textarea {
background: #fff;
color: #000;
border: 1px solid #ccc;
}
table {
background: #fff;
color: #000;
}
table th {
background: #f9f9f9;
color: #000;
}
table td {
border-color: #ddd;
}
/* ===== Dark mode overrides (toggled via html.dark-mode class) ===== */
html.dark-mode body {
background-color: #1e1e1e;
color: #e0e0e0;
}
html.dark-mode #worker_details {
background: #2d2d2d;
color: #e0e0e0;
}
html.dark-mode #worker-filter, html.dark-mode #worker-shift-select {
background: #3d3d3d;
color: #e0e0e0;
border: 1px solid #555;
}
html.dark-mode #worker-filter:focus, html.dark-mode #worker-shift-select:focus {
border-color: #0088ff;
box-shadow: 0 0 4px rgba(0,136,255,0.5);
}
html.dark-mode .worker-card {
background: #2d2d2d;
border-color: #444;
color: #e0e0e0;
}
html.dark-mode .worker-card:hover {
background: #333;
border-color: #666;
}
html.dark-mode .worker-header strong {
color: #e0e0e0;
}
html.dark-mode .worker-meta, html.dark-mode .worker-id {
color: #aaa;
}
html.dark-mode .worker-shifts-table {
background: #2d2d2d;
color: #e0e0e0;
}
html.dark-mode .worker-shifts-table thead {
background: #1e1e1e;
color: #e0e0e0;
}
html.dark-mode .worker-shifts-table th, html.dark-mode .worker-shifts-table td {
border-color: #444;
color: #e0e0e0;
}
html.dark-mode .worker-shifts-table tr:nth-child(even) {
background: #252525;
}
html.dark-mode .worker-full-details summary {
color: #cfd8e3;
}
html.dark-mode pre {
background: #1e1e1e;
color: #e0e0e0;
border: 1px solid #444;
}
html.dark-mode input, html.dark-mode select, html.dark-mode textarea {
background: #3d3d3d;
color: #e0e0e0;
border: 1px solid #555;
}
html.dark-mode table {
background: #2d2d2d;
color: #e0e0e0;
}
html.dark-mode table th {
background: #1e1e1e;
color: #e0e0e0;
}
html.dark-mode table td {
border-color: #444;
color: #e0e0e0;
}
html.dark-mode h1, html.dark-mode h2, html.dark-mode h3, html.dark-mode h4, html.dark-mode h5, html.dark-mode h6 {
color: #e0e0e0;
}
html.dark-mode a {
color: #4db8ff;
}
html.dark-mode a:visited {
color: #8866ff;
}
html.dark-mode button {
background: #3d3d3d;
color: #e0e0e0;
border: 1px solid #555;
}
html.dark-mode button:hover {
background: #4d4d4d;
}
</style>`);
}
// initialize panels
renderShiftSettings();
renderWorkerFilterOptions();
@@ -492,6 +739,15 @@ if ($('#tsummary-style').length === 0) {
/* Slightly thinner avoid highlight for compact layout */
td.rota-day.avoid-shift.avoid-highlight { border-width: 1.5px !important; box-shadow: 0 0 4px rgba(255,140,0,0.18); }
/* Higher-contrast dark mode for timetable readability */
html.dark-mode table.tsummary { background: #111922; color: #e8eef5; }
html.dark-mode table.tsummary th,
html.dark-mode table.tsummary td { border-color: #516274; }
html.dark-mode table.tsummary th { background: #253241; color: #f5f8fc; }
html.dark-mode table.tsummary td { background: #16212d; color: #e8eef5; }
html.dark-mode table.tsummary tr:nth-child(even) td { background: #1a2734; }
html.dark-mode table.tsummary td.target-assigned { color: #f2f6fb; }
</style>
`);
}
@@ -770,6 +1026,8 @@ $("table.tsummary").find("td.prior-adjust .prior-adjust-part").each((n, span) =>
let selectedShifts = new Set();
let shiftColors = {};
let rotaExportViewEnabled = false;
let shiftDisplayOrder = [...oshifts];
oshifts.forEach((shift, i) => {
selectedShifts.add(shift);
@@ -782,6 +1040,21 @@ $("#shift-timetable-options").append(
`<button id="toggle-grid-view" style="margin-left:10px;">Toggle Linear/Grid View</button><br/>`
);
// Add a button to toggle rota export view (spreadsheet-style day-by-day shifts)
$("#shift-timetable-options").append(
`<button id="toggle-rota-export-view" style="margin-left:10px;">Show Rota Export View</button>`
);
// Add optional control to show worker names in rota export view
$("#shift-timetable-options").append(`
<span id="rota-export-show-workers-wrap" style="margin-left:12px;">
<label>
<input type="checkbox" id="rota-export-show-workers" checked>
Show workers
</label>
</span><br/>
`);
let gridViewEnabled = false;
$("#toggle-grid-view").on("click", function () {
@@ -789,6 +1062,22 @@ $("#toggle-grid-view").on("click", function () {
renderShiftTimetable();
});
$("#toggle-rota-export-view").on("click", function () {
rotaExportViewEnabled = !rotaExportViewEnabled;
if (rotaExportViewEnabled) {
$(this).text("Show Linear/Grid View");
} else {
$(this).text("Show Rota Export View");
}
renderShiftTimetable();
});
$(document).on("change", "#rota-export-show-workers", function () {
if (rotaExportViewEnabled) {
renderShiftTimetable();
}
});
const shiftColourControls = $(`
<details><summary>Shift Timetable Colours</summary>
@@ -1016,6 +1305,12 @@ function renderShiftTimetable() {
console.log(workerFilterSet)
$("#shift-timetable-div").empty();
if (selectedShifts.size === 0) return;
if (rotaExportViewEnabled) {
renderRotaExportTimetable();
return;
}
if (workerFilterSet.size === 0) return;
// Helper: returns true if worker id matches filter set or set is empty (all)
@@ -1189,6 +1484,127 @@ function renderShiftTimetable() {
$("#shift-timetable-div").append(gridTable);
}
function formatDateForExport(dateStr) {
if (!dateStr) return "";
let d = new Date(dateStr);
if (Number.isNaN(d.getTime())) return dateStr;
let dd = String(d.getDate()).padStart(2, "0");
let mm = String(d.getMonth() + 1).padStart(2, "0");
let yyyy = d.getFullYear();
return `${dd}/${mm}/${yyyy}`;
}
function getShiftHeaderLabel(shift) {
let s = (shift || "").toLowerCase();
if (s.includes("twil")) return "TWIL";
if (s.includes("night") || s.includes("oncall")) return "NIGHT";
if (s.includes("weekend")) return "WEEKEND";
return (shift || "").toUpperCase();
}
function renderRotaExportTimetable() {
let weeks = [];
let daysOfWeek = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
let weekStartDates = {};
let showWorkers = $("#rota-export-show-workers").is(":checked");
// Build week index and week start dates from existing table data
$("table#main-table td[data-week][data-date]").each(function () {
let week = $(this).attr("data-week");
let date = $(this).attr("data-date");
if (!week || !date) return;
if (!weeks.includes(week)) weeks.push(week);
if (!weekStartDates[week] || new Date(date) < new Date(weekStartDates[week])) {
weekStartDates[week] = date;
}
});
weeks = weeks.sort((a, b) => parseInt(a) - parseInt(b));
const orderedSelectedShifts = shiftDisplayOrder.filter(s => selectedShifts.has(s));
const dayShiftOrder = {};
daysOfWeek.forEach(day => {
dayShiftOrder[day] = orderedSelectedShifts.filter(shift => {
let found = false;
$(`table#main-table td[data-day='${day}']`).each(function () {
let shifts = ($(this).attr("data-shift") || "").split(",").map(x => x.trim()).filter(Boolean);
if (shifts.includes(shift)) {
found = true;
return false;
}
});
return found;
});
});
let table = $(`<table id="rota-export-table" class="shift-grid-table" style="margin-bottom:20px; border-collapse:collapse; width:auto; table-layout:fixed;"><caption>Rota Export (Shifts by Day)</caption></table>`);
let headerTop = $("<tr></tr>");
headerTop.append("<th style='border:1px solid #888; min-width:130px;'>Week Start</th>");
headerTop.append("<th style='border:1px solid #888; min-width:70px;'>Week</th>");
daysOfWeek.forEach(day => {
let span = Math.max(1, dayShiftOrder[day].length);
headerTop.append(`<th style='border:1px solid #888; min-width:120px;' colspan='${span}'>${day.toUpperCase()}</th>`);
});
table.append(headerTop);
let headerBottom = $("<tr></tr>");
headerBottom.append("<th style='border:1px solid #888; min-width:130px;'></th>");
headerBottom.append("<th style='border:1px solid #888; min-width:70px;'>No</th>");
daysOfWeek.forEach(day => {
if (dayShiftOrder[day].length === 0) {
headerBottom.append("<th style='border:1px solid #888; min-width:120px;'></th>");
return;
}
dayShiftOrder[day].forEach(shift => {
headerBottom.append(`<th style='border:1px solid #888; min-width:120px;'>${getShiftHeaderLabel(shift)}</th>`);
});
});
table.append(headerBottom);
weeks.forEach(week => {
let row = $("<tr></tr>");
let startDateFormatted = formatDateForExport(weekStartDates[week]);
row.append(`<td style='border:1px solid #888; font-weight:bold; vertical-align:top;'>${startDateFormatted}</td>`);
row.append(`<td style='border:1px solid #888; text-align:center; font-weight:bold; vertical-align:top;'>${week}</td>`);
daysOfWeek.forEach(day => {
const dayShifts = dayShiftOrder[day];
if (dayShifts.length === 0) {
row.append(`<td style='border:1px solid #888; color:#999; text-align:center;'>-</td>`);
return;
}
dayShifts.forEach(shift => {
let workersForShift = new Set();
$(`table#main-table td[data-week='${week}'][data-day='${day}']`).each(function () {
let shifts = ($(this).attr("data-shift") || "").split(",").map(s => s.trim()).filter(Boolean);
if (!shifts.includes(shift)) return;
let workerName = $(this).closest("tr").find("td.worker span.name").text().trim();
if (workerName) workersForShift.add(workerName);
});
let workerList = Array.from(workersForShift).sort();
let text = "";
if (showWorkers) {
text = workerList.join("/");
} else {
text = workerList.length > 0 ? "X" : "";
}
row.append(`<td style='border:1px solid #888; vertical-align:top; text-align:center;'>${text}</td>`);
});
});
table.append(row);
});
if (weeks.length === 0) {
table.append(`<tr><td colspan="16" style="border:1px solid #888; text-align:center; color:#666;">No rota data found.</td></tr>`);
}
$("#shift-timetable-div").append(table);
}
// Re-render worker checkboxes after timetable is updated (in case workers change)
function rerenderWorkerCheckboxesIfNeeded() {
$("#shift-timetable-worker-filter").remove();
@@ -1206,7 +1622,7 @@ rerenderWorkerCheckboxesIfNeeded();
// Patch the timetable button logic to use renderShiftTimetable
oshifts.forEach((shift) => {
let button = $(`<button data-shift='${shift}'>${shift}</button>`);
let button = $(`<button data-shift='${shift}' draggable='true' title='Drag to reorder shifts'>${shift}</button>`);
button.css({
background: "",
border: "1px solid #888",
@@ -1231,6 +1647,42 @@ oshifts.forEach((shift) => {
$("#shift-timetable-options").append(button);
});
let draggedShiftButton = null;
$(document).on("dragstart", "#shift-timetable-options button[data-shift]", function (e) {
draggedShiftButton = this;
if (e.originalEvent && e.originalEvent.dataTransfer) {
e.originalEvent.dataTransfer.effectAllowed = "move";
}
});
$(document).on("dragover", "#shift-timetable-options button[data-shift]", function (e) {
e.preventDefault();
});
$(document).on("drop", "#shift-timetable-options button[data-shift]", function (e) {
e.preventDefault();
if (!draggedShiftButton || draggedShiftButton === this) return;
const $dragged = $(draggedShiftButton);
const $target = $(this);
const draggedIndex = $dragged.index();
const targetIndex = $target.index();
if (draggedIndex < targetIndex) {
$target.after($dragged);
} else {
$target.before($dragged);
}
shiftDisplayOrder = $("#shift-timetable-options button[data-shift]").map(function () {
return $(this).attr("data-shift");
}).get();
renderShiftTimetable();
draggedShiftButton = null;
});
// Add help text to the shift timetable section
$("#shift-timetable-options").prepend(`
@@ -1331,9 +1783,19 @@ $("#rota-table tr td:first-child").each((n, td) => {
function viewWorker(worker) {
ds = worker.dataset;
let exactShiftsText = "(none)";
if (ds.exactShifts) {
try {
let parsed = JSON.parse(ds.exactShifts);
if (Object.keys(parsed).length > 0) {
exactShiftsText = Object.entries(parsed).map(([s, c]) => `${s}: ${c}`).join(", ");
}
} catch(e) {}
}
dlg = $(`<div class='dialog' title='${ds.worker}'>
Site: ${ds.site}</br>
FTE: ${ds.fte} (${ds.fte_adj})</br>
Exact shifts: ${exactShiftsText}</br>
Start date: ${ds.start_date}</br>
End date: ${ds.end_date}</br>
Non working days: ${ds.nwds}</br>
+485 -194
View File
@@ -346,7 +346,7 @@ def _pydantic_to_dict(obj):
# Recursively convert pydantic models, lists and dicts to JSON-serializable structures
if obj is None:
return None
if isinstance(obj, list):
if isinstance(obj, (list, set, tuple)):
return [_pydantic_to_dict(x) for x in obj]
if isinstance(obj, dict):
return {k: _pydantic_to_dict(v) for k, v in obj.items()}
@@ -417,7 +417,7 @@ class SingleShift(BaseModel):
rota_on_nwds: bool = False
assign_as_block: bool = False
force_as_block: bool = False
force_as_block_unless_nwd: bool = False
force_as_block_unless_nwd: bool | List[List[str]] = False
hard_constrain_shift: bool = True
bank_holidays_only: bool = False
#constraint: list[ShiftConstraint] = []
@@ -433,6 +433,17 @@ class SingleShift(BaseModel):
pair_proxy: bool = False
display_char: str | None = None # Add this line
force_assign_with: list[str] = [] # List of shift names to force assign with this shift on the same day
include_groups: set[str] = set()
exclude_groups: set[str] = set()
@model_validator(mode="after")
def check_groups_non_overlapping(self) -> "SingleShift":
intersection = self.include_groups.intersection(self.exclude_groups)
if intersection:
raise ValueError(
f"include_groups and exclude_groups cannot overlap. Intersection: {intersection}"
)
return self
model_config = ConfigDict(
extra="allow",
@@ -855,6 +866,8 @@ class RotaBuilder(object):
options["time_limit"] = options.pop("seconds")
if "ratio" in options:
options["mip_rel_gap"] = options.pop("ratio")
if "threads" in options:
options.pop("threads")
else:
self.opt = SolverFactory(solver)
@@ -1037,24 +1050,61 @@ class RotaBuilder(object):
initialize=0,
)
blocks_worker_keys = []
for worker, week, shift_name in self.worker_week_shifts_to_assign_as_blocks():
shift = self.get_shift_by_name(shift_name)
sub_blocks = self.get_shift_sub_blocks(shift)
for sb_idx in range(len(sub_blocks)):
blocks_worker_keys.append((worker.id, week, shift_name, sb_idx))
self.model.blocks_worker_shift_assigned = Var(
(
(worker.id, week, shift)
for worker, week, shift in self.worker_week_shifts_to_assign_as_blocks()
),
blocks_worker_keys,
within=Binary,
initialize=0,
)
blocks_assigned_keys = []
for week, shift_name in self.week_shifts_to_assign_as_blocks():
shift = self.get_shift_by_name(shift_name)
sub_blocks = self.get_shift_sub_blocks(shift)
for sb_idx in range(len(sub_blocks)):
blocks_assigned_keys.append((week, shift_name, sb_idx))
self.model.blocks_assigned = Var(
(
(week, shift)
for week, shift in self.week_shifts_to_assign_as_blocks()
),
blocks_assigned_keys,
within=NonNegativeIntegers,
initialize=0,
)
# Helper binary variables for per-worker unique-shift block limits.
# Index is (worker_id, block_length, block_start_week, shift_name).
unique_shift_block_indices = []
for worker in self.workers:
for constraint in worker.max_unique_shifts_per_week_block:
for week_blocks in self.get_week_block_iterator(constraint.week_block):
if not week_blocks:
continue
block_start_week = week_blocks[0]
for shift_name in self.get_shift_names():
if any(
shift_name in self.get_shift_names_by_week_day(
week,
day,
return_empty_if_week_day_not_found=True,
)
for week in week_blocks
for day in days
):
unique_shift_block_indices.append(
(worker.id, constraint.week_block, block_start_week, shift_name)
)
self.model.worker_shift_used_in_week_block = Var(
unique_shift_block_indices,
within=Binary,
initialize=0,
)
if self.constraint_options_model.balance_bank_holidays:
# Bank holidays
self.model.bank_holiday_count = Var(
@@ -1581,13 +1631,16 @@ class RotaBuilder(object):
workers_required,
site_required,
) in self.get_required_workers_and_site_combinations():
# print(week, day, shift, workers_required, site_required)
shift_obj = self.get_shift_by_name(shift)
eligible_workers = [
worker for worker in self.workers
if self.is_worker_eligible_for_shift(worker, shift_obj)
]
self.model.constraints.add(
workers_required
== sum(
self.model.works[worker.id, week, day, shift]
for worker in self.workers
if worker.site in site_required
for worker in eligible_workers
)
)
@@ -1596,7 +1649,7 @@ class RotaBuilder(object):
self.model.locum_required[week, day, shift]
== sum(
self.model.works[worker.id, week, day, shift]
for worker in self.workers
for worker in eligible_workers
if worker.locum
)
)
@@ -1609,6 +1662,7 @@ class RotaBuilder(object):
# if not worker.locum
)
)
self.model.constraints.add(
0
== sum(
@@ -1618,14 +1672,17 @@ class RotaBuilder(object):
)
)
# And it is not assigned if the worker is from the wrong site
if [worker for worker in self.workers if worker.site not in site_required]:
# And it is not assigned if the worker is ineligible
ineligible_workers = [
worker for worker in self.workers
if not self.is_worker_eligible_for_shift(worker, shift_obj)
]
if ineligible_workers:
self.model.constraints.add(
0
== sum(
self.model.works[worker.id, week, day, shift]
for worker in self.workers
if worker.site not in site_required
for worker in ineligible_workers
)
)
# # Ensure shifts are only assigned by workers from the allowed sites
@@ -1851,99 +1908,103 @@ class RotaBuilder(object):
for week, shift_name in self.week_shifts_to_assign_as_blocks():
shift = self.get_shift_by_name(shift_name)
sub_blocks = self.get_shift_sub_blocks(shift)
for worker in self.get_workers_for_shift(shift):
if (worker.id, week, shift.name) in self.model.blocks_worker_shift_assigned:
try:
self.model.constraints.add(
8
* self.model.blocks_worker_shift_assigned[
worker.id, week, shift_name
]
>= sum(
self.model.works[worker.id, week, day, shift_name]
for day in shift.days
for sb_idx, sb in enumerate(sub_blocks):
for worker in self.get_workers_for_shift(shift):
if (worker.id, week, shift.name, sb_idx) in self.model.blocks_worker_shift_assigned:
try:
self.model.constraints.add(
8
* self.model.blocks_worker_shift_assigned[
worker.id, week, shift_name, sb_idx
]
>= sum(
self.model.works[worker.id, week, day, shift_name]
for day in sb
)
)
)
except KeyError:
pass
self.model.constraints.add(
self.model.blocks_assigned[week, shift_name]
== sum(
self.model.blocks_worker_shift_assigned[
worker.id, week, shift_name
]
for worker in self.workers
if (worker.id, week, shift.name) in self.model.blocks_worker_shift_assigned
except KeyError:
pass
self.model.constraints.add(
self.model.blocks_assigned[week, shift_name, sb_idx]
== sum(
self.model.blocks_worker_shift_assigned[
worker.id, week, shift_name, sb_idx
]
for worker in self.workers
if (worker.id, week, shift.name, sb_idx) in self.model.blocks_worker_shift_assigned
)
)
)
workers_required = shift.get_worker_requirement_by_date(
self.get_week_start_date(week)
)
workers_required = shift.get_worker_requirement_by_date(
self.get_week_start_date(week)
)
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 sub-block
nwd_workers = []
full_workers = []
for w in workers:
if w.non_working_day_list:
l = []
for (
nwd,
start_nwd_date,
end_nwd_date,
) in w.non_working_day_list:
if nwd in 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)
for w in workers:
if w.non_working_day_list:
l = []
for (
nwd,
start_nwd_date,
end_nwd_date,
) in w.non_working_day_list:
if nwd in sb:
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)
if l:
nwd_workers.append(w)
else:
full_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, sb_idx
]
for worker in self.workers
if (worker.id, week, shift.name, sb_idx) in self.model.blocks_worker_shift_assigned
)
<= workers_required
)
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
self.model.constraints.add(
sum(
self.model.blocks_worker_shift_assigned[
worker.id, week, shift.name, sb_idx
]
for worker in full_workers
if (worker.id, week, shift.name, sb_idx) in self.model.blocks_worker_shift_assigned
)
<= workers_required + 1
)
<= workers_required
)
else:
self.model.constraints.add(
sum(
self.model.blocks_worker_shift_assigned[
worker.id, week, shift.name
]
for worker in full_workers
elif shift.force_as_block:
if workers_required:
self.model.constraints.add(
sum(
self.model.blocks_worker_shift_assigned[
worker.id, week, shift.name, sb_idx
]
for worker in self.workers
if (worker.id, week, shift.name, sb_idx) in self.model.blocks_worker_shift_assigned
)
<= workers_required
)
<= workers_required + 1
)
elif shift.force_as_block:
if workers_required:
self.model.constraints.add(
sum(
self.model.blocks_worker_shift_assigned[
worker.id, week, shift.name
]
for worker in self.workers
)
<= workers_required
)
# Most of our constraints apply per worker
# Worker constraint loop (worker loop)
@@ -2374,6 +2435,81 @@ class RotaBuilder(object):
<= max_days
)
for constraint in worker.max_days_per_week_block:
for week_blocks in self.get_week_block_iterator(constraint.week_block):
# Determine which (week, day) combinations to ignore
ignored_week_days = set()
for ignore_date in (constraint.ignore_dates or []):
try:
w, d = self.date_week_day_map.get(ignore_date, (None, None))
if w is not None and d is not None:
ignored_week_days.add((w, d))
except Exception:
pass
included_days = [
(week, day)
for week, day in self.get_week_day_combinations()
if week in week_blocks and (week, day) not in ignored_week_days
]
if included_days:
self.model.constraints.add(
sum(
self.model.works_day[worker.id, week, day]
for week, day in included_days
)
<= constraint.max_days
)
for constraint in worker.max_unique_shifts_per_week_block:
for week_blocks in self.get_week_block_iterator(constraint.week_block):
if not week_blocks:
continue
# Determine which (week, day) combinations to ignore
ignored_week_days = set()
for ignore_date in (constraint.ignore_dates or []):
try:
w, d = self.date_week_day_map.get(ignore_date, (None, None))
if w is not None and d is not None:
ignored_week_days.add((w, d))
except Exception:
pass
block_start_week = week_blocks[0]
used_shift_vars = []
for shift_name in self.get_shift_names():
shift_assignments = []
for week, day in self.get_week_day_combinations():
if week in week_blocks and (week, day) not in ignored_week_days:
if shift_name in self.get_shift_names_by_week_day(
week,
day,
return_empty_if_week_day_not_found=True,
):
shift_assignments.append(
self.model.works[worker.id, week, day, shift_name]
)
if not shift_assignments:
continue
used_shift_var = self.model.worker_shift_used_in_week_block[
worker.id,
constraint.week_block,
block_start_week,
shift_name,
]
used_shift_vars.append(used_shift_var)
self.model.constraints.add(
sum(shift_assignments)
<= len(shift_assignments) * used_shift_var
)
if used_shift_vars:
self.model.constraints.add(
sum(used_shift_vars) <= constraint.max_unique_shifts
)
for week_blocks in self.get_week_block_iterator(4):
# Prevent more than n number shifts per 4 weeks
try:
@@ -2543,90 +2679,103 @@ class RotaBuilder(object):
self.get_shifts(), description="Generate shift balance constraints"
):
if (
worker.site in shift.sites
): # Each site specfies which sites self.workers can fullfill it
# calaculate the total number of shifts that need assigning over the rota
# total_shifts = (len(self.weeks) * len(shift.days) *
# TODO: check if shift_counts is still required?
# total_shifts = (
# self.shift_counts[shift.name] * shift.workers_required
# )
self.is_worker_eligible_for_shift(worker, shift)
): # Each site specfies which sites self.workers can fullfill
total_shifts = self.shift_worker_counts[shift.name]
full_time_equivalent_joined = sum(
self.full_time_equivalent_sites[i][shift.name]
for i in shift.sites
)
# Find workers who have exact shifts for this shift type
exact_workers = [
w for w in self.workers
if self.is_worker_eligible_for_shift(w, shift) and shift.name in getattr(w, "exact_shifts", {})
]
exact_shifts_sum = sum(w.exact_shifts[shift.name] for w in exact_workers)
# Guard against division by zero: if no full-time
# equivalent is available for the shift's sites the
# denominator may be zero (for example when all site
# FTEs are 0). In that case emit a warning and set the
# target to 0 rather than raising an exception.
if not full_time_equivalent_joined:
# avoid ZeroDivisionError
self.add_warning(
"Zero FTE for sites",
f"full_time_equivalent sum is zero for shift {shift.name}; setting target_shifts=0 for worker {worker.name}",
)
target_shifts = 0
if exact_shifts_sum > total_shifts:
warning_msg = f"For shift {shift.name}, total exact shifts ({exact_shifts_sum}) exceeds total required shifts ({total_shifts})."
if not any(warn[1] == warning_msg for warn in self.warnings):
self.add_warning("Exact shifts exceed total shifts", warning_msg)
if worker in exact_workers:
target_shifts = worker.exact_shifts[shift.name]
else:
target_shifts = (
total_shifts
/ full_time_equivalent_joined
* worker.get_fte(shift=shift.name)
remaining_shifts = total_shifts - exact_shifts_sum
# Sum FTE only for workers who do not have exact shifts for this shift type
full_time_equivalent_joined = sum(
w.get_fte(shift=shift.name)
for w in self.workers
if self.is_worker_eligible_for_shift(w, shift) and w not in exact_workers
)
if self.use_previous_shifts:
if shift.name in worker.previous_shifts:
entry = worker.previous_shifts[shift.name]
# entry may be a tuple (worked, allocated) or a dict of per-day tuples
if isinstance(entry, dict):
# prefer overall tuple if present
if "__all__" in entry:
try:
worked = float(entry["__all__"][0])
allocated = float(entry["__all__"][1])
target_shifts = target_shifts + allocated - worked
except Exception:
pass
if not full_time_equivalent_joined:
# avoid ZeroDivisionError
if remaining_shifts > 0:
self.add_warning(
"Zero FTE for sites with remaining shifts",
f"full_time_equivalent sum is zero for shift {shift.name} but remaining_shifts = {remaining_shifts}; setting target_shifts=0 for worker {worker.name}",
)
target_shifts = 0
else:
target_shifts = (
max(0.0, float(remaining_shifts))
/ full_time_equivalent_joined
* worker.get_fte(shift=shift.name)
)
if worker not in exact_workers:
if self.use_previous_shifts:
if shift.name in worker.previous_shifts:
entry = worker.previous_shifts[shift.name]
# entry may be a tuple (worked, allocated) or a dict of per-day tuples
if isinstance(entry, dict):
# prefer overall tuple if present
if "__all__" in entry:
try:
worked = float(entry["__all__"][0])
allocated = float(entry["__all__"][1])
target_shifts = target_shifts + allocated - worked
except Exception:
pass
else:
# sum any per-day entries
try:
total_worked = 0.0
total_alloc = 0.0
for v in entry.values():
total_worked += float(v[0])
total_alloc += float(v[1])
target_shifts = target_shifts + total_alloc - total_worked
except Exception:
pass
else:
# sum any per-day entries
try:
total_worked = 0.0
total_alloc = 0.0
for v in entry.values():
total_worked += float(v[0])
total_alloc += float(v[1])
target_shifts = target_shifts + total_alloc - total_worked
worked, allocated = entry
target_shifts = (
target_shifts + float(allocated) - float(worked)
)
except Exception:
pass
else:
try:
worked, allocated = entry
target_shifts = (
target_shifts + float(allocated) - float(worked)
)
except Exception:
pass
if self.use_shift_balance_extra:
if shift.name in worker.shift_balance_extra:
extra = worker.shift_balance_extra[shift.name]
if self.use_shift_balance_extra:
if shift.name in worker.shift_balance_extra:
extra = worker.shift_balance_extra[shift.name]
# TODO look at how this affects allocation (how does it affect fte)
match extra:
case "double":
target_shifts = target_shifts * 2
case "half":
target_shifts = target_shifts / 2
case _:
target_shifts = target_shifts + extra
# TODO look at how this affects allocation (how does it affect fte)
match extra:
case "double":
target_shifts = target_shifts * 2
case "half":
target_shifts = target_shifts / 2
case _:
target_shifts = target_shifts + extra
# print(worker.name, shift.name, target_shifts)
worker.shift_target_number[shift.name] = target_shifts
if shift.hard_constrain_shift:
if worker in exact_workers:
self.model.constraints.add(
self.model.shift_count[worker.id, shift.name] == target_shifts
)
elif shift.hard_constrain_shift:
adjusted_balance_offset = shift.balance_offset
if worker.get_fte(shift=shift.name) < 100:
adjusted_balance_offset = (
@@ -2651,18 +2800,19 @@ class RotaBuilder(object):
"target_shifts": target_shifts,
}
self.model.constraints.add(
inequality(
min_shifts,
sum(
self.model.works[worker.id, week, day, shift.name]
for week, day in self.get_week_day_combinations_for_shift(
shift
)
),
max_shifts,
if self.get_week_day_combinations_for_shift(shift):
self.model.constraints.add(
inequality(
min_shifts,
sum(
self.model.works[worker.id, week, day, shift.name]
for week, day in self.get_week_day_combinations_for_shift(
shift
)
),
max_shifts,
)
)
)
else:
# Make sure shifts aren't assigned to those from other sites
# Not needed if there are no shifts
@@ -3588,6 +3738,22 @@ class RotaBuilder(object):
)
)
# Shift-specific active dates constraint
for shift in self.get_shifts():
if (worker.id, week, day, shift.name) in self.model.works:
calc_start = getattr(worker, "calculated_shift_start_dates", {}).get(shift.name, worker.calculated_start_date)
calc_end = getattr(worker, "calculated_shift_end_dates", {}).get(shift.name, worker.calculated_end_date)
if calc_start is not None and calc_end is not None:
date = self.week_day_date_map[(week, day)]
if date < calc_start or date >= calc_end:
self.model.constraints.add(
self.model.works[worker.id, week, day, shift.name] == 0
)
if self.get_locum_workers() and (worker.id, week, day, shift.name) in self.model.locum_works:
self.model.constraints.add(
self.model.locum_works[worker.id, week, day, shift.name] == 0
)
# single shift per day (unless multi-shift allowed)
# This is signifantly slower so only enable if required
shifts_today = self.get_shift_names_by_week_day(week, day)
@@ -3708,7 +3874,7 @@ class RotaBuilder(object):
continue
if day in constraint_shift.days:
# Only apply if worker can work this shift
if worker.site not in constraint_shift.sites:
if not self.is_worker_eligible_for_shift(worker, constraint_shift):
continue
try:
works = self.model.works[
@@ -3723,7 +3889,7 @@ class RotaBuilder(object):
pre_date = None
if pre_date is not None and pre_date in getattr(constraint, "exclude_dates", []):
continue
self.model.constraints.add(
1
>= works
@@ -3737,7 +3903,7 @@ class RotaBuilder(object):
)
if shiftname not in ignore_shifts
for w in workers
if w.site in constraint_shift.sites # Only workers who can work this shift
if self.is_worker_eligible_for_shift(w, constraint_shift) # Only workers who can work this shift
)
)
@@ -3759,7 +3925,7 @@ class RotaBuilder(object):
continue
if day in constraint_shift.days:
# Only apply if worker can work this shift
if worker.site not in constraint_shift.sites:
if not self.is_worker_eligible_for_shift(worker, constraint_shift):
continue
try:
works = self.model.works[
@@ -3774,7 +3940,7 @@ class RotaBuilder(object):
post_date = None
if post_date is not None and post_date in getattr(constraint, "exclude_dates", []):
continue
self.model.constraints.add(
1
>= works
@@ -3788,7 +3954,7 @@ class RotaBuilder(object):
)
if shiftname not in ignore_shifts
for w in workers
if w.site in constraint_shift.sites # Only workers who can work this shift
if self.is_worker_eligible_for_shift(w, constraint_shift) # Only workers who can work this shift
)
)
@@ -4047,24 +4213,27 @@ class RotaBuilder(object):
if self.constraint_options_model.balance_blocks:
blocks_balancing = sum(
block_shift_balancing_constant
* self.model.blocks_assigned[week, shift]
* self.model.blocks_assigned[week, shift, sb_idx]
for week in self.weeks
for shift in self.shifts_to_assign_as_blocks()
for sb_idx in range(len(self.get_shift_sub_blocks(self.get_shift_by_name(shift))))
if (week, shift, sb_idx) in self.model.blocks_assigned
)
else:
blocks_balancing = 0
prefer_block_expr = sum(
worker.assign_as_block_preferences.get(shift.name, 0)
* self.model.blocks_worker_shift_assigned[worker.id, week, shift.name]
* self.model.blocks_worker_shift_assigned[worker.id, week, shift.name, sb_idx]
for worker in self.workers
for week in self.weeks
for shift in self.shifts
if hasattr(worker, "assign_as_block_preferences")
and shift.name in worker.assign_as_block_preferences
and (worker.id, week, shift.name)
and self.is_worker_eligible_for_shift(worker, shift)
for sb_idx in range(len(self.get_shift_sub_blocks(shift)))
if (worker.id, week, shift.name, sb_idx)
in self.model.blocks_worker_shift_assigned
and worker.site in shift.sites
)
# Quadratic :(
@@ -4140,6 +4309,9 @@ class RotaBuilder(object):
Must be called prior to attempting to solve
"""
if not hasattr(self, "shifts_by_name"):
self.build_shifts()
if not self.workers:
raise NoWorkers("Workers must be added prior to calling build_workers")
@@ -4149,6 +4321,56 @@ class RotaBuilder(object):
for worker in track(self.workers, description="Building workers"):
print(worker.name)
worker.load_rota(self)
for s_name in getattr(worker, "exact_shifts", {}):
try:
s = self.get_shift_by_name(s_name)
if worker.site not in s.sites:
self.add_warning(
"Invalid exact shift site",
f"Worker {worker.name} requested exact shifts for shift {s_name} but their site {worker.site} is not in the shift sites {s.sites}",
)
elif not self.is_worker_eligible_for_shift(worker, s):
self.add_warning(
"Invalid exact shift group",
f"Worker {worker.name} requested exact shifts for shift {s_name} but is not eligible for it",
)
except KeyError:
self.add_warning(
"Invalid exact shift",
f"Worker {worker.name} requested exact shifts for non-existent shift {s_name}",
)
# Validate shift-dependent start/end dates
start_dates = getattr(worker, "shift_start_dates", [])
end_dates = getattr(worker, "shift_end_dates", [])
start_dict = {}
if isinstance(start_dates, dict):
start_dict = start_dates
elif isinstance(start_dates, list):
for item in start_dates:
if hasattr(item, "shift"):
start_dict[item.shift] = item.start_date
end_dict = {}
if isinstance(end_dates, dict):
end_dict = end_dates
elif isinstance(end_dates, list):
for item in end_dates:
if hasattr(item, "shift"):
end_dict[item.shift] = item.end_date
for s_name in set(list(start_dict.keys()) + list(end_dict.keys())):
if s_name not in self.shifts_by_name:
raise InvalidShift(
f"Worker {worker.name} specified shift-dependent dates for non-existent shift {s_name}"
)
s = self.get_shift_by_name(s_name)
if not self.is_worker_eligible_for_shift(worker, s):
self.add_warning(
"Worker/ineligible shift date constraint",
f"Worker {worker.name} specified shift-dependent dates for shift {s_name} but is not eligible to work it"
)
wid = worker.id
if wid in self.workers_id_map:
message = f"Worker with id '{wid}' has been added twice"
@@ -4161,7 +4383,13 @@ class RotaBuilder(object):
self.add_warning("Worker/duplicate name", message)
self.workers_name_map[worker.name] = worker
if worker.site not in self.sites:
worker_has_valid_shift = False
for shift in self.shifts:
if self.is_worker_eligible_for_shift(worker, shift):
worker_has_valid_shift = True
break
if not worker_has_valid_shift:
message = f"Worker with name '{worker.name}' ({worker.id}) has no valid shifts (site: {worker.site})"
logger.warning(message)
self.add_warning("Worker/no valid shifts", message)
@@ -4740,8 +4968,7 @@ class RotaBuilder(object):
def get_shifts_for_worker(self, worker_id):
worker = self.get_worker_by_id(worker_id)
shifts = [shift for shift in self.shifts if worker.site in shift.sites]
return shifts
return [shift for shift in self.shifts if self.is_worker_eligible_for_shift(worker, shift)]
def get_shifts_with_constraint(self, constraint) -> List[SingleShift]:
return [shift for shift in self.shifts if shift.has_constraint(constraint)]
@@ -4882,6 +5109,27 @@ class RotaBuilder(object):
)
])
def get_shift_sub_blocks(self, shift: SingleShift) -> List[List[str]]:
if getattr(shift, "force_as_block_unless_nwd", None):
val = shift.force_as_block_unless_nwd
if isinstance(val, (list, tuple)) and all(isinstance(x, (list, tuple, set)) for x in val):
return [list(x) for x in val]
return [list(shift.days)]
# Check if it is a block shift globally or per-worker
is_block = (
shift.assign_as_block
or shift.force_as_block
or any(
hasattr(worker, "assign_as_block_preferences")
and getattr(worker, "assign_as_block_preferences", {}).get(shift.name, 0) != 0
for worker in self.workers
)
)
if is_block:
return [list(shift.days)]
return []
def get_all_locum_availability(self):
return self.locum_availability_map
@@ -4933,8 +5181,46 @@ class RotaBuilder(object):
return group_workers
def is_worker_eligible_for_shift(self, worker: Worker, shift: SingleShift) -> bool:
"""Returns True if the worker is eligible for the shift based on site and group options."""
# 1. Group checks
worker_groups = getattr(worker, "groups", set())
if not isinstance(worker_groups, (set, list, tuple)):
worker_groups = {worker_groups} if worker_groups else set()
worker_groups_set = set(worker_groups)
# exclude_groups check: worker MUST NOT belong to any group in exclude_groups
if getattr(shift, "exclude_groups", None):
exclude_set = set(shift.exclude_groups)
if worker_groups_set.intersection(exclude_set):
return False
# Additive Site or Include Group check
has_site = worker.site in shift.sites
has_include_group = False
if getattr(shift, "include_groups", None):
include_set = set(shift.include_groups)
if worker_groups_set.intersection(include_set):
has_include_group = True
if getattr(shift, "include_groups", None):
if not (has_site or has_include_group):
return False
else:
if not has_site:
return False
return True
def get_workers_in_group(self, group_name: str) -> List[Worker]:
"""Returns a list of all workers belonging to the specified group."""
return [
w for w in self.workers
if group_name in getattr(w, "groups", set())
]
def get_workers_for_shift(self, shift: SingleShift) -> List[Worker]:
return [worker for worker in self.workers if worker.site in shift.sites]
return [worker for worker in self.workers if self.is_worker_eligible_for_shift(worker, shift)]
def get_workers_total_fte(self) -> float:
"""Does not take into account shift adjusted ftes"""
@@ -5042,6 +5328,10 @@ class RotaBuilder(object):
l4.extend([worker.fte for worker in self.workers])
wr.writerow(l4)
l5 = ["Exact Shifts"]
l5.extend([json.dumps(getattr(worker, "exact_shifts", {})) for worker in self.workers])
wr.writerow(l5)
for week, day in self.get_week_day_combinations():
d = [f"Week {week} Day {day}"]
@@ -5371,9 +5661,7 @@ class RotaBuilder(object):
# --- Highlight force assigned shifts ---
if force_assigned:
css_class += " force-assigned"
title = f"{shift_name} ({d}) [FORCE ASSIGNED]"
else:
title = f"{shift_name} ({d})"
title = f"{title} [FORCE ASSIGNED]"
bank_holiday = ""
if d in self.bank_holidays:
@@ -5413,8 +5701,9 @@ class RotaBuilder(object):
if shift.has_constraint(NightConstraint):
css_class = " ".join((css_class, "night-shift"))
title_escaped = title.replace("'", "&apos;").replace('"', "&quot;")
shift_tds.append(
f"<td title='{','.join(shift_names)} ({d})' class='rota-day {css_class}'"
f"<td title='{title_escaped}' class='rota-day {css_class}'"
f" data-shift='{','.join(assigned_shift_names)}'"
f" data-shift-display='{','.join(shift_names)}'"
f" data-available='{available}'"
@@ -5625,6 +5914,7 @@ class RotaBuilder(object):
data-weekend-sat='{weekend_sat}'
data-weekend-sun='{weekend_sun}'
data-previous-shifts='{previous_shifts}'
data-exact-shifts='{exact_shifts}'
data-shift-diff='{shift_diff}'
data-shift-diff-summed='{shift_diff_summed}'
>
@@ -5648,6 +5938,7 @@ class RotaBuilder(object):
weekend_sun=sun_count,
worker_summary_html=worker_summary_html,
previous_shifts=json.dumps(prior_map),
exact_shifts=json.dumps(getattr(worker, "exact_shifts", {})),
pair=worker.pair,
shift_balance_extra=json.dumps(worker.shift_balance_extra),
shift_diff=json.dumps(shift_diff_dict),
+201 -3
View File
@@ -135,6 +135,76 @@ class AvoidShiftOnDates(BaseModel):
raise ValueError(f"Cannot parse date: {v}")
class MaxDaysPerWeekBlockConstraint(BaseModel):
max_days: int = Field(
...,
ge=1,
description="Maximum number of worked days allowed inside each sliding week block.",
)
week_block: int = Field(
...,
ge=1,
description="Length of the sliding week block (for example, 2 means every consecutive 2-week window).",
)
ignore_dates: list[datetime.date] = Field(
default_factory=list,
description="List of specific dates to exclude from this constraint.",
)
class MaxUniqueShiftsPerWeekBlockConstraint(BaseModel):
max_unique_shifts: int = Field(
...,
ge=1,
description="Maximum number of distinct shift names allowed inside each sliding week block.",
)
week_block: int = Field(
...,
ge=1,
description="Length of the sliding week block (for example, 2 means every consecutive 2-week window).",
)
ignore_dates: list[datetime.date] = Field(
default_factory=list,
description="List of specific dates to exclude from this constraint.",
)
class ShiftStartDate(BaseModel):
shift: str
start_date: datetime.date
@field_validator("start_date", mode="before")
@classmethod
def coerce_date(cls, v):
if isinstance(v, datetime.date):
return v
if isinstance(v, str):
for fmt in ("%d/%m/%Y", "%d/%m/%y", "%Y-%m-%d"):
try:
return datetime.datetime.strptime(v, fmt).date()
except Exception:
continue
raise ValueError(f"Cannot parse date: {v}")
class ShiftEndDate(BaseModel):
shift: str
end_date: datetime.date
@field_validator("end_date", mode="before")
@classmethod
def coerce_date(cls, v):
if isinstance(v, datetime.date):
return v
if isinstance(v, str):
for fmt in ("%d/%m/%Y", "%d/%m/%y", "%Y-%m-%d"):
try:
return datetime.datetime.strptime(v, fmt).date()
except Exception:
continue
raise ValueError(f"Cannot parse date: {v}")
class Worker(BaseModel):
name: str
site: str
@@ -171,16 +241,65 @@ class Worker(BaseModel):
forced_assignments: List[tuple[int, str, str]] = [] # (week, day, shift_name)
forced_assignments_by_date: List[tuple[datetime.date, str]] = [] # (date, shift_name)
max_days_worked_per_week: int | None= None # Default: no limit, can be set to a specific number
max_days_per_week_block: list[MaxDaysPerWeekBlockConstraint] = []
max_unique_shifts_per_week_block: list[MaxUniqueShiftsPerWeekBlockConstraint] = []
assign_as_block_preferences: dict[str, float] = {} # shift_name -> weight (positive = prefer block)
shift_fte_overrides: dict[str, int] = {} # Need checks to ensure shifts exist
exact_shifts: dict[str, int] = {} # Map shift_name to exact number of shifts
groups: set[str] = set() # Groups that the worker belongs to
shift_start_dates: list[ShiftStartDate] = [] # List of ShiftStartDate models
shift_end_dates: list[ShiftEndDate] = [] # List of ShiftEndDate models
weekend_shift_target_number: float = 0.0
avoid_shifts_on_dates: list[AvoidShiftOnDates] = []
@field_validator("shift_start_dates", mode="before")
@classmethod
def coerce_shift_start_dates(cls, v):
if v is None:
return []
if isinstance(v, dict):
res = []
for shift, d in v.items():
res.append(ShiftStartDate(shift=shift, start_date=d))
return res
if isinstance(v, list):
res = []
for item in v:
if isinstance(item, ShiftStartDate):
res.append(item)
elif isinstance(item, dict):
res.append(ShiftStartDate(**item))
else:
raise ValueError(f"Invalid ShiftStartDate item: {item}")
return res
raise ValueError("Must be a list or dictionary")
@field_validator("shift_end_dates", mode="before")
@classmethod
def coerce_shift_end_dates(cls, v):
if v is None:
return []
if isinstance(v, dict):
res = []
for shift, d in v.items():
res.append(ShiftEndDate(shift=shift, end_date=d))
return res
if isinstance(v, list):
res = []
for item in v:
if isinstance(item, ShiftEndDate):
res.append(item)
elif isinstance(item, dict):
res.append(ShiftEndDate(**item))
else:
raise ValueError(f"Invalid ShiftEndDate item: {item}")
return res
raise ValueError("Must be a list or dictionary")
model_config = ConfigDict(
extra="allow",
@@ -377,13 +496,88 @@ class Worker(BaseModel):
self.proportion_rota_to_work = days_to_work / Rota.rota_days_length
self.days_to_work = days_to_work
# Shift-specific start/end dates and adjusted FTEs
self.calculated_shift_start_dates = {}
self.calculated_shift_end_dates = {}
self.proportion_rota_to_work_shifts = {}
# Convert list of ShiftStartDate/ShiftEndDate models/dicts to dictionary mapping for quick lookup
start_dates_dict = {item.shift: item.start_date for item in getattr(self, "shift_start_dates", []) if hasattr(item, "shift")}
end_dates_dict = {item.shift: item.end_date for item in getattr(self, "shift_end_dates", []) if hasattr(item, "shift")}
for shift in Rota.get_shifts():
s_date = start_dates_dict.get(shift.name, self.start_date)
if s_date is None:
calc_s = self.calculated_start_date
else:
if isinstance(s_date, str):
for fmt in ("%d/%m/%Y", "%d/%m/%y", "%Y-%m-%d"):
try:
s_date = datetime.datetime.strptime(s_date, fmt).date()
break
except Exception:
continue
calc_s = s_date
if calc_s < Rota.start_date:
calc_s = Rota.start_date
elif calc_s > Rota.rota_end_date:
calc_s = Rota.rota_end_date
e_date = end_dates_dict.get(shift.name, self.end_date)
if e_date is None:
calc_e = self.calculated_end_date
else:
if isinstance(e_date, str):
for fmt in ("%d/%m/%Y", "%d/%m/%y", "%Y-%m-%d"):
try:
e_date = datetime.datetime.strptime(e_date, fmt).date()
break
except Exception:
continue
calc_e = e_date
if calc_e > Rota.rota_end_date:
calc_e = Rota.rota_end_date
elif calc_e < Rota.start_date:
calc_e = Rota.start_date
if calc_s >= calc_e:
calc_s = Rota.rota_end_date
calc_e = Rota.rota_end_date
self.calculated_shift_start_dates[shift.name] = calc_s
self.calculated_shift_end_dates[shift.name] = calc_e
days_active = (calc_e - calc_s).days
# Subtract overlapping OOP days from the active period of this shift
for item in self.oop:
start_oop = item.start_date
end_oop = item.end_date
if isinstance(start_oop, datetime.date):
start_oop_date = start_oop
else:
start_oop_date = datetime.datetime.strptime(start_oop, "%d/%m/%y").date()
if isinstance(end_oop, datetime.date):
end_oop_date = end_oop
else:
end_oop_date = datetime.datetime.strptime(end_oop, "%d/%m/%y").date()
overlap_start = max(calc_s, start_oop_date)
overlap_end = min(calc_e, end_oop_date)
if overlap_start < overlap_end:
days_active -= (overlap_end - overlap_start).days
self.proportion_rota_to_work_shifts[shift.name] = max(0.0, days_active / Rota.rota_days_length)
# We have to adjust the full time equivalent for people who CCT / leave the rota early
self.fte_adj = self.fte * self.proportion_rota_to_work
self.fte_adj_shifts = {}
if self.shift_fte_overrides:
for shift, fte in self.shift_fte_overrides.items():
self.fte_adj_shifts[shift] = fte * self.proportion_rota_to_work
for shift in Rota.get_shifts():
prop = self.proportion_rota_to_work_shifts.get(shift.name, self.proportion_rota_to_work)
base_fte = self.shift_fte_overrides.get(shift.name, self.fte)
self.fte_adj_shifts[shift.name] = base_fte * prop
if self.fte_adj > 100:
@@ -516,3 +710,7 @@ class Worker(BaseModel):
def force_assign_shift_by_date(self, date: datetime.date, shift_name: str):
"""Force this worker to be assigned to a shift on a specific date."""
self.forced_assignments_by_date.append((date, shift_name))
def add_max_days_per_week_block_constraint(self, max_days: int, week_block: int, ignore_dates: Optional[List[datetime.date]] = None):
"""Add a constraint to limit the number of days worked in any sliding block of weeks."""
self.max_days_per_week_block.append(MaxDaysPerWeekBlockConstraint(max_days=max_days, week_block=week_block, ignore_dates=ignore_dates or []))
+59 -2
View File
@@ -163,7 +163,7 @@ def test_nwd_force_as_block_force_split():
for worker in Rota.workers:
shifts = Rota.get_worker_shift_list(worker)
shifts_string = "".join([i if i != "" else "-" for i in shifts])
for week in weeks_from_list(shifts_string[7 * 5 :]):
for week in weeks_from_list(shifts_string[7 * 6 :]):
assert week in ("-----ww", "dddddww")
if worker.name == "worker1":
assert shifts_string[: 7 * 5].count("ww-") == 4
@@ -213,4 +213,61 @@ def test_nwd_testing():
if worker.name == "worker1":
assert week == "dddddww"
else:
assert week == "-----ww"
assert week == "-----ww"
def test_force_as_block_unless_nwd_with_subblocks():
# Test that a Fri/Sat/Sun shift can be split into Fri and Sat/Sun blocks
# if a worker does not work on Fridays.
Rota = setup_rota(weeks_to_rota=2)
start_date = Rota.start_date
# worker1 does not work on Fridays (Fri is NWD)
worker1 = Worker(
name="worker1", site="group1", grade=1,
nwds=[{"day": "Fri", "start_date": start_date, "end_date": start_date + datetime.timedelta(weeks=2)}],
)
# worker2 works normally
worker2 = Worker(name="worker2", site="group1", grade=1)
Rota.add_workers((worker1, worker2))
# Fri/Sat/Sun shift, requiring 1 worker, forced as block unless NWD
# We specify sub-blocks: [["Fri"], ["Sat", "Sun"]]
Rota.add_shifts(
SingleShift(
sites=("group1",), name="weekend", length=12.5,
days=["Fri", "Sat", "Sun"],
workers_required=1,
force_as_block_unless_nwd=[["Fri"], ["Sat", "Sun"]],
),
)
# We remove no valid shifts warning
Rota.terminate_on_warning.remove("Worker/no valid shifts")
Rota.build_and_solve()
assert Rota.results.solver.status == "ok"
# Check assignments:
# Since worker1 has NWD on Friday:
# - worker1 cannot work Friday.
# - But worker1 can work Sat and Sun as a block!
# So on Saturday and Sunday, worker1 should be assigned.
# On Friday, worker2 should be assigned.
for week in Rota.weeks:
w1_fri = Rota.model.works[worker1.id, week, "Fri", "weekend"].value
w1_sat = Rota.model.works[worker1.id, week, "Sat", "weekend"].value
w1_sun = Rota.model.works[worker1.id, week, "Sun", "weekend"].value
w2_fri = Rota.model.works[worker2.id, week, "Fri", "weekend"].value
w2_sat = Rota.model.works[worker2.id, week, "Sat", "weekend"].value
w2_sun = Rota.model.works[worker2.id, week, "Sun", "weekend"].value
assert w1_fri == 0
# Either worker1 works Sat/Sun and worker2 works Fri (split block)
# OR worker2 works Fri/Sat/Sun (full block) and worker1 works nothing
is_split = (w1_sat == 1 and w1_sun == 1 and w2_fri == 1 and w2_sat == 0 and w2_sun == 0)
is_full_w2 = (w1_sat == 0 and w1_sun == 0 and w2_fri == 1 and w2_sat == 1 and w2_sun == 1)
assert is_split or is_full_w2
+168 -1
View File
@@ -1,11 +1,19 @@
import datetime
import pytest
from pydantic import ValidationError
from rota_generator.shifts import (
InvalidShift, MaxShiftsPerWeekBlockConstraint, MaxShiftsPerWeekConstraint,
NoWorkers, PostShiftConstraint, PreShiftConstraint, RotaBuilder, SingleShift,
WarningTermination, days, WorkerRequirement
)
from rota_generator.workers import NotAvailableToWork, WorkRequests, Worker, generate_not_available_to_works
from rota_generator.workers import (
MaxDaysPerWeekBlockConstraint,
MaxUniqueShiftsPerWeekBlockConstraint,
NotAvailableToWork,
WorkRequests,
Worker,
generate_not_available_to_works,
)
def generate_basic_rota(weeks_to_rota=10, workers=2, start_date=datetime.date(2022, 3, 7)):
Rota = RotaBuilder(
@@ -89,6 +97,165 @@ def test_max_shifts_per_week_block_shift_number_fail():
Rota.export_rota_to_html("max_shifts_per_week_block")
assert Rota.results.solver.status in ("warning", "error")
def test_worker_max_days_per_week_block_fail():
Rota = RotaBuilder(datetime.date(2022, 3, 7), weeks_to_rota=2)
Rota.add_worker(
Worker(
name="worker1",
site="group1",
grade=1,
max_days_per_week_block=[
MaxDaysPerWeekBlockConstraint(max_days=1, week_block=1)
],
)
)
Rota.add_shifts(
SingleShift(
sites=("group1",),
name="a",
length=12.5,
days=days[:3],
workers_required=1,
)
)
Rota.build_and_solve(options={"ratio": 0.000})
assert Rota.results.solver.termination_condition == "infeasible"
def test_worker_max_unique_shifts_per_week_block_fail():
Rota = RotaBuilder(datetime.date(2022, 3, 7), weeks_to_rota=2)
Rota.add_worker(
Worker(
name="worker1",
site="group1",
grade=1,
max_unique_shifts_per_week_block=[
MaxUniqueShiftsPerWeekBlockConstraint(max_unique_shifts=1, week_block=1)
],
)
)
Rota.add_shifts(
SingleShift(
sites=("group1",),
name="a",
length=12.5,
days=["Mon"],
workers_required=1,
),
SingleShift(
sites=("group1",),
name="b",
length=12.5,
days=["Tue"],
workers_required=1,
),
)
Rota.build_and_solve(options={"ratio": 0.000})
assert Rota.results.solver.termination_condition == "infeasible"
def test_worker_block_constraint_validation():
with pytest.raises(ValidationError):
MaxDaysPerWeekBlockConstraint(max_days=0, week_block=1)
with pytest.raises(ValidationError):
MaxUniqueShiftsPerWeekBlockConstraint(max_unique_shifts=0, week_block=1)
def test_worker_max_days_per_week_block_pass():
"""Positive test: constraint allowing reasonable days."""
Rota = generate_basic_rota()
# Add constraint allowing up to 4 days per week
Rota.workers[0].max_days_per_week_block = [
MaxDaysPerWeekBlockConstraint(max_days=4, week_block=1)
]
# Add shifts to the rota
Rota.add_shifts(
SingleShift(
sites=("group1", "group2"),
name="a",
length=12.5,
days=days,
)
)
Rota.build_and_solve(options={"ratio": 0.000})
assert Rota.results.solver.status == "ok"
assert Rota.results.solver.termination_condition == "optimal"
def test_worker_max_unique_shifts_per_week_block_pass():
"""Positive test: allowing multiple shifts within week block."""
Rota = generate_basic_rota()
# Add constraint allowing up to 2 unique shifts per week
Rota.workers[0].max_unique_shifts_per_week_block = [
MaxUniqueShiftsPerWeekBlockConstraint(max_unique_shifts=2, week_block=1)
]
# Add shifts to the rota
Rota.add_shifts(
SingleShift(
sites=("group1", "group2"),
name="a",
length=12.5,
days=days,
)
)
Rota.build_and_solve(options={"ratio": 0.000})
assert Rota.results.solver.status == "ok"
assert Rota.results.solver.termination_condition == "optimal"
def test_worker_max_days_per_week_block_with_ignore_dates():
"""Test max_days_per_week_block with ignored dates works without error."""
Rota = generate_basic_rota()
rota_start = Rota.start_date
ignored_date = rota_start # Ignore first Monday
# Add constraint with ignore_dates
Rota.workers[0].max_days_per_week_block = [
MaxDaysPerWeekBlockConstraint(
max_days=3,
week_block=1,
ignore_dates=[ignored_date],
)
]
# Add shifts to the rota
Rota.add_shifts(
SingleShift(
sites=("group1", "group2"),
name="a",
length=12.5,
days=days,
)
)
Rota.build_and_solve(options={"ratio": 0.000})
assert Rota.results.solver.status == "ok"
def test_worker_max_unique_shifts_per_week_block_with_ignore_dates():
"""Test max_unique_shifts_per_week_block with ignored dates works without error."""
Rota = generate_basic_rota()
rota_start = Rota.start_date
ignored_date = rota_start + datetime.timedelta(days=1) # Ignore Tuesday
# Add constraint with ignore_dates
Rota.workers[0].max_unique_shifts_per_week_block = [
MaxUniqueShiftsPerWeekBlockConstraint(
max_unique_shifts=1,
week_block=1,
ignore_dates=[ignored_date],
)
]
# Add shifts to the rota
Rota.add_shifts(
SingleShift(
sites=("group1", "group2"),
name="a",
length=12.5,
days=days,
)
)
Rota.build_and_solve(options={"ratio": 0.000})
# This should still be feasible with only one shift type in main rota
assert Rota.results.solver.status == "ok"
def test_pre_shift_constraint():
Rota = generate_basic_rota()
for i, d in enumerate(days[:6]):
+508 -2
View File
@@ -1,8 +1,9 @@
import pytest
from rota_generator.shifts import InvalidShift, MaxShiftsPerWeekConstraint, NoWorkers, PostShiftConstraint, RotaBuilder, SingleShift, WarningTermination, WorkerRequirement, days
from pydantic import ValidationError
import datetime
from rota_generator.workers import NonWorkingDays, NotAvailableToWork, Worker, generate_not_available_to_works
from rota_generator.workers import NonWorkingDays, NotAvailableToWork, Worker, generate_not_available_to_works, ShiftStartDate, ShiftEndDate
from rota_generator.workers import WorkRequests
from rota_generator.shifts import PreShiftConstraint
@@ -1624,4 +1625,509 @@ def test_worker_hard_day_exclusion2():
for week in range(16):
sat_idx = week * 7 + days.index("Sat")
sun_idx = week * 7 + days.index("Sun")
assert not (shift_list[sat_idx] == "a" and shift_list[sun_idx] == "a"), f"Worker {worker.name} has 'a' on both Mon and Tue in week {week}, which should not happen due to hard day exclusion"
assert not (shift_list[sat_idx] == "a" and shift_list[sun_idx] == "a"), f"Worker {worker.name} has 'a' on both Mon and Tue in week {week}, which should not happen due to hard day exclusion"
def test_exact_shifts_distribution():
weeks_to_rota = 10
start_date = datetime.date(2022, 3, 7)
Rota = RotaBuilder(
start_date,
weeks_to_rota=weeks_to_rota,
)
workers = [
Worker(name="worker1", site="group1", grade=1, fte=100, exact_shifts={"a": 4}),
Worker(name="worker2", site="group1", grade=1, fte=100),
Worker(name="worker3", site="group1", grade=1, fte=50),
]
Rota.add_workers(workers)
Rota.add_shifts(
SingleShift(
sites=("group1",),
name="a",
length=12.5,
days=("Mon",),
workers_required=1,
hard_constrain_shift=True,
balance_offset=0,
),
)
Rota.build_and_solve()
assert Rota.workers_name_map["worker1"].shift_target_number["a"] == 4
assert Rota.workers_name_map["worker2"].shift_target_number["a"] == 4
assert Rota.workers_name_map["worker3"].shift_target_number["a"] == 2
# Verify actual assigned counts are exactly correct
worker_shift_counts = {w.name: 0 for w in Rota.get_workers()}
for week, day, shiftname in Rota.get_all_shiftname_combinations():
for worker in Rota.get_workers():
val = Rota.model.works[worker.id, week, day, shiftname].value
if val is not None and val > 0.5:
if shiftname == "a":
worker_shift_counts[worker.name] += 1
assert worker_shift_counts["worker1"] == 4
assert worker_shift_counts["worker2"] == 4
assert worker_shift_counts["worker3"] == 2
def test_exact_shifts_distribution_unbalanced():
weeks_to_rota = 16
start_date = datetime.date(2022, 3, 7)
Rota = RotaBuilder(
start_date,
weeks_to_rota=weeks_to_rota,
)
workers = [
Worker(name="worker1", site="group1", grade=1, fte=100, exact_shifts={"a": 4}),
Worker(name="worker2", site="group1", grade=1, fte=100),
Worker(name="worker3", site="group1", grade=1, fte=50),
]
Rota.add_workers(workers)
Rota.add_shifts(
SingleShift(
sites=("group1",),
name="a",
length=12.5,
days=("Mon",),
workers_required=1,
hard_constrain_shift=True,
balance_offset=0,
),
SingleShift(
sites=("group1",),
name="b",
length=12.5,
days=("Tue",),
workers_required=1,
hard_constrain_shift=True,
balance_offset=0.8,
),
)
Rota.build_and_solve()
Rota.export_rota_to_html("test_exact_shifts_distribution_unbalanced", folder="tests")
assert Rota.results.solver.status == "ok"
assert Rota.workers_name_map["worker1"].shift_target_number["a"] == 4
assert Rota.workers_name_map["worker2"].shift_target_number["a"] == 8
assert Rota.workers_name_map["worker3"].shift_target_number["a"] == 4
assert Rota.workers_name_map["worker1"].shift_target_number["b"] == 6.4
assert Rota.workers_name_map["worker2"].shift_target_number["b"] == 6.4
assert Rota.workers_name_map["worker3"].shift_target_number["b"] == 3.2
# Verify actual assigned counts are exactly correct
worker_shift_counts_a = {w.name: 0 for w in Rota.get_workers()}
worker_shift_counts_b = {w.name: 0 for w in Rota.get_workers()}
for week, day, shiftname in Rota.get_all_shiftname_combinations():
for worker in Rota.get_workers():
val = Rota.model.works[worker.id, week, day, shiftname].value
if val is not None and val > 0.5:
if shiftname == "a":
worker_shift_counts_a[worker.name] += 1
elif shiftname == "b":
worker_shift_counts_b[worker.name] += 1
assert worker_shift_counts_a["worker1"] == 4
assert worker_shift_counts_a["worker2"] == 8
assert worker_shift_counts_a["worker3"] == 4
assert worker_shift_counts_b["worker1"] in (6, 7) # Allowing for rounding differences
assert worker_shift_counts_b["worker2"] in (6, 7) # Allowing for rounding differences
assert worker_shift_counts_b["worker3"] in (3, 4) # Allowing for rounding differences
def test_exact_shifts_invalid_site_warning():
weeks_to_rota = 2
start_date = datetime.date(2022, 3, 7)
Rota = RotaBuilder(
start_date,
weeks_to_rota=weeks_to_rota,
)
Rota.add_workers([
Worker(name="worker1", site="group2", grade=1, fte=100, exact_shifts={"a": 4}),
])
Rota.add_shifts(
SingleShift(
sites=("group1",),
name="a",
length=12.5,
days=("Mon",),
workers_required=1,
),
SingleShift(
sites=("group2",),
name="b",
length=12.5,
days=("Mon",),
workers_required=1,
),
)
Rota.build_workers()
warnings = Rota.get_warnings("Invalid exact shift site")
assert len(warnings) > 0
assert "group2 is not in the shift sites" in warnings[0][1]
def test_exact_shifts_invalid_shift_warning():
weeks_to_rota = 2
start_date = datetime.date(2022, 3, 7)
Rota = RotaBuilder(
start_date,
weeks_to_rota=weeks_to_rota,
)
Rota.add_workers([
Worker(name="worker1", site="group1", grade=1, fte=100, exact_shifts={"nonexistent": 4}),
])
Rota.add_shifts(
SingleShift(
sites=("group1",),
name="a",
length=12.5,
days=("Mon",),
workers_required=1,
),
)
Rota.build_workers()
warnings = Rota.get_warnings("Invalid exact shift")
assert len(warnings) > 0
assert "non-existent shift nonexistent" in warnings[0][1]
def test_exact_shifts_exceed_total_warning():
weeks_to_rota = 2
start_date = datetime.date(2022, 3, 7)
Rota = RotaBuilder(
start_date,
weeks_to_rota=weeks_to_rota,
)
Rota.add_workers([
Worker(name="worker1", site="group1", grade=1, fte=100, exact_shifts={"a": 4}),
])
Rota.add_shifts(
SingleShift(
sites=("group1",),
name="a",
length=12.5,
days=("Mon",),
workers_required=1,
),
)
Rota.build_workers()
Rota.build_model()
warnings = Rota.get_warnings("Exact shifts exceed total shifts")
assert len(warnings) > 0
assert "total exact shifts (4) exceeds total required shifts" in warnings[0][1]
def test_html_export_worker_details_and_unavailable_reason():
weeks_to_rota = 2
start_date = datetime.date(2022, 3, 7)
Rota = RotaBuilder(
start_date,
weeks_to_rota=weeks_to_rota,
)
Rota.add_workers([
Worker(
name="worker1",
site="group1",
grade=1,
fte=100,
exact_shifts={"a": 1},
start_date=datetime.date(2022, 3, 14)
),
])
Rota.add_shifts(
SingleShift(
sites=("group1",),
name="a",
length=12.5,
days=("Mon",),
workers_required=1,
),
)
Rota.build_workers()
Rota.build_model()
html = Rota.get_worker_timetable_html()
assert 'data-exact-shifts=\'{"a": 1}\'' in html
assert 'title=\' (2022-03-07) / START DATE: 2022-03-14\'' in html
def test_worker_groups_selector():
weeks_to_rota = 2
start_date = datetime.date(2022, 3, 7)
Rota = RotaBuilder(
start_date,
weeks_to_rota=weeks_to_rota,
)
Rota.add_workers([
Worker(name="worker1", site="group1", grade=1, fte=100, groups={"groupA", "groupB"}),
Worker(name="worker2", site="group1", grade=1, fte=100, groups={"groupB", "groupC"}),
])
workers_A = Rota.get_workers_in_group("groupA")
workers_B = Rota.get_workers_in_group("groupB")
workers_C = Rota.get_workers_in_group("groupC")
workers_D = Rota.get_workers_in_group("groupD")
assert [w.name for w in workers_A] == ["worker1"]
assert set(w.name for w in workers_B) == {"worker1", "worker2"}
assert [w.name for w in workers_C] == ["worker2"]
assert workers_D == []
def test_shift_groups_overlapping_validation():
with pytest.raises(ValidationError) as exc_info:
SingleShift(
sites=("group1",),
name="a",
length=12.5,
days=("Mon",),
workers_required=1,
include_groups={"pool1", "pool2"},
exclude_groups={"pool2", "pool3"},
)
assert "include_groups and exclude_groups cannot overlap" in str(exc_info.value)
def test_shift_group_availability_inclusion():
weeks_to_rota = 2
start_date = datetime.date(2022, 3, 7)
Rota = RotaBuilder(
start_date,
weeks_to_rota=weeks_to_rota,
)
Rota.add_workers([
Worker(name="worker1", site="group1", grade=1, fte=100),
Worker(name="worker2", site="group2", grade=1, fte=100, groups={"senior"}),
Worker(name="worker3", site="group2", grade=1, fte=100, groups={"junior"}),
])
Rota.add_shifts(
SingleShift(
sites=("group1",),
name="a",
length=12.5,
days=("Mon",),
workers_required=1,
include_groups={"senior"},
),
)
Rota.terminate_on_warning.remove("Worker/no valid shifts")
Rota.build_and_solve()
Rota.export_rota_to_html("test_shift_group_availability_inclusion", folder="tests")
assert Rota.results.solver.status == "ok"
worker_shift_counts = {w.name: 0 for w in Rota.get_workers()}
for week, day, shiftname in Rota.get_all_shiftname_combinations():
for worker in Rota.get_workers():
val = Rota.model.works[worker.id, week, day, shiftname].value
if val is not None and val > 0.5:
worker_shift_counts[worker.name] += 1
assert worker_shift_counts["worker1"] in (1, 2)
assert worker_shift_counts["worker2"] in (1, 2)
assert worker_shift_counts["worker3"] == 0
def test_shift_group_availability_exclusion():
weeks_to_rota = 2
start_date = datetime.date(2022, 3, 7)
Rota = RotaBuilder(
start_date,
weeks_to_rota=weeks_to_rota,
)
Rota.add_workers([
Worker(name="worker1", site="group1", grade=1, fte=100, groups={"senior"}),
Worker(name="worker2", site="group1", grade=1, fte=100, groups={"junior"}),
])
Rota.add_shifts(
SingleShift(
sites=("group1",),
name="a",
length=12.5,
days=("Mon",),
workers_required=1,
exclude_groups={"junior"},
),
)
Rota.terminate_on_warning.remove("Worker/no valid shifts")
Rota.build_and_solve()
assert Rota.results.solver.status == "ok"
worker_shift_counts = {w.name: 0 for w in Rota.get_workers()}
for week, day, shiftname in Rota.get_all_shiftname_combinations():
for worker in Rota.get_workers():
val = Rota.model.works[worker.id, week, day, shiftname].value
if val is not None and val > 0.5:
worker_shift_counts[worker.name] += 1
assert worker_shift_counts["worker1"] == 2
assert worker_shift_counts["worker2"] == 0
def test_shift_dependent_active_dates_constraints():
weeks_to_rota = 10
start_date = datetime.date(2022, 3, 7) # Mon
Rota = RotaBuilder(
start_date,
weeks_to_rota=weeks_to_rota,
)
worker1 = Worker(name="worker1", site="group1", grade=1, fte=100)
worker2 = Worker(
name="worker2", site="group1", grade=1, fte=100,
shift_start_dates={"a": datetime.date(2022, 3, 28)},
shift_end_dates={"a": datetime.date(2022, 4, 25)},
)
Rota.add_workers([worker1, worker2])
Rota.add_shifts(
SingleShift(
sites=("group1",),
name="a",
length=12.5,
days=("Mon",),
workers_required=1,
),
)
Rota.build_and_solve(options={"ratio": 0.0})
assert Rota.results.solver.status == "ok"
# Verify assignments for worker2
for week in range(1, weeks_to_rota + 1):
val = Rota.model.works[worker2.id, week, "Mon", "a"].value
assigned = val is not None and val > 0.5
if week < 4 or week >= 8:
assert not assigned, f"Worker 2 should not be assigned 'a' in week {week}"
def test_shift_dependent_fte_target_scaling():
weeks_to_rota = 10
start_date = datetime.date(2022, 3, 7) # Mon
Rota = RotaBuilder(
start_date,
weeks_to_rota=weeks_to_rota,
)
worker1 = Worker(name="worker1", site="group1", grade=1, fte=100)
worker2 = Worker(
name="worker2", site="group1", grade=1, fte=100,
shift_start_dates={"a": datetime.date(2022, 3, 28)},
shift_end_dates={"a": datetime.date(2022, 4, 25)},
)
Rota.add_workers([worker1, worker2])
Rota.add_shifts(
SingleShift(
sites=("group1",),
name="a",
length=12.5,
days=("Mon",),
workers_required=1,
),
)
Rota.build_and_solve(options={"ratio": 0.0})
assert Rota.results.solver.status == "ok"
# Targets check
assert abs(worker1.shift_target_number["a"] - 7.14) < 0.1
assert abs(worker2.shift_target_number["a"] - 2.86) < 0.1
def test_shift_dependent_dates_validation_invalid_shift():
weeks_to_rota = 2
start_date = datetime.date(2022, 3, 7)
Rota = RotaBuilder(start_date, weeks_to_rota=weeks_to_rota)
worker = Worker(
name="worker1", site="group1", grade=1, fte=100,
shift_start_dates=[ShiftStartDate(shift="non_existent", start_date=datetime.date(2022, 3, 7))]
)
Rota.add_worker(worker)
Rota.add_shifts(
SingleShift(
sites=("group1",),
name="a",
length=12.5,
days=("Mon",),
workers_required=1,
)
)
with pytest.raises(InvalidShift):
Rota.build_workers()
def test_shift_dependent_dates_validation_ineligible_warning():
weeks_to_rota = 2
start_date = datetime.date(2022, 3, 7)
Rota = RotaBuilder(start_date, weeks_to_rota=weeks_to_rota)
worker = Worker(
name="worker1", site="group1", grade=1, fte=100,
shift_start_dates=[ShiftStartDate(shift="a", start_date=datetime.date(2022, 3, 7))]
)
Rota.add_worker(worker)
Rota.add_shifts(
SingleShift(
sites=("group2",),
name="a",
length=12.5,
days=("Mon",),
workers_required=1,
)
)
Rota.terminate_on_warning.remove("Worker/no valid shifts")
Rota.build_workers()
warnings = Rota.get_warnings("Worker/ineligible shift date constraint")
assert len(warnings) == 1
assert "is not eligible to work it" in warnings[0][1]
def test_shift_dependent_dates_model_definition():
worker = Worker(
name="worker1", site="group1", grade=1, fte=100,
shift_start_dates=[{"shift": "a", "start_date": "2022-03-07"}],
shift_end_dates=[{"shift": "a", "end_date": "2022-04-07"}]
)
assert len(worker.shift_start_dates) == 1
assert isinstance(worker.shift_start_dates[0], ShiftStartDate)
assert worker.shift_start_dates[0].shift == "a"
assert worker.shift_start_dates[0].start_date == datetime.date(2022, 3, 7)
assert len(worker.shift_end_dates) == 1
assert isinstance(worker.shift_end_dates[0], ShiftEndDate)
assert worker.shift_end_dates[0].shift == "a"
assert worker.shift_end_dates[0].end_date == datetime.date(2022, 4, 7)
def test_parse_time_limit():
from gen_proc import parse_time_limit
assert parse_time_limit(3600) == 3600
assert parse_time_limit("3600") == 3600
assert parse_time_limit("10h") == 36000
assert parse_time_limit("30m") == 1800
assert parse_time_limit("45s") == 45
assert parse_time_limit(" 1.5h ") == 5400
with pytest.raises(ValueError):
parse_time_limit("invalid")