diff --git a/poetry.lock b/poetry.lock index d7e87f7..d46bcc9 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,5 +1,16 @@ # This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + [[package]] name = "numpy" version = "2.0.1" @@ -95,7 +106,27 @@ timezone = ["backports-zoneinfo", "tzdata"] xlsx2csv = ["xlsx2csv (>=0.8.0)"] xlsxwriter = ["xlsxwriter"] +[[package]] +name = "tqdm" +version = "4.66.5" +description = "Fast, Extensible Progress Meter" +optional = false +python-versions = ">=3.7" +files = [ + {file = "tqdm-4.66.5-py3-none-any.whl", hash = "sha256:90279a3770753eafc9194a0364852159802111925aa30eb3f9d85b0e805ac7cd"}, + {file = "tqdm-4.66.5.tar.gz", hash = "sha256:e1020aef2e5096702d8a025ac7d16b1577279c9d63f8375b63083e9a5f0fcbad"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[package.extras] +dev = ["pytest (>=6)", "pytest-cov", "pytest-timeout", "pytest-xdist"] +notebook = ["ipywidgets (>=6)"] +slack = ["slack-sdk"] +telegram = ["requests"] + [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "3b5f1c763a56f03ec547b2513ce2ef1a9935ac6df8a8caca8288795aee44cbbf" +content-hash = "e518e9e57f22023c75b734bc9fdfb85e25b6b937f87bbf5a26c1b31ff370b15f" diff --git a/pyproject.toml b/pyproject.toml index 96476d5..7b402fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ readme = "README.md" python = "^3.10" numpy = "^2.0.1" polars = "^1.4.1" +tqdm = "^4.66.5" [build-system] diff --git a/simulation/app.py b/simulation/app.py index 8c89063..6533887 100644 --- a/simulation/app.py +++ b/simulation/app.py @@ -1,63 +1,67 @@ +from concurrent.futures import ProcessPoolExecutor, as_completed from dataclasses import dataclass import numpy as np import polars as pl +from tqdm import tqdm from simulation.lib import Process, Simulation -@dataclass -class Customer: - arrived_at: float - started_at: float | None = None - handling_time: float | None = None - finished_at: float | None = None - - -customers_unhandled: list[Customer] = [] -customers_in_progress: list[Customer] = [] -customers_handled: list[Customer] = [] - - -class Server(Process): - def run(self): - while True: - if len(customers_unhandled) > 0: - customer = customers_unhandled[0] - customers_unhandled.remove(customer) - customers_in_progress.append(customer) - customer.started_at = self.simulation.clock - handling_time = float(max(0, np.random.normal(5, 2.5, size=1)[0])) - yield from self.hold(handling_time) - customer.handling_time = handling_time - customer.finished_at = self.simulation.clock - customers_in_progress.remove(customer) - customers_handled.append(customer) - self.simulation.log(f"unhandled customers: {len(customers_unhandled)}") - else: - yield from self.suspend() - - -class CustomerGenerator(Process): - def __init__(self, lam=40, servers=[]): - self.lam = lam - self.servers = servers - super().__init__() - - def run(self): - while True: - wait_for = float(max(0, (np.random.poisson(lam=self.lam, size=1) / 10)[0])) - customer = Customer(arrived_at=self.simulation.clock) - customers_unhandled.append(customer) - for server in self.servers: - server.resume() - yield from self.hold(wait_for) - - def run_simulation(n_servers=2, lam=40): + + @dataclass + class Customer: + arrived_at: float + started_at: float | None = None + handling_time: float | None = None + finished_at: float | None = None + + customers_unhandled: list[Customer] = [] + customers_in_progress: list[Customer] = [] + customers_handled: list[Customer] = [] + + class Server(Process): + def run(self): + while True: + if len(customers_unhandled) > 0: + customer = customers_unhandled[0] + customers_unhandled.remove(customer) + customers_in_progress.append(customer) + customer.started_at = self.simulation.clock + handling_time = float(max(0, np.random.normal(5, 2.5, size=1)[0])) + yield from self.hold(handling_time) + customer.handling_time = handling_time + customer.finished_at = self.simulation.clock + customers_in_progress.remove(customer) + customers_handled.append(customer) + self.simulation.log( + f"unhandled customers: {len(customers_unhandled)}" + ) + else: + yield from self.suspend() + + class CustomerGenerator(Process): + def __init__(self, lam=40, servers=[]): + self.lam = lam + self.servers = servers + super().__init__() + + def run(self): + while True: + wait_for = float( + max(0, (np.random.poisson(lam=self.lam, size=1) / 10)[0]) + ) + customer = Customer(arrived_at=self.simulation.clock) + customers_unhandled.append(customer) + for server in self.servers: + server.resume() + yield from self.hold(wait_for) + servers = [Server() for _ in range(n_servers)] simulation = Simulation( - processes=[CustomerGenerator(servers=servers, lam=lam), *servers] + processes=[CustomerGenerator(servers=servers, lam=lam), *servers], + enable_logging=False, ) simulation.start() @@ -67,9 +71,56 @@ def run_simulation(n_servers=2, lam=40): df_servers = pl.DataFrame([server.__dict__ for server in servers]) - # print(f"\nservers: {len(servers)}") - print("\ncustomers", df_customers[["queue_time", "handling_time"]].mean()) - print("\nservers", df_servers[["id", "utilization"]]) + utilization = { + f"utilization_server_{key}": value + for key, value in dict( + zip(df_servers["id"].to_list(), df_servers["utilization"].to_list()) + ).items() + } + return { + "n_servers": n_servers, + "lam": lam, + "queue_time": df_customers["queue_time"].mean(), + "handling_time": df_customers["handling_time"].mean(), + **utilization, + } -run_simulation(n_servers=3) +with ProcessPoolExecutor(max_workers=4) as executor: + n_servers = [n + 1 for n in range(10)] + lam = [(n + 1) * 4 for n in range(10)] + parameter_values = [ + {"n_servers": n, "lam": m} for n in n_servers for m in lam for _ in range(10) + ] + futures = [ + executor.submit(run_simulation, **parameters) for parameters in parameter_values + ] + + results = [] + for future in tqdm(as_completed(futures), total=len(parameter_values)): + result = future.result() + results.append(result) + + df = ( + pl.DataFrame(results) + .fill_null(0) + .group_by(["n_servers", "lam"]) + .agg(pl.all().mean()) + .sort(["n_servers", "lam"]) + ) + + df_queue_time = df.pivot("lam", index="n_servers", values="queue_time").sort( + "n_servers" + ) + + def stats(column): + return ( + df.pivot("lam", index="n_servers", values=column) + .sort("n_servers") + .with_columns(pl.all().round(2)) + ) + + pl.Config.set_tbl_cols(20) + print(df) + print(stats("queue_time")) + print(stats("utilization_server_1")) diff --git a/simulation/lib.py b/simulation/lib.py index 2d7b626..6f6cdfe 100644 --- a/simulation/lib.py +++ b/simulation/lib.py @@ -3,10 +3,11 @@ from typing import Any, Generator, Protocol class BaseSimulation: clock: float + enable_logging: bool def log(self, msg): - print(f"{'{:.2f}'.format(self.clock)}: {msg}") - pass + if self.enable_logging: + print(f"{'{:.2f}'.format(self.clock)}: {msg}") class BaseProcess(Protocol): @@ -42,7 +43,8 @@ class Process(BaseProcess): self.simulation.clock - self.started_at ) except Exception as e: - print(e) + if self.simulation.enable_logging: + print(e) def suspend(self): self.suspended = True @@ -61,9 +63,10 @@ class Process(BaseProcess): class Simulation(BaseSimulation): - def __init__(self, processes: list[Process]): + def __init__(self, processes: list[Process], enable_logging=True): self.clock = 0 self.processes = [] + self.enable_logging = enable_logging for process in processes: self.register_process(process)