diff --git a/.gitignore b/.gitignore
index 4b28f78..1aa032e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,7 +3,9 @@ __pycache__
.pytest_cache
.pytype
+# Log messages, except the example one generated by logs.ipynb
*.log
+!log_example.log
# vscode
.vscode/*
diff --git a/CITATION.cff b/CITATION.cff
index 1200eff..4132c8b 100644
--- a/CITATION.cff
+++ b/CITATION.cff
@@ -14,6 +14,11 @@ authors:
email: a.heather2@exeter.ac.uk
affiliation: University of Exeter
orcid: 'https://orcid.org/0000-0002-6596-3479'
+ - given-names: Thomas
+ family-names: Monks
+ email: t.m.w.monks@exeter.ac.uk
+ affiliation: University of Exeter
+ orcid: 'https://orcid.org/0000-0003-2631-4481'
repository-code: >-
https://github.com/pythonhealthdatascience/rap_template_python_des
abstract: >-
diff --git a/README.md b/README.md
index a55abe3..adc2977 100644
--- a/README.md
+++ b/README.md
@@ -193,7 +193,7 @@ This section describes the purposes of each class in the simulation.
**Model Run Process:**
-1. **Set Parameters:** Create a `Defaults` instance and modify it with desired model parameters.
+1. **Set Parameters:** Create a `Param` instance with desired model parameters.
2. **Initialise Model:** Instantiate `Model` using the parameters. During setup, `Model` creates `Exponential` instances for each distribution.
3. **Run Simulation:** Call `model.run()` to execute the simulation within the SimPy environment, running two processes:
@@ -260,17 +260,18 @@ These times were obtained on an Intel Core i7-12700H with 32GB RAM running Ubunt
If you use this template, please cite the archived repository:
-> Heather, A. (2025). Simple Reproducible Python Discrete-Event Simulation (DES) Template. Zenodo. https://doi.org/10.5281/zenodo.14622466
+> Heather, A. Monks, T. (2025). Python DES RAP Template. Zenodo. https://doi.org/10.5281/zenodo.14622466
You can also cite the GitHub repository:
-> Heather, A. (2025). Simple Reproducible Python Discrete-Event Simulation (DES) Template. GitHub. https://github.com/pythonhealthdatascience/rap_template_python_des.
+> Heather, A. Monks, T. (2025). Python DES RAP Template. GitHub. https://github.com/pythonhealthdatascience/rap_template_python_des.
Researcher details:
-| Contributor | ORCID | GitHub |
-| --- | --- | --- |
-| Amy Heather | [](https://orcid.org/0000-0002-6596-3479) | https://github.com/amyheather |
+| Contributor | Role | ORCID | GitHub |
+| --- | --- | --- | --- |
+| Amy Heather | Author | [](https://orcid.org/0000-0002-6596-3479) | https://github.com/amyheather |
+| Tom Monks | Code review | [](https://orcid.org/0000-0003-2631-4481) | https://github.com/TomMonks |
diff --git a/docs/time_weighted_averages.ipynb b/docs/time_weighted_averages.ipynb
deleted file mode 100644
index cbac3af..0000000
--- a/docs/time_weighted_averages.ipynb
+++ /dev/null
@@ -1,372 +0,0 @@
-{
- "cells": [
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "# Time-weighted averages\n",
- "\n",
- "This notebook provides some simple examples to explain the time-weighted average calculation used in the model."
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 1,
- "metadata": {},
- "outputs": [],
- "source": [
- "# pylint: disable=missing-module-docstring\n",
- "import plotly.express as px\n",
- "import plotly.io as pio\n",
- "\n",
- "pio.renderers.default = 'svg'"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "## Example 1: Queue size"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "Here we have a given queue size over time:\n",
- "\n",
- "| Interval | Queue Size | Duration |\n",
- "|-------------|------------|----------|\n",
- "| 0 - 14.4 | 0 | 14.4 |\n",
- "| 14.4 - 15.2 | 1 | 0.8 |\n",
- "| 15.2 - 16.1 | 2 | 0.9 |\n",
- "| 16.1 - 17.0 | 3 | 0.9 |\n",
- "\n",
- "You can see that, for most of the time, there is no-one in the queue, but then a few people join at the end.\n",
- "\n",
- "Hence, we would logically expect to see an average queue size fairly close to 0."
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 2,
- "metadata": {},
- "outputs": [
- {
- "data": {
- "image/svg+xml": [
- ""
- ]
- },
- "metadata": {},
- "output_type": "display_data"
- }
- ],
- "source": [
- "# Given data\n",
- "time = [0.0, 14.4, 15.2, 16.1, 17.0]\n",
- "queue = [0, 1, 2, 3, 3]\n",
- "\n",
- "fig = px.line(x=time, y=queue)\n",
- "fig.update_traces(mode='lines', line_shape='hv')\n",
- "fig.update_xaxes(dtick=2)\n",
- "fig.update_yaxes(dtick=1)\n",
- "fig.update_layout(\n",
- " xaxis_title='Time',\n",
- " yaxis_title='Queue size',\n",
- " template='plotly_white')\n",
- "fig.show()"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "### Simple average\n",
- "\n",
- "#### Formula for simple average queue size:\n",
- "\n",
- "$$\n",
- "\\text{Simple Average} = \\frac{\\sum (\\text{Queue Size})}{\\text{Number of Intervals}}\n",
- "$$\n",
- "\n",
- "#### Applying values:\n",
- "\n",
- "$$\n",
- "= \\frac{0 + 1 + 2 + 3}{4}\n",
- "$$\n",
- "\n",
- "$$\n",
- "= \\frac{6}{4} = 1.5\n",
- "$$\n",
- "\n",
- "With a simple average, we find quite a high average: 1.5."
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 3,
- "metadata": {},
- "outputs": [
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "Simple Average Queue Size: 1.5\n"
- ]
- }
- ],
- "source": [
- "simple_avg = sum(queue[:-1]) / len(queue[:-1])\n",
- "print(f'Simple Average Queue Size: {simple_avg}')"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "### Time-weighted average\n",
- "\n",
- "#### Formula for time-weighted average queue size:\n",
- "\n",
- "$$\n",
- "\\text{Time-Weighted Average} = \\frac{\\sum (\\text{Queue Size} \\times \\text{Time Duration})}{\\text{Total Time}}\n",
- "$$\n",
- "\n",
- "#### Applying values:\n",
- "\n",
- "$$\n",
- "= \\frac{(0 \\times 14.4) + (1 \\times 0.8) + (2 \\times 0.9) + (3 \\times 0.9)}{17.0}\n",
- "$$\n",
- "\n",
- "$$\n",
- "= \\frac{0 + 0.8 + 1.8 + 2.7}{17.0}\n",
- "$$\n",
- "\n",
- "$$\n",
- "= \\frac{5.3}{17.0} \\approx 0.312\n",
- "$$\n",
- "\n",
- "The time-weighted average better meets our expectations, being fairly close to 0."
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 4,
- "metadata": {},
- "outputs": [
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "Time-Weighted Average Queue Size: 0.3117647058823529\n"
- ]
- }
- ],
- "source": [
- "# Compute time-weighted sum\n",
- "weighted_sum = sum(\n",
- " queue[i] * (time[i+1] - time[i]) for i in range(len(time)-1))\n",
- "\n",
- "# Total time duration\n",
- "# pylint:disable=invalid-name\n",
- "total_time = time[-1] - time[0]\n",
- "\n",
- "# Compute time-weighted average\n",
- "time_weighted_avg = weighted_sum / total_time\n",
- "\n",
- "print(f'Time-Weighted Average Queue Size: {time_weighted_avg}')"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "## Example 2: Utilisation\n",
- "\n",
- "Building on our queue example:\n",
- "\n",
- "* There are **2 nurses** in this system.\n",
- "* Five patients arrive during the observation period - 2 at the start, and 3 near the end.\n",
- "* **Patients A and B** are seen immediately, and each has a long consultation.\n",
- "* **Patients C, D, and E** arrive and wait in the queue since no nurses are available, and are still waiting at the end of the observation period."
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "| Time | Event | Patients in system | Nurses busy | Queue size |\n",
- "| - | - | - | - | - |\n",
- "| 0.0 | Clinic opens |0 | 0 | 0 |\n",
- "| 1.2 | Patient A arrives, sees nurse for 19 | 1 | 1 | 0 |\n",
- "| 2.3 | Patient B arrives, sees nurse for 20 | 2 | 2 | 0 |\n",
- "| 14.4 | Patient C arrives, waits for nurse | 3 | 2 | 1 |\n",
- "| 15.2 | Patient D arrives, waits for nurse | 4 | 2 | 2 |\n",
- "| 16.1 | Patient E arrives, waits for nurse | 5 | 2 | 3 |\n",
- "| 17.0 | Simulation ends, three patients waiting still | 5 | 2 | 3 |"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "The utilisation of the nurses will depend on how many are busy at each point in time.\n",
- "\n",
- "* When **no nurses** are busy, utilisation is **0.0** (i.e. 0%).\n",
- "* When **one nurse** is busy, utilisation is **0.5** (i.e. 50%).\n",
- "* When **two nurses** are busy, utilisation is **1.0** (i.e. 100%)."
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 5,
- "metadata": {},
- "outputs": [
- {
- "data": {
- "image/svg+xml": [
- ""
- ]
- },
- "metadata": {},
- "output_type": "display_data"
- }
- ],
- "source": [
- "time = [0.0, 1.2, 2.3, 14.4, 15.2, 16.1, 17.0]\n",
- "utilisation = [0, 0.5, 1.0, 1.0, 1.0, 1.0, 1.0]\n",
- "\n",
- "fig = px.line(x=time, y=utilisation)\n",
- "fig.update_traces(mode='lines', line_shape='hv')\n",
- "fig.update_xaxes(dtick=2)\n",
- "fig.update_yaxes(dtick=0.5)\n",
- "fig.update_layout(\n",
- " xaxis_title='Time',\n",
- " yaxis_title='Nurse utilisation',\n",
- " template='plotly_white')\n",
- "fig.show()"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "In this scenario, we'd expect to see an average utilisation close to 1, as there was full utilisation for most of the observation period."
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "### Simple average\n",
- "\n",
- "#### Formula for simple average utilisation\n",
- "\n",
- "$$\n",
- "\\text{Simple Average} = \\frac{\\sum (\\text{Utilisation})}{\\text{Number of Intervals}}\n",
- "$$\n",
- "\n",
- "#### Applying values:\n",
- "\n",
- "$$\n",
- "= \\frac{0 + 0.5 + 1 + 1 + 1 + 1 + 1}{7} = 0.7857\n",
- "$$\n"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 6,
- "metadata": {},
- "outputs": [
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "Simple Average Utilisation: 0.7857142857142857\n"
- ]
- }
- ],
- "source": [
- "simple_avg = sum(utilisation) / len(utilisation)\n",
- "print(f'Simple Average Utilisation: {simple_avg}')"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "### Time-weighted average\n",
- "\n",
- "#### Formula for time-weighted utilisation\n",
- "\n",
- "$$\n",
- "\\text{Time-Weighted Average} = \\frac{\\sum (\\text{Utilisation} \\times \\text{Time Duration})}{\\text{Total Time}}\n",
- "$$\n",
- "\n",
- "#### Applying values:\n",
- "\n",
- "$$\n",
- "= \\frac{(0 \\times 1.2) + (0.5 \\times 1.1) + (1 \\times 14.7)}{17.0}\n",
- "$$\n",
- "\n",
- "$$\n",
- "= \\frac{0 + 0.55 + 14.7}{17.0} \\approx 0.897\n",
- "$$"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 7,
- "metadata": {},
- "outputs": [
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "Time-Weighted Average Utilisation: 0.8970588235294119\n"
- ]
- }
- ],
- "source": [
- "weighted_sum = sum(\n",
- " utilisation[i] * (time[i+1] - time[i]) for i in range(len(time)-1)\n",
- ")\n",
- "\n",
- "# Total time duration\n",
- "total_time = time[-1] - time[0]\n",
- "\n",
- "time_weighted_avg = weighted_sum / total_time\n",
- "print(f'Time-Weighted Average Utilisation: {time_weighted_avg}')"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "When calculating the time-weighted average, we indeed observe a higher average utilisation, better reflecting the reality of utilisation during the observation period."
- ]
- }
- ],
- "metadata": {
- "kernelspec": {
- "display_name": "Python 3",
- "language": "python",
- "name": "python3"
- },
- "language_info": {
- "codemirror_mode": {
- "name": "ipython",
- "version": 3
- },
- "file_extension": ".py",
- "mimetype": "text/x-python",
- "name": "python",
- "nbconvert_exporter": "python",
- "pygments_lexer": "ipython3",
- "version": "3.13.1"
- }
- },
- "nbformat": 4,
- "nbformat_minor": 2
-}
diff --git a/environment.yaml b/environment.yaml
index 7f3fe20..fb40f83 100644
--- a/environment.yaml
+++ b/environment.yaml
@@ -15,6 +15,7 @@ dependencies:
- pytest=8.3.4
- pytest-xdist=3.6.1
- python=3.13.1
+ - rich=13.9.4
- simpy=4.1.1
- pip:
- kaleido==0.2.1
diff --git a/images/model_structure.drawio b/images/model_structure.drawio
index fffe8e4..0b1108d 100644
--- a/images/model_structure.drawio
+++ b/images/model_structure.drawio
@@ -1,6 +1,6 @@
-
+
-
+
@@ -25,7 +25,7 @@
-
+
@@ -219,13 +219,13 @@
-
+
-
+
@@ -243,16 +243,16 @@
-
+
-
+
-
+
-
+
@@ -262,7 +262,7 @@
-
+
diff --git a/images/model_structure.png b/images/model_structure.png
index 10326e7..3349255 100644
Binary files a/images/model_structure.png and b/images/model_structure.png differ
diff --git a/lint.sh b/lint.sh
index e558a8e..5eecd83 100644
--- a/lint.sh
+++ b/lint.sh
@@ -7,7 +7,4 @@ echo "Linting tests..."
pylint ./tests
echo "Linting notebooks..."
-nbqa pylint ./notebooks
-
-echo "Linting time-weighted averages notebook..."
-nbqa pylint ./docs/time_weighted_averages.ipynb
\ No newline at end of file
+nbqa pylint ./notebooks
\ No newline at end of file
diff --git a/notebooks/analysis.ipynb b/notebooks/analysis.ipynb
index 54d778a..4d755ba 100644
--- a/notebooks/analysis.ipynb
+++ b/notebooks/analysis.ipynb
@@ -57,15 +57,15 @@
"# pylint: disable=wrong-import-position\n",
"import os\n",
"import time\n",
+ "\n",
"from IPython.display import display\n",
"import pandas as pd\n",
"import plotly.express as px\n",
"import plotly.graph_objects as go\n",
"import plotly.io as pio\n",
"\n",
- "from simulation.logging import SimLogger\n",
- "from simulation.model import (\n",
- " Defaults, Model, Runner, summary_stats, run_scenarios)"
+ "from simulation.model import Param, Runner, run_scenarios\n",
+ "from simulation.helper import summary_stats"
]
},
{
@@ -147,7 +147,7 @@
"metadata": {},
"outputs": [],
"source": [
- "param = Defaults()\n",
+ "param = Param()\n",
"experiment = Runner(param)\n",
"experiment.run_reps()"
]
@@ -1989,7 +1989,7 @@
{
"data": {
"image/svg+xml": [
- ""
+ ""
]
},
"metadata": {},
@@ -1998,7 +1998,7 @@
{
"data": {
"image/svg+xml": [
- ""
+ ""
]
},
"metadata": {},
@@ -2007,7 +2007,7 @@
{
"data": {
"image/svg+xml": [
- ""
+ ""
]
},
"metadata": {},
@@ -2016,7 +2016,7 @@
{
"data": {
"image/svg+xml": [
- ""
+ ""
]
},
"metadata": {},
@@ -2324,7 +2324,7 @@
{
"data": {
"image/svg+xml": [
- ""
+ ""
]
},
"metadata": {},
@@ -2359,7 +2359,7 @@
{
"data": {
"image/svg+xml": [
- ""
+ ""
]
},
"metadata": {},
@@ -2486,7 +2486,7 @@
{
"data": {
"image/svg+xml": [
- ""
+ ""
]
},
"metadata": {},
@@ -2660,222 +2660,12 @@
}
],
"source": [
- "param = Defaults()\n",
- "param.patient_inter = 2\n",
+ "param = Param(patient_inter=2)\n",
"nan_experiment = Runner(param)\n",
"nan_experiment.run_reps()\n",
"nan_experiment.patient_results_df.tail()"
]
},
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "## Example run with logs\n",
- "\n",
- "The `SimLogger` class is used to log events during the simulation. These can be print to the console (`log_to_console`) or saved to a file (`log_to_file`).\n",
- "\n",
- "This will output lots of information to the screen - currently set to give information on each patient as they arrive and then see the nurse. Therefore, it is only best used when running the simulation for a short time with few patients.\n",
- "\n",
- "The logs in `model.py` can be altered to print your desired information during the simulation run, which can be helpful during development."
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 27,
- "metadata": {},
- "outputs": [
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "2025-01-30 14:13:41,954 - INFO - logging.py:log():128 - Initialised model: {'param': , 'run_number': 0, 'env': , 'nurse': , 'patients': [], 'nurse_time_used': 0, 'nurse_consult_count': 0, 'running_mean_nurse_wait': 0, 'audit_list': [], 'results_list': [], 'patient_inter_arrival_dist': , 'nurse_consult_time_dist': }\n",
- "2025-01-30 14:13:41,955 - INFO - logging.py:log():128 - Parameters: {'_initialising': False, 'patient_inter': 4, 'mean_n_consult_time': 10, 'number_of_nurses': 5, 'warm_up_period': 50, 'data_collection_period': 100, 'number_of_runs': 1, 'audit_interval': 120, 'scenario_name': 0, 'cores': 0, 'logger': }\n",
- "2025-01-30 14:13:41,955 - INFO - logging.py:log():128 - Patient 1 arrives at: 13.174\n",
- "2025-01-30 14:13:41,955 - INFO - logging.py:log():128 - Patient 1 is seen by nurse after 0.000 minutes. Consultation length: 8.031 minutes.\n",
- "2025-01-30 14:13:41,956 - INFO - logging.py:log():128 - Patient 2 arrives at: 16.227\n",
- "2025-01-30 14:13:41,956 - INFO - logging.py:log():128 - Patient 2 is seen by nurse after 0.000 minutes. Consultation length: 3.820 minutes.\n",
- "2025-01-30 14:13:41,956 - INFO - logging.py:log():128 - Patient 3 arrives at: 21.236\n",
- "2025-01-30 14:13:41,956 - INFO - logging.py:log():128 - Patient 3 is seen by nurse after 0.000 minutes. Consultation length: 3.642 minutes.\n",
- "2025-01-30 14:13:41,956 - INFO - logging.py:log():128 - Patient 4 arrives at: 22.140\n",
- "2025-01-30 14:13:41,956 - INFO - logging.py:log():128 - Patient 4 is seen by nurse after 0.000 minutes. Consultation length: 5.295 minutes.\n",
- "2025-01-30 14:13:41,956 - INFO - logging.py:log():128 - Patient 5 arrives at: 23.023\n",
- "2025-01-30 14:13:41,956 - INFO - logging.py:log():128 - Patient 5 is seen by nurse after 0.000 minutes. Consultation length: 27.884 minutes.\n",
- "2025-01-30 14:13:41,957 - INFO - logging.py:log():128 - Patient 6 arrives at: 30.223\n",
- "2025-01-30 14:13:41,957 - INFO - logging.py:log():128 - Patient 6 is seen by nurse after 0.000 minutes. Consultation length: 19.610 minutes.\n",
- "2025-01-30 14:13:41,957 - INFO - logging.py:log():128 - Patient 7 arrives at: 30.487\n",
- "2025-01-30 14:13:41,957 - INFO - logging.py:log():128 - Patient 7 is seen by nurse after 0.000 minutes. Consultation length: 9.490 minutes.\n",
- "2025-01-30 14:13:41,958 - INFO - logging.py:log():128 - Patient 8 arrives at: 34.089\n",
- "2025-01-30 14:13:41,959 - INFO - logging.py:log():128 - Patient 8 is seen by nurse after 0.000 minutes. Consultation length: 41.665 minutes.\n",
- "2025-01-30 14:13:41,959 - INFO - logging.py:log():128 - Patient 9 arrives at: 35.270\n",
- "2025-01-30 14:13:41,959 - INFO - logging.py:log():128 - Patient 9 is seen by nurse after 0.000 minutes. Consultation length: 5.874 minutes.\n",
- "2025-01-30 14:13:41,959 - INFO - logging.py:log():128 - Patient 10 arrives at: 44.470\n",
- "2025-01-30 14:13:41,960 - INFO - logging.py:log():128 - Patient 10 is seen by nurse after 0.000 minutes. Consultation length: 27.882 minutes.\n",
- "2025-01-30 14:13:41,960 - INFO - logging.py:log():128 - ──────────\n",
- "2025-01-30 14:13:41,960 - INFO - logging.py:log():128 - 50.00: Warm up complete.\n",
- "2025-01-30 14:13:41,961 - INFO - logging.py:log():128 - ──────────\n",
- "2025-01-30 14:13:41,961 - INFO - logging.py:log():128 - Patient 1 arrives at: 51.904\n",
- "2025-01-30 14:13:41,961 - INFO - logging.py:log():128 - Patient 1 is seen by nurse after 0.000 minutes. Consultation length: 24.915 minutes.\n",
- "2025-01-30 14:13:41,961 - INFO - logging.py:log():128 - Patient 2 arrives at: 51.963\n",
- "2025-01-30 14:13:41,961 - INFO - logging.py:log():128 - Patient 2 is seen by nurse after 0.000 minutes. Consultation length: 18.079 minutes.\n",
- "2025-01-30 14:13:41,962 - INFO - logging.py:log():128 - Patient 3 arrives at: 74.349\n",
- "2025-01-30 14:13:41,962 - INFO - logging.py:log():128 - Patient 3 is seen by nurse after 0.000 minutes. Consultation length: 3.102 minutes.\n",
- "2025-01-30 14:13:41,962 - INFO - logging.py:log():128 - Patient 4 arrives at: 77.534\n",
- "2025-01-30 14:13:41,962 - INFO - logging.py:log():128 - Patient 4 is seen by nurse after 0.000 minutes. Consultation length: 26.745 minutes.\n",
- "2025-01-30 14:13:41,963 - INFO - logging.py:log():128 - Patient 5 arrives at: 78.932\n",
- "2025-01-30 14:13:41,963 - INFO - logging.py:log():128 - Patient 5 is seen by nurse after 0.000 minutes. Consultation length: 0.748 minutes.\n",
- "2025-01-30 14:13:41,963 - INFO - logging.py:log():128 - Patient 6 arrives at: 86.815\n",
- "2025-01-30 14:13:41,963 - INFO - logging.py:log():128 - Patient 6 is seen by nurse after 0.000 minutes. Consultation length: 0.528 minutes.\n",
- "2025-01-30 14:13:41,963 - INFO - logging.py:log():128 - Patient 7 arrives at: 89.783\n",
- "2025-01-30 14:13:41,963 - INFO - logging.py:log():128 - Patient 7 is seen by nurse after 0.000 minutes. Consultation length: 2.435 minutes.\n",
- "2025-01-30 14:13:41,964 - INFO - logging.py:log():128 - Patient 8 arrives at: 89.807\n",
- "2025-01-30 14:13:41,964 - INFO - logging.py:log():128 - Patient 8 is seen by nurse after 0.000 minutes. Consultation length: 9.666 minutes.\n",
- "2025-01-30 14:13:41,964 - INFO - logging.py:log():128 - Patient 9 arrives at: 93.118\n",
- "2025-01-30 14:13:41,964 - INFO - logging.py:log():128 - Patient 9 is seen by nurse after 0.000 minutes. Consultation length: 7.005 minutes.\n",
- "2025-01-30 14:13:41,964 - INFO - logging.py:log():128 - Patient 10 arrives at: 95.598\n",
- "2025-01-30 14:13:41,965 - INFO - logging.py:log():128 - Patient 10 is seen by nurse after 0.000 minutes. Consultation length: 20.185 minutes.\n",
- "2025-01-30 14:13:41,965 - INFO - logging.py:log():128 - Patient 11 arrives at: 98.019\n",
- "2025-01-30 14:13:41,965 - INFO - logging.py:log():128 - Patient 11 is seen by nurse after 0.000 minutes. Consultation length: 7.651 minutes.\n",
- "2025-01-30 14:13:41,965 - INFO - logging.py:log():128 - Patient 12 arrives at: 109.379\n",
- "2025-01-30 14:13:41,966 - INFO - logging.py:log():128 - Patient 12 is seen by nurse after 0.000 minutes. Consultation length: 27.908 minutes.\n",
- "2025-01-30 14:13:41,966 - INFO - logging.py:log():128 - Patient 13 arrives at: 110.322\n",
- "2025-01-30 14:13:41,966 - INFO - logging.py:log():128 - Patient 13 is seen by nurse after 0.000 minutes. Consultation length: 16.811 minutes.\n",
- "2025-01-30 14:13:41,966 - INFO - logging.py:log():128 - Patient 14 arrives at: 120.342\n",
- "2025-01-30 14:13:41,966 - INFO - logging.py:log():128 - Patient 14 is seen by nurse after 0.000 minutes. Consultation length: 24.157 minutes.\n",
- "2025-01-30 14:13:41,966 - INFO - logging.py:log():128 - Patient 15 arrives at: 121.643\n",
- "2025-01-30 14:13:41,967 - INFO - logging.py:log():128 - Patient 15 is seen by nurse after 0.000 minutes. Consultation length: 1.451 minutes.\n",
- "2025-01-30 14:13:41,967 - INFO - logging.py:log():128 - Patient 16 arrives at: 127.827\n",
- "2025-01-30 14:13:41,967 - INFO - logging.py:log():128 - Patient 16 is seen by nurse after 0.000 minutes. Consultation length: 1.343 minutes.\n",
- "2025-01-30 14:13:41,967 - INFO - logging.py:log():128 - Patient 17 arrives at: 132.055\n",
- "2025-01-30 14:13:41,967 - INFO - logging.py:log():128 - Patient 17 is seen by nurse after 0.000 minutes. Consultation length: 1.554 minutes.\n",
- "2025-01-30 14:13:41,967 - INFO - logging.py:log():128 - Patient 18 arrives at: 133.617\n",
- "2025-01-30 14:13:41,967 - INFO - logging.py:log():128 - Patient 18 is seen by nurse after 0.000 minutes. Consultation length: 15.623 minutes.\n",
- "2025-01-30 14:13:41,968 - INFO - logging.py:log():128 - Patient 19 arrives at: 136.346\n",
- "2025-01-30 14:13:41,968 - INFO - logging.py:log():128 - Patient 19 is seen by nurse after 0.000 minutes. Consultation length: 4.688 minutes.\n",
- "2025-01-30 14:13:41,968 - INFO - logging.py:log():128 - Patient 20 arrives at: 144.142\n",
- "2025-01-30 14:13:41,968 - INFO - logging.py:log():128 - Patient 20 is seen by nurse after 0.000 minutes. Consultation length: 2.612 minutes.\n"
- ]
- }
- ],
- "source": [
- "# Mini run of simulation with logger enabled\n",
- "param = Defaults()\n",
- "param.warm_up_period = 50\n",
- "param.data_collection_period = 100\n",
- "param.number_of_runs = 1\n",
- "param.cores = 0\n",
- "param.logger = SimLogger(log_to_console=True, log_to_file=False)\n",
- "\n",
- "model = Model(param, run_number=0)\n",
- "model.run()"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "This will align with the recorded results of each patient (though we only save those that arrive after the warm-up period)."
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 28,
- "metadata": {},
- "outputs": [
- {
- "data": {
- "text/plain": [
- "[{'patient_id': 1,\n",
- " 'arrival_time': 51.90400587259546,\n",
- " 'q_time_nurse': 0.0,\n",
- " 'time_with_nurse': 24.91461474992887},\n",
- " {'patient_id': 2,\n",
- " 'arrival_time': 51.963434706622714,\n",
- " 'q_time_nurse': 0.0,\n",
- " 'time_with_nurse': 18.07891954142075},\n",
- " {'patient_id': 3,\n",
- " 'arrival_time': 74.3494580155259,\n",
- " 'q_time_nurse': 0.0,\n",
- " 'time_with_nurse': 3.1020092355006064},\n",
- " {'patient_id': 4,\n",
- " 'arrival_time': 77.53382703300574,\n",
- " 'q_time_nurse': 0.0,\n",
- " 'time_with_nurse': 26.744513862017026},\n",
- " {'patient_id': 5,\n",
- " 'arrival_time': 78.93233230430721,\n",
- " 'q_time_nurse': 0.0,\n",
- " 'time_with_nurse': 0.7481033661053572},\n",
- " {'patient_id': 6,\n",
- " 'arrival_time': 86.81473043550623,\n",
- " 'q_time_nurse': 0.0,\n",
- " 'time_with_nurse': 0.5277574384602378},\n",
- " {'patient_id': 7,\n",
- " 'arrival_time': 89.78326290873765,\n",
- " 'q_time_nurse': 0.0,\n",
- " 'time_with_nurse': 2.4349563515001904},\n",
- " {'patient_id': 8,\n",
- " 'arrival_time': 89.80720556833339,\n",
- " 'q_time_nurse': 0.0,\n",
- " 'time_with_nurse': 9.665598479334754},\n",
- " {'patient_id': 9,\n",
- " 'arrival_time': 93.11795080803735,\n",
- " 'q_time_nurse': 0.0,\n",
- " 'time_with_nurse': 7.004542644523265},\n",
- " {'patient_id': 10,\n",
- " 'arrival_time': 95.5976129513721,\n",
- " 'q_time_nurse': 0.0,\n",
- " 'time_with_nurse': 20.185457901243318},\n",
- " {'patient_id': 11,\n",
- " 'arrival_time': 98.01876244811595,\n",
- " 'q_time_nurse': 0.0,\n",
- " 'time_with_nurse': 7.651056296080149},\n",
- " {'patient_id': 12,\n",
- " 'arrival_time': 109.3788794635365,\n",
- " 'q_time_nurse': 0.0,\n",
- " 'time_with_nurse': 27.908241618400943},\n",
- " {'patient_id': 13,\n",
- " 'arrival_time': 110.32175512279903,\n",
- " 'q_time_nurse': 0.0,\n",
- " 'time_with_nurse': 16.81120548341985},\n",
- " {'patient_id': 14,\n",
- " 'arrival_time': 120.3416606997885,\n",
- " 'q_time_nurse': 0.0,\n",
- " 'time_with_nurse': 24.156692772991306},\n",
- " {'patient_id': 15,\n",
- " 'arrival_time': 121.64300082175524,\n",
- " 'q_time_nurse': 0.0,\n",
- " 'time_with_nurse': 1.4511207498950371},\n",
- " {'patient_id': 16,\n",
- " 'arrival_time': 127.826666746174,\n",
- " 'q_time_nurse': 0.0,\n",
- " 'time_with_nurse': 1.3431449546382932},\n",
- " {'patient_id': 17,\n",
- " 'arrival_time': 132.0550651782585,\n",
- " 'q_time_nurse': 0.0,\n",
- " 'time_with_nurse': 1.553948790248819},\n",
- " {'patient_id': 18,\n",
- " 'arrival_time': 133.6173285469348,\n",
- " 'q_time_nurse': 0.0,\n",
- " 'time_with_nurse': 15.623013920647606},\n",
- " {'patient_id': 19,\n",
- " 'arrival_time': 136.3456071646416,\n",
- " 'q_time_nurse': 0.0,\n",
- " 'time_with_nurse': 4.687792366565003},\n",
- " {'patient_id': 20,\n",
- " 'arrival_time': 144.14241169590065,\n",
- " 'q_time_nurse': 0.0,\n",
- " 'time_with_nurse': 2.6115609358702656}]"
- ]
- },
- "metadata": {},
- "output_type": "display_data"
- }
- ],
- "source": [
- "# Compare to patient-level results\n",
- "display(model.results_list)"
- ]
- },
{
"cell_type": "markdown",
"metadata": {},
@@ -2885,14 +2675,14 @@
},
{
"cell_type": "code",
- "execution_count": 29,
+ "execution_count": 27,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
- "Notebook run time: 0m 27s\n"
+ "Notebook run time: 0m 29s\n"
]
}
],
diff --git a/notebooks/choosing_parameters.ipynb b/notebooks/choosing_parameters.ipynb
index 3c65731..512fee7 100644
--- a/notebooks/choosing_parameters.ipynb
+++ b/notebooks/choosing_parameters.ipynb
@@ -73,7 +73,8 @@
"import plotly.graph_objects as go\n",
"import plotly.subplots as sp\n",
"\n",
- "from simulation.model import Defaults, Runner, summary_stats"
+ "from simulation.model import Param, Runner\n",
+ "from simulation.helper import summary_stats"
]
},
{
@@ -170,7 +171,7 @@
},
{
"cell_type": "code",
- "execution_count": 19,
+ "execution_count": 7,
"metadata": {},
"outputs": [],
"source": [
@@ -196,10 +197,9 @@
" \"\"\"\n",
" # Use default parameters, but with no warm-up and specified run length,\n",
" # and with no replications\n",
- " param = Defaults()\n",
- " param.warm_up_period = 0\n",
- " param.data_collection_period = data_collection_period\n",
- " param.number_of_runs = 1\n",
+ " param = Param(warm_up_period=0,\n",
+ " data_collection_period=data_collection_period,\n",
+ " number_of_runs=1)\n",
" # display(param.__dict__)\n",
"\n",
" # Run model\n",
@@ -286,13 +286,13 @@
},
{
"cell_type": "code",
- "execution_count": 20,
+ "execution_count": 8,
"metadata": {},
"outputs": [
{
"data": {
"image/svg+xml": [
- ""
+ ""
]
},
"metadata": {},
@@ -315,13 +315,13 @@
},
{
"cell_type": "code",
- "execution_count": 21,
+ "execution_count": 9,
"metadata": {},
"outputs": [
{
"data": {
"image/svg+xml": [
- ""
+ ""
]
},
"metadata": {},
@@ -382,8 +382,7 @@
" Contains mappings from variable names to full labels. If none\n",
" provided, will default to using variable names.\n",
" \"\"\"\n",
- " param = Defaults()\n",
- " param.number_of_runs = replications\n",
+ " param = Param(number_of_runs=replications)\n",
" choose_rep = Runner(param)\n",
" choose_rep.run_reps()\n",
"\n",
@@ -717,7 +716,7 @@
{
"data": {
"image/svg+xml": [
- ""
+ ""
]
},
"metadata": {},
@@ -1350,7 +1349,7 @@
{
"data": {
"image/svg+xml": [
- ""
+ ""
]
},
"metadata": {},
@@ -1646,7 +1645,7 @@
{
"data": {
"image/svg+xml": [
- ""
+ ""
]
},
"metadata": {},
@@ -1704,7 +1703,7 @@
{
"data": {
"image/svg+xml": [
- ""
+ ""
]
},
"metadata": {},
@@ -1718,8 +1717,7 @@
" print(f'Running with cores: {i}.')\n",
" start_time = time.time()\n",
"\n",
- " run_param = Defaults()\n",
- " run_param.cores = i\n",
+ " run_param = Param(cores=i)\n",
" experiment = Runner(run_param)\n",
" experiment.run_reps()\n",
"\n",
@@ -1759,7 +1757,7 @@
"name": "stdout",
"output_type": "stream",
"text": [
- "Notebook run time: 0m 25s\n"
+ "Notebook run time: 0m 26s\n"
]
}
],
diff --git a/notebooks/generate_exp_results.ipynb b/notebooks/generate_exp_results.ipynb
index 955b599..644535e 100644
--- a/notebooks/generate_exp_results.ipynb
+++ b/notebooks/generate_exp_results.ipynb
@@ -53,7 +53,7 @@
"import time\n",
"from IPython.display import display\n",
"\n",
- "from simulation.model import Defaults, Runner"
+ "from simulation.model import Param, Runner"
]
},
{
@@ -104,16 +104,17 @@
"outputs": [],
"source": [
"# Define model parameters\n",
- "param = Defaults()\n",
- "param.patient_inter = 4\n",
- "param.mean_n_consult_time = 10\n",
- "param.number_of_nurses = 4\n",
- "param.warm_up_period = 500\n",
- "param.data_collection_period = 1500\n",
- "param.number_of_runs = 5\n",
- "param.audit_interval = 50\n",
- "param.scenario_name = 0\n",
- "param.cores = 1\n",
+ "param = Param(\n",
+ " patient_inter=4,\n",
+ " mean_n_consult_time = 10,\n",
+ " number_of_nurses = 4,\n",
+ " warm_up_period = 500,\n",
+ " data_collection_period = 1500,\n",
+ " number_of_runs=5,\n",
+ " audit_interval = 50,\n",
+ " scenario_name = 0,\n",
+ " cores = 1\n",
+ ")\n",
"\n",
"# Run the replications\n",
"experiment = Runner(param)\n",
diff --git a/notebooks/logs.ipynb b/notebooks/logs.ipynb
new file mode 100644
index 0000000..a0c44b2
--- /dev/null
+++ b/notebooks/logs.ipynb
@@ -0,0 +1,261 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "# Logs\n",
+ "\n",
+ "The `SimLogger` class is used to log events during the simulation. These can be print to the console (`log_to_console`) or saved to a file (`log_to_file`).\n",
+ "\n",
+ "This will output lots of information to the screen - currently set to give information on each patient as they arrive and then see the nurse. Therefore, it is only best used when running the simulation for a short time with few patients.\n",
+ "\n",
+ "The logs in `model.py` can be altered to print your desired information during the simulation run, which can be helpful during development.\n",
+ "\n",
+ "## Set-up\n",
+ "\n",
+ "Load required packages."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# pylint: disable=missing-module-docstring\n",
+ "# To ensure any updates to `simulation/` are fetched without needing to restart\n",
+ "# the notebook environment, reload `simulation/` before execution of each cell\n",
+ "%load_ext autoreload\n",
+ "%autoreload 1\n",
+ "%aimport simulation\n",
+ "\n",
+ "# pylint: disable=wrong-import-position\n",
+ "import time\n",
+ "from IPython.display import display\n",
+ "\n",
+ "from simulation.logging import SimLogger\n",
+ "from simulation.model import Param, Model"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "Start timer."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "start_time = time.time()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Simulation run with logs"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "\u001b[1;36m0.000\u001b[0m: Initialise model: \n",
+ " \n",
+ "\u001b[1m{\u001b[0m \u001b[32m'audit_list'\u001b[0m: \u001b[1m[\u001b[0m\u001b[1m]\u001b[0m, \n",
+ " \u001b[32m'env'\u001b[0m: \u001b[32m'\u001b[0m\u001b[32m<\u001b[0m\u001b[32msimpy.core.Environment\u001b[0m\u001b[32m>'\u001b[0m\u001b[39m,\u001b[0m \n",
+ "\u001b[39m \u001b[0m\u001b[32m'nurse'\u001b[0m\u001b[39m: \u001b[0m\u001b[32m''\u001b[0m\u001b[39m,\u001b[0m \n",
+ "\u001b[39m \u001b[0m\u001b[32m'nurse_consult_count'\u001b[0m\u001b[39m: \u001b[0m\u001b[1;36m0\u001b[0m\u001b[39m,\u001b[0m \n",
+ "\u001b[39m \u001b[0m\u001b[32m'nurse_consult_time_dist'\u001b[0m\u001b[39m: \u001b[0m\u001b[32m''\u001b[0m\u001b[39m,\u001b[0m \n",
+ "\u001b[39m \u001b[0m\u001b[32m'nurse_time_used'\u001b[0m\u001b[39m: \u001b[0m\u001b[1;36m0\u001b[0m\u001b[39m,\u001b[0m \n",
+ "\u001b[39m \u001b[0m\u001b[32m'param'\u001b[0m\u001b[39m: \u001b[0m\u001b[32m''\u001b[0m\u001b[39m,\u001b[0m \n",
+ "\u001b[39m \u001b[0m\u001b[32m'patient_inter_arrival_dist'\u001b[0m\u001b[39m: \u001b[0m\u001b[32m'\u001b[0m\u001b[32m'\u001b[0m, \n",
+ " \u001b[32m'patients'\u001b[0m: \u001b[1m[\u001b[0m\u001b[1m]\u001b[0m, \n",
+ " \u001b[32m'results_list'\u001b[0m: \u001b[1m[\u001b[0m\u001b[1m]\u001b[0m, \n",
+ " \u001b[32m'run_number'\u001b[0m: \u001b[1;36m0\u001b[0m, \n",
+ " \u001b[32m'running_mean_nurse_wait'\u001b[0m: \u001b[1;36m0\u001b[0m\u001b[1m}\u001b[0m \n",
+ "\u001b[1;36m0.000\u001b[0m: Parameters: \n",
+ " \n",
+ "\u001b[1m{\u001b[0m \u001b[32m'_initialising'\u001b[0m: \u001b[3;91mFalse\u001b[0m, \n",
+ " \u001b[32m'audit_interval'\u001b[0m: \u001b[1;36m120\u001b[0m, \n",
+ " \u001b[32m'cores'\u001b[0m: \u001b[1;36m1\u001b[0m, \n",
+ " \u001b[32m'data_collection_period'\u001b[0m: \u001b[1;36m50\u001b[0m, \n",
+ " \u001b[32m'logger'\u001b[0m: \u001b[32m'\u001b[0m\u001b[32m<\u001b[0m\u001b[32msimulation.logging.SimLogger\u001b[0m\u001b[32m>\u001b[0m\u001b[32m'\u001b[0m, \n",
+ " \u001b[32m'mean_n_consult_time'\u001b[0m: \u001b[1;36m10\u001b[0m, \n",
+ " \u001b[32m'number_of_nurses'\u001b[0m: \u001b[1;36m1\u001b[0m, \n",
+ " \u001b[32m'number_of_runs'\u001b[0m: \u001b[1;36m1\u001b[0m, \n",
+ " \u001b[32m'patient_inter'\u001b[0m: \u001b[1;36m4\u001b[0m, \n",
+ " \u001b[32m'scenario_name'\u001b[0m: \u001b[1;36m0\u001b[0m, \n",
+ " \u001b[32m'warm_up_period'\u001b[0m: \u001b[1;36m30\u001b[0m\u001b[1m}\u001b[0m \n",
+ "\u001b[1;36m13.174\u001b[0m: 🔸 WU Patient \u001b[1;36m1\u001b[0m arrives at: \u001b[1;36m13.174\u001b[0m. \n",
+ "\u001b[1;36m13.174\u001b[0m: 🔶 WU Patient \u001b[1;36m1\u001b[0m is seen by nurse after \u001b[1;36m0.000\u001b[0m. Consultation length: \u001b[1;36m8.031\u001b[0m. \n",
+ "\u001b[1;36m16.227\u001b[0m: 🔸 WU Patient \u001b[1;36m2\u001b[0m arrives at: \u001b[1;36m16.227\u001b[0m. \n",
+ "\u001b[1;36m21.205\u001b[0m: 🔶 WU Patient \u001b[1;36m2\u001b[0m is seen by nurse after \u001b[1;36m4.979\u001b[0m. Consultation length: \u001b[1;36m3.820\u001b[0m. \n",
+ "\u001b[1;36m21.236\u001b[0m: 🔸 WU Patient \u001b[1;36m3\u001b[0m arrives at: \u001b[1;36m21.236\u001b[0m. \n",
+ "\u001b[1;36m22.140\u001b[0m: 🔸 WU Patient \u001b[1;36m4\u001b[0m arrives at: \u001b[1;36m22.140\u001b[0m. \n",
+ "\u001b[1;36m23.023\u001b[0m: 🔸 WU Patient \u001b[1;36m5\u001b[0m arrives at: \u001b[1;36m23.023\u001b[0m. \n",
+ "\u001b[1;36m25.025\u001b[0m: 🔶 WU Patient \u001b[1;36m3\u001b[0m is seen by nurse after \u001b[1;36m3.789\u001b[0m. Consultation length: \u001b[1;36m3.642\u001b[0m. \n",
+ "\u001b[1;36m28.667\u001b[0m: 🔶 WU Patient \u001b[1;36m4\u001b[0m is seen by nurse after \u001b[1;36m6.527\u001b[0m. Consultation length: \u001b[1;36m5.295\u001b[0m. \n",
+ "\u001b[1;36m30.000\u001b[0m: ────────── \n",
+ "\u001b[1;36m30.000\u001b[0m: Warm up complete. \n",
+ "\u001b[1;36m30.000\u001b[0m: ────────── \n",
+ "\u001b[1;36m30.223\u001b[0m: 🔹 DC Patient \u001b[1;36m1\u001b[0m arrives at: \u001b[1;36m30.223\u001b[0m. \n",
+ "\u001b[1;36m30.487\u001b[0m: 🔹 DC Patient \u001b[1;36m2\u001b[0m arrives at: \u001b[1;36m30.487\u001b[0m. \n",
+ "\u001b[1;36m33.962\u001b[0m: 🔶 WU Patient \u001b[1;36m5\u001b[0m is seen by nurse after \u001b[1;36m10.939\u001b[0m. Consultation length: \u001b[1;36m27.884\u001b[0m. \n",
+ "\u001b[1;36m34.089\u001b[0m: 🔹 DC Patient \u001b[1;36m3\u001b[0m arrives at: \u001b[1;36m34.089\u001b[0m. \n",
+ "\u001b[1;36m35.270\u001b[0m: 🔹 DC Patient \u001b[1;36m4\u001b[0m arrives at: \u001b[1;36m35.270\u001b[0m. \n",
+ "\u001b[1;36m44.470\u001b[0m: 🔹 DC Patient \u001b[1;36m5\u001b[0m arrives at: \u001b[1;36m44.470\u001b[0m. \n",
+ "\u001b[1;36m51.904\u001b[0m: 🔹 DC Patient \u001b[1;36m6\u001b[0m arrives at: \u001b[1;36m51.904\u001b[0m. \n",
+ "\u001b[1;36m51.963\u001b[0m: 🔹 DC Patient \u001b[1;36m7\u001b[0m arrives at: \u001b[1;36m51.963\u001b[0m. \n",
+ "\u001b[1;36m61.845\u001b[0m: 🔷 DC Patient \u001b[1;36m1\u001b[0m is seen by nurse after \u001b[1;36m31.623\u001b[0m. Consultation length: \u001b[1;36m19.610\u001b[0m. \n",
+ "\u001b[1;36m74.349\u001b[0m: 🔹 DC Patient \u001b[1;36m8\u001b[0m arrives at: \u001b[1;36m74.349\u001b[0m. \n",
+ "\u001b[1;36m77.534\u001b[0m: 🔹 DC Patient \u001b[1;36m9\u001b[0m arrives at: \u001b[1;36m77.534\u001b[0m. \n",
+ "\u001b[1;36m78.932\u001b[0m: 🔹 DC Patient \u001b[1;36m10\u001b[0m arrives at: \u001b[1;36m78.932\u001b[0m. \n"
+ ]
+ }
+ ],
+ "source": [
+ "# Mini run of simulation with logger enabled\n",
+ "param = Param(\n",
+ " warm_up_period=30,\n",
+ " data_collection_period=50,\n",
+ " number_of_nurses=1,\n",
+ " number_of_runs=1,\n",
+ " cores=1,\n",
+ " logger=SimLogger(log_to_console=True, log_to_file=True,\n",
+ " file_path='../outputs/logs/log_example.log',\n",
+ " sanitise=True)\n",
+ ")\n",
+ "\n",
+ "model = Model(param, run_number=0)\n",
+ "model.run()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "This will align with the recorded results of each patient (though we only save those that arrive after the warm-up period)."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "[{'patient_id': 1,\n",
+ " 'arrival_time': 30.22259995332274,\n",
+ " 'q_time_nurse': 31.622866260876396,\n",
+ " 'time_with_nurse': 19.610316954226214},\n",
+ " {'patient_id': 2,\n",
+ " 'arrival_time': 30.486546917468086,\n",
+ " 'q_time_nurse': nan,\n",
+ " 'time_with_nurse': nan},\n",
+ " {'patient_id': 3,\n",
+ " 'arrival_time': 34.08853250025673,\n",
+ " 'q_time_nurse': nan,\n",
+ " 'time_with_nurse': nan},\n",
+ " {'patient_id': 4,\n",
+ " 'arrival_time': 35.270388134803824,\n",
+ " 'q_time_nurse': nan,\n",
+ " 'time_with_nurse': nan},\n",
+ " {'patient_id': 5,\n",
+ " 'arrival_time': 44.47021063300173,\n",
+ " 'q_time_nurse': nan,\n",
+ " 'time_with_nurse': nan},\n",
+ " {'patient_id': 6,\n",
+ " 'arrival_time': 51.90400587259546,\n",
+ " 'q_time_nurse': nan,\n",
+ " 'time_with_nurse': nan},\n",
+ " {'patient_id': 7,\n",
+ " 'arrival_time': 51.963434706622714,\n",
+ " 'q_time_nurse': nan,\n",
+ " 'time_with_nurse': nan},\n",
+ " {'patient_id': 8,\n",
+ " 'arrival_time': 74.3494580155259,\n",
+ " 'q_time_nurse': nan,\n",
+ " 'time_with_nurse': nan},\n",
+ " {'patient_id': 9,\n",
+ " 'arrival_time': 77.53382703300574,\n",
+ " 'q_time_nurse': nan,\n",
+ " 'time_with_nurse': nan},\n",
+ " {'patient_id': 10,\n",
+ " 'arrival_time': 78.93233230430721,\n",
+ " 'q_time_nurse': nan,\n",
+ " 'time_with_nurse': nan}]"
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "# Compare to patient-level results\n",
+ "display(model.results_list)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Run time"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Notebook run time: 0m 0s\n"
+ ]
+ }
+ ],
+ "source": [
+ "# Get run time in seconds\n",
+ "end_time = time.time()\n",
+ "runtime = round(end_time - start_time)\n",
+ "\n",
+ "# Display converted to minutes and seconds\n",
+ "print(f'Notebook run time: {runtime // 60}m {runtime % 60}s')"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "template-des",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.13.1"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 2
+}
diff --git a/notebooks/time_weighted_averages.ipynb b/notebooks/time_weighted_averages.ipynb
new file mode 100644
index 0000000..2b9ae4b
--- /dev/null
+++ b/notebooks/time_weighted_averages.ipynb
@@ -0,0 +1,620 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "# Time-weighted averages\n",
+ "\n",
+ "This notebook provides two simple examples to explain the concept of **time-weighted averages**.\n",
+ "\n",
+ "These are then related back to the model, with the concept of the \"**area under the curve**\" and some exercepts from the model code."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# pylint: disable=missing-module-docstring\n",
+ "import plotly.express as px\n",
+ "import plotly.graph_objects as go\n",
+ "import plotly.io as pio\n",
+ "from plotly.subplots import make_subplots\n",
+ "\n",
+ "pio.renderers.default = 'svg'"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Example 1: Queue size"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "Here we have a given queue size over time:\n",
+ "\n",
+ "| Interval | Queue Size | Duration |\n",
+ "|-------------|------------|----------|\n",
+ "| 0 - 14.4 | 0 | 14.4 |\n",
+ "| 14.4 - 15.2 | 1 | 0.8 |\n",
+ "| 15.2 - 16.1 | 2 | 0.9 |\n",
+ "| 16.1 - 17.0 | 3 | 0.9 |\n",
+ "\n",
+ "You can see that, for most of the time, there is no-one in the queue, but then a few people join at the end.\n",
+ "\n",
+ "Hence, we would logically expect to see an average queue size fairly close to 0."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "image/svg+xml": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "# Given data\n",
+ "time = [0.0, 14.4, 15.2, 16.1, 17.0]\n",
+ "queue = [0, 1, 2, 3, 3]\n",
+ "\n",
+ "fig = px.line(x=time, y=queue)\n",
+ "fig.update_traces(mode='lines', line_shape='hv')\n",
+ "fig.update_xaxes(dtick=2)\n",
+ "fig.update_yaxes(dtick=1)\n",
+ "fig.update_layout(\n",
+ " xaxis_title='Time',\n",
+ " yaxis_title='Queue size',\n",
+ " template='plotly_white')\n",
+ "fig.show()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "### Simple average\n",
+ "\n",
+ "#### Formula for simple average queue size:\n",
+ "\n",
+ "$$\n",
+ "\\text{Simple Average} = \\frac{\\sum (\\text{Queue Size})}{\\text{Number of Intervals}}\n",
+ "$$\n",
+ "\n",
+ "#### Applying values:\n",
+ "\n",
+ "$$\n",
+ "= \\frac{0 + 1 + 2 + 3}{4}\n",
+ "$$\n",
+ "\n",
+ "$$\n",
+ "= \\frac{6}{4} = 1.5\n",
+ "$$\n",
+ "\n",
+ "With a simple average, we find quite a high average: 1.5."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Simple Average Queue Size: 1.5\n"
+ ]
+ }
+ ],
+ "source": [
+ "simple_avg = sum(queue[:-1]) / len(queue[:-1])\n",
+ "print(f'Simple Average Queue Size: {simple_avg}')"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "### Time-weighted average\n",
+ "\n",
+ "#### Formula for time-weighted average queue size:\n",
+ "\n",
+ "$$\n",
+ "\\text{Time-Weighted Average} = \\frac{\\sum (\\text{Queue Size} \\times \\text{Time Duration})}{\\text{Total Time}}\n",
+ "$$\n",
+ "\n",
+ "#### Applying values:\n",
+ "\n",
+ "$$\n",
+ "= \\frac{(0 \\times 14.4) + (1 \\times 0.8) + (2 \\times 0.9) + (3 \\times 0.9)}{17.0}\n",
+ "$$\n",
+ "\n",
+ "$$\n",
+ "= \\frac{0 + 0.8 + 1.8 + 2.7}{17.0}\n",
+ "$$\n",
+ "\n",
+ "$$\n",
+ "= \\frac{5.3}{17.0} \\approx 0.312\n",
+ "$$\n",
+ "\n",
+ "The time-weighted average better meets our expectations, being fairly close to 0."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Time-Weighted Average Queue Size: 0.3117647058823529\n"
+ ]
+ }
+ ],
+ "source": [
+ "# Compute time-weighted sum\n",
+ "weighted_sum = sum(\n",
+ " queue[i] * (time[i+1] - time[i]) for i in range(len(time)-1))\n",
+ "\n",
+ "# Total time duration\n",
+ "# pylint:disable=invalid-name\n",
+ "total_time = time[-1] - time[0]\n",
+ "\n",
+ "# Compute time-weighted average\n",
+ "time_weighted_avg = weighted_sum / total_time\n",
+ "\n",
+ "print(f'Time-Weighted Average Queue Size: {time_weighted_avg}')"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "### Relating this back to the model code\n",
+ "\n",
+ "In `model.py`, the queue length is recorded during the simulation using:\n",
+ "\n",
+ "```{python}\n",
+ "# Add \"area under curve\" of people in queue\n",
+ "# len(self.queue) is the number of requests queued\n",
+ "self.area_n_in_queue.append(len(self.queue) * time_since_last_event)\n",
+ "```\n",
+ "\n",
+ "At the end, this is used to calculate the time-weighted average queue length using:\n",
+ "\n",
+ "```{python}\n",
+ "'mean_nurse_q_length': (\n",
+ " model.nurse.area_n_in_queue /\n",
+ " self.param.data_collection_period)\n",
+ "```\n",
+ "\n",
+ "This is the same as the calculations performed above...\n",
+ "\n",
+ "$$\n",
+ "\\text{Time-Weighted Average} = \\frac{\\sum (\\text{Queue Size} \\times \\text{Time Duration})}{\\text{Total Time}}\n",
+ "$$\n",
+ "\n",
+ "$$\n",
+ "\\text{Time-Weighted Average} = \\frac{\\sum (\\text{len(self.queue)} \\times \\text{time-since-last-event})}{\\text{Total Time}}\n",
+ "$$\n",
+ "\n",
+ "The calculation `queue size x time duration` is gives us the area of a rectangle in the figure below. We sum these rectangles to get the total \"**area under the curve**\", and then divide by total time to get the time-weighted average. Hence, why this can be referred to as the \"area under the curve\" (and in the model, the calculation is saved as `self.area_n_in_queue`)."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "image/svg+xml": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "# Calculate durations (bar widths)\n",
+ "durations = [time[i+1] - time[i] for i in range(len(time)-1)]\n",
+ "\n",
+ "# Create bar plot\n",
+ "fig = go.Figure()\n",
+ "\n",
+ "fig.add_trace(go.Bar(\n",
+ " x=time[:-1], # Bar positions (start of each interval)\n",
+ " y=queue[:-1], # Queue sizes\n",
+ " width=durations, # Bar widths = time intervals\n",
+ " marker={'color': 'blue', 'opacity': 0.6},\n",
+ " name='Queue Size'\n",
+ "))\n",
+ "\n",
+ "# Update layout\n",
+ "fig.update_xaxes(dtick=2, range=[min(time), max(time)])\n",
+ "fig.update_yaxes(dtick=1)\n",
+ "fig.update_layout(\n",
+ " xaxis_title='Time',\n",
+ " yaxis_title='Queue Size',\n",
+ " title='Queue Size Over Time (Area Under Curve Visualisation)',\n",
+ " template='plotly_white',\n",
+ " bargap=0 # Ensures bars touch, mimicking area under curve\n",
+ ")\n",
+ "\n",
+ "fig.show()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Example 2: Utilisation\n",
+ "\n",
+ "Building on our queue example:\n",
+ "\n",
+ "* There are **2 nurses** in this system.\n",
+ "* Five patients arrive during the observation period - 2 at the start, and 3 near the end.\n",
+ "* **Patients A and B** are seen immediately, and each has a long consultation.\n",
+ "* **Patients C, D, and E** arrive and wait in the queue since no nurses are available, and are still waiting at the end of the observation period."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "| Time | Event | Patients in system | Nurses busy | Queue size |\n",
+ "| - | - | - | - | - |\n",
+ "| 0.0 | Clinic opens |0 | 0 | 0 |\n",
+ "| 1.2 | Patient A arrives, sees nurse for 19 | 1 | 1 | 0 |\n",
+ "| 2.3 | Patient B arrives, sees nurse for 20 | 2 | 2 | 0 |\n",
+ "| 14.4 | Patient C arrives, waits for nurse | 3 | 2 | 1 |\n",
+ "| 15.2 | Patient D arrives, waits for nurse | 4 | 2 | 2 |\n",
+ "| 16.1 | Patient E arrives, waits for nurse | 5 | 2 | 3 |\n",
+ "| 17.0 | Simulation ends, three patients waiting still | 5 | 2 | 3 |"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 6,
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "image/svg+xml": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "# Time stamps and number of nurses in use\n",
+ "time = [0.0, 1.2, 2.3, 14.4, 15.2, 16.1, 17.0]\n",
+ "nurse_in_use = [0, 1, 2, 2, 2, 2, 2]\n",
+ "\n",
+ "fig1 = px.line(x=time, y=nurse_in_use)\n",
+ "fig1.update_traces(mode='lines', line_shape='hv')\n",
+ "fig1.update_xaxes(dtick=2)\n",
+ "fig1.update_yaxes(dtick=0.5)\n",
+ "fig1.update_layout(\n",
+ " xaxis_title='Time',\n",
+ " yaxis_title='Nurses busy',\n",
+ " template='plotly_white')\n",
+ "fig1.show()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "The utilisation of the nurses will depend on how many are busy at each point in time.\n",
+ "\n",
+ "* When **no nurses** are busy, utilisation is **0.0** (i.e. 0%).\n",
+ "* When **one nurse** is busy, utilisation is **0.5** (i.e. 50%).\n",
+ "* When **two nurses** are busy, utilisation is **1.0** (i.e. 100%).\n",
+ "\n",
+ "We can convert this example to utilisation..."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 7,
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Utilisation: [0.0, 0.5, 1.0, 1.0, 1.0, 1.0, 1.0]\n"
+ ]
+ },
+ {
+ "data": {
+ "image/svg+xml": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "total_nurses = 2\n",
+ "\n",
+ "# Calculate utilisation at each timepoint\n",
+ "utilisation = [x/total_nurses for x in nurse_in_use]\n",
+ "print(f'Utilisation: {utilisation}')\n",
+ "\n",
+ "fig2 = px.line(x=time, y=utilisation)\n",
+ "fig2.update_traces(mode='lines', line_shape='hv')\n",
+ "fig2.update_xaxes(dtick=2)\n",
+ "fig2.update_yaxes(dtick=0.5)\n",
+ "fig2.update_layout(\n",
+ " xaxis_title='Time',\n",
+ " yaxis_title='Nurse utilisation',\n",
+ " template='plotly_white')\n",
+ "fig2.show()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "In this scenario, we'd expect to see an average utilisation close to 1, as there was full utilisation for most of the observation period."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "### Simple average\n",
+ "\n",
+ "#### Formula for simple average utilisation\n",
+ "\n",
+ "$$\n",
+ "\\text{Simple Average} = \\frac{\\sum (\\text{Utilisation})}{\\text{Number of Intervals}}\n",
+ "$$\n",
+ "\n",
+ "#### Applying values:\n",
+ "\n",
+ "$$\n",
+ "= \\frac{0 + 0.5 + 1 + 1 + 1 + 1 + 1}{7} = 0.7857\n",
+ "$$\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 8,
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Simple Average Utilisation: 0.7857142857142857\n"
+ ]
+ }
+ ],
+ "source": [
+ "simple_avg = sum(utilisation) / len(utilisation)\n",
+ "print(f'Simple Average Utilisation: {simple_avg}')"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "### Time-weighted average\n",
+ "\n",
+ "#### Formula for time-weighted utilisation\n",
+ "\n",
+ "$$\n",
+ "\\text{Time-Weighted Average} = \\frac{\\sum (\\text{Utilisation} \\times \\text{Time Duration})}{\\text{Total Time}}\n",
+ "$$\n",
+ "\n",
+ "#### Applying values:\n",
+ "\n",
+ "$$\n",
+ "= \\frac{(0 \\times 1.2) + (0.5 \\times 1.1) + (1 \\times 14.7)}{17.0}\n",
+ "$$\n",
+ "\n",
+ "$$\n",
+ "= \\frac{0 + 0.55 + 14.7}{17.0} \\approx 0.897\n",
+ "$$"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 9,
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Time-Weighted Average Utilisation: 0.8970588235294119\n"
+ ]
+ }
+ ],
+ "source": [
+ "weighted_sum = sum(\n",
+ " utilisation[i] * (time[i+1] - time[i]) for i in range(len(time)-1)\n",
+ ")\n",
+ "\n",
+ "# Total time duration\n",
+ "total_time = time[-1] - time[0]\n",
+ "\n",
+ "time_weighted_avg = weighted_sum / total_time\n",
+ "print(f'Time-Weighted Average Utilisation: {time_weighted_avg}')"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "When calculating the time-weighted average, we indeed observe a higher average utilisation, better reflecting the reality of utilisation during the observation period."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "### Relating this back to the model code\n",
+ "\n",
+ "In `model.py`, the simulation records resource usage as:\n",
+ "\n",
+ "```{python}\n",
+ "# Add \"area under curve\" of resources in use\n",
+ "# self.count is the number of resources in use\n",
+ "self.area_resource_busy.append(self.count * time_since_last_event)\n",
+ "```\n",
+ "\n",
+ "This is later used to calculate the time-weighted average utilisation:\n",
+ "\n",
+ "```{python}\n",
+ "'mean_nurse_utilisation_tw': (\n",
+ " model.nurse.area_resource_busy /\n",
+ " (self.param.number_of_nurses *\n",
+ " self.param.data_collection_period))\n",
+ "```\n",
+ "\n",
+ "The calculation for time-weighted average utilisation in our example above was:\n",
+ "\n",
+ "$$\n",
+ "\\text{Time-Weighted Average} = \\frac{\\sum (\\text{Utilisation} \\times \\text{Time Duration})}{\\text{Total Time}}\n",
+ "$$\n",
+ "\n",
+ "This is equivalent to the calculation in the code:\n",
+ "\n",
+ "$$\n",
+ "\\text{Time-Weighted Average} = \\frac{\\sum (\\text{Nurses in use} \\times \\text{Time Duration})}{\\text{Total Available Nurse Time}}\n",
+ "$$\n",
+ "\n",
+ "Both formulas give the same result because they are measuring the **proportion of time nurses were used relative to the total available nurse time**. One formula does it by calculating the utilisation fraction and the other does it directly by calculating nurse time.\n",
+ "\n",
+ "Remember - we are calculating area under the curve - and both are equivalent..."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 10,
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "image/svg+xml": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "# Create a subplot (side by side)\n",
+ "fig = make_subplots(rows=1, cols=2)\n",
+ "\n",
+ "# Add fig1 and fig2 to the subplot\n",
+ "fig.add_trace(fig1['data'][0], row=1, col=1)\n",
+ "fig.add_trace(fig2['data'][0], row=1, col=2)\n",
+ "\n",
+ "# Update layout\n",
+ "fig.update_layout(\n",
+ " template='plotly_white',\n",
+ " title='Nurses in Use vs Nurse Utilisation',\n",
+ " showlegend=False\n",
+ ")\n",
+ "\n",
+ "# Edit axis labels\n",
+ "fig['layout']['xaxis']['title']='Time'\n",
+ "fig['layout']['xaxis2']['title']='Time'\n",
+ "fig['layout']['yaxis']['title']='Nurses in Use'\n",
+ "fig['layout']['yaxis2']['title']='Nurse Utilisation'\n",
+ "\n",
+ "\n",
+ "fig.show()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "### Demonstrating equivalence\n",
+ "\n",
+ "If we don't multiply by the total number of nurses and simply divide by the total time, like this:\n",
+ "\n",
+ "$$\n",
+ "\\text{Time-Weighted Average} = \\frac{\\sum (\\text{Nurses in use} \\times \\text{Time Duration})}{\\text{Total Time}}\n",
+ "$$\n",
+ "\n",
+ "We would be calculating the time-weighted **average number of nurses in use** over time, rather than nurse utilisation.\n",
+ "\n",
+ "To correctly calculate utilisation, we need to divide by the total available nurse time (i.e., total nurses multiplied by the total time). This ensures that we measure how efficiently the available nurses are being utilised, not just the raw number of nurses in use.\n",
+ "\n",
+ "We can see this by recalculating our original data...\n",
+ "\n",
+ "```\n",
+ "time = [0.0, 1.2, 2.3, 14.4, 15.2, 16.1, 17.0]\n",
+ "nurse_in_use = [0, 1, 2, 2, 2, 2, 2]\n",
+ "```\n",
+ "\n",
+ "So time-weighted average number of nurses in use...\n",
+ "\n",
+ "$$\n",
+ "= \\frac{(0 \\times 1.2) + (1 \\times 1.1) + (2 \\times 14.7)}{17.0}\n",
+ "$$\n",
+ "\n",
+ "$$\n",
+ "= \\frac{0 + 1.1 + 29.4}{17.0} = \\frac{30.5}{17.0} \\approx 1.794\n",
+ "$$\n",
+ "\n",
+ "And then time-weighted average nurse utilisation, it's total available nurse time, which is `17 x 2` so 34...\n",
+ "\n",
+ "$$\n",
+ "\\frac{30.5}{34.0} \\approx 0.897\n",
+ "$$\n",
+ "\n",
+ "And you can see this matches the calculated utilisation above."
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "template-des",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.13.1"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 2
+}
diff --git a/outputs/logs/log_example.log b/outputs/logs/log_example.log
new file mode 100644
index 0000000..8723e5b
--- /dev/null
+++ b/outputs/logs/log_example.log
@@ -0,0 +1,51 @@
+0.000: Initialise model:
+
+{ 'audit_list': [],
+ 'env': '',
+ 'nurse': '',
+ 'nurse_consult_count': 0,
+ 'nurse_consult_time_dist': '',
+ 'nurse_time_used': 0,
+ 'param': '',
+ 'patient_inter_arrival_dist': '',
+ 'patients': [],
+ 'results_list': [],
+ 'run_number': 0,
+ 'running_mean_nurse_wait': 0}
+0.000: Parameters:
+
+{ '_initialising': False,
+ 'audit_interval': 120,
+ 'cores': 1,
+ 'data_collection_period': 50,
+ 'logger': '',
+ 'mean_n_consult_time': 10,
+ 'number_of_nurses': 1,
+ 'number_of_runs': 1,
+ 'patient_inter': 4,
+ 'scenario_name': 0,
+ 'warm_up_period': 30}
+13.174: 🔸 WU Patient 1 arrives at: 13.174.
+13.174: 🔶 WU Patient 1 is seen by nurse after 0.000. Consultation length: 8.031.
+16.227: 🔸 WU Patient 2 arrives at: 16.227.
+21.205: 🔶 WU Patient 2 is seen by nurse after 4.979. Consultation length: 3.820.
+21.236: 🔸 WU Patient 3 arrives at: 21.236.
+22.140: 🔸 WU Patient 4 arrives at: 22.140.
+23.023: 🔸 WU Patient 5 arrives at: 23.023.
+25.025: 🔶 WU Patient 3 is seen by nurse after 3.789. Consultation length: 3.642.
+28.667: 🔶 WU Patient 4 is seen by nurse after 6.527. Consultation length: 5.295.
+30.000: ──────────
+30.000: Warm up complete.
+30.000: ──────────
+30.223: 🔹 DC Patient 1 arrives at: 30.223.
+30.487: 🔹 DC Patient 2 arrives at: 30.487.
+33.962: 🔶 WU Patient 5 is seen by nurse after 10.939. Consultation length: 27.884.
+34.089: 🔹 DC Patient 3 arrives at: 34.089.
+35.270: 🔹 DC Patient 4 arrives at: 35.270.
+44.470: 🔹 DC Patient 5 arrives at: 44.470.
+51.904: 🔹 DC Patient 6 arrives at: 51.904.
+51.963: 🔹 DC Patient 7 arrives at: 51.963.
+61.845: 🔷 DC Patient 1 is seen by nurse after 31.623. Consultation length: 19.610.
+74.349: 🔹 DC Patient 8 arrives at: 74.349.
+77.534: 🔹 DC Patient 9 arrives at: 77.534.
+78.932: 🔹 DC Patient 10 arrives at: 78.932.
diff --git a/outputs/logs/placeholder b/outputs/logs/placeholder
deleted file mode 100644
index e69de29..0000000
diff --git a/requirements.txt b/requirements.txt
index 2d6ec97..1ce5b34 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -11,5 +11,6 @@ plotly_express==0.4.1
pylint==3.3.4
pytest==8.3.4
pytest-xdist==3.6.1
+rich==13.9.4
simpy==4.1.1
-e .[dev]
\ No newline at end of file
diff --git a/simulation/helper.py b/simulation/helper.py
new file mode 100644
index 0000000..51bbcba
--- /dev/null
+++ b/simulation/helper.py
@@ -0,0 +1,46 @@
+"""Helper functions.
+
+Other helpful functions used in the code that do not set up or run
+the simulation model.
+
+Licence:
+ This project is licensed under the MIT Licence. See the LICENSE file for
+ more details.
+
+Typical usage example:
+ mean, std_dev, ci_lower, ci_upper = summary_stats(data)
+"""
+import numpy as np
+import scipy.stats as st
+
+
+def summary_stats(data):
+ """
+ Calculate mean, standard deviation and 95% confidence interval (CI).
+
+ Arguments:
+ data (pd.Series):
+ Data to use in calculation.
+
+ Returns:
+ tuple: (mean, standard deviation, CI lower, CI upper).
+ """
+ mean = data.mean()
+ count = len(data)
+
+ # Cannot calculate some metrics if there is only 1 sample in data
+ if count == 1:
+ std_dev = np.nan
+ ci_lower = np.nan
+ ci_upper = np.nan
+ else:
+ std_dev = data.std()
+ # Calculation of CI uses t-distribution, which is suitable for
+ # smaller sample sizes (n<30)
+ ci_lower, ci_upper = st.t.interval(
+ confidence=0.95,
+ df=count-1,
+ loc=mean,
+ scale=st.sem(data))
+
+ return mean, std_dev, ci_lower, ci_upper
diff --git a/simulation/logging.py b/simulation/logging.py
index 1549a91..79649bb 100644
--- a/simulation/logging.py
+++ b/simulation/logging.py
@@ -4,7 +4,8 @@
more permanent record.
Credit:
- > This code is adapted from NHS Digital (2024) RAP repository template
+ > Use of the logging module was initially inspired and adapted from NHS
+ Digital (2024) RAP repository template
(https://github.com/NHSDigital/rap-package-template) (MIT Licence).
Licence:
@@ -20,9 +21,12 @@
import logging
import os
-import sys
+from pprint import pformat
import time
+from rich.logging import RichHandler
+from rich.console import Console
+
class SimLogger:
"""
@@ -35,12 +39,16 @@ class SimLogger:
Whether to save log to a file.
file_path (str):
Path to save log to file.
+ sanitise (boolean):
+ Whether to sanitise dictionaries to remove memory addresses in
+ logs, default False.
logger (logging.Logger):
The logging instance used for logging messages.
"""
def __init__(self, log_to_console=False, log_to_file=False,
file_path=('../outputs/logs/' +
- f'{time.strftime("%Y-%m-%d_%H-%M-%S")}.log')):
+ f'{time.strftime("%Y-%m-%d_%H-%M-%S")}.log'),
+ sanitise=False):
"""
Initialise the Logger class.
@@ -53,10 +61,14 @@ def __init__(self, log_to_console=False, log_to_file=False,
Path to save log to file. Note, if you use an existing .log
file name, it will append to that log. Defaults to filename
based on current date and time, and folder '../outputs/log/'.
+ sanitise (boolean):
+ Whether to sanitise dictionaries to remove memory addresses
+ in logs, default False.
"""
self.log_to_console = log_to_console
self.log_to_file = log_to_file
self.file_path = file_path
+ self.sanitise = sanitise
self.logger = None
# If saving to file, check path is valid
@@ -95,34 +107,77 @@ def _configure_logging(self):
for handler in self.logger.handlers[:]:
self.logger.removeHandler(handler)
+ # Configure RichHandler without INFO/ERROR labels, times or paths
+ # to log message. Set up console with jupyter-specific behaviour
+ # disabled to prevent large gaps between each log message on ipynb.
+ console = Console()
+ console.is_jupyter = False
+ rich_handler = RichHandler(console=console, show_time=False,
+ show_level=False, show_path=False)
+
# Add handlers for saving messages to file and/or printing to console
handlers = []
if self.log_to_file:
- handlers.append(logging.FileHandler(self.file_path))
+ # In write mode, meaning will overwrite existing log of same name
+ # (append mode 'a' would add to the end of the log)
+ handlers.append(logging.FileHandler(self.file_path, mode='w'))
if self.log_to_console:
- handlers.append(logging.StreamHandler(sys.stdout))
+ handlers.append(rich_handler)
+ #handlers.append(logging.StreamHandler(sys.stdout))
# Add handlers directly to the logger
for handler in handlers:
self.logger.addHandler(handler)
- # Set logging level and format. Level 'INFO' means it's purpose is to
- # confirm things are working as expected.
+ # Set logging level and format. If don't set level info, it would
+ # only show log messages which are warning, error or critical.
self.logger.setLevel(logging.INFO)
- formatter = logging.Formatter(
- '%(asctime)s - %(levelname)s - %(filename)s:'
- '%(funcName)s():%(lineno)d - %(message)s'
- )
+ formatter = logging.Formatter('%(message)s')
for handler in handlers:
handler.setFormatter(formatter)
- def log(self, msg):
+ def sanitise_object(self, obj):
+ """
+ Sanitise object references to avoid memory addresses in logs.
+
+ Arguments:
+ obj (object):
+ Object to sanitise
+
+ Returns:
+ str:
+ Sanitised version of the object. If it's an object,
+ it returns the class name; otherwise, it returns the
+ object itself.
+ """
+ # Only sanitise custom objects (not basic types like int, str, etc.)
+ if isinstance(obj, object) and not isinstance(
+ obj, (int, float, bool, str, list, dict, tuple, set)
+ ):
+ # Return the class name instead of the memory address
+ return f'<{obj.__class__.__module__}.{obj.__class__.__name__}>'
+ return obj
+
+ def log(self, msg, sim_time=None):
"""
Log a message if logging is enabled.
Arguments:
msg (str):
Message to log.
+ sim_time (float|None, optional):
+ Current simulation time. If provided, prints before message.
"""
+ # Sanitise (if enabled) and pretty format dictionaries
+ if isinstance(msg, dict):
+ if self.sanitise:
+ msg = {key: self.sanitise_object(value)
+ for key, value in msg.items()}
+ msg = pformat(msg, indent=4)
+
if self.log_to_console or self.log_to_file:
- self.logger.info(msg)
+ # Log message, with simulation time rounded to 3dp if given.
+ if sim_time is not None:
+ self.logger.info('%0.3f: %s', sim_time, msg)
+ else:
+ self.logger.info(msg)
diff --git a/simulation/model.py b/simulation/model.py
index 63dbfba..c494aab 100644
--- a/simulation/model.py
+++ b/simulation/model.py
@@ -28,68 +28,85 @@
more details.
Typical usage example:
- experiment = Runner(param=Defaults())
+ experiment = Runner(param=Param())
experiment.run_reps()
print(experiment.run_results_df)
"""
import itertools
-from joblib import Parallel, delayed
+
+from joblib import Parallel, delayed, cpu_count
import numpy as np
import pandas as pd
-import scipy.stats as st
import simpy
+
from simulation.logging import SimLogger
+from simulation.helper import summary_stats
-class Defaults:
+class Param:
"""
Default parameters for simulation.
- Attributes:
- patient_inter (float):
- Mean inter-arrival time between patients in minutes.
- mean_n_consult_time (float):
- Mean nurse consultation time in minutes.
- number_of_nurses (float):
- Number of available nurses.
- warm_up_period (int):
- Duration of the warm-up period in minutes - running simulation but
- not yet collecting results.
- data_collection_period (int):
- Duration of data collection period in minutes (also known as the
- measurement interval) - which begins after any warm-up period.
- number_of_runs (int):
- The number of runs (also known as replications), defining how many
- times to re-run the simulation (with different random numbers).
- audit_interval (int):
- How frequently to audit resource utilisation, in minutes.
- scenario_name (int|float|string):
- Label for the scenario.
- cores (int):
- Number of CPU cores to use for parallel execution. Set to
- desired number, or to -1 to use all available cores. For
- sequential execution, set to 1 (default).
- logger (logging.Logger):
- The logging instance used for logging messages.
+ Attributes are described in initialisation docstring.
"""
- def __init__(self):
+ # pylint: disable=too-many-arguments,too-many-positional-arguments
+ def __init__(
+ self,
+ patient_inter=4,
+ mean_n_consult_time=10,
+ number_of_nurses=5,
+ warm_up_period=1440*13, # 13 days
+ data_collection_period=1440*30, # 30 days
+ number_of_runs=31,
+ audit_interval=120, # every 2 hours
+ scenario_name=0,
+ cores=-1,
+ logger=SimLogger(log_to_console=False, log_to_file=False)
+ ):
"""
Initalise instance of parameters class.
+
+ Arguments:
+ patient_inter (float):
+ Mean inter-arrival time between patients in minutes.
+ mean_n_consult_time (float):
+ Mean nurse consultation time in minutes.
+ number_of_nurses (float):
+ Number of available nurses.
+ warm_up_period (int):
+ Duration of the warm-up period in minutes - running simulation
+ but not yet collecting results.
+ data_collection_period (int):
+ Duration of data collection period in minutes (also known as
+ measurement interval) - which begins after any warm-up period.
+ number_of_runs (int):
+ The number of runs (i.e. replications), defining how many
+ times to re-run the simulation (with different random numbers).
+ audit_interval (int):
+ How frequently to audit resource utilisation, in minutes.
+ scenario_name (int|float|string):
+ Label for the scenario.
+ cores (int):
+ Number of CPU cores to use for parallel execution. Set to
+ desired number, or to -1 to use all available cores. For
+ sequential execution, set to 1 (default).
+ logger (logging.Logger):
+ The logging instance used for logging messages.
"""
# Disable restriction on attribute modification during initialisation
object.__setattr__(self, '_initialising', True)
- self.patient_inter = 4
- self.mean_n_consult_time = 10
- self.number_of_nurses = 5
- self.warm_up_period = 1440*13 # 13 days
- self.data_collection_period = 1440*30 # 30 days
- self.number_of_runs = 31
- self.audit_interval = 120 # every 2 hours
- self.scenario_name = 0
- self.cores = -1
- self.logger = SimLogger(log_to_console=False, log_to_file=False)
+ self.patient_inter = patient_inter
+ self.mean_n_consult_time = mean_n_consult_time
+ self.number_of_nurses = number_of_nurses
+ self.warm_up_period = warm_up_period
+ self.data_collection_period = data_collection_period
+ self.number_of_runs = number_of_runs
+ self.audit_interval = audit_interval
+ self.scenario_name = scenario_name
+ self.cores = cores
+ self.logger = logger
# Re-enable attribute checks after initialisation
object.__setattr__(self, '_initialising', False)
@@ -129,38 +146,6 @@ def __setattr__(self, name, value):
f'modify existing attributes: {self.__dict__.keys()}')
-def summary_stats(data):
- """
- Calculate mean, standard deviation and 95% confidence interval (CI).
-
- Arguments:
- data (pd.Series):
- Data to use in calculation.
-
- Returns:
- tuple: (mean, standard deviation, CI lower, CI upper).
- """
- mean = data.mean()
- count = len(data)
-
- # Cannot calculate some metrics if there is only 1 sample in data
- if count == 1:
- std_dev = np.nan
- ci_lower = np.nan
- ci_upper = np.nan
- else:
- std_dev = data.std()
- # Calculation of CI uses t-distribution, which is suitable for
- # smaller sample sizes (n<30)
- ci_lower, ci_upper = st.t.interval(
- confidence=0.95,
- df=count-1,
- loc=mean,
- scale=st.sem(data))
-
- return mean, std_dev, ci_lower, ci_upper
-
-
class Patient:
"""
Represents a patient.
@@ -351,7 +336,7 @@ class Model:
nurse, have a consultation with the nurse, and then leave.
Attributes:
- param (Defaults):
+ param (Param):
Simulation parameters.
run_number (int):
Run number for random seed generation.
@@ -385,9 +370,9 @@ def __init__(self, param, run_number):
Initalise a new model.
Arguments:
- param (Defaults, optional):
+ param (Param, optional):
Simulation parameters. Defaults to new instance of the
- Defaults() class.
+ Param() class.
run_number (int):
Run number for random seed generation.
"""
@@ -395,6 +380,9 @@ def __init__(self, param, run_number):
self.param = param
self.run_number = run_number
+ # Check validity of provided parameters
+ self.valid_inputs()
+
# Create simpy environment and resource
self.env = simpy.Environment()
self.nurse = MonitoredResource(self.env,
@@ -419,6 +407,16 @@ def __init__(self, param, run_number):
self.nurse_consult_time_dist = Exponential(
mean=self.param.mean_n_consult_time, random_seed=seeds[1])
+ # Log model initialisation
+ self.param.logger.log(sim_time=self.env.now, msg='Initialise model:\n')
+ self.param.logger.log(vars(self))
+ self.param.logger.log(sim_time=self.env.now, msg='Parameters:\n ')
+ self.param.logger.log(vars(self.param))
+
+ def valid_inputs(self):
+ """
+ Checks validity of provided parameters.
+ """
# Define validation rules for attributes
# Doesn't include number_of_nurses as this is tested by simpy.Resource
validation_rules = {
@@ -442,10 +440,6 @@ def __init__(self, param, run_number):
'equal to 0.'
)
- # Log model initialisation
- self.param.logger.log(f'Initialised model: {vars(self)}')
- self.param.logger.log(f'Parameters: {vars(self.param)}')
-
def generate_patient_arrivals(self):
"""
Generate patient arrivals.
@@ -465,8 +459,14 @@ def generate_patient_arrivals(self):
self.patients.append(p)
# Log arrival time
+ if p.arrival_time < self.param.warm_up_period:
+ arrive_pre = '\U0001F538 WU'
+ else:
+ arrive_pre = '\U0001F539 DC'
self.param.logger.log(
- f'Patient {p.patient_id} arrives at: {p.arrival_time:.3f}'
+ sim_time=self.env.now,
+ msg=(f'{arrive_pre} Patient {p.patient_id} arrives at: ' +
+ f'{p.arrival_time:.3f}.')
)
# Start process of attending clinic
@@ -499,10 +499,15 @@ def attend_clinic(self, patient):
patient.time_with_nurse = self.nurse_consult_time_dist.sample()
# Log wait time and time spent with nurse
+ if patient.arrival_time < self.param.warm_up_period:
+ nurse_pre = '\U0001F536 WU'
+ else:
+ nurse_pre = '\U0001F537 DC'
self.param.logger.log(
- f'Patient {patient.patient_id} is seen by nurse after ' +
- f'{patient.q_time_nurse:.3f} minutes. Consultation length: ' +
- f'{patient.time_with_nurse:.3f} minutes.'
+ sim_time=self.env.now,
+ msg=(f'{nurse_pre} Patient {patient.patient_id} is seen ' +
+ f'by nurse after {patient.q_time_nurse:.3f}. ' +
+ f'Consultation length: {patient.time_with_nurse:.3f}.')
)
# Update the total nurse time used.
@@ -572,9 +577,12 @@ def warm_up_complete(self):
# If there was a warm-up period, log that this time has passed so
# can distinguish between patients before and after warm-up in logs
if self.param.warm_up_period > 0:
- self.param.logger.log('─' * 10)
- self.param.logger.log(f'{self.env.now:.2f}: Warm up complete.')
- self.param.logger.log('─' * 10)
+ self.param.logger.log(sim_time=self.env.now,
+ msg='─' * 10)
+ self.param.logger.log(sim_time=self.env.now,
+ msg='Warm up complete.')
+ self.param.logger.log(sim_time=self.env.now,
+ msg='─' * 10)
def run(self):
"""
@@ -623,7 +631,7 @@ class Runner:
(replications).
Attributes:
- param (Defaults):
+ param (Param):
Simulation parameters.
patient_results_df (pandas.DataFrame):
Dataframe to store patient-level results.
@@ -639,7 +647,7 @@ def __init__(self, param):
Initialise a new instance of the Runner class.
Arguments:
- param (Defaults):
+ param (Param):
Simulation parameters.
'''
# Store model parameters
@@ -711,6 +719,29 @@ def run_reps(self):
for run in range(self.param.number_of_runs)]
# Parallel execution
else:
+
+ # Check number of cores is valid - must be -1, or between 1 and
+ # total CPUs-1 (saving one for logic control).
+ # Done here rather than in model as this is called before model,
+ # and only relevant for Runner.
+ valid_cores = [-1] + list(range(1, cpu_count()))
+ if self.param.cores not in valid_cores:
+ raise ValueError(
+ f'Invalid cores: {self.param.cores}. Must be one of: ' +
+ f'{valid_cores}.')
+
+ # Warn users that logging will not run as it is in parallel
+ if (
+ self.param.logger.log_to_console or
+ self.param.logger.log_to_file
+ ):
+ self.param.logger.log(
+ 'WARNING: Logging is disabled in parallel ' +
+ '(multiprocessing mode). Simulation log will not appear.' +
+ ' If you wish to generate logs, switch to `cores=1`, or ' +
+ 'just run one replication with `run_single()`.')
+
+ # Execute replications
all_results = Parallel(n_jobs=self.param.cores)(
delayed(self.run_single)(run)
for run in range(self.param.number_of_runs))
@@ -771,11 +802,9 @@ def run_scenarios(scenarios):
for index, scenario_to_run in enumerate(all_scenarios_dicts):
print(scenario_to_run)
- # Overwrite defaults from the passed dictionary
- param = Defaults()
- param.scenario_name = index
- for key in scenario_to_run:
- setattr(param, key, scenario_to_run[key])
+ # Pass scenario arguments to Param()
+ param = Param(scenario_name=index,
+ **scenario_to_run)
# Perform replications and keep results from each run, adding the
# scenario values to the results dataframe
diff --git a/tests/test_backtest.py b/tests/test_backtest.py
index 61d97f9..4a7c2b5 100644
--- a/tests/test_backtest.py
+++ b/tests/test_backtest.py
@@ -14,7 +14,7 @@
from pathlib import Path
import pandas as pd
-from simulation.model import Defaults, Runner
+from simulation.model import Param, Runner
def test_reproduction():
@@ -23,16 +23,17 @@ def test_reproduction():
generated using the code.
"""
# Choose a specific set of parameters
- param = Defaults()
- param.patient_inter = 4
- param.mean_n_consult_time = 10
- param.number_of_nurses = 4
- param.warm_up_period = 500
- param.data_collection_period = 1500
- param.number_of_runs = 5
- param.audit_interval = 50
- param.scenario_name = 0
- param.cores = 1
+ param = Param(
+ patient_inter=4,
+ mean_n_consult_time=10,
+ number_of_nurses=4,
+ warm_up_period=500,
+ data_collection_period=1500,
+ number_of_runs=5,
+ audit_interval=50,
+ scenario_name=0,
+ cores=1
+ )
# Run the replications
experiment = Runner(param)
diff --git a/tests/test_unittest_logger.py b/tests/test_unittest_logger.py
index ef447c5..2610453 100644
--- a/tests/test_unittest_logger.py
+++ b/tests/test_unittest_logger.py
@@ -26,7 +26,7 @@ def test_log_to_console():
"""
with patch('sys.stdout', new_callable=StringIO) as mock_stdout:
logger = SimLogger(log_to_console=True)
- logger.log('Test console log')
+ logger.log(sim_time=None, msg='Test console log')
# Check if console output matches
assert 'Test console log' in mock_stdout.getvalue()
@@ -40,11 +40,11 @@ def test_log_to_file():
with patch('builtins.open', new_callable=MagicMock) as mock_open:
# Create the logger and log a simple example
logger = SimLogger(log_to_file=True, file_path='test.log')
- logger.log('Log message')
+ logger.log(sim_time=None, msg='Log message')
- # Check that the file was opened in append mode at the absolute path
+ # Check that the file was opened in write mode at the absolute path
mock_open.assert_called_with(
- os.path.abspath('test.log'), 'a', encoding='locale', errors=None)
+ os.path.abspath('test.log'), 'w', encoding='locale', errors=None)
# Verify a FileHandler is attached to the logger
assert (any(isinstance(handler, logging.FileHandler)
diff --git a/tests/test_unittest_model.py b/tests/test_unittest_model.py
index 2436c29..8904dd6 100644
--- a/tests/test_unittest_model.py
+++ b/tests/test_unittest_model.py
@@ -12,19 +12,23 @@
pytest
"""
+from joblib import cpu_count
import numpy as np
import pandas as pd
import pytest
import simpy
from simulation.model import (
- Defaults, Exponential, Model, Runner, MonitoredResource)
+ Param, Exponential, Model, Runner, MonitoredResource)
def test_new_attribute():
"""
- Confirm that it is not possible to add new attributes to Defaults.
+ Confirm that it is impossible to add new attributes to the parameter class.
"""
- param = Defaults()
+ # No need to test when creating class (e.g. Param(new_entry=3)) as it will
+ # not allow input of variables not inputs for __init__.
+ # However, do need to check it is preventing additions after creating class
+ param = Param()
with pytest.raises(AttributeError,
match='only possible to modify existing attributes'):
param.new_entry = 3
@@ -44,17 +48,15 @@ def test_negative_inputs(param_name, value, rule):
Arguments:
param_name (string):
- Name of parameter to change from the Defaults() class.
+ Name of parameter to change in the Param() class.
value (float|int):
Invalid value for parameter.
rule (string):
Either 'positive' (if value must be > 0) or 'non-negative' (if
value must be >= 0).
"""
- param = Defaults()
-
- # Set parameter to an invalid value
- setattr(param, param_name, value)
+ # Create parameter class with an invalid value
+ param = Param(**{param_name: value})
# Construct the expected error message
if rule == 'positive':
@@ -73,7 +75,7 @@ def test_negative_results():
Check that values are non-negative.
"""
# Run model with standard parameters
- model = Model(param=Defaults(), run_number=0)
+ model = Model(param=Param(), run_number=0)
model.run()
# Check that at least one patient was processed
@@ -100,9 +102,8 @@ def test_high_demand():
unseen patients are still in the dataset.
"""
# Run model with high number of arrivals and only one nurse
- param = Defaults()
- param.number_of_nurses = 1
- param.patient_inter = 0.1
+ param = Param(number_of_nurses=1,
+ patient_inter=0.1)
experiment = Runner(param)
results = experiment.run_single(run=0)
@@ -149,9 +150,8 @@ def test_warmup_only():
and then checking that results are all zero or empty.
"""
# Run model with only a warm-up period and no time for results collection.
- param = Defaults()
- param.warm_up_period = 500
- param.data_collection_period = 0
+ param = Param(warm_up_period=500,
+ data_collection_period=0)
model = Model(param, run_number=0)
model.run()
@@ -184,10 +184,9 @@ def helper_warmup(warm_up_period):
Duration of the warm-up period - running simulation but not yet
collecting results.
"""
- param = Defaults()
- param.patient_inter = 1
- param.warm_up_period = warm_up_period
- param.data_collection_period = 1500
+ param = Param(patient_inter=1,
+ warm_up_period=warm_up_period,
+ data_collection_period=1500)
experiment = Runner(param)
return experiment.run_single(run=0)
@@ -261,7 +260,7 @@ def test_arrivals():
Check that count of arrivals in each run is consistent with the number of
patients recorded in the patient-level results.
"""
- experiment = Runner(Defaults())
+ experiment = Runner(Param())
experiment.run_reps()
# Get count of patients from patient-level and run results
@@ -289,7 +288,7 @@ def test_waiting_time_utilisation(param_name, initial_value, adjusted_value):
Arguments:
param_name (string):
- Name of parameter to change from the Defaults() class.
+ Name of parameter to change in the Param() class.
initial_value (float|int):
Value with which we expect longer waiting times.
adjusted_value (float|int):
@@ -314,13 +313,10 @@ def helper_param(param_name, value):
# Create a default parameter, but set some specific values
# (which will ensure sufficient arrivals/capacity/etc. that we will
# see variation in wait time, and not just no wait time with all
- # different parameters tried).
- param = Defaults()
- param.number_of_nurses = 4
- param.patient_inter = 3
- param.mean_n_consult_time = 15
-
- # Modify chosen parameter for the test
+ # different parameters tried), then modify chosen parameter for test.
+ param = Param(number_of_nurses=4,
+ patient_inter=3,
+ mean_n_consult_time=15)
setattr(param, param_name, value)
# Run replications and return the mean queue time for nurses
@@ -359,14 +355,12 @@ def test_arrivals_decrease(param_name, initial_value, adjusted_value):
Test that adjusting parameters reduces the number of arrivals as expected.
"""
# Run model with initial value
- param = Defaults()
- setattr(param, param_name, initial_value)
+ param = Param(**{param_name: initial_value})
experiment = Runner(param)
initial_arrivals = experiment.run_single(run=0)['run']['arrivals']
# Run model with adjusted value
- param = Defaults()
- setattr(param, param_name, adjusted_value)
+ param = Param(**{param_name: adjusted_value})
experiment = Runner(param)
adjusted_arrivals = experiment.run_single(run=0)['run']['arrivals']
@@ -383,9 +377,9 @@ def test_seed_stability():
Check that two runs using the same random seed return the same results.
"""
# Run model twice, with same run number (and therefore same seed) each time
- experiment1 = Runner(param=Defaults())
+ experiment1 = Runner(param=Param())
result1 = experiment1.run_single(run=33)
- experiment2 = Runner(param=Defaults())
+ experiment2 = Runner(param=Param())
result2 = experiment2.run_single(run=33)
# Check that dataframes with patient-level results are equal
@@ -397,7 +391,7 @@ def test_interval_audit_time():
Check that length of interval audit is less than the length of simulation.
"""
# Run model once with default parameters and get max time from audit
- param = Defaults()
+ param = Param()
experiment = Runner(param)
results = experiment.run_single(run=0)
max_time = max(results['interval_audit']['simulation_time'])
@@ -455,8 +449,7 @@ def test_parallel():
# Sequential (1 core) and parallel (-1 cores) execution
results = {}
for mode, cores in [('seq', 1), ('par', -1)]:
- param = Defaults()
- param.cores = cores
+ param = Param(cores=cores)
experiment = Runner(param)
results[mode] = experiment.run_single(run=0)
@@ -468,6 +461,19 @@ def test_parallel():
assert results['seq']['run'] == results['par']['run']
+@pytest.mark.parametrize('cores', [
+ (-2), (0), (cpu_count()), (cpu_count()+1)
+])
+def test_valid_cores(cores):
+ """
+ Check there is error handling for input of invalid number of cores.
+ """
+ param = Param(cores=cores)
+ runner = Runner(param)
+ with pytest.raises(ValueError):
+ runner.run_reps()
+
+
def test_consistent_metrics():
"""
Presently, the simulation code includes a few different methods to
@@ -476,7 +482,7 @@ def test_consistent_metrics():
differences).
"""
# Run default model
- experiment = Runner(Defaults())
+ experiment = Runner(Param())
experiment.run_reps()
# Absolute tolerance (atol) = +- 0.001