Repository: starsimhub/starsim (language: python)

Issue:
Rethink Starsim parameters and API
There are lots of quirks and inconveniences with how parameters are supplied, initialized, and validated. For example, this works:
```py
sim = ss.Sim(pars=dict(networks='random', diseases='sir'), people=ppl, demographics=demographics)
```

But these should also work:

```py
# Supply strings as kwargs
sim = ss.Sim(people=ppl, demographics=demographics, networks='random', diseases='sir')


# Supply people as a parameter
pars=dict(networks='random', diseases='sir', people=ppl, demographics=demographics)
sim = ss.Sim(pars)
```

Tasks:
- [x] Move initialization into `parameters.py`
- [x] Replace `dictmergeleft()` with `pars.update()`
- [x] Replace `par_dists` (was #354)
- [x] Reimplement `convert_plugins` (was #284)
- [x] Copy inputs flag (was #103)
- [x] Allow smarter dict-based parameter input
- [x] Link modules to sim
Demographic update_results normalization
### Description

* Normalizing `update_results` function for demographic modules, resolving #497.
* Also found and fixed a bug in the uids that are used to choose birth sex from pregnancy.
Demographic results should be handled like other modules
I'm writing a custom demographics module based on Pregnancy. A custom intervention averts pregnancy-related deaths. However, there's no way to record true results (after demographic and intervention updates). Most other modules `update_results` near the end of `sim.step`. It's not as easy as calling update results because demographic modules have customized `update_results`, these will need to conform.


Discussion / hints:
I spent a little time thinking about this today. In some ways it'd be easier to do if we went back to the interpretation of all sim kwargs being entries of the pars dict, but I'm a little reluctant to do that



Fix patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 87737bd5..f65cec86 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -7,6 +7,108 @@ What's new
 All notable changes to the codebase are documented in this file. Changes that may result in differences in model output, or are required in order to run an old parameter set with the current version, are flagged with the term "Regression information".
 
 
+Version 0.5.0 (2024-05-07)
+--------------------------
+
+Summary
+~~~~~~~
+All inputs to the sim and modules now use a ``ss.Pars()`` class, which handles updating and validation. It is now not necessary to ever use ``pars=`` (although you still can if you want), so what was previously:
+
+``sim = ss.Sim(pars=dict(diseases='sir', networks='random'))``
+
+is now just:
+
+``sim = ss.Sim(diseases='sir', networks='random')``
+
+Updates happen recursively, so distributions etc. can be flexibly updated.
+
+This has significantly changed how modules are initialized; what was previously:
+
+.. code-block:: python
+
+    def __init__(self, pars=None, **kwargs):
+
+        pars = ss.omergeleft(pars,
+            dur_inf = 6,
+            init_prev = 0.01,
+            p_death = 0.01,
+            beta = 0.5,
+        )
+
+        par_dists = ss.omergeleft(par_dists,
+            dur_inf = ss.lognorm_ex,
+            init_prev = ss.bernoulli,
+            p_death = ss.bernoulli,
+        )
+
+        super().__init__(pars=pars, par_dists=par_dists, *args, **kwargs)
+
+is now:
+
+.. code-block:: python
+    
+    def __init__(self, pars=None, **kwargs):
+        super().__init__()
+        self.default_pars(
+            beta = 0.5,
+            init_prev = ss.bernoulli(0.01),
+            dur_inf = ss.lognorm_ex(6),
+            p_death = ss.bernoulli(0.01),
+        )
+        self.update_pars(pars, **kwargs)
+
+
+Parameter changes
+~~~~~~~~~~~~~~~~~
+- Added a ``ss.Pars`` class (and a ``ss.SimPars`` subclass) that handles parameter creation, updates, and validation.
+- Initialization has been moved from ``sim.py`` to ``parameters.py``; ``ss.Sim.convert_plugins()`` has been replaced by ``ss.SimPars.convert_modules()``.
+- The key method is ``ss.Pars.update()``, which performs all necessary validation on the parameters being updated.
+
+Initialization changes
+~~~~~~~~~~~~~~~~~~~~~~
+- Previously, the people were initialized first, then the states were initialized and the values populated, then the modules were initialized, and finally the distributions are initialized. This led to circular logic with the states being initialized based on uninitialized distributions. Now, states and modules are *linked* to the ``People`` and ``Sim`` objects, but further initialization is not done at this step. This ensures all distributions are created but not yet used. Next, distributions are initialized. Finally, the initial values are populated, and everything is initialized.
+- New methods supporting these changes include ``ss.link_dists()``, ``dist.link_sim()``, ``dist.link_module()``, ``sim.init_vals()``, ``people.init_vals()``, ``module.init_vals()``, 
+
+Module changes
+~~~~~~~~~~~~~~
+- Whereas modules previously initialized a dict of parameters and then called ``super().__init__(pars, **kwargs)``, they now call ``super().__init__()`` first, then ``self.default_pars(par1=x, par2=y)``, then finally ``self.update_pars(pars, **kwargs)``.
+- What was previously e.g. ``ss.Module(pars=dict(par=x))`` is now ``ss.Module(par=x)``.
+- ``par_dists`` has been removed; instead, distributions are specified in the default parameters, and are updated via the ``Pars`` object.
+- Modules now contain a link back to the ``Sim`` object. This means that all methods that used to have ``sim`` as an argument now do not, e.g. ``self.update()`` instead of ``self.update(sim)``.
+- ``ss.module_map()`` maps different module types to their location in the sim.
+- ``ss.find_modules()`` finds all available modules (including subclasses) in Starsim.
+- Removed ``ss.dictmerge()`` and ``ss.dictmergeleft`` (now handled by ``ss.Pars.update()``).
+- Removed ``ss.get_subclasses()`` and ``ss.all_subclasses()`` (now handled by ``ss.find_modules()``).
+- Modules can no longer be initialized with a ``name`` key; it must be ``type`` (e.g. ``dict(type='sir')`` rather than ``dict(name='sir')``.
+- Added ``to_json()`` and ``plot()`` methods to ``Module``.
+- Removed ``connectors.py``; connectors still exist but as an empty subclass of ``Module``.
+
+People and network changes
+~~~~~~~~~~~~~~~~~~~~~~~~~~
+- ``BasePeople`` has been removed and merged with ``People``.
+- Time parameters (``ti``, ``dt``, etc.) have been removed from ``People``. Use ``sim.ti``, ``sim.dt`` etc. instead. One consequence of this is that ``people.request_death()`` now requires a ``sim`` argument. Another is that network methods (e.g. ``add_pairs()``) now take ``sim`` arguments instead of ``people`` arguments.
+- ``SexualNetwork`` is now a subclass of ``DynamicNetwork``.
+- Removed ``ss.Networks`` (now just an ``ss.ndict``).
+- Network connectors have been removed.
+- ``Person`` has been implemented as a slice of ``sim.people[i]``.
+- There is a new parameter ``use_aging``; this defaults to ``True`` if demographic modules are supplied, and ``False`` otherwise.
+
+Other changes
+~~~~~~~~~~~~~
+- Boolean arrays have new methods ``true()``, ``false()``, and ``split()``, which return the UIDs for the ``True`` values (alias to ``arr.uids``), ``False`` values, and both sets of values, respectively. ``ss.bernoulli.split()`` has been added as an alias of ``ss.bernoulli.filter(both=True)``.
+- All inputs to a sim are now copied by default. To disable, use ``ss.Sim(..., copy_inputs=False)``.
+- There is a new ``Plugin`` class, which contains shared logic for Interventions and Analyzers. It has a ``from_func()``, which will generate an intervention/analyzer from a function.
+- Diseases no longer have a default value of ``beta=1`` assigned; beta must be defined explicitly if being used.
+- Individual diseases can now be plotted via either e.g. ``sim.plot('hiv')`` or ``sim.diseases.hiv.plot()``.
+- Distributions can be created from dicts via ``ss.make_dist()``.
+- A new function ``ss.check_sims_match()`` will check if the results of two or more simulations match.
+- ``ndict`` values can be accessed through a call; e.g. ``sim.diseases()`` is equivalent to ``sim.diseases.values()``.
+- Merged ``test_dcp.py`` and ``test_base.py`` into ``test_other.py``.
+- Renamed ``test_simple.py`` to ``test_sim.py``.
+- Renamed ``test_dists.py`` to ``test_randomness.py``.
+- *GitHub info*: PR `488 <https://github.com/starsimhub/starsim/pull/488>`_
+
+
 Version 0.4.0 (2024-04-24)
 --------------------------
 - Replace ``UIDArray``, ``ArrayView``, and ``State`` with ``Arr``, which has different subclasses for different data types (e.g. ``FloatArr``, ``BoolArr``, and ``IndexArr``). States are usually represented by ``BoolArr`` (e.g. ``sir.infected``), while other agent properties are represented by ``FloatArr`` (e.g. ``sir.rel_trans``).
diff --git a/docs/api/index.rst b/docs/api/index.rst
index e050cccd..3c9540c7 100644
--- a/docs/api/index.rst
+++ b/docs/api/index.rst
@@ -12,7 +12,6 @@ This page lists Starsim's full API, including all functions and classes.
    :recursive:
    :nosignatures:
 
-   starsim.connectors
    starsim.demographics
    starsim.disease
    starsim.distributions
diff --git a/docs/tutorials/tut_buildsim.ipynb b/docs/tutorials/tut_buildsim.ipynb
index 5e91b5da..0a374c47 100644
--- a/docs/tutorials/tut_buildsim.ipynb
+++ b/docs/tutorials/tut_buildsim.ipynb
@@ -34,15 +34,15 @@
     "\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_agents = 5_000,     # Number of agents to simulate\n",
+    "    networks = dict(      # *Networks* add detail on how the agents interact with each other \n",
+    "        type = '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",
+    "    diseases = dict(      # *Diseases* add detail on what diseases to model \n",
+    "        type = '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",
@@ -103,7 +103,7 @@
    "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})"
+    "network = ss.RandomNet(pars=dict(n_contacts=dict(type='poisson', scale=4)))"
    ]
   },
   {
diff --git a/docs/tutorials/tut_demographics.ipynb b/docs/tutorials/tut_demographics.ipynb
index f55cee97..93c3981c 100644
--- a/docs/tutorials/tut_demographics.ipynb
+++ b/docs/tutorials/tut_demographics.ipynb
@@ -20,10 +20,10 @@
     "import starsim as ss\n",
     "\n",
     "pars = dict(\n",
-    "    birth_rate=20,      # Annual crude birth rate (per 1000 people)\n",
-    "    death_rate=15,      # Annual crude death rate (per 1000 people)\n",
-    "    networks='randomnet',\n",
-    "    diseases='sir'\n",
+    "    birth_rate = 20,      # Annual crude birth rate (per 1000 people)\n",
+    "    death_rate = 15,      # Annual crude death rate (per 1000 people)\n",
+    "    networks = 'random',\n",
+    "    diseases = 'sir'\n",
     ")\n",
     "sim = ss.Sim(pars)"
    ]
@@ -47,10 +47,10 @@
    "outputs": [],
    "source": [
     "demographics = [\n",
-    "    ss.Births(pars={'birth_rate': 20}),\n",
-    "    ss.Deaths(pars={'death_rate': 15})\n",
+    "    ss.Births(birth_rate=20),\n",
+    "    ss.Deaths(death_rate=15)\n",
     "]\n",
-    "sim = ss.Sim(diseases=ss.SIR(), demographics=demographics, networks=ss.RandomNet())"
+    "sim = ss.Sim(diseases='sir', networks='random', demographics=demographics)"
    ]
   },
   {
@@ -68,6 +68,9 @@
   {
    "cell_type": "code",
    "execution_count": null,
+   "metadata": {
+    "collapsed": false
+   },
    "outputs": [],
    "source": [
     "demographics = [\n",
@@ -75,20 +78,17 @@
     "    ss.Deaths(pars={'death_rate': 15})\n",
     "]\n",
     "sim = ss.Sim(pars={'total_pop': 200e6, 'n_agents': 50e3}, demographics=demographics)"
-   ],
-   "metadata": {
-    "collapsed": false
-   }
+   ]
   },
   {
    "cell_type": "markdown",
+   "metadata": {
+    "collapsed": false
+   },
    "source": [
     "## 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: "
-   ],
-   "metadata": {
-    "collapsed": false
-   }
+   ]
   },
   {
    "cell_type": "code",
@@ -117,7 +117,7 @@
     "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 = ss.Sim(total_pop=nga_pop_1995, start=1995, people=ppl, demographics=demographics, networks='random', diseases='sir')\n",
     "sim.run()\n",
     "\n",
     "# Read in a file with the actual population size\n",
diff --git a/docs/tutorials/tut_diseases.ipynb b/docs/tutorials/tut_diseases.ipynb
index c274cb62..2ba4a8e5 100644
--- a/docs/tutorials/tut_diseases.ipynb
+++ b/docs/tutorials/tut_diseases.ipynb
@@ -29,8 +29,8 @@
    "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",
+    "sir = ss.SIR(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='random')\n",
     "sim.run()"
    ]
   },
@@ -74,20 +74,51 @@
    },
    "outputs": [],
    "source": [
+    "import sciris as sc\n",
+    "import starsim as ss\n",
+    "import pylab as pl\n",
+    "sc.options(jupyter=True)\n",
+    "\n",
+    "class simple_hiv_ng(ss.Connector):\n",
+    "    \"\"\" Simple connector whereby rel_sus to NG doubles if CD4 count is <200\"\"\"\n",
+    "    def __init__(self, pars=None, **kwargs):\n",
+    "        super().__init__(label='HIV-Gonorrhea', requires=[ss.HIV, ss.Gonorrhea])\n",
+    "        self.default_pars(\n",
+    "            rel_trans_hiv  = 2,\n",
+    "            rel_trans_aids = 5,\n",
+    "            rel_sus_hiv    = 2,\n",
+    "            rel_sus_aids   = 5,\n",
+    "        )\n",
+    "        self.update_pars(pars, **kwargs)\n",
+    "        return\n",
+    "\n",
+    "    def update(self):\n",
+    "        \"\"\" Specify how HIV increases NG rel_sus and rel_trans \"\"\"\n",
+    "        people = self.sim.people\n",
+    "        people.gonorrhea.rel_sus[people.hiv.cd4 < 500] = self.pars.rel_sus_hiv\n",
+    "        people.gonorrhea.rel_sus[people.hiv.cd4 < 200] = self.pars.rel_sus_aids\n",
+    "        people.gonorrhea.rel_trans[people.hiv.cd4 < 500] = self.pars.rel_trans_hiv\n",
+    "        people.gonorrhea.rel_trans[people.hiv.cd4 < 200] = self.pars.rel_trans_aids\n",
+    "        return\n",
+    "\n",
     "# 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",
+    "hiv = ss.HIV(\n",
+    "    beta = {'mf': [0.0008, 0.0004]},  # Specify transmissibility over the MF network\n",
+    "    init_prev = 0.05,\n",
+    ")\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",
+    "ng = ss.Gonorrhea(\n",
+    "    beta = {'mf': [0.05, 0.025]},  # Specify transmissibility over the MF network\n",
+    "    init_prev = 0.025,\n",
+    ")\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()"
+    "sim = ss.Sim(n_agents=n_agents, networks='mf', diseases=[hiv, ng], connectors=simple_hiv_ng())\n",
+    "sim.run()\n",
+    "sim.plot('hiv')\n",
+    "sim.plot('gonorrhea');"
    ]
   }
  ],
diff --git a/docs/tutorials/tut_interventions.ipynb b/docs/tutorials/tut_interventions.ipynb
index b1794f6c..053294c9 100644
--- a/docs/tutorials/tut_interventions.ipynb
+++ b/docs/tutorials/tut_interventions.ipynb
@@ -40,12 +40,13 @@
    "outputs": [],
    "source": [
     "import starsim as ss\n",
+    "\n",
     "pars = dict(\n",
     "    n_agents = 5_000,\n",
     "    birth_rate = 20,\n",
     "    death_rate = 15,\n",
     "    networks = dict(\n",
-    "        type = 'randomnet',\n",
+    "        type = 'randomnet', # Or 'random'\n",
     "        n_contacts = 4\n",
     "    ),\n",
     "    diseases = dict(\n",
@@ -74,19 +75,19 @@
    "outputs": [],
    "source": [
     "# Create the product - a vaccine with 50% efficacy\n",
-    "MyVaccine = ss.sir_vaccine(pars=dict(efficacy=0.5))\n",
+    "my_vaccine = ss.sir_vaccine(efficacy=0.5)\n",
     "\n",
     "# Create the intervention\n",
-    "MyIntervention = ss.routine_vx(\n",
+    "my_intervention = 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",
+    "    product=my_vaccine   # 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 = ss.Sim(pars=pars, interventions=my_intervention)\n",
     "sim_intv.run()"
    ]
   },
