diff --git a/content/12_arrival_classes.ipynb b/content/12_arrival_classes.ipynb new file mode 100644 index 0000000..a224130 --- /dev/null +++ b/content/12_arrival_classes.ipynb @@ -0,0 +1,728 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0be7dabf-cb34-4faf-abb1-e2c8e735beda", + "metadata": {}, + "source": [ + "# Modelling distributions dependent on an entity class\n", + "\n", + "In this notebook we will learn how to code simpy logic to mimic a process where there are two classes of arrivals to the system. Each arrival class has its own distribution, but requires the same resource.\n", + "\n", + "We will work with a hypothetical walk-in health clinic where individual patients arrive with either minor trauma or non-trauma needs (the patient class). The patients wait in turn for a cubicle space, but their treatment time depends on their class. \n", + "\n", + "We will look at two ways to implement the logic. \n", + "\n", + "1. A single process with a dictionary lookup to select the distribution\n", + "2. A process per class type.\n", + "\n", + "> In this example we will not concern ourselves with a warm-up period or initial conditions.\n", + "\n", + "![model image](img/patient_classes.png \"arrival classes\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "0d9383eb-420c-49f8-b178-f2fe9e6b2a90", + "metadata": {}, + "source": [ + "## 1. Imports " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c1cee9f9-8696-4b13-94ff-bee2a2a2e5f8", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "import itertools\n", + "import simpy\n", + "\n", + "# we are going to use the built-in Enum to help with modelling patient class\n", + "from enum import Enum" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ea3d507f-9e6d-4ff0-8b90-f9c63c8a8bdf", + "metadata": {}, + "outputs": [], + "source": [ + "# to reduce code these classes can be found in distribution.py\n", + "from distributions import Exponential, Lognormal, Bernoulli" + ] + }, + { + "cell_type": "markdown", + "id": "c422046d-488a-4743-8ad4-97e9f3dab420", + "metadata": {}, + "source": [ + "## 2. Constants" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1ecf0429-f03f-4ad2-abb4-46692a74e559", + "metadata": {}, + "outputs": [], + "source": [ + "# default mean inter-arrival times(exp)\n", + "# time is in minutes\n", + "IAT_WALK_IN = 8.0\n", + "\n", + "# resources\n", + "N_CUBICLES = 2\n", + "\n", + "# probability trauma\n", + "P_TRAUMA = 0.70\n", + "\n", + "# minor trauma treatment time (Lognormal)\n", + "TRAUMA_TREAT_MEAN = 15.0\n", + "TRAUMA_TREAT_STD = 1.0\n", + "\n", + "# non-trauma consultation time (Lognormal)\n", + "NONTRAUMA_CONSULT_MEAN = 5.0\n", + "NONTRAUMA_CONSULT_STD = 0.1\n", + "\n", + "# sampling settings\n", + "N_STREAMS = 4\n", + "DEFAULT_RND_SET = 0\n", + "\n", + "# Boolean switch to simulation results as the model runs\n", + "TRACE = False\n", + "\n", + "# run variables (units = minutes)\n", + "RUN_LENGTH = 60.0 * 4" + ] + }, + { + "cell_type": "markdown", + "id": "5f2a4ad9-6d5e-480d-850f-84d4882a738b", + "metadata": {}, + "source": [ + "## 2. Helper classes and functions" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "49ba4f2d-227d-4e5b-a52b-774c4affdf37", + "metadata": {}, + "outputs": [], + "source": [ + "class PatientType(Enum):\n", + " \"\"\"A simple enumeration to discriminate between trauma and non-trauma.\n", + " \"\"\"\n", + " NON_TRAUMA = 0\n", + " TRAUMA = 1" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "52c9271f-1d05-454d-a199-8768bdf5b6e8", + "metadata": {}, + "outputs": [], + "source": [ + "def trace(msg):\n", + " \"\"\"\n", + " Turing printing of events on and off.\n", + "\n", + " Params:\n", + " -------\n", + " msg: str\n", + " string to print to screen.\n", + " \"\"\"\n", + " if TRACE:\n", + " print(msg)" + ] + }, + { + "cell_type": "markdown", + "id": "5a8c050c-4bb6-408f-a805-3a4aaab56916", + "metadata": {}, + "source": [ + "## 3. Experiment class" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "576ae9b4-b21b-4ed0-9b13-e5898d423173", + "metadata": {}, + "outputs": [], + "source": [ + "class Experiment:\n", + " \"\"\"\n", + " Encapsulates the concept of an experiment 🧪 for the stroke pathway\n", + " bed blocking simulator. Manages parameters, PRNG streams and results.\n", + " \"\"\"\n", + " def __init__(\n", + " self,\n", + " random_number_set=DEFAULT_RND_SET,\n", + " n_streams=N_STREAMS,\n", + " iat_walk_in=IAT_WALK_IN,\n", + " p_trauma=P_TRAUMA,\n", + " trauma_treat_mean=TRAUMA_TREAT_MEAN,\n", + " trauma_treat_std=TRAUMA_TREAT_STD,\n", + " nontrauma_consult_mean=NONTRAUMA_CONSULT_MEAN,\n", + " nontrauma_consult_std=NONTRAUMA_CONSULT_MEAN,\n", + " n_cubicles=N_CUBICLES\n", + " ):\n", + " \"\"\"\n", + " The init method sets up our defaults.\n", + " \"\"\"\n", + " # sampling\n", + " self.random_number_set = random_number_set\n", + " self.n_streams = n_streams\n", + "\n", + " # store parameters for the run of the model\n", + " self.iat_walk_in = iat_walk_in\n", + " self.p_trauma = p_trauma\n", + " self.trauma_treat_mean = trauma_treat_mean\n", + " self.trauma_treat_std = trauma_treat_std\n", + " self.nontrauma_consult_mean = nontrauma_consult_mean\n", + " self.nontrauma_consult_std = nontrauma_consult_std\n", + "\n", + " # place holder for resources\n", + " self.cubicles = None\n", + " self.n_cubicles = n_cubicles\n", + " \n", + " # initialise results to zero\n", + " self.init_results_variables()\n", + "\n", + " # initialise sampling objects\n", + " self.init_sampling()\n", + "\n", + " def set_random_no_set(self, random_number_set):\n", + " \"\"\"\n", + " Controls the random sampling\n", + " Parameters:\n", + " ----------\n", + " random_number_set: int\n", + " Used to control the set of pseudo random numbers used by\n", + " the distributions in the simulation.\n", + " \"\"\"\n", + " self.random_number_set = random_number_set\n", + " self.init_sampling()\n", + "\n", + " def init_sampling(self):\n", + " \"\"\"\n", + " Create the distributions used by the model and initialise\n", + " the random seeds of each.\n", + " \"\"\"\n", + " # produce n non-overlapping streams\n", + " seed_sequence = np.random.SeedSequence(self.random_number_set)\n", + " self.seeds = seed_sequence.spawn(self.n_streams)\n", + "\n", + " # create distributions\n", + "\n", + " # inter-arrival time distributions\n", + " self.arrivals = Exponential(\n", + " self.iat_walk_in, random_seed=self.seeds[0]\n", + " )\n", + "\n", + " self.patient_class = Bernoulli(self.p_trauma, self.seeds[1])\n", + " \n", + " # dictionary that will contain class lookup\n", + " self.treatment_times = {}\n", + " \n", + " self.treatment_times[PatientType.TRAUMA] = Lognormal(\n", + " self.trauma_treat_mean, self.trauma_treat_std, \n", + " random_seed=self.seeds[2]\n", + " )\n", + "\n", + " self.treatment_times[PatientType.NON_TRAUMA] = Lognormal(\n", + " self.nontrauma_consult_mean, self.nontrauma_consult_std, \n", + " random_seed=self.seeds[3]\n", + " )\n", + "\n", + " def init_results_variables(self):\n", + " \"\"\"\n", + " Initialise all of the experiment variables used in results\n", + " collection. This method is called at the start of each run\n", + " of the model\n", + " \"\"\"\n", + " # variable used to store results of experiment\n", + " self.results = {}\n", + " self.results[\"waiting_time\"] = []\n", + " # counts of arrivals\n", + " self.results[PatientType.TRAUMA] = 0\n", + " self.results[PatientType.NON_TRAUMA] = 0\n", + " " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3cedafa0-66d0-4d9f-a473-17c6088ec765", + "metadata": {}, + "outputs": [], + "source": [ + "class Patient:\n", + " \"\"\" Convenience class to store individual patient attributes\n", + " \"\"\"\n", + " def __init__(self, unique_id: int, patient_type: PatientType):\n", + " self.id = unique_id\n", + " self.type = patient_type\n", + "\n", + " def __repr__(self) -> str:\n", + " \"\"\"string representation of patient for tracing\"\"\"\n", + " return f\"Patient {self.id} ({self.type.name})\"" + ] + }, + { + "cell_type": "markdown", + "id": "94f0f9c5-22cb-493a-9f1f-4e2a8325beaa", + "metadata": {}, + "source": [ + "## 4. A single process for both classes" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bf2fae82-665f-4990-bc6c-4e43eb68bbeb", + "metadata": {}, + "outputs": [], + "source": [ + "def patient_pathway(patient, env, args):\n", + " \"\"\"Process a patient through the walk-in clinic. Treatment\n", + " time depends on patient type.\n", + " \n", + " Parameters:\n", + " -----------\n", + " patient: Patient\n", + " Container for patient information.\n", + " \n", + " env: simpy.Environment\n", + " The simulation environment\n", + "\n", + " args: Experiment\n", + " Container class for the simulation parameters/results.\n", + " \"\"\"\n", + " arrival_time = env.now\n", + "\n", + " with args.cubicles.request() as cubicle_request:\n", + " yield cubicle_request\n", + " \n", + " cubicle_assign_time = env.now\n", + " wait_for_cubicle = cubicle_assign_time - arrival_time\n", + " args.results['waiting_time'].append(wait_for_cubicle)\n", + " \n", + " trace(f\"{env.now:.2f}: {patient} assigned cubicle.\" \\\n", + " + f\"(waited {wait_for_cubicle:.2f} minutes)\")\n", + " \n", + " # Simulate treatment/consult time depending on class\n", + " # implemented as a dictionary lookup\n", + " treat_duration = args.treatment_times[patient.type].sample()\n", + " \n", + " yield env.timeout(treat_duration)\n", + " \n", + " trace(f\"{env.now:.2f}: {patient} exit\")\n", + " " + ] + }, + { + "cell_type": "markdown", + "id": "de8990c2-a330-4c02-ac77-26c30d3e0a41", + "metadata": {}, + "source": [ + "## 4. Arrivals generator\n", + "\n", + "This is a standard arrivals generator. We create stroke arrivals according to their distribution." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b3e686ce-5371-4471-a052-b9d43309bc85", + "metadata": {}, + "outputs": [], + "source": [ + "def walk_in_generator(env, args):\n", + " \"\"\"\n", + " Arrival process for walk in clinic.\n", + "\n", + " Parameters:\n", + " ------\n", + " env: simpy.Environment\n", + " The simpy environment for the simulation\n", + "\n", + " args: Experiment\n", + " The settings and input parameters for the simulation.\n", + " \"\"\"\n", + " # use itertools as it provides an infinite loop\n", + " # with a counter variable that we can use for unique Ids\n", + " for patient_id in itertools.count(start=1):\n", + "\n", + " # the sample distribution is defined by the experiment.\n", + " inter_arrival_time = args.arrivals.sample()\n", + " yield env.timeout(inter_arrival_time)\n", + "\n", + " # create a patient \n", + " patient = Patient(\n", + " unique_id = patient_id, \n", + " patient_type = PatientType(args.patient_class.sample())\n", + " )\n", + "\n", + " # increment count of patient arrivals for class\n", + " args.results[patient.type] += 1\n", + " \n", + " trace(f\"{env.now:.2f}: ARRIVAL - {patient}\")\n", + "\n", + " # patient enters pathway\n", + " env.process(patient_pathway(patient, env, args))" + ] + }, + { + "cell_type": "markdown", + "id": "6058571e-9fdb-4961-be27-8a3b8c2fe26e", + "metadata": {}, + "source": [ + "## 5. Single run function" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0d0ea6cf-7d95-4d2c-9690-fcdbdae35d84", + "metadata": {}, + "outputs": [], + "source": [ + "def single_run(\n", + " experiment, \n", + " rep=0,\n", + " run_length=RUN_LENGTH\n", + "):\n", + " \"\"\"\n", + " Perform a single run of the model and return the results\n", + "\n", + " Parameters:\n", + " -----------\n", + "\n", + " experiment: Experiment\n", + " The experiment/paramaters to use with model\n", + "\n", + " rep: int\n", + " The replication number.\n", + "\n", + " rc_period: float, optional (default=RUN_LENGTH)\n", + " The run length of the model\n", + " \"\"\"\n", + "\n", + " # reset all results variables to zero and empty\n", + " experiment.init_results_variables()\n", + "\n", + " # set random number set to the replication no.\n", + " # this controls sampling for the run.\n", + " experiment.set_random_no_set(rep)\n", + "\n", + " # environment is (re)created inside single run\n", + " env = simpy.Environment()\n", + "\n", + " # simpy resources\n", + " experiment.cubicles = simpy.Resource(env, experiment.n_cubicles)\n", + "\n", + " # we pass all arrival generators to simpy \n", + " env.process(walk_in_generator(env, experiment))\n", + "\n", + " # run model\n", + " env.run(until=run_length)\n", + "\n", + " # quick stats\n", + " results = {}\n", + " results['mean_wait'] = np.array(\n", + " experiment.results[\"waiting_time\"]\n", + " ).mean()\n", + " \n", + " results[PatientType.TRAUMA] = experiment.results[PatientType.TRAUMA]\n", + " results[PatientType.NON_TRAUMA] = experiment.results[PatientType.NON_TRAUMA]\n", + "\n", + "\n", + " # return single run results\n", + " return results" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "caf52390-5455-4fa1-bb22-60b5b91ad8d0", + "metadata": {}, + "outputs": [], + "source": [ + "TRACE = True\n", + "experiment = Experiment()\n", + "results = single_run(experiment)\n", + "results" + ] + }, + { + "cell_type": "markdown", + "id": "a48ffebd-5af0-4354-89bc-7de77ee60e8b", + "metadata": {}, + "source": [ + "## Quick tests\n", + "\n", + "### All patients are trauma" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8228ab0c-cc99-48e2-a2c9-c9dcce8d854f", + "metadata": {}, + "outputs": [], + "source": [ + "TRACE = False\n", + "experiment = Experiment(p_trauma=1.0)\n", + "results = single_run(experiment)\n", + "results" + ] + }, + { + "cell_type": "markdown", + "id": "f8e498cb-3999-4c69-8d41-e436cbebe6d8", + "metadata": {}, + "source": [ + "### All patients are non-trauma" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "00d319d8-2a15-46f1-b1e9-a8efb7a69b2a", + "metadata": {}, + "outputs": [], + "source": [ + "TRACE = False\n", + "experiment = Experiment(p_trauma=0.0)\n", + "results = single_run(experiment)\n", + "results" + ] + }, + { + "cell_type": "markdown", + "id": "de83947d-383b-4e45-b389-398b66265747", + "metadata": {}, + "source": [ + "## 5. A process per class\n", + "\n", + "Alternatively you could implement process per arrival class. This is useful when process differ in more ways that just process duration. For example, there might be multiple steps or different resources used. \n", + "\n", + "To illustrate this we will add a second activity to the trauma pathway where the patients waits for 10 minutes before leaving.\n", + "\n", + "To implement this we need two pathway processes \n", + "\n", + "* `trauma_pathway` a process for the trauma pathway (with ammended logic)\n", + "* `non_trauma_pathway` a process for the non-trauma pathway\n", + "\n", + "We also need to ammend the `walk_in_generator` function to schedule the process dependent on class of arrival." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b8864291-9507-4aaa-b41f-e7c05da62a07", + "metadata": {}, + "outputs": [], + "source": [ + "def trauma_pathway(patient, env, args):\n", + " \"\"\"Process a TRAUMA patient through the walk-in clinic. \n", + "\n", + " Ammended to include a second fixed duration activity before exit.\n", + " \n", + " Parameters:\n", + " -----------\n", + " patient: Patient\n", + " Container for patient information.\n", + " \n", + " env: simpy.Environment\n", + " The simulation environment\n", + "\n", + " args: Experiment\n", + " Container class for the simulation parameters/results.\n", + " \"\"\"\n", + " arrival_time = env.now\n", + "\n", + " with args.cubicles.request() as cubicle_request:\n", + " yield cubicle_request\n", + " \n", + " cubicle_assign_time = env.now\n", + " wait_for_cubicle = cubicle_assign_time - arrival_time\n", + " args.results['waiting_time'].append(wait_for_cubicle)\n", + " \n", + " trace(f\"{env.now:.2f}: {patient} assigned cubicle.\" \\\n", + " + f\"(waited {wait_for_cubicle:.2f} minutes)\")\n", + " \n", + " # MODIFICATION: here we always use PatientType.TRAUMA in the lookup.\n", + " treat_duration = args.treatment_times[PatientType.TRAUMA].sample()\n", + " \n", + " yield env.timeout(treat_duration)\n", + "\n", + " trace(f\"{env.now:.2f}: {patient} treatment complete.\")\n", + "\n", + " # MODIFICATION: simulate variance from the non-trauma pathway\n", + " yield env.timeout(10.0)\n", + " \n", + " trace(f\"{env.now:.2f}: {patient} exit\")\n", + " " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "88515b18-a894-405d-bfe3-47be5ee5b3dd", + "metadata": {}, + "outputs": [], + "source": [ + "def non_trauma_pathway(patient, env, args):\n", + " \"\"\"Process a NON_TRAUMA patient through the walk-in clinic. \n", + " \n", + " Parameters:\n", + " -----------\n", + " patient: Patient\n", + " Container for patient information.\n", + " \n", + " env: simpy.Environment\n", + " The simulation environment\n", + "\n", + " args: Experiment\n", + " Container class for the simulation parameters/results.\n", + " \"\"\"\n", + " arrival_time = env.now\n", + "\n", + " with args.cubicles.request() as cubicle_request:\n", + " yield cubicle_request\n", + " \n", + " cubicle_assign_time = env.now\n", + " wait_for_cubicle = cubicle_assign_time - arrival_time\n", + " args.results['waiting_time'].append(wait_for_cubicle)\n", + " \n", + " trace(f\"{env.now:.2f}: {patient} assigned cubicle.\" \\\n", + " + f\"(waited {wait_for_cubicle:.2f} minutes)\")\n", + " \n", + " # MODIFICATION: here we always use PatientType.NON_TRAUMA in the lookup.\n", + " treat_duration = args.treatment_times[PatientType.NON_TRAUMA].sample()\n", + " \n", + " yield env.timeout(treat_duration)\n", + " \n", + " trace(f\"{env.now:.2f}: {patient} exit\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "dbdb023c-5f59-49b2-9988-ac84ce951b50", + "metadata": {}, + "outputs": [], + "source": [ + "def walk_in_generator(env, args):\n", + " \"\"\"\n", + " Arrival process for walk in clinic.\n", + " Modified to scheduled processes dependent on arrival class.\n", + "\n", + " Parameters:\n", + " ------\n", + " env: simpy.Environment\n", + " The simpy environment for the simulation\n", + "\n", + " args: Experiment\n", + " The settings and input parameters for the simulation.\n", + " \"\"\"\n", + " # use itertools as it provides an infinite loop\n", + " # with a counter variable that we can use for unique Ids\n", + " for patient_id in itertools.count(start=1):\n", + "\n", + " # the sample distribution is defined by the experiment.\n", + " inter_arrival_time = args.arrivals.sample()\n", + " yield env.timeout(inter_arrival_time)\n", + "\n", + " # create a patient \n", + " patient = Patient(\n", + " unique_id = patient_id, \n", + " patient_type = PatientType(args.patient_class.sample())\n", + " )\n", + "\n", + " # increment count of patient arrivals for class\n", + " args.results[patient.type] += 1\n", + " \n", + " trace(f\"{env.now:.2f}: ARRIVAL - {patient}\")\n", + "\n", + " if patient.type is PatientType.TRAUMA:\n", + " # patient enters trauma pathway\n", + " env.process(trauma_pathway(patient, env, args))\n", + " else:\n", + " # patient enters non-trauma pathway\n", + " env.process(non_trauma_pathway(patient, env, args))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d9e8ff86-8f32-450e-8c61-bae5b2a04ced", + "metadata": {}, + "outputs": [], + "source": [ + "TRACE = True\n", + "experiment = Experiment()\n", + "results = single_run(experiment)\n", + "results" + ] + }, + { + "cell_type": "markdown", + "id": "a6cf45c5-a762-4e5f-bc05-c0339d7e486c", + "metadata": {}, + "source": [ + "### Quick test!" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7b72a3e7-8b0e-40fe-9600-12f7c9dfc683", + "metadata": {}, + "outputs": [], + "source": [ + "TRACE = False\n", + "experiment = Experiment(p_trauma=1.0)\n", + "results = single_run(experiment)\n", + "results" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c26d1105-9aa9-44fe-ada9-9157566dd244", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "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.11.11" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/content/distributions.py b/content/distributions.py index f8f42e0..33d8ab5 100644 --- a/content/distributions.py +++ b/content/distributions.py @@ -140,4 +140,48 @@ def sample(self, size=None): ------- float or np.ndarray (if size >=1) """ - return self.rand.exponential(self.mean, size=size) \ No newline at end of file + return self.rand.exponential(self.mean, size=size) + + +class Bernoulli: + """ + Convenience class for the Bernoulli distribution. + packages up distribution parameters, seed and random generator. + + The Bernoulli distribution is a special case of the binomial distribution + where a single trial is conducted + + Use the Bernoulli distribution to sample success or failure. + """ + + def __init__(self, p, random_seed=None): + """ + Constructor + + Params: + ------ + p: float + probability of drawing a 1 + + random_seed: int | SeedSequence, optional (default=None) + A random seed to reproduce samples. If set to none then a unique + sample is created. + """ + self.rand = np.random.default_rng(seed=random_seed) + self.p = p + + def sample(self, size=None): + """ + Generate a sample from the exponential distribution + + Params: + ------- + size: int, optional (default=None) + the number of samples to return. If size=None then a single + sample is returned. + + Returns: + ------- + float or np.ndarray (if size >=1) + """ + return self.rand.binomial(n=1, p=self.p, size=size) \ No newline at end of file diff --git a/content/img/patient_classes.drawio b/content/img/patient_classes.drawio new file mode 100644 index 0000000..5513669 --- /dev/null +++ b/content/img/patient_classes.drawio @@ -0,0 +1,182 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/content/img/patient_classes.png b/content/img/patient_classes.png new file mode 100644 index 0000000..f425ad0 Binary files /dev/null and b/content/img/patient_classes.png differ