diff --git a/implement-laptop-allocation/.gitignore b/implement-laptop-allocation/.gitignore new file mode 100644 index 000000000..1d17dae13 --- /dev/null +++ b/implement-laptop-allocation/.gitignore @@ -0,0 +1 @@ +.venv diff --git a/implement-laptop-allocation/README.md b/implement-laptop-allocation/README.md new file mode 100644 index 000000000..461a940bf --- /dev/null +++ b/implement-laptop-allocation/README.md @@ -0,0 +1,29 @@ +# Laptop Allocation Program + +## About +A program that assigns laptops to people based on a list of operating system preferences, it includes a simulation that can be re-run to simulate different scenarios. + +## Specification +Create a program that alllocate laptops to people based on a list of preferred operating systems. Every person should be allocated exactly one laptop. + +If we define "sadness" as the number of places down in someone's ranking the operating system the ended up with (i.e. if your preferences were [UBUNTU, ARCH, MACOS] and you were allocated a MACOS machine your sadness would be 2), we want to minimise the total sadness of all people. If we allocate someone a laptop with an operating system not in their preferred list, treat them as having a sadness of 100. + +## To run code +python3 -m venv .venv +. .venv/bin/activate +pip install -r requirements.txt +python3 simulate.py + +## About Allocation +Allocation of laptops works using the Hungarian algorithm. +This is achieved using the linear_sum_assignment function in scipy which acts on a cost matrix constructed from the possible combinations between each person and laptop. + +## About Simulation +To test the code for different scenarios, a simulation was created, which simulates: +- A random laptop to person ratio between 0.5 and 2 +- A random number of people between 10 and 100 +- Therefore a theoretical random number of laptops between 5 and 200 +- Random percentage chances a person will be created with 1, 2 or 3 preferred operating systems (randomly assigned) + +When the simulation is run the statistics and output data are logged to a log.txt file and partially to the terminal. +An example of a simulation is shown in this repo in log.txt. diff --git a/implement-laptop-allocation/laptop_allocation.py b/implement-laptop-allocation/laptop_allocation.py new file mode 100644 index 000000000..27f115a3f --- /dev/null +++ b/implement-laptop-allocation/laptop_allocation.py @@ -0,0 +1,53 @@ +# ----- Imports +from dataclasses import dataclass +from enum import Enum + +from scipy.optimize import linear_sum_assignment + + +# ----- Classes +class OperatingSystem(Enum): + MACOS = "macOS" + ARCH = "Arch Linux" + UBUNTU = "Ubuntu" + + +@dataclass(frozen=True) +class Person: + name: str + age: int + # Sorted in order of preference, most preferred is first. + preferred_operating_systems: tuple[OperatingSystem, ...] + + +@dataclass(frozen=True) +class Laptop: + id: int + manufacturer: str + model: str + screen_size_in_inches: float + operating_system: OperatingSystem + + +# ----- Functions +def allocate_laptops( + people: list[Person], laptops: list[Laptop] +) -> dict[Person, Laptop]: + cost_matrix = [] + for person in people: + person_costs = [] + for laptop in laptops: + if laptop.operating_system in person.preferred_operating_systems: + sadness = person.preferred_operating_systems.index( + laptop.operating_system + ) + else: + sadness = 100 + person_costs.append(sadness) + cost_matrix.append(person_costs) + + row_ind, col_ind = linear_sum_assignment(cost_matrix) + + return { + people[p_index]: laptops[l_index] for p_index, l_index in zip(row_ind, col_ind) + } diff --git a/implement-laptop-allocation/log.txt b/implement-laptop-allocation/log.txt new file mode 100644 index 000000000..7856d297d --- /dev/null +++ b/implement-laptop-allocation/log.txt @@ -0,0 +1,135 @@ +------------------------------------------- +Simulation created at: 03-09-2026 04:29:49 +------------------------------------------- +Laptop to person weighting = 0.84 +Number of People: 21 +Number of Laptops: 17 + +Laptop surplus/deficit: -4 + +Percentage chance of 1 preference: 48.75% +Percentage chance of 2 preference: 11.71% +Percentage chance of 3 preference: 39.55% + +===== List of Laptops ===== +ID: 1 | Manufacturer: Dell | Model: XPS | Screen size (inches): 15 | OS: Ubuntu +ID: 2 | Manufacturer: Dell | Model: XPS | Screen size (inches): 13 | OS: Arch Linux +ID: 3 | Manufacturer: Dell | Model: XPS | Screen size (inches): 16 | OS: Arch Linux +ID: 4 | Manufacturer: Dell | Model: XPS | Screen size (inches): 14 | OS: Ubuntu +ID: 5 | Manufacturer: Dell | Model: XPS | Screen size (inches): 13 | OS: Ubuntu +ID: 6 | Manufacturer: Dell | Model: XPS | Screen size (inches): 16 | OS: Arch Linux +ID: 7 | Manufacturer: Dell | Model: XPS | Screen size (inches): 17 | OS: Ubuntu +ID: 8 | Manufacturer: Apple | Model: macBook | Screen size (inches): 16 | OS: macOS +ID: 9 | Manufacturer: Dell | Model: XPS | Screen size (inches): 13 | OS: Ubuntu +ID: 10 | Manufacturer: Dell | Model: XPS | Screen size (inches): 13 | OS: Arch Linux +ID: 11 | Manufacturer: Apple | Model: macBook | Screen size (inches): 17 | OS: macOS +ID: 12 | Manufacturer: Dell | Model: XPS | Screen size (inches): 14 | OS: Ubuntu +ID: 13 | Manufacturer: Dell | Model: XPS | Screen size (inches): 15 | OS: Arch Linux +ID: 14 | Manufacturer: Dell | Model: XPS | Screen size (inches): 15 | OS: Ubuntu +ID: 15 | Manufacturer: Dell | Model: XPS | Screen size (inches): 17 | OS: Arch Linux +ID: 16 | Manufacturer: Apple | Model: macBook | Screen size (inches): 16 | OS: macOS +ID: 17 | Manufacturer: Dell | Model: XPS | Screen size (inches): 16 | OS: Ubuntu + +===== List of People ===== +Name: Person 1 | Age: 45 | Preferred OS: Ubuntu +Name: Person 2 | Age: 46 | Preferred OS: macOS, Arch Linux, Ubuntu +Name: Person 3 | Age: 56 | Preferred OS: Ubuntu +Name: Person 4 | Age: 85 | Preferred OS: Arch Linux, Ubuntu +Name: Person 5 | Age: 60 | Preferred OS: macOS, Ubuntu, Arch Linux +Name: Person 6 | Age: 89 | Preferred OS: Arch Linux, Ubuntu +Name: Person 7 | Age: 62 | Preferred OS: macOS +Name: Person 8 | Age: 21 | Preferred OS: macOS +Name: Person 9 | Age: 59 | Preferred OS: macOS +Name: Person 10 | Age: 46 | Preferred OS: macOS +Name: Person 11 | Age: 82 | Preferred OS: macOS +Name: Person 12 | Age: 33 | Preferred OS: Arch Linux, macOS, Ubuntu +Name: Person 13 | Age: 33 | Preferred OS: Arch Linux +Name: Person 14 | Age: 48 | Preferred OS: Ubuntu, Arch Linux, macOS +Name: Person 15 | Age: 67 | Preferred OS: Ubuntu, macOS, Arch Linux +Name: Person 16 | Age: 26 | Preferred OS: macOS, Arch Linux, Ubuntu +Name: Person 17 | Age: 66 | Preferred OS: macOS +Name: Person 18 | Age: 31 | Preferred OS: Ubuntu, macOS, Arch Linux +Name: Person 19 | Age: 42 | Preferred OS: Arch Linux, Ubuntu, macOS +Name: Person 20 | Age: 97 | Preferred OS: Arch Linux, Ubuntu, macOS +Name: Person 21 | Age: 79 | Preferred OS: Ubuntu + +Laptop operating system options: +Arch Linux: 6 +Ubuntu: 8 +macOS: 3 + +First choice of operating systems: +Arch Linux: 6 +Ubuntu: 6 +macOS: 9 + +Second choice of operating systems: +Arch Linux: 3 +Ubuntu: 5 +macOS: 3 + +Third choice of operating systems: +Arch Linux: 3 +Ubuntu: 3 +macOS: 3 + +First choice OS given: 15 +Second choice OS given: 1 +Third choice OS given: 1 + +>>>>> Total Sadness Score: 403 <<<<< + +Note: Due to a deficit of 4 laptops, the minimum sadness score theoretically achievable could never be less than 400 + +===== Allocations ===== +Person 1 -- Preferred OS: Ubuntu +----> Laptop ID: 1 -- OS: Ubuntu + +Person 2 -- Preferred OS: macOS | Arch Linux | Ubuntu +----> Laptop ID: 8 -- OS: macOS + +Person 3 -- Preferred OS: Ubuntu +----> Laptop ID: 4 -- OS: Ubuntu + +Person 4 -- Preferred OS: Arch Linux | Ubuntu +----> Laptop ID: 2 -- OS: Arch Linux + +Person 5 -- Preferred OS: macOS | Ubuntu | Arch Linux +----> Laptop ID: 14 -- OS: Ubuntu + +Person 6 -- Preferred OS: Arch Linux | Ubuntu +----> Laptop ID: 3 -- OS: Arch Linux + +Person 7 -- Preferred OS: macOS +----> Laptop ID: 11 -- OS: macOS + +Person 8 -- Preferred OS: macOS +----> Laptop ID: 16 -- OS: macOS + +Person 12 -- Preferred OS: Arch Linux | macOS | Ubuntu +----> Laptop ID: 6 -- OS: Arch Linux + +Person 13 -- Preferred OS: Arch Linux +----> Laptop ID: 10 -- OS: Arch Linux + +Person 14 -- Preferred OS: Ubuntu | Arch Linux | macOS +----> Laptop ID: 5 -- OS: Ubuntu + +Person 15 -- Preferred OS: Ubuntu | macOS | Arch Linux +----> Laptop ID: 7 -- OS: Ubuntu + +Person 16 -- Preferred OS: macOS | Arch Linux | Ubuntu +----> Laptop ID: 17 -- OS: Ubuntu + +Person 18 -- Preferred OS: Ubuntu | macOS | Arch Linux +----> Laptop ID: 9 -- OS: Ubuntu + +Person 19 -- Preferred OS: Arch Linux | Ubuntu | macOS +----> Laptop ID: 13 -- OS: Arch Linux + +Person 20 -- Preferred OS: Arch Linux | Ubuntu | macOS +----> Laptop ID: 15 -- OS: Arch Linux + +Person 21 -- Preferred OS: Ubuntu +----> Laptop ID: 12 -- OS: Ubuntu + diff --git a/implement-laptop-allocation/requirements.txt b/implement-laptop-allocation/requirements.txt new file mode 100644 index 000000000..e5cafb6b7 --- /dev/null +++ b/implement-laptop-allocation/requirements.txt @@ -0,0 +1 @@ +scipy>=1.13.1 diff --git a/implement-laptop-allocation/simulate.py b/implement-laptop-allocation/simulate.py new file mode 100644 index 000000000..66542ddba --- /dev/null +++ b/implement-laptop-allocation/simulate.py @@ -0,0 +1,221 @@ +import math +import random +from collections import Counter +from datetime import datetime + +from laptop_allocation import Laptop, OperatingSystem, Person, allocate_laptops + + +# ----- Log and Print functions +def log(message, filename="log.txt"): + with open(filename, "a") as f: + f.write(f"{message}\n") + + +def log_and_print(message, filename="log.txt"): + print(message) + with open(filename, "a") as f: + f.write(f"{message}\n") + + +# THIS CODE CLEARS THE LOG EACH RUN - COMMENT TO KEEP LOG HISTORY +with open("log.txt", "w") as file: + pass + + +# ----- Data Functions +def create_people() -> list[Person]: + people = [] + for i in range(number_of_people): + people.append( + Person(f"Person {i + 1}", random.randint(18, 99), get_preferred_OS_list()) + ) + + print("List of people added to log.txt") + log("\n===== List of People =====") + + for person in people: + preferred_OS_list = [] + for OS in person.preferred_operating_systems: + preferred_OS_list.append(OS.value) + log( + f"Name: {person.name} | Age: {person.age} | Preferred OS: {', '.join(preferred_OS_list)}" + ) + + return people + + +def get_preferred_OS_list() -> tuple[OperatingSystem, ...]: + number_of_preferences = random.choices([1, 2, 3], OS_preference_weightings) + return tuple(random.sample(list(OperatingSystem), k=number_of_preferences[0])) + + +def create_laptops() -> list[Laptop]: + laptops = [] + for i in range(number_of_laptops): + os = random.choices(list(OperatingSystem))[0] + if os == OperatingSystem.MACOS: + manufacturer = "Apple" + model = "macBook" + else: + manufacturer = "Dell " + model = "XPS " + screen_size = random.choices([13, 14, 15, 16, 17])[0] + + laptops.append(Laptop(i + 1, manufacturer, model, screen_size, os)) + + print("\nList of laptops added to log.txt") + log("\n===== List of Laptops =====") + + for laptop in laptops: + log( + f"ID: {laptop.id} | Manufacturer: {laptop.manufacturer} | Model: {laptop.model} | Screen size (inches): {laptop.screen_size_in_inches} | OS: {laptop.operating_system.value}" + ) + + return laptops + + +def count_operating_systems(list_of_laptops: list[Laptop]) -> dict[str, int]: + counts = Counter(laptop.operating_system.value for laptop in list_of_laptops) + sorted_counts = dict(sorted(counts.items(), key=lambda item: item[0])) + + log("\nLaptop operating system options:") + for os_name, count in sorted_counts.items(): + log(f"{os_name}: {count}") + return sorted_counts + + +def count_first_choice_operating_systems( + list_of_people: list[Person], +) -> dict[str, int]: + counts = Counter( + person.preferred_operating_systems[0].value for person in list_of_people + ) + sorted_counts = dict(sorted(counts.items(), key=lambda item: item[0])) + + log("\nFirst choice of operating systems:") + for os_name, count in sorted_counts.items(): + log(f"{os_name}: {count}") + return sorted_counts + + +def count_second_choice_operating_systems( + list_of_people: list[Person], +) -> dict[str, int]: + counts = Counter( + person.preferred_operating_systems[1].value + for person in list_of_people + if len(person.preferred_operating_systems) > 1 + ) + + sorted_counts = dict(sorted(counts.items(), key=lambda item: item[0])) + + log("\nSecond choice of operating systems:") + for os_name, count in sorted_counts.items(): + log(f"{os_name}: {count}") + return sorted_counts + + +def count_third_choice_operating_systems( + list_of_people: list[Person], +) -> dict[str, int]: + counts = Counter( + person.preferred_operating_systems[2].value + for person in list_of_people + if len(person.preferred_operating_systems) > 2 + ) + + sorted_counts = dict(sorted(counts.items(), key=lambda item: item[0])) + + log("\nThird choice of operating systems:") + for os_name, count in sorted_counts.items(): + log(f"{os_name}: {count}") + return sorted_counts + + +def get_sadness_scores(allocations: dict[Person, Laptop]) -> dict[int, int]: + sadness_scores = {0: 0, 1: 0, 2: 0, 100: 0} + for person, laptop in allocations.items(): + try: + sadness_score = person.preferred_operating_systems.index( + laptop.operating_system + ) + except ValueError: + sadness_score = 100 + sadness_scores[sadness_score] += 1 + return sadness_scores + + +def calculate_total_sadness(scores: dict[int, int]) -> int: + total_sadness = 0 + if laptop_surplus < 0: + total_sadness += -laptop_surplus * 100 + for score, occurrence in scores.items(): + total_sadness += score * occurrence + return total_sadness + + +# ----- Random Simulation +log_and_print("-------------------------------------------") +log_and_print(f"Simulation created at: {datetime.now().strftime('%d-%m-%Y %H:%M:%S')}") +log_and_print("-------------------------------------------") + +laptop_to_person_weighting = random.uniform(0.5, 2) +log_and_print(f"Laptop to person weighting = {laptop_to_person_weighting:.2f}") + +number_of_people = random.randint(10, 100) +log_and_print(f"Number of People: {number_of_people}") + +number_of_laptops = math.floor(number_of_people * laptop_to_person_weighting) +log_and_print(f"Number of Laptops: {number_of_laptops}") + +laptop_surplus = number_of_laptops - number_of_people +log(f"\nLaptop surplus/deficit: {laptop_surplus}\n") + +number_of_available_OS = 3 +OS_preference_weightings = [] + +for i in range(number_of_available_OS): + OS_preference_weightings.append(random.random()) + +sum_of_OS_preference_weightings = sum(OS_preference_weightings) + +for i in range(number_of_available_OS): + percentage_chance = ( + OS_preference_weightings[i] / sum_of_OS_preference_weightings + ) * 100 + log_and_print(f"Percentage chance of {i + 1} preference: {percentage_chance:.2f}%") + +laptops = create_laptops() +people = create_people() + +count_operating_systems(laptops) +count_first_choice_operating_systems(people) +count_second_choice_operating_systems(people) +count_third_choice_operating_systems(people) + +allocated_laptops = allocate_laptops(people, laptops) +print("List of allocations added to log.txt") + +sadness_scores = get_sadness_scores(allocated_laptops) +log_and_print(f"\nFirst choice OS given: {sadness_scores[0]}") +log_and_print(f"Second choice OS given: {sadness_scores[1]}") +log_and_print(f"Third choice OS given: {sadness_scores[2]}") + +log_and_print( + f"\n>>>>> Total Sadness Score: {calculate_total_sadness(sadness_scores)} <<<<<" +) + +if number_of_people > number_of_laptops: + log_and_print( + f"\nNote: Due to a deficit of {-laptop_surplus} laptops, the minimum sadness score theoretically achievable could never be less than {-laptop_surplus * 100}" + ) + + +log("\n===== Allocations =====") +for person, laptop in allocated_laptops.items(): + preferred_OS = "" + for OS in person.preferred_operating_systems: + preferred_OS += OS.value + " | " + log(f"{person.name} -- Preferred OS: {preferred_OS[:-2]}") + log(f"----> Laptop ID: {laptop.id} -- OS: {laptop.operating_system.value}\n")