Repository: starsimhub/starsim (language: python)

Issue:
Apis fix tutorials2

Handle empty contacts when making new cases
This test with an empty graph exposes what would happen if at any time step there were no contacts (prob unlikely, but could happen). From the Sim perspective, there is a network, and the disease tries to make new cases.  

``` python
    ss.options(multirng=False)
    ppl = ss.People(100)
    syph = ss.Syphilis()
    sim = ss.Sim(people=ppl, diseases=syph, networks=[ss.StaticNet(nx.empty_graph)])
    sim.run()
```

##### Output:
Two sources of error: 
- from numba: when network contacts are empty, indexing variables `a` and `b`  in diseases.py: `_make_new_cases...`
```python
            for a, b, beta in [[contacts.p1, contacts.p2, nbetas[0]],
                               [contacts.p2, contacts.p1, nbetas[1]]]:
```
are empty numpy arrays of type float64, and numba fails cause it's trying to index and array with a float 
- from numpy: even if numba didn't complain,  np.concatenate would because it doesn't take empty lists or arrays


****
Numba error
```
Traceback (most recent call last):
  File "/home/paula/Projects/IDM/dev-starsim/starsim/tests/test_null_network.py", line 31, in <module>
    sim1 = test_null_network()
  File "/home/paula/Projects/IDM/dev-starsim/starsim/tests/test_null_network.py", line 23, in test_null_network
    sim.run()
  File "/home/paula/Projects/IDM/dev-starsim/starsim/starsim/sim.py", line 460, in run
    self.step()
  File "/home/paula/Projects/IDM/dev-starsim/starsim/starsim/sim.py", line 389, in step
    disease.make_new_cases(self)
  File "/home/paula/Projects/IDM/dev-starsim/starsim/starsim/diseases/syphilis.py", line 175, in make_new_cases
    super(Syphilis, self).make_new_cases(sim)
  File "/home/paula/Projects/IDM/dev-starsim/starsim/starsim/diseases/disease.py", line 415, in make_new_cases
    new_cases, sources = self._make_new_cases_singlerng(sim.people)
  File "/home/paula/Projects/IDM/dev-starsim/starsim/starsim/diseases/disease.py", line 339, in _make_new_cases_singlerng
    p_transmit = rel_trans[a] * rel_sus[b] * contacts.beta * beta * people.dt
  File "/home/paula/Projects/IDM/dev-starsim/starsim/starsim/states.py", line 135, in __getitem__
    values, uids, new_uid_map = self._get_vals_uids(self.values, key.__array__(), self._uid_map.__array__())
  File "/home/paula/miniconda3/envs/py310-starsim-dev/lib/python3.10/site-packages/numba/core/dispatcher.py", line 468, in _compile_for_args
    error_rewrite(e, 'typing')
  File "/home/paula/miniconda3/envs/py310-starsim-dev/lib/python3.10/site-packages/numba/core/dispatcher.py", line 409, in error_rewrite
    raise e.with_traceback(None)
numba.core.errors.TypingError: Failed in nopython mode pipeline (step: nopython frontend)
No implementation of function Function(<built-in function getitem>) found for signature:
 
 >>> getitem(array(int64, 1d, C), float64)
 
There are 22 candidate implementations:
  - Of which 20 did not match due to:
  Overload of function 'getitem': File: <numerous>: Line N/A.
    With argument(s): '(array(int64, 1d, C), float64)':
   No match.
  - Of which 2 did not match due to:
  Overload in function 'GetItemBuffer.generic': File: numba/core/typing/arraydecl.py: Line 209.
    With argument(s): '(array(int64, 1d, C), float64)':
   Rejected as the implementation raised a specific error:
     NumbaTypeError: Unsupported array index type float64 in [float64]
  raised from /home/paula/miniconda3/envs/py310-starsim-dev/lib/python3.10/site-packages/numba/core/typing/arraydecl.py:102

During: typing of intrinsic-call at /home/paula/Projects/IDM/dev-starsim/starsim/starsim/states.py (67)

File "../starsim/states.py", line 67:
    def _get_vals_uids(vals, key, uid_map):
        <source elided>
        for i in range(len(key)):
            idx = uid_map[key[i]]
            ^
```


Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 4b19b8fc..515b1563 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -9,6 +9,19 @@ All notable changes to the codebase are documented in this file. Changes that ma
    :depth: 1
 
 
+Version 0.2.0 (2024-02-15)
+--------------------------
+- Code reorganization, including making ``networks.py`` and ``disease.py`` to the top level
+- Networks moved from ``People`` to ``Sim``
+- Various classes renamed (e.g. ``FusedArray`` to ``UIDArray``, ``STI`` to ``Infection``)
+- Better type checking
+- Added ``MultiSim``
+- Added cholera, measles, and Ebola
+- Added vaccination
+- More flexible inputs
+- *GitHub info*: PR `235 <https://github.com/amath-idm/stisim/pull/235>`_
+
+
 Version 0.1.8 (2024-01-30)
 --------------------------
 - Transmission based on number of contacts
diff --git a/docs/api/index.rst b/docs/api/index.rst
index 41136082..73531694 100644
--- a/docs/api/index.rst
+++ b/docs/api/index.rst
@@ -12,10 +12,20 @@ This page lists Starsim's full API, including all functions and classes.
    :recursive:
    :nosignatures:
 
-   starsim.analyzers
+   starsim.connectors
+   starsim.demographics
+   starsim.disease
+   starsim.distributions
    starsim.interventions
+   starsim.modules
+   starsim.networks
    starsim.parameters
    starsim.people
+   starsim.products
+   starsim.random
+   starsim.results
+   starsim.run
    starsim.settings
    starsim.sim
+   starsim.states
    starsim.utils
diff --git a/docs/tutorials.rst b/docs/tutorials.rst
index b33019f0..35c31f0d 100644
--- a/docs/tutorials.rst
+++ b/docs/tutorials.rst
@@ -1,7 +1,23 @@
 .. include:: tutorials/README.rst
 
+User tutorials
+--------------
+
 .. toctree::
     :maxdepth: 1
 
     tutorials/tut_intro.ipynb
+    tutorials/tut_buildsim.ipynb
+    tutorials/tut_demographics.ipynb
+    tutorials/tut_networks.ipynb
+    tutorials/tut_diseases.ipynb
+    tutorials/tut_interventions.ipynb
+
+Developer tutorials
+-------------------
+
+.. toctree::
+    :maxdepth: 1
+
+    tutorials/tut_dev_diseases.ipynb
     
diff --git a/docs/tutorials/clean_outputs b/docs/tutorials/clean_outputs
index 60492a75..acb57c38 100755
--- a/docs/tutorials/clean_outputs
+++ b/docs/tutorials/clean_outputs
@@ -2,6 +2,6 @@
 # Remove auto-generated files; use -f in case they don't exist
 echo 'Deleting:'
 echo `ls -1 ./my-*.* 2> /dev/null`
-echo '...in 2 seconds'
-sleep 2
+echo '...in 1 second'
+sleep 1
 rm -vf ./my-*.*
\ No newline at end of file
diff --git a/docs/tutorials/tut_buildsim.ipynb b/docs/tutorials/tut_buildsim.ipynb
new file mode 100644
index 00000000..e24598ff
--- /dev/null
+++ b/docs/tutorials/tut_buildsim.ipynb
@@ -0,0 +1,228 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# T2 - How to build your model\n",
+    "\n",
+    "The purpose of this tutorial is to introduce you to the idea of _sim components_. In brief, these are: people, demographics, networks, diseases, interventions, analyzers, and connectors. Within Starsim, these are the ingredients of a model. On a more basic level, you can think of these as the ingredients of an epidemic. Because Starsim is intended to be very modular, you can build up all these things independently and then piece them together to make a model. Or, if that's too complex for your needs, there are also shortcuts you can take to make life simpler!\n",
+    "\n",
+    "In this tutorial we'll focus on people, demographics, networks, diseases. The remaining components (interventions, analyzers, and connectors) will be covered later."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {
+    "collapsed": false,
+    "jupyter": {
+     "outputs_hidden": false
+    }
+   },
+   "source": [
+    "## Simple SIR model\n",
+    "\n",
+    "Let's revisit the simple SIR model from Tutorial 1:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "collapsed": false,
+    "jupyter": {
+     "outputs_hidden": false
+    }
+   },
+   "outputs": [],
+   "source": [
+    "import starsim as ss\n",
+    "\n",
+    "# Define the parameters\n",
+    "pars = dict(\n",
+    "    n_agents=5_000,     # Number of agents to simulate\n",
+    "    networks=dict(      # *Networks* add detail on how the agents interact with each other \n",
+    "        name='random',  # Here, we use a 'random' network\n",
+    "        n_contacts=4    # Each person has an average of 4 contacts with other people\n",
+    "    ),\n",
+    "    diseases=dict(      # *Diseases* add detail on what diseases to model \n",
+    "        name='sir',     # Here, we're creating an SIR disease \n",
+    "        init_prev=0.1,  # Proportion of the population initially infected \n",
+    "        beta=0.1,       # Probability of transmission between contacts\n",
+    "    )\n",
+    ")\n",
+    "\n",
+    "# Make the sim, run and plot\n",
+    "sim = ss.Sim(pars)\n",
+    "sim.run()\n",
+    "sim.plot();"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {
+    "collapsed": false,
+    "jupyter": {
+     "outputs_hidden": false
+    }
+   },
+   "source": [
+    "Now let's look at the `pars` dictionary in more detail. The one we've created here has 3 things in it: the number of agents (`n_agents`), detail on how the agents interact with each other (`networks`) and detail on what disease we're modeling (`diseases`). When we create and run the model, what happens 'under the hood' is that the simulation creates 5,000 people, and allows them to the interact with one another over the network and transmit the infection. \n",
+    "\n",
+    "## Simple SIR model built with components\n",
+    "The example above is a nice simple way to get started, but you might want to have more control over the networks, diseases, and people that you're creating. Here's another version of the exact same model, but written slightly differently:  "
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "people = ss.People(n_agents=5_000)\n",
+    "network = ss.RandomNet(pars=dict(n_contacts=4))\n",
+    "sir = ss.SIR(init_prev=0.1, beta=0.1)\n",
+    "sim = ss.Sim(diseases=sir, people=people, networks=network)\n",
+    "sim.run()\n",
+    "sim.plot();"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {
+    "collapsed": false,
+    "jupyter": {
+     "outputs_hidden": false
+    }
+   },
+   "source": [
+    "Rather than bundling everything under pars, we're now defining components individually for people, networks, and diseases. As for the disease/network details, instead of putting all information in one bucket (`pars['diseases'] = dict(name='sir', init_prev=0.1, beta=0.1)`), we're now using `ss.SIR() `which serves as a prepared 'template' where we fill in the details. This new way provides us more flexibility to adjust details of the disease as we need.\n",
+    "\n",
+    "Don't worry if you have not seen or used these 'templates' (called custom classes in programming) before, but imagine them as special containers that come with predefined tools (aka built-in methods) to streamline your modelling process. Even if you're not an expert programmer, these 'templates' are intuitive to use and they will serve as our go-to solution as we move through the examples.\n",
+    "\n",
+    "Now, let's look at a few useful ways to improve our model by extending these three components (people, networks, and diseases).\n",
+    "\n",
+    "## Making changes to our components\n",
+    "One of the main advantages of agent-based models is they allow you to capture heterogeneity between people. In real life, it's not realistic that everyone in a population has the same number of contacts with other people. Let's make our contact network more realistic by adding some variation here. For this, we'll use a Poisson distribution. The two lines below both do the same thing:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "collapsed": false,
+    "jupyter": {
+     "outputs_hidden": false
+    }
+   },
+   "outputs": [],
+   "source": [
+    "network = ss.RandomNet(pars={'n_contacts': ss.poisson(4)})\n",
+    "network = ss.RandomNet(pars={'n_contacts': 4}, par_dists={'n_contacts': ss.poisson})"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {
+    "collapsed": false,
+    "jupyter": {
+     "outputs_hidden": false
+    }
+   },
+   "source": [
+    "If we use this network, our agents will have varying numbers of contacts."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {
+    "collapsed": false,
+    "jupyter": {
+     "outputs_hidden": false
+    }
+   },
+   "source": [
+    "## Accessing results \n",
+    "Once you've run a model, you will want to see what the results look like! We've seen a few basic plotting commands above, but if you want to inspect the results for yourself, you can take a look in `sim.results`. This is a dictionary with keys corresponding to core components of interest. For example, the sim we created in the code block above will have the following keys: `['n_alive', 'new_deaths', 'births', 'background_deaths', 'sir']`. Then `sim.results.sir` is also a dictionary and contains all the results relating to this disease over time. For example, `new_infections` is a kind of array showing annual new infections. \n",
+    "\n",
+    "## Matters of time \n",
+    "The default for Starsim models is the start simulations in 1995 and simulate with an annual timestep for 35 years. All of this can be easily changed within the main `pars` dictionary or by passing alternative values straight to the sim, e.g. "
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "collapsed": false,
+    "jupyter": {
+     "outputs_hidden": false
+    }
+   },
+   "outputs": [],
+   "source": [
+    "sim = ss.Sim(start=2020, n_years=1, dt=1/12)  # Simulate from 2020 for 1 year with a monthly timestep"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {
+    "collapsed": false,
+    "jupyter": {
+     "outputs_hidden": false
+    }
+   },
+   "source": [
+    "## Summary \n",
+    "You've now seen how to create models using the \"sim\" class (`ss.Sim`), either by defining a dictionary of parameters or by passing in sim components (demographics, people, diseases, and networks). This means you've got the basic skills needed to start making models to answer a range of different questions. We'll close this tutorial with a few examples that you might like to try out for yourself.\n",
+    "\n",
+    "## Questions\n",
+    "\n",
+    "1. How would you model an outbreak of an SIR-like disease within a refugee camp of 20,000 people? Suppose you were interested in the cumulative number of people who got infected over 1 year - how would you find this out?\n",
+    "2. Whether an epidemic 'takes off' depends to a large extent on the [basic reproduction number](https://en.wikipedia.org/wiki/Basic_reproduction_number), which is the expected number of cases that an infected individual generates. In an agent based model like the one we've created here, that depends largely on three things: `beta` (the transmissibility parameter for the disease), `n_contacts` (the number of contacts each person has), and `dur_inf` (another disease-related parameter that determines the duration of infection). Experiment with different values for each of these and compare the trajectory of `sim.results.sir.n_infected` with different parameter values.\n"
+   ]
+  }
+ ],
+ "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.7"
+  },
+  "pycharm": {
+   "stem_cell": {
+    "cell_type": "raw",
+    "metadata": {
+     "collapsed": false
+    },
+    "source": []
+   }
+  },
+  "toc": {
+   "base_numbering": 1,
+   "nav_menu": {},
+   "number_sections": true,
+   "sideBar": true,
+   "skip_h1_title": false,
+   "title_cell": "Table of Contents",
+   "title_sidebar": "Contents",
+   "toc_cell": false,
+   "toc_position": {},
+   "toc_section_display": true,
+   "toc_window_display": false
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 4
+}
diff --git a/docs/tutorials/tut_demographics.ipynb b/docs/tutorials/tut_demographics.ipynb
new file mode 100644
index 00000000..4235de7d
--- /dev/null
+++ b/docs/tutorials/tut_demographics.ipynb
@@ -0,0 +1,186 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# T3 - Demographics \n",
+    "\n",
+    "We've already seen a few basic ways to add vital dynamics to your model, e.g. "
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "collapsed": false,
+    "jupyter": {
+     "outputs_hidden": false
+    }
+   },
+   "outputs": [],
+   "source": [
+    "import starsim as ss\n",
+    "\n",
+    "pars = dict(\n",
+    "    birth_rate=20,     # Annual crude birth rate (per 1000 people)\n",
+    "    death_rate=0.015,  # Annual crude death rate\n",
+    "    networks='random',\n",
+    "    diseases='sir'\n",
+    ")\n",
+    "sim = ss.Sim(pars)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {
+    "collapsed": false,
+    "jupyter": {
+     "outputs_hidden": false
+    }
+   },
+   "source": [
+    "\n",
+    "This will scale all your results to represent a population of 50 million people, and will apply annual birth and death rates as specified in the `pars` dict. Alternatively, we can make demographic components:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "collapsed": false,
+    "jupyter": {
+     "outputs_hidden": false
+    }
+   },
+   "outputs": [],
+   "source": [
+    "demographics = [\n",
+    "    ss.Births(pars={'birth_rate': 20}),\n",
+    "    ss.Deaths(pars={'death_rate': 0.015})\n",
+    "]\n",
+    "sim = ss.Sim(diseases=ss.SIR(), demographics=demographics, networks=ss.RandomNet())"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {
+    "collapsed": false,
+    "jupyter": {
+     "outputs_hidden": false
+    }
+   },
+   "source": [
+    "Both achieve the same thing. \n",
+    "\n",
+    "## Scaling results to whole populations\n",
+    "Even though we've been simulating populations of a few thousand agents, we can also use the `total_pop` parameter to scale our results so that they reflect a much larger population. You can think of this as a kind of statistical sampling approximation. If we want to model the population of Nigeria, for example, it would be much too computationally intensive to simulate 200 million agents. However, we could simulate 50,000 agents and then say that each agent represents 4,000 people. Again, we can do this by passing `total_pop=200e6` to the sim or in the `pars` dictionary.\n",
+    "\n",
+    "## Using realistic vital demographics\n",
+    "For more realistic demographics, we can also pass in a file that has birth or death rates over time and by age/sex. There are examples of these files in the `tests/test_data` folder. Here's how we would read them in and construct realistic vital dynamics for Nigeria: "
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "collapsed": false,
+    "jupyter": {
+     "outputs_hidden": false
+    }
+   },
+   "outputs": [],
+   "source": [
+    "import starsim as ss\n",
+    "import pandas as pd\n",
+    "\n",
+    "# Read in age-specific fertility rates\n",
+    "fertility_rates = pd.read_csv(ss.root / 'tests/test_data/nigeria_asfr.csv')\n",
+    "pregnancy = ss.Pregnancy(pars={'fertility_rate': fertility_rates})\n",
+    "\n",
+    "death_rates = pd.read_csv(ss.root / 'tests/test_data/nigeria_deaths.csv')\n",
+    "death = ss.Deaths(pars={'death_rate': death_rates})\n",
+    "\n",
+    "demographics = [pregnancy, death]\n",
+    "\n",
+    "# Make people using the distribution of the population by age/sex in 1995\n",
+    "n_agents = 5_000\n",
+    "nga_pop_1995 = 106819805  # Population of Nigeria in 1995, the year we will start the model\n",
+    "age_data = pd.read_csv(ss.root / 'tests/test_data/nigeria_age.csv')\n",
+    "ppl = ss.People(n_agents, age_data=age_data)\n",
+    "\n",
+    "# Make the sim, run and plot\n",
+    "sim = ss.Sim(people=ppl, demographics=demographics, pars=dict(networks='random', diseases='sir'))\n",
+    "sim.run()\n",
+    "\n",
+    "# Read in a file with the actual population size\n",
+    "nigeria_popsize = pd.read_csv(ss.root / 'tests/test_data/nigeria_popsize.csv')\n",
+    "data = nigeria_popsize[(nigeria_popsize.year >= 1995) & (nigeria_popsize.year <= 2030)]\n",
+    "\n",
+    "# Plot the overall population size - simulated vs data\n",
+    "import matplotlib.pyplot as plt\n",
+    "fig, ax = plt.subplots(1, 1)\n",
+    "ax.scatter(data.year, data.n_alive, alpha=0.5, label='Data')\n",
+    "ax.plot(sim.yearvec, sim.results.n_alive, color='k', label='Model')\n",
+    "ax.legend()\n",
+    "ax.set_title('Population')\n",
+    "plt.show();"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {
+    "collapsed": false,
+    "jupyter": {
+     "outputs_hidden": false
+    }
+   },
+   "source": [
+    "If you want to use realistic demographics for your model, you can adapt the data files and code snippet above to read in the relevant demographic data files for your country/setting. "
+   ]
+  }
+ ],
+ "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.7"
+  },
+  "pycharm": {
+   "stem_cell": {
+    "cell_type": "raw",
+    "metadata": {
+     "collapsed": false
+    },
+    "source": []
+   }
+  },
+  "toc": {
+   "base_numbering": 1,
+   "nav_menu": {},
+   "number_sections": true,
+   "sideBar": true,
+   "skip_h1_title": false,
+   "title_cell": "Table of Contents",
+   "title_sidebar": "Contents",
+   "toc_cell": false,
+   "toc_position": {},
+   "toc_section_display": true,
+   "toc_window_display": false
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 4
+}
diff --git a/docs/tutorials/tut_dev_diseases.ipynb b/docs/tutorials/tut_dev_diseases.ipynb
new file mode 100644
index 00000000..ce09ced1
--- /dev/null
+++ b/docs/tutorials/tut_dev_diseases.ipynb
@@ -0,0 +1,166 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Developer tutorial - Diseases"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Overview of Starsim's disease structure\n",
+    "\n",
+    "The basic template for modeling a disease in Starsim is the `Disease` class. Much like sims or networks, a `Disease` can be customized by passing in a `pars` dictionary containing parameters. THe `Disease` module dose lots of different things, but three of the model fundamental are:\n",
+    "- `set_initial_states`, which initializes people into states (e.g. susceptible, infected, recovered)\n",
+    "- `make_new_cases`, which makes new cases (e.g., by finding the susceptible contacts of all infected people and calculating the probability of transmission for each)\n",
+    "- `set_prognoses`, which sets the outcomes for people who get infected (e.g., by setting their date of recovery or death).\n"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {
+    "collapsed": false,
+    "jupyter": {
+     "outputs_hidden": false
+    }
+   },
+   "source": [
+    "## Making your own disease\n",
+    "If you want to make your own disease, you could either inherit from one of the templates in diseases.py, or you could copy the examples and extend them to capture features of the disease that you want to model. For example, suppose you wanted to change the SIR model to an SEIR model (i.e., add an 'exposed' state where people were transmissible but did not yet have symptoms. You might hope that this would be a relatively simple change to make. Here's how it would look:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "collapsed": false,
+    "jupyter": {
+     "outputs_hidden": false
+    }
+   },
+   "outputs": [],
+   "source": [
+    "import numpy as np\n",
+    "import starsim as ss\n",
+    "\n",
+    "\n",
+    "class SEIR(ss.SIR):\n",
+    "    def __init__(self, pars=None, *args, **kwargs):\n",
+    "        \n",
+    "        # Additional pars beyond the SIR model ones\n",
+    "        pars = ss.omerge({\n",
+    "            'dur_exp': 0.5,  \n",
+    "        }, pars)\n",
+    "        \n",
+    "        # Initialize the SIR model, which will add all the parameters and states of that model.\n",
+    "        super().__init__(pars=pars, *args, **kwargs)\n",
+    "\n",
+    "        # Additional states beyond the SIR ones \n",
+    "        self.exposed = ss.State('exposed', bool, False)\n",
+    "        self.ti_exposed = ss.State('ti_exposed', float, np.nan)\n",
+    "\n",
+    "        return\n",
+    "\n",
+    "    @property\n",
+    "    def infectious(self):\n",
+    "        return self.infected | self.exposed\n",
+    "\n",
+    "    def update_pre(self, sim):\n",
+    "        # Make all the updates from the SIR model \n",
+    "        n_deaths = super().update_pre(sim)\n",
+    "\n",
+    "        # Additional updates: progress exposed -> infected\n",
+    "        infected = ss.true(self.exposed & (self.ti_infected <= sim.year))\n",
+    "        self.exposed[infected] = False\n",
+    "        self.infected[infected] = True\n",
+    "\n",
+    "        return n_deaths\n",
+    "\n",
+    "    def update_death(self, sim, uids):\n",
+    "        super().update_death(sim, uids)\n",
+    "        self.exposed[uids] = False\n",
+    "        return\n",
+    "\n",
+    "    def set_prognoses(self, sim, uids, from_uids):\n",
+    "        # Carry out state changes associated with infection\n",
+    "        self.susceptible[uids] = False\n",
+    "        self.exposed[uids] = True\n",
+    "        self.ti_exposed[uids] = sim.year\n",
+    "\n",
+    "        # Calculate and schedule future outcomes\n",
+    "        dur_exp = self.pars['dur_exp'].rvs(uids)\n",
+    "        self.ti_infected[uids] = sim.year + dur_exp\n",
+    "        dur_inf = self.pars['dur_inf'].rvs(uids)\n",
+    "        will_die = self.pars['p_death'].rvs(uids)        \n",
+    "        self.ti_recovered[uids[~will_die]] = sim.year + dur_inf[~will_die]\n",
+    "        self.ti_dead[uids[will_die]] = sim.year + dur_inf[will_die]\n",
+    "\n",
+    "        # Update result count of new infections \n",
+    "        self.results['new_infections'][sim.ti] += len(uids)\n",
+    "        return\n"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {
+    "collapsed": false,
+    "jupyter": {
+     "outputs_hidden": false
+    }
+   },
+   "source": [
+    "The new class includes the following main changes:\n",
+    "1. In `__init__` we added the extra pars and states needed for our model\n",
+    "2. We defined `infectious` to include both infected and exposed people - this means that we can just reuse the existing logic for how the SIR model handles transmission\n",
+    "3. We updated `update_pre` and `update_death` to include changes to the `exposed` state \n",
+    "4. We rewrote `set_prognoses` to include the new exposed state.\n"
+   ]
+  }
+ ],
+ "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.7"
+  },
+  "pycharm": {
+   "stem_cell": {
+    "cell_type": "raw",
+    "metadata": {
+     "collapsed": false
+    },
+    "source": []
+   }
+  },
+  "toc": {
+   "base_numbering": 1,
+   "nav_menu": {},
+   "number_sections": true,
+   "sideBar": true,
+   "skip_h1_title": false,
+   "title_cell": "Table of Contents",
+   "title_sidebar": "Contents",
+   "toc_cell": false,
+   "toc_position": {},
+   "toc_section_display": true,
+   "toc_window_display": false
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 4
+}
diff --git a/docs/tutorials/tut_diseases.ipynb b/docs/tutorials/tut_diseases.ipynb
new file mode 100644
index 00000000..c274cb62
--- /dev/null
+++ b/docs/tutorials/tut_diseases.ipynb
@@ -0,0 +1,137 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# T5 - Diseases\n",
+    "\n",
+    "Diseases are the cornerstone of almost any Starsim analysis. This is where you add details about what disease you are modeling, including transmissibility, natural history, and mortality outcomes.      \n",
+    "\n",
+    "NOTE 1: The version of Starsim was originally conceived as a model for co-circulating STIs, and the current version of the codebase has much more developed models of STIs (including syphilis and HIV) than it does for other pathogens. However, the library of available diseases will keep expanding with time, and we will update these tutorials as it does.\n",
+    "\n",
+    "NOTE 2: Starsim descended from a family of similar agent-based models that share common architecture and design principles: [Covasim](https://covasim.org/), [HPVsim](https://hpvsim.org/), and [FPsim](https://fpsim.org/). If you are especially interested in modeling COVID-19, HPV and cervical cancer, or family planning, you may be better off using one of these pre-existing models. For all other diseases/health conditions, Starsim is probably your best bet.\n"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Modifying a disease\n",
+    "\n",
+    "Much like sims or networks, a `Disease` can be customized by passing in a `pars` dictionary containing parameters. We've already seen examples of this, but  "
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import starsim as ss\n",
+    "sir = ss.SIR(pars=dict(dur_inf=10, beta=0.2, init_prev=0.4, p_death=0.2))\n",
+    "sim = ss.Sim(n_agents=2_000, diseases=sir, networks=ss.RandomNet())\n",
+    "sim.run()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {
+    "collapsed": false,
+    "jupyter": {
+     "outputs_hidden": false
+    }
+   },
+   "source": [
+    "We already saw that this model creates results that are stored in `sim.results.sir`. The results can also be directly accessed via `sir.results`. \n",
+    "\n",
+    "For more detail on any of the diseases that are in the Starsim library of diseases, please refer to the docstrings and source code of the disease files. \n",
+    "\n",
+    "For more detail on making your own disease, please refer to the developer tutorial.\n"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {
+    "collapsed": false,
+    "jupyter": {
+     "outputs_hidden": false
+    }
+   },
+   "source": [
+    "## Adding multiple diseases\n",
+    "You can add multiple diseases to the same simulation, like so. Here we are making use of a _connector_. A connector is a special thing in Starsim that tells you how two things relate to one another - in this case, how HIV modifies a person's transmissibility and susceptibility to gonorrhea and vice versa "
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "collapsed": false,
+    "jupyter": {
+     "outputs_hidden": false
+    }
+   },
+   "outputs": [],
+   "source": [
+    "# Make HIV\n",
+    "hiv = ss.HIV()\n",
+    "hiv.pars['beta'] = {'mf': [0.0008, 0.0004]}  # Specify transmissibility over the MF network\n",
+    "hiv.pars['init_prev'] = 0.05\n",
+    "\n",
+    "# Make gonorrhea\n",
+    "ng = ss.Gonorrhea()\n",
+    "ng.pars['beta'] = {'mf': [0.05, 0.025]}  # Specify transmissibility over the MF network\n",
+    "ng.pars['init_prev'] = 0.025\n",
+    "\n",
+    "# Make the sim, including a connector betweeh HIV and gonorrhea:\n",
+    "n_agents = 5_000\n",
+    "sim = ss.Sim(n_agents=n_agents, networks=ss.MFNet(), diseases=[hiv, ng], connectors=ss.simple_hiv_ng())\n",
+    "sim.run()"
+   ]
+  }
+ ],
+ "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.7"
+  },
+  "pycharm": {
+   "stem_cell": {
+    "cell_type": "raw",
+    "metadata": {
+     "collapsed": false
+    },
+    "source": []
+   }
+  },
+  "toc": {
+   "base_numbering": 1,
+   "nav_menu": {},
+   "number_sections": true,
+   "sideBar": true,
+   "skip_h1_title": false,
+   "title_cell": "Table of Contents",
+   "title_sidebar": "Contents",
+   "toc_cell": false,
+   "toc_position": {},
+   "toc_section_display": true,
+   "toc_window_display": false
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 4
+}
diff --git a/docs/tutorials/tut_interventions.ipynb b/docs/tutorials/tut_interventions.ipynb
new file mode 100644
index 00000000..23cdc639
--- /dev/null
+++ b/docs/tutorials/tut_interventions.ipynb
@@ -0,0 +1,172 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# T6 - Interventions\n",
+    "\n",
+    "Understanding the impact of interventions is one of the most common reasons to use a disease model. This tutorial shows how to implement standard interventions, as well as how to define your own custom interventions.  \n"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Products and interventions\n",
+    "\n",
+    "Starsim contains _products_, which can be thought of as the actual test, diagnostic, treatment, or vaccine product being used, as well as _interventions_, which are responsible for delivering the products to the population. \n",
+    "\n",
+    "Depending on what disease you're modeling, you might need to define your own custom products and interventions, or you might be able to directly use some of the examples provided in Starsim.\n",
+    "\n",
+    "Starsim includes three basic \"types\" of products: diagnostics, treatment, and vaccination. There isn't a lot of detail in the templates for each of these, because most of the details about products is specific to a disease. There are also some disease-specific products built in the Starsim's library of diseases - these can generally be found in the `diseases` subfolder (e.g. the cholera interventions are in `cholera.py`. \n",
+    "\n",
+    "Starsim also includes several basic types of intervention:\n",
+    "- `routine_vx()` for routine vaccination, `campaign_vx()` for one-off campaigns \n",
+    "- similarly, `routine_screening()` and `campaign_screening()` for different types of screening program \n",
+    "- `treat_num()`, which treats a certain number of people each timestep (by default, as many people as need treatment, but you can also set a maximum).\n",
+    "\n",
+    "These are sometimes general enough that they don't need to be tailored to a particular disease, and you can just use them directly. That being said, you are always welcome to tailor them as you like to capture particular features of the intervention you're modeling.\n",
+    "\n",
+    "## Vaccination\n",
+    "To create an example, let's first create the parameters that we want to use for the simulation:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import starsim as ss\n",
+    "pars = dict(\n",
+    "    n_agents = 5_000,\n",
+    "    birth_rate = 20,\n",
+    "    death_rate = 0.015,\n",
+    "    networks = dict(\n",
+    "        type = 'randomnet',\n",
+    "        n_contacts = 4\n",
+    "    ),\n",
+    "    diseases = dict(\n",
+    "        type = 'sir',\n",
+    "        dur_inf = 10,\n",
+    "        beta = 0.1,\n",
+    "    )\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "source": [
+    "Now we'll create a vaccine product and a vaccination intervention:"
+   ],
+   "metadata": {
+    "collapsed": false
+   }
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "outputs": [],
+   "source": [
+    "# Create the product - a vaccine with 50% efficacy\n",
+    "MyVaccine = ss.sir_vaccine(pars=dict(efficacy=0.5))\n",
+    "\n",
+    "# Create the intervention\n",
+    "MyIntervention = ss.routine_vx(\n",
+    "    start_year=2015,    # Begin vaccination in 2015\n",
+    "    prob=0.2,           # 20% coverage\n",
+    "    product=MyVaccine   # Use the MyVaccine product\n",
+    ")\n",
+    "\n",
+    "# Now create two sims: a baseline sim and one with the intervention\n",
+    "sim_base = ss.Sim(pars=pars)\n",
+    "sim_base.run()\n",
+    "sim_intv = ss.Sim(pars=pars, interventions=MyIntervention)\n",
+    "sim_intv.run()"
+   ],
+   "metadata": {
+    "collapsed": false
+   }
+  },
+  {
+   "cell_type": "markdown",
+   "source": [
+    "If we want to see the impact, we can create a plot:"
+   ],
+   "metadata": {
+    "collapsed": false
+   }
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "outputs": [],
+   "source": [
+    "import matplotlib.pyplot as plt\n",
+    "plt.figure()\n",
+    "plt.plot(sim_base.yearvec, sim_base.results.sir.prevalence, label='Baseline')\n",
+    "plt.plot(sim_intv.yearvec, sim_intv.results.sir.prevalence, label='Vax')\n",
+    "plt.axvline(x=2015, color='k', ls='--')\n",
+    "plt.title('Prevalence')\n",
+    "plt.legend()\n",
+    "plt.show();"
+   ],
+   "metadata": {
+    "collapsed": false
+   }
+  },
+  {
+   "cell_type": "markdown",
+   "source": [
+    "We can see that from the year of introducing the vaccine, prevalence starts to fall. "
+   ],
+   "metadata": {
+    "collapsed": false
+   }
+  }
+ ],
+ "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.3"
+  },
+  "pycharm": {
+   "stem_cell": {
+    "cell_type": "raw",
+    "metadata": {
+     "collapsed": false
+    },
+    "source": []
+   }
+  },
+  "toc": {
+   "base_numbering": 1,
+   "nav_menu": {},
+   "number_sections": true,
+   "sideBar": true,
+   "skip_h1_title": false,
+   "title_cell": "Table of Contents",
+   "title_sidebar": "Contents",
+   "toc_cell": false,
+   "toc_position": {},
+   "toc_section_display": true,
+   "toc_window_display": false
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 4
+}
diff --git a/docs/tutorials/tut_intro.ipynb b/docs/tutorials/tut_intro.ipynb
index 1299f8d2..212ab8f5 100644
--- a/docs/tutorials/tut_intro.ipynb
+++ b/docs/tutorials/tut_intro.ipynb
@@ -6,28 +6,19 @@
    "source": [
     "# T1 - Getting started\n",
     "\n",
+    "Starsim is a highly flexible framework for creating agent-based models. Starsim was designed by a team with expertise in infectious diseases, so many of the examples that we'll cover throughout these tutorials are examples of infectious diseases. However, you can also use Starsim for modeling other things, like non-communicable diseases (NCDs) or family planning and maternal health.\n",
+    "\n",
     "Installing and getting started with Starsim is quite simple. \n",
     "\n",
     "To install, just type `pip install starsim`. If it worked, you should be able to import Starsim with `import starsim as ss`.\n",
     "\n",
-    "The basic design philosophy of Starsim is: **common tasks should be simple**. For example:\n",
+    "The basic design philosophy of Starsim is: **common tasks should be simple**. Since Starsim is most commonly used for modeling infectious diseases, everything related to that should be straightforward. The most common tasks that you are likely to do are:\n",
     "\n",
     "- Defining parameters\n",
     "- Running a simulation\n",
     "- Plotting results\n",
     "\n",
-    "This tutorial walks you through how to do these things."
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "<div class=\"alert alert-info\">\n",
-    "    \n",
-    "Click [here](https://mybinder.org/v2/gh/institutefordiseasemodeling/covasim/HEAD?urlpath=lab%2Ftree%2Fdocs%2Ftutorials%2Ftut_intro.ipynb) to open an interactive version of this notebook. [NB, URL is out of date.]\n",
-    "    \n",
-    "</div>"
+    "This tutorial walks you through the simplest possible version of how to do these things."
    ]
   },
   {
@@ -36,62 +27,50 @@
    "source": [
     "## Hello world\n",
     "\n",
-    "To create, run, and plot a sim is \"just\":"
+    "Let's start with the simplest version of a Starsim model. We'll make a version of a classic SIR model [Wiki](https://en.wikipedia.org/wiki/Compartmental_models_in_epidemiology). Here's how our code would look:"
    ]
   },
   {
    "cell_type": "code",
-   "execution_count": 3,
+   "execution_count": null,
    "metadata": {
     "tags": []
    },
-   "outputs": [
-    {
-     "data": {
-      "image/png": "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",
-      "text/plain": [
-       "<Figure size 640x480 with 12 Axes>"
-      ]
-     },
-     "metadata": {},
-     "output_type": "display_data"
-    }
-   ],
+   "outputs": [],
    "source": [
-    "import sciris as sc\n",
     "import starsim as ss\n",
     "\n",
     "# Define the parameters\n",
-    "pars = sc.objdict(\n",
-    "    start         = 2000,       # Starting year\n",
-    "    n_years       = 20,         # Number of years to simulate\n",
-    "    dt            = 0.2,        # Timestep\n",
-    "    verbose       = 0,          # Don't print details of the run\n",
-    "    rand_seed     = 2,          # Set a non-default seed\n",
+    "pars = dict(\n",
+    "    n_agents=5_000,     # Number of agents to simulate\n",
+    "    networks=dict(      # *Networks* add detail on how the agents interact with each other \n",
+    "        name='random',  # Here, we use a 'random' network\n",
+    "        n_contacts=4    # Each person has an average of 4 contacts with other people\n",
+    "    ),\n",
+    "    diseases=dict(      # *Diseases* add detail on what diseases to model \n",
+    "        name='sir',     # Here, we're creating an SIR disease \n",
+    "        init_prev=0.1,  # Proportion of the population initially infected \n",
+    "        beta=0.1,       # Probability of transmission between contacts\n",
+    "    )\n",
     ")\n",
     "\n",
-    "# Make people\n",
-    "ss.set_seed(pars.rand_seed)\n",
-    "n_agents = int(10e3)\n",
-    "networks = [ss.mf(), ss.maternal()]\n",
-    "ppl = ss.People(n_agents, networks=networks)\n",
-    "\n",
-    "# Make the sim\n",
-    "hiv = ss.HIV()\n",
-    "hiv.pars['beta'] = {'mf': [0.15, 0.10], 'maternal': [0.2, 0]}\n",
-    "sim = ss.Sim(pars=pars, people=ppl, demographics=ss.Pregnancy(), diseases=hiv)\n",
-    "\n",
-    "# Run and plot\n",
+    "# Make the sim, run and plot\n",
+    "sim = ss.Sim(pars)\n",
     "sim.run()\n",
     "sim.plot();"
    ]
   },
   {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": []
+   "cell_type": "markdown",
+   "metadata": {
+    "collapsed": false,
+    "jupyter": {
+     "outputs_hidden": false
+    }
+   },
+   "source": [
+    "As the tutorials progress we'll show how to extend this different diseases with different transmission pathways, and how to customize the model in lots of different ways."
+   ]
   }
  ],
  "metadata": {
@@ -110,7 +89,7 @@
    "name": "python",
    "nbconvert_exporter": "python",
    "pygments_lexer": "ipython3",
-   "version": "3.11.3"
+   "version": "3.11.7"
   },
   "pycharm": {
    "stem_cell": {
diff --git a/docs/tutorials/tut_networks.ipynb b/docs/tutorials/tut_networks.ipynb
new file mode 100644
index 00000000..a0853042
--- /dev/null
+++ b/docs/tutorials/tut_networks.ipynb
@@ -0,0 +1,235 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# T4 - Networks\n",
+    "\n",
+    "Many infectious diseases transmit when people come into contact with one another. In the basic SIR example that we covered in the first two tutorials, we used a 'random' contact network, which creates random pairings of people. Generally speaking, the structure of network that you use depends on what disease you're modeling. In addition to the 'random' network, Starim contains some basic sexual networks for modeling sexually transmitted infections (STIs) and a 'maternal' network for modeling mother-to-child transmission.  \n",
+    "\n",
+    "In this tutorial, we'll demonstrate a couple of the built-in networks, and give an overview of how you might create your own for your own disease modeling work."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Overview of Starsim's network structure\n",
+    "\n",
+    "Starsim's networks are stored in the `networks.py` file. At the top of this file you'll see the `Network` class, which provides the template for all the other networks. At a high level, all you need to know is that network stores various *parameters* that determine how pairs of people form and dissolve, and a collection of all the *contacts* that exist at a given point in time. Let's look at a simple example of the `random` network:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "tags": []
+   },
+   "outputs": [],
+   "source": [
+    "import starsim as ss\n",
+    "\n",
+    "# Make random network\n",
+    "random = ss.RandomNet()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {
+    "collapsed": false,
+    "jupyter": {
+     "outputs_hidden": false
+    }
+   },
+   "source": [
+    "The network we just made stores two very important things: the list of contacts (found in `random.contacts`) and the parameters (`random.pars`).  \n",
+    "\n",
+    "If we just make a network on its own like this, it's not very interesting. Like all components of Starsim, it needs to be initialized within a sim before the database of contacts gets populated:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "network = ss.RandomNet()\n",
+    "sir = ss.SIR()\n",
+    "sim = ss.Sim(n_agents=2_000, diseases=sir, networks=network)\n",
+    "sim.initialize()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {
+    "collapsed": false,
+    "jupyter": {
+     "outputs_hidden": false
+    }
+   },
+   "source": [
+    "Now `sim.networks.random.contacts` has all the information about which agents (referenced by their UID) are paired with which other agents.\n",
+    "\n",
+    "## Sexual networks\n",
+    "Starsim contains a few basic sexual networks, including an `mf` network for modeling heterosexual partnerships and an `msm` network for men having sex with men. Modeling STIs is a straightforward extension of the SIR example we looked at previously. Here's an example modeling syphilis:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "collapsed": false,
+    "jupyter": {
+     "outputs_hidden": false
+    }
+   },
+   "outputs": [],
+   "source": [
+    "# Create the disease\n",
+    "syph = ss.Syphilis()\n",
+    "syph.pars['beta'] = {'mf': [0.25, 0.15]}    # See notes below \n",
+    "\n",
+    "# Create a heterosexual network\n",
+    "mf = ss.MFNet(\n",
+    "    pars=dict(\n",
+    "        duration=1/24,  # Using very short relationships so that we get more transmission \n",
+    "        acts=80,        # Distribution of the number of coital acts/year\n",
+    "    )\n",
+    ")\n",
+    "\n",
+    "pars = dict(dt=1/12, start=2000, n_years=20)\n",
+    "sim = ss.Sim(pars=pars, diseases=syph, networks=mf)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {
+    "collapsed": false,
+    "jupyter": {
+     "outputs_hidden": false
+    }
+   },
+   "source": [
+    "The main difference here is that we've added the line `syph.pars['beta'] = {'mf': [0.25, 0.15]}`. Because STIs are generally more transmissible from males to females than vice versa, we can specify `beta` as a list, and we can also allow it to vary between networks. For instance, if we also had an MSM network here, we could set \n",
+    "```\n",
+    "syph.pars['beta'] = {'mf': [0.25, 0.15], 'msm': [0.4, 0.4]}\n",
+    "```\n",
+    "The sexual network itself is structurally similar to the `random` network that we looked at previously, and can be customized by changing the `pars` dictionary that's used to construct it.\n",
+    " \n",
+    "## Maternal networks\n",
+    "Maternal networks are useful for capturing mother-to-child transmission. If you want to use them, you also need to model pregnancy. Here's an example showing how you would do this for syphilis:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "collapsed": false,
+    "jupyter": {
+     "outputs_hidden": false
+    }
+   },
+   "outputs": [],
+   "source": [
+    "syph = ss.Syphilis()\n",
+    "syph.pars['beta'] = {'mf': [0.25, 0.15], 'maternal': [0.99, 0]}\n",
+    "\n",
+    "# Make demographic modules\n",
+    "pregnancy = ss.Pregnancy(pars={'fertility_rate': 20})\n",
+    "death = ss.Deaths(pars={'death_rate': 0.015})\n",
+    "\n",
+    "# Make maternal network\n",
+    "maternal = ss.MaternalNet()\n",
+    "\n",
+    "# Make sim\n",
+    "sim = ss.Sim(pars=pars, diseases=syph, networks=[mf, maternal], demographics=[pregnancy, death])"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {
+    "collapsed": false,
+    "jupyter": {
+     "outputs_hidden": false
+    }
+   },
+   "source": [
+    "## Making your own network\n",
+    "If you want to make your own network, a good place to start is by inheriting from one of the templates in networks.py. For example, if you wanted to make an `mf` network that has more age structure, you could do something like this:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "collapsed": false,
+    "jupyter": {
+     "outputs_hidden": false
+    }
+   },
+   "outputs": [],
+   "source": [
+    "class age_mf(ss.MFNet):\n",
+    "    def add_pairs(self, people, ti=None):\n",
+    "        # Custom code to select pairs based on age\n",
+    "        return "
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {
+    "collapsed": false,
+    "jupyter": {
+     "outputs_hidden": false
+    }
+   },
+   "source": [
+    "This network would operate exactly like the existing `mf` network, but you could control how new pairs were added.\n"
+   ]
+  }
+ ],
+ "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.7"
+  },
+  "pycharm": {
+   "stem_cell": {
+    "cell_type": "raw",
+    "metadata": {
+     "collapsed": false
+    },
+    "source": []
+   }
+  },
+  "toc": {
+   "base_numbering": 1,
+   "nav_menu": {},
+   "number_sections": true,
+   "sideBar": true,
+   "skip_h1_title": false,
+   "title_cell": "Table of Contents",
+   "title_sidebar": "Contents",
+   "toc_cell": false,
+   "toc_position": {},
+   "toc_section_display": true,
+   "toc_window_display": false
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 4
+}
diff --git a/setup.py b/setup.py
index 204197a7..8ab41fc0 100644
--- a/setup.py
+++ b/setup.py
@@ -40,7 +40,7 @@
         'numpy',
         'scipy',
         'pandas>=2.0.0',
-        'sciris>=3.0.0',
+        'sciris>=3.1.0',
         'matplotlib',
         'seaborn',
         'numba',
diff --git a/starsim/__init__.py b/starsim/__init__.py
index 0f2961ac..92d6d3c1 100644
--- a/starsim/__init__.py
+++ b/starsim/__init__.py
@@ -12,11 +12,12 @@
 from .demographics  import *
 from .products      import *
 from .interventions import *
-from .analyzers     import *
 from .demographics  import *
 from .connectors    import *
+from .disease       import *
 from .diseases      import *
 from .sim           import *
+from .run           import *
 
 import sciris as sc
 root = sc.thispath(__file__).parent
diff --git a/starsim/analyzers.py b/starsim/analyzers.py
deleted file mode 100644
index 16a7f468..00000000
--- a/starsim/analyzers.py
+++ /dev/null
@@ -1,17 +0,0 @@
-"""
-Define analyzers
-"""
-
-import starsim as ss
-
-
-__all__ = ['Analyzer']
-
-
-class Analyzer(ss.Module):
-
-    def __call__(self, *args, **kwargs):
-        return self.apply(*args, **kwargs)
-
-    def update_results(self, sim):
-        raise NotImplementedError
\ No newline at end of file
diff --git a/starsim/products/syph_dx.csv b/starsim/data/products/syph_dx.csv
similarity index 100%
rename from starsim/products/syph_dx.csv
rename to starsim/data/products/syph_dx.csv
diff --git a/starsim/products/syph_tx.csv b/starsim/data/products/syph_tx.csv
similarity index 100%
rename from starsim/products/syph_tx.csv
rename to starsim/data/products/syph_tx.csv
diff --git a/starsim/demographics.py b/starsim/demographics.py
index 360ca14f..9eb4d943 100644
--- a/starsim/demographics.py
+++ b/starsim/demographics.py
@@ -6,12 +6,10 @@
 import starsim as ss
 import sciris as sc
 import pandas as pd
-import scipy.stats as sps
 
-__all__ = ['DemographicModule', 'births', 'background_deaths', 'Pregnancy']
+__all__ = ['BaseDemographics', 'Births', 'Deaths', 'Pregnancy']
 
-
-class DemographicModule(ss.Module):
+class BaseDemographics(ss.Module):
     # A demographic module typically handles births/deaths/migration and takes
     # place at the start of the timestep, before networks are updated and before
     # any disease modules are executed
@@ -29,21 +27,21 @@ def update(self, sim):
         # carried out inside this function
         pass
 
-class births(DemographicModule):
+class Births(BaseDemographics):
     def __init__(self, pars=None, metadata=None):
         super().__init__(pars)
 
         # Set defaults
-        self.pars = ss.omerge({
-            'birth_rate': 0,
-            'rel_birth': 1,
-            'units': 1e-3  # assumes birth rates are per 1000. If using percentages, switch this to 1
-        }, self.pars)
+        self.pars = ss.omergeleft(self.pars,
+            birth_rate = 0,
+            rel_birth = 1,
+            units = 1e-3,  # assumes birth rates are per 1000. If using percentages, switch this to 1
+        )
 
         # Process metadata. Defaults here are the labels used by UN data
-        self.metadata = ss.omerge({
-            'data_cols': {'year': 'Year', 'cbr': 'CBR'},
-        }, metadata)
+        self.metadata = ss.omergeleft(metadata,
+            data_cols = dict(year='Year', cbr='CBR'),
+        )
 
         # Process data, which may be provided as a number, dict, dataframe, or series
         # If it's a number it's left as-is; otherwise it's converted to a dataframe
@@ -65,9 +63,11 @@ def standardize_birth_data(self):
         return birth_rate
 
     def init_results(self, sim):
-        self.results += ss.Result(self.name, 'new', sim.npts, dtype=int, scale=True)
-        self.results += ss.Result(self.name, 'cumulative', sim.npts, dtype=int, scale=True)
-        self.results += ss.Result(self.name, 'cbr', sim.npts, dtype=int, scale=False)
+        self.results += [
+            ss.Result(self.name, 'new', sim.npts, dtype=int, scale=True),
+            ss.Result(self.name, 'cumulative', sim.npts, dtype=int, scale=True),
+            ss.Result(self.name, 'cbr', sim.npts, dtype=int, scale=False),
+        ]
         return
 
     def update(self, sim):
@@ -87,7 +87,7 @@ def get_births(self, sim):
 
         scaled_birth_prob = this_birth_rate * p.units * p.rel_birth * sim.pars.dt
         scaled_birth_prob = np.clip(scaled_birth_prob, a_min=0, a_max=1)
-        n_new = int(np.floor(np.count_nonzero(sim.people.alive) * scaled_birth_prob))
+        n_new = int(np.floor(sim.people.alive.count() * scaled_birth_prob))
         return n_new
 
     def add_births(self, sim):
@@ -106,8 +106,8 @@ def finalize(self, sim):
         self.results['cbr'] = 1/self.pars.units*np.divide(self.results['new'], sim.results['n_alive'], where=sim.results['n_alive']>0)
 
 
-class background_deaths(DemographicModule):
-    def __init__(self, pars=None, metadata=None):
+class Deaths(BaseDemographics):
+    def __init__(self, pars=None, par_dists=None, metadata=None):
         """
         Configure disease-independent "background" deaths.
 
@@ -124,37 +124,41 @@ def __init__(self, pars=None, metadata=None):
         Alternatively, it is possible to override the `death_rate` parameter
         with a bernoulli distribution containing a constant value of function of
         your own design.
-        
-        :param pars: dict with arguments including:
-            rel_death: constant used to scale all death rates
-            death_rate: float, dict, or pandas dataframe/series containing mortality data
-            units: units for death rates (see in-line comment on par dict below)
-
-        :param metadata: data about the data contained within the data input.
-            "data_cols" is is a dictionary mapping standard keys, like "year" to the
-            corresponding column name in data. Similar for "sex_keys". Finally,
+
+        Args:
+            pars: dict with arguments including:
+                rel_death: constant used to scale all death rates
+                death_rate: float, dict, or pandas dataframe/series containing mortality data
+                units: units for death rates (see in-line comment on par dict below)
+
+            par_dists: dict
+
+            metadata: data about the data contained within the data input.
+                "data_cols" is is a dictionary mapping standard keys, like "year" to the
+                corresponding column name in data. Similar for "sex_keys". Finally,
         """
         super().__init__(pars)
 
-        self.pars = ss.omerge({
-            'rel_death': 1,
-            'death_rate': 0.02,  # Default = a fixed rate of 2%/year, overwritten if data provided
-            'units': 1,  # units for death rates. If using percentages, leave as 1. If using a CMR (e.g. 12 deaths per 1000), change to 1/1000
-        }, self.pars)
+        self.pars = ss.omergeleft(self.pars,
+            rel_death = 1,
+            death_rate = 0.02,  # Default = a fixed rate of 2%/year, overwritten if data provided
+            units = 1,  # units for death rates. If using percentages, leave as 1. If using a CMR (e.g. 12 deaths per 1000), change to 1/1000
+        )
+
+        self.par_dists = ss.omergeleft(par_dists,
+            death_rate = ss.bernoulli
+        )
 
         # Process metadata. Defaults here are the labels used by UN data
-        self.metadata = ss.omerge({
-            'data_cols': {'year': 'Time', 'sex': 'Sex', 'age': 'AgeGrpStart', 'value': 'mx'},
-            'sex_keys': {'f': 'Female', 'm': 'Male'},
-        }, metadata)
+        self.metadata = ss.omergeleft(metadata,
+            data_cols = dict(year='Time', sex='Sex', age='AgeGrpStart', value='mx'),
+            sex_keys = dict(f='Female', m='Male'),
+        )
 
         # Process data, which may be provided as a number, dict, dataframe, or series
         # If it's a number it's left as-is; otherwise it's converted to a dataframe
-        self.pars.death_rate = self.standardize_death_data()
-
-        # Create death_prob_fn, a function which returns a probability of death for each requested uid
-        self.death_prob_fn = self.make_death_prob_fn
-        self.death_dist = sps.bernoulli(p=self.death_prob_fn)
+        self.death_rate_data = self.standardize_death_data()
+        self.pars.death_rate = self.make_death_prob_fn
 
         return
 
@@ -162,21 +166,22 @@ def __init__(self, pars=None, metadata=None):
     def make_death_prob_fn(module, sim, uids):
         """ Take in the module, sim, and uids, and return the probability of death for each UID on this timestep """
 
-        if sc.isnumber(module.pars.death_rate):
-            death_rate = module.pars.death_rate
+        if sc.isnumber(module.death_rate_data):
+            death_rate = module.death_rate_data
 
         else:
-            year_label = module.metadata.data_cols['year']
-            age_label = module.metadata.data_cols['age']
-            sex_label = module.metadata.data_cols['sex']
-            val_label = module.metadata.data_cols['value']
+            data_cols = sc.objdict(module.metadata.data_cols)
+            year_label = data_cols.year
+            age_label  = data_cols.age
+            sex_label  = data_cols.sex
+            val_label  = data_cols.value
             sex_keys = module.metadata.sex_keys
 
-            available_years = module.pars.death_rate[year_label].unique()
+            available_years = module.death_rate_data[year_label].unique()
             year_ind = sc.findnearest(available_years, sim.year)
             nearest_year = available_years[year_ind]
 
-            df = module.pars.death_rate.loc[module.pars.death_rate[year_label] == nearest_year]
+            df = module.death_rate_data.loc[module.death_rate_data[year_label] == nearest_year]
             age_bins = df[age_label].unique()
             age_inds = np.digitize(sim.people.age[uids], age_bins) - 1
 
@@ -185,7 +190,7 @@ def make_death_prob_fn(module, sim, uids):
 
             # Initialize
             death_rate_df = pd.Series(index=uids)
-            death_rate_df[uids[sim.people.female[uids]]] = f_arr[age_inds[sim.people.female[uids]]]
+            death_rate_df[uids[sim.people.female[uids]]] = f_arr[age_inds[sim.people.female[uids]]] # TODO: avoid double indexing
             death_rate_df[uids[sim.people.male[uids]]] = m_arr[age_inds[sim.people.male[uids]]]
             death_rate_df[uids[sim.people.age[uids] < 0]] = 0  # Don't use background death rates for unborn babies
 
@@ -203,9 +208,11 @@ def standardize_death_data(self):
         return death_rate
 
     def init_results(self, sim):
-        self.results += ss.Result(self.name, 'new', sim.npts, dtype=int, scale=True)
-        self.results += ss.Result(self.name, 'cumulative', sim.npts, dtype=int, scale=True)
-        self.results += ss.Result(self.name, 'cmr', sim.npts, dtype=int, scale=False)
+        self.results += [
+            ss.Result(self.name, 'new', sim.npts, dtype=int, scale=True),
+            ss.Result(self.name, 'cumulative', sim.npts, dtype=int, scale=True),
+            ss.Result(self.name, 'cmr', sim.npts, dtype=int, scale=False),
+        ]
         return
 
     def update(self, sim):
@@ -216,63 +223,65 @@ def update(self, sim):
     def apply_deaths(self, sim):
         """ Select people to die """
         alive_uids = ss.true(sim.people.alive)
-        death_uids = self.death_dist.filter(alive_uids)
+        death_uids = self.pars.death_rate.filter(alive_uids)
         sim.people.request_death(death_uids)
         return len(death_uids)
 
     def update_results(self, n_deaths, sim):
         self.results['new'][sim.ti] = n_deaths
+        return
 
     def finalize(self, sim):
         super().finalize(sim)
         self.results['cumulative'] = np.cumsum(self.results['new'])
         self.results['cmr'] = 1/self.pars.units*np.divide(self.results['new'], sim.results['n_alive'], where=sim.results['n_alive']>0)
+        return
 
+class Pregnancy(BaseDemographics):
 
-class Pregnancy(DemographicModule):
-
-    def __init__(self, pars=None, metadata=None):
+    def __init__(self, pars=None, par_dists=None, metadata=None):
         super().__init__(pars)
 
         # Other, e.g. postpartum, on contraception...
-        self.infertile = ss.State('infertile', bool, False)  # Applies to girls and women outside the fertility window
-        self.susceptible = ss.State('fecund', bool, True)  # Applies to girls and women inside the fertility window
-        self.pregnant = ss.State('pregnant', bool, False)  # Currently pregnant
-        self.postpartum = ss.State('postpartum', bool, False)  # Currently post-partum
-        self.ti_pregnant = ss.State('ti_pregnant', int, ss.INT_NAN)  # Time pregnancy begins
-        self.ti_delivery = ss.State('ti_delivery', int, ss.INT_NAN)  # Time of delivery
-        self.ti_postpartum = ss.State('ti_postpartum', int, ss.INT_NAN)  # Time postpartum ends
-        self.ti_dead = ss.State('ti_dead', int, ss.INT_NAN)  # Maternal mortality
-        self.conception_probs = ss.State('conception_probs', float, 0)
-
-        self.pars = ss.omerge({
-            'dur_pregnancy': 0.75,  # Make this a distribution?
-            'dur_postpartum': 0.5,  # Make this a distribution?
-            'fertility_rate': 0,    # Usually this will be provided in CSV format
-            'rel_fertility': 1,
-            'maternal_death_rate': 0,
-            'sex_ratio': 0.5,       # Ratio of babies born female
-            'units': 1e-3,          # Assumes fertility rates are per 1000. If using percentages, switch this to 1
-        }, self.pars)
+        self.add_states(
+            ss.State('infertile', bool, False),  # Applies to girls and women outside the fertility window
+            ss.State('fecund', bool, True),  # Applies to girls and women inside the fertility window
+            ss.State('pregnant', bool, False),  # Currently pregnant
+            ss.State('postpartum', bool, False),  # Currently post-partum
+            ss.State('ti_pregnant', int, ss.INT_NAN),  # Time pregnancy begins
+            ss.State('ti_delivery', int, ss.INT_NAN),  # Time of delivery
+            ss.State('ti_postpartum', int, ss.INT_NAN),  # Time postpartum ends
+            ss.State('ti_dead', int, ss.INT_NAN),  # Maternal mortality
+            ss.State('conception_probs', float, 0),
+        )
+
+        self.pars = ss.omergeleft(self.pars,
+            dur_pregnancy = 0.75,
+            dur_postpartum = 0.5,
+            fertility_rate = 0,    # Usually this will be provided in CSV format
+            rel_fertility = 1,
+            maternal_death_rate = 0,
+            sex_ratio = 0.5,       # Ratio of babies born female
+            units = 1e-3,          # Assumes fertility rates are per 1000. If using percentages, switch this to 1
+        )
+
+        self.par_dists = ss.omergeleft(par_dists,
+            fertility_rate = ss.bernoulli,
+            maternal_death_rate = ss.bernoulli,
+            sex_ratio = ss.bernoulli
+        )
 
         # Process metadata. Defaults here are the labels used by UN data
-        self.metadata = ss.omerge({
-            'data_cols': {'year': 'Time', 'age': 'AgeGrp', 'value': 'ASFR'},
-        }, metadata)
+        self.metadata = ss.omergeleft(metadata,
+            data_cols = dict(year='Time', age='AgeGrp', value='ASFR'),
+        )
 
-        self.choose_slots = sps.randint(low=0, high=1) # Low and high will be reset upon initialization
+        self.choose_slots = ss.randint(low=0, high=1) # Low and high will be reset upon initialization
 
         # Process data, which may be provided as a number, dict, dataframe, or series
         # If it's a number it's left as-is; otherwise it's converted to a dataframe
-        self.pars.fertility_rate = self.standardize_fertility_data()
-
-        # Create fertility_prob_fn, a function which returns a probability of death for each requested uid
-        self.fertility_prob_fn = self.make_fertility_prob_fn
-        self.fertility_dist = sps.bernoulli(p=self.fertility_prob_fn)
-
-        # Add other sampling functions
-        self.sex_dist = sps.bernoulli(p=self.pars.sex_ratio)
-        self.death_dist = sps.bernoulli(p=self.pars.maternal_death_rate)
+        self.fertility_rate_data = self.standardize_fertility_data()
+        self.pars.fertility_rate = self.make_fertility_prob_fn
 
         return
 
@@ -280,20 +289,21 @@ def __init__(self, pars=None, metadata=None):
     def make_fertility_prob_fn(module, sim, uids):
         """ Take in the module, sim, and uids, and return the conception probability for each UID on this timestep """
 
-        if sc.isnumber(module.pars.fertility_rate):
-            fertility_rate = module.pars.fertility_rate
+        if sc.isnumber(module.fertility_rate_data):
+            fertility_rate = module.fertility_rate_data
 
         else:
             # Abbreviate key variables
-            year_label = module.metadata.data_cols['year']
-            age_label = module.metadata.data_cols['age']
-            val_label = module.metadata.data_cols['value']
+            data_cols = sc.objdict(module.metadata.data_cols)
+            year_label = data_cols.year
+            age_label  = data_cols.age
+            val_label  = data_cols.value
 
-            available_years = module.pars.fertility_rate[year_label].unique()
+            available_years = module.fertility_rate_data[year_label].unique()
             year_ind = sc.findnearest(available_years, sim.year-module.pars.dur_pregnancy)
             nearest_year = available_years[year_ind]
 
-            df = module.pars.fertility_rate.loc[module.pars.fertility_rate[year_label] == nearest_year]
+            df = module.fertility_rate_data.loc[module.fertility_rate_data[year_label] == nearest_year]
             df_arr = df[val_label].values  # Pull out dataframe values
             df_arr = np.append(df_arr, 0)  # Add zeros for those outside data range
 
@@ -342,9 +352,11 @@ def init_results(self, sim):
         Still unclear whether this logic should live in the pregnancy module, the
         individual disease modules, the connectors, or the sim.
         """
-        self.results += ss.Result(self.name, 'pregnancies', sim.npts, dtype=int, scale=True)
-        self.results += ss.Result(self.name, 'births', sim.npts, dtype=int, scale=True)
-        self.results += ss.Result(self.name, 'cbr', sim.npts, dtype=int, scale=False)
+        self.results += [
+            ss.Result(self.name, 'pregnancies', sim.npts, dtype=int, scale=True),
+            ss.Result(self.name, 'births', sim.npts, dtype=int, scale=True),
+            ss.Result(self.name, 'cbr', sim.npts, dtype=int, scale=False),
+        ]
         return
 
     def update(self, sim):
@@ -366,12 +378,12 @@ def update_states(self, sim):
         deliveries = self.pregnant & (self.ti_delivery <= sim.ti)
         self.pregnant[deliveries] = False
         self.postpartum[deliveries] = True
-        self.susceptible[deliveries] = False
+        self.fecund[deliveries] = False
 
         # Check for new women emerging from post-partum
         postpartum = ~self.pregnant & (self.ti_postpartum <= sim.ti)
         self.postpartum[postpartum] = False
-        self.susceptible[postpartum] = True
+        self.fecund[postpartum] = True
 
         # Maternal deaths
         maternal_deaths = ss.true(self.ti_dead <= sim.ti)
@@ -390,7 +402,7 @@ def make_pregnancies(self, sim):
         # are instead handled in the fertility_dist logic as the rates need to be adjusted
         denom_conds = ppl.female & ppl.alive
         inds_to_choose_from = ss.true(denom_conds)
-        conceive_uids = self.fertility_dist.filter(inds_to_choose_from)
+        conceive_uids = self.pars.fertility_rate.filter(inds_to_choose_from)
 
         # Set prognoses for the pregnancies
         if len(conceive_uids) > 0:
@@ -410,12 +422,12 @@ def make_embryos(self, sim, conceive_uids):
             new_uids = sim.people.grow(len(new_slots))
             sim.people.age[new_uids] = -self.pars.dur_pregnancy
             sim.people.slot[new_uids] = new_slots  # Before sampling female_dist
-            sim.people.female[new_uids] = self.sex_dist.rvs(new_uids)
+            sim.people.female[new_uids] = self.pars.sex_ratio.rvs(new_uids)
 
             # Add connections to any vertical transmission layers
             # Placeholder code to be moved / refactored. The maternal network may need to be
             # handled separately to the sexual networks, TBC how to handle this most elegantly
-            for lkey, layer in sim.people.networks.items():
+            for lkey, layer in sim.networks.items():
                 if layer.vertical:  # What happens if there's more than one vertical layer?
                     durs = np.full(n_unborn_agents, fill_value=self.pars.dur_pregnancy + self.pars.dur_postpartum)
                     layer.add_pairs(conceive_uids, new_uids, dur=durs)
@@ -431,18 +443,18 @@ def set_prognoses(self, sim, uids):
         """
 
         # Change states for the newly pregnant woman
-        self.susceptible[uids] = False
+        self.fecund[uids] = False
         self.pregnant[uids] = True
         self.ti_pregnant[uids] = sim.ti
 
         # Outcomes for pregnancies
         dur = np.full(len(uids), sim.ti + self.pars.dur_pregnancy / sim.dt)
-        dead = self.death_dist.rvs(uids)
+        dead = self.pars.maternal_death_rate.rvs(uids)
         self.ti_delivery[uids] = dur  # Currently assumes maternal deaths still result in a live baby
         dur_post_partum = np.full(len(uids), dur + self.pars.dur_postpartum / sim.dt)
         self.ti_postpartum[uids] = dur_post_partum
 
-        if np.count_nonzero(dead):
+        if np.any(dead): # NB: 100x faster than np.sum(), 10x faster than np.count_nonzero()
             self.ti_dead[uids[dead]] = dur[dead]
         return
 
diff --git a/starsim/diseases/disease.py b/starsim/disease.py
similarity index 59%
rename from starsim/diseases/disease.py
rename to starsim/disease.py
index 0e5b76a2..34200e3c 100644
--- a/starsim/diseases/disease.py
+++ b/starsim/disease.py
@@ -5,91 +5,11 @@
 import numpy as np
 import sciris as sc
 import starsim as ss
-import scipy.stats as sps
 import networkx as nx
 from operator import itemgetter
 import pandas as pd
 
-__all__ = ['InfectionLog', 'Disease', 'STI']
-
-class InfectionLog(nx.MultiDiGraph):
-    """
-    Record infections
-
-    The infection log records transmission events and optionally other data
-    associated with each transmission. Basic functionality is to track
-    transmission with
-
-    >>> Disease.log.append(source, target, t)
-
-    Seed infections can be recorded with a source of `None`, although all infections
-    should have a target and a time. Other data can be captured in the log, either at
-    the time of creation, or later on. For example
-
-    >>> Disease.log.append(source, target, t, network='msm')
-
-    could be used by a module to track the network in which transmission took place.
-    Modules can optionally add per-infection outcomes later as well, for example
-
-    >>> Disease.log.add_data(source, t_dead=2024.25)
-
-    This would be equivalent to having specified the data at the original time the log
-    entry was created - however, it is more useful for tracking events that may or may
-    not occur after the infection and could be modified by interventions (e.g., tracking
-    diagnosis, treatment, notification etc.)
-
-    A table of outcomes can be returned using `InfectionLog.line_list()`
-    """
-    # Add entries
-    # Add items to the most recent infection for an agent
-
-    def add_data(self, uids, **kwargs):
-        """
-        Record extra infection data
-
-        This method can be used to add data to an existing transmission event.
-        The most recent transmission event will be used
-
-        :param uid: The UID of the target node (the agent that was infected)
-        :param kwargs: Remaining arguments are stored as edge data
-        """
-        for uid in sc.promotetoarray(uids):
-            source, target, key = max(self.in_edges(uid, keys=True), key=itemgetter(2,0)) # itemgetter twice as fast as lambda apparently
-            self[source][target][key].update(**kwargs)
-
-    def append(self, source, target, t, **kwargs):
-        self.add_edge(source, target, key=t, **kwargs)
-
-    @property
-    def line_list(self):
-        """
-        Return a tabular representation of the log
-
-        This function returns a dataframe containing columns for all quantities
-        recorded in the log. Note that the log will contain `NaN` for quantities
-        that are defined for some edges and not others (and which are missing for
-        a particular entry)
-        """
-        if len(self) == 0:
-            return pd.DataFrame(columns=['t','source','target'])
-
-        entries = []
-        for source, target, t, data in self.edges(keys=True, data=True):
-            d = data.copy()
-            d.update(source=source, target=target, t=t)
-            entries.append(d)
-        df = pd.DataFrame.from_records(entries)
-        df = df.sort_values(['t','source','target'])
-        df = df.reset_index(drop=True)
-
-        # Use Pandas "Int64" type to allow nullable integers. This allows the 'source' column
-        # to have an integer type corresponding to UIDs while simultaneously supporting the use
-        # of null values to represent exogenous/seed infections
-        df = df.fillna(pd.NA)
-        df['source'] = df['source'].astype("Int64")
-        df['target'] = df['target'].astype("Int64")
-
-        return df
+__all__ = ['Disease', 'Infection', 'STI', 'InfectionLog']
 
 
 class Disease(ss.Module):
@@ -97,9 +17,8 @@ class Disease(ss.Module):
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
-        self.results = ss.ndict(type=ss.Result)
-        self.log = InfectionLog()
-        self.new_cases_RNG = ss.MultiRNG(name=f'New cases of {self.name}')
+        self.results = ss.Results(self.name)
+        self.log = InfectionLog()  # See below for definition
         return
 
     @property
@@ -109,8 +28,6 @@ def _boolean_states(self):
 
         For diseases, these states typically represent attributes like 'susceptible',
         'infectious', 'diagnosed' etc. These variables are typically useful to
-
-        :return:
         """
         for state in self.states:
             if state.dtype == bool:
@@ -131,11 +48,24 @@ def finalize(self, sim):
     def validate_pars(self, sim):
         """
         Perform any parameter validation
-
-        :return: None if parameters are all valid
-        :raises: Exception if there are any invalid parameters (or if the initialization is otherwise invalid in some way)
         """
-        pass
+        if sim.networks is not None and len(sim.networks) > 0:
+
+            # If there's no beta, make a default one
+            if 'beta' not in self.pars or self.pars.beta is None:
+                self.pars.beta = sc.objdict({k: [1, 1] for k in sim.networks})
+
+            # If beta is a scalar, apply this bi-directionally to all networks
+            if sc.isnumber(self.pars.beta):
+                orig_beta = self.pars.beta
+                self.pars.beta = sc.objdict({k: [orig_beta] * 2 for k in sim.networks})
+
+            # If beta is a dict, check all entries are bi-directional
+            elif isinstance(self.pars.beta, dict):
+                for k, v in self.pars.beta.items():
+                    if sc.isnumber(v):
+                        self.pars.beta[k] = [v, v]
+        return
 
     def set_initial_states(self, sim):
         """
@@ -164,9 +94,6 @@ def init_results(self, sim):
     def update_pre(self, sim):
         """
         Carry out autonomous updates at the start of the timestep (prior to transmission)
-
-        :param sim:
-        :return:
         """
         pass
 
@@ -184,10 +111,6 @@ def update_death(self, sim, uids):
 
         Depending on the module and the results it produces, it may or may not be necessary
         to implement this.
-
-        :param sim:
-        :param uids:
-        :return:
         """
         pass
 
@@ -202,7 +125,6 @@ def make_new_cases(self, sim):
 
         It is expected that this method will internally call Disease.set_prognoses()
         at some point.
-
         """
         pass
 
@@ -218,10 +140,10 @@ def set_prognoses(self, sim, target_uids, source_uids=None):
         The from_uids are relevant for infectious diseases, but would be left
         as `None` for NCDs.
 
-        :param sim:
-        :param uids: UIDs for agents to assign disease progoses to
-        :param from_uids: Optionally specify the infecting agent
-        :return:
+        Args:
+            sim (Sim): the STarsim simulation object
+            uids (array): UIDs for agents to assign disease progoses to
+            from_uids (array): Optionally specify the infecting agent
         """
         if source_uids is None:
             for target in target_uids:
@@ -229,6 +151,7 @@ def set_prognoses(self, sim, target_uids, source_uids=None):
         else:
             for target, source in zip(target_uids, source_uids):
                 self.log.append(source, target, sim.year)
+        return
 
     def update_results(self, sim):
         """
@@ -243,23 +166,25 @@ def update_results(self, sim):
         return
 
 
-class STI(Disease):
+class Infection(Disease):
     """
-    Base class for STIs used in STIsim
+    Base class for infectious diseases used in Starsim
 
-    This class contains specializations for STI transmission (i.e., implements network-based
-    transmission with directional beta values) and defines attributes that STIsim connectors
+    This class contains specializations for infectious transmission (i.e., implements network-based
+    transmission with directional beta values) and defines attributes that connectors
     operate on to capture co-infection
     """
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
-        self.rel_sus     = ss.State('rel_sus', float, 1)
-        self.rel_sev     = ss.State('rel_sev', float, 1)
-        self.rel_trans   = ss.State('rel_trans', float, 1)
-        self.susceptible = ss.State('susceptible', bool, True)
-        self.infected    = ss.State('infected', bool, False)
-        self.ti_infected = ss.State('ti_infected', int, ss.INT_NAN)
+        self.add_states(
+            ss.State('susceptible', bool, True),
+            ss.State('infected', bool, False),
+            ss.State('rel_sus', float, 1.0),
+            ss.State('rel_sev', float, 1.0),
+            ss.State('rel_trans', float, 1.0),
+            ss.State('ti_infected', int, ss.INT_NAN),
+        )
         return
 
     @property
@@ -270,16 +195,6 @@ def infectious(self):
         """
         return self.infected
 
-
-    def validate_pars(self, sim):
-        """
-        Perform any parameter validation
-        """
-        super().validate_pars(sim)
-        if 'beta' not in self.pars:
-            self.pars.beta = sc.objdict({k: [1, 1] for k in sim.people.networks})
-        return
-
     def set_initial_states(self, sim):
         """
         Set initial values for states. This could involve passing in a full set of initial conditions,
@@ -287,11 +202,11 @@ def set_initial_states(self, sim):
         i.e., creating their dynamic array, linking them to a People instance. That should have already
         taken place by the time this method is called.
         """
-        if self.pars['seed_infections'] is None:
+        if self.pars.init_prev is None:
             return
 
         alive_uids = ss.true(sim.people.alive)  # Maybe just sim.people.uid?
-        initial_cases = self.pars['seed_infections'].filter(alive_uids)
+        initial_cases = self.pars.init_prev.filter(alive_uids)
         self.set_prognoses(sim, initial_cases)  # TODO: sentinel value to indicate seeds?
         return
 
@@ -300,97 +215,134 @@ def init_results(self, sim):
         Initialize results
         """
         super().init_results(sim)
-        self.results += ss.Result(self.name, 'prevalence', sim.npts, dtype=float, scale=False)
-        self.results += ss.Result(self.name, 'new_infections', sim.npts, dtype=int, scale=True)
+        self.results += [
+            ss.Result(self.name, 'prevalence', sim.npts, dtype=float, scale=False),
+            ss.Result(self.name, 'new_infections', sim.npts, dtype=int, scale=True),
+            ss.Result(self.name, 'cum_infections', sim.npts, dtype=int, scale=True),
+        ]
         return
 
-    def update_pre(self, sim):
-        """
-        Carry out autonomous updates at the start of the timestep (prior to transmission)
-
-        :param sim:
-        :return:
-        """
-        pass
-
-    def _make_new_cases_singlerng(self, people):
+    def _check_betas(self, sim):
+        """ Check that there's a network for each beta keys """
+        betapars = self.pars.beta
+        betamap = sc.objdict()
+        netkeys = list(sim.networks.keys())
+        for bkey in betapars.keys():
+            orig_bkey = bkey[:]
+            if bkey in netkeys: # TODO: CK: could tidy up logic
+                betamap[bkey] = betapars[orig_bkey]
+            else:
+                if 'net' not in bkey:
+                    bkey += 'net'  # Add 'net' suffix if not already there
+                if bkey in netkeys:
+                    betamap[bkey] = betapars[orig_bkey]
+                else:
+                    errormsg = f'No network for beta parameter "{bkey}"; your beta should match network keys:\n{sc.newlinejoin(netkeys)}'
+                    raise ValueError(errormsg)
+        return betamap
+
+    def _make_new_cases_singlerng(self, sim):
         # Not common-random-number-safe, but more efficient for when not using the multirng feature
         new_cases = []
         sources = []
-        for k, layer in people.networks.items():
-            if k in self.pars['beta']:
-                contacts = layer.contacts
-                rel_trans = (self.infectious & people.alive) * self.rel_trans
-                rel_sus = (self.susceptible & people.alive) * self.rel_sus
-                for a, b, beta in [[contacts.p1, contacts.p2, self.pars.beta[k][0]],
-                                   [contacts.p2, contacts.p1, self.pars.beta[k][1]]]:
-
-                    # Skip networks with no transmission
-                    if beta == 0:
-                        continue
-
-                    # Calculate probability of a->b transmission. If we have information on the
-                    # number of sexual acts, then beta is assumed to be a per-act transmission
-                    # probability. If not, it's assumed to be annual.
-                    if 'acts' in contacts.keys():
-                        beta_per_dt = 1-(1-beta)**(contacts.acts*people.dt)
-                        p_transmit = rel_trans[a] * rel_sus[b] * contacts.beta * beta_per_dt
-                    else:
-                        p_transmit = rel_trans[a] * rel_sus[b] * contacts.beta * beta * people.dt
-
-                    new_cases_bool = np.random.random(len(a)) < p_transmit  # As this class is not common-random-number safe anyway, calling np.random is perfectly fine!
-                    new_cases.append(b[new_cases_bool])
-                    sources.append(a[new_cases_bool])
-        return np.concatenate(new_cases), np.concatenate(sources)
-
-    def _make_new_cases_multirng(self, people):
-        '''
+        people = sim.people
+        betamap = self._check_betas(sim)
+
+        for nkey, net in sim.networks.items():
+            if not len(net):
+                break
+            nbetas = betamap[nkey]
+            contacts = net.contacts
+            rel_trans = (self.infectious & people.alive) * self.rel_trans
+            rel_sus = (self.susceptible & people.alive) * self.rel_sus
+            for a, b, beta in [[contacts.p1, contacts.p2, nbetas[0]],
+                               [contacts.p2, contacts.p1, nbetas[1]]]:
+
+                # Skip networks with no transmission
+                if beta == 0:
+                    continue
+
+                # Calculate probability of a->b transmission. If we have information on the
+                # number of sexual acts, then beta is assumed to be a per-act transmission
+                # probability. If not, it's assumed to be annual.
+                # TODO: move this to STI?
+                if 'acts' in contacts.keys():
+                    beta_per_dt = 1 - (1 - beta) ** (contacts.acts * people.dt)
+                    p_transmit = rel_trans[a] * rel_sus[b] * contacts.beta * beta_per_dt
+                else:
+                    p_transmit = rel_trans[a] * rel_sus[b] * contacts.beta * beta * people.dt
+
+                new_cases_bool = np.random.random(
+                    len(a)) < p_transmit  # As this class is not common-random-number safe anyway, calling np.random is perfectly fine!
+                new_cases.append(b[new_cases_bool])
+                sources.append(a[new_cases_bool])
+        if len(new_cases) and len(sources):
+            return np.concatenate(new_cases), np.concatenate(sources)
+        return np.empty((0,), dtype=int), np.empty((0,), dtype=int)
+
+    def _make_new_cases_multirng(self, sim):
+        """
         Common-random-number-safe transmission code works by computing the
         probability of each _node_ acquiring a case rather than checking if each
         _edge_ transmits.
         Subsequent step uses a roulette wheel with slotted RNG to determine
         infection source.
-        '''
+        """
+        people = sim.people
         n = len(people.uid)  # TODO: possibly could be shortened to just the people who are alive
         p_acq_node = np.zeros(n)
+        betamap = self._check_betas(sim)
 
         avec = []
         bvec = []
         pvec = []
-        for lkey, layer in people.networks.items():
-            if lkey in self.pars['beta']:
-                contacts = layer.contacts
-                rel_trans = self.rel_trans * (self.infectious & people.alive)
-                rel_sus = self.rel_sus * (self.susceptible & people.alive)
-
-                a_to_b = ['p1', 'p2', self.pars.beta[lkey][0]]
-                b_to_a = ['p2', 'p1', self.pars.beta[lkey][1]]
-                for lbl_src, lbl_tgt, beta in [a_to_b, b_to_a]:  # Transmission from a --> b
-                    if beta == 0:
-                        continue
-
-                    a, b = contacts[lbl_src], contacts[lbl_tgt]
-                    nzi = (rel_trans[a]>0) & (rel_sus[b]>0) & (contacts.beta>0)
-                    avec.append(a[nzi])
-                    bvec.append(b[nzi])
-
-                    if 'acts' in contacts.keys():
-                        beta_per_dt = 1 - (1 - beta) ** (contacts.acts[nzi] * people.dt)
-                    else:
-                        beta_per_dt = beta * people.dt
-                    new_pvec = rel_trans[a[nzi]].__array__() * rel_sus[b[nzi]].__array__() * contacts.beta[nzi] * beta_per_dt
-                    pvec.append(new_pvec)
-
-        df = pd.DataFrame({'p1': np.concatenate(avec), 'p2': np.concatenate(bvec), 'p': np.concatenate(pvec)})
+        for nkey, net in sim.networks.items():
+            if not len(net):
+                break
+            nbetas = betamap[nkey]
+            contacts = net.contacts
+            rel_trans = self.rel_trans * (self.infectious & people.alive)
+            rel_sus = self.rel_sus * (self.susceptible & people.alive)
+
+            p1p2 = ['p1', 'p2', nbetas[0]]
+            p2p1 = ['p2', 'p1', nbetas[1]]
+            for source, target, beta in [p1p2, p2p1]:  # Transmission from a --> b
+                if beta == 0:
+                    continue
+
+                a, b, beta_arr = contacts[source], contacts[target], contacts.beta
+                nzi = (rel_trans[a] > 0) & (rel_sus[b] > 0) & (beta_arr > 0)
+                avec.append(a[nzi])
+                bvec.append(b[nzi])
+
+                # TODO: move this to STI?
+                if 'acts' in contacts.keys():
+                    beta_per_dt = 1 - (1 - beta) ** (contacts.acts[nzi] * people.dt)
+                else:
+                    beta_per_dt = beta * people.dt
+
+                trans_arr = rel_trans[a[nzi]].__array__()
+                sus_arr = rel_sus[b[nzi]].__array__()
+                new_pvec = trans_arr * sus_arr * beta_arr[nzi] * beta_per_dt
+                pvec.append(new_pvec)
+
+        if len(avec):
+            dfp1 = np.concatenate(avec)
+            dfp2 = np.concatenate(bvec)
+            dfp = np.concatenate(pvec)
+        else:
+            return np.empty((0,), dtype=int), np.empty((0,), dtype=int)
+
+        df = pd.DataFrame({'p1': dfp1, 'p2': dfp2, 'p': dfp})
         if len(df) == 0:
-            return [], []
+            return np.empty((0,), dtype=int), np.empty((0,), dtype=int)
 
-        p_acq_node = df.groupby('p2').apply(lambda x: 1 - np.prod(1 - x['p']))
-        uids = p_acq_node.index.values
+        p_acq_node = df.groupby('p2').apply(lambda x: 1 - np.prod(1 - x['p']))  # prob(inf) for each potential infectee
+        uids = p_acq_node.index.values  # UIDs of those who get come into contact with 1 or more infected person
 
         # Slotted draw, need to find a long-term place for this logic
-        slots = people.slot[uids]
-        new_cases_bool = sps.uniform.rvs(size=np.max(slots) + 1)[slots] < p_acq_node.values
+        slots = people.slot[uids]  # Slots for the possible infectee
+        new_cases_bool = ss.uniform.rvs(size=np.max(slots) + 1)[slots] < p_acq_node.values
         new_cases = uids[new_cases_bool]
 
         # Now choose infection source for new cases
@@ -399,43 +351,142 @@ def choose_source(df):
                 src_idx = 0
             else:
                 # Roulette selection using slotted draw r associated with this new case
-                cumsum = df['p'] / df['p'].sum()
+                cumsum = df['p'].cumsum() / df['p'].sum()
                 src_idx = np.argmax(cumsum >= df['r'])
             return df['p1'].iloc[src_idx]
 
-        df['r'] = sps.uniform.rvs(size=np.max(slots) + 1)[slots]
+        df['r'] = ss.uniform.rvs(size=np.max(slots) + 1)[slots[df.p2.values]]  # Draws for each potential infectee
         sources = df.set_index('p2').loc[new_cases].groupby('p2').apply(choose_source)
 
         return new_cases, sources[new_cases].values
 
     def make_new_cases(self, sim):
         """ Add new cases of module, through transmission, incidence, etc. """
+        if not sim.networks:
+            warnmsg = f'Disease {self.name} does not transmit without a network.'
+            if sim.ti == 0: ss.warn(warnmsg, die=False)
+            return
+
         if not ss.options.multirng:
             # Determine new cases for singlerng
-            new_cases, sources = self._make_new_cases_singlerng(sim.people)
+            new_cases, sources = self._make_new_cases_singlerng(sim)
         else:
             # Determine new cases for multirng
-            new_cases, sources = self._make_new_cases_multirng(sim.people)
+            new_cases, sources = self._make_new_cases_multirng(sim)
 
         if len(new_cases):
             self._set_cases(sim, new_cases, sources)
 
+    def _set_cases(self, sim, target_uids, source_uids=None):
+        self.set_prognoses(sim, target_uids, source_uids=source_uids)
+        return
+
+    def update_results(self, sim):
+        super().update_results(sim)
+        self.results['prevalence'][sim.ti] = self.results.n_infected[sim.ti] / np.count_nonzero(sim.people.alive)
+        self.results['new_infections'][sim.ti] = np.count_nonzero(self.ti_infected == sim.ti)
+        self.results['cum_infections'][sim.ti] = np.sum(self.results['new_infections'][:sim.ti])
+
+
+class STI(Infection):
+    """
+    Base class for STIs used in STIsim
+
+    This class contains specializations for STI transmission (i.e., implements network-based
+    transmission with directional beta values) and defines attributes that STIsim connectors
+    operate on to capture co-infection
+    """
+
     def _set_cases(self, sim, target_uids, source_uids=None):
         congenital = sim.people.age[target_uids] <= sim.dt
-        if len(ss.true(congenital))>0:
+        if len(ss.true(congenital)) > 0:
             src_c = source_uids[congenital] if source_uids is not None else None
             self.set_congenital(sim, target_uids[congenital], src_c)
         src_p = source_uids[~congenital] if source_uids is not None else None
         self.set_prognoses(sim, target_uids[~congenital], src_p)
         return
 
-    def set_prognoses(self, sim, target_uids, source_uids=None):
-        pass
-
     def set_congenital(self, sim, target_uids, source_uids=None):
         pass
 
-    def update_results(self, sim):
-        super().update_results(sim)
-        self.results['prevalence'][sim.ti] = self.results.n_infected[sim.ti] / np.count_nonzero(sim.people.alive)
-        self.results['new_infections'][sim.ti] = np.count_nonzero(self.ti_infected == sim.ti)
+
+class InfectionLog(nx.MultiDiGraph):
+    """
+    Record infections
+
+    The infection log records transmission events and optionally other data
+    associated with each transmission. Basic functionality is to track
+    transmission with
+
+    >>> Disease.log.append(source, target, t)
+
+    Seed infections can be recorded with a source of `None`, although all infections
+    should have a target and a time. Other data can be captured in the log, either at
+    the time of creation, or later on. For example
+
+    >>> Disease.log.append(source, target, t, network='msm')
+
+    could be used by a module to track the network in which transmission took place.
+    Modules can optionally add per-infection outcomes later as well, for example
+
+    >>> Disease.log.add_data(source, t_dead=2024.25)
+
+    This would be equivalent to having specified the data at the original time the log
+    entry was created - however, it is more useful for tracking events that may or may
+    not occur after the infection and could be modified by interventions (e.g., tracking
+    diagnosis, treatment, notification etc.)
+
+    A table of outcomes can be returned using `InfectionLog.line_list()`
+    """
+
+    # Add entries
+    # Add items to the most recent infection for an agent
+
+    def add_data(self, uids, **kwargs):
+        """
+        Record extra infection data
+
+        This method can be used to add data to an existing transmission event.
+        The most recent transmission event will be used
+
+        :param uid: The UID of the target node (the agent that was infected)
+        :param kwargs: Remaining arguments are stored as edge data
+        """
+        for uid in sc.promotetoarray(uids):
+            source, target, key = max(self.in_edges(uid, keys=True),
+                                      key=itemgetter(2, 0))  # itemgetter twice as fast as lambda apparently
+            self[source][target][key].update(**kwargs)
+
+    def append(self, source, target, t, **kwargs):
+        self.add_edge(source, target, key=t, **kwargs)
+
+    @property
+    def line_list(self):
+        """
+        Return a tabular representation of the log
+
+        This function returns a dataframe containing columns for all quantities
+        recorded in the log. Note that the log will contain `NaN` for quantities
+        that are defined for some edges and not others (and which are missing for
+        a particular entry)
+        """
+        if len(self) == 0:
+            return sc.dataframe(columns=['t', 'source', 'target'])
+
+        entries = []
+        for source, target, t, data in self.edges(keys=True, data=True):
+            d = data.copy()
+            d.update(source=source, target=target, t=t)
+            entries.append(d)
+        df = sc.dataframe.from_records(entries)
+        df = df.sort_values(['t', 'source', 'target'])
+        df = df.reset_index(drop=True)
+
+        # Use Pandas "Int64" type to allow nullable integers. This allows the 'source' column
+        # to have an integer type corresponding to UIDs while simultaneously supporting the use
+        # of null values to represent exogenous/seed infections
+        df = df.fillna(pd.NA)
+        df['source'] = df['source'].astype("Int64")
+        df['target'] = df['target'].astype("Int64")
+
+        return df
diff --git a/starsim/diseases/__init__.py b/starsim/diseases/__init__.py
index 4f414124..8c0bf910 100644
--- a/starsim/diseases/__init__.py
+++ b/starsim/diseases/__init__.py
@@ -1,5 +1,10 @@
-from .disease       import *
-from .hiv           import *
+from .sir           import *
+from .ncd           import *
+
+# Specific diseases listed alphabetically
+from .cholera       import *
+from .ebola         import *
 from .gonorrhea     import *
-from .examples      import *
+from .hiv           import *
+from .measles       import *
 from .syphilis      import *
diff --git a/starsim/diseases/cholera.py b/starsim/diseases/cholera.py
new file mode 100644
index 00000000..0cb1ff9a
--- /dev/null
+++ b/starsim/diseases/cholera.py
@@ -0,0 +1,199 @@
+"""
+Define cholera model.
+Adapted from https://github.com/optimamodel/gavi-outbreaks/blob/main/stisim/gavi/cholera.py
+Original version by Dom Delport 2024
+"""
+
+import numpy as np
+import starsim as ss
+
+__all__ = ['Cholera']
+
+
+class Cholera(ss.Infection):
+    """
+    Cholera
+    """
+    def __init__(self, pars=None, par_dists=None, *args, **kwargs):
+        """ Initialize with parameters """
+
+        pars = ss.omergeleft(pars,
+            # Natural history parameters, all specified in days
+            dur_exp2inf = ss.lognorm_mean(mean=2.772, stdev=4.737),  # Calculated from Azman et al. estimates https://www.ncbi.nlm.nih.gov/pmc/articles/PMC3677557/
+            dur_asymp2rec = ss.uniform(loc=1, scale=10),    # From WHO cholera fact sheet, asymptomatic individuals shed bacteria for 1-10 days (https://www.who.int/news-room/fact-sheets/detail/cholera)
+            dur_symp2rec = ss.lognorm_mean(mean=5, stdev=1.8),    # According to Fung most modelling studies assume 5 days of symptoms (https://www.ncbi.nlm.nih.gov/pmc/articles/PMC3926264/), but found a range of 2.9-14 days. Distribution approximately fit to these values
+            dur_symp2dead = ss.lognorm_mean(mean=1, stdev=0.5),   # There does not appear to be differences in timing/duration of mild vs severe disease, but death from severe disease happens rapidly https://www.ncbi.nlm.nih.gov/pmc/articles/PMC5767916/
+            p_death = 0.005,   # Probability of death is typically less than 1% when treated
+            p_symp = 0.5,   # Proportion of infected which are symptomatic, mid range of ~25% and 57% estimates from Jaclson et al (https://www.ncbi.nlm.nih.gov/pmc/articles/PMC3795095/) and Nelson et al (https://www.ncbi.nlm.nih.gov/pmc/articles/PMC3842031/), respectively
+            asymp_trans = 0.01,    # Reduction in transmission probability for asymptomatic infection, asymptomatic carriers shed 100-1000 times less bacteria than symptomatic carriers (https://www.ncbi.nlm.nih.gov/pmc/articles/PMC3084143/ and https://www.ncbi.nlm.nih.gov/pmc/articles/PMC3842031/). Previous models assume a 10% relative transmissibility (https://www.ncbi.nlm.nih.gov/pmc/articles/PMC4238032/)
+
+            # Initial conditions and beta
+            init_prev = 0.005,
+            beta = None,
+
+            # Environmental parameters
+            beta_env = 0.5 / 3,  # Scaling factor for transmission from environment,
+            half_sat_rate = 1000000,   # Infectious dose in water sufficient to produce infection in 50% of  exposed, from Mukandavire et al. (https://www.ncbi.nlm.nih.gov/pmc/articles/PMC3102413/)
+            shedding_rate = 10,    # Rate at which infectious people shed bacteria to the environment (per day), from Mukandavire et al. (https://www.ncbi.nlm.nih.gov/pmc/articles/PMC3102413/)
+            decay_rate = 0.033,    # Rate at which bacteria in the environment dies (per day), from Chao et al. and Mukandavire et al. citing https://pubmed.ncbi.nlm.nih.gov/8882180/
+            p_env_transmit = 0,    # Probability of environmental transmission - filled out later
+        )
+
+        par_dists = ss.omergeleft(par_dists,
+            dur_exp2inf    = ss.lognorm,
+            dur_asymp2rec  = ss.uniform,
+            dur_symp2rec   = ss.lognorm,
+            dur_symp2dead  = ss.lognorm,
+            init_prev      = ss.bernoulli,
+            p_death        = ss.bernoulli,
+            p_symp         = ss.bernoulli,
+            p_env_transmit = ss.bernoulli,
+        )
+
+        super().__init__(pars=pars, par_dists=par_dists, *args, **kwargs)
+
+        # Boolean states
+        
+        self.add_states(
+            # Susceptible & infected are added automatically, here we add the rest
+            ss.State('exposed', bool, False),
+            ss.State('symptomatic', bool, False),
+            ss.State('recovered', bool, False),
+    
+            # Timepoint states
+            ss.State('ti_exposed', float, np.nan),
+            ss.State('ti_symptomatic', float, np.nan),
+            ss.State('ti_recovered', float, np.nan),
+            ss.State('ti_dead', float, np.nan),
+        )
+
+        return
+
+    @property
+    def infectious(self):
+        return self.infected | self.exposed
+
+    @property
+    def asymptomatic(self):
+        return self.infected & ~self.symptomatic
+
+    def init_results(self, sim):
+        """
+        Initialize results
+        """
+        super().init_results(sim)
+        self.results += [
+            ss.Result(self.name, 'new_deaths', sim.npts, dtype=int),
+            ss.Result(self.name, 'cum_deaths', sim.npts, dtype=int),
+            ss.Result(self.name, 'env_prev', sim.npts, dtype=float),
+            ss.Result(self.name, 'env_conc', sim.npts, dtype=float),
+        ]
+        return
+
+    def update_pre(self, sim):
+        """
+        Adapted from https://github.com/optimamodel/gavi-outbreaks/blob/main/stisim/gavi/cholera.py
+        Original version by Dom Delport
+        """
+
+        # Progress exposed -> infected
+        infected = ss.true(self.exposed & (self.ti_infected <= sim.ti))
+        self.infected[infected] = True
+
+        # Progress infected -> symptomatic
+        symptomatic = ss.true(self.infected & (self.ti_symptomatic <= sim.ti))
+        self.symptomatic[symptomatic] = True
+
+        # Progress symptomatic -> recovered
+        recovered = ss.true(self.infectious & (self.ti_recovered <= sim.ti))
+        self.infected[recovered] = False
+        self.infectious[recovered] = False
+        self.symptomatic[recovered] = False
+        self.recovered[recovered] = True
+
+        # Trigger deaths
+        deaths = ss.true(self.ti_dead <= sim.year)
+        if len(deaths):
+            sim.people.request_death(deaths)
+
+        # Update today's environmental prevalence
+        self.calc_environmental_prev(sim)
+
+    def calc_environmental_prev(self, sim):
+        """
+        Calculate environmental prevalence
+        """
+        p = self.pars
+        r = self.results
+
+        n_symptomatic = len(ss.true(self.symptomatic))
+        n_asymptomatic = len(ss.true(self.asymptomatic))
+        old_prev = self.results.env_prev[sim.ti-1]
+
+        new_bacteria = p.shedding_rate * (n_symptomatic + p.asymp_trans * n_asymptomatic)
+        old_bacteria = old_prev * (1 - p.decay_rate)
+
+        r.env_prev[sim.ti] = new_bacteria + old_bacteria
+        r.env_conc[sim.ti] = r.env_prev[sim.ti] / (r.env_prev[sim.ti] + p.half_sat_rate)
+
+    def set_prognoses(self, sim, uids, source_uids=None):
+        """ Set prognoses for those who get infected """
+        super().set_prognoses(sim, uids, source_uids)
+
+        self.susceptible[uids] = False
+        self.exposed[uids] = True
+        self.ti_exposed[uids] = sim.ti
+
+        p = self.pars
+
+        # Determine when exposed become infected
+        self.ti_infected[uids] = sim.ti + p.dur_exp2inf.rvs(uids)
+
+        # Determine who becomes symptomatic and when
+        symp_uids = p.p_symp.filter(uids)
+        self.ti_symptomatic[symp_uids] = sim.ti + p.dur_exp2inf.rvs(symp_uids)
+
+        # Determine who dies and when
+        dead_uids = p.p_symp.filter(symp_uids)
+        self.ti_dead[dead_uids] = self.ti_symptomatic[dead_uids] + p.dur_symp2dead.rvs(dead_uids)
+        symp_rev_uids = np.setdiff1d(symp_uids, dead_uids)
+        asymp_uids = np.setdiff1d(uids, symp_uids)
+
+        # Determine when agents recover
+        self.ti_recovered[symp_rev_uids] = self.ti_exposed[symp_rev_uids] + p.dur_symp2rec.rvs(symp_rev_uids)
+        self.ti_recovered[asymp_uids] = self.ti_exposed[asymp_uids] + p.dur_asymp2rec.rvs(asymp_uids)
+
+        return
+
+    def make_new_cases(self, sim):
+        """ Add indirect transmission """
+
+        p = self.pars
+        r = self.results
+
+        # Make new cases via direct transmission
+        super().make_new_cases(sim)
+
+        # Make new cases via indirect transmission
+        p_transmit = r.env_conc[sim.ti] * p.beta_env
+        p.p_env_transmit.kwds['p'] = p_transmit
+        new_cases = p.p_env_transmit.filter(self.susceptible)
+        if new_cases.any():
+            self.set_prognoses(sim, new_cases, source_uids=None)
+        return
+
+    def update_death(self, sim, uids):
+        """ Reset infected/recovered flags for dead agents """
+        for state in ['susceptible', 'exposed', 'infected', 'symptomatic', 'recovered']:
+            self.statesdict[state][uids] = False
+        return
+
+    def update_results(self, sim):
+        super().update_results(sim)
+        res = self.results
+        ti = sim.ti
+        res.prevalence[ti]     = res.n_infected[ti] / np.count_nonzero(sim.people.alive)
+        res.new_infections[ti] = np.count_nonzero(self.ti_infected == ti)
+        res.cum_infections[ti] = np.sum(res.new_infections[:ti+1])
+        res.cum_deaths[ti]     = np.sum(res.new_deaths[:ti+1])
+        return
diff --git a/starsim/diseases/ebola.py b/starsim/diseases/ebola.py
new file mode 100644
index 00000000..f6ced540
--- /dev/null
+++ b/starsim/diseases/ebola.py
@@ -0,0 +1,155 @@
+"""
+Define Ebola model.
+Adapted from https://github.com/optimamodel/gavi-outbreaks/blob/main/stisim/gavi/ebola.py
+Original version by@domdelport and @RomeshA
+"""
+
+import numpy as np
+import starsim as ss
+from starsim.diseases.sir import SIR
+
+__all__ = ['Ebola']
+
+
+class Ebola(SIR):
+
+    def __init__(self, pars=None, par_dists=None, *args, **kwargs):
+        """ Initialize with parameters """
+
+        pars = ss.omergeleft(pars,
+            # Natural history parameters, all specified in days
+            dur_exp2symp = 12.7, # Add source
+            dur_symp2sev =    6, # Add source
+            dur_sev2dead =  1.5, # Add source
+            dur_dead2buried = 2, # Add source
+            dur_symp2rec =   10, # Add source
+            dur_sev2rec =  10.4, # Add source
+            p_sev =         0.7, # Add source
+            p_death =      0.55, # Add source
+            p_safe_bury =  0.25, # Probability of a safe burial - should be linked to diagnoses
+
+            # Initial conditions and beta
+            init_prev = 0.005,
+            beta = None,
+            sev_factor = 2.2,
+            unburied_factor = 2.1,
+        )
+
+        par_dists = ss.omergeleft(par_dists,
+            dur_exp2symp    = ss.lognorm,
+            dur_symp2sev    = ss.lognorm,
+            dur_sev2dead    = ss.lognorm,
+            dur_dead2buried = ss.lognorm,
+            dur_symp2rec    = ss.lognorm,
+            dur_sev2rec     = ss.lognorm,
+            p_sev           = ss.bernoulli,
+            p_death         = ss.bernoulli,
+            p_safe_bury     = ss.bernoulli,
+            init_prev       = ss.bernoulli,
+        )
+
+        super().__init__(pars=pars, par_dists=par_dists, *args, **kwargs)
+
+        # Boolean states
+        
+        self.add_states(
+            # SIR are added automatically, here we add E
+            ss.State('exposed', bool, False),
+            ss.State('severe', bool, False),
+            ss.State('recovered', bool, False),
+            ss.State('buried', bool, False),
+    
+            # Timepoint states
+            ss.State('ti_exposed', float, np.nan),
+            ss.State('ti_severe', float, np.nan),
+            ss.State('ti_recovered', float, np.nan),
+            ss.State('ti_dead', float, np.nan),
+            ss.State('ti_buried', float, np.nan),
+        )
+
+        return
+
+    @property
+    def infectious(self):
+        return self.infected | self.exposed
+
+    def update_pre(self, sim):
+
+        # Progress exposed -> infected
+        infected = ss.true(self.exposed & (self.ti_infected <= sim.ti))
+        self.exposed[infected] = False
+        self.infected[infected] = True
+
+        # Progress infectious -> severe
+        severe = ss.true(self.infected & (self.ti_severe <= sim.ti))
+        self.severe[severe] = True
+
+        # Progress infected -> recovered
+        recovered = ss.true(self.infected & (self.ti_recovered <= sim.ti))
+        self.infected[recovered] = False
+        self.recovered[recovered] = True
+
+        # Progress severe -> recovered
+        recovered_sev = ss.true(self.severe & (self.ti_recovered <= sim.ti))
+        self.severe[recovered_sev] = False
+        self.recovered[recovered_sev] = True
+
+        # Trigger deaths
+        deaths = ss.true(self.ti_dead <= sim.year)
+        if len(deaths):
+            sim.people.request_death(deaths)
+
+        # Progress dead -> buried
+        buried = ss.true(self.ti_buried <= sim.ti)
+        self.buried[buried] = True
+        
+        return
+
+    def set_prognoses(self, sim, uids, source_uids=None):
+        """ Set prognoses for those who get infected """
+        super().set_prognoses(sim, uids, source_uids)
+
+        self.susceptible[uids] = False
+        self.exposed[uids] = True
+        self.ti_exposed[uids] = sim.ti
+
+        p = self.pars
+
+        # Determine when exposed become infected
+        self.ti_infected[uids] = sim.ti + p.dur_exp2symp.rvs(uids)
+
+        # Determine who progresses to sever and when
+        sev_uids = p.p_sev.filter(uids)
+        self.ti_severe[sev_uids] = self.ti_infected[sev_uids] + p.dur_symp2sev.rvs(sev_uids)
+
+        # Determine who dies and who recovers and when
+        dead_uids = p.p_death.filter(sev_uids)
+        self.ti_dead[dead_uids] = self.ti_severe[dead_uids] + p.dur_sev2dead.rvs(dead_uids)
+        rec_sev_uids = np.setdiff1d(sev_uids, dead_uids)
+        self.ti_recovered[rec_sev_uids] = self.ti_severe[rec_sev_uids] + p.dur_sev2rec.rvs(rec_sev_uids)
+        rec_symp_uids = np.setdiff1d(uids, sev_uids)
+        self.ti_recovered[rec_symp_uids] = self.ti_infected[rec_symp_uids] + p.dur_symp2rec.rvs(rec_symp_uids)
+
+        # Determine time of burial - either immediate (safe burials) or after a delay (unsafe)
+        safe_buried = p.p_safe_bury.filter(dead_uids)
+        unsafe_buried = np.setdiff1d(dead_uids, safe_buried)
+        self.ti_buried[safe_buried] = self.ti_dead[safe_buried]
+        self.ti_buried[unsafe_buried] = self.ti_dead[unsafe_buried] + p.dur_dead2buried.rvs(unsafe_buried)
+
+        return
+
+    def update_death(self, sim, uids):
+        # Reset infected/recovered flags for dead agents
+        for state in ['susceptible', 'exposed', 'infected', 'severe', 'recovered']:
+            self.statesdict[state][uids] = False
+        return
+
+    def make_new_cases(self, sim):
+        self.rel_trans[self.infectious] = 1
+        self.rel_trans[self.severe] = self.pars['sev_factor']
+
+        # Unburied UIDs
+        unburied_uids = ss.true((self.ti_dead <= sim.ti) & (self.ti_buried > sim.ti))
+        self.rel_trans[unburied_uids] = self.pars['unburied_factor']
+        super().make_new_cases(sim)
+        return
diff --git a/starsim/diseases/examples.py b/starsim/diseases/examples.py
deleted file mode 100644
index 39b2f996..00000000
--- a/starsim/diseases/examples.py
+++ /dev/null
@@ -1,203 +0,0 @@
-"""
-Define example disease modules
-"""
-
-import numpy as np
-import starsim as ss
-import sciris as sc
-from .disease import Disease
-import scipy.stats as sps
-
-class SIR(Disease):
-    """
-    Example SIR model
-
-    This class implements a basic SIR model with states for susceptible,
-    infected/infectious, and recovered. It also includes deaths, and basic
-    results.
-
-    Note that this class is not fully compatible with common random numbers.
-    """
-
-    def __init__(self, pars=None, *args, **kwargs):
-        default_pars = {
-            #'dur_inf': sps.weibull_min(c=lambda self, sim, uids: sim.people.age[uids], scale=10),#, seed='Duration of SIR Infection'),
-            #'dur_inf': sps.norm(loc=lambda self, sim, uids: sim.people.age[uids], scale=2),
-            'dur_inf': sps.lognorm(s=1, loc=10),
-            'seed_infections': sps.bernoulli(p=0.1),
-            'death_given_infection': sps.bernoulli(p=0.2),
-            'beta': None,
-        }
-
-        super().__init__(pars=ss.omerge(default_pars, pars), *args, **kwargs)
-
-        self.susceptible = ss.State('susceptible', bool, True)
-        self.infected = ss.State('infected', bool, False)
-        self.recovered = ss.State('recovered', bool, False)
-        self.t_infected = ss.State('t_infected', float, np.nan)
-        self.t_recovered = ss.State('t_recovered', float, np.nan)
-        self.t_dead = ss.State('t_dead', float, np.nan)
-
-        return
-
-    def init_results(self, sim):
-        """
-        Initialize results
-        """
-        super().init_results(sim)
-        self.results += ss.Result(self.name, 'prevalence', sim.npts, dtype=float)
-        self.results += ss.Result(self.name, 'new_infections', sim.npts, dtype=int)
-        return
-
-    def update_pre(self, sim):
-        # Progress infectious -> recovered
-        recovered = ss.true(self.infected & (self.t_recovered <= sim.year))
-        self.infected[recovered] = False
-        self.recovered[recovered] = True
-
-        # Trigger deaths
-        deaths = ss.true(self.t_dead <= sim.year)
-        if len(deaths):
-            sim.people.request_death(deaths)
-        return len(deaths)
-
-    def update_death(self, sim, uids):
-        # Reset infected/recovered flags for dead agents
-        # This is an optional step. Implementing this function means that in `SIR.update_results()` the prevalence
-        # calculation does not need to filter the infected agents by the alive agents. An alternative would be
-        # to omit implementing this function, and instead filter by the alive agents when calculating prevalence
-        super().update_death(sim, uids)
-        self.infected[uids] = False
-        self.recovered[uids] = False
-        return
-
-    def validate_pars(self, sim):
-        if self.pars.beta is None:
-            self.pars.beta = sc.objdict({k: 1 for k in sim.people.networks})
-        return
-
-    def set_initial_states(self, sim):
-        """
-        Set initial values for states. This could involve passing in a full set of initial conditions,
-        or using init_prev, or other. Note that this is different to initialization of the State objects
-        i.e., creating their dynamic array, linking them to a People instance. That should have already
-        taken place by the time this method is called.
-        """
-        alive_uids = ss.true(sim.people.alive)
-        initial_cases = self.pars['seed_infections'].filter(alive_uids)
-        self.infect(sim, initial_cases, None)
-        return
-
-    def infect(self, sim, uids, from_uids):
-        super().set_prognoses(sim, uids, from_uids)
-
-        # Carry out state changes associated with infection
-        self.susceptible[uids] = False
-        self.infected[uids] = True
-        self.t_infected[uids] = sim.year
-
-        # Calculate and schedule future outcomes for recovery/death
-        dur_inf = self.pars['dur_inf'].rvs(uids)
-        will_die = self.pars['death_given_infection'].rvs(uids)
-        self.t_recovered[uids[~will_die]] = sim.year + dur_inf[~will_die]
-        self.t_dead[uids[will_die]] = sim.year + dur_inf[will_die]
-
-        # Update result count of new infections - important to use += because
-        # infect() may be called multiple times per timestep
-        self.results['new_infections'][sim.ti] += len(uids)
-        return
-
-    def make_new_cases(self, sim): # TODO: Use function from STI
-        for k, layer in sim.people.networks.items():
-            if k in self.pars['beta']:
-                rel_trans = (self.infected & sim.people.alive).astype(float)
-                rel_sus = (self.susceptible & sim.people.alive).astype(float)
-                for a, b, beta in [[layer.contacts['p1'], layer.contacts['p2'], self.pars['beta'][k]],
-                                   [layer.contacts['p2'], layer.contacts['p1'], self.pars['beta'][k]]]:
-                    # probability of a->b transmission
-                    p_transmit = rel_trans[a] * rel_sus[b] * layer.contacts['beta'] * beta * sim.dt
-                    new_cases = np.random.random(len(a)) < p_transmit # As this class is not common-random-number safe anyway, calling np.random is perfectly fine!
-                    if new_cases.any():
-                        self.infect(sim, b[new_cases], a[new_cases])
-        return
-
-    def update_results(self, sim):
-        super().update_results(sim)
-        self.results['prevalence'][sim.ti] = self.results.n_infected[sim.ti] / np.count_nonzero(sim.people.alive)
-        return
-
-
-class NCD(Disease):
-    """
-    Example non-communicable disease
-
-    This class implements a basic NCD model with risk of developing a condition
-    (e.g., hypertension, diabetes), a state for having the condition, and associated
-    mortality.
-    """
-    def __init__(self, pars=None):
-        default_pars = {
-            'initial_risk': sps.bernoulli(p=0.3), # Initial prevalence of risk factors
-            #'affection_rate': ss.rate(p=0.1), # Instantaneous rate of acquisition applied to those at risk (units are acquisitions / year)
-            'dur_risk': sps.expon(scale=10),
-            'prognosis': sps.weibull_min(c=2, scale=5), # Time in years between first becoming affected and death
-        }
-
-        super().__init__(ss.omerge(default_pars, pars))
-        self.at_risk      = ss.State('at_risk', bool, False)
-        self.affected     = ss.State('affected', bool, False)
-        self.ti_affected  = ss.State('ti_affected', int, ss.INT_NAN)
-        self.ti_dead      = ss.State('ti_dead', int, ss.INT_NAN)
-        return
-
-    @property
-    def not_at_risk(self):
-        return ~self.at_risk
-
-    def set_initial_states(self, sim):
-        """
-        Set initial values for states. This could involve passing in a full set of initial conditions,
-        or using init_prev, or other. Note that this is different to initialization of the State objects
-        i.e., creating their dynamic array, linking them to a People instance. That should have already
-        taken place by the time this method is called.
-        """
-        alive_uids = ss.true(sim.people.alive)
-        initial_risk = self.pars['initial_risk'].filter(alive_uids)
-        self.at_risk[initial_risk] = True
-        self.ti_affected[initial_risk] = sim.ti + sc.randround(self.pars['dur_risk'].rvs(initial_risk) / sim.dt)
-
-        return initial_risk
-
-    def update_pre(self, sim):
-        deaths = ss.true(self.ti_dead == sim.ti)
-        sim.people.request_death(deaths)
-        self.log.add_data(deaths, died=True)
-        self.results.new_deaths[sim.ti] = len(deaths) # Log deaths attributable to this module
-        return
-
-    def make_new_cases(self, sim):
-        #atrisk_uids = ss.true(self.at_risk)
-        #new_cases = self.pars['affection_rate'].filter(atrisk_uids)
-        new_cases = ss.true(self.ti_affected == sim.ti)
-        self.affected[new_cases] = True
-        prog_years = self.pars['prognosis'].rvs(new_cases)
-        self.ti_dead[new_cases] = sim.ti + sc.randround(prog_years / sim.dt)
-        super().set_prognoses(sim, new_cases)
-        return new_cases
-
-    def init_results(self, sim):
-        """
-        Initialize results
-        """
-        super().init_results(sim)
-        self.results += ss.Result(self.name, 'n_not_at_risk', sim.npts, dtype=int)
-        self.results += ss.Result(self.name, 'prevalence', sim.npts, dtype=float)
-        self.results += ss.Result(self.name, 'new_deaths', sim.npts, dtype=int)
-        return
-
-    def update_results(self, sim):
-        super().update_results(sim)
-        self.results['n_not_at_risk'][sim.ti] = np.count_nonzero(self.not_at_risk & sim.people.alive)
-        self.results['prevalence'][sim.ti] = np.count_nonzero(self.affected & sim.people.alive)/np.count_nonzero(sim.people.alive)
-        self.results['new_deaths'][sim.ti] = np.count_nonzero(self.ti_dead == sim.ti)
-        return
diff --git a/starsim/diseases/gonorrhea.py b/starsim/diseases/gonorrhea.py
index fa9028ae..ef8e89c8 100644
--- a/starsim/diseases/gonorrhea.py
+++ b/starsim/diseases/gonorrhea.py
@@ -4,34 +4,38 @@
 
 import numpy as np
 import starsim as ss
-from .disease import STI
-import scipy.stats as sps
-
 
 __all__ = ['Gonorrhea']
 
+class Gonorrhea(ss.Infection):
 
-class Gonorrhea(STI):
-
-    def __init__(self, pars=None):
-        super().__init__(pars)
+    def __init__(self, pars=None, par_dists=None, *args, **kwargs):
 
         # States additional to the default disease states (see base class)
-        self.symptomatic = ss.State('symptomatic', bool, False)
-        self.ti_clearance = ss.State('ti_clearance', int, ss.INT_NAN)
-        self.p_symp = ss.State('p_symp', float, 1)
-
-        # Parameters
-        self.pars = ss.omerge({
-            'dur_inf_in_days': sps.lognorm(s=0.6, scale=10),  # median of 10 days (IQR 7–15 days) https://sti.bmj.com/content/96/8/556
-            'p_symp': sps.bernoulli(p=0.5),  # Share of infections that are symptomatic. Placeholder value
-            'p_clear': sps.bernoulli(p=0.2),  # Share of infections that spontaneously clear: https://sti.bmj.com/content/96/8/556
-            'seed_infections': sps.bernoulli(p=0.1),
-        }, self.pars)
-
         # Additional states dependent on parameter values, e.g. self.p_symp?
         # These might be useful for connectors to target, e.g. if HIV reduces p_clear
+        self.add_states(
+            ss.State('symptomatic', bool, False),
+            ss.State('ti_clearance', int, ss.INT_NAN),
+            ss.State('p_symp', float, 1),
+        )
 
+        # Parameters
+        pars = ss.omergeleft(pars,
+            dur_inf_in_days = ss.lognorm(s=0.6, scale=10),  # median of 10 days (IQR 7–15 days) https://sti.bmj.com/content/96/8/556
+            p_symp = 0.5,  # Share of infections that are symptomatic. Placeholder value
+            p_clear = 0.2,  # Share of infections that spontaneously clear: https://sti.bmj.com/content/96/8/556
+            init_prev = 0.1,
+        )
+
+        par_dists = ss.omergeleft(par_dists,
+            dur_inf_in_days = ss.lognorm,
+            p_symp          = ss.bernoulli,
+            p_clear         = ss.bernoulli,
+            init_prev       = ss.bernoulli,
+        )
+
+        super().__init__(pars=pars, par_dists=par_dists, *args, **kwargs)
         return
 
     def init_results(self, sim):
@@ -39,14 +43,14 @@ def init_results(self, sim):
         Initialize results
         """
         super().init_results(sim)
-        self.results += ss.Result(self.name, 'n_symptomatic', sim.npts, dtype=int)
         self.results += ss.Result(self.name, 'new_clearances', sim.npts, dtype=int)
         return
 
     def update_results(self, sim):
+        ti = sim.ti
         super(Gonorrhea, self).update_results(sim)
-        self.results['n_symptomatic'][sim.ti] = np.count_nonzero(self.symptomatic)
-        self.results['new_clearances'][sim.ti] = np.count_nonzero(self.ti_clearance == sim.ti)
+        self.results.n_symptomatic[ti] = self.symptomatic.count()
+        self.results.new_clearances[ti] = np.count_nonzero(self.ti_clearance == ti)
         return
 
     def update_pre(self, sim):
diff --git a/starsim/diseases/hiv.py b/starsim/diseases/hiv.py
index 61e74d8c..37b11f01 100644
--- a/starsim/diseases/hiv.py
+++ b/starsim/diseases/hiv.py
@@ -5,38 +5,46 @@
 import numpy as np
 import sciris as sc
 import starsim as ss
-from .disease import STI
-import scipy.stats as sps
 
 __all__ = ['HIV', 'ART', 'CD4_analyzer']
 
-
-class HIV(STI):
-
-    def __init__(self, pars=None):
-        super().__init__(pars)
-
-        self.on_art      = ss.State('on_art', bool, False)
-        self.ti_art      = ss.State('ti_art', int, ss.INT_NAN)
-        self.cd4         = ss.State('cd4', float, 500)
-        self.ti_dead     = ss.State('ti_dead', int, ss.INT_NAN) # Time of HIV-cause death
-
-        self.death_prob_per_dt = sps.bernoulli(p=self.death_prob)
-
-        self.pars = ss.omerge({
-            'cd4_min': 100,
-            'cd4_max': 500,
-            'cd4_rate': 5,
-            'seed_infections': sps.bernoulli(p=0.05),
-            'eff_condoms': 0.7,
-            'art_efficacy': 0.96,
-        }, self.pars)
+class HIV(ss.Infection):
+
+    def __init__(self, pars=None, par_dists=None, *args, **kwargs):
+
+        # States
+        self.add_states(
+            ss.State('on_art', bool, False),
+            ss.State('ti_art', int, ss.INT_NAN),
+            ss.State('cd4', float, 500),
+            ss.State('ti_dead', int, ss.INT_NAN), # Time of HIV-cause death
+        )
+
+        pars = ss.omergeleft(pars,
+            cd4_min = 100,
+            cd4_max = 500,
+            cd4_rate = 5,
+            init_prev = 0.05,
+            eff_condoms = 0.7,
+            art_efficacy = 0.96,
+            death_prob = 0.05,
+        )
+
+        par_dists = ss.omergeleft(par_dists,
+            init_prev  = ss.bernoulli,
+            death_prob = ss.bernoulli,
+        )
+
+        super().__init__(pars=pars, par_dists=par_dists, *args, **kwargs)
+        self.death_prob_data = sc.dcp(self.pars.death_prob)
+        self.pars.death_prob = self.make_death_prob
 
         return
 
     @staticmethod
-    def death_prob(self, sim, uids):
-        return 0.05 / (self.pars.cd4_min - self.pars.cd4_max)**2 *  (self.cd4[uids] - self.pars.cd4_max)**2
+    def make_death_prob(module, sim, uids):
+        p = module.pars
+        return module.death_prob_data / (p.cd4_min - p.cd4_max)**2 *  (module.cd4[uids] - p.cd4_max)**2
 
     def update_pre(self, sim):
         """ Update CD4 """
@@ -46,7 +54,7 @@ def update_pre(self, sim):
         self.rel_trans[sim.people.alive & self.infected & self.on_art] = 1 - self.pars['art_efficacy']
 
         can_die = ss.true(sim.people.alive & sim.people.hiv.infected)
-        hiv_deaths = self.death_prob_per_dt.filter(can_die)
+        hiv_deaths = self.pars.death_prob.filter(can_die)
         
         sim.people.request_death(hiv_deaths)
         self.ti_dead[hiv_deaths] = sim.ti
@@ -93,7 +101,7 @@ def __init__(self, t: np.array, coverage: np.array, **kwargs):
 
         super().__init__(**kwargs)
 
-        self.prob_art_at_infection = sps.bernoulli(p=lambda self, sim, uids: np.interp(sim.year, self.t, self.coverage))
+        self.prob_art_at_infection = ss.bernoulli(p=lambda self, sim, uids: np.interp(sim.year, self.t, self.coverage))
         return
 
     def initialize(self, sim):
diff --git a/starsim/diseases/measles.py b/starsim/diseases/measles.py
new file mode 100644
index 00000000..164dc8ee
--- /dev/null
+++ b/starsim/diseases/measles.py
@@ -0,0 +1,94 @@
+"""
+Define measles model.
+Adapted from https://github.com/optimamodel/gavi-outbreaks/blob/main/stisim/gavi/measles.py
+Original version by @alina-muellenmeister, @domdelport, and @RomeshA
+"""
+
+import numpy as np
+import starsim as ss
+from starsim.diseases.sir import SIR
+
+__all__ = ['Measles']
+
+
+class Measles(SIR):
+
+    def __init__(self, pars=None, par_dists=None, *args, **kwargs):
+        """ Initialize with parameters """
+
+        pars = ss.omergeleft(pars,
+            # Natural history parameters, all specified in days
+            dur_exp = 8,       # (days) - source: US CDC
+            dur_inf = 11,      # (days) - source: US CDC
+            p_death = 0.005,   # Probability of death
+
+            # Initial conditions and beta
+            init_prev = 0.005,
+            beta = None,
+        )
+
+        par_dists = ss.omergeleft(par_dists,
+            dur_exp   = ss.norm,
+            dur_inf   = ss.norm,
+            init_prev = ss.bernoulli,
+            p_death   = ss.bernoulli,
+        )
+
+        super().__init__(pars=pars, par_dists=par_dists, *args, **kwargs)
+
+        # SIR are added automatically, here we add E
+        self.add_states(
+            ss.State('exposed', bool, False),
+            ss.State('ti_exposed', float, np.nan),
+        )
+
+        return
+
+    @property
+    def infectious(self):
+        return self.infected | self.exposed
+
+    def update_pre(self, sim):
+
+        # Progress exposed -> infected
+        infected = ss.true(self.exposed & (self.ti_infected <= sim.ti))
+        self.exposed[infected] = False
+        self.infected[infected] = True
+
+        # Progress infected -> recovered
+        recovered = ss.true(self.infected & (self.ti_recovered <= sim.ti))
+        self.infected[recovered] = False
+        self.recovered[recovered] = True
+
+        # Trigger deaths
+        deaths = ss.true(self.ti_dead <= sim.year)
+        if len(deaths):
+            sim.people.request_death(deaths)
+
+    def set_prognoses(self, sim, uids, source_uids=None):
+        """ Set prognoses for those who get infected """
+        super().set_prognoses(sim, uids, source_uids)
+
+        self.susceptible[uids] = False
+        self.exposed[uids] = True
+        self.ti_exposed[uids] = sim.ti
+
+        p = self.pars
+
+        # Determine when exposed become infected
+        self.ti_infected[uids] = sim.ti + p.dur_exp.rvs(uids)
+
+        # Determine who dies and who recovers and when
+        dead_uids = p.p_death.filter(uids)
+        self.ti_dead[dead_uids] = self.ti_infected[dead_uids] + p.dur_inf.rvs(dead_uids)
+        rec_uids = np.setdiff1d(uids, dead_uids)
+        self.ti_recovered[rec_uids] = self.ti_infected[rec_uids] + p.dur_inf.rvs(rec_uids)
+
+        return
+
+    def update_death(self, sim, uids):
+        # Reset infected/recovered flags for dead agents
+        for state in ['susceptible', 'exposed', 'infected', 'recovered']:
+            self.statesdict[state][uids] = False
+        return
+
diff --git a/starsim/diseases/ncd.py b/starsim/diseases/ncd.py
new file mode 100644
index 00000000..e05ac976
--- /dev/null
+++ b/starsim/diseases/ncd.py
@@ -0,0 +1,87 @@
+"""
+Define non-communicable disease (NCD) model
+"""
+
+import numpy as np
+import starsim as ss
+import sciris as sc
+
+__all__ = ['NCD']
+
+class NCD(ss.Disease):
+    """
+    Example non-communicable disease
+
+    This class implements a basic NCD model with risk of developing a condition
+    (e.g., hypertension, diabetes), a state for having the condition, and associated
+    mortality.
+    """
+    def __init__(self, pars=None):
+        default_pars = dict(
+            initial_risk = ss.bernoulli(p=0.3), # Initial prevalence of risk factors
+            #'affection_rate': ss.rate(p=0.1), # Instantaneous rate of acquisition applied to those at risk (units are acquisitions / year)
+            dur_risk = ss.expon(scale=10),
+            prognosis = ss.weibull_min(c=2, scale=5), # Time in years between first becoming affected and death
+        )
+
+        super().__init__(ss.omerge(default_pars, pars))
+        self.add_states(
+            ss.State('at_risk', bool, False),
+            ss.State('affected', bool, False),
+            ss.State('ti_affected', int, ss.INT_NAN),
+            ss.State('ti_dead', int, ss.INT_NAN),
+        )
+        return
+
+    @property
+    def not_at_risk(self):
+        return ~self.at_risk
+
+    def set_initial_states(self, sim):
+        """
+        Set initial values for states. This could involve passing in a full set of initial conditions,
+        or using init_prev, or other. Note that this is different to initialization of the State objects
+        i.e., creating their dynamic array, linking them to a People instance. That should have already
+        taken place by the time this method is called.
+        """
+        alive_uids = ss.true(sim.people.alive)
+        initial_risk = self.pars['initial_risk'].filter(alive_uids)
+        self.at_risk[initial_risk] = True
+        self.ti_affected[initial_risk] = sim.ti + sc.randround(self.pars['dur_risk'].rvs(initial_risk) / sim.dt)
+        return initial_risk
+
+    def update_pre(self, sim):
+        deaths = ss.true(self.ti_dead == sim.ti)
+        sim.people.request_death(deaths)
+        self.log.add_data(deaths, died=True)
+        self.results.new_deaths[sim.ti] = len(deaths) # Log deaths attributable to this module
+        return
+
+    def make_new_cases(self, sim):
+        new_cases = ss.true(self.ti_affected == sim.ti)
+        self.affected[new_cases] = True
+        prog_years = self.pars['prognosis'].rvs(new_cases)
+        self.ti_dead[new_cases] = sim.ti + sc.randround(prog_years / sim.dt)
+        super().set_prognoses(sim, new_cases)
+        return new_cases
+
+    def init_results(self, sim):
+        """
+        Initialize results
+        """
+        super().init_results(sim)
+        self.results += [
+            ss.Result(self.name, 'n_not_at_risk', sim.npts, dtype=int),
+            ss.Result(self.name, 'prevalence', sim.npts, dtype=float),
+            ss.Result(self.name, 'new_deaths', sim.npts, dtype=int),
+        ]
+        return
+
+    def update_results(self, sim):
+        super().update_results(sim)
+        ti = sim.ti
+        alive = sim.people.alive
+        self.results.n_not_at_risk[ti] = np.count_nonzero(self.not_at_risk & alive)
+        self.results.prevalence[ti]    = np.count_nonzero(self.affected & alive)/alive.count()
+        self.results.new_deaths[ti]    = np.count_nonzero(self.ti_dead == ti)
+        return
diff --git a/starsim/diseases/sir.py b/starsim/diseases/sir.py
new file mode 100644
index 00000000..78708db4
--- /dev/null
+++ b/starsim/diseases/sir.py
@@ -0,0 +1,107 @@
+"""
+Define example disease modules
+"""
+
+import numpy as np
+import starsim as ss
+
+__all__ = ['SIR']
+
+class SIR(ss.Infection):
+    """
+    Example SIR model
+
+    This class implements a basic SIR model with states for susceptible,
+    infected/infectious, and recovered. It also includes deaths, and basic
+    results.
+    """
+
+    def __init__(self, pars=None, par_dists=None, *args, **kwargs):
+        pars = ss.omergeleft(pars,
+            dur_inf = 1,
+            init_prev = 0.1,
+            p_death = 0.2,
+            beta = None,
+        )
+
+        par_dists = ss.omergeleft(par_dists,
+            dur_inf   = ss.lognorm,
+            init_prev = ss.bernoulli,
+            p_death   = ss.bernoulli,
+        )
+
+        super().__init__(pars=pars, par_dists=par_dists, *args, **kwargs)
+
+        self.add_states(
+            ss.State('susceptible', bool, True),
+            ss.State('infected', bool, False),
+            ss.State('recovered', bool, False),
+            ss.State('ti_infected', float, np.nan),
+            ss.State('ti_recovered', float, np.nan),
+            ss.State('ti_dead', float, np.nan),
+        )
+        return
+
+    def update_pre(self, sim):
+        # Progress infectious -> recovered
+        recovered = ss.true(self.infected & (self.ti_recovered <= sim.year))
+        self.infected[recovered] = False
+        self.recovered[recovered] = True
+
+        # Trigger deaths
+        deaths = ss.true(self.ti_dead <= sim.year)
+        if len(deaths):
+            sim.people.request_death(deaths)
+        return
+
+    def set_prognoses(self, sim, uids, source_uids=None):
+        """ Set prognoses """
+        self.susceptible[uids] = False
+        self.infected[uids] = True
+        self.ti_infected[uids] = sim.ti
+
+        p = self.pars
+
+        # Determine who dies and who recovers and when
+        dead_uids = p.p_death.filter(uids)
+        rec_uids = np.setdiff1d(uids, dead_uids)
+        self.ti_dead[dead_uids] = sim.ti + p.dur_inf.rvs(dead_uids)
+        self.ti_recovered[rec_uids] = sim.ti + p.dur_inf.rvs(rec_uids)
+
+        return
+
+    def update_death(self, sim, uids):
+        # Reset infected/recovered flags for dead agents
+        self.susceptible[uids] = False
+        self.infected[uids] = False
+        self.recovered[uids] = False
+        return
+
+
+# %% Interventions
+__all__ += ['sir_vaccine']
+
+
+class sir_vaccine(ss.vx):
+    """
+    Create a vaccine product that changes susceptible people to recovered (i.e., perfect immunity)
+    """
+    def __init__(self, pars=None, par_dists=None, *args, **kwargs):
+        pars = ss.omerge({
+            'efficacy': 0.9,
+        }, pars)
+
+        par_dists = ss.omerge({
+            'efficacy': ss.bernoulli
+        }, par_dists)
+
+        super().__init__(pars=pars, par_dists=par_dists, *args, **kwargs)
+
+        return
+
+    def administer(self, people, uids):
+        eff_vacc_uids = self.pars.efficacy.filter(uids)
+        people.sir.susceptible[eff_vacc_uids] = False
+        people.sir.recovered[eff_vacc_uids] = True
+        return
+
diff --git a/starsim/diseases/syphilis.py b/starsim/diseases/syphilis.py
index 5aab411e..9715e06c 100644
--- a/starsim/diseases/syphilis.py
+++ b/starsim/diseases/syphilis.py
@@ -4,57 +4,54 @@
 
 import numpy as np
 import sciris as sc
-import pandas as pd
-from sciris import randround as rr
-import scipy.stats as sps
-
+from sciris import randround as rr # Since used frequently
 import starsim as ss
-from .disease import STI
 
 __all__ = ['Syphilis']
 
-
-class Syphilis(STI):
+class Syphilis(ss.STI):
 
     def __init__(self, pars=None):
         super().__init__(pars)
 
-        # Adult syphilis states
-        self.exposed = ss.State('exposed', bool, False)  # AKA incubating. Free of symptoms, not transmissible
-        self.primary = ss.State('primary', bool, False)  # Primary chancres
-        self.secondary = ss.State('secondary', bool, False)  # Inclusive of those who may still have primary chancres
-        self.latent_temp = ss.State('latent_temp', bool, False)  # Relapses to secondary (~1y)
-        self.latent_long = ss.State('latent_long', bool, False)  # Can progress to tertiary or remain here
-        self.tertiary = ss.State('tertiary', bool, False)  # Includes complications (cardio/neuro/disfigurement)
-        self.immune = ss.State('immune', bool, False)  # After effective treatment people may acquire temp immunity
-        self.ever_exposed = ss.State('ever_exposed', bool, False)  # Anyone ever exposed - stays true after treatment
-
-        # Congenital syphilis states
-        self.congenital = ss.State('congenital', bool, False)
-
-        # Timestep of state changes
-        self.ti_exposed = ss.State('ti_exposed', int, ss.INT_NAN)
-        self.ti_primary = ss.State('ti_primary', int, ss.INT_NAN)
-        self.ti_secondary = ss.State('ti_secondary', int, ss.INT_NAN)
-        self.ti_latent_temp = ss.State('ti_latent_temp', int, ss.INT_NAN)
-        self.ti_latent_long = ss.State('ti_latent_long', int, ss.INT_NAN)
-        self.ti_tertiary = ss.State('ti_tertiary', int, ss.INT_NAN)
-        self.ti_immune = ss.State('ti_immune', int, ss.INT_NAN)
-        self.ti_miscarriage = ss.State('ti_miscarriage', int, ss.INT_NAN)
-        self.ti_nnd = ss.State('ti_nnd', int, ss.INT_NAN)
-        self.ti_stillborn = ss.State('ti_stillborn', int, ss.INT_NAN)
-        self.ti_congenital = ss.State('ti_congenital', int, ss.INT_NAN)
+        self.add_states(
+            # Adult syphilis states
+            ss.State('exposed', bool, False),  # AKA incubating. Free of symptoms, not transmissible
+            ss.State('primary', bool, False),  # Primary chancres
+            ss.State('secondary', bool, False),  # Inclusive of those who may still have primary chancres
+            ss.State('latent_temp', bool, False),  # Relapses to secondary (~1y)
+            ss.State('latent_long', bool, False),  # Can progress to tertiary or remain here
+            ss.State('tertiary', bool, False),  # Includes complications (cardio/neuro/disfigurement)
+            ss.State('immune', bool, False),  # After effective treatment people may acquire temp immunity
+            ss.State('ever_exposed', bool, False),  # Anyone ever exposed - stays true after treatment
+    
+            # Congenital syphilis states
+            ss.State('congenital', bool, False),
+    
+            # Timestep of state changes
+            ss.State('ti_exposed', int, ss.INT_NAN),
+            ss.State('ti_primary', int, ss.INT_NAN),
+            ss.State('ti_secondary', int, ss.INT_NAN),
+            ss.State('ti_latent_temp', int, ss.INT_NAN),
+            ss.State('ti_latent_long', int, ss.INT_NAN),
+            ss.State('ti_tertiary', int, ss.INT_NAN),
+            ss.State('ti_immune', int, ss.INT_NAN),
+            ss.State('ti_miscarriage', int, ss.INT_NAN),
+            ss.State('ti_nnd', int, ss.INT_NAN),
+            ss.State('ti_stillborn', int, ss.INT_NAN),
+            ss.State('ti_congenital', int, ss.INT_NAN),
+        )
 
         # Parameters
         default_pars = dict(
             # Adult syphilis natural history, all specified in years
-            dur_exposed=ss.lognorm(mean=1 / 12, stdev=1 / 36),  # https://pubmed.ncbi.nlm.nih.gov/9101629/
-            dur_primary=ss.lognorm(mean=1.5 / 12, stdev=1 / 36),  # https://pubmed.ncbi.nlm.nih.gov/9101629/
-            dur_secondary=sps.norm(loc=3.6 / 12, scale=1.5 / 12),  # https://pubmed.ncbi.nlm.nih.gov/9101629/
-            dur_latent_temp=ss.lognorm(mean=1, stdev=6 / 12),  # https://pubmed.ncbi.nlm.nih.gov/9101629/
-            dur_latent_long=ss.lognorm(mean=20, stdev=8),  # https://pubmed.ncbi.nlm.nih.gov/9101629/
-            p_latent_temp=sps.bernoulli(p=0.25),  # https://pubmed.ncbi.nlm.nih.gov/9101629/
-            p_tertiary=sps.bernoulli(p=0.35),  # https://www.ncbi.nlm.nih.gov/pmc/articles/PMC4917057/
+            dur_exposed = ss.lognorm_mean(mean=1 / 12, stdev=1 / 36),  # https://pubmed.ncbi.nlm.nih.gov/9101629/
+            dur_primary = ss.lognorm_mean(mean=1.5 / 12, stdev=1 / 36),  # https://pubmed.ncbi.nlm.nih.gov/9101629/
+            dur_secondary = ss.norm(loc=3.6 / 12, scale=1.5 / 12),  # https://pubmed.ncbi.nlm.nih.gov/9101629/
+            dur_latent_temp = ss.lognorm_mean(mean=1, stdev=6 / 12),  # https://pubmed.ncbi.nlm.nih.gov/9101629/
+            dur_latent_long = ss.lognorm_mean(mean=20, stdev=8),  # https://pubmed.ncbi.nlm.nih.gov/9101629/
+            p_latent_temp = ss.bernoulli(p=0.25),  # https://pubmed.ncbi.nlm.nih.gov/9101629/
+            p_tertiary = ss.bernoulli(p=0.35),  # https://www.ncbi.nlm.nih.gov/pmc/articles/PMC4917057/
 
             # Congenital syphilis outcomes
             # Birth outcomes coded as:
@@ -64,15 +61,15 @@ def __init__(self, pars=None):
             #   3: Live birth without syphilis-related complications
             # Source: https://www.ncbi.nlm.nih.gov/pmc/articles/PMC5973824/)
             birth_outcomes=sc.objdict(
-                active=sps.rv_discrete(values=([0, 1, 2, 3, 4], [0.125, 0.125, 0.20, 0.35, 0.200])),
-                latent=sps.rv_discrete(values=([0, 1, 2, 3, 4], [0.050, 0.075, 0.10, 0.05, 0.725])),
+                active = ss.rv_discrete(values=([0, 1, 2, 3, 4], [0.125, 0.125, 0.20, 0.35, 0.200])),
+                latent = ss.rv_discrete(values=([0, 1, 2, 3, 4], [0.050, 0.075, 0.10, 0.05, 0.725])),
             ),
             birth_outcome_keys=['miscarriage', 'nnd', 'stillborn', 'congenital'],
 
             # Initial conditions
-            seed_infections=sps.bernoulli(p=0.03),
+            init_prev=ss.bernoulli(p=0.03),
         )
-        self.pars = ss.omerge(default_pars, self.pars)
+        self.pars = ss.omerge(default_pars, self.pars) # NB: regular omerge rather than omergeleft
 
         return
 
@@ -104,9 +101,11 @@ def infectious(self):
     def init_results(self, sim):
         """ Initialize results """
         super().init_results(sim)
-        self.results += ss.Result(self.name, 'new_nnds', sim.npts, dtype=int, scale=True)
-        self.results += ss.Result(self.name, 'new_stillborns', sim.npts, dtype=int, scale=True)
-        self.results += ss.Result(self.name, 'new_congenital', sim.npts, dtype=int, scale=True)
+        self.results += [
+            ss.Result(self.name, 'new_nnds',       sim.npts, dtype=int, scale=True),
+            ss.Result(self.name, 'new_stillborns', sim.npts, dtype=int, scale=True),
+            ss.Result(self.name, 'new_congenital', sim.npts, dtype=int, scale=True),
+        ]
         return
 
     def update_pre(self, sim):
@@ -165,9 +164,10 @@ def update_pre(self, sim):
 
     def update_results(self, sim):
         super(Syphilis, self).update_results(sim)
-        self.results['new_nnds'][sim.ti] = np.count_nonzero(self.ti_nnd == sim.ti)
-        self.results['new_stillborns'][sim.ti] = np.count_nonzero(self.ti_stillborn == sim.ti)
-        self.results['new_congenital'][sim.ti] = np.count_nonzero(self.ti_congenital == sim.ti)
+        ti = sim.ti
+        self.results.new_nnds[ti]       = np.count_nonzero(self.ti_nnd == ti)
+        self.results.new_stillborns[ti] = np.count_nonzero(self.ti_stillborn == ti)
+        self.results.new_congenital[ti] = np.count_nonzero(self.ti_congenital == ti)
         return
 
     def make_new_cases(self, sim):
@@ -271,7 +271,7 @@ def set_congenital(self, sim, target_uids, source_uids=None):
 # %% Syphilis-related interventions
 datafiles = sc.objdict()
 for key in ['dx', 'tx', 'vx']:
-    datafiles[key] = sc.thispath() / f'../products/syph_{key}.csv'
+    datafiles[key] = sc.thispath() / f'../data/products/syph_{key}.csv' # CK: may want to make this more robust if we keep using it
 
 __all__ += ['syph_dx', 'syph_tx', 'syph_screening', 'syph_treatment']
 
@@ -280,7 +280,7 @@ def syph_dx(prod_name=None):
     """
     Create default diagnostic products
     """
-    dfdx = pd.read_csv(datafiles.dx)
+    dfdx = sc.dataframe.read_csv(datafiles.dx)
     dxprods = dict(
         rpr=ss.dx(dfdx[dfdx.name == 'rpr'], hierarchy=['positive', 'inadequate', 'negative']),
         rst=ss.dx(dfdx[dfdx.name == 'rst'], hierarchy=['positive', 'inadequate', 'negative']),
@@ -295,7 +295,7 @@ def syph_tx(prod_name=None):
     """
     Create default treatment products
     """
-    dftx = pd.read_csv(datafiles.tx)  # Read in dataframe with parameters
+    dftx = sc.dataframe.read_csv(datafiles.tx)  # Read in dataframe with parameters
     txprods = dict()
     for name in dftx.name.unique():
         txprods[name] = ss.tx(dftx[dftx.name == name])
@@ -333,8 +333,10 @@ def check_eligibility(self, sim):
 
     def initialize(self, sim):
         super().initialize(sim)
-        self.results += ss.Result('syphilis', 'n_screened', sim.npts, dtype=int, scale=True)
-        self.results += ss.Result('syphilis', 'n_dx', sim.npts, dtype=int, scale=True)
+        self.results += [
+            ss.Result('syphilis', 'n_screened', sim.npts, dtype=int, scale=True),
+            ss.Result('syphilis', 'n_dx', sim.npts, dtype=int, scale=True),
+        ]
         return
 
 
@@ -353,16 +355,6 @@ def _parse_product_str(self, product):
             raise ValueError(errormsg)
         return product
 
-    def check_eligibility(self, sim):
-        """
-        Return an array of indices of agents eligible for screening at time t
-        """
-        if self.eligibility is not None:
-            is_eligible = self.eligibility(sim)
-        else:
-            is_eligible = sim.people.alive  # Probably not required
-        return is_eligible
-
     def initialize(self, sim):
         super().initialize(sim)
         self.results += ss.Result('syphilis', 'n_tx', sim.npts, dtype=int, scale=True)
@@ -373,3 +365,4 @@ def apply(self, sim):
         sim.people.syphilis.infected[treat_inds] = False
         self.results['n_tx'][sim.ti] += len(treat_inds)
 
+
diff --git a/starsim/distributions.py b/starsim/distributions.py
index d24f5ac9..3eacfbd6 100644
--- a/starsim/distributions.py
+++ b/starsim/distributions.py
@@ -4,15 +4,19 @@
 2. Ability to use MultiRNG for common random number support
 """
 
+from copy import deepcopy
 import numpy as np
-from starsim.utils import INT_NAN
+import starsim as ss
 from starsim.random import SingleRNG, MultiRNG
-from starsim import options, int_
-from copy import deepcopy
-from scipy.stats._discrete_distns import bernoulli_gen
-from scipy.stats import rv_histogram
+from starsim import options
+from scipy.stats import (bernoulli, expon, lognorm, norm, poisson, randint, rv_discrete, 
+                         uniform, rv_histogram, weibull_min)
+from scipy.stats._discrete_distns import bernoulli_gen # TODO: can we remove this?
+
 
 __all__ = ['ScipyDistribution', 'ScipyHistogram']
+__all__ += ['bernoulli', 'expon', 'lognorm', 'norm', 'poisson', 'randint', 'rv_discrete', 
+            'uniform', 'weibull_min'] # Add common distributions so they can be imported directly
 
 
 class ScipyDistribution():
@@ -40,7 +44,7 @@ def rvs(self, *args, **kwargs):
                 self.repeat_slot_handling = {}
                 # Work out how many samples to draw. If sampling by UID, this depends on the slots assigned to agents.
                 if np.isscalar(size):
-                    if not isinstance(size, (int, np.int64, int_)):
+                    if not isinstance(size, int):
                         raise Exception('Input "size" must be an integer')
                     if size < 0:
                         raise Exception('Input "size" cannot be negative')
@@ -52,7 +56,7 @@ def rvs(self, *args, **kwargs):
                     return np.array([], dtype=int)
                 elif size.dtype == bool:
                     n_samples = len(size) if options.multirng else size.sum()
-                elif size.dtype in [int, np.int64, int_]:
+                elif size.dtype in [int, np.int64, np.int32]: # CK: TODO: need to refactor
                     if not options.multirng:
                         n_samples = len(size)
                     else:
@@ -68,7 +72,7 @@ def rvs(self, *args, **kwargs):
                                     raise Exception('The MultiRNG instance must be initialized before use.')
                             raise e
 
-                        if max_slot == INT_NAN:
+                        if max_slot == ss.INT_NAN:
                             raise Exception('Attempted to sample from an INT_NAN slot')
                         n_samples = max_slot + 1
                 else:
@@ -210,7 +214,7 @@ def __getattr__(self, attr):
         except Exception:
             try:
                 return getattr(self.gen, attr) # .dist?
-            except Exception as e:
+            except Exception:
                 errormsg = f'"{attr}" is not a member of this class or the underlying scipy stats class'
                 raise Exception(errormsg)
 
@@ -254,7 +258,7 @@ def rvs(self, *args, **kwargs):
         slots = None
         # Work out how many samples to draw. If sampling by UID, this depends on the slots assigned to agents.
         if np.isscalar(size):
-            if not isinstance(size, (int, np.int64, int_)):
+            if not isinstance(size, int):
                 raise Exception('Input "size" must be an integer')
             if size < 0:
                 raise Exception('Input "size" cannot be negative')
@@ -266,7 +270,7 @@ def rvs(self, *args, **kwargs):
             return np.array([], dtype=int)
         elif size.dtype == bool:
             n_samples = len(size) if options.multirng else size.sum()
-        elif size.dtype in [int, np.int64, int_]:
+        elif size.dtype in [int, np.int64, np.int32]: # CK: TODO -- need to refactor
             if not options.multirng:
                 n_samples = len(size)
             else:
@@ -282,7 +286,7 @@ def rvs(self, *args, **kwargs):
                             raise Exception('The MultiRNG instance must be initialized before use.')
                     raise e
 
-                if max_slot == INT_NAN:
+                if max_slot == ss.INT_NAN:
                     raise Exception('Attempted to sample from an INT_NAN slot')
                 n_samples = max_slot + 1
         else:
diff --git a/starsim/interventions.py b/starsim/interventions.py
index 700dec13..3add8a68 100644
--- a/starsim/interventions.py
+++ b/starsim/interventions.py
@@ -1,46 +1,81 @@
 """
-Define interventions
+Define interventions (and analyzers)
 """
 
 import starsim as ss
 import sciris as sc
 import numpy as np
-import scipy.stats as sps
 
-__all__ = ['Intervention']
+__all__ = ['Analyzer', 'Intervention']
+
+class Analyzer(ss.Module):
+
+    def __call__(self, *args, **kwargs):
+        return self.apply(*args, **kwargs)
+
+    def update_results(self, sim):
+        raise NotImplementedError
 
 
 class Intervention(ss.Module):
 
-    def __init__(self, *args, **kwargs):
+    def __init__(self, eligibility=None, *args, **kwargs):
         super().__init__(*args, **kwargs)
+        self.eligibility = eligibility
         return
 
     def __call__(self, *args, **kwargs):
         return self.apply(*args, **kwargs)
 
+    def _parse_product(self, product):
+        """
+        Parse the product input
+        """
+        if isinstance(product, ss.Product):  # No need to do anything
+            self.product = product
+        elif isinstance(product, str):
+            self.product = self._parse_product_str(product)
+        else:
+            errormsg = f'Cannot understand {product} - please provide it as a Product.'
+            raise ValueError(errormsg)
+        return
+
+    def _parse_product_str(self, product):
+        raise NotImplementedError
+
+    def check_eligibility(self, sim):
+        """
+        Return an array of indices of agents eligible for screening at time t
+        """
+        if self.eligibility is not None:
+            is_eligible = self.eligibility(sim)
+        else:
+            is_eligible = sim.people.alive  # Probably not required
+        return is_eligible
+
     def apply(self, sim, *args, **kwargs):
         raise NotImplementedError
 
     def finalize(self, sim):
         super().finalize(sim)
 
+
 # %% Template classes for routine and campaign delivery
 __all__ += ['RoutineDelivery', 'CampaignDelivery']
 
-
 class RoutineDelivery(Intervention):
     """
     Base class for any intervention that uses routine delivery; handles interpolation of input years.
     """
 
     def __init__(self, years=None, start_year=None, end_year=None, prob=None, annual_prob=True, *args, **kwargs):
+        super().__init__(*args, **kwargs)
         self.years = years
         self.start_year = start_year
         self.end_year = end_year
         self.prob = sc.promotetoarray(prob)
         self.annual_prob = annual_prob  # Determines whether the probability is annual or per timestep
-        self.coverage_dist = sps.bernoulli(p=0)  # Placeholder - initialize delivery
+        self.coverage_dist = ss.bernoulli(p=0)  # Placeholder - initialize delivery
         return
 
     def initialize(self, sim):
@@ -95,6 +130,7 @@ class CampaignDelivery(Intervention):
     """
 
     def __init__(self, years, interpolate=None, prob=None, annual_prob=True, *args, **kwargs):
+        super().__init__(*args, **kwargs)
         self.years = sc.promotetoarray(years)
         self.interpolate = True if interpolate is None else interpolate
         self.prob = sc.promotetoarray(prob)
@@ -141,30 +177,15 @@ class BaseTest(Intervention):
     """
 
     def __init__(self, product=None, prob=None, eligibility=None, **kwargs):
-        Intervention.__init__(self, **kwargs)
+        super().__init__(**kwargs)
         self.prob = sc.promotetoarray(prob)
         self.eligibility = eligibility
         self._parse_product(product)
         self.screened = ss.State('screened', bool, False)
         self.screens = ss.State('screens', int, 0)
         self.ti_screened = ss.State('ti_screened', int, ss.INT_NAN)
-
-    def _parse_product(self, product):
-        """
-        Parse the product input
-        """
-        if isinstance(product, ss.Product):  # No need to do anything
-            self.product = product
-        elif isinstance(product, str):
-            self.product = self._parse_product_str(product)
-        else:
-            errormsg = f'Cannot understand {product} - please provide it as a Product.'
-            raise ValueError(errormsg)
         return
 
-    def _parse_product_str(self, product):
-        raise NotImplementedError
-
     def initialize(self, sim):
         Intervention.initialize(self, sim)
         self.outcomes = {k: np.array([], dtype=int) for k in self.product.hierarchy}
@@ -193,10 +214,6 @@ class BaseScreening(BaseTest):
     Args:
         kwargs (dict): passed to BaseTest
     """
-
-    def __init__(self, **kwargs):
-        BaseTest.__init__(self, **kwargs)  # Initialize the BaseTest object
-
     def check_eligibility(self, sim):
         """
         Check eligibility
@@ -225,10 +242,6 @@ class BaseTriage(BaseTest):
     Args:
         kwargs (dict): passed to BaseTest
     """
-
-    def __init__(self, **kwargs):
-        BaseTest.__init__(self, **kwargs)
-
     def check_eligibility(self, sim):
         return sc.promotetoarray(self.eligibility(sim))
 
@@ -339,38 +352,16 @@ class BaseTreatment(Intervention):
          kwargs         (dict)          : passed to Intervention()
     """
     def __init__(self, product=None, prob=None, eligibility=None, **kwargs):
-        Intervention.__init__(self, **kwargs)
+        super().__init__(**kwargs)
         self.prob = sc.promotetoarray(prob)
         self.eligibility = eligibility
         self._parse_product(product)
-        self.coverage_dist = sps.bernoulli(p=0)  # Placeholder
-
-    def _parse_product(self, product):
-        """
-        Parse the product input
-        """
-        if isinstance(product, ss.Product):  # No need to do anything
-            self.product = product
-        elif isinstance(product, str):  # Try to find it in the list of defaults
-            self.product = self._parse_product_str(product)
-        else:
-            errormsg = f'Cannot understand {product} - please provide it as a Product.'
-            raise ValueError(errormsg)
-        return
-
-    def _parse_product_str(self, product):
-        raise NotImplementedError
+        self.coverage_dist = ss.bernoulli(p=0)  # Placeholder
 
     def initialize(self, sim):
         Intervention.initialize(self, sim)
         self.outcomes = {k: np.array([], dtype=int) for k in ['unsuccessful', 'successful']} # Store outcomes on each timestep
 
-    def check_eligibility(self, sim):
-        """
-        Check people's eligibility for treatment
-        """
-        raise NotImplementedError
-
     def get_accept_inds(self, sim):
         """
         Get indices of people who will acccept treatment; these people are then added to a queue or scheduled for receiving treatment
@@ -409,7 +400,7 @@ class treat_num(BaseTreatment):
          max_capacity (int): maximum number who can be treated each timestep
     """
     def __init__(self, max_capacity=None, **kwargs):
-        BaseTreatment.__init__(self, **kwargs)
+        super().__init__(**kwargs)
         self.queue = []
         self.max_capacity = max_capacity
         return
@@ -443,3 +434,89 @@ def apply(self, sim):
         self.queue = [e for e in self.queue if e not in treat_inds] # Recreate the queue, removing people who were treated
         return treat_inds
 
+
+
+#%% Vaccination
+
+
+__all__ += ['BaseVaccination', 'routine_vx', 'campaign_vx']
+
+
+class BaseVaccination(Intervention):
+    """
+    Base vaccination class for determining who will receive a vaccine.
+
+    Args:
+         product        (str/Product)   : the vaccine to use
+         prob           (float/arr)     : annual probability of eligible population getting vaccinated
+         eligibility    (inds/callable) : indices OR callable that returns inds
+         label          (str)           : the name of vaccination strategy
+         kwargs         (dict)          : passed to Intervention()
+    """
+    def __init__(self, product=None, prob=None, label=None, **kwargs):
+        Intervention.__init__(self, **kwargs)
+        self.prob = sc.promotetoarray(prob)
+        self.label = label
+        self._parse_product(product)
+        self.vaccinated = ss.State('vaccinated', bool, False)
+        self.n_doses = ss.State('doses', int, 0)
+        self.ti_vaccinated = ss.State('ti_vaccinated', int, ss.INT_NAN)
+
+    def apply(self, sim):
+        """
+        Deliver the diagnostics by finding who's eligible, finding who accepts, and applying the product.
+        """
+        accept_uids = np.array([])
+        if sim.ti in self.timepoints:
+
+            ti = sc.findinds(self.timepoints, sim.ti)[0]
+            prob = self.prob[ti]  # Get the proportion of people who will be tested this timestep
+            is_eligible = self.check_eligibility(sim)  # Check eligibility
+            self.coverage_dist.kwds['p'] = prob
+            accept_uids = self.coverage_dist.filter(ss.true(is_eligible))
+
+            if len(accept_uids):
+                self.product.administer(sim.people, accept_uids)
+
+                # Update people's state and dates
+                self.vaccinated[accept_uids] = True
+                self.ti_vaccinated[accept_uids] = sim.ti
+                self.n_doses[accept_uids] += 1
+
+        return accept_uids
+
+
+class routine_vx(BaseVaccination, RoutineDelivery):
+    """
+    Routine vaccination - an instance of base vaccination combined with routine delivery.
+    See base classes for a description of input arguments.
+    """
+
+    def __init__(self, product=None, prob=None, eligibility=None,
+                 start_year=None, end_year=None, years=None, **kwargs):
+
+        BaseVaccination.__init__(self, product=product, eligibility=eligibility, **kwargs)
+        RoutineDelivery.__init__(self, prob=prob, start_year=start_year, end_year=end_year, years=years)
+
+    def initialize(self, sim):
+        RoutineDelivery.initialize(self, sim)  # Initialize this first, as it ensures that prob is interpolated properly
+        BaseVaccination.initialize(self, sim)  # Initialize this next
+
+
+class campaign_vx(BaseVaccination, CampaignDelivery):
+    """
+    Campaign vaccination - an instance of base vaccination combined with campaign delivery.
+    See base classes for a description of input arguments.
+    """
+
+    def __init__(self, product=None, prob=None, eligibility=None,
+                 years=None, interpolate=True, **kwargs):
+
+        BaseVaccination.__init__(self, product=product, eligibility=eligibility, **kwargs)
+        CampaignDelivery.__init__(self, prob=prob, years=years, interpolate=interpolate)
+
+    def initialize(self, sim):
+        CampaignDelivery.initialize(self, sim) # Initialize this first, as it ensures that prob is interpolated properly
+        BaseVaccination.initialize(self, sim) # Initialize this next
+
+
diff --git a/starsim/modules.py b/starsim/modules.py
index ff349663..4e8aecbd 100644
--- a/starsim/modules.py
+++ b/starsim/modules.py
@@ -1,31 +1,39 @@
-'''
-Disease modules
-'''
+"""
+General module class -- base class for diseases, interventions, etc.
+"""
 
 import sciris as sc
 import starsim as ss
 from scipy.stats._distn_infrastructure import rv_frozen
+from inspect import signature, _empty
+import numpy as np
 
 __all__ = ['Module']
 
-
 class Module(sc.prettyobj):
 
-    def __init__(self, pars=None, name=None, label=None, requires=None, *args, **kwargs):
+    def __init__(self, pars=None, par_dists=None, name=None, label=None, requires=None, *args, **kwargs):
         self.pars = ss.omerge(pars)
+        self.par_dists = ss.omerge(par_dists)
         self.name = name if name else self.__class__.__name__.lower() # Default name is the class name
-        self.label = label if label else ''
+        self.label = label if label else self.name
         self.requires = sc.mergelists(requires)
-        self.results = ss.ndict(type=ss.Result)
+        self.results = ss.Results(self.name)
         self.initialized = False
         self.finalized = False
-
         return
 
     def check_requires(self, sim):
-        for req in sc.tolist(self.requires):
-            if req not in [m.__class__ for m in sim.modules]:
-                raise Exception(f'{self.name} (label={self.label}) requires module {req} but the Sim did not contain a module of this type.')
+        """ Check that the module's requirements (of other modules) are met """
+        errs = sc.autolist()
+        all_names = [m.__class__ for m in sim.modules] + [m.name for m in sim.modules]
+        for req in self.requires:
+            if req not in all_names:
+                errs += req
+        if len(errs):
+            errormsg = f'{self.name} (label={self.label}) requires the following module(s), but the Sim does not contain them.'
+            errormsg += sc.newlinejoin(errs)
+            raise Exception(errormsg)
         return
 
     def initialize(self, sim):
@@ -33,9 +41,6 @@ def initialize(self, sim):
         Perform initialization steps
 
         This method is called once, as part of initializing a Sim
-
-        :param sim:
-        :return:
         """
         self.check_requires(sim)
 
@@ -45,13 +50,32 @@ def initialize(self, sim):
             if not rng.initialized:
                 rng.initialize(sim.rng_container, sim.people.slot)
 
+        # First, convert any scalar pars to distributions if required
+        for key in self.par_dists.keys():
+            par = self.pars[key]
+            if isinstance(par, rv_frozen):
+                continue
+
+            par_dist = self.par_dists[key]
+
+            # If it's a lognormal distribution, initialize assuming the par is the desired mean
+            if par_dist.name == 'lognorm':
+                self.pars[key] = self.par_dists[key](s=1, scale=np.exp(par))
+
+            # Otherwise, figure out the required arguments and assume the user is trying to set them
+            else:
+                rqrd_args = [x for x, p in signature(par_dist._parse_args).parameters.items() if p.default == _empty]
+                if len(rqrd_args) != 0:
+                    par_dist_arg = rqrd_args[0]
+                else:
+                    par_dist_arg = 'loc'
+                self.pars[key] = self.par_dists[key](**{par_dist_arg: par})
+
         # Initialize distributions in pars
         for key, value in self.pars.items():
             if isinstance(value, rv_frozen):
                 self.pars[key] = ss.ScipyDistribution(value, f'{self.name}_{self.label}_{key}')
                 self.pars[key].initialize(sim, self)
-            #elif isinstance(value, ss.rate):
-            #    self.pars[key].initialize(sim, f'{self.name}_{self.label}_{key}')
 
         for key, value in self.__dict__.items():
             if isinstance(value, rv_frozen):
@@ -80,37 +104,65 @@ def finalize_results(self, sim):
             if isinstance(res, ss.Result) and res.scale:
                 self.results[reskey] = self.results[reskey]*sim.pars.pop_scale
         return
+    
+    def add_states(self, *args, check=True):
+        """
+        Add states to the module with the same attribute name as the state
+        
+        Args:
+            args (states): list of states to add
+            check (bool): whether to check that the object being added is a state
+        """
+        for arg in args:
+            if isinstance(arg, (list, tuple)):
+                state = ss.State(*arg)
+            elif isinstance(arg, dict):
+                state = ss.State(**arg)
+            else:
+                state = arg
+                
+            if check:
+                assert isinstance(state, ss.State), f'Could not add {state}: not a State object'
+                
+            setattr(self, state.name, state)
+        return
 
     @property
     def states(self):
         """
-        Return a flat collection of all states
+        Return a flat list of all states
 
         The base class returns all states that are contained in top-level attributes
         of the Module. If a Module stores states in a non-standard location (e.g.,
         within a list of states, or otherwise in some other nested structure - perhaps
         due to supporting features like multiple genotypes) then the Module should
         overload this attribute to ensure that all states appear in here.
-
-        :return:
         """
         return [x for x in self.__dict__.values() if isinstance(x, ss.State)]
 
+    @property
+    def statesdict(self):
+        """
+        Return a flat dictionary (objdict) of all states
+        """
+        return sc.objdict({s.name:s for s in self.states})
+
     @property
     def rngs(self):
         """
         Return a flat collection of all random number generators, as with states above
-
-        :return:
         """
         return [x for x in self.__dict__.values() if isinstance(x, (ss.MultiRNG, ss.SingleRNG))]
 
-    @property
-    def scipy_dbns(self):
+    @classmethod
+    def create(cls, name, *args, **kwargs):
         """
-        Return a flat collection of all ScipyDistributions
-
-        :return:
+        Create a module instance by name
+        Args:
+            name (str): A string with the name of the module class in lower case, e.g. 'sir'
         """
-        return [x for x in self.__dict__.values() if isinstance(x, ss.ScipyDistribution)] \
-             + [x for x in self.pars.values()     if isinstance(x, ss.ScipyDistribution)]
+        for subcls in ss.all_subclasses(cls):
+            if subcls.__name__.lower() == name:
+                return subcls(*args, **kwargs)
+        else:
+            raise KeyError(f'Module "{name}" did not match any known Starsim Modules')
diff --git a/starsim/networks/networks.py b/starsim/networks.py
similarity index 73%
rename from starsim/networks/networks.py
rename to starsim/networks.py
index fb621e57..f33673f7 100644
--- a/starsim/networks/networks.py
+++ b/starsim/networks.py
@@ -4,19 +4,18 @@
 
 # %% Imports
 import numpy as np
+import numba as nb
 import sciris as sc
 import starsim as ss
 import scipy.optimize as spo
-import scipy.stats as sps
 import scipy.spatial as spsp
-import pandas as pd
-from scipy.stats._distn_infrastructure import rv_frozen
-import scipy.stats as sps
 
+# CK: need to remove this, but slows down the code otherwise
+ss_float_ = ss.dtypes.float
+ss_int_ = ss.dtypes.int
 
-# Specify all externally visible functions this file defines
-__all__ = ['Networks', 'Network', 'NetworkConnector', 'SexualNetwork', 'mf', 'msm', 'mf_msm', 'hpv_network', 'maternal', 'embedding', 'static']
-
+# Specify all externally visible functions this file defines; see also more definitions below
+__all__ = ['Network', 'Networks', 'DynamicNetwork', 'SexualNetwork']
 
 class Network(ss.Module):
     """
@@ -61,14 +60,15 @@ class Network(ss.Module):
     def __init__(self, pars=None, key_dict=None, vertical=False, *args, **kwargs):
 
         # Initialize as a module
+        pars = self.validate_pars(pars)
         super().__init__(pars, *args, **kwargs)
 
         # Each relationship is characterized by these default set of keys, plus any user- or network-supplied ones
-        default_keys = {
-            'p1': ss.int_,
-            'p2': ss.int_,
-            'beta': ss.float_,
-        }
+        default_keys = dict(
+            p1 = ss_int_,
+            p2 = ss_int_,
+            beta = ss_float_,
+        )
         self.meta = ss.omerge(default_keys, key_dict)
         self.vertical = vertical  # Whether transmission is bidirectional
 
@@ -83,15 +83,28 @@ def __init__(self, pars=None, key_dict=None, vertical=False, *args, **kwargs):
             self.initialized = True
 
         # Define states using placeholder values
-        self.participant = ss.State('participant', bool, fill_value=False)
-        self.debut = ss.State('debut', float, fill_value=0)
+        self.participant = ss.State('participant', bool, default=False)
         return
+    
+    @property
+    def p1(self):
+        return self.contacts['p1'] if 'p1' in self.contacts else None
+    
+    @property
+    def p2(self):
+        return self.contacts['p2'] if 'p2' in self.contacts else None
 
+    @property
+    def beta(self):
+        return self.contacts['beta'] if 'beta' in self.contacts else None
 
     def initialize(self, sim):
         super().initialize(sim)
         return
 
+    def validate_pars(self, pars):
+        return pars
+
     def __len__(self):
         try:
             return len(self.contacts.p1)
@@ -250,7 +263,7 @@ def find_contacts(self, inds, as_array=True):
         # Find the contacts
         contact_inds = ss.find_contacts(self.contacts.p1, self.contacts.p2, inds)
         if as_array:
-            contact_inds = np.fromiter(contact_inds, dtype=ss.int_)
+            contact_inds = np.fromiter(contact_inds, dtype=ss_int_)
             contact_inds.sort()
 
         return contact_inds
@@ -274,22 +287,10 @@ def remove_uids(self, uids):
             self.contacts[k] = self.contacts[k][keep]
 
 
-class DynamicNetwork(Network):
-    def __init__(self, pars=None, key_dict=None):
-        key_dict = ss.omerge({'dur': ss.float_}, key_dict)
-        super().__init__(pars, key_dict=key_dict)
-
-    def end_pairs(self, people):
-        dt = people.dt
-        self.contacts.dur = self.contacts.dur - dt
-
-        # Non-alive agents are removed
-        active = (self.contacts.dur > 0) & people.alive[self.contacts.p1] & people.alive[self.contacts.p2]
-        for k in self.meta_keys():
-            self.contacts[k] = self.contacts[k][active]
-
-
 class Networks(ss.ndict):
+    """
+    Container for networks
+    """
     def __init__(self, *args, type=Network, connectors=None, **kwargs):
         self.setattribute('_connectors', ss.ndict(connectors))
         super().__init__(*args, type=type, **kwargs)
@@ -310,11 +311,28 @@ def update(self, people):
         return
 
 
+class DynamicNetwork(Network):
+    def __init__(self, pars=None, key_dict=None, **kwargs):
+        key_dict = ss.omerge({'dur': ss_float_}, key_dict)
+        super().__init__(pars, key_dict=key_dict, **kwargs)
+
+    def end_pairs(self, people):
+        dt = people.dt
+        self.contacts.dur = self.contacts.dur - dt
+
+        # Non-alive agents are removed
+        active = (self.contacts.dur > 0) & people.alive[self.contacts.p1] & people.alive[self.contacts.p2]
+        for k in self.meta_keys():
+            self.contacts[k] = self.contacts[k][active]
+
+
 class SexualNetwork(Network):
     """ Base class for all sexual networks """
-    def __init__(self, pars=None, key_dict=None):
-        key_dict = ss.omerge({'acts': ss.int_}, key_dict)
-        super().__init__(pars, key_dict=key_dict)
+    def __init__(self, pars=None, key_dict=None, **kwargs):
+        key_dict = ss.omerge({'acts': ss_int_}, key_dict)
+        super().__init__(pars, key_dict=key_dict, **kwargs)
+        self.debut = ss.State('debut', float, default=0)
+        return
 
     def active(self, people):
         # Exclude people who are not alive
@@ -329,23 +347,35 @@ def available(self, people, sex):
         return np.setdiff1d(ss.true(people[sex] & self.active(people)), self.members) # ss.true instead of people.uid[]?
 
 
-class mf(SexualNetwork, DynamicNetwork):
+# %% Specific instances of networks
+__all__ += ['MFNet', 'MSMNet', 'EmbeddingNet', 'MaternalNet', 'StaticNet', 'RandomNet', 'HPVNet']
+
+class MFNet(SexualNetwork, DynamicNetwork):
     """
     This network is built by **randomly pairing** males and female with variable
     relationship durations.
     """
 
-    def __init__(self, pars=None, key_dict=None):
-        pars = ss.omerge({
-            'duration_dist': ss.lognorm(mean=15, stdev=15),  # Can vary by age, year, and individual pair. Set scale=exp(mu) and s=sigma where mu,sigma are of the underlying normal distribution.
-            'participation_dist': sps.bernoulli(p=0.9),  # Probability of participating in this network - can vary by individual properties (age, sex, ...) using callable parameter values
-            'debut_dist': sps.norm(loc=16, scale=2),  # Age of debut can vary by using callable parameter values
-            'acts': ss.lognorm(mean=80, stdev=20),
-            'rel_part_rates': 1.0,
-        }, pars)
+    def __init__(self, pars=None, par_dists=None, key_dict=None):
+        pars = ss.omergeleft(pars,
+            duration = 15,  # Can vary by age, year, and individual pair. Set scale=exp(mu) and s=sigma where mu,sigma are of the underlying normal distribution.
+            participation = 0.9,  # Probability of participating in this network - can vary by individual properties (age, sex, ...) using callable parameter values
+            debut = 16,  # Age of debut can vary by using callable parameter values
+            acts = 80,
+            rel_part_rates = 1.0,
+        )
 
-        DynamicNetwork.__init__(self, key_dict)
-        SexualNetwork.__init__(self, pars, key_dict)
+        par_dists = ss.omergeleft(par_dists,
+            duration      = ss.lognorm,
+            participation = ss.bernoulli,
+            debut         = ss.norm,
+            acts          = ss.lognorm,
+        )
+
+        DynamicNetwork.__init__(self, key_dict=key_dict)
+        SexualNetwork.__init__(self, pars, key_dict=key_dict)
+
+        self.par_dists = par_dists
 
         return
 
@@ -362,16 +392,15 @@ def set_network_states(self, people, upper_age=None):
 
     def set_participation(self, people, upper_age=None):
         # Set people who will participate in the network at some point
-        year = people.year
         if upper_age is None: uids = people.uid
         else: uids = people.uid[(people.age < upper_age)]
-        self.participant[uids] = self.pars.participation_dist.rvs(uids)
+        self.participant[uids] = self.pars.participation.rvs(uids)
 
     def set_debut(self, people, upper_age=None):
         # Set debut age
         if upper_age is None: uids = people.uid
         else: uids = people.uid[(people.age < upper_age)]
-        self.debut[uids] = self.pars.debut_dist.rvs(uids)
+        self.debut[uids] = self.pars.debut.rvs(uids)
         uids_to_update = uids[np.isnan(people.debut[uids])]
         people.debut[uids_to_update] = self.debut[uids_to_update]
         return
@@ -395,9 +424,9 @@ def add_pairs(self, people, ti=None):
         # print('DJK TODO')
         if ss.options.multirng and (len(p1) == len(np.unique(p1))):
             # No duplicates and user has enabled multirng, so use slotting based on p1
-            dur_vals = self.pars.duration_dist.rvs(p1)
+            dur_vals = self.pars.duration.rvs(p1)
         else:
-            dur_vals = self.pars.duration_dist.rvs(len(p1))  # Just use len(p1) to say how many draws are needed
+            dur_vals = self.pars.duration.rvs(len(p1))  # Just use len(p1) to say how many draws are needed
 
         # Figure out acts
         act_vals = self.pars.acts.rvs(len(p1))  # TODO use slots
@@ -417,22 +446,21 @@ def update(self, people, dt=None):
         return
 
 
-class msm(SexualNetwork, DynamicNetwork):
+class MSMNet(SexualNetwork, DynamicNetwork):
     """
     A network that randomly pairs males
     """
 
-    def __init__(self, pars=None):
-
-        pars = ss.omerge({
-            'participation_dist': sps.bernoulli(p=0.1),  # Probability of participating in this network - can vary by individual properties (age, sex, ...) using callable parameter values
-            'rel_part_rates': 1.0,
-            'duration_dist': ss.lognorm(mean=15, stdev=15),
-            'debut_dist': sps.norm(loc=16, scale=2),
-        }, pars)
-        DynamicNetwork.__init__(self)
-        SexualNetwork.__init__(self, pars)
-
+    def __init__(self, pars=None, key_dict=None, **kwargs):
+        pars = ss.omergeleft(pars,
+            duration_dist = ss.lognorm_mean(mean=15, stdev=15),
+            participation_dist = ss.bernoulli(p=0.1),  # Probability of participating in this network - can vary by individual properties (age, sex, ...) using callable parameter values
+            debut_dist = ss.norm(loc=16, scale=2),
+            acts = ss.lognorm_mean(mean=80, stdev=20),
+            rel_part_rates = 1.0,
+        )
+        DynamicNetwork.__init__(self, key_dict, **kwargs)
+        SexualNetwork.__init__(self, pars, key_dict)
         return
 
     def initialize(self, sim):
@@ -451,8 +479,8 @@ def set_network_states(self, people, upper_age=None):
 
         # Participation
         self.participant[people.female] = False
-        pr = self.pars.part_rates
-        dist = sps.bernoulli.rvs(p=pr, size=len(uids))
+        pr = self.pars.rel_part_rates
+        dist = ss.bernoulli.rvs(p=pr, size=len(uids))
         self.participant[uids] = dist
 
         # Debut
@@ -476,10 +504,14 @@ def add_pairs(self, people, ti=None):
         else:
             dur = self.pars['duration_dist'].rvs(len(p1)) # Just use len(p1) to say how many draws are needed
 
+        # Figure out acts
+        act_vals = self.pars.acts.rvs(len(p1))  # TODO use slots
+
         self.contacts.p1 = np.concatenate([self.contacts.p1, p1])
         self.contacts.p2 = np.concatenate([self.contacts.p2, p2])
         self.contacts.beta = np.concatenate([self.contacts.beta, np.ones_like(p1)])
         self.contacts.dur = np.concatenate([self.contacts.dur, dur])
+        self.contacts.acts = np.concatenate([self.contacts.acts, act_vals])
         return len(p1)
 
     def update(self, people, dt=None):
@@ -489,7 +521,7 @@ def update(self, people, dt=None):
         return
 
 
-class embedding(mf):
+class EmbeddingNet(MFNet):
     """
     Heterosexual age-assortative network based on a one-dimensional embedding. Could be made more generic.
     """
@@ -503,7 +535,7 @@ def __init__(self, pars=None, **kwargs):
         
         """
         pars = ss.omerge({
-            'embedding_func': sps.norm(loc=self.embedding_loc, scale=2),
+            'embedding_func': ss.norm(loc=self.embedding_loc, scale=2),
             'male_shift': 5,
         }, pars)
         super().__init__(pars, **kwargs)
@@ -549,126 +581,217 @@ def add_pairs(self, people, ti=None):
         return len(beta)
 
 
-class NetworkConnector(ss.Module):
+class MaternalNet(Network):
     """
-    Template for a connector between networks.
+    Vertical (birth-related) transmission network
     """
-    def __init__(self, *args, networks=None, pars=None, **kwargs):
-        super().__init__(pars, requires=networks, *args, **kwargs)
+    def __init__(self, key_dict=None, vertical=True, **kwargs):
+        """
+        Initialized empty and filled with pregnancies throughout the simulation
+        """
+        key_dict = sc.mergedicts({'dur': ss_float_}, key_dict)
+        super().__init__(key_dict=key_dict, vertical=vertical, **kwargs)
         return
 
-    def set_participation(self, people, upper_age=None):
-        pass
+    def update(self, people, dt=None):
+        if dt is None: dt = people.dt
+        # Set beta to 0 for women who complete post-partum period
+        # Keep connections for now, might want to consider removing
+        self.contacts.dur = self.contacts.dur - dt
+        inactive = self.contacts.dur <= 0
+        self.contacts.beta[inactive] = 0
+        return
 
-    def update(self, people):
+    def initialize(self, sim):
+        """ No pairs added upon initialization """
         pass
 
+    def add_pairs(self, mother_inds, unborn_inds, dur):
+        """
+        Add connections between pregnant women and their as-yet-unborn babies
+        """
+        beta = np.ones_like(mother_inds)
+        self.contacts.p1 = np.concatenate([self.contacts.p1, mother_inds])
+        self.contacts.p2 = np.concatenate([self.contacts.p2, unborn_inds])
+        self.contacts.beta = np.concatenate([self.contacts.beta, beta])
+        self.contacts.dur = np.concatenate([self.contacts.dur, dur])
+        return len(mother_inds)
 
-class mf_msm(NetworkConnector):
-    """ Combines the MF and MSM networks """
-    def __init__(self, pars=None):
-        networks = [ss.mf, ss.msm]
+
+class StaticNet(Network):
+    """
+    A network class of static partnerships converted from a networkx graph. There's no formation of new partnerships
+    and initialized partnerships only end when one of the partners dies. The networkx graph can be created outside Starsim
+    if population size is known. Or the graph can be created by passing a networkx generator function to Starsim.
+
+    **Examples**::
+
+    # Generate a networkx graph and pass to Starsim
+    import networkx as nx
+    import starsim as ss
+    g = nx.scale_free_graph(n=10000)
+    ss.static(graph=g)
+
+    # Pass a networkx graph generator to Starsim
+    ss.static(graph=nx.erdos_renyi_graph, p=0.0001)
+
+    """
+
+    def __init__(self, graph, pars=None, **kwargs):
+        self.graph = graph
+        self.pars = ss.omerge(pars)
+        super().__init__(**kwargs)
+        return
+
+    def initialize(self, sim):
+        popsize = sim.pars['n_agents']
+        if callable(self.graph):
+            self.graph = self.graph(n=popsize, **self.pars)
+        self.validate_pop(popsize)
+        super().initialize(sim)
+        self.get_contacts()
+        return
+
+    def validate_pop(self, popsize):
+        n_nodes = self.graph.number_of_nodes()
+        if n_nodes > popsize:
+            errormsg = f'Please ensure the number of nodes in graph {n_nodes} is smaller than population size {popsize}.'
+            raise ValueError(errormsg)
+
+    def get_contacts(self):
+        p1s = []
+        p2s = []
+        for edge in self.graph.edges():
+            p1, p2 = edge
+            p1s.append(p1)
+            p2s.append(p2)
+        self.contacts.p1 = np.concatenate([self.contacts.p1, p1s])
+        self.contacts.p2 = np.concatenate([self.contacts.p2, p2s])
+        self.contacts.beta = np.concatenate([self.contacts.beta, np.ones_like(p1s)])
+        return
+
+
+class RandomNet(DynamicNetwork):
+    """ Random connectivity between agents """
+
+    def __init__(self, pars=None, par_dists=None, key_dict=None, **kwargs):
+        """ Initialize """
         pars = ss.omerge({
-            'prop_bi': 0.5,  # Could vary over time -- but not by age or sex or individual
+            'n_contacts': 15,  # Distribution or int. If int, interpreted as the mean of the dist listed in par_dists
+            'dur': 1,
         }, pars)
-        super().__init__(networks=networks, pars=pars)
 
-        self.bi_dist = sps.bernoulli(p=self.pars.prop_bi)
+        DynamicNetwork.__init__(self, pars=pars, key_dict=key_dict, **kwargs)
+
         return
 
     def initialize(self, sim):
         super().initialize(sim)
-        self.set_participation(sim.people)
+        self.add_pairs(sim.people)
         return
 
-    def set_participation(self, people, upper_age=None):
-        if upper_age is None:
-            uids = people.uid
-        else:
-            uids = people.uid[(people.age < upper_age)]
-        uids = ss.true(people.male[uids])
+    @staticmethod
+    @nb.njit(cache=True)
+    def get_contacts(inds, number_of_contacts):
+        """
+        Efficiently generate contacts
 
-        # Get networks and overwrite default participation
-        mf = people.networks['mf']
-        msm = people.networks['msm']
-        mf.participant[uids] = False
-        msm.participant[uids] = False
+        Note that because of the shuffling operation, each person is assigned 2N contacts
+        (i.e. if a person has 5 contacts, they appear 5 times in the 'source' array and 5
+        times in the 'target' array). Therefore, the `number_of_contacts` argument to this
+        function should be HALF of the total contacts a person is expected to have, if both
+        the source and target array outputs are used (e.g. for social contacts)
 
-        # Male participation rate uses info about cross-network participation.
-        # First, we determine who's participating in the MSM network
-        pr = msm.pars.part_rates
-        dist = sps.bernoulli.rvs(p=pr, size=len(uids))
-        msm.participant[uids] = dist
+        adjusted_number_of_contacts = np.round(number_of_contacts / 2).astype(cvd.default_int)
 
-        # Now we take the MSM participants and determine which are also in the MF network
-        msm_uids = ss.true(msm.participant[uids])  # Males in the MSM network
-        bi_uids = self.bi_dist.filter(msm_uids)  # Males in both MSM and MF networks
-        mf_excl_set = np.setdiff1d(uids, msm_uids)  # Set of males who aren't in the MSM network
+        Whereas for asymmetric contacts (e.g. staff-public interactions) it might not be necessary
 
-        # What remaining share to we need?
-        mf_df = mf.pars.part_rates.loc[mf.pars.part_rates.sex == 'm']  # Male participation in the MF network
-        mf_pr = np.interp(people.year, mf_df['year'], mf_df['part_rates']) * mf.pars.rel_part_rates
-        remaining_pr = max(mf_pr*len(uids)-len(bi_uids), 0)/len(mf_excl_set)
+        Args:
+            inds: List/array of person indices
+            number_of_contacts: List/array the same length as `inds` with the number of unidirectional
+            contacts to assign to each person. Therefore, a person will have on average TWICE this number
+            of random contacts.
 
-        # Don't love the following new syntax:
-        mf_excl_uids = mf_excl_set[sps.uniform.rvs(size=len(mf_excl_set)) < remaining_pr]
+        Returns: Two arrays, for source and target
+        """
 
-        mf.participant[bi_uids] = True
-        mf.participant[mf_excl_uids] = True
+        total_number_of_half_edges = np.sum(number_of_contacts)
+        count = 0
+        source = np.zeros((total_number_of_half_edges,), dtype=ss_int_)
+        for i, person_id in enumerate(inds):
+            n_contacts = number_of_contacts[i]
+            source[count: count + n_contacts] = person_id
+            count += n_contacts
+        target = np.random.permutation(source)
+        return source, target
+
+    def update(self, people, dt=None):
+        self.end_pairs(people)
+        self.add_pairs(people)
         return
 
-    def update(self, people):
-        self.set_participation(people, upper_age=people.dt)
+    def add_pairs(self, people):
+        """
+        Generate contacts
+        """
+
+        if isinstance(self.pars.n_contacts, ss.ScipyDistribution):
+            number_of_contacts = self.pars.n_contacts.rvs(people.alive)  # or people.uid?
+        else:
+            number_of_contacts = np.full(len(people), self.pars.n_contacts)
+
+        number_of_contacts = np.round(number_of_contacts / 2).astype(ss_int_)  # One-way contacts
+
+        p1, p2 = self.get_contacts(people.uid.__array__(), number_of_contacts)
+        beta = np.ones(len(p1), dtype=ss_float_)
+
+        if isinstance(self.pars.dur, ss.ScipyDistribution):
+            dur = self.pars.dur.rvs(p1)
+        else:
+            dur = np.full(len(p1), self.pars.dur)
+
+        self.contacts.p1 = np.concatenate([self.contacts.p1, p1])
+        self.contacts.p2 = np.concatenate([self.contacts.p2, p2])
+        self.contacts.beta = np.concatenate([self.contacts.beta, beta])
+        self.contacts.dur = np.concatenate([self.contacts.dur, dur])
+
         return
 
 
-class hpv_network(mf):
-    def __init__(self, pars=None):
+class HPVNet(MFNet):
+    def __init__(self, pars=None, par_dists=None, key_dict=None, **kwargs):
+        pars = ss.omergeleft(pars,
+            duration = 15,
+            participation = 0.9,
+            debut = 16,
+            acts = 80,
+            rel_part_rates = 1.0,
+            cross_layer = 0.05,
+            concurrency = 0.05,
+            condoms = 0.2,
+            mixing = None,
+        )
+
+        self.par_dists = ss.omergeleft(par_dists,
+            duration      = ss.lognorm,
+            participation = ss.bernoulli,
+            debut         = ss.norm,
+            acts          = ss.lognorm,
+            cross_layer   = ss.bernoulli,
+            concurrency   = ss.bernoulli,
+        )
+
+        key_dict = dict(
+            acts = ss_float_,
+            start = ss_float_,
+        )
+
+        DynamicNetwork.__init__(self, key_dict)
+        SexualNetwork.__init__(self, pars, key_dict)
+
+        super().__init__(pars, key_dict=key_dict, **kwargs)
 
-        key_dict = {
-            'acts': ss.float_,
-            'start': ss.float_,
-        }
-
-        # Define default parameters
-        default_pars = dict()
-        default_pars['cross_layer']   = 0.05  # Proportion of agents who have concurrent cross-layer relationships
-        default_pars['partner_dist']  = sps.poisson(mu=0.01)  # The number of concurrent sexual partners
-
-        # TODO: Wrap so user can provide mean and dispersion directly - see #168
-        mu = 80 # Mean
-        alpha = 40 # Dispersion
-        sigma2 = mu + alpha * mu**2
-        n = mu**2 / (sigma2 - mu)
-        p = mu / sigma2
-        default_pars['act_dist']      = sps.nbinom(n=n, p=p)  # The number of sexual acts per year
-
-        default_pars['age_act_pars']  = dict(peak=30, retirement=100, debut_ratio=0.5,
-                                         retirement_ratio=0.1)  # Parameters describing changes in coital frequency over agent lifespans
-        default_pars['condoms']       = 0.2  # The proportion of acts in which condoms are used
-
-        low = 0
-        loc = 1
-        scale = 1
-        a = (low - loc) / scale
-        default_pars['duration_dist'] = sps.truncnorm(a=a, b=np.inf, loc=loc, scale=scale)  # Duration of partnerships
-
-        #default_pars['participation'] = None  # Incidence of partnership formation by age
-        default_pars['mixing']        = None  # Mixing matrices for storing age differences in partnerships
-
-        self.agebins = [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75]
-        # Share of females of each age newly having casual relationships
-        self.f_participation = [0, 0, 0.10, 0.7, 0.8, 0.6, 0.6, 0.4, 0.1, 0.05, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001]
-        # Share of males of each age newly having casual relationships
-        self.m_participation = [0, 0, 0.05, 0.7, 0.8, 0.6, 0.6, 0.4, 0.4, 0.3, 0.1, 0.05, 0.01, 0.01, 0.001, 0.001]
-
-        default_pars['participation_dist'] = sps.bernoulli(p=self.participation)
-
-        pars = ss.omerge(default_pars, pars)
-        super().__init__(pars, key_dict)
- 
         self.get_layer_probs()
-        self.validate_pars()
 
         return
 
@@ -676,11 +799,6 @@ def initialize(self, sim):
         super().initialize(sim)
         return self.add_pairs(sim.people, ti=0)
 
-    def validate_pars(self):
-        for par in ['partner_dist', 'act_dist', 'duration_dist', 'participation_dist']:
-            if not isinstance(self.pars[par], rv_frozen):
-                raise Exception(f'Network parameter {par} must be an instance of a scipy frozen distribution')
-
     def update_pars(self, pars):
         if pars is not None:
             for k, v in pars.items():
@@ -689,7 +807,7 @@ def update_pars(self, pars):
 
     @staticmethod
     def participation(self, sim, uids):
-        p = np.ones_like(uids, dtype=ss.float_)
+        p = np.ones_like(uids, dtype=ss_float_)
         fem = sim.people.female[uids]
         p[fem] = np.interp(sim.people.age[uids[fem]], self.agebins, self.f_participation)
         p[~fem] = np.interp(sim.people.age[uids[~fem]], self.agebins, self.m_participation)
@@ -699,23 +817,23 @@ def get_layer_probs(self):
 
         defaults = {}
         mixing = np.array([
-            #       0,  5,  10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75
-            [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
-            [5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
-            [10, 0, 0, .1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
-            [15, 0, 0, .1, .1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
-            [20, 0, 0, .1, .1, .1, .1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
-            [25, 0, 0, .5, .1, .5, .1, .1, 0, 0, 0, 0, 0, 0, 0, 0, 0],
-            [30, 0, 0, 1, .5, .5, .5, .5, .1, 0, 0, 0, 0, 0, 0, 0, 0],
-            [35, 0, 0, .5, 1, 1, .5, 1, 1, .5, 0, 0, 0, 0, 0, 0, 0],
-            [40, 0, 0, 0, .5, 1, 1, 1, 1, 1, .5, 0, 0, 0, 0, 0, 0],
-            [45, 0, 0, 0, 0, .1, 1, 1, 2, 1, 1, .5, 0, 0, 0, 0, 0],
-            [50, 0, 0, 0, 0, 0, .1, 1, 1, 1, 1, 2, .5, 0, 0, 0, 0],
-            [55, 0, 0, 0, 0, 0, 0, .1, 1, 1, 1, 1, 2, .5, 0, 0, 0],
-            [60, 0, 0, 0, 0, 0, 0, 0, .1, .5, 1, 1, 1, 2, .5, 0, 0],
-            [65, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 2, .5, 0],
-            [70, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, .5],
-            [75, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1],
+            # 0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 75+
+            [0 , 0,  0, 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0] ,
+            [5 , 0,  0, 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0] ,
+            [10, 0,  0, .1, 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0] ,
+            [15, 0,  0, .1, .1, 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0] ,
+            [20, 0,  0, .1, .1, .1, .1, 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0] ,
+            [25, 0,  0, .5, .1, .5, .1, .1, 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0] ,
+            [30, 0,  0, 1 , .5, .5, .5, .5, .1, 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0] ,
+            [35, 0,  0, .5, 1 , 1 , .5, 1 , 1 , .5, 0 , 0 , 0 , 0 , 0 , 0 , 0] ,
+            [40, 0,  0, 0 , .5, 1 , 1 , 1 , 1 , 1 , .5, 0 , 0 , 0 , 0 , 0 , 0] ,
+            [45, 0,  0, 0 , 0 , .1, 1 , 1 , 2 , 1 , 1 , .5, 0 , 0 , 0 , 0 , 0] ,
+            [50, 0,  0, 0 , 0 , 0 , .1, 1 , 1 , 1 , 1 , 2 , .5, 0 , 0 , 0 , 0] ,
+            [55, 0,  0, 0 , 0 , 0 , 0 , .1, 1 , 1 , 1 , 1 , 2 , .5, 0 , 0 , 0] ,
+            [60, 0,  0, 0 , 0 , 0 , 0 , 0 , .1, .5, 1 , 1 , 1 , 2 , .5, 0 , 0] ,
+            [65, 0,  0, 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 1 , 1 , 1 , 2 , .5, 0] ,
+            [70, 0,  0, 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 1 , 1 , 1 , 1 , .5],
+            [75, 0,  0, 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 1 , 1 , 1 , 1] ,
         ])
 
         defaults['mixing'] = mixing
@@ -747,7 +865,8 @@ def set_participation(self, people, upper_age=None):
         return
 
     def add_pairs(self, people, ti=0):
-        participating = ss.true(self.participant) # Will be the same people each time, with participation decided once per person
+        participating = ss.true(
+            self.participant)  # Will be the same people each time, with participation decided once per person
         f = participating[people.female[participating]]
         m = participating[~people.female[participating]]
 
@@ -759,7 +878,7 @@ def add_pairs(self, people, ti=0):
         pair_probs = self.pars['mixing'][age_m, age_f + 1]
 
         f_to_remove = pair_probs.max(axis=0) == 0  # list of female inds to remove if no male partners are found for her
-        #f = [i for i, flag in zip(f, f_to_remove) if ~flag]  # remove the inds who don't get paired on this timestep
+        # f = [i for i, flag in zip(f, f_to_remove) if ~flag]  # remove the inds who don't get paired on this timestep
         f = f[~f_to_remove]
         selected_males = []
         if len(f):
@@ -767,12 +886,12 @@ def add_pairs(self, people, ti=0):
             choices = []
             fems = np.arange(len(f))
             f_paired_bools = np.full(len(fems), True, dtype=bool)
-            np.random.shuffle(fems) # TODO: Stream-ify?
+            np.random.shuffle(fems)  # TODO: Stream-ify?
             for fem in fems:
                 m_col = pair_probs[:, fem]
                 if m_col.sum() > 0:
                     m_col_norm = m_col / m_col.sum()
-                    choice = np.random.choice(len(m_col_norm), p=m_col_norm) # TODO: Stream-ify?
+                    choice = np.random.choice(len(m_col_norm), p=m_col_norm)  # TODO: Stream-ify?
                     choices.append(choice)
                     pair_probs[choice, :] = 0  # Once male partner is assigned, remove from eligible pool
                 else:
@@ -816,11 +935,11 @@ def add_pairs(self, people, ti=0):
         retired_vals = 0
 
         # Set values and return
-        scaled_acts = np.full(len(acts), np.nan, dtype=ss.float_)
+        scaled_acts = np.full(len(acts), np.nan, dtype=ss_float_)
         scaled_acts[below_peak_inds] = below_peak_vals
         scaled_acts[above_peak_inds] = above_peak_vals
         scaled_acts[retired_inds] = retired_vals
-        start = np.array([ti] * n_partnerships, dtype=ss.float_)
+        start = np.array([ti] * n_partnerships, dtype=ss_float_)
         beta = np.ones(n_partnerships)
 
         new_contacts = dict(
@@ -848,88 +967,79 @@ def update(self, people, ti=None, dt=None):
         return
 
 
-class maternal(Network):
-    def __init__(self, key_dict=None, vertical=True, **kwargs):
-        """
-        Initialized empty and filled with pregnancies throughout the simulation
-        """
-        key_dict = sc.mergedicts({'dur': ss.float_}, key_dict)
-        super().__init__(key_dict=key_dict, vertical=vertical, **kwargs)
-        return
+# %% Network connectors
+__all__ += ['NetworkConnector', 'MF_MSM']
 
-    def update(self, people, dt=None):
-        if dt is None: dt = people.dt
-        # Set beta to 0 for women who complete post-partum period
-        # Keep connections for now, might want to consider removing
-        self.contacts.dur = self.contacts.dur - dt
-        inactive = self.contacts.dur <= 0
-        self.contacts.beta[inactive] = 0
+class NetworkConnector(ss.Module):
+    """
+    Template for a connector between networks.
+    """
+    def __init__(self, *args, networks=None, pars=None, **kwargs):
+        super().__init__(pars, requires=networks, *args, **kwargs)
         return
 
-    def initialize(self, sim):
-        """ No pairs added upon initialization """
+    def set_participation(self, people, upper_age=None):
         pass
 
-    def add_pairs(self, mother_inds, unborn_inds, dur):
-        """
-        Add connections between pregnant women and their as-yet-unborn babies
-        """
-        beta = np.ones_like(mother_inds)
-        self.contacts.p1 = np.concatenate([self.contacts.p1, mother_inds])
-        self.contacts.p2 = np.concatenate([self.contacts.p2, unborn_inds])
-        self.contacts.beta = np.concatenate([self.contacts.beta, beta])
-        self.contacts.dur = np.concatenate([self.contacts.dur, dur])
-        return len(mother_inds)
-
-
-class static(Network):
-    """
-    A network class of static partnerships converted from a networkx graph. There's no formation of new partnerships
-    and initialized partnerships only end when one of the partners dies. The networkx graph can be created outside Starsim
-    if population size is known. Or the graph can be created by passing a networkx generator function to Starsim.
+    def update(self, people):
+        pass
 
-    **Examples**::
 
-    # Generate a networkx graph and pass to Starsim
-    import networkx as nx
-    import starsim as ss
-    g = nx.scale_free_graph(n=10000)
-    ss.static(graph=g)
-
-    # Pass a networkx graph generator to Starsim
-    ss.static(graph=nx.erdos_renyi_graph, p=0.0001)
+class MF_MSM(NetworkConnector):
+    """ Combines the MF and MSM networks """
+    def __init__(self, pars=None):
+        networks = [ss.MFNet, ss.MSMNet]
+        pars = ss.omergeleft(pars,
+            prop_bi = 0.5,  # Could vary over time -- but not by age or sex or individual
+        )
+        super().__init__(networks=networks, pars=pars)
 
-    """
-    def __init__(self, graph, **kwargs):
-        self.graph = graph
-        self.kwargs = kwargs
-        super().__init__()
+        self.bi_dist = ss.bernoulli(p=self.pars.prop_bi)
         return
 
     def initialize(self, sim):
-        popsize = sim.pars['n_agents']
-        if callable(self.graph):
-            self.graph = self.graph(n = popsize, **self.kwargs)
-        self.validate_pop(popsize)
         super().initialize(sim)
-        self.get_contacts()
+        self.set_participation(sim.people)
         return
 
-    def validate_pop(self, popsize):
-        n_nodes =  self.graph.number_of_nodes()
-        if n_nodes > popsize:
-            errormsg = f'Please ensure the number of nodes in graph {n_nodes} is smaller than population size {popsize}.'
-            raise ValueError(errormsg)
+    def set_participation(self, people, upper_age=None):
+        if upper_age is None:
+            uids = people.uid
+        else:
+            uids = people.uid[(people.age < upper_age)]
+        uids = ss.true(people.male[uids])
 
-    def get_contacts(self):
-        p1s = []
-        p2s = []
-        for edge in self.graph.edges():
-            p1, p2 = edge
-            p1s.append(p1)
-            p2s.append(p2)
-        self.contacts.p1 = np.concatenate([self.contacts.p1, p1s])
-        self.contacts.p2 = np.concatenate([self.contacts.p2, p2s])
-        self.contacts.beta = np.concatenate([self.contacts.beta, np.ones_like(p1s)])
+        # Get networks and overwrite default participation
+        mf = people.networks.mf
+        msm = people.networks.msm
+        mf.participant[uids] = False
+        msm.participant[uids] = False
+
+        # Male participation rate uses info about cross-network participation.
+        # First, we determine who's participating in the MSM network
+        pr = msm.pars.part_rates
+        dist = ss.bernoulli.rvs(p=pr, size=len(uids))
+        msm.participant[uids] = dist
+
+        # Now we take the MSM participants and determine which are also in the MF network
+        msm_uids = ss.true(msm.participant[uids])  # Males in the MSM network
+        bi_uids = self.bi_dist.filter(msm_uids)  # Males in both MSM and MF networks
+        mf_excl_set = np.setdiff1d(uids, msm_uids)  # Set of males who aren't in the MSM network
+
+        # What remaining share to we need?
+        mf_df = mf.pars.part_rates.loc[mf.pars.part_rates.sex == 'm']  # Male participation in the MF network
+        mf_pr = np.interp(people.year, mf_df['year'], mf_df['part_rates']) * mf.pars.rel_part_rates
+        remaining_pr = max(mf_pr*len(uids)-len(bi_uids), 0)/len(mf_excl_set)
+
+        # Don't love the following new syntax:
+        mf_excl_uids = mf_excl_set[ss.uniform.rvs(size=len(mf_excl_set)) < remaining_pr]
+
+        mf.participant[bi_uids] = True
+        mf.participant[mf_excl_uids] = True
         return
 
+    def update(self, people):
+        self.set_participation(people, upper_age=people.dt)
+        return
+
+
diff --git a/starsim/networks/__init__.py b/starsim/networks/__init__.py
deleted file mode 100644
index ee4ed0e5..00000000
--- a/starsim/networks/__init__.py
+++ /dev/null
@@ -1,2 +0,0 @@
-from .networks import *
-from .randnet import *
\ No newline at end of file
diff --git a/starsim/networks/randnet.py b/starsim/networks/randnet.py
deleted file mode 100644
index e1bb0ba5..00000000
--- a/starsim/networks/randnet.py
+++ /dev/null
@@ -1,87 +0,0 @@
-"""Random networks"""
-
-import starsim as ss
-import numpy as np
-import numba as nb
-from .networks import Network
-from scipy.stats._distn_infrastructure import rv_frozen
-from typing import Union
-
-__all__ = ['RandomNetwork']
-
-class RandomNetwork(Network):
-
-    def __init__(self, *, n_contacts : Union[int, rv_frozen], dynamic=True, **kwargs):
-        """
-        :param n_contacts: A SciPy distribution from which the number of
-        contacts are sampled e.g., sps.nbinom(n=5, p=2). Note distribution
-        parameter values can be callable for greater functionality.
-        :param dynamic: If True, regenerate contacts each timestep
-        """
-        super().__init__(**kwargs)
-        if isinstance(n_contacts, rv_frozen):
-            self.n_contacts = ss.ScipyDistribution(n_contacts, f'{self.__class__.__name__}_{self.name}_{self.label}')
-
-        self.dynamic = dynamic
-
-    def initialize(self, sim):
-        super().initialize(sim)
-        if isinstance(self.n_contacts, ss.ScipyDistribution):
-            self.n_contacts.initialize(sim, self)
-        self.update(sim.people, force=True)
-
-    @staticmethod
-    @nb.njit
-    def get_contacts(inds, number_of_contacts):
-        """
-        Efficiently generate contacts
-
-        Note that because of the shuffling operation, each person is assigned 2N contacts
-        (i.e. if a person has 5 contacts, they appear 5 times in the 'source' array and 5
-        times in the 'target' array). Therefore, the `number_of_contacts` argument to this
-        function should be HALF of the total contacts a person is expected to have, if both
-        the source and target array outputs are used (e.g. for social contacts)
-
-        adjusted_number_of_contacts = np.round(number_of_contacts / 2).astype(cvd.default_int)
-
-        Whereas for asymmetric contacts (e.g. staff-public interactions) it might not be necessary
-
-        Args:
-            inds: List/array of person indices
-            number_of_contacts: List/array the same length as `inds` with the number of unidirectional
-            contacts to assign to each person. Therefore, a person will have on average TWICE this number
-            of random contacts.
-
-        Returns: Two arrays, for source and target
-        """
-
-        total_number_of_half_edges = np.sum(number_of_contacts)
-        count = 0
-        source = np.zeros((total_number_of_half_edges,), dtype=ss.int_)
-        for i, person_id in enumerate(inds):
-            n_contacts = number_of_contacts[i]
-            source[count : count + n_contacts] = person_id
-            count += n_contacts
-        target = np.random.permutation(source)
-        return source, target
-
-    def update(self, people: ss.People, force: bool = True) -> None:
-        """
-        Regenerate contacts
-
-        Args:
-            force: If True, ignore the `self.dynamic` flag. This is required for initialization.
-
-        """
-
-        if not self.dynamic and not force:
-            return
-
-        if isinstance(self.n_contacts, ss.ScipyDistribution):
-            number_of_contacts = self.n_contacts.rvs(people.alive) # or people.uid?
-        else:
-            number_of_contacts = np.full(len(people), self.n_contacts)
-
-        number_of_contacts = np.round(number_of_contacts / 2).astype(ss.int_)  # One-way contacts
-        self.contacts.p1, self.contacts.p2 = self.get_contacts(people.uid.__array__(), number_of_contacts)
-        self.contacts.beta = np.ones(len(self.contacts.p1), dtype=ss.float_)
\ No newline at end of file
diff --git a/starsim/parameters.py b/starsim/parameters.py
index e26ef1d1..9108b603 100644
--- a/starsim/parameters.py
+++ b/starsim/parameters.py
@@ -5,10 +5,8 @@
 import sciris as sc
 import starsim as ss
 
-
 __all__ = ['Parameters', 'make_pars']
 
-
 class Parameters(sc.objdict):
     """
     Create the parameters for the simulation. Typically, this function is used
@@ -30,9 +28,10 @@ def __init__(self, **kwargs):
         self.pop_scale       = None  # How much to scale the population
         self.remove_dead     = True          # Remove dead agents each timestep
 
-        # Demographic parameters: NOT CURRENTLY FUNCTIONAL
-        # TBC whether these parameters live here or in separate demographic modules
-        self.location    = None  # What demographics to use
+        # Demographic parameters
+        self.location    = None  #  NOT CURRENTLY FUNCTIONAL - what demographics to use
+        self.birth_rate = None
+        self.death_rate = None
 
         # Simulation parameters
         self.start           = 1995.         # Start of the simulation
@@ -45,13 +44,13 @@ def __init__(self, **kwargs):
         self.slot_scale      = 5             # Random slots will be assigned to newborn agents between min=n_agents and max=slot_scale*n_agents. Choosing a larger value here will reduce the probability of two agents using the same slot (and hence random draws), but increase the number of random numbers that are required.
         self.verbose         = ss.options.verbose # Whether or not to display information during the run -- options are 0 (silent), 0.1 (some; default), 1 (default), 2 (everything)
 
-        # Events and interventions
-        self.connectors = sc.autolist()
-        self.interventions = sc.autolist()  # The interventions present in this simulation; populated by the user
-        self.analyzers = sc.autolist()  # The functions present in this simulation; populated by the user
-
-        # Network parameters, generally initialized after the population has been constructed
-        self.networks        = sc.autolist()  # Network types and parameters
+        # Plug-ins: demographics, diseases, connectors, networks, analyzers, and interventions
+        self.demographics = ss.ndict()
+        self.diseases = ss.ndict()
+        self.networks        = ss.ndict()
+        self.connectors = ss.ndict()
+        self.interventions = ss.ndict()
+        self.analyzers = ss.ndict()
 
         # Update with any supplied parameter values and generate things that need to be generated
         self.update(kwargs)
@@ -84,4 +83,4 @@ def update_pars(self, pars=None, create=False, **kwargs):
 
 
 def make_pars(**kwargs):
-    return Parameters(**kwargs)
\ No newline at end of file
+    return Parameters(**kwargs)
diff --git a/starsim/people.py b/starsim/people.py
index 270567ec..8b20a1f0 100644
--- a/starsim/people.py
+++ b/starsim/people.py
@@ -7,7 +7,6 @@
 import pandas as pd
 import sciris as sc
 import starsim as ss
-import scipy.stats as sps
 
 __all__ = ['BasePeople', 'People']
 
@@ -19,13 +18,13 @@ class BasePeople(sc.prettyobj):
     interesting implementation details.
     """
 
-    def __init__(self, n):
+    def __init__(self, n_agents):
 
         self.initialized = False
-        self._uid_map = ss.DynamicView(int, fill_value=ss.INT_NAN)  # This variable tracks all UIDs ever created
-        self.uid = ss.DynamicView(int, fill_value=ss.INT_NAN)  # This variable tracks all UIDs currently in use
+        self._uid_map = ss.ArrayView(int, default=ss.INT_NAN)  # This variable tracks all UIDs ever created
+        self.uid = ss.ArrayView(int, default=ss.INT_NAN)  # This variable tracks all UIDs currently in use
 
-        n = int(n)
+        n = int(n_agents)
 
         self._uid_map.grow(n)
         self._uid_map[:] = np.arange(0, n)
@@ -58,10 +57,11 @@ def __len__(self):
         """ Length of people """
         return len(self.uid)
 
+    # TODO: CK: should not be needed
     def add_state(self, state, die=True):
         if id(state) not in self._states:
             self._states[id(state)] = state
-            self.states.append(state)  # Expose these states with their original names
+            self.states.append(state, overwrite=True)  # Expose these states with their original names # TODO: should have overwrite=False
             setattr(self, state.name, state)
         elif die:
             errormsg = f'Cannot add state {state} since already added'
@@ -77,7 +77,7 @@ def grow(self, n, new_slots=None):
         """
 
         if n == 0:
-            return np.array([], dtype=ss.int_)
+            return np.array([], dtype=ss.dtypes.int)
 
         start_uid = len(self._uid_map)
         start_idx = len(self.uid)
@@ -120,10 +120,6 @@ def remove(self, uids_to_remove):
         self._uid_map[:] = ss.INT_NAN  # Clear out all previously used UIDs
         self._uid_map[keep_uids] = np.arange(0, len(keep_uids))  # Assign the array indices for all of the current UIDs
 
-        # Remove the UIDs from the network too
-        for network in self.networks.values():
-            network.remove_uids(uids_to_remove)
-
         return
 
     def __getitem__(self, key):
@@ -168,10 +164,10 @@ class People(BasePeople):
         ppl = ss.People(2000)
     """
 
-    def __init__(self, n, age_data=None, extra_states=None, networks=None, rand_seed=0):
+    def __init__(self, n_agents, age_data=None, extra_states=None, rand_seed=0):
         """ Initialize """
 
-        super().__init__(n)
+        super().__init__(n_agents)
 
         self.initialized = False
         self.version = ss.__version__  # Store version info
@@ -179,7 +175,7 @@ def __init__(self, n, age_data=None, extra_states=None, networks=None, rand_seed
         # Handle states
         states = [
             ss.State('age', float, np.nan), # NaN until conceived
-            ss.State('female', bool, sps.bernoulli(p=0.5)),
+            ss.State('female', bool, ss.bernoulli(p=0.5)),
             ss.State('ti_dead', int, ss.INT_NAN),  # Time index for death
             ss.State('alive', bool, True),  # Time index for death
             ss.State('scale', float, 1.0),
@@ -188,8 +184,6 @@ def __init__(self, n, age_data=None, extra_states=None, networks=None, rand_seed
         for state in states:
             self.add_state(state)
         self._initialize_states(sim=None) # No sim yet, but initialize what we can
-        
-        self.networks = ss.Networks(networks)
 
         # Set initial age distribution - likely move this somewhere else later
         self.age_data_dist = self.get_age_dist(age_data)
@@ -200,7 +194,7 @@ def __init__(self, n, age_data=None, extra_states=None, networks=None, rand_seed
     def get_age_dist(age_data):
         """ Return an age distribution based on provided data """
         if age_data is None:
-            dist = sps.uniform(loc=0, scale=100)  # loc and width
+            dist = ss.uniform(loc=0, scale=100)  # loc and width
             return ss.ScipyDistribution(dist, 'Age distribution')
 
         if sc.checktype(age_data, pd.DataFrame):
@@ -274,6 +268,11 @@ def remove_dead(self, sim):
         uids_to_remove = ss.true(self.dead)
         if len(uids_to_remove):
             self.remove(uids_to_remove)
+
+        # Remove the UIDs from the network too
+        for network in sim.networks.values():
+            network.remove_uids(uids_to_remove)
+
         return
 
     def update_post(self, sim):
@@ -296,11 +295,6 @@ def resolve_deaths(self):
         self.alive[death_uids] = False
         return death_uids
 
-    def update_networks(self):
-        """
-        Update networks
-        """
-        return self.networks.update(self)
 
     @property
     def dead(self):
@@ -323,8 +317,10 @@ def m(self):
         return self.male
 
     def init_results(self, sim):
-        sim.results += ss.Result(None, 'n_alive', sim.npts, ss.int_, scale=True)
-        sim.results += ss.Result(None, 'new_deaths', sim.npts, ss.int_, scale=True)
+        sim.results += [
+            ss.Result(None, 'n_alive', sim.npts, ss.dtypes.int, scale=True),
+            ss.Result(None, 'new_deaths', sim.npts, ss.dtypes.int, scale=True),
+        ]
         return
 
     def update_results(self, sim):
diff --git a/starsim/products.py b/starsim/products.py
index 905536dd..e7b69e57 100644
--- a/starsim/products.py
+++ b/starsim/products.py
@@ -5,10 +5,9 @@
 import starsim as ss
 import sciris as sc
 import numpy as np
-import scipy.stats as sps
 
 
-__all__ = ['Product', 'dx', 'tx']
+__all__ = ['Product', 'dx', 'tx', 'vx']
 
 
 class Product(ss.Module):
@@ -37,7 +36,7 @@ def __init__(self, df, hierarchy=None, *args, **kwargs):
 
         # Create placehold for multinomial sampling
         n_results = len(self.hierarchy)
-        self.result_dist = sps.rv_discrete(values=(np.arange(n_results), 1/n_results*np.ones(n_results)))
+        self.result_dist = ss.rv_discrete(values=(np.arange(n_results), 1/n_results*np.ones(n_results)))
 
     @property
     def default_value(self):
@@ -87,7 +86,7 @@ def __init__(self, df, *args, **kwargs):
         self.df = df
         self.diseases = df.disease.unique()
         self.health_states = df.state.unique()
-        self.efficacy_dist = sps.bernoulli(p=0)
+        self.efficacy_dist = ss.bernoulli(p=0)
         # # TEMP
         # self.efficacy_dist = ss.ScipyDistribution(self.efficacy_dist, f'{self.name}_{self.label}_efficacy_dist')
 
@@ -132,3 +131,20 @@ def administer(self, sim, uids, return_format='dict'):
             output = tx_successful
 
         return output
+
+
+class vx(Product):
+    """ Vaccine product """
+    def __init__(self, diseases=None, pars=None, par_dists=None, *args, **kwargs):
+        pars = ss.omerge({}, pars)
+        par_dists = ss.omerge({}, par_dists)
+        super().__init__(pars, par_dists, *args, **kwargs)
+        self.diseases = sc.tolist(diseases)
+
+    def administer(self, people, uids):
+        """ Apply the vaccine to the requested uids. """
+        pass
+
+
+
+
diff --git a/starsim/results.py b/starsim/results.py
index a88bdeee..8447402f 100644
--- a/starsim/results.py
+++ b/starsim/results.py
@@ -4,9 +4,10 @@
 
 import numpy as np
 import sciris as sc
+import starsim as ss
 
 
-__all__ = ['Result']
+__all__ = ['Result', 'Results']
 
 
 class Result(np.ndarray):
@@ -41,3 +42,35 @@ def __array_wrap__(self, obj, **kwargs):
     
     def to_df(self):
         return sc.dataframe({self.name:self})
+    
+
+class Results(ss.ndict):
+    
+    def __init__(self, module, strict=True, *args, **kwargs):
+        super().__init__(type=Result, strict=strict)
+        if hasattr(module, 'name'):
+            module = module.name
+        self.setattribute('_module', module)
+        return
+    
+    def append(self, arg, key=None):
+        if isinstance(arg, (list, tuple)):
+            result = ss.Result(self._module, *arg)
+        elif isinstance(arg, dict):
+            result = ss.Result(self._module, **arg)
+        else:
+            result = arg
+        if result.module != self._module:
+            result.module = self._module
+        
+        super().append(result, key=key)
+        return
+    
+    def to_df(self):
+        pass
+    
+    def __repr__(self, *args, **kwargs): # TODO: replace with dataframe summary
+        return super().__repr__(*args, **kwargs)
+        
+    def plot(self):
+        pass
diff --git a/starsim/run.py b/starsim/run.py
new file mode 100644
index 00000000..ef26ba76
--- /dev/null
+++ b/starsim/run.py
@@ -0,0 +1,420 @@
+"""
+Utilities for running in parallel
+"""
+
+import numpy as np
+import sciris as sc
+import starsim as ss
+
+__all__ = ['MultiSim', 'single_run', 'multi_run', 'parallel']
+
+
+class MultiSim(sc.prettyobj):
+    """
+    Class for running multiple copies of a simulation.
+    """
+
+    def __init__(self, sims=None, base_sim=None, label=None, initialize=False, *args, **kwargs):
+
+        # Handle inputs
+        super().__init__(*args, **kwargs)
+        if base_sim is None:
+            if isinstance(sims, ss.Sim):
+                base_sim = sims
+                sims = None
+            elif isinstance(sims, list):
+                base_sim = sims[0]
+            else:
+                errormsg = (f'If base_sim is not supplied, sims must be either a single sim'
+                            f' (treated as base_sim) or a list of sims, not {type(sims)}')
+                raise TypeError(errormsg)
+
+        # Set properties
+        self.sims = sims
+        self.base_sim = base_sim
+        self.label = base_sim.label if (label is None and base_sim is not None) else label
+        self.run_args = sc.mergedicts(kwargs)
+        self.results = None
+        self.which = None  # Whether the multisim is to be reduced, combined, etc.
+
+        # Optionally initialize
+        if initialize:
+            self.init_sims()
+
+        return
+
+    def __len__(self):
+        return len(self.sims)
+
+    def init_sims(self, **kwargs):
+        """
+        Initialize the sims
+        """
+
+        # Handle which sims to use
+        if self.sims is None:
+            sims = self.base_sim
+        else:
+            sims = self.sims
+
+        # Initialize the sims but don't run them
+        kwargs = sc.mergedicts(self.run_args, kwargs, {'do_run': False})  # Never run, that's the point!
+        self.sims = multi_run(sims, **kwargs)
+
+        return
+
+    def run(self, reduce=False, **kwargs):
+        """
+        Run the sims
+
+        Args:
+            reduce  (bool): whether to reduce after running (see reduce())
+            kwargs  (dict): passed to multi_run(); use run_args to pass arguments to sim.run()
+
+        Returns:
+            None (modifies MultiSim object in place)
+        """
+
+        # Handle which sims to use -- same as init_sims()
+        if self.sims is None:
+            sims = self.base_sim
+        else:
+            sims = self.sims
+
+            # Handle missing labels
+            for s, sim in enumerate(sims):
+                if sim.label is None:
+                    sim.label = f'Sim {s}'
+
+        # Run
+        kwargs = sc.mergedicts(self.run_args, kwargs)
+        self.sims = multi_run(sims, **kwargs)
+
+        # Reduce
+        if reduce:
+            self.reduce()
+
+        return self
+
+    def _has_orig_sim(self):
+        """ Helper method for determining if an original base sim is present """
+        return hasattr(self, 'orig_base_sim')
+
+    def _rm_orig_sim(self, reset=False):
+        """ Helper method for removing the original base sim, if present """
+        if self._has_orig_sim():
+            if reset:
+                self.base_sim = self.orig_base_sim
+            delattr(self, 'orig_base_sim')
+        return
+
+    def shrink(self, **kwargs):
+        """
+        Not to be confused with reduce(), this shrinks each sim in the msim;
+        see sim.shrink() for more information.
+
+        Args:
+            kwargs (dict): passed to sim.shrink() for each sim
+        """
+        self.base_sim.shrink(**kwargs)
+        self._rm_orig_sim()
+        for sim in self.sims:
+            sim.shrink(**kwargs)
+        return
+
+    def reset(self):
+        """ Undo reduce() by resetting the base sim, which, and results """
+        self._rm_orig_sim(reset=True)
+        self.which = None
+        self.results = None
+        return
+
+    def reduce(self, quantiles=None, use_mean=False, bounds=None, output=False):
+        """
+        Combine multiple sims into a single sim statistically: by default, use
+        the median value and the 10th and 90th percentiles for the lower and upper
+        bounds. If use_mean=True, then use the mean and ±2 standard deviations
+        for lower and upper bounds.
+
+        Args:
+            quantiles (dict): the quantiles to use, e.g. [0.1, 0.9] or {'low : '0.1, 'high' : 0.9}
+            use_mean (bool): whether to use the mean instead of the median
+            bounds (float): if use_mean=True, the multiplier on the standard deviation for upper and lower bounds (default 2)
+            output (bool): whether to return the "reduced" sim (in any case, modify the multisim in-place)
+
+        **Example**::
+
+            msim = ss.MultiSim(ss.Sim())
+            msim.run()
+            msim.reduce()
+            msim.summarize()
+        """
+
+        if use_mean:
+            if bounds is None:
+                bounds = 2
+        else:
+            if quantiles is None:
+                quantiles = {'low': 0.1, 'high': 0.9}
+            if not isinstance(quantiles, dict):
+                try:
+                    quantiles = {'low': float(quantiles[0]), 'high': float(quantiles[1])}
+                except Exception as E:
+                    errormsg = (f'Could not figure out how to convert {quantiles} into a quantiles object:'
+                                f' must be a dict with keys low, high or a 2-element array ({str(E)})')
+                    raise ValueError(errormsg)
+
+        # Store information on the sims
+        n_runs = len(self)
+        reduced_sim = sc.dcp(self.sims[0])
+        reduced_sim.metadata = dict(parallelized=True, combined=False, n_runs=n_runs, quantiles=quantiles,
+                                    use_mean=use_mean, bounds=bounds)  # Store how this was parallelized
+
+        # Perform the statistics
+        raw = {}
+
+        rkeys = reduced_sim.results.keys()
+
+        for rkey in rkeys:
+            raw[rkey] = np.zeros((reduced_sim.res_npts, len(self.sims)))
+            for s, sim in enumerate(self.sims):
+                vals = sim.results[rkey].values
+                raw[rkey][:, s] = vals
+
+        for rkey in rkeys:
+            results = reduced_sim.results
+            if use_mean:
+                r_mean = np.mean(raw[rkey], axis=1)
+                r_std = np.std(raw[rkey], axis=1)
+                results[rkey].values[:] = r_mean
+                results[rkey].low = r_mean - bounds * r_std
+                results[rkey].high = r_mean + bounds * r_std
+            else:
+                results[rkey].values[:] = np.quantile(raw[rkey], q=0.5, axis=1)
+                results[rkey].low = np.quantile(raw[rkey], q=quantiles['low'], axis=1)
+                results[rkey].high = np.quantile(raw[rkey], q=quantiles['high'], axis=1)
+
+        # Compute and store final results
+        reduced_sim.compute_summary()
+        self.orig_base_sim = self.base_sim
+        self.base_sim = reduced_sim
+        self.results = reduced_sim.results
+        self.summary = reduced_sim.summary
+        self.which = 'reduced'
+
+        if output:
+            return self.base_sim
+        else:
+            return
+
+    def mean(self, bounds=None, **kwargs):
+        """
+        Alias for reduce(use_mean=True). See reduce() for full description.
+
+        Args:
+            bounds (float): multiplier on the standard deviation for the upper and lower bounds (default, 2)
+            kwargs (dict): passed to reduce()
+        """
+        return self.reduce(use_mean=True, bounds=bounds, **kwargs)
+
+    def median(self, quantiles=None, **kwargs):
+        """
+        Alias for reduce(use_mean=False). See reduce() for full description.
+
+        Args:
+            quantiles (list or dict): upper and lower quantiles (default, 0.1 and 0.9)
+            kwargs (dict): passed to reduce()
+        """
+        return self.reduce(use_mean=False, quantiles=quantiles, **kwargs)
+
+
+def single_run(sim, ind=0, reseed=True, keep_people=False, run_args=None, sim_args=None,
+               verbose=None, do_run=True, **kwargs):
+    """
+    Convenience function to perform a single simulation run. Mostly used for
+    parallelization, but can also be used directly.
+
+    Args:
+        sim         (Sim)   : the sim instance to be run
+        ind         (int)   : the index of this sim
+        reseed      (bool)  : whether to generate a fresh seed for each run
+        noise       (float) : the amount of noise to add to each run
+        noisepar    (str)   : the name of the parameter to add noise to
+        keep_people (bool)  : whether to keep the people after the sim run
+        run_args    (dict)  : arguments passed to sim.run()
+        sim_args    (dict)  : extra parameters to pass to the sim, e.g. 'n_infected'
+        verbose     (int)   : detail to print
+        do_run      (bool)  : whether to actually run the sim (if not, just initialize it)
+        kwargs      (dict)  : also passed to the sim
+
+    Returns:
+        sim (Sim): a single sim object with results
+
+    **Example**::
+
+        import starsim as ss
+        sim = ss.Sim() # Create a default simulation
+        sim = ss.single_run(sim) # Run it, equivalent(ish) to sim.run()
+    """
+
+    # Set sim and run arguments
+    sim_args = sc.mergedicts(sim_args, kwargs)
+    run_args = sc.mergedicts({'verbose': verbose}, run_args)
+    if verbose is None:
+        verbose = sim.pars['verbose']
+
+    if not sim.label:
+        sim.label = f'Sim {ind}'
+
+    if reseed:
+        sim.pars['rand_seed'] += ind  # Reset the seed, otherwise no point of parallel runs
+        ss.set_seed()
+
+    if verbose >= 1:
+        verb = 'Running' if do_run else 'Creating'
+        print(f'{verb} a simulation using seed={sim["rand_seed"]}')
+
+    # Handle additional arguments
+    for key, val in sim_args.items():
+        print(f'Processing {key}:{val}')
+        if key in sim.pars.keys():
+            if verbose >= 1:
+                print(f'Setting key {key} from {sim[key]} to {val}')
+                sim[key] = val
+        else:
+            raise sc.KeyNotFoundError(f'Could not set key {key}: not a valid parameter name')
+
+    # Run
+    if do_run:
+        sim.run(**run_args)
+
+    # Shrink the sim to save memory
+    if not keep_people:
+        sim.shrink()
+
+    return sim
+
+
+def multi_run(sim, n_runs=4, reseed=None, iterpars=None, keep_people=None, run_args=None, sim_args=None,
+              par_args=None, do_run=True, parallel=True, n_cpus=None, verbose=None, **kwargs):
+    """
+    For running multiple runs in parallel. If the first argument is a list of sims,
+    exactly these will be run and most other arguments will be ignored.
+
+    Args:
+        sim         (Sim)   : the sim instance to be run, or a list of sims.
+        n_runs      (int)   : the number of parallel runs
+        reseed      (bool)  : whether or not to generate a fresh seed for each run (default: true for single, false for list of sims)
+        iterpars    (dict)  : any other parameters to iterate over the runs; see sc.parallelize() for syntax
+        keep_people (bool)  : whether to keep the people after the sim run (default false)
+        run_args    (dict)  : arguments passed to sim.run()
+        sim_args    (dict)  : extra parameters to pass to the sim
+        par_args    (dict)  : arguments passed to sc.parallelize()
+        do_run      (bool)  : whether to actually run the sim (if not, just initialize it)
+        parallel    (bool)  : whether to run in parallel using multiprocessing (else, just run in a loop)
+        n_cpus      (int)   : the number of CPUs to run on (if blank, set automatically; otherwise, passed to par_args)
+        verbose     (int)   : detail to print
+        kwargs      (dict)  : also passed to the sim
+
+    Returns:
+        If combine is True, a single sim object with the combined results from each sim.
+        Otherwise, a list of sim objects (default).
+
+    **Example**::
+
+        import starsim as ss
+        sim = ss.Sim()
+        sims = ss.multi_run(sim, n_runs=6)
+    """
+
+    # Handle inputs
+    sim_args = sc.mergedicts(sim_args, kwargs)  # Handle blank
+    par_args = sc.mergedicts({'ncpus': n_cpus}, par_args)  # Handle blank
+
+    # Handle iterpars
+    if iterpars is None:
+        iterpars = {}
+    else:
+        n_runs = None  # Reset and get from length of dict instead
+        for key, val in iterpars.items():
+            new_n = len(val)
+            if n_runs is not None and new_n != n_runs:
+                raise ValueError(f'Each entry in iterpars must have the same length, not {n_runs} and {len(val)}')
+            else:
+                n_runs = new_n
+
+    # Run the sims
+    if isinstance(sim, ss.Sim):  # One sim
+        if reseed is None: reseed = True
+        iterkwargs = dict(ind=np.arange(n_runs))
+        iterkwargs.update(iterpars)
+        kwargs = dict(sim=sim, reseed=reseed, verbose=verbose, keep_people=keep_people,
+                      sim_args=sim_args, run_args=run_args, do_run=do_run)
+    elif isinstance(sim, list):  # List of sims
+        if reseed is None: reseed = False
+        iterkwargs = dict(sim=sim, ind=np.arange(len(sim)))
+        kwargs = dict(reseed=reseed, verbose=verbose, keep_people=keep_people, sim_args=sim_args, run_args=run_args,
+                      do_run=do_run)
+    else:
+        errormsg = f'Must be Sim object or list, not {type(sim)}'
+        raise TypeError(errormsg)
+
+    # Actually run
+    if parallel:
+        try:
+            sims = sc.parallelize(single_run, iterkwargs=iterkwargs, kwargs=kwargs, **par_args)  # Run in parallel
+        except RuntimeError as E:  # Handle if run outside __main__ on Windows
+            if 'freeze_support' in E.args[0]:  # For this error, add additional information
+                errormsg = '''
+ Uh oh! It appears you are trying to run with multiprocessing on Windows outside
+ of the __main__ block; please see https://docs.python.org/3/library/multiprocessing.html
+ for more information. The correct syntax to use is e.g.
+
+     import starsim as ss
+     sim = ss.Sim()
+     msim = ss.MultiSim(sim)
+
+     if __name__ == '__main__':
+         msim.run()
+
+Alternatively, to run without multiprocessing, set parallel=False.
+ '''
+                raise RuntimeError(errormsg) from E
+            else:  # For all other runtime errors, raise the original exception
+                raise E
+    else:  # Run in serial, not in parallel
+        sims = []
+        n_sims = len(list(iterkwargs.values())[0])  # Must have length >=1 and all entries must be the same length
+        for s in range(n_sims):
+            this_iter = {k: v[s] for k, v in iterkwargs.items()}  # Pull out items specific to this iteration
+            this_iter.update(kwargs)  # Merge with the kwargs
+            this_iter['sim'] = this_iter[
+                'sim'].copy()  # Ensure we have a fresh sim; this happens implicitly on pickling with multiprocessing
+            sim = single_run(**this_iter)  # Run in series
+            sims.append(sim)
+
+    return sims
+
+
+def parallel(*args, **kwargs):
+    """
+    A shortcut to ``hpv.MultiSim()``, allowing the quick running of multiple simulations
+    at once.
+
+    Args:
+        args (list): The simulations to run
+        kwargs (dict): passed to multi_run()
+
+    Returns:
+        A run MultiSim object.
+
+    **Examples**::
+
+        s1 = ss.Sim(beta=0.01, label='Low')
+        s2 = ss.Sim(beta=0.02, label='High')
+        ss.parallel(s1, s2).plot()
+        msim = ss.parallel([s1, s2], keep_people=True)
+    """
+    sims = sc.mergelists(*args)
+    return MultiSim(sims=sims).run(**kwargs)
+
diff --git a/starsim/settings.py b/starsim/settings.py
index 3feebd15..c10fe2a4 100644
--- a/starsim/settings.py
+++ b/starsim/settings.py
@@ -10,8 +10,17 @@
 import sciris as sc
 
 
-__all__ = ['options', 'float_', 'int_']
+__all__ = ['INT_NAN', 'dtypes', 'options']
 
+INT_NAN = 2147483647 # From np.iinfo(np.int32).max: value to use to flag invalid content (i.e., an integer value we are treating like NaN, since NaN can't be stored in an integer array)
+
+# Define Starsim-default data types
+dtypes = sc.objdict(
+    bool = bool,
+    int = np.int64,
+    float = np.float64,
+    result_float = np.float64,
+)
 
 # Not public to avoid confusion with ss.options
 class Options(sc.objdict):
@@ -72,7 +81,7 @@ def get_orig_options():
         options.sep = str(os.getenv('STARSIM_SEP', ','))
 
         optdesc.precision = 'Set arithmetic precision -- 32-bit by default for efficiency'
-        options.precision = int(os.getenv('STARSIM_PRECISION', 32))
+        options.precision = int(os.getenv('STARSIM_PRECISION', 64))
 
         return optdesc, options
 
@@ -151,16 +160,16 @@ def set(self, key=None, value=None, use=False, **kwargs):
                 if value in [None, 'default']:
                     value = self.orig_options[key]
                 self[key] = value
+                
+                # Handle special cases
+                if key == 'precision':
+                    self.reset_precision()
 
         return
 
     def context(self, **kwargs):
         """
-        Alias to set() for non-plotting options, for use in a "with" block.
-
-        Note: for plotting options, use ``ss.options.with_style()``, which is linked
-        to Matplotlib's context manager. If you set plotting options with this,
-        they won't have any effect.
+        Alias to set(), for use in a "with" block.
 
         **Examples**::
 
@@ -175,7 +184,6 @@ def context(self, **kwargs):
             # Use with_style(), not context(), for plotting options
             with ss.options.with_style(dpi=50):
                 ss.Sim().run().plot()
-
         """
 
         # Store current settings
@@ -196,21 +204,19 @@ def changed(self, key):
             return self[key] != self.orig_options[key]
         else:
             return None
+    
+    def set_precision(self):
+        if self.precision == 32:
+            dtypes.int = np.int32
+            dtypes.float = np.float32
+        elif self.precision == 64:
+            dtypes.int = np.int64
+            dtypes.float = np.float64
+        else:
+            errormsg = f'Precision {self.precision} not recognized; must be 32 or 64'
+            raise ValueError(errormsg)
+        return
 
 
-# Create the options on module load, and load the fonts
+# Create the options on module load
 options = Options()
-
-# Default for precision
-# Used in various places throughout the code, generally as:
-#   import stisim.settings as sss
-#   arr = np.full(100, 0, sss.float_)
-result_float = np.float64  # Always use float64 for results, for simplicity
-if options.precision == 32:
-    float_ = np.float32
-    int_ = np.int32
-elif options.precision == 64:  # pragma: no cover
-    float_ = np.float64
-    int_ = np.int64
-else:
-    raise NotImplementedError(f'Precision must be either 32 bit or 64 bit, not {options.precision}')
\ No newline at end of file
diff --git a/starsim/sim.py b/starsim/sim.py
index b8a6bdbb..7b52fee6 100644
--- a/starsim/sim.py
+++ b/starsim/sim.py
@@ -11,43 +11,49 @@
 
 __all__ = ['Sim', 'AlreadyRunError', 'diff_sims']
 
-@nb.njit
+
+@nb.njit(cache=True)
 def set_numba_seed(value):
     # Needed to ensure reproducibility when using random calls in numba, e.g. RandomNetwork
     # Note, these random numbers are not currently common-random-number safe
     np.random.seed(value)
+    return
+
 
 class Sim(sc.prettyobj):
 
-    def __init__(self, pars=None, label=None, people=None, demographics=None, diseases=None, connectors=None, **kwargs):
+    def __init__(self, pars=None, label=None, people=None, demographics=None, diseases=None, networks=None,
+                 connectors=None, interventions=None, analyzers=None, **kwargs):
 
         # Set attributes
         self.label = label  # The label/name of the simulation
         self.created = None  # The datetime the sim was created
         self.people = people  # People object
-        self.demographics  = ss.ndict(demographics, type=ss.DemographicModule)
-        self.diseases      = ss.ndict(diseases, type=ss.Disease)
-        self.connectors    = ss.ndict(connectors, type=ss.Connector)
-        self.results       = ss.ndict(type=ss.Result)  # For storing results
-        self.summary       = None  # For storing a summary of the results
-        self.initialized   = False  # Whether initialization is complete
-        self.complete      = False  # Whether a simulation has completed running # TODO: replace with finalized?
+        self.results = ss.Results(module='sim')  # For storing results
+        self.summary = None  # For storing a summary of the results
+        self.initialized = False  # Whether initialization is complete
+        self.complete = False  # Whether a simulation has completed running # TODO: replace with finalized?
         self.results_ready = False  # Whether results are ready
-        self.filename      = None
+        self.filename = None
 
         # Time indexing
-        self.ti      = None  # The time index, e.g. 0, 1, 2 # TODO: do we need all of these?
+        self.ti = None  # The time index, e.g. 0, 1, 2 # TODO: do we need all of these?
         self.yearvec = None
-        self.tivec   = None
-        self.npts    = None
+        self.tivec = None
+        self.npts = None
 
         # Make default parameters (using values from parameters.py)
         self.pars = ss.make_pars()  # Start with default pars
         self.pars.update_pars(sc.mergedicts(pars, kwargs))  # Update the parameters
 
-        # Initialize other quantities
-        self.interventions = ss.ndict(type=ss.Intervention)
-        self.analyzers = ss.ndict(type=ss.Analyzer)
+        # Placeholders for plug-ins: demographics, diseases, connectors, analyzers, and interventions
+        # Products are not here because they are stored within interventions
+        self.demographics = ss.ndict(demographics, type=ss.BaseDemographics)
+        self.diseases = ss.ndict(diseases, type=ss.Disease)
+        self.networks = ss.ndict(networks, type=ss.Network)
+        self.connectors = ss.ndict(connectors, type=ss.Connector)
+        self.interventions = ss.ndict(interventions, type=ss.Intervention)
+        self.analyzers = ss.ndict(analyzers, type=ss.Analyzer)
 
         # Initialize the random number generator container
         self.rng_container = ss.RNGContainer()
@@ -56,19 +62,26 @@ def __init__(self, pars=None, label=None, people=None, demographics=None, diseas
 
     @property
     def dt(self):
-        return self.pars['dt']
+        if 'dt' in self.pars:
+            return self.pars['dt']
+        else:
+            return np.nan
 
     @property
     def year(self):
-        return self.yearvec[self.ti]
+        try:
+            return self.yearvec[self.ti]
+        except:
+            return np.nan
 
     @property
     def modules(self):
         # Return iterator over all Module instances (stored in standard places) in the Sim
-        products = [intv.product for intv in self.interventions.values() if hasattr(intv, 'product') and isinstance(intv.product, ss.Product)]
+        products = [intv.product for intv in self.interventions.values() if
+                    hasattr(intv, 'product') and isinstance(intv.product, ss.Product)]
         return itertools.chain(
             self.demographics.values(),
-            self.people.networks.values(),
+            self.networks.values(),
             self.diseases.values(),
             self.connectors.values(),
             self.interventions.values(),
@@ -76,7 +89,7 @@ def modules(self):
             self.analyzers.values(),
         )
 
-    def initialize(self, popdict=None, reset=False, **kwargs):
+    def initialize(self, reset=False, **kwargs):
         """
         Perform all initializations on the sim.
         """
@@ -85,12 +98,14 @@ def initialize(self, popdict=None, reset=False, **kwargs):
         self.validate_pars()  # Ensure parameters have valid values
         self.validate_dt()
         self.init_time_vecs()  # Initialize time vecs
-        ss.set_seed(self.pars['rand_seed'])  # Reset the random seed before the population is created
-        set_numba_seed(self.pars['rand_seed'])
+        ss.set_seed(self.pars.rand_seed)  # Reset the random seed before the population is created
+        set_numba_seed(self.pars.rand_seed)
 
         # Initialize the core sim components
-        self.rng_container.initialize(self.pars['rand_seed'] + 2) # +2 ensures that seeds from the above population initialization and the +1-offset below are not reused within the rng_container
+        self.rng_container.initialize(self.pars.rand_seed + 2)  # +2 ensures that seeds from the above population initialization and the +1-offset below are not reused within the rng_container
         self.init_people(reset=reset, **kwargs)  # Create all the people (the heaviest step)
+
+        # Initialize plug-ins
         self.init_demographics()
         self.init_networks()
         self.init_diseases()
@@ -103,7 +118,7 @@ def initialize(self, popdict=None, reset=False, **kwargs):
 
         # Reset the random seed to the default run seed, so that if the simulation is run with
         # reset_seed=False right after initialization, it will still produce the same output
-        ss.set_seed(self.pars['rand_seed'] + 1) # Hopefully not used now that we can use multiple random number generators
+        ss.set_seed(self.pars.rand_seed + 1)  # Hopefully not used now that we can use multiple random number generators
 
         # Final steps
         self.initialized = True
@@ -123,8 +138,8 @@ def validate_dt(self):
             # Round the reciprocal
             reciprocal = int(reciprocal)
             rounded_dt = 1.0 / reciprocal
-            self.pars['dt'] = rounded_dt
-            if self.pars['verbose']:
+            self.pars.dt = rounded_dt
+            if self.pars.verbose:
                 warnmsg = f"Warning: Provided time step dt: {dt} resulted in a non-integer number of steps per year. Rounded to {rounded_dt}."
                 print(warnmsg)
         return
@@ -135,34 +150,34 @@ def validate_pars(self):
         """
         # Handle n_agents
         if self.people is not None:
-            self.pars['n_agents'] = len(self.people)
-        #elif self.popdict is not None: # Starsim does not currenlty support self.popdict
-            #self.pars['n_agents'] = len(self.popdict)
-        elif self.pars['n_agents'] is not None:
-            self.pars['n_agents'] = int(self.pars['n_agents'])
+            self.pars.n_agents = len(self.people)
+        # elif self.popdict is not None: # Starsim does not currenlty support self.popdict
+        # self.pars.n_agents = len(self.popdict)
+        elif self.pars.n_agents is not None:
+            self.pars.n_agents = int(self.pars.n_agents)
         else:
             errormsg = 'Must supply n_agents, a people object, or a popdict'
             raise ValueError(errormsg)
 
         # Handle end and n_years
-        if self.pars['end']:
-            self.pars['n_years'] = int(self.pars['end'] - self.pars['start'])
-            if self.pars['n_years'] <= 0:
-                errormsg = f"Number of years must be >0, but you supplied start={str(self.pars['start'])} and " \
-                           f"end={str(self.pars['end'])}, which gives n_years={self.pars['n_years']}"
+        if self.pars.end:
+            self.pars.n_years = int(self.pars.end - self.pars.start)
+            if self.pars.n_years <= 0:
+                errormsg = f"Number of years must be >0, but you supplied start={str(self.pars.start)} and " \
+                           f"end={str(self.pars.end)}, which gives n_years={self.pars.n_years}"
                 raise ValueError(errormsg)
         else:
-            if self.pars['n_years']:
-                self.pars['end'] = self.pars['start'] + self.pars['n_years']
+            if self.pars.n_years:
+                self.pars.end = self.pars.start + self.pars.n_years
             else:
                 errormsg = 'You must supply one of n_years and end."'
                 raise ValueError(errormsg)
 
         # Handle verbose
-        if self.pars['verbose'] == 'brief':
-            self.pars['verbose'] = -1
-        if not sc.isnumber(self.pars['verbose']):  # pragma: no cover
-            errormsg = f'Verbose argument should be either "brief", -1, or a float, not {type(self.pars["verbose"])} "{self.pars["verbose"]}"'
+        if self.pars.verbose == 'brief':
+            self.pars.verbose = -1
+        if not sc.isnumber(self.pars.verbose):  # pragma: no cover
+            errormsg = f'Verbose argument should be either "brief", -1, or a float, not {type(self.par.verbose)} "{self.par.verbose}"'
             raise ValueError(errormsg)
 
         return
@@ -171,8 +186,8 @@ def init_time_vecs(self):
         """
         Construct vectors things that keep track of time
         """
-        self.yearvec = sc.inclusiverange(start=self.pars['start'], stop=self.pars['end'] + 1 - self.pars['dt'],
-                                         step=self.pars['dt'])  # Includes all the timepoints in the last year
+        self.yearvec = sc.inclusiverange(start=self.pars.start, stop=self.pars.end + 1 - self.pars.dt,
+                                         step=self.pars.dt)  # Includes all the timepoints in the last year
         self.npts = len(self.yearvec)
         self.tivec = np.arange(self.npts)
         return
@@ -190,7 +205,7 @@ def init_people(self, reset=False, verbose=None, **kwargs):
 
         # Handle inputs
         if verbose is None:
-            verbose = self.pars['verbose']
+            verbose = self.pars.verbose
         if verbose > 0:
             resetstr = ''
             if self.people and reset:
@@ -199,27 +214,27 @@ def init_people(self, reset=False, verbose=None, **kwargs):
 
         # If people have not been supplied, make them
         if self.people is None or reset:
-            self.people = ss.People(n=self.pars['n_agents'], **kwargs)  # This just assigns UIDs and length
+            self.people = ss.People(n_agents=self.pars.n_agents, **kwargs)  # This just assigns UIDs and length
 
         # If a popdict has not been supplied, we can make one from location data
-        if self.pars['location'] is not None:
+        if self.pars.location is not None:
             # Check where to get total_pop from
-            if self.pars['total_pop'] is not None:  # If no pop_scale has been provided, try to get it from the location
+            if self.pars.total_pop is not None:  # If no pop_scale has been provided, try to get it from the location
                 errormsg = 'You can either define total_pop explicitly or via the location, but not both'
                 raise ValueError(errormsg)
 
         else:
-            if self.pars['total_pop'] is not None:  # If no pop_scale has been provided, try to get it from the location
-                total_pop = self.pars['total_pop']
+            if self.pars.total_pop is not None:  # If no pop_scale has been provided, try to get it from the location
+                total_pop = self.pars.total_pop
             else:
-                if self.pars['pop_scale'] is not None:
-                    total_pop = self.pars['pop_scale'] * self.pars['n_agents']
+                if self.pars.pop_scale is not None:
+                    total_pop = self.pars.pop_scale * self.pars.n_agents
                 else:
-                    total_pop = self.pars['n_agents']
+                    total_pop = self.pars.n_agents
 
-        self.pars['total_pop'] = total_pop
-        if self.pars['pop_scale'] is None:
-            self.pars['pop_scale'] = total_pop / self.pars['n_agents']
+        self.pars.total_pop = total_pop
+        if self.pars.pop_scale is None:
+            self.pars.pop_scale = total_pop / self.pars.n_agents
 
         # Any other initialization
         if not self.people.initialized:
@@ -232,14 +247,102 @@ def init_people(self, reset=False, verbose=None, **kwargs):
         self.people.init_results(self)
         return self
 
+    def convert_plugins(self, plugin_class, plugin_name=None):
+        """
+        Common logic for converting plug-ins to a standard format
+        Used for networks, demographics, diseases, connectors, analyzers, and interventions
+        Args:
+            plugin: class
+        """
+
+        if plugin_name is None: plugin_name = plugin_class.__name__.lower()
+
+        # Get lower-case names of all subclasses
+        known_plugins = {n.__name__.lower():n for n in ss.all_subclasses(plugin_class)}
+        if plugin_name == 'networks': # Allow "msm" or "msmnet"
+            known_plugins.update({k.removesuffix('net'):v for k,v in known_plugins.items()})
+
+        # Figure out if it's in the sim pars or provided directly
+        attr_plugins = getattr(self, plugin_name)  # Get any plugins that have been provided directly
+        if attr_plugins is None or len(attr_plugins) == 0:  # None have been provided directly
+
+            # See if they've been provided in the pars dict
+            if self.pars.get(plugin_name):
+
+                par_plug = self.pars[plugin_name]
+
+                # String: convert to ndict
+                if isinstance(par_plug, str):
+                    plugins = ss.ndict(dict(name=par_plug))
+
+                # List or dict: convert to ndict
+                elif sc.isiterable(par_plug) and len(par_plug):
+                    if isinstance(par_plug, dict) and 'type' in par_plug and 'name' not in par_plug:
+                        par_plug['name'] = par_plug['type'] # TODO: simplify/remove this
+                    plugins = ss.ndict(par_plug)
+
+            else:  # Not provided directly or in pars
+                plugins = {}
+        else:
+            plugins = attr_plugins
+
+        processed_plugins = sc.autolist()
+        for plugin in plugins.values():
+
+            if not isinstance(plugin, plugin_class):
+
+                if isinstance(plugin, dict):
+                    ptype = (plugin.get('type') or plugin.get('name') or '').lower()
+                    name = plugin.get('name') or ptype
+                    if ptype in known_plugins:
+                        # Make an instance of the requested plugin
+                        plugin_pars = {k: v for k, v in plugin.items() if k not in ['type', 'name']}
+                        pclass = known_plugins[ptype]
+                        plugin = pclass(name=name, pars=plugin_pars) # TODO: does this handle par_dists, etc?
+                    else:
+                        errormsg = (f'Could not convert {plugin} to an instance of class {plugin_name}.'
+                                    f'Try specifying it directly rather than as a dictionary.')
+                        raise ValueError(errormsg)
+                else:
+                    errormsg = (
+                        f'{plugin_name.capitalize()} must be provided as either class instances or dictionaries with a '
+                        f'"name" key corresponding to one of these known subclasses: {known_plugins}.')
+                    raise ValueError(errormsg)
+
+            processed_plugins += plugin
+
+        return processed_plugins
+
     def init_demographics(self):
-        for module in self.demographics.values():
-            module.initialize(self)
-            self.results[module.name] = module.results
+        """ Initialize demographics """
+
+        # Demographics can be provided via sim.demographics or sim.pars - this methods reconciles them
+        demographics = self.convert_plugins(ss.BaseDemographics, plugin_name='demographics')
+
+        # We also allow users to add vital dynamics by entering birth_rate and death_rate parameters directly to the sim
+        if self.pars.birth_rate is not None:
+            births = ss.Births(pars={'birth_rate': self.pars.birth_rate})
+            demographics += births
+        if self.pars.death_rate is not None:
+            background_deaths = ss.Deaths(pars={'death_rate': self.pars.death_rate})
+            demographics += background_deaths
+
+        # Iterate over demographic modules and initialize them
+        for dem_mod in demographics:
+            dem_mod.initialize(self)
+            self.results[dem_mod.name] = dem_mod.results
+
+        # Ensure they're stored at the sim level
+        self.demographics = ss.ndict(*demographics)
 
     def init_diseases(self):
-        """ Initialize modules and connectors to be simulated """
-        for disease in self.diseases.values():
+        """ Initialize diseases """
+
+        # Diseases can be provided in sim.demographics or sim.pars
+        diseases = self.convert_plugins(ss.Disease, plugin_name='diseases')
+
+        # Interate over diseases and initialize them
+        for disease in diseases:
             disease.initialize(self)
 
             # Add the disease's parameters and results into the Sim's dicts
@@ -249,6 +352,9 @@ def init_diseases(self):
             # Add disease states to the People's dicts
             self.people.add_module(disease)
 
+        # Store diseases in the sim
+        self.diseases = ss.ndict(*diseases)
+
         return
 
     def init_connectors(self):
@@ -258,46 +364,34 @@ def init_connectors(self):
     def init_networks(self):
         """ Initialize networks if these have been provided separately from the people """
 
-        # One possible workflow is that users will provide a location and a set of networks but not people.
-        # This means networks will be stored in self.pars['networks'] and we'll need to copy them to the people.
-        if self.people.networks is None or len(self.people.networks) == 0:
-            if self.pars['networks'] is not None:
-                self.people.networks = ss.Networks(self.pars['networks'])
-
-        if not isinstance(self.people.networks, ss.Networks):
-            self.people.networks = ss.Networks(networks=self.people.networks)
-
-        self.people.networks.initialize(self)
+        processed_networks = self.convert_plugins(ss.Network, plugin_name='networks')
 
-        # for key, network in self.people.networks.networks.items():  # TODO rename
-            # if network.label is not None:
-            #     layer_name = network.label
-            # else:
-            #     layer_name = key
-            #     network.label = layer_name
-            # network.initialize(self)
-
-            # Add network states to the People's dicts
-            # self.people.add_module(network)
-            # self.people.networks[network.name] = network
+        # Now store the networks in a Networks object, which also allows for connectors between networks
+        if not isinstance(processed_networks, ss.Networks):
+            self.networks = ss.Networks(*processed_networks)
+        self.networks.initialize(self)
 
         return
 
     def init_interventions(self):
         """ Initialize and validate the interventions """
 
+        interventions = self.convert_plugins(ss.Intervention, plugin_name='interventions')
+
         # Translate the intervention specs into actual interventions
-        for i, intervention in enumerate(self.pars['interventions']):
+        for i, intervention in enumerate(interventions):
             if isinstance(intervention, type) and issubclass(intervention, ss.Intervention):
                 intervention = intervention()  # Convert from a class to an instance of a class
             if isinstance(intervention, ss.Intervention):
                 intervention.initialize(self)
-                self.interventions += intervention
             elif callable(intervention):
-                self.interventions += intervention
+                pass # TODO: check if this fails with a plain function (it should?)
+                # self.interventions += intervention 
             else:
                 errormsg = f'Intervention {intervention} does not seem to be a valid intervention: must be a function or Intervention subclass'
                 raise TypeError(errormsg)
+            if intervention.name not in self.interventions:
+                self.interventions += intervention
 
             # Add the intervention parameters and results into the Sim's dicts
             self.pars[intervention.name] = intervention.pars
@@ -313,6 +407,7 @@ def init_interventions(self):
             # If there's a product module present, initialize and add it
             if hasattr(intervention, 'product') and isinstance(intervention.product, ss.Product):
                 intervention.product.initialize(self)
+
                 self.people.add_module(intervention.product)
                 for rng in intervention.product.rngs:
                     rng.initialize(self.rng_container, self.people.slot)
@@ -323,7 +418,7 @@ def init_analyzers(self):
         """ Initialize the analyzers """
 
         # Interpret analyzers
-        for ai, analyzer in enumerate(self.pars['analyzers']):
+        for ai, analyzer in enumerate(self.pars.analyzers):
             if isinstance(analyzer, type) and issubclass(analyzer, ss.Analyzer):
                 analyzer = analyzer()  # Convert from a class to an instance of a class
             if not (isinstance(analyzer, ss.Analyzer) or callable(analyzer)):
@@ -343,7 +438,7 @@ def validate_post_init(self):
         TBC whether we keep this or incorporate the checks into the init methods
         """
         # Make sure that there's a contact network if any diseases are present
-        if self.diseases and not self.people.networks:
+        if self.diseases and not self.networks:
             warnmsg = f'Warning: simulation has {len(self.diseases)} diseases but no contact network(s).'
             ss.warn(warnmsg, die=False)
         return
@@ -356,7 +451,7 @@ def step(self):
             raise AlreadyRunError('Simulation already complete (call sim.initialize() to re-run)')
 
         # Advance random number generators forward to prepare for any random number calls that may be necessary on this step
-        self.rng_container.step(self.ti+1) # +1 offset because ti=0 is used on initialization
+        self.rng_container.step(self.ti + 1)  # +1 offset because ti=0 is used on initialization
 
         # Clean up dead agents, if removing agents is enabled
         if self.pars.remove_dead:
@@ -377,7 +472,7 @@ def step(self):
 
         # Update networks - this takes place here in case autonomous state changes at this timestep
         # affect eligibility for contacts
-        self.people.update_networks()
+        self.networks.update(self.people)
 
         # Apply interventions - new changes to contacts will be visible and so the final networks can be customized by
         # interventions, by running them at this point
@@ -423,10 +518,10 @@ def run(self, until=None, reset_seed=True, verbose=None):
             self._orig_pars = sc.dcp(self.pars)  # Create a copy of the parameters to restore after the run
 
         if verbose is None:
-            verbose = self.pars['verbose']
+            verbose = self.pars.verbose
 
         if reset_seed:
-            ss.set_seed(self.pars['rand_seed'] + 1)
+            ss.set_seed(self.pars.rand_seed + 1)
 
         # Check for AlreadyRun errors
         errormsg = None
@@ -477,7 +572,7 @@ def finalize(self, verbose=None):
         # Scale the results
         for reskey, res in self.results.items():
             if isinstance(res, ss.Result) and res.scale:
-                self.results[reskey] = self.results[reskey]*self.pars.pop_scale
+                self.results[reskey] = self.results[reskey] * self.pars.pop_scale
 
         for module in self.modules:
             module.finalize(self)
@@ -486,15 +581,14 @@ def finalize(self, verbose=None):
         self.results_ready = True  # Set this first so self.summary() knows to print the results
         self.ti -= 1  # During the run, this keeps track of the next step; restore this be the final day of the sim
         return
-    
+
     def summarize(self):
         summary = sc.objdict()
         flat = sc.flattendict(self.results, sep='_')
-        for k,v in flat.items():
+        for k, v in flat.items():
             summary[k] = v.mean()
         self.summary = summary
         return summary
-        
 
     def shrink(self, skip_attrs=None, in_place=True):
         """
@@ -533,14 +627,14 @@ def _get_ia(self, which, label=None, partial=False, as_list=False, as_inds=False
         """ Helper method for get_interventions() and get_analyzers(); see get_interventions() docstring """
 
         # Handle inputs
-        if which not in ['interventions', 'analyzers']: # pragma: no cover
+        if which not in ['interventions', 'analyzers']:  # pragma: no cover
             errormsg = f'This method is only defined for interventions and analyzers, not "{which}"'
             raise ValueError(errormsg)
 
-        ia_ndict = self.analyzers if which == 'analyzers' else self.interventions # List of interventions or analyzers
+        ia_ndict = self.analyzers if which == 'analyzers' else self.interventions  # List of interventions or analyzers
         n_ia = len(ia_ndict)  # Number of interventions/analyzers
 
-        position = 0 if first else -1 # Choose either the first or last element
+        position = 0 if first else -1  # Choose either the first or last element
         if label is None:  # Get all interventions if no label is supplied, e.g. sim.get_interventions()
             label = np.arange(n_ia)
         if isinstance(label, np.ndarray):  # Allow arrays to be provided
@@ -564,24 +658,25 @@ def _get_ia(self, which, label=None, partial=False, as_list=False, as_inds=False
                     elif isinstance(label, type) and isinstance(ia_obj, label):
                         matches.append(ia_obj)
                         match_inds.append(ind)
-            else: # pragma: no cover
+            else:  # pragma: no cover
                 errormsg = f'Could not interpret label type "{type(label)}": should be str, int, list, or {which} class'
                 raise TypeError(errormsg)
 
         # Parse the output options
         if as_inds:
             output = match_inds
-        elif as_list: # Used by get_interventions()
+        elif as_list:  # Used by get_interventions()
             output = matches
         else:
-            if len(matches) == 0: # pragma: no cover
+            if len(matches) == 0:  # pragma: no cover
                 if die:
                     errormsg = f'No {which} matching "{label}" were found'
                     raise ValueError(errormsg)
                 else:
                     output = None
             else:
-                output = matches[position] # Return either the first or last match (usually), used by get_intervention()
+                output = matches[
+                    position]  # Return either the first or last match (usually), used by get_intervention()
 
         return output
 
@@ -598,7 +693,6 @@ def get_interventions(self, label=None, partial=False, as_inds=False):
         """
         return self._get_ia('interventions', label=label, partial=partial, as_inds=as_inds, as_list=True)
 
-
     def get_intervention(self, label=None, partial=False, first=False, die=True):
         """
         Find the matching intervention(s) by label, index, or type.
@@ -611,7 +705,8 @@ def get_intervention(self, label=None, partial=False, first=False, die=True):
             first (bool): if true, return first matching intervention (otherwise, return last)
             die (bool): whether to raise an exception if no intervention is found
         """
-        return self._get_ia('interventions', label=label, partial=partial, first=first, die=die, as_inds=False, as_list=False)
+        return self._get_ia('interventions', label=label, partial=partial, first=first, die=die, as_inds=False,
+                            as_list=False)
 
     def save(self, filename=None, keep_people=None, skip_attrs=None, **kwargs):
         """
@@ -742,7 +837,7 @@ def to_json(self, filename=None, keys=None, tostring=False, indent=2, verbose=Fa
                     d['short_summary'] = dict(sc.dcp(self.short_summary))
                 else:
                     d['short_summary'] = 'Full summary not available (Sim has not yet been run)'
-            else: # pragma: no cover
+            else:  # pragma: no cover
                 try:
                     d[key] = sc.sanitizejson(getattr(self, key))
                 except Exception as E:
@@ -755,15 +850,15 @@ def to_json(self, filename=None, keys=None, tostring=False, indent=2, verbose=Fa
             output = sc.savejson(filename=filename, obj=d, indent=indent, *args, **kwargs)
 
         return output
-    
+
     def plot(self):
         flat = sc.flattendict(self.results, sep=': ')
         fig, axs = sc.getrowscols(len(flat), make=True)
-        for ax,(k,v) in zip(axs.flatten(), flat.items()):
+        for ax, (k, v) in zip(axs.flatten(), flat.items()):
             ax.plot(v)
             ax.set_title(k)
         return fig
-            
+
 
 class AlreadyRunError(RuntimeError):
     """
@@ -792,9 +887,9 @@ def diff_sims(sim1, sim2, skip_key_diffs=False, skip=None, full=False, output=Fa
 
     **Example**::
 
-        s1 = hpv.Sim(rand_seed=1).run()
-        s2 = hpv.Sim(rand_seed=2).run()
-        hpv.diff_sims(s1, s2)
+        s1 = ss.Sim(rand_seed=1).run()
+        s2 = ss.Sim(rand_seed=2).run()
+        ss.diff_sims(s1, s2)
     '''
 
     if isinstance(sim1, Sim):
@@ -802,7 +897,7 @@ def diff_sims(sim1, sim2, skip_key_diffs=False, skip=None, full=False, output=Fa
     if isinstance(sim2, Sim):
         sim2 = sim2.summarize()
     for sim in [sim1, sim2]:
-        if not isinstance(sim, dict): # pragma: no cover
+        if not isinstance(sim, dict):  # pragma: no cover
             errormsg = f'Cannot compare object of type {type(sim)}, must be a sim or a sim.summary dict'
             raise TypeError(errormsg)
 
@@ -810,10 +905,10 @@ def diff_sims(sim1, sim2, skip_key_diffs=False, skip=None, full=False, output=Fa
     keymatchmsg = ''
     sim1_keys = set(sim1.keys())
     sim2_keys = set(sim2.keys())
-    if sim1_keys != sim2_keys and not skip_key_diffs: # pragma: no cover
+    if sim1_keys != sim2_keys and not skip_key_diffs:  # pragma: no cover
         keymatchmsg = "Keys don't match!\n"
         missing = list(sim1_keys - sim2_keys)
-        extra   = list(sim2_keys - sim1_keys)
+        extra = list(sim2_keys - sim1_keys)
         if missing:
             keymatchmsg += f'  Missing sim1 keys: {missing}\ns'
         if extra:
@@ -823,8 +918,8 @@ def diff_sims(sim1, sim2, skip_key_diffs=False, skip=None, full=False, output=Fa
     valmatchmsg = ''
     mismatches = {}
     skip = sc.tolist(skip)
-    for key in sim2.keys(): # To ensure order
-        if key in sim1_keys and key not in skip: # If a key is missing, don't count it as a mismatch
+    for key in sim2.keys():  # To ensure order
+        if key in sim1_keys and key not in skip:  # If a key is missing, don't count it as a mismatch
             sim1_val = sim1[key] if key in sim1 else 'not present'
             sim2_val = sim2[key] if key in sim2 else 'not present'
             if not np.isclose(sim1_val, sim2_val, equal_nan=True):
@@ -833,21 +928,21 @@ def diff_sims(sim1, sim2, skip_key_diffs=False, skip=None, full=False, output=Fa
     if len(mismatches):
         valmatchmsg = '\nThe following values differ between the two simulations:\n'
         df = sc.dataframe.from_dict(mismatches).transpose()
-        diff   = []
-        ratio  = []
+        diff = []
+        ratio = []
         change = []
-        small_change = 1e-3 # Define a small change, e.g. a rounding error
+        small_change = 1e-3  # Define a small change, e.g. a rounding error
         for mdict in mismatches.values():
             old = mdict['sim1']
             new = mdict['sim2']
             numeric = sc.isnumber(sim1_val) and sc.isnumber(sim2_val)
-            if numeric and old>0:
-                this_diff  = new - old
-                this_ratio = new/old
-                abs_ratio  = max(this_ratio, 1.0/this_ratio)
+            if numeric and old > 0:
+                this_diff = new - old
+                this_ratio = new / old
+                abs_ratio = max(this_ratio, 1.0 / this_ratio)
 
                 # Set the character to use
-                if abs_ratio<small_change:
+                if abs_ratio < small_change:
                     change_char = '≈'
                 elif new > old:
                     change_char = '↑'
@@ -866,10 +961,10 @@ def diff_sims(sim1, sim2, skip_key_diffs=False, skip=None, full=False, output=Fa
                 if abs_ratio >= 10:
                     repeats = 4
 
-                this_change = change_char*repeats
-            else: # pragma: no cover
-                this_diff   = np.nan
-                this_ratio  = np.nan
+                this_change = change_char * repeats
+            else:  # pragma: no cover
+                this_diff = np.nan
+                this_ratio = np.nan
                 this_change = 'N/A'
 
             diff.append(this_diff)
@@ -885,7 +980,7 @@ def diff_sims(sim1, sim2, skip_key_diffs=False, skip=None, full=False, output=Fa
 
     # Raise an error if mismatches were found
     mismatchmsg = keymatchmsg + valmatchmsg
-    if mismatchmsg: # pragma: no cover
+    if mismatchmsg:  # pragma: no cover
         if die:
             raise ValueError(mismatchmsg)
         elif output:
diff --git a/starsim/states.py b/starsim/states.py
index fce06231..49096bb2 100644
--- a/starsim/states.py
+++ b/starsim/states.py
@@ -1,64 +1,98 @@
-import pandas as pd
+"""
+Define array-handling classes, including agent states
+"""
+
 import numpy as np
 import sciris as sc
 import numba as nb
-from starsim.utils import INT_NAN
-from starsim.distributions import ScipyDistribution
-from starsim.utils import warn
+import starsim as ss
+from starsim.settings import INT_NAN
+from starsim.settings import dtypes as sdt
 from numpy.lib.mixins import NDArrayOperatorsMixin  # Inherit from this to automatically gain operators like +, -, ==, <, etc.
 from scipy.stats._distn_infrastructure import rv_frozen
 
-__all__ = ['State', 'DynamicView']
-
 
+__all__ = ['check_dtype', 'State', 'ArrayView']
 
-class FusedArray(NDArrayOperatorsMixin):
-    # This is a class that allows indexing by UID but does not support dynamic growth
-    # It's kind of like a Pandas series but one that only supports a monotonically increasing
-    # unique integer index, and that we can customize and optimize indexing for.
-    #
-    # We explictly do NOT support slicing (except for `[:]`), as these arrays are indexed by UID and slicing
-    # by UID can be confusing/ambiguous when there are missing values. Indexing with a list/array returns
-    # another FusedArray instance which enables chained filtering
 
-    __slots__ = ('values','_uid_map', 'uid')
-
-    def __init__(self, values, uid, uid_map=None):
+def check_dtype(dtype, default=None):
+    """ Check that the supplied dtype is one of the supported options """
+    
+    # Handle dtype
+    if dtype is None:
+        if default is None:
+            errormsg = 'Must supply either a dtype or a default value'
+            raise ValueError(errormsg)
+        else:
+            dtype = type(default)
+    
+    if dtype in ['float', float, np.float64, np.float32]:
+        dtype = sdt.float
+    elif dtype in ['int', int, np.int64, np.int32]:
+        dtype = sdt.int
+    elif dtype in ['bool', bool, np.bool_]:
+        dtype = sdt.bool
+    else:
+        warnmsg = f'Data type {type(default)} not a supported data type; set warn=False to suppress warning'
+        ss.warn(warnmsg)
+    
+    return dtype
+
+
+class UIDArray(NDArrayOperatorsMixin):
+    """
+    This is a class that allows indexing by UID but does not support dynamic growth
+    It's kind of like a Pandas series but one that only supports a monotonically increasing
+    unique integer index, and that we can customize and optimize indexing for.
+    
+    We explictly do NOT support slicing (except for `[:]`), as these arrays are indexed by UID and slicing
+    by UID can be confusing/ambiguous when there are missing values. Indexing with a list/array returns
+    another UIDArray instance which enables chained filtering
+    """
+
+    __slots__ = ('values', '_uid_map', 'uid')
+
+    def __init__(self, values=None, uid=None, uid_map=None):
 
         self.values = values
         self.uid = uid
 
         if uid_map is None and uid is not None:
             # Construct a local UID map as opposed to using a shared one (i.e., the one for all agents contained in the People instance)
-            self.uid_map = np.full(np.max(uid) + 1, fill_value=INT_NAN, dtype=int)
+            self.uid_map = np.full(np.max(uid) + 1, fill_value=ss.INT_NAN, dtype=sdt.int)
             self.uid_map[uid] = np.arange(len(uid))
         else:
             self._uid_map = uid_map
+        return
 
     def __repr__(self):
-        # TODO - optimize? Don't really need to create a dataframe just to print it, but on the other hand, it's fast enough and very easy
-        df = pd.DataFrame(self.values.T, index=self.uid, columns=['Quantity'])
-        df.index.name = 'UID'
+        df = self.to_df()
         return df.__repr__()
+    
+    def to_df(self):
+        """ Convert to a dataframe """
+        df = sc.dataframe({'Quantity':self.values.T}, index=self.uid)
+        df.index.name = 'UID'
+        return df
 
     @property
     def dtype(self):
         return self.values.dtype
 
     @staticmethod
-    @nb.njit
+    @nb.njit(cache=True)
     def _get_vals_uids(vals, key, uid_map):
         """
         Extract values from a collection of UIDs
 
-        This function is used to retrieve values based on UID. As indexing a FusedArray returns a new FusedArray,
-        this method also populates the new UID map for use in the subsequently created FusedArray, avoiding the
+        This function is used to retrieve values based on UID. As indexing a UIDArray returns a new UIDArray,
+        this method also populates the new UID map for use in the subsequently created UIDArray, avoiding the
         need to re-compute it separately.
 
         :param vals: A 1D np.ndarray containing the values
         :param key: A 1D np.ndnarray of integers containing the UIDs to query
         :param uid_map: A 1D np.ndarray of integers mapping UID to array position in ``vals``
-        :return: A tuple of (values, uids, new_uid_map) suitable for passing into the FusedArray constructor
+        :return: A tuple of (values, uids, new_uid_map) suitable for passing into the UIDArray constructor
         """
         out = np.empty(len(key), dtype=vals.dtype)
         new_uid_map = np.full(uid_map.shape[0], fill_value=INT_NAN, dtype=np.int64)
@@ -72,7 +106,7 @@ def _get_vals_uids(vals, key, uid_map):
         return out, key, new_uid_map
 
     @staticmethod
-    @nb.njit
+    @nb.njit(cache=True)
     def _set_vals_uids_multiple(vals, key, uid_map, value):
         """
         Insert an array of values based on UID
@@ -86,16 +120,19 @@ def _set_vals_uids_multiple(vals, key, uid_map, value):
 
         for i in range(len(key)):
             if key[i] >= len(uid_map):
-                raise IndexError('UID not present in array (requested UID is larger than the maximum UID in use)')
+                errormsg = f'UID not present in array (requested UID ({key[i]}) is larger than the maximum UID in use ({len(uid_map)}))'
+                raise IndexError(errormsg)
             idx = uid_map[key[i]]
             if idx == INT_NAN:
                 raise IndexError('UID not present in array')
             elif idx >= len(vals):
-                raise Exception(f'Attempted to write to a non-existant index - this can happen if attempting to write to new entries that have not yet been allocated via grow()')
+                errormsg = f'Attempted to write to a non-existant index ({idx}) - this can happen if attempting to write to new entries that have not yet been allocated via grow()'
+                raise IndexError(errormsg)
             vals[idx] = value[i]
+        return
 
     @staticmethod
-    @nb.njit
+    @nb.njit(cache=True)
     def _set_vals_uids_single(vals, key, uid_map, value):
         """
         Insert a single value into multiple UIDs
@@ -121,7 +158,7 @@ def __getitem__(self, key):
             if isinstance(key, (int, np.integer)):
                 # Handle getting a single item by UID
                 return self.values[self._uid_map[key]]
-            elif isinstance(key, (np.ndarray, FusedArray, DynamicView)):
+            elif isinstance(key, (np.ndarray, UIDArray, ArrayView)):
                 if key.dtype.kind == 'b':
                     # Handle accessing items with a logical array. Surprisingly, it seems faster to use nonzero() to convert
                     # it to indices first. Also, the pure Python implementation is difficult to improve upon using numba
@@ -142,26 +179,26 @@ def __getitem__(self, key):
                 # This branch is specifically handling the user passing in a list of integers instead of an array, therefore
                 # we need an additional conversion to an array first using np.fromiter to improve numba performance
                 values, uids, new_uid_map = self._get_vals_uids(self.values, np.fromiter(key, dtype=int), self._uid_map.__array__())
-            return FusedArray(values=values, uid=uids, uid_map=new_uid_map)
+            return UIDArray(values=values, uid=uids, uid_map=new_uid_map)
         except IndexError as e:
             if str(INT_NAN) in str(e):
-                raise IndexError(f'UID not present in array')
+                raise IndexError(f'UID {key} not present in array')
             else:
                 raise e
 
     def __setitem__(self, key, value):
-        # nb. the use of .__array__() calls is to access the array interface and thereby treat both np.ndarray and DynamicView instances
-        # in the same way without needing an additional type check. This is also why the FusedArray.dtype property is defined. Noting
+        # nb. the use of .__array__() calls is to access the array interface and thereby treat both np.ndarray and ArrayView instances
+        # in the same way without needing an additional type check. This is also why the UIDArray.dtype property is defined. Noting
         # that for a State, the uid_map is a dynamic view attached to the People, but after an indexing operation, it will be a bare
-        # FusedArray that has an ordinary numpy array as the uid_map
+        # UIDArray that has an ordinary numpy array as the uid_map
         try:
             if isinstance(key, (int, np.integer)):
                 return self.values.__setitem__(self._uid_map[key], value)
-            elif isinstance(key, (np.ndarray, FusedArray)):
+            elif isinstance(key, (np.ndarray, UIDArray)):
                 if key.dtype.kind == 'b':
                     self.values.__setitem__(key.__array__().nonzero()[0], value)
                 else:
-                    if isinstance(value, (np.ndarray, FusedArray)):
+                    if isinstance(value, (np.ndarray, UIDArray)):
                         return self._set_vals_uids_multiple(self.values, key, self._uid_map.__array__(), value.__array__())
                     else:
                         return self._set_vals_uids_single(self.values, key, self._uid_map.__array__(), value)
@@ -171,15 +208,19 @@ def __setitem__(self, key, value):
                 else:
                     raise Exception('Slicing not supported - slice the .values attribute by index instead e.g., x.values[0:5], not x[0:5]')
             else:
-                if isinstance(value, (np.ndarray, FusedArray)):
+                if isinstance(value, (np.ndarray, UIDArray)):
                     return self._set_vals_uids_multiple(self.values, np.fromiter(key, dtype=int), self._uid_map.__array__(), value.__array__())
                 else:
                     return self._set_vals_uids_single(self.values, np.fromiter(key, dtype=int), self._uid_map.__array__(), value)
         except IndexError as e:
             if str(INT_NAN) in str(e):
-                raise IndexError(f'UID not present in array')
+                raise IndexError(f'UID {key} not present in array')
             else:
                 raise e
+                
+    def __getattr__(self, attr):
+        """ Make it behave like a regular array mostly -- enables things like sum(), mean(), etc. """
+        return getattr(self.values, attr)
 
     # Make it behave like a regular array mostly
     def __len__(self):
@@ -189,7 +230,7 @@ def __contains__(self, *args, **kwargs):
         return self.values.__contains__(*args, **kwargs)
 
     def astype(self, *args, **kwargs):
-        return FusedArray(values=self.values.astype(*args, **kwargs), uid=self.uid, uid_map=self._uid_map)
+        return UIDArray(values=self.values.astype(*args, **kwargs), uid=self.uid, uid_map=self._uid_map)
 
     def sum(self, *args, **kwargs):
         return self.values.sum(*args, **kwargs)
@@ -203,7 +244,7 @@ def any(self, *args, **kwargs):
     def all(self, *args, **kwargs):
         return self.values.all(*args, **kwargs)
 
-    def count_nonzero(self, *args, **kwargs):
+    def count(self, *args, **kwargs):
         return np.count_nonzero(self.values, *args, **kwargs)
 
     @property
@@ -232,35 +273,35 @@ def __array_ufunc__(self, *args, **kwargs):
             return self
         else:
             out = args[0](*args[2:], **kwargs)
-            if isinstance(out, FusedArray):
-                # For some operations (e.g., those involving two FusedArrays) the result of the ufunc will already be a FusedArray
-                # In particular, operating on two states will result in a FusedArray where the references to the original People uid_map and uids
-                # are still intact. In such cases, we can return the resulting FusedArray directly
+            if isinstance(out, UIDArray):
+                # For some operations (e.g., those involving two UIDArrays) the result of the ufunc will already be a UIDArray
+                # In particular, operating on two states will result in a UIDArray where the references to the original People uid_map and uids
+                # are still intact. In such cases, we can return the resulting UIDArray directly
                 return out
             else:
                 # Otherwise, if the result of the ufunc is an array (e.g., because one of the arguments was an array) then
-                # we need to wrap it up in a new FusedArray. With '__call__' the dimensions should hopefully be the same and we should
+                # we need to wrap it up in a new UIDArray. With '__call__' the dimensions should hopefully be the same and we should
                 # be able to reuse the UID arrays directly
-                return FusedArray(values=out, uid=self.uid, uid_map=self._uid_map)
+                return UIDArray(values=out, uid=self.uid, uid_map=self._uid_map)
 
     def __array_wrap__(self, out_arr, context=None):
-        # This allows numpy operations addition etc. to return instances of FusedArray
+        # This allows numpy operations addition etc. to return instances of UIDArray
         if out_arr.ndim == 0:
             return out_arr.item()
-        return FusedArray(values=out_arr, uid=self.uid, uid_map=self._uid_map) # Hardcoding class means State can inherit from FusedArray but return FusedArray base instances
+        return UIDArray(values=out_arr, uid=self.uid, uid_map=self._uid_map) # Hardcoding class means State can inherit from UIDArray but return UIDArray base instances
 
 
-class DynamicView(NDArrayOperatorsMixin):
-    def __init__(self, dtype, fill_value=None):
+class ArrayView(NDArrayOperatorsMixin):
+    def __init__(self, dtype, default=None, coerce=True):
         """
         Args:
-            name: name of the result as used in the model
-            dtype: datatype
-            fill_value: default value for this state upon model initialization. If not provided, it will use the default value for the dtype
-            shape: If not none, set to match a string in `pars` containing the dimensionality
-            label: text used to construct labels for the result for displaying on plots and other outputs
+            dtype (class): The dtype to use for this instance (if None, infer from value)
+            default (any): Specify default value for new agents. This can be
+            coerce (bool): Whether to ensure the the data is one of the supported data types
         """
-        self.fill_value = fill_value if fill_value is not None else dtype()
+        if coerce:
+            dtype = check_dtype(dtype, default)
+        self.default = default if default is not None else dtype()
         self.n = 0  # Number of agents currently in use
         self._data = np.empty(0, dtype=dtype)  # The underlying memory array (length at least equal to n)
         self._view = None  # The view corresponding to what is actually accessible (length equal to n)
@@ -275,10 +316,10 @@ def _s(self):
     @property
     def dtype(self):
         # The specified dtype and the underlying array dtype can be different. For instance, the user might pass in
-        # DynamicView(dtype=int) but the underlying array's dtype will be np.dtype('int32'). This distinction is important
-        # because the numpy dtype has attributes like 'kind' that the input dtype may not have. We need the DynamicView's
+        # ArrayView(dtype=int) but the underlying array's dtype will be np.dtype('int32'). This distinction is important
+        # because the numpy dtype has attributes like 'kind' that the input dtype may not have. We need the ArrayView's
         # dtype to match that of the underlying array so that it can be more seamlessly exchanged with direct numpy arrays
-        # Therefore, we retain the original dtype in DynamicView._dtype() and use
+        # Therefore, we retain the original dtype in ArrayView._dtype() and use
         return self._data.dtype
 
     def __len__(self):
@@ -293,7 +334,7 @@ def grow(self, n):
         # If the total number of agents exceeds the array size, extend the underlying arrays
         if self.n + n > self._s:
             n_new = max(n, int(self._s / 2))  # Minimum 50% growth
-            self._data = np.concatenate([self._data, np.full(n_new, dtype=self.dtype, fill_value=self.fill_value)], axis=0)
+            self._data = np.concatenate([self._data, np.full(n_new, dtype=self.dtype, fill_value=self.default)], axis=0)
         self.n += n  # Increase the count of the number of agents by `n` (the requested number of new agents)
         self._map_arrays()
 
@@ -302,7 +343,7 @@ def _trim(self, inds):
         # Note that these are indices, not UIDs!
         n = len(inds)
         self._data[:n] = self._data[inds]
-        self._data[n:self.n] = self.fill_value
+        self._data[n:self.n] = self.default
         self.n = n
         self._map_arrays()
 
@@ -334,43 +375,52 @@ def __array_ufunc__(self, *args, **kwargs):
         return self._view.__array_ufunc__(*args, **kwargs)
 
 
-class State(FusedArray):
+class State(UIDArray):
 
-    def __init__(self, name, dtype, fill_value=None, label=None):
+    def __init__(self, name, dtype=None, default=None, label=None, coerce=True):
         """
+        Store a state of the agents (e.g. age, infection status, etc.)
 
-        :param name: A string name for the state
-        :param dtype: The dtype to use for this instance
-        :param fill_value: Specify default value for new agents. This can be
+        Args: 
+            name (str): The name for the state (also used as the dictionary key, so should not have spaces etc.)
+            dtype (class): The dtype to use for this instance (if None, infer from value)
+            default (any): Specify default value for new agents. This can be
             - A scalar with the same dtype (or castable to the same dtype) as the State
             - A callable, with a single argument for the number of values to produce
-            - An ss.ScipyDistribution instance
-        :param label:
+            - An ``ss.ScipyDistribution`` instance
+            label (str): The human-readable name for the state
+            coerce (bool): Whether to ensure the the data is one of the supported data types
         """
-
-        super().__init__(values=None, uid=None, uid_map=None)  # Call the FusedArray constructor
-
-        self.fill_value = fill_value
-
-        self._data = DynamicView(dtype=dtype)
+        super().__init__()  # Call the UIDArray constructor
+        
+        if coerce:
+            dtype = check_dtype(dtype, default)
+        
+        if default is None:
+            default = dtype()
+        
+        # Set attributes
+        self.default = default
         self.name = name
         self.label = label or name
+        self._data = ArrayView(dtype=dtype)
         self.values = self._data._view
         self._initialized = False
+        return
 
     def __repr__(self):
         if not self._initialized:
             return f'<State {self.name} (uninitialized)>'
         else:
-            return FusedArray.__repr__(self)
+            return UIDArray.__repr__(self)
 
     def _new_vals(self, uids):
-        if isinstance(self.fill_value, ScipyDistribution):
-            new_vals = self.fill_value.rvs(uids)
-        elif callable(self.fill_value):
-            new_vals = self.fill_value(len(uids))
+        if isinstance(self.default, ss.ScipyDistribution):
+            new_vals = self.default.rvs(uids)
+        elif callable(self.default):
+            new_vals = self.default(len(uids))
         else:
-            new_vals = self.fill_value
+            new_vals = self.default
         return new_vals
 
     def initialize(self, sim=None, people=None):
@@ -381,10 +431,10 @@ def initialize(self, sim=None, people=None):
             people = sim.people
 
         sim_still_needed = False
-        if isinstance(self.fill_value, rv_frozen):
+        if isinstance(self.default, rv_frozen):
             if sim is not None:
-                self.fill_value = ScipyDistribution(self.fill_value, f'{self.__class__.__name__}_{self.label}')
-                self.fill_value.initialize(sim, self)
+                self.default = ss.ScipyDistribution(self.default, f'{self.__class__.__name__}_{self.label}')
+                self.default.initialize(sim, self)
             else:
                 sim_still_needed = True
 
diff --git a/starsim/utils.py b/starsim/utils.py
index 0d79d01c..9e43efc0 100644
--- a/starsim/utils.py
+++ b/starsim/utils.py
@@ -2,27 +2,17 @@
 Numerical utilities
 """
 
-# %% Housekeeping
-
 import warnings
 import numpy as np
 import sciris as sc
 import starsim as ss
 import numba as nb
-import scipy.stats as sps
 import pandas as pd
 
-# What functions are externally visible -- note, this gets populated in each section below
-__all__ = []
-
-# System constants
-__all__ += ['INT_NAN']
-
-INT_NAN = np.iinfo(
-    np.int32).max  # Value to use to flag invalid content (i.e., an integer value we are treating like NaN, since NaN can't be stored in an integer array)
-
 # %% Helper functions
-__all__ += ['ndict', 'omerge', 'warn', 'unique', 'find_contacts', 'get_subclasses']
+
+# What functions are externally visible -- note, this gets populated in each section below
+__all__ = ['ndict', 'omerge', 'omergeleft', 'warn', 'unique', 'find_contacts', 'get_subclasses', 'all_subclasses']
 
 
 class ndict(sc.objdict):
@@ -30,35 +20,36 @@ class ndict(sc.objdict):
     A dictionary-like class that provides additional functionalities for handling named items.
 
     Args:
-        name (str): The items' attribute to use as keys.
+        name (str): The attribute of the item to use as the dict key (i.e., all items should have this attribute defined)
         type (type): The expected type of items.
         strict (bool): If True, only items with the specified attribute will be accepted.
+        overwrite (bool): whether to allow adding a key when one has already been added
 
     **Examples**::
 
-        networks = ss.ndict(ss.mf(), ss.maternal())
-        networks = ss.ndict([ss.mf(), ss.maternal()])
-        networks = ss.ndict({'mf':ss.mf(), 'maternal':ss.maternal()})
-
+        networks = ss.ndict(ss.MFNet(), ss.MaternalNet())
+        networks = ss.ndict([ss.MFNet(), ss.MaternalNet()])
+        networks = ss.ndict({'mf':ss.MFNet(), 'maternal':ss.MaternalNet()})
     """
 
-    def __init__(self, *args, name='name', type=None, strict=True, **kwargs):
-        self.setattribute('_name', name)  # Since otherwise treated as keys
+    def __init__(self, *args, nameattr='name', type=None, strict=True, overwrite=False, **kwargs):
+        self.setattribute('_nameattr', nameattr)  # Since otherwise treated as keys
         self.setattribute('_type', type)
         self.setattribute('_strict', strict)
-        self._initialize(*args, **kwargs)
+        self.setattribute('_overwrite', overwrite)
+        self.extend(*args, **kwargs)
         return
 
-    def append(self, arg, key=None):
+    def append(self, arg, key=None, overwrite=None):
         valid = False
         if arg is None:
             return  # Nothing to do
-        elif hasattr(arg, self._name):
-            key = key or getattr(arg, self._name)
+        elif hasattr(arg, self._nameattr):
+            key = key or getattr(arg, self._nameattr)
             valid = True
         elif isinstance(arg, dict):
-            if self._name in arg:
-                key = key or arg[self._name]
+            if self._nameattr in arg:
+                key = key or arg[self._nameattr]
                 valid = True
             else:
                 for k, v in arg.items():
@@ -72,15 +63,26 @@ def append(self, arg, key=None):
 
         if valid is True:
             self._check_type(arg)
-            self[key] = arg
+            self._check_key(key, overwrite=overwrite)
+            self[key] = arg # Actually add to the ndict!
         elif valid is None:
             pass  # Nothing to do
         else:
-            errormsg = f'Could not interpret argument {arg}: does not have expected attribute "{self._name}"'
-            raise ValueError(errormsg)
-
+            errormsg = f'Could not interpret argument {arg}: does not have expected attribute "{self._nameattr}"'
+            raise TypeError(errormsg)
         return
-
+    
+    def _check_key(self, key, overwrite=None):
+        if overwrite is None: overwrite = self._overwrite
+        if key in self:
+            if not overwrite:
+                typestr = ' "{self._type"}' if self._type else ''
+                errormsg = f'Cannot add object "{key}" since already present in ndict{typestr} with keys:\n{sc.newlinejoin(self.keys())}'
+                raise ValueError(errormsg)
+            else:
+                ss.warn(f'Overwriting existing ndict entry "{key}"')
+        return
+    
     def _check_type(self, arg):
         """ Check types """
         if self._type is not None:
@@ -89,7 +91,8 @@ def _check_type(self, arg):
                 raise TypeError(errormsg)
         return
 
-    def _initialize(self, *args, **kwargs):
+    def extend(self, *args, **kwargs):
+        """ Add new items to the ndict, by item, list, or dict """
         args = sc.mergelists(*args)
         for arg in args:
             self.append(arg)
@@ -98,29 +101,49 @@ def _initialize(self, *args, **kwargs):
         return
 
     def copy(self):
-        new = self.__class__.__new__(name=self._name, type=self._type, strict=self._strict)
+        new = self.__class__.__new__(nameattr=self._nameattr, type=self._type, strict=self._strict)
         new.update(self)
         return new
 
     def __add__(self, dict2):
         """ Allow c = a + b """
         new = self.copy()
-        new.append(dict2)
+        if isinstance(dict2, list):
+            new.extend(dict2)
+        else:
+            new.append(dict2)
         return new
 
     def __iadd__(self, dict2):
         """ Allow a += b """
-        self.append(dict2)
+        if isinstance(dict2, list):
+            self.extend(dict2)
+        else:
+            self.append(dict2)
         return self
 
 
 def omerge(*args, **kwargs):
-    """ Merge things into an objdict """
+    """ Merge things into an objdict, using standard order """
     return sc.objdict(sc.mergedicts(*args, **kwargs))
 
 
+def omergeleft(*args, **kwargs):
+    """ Merge things into an odict, using opposite order to allow defaults to be supplied second """
+    if len(args) == 1 and len(kwargs):
+        new = args[0]
+        default = kwargs
+    elif len(args) == 2 and len(kwargs) == 0:
+        new = args[0]
+        default = args[1]
+    else:
+        errormsg = 'Expecting either two arguments, or one argument and kwargs; for any other arrangement, use ss.omerge()'
+        raise ValueError(errormsg)
+    return sc.objdict(sc.mergedicts(default, new))
+
+
 def warn(msg, category=None, verbose=None, die=None):
-    """ Helper function to handle warnings -- not for the user """
+    """ Helper function to handle warnings -- shortcut to warnings.warn """
 
     # Handle inputs
     warnopt = ss.options.warnings if not die else 'error'
@@ -184,6 +207,10 @@ def get_subclasses(cls):
         yield from get_subclasses(subclass)
         yield subclass
 
+def all_subclasses(cls):
+    """ As above but also returns subsubclases """
+    return set(cls.__subclasses__()).union(
+        [s for c in cls.__subclasses__() for s in all_subclasses(c)])
 
 # %% Seed methods
 
@@ -200,7 +227,7 @@ def set_seed(seed=None):
         seed (int): the random seed
     '''
 
-    @nb.njit
+    @nb.njit(cache=True)
     def set_seed_numba(seed):
         return np.random.seed(seed)
 
@@ -220,7 +247,7 @@ def set_seed_regular(seed):
 
 
 # %% Helper functions related to distributions
-__all__ += ['lognorm_params', 'lognorm']
+__all__ += ['lognorm_params', 'lognorm_mean']
 
 
 def lognorm_params(mean, stdev):
@@ -234,12 +261,12 @@ def lognorm_params(mean, stdev):
     return s, scale
 
 
-def lognorm(mean, stdev):
+def lognorm_mean(mean, stdev):
     """
     Wrapper for scipy lognorm but using mean and stdev
     """
     s, scale = lognorm_params(mean, stdev)
-    return sps.lognorm(s=s, scale=scale)
+    return ss.lognorm(s=s, scale=scale)
 
 
 # %% Simple array operations
@@ -247,7 +274,7 @@ def lognorm(mean, stdev):
 __all__ += ['true', 'false', 'defined', 'undefined']
 
 
-@nb.njit
+@nb.njit(cache=True)
 def _true(uids, values):
     """
     Returns the UIDs for indices where the value evaluates as True
@@ -262,7 +289,7 @@ def _true(uids, values):
     return out
 
 
-@nb.njit
+@nb.njit(cache=True)
 def _false(uids, values):
     """
     Returns the UIDs for indices where the value evaluates as False
@@ -282,7 +309,7 @@ def true(state):
     Returns the UIDs of the values of the array that are true
 
     Args:
-        state (State, FusedArray)
+        state (State, UIDArray)
 
     **Example**::
 
@@ -296,7 +323,7 @@ def false(state):
     Returns the indices of the values of the array that are false.
 
     Args:
-        state (State, FusedArray)
+        state (State, UIDArray)
 
     **Example**::
 
@@ -339,7 +366,21 @@ def undefined(arr):
 
 
 def standardize_data(data=None, metadata=None, max_age=120, min_year=1800):
-
+    """
+    Args:
+        data (pandas.DataFrame, pandas.Series, dict, int, float): An associative array  or a number, with the
+        input data to be standardized.
+        metadata (dict): The metadata containing information about the columns of the data.
+        max_age (float): The maximum age allowed in the data. Default is 120 years.
+        min_year (float): The minimum year allowed in the data. Default is 1800.
+
+    Returns:
+        df (pandas.DataFrame or sciris.dataframe): The standardized data
+
+    Raises:
+        ValueError: If the columns in `data` do not match the column names in metadata.data_cols
+        or if the index of the data series is not understood.
+    """
     metadata = sc.objdict(metadata)
 
     if isinstance(data, pd.DataFrame):
@@ -351,13 +392,13 @@ def standardize_data(data=None, metadata=None, max_age=120, min_year=1800):
     elif isinstance(data, pd.Series):
         if metadata.data_cols.get('age'):
             if (data.index <= max_age).all():  # Assume index is age bins
-                df = pd.DataFrame({
+                df = sc.dataframe({
                     metadata.data_cols['year']: 2000,
                     metadata.data_cols['age']: data.index.values,
                     metadata.data_cols['value']: data.values,
                 })
             elif (data.index >= min_year).all():  # Assume index year
-                df = pd.DataFrame({
+                df = sc.dataframe({
                     metadata.data_cols['year']: data.index.values,
                     metadata.data_cols['age']: 0,
                     metadata.data_cols['value']: data.values,
@@ -366,7 +407,7 @@ def standardize_data(data=None, metadata=None, max_age=120, min_year=1800):
                 errormsg = 'Could not understand index of data series: should be age (all values less than 120) or year (all values greater than 1900).'
                 raise ValueError(errormsg)
         else:
-            df = pd.DataFrame({
+            df = sc.dataframe({
                 metadata.data_cols['year']: data.index.values,
                 metadata.data_cols['value']: data.values,
             })
@@ -382,7 +423,7 @@ def standardize_data(data=None, metadata=None, max_age=120, min_year=1800):
         new_data = dict()
         for sim_name, col_name in metadata.data_cols.items():
             new_data[sim_name] = sc.tolist(data[col_name])
-        df = pd.DataFrame(new_data)
+        df = sc.dataframe(new_data)
 
     elif sc.isnumber(data):
         df = data  # Just return it as-is
diff --git a/starsim/version.py b/starsim/version.py
index 81fe3adb..a162a0e8 100644
--- a/starsim/version.py
+++ b/starsim/version.py
@@ -4,6 +4,6 @@
 
 __all__ = ['__version__', '__versiondate__', '__license__']
 
-__version__ = '0.1.8'
-__versiondate__ = '2024-01-30'
-__license__ = f'STIsim {__version__} ({__versiondate__}) — © 2024 by IDM'
+__version__ = '0.2.0'
+__versiondate__ = '2024-02-15'
+__license__ = f'Starsim {__version__} ({__versiondate__}) — © 2023-2024 by IDM'


Test patch:
diff --git a/tests/baseline.json b/tests/baseline.json
index a00d7b95..bc005d4d 100644
--- a/tests/baseline.json
+++ b/tests/baseline.json
@@ -1,15 +1,16 @@
 {
   "summary": {
-    "n_alive": 8861.15238095238,
-    "new_deaths": 20.047619047619047,
+    "n_alive": 9160.866666666667,
+    "new_deaths": 12.142857142857142,
     "pregnancy_pregnancies": 0.0,
     "pregnancy_births": 0.0,
     "pregnancy_cbr": 0.0,
-    "hiv_n_susceptible": 8320.219047619048,
-    "hiv_n_infected": 540.9333333333333,
     "hiv_n_on_art": 0.0,
-    "hiv_prevalence": 0.06038849306129801,
-    "hiv_new_infections": 23.476190476190474,
-    "hiv_new_deaths": 20.047619047619047
+    "hiv_n_susceptible": 8847.961904761905,
+    "hiv_n_infected": 312.9047619047619,
+    "hiv_prevalence": 0.0331775567376584,
+    "hiv_new_infections": 12.714285714285714,
+    "hiv_cum_infections": 1139.3238095238096,
+    "hiv_new_deaths": 12.142857142857142
   }
 }
\ No newline at end of file
diff --git a/tests/benchmark.json b/tests/benchmark.json
index ff90e748..a5f47d4f 100644
--- a/tests/benchmark.json
+++ b/tests/benchmark.json
@@ -1,13 +1,13 @@
 {
   "time": {
-    "people": 0.018,
-    "initialize": 0.043,
-    "run": 0.278
+    "people": 0.003,
+    "initialize": 0.017,
+    "run": 0.258
   },
   "parameters": {
     "n_agents": 10000,
     "n_years": 20,
     "dt": 0.2
   },
-  "cpu_performance": 0.3214156099481553
+  "cpu_performance": 0.2939902627790617
 }
\ No newline at end of file
diff --git a/tests/benchmark_full.py b/tests/benchmark_full.py
new file mode 100644
index 00000000..9cc54afa
--- /dev/null
+++ b/tests/benchmark_full.py
@@ -0,0 +1,45 @@
+#!/usr/bin/env python3
+"""
+Larger test of sim performance.
+"""
+
+import sciris as sc
+import starsim as ss
+
+# Define the parameters
+repeats = 10
+n_agents = 100_000
+pars = sc.objdict(
+    n_years       = 100,        # Number of years to simulate
+    dt            = 0.5,        # Timestep
+    verbose       = 0,          # Don't print details of the run
+)
+
+def make_run_sim():
+    """ Make and run a decent-sized default simulation """
+    # Make the people
+    ppl = ss.People(n_agents=n_agents)
+    
+    # Make the components
+    sir = ss.SIR()
+    hiv = ss.HIV()
+    hiv.pars['beta'] = {'mf': [0.15, 0.10], 'maternal': [0.2, 0]}
+    networks = [ss.MFNet(), ss.MaternalNet()]
+    
+    # Make the sim
+    sim = ss.Sim(pars=pars, people=ppl, networks=networks, demographics=ss.Pregnancy(), diseases=[sir, hiv])
+
+    # Run the sim
+    sim.run()
+
+    return sim
+
+
+if __name__ == '__main__':
+    
+    T = sc.timer()
+    for r in range(repeats):
+        make_run_sim()
+        T.tt(f'Trial {r+1}/{repeats}')
+        
+    sc.heading(f'Average: {T.mean()*1000:0.0f} ± {T.std()/len(T)**0.5*1000:0.0f} ms')
\ No newline at end of file
diff --git a/tests/simple_benchmark.py b/tests/benchmark_simple.py
similarity index 67%
rename from tests/simple_benchmark.py
rename to tests/benchmark_simple.py
index ed98a476..8a0e1701 100644
--- a/tests/simple_benchmark.py
+++ b/tests/benchmark_simple.py
@@ -6,12 +6,12 @@
 
 with sc.timer():
     ppl = ss.People(int(1e3))
-    ppl.networks = ss.ndict(ss.mf(), ss.maternal())
+    networks = ss.ndict(ss.MFNet(), ss.MaternalNet())
 
     hiv = ss.HIV()
     hiv.pars['beta'] = {'simple_sexual': [0.0008, 0.0004], 'maternal': [0.2, 0]}
     
-    sim = ss.Sim(start=1950, end=2050, people=ppl, demographics=ss.Pregnancy(), diseases=hiv)
+    sim = ss.Sim(start=1950, end=2050, people=ppl, networks=networks, demographics=ss.Pregnancy(), diseases=hiv)
     sim.initialize()
     sim.run()
 
diff --git a/tests/devtests/devtest_connectors.py b/tests/devtests/devtest_connectors.py
index e30d7189..80416677 100644
--- a/tests/devtests/devtest_connectors.py
+++ b/tests/devtests/devtest_connectors.py
@@ -11,8 +11,7 @@
 ng.pars['init_prev'] = 0.025
 
 ppl1 = ss.People(10000)
-ppl1.networks = ss.ndict(ss.mf())
-sim_nohiv = ss.Sim(people=ppl1, diseases=ng)
+sim_nohiv = ss.Sim(people=ppl1, networks=ss.mf(), diseases=ng)
 sim_nohiv.run()
 
 hiv = ss.HIV()
@@ -22,8 +21,7 @@
 ng.pars['beta'] = {'mf': [0.05, 0.025]}
 ng.pars['init_prev'] = 0.025
 ppl2 = ss.People(10000)
-ppl2.networks = ss.ndict(ss.mf())
-sim_hiv = ss.Sim(people=ppl2, diseases=[hiv, ng], connectors=ss.simple_hiv_ng())
+sim_hiv = ss.Sim(people=ppl2, networks=ss.mf(), diseases=[hiv, ng], connectors=ss.simple_hiv_ng())
 sim_hiv.run()
 
 plt.figure()
diff --git a/tests/removing_people.py b/tests/removing_people.py
index b2008deb..a03f18a5 100644
--- a/tests/removing_people.py
+++ b/tests/removing_people.py
@@ -1,7 +1,7 @@
 import pandas as pd
 import numpy as np
 import sciris as sc
-from starsim.states import State, DynamicView
+from starsim.states import State, ArrayView
 
 INT_NAN = np.iinfo(int).max  # Value to use to flag removed UIDs (i.e., an integer value we are treating like NaN, since NaN can't be stored in an integer array)
 
@@ -13,8 +13,8 @@
 class DynamicPeople():
     def __init__(self, states=None):
 
-        self._uid_map = DynamicView(int, fill_value=INT_NAN) # This variable tracks all UIDs ever created
-        self.uid = DynamicView(int, fill_value=INT_NAN) # This variable tracks all UIDs currently in use
+        self._uid_map = ArrayView(int, fill_value=INT_NAN) # This variable tracks all UIDs ever created
+        self.uid = ArrayView(int, fill_value=INT_NAN) # This variable tracks all UIDs currently in use
 
         self.sex = State('sex',bool)
         self.dead = State('dead',bool)
diff --git a/tests/test_base.py b/tests/test_base.py
index 4ca263fe..be46241c 100644
--- a/tests/test_base.py
+++ b/tests/test_base.py
@@ -45,23 +45,22 @@ def test_networks():
     sim = ss.Sim()
     sim.initialize()
     
-    nw3 = ss.hpv_network()
+    nw3 = ss.MaternalNet()
     nw3.initialize(sim)
-    sim.people.networks.update(sim.people)  # Update by providing a timestep & current time index
+    nw3.add_pairs(mother_inds=[1, 2, 3], unborn_inds=[100, 101, 102], dur=[1, 1, 1])
 
-    nw4 = ss.maternal()
-    nw4.initialize(sim)
-    nw4.add_pairs(mother_inds=[1, 2, 3], unborn_inds=[100, 101, 102], dur=[1, 1, 1])
+    # HPV NETWORK - NOT FUNCTIONAL
+    # nw3 = ss.hpv_network()
+    # nw3.initialize(sim)
+    # sim.people.networks.update(sim.people)  # Update by providing a timestep & current time index
 
-    return nw1, nw2, nw3, nw4
+
+    return nw1, nw2, nw3
 
 
 def test_microsim():
     sc.heading('Test making people and providing them to a sim')
 
-    networks = [ss.mf(), ss.maternal()]
-    ppl = ss.People(100, networks=networks)
-
     # Make HIV module
     hiv = ss.HIV()
     # Set beta. The first entry represents transmission risk from infected p1 -> susceptible p2
@@ -69,7 +68,12 @@ def test_microsim():
     # sexual  network, p1 is male and p2 is female. In the maternal network, p1=mothers, p2=babies.
     hiv.pars['beta'] = {'mf': [0.15, 0.10], 'maternal': [0.2, 0]}
 
-    sim = ss.Sim(people=ppl, demographics=ss.Pregnancy(), diseases=hiv)
+    sim = ss.Sim(
+        people=ss.People(100),
+        networks=[ss.MFNet(), ss.MaternalNet()],
+        demographics=ss.Pregnancy(),
+        diseases=hiv
+    )
     sim.initialize()
     sim.run()
 
@@ -89,9 +93,9 @@ def init_debut(self, sim, uids):
         return loc
 
     mf_pars = {
-        'debut_dist': sps.norm(loc=init_debut, scale=2),  # Age of debut can vary by using callable parameter values
+        'debut': sps.norm(loc=init_debut, scale=2),  # Age of debut can vary by using callable parameter values
     }
-    sim_pars = {'networks': [ss.mf(mf_pars)], 'n_agents': 100}
+    sim_pars = {'networks': [ss.MFNet(mf_pars)], 'n_agents': 100}
     gon_pars = {'beta': {'mf': [0.08, 0.04]}, 'p_death': 0.2}
     gon = ss.Gonorrhea(pars=gon_pars)
 
@@ -111,11 +115,11 @@ def init_debut(self, sim, uids):
     # Start timing
     T = sc.tic()
 
-    ss.options.multirng = True
+    ss.options.multirng = False
 
     # Run tests
     ppl = test_people()
-    nw1, nw2, nw3, nw4 = test_networks()
+    nw1, nw2, nw3 = test_networks()
     sim1 = test_microsim()
     sim2 = test_ppl_construction()
 
diff --git a/tests/test_baselines.py b/tests/test_baselines.py
index 853e2b72..58123cd5 100644
--- a/tests/test_baselines.py
+++ b/tests/test_baselines.py
@@ -1,5 +1,5 @@
 """
-Test that the current version of HPVsim exactly matches
+Test that the current version of Starsim exactly matches
 the baseline results.
 """
 
@@ -26,8 +26,7 @@
 def make_people():
     ss.set_seed(pars.rand_seed)
     n_agents = int(10e3)
-    networks = [ss.mf(), ss.maternal()]
-    ppl = ss.People(n_agents, networks=networks)
+    ppl = ss.People(n_agents=n_agents)
     return ppl
 
 
@@ -43,8 +42,9 @@ def make_sim(ppl=None, do_plot=False, **kwargs):
     
     # Make the sim
     hiv = ss.HIV()
-    hiv.pars['beta'] = {'mf': [0.15, 0.10], 'maternal': [0.2, 0]}
-    sim = ss.Sim(pars=pars, people=ppl, demographics=ss.Pregnancy(), diseases=hiv)
+    hiv.pars.beta = {'mf': [0.15, 0.10], 'maternal': [0.2, 0]}
+    networks = [ss.MFNet(), ss.MaternalNet()]
+    sim = ss.Sim(pars=pars, people=ppl, networks=networks, demographics=ss.Pregnancy(), diseases=hiv)
 
     # Optionally plot
     if do_plot:
diff --git a/tests/test_demographics.py b/tests/test_demographics.py
index 81429c3b..37bef813 100644
--- a/tests/test_demographics.py
+++ b/tests/test_demographics.py
@@ -23,19 +23,20 @@ def test_nigeria(which='births', dt=1, start=1995, n_years=15, plot_init=False,
 
     if which == 'births':
         birth_rates = pd.read_csv(ss.root / 'tests/test_data/nigeria_births.csv')
-        births = ss.births(pars={'birth_rate': birth_rates})
+        births = ss.Births(pars={'birth_rate': birth_rates})
         demographics += births
+
     elif which == 'pregnancy':
         fertility_rates = pd.read_csv(ss.root / 'tests/test_data/nigeria_asfr.csv')
         pregnancy = ss.Pregnancy(pars={'fertility_rate': fertility_rates, 'rel_fertility': 1})  # 4/3
         demographics += pregnancy
 
     death_rates = pd.read_csv(ss.root / 'tests/test_data/nigeria_deaths.csv')
-    death = ss.background_deaths(pars={'death_rate': death_rates})
+    death = ss.Deaths(pars={'death_rate': death_rates})
     demographics += death
 
     # Make people
-    n_agents = 10_000
+    n_agents = 5_000
     nga_pop_1995 = 106819805
     age_data = pd.read_csv(ss.root / 'tests/test_data/nigeria_age.csv')
     ppl = ss.People(n_agents, age_data=age_data)
@@ -77,12 +78,12 @@ def test_nigeria(which='births', dt=1, start=1995, n_years=15, plot_init=False,
 
         print("Check we don't have more births than pregnancies")
         assert sum(sim.results.pregnancy.births) <= sum(sim.results.pregnancy.pregnancies)
-        print(f'✓ (births <= pregnancies)')
+        print('✓ (births <= pregnancies)')
 
         if dt == 1:
             print("Checking that births equal pregnancies with dt=1")
             assert np.array_equal(sim.results.pregnancy.pregnancies, sim.results.pregnancy.births)
-            print(f'✓ (births == pregnancies)')
+            print('✓ (births == pregnancies)')
 
     print("Check final pop size within 5% of data")
     assert np.isclose(data.n_alive.values[-1], sim.results.n_alive[-1], rtol=0.05)
@@ -96,7 +97,7 @@ def test_nigeria(which='births', dt=1, start=1995, n_years=15, plot_init=False,
         ax[0].plot(sim.yearvec, sim.results.n_alive, color='k')
         ax[0].set_title('Population')
 
-        ax[1].plot(sim.yearvec, 1000 * sim.results.background_deaths.cmr / dt, label='Simulated CMR')
+        ax[1].plot(sim.yearvec, 1000 * sim.results.deaths.cmr / dt, label='Simulated CMR')
         ax[1].scatter(cmr_data.Year, cmr_data.CMR, label='Data CMR')
         ax[1].set_title('CMR')
         ax[1].legend()
diff --git a/tests/test_examples.py b/tests/test_diseases.py
similarity index 51%
rename from tests/test_examples.py
rename to tests/test_diseases.py
index 3201aa59..3e7dcd9c 100644
--- a/tests/test_examples.py
+++ b/tests/test_diseases.py
@@ -1,35 +1,40 @@
 """
-Run simplest tests
+Run tests of disease models
 """
 
 # %% Imports and settings
 import starsim as ss
 import matplotlib.pyplot as plt
 import scipy.stats as sps
-import numpy as np
+import sciris as sc
+
+test_run = True
+n_agents = [10_000, 2_000][test_run]
+
 
 def test_sir():
-    ppl = ss.People(10000)
-    ppl.networks = ss.ndict(ss.RandomNetwork(n_contacts=sps.poisson(mu=4)))
+    ppl = ss.People(n_agents)
 
     sir_pars = {
-        'dur_inf': sps.norm(loc=10), # Override the default distribution
+        'dur_inf': sps.norm(loc=10),  # Override the default distribution
     }
     sir = ss.SIR(sir_pars)
 
     # You can also change the parameters of the default lognormal distribution directly!
-    #sir.pars['dur_inf'].kwds['loc'] = 5 
-    
+    # sir.pars['dur_inf'].kwds['loc'] = 5
+
     # Or why not put a lambda here for fun!
-    sir.pars['dur_inf'].kwds['loc'] = lambda self, sim, uids: sim.people.age[uids]/10
+    sir.pars.dur_inf.kwds['loc'] = lambda self, sim, uids: sim.people.age[uids] / 10
+
+    sir.pars.beta = {'randomnet': 0.1}
+    networks = ss.RandomNet(pars=dict(n_contacts=sps.poisson(mu=4)))
 
-    sir.pars['beta'] = {'randomnetwork': 0.1}
-    sim = ss.Sim(people=ppl, diseases=sir)
+    sim = ss.Sim(people=ppl, diseases=sir, networks=networks)
     sim.run()
 
     # CK: parameters changed
     # assert len(sir.log.out_edges(np.nan)) == sir.pars.initial # Log should match initial infections
-    df = sir.log.line_list # Check generation of line-list
+    df = sir.log.line_list  # Check generation of line-list
     # assert df.source.isna().sum() == sir.pars.initial # Check seed infections in line list
 
     plt.figure()
@@ -43,36 +48,50 @@ def test_sir():
     plt.legend(['Susceptible', 'Infected', 'Recovered', 'Dead'])
     plt.xlabel('Year')
     plt.title('SIR')
-    return
+    return sim
 
-#@pytest.mark.skip(reason="Haven't converted yet")
+
+# @pytest.mark.skip(reason="Haven't converted yet")
 def test_ncd():
-    ppl = ss.People(10000)
-    ppl.networks = None
+    ppl = ss.People(n_agents)
     ncd = ss.NCD()
     sim = ss.Sim(people=ppl, diseases=ncd)
     sim.run()
 
     assert len(ncd.log.out_edges) == ncd.log.number_of_edges()
-    df = ncd.log.line_list # Check generation of line-list
+    df = ncd.log.line_list  # Check generation of line-list
     assert df.source.isna().all()
 
     plt.figure()
     plt.stackplot(
         sim.yearvec,
         ncd.results.n_not_at_risk,
-        ncd.results.n_at_risk-ncd.results.n_affected,
+        ncd.results.n_at_risk - ncd.results.n_affected,
         ncd.results.n_affected,
         sim.results.new_deaths.cumsum(),
     )
-    plt.legend(['Not at risk','At risk','Affected', 'Dead'])
+    plt.legend(['Not at risk', 'At risk', 'Affected', 'Dead'])
     plt.xlabel('Year')
     plt.title('NCD')
-    return
+    return sim
+
+
+def test_gavi():
+    sims = sc.autolist()
+    for disease in ['cholera', 'measles', 'ebola']:
+        pars = dict(
+            diseases=disease,
+            n_agents=n_agents,
+            networks='randomnet',
+        )
+        sim = ss.Sim(pars)
+        sim.run()
+        sims += sim
+    return sims
 
 
 if __name__ == '__main__':
-    ss.options(multirng=False)
     sim1 = test_sir()
     sim2 = test_ncd()
+    sims = test_gavi()
     plt.show()
diff --git a/tests/test_simple.py b/tests/test_simple.py
new file mode 100644
index 00000000..a3cf9fa7
--- /dev/null
+++ b/tests/test_simple.py
@@ -0,0 +1,146 @@
+"""
+Test simple APIs
+"""
+
+# %% Imports and settings
+import starsim as ss
+import sciris as sc
+
+n_agents = 1_000
+
+
+def test_default():
+    """ Create, run, and plot a sim with default settings """
+    sim = ss.Sim(n_agents=n_agents).run()
+    sim.plot()
+    return sim
+
+
+def make_sim_pars():
+    pars = dict(
+        n_agents = n_agents,
+        birth_rate = 20,
+        death_rate = 0.015,
+        networks = dict(
+            type = 'randomnet',
+            n_contacts = 4,
+        ),
+        diseases = dict(
+            type = 'sir',
+            dur_inf = 10,
+            beta = 0.1,
+        )
+    )
+    return pars
+
+
+def test_simple():
+    """ Create, run, and plot a sim by passing a parameters dictionary """
+    pars = make_sim_pars()
+    sim = ss.Sim(pars)
+    sim.run()
+    sim.plot()
+    return sim
+
+
+def test_sir_epi():
+    sc.heading('Test basic epi dynamics')
+
+    # Define the parameters to vary
+    par_effects = dict(
+        beta=[0.01, 0.99],
+        n_contacts=[1, 20],
+        init_prev=[0.1, 0.8],
+        dur_inf=[1, 8],
+    )
+
+    # Loop over each of the above parameters and make sure they affect the epi dynamics in the expected ways
+    for par, par_val in par_effects.items():
+        lo = par_val[0]
+        hi = par_val[1]
+
+        # Make baseline pars
+        pars0 = make_sim_pars()
+        pars1 = make_sim_pars()
+
+        if par != 'n_contacts':
+            pars0['diseases'] = sc.mergedicts(pars0['diseases'], {par: lo})
+            pars1['diseases'] = sc.mergedicts(pars1['diseases'], {par: hi})
+        else:
+            pars0['networks'] = sc.mergedicts(pars0['networks'], {par: lo})
+            pars1['networks'] = sc.mergedicts(pars1['networks'], {par: hi})
+
+        # Run the simulations and pull out the results
+        s0 = ss.Sim(pars0, label=f'{par} {par_val[0]}').run()
+        s1 = ss.Sim(pars1, label=f'{par} {par_val[1]}').run()
+
+        # Check results
+        v0 = s0.results.sir.cum_infections[-1]
+        v1 = s1.results.sir.cum_infections[-1]
+        print(f'Checking with varying {par:10s} ... ', end='')
+        assert v0 <= v1, f'Expected infections to be lower with {par}={lo} than with {par}={hi}, but {v0} > {v1})'
+        print(f'✓ ({v0} <= {v1})')
+
+    return s0, s1
+
+
+
+def test_simple_vax(do_plot=False):
+    """ Create and run a sim with vaccination """
+    ss.set_seed(1)
+    pars = make_sim_pars()
+    sim_base = ss.Sim(pars=pars)
+    sim_base.run()
+
+    my_vax = ss.sir_vaccine(pars=dict(efficacy=0.5))
+    sim_intv = ss.Sim(pars=pars, interventions=ss.routine_vx(start_year=2015, prob=0.2, product=my_vax))
+    sim_intv.run()
+
+    # Check plots
+    if do_plot:
+        import matplotlib.pyplot as plt
+        pi = 0
+
+        plt.figure()
+        plt.plot(sim_base.yearvec[pi:], sim_base.results.sir.prevalence[pi:], label='Baseline')
+        plt.plot(sim_intv.yearvec[pi:], sim_intv.results.sir.prevalence[pi:], label='Vax')
+        plt.axvline(x=2015, color='k', ls='--')
+        plt.title('Prevalence')
+        plt.legend()
+        plt.show()
+
+    return sim_base, sim_intv
+
+
+def test_components():
+    """ Create, run, and plot a sim by assembling components """
+    people = ss.People(n_agents=n_agents)
+    network = ss.RandomNet(pars=dict(n_contacts=4))
+    sir = ss.SIR(pars=dict(dur_inf=10, beta=0.1))
+    sim = ss.Sim(diseases=sir, people=people, networks=network)
+    sim.run()
+    sim.plot()
+    return sim
+
+
+def test_parallel():
+    """ Test running two identical sims in parallel """
+    pars = make_sim_pars()
+    s1 = ss.Sim(pars)
+    s2 = ss.Sim(pars)
+    # s1, s2 = ss.parallel(s1, s2).sims
+    # assert np.allclose(s1.summary[:], s2.summary[:], rtol=0, atol=0, equal_nan=True)
+    return s1, s2
+
+
+if __name__ == '__main__':
+    T = sc.timer()
+    
+    s1 = test_default()
+    s2 = test_simple()
+    s3a, s3b = test_sir_epi()
+    s4_base, s4_intv = test_simple_vax(do_plot=True)
+    s5 = test_components()
+    s6a, s6b = test_parallel()
+    
+    T.toc()
diff --git a/tests/test_syphilis.py b/tests/test_syphilis.py
index 44189fc4..99ab1735 100644
--- a/tests/test_syphilis.py
+++ b/tests/test_syphilis.py
@@ -7,7 +7,6 @@
 import starsim as ss
 import pandas as pd
 import matplotlib.pyplot as plt
-import scipy.stats as sps
 
 quick_run = True
 ss.options['multirng']=False
@@ -15,27 +14,26 @@ def make_syph_sim(dt=1, n_agents=500):
     """ Make a sim with syphilis - used by several subsequent tests """
     syph = ss.Syphilis()
     syph.pars['beta'] = {'mf': [0.25, 0.15], 'maternal': [0.99, 0]}
-    syph.pars['seed_infections'] = sps.bernoulli(p=0.1)
+    syph.pars['init_prev'] = ss.bernoulli(p=0.1)
 
     # Make demographic modules
     fertility_rates = {'fertility_rate': pd.read_csv(ss.root / 'tests/test_data/nigeria_asfr.csv')}
     pregnancy = ss.Pregnancy(pars=fertility_rates)
     death_rates = {'death_rate': pd.read_csv(ss.root / 'tests/test_data/nigeria_deaths.csv')}
-    death = ss.background_deaths(death_rates)
+    death = ss.Deaths(death_rates)
 
     # Make people and networks
     ss.set_seed(1)
-    ppl = ss.People(n_agents, age_data=pd.read_csv(ss.root / 'tests/test_data/nigeria_age.csv')) # CK: temporary small pop size
+    ppl = ss.People(n_agents, age_data=pd.read_csv(ss.root / 'tests/test_data/nigeria_age.csv'))
 
     # Marital
-    mf = ss.mf(
-        pars=dict(
-            duration_dist=ss.lognorm(mean=1/24, stdev=0.5),
-            acts=ss.lognorm(mean=80, stdev=30),
+    mf = ss.MFNet(
+        pars = dict(
+            duration = ss.lognorm_mean(mean=1/24, stdev=0.5),
+            acts = ss.lognorm_mean(mean=80, stdev=30),
         )
     )
-    maternal = ss.maternal()
-    ppl.networks = ss.ndict(mf, maternal)
+    maternal = ss.MaternalNet()
 
     sim_kwargs = dict(
         dt=dt,
@@ -44,6 +42,7 @@ def make_syph_sim(dt=1, n_agents=500):
         n_years=40,
         people=ppl,
         diseases=syph,
+        networks=ss.ndict(mf, maternal),
         demographics=[pregnancy, death],
     )
 
@@ -55,6 +54,7 @@ class check_states(ss.Analyzer):
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
+        self.name = 'check_states'
         self.okay = True
         return
 
@@ -90,7 +90,7 @@ def apply(self, sim):
 def test_syph(dt=1, n_agents=500):
 
     sim_kwargs = make_syph_sim(dt=dt, n_agents=n_agents)
-    sim = ss.Sim(analyzers=[check_states], **sim_kwargs)
+    sim = ss.Sim(analyzers=check_states(), **sim_kwargs)
     sim.run()
 
     # Check plots
@@ -130,7 +130,7 @@ def test_syph_intvs(dt=1, n_agents=500, do_plot=False):
 
     # Interventions
     # screen_eligible = lambda sim: sim.demographics.pregnancy.pregnant
-    screen_eligible = lambda sim: sim.people.networks.mf.active(sim.people)
+    screen_eligible = lambda sim: sim.networks.mf.active(sim.people)
     syph_screening = ss.syph_screening(
         product='rpr',
         prob=0.99,
@@ -148,7 +148,7 @@ def test_syph_intvs(dt=1, n_agents=500, do_plot=False):
     )
 
     sim_kwargs1 = make_syph_sim(dt=dt, n_agents=n_agents)
-    sim_intv = ss.Sim(analyzers=[check_states], interventions=[syph_screening, bpg], **sim_kwargs1)
+    sim_intv = ss.Sim(analyzers=[check_states()], interventions=[syph_screening, bpg], **sim_kwargs1)
     sim_intv.run()
 
     # Check plots
diff --git a/tests/update_baseline.py b/tests/update_baseline.py
old mode 100755
new mode 100644