[{"data":1,"prerenderedAt":3642},["ShallowReactive",2],{"doc:\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fgenerate-a-staff-rota-in-excel-with-python":3,"surround:\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fgenerate-a-staff-rota-in-excel-with-python":3607,"ancestors:\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fgenerate-a-staff-rota-in-excel-with-python":3616,"children:\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fgenerate-a-staff-rota-in-excel-with-python":3621,"related:\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fgenerate-a-staff-rota-in-excel-with-python":3622},{"id":4,"title":5,"body":6,"dateModified":3572,"datePublished":3572,"description":3573,"extension":3574,"faq":3575,"keyword":3584,"kind":3585,"meta":3586,"navigation":120,"path":3596,"seo":3597,"slug":3600,"stem":3601,"topics":3602,"type":3605,"__hash__":3606},"docs\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fgenerate-a-staff-rota-in-excel-with-python\u002Findex.md","Generate a Staff Rota in Excel with Python",{"type":7,"value":8,"toc":3560},"minimark",[9,24,29,63,72,76,79,580,595,599,602,1017,1024,1192,1196,1199,1537,1547,1551,1554,2410,2421,2739,2743,2746,3309,3312,3440,3444,3482,3486,3495,3499,3505,3511,3517,3523,3527,3556],[10,11,12,13,17,18,23],"p",{},"Every week someone in a shop, a clinic or a support team sits down with last week's rota and a list of who cannot work when, and shuffles names around until every shift is covered. It takes an hour or two, the result is rarely fair — the same people get the weekends — and the rules that matter most, like not putting anyone on an early shift straight after a late one, are easy to break by accident. The problem is a classic small optimisation: choose who works which shift so that every shift has enough people and every rule holds, while respecting preferences as far as possible. SciPy's ",[14,15,16],"code",{},"milp"," solver, which ships with SciPy, solves rotas of this size in a fraction of a second. This page models the rota, solves it, writes a colour-coded Excel sheet with coverage formulas, and checks every rule in the result. It belongs to ",[19,20,22],"a",{"href":21},"\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002F","Building Finance and Operations Reports",".",[25,26,28],"h2",{"id":27},"prerequisites","Prerequisites",[30,31,36],"pre",{"className":32,"code":33,"language":34,"meta":35,"style":35},"language-bash shiki shiki-themes github-light github-dark-high-contrast","pip install pandas numpy scipy openpyxl\n","bash","",[14,37,38],{"__ignoreMap":35},[39,40,43,47,51,54,57,60],"span",{"class":41,"line":42},"line",1,[39,44,46],{"class":45},"sMTad","pip",[39,48,50],{"class":49},"srMev"," install",[39,52,53],{"class":49}," pandas",[39,55,56],{"class":49}," numpy",[39,58,59],{"class":49}," scipy",[39,61,62],{"class":49}," openpyxl\n",[10,64,65,68,69,71],{},[14,66,67],{},"staff.xlsx"," lists six people with the most shifts each may work this week, the days they are unavailable, and a shift type they prefer to avoid. The rota is for the week of Monday 5 October 2026 with two shifts a day — Early (07:00–15:00) and Late (15:00–23:00). Weekdays need two people on each shift; at the weekend, two on Early and one on Late. ",[14,70,16],{}," uses the HiGHS solver bundled with SciPy 1.9 and later, so nothing else needs installing.",[25,73,75],{"id":74},"step-1-load-the-inputs-as-sets-and-lookups","Step 1 — Load the inputs as sets and lookups",[10,77,78],{},"The model works with index positions, so turn the spreadsheet into lists and dictionaries first:",[30,80,84],{"className":81,"code":82,"language":83,"meta":35,"style":35},"language-python shiki shiki-themes github-light github-dark-high-contrast","import numpy as np\nimport pandas as pd\n\nstaff = pd.read_excel(\"staff.xlsx\").fillna(\"\")\nDAYS = [\"Mon\", \"Tue\", \"Wed\", \"Thu\", \"Fri\", \"Sat\", \"Sun\"]\nSHIFTS = [\"Early\", \"Late\"]\nNEED = {(d, s): (2 if d not in (\"Sat\", \"Sun\") or s == \"Early\" else 1) for d in DAYS for s in SHIFTS}\n\npeople = list(staff[\"Name\"])\nunavailable = {r.Name: {x.strip() for x in r.Unavailable.split(\",\") if x.strip()} for r in staff.itertuples()}\nmax_shifts = dict(zip(staff[\"Name\"], staff[\"Max shifts\"]))\navoid = dict(zip(staff[\"Name\"], staff[\"Avoid\"]))\n\nidx = {(p, d, s): i for i, (p, d, s) in enumerate((p, d, s) for p in people for d in DAYS for s in SHIFTS)}\nextra = {p: len(idx) + k for k, p in enumerate(people)}     # weekend shifts beyond the first, per person\nNV = len(idx) + len(extra)\nprint(len(idx), \"yes\u002Fno decisions;\", sum(NEED.values()), \"shifts to fill;\", sum(max_shifts.values()), \"shifts available\")\n","python",[14,85,86,102,115,122,146,196,216,298,303,323,365,395,420,425,478,517,537],{"__ignoreMap":35},[39,87,88,92,96,99],{"class":41,"line":42},[39,89,91],{"class":90},"s-kum","import",[39,93,95],{"class":94},"skGVy"," numpy ",[39,97,98],{"class":90},"as",[39,100,101],{"class":94}," np\n",[39,103,105,107,110,112],{"class":41,"line":104},2,[39,106,91],{"class":90},[39,108,109],{"class":94}," pandas ",[39,111,98],{"class":90},[39,113,114],{"class":94}," pd\n",[39,116,118],{"class":41,"line":117},3,[39,119,121],{"emptyLinePlaceholder":120},true,"\n",[39,123,125,128,131,134,137,140,143],{"class":41,"line":124},4,[39,126,127],{"class":94},"staff ",[39,129,130],{"class":90},"=",[39,132,133],{"class":94}," pd.read_excel(",[39,135,136],{"class":49},"\"staff.xlsx\"",[39,138,139],{"class":94},").fillna(",[39,141,142],{"class":49},"\"\"",[39,144,145],{"class":94},")\n",[39,147,149,153,156,159,162,165,168,170,173,175,178,180,183,185,188,190,193],{"class":41,"line":148},5,[39,150,152],{"class":151},"sP0c6","DAYS",[39,154,155],{"class":90}," =",[39,157,158],{"class":94}," [",[39,160,161],{"class":49},"\"Mon\"",[39,163,164],{"class":94},", ",[39,166,167],{"class":49},"\"Tue\"",[39,169,164],{"class":94},[39,171,172],{"class":49},"\"Wed\"",[39,174,164],{"class":94},[39,176,177],{"class":49},"\"Thu\"",[39,179,164],{"class":94},[39,181,182],{"class":49},"\"Fri\"",[39,184,164],{"class":94},[39,186,187],{"class":49},"\"Sat\"",[39,189,164],{"class":94},[39,191,192],{"class":49},"\"Sun\"",[39,194,195],{"class":94},"]\n",[39,197,199,202,204,206,209,211,214],{"class":41,"line":198},6,[39,200,201],{"class":151},"SHIFTS",[39,203,155],{"class":90},[39,205,158],{"class":94},[39,207,208],{"class":49},"\"Early\"",[39,210,164],{"class":94},[39,212,213],{"class":49},"\"Late\"",[39,215,195],{"class":94},[39,217,219,222,224,227,230,233,236,239,242,245,247,249,251,254,257,260,263,266,269,272,274,277,279,282,285,288,290,292,295],{"class":41,"line":218},7,[39,220,221],{"class":151},"NEED",[39,223,155],{"class":90},[39,225,226],{"class":94}," {(d, s): (",[39,228,229],{"class":151},"2",[39,231,232],{"class":90}," if",[39,234,235],{"class":94}," d ",[39,237,238],{"class":90},"not",[39,240,241],{"class":90}," in",[39,243,244],{"class":94}," (",[39,246,187],{"class":49},[39,248,164],{"class":94},[39,250,192],{"class":49},[39,252,253],{"class":94},") ",[39,255,256],{"class":90},"or",[39,258,259],{"class":94}," s ",[39,261,262],{"class":90},"==",[39,264,265],{"class":49}," \"Early\"",[39,267,268],{"class":90}," else",[39,270,271],{"class":151}," 1",[39,273,253],{"class":94},[39,275,276],{"class":90},"for",[39,278,235],{"class":94},[39,280,281],{"class":90},"in",[39,283,284],{"class":151}," DAYS",[39,286,287],{"class":90}," for",[39,289,259],{"class":94},[39,291,281],{"class":90},[39,293,294],{"class":151}," SHIFTS",[39,296,297],{"class":94},"}\n",[39,299,301],{"class":41,"line":300},8,[39,302,121],{"emptyLinePlaceholder":120},[39,304,306,309,311,314,317,320],{"class":41,"line":305},9,[39,307,308],{"class":94},"people ",[39,310,130],{"class":90},[39,312,313],{"class":151}," list",[39,315,316],{"class":94},"(staff[",[39,318,319],{"class":49},"\"Name\"",[39,321,322],{"class":94},"])\n",[39,324,326,329,331,334,336,339,341,344,347,349,352,355,357,360,362],{"class":41,"line":325},10,[39,327,328],{"class":94},"unavailable ",[39,330,130],{"class":90},[39,332,333],{"class":94}," {r.Name: {x.strip() ",[39,335,276],{"class":90},[39,337,338],{"class":94}," x ",[39,340,281],{"class":90},[39,342,343],{"class":94}," r.Unavailable.split(",[39,345,346],{"class":49},"\",\"",[39,348,253],{"class":94},[39,350,351],{"class":90},"if",[39,353,354],{"class":94}," x.strip()} ",[39,356,276],{"class":90},[39,358,359],{"class":94}," r ",[39,361,281],{"class":90},[39,363,364],{"class":94}," staff.itertuples()}\n",[39,366,368,371,373,376,379,382,384,386,389,392],{"class":41,"line":367},11,[39,369,370],{"class":94},"max_shifts ",[39,372,130],{"class":90},[39,374,375],{"class":151}," dict",[39,377,378],{"class":94},"(",[39,380,381],{"class":151},"zip",[39,383,316],{"class":94},[39,385,319],{"class":49},[39,387,388],{"class":94},"], staff[",[39,390,391],{"class":49},"\"Max shifts\"",[39,393,394],{"class":94},"]))\n",[39,396,398,401,403,405,407,409,411,413,415,418],{"class":41,"line":397},12,[39,399,400],{"class":94},"avoid ",[39,402,130],{"class":90},[39,404,375],{"class":151},[39,406,378],{"class":94},[39,408,381],{"class":151},[39,410,316],{"class":94},[39,412,319],{"class":49},[39,414,388],{"class":94},[39,416,417],{"class":49},"\"Avoid\"",[39,419,394],{"class":94},[39,421,423],{"class":41,"line":422},13,[39,424,121],{"emptyLinePlaceholder":120},[39,426,428,431,433,436,438,441,443,446,449,451,454,456,459,461,463,465,467,469,471,473,475],{"class":41,"line":427},14,[39,429,430],{"class":94},"idx ",[39,432,130],{"class":90},[39,434,435],{"class":94}," {(p, d, s): i ",[39,437,276],{"class":90},[39,439,440],{"class":94}," i, (p, d, s) ",[39,442,281],{"class":90},[39,444,445],{"class":151}," enumerate",[39,447,448],{"class":94},"((p, d, s) ",[39,450,276],{"class":90},[39,452,453],{"class":94}," p ",[39,455,281],{"class":90},[39,457,458],{"class":94}," people ",[39,460,276],{"class":90},[39,462,235],{"class":94},[39,464,281],{"class":90},[39,466,284],{"class":151},[39,468,287],{"class":90},[39,470,259],{"class":94},[39,472,281],{"class":90},[39,474,294],{"class":151},[39,476,477],{"class":94},")}\n",[39,479,481,484,486,489,492,495,498,501,503,506,508,510,513],{"class":41,"line":480},15,[39,482,483],{"class":94},"extra ",[39,485,130],{"class":90},[39,487,488],{"class":94}," {p: ",[39,490,491],{"class":151},"len",[39,493,494],{"class":94},"(idx) ",[39,496,497],{"class":90},"+",[39,499,500],{"class":94}," k ",[39,502,276],{"class":90},[39,504,505],{"class":94}," k, p ",[39,507,281],{"class":90},[39,509,445],{"class":151},[39,511,512],{"class":94},"(people)}     ",[39,514,516],{"class":515},"s-wDw","# weekend shifts beyond the first, per person\n",[39,518,520,523,525,528,530,532,534],{"class":41,"line":519},16,[39,521,522],{"class":151},"NV",[39,524,155],{"class":90},[39,526,527],{"class":151}," len",[39,529,494],{"class":94},[39,531,497],{"class":90},[39,533,527],{"class":151},[39,535,536],{"class":94},"(extra)\n",[39,538,540,543,545,547,550,553,555,558,560,562,565,568,570,572,575,578],{"class":41,"line":539},17,[39,541,542],{"class":151},"print",[39,544,378],{"class":94},[39,546,491],{"class":151},[39,548,549],{"class":94},"(idx), ",[39,551,552],{"class":49},"\"yes\u002Fno decisions;\"",[39,554,164],{"class":94},[39,556,557],{"class":151},"sum",[39,559,378],{"class":94},[39,561,221],{"class":151},[39,563,564],{"class":94},".values()), ",[39,566,567],{"class":49},"\"shifts to fill;\"",[39,569,164],{"class":94},[39,571,557],{"class":151},[39,573,574],{"class":94},"(max_shifts.values()), ",[39,576,577],{"class":49},"\"shifts available\"",[39,579,145],{"class":94},[10,581,582,583,586,587,590,591,594],{},"Each decision is \"does person ",[584,585,10],"em",{}," work shift ",[584,588,589],{},"s"," on day ",[584,592,593],{},"d","?\" — six people × seven days × two shifts gives 84 yes\u002Fno variables. The week needs 26 person-shifts and the team can offer at most 27, so there is very little slack; a manual rota with this little room for manoeuvre is exactly where rules get broken. Checking total supply against demand up front is worthwhile: if the team can offer fewer shifts than are needed, no rota exists and the solver will say \"infeasible\".",[25,596,598],{"id":597},"step-2-write-the-rules-as-constraints","Step 2 — Write the rules as constraints",[10,600,601],{},"Every rule becomes a set of linear inequalities over the 0\u002F1 variables. Collect them as rows of a matrix with lower and upper bounds:",[30,603,605],{"className":81,"code":604,"language":83,"meta":35,"style":35},"from scipy.optimize import LinearConstraint\n\nrows, lo, hi = [], [], []\ndef add(coeffs, lower, upper):\n    row = np.zeros(NV)\n    for key, c in coeffs.items():\n        row[idx[key] if key in idx else extra[key]] = c\n    rows.append(row); lo.append(lower); hi.append(upper)\n\nfor d in DAYS:\n    for s in SHIFTS:                                            # exact cover for every shift\n        add({(p, d, s): 1 for p in people}, NEED[d, s], NEED[d, s])\nfor p in people:\n    for d in DAYS:                                              # at most one shift a day\n        add({(p, d, s): 1 for s in SHIFTS}, 0, 1)\n    add({(p, d, s): 1 for d in DAYS for s in SHIFTS}, 0, max_shifts[p])     # contracted maximum\n    weekend = {(p, d, s): 1 for d in (\"Sat\", \"Sun\") for s in SHIFTS}\n    add({**weekend, p: -1}, -np.inf, 1)                         # weekend shifts − extra ≤ 1 (soft rule)\n    for today, tomorrow in zip(DAYS, DAYS[1:]):                 # no Late followed by Early\n        add({(p, today, \"Late\"): 1, (p, tomorrow, \"Early\"): 1}, 0, 1)\n\nconstraints = LinearConstraint(np.array(rows), lo, hi)\nprint(len(rows), \"constraints\")\n",[14,606,607,620,624,634,646,660,673,699,704,708,721,737,764,775,791,817,850,888,920,952,984,989,1000],{"__ignoreMap":35},[39,608,609,612,615,617],{"class":41,"line":42},[39,610,611],{"class":90},"from",[39,613,614],{"class":94}," scipy.optimize ",[39,616,91],{"class":90},[39,618,619],{"class":94}," LinearConstraint\n",[39,621,622],{"class":41,"line":104},[39,623,121],{"emptyLinePlaceholder":120},[39,625,626,629,631],{"class":41,"line":117},[39,627,628],{"class":94},"rows, lo, hi ",[39,630,130],{"class":90},[39,632,633],{"class":94}," [], [], []\n",[39,635,636,639,643],{"class":41,"line":124},[39,637,638],{"class":90},"def",[39,640,642],{"class":641},"s_Opv"," add",[39,644,645],{"class":94},"(coeffs, lower, upper):\n",[39,647,648,651,653,656,658],{"class":41,"line":148},[39,649,650],{"class":94},"    row ",[39,652,130],{"class":90},[39,654,655],{"class":94}," np.zeros(",[39,657,522],{"class":151},[39,659,145],{"class":94},[39,661,662,665,668,670],{"class":41,"line":198},[39,663,664],{"class":90},"    for",[39,666,667],{"class":94}," key, c ",[39,669,281],{"class":90},[39,671,672],{"class":94}," coeffs.items():\n",[39,674,675,678,680,683,685,688,691,694,696],{"class":41,"line":218},[39,676,677],{"class":94},"        row[idx[key] ",[39,679,351],{"class":90},[39,681,682],{"class":94}," key ",[39,684,281],{"class":90},[39,686,687],{"class":94}," idx ",[39,689,690],{"class":90},"else",[39,692,693],{"class":94}," extra[key]] ",[39,695,130],{"class":90},[39,697,698],{"class":94}," c\n",[39,700,701],{"class":41,"line":300},[39,702,703],{"class":94},"    rows.append(row); lo.append(lower); hi.append(upper)\n",[39,705,706],{"class":41,"line":305},[39,707,121],{"emptyLinePlaceholder":120},[39,709,710,712,714,716,718],{"class":41,"line":325},[39,711,276],{"class":90},[39,713,235],{"class":94},[39,715,281],{"class":90},[39,717,284],{"class":151},[39,719,720],{"class":94},":\n",[39,722,723,725,727,729,731,734],{"class":41,"line":367},[39,724,664],{"class":90},[39,726,259],{"class":94},[39,728,281],{"class":90},[39,730,294],{"class":151},[39,732,733],{"class":94},":                                            ",[39,735,736],{"class":515},"# exact cover for every shift\n",[39,738,739,742,745,747,749,751,754,756,759,761],{"class":41,"line":397},[39,740,741],{"class":94},"        add({(p, d, s): ",[39,743,744],{"class":151},"1",[39,746,287],{"class":90},[39,748,453],{"class":94},[39,750,281],{"class":90},[39,752,753],{"class":94}," people}, ",[39,755,221],{"class":151},[39,757,758],{"class":94},"[d, s], ",[39,760,221],{"class":151},[39,762,763],{"class":94},"[d, s])\n",[39,765,766,768,770,772],{"class":41,"line":422},[39,767,276],{"class":90},[39,769,453],{"class":94},[39,771,281],{"class":90},[39,773,774],{"class":94}," people:\n",[39,776,777,779,781,783,785,788],{"class":41,"line":427},[39,778,664],{"class":90},[39,780,235],{"class":94},[39,782,281],{"class":90},[39,784,284],{"class":151},[39,786,787],{"class":94},":                                              ",[39,789,790],{"class":515},"# at most one shift a day\n",[39,792,793,795,797,799,801,803,805,808,811,813,815],{"class":41,"line":480},[39,794,741],{"class":94},[39,796,744],{"class":151},[39,798,287],{"class":90},[39,800,259],{"class":94},[39,802,281],{"class":90},[39,804,294],{"class":151},[39,806,807],{"class":94},"}, ",[39,809,810],{"class":151},"0",[39,812,164],{"class":94},[39,814,744],{"class":151},[39,816,145],{"class":94},[39,818,819,822,824,826,828,830,832,834,836,838,840,842,844,847],{"class":41,"line":519},[39,820,821],{"class":94},"    add({(p, d, s): ",[39,823,744],{"class":151},[39,825,287],{"class":90},[39,827,235],{"class":94},[39,829,281],{"class":90},[39,831,284],{"class":151},[39,833,287],{"class":90},[39,835,259],{"class":94},[39,837,281],{"class":90},[39,839,294],{"class":151},[39,841,807],{"class":94},[39,843,810],{"class":151},[39,845,846],{"class":94},", max_shifts[p])     ",[39,848,849],{"class":515},"# contracted maximum\n",[39,851,852,855,857,860,862,864,866,868,870,872,874,876,878,880,882,884,886],{"class":41,"line":539},[39,853,854],{"class":94},"    weekend ",[39,856,130],{"class":90},[39,858,859],{"class":94}," {(p, d, s): ",[39,861,744],{"class":151},[39,863,287],{"class":90},[39,865,235],{"class":94},[39,867,281],{"class":90},[39,869,244],{"class":94},[39,871,187],{"class":49},[39,873,164],{"class":94},[39,875,192],{"class":49},[39,877,253],{"class":94},[39,879,276],{"class":90},[39,881,259],{"class":94},[39,883,281],{"class":90},[39,885,294],{"class":151},[39,887,297],{"class":94},[39,889,891,894,897,900,903,905,907,909,912,914,917],{"class":41,"line":890},18,[39,892,893],{"class":94},"    add({",[39,895,896],{"class":90},"**",[39,898,899],{"class":94},"weekend, p: ",[39,901,902],{"class":90},"-",[39,904,744],{"class":151},[39,906,807],{"class":94},[39,908,902],{"class":90},[39,910,911],{"class":94},"np.inf, ",[39,913,744],{"class":151},[39,915,916],{"class":94},")                         ",[39,918,919],{"class":515},"# weekend shifts − extra ≤ 1 (soft rule)\n",[39,921,923,925,928,930,933,935,937,939,941,944,946,949],{"class":41,"line":922},19,[39,924,664],{"class":90},[39,926,927],{"class":94}," today, tomorrow ",[39,929,281],{"class":90},[39,931,932],{"class":151}," zip",[39,934,378],{"class":94},[39,936,152],{"class":151},[39,938,164],{"class":94},[39,940,152],{"class":151},[39,942,943],{"class":94},"[",[39,945,744],{"class":151},[39,947,948],{"class":94},":]):                 ",[39,950,951],{"class":515},"# no Late followed by Early\n",[39,953,955,958,960,963,965,968,970,972,974,976,978,980,982],{"class":41,"line":954},20,[39,956,957],{"class":94},"        add({(p, today, ",[39,959,213],{"class":49},[39,961,962],{"class":94},"): ",[39,964,744],{"class":151},[39,966,967],{"class":94},", (p, tomorrow, ",[39,969,208],{"class":49},[39,971,962],{"class":94},[39,973,744],{"class":151},[39,975,807],{"class":94},[39,977,810],{"class":151},[39,979,164],{"class":94},[39,981,744],{"class":151},[39,983,145],{"class":94},[39,985,987],{"class":41,"line":986},21,[39,988,121],{"emptyLinePlaceholder":120},[39,990,992,995,997],{"class":41,"line":991},22,[39,993,994],{"class":94},"constraints ",[39,996,130],{"class":90},[39,998,999],{"class":94}," LinearConstraint(np.array(rows), lo, hi)\n",[39,1001,1003,1005,1007,1009,1012,1015],{"class":41,"line":1002},23,[39,1004,542],{"class":151},[39,1006,378],{"class":94},[39,1008,491],{"class":151},[39,1010,1011],{"class":94},"(rows), ",[39,1013,1014],{"class":49},"\"constraints\"",[39,1016,145],{"class":94},[10,1018,1019,1020,1023],{},"Exact cover (lower and upper bound both equal to the need) stops the solver over-staffing quiet shifts. \"At most one shift a day\" and the contracted maximum are simple sums. The weekend rule is written as a ",[584,1021,1022],{},"soft"," rule: weekend shifts minus an extra variable must be at most 1, so a second weekend shift is allowed but shows up in that person's extra variable, which the objective penalises. A hard limit of one weekend shift each was the first version of this model, and the solver reported it infeasible — rightly: the weekend has six slots, Ben cannot work weekends, and the five others can supply only five weekend shifts under that rule. Making the rule soft keeps the intent — spread weekends as evenly as possible — while letting the solver find a rota. The turnaround rule says a person's Late shift today and Early shift tomorrow cannot both be 1 — finishing at 23:00 and starting at 07:00 leaves only eight hours, below the eleven-hour daily rest many employment rules require. Unavailability is handled next, through variable bounds, which is simpler than extra constraints.",[1025,1026,1034,1035,1034,1039,1034,1043,1034,1050,1034,1058,1034,1065,1034,1072,1034,1075,1034,1079,1034,1084,1034,1090,1034,1092,1034,1097,1034,1099,1034,1102,1034,1105,1034,1109,1034,1111,1034,1115,1034,1117,1034,1120,1034,1123,1034,1127,1034,1129,1034,1133,1034,1135,1034,1138,1034,1141,1034,1145,1034,1147,1034,1151,1034,1153,1034,1156,1034,1159,1034,1163,1034,1165,1034,1169,1034,1171,1034,1174,1034,1177,1034,1181,1034,1183,1034,1187,1034,1189],"svg",{"viewBox":1027,"role":1028,"ariaLabelledBy":1029,"xmlns":1032,"style":1033},"4 0 752 276","img",[1030,1031],"ro-rules-t","ro-rules-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;max-width:752px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif","\n  ",[1036,1037,1038],"title",{"id":1030},"The rota rules as constraints",[1040,1041,1042],"desc",{"id":1031},"Each rule becomes a linear constraint over yes-no shift variables. Cover is an equality per shift. One shift a day, the contracted maximum and no late-then-early turnaround are upper limits; the weekend rule is soft, with a penalised extra variable. Unavailability fixes variables to zero.",[1044,1045],"rect",{"x":1046,"y":810,"width":1047,"height":1048,"fill":1049},"4","752","276","#ffffff",[1044,1051],{"x":1052,"y":1053,"width":1054,"height":1055,"rx":810,"fill":1049,"stroke":1056,"style":1057},"20.0","16.0","164.4","40.0","var(--line,#cdd5e6)","stroke-width:1px",[1044,1059],{"x":1060,"y":1053,"width":1061,"height":1055,"rx":810,"fill":1062,"stroke":1063,"style":1064},"184.4","277.8","#f0f4ff","var(--brand,#5b5cf0)","stroke-width:1.2px",[1066,1067,1071],"text",{"x":1068,"y":1069,"style":1070},"323.3","41.0","font-size:13px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","constraint",[1044,1073],{"x":1074,"y":1053,"width":1061,"height":1055,"rx":810,"fill":1062,"stroke":1063,"style":1064},"462.2",[1066,1076,1078],{"x":1077,"y":1069,"style":1070},"601.1","example",[1044,1080],{"x":1052,"y":1081,"width":1054,"height":1082,"rx":810,"fill":1083,"stroke":1056,"style":1057},"56.0","34.0","#e7ebef",[1066,1085,1089],{"x":1086,"y":1087,"style":1088},"32.0","78.0","font-size:12.5px;font-weight:700;fill:var(--text,#172033);text-anchor:start","cover each shift",[1044,1091],{"x":1060,"y":1081,"width":1061,"height":1082,"rx":810,"fill":1049,"stroke":1056,"style":1057},[1066,1093,1096],{"x":1068,"y":1094,"style":1095},"77.0","font-size:11.5px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","sum over people = need",[1044,1098],{"x":1074,"y":1081,"width":1061,"height":1082,"rx":810,"fill":1049,"stroke":1056,"style":1057},[1066,1100,1101],{"x":1077,"y":1094,"style":1095},"Mon Early = 2",[1044,1103],{"x":1052,"y":1104,"width":1054,"height":1082,"rx":810,"fill":1083,"stroke":1056,"style":1057},"90.0",[1066,1106,1108],{"x":1086,"y":1107,"style":1088},"112.0","one shift a day",[1044,1110],{"x":1060,"y":1104,"width":1061,"height":1082,"rx":810,"fill":1049,"stroke":1056,"style":1057},[1066,1112,1114],{"x":1068,"y":1113,"style":1095},"111.0","Early + Late ≤ 1",[1044,1116],{"x":1074,"y":1104,"width":1061,"height":1082,"rx":810,"fill":1049,"stroke":1056,"style":1057},[1066,1118,1119],{"x":1077,"y":1113,"style":1095},"per person, per day",[1044,1121],{"x":1052,"y":1122,"width":1054,"height":1082,"rx":810,"fill":1083,"stroke":1056,"style":1057},"124.0",[1066,1124,1126],{"x":1086,"y":1125,"style":1088},"146.0","contracted maximum",[1044,1128],{"x":1060,"y":1122,"width":1061,"height":1082,"rx":810,"fill":1049,"stroke":1056,"style":1057},[1066,1130,1132],{"x":1068,"y":1131,"style":1095},"145.0","week total ≤ max",[1044,1134],{"x":1074,"y":1122,"width":1061,"height":1082,"rx":810,"fill":1049,"stroke":1056,"style":1057},[1066,1136,1137],{"x":1077,"y":1131,"style":1095},"Dev ≤ 3",[1044,1139],{"x":1052,"y":1140,"width":1054,"height":1082,"rx":810,"fill":1083,"stroke":1056,"style":1057},"158.0",[1066,1142,1144],{"x":1086,"y":1143,"style":1088},"180.0","fair weekends (soft)",[1044,1146],{"x":1060,"y":1140,"width":1061,"height":1082,"rx":810,"fill":1049,"stroke":1056,"style":1057},[1066,1148,1150],{"x":1068,"y":1149,"style":1095},"179.0","Sat + Sun − extra ≤ 1",[1044,1152],{"x":1074,"y":1140,"width":1061,"height":1082,"rx":810,"fill":1049,"stroke":1056,"style":1057},[1066,1154,1155],{"x":1077,"y":1149,"style":1095},"extra costs 5",[1044,1157],{"x":1052,"y":1158,"width":1054,"height":1082,"rx":810,"fill":1083,"stroke":1056,"style":1057},"192.0",[1066,1160,1162],{"x":1086,"y":1161,"style":1088},"214.0","rest between shifts",[1044,1164],{"x":1060,"y":1158,"width":1061,"height":1082,"rx":810,"fill":1049,"stroke":1056,"style":1057},[1066,1166,1168],{"x":1068,"y":1167,"style":1095},"213.0","Late today + Early tomorrow ≤ 1",[1044,1170],{"x":1074,"y":1158,"width":1061,"height":1082,"rx":810,"fill":1049,"stroke":1056,"style":1057},[1066,1172,1173],{"x":1077,"y":1167,"style":1095},"≥ 8 h gap kept",[1044,1175],{"x":1052,"y":1176,"width":1054,"height":1082,"rx":810,"fill":1083,"stroke":1056,"style":1057},"226.0",[1066,1178,1180],{"x":1086,"y":1179,"style":1088},"248.0","unavailable",[1044,1182],{"x":1060,"y":1176,"width":1061,"height":1082,"rx":810,"fill":1049,"stroke":1056,"style":1057},[1066,1184,1186],{"x":1068,"y":1185,"style":1095},"247.0","variable fixed to 0",[1044,1188],{"x":1074,"y":1176,"width":1061,"height":1082,"rx":810,"fill":1049,"stroke":1056,"style":1057},[1066,1190,1191],{"x":1077,"y":1185,"style":1095},"Ben: Sat, Sun",[25,1193,1195],{"id":1194},"step-3-preferences-as-the-objective-then-solve","Step 3 — Preferences as the objective, then solve",[10,1197,1198],{},"Hard rules are constraints; wishes go into the objective. Give each assignment a cost — high for a shift type the person wants to avoid — and ask the solver for the cheapest rota that satisfies every rule:",[30,1200,1202],{"className":81,"code":1201,"language":83,"meta":35,"style":35},"from scipy.optimize import Bounds, milp\n\ncost = np.ones(NV)                               # every shift costs 1 …\nupper = np.ones(NV)\ncost[len(idx):] = 5                              # each extra weekend shift costs 5\nupper[len(idx):] = 2\nfor (p, d, s), i in idx.items():\n    if avoid[p] == s:\n        cost[i] += 10                            # … and a disliked shift costs much more\n    if d in unavailable[p]:\n        upper[i] = 0                             # unavailable: the variable can only be 0\n\nres = milp(c=cost, constraints=constraints, integrality=np.ones(NV),\n           bounds=Bounds(np.zeros(NV), upper))\nprint(res.status, res.message)\nif res.status != 0:\n    raise SystemExit(\"no rota satisfies the rules — relax a maximum or the cover\")\nx = np.round(res.x).astype(int)\nassigned = {key: x[i] for key, i in idx.items()}\nprint(\"disliked shifts given:\", sum(assigned[p, d, s] for (p, d, s) in idx if avoid[p] == s),\n      \"| people with two weekend shifts:\", [p for p in people if x[extra[p]] > 0])\n",[14,1203,1204,1215,1219,1237,1250,1268,1282,1294,1307,1321,1332,1345,1349,1389,1404,1411,1425,1440,1455,1475,1509],{"__ignoreMap":35},[39,1205,1206,1208,1210,1212],{"class":41,"line":42},[39,1207,611],{"class":90},[39,1209,614],{"class":94},[39,1211,91],{"class":90},[39,1213,1214],{"class":94}," Bounds, milp\n",[39,1216,1217],{"class":41,"line":104},[39,1218,121],{"emptyLinePlaceholder":120},[39,1220,1221,1224,1226,1229,1231,1234],{"class":41,"line":117},[39,1222,1223],{"class":94},"cost ",[39,1225,130],{"class":90},[39,1227,1228],{"class":94}," np.ones(",[39,1230,522],{"class":151},[39,1232,1233],{"class":94},")                               ",[39,1235,1236],{"class":515},"# every shift costs 1 …\n",[39,1238,1239,1242,1244,1246,1248],{"class":41,"line":124},[39,1240,1241],{"class":94},"upper ",[39,1243,130],{"class":90},[39,1245,1228],{"class":94},[39,1247,522],{"class":151},[39,1249,145],{"class":94},[39,1251,1252,1255,1257,1260,1262,1265],{"class":41,"line":148},[39,1253,1254],{"class":94},"cost[",[39,1256,491],{"class":151},[39,1258,1259],{"class":94},"(idx):] ",[39,1261,130],{"class":90},[39,1263,1264],{"class":151}," 5",[39,1266,1267],{"class":515},"                              # each extra weekend shift costs 5\n",[39,1269,1270,1273,1275,1277,1279],{"class":41,"line":198},[39,1271,1272],{"class":94},"upper[",[39,1274,491],{"class":151},[39,1276,1259],{"class":94},[39,1278,130],{"class":90},[39,1280,1281],{"class":151}," 2\n",[39,1283,1284,1286,1289,1291],{"class":41,"line":218},[39,1285,276],{"class":90},[39,1287,1288],{"class":94}," (p, d, s), i ",[39,1290,281],{"class":90},[39,1292,1293],{"class":94}," idx.items():\n",[39,1295,1296,1299,1302,1304],{"class":41,"line":300},[39,1297,1298],{"class":90},"    if",[39,1300,1301],{"class":94}," avoid[p] ",[39,1303,262],{"class":90},[39,1305,1306],{"class":94}," s:\n",[39,1308,1309,1312,1315,1318],{"class":41,"line":305},[39,1310,1311],{"class":94},"        cost[i] ",[39,1313,1314],{"class":90},"+=",[39,1316,1317],{"class":151}," 10",[39,1319,1320],{"class":515},"                            # … and a disliked shift costs much more\n",[39,1322,1323,1325,1327,1329],{"class":41,"line":325},[39,1324,1298],{"class":90},[39,1326,235],{"class":94},[39,1328,281],{"class":90},[39,1330,1331],{"class":94}," unavailable[p]:\n",[39,1333,1334,1337,1339,1342],{"class":41,"line":367},[39,1335,1336],{"class":94},"        upper[i] ",[39,1338,130],{"class":90},[39,1340,1341],{"class":151}," 0",[39,1343,1344],{"class":515},"                             # unavailable: the variable can only be 0\n",[39,1346,1347],{"class":41,"line":397},[39,1348,121],{"emptyLinePlaceholder":120},[39,1350,1351,1354,1356,1359,1363,1365,1368,1371,1373,1376,1379,1381,1384,1386],{"class":41,"line":422},[39,1352,1353],{"class":94},"res ",[39,1355,130],{"class":90},[39,1357,1358],{"class":94}," milp(",[39,1360,1362],{"class":1361},"sa561","c",[39,1364,130],{"class":90},[39,1366,1367],{"class":94},"cost, ",[39,1369,1370],{"class":1361},"constraints",[39,1372,130],{"class":90},[39,1374,1375],{"class":94},"constraints, ",[39,1377,1378],{"class":1361},"integrality",[39,1380,130],{"class":90},[39,1382,1383],{"class":94},"np.ones(",[39,1385,522],{"class":151},[39,1387,1388],{"class":94},"),\n",[39,1390,1391,1394,1396,1399,1401],{"class":41,"line":427},[39,1392,1393],{"class":1361},"           bounds",[39,1395,130],{"class":90},[39,1397,1398],{"class":94},"Bounds(np.zeros(",[39,1400,522],{"class":151},[39,1402,1403],{"class":94},"), upper))\n",[39,1405,1406,1408],{"class":41,"line":480},[39,1407,542],{"class":151},[39,1409,1410],{"class":94},"(res.status, res.message)\n",[39,1412,1413,1415,1418,1421,1423],{"class":41,"line":519},[39,1414,351],{"class":90},[39,1416,1417],{"class":94}," res.status ",[39,1419,1420],{"class":90},"!=",[39,1422,1341],{"class":151},[39,1424,720],{"class":94},[39,1426,1427,1430,1433,1435,1438],{"class":41,"line":539},[39,1428,1429],{"class":90},"    raise",[39,1431,1432],{"class":151}," SystemExit",[39,1434,378],{"class":94},[39,1436,1437],{"class":49},"\"no rota satisfies the rules — relax a maximum or the cover\"",[39,1439,145],{"class":94},[39,1441,1442,1445,1447,1450,1453],{"class":41,"line":890},[39,1443,1444],{"class":94},"x ",[39,1446,130],{"class":90},[39,1448,1449],{"class":94}," np.round(res.x).astype(",[39,1451,1452],{"class":151},"int",[39,1454,145],{"class":94},[39,1456,1457,1460,1462,1465,1467,1470,1472],{"class":41,"line":922},[39,1458,1459],{"class":94},"assigned ",[39,1461,130],{"class":90},[39,1463,1464],{"class":94}," {key: x[i] ",[39,1466,276],{"class":90},[39,1468,1469],{"class":94}," key, i ",[39,1471,281],{"class":90},[39,1473,1474],{"class":94}," idx.items()}\n",[39,1476,1477,1479,1481,1484,1486,1488,1491,1493,1496,1498,1500,1502,1504,1506],{"class":41,"line":954},[39,1478,542],{"class":151},[39,1480,378],{"class":94},[39,1482,1483],{"class":49},"\"disliked shifts given:\"",[39,1485,164],{"class":94},[39,1487,557],{"class":151},[39,1489,1490],{"class":94},"(assigned[p, d, s] ",[39,1492,276],{"class":90},[39,1494,1495],{"class":94}," (p, d, s) ",[39,1497,281],{"class":90},[39,1499,687],{"class":94},[39,1501,351],{"class":90},[39,1503,1301],{"class":94},[39,1505,262],{"class":90},[39,1507,1508],{"class":94}," s),\n",[39,1510,1511,1514,1517,1519,1521,1523,1525,1527,1530,1533,1535],{"class":41,"line":986},[39,1512,1513],{"class":49},"      \"| people with two weekend shifts:\"",[39,1515,1516],{"class":94},", [p ",[39,1518,276],{"class":90},[39,1520,453],{"class":94},[39,1522,281],{"class":90},[39,1524,458],{"class":94},[39,1526,351],{"class":90},[39,1528,1529],{"class":94}," x[extra[p]] ",[39,1531,1532],{"class":90},">",[39,1534,1341],{"class":151},[39,1536,322],{"class":94},[10,1538,1539,1542,1543,1546],{},[14,1540,1541],{},"integrality=1"," for every variable makes each one a whole number, and the bounds of 0 and 1 make it yes\u002Fno; unavailable slots get an upper bound of 0. Status 0 means an optimal rota was found. Because cover is exact, every feasible rota assigns the same 26 shifts, so minimising cost really means minimising disliked shifts and second weekend shifts. The weights encode priorities: a disliked shift (10) is worse than a second weekend shift (5). The solver finds a rota in which nobody works a shift type they asked to avoid and exactly one person works both weekend days — the unavoidable minimum. If the rules cannot all be met, ",[14,1544,1545],{},"res.status"," is 2 (infeasible); the usual fixes are relaxing a maximum or the weekend rule, and it pays to report which person-days are the bottleneck rather than just failing.",[25,1548,1550],{"id":1549},"step-4-write-a-colour-coded-rota-with-coverage-checks","Step 4 — Write a colour-coded rota with coverage checks",[10,1552,1553],{},"The rota sheet has people down the side and days across, with E or L in each cell, a shift count per person, and coverage rows that count each day's Earlies and Lates with formulas:",[30,1555,1557],{"className":81,"code":1556,"language":83,"meta":35,"style":35},"from openpyxl import Workbook\nfrom openpyxl.formatting.rule import CellIsRule, FormulaRule\nfrom openpyxl.styles import Alignment, Font, PatternFill\n\nwb = Workbook()\nws = wb.active\nws.title = \"Rota 5 Oct\"\nws.append([\"Name\"] + DAYS + [\"Shifts\", \"Max\"])\nfor p in people:\n    cells = [\"E\" if assigned[p, d, \"Early\"] else \"L\" if assigned[p, d, \"Late\"] else \"\" for d in DAYS]\n    r = ws.max_row + 1\n    ws.append([p] + cells + [f'=COUNTIF(B{r}:H{r},\"?\")', max_shifts[p]])\nlast = ws.max_row\nfor label, code in ((\"Early cover\", \"E\"), (\"Late cover\", \"L\")):\n    r = ws.max_row + 1\n    ws.append([label] + [f'=COUNTIF({c}2:{c}{last},\"{code}\")' for c in \"BCDEFGH\"])\n    ws.append([f\"{label[:-6]} needed\"] + [NEED[d, \"Early\" if code == \"E\" else \"Late\"] for d in DAYS])\n\nfills = {\"E\": \"FFF2CC\", \"L\": \"D9E1F2\"}\nfor code, colour in fills.items():\n    ws.conditional_formatting.add(f\"B2:H{last}\", CellIsRule(operator=\"equal\", formula=[f'\"{code}\"'],\n                                  fill=PatternFill(\"solid\", fgColor=colour)))\nfor r in (last + 1, last + 3):\n    ws.conditional_formatting.add(f\"B{r}:H{r}\", FormulaRule(formula=[f\"B{r}\u003C>B{r + 1}\"],\n                                  fill=PatternFill(\"solid\", fgColor=\"FFC7CE\")))\nfor row in ws.iter_rows(min_row=1, max_row=ws.max_row):\n    for c in row[1:]:\n        c.alignment = Alignment(horizontal=\"center\")\nfor c in ws[1]:\n    c.font = Font(bold=True)\nws.column_dimensions[\"A\"].width = 14\nwb.save(\"rota_2026-10-05.xlsx\")\nfor p in people:\n    print(f\"{p:6}\", \" \".join((\"E\" if assigned[p, d, \"Early\"] else \"L\" if assigned[p, d, \"Late\"] else \"·\") for d in DAYS))\n",[14,1558,1559,1571,1583,1595,1599,1609,1619,1629,1658,1668,1717,1732,1777,1787,1820,1832,1891,1959,1963,1992,2004,2059,2082,2108,2171,2194,2224,2241,2262,2279,2300,2317,2328,2339],{"__ignoreMap":35},[39,1560,1561,1563,1566,1568],{"class":41,"line":42},[39,1562,611],{"class":90},[39,1564,1565],{"class":94}," openpyxl ",[39,1567,91],{"class":90},[39,1569,1570],{"class":94}," Workbook\n",[39,1572,1573,1575,1578,1580],{"class":41,"line":104},[39,1574,611],{"class":90},[39,1576,1577],{"class":94}," openpyxl.formatting.rule ",[39,1579,91],{"class":90},[39,1581,1582],{"class":94}," CellIsRule, FormulaRule\n",[39,1584,1585,1587,1590,1592],{"class":41,"line":117},[39,1586,611],{"class":90},[39,1588,1589],{"class":94}," openpyxl.styles ",[39,1591,91],{"class":90},[39,1593,1594],{"class":94}," Alignment, Font, PatternFill\n",[39,1596,1597],{"class":41,"line":124},[39,1598,121],{"emptyLinePlaceholder":120},[39,1600,1601,1604,1606],{"class":41,"line":148},[39,1602,1603],{"class":94},"wb ",[39,1605,130],{"class":90},[39,1607,1608],{"class":94}," Workbook()\n",[39,1610,1611,1614,1616],{"class":41,"line":198},[39,1612,1613],{"class":94},"ws ",[39,1615,130],{"class":90},[39,1617,1618],{"class":94}," wb.active\n",[39,1620,1621,1624,1626],{"class":41,"line":218},[39,1622,1623],{"class":94},"ws.title ",[39,1625,130],{"class":90},[39,1627,1628],{"class":49}," \"Rota 5 Oct\"\n",[39,1630,1631,1634,1636,1639,1641,1643,1646,1648,1651,1653,1656],{"class":41,"line":300},[39,1632,1633],{"class":94},"ws.append([",[39,1635,319],{"class":49},[39,1637,1638],{"class":94},"] ",[39,1640,497],{"class":90},[39,1642,284],{"class":151},[39,1644,1645],{"class":90}," +",[39,1647,158],{"class":94},[39,1649,1650],{"class":49},"\"Shifts\"",[39,1652,164],{"class":94},[39,1654,1655],{"class":49},"\"Max\"",[39,1657,322],{"class":94},[39,1659,1660,1662,1664,1666],{"class":41,"line":305},[39,1661,276],{"class":90},[39,1663,453],{"class":94},[39,1665,281],{"class":90},[39,1667,774],{"class":94},[39,1669,1670,1673,1675,1677,1680,1682,1685,1687,1689,1691,1694,1696,1698,1700,1702,1704,1707,1709,1711,1713,1715],{"class":41,"line":325},[39,1671,1672],{"class":94},"    cells ",[39,1674,130],{"class":90},[39,1676,158],{"class":94},[39,1678,1679],{"class":49},"\"E\"",[39,1681,232],{"class":90},[39,1683,1684],{"class":94}," assigned[p, d, ",[39,1686,208],{"class":49},[39,1688,1638],{"class":94},[39,1690,690],{"class":90},[39,1692,1693],{"class":49}," \"L\"",[39,1695,232],{"class":90},[39,1697,1684],{"class":94},[39,1699,213],{"class":49},[39,1701,1638],{"class":94},[39,1703,690],{"class":90},[39,1705,1706],{"class":49}," \"\"",[39,1708,287],{"class":90},[39,1710,235],{"class":94},[39,1712,281],{"class":90},[39,1714,284],{"class":151},[39,1716,195],{"class":94},[39,1718,1719,1722,1724,1727,1729],{"class":41,"line":367},[39,1720,1721],{"class":94},"    r ",[39,1723,130],{"class":90},[39,1725,1726],{"class":94}," ws.max_row ",[39,1728,497],{"class":90},[39,1730,1731],{"class":151}," 1\n",[39,1733,1734,1737,1739,1742,1744,1746,1749,1752,1756,1759,1762,1765,1767,1769,1771,1774],{"class":41,"line":397},[39,1735,1736],{"class":94},"    ws.append([p] ",[39,1738,497],{"class":90},[39,1740,1741],{"class":94}," cells ",[39,1743,497],{"class":90},[39,1745,158],{"class":94},[39,1747,1748],{"class":90},"f",[39,1750,1751],{"class":49},"'=COUNTIF(B",[39,1753,1755],{"class":1754},"sSjpA","{",[39,1757,1758],{"class":94},"r",[39,1760,1761],{"class":1754},"}",[39,1763,1764],{"class":49},":H",[39,1766,1755],{"class":1754},[39,1768,1758],{"class":94},[39,1770,1761],{"class":1754},[39,1772,1773],{"class":49},",\"?\")'",[39,1775,1776],{"class":94},", max_shifts[p]])\n",[39,1778,1779,1782,1784],{"class":41,"line":422},[39,1780,1781],{"class":94},"last ",[39,1783,130],{"class":90},[39,1785,1786],{"class":94}," ws.max_row\n",[39,1788,1789,1791,1794,1796,1799,1802,1804,1806,1809,1812,1814,1817],{"class":41,"line":427},[39,1790,276],{"class":90},[39,1792,1793],{"class":94}," label, code ",[39,1795,281],{"class":90},[39,1797,1798],{"class":94}," ((",[39,1800,1801],{"class":49},"\"Early cover\"",[39,1803,164],{"class":94},[39,1805,1679],{"class":49},[39,1807,1808],{"class":94},"), (",[39,1810,1811],{"class":49},"\"Late cover\"",[39,1813,164],{"class":94},[39,1815,1816],{"class":49},"\"L\"",[39,1818,1819],{"class":94},")):\n",[39,1821,1822,1824,1826,1828,1830],{"class":41,"line":480},[39,1823,1721],{"class":94},[39,1825,130],{"class":90},[39,1827,1726],{"class":94},[39,1829,497],{"class":90},[39,1831,1731],{"class":151},[39,1833,1834,1837,1839,1841,1843,1846,1848,1850,1852,1855,1857,1859,1862,1865,1867,1870,1872,1874,1876,1879,1881,1884,1886,1889],{"class":41,"line":519},[39,1835,1836],{"class":94},"    ws.append([label] ",[39,1838,497],{"class":90},[39,1840,158],{"class":94},[39,1842,1748],{"class":90},[39,1844,1845],{"class":49},"'=COUNTIF(",[39,1847,1755],{"class":1754},[39,1849,1362],{"class":94},[39,1851,1761],{"class":1754},[39,1853,1854],{"class":49},"2:",[39,1856,1755],{"class":1754},[39,1858,1362],{"class":94},[39,1860,1861],{"class":1754},"}{",[39,1863,1864],{"class":94},"last",[39,1866,1761],{"class":1754},[39,1868,1869],{"class":49},",\"",[39,1871,1755],{"class":1754},[39,1873,14],{"class":94},[39,1875,1761],{"class":1754},[39,1877,1878],{"class":49},"\")'",[39,1880,287],{"class":90},[39,1882,1883],{"class":94}," c ",[39,1885,281],{"class":90},[39,1887,1888],{"class":49}," \"BCDEFGH\"",[39,1890,322],{"class":94},[39,1892,1893,1896,1898,1901,1903,1906,1908,1911,1914,1916,1919,1921,1923,1925,1927,1930,1932,1934,1937,1939,1942,1944,1947,1949,1951,1953,1955,1957],{"class":41,"line":539},[39,1894,1895],{"class":94},"    ws.append([",[39,1897,1748],{"class":90},[39,1899,1900],{"class":49},"\"",[39,1902,1755],{"class":1754},[39,1904,1905],{"class":94},"label[:",[39,1907,902],{"class":90},[39,1909,1910],{"class":151},"6",[39,1912,1913],{"class":94},"]",[39,1915,1761],{"class":1754},[39,1917,1918],{"class":49}," needed\"",[39,1920,1638],{"class":94},[39,1922,497],{"class":90},[39,1924,158],{"class":94},[39,1926,221],{"class":151},[39,1928,1929],{"class":94},"[d, ",[39,1931,208],{"class":49},[39,1933,232],{"class":90},[39,1935,1936],{"class":94}," code ",[39,1938,262],{"class":90},[39,1940,1941],{"class":49}," \"E\"",[39,1943,268],{"class":90},[39,1945,1946],{"class":49}," \"Late\"",[39,1948,1638],{"class":94},[39,1950,276],{"class":90},[39,1952,235],{"class":94},[39,1954,281],{"class":90},[39,1956,284],{"class":151},[39,1958,322],{"class":94},[39,1960,1961],{"class":41,"line":890},[39,1962,121],{"emptyLinePlaceholder":120},[39,1964,1965,1968,1970,1973,1975,1978,1981,1983,1985,1987,1990],{"class":41,"line":922},[39,1966,1967],{"class":94},"fills ",[39,1969,130],{"class":90},[39,1971,1972],{"class":94}," {",[39,1974,1679],{"class":49},[39,1976,1977],{"class":94},": ",[39,1979,1980],{"class":49},"\"FFF2CC\"",[39,1982,164],{"class":94},[39,1984,1816],{"class":49},[39,1986,1977],{"class":94},[39,1988,1989],{"class":49},"\"D9E1F2\"",[39,1991,297],{"class":94},[39,1993,1994,1996,1999,2001],{"class":41,"line":954},[39,1995,276],{"class":90},[39,1997,1998],{"class":94}," code, colour ",[39,2000,281],{"class":90},[39,2002,2003],{"class":94}," fills.items():\n",[39,2005,2006,2009,2011,2014,2016,2018,2020,2022,2025,2028,2030,2033,2035,2038,2040,2042,2044,2047,2049,2051,2053,2056],{"class":41,"line":986},[39,2007,2008],{"class":94},"    ws.conditional_formatting.add(",[39,2010,1748],{"class":90},[39,2012,2013],{"class":49},"\"B2:H",[39,2015,1755],{"class":1754},[39,2017,1864],{"class":94},[39,2019,1761],{"class":1754},[39,2021,1900],{"class":49},[39,2023,2024],{"class":94},", CellIsRule(",[39,2026,2027],{"class":1361},"operator",[39,2029,130],{"class":90},[39,2031,2032],{"class":49},"\"equal\"",[39,2034,164],{"class":94},[39,2036,2037],{"class":1361},"formula",[39,2039,130],{"class":90},[39,2041,943],{"class":94},[39,2043,1748],{"class":90},[39,2045,2046],{"class":49},"'\"",[39,2048,1755],{"class":1754},[39,2050,14],{"class":94},[39,2052,1761],{"class":1754},[39,2054,2055],{"class":49},"\"'",[39,2057,2058],{"class":94},"],\n",[39,2060,2061,2064,2066,2069,2072,2074,2077,2079],{"class":41,"line":991},[39,2062,2063],{"class":1361},"                                  fill",[39,2065,130],{"class":90},[39,2067,2068],{"class":94},"PatternFill(",[39,2070,2071],{"class":49},"\"solid\"",[39,2073,164],{"class":94},[39,2075,2076],{"class":1361},"fgColor",[39,2078,130],{"class":90},[39,2080,2081],{"class":94},"colour)))\n",[39,2083,2084,2086,2088,2090,2093,2095,2097,2100,2102,2105],{"class":41,"line":1002},[39,2085,276],{"class":90},[39,2087,359],{"class":94},[39,2089,281],{"class":90},[39,2091,2092],{"class":94}," (last ",[39,2094,497],{"class":90},[39,2096,271],{"class":151},[39,2098,2099],{"class":94},", last ",[39,2101,497],{"class":90},[39,2103,2104],{"class":151}," 3",[39,2106,2107],{"class":94},"):\n",[39,2109,2111,2113,2115,2118,2120,2122,2124,2126,2128,2130,2132,2134,2137,2139,2141,2143,2145,2147,2149,2151,2153,2156,2158,2161,2163,2165,2167,2169],{"class":41,"line":2110},24,[39,2112,2008],{"class":94},[39,2114,1748],{"class":90},[39,2116,2117],{"class":49},"\"B",[39,2119,1755],{"class":1754},[39,2121,1758],{"class":94},[39,2123,1761],{"class":1754},[39,2125,1764],{"class":49},[39,2127,1755],{"class":1754},[39,2129,1758],{"class":94},[39,2131,1761],{"class":1754},[39,2133,1900],{"class":49},[39,2135,2136],{"class":94},", FormulaRule(",[39,2138,2037],{"class":1361},[39,2140,130],{"class":90},[39,2142,943],{"class":94},[39,2144,1748],{"class":90},[39,2146,2117],{"class":49},[39,2148,1755],{"class":1754},[39,2150,1758],{"class":94},[39,2152,1761],{"class":1754},[39,2154,2155],{"class":49},"\u003C>B",[39,2157,1755],{"class":1754},[39,2159,2160],{"class":94},"r ",[39,2162,497],{"class":90},[39,2164,271],{"class":151},[39,2166,1761],{"class":1754},[39,2168,1900],{"class":49},[39,2170,2058],{"class":94},[39,2172,2174,2176,2178,2180,2182,2184,2186,2188,2191],{"class":41,"line":2173},25,[39,2175,2063],{"class":1361},[39,2177,130],{"class":90},[39,2179,2068],{"class":94},[39,2181,2071],{"class":49},[39,2183,164],{"class":94},[39,2185,2076],{"class":1361},[39,2187,130],{"class":90},[39,2189,2190],{"class":49},"\"FFC7CE\"",[39,2192,2193],{"class":94},")))\n",[39,2195,2197,2199,2202,2204,2207,2210,2212,2214,2216,2219,2221],{"class":41,"line":2196},26,[39,2198,276],{"class":90},[39,2200,2201],{"class":94}," row ",[39,2203,281],{"class":90},[39,2205,2206],{"class":94}," ws.iter_rows(",[39,2208,2209],{"class":1361},"min_row",[39,2211,130],{"class":90},[39,2213,744],{"class":151},[39,2215,164],{"class":94},[39,2217,2218],{"class":1361},"max_row",[39,2220,130],{"class":90},[39,2222,2223],{"class":94},"ws.max_row):\n",[39,2225,2227,2229,2231,2233,2236,2238],{"class":41,"line":2226},27,[39,2228,664],{"class":90},[39,2230,1883],{"class":94},[39,2232,281],{"class":90},[39,2234,2235],{"class":94}," row[",[39,2237,744],{"class":151},[39,2239,2240],{"class":94},":]:\n",[39,2242,2244,2247,2249,2252,2255,2257,2260],{"class":41,"line":2243},28,[39,2245,2246],{"class":94},"        c.alignment ",[39,2248,130],{"class":90},[39,2250,2251],{"class":94}," Alignment(",[39,2253,2254],{"class":1361},"horizontal",[39,2256,130],{"class":90},[39,2258,2259],{"class":49},"\"center\"",[39,2261,145],{"class":94},[39,2263,2265,2267,2269,2271,2274,2276],{"class":41,"line":2264},29,[39,2266,276],{"class":90},[39,2268,1883],{"class":94},[39,2270,281],{"class":90},[39,2272,2273],{"class":94}," ws[",[39,2275,744],{"class":151},[39,2277,2278],{"class":94},"]:\n",[39,2280,2282,2285,2287,2290,2293,2295,2298],{"class":41,"line":2281},30,[39,2283,2284],{"class":94},"    c.font ",[39,2286,130],{"class":90},[39,2288,2289],{"class":94}," Font(",[39,2291,2292],{"class":1361},"bold",[39,2294,130],{"class":90},[39,2296,2297],{"class":151},"True",[39,2299,145],{"class":94},[39,2301,2303,2306,2309,2312,2314],{"class":41,"line":2302},31,[39,2304,2305],{"class":94},"ws.column_dimensions[",[39,2307,2308],{"class":49},"\"A\"",[39,2310,2311],{"class":94},"].width ",[39,2313,130],{"class":90},[39,2315,2316],{"class":151}," 14\n",[39,2318,2320,2323,2326],{"class":41,"line":2319},32,[39,2321,2322],{"class":94},"wb.save(",[39,2324,2325],{"class":49},"\"rota_2026-10-05.xlsx\"",[39,2327,145],{"class":94},[39,2329,2331,2333,2335,2337],{"class":41,"line":2330},33,[39,2332,276],{"class":90},[39,2334,453],{"class":94},[39,2336,281],{"class":90},[39,2338,774],{"class":94},[39,2340,2342,2345,2347,2349,2351,2353,2355,2358,2360,2362,2364,2367,2370,2372,2374,2376,2378,2380,2382,2384,2386,2388,2390,2392,2394,2397,2399,2401,2403,2405,2407],{"class":41,"line":2341},34,[39,2343,2344],{"class":151},"    print",[39,2346,378],{"class":94},[39,2348,1748],{"class":90},[39,2350,1900],{"class":49},[39,2352,1755],{"class":1754},[39,2354,10],{"class":94},[39,2356,2357],{"class":90},":6",[39,2359,1761],{"class":1754},[39,2361,1900],{"class":49},[39,2363,164],{"class":94},[39,2365,2366],{"class":49},"\" \"",[39,2368,2369],{"class":94},".join((",[39,2371,1679],{"class":49},[39,2373,232],{"class":90},[39,2375,1684],{"class":94},[39,2377,208],{"class":49},[39,2379,1638],{"class":94},[39,2381,690],{"class":90},[39,2383,1693],{"class":49},[39,2385,232],{"class":90},[39,2387,1684],{"class":94},[39,2389,213],{"class":49},[39,2391,1638],{"class":94},[39,2393,690],{"class":90},[39,2395,2396],{"class":49}," \"·\"",[39,2398,253],{"class":94},[39,2400,276],{"class":90},[39,2402,235],{"class":94},[39,2404,281],{"class":90},[39,2406,284],{"class":151},[39,2408,2409],{"class":94},"))\n",[10,2411,2412,2413,2416,2417,2420],{},"Earlies are shaded yellow and Lates blue by conditional formatting, so the pattern of the week is visible at a glance. The coverage rows are ",[14,2414,2415],{},"COUNTIF"," formulas comparing each day's count with the need underneath, and a rule turns any mismatch red. That matters after publication: when a manager swaps two shifts by hand, the sheet itself shows whether cover still holds. ",[14,2418,2419],{},"COUNTIF(..., \"?\")"," counts cells with exactly one character — any shift — for the per-person total.",[1025,2422,1034,2428,1034,2431,1034,2434,1034,2439,1034,2443,1034,2447,1034,2453,1034,2457,1034,2461,1034,2465,1034,2469,1034,2473,1034,2477,1034,2481,1034,2485,1034,2489,1034,2493,1034,2496,1034,2500,1034,2504,1034,2508,1034,2511,1034,2515,1034,2518,1034,2522,1034,2524,1034,2527,1034,2529,1034,2532,1034,2534,1034,2537,1034,2539,1034,2542,1034,2544,1034,2547,1034,2549,1034,2552,1034,2554,1034,2557,1034,2560,1034,2563,1034,2565,1034,2569,1034,2571,1034,2573,1034,2575,1034,2577,1034,2579,1034,2581,1034,2583,1034,2585,1034,2587,1034,2589,1034,2591,1034,2594,1034,2598,1034,2600,1034,2603,1034,2605,1034,2608,1034,2610,1034,2612,1034,2614,1034,2616,1034,2618,1034,2620,1034,2622,1034,2624,1034,2626,1034,2628,1034,2630,1034,2633,1034,2635,1034,2638,1034,2640,1034,2642,1034,2644,1034,2646,1034,2648,1034,2650,1034,2652,1034,2654,1034,2656,1034,2658,1034,2661,1034,2665,1034,2669,1034,2673,1034,2675,1034,2677,1034,2679,1034,2681,1034,2683,1034,2685,1034,2687,1034,2689,1034,2691,1034,2693,1034,2695,1034,2697,1034,2699,1034,2701,1034,2703,1034,2706,1034,2708,1034,2711,1034,2713,1034,2715,1034,2717,1034,2719,1034,2721,1034,2723,1034,2725,1034,2727,1034,2729,1034,2731,1034,2733,1034,2735,1034,2737],{"viewBox":2423,"role":1028,"ariaLabelledBy":2424,"xmlns":1032,"style":2427},"145 0 469 224",[2425,2426],"ro-sheet-t","ro-sheet-d","width:100%;max-width:469px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif",[1036,2429,2430],{"id":2425},"The generated rota",[1040,2432,2433],{"id":2426},"People down the side and days across, with E for Early and L for Late. Each person works within their maximum, nobody works a Late followed by an Early, nobody works a shift type they asked to avoid, Dev is the one person on both weekend days, and the coverage rows match the need for every day.",[1044,2435],{"x":2436,"y":810,"width":2437,"height":2438,"fill":1049},"145","469","224",[1044,2440],{"x":2441,"y":1053,"width":1086,"height":2442,"rx":810,"fill":1083,"stroke":1056,"style":1057},"161.8","24.0",[1044,2444],{"x":2445,"y":1053,"width":2446,"height":2442,"rx":810,"fill":1083,"stroke":1056,"style":1057},"193.8","84.7",[1066,2448,2452],{"x":2449,"y":2450,"style":2451},"236.1","33.0","font-size:11px;font-weight:600;fill:var(--muted,#5b6780);text-anchor:middle","A",[1044,2454],{"x":2455,"y":1053,"width":2456,"height":2442,"rx":810,"fill":1083,"stroke":1056,"style":1057},"278.5","47.9",[1066,2458,2460],{"x":2459,"y":2450,"style":2451},"302.5","B",[1044,2462],{"x":2463,"y":1053,"width":2464,"height":2442,"rx":810,"fill":1083,"stroke":1056,"style":1057},"326.4","44.8",[1066,2466,2468],{"x":2467,"y":2450,"style":2451},"348.8","C",[1044,2470],{"x":2471,"y":1053,"width":2472,"height":2442,"rx":810,"fill":1083,"stroke":1056,"style":1057},"371.2","49.0",[1066,2474,2476],{"x":2475,"y":2450,"style":2451},"395.7","D",[1044,2478],{"x":2479,"y":1053,"width":2480,"height":2442,"rx":810,"fill":1083,"stroke":1056,"style":1057},"420.2","45.1",[1066,2482,2484],{"x":2483,"y":2450,"style":2451},"442.7","E",[1044,2486],{"x":2487,"y":1053,"width":2488,"height":2442,"rx":810,"fill":1083,"stroke":1056,"style":1057},"465.3","44.0",[1066,2490,2492],{"x":2491,"y":2450,"style":2451},"487.3","F",[1044,2494],{"x":2495,"y":1053,"width":2488,"height":2442,"rx":810,"fill":1083,"stroke":1056,"style":1057},"509.3",[1066,2497,2499],{"x":2498,"y":2450,"style":2451},"531.3","G",[1044,2501],{"x":2502,"y":1053,"width":2503,"height":2442,"rx":810,"fill":1083,"stroke":1056,"style":1057},"553.3","45.0",[1066,2505,2507],{"x":2506,"y":2450,"style":2451},"575.7","H",[1044,2509],{"x":2441,"y":1055,"width":1086,"height":2510,"rx":810,"fill":1083,"stroke":1056,"style":1057},"28.0",[1066,2512,744],{"x":2513,"y":2514,"style":2451},"177.8","59.0",[1044,2516],{"x":2445,"y":1055,"width":2446,"height":2510,"rx":810,"fill":1062,"stroke":1063,"style":2517},"stroke-width:1.5px",[1066,2519,2521],{"x":2449,"y":2514,"style":2520},"font-size:10.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","Name",[1044,2523],{"x":2455,"y":1055,"width":2456,"height":2510,"rx":810,"fill":1062,"stroke":1063,"style":2517},[1066,2525,2526],{"x":2459,"y":2514,"style":2520},"Mon",[1044,2528],{"x":2463,"y":1055,"width":2464,"height":2510,"rx":810,"fill":1062,"stroke":1063,"style":2517},[1066,2530,2531],{"x":2467,"y":2514,"style":2520},"Tue",[1044,2533],{"x":2471,"y":1055,"width":2472,"height":2510,"rx":810,"fill":1062,"stroke":1063,"style":2517},[1066,2535,2536],{"x":2475,"y":2514,"style":2520},"Wed",[1044,2538],{"x":2479,"y":1055,"width":2480,"height":2510,"rx":810,"fill":1062,"stroke":1063,"style":2517},[1066,2540,2541],{"x":2483,"y":2514,"style":2520},"Thu",[1044,2543],{"x":2487,"y":1055,"width":2488,"height":2510,"rx":810,"fill":1062,"stroke":1063,"style":2517},[1066,2545,2546],{"x":2491,"y":2514,"style":2520},"Fri",[1044,2548],{"x":2495,"y":1055,"width":2488,"height":2510,"rx":810,"fill":1062,"stroke":1063,"style":2517},[1066,2550,2551],{"x":2498,"y":2514,"style":2520},"Sat",[1044,2553],{"x":2502,"y":1055,"width":2503,"height":2510,"rx":810,"fill":1062,"stroke":1063,"style":2517},[1066,2555,2556],{"x":2506,"y":2514,"style":2520},"Sun",[1044,2558],{"x":2441,"y":2559,"width":1086,"height":2510,"rx":810,"fill":1083,"stroke":1056,"style":1057},"68.0",[1066,2561,229],{"x":2513,"y":2562,"style":2451},"87.0",[1044,2564],{"x":2445,"y":2559,"width":2446,"height":2510,"rx":810,"fill":1049,"stroke":1056,"style":1057},[1066,2566,2568],{"x":2449,"y":2562,"style":2567},"font-size:10.5px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","Ana",[1044,2570],{"x":2455,"y":2559,"width":2456,"height":2510,"rx":810,"fill":1049,"stroke":1056,"style":1057},[1044,2572],{"x":2463,"y":2559,"width":2464,"height":2510,"rx":810,"fill":1049,"stroke":1056,"style":1057},[1044,2574],{"x":2471,"y":2559,"width":2472,"height":2510,"rx":810,"fill":1049,"stroke":1056,"style":1057},[1066,2576,2484],{"x":2475,"y":2562,"style":2567},[1044,2578],{"x":2479,"y":2559,"width":2480,"height":2510,"rx":810,"fill":1049,"stroke":1056,"style":1057},[1066,2580,2484],{"x":2483,"y":2562,"style":2567},[1044,2582],{"x":2487,"y":2559,"width":2488,"height":2510,"rx":810,"fill":1049,"stroke":1056,"style":1057},[1066,2584,2484],{"x":2491,"y":2562,"style":2567},[1044,2586],{"x":2495,"y":2559,"width":2488,"height":2510,"rx":810,"fill":1049,"stroke":1056,"style":1057},[1066,2588,2484],{"x":2498,"y":2562,"style":2567},[1044,2590],{"x":2502,"y":2559,"width":2503,"height":2510,"rx":810,"fill":1049,"stroke":1056,"style":1057},[1044,2592],{"x":2441,"y":2593,"width":1086,"height":2510,"rx":810,"fill":1083,"stroke":1056,"style":1057},"96.0",[1066,2595,2597],{"x":2513,"y":2596,"style":2451},"115.0","3",[1044,2599],{"x":2445,"y":2593,"width":2446,"height":2510,"rx":810,"fill":1049,"stroke":1056,"style":1057},[1066,2601,2602],{"x":2449,"y":2596,"style":2567},"Eva",[1044,2604],{"x":2455,"y":2593,"width":2456,"height":2510,"rx":810,"fill":1049,"stroke":1056,"style":1057},[1066,2606,2607],{"x":2459,"y":2596,"style":2567},"L",[1044,2609],{"x":2463,"y":2593,"width":2464,"height":2510,"rx":810,"fill":1049,"stroke":1056,"style":1057},[1066,2611,2607],{"x":2467,"y":2596,"style":2567},[1044,2613],{"x":2471,"y":2593,"width":2472,"height":2510,"rx":810,"fill":1049,"stroke":1056,"style":1057},[1066,2615,2607],{"x":2475,"y":2596,"style":2567},[1044,2617],{"x":2479,"y":2593,"width":2480,"height":2510,"rx":810,"fill":1049,"stroke":1056,"style":1057},[1044,2619],{"x":2487,"y":2593,"width":2488,"height":2510,"rx":810,"fill":1049,"stroke":1056,"style":1057},[1066,2621,2607],{"x":2491,"y":2596,"style":2567},[1044,2623],{"x":2495,"y":2593,"width":2488,"height":2510,"rx":810,"fill":1049,"stroke":1056,"style":1057},[1066,2625,2607],{"x":2498,"y":2596,"style":2567},[1044,2627],{"x":2502,"y":2593,"width":2503,"height":2510,"rx":810,"fill":1049,"stroke":1056,"style":1057},[1044,2629],{"x":2441,"y":1122,"width":1086,"height":2510,"rx":810,"fill":1083,"stroke":1056,"style":1057},[1066,2631,1046],{"x":2513,"y":2632,"style":2451},"143.0",[1044,2634],{"x":2445,"y":1122,"width":2446,"height":2510,"rx":810,"fill":1049,"stroke":1056,"style":1057},[1066,2636,2637],{"x":2449,"y":2632,"style":2567},"Dev",[1044,2639],{"x":2455,"y":1122,"width":2456,"height":2510,"rx":810,"fill":1049,"stroke":1056,"style":1057},[1044,2641],{"x":2463,"y":1122,"width":2464,"height":2510,"rx":810,"fill":1049,"stroke":1056,"style":1057},[1044,2643],{"x":2471,"y":1122,"width":2472,"height":2510,"rx":810,"fill":1049,"stroke":1056,"style":1057},[1044,2645],{"x":2479,"y":1122,"width":2480,"height":2510,"rx":810,"fill":1049,"stroke":1056,"style":1057},[1044,2647],{"x":2487,"y":1122,"width":2488,"height":2510,"rx":810,"fill":1049,"stroke":1056,"style":1057},[1066,2649,2484],{"x":2491,"y":2632,"style":2567},[1044,2651],{"x":2495,"y":1122,"width":2488,"height":2510,"rx":810,"fill":1049,"stroke":1056,"style":1057},[1066,2653,2484],{"x":2498,"y":2632,"style":2567},[1044,2655],{"x":2502,"y":1122,"width":2503,"height":2510,"rx":810,"fill":1049,"stroke":1056,"style":1057},[1066,2657,2607],{"x":2506,"y":2632,"style":2567},[1044,2659],{"x":2441,"y":2660,"width":1086,"height":2510,"rx":810,"fill":1083,"stroke":1056,"style":1057},"152.0",[1066,2662,2664],{"x":2513,"y":2663,"style":2451},"171.0","5",[1044,2666],{"x":2445,"y":2660,"width":2446,"height":2510,"rx":810,"fill":2667,"stroke":2668,"style":2517},"#d9f4f1","var(--teal,#0f9488)",[1066,2670,2672],{"x":2449,"y":2663,"style":2671},"font-size:10.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","Early cover",[1044,2674],{"x":2455,"y":2660,"width":2456,"height":2510,"rx":810,"fill":2667,"stroke":2668,"style":2517},[1066,2676,229],{"x":2459,"y":2663,"style":2671},[1044,2678],{"x":2463,"y":2660,"width":2464,"height":2510,"rx":810,"fill":2667,"stroke":2668,"style":2517},[1066,2680,229],{"x":2467,"y":2663,"style":2671},[1044,2682],{"x":2471,"y":2660,"width":2472,"height":2510,"rx":810,"fill":2667,"stroke":2668,"style":2517},[1066,2684,229],{"x":2475,"y":2663,"style":2671},[1044,2686],{"x":2479,"y":2660,"width":2480,"height":2510,"rx":810,"fill":2667,"stroke":2668,"style":2517},[1066,2688,229],{"x":2483,"y":2663,"style":2671},[1044,2690],{"x":2487,"y":2660,"width":2488,"height":2510,"rx":810,"fill":2667,"stroke":2668,"style":2517},[1066,2692,229],{"x":2491,"y":2663,"style":2671},[1044,2694],{"x":2495,"y":2660,"width":2488,"height":2510,"rx":810,"fill":2667,"stroke":2668,"style":2517},[1066,2696,229],{"x":2498,"y":2663,"style":2671},[1044,2698],{"x":2502,"y":2660,"width":2503,"height":2510,"rx":810,"fill":2667,"stroke":2668,"style":2517},[1066,2700,229],{"x":2506,"y":2663,"style":2671},[1044,2702],{"x":2441,"y":1143,"width":1086,"height":2510,"rx":810,"fill":1083,"stroke":1056,"style":1057},[1066,2704,1910],{"x":2513,"y":2705,"style":2451},"199.0",[1044,2707],{"x":2445,"y":1143,"width":2446,"height":2510,"rx":810,"fill":2667,"stroke":2668,"style":2517},[1066,2709,2710],{"x":2449,"y":2705,"style":2671},"Late cover",[1044,2712],{"x":2455,"y":1143,"width":2456,"height":2510,"rx":810,"fill":2667,"stroke":2668,"style":2517},[1066,2714,229],{"x":2459,"y":2705,"style":2671},[1044,2716],{"x":2463,"y":1143,"width":2464,"height":2510,"rx":810,"fill":2667,"stroke":2668,"style":2517},[1066,2718,229],{"x":2467,"y":2705,"style":2671},[1044,2720],{"x":2471,"y":1143,"width":2472,"height":2510,"rx":810,"fill":2667,"stroke":2668,"style":2517},[1066,2722,229],{"x":2475,"y":2705,"style":2671},[1044,2724],{"x":2479,"y":1143,"width":2480,"height":2510,"rx":810,"fill":2667,"stroke":2668,"style":2517},[1066,2726,229],{"x":2483,"y":2705,"style":2671},[1044,2728],{"x":2487,"y":1143,"width":2488,"height":2510,"rx":810,"fill":2667,"stroke":2668,"style":2517},[1066,2730,229],{"x":2491,"y":2705,"style":2671},[1044,2732],{"x":2495,"y":1143,"width":2488,"height":2510,"rx":810,"fill":2667,"stroke":2668,"style":2517},[1066,2734,744],{"x":2498,"y":2705,"style":2671},[1044,2736],{"x":2502,"y":1143,"width":2503,"height":2510,"rx":810,"fill":2667,"stroke":2668,"style":2517},[1066,2738,744],{"x":2506,"y":2705,"style":2671},[25,2740,2742],{"id":2741},"step-5-verify-every-rule-in-the-result","Step 5 — Verify every rule in the result",[10,2744,2745],{},"Never trust a solver's output blindly — check it against the rules independently, from the written workbook:",[30,2747,2749],{"className":81,"code":2748,"language":83,"meta":35,"style":35},"from openpyxl import load_workbook\n\nws = load_workbook(\"rota_2026-10-05.xlsx\")[\"Rota 5 Oct\"]\ngrid = {ws.cell(r, 1).value: [ws.cell(r, c).value or \"\" for c in range(2, 9)] for r in range(2, 2 + len(people))}\n\nproblems = []\nfor d_i, d in enumerate(DAYS):\n    for s, code in ((\"Early\", \"E\"), (\"Late\", \"L\")):\n        n = sum(row[d_i] == code for row in grid.values())\n        if n != NEED[d, s]:\n            problems.append(f\"{d} {s}: {n} of {NEED[d, s]}\")\nfor p, row in grid.items():\n    if sum(bool(v) for v in row) > max_shifts[p]:\n        problems.append(f\"{p} over maximum\")\n    if any(row[DAYS.index(d)] for d in unavailable[p]):\n        problems.append(f\"{p} rostered when unavailable\")\n    if avoid[p] and any(v == avoid[p][0] for v in row):\n        problems.append(f\"{p} given a disliked {avoid[p]} shift\")\n    for i in range(6):\n        if row[i] == \"L\" and row[i + 1] == \"E\":\n            problems.append(f\"{p}: Late {DAYS[i]} then Early {DAYS[i + 1]}\")\nprint(\"rule violations:\", problems or \"none\")\nprint(\"shifts per person:\", {p: sum(bool(v) for v in row) for p, row in grid.items()})\n",[14,2750,2751,2762,2766,2785,2848,2852,2862,2879,2906,2932,2948,2994,3006,3035,3055,3079,3098,3130,3159,3176,3205,3253,3272],{"__ignoreMap":35},[39,2752,2753,2755,2757,2759],{"class":41,"line":42},[39,2754,611],{"class":90},[39,2756,1565],{"class":94},[39,2758,91],{"class":90},[39,2760,2761],{"class":94}," load_workbook\n",[39,2763,2764],{"class":41,"line":104},[39,2765,121],{"emptyLinePlaceholder":120},[39,2767,2768,2770,2772,2775,2777,2780,2783],{"class":41,"line":117},[39,2769,1613],{"class":94},[39,2771,130],{"class":90},[39,2773,2774],{"class":94}," load_workbook(",[39,2776,2325],{"class":49},[39,2778,2779],{"class":94},")[",[39,2781,2782],{"class":49},"\"Rota 5 Oct\"",[39,2784,195],{"class":94},[39,2786,2787,2790,2792,2795,2797,2800,2802,2804,2806,2808,2810,2813,2815,2817,2819,2822,2825,2827,2829,2831,2833,2835,2837,2839,2841,2843,2845],{"class":41,"line":124},[39,2788,2789],{"class":94},"grid ",[39,2791,130],{"class":90},[39,2793,2794],{"class":94}," {ws.cell(r, ",[39,2796,744],{"class":151},[39,2798,2799],{"class":94},").value: [ws.cell(r, c).value ",[39,2801,256],{"class":90},[39,2803,1706],{"class":49},[39,2805,287],{"class":90},[39,2807,1883],{"class":94},[39,2809,281],{"class":90},[39,2811,2812],{"class":151}," range",[39,2814,378],{"class":94},[39,2816,229],{"class":151},[39,2818,164],{"class":94},[39,2820,2821],{"class":151},"9",[39,2823,2824],{"class":94},")] ",[39,2826,276],{"class":90},[39,2828,359],{"class":94},[39,2830,281],{"class":90},[39,2832,2812],{"class":151},[39,2834,378],{"class":94},[39,2836,229],{"class":151},[39,2838,164],{"class":94},[39,2840,229],{"class":151},[39,2842,1645],{"class":90},[39,2844,527],{"class":151},[39,2846,2847],{"class":94},"(people))}\n",[39,2849,2850],{"class":41,"line":148},[39,2851,121],{"emptyLinePlaceholder":120},[39,2853,2854,2857,2859],{"class":41,"line":198},[39,2855,2856],{"class":94},"problems ",[39,2858,130],{"class":90},[39,2860,2861],{"class":94}," []\n",[39,2863,2864,2866,2869,2871,2873,2875,2877],{"class":41,"line":218},[39,2865,276],{"class":90},[39,2867,2868],{"class":94}," d_i, d ",[39,2870,281],{"class":90},[39,2872,445],{"class":151},[39,2874,378],{"class":94},[39,2876,152],{"class":151},[39,2878,2107],{"class":94},[39,2880,2881,2883,2886,2888,2890,2892,2894,2896,2898,2900,2902,2904],{"class":41,"line":300},[39,2882,664],{"class":90},[39,2884,2885],{"class":94}," s, code ",[39,2887,281],{"class":90},[39,2889,1798],{"class":94},[39,2891,208],{"class":49},[39,2893,164],{"class":94},[39,2895,1679],{"class":49},[39,2897,1808],{"class":94},[39,2899,213],{"class":49},[39,2901,164],{"class":94},[39,2903,1816],{"class":49},[39,2905,1819],{"class":94},[39,2907,2908,2911,2913,2916,2919,2921,2923,2925,2927,2929],{"class":41,"line":305},[39,2909,2910],{"class":94},"        n ",[39,2912,130],{"class":90},[39,2914,2915],{"class":151}," sum",[39,2917,2918],{"class":94},"(row[d_i] ",[39,2920,262],{"class":90},[39,2922,1936],{"class":94},[39,2924,276],{"class":90},[39,2926,2201],{"class":94},[39,2928,281],{"class":90},[39,2930,2931],{"class":94}," grid.values())\n",[39,2933,2934,2937,2940,2942,2945],{"class":41,"line":325},[39,2935,2936],{"class":90},"        if",[39,2938,2939],{"class":94}," n ",[39,2941,1420],{"class":90},[39,2943,2944],{"class":151}," NEED",[39,2946,2947],{"class":94},"[d, s]:\n",[39,2949,2950,2953,2955,2957,2959,2961,2963,2965,2967,2969,2971,2973,2976,2978,2981,2983,2985,2988,2990,2992],{"class":41,"line":367},[39,2951,2952],{"class":94},"            problems.append(",[39,2954,1748],{"class":90},[39,2956,1900],{"class":49},[39,2958,1755],{"class":1754},[39,2960,593],{"class":94},[39,2962,1761],{"class":1754},[39,2964,1972],{"class":1754},[39,2966,589],{"class":94},[39,2968,1761],{"class":1754},[39,2970,1977],{"class":49},[39,2972,1755],{"class":1754},[39,2974,2975],{"class":94},"n",[39,2977,1761],{"class":1754},[39,2979,2980],{"class":49}," of ",[39,2982,1755],{"class":1754},[39,2984,221],{"class":151},[39,2986,2987],{"class":94},"[d, s]",[39,2989,1761],{"class":1754},[39,2991,1900],{"class":49},[39,2993,145],{"class":94},[39,2995,2996,2998,3001,3003],{"class":41,"line":397},[39,2997,276],{"class":90},[39,2999,3000],{"class":94}," p, row ",[39,3002,281],{"class":90},[39,3004,3005],{"class":94}," grid.items():\n",[39,3007,3008,3010,3012,3014,3017,3020,3022,3025,3027,3030,3032],{"class":41,"line":422},[39,3009,1298],{"class":90},[39,3011,2915],{"class":151},[39,3013,378],{"class":94},[39,3015,3016],{"class":151},"bool",[39,3018,3019],{"class":94},"(v) ",[39,3021,276],{"class":90},[39,3023,3024],{"class":94}," v ",[39,3026,281],{"class":90},[39,3028,3029],{"class":94}," row) ",[39,3031,1532],{"class":90},[39,3033,3034],{"class":94}," max_shifts[p]:\n",[39,3036,3037,3040,3042,3044,3046,3048,3050,3053],{"class":41,"line":427},[39,3038,3039],{"class":94},"        problems.append(",[39,3041,1748],{"class":90},[39,3043,1900],{"class":49},[39,3045,1755],{"class":1754},[39,3047,10],{"class":94},[39,3049,1761],{"class":1754},[39,3051,3052],{"class":49}," over maximum\"",[39,3054,145],{"class":94},[39,3056,3057,3059,3062,3065,3067,3070,3072,3074,3076],{"class":41,"line":480},[39,3058,1298],{"class":90},[39,3060,3061],{"class":151}," any",[39,3063,3064],{"class":94},"(row[",[39,3066,152],{"class":151},[39,3068,3069],{"class":94},".index(d)] ",[39,3071,276],{"class":90},[39,3073,235],{"class":94},[39,3075,281],{"class":90},[39,3077,3078],{"class":94}," unavailable[p]):\n",[39,3080,3081,3083,3085,3087,3089,3091,3093,3096],{"class":41,"line":519},[39,3082,3039],{"class":94},[39,3084,1748],{"class":90},[39,3086,1900],{"class":49},[39,3088,1755],{"class":1754},[39,3090,10],{"class":94},[39,3092,1761],{"class":1754},[39,3094,3095],{"class":49}," rostered when unavailable\"",[39,3097,145],{"class":94},[39,3099,3100,3102,3104,3107,3109,3112,3114,3117,3119,3121,3123,3125,3127],{"class":41,"line":539},[39,3101,1298],{"class":90},[39,3103,1301],{"class":94},[39,3105,3106],{"class":90},"and",[39,3108,3061],{"class":151},[39,3110,3111],{"class":94},"(v ",[39,3113,262],{"class":90},[39,3115,3116],{"class":94}," avoid[p][",[39,3118,810],{"class":151},[39,3120,1638],{"class":94},[39,3122,276],{"class":90},[39,3124,3024],{"class":94},[39,3126,281],{"class":90},[39,3128,3129],{"class":94}," row):\n",[39,3131,3132,3134,3136,3138,3140,3142,3144,3147,3149,3152,3154,3157],{"class":41,"line":890},[39,3133,3039],{"class":94},[39,3135,1748],{"class":90},[39,3137,1900],{"class":49},[39,3139,1755],{"class":1754},[39,3141,10],{"class":94},[39,3143,1761],{"class":1754},[39,3145,3146],{"class":49}," given a disliked ",[39,3148,1755],{"class":1754},[39,3150,3151],{"class":94},"avoid[p]",[39,3153,1761],{"class":1754},[39,3155,3156],{"class":49}," shift\"",[39,3158,145],{"class":94},[39,3160,3161,3163,3166,3168,3170,3172,3174],{"class":41,"line":922},[39,3162,664],{"class":90},[39,3164,3165],{"class":94}," i ",[39,3167,281],{"class":90},[39,3169,2812],{"class":151},[39,3171,378],{"class":94},[39,3173,1910],{"class":151},[39,3175,2107],{"class":94},[39,3177,3178,3180,3183,3185,3187,3190,3193,3195,3197,3199,3201,3203],{"class":41,"line":954},[39,3179,2936],{"class":90},[39,3181,3182],{"class":94}," row[i] ",[39,3184,262],{"class":90},[39,3186,1693],{"class":49},[39,3188,3189],{"class":90}," and",[39,3191,3192],{"class":94}," row[i ",[39,3194,497],{"class":90},[39,3196,271],{"class":151},[39,3198,1638],{"class":94},[39,3200,262],{"class":90},[39,3202,1941],{"class":49},[39,3204,720],{"class":94},[39,3206,3207,3209,3211,3213,3215,3217,3219,3222,3224,3226,3229,3231,3234,3236,3238,3241,3243,3245,3247,3249,3251],{"class":41,"line":986},[39,3208,2952],{"class":94},[39,3210,1748],{"class":90},[39,3212,1900],{"class":49},[39,3214,1755],{"class":1754},[39,3216,10],{"class":94},[39,3218,1761],{"class":1754},[39,3220,3221],{"class":49},": Late ",[39,3223,1755],{"class":1754},[39,3225,152],{"class":151},[39,3227,3228],{"class":94},"[i]",[39,3230,1761],{"class":1754},[39,3232,3233],{"class":49}," then Early ",[39,3235,1755],{"class":1754},[39,3237,152],{"class":151},[39,3239,3240],{"class":94},"[i ",[39,3242,497],{"class":90},[39,3244,271],{"class":151},[39,3246,1913],{"class":94},[39,3248,1761],{"class":1754},[39,3250,1900],{"class":49},[39,3252,145],{"class":94},[39,3254,3255,3257,3259,3262,3265,3267,3270],{"class":41,"line":991},[39,3256,542],{"class":151},[39,3258,378],{"class":94},[39,3260,3261],{"class":49},"\"rule violations:\"",[39,3263,3264],{"class":94},", problems ",[39,3266,256],{"class":90},[39,3268,3269],{"class":49}," \"none\"",[39,3271,145],{"class":94},[39,3273,3274,3276,3278,3281,3284,3286,3288,3290,3292,3294,3296,3298,3300,3302,3304,3306],{"class":41,"line":1002},[39,3275,542],{"class":151},[39,3277,378],{"class":94},[39,3279,3280],{"class":49},"\"shifts per person:\"",[39,3282,3283],{"class":94},", {p: ",[39,3285,557],{"class":151},[39,3287,378],{"class":94},[39,3289,3016],{"class":151},[39,3291,3019],{"class":94},[39,3293,276],{"class":90},[39,3295,3024],{"class":94},[39,3297,281],{"class":90},[39,3299,3029],{"class":94},[39,3301,276],{"class":90},[39,3303,3000],{"class":94},[39,3305,281],{"class":90},[39,3307,3308],{"class":94}," grid.items()})\n",[10,3310,3311],{},"The check reads the rota back from Excel — the version people will see — and tests cover, maximums, availability, preferences and turnarounds without using any of the solver's code. No violations, and the shift counts show how the 26 shifts were shared: Ben, Eva and Farid at their maximum of five, Ana and Chloé on four, and Dev on three, including both weekend days. Keep this check even if you trust the model: it catches mistakes in the model itself, such as an off-by-one in the turnaround pairs, and it can be rerun on a rota after manual edits.",[1025,3313,1034,3318,1034,3321,1034,3324,1034,3327,1034,3333,1034,3344,1034,3350,1034,3354,1034,3359,1034,3364,1034,3367,1034,3374,1034,3379,1034,3382,1034,3386,1034,3389,1034,3392,1034,3399,1034,3404,1034,3407,1034,3411,1034,3414,1034,3419,1034,3426,1034,3432,1034,3436],{"viewBox":3314,"role":1028,"ariaLabelledBy":3315,"xmlns":1032,"style":1033},"4 0 752 142",[3316,3317],"ro-flow-t","ro-flow-d",[1036,3319,3320],{"id":3316},"From availability to a checked rota",[1040,3322,3323],{"id":3317},"Staff availability, maximums and preferences and the cover needed per shift become yes-no variables and linear constraints. SciPy's milp solver finds the rota with the fewest disliked shifts. The rota is written to Excel with coverage formulas and then checked against every rule.",[1044,3325],{"x":1046,"y":810,"width":1047,"height":3326,"fill":1049},"142",[1044,3328],{"x":1052,"y":1053,"width":3329,"height":3330,"rx":3331,"fill":1083,"stroke":1056,"style":3332},"153.0","110.5","12","stroke-width:2px",[3334,3335,3337],"g",{"transform":3336},"translate(84.5,37.8) scale(1.000)",[3338,3339],"path",{"d":3340,"fill":3341,"stroke":3342,"style":3343},"M9 11a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7z M2.5 20c0-3.6 2.9-6 6.5-6s6.5 2.4 6.5 6 M16 4.5a3.3 3.3 0 0 1 0 6.3 M18 14.3c2.2.7 3.5 2.8 3.5 5.7","none","var(--text,#172033)","stroke-width:1.80px;stroke-linecap:round;stroke-linejoin:round",[1066,3345,3349],{"x":3346,"y":3347,"style":3348},"96.5","84.2","font-size:13.5px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","Inputs",[1066,3351,3353],{"x":3346,"y":3352,"style":1095},"103.8","availability, max, cover",[41,3355],{"x1":3356,"y1":3357,"x2":3358,"y2":3357,"stroke":1063,"style":3332},"178.0","71.2","200.0",[3360,3361],"polygon",{"points":3362,"fill":3363},"204.0,71.2 195.0,76.2 195.0,66.2","#5b5cf0",[1044,3365],{"x":3366,"y":1053,"width":3329,"height":3330,"rx":3331,"fill":1062,"stroke":1063,"style":3332},"209.0",[3334,3368,3370],{"transform":3369},"translate(273.5,37.8) scale(1.000)",[3338,3371],{"d":3372,"fill":3341,"stroke":3373,"style":3343},"M9 8l-4 4 4 4 M15 8l4 4-4 4","var(--brand-strong,#4338ca)",[1066,3375,3378],{"x":3376,"y":3347,"style":3377},"285.5","font-size:13.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","Model",[1066,3380,3381],{"x":3376,"y":3352,"style":1095},"84 yes\u002Fno variables",[41,3383],{"x1":3384,"y1":3357,"x2":3385,"y2":3357,"stroke":1063,"style":3332},"367.0","389.0",[3360,3387],{"points":3388,"fill":3363},"393.0,71.2 384.0,76.2 384.0,66.2",[1044,3390],{"x":3391,"y":1053,"width":3329,"height":3330,"rx":3331,"fill":2667,"stroke":2668,"style":3332},"398.0",[3334,3393,3395],{"transform":3394},"translate(462.5,37.8) scale(1.000)",[3338,3396],{"d":3397,"fill":3341,"stroke":3398,"style":3343},"M12 9a3 3 0 1 0 .01 0z M12 2.5v3 M12 18.5v3 M2.5 12h3 M18.5 12h3 M5.3 5.3l2.1 2.1 M16.6 16.6l2.1 2.1 M5.3 18.7l2.1-2.1 M16.6 7.4l2.1-2.1","var(--teal-ink,#0b6157)",[1066,3400,3403],{"x":3401,"y":3347,"style":3402},"474.5","font-size:13.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","milp (HiGHS)",[1066,3405,3406],{"x":3401,"y":3352,"style":1095},"fewest disliked shifts",[41,3408],{"x1":3409,"y1":3357,"x2":3410,"y2":3357,"stroke":1063,"style":3332},"556.0","578.0",[3360,3412],{"points":3413,"fill":3363},"582.0,71.2 573.0,76.2 573.0,66.2",[1044,3415],{"x":3416,"y":1053,"width":3329,"height":3330,"rx":3331,"fill":3417,"stroke":3418,"style":3332},"587.0","#fdefd8","var(--gold,#b4740a)",[3334,3420,3422],{"transform":3421},"translate(651.5,29.0) scale(1.000)",[3338,3423],{"d":3424,"fill":3341,"stroke":3425,"style":3343},"M5 12.5l4.5 4.5L19 7","var(--gold-ink,#7a4e06)",[1066,3427,3431],{"x":3428,"y":3429,"style":3430},"663.5","75.5","font-size:13.5px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","Excel + check",[1066,3433,3435],{"x":3428,"y":3434,"style":1095},"95.0","COUNTIF cover, rule",[1066,3437,3439],{"x":3428,"y":3438,"style":1095},"112.5","test",[25,3441,3443],{"id":3442},"common-pitfalls","Common pitfalls",[3445,3446,3447,3455,3461,3467,3476],"ul",{},[3448,3449,3450,3454],"li",{},[3451,3452,3453],"strong",{},"More shifts needed than people can work."," Check supply against demand before solving; otherwise the model is infeasible.",[3448,3456,3457,3460],{},[3451,3458,3459],{},"Preferences as hard rules."," Put wishes — and fairness rules that may conflict, like weekends — in the objective so a rota still exists.",[3448,3462,3463,3466],{},[3451,3464,3465],{},"Forgetting rest between shifts."," Add the Late-then-Early constraint explicitly.",[3448,3468,3469,3472,3473,3475],{},[3451,3470,3471],{},"Static cover numbers in Excel."," Use ",[14,3474,2415],{}," formulas so manual swaps are checked too.",[3448,3477,3478,3481],{},[3451,3479,3480],{},"Trusting the solver blindly."," Verify the written rota against the rules independently.",[25,3483,3485],{"id":3484},"conclusion","Conclusion",[10,3487,3488,3489,3491,3492,3494],{},"A weekly rota is a small integer programme: one yes\u002Fno variable per person, day and shift; constraints for cover, one shift a day, contracted maximums, fair weekends, rest between shifts and availability; and an objective that penalises disliked shifts. SciPy's ",[14,3490,16],{}," solves it in moments without extra software. Write the result as a colour-coded Excel grid with ",[14,3493,2415],{}," coverage rows that stay correct after manual swaps, and check every rule from the written file before the rota is published.",[25,3496,3498],{"id":3497},"frequently-asked-questions","Frequently asked questions",[10,3500,3501,3504],{},[3451,3502,3503],{},"How do I create a shift rota automatically with Python?","\nCreate a binary variable for each person, day and shift, express cover requirements and rules as linear constraints, give disliked shifts a higher cost, and solve with scipy.optimize.milp. Then write the assignments to an Excel grid.",[10,3506,3507,3510],{},[3451,3508,3509],{},"Do I need a separate solver to schedule shifts in Python?","\nNo. SciPy 1.9 and later include milp, which uses the bundled HiGHS solver and handles rotas for teams of this size quickly.",[10,3512,3513,3516],{},[3451,3514,3515],{},"How do I stop someone working an early shift straight after a late shift?","\nFor each person and each pair of consecutive days, add a constraint that the Late variable for the first day plus the Early variable for the next day is at most 1.",[10,3518,3519,3522],{},[3451,3520,3521],{},"How can Excel show whether a rota still covers every shift?","\nAdd rows with COUNTIF formulas counting each day's E and L cells, compare them with the required numbers, and highlight mismatches with conditional formatting.",[25,3524,3526],{"id":3525},"related","Related",[3445,3528,3529,3535,3542,3549],{},[3448,3530,3531,3532,3534],{},"Up one level: ",[19,3533,22],{"href":21}," — finance and operations reports in Excel.",[3448,3536,3537,3541],{},[19,3538,3540],{"href":3539},"\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fbuild-a-staff-utilisation-report-from-timesheets\u002F","Build a Staff Utilisation Report from Timesheets"," — measuring the hours worked.",[3448,3543,3544,3548],{},[19,3545,3547],{"href":3546},"\u002Fformatting-and-charting-excel-reports-with-python\u002Fstyling-excel-cells-with-openpyxl\u002Fbuild-a-calendar-sheet-in-excel-with-python\u002F","Build a Calendar Sheet in Excel with Python"," — another grid laid out by day.",[3448,3550,3551,3555],{},[19,3552,3554],{"href":3553},"\u002Fadvanced-data-transformation-and-cleaning\u002Fapplying-conditional-formatting-with-openpyxl\u002Fhighlight-an-entire-row-based-on-a-cell-value-with-openpyxl\u002F","Highlight an Entire Row Based on a Cell Value with openpyxl"," — formula-based conditional formatting.",[3557,3558,3559],"style",{},"html pre.shiki code .sMTad, html code.shiki .sMTad{--shiki-default:#6F42C1;--shiki-dark:#FFB757}html pre.shiki code .srMev, html code.shiki .srMev{--shiki-default:#032F62;--shiki-dark:#ADDCFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .s-kum, html code.shiki .s-kum{--shiki-default:#D73A49;--shiki-dark:#FF9492}html pre.shiki code .skGVy, html code.shiki .skGVy{--shiki-default:#24292E;--shiki-dark:#F0F3F6}html pre.shiki code .sP0c6, html code.shiki .sP0c6{--shiki-default:#005CC5;--shiki-dark:#91CBFF}html pre.shiki code .s-wDw, html code.shiki .s-wDw{--shiki-default:#6A737D;--shiki-dark:#BDC4CC}html pre.shiki code .s_Opv, html code.shiki .s_Opv{--shiki-default:#6F42C1;--shiki-dark:#DBB7FF}html pre.shiki code .sa561, html code.shiki .sa561{--shiki-default:#E36209;--shiki-dark:#FFB757}html pre.shiki code .sSjpA, html code.shiki .sSjpA{--shiki-default:#005CC5;--shiki-dark:#FF9492}",{"title":35,"searchDepth":104,"depth":104,"links":3561},[3562,3563,3564,3565,3566,3567,3568,3569,3570,3571],{"id":27,"depth":104,"text":28},{"id":74,"depth":104,"text":75},{"id":597,"depth":104,"text":598},{"id":1194,"depth":104,"text":1195},{"id":1549,"depth":104,"text":1550},{"id":2741,"depth":104,"text":2742},{"id":3442,"depth":104,"text":3443},{"id":3484,"depth":104,"text":3485},{"id":3497,"depth":104,"text":3498},{"id":3525,"depth":104,"text":3526},"2026-10-02","Build a weekly shift rota automatically from staff availability and cover requirements: model it as a small optimisation with SciPy's milp solver, enforce one shift a day, contracted maximums, no late-then-early turnarounds and fair weekends, respect shift preferences, then write a colour-coded Excel rota with live coverage checks and verify every rule.","md",[3576,3578,3580,3582],{"q":3503,"a":3577},"Create a binary variable for each person, day and shift, express cover requirements and rules as linear constraints, give disliked shifts a higher cost, and solve with scipy.optimize.milp. Then write the assignments to an Excel grid.",{"q":3509,"a":3579},"No. SciPy 1.9 and later include milp, which uses the bundled HiGHS solver and handles rotas for teams of this size quickly.",{"q":3515,"a":3581},"For each person and each pair of consecutive days, add a constraint that the Late variable for the first day plus the Early variable for the next day is at most 1.",{"q":3521,"a":3583},"Add rows with COUNTIF formulas counting each day's E and L cells, compare them with the required numbers, and highlight mismatches with conditional formatting.","generate a staff rota in excel with python",null,{"breadcrumb":3587},[3588,3591,3594],{"name":3589,"item":3590},"Home","\u002F",{"name":3592,"item":3593},"Automating Reporting Workflows with Python and Excel","\u002Fautomating-reporting-workflows\u002F",{"name":3595,"item":21},"Building Finance and Operations Reports in Excel with Python","\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fgenerate-a-staff-rota-in-excel-with-python",{"title":3598,"description":3599},"Generate a Staff Rota in Excel with Python and SciPy","Automatic shift scheduling with scipy.optimize.milp: cover per shift, availability, max shifts, no late-to-early turnarounds, fair weekends and preferences, written to a colour-coded Excel rota with COUNTIF coverage checks.","generate-a-staff-rota-in-excel-with-python","automating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fgenerate-a-staff-rota-in-excel-with-python\u002Findex",[3603,3604],"report-layouts","cell-styling","how-to","Hh2DezuumbuGB7TDwRclFhzVHYtu5FR7CGkhjuCiI5w",[3608,3612],{"title":3609,"path":3610,"stem":3611,"children":-1},"Convert Multi-Currency Transactions with an FX Rate Table","\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fconvert-multi-currency-transactions-with-an-fx-rate-table","automating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fconvert-multi-currency-transactions-with-an-fx-rate-table\u002Findex",{"title":3613,"path":3614,"stem":3615,"children":-1},"Model the Impact of a Price Increase","\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fmodel-the-impact-of-a-price-increase","automating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fmodel-the-impact-of-a-price-increase\u002Findex",[3617,3619],{"path":3618,"title":3595},"\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports",{"path":3620,"title":3592},"\u002Fautomating-reporting-workflows",[],[3623,3627,3630,3633,3636,3639],{"path":3624,"title":3625,"kind":3626},"\u002Fautomating-reporting-workflows\u002Fbuilding-multi-sheet-excel-dashboards","Building Multi-Sheet Excel Dashboards","guide",{"path":3628,"title":3629,"kind":3605},"\u002Fautomating-reporting-workflows\u002Fbuilding-multi-sheet-excel-dashboards\u002Fadd-a-data-dictionary-sheet-to-an-excel-report","Add a Data Dictionary Sheet to an Excel Report",{"path":3631,"title":3632,"kind":3605},"\u002Fautomating-reporting-workflows\u002Fbuilding-multi-sheet-excel-dashboards\u002Fadd-a-kpi-summary-block-to-an-excel-dashboard","Add a KPI Summary Block to an Excel Dashboard",{"path":3634,"title":3635,"kind":3605},"\u002Fautomating-reporting-workflows\u002Fbuilding-multi-sheet-excel-dashboards\u002Fadd-a-table-of-contents-sheet-with-hyperlinks-in-excel","Add a Table of Contents Sheet with Hyperlinks in Excel",{"path":3637,"title":3638,"kind":3605},"\u002Fautomating-reporting-workflows\u002Fbuilding-multi-sheet-excel-dashboards\u002Fadd-slicer-style-filters-to-an-excel-dashboard","Add a Dropdown Filter That Drives an Excel Dashboard",{"path":3640,"title":3641,"kind":3605},"\u002Fautomating-reporting-workflows\u002Fbuilding-multi-sheet-excel-dashboards\u002Fadd-summary-sheet-to-excel-report-python","Add a Summary Sheet to an Excel Report with Python",1790972128015]