Repository: starsimhub/starsim (language: python)

Issue:
Rc2.0 tidying
### Description
Testing, documentation, linting, etc.

### Checklist
- [ ] Code commented & docstrings added
- [ ] New tests were needed and have been added
- [ ] A new version number was needed & changelog has been updated
- [ ] A new PyPI version needs to be released


Discussion / hints:

Fix patch:
diff --git a/.flake8 b/.flake8
new file mode 100644
index 00000000..bf545913
--- /dev/null
+++ b/.flake8
@@ -0,0 +1,11 @@
+# Custom settings for Starsim. Changes:
+# E221: allow multiple spaces before operator
+# E225: allow space around arithmetic operators
+# E226: allow space around arithmetic operators
+# E261: allow one space before comment
+# E272: allow multiple spaces before keyword
+# E501: allow long lines
+# E701: allow multiple statements on a line
+
+[flake8]
+ignore = E221, E225, E226, E261, E272, E501, E701
\ No newline at end of file
diff --git a/.pylintrc b/.pylintrc
new file mode 100644
index 00000000..2f032f13
--- /dev/null
+++ b/.pylintrc
@@ -0,0 +1,115 @@
+[BASIC]
+
+# Regular expression matching correct argument names. Overrides argument-
+# naming-style. If left empty, argument names will be checked with the set
+# naming style.
+argument-rgx=[a-z_][a-z0-9_]{0,15}$ # Starsim
+variable-rgx=[a-z_][a-z0-9_]{0,15}$ # Starsim
+attr-rgx=[a-z_][a-z0-9_]{0,15}$ # Starsim
+class-attribute-rgx=[a-z_][a-z0-9_]{0,15}$ # Starsim
+function-rgx=[a-z_][a-z0-9_]{0,15}$ # Starsim
+method-rgx=[a-z_][a-z0-9_]{0,15}$ # Starsim
+class-rgx=[a-zA-Z_][a-zA-Z0-9_]{0,15}$ # Starsim
+
+# Good variable names which should always be accepted, separated by a comma.
+good-names=E # Starsim
+
+
+[DESIGN]
+
+# Maximum number of arguments for function / method.
+max-args=20 # Starsim
+
+# Maximum number of attributes for a class (see R0902).
+max-attributes=20 # Starsim
+
+# Maximum number of boolean expressions in an if statement (see R0916).
+max-bool-expr=5
+
+# Maximum number of branch for function / method body.
+max-branches=15 # Starsim
+
+# Maximum number of locals for function / method body.
+max-locals=20 # Starsim
+
+# Maximum number of parents for a class (see R0901).
+max-parents=7
+
+# Maximum number of public methods for a class (see R0904).
+max-public-methods=20
+
+# Maximum number of return / yield for function / method body.
+max-returns=5 # Starsim
+
+# Maximum number of statements in function / method body.
+max-statements=200 # Starsim
+
+# Minimum number of public methods for a class (see R0903).
+min-public-methods=2
+
+
+[FORMAT]
+
+# Maximum number of characters on a single line.
+max-line-length=200 # Starsim
+
+# Maximum number of lines in a module.
+max-module-lines=2000 # Starsim
+
+# Allow the body of an if to be on the same line as the test if there is no
+# else.
+single-line-if-stmt=yes # Starsim
+
+
+[IMPORTS]
+
+# Allow wildcard imports from modules that define __all__.
+allow-wildcard-with-all=yes # Starsim
+
+
+[MESSAGES CONTROL]
+
+# Disable the message, report, category or checker with the given id(s). You
+# can either give multiple identifiers separated by comma (,) or put this
+# option multiple times (only on the command line, not in the configuration
+# file where it should appear only once). You can also use "--disable=all" to
+# disable everything first and then re-enable specific checks. For example, if
+# you want to run only the similarities checker, you can use "--disable=all
+# --enable=similarities". If you want to run only the classes checker, but have
+# no Warning level messages displayed, use "--disable=all --enable=classes
+# --disable=W".
+disable=raw-checker-failed,
+        bad-inline-option,
+        locally-disabled,
+        file-ignored,
+        suppressed-message,
+        useless-suppression,
+        deprecated-pragma,
+        use-symbolic-message-instead,
+        C0321, # Starsim: multiple statements on a line
+        C0325, # Starsim: allow parentheses in if
+        C0411, # Starsim: allow different import order
+        C0412, # Starsim: allow ungrouped imports
+        C0413, # Starsim: allow imports not at the top
+        E1101, # Starsim: don't check class membership (dynamic classes)
+        R1705, # Starsim: allow else-return
+        R1711, # Starsim: empty return
+        R1720, # Starsim: allow else after raise
+        R1735, # Starsim: dict literal
+        W0702, # Starsim: allow bare except
+        W0718 # Starsim: allow general exception
+
+# Enable the message, report, category or checker with the given id(s). You can
+# either give multiple identifier separated by comma (,) or put this option
+# multiple time (only on the command line, not in the configuration file where
+# it should appear only once). See also the "--disable" option for examples.
+enable=c-extension-no-member
+
+
+[MISCELLANEOUS]
+
+# List of note tags to take in consideration, separated by a comma.
+notes=FIXME,
+      XXX,
+      TODO,
+      TEMP # Starsim
\ No newline at end of file
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 0d71dc60..3edf66af 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -7,6 +7,100 @@ 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 2.0.0 (2024-10-01)
+--------------------------
+
+Summary
+~~~~~~~
+Version 2.0 contains several major changes. These include: module-specific timesteps and time-aware parameters (including a day/year ``unit`` flag for modules, and  ``ss.dur()`` and ``ss.rate()`` classes for parameters), and changes to module types and integration (e.g. renaming ``update()`` and ``apply()`` methods to ``step()``;).
+
+Time-aware parameters and modules
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+- Added ``ss.dur()``, ``ss.rate()``, and ``ss.time_prob()`` classes, for automatic handling of time units in simulations. There are also convenience classes ``ss.days()``, ``ss.years()``, ``ss.perday()``, ``ss.peryear()``, and ``ss.beta()`` for special cases of these.
+- ``ss.dur()`` and ``ss.rate()``, along with modules and the sim itself, have a ``unit`` parameter which can be ``'day'``, ``'week'``, ``'month'``, or ``'year'`` (default). Modules now also have their own timestep ``dt``. Different units and timesteps can be mixed and matched. Time parameters have a ``to()`` method, e.g. ``ss.dur(1, 'year').to('day')`` will return ``ss.dur(365, unit='day')``.
+- The ``ss.Sim`` parameter ``n_years`` has been renamed ``dur``; ``sim.yearvec`` is now ``sim.timevec``, which can have units of days (usually starting at 0), dates (e.g. ``'2020-01-01'``), or years (e.g. ``2020``). ``sim.abs_tvec`` is the translation of ``sim.timevec`` as a numeric array starting at 0, using the sim's units (usually ``'day'`` or ``'year'``). For example, if ``sim.timevec`` is a list of daily dates from ``'2022-01-01'`` to ``'2022-12-31'``, ``sim.abs_tvec`` will be ``np.arange(365)``.
+- Each module also has its own ``mod.timevec``; this can be different from the sim if it defines its own time unit and/or timestep. ``mod.abs_tvec`` always starts at 0 and always uses the sim's unit.
+- There is a new ``Loop`` class which handles the integration loop. You can view the integration plan via ``sim.loop.to_df()`` or ``sim.loop.plot()``. You can see how long each part of the sim took with ``sim.loop.plot_cpu()``.
+- There are more advanced debugging tools. You can run a single sim timestep with ``sim.run_one_step()`` (which in turn calls multiple functions), and you can run a single function from the integration loop with ``sim.loop.run_one_step()``.
+
+Module changes
+~~~~~~~~~~~~~~
+- Functionality has been moved from ``ss.Plugin`` to ``ss.Module``, and the former has been removed.
+- ``ss.Connector`` functionality has been moved to ``ss.Module``. ``ss.Module`` objects can be placed anywhere in the list of modules (e.g., in demographics, networks, diseases, interventions), depending on when you want them to execute. However, ``ss.Connector`` objects are applied after ``Disease.step_state()`` and before ``Network.step()``.
+- Many of the module methods have been renamed; in particular, all modules now have a ``step()`` method, which replaces ``update()`` (for demographics and networks), ``apply()`` (for interventions and analyzers), and ``make_new_cases()`` (for diseases). For both the sim and modules, ``initialize()`` has been renamed ``init()``.
+- All modules are treated the same in the integration loop, except for diseases, which have ``step_state()`` and ``step_die()`` methods.
+- The Starsim module ``states.py`` has been moved to ``arrays.py``, and ``network.py`` has been moved to ``networks.py``.
+
+State and array changes
+~~~~~~~~~~~~~~~~~~~~~~~
+- ``ss.Arr``, ``ss.TimePar``, and ``ss.Result`` all inherit from the new class ``ss.BaseArr``, which provides functionality similar to a NumPy array, except all values are stored in ``arr.values`` (like a ``pd.Series``).
+- Whereas before, computations on an ``ss.Arr`` usually returned a NumPy array, calculations now usually return the same type. To access the NumPy array, use ``arr.values``. 
+- There is a new ``ss.State`` class, which is a subtype of ``ss.BoolArr``. Typically, ``ss.State`` is used for boolean disease states, such as ``infected``, ``susceptible``, etc., where you want to automatically generate results (e.g. ``n_infected``). You can continue using ``ss.BoolArr`` for other agent attributes that you don't necessarily want to automatically generate results for, e.g. ``ever_vaccinated``.
+
+Results changes
+~~~~~~~~~~~~~~~
+- Results are now defined differently. They should be defined in ``ss.Module.init_results()``, not ``ss.Module.init_pre()``. They now take the module name, number of points, and time vector from the parent module. As a result, they are usually initialized via ``ss.Module.define_results(res1, res2)`` (as opposed to ``mod.results += [res1, res2]`` previously). ``define_results()`` automatically adds these properties from the parent module; they can still be defined explicitly if needed however.
+- Because results now store their own time information, they can be plotted in a self-contained way. Both ``ss.Result`` and ``ss.Results`` objects now have ``plot()`` and ``to_df()`` methods.
+
+Demographics changes
+~~~~~~~~~~~~~~~~~~~~
+- Fixed a bug in how results were defined for ``ss.Births`` and ``ss.Deaths``.
+- The ``ss.Pregnancy`` module has been significantly rewritten, including: (1) Agents now have a ``parent`` which indicates the UID of the parent; (2) Women now track ``child_uid``; (3) On neonatal death, the pregnancy state of the mother is corrected; (4) Pregnancy rates now adjusted for infecund rather than pregnant; (4) Pregnancy now has a burn-in, which defaults to ``True``; (5) Pregnancy has a ``p_neonatal_death`` parameter to capture fetal and neonatal death if the mother dies.
+- Slots now has a minimum, default of 100, to account for small initial population sizes that grow dramatically over time.
+
+Computational changes
+~~~~~~~~~~~~~~~~~~~~~
+- There have been several performance improvements. The default float type is now ``np.float32``. Transmission is now handled by a specialized ``Infection.compute_transmission()`` method. Several additional functions now use Numba, including ``fastmath=True``, which leverages Intel's short vector math library.
+- A new ``ss.multi_random()`` distribution class has been added, that allows random numbers to be generated by two (or more) agents. It largely replaces ``ss.combine_rands()`` and is 5-10x faster.
+- A new ``ss.gamma()`` distribution has also been added.
+- Distributions have a new ``jump_dt`` method that jumps by much more than a single state update.
+- ``ss.parallel()`` and ``ss.MultiSim.run()`` now modify simulations in place by default. Instead of ``sims = ss.parallel(sim1, sim2).sims; sims[0].plot()``, you can now simply do ``ss.parallel(sim1, sim2); sim1.plot()``.
+
+Other changes
+~~~~~~~~~~~~~
+- Data can now be supplied to a simulation; it will be automatically plotted by ``sim.plot()``.
+- ``ss.Calibration`` has been significantly reworked, and now includes more flexible parameter setting, plus plotting (``calib.plot_sims()`` and ``calib.plot_trend()``). It also has a ``debug`` argument (which runs in serial rather than paralell), which can be helpful for troubleshooting issues.
+- ``MultiSim`` now has display methods ``brief()`` (minimal), ``show()`` (moderate), and ``disp`` (verbose).
+- ``sim.export_df()`` has been renamed ``sim.to_df()``.
+- Most classes now have ``to_json()`` methods (which can also export to a dict).
+- Fixed a bug in how the ``InfectionLog`` is added to disease modules.
+- ``Sim.gitinfo`` has been replaced with ``Sim.metadata`` (which includes git info).
+- ``Infection.validate_beta()`` is now applied on every timestep, so changes to beta during the simulation are now honored.
+- ``sim.get_intervention()`` and ``sim.get_analyzer()`` have been removed; use built-in ``ndict`` operations (e.g., the label) to find the object you're after.
+- ``requires`` has been removed from modules, but ``ss.check_requires()`` is still available if needed. Call it manually from ``init_pre()`` if desired, e.g. a PMTCT intervention might call ``ss.check_requires(self.sim, ['hiv', 'maternalnet'])``.
+- For networks, ``contacts`` has been renamed ``edges`` except in cases where it refers to an *agent's* contacts. For example, ``network.contacts`` has been renamed ``network.edges``, but ``ss.find_contacts()`` remains the same.
+- Networks now have a ``to_graph()`` method that exports to NetworkX.
+- ``ss.diff_sims()`` can now handle ``MultiSim`` objects.
+- ``Sim._orig_pars`` has been removed.
+- ``ss.unique()`` has been removed.
+
+Regression information
+~~~~~~~~~~~~~~~~~~~~~~
+- Note: the list here covers major changes only; in general, Starsim v1.0 scripts will not be compatible with Starsim v2.0.
+- Results from Starsim v2.0 will be stochastically (but not statistically) different from Starsim v1.0.
+- All duration and rate parameters should now be wrapped with ``ss.dur()`` and ``ss.rate()``. Events that represent probabilities over time (i.e. hazard rates) can also be wrapped with ``ss.time_prob()``, although this is similar to ``ss.rate()`` unless the value is relatively large.
+- ``ss.Plugin`` has been removed. Use ``ss.Module`` instead.
+- ``init_results()`` is now called by ``init_pre()``, and does not need to be called explicitly.
+- ``default_pars()`` has been renamed ``define_pars()``.
+- ``add_states()`` has been renamed ``define_states()``
+- ``initialize()`` has been renamed ``init()``.
+- ``Demographics.update()`` has been renamed ``Demographics.step()``.
+- ``Network.update()`` has been renamed ``Network.step()``.
+- ``Disease.update_pre()`` has been renamed ``Disease.step_state()``.
+- ``Disease.make_new_cases()`` has been renamed ``Disease.step()``.
+- ``Disease.update_death()`` has been renamed ``Disease.step_die()`` (which is now called by ``People.step_die()``).
+- ``Infection._set_cases()`` has been renamed ``Infection.set_outcomes()``.
+- ``Intervention.apply(sim)`` has been renamed ``Intervention.step()``; ditto for ``Analyzer``.
+- ``Module.step()`` no longer takes ``sim`` as an argument (e.g., replace ``intervention.apply(sim)`` with ``intervention.step()``).
+- All modules now have methods for ``start_step()``, ``finish_step()``, ``init_results()``, and ``update_results()``.
+- ``Network.contacts`` has been renamed ``Network.edges``.
+- ``sim.get_intervention()`` and ``sim.get_analyzer()`` have been removed; simply call directly instead (e.g. replace ``sim.get_intervention('vaccine')`` with ``sim.interventions['vaccine']``).
+- ``requires`` is no longer an attribute of modules; call the ``ss.check_requires()`` function directly if needed.
+- ``People.resolve_deaths()`` has been renamed ``People.check_deaths()``
+- ``ss.unique()`` has been removed.
+- *GitHub info*: PR `626 <https://github.com/starsimhub/starsim/pull/626>`_
+
+
 Version 1.0.3 (2024-09-26)
 ---------------------------
 - Fixes a bug in which some intervention parameters (e.g. eligibility) do not get set properly.
diff --git a/README.rst b/README.rst
index dbf26f01..0b71b2b8 100644
--- a/README.rst
+++ b/README.rst
@@ -25,6 +25,8 @@ Starsim is most easily installed via PyPI: ``pip install starsim``.
 
 Starsim can also be installed locally. To do this, clone first this repository, then run ``pip install -e .`` (don't forget the dot at the end!).
 
+*Note:* Starsim leverages Intel's `short vector math library <https://numba.readthedocs.io/en/stable/user/performance-tips.html#intel-svml>`_. If you want to use this (for a ~10% speed improvement), install via `conda install intel-cmplr-lib-rt`.
+
 
 Usage and documentation
 -----------------------
@@ -63,22 +65,24 @@ Here is a slightly more realistic example of an SIR model with random connection
 More usage examples are available in the tutorials, as well as the ``tests`` folder.
 
 
-Model structure
----------------
+Starsim structure
+-----------------
 
 All core model code is located in the ``starsim`` subfolder; standard usage is ``import starsim as ss``.
 
-The model consists of core classes including Sim, Run, People, State, Network, Connectors, Analyzers, Interventions, Results, and more. These classes contain methods for running, building simple or dynamic networks, generating random numbers, calculating results, plotting, etc.
+The model consists of core classes including ``Sim``, ``People``, ``Disease``, ``Network``, ``Intervention``, and more. These classes contain methods for running, building simple or dynamic networks, generating random numbers, calculating results, plotting, etc.
 
-The structure of the starsim folder is as follows, roughly in the order in which the modules are imported, building from most fundamental to most complex:
+The submodules of the Starsim folder are as follows:
 
+• ``arrays.py``: Classes to handle store and update states for people in networks in the simulation including living, mother, child, susceptible, infected, inoculated, recovered, etc.
 • ``calibration.py``: Class to handle automated calibration of the model to data.
 •	``demographics.py``: Classes to transform initial condition input parameters for use in building and utilizing networks.
 •	``disease.py``: Classes to manage infection rate of spread, prevalence, waning effects, and other parameters for specific diseases.
-•	``distributions.py``: Classes that handle statistical distributions used throughout Starsim.
+•	``distributions.py``: Classes that handle statistical distributions used throughout Starsim to produce random numbers.
 •	``interventions.py``: The Intervention class, for adding interventions and dynamically modifying parameters, and classes for each of the specific interventions derived from it. The Analyzers class (for performing analyses on the sim while it's running), and other classes and functions for analyzing simulations.
+• ``loop.py``: The logic for the main simulation integration loop.
 •	``modules.py``: Class to handle "module" logic, such as updates (diseases, networks, etc).
-•	``network.py``: Classes for creating simple and dynamic networks of people based on input parameters.
+•	``networks.py``: Classes for creating simple and dynamic networks of people based on input parameters.
 •	``parameters.py``: Classes for creating the simulation parameters.
 •	``people.py``: The People class, for handling updates of state for each person.
 •	``products.py``: Classes to manage the deployment of vaccines and treatments.
@@ -87,7 +91,6 @@ The structure of the starsim folder is as follows, roughly in the order in which
 •	``samples.py``: Class to store data from a large number of simulations.
 •	``settings.py``: User-customizable options for Starsim (e.g. default font size).
 •	``sim.py``: The Sim class, which performs most of the heavy lifting: initializing the model, running, and plotting.
-•	``states.py``: Classes to handle store and update states for people in networks in the simulation including living, mother, child, susceptible, infected, inoculated, recovered, etc.
 •	``utils.py``: Helper functions.
 •	``version.py``: Version, date, and license information.
 
diff --git a/docs/api/index.rst b/docs/api/index.rst
index 3c9540c7..2177c1dd 100644
--- a/docs/api/index.rst
+++ b/docs/api/index.rst
@@ -12,19 +12,20 @@ This page lists Starsim's full API, including all functions and classes.
    :recursive:
    :nosignatures:
 
-   starsim.demographics
-   starsim.disease
-   starsim.distributions
-   starsim.interventions
-   starsim.modules
-   starsim.network
-   starsim.parameters
-   starsim.people
-   starsim.products
-   starsim.results
-   starsim.run
-   starsim.samples
-   starsim.settings
-   starsim.sim
-   starsim.states
-   starsim.utils
+   arrays
+   demographics
+   disease
+   distributions
+   interventions
+   loop
+   modules
+   networks
+   parameters
+   people
+   products
+   results
+   run
+   samples
+   settings
+   sim
+   utils
diff --git a/docs/requirements.txt b/docs/requirements.txt
index ed9af804..01b30ba9 100644
--- a/docs/requirements.txt
+++ b/docs/requirements.txt
@@ -5,11 +5,11 @@ sphinx-autodoc-typehints
 pydata-sphinx-theme
 sphinx-design
 readthedocs-sphinx-search
+sphinxcontrib-napoleon
 ipykernel # For tutorials
-nbconvert==7.13.0 # For tutorials; >=7.14 does not work
+ipywidgets # For the tutorials
+nbconvert # For tutorials
 nbsphinx # For tutorials
 plantweb
-ipykernel
-nbsphinx
 optuna
 seaborn
diff --git a/docs/run_tutorials b/docs/run_tutorials
new file mode 120000
index 00000000..9d6178e7
--- /dev/null
+++ b/docs/run_tutorials
@@ -0,0 +1,1 @@
+tutorials/run_tutorials
\ No newline at end of file
diff --git a/docs/tutorials.rst b/docs/tutorials.rst
index 88f9334e..a02506c2 100644
--- a/docs/tutorials.rst
+++ b/docs/tutorials.rst
@@ -22,5 +22,5 @@ Developer tutorials
     tutorials/dev_tut_diseases.ipynb
     tutorials/dev_tut_pars.ipynb
     tutorials/dev_tut_analyzers.ipynb
-    tutorials/dev_tut_abm.ipynb
+    tutorials/dev_tut_nonstandard.ipynb
     
diff --git a/docs/tutorials/dev_tut_abm.ipynb b/docs/tutorials/dev_tut_abm.ipynb
deleted file mode 100644
index 6f3cb382..00000000
--- a/docs/tutorials/dev_tut_abm.ipynb
+++ /dev/null
@@ -1,165 +0,0 @@
-{
- "cells": [
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "# Developer tutorial: ABM examples\n",
-    "\n",
-    "This tutorial describes how Starsim can be used to reproduce examples from other Python ABMs.\n",
-    "\n",
-    "## Mesa: Wealth model\n",
-    "\n",
-    "Although Starsim is intended primarily for modeling disease, it can also be used as a general-purpose agent-based model.  This example illustrates a simple \"weath model\", in which each agent starts with a single unit of wealth, and on each timestep, every agent with more than zero wealth gives one unit of wealth to another agent.\n",
-    "\n",
-    "This tutorial is adapted from the following example:\n",
-    "\n",
-    "https://mesa.readthedocs.io/en/stable/tutorials/intro_tutorial.html\n",
-    "\n",
-    "### Setting up the model\n",
-    "\n",
-    "We could define the wealth model as any type of module, since they all can store states and update them. Here we will define wealth as a subclass of `ss.Intervention` (though it could equally well be a subclass of `ss.Demographics` or even `ss.Disease`, if you are so inclined). All we need to do is update the `wealth` state (which we can store inside the \"intervention\"), and we can also use this class to track the wealth distribution over time and plot it. The full model looks like this:"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "# Imports\n",
-    "import numpy as np\n",
-    "import starsim as ss\n",
-    "import matplotlib.pyplot as plt\n",
-    "\n",
-    "# Define the model\n",
-    "class WealthModel(ss.Intervention):\n",
-    "    \"\"\" A simple wealth transfer model\"\"\"\n",
-    "    \n",
-    "    def init_post(self, bins=10):\n",
-    "        \"\"\" Define custom model attributes \"\"\"\n",
-    "        super().init_post()\n",
-    "        self.npts = self.sim.npts # Number of timepoints\n",
-    "        self.n_agents = len(sim.people) # Number of agents\n",
-    "        self.wealth = np.ones(self.n_agents) # Initial wealth of each agent\n",
-    "        self.bins = np.arange(bins+1) # Bins used for plotting\n",
-    "        self.wealth_dist = np.zeros((self.npts, len(self.bins)-1)) # Wealth distribution over time\n",
-    "        return\n",
-    "        \n",
-    "    def apply(self, sim):\n",
-    "        \"\"\" Transfer wealth between agents -- core model logic \"\"\"\n",
-    "        self.wealth_hist() # Store the wealth at this time point\n",
-    "        givers = self.wealth > 0 # People need wealth to be givers\n",
-    "        receivers = np.random.choice(sim.people.uid, size=givers.sum()) # Anyone can be a receiver\n",
-    "        self.wealth[givers] -= 1 # Givers are unique, so can use vectorized version\n",
-    "        for receive in receivers: # Vectorized version is: np.add.at(sim.people.wealth.raw, receivers, 1)\n",
-    "            self.wealth[receive] += 1\n",
-    "        return\n",
-    "    \n",
-    "    def wealth_hist(self):\n",
-    "        \"\"\" Calculate the wealth histogram \"\"\"\n",
-    "        ti = self.sim.ti # Current timestep\n",
-    "        self.wealth_dist[ti,:], _ = np.histogram(self.wealth, bins=self.bins)\n",
-    "        return\n",
-    "    \n",
-    "    def plot(self):\n",
-    "        \"\"\" Plot a 2D histogram of the final wealth distribution \"\"\"\n",
-    "        plt.figure()\n",
-    "        plt.bar(self.bins[:-1], self.wealth_dist[-1,:])\n",
-    "        plt.title('Wealth distribution at final time point')\n",
-    "        plt.xlabel('Wealth')\n",
-    "        plt.ylabel('Number of agents')\n",
-    "        plt.show()\n",
-    "        return\n",
-    "    \n",
-    "    def plot3d(self):\n",
-    "        \"\"\" Plot a 3D heatmap of the wealth distribution over time \"\"\"\n",
-    "        plt.figure()\n",
-    "        plt.pcolor(self.wealth_dist.T, cmap='turbo')\n",
-    "        plt.title('Wealth distribution over time')\n",
-    "        plt.xlabel('Time')\n",
-    "        plt.ylabel('Wealth')\n",
-    "        plt.colorbar().set_label('Number of agents', rotation=270)\n",
-    "        plt.show()\n",
-    "        return\n",
-    "\n",
-    "# Create sim inputs, including the wealth model\n",
-    "wealth = WealthModel()\n",
-    "pars = dict(\n",
-    "    n_agents = 100, # Number of agents\n",
-    "    start = 0,\n",
-    "    end = 100,\n",
-    "    interventions = wealth,\n",
-    ")\n",
-    "\n",
-    "# Run the model\n",
-    "sim = ss.Sim(pars, copy_inputs=False) # copy_inputs=False lets us reuse the \"wealth\" object from above\n",
-    "sim.run()\n",
-    "\n",
-    "# Plot the results\n",
-    "wealth.plot()\n",
-    "wealth.plot3d()"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "### Comparison with Mesa\n",
-    "\n",
-    "While the implementation in Starsim is similar to Mesa, there are a couple key differences:\n",
-    "\n",
-    "- Because Starsim's people object is vectorized, the wealth definition and update is vectorized as well.\n",
-    "- Both Mesa and Starsim versions of the model are quite simple, but there is a little less boilerplate in the Starsim version."
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": []
-  }
- ],
- "metadata": {
-  "kernelspec": {
-   "display_name": "Python 3 (ipykernel)",
-   "language": "python",
-   "name": "python3"
-  },
-  "language_info": {
-   "codemirror_mode": {
-    "name": "ipython",
-    "version": 3
-   },
-   "file_extension": ".py",
-   "mimetype": "text/x-python",
-   "name": "python",
-   "nbconvert_exporter": "python",
-   "pygments_lexer": "ipython3",
-   "version": "3.11.7"
-  },
-  "pycharm": {
-   "stem_cell": {
-    "cell_type": "raw",
-    "metadata": {
-     "collapsed": false
-    },
-    "source": []
-   }
-  },
-  "toc": {
-   "base_numbering": 1,
-   "nav_menu": {},
-   "number_sections": true,
-   "sideBar": true,
-   "skip_h1_title": false,
-   "title_cell": "Table of Contents",
-   "title_sidebar": "Contents",
-   "toc_cell": false,
-   "toc_position": {},
-   "toc_section_display": true,
-   "toc_window_display": false
-  }
- },
- "nbformat": 4,
- "nbformat_minor": 4
-}
diff --git a/docs/tutorials/dev_tut_analyzers.ipynb b/docs/tutorials/dev_tut_analyzers.ipynb
index facfa12c..a764ef39 100644
--- a/docs/tutorials/dev_tut_analyzers.ipynb
+++ b/docs/tutorials/dev_tut_analyzers.ipynb
@@ -13,7 +13,68 @@
    "source": [
     "## Reporting results\n",
     "\n",
-    "Each Starsim module can have its own results, which get added to the full list of results in the Sim object. For example, the `ss.Pregnancy` module adds results like `sim.results.pregnancy.pregnant`, and the `ss.HIV` module adds results like `sim.results.hiv.new_infections`. If you are writing your own module, you can add whatever custom results you want. However, another option is to create an `Analyzer` to store results that you might need for one particular analysis but won't need all the time. An `Analyzer` is very similar to other Starsim modules in its structure, but the general idea of an analyzer is that it gets called at the end of a timestep, and reports of the state of things after everything else has been updated without changing any of the module states itself.\n",
+    "Each Starsim module can have its own results, which get added to the full list of results in the Sim object. For example, the `ss.Pregnancy` module adds results like `sim.results.pregnancy.pregnant`, and the `ss.HIV` module adds results like `sim.results.hiv.new_infections`. If you are writing your own module, you can add whatever custom results you want. However, another option is to create an `Analyzer` to store results that you might need for one particular analysis but won't need all the time. An `Analyzer` is very similar to other Starsim modules in its structure, but the general idea of an analyzer is that it gets called at the end of a timestep, and reports of the state of things after everything else has been updated without changing any of the module states itself.\n"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Simple usage\n",
+    "\n",
+    "For simple reporting, it's possible to use a single function as an analyzer. In this case, the function receives a single argument, `sim`, which it has full access to. For example, if you wanted to know the number of connections in the network on each timestep, you could write a small analyzer as follows:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import starsim as ss\n",
+    "import matplotlib.pyplot as plt\n",
+    "\n",
+    "# Store the number of edges\n",
+    "n_edges = []\n",
+    "\n",
+    "def count_edges(sim):\n",
+    "    \"\"\" Print out the number of edges in the network on each timestep \"\"\"\n",
+    "    network = sim.networks[0] # Get the first network\n",
+    "    n = len(network)\n",
+    "    n_edges.append(n)\n",
+    "    print(f'Number of edges for network {network.name} on step {sim.ti}: {n}')\n",
+    "    return\n",
+    "\n",
+    "# Create the sim\n",
+    "pars = dict(\n",
+    "    diseases='sis',\n",
+    "    networks = 'mf',\n",
+    "    analyzers = count_edges,\n",
+    "    demographics = True,\n",
+    ")\n",
+    "\n",
+    "# Run the sim\n",
+    "sim = ss.Sim(pars).run()\n",
+    "sim.plot()\n",
+    "\n",
+    "# Plot the number of edges\n",
+    "plt.figure()\n",
+    "plt.plot(sim.timevec, n_edges)\n",
+    "plt.title('Number of edges over time')"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Is that what you expected it to look like? The reason it looks like that is that initially, agents die (either from aging or from disease), reducing the number of edges. New agents are being born, but they don't participate in male-female networks until the age of debut -- which is 15 years old by default, which is why the trend reverses (and tracks population size) after 2015. This illustrates the importance of model burn-in!"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Advanced usage\n",
     "\n",
     "Suppose we wanted to create an analyzer that would report on the number of new HIV infections in pregnant women:\n"
    ]
@@ -34,12 +95,11 @@
     "        self.name = 'hiv_in_pregnancy'\n",
     "        return\n",
     "    \n",
-    "    def init_pre(self, sim):\n",
-    "        super().init_pre(sim)\n",
-    "        npts = self.sim.npts\n",
-    "        self.results += [\n",
-    "            ss.Result(self.name, 'new_infections_pregnancy', npts, dtype=float, scale=True),\n",
-    "        ]\n",
+    "    def init_results(self):\n",
+    "        super().init_results()\n",
+    "        self.define_results(\n",
+    "            ss.Result('new_infections_pregnancy'),\n",
+    "        )\n",
     "        return\n",
     "\n",
     "    def apply(self, sim):\n",
@@ -77,7 +137,7 @@
     "res = sim.results\n",
     "\n",
     "plt.figure()\n",
-    "plt.plot(res.yearvec, res.hiv_in_pregnancy.new_infections_pregnancy)\n",
+    "plt.plot(res.timevec, res.hiv_in_pregnancy.new_infections_pregnancy)\n",
     "plt.title('HIV infections acquired during pregnancy')\n",
     "plt.show();"
    ]
@@ -99,7 +159,7 @@
    "name": "python",
    "nbconvert_exporter": "python",
    "pygments_lexer": "ipython3",
-   "version": "3.10.11"
+   "version": "3.11.7"
   },
   "pycharm": {
    "stem_cell": {
diff --git a/docs/tutorials/dev_tut_nonstandard.ipynb b/docs/tutorials/dev_tut_nonstandard.ipynb
new file mode 100644
index 00000000..fef9c4f9
--- /dev/null
+++ b/docs/tutorials/dev_tut_nonstandard.ipynb
@@ -0,0 +1,319 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Developer tutorial: Nonstandard usage\n",
+    "\n",
+    "Although Starsim is primarily intended as an agent-based disease model, due to its modular structure, it can be used for other applications as well. This tutorial describes how Starsim can be used (1) as a compartmental disease model, and (2) as a general-purpose agent-based model."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Compartmental modeling\n",
+    "\n",
+    "Much of Starsim's power comes from how it handles agents. However, agent-based modeling may be too slow or too complex for some problems. While in many cases it probably makes more sense to do compartmental disease modeling in another framework (such as [Atomica](https://atomica.tools/)), it is also possible to do it within Starsim, taking advantage of features such as demographics, time units, etc. This is especially useful for a multi-disease simulation where some diseases need the detail and flexibility of an ABM, while others can be modeled more simply (and faster) as compartmental models.\n",
+    "\n",
+    "### Setting up the model\n",
+    "\n",
+    "Here we will define a simple compartmental susceptible-infectious-susceptible (SIS) model. The model definition here is quite similar to the agent-based implementation in Starsim's [SIR module](https://github.com/starsimhub/starsim/blob/main/starsim/diseases/sir.py); differences are noted in comments. Note that the model runs extremely fast, since a three-state compartmental model (susceptible, infecious, and immunity) runs as fast as an agent-based model with three agents!"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "\"\"\"\n",
+    "Example compartmental SIS model for Starsim\n",
+    "\"\"\"\n",
+    "\n",
+    "import starsim as ss\n",
+    "import sciris as sc\n",
+    "import matplotlib.pyplot as plt\n",
+    "\n",
+    "\n",
+    "class CompartmentalSIS(ss.Module): # We don't need the extra functionality of the Infection class, so just inherit from Module\n",
+    "    def __init__(self, pars=None, *args, **kwargs):\n",
+    "        super().__init__()\n",
+    "        self.define_pars(\n",
+    "            beta = ss.rate(0.8), # Leverage Starsim's automatic time unit handling\n",
+    "            init_prev = 0.01, # NB: this is a scalar, rather than a distribution for an ABM\n",
+    "            recovery = ss.rate(0.1), # Also not a distribution\n",
+    "            waning = ss.rate(0.05),\n",
+    "            imm_boost = 1.0,\n",
+    "            use_immunity = True,\n",
+    "        )\n",
+    "        self.update_pars(pars=pars, *args, **kwargs)\n",
+    "        \n",
+    "        # Don't need to define states; just use scalars\n",
+    "        self.N = 0\n",
+    "        self.S = 0\n",
+    "        self.I = 0\n",
+    "        self.immunity = 0\n",
+    "        return\n",
+    "    \n",
+    "    def init_post(self):\n",
+    "        \"\"\" Finish initialization \"\"\"\n",
+    "        super().init_post()\n",
+    "        self.N = len(self.sim.people) # Assumes a static population; could also use a dynamic population size\n",
+    "        i0 = self.pars.init_prev\n",
+    "        self.S = self.N*(1-i0)\n",
+    "        self.I = self.N*i0\n",
+    "        self.immunity = i0\n",
+    "        return\n",
+    "        \n",
+    "    @property\n",
+    "    def rel_sus(self):\n",
+    "        return 1 - self.immunity\n",
+    "    \n",
+    "    def step(self):\n",
+    "        \"\"\" Carry out disease transmission logic \"\"\"\n",
+    "        self.immunity *= (1 - self.pars.waning) # Update immunity from waning\n",
+    "        rel_sus = self.rel_sus if self.pars.use_immunity else 1.0\n",
+    "        infected = (self.S*self.I/self.N)*self.pars.beta*rel_sus # Replaces Infection.infect()\n",
+    "        recovered = self.I*self.pars.recovery # Replaces setting a time to recovery and checking that time\n",
+    "        net = infected - recovered # Net change in number infectious\n",
+    "        self.S -= net\n",
+    "        self.I += net\n",
+    "        self.immunity += infected/self.N*self.pars.imm_boost # Update immunity from new infections\n",
+    "        return\n",
+    "    \n",
+    "    def init_results(self):\n",
+    "        \"\"\" Initialize results \"\"\"\n",
+    "        super().init_results()\n",
+    "        self.define_results(\n",
+    "            ss.Result('S', label='Susceptible'),\n",
+    "            ss.Result('I', label='Infectious'),\n",
+    "        )\n",
+    "        return\n",
+    "\n",
+    "    def update_results(self):\n",
+    "        \"\"\" Store the current state \"\"\"\n",
+    "        super().update_results()\n",
+    "        self.results['S'][self.ti] = self.S\n",
+    "        self.results['I'][self.ti] = self.I\n",
+    "        return \n",
+    "\n",
+    "    def plot(self):\n",
+    "        \"\"\" Default plot for SIS model \"\"\"\n",
+    "        fig = plt.figure()\n",
+    "        res = self.results\n",
+    "        kw = dict(lw=2, alpha=0.7)\n",
+    "        for rkey in ['S', 'I']:\n",
+    "            plt.plot(self.timevec, res[rkey], label=res[rkey].label, **kw)\n",
+    "        plt.legend(frameon=False)\n",
+    "        plt.xlabel('Time')\n",
+    "        plt.ylabel('Number of people')\n",
+    "        plt.ylim(bottom=0)\n",
+    "        sc.boxoff()\n",
+    "        sc.commaticks() \n",
+    "        plt.show()      \n",
+    "        return fig\n",
+    "    \n",
+    "\n",
+    "# Run the compartmental simulation (csim)\n",
+    "csim = ss.Sim(diseases=CompartmentalSIS(), dur=100, dt=0.1, verbose=0.01)\n",
+    "csim.run()\n",
+    "\n",
+    "# Plot the results\n",
+    "csim.diseases.compartmentalsis.plot();"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Let's compare to our standard agent-based SIS model with similar parameters:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import starsim as ss\n",
+    "\n",
+    "# Run the model\n",
+    "asim = ss.Sim(diseases=ss.SIS(beta=0.03), networks='random', dur=100, dt=0.1, verbose=0.01)\n",
+    "asim.run()\n",
+    "\n",
+    "# Plot the results\n",
+    "asim.diseases.sis.plot();"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "The results are broadly similar, although there are differences due to the network transmission, duration of infection, etc. Note that the compartmental version runs 20 times faster than the agent-based version. Does this mean that compartmental models are better than agent-based models? If all you want to simulate is a simple SIS model, then ... the answer is probably yes!"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Mesa: Wealth model\n",
+    "\n",
+    "This example illustrates a simple \"weath model\", in which each agent starts with a single unit of wealth, and on each timestep, every agent with more than zero wealth gives one unit of wealth to another agent.\n",
+    "\n",
+    "This tutorial is adapted from the following example:\n",
+    "\n",
+    "https://mesa.readthedocs.io/en/stable/tutorials/intro_tutorial.html\n",
+    "\n",
+    "### Setting up the model\n",
+    "\n",
+    "We could define the wealth model as any type of module, since they all can store states and update them. Here we will define wealth as a subclass of `ss.Intervention` (though it could equally well be a subclass of `ss.Demographics` or even `ss.Disease`, if you are so inclined). All we need to do is update the `wealth` state (which we can store inside the \"intervention\"), and we can also use this class to track the wealth distribution over time and plot it. The full model looks like this:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "\"\"\"\n",
+    "Define the classic agent-based \"wealth model\" in Starsim\n",
+    "\"\"\"\n",
+    "\n",
+    "# Imports\n",
+    "import numpy as np\n",
+    "import starsim as ss\n",
+    "import matplotlib.pyplot as plt\n",
+    "\n",
+    "# Define the model\n",
+    "class WealthModel(ss.Module):\n",
+    "    \"\"\" A simple wealth transfer model\"\"\"\n",
+    "    \n",
+    "    def init_post(self, bins=10):\n",
+    "        \"\"\" Define custom model attributes \"\"\"\n",
+    "        super().init_post()\n",
+    "        self.npts = self.sim.npts # Number of timepoints\n",
+    "        self.n_agents = len(sim.people) # Number of agents\n",
+    "        self.wealth = np.ones(self.n_agents) # Initial wealth of each agent\n",
+    "        self.bins = np.arange(bins+1) # Bins used for plotting\n",
+    "        self.wealth_dist = np.zeros((self.npts, len(self.bins)-1)) # Wealth distribution over time\n",
+    "        return\n",
+    "        \n",
+    "    def step(self):\n",
+    "        \"\"\" Transfer wealth between agents -- core model logic \"\"\"\n",
+    "        self.wealth_hist() # Store the wealth at this time point\n",
+    "        givers = self.wealth > 0 # People need wealth to be givers\n",
+    "        receivers = np.random.choice(self.sim.people.uid, size=givers.sum()) # Anyone can be a receiver\n",
+    "        self.wealth[givers] -= 1 # Givers are unique, so can use vectorized version\n",
+    "        for receive in receivers: # Vectorized version is: np.add.at(sim.people.wealth.raw, receivers, 1)\n",
+    "            self.wealth[receive] += 1\n",
+    "        return\n",
+    "    \n",
+    "    def wealth_hist(self):\n",
+    "        \"\"\" Calculate the wealth histogram \"\"\"\n",
+    "        ti = self.sim.ti # Current timestep\n",
+    "        self.wealth_dist[ti,:], _ = np.histogram(self.wealth, bins=self.bins)\n",
+    "        return\n",
+    "    \n",
+    "    def plot(self):\n",
+    "        \"\"\" Plot a 2D histogram of the final wealth distribution \"\"\"\n",
+    "        plt.figure()\n",
+    "        plt.bar(self.bins[:-1], self.wealth_dist[-1,:])\n",
+    "        plt.title('Wealth distribution at final time point')\n",
+    "        plt.xlabel('Wealth')\n",
+    "        plt.ylabel('Number of agents')\n",
+    "        plt.show()\n",
+    "        return\n",
+    "    \n",
+    "    def plot3d(self):\n",
+    "        \"\"\" Plot a 3D heatmap of the wealth distribution over time \"\"\"\n",
+    "        plt.figure()\n",
+    "        plt.pcolor(self.wealth_dist.T, cmap='turbo')\n",
+    "        plt.title('Wealth distribution over time')\n",
+    "        plt.xlabel('Time')\n",
+    "        plt.ylabel('Wealth')\n",
+    "        plt.colorbar().set_label('Number of agents', rotation=270)\n",
+    "        plt.show()\n",
+    "        return\n",
+    "\n",
+    "# Create sim inputs, including the wealth model\n",
+    "wealth = WealthModel()\n",
+    "pars = dict(\n",
+    "    n_agents = 100, # Number of agents\n",
+    "    start = 0,\n",
+    "    stop = 100,\n",
+    "    demographics = wealth,\n",
+    ")\n",
+    "\n",
+    "# Run the model\n",
+    "sim = ss.Sim(pars, copy_inputs=False) # copy_inputs=False lets us reuse the \"wealth\" object from above\n",
+    "sim.run()\n",
+    "\n",
+    "# Plot the results\n",
+    "wealth.plot()\n",
+    "wealth.plot3d()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "### Comparison with Mesa\n",
+    "\n",
+    "While the implementation in Starsim is similar to Mesa, there are a couple key differences:\n",
+    "\n",
+    "- Because Starsim's people object is vectorized, the wealth definition and update is vectorized as well.\n",
+    "- Both Mesa and Starsim versions of the model are quite simple, but there is a little less boilerplate in the Starsim version."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": []
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "Python 3 (ipykernel)",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.11.7"
+  },
+  "pycharm": {
+   "stem_cell": {
+    "cell_type": "raw",
+    "metadata": {
+     "collapsed": false
+    },
+    "source": []
+   }
+  },
+  "toc": {
+   "base_numbering": 1,
+   "nav_menu": {},
+   "number_sections": true,
+   "sideBar": true,
+   "skip_h1_title": false,
+   "title_cell": "Table of Contents",
+   "title_sidebar": "Contents",
+   "toc_cell": false,
+   "toc_position": {},
+   "toc_section_display": true,
+   "toc_window_display": false
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 4
+}
diff --git a/docs/tutorials/run_tutorials b/docs/tutorials/run_tutorials
new file mode 100755
index 00000000..df269b79
--- /dev/null
+++ b/docs/tutorials/run_tutorials
@@ -0,0 +1,62 @@
+#!/usr/bin/env python
+"""
+Run all the Jupyter notebook tutorials. Usage:
+  ./run_tutorials
+
+It will execute all notebooks in parallel, print out any errors
+encountered, and print a summary of results.
+
+NB: The execute() function was drafted in part via ChatGPT.
+"""
+
+import os
+import sciris as sc
+import nbformat
+import nbconvert.preprocessors as nbp
+
+folder = sc.path(__file__).resolve().parent # Ensure it's the real path if it's symlinked
+timeout = 600
+yay = '✓'
+boo = '😢'
+
+
+def execute(path):
+    """ Executes a single Jupyter notebook and returns success/failure """
+    try:
+        with open(path) as f:
+            print(f'Executing {path}...')
+            nb = nbformat.read(f, as_version=4)
+            ep = nbp.ExecutePreprocessor(timeout=timeout)
+            ep.preprocess(nb, {'metadata': {'path': os.path.dirname(path)}})
+        return f'{yay} {path} executed successfully.'
+    except nbp.CellExecutionError as e:
+        return f'{boo} Execution failed for {path}: {str(e)}'
+    except Exception as e:
+        return f'{boo} Error processing {path}: {str(e)}'
+
+
+def main():
+    """ Executes the notebooks in parallel and prints the results """
+    notebooks = sc.getfilepaths(folder=folder, pattern='*.ipynb')
+    results = sc.parallelize(execute, notebooks)
+    string = sc.newlinejoin(results)
+    
+    sc.heading('Results')
+    print(string)
+    
+    sc.heading('Summary')
+    n_yay = string.count(yay)
+    n_boo = string.count(boo)
+    summary = f'{n_yay} succeeded, {n_boo} failed'
+    if n_boo:
+        for i in range(len(notebooks)):
+            if boo in results[i]:
+                summary += f'\nFailed: {notebooks[i]}'
+    print(summary)
+    
+    return results
+
+
+if __name__ == '__main__':
+    results = main()
+
diff --git a/docs/tutorials/tut_buildsim.ipynb b/docs/tutorials/tut_buildsim.ipynb
index 0a374c47..1bce57f9 100644
--- a/docs/tutorials/tut_buildsim.ipynb
+++ b/docs/tutorials/tut_buildsim.ipynb
@@ -14,7 +14,10 @@
   {
    "cell_type": "markdown",
    "metadata": {
-    "collapsed": false
+    "collapsed": false,
+    "jupyter": {
+     "outputs_hidden": false
+    }
    },
    "source": [
     "## Simple SIR model\n",
@@ -26,7 +29,10 @@
    "cell_type": "code",
    "execution_count": null,
    "metadata": {
-    "collapsed": false
+    "collapsed": false,
+    "jupyter": {
+     "outputs_hidden": false
+    }
    },
    "outputs": [],
    "source": [
@@ -55,7 +61,10 @@
   {
    "cell_type": "markdown",
    "metadata": {
-    "collapsed": false
+    "collapsed": false,
+    "jupyter": {
+     "outputs_hidden": false
+    }
    },
    "source": [
     "Now let's look at the `pars` dictionary in more detail. The one we've created here has 3 things in it: the number of agents (`n_agents`), detail on how the agents interact with each other (`networks`) and detail on what disease we're modeling (`diseases`). When we create and run the model, what happens 'under the hood' is that the simulation creates 5,000 people, and allows them to the interact with one another over the network and transmit the infection. \n",
@@ -75,13 +84,16 @@
     "sir = ss.SIR(init_prev=0.1, beta=0.1)\n",
     "sim = ss.Sim(diseases=sir, people=people, networks=network)\n",
     "sim.run()\n",
-    "sim.plot();"
+    "sim.plot()"
    ]
   },
   {
    "cell_type": "markdown",
    "metadata": {
-    "collapsed": false
+    "collapsed": false,
+    "jupyter": {
+     "outputs_hidden": false
+    }
    },
    "source": [
     "Rather than bundling everything under pars, we're now defining components individually for people, networks, and diseases. As for the disease/network details, instead of putting all information in one bucket (`pars['diseases'] = dict(name='sir', init_prev=0.1, beta=0.1)`), we're now using `ss.SIR() `which serves as a prepared 'template' where we fill in the details. This new way provides us more flexibility to adjust details of the disease as we need.\n",
@@ -98,7 +110,10 @@
    "cell_type": "code",
    "execution_count": null,
    "metadata": {
-    "collapsed": false
+    "collapsed": false,
+    "jupyter": {
+     "outputs_hidden": false
+    }
    },
    "outputs": [],
    "source": [
@@ -109,7 +124,10 @@
   {
    "cell_type": "markdown",
    "metadata": {
-    "collapsed": false
+    "collapsed": false,
+    "jupyter": {
+     "outputs_hidden": false
+    }
    },
    "source": [
     "If we use this network, our agents will have varying numbers of contacts."
@@ -118,7 +136,10 @@
   {
    "cell_type": "markdown",
    "metadata": {
-    "collapsed": false
+    "collapsed": false,
+    "jupyter": {
+     "outputs_hidden": false
+    }
    },
    "source": [
     "## Accessing results \n",
@@ -132,17 +153,23 @@
    "cell_type": "code",
    "execution_count": null,
    "metadata": {
-    "collapsed": false
+    "collapsed": false,
+    "jupyter": {
+     "outputs_hidden": false
+    }
    },
    "outputs": [],
    "source": [
-    "sim = ss.Sim(start=2020, n_years=1, dt=1/12)  # Simulate from 2020 for 1 year with a monthly timestep"
+    "sim = ss.Sim(start=2020, dur=1, dt=1/12)  # Simulate from 2020 for 1 year with a monthly timestep"
    ]
   },
   {
    "cell_type": "markdown",
    "metadata": {
-    "collapsed": false
+    "collapsed": false,
+    "jupyter": {
+     "outputs_hidden": false
+    }
    },
    "source": [
     "## Summary \n",
@@ -153,6 +180,13 @@
     "1. How would you model an outbreak of an SIR-like disease within a refugee camp of 20,000 people? Suppose you were interested in the cumulative number of people who got infected over 1 year - how would you find this out?\n",
     "2. Whether an epidemic 'takes off' depends to a large extent on the [basic reproduction number](https://en.wikipedia.org/wiki/Basic_reproduction_number), which is the expected number of cases that an infected individual generates. In an agent based model like the one we've created here, that depends largely on three things: `beta` (the transmissibility parameter for the disease), `n_contacts` (the number of contacts each person has), and `dur_inf` (another disease-related parameter that determines the duration of infection). Experiment with different values for each of these and compare the trajectory of `sim.results.sir.n_infected` with different parameter values.\n"
    ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": []
   }
  ],
  "metadata": {
@@ -171,7 +205,7 @@
    "name": "python",
    "nbconvert_exporter": "python",
    "pygments_lexer": "ipython3",
-   "version": "3.11.7"
+   "version": "3.12.2"
   },
   "pycharm": {
    "stem_cell": {
diff --git a/docs/tutorials/tut_demographics.ipynb b/docs/tutorials/tut_demographics.ipynb
index 93c3981c..6f3fbe21 100644
--- a/docs/tutorials/tut_demographics.ipynb
+++ b/docs/tutorials/tut_demographics.ipynb
@@ -11,7 +11,7 @@
   },
   {
    "cell_type": "code",
-   "execution_count": null,
+   "execution_count": 2,
    "metadata": {
     "collapsed": false
    },
@@ -40,7 +40,7 @@
   },
   {
    "cell_type": "code",
-   "execution_count": null,
+   "execution_count": 3,
    "metadata": {
     "collapsed": false
    },
@@ -67,7 +67,7 @@
   },
   {
    "cell_type": "code",
-   "execution_count": null,
+   "execution_count": 4,
    "metadata": {
     "collapsed": false
    },
@@ -100,6 +100,7 @@
    "source": [
     "import starsim as ss\n",
     "import pandas as pd\n",
+    "import matplotlib.pyplot as plt\n",
     "\n",
     "# Read in age-specific fertility rates\n",
     "fertility_rates = pd.read_csv('test_data/nigeria_asfr.csv')\n",
@@ -125,10 +126,9 @@
     "data = nigeria_popsize[(nigeria_popsize.year >= 1995) & (nigeria_popsize.year <= 2030)]\n",
     "\n",
     "# Plot the overall population size - simulated vs data\n",
-    "import matplotlib.pyplot as plt\n",
     "fig, ax = plt.subplots(1, 1)\n",
     "ax.scatter(data.year, data.n_alive, alpha=0.5, label='Data')\n",
-    "ax.plot(sim.yearvec, sim.results.n_alive, color='k', label='Model')\n",
+    "ax.plot(sim.timevec, sim.results.n_alive, color='k', label='Model')\n",
     "ax.legend()\n",
     "ax.set_title('Population')\n",
     "plt.show();"
diff --git a/docs/tutorials/tut_diseases.ipynb b/docs/tutorials/tut_diseases.ipynb
index 2ba4a8e5..6b9ec07b 100644
--- a/docs/tutorials/tut_diseases.ipynb
+++ b/docs/tutorials/tut_diseases.ipynb
@@ -60,7 +60,7 @@
    },
    "source": [
     "## Adding multiple diseases\n",
-    "You can add multiple diseases to the same simulation, like so. Here we are making use of a _connector_. A connector is a special thing in Starsim that tells you how two things relate to one another - in this case, how HIV modifies a person's transmissibility and susceptibility to gonorrhea and vice versa "
+    "You can add multiple diseases to the same simulation, like so. Here we are making use of a \"connector\". A connector is a module in Starsim that tells you how two things relate to one another - in this case, how HIV modifies a person's transmissibility and susceptibility to gonorrhea and vice versa. Unlike dieases, networks, interventions, etc., connectors don't have any pre-specified location in the sim. Instead, they can be placed wherever they make the most sense (for example, a connector that mediated how two networks behaved might be placed at the beginning or end of the list of networks; for diseases, it might be placed at the beginning or end of the list of diseases)."
    ]
   },
   {
@@ -76,14 +76,13 @@
    "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",
+    "class simple_hiv_ng(ss.Module):\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",
+    "    def __init__(self, pars=None, label='HIV-Gonorrhea', **kwargs):\n",
+    "        super().__init__()\n",
+    "        self.define_pars(\n",
     "            rel_trans_hiv  = 2,\n",
     "            rel_trans_aids = 5,\n",
     "            rel_sus_hiv    = 2,\n",
@@ -92,7 +91,7 @@
     "        self.update_pars(pars, **kwargs)\n",
     "        return\n",
     "\n",
-    "    def update(self):\n",
+    "    def step(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",
@@ -115,7 +114,7 @@
     "\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='mf', diseases=[hiv, ng], connectors=simple_hiv_ng())\n",
+    "sim = ss.Sim(n_agents=n_agents, networks='mf', diseases=[simple_hiv_ng(), 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 053294c9..aa038198 100644
--- a/docs/tutorials/tut_interventions.ipynb
+++ b/docs/tutorials/tut_interventions.ipynb
@@ -35,7 +35,7 @@
   },
   {
    "cell_type": "code",
-   "execution_count": null,
+   "execution_count": 2,
    "metadata": {},
    "outputs": [],
    "source": [
@@ -114,8 +114,8 @@
     "res_intv = sim_intv.results\n",
     "\n",
     "plt.figure()\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.plot(res_base.timevec, res_base.sir.prevalence, label='Baseline')\n",
+    "plt.plot(res_intv.timevec, res_intv.sir.prevalence, label='Vax')\n",
     "plt.axvline(x=2015, color='k', ls='--')\n",
     "plt.title('Prevalence')\n",
     "plt.legend()\n",
diff --git a/docs/tutorials/tut_intro.ipynb b/docs/tutorials/tut_intro.ipynb
index f26e4b41..daa57ad7 100644
--- a/docs/tutorials/tut_intro.ipynb
+++ b/docs/tutorials/tut_intro.ipynb
@@ -2,6 +2,7 @@
  "cells": [
   {
    "cell_type": "markdown",
+   "id": "dd65a1f9",
    "metadata": {},
    "source": [
     "# T1 - Getting started\n",
@@ -23,6 +24,7 @@
   },
   {
    "cell_type": "markdown",
+   "id": "2ae50b40",
    "metadata": {},
    "source": [
     "## Hello world\n",
@@ -33,6 +35,7 @@
   {
    "cell_type": "code",
    "execution_count": null,
+   "id": "c6b0dfc8",
    "metadata": {},
    "outputs": [],
    "source": [
@@ -40,15 +43,15 @@
     "\n",
     "# Define the parameters\n",
     "pars = dict(\n",
-    "    n_agents = 5_000,     # Number of agents to simulate\n",
+    "    n_agents = 10_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 = 10    # Each person has an average of 10 contacts with other people\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",
     "        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",
+    "        init_prev = 0.01, # Proportion of the population initially infected \n",
+    "        beta = 0.05,      # Probability of transmission between contacts\n",
     "    )\n",
     ")\n",
     "\n",
@@ -60,6 +63,7 @@
   },
   {
    "cell_type": "markdown",
+   "id": "02be91b3",
    "metadata": {},
    "source": [
     "As the tutorials progress we'll show how to extend this different diseases with different transmission pathways, and how to customize the model in lots of different ways."
diff --git a/docs/tutorials/tut_networks.ipynb b/docs/tutorials/tut_networks.ipynb
index 939134cb..6a75c8eb 100644
--- a/docs/tutorials/tut_networks.ipynb
+++ b/docs/tutorials/tut_networks.ipynb
@@ -22,7 +22,7 @@
   },
   {
    "cell_type": "code",
-   "execution_count": null,
+   "execution_count": 7,
    "metadata": {
     "tags": []
    },
@@ -54,7 +54,7 @@
     "network = ss.RandomNet()\n",
     "sir = ss.SIR()\n",
     "sim = ss.Sim(n_agents=2_000, diseases=sir, networks=network)\n",
-    "sim.initialize()"
+    "sim.init()"
    ]
   },
   {
@@ -71,7 +71,7 @@
   },
   {
    "cell_type": "code",
-   "execution_count": null,
+   "execution_count": 9,
    "metadata": {
     "collapsed": false
    },
@@ -88,7 +88,7 @@
     "    acts=80,        # Distribution of the number of coital acts/year\n",
     ")\n",
     "\n",
-    "pars = dict(dt=1/12, start=2000, n_years=20)\n",
+    "pars = dict(start=2000, dur=20, dt=1/12)\n",
     "sim = ss.Sim(pars=pars, diseases=syph, networks=mf)"
    ]
   },
@@ -110,7 +110,7 @@
   },
   {
    "cell_type": "code",
-   "execution_count": null,
+   "execution_count": 10,
    "metadata": {
     "collapsed": false
    },
@@ -143,7 +143,7 @@
   },
   {
    "cell_type": "code",
-   "execution_count": null,
+   "execution_count": 11,
    "metadata": {
     "collapsed": false
    },
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 00000000..d6121b44
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,64 @@
+[build-system]
+requires = ["setuptools", "setuptools-scm"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "starsim"
+dynamic = ["version"]
+description = "A fast, flexible agent-based disease modeling framework"
+readme = "README.rst"
+requires-python = ">=3.8"
+license = {file = "LICENSE.txt"}
+keywords = ["agent-based model", "simulation", "disease", "epidemiology"]
+
+authors = [
+  { name = "Starsim Development Team", email = "info@starsim.org" },
+  { name = "Cliff Kerr" },
+  { name = "Robyn Stuart" },
+  { name = "Romesh Abeysuriya" },
+  { name = "Paula Sanz-Leon" },
+  { name = "Jamie Cohen" },
+  { name = "Daniel Klein" }
+]
+
+classifiers = [
+  "Intended Audience :: Science/Research",
+  "License :: OSI Approved :: MIT License",
+  "Operating System :: OS Independent",
+  "Programming Language :: Python",
+  "Topic :: Software Development :: Libraries :: Python Modules",
+  "Development Status :: 5 - Production/Stable",
+  "Programming Language :: Python :: 3.9",
+  "Programming Language :: Python :: 3.10",
+  "Programming Language :: Python :: 3.11",
+  "Programming Language :: Python :: 3.12"
+]
+
+dependencies = [
+    "numpy",
+    "scipy",
+    "pandas>=2.0.0",
+    "sciris>=3.2.0",
+    "matplotlib",
+    "numba",
+    "networkx",
+    "optuna"
+]
+
+[project.optional-dependencies]
+fastmath = ["intel-cmplr-lib-rt"]
+test = ["pytest>=7.3", "pytest-cov", "pytest-env", "pytest-xdist"]
+
+[project.urls]
+"Website" = "https://starsim.org"
+"Source" = "https://github.com/starsimhub/starsim/"
+
+[tool.setuptools.packages.find]
+where = ["."]
+include = ["starsim*"]
+
+[tool.setuptools.package-data]
+mypkg = ["*.ipynb", "*.rst", "*.csv"]
+
+[tool.setuptools.dynamic]
+version = {attr = "starsim.version.__version__"}
\ No newline at end of file
diff --git a/setup.py b/setup.py
index 4d4ae2f9..d7b72bd5 100644
--- a/setup.py
+++ b/setup.py
@@ -1,49 +1,7 @@
-import os
-import runpy
-from setuptools import setup, find_packages
+"""
+Legacy support; see pyproject.toml for current information
+"""
 
-# Get version
-cwd = os.path.abspath(os.path.dirname(__file__))
-versionpath = os.path.join(cwd, 'starsim', 'version.py')
-version = runpy.run_path(versionpath)['__version__']
+import setuptools
 
-# Get the documentation
-with open(os.path.join(cwd, 'README.rst'), "r") as f:
-    long_description = f.read()
-
-CLASSIFIERS = [
-    "Environment :: Console",
-    "Intended Audience :: Science/Research",
-    "License :: Other/Proprietary License",
-    "Operating System :: OS Independent",
-    "Programming Language :: Python",
-    "Topic :: Software Development :: Libraries :: Python Modules",
-    "Development Status :: 5 - Production/Stable",
-    "Programming Language :: Python :: 3.9",
-    "Programming Language :: Python :: 3.10",
-    "Programming Language :: Python :: 3.11",
-    "Programming Language :: Python :: 3.12",
-]
-
-setup(
-    name="starsim",
-    version=version,
-    author="Robyn Stuart, Cliff Kerr, Romesh Abeysuriya, Paula Sanz-Leon, Jamie Cohen, and Daniel Klein on behalf of the Starsim Collective",
-    description="Starsim",
-    long_description=long_description,
-    long_description_content_type="text/x-rst",
-    keywords=["agent-based model", "simulation", "disease", "epidemiology"],
-    platforms=["OS Independent"],
-    classifiers=CLASSIFIERS,
-    packages=find_packages(),
-    include_package_data=True,
-    install_requires=[ # NB: remember to update __init__.py if these requirements change
-        'numpy',
-        'scipy',
-        'pandas>=2.0.0',
-        'sciris>=3.1.6',
-        'matplotlib',
-        'numba',
-        'networkx',
-    ],
-)
+setuptools.setup()
\ No newline at end of file
diff --git a/starsim/__init__.py b/starsim/__init__.py
index 19c32818..c8e5ff8a 100644
--- a/starsim/__init__.py
+++ b/starsim/__init__.py
@@ -1,12 +1,13 @@
 from .version import __version__, __versiondate__, __license__
 from .settings      import *
 from .utils         import *
+from .arrays        import *
+from .timepars      import *
 from .parameters    import *
 from .distributions import *
-from .states        import *
 from .people        import *
 from .modules       import *
-from .network       import *
+from .networks      import *
 from .results       import *
 from .demographics  import *
 from .products      import *
@@ -14,6 +15,7 @@
 from .demographics  import *
 from .disease       import *
 from .diseases      import *
+from .loop          import *
 from .sim           import *
 from .run           import *
 from .calibration   import *
@@ -28,7 +30,7 @@
     print(__license__)
 
 # Double-check key requirements -- should match setup.py
-reqs = ['sciris>=3.1.6', 'pandas>=2.0.0', 'scipy', 'numba', 'networkx']
+reqs = ['sciris>=3.2.0', 'pandas>=2.0.0', 'scipy', 'numba', 'networkx']
 msg = f'The following dependencies for Starsim {__version__} were not met: <MISSING>.'
 sc.require(reqs, message=msg)
 del sc, reqs, msg # Don't keep this in the module
\ No newline at end of file
diff --git a/starsim/states.py b/starsim/arrays.py
similarity index 70%
rename from starsim/states.py
rename to starsim/arrays.py
index c9c45d17..7c8bc5cf 100644
--- a/starsim/states.py
+++ b/starsim/arrays.py
@@ -1,65 +1,159 @@
 """
 Define array-handling classes, including agent states
 """
-
 import numpy as np
+import sciris as sc
 import starsim as ss
 
 # Shorten these for performance
 ss_float = ss.dtypes.float
 ss_int   = ss.dtypes.int
 ss_bool  = ss.dtypes.bool
+type_def = {
+    ss_float: ('float', float, np.float64, np.float32),
+    ss_int: ('int', int, np.int64, np.int32),
+    ss_bool: ('bool', bool, np.bool_),
+}
+type_map = {v:k for k,vlist in type_def.items() for v in vlist} # Invert into a full dictionary
+
+__all__ = ['BaseArr', 'Arr', 'FloatArr', 'BoolArr', 'State', 'IndexArr', 'uids']
 
-__all__ = ['check_dtype', 'Arr', 'FloatArr', 'BoolArr', 'IndexArr', 'uids']
 
+class BaseArr(np.lib.mixins.NDArrayOperatorsMixin):
+    """
+    An object that acts exactly like a NumPy array, except stores the values in self.values.
+    """
+    def __init__(self, values, *args, **kwargs):
+        self.values = np.array(values, *args, **kwargs)
+        return
 
-def check_dtype(dtype, default=None):
-    """ Check that the supplied dtype is one of the supported options """
-    
-    # Handle dtype
-    if dtype is None:
-        if default is None:
-            errormsg = 'Must supply either a dtype or a default value'
-            raise ValueError(errormsg)
+    def __getattr__(self, attr):
+        """ Make it behave like a regular array mostly -- enables things like sum(), mean(), etc. """
+        if attr in ['__deepcopy__', '__getstate__', '__setstate__']:
+            return self.__getattribute__(attr)
         else:
-            dtype = type(default)
-    
-    if dtype in ['float', float, np.float64, np.float32]:
-        dtype = ss_float
-    elif dtype in ['int', int, np.int64, np.int32]:
-        dtype = ss_int
-    elif dtype in ['bool', bool, np.bool_]:
-        dtype = ss_bool
-    else:
-        warnmsg = f'Data type {type(default)} not a supported data type; set warn=False to suppress warning'
-        ss.warn(warnmsg)
-    
-    return dtype
-
-
-class Arr(np.lib.mixins.NDArrayOperatorsMixin):
+            return object.__getattribute__(self, 'values').__getattribute__(attr) # Be explicit to avoid possible recurison
+
+    # Define more base methods
+    def __len__(self):   return self.values.__len__()
+    def __bool__(self):  return self.values.__bool__()
+    def __int__(self):   return self.values.__int__()
+    def __float__(self): return self.values.__float__()
+    def __contains__(self, key): return self.values.__contains__(key)
+
+    def convert(self, obj):
+        """ Check if an object is an array, and convert if so """
+        if isinstance(obj, np.ndarray):
+            return self.asnew(obj)
+        elif isinstance(obj, BaseArr):
+            return self.asnew(obj.values)
+        return obj
+
+    @staticmethod
+    def _arr(obj):
+        """ Helper function to efficiently extract values from a BaseArr, or return the original object """
+        if isinstance(obj, BaseArr):
+            return obj.values
+        return obj
+
+    def __array_ufunc__(self, ufunc, method, *inputs, **kwargs):
+        """ To handle all numpy operations, e.g. arr1*arr2 """
+        # Convert all inputs to their .values if they are BaseArr, otherwise leave unchanged
+        inputs = [self._arr(x) for x in inputs]
+        kwargs = {k:self._arr(v) for k,v in kwargs.items()}
+        result = getattr(ufunc, method)(*inputs, **kwargs)
+
+        # If result is a tuple (e.g., for divmod or ufuncs that return multiple values), convert all results to BaseArr
+        if isinstance(result, tuple):
+            return tuple(self.convert(x) for x in result)
+
+        result = self.convert(result)
+        return result
+
+    def __iter__(self):
+        """ For iterating correctly, e.g. for sum() """
+        return iter(self.values)
+
+    def __getitem__(self, index):
+        """ For indexing and slicing, e.g. arr[inds] """
+        return self.values[index]
+
+    def __setitem__(self, index, value):
+        """ Assign values, e.g. arr1[inds] = arr2 """
+        self.values[index] = value
+
+    def __array__(self, *args, **kwargs):
+        """ To ensure isinstance(arr, BaseArr) passes when creating new instances """
+        return np.array(self.values, *args, **kwargs)
+
+    def __repr__(self):
+        return f"{self.__class__.__name__}({self.values})"
+
+    def disp(self):
+        """ Full display of object """
+        return sc.pr(self)
+
+    def asnew(self, values=None, cls=None, **kwargs):
+        """ Duplicate and copy (rather than link) data """
+        if cls is None: # Use the current class if none is provided
+            cls = self.__class__
+        new = object.__new__(cls) # Create a new Arr instance
+        new.__dict__ = self.__dict__.copy() # Copy pointers
+        new.__dict__.update(kwargs) # Update any keyword arguments provided # TODO: add validation?
+        if values is not None:
+            new.values = values # Replace data with new data
+        else:
+            new.values = sc.cp(new.values) # TODO: is this needed?
+        return new
+
+    def update(self, skip_none=True, overwrite=True, force=False, **kwargs):
+        """ Update the attributes, skipping None values and raising an error if extra keys are added """
+        if skip_none: # Remove None inputs
+            kwargs = {k:v for k,v in kwargs.items() if v is not None}
+        if not overwrite: # Don't overwrite non-None values
+            kwargs = {k:v for k,v in kwargs.items() if getattr(self, k, None) is None}
+        if not force: # Check if we'd be creating any new keys
+            kw_keys = set(kwargs.keys())
+            self_keys = set(self.__dict__.keys())
+            diff = kw_keys - self_keys
+            if diff:
+                errormsg = f'Invalid arguments to {self}: {diff}'
+                raise ValueError(errormsg)
+        self.__dict__.update(kwargs) # Actually perform the update
+        return self
+
+    def to_json(self):
+        """ Return a dictionary representation of the Arr """
+        out = dict(
+            classname = self.__class__.__name__,
+            values = sc.jsonify(self.values),
+        )
+        return out
+
+
+class Arr(BaseArr):
     """
     Store a state of the agents (e.g. age, infection status, etc.) as an array.
-    
+
     In practice, ``Arr`` objects can be used interchangeably with NumPy arrays.
     They have two main data interfaces: ``Arr.raw`` contains the "raw", underlying
     NumPy array of the data. ``Arr.values`` contains the "active" values, which
     usually corresponds to agents who are alive.
-    
+
     By default, operations are performed on active agents only (specified by ``Arr.auids``,
     which is a pointer to ``sim.people.auids``). For example, ``sim.people.age.mean()``
     will only use the ages of active agents. Thus, ``sim.people.age.mean()``
     is equal to ``sim.people.age.values.mean()``, not ``sim.people.age.raw.mean()``.
-    
+
     If indexing by an int or slice, ``Arr.values`` is used. If indexing by an
     ``ss.uids`` object, ``Arr.raw`` is used. ``Arr`` objects can't be directly
     indexed by a list or array of ints, as this would be ambiguous about whether
-    ``values`` or ``raw`` is intended. For example, if there are 1000 people in a 
+    ``values`` or ``raw`` is intended. For example, if there are 1000 people in a
     simulation and 100 of them have died, ``sim.people.age[999]`` will return
     an ``IndexError`` (since ``sim.people.age[899]`` is the last active agent),
     whereas ``sim.people.age[ss.uids(999)]`` is valid.
 
-    Args: 
+    Args:
         name (str): The name for the state (also used as the dictionary key, so should not have spaces etc.)
         dtype (class): The dtype to use for this instance (if None, infer from value)
         default (any): Specify default value for new agents. This can be
@@ -68,14 +162,10 @@ class Arr(np.lib.mixins.NDArrayOperatorsMixin):
         - A ``ss.Dist`` instance
         nan (any): the value to use to represent NaN (not a number); also used as the default value if not supplied
         label (str): The human-readable name for the state
-        coerce (bool): Whether to ensure the the data is one of the supported data types
         skip_init (bool): Whether to skip initialization with the People object (used for uid and slot states)
         people (ss.People): Optionally specify an initialized People object, used to construct temporary Arr instances
     """
-    def __init__(self, name=None, dtype=None, default=None, nan=None, label=None, coerce=True, skip_init=False, people=None):
-        if coerce:
-            dtype = check_dtype(dtype, default)
-        
+    def __init__(self, name=None, dtype=None, default=None, nan=None, label=None, skip_init=False, people=None):
         # Set attributes
         self.name = name
         self.label = label or name
@@ -131,55 +221,28 @@ def _convert_key(self, key):
         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.'
             raise Exception(errormsg)
-    
+
     def __getitem__(self, key):
         key = self._convert_key(key)
         return self.raw[key]
-    
+
     def __setitem__(self, key, value):
         key = self._convert_key(key)
         self.raw[key] = value
         return
-            
-    def __getattr__(self, attr):
-        """ Make it behave like a regular array mostly -- enables things like sum(), mean(), etc. """
-        if attr in ['__deepcopy__', '__getstate__', '__setstate__']:
-            return self.__getattribute__(attr)
-        else:
-            return getattr(self.values, attr)
-        
+
     def __gt__(self, other): return self.asnew(self.values > other,  cls=BoolArr)
     def __lt__(self, other): return self.asnew(self.values < other,  cls=BoolArr)
     def __ge__(self, other): return self.asnew(self.values >= other, cls=BoolArr)
     def __le__(self, other): return self.asnew(self.values <= other, cls=BoolArr)
     def __eq__(self, other): return self.asnew(self.values == other, cls=BoolArr)
     def __ne__(self, other): return self.asnew(self.values != other, cls=BoolArr)
-    
+
     def __and__(self, other): raise BooleanOperationError(self)
     def __or__(self, other):  raise BooleanOperationError(self)
     def __xor__(self, other): raise BooleanOperationError(self)
     def __invert__(self):     raise BooleanOperationError(self)
 
-    def __array_ufunc__(self, *args, **kwargs):
-        if args[1] != '__call__':
-            # This is a catch-all for ufuncs that are not being applied with '__call__' (e.g., operations returning a scalar like 'np.sum()' use reduce instead)
-            args = [(x if x is not self else self.values) for x in args]
-            kwargs = {k: v if v is not self else self.values for k, v in kwargs.items()}
-            return self.values.__array_ufunc__(*args, **kwargs)
-        else:
-            args = [(x if x is not self else self.values) for x in args] # Convert any operands that are Arr instances to their value arrays
-            if 'out' in kwargs and kwargs['out'][0] is self:
-                # In-place operations like += applied to the entire Arr instance
-                # use this branch. Therefore, we perform our computation on a new
-                # array with the same size as self.values, and then write it back
-                # to the appropriate entries in `self.raw` via `self[:]`
-                del kwargs['out']
-                self[:] = args[0](*args[2:], **kwargs)
-                return self
-            else:
-                # Otherwise, just run the ufunc
-                return args[0](*args[2:], **kwargs)
-
     @property
     def auids(self):
         """ Link to the indices of active agents -- sim.people.auids """
@@ -189,7 +252,7 @@ def auids(self):
             if not self.initialized:
                 ss.warn('Trying to access non-initialized Arr object; in most cases, Arr objects need to be initialized with a Sim object, but set skip_init=True if this is intentional.')
             return uids(np.arange(len(self.raw)))
-    
+
     def count(self):
         return np.count_nonzero(self.values)
 
@@ -200,7 +263,7 @@ def values(self):
 
     def set(self, uids, new_vals=None):
         """ Set the values for the specified UIDs"""
-        if new_vals is None: 
+        if new_vals is None:
             if isinstance(self.default, ss.Dist):
                 new_vals = self.default.rvs(uids)
             elif callable(self.default):
@@ -211,7 +274,7 @@ def set(self, uids, new_vals=None):
                 new_vals = self.nan
         self.raw[uids] = new_vals
         return new_vals
-    
+
     def set_nan(self, uids):
         """ Shortcut function to set values to NaN """
         self.raw[uids] = self.nan
@@ -238,7 +301,7 @@ def grow(self, new_uids=None, new_vals=None):
         orig_len = self.len_used
         n_new = len(new_uids)
         self.len_used += n_new  # Increase the count of the number of agents by `n` (the requested number of new agents)
-        
+
         # Physically reshape the arrays, if needed
         if orig_len + n_new > self.len_tot:
             n_grow = max(n_new, self.len_tot//2)  # Minimum 50% growth, since growing arrays is slow
@@ -248,17 +311,17 @@ def grow(self, new_uids=None, new_vals=None):
             if n_grow > n_new: # We added extra space at the end, set to NaN
                 nan_uids = np.arange(self.len_used, self.len_tot)
                 self.set_nan(nan_uids)
-        
+
         # Set new values, and NaN if needed
         self.set(new_uids, new_vals=new_vals) # Assign new default values to those agents
         return
-    
+
     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 init_vals(self):
         """ Actually populate the initial values and mark as initialized; only to be used on initialization """
         if self.initialized:
@@ -290,17 +353,30 @@ def false(self):
         """ Reverse of true(); return UIDs of falsy values """
         return self.auids[~self.values.astype(bool)]
 
+    def to_json(self):
+        """ Export to JSON """
+        out = dict(
+            classname = self.__class__.__name__,
+            name = self.name,
+            label = self.label,
+            default = self.default,
+            nan = self.nan,
+            dtype = self.dtype,
+            values = sc.jsonify(self.values),
+        )
+        return out
+
 
 class FloatArr(Arr):
     """
     Subclass of Arr with defaults for floats and ints.
-    
+
     Note: Starsim does not support integer arrays by default since they introduce
     ambiguity in dealing with NaNs, and float arrays are suitable for most purposes.
-    If you really want an integer array, you can use the default Arr class instead.    
+    If you really want an integer array, you can use the default Arr class instead.
     """
     def __init__(self, name=None, nan=np.nan, **kwargs):
-        super().__init__(name=name, dtype=ss_float, nan=nan, coerce=False, **kwargs)
+        super().__init__(name=name, dtype=ss_float, nan=nan, **kwargs)
         return
 
     @property
@@ -312,7 +388,7 @@ def isnan(self):
     def notnan(self):
         """ Return BoolArr for non-NaN values """
         return self.asnew(~np.isnan(self.values), cls=BoolArr)
-    
+
     @property
     def notnanvals(self):
         """ Return values that are not-NaN """
@@ -324,9 +400,9 @@ def notnanvals(self):
 class BoolArr(Arr):
     """ Subclass of Arr with defaults for booleans """
     def __init__(self, name=None, nan=False, **kwargs): # No good NaN equivalent for bool arrays
-        super().__init__(name=name, dtype=ss_bool, nan=nan, coerce=False, **kwargs)
+        super().__init__(name=name, dtype=ss_bool, nan=nan, **kwargs)
         return
-    
+
     def __and__(self, other): return self.asnew(self.values & other)
     def __or__(self, other):  return self.asnew(self.values | other)
     def __xor__(self, other): return self.asnew(self.values ^ other)
@@ -352,20 +428,34 @@ def split(self):
         f_uids = self.false()
         return t_uids, f_uids
 
-    
+
+class State(BoolArr):
+    """
+    A boolean array being used as a state.
+
+    Although functionally identical to BoolArr, a State is handled differently in
+    terms of automation: specifically, results are automatically generated from a
+    State (but not a BoolArr).
+
+    States are typically used to keep track of externally-facing variables (e.g.
+    disease.susceptible), while BoolArrs can be used to keep track of internal
+    ones (e.g. disease.has_immunity).
+    """
+    pass
+
+
 class IndexArr(Arr):
     """ A special class of Arr used for UIDs and RNG IDs; not to be used as an integer array (for that, use FloatArr) """
     def __init__(self, name=None, label=None):
-        super().__init__(name=name, dtype=ss_int, default=None, nan=-1, label=label, coerce=False, skip_init=True)
+        super().__init__(name=name, dtype=ss_int, default=None, nan=-1, label=label, skip_init=True)
         self.raw = uids(self.raw)
         return
-    
+
     @property
     def uids(self):
         """ Alias to self.values, to allow Arr.uids like BoolArr """
         return self.values
 
-    
     def grow(self, new_uids=None, new_vals=None):
         """ Change the size of the array """
         if new_uids is None and new_vals is not None: # Used as a shortcut to avoid needing to supply twice
@@ -373,16 +463,16 @@ def grow(self, new_uids=None, new_vals=None):
         super().grow(new_uids=new_uids, new_vals=new_vals)
         self.raw = uids(self.raw)
         return
-    
-    
+
+
 class uids(np.ndarray):
     """
     Class to specify that integers should be interpreted as UIDs.
-    
+
     For all practical purposes, behaves like a NumPy integer array. However,
     has additional methods ``uids.concat()`` (instance method), ``ss.uids.cat()``
     (class method), ``uids.remove()``, and ``uids.intersect()`` to simplify common
-    UID operations.    
+    UID operations.
     """
     def __new__(cls, arr=None):
         if isinstance(arr, np.ndarray): # Shortcut to typical use case, where the input is an array
@@ -397,14 +487,16 @@ def __new__(cls, arr=None):
             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; see ss.uids.cat() for the class method """
+    def concat(self, other, **kw): # Class and instance methods can't share a name
+        """ Equivalent to np.concatenate(), but return correct type """
         return np.concatenate([self, other], **kw).view(self.__class__)
 
     @classmethod
     def cat(cls, *args, **kw):
-        """ Equivalent to np.concatenate(), but return correct type; see ss.uids.concat() for the instance method """
-        arrs = args[0] if len(args) == 1 else args
+        """ Equivalent to np.concatenate(), but return correct type """
+        if len(args) == 0 or (len(args) == 1 and (args[0] is None or not len(args[0]))):
+            return uids()
+        arrs = args[0] if len(args) == 1 else args # TODO: handle one-array case
         return np.concatenate(arrs, **kw).view(cls)
 
     def remove(self, other, **kw):
@@ -434,7 +526,7 @@ def xor(self, other, **kw):
     def to_numpy(self):
         """ Return a view as a standard NumPy array """
         return self.view(np.ndarray)
-    
+
     def unique(self, return_index=False):
         """ Return unique UIDs; equivalent to np.unique() """
         if return_index:
@@ -456,4 +548,4 @@ class BooleanOperationError(NotImplementedError):
     """ Raised when a logical operation is performed on a non-logical array """
     def __init__(self, arr):
         msg = f'Logical operations are only valid on Boolean arrays, not {arr.dtype}'
-        super().__init__(msg)
\ No newline at end of file
+        super().__init__(msg)
diff --git a/starsim/calibration.py b/starsim/calibration.py
index 677ce47b..5e17cb56 100644
--- a/starsim/calibration.py
+++ b/starsim/calibration.py
@@ -1,28 +1,20 @@
 """
 Define the calibration class
 """
-
 import os
 import numpy as np
 import pandas as pd
 import sciris as sc
+import optuna as op
+import matplotlib.pyplot as plt
 import starsim as ss
 
 
-__all__ = ['Calibration']
-
+__all__ = ['Calibration', 'compute_gof']
 
-def import_optuna():
-    """ A helper function to import Optuna, which is an optional dependency """
-    try:
-        import optuna as op # Import here since it's slow
-    except ModuleNotFoundError as E: # pragma: no cover
-        errormsg = f'Optuna import failed ({str(E)}), please install first (pip install optuna)'
-        raise ModuleNotFoundError(errormsg)
-    return op
 
-
-def compute_gof(actual, predicted, normalize=True, use_frac=False, use_squared=False, as_scalar='none', eps=1e-9, skestimator=None, estimator=None, **kwargs):
+def compute_gof(actual, predicted, normalize=True, use_frac=False, use_squared=False,
+                as_scalar='none', eps=1e-9, skestimator=None, estimator=None, **kwargs):
     """
     Calculate the goodness of fit. By default use normalized absolute error, but
     highly customizable. For example, mean squared error is equivalent to
@@ -65,14 +57,16 @@ def compute_gof(actual, predicted, normalize=True, use_frac=False, use_squared=F
             import sklearn.metrics as sm
             sklearn_gof = getattr(sm, skestimator) # Shortcut to e.g. sklearn.metrics.max_error
         except ImportError as E:
-            raise ImportError(f'You must have scikit-learn >=0.22.2 installed: {str(E)}')
-        except AttributeError:
-            raise AttributeError(f'Estimator {skestimator} is not available; see https://scikit-learn.org/stable/modules/model_evaluation.html#scoring-parameter for options')
+            errormsg = f'You must have scikit-learn >=0.22.2 installed: {str(E)}'
+            raise ImportError(errormsg) from E
+        except AttributeError as E:
+            errormsg = f'Estimator {skestimator} is not available; see https://scikit-learn.org/stable/modules/model_evaluation.html#scoring-parameter for options'
+            raise AttributeError(errormsg) from E
         gof = sklearn_gof(actual, predicted, **kwargs)
         return gof
 
     # Custom estimator is supplied: use that
-    if estimator is not None:
+    if estimator is not None: # pragma: no cover
         try:
             gof = estimator(actual, predicted, **kwargs)
         except Exception as E:
@@ -87,7 +81,7 @@ def compute_gof(actual, predicted, normalize=True, use_frac=False, use_squared=F
 
         if normalize and not use_frac:
             actual_max = abs(actual).max()
-            if actual_max>0:
+            if actual_max > 0:
                 gofs /= actual_max
 
         if use_frac:
@@ -110,77 +104,77 @@ def compute_gof(actual, predicted, normalize=True, use_frac=False, use_squared=F
         return gofs
 
 
-class Calibration(sc.prettyobj):
+class Calibration(sc.prettyobj): # pragma: no cover
     """
-    A class to handle calibration of STIsim simulations. Uses the Optuna hyperparameter
-    optimization library (optuna.org), which must be installed separately (via
-    pip install optuna).
+    A class to handle calibration of Starsim simulations. Uses the Optuna hyperparameter
+    optimization library (optuna.org).
+
     Args:
         sim          (Sim)  : the simulation to calibrate
-        data         (df)   : pandas dataframe
+        data         (df)   : pandas dataframe (or dataframe-compatible dict) of the data to calibrate to
         calib_pars   (dict) : a dictionary of the parameters to calibrate of the format dict(key1=[best, low, high])
-        fit_args     (dict) : a dictionary of options that are passed to sim.compute_fit() to calculate the goodness-of-fit
-        par_samplers (dict) : an optional mapping from parameters to the Optuna sampler to use for choosing new points for each; by default, suggest_float
         n_trials     (int)  : the number of trials per worker
-        n_workers    (int)  : the number of parallel workers (default: maximum
-        total_trials (int)  : if n_trials is not supplied, calculate by dividing this number by n_workers)
-        name         (str)  : the name of the database (default: 'hpvsim_calibration')
-        db_name      (str)  : the name of the database file (default: 'hpvsim_calibration.db')
+        n_workers    (int)  : the number of parallel workers (default: maximum number of available CPUs)
+        total_trials (int)  : if n_trials is not supplied, calculate by dividing this number by n_workers
+        reseed       (bool) : whether to generate new random seeds for each trial
+        weights      (dict) : the relative weights of each data source
+        fit_args     (dict) : a dictionary of options that are passed to sim.compute_fit() to calculate the goodness-of-fit
+        sep          (str)  : the separate between different types of results, e.g. 'hiv.deaths' vs 'hiv_deaths'
+        name         (str)  : the name of the database (default: 'starsim_calibration')
+        db_name      (str)  : the name of the database file (default: 'starsim_calibration.db')
         keep_db      (bool) : whether to keep the database after calibration (default: false)
         storage      (str)  : the location of the database (default: sqlite)
         rand_seed    (int)  : if provided, use this random seed to initialize Optuna runs (for reproducibility)
         label        (str)  : a label for this calibration object
         die          (bool) : whether to stop if an exception is encountered (default: false)
+        debug        (bool) : if True, do not run in parallel
         verbose      (bool) : whether to print details of the calibration
-        kwargs       (dict) : passed to hpv.Calibration()
 
     Returns:
         A Calibration object
     """
-    def __init__(self, sim, data, calib_pars=None, weights=None, fit_args=None, par_samplers=None, n_trials=None, n_workers=None,
-                total_trials=None, name=None, db_name=None, estimator=None, keep_db=None, storage=None, rand_seed=None,
-                 sampler=None, label=None, die=False, verbose=True):
-
-        import multiprocessing as mp
+    def __init__(self, sim, data, calib_pars, n_trials=None, n_workers=None, total_trials=None, reseed=True,
+                 weights=None, fit_args=None, sep='.', name=None, db_name=None, keep_db=None, storage=None,
+                 rand_seed=None, sampler=None, label=None, die=False, debug=False, verbose=True):
 
         # Handle run arguments
         if n_trials  is None: n_trials  = 20
-        if n_workers is None: n_workers = mp.cpu_count()
+        if n_workers is None: n_workers = sc.cpu_count()
         if name      is None: name      = 'starsim_calibration'
         if db_name   is None: db_name   = f'{name}.db'
         if keep_db   is None: keep_db   = False
         if storage   is None: storage   = f'sqlite:///{db_name}'
         if total_trials is not None: n_trials = int(np.ceil(total_trials/n_workers))
-        self.run_args   = sc.objdict(n_trials=int(n_trials), n_workers=int(n_workers), name=name, db_name=db_name,
-                                     keep_db=keep_db, storage=storage, rand_seed=rand_seed, sampler=sampler)
+        kw = dict(n_trials=int(n_trials), n_workers=int(n_workers), debug=debug, name=name, db_name=db_name,
+                  keep_db=keep_db, storage=storage, rand_seed=rand_seed, sampler=sampler)
+        self.run_args = sc.objdict(kw)
 
         # Handle other inputs
-        self.label          = label
-        self.sim            = sim
-        self.calib_pars     = calib_pars
-        self.weights        = weights
-        self.fit_args       = sc.mergedicts(fit_args)
-        self.par_samplers   = sc.mergedicts(par_samplers)
-        self.die            = die
-        self.verbose        = verbose
-        self.calibrated     = False
-
-        # Load data -- this is expecting a dataframe with a column for 'year' and other columns for to sim results
-        if not isinstance(data, pd.DataFrame):
-            errormsg = 'Please pass data as a pandas dataframe'
-            raise ValueError(errormsg)
-        self.target_data = data
-        self.target_data.set_index('year', inplace=True)
+        self.label      = label
+        self.sim        = sim
+        self.calib_pars = calib_pars
+        self.reseed     = reseed
+        self.sep        = sep
+        self.weights    = sc.mergedicts(weights)
+        self.fit_args   = sc.mergedicts(fit_args)
+        self.die        = die
+        self.verbose    = verbose
+        self.calibrated = False
+        self.before_sim = None
+        self.after_sim  = None
+
+        # Load data -- this is expecting a dataframe with a column for 'time' and other columns for to sim results
+        self.data = ss.validate_sim_data(data, die=True)
 
         # Temporarily store a filename
         self.tmp_filename = 'tmp_calibration_%05i.obj'
 
         # Initialize sim
         if not self.sim.initialized:
-            self.sim.initialize()
+            self.sim.init()
 
         # Figure out which sim results to get
-        self.sim_result_list = self.target_data.columns.values.tolist()
+        self.sim_result_list = self.data.cols
 
         return
 
@@ -207,107 +201,125 @@ def run_sim(self, calib_pars=None, label=None):
     @staticmethod
     def translate_pars(sim=None, calib_pars=None):
         """ Take the nested dict of calibration pars and modify the sim """
-        for modtype in calib_pars.keys():
-            for dkey, dpars in calib_pars[modtype].items():
-                for dparkey, dparval in dpars.items():
-                    targetpar = sim[modtype][dkey].pars[dparkey]
-                    if sc.isnumber(targetpar):
-                        sim[modtype][dkey].pars[dparkey] = dparval
-                    elif isinstance(targetpar, ss.Dist):
-                        sim[modtype][dkey].pars[dparkey].set(dparval)
-                    else:
-                        errormsg = 'Type not implemented'
-                        raise ValueError(errormsg)
+
+        if 'rand_seed' in calib_pars:
+            sim.pars['rand_seed'] = calib_pars['rand_seed']
+
+        for parname, spec in calib_pars.items():
+            if parname == 'rand_seed':
+                continue
+
+            if 'path' not in spec:
+                raise ValueError(f'Cannot map {parname} because "path" is missing from the parameter configuration.')
+
+            p = spec['path']
+
+            if len(p) != 3:
+                raise ValueError(f'Cannot map {parname} because "path" must be a tuple of length 3.')
+
+            modtype = p[0]
+            dkey = p[1]
+            dparkey = p[2]
+            dparval = spec['value']
+            targetpar = sim[modtype][dkey].pars[dparkey]
+
+            if sc.isnumber(targetpar):
+                sim[modtype][dkey].pars[dparkey] = dparval
+            elif isinstance(targetpar, ss.Dist):
+                sim[modtype][dkey].pars[dparkey].set(dparval)
+            else:
+                errormsg = 'Type not implemented'
+                raise ValueError(errormsg)
 
         return sim
 
     def trial_to_sim_pars(self, pardict=None, trial=None):
         """
         Take in an optuna trial and sample from pars, after extracting them from the structure they're provided in
+
         Different use cases:
             - pardict is self.calib_pars, i.e. {'diseases':{'hiv':{'art_efficacy':[0.96, 0.9, 0.99]}}}, need to sample
             - pardict is self.initial_pars, i.e. {'diseases':{'hiv':{'art_efficacy':[0.96, 0.9, 0.99]}}}, pull 1st vals
             - pardict is self.best_pars, i.e. {'diseases':{'hiv':{'art_efficacy':0.96786}}}, pull single vals
         """
         pars = sc.dcp(pardict)
-        flattened_inputs = sc.flattendict(pars)
-
-        structured_outputs = sc.dcp(self.calib_pars)
-        flattened_outputs = sc.flattendict(structured_outputs)
-
-        for key in flattened_outputs.keys():
-            sampler_key = '_'.join(key)
-
-            if key in flattened_inputs.keys():
-                val = flattened_inputs[key]
-            else:
-                val = pars[sampler_key]
+        for parname, spec in pars.items():
 
-            if sc.isnumber(val) and trial is None:
-                sc.setnested(structured_outputs, list(key), val)
+            if 'value' in spec:
+                # Already have a value, likely running initial or final values as part of checking the fit
+                continue
 
+            if 'sampler' in spec:
+                sampler = spec.pop('sampler')
+                sampler_fn = getattr(trial, sampler)
             else:
-                low, high = val[1], val[2]
-                step = val[3] if len(val) > 3 else None
+                sampler_fn = trial.suggest_float
 
-                if trial is not None:
-                    if key in self.par_samplers:  # If a custom sampler is used, get it now (Not working properly for now)
-                        try:
-                            sampler_fn = getattr(trial, self.par_samplers[key])
-                        except Exception as E:
-                            errormsg = 'The requested sampler function is not found: ensure it is a valid attribute of an Optuna Trial object'
-                            raise AttributeError(errormsg) from E
-                    else:
-                        sampler_fn = trial.suggest_float
-                    sc.setnested(structured_outputs, list(key), sampler_fn(sampler_key, low, high, step=step))
-                else:
-                    sc.setnested(structured_outputs, list(key), val[0])
+            path = spec.pop('path', None) # remove path
+            guess = spec.pop('guess', None) # remove guess
+            spec['value'] = sampler_fn(name=parname, **spec) # Sample!
+            spec['path'] = path
+            spec['guess'] = guess
 
-        return structured_outputs
+        return pars
 
-    def run_trial(self, trial, save=True):
+    @staticmethod
+    def sim_to_df(sim): # TODO: remove this method
+        """ Convert a sim to the expected dataframe type """
+        df_res = sim.to_df(sep='.')
+        df_res['t'] = df_res['timevec']
+        df_res = df_res.set_index('t')
+        df_res['time'] = np.floor(np.round(df_res.index, 1)).astype(int)
+        return df_res
+
+    def run_trial(self, trial, save=False):
         """ Define the objective for Optuna """
         if self.calib_pars is not None:
             calib_pars = self.trial_to_sim_pars(self.calib_pars, trial)
         else:
             calib_pars = None
+
+        if self.reseed:
+            calib_pars['rand_seed'] = trial.suggest_int('rand_seed', 0, 1_000_000) # Choose a random rand_seed
+
         sim = self.run_sim(calib_pars)
 
-        # Export results
-        df_res = sim.export_df()
-        df_res['year'] = np.floor(np.round(df_res.index, 1)).astype(int)
+        # Export results # TODO: make more robust
+        df_res = self.sim_to_df(sim)
         sim_results = sc.objdict()
 
         for skey in self.sim_result_list:
             if 'prevalence' in skey:
-                model_output = df_res.groupby(by='year')[skey].mean()
+                model_output = df_res.groupby(by='time')[skey].mean()
             else:
-                model_output = df_res.groupby(by='year')[skey].sum()
+                model_output = df_res.groupby(by='time')[skey].sum()
             sim_results[skey] = model_output.values
 
-        sim_results['year'] = model_output.index.values
+        sim_results['time'] = model_output.index.values
         # Store results in temporary files
         if save:
             filename = self.tmp_filename % trial.number
             sc.save(filename, sim_results)
 
         # Compute fit
-        fit = self.compute_fit(sim)
+        fit = self.compute_fit(df_res=df_res)
         return fit
 
-    def compute_fit(self, sim):
+    def compute_fit(self, sim=None, df_res=None):
         """ Compute goodness-of-fit """
         fit = 0
-        df_res = sim.export_df()
-        df_res['year'] = np.floor(np.round(df_res.index, 1)).astype(int)
+
+        # TODO: reduce duplication with above
+        if df_res is None:
+            df_res = self.sim_to_df(sim)
         for skey in self.sim_result_list:
             if 'prevalence' in skey:
-                model_output = df_res.groupby(by='year')[skey].mean()
+                model_output = df_res.groupby(by='time')[skey].mean()
             else:
-                model_output = df_res.groupby(by='year')[skey].sum()
+                model_output = df_res.groupby(by='time')[skey].sum()
 
-            data = self.target_data[skey]
-            combined = pd.merge(data, model_output, how='left', on='year')
+            data = self.data[skey]
+            combined = pd.merge(data, model_output, how='left', on='time')
             combined['diffs'] = combined[skey+'_x'] - combined[skey+'_y']
             gofs = compute_gof(combined.dropna()[skey+'_x'], combined.dropna()[skey+'_y'])
 
@@ -319,7 +331,6 @@ def compute_fit(self, sim):
 
     def worker(self):
         """ Run a single worker """
-        op = import_optuna()
         if self.verbose:
             op.logging.set_verbosity(op.logging.DEBUG)
         else:
@@ -330,37 +341,32 @@ def worker(self):
 
     def run_workers(self):
         """ Run multiple workers in parallel """
-        if self.run_args.n_workers > 1: # Normal use case: run in parallel
+        if self.run_args.n_workers > 1 and not self.run_args.debug: # Normal use case: run in parallel
             output = sc.parallelize(self.worker, iterarg=self.run_args.n_workers)
         else: # Special case: just run one
             output = [self.worker()]
         return output
 
     def remove_db(self):
-        """
-        Remove the database file if keep_db is false and the path exists.
-        """
+        """ Remove the database file if keep_db is false and the path exists """
         try:
             if 'sqlite' in self.run_args.storage:
                 # Delete the file from disk
                 if os.path.exists(self.run_args.db_name):
                     os.remove(self.run_args.db_name)
-                if self.verbose:
-                    print(f'Removed existing calibration file {self.run_args.db_name}')
+                if self.verbose: print(f'Removed existing calibration file {self.run_args.db_name}')
             else:
                 # Delete the study from the database e.g., mysql
-                op = import_optuna()
                 op.delete_study(study_name=self.run_args.name, storage=self.run_args.storage)
-                if self.verbose:
-                    print(f'Deleted study {self.run_args.name} in {self.run_args.storage}')
+                if self.verbose: print(f'Deleted study {self.run_args.name} in {self.run_args.storage}')
         except Exception as E:
-            print('Could not delete study, skipping...')
-            print(str(E))
+            if self.verbose:
+                print('Could not delete study, skipping...')
+                print(str(E))
         return
 
     def make_study(self):
         """ Make a study, deleting one if it already exists """
-        op = import_optuna()
         if not self.run_args.keep_db:
             self.remove_db()
         if self.run_args.rand_seed is not None:
@@ -369,13 +375,14 @@ def make_study(self):
             raise NotImplementedError('Implemented but does not work')
         else:
             sampler = None
-        print(self.run_args.storage)
+        if self.verbose: print(self.run_args.storage)
         output = op.create_study(storage=self.run_args.storage, study_name=self.run_args.name, sampler=sampler)
         return output
 
-    def calibrate(self, calib_pars=None, confirm_fit=False, load=True, tidyup=True, **kwargs):
+    def calibrate(self, calib_pars=None, confirm_fit=False, load=False, tidyup=True, **kwargs):
         """
         Perform calibration.
+
         Args:
             calib_pars (dict): if supplied, overwrite stored calib_pars
             confirm_fit (bool): if True, run simulations with parameters from before and after calibration
@@ -384,8 +391,6 @@ def calibrate(self, calib_pars=None, confirm_fit=False, load=True, tidyup=True,
             verbose (bool): whether to print output from each trial
             kwargs (dict): if supplied, overwrite stored run_args (n_trials, n_workers, etc.)
         """
-        op = import_optuna()
-
         # Load and validate calibration parameters
         if calib_pars is not None:
             self.calib_pars = calib_pars
@@ -401,7 +406,7 @@ def calibrate(self, calib_pars=None, confirm_fit=False, load=True, tidyup=True,
 
         self.sim_results = []
         if load:
-            print('Loading saved results...')
+            if self.verbose: print('Loading saved results...')
             for trial in study.trials:
                 n = trial.number
                 try:
@@ -411,38 +416,60 @@ def calibrate(self, calib_pars=None, confirm_fit=False, load=True, tidyup=True,
                     if tidyup:
                         try:
                             os.remove(filename)
-                            print(f'    Removed temporary file {filename}')
+                            if self.verbose: print(f'    Removed temporary file {filename}')
                         except Exception as E:
                             errormsg = f'Could not remove {filename}: {str(E)}'
-                            print(errormsg)
-                    print(f'  Loaded trial {n}')
+                            if self.verbose: print(errormsg)
+                    if self.verbose: print(f'  Loaded trial {n}')
                 except Exception as E:
                     errormsg = f'Warning, could not load trial {n}: {str(E)}'
-                    print(errormsg)
+                    if self.verbose: print(errormsg)
 
         # Compare the results
-        self.initial_pars = self.trial_to_sim_pars(pardict=self.calib_pars)
         self.parse_study(study)
 
+        if self.verbose: print('Best pars:', self.best_pars)
+
         # Tidy up
         self.calibrated = True
         if not self.run_args.keep_db:
             self.remove_db()
-        
+
         # Optionally compute the sims before and after the fit
         if confirm_fit:
             self.confirm_fit()
 
         return self
-    
+
     def confirm_fit(self):
         """ Run before and after simulations to validate the fit """
-        before_pars = self.initial_pars
-        after_pars  = self.trial_to_sim_pars(pardict=self.best_pars)
+
+        if self.verbose: print('\nConfirming fit...')
+
+        before_pars = sc.dcp(self.calib_pars)
+        for spec in before_pars.values():
+            spec['value'] = spec['guess'] # Use guess values
+
+        after_pars = sc.dcp(self.calib_pars)
+        for parname, spec in after_pars.items():
+            spec['value'] = self.best_pars[parname]
+
         self.before_sim = self.run_sim(calib_pars=before_pars, label='Before calibration')
         self.after_sim  = self.run_sim(calib_pars=after_pars, label='After calibration')
         self.before_fit = self.compute_fit(self.before_sim)
         self.after_fit  = self.compute_fit(self.after_sim)
+
+        # Add the data to the sims
+        for sim in [self.before_sim, self.after_sim]:
+            sim.init_data(self.data)
+
+        print(f'Fit with original pars: {self.before_fit:n}')
+        print(f'Fit with best-fit pars: {self.after_fit:n}')
+        if self.after_fit <= self.before_fit:
+            print('✓ Calibration improved fit')
+        else:
+            print('✗ Calibration did not improve fit, but this sometimes happens stochastically and is not necessarily an error')
+
         return self.before_fit, self.after_fit
 
     def parse_study(self, study):
@@ -450,7 +477,7 @@ def parse_study(self, study):
         best = study.best_params
         self.best_pars = best
 
-        print('Making results structure...')
+        if self.verbose: print('Making results structure...')
         results = []
         n_trials = len(study.trials)
         failed_trials = []
@@ -462,7 +489,7 @@ def parse_study(self, study):
                 failed_trials.append(data['index'])
             else:
                 results.append(data)
-        print(f'Processed {n_trials} trials; {len(failed_trials)} failed')
+        if self.verbose: print(f'Processed {n_trials} trials; {len(failed_trials)} failed')
 
         keys = ['index', 'mismatch'] + list(best.keys())
         data = sc.objdict().make(keys=keys, vals=[])
@@ -473,8 +500,8 @@ def parse_study(self, study):
                     print(warnmsg)
                     r[key] = best[key]
                 data[key].append(r[key])
-        self.data = data
-        self.df = pd.DataFrame.from_dict(data)
+        self.study_data = data
+        self.df = sc.dataframe.from_dict(data)
         self.df = self.df.sort_values(by=['mismatch']) # Sort
         return
 
@@ -494,3 +521,52 @@ def to_json(self, filename=None, indent=2, **kwargs):
         else:
             return json
 
+    def plot_sims(self, **kwargs):
+        """
+        Plot sims, before and after calibration.
+
+        Args:
+            kwargs (dict): passed to MultiSim.plot()
+        """
+        if self.before_sim is None:
+            self.comfirm_fit()
+        msim = ss.MultiSim([self.before_sim, self.after_sim])
+        fig = msim.plot(**kwargs)
+        return fig
+
+    def plot_trend(self, best_thresh=None, fig_kw=None):
+        """
+        Plot the trend in best mismatch over time.
+
+        Args:
+            best_thresh (int): Define the threshold for the "best" fits, relative to the lowest mismatch value (if None, show all)
+            fig_kw (dict): passed to plt.figure()
+        """
+        df = self.df.sort_values('index') # Make a copy of the dataframe, sorted by trial number
+        mismatch = sc.dcp(df['mismatch'].values)
+        best_mismatch = np.zeros(len(mismatch))
+        for i in range(len(mismatch)):
+            best_mismatch[i] = mismatch[:i+1].min()
+        smoothed_mismatch = sc.smooth(mismatch)
+        fig = plt.figure(**sc.mergedicts(fig_kw))
+
+        ax1 = plt.subplot(2,1,1)
+        plt.plot(mismatch, alpha=0.2, label='Original')
+        plt.plot(smoothed_mismatch, lw=3, label='Smoothed')
+        plt.plot(best_mismatch, lw=3, label='Best')
+
+        ax2 = plt.subplot(2,1,2)
+        max_mismatch = mismatch.min()*best_thresh if best_thresh is not None else np.inf
+        inds = sc.findinds(mismatch<=max_mismatch)
+        plt.plot(best_mismatch, lw=3, label='Best')
+        plt.scatter(inds, mismatch[inds], c=mismatch[inds], label='Trials')
+        for ax in [ax1, ax2]:
+            plt.sca(ax)
+            plt.grid(True)
+            plt.legend()
+            sc.setylim()
+            sc.setxlim()
+            plt.xlabel('Trial number')
+            plt.ylabel('Mismatch')
+        sc.figlayout()
+        return fig
\ No newline at end of file
diff --git a/starsim/demographics.py b/starsim/demographics.py
index 2c71de9c..db0357d2 100644
--- a/starsim/demographics.py
+++ b/starsim/demographics.py
@@ -1,7 +1,6 @@
 """
 Define pregnancy, deaths, migration, etc.
 """
-
 import numpy as np
 import starsim as ss
 import sciris as sc
@@ -19,26 +18,14 @@ class Demographics(ss.Module):
     place at the start of the timestep, before networks are updated and before
     any disease modules are executed.
     """
-    def init_pre(self, sim):
-        super().init_pre(sim)
-        self.init_results()
-        return
-
-    def init_results(self):
-        pass
-
-    def update(self):
-        pass
-
-    def update_results(self):
-        pass
+    pass
 
 
 class Births(Demographics):
     """ Create births based on rates, rather than based on pregnancy """
     def __init__(self, pars=None, metadata=None, **kwargs):
         super().__init__()
-        self.default_pars(
+        self.define_pars(
             birth_rate = 30,
             rel_birth = 1,
             units = 1e-3,  # assumes birth rates are per 1000. If using percentages, switch this to 1
@@ -63,7 +50,7 @@ def init_pre(self, sim):
         if isinstance(self.pars.birth_rate, pd.DataFrame):
             br_year = self.pars.birth_rate[self.metadata.data_cols['year']]
             br_val = self.pars.birth_rate[self.metadata.data_cols['cbr']]
-            all_birth_rates = np.interp(sim.yearvec, br_year, br_val)
+            all_birth_rates = np.interp(self.timevec, br_year, br_val) # This assumes a year timestep -- probably ok?
             self.pars.birth_rate = all_birth_rates
         return
 
@@ -75,19 +62,14 @@ def standardize_birth_data(self):
         return birth_rate
 
     def init_results(self):
-        npts = self.sim.npts
-        self.results += [
-            ss.Result(self.name, 'new', npts, dtype=int, scale=True, label='New births'),
-            ss.Result(self.name, 'cumulative', npts, dtype=int, scale=True, label='Cumulative births'),
-            ss.Result(self.name, 'cbr', npts, dtype=int, scale=False, label='Crude birth rate'),
-        ]
+        super().init_results()
+        self.define_results(
+            ss.Result('new',        dtype=int,   scale=True,  label='New births'),
+            ss.Result('cumulative', dtype=int,   scale=True,  label='Cumulative births'),
+            ss.Result('cbr',        dtype=float, scale=False, label='Crude birth rate'),
+        )
         return
 
-    def update(self):
-        new_uids = self.add_births()
-        self.n_births = len(new_uids)
-        return new_uids
-
     def get_births(self):
         """
         Extract the right birth rates to use and translate it into a number of people to add.
@@ -97,7 +79,7 @@ def get_births(self):
 
         if isinstance(p.birth_rate, (pd.Series, pd.DataFrame)):
             available_years = p.birth_rate.index
-            year_ind = sc.findnearest(available_years, sim.year)
+            year_ind = sc.findnearest(available_years, sim.now) # TODO: make robust to timestep
             nearest_year = available_years[year_ind]
             this_birth_rate = p.birth_rate.loc[nearest_year]
         else:
@@ -108,6 +90,11 @@ def get_births(self):
         n_new = int(np.floor(sim.people.alive.count() * scaled_birth_prob))
         return n_new
 
+    def step(self):
+        new_uids = self.add_births()
+        self.n_births = len(new_uids)
+        return new_uids
+
     def add_births(self):
         """ Add n_new births to each state in the sim """
         people = self.sim.people
@@ -117,14 +104,19 @@ def add_births(self):
         return new_uids
 
     def update_results(self):
-        self.results['new'][self.sim.ti] = self.n_births
+        # New births -- already calculated
+        self.results.new[self.ti] = self.n_births
+
+        # Calculate crude birth rate (CBR)
+        inv_units = 1.0/self.pars.units
+        births_per_year = self.n_births/self.sim.dt_year
+        denom = self.sim.results.n_alive[self.sim.ti]
+        self.results.cbr[self.ti] = inv_units*births_per_year/denom
         return
 
     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)
+        self.results.cumulative[:] = np.cumsum(self.results.new)
         return
 
 
@@ -158,7 +150,7 @@ def __init__(self, pars=None, metadata=None, **kwargs):
                 corresponding column name in data. Similar for "sex_keys". Finally,
         """
         super().__init__()
-        self.default_pars(
+        self.define_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
@@ -180,7 +172,7 @@ def __init__(self, pars=None, metadata=None, **kwargs):
         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)
@@ -205,7 +197,7 @@ def make_death_prob_fn(self, sim, uids):
             assert len(uids) == len(ppl.auids)
 
             available_years = drd.index.get_level_values('year')
-            year_ind = sc.findnearest(available_years, sim.year)
+            year_ind = sc.findnearest(available_years, sim.now) # TODO: make work with different timesteps
             nearest_year = available_years[year_ind]
 
             death_rate = np.empty(uids.shape, dtype=ss_float_)
@@ -229,33 +221,30 @@ def make_death_prob_fn(self, sim, uids):
         return death_prob
 
     def init_results(self):
-        npts = self.sim.npts
-        self.results += [
-            ss.Result(self.name, 'new', npts, dtype=int, scale=True, label='Deaths'),
-            ss.Result(self.name, 'cumulative', npts, dtype=int, scale=True, label='Cumulative deaths'),
-            ss.Result(self.name, 'cmr', npts, dtype=int, scale=False, label='Crude mortality rate'),
-        ]
-        return
-
-    def update(self):
-        self.n_deaths = self.apply_deaths()
+        super().init_results()
+        self.define_results(
+            ss.Result('new',        dtype=int,   scale=True,  label='Deaths'),
+            ss.Result('cumulative', dtype=int,   scale=True,  label='Cumulative deaths'),
+            ss.Result('cmr',        dtype=float, scale=False, label='Crude mortality rate'),
+        )
         return
 
-    def apply_deaths(self):
+    def step(self):
         """ Select people to die """
         death_uids = self.pars.death_rate.filter()
         self.sim.people.request_death(death_uids)
-        return len(death_uids)
+        self.n_deaths = len(death_uids)
+        return self.n_deaths
 
     def update_results(self):
-        self.results['new'][self.sim.ti] = self.n_deaths
+        self.results['new'][self.ti] = self.n_deaths
         return
 
     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)
+        self.results.cumulative[:] = np.cumsum(self.results.new)
+        self.results.cmr[:] = 1/self.pars.units*np.divide(self.results.new / self.sim.dt_year, n_alive, where=n_alive>0)
         return
 
 
@@ -263,27 +252,29 @@ class Pregnancy(Demographics):
     """ Create births via pregnancies """
     def __init__(self, pars=None, metadata=None, **kwargs):
         super().__init__()
-        self.default_pars(
-            dur_pregnancy = 0.75, # Duration for pre-natal transmission
-            dur_postpartum = ss.lognorm_ex(0.5, 0.5), # Duration for post-natal transmission (e.g. via breastfeeding)
+        self.define_pars(
+            dur_pregnancy = ss.years(0.75), # Duration for pre-natal transmission
+            dur_postpartum = ss.lognorm_ex(mean=ss.years(0.5), std=ss.years(0.5)), # Duration for post-natal transmission (e.g. via breastfeeding)
             fertility_rate = 0, # Can be a number of Pandas DataFrame
             rel_fertility = 1,
-            maternal_death_prob = ss.bernoulli(0),
+            p_maternal_death = ss.bernoulli(0),
+            p_neonatal_death = ss.bernoulli(0),
             sex_ratio = ss.bernoulli(0.5), # Ratio of babies born female
             min_age = 15, # Minimum age to become pregnant
             max_age = 50, # Maximum age to become pregnant
             units = 1e-3, # Assumes fertility rates are per 1000. If using percentages, switch this to 1
+            burnin = True, # Should we seed pregnancies that would have happened before the start of the simulation?
         )
         self.update_pars(pars, **kwargs)
 
         self.pars.p_fertility = ss.bernoulli(p=0) # Placeholder, see make_fertility_prob_fn
-        
+
         # Other, e.g. postpartum, on contraception...
-        self.add_states(
-            ss.BoolArr('infertile', label='Infertile'),  # Applies to girls and women outside the fertility window
-            ss.BoolArr('fecund', default=True, label='Female of childbearing age'),
-            ss.BoolArr('pregnant', label='Pregnant'),  # Currently pregnant
-            ss.BoolArr('postpartum', label="Post-partum"),  # Currently post-partum
+        self.define_states(
+            ss.State('fecund', default=True, label='Female of childbearing age'),
+            ss.State('pregnant', label='Pregnant'),  # Currently pregnant
+            ss.State('postpartum', label="Post-partum"),  # Currently post-partum
+            ss.FloatArr('child_uid', label='UID of children, from embryo through postpartum'),
             ss.FloatArr('dur_postpartum', label='Post-partum duration'),  # Duration of postpartum phase
             ss.FloatArr('ti_pregnant', label='Time of pregnancy'),  # Time pregnancy begins
             ss.FloatArr('ti_delivery', label='Time of delivery'),  # Time of delivery
@@ -296,7 +287,7 @@ def __init__(self, pars=None, metadata=None, **kwargs):
             sc.objdict(data_cols=dict(year='Time', age='AgeGrp', value='ASFR')),
             metadata,
         )
-        self.choose_slots = None # Distribution for choosing slots; set in self.initialize()
+        self.choose_slots = None # Distribution for choosing slots; set in self.init()
 
         # For results tracking
         self.n_pregnancies = 0
@@ -315,7 +306,7 @@ def make_fertility_prob_fn(self, sim, uids):
         if sc.isnumber(frd):
             fertility_rate[uids] = self.fertility_rate_data
         else:
-            year_ind = sc.findnearest(frd.index, sim.year-self.pars.dur_pregnancy)
+            year_ind = sc.findnearest(frd.index, sim.now-self.pars.dur_pregnancy) # TODO: make time-unit-aware
             nearest_year = frd.index[year_ind]
 
             # Assign agents to age bins
@@ -323,19 +314,19 @@ def make_fertility_prob_fn(self, sim, uids):
             age_bin_all = np.digitize(age, age_bins) - 1
             new_rate = self.fertility_rate_data.loc[nearest_year].values.copy()  # Initialize array with new rates
 
-            if self.pregnant.any():
-                # Scale the new rate to convert the denominator from all women to non-pregnant women
+            if (~self.fecund).any():
+                # Scale the new rate to convert the denominator from all women to fecund women
                 v, c = np.unique(age_bin_all, return_counts=True)
                 age_counts = np.zeros(len(age_bins))
                 age_counts[v] = c
 
-                age_bin_pw = np.digitize(sim.people.age[self.pregnant], age_bins) - 1
-                v, c = np.unique(age_bin_pw, return_counts=True)
-                pregnant_age_counts = np.zeros(len(age_bins))
-                pregnant_age_counts[v] = c
+                age_bin_infecund = np.digitize(sim.people.age[~self.fecund], age_bins) - 1
+                v, c = np.unique(age_bin_infecund, return_counts=True)
+                infecund_age_counts = np.zeros(len(age_bins))
+                infecund_age_counts[v] = c
 
                 num_to_make = new_rate * age_counts  # Number that we need to make pregnant
-                new_denom = age_counts - pregnant_age_counts  # New denominator for rates
+                new_denom = age_counts - infecund_age_counts  # New denominator for rates
                 np.divide(num_to_make, new_denom, where=new_denom>0, out=new_rate)
 
             fertility_rate[uids] = new_rate[age_bin_all]
@@ -343,7 +334,7 @@ def make_fertility_prob_fn(self, sim, uids):
         # Scale from rate to probability
         invalid_age = (age < self.pars.min_age) | (age > self.pars.max_age)
         fertility_prob = fertility_rate * (self.pars.units * self.pars.rel_fertility * sim.pars.dt)
-        fertility_prob[self.pregnant.uids] = 0 # Currently pregnant women cannot become pregnant again
+        fertility_prob[(~self.fecund).uids] = 0 # Currently infecund women cannot become pregnant
         fertility_prob[uids[invalid_age]] = 0 # Women too young or old cannot become pregnant
         fertility_prob = np.clip(fertility_prob[uids], a_min=0, a_max=1)
         return fertility_prob
@@ -372,6 +363,7 @@ def init_pre(self, sim):
 
         low = sim.pars.n_agents + 1
         high = int(sim.pars.slot_scale*sim.pars.n_agents)
+        high = np.maximum(high, sim.pars.min_slots) # Make sure there are at least min_slots slots to avoid artifacts related to small populations
         self.choose_slots = ss.randint(low=low, high=high, sim=sim, module=self)
         return
 
@@ -381,26 +373,37 @@ def init_results(self):
         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', npts, dtype=int, scale=True, label='New pregnancies'),
-            ss.Result(self.name, 'births', npts, dtype=int, scale=True, label='New births'),
-            ss.Result(self.name, 'cbr', npts, dtype=int, scale=False, label='Crude birth rate'),
-        ]
+        super().init_results()
+        self.define_results(
+            ss.Result('pregnancies', dtype=int,   scale=True,  label='New pregnancies'),
+            ss.Result('births',      dtype=int,   scale=True,  label='New births'),
+            ss.Result('cbr',         dtype=float, scale=False, label='Crude birth rate'),
+        )
         return
 
-    def update(self):
+    def step(self):
+        if self.sim.ti == 0 and self.pars.burnin:
+            dtis = np.arange(np.ceil(-1 * self.pars.dur_pregnancy), 0, 1).astype(int)
+            for dti in dtis:
+                self.sim.ti = dti
+                self.do_step()
+            self.sim.ti = 0
+        new_uids = self.do_step()
+
+        return new_uids
+
+    def do_step(self):
         """ Perform all updates """
         self.update_states()
         conceive_uids = self.make_pregnancies()
         self.n_pregnancies = len(conceive_uids)
-        self.make_embryos(conceive_uids)
-        return
+        new_uids = self.make_embryos(conceive_uids)
+        return new_uids
 
     def update_states(self):
         """ Update states """
         # Check for new deliveries
-        ti = self.sim.ti
+        ti = self.ti
         deliveries = self.pregnant & (self.ti_delivery <= ti)
         self.n_births = np.count_nonzero(deliveries)
         self.pregnant[deliveries] = False
@@ -416,27 +419,28 @@ def update_states(self):
                 prenatalnet = [nw for nw in self.sim.networks.values() if nw.prenatal][0]
 
                 # Find the prenatal connections that are ending
-                prenatal_ending = prenatalnet.edges.end<=self.sim.ti
+                prenatal_ending = prenatalnet.edges.end <= ti
                 new_mother_uids = prenatalnet.edges.p1[prenatal_ending]
                 new_infant_uids = prenatalnet.edges.p2[prenatal_ending]
 
                 # Validation
-                if not np.array_equal(new_mother_uids, deliveries.uids):
+                if not set(new_mother_uids) == set(deliveries.uids): # Not sure why sometimes out of order
                     errormsg = 'IDs of new mothers do not match IDs of new deliveries'
                     raise ValueError(errormsg)
 
                 # Create durations and start dates, and add connections
                 durs = self.dur_postpartum[new_mother_uids]
-                start = np.full(self.n_births, fill_value=self.sim.ti)
+                start = np.full(self.n_births, fill_value=ti)
 
                 # # Remove pairs from prenatal network and add to postnatal
                 prenatalnet.end_pairs()
                 layer.add_pairs(new_mother_uids, new_infant_uids, dur=durs, start=start)
 
         # Check for new women emerging from post-partum
-        postpartum = ~self.pregnant & (self.ti_postpartum <= ti)
+        postpartum = self.postpartum & (self.ti_postpartum <= ti)
         self.postpartum[postpartum] = False
         self.fecund[postpartum] = True
+        self.child_uid[postpartum] = np.nan
 
         # Maternal deaths
         maternal_deaths = (self.ti_dead <= ti).uids
@@ -453,7 +457,7 @@ def make_pregnancies(self):
         # Validation
         if np.any(self.pregnant[conceive_uids]):
             which_uids = conceive_uids[self.pregnant[conceive_uids]]
-            errormsg = f'New conceptions registered in {len(which_uids)} pregnant agent(s) at timestep {self.sim.ti}.'
+            errormsg = f'New conceptions registered in {len(which_uids)} pregnant agent(s) at timestep {self.ti}.'
             raise ValueError(errormsg)
 
         # Set prognoses for the pregnancies
@@ -474,17 +478,22 @@ def make_embryos(self, conceive_uids):
 
             # Grow the arrays and set properties for the unborn agents
             new_uids = people.grow(len(new_slots), new_slots)
-            people.age[new_uids] = -self.pars.dur_pregnancy
+            people.age[new_uids] = -self.pars.dur_pregnancy.to('year')
             people.slot[new_uids] = new_slots  # Before sampling female_dist
             people.female[new_uids] = self.pars.sex_ratio.rvs(conceive_uids)
+            people.parent[new_uids] = conceive_uids
+            self.child_uid[conceive_uids] = new_uids
 
             # Add connections to any prenatal transmission layers
             for lkey, layer in self.sim.networks.items():
                 if layer.prenatal:
                     durs = np.full(n_unborn, fill_value=self.pars.dur_pregnancy)
-                    start = np.full(n_unborn, fill_value=self.sim.ti)
+                    start = np.full(n_unborn, fill_value=self.ti)
                     layer.add_pairs(conceive_uids, new_uids, dur=durs, start=start)
 
+        if self.sim.ti < 0:
+            people.age[new_uids] += -self.sim.ti * self.dt # Age to ti=0, TODO: Check!
+
         return new_uids
 
     def set_prognoses(self, uids):
@@ -496,26 +505,52 @@ def set_prognoses(self, uids):
         """
 
         # Change states for the newly pregnant woman
-        ti = self.sim.ti
-        dt = self.sim.dt
+        ti = self.ti
         self.fecund[uids] = False
         self.pregnant[uids] = True
         self.ti_pregnant[uids] = ti
 
         # Outcomes for pregnancies
-        dur_preg = np.full(len(uids), self.pars.dur_pregnancy)  # Duration in years
+        dur_preg = np.ones(len(uids))*self.pars.dur_pregnancy  # Duration in years
         dur_postpartum = self.pars.dur_postpartum.rvs(uids)
-        dead = self.pars.maternal_death_prob.rvs(uids)
-        self.ti_delivery[uids] = ti + dur_preg/dt # Currently assumes maternal deaths still result in a live baby
-        self.ti_postpartum[uids] = self.ti_delivery[uids] + dur_postpartum/dt
+        dead = self.pars.p_maternal_death.rvs(uids)
+        self.ti_delivery[uids] = ti + dur_preg # Currently assumes maternal deaths still result in a live baby
+        self.ti_postpartum[uids] = self.ti_delivery[uids] + dur_postpartum
         self.dur_postpartum[uids] = dur_postpartum
 
         if np.any(dead): # NB: 100x faster than np.sum(), 10x faster than np.count_nonzero()
             self.ti_dead[uids[dead]] = ti + dur_preg[dead]
         return
 
+    def finish_step(self):
+        super().finish_step()
+        death_uids = ss.uids(self.sim.people.ti_dead <= self.sim.ti)
+        if len(death_uids) == 0:
+            return
+
+        # Any pregnant? Consider death of the neonate
+        mother_death_uids = death_uids[self.pregnant[death_uids]]
+        if len(mother_death_uids):
+            neonate_uids = ss.uids(self.child_uid[mother_death_uids])
+            neonatal_death_uids = self.pars.p_neonatal_death.filter(neonate_uids)
+            if len(neonatal_death_uids):
+                self.sim.people.request_death(neonatal_death_uids)
+
+        # Any prenatal? Handle changes to pregnancy
+        is_prenatal = self.sim.people.age[death_uids] < 0
+        prenatal_death_uids = death_uids[is_prenatal]
+        if len(prenatal_death_uids):
+            mother_uids = self.sim.people.parent[prenatal_death_uids]
+            self.pregnant[mother_uids] = False # Baby lost, mother no longer pregnant
+            self.fecund[mother_uids] = True # Or wait?
+            self.postpartum[mother_uids] = False
+            self.child_uid[mother_uids] = np.nan
+            self.ti_delivery[mother_uids] = np.nan
+            self.ti_postpartum[mother_uids] = np.nan
+        return
+
     def update_results(self):
-        ti = self.sim.ti
+        ti = self.ti
         self.results['pregnancies'][ti] = self.n_pregnancies
         self.results['births'][ti] = self.n_births
         return
@@ -523,5 +558,5 @@ def update_results(self):
     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)
+        self.results['cbr'] = 1/self.pars.units * np.divide(self.results['births'] / self.sim.dt_year, n_alive, where=n_alive>0)
         return
diff --git a/starsim/disease.py b/starsim/disease.py
index 72a89939..378aaeba 100644
--- a/starsim/disease.py
+++ b/starsim/disease.py
@@ -1,7 +1,6 @@
 """
 Base classes for diseases
 """
-
 import numpy as np
 import sciris as sc
 import starsim as ss
@@ -19,52 +18,57 @@ class Disease(ss.Module):
 
     def __init__(self, pars=None, *args, **kwargs):
         super().__init__(*args, **kwargs)
-        self.default_pars(
+        self.define_pars(
             log = False,
         )
         self.update_pars(pars, **kwargs)
         self.results = ss.Results(self.name)
-        self.log = InfectionLog()
         return
 
     @property
-    def _boolean_states(self):
+    def _disease_states(self):
         """
-        Iterator over states with boolean type
+        Iterator over disease states with boolean type
 
         For diseases, these states typically represent attributes like 'susceptible',
-        'infectious', 'diagnosed' etc. These variables are typically useful to
+        'infectious', 'diagnosed' etc. These variables are typically useful to store
+        results for.
         """
         for state in self.states:
-            if state.dtype == bool:
+            if isinstance(state, ss.State):
                 yield state
         return
 
     def init_pre(self, sim):
         """ Link the disease to the sim, create objects, and initialize results; see Module.init_pre() for details """
         super().init_pre(sim)
-        self.init_results()
+        if self.pars.log:
+            self.log = InfectionLog()
         return
 
     def init_results(self):
         """
         Initialize results
 
-        By default, diseases all report on counts for any boolean states e.g., if
+        By default, diseases all report on counts for any explicitly defined "States", e.g. if
         a disease contains a boolean state 'susceptible' it will automatically contain a
-        Result for 'n_susceptible'
+        Result for 'n_susceptible'.
         """
-        for state in self._boolean_states:
-            self.results += ss.Result(self.name, f'n_{state.name}', self.sim.npts, dtype=int, scale=True, label=state.label)
+        super().init_results()
+        results = sc.autolist()
+        for state in self._disease_states:
+            results += ss.Result(f'n_{state.name}', dtype=int, scale=True, label=state.label)
+        self.define_results(*results)
         return
 
-    def update_pre(self):
+    def step_state(self):
         """
-        Carry out autonomous updates at the start of the timestep (prior to transmission)
+        Carry out updates at the start of the timestep (prior to transmission);
+        these are typically state changes
         """
         pass
 
-    def update_death(self, uids):
+    def step_die(self, uids):
         """
         Carry out state changes upon death
 
@@ -76,14 +80,18 @@ def update_death(self, uids):
         to undo any state changes that have already been applied (because they only run via this
         function if the death actually occurs).
 
+        Unlike other methods during the integration loop, this method is not called directly
+        by the sim; instead, it is called by people.step_die(), which reconciles the UIDs of
+        the agents who will die.
+
         Depending on the module and the results it produces, it may or may not be necessary
         to implement this.
         """
         pass
 
-    def make_new_cases(self):
+    def step(self):
         """
-        Add new cases of the disease
+        Handle the main disease updates, e.g. add new cases
 
         This method is agnostic as to the mechanism by which new cases occur. This
         could be through transmission (parametrized in different ways, which may or
@@ -95,7 +103,7 @@ def make_new_cases(self):
         """
         pass
 
-    def set_prognoses(self, uids, source_uids=None):
+    def set_prognoses(self, uids, sources=None):
         """
         Set prognoses upon infection/acquisition
 
@@ -105,7 +113,7 @@ def set_prognoses(self, uids, source_uids=None):
         the log as part of this operation if logging is enabled (in the
         `Disease` parameters)
 
-        The `from_uids` are relevant for infectious diseases, but would be left
+        The `sources` are relevant for infectious diseases, but would be left
         as `None` for NCDs.
 
         Args:
@@ -113,14 +121,13 @@ def set_prognoses(self, uids, source_uids=None):
             uids (array): UIDs for agents to assign disease progoses to
             from_uids (array): Optionally specify the infecting agent
         """
+        # Track infections
         if self.pars.log:
-            sim = self.sim
-            if source_uids is None:
-                for target in uids:
-                    self.log.append(np.nan, target, sim.year)
-            else:
-                for target, source in zip(uids, source_uids):
-                    self.log.append(source, target, sim.year)
+            self.log_infections(uids, sources)
+        return
+
+    def log_infections(self, uids, sources=None):
+        self.log.add_entries(uids, sources, self.now)
         return
 
     def update_results(self):
@@ -131,9 +138,8 @@ def update_results(self):
         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)
+        for state in self._disease_states:
+            self.results[f'n_{state.name}'][self.ti] = state.sum()
         return
 
 
@@ -148,45 +154,21 @@ class Infection(Disease):
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
-        self.add_states(
-            ss.BoolArr('susceptible', default=True, label='Susceptible'),
-            ss.BoolArr('infected', label='Infectious'),
+        self.define_states(
+            ss.State('susceptible', default=True, label='Susceptible'),
+            ss.State('infected', label='Infectious'),
             ss.FloatArr('rel_sus', default=1.0, label='Relative susceptibility'),
             ss.FloatArr('rel_trans', default=1.0, label='Relative transmission'),
             ss.FloatArr('ti_infected', label='Time of infection' ),
         )
 
-        # Define random number generators for make_new_cases
-        self.rng_target = ss.rand_raw(name='target')
-        self.rng_source = ss.rand_raw(name='source')
+        # Define random number generator for determining transmission
+        self.trans_rng = ss.multi_random('source', 'target')
         return
-    
+
     def init_pre(self, sim):
         super().init_pre(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] = [β,β]
+        self.validate_beta(run_checks=True)
         return
 
     @property
@@ -204,130 +186,163 @@ def init_post(self):
         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_post()
         if self.pars.init_prev is None:
             return
 
         initial_cases = self.pars.init_prev.filter()
-        self.set_prognoses(initial_cases)  # TODO: sentinel value to indicate seeds?
-        return
+        if len(initial_cases):
+            self.set_prognoses(initial_cases)  # TODO: sentinel value to indicate seeds?
+
+        # Exclude initial cases from results -- disabling for now since it disrupts counting of new infections, e.g. test_diseases.py
+        # self.ti_infected[self.ti_infected == self.ti] = -1
+        return initial_cases
 
     def init_results(self):
         """
         Initialize results
         """
         super().init_results()
-        sim = self.sim
-        self.results += [
-            ss.Result(self.name, 'prevalence',     sim.npts, dtype=float, scale=False, label='Prevalence'),
-            ss.Result(self.name, 'new_infections', sim.npts, dtype=int, scale=True, label='New infections'),
-            ss.Result(self.name, 'cum_infections', sim.npts, dtype=int, scale=True, label='Cumulative infections'),
-        ]
+        self.define_results(
+            ss.Result('prevalence',     dtype=float, scale=False, label='Prevalence'),
+            ss.Result('new_infections', dtype=int,   scale=True,  label='New infections'),
+            ss.Result('cum_infections', dtype=int,   scale=True,  label='Cumulative infections'),
+        )
         return
 
-    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
-            self.pars.beta = {k.lower(): v for k, v in self.pars.beta.items()}
-
-        # Create a mapping between beta and networks, and populate it
-        betapars = self.pars.beta
-        betamap = sc.objdict()
-        netkeys = list(self.sim.networks.keys())
-        if netkeys: # Skip if no networks
-            for bkey in betapars.keys():
-                orig_bkey = bkey[:]
-                if bkey in netkeys: # TODO: CK: could tidy up logic
-                    betamap[bkey] = betapars[orig_bkey]
+    def validate_beta(self, run_checks=False):
+        """ Validate beta and return as a map to match the networks """
+        sim = self.sim
+        β = self.pars.beta
+
+        def scalar_beta(β):
+            return isinstance(β, ss.TimePar) or sc.isnumber(β)
+
+        if run_checks:
+            scalar_warn = f'Beta is defined as a number ({β}); convert it to a rate to handle timestep conversions'
+
+            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 sc.isnumber(β):
+                ss.warn(scalar_warn)
+            elif isinstance(β, dict):
+                for netbeta in β.values():
+                    if sc.isnumber(netbeta):
+                        ss.warn(scalar_warn)
+                    elif isinstance(netbeta, (list, tuple)):
+                        for thisbeta in netbeta:
+                            if sc.isnumber(netbeta):
+                                ss.warn(scalar_warn)
+
+        # If beta is a scalar, apply this bi-directionally to all networks
+        if scalar_beta(β):
+            betamap = {ss.standardize_netkey(k):[β,β] for k in sim.networks.keys()}
+
+        # If beta is a dict, check all entries are bi-directional
+        elif isinstance(β, dict):
+            betamap = dict()
+            for k,thisbeta in β.items():
+                nkey = ss.standardize_netkey(k)
+                if scalar_beta(thisbeta):
+                    betamap[nkey] = [thisbeta, thisbeta]
                 else:
-                    if 'net' not in bkey:
-                        bkey += 'net'  # Add 'net' suffix if not already there
-                    if bkey in netkeys:
-                        betamap[bkey] = betapars[orig_bkey]
-                    else:
-                        errormsg = f'No network for beta parameter "{bkey}"; your beta should match network keys:\n{sc.newlinejoin(netkeys)}'
-                        raise ValueError(errormsg)
+                    betamap[nkey] = thisbeta
+
+        else:
+            errormsg = f'Invalid type {type(β)} for beta'
+            raise TypeError(errormsg)
+
+        # Check that it matches the network
+        netkeys = [ss.standardize_netkey(k) for k in list(sim.networks.keys())]
+        if set(betamap.keys()) != set(netkeys):
+            errormsg = f'Network keys ({netkeys}) and beta keys ({betamap.keys()}) do not match'
+            raise ValueError(errormsg)
+
         return betamap
 
-    def make_new_cases(self):
+    def step(self):
         """
-        Add new cases of module, through transmission, incidence, etc.
-        
-        Common-random-number-safe transmission code works by mapping edges onto
-        slots.
+        Perform key infection updates, including infection and setting prognoses
         """
+        # Create new cases
+        new_cases, sources, networks = self.infect() # TODO: store outputs in self or use objdict rather than 3 returns
+
+        # Set prognoses
+        if len(new_cases):
+            self.set_outcomes(new_cases, sources)
+
+        return new_cases, sources, networks
+
+    @staticmethod # In future, consider: @nb.njit(fastmath=True, parallel=True, cache=True), but no faster it seems
+    def compute_transmission(src, trg, rel_trans, rel_sus, beta_per_dt, randvals):
+        """ Compute the probability of a->b transmission """
+        p_transmit = rel_trans[src] * rel_sus[trg] * beta_per_dt
+        transmitted = p_transmit > randvals
+        target_uids = trg[transmitted]
+        source_uids = src[transmitted]
+        return target_uids, source_uids
+
+    def infect(self):
+        """ Determine who gets infected on this timestep via transmission on the network """
         new_cases = []
         sources = []
         networks = []
-        betamap = self._check_betas()
+        betamap = self.validate_beta()
+
+        rel_trans = self.rel_trans.asnew(self.infectious * self.rel_trans)
+        rel_sus   = self.rel_sus.asnew(self.susceptible * self.rel_sus)
 
         for i, (nkey,net) in enumerate(self.sim.networks.items()):
-            if not len(net):
-                break
-
-            nbetas = betamap[nkey]
-            edges = net.edges
-
-            rel_trans = self.rel_trans.asnew(self.infectious * self.rel_trans)
-            rel_sus   = self.rel_sus.asnew(self.susceptible * self.rel_sus)
-            p1p2b0 = [edges.p1, edges.p2, nbetas[0]]
-            p2p1b1 = [edges.p2, edges.p1, nbetas[1]]
-            for src, trg, beta in [p1p2b0, p2p1b1]:
-
-                # Skip networks with no transmission
-                if beta == 0:
-                    continue
-
-                # Calculate probability of a->b transmission.
-                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
-                rvs_s = self.rng_source.rvs(src)
-                rvs_t = self.rng_target.rvs(trg)
-                rvs = ss.combine_rands(rvs_s, rvs_t)
-                
-                new_cases_bool = rvs < p_transmit
-                new_cases.append(trg[new_cases_bool])
-                sources.append(src[new_cases_bool])
-                networks.append(np.full(np.count_nonzero(new_cases_bool), dtype=ss_int_, fill_value=i))
-                
-        # Tidy up
+            nk = ss.standardize_netkey(nkey)
+            if len(net): # Skip networks with no edges
+                edges = net.edges
+                p1p2b0 = [edges.p1, edges.p2, betamap[nk][0]] # Person 1, person 2, beta 0
+                p2p1b1 = [edges.p2, edges.p1, betamap[nk][1]] # Person 2, person 1, beta 1
+                for src, trg, beta in [p1p2b0, p2p1b1]:
+                    if beta: # Skip networks with no transmission
+                        beta_per_dt = net.net_beta(disease_beta=beta) # Compute beta for this network and timestep
+                        randvals = self.trans_rng.rvs(src, trg) # Generate a new random number based on the two other random numbers
+                        args = (src, trg, rel_trans, rel_sus, beta_per_dt, randvals) # Set up the arguments to calculate transmission
+                        target_uids, source_uids = self.compute_transmission(*args) # Actually calculate it
+                        new_cases.append(target_uids)
+                        sources.append(source_uids)
+                        networks.append(np.full(len(target_uids), dtype=ss_int_, fill_value=i))
+
+        # Finalize
         if len(new_cases) and len(sources):
             new_cases = ss.uids.cat(new_cases)
             new_cases, inds = new_cases.unique(return_index=True)
             sources = ss.uids.cat(sources)[inds]
             networks = np.concatenate(networks)[inds]
         else:
-            new_cases = np.empty(0, dtype=int)
-            sources = np.empty(0, dtype=int)
-            networks = np.empty(0, dtype=int)
+            new_cases = ss.uids()
+            sources = ss.uids()
+            networks = np.empty(0, dtype=ss_int_)
 
-        if len(new_cases):
-            self._set_cases(new_cases, sources)
-            
         return new_cases, sources, networks
 
-    def _set_cases(self, target_uids, source_uids=None):
+    def set_outcomes(self, uids, sources=None):
         sim = self.sim
-        congenital = sim.people.age[target_uids] <= 0
+        congenital = sim.people.age[uids] <= 0
         if np.count_nonzero(congenital):
-            src_c = source_uids[congenital] if source_uids is not None else None
-            self.set_congenital(target_uids[congenital], src_c)
-        src_p = source_uids[~congenital] if source_uids is not None else None
-        self.set_prognoses(target_uids[~congenital], src_p)
+            src_c = sources[congenital] if sources is not None else None
+            self.set_congenital(uids[congenital], src_c)
+        src_p = sources[~congenital] if sources is not None else None
+        self.set_prognoses(uids[~congenital], src_p)
         return
 
-    def set_congenital(self, target_uids, source_uids=None):
+    def set_congenital(self, uids, sources=None):
         pass
 
     def update_results(self):
         super().update_results()
         res = self.results
-        ti = self.sim.ti
+        ti = self.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])
+        res.cum_infections[ti] = np.sum(res['new_infections'][:ti+1]) # TODO: can compute at end
         return
 
 
@@ -359,9 +374,14 @@ class InfectionLog(nx.MultiDiGraph):
 
     A table of outcomes can be returned using `InfectionLog.line_list()`
     """
-
-    # Add entries
-    # Add items to the most recent infection for an agent
+    def add_entries(self, uids, sources=None, time=np.nan):
+        if sources is None:
+            for target in uids:
+                self.append(np.nan, target, time)
+        else:
+            for target, source in zip(uids, sources):
+                self.append(source, target, time)
+        return
 
     def add_data(self, uids, **kwargs):
         """
@@ -370,21 +390,19 @@ def add_data(self, uids, **kwargs):
         This method can be used to add data to an existing transmission event.
         The most recent transmission event will be used
 
-        :param uid: The UID of the target node (the agent that was infected)
-        :param kwargs: Remaining arguments are stored as edge data
+        Args:
+            uid: The UID of the target node (the agent that was infected)
+            kwargs: Remaining arguments are stored as edge data
         """
-        for uid in sc.promotetoarray(uids):
-            source, target, key = max(self.in_edges(uid, keys=True),
-                                      key=itemgetter(2, 0))  # itemgetter twice as fast as lambda apparently
+        for uid in sc.toarray(uids):
+            source, target, key = max(self.in_edges(uid, keys=True), key=itemgetter(2, 0))  # itemgetter twice as fast as lambda apparently
             self[source][target][key].update(**kwargs)
-
         return
 
     def append(self, source, target, t, **kwargs):
         self.add_edge(source, target, key=t, **kwargs)
         return
 
-    @property
     def line_list(self):
         """
         Return a tabular representation of the log
diff --git a/starsim/diseases/cholera.py b/starsim/diseases/cholera.py
index ac8060be..b4202871 100644
--- a/starsim/diseases/cholera.py
+++ b/starsim/diseases/cholera.py
@@ -17,36 +17,36 @@ class Cholera(ss.Infection):
     def __init__(self, pars=None, *args, **kwargs):
         """ Initialize with parameters """
         super().__init__()
-        self.default_pars(
+        self.define_pars(
             # Initial conditions and beta
-            beta = 1.0, # Placeholder value
+            beta = ss.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/
+            dur_exp2inf   = ss.days(ss.lognorm_ex(mean=2.772, std=4.737)), # Calculated from Azman et al. estimates https://www.ncbi.nlm.nih.gov/pmc/articles/PMC3677557/
+            dur_asymp2rec = ss.days(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.days(ss.lognorm_ex(mean=5, std=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.days(ss.lognorm_ex(mean=1, std=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,
+            beta_env = ss.beta(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/
+            shedding_rate = ss.rate(10, unit='day'), # 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 = ss.rate(0.033, unit='day'), # 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
         )
         self.update_pars(pars, **kwargs)
 
         # Boolean states
-        self.add_states(
+        self.define_states(
             # Susceptible & infected are added automatically, here we add the rest
-            ss.BoolArr('exposed', label='Exposed'),
-            ss.BoolArr('symptomatic', label='Symptomatic'),
-            ss.BoolArr('recovered', label='Recovered'),
-    
+            ss.State('exposed', label='Exposed'),
+            ss.State('symptomatic', label='Symptomatic'),
+            ss.State('recovered', label='Recovered'),
+
             # Timepoint states
             ss.FloatArr('ti_exposed', label='Time of exposure'),
             ss.FloatArr('ti_symptomatic', label='Time of symptoms'),
@@ -64,26 +64,23 @@ def asymptomatic(self):
         return self.infected & ~self.symptomatic
 
     def init_results(self):
-        """
-        Initialize results
-        """
+        """ Initialize results """
         super().init_results()
-        npts = self.sim.npts
-        self.results += [
-            ss.Result(self.name, 'new_deaths', npts, dtype=int, label='Deaths'),
-            ss.Result(self.name, 'cum_deaths', npts, dtype=int, label='Cumulative deaths'),
-            ss.Result(self.name, 'env_prev', npts, dtype=float, label='Environmental prevalence'),
-            ss.Result(self.name, 'env_conc', npts, dtype=float, label='Environmental concentration'),
-        ]
+        self.define_results(
+            ss.Result('new_deaths', dtype=int,   label='Deaths'),
+            ss.Result('cum_deaths', dtype=int,   label='Cumulative deaths'),
+            ss.Result('env_prev',   dtype=float, label='Environmental prevalence'),
+            ss.Result('env_conc',   dtype=float, label='Environmental concentration'),
+        )
         return
 
-    def update_pre(self):
+    def step_state(self):
         """
         Adapted from https://github.com/optimamodel/gavi-outbreaks/blob/main/stisim/gavi/cholera.py
         Original version by Dom Delport
         """
         # Progress exposed -> infected
-        ti = self.sim.ti
+        ti = self.ti
         infected = (self.exposed & (self.ti_infected <= ti)).uids
         self.infected[infected] = True
 
@@ -114,7 +111,7 @@ def calc_environmental_prev(self):
         """
         p = self.pars
         r = self.results
-        ti = self.sim.ti
+        ti = self.ti
 
         n_symptomatic = self.symptomatic.sum()
         n_asymptomatic = self.asymptomatic.sum()
@@ -130,8 +127,7 @@ def calc_environmental_prev(self):
     def set_prognoses(self, uids, source_uids=None):
         """ Set prognoses for those who get infected """
         super().set_prognoses(uids, source_uids)
-        ti = self.sim.ti
-        dt = self.sim.dt
+        ti = self.ti
 
         self.susceptible[uids] = False
         self.exposed[uids] = True
@@ -140,7 +136,7 @@ def set_prognoses(self, uids, source_uids=None):
         p = self.pars
 
         # Determine when exposed become infected
-        self.ti_infected[uids] = ti + p.dur_exp2inf.rvs(uids) / dt
+        self.ti_infected[uids] = ti + p.dur_exp2inf.rvs(uids)
 
         # Determine who becomes symptomatic and when
         symp_uids = p.p_symp.filter(uids)
@@ -148,13 +144,13 @@ def set_prognoses(self, 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) / dt
+        self.ti_dead[dead_uids] = self.ti_symptomatic[dead_uids] + p.dur_symp2dead.rvs(dead_uids)
         symp_rev_uids = np.setdiff1d(symp_uids, dead_uids)
         asymp_uids = np.setdiff1d(uids, symp_uids)
 
         # Determine when agents recover
-        self.ti_recovered[symp_rev_uids] = self.ti_exposed[symp_rev_uids] + p.dur_symp2rec.rvs(symp_rev_uids) / dt
-        self.ti_recovered[asymp_uids] = self.ti_exposed[asymp_uids] + p.dur_asymp2rec.rvs(asymp_uids) / dt
+        self.ti_recovered[symp_rev_uids] = self.ti_exposed[symp_rev_uids] + p.dur_symp2rec.rvs(symp_rev_uids)
+        self.ti_recovered[asymp_uids] = self.ti_exposed[asymp_uids] + p.dur_asymp2rec.rvs(asymp_uids)
 
         return
 
@@ -162,18 +158,18 @@ def make_new_cases(self):
         """ Add indirect transmission """
         # Make new cases via direct transmission
         super().make_new_cases()
-        
+
         # Make new cases via indirect transmission
         pars = self.pars
         res = self.results
-        p_transmit = res.env_conc[self.sim.ti] * pars.beta_env
+        p_transmit = res.env_conc[self.ti] * pars.beta_env
         pars.p_env_transmit.set(p=p_transmit)
         new_cases = pars.p_env_transmit.filter(self.sim.people.uid[self.susceptible]) # TODO: make syntax nicer
         if new_cases.any():
             self.set_prognoses(new_cases, source_uids=None)
         return
 
-    def update_death(self, uids):
+    def step_die(self, uids):
         """ Reset infected/recovered flags for dead agents """
         for state in ['susceptible', 'exposed', 'infected', 'symptomatic', 'recovered']:
             self.statesdict[state][uids] = False
@@ -182,7 +178,7 @@ def update_death(self, uids):
     def update_results(self):
         super().update_results()
         res = self.results
-        ti = self.sim.ti
+        ti = self.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 da32cdb3..ffc91817 100644
--- a/starsim/diseases/ebola.py
+++ b/starsim/diseases/ebola.py
@@ -16,34 +16,34 @@ class Ebola(SIR):
     def __init__(self, pars=None, *args, **kwargs):
         """ Initialize with parameters """
         super().__init__()
-        self.default_pars(
+        self.define_pars(
             # Initial conditions and beta
             init_prev       = ss.bernoulli(p=0.005),
-            beta            = 1.0, # Placeholder value
+            beta            = ss.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
+            dur_exp2symp    = ss.lognorm_ex(mean=ss.days(12.7)), # Add source
+            dur_symp2sev    = ss.lognorm_ex(mean=ss.days(6)), # Add source
+            dur_sev2dead    = ss.lognorm_ex(mean=ss.days(1.5)), # Add source
+            dur_dead2buried = ss.lognorm_ex(mean=ss.days(2)), # Add source
+            dur_symp2rec    = ss.lognorm_ex(mean=ss.days(10)), # Add source
+            dur_sev2rec     = ss.lognorm_ex(mean=ss.days(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
         )
         self.update_pars(pars=pars, **kwargs)
-        
+
         # Boolean states
-        self.add_states(
+        self.define_states(
             # SIR are added automatically, here we add E
-            ss.BoolArr('exposed', label='Exposed'),
-            ss.BoolArr('severe', label='Severe'),
-            ss.BoolArr('recovered', label='Recovered'),
-            ss.BoolArr('buried', label='Buried'),
-    
+            ss.State('exposed', label='Exposed'),
+            ss.State('severe', label='Severe'),
+            ss.State('recovered', label='Recovered'),
+            ss.State('buried', label='Buried'),
+
             # Timepoint states
             ss.FloatArr('ti_exposed', label='Time of exposure'),
             ss.FloatArr('ti_severe', label='Time of severe symptoms'),
@@ -57,10 +57,10 @@ def __init__(self, pars=None, *args, **kwargs):
     def infectious(self):
         return self.infected | self.exposed
 
-    def update_pre(self):
+    def step_state(self):
 
         # Progress exposed -> infected
-        ti = self.sim.ti
+        ti = self.ti
         infected = (self.exposed & (self.ti_infected <= ti)).uids
         self.exposed[infected] = False
         self.infected[infected] = True
@@ -87,7 +87,7 @@ def update_pre(self):
         # Progress dead -> buried
         buried = (self.ti_buried <= ti).uids
         self.buried[buried] = True
-        
+
         return
 
     def set_prognoses(self, uids, source_uids=None):
@@ -95,8 +95,7 @@ def set_prognoses(self, uids, source_uids=None):
         # Do not call set_prognoses on the parent
         #super().set_prognoses(sim, uids, source_uids)
 
-        ti = self.sim.ti
-        dt = self.sim.dt
+        ti = self.ti
         self.susceptible[uids] = False
         self.exposed[uids] = True
         self.ti_exposed[uids] = ti
@@ -104,25 +103,25 @@ def set_prognoses(self, uids, source_uids=None):
         p = self.pars
 
         # Determine when exposed become infected
-        self.ti_infected[uids] = ti + p.dur_exp2symp.rvs(uids) / dt
+        self.ti_infected[uids] = ti + p.dur_exp2symp.rvs(uids)
 
         # Determine who progresses to sever and when
         sev_uids = p.p_sev.filter(uids)
-        self.ti_severe[sev_uids] = self.ti_infected[sev_uids] + p.dur_symp2sev.rvs(sev_uids) / dt
+        self.ti_severe[sev_uids] = self.ti_infected[sev_uids] + p.dur_symp2sev.rvs(sev_uids)
 
         # Determine who dies and who recovers and when
         dead_uids = p.p_death.filter(sev_uids)
-        self.ti_dead[dead_uids] = self.ti_severe[dead_uids] + p.dur_sev2dead.rvs(dead_uids) / dt
+        self.ti_dead[dead_uids] = self.ti_severe[dead_uids] + p.dur_sev2dead.rvs(dead_uids)
         rec_sev_uids = np.setdiff1d(sev_uids, dead_uids)
-        self.ti_recovered[rec_sev_uids] = self.ti_severe[rec_sev_uids] + p.dur_sev2rec.rvs(rec_sev_uids) / dt
+        self.ti_recovered[rec_sev_uids] = self.ti_severe[rec_sev_uids] + p.dur_sev2rec.rvs(rec_sev_uids)
         rec_symp_uids = np.setdiff1d(uids, sev_uids)
-        self.ti_recovered[rec_symp_uids] = self.ti_infected[rec_symp_uids] + p.dur_symp2rec.rvs(rec_symp_uids) / dt
+        self.ti_recovered[rec_symp_uids] = self.ti_infected[rec_symp_uids] + p.dur_symp2rec.rvs(rec_symp_uids)
 
         # Determine time of burial - either immediate (safe burials) or after a delay (unsafe)
         safe_buried = p.p_safe_bury.filter(dead_uids)
         unsafe_buried = np.setdiff1d(dead_uids, safe_buried)
         self.ti_buried[safe_buried] = self.ti_dead[safe_buried]
-        self.ti_buried[unsafe_buried] = self.ti_dead[unsafe_buried] + p.dur_dead2buried.rvs(unsafe_buried) / dt
+        self.ti_buried[unsafe_buried] = self.ti_dead[unsafe_buried] + p.dur_dead2buried.rvs(unsafe_buried)
 
         # Change rel_trans values
         self.rel_trans[self.infectious] = 1
@@ -131,7 +130,7 @@ def set_prognoses(self, uids, source_uids=None):
         self.rel_trans[unburied_uids] = self.pars['unburied_factor']  # Change for unburied
         return
 
-    def update_death(self, uids):
+    def step_die(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 7b4f2da2..6a9dda27 100644
--- a/starsim/diseases/gonorrhea.py
+++ b/starsim/diseases/gonorrhea.py
@@ -13,53 +13,55 @@ class Gonorrhea(ss.Infection):
     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
+        self.define_pars(
+            beta = ss.beta(1.0), # Placeholder value
+            dur_inf_in_days = ss.lognorm_ex(mean=ss.days(10), std=ss.days(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
-        self.add_states(
-            ss.BoolArr('symptomatic', label='Symptomatic'),
+        self.define_states(
+            ss.State('symptomatic', label='Symptomatic'),
             ss.FloatArr('ti_clearance', label='Time of clearance'),
             ss.FloatArr('p_symp', default=1, label='Probability of symptoms'),
         )
-        
+
         return
 
     def init_results(self):
         """ Initialize results """
         super().init_results()
-        self.results += ss.Result(self.name, 'new_clearances', self.sim.npts, dtype=int)
+        self.define_results(
+            ss.Result('new_clearances', dtype=int, label='New clearances')
+        )
         return
 
     def update_results(self):
         super().update_results()
-        ti = self.sim.ti
+        ti = self.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):
+    def step_state(self):
         """ Natural clearance """
-        clearances = self.ti_clearance <= self.sim.ti
+        clearances = self.ti_clearance <= self.ti
         self.susceptible[clearances] = True
         self.infected[clearances] = False
         self.symptomatic[clearances] = False
-        self.ti_clearance[clearances] = self.sim.ti
+        self.ti_clearance[clearances] = self.ti
 
         return
 
     def set_prognoses(self, uids, source_uids=None):
         """ Natural history of gonorrhea for adult infection """
         super().set_prognoses(uids, source_uids)
-        ti = self.sim.ti
+        ti = self.ti
 
         # Set infection status
         self.susceptible[uids] = False
@@ -72,6 +74,6 @@ def set_prognoses(self, uids, source_uids=None):
 
         # Set natural clearance
         clear_uids = self.pars.p_clear.filter(uids)
-        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
+        dur = ti + self.pars.dur_inf_in_days.rvs(clear_uids) # Convert from days to years and then adjust for dt automatically # TODO: check!
         self.ti_clearance[clear_uids] = dur
         return
\ No newline at end of file
diff --git a/starsim/diseases/hiv.py b/starsim/diseases/hiv.py
index 706e2da0..caac68f9 100644
--- a/starsim/diseases/hiv.py
+++ b/starsim/diseases/hiv.py
@@ -13,8 +13,9 @@ class HIV(ss.Infection):
 
     def __init__(self, pars=None, *args, **kwargs):
         super().__init__()
-        self.default_pars(
-            beta = 1.0, # Placeholder value
+        self.define_pars(
+            unit = 'year',
+            beta = ss.beta(1.0), # Placeholder value
             cd4_min = 100,
             cd4_max = 500,
             cd4_rate = 5,
@@ -22,13 +23,13 @@ def __init__(self, pars=None, *args, **kwargs):
             art_efficacy = 0.96,
             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,
+            p_death = ss.rate(0.05), # NB: this is death per unit time, not death per infection
         )
         self.update_pars(pars=pars, **kwargs)
 
         # States
-        self.add_states(
-            ss.BoolArr('on_art', label='On ART'),
+        self.define_states(
+            ss.State('on_art', label='On ART'),
             ss.FloatArr('ti_art', label='Time of ART initiation'),
             ss.FloatArr('ti_dead', label='Time of death'), # Time of HIV-caused death
             ss.FloatArr('cd4', default=500, label='CD4 count'),
@@ -38,11 +39,11 @@ def __init__(self, pars=None, *args, **kwargs):
     @staticmethod
     def death_prob_func(module, sim, uids):
         p = module.pars
-        out = sim.dt * p.p_death / (p.cd4_min - p.cd4_max)**2 *  (module.cd4[uids] - p.cd4_max)**2
+        out = 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):
+    def step_state(self):
         """ Update CD4 """
         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
@@ -52,28 +53,30 @@ def update_pre(self):
 
         can_die = people.hiv.infected.uids
         hiv_deaths = self.pars.death_dist.filter(can_die)
-        
+
         people.request_death(hiv_deaths)
-        self.ti_dead[hiv_deaths] = self.sim.ti
+        self.ti_dead[hiv_deaths] = self.ti
         return
 
     def init_results(self):
         """ Initialize results """
         super().init_results()
-        self.results += ss.Result(self.name, 'new_deaths', self.sim.npts, dtype=int, label='Deaths')
+        self.define_results(
+            ss.Result('new_deaths', dtype=int, label='Deaths')
+        )
         return
 
     def update_results(self):
         super().update_results()
-        ti = self.sim.ti
+        ti = self.ti
         self.results['new_deaths'][ti] = np.count_nonzero(self.ti_dead == ti)
-        return 
+        return
 
     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] = self.sim.ti
+        self.ti_infected[uids] = self.ti
         return
 
     def set_congenital(self, uids, source_uids):
@@ -89,8 +92,8 @@ def __init__(self, year, coverage, pars=None, **kwargs):
         self.year = sc.toarray(year)
         self.coverage = sc.toarray(coverage)
         super().__init__()
-        self.default_pars(
-            art_delay = ss.constant(v=1) # Value in years
+        self.define_pars(
+            art_delay = ss.constant(v=ss.years(1.0)) # Value in years
         )
         self.update_pars(pars=pars, **kwargs)
 
@@ -100,17 +103,22 @@ def __init__(self, year, coverage, pars=None, **kwargs):
 
     def init_pre(self, sim):
         super().init_pre(sim)
-        self.results += ss.Result(self.name, 'n_art', sim.npts, dtype=int)
         self.initialized = True
         return
 
-    def apply(self, sim):
+    def init_results(self):
+        super().init_results()
+        self.define_results(ss.Result('n_art', dtype=int, label='Number on ART'))
+        return
+
+    def step(self):
+        sim = self.sim
         if sim.year < self.year[0]:
             return
 
         hiv = sim.people.hiv
         infected = hiv.infected.uids
-        ti_delay = np.round(self.pars.art_delay.rvs(infected)/sim.dt).astype(int)
+        ti_delay = np.round(self.pars.art_delay.rvs(infected)).astype(int)
         recently_infected = infected[hiv.ti_infected[infected] == sim.ti-ti_delay]
 
         n_added = 0
@@ -137,9 +145,10 @@ def __init__(self):
 
     def init_pre(self, sim):
         super().init_pre(sim)
-        self.cd4 = np.zeros((sim.npts, sim.people.n), dtype=int)
+        self.cd4 = np.zeros((self.npts, sim.people.n), dtype=int)
         return
 
-    def apply(self, sim):
+    def step(self):
+        sim = self.sim
         self.cd4[sim.t] = sim.people.hiv.cd4
         return
diff --git a/starsim/diseases/measles.py b/starsim/diseases/measles.py
index 7ff86d6e..3fa3821a 100644
--- a/starsim/diseases/measles.py
+++ b/starsim/diseases/measles.py
@@ -15,21 +15,21 @@ class Measles(SIR):
     def __init__(self, pars=None, *args, **kwargs):
         """ Initialize with parameters """
         super().__init__()
-        self.default_pars(
+        self.define_pars(
             # Initial conditions and beta
             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
+            dur_exp = ss.normal(loc=ss.days(8)),        # (days) - source: US CDC
+            dur_inf = ss.normal(loc=ss.days(11)),       # (days) - source: US CDC
             p_death = ss.bernoulli(p=0.005), # Probability of death
         )
         self.update_pars(pars=pars, **kwargs)
 
         # SIR are added automatically, here we add E
-        self.add_states(
-            ss.BoolArr('exposed', label='Exposed'),
+        self.define_states(
+            ss.State('exposed', label='Exposed'),
             ss.FloatArr('ti_exposed', label='Time of exposure'),
         )
 
@@ -39,9 +39,9 @@ def __init__(self, pars=None, *args, **kwargs):
     def infectious(self):
         return self.infected | self.exposed
 
-    def update_pre(self):
+    def step_state(self):
         # Progress exposed -> infected
-        ti = self.sim.ti
+        ti = self.ti
         infected = (self.exposed & (self.ti_infected <= ti)).uids
         self.exposed[infected] = False
         self.infected[infected] = True
@@ -60,8 +60,7 @@ def update_pre(self):
     def set_prognoses(self, uids, source_uids=None):
         """ Set prognoses for those who get infected """
         super().set_prognoses(uids, source_uids)
-        ti = self.sim.ti
-        dt = self.sim.dt
+        ti = self.ti
 
         self.susceptible[uids] = False
         self.exposed[uids] = True
@@ -70,7 +69,7 @@ def set_prognoses(self, uids, source_uids=None):
         p = self.pars
 
         # Determine when exposed become infected
-        self.ti_infected[uids] = ti + p.dur_exp.rvs(uids) / dt
+        self.ti_infected[uids] = ti + p.dur_exp.rvs(uids)
 
         # Sample duration of infection, being careful to only sample from the
         # distribution once per timestep.
@@ -80,12 +79,12 @@ def set_prognoses(self, 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] / dt
-        self.ti_recovered[rec_uids] = self.ti_infected[rec_uids] + dur_inf[~will_die] / dt
+        self.ti_dead[dead_uids] = self.ti_infected[dead_uids] + dur_inf[will_die]
+        self.ti_recovered[rec_uids] = self.ti_infected[rec_uids] + dur_inf[~will_die]
 
         return
 
-    def update_death(self, uids):
+    def step_die(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 6177f130..d55fe67e 100644
--- a/starsim/diseases/ncd.py
+++ b/starsim/diseases/ncd.py
@@ -19,16 +19,16 @@ class NCD(ss.Disease):
     """
     def __init__(self, pars=None, **kwargs):
         super().__init__()
-        self.default_pars(
+        self.define_pars(
             initial_risk = ss.bernoulli(p=0.3), # Initial prevalence of risk factors
-            dur_risk = ss.expon(scale=10),
-            prognosis = ss.weibull(c=2, scale=5), # Time in years between first becoming affected and death
+            dur_risk = ss.expon(scale=ss.dur(10)),
+            prognosis = ss.weibull(c=ss.years(2), scale=5), # Time in years between first becoming affected and death
         )
         self.update_pars(pars=pars, **kwargs)
-        
-        self.add_states(
-            ss.BoolArr('at_risk', label='At risk'),
-            ss.BoolArr('affected', label='Affected'),
+
+        self.define_states(
+            ss.State('at_risk', label='At risk'),
+            ss.State('affected', label='Affected'),
             ss.FloatArr('ti_affected', label='Time of becoming affected'),
             ss.FloatArr('ti_dead', label='Time of death'),
         )
@@ -48,23 +48,23 @@ def init_post(self):
         super().init_post()
         initial_risk = self.pars['initial_risk'].filter()
         self.at_risk[initial_risk] = True
-        self.ti_affected[initial_risk] = self.sim.ti + sc.randround(self.pars['dur_risk'].rvs(initial_risk) / self.sim.dt)
+        self.ti_affected[initial_risk] = self.ti + sc.randround(self.pars['dur_risk'].rvs(initial_risk))
         return initial_risk
 
-    def update_pre(self):
-        ti = self.sim.ti
+    def step_state(self):
+        ti = self.ti
         deaths = (self.ti_dead == ti).uids
         self.sim.people.request_death(deaths)
         self.log.add_data(deaths, died=True)
         self.results.new_deaths[ti] = len(deaths) # Log deaths attributable to this module
         return
 
-    def make_new_cases(self):
-        ti = self.sim.ti
+    def step(self):
+        ti = self.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] = ti + sc.randround(prog_years / self.sim.dt)
+        self.ti_dead[new_cases] = ti + sc.randround(prog_years / self.dt)
         super().set_prognoses(new_cases)
         return new_cases
 
@@ -73,17 +73,16 @@ def init_results(self):
         Initialize results
         """
         super().init_results()
-        npts = self.sim.npts
-        self.results += [
-            ss.Result(self.name, 'n_not_at_risk', npts, dtype=int, label='Not at risk'),
-            ss.Result(self.name, 'prevalence', npts, dtype=float, label='Prevalence'),
-            ss.Result(self.name, 'new_deaths', npts, dtype=int, label='Deaths'),
-        ]
+        self.define_results(
+            ss.Result('n_not_at_risk', dtype=int,   label='Not at risk'),
+            ss.Result('prevalence',    dtype=float, label='Prevalence'),
+            ss.Result('new_deaths',    dtype=int,   label='Deaths'),
+        )
         return
 
     def update_results(self):
         super().update_results()
-        ti = self.sim.ti
+        ti = self.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(self.sim.people)
         self.results.new_deaths[ti]    = np.count_nonzero(self.ti_dead == ti)
diff --git a/starsim/diseases/sir.py b/starsim/diseases/sir.py
index 939909cc..6243d8d7 100644
--- a/starsim/diseases/sir.py
+++ b/starsim/diseases/sir.py
@@ -4,7 +4,7 @@
 
 import numpy as np
 import sciris as sc
-import matplotlib.pyplot as pl
+import matplotlib.pyplot as plt
 import starsim as ss
 
 
@@ -19,23 +19,29 @@ class SIR(ss.Infection):
     results.
     """
     def __init__(self, pars=None, **kwargs):
+        """ The current implementation """
         super().__init__()
-        self.default_pars(
-            beta = 0.1,
+        self.define_pars(
+            beta = ss.beta(0.1),
             init_prev = ss.bernoulli(p=0.01),
-            dur_inf = ss.lognorm_ex(mean=6),
+            dur_inf = ss.lognorm_ex(mean=ss.dur(6)),
             p_death = ss.bernoulli(p=0.01),
         )
         self.update_pars(pars, **kwargs)
 
-        self.add_states(
-            ss.BoolArr('recovered', label='Recovered'),
+        self.define_states(
+            ss.State('susceptible', default=True, label='Susceptible'),
+            ss.State('infected', label='Infectious'),
+            ss.State('recovered', label='Recovered'),
+            ss.FloatArr('ti_infected', label='Time of infection'),
             ss.FloatArr('ti_recovered', label='Time of recovery'),
             ss.FloatArr('ti_dead', label='Time of death'),
+            ss.FloatArr('rel_sus', default=1.0, label='Relative susceptibility'),
+            ss.FloatArr('rel_trans', default=1.0, label='Relative transmission'),
         )
         return
 
-    def update_pre(self):
+    def step_state(self):
         # Progress infectious -> recovered
         sim = self.sim
         recovered = (self.infected & (self.ti_recovered <= sim.ti)).uids
@@ -50,9 +56,7 @@ def update_pre(self):
 
     def set_prognoses(self, uids, source_uids=None):
         """ Set prognoses """
-        super().set_prognoses(uids, source_uids)
-        ti = self.sim.ti
-        dt = self.sim.dt
+        ti = self.ti
         self.susceptible[uids] = False
         self.infected[uids] = True
         self.ti_infected[uids] = ti
@@ -67,30 +71,31 @@ def set_prognoses(self, 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] = ti + dur_inf[will_die] / dt # Consider rand round, but not CRN safe
-        self.ti_recovered[rec_uids] = ti + dur_inf[~will_die] / dt
+        self.ti_dead[dead_uids] = ti + dur_inf[will_die] # Consider rand round, but not CRN safe
+        self.ti_recovered[rec_uids] = ti + dur_inf[~will_die]
         return
 
-    def update_death(self, uids):
+    def step_die(self, uids):
         """ Reset infected/recovered flags for dead agents """
         self.susceptible[uids] = False
         self.infected[uids] = False
         self.recovered[uids] = False
         return
 
-    def plot(self, plot_kw=None):
+    def plot(self, **kwargs):
         """ Default plot for SIR model """
-        fig = pl.figure()
-        plot_kw = sc.mergedicts(dict(lw=2, alpha=0.8), plot_kw)
+        fig = plt.figure()
+        kw = sc.mergedicts(dict(lw=2, alpha=0.8), kwargs)
         for rkey in ['n_susceptible', 'n_infected', 'n_recovered']:
-            pl.plot(self.sim.results.yearvec, self.results[rkey], label=self.results[rkey].label, **plot_kw)
-        pl.legend(frameon=False)
-        pl.xlabel('Year')
-        pl.ylabel('Number of people')
+            plt.plot(self.timevec, self.results[rkey], label=self.results[rkey].label, **kw)
+        plt.legend(frameon=False)
+        plt.xlabel('Time')
+        plt.ylabel('Number of people')
+        plt.ylim(bottom=0)
         sc.boxoff()
         sc.commaticks()
         return fig
-    
+
 
 class SIS(ss.Infection):
     """
@@ -102,32 +107,32 @@ class SIS(ss.Infection):
     """
     def __init__(self, pars=None, *args, **kwargs):
         super().__init__()
-        self.default_pars(
-            beta = 0.05,
+        self.define_pars(
+            beta = ss.beta(0.05),
             init_prev = ss.bernoulli(p=0.01),
-            dur_inf = ss.lognorm_ex(mean=10),
-            waning = 0.05,
+            dur_inf = ss.lognorm_ex(mean=ss.dur(10)),
+            waning = ss.rate(0.05),
             imm_boost = 1.0,
         )
         self.update_pars(pars=pars, *args, **kwargs)
 
-        self.add_states(
+        self.define_states(
             ss.FloatArr('ti_recovered'),
             ss.FloatArr('immunity', default=0.0),
         )
         return
 
-    def update_pre(self):
+    def step_state(self):
         """ Progress infectious -> recovered """
-        recovered = (self.infected & (self.ti_recovered <= self.sim.ti)).uids
+        recovered = (self.infected & (self.ti_recovered <= self.ti)).uids
         self.infected[recovered] = False
         self.susceptible[recovered] = True
         self.update_immunity()
         return
-    
+
     def update_immunity(self):
         has_imm = (self.immunity > 0).uids
-        self.immunity[has_imm] = (self.immunity[has_imm])*(1 - self.pars.waning*self.sim.dt)
+        self.immunity[has_imm] = (self.immunity[has_imm])*(1 - self.pars.waning)
         self.rel_sus[has_imm] = np.maximum(0, 1 - self.immunity[has_imm])
         return
 
@@ -136,35 +141,43 @@ 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] = self.sim.ti
+        self.ti_infected[uids] = self.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] = self.sim.ti + dur_inf / self.sim.dt
+        self.ti_recovered[uids] = self.ti + dur_inf
 
         return
-    
+
     def init_results(self):
         """ Initialize results """
         super().init_results()
-        self.results += ss.Result(self.name, 'rel_sus', self.sim.npts, dtype=float, label='Relative susceptibility')
+        self.define_results(
+            ss.Result('rel_sus', dtype=float, label='Relative susceptibility')
+        )
         return
 
     def update_results(self):
         """ Store the population immunity (susceptibility) """
         super().update_results()
-        self.results['rel_sus'][self.sim.ti] = self.rel_sus.mean()
-        return 
+        self.results['rel_sus'][self.ti] = self.rel_sus.mean()
+        return
 
-    def plot(self):
+    def plot(self, **kwargs):
         """ Default plot for SIS model """
-        fig = pl.figure()
+        fig = plt.figure()
+        kw = sc.mergedicts(dict(lw=2, alpha=0.8), kwargs)
         for rkey in ['n_susceptible', 'n_infected']:
-            pl.plot(self.results[rkey], label=self.results[rkey].label)
-        pl.legend()
+            plt.plot(self.timevec, self.results[rkey], label=self.results[rkey].label, **kw)
+        plt.legend(frameon=False)
+        plt.xlabel('Time')
+        plt.ylabel('Number of people')
+        plt.ylim(bottom=0)
+        sc.boxoff()
+        sc.commaticks()
         return fig
 
 
@@ -175,27 +188,27 @@ def plot(self):
 class sir_vaccine(ss.Vx):
     """
     Create a vaccine product that affects the probability of infection.
-    
-    The vaccine can be either "leaky", in which everyone who receives the vaccine 
-    receives the same amount of protection (specified by the efficacy parameter) 
+
+    The vaccine can be either "leaky", in which everyone who receives the vaccine
+    receives the same amount of protection (specified by the efficacy parameter)
     each time they are exposed to an infection. The alternative (leaky=False) is
     that the efficacy is the probability that the vaccine "takes", in which case
     that person is 100% protected (and the remaining people are 0% protected).
-    
+
     Args:
         efficacy (float): efficacy of the vaccine (0<=efficacy<=1)
         leaky (bool): see above
     """
     def __init__(self, pars=None, *args, **kwargs):
         super().__init__()
-        self.default_pars(
+        self.define_pars(
             efficacy = 0.9,
             leaky = True
         )
         self.update_pars(pars, **kwargs)
         return
 
-    def administer(self, people, uids):        
+    def administer(self, people, uids):
         if self.pars.leaky:
             people.sir.rel_sus[uids] *= 1-self.pars.efficacy
         else:
diff --git a/starsim/diseases/syphilis.py b/starsim/diseases/syphilis.py
index badfb335..77846408 100644
--- a/starsim/diseases/syphilis.py
+++ b/starsim/diseases/syphilis.py
@@ -15,17 +15,17 @@ class Syphilis(ss.Infection):
     def __init__(self, pars=None, **kwargs):
         # Parameters
         super().__init__()
-        self.default_pars(
+        self.define_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/
+            dur_exposed = ss.lognorm_ex(mean=1 / 12, std=1 / 36),  # https://pubmed.ncbi.nlm.nih.gov/9101629/
+            dur_primary = ss.lognorm_ex(mean=1.5 / 12, std=1 / 36),  # https://pubmed.ncbi.nlm.nih.gov/9101629/
             dur_secondary = ss.normal(loc=3.6 / 12, scale=1.5 / 12),  # https://pubmed.ncbi.nlm.nih.gov/9101629/
-            dur_latent_temp = ss.lognorm_ex(mean=1, stdev=6 / 12),  # https://pubmed.ncbi.nlm.nih.gov/9101629/
-            dur_latent_long = ss.lognorm_ex(mean=20, stdev=8),  # https://pubmed.ncbi.nlm.nih.gov/9101629/
+            dur_latent_temp = ss.lognorm_ex(mean=1, std=6 / 12),  # https://pubmed.ncbi.nlm.nih.gov/9101629/
+            dur_latent_long = ss.lognorm_ex(mean=20, std=8),  # https://pubmed.ncbi.nlm.nih.gov/9101629/
             p_latent_temp = ss.bernoulli(p=0.25),  # https://pubmed.ncbi.nlm.nih.gov/9101629/
             p_tertiary = ss.bernoulli(p=0.35),  # https://www.ncbi.nlm.nih.gov/pmc/articles/PMC4917057/
 
@@ -54,18 +54,18 @@ def __init__(self, pars=None, **kwargs):
         )
         self.update_pars(pars, **kwargs)
 
-        self.add_states(
+        self.define_states(
             # Adult syphilis states
-            ss.BoolArr('exposed', label='Exposed'),  # AKA incubating. Free of symptoms, not transmissible
-            ss.BoolArr('primary', label='Primary'),  # Primary chancres
-            ss.BoolArr('secondary', label="Secondary"),  # Inclusive of those who may still have primary chancres
-            ss.BoolArr('latent_temp', label="Latent temporary"),  # Relapses to secondary (~1y)
-            ss.BoolArr('latent_long', label="Latent long"),  # Can progress to tertiary or remain here
-            ss.BoolArr('tertiary', label="Tertiary"),  # Includes complications (cardio/neuro/disfigurement)
-            ss.BoolArr('immune', label="Immune"),  # After effective treatment people may acquire temp immunity
-            ss.BoolArr('ever_exposed', label="Ever exposed"),  # Anyone ever exposed - stays true after treatment
-            ss.BoolArr('congenital', label="Congenital"),  # Congenital syphilis states
-    
+            ss.State('exposed', label='Exposed'),  # AKA incubating. Free of symptoms, not transmissible
+            ss.State('primary', label='Primary'),  # Primary chancres
+            ss.State('secondary', label="Secondary"),  # Inclusive of those who may still have primary chancres
+            ss.State('latent_temp', label="Latent temporary"),  # Relapses to secondary (~1y)
+            ss.State('latent_long', label="Latent long"),  # Can progress to tertiary or remain here
+            ss.State('tertiary', label="Tertiary"),  # Includes complications (cardio/neuro/disfigurement)
+            ss.State('immune', label="Immune"),  # After effective treatment people may acquire temp immunity
+            ss.State('ever_exposed', label="Ever exposed"),  # Anyone ever exposed - stays true after treatment
+            ss.State('congenital', label="Congenital"),  # Congenital syphilis states
+
             # Timestep of state changes
             ss.FloatArr('ti_exposed', label='Time of exposure'),
             ss.FloatArr('ti_primary', label='Time of primary'),
@@ -110,19 +110,18 @@ def infectious(self):
     def init_results(self):
         """ Initialize results """
         super().init_results()
-        npts = self.sim.npts
-        self.results += [
-            ss.Result(self.name, 'new_nnds',       npts, dtype=int, scale=True, label='New neonatal deaths'),
-            ss.Result(self.name, 'new_stillborns', npts, dtype=int, scale=True, label='New stillborns'),
-            ss.Result(self.name, 'new_congenital', npts, dtype=int, scale=True, label='New congenital syphilis'),
-        ]
+        self.define_results(
+            ss.Result('new_nnds',       dtype=int, label='New neonatal deaths'),
+            ss.Result('new_stillborns', dtype=int, label='New stillborns'),
+            ss.Result('new_congenital', dtype=int, label='New congenital syphilis'),
+        )
         return
 
-    def update_pre(self):
+    def step_state(self):
         """ Updates prior to interventions """
 
         # Primary
-        ti = self.sim.ti
+        ti = self.ti
         primary = self.exposed & (self.ti_primary <= ti)
         self.primary[primary] = True
         self.exposed[primary] = False
@@ -136,10 +135,6 @@ def update_pre(self):
             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(ti+1)
-        self.pars.dur_secondary.jump(ti+1)
-
         # Secondary reactivation from latent
         secondary_from_latent = (self.latent_temp & (self.ti_secondary <= ti)).uids
         if len(secondary_from_latent) > 0:
@@ -185,7 +180,7 @@ def update_pre(self):
 
     def update_results(self):
         super().update_results()
-        ti = self.sim.ti
+        ti = self.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)
@@ -195,10 +190,8 @@ def set_prognoses(self, uids, source_uids=None):
         """
         Set initial prognoses for adults newly infected with syphilis
         """
-        super().set_prognoses(uids, source_uids)
-        
-        ti = self.sim.ti
-        dt = self.sim.dt
+
+        ti = self.ti
 
         self.susceptible[uids] = False
         self.ever_exposed[uids] = True
@@ -210,17 +203,16 @@ def set_prognoses(self, uids, source_uids=None):
         # Set future dates and probabilities
         # Exposed to primary
         dur_exposed = self.pars.dur_exposed.rvs(uids)
-        self.ti_primary[uids] = ti + rr(dur_exposed / dt)
+        self.ti_primary[uids] = ti + rr(dur_exposed)
 
         # Primary to secondary
         dur_primary = self.pars.dur_primary.rvs(uids)
-        self.ti_secondary[uids] = self.ti_primary[uids] + rr(dur_primary / dt)
+        self.ti_secondary[uids] = self.ti_primary[uids] + rr(dur_primary)
         return
 
     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
@@ -229,31 +221,30 @@ def set_secondary_prognoses(self, 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 / dt)
+        self.ti_latent_temp[latent_temp_uids] = self.ti_secondary[latent_temp_uids] + rr(dur_secondary_temp)
 
         dur_secondary_long = dur_secondary[~latent_temp]
-        self.ti_latent_long[latent_long_uids] = self.ti_secondary[latent_long_uids] + rr(dur_secondary_long / dt)
+        self.ti_latent_long[latent_long_uids] = self.ti_secondary[latent_long_uids] + rr(dur_secondary_long)
 
         return
 
     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 / self.sim.dt)
+        self.ti_secondary[uids] = self.ti_latent_temp[uids] + rr(dur_latent_temp)
         return
 
     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 / dt)
+        self.ti_secondary[uids] = self.ti_latent_temp[uids] + rr(dur_latent_long)
 
         # 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] / dt)
+        self.ti_tertiary[tertiary_uids] = self.ti_latent_long[tertiary_uids] + rr(dur_latent_long[tertiary])
 
         return
 
@@ -275,12 +266,13 @@ def set_congenital(self, uids, source_uids=None):
                 time_to_birth = -sim.people.age.raw # TODO: make nicer
 
                 # Schedule events
+                ratio = ss.time_ratio(unit1='year', dt1=1.0, unit2=self.unit, dt2=self.dt) # TODO: think about simplifying
                 for oi, outcome in enumerate(self.pars.birth_outcome_keys):
                     o_uids = state_uids[assigned_outcomes == oi]
                     if len(o_uids) > 0:
                         ti_outcome = f'ti_{outcome}'
                         vals = getattr(self, ti_outcome)
-                        vals[o_uids] = sim.ti + rr(time_to_birth[o_uids] / sim.dt)
+                        vals[o_uids] = sim.ti + rr(time_to_birth[o_uids] * ratio)
                         setattr(self, ti_outcome, vals)
 
         return
@@ -334,23 +326,24 @@ def _parse_product_str(self, product):
         else:
             return products[product]
 
-    def check_eligibility(self, sim):
+    def check_eligibility(self):
         """
         Return an array of indices of agents eligible for screening at time t, i.e. sexually active
         females in age range, plus any additional user-defined eligibility
         """
+        sim = self.sim
         if self.eligibility is not None:
             is_eligible = self.eligibility(sim)
         else:
             is_eligible = sim.people.auids  # Probably not required
         return is_eligible
 
-    def init_pre(self, sim):
-        super().init_pre(sim)
-        self.results += [
-            ss.Result('syphilis', 'n_screened', sim.npts, dtype=int, scale=True, label='Number screened'),
-            ss.Result('syphilis', 'n_dx', sim.npts, dtype=int, scale=True, label='Number diagnosed'),
-        ]
+    def init_results(self):
+        super().init_results()
+        self.define_results(
+            ss.Result('n_screened', dtype=int, label='Number screened'),
+            ss.Result('n_dx',       dtype=int, label='Number diagnosed'),
+        )
         return
 
 
@@ -369,12 +362,15 @@ def _parse_product_str(self, product):
         else:
             return products[product]
 
-    def init_pre(self, sim):
-        super().init_pre(sim)
-        self.results += ss.Result('syphilis', 'n_tx', sim.npts, dtype=int, scale=True, label='Number treated')
+    def init_results(self):
+        super().init_results()
+        self.define_results(
+            ss.Result('n_tx', dtype=int, label='Number treated')
+        )
         return
 
-    def apply(self, sim):
-        treat_inds = super().apply(sim)
+    def step(self):
+        sim = self.sim
+        treat_inds = super().step()
         sim.people.syphilis.infected[treat_inds] = False
         self.results['n_tx'][sim.ti] += len(treat_inds)
diff --git a/starsim/distributions.py b/starsim/distributions.py
index d3512a88..42bb243f 100644
--- a/starsim/distributions.py
+++ b/starsim/distributions.py
@@ -1,49 +1,40 @@
 """
 Define random-number-safe distributions.
 """
-
-import numpy as np
 import sciris as sc
+import numpy as np
+import numba as nb
 import scipy.stats as sps
 import starsim as ss
-import matplotlib.pyplot as pl
+import matplotlib.pyplot as plt
 
-__all__ = ['find_dists', 'link_dists', 'make_dist', 'dist_list', 'Dists', 'Dist']
+__all__ = ['link_dists', 'make_dist', 'dist_list', 'Dists', 'Dist']
 
 
 def str2int(string, modulo=1_000_000):
     """
     Convert a string to an int
-    
-    Cannot use Python's built-in hash() since it's randomized for strings, but
-    this is almost as fast (and 5x faster than hashlib).
-    """
-    return int.from_bytes(string.encode(), byteorder='big') % modulo
-
-
-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, **kwargs)
-    if verbose: print(f'Found {len(tree)} objects')
-    for trace,val in tree.items():
-        if isinstance(val, Dist):
-            out[trace] = val
-            if verbose: print(f'  {trace} is a dist ({len(out)})')
+
+    Cannot use Python's built-in hash() since it's randomized for strings. Hashlib
+    is 5x slower than int.from_bytes(string.encode(), byteorder='big'), but should
+    only add a couple milliseconds to a typical sim.
+    """
+    integer = int.from_bytes(string.encode(), byteorder='big')
+    # integer = sc.sha(string, asint=True) # Hash the string to an integer
+    out = integer % modulo # Don't need all of it, this is more user-friendly
     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() """
+def link_dists(obj, sim, module=None, overwrite=False, init=False, **kwargs): # TODO: actually link the distributions to the modules! Currently this only does the opposite, but should have mod.dists as well
+    """ Link distributions to the sim and the module; used in module.init() and people.init() """
     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()
+    dists = sc.search(obj, type=Dist, **kwargs)
+    for dist in dists.values():
+        dist.link_sim(sim, overwrite=overwrite)
+        dist.link_module(module, overwrite=overwrite)
+        if init: # Usually this is false since usually these are initialized centrally by the sim
+            dist.init()
     return
 
 
@@ -75,10 +66,10 @@ def __init__(self, obj=None, *args, base_seed=None, sim=None):
         self.initialized = False
         return
 
-    def initialize(self, obj=None, base_seed=None, sim=None, force=False):
+    def init(self, obj=None, base_seed=None, sim=None, force=False):
         """
         Set the base seed, find and initialize all distributions in an object
-        
+
         In practice, the object is usually a Sim, but can be anything.
         """
         if base_seed:
@@ -92,21 +83,21 @@ def initialize(self, obj=None, base_seed=None, sim=None, force=False):
         if obj is None:
             errormsg = 'Must supply a container that contains one or more Dist objects, typically the sim'
             raise ValueError(errormsg)
-            
+
         # Do not look for distributions in the people states, since they shadow the "real" states
         skip = id(sim.people._states) if sim is not None else None
-        
+
         # Find and initialize the distributions
-        self.dists = find_dists(obj, skip=skip)
+        self.dists = sc.search(obj, type=Dist, skip=skip, flatten=True)
         for trace,dist in self.dists.items():
             if not dist.initialized or force:
-                dist.initialize(trace=trace, seed=base_seed, sim=sim, force=force)
-        
+                dist.init(trace=trace, seed=base_seed, sim=sim, force=force)
+
         # Confirm the seeds are unique
         self.check_seeds()
         self.initialized = True
         return self
-    
+
     def check_seeds(self):
         """ Check that no two distributions share the same seed """
         checked = dict()
@@ -118,8 +109,8 @@ def check_seeds(self):
                 checked[seed] = dist
         return
 
-    def jump(self, to=None, delta=1):
-        """ Advance all RNGs, e.g. to timestep "to", by jumping """
+    def jump(self, to=None, delta=1, force=False):
+        """ Advance all RNGs, e.g. to call "to", by jumping """
         out = sc.autolist()
 
         # Do not jump if centralized
@@ -127,7 +118,24 @@ def jump(self, to=None, delta=1):
             return out
 
         for dist in self.dists.values():
-            out += dist.jump(to=to, delta=delta)
+            out += dist.jump(to=to, delta=delta, force=force)
+        return out
+
+    def jump_dt(self, ti=None, force=False): # Could this be simplified with jump(), or nice to have the parallel with Dist?
+        """
+        Advance all RNGs to the next timestep
+
+        Args:
+            ti (int): if specified, jump to this timestep (default: current sim timestep)
+        """
+        out = sc.autolist()
+
+        # Do not jump if centralized
+        if ss.options._centralized:
+            return out
+
+        for dist in self.dists.values():
+            out += dist.jump_dt(ti=ti, force=force)
         return out
 
     def reset(self):
@@ -137,22 +145,34 @@ def reset(self):
             out += dist.reset()
         return out
 
+    def copy_to_module(self, module):
+        """ Copy the Sim's Dists object to the specified module """
+        matches = {key:dist for key,dist in self.dists.items() if id(dist.module) == id(module)} # Find which dists belong to this module
+        if len(matches):
+            new = Dists() # Create an empty Dists object
+            new.__dict__.update(self.__dict__) # Shallow-copy all values over
+            new.obj = module # Replace the module
+            new.dists = sc.objdict(matches) # Replace the dists with a shallow copy the matching dists
+            module.dists = new # Copy to the module
+        else:
+            new = None
+        return new
 
 class Dist:
     """
     Base class for tracking one random number generator associated with one distribution,
     i.e. one decision per timestep.
-    
+
     See ss.dist_list for a full list of supported distributions.
-    
+
     Although it's possible in theory to define a custom distribution (i.e., not
     one from NumPy or SciPy), in practice this is difficult. The distribution needs
-    to have both a way to return random variables (easy), as well as the probability
+    to have both a way to return random variates (easy), as well as the probability
     point function (inverse CDF). In addition, the distribution must be able to
     take a NumPy RNG as its bit generator. It's easier to just use a default Dist
-    (e.g., ss.random()), and then take its output as input (i.e., quantiles) for 
+    (e.g., ss.random()), and then take its output as input (i.e., quantiles) for
     whatever custom distribution you want to create.
-    
+
     Args:
         dist (rv_generic): optional; a scipy.stats distribution (frozen or not) to get the ppf from
         distname (str): the name for this class of distribution (e.g. "uniform")
@@ -164,13 +184,13 @@ class Dist:
         sim (Sim): usually determined on initialization; the sim to use as input to callable parameters
         module (Module): usually determined on initialization; the module to use as input to callable parameters
         kwargs (dict): parameters of the distribution
-        
+
     **Examples**::
-        
+
         dist = ss.Dist(sps.norm, loc=3)
         dist.rvs(10) # Return 10 normally distributed random numbers
     """
-    def __init__(self, dist=None, distname=None, name=None, seed=None, offset=None, 
+    def __init__(self, dist=None, distname=None, name=None, seed=None, offset=None,
                  strict=True, auto=True, sim=None, module=None, debug=False, **kwargs):
         # If a string is provided as "dist" but there's no distname, swap the dist and the distname
         if isinstance(dist, str) and distname is None:
@@ -179,7 +199,7 @@ def __init__(self, dist=None, distname=None, name=None, seed=None, offset=None,
         self.dist = dist # The type of distribution
         self.distname = distname
         self.name = name
-        self.pars = sc.dictobj(kwargs) # The user-defined kwargs
+        self.pars = sc.objdict(kwargs) # The user-defined kwargs
         self.seed = seed # Usually determined once added to the container
         self.offset = offset
         self.module = module
@@ -188,8 +208,8 @@ def __init__(self, dist=None, distname=None, name=None, seed=None, offset=None,
         self.strict = strict
         self.auto = auto
         self.debug = debug
-        
-        # Auto-generated 
+
+        # Auto-generated
         self.rvs_func = None # The default function to call in make_rvs() to generate the random numbers
         self.dynamic_pars = None # Whether or not the distribution has array or callable parameters
         self._pars = None # Validated and transformed (if necessary) parameters
@@ -197,8 +217,9 @@ def __init__(self, dist=None, distname=None, name=None, seed=None, offset=None,
         self._size = None # Internal variable to keep track of actual number of random variates asked for
         self._uids = None # Internal variable to track currently-in-use UIDs
         self._slots = None # Internal variable to track currently-in-use slots
-        
+
         # History and random state
+        self.dt_jump_size = 1000 # How much to advance the RNG for each timestep (must be larger than the number times dist is called per timestep)
         self.rng = None # The actual RNG generator for generating random numbers
         self.trace = None # The path of this object within the parent
         self.ind = 0 # The index of the RNG (usually updated on each timestep)
@@ -207,32 +228,19 @@ def __init__(self, dist=None, distname=None, name=None, seed=None, offset=None,
         self.ready = True
         self.initialized = False
         if not strict: # Otherwise, wait for a sim
-            self.initialize()
+            self.init()
         return
-    
+
     def __repr__(self):
         """ Custom display to show state of object """
-        tracestr = '<no trace>' if self.trace is None else f"{self.trace}"
-        classname = self.__class__.__name__
-        diststr = ''
-        if classname == 'Dist':
-            if self.dist is not None:
-                try:
-                    diststr = f'dist={self.dist.name}, '
-                except:
-                    try: # What is wrong with you, SciPy -- after initialization, it moves here
-                        diststr = f'dist={self.dist.dist.name}, '
-                    except:
-                        diststr = f'dist={type(self.dist)}'
-            elif self.distname is not None:
-                diststr = f'dist={self.distname}, '
-        string = f'ss.{classname}({tracestr}, {diststr}pars={dict(self.pars)})'
+        j = sc.dictobj(self.to_json())
+        string = f'ss.{j.classname}({j.tracestr}, {j.diststr}pars={j.pars})'
         return string
-    
+
     def disp(self):
         """ Return full display of object """
         return sc.pr(self)
-    
+
     def show_state(self, output=False):
         """ Show the state of the object """
         keys = ['pars', 'trace', 'offset', 'seed', 'ind', 'called', 'ready', 'state_int']
@@ -245,11 +253,11 @@ def show_state(self, output=False):
         else:
             print(s)
             return
-    
+
     def __call__(self, n=1):
         """ Alias to self.rvs() """
         return self.rvs(n=n)
-    
+
     def set(self, *args, dist=None, **kwargs):
         """ Set (change) the distribution type, or one or more parameters of the distribution """
         if dist:
@@ -272,23 +280,23 @@ def set(self, *args, dist=None, **kwargs):
     def bitgen(self):
         try:    return self.rng._bit_generator
         except: return None
-    
+
     @property
     def state(self):
         """ Get the current state """
         try:    return self.bitgen.state
         except: return None
-        
+
     @property
     def state_int(self):
         """ Get the integer corresponding to the current state """
         try:    return self.state['state']['state']
         except: return None
-    
+
     def get_state(self):
         """ Return a copy of the state """
         return self.state.copy()
-    
+
     def make_history(self, reset=False):
         """ Store the current state in history """
         if reset:
@@ -299,12 +307,12 @@ def make_history(self, reset=False):
     def reset(self, state=0):
         """
         Restore state, allowing the same numbers to be resampled
-        
+
         Use 0 for original state, -1 for most recent state.
-        
+
         **Example**::
-            
-            dist = ss.random(seed=5).initialize()
+
+            dist = ss.random(seed=5).init()
             r1 = dist(5)
             r2 = dist(5)
             dist.reset(-1)
@@ -321,37 +329,57 @@ def reset(self, state=0):
         self.ready = True
         return self.state
 
-    def jump(self, to=None, delta=1):
+    def jump(self, to=None, delta=1, force=False):
         """ Advance the RNG, e.g. to timestep "to", by jumping """
-        
-        # Do not jump if centralized
+
+        # Do not jump if centralized # TODO: remove
         if ss.options._centralized:
             return self.state
 
+        # Validation
         jumps = to if (to is not None) else self.ind + delta
+        if self.ind >= jumps and not force:
+            errormsg = f'You tried to jump the distribution "{self}" to state {jumps}, but the ' \
+                       f'RNG state is already at state {self.ind}, meaning you will draw the same ' \
+                        'random numbers twice. If you are sure you want to do this, set force=True.'
+            raise DistSeedRepeatError(msg=errormsg)
+
+        # Do the jumping
         self.ind = jumps
         self.reset() # First reset back to the initial state (used in case of different numbers of calls)
         if jumps: # Seems to randomize state if jumps=0
             self.bitgen.state = self.bitgen.jumped(jumps=jumps).state # Now take "jumps" number of jumps
         return self.state
-    
-    def initialize(self, trace=None, seed=None, module=None, sim=None, slots=None, force=False):
+
+    def jump_dt(self, ti=None, force=False):
+        """
+        Automatically jump on the next value of dt
+
+        Args:
+            ti (int): if specified, jump to this timestep (default: current module timestep plus one)
+        """
+        if ti is None:
+            ti = self.module.ti + 1
+        to = self.dt_jump_size*ti
+        return self.jump(to=to, force=force)
+
+    def init(self, trace=None, seed=None, module=None, sim=None, slots=None, force=False):
         """ Calculate the starting seed and create the RNG """
-        
+
         if self.initialized is True and not force: # Don't warn if we have a partially initialized distribution
             msg = f'Distribution {self} is already initialized, use force=True if intentional'
             ss.warn(msg)
-        
+
         # Calculate the offset (starting seed)
         self.process_seed(trace, seed)
-        
+
         # Create the actual RNG
         if ss.options._centralized:
             self.rng = np.random.mtrand._rand # If _centralized, return the centralized numpy random number instance
         else:
             self.rng = np.random.default_rng(seed=self.seed)
         self.make_history(reset=True)
-        
+
         # Handle the sim, module, and slots
         self.link_sim(sim)
         self.link_module(module)
@@ -363,7 +391,7 @@ def initialize(self, trace=None, seed=None, module=None, sim=None, slots=None, f
                 ss.warn(warnmsg)
         if slots is not None:
             self.slots = slots
-            
+
         # Initialize the distribution and finalize
         self.process_dist()
         self.process_pars(call=False)
@@ -378,21 +406,21 @@ def initialize(self, trace=None, seed=None, module=None, sim=None, slots=None, f
         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 """
+        """ Shortcut for linking the sim, only overwriting an existing one if overwrite=True; not for the user """
         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 """
+        """ Obtain the seed offset by hashing the path to this distribution; not for the user """
         unique_name = trace or self.trace or self.name
         if unique_name:
             if not self.name:
@@ -403,34 +431,35 @@ def process_seed(self, trace=None, seed=None):
             self.offset = self.offset or 0
         self.seed = self.offset + (seed or self.seed or 0)
         return
-    
+
     def process_dist(self):
-        """ Ensure the distribution works """
-        
+        """ Ensure the distribution works; not for the user """
+
         # Handle a SciPy distribution, if provided
         if self.dist is not None:
-            
+
             # Pull out parameters of an already-frozen distribution
             if isinstance(self.dist, sps._distn_infrastructure.rv_frozen):
                 if not self.initialized: # Don't do this more than once
                     self.pars = sc.dictobj(sc.mergedicts(self.pars, self.dist.kwds))
-            
+
             # Convert to a frozen distribution
             if isinstance(self.dist, sps._distn_infrastructure.rv_generic):
                 spars = self.process_pars(call=False)
-                self.dist = self.dist(**spars) 
-                
+                spars.pop('dtype', None) # Not a valid arg for SciPy distributions
+                self.dist = self.dist(**spars)
+
             # Override the default random state with the correct one
-            self.dist.random_state = self.rng 
-            
+            self.dist.random_state = self.rng
+
         # Set the default function for getting the rvs
         if self.distname is not None and hasattr(self.rng, self.distname): # Don't worry if it doesn't, it's probably being manually overridden
             self.rvs_func = self.distname # e.g. self.rng.uniform -- can't use the actual function because can become linked to the wrong RNG
-        
+
         return
-    
+
     def process_size(self, n=1):
-        """ Handle an input of either size or UIDs and calculate size, UIDs, and slots """
+        """ Handle an input of either size or UIDs and calculate size, UIDs, and slots; not for the user """
         if np.isscalar(n) or isinstance(n, tuple):  # If passing a non-scalar size, interpret as dimension rather than UIDs iff a tuple
             uids = None
             slots = None
@@ -438,6 +467,9 @@ def process_size(self, n=1):
         else:
             uids = ss.uids(n)
             if len(uids):
+                if self.slots is None:
+                    errormsg = f'Could not find any slots in {self}. Did you remember to initialize the distribution with the sim?'
+                    raise ValueError(errormsg)
                 slots = self.slots[uids]
                 if len(slots): # Handle case where uids is boolean
                     size = slots.max() + 1
@@ -446,83 +478,115 @@ def process_size(self, n=1):
             else:
                 slots = np.array([])
                 size = 0
-        
+
         self._n = n
         self._size = size
         self._uids = uids
         self._slots = slots
         return size, slots
-    
+
     def process_pars(self, call=True):
-        """ Ensure the supplied dist and parameters are valid, and initialize them; called automatically """
+        """ Ensure the supplied dist and parameters are valid, and initialize them; not for the user """
+        self._timepar = None # Time rescalings need to be done after distributions are calculated; store the correction factor here
         self._pars = sc.cp(self.pars) # The actual keywords; shallow copy, modified below for special cases
         if call:
             self.call_pars() # Convert from function to values if needed
         spars = self.sync_pars() # Synchronize parameters between the NumPy and SciPy distributions
         return spars
-    
+
+    def preprocess_timepar(self, key, timepar):
+        """ Method to handle how timepars are processed; not for the user. By default, scales the output of the distribution. """
+        if self._timepar is None: # Store this here for later use
+            self._timepar = sc.dcp(timepar) # Make a copy to avoid modifying the original
+        elif timepar.factor != self._timepar.factor:
+            errormsg = f'Cannot have time parameters in the same distribution with inconsistent unit/dt values: {self._pars}'
+            raise ValueError(errormsg)
+        self._pars[key] = timepar.v # Use the raw value, since it could be anything (including a function)
+        return timepar.v # Also use this for the rest of the loop
+
+    def convert_callable(self, key, val, size, uids):
+        """ Method to handle how callable parameters are processed; not for the user """
+        size_par = uids if uids is not None else size
+        out = val(self.module, self.sim, size_par)
+        val = np.asarray(out) # Necessary since FloatArrs don't allow slicing # TODO: check if this is correct
+        self._pars[key] = val
+        return val
+
+    def call_par(self, key, val, size, uids):
+        """ Check if this parameter needs to be called to be turned into an array; not for the user """
+        if isinstance(val, ss.TimePar): # If it's a time parameter, transform it to a float now
+            val = self.preprocess_timepar(key, val)
+        if callable(val) and not isinstance(val, type): # If the parameter is callable, then call it (types can appear as callable)
+            val = self.convert_callable(key, val, size, uids)
+        return val
+
     def call_pars(self):
-        """ Check if any parameters need to be called to be turned into arrays """
-        
+        """ Check if any parameters need to be called to be turned into arrays; not for the user """
+
         # Initialize
         size, uids = self._size, self._uids
         if self.dynamic_pars != False: # Allow "False" to prevent ever using dynamic pars (used in ss.choice())
             self.dynamic_pars = None
-        
+
         # Check each parameter
         for key,val in self._pars.items():
-            
-            # If the parameter is callable, then call it
-            if callable(val) and not isinstance(val, type): # Types can appear as callable
-                size_par = uids if uids is not None else size
-                out = val(self.module, self.sim, size_par) # TODO: swap order to sim, module, size?
-                val = np.asarray(out) # Necessary since UIDArrays don't allow slicing # TODO: check if this is correct
-                self._pars[key] = val
-            
+            val = self.call_par(key, val, size, uids)
+
             # If it's iterable and UIDs are provided, then we need to use array-parameter logic
             if self.dynamic_pars is None and np.iterable(val) and uids is not None:
                 self.dynamic_pars = True
         return
-    
+
     def sync_pars(self):
-        """ Perform any necessary synchronizations or transformations on distribution parameters """
+        """ Perform any necessary synchronizations or transformations on distribution parameters; not for the user """
         self.update_dist_pars()
         return self._pars
-    
+
     def update_dist_pars(self, pars=None):
-        """ Update SciPy distribution parameters """
+        """ Update SciPy distribution parameters; not for the user """
         if self.dist is not None:
             pars = pars if pars is not None else self._pars
             self.dist.kwds = pars
         return
-    
+
     def rand(self, size):
         """ Simple way to get simple random numbers """
-        return self.rng.random(size)
-    
+        return self.rng.random(size, dtype=ss.dtypes.float) # Up to 2x faster with float32
+
     def make_rvs(self):
-        """ Return default random numbers for scalar parameters """
+        """ Return default random numbers for scalar parameters; not for the user """
         if self.rvs_func is not None:
             rvs_func = getattr(self.rng, self.rvs_func) # Can't store this because then it references the wrong RNG after copy
-            rvs = rvs_func(**self._pars, size=self._size)
+            rvs = rvs_func(size=self._size, **self._pars)
         elif self.dist is not None:
             rvs = self.dist.rvs(self._size)
         else:
             errormsg = 'Dist.rvs() failed: no valid NumPy/SciPy function found in this Dist. Has it been created and initialized correctly?'
             raise ValueError(errormsg)
         return rvs
-    
+
     def ppf(self, rands):
-        """ Return default random numbers for array parameters """
+        """ Return default random numbers for array parameters; not for the user """
         rvs = self.dist.ppf(rands)
         return rvs
-    
+
+    def postprocess_timepar(self, rvs):
+        """ Scale random variates after generation; not for the user """
+        timepar = self._timepar # Shorten
+        self._timepar = None # Remove the timepar which is no longer needed
+        timepar.v = rvs # Replace the base value with the random variates
+        timepar.update_cached() # Recalculate the factor and values with the time scaling
+        rvs = timepar.values # Replace the rvs with the scaled version
+        if isinstance(rvs, np.ndarray): # This can be false when converting values for a Bernoulli distribution (in which case rvs are actually dist parameters)
+            rvs = rvs.astype(rvs.dtype) # Replace the random variates with the scaled version, and preserve type
+        return rvs
+
     def rvs(self, n=1, reset=False):
         """
-        Get random variables
-        
+        Get random variates -- use this!
+
         Args:
-            n (int/tuple/arr): if an int or tuple, return this many random variables; if an array, treat as UIDs
+            n (int/tuple/arr): if an int or tuple, return this many random variates; if an array, treat as UIDs
             reset (bool): whether to automatically reset the random number distribution state after being called
         """
         # Check for readiness
@@ -530,32 +594,36 @@ def rvs(self, n=1, reset=False):
             raise DistNotInitializedError(self)
         if not self.ready and self.strict and not ss.options._centralized:
             raise DistNotReadyError(self)
-        
+
         # Figure out size, UIDs, and slots
         size, slots = self.process_size(n)
-        
+
         # 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
+            return np.array([], dtype=ss.dtypes.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
         self.process_pars()
-        
+
         # Actually get the random numbers
         if self.dynamic_pars:
-            rands = self.rand(size)[slots] # Get random values 
+            rands = self.rand(size)[slots] # Get random values
             rvs = self.ppf(rands) # Convert to actual values via the PPF
         else:
             rvs = self.make_rvs() # Or, just get regular values
             if self._slots is not None:
                 rvs = rvs[self._slots]
-        
+
+        # Scale by time if needed
+        if self._timepar is not None:
+            rvs = self.postprocess_timepar(rvs)
+
         # Tidy up
         self.called += 1
         if reset:
@@ -565,46 +633,72 @@ def rvs(self, n=1, reset=False):
         elif self.strict:
             self.ready = False
         if self.debug:
-            simstr = f'on ti={self.sim.ti} ' if self.sim else ''
+            tistr   = f'on ti={self.module.ti} ' if self.module else ''
             sizestr = f'with size={self._size}, '
             slotstr = f'Σ(slots)={self._slots.sum()}, ' if self._slots else '<no slots>, '
-            rvstr = f'Σ(rvs)={rvs.sum():0.2f}, |rvs|={rvs.mean():0.4f}'
+            rvstr   = f'Σ(rvs)={rvs.sum():0.2f}, |rvs|={rvs.mean():0.4f}'
             pre_state = str(self.history[-1]['state']['state'])[-5:]
             post_state = str(self.state_int)[-5:]
             statestr = f"state {pre_state}→{post_state}"
-            print(f'Debug: {self} called {simstr}{sizestr}{slotstr}{rvstr}, {statestr}')
+            print(f'Debug: {self} called {tistr}{sizestr}{slotstr}{rvstr}, {statestr}')
             assert pre_state != post_state # Always an error if the state doesn't change after drawing random numbers
-            
+
         return rvs
 
+    def to_json(self):
+        """ Return a dictionary representation of the Dist """
+        tracestr = '<no trace>' if self.trace is None else f"{self.trace}"
+        classname = self.__class__.__name__
+        diststr = ''
+        if classname == 'Dist':
+            if self.dist is not None:
+                try:
+                    diststr = f'dist={self.dist.name}, '
+                except:
+                    try: # What is wrong with you, SciPy -- after initialization, it moves here
+                        diststr = f'dist={self.dist.dist.name}, '
+                    except:
+                        diststr = f'dist={type(self.dist)}'
+            elif self.distname is not None:
+                diststr = f'dist={self.distname}, '
+        out = dict(
+            type = 'Dist',
+            classname = classname,
+            tracestr = tracestr,
+            diststr = diststr,
+            pars = dict(self.pars),
+        )
+        return out
+
 
     def plot_hist(self, n=1000, bins=None, fig_kw=None, hist_kw=None):
         """ Plot the current state of the RNG as a histogram """
-        pl.figure(**sc.mergedicts(fig_kw))
+        plt.figure(**sc.mergedicts(fig_kw))
         rvs = self.rvs(n)
         self.reset(-1) # As if nothing ever happened
-        pl.hist(rvs, bins=bins, **sc.mergedicts(hist_kw))
-        pl.title(str(self))
-        pl.xlabel('Value')
-        pl.ylabel(f'Count ({n} total)')
+        plt.hist(rvs, bins=bins, **sc.mergedicts(hist_kw))
+        plt.title(str(self))
+        plt.xlabel('Value')
+        plt.ylabel(f'Count ({n} total)')
         return rvs
-        
+
 
 #%% Specific distributions
 
 # Add common distributions so they can be imported directly; assigned to a variable since used in help messages
 dist_list = ['random', 'uniform', 'normal', 'lognorm_ex', 'lognorm_im', 'expon',
-             'poisson', 'weibull', 'constant', 'randint', 'rand_raw', 'bernoulli',
+             'poisson', 'weibull', 'gamma', 'constant', 'randint', 'rand_raw', 'bernoulli',
              'choice', 'histogram']
 __all__ += dist_list
+__all__ += ['multi_random'] # Not a dist in the same sense as the others
 
 
 class random(Dist):
     """ Random distribution, with values on the interval (0, 1) """
     def __init__(self, **kwargs):
-        super().__init__(distname='random', **kwargs)
+        super().__init__(distname='random', dtype=ss.dtypes.float, **kwargs)
         return
-    
+
     def ppf(self, rands):
         return rands
 
@@ -612,15 +706,28 @@ def ppf(self, rands):
 class uniform(Dist):
     """
     Uniform distribution, values on interval (low, high)
-    
+
     Args:
         low (float): the lower bound of the distribution (default 0.0)
         high (float): the upper bound of the distribution (default 1.0)
     """
-    def __init__(self, low=0.0, high=1.0, **kwargs):
+    def __init__(self, low=None, high=None, **kwargs):
+        if high is None and low is not None: # One argument, swap
+            high = low
+            low = 0.0
+        if low is None:
+            low = 0.0
+        if high is None:
+            high = 1.0
         super().__init__(distname='uniform', low=low, high=high, **kwargs)
         return
-    
+
+    def make_rvs(self):
+        """ Specified here because uniform() doesn't take a dtype argument """
+        p = self._pars
+        rvs = self.rand(self._size) * (p.high - p.low) + p.low
+        return rvs
+
     def ppf(self, rands):
         p = self._pars
         rvs = rands * (p.high - p.low) + p.low
@@ -630,13 +737,13 @@ def ppf(self, rands):
 class normal(Dist):
     """
     Normal distribution
-    
+
     Args:
         loc (float): the mean of the distribution (default 0.0)
         scale (float) the standard deviation of the distribution (default 1.0)
-    
+
     """
-    def __init__(self, loc=0.0, scale=1.0, **kwargs):
+    def __init__(self, loc=0.0, scale=1.0, **kwargs): # Does not accept dtype
         super().__init__(distname='normal', dist=sps.norm, loc=loc, scale=scale, **kwargs)
         return
 
@@ -644,23 +751,23 @@ def __init__(self, loc=0.0, scale=1.0, **kwargs):
 class lognorm_im(Dist):
     """
     Lognormal distribution, parameterized in terms of the "implicit" (normal)
-    distribution, with mean=loc and stdev=scale (see lognorm_ex for comparison).
-    
+    distribution, with mean=loc and std=scale (see lognorm_ex for comparison).
+
     Note: the "loc" parameter here does *not* correspond to the mean of the resulting
     random variates!
-    
+
     Args:
         mean (float): the mean of the underlying normal distribution (not this distribution) (default 0.0)
         sigma (float): the standard deviation of the underlying normal distribution (not this distribution) (default 1.0)
-    
+
     **Example**::
-        
+
         ss.lognorm_im(mean=2, sigma=1, strict=False).rvs(1000).mean() # Should be roughly 10
     """
-    def __init__(self, mean=0.0, sigma=1.0, **kwargs):
+    def __init__(self, mean=0.0, sigma=1.0, **kwargs): # Does not accept dtype
         super().__init__(distname='lognormal', dist=sps.lognorm, mean=mean, sigma=sigma, **kwargs)
         return
-    
+
     def sync_pars(self, call=True):
         """ Translate between NumPy and SciPy parameters """
         if call:
@@ -672,27 +779,32 @@ def sync_pars(self, call=True):
         spars.loc = 0
         self.update_dist_pars(spars)
         return spars
-    
+
+    def preprocess_timepar(self, key, val):
+        """ Not valid since incorrect time units """
+        errormsg = f'Cannot use timepars with a lognorm_im distribution ({self}) since its units are not time. Use lognorm_ex instead.'
+        raise NotImplementedError(errormsg)
+
 
 class lognorm_ex(Dist):
     """
     Lognormal distribution, parameterized in terms of the "explicit" (lognormal)
-    distribution, with mean=mean and stdev=stdev for this distribution (see lognorm_im for comparison).
+    distribution, with mean=mean and std=std for this distribution (see lognorm_im for comparison).
     Note that a mean ≤ 0.0 is impossible, since this is the parameter of the distribution
     after the log transform.
-    
+
     Args:
         mean (float): the mean of this distribution (not the underlying distribution) (default 1.0)
-        stdev (float): the standard deviation of this distribution (not the underlying distribution) (default 1.0)
-    
+        std (float): the standard deviation of this distribution (not the underlying distribution) (default 1.0)
+
     **Example**::
-        
-        ss.lognorm_ex(mean=2, stdev=1, strict=False).rvs(1000).mean() # Should be close to 2
+
+        ss.lognorm_ex(mean=2, std=1, strict=False).rvs(1000).mean() # Should be close to 2
     """
-    def __init__(self, mean=1.0, stdev=1.0, **kwargs):
-        super().__init__(distname='lognormal', dist=sps.lognorm, mean=mean, stdev=stdev, **kwargs)
+    def __init__(self, mean=1.0, std=1.0, **kwargs): # Does not accept dtype
+        super().__init__(distname='lognormal', dist=sps.lognorm, mean=mean, std=std, **kwargs)
         return
-    
+
     def convert_ex_to_im(self):
         """
         Lognormal distributions can be specified in terms of the mean and standard
@@ -704,66 +816,79 @@ def convert_ex_to_im(self):
         self.call_pars() # Since can't work with functions
         p = self._pars
         mean = p.pop('mean')
-        stdev = p.pop('stdev')
+        std = p.pop('std')
         if np.isscalar(mean) and mean <= 0:
             errormsg = f'Cannot create a lognorm_ex distribution with mean≤0 (mean={mean}); did you mean to use lognorm_im instead?'
             raise ValueError(errormsg)
-        std2 = stdev**2
+        std2 = std**2
         mean2 = mean**2
-        sigma_im = np.sqrt(np.log(std2/mean2 + 1)) # Computes stdev for the underlying normal distribution
+        sigma_im = np.sqrt(np.log(std2/mean2 + 1)) # Computes std for the underlying normal distribution
         mean_im  = np.log(mean2 / np.sqrt(std2 + mean2)) # Computes the mean of the underlying normal distribution
         p.mean = mean_im
         p.sigma = sigma_im
         return mean_im, sigma_im
-    
+
     def sync_pars(self):
         """ Convert from overlying to underlying parameters, then translate to SciPy """
         self.convert_ex_to_im()
         spars = lognorm_im.sync_pars(self, call=False) # Borrow sync_pars from lognorm_im
         return spars
-    
+
 
 class expon(Dist):
     """
     Exponential distribution
-    
+
     Args:
         scale (float): the scale of the distribution (default 1.0)
-    
+
     """
     def __init__(self, scale=1.0, **kwargs):
         super().__init__(distname='exponential', dist=sps.expon, scale=scale, **kwargs)
         return
 
 
-class poisson(Dist):
+class poisson(Dist): # TODO: does not currently scale correctly with dt
     """
     Poisson distribution
-    
+
     Args:
         lam (float): the scale of the distribution (default 1.0)
     """
     def __init__(self, lam=1.0, **kwargs):
         super().__init__(distname='poisson', dist=sps.poisson, lam=lam, **kwargs)
         return
-    
+
     def sync_pars(self):
         """ Translate between NumPy and SciPy parameters """
         spars = dict(mu=self._pars.lam)
         self.update_dist_pars(spars)
         return spars
 
+    def preprocess_timepar(self, key, timepar):
+        """ Try to update the timepar before calculating array parameters, but raise an exception if this isn't possible """
+        try:
+            timepar.update_cached()
+        except Exception as E:
+            errormsg = f'Could not process timepar {timepar} for {self}. Note that Poisson distributions are not compatible with both callable parameters and timepars, since this would change the shape in an unknowable way.'
+            raise ValueError(errormsg) from E
+
+        self._pars[key] = timepar.values # Use the raw value, since it could be anything (including a function)
+        return timepar.values # Also use this for the rest of the loop
+
 
 class randint(Dist):
     """
     Random integer distribution, on the interval [low, high)
-    
+
     Args:
         low (int): the lower bound of the distribution (default 0)
         high (int): the upper bound of the distribution (default of maximum integer size: 9,223,372,036,854,775,807)
+        allow_time (bool): allow time parameters to be specified as high/low values (disabled by default since introduces rounding error)
     """
-    def __init__(self, *args, low=None, high=None, dtype=ss.dtypes.int, **kwargs):
-        # Handle input arguments
+    def __init__(self, *args, low=None, high=None, dtype=ss.dtypes.rand_int, allow_time=False, **kwargs):
+        # Handle input arguments # TODO: reconcile with how this is handled in uniform()
+        self.allow_time = allow_time
         if len(args):
             if len(args) == 1:
                 high = args[0]
@@ -775,20 +900,29 @@ def __init__(self, *args, low=None, high=None, dtype=ss.dtypes.int, **kwargs):
         if low is None:
             low = 0
         if high is None:
-            high = np.iinfo(ss.dtypes.int).max
-            
+            high = np.iinfo(ss.dtypes.rand_int).max
+
         if ss.options._centralized: # randint because we're accessing via numpy.random
             super().__init__(distname='randint', low=low, high=high, dtype=dtype, **kwargs)
         else: # integers instead of randint because interfacing a numpy.random.Generator
             super().__init__(distname='integers', low=low, high=high, dtype=dtype, **kwargs)
         return
-    
+
     def ppf(self, rands):
         p = self._pars
         rvs = rands * (p.high + 1 - p.low) + p.low
-        rvs = rvs.astype(self.pars['dtype'])
+        rvs = rvs.astype(self.dtype)
         return rvs
 
+    def preprocess_timepar(self, key, timepar):
+        """ Not valid due to a rounding error """
+        if self.allow_time:
+            return super().preprocess_timepar(key, timepar)
+        else:
+            errormsg = f'Cannot use timepars with a randint distribution ({self}) since the values may be rounded incorrectly. Use uniform() instead and convert to int yourself, or set allow_time=True if you really want to do this.'
+            raise NotImplementedError(errormsg)
+
+
 class rand_raw(Dist):
     """
     Directly sample raw integers (uint64) from the random number generator.
@@ -798,13 +932,13 @@ def make_rvs(self):
         if ss.options._centralized:
             return self.rng.randint(low=0, high=np.iinfo(np.uint64).max, dtype=np.uint64, size=self._size)
         else:
-            return self.bitgen.random_raw(self._size)
+            return self.bitgen.random_raw(self._size) # TODO: figure out how to make accept dtype, or check speed
 
 
 class weibull(Dist):
     """
     Weibull distribution (specifically, scipy.stats.weibull_min)
-    
+
     Args:
         c (float): the shape parameter, sometimes called k (default 1.0)
         loc (float): the location parameter, which shifts the position of the distribution (default 0.0)
@@ -813,7 +947,26 @@ class weibull(Dist):
     def __init__(self, c=1.0, loc=0.0, scale=1.0, **kwargs):
         super().__init__(distname='weibull', dist=sps.weibull_min, c=c, loc=loc, scale=scale, **kwargs)
         return
-    
+
+    def make_rvs(self):
+        """ Use SciPy rather than NumPy to include the scale parameter """
+        rvs = self.dist.rvs(self._size)
+        return rvs
+
+
+class gamma(Dist):
+    """
+    Gamma distribution (specifically, scipy.stats.gamma)
+
+    Args:
+        a (float): the shape parameter, sometimes called k (default 1.0)
+        loc (float): the location parameter, which shifts the position of the distribution (default 0.0)
+        scale (float): the scale parameter, sometimes called θ (default 1.0)
+    """
+    def __init__(self, a=1.0, loc=0.0, scale=1.0, **kwargs):
+        super().__init__(distname='gamma', dist=sps.gamma, a=a, loc=loc, scale=scale, **kwargs)
+        return
+
     def make_rvs(self):
         """ Use SciPy rather than NumPy to include the scale parameter """
         rvs = self.dist.rvs(self._size)
@@ -823,17 +976,17 @@ def make_rvs(self):
 class constant(Dist):
     """
     Constant (delta) distribution: equivalent to np.full()
-    
+
     Args:
         v (float): the value to return
     """
     def __init__(self, v=0.0, **kwargs):
         super().__init__(distname='const', v=v, **kwargs)
         return
-    
+
     def make_rvs(self):
         return np.full(self._size, self._pars.v)
-    
+
     def ppf(self, rands): # NB: don't actually need to use random numbers here, but not worth the complexity of avoiding this
         return np.full(rands.shape, self._pars.v)
 
@@ -841,25 +994,25 @@ def ppf(self, rands): # NB: don't actually need to use random numbers here, but
 class bernoulli(Dist):
     """
     Bernoulli distribution: return True or False with the specified probability (which can be an array)
-    
+
     Unlike other distributions, Bernoulli distributions have a filter() method,
     which returns elements of the array that return True.
-    
+
     Args:
         p (float): the probability of returning True (default 0.5)
     """
     def __init__(self, p=0.5, **kwargs):
         super().__init__(distname='bernoulli', p=p, **kwargs)
         return
-    
+
     def make_rvs(self):
-        rvs = self.rng.random(self._size) < self._pars.p # 3x faster than using rng.binomial(1, p, size)
+        rvs = self.rand(self._size) < self._pars.p # 3x faster than using rng.binomial(1, p, size)
         return rvs
-    
+
     def ppf(self, rands):
         rvs = rands < self._pars.p
         return rvs
-    
+
     def filter(self, uids=None, both=False):
         """ Return UIDs that correspond to True, or optionally return both True and False """
         if uids is None:
@@ -877,23 +1030,47 @@ def split(self, uids=None):
         """ Alias to filter(uids, both=True) """
         return self.filter(uids=uids, both=True)
 
+    def call_par(self, key, val, size, uids):
+        """ Reverse the usual order of processing so callable is processed first, and then the timepar conversion """
+        is_timepar = isinstance(val, ss.TimePar)
+
+        if is_timepar: # If it's a time parameter, pull out the value
+            timepar = sc.dcp(val) # Rename to make more sense within the context of this method
+            val = timepar.v # Pull out the base value; we'll deal with the transformation later
+            self._timepar = timepar # This is used, then destroyed, by postprocess_timepar() below
+            if isinstance(timepar, ss.dur): # Validation
+                errormsg = f'Bernoulli distributions can only be used with ss.time_prob() or ss.rate(), not {timepar}'
+                raise TypeError(errormsg)
+
+        # As normal: if the parameter is callable, then call it (types can appear as callable)
+        if callable(val) and not isinstance(val, type):
+            val = self.convert_callable(key, val, size, uids)
+
+        # Process as a timepar
+        if is_timepar:
+            val = self.postprocess_timepar(val) # Note: this is processing the parameter rather than the rvs as usual
+
+        # Store in the parameters and return
+        self._pars[key] = val
+        return val
+
 
 class choice(Dist):
     """
     Random choice between discrete options (note: dynamic parameters not supported)
-    
+
     Args:
         a (int or array): the number of choices, or the choices themselves (default 2)
         p (array): if supplied, the probability of each choice (default, 1/a for a choices)
-    
+
     **Examples**::
-        
+
         # Simulate 10 die rolls
-        ss.choice(6, strict=False)(10) + 1 
-        
+        ss.choice(6, strict=False)(10) + 1
+
         # Choose between specified options each with a specified probability (must sum to 1)
         ss.choice(a=[30, 70], p=[0.3, 0.7], strict=False)(10)
-    
+
     Note: although Bernoulli trials can be generated using a=2, it is much faster
     to use ss.bernoulli() instead.
     """
@@ -901,7 +1078,7 @@ def __init__(self, a=2, p=None, **kwargs):
         super().__init__(distname='choice', a=a, p=p, **kwargs)
         self.dynamic_pars = False # Set to false since array arguments don't imply dynamic pars here
         return
-    
+
     def ppf(self, rands):
         """ Shouldn't actually be needed since dynamic pars not supported """
         pars = self._pars
@@ -912,37 +1089,42 @@ def ppf(self, rands):
         rvs = pars.a[inds]
         return rvs
 
+    def preprocess_timepar(self, key, timepar):
+        """ Not valid since does not scale with time """
+        errormsg = f'Cannot use timepars with a choice distribution ({self}) since its units are not time. Convert output to time units instead.'
+        raise NotImplementedError(errormsg)
+
 
 class histogram(Dist):
     """
     Sample from a histogram with defined bins
-    
+
     Note: unlike other distributions, the parameters of this distribution can't
     be modified after creation.
-    
+
     Args:
         values (array): the probability (or count) of each bin
         bins (array): the edges of each bin
         density (bool): treat the histogram as a density instead of counts; only matters with unequal bin widths, see numpy.histogram and scipy.stats.rv_histogram for more information
         data (array): if supplied, compute the values and bin edges using this data and np.histogram() instead
-        
+
     Note: if the length of bins is equal to the length of values, they will be
     interpreted as left bin edges, and one additional right-bin edge will be added
     based on the difference between the last two bins (e.g. if the last two bins are
     40 and 50, the final right edge will be added at 60). If no bins are supplied,
     then they will be created as integers matching the length of the values.
-    
+
     The values can be supplied in either normalized (sum to 1) or un-normalized
     format.
-    
+
     **Examples**::
-        
+
         # Sample from an age distribution
         age_bins = [0,    10,  20,  40,  65, 100]
         age_vals = [0.1, 0.1, 0.3, 0.3, 0.2]
         h1 = ss.histogram(values=age_vals, bins=age_bins, strict=False)
         h1.plot_hist()
-        
+
         # Create a histogram from data
         data = np.random.randn(10_000)*2+5
         h2 = ss.histogram(data=data, strict=False)
@@ -967,34 +1149,105 @@ def __init__(self, values=None, bins=None, density=False, data=None, **kwargs):
             bins = np.append(bins, bins[-1]+delta)
         vsum = values.sum()
         if vsum != 1.0:
-            values /= vsum
+            values = values / vsum
         dist = sps.rv_histogram((values, bins), density=density) # Create the SciPy distribution
         super().__init__(dist=dist, distname='histogram', **kwargs)
         self.dynamic_pars = False # Set to false since array arguments don't imply dynamic pars here
         return
-        
+
+
+class multi_random(sc.prettyobj):
+    """
+    A class for holding two or more ss.random() distributions, and generating
+    random numbers linked to each of them. Useful for e.g. pairwise transmission
+    probabilities.
+
+    See ss.combine_rands() for the manual version; in almost all cases this class
+    should be used instead.
+
+    Usage:
+        multi = ss.multi_random('source', 'target')
+        rvs = multi.rvs(source_uids, target_uids)
+    """
+    def __init__(self, names, *args, **kwargs):
+        names = sc.mergelists(names, args)
+        self.dists = [ss.random(name=name, **kwargs) for name in names]
+        return
+
+    def __len__(self):
+        return len(self.dists)
+
+    def init(self, *args, **kwargs):
+        """ Not usually needed since each dist will handle this automatically; for completeness only """
+        for dist in self.dists: dist.init(*args, **kwargs)
+        return
+
+    def reset(self, *args, **kwargs):
+        """ Not usually needed since each dist will handle this automatically; for completeness only """
+        for dist in self.dists: dist.reset(*args, **kwargs)
+        return
+
+    def jump(self, *args, **kwargs):
+        """ Not usually needed since each dist will handle this automatically; for completeness only """
+        for dist in self.dists: dist.jump(*args, **kwargs)
+        return
+
+    @staticmethod
+    @nb.njit(fastmath=True, parallel=False, cache=True) # Numba is 3x faster, but disabling parallel for efficiency
+    def combine_rvs(rvs_list, int_type, int_max):
+        """ Combine inputs into one number """
+        # Combine using bitwise-or
+        rand_ints = rvs_list[0].view(int_type)
+        for rand_floats in rvs_list[1:]:
+            rand_ints2 = rand_floats.view(int_type)
+            rand_ints = np.bitwise_xor(rand_ints*rand_ints2, rand_ints-rand_ints2)
+
+        # Normalize
+        rvs = rand_ints / int_max
+        return rvs
+
+    def rvs(self, *args):
+        """ Get random variates from each of the underlying distributions and combine them efficiently """
+        # Validation
+        n_args = len(args)
+        n_dists = len(self)
+        if n_args != len(self):
+            errormsg = f'Number of UID lists supplied ({n_args}) does not match number of distributions ({n_dists})'
+            raise ValueError(errormsg)
+
+        rvs_list = [dist.rvs(arg) for dist,arg in zip(self.dists, args)]
+        int_type = ss.dtypes.rand_uint
+        int_max = np.iinfo(int_type).max
+        rvs = self.combine_rvs(rvs_list, int_type, int_max)
+        return rvs
+
+
+
 
 #%% Dist exceptions
 
 class DistNotInitializedError(RuntimeError):
     """ Raised when Dist object is called when not initialized. """
-    def __init__(self, dist):
-        msg = f'{dist} has not been initialized; please set strict=False when creating the distribution, or call dist.initialize()'
+    def __init__(self, dist=None, msg=None):
+        if msg is None:
+            msg = f'{dist} has not been initialized; please set strict=False when creating the distribution, or call dist.init()'
         super().__init__(msg)
         return
 
 
 class DistNotReadyError(RuntimeError):
     """ Raised when a Dist object is called without being ready. """
-    def __init__(self, dist):
-        msg = f'{dist} is not ready. This is likely caused by calling a distribution multiple times in a single step. Call dist.jump() to reset.'
+    def __init__(self, dist=None, msg=None):
+        if msg is None:
+            msg = f'{dist} is not ready. This is likely caused by calling a distribution multiple times in a single step. Call dist.jump() to reset.'
         super().__init__(msg)
         return
 
 
 class DistSeedRepeatError(RuntimeError):
     """ Raised when a Dist object shares a seed with another """
-    def __init__(self, dist1, dist2):
-        msg = f'A common seed was found between {dist1} and {dist2}. This is likely caused by incorrect initialization of the parent Dists object.'
+    def __init__(self, dist1=None, dist2=None, msg=None):
+        if msg is None:
+            msg = f'A common seed was found between {dist1} and {dist2}. This is likely caused by incorrect initialization of the parent Dists object.'
         super().__init__(msg)
         return
diff --git a/starsim/interventions.py b/starsim/interventions.py
index b7c8abb4..0fae1f8b 100644
--- a/starsim/interventions.py
+++ b/starsim/interventions.py
@@ -1,60 +1,18 @@
 """
-Define interventions (and analyzers)
+Define interventions
 """
-
 import starsim as ss
 import sciris as sc
 import numpy as np
 
-__all__ = ['Plugin', 'Analyzer', 'Intervention']
-
-
-class Plugin(ss.Module):
-    """ Base class for interventions and analyzers """
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        return
-    
-    def __call__(self, *args, **kwargs):
-        return self.apply(*args, **kwargs)
-    
-    def init_pre(self, sim):
-        return super().init_pre(sim)
-    
-    def apply(self, sim):
-        pass
-
-    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 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
+__all__ = ['Intervention']
 
 
-class Intervention(Plugin):
+class Intervention(ss.Module):
     """
     Base class for interventions.
-    
-    The key method of the intervention is ``apply()``, which is called with the sim
+
+    The key method of the intervention is ``step()``, which is called with the sim
     on each timestep.
     """
 
@@ -79,12 +37,12 @@ def _parse_product(self, product):
     def _parse_product_str(self, product):
         raise NotImplementedError
 
-    def check_eligibility(self, sim):
+    def check_eligibility(self):
         """
         Return an array of indices of agents eligible for screening at time t
         """
         if self.eligibility is not None:
-            is_eligible = self.eligibility(sim)
+            is_eligible = self.eligibility(self.sim)
             if is_eligible is not None and len(is_eligible): # Only worry if non-None/nonzero length
                 if isinstance(is_eligible, ss.BoolArr):
                     is_eligible = is_eligible.uids
@@ -92,7 +50,7 @@ def check_eligibility(self, sim):
                     errormsg = f'Eligibility function must return BoolArr or UIDs, not {type(is_eligible)} {is_eligible}'
                     raise TypeError(errormsg)
         else:
-            is_eligible = sim.people.auids # Everyone
+            is_eligible = self.sim.people.auids # Everyone
         return is_eligible
 
 
@@ -125,27 +83,28 @@ def init_pre(self, sim):
 
         # If start_year and end_year are not provided, figure them out from the provided years or the sim
         if self.years is None:
-            if self.start_year is None: self.start_year = sim.pars['start']
-            if self.end_year is None:   self.end_year = sim.pars['end']
+            if self.start_year is None: self.start_year = sim.pars.start
+            if self.end_year is None:   self.end_year = sim.pars.stop
         else:
             self.years = sc.promotetoarray(self.years)
             self.start_year = self.years[0]
             self.end_year = self.years[-1]
 
         # More validation
-        if not(any(np.isclose(self.start_year, sim.yearvec)) and any(np.isclose(self.end_year, sim.yearvec))):
+        if not(any(np.isclose(self.start_year, sim.timevec)) and any(np.isclose(self.end_year, sim.timevec))):
             errormsg = 'Years must be within simulation start and end dates.'
             raise ValueError(errormsg)
 
         # Adjustment to get the right end point
-        adj_factor = int(1 / sim.dt) - 1 if sim.dt < 1 else 1
+        dt = sim.pars.dt # TODO: need to eventually replace with own timestep, but not initialized yet since super().init_pre() hasn't been called
+        adj_factor = int(1/dt) - 1 if dt < 1 else 1
 
         # Determine the timepoints at which the intervention will be applied
-        self.start_point = sc.findfirst(sim.yearvec, self.start_year)
-        self.end_point   = sc.findfirst(sim.yearvec, self.end_year) + adj_factor
+        self.start_point = sc.findfirst(sim.timevec, self.start_year)
+        self.end_point   = sc.findfirst(sim.timevec, self.end_year) + adj_factor
         self.years       = sc.inclusiverange(self.start_year, self.end_year)
         self.timepoints  = sc.inclusiverange(self.start_point, self.end_point)
-        self.yearvec     = np.arange(self.start_year, self.end_year + adj_factor, sim.dt)
+        self.yearvec     = np.arange(self.start_year, self.end_year + adj_factor, dt)
 
         # Get the probability input into a format compatible with timepoints
         if len(self.years) != len(self.prob):
@@ -158,7 +117,7 @@ def init_pre(self, sim):
             self.prob = sc.smoothinterp(self.yearvec, self.years, self.prob, smoothness=0)
 
         # Lastly, adjust the probability by the sim's timestep, if it's an annual probability
-        if self.annual_prob: self.prob = 1 - (1 - self.prob) ** sim.dt
+        if self.annual_prob: self.prob = 1 - (1 - self.prob) ** dt
 
         return
 
@@ -177,9 +136,9 @@ def __init__(self, *args, years=None, interpolate=None, prob=None, **kwargs):
 
     def init_pre(self, sim):
         super().init_pre(sim)
-        
+
         # Decide whether to apply the intervention at every timepoint throughout the year, or just once.
-        self.timepoints = sc.findnearest(sim.yearvec, self.years)
+        self.timepoints = sc.findnearest(sim.timevec, self.years)
 
         if len(self.prob) == 1:
             self.prob = np.array([self.prob[0]] * len(self.timepoints))
@@ -222,42 +181,45 @@ def init_pre(self, sim):
         self.outcomes = {k: np.array([], dtype=int) for k in self.product.hierarchy}
         return
 
-    def deliver(self, sim):
+    def deliver(self):
         """
         Deliver the diagnostics by finding who's eligible, finding who accepts, and applying the product.
         """
+        sim = self.sim
         ti = sc.findinds(self.timepoints, sim.ti)[0]
         prob = self.prob[ti]  # Get the proportion of people who will be tested this timestep
-        eligible_uids = self.check_eligibility(sim)  # Check eligibility
+        eligible_uids = self.check_eligibility()  # Check eligibility
         self.coverage_dist.set(p=prob)
         accept_uids = self.coverage_dist.filter(eligible_uids)
         if len(accept_uids):
-            self.outcomes = self.product.administer(sim, accept_uids)  # Actually administer the diagnostic
+            self.outcomes = self.product.administer(accept_uids)  # Actually administer the diagnostic
         return accept_uids
 
-    def check_eligibility(self, sim):
+    def check_eligibility(self):
         raise NotImplementedError
 
 
 class BaseScreening(BaseTest):
     """
     Base class for screening.
+
     Args:
         kwargs (dict): passed to BaseTest
     """
-    def check_eligibility(self, sim):
+    def check_eligibility(self):
         """
         Check eligibility
         """
         raise NotImplementedError
 
-    def apply(self, sim, module=None):
+    def step(self):
         """
         Perform screening by finding who's eligible, finding who accepts, and applying the product.
         """
+        sim = self.sim
         accept_uids = ss.uids()
-        if sim.ti in self.timepoints:
-            accept_uids = self.deliver(sim)
+        if sim.ti in self.timepoints: # TODO: change to self.ti
+            accept_uids = self.deliver()
             self.screened[accept_uids] = True
             self.screens[accept_uids] += 1
             self.ti_screened[accept_uids] = sim.ti
@@ -270,16 +232,17 @@ def apply(self, sim, module=None):
 class BaseTriage(BaseTest):
     """
     Base class for triage.
+
     Args:
         kwargs (dict): passed to BaseTest
     """
-    def check_eligibility(self, sim):
-        return sc.promotetoarray(self.eligibility(sim))
+    def check_eligibility(self):
+        return sc.promotetoarray(self.eligibility(self.sim))
 
-    def apply(self, sim):
+    def step(self):
         self.outcomes = {k: np.array([], dtype=int) for k in self.product.hierarchy}
         accept_inds = ss.uids()
-        if sim.t in self.timepoints: accept_inds = self.deliver(sim)
+        if self.sim.t in self.timepoints: accept_inds = self.deliver() # TODO: not robust for timestep
         return accept_inds
 
 
@@ -289,6 +252,7 @@ class routine_screening(BaseScreening, RoutineDelivery):
     See base classes for a description of input arguments.
 
     **Examples**::
+
         screen1 = ss.routine_screening(product=my_prod, prob=0.02) # Screen 2% of the eligible population every year
         screen2 = ss.routine_screening(product=my_prod, prob=0.02, start_year=2020) # Screen 2% every year starting in 2020
         screen3 = ss.routine_screening(product=my_prod, prob=np.linspace(0.005,0.025,5), years=np.arange(2020,2025)) # Scale up screening over 5 years starting in 2020
@@ -316,7 +280,7 @@ class routine_triage(BaseTriage, RoutineDelivery):
 
     **Example**:
         # Example: Triage positive screens into confirmatory testing
-        screened_pos = lambda sim: sim.get_intervention('screening').outcomes['positive']
+        screened_pos = lambda sim: sim.interventions.screening.outcomes['positive']
         triage = ss.routine_triage(product=my_triage, eligibility=screen_pos, prob=0.9, start_year=2030)
     """
     pass
@@ -329,7 +293,7 @@ class campaign_triage(BaseTriage, CampaignDelivery):
 
     **Examples**:
         # Example: In 2030, triage all positive screens into confirmatory testing
-        screened_pos = lambda sim: sim.get_intervention('screening').outcomes['positive']
+        screened_pos = lambda sim: sim.interventions.screening.outcomes['positive']
         triage1 = ss.campaign_triage(product=my_triage, eligibility=screen_pos, prob=0.9, years=2030)
     """
     pass
@@ -362,33 +326,33 @@ def init_pre(self, sim):
         self.outcomes = {k: np.array([], dtype=int) for k in ['unsuccessful', 'successful']} # Store outcomes on each timestep
         return
 
-    def get_accept_inds(self, sim):
+    def get_accept_inds(self):
         """
         Get indices of people who will acccept treatment; these people are then added to a queue or scheduled for receiving treatment
         """
         accept_uids = ss.uids()
-        eligible_uids = self.check_eligibility(sim)  # Apply eligiblity
+        eligible_uids = self.check_eligibility()  # Apply eligiblity
         if len(eligible_uids):
             self.coverage_dist.set(p=self.prob[0])
             accept_uids = self.coverage_dist.filter(eligible_uids)
         return accept_uids
 
-    def get_candidates(self, sim):
+    def get_candidates(self):
         """
         Get candidates for treatment on this timestep. Implemented by derived classes.
         """
         raise NotImplementedError
 
-    def apply(self, sim):
+    def step(self):
         """
         Perform treatment by getting candidates, checking their eligibility, and then treating them.
         """
         # Get indices of who will get treated
-        treat_candidates = self.get_candidates(sim)  # NB, this needs to be implemented by derived classes
-        still_eligible = self.check_eligibility(sim)
+        treat_candidates = self.get_candidates()  # NB, this needs to be implemented by derived classes
+        still_eligible = self.check_eligibility()
         treat_uids = treat_candidates.intersect(still_eligible)
         if len(treat_uids):
-            self.outcomes = self.product.administer(sim, treat_uids)
+            self.outcomes = self.product.administer(treat_uids)
         return treat_uids
 
 
@@ -405,15 +369,15 @@ def __init__(self, max_capacity=None, **kwargs):
         self.max_capacity = max_capacity
         return
 
-    def add_to_queue(self, sim):
+    def add_to_queue(self):
         """
         Add people who are willing to accept treatment to the queue
         """
-        accept_inds = self.get_accept_inds(sim)
+        accept_inds = self.get_accept_inds()
         if len(accept_inds): self.queue += accept_inds.tolist()
         return
 
-    def get_candidates(self, sim):
+    def get_candidates(self):
         """
         Get the indices of people who are candidates for treatment
         """
@@ -425,13 +389,13 @@ def get_candidates(self, sim):
                 treat_candidates = self.queue[:self.max_capacity]
         return ss.uids(treat_candidates) # TODO: Check
 
-    def apply(self, sim):
+    def step(self):
         """
         Apply treatment. On each timestep, this method will add eligible people who are willing to accept treatment to a
         queue, and then will treat as many people in the queue as there is capacity for.
         """
-        self.add_to_queue(sim)
-        treat_inds = BaseTreatment.apply(self, sim) # Apply method from BaseTreatment class
+        self.add_to_queue()
+        treat_inds = BaseTreatment.step(self) # Apply method from BaseTreatment class
         self.queue = [e for e in self.queue if e not in treat_inds] # Recreate the queue, removing people who were treated
         return treat_inds
 
@@ -462,16 +426,17 @@ def __init__(self, *args, product=None, prob=None, label=None, **kwargs):
         self.coverage_dist = ss.bernoulli(p=0)  # Placeholder
         return
 
-    def apply(self, sim):
+    def step(self):
         """
         Deliver the diagnostics by finding who's eligible, finding who accepts, and applying the product.
         """
+        sim = self.sim
         accept_uids = np.array([])
         if sim.ti in self.timepoints:
 
             ti = sc.findinds(self.timepoints, sim.ti)[0]
             prob = self.prob[ti]  # Get the proportion of people who will be tested this timestep
-            is_eligible = self.check_eligibility(sim)  # Check eligibility
+            is_eligible = self.check_eligibility()  # Check eligibility
             self.coverage_dist.set(p=prob)
             accept_uids = self.coverage_dist.filter(is_eligible)
 
diff --git a/starsim/loop.py b/starsim/loop.py
new file mode 100644
index 00000000..315d7a09
--- /dev/null
+++ b/starsim/loop.py
@@ -0,0 +1,334 @@
+"""
+Parent class for the integration loop.
+"""
+import time
+import numpy as np
+import pandas as pd
+import sciris as sc
+import starsim as ss
+import matplotlib.pyplot as plt
+
+# What classes are externally visible
+__all__ = ['Loop']
+
+
+
+#%% Loop class
+
+class Loop:
+    """ Base class for integration loop """
+    def __init__(self, sim): # TODO: consider eps=1e-6 and round times to this value
+        self.sim = sim
+        self.funcs = None
+        self.abs_tvecs = None
+        self.plan = None
+        self.index = 0 # The next function to execute
+        self.cpu_time = [] # Store the CPU time of execution of each function
+        self.df = None # User-friendly verison of the plan
+        self.cpu_df = None # User-friendly time analysis
+        self.initialized = False
+        return
+
+    def init(self):
+        """ Parse the sim into the integration plan """
+        self.collect_funcs()
+        self.collect_abs_tvecs()
+        self.make_plan()
+        self.initialized = True
+        return
+
+    def __len__(self):
+        if self.initialized:
+            return len(self.plan)
+        else:
+            return 0 # Or None?
+
+    def __iadd__(self, func):
+        """ Allow functions to be added to the function list """
+        parent = func.__self__
+        func_name = func.__name__
+
+        # Get the name if it's defined, the class otherwise; these must match abs_tvecs
+        module = parent.name if isinstance(parent, ss.Module) else parent.__class__.__name__.lower()
+
+        # Create the row and append it to the function list
+        row = dict(func_order=len(self.funcs), module=module, func_name=func_name, func=func)
+        self.funcs.append(row)
+        return self
+
+    def __repr__(self):
+        if self.initialized:
+            arrs = list({len(arr) for arr in self.abs_tvecs.values()})
+            if len(arrs) == 1: arrs = arrs[0] # If all are the same, just use that
+            string = f'Loop(n={len(self)}, funcs={len(self.funcs)}, npts={arrs}, index={self.index})'
+        else:
+            string = 'Loop(initialized=False)'
+        return string
+
+    def disp(self):
+        return sc.pr(self)
+
+    def collect_funcs(self):
+        """ Collect all the callable functions (methods) that comprise the step """
+
+        # Run the simulation step first (updates the distributions)
+        self.funcs = [] # Reset, just in case
+        sim = self.sim
+
+        # Collect the start_steps
+        self += sim.start_step # Note special __iadd__() method above, which appends these to the funcs list
+        for mod in sim.modules:
+            self += mod.start_step
+
+        # Update demographic modules (create new agents from births/immigration, schedule non-disease deaths and emigration)
+        for dem in sim.demographics():
+            self += dem.step
+
+        # 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 sim.diseases():
+            if isinstance(disease, ss.Disease): # Could be a connector instead -- TODO, rethink this
+                self += disease.step_state
+
+        # Update connectors
+        for connector in sim.connectors():
+            self += connector.step
+
+        # Update networks - this takes place here in case autonomous state changes at this timestep
+        # affect eligibility for contacts
+        for network in sim.networks():
+            self += network.step
+
+        # 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 intv in sim.interventions():
+            self += intv.step
+
+        # Carry out autonomous state changes in the disease modules, including transmission (but excluding deaths)
+        for disease in sim.diseases():
+            self += disease.step
+
+        # Update people who died -- calls disease.step_die() internally
+        self += sim.people.step_die
+
+        # Update results
+        self += sim.people.update_results
+        for mod in sim.modules:
+            self += mod.update_results
+
+        # Apply analyzers
+        for ana in sim.analyzers():
+            self += ana.step
+
+        # Clean up dead agents, increment the time index, and perform other housekeeping tasks
+        for mod in sim.modules:
+            self += mod.finish_step
+        self += sim.people.finish_step
+        self += sim.finish_step
+
+        return self.funcs
+
+    def collect_abs_tvecs(self):
+        """ Collect numerical time arrays for each module """
+        self.abs_tvecs = sc.objdict()
+
+        # Handle the sim and people first
+        sim = self.sim
+        for key in ['sim', 'people']:
+            self.abs_tvecs[key] = sim.abs_tvec
+
+        # Handle all other modules
+        for mod in sim.modules:
+            abs_tvec = ss.make_abs_tvec(mod.timevec, mod.unit, sim.pars.unit)
+            self.abs_tvecs[mod.name] = abs_tvec
+
+        return self.abs_tvecs
+
+    def make_plan(self):
+        """ Combine the module ordering and the time vectors into the integration plan """
+
+        # Assemble the list of dicts
+        raw = []
+        for func_row in self.funcs:
+            for t in self.abs_tvecs[func_row['module']]:
+                row = func_row.copy()
+                row['time'] = t # Add time column
+                raw.append(row)
+
+        # Turn it into a dataframe
+        self.plan = sc.dataframe(raw)
+
+        # Sort it by step_order, a combination of time and function order
+        self.plan['step_order'] = self.plan.time + ss.options.time_eps*self.plan.func_order
+        self.plan['func_label'] = self.plan.module + '.' + self.plan.func_name
+        col_order = ['time', 'func_order', 'step_order', 'func', 'func_label', 'module', 'func_name'] # Func in the middle to hide it
+        self.plan = self.plan.sort_values('step_order').reset_index(drop=True)[col_order]
+        return
+
+    def store_time(self):
+        """ Store the current time in as high resolution as possible """
+        self.cpu_time.append(time.perf_counter())
+        return
+
+    def run_one_step(self):
+        """
+        Take a single step, i.e. call a single function; only used for debugging purposes.
+
+        Compare sim.run_one_step(), which runs a full timestep (which involves multiple function calls).
+        """
+        f = self.plan.func[self.index] # Get the next function
+        f() # Call it
+        self.index += 1 # Increment the time
+        return
+
+    def run(self, until=None, verbose=None):
+        """ Actually run the integration loop; usually called by sim.run() """
+        # Loop over every function in the integration loop, e.g. disease.step()
+        self.store_time()
+        for f,label in zip(self.plan.func[self.index:], self.plan.func_label[self.index:]):
+            if verbose:
+                row = self.plan[self.index]
+                print(f'Running t={row.time:n}, step={row.step_order}, {label}()')
+
+            f() # Execute the function -- this is where all of Starsim happens!!
+
+            # Tidy up
+            self.index += 1 # Increment the count
+            self.store_time()
+            if until and self.sim.now > until: # Terminate if asked to
+                break
+        return
+
+    def manual_reset(self): # TODO: do we need this? I feel if we don't have it, people will be tempted to manually set loop.index = 0.
+        """
+        Reset the loop to run again. Note, does not reset sim quantities so should
+        only be used for debugging.
+        """
+        self.index = 0
+        self.sim.complete = False
+        return
+
+    def to_df(self):
+        """ Return a user-friendly version of the plan, omitting object columns """
+        # Compute the main dataframe
+        cols = ['time', 'func_order', 'module', 'func_name', 'func_label']
+        df = self.plan[cols].copy() # Need to copy, otherwise it's messed up
+        times = np.diff(self.cpu_time)
+        if len(times) == len(df):
+            df['cpu_time'] = times
+        else:
+            warnmsg = f'Not adding timings since length mismatch ({len(df)} expected, {len(times)} actual'
+            ss.warn(warnmsg)
+        self.df = df
+
+        # Compute the CPU dataframe
+        by_func = df.groupby('func_label')
+        method = dict(func_order='first', module='first', func_name='first', cpu_time='sum')
+        cdf = sc.dataframe(by_func.agg(method))
+        cdf['percent'] = cdf.cpu_time / cdf.cpu_time.sum()*100
+        cdf.insert(cdf.cols.index('cpu_time'), 'calls', by_func.size())
+        cdf.sort_values('cpu_time', inplace=True, ascending=False)
+        self.cpu_df = cdf
+        return df
+
+    def plot(self, simplify=False, fig_kw=None, plot_kw=None, scatter_kw=None):
+        """
+        Plot a diagram of all the events
+
+        Args:
+            simplify (bool): if True, skip update_results and finish_step events, which are automatically applied
+            fig_kw (dict): passed to ``plt.figure()``
+            plot_kw (dict): passed to ``plt.plot()``
+            scatter_kw (dict): passed to ``plt.scatter()``
+        """
+
+        # Assemble data
+        df = self.to_df()
+        if simplify:
+            filter_out = ['update_results', 'finish_step']
+            df = df[~df.func_name.isin(filter_out)]
+        yticks = df.func_order.unique()
+        ylabels = df.func_label.unique()
+        x = df.time
+        y = df.func_order
+
+        # Convert module names to integers for plotting colors
+        mod_int, _ = pd.factorize(df.module)
+        colors = sc.gridcolors(np.unique(mod_int), asarray=True)
+
+        # Do the plotting
+        plot_kw = sc.mergedicts(dict(lw=2, alpha=0.2, c='k'), plot_kw)
+        scatter_kw = sc.mergedicts(dict(s=200, alpha=0.6), scatter_kw)
+        fig = plt.figure(**sc.mergedicts(fig_kw))
+        plt.plot(x, y, **plot_kw)
+        plt.scatter(x, y, c=colors[mod_int], **scatter_kw)
+        plt.yticks(yticks, ylabels)
+        plt.title(f'Integration plan ({len(df)} events)')
+        plt.xlabel(f'Time since simulation start (in {self.sim.pars.unit}s)')
+        plt.grid(True)
+        sc.figlayout()
+        sc.boxoff()
+        return fig
+
+    def plot_cpu(self, bytime=True, fig_kw=None, bar_kw=None):
+        """
+        Plot the CPU time spent on each event; visualization of Loop.cpu_df.
+
+        Args:
+            bytime (bool): if True, order events by total time rather than actual order
+            fig_kw (dict): passed to ``plt.figure()``
+            plot_kw (dict): passed to ``plt.bar()``
+        """
+        # Assemble data
+        if self.cpu_df is None:
+            self.to_df()
+        df = self.cpu_df
+        ylabels = df.index.values
+        if bytime:
+            y = np.arange(len(ylabels))
+        else:
+            y = df.func_order.values
+        y = y[::-1] # Reverse order so plots from top to bottom
+
+        x = df.cpu_time.values
+        pcts = df.percent.values
+
+        if x.max() < 1:
+            x *= 1e3
+            unit = 'ms'
+        else:
+            unit = 's'
+
+        # Assemble labels
+        for i in range(len(df)):
+            timestr = sc.sigfig(x[i], 3) + f' {unit}'
+            pctstr = sc.sigfig(pcts[i], 3) + '%'
+            ylabels[i] += f'()\n{timestr}, {pctstr}'
+
+        # Do the plotting
+        bar_kw = sc.mergedicts(bar_kw)
+        fig = plt.figure(**sc.mergedicts(fig_kw))
+        plt.barh(y, width=x, **bar_kw)
+        plt.yticks(y, ylabels)
+        plt.xlabel(f'CPU time ({unit})')
+        plt.ylabel('Function call')
+        plt.grid(True)
+        sc.figlayout()
+        sc.boxoff()
+        return fig
+
+    def __deepcopy__(self, memo):
+        """ A dataframe that has functions in it doesn't copy well; convert to a dict first """
+        cls = self.__class__
+        new = cls.__new__(cls)
+        memo[id(self)] = new
+        for k, v in self.__dict__.items():
+            if k == 'plan':
+                origdict = v.to_dict() # Convert to a dictionary
+                newdict = sc.dcp(origdict, memo=memo) # Copy the dict
+                newdf = sc.dataframe(newdict)
+                setattr(new, k, newdf)
+            else:
+                setattr(new, k, sc.dcp(v, memo=memo)) # Regular deepcopy
+        return new
+
diff --git a/starsim/modules.py b/starsim/modules.py
index d3700282..e8039aae 100644
--- a/starsim/modules.py
+++ b/starsim/modules.py
@@ -1,11 +1,12 @@
 """
-General module class -- base class for diseases, interventions, etc.
+General module class -- base class for diseases, interventions, etc. Also
+defines Analyzers and Connectors.
 """
-
 import sciris as sc
 import starsim as ss
+from functools import partial
 
-__all__ = ['module_map', 'find_modules', 'Module', 'Connector']
+__all__ = ['module_map', 'find_modules', 'Module', 'Analyzer', 'Connector']
 
 
 def module_map(key=None):
@@ -21,11 +22,11 @@ def module_map(key=None):
     return module_map if key is None else module_map[key]
 
 
-def find_modules(key=None):
-    """ Find all subclasses of Module, divided by type """
+def find_modules(key=None, flat=False):
+    """ Find all subclasses of Module present in Starsim, divided by type """
     modules = sc.objdict()
     modmap = module_map()
-    attrs = dir(ss)
+    attrs = dir(ss) # Find all attributes in Starsim (note: does not parse user code)
     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)
@@ -38,24 +39,63 @@ def find_modules(key=None):
                     modules[modkey][low_attr.removesuffix('net')] = item
             except:
                 pass
+    if flat:
+        modules = sc.objdict({k:v for vv in modules.values() for k,v in vv.items()}) # Unpack the nested dict into a flat one
     return modules if key is None else modules[key]
 
 
 class Module(sc.quickobj):
+    """
+    The main base class for all Starsim modules: diseases, networks, interventions, etc.
+
+    Args:
+        name (str): a short, key-like name for the module (e.g. "randomnet")
+        label (str): the full, human-readable name for the module (e.g. "Random network")
+        unit (str): the time unit (e.g. 'day', 'year'); inherits from sim if not supplied
+        dt (float): the timestep (e.g. 1.0, 0.1); inherits from sim if not supplied
+    """
+
+    def __init__(self, name=None, label=None, unit=None, dt=None):
+        # Handle parameters
+        self.pars = ss.Pars() # Usually populated via self.define_pars()
+        self.set_metadata(name, label) # Usually reset as part of self.update_pars()
+        self.set_time_pars(unit, dt)
 
-    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()
+        # Properties to be added by init_pre()
+        self.sim = None
+        self.dists = None # Turned into a Dists object by sim.init_dists() if this module has dists
         self.results = ss.Results(self.name)
+
+        # Time properties, added by init_time_pars()
+        self.unit = None
+        self.dt = None
+        self.timevec = None
+        self.npts = None
+        self.ti = None
+
+        # Finish initialization
+        self.pre_initialized = False
         self.initialized = False
         self.finalized = False
         return
-    
+
     def __bool__(self):
         """ Ensure that zero-length modules (e.g. networks) are still truthy """
         return True
-    
-    def set_metadata(self, name, label, requires):
+
+    def __call__(self, *args, **kwargs):
+        """ Allow modules to be called like functions """
+        return self.step(*args, **kwargs)
+
+    def disp(self, output=False):
+        """ Display the full object """
+        out = sc.prepr(self)
+        if not output:
+            print(out)
+        else:
+            return out
+
+    def set_metadata(self, name=None, label=None):
         """ Set metadata for the module """
         # Validation
         for key,val in dict(name=name, label=label).items():
@@ -63,107 +103,151 @@ def set_metadata(self, name, label, requires):
                 if not isinstance(val, str):
                     errormsg = f'Invalid value for {key}: must be str, not {type(val)}: {val}'
                     raise TypeError(errormsg)
-            
+
         # Set values
-        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)
+        self.name  = sc.ifelse(name,  getattr(self, 'name',  self.pars.get('name', self.__class__.__name__.lower()))) # Default name is the class name
+        self.label = sc.ifelse(label, getattr(self, 'label', self.pars.get('label', self.name)))
         return
-    
-    def default_pars(self, inherit=True, **kwargs):
+
+    def set_time_pars(self, unit=None, dt=None):
+        """ Set time units for the module """
+        self.unit  = sc.ifelse(unit,  getattr(self, 'unit', self.pars.get('unit')))
+        self.dt    = sc.ifelse(dt,    getattr(self, 'dt',   self.pars.get('dt')))
+        return
+
+    def define_pars(self, inherit=True, **kwargs): # TODO: think if inherit should default to true or false
         """ 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, getattr(self, key, None)) for key in ['name', 'label', 'requires']}
+        metadata = {key:pars.pop(key, None) for key in ['name', 'label']}
+        timepars = {key:pars.pop(key, None) for key in ['unit', 'dt']}
         self.set_metadata(**metadata)
-        
+        self.set_time_pars(**timepars)
+
         # 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()
-        all_names = [m.__class__ for m in sim.modules] + [m.name for m in sim.modules if hasattr(m, 'name')]
-        for req in self.requires:
-            if req not in all_names:
-                errs += req
-        if len(errs):
-            errormsg = f'{self.name} (label={self.label}) requires the following module(s), but the Sim does not contain them.'
-            errormsg += sc.newlinejoin(errs)
-            raise Exception(errormsg)
-        return
 
-    def init_pre(self, sim):
+    def init_pre(self, sim, force=False):
         """
         Perform initialization steps
 
         This method is called once, as part of initializing a Sim. Note: after
-        initialization, initialized=False until init_post() is called (which is after
+        initialization, initialized=False until init_vals() is called (which is after
         distributions are initialized).
-        
-        Note: distributions cannot be used here because they aren't initialized 
-        until after init_pre() is called. Use init_post() instead.
         """
-        self.check_requires(sim)
-        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
+        if force or not self.pre_initialized:
+            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
+            self.init_time_pars() # Initialize the modules' time parameters and link them to the sim
+            self.init_results()
+            self.pre_initialized = True
         return
-    
+
+    def init_results(self):
+        """ Initialize any results required; part of init_pre() """
+        self.results.timevec = self.timevec # Store the timevec in the results for plotting
+        return
+
     def init_post(self):
-        """ Initialize the values of the states, including calling distributions; the last step of initialization """
+        """ Initialize the values of the states; the last step of initialization """
         for state in self.states:
             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 init_time_pars(self, force=False):
+        """ Initialize all time parameters by ensuring all parameters are initialized; part of init_post() """
+        pars = self.sim.pars
+
+        # Find all modules and set the timestep
+        if force or self.unit is None:
+            self.unit = pars.unit
+        if force or self.dt is None:
+            self.dt = pars.dt
+
+        # Find all time parameters in the module
+        timepars = sc.search(self.pars, type=ss.TimePar) # Should it be self or self.pars?
+
+        # Initialize them with the parent module
+        for timepar in timepars.values():
+            if force or not timepar.initialized:
+                timepar.init(parent=self, die=False) # In some cases, the values can't be initialized; that's OK here
+
+        # Create the module-specific time vector
+        self.timevec = ss.make_timevec(pars.start, pars.stop, self.dt, self.unit)
+        self.npts = len(self.timevec)
+        self.ti = 0 # Track the current timestep, which may or may not match the sim's
+        return
+
+    @property
+    def now(self):
+        """ Return the current time, i.e. the time vector at the current timestep """
+        try:
+            return self.timevec[self.ti]
+        except Exception as E:
+            ss.warn(f'Encountered exception when getting the current time in {self.name}: {E}')
+            return None
+
+    def start_step(self):
+        """ Tasks to perform at the beginning of the step """
+        if self.dists is not None: # Will be None if no distributions are defined
+            self.dists.jump_dt() # Advance random number generators forward for calls on this step
+        return
+
+    def step(self):
+        """ Define how the module updates over time -- the key part of Starsim!! """
+        pass
+
+    def finish_step(self):
+        """ Define what should happen at the end of the step; at minimum, increment ti """
+        self.ti += 1
+        return
+
+    def update_results(self):
+        """ Perform any results updates on each timestep """
+        pass
 
     def finalize(self):
+        """ Perform any final operations, such as removing unneeded data """
         self.finalize_results()
         self.finalized = True
         return
 
-    def finalize_results(self):
+    def finalize_results(self): # TODO: this is confusing, needs to be not redefined by the user, or called *after* a custom finalize_results()
         """ 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]*self.sim.pars.pop_scale
         return
-    
-    def add_states(self, *args, check=True):
+
+    def define_states(self, *args, check=True):
         """
-        Add states to the module with the same attribute name as the state
-        
+        Define states of the module with the same attribute name as the state
+
         Args:
             args (states): list of states to add
             check (bool): whether to check that the object being added is a state
@@ -175,13 +259,31 @@ def add_states(self, *args, check=True):
                 state = ss.State(**arg)
             else:
                 state = arg
-                
+
             if check:
                 assert isinstance(state, ss.Arr), f'Could not add {state}: not an Arr object'
-                
+
+            # Add the state to the module
             setattr(self, state.name, state)
         return
 
+    def define_results(self, *args, check=True):
+        """ Add results to the module """
+        for arg in args:
+            if isinstance(arg, (list, tuple)):
+                result = ss.Result(*arg)
+            elif isinstance(arg, dict):
+                result = ss.Result(**arg)
+            else:
+                result = arg
+
+            # Update with module information
+            result.update(module=self.name, shape=self.npts, timevec=self.timevec)
+
+            # Add the result to the dict of results; does automatic checking
+            self.results += result
+        return
+
     @property
     def states(self):
         """
@@ -208,6 +310,7 @@ def statesdict(self): # TODO: remove
     def create(cls, name, *args, **kwargs):
         """
         Create a module instance by name
+
         Args:
             name (str): A string with the name of the module class in lower case, e.g. 'sir'
         """
@@ -216,7 +319,20 @@ def create(cls, name, *args, **kwargs):
                 return subcls(*args, **kwargs)
         else:
             raise KeyError(f'Module "{name}" did not match any known Starsim modules')
-            
+
+    @classmethod
+    def from_func(cls, func):
+        """ Create an module from a function """
+        def step(mod): # TODO: see if this can be done more simply
+            return mod.func(mod.sim)
+        name = func.__name__
+        mod = cls(name=name)
+        mod.func = func
+        mod.step = partial(step, mod)
+        mod.step.__name__ = name # Manually add these in as for a regular class method
+        mod.step.__self__ = mod
+        return mod
+
     def to_json(self):
         """ Export to a JSON-compatible format """
         out = sc.objdict()
@@ -227,23 +343,37 @@ def to_json(self):
         return out
 
     def plot(self):
+        """ Plot all results in the module """
         with sc.options.with_style('fancy'):
             flat = sc.flattendict(self.results, sep=': ')
-            yearvec = self.sim.yearvec
+            timevec = self.timevec
             fig, axs = sc.getrowscols(len(flat), make=True)
             for ax, (k, v) in zip(axs.flatten(), flat.items()):
-                ax.plot(yearvec, v)
+                ax.plot(timevec, v)
                 ax.set_title(k)
                 ax.set_xlabel('Year')
         return fig
 
 
+class Analyzer(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 ``step()``, which is called with the sim
+    on each timestep.
+
+    To retrieve a particular analyzer from a sim, use sim.get_analyzer().
+    """
+    pass
+
+
 class Connector(Module):
     """
-    Define a Connector, which mediates interactions between disease modules
-    
+    Base class for Connectors, which mediate interactions between disease (or other) modules
+
     Because connectors can do anything, they have no specified structure: it is
-    up to the user to define how they behave.    
+    up to the user to define how they behave.
     """
-    def update(self, sim):
-        pass
+    pass
diff --git a/starsim/network.py b/starsim/networks.py
similarity index 85%
rename from starsim/network.py
rename to starsim/networks.py
index 452bc5ae..80f19b5f 100644
--- a/starsim/network.py
+++ b/starsim/networks.py
@@ -1,7 +1,6 @@
-'''
+"""
 Networks that connect people within a population
-'''
-
+"""
 import networkx as nx
 import numpy as np
 import numba as nb
@@ -30,10 +29,8 @@ class Network(ss.Module):
     the connection.
 
     Args:
-        p1 (array): an array of length N, the number of connections in the network, with the indices of people
-                   on one side of the connection.
-        p2 (array): an array of length N, the number of connections in the network, with the indices of people
-                    on the other side of the connection.
+        p1 (array): an array of length N, the number of connections in the network, with the indices of people on one side of the connection.
+        p2 (array): an array of length N, the number of connections in the network, with the indices of people on the other side of the connection.
         beta (array): an array representing relative transmissibility of each connection for this network - TODO, do we need this?
         label (str): the name of the network (optional)
         kwargs (dict): other keys copied directly into the network
@@ -60,9 +57,9 @@ class Network(ss.Module):
         network2 = ss.Network(**network, index=index, self_conn=self_conn, label=network.label)
     """
 
-    def __init__(self, key_dict=None, prenatal=False, postnatal=False, name=None, label=None, requires=None, **kwargs):
+    def __init__(self, key_dict=None, prenatal=False, postnatal=False, name=None, label=None, **kwargs):
         # Initialize as a module
-        super().__init__(name=name, label=label, requires=requires)
+        super().__init__(name=name, label=label)
 
         # Each relationship is characterized by these default set of keys, plus any user- or network-supplied ones
         default_keys = sc.objdict(
@@ -88,17 +85,20 @@ def __init__(self, key_dict=None, prenatal=False, postnatal=False, name=None, la
         self.participant = ss.BoolArr('participant')
         self.validate_uids()
         return
-    
+
     @property
     def p1(self):
+        """ The first half of a network edge (person 1) """
         return self.edges['p1'] if 'p1' in self.edges else None
-    
+
     @property
     def p2(self):
+        """ The second half of a network edge (person 2) """
         return self.edges['p2'] if 'p2' in self.edges else None
 
     @property
     def beta(self):
+        """ Relative transmission on each network edge """
         return self.edges['beta'] if 'beta' in self.edges else None
 
     def init_post(self, add_pairs=True):
@@ -114,11 +114,14 @@ def __len__(self):
 
     def __repr__(self, **kwargs):
         """ Convert to a dataframe for printing """
-        namestr = self.name
-        labelstr = f'"{self.label}"' if self.label else '<no label>'
-        keys_str = ', '.join(self.edges.keys())
-        output = f'{namestr}({labelstr}, {keys_str})\n'  # e.g. Network("r", p1, p2, beta)
-        output += self.to_df().__repr__()
+        try:
+            namestr = self.name
+            labelstr = f'"{self.label}"' if self.label else '<no label>'
+            keys_str = ', '.join(self.edges.keys())
+            output = f'{namestr}({labelstr}, {keys_str})\n'  # e.g. Network("r", p1, p2, beta)
+            output += self.to_df().__repr__()
+        except:
+            output = sc.prepr(self, vals=False)
         return output
 
     def __contains__(self, item):
@@ -149,7 +152,7 @@ def set_network_states(self, people):
         states that depend on other states.
         """
         pass
-    
+
     def validate_uids(self):
         """ Ensure that p1, p2 are both UID arrays """
         edges = self.edges
@@ -184,6 +187,7 @@ def validate(self, force=True):
     def get_inds(self, inds, remove=False):
         """
         Get the specified indices from the edgelist and return them as a dict.
+
         Args:
             inds (int, array, slice): the indices to find
             remove (bool): whether to remove the indices
@@ -207,25 +211,43 @@ def pop_inds(self, inds):
         popped_inds = self.get_inds(inds, remove=True)
         return popped_inds
 
-    def append(self, contacts=None, **kwargs):
+    def append(self, edges=None, **kwargs):
         """
-        Append contacts to the current network.
+        Append edges to the current network.
 
         Args:
-            contacts (dict): a dictionary of arrays with keys p1,p2,beta, as returned from network.pop_inds()
+            edges (dict): a dictionary of arrays with keys p1,p2,beta, as returned from network.pop_inds()
         """
-        contacts = sc.mergedicts(contacts, kwargs)
+        edges = sc.mergedicts(edges, kwargs)
         for key in self.meta_keys():
             curr_arr = self.edges[key]
             try:
-                new_arr = contacts[key]
+                new_arr = edges[key]
             except KeyError:
-                errormsg = f'Cannot append contacts since required key "{key}" is missing'
+                errormsg = f'Cannot append edges since required key "{key}" is missing'
                 raise KeyError(errormsg)
             self.edges[key] = np.concatenate([curr_arr, new_arr])  # Resize to make room, preserving dtype
         self.validate_uids()
         return
 
+    def to_graph(self): # pragma: no cover
+        """
+        Convert to a networkx DiGraph
+
+        **Example**::
+
+            import networkx as nx
+            sim = ss.Sim(n_agents=100, networks='mf').init()
+            G = sim.networks.randomnet.to_graph()
+            nx.draw(G)
+        """
+        keys = [('p1', int), ('p2', int), ('beta', float)]
+        data = [np.array(self.edges[k], dtype=dtype).tolist() for k,dtype in keys]
+        G = nx.DiGraph()
+        G.add_weighted_edges_from(zip(*data), weight='beta')
+        nx.set_edge_attributes(G, self.label, name='layer')
+        return G
+
     def to_dict(self):
         """ Convert to dictionary """
         d = {k: self.edges[k] for k in self.meta_keys()}
@@ -248,8 +270,8 @@ def find_contacts(self, inds, as_array=True):
         """
         Find all contacts of the specified people
 
-        For some purposes (e.g. contact tracing) it's necessary to find all the contacts
-        associated with a subset of the people in this network. Since contacts are bidirectional
+        For some purposes (e.g. contact tracing) it's necessary to find all the edges
+        associated with a subset of the people in this network. Since edges are bidirectional
         it's necessary to check both p1 and p2 for the target indices. The return type is a Set
         so that there is no duplication of indices (otherwise if the Network has explicit
         symmetric interactions, they could appear multiple times). This is also for performance so
@@ -258,7 +280,7 @@ def find_contacts(self, inds, as_array=True):
         infection, e.g. exposure risk.
 
         Args:
-            inds (array): indices of people whose contacts to return
+            inds (array): indices of people whose edges to return
             as_array (bool): if true, return as sorted array (otherwise, return as unsorted set)
 
         Returns:
@@ -267,7 +289,7 @@ def find_contacts(self, inds, as_array=True):
         Example: If there were a network with
         - p1 = [1,2,3,4]
         - p2 = [2,3,1,4]
-        Then find_contacts([1,3]) would return {1,2,3}
+        Then find_edges([1,3]) would return {1,2,3}
         """
 
         # Check types
@@ -276,7 +298,7 @@ def find_contacts(self, inds, as_array=True):
         if inds.dtype != np.int64:  # pragma: no cover # This is int64 since indices often come from utils.true(), which returns int64
             inds = np.array(inds, dtype=np.int64)
 
-        # Find the contacts
+        # Find the edges
         contact_inds = ss.find_contacts(self.edges.p1, self.edges.p2, inds)
         if as_array:
             contact_inds = np.fromiter(contact_inds, dtype=ss_int_)
@@ -288,10 +310,6 @@ def add_pairs(self):
         """ Define how pairs of people are formed """
         pass
 
-    def update(self):
-        """ Define how pairs/connections evolve (in time) """
-        pass
-
     def remove_uids(self, uids):
         """
         Remove interactions involving specified UIDs
@@ -304,9 +322,10 @@ def remove_uids(self, uids):
 
         return
 
-    def beta_per_dt(self, disease_beta=None, dt=None, uids=None):
-        if uids is None: uids = Ellipsis
-        return self.edges.beta[uids] * disease_beta * dt
+    def net_beta(self, disease_beta=None, inds=None, disease=None):
+        """ Calculate the beta for the given disease and network """
+        if inds is None: inds = Ellipsis
+        return self.edges.beta[inds] * disease_beta # Beta should already include dt if desired
 
 
 class DynamicNetwork(Network):
@@ -316,9 +335,14 @@ def __init__(self, key_dict=None, **kwargs):
         super().__init__(key_dict=key_dict, **kwargs)
         return
 
+    def step(self):
+        self.end_pairs()
+        self.add_pairs()
+        return
+
     def end_pairs(self):
         people = self.sim.people
-        self.edges.dur = self.edges.dur - self.sim.dt
+        self.edges.dur = self.edges.dur - self.dt # TODO: think about whether this is right # Update: it is, if duration is *NOT* a ss.dur! Otherwise it should be -1, in timestep units
 
         # Non-alive agents are removed
         active = (self.edges.dur > 0) & people.alive[self.edges.p1] & people.alive[self.edges.p2]
@@ -352,9 +376,9 @@ def available(self, people, sex):
         available[self.edges.p2] = False
         return available.uids
 
-    def beta_per_dt(self, disease_beta=None, dt=None, uids=None):
-        if uids is None: uids = Ellipsis
-        return self.edges.beta[uids] * (1 - (1 - disease_beta) ** (self.edges.acts[uids] * dt))
+    def net_beta(self, disease_beta=None, inds=None, disease=None):
+        if inds is None: inds = Ellipsis
+        return self.edges.beta[inds] * (1 - (1 - disease_beta) ** (self.edges.acts[inds] * self.dt))
 
 
 # %% Specific instances of networks
@@ -386,7 +410,7 @@ class StaticNet(Network):
     def __init__(self, graph=None, pars=None, **kwargs):
         super().__init__()
         self.graph = graph
-        self.default_pars(seed=True, p=None, n_contacts=10)
+        self.define_pars(seed=True, p=None, n_contacts=10)
         self.update_pars(pars, **kwargs)
         self.dist = ss.Dist(name='StaticNet')
         return
@@ -400,7 +424,7 @@ def init_pre(self, sim):
         if self.pars.p is None: # Convert from n_contacts to probability
             self.pars.p = n_contacts/self.n_agents
         return
-    
+
     def init_post(self):
         super().init_post()
         if 'seed' in self.pars and self.pars.seed is True:
@@ -412,7 +436,7 @@ def init_post(self):
                 print(f"{str(e)}: networkx {self.graph.name} not supported. Try using ss.NullNet().")
                 raise e
         self.validate_pop(self.n_agents)
-        self.get_contacts()
+        self.get_edges()
         return
 
     def validate_pop(self, n_agents):
@@ -426,15 +450,15 @@ def validate_pop(self, n_agents):
             errmsg = f"The nx generator {self.graph.name} produced a graph with no edges"
             raise ValueError(errmsg)
 
-    def get_contacts(self):
+    def get_edges(self):
         p1s = []
         p2s = []
         for edge in self.graph.edges():
             p1, p2 = edge
             p1s.append(p1)
             p2s.append(p2)
-        contacts = dict(p1=p1s, p2=p2s, beta=np.ones_like(p1s))
-        self.append(contacts)
+        edges = dict(p1=p1s, p2=p2s, beta=np.ones_like(p1s))
+        self.append(edges)
         return
 
 
@@ -444,9 +468,9 @@ class RandomNet(DynamicNetwork):
     def __init__(self, pars=None, key_dict=None, **kwargs):
         """ Initialize """
         super().__init__(key_dict=key_dict)
-        self.default_pars(
+        self.define_pars(
             n_contacts = ss.constant(10),
-            dur = 0,
+            dur = 0, # Note; network edge durations are required to have the same unit as the network
         )
         self.update_pars(pars, **kwargs)
         self.dist = ss.Dist(distname='RandomNet') # Default RNG
@@ -457,7 +481,7 @@ def init_post(self):
         return
 
     @staticmethod
-    @nb.njit(cache=True)
+    @nb.njit(fastmath=True, parallel=False, cache=True)
     def get_source(inds, n_contacts):
         """ Optimized helper function for getting contacts """
         total_number_of_half_edges = np.sum(n_contacts)
@@ -469,9 +493,9 @@ def get_source(inds, n_contacts):
             count += n
         return source
 
-    def get_contacts(self, inds, n_contacts):
+    def get_edges(self, inds, n_contacts):
         """
-        Efficiently generate contacts
+        Efficiently find edges
 
         Note that because of the shuffling operation, each person is assigned 2N contacts
         (i.e. if a person has 5 contacts, they appear 5 times in the 'source' array and 5
@@ -479,7 +503,7 @@ def get_contacts(self, inds, n_contacts):
         function should be HALF of the total contacts a person is expected to have, if both
         the source and target array outputs are used (e.g. for social contacts)
 
-        adjusted_number_of_contacts = np.round(number_of_contacts / 2).astype(cvd.default_int)
+        adjusted_number_of_contacts = np.round(number_of_contacts / 2).astype(ss.dtype.int)
 
         Whereas for asymmetric contacts (e.g. staff-public interactions) it might not be necessary
 
@@ -493,33 +517,28 @@ def get_contacts(self, inds, n_contacts):
         """
         source = self.get_source(inds, n_contacts)
         target = self.dist.rng.permutation(source)
-        self.dist.jump() # Reset the RNG manually # TODO, think if there's a better way
+        self.dist.jump() # Reset the RNG manually; does not auto-jump since using rng directly above # TODO, think if there's a better way
         return source, target
 
-    def update(self):
-        self.end_pairs()
-        self.add_pairs()
-        return
-
     def add_pairs(self):
-        """ Generate contacts """
+        """ Generate edges """
         people = self.sim.people
         born = people.alive & (people.age > 0)
         if isinstance(self.pars.n_contacts, ss.Dist):
             number_of_contacts = self.pars.n_contacts.rvs(born.uids)  # or people.uid?
         else:
-            number_of_contacts = np.full(len(people), self.pars.n_contacts)
+            number_of_contacts = np.ones(len(people))*self.pars.n_contacts
 
         number_of_contacts = sc.randround(number_of_contacts / 2).astype(ss_int_)  # One-way contacts
 
-        p1, p2 = self.get_contacts(born.uids, number_of_contacts)
+        p1, p2 = self.get_edges(born.uids, number_of_contacts)
         beta = np.ones(len(p1), dtype=ss_float_)
 
         if isinstance(self.pars.dur, ss.Dist):
             dur = self.pars.dur.rvs(p1)
         else:
-            dur = np.full(len(p1), self.pars.dur)
-        
+            dur = np.ones(len(p1))*self.pars.dur # Other option would be np.full(len(p1), self.pars.dur.x), but this is harder to read
+
         self.append(p1=p1, p2=p2, beta=beta, dur=dur)
         return
 
@@ -543,7 +562,7 @@ class ErdosRenyiNet(DynamicNetwork):
     def __init__(self, pars=None, key_dict=None, **kwargs):
         """ Initialize """
         super().__init__(key_dict=key_dict)
-        self.default_pars(
+        self.define_pars(
             p = 0.1, # Probability of each edge
             dur = 0, # Duration of zero ensures that new random edges are formed on each time step
         )
@@ -555,11 +574,6 @@ def init_post(self):
         self.add_pairs()
         return
 
-    def update(self):
-        self.end_pairs()
-        self.add_pairs()
-        return
-
     def add_pairs(self):
         """ Generate contacts """
         people = self.sim.people
@@ -574,7 +588,7 @@ def add_pairs(self):
         # Use integers to create random numbers per edge
         i1 = ints[idx1]
         i2 = ints[idx2]
-        r = ss.combine_rands(i1, i2)
+        r = ss.utils.combine_rands(i1, i2) # TODO: use ss.multi_rand()
         edge = r <= self.pars.p
 
         p1 = idx1[edge]
@@ -584,8 +598,8 @@ def add_pairs(self):
         if isinstance(self.pars.dur, ss.Dist):
             dur = self.pars.dur.rvs(p1)
         else:
-            dur = np.full(len(p1), self.pars.dur)
-        
+            dur = np.ones(len(p1))*self.pars.dur
+
         self.append(p1=p1, p2=p2, beta=beta, dur=dur)
         return
 
@@ -605,12 +619,12 @@ class DiskNet(Network):
     def __init__(self, pars=None, key_dict=None, **kwargs):
         """ Initialize """
         super().__init__(key_dict=key_dict)
-        self.default_pars(
+        self.define_pars(
             r = 0.1, # Radius
             v = 0.05, # Velocity
         )
         self.update_pars(pars, **kwargs)
-        self.add_states(
+        self.define_states(
             ss.FloatArr('x', default=ss.random(), label='X position'),
             ss.FloatArr('y', default=ss.random(), label='Y position'),
             ss.FloatArr('theta', default=ss.uniform(high=2*np.pi), label='Heading'),
@@ -621,10 +635,10 @@ def init_post(self):
         self.add_pairs()
         return
 
-    def update(self):
+    def step(self):
 
         # Motion step
-        vdt = self.pars.v * self.sim.dt
+        vdt = self.pars.v * self.dt
         self.x[:] = self.x + vdt * np.cos(self.theta)
         self.y[:] = self.y + vdt * np.sin(self.theta)
 
@@ -674,7 +688,7 @@ class NullNet(Network):
 
     Guarantees there's one (1) contact per agent (themselves), and that their connection weight is zero.
 
-    For an empty network (ie, no contacts) use
+    For an empty network (ie, no edges) use
     >> import starsim as ss
     >> import networkx as nx
     >> empty_net_static = ss.StaticNet(nx.empty_graph)
@@ -696,10 +710,10 @@ def init_pre(self, sim):
             if self.n > popsize:
                 errormsg = f'Please ensure the size of the network ({self.n} is less than or equal to the population size ({popsize}).'
                 raise ValueError(errormsg)
-        self.get_contacts()
+        self.get_edges()
         return
 
-    def get_contacts(self):
+    def get_edges(self):
         indices = np.arange(self.n)
         self.append(dict(p1=indices, p2=indices, beta=np.zeros_like(indices)))
         return
@@ -712,11 +726,11 @@ class MFNet(SexualNetwork):
     """
     def __init__(self, pars=None, key_dict=None, **kwargs):
         super().__init__(key_dict=key_dict)
-        self.default_pars(
+        self.define_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),
+            acts = ss.poisson(lam=80), # TODO: make this work with ss.rate, which it currently does not due to network initialization limitations
             rel_part_rates = 1.0,
         )
         self.update_pars(pars=pars, **kwargs)
@@ -789,9 +803,9 @@ def add_pairs(self):
 
         return len(p1)
 
-    def update(self):
+    def step(self):
         self.end_pairs()
-        self.set_network_states(upper_age=self.sim.dt) # TODO: looks wrong
+        self.set_network_states(upper_age=self.dt) # TODO: check
         self.add_pairs()
         return
 
@@ -803,32 +817,29 @@ class MSMNet(SexualNetwork):
 
     def __init__(self, pars=None, key_dict=None, **kwargs):
         super().__init__(key_dict=key_dict)
-        self.default_pars(
-            duration = ss.lognorm_ex(mean=15, stdev=15),
+        self.define_pars(
+            duration = ss.lognorm_ex(mean=2, std=1),
             debut = ss.normal(loc=16, scale=2),
-            acts = ss.lognorm_ex(mean=80, stdev=20),
+            acts = ss.lognorm_ex(mean=80, std=20),
             participation = ss.bernoulli(p=0.1),
         )
         self.update_pars(pars, **kwargs)
         return
 
-    def init_pre(self, sim):
-        # Add more here in line with MF network, e.g. age of debut
-        # Or if too much replication then perhaps both these networks
-        # should be subclasss of a specific network type (ask LY/DK)
-        super().init_pre(sim)
-        self.set_network_states(sim.people)
-        self.add_pairs(sim.people, ti=0)
+    def init_post(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 """
+        people = self.sim.people
         if upper_age is None: uids = people.uid[people.male]
         else: uids = people.uid[people.male & (people.age < upper_age)]
 
         # Participation
         self.participant[people.female] = False
-        self.participant[uids] = self.participation.rvs(uids) # Should be CRN safe?
+        self.participant[uids] = self.pars.participation.rvs(uids) # Should be CRN safe?
 
         # Debut
         self.debut[uids] = self.pars.debut.rvs(len(uids)) # Just pass len(uids) as this network is not crn safe anyway
@@ -836,7 +847,7 @@ def set_network_states(self, people, upper_age=None):
 
     def add_pairs(self):
         """ Pair all unpartnered MSM """
-        available_m = self.available(self.sim.people, 'm')
+        available_m = self.available(self.sim.people, 'male')
         n_pairs = int(len(available_m)/2)
         p1 = available_m[:n_pairs]
         p2 = available_m[n_pairs:n_pairs*2]
@@ -851,13 +862,13 @@ def add_pairs(self):
             act_vals = self.pars.acts.rvs(len(p1))
 
         self.append(p1=p1, p2=p2, beta=np.ones_like(p1), dur=dur, acts=act_vals)
-        
+
         return len(p1)
 
-    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)
+    def step(self):
+        self.end_pairs()
+        self.set_network_states()
+        self.add_pairs()
         return
 
 
@@ -874,7 +885,7 @@ def __init__(self, pars=None, **kwargs):
             male_shift is the average age that males are older than females in partnerships
         """
         super().__init__()
-        self.default_pars(
+        self.define_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,
@@ -905,11 +916,12 @@ def add_pairs(self):
 
         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
         p1 = available_m[ind_m]
         p2 = available_f[ind_f]
-        beta = np.ones(len(p1)) # TODO: Allow custom beta
+        beta = np.ones(n_pairs) # TODO: Allow custom beta
         dur_vals = self.pars.duration.rvs(p1)
         act_vals = self.pars.acts.rvs(p1)
 
@@ -930,20 +942,24 @@ def __init__(self, key_dict=None, prenatal=True, postnatal=False, **kwargs):
         super().__init__(key_dict=key_dict, prenatal=prenatal, postnatal=postnatal, **kwargs)
         return
 
-    def update(self):
+    def step(self):
         """
         Set beta to 0 for women who complete duration of transmission
         Keep connections for now, might want to consider removing
+
+        NB: add_pairs() and end_pairs() are NOT called here; this is done separately
+        in ss.Pregnancy.update_states().
         """
-        inactive = self.edges.end <= self.sim.ti
+        inactive = self.edges.end <= self.ti
         self.edges.beta[inactive] = 0
         return
 
     def end_pairs(self):
         people = self.sim.people
-        active = (self.edges.end > self.sim.ti) & people.alive[self.edges.p1] & people.alive[self.edges.p2]
+        edges = self.edges
+        active = (edges.end > self.ti) & people.alive[edges.p1] & people.alive[edges.p2]
         for k in self.meta_keys():
-            self.edges[k] = self.edges[k][active]
+            edges[k] = edges[k][active]
         return len(active)
 
     def add_pairs(self, mother_inds=None, unborn_inds=None, dur=None, start=None):
@@ -952,10 +968,10 @@ def add_pairs(self, mother_inds=None, unborn_inds=None, dur=None, start=None):
             return 0
         else:
             if start is None:
-                start = np.full_like(dur, fill_value=self.sim.ti)
+                start = np.ones_like(dur)*self.ti
             n = len(mother_inds)
             beta = np.ones(n)
-            end = start + sc.promotetoarray(dur) / self.sim.dt
+            end = start + sc.promotetoarray(dur)
             self.append(p1=mother_inds, p2=unborn_inds, beta=beta, dur=dur, start=start, end=end)
             return n
 
diff --git a/starsim/parameters.py b/starsim/parameters.py
index 87ee237f..db378ca4 100644
--- a/starsim/parameters.py
+++ b/starsim/parameters.py
@@ -1,7 +1,6 @@
 """
 Set parameters
 """
-
 from numbers import Number
 import numpy as np
 import pandas as pd
@@ -17,7 +16,7 @@
 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.
     """
@@ -30,11 +29,11 @@ def __init__(self, pars=None, **kwargs):
                 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
@@ -47,7 +46,7 @@ def update(self, pars=None, create=False, **kwargs):
             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
@@ -56,12 +55,15 @@ def update(self, pars=None, create=False, **kwargs):
                 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.TimePar):
+                    self._update_timepar(key, old, new)
                 elif isinstance(old, ss.Dist): # Update a distribution
                     self._update_dist(key, old, new)
                 elif callable(old): # It's a function: update directly
@@ -71,7 +73,7 @@ def update(self, pars=None, create=False, **kwargs):
                     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
@@ -84,7 +86,7 @@ def _update_ndict(self, key, old, new):
                 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):
@@ -93,25 +95,55 @@ def _update_module(self, key, old, new):
             errormsg = f'Cannot update a module with {type(new)}: must be a dict to set new parameters'
             raise TypeError(errormsg)
         return
-    
+
+    def _update_timepar(self, key, old, new):
+        """ Update a time parameter (duration or rate) """
+
+        # It's a TimePar, e.g. dur_inf = ss.dur(6); use directly
+        if isinstance(new, ss.TimePar):
+            new_cls = new.__class__
+            old_cls = old.__class__
+            if new_cls == old_cls:
+                self[key] = new
+            else:
+                errormsg = f'When updating a time parameter, the new class ({new_cls}) should match the original class ({old_cls})'
+                raise TypeError(errormsg)
+
+        # It's a single number, e.g. dur_inf = 6; set parameters
+        elif isinstance(new, Number):
+            old.set(new)
+
+        # It's a list of numbers, 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):
+            if isinstance(old, ss.beta):
+                self[key] = new # TODO: use an actual set here
+            else:
+                old.set(**new)
+        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(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
+
+        # It's a list of numbers, 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')
@@ -135,15 +167,15 @@ def check_key_mismatch(self, pars):
             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)
+        return sc.jsonify(dict(self), **kwargs)
 
 
 class SimPars(Pars):
@@ -157,7 +189,7 @@ class SimPars(Pars):
         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)
@@ -168,20 +200,21 @@ def __init__(self, **kwargs):
         self.pop_scale = None  # How much to scale the population
 
         # Simulation parameters
-        self.unit       = 'year' # The time unit to use (NOT YET IMPLEMENTED)
+        self.unit       = 'year' # The time unit to use; options are 'year' (default), 'day', and 'none'
         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.stop       = None   # End of the simulation
+        self.dur        = 50     # Duration of time to run, if stop isn't specified
+        self.dt         = 1.0    # Timestep (in units of self.unit)
+        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
+        self.min_slots  = 100  # Minimum number of slots, useful if the population size is very small
 
         # Demographic parameters
         self.birth_rate = None
         self.death_rate = None
         self.use_aging  = None # True if demographics, false otherwise
 
-        # Modules: demographics, diseases, connectors, networks, analyzers, and interventions
+        # Modules: demographics, diseases, networks, analyzers, and interventions
         self.people = None
         self.networks      = ss.ndict()
         self.demographics  = ss.ndict()
@@ -193,7 +226,7 @@ def __init__(self, **kwargs):
         # 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
@@ -207,16 +240,15 @@ def validate(self):
         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()
+        self.validate_time()
         return
-    
+
     def validate_verbose(self):
         """ Validate verbosity """
         if self.verbose == 'brief':
@@ -225,7 +257,7 @@ def validate_verbose(self):
             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:
@@ -240,7 +272,7 @@ def validate_agents(self):
             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:  # In future, if no pop_scale has been provided, try to get it from the location
@@ -257,48 +289,33 @@ def validate_total_pop(self):
         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
+
+    def validate_time(self):
+        """ Ensure at least one of dur and stop is defined, but not both """
+        if isinstance(self.start, str):
+            self.start = sc.date(self.start)
+        if isinstance(self.stop, str):
+            self.stop = sc.date(self.stop)
+        if self.stop is not None:
+            if self.is_default('dur'):
+                self.dur = ss.date_diff(self.start, self.stop, self.unit)
             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'
+                errormsg = f'You can supply either stop ({self.stop}) or dur ({self.dur}) 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}"
+            if self.dur <= 0:
+                errormsg = f"Duration must be >0, but you supplied start={str(self.start)} and stop={str(self.stop)}, which gives dur={self.dur}"
                 raise ValueError(errormsg)
         else:
-            if self.n_years is not None:
-                self.end = self.start + self.n_years
+            if self.dur is not None:
+                self.stop = ss.date_add(self.start, self.dur, self.unit)
             else:
-                errormsg = 'You must supply one of n_years and end."'
+                errormsg = 'You must supply either "dur" or "stop".'
                 raise ValueError(errormsg)
         return
 
-    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).
-        """
-        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 validation on demographics (must be before modules are created)
         self.validate_demographics()
 
@@ -318,21 +335,32 @@ def validate_modules(self):
         # Do special validation on networks (must be after modules are created)
         self.validate_networks()
         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
+        # Allow shortcut for default demographics
         if self.demographics == True:
-            self.demographics = [ss.Births(), ss.Deaths()]
+            self.demographics = sc.autolist()
+            if self.birth_rate is None:
+                self.demographics += ss.Births()
+            if self.death_rate is None:
+                self.demographics += ss.Deaths()
 
         # Allow users to add vital dynamics by entering birth_rate and death_rate parameters directly to the sim
+        valid = isinstance(self.demographics, ss.ndict) and not len(self.demographics)
         if self.birth_rate is not None:
+            if not valid:
+                errormsg = 'You can only specify birth_rate together with (optionally) death_rate, not other demographics modules; add ss.Births() manually'
+                raise ValueError(errormsg)
             births = ss.Births(birth_rate=self.birth_rate)
             self.demographics += births
         if self.death_rate is not None:
+            if not valid:
+                errormsg = 'You can only specify death_rate together with (optionally) birth_rate, not other demographics modules; add ss.Deaths() manually'
+                raise ValueError(errormsg)
             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
@@ -369,7 +397,7 @@ def convert_modules(self):
         """
         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()
@@ -377,25 +405,25 @@ def convert_modules(self):
             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
@@ -410,24 +438,25 @@ def convert_modules(self):
                             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'
+                    if not isinstance(mod, (expected_cls, ss.Module)): # TEMP: check if this check still works?
+                        errormsg = f'Was expecting {modkey} entry {i} to be class {expected_cls} or Plugin, but was {type(mod)} instead'
                         raise TypeError(errormsg)
                     modlist[i] = mod
         return
 
 
 def make_pars(**kwargs):
+    """ Shortcut for making a new instance of SimPars """
     return SimPars(**kwargs)
diff --git a/starsim/people.py b/starsim/people.py
index 513d56da..58ac6ff5 100644
--- a/starsim/people.py
+++ b/starsim/people.py
@@ -1,8 +1,6 @@
 """
 Defines the People class and functions associated with making people
 """
-
-# %% Imports
 import numpy as np
 import pandas as pd
 import sciris as sc
@@ -20,9 +18,8 @@ class People(sc.prettyobj):
     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.
+    Note that this class handles the mechanics of updating the actual people,
+    as well as the additional housekeeping methods (saving, loading, exporting, etc.).
 
     Args:
         pars (dict): the sim parameters, e.g. sim.pars -- alternatively, if a number, interpreted as n_agents
@@ -35,7 +32,7 @@ class People(sc.prettyobj):
 
     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
@@ -45,23 +42,25 @@ def __init__(self, n_agents, age_data=None, extra_states=None):
         self.version = ss.__version__  # Store version info
         self.initialized = False
 
-        # Handle the three fundamental arrays: UIDs for tracking agents, slots for 
+        # 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.parent = ss.IndexArr('parent', label='UID of parent')  # UID of parent, if any, IndexArray?
         self.uid.grow(new_vals=uids)
         self.slot.grow(new_vals=uids)
+        self.parent.grow(new_uids=uids, new_vals=np.full(len(uids), self.parent.nan))
         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.State('alive', default=True),  # Time index for death
+            ss.State('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)
@@ -72,7 +71,7 @@ def __init__(self, n_agents, age_data=None, extra_states=None):
             self.states.append(state, overwrite=False)
             setattr(self, state.name, state)
             state.link_people(self)
-            
+
         return
 
     @staticmethod
@@ -90,20 +89,20 @@ def get_age_dist(age_data):
             2      195.792
             3      187.442
 
-        The ages will be interpreted as lower bin edges. An upper bin edge will 
-        automatically be added based on the final age plus the difference of the 
-        last two bins. To explicitly control the width of the upper age bin, add 
-        an extra entry to the ``age_data`` with a value of 0 and an age value 
+        The ages will be interpreted as lower bin edges. An upper bin edge will
+        automatically be added based on the final age plus the difference of the
+        last two bins. To explicitly control the width of the upper age bin, add
+        an extra entry to the ``age_data`` with a value of 0 and an age value
         corresponding to the desired upper age bound.
-        
+
         Args:
             age_data: An array/series/dataframe with an index corresponding to age values, and a value corresponding to histogram counts
                          or relative proportions. A distribution will be estimated based on the histogram. The histogram will be
                          assumed to correspond to probability densitiy if the sum of the histogram values is equal to 1, otherwise
                          it will be assumed to correspond to counts.
-        
+
         Note: age_data can also be provided as a string
-        
+
         Returns:
             An ``ss.Dist`` instance that returns an age for newly created agents
         """
@@ -113,7 +112,7 @@ def get_age_dist(age_data):
             # Try loading from file
             if isinstance(age_data, str) or isinstance(age_data, Path):
                 age_data = pd.read_csv(age_data)
-                
+
             # Process
             if isinstance(age_data, np.ndarray): # TODO: accept output of np.histogram()
                 age_bins = age_data[:,0]
@@ -124,21 +123,22 @@ def get_age_dist(age_data):
             elif isinstance(age_data, pd.DataFrame):
                 age_bins = age_data['age'].values
                 age_props = age_data['value'].values
-            
+
             # Convert to a histogram
             dist = ss.histogram(values=age_props, bins=age_bins, name='Age distribution')
-        
+
         return dist
 
     def link_sim(self, sim):
         """ Initialization """
         if self.initialized:
-            errormsg = 'Cannot re-initialize a People object directly; use sim.initialize(reset=True)'
+            errormsg = 'Cannot re-initialize a People object directly; use sim.init(reset=True)'
             raise RuntimeError(errormsg)
         self.sim = sim # Store the sim
+        self.timevec = sim.timevec # Also store the timevec in the People object (not used, but for completeness)
         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
@@ -146,7 +146,7 @@ def add_module(self, module, force=False):
         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
         """
@@ -163,7 +163,7 @@ def add_module(self, module, force=False):
                 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():
@@ -171,19 +171,19 @@ def init_vals(self):
                 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)
-    
+
     @property
     def n_uids(self):
         return self.uid.len_used
-    
+
     def _link_state(self, state, die=True):
         """
         Link a state with the People instance for dynamic resizing; usually called by
@@ -191,7 +191,7 @@ def _link_state(self, state, die=True):
 
         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.
-        This operation is normally triggered as part of initializing the state (via `State.initialize()`)
+        This operation is normally triggered as part of initializing the state (via `State.init()`)
         """
         if id(state) not in self._states:
             self._states[id(state)] = state
@@ -227,10 +227,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)
 
+        self.parent.grow(new_uids, new_vals=self.parent.nan) # Grow parent array
+
         # 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
@@ -264,7 +266,7 @@ def __setstate__(self, state):
         """
         state['_states'] =  {id(v):v for v in state['_states'].values()}
         self.__dict__ = state
-        
+
         return
 
     def scale_flows(self, inds):
@@ -278,7 +280,7 @@ 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
+            self.age[self.alive.uids] += sim.dt_year
         return
 
     def request_death(self, uids):
@@ -309,28 +311,35 @@ def request_death(self, uids):
         self.ti_dead[uids] = self.sim.ti
         return
 
-    def resolve_deaths(self):
+    def step_die(self):
         """ Carry out any deaths that took place this timestep """
         death_uids = (self.ti_dead <= self.sim.ti).uids
         self.alive[death_uids] = False
+
+        # 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
+        for disease in self.sim.diseases():
+            if isinstance(disease, ss.Disease):
+                disease.step_die(death_uids)
+
         return death_uids
-    
+
     def remove_dead(self):
         """
         Remove dead agents
         """
         uids = self.dead.uids
         if len(uids):
-            
+
             # Remove the UIDs from the networks too
             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[np.isin(self.auids, np.unique(uids), assume_unique=True, invert=True, kind='sort')]
 
         return
-    
+
     @property
     def dead(self):
         """ Dead boolean """
@@ -348,7 +357,13 @@ def update_results(self):
         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 finish_step(self):
+        # self.update_results() # This is called separately
+        self.remove_dead()
+        self.update_post()
+        return
+
     def person(self, ind):
         """ Get all the properties for a single person """
         person = Person()
@@ -357,12 +372,12 @@ def person(self, ind):
         for key in self.states.keys():
             person[key] = self.states[key][ind]
         return person
-            
+
 
 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 = 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 252df413..145a5434 100644
--- a/starsim/products.py
+++ b/starsim/products.py
@@ -1,7 +1,6 @@
 """
 Define products
 """
-
 import starsim as ss
 import sciris as sc
 import numpy as np
@@ -25,7 +24,7 @@ def administer(self, people, inds):
 
 class Dx(Product):
     """
-    Generic class for diagnostics 
+    Generic class for diagnostics
     """
 
     def __init__(self, df, hierarchy=None, *args, **kwargs):
@@ -48,10 +47,10 @@ def __init__(self, df, hierarchy=None, *args, **kwargs):
     def default_value(self):
         return len(self.hierarchy) - 1
 
-    def administer(self, sim, uids, return_format='dict'):
+    def administer(self, uids, return_format='dict'):
         """
         Administer a testing product.
-        
+
         Returns:
 
              if return_format=='array': an array of length len(inds) with integer entries that map each person to one of the result_states
@@ -63,7 +62,7 @@ def administer(self, sim, uids, return_format='dict'):
 
         for disease in self.diseases:
             for state in self.health_states:
-                this_state = getattr(sim.diseases[disease], state)
+                this_state = getattr(self.sim.diseases[disease], state)
                 true_uids = this_state.uids # Find people for which this state is true
                 these_uids = true_uids.intersect(uids) # Find intersection of people in this state and the supplied UIDs
 
@@ -96,7 +95,7 @@ def __init__(self, df, *args, **kwargs):
         self.efficacy_dist = ss.bernoulli(p=0)
         return
 
-    def administer(self, sim, uids, return_format='dict'):
+    def administer(self, uids, return_format='dict'):
         """
         Loop over treatment states to determine those who are successfully treated and clear infection
         """
@@ -104,26 +103,20 @@ def administer(self, sim, uids, return_format='dict'):
         tx_successful = []  # Initialize list of successfully treated individuals
 
         for disease_name in self.diseases:
-
-            disease = sim.diseases[disease_name]
+            disease = self.sim.diseases[disease_name]
 
             for state in self.health_states:
-
                 pre_tx_state = getattr(disease, state)
                 true_uids = pre_tx_state.uids # People in this state
                 these_uids = true_uids.intersect(uids)
 
                 if len(these_uids):
-
                     df_filter = (self.df.state == state) & (self.df.disease == disease_name)  # Filter by state
                     thisdf = self.df[df_filter]  # apply filter to get the results for this state & genotype
 
                     # Determine whether treatment is successful
                     self.efficacy_dist.set(p=thisdf.efficacy.values[0])
-
-                    # HACK to reset the efficacy_dist as it is called multiple times per timestep. TODO: Refactor
-                    self.efficacy_dist.jump(sim.ti+1)
-                    eff_treat_inds = self.efficacy_dist.filter(these_uids)
+                    eff_treat_inds = self.efficacy_dist.filter(these_uids) # TODO: think if there's a way of not calling this inside a loop like this
 
                     post_tx_state_name = thisdf.post_state.values[0]
                     post_tx_state = getattr(disease, post_tx_state_name)
diff --git a/starsim/regression/pars_v2.0.0.json b/starsim/regression/pars_v2.0.0.json
new file mode 100644
index 00000000..15d5dd12
--- /dev/null
+++ b/starsim/regression/pars_v2.0.0.json
@@ -0,0 +1,200 @@
+{
+  "pars": {
+    "verbose": 0,
+    "n_agents": 10000,
+    "total_pop": 10000,
+    "pop_scale": 1.0,
+    "unit": "year",
+    "start": 2000,
+    "stop": 2020,
+    "dur": 20,
+    "dt": 0.2,
+    "rand_seed": 2,
+    "slot_scale": 5,
+    "min_slots": 100,
+    "birth_rate": null,
+    "death_rate": null,
+    "use_aging": true,
+    "births": {
+      "birth_rate": 30,
+      "rel_birth": 1,
+      "units": 0.001
+    },
+    "deaths": {
+      "rel_death": 1,
+      "death_rate": {
+        "type": "Dist",
+        "classname": "bernoulli",
+        "tracestr": "pars_deaths_death_rate",
+        "diststr": "",
+        "pars": {
+          "p": {
+            "python_class": "<class 'function'>"
+          }
+        }
+      },
+      "units": 0.001
+    },
+    "randomnet": {
+      "n_contacts": {
+        "type": "Dist",
+        "classname": "constant",
+        "tracestr": "pars_randomnet_n_contacts",
+        "diststr": "",
+        "pars": {
+          "v": 10
+        }
+      },
+      "dur": 0
+    },
+    "mfnet": {
+      "duration": {
+        "type": "Dist",
+        "classname": "lognorm_ex",
+        "tracestr": "pars_mfnet_duration",
+        "diststr": "",
+        "pars": {
+          "mean": 15,
+          "std": 1.0
+        }
+      },
+      "participation": {
+        "type": "Dist",
+        "classname": "bernoulli",
+        "tracestr": "pars_mfnet_participation",
+        "diststr": "",
+        "pars": {
+          "p": 0.9
+        }
+      },
+      "debut": {
+        "type": "Dist",
+        "classname": "normal",
+        "tracestr": "pars_mfnet_debut",
+        "diststr": "",
+        "pars": {
+          "loc": 16,
+          "scale": 1.0
+        }
+      },
+      "acts": {
+        "type": "Dist",
+        "classname": "poisson",
+        "tracestr": "pars_mfnet_acts",
+        "diststr": "",
+        "pars": {
+          "lam": 80
+        }
+      },
+      "rel_part_rates": 1.0
+    },
+    "maternalnet": {},
+    "sir": {
+      "log": false,
+      "beta": {
+        "classname": "beta",
+        "v": 0.1,
+        "unit": "year",
+        "parent_unit": "year",
+        "parent_dt": 0.2,
+        "parent_name": "Deaths",
+        "self_dt": 1.0,
+        "factor": 5.0,
+        "values": 0.0208516376390232
+      },
+      "init_prev": {
+        "type": "Dist",
+        "classname": "bernoulli",
+        "tracestr": "pars_sir_init_prev",
+        "diststr": "",
+        "pars": {
+          "p": 0.01
+        }
+      },
+      "dur_inf": {
+        "type": "Dist",
+        "classname": "lognorm_ex",
+        "tracestr": "pars_sir_dur_inf",
+        "diststr": "",
+        "pars": {
+          "mean": {
+            "classname": "dur",
+            "v": 6,
+            "unit": "year",
+            "parent_unit": "year",
+            "parent_dt": 0.2,
+            "parent_name": "Deaths",
+            "self_dt": 1.0,
+            "factor": 5.0,
+            "values": 30.0
+          },
+          "std": 1.0
+        }
+      },
+      "p_death": {
+        "type": "Dist",
+        "classname": "bernoulli",
+        "tracestr": "pars_sir_p_death",
+        "diststr": "",
+        "pars": {
+          "p": 0.01
+        }
+      }
+    },
+    "sis": {
+      "log": false,
+      "beta": {
+        "classname": "beta",
+        "v": 0.05,
+        "unit": "year",
+        "parent_unit": "year",
+        "parent_dt": 0.2,
+        "parent_name": "Deaths",
+        "self_dt": 1.0,
+        "factor": 5.0,
+        "values": 0.010206218313011495
+      },
+      "init_prev": {
+        "type": "Dist",
+        "classname": "bernoulli",
+        "tracestr": "pars_sis_init_prev",
+        "diststr": "",
+        "pars": {
+          "p": 0.01
+        }
+      },
+      "dur_inf": {
+        "type": "Dist",
+        "classname": "lognorm_ex",
+        "tracestr": "pars_sis_dur_inf",
+        "diststr": "",
+        "pars": {
+          "mean": {
+            "classname": "dur",
+            "v": 10,
+            "unit": "year",
+            "parent_unit": "year",
+            "parent_dt": 0.2,
+            "parent_name": "Deaths",
+            "self_dt": 1.0,
+            "factor": 5.0,
+            "values": 50.0
+          },
+          "std": 1.0
+        }
+      },
+      "waning": {
+        "classname": "rate",
+        "v": 0.05,
+        "unit": "year",
+        "parent_unit": "year",
+        "parent_dt": 0.2,
+        "parent_name": "Deaths",
+        "self_dt": 1.0,
+        "factor": 5.0,
+        "values": 0.01
+      },
+      "imm_boost": 1.0
+    }
+  }
+}
\ No newline at end of file
diff --git a/starsim/results.py b/starsim/results.py
index 789de967..721fdca0 100644
--- a/starsim/results.py
+++ b/starsim/results.py
@@ -1,99 +1,232 @@
 """
 Result structures.
 """
-
 import numpy as np
 import sciris as sc
 import starsim as ss
-
+import matplotlib.pyplot as plt
 
 __all__ = ['Result', 'Results']
 
 
-class Result(np.ndarray):
-    
-    # Define the custom attributes
-    _custom_attrs = [
-        'name', # The name of the result (e.g. n_infections)
-        'module', # The name of the module (e.g. hiv)
-        'scale', # Whether or not the result scales with population size (e.g. True)
-        'label', # The human-readable label for the result (e.g. Number of infections)
-        'low', # The lower bound for the values (used with MultiSim)
-        'high', # The upper bound for the values
-    ]
-    
-    def __new__(cls, module=None, name=None, shape=None, dtype=None, scale=None, label=None, low=None, high=None):
-        arr = np.zeros(shape=shape, dtype=dtype).view(cls)
-        arr.name = name
-        arr.module = module
-        arr.scale = scale
-        arr.label = label
-        arr.low = low
-        arr.high = high
-        return arr
-    
-    
-    def __repr__(self):
+class Result(ss.BaseArr):
+    """
+    Array-like container for holding sim results.
+
+    Args:
+        module (str): the name of the parent module, e.g. 'hiv'
+        name (str): the name of this result, e.g. 'new_infections'
+        shape (int/tuple): the shape of the result array (usually module.npts)
+        scale (bool): whether or not the result scales by population size (e.g. a count does, a prevalence does not)
+        label (str): a human-readable label for the result
+        values (array): prepopulate the Result with these values
+        timevec (array): an array of time points
+        low (array): values for the lower bound
+        high (array): values for the upper bound
+
+    In most cases, ``ss.Result`` behaves exactly like ``np.array()``, except with
+    the additional fields listed above. To see everything contained in a result,
+    you can use result.disp().
+    """
+    def __init__(self, name=None, label=None, dtype=float, shape=None, scale=True,
+                 module=None, values=None, timevec=None, low=None, high=None):
+        # Copy inputs
+        self.name = name
+        self.label = label
+        self.module = module
+        self.scale = scale
+        self.timevec = timevec
+        self.low = low
+        self.high = high
+        self.dtype = dtype
+        self.shape = shape
+        self.values = values
+        self.init_values()
+        return
+
+    @property
+    def initialized(self):
+        return self.values is not None
+
+    def init_values(self, values=None, dtype=None, shape=None, force=False):
+        """ Handle values """
+        if not self.initialized or force:
+            values = sc.ifelse(values, self.values)
+            dtype = sc.ifelse(dtype, self.dtype)
+            shape = sc.ifelse(shape, self.shape)
+            if values is not None: # Create if values already supplied
+                self.values = np.array(values, dtype=dtype)
+                dtype = self.values.dtype
+                shape = self.values.shape
+            elif shape is not None: # Or if a shape is provided, initialize
+                self.values = np.zeros(shape=shape, dtype=dtype)
+            else:
+                self.values = None
+            self.dtype = dtype
+            self.shape = shape
+        return self.values
+
+    def update(self, *args, **kwargs):
+        """ Update parameters, and initialize values if needed """
+        super().update(*args, **kwargs)
+        self.init_values()
+        return
+
+    @property
+    def key(self):
+        """ Return the unique key of the result: <module>.<name>, e.g. "hiv.new_infections" """
         modulestr = f'{self.module}.' if (self.module is not None) else ''
+        namestr = self.name if (self.name is not None) else 'unnamed'
+        key = modulestr + namestr
+        return key
+
+    @property
+    def full_label(self):
+        """ Return the full label of the result: <Module>: <label>, e.g. "HIV: New infections" """
+        reslabel = sc.ifelse(self.label, self.name)
+        if self.module == 'sim': # Don't add anything if it's the sim
+            full = f'Sim: {reslabel}'
+        else:
+            try:
+                mod = ss.find_modules(flat=True)[self.module]
+                modlabel = mod.__name__
+                assert self.module == modlabel.lower(), f'Mismatch: {self.module}, {modlabel}' # Only use the class name if the module name is the default
+            except: # Don't worry if we can't find it, just use the module name
+                modlabel = self.module.title()
+            full = f'{modlabel}: {reslabel}'
+        return full
+
+    def __repr__(self):
         cls_name = self.__class__.__name__
         arrstr = super().__repr__().removeprefix(cls_name)
-        out = f'{cls_name}({modulestr}{self.name}):\narray{arrstr}'
+        out = f'{cls_name}({self.key}):\narray{arrstr}'
         return out
 
-    def __array_finalize__(self, obj):
-        if obj is None:
-            return
-        for attr in self._custom_attrs:
-            value = getattr(obj, attr, None)
-            setattr(self, attr, value)
-        return
-
-    def __array_wrap__(self, obj, **kwargs):
-        if obj.shape == ():
-            return obj[()]
+    def __getitem__(self, key):
+        """ Allow e.g. result['low'] """
+        if isinstance(key, str):
+            return getattr(self, key)
         else:
-            return super().__array_wrap__(obj, **kwargs)
-    
-    def to_df(self):
-        data = {self.name:self}
-        if self.low is not None:
-            data['low']  = self.low
-        if self.high is not None:
-            data['high'] = self.high
+            return super().__getitem__(key)
+
+    def to_df(self, sep='_', rename=False):
+        """
+        Convert to a dataframe with timevec, value, low, and high columns
+
+        Args:
+            rename (bool): if True, rename the columns with the name of the result (else value, low, high)
+        """
+        data = dict()
+        if self.timevec is not None:
+            data['timevec'] = self.timevec
+        valcol = self.name if rename else 'value'
+        data[valcol] = self.values
+        for key in ['low', 'high']:
+            val = self[key]
+            valcol = f'{self.name}{sep}{key}' if rename else key
+            if val is not None:
+                data[valcol] = val
         df = sc.dataframe(data)
         return df
-    
+
+    def plot(self, fig=None, ax=None, fig_kw=None, plot_kw=None, fill_kw=None, **kwargs):
+        """ Plot a single result; kwargs are interpreted as plot_kw """
+        # Prepare inputs
+        fig_kw = sc.mergedicts(fig_kw)
+        plot_kw = sc.mergedicts(dict(lw=3, alpha=0.8), plot_kw, kwargs)
+        fill_kw = sc.mergedicts(dict(alpha=0.1), fill_kw)
+        if fig is None and ax is None:
+            fig = plt.figure(**fig_kw)
+        if ax is None:
+            ax = plt.subplot(111)
+        if self.timevec is None:
+            errormsg = f'Cannot figure out how to plot {self}: no time data associated with it'
+            raise ValueError(errormsg)
+
+        # Plot bounds
+        if self.low is not None and self.high is not None:
+            ax.fill_between(self.timevec, self.low, self.high, **fill_kw)
+
+        # Plot results
+        plt.plot(self.timevec, self.values, **plot_kw)
+        plt.title(self.full_label)
+        plt.xlabel('Time')
+        sc.commaticks()
+        if (self.values.min() >= 0) and (plt.ylim()[0]<0): # Don't allow axis to go negative if results don't
+            plt.ylim(bottom=0)
+        return fig
+
 
 class Results(ss.ndict):
-    
-    def __init__(self, module, strict=True, *args, **kwargs):
-        super().__init__(type=Result, strict=strict)
+    """ Container for storing results """
+    def __init__(self, module, *args, strict=True, **kwargs):
         if hasattr(module, 'name'):
             module = module.name
         self.setattribute('_module', module)
+        super().__init__(type=Result, strict=strict, *args, **kwargs)
         return
-    
+
+    def __repr__(self, *args, **kwargs): # TODO: replace with dataframe summary
+        return super().__repr__(*args, **kwargs)
+
     def append(self, arg, key=None):
+        """ This is activated by adding as well, e.g. results += result """
         if isinstance(arg, (list, tuple)):
             result = ss.Result(self._module, *arg)
         elif isinstance(arg, dict):
             result = ss.Result(self._module, **arg)
         else:
             result = arg
+
+        if not isinstance(result, Result):
+            warnmsg = f'You are adding a result of type {type(result)} to Results, which is inadvisable.'
+            ss.warn(warnmsg)
+
         if result.module != self._module:
+            if result.module:
+                warnmsg = f'You are adding a result from module {result.module} to module {self._module}; check that this is intentional.'
+                ss.warn(warnmsg)
             result.module = self._module
-        
+
         super().append(result, key=key)
         return
-    
-    def flatten(self, sep='_'):
-        return sc.objdict(sc.flattendict(self, sep=sep))
-    
-    def to_df(self):
-        pass
-    
-    def __repr__(self, *args, **kwargs): # TODO: replace with dataframe summary
-        return super().__repr__(*args, **kwargs)
-        
-    def plot(self):
-        pass
+
+    @property
+    def all_results(self):
+        """ Iterator over all results, skipping any nested values """
+        return iter(res for res in self.values() if isinstance(res, Result))
+
+    def flatten(self, sep='_', only_results=True):
+        """ Turn from a nested dictionary into a flat dictionary, keeping only results by default """
+        out = sc.flattendict(self, sep=sep)
+        if only_results:
+            out = sc.objdict({k:v for k,v in out.items() if isinstance(v, Result)})
+        return out
+
+    def to_df(self, sep='_'):
+        """ Merge all results dataframes into one """
+        dfs = [res.to_df(sep=sep, rename=True) for res in self.all_results]
+        df = dfs[0]
+        for df2 in dfs[1:]:
+            df = df.merge(df2)
+        return df
+
+    def plot(self, style='fancy', fig_kw=None, plot_kw=None):
+        """ Plot all the results """
+        # Prepare the inputs
+        fig_kw = sc.mergedicts(fig_kw)
+        plot_kw = sc.mergedicts(plot_kw)
+        results = list(self.all_results)
+        nrows,ncols = sc.getrowscols(len(results))
+
+        # Do the plotting
+        with sc.options.with_style(style):
+            fig = plt.figure(**fig_kw)
+            for i,res in enumerate(results):
+                ax = plt.subplot(nrows, ncols, i+1)
+                res.plot(ax=ax, **plot_kw)
+            sc.figlayout()
+        return fig
+
+
+
diff --git a/starsim/run.py b/starsim/run.py
index acc71b49..064591c1 100644
--- a/starsim/run.py
+++ b/starsim/run.py
@@ -1,24 +1,32 @@
 """
 Utilities for running in parallel
 """
-
 import numpy as np
 import sciris as sc
-import pylab as pl
+import matplotlib.pyplot as plt
 import starsim as ss
 
 __all__ = ['MultiSim', 'single_run', 'multi_run', 'parallel']
 
 
-class MultiSim(sc.prettyobj):
+class MultiSim:
     """
     Class for running multiple copies of a simulation.
-    """
-
-    def __init__(self, sims=None, base_sim=None, label=None, initialize=False, *args, **kwargs):
 
+    Args:
+        sims (Sim/list): a single sim or a list of sims
+        base_sim (Sim): the sim used for shared properties; if not supplied, the first of the sims provided
+        label (str): the name of the multisim
+        n_runs (int): if a single sim is provided, the number of replicates (default 4)
+        initialize (bool): whether or not to initialize the sims (otherwise, initialize them during run)
+        inplace (bool): whether to modify the sims in-place (default True); else return new sims
+        debug (bool): if True, run in serial
+        kwargs (dict): stored in run_args and passed to run()
+    """
+    def __init__(self, sims=None, base_sim=None, label=None, n_runs=4, initialize=False,
+                 inplace=True, debug=False, **kwargs):
         # Handle inputs
-        super().__init__(*args, **kwargs)
+        super().__init__(**kwargs)
         if base_sim is None:
             if isinstance(sims, ss.Sim):
                 base_sim = sims
@@ -34,9 +42,11 @@ def __init__(self, sims=None, base_sim=None, label=None, initialize=False, *args
         self.sims = sims
         self.base_sim = base_sim
         self.label = base_sim.label if (label is None and base_sim is not None) else label
-        self.run_args = sc.mergedicts(kwargs)
+        self.run_args = sc.mergedicts(dict(n_runs=n_runs, inplace=inplace, debug=debug), kwargs)
         self.results = None
+        self.summary = None
         self.which = None  # Whether the multisim is to be reduced, combined, etc.
+        self.timer = sc.timer() # Create a timer
 
         # Optionally initialize
         if initialize:
@@ -45,7 +55,60 @@ def __init__(self, sims=None, base_sim=None, label=None, initialize=False, *args
         return
 
     def __len__(self):
-        return len(self.sims)
+        """ The length of a MultiSim is how many sims it contains """
+        try:
+            return len(self.sims)
+        except:
+            return 0
+
+    def __repr__(self):
+        """ Return a brief description of a multisim; see multisim.disp() for the more detailed version. """
+        try:
+            labelstr = f'"{self.label}"; ' if self.label else ''
+            string   = f'MultiSim({labelstr}n_sims: {len(self)}; base: {self.base_sim})'
+        except Exception as E:
+            string = sc.objectid(self)
+            string += f'Warning, multisim appears to be malformed:\n{str(E)}'
+        return string
+
+    def brief(self):
+        """ A single-line display of the MultiSim; same as print(multisim) """
+        print(self)
+        return
+
+    def show(self, output=False):
+        """
+        Print a moderate length summary of the MultiSim. See also multisim.disp()
+        (detailed output) and multisim.brief() (short output).
+
+        Args:
+            output (bool): if true, return a string instead of printing output
+
+        **Example**::
+
+            msim = ss.MultiSim(ss.demo(run=False), label='Example multisim')
+            msim.run()
+            msim.show() # Prints moderate length output
+        """
+        labelstr = f' "{self.label}"' if self.label else ''
+        simlenstr = f'{len(self)}'
+        string  = f'MultiSim{labelstr} summary:\n'
+        string += f'  Number of sims: {simlenstr}\n'
+        string += f'  Reduced/combined: {self.which}\n'
+        string += f'  Base: {self.base_sim}\n'
+        if self.sims:
+            string += '  Sims:\n'
+            for s,sim in enumerate(self.sims):
+                string += f'    {s}: {sim}\n'
+        if not output:
+            print(string)
+            return
+        else:
+            return string
+
+    def disp(self):
+        """ Display the full object """
+        return sc.pr(self)
 
     def init_sims(self, **kwargs):
         """
@@ -60,16 +123,19 @@ def init_sims(self, **kwargs):
 
         # Initialize the sims but don't run them
         kwargs = sc.mergedicts(self.run_args, kwargs, {'do_run': False})  # Never run, that's the point!
+        kwargs.pop('inplace', None)
+        kwargs.pop('debug', None)
         self.sims = multi_run(sims, **kwargs)
 
         return
 
-    def run(self, n_runs=4, **kwargs):
+    def run(self, **kwargs):
         """
-        Run the sims
+        Run the sims; see ``ss.multi_run()`` for additional arguments
 
         Args:
-            n_runs (int): whether to reduce after running (see reduce())
+            n_runs (int): how many replicates of each sim to run (if a list of sims is not provided)
+            inplace (bool): whether to modify the sims in place (otherwise return copies)
             kwargs (dict): passed to multi_run(); use run_args to pass arguments to sim.run()
 
         Returns:
@@ -88,8 +154,21 @@ def run(self, n_runs=4, **kwargs):
                     sim.label = f'Sim {s}'
 
         # Run
+        self.timer.start()
         kwargs = sc.mergedicts(self.run_args, kwargs)
-        self.sims = multi_run(sims, n_runs=n_runs, **kwargs)
+        inplace = kwargs.pop('inplace', True)
+        debug = kwargs.pop('debug', False)
+        if debug:
+            run_sims = [single_run(sim, **kwargs) for sim in sims]
+        else: # The next line does all the work!
+            run_sims = multi_run(sims, **kwargs) # Output sims are copies due to the pickling during parallelization
+
+        # Handle output
+        if inplace and isinstance(self.sims, list) and len(run_sims) == len(self.sims): # Validation
+            for old,new in zip(self.sims, run_sims):
+                old.__dict__.update(new.__dict__) # Update the same object with the new results
+        self.sims = run_sims # Just overwrite references
+        self.timer.stop()
 
         return self
 
@@ -158,7 +237,7 @@ def reduce(self, quantiles=None, use_mean=False, bounds=None, output=False):
                 except Exception as E:
                     errormsg = (f'Could not figure out how to convert {quantiles} into a quantiles object:'
                                 f' must be a dict with keys low, high or a 2-element array ({str(E)})')
-                    raise ValueError(errormsg)
+                    raise ValueError(errormsg) from E
 
         # Store information on the sims
         n_runs = len(self)
@@ -168,12 +247,9 @@ def reduce(self, quantiles=None, use_mean=False, bounds=None, output=False):
 
         # Calculate the statistics
         raw = {}
-        exclude = ['yearvec'] # Exclude things that we don't want to include
 
         rflat = reduced_sim.results.flatten()
         rkeys = list(rflat.keys())
-        rkeys = [k for k in rkeys if k not in exclude]
-
         for rkey in rkeys:
             raw[rkey] = np.zeros((reduced_sim.npts, len(self.sims)))
             for s, sim in enumerate(self.sims):
@@ -181,22 +257,23 @@ def reduce(self, quantiles=None, use_mean=False, bounds=None, output=False):
                 raw[rkey][:, s] = flat[rkey]
 
         for rkey in rkeys:
+            res = rflat[rkey]
             if use_mean:
                 r_mean = np.mean(raw[rkey], axis=1)
                 r_std = np.std(raw[rkey], axis=1)
-                rflat[rkey][:] = r_mean
-                rflat[rkey].low = r_mean - bounds * r_std
-                rflat[rkey].high = r_mean + bounds * r_std
+                res[:] = r_mean
+                res.low = r_mean - bounds * r_std
+                res.high = r_mean + bounds * r_std
             else:
-                rflat[rkey][:] = np.quantile(raw[rkey], q=0.5, axis=1)
-                rflat[rkey].low = np.quantile(raw[rkey], q=quantiles['low'], axis=1)
-                rflat[rkey].high = np.quantile(raw[rkey], q=quantiles['high'], axis=1)
+                res[:] = np.quantile(raw[rkey], q=0.5, axis=1)
+                res.low = np.quantile(raw[rkey], q=quantiles['low'], axis=1)
+                res.high = np.quantile(raw[rkey], q=quantiles['high'], axis=1)
 
         # Compute and store final results
         reduced_sim.summarize()
         self.orig_base_sim = self.base_sim
         self.base_sim = reduced_sim
-        self.results = rflat
+        self.results = ss.Results('MultiSim').merge(rflat) # Create the dictionary and merge it
         self.summary = reduced_sim.summary
         self.which = 'reduced'
 
@@ -224,47 +301,47 @@ def median(self, quantiles=None, **kwargs):
             kwargs (dict): passed to reduce()
         """
         return self.reduce(use_mean=False, quantiles=quantiles, **kwargs)
-    
+
     def summarize(self, method='mean', quantiles=None, how='default'):
         """
         Summarize the simulations statistically.
-        
+
         Args:
             method (str): one of 'mean' (default: [mean, 2*std]), 'median' ([median, min, max]), or 'all' (all results)
             quantiles (dict): if method='median', use these quantiles
             how (str): passed to sim.summarize()
         """
-        
+
         # Compute the summaries
         summaries = []
         for sim in self.sims:
             summaries.append(sim.summarize(how=how))
-            
+
         summary = sc.dcp(summaries[0]) # Use the first one as a template
         for k in summary.keys():
             arr = np.array([s[k] for s in summaries])
             if method == 'all':
                 summary[k] = arr
             elif method == 'mean':
-                summary[k] = sc.objdict({'mean':arr.mean(), 'std':arr.std(), 'sem':arr.std()/np.sqrt(len(arr))}) # TODO: replace with sc.sem()
+                summary[k] = sc.objdict({'mean':arr.mean(), 'std':arr.std(), 'sem':sc.sem(arr)})
             elif method == 'median':
                 if quantiles is None:
                     quantiles = sc.objdict({'median':0.5, 'min':0, 'max':1, 'q25':0.25, 'q75':0.75})
                 elif isinstance(quantiles, list):
                     quantiles = {q:q for q in quantiles}
                 summary[k] = {q:v for q,v in zip(quantiles, np.quantile(arr, quantiles))}
-        
+
         self.summary = summary # Could reconcile with reduce()'s summary
-                                  
+
         return summary
-    
+
     def plot(self, key=None, fig=None, fig_kw=None, plot_kw=None, fill_kw=None):
-        """ 
+        """
         Plot all results in the MultiSim object.
-        
+
         If the MultiSim object has been reduced (i.e. mean or median), then plot
         the best value and uncertainty bound. Otherwise, plot individual sims.
-        
+
         Args:
             key (str): the results key to plot (by default, all)
             fig (Figure): if provided, plot results into an existing figure
@@ -275,16 +352,15 @@ def plot(self, key=None, fig=None, fig_kw=None, plot_kw=None, fill_kw=None):
         # Has not been reduced yet, plot individual sim
         if self.which is None:
             fig = None
-            alpha = 0.7 if len(self.sims) < 5 else 0.5
+            alpha = 0.7 if len(self) < 5 else 0.5
             plot_kw = sc.mergedicts({'alpha':alpha}, plot_kw)
             for sim in self.sims:
                 fig = sim.plot(key=key, fig=fig, fig_kw=fig_kw, plot_kw=plot_kw)
-            pl.legend()
-        
+            plt.legend()
+
         # Has been reduced, plot with uncertainty bounds
         else:
             flat = self.results
-            yearvec = flat.pop('yearvec')
             n_cols = np.ceil(np.sqrt(len(flat))) # TODO: remove duplication with sim.plot()
             default_figsize = np.array([8, 6])
             figsize_factor = np.clip((n_cols-3)/6+1, 1, 1.5) # Scale the default figure size based on the number of rows and columns
@@ -296,15 +372,18 @@ def plot(self, key=None, fig=None, fig_kw=None, plot_kw=None, fill_kw=None):
             with sc.options.with_style('simple'):
                 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, **fig_kw)
-                    
+                if fig is None:
+                    fig, axs = sc.getrowscols(len(flat), make=True, **fig_kw)
+                else:
+                    axs = sc.toarray(fig.axes)
+
                 # Do the plotting
                 for ax, (key, res) in zip(axs.flatten(), flat.items()):
-                    ax.fill_between(yearvec, res.low, res.high, **fill_kw)
-                    ax.plot(yearvec, res, **plot_kw)
-                    ax.set_title(getattr(res, 'label', key)) 
+                    ax.fill_between(res.timevec, res.low, res.high, **fill_kw)
+                    ax.plot(res.timevec, res, **plot_kw)
+                    ax.set_title(getattr(res, 'label', key))
                     ax.set_xlabel('Year')
-                
+
         return fig
 
 
@@ -348,7 +427,7 @@ def single_run(sim, ind=0, reseed=True, keep_people=False, run_args=None, sim_ar
 
     if reseed:
         sim.pars['rand_seed'] += ind  # Reset the seed, otherwise no point of parallel runs
-        ss.set_seed()
+        ss.set_seed() # Note: may not be needed
 
     if verbose >= 1:
         verb = 'Running' if do_run else 'Creating'
@@ -360,7 +439,9 @@ def single_run(sim, ind=0, reseed=True, keep_people=False, run_args=None, sim_ar
         if key in sim.pars.keys():
             if verbose >= 1:
                 print(f'Setting key {key} from {sim[key]} to {val}')
-                sim[key] = val
+            sim.pars[key] = val
+            if key == 'rand_seed':
+                ss.set_seed() # Note: may not be needed
         else:
             raise sc.KeyNotFoundError(f'Could not set key {key}: not a valid parameter name')
 
@@ -379,7 +460,7 @@ def multi_run(sim, n_runs=4, reseed=None, iterpars=None, keep_people=None, run_a
               par_args=None, do_run=True, parallel=True, n_cpus=None, verbose=None, **kwargs):
     """
     For running multiple sims in parallel. If the first argument is a list of sims
-    rather than a single sim, exactly these will be run and most other arguments 
+    rather than a single sim, exactly these will be run and most other arguments
     will be ignored.
 
     Args:
@@ -475,7 +556,8 @@ def multi_run(sim, n_runs=4, reseed=None, iterpars=None, keep_people=None, run_a
             sims.append(sim)
 
     return sims
-                  
+
+
 def parallel(*args, **kwargs):
     """
     A shortcut to ``ss.MultiSim()``, allowing the quick running of multiple simulations
@@ -490,10 +572,12 @@ def parallel(*args, **kwargs):
 
     **Examples**::
 
-        s1 = ss.Sim(beta=0.01, label='Low')
-        s2 = ss.Sim(beta=0.02, label='High')
+        s1 = ss.Sim(n_agents=1000, label='Small', diseases='sis', networks='random')
+        s2 = ss.Sim(n_agents=2000, label='Large', diseases='sis', networks='random')
         ss.parallel(s1, s2).plot()
         msim = ss.parallel([s1, s2], keep_people=True)
     """
     sims = sc.mergelists(*args)
-    return MultiSim(sims=sims).run(**kwargs)
+    msim = MultiSim(sims=sims, **kwargs)
+    msim.run()
+    return msim
diff --git a/starsim/samples.py b/starsim/samples.py
index a8818a64..1e43f911 100644
--- a/starsim/samples.py
+++ b/starsim/samples.py
@@ -6,7 +6,6 @@
 - samples: collection of results with same parameters but different seeds
 - dataset: collection of samples (with different parameters)
 """
-
 import io
 import zipfile
 import sciris as sc
@@ -146,7 +145,7 @@ def __init__(self, fname, memory_buffer=True, preload=False):
             self.preload()
 
         return
-        
+
     def copy(self):
         """
         Shallow copy - shared cache, copied summary
@@ -269,7 +268,7 @@ def seeds(self):
         Returns: Array of seed values
         """
         return self.index.get_level_values("seed").values
-    
+
     @staticmethod
     def _seedfile(seed):
         """ Helper function to generate a seed file name """
@@ -281,8 +280,7 @@ def new(cls, folder, outputs, identifiers, fname=None):
         Args:
             folder: The folder name
             outputs: A list of tuples (df:pd.DataFrame, summary_row:dict) where the summary row as an entry 'seed' for the seed
-            identifiers: A list of columns to use as identifiers. These should appear in the summary dataframe and should have the same
-                         value for all samples.
+            identifiers: A list of columns to use as identifiers. These should appear in the summary dataframe and should have the same value for all samples.
         """
         zipdata = {} # Store all the data to be written as files inside the zipfile
         summary_rows = []
@@ -309,12 +307,12 @@ def new(cls, folder, outputs, identifiers, fname=None):
         # Finish working on and write the summary
         summary.set_index(identifiers, inplace=True)
         zipdata[summary_file] = summary.to_csv()
-        
+
         # Handle the zip file name
         if fname is None:
             fname = "-".join(str(row[x]) for x in identifiers[1:]) + ".zip"
         folder.mkdir(parents=True, exist_ok=True)
-        
+
         # Save the zip file
         sc.savezip(folder/fname, data=zipdata, tobytes=False)
 
@@ -349,7 +347,7 @@ def items(self):
         """
         for seed in self.seeds:
             yield seed, self.get(seed)
-            
+
     def __getitem__(self, item):
         """
         Overload getitem for convenience
@@ -401,8 +399,8 @@ def apply(self, fcn, *args, **kwargs):
 
         Args:
             fcn: A function to apply. It should take in a dataframe
-            *args: Additional arguments for ``fcn``
-            **kwargs: Additional arguments for ``fcn``
+            args: Additional arguments for ``fcn``
+            kwargs: Additional arguments for ``fcn``
 
         Returns: A list with the output of ``fcn``
         """
diff --git a/starsim/settings.py b/starsim/settings.py
index 1bccd52e..10524123 100644
--- a/starsim/settings.py
+++ b/starsim/settings.py
@@ -4,7 +4,6 @@
 
     ss.options(verbose=False)
 """
-
 import os
 import numpy as np
 import sciris as sc
@@ -12,12 +11,13 @@
 __all__ = ['dtypes', 'options']
 
 # Define Starsim-default data types
-dtypes = sc.objdict(
-    bool = bool,
-    int = np.int64,
-    float = np.float64,
-    result_float = np.float64,
-)
+class dtypes:
+    bool = bool
+    int = np.int64
+    rand_int = np.int32
+    rand_uint = np.uint32
+    float = np.float32
+    result_float = np.float64
 
 
 # Not public to avoid confusion with ss.options
@@ -51,7 +51,7 @@ def __init__(self):
         self.setattribute('optdesc', optdesc)  # Set the description as an attribute, not a dict entry
         self.setattribute('orig_options', sc.dcp(options))  # Copy the default options
         return
-    
+
     @staticmethod
     def get_orig_options():
         """
@@ -69,10 +69,13 @@ def get_orig_options():
         optdesc.warnings = 'How warnings are handled: options are "warn" (default), "print", and "error"'
         options.warnings = str(os.getenv('STARSIM_WARNINGS', 'warn'))
 
+        optdesc.time_eps = 'Set size of smallest possible time unit (in units of sim time, e.g. "year" or "day")'
+        options.time_eps = float(os.getenv('STARSIM_TIME_EPS', 1e-6))
+
         optdesc.sep = 'Set thousands seperator for text output'
         options.sep = str(os.getenv('STARSIM_SEP', ','))
 
-        optdesc.precision = 'Set arithmetic precision -- 32-bit by default for efficiency'
+        optdesc.precision = 'Set arithmetic precision'
         options.precision = int(os.getenv('STARSIM_PRECISION', 64))
 
         optdesc._centralized = 'If True, revert to centralized random number generation (NOT ADVISED).'
@@ -143,7 +146,7 @@ def set(self, key=None, value=None, use=False, **kwargs):
         # Handle other keys
         elif key is not None:
             kwargs = sc.mergedicts(kwargs, {key: value})
-        
+
         # Reset options
         for key, value in kwargs.items():
             if key not in self:
@@ -155,7 +158,7 @@ def set(self, key=None, value=None, use=False, **kwargs):
                 if value in [None, 'default']:
                     value = self.orig_options[key]
                 self[key] = value
-                
+
                 # Handle special cases
                 if key == 'precision':
                     self.reset_precision()
@@ -174,7 +177,7 @@ def context(self, **kwargs):
 
             # Convert warnings to errors
             with ss.options.context(warnings='error'):
-                ss.Sim(location='not a location').initialize()
+                ss.Sim(location='not a location').init()
 
             # Use with_style(), not context(), for plotting options
             with ss.options.with_style(dpi=50):
@@ -199,7 +202,7 @@ def changed(self, key):
             return self[key] != self.orig_options[key]
         else:
             return None
-    
+
     def set_precision(self):
         if self.precision == 32:
             dtypes.int = np.int32
diff --git a/starsim/sim.py b/starsim/sim.py
index 1b82a3c1..260ae6c0 100644
--- a/starsim/sim.py
+++ b/starsim/sim.py
@@ -1,54 +1,84 @@
 """
 Define core Sim classes
 """
-
-# Imports
 import itertools
 import numpy as np
 import sciris as sc
 import starsim as ss
-import matplotlib.pyplot as pl
+import matplotlib.pyplot as plt
 
 __all__ = ['Sim', 'AlreadyRunError', 'demo', 'diff_sims', 'check_sims_match']
 
 
 class Sim:
+    """
+    The Sim object
 
-    def __init__(self, pars=None, label=None, people=None, demographics=None, diseases=None, networks=None,
-                 interventions=None, analyzers=None, connectors=None, copy_inputs=True, **kwargs):
+    All Starsim simulations run via the Sim class. It is responsible for initializing
+    and running all modules and generating results.
+
+    Args:
+        pars (SimPars/dict): either an ss.SimPars object, or a nested dictionary; can include all other arguments
+        label (str): the human-readable name of the simulation
+        people (People): if provided, use this ss.People object
+        demographics (str/Demographics/list): a string naming the demographics module to use, the module itself, or a list
+        diseases (str/Disease/list): as above, for diseases
+        networks (str/Network/list): as above, for networks
+        interventions (str/Intervention/list): as above, for interventions
+        analyzers (str/Analyzer/list): as above, for analyzers
+        connectors (str/Connector/list): as above, for connectors
+        copy_inputs (bool): if True, copy modules as they're inserted into the sim (allowing reuse in other sims, but meaning they won't be updated)
+        data (df): a dataframe (or dict) of data, with a column "time" plus data of the form "module.result", e.g. "hiv.new_infections" (used for plotting only)
+        kwargs (dict): merged with pars
+
+    **Examples**::
 
-        # 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, 
+        sim = ss.Sim(diseases='sir', networks='random') # Simplest Starsim sim; equivalent to ss.demo()
+        sim = ss.Sim(diseases=ss.SIR(), networks=ss.RandomNet()) # Equivalent using objects instead of strings
+        sim = ss.Sim(diseases=['sir', ss.SIS()], networks=['random', 'mf']) # Example using list inputs; can mix and match types
+    """
+    def __init__(self, pars=None, label=None, people=None, demographics=None, diseases=None, networks=None,
+                 interventions=None, analyzers=None, connectors=None, copy_inputs=True, data=None, **kwargs):
+        self.pars = ss.make_pars() # Make default parameters (using values from parameters.py)
+        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
+        input_pars = sc.mergedicts(pars, args, kwargs, _copy=copy_inputs)
+        self.pars.update(input_pars)  # Update the parameters
+
+        # Set attributes; see also sim.init() for more
         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.version = ss.__version__ # The Starsim version
+        self.metadata = sc.metadata(version=self.version, pipfreeze=False)
         self.dists = ss.Dists(obj=self) # Initialize the random number generator container
+        self.loop = ss.Loop(self) # Initialize the integration loop
         self.results = ss.Results(module='sim')  # For storing results
+        self.data = data # For storing input data
+        self.initialized = False  # Whether initialization is complete
+        self.complete = False  # Whether a simulation has completed running
+        self.results_ready = False  # Whether results are ready
+        self.elapsed = None # The time required to run
         self.summary = None  # For storing a summary of the results
         self.filename = None # Store the filename, if saved
         return
-    
+
     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 __repr__(self):
         """ Show a quick version of the sim """
         # Try a more custom repr first
         try:
+            labelstr = f'{self.label}; ' if self.label else ''
             n = int(self.pars.n_agents)
+            timestr = f'{self.pars.start}—{self.pars.stop}'
+
             moddict = {}
             for modkey in ss.module_map().keys():
                 if hasattr(self, modkey):
@@ -57,8 +87,12 @@ def __repr__(self):
                     thismodtype = self.pars[modkey]
                 else:
                     thismodtype = {}
-                if sc.isiterable(thismodtype) and len(thismodtype):
+
+                if isinstance(thismodtype, dict) and len(thismodtype):
                     moddict[modkey] = sc.strjoin(thismodtype.keys())
+                elif isinstance(thismodtype, str):
+                    moddict[modkey] = thismodtype
+
             if len(moddict):
                 modulestr = ''
                 for k,mstr in moddict.items():
@@ -67,67 +101,84 @@ def __repr__(self):
                 modulestr = ''
             if not self.initialized:
                 modulestr += '; not initialized'
-            string = f'Sim(n={n:n}{modulestr})'
-        
+
+            string = f'Sim({labelstr}n={n:n}; {timestr}{modulestr})'
+
         # Or just use default
         except Exception as E:
             ss.warn(f'Error displaying custom sim repr, falling back to default: {E}')
             string = sc.prepr(self, vals=False)
-            
+
         return string
-    
-    def initialize(self, **kwargs):
+
+    @property
+    def now(self):
+        """ Return the current time, i.e. the time vector at the current timestep """
+        try:
+            ti = min(self.ti, len(self.timevec)-1) # During integration, ti can go one past the end of the time vector
+            return self.timevec[ti]
+        except Exception as E:
+            ss.warn(f'Encountered exception in sim when getting the current time: {E}')
+            return None
+
+    @property
+    def modules(self):
+        """ Return iterator over all Module instances (stored in standard places) in the Sim """
+        return itertools.chain(
+            self.demographics(),
+            self.networks(),
+            self.diseases(),
+            self.connectors(),
+            self.interventions(),
+            [intv.product for intv in self.interventions() if hasattr(intv, 'product') and intv.product is not None], # TODO: simplify
+            self.analyzers(),
+        )
+
+    def init(self, **kwargs):
         """ Perform all initializations for the sim; most heavy lifting is done by the parameters """
+
         # Validation and initialization
         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)
-        
+        self.pars.validate() # Validate parameters
+        self.init_time_attrs() # Initialize time
+        self.init_people(**kwargs) # Initialize the people
+
         # 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.init_pre(self)
 
-        # Initialize products # TODO: think about simplifying
-        for mod in self.interventions:
-            if hasattr(mod, 'product') and isinstance(mod.product, ss.Product):
-                mod.product.init_pre(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()
+        # Initialize products # TODO: think about moving with other modules
+        for intv in self.interventions():
+            if hasattr(intv, 'product'): # TODO: simplify
+                intv.product.init_pre(self)
+
+        # Final initializations
+        self.init_dists() # Initialize distributions
+        self.init_vals() # Initialize the values in all of the states and networks
+        self.init_results() # Initialize the results
+        self.init_data() # Initialize the data
+        self.loop.init() # Initialize the integration loop
+        self.timer = sc.timer() # Store a timer for keeping track of how long the run takes
+        self.verbose = self.pars.verbose # Store a run-specific value of verbose
 
         # It's initialized
         self.initialized = True
         return self
-    
+
     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
+        pars = self.pars
+        self.timevec = ss.make_timevec(pars.start, pars.stop, pars.dt, pars.unit)
+        self.results.timevec = self.timevec # Store the timevec in the results for plotting
+        self.npts = len(self.timevec) # The number of points in the sim
+        self.abs_tvec = np.arange(self.npts)*pars.dt # Absolute time array
         self.ti = 0  # The time index, e.g. 0, 1, 2
+        self.dt_year = ss.time_ratio(pars.unit, pars.dt, 'year', 1.0) # Figure out what dt is in years; used for demographics # TODO: handle None
         return
 
     def init_people(self, verbose=None, **kwargs):
@@ -135,7 +186,7 @@ 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:
             verbose (int):  detail to print
             kwargs  (dict): passed to ss.make_people()
@@ -156,166 +207,123 @@ def init_people(self, verbose=None, **kwargs):
         self.people = people
         self.people.link_sim(self)
         return self.people
-    
+
+    def init_dists(self):
+        """ Initialize the distributions """
+        # Initialize all distributions now that everything else is in place
+        self.dists.init(obj=self, base_seed=self.pars.rand_seed, force=True)
+
+        # Copy relevant dists to each module
+        for mod in self.modules:
+            self.dists.copy_to_module(mod)
+        return
+
     def init_vals(self):
         """ Initialize the states and other objects with values """
-        
+
         # Initialize values in people
         self.people.init_vals()
-        
-        # Initialize values in other modules, including networks
+
+        # Initialize values in other modules, including networks and time parameters
         for mod in self.modules:
             mod.init_post()
         return
-    
+
+    def reset_time_pars(self, force=True):
+        """ Reset the time parameters in the modules; used for imposing the sim's timestep on the modules """
+        for mod in self.modules:
+            mod.init_time_pars(force=force)
+        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, label='Number alive'),
-            ss.Result(None, 'new_deaths', self.npts, ss.dtypes.int, scale=True, label='Deaths'),
-            ss.Result(None, 'cum_deaths', self.npts, ss.dtypes.int, scale=True, label='Cumulative deaths'),
+        kw = dict(shape=self.npts, timevec=self.timevec, dtype=int, scale=True)
+        self.results += [
+            ss.Result('n_alive',    label='Number alive', **kw),
+            ss.Result('new_deaths', label='Deaths', **kw),
+            ss.Result('cum_deaths', label='Cumulative deaths', **kw),
         ]
         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 init_data(self, data=None):
+        """ Initialize or add data to the sim """
+        data = data if data is not None else self.data
+        self.data = ss.validate_sim_data(data)
+        return
 
-    def step(self):
-        """ Step through time and update values """
+    def start_step(self):
+        """ Start the step -- only print progress; all actual changes happen in the modules """
 
         # Set the time and if we have reached the end of the simulation, then do nothing
         if self.complete:
-            raise AlreadyRunError('Simulation already complete (call sim.initialize() to re-run)')
-
-        # Advance random number generators forward to prepare for any random number calls that may be necessary on this step
-        self.dists.jump(to=1000*(self.ti+1)) # *1000 to fix repeated draws for dists that auto jumped
-
-        # Update demographic modules (create new agents from births/immigration, schedule non-disease deaths and emigration)
-        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():
-            disease.update_pre()
-
-        # Update connectors -- TBC where this appears in the ordering
-        for connector in self.connectors():
-            connector.update()
-
-        # Update networks - this takes place here in case autonomous state changes at this timestep affect eligibility for contacts
-        for network in self.networks():
-            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():
-            intervention(self)
-
-        # Carry out transmission/new cases
-        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():
-            disease.update_death(uids)
-
-        # Update results
-        self.people.update_results()
-
-        for dem_mod in self.demographics():
-            dem_mod.update_results()
-
-        for disease in self.diseases():
-            disease.update_results()
+            errormsg = 'Simulation already complete (call sim.init() to re-run)'
+            raise AlreadyRunError(errormsg)
 
-        for analyzer in self.analyzers():
-            analyzer(self)
-            
-        # Clean up dead agents
-        self.people.remove_dead()
+        # Print out progress if needed
+        self.elapsed = self.timer.toc(output=True)
+        if self.verbose: # Print progress
+            simlabel = f'"{self.label}": ' if self.label else ''
+            timepoint = self.timevec[self.ti]
+            timelabel = f'{timepoint:0.1f}' if isinstance(timepoint, float) else str(timepoint) # TODO: fix
+            string = f'  Running {simlabel}{timelabel} ({self.ti:2.0f}/{self.npts}) ({self.elapsed:0.2f} s) '
+            if self.verbose >= 2:
+                sc.heading(string)
+            elif self.verbose > 0:
+                if not (self.ti % int(1.0 / self.verbose)):
+                    sc.progressbar(self.ti + 1, self.npts, label=string, length=20, newline=True)
+        return
 
-        # Tidy up
+    def finish_step(self):
+        """ Finish the simulation timestep """
         self.ti += 1
-        self.people.ti = self.ti
-        self.people.update_post()
+        return
 
-        if self.ti == self.npts:
-            self.complete = True
+    def run_one_step(self, verbose=None):
+        """
+        Run a single sim step; only used for debugging purposes.
 
-        return
+        Note: sim.run_one_step() runs a single simulation timestep, which involves
+        multiple function calls. In contrast, loop.run_one_step() runs a single
+        function call.
+
+        Note: the verbose here is only for the Loop object, not the sim.
+        """
+        self.loop.run(self.now, verbose)
+        return self
 
     def run(self, until=None, verbose=None):
         """ Run the model once """
 
         # Initialization steps
-        T = sc.timer()
-        if not self.initialized:
-            self.initialize()
-            self._orig_pars = sc.dcp(self.pars)  # Create a copy of the parameters to restore after the run
-
-        if verbose is None:
-            verbose = self.pars.verbose
+        if not self.initialized: self.init()
+        self.verbose = sc.ifelse(verbose, self.pars.verbose)
+        self.timer.start()
 
         # Check for AlreadyRun errors
         errormsg = None
-        if until is None: until = self.npts
-        if until > self.npts:
-            errormsg = f'Requested to run until t={until} but the simulation end is ti={self.npts}'
-        if self.ti >= until:  # NB. At the start, self.t is None so this check must occur after initialization
-            errormsg = f'Simulation is currently at t={self.ti}, requested to run until ti={until} which has already been reached'
         if self.complete:
-            errormsg = 'Simulation is already complete (call sim.initialize() to re-run)'
+            errormsg = 'Simulation is already complete (call sim.init() to re-run)'
         if errormsg:
             raise AlreadyRunError(errormsg)
 
-        # Main simulation loop
-        while self.ti < until:
-
-            # Check if we were asked to stop
-            elapsed = T.toc(output=True)
-
-            # Print progress
-            if verbose:
-                simlabel = f'"{self.label}": ' if self.label else ''
-                string = f'  Running {simlabel}{self.yearvec[self.ti]:0.1f} ({self.ti:2.0f}/{self.npts}) ({elapsed:0.2f} s) '
-                if verbose >= 2:
-                    sc.heading(string)
-                elif verbose > 0:
-                    if not (self.ti % int(1.0 / verbose)):
-                        sc.progressbar(self.ti + 1, self.npts, label=string, length=20, newline=True)
+        # Main simulation loop -- just one line!!!
+        self.loop.run(until)
 
-            # Actually run the model
-            self.step()
+        # Check if the simulation is complete
+        if self.loop.index == len(self.loop.plan):
+            self.complete = True
 
         # If simulation reached the end, finalize the results
         if self.complete:
-            self.finalize(verbose=verbose)
-            sc.printv(f'Run finished after {elapsed:0.2f} s.\n', 1, verbose)
-
-        return self
-
-    def finalize(self, verbose=None):
+            self.ti -= 1  # During the run, this keeps track of the next step; restore this be the final day of the sim
+            for mod in self.modules: # May not be needed, but keeps it consistent with the sim
+                mod.ti -= 1
+            self.finalize()
+            sc.printv(f'Run finished after {self.elapsed:0.2f} s.\n', 1, self.verbose)
+        return self # Allows e.g. ss.Sim().run().plot()
+
+    def finalize(self):
         """ Compute final results """
-
         if self.results_ready:
             # Because the results are rescaled in-place, finalizing the sim cannot be run more than once or
             # otherwise the scale factor will be applied multiple times
@@ -323,31 +331,30 @@ def finalize(self, verbose=None):
 
         # Scale the results
         for reskey, res in self.results.items():
-            if isinstance(res, ss.Result) and res.scale:
+            if isinstance(res, ss.Result) and res.scale: # NB: disease-specific results are scaled in module.finalize() below
                 self.results[reskey] = self.results[reskey] * self.pars.pop_scale
+        self.results_ready = True # Results are ready to use
 
+        # Finalize each module
         for module in self.modules:
             module.finalize()
 
-        self.summarize()
-        self.results_ready = True  # Set this first so self.summary() knows to print the results
-        self.ti -= 1  # During the run, this keeps track of the next step; restore this be the final day of the sim
+        # Generate the summary and finish up
+        self.summarize() # Create summary
         return
 
     def summarize(self, how='default'):
         """
-        Provide a quick summary of the sim
-        
+        Provide a quick summary of the sim; returns the last entry for count and
+        cumulative results, and the mean otherwise.
+
         Args:
             how (str): how to summarize: can be 'mean', 'median', 'last', or a mapping of result keys to those
-        
-        Returns the last entry for count and cumulative results, and the mean otherwise
         """
-        
         def get_func(key, how, default='mean'):
             """
             Find the right function by matching the "how" key with the result key
-            
+
             For example, hkey="cum_ " will match result key "cum_infections"
             """
             func = None
@@ -358,7 +365,7 @@ def get_func(key, how, default='mean'):
             if func is None:
                 func = default
             return func
-            
+
         def get_result(res, func):
             """ Convert a string to the actual function to use, e.g. "median" maps to np.median() """
             if   func == 'mean':   return res.mean()
@@ -366,30 +373,31 @@ def get_result(res, func):
             elif func == 'last':   return res[-1]
             elif callable(func):   return func(res)
             else: raise Exception(f'"{func}" is not a valid function')
-        
+
         # Convert "how" from a string to a dict
         if how == 'default':
             how = {'n_':'mean', 'new_':'mean', 'cum_':'last', '':'mean'}
         elif isinstance(how, str):
             how = {'':how} # Match everything
-        
+
         summary = sc.objdict()
         flat = sc.flattendict(self.results, sep='_')
         for key, res in flat.items():
-            try:
-                func = get_func(key, how)
-                entry = get_result(res, func)
-            except Exception as E:
-                entry = f'N/A {E}'
-            summary[key] = entry
+            if '_timevec' not in key: # Skip module-specific time vectors
+                try:
+                    func = get_func(key, how)
+                    entry = get_result(res, func)
+                except Exception as E:
+                    entry = f'N/A {E}'
+                summary[key] = entry
         self.summary = summary
         return summary
-    
+
     def disp(self):
         """ Print a full version of the sim """
         sc.pr(self)
         return
-    
+
     def shrink(self, skip_attrs=None, in_place=True):
         """
         "Shrinks" the simulation by removing the people and other memory-intensive
@@ -404,9 +412,9 @@ def shrink(self, skip_attrs=None, in_place=True):
         Returns:
             shrunken (Sim): a Sim object with the listed attributes removed
         """
-        # By default, skip people (~90% of memory), popdict, and _orig_pars (which is just a backup)
+        # By default, skip people
         if skip_attrs is None:
-            skip_attrs = ['people']
+            skip_attrs = ['people'] # TODO: think about skipping all states in all modules
 
         # Create the new object, and copy original dict, skipping the skipped attributes
         if in_place:
@@ -423,144 +431,14 @@ def shrink(self, skip_attrs=None, in_place=True):
         else:
             return shrunken
 
-    def _get_ia(self, which, label=None, partial=False, as_list=False, as_inds=False, die=True, first=False):
-        """ Helper method for get_interventions() and get_analyzers(); see get_interventions() docstring """
-
-        # Handle inputs
-        if which not in ['interventions', 'analyzers']:  # pragma: no cover
-            errormsg = f'This method is only defined for interventions and analyzers, not "{which}"'
-            raise ValueError(errormsg)
-
-        ia_ndict = self.analyzers if which == 'analyzers' else self.interventions  # List of interventions or analyzers
-        n_ia = len(ia_ndict)  # Number of interventions/analyzers
-
-        position = 0 if first else -1  # Choose either the first or last element
-        if label is None:  # Get all interventions if no label is supplied, e.g. sim.get_interventions()
-            label = np.arange(n_ia)
-        if isinstance(label, np.ndarray):  # Allow arrays to be provided
-            label = label.tolist()
-        labels = sc.promotetolist(label)
-
-        # Calculate the matches
-        matches = []
-        match_inds = []
-
-        for label in labels:
-            if sc.isnumber(label):
-                matches.append(ia_ndict[label])
-                label = n_ia + label if label < 0 else label  # Convert to a positive number
-                match_inds.append(label)
-            elif sc.isstring(label) or isinstance(label, type):
-                for ind, ia_key, ia_obj in ia_ndict.enumitems():
-                    if sc.isstring(label) and ia_obj.label == label or (partial and (label in str(ia_obj.label))):
-                        matches.append(ia_obj)
-                        match_inds.append(ind)
-                    elif isinstance(label, type) and isinstance(ia_obj, label):
-                        matches.append(ia_obj)
-                        match_inds.append(ind)
-            else:  # pragma: no cover
-                errormsg = f'Could not interpret label type "{type(label)}": should be str, int, list, or {which} class'
-                raise TypeError(errormsg)
-
-        # Parse the output options
-        if as_inds:
-            output = match_inds
-        elif as_list:  # Used by get_interventions()
-            output = matches
-        else:
-            if len(matches) == 0:  # pragma: no cover
-                if die:
-                    errormsg = f'No {which} matching "{label}" were found'
-                    raise ValueError(errormsg)
-                else:
-                    output = None
-            else:
-                output = matches[
-                    position]  # Return either the first or last match (usually), used by get_intervention()
-
-        return output
-
-    def get_interventions(self, label=None, partial=False, as_inds=False):
-        """
-        Find the matching intervention(s) by label, index, or type. If None, return
-        all interventions. If the label provided is "summary", then print a summary
-        of the interventions (index, label, type).
-
-        Args:
-            label (str, int, Intervention, list): the label, index, or type of intervention to get; if a list, iterate over one of those types
-            partial (bool): if true, return partial matches (e.g. 'beta' will match all beta interventions)
-            as_inds (bool): if true, return matching indices instead of the actual interventions
-        """
-        return self._get_ia('interventions', label=label, partial=partial, as_inds=as_inds, as_list=True)
-
-    def get_intervention(self, label=None, partial=False, first=False, die=True):
-        """
-        Find the matching intervention(s) by label, index, or type.
-        If more than one intervention matches, return the last by default.
-        If no label is provided, return the last intervention in the list.
-
-        Args:
-            label (str, int, Intervention, list): the label, index, or type of intervention to get; if a list, iterate over one of those types
-            partial (bool): if true, return partial matches
-            first (bool): if true, return first matching intervention (otherwise, return last)
-            die (bool): whether to raise an exception if no intervention is found
-        """
-        return self._get_ia('interventions', label=label, partial=partial, first=first, die=die, as_inds=False,
-                            as_list=False)
-
-    def get_analyzers(self, label=None, partial=False, as_inds=False):
-        """
-        Find the matching analyzer(s) by label, index, or type. If None, return
-        all analyzers. If the label provided is "summary", then print a summary
-        of the analyzers (index, label, type).
-
-        Args:
-            label (str, int, Analyzer, list): the label, index, or type of analyzer to get; if a list, iterate over one of those types
-            partial (bool): if true, return partial matches (e.g. 'beta' will match all beta analyzers)
-            as_inds (bool): if true, return matching indices instead of the actual analyzers
-        """
-        return self._get_ia('analyzers', label=label, partial=partial, as_inds=as_inds, as_list=True)
-
-    def get_analyzer(self, label=None, partial=False, first=False, die=True):
-        """
-        Find the matching analyzer(s) by label, index, or type.
-        If more than one analyzer matches, return the last by default.
-        If no label is provided, return the last analyzer in the list.
-
-        Args:
-            label (str, int, Analyzer, list): the label, index, or type of analyzer to get; if a list, iterate over one of those types
-            partial (bool): if true, return partial matches
-            first (bool): if true, return first matching analyzer (otherwise, return last)
-            die (bool): whether to raise an exception if no analyzer is found
-        """
-        return self._get_ia('analyzers', label=label, partial=partial, first=first, die=die, as_inds=False,
-                            as_list=False)
-
-    def export_df(self):
-        """
-        Export results as a Pandas dataframe
-
-        :return:
-
-        """
-
+    def to_df(self, sep='_'):
+        """ Export results as a Pandas dataframe """
         if not self.results_ready:  # pragma: no cover
             errormsg = 'Please run the sim before exporting the results'
             raise RuntimeError(errormsg)
 
-        def flatten_results(d, prefix=''):
-            flat = {}
-            for key, val in d.items():
-                if isinstance(val, dict):
-                    flat.update(flatten_results(val, prefix=prefix+key+'.'))
-                else:
-                    flat[prefix+key] = val
-            return flat
-
-        resdict = flatten_results(self.results)
-        resdict['t'] = self.yearvec
-
-        df = sc.dataframe.from_dict(resdict).set_index('t')
+        flat = self.results.flatten(sep=sep, only_results=False)
+        df = sc.dataframe.from_dict(flat)
         return df
 
     def save(self, filename=None, keep_people=None, skip_attrs=None, **kwargs):
@@ -600,130 +478,76 @@ def save(self, filename=None, keep_people=None, skip_attrs=None, **kwargs):
         else:
             obj = self
         sc.save(filename=filename, obj=obj)
-
         return filename
 
     @staticmethod
     def load(filename, *args, **kwargs):
-        """ Load from disk from a gzipped pickle.  """
-
+        """ Load from disk from a gzipped pickle """
         sim = sc.load(filename, *args, **kwargs)
         if not isinstance(sim, Sim):  # pragma: no cover
             errormsg = f'Cannot load object of {type(sim)} as a Sim object'
             raise TypeError(errormsg)
         return sim
 
-    def export_pars(self, filename=None, indent=2, *args, **kwargs):
-        '''
-        Return parameters for JSON export -- see also to_json().
-
-        This method is required so that interventions can specify
-        their JSON-friendly representation.
-
-        Args:
-            filename (str): filename to save to; if None, do not save
-            indent (int): indent (int): if writing to file, how many indents to use per nested level
-            args (list): passed to savejson()
-            kwargs (dict): passed to savejson()
-
-        Returns:
-            pardict (dict): a dictionary containing all the parameter values
-        '''
-        pardict = {}
-        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
-
-    def to_json(self, filename=None, keys=None, tostring=False, indent=2, verbose=False, *args, **kwargs):
-        '''
+    def to_json(self, filename=None, keys=None, tostring=False, indent=2, verbose=False, **kwargs):
+        """
         Export results and parameters as JSON.
 
         Args:
             filename (str): if None, return string; else, write to file
-            keys (str or list): attributes to write to json (default: results, parameters, and summary)
-            tostring (bool): if not writing to file, whether to write to string (alternative is sanitized dictionary)
-            indent (int): if writing to file, how many indents to use per nested level
+            keys (str/list): attributes to write to json (choices: 'pars' and/or 'summary')
             verbose (bool): detail to print
-            args (list): passed to savejson()
-            kwargs (dict): passed to savejson()
+            kwargs (dict): passed to sc.jsonify()
 
         Returns:
-            A unicode string containing a JSON representation of the results,
-            or writes the JSON file to disk
+            A dictionary representation of the parameters and/or summary results
+            (or write that dictionary to a file)
 
         **Examples**::
 
             json = sim.to_json()
             sim.to_json('results.json')
             sim.to_json('summary.json', keys='summary')
-        '''
-
+        """
         # Handle keys
         if keys is None:
-            keys = ['results', 'pars', 'summary', 'short_summary']
+            keys = ['pars', 'summary']
         keys = sc.promotetolist(keys)
 
         # Convert to JSON-compatible format
-        d = {}
+        d = sc.objdict()
         for key in keys:
-            if key == 'results':
-                if self.results_ready:
-                    resdict = self.export_results(for_json=True)
-                    d['results'] = resdict
-                else:
-                    d['results'] = 'Results not available (Sim has not yet been run)'
-            elif key in ['pars', 'parameters']:
-                pardict = self.export_pars()
-                d['parameters'] = pardict
+            if key in ['pars', 'parameters']:
+                pardict = self.pars.to_json()
+                d.pars = pardict
             elif key == 'summary':
                 if self.results_ready:
-                    d['summary'] = dict(sc.dcp(self.summary))
+                    d.summary = dict(sc.dcp(self.summary))
                 else:
-                    d['summary'] = 'Summary not available (Sim has not yet been run)'
-            elif key == 'short_summary':
-                if self.results_ready:
-                    d['short_summary'] = dict(sc.dcp(self.short_summary))
-                else:
-                    d['short_summary'] = 'Full summary not available (Sim has not yet been run)'
+                    d.summary = 'Summary not available (Sim has not yet been run)'
             else:  # pragma: no cover
-                try:
-                    d[key] = sc.sanitizejson(getattr(self, key))
-                except Exception as E:
-                    errormsg = f'Could not convert "{key}" to JSON: {str(E)}; continuing...'
-                    print(errormsg)
+                errormsg = f'Could not convert "{key}" to JSON; continuing...'
+                print(errormsg)
 
-        if filename is None:
-            output = sc.jsonify(d, tostring=tostring, indent=indent, verbose=verbose, *args, **kwargs)
-        else:
-            output = sc.savejson(filename=filename, obj=d, indent=indent, *args, **kwargs)
+        # Final conversion
+        if filename is not None:
+            sc.savejson(filename=filename, obj=d, **kwargs)
+        d = sc.jsonify(d)
+        return d
 
-        return output
-    
-    def plot(self, key=None, fig=None, style='fancy', fig_kw=None, plot_kw=None):
-        """ 
+    def plot(self, key=None, fig=None, style='fancy', show_data=True, fig_kw=None, plot_kw=None, scatter_kw=None):
+        """
         Plot all results in the Sim object
-        
+
         Args:
-            key (str): the results key to plot (by default, all)
+            key (str/list): the results key to plot (by default, all); if a list, plot exactly those keys
             fig (Figure): if provided, plot results into an existing figure
             style (str): the plotting style to use (default "fancy"; other options are "simple", None, or any Matplotlib style)
+            show_data (bool): plot the data, if available
             fig_kw (dict): passed to ``plt.subplots()``
             plot_kw (dict): passed to ``plt.plot()``
-        
+            scatter_kw (dict): passed to ``plt.scatter()``, for plotting the data
         """
-        
         # Configuration
         flat = self.results.flatten()
         n_cols = np.ceil(np.sqrt(len(flat))) # Number of columns of axes
@@ -732,15 +556,17 @@ def plot(self, key=None, fig=None, style='fancy', fig_kw=None, plot_kw=None):
         figsize = default_figsize*figsize_factor
         fig_kw = sc.mergedicts({'figsize':figsize}, fig_kw)
         plot_kw = sc.mergedicts({'lw':2}, plot_kw)
-        modmap = {m.name:m for m in self.modules} # Find modules
-        
+        scatter_kw = sc.mergedicts({'alpha':0.3, 'color':'k'}, scatter_kw)
+
         # Do the plotting
         with sc.options.with_style(style):
-            
-            yearvec = flat.pop('yearvec')
+
             if key is not None:
-                flat = {k:v for k,v in flat.items() if k.startswith(key)}
-            
+                if isinstance(key, str):
+                    flat = {k:v for k,v in flat.items() if (key in k)}
+                else:
+                    flat = {k:flat[k] for k in key}
+
             # Get the figure
             if fig is None:
                 fig, axs = sc.getrowscols(len(flat), make=True, **fig_kw)
@@ -750,51 +576,52 @@ def plot(self, key=None, fig=None, style='fancy', fig_kw=None, plot_kw=None):
                 axs = fig.axes
             if not sc.isiterable(axs):
                 axs = [axs]
-                
+
             # Do the plotting
+            df = self.data if show_data else None # For plotting the data
             for ax, (key, res) in zip(axs, flat.items()):
-                ax.plot(yearvec, res, **plot_kw, label=self.label)
-                title = getattr(res, 'label', key)
-                if res.module != 'sim':
-                    try:
-                        mod = modmap[res.module]
-                        modtitle = mod.__class__.__name__
-                        assert res.module == modtitle.lower() # Only use the class name if the module name is the default
-                    except:
-                        modtitle = res.module
-                    title = f'{modtitle}: {title}'
-                ax.set_title(title) 
-                ax.set_xlabel('Year')
-            
+
+                # Plot data
+                if df is not None:
+                    mod = res.module
+                    name = res.name
+                    found = False
+                    for dfkey in [f'{mod}.{name}', f'{mod}_{name}']: # Allow dot or underscore
+                        if dfkey in df.cols:
+                            found = True
+                            break
+                    if found:
+                        ax.scatter(df.index.values, df[dfkey].values, **scatter_kw)
+
+                # Plot results
+                ax.plot(res.timevec, res.values, **plot_kw, label=self.label)
+                ax.set_title(res.full_label)
+                ax.set_xlabel('Time')
+
         sc.figlayout(fig=fig)
-                
+
         return fig
 
 
 class AlreadyRunError(RuntimeError):
-    """
-    This error is raised if a simulation is run in such a way that no timesteps
-    will be taken. This error is a distinct type so that it can be safely caught
-    and ignored if required, but it is anticipated that most of the time, calling
-    :py:func:`Sim.run` and not taking any timesteps, would be an inadvertent error.
-    """
+    """ Raised if trying to re-run an already-run sim without re-initializing """
     pass
 
 
 def demo(run=True, plot=True, summary=True, show=True, **kwargs):
     """
     Create a simple demo simulation for Starsim
-    
+
     Defaults to using the SIR model with a random network, but these can be configured.
-    
+
     Args:
         run (bool): whether to run the sim
         plot (bool): whether to plot the results
         summary (bool): whether to print a summary of the results
         kwargs (dict): passed to ``ss.Sim()``
-    
+
     **Examples**::
-        
+
         ss.demo() # Run, plot, and show results
         ss.demo(diseases='hiv', networks='mf') # Run with different defaults
     """
@@ -809,11 +636,11 @@ def demo(run=True, plot=True, summary=True, show=True, **kwargs):
             if plot:
                 sim.plot()
                 if show:
-                    pl.show()
+                    plt.show()
     return sim
 
 
-def diff_sims(sim1, sim2, multi=False, skip_key_diffs=False, skip=None, full=False, output=False, die=False):
+def diff_sims(sim1, sim2, skip_key_diffs=False, skip=None, full=False, output=False, die=False):
     '''
     Compute the difference of the summaries of two simulations, and print any
     values which differ.
@@ -834,7 +661,7 @@ def diff_sims(sim1, sim2, multi=False, skip_key_diffs=False, skip=None, full=Fal
         s2 = ss.Sim(rand_seed=2).run()
         ss.diff_sims(s1, s2)
     '''
-    
+
     # Convert to dict
     if isinstance(sim1, (Sim, ss.MultiSim)):
         sim1 = sim1.summarize()
@@ -844,8 +671,8 @@ def diff_sims(sim1, sim2, multi=False, skip_key_diffs=False, skip=None, full=Fal
         if not isinstance(sim, dict):  # pragma: no cover
             errormsg = f'Cannot compare object of type {type(sim)}, must be a sim or a sim.summary dict'
             raise TypeError(errormsg)
-    
-    
+
+
     # Check if it's a multisim
     sim1 = sc.objdict(sim1)
     sim2 = sc.objdict(sim2)
@@ -863,7 +690,7 @@ def diff_sims(sim1, sim2, multi=False, skip_key_diffs=False, skip=None, full=Fal
         missing = list(sim1_keys - sim2_keys)
         extra = list(sim2_keys - sim1_keys)
         if missing:
-            keymatchmsg += f'  Missing sim1 keys: {missing}\ns'
+            keymatchmsg += f'  Missing sim1 keys: {missing}\n'
         if extra:
             keymatchmsg += f'  Extra sim2 keys: {extra}\n'
 
@@ -888,6 +715,7 @@ def diff_sims(sim1, sim2, multi=False, skip_key_diffs=False, skip=None, full=Fal
             if mm or full:
                 mismatches[key] = d
 
+    df = sc.dataframe() # Preallocate in case there were no mismatches
     if len(mismatches):
         valmatchmsg = '\nThe following values differ between the two simulations:\n' if not full else ''
         df = sc.dataframe.from_dict(mismatches).transpose()
@@ -904,7 +732,7 @@ def diff_sims(sim1, sim2, multi=False, skip_key_diffs=False, skip=None, full=Fal
                 new_sem = d.sim1_sem
                 sem = old_sem + new_sem
                 small_change = 1.96 # 95% CI, roughly speaking
-                
+
             numeric = sc.isnumber(old) and sc.isnumber(new) # Should all be numeric, but just in case
             if numeric and old > 0:
                 this_diff = new - old
@@ -915,35 +743,27 @@ def diff_sims(sim1, sim2, multi=False, skip_key_diffs=False, skip=None, full=Fal
                 else:
                     abs_ratio = max(this_ratio, 1.0/this_ratio)
                     this_zscore = np.nan
-                
+
                 # Set the character to use
-                if abs_ratio < small_change:
-                    change_char = '≈'
-                elif new > old:
-                    change_char = '↑'
-                elif new < old:
-                    change_char = '↓'
-                elif new == old:
-                    change_char = '='
+                approx_eq = abs_ratio < small_change
+                if approx_eq:    change_char = '≈'
+                elif new > old:  change_char = '↑'
+                elif new < old:  change_char = '↓'
+                elif new == old: change_char = '='
                 else:
                     errormsg = f'Could not determine relationship between sim1={old} and sim2={new}'
                     raise ValueError(errormsg)
 
                 # Set how many repeats it should have
                 repeats = 1
-                if abs_ratio == 0:
-                    repeats = 0
-                if abs_ratio >= 1.1:
-                    repeats = 2
-                if abs_ratio >= 2:
-                    repeats = 3
-                if abs_ratio >= 10:
-                    repeats = 4
-
+                if abs_ratio == 0:   repeats = 0
+                if abs_ratio >= 1.1: repeats = 2
+                if abs_ratio >= 2:   repeats = 3
+                if abs_ratio >= 10:  repeats = 4
                 this_change = change_char * repeats
             else:  # pragma: no cover
-                this_diff = np.nan
-                this_ratio = np.nan
+                this_diff =  np.nan
+                this_ratio  = np.nan
                 this_change = 'N/A'
                 this_zscore = np.nan
 
@@ -982,14 +802,30 @@ def diff_sims(sim1, sim2, multi=False, skip_key_diffs=False, skip=None, full=Fal
 
 
 def check_sims_match(*args, full=False):
-    """ Shortcut to using ss.diff_sims() to check if multiple sims match """
-    s1 = args[0]
+    """
+    Shortcut to using ss.diff_sims() to check if multiple sims match
+
+    Args:
+        args (list): a list of 2 or more sims to compare
+        full (bool): if True, return whether each sim matches the first
+
+    **Example**::
+
+        s1 = ss.Sim(diseases='sir', networks='random')
+        s2 = ss.Sim(pars=dict(diseases='sir', networks='random'))
+        s3 = ss.Sim(diseases=ss.SIR(), networks=ss.RandomNet())
+        assert ss.check_sims_match(s1, s2, s3)
+    """
+    if len(args) < 2:
+        errormsg = 'Must compare at least 2 sims'
+        raise ValueError(errormsg)
+    base = args[0]
     matches = []
-    for s2 in args[1:]:
-        diff = diff_sims(s1, s2, full=False, output=False, die=False)
+    for other in args[1:]:
+        diff = diff_sims(base, other, 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/timepars.py b/starsim/timepars.py
new file mode 100644
index 00000000..bb730c45
--- /dev/null
+++ b/starsim/timepars.py
@@ -0,0 +1,369 @@
+"""
+Functions and classes for handling time
+"""
+import numpy as np
+import sciris as sc
+import starsim as ss
+
+# Classes that are externally visible
+__all__ = ['time_units', 'time_ratio', 'date_add', 'date_diff', 'make_timevec', 'make_abs_tvec',
+           'TimePar', 'dur', 'days', 'years', 'rate', 'perday', 'peryear', 'time_prob', 'beta']
+
+
+#%% Helper functions
+
+# Define available time units
+time_units = sc.dictobj(
+    day = 1,
+    week = 7,
+    month = 30.4375, # 365.25/12 -- more accurate and nicer fraction
+    year = 365, # For simplicity with days
+)
+
+def time_ratio(unit1='day', dt1=1.0, unit2='day', dt2=1.0, as_int=False):
+    """
+    Calculate the relationship between two sets of time factors
+
+    Args:
+        unit1 (str): units for the numerator
+        dt1 (float): timestep for the numerator
+        unit2 (str): units for the denominator
+        dt2 (float): timestep for the denominator
+        as_int (bool): round and convert to an integer
+
+    **Example**::
+        ss.time_ratio(unit1='week', dt1=2, unit2='day', dt2=1, as_int=True) # Returns 14
+    """
+    if dt1 == dt2:
+        dt_ratio = 1.0
+    else:
+        if dt1 is None or dt2 is None:
+            errormsg = f'Cannot convert between dt when one is None ({dt1}, {dt2})'
+            raise ValueError(errormsg)
+        dt_ratio = dt1/dt2
+
+    if unit1 == unit2:
+        unit_ratio = 1.0
+    else:
+        if unit1 is None or unit2 is None:
+            errormsg = f'Cannot convert between units when one is None ({unit1}, {unit2})'
+            raise ValueError(errormsg)
+        u1 = time_units[unit1]
+        u2 = time_units[unit2]
+        unit_ratio = u1/u2
+
+    factor = dt_ratio * unit_ratio
+    if as_int:
+        factor = int(round(factor))
+    return factor
+
+
+def date_add(start, dur, unit):
+    """ Add two dates (or integers) together """
+    if sc.isnumber(start):
+        stop = start + dur
+    else:
+        if unit in time_units:
+            ndays = int(round(time_units[unit]*dur))
+            stop = sc.datedelta(start, days=ndays)
+        else:
+            errormsg = f'Unknown unit {unit}, choices are: {sc.strjoin(time_units.keys())}'
+            raise ValueError(errormsg)
+    return stop
+
+
+def date_diff(start, stop, unit):
+    """ Find the difference between two dates (or integers) """
+    if sc.isnumber(start) and sc.isnumber(stop):
+        dur = stop - start
+    else:
+        if unit == 'year':
+            dur = sc.datetoyear(stop) - sc.datetoyear(start) # TODO: allow non-integer amounts
+        elif unit == 'day':
+            dur = (stop - start).days
+        else:
+            raise NotImplementedError
+    return dur
+
+
+def make_timevec(start, stop, dt, unit):
+    """ Parse start, stop, and dt into an appropriate time vector """
+    if sc.isnumber(start):
+        try:
+            timevec = sc.inclusiverange(start=start, stop=stop, step=dt) # The time points of the sim
+        except Exception as E:
+            errormsg = f'Incompatible set of time inputs: start={start}, stop={stop}, dt={dt}. You can use dates or numbers but not both.'
+            raise ValueError(errormsg) from E
+    else:
+        day_delta = time_ratio(unit1=unit, dt1=dt, unit2='day', dt2=1.0, as_int=True)
+        if day_delta > 0:
+            timevec = sc.daterange(start, stop, interval={'days':day_delta})
+        else:
+            errormsg = f'Timestep {dt} is too small; must be at least 1 day'
+            raise ValueError(errormsg)
+    return timevec
+
+
+def make_abs_tvec(tv, unit, sim_unit):
+    """ Convert a module time vector into a numerical time array with the same units as the sim """
+
+    # It's an array of days or years: easy
+    if sc.isarray(tv):
+        ratio = time_ratio(unit1=unit, unit2=sim_unit)
+        abstv = tv*ratio # Get the units right
+        abstv -= abstv[0] # Start at 0
+
+    # It's a date: convert to fractional years and then subtract the
+    else:
+        yearvec = [sc.datetoyear(d) for d in tv]
+        absyearvec = np.array(yearvec) - yearvec[0] # Subtract start date
+        abstv = absyearvec*time_ratio(unit1='year', unit2=sim_unit)
+
+    # Round to the value of epsilon; alternative to np.round(abstv/eps)*eps, which has floating point error
+    decimals = int(-np.log10(ss.options.time_eps))
+    abstv = np.round(abstv, decimals=decimals)
+
+    return abstv
+
+
+#%% Time classes
+
+class TimePar(ss.BaseArr):
+    """
+    Base class for time-aware parameters, durations and rates
+
+    NB, because the factor needs to be recalculated, do not set values directly.
+    """
+    def __new__(cls, v=None, *args, **kwargs):
+        """ Allow TimePars to wrap distributions and return the distributions """
+
+        # Special distribution handling
+        if isinstance(v, ss.Dist):
+            dist = v
+            dist.pars[0] = cls(dist.pars[0], *args, **kwargs) # Convert the first parameter to a TimePar (the same scale is applied to all parameters)
+            return dist
+
+        # Otherwise, do the usual initialization
+        else:
+            return super().__new__(cls)
+
+    def __init__(self, v, unit=None, parent_unit=None, parent_dt=None, self_dt=1.0):
+        self.v = v
+        self.unit = unit
+        self.parent_unit = parent_unit
+        self.parent_dt = parent_dt
+        self.self_dt = self_dt
+        self.factor = None
+        self.values = None
+        self.parent_name = None
+        self.initialized = False
+        return
+
+    def init(self, parent=None, parent_unit=None, parent_dt=None, update_values=True, die=True):
+        """ Link to the sim and/or module units """
+        if parent is None:
+            parent = sc.dictobj(unit=parent_unit, dt=parent_dt)
+        else:
+            if parent_dt is not None:
+                errormsg = f'Cannot override parent {parent} by setting parent_dt; set in parent object instead'
+                raise ValueError(errormsg)
+            self.parent_name = parent.__class__.__name__ # TODO: or parent.name for Starsim objects?
+
+        if parent.unit is not None:
+            self.parent_unit = parent.unit
+
+        if parent.dt is not None:
+           self.parent_dt = parent.dt
+
+        # Set defaults if not yet set
+        self.unit = sc.ifelse(self.unit, self.parent_unit) # If unit isn't defined but parent is, set to parent
+        self.parent_unit = sc.ifelse(self.parent_unit, self.unit) # If parent isn't defined but unit is, set to self
+        self.parent_dt = sc.ifelse(self.parent_dt, self.self_dt, 1.0) # If dt isn't defined, assume 1 (self_dt is defined by default)
+
+        # Calculate the actual conversion factor to be used in the calculations
+        self.update_cached(update_values=update_values, die=die)
+        self.initialized = True
+        return self
+
+    def __repr__(self):
+        name = self.__class__.__name__
+
+        if self.initialized:
+            if self.factor == 1.0:
+                xstr = ''
+            else:
+                xstr = f', values={self.values}'
+        else:
+            xstr = ', initialized=False'
+
+        if (self.parent_unit is not None) and (self.unit != self.parent_unit):
+            parentstr = f', parent={self.parent_unit}'
+        else:
+            parentstr = ''
+
+        return f'ss.{name}({self.v}, unit={self.unit}{parentstr}{xstr})'
+
+    @property
+    def isarray(self):
+        return isinstance(self.v, np.ndarray)
+
+    def set(self, v=None, unit=None, parent_unit=None, parent_dt=None, self_dt=None, force=False):
+        """ Set the specified parameter values (ignoring None values) and update stored values """
+        if v           is not None: self.v           = v
+        if unit        is not None: self.unit        = unit
+        if parent_unit is not None: self.parent_unit = parent_unit
+        if parent_dt   is not None: self.parent_dt   = parent_dt
+        if self_dt     is not None: self.self_dt     = self_dt
+        if self.initialized or force: # Don't try to set these unless it's been initialized
+            self.update_cached()
+        return self
+
+    def update_cached(self, update_values=True, die=True):
+        """ Update the cached factor and values """
+        try:
+            self.update_factor()
+            if update_values:
+                self.update_values()
+        except TypeError as E: # For a known error, skip silently if die=False
+            if die:
+                errormsg = f'Update failed for {self}. Argument v={self.v} should be a number or array; if a function, use update_values=False. Error: {E}'
+                raise TypeError(errormsg) from E
+        except Exception as E: # For other errors, raise a warning
+            if die:
+                raise E
+            else:
+                tb = sc.traceback(E)
+                warnmsg = f'Uncaught error encountered while updating {self}, but die=False. Traceback:\n{tb}'
+                ss.warn(warnmsg)
+
+        return self
+
+    def update_factor(self):
+        """ Set factor used to multiply the value to get the output """
+        self.factor = time_ratio(unit1=self.unit, dt1=self.self_dt, unit2=self.parent_unit, dt2=self.parent_dt)
+        return
+
+    def update_values(self):
+        """ Convert from self.v to self.values based on self.factor -- must be implemented by derived classes """
+        raise NotImplementedError
+
+    def to(self, unit=None, dt=None):
+        """ Create a new timepar based on the current one but with a different unit and/or dt """
+        new = self.asnew()
+        unit = sc.ifelse(unit, self.parent_unit, self.unit)
+        parent_dt = sc.ifelse(dt, 1.0)
+        new.factor = time_ratio(unit1=self.unit, dt1=self.self_dt, unit2=unit, dt2=parent_dt) # Calculaet the new factor
+        new.update_values() # Update values
+        new.v = new.values # Reset the base value
+        new.factor = 1.0 # Reset everything else to be 1
+        new.parent_unit = unit
+        new.parent_dt = parent_dt
+        return new
+
+    def to_parent(self):
+        """ Create a new timepar with the same units as the parent """
+        unit = self.parent_unit
+        dt = self.parent_dt
+        return self.to(unit=unit, dt=dt)
+
+    def to_json(self):
+        """ Export to JSON """
+        attrs = ['v', 'unit', 'parent_unit', 'parent_dt', 'parent_name', 'self_dt', 'factor']
+        out = {'classname': self.__class__.__name__}
+        out.update({attr:getattr(self, attr) for attr in attrs})
+        out['values'] = sc.jsonify(self.values)
+        return out
+
+    # Act like a float -- TODO, add type checking
+    def __add__(self, other): return self.values + other
+    def __sub__(self, other): return self.values - other
+    def __mul__(self, other): return self.asnew().set(v=self.v * other)
+    def __pow__(self, other): return self.values ** other
+    def __truediv__(self, other): return self.asnew().set(v=self.v / other)
+
+    # ...from either side
+    def __radd__(self, other): return other + self.values
+    def __rsub__(self, other): return other - self.values
+    def __rmul__(self, other): return self.asnew().set(v= other * self.v)
+    def __rpow__(self, other): return other ** self.values
+    def __rtruediv__(self, other): return other / self.values # TODO: should be a rate?
+
+    # Handle modify-in-place methods
+    def __iadd__(self, other): return self.set(v=self.v + other)
+    def __isub__(self, other): return self.set(v=self.v - other)
+    def __imul__(self, other): return self.set(v=self.v * other)
+    def __itruediv__(self, other): return self.set(v=self.v / other)
+
+    # Other methods
+    def __neg__(self): return self.asnew().set(v=-self.v)
+
+
+class dur(TimePar):
+    """ Any number that acts like a duration """
+    def update_values(self):
+        self.values = self.v*self.factor
+        return self.values
+
+
+class days(dur):
+    """ Shortcut to ss.dur(value, units='day') """
+    def __init__(self, v, parent_unit=None, parent_dt=None):
+        super().__init__(v=v, unit='day', parent_unit=parent_unit, parent_dt=parent_dt)
+        return
+
+
+class years(dur):
+    """ Shortcut to ss.dur(value, units='year') """
+    def __init__(self, v, parent_unit=None, parent_dt=None):
+        super().__init__(v=v, unit='year', parent_unit=parent_unit, parent_dt=parent_dt)
+        return
+
+
+class rate(TimePar): # TODO: should all rates just be time_prob?
+    """ Any number that acts like a rate; can be greater than 1 """
+    def update_values(self):
+        self.values = self.v/self.factor
+        return self.values
+
+
+class perday(rate):
+    """ Shortcut to ss.rate(value, units='day') """
+    def __init__(self, v, parent_unit=None, parent_dt=None):
+        super().__init__(v=v, unit='day', parent_unit=parent_unit, parent_dt=parent_dt)
+        return
+
+
+class peryear(rate):
+    """ Shortcut to ss.rate(value, units='year') """
+    def __init__(self, v, parent_unit=None, parent_dt=None):
+        super().__init__(v=v, unit='year', parent_unit=parent_unit, parent_dt=parent_dt)
+        return
+
+
+class time_prob(TimePar):
+    """ A probability over time (a.k.a. a cumulative hazard rate); must be >0 and <1 """
+    def update_values(self):
+        v = self.v
+        if self.isarray:
+            self.values = v.copy()
+            inds = np.logical_and(0.0 < v, v < 1.0)
+            if inds.sum():
+                rates = -np.log(1 - v[inds])
+                self.values[inds] = 1 - np.exp(-rates/self.factor)
+        else:
+            if v == 0:
+                self.values = 0
+            elif v == 1:
+                self.values = 1
+            elif 0 <= v <= 1:
+                rate = -np.log(1 - v)
+                self.values = 1 - np.exp(-rate/self.factor)
+            else:
+                errormsg = f'Invalid value {self.value} for {self}: must be 0-1'
+                raise ValueError(errormsg)
+        return self.values
+
+
+class beta(time_prob):
+    """ A container for beta (i.e. the disease transmission rate) """
+    pass
diff --git a/starsim/utils.py b/starsim/utils.py
index bb7aa20c..f5f02da9 100644
--- a/starsim/utils.py
+++ b/starsim/utils.py
@@ -1,18 +1,18 @@
 """
 Numerical utilities
 """
-
 import warnings
 import numpy as np
-import sciris as sc
-import starsim as ss
 import numba as nb
 import pandas as pd
+import sciris as sc
+import starsim as ss
 
 # %% Helper functions
 
-# What functions are externally visible -- note, this gets populated in each section below
-__all__ = ['ndict', 'warn', 'unique', 'find_contacts', 'combine_rands']
+# What functions are externally visible
+__all__ = ['ndict', 'warn', 'find_contacts', 'set_seed', 'check_requires', 'standardize_netkey',
+           'standardize_data', 'validate_sim_data', ]
 
 
 class ndict(sc.objdict):
@@ -40,7 +40,7 @@ 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()
@@ -76,8 +76,8 @@ def append(self, arg, key=None, overwrite=None):
         else:
             errormsg = f'Could not interpret argument {arg}: does not have expected attribute "{self._nameattr}"'
             raise TypeError(errormsg)
-        return
-    
+        return self
+
     def _check_key(self, key, overwrite=None):
         if overwrite is None: overwrite = self._overwrite
         if key in self:
@@ -88,11 +88,11 @@ def _check_key(self, key, overwrite=None):
             else:
                 ss.warn(f'Overwriting existing ndict entry "{key}"')
         return
-    
+
     def _check_type(self, arg):
         """ Check types """
         if self._type is not None:
-            if not isinstance(arg, self._type):
+            if not isinstance(arg, self._type) and not isinstance(arg, ss.Module): # Module is a valid argument anywhere
                 errormsg = f'The following item does not have the expected type {self._type}:\n{arg}'
                 raise TypeError(errormsg)
         return
@@ -104,28 +104,35 @@ def extend(self, *args, **kwargs):
             self.append(arg)
         for key, arg in kwargs.items():
             self.append(arg, key=key)
-        return
+        return self
+
+    def merge(self, other):
+        """ Merge another dictionary with this one """
+        for key, arg in other.items():
+            self.append(arg, key=key)
+        return self
 
     def copy(self):
+        """ Shallow copy """
         new = self.__class__.__new__(nameattr=self._nameattr, type=self._type, strict=self._strict)
         new.update(self)
         return new
 
-    def __add__(self, dict2):
+    def __add__(self, other):
         """ Allow c = a + b """
         new = self.copy()
-        if isinstance(dict2, list):
-            new.extend(dict2)
+        if isinstance(other, list):
+            new.extend(other)
         else:
-            new.append(dict2)
+            new.append(other)
         return new
 
-    def __iadd__(self, dict2):
+    def __iadd__(self, other):
         """ Allow a += b """
-        if isinstance(dict2, list):
-            self.extend(dict2)
+        if isinstance(other, list):
+            self.extend(other)
         else:
-            self.append(dict2)
+            self.append(other)
         return self
 
 
@@ -159,18 +166,6 @@ def warn(msg, category=None, verbose=None, die=None):
     return
 
 
-def unique(arr):
-    """
-    Find the unique elements and counts in an array.
-    Equivalent to np.unique(return_counts=True) but ~5x faster, and
-    only works for arrays of positive integers.
-    """
-    counts = np.bincount(arr.ravel())
-    unique = np.flatnonzero(counts)
-    counts = counts[unique]
-    return unique, counts
-
-@nb.njit
 def find_contacts(p1, p2, inds):  # pragma: no cover
     """
     Variation on Network.find_contacts() that avoids sorting.
@@ -189,9 +184,19 @@ def find_contacts(p1, p2, inds):  # pragma: no cover
     return pairing_partners
 
 
-# %% Seed methods
+def check_requires(sim, requires, *args):
+    """ Check that the module's requirements (of other modules) are met """
+    errs = sc.autolist()
+    all_classes = [m.__class__ for m in sim.modules]
+    all_names = [m.name for m in sim.modules]
+    for req in sc.mergelists(requires, *args):
+        if req not in all_classes + all_names:
+            errs += req
+    if len(errs):
+        errormsg = f'The following module(s) are required, but the Sim does not contain them: {sc.strjoin(errs)}'
+        raise AttributeError(errormsg)
+    return
 
-__all__ += ['set_seed']
 
 def set_seed(seed=None):
     '''
@@ -224,8 +229,10 @@ def set_seed_regular(seed):
 
 # %% Data cleaning and processing
 
-__all__ += ['standardize_data']
 
+def standardize_netkey(key):
+    """ Networks can be upper or lowercase, and have a suffix 'net' or not; this function standardizes them """
+    return key.lower().removesuffix('net')
 
 def standardize_data(data=None, metadata=None, min_year=1800, out_of_range=0, default_age=0, default_year=2024):
     """
@@ -236,13 +243,12 @@ def standardize_data(data=None, metadata=None, min_year=1800, out_of_range=0, de
     specified metadata, or an ``ss.Dist`` if the data is already an ``ss.Dist`` object.
 
     The metadata is a dictionary that defines columns of the dataframe or keys
-    of the dictionary to use as indices in the output Series. It should contain
+    of the dictionary to use as indices in the output Series. It should contain:
 
     - ``metadata['data_cols']['value']`` specifying the name of the column/key to draw values from
     - ``metadata['data_cols']['year']`` optionally specifying the column containing year values; otherwise the default year will be used
     - ``metadata['data_cols']['age']`` optionally specifying the column containing age values; otherwise the default age will be used
-    - ``metadata['data_cols'][<arbitrary>]`` optionally specifying any other columns to use as indices. These will form part of the multiindex
-                                         for the standardized Series output.
+    - ``metadata['data_cols'][<arbitrary>]`` optionally specifying any other columns to use as indices. These will form part of the multi-index for the standardized Series output.
 
     If a ``sex`` column is part of the index, the metadata can also optionally specify a string mapping to convert
     the sex labels in the input data into the 'm'/'f' labels used by Starsim. In that case, the metadata can contain
@@ -253,11 +259,10 @@ def standardize_data(data=None, metadata=None, min_year=1800, out_of_range=0, de
         data (pandas.DataFrame, pandas.Series, dict, int, float): An associative array  or a number, with the input data to be standardized.
         metadata (dict): Dictionary specifiying index columns, the value column, and optionally mapping for sex labels
         min_year (float): Optionally specify a minimum year allowed in the data. Default is 1800.
-        out_of_range (float): Value to use for negative ages - typically 0 is a reasonable choice but other values (e.g., np.inf or np.nan)
-                              may be useful depending on the calculation. This will automatically be added to the dataframe with an age of
-                              ``-np.inf``
+        out_of_range (float): Value to use for negative ages - typically 0 is a reasonable choice but other values (e.g., np.inf or np.nan) may be useful depending on the calculation. This will automatically be added to the dataframe with an age of ``-np.inf``
 
     Returns:
+
         - A `pd.Series` for all supported formats of `data` *except* an ``ss.Dist``. This series will contain index columns for 'year'
           and 'age' (in that order) and then subsequent index columns for any other variables specified in the metadata, in the order
           they appeared in the metadata (except for year and age appearing first).
@@ -317,14 +322,58 @@ def standardize_data(data=None, metadata=None, min_year=1800, out_of_range=0, de
     return output
 
 
+def validate_sim_data(data=None, die=None):
+    """
+    Validate data intended to be compared to the sim outputs, e.g. for calibration
+
+    Args:
+        data (df/dict): a dataframe (or dict) of data, with a column "time" plus data columns of the form "module.result", e.g. "hiv.new_infections"
+        die (bool): whether to raise an exception if the data cannot be converted (default: die if data is not None but cannot be converted)
+
+    """
+    success = False
+    if data is not None:
+        # Try loading the data
+        try:
+            data = sc.dataframe(data) # Convert it to a dataframe
+            timecols = ['t', 'timevec', 'tvec', 'time', 'day', 'date', 'year'] # If a time column is supplied, use it as the index
+            found = False
+            for timecol in timecols:
+                if timecol in data.cols:
+                    if found:
+                        errormsg = f'Multiple time columns found: please ensure only one of {timecols} is present; you supplied {data.cols}.'
+                        raise ValueError(errormsg)
+                    data.set_index(timecol, inplace=True)
+                    found = True
+            success = True
+
+        # Data loading failed
+        except Exception as E:
+            errormsg = f'Failed to add data "{data}": expecting a dataframe-compatible object. Error:\n{E}'
+            if die == False:
+                print(errormsg)
+            else:
+                raise ValueError(errormsg)
+
+    # Validation
+    if not success and die == True:
+        errormsg = 'Data "{data}" could not be converted and die == True'
+        raise ValueError(errormsg)
+
+    return data
+
+
 def combine_rands(a, b):
     """
-    Efficient algorithm for combining two arrays of random numbers into one
-    
+    Efficient algorithm for combining two arrays of random integers into an array
+    of floats.
+
+    See ss.multi_random() for the user-facing version.
+
     Args:
-        a (array): array of random integers between np.iinfo(np.int64).min and np.iinfo(np.int64).max
+        a (array): array of random integers between 0 and np.iinfo(np.uint64).max, as from ss.rand_raw()
         b (array): ditto, same size as a
-        
+
     Returns:
         A new array of random numbers the same size as a and b
     """
diff --git a/starsim/version.py b/starsim/version.py
index 2d61772b..12426412 100644
--- a/starsim/version.py
+++ b/starsim/version.py
@@ -4,6 +4,6 @@
 
 __all__ = ['__version__', '__versiondate__', '__license__']
 
-__version__ = '1.0.3'
-__versiondate__ = '2024-09-26'
+__version__ = '2.0.0'
+__versiondate__ = '2024-10-01'
 __license__ = f'Starsim {__version__} ({__versiondate__}) — © 2023-2024 by IDM'


Test patch:
diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml
index dc0965f4..6735f42c 100644
--- a/.github/workflows/tests.yaml
+++ b/.github/workflows/tests.yaml
@@ -9,7 +9,7 @@ jobs:
       max-parallel: 8
       matrix:
         os: ['ubuntu-latest']
-        python-version: ['3.9', '3.11']
+        python-version: ['3.9', '3.12']
     runs-on: ${{ matrix.os }}
     name: Install and test
     steps:
diff --git a/tests/baseline.json b/tests/baseline.json
index 9ed445c2..8c6fbc9b 100644
--- a/tests/baseline.json
+++ b/tests/baseline.json
@@ -1,26 +1,26 @@
 {
   "summary": {
-    "yearvec": 2010.1000000000026,
-    "births_new": 65.16666666666667,
-    "births_cumulative": 3258.0,
-    "births_cbr": 29.72790472247259,
-    "deaths_new": 44.48039215686274,
-    "deaths_cumulative": 2237.725490196078,
-    "deaths_cmr": 20.30349109686445,
-    "sir_n_susceptible": 2404.6960784313724,
-    "sir_n_infected": 3594.372549019608,
-    "sir_n_recovered": 4961.392156862745,
-    "sir_prevalence": 0.3366808775522005,
-    "sir_new_infections": 129.13725490196077,
-    "sir_cum_infections": 13172.0,
-    "sis_n_susceptible": 3951.8039215686276,
-    "sis_n_infected": 7008.656862745098,
-    "sis_prevalence": 0.6274153751922642,
-    "sis_new_infections": 208.76470588235293,
-    "sis_cum_infections": 21294.0,
-    "sis_rel_sus": 0.46013020487262113,
-    "n_alive": 10960.460784313726,
-    "new_deaths": 45.59803921568628,
-    "cum_deaths": 4616.0
+    "timevec": 2010.0,
+    "births_new": 65.02970297029702,
+    "births_cumulative": 3224.019801980198,
+    "births_cbr": 29.723743148428454,
+    "deaths_new": 44.97029702970297,
+    "deaths_cumulative": 2229.108910891089,
+    "deaths_cmr": 20.555949238807358,
+    "sir_n_susceptible": 2520.4356435643563,
+    "sir_n_infected": 3626.366336633663,
+    "sir_n_recovered": 4792.089108910891,
+    "sir_prevalence": 0.33888892397890347,
+    "sir_new_infections": 130.36633663366337,
+    "sir_cum_infections": 13167.0,
+    "sis_n_susceptible": 4740.722772277228,
+    "sis_n_infected": 6244.287128712871,
+    "sis_prevalence": 0.5587172284995694,
+    "sis_new_infections": 183.87128712871288,
+    "sis_cum_infections": 18571.0,
+    "sis_rel_sus": 0.5182920328461298,
+    "n_alive": 10938.89108910891,
+    "new_deaths": 46.118811881188115,
+    "cum_deaths": 4608.0
   }
 }
\ No newline at end of file
diff --git a/tests/benchmark.json b/tests/benchmark.json
index 1f3cb651..992ff85b 100644
--- a/tests/benchmark.json
+++ b/tests/benchmark.json
@@ -1,12 +1,12 @@
 {
   "time": {
-    "initialize": 0.015,
-    "run": 0.974
+    "initialize": 0.034,
+    "run": 0.913
   },
   "parameters": {
     "n_agents": 10000,
-    "n_years": 20,
+    "dur": 20,
     "dt": 0.2
   },
-  "cpu_performance": 0.9459193500416027
+  "cpu_performance": 0.9555946088102025
 }
\ No newline at end of file
diff --git a/tests/benchmark_full.py b/tests/benchmark_full.py
index 9cc54afa..b3f40f38 100644
--- a/tests/benchmark_full.py
+++ b/tests/benchmark_full.py
@@ -10,9 +10,9 @@
 repeats = 10
 n_agents = 100_000
 pars = sc.objdict(
-    n_years       = 100,        # Number of years to simulate
-    dt            = 0.5,        # Timestep
-    verbose       = 0,          # Don't print details of the run
+    dur     = 100, # Number of years to simulate
+    dt      = 0.5, # Timestep
+    verbose = 0,   # Don't print details of the run
 )
 
 def make_run_sim():
@@ -21,10 +21,10 @@ def make_run_sim():
     ppl = ss.People(n_agents=n_agents)
     
     # Make the components
-    sir = ss.SIR()
+    sir = ss.SIS()
     hiv = ss.HIV()
-    hiv.pars['beta'] = {'mf': [0.15, 0.10], 'maternal': [0.2, 0]}
-    networks = [ss.MFNet(), ss.MaternalNet()]
+    hiv.pars['beta'] = {'mf': [0.15, 0.10], 'maternal': [0.2, 0], 'random': [0,0]}
+    networks = [ss.RandomNet(), ss.MFNet(), ss.MaternalNet()]
     
     # Make the sim
     sim = ss.Sim(pars=pars, people=ppl, networks=networks, demographics=ss.Pregnancy(), diseases=[sir, hiv])
diff --git a/tests/benchmark_simple.py b/tests/benchmark_simple.py
index 54e2a445..cd3f26a1 100644
--- a/tests/benchmark_simple.py
+++ b/tests/benchmark_simple.py
@@ -1,5 +1,5 @@
 import sciris as sc
-import matplotlib.pyplot as pl
+import matplotlib.pyplot as plt
 import starsim as ss
 
 do_plot = False
@@ -11,10 +11,10 @@
     hiv = ss.HIV()
     hiv.pars['beta'] = {'simple_sexual': [0.0008, 0.0004], 'maternal': [0.2, 0]}
     
-    sim = ss.Sim(start=1950, end=2050, people=ppl, networks=networks, demographics=ss.Pregnancy(), diseases=hiv)
-    sim.initialize()
+    sim = ss.Sim(start=1950, stop=2050, people=ppl, networks=networks, demographics=ss.Pregnancy(), diseases=hiv)
+    sim.init()
     sim.run()
 
 if do_plot:
-    pl.plot(sim.tivec, sim.results.hiv.n_infected)
-    pl.show()
+    plt.plot(sim.tivec, sim.results.hiv.n_infected)
+    plt.show()
diff --git a/tests/multisim_baseline.py b/tests/multisim_baseline.py
index b50a965d..ede2f48d 100755
--- a/tests/multisim_baseline.py
+++ b/tests/multisim_baseline.py
@@ -8,7 +8,7 @@
 """
 
 import sys
-import test_baselines as tb
+import test_baselines
 
 save = False
 n_runs = 10
@@ -19,4 +19,4 @@
 if len(sys.argv) > 2:
     n_runs = int(sys.argv[2])
 
-tb.multisim_baseline(save, n_runs)
+test_baselines.multisim_baseline(save, n_runs)
diff --git a/tests/profile_sim.py b/tests/profile_sim.py
new file mode 100644
index 00000000..283182e4
--- /dev/null
+++ b/tests/profile_sim.py
@@ -0,0 +1,19 @@
+import sciris as sc
+import starsim as ss
+
+kw = dict(n_agents=100e3, start=2000, dur=100, diseases='sis', networks='random')
+sim = ss.Sim(**kw)
+
+prof = ['profile', 'cprofile', 'time'][1]
+
+if prof == 'profile':
+    prf = sc.profile(run=sim.run, follow=[sim.run, ss.disease.Infection.infect, ss.network.RandomNet.step])
+
+elif prof == 'cprofile':
+    cprf = sc.cprofile(sort='selfpct', mintime=1e-3, maxitems=None, maxfunclen=None, maxpathlen=None)
+    with cprf:
+        sim.run()
+
+else:
+    with sc.timer() as T:
+        sim.run()
\ No newline at end of file
diff --git a/tests/quick_benchmark b/tests/quick_benchmark
new file mode 100755
index 00000000..a41c8991
--- /dev/null
+++ b/tests/quick_benchmark
@@ -0,0 +1,8 @@
+#!/usr/bin/env python3
+"""
+Quickly check the performance benchmark
+"""
+
+import test_baselines
+
+test_baselines.test_benchmark(do_save=False, repeats=5)
diff --git a/tests/test_baselines.py b/tests/test_baselines.py
index ade4ddd7..b51d2ea8 100644
--- a/tests/test_baselines.py
+++ b/tests/test_baselines.py
@@ -15,24 +15,24 @@
 
 # Define the parameters
 pars = sc.objdict(
-    n_agents   = 10e3, # Number of agents
-    start      = 2000, # Starting year
-    n_years    = 20,   # Number of years to simulate
-    dt         = 0.2,  # Timestep
-    verbose    = 0,    # Don't print details of the run
-    rand_seed  = 2,    # Set a non-default seed
+    n_agents  = 10e3, # Number of agents
+    start     = 2000, # Starting year
+    dur       = 20,   # Number of years to simulate
+    dt        = 0.2,  # Timestep
+    verbose   = 0,    # Don't print details of the run
+    rand_seed = 2,    # Set a non-default seed
 )
 
 
 def make_sim(run=False, **kwargs):
     """
-    Define a default simulation for testing the baseline. If run directly (not 
+    Define a default simulation for testing the baseline. If run directly (not
     via pytest), also plot the sim by default.
     """
     diseases = ['sir', 'sis']
     networks = ['random', 'mf', 'maternal']
     sim = ss.Sim(pars=pars | kwargs, networks=networks, diseases=diseases, demographics=True)
-    
+
     # Optionally run and plot
     if run:
         sim.run()
@@ -54,9 +54,9 @@ def save_baseline():
 
     # Export results
     sim.to_json(filename=baseline_filename, keys='summary')
-    
-    # CK: To restore once export_pars is fixed
-    # sim.export_pars(filename=parameters_filename) # If not different from previous version, can safely delete
+
+    # Save parameters
+    sim.to_json(filename=parameters_filename, keys='pars') # If not different from previous version, can safely delete
 
     print('Done.')
     return
@@ -89,7 +89,7 @@ def multisim_baseline(save=False, n_runs=10, **kwargs):
 
 def test_baseline():
     """ Compare the current default sim against the saved baseline """
-    
+
     # Load existing baseline
     baseline = sc.loadjson(baseline_filename)
     old = baseline['summary']
@@ -106,7 +106,7 @@ def test_baseline():
 
 def test_benchmark(do_save=False, repeats=1, verbose=True):
     """ Compare benchmark performance """
-    
+
     if verbose: print('Running benchmark...')
     try:
         previous = sc.loadjson(benchmark_filename)
@@ -141,13 +141,13 @@ def normalize_performance():
 
     # Do the actual benchmarking
     for r in range(repeats):
-        
+
         print(f'Repeat {r}')
-        
+
         # Time initialization
         t0 = sc.tic()
         sim = make_sim()
-        sim.initialize()
+        sim.init()
         t_init = sc.toc(t0, output=True)
 
         # Time running
@@ -173,7 +173,7 @@ def normalize_performance():
                 },
             'parameters': {
                 'n_agents': sim.pars.n_agents,
-                'n_years':  sim.pars.n_years,
+                'dur':      sim.pars.dur,
                 'dt':       sim.pars.dt,
                 },
             'cpu_performance': ratio,
diff --git a/tests/test_calibration.py b/tests/test_calibration.py
index 31999001..2d89ebba 100644
--- a/tests/test_calibration.py
+++ b/tests/test_calibration.py
@@ -28,7 +28,7 @@ def make_sim():
         n_agents = n_agents,
         total_pop = 9980999,
         start = 1990,
-        n_years = 40,
+        dur = 40,
         diseases = [hiv],
         networks = [random, maternal],
         demographics = [pregnancy, death],
@@ -40,7 +40,7 @@ def make_sim():
 def make_data():
     """ Define the calibration target data """
     target_data = [
-    ['year', 'n_alive', 'hiv.prevalence', 'hiv.n_infected', 'hiv.new_infections', 'hiv.new_deaths'],
+    ['time', 'n_alive', 'hiv.prevalence', 'hiv.n_infected', 'hiv.new_infections', 'hiv.new_deaths'],
     [  1990,  10432409,        0.0699742,          730000 ,               210000,           25000],
     [  1991,  10681008,        0.0851979,          910000 ,               220000,           33000],
     [  1992,  10900511,        0.1009127,          1100000,               220000,           43000],
@@ -80,21 +80,13 @@ def make_data():
 
 #%% Define the tests
 
-def test_calibration(do_plot=True):
+def test_calibration(do_plot=False):
     sc.heading('Testing calibration')
 
     # Define the calibration parameters
     calib_pars = dict(
-        diseases = dict(
-            hiv = dict(
-                init_prev = [0.15, 0.01, 0.30],
-            ),
-        ),
-        networks = dict(
-            randomnet = dict(
-                n_contacts = [4, 2, 10],
-            ),
-        ),
+        init_prev = dict(low=0.01, high=0.30, guess=0.15, path=('diseases', 'hiv', 'init_prev')),
+        n_contacts = dict(low=2, high=10, guess=4, path=('networks', 'randomnet', 'n_contacts')),
     )
 
     # Make the sim and data
@@ -116,9 +108,10 @@ def test_calibration(do_plot=True):
         sim = sim,
         data = data,
         weights = weights,
-        total_trials = 4,
+        total_trials = 8,
         n_workers = 2,
-        die = True
+        die = True,
+        debug = False,
     )
 
     # Perform the calibration
@@ -134,7 +127,11 @@ def test_calibration(do_plot=True):
         print('✓ Calibration improved fit')
     else:
         print('✗ Calibration did not improve fit, but this sometimes happens stochastically and is not necessarily an error')
-    
+
+    if do_plot:
+        calib.plot_sims()
+        calib.plot_trend()
+
     return sim, calib
 
 
@@ -142,7 +139,8 @@ def test_calibration(do_plot=True):
 if __name__ == '__main__':
 
     T = sc.timer()
+    do_plot = True
 
-    sim, calib = test_calibration()
+    sim, calib = test_calibration(do_plot=do_plot)
 
     T.toc()
diff --git a/tests/test_demographics.py b/tests/test_demographics.py
index 2e14c837..54e686e5 100644
--- a/tests/test_demographics.py
+++ b/tests/test_demographics.py
@@ -1,8 +1,6 @@
 """
 Test demographic consistency
 """
-
-# %% Imports and settings
 import starsim as ss
 import pandas as pd
 import matplotlib.pyplot as plt
@@ -10,12 +8,11 @@
 import sciris as sc
 import pytest
 
-
 sc.options(interactive=False) # Assume not running interactively
 datadir = ss.root / 'tests/test_data'
 
 
-def test_nigeria(which='births', dt=1, start=1995, n_years=15, do_plot=False):
+def test_nigeria(which='births', dt=1, start=1995, dur=15, do_plot=False):
     """
     Make a Nigeria sim with demographic modules
     Switch between which='births' or 'pregnancy' to determine which demographic module to use
@@ -49,13 +46,13 @@ def test_nigeria(which='births', dt=1, start=1995, n_years=15, do_plot=False):
         dt=dt,
         total_pop=nga_pop_1995,
         start=start,
-        n_years=n_years,
+        dur=dur,
         people=ppl,
         demographics=demographics,
     )
 
     if do_plot:
-        sim.initialize()
+        sim.init()
         # Plot histograms of the age distributions - simulated vs data
         bins = np.arange(0, 101, 1)
         init_scale = nga_pop_1995 / n_agents
@@ -66,15 +63,15 @@ def test_nigeria(which='births', dt=1, start=1995, n_years=15, do_plot=False):
 
     sim.run()
 
-    end = start + n_years
+    stop = start + dur
     nigeria_popsize = pd.read_csv(datadir/'nigeria_popsize.csv')
-    data = nigeria_popsize[(nigeria_popsize.year >= start) & (nigeria_popsize.year <= end)]
+    data = nigeria_popsize[(nigeria_popsize.year >= start) & (nigeria_popsize.year <= stop)]
 
     nigeria_cbr = pd.read_csv(datadir/'nigeria_births.csv')
-    cbr_data = nigeria_cbr[(nigeria_cbr.Year >= start) & (nigeria_cbr.Year <= end)]
+    cbr_data = nigeria_cbr[(nigeria_cbr.Year >= start) & (nigeria_cbr.Year <= stop)]
 
     nigeria_cmr = pd.read_csv(datadir/'nigeria_cmr.csv')
-    cmr_data = nigeria_cmr[(nigeria_cmr.Year >= start) & (nigeria_cmr.Year <= end)]
+    cmr_data = nigeria_cmr[(nigeria_cmr.Year >= start) & (nigeria_cmr.Year <= stop)]
 
     # Tests
     if which == 'pregnancy':
@@ -88,34 +85,36 @@ def test_nigeria(which='births', dt=1, start=1995, n_years=15, do_plot=False):
             assert np.array_equal(sim.results.pregnancy.pregnancies, sim.results.pregnancy.births)
             print('✓ (births == pregnancies)')
 
-    print("Check final pop size within 5% of data")
-    assert np.isclose(data.n_alive.values[-1], sim.results.n_alive[-1], rtol=0.05)
+    rtol = 0.05
+    print(f'Check final pop size within {rtol*100:n}% of data')
+    assert np.isclose(data.n_alive.values[-1], sim.results.n_alive[-1], rtol=rtol), f'Final population size not within {rtol*100:n}% of data'
     print(f'✓ (simulated/data={sim.results.n_alive[-1] / data.n_alive.values[-1]:.2f})')
 
     # Plots
     if do_plot:
+        tvec = sim.timevec
         fig, ax = plt.subplots(2, 2)
         ax = ax.ravel()
         ax[0].scatter(data.year, data.n_alive, alpha=0.5)
-        ax[0].plot(sim.yearvec, sim.results.n_alive, color='k')
+        ax[0].plot(tvec, sim.results.n_alive, color='k')
         ax[0].set_title('Population')
 
-        ax[1].plot(sim.yearvec, 1000 * sim.results.deaths.cmr / dt, label='Simulated CMR')
+        ax[1].plot(tvec, 1000 * sim.results.deaths.cmr, label='Simulated CMR')
         ax[1].scatter(cmr_data.Year, cmr_data.CMR, label='Data CMR')
         ax[1].set_title('CMR')
         ax[1].legend()
 
         if which == 'births':
-            ax[2].plot(sim.yearvec, sim.results.births.cbr / dt, label='Simulated CBR')
+            ax[2].plot(tvec, sim.results.births.cbr, label='Simulated CBR')
         elif which == 'pregnancy':
-            ax[2].plot(sim.yearvec, sim.results.pregnancy.cbr / dt, label='Simulated CBR')
+            ax[2].plot(tvec, sim.results.pregnancy.cbr, label='Simulated CBR')
         ax[2].scatter(cbr_data.Year, cbr_data.CBR, label='Data CBR')
         ax[2].set_title('CBR')
         ax[2].legend()
 
         if which == 'pregnancy':
-            ax[3].plot(sim.yearvec, sim.results.pregnancy.pregnancies / dt, label='Pregnancies')
-            ax[3].plot(sim.yearvec, sim.results.pregnancy.births / dt, label='Births')
+            ax[3].plot(tvec, sim.results.pregnancy.pregnancies, label='Pregnancies')
+            ax[3].plot(tvec, sim.results.pregnancy.births, label='Births')
             ax[3].set_title('Pregnancies and births')
             ax[3].legend()
 
@@ -127,7 +126,7 @@ def test_nigeria(which='births', dt=1, start=1995, n_years=15, do_plot=False):
 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()
+    sim = ss.Sim(n_agents=10e3, birth_rate=10, death_rate=10/1010*1000, dur=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)
     print(f'✓ (final pop / starting pop={sim.results.n_alive[-1] / sim.results.n_alive[0]:.2f})')
@@ -144,7 +143,7 @@ def test_module_adding():
     births = ss.Births(pars={'birth_rate': 10})
     deaths = ss.Deaths(pars={'death_rate': 10})
     demographics = [births, deaths]
-    with pytest.raises(Exception): # CK: should be ValueError, but that fails for now, and this is OK
+    with pytest.raises(ValueError):
         ss.Sim(n_agents=1e3, demographics=demographics, birth_rate=10, death_rate=10).run()
     return demographics
 
@@ -153,27 +152,27 @@ 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()
+    s1 = ss.Sim(n_agents=n_agents).init()
     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
diff --git a/tests/test_diseases.py b/tests/test_diseases.py
index 4de6f93e..3ba2c4e6 100644
--- a/tests/test_diseases.py
+++ b/tests/test_diseases.py
@@ -10,10 +10,12 @@
 test_run = True
 n_agents = [10_000, 2_000][test_run]
 do_plot = False
-sc.options(interactive=False) # Assume not running interactively
+sc.options(interactive=do_plot) # Assume not running interactively
 
 
 def test_sir():
+    sc.heading('Testing SIR dynamics')
+
     ppl = ss.People(n_agents)
     network_pars = {
         'n_contacts': ss.poisson(4), # Contacts Poisson distributed with a mean of 4
@@ -26,7 +28,7 @@ def test_sir():
     sir = ss.SIR(sir_pars)
 
     # Change pars after creating the SIR instance
-    sir.pars.beta = {'random': 0.1}
+    sir.pars.beta = {'random': ss.rate(0.1)}
 
     # You can also change the parameters of the default lognormal distribution directly
     sir.pars.dur_inf.set(loc=5)
@@ -40,7 +42,7 @@ def test_sir():
     plt.figure()
     res = sim.results
     plt.stackplot(
-        sim.yearvec,
+        sim.timevec,
         res.sir.n_susceptible,
         res.sir.n_infected,
         res.sir.n_recovered,
@@ -54,7 +56,7 @@ def test_sir():
 
 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
@@ -103,6 +105,8 @@ def test_sir_epi():
 
 
 def test_sis(do_plot=do_plot):
+    sc.heading('Testing SIS dynamics')
+
     pars = dict(
         n_agents = n_agents,
         diseases = 'sis',
@@ -117,18 +121,20 @@ def test_sis(do_plot=do_plot):
 
 
 def test_ncd():
+    sc.heading('Testing NCDs')
+
     ppl = ss.People(n_agents)
     ncd = ss.NCD(pars={'log':True})
     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()
-    df = ncd.log.line_list  # Check generation of line-list
+    df = ncd.log.line_list()  # Check generation of line-list
     assert df.source.isna().all()
 
     plt.figure()
     plt.stackplot(
-        sim.yearvec,
+        sim.timevec,
         ncd.results.n_not_at_risk,
         ncd.results.n_at_risk - ncd.results.n_affected,
         ncd.results.n_affected,
@@ -143,6 +149,8 @@ def test_ncd():
 
 
 def test_gavi():
+    sc.heading('Testing GAVI diseases')
+
     sims = sc.autolist()
     for disease in ['cholera', 'measles', 'ebola']:
         pars = dict(
@@ -157,6 +165,8 @@ def test_gavi():
 
 
 def test_multidisease():
+    sc.heading('Testing simulating multiple diseases')
+
     ppl = ss.People(n_agents)
     sir1 = ss.SIR(name='sir1')
     sir2 = ss.SIR(name='sir2')
@@ -169,9 +179,9 @@ def test_multidisease():
     sim.run()
     return sim
 
-def test_mtct():
-    """ Test mother-to-child transmission routes """
 
+def test_mtct():
+    sc.heading('Test mother-to-child transmission routes')
     ppl = ss.People(n_agents)
     sis = ss.SIS(beta={'random':[0.005, 0.001], 'prenatal':[0.1, 0], 'postnatal':[0.1, 0]})
     networks = [ss.RandomNet(), ss.PrenatalNet(), ss.PostnatalNet()]
@@ -180,7 +190,9 @@ def test_mtct():
     sim.run()
     return sim
 
+
 if __name__ == '__main__':
+    do_plot = True
     sc.options(interactive=do_plot)
     sir   = test_sir()
     s1,s2 = test_sir_epi()
diff --git a/tests/test_dist.py b/tests/test_dist.py
index eaedd018..820e5fbc 100644
--- a/tests/test_dist.py
+++ b/tests/test_dist.py
@@ -6,7 +6,7 @@
 import sciris as sc
 import scipy.stats as sps
 import starsim as ss
-import matplotlib.pyplot as pl
+import matplotlib.pyplot as plt
 import pytest
 
 n = 1_000_000 # For testing statistical properties and performance of distributions
@@ -15,13 +15,13 @@
 
 
 def plot_rvs(rvs, times=None, nrows=None):
-    fig = pl.figure(figsize=(12,12))
+    fig = plt.figure(figsize=(12,12))
     nrows, ncols = sc.getrowscols(len(rvs), nrows=nrows)
     for i,name,r in rvs.enumitems():
-        pl.subplot(nrows, ncols, i+1)
-        pl.hist(r.astype(float))
+        plt.subplot(nrows, ncols, i+1)
+        plt.hist(r.astype(float))
         title = times[name] if times else name
-        pl.title(title)
+        plt.title(title)
         sc.commaticks()
     sc.figlayout()
     return fig
@@ -29,7 +29,7 @@ def plot_rvs(rvs, times=None, nrows=None):
 
 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
+    sim = ss.Sim(n_agents=100).init() # Need an empty sim to initialize properly
     return sim
 
 
@@ -38,39 +38,43 @@ def test_dist(m=m):
     """ Test the Dist class """
     sc.heading('Testing the basic Dist call')
     dist = ss.Dist(distname='random', name='test', strict=False)
-    dist.initialize()
+    dist.init()
     rvs = dist(m)
     print(rvs)
     assert 0 < rvs.min() < 1, 'Values should be between 0 and 1'
+
+    # Test other options
+    dist.show_state()
+    dist.plot_hist()
     return rvs
 
 
 def test_custom_dists(n=n, do_plot=False):
     """ Test all custom dists """
     sc.heading('Testing all custom distributions')
-    
+
     o     = sc.objdict()
     dists = sc.objdict()
     rvs   = sc.objdict()
     times = sc.objdict()
     for name in ss.dist_list:
-        func = getattr(ss, name)
-        dist = func(name='test', strict=False)
-        dist.initialize()
+        dist_class = getattr(ss, name)
+        dist = dist_class(name='test', strict=False)
+        dist.init()
         dists[name] = dist
         sc.tic()
         rvs[name] = dist.rvs(n)
         times[name] = sc.toc(name, unit='ms', output='message')
         print(f'{name:10s}: mean = {rvs[name].mean():n}')
-    
+
     if do_plot:
         plot_rvs(rvs, times=times, nrows=5)
-    
+
     o.dists = dists
     o.rvs = rvs
     o.times = times
     return o
-        
+
 
 def test_dists(n=n, do_plot=False):
     """ Test the Dists container """
@@ -79,14 +83,14 @@ def test_dists(n=n, do_plot=False):
 
     # Create the objects twice
     for i in range(2):
-        
+
         # Create a complex object containing various distributions
         obj = sc.prettyobj()
         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.constant(v=0.3))
-        dists = ss.Dists(obj).initialize(sim=make_sim())
-        
+        dists = ss.Dists(obj).init(sim=make_sim())
+
         # Call each distribution twice
         for j in range(2):
             rvs = sc.objdict()
@@ -94,15 +98,15 @@ def test_dists(n=n, do_plot=False):
                 rvs[str(dist)] = dist(n)
                 dist.jump() # Reset
             testvals[i,j] = rvs[0][283] # Pick one of the random values and store it
-    
+
     # Check that results are as expected
     print(testvals)
     assert np.all(testvals[0,:] == testvals[1,:]), 'Newly initialized objects should match'
     assert np.all(testvals[:,0] != testvals[:,1]), 'After jumping, values should be different'
-            
+
     if do_plot:
         plot_rvs(rvs)
-    
+
     o = sc.objdict()
     o.dists = dists
     o.rvs = rvs
@@ -112,96 +116,101 @@ def test_dists(n=n, do_plot=False):
 def test_scipy(m=m):
     """ Test that SciPy distributions also work """
     sc.heading('Testing SciPy distributions')
-    
+
     # Make SciPy distributions in two different ways
-    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
+    dist1 = ss.Dist(dist=sps.expon, name='scipy', scale=2, strict=False).init() # Version 1: callable
+    dist2 = ss.Dist(dist=sps.expon(scale=2), name='scipy', strict=False).init() # Version 2: frozen
     rvs1 = dist1(m)
     rvs2 = dist2(m)
-    
+
     # Check that they match
     print(rvs1)
     assert np.array_equal(rvs1, rvs2), 'Arrays should match'
-    
+
     return dist1, dist2
 
 
 def test_exceptions(m=m):
     """ Check that exceptions are being appropriately raised """
     sc.heading('Testing exceptions and strict')
-    
+
     # Create a strict distribution
     dist = ss.random(strict=True, auto=False)
     with pytest.raises(ss.distributions.DistNotInitializedError):
         dist(m) # Check that we can't call an uninitialized
-    
+
     # Initialize and check we can't call repeatedly
-    dist.initialize(trace='test', sim=make_sim())
+    dist.init(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')
+    dist2.init(trace='test')
     rvs2 = sc.autolist()
     for i in range(2):
         rvs2 += dist2(m) # We should be able to call multiple times with no problem
-    
+
     print(rvs)
     print(rvs2)
     assert np.array_equal(rvs, rvs2[0]), 'Separate dists should match'
     assert not np.array_equal(rvs2[0], rvs2[1]), 'Multiple calls to the same dist should not match'
-    
+
     return dist, dist2
-    
+
 
 def test_reset(m=m):
     """ Check that reset works as expected """
     sc.heading('Testing reset')
-    
+
     # Create and draw two sets of random numbers
-    dist = ss.random(seed=533, strict=False).initialize()
+    dist = ss.random(seed=533, strict=False).init()
     r1 = dist.rvs(m)
     r2 = dist.rvs(m)
     assert all(r1 != r2)
-    
+
     # Reset to the most recent state
     dist.reset(-1)
     r3 = dist.rvs(m)
     assert all(r3 == r2)
-    
+
     # Reset to the initial state
     dist.reset(0)
     r4 = dist.rvs(m)
     assert all(r4 == r1)
-    
+
     for r in [r1, r2, r3, r4]:
         print(r)
-    
+
     return dist
 
 
-def test_callable(n=n):
-    """ Test callable parameters """
-    sc.heading('Testing a uniform distribution with callable parameters')
-    
-    # Define a fake people object
+def make_fake_sim(n=10):
+    """ Define a fake sim object for testing slots """
     sim = sc.prettyobj()
-    sim.n = 10
+    sim.n = n
     sim.people = sc.prettyobj()
     sim.people.uid = np.arange(sim.n)
     sim.people.slot = np.arange(sim.n)
-    sim.people.age = np.random.uniform(0, 90, size=sim.n)
+    sim.people.age = np.random.uniform(0, 100, size=sim.n)
+    return sim
+
+
+def test_callable(n=n):
+    """ Test callable parameters """
+    sc.heading('Testing a uniform distribution with callable parameters')
+
+    sim = make_fake_sim()
 
     # Define a parameter as a function
     def custom_loc(module, sim, uids):
         out = sim.people.age[uids]
         return out
-    
+
     scale = 1
-    d1 = ss.normal(name='callable', loc=custom_loc, scale=scale).initialize(sim=sim)
-    d2 = ss.lognorm_ex(name='callable', mean=custom_loc, stdev=scale).initialize(sim=sim)
+    d1 = ss.normal(name='callable', loc=custom_loc, scale=scale).init(sim=sim)
+    d2 = ss.lognorm_ex(name='callable', mean=custom_loc, std=scale).init(sim=sim)
 
     uids = np.array([1, 3, 7, 9])
     draws1 = d1.rvs(uids)
@@ -212,6 +221,7 @@ def custom_loc(module, sim, uids):
     for draws in [draws1, draws2]:
         meandiff = np.abs(sim.people.age[uids] - draws).mean()
         assert meandiff < scale*3, 'Outputs should match ages'
+
     return d1
 
 
@@ -223,7 +233,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, strict=False).initialize(slots=np.arange(uids.max()+1))
+    d = ss.uniform(low=low, high=high, strict=False).init(slots=np.arange(uids.max()+1))
     draws = d.rvs(uids)
     print(f'Uniform sample for uids {uids} returned {draws}')
 
@@ -245,9 +255,9 @@ def test_repeat_slot():
     high = low + 1
 
     # Draw values
-    d = ss.uniform(low=low, high=high, strict=False).initialize(slots=slots)
+    d = ss.uniform(low=low, high=high, strict=False).init(slots=slots)
     draws = d.rvs(uids)
-    
+
     # Print and test
     print(f'Uniform sample for slots {slots} returned {draws}')
     assert len(draws) == len(slots)
@@ -261,14 +271,176 @@ def test_repeat_slot():
     return draws
 
 
+def test_timepar_dists():
+    """ Test interaction of distributions and timepars """
+    sc.heading('Test interaction of distributions and timepars')
+
+    # Set parameters
+    n = int(10e3)
+    u1 = 'day'
+    u2 = 'year'
+    dt_dur = 0.1
+    dt_rate = 20.0
+    ratio_dur  = ss.time_ratio(u1, 1.0, u2, dt_dur)
+    ratio_rate = ss.time_ratio(u2, 1.0, u1, dt_rate)
+
+    # Create time parameters
+    v = sc.objdict()
+    v.base = 30.0
+    v.dur = ss.dur(v.base, unit=u1, parent_unit=u2, parent_dt=dt_dur).init()
+    v.rate = ss.rate(v.base, unit=u2, parent_unit=u1, parent_dt=dt_rate).init() # Swap units
+
+    # Check distributions that scale linearly with time, with the parameter we'll set
+    print('Testing linear distributions ...')
+    linear_dists = dict(
+        constant   = 'v',
+        uniform    = 'high',
+        normal     = 'loc',
+        lognorm_ex = 'mean',
+        expon      = 'scale',
+        weibull    = 'scale',
+        gamma      = 'scale',
+    )
+
+    for name,par in linear_dists.items():
+        dist_class = getattr(ss, name)
+
+        # Create the dists, the first parameter of which should have time units
+        dists = sc.objdict()
+        for key,val in v.items():
+            pardict = {par:val} # Convert to a tiny dictionary to insert the correct name
+            dists[key] = dist_class(**pardict, name=key, strict=False).init()
+
+        # Create th erandom variates
+        rvs = sc.objdict()
+        for k,dist in dists.items():
+            rvs[k] = dist.rvs(n)
+
+        # Check that the distributions match
+        rtol = 0.1 # Be somewhat generous with the uncertainty
+        expected = rvs.base.mean()
+        expected_dur = expected*ratio_dur
+        expected_rate = expected/ratio_rate
+        actual_dur = rvs.dur.mean()
+        actual_rate = rvs.rate.mean()
+        assert np.isclose(expected_dur, actual_dur, rtol=rtol), f'Duration not close for {name}: {expected_dur:n} ≠ {actual_dur:n}'
+        assert np.isclose(expected_rate, actual_rate, rtol=rtol), f'Rate not close for {name}: {expected_rate:n} ≠ {actual_rate:n}'
+        sc.printgreen(f'✓ {name} passed: {expected_dur:n} ≈ {actual_dur:n}')
+
+    # Check that unitless distributions fail
+    print('Testing unitless distributions ...')
+    par = ss.dur(10)
+    unitless_dists = ['lognorm_im', 'randint', 'choice']
+    for name in unitless_dists:
+        dist_class = getattr(ss, name)
+        with pytest.raises(NotImplementedError):
+            dist = dist_class(par, name='notimplemented', strict=False).init()
+            dist.rvs(n)
+        sc.printgreen(f'✓ {name} passed: raised appropriate error')
+
+    # Check special distributions
+    print('Testing Poisson distribution ...')
+    lam1 = ss.rate(365, 'year', parent_unit='year').init()
+    lam2 = ss.rate(1,   'day',  parent_unit='year').init()
+    poi1 = ss.poisson(lam=lam1, strict=False).init()
+    poi2 = ss.poisson(lam=lam2, strict=False).init()
+    mean1 = poi1.rvs(n).mean()
+    mean2 = poi2.rvs(n).mean()
+    assert np.isclose(mean1, mean2, rtol=rtol), f'Poisson values do not match for {lam1} and {lam2}: {mean1:n} ≠ {mean2:n}'
+    sc.printgreen(f'✓ poisson passed: {mean1:n} ≈ {mean2:n}')
+
+    print('Testing Bernoulli distribution ...')
+    p1 = 0.1
+    p2 = ss.time_prob(0.01, parent_dt=10).init()
+    ber1 = ss.bernoulli(p=p1, strict=False).init()
+    ber2 = ss.bernoulli(p=p2, strict=False).init()
+    mean1 = ber1.rvs(n).mean()
+    mean2 = ber2.rvs(n).mean()
+    assert np.isclose(mean1, mean2, rtol=rtol), f'Bernoulli values do not match for {lam1} and {lam2}: {mean1:n} ≠ {mean2:n}'
+    sc.printgreen(f'✓ bernoulli passed: {mean1:n} ≈ {mean2:n}')
+
+    return ber2
+
+
+def test_timepar_callable():
+    sc.heading('Test that timepars work with (some) callable functions')
+
+    print('Testing callable parameters with regular-scaling distributions')
+    sim = make_fake_sim(n=10)
+    uids = np.array([1, 3, 7, 9])
+
+    def age_year(module, sim, uids):
+        """ Extract age as a year """
+        out = sim.people.age[uids].copy()
+        return out
+
+    def age_day(module, sim, uids):
+        """ Extract age as a day """
+        out = sim.people.age[uids].copy()
+        out *= 365 # Convert to days manually
+        return out
+
+    scale = 1e-3 # Set to a very small but nonzero scale
+    loc  = ss.dur(age_year, unit='year', parent_unit='day').init(update_values=False)
+    mean = ss.dur(age_day,  unit='day',  parent_unit='day').init(update_values=False)
+    d3 = ss.normal(name='callable', loc=loc, scale=scale).init(sim=sim)
+    d4 = ss.lognorm_ex(name='callable', mean=mean, std=scale).init(sim=sim)
+    draws3 = d3.rvs(uids)
+    draws4 = d4.rvs(uids)
+    age_in_days = sim.people.age[uids]*365
+    draw_diff = np.abs(draws3 - draws4).mean()
+    age_diff = np.abs(age_in_days - draws3).mean()
+    print(f'Input ages were:\n{sim.people.age[uids]}')
+    print(f'Output samples were:\n{sc.sigfiground(draws3)}\n{sc.sigfiground(draws4)}')
+    assert draw_diff < 1, 'Day and year outputs should match to the nearest day'
+    assert age_diff < 1, 'Distribution outputs should match ages to the nearest day'
+
+    print('Testing callable parameters with Bernoulli distributions')
+    n = 100_000
+    sim = make_fake_sim(n=n)
+    uids = np.random.choice(n, size=n//2, replace=False)
+    age = sim.people.age[uids]
+    mean = age.mean()
+    young = sc.findinds(age<=mean)
+    old = sc.findinds(age>mean)
+    p_young = 0.001
+    p_old = 0.010
+
+    def age_prob(module, sim, uids):
+        out = np.zeros_like(age)
+        out[young] = p_young
+        out[old]   = p_old
+        return out
+
+    parent_dt = 10
+    p1 = age_prob
+    p2 = ss.time_prob(age_prob, parent_dt=parent_dt).init(update_values=False)
+    ber1 = ss.bernoulli(name='base', p=p1).init(sim=sim)
+    ber2 = ss.bernoulli(name='time', p=p2).init(sim=sim)
+    brvs1 = ber1.rvs(uids)
+    brvs2 = ber2.rvs(uids)
+
+    rtol = 0.5 # We're dealing with order-of-magnitude differences but small numbers, so be generous to avoid random failures
+    sum1 = brvs1.sum()*parent_dt
+    sum2 = brvs2.sum()
+    assert np.isclose(sum1, sum2, rtol=rtol), f'Callable Bernoulli sums did not match: {sum1}  ≠  {sum2}'
+    sc.printgreen(f'✓ Callable Bernoulli sums matched: {sum1:n} ≈ {sum2:n}')
+    for key,expected,inds in zip(['young', 'old'], [p_young, p_old], [young, old]):
+        m1 = brvs1[inds].mean()
+        m2 = brvs2[inds].mean()/parent_dt
+        assert np.allclose([expected, m1], [expected, m2], rtol=rtol), f'Callable Bernoulli proportions did not match: {expected:n}  ≠  {m1:n}  ≠  {m2:n}'
+        sc.printgreen(f'✓ Callable Bernoulli proportions matched: {expected:n}  ≈  {m1:n}  ≈  {m2:n}')
+
+    return
+
 
 # %% Run as a script
 if __name__ == '__main__':
     do_plot = True
     sc.options(interactive=do_plot)
-    
+
     T = sc.timer()
-    
+
     o1 = test_dist()
     o2 = test_custom_dists(do_plot=do_plot)
     o3 = test_dists(do_plot=do_plot)
@@ -278,5 +450,7 @@ def test_repeat_slot():
     o7 = test_callable()
     o8 = test_array()
     o9 = test_repeat_slot()
-    
+    o10 = test_timepar_dists()
+    o10 = test_timepar_callable()
+
     T.toc()
diff --git a/tests/test_interventions.py b/tests/test_interventions.py
index 1f7f05e7..0c556ff1 100644
--- a/tests/test_interventions.py
+++ b/tests/test_interventions.py
@@ -8,7 +8,7 @@
 import starsim as ss
 
 
-def run_sir_vaccine(efficacy, leaky=True):
+def run_sir_vaccine(efficacy, leaky=True, do_plot=False):
     # parameters
     v_frac      = 0.5    # fraction of population vaccinated
     total_cases = 500    # total cases at which point we check results
@@ -16,24 +16,24 @@ def run_sir_vaccine(efficacy, leaky=True):
 
     # create a basic SIR sim
     sim = ss.Sim(
-        n_agents = 1000,     
+        n_agents = 1000,
         pars = dict(
           networks = dict(     
-                type = 'random', 
-                n_contacts = 4    
+                type = 'random',
+                n_contacts = 4
           ),
-          diseases = dict(      
-                type      = 'sir',     
-                init_prev = 0.01,  
-                dur_inf   = 0.1,
+          diseases = dict(
+                type      = 'sir',
+                init_prev = 0.01,
+                dur_inf   = ss.dur(10),
                 p_death   = 0,
-                beta      = 6,       
+                beta      = ss.beta(0.06),
           )
         ),
-        n_years = 10,
-        dt      = 0.01
+        dur = 10,
+        dt  = 0.01
     )
-    sim.initialize(verbose=False)
+    sim.init(verbose=False)
     
     # work out who to vaccinate
     in_trial = sim.people.sir.susceptible.uids
@@ -62,7 +62,7 @@ def run_sir_vaccine(efficacy, leaky=True):
     # run the simulation until sufficient cases
     old_cases = []
     for idx in range(1000):
-        sim.step()
+        sim.run_one_step()
         susc = sim.people.sir.susceptible.uids
         cases = np.setdiff1d(in_trial, susc)
         if len(cases) > total_cases:
@@ -84,20 +84,24 @@ def run_sir_vaccine(efficacy, leaky=True):
         assert len(np.intersect1d(vac_cases, in_vac[rel_susc[in_vac] == 1.0])) == len(vac_cases), 'Not all vaccine cases amongst vaccine failures (all or nothing)'
         assert len(np.intersect1d(vac_cases, in_vac[rel_susc[in_vac] == 0.0])) == 0, 'Vaccine cases amongst fully vaccincated (all or nothing)'
 
+    if do_plot:
+        sim.plot()
+
     return sim
 
 
-def test_sir_vaccine_leaky():
-    return run_sir_vaccine(0.3, False)
+def test_sir_vaccine_leaky(do_plot=False):
+    return run_sir_vaccine(0.3, False, do_plot=do_plot)
 
-def test_sir_vaccine_all_or_nothing():
-    return run_sir_vaccine(0.3, True)
+def test_sir_vaccine_all_or_nothing(do_plot=False):
+    return run_sir_vaccine(0.3, True, do_plot=do_plot)
 
 
 if __name__ == '__main__':
     T = sc.timer()
+    do_plot = True
 
-    sir_vaccine_leaky   = test_sir_vaccine_leaky(leaky=True)
-    sir_vaccine_a_or_n  = test_sir_vaccine_all_or_nothing(leaky=False)
+    leaky  = test_sir_vaccine_leaky(do_plot=do_plot)
+    a_or_n = test_sir_vaccine_all_or_nothing(do_plot=do_plot)
 
     T.toc()
diff --git a/tests/test_loop.py b/tests/test_loop.py
new file mode 100644
index 00000000..d438aba2
--- /dev/null
+++ b/tests/test_loop.py
@@ -0,0 +1,55 @@
+"""
+Test the Loop class
+"""
+
+# %% Imports and settings
+import sciris as sc
+import starsim as ss
+
+sc.options(interactive=False) # Assume not running interactively
+
+pars = sc.objdict(
+    dur      = 20,
+    n_agents = 1000,
+    diseases = 'sis',
+    networks = 'random',
+)
+
+
+def test_run_options():
+    sc.heading('Testing run options...')
+    s1 = ss.Sim(pars).init()
+    s2 = ss.Sim(pars).init()
+
+    # Automatic run
+    s1.run()
+
+    # Manual run
+    while s2.loop.index < len(s2.loop):
+        s2.run_one_step()
+    s2.finalize()
+
+    assert s1.summary == s2.summary, 'Sims do not match'
+
+    return s2.loop
+
+
+def test_loop_plotting():
+    sc.heading('Testing loop plotting...')
+    sim = ss.Sim(pars).run()
+    sim.loop.plot()
+    sim.loop.plot_cpu()
+    return sim.loop
+
+
+# %% Run as a script
+if __name__ == '__main__':
+    do_plot = True
+    sc.options(interactive=do_plot)
+    T = sc.timer()
+
+    # Run tests
+    l1 = test_run_options()
+    l2 = test_loop_plotting()
+
+    T.toc()
\ No newline at end of file
diff --git a/tests/test_networks.py b/tests/test_networks.py
index 1ceb0eae..7a5813af 100644
--- a/tests/test_networks.py
+++ b/tests/test_networks.py
@@ -28,11 +28,11 @@ def test_manual():
 
     # Create a maternal network
     sim = ss.Sim(n_agents=n_agents)
-    sim.initialize()
+    sim.init()
     nw2 = ss.MaternalNet()
     nw2.init_pre(sim)
     nw2.add_pairs(mother_inds=[1, 2, 3], unborn_inds=[100, 101, 102], dur=[1, 1, 1])
-    
+
     # Tidy
     o = sc.objdict(nw1=nw1, nw2=nw2)
     return o
@@ -43,21 +43,21 @@ def test_random():
 
     # Manual creation
     nw1 = ss.RandomNet()
-    ss.Sim(n_agents=small, networks=nw1, copy_inputs=False).initialize() # This initializes the network
-    
+    ss.Sim(n_agents=small, networks=nw1, copy_inputs=False).init() # This initializes the network
+
     # Automatic creation as part of sim
-    s2 = ss.Sim(n_agents=small, networks='random').initialize()
+    s2 = ss.Sim(n_agents=small, networks='random').init()
     nw2 = s2.networks[0]
-    
+
     # Increase the number of contacts
     nwdict = dict(type='random', n_contacts=20)
-    s3 = ss.Sim(n_agents=small, networks=nwdict).initialize()
+    s3 = ss.Sim(n_agents=small, networks=nwdict).init()
     nw3 = s3.networks[0]
-    
+
     # Checks
     assert np.array_equal(nw1.p2, nw2.p2), 'Implicit and explicit creation should give the same network'
     assert len(nw3) == len(nw2)*2, 'Doubling n_contacts should produce twice as many contacts'
-    
+
     # Tidy
     o = sc.objdict(nw1=nw1, nw2=nw2, nw3=nw3)
     return o
@@ -99,23 +99,23 @@ def test_ER(n, p, nw, alpha=0.01):
     # Manual creation
     p = 0.1
     nw1 = ss.ErdosRenyiNet(p=p)
-    ss.Sim(n_agents=small, networks=nw1, copy_inputs=False).initialize() # This initializes the network
+    ss.Sim(n_agents=small, networks=nw1, copy_inputs=False).init() # This initializes the network
     test_ER(small, p, nw1)
 
     # Automatic creation as part of sim
-    s2 = ss.Sim(n_agents=small, networks='erdosrenyi').initialize()
+    s2 = ss.Sim(n_agents=small, networks='erdosrenyi').init()
     nw2 = s2.networks[0]
-    
+
     # Larger example with higher p
     p=0.6
     nwdict = dict(type='erdosrenyi', p=p)
-    s3 = ss.Sim(n_agents=medium, networks=nwdict).initialize()
+    s3 = ss.Sim(n_agents=medium, networks=nwdict).init()
     nw3 = s3.networks[0]
     test_ER(medium, p, nw3)
-    
+
     # Checks
     assert np.array_equal(nw1.p2, nw2.p2), 'Implicit and explicit creation should give the same network'
-    
+
     # Tidy
     o = sc.objdict(nw1=nw1, nw2=nw2, nw3=nw3)
     return o
@@ -126,7 +126,7 @@ def test_disk():
 
     # Visualize the path of agents
     nw1 = ss.DiskNet()
-    s1 = ss.Sim(n_agents=5, n_years=50, networks=nw1, copy_inputs=False).initialize() # This initializes the network
+    s1 = ss.Sim(n_agents=5, dur=50, networks=nw1, copy_inputs=False).init() # This initializes the network
 
     if sc.options.interactive:
         # Visualize motion:
@@ -139,15 +139,15 @@ def test_disk():
         cmap = mpl.colormaps['plasma']
         colors = cmap(np.linspace(0, 1, s1.pars.n_agents))
         ax.scatter(nw1.x, nw1.y, s=50, c=colors)
-        for i in range(s1.pars.n_years):
+        for i in range(s1.pars.dur):
             ax.plot([0,1,1,0,0], [0,0,1,1,0], 'k-', lw=1)
             ax.quiver(nw1.x, nw1.y, vdt * np.cos(nw1.theta), vdt * np.sin(nw1.theta), color=colors)
             ax.set_aspect('equal', adjustable='box') #ax.set_xlim([0,1]); ax.set_ylim([0,1])
-            s1.step()
+            s1.run_one_step()
 
     # Simulate SIR on a DiskNet
     nw2 = ss.DiskNet(r=0.15, v=0.05)
-    s2 = ss.Sim(n_agents=small, networks=nw2, diseases='sir').initialize() # This initializes the network
+    s2 = ss.Sim(n_agents=small, networks=nw2, diseases='sir').init() # This initializes the network
     s2.run()
 
     if sc.options.interactive:
@@ -159,23 +159,23 @@ def test_disk():
 
 def test_static():
     sc.heading('Testing static networks')
-    
+
     # Create with p
     p = 0.2
     n = 100
     nc = p*n
     nd1 = dict(type='static', p=p)
-    nw1 = ss.Sim(n_agents=n, networks=nd1).initialize().networks[0]
-    
+    nw1 = ss.Sim(n_agents=n, networks=nd1).init().networks[0]
+
     # Create with n_contacts
     nd2 = dict(type='static', n_contacts=nc)
-    nw2 = ss.Sim(n_agents=n, networks=nd2).initialize().networks[0]
-    
+    nw2 = ss.Sim(n_agents=n, networks=nd2).init().networks[0]
+
     # Check
     assert len(nw1) == len(nw2), 'Networks should be the same length'
     target = n*n*p/2
     assert target/2 < len(nw1) < target*2, f'Network should be approximately length {target}'
-    
+
     # Tidy
     o = sc.objdict(nw1=nw1, nw2=nw2)
     return o
@@ -191,6 +191,25 @@ def test_null():
     return sim
 
 
+def test_other():
+    sc.heading('Other network tests...')
+
+    print('Testing MSM network')
+    msm = ss.MSMNet(participation=0.3)
+    sim = ss.Sim(diseases=dict(type='sis', beta=0.5), networks=msm, copy_inputs=False)
+    sim.run()
+
+    print('Testing other network methods')
+    msm.validate()
+    inds1 = msm.get_inds([0])
+    contacts = msm.find_contacts(inds1['p1'])
+    assert contacts[0] == inds1['p2']
+    inds2 = msm.pop_inds([0])
+    assert inds1 == inds2
+
+    return msm
+
+
 
 # %% Run as a script
 if __name__ == '__main__':
@@ -205,5 +224,6 @@ def test_null():
     erdo = test_erdosrenyi()
     disk = test_disk()
     null = test_null()
+    oth  = test_other()
 
     T.toc()
\ No newline at end of file
diff --git a/tests/test_other.py b/tests/test_other.py
index 06f673a6..be1a5f77 100644
--- a/tests/test_other.py
+++ b/tests/test_other.py
@@ -1,12 +1,11 @@
 """
 Test Starsim features not covered by other test files
 """
-
-# %% Imports and settings
 import sciris as sc
 import numpy as np
 import starsim as ss
-import matplotlib.pyplot as pl
+import matplotlib.pyplot as plt
+import pytest
 
 sc.options(interactive=False) # Assume not running interactively
 
@@ -19,7 +18,7 @@ def test_people():
     sc.heading('Testing people object')
 
     # Base people contains only the states defined in base.base_states
-    ppl = ss.People(small)  # BasePeople
+    ppl = ss.People(small)
     del ppl
 
     # Possible to initialize people with extra states, e.g. a geolocation
@@ -51,15 +50,16 @@ def test_microsim(do_plot=False):
         people=ss.People(small),
         networks=[ss.MFNet(), ss.MaternalNet()],
         demographics=ss.Pregnancy(),
-        diseases=hiv
+        diseases=hiv,
+        copy_inputs = False, # So we can reuse hiv
     )
-    sim.initialize()
+    sim.init()
     sim.run()
 
     if do_plot:
-        pl.figure()
-        pl.plot(sim.tivec, sim.results.hiv.n_infected)
-        pl.title('HIV number of infections')
+        plt.figure()
+        plt.plot(hiv.timevec, hiv.results.n_infected)
+        plt.title('HIV number of infections')
 
     return sim
 
@@ -81,11 +81,11 @@ def init_debut(module, sim, uids):
     gon = ss.Gonorrhea(pars=gon_pars)
 
     sim = ss.Sim(pars=sim_pars, diseases=[gon])
-    sim.initialize()
+    sim.init()
     sim.run()
-    pl.figure()
-    pl.plot(sim.tivec, sim.results.gonorrhea.n_infected)
-    pl.title('Number of gonorrhea infections')
+    plt.figure()
+    plt.plot(sim.timevec, sim.results.gonorrhea.n_infected)
+    plt.title('Number of gonorrhea infections')
 
     return sim
 
@@ -93,14 +93,14 @@ def init_debut(module, sim, uids):
 def test_arrs():
     sc.heading('Testing Arr objects')
     o = sc.objdict()
-    
+
     # Create a sim with only births
     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()
     s2 = ss.Sim(pars=p2).run()
-    
+
     # Tests
     assert len(s1.people.auids) > len(s2.people.auids), 'Should have more people with births'
     assert np.array_equal(s1.people.age, s1.people.age.raw[s1.people.auids]), 'Different ways of indexing age should match'
@@ -109,24 +109,25 @@ def test_arrs():
     assert not np.all(s2.people.alive.raw), 'Some agents should not be alive when indexed like this'
     assert np.array_equal(~s1.people.female, s1.people.male), 'Definition of men does not match'
     assert isinstance(s1.people.age < 5, ss.BoolArr), 'Performing logical operations should return a BoolArr'
-    
+
     o.s1 = s1
-    o.s2 = s2   
-    
+    o.s2 = s2
+
     return o
 
 
 def test_deepcopy():
+    sc.heading('Testing deepcopy')
     s1 = ss.Sim(pars=dict(diseases='sir', networks='embedding'), n_agents=small)
-    s1.initialize()
-    
+    s1.init()
+
     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)
 
@@ -134,8 +135,9 @@ def test_deepcopy():
 
 
 def test_deepcopy_until():
-    s1 = ss.Sim(pars=dict(diseases='sir', networks='embedding'), n_agents=small)
-    s1.initialize()
+    sc.heading('Testing deepcopy with until')
+    s1 = ss.Sim(diseases='sir', networks='embedding', n_agents=small)
+    s1.init()
 
     s1.run(until=5)
 
@@ -151,6 +153,34 @@ def test_deepcopy_until():
     return s1, s2
 
 
+def test_results():
+    sc.heading('Testing results export and plotting')
+    sim = ss.Sim(diseases='sis', networks='random', n_agents=medium)
+    sim.run()
+
+    # Export to dataframe
+    sis = sim.results.sis
+    res = sis.new_infections
+    df = res.to_df()
+    dfs = sis.to_df()
+    assert df.value.sum() == dfs.cum_infections.values[-1] == sim.summary.sis_cum_infections
+
+    # Plot
+    res.plot()
+    sim.results.sis.plot()
+
+    return sim
+
+
+def test_check_reqiures():
+    sc.heading('Testing check_requires')
+    s1 = ss.Sim(diseases='sis', networks='random', n_agents=medium).init()
+    ss.check_requires(s1, 'sis')
+    ss.check_requires(s1, ss.SIS)
+    with pytest.raises(AttributeError):
+        ss.check_requires(s1, ss.SIR)
+    return s1
+
 
 # %% Run as a script
 if __name__ == '__main__':
@@ -167,7 +197,9 @@ def test_deepcopy_until():
     sims = test_arrs()
     sims2 = test_deepcopy()
     sims3 = test_deepcopy_until()
+    sim4 = test_results()
+    sim5 = test_check_reqiures()
 
     sc.toc(T)
-    pl.show()
+    plt.show()
     print('Done.')
\ No newline at end of file
diff --git a/tests/test_plugins.py b/tests/test_plugins.py
index 0bc9030f..d67b052c 100644
--- a/tests/test_plugins.py
+++ b/tests/test_plugins.py
@@ -4,7 +4,7 @@
 
 import sciris as sc
 import numpy as np
-import pylab as pl
+import matplotlib.pyplot as plt
 import starsim as ss
 
 sc.options(interactive=False) # Assume not running interactively
@@ -13,8 +13,9 @@
 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(
+        super().__init__()
+        self.define_pars(
+            label = 'HIV-Syphilis',
             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
@@ -25,8 +26,9 @@ def __init__(self, pars=None, **kwargs):
         self.update_pars(pars, **kwargs)
         return
 
-    def update(self):
+    def step(self):
         """ Specify HIV-syphilis interactions """
+        
         diseases = self.sim.diseases
         syph = diseases.syphilis
         hiv = diseases.hiv
@@ -56,8 +58,9 @@ def __init__(self, year=2020, prob=0.8):
         self.year = year
         return
 
-    def apply(self, sim):
-        if sim.year > self.year:
+    def step(self):
+        sim = self.sim
+        if sim.now > self.year:
             syphilis = sim.diseases.syphilis
 
             # Define who is eligible for treatment
@@ -77,8 +80,8 @@ def apply(self, sim):
 def make_args():
     """ Make people, HIV, syphilis, and network """
     pars = dict(n_agents=2000, 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)
+    mf = ss.MFNet(duration=ss.lognorm_ex(mean=5, std=0.5)) # TODO: think about whether these should be ss.dur(); currently they are not since stored in natural units with -self.dt
+    hiv = ss.HIV(beta={'mf': [0.0008, 0.0004]}, init_prev=0.2) # TODO: beta should wrap the other way
     syph = ss.Syphilis(beta={'mf': [0.1, 0.05]}, init_prev=0.05)
     args = dict(pars=pars, networks=mf, diseases=[hiv, syph])
     return args
@@ -114,29 +117,29 @@ def test_connectors(do_plot=False):
 
     # Plot
     if do_plot:
-        pl.figure()
+        plt.figure()
         
-        pl.subplot(2,1,1)
-        x = sims.con.yearvec
+        plt.subplot(2,1,1)
+        x = sims.con.timevec
         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()
+            plt.plot(x, res.syphilis, label=label)
+        plt.title('Syphilis infections')
+        plt.xlabel('Year')
+        plt.ylabel('Count')
+        plt.axvline(2020)
+        plt.legend()
         
-        pl.subplot(2,1,2)
+        plt.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()
+            plt.plot(x, res.hiv, label=label)
+        plt.title('HIV infections')
+        plt.xlabel('Year')
+        plt.ylabel('Count')
+        plt.axvline(2020)
+        plt.legend()
         
         sc.figlayout()
-        pl.show()
+        plt.show()
     
     # Check results
     for disease in diseases:
@@ -154,4 +157,4 @@ def test_connectors(do_plot=False):
     sims = test_connectors(do_plot=do_plot)
     
     T.toc()
-    
\ No newline at end of file
+    
diff --git a/tests/test_randomness.py b/tests/test_randomness.py
index 58644177..5d868022 100644
--- a/tests/test_randomness.py
+++ b/tests/test_randomness.py
@@ -5,9 +5,9 @@
 # %% Imports and settings
 import numpy as np
 import sciris as sc
-import starsim as ss
 import scipy.stats as sps
-import matplotlib.pyplot as pl
+import matplotlib.pyplot as plt
+import starsim as ss
 
 n = 5 # Default number of samples
 
@@ -18,10 +18,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
+    sim = ss.Sim(n_agents=100).init() # Need an empty sim to initialize properly
     distlist = [make_dist(), make_dist()]
     dists = ss.Dists(distlist)
-    dists.initialize(sim=sim)
+    dists.init(sim=sim)
     return dists
 
 
@@ -112,27 +112,35 @@ def test_order(n=n):
 class CountInf(ss.Intervention):
     """ Store every infection state in a timepoints x people array """
     def init_pre(self, sim):
+        super().init_pre(sim)
         n_agents = len(sim.people)
         self.arr = np.zeros((sim.npts, n_agents))
         self.n_agents = n_agents
         return
     
-    def apply(self, sim):
-        self.arr[sim.ti, :] = np.array(sim.diseases.sir.infected)[:self.n_agents]
+    def step(self):
+        self.arr[self.sim.ti, :] = np.array(self.sim.diseases.sir.infected)[:self.n_agents]
         return
 
 
 class OneMore(ss.Intervention):
     """ Add one additional agent and infection """
+    def __init__(self, ti_apply=10):
+        super().__init__()
+        self.ti_apply = ti_apply
+        return
+    
     def init_pre(self, sim):
+        super().init_pre(sim)
         one_birth = ss.Pregnancy(name='one_birth', rel_fertility=0) # Ensure no default births
         one_birth.init_pre(sim)
         self.one_birth = one_birth
         return
     
-    def apply(self, sim, ti=10):
+    def step(self):
         """ Create an extra agent """
-        if sim.ti == ti:
+        sim = self.sim
+        if sim.ti == self.ti_apply:
             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
             
@@ -144,7 +152,7 @@ def apply(self, sim, ti=10):
             # Reset the random states
             p = sir.pars
             for dist in [p.dur_inf, p.p_death]:
-                dist.jump(delta=-1) # Reset RNG as if this never happened
+                dist.jump_dt(force=True) # Already has correct dt value, but we need to force going back in time
 
         return
 
@@ -154,20 +162,20 @@ def plot_infs(s1, s2):
     a1 = s1.interventions.countinf.arr
     a2 = s2.interventions.countinf.arr
     
-    fig = pl.figure()
-    pl.subplot(1,3,1)
-    pl.pcolormesh(a1.T)
-    pl.xlabel('Timestep')
-    pl.ylabel('Person')
-    pl.title('Baseline')
+    fig = plt.figure()
+    plt.subplot(1,3,1)
+    plt.pcolormesh(a1.T)
+    plt.xlabel('Timestep')
+    plt.ylabel('Person')
+    plt.title('Baseline')
     
-    pl.subplot(1,3,2)
-    pl.pcolormesh(a2.T)
-    pl.title('OneMore')
+    plt.subplot(1,3,2)
+    plt.pcolormesh(a2.T)
+    plt.title('OneMore')
     
-    pl.subplot(1,3,3)
-    pl.pcolormesh(a2.T - a1.T)
-    pl.title('Difference')
+    plt.subplot(1,3,3)
+    plt.pcolormesh(a2.T - a1.T)
+    plt.title('Difference')
     
     sc.figlayout()
     return fig
@@ -181,7 +189,7 @@ def test_worlds(do_plot=False):
     
     pars = dict(
         start = 2000,
-        end = 2100,
+        stop = 2100,
         n_agents = 200,
         verbose = 0.05,
         diseases = dict(
@@ -210,7 +218,7 @@ def test_worlds(do_plot=False):
     if do_plot:
         s1.plot()
         plot_infs(s1, s2)
-        pl.show()
+        plt.show()
     
     l1 = len(s1.people)
     l2 = len(s2.people)
@@ -242,7 +250,7 @@ def test_independence(do_plot=False, thresh=0.1):
             dict(type='mf', debut=ss.constant(0), participation=0.5), # To avoid age correlations
         ]
     )
-    sim.initialize()
+    sim.init()
     
     # Assemble measures
     st = sim.people.states
@@ -268,16 +276,16 @@ def test_independence(do_plot=False, thresh=0.1):
                 
     # Optionally plot
     if do_plot:
-        pl.figure()
-        pl.imshow(stats)
+        plt.figure()
+        plt.imshow(stats)
         ticks = np.arange(n)
         labels = arrs.keys()
-        pl.xticks(ticks, labels)
-        pl.yticks(ticks, labels)
-        pl.xticks(rotation=15)
-        pl.colorbar()
+        plt.xticks(ticks, labels)
+        plt.yticks(ticks, labels)
+        plt.xticks(rotation=15)
+        plt.colorbar()
         sc.figlayout()
-        pl.show()
+        plt.show()
             
     # Test that everything is independent
     max_corr = abs(stats).max()
@@ -293,15 +301,15 @@ def test_combine_rands(do_plot=False):
     np.random.seed(2)
     a = np.random.randint(np.iinfo(np.uint64).max, size=n, dtype=np.uint64)
     b = np.random.randint(np.iinfo(np.uint64).max, size=n, dtype=np.uint64)
-    c = ss.combine_rands(a, b)
+    c = ss.utils.combine_rands(a, b)
     if do_plot:
-        pl.figure()
+        plt.figure()
         for i,k,v in sc.objdict(a=a,b=b,combined=c).enumitems():
-            pl.subplot(3,1,i+1)
-            pl.hist(v)
-            pl.title(k)
+            plt.subplot(3,1,i+1)
+            plt.hist(v)
+            plt.title(k)
         sc.figlayout()
-        pl.show()
+        plt.show()
     
     mean = c.mean()
     assert np.isclose(mean, target, atol=atol), f'Expected value to be 0.5±{atol}, not {mean}'
diff --git a/tests/test_run.py b/tests/test_run.py
index f9f47dbe..42df73b4 100644
--- a/tests/test_run.py
+++ b/tests/test_run.py
@@ -32,7 +32,7 @@ def test_parallel():
     msim = ss.MultiSim([ss.Sim(pars, label='Sim1'), ss.Sim(pars, label='Sim2')])
     msim.run(keep_people=True)
     s1, s2 = msim.sims
-    assert np.allclose(s1.summary[:], s2.summary[:], rtol=0, atol=0, equal_nan=True)
+    assert np.allclose(s1.summary[:], s2.summary[:], rtol=0, atol=0, equal_nan=True), "Sims don't match and should"
 
     # Check that two non-identical sims don't match
     pars2 = sc.dcp(pars)
@@ -40,7 +40,7 @@ def test_parallel():
     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)
+    assert not np.allclose(s1.summary[:], s2.summary[:], rtol=0, atol=0, equal_nan=True), "Sims do match and shouldn't"
 
     return s1, s2
 
@@ -50,31 +50,62 @@ def test_multisim():
     sc.heading('Testing MultiSim')
     pars = make_sim_pars()
     msim = ss.MultiSim(ss.Sim(pars))
+    msim.init_sims()
     msim.run(n_runs=4)
-    
+
     # Plot individual sims
     msim.plot()
-    
+
     # Reduce and plot mean
     msim.mean()
     msim.plot()
-    
+
     # Reduce and plot median
     msim.median()
     msim.plot()
 
+    # Other methods
+    msim.show()
+    msim.summarize()
+    msim.reset()
+
     return msim
 
 
+def test_other():
+    """ Check other run options """
+    sc.heading('Testing other run options')
+    # Check parallel
+    pars = make_sim_pars()
+    pars2 = sc.dcp(pars)
+    pars2.diseases.beta *= 2
+    s1 = ss.Sim(pars)
+    s2 = ss.Sim(pars2)
+    ss.parallel(s1, s2, inplace=False)
+    assert not s1.initialized
+    ss.parallel(s1, s2, inplace=True)
+    assert not np.allclose(s1.summary[:], s2.summary[:], rtol=0, atol=0, equal_nan=True), "Sims do match and shouldn't"
+
+    # Check single run
+    s3 = ss.Sim(pars)
+    s4 = ss.Sim(pars)
+    s3 = ss.single_run(s3)
+    s4 = ss.single_run(s4, n_agents=2000)
+    assert not np.allclose(s3.summary[:], s4.summary[:], rtol=0, atol=0, equal_nan=True), "Sims do match and shouldn't"
+
+    return s3,s4
+
+
 if __name__ == '__main__':
     do_plot = True
     sc.options(interactive=do_plot)
     T = sc.timer()
 
-    # s1, s2 = test_parallel()
+    s1, s2 = test_parallel()
     msim = test_multisim()
+    s3,s4 = test_other()
 
     T.toc()
-    
+
     if do_plot:
         plt.show()
diff --git a/tests/test_samples.py b/tests/test_samples.py
index 105662a4..f39c7379 100644
--- a/tests/test_samples.py
+++ b/tests/test_samples.py
@@ -14,9 +14,9 @@ def get_outputs(p_death):
         ppl = ss.People(1000)
         network = ss.RandomNet(n_contacts=ss.poisson(5))
         sir = ss.SIR(pars={'p_death':p_death})
-        sim = ss.Sim(people=ppl, networks=network, diseases=sir, rand_seed=0, n_years=5)
+        sim = ss.Sim(people=ppl, networks=network, diseases=sir, rand_seed=0, dur=5)
         sim.run(verbose=0)
-        df = sim.export_df()
+        df = sim.to_df()
         summary = {}
         summary['seed'] = sim.pars['rand_seed']
         summary['p_death'] = p_death
diff --git a/tests/test_sim.py b/tests/test_sim.py
index b45e127c..9c8fef99 100644
--- a/tests/test_sim.py
+++ b/tests/test_sim.py
@@ -35,11 +35,11 @@ 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 
+
+    # Test explicit
     sir = ss.SIR()
     net = ss.RandomNet()
     s3 = ss.Sim(diseases=sir, networks=net).run()
@@ -70,57 +70,57 @@ def test_simple(do_plot=do_plot):
 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
+    d2 = ss.lognorm_ex(mean=10, std=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
+    s7 = ss.Sim(diseases=dict(type='sir', dur_inf=dict(mean=10, std=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()
-    
+        ss.Sim(diseases=dict(type='sir', init_prev=dict(type='normal', loc=10)), **kw).init()
+
     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(
+    pars1 = dict(
         n_agents = 1000,
         label = 'v1',
         verbose = 'brief',
-        end = 2020,
+        stop = 2020,
         networks = [
             ss.RandomNet(name='random1', n_contacts=6),
             dict(type='random', name='random2', n_contacts=4)
@@ -135,35 +135,35 @@ def jump_age(sim):
         ],
         interventions = jump_age,
     )
-    
+
     # Test with explicit initialization
-    pars = ss.SimPars(n_agents=1000, label='v1', verbose='brief', end=2020)
-    
+    pars2 = ss.SimPars(n_agents=1000, label='v1', verbose='brief', stop=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)
-    
+    s1 = ss.Sim(pars1)
+    s2 = ss.Sim(pars=pars2, 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
 
 
@@ -179,14 +179,14 @@ def test_simple_vax(do_plot=do_plot):
     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.plot(sim_base.timevec, sim_base.results.sir.prevalence, label='Baseline')
+        plt.plot(sim_intv.timevec, sim_intv.results.sir.prevalence, label='Vax')
         plt.axvline(x=2015, color='k', ls='--')
         plt.title('Prevalence')
         plt.legend()
@@ -197,12 +197,12 @@ def test_simple_vax(do_plot=do_plot):
 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')
@@ -211,16 +211,16 @@ def test_shared_product(do_plot=do_plot):
     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.plot(sim.timevec, 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
@@ -239,11 +239,39 @@ def test_components(do_plot=do_plot):
     return sim
 
 
+def test_save():
+    """ Test save and export """
+    sc.heading('Testing save and export...')
+    f = sc.objdict() # Filenames
+    f.sim  = 'temp.sim'
+    f.json = 'sim.json'
+    sim = ss.Sim(n_agents=n_agents, diseases='sis', networks='random').run()
+
+    # Run the methods
+    sim.save(filename=f.sim)
+    sim.to_json(filename=f.json)
+    json = sim.to_json()
+
+    # Run methods
+    s2 = sc.load(f.sim)
+    json2 = sc.loadjson(f.json)
+
+    # Run tests
+    assert sim.summary == s2.summary, 'Sims do not match'
+    assert sim.pars.n_agents == json2['pars']['n_agents'], 'Parameters do not match'
+    assert json == json2, 'Outputs do not match'
+
+    # Delete files
+    sc.rmpath(f.values())
+
+    return sim
+
+
 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)
@@ -252,8 +280,9 @@ def test_components(do_plot=do_plot):
     sim5b, sim5i = test_simple_vax(do_plot=do_plot)
     sim6 = test_shared_product(do_plot=do_plot)
     sim7 = test_components(do_plot=do_plot)
+    sim8 = test_save()
 
     T.toc()
-    
+
     if do_plot:
         plt.show()
diff --git a/tests/test_syphilis.py b/tests/test_syphilis.py
index 18981d1b..3971de33 100644
--- a/tests/test_syphilis.py
+++ b/tests/test_syphilis.py
@@ -37,7 +37,7 @@ def make_syph_sim(dt=1, n_agents=500):
         dt=dt,
         total_pop=93963392,
         start=1990,
-        n_years=40,
+        dur=40,
         people=ppl,
         diseases=syph,
         networks=ss.ndict(mf, maternal),
@@ -56,13 +56,11 @@ def __init__(self, *args, **kwargs):
         self.okay = True
         return
 
-    def update_results(self, sim):
-        return self.apply(sim)
-
-    def apply(self, sim):
+    def update_results(self):
         """
         Checks states that should be mutually exlusive and collectively exhaustive
         """
+        sim = self.sim
         sppl = sim.diseases.syphilis
 
         # Infection states: people must be exactly one of these
@@ -93,13 +91,14 @@ def test_syph(dt=1, n_agents=500, do_plot=False):
 
     # Check plots
     burnin = 0
-    pi = int(burnin/sim.dt)
+    pi = int(burnin/dt)
     
     if do_plot:
+        tvec = sim.timevec[pi:]
         fig, ax = plt.subplots(2, 2)
         ax = ax.ravel()
         ax[0].stackplot(
-            sim.yearvec[pi:],
+            tvec,
             # sim.results.syphilis.n_susceptible[pi:],
             sim.results.syphilis.n_congenital[pi:],
             sim.results.syphilis.n_exposed[pi:],
@@ -110,13 +109,13 @@ def test_syph(dt=1, n_agents=500, do_plot=False):
         )
         ax[0].legend(['Congenital', 'Exposed', 'Primary', 'Secondary', 'Latent', 'Tertiary'], loc='lower right')
     
-        ax[1].plot(sim.yearvec[pi:], sim.results.syphilis.prevalence[pi:])
+        ax[1].plot(tvec, sim.results.syphilis.prevalence[pi:])
         ax[1].set_title('Syphilis prevalence')
     
-        ax[2].plot(sim.yearvec[pi:], sim.results.n_alive[pi:])
+        ax[2].plot(tvec, sim.results.n_alive[pi:])
         ax[2].set_title('Population')
     
-        ax[3].plot(sim.yearvec[pi:], sim.results.syphilis.new_infections[pi:])
+        ax[3].plot(tvec, sim.results.syphilis.new_infections[pi:])
         ax[3].set_title('New infections')
     
         fig.tight_layout()
@@ -138,7 +137,7 @@ def test_syph_intvs(dt=1, n_agents=500, do_plot=False):
         label='syph_screening',
     )
 
-    treat_eligible = lambda sim: ss.uids(sim.get_intervention('syph_screening').outcomes['positive'])
+    treat_eligible = lambda sim: ss.uids(sim.interventions['syph_screening'].outcomes['positive'])
     bpg = ss.syph_treatment(
         prob=0.9,
         product='bpg',
@@ -158,10 +157,12 @@ def test_syph_intvs(dt=1, n_agents=500, do_plot=False):
         sim_base.run()
 
         burnin = 10
-        pi = int(burnin/sim_base.dt)
+        syph_b = sim_base.diseases.syphilis
+        syph_i = sim_intv.diseases.syphilis
+        pi = int(burnin/syph_b.dt)
         plt.figure()
-        plt.plot(sim_base.yearvec[pi:], sim_base.results.syphilis.prevalence[pi:], label='Baseline')
-        plt.plot(sim_intv.yearvec[pi:], sim_intv.results.syphilis.prevalence[pi:], label='S&T')
+        plt.plot(syph_b.timevec[pi:], syph_b.results.prevalence[pi:], label='Baseline')
+        plt.plot(syph_i.timevec[pi:], syph_i.results.prevalence[pi:], label='S&T')
         plt.axvline(x=2020, color='k', ls='--')
         plt.title('Syphilis prevalence')
         plt.legend()
diff --git a/tests/test_time.py b/tests/test_time.py
new file mode 100644
index 00000000..0e758e9d
--- /dev/null
+++ b/tests/test_time.py
@@ -0,0 +1,152 @@
+"""
+Test different time units and timesteps
+"""
+
+import numpy as np
+import sciris as sc
+import starsim as ss
+
+small = 100
+medium = 1000
+sc.options(interactive=False)
+
+
+# %% Define the tests
+def test_ratio():
+    sc.heading('Test behavior of time_ratio()')
+    
+    assert ss.time_ratio() == 1.0
+    assert ss.time_ratio(dt1=1.0, dt2=0.1) == 10.0
+    assert ss.time_ratio(dt1=0.5, dt2=5) == 0.1
+    assert ss.time_ratio(dt1=0.5, dt2=5) == 0.1
+    
+    assert ss.time_ratio(unit1='year', unit2='day') == 365
+    assert ss.time_ratio(unit1='day', unit2='year') == 1/365
+    
+    return
+
+
+def test_classes():
+    sc.heading('Test behavior of dur() and rate()')
+    
+    # Test duration dt
+    d1 = ss.dur(2)
+    d2 = ss.dur(3)
+    d3 = ss.dur(2, parent_dt=0.1)
+    d4 = ss.dur(3, parent_dt=0.2)
+    for d in [d1,d2,d3,d4]: d.init()
+    
+    assert d1 + d2 == 2+3
+    assert d3 + d4 == 2/0.1 + 3/0.2
+    assert d3 * 2 == 2/0.1*2
+    assert d3 / 2 == 2/0.1/2
+    
+    # Test rate dt
+    r1 = ss.rate(2)
+    r2 = ss.rate(3)
+    r3 = ss.rate(2, parent_dt=0.1)
+    r4 = ss.rate(3, parent_dt=0.2)
+    for r in [r1,r2,r3,r4]: r.init()
+    
+    assert r1 + r2 == 2 + 3
+    assert r3 + r4 == 2*0.1 + 3*0.2
+    assert r3 * 2 == 2*0.1*2
+    assert r3 / 2 == 2*0.1/2
+    
+    # Test duration units
+    d5 = ss.dur(2, unit='year').init(parent_unit='day')
+    d6 = ss.dur(3, unit='day').init(parent_unit='day')
+    assert d5 + d6 == 2*365+3
+    
+    # Test rate units
+    rval = 0.7
+    r5 = ss.rate(rval, unit='week').init(parent_unit='day')
+    r6 = ss.rate(rval).init(parent_dt=0.1)
+    assert np.isclose(r5.values, rval/7) # A limitation of this approach, not exact!
+    assert np.isclose(r6.values, rval/10)
+    
+    # Test time_prob
+    tpval = 0.1
+    tp0 = ss.time_prob(tpval).init(parent_dt=1.0)
+    tp1 = ss.time_prob(tpval).init(parent_dt=0.5)
+    tp2 = ss.time_prob(tpval).init(parent_dt=2)
+    assert np.isclose(tp0.values, tpval)
+    assert np.isclose(tp1.values, tpval/2, rtol=0.1)
+    assert np.isclose(tp2.values, tpval*2, rtol=0.1)
+    assert tp1.values > tpval/2
+    assert tp2.values < tpval*2
+    
+    return d3, d4, r3, r4, tp1
+    
+
+def test_units(do_plot=False):
+    sc.heading('Test behavior of year vs day units')
+
+    sis = ss.SIS(
+        beta = ss.beta(0.05, 'day'),
+        init_prev = ss.bernoulli(p=0.1),
+        dur_inf = ss.lognorm_ex(mean=ss.dur(10, 'day')),
+        waning = ss.rate(0.05, 'day'),
+        imm_boost = 1.0,
+    )
+
+    rnet = ss.RandomNet(
+        n_contacts = 10,
+        dur = 0, # Note; network edge durations are required to have the same unit as the network
+    )
+
+    pars = dict(
+        diseases = sis,
+        networks = rnet,
+        n_agents = small,
+    )
+    
+    sims = sc.objdict()
+    sims.y = ss.Sim(pars, unit='year', label='Year', start=2000, stop=2002, dt=1/365)
+    sims.d = ss.Sim(pars, unit='day', label='Day', start='2000-01-01', stop='2002-01-01', dt=1)
+    
+    for sim in sims.values():
+        sim.run()
+        if do_plot:
+            sim.plot()
+    
+    # Check that results match to within stochastic uncertainty
+    rtol = 0.05
+    vals = [sim.summary.sis_cum_infections for sim in [sims.y, sims.d]]
+    assert np.isclose(*vals, rtol=rtol), f'Values for cum_infections do not match ({vals})'
+        
+    return sims
+
+
+def test_multi_timestep(do_plot=False):
+    sc.heading('Test behavior of different modules having different timesteps')
+    
+    pars = dict(
+        diseases = ss.SIS(unit='day', dt=1.0, init_prev=0.1, beta=ss.beta(0.01)),
+        demographics = ss.Births(unit='year', dt=0.25),
+        networks = ss.RandomNet(unit='week'),
+        n_agents = small,
+    )
+    
+    sim = ss.Sim(pars, unit='day', dt=2, start='2000-01-01', stop='2002-01-01')
+    sim.run()
+    
+    if do_plot:
+        sim.plot()
+        
+    return sim
+
+
+# %% Run as a script
+if __name__ == '__main__':
+    do_plot = True
+    sc.options(interactive=do_plot)
+    
+    T = sc.timer()
+    
+    o1 = test_ratio()
+    o2 = test_classes()
+    o3 = test_units(do_plot)
+    o4 = test_multi_timestep(do_plot)
+    
+    T.toc()
diff --git a/tests/update_baseline.py b/tests/update_baseline.py
index 67af0b86..39b85147 100755
--- a/tests/update_baseline.py
+++ b/tests/update_baseline.py
@@ -1,10 +1,9 @@
 #!/usr/bin/env python3
-
-'''
+"""
 Run this script to regenerate the baseline.
-'''
+"""
 
-import test_baselines as tb
+import test_baselines
 
-tb.save_baseline()
-tb.test_benchmark(do_save=True, repeats=5)
+test_baselines.save_baseline()
+test_baselines.test_benchmark(do_save=True, repeats=5)