From 9a3d36414d3bf7ab64a26e574d01d109e079af9a Mon Sep 17 00:00:00 2001 From: Ignace Bleukx Date: Fri, 24 Oct 2025 14:58:41 +0200 Subject: [PATCH 01/11] start of nr benchmark --- examples/nurserostering.py | 213 +++++++++++++++++++++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 examples/nurserostering.py diff --git a/examples/nurserostering.py b/examples/nurserostering.py new file mode 100644 index 000000000..b96fd4715 --- /dev/null +++ b/examples/nurserostering.py @@ -0,0 +1,213 @@ +""" +PyTorch-style Dataset for Nurserostering instances from schedulingbenchmarks.org + +Simply create a dataset instance and start iterating over its contents: +The `metadata` contains usefull information about the current problem instance. +""" +import json +import pathlib +from io import StringIO +from os.path import join +from typing import Tuple, Any +from urllib.request import urlretrieve +from urllib.error import HTTPError, URLError +import zipfile + +import faker +import numpy as np +import xml.etree.ElementTree as ET +import pandas as pd +from natsort import natsorted + +pd.set_option('display.max_columns', 500) +pd.set_option('display.width', 5000) + + + + +import cpmpy as cp + +class NurseRosteringDataset(object): # torch.utils.data.Dataset compatible + + """ + Nurserostering Dataset in a PyTorch compatible format. + + Arguments: + root (str): Root directory containing the nurserostering instances (if 'download', instances will be downloaded to this location) + transform (callable, optional): Optional transform to be applied on the instance data + target_transform (callable, optional): Optional transform to be applied on the file path + download (bool): If True, downloads the dataset from the internet and puts it in `root` directory + """ + + def __init__(self, root: str = ".", transform=None, target_transform=None, download: bool = False): + """ + Initialize the Nurserostering Dataset. + """ + + self.root = pathlib.Path(root) + self.instance_dir = pathlib.Path(join(self.root, "nurserostering")) + self.transform = transform + self.target_transform = target_transform + + # Create root directory if it doesn't exist + self.root.mkdir(parents=True, exist_ok=True) + + print(self.instance_dir, self.instance_dir.exists(), self.instance_dir.is_dir()) + if not self.instance_dir.exists(): + if not download: + raise ValueError(f"Dataset not found in local file system. Please set download=True to download the dataset.") + else: + url = f"https://schedulingbenchmarks.org/nrp/data/instances1_24.zip" # download full repo... + zip_path = pathlib.Path(join(root,"jsplib-master.zip")) + + print(f"Downloading Nurserostering instances from schedulingbenchmarks.org") + + try: + urlretrieve(url, str(zip_path)) + except (HTTPError, URLError) as e: + raise ValueError(f"No dataset available on {url}. Error: {str(e)}") + + # make directory and extract files + with zipfile.ZipFile(zip_path, 'r') as zip_ref: + self.instance_dir.mkdir(parents=True, exist_ok=True) + + # Extract files + for file_info in zip_ref.infolist(): + filename = pathlib.Path(file_info.filename).name + with zip_ref.open(file_info) as source, open(self.instance_dir / filename, 'wb') as target: + target.write(source.read()) + + # Clean up the zip file + zip_path.unlink() + + + def __len__(self) -> int: + """Return the total number of instances.""" + return len(list(self.instance_dir.glob("*"))) + + def __getitem__(self, index: int) -> Tuple[Any, Any]: + """ + Get a single RCPSP instance filename and metadata. + + Args: + index (int or str): Index or name of the instance to retrieve + + Returns: + Tuple[Any, Any]: A tuple containing: + - The filename of the instance + - Metadata dictionary with file name, track, year etc. + """ + if isinstance(index, int) and (index < 0 or index >= len(self)): + raise IndexError("Index out of range") + + # Get all instance files and sort for deterministic behavior # TODO: use natsort instead? + files = natsorted(list(self.instance_dir.glob("*.txt"))) # use .txt files instead of xml files + file_path = files[index] + + filename = str(file_path) + if self.transform: + # does not need to remain a filename... + filename = self.transform(filename) + + metadata = dict(name=file_path.stem) + + if self.target_transform: + metadata = self.target_transform(metadata) + + return filename, metadata + + +import re +def _tag_to_data(string, tag, skip_lines=0, datatype=pd.DataFrame, *args, **kwargs): + + regex = rf'{tag}[\s\S]*?($|(?=\n\s*\n))' + match = re.search(regex, string) + + data = "\n".join(match.group().split("\n")[skip_lines+1:]) + if datatype == pd.DataFrame: + kwargs = {"header":0, "index_col":0} | kwargs + df = pd.read_csv(StringIO(data), *args, **kwargs) + return df.rename(columns=lambda x: x.strip()) + return datatype(data, *args, **kwargs) + +def parse_scheduling_period(fname): + from faker import Faker + fake = Faker() + fake.seed_instance(0) + + with open(fname, "r") as f: + string = f.read() + + + horizon = _tag_to_data(string, "SECTION_HORIZON", skip_lines=2, datatype=int) + shifts = _tag_to_data(string, "SECTION_SHIFTS", names=["ShiftID", "Length", "cannot follow"], + dtype={'ShiftID':str, 'Length':int, 'cannot follow':str}) + shifts.fillna("", inplace=True) + shifts["cannot follow"] = shifts["cannot follow"].apply(lambda val : val.split("|")) + + staff = _tag_to_data(string, "SECTION_STAFF", index_col=False) + maxes = staff["MaxShifts"].str.split("|", expand=True) + for col in maxes: + shift_id = maxes[col].iloc[0].split("=")[0] + column = maxes[col].apply(lambda x : x.split("=")[1]) + staff[f"max_shifts_{shift_id}"] = column.astype(int) + + staff["name"] = [fake.unique.first_name() for _ in staff.index] + + days_off = _tag_to_data(string, "SECTION_DAYS_OFF", datatype=str) + # process string to be EmployeeID, Day off for each line + rows = [] + for line in days_off.split("\n")[1:]: + employee_id , *days = line.split(",") + rows += [dict(EmployeeID=employee_id, DayIndex= int(d)) for d in days] + days_off = pd.DataFrame(rows) + + + shift_on = _tag_to_data(string, "SECTION_SHIFT_ON_REQUESTS", index_col=False) + shift_off = _tag_to_data(string, "SECTION_SHIFT_OFF_REQUESTS", index_col=False) + cover = _tag_to_data(string, "SECTION_COVER", index_col=False) + + return dict(horizon=horizon, shifts=shifts, staff=staff, days_off=days_off, shift_on=shift_on, shift_off=shift_off, cover=cover) + + +def nurserostering_model(horizon, shifts:pd.DataFrame, staff, days_off, shift_on, shift_off, cover): + + n_nurses = len(staff) + + FREE = 0 + SHIFTS = list(shifts.index) + + nurse_view = cp.intvar(0,len(shifts), shape=(n_nurses, horizon), name="nv") + + model = cp.Model() + + # Shifts which cannot follow the shift on the previous day. + for id, shift in enumerate(shifts.iterrows()): + for other_shift in shift['cannot follow']: + model += (nurse_view[:,:-1] == id).implies(nurse_view[:,1:] != other_shift) + + # Maximum number of shifts of each type that can be assigned to each employee. + for _, nurse in staff.iterrows(): + + + n = self.nurse_map.index(nurse['# ID']) + for shift_id, shift in self.data.shifts.iterrows(): + n_shifts = cp.Count(self.nurse_view[n], self.shift_name_to_idx[shift_id]) + max_shifts = nurse[f"max_shifts_{shift_id}"] + cons = n_shifts <= max_shifts + cons.set_description(f"{nurse['name']} can work at most {max_shifts} {shift_id}-shifts") + cons.visualize = get_visualizer(n, shift_id) + constraints.append(cons) + + return constraints + +if __name__ == "__main__": + + dataset = NurseRosteringDataset(root=".", download=True, transform=parse_scheduling_period) + print("Dataset size:", len(dataset)) + print("Instance 0:") + data, metadata = dataset[1] + print(metadata) + for key, df in data.items(): + print(key) + print(df) \ No newline at end of file From 3cf21d9ad746e20d9e3e7a2a0fe94dc26386a16a Mon Sep 17 00:00:00 2001 From: IgnaceBleukx Date: Mon, 10 Nov 2025 16:17:24 +0100 Subject: [PATCH 02/11] finish model --- examples/nurserostering.py | 132 +++++++++++++++++++++++++++++-------- 1 file changed, 105 insertions(+), 27 deletions(-) diff --git a/examples/nurserostering.py b/examples/nurserostering.py index b96fd4715..6615c1f8a 100644 --- a/examples/nurserostering.py +++ b/examples/nurserostering.py @@ -4,7 +4,7 @@ Simply create a dataset instance and start iterating over its contents: The `metadata` contains usefull information about the current problem instance. """ -import json +import copy import pathlib from io import StringIO from os.path import join @@ -13,9 +13,7 @@ from urllib.error import HTTPError, URLError import zipfile -import faker -import numpy as np -import xml.etree.ElementTree as ET +from faker import Faker import pandas as pd from natsort import natsorted @@ -83,14 +81,14 @@ def __init__(self, root: str = ".", transform=None, target_transform=None, downl def __len__(self) -> int: """Return the total number of instances.""" - return len(list(self.instance_dir.glob("*"))) + return len(list(self.instance_dir.glob("*.txt"))) def __getitem__(self, index: int) -> Tuple[Any, Any]: """ - Get a single RCPSP instance filename and metadata. + Get a single Nurserostering instance filename and metadata. Args: - index (int or str): Index or name of the instance to retrieve + index (int): Index of the instance to retrieve Returns: Tuple[Any, Any]: A tuple containing: @@ -127,11 +125,10 @@ def _tag_to_data(string, tag, skip_lines=0, datatype=pd.DataFrame, *args, **kwar if datatype == pd.DataFrame: kwargs = {"header":0, "index_col":0} | kwargs df = pd.read_csv(StringIO(data), *args, **kwargs) - return df.rename(columns=lambda x: x.strip()) + return df.rename(columns=lambda x: x.replace("#","").strip()) return datatype(data, *args, **kwargs) def parse_scheduling_period(fname): - from faker import Faker fake = Faker() fake.seed_instance(0) @@ -143,7 +140,7 @@ def parse_scheduling_period(fname): shifts = _tag_to_data(string, "SECTION_SHIFTS", names=["ShiftID", "Length", "cannot follow"], dtype={'ShiftID':str, 'Length':int, 'cannot follow':str}) shifts.fillna("", inplace=True) - shifts["cannot follow"] = shifts["cannot follow"].apply(lambda val : val.split("|")) + shifts["cannot follow"] = shifts["cannot follow"].apply(lambda val : [v.strip() for v in val.split("|") if len(v.strip())]) staff = _tag_to_data(string, "SECTION_STAFF", index_col=False) maxes = staff["MaxShifts"].str.split("|", expand=True) @@ -175,39 +172,120 @@ def nurserostering_model(horizon, shifts:pd.DataFrame, staff, days_off, shift_on n_nurses = len(staff) FREE = 0 - SHIFTS = list(shifts.index) + SHIFTS = ["F"] + list(shifts.index) nurse_view = cp.intvar(0,len(shifts), shape=(n_nurses, horizon), name="nv") model = cp.Model() # Shifts which cannot follow the shift on the previous day. - for id, shift in enumerate(shifts.iterrows()): + for id, shift in shifts.iterrows(): for other_shift in shift['cannot follow']: - model += (nurse_view[:,:-1] == id).implies(nurse_view[:,1:] != other_shift) + model += (nurse_view[:,:-1] == SHIFTS.index(id)).implies(nurse_view[:,1:] != SHIFTS.index(other_shift)) # Maximum number of shifts of each type that can be assigned to each employee. - for _, nurse in staff.iterrows(): - - - n = self.nurse_map.index(nurse['# ID']) - for shift_id, shift in self.data.shifts.iterrows(): - n_shifts = cp.Count(self.nurse_view[n], self.shift_name_to_idx[shift_id]) + for i, nurse in staff.iterrows(): + for shift_id, shift in shifts.iterrows(): max_shifts = nurse[f"max_shifts_{shift_id}"] - cons = n_shifts <= max_shifts - cons.set_description(f"{nurse['name']} can work at most {max_shifts} {shift_id}-shifts") - cons.visualize = get_visualizer(n, shift_id) - constraints.append(cons) - - return constraints + model += cp.Count(nurse_view[i], SHIFTS.index(shift_id)) <= max_shifts + + # Minimum and maximum amount of total time in minutes that can be assigned to each employee. + shift_length = cp.cpm_array([0] + shifts['Length'].tolist()) # FREE = length 0 + for i, nurse in staff.iterrows(): + time_worked = cp.sum(shift_length[nurse_view[i,d]] for d in range(horizon)) + model += time_worked <= nurse['MaxTotalMinutes'] + model += time_worked >= nurse['MinTotalMinutes'] + + # Maximum number of consecutive shifts that can be worked before having a day off. + for i, nurse in staff.iterrows(): + max_days = nurse['MaxConsecutiveShifts'] + for d in range(horizon - max_days): + window = nurse_view[i,d:d+max_days+1] + model += cp.Count(window, FREE) >= 1 # at least one holiday in this window + + # Minimum number of concecutive shifts that must be worked before having a day off. + for i, nurse in staff.iterrows(): + min_days = nurse['MinConsecutiveShifts'] + for d in range(1,horizon): + is_start_of_working_period = (nurse_view[i, d-1] == FREE) & (nurse_view[i, d] != FREE) + model += is_start_of_working_period.implies(cp.all(nurse_view[i,d:d+min_days] != FREE)) + + # Minimum number of concecutive days off. + for i, nurse in staff.iterrows(): + min_days = nurse['MinConsecutiveDaysOff'] + for d in range(1,horizon): + is_start_of_free_period = (nurse_view[i, d - 1] != FREE) & (nurse_view[i, d] == FREE) + model += is_start_of_free_period.implies(cp.all(nurse_view[i, d:d + min_days] == FREE)) + + # Max number of working weekends for each nurse + weekends = [(i - 1, i) for i in range(1,horizon) if (i + 1) % 7 == 0] + for i, nurse in staff.iterrows(): + n_weekends = cp.sum((nurse_view[i,sat] != FREE) | (nurse_view[i,sun] != FREE) for sat,sun in weekends) + model += n_weekends <= nurse['MaxWeekends'] + + # Days off + for _, holiday in days_off.iterrows(): # could also do this vectorized... TODO? + i = staff.index[staff['ID'] == holiday['EmployeeID']][0] + model += nurse_view[i,holiday['DayIndex']] == FREE + + # Shift requests, encode in linear objective + objective = 0 + for _, request in shift_on.iterrows(): + i = staff.index[staff['ID'] == request['EmployeeID']][0] + cpm_request = nurse_view[i, request['Day']] == SHIFTS.index(request['ShiftID']) + objective += request['Weight'] * ~cpm_request + + # Shift off requests, encode in linear objective + for _, request in shift_off.iterrows(): + i = staff.index[staff['ID'] == request['EmployeeID']][0] + cpm_request = nurse_view[i, request['Day']] != SHIFTS.index(request['ShiftID']) + objective += request['Weight'] * ~cpm_request + + # Cover constraints, encode in objective with slack variables + for _, cover_request in cover.iterrows(): + nb_nurses = cp.Count(nurse_view[:, cover_request['Day']], SHIFTS.index(cover_request['ShiftID'])) + slack_over, slack_under = cp.intvar(0, len(staff), shape=2) + model += nb_nurses - slack_over + slack_under == cover_request["Requirement"] + + objective += cover_request["Weight for over"] * slack_over + cover_request["Weight for under"] * slack_under + + model.minimize(objective) + + return model, nurse_view if __name__ == "__main__": dataset = NurseRosteringDataset(root=".", download=True, transform=parse_scheduling_period) print("Dataset size:", len(dataset)) - print("Instance 0:") data, metadata = dataset[1] print(metadata) for key, df in data.items(): print(key) - print(df) \ No newline at end of file + print(df) + + for key, value in data.items(): + print(key,":") + print(value) + + model, nurse_view = nurserostering_model(**data) + assert model.solve() + + print(f"Found optimal solution with penalty of {model.objective_value()}") + + # pretty print solution + names = ["-"] + data['shifts'].index.tolist() + sol = nurse_view.value() + df = pd.DataFrame(sol, index=data['staff'].name).map(names.__getitem__) + + for shift, _ in data['shifts'].iterrows(): + df.loc[f'Cover {shift}'] = "" + + for _, cover_request in data['cover'].iterrows(): + shift = cover_request['ShiftID'] + num_shifts = sum(df[cover_request['Day']] == shift) + df.loc[f"Cover {shift}",cover_request['Day']] = f"{num_shifts}/{cover_request['Requirement']}" + + days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] + df.columns = [days[(int(col)) % 7] for col in df.columns] + + print(df.to_markdown()) From bd62b881de271bc0195ceaf34dcab1de897b479f Mon Sep 17 00:00:00 2001 From: IgnaceBleukx Date: Mon, 10 Nov 2025 16:17:39 +0100 Subject: [PATCH 03/11] print data --- examples/nurserostering.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/examples/nurserostering.py b/examples/nurserostering.py index 6615c1f8a..34151b069 100644 --- a/examples/nurserostering.py +++ b/examples/nurserostering.py @@ -258,10 +258,6 @@ def nurserostering_model(horizon, shifts:pd.DataFrame, staff, days_off, shift_on dataset = NurseRosteringDataset(root=".", download=True, transform=parse_scheduling_period) print("Dataset size:", len(dataset)) data, metadata = dataset[1] - print(metadata) - for key, df in data.items(): - print(key) - print(df) for key, value in data.items(): print(key,":") From 13f09532bdfb93a4e3dbb54d8182841dc768f1c2 Mon Sep 17 00:00:00 2001 From: IgnaceBleukx Date: Fri, 14 Nov 2025 08:59:18 +0100 Subject: [PATCH 04/11] set to smallest instance --- examples/nurserostering.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/examples/nurserostering.py b/examples/nurserostering.py index 34151b069..72793c8c7 100644 --- a/examples/nurserostering.py +++ b/examples/nurserostering.py @@ -20,9 +20,6 @@ pd.set_option('display.max_columns', 500) pd.set_option('display.width', 5000) - - - import cpmpy as cp class NurseRosteringDataset(object): # torch.utils.data.Dataset compatible @@ -50,7 +47,6 @@ def __init__(self, root: str = ".", transform=None, target_transform=None, downl # Create root directory if it doesn't exist self.root.mkdir(parents=True, exist_ok=True) - print(self.instance_dir, self.instance_dir.exists(), self.instance_dir.is_dir()) if not self.instance_dir.exists(): if not download: raise ValueError(f"Dataset not found in local file system. Please set download=True to download the dataset.") @@ -257,7 +253,7 @@ def nurserostering_model(horizon, shifts:pd.DataFrame, staff, days_off, shift_on dataset = NurseRosteringDataset(root=".", download=True, transform=parse_scheduling_period) print("Dataset size:", len(dataset)) - data, metadata = dataset[1] + data, metadata = dataset[0] for key, value in data.items(): print(key,":") From 0fab7bd660b14c6015a2c3078b7cd8cd270eb728 Mon Sep 17 00:00:00 2001 From: Ignace Bleukx Date: Wed, 10 Dec 2025 12:43:59 +0100 Subject: [PATCH 05/11] better import error message --- examples/nurserostering.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/examples/nurserostering.py b/examples/nurserostering.py index 72793c8c7..54a01b296 100644 --- a/examples/nurserostering.py +++ b/examples/nurserostering.py @@ -12,10 +12,16 @@ from urllib.request import urlretrieve from urllib.error import HTTPError, URLError import zipfile - -from faker import Faker import pandas as pd -from natsort import natsorted + +try: + from faker import Faker +except ImportError: + print("Install `faker` package using `pip install faker`") +try: + from natsort import natsorted +except ImportError: + print("Install `natsort` package using `pip install natsort`") pd.set_option('display.max_columns', 500) pd.set_option('display.width', 5000) From 2b85268c53901c9bcd5f9d28e08c9b01f3308541 Mon Sep 17 00:00:00 2001 From: Ignace Bleukx Date: Wed, 10 Dec 2025 12:44:05 +0100 Subject: [PATCH 06/11] style --- examples/nurserostering.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/nurserostering.py b/examples/nurserostering.py index 54a01b296..353b5e77c 100644 --- a/examples/nurserostering.py +++ b/examples/nurserostering.py @@ -97,10 +97,10 @@ def __getitem__(self, index: int) -> Tuple[Any, Any]: - The filename of the instance - Metadata dictionary with file name, track, year etc. """ - if isinstance(index, int) and (index < 0 or index >= len(self)): + if isinstance(index, int) and not (0 <= index < len(self)): raise IndexError("Index out of range") - # Get all instance files and sort for deterministic behavior # TODO: use natsort instead? + # Get all instance files and sort for deterministic behavior files = natsorted(list(self.instance_dir.glob("*.txt"))) # use .txt files instead of xml files file_path = files[index] From 51004fbd24b33dfb6ca9dd59b3d0441d7921d978 Mon Sep 17 00:00:00 2001 From: Ignace Bleukx Date: Wed, 10 Dec 2025 12:44:35 +0100 Subject: [PATCH 07/11] update comment --- examples/nurserostering.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/nurserostering.py b/examples/nurserostering.py index 353b5e77c..3bea44e36 100644 --- a/examples/nurserostering.py +++ b/examples/nurserostering.py @@ -106,7 +106,7 @@ def __getitem__(self, index: int) -> Tuple[Any, Any]: filename = str(file_path) if self.transform: - # does not need to remain a filename... + # user might want to process the filename to something else filename = self.transform(filename) metadata = dict(name=file_path.stem) From 60343fbe900ebf1e61a4fe87f717cae15f2c2b75 Mon Sep 17 00:00:00 2001 From: Ignace Bleukx Date: Wed, 10 Dec 2025 12:48:27 +0100 Subject: [PATCH 08/11] add assert --- examples/nurserostering.py | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/nurserostering.py b/examples/nurserostering.py index 3bea44e36..5532c19d4 100644 --- a/examples/nurserostering.py +++ b/examples/nurserostering.py @@ -269,6 +269,7 @@ def nurserostering_model(horizon, shifts:pd.DataFrame, staff, days_off, shift_on assert model.solve() print(f"Found optimal solution with penalty of {model.objective_value()}") + assert model.objective_value() == 607 # optimal solution for the first instance # pretty print solution names = ["-"] + data['shifts'].index.tolist() From 200879861817f8d0d302dcbf679ce94ff1e8daa3 Mon Sep 17 00:00:00 2001 From: Ignace Bleukx Date: Wed, 10 Dec 2025 12:51:48 +0100 Subject: [PATCH 09/11] argmax to find first occ of employee id --- examples/nurserostering.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/nurserostering.py b/examples/nurserostering.py index 5532c19d4..488ff6de8 100644 --- a/examples/nurserostering.py +++ b/examples/nurserostering.py @@ -227,19 +227,19 @@ def nurserostering_model(horizon, shifts:pd.DataFrame, staff, days_off, shift_on # Days off for _, holiday in days_off.iterrows(): # could also do this vectorized... TODO? - i = staff.index[staff['ID'] == holiday['EmployeeID']][0] + i = (staff['ID'] == holiday['EmployeeID']).argmax() # index of employee model += nurse_view[i,holiday['DayIndex']] == FREE # Shift requests, encode in linear objective objective = 0 for _, request in shift_on.iterrows(): - i = staff.index[staff['ID'] == request['EmployeeID']][0] + i = (staff['ID'] == request['EmployeeID']).argmax() # index of employee cpm_request = nurse_view[i, request['Day']] == SHIFTS.index(request['ShiftID']) objective += request['Weight'] * ~cpm_request # Shift off requests, encode in linear objective for _, request in shift_off.iterrows(): - i = staff.index[staff['ID'] == request['EmployeeID']][0] + i = (staff['ID'] == request['EmployeeID']).argmax() # index of employee cpm_request = nurse_view[i, request['Day']] != SHIFTS.index(request['ShiftID']) objective += request['Weight'] * ~cpm_request From 3644cbf0dc578c8bc439252c2eea3c4892f0078a Mon Sep 17 00:00:00 2001 From: Ignace Bleukx Date: Wed, 10 Dec 2025 13:34:59 +0100 Subject: [PATCH 10/11] don't run nurserostering example on CI --- tests/test_examples.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_examples.py b/tests/test_examples.py index 1db1a819a..a862e3e34 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -25,6 +25,7 @@ SKIPPED_EXAMPLES = [ "ocus_explanations.py", # waiting for issues to be resolved "psplib.py" # randomly fails on github due to file creation + "nurserostering.py" ] SKIP_MIP = ['npuzzle.py', 'tst_likevrp.py', 'sudoku_', 'pareto_optimal.py', From 3b51a7a1b2aeee8bf71df08d1053e2146422ba29 Mon Sep 17 00:00:00 2001 From: Ignace Bleukx Date: Wed, 10 Dec 2025 13:35:54 +0100 Subject: [PATCH 11/11] raise import error --- examples/nurserostering.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/examples/nurserostering.py b/examples/nurserostering.py index 488ff6de8..ce424d667 100644 --- a/examples/nurserostering.py +++ b/examples/nurserostering.py @@ -16,12 +16,14 @@ try: from faker import Faker -except ImportError: +except ImportError as e: print("Install `faker` package using `pip install faker`") + raise e try: from natsort import natsorted -except ImportError: +except ImportError as e: print("Install `natsort` package using `pip install natsort`") + raise e pd.set_option('display.max_columns', 500) pd.set_option('display.width', 5000)