@@ -108,9 +109,13 @@
    "outputs": [],
    "source": [
     "import matplotlib.pyplot as plt\n",
+    "\n",
+    "res_base = sim_base.results\n",
+    "res_intv = sim_intv.results\n",
+    "\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.plot(res_base.yearvec, res_base.sir.prevalence, label='Baseline')\n",
+    "plt.plot(res_intv.yearvec, res_intv.sir.prevalence, label='Vax')\n",
     "plt.axvline(x=2015, color='k', ls='--')\n",
     "plt.title('Prevalence')\n",
     "plt.legend()\n",
@@ -143,7 +148,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_intro.ipynb b/docs/tutorials/tut_intro.ipynb
index 60b0c5ac..f26e4b41 100644
--- a/docs/tutorials/tut_intro.ipynb
+++ b/docs/tutorials/tut_intro.ipynb
@@ -42,11 +42,11 @@
     "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",
+    "        type = 'random',  # Here, we use a 'random' network\n",
     "        n_contacts = 10    # Each person has an average of 10 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",
+    "        type = 'sir',     # Here, we're creating an SIR disease \n",
     "        init_prev = 0.1,  # Proportion of the population initially infected \n",
     "        beta = 0.5,       # Probability of transmission between contacts\n",
     "    )\n",
@@ -55,7 +55,7 @@
     "# Make the sim, run and plot\n",
     "sim = ss.Sim(pars)\n",
     "sim.run()\n",
-    "sim.plot()"
+    "sim.plot();"
    ]
   },
   {
diff --git a/docs/tutorials/tut_networks.ipynb b/docs/tutorials/tut_networks.ipynb
index 441e4cab..939134cb 100644
--- a/docs/tutorials/tut_networks.ipynb
+++ b/docs/tutorials/tut_networks.ipynb
@@ -78,15 +78,14 @@
    "outputs": [],
    "source": [
     "# Create the disease\n",
-    "syph = ss.Syphilis()\n",
-    "syph.pars['beta'] = {'mf': [0.25, 0.15]}    # See notes below \n",
+    "syph = ss.Syphilis(\n",
+    "    beta = {'mf': [0.25, 0.15]},    # See notes below \n",
+    ")\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",
+    "    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",
     "pars = dict(dt=1/12, start=2000, n_years=20)\n",
@@ -101,7 +100,7 @@
    "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",
+    "syph.pars.update(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",
@@ -117,12 +116,13 @@
    },
    "outputs": [],
    "source": [
-    "syph = ss.Syphilis()\n",
-    "syph.pars['beta'] = {'mf': [0.25, 0.15], 'maternal': [0.99, 0]}\n",
+    "syph = ss.Syphilis(\n",
+    "    beta = {'mf': [0.25, 0.15], 'maternal': [0.99, 0]}\n",
+    ")\n",
     "\n",
     "# Make demographic modules\n",
-    "pregnancy = ss.Pregnancy(pars={'fertility_rate': 20})\n",
-    "death = ss.Deaths(pars={'death_rate': 15})\n",
+    "pregnancy = ss.Pregnancy(fertility_rate=20)\n",
+    "death = ss.Deaths(death_rate=15)\n",
     "\n",
     "# Make maternal network\n",
     "maternal = ss.MaternalNet()\n",
diff --git a/starsim/__init__.py b/starsim/__init__.py
index 429c1a7d..78e308d4 100644
--- a/starsim/__init__.py
+++ b/starsim/__init__.py
@@ -1,7 +1,7 @@
 from .version import __version__, __versiondate__, __license__
 from .settings      import *
-from .parameters    import *
 from .utils         import *
+from .parameters    import *
 from .distributions import *
 from .states        import *
 from .people        import *
@@ -12,7 +12,6 @@
 from .products      import *
 from .interventions import *
 from .demographics  import *
-from .connectors    import *
 from .disease       import *
 from .diseases      import *
 from .sim           import *
diff --git a/starsim/connectors.py b/starsim/connectors.py
deleted file mode 100644
index 089d8fe6..00000000
--- a/starsim/connectors.py
+++ /dev/null
@@ -1,38 +0,0 @@
-'''
-Define connections between disease modules
-'''
-
-import starsim as ss
-
-
-class Connector(ss.Module):
-    def __init__(self, pars=None, diseases=None, *args, **kwargs):
-        super().__init__(pars=pars, requires=diseases, *args, **kwargs)
-        return
-
-
-# %% Individual connectors
-
-class simple_hiv_ng(Connector):
-    """ Simple connector whereby rel_sus to NG doubles if CD4 count is <200"""
-
-    def __init__(self, pars=None):
-        super().__init__(pars=pars, label='HIV-Gonorrhea', diseases=[ss.HIV, ss.Gonorrhea])
-        self.pars = ss.dictmerge({
-            'rel_trans_hiv': 2,
-            'rel_trans_aids': 5,
-            'rel_sus_hiv': 2,
-            'rel_sus_aids': 5,
-        }, self.pars)
-        return
-
-    def update(self, sim):
-        """ Specify how HIV increases NG rel_sus and rel_trans """
-
-        sim.people.gonorrhea.rel_sus[sim.people.hiv.cd4 < 500] = self.pars.rel_sus_hiv
-        sim.people.gonorrhea.rel_sus[sim.people.hiv.cd4 < 200] = self.pars.rel_sus_aids
-
-        sim.people.gonorrhea.rel_trans[sim.people.hiv.cd4 < 500] = self.pars.rel_trans_hiv
-        sim.people.gonorrhea.rel_trans[sim.people.hiv.cd4 < 200] = self.pars.rel_trans_aids
-
-        return
\ No newline at end of file
diff --git a/starsim/demographics.py b/starsim/demographics.py
index cd132483..aed2f23f 100644
--- a/starsim/demographics.py
+++ b/starsim/demographics.py
@@ -11,43 +11,47 @@
 
 
 class Demographics(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
-
+    """
+    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.
+    """
     def initialize(self, sim):
         super().initialize(sim)
-        self.init_results(sim)
+        self.init_results()
         return
 
-    def init_results(self, sim):
+    def init_results(self):
+        pass
+
+    def update(self):
         pass
 
-    def update(self, sim):
-        # Note that for demographic modules, any result updates should be
-        # carried out inside this function
+    def update_results(self):
         pass
 
 
 class Births(Demographics):
+    """ Create births based on rates, rather than based on pregnancy """
     def __init__(self, pars=None, metadata=None, **kwargs):
-        super().__init__(pars, **kwargs)
-
-        # Set defaults
-        self.pars = ss.dictmergeleft(self.pars,
+        super().__init__()
+        self.default_pars(
             birth_rate = 0,
             rel_birth = 1,
             units = 1e-3,  # assumes birth rates are per 1000. If using percentages, switch this to 1
         )
+        self.update_pars(pars, **kwargs)
 
         # Process metadata. Defaults here are the labels used by UN data
-        self.metadata = ss.dictmergeleft(metadata,
-            data_cols = dict(year='Year', cbr='CBR'),
+        self.metadata = sc.mergedicts(
+            sc.objdict(data_cols=dict(year='Year', cbr='CBR')),
+            metadata,
         )
 
         # 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.birth_rate = self.standardize_birth_data()
+        self.n_births = 0 # For results tracking
         return
 
     def initialize(self, sim):
@@ -65,23 +69,25 @@ def standardize_birth_data(self):
         birth_rate = ss.standardize_data(data=self.pars.birth_rate, metadata=self.metadata)
         return birth_rate
 
-    def init_results(self, sim):
+    def init_results(self):
+        npts = self.sim.npts
         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),
+            ss.Result(self.name, 'new', npts, dtype=int, scale=True),
+            ss.Result(self.name, 'cumulative', npts, dtype=int, scale=True),
+            ss.Result(self.name, 'cbr', npts, dtype=int, scale=False),
         ]
         return
 
-    def update(self, sim):
-        new_uids = self.add_births(sim)
-        self.update_results(len(new_uids), sim)
+    def update(self):
+        new_uids = self.add_births()
+        self.n_births = len(new_uids)
         return new_uids
 
-    def get_births(self, sim):
+    def get_births(self):
         """
         Extract the right birth rates to use and translate it into a number of people to add.
         """
+        sim = self.sim
         p = self.pars
         if sc.isnumber(p.birth_rate):
             this_birth_rate = p.birth_rate
@@ -93,26 +99,28 @@ def get_births(self, sim):
         n_new = int(np.floor(sim.people.alive.count() * scaled_birth_prob))
         return n_new
 
-    def add_births(self, sim):
-        # Add n_new births to each state in the sim
-        n_new = self.get_births(sim)
-        new_uids = sim.people.grow(n_new)
-        sim.people.age[new_uids] = 0
+    def add_births(self):
+        """ Add n_new births to each state in the sim """
+        people = self.sim.people
+        n_new = self.get_births()
+        new_uids = people.grow(n_new)
+        people.age[new_uids] = 0
         return new_uids
 
-    def update_results(self, n_new, sim):
-        self.results['new'][sim.ti] = n_new
+    def update_results(self):
+        self.results['new'][self.sim.ti] = self.n_births
         return
 
-    def finalize(self, sim):
-        super().finalize(sim)
-        self.results['cumulative'] = np.cumsum(self.results['new'])
-        self.results['cbr'] = 1/self.pars.units*np.divide(self.results['new'] / sim.dt, sim.results['n_alive'], where=sim.results['n_alive']>0)
+    def finalize(self):
+        super().finalize()
+        res = self.sim.results
+        self.results.cumulative = np.cumsum(self.results.new)
+        self.results.cbr = 1/self.pars.units*np.divide(self.results.new/self.sim.dt, res.n_alive, where=res.n_alive>0)
         return
 
 
 class Deaths(Demographics):
-    def __init__(self, pars=None, par_dists=None, metadata=None, **kwargs):
+    def __init__(self, pars=None, metadata=None, **kwargs):
         """
         Configure disease-independent "background" deaths.
 
@@ -136,58 +144,61 @@ def __init__(self, pars=None, par_dists=None, metadata=None, **kwargs):
                 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, **kwargs)
-
-        self.pars = ss.dictmergeleft(self.pars,
+        super().__init__()
+        self.default_pars(
             rel_death = 1,
             death_rate = 20,  # Default = a fixed rate of 2%/year, overwritten if data provided
             units = 1e-3,  # assumes death rates are per 1000. If using percentages, switch this to 1
         )
-
-        self.par_dists = ss.dictmergeleft(par_dists,
-            death_rate = ss.bernoulli
-        )
+        self.update_pars(pars, **kwargs)
 
         # Process metadata. Defaults here are the labels used by UN data
-        self.metadata = ss.dictmergeleft(metadata,
-            data_cols = dict(year='Time', sex='Sex', age='AgeGrpStart', value='mx'),
-            sex_keys = dict(f='Female', m='Male'),
+        self.metadata = sc.mergedicts(
+            sc.objdict(
+                data_cols = dict(year='Time', sex='Sex', age='AgeGrpStart', value='mx'),
+                sex_keys = dict(f='Female', m='Male'),
+            ),
+            metadata
         )
 
         # 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.death_rate_data = self.standardize_death_data()
-        self.pars.death_rate = self.make_death_prob_fn
-
+        self.death_rate_data = self.standardize_death_data() # TODO: refactor
+        self.pars.death_rate = ss.bernoulli(p=self.make_death_prob_fn)
+        self.n_deaths = 0 # For results tracking
         return
+    
+    def standardize_death_data(self):
+        """ Standardize/validate death rates - handled in an external file due to shared functionality """
+        death_rate = ss.standardize_data(data=self.pars.death_rate, metadata=self.metadata)
+        return death_rate
 
-    @staticmethod
-    def make_death_prob_fn(module, sim, uids):
+    @staticmethod # Needs to be static since called externally, although it sure looks like a class method!
+    def make_death_prob_fn(self, 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.death_rate_data):
-            death_rate = module.death_rate_data
+        drd = self.death_rate_data
+        if sc.isnumber(drd) or isinstance(drd, ss.Dist):
+            death_rate = drd
 
         else:
             ppl = sim.people
-            data_cols = sc.objdict(module.metadata.data_cols)
+            data_cols = sc.objdict(self.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
+            sex_keys = self.metadata.sex_keys
 
-            available_years = module.death_rate_data[year_label].unique()
+            available_years = self.death_rate_data[year_label].unique()
             year_ind = sc.findnearest(available_years, sim.year)
             nearest_year = available_years[year_ind]
 
-            df = module.death_rate_data.loc[module.death_rate_data[year_label] == nearest_year]
+            df = self.death_rate_data.loc[self.death_rate_data[year_label] == nearest_year]
             age_bins = df[age_label].unique()
 
             f_arr = df[val_label].loc[df[sex_label] == sex_keys['f']].values
@@ -207,51 +218,57 @@ def make_death_prob_fn(module, sim, uids):
             death_rate = death_rate_df.values
 
         # Scale from rate to probability. Consider an exponential here.
-        death_prob = death_rate * (module.pars.units * module.pars.rel_death * sim.pars.dt)
+        death_prob = death_rate * (self.pars.units * self.pars.rel_death * sim.pars.dt)
         death_prob = np.clip(death_prob, a_min=0, a_max=1)
 
         return death_prob
 
-    def standardize_death_data(self):
-        """ Standardize/validate death rates - handled in an external file due to shared functionality """
-        death_rate = ss.standardize_data(data=self.pars.death_rate, metadata=self.metadata)
-        return death_rate
-
-    def init_results(self, sim):
+    def init_results(self):
+        npts = self.sim.npts
         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),
+            ss.Result(self.name, 'new', npts, dtype=int, scale=True),
+            ss.Result(self.name, 'cumulative', npts, dtype=int, scale=True),
+            ss.Result(self.name, 'cmr', npts, dtype=int, scale=False),
         ]
         return
 
-    def update(self, sim):
-        n_deaths = self.apply_deaths(sim)
-        self.update_results(n_deaths, sim)
+    def update(self):
+        self.n_deaths = self.apply_deaths()
         return
 
-    def apply_deaths(self, sim):
+    def apply_deaths(self):
         """ Select people to die """
         death_uids = self.pars.death_rate.filter()
-        sim.people.request_death(death_uids)
+        self.sim.people.request_death(death_uids)
         return len(death_uids)
 
-    def update_results(self, n_deaths, sim):
-        self.results['new'][sim.ti] = n_deaths
+    def update_results(self):
+        self.results['new'][self.sim.ti] = self.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.dt, sim.results['n_alive'], where=sim.results['n_alive']>0)
+    def finalize(self):
+        super().finalize()
+        n_alive = self.sim.results.n_alive
+        self.results.cumulative = np.cumsum(self.results.new)
+        self.results.cmr = 1/self.pars.units*np.divide(self.results.new / self.sim.dt, n_alive, where=n_alive>0)
         return
 
 
 class Pregnancy(Demographics):
-
-    def __init__(self, pars=None, par_dists=None, metadata=None, **kwargs):
-        super().__init__(pars, **kwargs)
-
+    """ Create births via pregnancies """
+    def __init__(self, pars=None, metadata=None, **kwargs):
+        super().__init__()
+        self.default_pars(
+            dur_pregnancy = 0.75,
+            dur_postpartum = 0.5,
+            fertility_rate = 0, # See make_fertility_prob_function
+            rel_fertility = 1,
+            maternal_death_prob = ss.bernoulli(0),
+            sex_ratio = ss.bernoulli(0.5), # Ratio of babies born female
+            units = 1e-3, # Assumes fertility rates are per 1000. If using percentages, switch this to 1
+        )
+        self.update_pars(pars, **kwargs)
+        
         # Other, e.g. postpartum, on contraception...
         self.add_states(
             ss.BoolArr('infertile'),  # Applies to girls and women outside the fertility window
@@ -264,55 +281,42 @@ def __init__(self, pars=None, par_dists=None, metadata=None, **kwargs):
             ss.FloatArr('ti_dead'),  # Maternal mortality
         )
 
-        self.pars = ss.dictmergeleft(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.dictmergeleft(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.dictmergeleft(metadata,
-            data_cols = dict(year='Time', age='AgeGrp', value='ASFR'),
+        self.metadata = sc.mergedicts(
+            sc.objdict(data_cols=dict(year='Time', age='AgeGrp', value='ASFR')),
+            metadata,
         )
-
-        self.choose_slots = ss.randint() # Low and high will be reset upon initialization
+        self.choose_slots = None # Distribution for choosing slots; set in self.initialize()
 
         # 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.fertility_rate_data = self.standardize_fertility_data()
-        self.pars.fertility_rate = self.make_fertility_prob_fn
+        self.pars.fertility_rate = ss.bernoulli(self.make_fertility_prob_fn)
 
+        # For results tracking
+        self.n_pregnancies = 0
+        self.n_births = 0
         return
 
     @staticmethod
-    def make_fertility_prob_fn(module, sim, uids):
+    def make_fertility_prob_fn(self, sim, uids):
         """ Take in the module, sim, and uids, and return the conception probability for each UID on this timestep """
 
-        if sc.isnumber(module.fertility_rate_data):
-            fertility_rate = module.fertility_rate_data
+        if sc.isnumber(self.fertility_rate_data):
+            fertility_rate = self.fertility_rate_data
 
         else:
             # Abbreviate key variables
-            data_cols = sc.objdict(module.metadata.data_cols)
+            data_cols = sc.objdict(self.metadata.data_cols)
             year_label = data_cols.year
             age_label  = data_cols.age
             val_label  = data_cols.value
 
-            available_years = module.fertility_rate_data[year_label].unique()
-            year_ind = sc.findnearest(available_years, sim.year-module.pars.dur_pregnancy)
+            available_years = self.fertility_rate_data[year_label].unique()
+            year_ind = sc.findnearest(available_years, sim.year-self.pars.dur_pregnancy)
             nearest_year = available_years[year_ind]
 
-            df = module.fertility_rate_data.loc[module.fertility_rate_data[year_label] == nearest_year]
+            df = self.fertility_rate_data.loc[self.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
 
@@ -324,7 +328,7 @@ def make_fertility_prob_fn(module, sim, uids):
 
             # Adjust rates: rates are based on the entire population, but we need to remove
             # anyone already pregnant and then inflate the rates for the remainder
-            pregnant_uids = module.pregnant.uids # Find agents who are already pregnant
+            pregnant_uids = self.pregnant.uids # Find agents who are already pregnant
             pregnant_age_counts, _ = np.histogram(sim.people.age[pregnant_uids], age_bins)  # Count them by age
             age_counts, _ = np.histogram(sim.people.age[uids], age_bins)  # Count overall number per age bin
             new_denom = age_counts - pregnant_age_counts  # New denominator for rates
@@ -339,7 +343,7 @@ def make_fertility_prob_fn(module, sim, uids):
             fertility_rate[pregnant_uids] = 0
 
         # Scale from rate to probability. Consider an exponential here.
-        fertility_prob = fertility_rate * (module.pars.units * module.pars.rel_fertility * sim.pars.dt)
+        fertility_prob = fertility_rate * (self.pars.units * self.pars.rel_fertility * sim.pars.dt)
         fertility_prob = np.clip(fertility_prob, a_min=0, a_max=1)
 
         return fertility_prob
@@ -353,94 +357,91 @@ def initialize(self, sim):
         super().initialize(sim)
         low = sim.pars.n_agents + 1
         high = int(sim.pars.slot_scale*sim.pars.n_agents)
-        self.choose_slots.set(low=low, high=high)
+        self.choose_slots = ss.randint(low=low, high=high, sim=sim, module=self)
         return
 
-    def init_results(self, sim):
+    def init_results(self):
         """
         Results could include a range of birth outcomes e.g. LGA, stillbirths, etc.
         Still unclear whether this logic should live in the pregnancy module, the
         individual disease modules, the connectors, or the sim.
         """
+        npts = self.sim.npts
         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),
+            ss.Result(self.name, 'pregnancies', npts, dtype=int, scale=True),
+            ss.Result(self.name, 'births', npts, dtype=int, scale=True),
+            ss.Result(self.name, 'cbr', npts, dtype=int, scale=False),
         ]
         return
 
-    def update(self, sim):
-        """
-        Perform all updates
-        """
-        self.update_states(sim)
-        conceive_uids = self.make_pregnancies(sim)
-        self.make_embryos(sim, conceive_uids)
-        self.update_results(sim)
+    def update(self):
+        """ Perform all updates """
+        self.update_states()
+        conceive_uids = self.make_pregnancies()
+        self.n_pregnancies = len(conceive_uids)
+        self.make_embryos(conceive_uids)
         return
 
-    def update_states(self, sim):
-        """
-        Update states
-        """
-
+    def update_states(self):
+        """ Update states """
         # Check for new deliveries
-        deliveries = self.pregnant & (self.ti_delivery <= sim.ti)
+        ti = self.sim.ti
+        deliveries = self.pregnant & (self.ti_delivery <= ti)
+        self.n_births = np.count_nonzero(deliveries)
         self.pregnant[deliveries] = False
         self.postpartum[deliveries] = True
         self.fecund[deliveries] = False
 
         # Check for new women emerging from post-partum
-        postpartum = ~self.pregnant & (self.ti_postpartum <= sim.ti)
+        postpartum = ~self.pregnant & (self.ti_postpartum <= ti)
         self.postpartum[postpartum] = False
         self.fecund[postpartum] = True
 
         # Maternal deaths
-        maternal_deaths = (self.ti_dead <= sim.ti).uids
-        sim.people.request_death(maternal_deaths)
-
+        maternal_deaths = (self.ti_dead <= ti).uids
+        self.sim.people.request_death(maternal_deaths)
         return
 
-    def make_pregnancies(self, sim):
-        """
-        Select people to make pregnant using incidence data
-        """
+    def make_pregnancies(self):
+        """ Select people to make pregnant using incidence data """
         # People eligible to become pregnant. We don't remove pregnant people here, these
         # are instead handled in the fertility_dist logic as the rates need to be adjusted
-        eligible_uids = sim.people.female.uids
+        eligible_uids = self.sim.people.female.uids
         conceive_uids = self.pars.fertility_rate.filter(eligible_uids)
 
         # Set prognoses for the pregnancies
         if len(conceive_uids) > 0:
-            self.set_prognoses(sim, conceive_uids)
-
+            self.set_prognoses(conceive_uids)
         return conceive_uids
 
-    def make_embryos(self, sim, conceive_uids):
+    def make_embryos(self, conceive_uids):
         """ Add properties for the just-conceived """
-        n_unborn_agents = len(conceive_uids)
-        if n_unborn_agents > 0:
+        people = self.sim.people
+        n_unborn = len(conceive_uids)
+        if n_unborn == 0:
+            new_uids = ss.uids()
+        else:
 
             # Choose slots for the unborn agents
             new_slots = self.choose_slots.rvs(conceive_uids)
 
             # Grow the arrays and set properties for the unborn agents
-            new_uids = sim.people.grow(len(new_slots), 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.pars.sex_ratio.rvs(new_uids)
+            new_uids = people.grow(len(new_slots), new_slots)
+            people.age[new_uids] = -self.pars.dur_pregnancy
+            people.slot[new_uids] = new_slots  # Before sampling female_dist
+            people.female[new_uids] = self.pars.sex_ratio.rvs(conceive_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.networks.items():
+            for lkey, layer in self.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)
+                    durs = np.full(n_unborn, fill_value=self.pars.dur_pregnancy + self.pars.dur_postpartum)
                     layer.add_pairs(conceive_uids, new_uids, dur=durs)
 
-        return
+        return new_uids
 
-    def set_prognoses(self, sim, uids):
+    def set_prognoses(self, uids):
         """
         Make pregnancies
         Add miscarriage/termination logic here
@@ -449,27 +450,31 @@ def set_prognoses(self, sim, uids):
         """
 
         # Change states for the newly pregnant woman
+        ti = self.sim.ti
+        dt = self.sim.dt
         self.fecund[uids] = False
         self.pregnant[uids] = True
-        self.ti_pregnant[uids] = sim.ti
+        self.ti_pregnant[uids] = ti
 
         # Outcomes for pregnancies
-        dur_preg = np.full(len(uids), self.pars.dur_pregnancy / sim.dt)
-        dur_postpartum = np.full(len(uids), self.pars.dur_postpartum / sim.dt)
-        dead = self.pars.maternal_death_rate.rvs(uids)
-        self.ti_delivery[uids] = sim.ti + dur_preg # Currently assumes maternal deaths still result in a live baby
-        self.ti_postpartum[uids] = sim.ti + dur_preg + dur_postpartum
+        dur_preg = np.full(len(uids), self.pars.dur_pregnancy / dt)
+        dur_postpartum = np.full(len(uids), self.pars.dur_postpartum / dt)
+        dead = self.pars.maternal_death_prob.rvs(uids)
+        self.ti_delivery[uids] = ti + dur_preg # Currently assumes maternal deaths still result in a live baby
+        self.ti_postpartum[uids] = ti + dur_preg + dur_postpartum
 
         if np.any(dead): # NB: 100x faster than np.sum(), 10x faster than np.count_nonzero()
-            self.ti_dead[uids[dead]] = sim.ti + dur_preg[dead]
+            self.ti_dead[uids[dead]] = ti + dur_preg[dead]
         return
 
-    def update_results(self, sim):
-        self.results['pregnancies'][sim.ti] = np.count_nonzero(self.ti_pregnant == sim.ti)
-        self.results['births'][sim.ti] = np.count_nonzero(self.ti_delivery == sim.ti)
+    def update_results(self):
+        ti = self.sim.ti
+        self.results['pregnancies'][ti] = self.n_pregnancies
+        self.results['births'][ti] = self.n_births
         return
 
-    def finalize(self, sim):
-        super().finalize(sim)
-        self.results['cbr'] = 1/self.pars.units * np.divide(self.results['births'] / sim.dt, sim.results['n_alive'], where=sim.results['n_alive']>0)
+    def finalize(self):
+        super().finalize()
+        n_alive = self.sim.results.n_alive
+        self.results['cbr'] = 1/self.pars.units * np.divide(self.results['births'] / self.sim.dt, n_alive, where=n_alive>0)
         return
diff --git a/starsim/disease.py b/starsim/disease.py
index 2af7c713..fab11a8b 100644
--- a/starsim/disease.py
+++ b/starsim/disease.py
@@ -15,8 +15,8 @@
 class Disease(ss.Module):
     """ Base module class for diseases """
 
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
+    def __init__(self):
+        super().__init__()
         self.results = ss.Results(self.name)
         self.log = InfectionLog()  # See below for definition
         return
@@ -36,52 +36,10 @@ def _boolean_states(self):
 
     def initialize(self, sim):
         super().initialize(sim)
-        self.validate_pars(sim)
-        self.init_results(sim)
-        self.set_initial_states(sim)
+        self.init_results()
         return
 
-    def finalize(self, sim):
-        super().finalize(sim)
-        return
-
-    def validate_pars(self, sim):
-        """
-        Perform any parameter validation
-        """
-        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})
-                msg = f'Beta not supplied for disease "{self.name}"; defaulting to 1'
-                ss.warn(msg)
-
-            # 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):
-        """
-        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 Arr 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. This method is about supplying initial values
-        for the states (e.g., seeding initial infections)
-        """
-        pass
-
-    def init_results(self, sim):
+    def init_results(self):
         """
         Initialize results
 
@@ -90,16 +48,16 @@ def init_results(self, sim):
         Result for 'n_susceptible'
         """
         for state in self._boolean_states:
-            self.results += ss.Result(self.name, f'n_{state.name}', sim.npts, dtype=int, scale=True)
+            self.results += ss.Result(self.name, f'n_{state.name}', self.sim.npts, dtype=int, scale=True)
         return
 
-    def update_pre(self, sim):
+    def update_pre(self):
         """
         Carry out autonomous updates at the start of the timestep (prior to transmission)
         """
         pass
 
-    def update_death(self, sim, uids):
+    def update_death(self, uids):
         """
         Carry out state changes upon death
 
@@ -116,7 +74,7 @@ def update_death(self, sim, uids):
         """
         pass
 
-    def make_new_cases(self, sim):
+    def make_new_cases(self):
         """
         Add new cases of the disease
 
@@ -130,7 +88,7 @@ def make_new_cases(self, sim):
         """
         pass
 
-    def set_prognoses(self, sim, target_uids, source_uids=None):
+    def set_prognoses(self, target_uids, source_uids=None):
         """
         Set prognoses upon infection/acquisition
 
@@ -147,6 +105,7 @@ def set_prognoses(self, sim, target_uids, source_uids=None):
             uids (array): UIDs for agents to assign disease progoses to
             from_uids (array): Optionally specify the infecting agent
         """
+        sim = self.sim
         if source_uids is None:
             for target in target_uids:
                 self.log.append(np.nan, target, sim.year)
@@ -155,7 +114,7 @@ def set_prognoses(self, sim, target_uids, source_uids=None):
                 self.log.append(source, target, sim.year)
         return
 
-    def update_results(self, sim):
+    def update_results(self):
         """
         Update results
 
@@ -163,6 +122,7 @@ def update_results(self, sim):
         This allows result updates at this point to capture outcomes dependent on multiple
         modules, where relevant.
         """
+        sim = self.sim
         for state in self._boolean_states:
             self.results[f'n_{state.name}'][sim.ti] = np.count_nonzero(state & sim.people.alive)
         return
@@ -177,8 +137,8 @@ class Infection(Disease):
     operate on to capture co-infection
     """
 
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
+    def __init__(self):
+        super().__init__()
         self.add_states(
             ss.BoolArr('susceptible', default=True),
             ss.BoolArr('infected'),
@@ -187,9 +147,37 @@ def __init__(self, *args, **kwargs):
             ss.FloatArr('ti_infected'),
         )
 
+        # Define random number generators for make_new_cases
         self.rng_target = ss.random(name='target')
         self.rng_source = ss.random(name='source')
+        return
+    
+    def initialize(self, sim):
+        super().initialize(sim)
+        self.validate_beta()
+        return
+    
+    def validate_beta(self):
+        """
+        Perform any parameter validation
+        """
+        networks = self.sim.networks
+        if networks is not None and len(networks) > 0:
+            
+            if 'beta' not in self.pars:
+                errormsg = f'Disease {self.name} is missing beta; pars are: {sc.strjoin(self.pars.keys())}'
+                raise sc.KeyNotFoundError(errormsg)
+
+            # If beta is a scalar, apply this bi-directionally to all networks
+            if sc.isnumber(self.pars.beta):
+                β = self.pars.beta
+                self.pars.beta = sc.objdict({k:[β,β] for k in networks.keys()})
 
+            # If beta is a dict, check all entries are bi-directional
+            elif isinstance(self.pars.beta, dict):
+                for k,β in self.pars.beta.items():
+                    if sc.isnumber(β):
+                        self.pars.beta[k] = [β,β]
         return
 
     @property
@@ -200,7 +188,7 @@ def infectious(self):
         """
         return self.infected
 
-    def set_initial_states(self, sim):
+    def init_vals(self):
         """
         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 Arr objects
@@ -211,22 +199,23 @@ def set_initial_states(self, sim):
             return
 
         initial_cases = self.pars.init_prev.filter()
-        self.set_prognoses(sim, initial_cases)  # TODO: sentinel value to indicate seeds?
+        self.set_prognoses(initial_cases)  # TODO: sentinel value to indicate seeds?
         return
 
-    def init_results(self, sim):
+    def init_results(self):
         """
         Initialize results
         """
-        super().init_results(sim)
+        super().init_results()
+        sim = self.sim
         self.results += [
-            ss.Result(self.name, 'prevalence', sim.npts, dtype=float, scale=False),
+            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 _check_betas(self, sim):
+    def _check_betas(self):
         """ Check that there's a network for each beta key """
         # Ensure keys are lowercase
         if isinstance(self.pars.beta, dict): # TODO: check if needed
@@ -235,7 +224,7 @@ def _check_betas(self, sim):
         # Create a mapping between beta and networks, and populate it
         betapars = self.pars.beta
         betamap = sc.objdict()
-        netkeys = list(sim.networks.keys())
+        netkeys = list(self.sim.networks.keys())
         if netkeys: # Skip if no networks
             for bkey in betapars.keys():
                 orig_bkey = bkey[:]
@@ -251,7 +240,7 @@ def _check_betas(self, sim):
                         raise ValueError(errormsg)
         return betamap
 
-    def make_new_cases(self, sim):
+    def make_new_cases(self):
         """
         Add new cases of module, through transmission, incidence, etc.
         
@@ -260,15 +249,15 @@ def make_new_cases(self, sim):
         """
         new_cases = []
         sources = []
-        people = sim.people
-        betamap = self._check_betas(sim)
+        betamap = self._check_betas()
 
-        for nkey,net in sim.networks.items():
+        for nkey,net in self.sim.networks.items():
             if not len(net):
                 break
 
             nbetas = betamap[nkey]
             contacts = net.contacts
+
             rel_trans = self.rel_trans.asnew(self.infectious * self.rel_trans)
             rel_sus   = self.rel_sus.asnew(self.susceptible * self.rel_sus)
             p1p2b0 = [contacts.p1, contacts.p2, nbetas[0]]
@@ -280,7 +269,7 @@ def make_new_cases(self, sim):
                     continue
 
                 # Calculate probability of a->b transmission.
-                beta_per_dt = net.beta_per_dt(disease_beta=beta, dt=people.dt) # TODO: should this be sim.dt?
+                beta_per_dt = net.beta_per_dt(disease_beta=beta, dt=self.sim.dt)
                 p_transmit = rel_trans[src] * rel_sus[trg] * beta_per_dt
 
                 # Generate a new random number based on the two other random numbers -- 3x faster than `rvs = np.remainder(rvs_s + rvs_t, 1)`
@@ -303,27 +292,28 @@ def make_new_cases(self, sim):
             sources = np.empty(0, dtype=int)
             
         if len(new_cases):
-            self._set_cases(sim, new_cases, sources)
+            self._set_cases(new_cases, sources)
             
         return new_cases, sources
 
-    def _set_cases(self, sim, target_uids, source_uids=None):
+    def _set_cases(self, target_uids, source_uids=None):
+        sim = self.sim
         congenital = sim.people.age[target_uids] <= 0
         if np.count_nonzero(congenital):
             src_c = source_uids[congenital] if source_uids is not None else None
-            self.set_congenital(sim, target_uids[congenital], src_c)
+            self.set_congenital(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)
+        self.set_prognoses(target_uids[~congenital], src_p)
         return
 
-    def set_congenital(self, sim, target_uids, source_uids=None):
+    def set_congenital(self, target_uids, source_uids=None):
         pass
 
-    def update_results(self, sim):
-        super().update_results(sim)
+    def update_results(self):
+        super().update_results()
         res = self.results
-        ti = sim.ti
-        res.prevalence[ti] = res.n_infected[ti] / np.count_nonzero(sim.people.alive)
+        ti = self.sim.ti
+        res.prevalence[ti] = res.n_infected[ti] / np.count_nonzero(self.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])
         return
diff --git a/starsim/diseases/cholera.py b/starsim/diseases/cholera.py
index a5bed241..8f897bdd 100644
--- a/starsim/diseases/cholera.py
+++ b/starsim/diseases/cholera.py
@@ -14,46 +14,33 @@ class Cholera(ss.Infection):
     """
     Cholera
     """
-    def __init__(self, pars=None, par_dists=None, *args, **kwargs):
+    def __init__(self, pars=None, *args, **kwargs):
         """ Initialize with parameters """
-
-        pars = ss.dictmergeleft(pars,
-            # Natural history parameters, all specified in days
-            dur_exp2inf = ss.lognorm_ex(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(low=1, high=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_ex(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_ex(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/)
-
+        super().__init__()
+        self.default_pars(
             # Initial conditions and beta
-            init_prev = 0.005,
-            beta = None,
+            beta = 1.0, # Placeholder value
+            init_prev = ss.bernoulli(0.005),
+            
+            # Natural history parameters, all specified in days
+            dur_exp2inf   = ss.lognorm_ex(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(low=1, high=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_ex(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_ex(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       = ss.bernoulli(p=0.005), # Probability of death is typically less than 1% when treated
+            p_symp        = ss.bernoulli(p=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/)
 
             # 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
+            beta_env = 0.5 / 3, # Scaling factor for transmission from environment,
+            half_sat_rate = 1_000_000, # 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 = ss.bernoulli(p=0), # Probability of environmental transmission - filled out later
         )
-
-        par_dists = ss.dictmergeleft(par_dists,
-            dur_exp2inf    = ss.lognorm_ex,
-            dur_asymp2rec  = ss.uniform,
-            dur_symp2rec   = ss.lognorm_ex,
-            dur_symp2dead  = ss.lognorm_ex,
-            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)
+        self.update_pars(pars, **kwargs)
 
         # Boolean states
-        
         self.add_states(
             # Susceptible & infected are added automatically, here we add the rest
             ss.BoolArr('exposed'),
@@ -66,7 +53,6 @@ def __init__(self, pars=None, par_dists=None, *args, **kwargs):
             ss.FloatArr('ti_recovered'),
             ss.FloatArr('ti_dead'),
         )
-
         return
 
     @property
@@ -77,80 +63,84 @@ def infectious(self):
     def asymptomatic(self):
         return self.infected & ~self.symptomatic
 
-    def init_results(self, sim):
+    def init_results(self):
         """
         Initialize results
         """
-        super().init_results(sim)
+        super().init_results()
+        npts = self.sim.npts
         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),
+            ss.Result(self.name, 'new_deaths', npts, dtype=int),
+            ss.Result(self.name, 'cum_deaths', npts, dtype=int),
+            ss.Result(self.name, 'env_prev', npts, dtype=float),
+            ss.Result(self.name, 'env_conc', npts, dtype=float),
         ]
         return
 
-    def update_pre(self, sim):
+    def update_pre(self):
         """
         Adapted from https://github.com/optimamodel/gavi-outbreaks/blob/main/stisim/gavi/cholera.py
         Original version by Dom Delport
         """
         # Progress exposed -> infected
-        infected = (self.exposed & (self.ti_infected <= sim.ti)).uids
+        ti = self.sim.ti
+        infected = (self.exposed & (self.ti_infected <= ti)).uids
         self.infected[infected] = True
 
         # Progress infected -> symptomatic
-        symptomatic = (self.infected & (self.ti_symptomatic <= sim.ti)).uids
+        symptomatic = (self.infected & (self.ti_symptomatic <= ti)).uids
         self.symptomatic[symptomatic] = True
 
         # Progress symptomatic -> recovered
-        recovered = (self.infectious & (self.ti_recovered <= sim.ti)).uids
+        recovered = (self.infectious & (self.ti_recovered <= ti)).uids
         self.exposed[recovered] = False
         self.infected[recovered] = False
         self.symptomatic[recovered] = False
         self.recovered[recovered] = True
 
         # Trigger deaths
-        deaths = (self.ti_dead <= sim.ti).uids
+        deaths = (self.ti_dead <= ti).uids
         if len(deaths):
-            sim.people.request_death(deaths)
+            self.sim.people.request_death(deaths)
 
         # Update today's environmental prevalence
-        self.calc_environmental_prev(sim)
+        self.calc_environmental_prev()
 
         return
 
-    def calc_environmental_prev(self, sim):
+    def calc_environmental_prev(self):
         """
         Calculate environmental prevalence
         """
         p = self.pars
         r = self.results
+        ti = self.sim.ti
 
         n_symptomatic = self.symptomatic.sum()
         n_asymptomatic = self.asymptomatic.sum()
-        old_prev = self.results.env_prev[sim.ti-1]
+        old_prev = self.results.env_prev[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)
-
+        r.env_prev[ti] = new_bacteria + old_bacteria
+        r.env_conc[ti] = r.env_prev[ti] / (r.env_prev[ti] + p.half_sat_rate)
         return
 
-    def set_prognoses(self, sim, uids, source_uids=None):
+    def set_prognoses(self, uids, source_uids=None):
         """ Set prognoses for those who get infected """
-        super().set_prognoses(sim, uids, source_uids)
+        super().set_prognoses(uids, source_uids)
+        ti = self.sim.ti
+        dt = self.sim.dt
 
         self.susceptible[uids] = False
         self.exposed[uids] = True
-        self.ti_exposed[uids] = sim.ti
+        self.ti_exposed[uids] = ti
 
         p = self.pars
 
         # Determine when exposed become infected
-        self.ti_infected[uids] = sim.ti + p.dur_exp2inf.rvs(uids) / sim.dt
+        self.ti_infected[uids] = ti + p.dur_exp2inf.rvs(uids) / dt
 
         # Determine who becomes symptomatic and when
         symp_uids = p.p_symp.filter(uids)
@@ -158,43 +148,41 @@ def set_prognoses(self, sim, uids, source_uids=None):
 
         # Determine who dies and when
         dead_uids = p.p_death.filter(symp_uids)
-        self.ti_dead[dead_uids] = self.ti_symptomatic[dead_uids] + p.dur_symp2dead.rvs(dead_uids) / sim.dt
+        self.ti_dead[dead_uids] = self.ti_symptomatic[dead_uids] + p.dur_symp2dead.rvs(dead_uids) / dt
         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) / sim.dt
-        self.ti_recovered[asymp_uids] = self.ti_exposed[asymp_uids] + p.dur_asymp2rec.rvs(asymp_uids) / sim.dt
+        self.ti_recovered[symp_rev_uids] = self.ti_exposed[symp_rev_uids] + p.dur_symp2rec.rvs(symp_rev_uids) / dt
+        self.ti_recovered[asymp_uids] = self.ti_exposed[asymp_uids] + p.dur_asymp2rec.rvs(asymp_uids) / dt
 
         return
 
-    def make_new_cases(self, sim):
+    def make_new_cases(self):
         """ Add indirect transmission """
-
-        pars = self.pars
-        res = self.results
-
         # Make new cases via direct transmission
-        super().make_new_cases(sim)
-
+        super().make_new_cases()
+        
         # Make new cases via indirect transmission
-        p_transmit = res.env_conc[sim.ti] * pars.beta_env
+        pars = self.pars
+        res = self.results
+        p_transmit = res.env_conc[self.sim.ti] * pars.beta_env
         pars.p_env_transmit.set(p=p_transmit)
-        new_cases = pars.p_env_transmit.filter(sim.people.uid[self.susceptible]) # TODO: make syntax nicer
+        new_cases = pars.p_env_transmit.filter(self.sim.people.uid[self.susceptible]) # TODO: make syntax nicer
         if new_cases.any():
-            self.set_prognoses(sim, new_cases, source_uids=None)
+            self.set_prognoses(new_cases, source_uids=None)
         return
 
-    def update_death(self, sim, uids):
+    def update_death(self, 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)
+    def update_results(self):
+        super().update_results()
         res = self.results
-        ti = sim.ti
+        ti = self.sim.ti
         res.new_deaths[ti] = np.count_nonzero(self.ti_dead == ti)
         res.cum_deaths[ti] = np.sum(res.new_deaths[:ti+1])
         return
diff --git a/starsim/diseases/ebola.py b/starsim/diseases/ebola.py
index 162cf701..eeb3c012 100644
--- a/starsim/diseases/ebola.py
+++ b/starsim/diseases/ebola.py
@@ -13,45 +13,30 @@
 
 class Ebola(SIR):
 
-    def __init__(self, pars=None, par_dists=None, *args, **kwargs):
+    def __init__(self, pars=None, *args, **kwargs):
         """ Initialize with parameters """
-
-        pars = ss.dictmergeleft(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
-
+        super().__init__()
+        self.default_pars(
             # Initial conditions and beta
-            init_prev = 0.005,
-            beta = None,
-            sev_factor = 2.2,
+            init_prev       = ss.bernoulli(p=0.005),
+            beta            = 1.0, # Placeholder value
+            sev_factor      = 2.2,
             unburied_factor = 2.1,
+            
+            # Natural history parameters, all specified in days
+            dur_exp2symp    = ss.lognorm_ex(mean=12.7), # Add source
+            dur_symp2sev    = ss.lognorm_ex(mean=6), # Add source
+            dur_sev2dead    = ss.lognorm_ex(mean=1.5), # Add source
+            dur_dead2buried = ss.lognorm_ex(mean=2), # Add source
+            dur_symp2rec    = ss.lognorm_ex(mean=10), # Add source
+            dur_sev2rec     = ss.lognorm_ex(mean=10.4), # Add source
+            p_sev           = ss.bernoulli(p=0.7), # Add source
+            p_death         = ss.bernoulli(p=0.55), # Add source
+            p_safe_bury     = ss.bernoulli(p=0.25), # Probability of a safe burial - should be linked to diagnoses
         )
-
-        par_dists = ss.dictmergeleft(par_dists,
-            dur_exp2symp    = ss.lognorm_ex,
-            dur_symp2sev    = ss.lognorm_ex,
-            dur_sev2dead    = ss.lognorm_ex,
-            dur_dead2buried = ss.lognorm_ex,
-            dur_symp2rec    = ss.lognorm_ex,
-            dur_sev2rec     = ss.lognorm_ex,
-            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.update_pars(pars=pars, **kwargs)
         
+        # Boolean states
         self.add_states(
             # SIR are added automatically, here we add E
             ss.BoolArr('exposed'),
@@ -66,86 +51,87 @@ def __init__(self, pars=None, par_dists=None, *args, **kwargs):
             ss.FloatArr('ti_dead'),
             ss.FloatArr('ti_buried'),
         )
-
         return
 
     @property
     def infectious(self):
         return self.infected | self.exposed
 
-    def update_pre(self, sim):
+    def update_pre(self):
 
         # Progress exposed -> infected
-        infected = (self.exposed & (self.ti_infected <= sim.ti)).uids
+        ti = self.sim.ti
+        infected = (self.exposed & (self.ti_infected <= ti)).uids
         self.exposed[infected] = False
         self.infected[infected] = True
 
         # Progress infectious -> severe
-        severe = (self.infected & (self.ti_severe <= sim.ti)).uids
+        severe = (self.infected & (self.ti_severe <= ti)).uids
         self.severe[severe] = True
 
         # Progress infected -> recovered
-        recovered = (self.infected & (self.ti_recovered <= sim.ti)).uids
+        recovered = (self.infected & (self.ti_recovered <= ti)).uids
         self.infected[recovered] = False
         self.recovered[recovered] = True
 
         # Progress severe -> recovered
-        recovered_sev = (self.severe & (self.ti_recovered <= sim.ti)).uids
+        recovered_sev = (self.severe & (self.ti_recovered <= ti)).uids
         self.severe[recovered_sev] = False
         self.recovered[recovered_sev] = True
 
         # Trigger deaths
-        deaths = (self.ti_dead <= sim.ti).uids
+        deaths = (self.ti_dead <= ti).uids
         if len(deaths):
-            sim.people.request_death(deaths)
+            self.sim.people.request_death(deaths)
 
         # Progress dead -> buried
-        buried = (self.ti_buried <= sim.ti).uids
+        buried = (self.ti_buried <= ti).uids
         self.buried[buried] = True
         
         return
 
-    def set_prognoses(self, sim, uids, source_uids=None):
+    def set_prognoses(self, uids, source_uids=None):
         """ Set prognoses for those who get infected """
         # Do not call set_prognoses on the parent
         #super().set_prognoses(sim, uids, source_uids)
 
+        ti = self.sim.ti
+        dt = self.sim.dt
         self.susceptible[uids] = False
         self.exposed[uids] = True
-        self.ti_exposed[uids] = sim.ti
+        self.ti_exposed[uids] = ti
 
         p = self.pars
 
         # Determine when exposed become infected
-        self.ti_infected[uids] = sim.ti + p.dur_exp2symp.rvs(uids) / sim.dt
+        self.ti_infected[uids] = ti + p.dur_exp2symp.rvs(uids) / dt
 
         # 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) / sim.dt
+        self.ti_severe[sev_uids] = self.ti_infected[sev_uids] + p.dur_symp2sev.rvs(sev_uids) / dt
 
         # 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) / sim.dt
+        self.ti_dead[dead_uids] = self.ti_severe[dead_uids] + p.dur_sev2dead.rvs(dead_uids) / dt
         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) / sim.dt
+        self.ti_recovered[rec_sev_uids] = self.ti_severe[rec_sev_uids] + p.dur_sev2rec.rvs(rec_sev_uids) / dt
         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) / sim.dt
+        self.ti_recovered[rec_symp_uids] = self.ti_infected[rec_symp_uids] + p.dur_symp2rec.rvs(rec_symp_uids) / dt
 
         # 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) / sim.dt
+        self.ti_buried[unsafe_buried] = self.ti_dead[unsafe_buried] + p.dur_dead2buried.rvs(unsafe_buried) / dt
 
         # Change rel_trans values
         self.rel_trans[self.infectious] = 1
         self.rel_trans[self.severe] = self.pars['sev_factor']  # Change for severe
-        unburied_uids = ((self.ti_dead <= sim.ti) & (self.ti_buried > sim.ti)).uids
+        unburied_uids = ((self.ti_dead <= ti) & (self.ti_buried > ti)).uids
         self.rel_trans[unburied_uids] = self.pars['unburied_factor']  # Change for unburied
-
         return
 
-    def update_death(self, sim, uids):
+    def update_death(self, uids):
         # Reset infected/recovered flags for dead agents
         for state in ['susceptible', 'exposed', 'infected', 'severe', 'recovered']:
             self.statesdict[state][uids] = False
diff --git a/starsim/diseases/gonorrhea.py b/starsim/diseases/gonorrhea.py
index 6f6527f4..f3dba8a7 100644
--- a/starsim/diseases/gonorrhea.py
+++ b/starsim/diseases/gonorrhea.py
@@ -10,8 +10,18 @@
 
 class Gonorrhea(ss.Infection):
 
-    def __init__(self, pars=None, par_dists=None, *args, **kwargs):
-
+    def __init__(self, pars=None, *args, **kwargs):
+        # Parameters
+        super().__init__()
+        self.default_pars(
+            beta = 1.0, # Placeholder value
+            dur_inf_in_days = ss.lognorm_ex(mean=10, stdev=0.6),  # median of 10 days (IQR 7–15 days) https://sti.bmj.com/content/96/8/556
+            p_symp    = ss.bernoulli(p=0.5),  # Share of infections that are symptomatic. Placeholder value
+            p_clear   = ss.bernoulli(p=0.2),  # Share of infections that spontaneously clear: https://sti.bmj.com/content/96/8/556
+            init_prev = ss.bernoulli(p=0.1),
+        )
+        self.update_pars(pars=pars, **kwargs)
+        
         # States additional to the default disease states (see base class)
         # 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
@@ -20,67 +30,48 @@ def __init__(self, pars=None, par_dists=None, *args, **kwargs):
             ss.FloatArr('ti_clearance'),
             ss.FloatArr('p_symp', default=1),
         )
-
-        # Parameters
-        pars = ss.dictmergeleft(pars,
-            dur_inf_in_days = ss.lognorm_ex(mean=10, stdev=0.6),  # 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.dictmergeleft(par_dists,
-            dur_inf_in_days = ss.lognorm_ex,
-            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):
+    def init_results(self):
         """
         Initialize results
         """
-        super().init_results(sim)
-        self.results += ss.Result(self.name, 'new_clearances', sim.npts, dtype=int)
+        super().init_results()
+        self.results += ss.Result(self.name, 'new_clearances', self.sim.npts, dtype=int)
         return
 
-    def update_results(self, sim):
-        ti = sim.ti
-        super(Gonorrhea, self).update_results(sim)
+    def update_results(self):
+        super().update_results()
+        ti = self.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):
+    def update_pre(self):
         # What if something in here should depend on another module?
         # I guess we could just check for it e.g., 'if HIV in sim.modules' or
         # 'if 'hiv' in sim.people' or something
         # Natural clearance
-        clearances = self.ti_clearance <= sim.ti
+        clearances = self.ti_clearance <= self.sim.ti
         self.susceptible[clearances] = True
         self.infected[clearances] = False
         self.symptomatic[clearances] = False
-        self.ti_clearance[clearances] = sim.ti
+        self.ti_clearance[clearances] = self.sim.ti
 
         return
-    
-    def make_new_cases(self, sim):
-        super(Gonorrhea, self).make_new_cases(sim)
-        return
 
-    def set_prognoses(self, sim, target_uids, source_uids=None):
+    def set_prognoses(self, target_uids, source_uids=None):
         """
         Natural history of gonorrhea for adult infection
         """
-        super().set_prognoses(sim, target_uids, source_uids)
+        super().set_prognoses(target_uids, source_uids)
+        ti = self.sim.ti
 
         # Set infection status
         self.susceptible[target_uids] = False
         self.infected[target_uids] = True
-        self.ti_infected[target_uids] = sim.ti
+        self.ti_infected[target_uids] = ti
 
         # Set infection status
         symp_uids = self.pars.p_symp.filter(target_uids)
@@ -88,10 +79,6 @@ def set_prognoses(self, sim, target_uids, source_uids=None):
 
         # Set natural clearance
         clear_uids = self.pars.p_clear.filter(target_uids)
-        dur = sim.ti + self.pars.dur_inf_in_days.rvs(clear_uids)/365/sim.dt # Convert from days to years and then adjust for dt
+        dur = ti + self.pars.dur_inf_in_days.rvs(clear_uids)/365/self.sim.dt # Convert from days to years and then adjust for dt
         self.ti_clearance[clear_uids] = dur
-
-        return
-
-    def set_congenital(self, sim, target_uids, source_uids=None):
-        pass
+        return
\ No newline at end of file
diff --git a/starsim/diseases/hiv.py b/starsim/diseases/hiv.py
index ef153287..65e8976f 100644
--- a/starsim/diseases/hiv.py
+++ b/starsim/diseases/hiv.py
@@ -11,86 +11,73 @@
 
 class HIV(ss.Infection):
 
-    def __init__(self, pars=None, par_dists=None, *args, **kwargs):
-
-        # States
-        self.add_states(
-            ss.BoolArr('on_art'),
-            ss.FloatArr('ti_art'),
-            ss.FloatArr('ti_dead'), # Time of HIV-cause death
-            ss.FloatArr('cd4', default=500),
-        )
-
-        pars = ss.dictmergeleft(pars,
+    def __init__(self, pars=None, *args, **kwargs):
+        super().__init__()
+        self.default_pars(
+            beta = 1.0, # Placeholder value
             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,
+            init_prev = ss.bernoulli(p=0.05),
+            death_dist = ss.bernoulli(p=self.death_prob_func), # Uses p_death by default, modulated by CD4
+            p_death = 0.05,
         )
+        self.update_pars(pars=pars, **kwargs)
 
-        par_dists = ss.dictmergeleft(par_dists,
-            init_prev  = ss.bernoulli,
-            death_prob = ss.bernoulli,
+        # States
+        self.add_states(
+            ss.BoolArr('on_art'),
+            ss.FloatArr('ti_art'),
+            ss.FloatArr('ti_dead'), # Time of HIV-cause death
+            ss.FloatArr('cd4', default=500),
         )
-
-        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 make_death_prob(module, sim, uids):
+    def death_prob_func(module, sim, uids):
         p = module.pars
-        out = sim.dt * module.death_prob_data / (p.cd4_min - p.cd4_max)**2 *  (module.cd4[uids] - p.cd4_max)**2
+        out = sim.dt * p.p_death / (p.cd4_min - p.cd4_max)**2 *  (module.cd4[uids] - p.cd4_max)**2
         out = np.array(out)
         return out
 
-    def update_pre(self, sim):
+    def update_pre(self):
         """ Update CD4 """
-        self.cd4[sim.people.alive & self.infected & self.on_art] += (self.pars.cd4_max - self.cd4[sim.people.alive & self.infected & self.on_art])/self.pars.cd4_rate
-        self.cd4[sim.people.alive & self.infected & ~self.on_art] += (self.pars.cd4_min - self.cd4[sim.people.alive & self.infected & ~self.on_art])/self.pars.cd4_rate
+        people = self.sim.people
+        self.cd4[people.alive & self.infected & self.on_art] += (self.pars.cd4_max - self.cd4[people.alive & self.infected & self.on_art])/self.pars.cd4_rate
+        self.cd4[people.alive & self.infected & ~self.on_art] += (self.pars.cd4_min - self.cd4[people.alive & self.infected & ~self.on_art])/self.pars.cd4_rate
 
-        self.rel_trans[sim.people.alive & self.infected & self.on_art] = 1 - self.pars['art_efficacy']
+        self.rel_trans[people.alive & self.infected & self.on_art] = 1 - self.pars['art_efficacy']
 
-        can_die = sim.people.hiv.infected.uids
-        hiv_deaths = self.pars.death_prob.filter(can_die)
+        can_die = people.hiv.infected.uids
+        hiv_deaths = self.pars.death_dist.filter(can_die)
         
-        sim.people.request_death(hiv_deaths)
-        self.ti_dead[hiv_deaths] = sim.ti
+        people.request_death(hiv_deaths)
+        self.ti_dead[hiv_deaths] = self.sim.ti
         return
 
-    def init_results(self, sim):
-        """
-        Initialize results
-        """
-        super().init_results(sim)
-        self.results += ss.Result(self.name, 'new_deaths', sim.npts, dtype=int)
+    def init_results(self):
+        """ Initialize results """
+        super().init_results()
+        self.results += ss.Result(self.name, 'new_deaths', self.sim.npts, dtype=int)
         return
 
-    def update_results(self, sim):
-        super().update_results(sim)
-        self.results['new_deaths'][sim.ti] = np.count_nonzero(self.ti_dead == sim.ti)
+    def update_results(self):
+        super().update_results()
+        ti = self.sim.ti
+        self.results['new_deaths'][ti] = np.count_nonzero(self.ti_dead == ti)
         return 
 
-    def make_new_cases(self, sim):
-        # eff_condoms = sim.pars[self.name]['eff_condoms'] # TODO figure out how to add this
-        super().make_new_cases(sim)
-        return
-
-    def set_prognoses(self, sim, uids, source_uids=None):
-        super().set_prognoses(sim, uids, source_uids)
-
+    def set_prognoses(self, uids, source_uids=None):
+        super().set_prognoses(uids, source_uids)
         self.susceptible[uids] = False
         self.infected[uids] = True
-        self.ti_infected[uids] = sim.ti
+        self.ti_infected[uids] = self.sim.ti
         return
 
-    def set_congenital(self, sim, target_uids, source_uids):
-        return self.set_prognoses(sim, target_uids, source_uids)
+    def set_congenital(self, target_uids, source_uids):
+        return self.set_prognoses(target_uids, source_uids)
 
 
 # %% HIV-related interventions
diff --git a/starsim/diseases/measles.py b/starsim/diseases/measles.py
index bf7410f7..669fcaba 100644
--- a/starsim/diseases/measles.py
+++ b/starsim/diseases/measles.py
@@ -12,28 +12,20 @@
 
 class Measles(SIR):
 
-    def __init__(self, pars=None, par_dists=None, *args, **kwargs):
+    def __init__(self, pars=None, *args, **kwargs):
         """ Initialize with parameters """
-
-        pars = ss.dictmergeleft(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
-
+        super().__init__()
+        self.default_pars(
             # Initial conditions and beta
-            init_prev = 0.005,
-            beta = None,
-        )
-
-        par_dists = ss.dictmergeleft(par_dists,
-            dur_exp   = ss.normal,
-            dur_inf   = ss.normal,
-            init_prev = ss.bernoulli,
-            p_death   = ss.bernoulli,
+            beta = 1.0, # Placeholder value
+            init_prev = ss.bernoulli(p=0.005),
+            
+            # Natural history parameters, all specified in days
+            dur_exp = ss.normal(loc=8),        # (days) - source: US CDC
+            dur_inf = ss.normal(loc=11),       # (days) - source: US CDC
+            p_death = ss.bernoulli(p=0.005), # Probability of death
         )
-
-        super().__init__(pars=pars, par_dists=par_dists, *args, **kwargs)
+        self.update_pars(pars=pars, **kwargs)
 
         # SIR are added automatically, here we add E
         self.add_states(
@@ -47,36 +39,39 @@ def __init__(self, pars=None, par_dists=None, *args, **kwargs):
     def infectious(self):
         return self.infected | self.exposed
 
-    def update_pre(self, sim):
+    def update_pre(self):
         # Progress exposed -> infected
-        infected = (self.exposed & (self.ti_infected <= sim.ti)).uids
+        ti = self.sim.ti
+        infected = (self.exposed & (self.ti_infected <= ti)).uids
         self.exposed[infected] = False
         self.infected[infected] = True
 
         # Progress infected -> recovered
-        recovered = (self.infected & (self.ti_recovered <= sim.ti)).uids
+        recovered = (self.infected & (self.ti_recovered <= ti)).uids
         self.infected[recovered] = False
         self.recovered[recovered] = True
 
         # Trigger deaths
-        deaths = (self.ti_dead <= sim.ti).uids
+        deaths = (self.ti_dead <= ti).uids
         if len(deaths):
-            sim.people.request_death(deaths)
+            self.sim.people.request_death(deaths)
         return
 
-    def set_prognoses(self, sim, uids, source_uids=None):
+    def set_prognoses(self, uids, source_uids=None):
         """ Set prognoses for those who get infected """
         # Do not call set_prognosis on parent
         # super().set_prognoses(sim, uids, source_uids)
+        ti = self.sim.ti
+        dt = self.sim.dt
 
         self.susceptible[uids] = False
         self.exposed[uids] = True
-        self.ti_exposed[uids] = sim.ti
+        self.ti_exposed[uids] = ti
 
         p = self.pars
 
         # Determine when exposed become infected
-        self.ti_infected[uids] = sim.ti + p.dur_exp.rvs(uids) / sim.dt
+        self.ti_infected[uids] = ti + p.dur_exp.rvs(uids) / dt
 
         # Sample duration of infection, being careful to only sample from the
         # distribution once per timestep.
@@ -86,12 +81,12 @@ def set_prognoses(self, sim, uids, source_uids=None):
         will_die = p.p_death.rvs(uids)
         dead_uids = uids[will_die]
         rec_uids = uids[~will_die]
-        self.ti_dead[dead_uids] = self.ti_infected[dead_uids] + dur_inf[will_die] / sim.dt
-        self.ti_recovered[rec_uids] = self.ti_infected[rec_uids] + dur_inf[~will_die] / sim.dt
+        self.ti_dead[dead_uids] = self.ti_infected[dead_uids] + dur_inf[will_die] / dt
+        self.ti_recovered[rec_uids] = self.ti_infected[rec_uids] + dur_inf[~will_die] / dt
 
         return
 
-    def update_death(self, sim, uids):
+    def update_death(self, uids):
         # Reset infected/recovered flags for dead agents
         for state in ['susceptible', 'exposed', 'infected', 'recovered']:
             self.statesdict[state][uids] = False
diff --git a/starsim/diseases/ncd.py b/starsim/diseases/ncd.py
index e57d804b..a4cf2151 100644
--- a/starsim/diseases/ncd.py
+++ b/starsim/diseases/ncd.py
@@ -17,15 +17,15 @@ class NCD(ss.Disease):
     (e.g., hypertension, diabetes), a state for having the condition, and associated
     mortality.
     """
-    def __init__(self, pars=None):
-        default_pars = dict(
+    def __init__(self, pars=None, **kwargs):
+        super().__init__()
+        self.default_pars(
             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(c=2, scale=5), # Time in years between first becoming affected and death
         )
-
-        super().__init__(ss.dictmerge(default_pars, pars))
+        self.update_pars(pars=pars, **kwargs)
+        
         self.add_states(
             ss.BoolArr('at_risk'),
             ss.BoolArr('affected'),
@@ -38,49 +38,53 @@ def __init__(self, pars=None):
     def not_at_risk(self):
         return ~self.at_risk
 
-    def set_initial_states(self, sim):
+    def init_vals(self):
         """
         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.
         """
+        super().init_vals()
         initial_risk = self.pars['initial_risk'].filter()
         self.at_risk[initial_risk] = True
-        self.ti_affected[initial_risk] = sim.ti + sc.randround(self.pars['dur_risk'].rvs(initial_risk) / sim.dt)
+        self.ti_affected[initial_risk] = self.sim.ti + sc.randround(self.pars['dur_risk'].rvs(initial_risk) / self.sim.dt)
         return initial_risk
 
-    def update_pre(self, sim):
-        deaths = (self.ti_dead == sim.ti).uids
-        sim.people.request_death(deaths)
+    def update_pre(self):
+        ti = self.sim.ti
+        deaths = (self.ti_dead == ti).uids
+        self.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
+        self.results.new_deaths[ti] = len(deaths) # Log deaths attributable to this module
         return
 
-    def make_new_cases(self, sim):
-        new_cases = (self.ti_affected == sim.ti).uids
+    def make_new_cases(self):
+        ti = self.sim.ti
+        new_cases = (self.ti_affected == ti).uids
         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)
+        self.ti_dead[new_cases] = ti + sc.randround(prog_years / self.sim.dt)
+        super().set_prognoses(new_cases)
         return new_cases
 
-    def init_results(self, sim):
+    def init_results(self):
         """
         Initialize results
         """
-        super().init_results(sim)
+        super().init_results()
+        npts = self.sim.npts
         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),
+            ss.Result(self.name, 'n_not_at_risk', npts, dtype=int),
+            ss.Result(self.name, 'prevalence', npts, dtype=float),
+            ss.Result(self.name, 'new_deaths', npts, dtype=int),
         ]
         return
 
-    def update_results(self, sim):
-        super().update_results(sim)
-        ti = sim.ti
+    def update_results(self):
+        super().update_results()
+        ti = self.sim.ti
         self.results.n_not_at_risk[ti] = np.count_nonzero(self.not_at_risk)
-        self.results.prevalence[ti]    = np.count_nonzero(self.affected)/len(sim.people)
+        self.results.prevalence[ti]    = np.count_nonzero(self.affected)/len(self.sim.people)
         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
index cd8f7f4d..991845ea 100644
--- a/starsim/diseases/sir.py
+++ b/starsim/diseases/sir.py
@@ -17,22 +17,15 @@ class SIR(ss.Infection):
     infected/infectious, and recovered. It also includes deaths, and basic
     results.
     """
-
-    def __init__(self, pars=None, par_dists=None, *args, **kwargs):
-        pars = ss.dictmergeleft(pars,
-            dur_inf = 6,
-            init_prev = 0.01,
-            p_death = 0.01,
+    def __init__(self, pars=None, **kwargs):
+        super().__init__()
+        self.default_pars(
             beta = 0.5,
+            init_prev = ss.bernoulli(p=0.01),
+            dur_inf = ss.lognorm_ex(mean=6),
+            p_death = ss.bernoulli(p=0.01),
         )
-
-        par_dists = ss.dictmergeleft(par_dists,
-            dur_inf   = ss.lognorm_ex,
-            init_prev = ss.bernoulli,
-            p_death   = ss.bernoulli,
-        )
-
-        super().__init__(pars=pars, par_dists=par_dists, *args, **kwargs)
+        self.update_pars(pars, **kwargs)
 
         self.add_states(
             ss.BoolArr('recovered'),
@@ -41,8 +34,9 @@ def __init__(self, pars=None, par_dists=None, *args, **kwargs):
         )
         return
 
-    def update_pre(self, sim):
+    def update_pre(self):
         # Progress infectious -> recovered
+        sim = self.sim
         recovered = (self.infected & (self.ti_recovered <= sim.ti)).uids
         self.infected[recovered] = False
         self.recovered[recovered] = True
@@ -53,11 +47,13 @@ def update_pre(self, sim):
             sim.people.request_death(deaths)
         return
 
-    def set_prognoses(self, sim, uids, source_uids=None):
+    def set_prognoses(self, uids, source_uids=None):
         """ Set prognoses """
+        ti = self.sim.ti
+        dt = self.sim.dt
         self.susceptible[uids] = False
         self.infected[uids] = True
-        self.ti_infected[uids] = sim.ti
+        self.ti_infected[uids] = ti
 
         p = self.pars
 
@@ -69,12 +65,12 @@ def set_prognoses(self, sim, uids, source_uids=None):
         will_die = p.p_death.rvs(uids)
         dead_uids = uids[will_die]
         rec_uids = uids[~will_die]
-        self.ti_dead[dead_uids] = sim.ti + dur_inf[will_die] / sim.dt # Consider rand round, but not CRN safe
-        self.ti_recovered[rec_uids] = sim.ti + dur_inf[~will_die] / sim.dt
+        self.ti_dead[dead_uids] = ti + dur_inf[will_die] / dt # Consider rand round, but not CRN safe
+        self.ti_recovered[rec_uids] = ti + dur_inf[~will_die] / dt
 
         return
 
-    def update_death(self, sim, uids):
+    def update_death(self, uids):
         """ Reset infected/recovered flags for dead agents """
         self.susceptible[uids] = False
         self.infected[uids] = False
@@ -98,67 +94,62 @@ class SIS(ss.Infection):
     infected/infectious, and back to susceptible based on waning immunity. There
     is no death in this case.
     """
-    def __init__(self, pars=None, par_dists=None, *args, **kwargs):
-        pars = ss.dictmergeleft(pars,
-            dur_inf = 10,
-            init_prev = 0.01,
+    def __init__(self, pars=None, *args, **kwargs):
+        super().__init__()
+        self.default_pars(
             beta = 0.05,
+            init_prev = ss.bernoulli(p=0.01),
+            dur_inf = ss.lognorm_ex(mean=10),
             waning = 0.05,
             imm_boost = 1.0,
         )
+        self.update_pars(pars=pars, *args, **kwargs)
 
-        par_dists = ss.dictmergeleft(par_dists,
-            dur_inf   = ss.lognorm_ex,
-            init_prev = ss.bernoulli,
-        )
-        
         self.add_states(
             ss.FloatArr('ti_recovered'),
             ss.FloatArr('immunity', default=0.0),
         )
-
-        super().__init__(pars=pars, par_dists=par_dists, *args, **kwargs)
         return
 
-    def update_pre(self, sim):
+    def update_pre(self):
         """ Progress infectious -> recovered """
-        recovered = (self.infected & (self.ti_recovered <= sim.ti)).uids
+        recovered = (self.infected & (self.ti_recovered <= self.sim.ti)).uids
         self.infected[recovered] = False
         self.susceptible[recovered] = True
-        self.update_immunity(sim)
+        self.update_immunity()
         return
     
-    def update_immunity(self, sim):
+    def update_immunity(self):
         has_imm = (self.immunity > 0).uids
-        self.immunity[has_imm] = (self.immunity[has_imm])*(1 - self.pars.waning*sim.dt)
+        self.immunity[has_imm] = (self.immunity[has_imm])*(1 - self.pars.waning*self.sim.dt)
         self.rel_sus[has_imm] = np.maximum(0, 1 - self.immunity[has_imm])
         return
 
-    def set_prognoses(self, sim, uids, source_uids=None):
+    def set_prognoses(self, uids, source_uids=None):
         """ Set prognoses """
         self.susceptible[uids] = False
         self.infected[uids] = True
-        self.ti_infected[uids] = sim.ti
+        self.ti_infected[uids] = self.sim.ti
         self.immunity[uids] += self.pars.imm_boost
 
         # Sample duration of infection
         dur_inf = self.pars.dur_inf.rvs(uids)
 
         # Determine when people recover
-        self.ti_recovered[uids] = sim.ti + dur_inf / sim.dt
+        self.ti_recovered[uids] = self.sim.ti + dur_inf / self.sim.dt
 
         return
     
-    def init_results(self, sim):
+    def init_results(self):
         """ Initialize results """
-        super().init_results(sim)
-        self.results += ss.Result(self.name, 'rel_sus', sim.npts, dtype=float)
+        super().init_results()
+        self.results += ss.Result(self.name, 'rel_sus', self.sim.npts, dtype=float)
         return
 
-    def update_results(self, sim):
+    def update_results(self):
         """ Store the population immunity (susceptibility) """
-        super().update_results(sim)
-        self.results['rel_sus'][sim.ti] = self.rel_sus.mean()
+        super().update_results()
+        self.results['rel_sus'][self.sim.ti] = self.rel_sus.mean()
         return 
 
     def plot(self):
@@ -178,13 +169,10 @@ 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.dictmerge({
-            'efficacy': 0.9,
-        }, pars)
-
-        super().__init__(pars=pars, *args, **kwargs)
-
+    def __init__(self, pars=None, *args, **kwargs):
+        super().__init__()
+        self.default_pars(efficacy=0.9)
+        self.update_pars(pars, **kwargs)
         return
 
     def administer(self, people, uids):
diff --git a/starsim/diseases/syphilis.py b/starsim/diseases/syphilis.py
index abc95196..27bc1ab4 100644
--- a/starsim/diseases/syphilis.py
+++ b/starsim/diseases/syphilis.py
@@ -12,37 +12,14 @@
 
 class Syphilis(ss.Infection):
 
-    def __init__(self, pars=None):
-        super().__init__(pars)
-
-        self.add_states(
-            # Adult syphilis states
-            ss.BoolArr('exposed'),  # AKA incubating. Free of symptoms, not transmissible
-            ss.BoolArr('primary'),  # Primary chancres
-            ss.BoolArr('secondary'),  # Inclusive of those who may still have primary chancres
-            ss.BoolArr('latent_temp'),  # Relapses to secondary (~1y)
-            ss.BoolArr('latent_long'),  # Can progress to tertiary or remain here
-            ss.BoolArr('tertiary'),  # Includes complications (cardio/neuro/disfigurement)
-            ss.BoolArr('immune'),  # After effective treatment people may acquire temp immunity
-            ss.BoolArr('ever_exposed'),  # Anyone ever exposed - stays true after treatment
-            ss.BoolArr('congenital'),  # Congenital syphilis states
-    
-            # Timestep of state changes
-            ss.FloatArr('ti_exposed'),
-            ss.FloatArr('ti_primary'),
-            ss.FloatArr('ti_secondary'),
-            ss.FloatArr('ti_latent_temp'),
-            ss.FloatArr('ti_latent_long'),
-            ss.FloatArr('ti_tertiary'),
-            ss.FloatArr('ti_immune'),
-            ss.FloatArr('ti_miscarriage'),
-            ss.FloatArr('ti_nnd'),
-            ss.FloatArr('ti_stillborn'),
-            ss.FloatArr('ti_congenital'),
-        )
-
+    def __init__(self, pars=None, **kwargs):
         # Parameters
-        default_pars = dict(
+        super().__init__()
+        self.default_pars(
+            # Initial conditions
+            beta = 1.0, # Placeholder
+            init_prev = ss.bernoulli(p=0.03),
+            
             # Adult syphilis natural history, all specified in years
             dur_exposed = ss.lognorm_ex(mean=1 / 12, stdev=1 / 36),  # https://pubmed.ncbi.nlm.nih.gov/9101629/
             dur_primary = ss.lognorm_ex(mean=1.5 / 12, stdev=1 / 36),  # https://pubmed.ncbi.nlm.nih.gov/9101629/
@@ -69,16 +46,39 @@ def __init__(self, pars=None):
             #   2: Congenital syphilis
             #   3: Live birth without syphilis-related complications
             # Source: https://www.ncbi.nlm.nih.gov/pmc/articles/PMC5973824/)
-            birth_outcomes=sc.objdict(
+            birth_outcomes = sc.objdict(
                 active = ss.choice(a=5, p=np.array([0.125, 0.125, 0.20, 0.35, 0.200])), # Probabilities of active by birth outcome
                 latent = ss.choice(a=5, p=np.array([0.050, 0.075, 0.10, 0.05, 0.725])), # Probabilities of latent
             ),
-            birth_outcome_keys=['miscarriage', 'nnd', 'stillborn', 'congenital'],
+            birth_outcome_keys = ['miscarriage', 'nnd', 'stillborn', 'congenital'],
+        )
+        self.update_pars(pars, **kwargs)
 
-            # Initial conditions
-            init_prev=ss.bernoulli(p=0.03),
+        self.add_states(
+            # Adult syphilis states
+            ss.BoolArr('exposed'),  # AKA incubating. Free of symptoms, not transmissible
+            ss.BoolArr('primary'),  # Primary chancres
+            ss.BoolArr('secondary'),  # Inclusive of those who may still have primary chancres
+            ss.BoolArr('latent_temp'),  # Relapses to secondary (~1y)
+            ss.BoolArr('latent_long'),  # Can progress to tertiary or remain here
+            ss.BoolArr('tertiary'),  # Includes complications (cardio/neuro/disfigurement)
+            ss.BoolArr('immune'),  # After effective treatment people may acquire temp immunity
+            ss.BoolArr('ever_exposed'),  # Anyone ever exposed - stays true after treatment
+            ss.BoolArr('congenital'),  # Congenital syphilis states
+    
+            # Timestep of state changes
+            ss.FloatArr('ti_exposed'),
+            ss.FloatArr('ti_primary'),
+            ss.FloatArr('ti_secondary'),
+            ss.FloatArr('ti_latent_temp'),
+            ss.FloatArr('ti_latent_long'),
+            ss.FloatArr('ti_tertiary'),
+            ss.FloatArr('ti_immune'),
+            ss.FloatArr('ti_miscarriage'),
+            ss.FloatArr('ti_nnd'),
+            ss.FloatArr('ti_stillborn'),
+            ss.FloatArr('ti_congenital'),
         )
-        self.pars = ss.dictmerge(default_pars, self.pars) # NB: regular dictmerge rather than dictmergeleft
 
         return
 
@@ -107,118 +107,119 @@ def infectious(self):
         """ Infectious """
         return self.active | self.latent | self.exposed
 
-    def init_results(self, sim):
+    def init_results(self):
         """ Initialize results """
-        super().init_results(sim)
+        super().init_results()
+        npts = self.sim.npts
         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),
+            ss.Result(self.name, 'new_nnds',       npts, dtype=int, scale=True),
+            ss.Result(self.name, 'new_stillborns', npts, dtype=int, scale=True),
+            ss.Result(self.name, 'new_congenital', npts, dtype=int, scale=True),
         ]
         return
 
-    def update_pre(self, sim):
+    def update_pre(self):
         """ Updates prior to interventions """
 
         # Primary
-        primary = self.exposed & (self.ti_primary <= sim.ti)
+        ti = self.sim.ti
+        primary = self.exposed & (self.ti_primary <= ti)
         self.primary[primary] = True
         self.exposed[primary] = False
         self.rel_trans[primary] = self.pars.rel_trans['primary']
 
         # Secondary from primary
-        secondary_from_primary = (self.primary & (self.ti_secondary <= sim.ti)).uids
+        secondary_from_primary = (self.primary & (self.ti_secondary <= ti)).uids
         if len(secondary_from_primary) > 0:
             self.secondary[secondary_from_primary] = True
             self.primary[secondary_from_primary] = False
-            self.set_secondary_prognoses(sim, secondary_from_primary)
+            self.set_secondary_prognoses(secondary_from_primary)
             self.rel_trans[secondary_from_primary] = self.pars.rel_trans['secondary']
 
         # Hack to reset the MultiRNGs in set_secondary_prognoses so that they can be called again in this timestep. TODO: Refactor
-        self.pars.p_latent_temp.jump(sim.ti+1)
-        self.pars.dur_secondary.jump(sim.ti+1)
+        self.pars.p_latent_temp.jump(ti+1)
+        self.pars.dur_secondary.jump(ti+1)
 
         # Secondary reactivation from latent
-        secondary_from_latent = (self.latent_temp & (self.ti_secondary <= sim.ti)).uids
+        secondary_from_latent = (self.latent_temp & (self.ti_secondary <= ti)).uids
         if len(secondary_from_latent) > 0:
             self.secondary[secondary_from_latent] = True
             self.latent_temp[secondary_from_latent] = False
-            self.set_secondary_prognoses(sim, secondary_from_latent)
+            self.set_secondary_prognoses(secondary_from_latent)
             self.rel_trans[secondary_from_latent] = self.pars.rel_trans['secondary']
 
         # Latent
-        latent_temp = (self.secondary & (self.ti_latent_temp <= sim.ti)).uids
+        latent_temp = (self.secondary & (self.ti_latent_temp <= ti)).uids
         if len(latent_temp) > 0:
             self.latent_temp[latent_temp] = True
             self.secondary[latent_temp] = False
-            self.set_latent_temp_prognoses(sim, latent_temp)
+            self.set_latent_temp_prognoses(latent_temp)
             self.rel_trans[latent_temp] = self.pars.rel_trans['latent_temp']
 
         # Latent long
-        latent_long = (self.secondary & (self.ti_latent_long <= sim.ti)).uids
+        latent_long = (self.secondary & (self.ti_latent_long <= ti)).uids
         if len(latent_long) > 0:
             self.latent_long[latent_long] = True
             self.secondary[latent_long] = False
-            self.set_latent_long_prognoses(sim, latent_long)
+            self.set_latent_long_prognoses(latent_long)
             self.rel_trans[latent_long] = self.pars.rel_trans['latent_long']
 
         # Tertiary
-        tertiary = (self.latent_long & (self.ti_tertiary <= sim.ti)).uids
+        tertiary = (self.latent_long & (self.ti_tertiary <= ti)).uids
         self.tertiary[tertiary] = True
         self.latent_long[tertiary] = False
         self.rel_trans[tertiary] = self.pars.rel_trans['tertiary']
 
         # Congenital syphilis deaths
-        nnd = (self.ti_nnd == sim.ti).uids
-        stillborn = (self.ti_stillborn == sim.ti).uids
-        sim.people.request_death(nnd)
-        sim.people.request_death(stillborn)
+        nnd = (self.ti_nnd == ti).uids
+        stillborn = (self.ti_stillborn == ti).uids
+        self.sim.people.request_death(nnd)
+        self.sim.people.request_death(stillborn)
 
         # Congenital syphilis transmission outcomes
-        congenital = self.ti_congenital == sim.ti
+        congenital = self.ti_congenital == ti
         self.congenital[congenital] = True
         self.susceptible[congenital] = False
 
         return
 
-    def update_results(self, sim):
-        super(Syphilis, self).update_results(sim)
-        ti = sim.ti
+    def update_results(self):
+        super().update_results()
+        ti = self.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):
-        super(Syphilis, self).make_new_cases(sim)
-        return
-
-    def set_prognoses(self, sim, uids, source_uids=None):
+    def set_prognoses(self, uids, source_uids=None):
         """
         Set initial prognoses for adults newly infected with syphilis
         """
+        
+        ti = self.sim.ti
+        dt = self.sim.dt
 
         self.susceptible[uids] = False
         self.ever_exposed[uids] = True
         self.exposed[uids] = True
         self.infected[uids] = True
-        self.ti_exposed[uids] = sim.ti
-        self.ti_infected[uids] = sim.ti
+        self.ti_exposed[uids] = ti
+        self.ti_infected[uids] = ti
 
         # Set future dates and probabilities
         # Exposed to primary
         dur_exposed = self.pars.dur_exposed.rvs(uids)
-        self.ti_primary[uids] = sim.ti + rr(dur_exposed / sim.dt)
+        self.ti_primary[uids] = ti + rr(dur_exposed / dt)
 
         # Primary to secondary
         dur_primary = self.pars.dur_primary.rvs(uids)
-        self.ti_secondary[uids] = self.ti_primary[uids] + rr(dur_primary / sim.dt)
-
+        self.ti_secondary[uids] = self.ti_primary[uids] + rr(dur_primary / dt)
         return
 
-    def set_secondary_prognoses(self, sim, uids):
+    def set_secondary_prognoses(self, uids):
         """ Set prognoses for people who have just progressed to secondary infection """
 
+        dt = self.sim.dt
         dur_secondary = self.pars.dur_secondary.rvs(uids)
 
         # Secondary to latent_temp or latent_long
@@ -227,38 +228,37 @@ def set_secondary_prognoses(self, sim, uids):
         latent_long_uids = uids[~latent_temp]
 
         dur_secondary_temp = dur_secondary[latent_temp]
-        self.ti_latent_temp[latent_temp_uids] = self.ti_secondary[latent_temp_uids] + rr(dur_secondary_temp / sim.dt)
+        self.ti_latent_temp[latent_temp_uids] = self.ti_secondary[latent_temp_uids] + rr(dur_secondary_temp / dt)
 
         dur_secondary_long = dur_secondary[~latent_temp]
-        self.ti_latent_long[latent_long_uids] = self.ti_secondary[latent_long_uids] + rr(dur_secondary_long / sim.dt)
+        self.ti_latent_long[latent_long_uids] = self.ti_secondary[latent_long_uids] + rr(dur_secondary_long / dt)
 
         return
 
-    def set_latent_temp_prognoses(self, sim, uids):
+    def set_latent_temp_prognoses(self, uids):
         # Primary to secondary
         dur_latent_temp = self.pars.dur_latent_temp.rvs(uids)
-        self.ti_secondary[uids] = self.ti_latent_temp[uids] + rr(dur_latent_temp / sim.dt)
+        self.ti_secondary[uids] = self.ti_latent_temp[uids] + rr(dur_latent_temp / self.sim.dt)
         return
 
-    def set_latent_long_prognoses(self, sim, uids):
-
+    def set_latent_long_prognoses(self, uids):
+        dt = self.sim.dt
         dur_latent = self.pars.dur_latent_long.rvs(uids)
 
         # Primary to secondary
         dur_latent_long = dur_latent
-        self.ti_secondary[uids] = self.ti_latent_temp[uids] + rr(dur_latent_long / sim.dt)
+        self.ti_secondary[uids] = self.ti_latent_temp[uids] + rr(dur_latent_long / dt)
 
         # Latent_long to tertiary
         tertiary = self.pars.p_tertiary.rvs(uids)
         tertiary_uids = uids[tertiary]
-        self.ti_tertiary[tertiary_uids] = self.ti_latent_long[tertiary_uids] + rr(dur_latent_long[tertiary] / sim.dt)
+        self.ti_tertiary[tertiary_uids] = self.ti_latent_long[tertiary_uids] + rr(dur_latent_long[tertiary] / dt)
 
         return
 
-    def set_congenital(self, sim, target_uids, source_uids=None):
-        """
-        Natural history of syphilis for congenital infection
-        """
+    def set_congenital(self, target_uids, source_uids=None):
+        """ Natural history of syphilis for congenital infection """
+        sim = self.sim
 
         # Determine outcomes
         for state in ['active', 'latent']:
diff --git a/starsim/distributions.py b/starsim/distributions.py
index e4f1d31c..41bc0d7b 100644
--- a/starsim/distributions.py
+++ b/starsim/distributions.py
@@ -8,7 +8,8 @@
 import starsim as ss
 import matplotlib.pyplot as pl
 
-__all__ = ['find_dists', 'dist_list', 'Dists', 'Dist']
+__all__ = ['find_dists', 'link_dists', 'make_dist', 'dist_list', 'Dists', 'Dist']
+
 
 def str2int(string, modulo=1_000_000):
     """
@@ -19,10 +20,11 @@ def str2int(string, modulo=1_000_000):
     """
     return int.from_bytes(string.encode(), byteorder='big') % modulo
 
-def find_dists(obj, verbose=False):
+
+def find_dists(obj, verbose=False, **kwargs):
     """ Find all Dist objects in a parent object """
     out = sc.objdict()
-    tree = sc.iterobj(obj, depthfirst=False, flatten=True)
+    tree = sc.iterobj(obj, depthfirst=False, flatten=True, **kwargs)
     if verbose: print(f'Found {len(tree)} objects')
     for trace,val in tree.items():
         if isinstance(val, Dist):
@@ -31,18 +33,46 @@ def find_dists(obj, verbose=False):
     return out
 
 
+def link_dists(obj, sim, module=None, overwrite=False, init=False, **kwargs):
+    """ Link distributions to the sim and the module; used in module.initialize() and people.initialize() """
+    if module is None and isinstance(obj, ss.Module):
+        module = obj
+    dists = ss.find_dists(obj, **kwargs) # Important that this comes first, before the sim is linked to the dist!
+    for key,val in dists.items():
+        if isinstance(val, ss.Dist):
+            val.link_sim(sim, overwrite=overwrite)
+            val.link_module(module, overwrite=overwrite)
+            if init: # Usually this is false since usually these are initialized centrally by the sim
+                val.initialize()
+    return
+
+
+def make_dist(pars=None, **kwargs):
+    """ Make a distribution from a dictionary """
+    pars = sc.mergedicts(pars, kwargs)
+    if 'type' not in pars:
+        errormsg = 'To make a distribution from a dict, one of the keys must be "type" to specify the distribution'
+        raise ValueError(errormsg)
+    dist_type = pars.pop('type')
+    if dist_type not in dist_list:
+        errormsg = f'"{dist_type}" is not a valid distribution; valid types are {dist_list}'
+        raise AttributeError(errormsg)
+    dist_class = getattr(ss, dist_type)
+    dist = dist_class(**pars) # Actually create the distribution
+    return dist
+
+
 class Dists(sc.prettyobj):
     """ Class for managing a collection of Dist objects """
-
     def __init__(self, obj=None, *args, base_seed=None, sim=None):
         if len(args): obj = [obj] + list(args)
         self.obj = obj
         self.dists = None
         self.base_seed = base_seed
+        if sim is None and obj is not None and isinstance(obj, ss.Sim):
+            sim = obj
         self.sim = sim
         self.initialized = False
-        if self.obj is not None:
-            self.initialize()
         return
 
     def initialize(self, obj=None, base_seed=None, sim=None, force=False):
@@ -55,6 +85,10 @@ def initialize(self, obj=None, base_seed=None, sim=None, force=False):
             self.base_seed = base_seed
         sim = sim if (sim is not None) else self.sim
         obj = obj if (obj is not None) else self.obj
+        if sim is None and obj is not None and isinstance(obj, ss.Sim):
+            sim = obj
+        if obj is None and sim is not None:
+            obj = sim
         if obj is None:
             errormsg = 'Must supply a container that contains one or more Dist objects, typically the sim'
             raise ValueError(errormsg)
@@ -97,7 +131,7 @@ def reset(self):
         return out
 
 
-class Dist: # TODO: figure out why subclassing sc.prettyobj breaks isinstance
+class Dist:
     """
     Base class for tracking one random number generator associated with one distribution,
     i.e. one decision per timestep.
@@ -209,11 +243,19 @@ def __call__(self, n=1):
         """ Alias to self.rvs() """
         return self.rvs(n=n)
     
-    def set(self, dist=None, **kwargs):
+    def set(self, *args, dist=None, **kwargs):
         """ Set (change) the distribution type, or one or more parameters of the distribution """
         if dist:
             self.dist = dist
             self.process_dist()
+        if args:
+            if kwargs:
+                errormsg = f'You can supply args or kwargs, but not both (args={len(args)}, kwargs={len(kwargs)})'
+                raise ValueError(errormsg)
+            else: # Convert from args to kwargs
+                parkeys = list(self.pars.keys())
+                for i,arg in enumerate(args):
+                    kwargs[parkeys[i]] = arg
         if kwargs:
             self.pars.update(kwargs)
             self.process_pars(call=False)
@@ -304,8 +346,8 @@ def initialize(self, trace=None, seed=None, module=None, sim=None, slots=None, f
         self.make_history(reset=True)
         
         # Handle the sim, module, and slots
-        self.sim = sim if (sim is not None) else self.sim
-        self.module = module if (module is not None) else self.module
+        self.link_sim(sim)
+        self.link_module(module)
         if slots is None and self.sim is not None:
             try:
                 slots = self.sim.people.slot
@@ -319,12 +361,29 @@ def initialize(self, trace=None, seed=None, module=None, sim=None, slots=None, f
         self.process_dist()
         self.process_pars(call=False)
         self.ready = True
-        if self.trace is None or self.sim is None or self.slots is None:
-            self.initialized = 'partial' # Initialized enough to produce random numbers, but not fully initialized
+        none_dict = dict(trace=self.trace, sim=self.sim, slots=self.slots)
+        none_dict = {k:v for k,v in none_dict.items() if v is None}
+        if len(none_dict):
+            self.initialized = 'partial'
+            if self.strict:
+                errormsg = f'Distribution {self} is not fully initialized, the following inputs are None:\n{none_dict.keys()}\nThis distribution may not produce valid random numbers.'
+                raise RuntimeError(errormsg)
         else:
             self.initialized = True
         return self
     
+    def link_sim(self, sim=None, overwrite=False):
+        """ Shortcut for linking the sim, only overwriting an existing one if overwrite=True """
+        if (not self.sim or overwrite) and sim is not None:
+            self.sim = sim
+        return
+    
+    def link_module(self, module=None, overwrite=False):
+        """ Shortcut for linking the module """
+        if (not self.module or overwrite) and module is not None:
+            self.module = module
+        return
+    
     def process_seed(self, trace=None, seed=None):
         """ Obtain the seed offset by hashing the path to this distribution; called automatically """
         unique_name = trace or self.trace or self.name
@@ -457,6 +516,7 @@ def rvs(self, n=1, reset=False):
         
         Args:
             n (int/tuple/arr): if an int or tuple, return this many random variables; if an array, treat as UIDs
+            reset (bool): whether to automatically reset the random number distribution state after being called
         """
         # Check for readiness
         if not self.initialized:
@@ -470,13 +530,15 @@ def rvs(self, n=1, reset=False):
         # Check if size is 0, then we can return
         if size == 0:
             return np.array([], dtype=int) # int dtype allows use as index, e.g. when filtering
+        elif isinstance(size, ss.uids) and self.initialized == 'partial': # This point can be reached if and only if strict=False and UIDs are used as input
+            errormsg = f'Distribution {self} is only partially initialized; cannot generate random numbers to match UIDs'
+            raise ValueError(errormsg)
         
         # Store the state
         self.make_history() # Store the pre-call state
         
         # Check if any keywords are callable -- parameters shouldn't need to be reprocessed otherwise
-        if True: # self.dynamic_pars: # TODO: fix!!!!
-            self.process_pars()
+        self.process_pars()
         
         # Actually get the random numbers
         if self.dynamic_pars:
@@ -708,6 +770,10 @@ def filter(self, uids=None, both=False):
         else:
             return uids[bools]
 
+    def split(self, uids=None):
+        """ Alias to filter(uids, both=True) """
+        return self.filter(uids=uids, both=True)
+
 
 class choice(Dist):
     """
diff --git a/starsim/interventions.py b/starsim/interventions.py
index df8ca413..eb14b253 100644
--- a/starsim/interventions.py
+++ b/starsim/interventions.py
@@ -6,21 +6,15 @@
 import sciris as sc
 import numpy as np
 
-__all__ = ['Analyzer', 'Intervention']
+__all__ = ['Plugin', 'Analyzer', 'Intervention']
 
 
-class Analyzer(ss.Module):
-    """
-    Base class for analyzers. Analyzers are used to provide more detailed information 
-    about a simulation than is available by default -- for example, pulling states 
-    out of sim.people on a particular timestep before they get updated on the next step.
-    
-    The key method of the analyzer is ``apply()``, which is called with the sim
-    on each timestep.
+class Plugin(ss.Module):
+    """ Base class for interventions and analyzers """
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        return
     
-    To retrieve a particular analyzer from a sim, use sim.get_analyzer().
-    """
-
     def __call__(self, *args, **kwargs):
         return self.apply(*args, **kwargs)
     
@@ -30,11 +24,33 @@ def initialize(self, sim):
     def apply(self, sim):
         pass
 
-    def finalize(self, sim):
-        return super().finalize(sim)
+    def finalize(self):
+        return super().finalize()
+    
+    @classmethod
+    def from_func(cls, func):
+        """ Create an intervention or analyzer from a function """
+        name = func.__name__
+        new = cls(name=name)
+        new.apply = func
+        return new
 
 
-class Intervention(ss.Module):
+class Analyzer(Plugin):
+    """
+    Base class for analyzers. Analyzers are used to provide more detailed information 
+    about a simulation than is available by default -- for example, pulling states 
+    out of sim.people on a particular timestep before they get updated on the next step.
+    
+    The key method of the analyzer is ``apply()``, which is called with the sim
+    on each timestep.
+    
+    To retrieve a particular analyzer from a sim, use sim.get_analyzer().
+    """
+    pass
+
+
+class Intervention(Plugin):
     """
     Base class for interventions.
     
@@ -47,18 +63,6 @@ def __init__(self, eligibility=None, *args, **kwargs):
         self.eligibility = eligibility
         return
 
-    def __call__(self, *args, **kwargs):
-        return self.apply(*args, **kwargs)
-    
-    def initialize(self, sim):
-        return super().initialize(sim)
-    
-    def apply(self, sim, *args, **kwargs):
-        raise NotImplementedError
-        
-    def finalize(self, sim):
-        return super().finalize(sim)
-
     def _parse_product(self, product):
         """
         Parse the product input
diff --git a/starsim/modules.py b/starsim/modules.py
index 20218986..5d816c1e 100644
--- a/starsim/modules.py
+++ b/starsim/modules.py
@@ -4,32 +4,93 @@
 
 import sciris as sc
 import starsim as ss
-from scipy.stats._distn_infrastructure import rv_frozen
-
-__all__ = ['Module']
 
+__all__ = ['module_map', 'find_modules', 'Module', 'Connector']
+
+
+def module_map(key=None):
+    """ Define the mapping between module names and types """
+    module_map = sc.objdict(
+        networks      = ss.Network,
+        demographics  = ss.Demographics,
+        diseases      = ss.Disease,
+        interventions = ss.Intervention,
+        analyzers     = ss.Analyzer,
+        connectors    = ss.Connector,
+    )
+    return module_map if key is None else module_map[key]
+
+
+def find_modules(key=None):
+    """ Find all subclasses of Module, divided by type """
+    modules = sc.objdict()
+    modmap = module_map()
+    attrs = dir(ss)
+    for modkey, modtype in modmap.items(): # Loop over each module type
+        modules[modkey] = sc.objdict()
+        for attr in attrs: # Loop over each attribute (inefficient, but doesn't need to be optimized)
+            item = getattr(ss, attr)
+            try:
+                assert issubclass(item, modtype) # Check that it's a class, and instance of this module
+                low_attr = attr.lower()
+                modules[modkey][low_attr] = item # It passes, so assign it to the dict
+                if modkey == 'networks' and low_attr.endswith('net'): # Also allow networks without 'net' suffix
+                    modules[modkey][low_attr.removesuffix('net')] = item
+            except:
+                pass
+    return modules if key is None else modules[key]
+    
 
 class Module(sc.quickobj):
 
-    def __init__(self, pars=None, par_dists=None, name=None, label=None, requires=None, **kwargs):
-        self.pars = ss.dictmerge(pars, kwargs)
-        self.par_dists = ss.dictmerge(par_dists)
-        self.name = sc.ifelse(name, getattr(self, 'name', self.__class__.__name__.lower())) # Default name is the class name
-        self.label = sc.ifelse(label, getattr(self, 'label', self.name))
-        self.requires = sc.mergelists(requires)
+    def __init__(self, name=None, label=None, requires=None):
+        self.set_metadata(name, label, requires) # Usually reset as part of self.update_pars()
+        self.pars = ss.Pars() # Usually populated via self.default_pars()
         self.results = ss.Results(self.name)
         self.initialized = False
         self.finalized = False
         return
     
-    def disp(self, output=False):
-        """ Display the full object """
-        out = sc.prepr(self)
-        if not output:
-            print(out)
-        else:
-            return out
-
+    def __bool__(self):
+        """ Ensure that zero-length modules (e.g. networks) are still truthy """
+        return True
+    
+    def set_metadata(self, name, label, requires):
+        """ Set metadata for the module """
+        self.name = sc.ifelse(name, getattr(self, 'name', self.__class__.__name__.lower())) # Default name is the class name
+        self.label = sc.ifelse(label, getattr(self, 'label', self.name))
+        self.requires = sc.mergelists(requires)
+        return
+    
+    def default_pars(self, inherit=False, **kwargs):
+        """ Create or merge Pars objects """
+        if inherit: # Merge with existing
+            self.pars.update(**kwargs, create=True)
+        else: # Or overwrite
+            self.pars = ss.Pars(**kwargs)
+        return self.pars
+    
+    def update_pars(self, pars, **kwargs):
+        """ Pull out recognized parameters, returning the rest """
+        pars = sc.mergedicts(pars, kwargs)
+        
+        # Update matching module parameters
+        matches = {}
+        for key in list(pars.keys()): # Need to cast to list to avoid "dict changed during iteration"
+            if key in self.pars:
+                matches[key] = pars.pop(key)
+        self.pars.update(matches)
+                
+        # Update module attributes
+        metadata = {key:pars.pop(key, None) for key in ['name', 'label', 'requires']}
+        self.set_metadata(**metadata)
+        
+        # Should be no remaining pars
+        if len(pars):
+            errormsg = f'{len(pars)} unrecognized arguments for {self.name}: {sc.strjoin(pars.keys())}'
+            raise ValueError(errormsg)
+        return
+    
     def check_requires(self, sim):
         """ Check that the module's requirements (of other modules) are met """
         errs = sc.autolist()
@@ -47,82 +108,45 @@ def initialize(self, sim):
         """
         Perform initialization steps
 
-        This method is called once, as part of initializing a Sim
+        This method is called once, as part of initializing a Sim. Note: after
+        initialization, initialized=False until init_vals() is called (which is after
+        distributions are initialized).
         """
         self.check_requires(sim)
-
-        # First, convert any scalar pars to distributions if required
-        for key in self.par_dists.keys():
-            par = self.pars[key]
-            if isinstance(par, ss.Dist):
-                continue
-
-            # Handle arguments
-            args = ()
-            kwargs = {}
-            if isinstance(par, dict):
-                kwargs = par
-            elif isinstance(par, (tuple, list)):
-                args = par
-            else:
-                args = [par]
-
-            # Make the distribution
-            par_dist = self.par_dists[key]
-            if isinstance(par_dist, str):
-                try:
-                    par_dist = getattr(ss.distributions, par_dist)
-                except Exception as E:
-                    errormsg = f'"{par_dist}" is not a valid distribution name; valid distributions are: {sc.newlinejoin(ss.dists.dist_list)}'
-                    raise ValueError(errormsg) from E
-            
-            if issubclass(par_dist, ss.Dist): # Main use case
-                par_dist = par_dist(*args, sim=sim, module=self, **kwargs)
-            
-            if not isinstance(par_dist, ss.Dist):
-                print('Warning, should probably be an ss.Dist already')
-                par_dist = ss.Dist(dist=par_dist, *args, **kwargs)
-            
-            self.pars[key] = par_dist
-
-        # Initialize distributions in pars # TODO: refactor
-        for key, value in self.pars.items():
-            if isinstance(value, rv_frozen):
-                self.pars[key] = ss.Dist(dist=value)
-
-        for key, value in self.__dict__.items():
-            if isinstance(value, rv_frozen):
-                setattr(self, key, ss.Dist(dist=value))
-        
-        # Initialize everything # TODO: shouldn't be needed, should be able to recurse more
-        for key,val in list(self.pars.items()) + list(self.__dict__.items()):
-            if isinstance(val, ss.Dist):
-                if val.initialized is not True: # Catches False and 'partial'
-                    val.initialize(module=self, sim=sim, force=True) # Actually a dist
-                else:
-                    raise RuntimeError(f'Trying to reinitialize {val}, this should not happen')
-
-        # Connect the states to the sim
-        # Will use random numbers, so do after distribution initialization
+        self.sim = sim # Link back to the sim object
+        ss.link_dists(self, sim, skip=ss.Sim) # Link the distributions to sim and module
+        sim.pars[self.name] = self.pars
+        sim.results[self.name] = self.results
+        sim.people.add_module(self) # Connect the states to the people
+        return
+    
+    def init_vals(self):
+        """ Initialize the values of the states; the last step of initialization """
         for state in self.states:
-            state.initialize(sim)
-
+            if not state.initialized:
+                state.init_vals()
         self.initialized = True
         return
+    
+    def disp(self, output=False):
+        """ Display the full object """
+        out = sc.prepr(self)
+        if not output:
+            print(out)
+        else:
+            return out
 
-    def finalize(self, sim):
-        self.finalize_results(sim)
+    def finalize(self):
+        self.finalize_results()
         self.finalized = True
         return
 
-    def finalize_results(self, sim):
-        """
-        Finalize results
-        """
+    def finalize_results(self):
+        """ Finalize results """
         # Scale results
         for reskey, res in self.results.items():
             if isinstance(res, ss.Result) and res.scale:
-                self.results[reskey] = self.results[reskey]*sim.pars.pop_scale
+                self.results[reskey] = self.results[reskey]*self.sim.pars.pop_scale
         return
     
     def add_states(self, *args, check=True):
@@ -158,10 +182,10 @@ def states(self):
         due to supporting features like multiple genotypes) then the Module should
         overload this attribute to ensure that all states appear in here.
         """
-        return [x for x in self.__dict__.values() if isinstance(x, ss.Arr)]
+        return [x for x in self.__dict__.values() if isinstance(x, ss.Arr)] # TODO: use ndict
 
     @property
-    def statesdict(self):
+    def statesdict(self): # TODO: remove
         """
         Return a flat dictionary (objdict) of all states
 
@@ -180,4 +204,34 @@ def create(cls, name, *args, **kwargs):
             if subcls.__name__.lower() == name:
                 return subcls(*args, **kwargs)
         else:
-            raise KeyError(f'Module "{name}" did not match any known Starsim Modules')
+            raise KeyError(f'Module "{name}" did not match any known Starsim modules')
+            
+    def to_json(self):
+        """ Export to a JSON-compatible format """
+        out = sc.objdict()
+        out.type = self.__class__.__name__
+        out.name = self.name
+        out.label = self.label
+        out.pars = self.pars.to_json()
+        return out
+
+    def plot(self):
+        with sc.options.with_style('fancy'):
+            flat = sc.flattendict(self.results, sep=': ')
+            yearvec = self.sim.yearvec
+            fig, axs = sc.getrowscols(len(flat), make=True)
+            for ax, (k, v) in zip(axs.flatten(), flat.items()):
+                ax.plot(yearvec, v)
+                ax.set_title(k)
+                ax.set_xlabel('Year')
+        return fig
+
+
+class Connector(Module):
+    """
+    Define a Connector, which mediates interactions between disease modules
+    
+    Because connectors can do anything, they have no specified structure: it is
+    up to the user to define how they behave.    
+    """
+    pass
diff --git a/starsim/network.py b/starsim/network.py
index 018361b1..f1f624fc 100644
--- a/starsim/network.py
+++ b/starsim/network.py
@@ -16,7 +16,7 @@
 
 
 # Specify all externally visible functions this file defines; see also more definitions below
-__all__ = ['Network', 'Networks', 'DynamicNetwork', 'SexualNetwork']
+__all__ = ['Network', 'DynamicNetwork', 'SexualNetwork']
 
 # %% General network classes
 
@@ -60,18 +60,17 @@ class Network(ss.Module):
         network2 = ss.Network(**network, index=index, self_conn=self_conn, label=network.label)
     """
 
-    def __init__(self, pars=None, key_dict=None, vertical=False, *args, **kwargs):
-
+    def __init__(self, key_dict=None, vertical=False, name=None, label=None, requires=None, **kwargs):
         # Initialize as a module
-        super().__init__(pars, *args, **kwargs)
+        super().__init__(name=name, label=label, requires=requires)
 
         # Each relationship is characterized by these default set of keys, plus any user- or network-supplied ones
-        default_keys = dict(
+        default_keys = sc.objdict(
             p1 = ss_int_,
             p2 = ss_int_,
             beta = ss_float_,
         )
-        self.meta = ss.dictmerge(default_keys, key_dict)
+        self.meta = sc.mergedicts(default_keys, key_dict)
         self.vertical = vertical  # Whether transmission is bidirectional
 
         # Initialize the keys of the network
@@ -81,7 +80,7 @@ def __init__(self, pars=None, key_dict=None, vertical=False, *args, **kwargs):
 
         # Set data, if provided
         for key, value in kwargs.items():
-            self.contacts[key] = np.array(value, dtype=self.meta.get(key))
+            self.contacts[key] = np.array(value, dtype=self.meta.get(key)) # Overwrite dtype if supplied, else keep original
             self.initialized = True
 
         # Define states using placeholder values
@@ -101,8 +100,9 @@ def p2(self):
     def beta(self):
         return self.contacts['beta'] if 'beta' in self.contacts else None
 
-    def initialize(self, sim):
-        super().initialize(sim)
+    def init_vals(self, add_pairs=True):
+        super().init_vals()
+        if add_pairs: self.add_pairs()
         return
 
     def __len__(self):
@@ -287,7 +287,7 @@ def add_pairs(self):
         """ Define how pairs of people are formed """
         pass
 
-    def update(self, people):
+    def update(self):
         """ Define how pairs/connections evolve (in time) """
         pass
 
@@ -308,53 +308,29 @@ def beta_per_dt(self, disease_beta=None, dt=None, uids=None):
         return self.contacts.beta[uids] * disease_beta * dt
 
 
-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)
-        return
-
-    def initialize(self, sim):
-        for nw in self.values():
-            nw.initialize(sim)
-        for cn in self._connectors.values():
-            cn.initialize(sim)
-        return
-
-    def update(self, people):
-        for nw in self.values():
-            nw.update(people)
-        for cn in self._connectors.values():
-            cn.update(people)
-        return
-
-
 class DynamicNetwork(Network):
     """ A network where partnerships update dynamically """
-    def __init__(self, pars=None, key_dict=None, **kwargs):
-        key_dict = ss.dictmerge({'dur': ss_float_}, key_dict)
-        super().__init__(pars, key_dict=key_dict, **kwargs)
+    def __init__(self, key_dict=None, **kwargs):
+        key_dict = sc.mergedicts({'dur': ss_float_}, key_dict)
+        super().__init__(key_dict=key_dict, **kwargs)
         return
 
-    def end_pairs(self, people):
-        dt = people.dt
-        self.contacts.dur = self.contacts.dur - dt
+    def end_pairs(self):
+        people = self.sim.people
+        self.contacts.dur = self.contacts.dur - self.sim.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]
-        return
+        return len(active)
 
 
-class SexualNetwork(Network):
+class SexualNetwork(DynamicNetwork):
     """ Base class for all sexual networks """
-    def __init__(self, pars=None, key_dict=None, **kwargs):
-        key_dict = ss.dictmerge({'acts': ss_int_}, key_dict)
-        super().__init__(pars, key_dict=key_dict, **kwargs)
+    def __init__(self, key_dict=None, **kwargs):
+        key_dict = sc.mergedicts({'acts': ss_int_}, key_dict)
+        super().__init__(key_dict=key_dict, **kwargs)
         self.debut = ss.FloatArr('debut', default=0)
         return
 
@@ -408,30 +384,35 @@ class StaticNet(Network):
     """
 
     def __init__(self, graph=None, pars=None, **kwargs):
-        super().__init__(**kwargs)
+        super().__init__()
         self.graph = graph
-        self.pars = ss.dictmerge(dict(seed=True), pars)
-        self.dist = ss.Dist(name='StaticNet').initialize()
+        self.default_pars(seed=True)
+        self.update_pars(pars, **kwargs)
+        self.dist = ss.Dist(name='StaticNet')
         return
 
     def initialize(self, sim):
-        n_agents = sim.pars.n_agents
+        super().initialize(sim)
+        self.n_agents = sim.pars.n_agents
         if self.graph is None:
             self.graph = nx.fast_gnp_random_graph # Fast random (Erdos-Renyi) graph creator
             if 'p' not in self.pars and 'n_contacts' not in self.pars: # TODO: refactor
                 self.pars.n_contacts = 10
         if 'n_contacts' in self.pars: # Convert from n_contacts to probability
-            self.pars.p = self.pars.pop('n_contacts')/n_agents
+            self.pars.p = self.pars.pop('n_contacts')/self.n_agents
+        return
+    
+    def init_vals(self):
+        super().init_vals()
         if 'seed' in self.pars and self.pars.seed is True:
             self.pars.seed = self.dist.rng
         if callable(self.graph):
             try:
-                self.graph = self.graph(n=n_agents, **self.pars)
+                self.graph = self.graph(n=self.n_agents, **self.pars)
             except TypeError as e:
                 print(f"{str(e)}: networkx {self.graph.name} not supported. Try using ss.NullNet().")
                 raise e
-        self.validate_pop(n_agents)
-        super().initialize(sim)
+        self.validate_pop(self.n_agents)
         self.get_contacts()
         return
 
@@ -461,23 +442,19 @@ def get_contacts(self):
 class RandomNet(DynamicNetwork):
     """ Random connectivity between agents """
 
-    def __init__(self, pars=None, par_dists=None, key_dict=None, **kwargs):
+    def __init__(self, pars=None, key_dict=None, **kwargs):
         """ Initialize """
-        pars = ss.dictmerge({
-            'n_contacts': 10,  # Distribution or int. If int, interpreted as the mean of the dist listed in par_dists
-            'dur': 0,
-        }, pars)
-
-        super().__init__(pars=pars, key_dict=key_dict, **kwargs)
-
-        # Default RNG
-        self.dist = ss.Dist(distname='RandomNet')
-
+        super().__init__(key_dict=key_dict)
+        self.default_pars(
+            n_contacts = ss.delta(10),
+            dur = 0,
+        )
+        self.update_pars(pars, **kwargs)
+        self.dist = ss.Dist(distname='RandomNet') # Default RNG
         return
 
-    def initialize(self, sim):
-        super().initialize(sim)
-        self.add_pairs(sim.people)
+    def init_vals(self):
+        self.add_pairs()
         return
 
     @staticmethod
@@ -520,13 +497,14 @@ def get_contacts(self, inds, n_contacts):
         self.dist.jump() # Reset the RNG manually # TODO, think if there's a better way
         return source, target
 
-    def update(self, people, dt=None):
-        self.end_pairs(people)
-        self.add_pairs(people)
+    def update(self):
+        self.end_pairs()
+        self.add_pairs()
         return
 
-    def add_pairs(self, people):
+    def add_pairs(self):
         """ Generate contacts """
+        people = self.sim.people
         if isinstance(self.pars.n_contacts, ss.Dist):
             number_of_contacts = self.pars.n_contacts.rvs(people.uid[people.alive])  # or people.uid?
         else:
@@ -585,63 +563,55 @@ def get_contacts(self):
         return
 
 
-class MFNet(SexualNetwork, DynamicNetwork):
+class MFNet(SexualNetwork):
     """
     This network is built by **randomly pairing** males and female with variable
     relationship durations.
     """
-
-    def __init__(self, pars=None, par_dists=None, key_dict=None, **kwargs):
-        pars = ss.dictmergeleft(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,
+    def __init__(self, pars=None, key_dict=None, **kwargs):
+        super().__init__(key_dict=key_dict)
+        self.default_pars(
+            duration = ss.lognorm_ex(mean=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 = ss.bernoulli(p=0.9),  # Probability of participating in this network - can vary by individual properties (age, sex, ...) using callable parameter values
+            debut = ss.normal(loc=16),  # Age of debut can vary by using callable parameter values
+            acts = ss.poisson(lam=80),
             rel_part_rates = 1.0,
         )
+        self.update_pars(pars=pars, **kwargs)
 
-        par_dists = ss.dictmergeleft(par_dists,
-            duration      = ss.lognorm_ex,
-            participation = ss.bernoulli,
-            debut         = ss.normal,
-            acts          = ss.poisson,
-        )
-
-        DynamicNetwork.__init__(self, key_dict=key_dict, **kwargs)
-        SexualNetwork.__init__(self, pars, key_dict=key_dict, **kwargs)
-
+        # Finish initialization
         self.dist = ss.choice(name='MFNet', replace=False) # Set the array later
-        self.par_dists = par_dists
-
         return
 
-    def initialize(self, sim):
-        super().initialize(sim)
-        self.set_network_states(sim.people)
-        self.add_pairs(sim.people, ti=0)
+    def init_vals(self):
+        self.set_network_states()
+        self.add_pairs()
         return
 
-    def set_network_states(self, people, upper_age=None):
+    def set_network_states(self, upper_age=None):
         """ Set network states including age of entry into network and participation rates """
-        self.set_debut(people, upper_age=upper_age)
-        self.set_participation(people, upper_age=upper_age)
+        self.set_debut(upper_age=upper_age)
+        self.set_participation(upper_age=upper_age)
         return
 
-    def set_participation(self, people, upper_age=None):
-        # Set people who will participate in the network at some point
+    def set_participation(self, upper_age=None):
+        """ Set people who will participate in the network at some point """
+        people = self.sim.people
         if upper_age is None: uids = people.auids
         else: uids = (people.age < upper_age).uids
         self.participant[uids] = self.pars.participation.rvs(uids)
         return
 
-    def set_debut(self, people, upper_age=None):
-        # Set debut age
+    def set_debut(self, upper_age=None):
+        """ Set debut age """
+        people = self.sim.people
         if upper_age is None: uids = people.auids
         else: uids = (people.age < upper_age).uids
         self.debut[uids] = self.pars.debut.rvs(uids)
         return
 
-    def add_pairs(self, people, ti=None):
+    def add_pairs(self):
+        people = self.sim.people
         available_m = self.available(people, 'male')
         available_f = self.available(people, 'female')
 
@@ -664,7 +634,7 @@ def add_pairs(self, people, ti=None):
             # No duplicates and user has enabled multirng, so use slotting based on p1
             dur_vals = self.pars.duration.rvs(p1)
             act_vals = self.pars.acts.rvs(p1)
-        else:
+        else: # TODO: rethink explanation without multirng
             # If multirng is enabled, we're here because some individuals in p1
             # are starting multiple relationships on this timestep. If using
             # slotted draws, as above, repeated relationships will get the same
@@ -677,29 +647,27 @@ def add_pairs(self, people, ti=None):
 
         return len(p1)
 
-    def update(self, people, dt=None):
-        self.end_pairs(people)
-        self.set_network_states(people, upper_age=people.dt)
-        self.add_pairs(people)
+    def update(self):
+        self.end_pairs()
+        self.set_network_states(upper_age=self.sim.dt) # TODO: looks wrong
+        self.add_pairs()
         return
 
 
-class MSMNet(SexualNetwork, DynamicNetwork):
+class MSMNet(SexualNetwork):
     """
     A network that randomly pairs males
     """
 
     def __init__(self, pars=None, key_dict=None, **kwargs):
-        pars = ss.dictmergeleft(pars,
-            duration_dist = ss.lognorm_ex(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.normal(loc=16, scale=2),
+        super().__init__(key_dict=key_dict)
+        self.default_pars(
+            duration = ss.lognorm_ex(mean=15, stdev=15),
+            debut = ss.normal(loc=16, scale=2),
             acts = ss.lognorm_ex(mean=80, stdev=20),
-            rel_part_rates = 1.0,
+            participation = ss.bernoulli(p=0.1),
         )
-        DynamicNetwork.__init__(self, key_dict, **kwargs)
-        SexualNetwork.__init__(self, pars, key_dict)
-        self.dist = ss.bernoulli(name='MSMNet')
+        self.update_pars(pars, **kwargs)
         return
 
     def initialize(self, sim):
@@ -718,17 +686,15 @@ def set_network_states(self, people, upper_age=None):
 
         # Participation
         self.participant[people.female] = False
-        pr = self.pars.rel_part_rates
-        self.dist.set(p=pr)
-        self.participant[uids] = self.dist.rvs(uids) # Should be CRN safe?
+        self.participant[uids] = self.participation.rvs(uids) # Should be CRN safe?
 
         # Debut
-        self.debut[uids] = self.pars.debut_dist.rvs(len(uids)) # Just pass len(uids) as this network is not crn safe anyway
+        self.debut[uids] = self.pars.debut.rvs(len(uids)) # Just pass len(uids) as this network is not crn safe anyway
         return
 
-    def add_pairs(self, people, ti=None):
-        # Pair all unpartnered MSM
-        available_m = self.available(people, 'm')
+    def add_pairs(self):
+        """ Pair all unpartnered MSM """
+        available_m = self.available(self.sim.people, 'm')
         n_pairs = int(len(available_m)/2)
         p1 = available_m[:n_pairs]
         p2 = available_m[n_pairs:n_pairs*2]
@@ -746,10 +712,10 @@ def add_pairs(self, people, ti=None):
         
         return len(p1)
 
-    def update(self, people, dt=None):
-        self.end_pairs(people)
-        self.set_network_states(people, upper_age=people.dt)
-        self.add_pairs(people)
+    def update(self):
+        self.end_pairs(self.sim)
+        self.set_network_states(self.sim.people, upper_age=self.sim.dt)
+        self.add_pairs(self.sim)
         return
 
 
@@ -762,15 +728,16 @@ def __init__(self, pars=None, **kwargs):
         """
         Create a sexual network from a 1D embedding based on age
 
-        male_shift is the average age that males are older than females in partnerships
-        std is the standard deviation of noise added to the age of each individual when seeking a pair. Larger values will created more diversity in age gaps.
-        
+        Args:
+            male_shift is the average age that males are older than females in partnerships
         """
-        pars = ss.dictmerge({
-            'embedding_func': ss.normal(name='EmbeddingNet', loc=self.embedding_loc, scale=2),
-            'male_shift': 5,
-        }, pars)
-        super().__init__(pars, **kwargs)
+        super().__init__()
+        self.default_pars(
+            inherit = True, # The MFNet already comes with pars, we want to keep those
+            embedding_func = ss.normal(name='EmbeddingNet', loc=self.embedding_loc, scale=2),
+            male_shift = 5,
+        )
+        self.update_pars(pars, **kwargs)
         return
 
     @staticmethod
@@ -779,7 +746,8 @@ def embedding_loc(module, sim, uids):
         loc[sim.people.female[uids]] += module.pars.male_shift  # Shift females so they will be paired with older men
         return loc
 
-    def add_pairs(self, people, ti=None):
+    def add_pairs(self):
+        people = self.sim.people
         available_m = self.available(people, 'male')
         available_f = self.available(people, 'female')
 
@@ -794,9 +762,7 @@ def add_pairs(self, people, ti=None):
         loc_m = loc[~people.female[available]]
 
         dist_mat = spsp.distance_matrix(loc_m[:, np.newaxis], loc_f[:, np.newaxis])
-
         ind_m, ind_f = spo.linear_sum_assignment(dist_mat)
-
         n_pairs = len(ind_f)
 
         # Finalize pairs
@@ -822,101 +788,23 @@ def __init__(self, key_dict=None, vertical=True, **kwargs):
         super().__init__(key_dict=key_dict, vertical=vertical, **kwargs)
         return
 
-    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
+    def update(self):
+        """
+        Set beta to 0 for women who complete post-partum period
+        Keep connections for now, might want to consider removing
+        """
+        dt = self.sim.dt
         self.contacts.dur = self.contacts.dur - dt
         inactive = self.contacts.dur <= 0
         self.contacts.beta[inactive] = 0
         return
 
-    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
-        """
-        n = len(mother_inds)
-        beta = np.ones(n)
-        self.append(p1=mother_inds, p2=unborn_inds, beta=beta, dur=dur)
-        return n
-
-
-# %% Network connectors
-
-__all__ += ['NetworkConnector', 'MF_MSM']
-
-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 set_participation(self, people, upper_age=None):
-        pass
-
-    def update(self, people):
-        pass
-
-
-class MF_MSM(NetworkConnector):
-    """ Combines the MF and MSM networks """
-    def __init__(self, pars=None):
-        networks = [ss.MFNet, ss.MSMNet]
-        pars = ss.dictmergeleft(pars,
-            prop_bi = 0.5,  # Could vary over time -- but not by age or sex or individual
-        )
-        super().__init__(networks=networks, pars=pars)
-
-        self.bi_dist = ss.bernoulli(p=self.pars.prop_bi)
-        return
-
-    def initialize(self, sim):
-        super().initialize(sim)
-        self.set_participation(sim.people)
-        return
-
-    def set_participation(self, people, upper_age=None):
-        if upper_age is None:
-            uids = people.uid
+    def add_pairs(self, mother_inds=None, unborn_inds=None, dur=None):
+        """ Add connections between pregnant women and their as-yet-unborn babies """
+        if mother_inds is None:
+            return 0
         else:
-            uids = people.uid[(people.age < upper_age)]
-        uids = ss.true(people.male[uids])
-
-        # 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.random().rvs(size=len(mf_excl_set)) < remaining_pr] # TODO: not RNG safe and yes ugly!
-
-        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
+            n = len(mother_inds)
+            beta = np.ones(n)
+            self.append(p1=mother_inds, p2=unborn_inds, beta=beta, dur=dur)
+            return n
diff --git a/starsim/parameters.py b/starsim/parameters.py
index 71b0d2fc..a386b4a6 100644
--- a/starsim/parameters.py
+++ b/starsim/parameters.py
@@ -2,13 +2,149 @@
 Set parameters
 """
 
+from numbers import Number
+import numpy as np
+import pandas as pd
 import sciris as sc
 import starsim as ss
 
+__all__ = ['Pars', 'SimPars', 'make_pars']
 
-__all__ = ['Parameters', 'make_pars']
+# Define classes to not descend into further -- based on sciris.sc_nested
+atomic_classes = (str, Number, list, np.ndarray, pd.Series, pd.DataFrame, type(None))
 
-class Parameters(sc.objdict):
+
+class Pars(sc.objdict):
+    """
+    Dict-like container of parameters
+    
+    Acts like an ``sc.objdict()``, except that adding new keys are disallowed by
+    default, and auto-updates known types.
+    """
+    def __init__(self, pars=None, **kwargs):
+        if pars is not None:
+            if isinstance(pars, dict):
+                kwargs = pars | kwargs
+            else:
+                errormsg = f'Cannot supply parameters as type {type(pars)}: must be a dict'
+                raise ValueError(errormsg)
+        super().__init__(**kwargs)
+        return
+    
+    def update(self, pars=None, create=False, **kwargs):
+        """
+        Update internal dict with new pars.
+        
+        Args:
+            pars (dict): the parameters to update (if None, do nothing)
+            create (bool): if create is False, then raise a KeyNotFoundError if the key does not already exist
+            kwargs (dict): merged with pars
+        """
+        # Get parameters, and return if none
+        pars = {} if pars is None else dict(pars) # Make it a simple dict, since just iterating over
+        pars = pars | kwargs # Merge dictionaries
+        if not len(pars): # Check that new parameters are supplied, and if not return unchanged
+            return self
+        if not create: # Check if there are any mismatches, which raises an exception if there are
+            self.check_key_mismatch(pars)
+        
+        # Perform the update
+        for key,new in pars.items():
+            if key not in self.keys(): # It's a new parameter and create=True: update directly
+                self[key] = new
+            else:
+                old = self[key] # Get the existing object we're about to update
+                if isinstance(old, atomic_classes): # It's a number, string, etc: update directly
+                    self[key] = new
+                elif isinstance(old, Pars): # It's a Pars object: update recursively
+                    old.update(new, create=create)
+                elif isinstance(old, ss.ndict): # Update module containers
+                    self._update_ndict(key, old, new)
+                elif isinstance(old, ss.Module):  # Update modules
+                    self._update_module(key, old, new)
+                elif isinstance(old, ss.Dist): # Update a distribution
+                    self._update_dist(key, old, new)
+                else: # Everything else; not used currently but could be
+                    warnmsg = 'No known mechanism for handling {type(old)} → {type(new)}; using default'
+                    ss.warn(warnmsg)
+                    self[key] = new
+        return self
+    
+    def _update_ndict(self, key, old, new):
+        """ Update an ndict object in the parameters, e.g. sim.pars.diseases """
+        if not len(old): # It's empty, just overwrite
+            self[key] = new
+        else: # Don't overwrite an existing ndict
+            if isinstance(new, dict):
+                for newkey,newvals in new.items():
+                    old[newkey].pars.update(newvals) # e.g. pars = {'diseases": {'sir': {'dur_inf': 6}}}
+            else:
+                errormsg = f'Cannot update an ndict with {type(new)}: must be a dict to set new parameters'
+                raise TypeError(errormsg)
+        return
+    
+    def _update_module(self, key, old, new):
+        """ Update a Module object in the parameters, e.g. sim.pars.diseases.sir """
+        if isinstance(new, dict):
+            old[key].pars.update(new) # e.g. pars = {'dur_inf': 6}
+        else:
+            errormsg = f'Cannot update a module with {type(new)}: must be a dict to set new parameters'
+            raise TypeError(errormsg)
+        return
+    
+    def _update_dist(self, key, old, new):
+        """ Update a Dist object in the parameters, e.g. sim.pars.diseases.sir.dur_inf """
+        # It's a Dist, e.g. dur_inf = ss.normal(6,2); use directly
+        if isinstance(new, ss.Dist): 
+            if isinstance(old, ss.bernoulli) and not isinstance(new, ss.bernoulli):
+                errormsg = f"Bernoulli distributions can't be changed to another type: {type(new)} is invalid"
+                raise TypeError(errormsg)
+            else:
+                self[key] = new
+        
+        # It's a single number, e.g. dur_inf = 6; set parameters
+        elif isinstance(new, Number):
+            old.set(new)
+        
+        # It's a list number, e.g. dur_inf = [6, 2]; set parameters
+        elif isinstance(new, list):
+            old.set(*new)
+        
+        # It's a dict, figure out what to do
+        elif isinstance(new, dict):
+            newtype = new.get('type')
+            if newtype is None: # Same type of dist, set parameters
+                old.set(**new)
+            else: # We need to create a new distribution
+                if isinstance(old, ss.bernoulli) and newtype != 'bernoulli':
+                    errormsg = f"Bernoulli distributions can't be changed to another type: {newtype} is invalid"
+                    raise TypeError(errormsg)
+                else:
+                    dist = ss.make_dist(new)
+                    self[key] = dist
+        return
+
+    def check_key_mismatch(self, pars):
+        """ Check whether additional keys are being added to the dictionary """
+        available_keys = list(self.keys())
+        new_keys = pars.keys()
+        mismatches = [key for key in new_keys if key not in available_keys]
+        if len(mismatches):
+            errormsg = f'Key(s) {mismatches} not found; available keys are {available_keys}'
+            raise sc.KeyNotFoundError(errormsg)
+        return
+    
+    def dict_update(self, *args, **kwargs):
+        """ Redefine default dict.update(), since overwritten in this class; should not usually be used """
+        super().update(*args, **kwargs)
+        return
+    
+    def to_json(self, **kwargs):
+        """ Convert to JSON representation """
+        return sc.jsonify(self, **kwargs)
+
+
+class SimPars(Pars):
     """
     Create the parameters for the simulation. Typically, this function is used
     internally rather than called by the user; e.g. typical use would be to do
@@ -16,71 +152,263 @@ class Parameters(sc.objdict):
     directly.
 
     Args:
-        kwargs        (dict): any additional kwargs are interpreted as parameter names
-    Returns:
-        pars (dict): the parameters of the simulation
+        kwargs (dict): any additional kwargs are interpreted as parameter names
     """
-
     def __init__(self, **kwargs):
+        
+        # General parameters
+        self.label   = '' # The label of the simulation
+        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)
 
         # Population parameters
-        self.n_agents        = 10e3  # Number of agents
-        self.total_pop       = None  # If defined, used for calculating the scale factor
-        self.pop_scale       = None  # How much to scale the population
+        self.n_agents  = 10e3  # Number of agents
+        self.total_pop = None  # If defined, used for calculating the scale factor
+        self.pop_scale = None  # How much to scale the population
+
+        # Simulation parameters
+        self.unit       = 'year' # The time unit to use (NOT YET IMPLEMENTED)
+        self.start      = 2000   # Start of the simulation
+        self.end        = None   # End of the simulation
+        self.n_years    = 50     # Number of years to run, if end isn't specified. Note that this includes burn-in
+        self.dt         = 1.0    # Timestep
+        self.rand_seed  = 1      # Random seed, if None, don't reset
+        self.slot_scale = 5      # Random slots will be assigned to newborn agents between min=n_agents and max=slot_scale*n_agents
 
         # Demographic parameters
-        self.location    = None  #  NOT CURRENTLY FUNCTIONAL - what demographics to use
+        self.location   = None  #  NOT CURRENTLY FUNCTIONAL - what demographics to use
         self.birth_rate = None
         self.death_rate = None
+        self.use_aging  = None # True if demographics, false otherwise
 
-        # Simulation parameters
-        self.start           = 2000          # Start of the simulation
-        self.end             = None          # End of the simulation
-        self.n_years         = 49            # Number of years to run, if end isn't specified. Note that this includes burn-in
-        self.burnin          = 0             # Number of years of burnin. NB, this is doesn't affect the start and end dates of the simulation, but it is possible remove these years from plots
-        self.dt              = 1.0           # Timestep (in years)
-        self.dt_demog        = 1.0           # Timestep for demographic updates (in years)
-        self.rand_seed       = 1             # Random seed, if None, don't reset
-        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)
-
-        # 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()
+        # Modules: demographics, diseases, connectors, networks, analyzers, and interventions
+        self.people = None
+        self.networks      = ss.ndict()
+        self.demographics  = ss.ndict()
+        self.diseases      = ss.ndict()
+        self.connectors    = ss.ndict()
         self.interventions = ss.ndict()
-        self.analyzers = ss.ndict()
+        self.analyzers     = ss.ndict()
 
         # Update with any supplied parameter values and generate things that need to be generated
         self.update(kwargs)
+        return
+    
+    def is_default(self, key):
+        """ Check if the provided value matches the default """
+        default_pars = SimPars() # Create a default SimPars object
+        default_val = default_pars[key]
+        current_val = self[key]
+        match = (current_val == default_val) # Check if the value matches
+        return match
 
-        if self.slot_scale < 1:
-            raise Exception('The value of the "slot_scale" parameter must be a number >= 1.0')
-
+    def validate(self):
+        """ Call parameter validation methods """
+        self.validate_sim_pars()
+        self.validate_modules()
+        return
+    
+    def validate_sim_pars(self):
+        """ Validate each of the parameter values """
+        self.validate_verbose()
+        self.validate_agents()
+        self.validate_total_pop()
+        self.validate_start_end()
+        self.validate_dt()
+        return
+    
+    def validate_verbose(self):
+        """ Validate verbosity """
+        if self.verbose == 'brief':
+            self.verbose = -1
+        if not sc.isnumber(self.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
+    
+    def validate_agents(self):
+        """ Check that n_agents is supplied and convert to an integer """
+        if self.people is not None:
+            len_people = len(self.people)
+            if (len_people != self.n_agents) and not self.is_default('n_agents'):
+                errormsg = f'Cannot set a custom value of n_agents ({self.n_agents}) that does not match a pre-created People object ({len_people}); use n_agents to create the People object instead'
+                raise ValueError(errormsg)
+            self.n_agents = len_people
+        elif self.n_agents is not None:
+            self.n_agents = int(self.n_agents)
+        else:
+            errormsg = 'Must supply n_agents, a people object, or a popdict'
+            raise ValueError(errormsg)
+        return
+    
+    def validate_total_pop(self):
+        """ Ensure one but not both of total_pop and pop_scale are defined """
+        if self.total_pop is not None:  # If no pop_scale has been provided, try to get it from the location
+            if self.pop_scale is not None:
+                errormsg = f'You can define total_pop ({self.total_pop}) or pop_scale ({self.pop_scale}), but not both, since one is calculated from the other'
+                raise ValueError(errormsg)
+            total_pop = self.total_pop
+        else:
+            if self.pop_scale is not None:
+                total_pop = self.pop_scale * self.n_agents
+            else:
+                total_pop = self.n_agents
+        self.total_pop = total_pop
+        if self.pop_scale is None:
+            self.pop_scale = total_pop / self.n_agents
+        return
+    
+    def validate_start_end(self):
+        """ Ensure at least one of n_years and end is defined, but not both """
+        if self.end is not None:
+            if self.is_default('n_years'):
+                self.n_years = self.end - self.start
+            else:
+                errormsg = f'You can supply either end ({self.end}) or n_years ({self.n_years}) but not both, since one is calculated from the other'
+                raise ValueError(errormsg)
+            if self.n_years <= 0:
+                errormsg = f"Number of years must be >0, but you supplied start={str(self.start)} and " \
+                           f"end={str(self.end)}, which gives n_years={self.n_years}"
+                raise ValueError(errormsg)
+        else:
+            if self.n_years is not None:
+                self.end = self.start + self.n_years
+            else:
+                errormsg = 'You must supply one of n_years and end."'
+                raise ValueError(errormsg)
         return
 
-    def update_pars(self, pars=None, create=False, **kwargs):
+    def validate_dt(self):
         """
-        Update internal dict with new pars.
-        Args:
-            pars (dict): the parameters to update (if None, do nothing)
-            create (bool): if create is False, then raise a KeyNotFoundError if the key does not already exist
+        Check that 1/dt is an integer value, otherwise results and time vectors will have mismatching shapes.
+        init_results explicitly makes this assumption by casting resfrequency = int(1/dt).
         """
-        if pars is not None:
-            if not isinstance(pars, dict):
-                raise TypeError(f'The pars object must be a dict; you supplied a {type(pars)}')
+        if self.unit == 'year': # TODO: implement properly
+            dt = self.dt
+            reciprocal = 1.0 / dt  # Compute the reciprocal of dt
+            if not reciprocal.is_integer():  # Check if reciprocal is not a whole (integer) number
+                # Round the reciprocal
+                reciprocal = int(reciprocal)
+                rounded_dt = 1.0 / reciprocal
+                self.dt = rounded_dt
+                if self.verbose:
+                    warnmsg = f'Warning: Provided time step dt={dt} resulted in a non-integer number of steps per year. Rounded to {rounded_dt}.'
+                    ss.warn(warnmsg)
+        return
+    
+    def validate_modules(self):
+        """ Validate modules passed in pars"""
+        
+        # Do special initializtaion on demographics
+        self.validate_demographics()
+        
+        # Get all modules into a consistent list format
+        modmap = ss.module_map()
+        for modkey in modmap.keys():
+            if not isinstance(self[modkey], ss.ndict):
+                self[modkey] = sc.tolist(self[modkey])
+
+        # Convert any modules that are not already Module objects
+        self.convert_modules()
+
+        # Convert from lists to ndicts
+        for modkey,modclass in modmap.items():
+            self[modkey] = ss.ndict(self[modkey], type=modclass)
+        return
+    
+    def validate_demographics(self):
+        """ Validate demographics-related input parameters"""
+        # Allow shortcut for default demographics # TODO: think about whether we want to enable this, when we have birth_rate and death_rate
+        if self.demographics == True:
+            self.demographics = [ss.Births(), ss.Deaths()]
+
+        # Allow users to add vital dynamics by entering birth_rate and death_rate parameters directly to the sim
+        if self.birth_rate is not None:
+            births = ss.Births(birth_rate=self.birth_rate)
+            self.demographics += births
+        if self.death_rate is not None:
+            background_deaths = ss.Deaths(death_rate=self.death_rate)
+            self.demographics += background_deaths
+        
+        # Decide whether to use aging based on if demographics modules are present
+        if self.use_aging is None:
+            self.use_aging = True if self.demographics else False
+        
+        return
 
-            pars = sc.mergedicts(pars, kwargs)
-            if not create:
-                available_keys = list(self.keys())
-                mismatches = [key for key in pars.keys() if key not in available_keys]
-                if len(mismatches):
-                    errormsg = f'Key(s) {mismatches} not found; available keys are {available_keys}'
-                    raise sc.KeyNotFoundError(errormsg)
-            self.update(pars)
+    def convert_modules(self):
+        """
+        Convert different types of representations for modules into a
+        standardized object representation that can be parsed and used by
+        a Sim object.
+        Used for starsim classes:
+        - networks,
+        - demographics,
+        - diseases,
+        - analyzers,
+        - interventions, and
+        - connectors.
+        """
+        modmap = ss.module_map() # List of modules and parent module classes, e.g. ss.Disease
+        modules = ss.find_modules() # Each individual module class option, e.g. ss.SIR
+        
+        for modkey,ssmoddict in modules.items():
+            moddictkeys = ssmoddict.keys()
+            moddictvals = ssmoddict.values()
+            expected_cls = modmap[modkey]
+            modlist = self[modkey]
+            if isinstance(modlist, list): # Skip over ones that are already ndict format, assume they're already initialized
+                for i,mod in enumerate(modlist):
+                    
+                    # Convert first from a string to a dict
+                    if isinstance(mod, str):
+                        mod = dict(type=mod)
+                        
+                    # Convert from class to class instance (used for interventions and analyzers only)
+                    if isinstance(mod, type) and modkey in ['interventions', 'analyzers']:
+                        mod = mod() # Call it to create a class instance
+                    
+                    # Now convert from a dict to a module
+                    if isinstance(mod, dict):
+                        
+                        # Get the module type as a string
+                        if 'type' in mod:
+                            modtype = mod.pop('type')
+                        else:
+                            errormsg = f'When specifying a {modkey} module with a dict, one of the keys must be "type"; you supplied {mod}'
+                            raise ValueError(errormsg)
+                        
+                        # Get the module type as a class
+                        if isinstance(modtype, str): # Usual case, a string, e.g. dict(type='sir', dur_inf=6)
+                            modtype = modtype.lower() # Because our map is in lowercase
+                            if modtype in moddictkeys:
+                                modcls = ssmoddict[modtype]
+                            else:
+                                errormsg = f'Invalid module name "{modtype}" for "{modkey}"; must be one of {moddictkeys}'
+                                raise TypeError(errormsg)
+                        else: # Allow supplying directly as a class, e.g. dict(type=ss.SIR, dur_inf=6)
+                            if modtype in moddictvals:
+                                modcls = modtype
+                            else:
+                                errormsg = f'Invalid module class "{modtype}" for "{modkey}"; must be one of {moddictvals}'
+                                raise TypeError(errormsg)
+                        
+                        # Create the module and store it in the list
+                        mod = modcls(**mod)
+                    
+                    # Special handling for interventions and analyzers: convert class and function to class instance
+                    if modkey in ['interventions', 'analyzers']:
+                        if isinstance(mod, type) and issubclass(mod, expected_cls):
+                            mod = mod()  # Convert from a class to an instance of a class
+                        elif not isinstance(mod, ss.Module) and callable(mod):
+                            mod = expected_cls.from_func(mod)
+                    
+                    # Do final check
+                    if not isinstance(mod, expected_cls):
+                        errormsg = f'Was expecting {modkey} entry {i} to be class {expected_cls}, but was {type(mod)} instead'
+                        raise TypeError(errormsg)
+                    modlist[i] = mod
         return
 
 
 def make_pars(**kwargs):
-    return Parameters(**kwargs)
+    return SimPars(**kwargs)
diff --git a/starsim/people.py b/starsim/people.py
index 3ab02290..e597f126 100644
--- a/starsim/people.py
+++ b/starsim/people.py
@@ -8,45 +8,131 @@
 import sciris as sc
 import starsim as ss
 
+__all__ = ['People', 'Person']
 
-__all__ = ['BasePeople', 'People']
 
-# %% Main people class
-
-class BasePeople(sc.prettyobj):
-    """
-    A class to handle all the boilerplate for people -- everything interesting 
-    happens in the People class, whereas this class exists to handle the less 
-    interesting implementation details.
+class People(sc.prettyobj):
     """
+    A class to perform all the operations on the people
+    This class is usually created automatically by the sim. The only required input
+    argument is the population size, but typically the full parameters dictionary
+    will get passed instead since it will be needed before the People object is
+    initialized.
 
-    def __init__(self, n_agents):
-
-        n = int(n_agents)
-        self.initialized = False
-        self.uid = ss.IndexArr('uid')  # This variable tracks all UIDs
-        uids = ss.uids(np.arange(n))
-        self.uid.grow(new_vals=uids)
-        self.auids = uids.copy() # This tracks all active UIDs (in practice, agents who are alive)
+    Note that this class handles the mechanics of updating the actual people, while
+    ``ss.BasePeople`` takes care of housekeeping (saving, loading, exporting, etc.).
+    Please see the BasePeople class for additional methods.
 
-        # A slot is a special state managed internally by BasePeople
-        # This is because it needs to be updated separately from any other states, as other states
-        # might have fill_values that depend on the slot
-        self.slot = ss.IndexArr('slot')
-        self.slot.grow(new_vals=uids)
+    Args:
+        pars (dict): the sim parameters, e.g. sim.pars -- alternatively, if a number, interpreted as n_agents
+        age_data (dataframe): a dataframe of years and population sizes, if available
+        extra_states (list): non-default states to initialize
 
-        # User-facing collection of states
-        self.states = ss.ndict(type=ss.Arr)
+    **Examples**::
+        ppl = ss.People(2000)
+    """
 
-        # We also internally store states in a dict keyed by the memory ID of the state, so that we can have colliding names
+    def __init__(self, n_agents, age_data=None, extra_states=None):
+        """ Initialize """
+        
+        # We internally store states in a dict keyed by the memory ID of the state, so that we can have colliding names
         # e.g., across modules, but we will never change the size of a State multiple times in the same iteration over
         # _states. This is a hidden variable because it is internally used to synchronize the size of all States contained
         # within the sim, regardless of where they are. In contrast, `People.states` offers a more user-friendly way to access
         # a selection of the states e.g., module states could be added in there, while intervention states might not
         self._states = {}
+        self.version = ss.__version__  # Store version info
+        self.initialized = False
+
+        # Handle the three fundamental arrays: UIDs for tracking agents, slots for 
+        # tracking random numbers, and AUIDs for tracking alive agents
+        n = int(n_agents)
+        uids = ss.uids(np.arange(n))
+        self.auids = uids.copy() # This tracks all active UIDs (in practice, agents who are alive)
+        self.uid = ss.IndexArr('uid')  # This variable tracks all UIDs
+        self.slot = ss.IndexArr('slot') # A slot is a special state managed internally
+        self.uid.grow(new_vals=uids)
+        self.slot.grow(new_vals=uids)
+        for state in [self.uid, self.slot]:
+            state.people = self # Manually link to people since we don't want to link to states
         
+        # Handle additional states
+        extra_states = sc.promotetolist(extra_states)
+        states = [
+            ss.BoolArr('alive', default=True),  # Time index for death
+            ss.BoolArr('female', default=ss.bernoulli(name='female', p=0.5)),
+            ss.FloatArr('age', default=self.get_age_dist(age_data)), # NaN until conceived
+            ss.FloatArr('ti_dead'),  # Time index for death
+            ss.FloatArr('scale', default=1.0), # The scale factor for the agents (multiplied for making results)
+        ]
+        states.extend(extra_states)
+        self.states = ss.ndict(type=ss.Arr)
+        for state in states:
+            self.states.append(state, overwrite=False)
+            setattr(self, state.name, state)
+            state.link_people(self)
+            
+        return
+
+    @staticmethod
+    def get_age_dist(age_data):
+        """ Return an age distribution based on provided data """
+        if age_data is None:
+            dist = ss.uniform(low=0, high=100, name='Age distribution')
+            return dist
+
+        if sc.checktype(age_data, pd.DataFrame):
+            age_bins = age_data['age'].values
+            age_props = age_data['value'].values
+            age_props = age_props / age_props.sum()
+            return ss.choice(a=age_bins, p=age_props)
+
+    def link_sim(self, sim):
+        """ Initialization """
+        if self.initialized:
+            errormsg = 'Cannot re-initialize a People object directly; use sim.initialize(reset=True)'
+            raise RuntimeError(errormsg)
+        self.sim = sim # Store the sim
+        ss.link_dists(obj=self.states, sim=sim, module=self, skip=[ss.Sim, ss.Module])
         return
+    
+    def add_module(self, module, force=False):
+        """
+        Add a Module to the People instance
+
+        This method is used to add a module to the People. It will register any module states with this
+        people instance for dynamic resizing, and expose the states contained in the module to the user
+        via `People.states.<module_name>.<state_name>`
+        
+        The entries created below make it possible to do `sim.people.hiv.susceptible` or
+        `sim.people.states['hiv.susceptible']` and have both of them work
+        """
+        # Map the module's states into the People state ndict
+        if hasattr(self, module.name) and not force:
+            raise Exception(f'Module {module.name} already added')
 
+        if len(module.states):
+            module_states = sc.objdict()
+            setattr(self, module.name, module_states)
+            for state in module.states:
+                state.link_people(self)
+                combined_name = module.name + '.' + state.name  # We will have to resolve how this works with multiple instances of the same module (e.g., for strains). The underlying machinery should be fine though, with People._states being flat and keyed by ID
+                self.states[combined_name] = state # Register the state on the user-facing side using the combined name. Within the original module, it can still be referenced by its original name
+                module_states[state.name] = state
+        return
+    
+    def init_vals(self):
+        """ Populate states with initial values, the final step of initialization """
+        for state in self.states():
+            if not state.initialized:
+                state.init_vals()
+        self.initialized = True
+        return
+    
+    def __bool__(self):
+        """ Ensure that zero-length people are still truthy """
+        return True
+    
     def __len__(self):
         """ Length of people """
         return len(self.auids)
@@ -55,9 +141,10 @@ def __len__(self):
     def n_uids(self):
         return self.uid.len_used
     
-    def register_state(self, state, die=True):
+    def _link_state(self, state, die=True):
         """
-        Register a state with the People instance for dynamic resizing
+        Link a state with the People instance for dynamic resizing; usually called by
+        state.link_people()
 
         All states should be registered by this function for the purpose of connecting them to the
         People's UIDs and to have them be automatically resized when the number of agents changes.
@@ -74,10 +161,10 @@ def grow(self, n=None, new_slots=None):
         """
         Increase the number of agents
 
-        :param n: Integer number of agents to add
-        :param new_slots: Optionally specify the slots to assign for the new agents. Otherwise, it will default to the new UIDs
+        Args:
+            n: Integer number of agents to add
+            new_slots: Optionally specify the slots to assign for the new agents. Otherwise, it will default to the new UIDs
         """
-        
         if n is None:
             if new_slots is None:
                 errormsg = 'Must supply either n or new_slots'
@@ -97,12 +184,12 @@ def grow(self, n=None, new_slots=None):
         new_slots = new_slots if new_slots is not None else new_uids
         self.slot.grow(new_uids, new_vals=new_slots)
 
+        # Grow the states
         for state in self._states.values():
             state.grow(new_uids)
             
         # Finally, update the alive indices
         self.auids = self.auids.concat(new_uids)
-
         return new_uids
 
     def __getitem__(self, key):
@@ -111,13 +198,13 @@ def __getitem__(self, key):
         If the key is an integer, alias `people.person()` to return a `Person` instance
         """
         if isinstance(key, int):
-            return self.person(key)  # TODO: need to re-implement
+            return self.person(key)
         else:
-            return self.__getattribute__(key)
+            return getattr(self, key)
 
     def __setitem__(self, key, value):
         """ Ditto """
-        return self.__setattr__(key, value)
+        return setattr(self, key, value)
 
     def __iter__(self):
         """ Iterate over people """
@@ -137,177 +224,70 @@ def __setstate__(self, state):
         
         return
 
-
-class People(BasePeople):
-    """
-    A class to perform all the operations on the people
-    This class is usually created automatically by the sim. The only required input
-    argument is the population size, but typically the full parameters dictionary
-    will get passed instead since it will be needed before the People object is
-    initialized.
-
-    Note that this class handles the mechanics of updating the actual people, while
-    ``ss.BasePeople`` takes care of housekeeping (saving, loading, exporting, etc.).
-    Please see the BasePeople class for additional methods.
-
-    Args:
-        pars (dict): the sim parameters, e.g. sim.pars -- alternatively, if a number, interpreted as n_agents
-        strict (bool): whether to only create keys that are already in self.meta.person; otherwise, let any key be set
-        pop_trend (dataframe): a dataframe of years and population sizes, if available
-        kwargs (dict): the actual data, e.g. from a popdict, being specified
-
-    **Examples**::
-        ppl = ss.People(2000)
-    """
-
-    def __init__(self, n_agents, age_data=None, extra_states=None, rand_seed=0):
-        """ Initialize """
-
-        super().__init__(n_agents)
-
-        self.initialized = False
-        self.version = ss.__version__  # Store version info
-
-        # Handle states
-        states = [
-            ss.BoolArr('alive', default=True),  # Time index for death
-            ss.BoolArr('female', default=ss.bernoulli(name='female', p=0.5)),
-            ss.FloatArr('age'), # NaN until conceived
-            ss.FloatArr('ti_dead'),  # Time index for death
-            ss.FloatArr('scale', default=1.0), # The scale factor for the agents (multiplied for making results)
-        ]
-        states.extend(sc.promotetolist(extra_states))
-
-        # Expose states with their original names directly as people attributes (e.g., `People.age`) and nested under states
-        # (e.g., `People.states.age`)
-        for state in states:
-            self.states.append(state, overwrite=False)
-            setattr(self, state.name, state)
-
-        # Set initial age distribution - likely move this somewhere else later
-        self.age_data_dist = self.get_age_dist(age_data)
-
-        return
-
-    @staticmethod
-    def get_age_dist(age_data):
-        """ Return an age distribution based on provided data """
-        if age_data is None:
-            dist = ss.uniform(low=0, high=100, name='Age distribution')
-            return dist
-
-        if sc.checktype(age_data, pd.DataFrame):
-            age_bins = age_data['age'].values
-            age_props = age_data['value'].values
-            age_props = age_props / age_props.sum()
-            return ss.choice(a=age_bins, p=age_props)
-
-    def initialize(self, sim):
-        """ Initialization """
-
-        if self.initialized:
-            return
-        else:
-            self.initialized = True # Expected by state.initialize()
-        
-        # For People initialization, first initialize slots, then initialize RNGs, then initialize remaining states
-        # This is because some states may depend on RNGs being initialized to generate initial values
-        self.uid.set_people(sim.people)
-        self.slot.set_people(sim.people)
-
-        # Initialize states
-        # Age is handled separately because the default value for new agents is NaN until they are concieved/born whereas
-        # the initial values need to depend on the current age distribution for the setting. In contrast, the sex for new
-        # agents can be sampled from the same distribution used to initialize the population
-        for state in self.states.values():
-            state.initialize(sim)
-
-        # Assign initial ages based on the current age distribution
-        self.age_data_dist.initialize(module=self, sim=sim)
-        self.age[:] = self.age_data_dist.rvs(self.uid)
-        return
-
-    def add_module(self, module, force=False):
+    def scale_flows(self, inds):
         """
-        Add a Module to the People instance
-
-        This method is used to add a module to the People. It will register any module states with this
-        people instance for dynamic resizing, and expose the states contained in the module to the user
-        via `People.states.<module_name>.<state_name>`
-        :param module:
-        :param force:
-        :return:
+        Return the scaled versions of the flows -- replacement for len(inds)
+        followed by scale factor multiplication
         """
-        # Map the module's states into the People state ndict
-        if hasattr(self, module.name) and not force:
-            raise Exception(f'Module {module.name} already added')
-        self.__setattr__(module.name, sc.objdict())
-
-        # The entries created below make it possible to do `sim.people.hiv.susceptible` or
-        # `sim.people.states['hiv.susceptible']` and have both of them work
+        return self.scale[inds].sum()
 
-        module_states = sc.objdict()
-        setattr(self, module.name, module_states)
-        self._register_module_states(module, module_states)
+    def update_post(self):
+        """ Final updates at the very end of the timestep """
+        sim = self.sim
+        if sim.pars.use_aging:
+            self.age[self.alive.uids] += sim.dt
         return
 
-    def _register_module_states(self, module, module_states):
-        """Enable dot notation for module specific states:
-         - `sim.people.hiv.susceptible` or
-         - `sim.people.states['hiv.susceptible']`
+    def request_death(self, uids):
         """
+        External-facing function to request an agent die at the current timestep
 
-        for state in module.states:
-            combined_name = module.name + '.' + state.name  # We will have to resolve how this works with multiple instances of the same module (e.g., for strains). The underlying machinery should be fine though, with People._states being flat and keyed by ID
-            self.states[combined_name] = state  # Register the state on the user-facing side using the combined name. Within the original module, it can still be referenced by its original name
-            pre, _, post = combined_name.rpartition('.')
-            setattr(module_states, state.name, state)
+        In general, users should not directly interact with `People.ti_dead` to minimize
+        interactions between modules (e.g., if a module requesting a future death, overwrites
+        death due to a different module taking place at the current timestep).
 
-        return
+        Modules that have a future time of death (e.g., due to disease duration) should keep
+        track of that internally. When the module is ready to cause the agent to die, it should
+        call this method, and can update its own results for the cause of death. This way, if
+        multiple modules request death on the same day, they can each record a death due to their
+        own cause.
 
-    def scale_flows(self, inds):
-        """
-        Return the scaled versions of the flows -- replacement for len(inds)
-        followed by scale factor multiplication
+        The actual deaths are resolved after modules have all run, but before analyzers. That way,
+        regardless of whether removing dead agents is enabled or not, analyzers will be able to
+        see and record outcomes for agents that died this timestep.
+
+        **WARNING** - this function allows multiple modules to each independently carry out and
+        record state changes associated with death. It is therefore important that they can
+        guarantee that after requesting death, the death is guaranteed to occur.
+
+        :param uids: Agent IDs to request deaths for
+        :return: UIDs of agents that have been scheduled to die on this timestep
         """
-        return self.scale[inds].sum()
+        self.ti_dead[uids] = self.sim.ti
+        return
 
-    def remove_dead(self, sim):
+    def resolve_deaths(self):
+        """ Carry out any deaths that took place this timestep """
+        death_uids = (self.ti_dead <= self.sim.ti).uids
+        self.alive[death_uids] = False
+        return death_uids
+    
+    def remove_dead(self):
         """
         Remove dead agents
         """
-        uids = (~self.alive).uids
+        uids = self.dead.uids
         if len(uids):
             
             # Remove the UIDs from the networks too
-            for network in sim.networks.values():
+            for network in self.sim.networks.values():
                 network.remove_uids(uids) # TODO: only run once every nth timestep
                 
             # Calculate the indices to keep
             self.auids = self.auids.remove(uids)
 
         return
-
-    def update_post(self, sim):
-        """
-        Final updates at the very end of the timestep
-
-        :param sim:
-        :return:
-        """
-        self.age[self.alive.uids] += self.dt
-        return
-
-    def resolve_deaths(self):
-        """
-        Carry out any deaths that took place this timestep
-
-        :return:
-        """
-        death_uids = (self.ti_dead <= self.ti).uids
-        self.alive[death_uids] = False
-        return death_uids
-
+    
     @property
     def dead(self):
         """ Dead boolean """
@@ -318,56 +298,28 @@ def male(self):
         """ Male boolean """
         return ~self.female
 
-    @property
-    def f(self): # TODO: remove?
-        """ Shorthand for female """
-        return self.female
-
-    @property
-    def m(self):
-        """ Shorthand for male """
-        return self.male
-
-    def init_results(self, sim):
-        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),
-            ss.Result(None, 'cum_deaths', sim.npts, ss.dtypes.int, scale=True),
-        ]
-        return
-
-    def update_results(self, sim):
-        ti = sim.ti
-        res = sim.results
+    def update_results(self):
+        ti = self.sim.ti
+        res = self.sim.results
         res.n_alive[ti] = np.count_nonzero(self.alive)
         res.new_deaths[ti] = np.count_nonzero(self.ti_dead == ti)
         res.cum_deaths[ti] = np.sum(res.new_deaths[:ti]) # TODO: inefficient to compute the cumulative sum on every timestep!
         return
+    
+    def person(self, ind):
+        """ Get all the properties for a single person """
+        person = Person()
+        for key in ['uid', 'slot']:
+            person[key] = self[key][ind]
+        for key in self.states.keys():
+            person[key] = self.states[key][ind]
+        return person
+            
 
-    def request_death(self, uids):
-        """
-        External-facing function to request an agent die at the current timestep
-
-        In general, users should not directly interact with `People.ti_dead` to minimize
-        interactions between modules (e.g., if a module requesting a future death, overwrites
-        death due to a different module taking place at the current timestep).
-
-        Modules that have a future time of death (e.g., due to disease duration) should keep
-        track of that internally. When the module is ready to cause the agent to die, it should
-        call this method, and can update its own results for the cause of death. This way, if
-        multiple modules request death on the same day, they can each record a death due to their
-        own cause.
-
-        The actual deaths are resolved after modules have all run, but before analyzers. That way,
-        regardless of whether removing dead agents is enabled or not, analyzers will be able to
-        see and record outcomes for agents that died this timestep.
-
-        **WARNING** - this function allows multiple modules to each independently carry out and
-        record state changes associated with death. It is therefore important that they can
-        guarantee that after requesting death, the death is guaranteed to occur.
-
-        :param uids: Agent IDs to request deaths for
-        :return: UIDs of agents that have been scheduled to die on this timestep
-        """
-        self.ti_dead[uids] = self.ti
-        return
+class Person(sc.objdict):
+    """ A simple class to hold all attributes of a person """
+    def to_df(self):
+        """ Convert to a dataframe """
+        df = sc.dataframe.from_dict(self, orient='index', columns='value')
+        df.index.name = 'key'
+        return df
diff --git a/starsim/products.py b/starsim/products.py
index a49987b0..f5e7a6b2 100644
--- a/starsim/products.py
+++ b/starsim/products.py
@@ -12,6 +12,11 @@
 
 class Product(ss.Module):
     """ Generic product implementation """
+    def initialize(self, sim):
+        if not self.initialized:
+            super().initialize(sim)
+        else:
+            return
 
     def administer(self, people, inds):
         """ Adminster a Product - implemented by derived classes """
@@ -142,10 +147,8 @@ def administer(self, sim, uids, return_format='dict'):
 
 class Vx(Product):
     """ Vaccine product """
-    def __init__(self, diseases=None, pars=None, par_dists=None, *args, **kwargs):
-        pars = ss.dictmerge({}, pars)
-        par_dists = ss.dictmerge({}, par_dists)
-        super().__init__(pars, par_dists, *args, **kwargs)
+    def __init__(self, diseases=None, pars=None, *args, **kwargs):
+        super().__init__(pars, *args, **kwargs)
         self.diseases = sc.tolist(diseases)
         return
 
diff --git a/starsim/results.py b/starsim/results.py
index 67bb956c..001cfd71 100644
--- a/starsim/results.py
+++ b/starsim/results.py
@@ -29,9 +29,9 @@ def __repr__(self):
     def __array_finalize__(self, obj):
         if obj is None:
             return
-        self.name = getattr(obj, 'name', None)
+        self.name   = getattr(obj, 'name',   None)
         self.module = getattr(obj, 'module', None)
-        self.scale = getattr(obj, 'scale', None)
+        self.scale  = getattr(obj, 'scale',  None)
         return
 
     def __array_wrap__(self, obj, **kwargs):
diff --git a/starsim/sim.py b/starsim/sim.py
index de7e55c4..f156128c 100644
--- a/starsim/sim.py
+++ b/starsim/sim.py
@@ -3,460 +3,167 @@
 """
 
 # Imports
+import itertools
 import numpy as np
 import sciris as sc
 import starsim as ss
-import itertools
 import matplotlib.pyplot as pl
 
-__all__ = ['Sim', 'AlreadyRunError', 'demo', 'diff_sims']
+__all__ = ['Sim', 'AlreadyRunError', 'demo', 'diff_sims', 'check_sims_match']
 
 
 class Sim(sc.prettyobj):
 
     def __init__(self, pars=None, label=None, people=None, demographics=None, diseases=None, networks=None,
-                 connectors=None, interventions=None, analyzers=None, **kwargs):
+                 interventions=None, analyzers=None, connectors=None, copy_inputs=True, **kwargs):
 
+        # Make default parameters (using values from parameters.py)
+        self.pars = ss.make_pars() # Start with default pars
+        args = dict(label=label, people=people, demographics=demographics, diseases=diseases, networks=networks, 
+                    interventions=interventions, analyzers=analyzers, connectors=connectors)
+        args = {key:val for key,val in args.items() if val is not None} # Remove None inputs
+        self.pars.update(sc.mergedicts(pars, args, kwargs, _copy=copy_inputs))  # Update the parameters
+        
         # 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.results = ss.Results(module='sim')  # For storing results
-        self.summary = None  # For storing a summary of the results
+        self.label = label # Usually overwritten during initialization by the parameters
+        self.created = sc.now()  # The datetime the sim was created
         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
-
-        # Time indexing
-        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
-
-        # 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
-
-        # Placeholders for plug-ins: demographics, diseases, connectors, analyzers, and interventions
-        # Products are not here because they are stored within interventions
-        if demographics == True: demographics = [ss.Births(), ss.Deaths()]  # Use default assumptions for demographics
-        self.demographics  = ss.ndict(demographics, type=ss.Demographics)
-        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, strict=False) # strict=False since can be a function
-        self.analyzers     = ss.ndict(analyzers, type=ss.Analyzer, strict=False)
-
-        # Initialize the random number generator container
-        self.dists = ss.Dists(obj=self)
-
+        self.dists = ss.Dists(obj=self) # Initialize the random number generator container
+        self.results = ss.Results(module='sim')  # For storing results
+        self.summary = None  # For storing a summary of the results
+        self.filename = None # Store the filename, if saved
         return
-
-    @property
-    def dt(self):
-        if 'dt' in self.pars:
-            return self.pars['dt']
-        else:
-            return np.nan
-
-    @property
-    def year(self):
-        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)]
-        return itertools.chain(
-            self.demographics.values(),
-            self.networks.values(),
-            self.diseases.values(),
-            self.connectors.values(),
-            self.interventions.values(),
-            products,
-            self.analyzers.values(),
-        )
-
-    def initialize(self, reset=False, **kwargs):
-        """
-        Perform all initializations on the sim.
-        """
+    
+    def __getitem__(self, key):
+        """ Allow dict-like access, e.g. sim['diseases'] """
+        return getattr(self, key)
+    
+    def __setitem__(self, key, value):
+        """ Allow dict-like access, e.g. sim['created'] = sc.now() """
+        return setattr(self, key, value)
+    
+    def initialize(self, **kwargs):
+        """ Perform all initializations for the sim; most heavy lifting is done by the parameters """
         # Validation and initialization
-        self.ti = 0  # The current time index
-        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 seed before the population is created -- shouldn't matter if only using Dist objects
-
-        # Initialize the core sim components
-        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()
-        self.init_connectors()
-        self.init_interventions()
-        self.init_analyzers()
-
-        # Initialize all distributions now that everything else is in place
+        ss.set_seed(self.pars.rand_seed) # Reset the seed before the population is created -- shouldn't matter if only using Dist objects
+        
+        # Validate parameters
+        self.pars.validate()
+
+        # Initialize time
+        self.init_time_attrs()
+        
+        # Initialize the people
+        self.init_people(**kwargs)  # Create all the people
+        
+        # # Initialize the modules within the parameters
+        # self.pars.validate_modules(self)
+        
+        # Move initialized modules to the sim
+        keys = ['label', 'demographics', 'networks', 'diseases', 'interventions', 'analyzers', 'connectors']
+        for key in keys:
+            setattr(self, key, self.pars.pop(key))
+            
+        # Initialize all the modules with the sim
+        for mod in self.modules:
+            mod.initialize(self)
+                
+        # Initialize products # TODO: think about simplifying
+        for mod in self.interventions:
+            if hasattr(mod, 'product') and isinstance(mod.product, ss.Product):
+                mod.product.initialize(self)
+        
+        # Initialize all distributions now that everything else is in place, then set states
         self.dists.initialize(obj=self, base_seed=self.pars.rand_seed, force=True)
+        
+        # Initialize the values in all of the states and networks
+        self.init_vals()
+        
+        # Initialize the results
+        self.init_results()
 
-        # Final steps
+        # It's initialized
         self.initialized = True
-        self.complete = False
-        self.results_ready = False
-
         return self
-
-    def validate_dt(self):
-        """
-        Check that 1/dt is an integer value, otherwise results and time vectors will have mismatching shapes.
-        init_results explicitly makes this assumption by casting resfrequency = int(1/dt).
-        """
-        dt = self.dt
-        reciprocal = 1.0 / dt  # Compute the reciprocal of dt
-        if not reciprocal.is_integer():  # Check if reciprocal is not a whole (integer) number
-            # Round the reciprocal
-            reciprocal = int(reciprocal)
-            rounded_dt = 1.0 / reciprocal
-            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
-
-    def validate_pars(self):
-        """
-        Some parameters can take multiple types; this makes them consistent.
-        """
-        # 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)
-        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 = 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
-            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.par.verbose)} "{self.par.verbose}"'
-            raise ValueError(errormsg)
-
-        return
-
-    def init_time_vecs(self):
-        """
-        Construct vectors things that keep track of time
-        """
-        self.yearvec = np.arange(start=self.pars.start, stop=self.pars.end + self.pars.dt, step=self.pars.dt)
-        self.npts = len(self.yearvec)
-        self.tivec = np.arange(self.npts)
+    
+    def init_time_attrs(self):
+        """ Time indexing; derived values live in the sim rather than in the pars """
+        self.dt = self.pars.dt # Shortcut to dt since used a lot
+        self.yearvec = np.arange(start=self.pars.start, stop=self.pars.end + self.pars.dt, step=self.pars.dt) # The time points of the sim
+        self.results.yearvec = self.yearvec # Store the yearvec in the results for plotting
+        self.npts = len(self.yearvec) # The number of points in the sim
+        self.tivec = np.arange(self.npts) # The vector of time indices
+        self.ti = 0  # The time index, e.g. 0, 1, 2
         return
 
-    def init_people(self, reset=False, verbose=None, **kwargs):
+    def init_people(self, verbose=None, **kwargs):
         """
         Initialize people within the sim
         Sometimes the people are provided, in which case this just adds a few sim properties to them.
         Other time people are not provided and this method makes them.
+        
         Args:
-            reset           (bool): whether to regenerate the people even if they already exist
-            verbose         (int):  detail to print
-            kwargs          (dict): passed to ss.make_people()
+            verbose (int):  detail to print
+            kwargs  (dict): passed to ss.make_people()
         """
-
         # Handle inputs
-        if verbose is None:
-            verbose = self.pars.verbose
+        people = self.pars.pop('people')
+        n_agents = self.pars.n_agents
+        verbose = sc.ifelse(verbose, self.pars.verbose)
         if verbose > 0:
-            resetstr = ''
-            if self.people and reset:
-                resetstr = ' (resetting people)'
-            print(f'Initializing sim{resetstr} with {self.pars["n_agents"]:0n} agents')
-
-        # If people have not been supplied, make them
-        if self.people is None or reset:
-            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:
-            # 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
-                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
-            else:
-                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
-
-        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:
-            self.people.initialize(self)
-
-        # Set time attributes
-        self.people.ti = self.ti
-        self.people.dt = self.dt
-        self.people.year = self.year
-        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
-
-        # 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 = {}
-
-        # Check that we don't have two copies
-        for attr_key in attr_plugins.keys():
-            if plugins.get(attr_key):
-                errormsg = f'Sim was created with {attr_key} module, cannot create another through the pars dict.'
-                raise ValueError(errormsg)
-
-        plugins = sc.mergedicts(plugins, attr_plugins)
-
-        # Process
-        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)
-                elif plugin_name in ['analyzers', 'interventions'] and callable(plugin):
-                    pass # This is ok, it's a function instead of an Intervention object
-                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):
-        """ Initialize demographics """
-
-        # Demographics can be provided via sim.demographics or sim.pars - this methods reconciles them
-        demographics = self.convert_plugins(ss.Demographics, 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
-
-        # Count how many of each kind of demographic module we have
-        demdict = {'births': ss.Births, 'pregnancy': ss.Pregnancy, 'deaths': ss.Deaths}
-        mod_names = dict()
-        for demname, demtype in demdict.items():
-            mod_names[demname] = [d.name for d in demographics if isinstance(d, demtype)]
-
-            # Validation
-            if len(mod_names[demname]) > 1:
-                if len(mod_names[demname]) == len(set(mod_names[demname])):  # No duplicate names, raise warning
-                    ss.warn(f'Two instances of {demname} module added to the sim; was this intentional?')
-                else:
-                    errormsg = (f'Cannot add two identically-named {demname} modules to a sim.\n '
-                                f'Demographic modules are: \n{sc.newlinejoin(mod_names[demname])}.\n'
-                                f'Tip: if using demographic modules, do not use birth and death rates in the sim pars.')
-                    raise ValueError(errormsg)
-
-        # Ensure they're stored at the sim level
-        self.demographics = ss.ndict(*demographics)
-
-    def init_diseases(self):
-        """ 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
-            self.pars[disease.name] = disease.pars
-            self.results[disease.name] = disease.results
-
-            # 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):
-        for connector in self.connectors.values():
-            connector.initialize(self)
-        return
-
-    def init_networks(self):
-        """ Initialize networks if these have been provided separately from the people """
-
-        processed_networks = self.convert_plugins(ss.Network, plugin_name='networks')
-
-        # 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(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)
-            elif callable(intervention):
-                intv_func = intervention
-                intervention = ss.Intervention(name=f'intervention_func_{i}')
-                intervention.apply = intv_func # Monkey-patch together an intervention from a function
-            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
+            labelstr = f' "{self.label}"' if self.label else ''
+            print(f'Initializing sim{labelstr} with {n_agents:0n} agents')
 
-            # Add the intervention parameters and results into the Sim's dicts
-            self.pars[intervention.name] = intervention.pars
-            self.results[intervention.name] = intervention.results
+        # If people have not been supplied, make them -- typical use case
+        if people is None:
+            people = ss.People(n_agents=n_agents, **kwargs)  # This just assigns UIDs and length
 
-            # Add intervention states to the People's dicts
-            self.people.add_module(intervention)
-
-            # 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)
+        # Finish up (NB: the People object is not yet initialized)
+        self.people = people
+        self.people.link_sim(self)
+        return self.people
+    
+    def init_vals(self):
+        """ Initialize the states and other objects with values """
         
-        # TODO: combine this with the code above
-        for k,intervention in self.interventions.items():
-            if not isinstance(intervention, ss.Intervention):
-                intv_func = intervention
-                intervention = ss.Intervention(name=f'intervention_func_{k}')
-                intervention.apply = intv_func # Monkey-patch together an intervention from a function
-                self.interventions[k] = intervention
-
-        return
-
-    def init_analyzers(self):
-        """ Initialize the analyzers """
+        # Initialize values in people
+        self.people.init_vals()
         
-        analyzers = self.pars.analyzers
-        if not np.iterable(analyzers):
-            analyzers = sc.tolist(analyzers)
-
-        # Interpret analyzers
-        for ai, analyzer in enumerate(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)):
-                errormsg = f'Analyzer {analyzer} does not seem to be a valid analyzer: must be a function or Analyzer subclass'
-                raise TypeError(errormsg)
-            self.analyzers += analyzer  # Add it in
-
-        # TODO: should tidy/remove this code
-        for k,analyzer in self.analyzers.items():
-            if not isinstance(analyzer, ss.Analyzer) and callable(analyzer):
-                ana_func = analyzer
-                analyzer = ss.Analyzer(name=f'analyzer_func_{k}')
-                analyzer.apply = ana_func # Monkey-patch together an intervention from a function
-                self.analyzers[k] = analyzer
-            if isinstance(analyzer, ss.Analyzer):
-                analyzer.initialize(self)
-
+        # Initialize values in other modules, including networks
+        for mod in self.modules:
+            mod.init_vals()
+        return
+    
+    def init_results(self):
+        """ Create initial results that are present in all simulations """
+        self.results += [ # TODO: refactor with self.add_results()
+            ss.Result(None, 'n_alive',    self.npts, ss.dtypes.int, scale=True),
+            ss.Result(None, 'new_deaths', self.npts, ss.dtypes.int, scale=True),
+            ss.Result(None, 'cum_deaths', self.npts, ss.dtypes.int, scale=True),
+        ]
         return
 
+    @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)]
+        return itertools.chain(
+            self.demographics.values(),
+            self.networks.values(),
+            self.diseases.values(),
+            self.connectors.values(),
+            self.interventions.values(),
+            products,
+            self.analyzers.values(),
+        )
+    
+    @property
+    def year(self):
+        return self.yearvec[self.ti]
+
     def step(self):
         """ Step through time and update values """
 
@@ -468,53 +175,57 @@ def step(self):
         self.dists.jump(to=self.ti+1)  # +1 offset because ti=0 is used on initialization
 
         # Update demographic modules (create new agents from births/immigration, schedule non-disease deaths and emigration)
-        for dem_mod in self.demographics.values():
-            dem_mod.update(self)
+        for dem_mod in self.demographics():
+            dem_mod.update()
 
         # Carry out autonomous state changes in the disease modules. This allows autonomous state changes/initializations
         # to be applied to newly created agents
-        for disease in self.diseases.values():
-            disease.update_pre(self)
+        for disease in self.diseases():
+            disease.update_pre()
 
         # Update connectors -- TBC where this appears in the ordering
-        for connector in self.connectors.values():
-            connector.update(self)
+        for connector in self.connectors():
+            connector.update()
 
         # Update networks - this takes place here in case autonomous state changes at this timestep
+        for network in self.networks():
         # affect eligibility for contacts
-        self.networks.update(self.people)
+            network.update()
 
         # 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
-        for intervention in self.interventions.values():
+        for intervention in self.interventions():
             intervention(self)
 
         # Carry out transmission/new cases
-        for disease in self.diseases.values():
-            disease.make_new_cases(self)
+        for disease in self.diseases():
+            disease.make_new_cases()
 
         # Execute deaths that took place this timestep (i.e., changing the `alive` state of the agents). This is executed
         # before analyzers have run so that analyzers are able to inspect and record outcomes for agents that died this timestep
         uids = self.people.resolve_deaths()
-        for disease in self.diseases.values():
-            disease.update_death(self, uids)
+        for disease in self.diseases():
+            disease.update_death(uids)
 
         # Update results
-        self.people.update_results(self)
+        self.people.update_results()
+
+        for dem_mod in self.demographics():
+            dem_mod.update_results()
 
-        for disease in self.diseases.values():
-            disease.update_results(self)
+        for disease in self.diseases():
+            disease.update_results()
 
-        for analyzer in self.analyzers.values():
+        for analyzer in self.analyzers():
             analyzer(self)
             
         # Clean up dead agents
-        self.people.remove_dead(self)
+        self.people.remove_dead()
 
         # Tidy up
         self.ti += 1
         self.people.ti = self.ti
-        self.people.update_post(self)
+        self.people.update_post()
 
         if self.ti == self.npts:
             self.complete = True
@@ -585,7 +296,7 @@ def finalize(self, verbose=None):
                 self.results[reskey] = self.results[reskey] * self.pars.pop_scale
 
         for module in self.modules:
-            module.finalize(self)
+            module.finalize()
 
         self.summarize()
         self.results_ready = True  # Set this first so self.summary() knows to print the results
@@ -859,13 +570,18 @@ def export_pars(self, filename=None, indent=2, *args, **kwargs):
             pardict (dict): a dictionary containing all the parameter values
         '''
         pardict = {}
-        for key in self.pars.keys():
-            if key == 'interventions':
-                pardict[key] = [intervention.to_json() for intervention in self.pars[key]]
-            elif key == 'start_day':
-                pardict[key] = str(self.pars[key])
-            else:
-                pardict[key] = self.pars[key]
+        for key,item in self.pars.items():
+            if key in ss.module_map().keys():
+                if np.iterable(item):
+                    item = [mod.to_json() for mod in item]
+                else:
+                    try:
+                        item = item.to_json()
+                    except:
+                        pass
+            elif key == 'people':
+                continue
+            pardict[key] = item
         if filename is not None:
             sc.savejson(filename=filename, obj=pardict, indent=indent, *args, **kwargs)
         return pardict
@@ -935,12 +651,15 @@ def to_json(self, filename=None, keys=None, tostring=False, indent=2, verbose=Fa
 
         return output
 
-    def plot(self):
+    def plot(self, key=None):
         with sc.options.with_style('fancy'):
             flat = sc.flattendict(self.results, sep=': ')
+            yearvec = flat.pop('yearvec')
+            if key is not None:
+                flat = {k:v for k,v in flat.items() if k.startswith(key)}
             fig, axs = sc.getrowscols(len(flat), make=True)
             for ax, (k, v) in zip(axs.flatten(), flat.items()):
-                ax.plot(self.yearvec, v)
+                ax.plot(yearvec, v)
                 ax.set_title(k)
                 ax.set_xlabel('Year')
         return fig
@@ -1112,7 +831,22 @@ def diff_sims(sim1, sim2, skip_key_diffs=False, skip=None, full=False, output=Fa
             return df
         else:
             print(mismatchmsg)
+            return True
     else:
         if not output:
             print('Sims match')
-    return
+        return False
+
+
+def check_sims_match(*args, full=False):
+    """ Shortcut to using ss.diff_sims() to check if multiple sims match """
+    s1 = args[0]
+    matches = []
+    for s2 in args[1:]:
+        diff = diff_sims(s1, s2, full=False, output=False, die=False)
+        matches.append(not(diff)) # Return the opposite of the diff
+    if full:
+        return matches
+    else:
+        return all(matches)
+        
diff --git a/starsim/states.py b/starsim/states.py
index 4dfa48ae..845a089e 100644
--- a/starsim/states.py
+++ b/starsim/states.py
@@ -85,7 +85,7 @@ def __init__(self, name, dtype=None, default=None, nan=None, raw=None, label=Non
         
         # Properties that are initialized later
         self.raw = np.empty(0, dtype=dtype)
-        self.people = None
+        self.people = None # Used solely for accessing people.auids
         self.len_used = 0
         self.len_tot = 0
         self.initialized = skip_init
@@ -107,14 +107,15 @@ def _convert_key(self, key):
         the raw array (``raw``) or the active agents (``values``), and to convert
         the key to array indices if needed.
         """
-        use_raw = True
         if isinstance(key, uids):
-            pass
+            use_raw = True
         elif isinstance(key, (BoolArr, IndexArr)):
+            use_raw = True
             key = key.uids
-        elif isinstance(key, (slice, int)):
+        elif isinstance(key, slice):
             use_raw = False
         elif not np.isscalar(key) and len(key) == 0: # Handle [], np.array([]), etc.
+            use_raw = True # Doesn't matter since returning empty, but this is faster
             key = uids()
         else:
             errormsg = f'Indexing an Arr ({self.name}) by ({key}) is ambiguous or not supported. Use ss.uids() instead, or index Arr.raw or Arr.values.'
@@ -166,7 +167,7 @@ def auids(self):
         try:
             return self.people.auids
         except:
-            print('Non-initialized States object')
+            ss.warn('Trying to access non-initialized States object')
             return uids(np.arange(len(self.raw)))
     
     def count(self):
@@ -226,54 +227,18 @@ def grow(self, new_uids=None, new_vals=None):
         self.set(new_uids, new_vals=new_vals) # Assign new default values to those agents
         return
     
-    def set_people(self, people):
-        """ Reset the people object associated with this state """
-        if isinstance(people, ss.People): # It's people, it's fine
-            pass
-        elif isinstance(people, ss.Sim): # Actually a sim
-            people = people.people
-        else:
-            errormsg = f'Must supply a Sim or People object, not {type(people)}'
-            raise TypeError(errormsg)
-        assert people.initialized, 'People must be initialized before initializing states'
-        self.people = people # Shorten since used a lot
+    def link_people(self, people):
+        """ Link a People object to this state, for access auids """
+        self.people = people # Link the people object to this state
+        people._link_state(self) # Ensure the state is linked to the People object as well
         return
-
-    def initialize(self, sim):
-        """
-        Initialize state
-
-        This method should be called as part of initialization of the parent class containing the state -
-        specifically, `People.initialize()` and `Module.initialize()`. Initialization of a State object
-        involves two processes:
-
-        - Converting any distribution objects to a Dist instance and linking it to RNGs stored in a `Sim`
-        - Establishing a bidirectional connection with a `People` object for the purpose of UID indexing and resizing
-
-        Since State objects can be stored in `People` or in a `Module` and the collection of all states in a `Sim` should
-        be connected to RNGs within that same `Sim`, the states must necessarily be linked to the same `People` object that
-        is inside a `Sim`. Initializing States outside of a `Sim` is not possible because of this RNG dependency, particularly
-        because the states in a `People` object cannot be initialized without a `Sim` and therefore it would not be possible to
-        have an initialized `People` object outside of a `Sim`.
-        
-        Args:
-            sim: A `Sim` instance that contains an initialized `People` object
-        """
-        # Skip if already initialized
+    
+    def init_vals(self):
+        """ Actually populate the initial values and mark as initialized; only to be used on initialization """
         if self.initialized:
-            return
-
-        # Establish connection with the People object
-        people = sim.people
-        self.set_people(people)
-        people.register_state(self)
-        
-        # Connect any distributions in the default to RNGs in the Sim
-        if isinstance(self.default, ss.Dist):
-            self.default.initialize(module=self, sim=sim)
-
-        # Populate initial values
-        self.grow(people.uid)
+            errormsg = f'Cannot re-initialize state {self}; use set() instead'
+            raise RuntimeError(errormsg)
+        self.grow(self.people.uid)
         self.initialized = True
         return
 
@@ -328,9 +293,23 @@ def __invert__(self):     return self.asnew(~self.values)
     
     @property
     def uids(self):
-        """ Convert True values to UIDs """
+        """ Efficiently convert True values to UIDs """
         return self.auids[np.nonzero(self.values)[0]]
 
+    def true(self):
+        """ Alias to BoolArr.uids """
+        return self.uids
+
+    def false(self):
+        """ Reverse of true(); return UIDs of values that are false """
+        return self.auids[np.nonzero(~self.values)[0]]
+
+    def split(self):
+        """ Return UIDs of values that are true and false as separate arrays """
+        t_uids = self.true()
+        f_uids = self.false()
+        return t_uids, f_uids
+
     
 class IndexArr(Arr):
     """ A special class of IndexArr used for UIDs and RNG IDs """
@@ -369,9 +348,15 @@ class uids(np.ndarray):
     UID operations.    
     """
     def __new__(cls, arr=None):
-        if arr is None:
-            arr = np.empty(0, dtype=ss_int)
-        return np.asarray(arr).view(cls)
+        if isinstance(arr, np.ndarray): # Shortcut to typical use case, where the input is an array
+            return arr.astype(ss_int).view(cls)
+        elif isinstance(arr, BoolArr): # Shortcut for arr.uids
+            return arr.uids
+        elif arr is None: # Shortcut to return empty
+            return np.empty(0, dtype=ss_int).view(cls)
+        elif isinstance(arr, int): # Convert e.g. ss.uids(0) to ss.uids([0])
+            arr = [arr]
+        return np.asarray(arr, dtype=ss_int).view(cls) # Handle everything else
     
     def concat(self, other, **kw): # TODO: why can't they both be called cat()?
         """ Equivalent to np.concatenate(), but return correct type """
@@ -384,11 +369,17 @@ def cat(cls, *args, **kw):
         return np.concatenate(arrs, **kw).view(cls)
     
     def remove(self, other, **kw):
+        """ Remove provided UIDs from current array"""
         return np.setdiff1d(self, other, assume_unique=True, **kw).view(self.__class__)
     
     def intersect(self, other, **kw):
+        """ Keep only UIDs that match other array """
         return np.intersect1d(self, other, assume_unique=True, **kw).view(self.__class__)
     
+    def to_numpy(self):
+        """ Convert to a standard NumPy array """
+        return np.array(self)
+    
 
 class BooleanOperationError(NotImplementedError):
     """ Raised when a logical operation is performed on a non-logical array """
diff --git a/starsim/utils.py b/starsim/utils.py
index ed3be7db..3ec4cd92 100644
--- a/starsim/utils.py
+++ b/starsim/utils.py
@@ -12,7 +12,7 @@
 # %% Helper functions
 
 # What functions are externally visible -- note, this gets populated in each section below
-__all__ = ['ndict', 'dictmerge', 'dictmergeleft', 'warn', 'unique', 'find_contacts', 'get_subclasses', 'all_subclasses']
+__all__ = ['ndict', 'warn', 'unique', 'find_contacts']
 
 
 class ndict(sc.objdict):
@@ -40,6 +40,10 @@ def __init__(self, *args, nameattr='name', type=None, strict=True, overwrite=Fal
         self.setattribute('_overwrite', overwrite)
         self.extend(*args, **kwargs)
         return
+    
+    def __call__(self):
+        """ Shortcut for returning values """
+        return self.values()
 
     def append(self, arg, key=None, overwrite=None):
         valid = False
@@ -125,25 +129,6 @@ def __iadd__(self, dict2):
         return self
 
 
-def dictmerge(*args, **kwargs):
-    """ Merge things into an objdict, using standard order """
-    return sc.objdict(sc.mergedicts(*args, **kwargs))
-
-
-def dictmergeleft(*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.dictmerge()'
-        raise ValueError(errormsg)
-    return sc.objdict(sc.mergedicts(default, new))
-
-
 def warn(msg, category=None, verbose=None, die=None):
     """ Helper function to handle warnings -- shortcut to warnings.warn """
 
@@ -204,16 +189,6 @@ def find_contacts(p1, p2, inds):  # pragma: no cover
     return pairing_partners
 
 
-def get_subclasses(cls):
-    for subclass in cls.__subclasses__():
-        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
 
 __all__ += ['set_seed']
@@ -312,11 +287,11 @@ def standardize_data(data=None, metadata=None, max_age=120, min_year=1800):
             new_data[sim_name] = sc.tolist(data[col_name])
         df = sc.dataframe(new_data)
 
-    elif sc.isnumber(data):
+    elif sc.isnumber(data) or isinstance(data, ss.Dist):
         df = data  # Just return it as-is
 
     else:
         errormsg = f'Data type {type(data)} not understood.'
         raise ValueError(errormsg)
-
+        
     return df
diff --git a/starsim/version.py b/starsim/version.py
index 2878d0f2..37996c63 100644
--- a/starsim/version.py
+++ b/starsim/version.py
@@ -4,6 +4,6 @@
 
 __all__ = ['__version__', '__versiondate__', '__license__']
 
-__version__ = '0.4.0'
-__versiondate__ = '2024-04-24'
+__version__ = '0.5.0'
+__versiondate__ = '2024-05-07'
 __license__ = f'Starsim {__version__} ({__versiondate__}) — © 2023-2024 by IDM'


Test patch:
diff --git a/tests/baseline.json b/tests/baseline.json
index 3df135e1..04479f95 100644
--- a/tests/baseline.json
+++ b/tests/baseline.json
@@ -1,17 +1,18 @@
 {
   "summary": {
-    "n_alive": 9647.450980392157,
-    "new_deaths": 7.490196078431373,
-    "cum_deaths": 755.0,
+    "yearvec": 2010.1000000000026,
     "pregnancy_pregnancies": 0.0,
     "pregnancy_births": 0.0,
     "pregnancy_cbr": 0.0,
+    "hiv_n_susceptible": 8867.64705882353,
+    "hiv_n_infected": 794.6078431372549,
     "hiv_n_on_art": 0.0,
-    "hiv_n_susceptible": 8785.009803921568,
-    "hiv_n_infected": 862.4411764705883,
-    "hiv_prevalence": 0.08951620561257022,
-    "hiv_new_infections": 16.705882352941178,
-    "hiv_cum_infections": 1704.0,
-    "hiv_new_deaths": 7.490196078431373
+    "hiv_prevalence": 0.0823032321868553,
+    "hiv_new_infections": 15.323529411764707,
+    "hiv_cum_infections": 1563.0,
+    "hiv_new_deaths": 6.931372549019608,
+    "n_alive": 9662.254901960785,
+    "new_deaths": 6.931372549019608,
+    "cum_deaths": 692.0
   }
 }
\ No newline at end of file
diff --git a/tests/benchmark.json b/tests/benchmark.json
index 2c18591e..8078aac1 100644
--- a/tests/benchmark.json
+++ b/tests/benchmark.json
@@ -1,13 +1,13 @@
 {
   "time": {
-    "people": 0.001,
-    "initialize": 0.023,
-    "run": 0.355
+    "people": 0.002,
+    "initialize": 0.014,
+    "run": 0.358
   },
   "parameters": {
     "n_agents": 10000,
     "n_years": 20,
     "dt": 0.2
   },
-  "cpu_performance": 0.974601487661388
+  "cpu_performance": 0.8271107100782993
 }
\ No newline at end of file
diff --git a/tests/devtests/devtest_connectors.py b/tests/devtests/devtest_connectors.py
deleted file mode 100644
index 2bff9794..00000000
--- a/tests/devtests/devtest_connectors.py
+++ /dev/null
@@ -1,34 +0,0 @@
-"""
-Experiment with connectors
-"""
-
-# %% Imports and settings
-import starsim as ss
-import matplotlib.pyplot as plt
-
-ng = ss.Gonorrhea()
-ng.pars['beta'] = {'mf': [0.05, 0.025]}
-ng.pars['init_prev'] = 0.025
-
-ppl1 = ss.People(10000)
-sim_nohiv = ss.Sim(people=ppl1, networks=ss.MFNet(), diseases=ng)
-sim_nohiv.run()
-
-hiv = ss.HIV()
-hiv.pars['beta'] = {'mf': [0.0008, 0.0004]}
-hiv.pars['init_prev'] = 0.05
-ng = ss.Gonorrhea()
-ng.pars['beta'] = {'mf': [0.05, 0.025]}
-ng.pars['init_prev'] = 0.025
-ppl2 = ss.People(10000)
-sim_hiv = ss.Sim(people=ppl2, networks=ss.MFNet(), diseases=[hiv, ng], connectors=ss.simple_hiv_ng())
-sim_hiv.run()
-
-plt.figure()
-plt.plot(sim_hiv.yearvec, sim_hiv.results.gonorrhea.n_infected, label='With HIV')
-plt.plot(sim_nohiv.yearvec, sim_nohiv.results.gonorrhea.n_infected, label='Without HIV')
-plt.title('Gonorrhea infections')
-plt.xlabel('Year')
-plt.ylabel('Count')
-plt.legend()
-plt.show()
\ No newline at end of file
diff --git a/tests/test_baselines.py b/tests/test_baselines.py
index 3ad89e3e..66796a57 100644
--- a/tests/test_baselines.py
+++ b/tests/test_baselines.py
@@ -30,7 +30,7 @@ def make_people():
     return ppl
 
 
-def make_sim(ppl=None, do_plot=False, **kwargs):
+def make_sim(ppl=None, do_run=False, **kwargs):
     '''
     Define a default simulation for testing the baseline, including
     interventions to increase coverage. If run directly (not via pytest), also
@@ -45,9 +45,9 @@ def make_sim(ppl=None, do_plot=False, **kwargs):
     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:
+    
+    # Optionally run and plot
+    if do_run:
         sim.run()
         sim.plot()
 
@@ -209,7 +209,7 @@ def normalize_performance():
 
     json = test_benchmark(do_save=do_save, repeats=5) # Run this first so benchmarking is available even if results are different
     new  = test_baseline()
-    sim = make_sim(do_plot=do_plot)
+    sim = make_sim(do_run=do_plot)
 
     print('\n'*2)
     sc.toc(T)
diff --git a/tests/test_dcp.py b/tests/test_dcp.py
deleted file mode 100644
index 84e93f72..00000000
--- a/tests/test_dcp.py
+++ /dev/null
@@ -1,50 +0,0 @@
-import starsim as ss
-import sciris as sc
-import numpy as np
-import matplotlib.pyplot as plt
-
-sc.options(interactive=False) # Assume not running interactively
-
-n_agents = 250
-
-
-def test_dcp():
-    s1 = ss.Sim(pars=dict(diseases='sir', networks='embedding'), n_agents=n_agents)
-    s1.initialize()
-    
-    s2 = sc.dcp(s1)
-
-    s1.run()
-    s2.run()
-    s1.plot()
-    s2.plot()
-    
-    ss.diff_sims(s1, s2, full=True)
-    assert np.allclose(s1.summary[:], s2.summary[:], rtol=0, atol=0, equal_nan=True)
-
-    return s1, s2
-
-
-def test_dcp_until():
-    s1 = ss.Sim(pars=dict(diseases='sir', networks='embedding'), n_agents=n_agents)
-    s1.initialize()
-
-    s1.run(until=5)
-
-    s2 = sc.dcp(s1)
-
-    s1.run()
-    s2.run()
-    s1.plot()
-    s2.plot()
-
-    assert np.allclose(s1.summary[:], s2.summary[:], rtol=0, atol=0, equal_nan=True)
-
-    return s1, s2
-
-
-if __name__ == '__main__':
-    sc.options(interactive=True)
-    s1 = test_dcp()
-    s1 = test_dcp_until()
-    plt.show()
\ No newline at end of file
diff --git a/tests/test_demographics.py b/tests/test_demographics.py
index 8af28891..bdfdd3d5 100644
--- a/tests/test_demographics.py
+++ b/tests/test_demographics.py
@@ -11,15 +11,15 @@
 import pytest
 
 
-do_plot = True
 sc.options(interactive=False) # Assume not running interactively
 
 
-def test_nigeria(which='births', dt=1, start=1995, n_years=15, plot_init=False, do_plot=True):
+def test_nigeria(which='births', dt=1, start=1995, n_years=15, do_plot=False):
     """
     Make a Nigeria sim with demographic modules
     Switch between which='births' or 'pregnancy' to determine which demographic module to use
     """
+    sc.heading('Testing Nigeria demographics')
 
     # Make demographic modules
     demographics = sc.autolist()
@@ -53,7 +53,7 @@ def test_nigeria(which='births', dt=1, start=1995, n_years=15, plot_init=False,
         demographics=demographics,
     )
 
-    if plot_init:
+    if do_plot:
         sim.initialize()
         # Plot histograms of the age distributions - simulated vs data
         bins = np.arange(0, 101, 1)
@@ -123,8 +123,9 @@ def test_nigeria(which='births', dt=1, start=1995, n_years=15, plot_init=False,
     return sim
 
 
-def test_constant_pop():
-    # Test pars for constant pop size
+def test_constant_pop(do_plot=False):
+    """ Test pars for constant pop size """
+    sc.heading('Testing constant population size')
     sim = ss.Sim(n_agents=10e3, birth_rate=10, death_rate=10/1010*1000, n_years=200, rand_seed=1).run()
     print("Check final pop size within 5% of starting pop")
     assert np.isclose(sim.results.n_alive[0], sim.results.n_alive[-1], rtol=0.05)
@@ -137,6 +138,8 @@ def test_constant_pop():
 
 
 def test_module_adding():
+    """ Test that modules can't be added twice """
+    sc.heading('Testing module duplication')
     births = ss.Births(pars={'birth_rate': 10})
     deaths = ss.Deaths(pars={'death_rate': 10})
     demographics = [births, deaths]
@@ -145,9 +148,46 @@ def test_module_adding():
     return demographics
 
 
+def test_aging():
+    """ Test that aging is configured properly """
+    sc.heading('Testing aging')
+    n_agents = int(1e3)
+    
+    # With no demograhpics, people shouldn't age
+    s1 = ss.Sim(n_agents=n_agents).initialize()
+    orig_ages = s1.people.age.raw.copy()
+    orig_age = orig_ages.mean()
+    s1.run()
+    end_age = s1.people.age.mean()
+    assert orig_age == end_age, f'By default there should be no aging, but {orig_age} != {end_age}'
+    
+    # We should be able to manually turn on aging
+    s2 = ss.Sim(n_agents=n_agents, use_aging=True).run()
+    age2 = s2.people.age.mean()
+    assert orig_age < age2, f'With aging, start age {orig_age} should be less than end age {age2}'
+    
+    # Aging should turn on automatically if we add demographics
+    s3 = ss.Sim(n_agents=n_agents, demographics=True).run()
+    agent = s3.people.auids[0] # Find first alive agent
+    orig_agent_age = orig_ages[agent]
+    age3 = s3.people.age[ss.uids(agent)]
+    assert orig_agent_age < age3, f'With demographics, original agent age {orig_agent_age} should be less than end age {age3}'
+    
+    # ...but can be turned off manually
+    s4 = ss.Sim(n_agents=n_agents, demographics=True, use_aging=False).run()
+    agent = s4.people.auids[0] # Find first alive agent
+    orig_agent_age = orig_ages[agent]
+    age4 = s4.people.age[ss.uids(agent)]
+    assert orig_agent_age == age4, f'With aging turned off, original agent age {orig_agent_age} should match end age {age4}'
+
+    return s3 # Most interesting sim
+
+
 if __name__ == '__main__':
+    do_plot = True
     sc.options(interactive=do_plot)
-    s1 = test_nigeria(dt=1, which='pregnancy', n_years=15, plot_init=True, do_plot=do_plot)
-    s2 = test_constant_pop()
+    s1 = test_nigeria(do_plot=do_plot)
+    s2 = test_constant_pop(do_plot=do_plot)
     s3 = test_module_adding()
+    s4 = test_aging()
     plt.show()
diff --git a/tests/test_diseases.py b/tests/test_diseases.py
index dc8bf92c..7b93ae99 100644
--- a/tests/test_diseases.py
+++ b/tests/test_diseases.py
@@ -9,7 +9,7 @@
 
 test_run = True
 n_agents = [10_000, 2_000][test_run]
-do_plot = True
+do_plot = False
 sc.options(interactive=False) # Assume not running interactively
 
 
@@ -39,16 +39,17 @@ def test_sir():
 
     # 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 # TODO: fix
     # assert df.source.isna().sum() == sir.pars.initial # Check seed infections in line list
 
     plt.figure()
+    res = sim.results
     plt.stackplot(
         sim.yearvec,
-        sir.results.n_susceptible,
-        sir.results.n_infected,
-        sir.results.n_recovered,
-        sim.results.new_deaths.cumsum(),
+        res.sir.n_susceptible,
+        res.sir.n_infected,
+        res.sir.n_recovered,
+        res.new_deaths.cumsum(),
     )
     plt.legend(['Susceptible', 'Infected', 'Recovered', 'Dead'])
     plt.xlabel('Year')
@@ -56,10 +57,74 @@ def test_sir():
     return sim
 
 
+def test_sir_epi():
+    sc.heading('Test basic epi dynamics')
+    
+    base_pars = dict(n_agents=n_agents, networks=dict(type='random'), diseases=dict(type='sir'))
+
+    # Define the parameters to vary
+    par_effects = dict(
+        beta = [0.01, 0.99],
+        n_contacts = [1, 20],
+        init_prev = [0.1, 0.9],
+        dur_inf = [1, 8],
+        p_death = [.01, .1],
+    )
+
+    # 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 = sc.dcp(base_pars)
+        pars1 = sc.dcp(base_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
+        if par == 'p_death':
+            v0 = s0.results.cum_deaths[-1]
+            v1 = s1.results.cum_deaths[-1]
+        else:
+            ind = 1 if par == 'init_prev' else -1
+            v0 = s0.results.sir.cum_infections[ind]
+            v1 = s1.results.sir.cum_infections[ind]
+
+        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_sis(do_plot=do_plot):
+    pars = dict(
+        n_agents = n_agents,
+        diseases = 'sis',
+        networks = 'random'
+    )
+    sim = ss.Sim(pars)
+    sim.run()
+    if do_plot:
+        sim.plot()
+        sim.diseases.sis.plot()
+    return sim
+
+
 def test_ncd():
     ppl = ss.People(n_agents)
     ncd = ss.NCD()
-    sim = ss.Sim(people=ppl, diseases=ncd)
+    sim = ss.Sim(people=ppl, diseases=ncd, copy_inputs=False) # Since using ncd directly below
     sim.run()
 
     assert len(ncd.log.out_edges) == ncd.log.number_of_edges()
@@ -110,7 +175,9 @@ def test_multidisease():
 
 if __name__ == '__main__':
     sc.options(interactive=do_plot)
-    sim1 = test_sir()
-    sim2 = test_ncd()
-    sims = test_gavi()
-    sim = test_multidisease()
+    sir   = test_sir()
+    s1,s2 = test_sir_epi()
+    sis   = test_sis()
+    ncd   = test_ncd()
+    gavi  = test_gavi()
+    multi = test_multidisease()
diff --git a/tests/test_dist.py b/tests/test_dist.py
index f0e2f049..c9a5d5ab 100644
--- a/tests/test_dist.py
+++ b/tests/test_dist.py
@@ -27,11 +27,17 @@ def plot_rvs(rvs, times=None, nrows=None):
     return fig
 
 
+def make_sim():
+    """ Make a tiny sim for initializing the distributions """
+    sim = ss.Sim(n_agents=100).initialize() # Need an empty sim to initialize properly
+    return sim
+
+
 # %% Define the tests
 def test_dist(m=m):
     """ Test the Dist class """
     sc.heading('Testing the basic Dist call')
-    dist = ss.Dist(distname='random', name='test')
+    dist = ss.Dist(distname='random', name='test', strict=False)
     dist.initialize()
     rvs = dist(m)
     print(rvs)
@@ -43,13 +49,13 @@ def test_custom_dists(n=n, do_plot=False):
     """ Test all custom dists """
     sc.heading('Testing all custom distributions')
     
-    o = sc.objdict()
+    o     = sc.objdict()
     dists = sc.objdict()
-    rvs = sc.objdict()
+    rvs   = sc.objdict()
     times = sc.objdict()
     for name in ss.dist_list:
         func = getattr(ss, name)
-        dist = func(name='test')
+        dist = func(name='test', strict=False)
         dist.initialize()
         dists[name] = dist
         sc.tic()
@@ -79,7 +85,7 @@ def test_dists(n=n, do_plot=False):
         obj.a = sc.objdict()
         obj.a.mylist = [ss.random(), ss.Dist(distname='uniform', low=2, high=3)]
         obj.b = dict(d3=ss.weibull(c=2), d4=ss.delta(v=0.3))
-        dists = ss.Dists(obj)
+        dists = ss.Dists(obj).initialize(sim=make_sim())
         
         # Call each distribution twice
         for j in range(2):
@@ -108,8 +114,8 @@ def test_scipy(m=m):
     sc.heading('Testing SciPy distributions')
     
     # Make SciPy distributions in two different ways
-    dist1 = ss.Dist(dist=sps.expon, name='scipy', scale=2).initialize() # Version 1: callable
-    dist2 = ss.Dist(dist=sps.expon(scale=2), name='scipy').initialize() # Version 2: frozen
+    dist1 = ss.Dist(dist=sps.expon, name='scipy', scale=2, strict=False).initialize() # Version 1: callable
+    dist2 = ss.Dist(dist=sps.expon(scale=2), name='scipy', strict=False).initialize() # Version 2: frozen
     rvs1 = dist1(m)
     rvs2 = dist2(m)
     
@@ -130,13 +136,14 @@ def test_exceptions(m=m):
         dist(m) # Check that we can't call an uninitialized
     
     # Initialize and check we can't call repeatedly
-    dist.initialize()
+    dist.initialize(trace='test', sim=make_sim())
     rvs = dist(m)
     with pytest.raises(ss.distributions.DistNotReadyError):
         dist(m) # Check that we can't call an already-used distribution
     
     # Check that we can with a non-strict Dist
     dist2 = ss.random(strict=False)
+    dist2.initialize(trace='test')
     rvs2 = sc.autolist()
     for i in range(2):
         rvs2 += dist2(m) # We should be able to call multiple times with no problem
@@ -154,7 +161,7 @@ def test_reset(m=m):
     sc.heading('Testing reset')
     
     # Create and draw two sets of random numbers
-    dist = ss.random(seed=533).initialize()
+    dist = ss.random(seed=533, strict=False).initialize()
     r1 = dist.rvs(m)
     r2 = dist.rvs(m)
     assert all(r1 != r2)
@@ -193,7 +200,7 @@ def custom_loc(module, sim, uids):
         return out
     
     scale = 1
-    d = ss.normal(loc=custom_loc, scale=scale).initialize(sim=sim)
+    d = ss.normal(name='callable', loc=custom_loc, scale=scale).initialize(sim=sim)
 
     uids = np.array([1, 3, 7, 9])
     draws = d.rvs(uids)
@@ -213,7 +220,7 @@ def test_array(n=n):
     low  = np.array([1, 100]) # Low
     high = np.array([3, 125]) # High
 
-    d = ss.uniform(low=low, high=high).initialize(slots=np.arange(uids.max()+1))
+    d = ss.uniform(low=low, high=high, strict=False).initialize(slots=np.arange(uids.max()+1))
     draws = d.rvs(uids)
     print(f'Uniform sample for uids {uids} returned {draws}')
 
@@ -235,7 +242,7 @@ def test_repeat_slot():
     high = low + 1
 
     # Draw values
-    d = ss.uniform(low=low, high=high).initialize(slots=slots)
+    d = ss.uniform(low=low, high=high, strict=False).initialize(slots=slots)
     draws = d.rvs(uids)
     
     # Print and test
diff --git a/tests/test_base.py b/tests/test_other.py
similarity index 75%
rename from tests/test_base.py
rename to tests/test_other.py
index 67b3467b..a9f0cbe5 100644
--- a/tests/test_base.py
+++ b/tests/test_other.py
@@ -10,13 +10,16 @@
 
 sc.options(interactive=False) # Assume not running interactively
 
+small = 100
+medium = 1000
+
 # %% Define the tests
 
 def test_people():
     sc.heading('Testing people object')
 
     # Base people contains only the states defined in base.base_states
-    ppl = ss.People(100)  # BasePeople
+    ppl = ss.People(small)  # BasePeople
     del ppl
 
     # Possible to initialize people with extra states, e.g. a geolocation
@@ -26,7 +29,7 @@ def geo_func(n):
     extra_states = [
         ss.FloatArr('geolocation', default=geo_func),
     ]
-    ppl = ss.People(100, extra_states=extra_states)
+    ppl = ss.People(small, extra_states=extra_states)
 
     # Possible to add a module to people outside a sim (not typical workflow)
     ppl.add_module(ss.HIV())
@@ -39,21 +42,20 @@ def test_networks():
 
     # Make completely abstract layers
     n_edges = 10_000
-    n_people = 1000
+    n_people = medium
     p1 = np.random.randint(n_people, size=n_edges)
     p2 = np.random.randint(n_people, size=n_edges)
     beta = np.ones(n_edges)
     nw1 = ss.Network(p1=p1, p2=p2, beta=beta, label='rand')
-    nw2 = ss.Network(dict(p1=p1, p2=p2, beta=beta), label='rand')  # Alternate method
 
     sim = ss.Sim()
     sim.initialize()
     
-    nw3 = ss.MaternalNet()
-    nw3.initialize(sim)
-    nw3.add_pairs(mother_inds=[1, 2, 3], unborn_inds=[100, 101, 102], dur=[1, 1, 1])
+    nw2 = ss.MaternalNet()
+    nw2.initialize(sim)
+    nw2.add_pairs(mother_inds=[1, 2, 3], unborn_inds=[100, 101, 102], dur=[1, 1, 1])
 
-    return nw1, nw2, nw3
+    return nw1, nw2
 
 
 def test_microsim(do_plot=False):
@@ -67,7 +69,7 @@ def test_microsim(do_plot=False):
     hiv.pars['beta'] = {'mf': [0.15, 0.10], 'maternal': [0.2, 0]}
 
     sim = ss.Sim(
-        people=ss.People(100),
+        people=ss.People(small),
         networks=[ss.MFNet(), ss.MaternalNet()],
         demographics=ss.Pregnancy(),
         diseases=hiv
@@ -95,8 +97,8 @@ def init_debut(module, sim, uids):
     mf_pars = {
         'debut': ss.normal(loc=init_debut, scale=2),  # Age of debut can vary by using callable parameter values
     }
-    sim_pars = {'networks': [ss.MFNet(mf_pars)], 'n_agents': 100}
-    gon_pars = {'beta': {'mf': [0.08, 0.04]}, 'p_death': 0.2}
+    sim_pars = {'networks': [ss.MFNet(mf_pars)], 'n_agents': small}
+    gon_pars = {'beta': {'mf': [0.08, 0.04]}}
     gon = ss.Gonorrhea(pars=gon_pars)
 
     sim = ss.Sim(pars=sim_pars, diseases=[gon])
@@ -114,7 +116,7 @@ def test_arrs():
     o = sc.objdict()
     
     # Create a sim with only births
-    pars = dict(n_agents=1000, diseases='sis', networks='random')
+    pars = dict(n_agents=medium, diseases='sis', networks='random')
     p1 = sc.mergedicts(pars, birth_rate=10)
     p2 = sc.mergedicts(pars, death_rate=10)
     s1 = ss.Sim(pars=p1).run()
@@ -135,6 +137,41 @@ def test_arrs():
     return o
 
 
+def test_deepcopy():
+    s1 = ss.Sim(pars=dict(diseases='sir', networks='embedding'), n_agents=small)
+    s1.initialize()
+    
+    s2 = sc.dcp(s1)
+
+    s1.run()
+    s2.run()
+    s1.plot()
+    s2.plot()
+    
+    ss.diff_sims(s1, s2, full=True)
+    assert np.allclose(s1.summary[:], s2.summary[:], rtol=0, atol=0, equal_nan=True)
+
+    return s1, s2
+
+
+def test_deepcopy_until():
+    s1 = ss.Sim(pars=dict(diseases='sir', networks='embedding'), n_agents=small)
+    s1.initialize()
+
+    s1.run(until=5)
+
+    s2 = sc.dcp(s1)
+
+    s1.run()
+    s2.run()
+    s1.plot()
+    s2.plot()
+
+    assert np.allclose(s1.summary[:], s2.summary[:], rtol=0, atol=0, equal_nan=True)
+
+    return s1, s2
+
+
 
 # %% Run as a script
 if __name__ == '__main__':
@@ -146,10 +183,12 @@ def test_arrs():
 
     # Run tests
     ppl = test_people()
-    nw1, nw2, nw3 = test_networks()
+    nw1, nw2 = test_networks()
     sim1 = test_microsim(do_plot)
     sim2 = test_ppl_construction()
     sims = test_arrs()
+    sims2 = test_deepcopy()
+    sims3 = test_deepcopy_until()
 
     sc.toc(T)
     pl.show()
diff --git a/tests/test_plugins.py b/tests/test_plugins.py
new file mode 100644
index 00000000..fc94e5d4
--- /dev/null
+++ b/tests/test_plugins.py
@@ -0,0 +1,157 @@
+"""
+Test connectors and custom interventions
+"""
+
+import sciris as sc
+import numpy as np
+import pylab as pl
+import starsim as ss
+
+sc.options(interactive=False) # Assume not running interactively
+
+
+class hiv_syph(ss.Connector):
+    """ Simple connector whereby rel_sus to NG doubles if CD4 count is <200"""
+    def __init__(self, pars=None, **kwargs):
+        super().__init__(label='HIV-Syphilis', requires=[ss.HIV, ss.Syphilis])
+        self.default_pars(
+            rel_sus_syph_hiv    = 2,   # People with HIV are 2x more likely to acquire syphilis
+            rel_sus_syph_aids   = 5,   # People with AIDS are 5x more likely to acquire syphilis
+            rel_trans_syph_hiv  = 1.5, # People with HIV are 1.5x more likely to transmit syphilis
+            rel_trans_syph_aids = 3,   # People with AIDS are 3x more likely to transmit syphilis
+            rel_sus_hiv_syph    = 2.7, # People with syphilis are 2.7x more likely to acquire HIV
+            rel_trans_hiv_syph  = 2.7, # People with syphilis are 2.7x more likely to transmit HIV
+        )
+        self.update_pars(pars, **kwargs)
+        return
+
+    def update(self):
+        """ Specify HIV-syphilis interactions """
+        diseases = self.sim.diseases
+        syph = diseases.syphilis
+        hiv = diseases.hiv
+        cd4 = self.sim.people.hiv.cd4
+        
+        # People with HIV are more likely to acquire syphilis
+        syph.rel_sus[cd4 < 500] = self.pars.rel_sus_syph_hiv
+        syph.rel_sus[cd4 < 200] = self.pars.rel_sus_syph_aids
+
+        # People with HIV are more likely to transmit syphilis
+        syph.rel_trans[cd4 < 500] = self.pars.rel_trans_syph_hiv
+        syph.rel_trans[cd4 < 200] = self.pars.rel_trans_syph_aids
+
+        # People with syphilis are more likely to acquire HIV
+        hiv.rel_sus[syph.active] = self.pars.rel_sus_hiv_syph
+
+        # People with syphilis are more likely to transmit HIV
+        hiv.rel_trans[syph.active] = self.pars.rel_trans_hiv_syph
+        return
+
+
+class Penicillin(ss.Intervention):
+    """ Create a penicillin (BPG) intervention for treating syphilis """
+    def __init__(self, year=2020, prob=0.5):
+        super().__init__() # Initialize the intervention
+        self.prob = prob # Store the probability of treatment
+        self.year = year
+        return
+
+    def apply(self, sim):
+        if sim.year > self.year:
+            syphilis = sim.diseases.syphilis
+
+            # Define who is eligible for treatment
+            eligible_ids = syphilis.infected.uids  # People are eligible for treatment if they have just started exhibiting symptoms
+            n_eligible = len(eligible_ids) # Number of people who are eligible
+
+            # Define who receives treatment
+            is_treated = np.random.rand(n_eligible) < self.prob  # Define which of the n_eligible people get treated by comparing np.random.rand() to self.p
+            treat_ids = eligible_ids[is_treated]  # Pull out the IDs for the people receiving the treatment
+            syphilis.infected[treat_ids] = False
+            syphilis.susceptible[treat_ids] = True
+            sim.diseases.hiv.rel_sus[treat_ids] = 1
+            sim.diseases.hiv.rel_trans[treat_ids] = 1
+        return
+
+
+def make_args():
+    """ Make people, HIV, syphilis, and network """
+    pars = dict(n_agents=1000, verbose=0)
+    mf = ss.MFNet(duration=ss.lognorm_ex(mean=5, stdev=0.5))
+    hiv = ss.HIV(beta={'mf': [0.0008, 0.0004]}, init_prev=0.2)
+    syph = ss.Syphilis(beta={'mf': [0.1, 0.05]}, init_prev=0.05)
+    args = dict(pars=pars, networks=mf, diseases=[hiv, syph])
+    return args
+
+
+def test_connectors(do_plot=False):
+    """ Test connector example """
+    sc.heading('Testing connectors')
+    
+    # Make arguments
+    args = make_args()
+    sims = sc.objdict() # List of sims
+    
+    # Make a sim with a connector, and run
+    sims.con = ss.Sim(label='With connector', connectors=hiv_syph(), **args)
+    sims.con.run()
+    
+    # Make a sim without a connector, and run
+    sims.nocon = ss.Sim(label='Without connector', **args)
+    sims.nocon.run()
+    
+    # Make a sim with a connector and syph treatment, and run
+    sims.treat = ss.Sim(label='With treatment', connectors=hiv_syph(), interventions=Penicillin(), **args)
+    sims.treat.run()
+    
+    # Parse results
+    results = sc.odict()
+    diseases = ['syphilis', 'hiv']
+    for sim in sims.values():
+        results[sim.label] = sc.objdict()
+        for disease in diseases:
+            results[sim.label][disease] = sim.results[disease].n_infected
+
+    # Plot
+    if do_plot:
+        pl.figure()
+        
+        pl.subplot(2,1,1)
+        x = sims.con.yearvec
+        for label,res in results.items():
+            pl.plot(x, res.syphilis, label=label)
+        pl.title('Syphilis infections')
+        pl.xlabel('Year')
+        pl.ylabel('Count')
+        pl.axvline(2020)
+        pl.legend()
+        
+        pl.subplot(2,1,2)
+        for label,res in results.items():
+            pl.plot(x, res.hiv, label=label)
+        pl.title('HIV infections')
+        pl.xlabel('Year')
+        pl.ylabel('Count')
+        pl.axvline(2020)
+        pl.legend()
+        
+        sc.figlayout()
+        pl.show()
+    
+    # Check results
+    for disease in diseases:
+        assert results[0][disease].sum() > results[1][disease].sum(), f'{disease.title()} infections should be higher with connector'
+        assert results[0][disease].sum() > results[2][disease].sum(), f'{disease.title()} infections should be lower with treatment'
+   
+    return sims
+
+
+if __name__ == '__main__':
+    do_plot = True
+    sc.options(interactive=do_plot)
+    T = sc.timer()
+    
+    sims = test_connectors(do_plot=do_plot)
+    
+    T.toc()
+    
\ No newline at end of file
diff --git a/tests/test_dists.py b/tests/test_randomness.py
similarity index 62%
rename from tests/test_dists.py
rename to tests/test_randomness.py
index 10599299..38f9b465 100644
--- a/tests/test_dists.py
+++ b/tests/test_randomness.py
@@ -17,9 +17,10 @@ def make_dist(name='test', **kwargs):
 
 def make_dists(**kwargs):
     """ Make a Dists object with two distributions in it """
+    sim = ss.Sim(n_agents=100).initialize() # Need an empty sim to initialize properly
     distlist = [make_dist(), make_dist()]
     dists = ss.Dists(distlist)
-    dists.initialize()
+    dists.initialize(sim=sim)
     return dists
 
 
@@ -122,19 +123,21 @@ def apply(self, sim):
 
 class OneMore(ss.Intervention):
     """ Add one additional agent and infection """
-    def apply(self, sim):
-        if sim.ti == 0:
-            # Create an extra agent
-            preg = ss.Pregnancy(rel_fertility=0) # Ensure no default births
-            preg.initialize(sim)
-            new_uids = ss.uids([len(sim.people)]) # Hack since make_embryos doesn't return UIDs
-            preg.make_embryos(sim, np.array([0])) # Assign 0th agent to be the "mother"
-            assert len(new_uids) == 1
+    def initialize(self, sim):
+        one_birth = ss.Pregnancy(name='one_birth', rel_fertility=0) # Ensure no default births
+        one_birth.initialize(sim)
+        self.one_birth = one_birth
+        return
+    
+    def apply(self, sim, ti=10):
+        """ Create an extra agent """
+        if sim.ti == ti:
+            new_uids = self.one_birth.make_embryos(ss.uids(0)) # Assign 0th agent to be the "mother"
             sim.people.age[new_uids] = -100 # Set to a very low number to never reach debut age
             
-            # Infect that agent
+            # Infect that agent and immediately recover
             sir = sim.diseases.sir
-            sir.set_prognoses(sim, new_uids)
+            sir.set_prognoses(new_uids)
             sir.ti_recovered[new_uids] = sim.ti + 1 # Reset recovery time to next timestep
             
             # Reset the random states
@@ -170,6 +173,8 @@ def plot_infs(s1, s2):
 
 
 def test_worlds(do_plot=False):
+    """ Test that one extra birth leads to one extra infection """
+    sc.heading('Testing worlds...')
     
     res = sc.objdict()
     
@@ -206,13 +211,81 @@ def test_worlds(do_plot=False):
         plot_infs(s1, s2)
         pl.show()
     
-    assert len(s2.people) == len(s1.people) + 1
-    assert sum2.sir_cum_infections == sum1.sir_cum_infections + 1
-    assert (s1.interventions.countinf.arr != s2.interventions.countinf.arr).sum() == 0
+    l1 = len(s1.people)
+    l2 = len(s2.people)
+    i1 = sum1.sir_cum_infections
+    i2 = sum2.sir_cum_infections
+    a1 = s1.interventions.countinf.arr
+    a2 = s2.interventions.countinf.arr
+    assert l2 == l1 + 1, f'Expected one more person in s2 ({l2}) than s1 ({l1})'
+    assert i2 == i1 + 1, f'Expected one more infection in s2 ({i2}) than s1 ({i1})'
+    assert (a1 != a2).sum() == 0, f'Expected infection arrays to match:\n{a1}\n{a2}'
         
     return res
 
 
+def test_independence(do_plot=False, thresh=0.1):
+    """ Test that when variables are created, they are uncorrelated """
+    sc.heading('Testing independence...')
+    
+    # Create the sim and initialize (do not run)
+    sim = ss.Sim(
+        n_agents = 1000,
+        diseases = [
+            dict(type='sir', init_prev=0.1),
+            dict(type='sis', init_prev=0.1),
+            dict(type='hiv', init_prev=0.1),
+        ],
+        networks = [
+            dict(type='random', n_contacts=ss.poisson(8)),
+            dict(type='mf', debut=ss.delta(0), participation=0.5), # To avoid age correlations
+        ]
+    )
+    sim.initialize()
+    
+    # Assemble measures
+    st = sim.people.states
+    arrs = sc.objdict()
+    arrs.age = st.age.values
+    arrs.sex = st.female.values
+    for key,disease in sim.diseases.items():
+        arrs[f'{key}_init'] = disease.infected.values
+    for key,network in sim.networks.items():
+        data = np.zeros(len(sim.people))
+        for p in ['p1', 'p2']:
+            for uid in network.contacts[p]:
+                data[uid] += 1 # Could also use a histogram
+        arrs[key] = data
+    
+    # Compute the correlations
+    n = len(arrs)
+    stats = np.zeros((n,n))
+    for i,arr1 in arrs.enumvals():
+        for j,arr2 in arrs.enumvals():
+            if i != j:
+                stats[i,j] = np.corrcoef(arr1, arr2)[0,1]
+                
+    # Optionally plot
+    if do_plot:
+        pl.figure()
+        pl.imshow(stats)
+        ticks = np.arange(n)
+        labels = arrs.keys()
+        pl.xticks(ticks, labels)
+        pl.yticks(ticks, labels)
+        pl.xticks(rotation=15)
+        pl.colorbar()
+        sc.figlayout()
+        pl.show()
+            
+    # Test that everything is independent
+    max_corr = abs(stats).max()
+    assert max_corr < thresh, f'The maximum correlation between variables was {max_corr}, above the threshold {thresh}'
+    
+    return sim
+    
+
+
 # %% Run as a script
 if __name__ == '__main__':
     T = sc.timer()
@@ -223,5 +296,7 @@ def test_worlds(do_plot=False):
     o3 = test_jump(n)
     o4 = test_order(n)
     o5 = test_worlds(do_plot=do_plot)
+    o6 = test_independence(do_plot=do_plot)
 
     T.toc()
+
diff --git a/tests/test_sim.py b/tests/test_sim.py
new file mode 100644
index 00000000..afe7bebe
--- /dev/null
+++ b/tests/test_sim.py
@@ -0,0 +1,283 @@
+"""
+Test simple APIs
+"""
+
+# %% Imports and settings
+import starsim as ss
+import sciris as sc
+import numpy as np
+import matplotlib.pyplot as plt
+import pytest
+
+n_agents = 1_000
+do_plot = False
+sc.options(interactive=False) # Assume not running interactively
+
+
+def make_sim_pars():
+    pars = sc.objdict(
+        n_agents = n_agents,
+        birth_rate = 20,
+        death_rate = 15,
+        networks = sc.objdict(
+            type = 'randomnet',
+            n_contacts = 4,
+        ),
+        diseases = sc.objdict(
+            type = 'sir',
+            dur_inf = 10,
+            beta = 0.1,
+        )
+    )
+    return pars
+
+
+def test_demo(do_plot=do_plot):
+    """ Test Starsim's demo run """
+    sc.heading('Testing demo...')
+    s1 = ss.demo(plot=do_plot)
+    
+    # Test explicit demo
+    s2 = ss.Sim(diseases='sir', networks='random').run()
+    
+    # Test explicit 
+    sir = ss.SIR()
+    net = ss.RandomNet()
+    s3 = ss.Sim(diseases=sir, networks=net).run()
+    assert ss.check_sims_match(s1, s2, s3), 'Sims should match'
+    return s1
+
+
+def test_default(do_plot=do_plot):
+    """ Create, run, and plot a sim with default settings """
+    sc.heading('Testing default...')
+    sim = ss.Sim(n_agents=n_agents).run()
+    if do_plot:
+        sim.plot()
+    return sim
+
+
+def test_simple(do_plot=do_plot):
+    """ Create, run, and plot a sim by passing a parameters dictionary """
+    sc.heading('Testing simple run...')
+    pars = make_sim_pars()
+    sim = ss.Sim(pars)
+    sim.run()
+    if do_plot:
+        sim.plot()
+    return sim
+
+
+def test_api():
+    """ Test all different ways of creating a sim """
+    sc.heading('Testing sim API...')
+    
+    # Check different ways of specifying a sim
+    s1 = ss.Sim(n_agents=n_agents, diseases='sir', networks='random').run() # Supply strings directly
+    s2 = ss.Sim(pars=dict(n_agents=n_agents, diseases='sir', networks='random')).run() # Supply as parameters
+    s3 = ss.Sim(n_agents=n_agents, diseases=ss.SIR(), networks=ss.RandomNet()).run() # Supply as objects
+    ss.check_sims_match(s1, s2, s3), 'Sims should match'
+    
+    # Check different ways of setting a distribution
+    kw = dict(n_agents=n_agents, networks='random')
+    d1 = ss.lognorm_ex(10) # Create a distribution with an argument
+    d2 = ss.lognorm_ex(mean=10, stdev=2) # Create a distribution with kwargs
+    d3 = ss.normal(loc=10) # Create a different type of distribution
+    
+    # Check specifying dist with a scalar
+    s4 = ss.Sim(diseases=dict(type='sir', dur_inf=10), **kw).run() # Supply values as a scalar
+    s5 = ss.Sim(diseases=dict(type='sir', dur_inf=d1), **kw).run() # Supply as a distribution
+    ss.check_sims_match(s4, s5), 'Sims should match'
+    
+    # Check specifying dist with a list and dict
+    s6 = ss.Sim(diseases=dict(type='sir', dur_inf=[10,2]), **kw).run() # Supply values as a list
+    s7 = ss.Sim(diseases=dict(type='sir', dur_inf=dict(mean=10, stdev=2)), **kw).run() # Supply values as a dict
+    s8 = ss.Sim(diseases=dict(type='sir', dur_inf=d2), **kw).run() # Supply as a distribution
+    ss.check_sims_match(s6, s7, s8), 'Sims should match'
+    
+    # Check changing dist type
+    s9  = ss.Sim(diseases=dict(type='sir', dur_inf=dict(type='normal', loc=10)), **kw).run() # Supply values as a dict
+    s10 = ss.Sim(diseases=dict(type='sir', dur_inf=d3), **kw).run() # Supply values as a distribution
+    ss.check_sims_match(s9, s10), 'Sims should match'
+    
+    # Check that Bernoulli distributions can't be changed
+    with pytest.raises(TypeError):
+        ss.Sim(diseases=dict(type='sir', init_prev=dict(type='normal', loc=10)), **kw).initialize()
+    
+    return s1
+
+
+def test_complex_api():
+    """ Test that complex inputs can be parsed correctly """
+    sc.heading('Testing complex API...')
+    
+    def jump_age(sim):
+        """ Arbitrary intervention to reduce people's ages in the simulation """
+        if sim.ti == 20:
+            sim.people.age[:] = sim.people.age[:] + 1000
+    
+    # Specify parameters as a dictionary
+    p = dict(
+        n_agents = 1000,
+        label = 'v1',
+        verbose = 'brief',
+        end = 2020,
+        networks = [
+            ss.RandomNet(name='random1', n_contacts=6),
+            dict(type='random', name='random2', n_contacts=4)
+        ],
+        diseases = [
+            dict(type='sir',  dur_inf=dict(type='expon', scale=6.0)),
+            dict(type='sis', beta=0.07, init_prev=0.1),
+        ],
+        demographics = [
+            ss.Births(birth_rate=20),
+            dict(type='deaths', death_rate=20)
+        ],
+        interventions = jump_age,
+    )
+    
+    # Test with explicit initialization
+    pars = ss.SimPars(n_agents=1000, label='v1', verbose='brief', end=2020)
+    
+    net1 = ss.RandomNet(name='random1', n_contacts=6)
+    net2 = ss.RandomNet(name='random2', n_contacts=4)
+    networks = ss.ndict(net1, net2)
+    
+    dis1 = ss.SIR(dur_inf=ss.expon(scale=6.0))
+    dis2 = ss.SIS(beta=0.07, init_prev=ss.bernoulli(0.1))
+    diseases = ss.ndict(dis1, dis2)
+    
+    dem1 = ss.Births(birth_rate=20)
+    dem2 = ss.Deaths(death_rate=20)
+    demographics = ss.ndict(dem1, dem2)
+    
+    int1 = ss.Intervention.from_func(jump_age)
+    interventions = ss.ndict(int1)
+
+    # Assemble
+    s1 = ss.Sim(p)
+    s2 = ss.Sim(pars=pars, networks=networks, diseases=diseases, demographics=demographics, interventions=interventions)
+    
+    # Run
+    s1.run()
+    s2.run()
+    
+    assert ss.check_sims_match(s1, s2), 'Sims should match'
+    
+    return s1
+
+
+def test_simple_vax(do_plot=do_plot):
+    """ Create and run a sim with vaccination """
+    sc.heading('Testing simple 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))
+    intv = ss.routine_vx(start_year=2015, prob=0.2, product=my_vax)
+    sim_intv = ss.Sim(pars=pars, interventions=intv)
+    sim_intv.run()
+    
+    assert sim_intv.summary.sir_cum_infections < sim_base.summary.sir_cum_infections, 'Vaccine should avert infections'
+
+    # Check plots
+    if do_plot:
+        plt.figure()
+        plt.plot(sim_base.yearvec, sim_base.results.sir.prevalence, label='Baseline')
+        plt.plot(sim_intv.yearvec, sim_intv.results.sir.prevalence, label='Vax')
+        plt.axvline(x=2015, color='k', ls='--')
+        plt.title('Prevalence')
+        plt.legend()
+
+    return sim_base, sim_intv
+
+
+def test_shared_product(do_plot=do_plot):
+    """ Check that multiple interventions can use the same product """
+    sc.heading('Testing sharing a product across interventions...')
+    
+    # Make interventions
+    vax = ss.sir_vaccine(pars=dict(efficacy=0.5))
+    routine1 = ss.routine_vx(name='early-small', start_year=2010, prob=0.05, product=vax)
+    routine2 = ss.routine_vx(name='late-big', start_year=2020, prob=0.5, product=vax)
+    
+    # Make and run sims
+    pars = make_sim_pars()
+    s1 = ss.Sim(pars, label='Baseline')
+    s2 = ss.Sim(pars, interventions=routine1, label='One vaccine campaign')
+    s3 = ss.Sim(pars=pars, interventions=[routine1, routine2], label='Two vaccine campaigns')
+    s1.run()
+    s2.run()
+    s3.run()
+    
+    if do_plot:
+        plt.figure()
+        for sim in [s1, s2, s3]:
+            plt.plot(sim.yearvec, sim.results.sir.cum_infections, label=sim.label)
+        plt.legend()
+        plt.title('Impact of vaccination')
+        plt.xlabel('Year')
+        plt.ylabel('Cumulative infections')
+        
+    assert s2.summary.sir_cum_infections < s1.summary.sir_cum_infections, 'Vaccine should avert infections'
+    assert s3.summary.sir_cum_infections < s2.summary.sir_cum_infections, 'Vaccine should avert infections'
+    return s3
+
+
+def test_components(do_plot=do_plot):
+    """ Create, run, and plot a sim by assembling components """
+    sc.heading('Testing 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()
+    if do_plot:
+        sim.plot()
+    return sim
+
+
+def test_parallel():
+    """ Test running two identical sims in parallel """
+    sc.heading('Testing parallel...')
+    pars = make_sim_pars()
+
+    # Check that two identical sims match
+    sims = ss.MultiSim([ss.Sim(pars, label='Sim1'), ss.Sim(pars, label='Sim2')])
+    sims.run(keep_people=True)
+    s1, s2 = sims.sims
+    assert np.allclose(s1.summary[:], s2.summary[:], rtol=0, atol=0, equal_nan=True)
+
+    # Check that two non-identical sims don't match
+    pars2 = sc.dcp(pars)
+    pars2.diseases.beta *= 2
+    sims = ss.MultiSim([ss.Sim(pars, label='Sim1'), ss.Sim(pars2, label='Sim2')])
+    sims.run(keep_people=True)
+    s1, s2 = sims.sims
+    assert not np.allclose(s1.summary[:], s2.summary[:], rtol=0, atol=0, equal_nan=True)
+
+    return s1, s2
+
+
+if __name__ == '__main__':
+    do_plot = True
+    sc.options(interactive=do_plot)
+    T = sc.timer()
+    
+    sim0 = test_demo(do_plot=do_plot)
+    sim1 = test_default(do_plot=do_plot)
+    sim2 = test_simple(do_plot=do_plot)
+    sim3 = test_api()
+    sim4 = test_complex_api()
+    sim5b, sim5i = test_simple_vax(do_plot=do_plot)
+    sim6 = test_shared_product(do_plot=do_plot)
+    sim7 = test_components(do_plot=do_plot)
+    sim8a, sim8b = test_parallel()
+    
+    T.toc()
+    
+    if do_plot:
+        plt.show()
diff --git a/tests/test_simple.py b/tests/test_simple.py
deleted file mode 100644
index 2d95db5b..00000000
--- a/tests/test_simple.py
+++ /dev/null
@@ -1,196 +0,0 @@
-"""
-Test simple APIs
-"""
-
-# %% Imports and settings
-import starsim as ss
-import sciris as sc
-import numpy as np
-import matplotlib.pyplot as plt
-
-n_agents = 1_000
-do_plot = False
-sc.options(interactive=False) # Assume not running interactively
-
-
-def make_sim_pars():
-    pars = sc.objdict(
-        n_agents = n_agents,
-        birth_rate = 20,
-        death_rate = 15,
-        networks = sc.objdict(
-            type = 'randomnet',
-            n_contacts = 4,
-        ),
-        diseases = sc.objdict(
-            type = 'sir',
-            dur_inf = 10,
-            beta = 0.1,
-        )
-    )
-    return pars
-
-
-def test_demo(do_plot=do_plot):
-    """ Test Starsim's demo run """
-    sim = ss.demo(plot=do_plot)
-    return sim
-
-
-def test_default(do_plot=do_plot):
-    """ Create, run, and plot a sim with default settings """
-    sim = ss.Sim(n_agents=n_agents).run()
-    if do_plot:
-        sim.plot()
-    return sim
-
-
-def test_simple(do_plot=do_plot):
-    """ Create, run, and plot a sim by passing a parameters dictionary """
-    pars = make_sim_pars()
-    sim = ss.Sim(pars)
-    sim.run()
-    if do_plot:
-        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.9],
-        dur_inf=[1, 8],
-        p_death=[.01, .1],
-    )
-
-    # 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
-        if par == 'p_death':
-            v0 = s0.results.cum_deaths[-1]
-            v1 = s1.results.cum_deaths[-1]
-        else:
-            ind = 1 if par == 'init_prev' else -1
-            v0 = s0.results.sir.cum_infections[ind]
-            v1 = s1.results.sir.cum_infections[ind]
-
-        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=do_plot):
-    """ 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))
-    intv = ss.routine_vx(start_year=2015, prob=0.2, product=my_vax)
-    sim_intv = ss.Sim(pars=pars, interventions=intv)
-    sim_intv.run()
-
-    # Check plots
-    if do_plot:
-        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()
-
-    return sim_base, sim_intv
-
-
-def test_components(do_plot=do_plot):
-    """ 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()
-    if do_plot:
-        sim.plot()
-    return sim
-
-
-def test_parallel():
-    """ Test running two identical sims in parallel """
-    pars = make_sim_pars()
-
-    # Check that two identical sims match
-    sims = ss.MultiSim([ss.Sim(pars, label='Sim1'), ss.Sim(pars, label='Sim2')])
-    sims.run(keep_people=True)
-    s1, s2 = sims.sims
-    assert np.allclose(s1.summary[:], s2.summary[:], rtol=0, atol=0, equal_nan=True)
-
-    # Check that two non-identical sims don't match
-    pars2 = sc.dcp(pars)
-    pars2.diseases.beta *= 2
-    sims = ss.MultiSim([ss.Sim(pars, label='Sim1'), ss.Sim(pars2, label='Sim2')])
-    sims.run(keep_people=True)
-    s1, s2 = sims.sims
-    assert not np.allclose(s1.summary[:], s2.summary[:], rtol=0, atol=0, equal_nan=True)
-
-    return s1, s2
-
-
-def test_sis(do_plot=do_plot):
-    pars = dict(
-        n_agents = n_agents,
-        diseases = 'sis',
-        networks = 'random'
-    )
-    sim = ss.Sim(pars)
-    sim.run()
-    if do_plot:
-        sim.plot()
-        sim.diseases.sis.plot()
-    return sim
-
-
-if __name__ == '__main__':
-    do_plot = True
-    sc.options(interactive=do_plot)
-    T = sc.timer()
-    
-    s0 = test_demo(do_plot=do_plot)
-    s1 = test_default(do_plot=do_plot)
-    s2 = test_simple(do_plot=do_plot)
-    s3a, s3b = test_sir_epi()
-    s4_base, s4_intv = test_simple_vax(do_plot=do_plot)
-    s5 = test_components(do_plot=do_plot)
-    s6a, s6b = test_parallel()
-    s7 = test_sis(do_plot=do_plot)
-    
-    T.toc()
-    
-    if do_plot:
-        plt.show()