diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 7a3c1d6a0..5d5797c30 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -19,12 +19,13 @@ jobs: python-version: ${{ matrix.python-version }} architecture: x64 - name: Install Covasim - run: python setup.py develop + run: pip install -e . + - name: Install tests + working-directory: ./tests + run: pip install -r requirements_test.txt - name: Run integration tests working-directory: ./tests - run: | - pip install pytest - pytest test*.py --durations=0 # Run actual tests + run: pytest -v test_*.py --workers auto --durations=0 - name: Run unit tests working-directory: ./tests/unittests - run: pytest test*.py --durations=0 # Run actual tests + run: pytest -v test_*.py --workers auto --durations=0 diff --git a/.readthedocs.yml b/.readthedocs.yml index 9f258f6bb..7e36f3990 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -24,7 +24,7 @@ python: version: 3.8 install: - requirements: docs/requirements.txt - - method: setuptools + - method: pip path: . system_packages: true diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 7ca4524a6..04a97ebef 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -9,23 +9,250 @@ All notable changes to the codebase are documented in this file. Changes that ma :depth: 1 -~~~~~~~~~~~~~~~~~~~~ -Future release plans -~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~ +Coming soon +~~~~~~~~~~~ These are the major improvements we are currently working on. If there is a specific bugfix or feature you would like to see, please `create an issue `__. -- Improved handling of vaccination, including more detailed targeting options, waning immunity, etc. -- Mechanistic handling of different strains -- Additional flexibility in plotting options (e.g. date ranges, per-plot DPI) -- Expanded tutorials (health care workers, vaccination, calibration, exercises, etc.) +- Continued updates to vaccine and variant parameters and workflows +- Multi-region and geographical support +- Economics and costing analysis ~~~~~~~~~~~~~~~~~~~~~~~ -Latest versions (2.0.x) +Latest versions (3.0.x) ~~~~~~~~~~~~~~~~~~~~~~~ +Version 3.0.3 (2021-05-17) +-------------------------- +- Added a new class, ``cv.Calibration``, that can perform automatic calibration. Simplest usage is ``sim.calibrate(calib_pars)``. Note: this requires Optuna, which is not installed by default; please install separately via ``pip install optuna``. See the updated calibration tutorial for more information. +- Added a new result, ``known_deaths``, which counts only deaths among people who have been diagnosed. +- Updated several vaccine and variant parameters (e.g., B1.351 and B117 cross-immunity). +- ``sim.compute_fit()`` now returns the fit by default, and creates ``sim.fit`` (previously, this was stored in ``sim.results.fit``). +- *Regression information*: Calls to ``sim.results.fit`` should be replaced with ``sim.fit``. The ``output`` parameter for ``sim.compute_fit()`` has been removed since it now always outputs the ``Fit`` object. +- *GitHub info*: PR `1047 `__ + + +Version 3.0.2 (2021-04-26) +-------------------------- +- Added Novavax as one of the default vaccines. +- If ``use_waning=True``, people will now become *undiagnosed* when they recover (so they are not incorrectly marked as diagnosed if they become reinfected). +- Added a new method, ``sim.to_df()``, that exports results to a pandas dataframe. +- Added ``people.lock()`` and ``people.unlock()`` methods, so you do not need to set ``people._lock`` manually. +- Added extra parameter checking to ``people.set_pars(pars)``, so ``pop_size`` is guaranteed to be an integer. +- Flattened ``sim['immunity']`` to no longer have separate axes for susceptible, symptomatic, and severe. +- Fixed a bug in ``cv.sequence()``, introduced in version 2.1.2, that meant it would only ever trigger the last intervention. +- Fixed a bug where if subtargeting was used with ``cv.vaccinate()``, it would trigger on every day. +- Fixed ``msim.compare()`` to be more careful about not converting all results to integers. +- *Regression information*: If you are using waning, ``sim.people.diagnosed`` no longer refers to everyone who has ever been diagnosed, only those still infectious. You can use ``sim.people.defined('date_diagnosed')`` in place of ``sim.people.true('diagnosed')`` (before these were identical). +- *GitHub info*: PR `1020 `__ + + +Version 3.0.1 (2021-04-16) +-------------------------- +- Immunity and vaccine parameters have been updated. +- The ``People`` class has been updated to remove parameters that were copied into attributes; thus there is no longer both ``people.pars['pop_size']`` and ``people.pop_size``; only the former. Recommended practice is to use ``len(people)`` to get the number of people. +- Loaded population files can now be used with more than one strain; arrays will be resized automatically. If there is a mismatch in the number of people, this will *not* be automatically resized. +- A bug was fixed with the ``rescale`` argument to ``cv.strain()`` not having any effect. +- Dead people are no longer eligible to be vaccinated. +- *Regression information*: Any user scripts that call ``sim.people.pop_size`` should be updated to call ``len(sim.people)`` (preferred), or ``sim.n``, ``sim['pop_size']``, or ``sim.people.pars['pop_size']``. +- *GitHub info*: PR `999 `__ + + +Version 3.0.0 (2021-04-13) +-------------------------- +This version introduces fully featured vaccines, variants, and immunity. **Note:** These new features are still under development; please use with caution and email us at covasim@idmod.org if you have any questions or issues. We expect there to be several more releases over the next few weeks as we refine these new features. + +Highlights +^^^^^^^^^^ +- **Model structure**: The model now follows an "SEIS"-type structure, instead of the previous "SEIR" structure. This means that after recovering from an infection, agents return to the "susceptible" compartment. Each agent in the simulation has properties ``sus_imm``, ``trans_imm`` and ``prog_imm``, which respectively determine their immunity to acquiring an infection, transmitting an infection, or developing a more severe case of COVID-19. All these immunity levels are initially zero. They can be boosted by either natural infection or vaccination, and thereafter they can wane over time or remain permanently elevated. +- **Multi-strain modeling**: Model functionality has been extended to allow for modeling of multiple different co-circulating strains with different properties. This means you can now do e.g. ``b117 = cv.strain('b117', days=1, n_imports=20)`` followed by ``sim = cv.Sim(strains=b117)`` to import strain B117. Further examples are contained in ``tests/test_immunity.py`` and in Tutorial 8. +- **New methods for vaccine modeling**: A new ``cv.vaccinate()`` intervention has been added, which allows more flexible modeling of vaccinations. Vaccines, like natural infections, are assumed to boost agents' immunity. +- **Consistency**: By default, results from Covasim 3.0.0 should exactly match Covasim 2.1.2. To use the new features, you will need to manually specify ``cv.Sim(use_waning=True)``. +- **Still TLDR?** Here's a quick showcase of the new features: + +.. code-block:: python + + import covasim as cv + + pars = dict( + use_waning = True, # Use the new immunity features + n_days = 180, # Set the days, as before + n_agents = 50e3, # New alias for pop_size + scaled_pop = 200e3, # New alternative to specifying pop_scale + strains = cv.strain('b117', days=20, n_imports=20), # Introduce B117 + interventions = cv.vaccinate('astrazeneca', days=80), # Create a vaccine + ) + + cv.Sim(pars).run().plot('strain') # Create, run, and plot strain results + +Immunity-related parameter changes +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +- A new control parameter, ``use_waning``, has been added that controls whether to use new waning immunity dynamics ("SEIS" structure) or the old dynamics where post-infection immunity was perfect and did not wane ("SEIR" structure). By default, ``use_waning=False``. +- A subset of existing parameters have been made strain-specific, meaning that they are allowed to differ by strain. These include: ``rel_beta``, which specifies the relative transmissibility of a new strain compared to the wild strain; ``rel_symp_prob``, ``rel_severe_prob``, ``rel_crit_prob``, and the newly-added immunity parameters ``rel_imm`` (see next point). The list of parameters that can vary by strain is specified in ``defaults.py``. +- The parameter ``n_strains`` is an integer that specifies how many strains will be in circulation at some point during the course of the simulation. +- Seven new parameters have been added to characterize agents' immunity levels: + - The parameter ``nab_init`` specifies a distribution for the level of neutralizing antibodies that agents have following an infection. These values are on log2 scale, and by default they follow a normal distribution. + - The parameter ``nab_decay`` is a dictionary specifying the kinetics of decay for neutralizing antibodies over time. + - The parameter ``nab_kin`` is constructed during sim initialization, and contains pre-computed evaluations of the nab decay functions described above over time. + - The parameter ``nab_boost`` is a multiplicative factor applied to a person's nab levels if they get reinfected. + - The parameter ``cross_immunity``. By default, infection with one strain of SARS-CoV-2 is assumed to grant 50% immunity to infection with a different strain. This default assumption of 50% cross-immunity can be modified via this parameter (which will then apply to all strains in the simulation), or it can be modified on a per-strain basis using the ``immunity`` parameter described below. + - The parameter ``immunity`` is a matrix of size ``total_strains`` by ``total_strains``. Row ``i`` specifies the immunity levels that people who have been infected with strain ``i`` have to other strains. + - The parameter ``rel_imm`` is a dictionary with keys ``asymp``, ``mild`` and ``severe``. These contain scalars specifying the relative immunity levels for someone who had an asymptomatic, mild, or severe infection. By default, values of 0.98, 0.99, and 1.0 are used. +- The parameter ``strains`` contains information about any circulating strains that have been specified as additional to the default strain. This is initialized as an empty list and then populated by the user. + +Other parameter changes +^^^^^^^^^^^^^^^^^^^^^^^ +- The parameter ``frac_susceptible`` will initialize the simulation with less than 100% of the population to be susceptible to COVID (to represent, for example, a baseline level of population immunity). Note that this is intended for quick explorations only, since people are selected at random, whereas in reality higher-risk people will typically be infected first and preferentially be immune. This is primarily designed for use with ``use_waning=False``. +- The parameter ``scaled_pop``, if supplied, can be used in place of ``pop_scale`` or ``pop_size``. For example, if you specify ``cv.Sim(pop_size=100e3, scaled_pop=550e3)``, it will automatically calculate ``pop_scale=5.5``. +- Aliases have been added for several parameters: ``pop_size`` can also be supplied as ``n_agents``, and ``pop_infected`` can also be supplied as ``init_infected``. This only applies when creating a sim; otherwise, the default names will be used for these parameters. + +Changes to states and results +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +- Several new states have been added, such as ``people.naive``, which stores whether or not a person has ever been exposed to COVID before. +- New results have been added to store information by strain, as well as population immunity levels. In addition to new entries in ``sim.results``, such as ``pop_nabs`` (population level neutralizing antibodies) and ``new_reinfections``, there is a new set of results ``sim.results.strain``: ``cum_infections_by_strain``, ``cum_infectious_by_strain``, ``new_infections_by_strain``, ``new_infectious_by_strain``, ``prevalence_by_strain``, ``incidence_by_strain``. + +New functions, methods and classes +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +- The newly-added file ``immunity.py`` contains functions, methods, and classes related to calculating immunity. This includes the ``strain`` class (which uses lowercase convention like Covasim interventions, which are also technically classes). +- A new ``cv.vaccinate()`` intervention has been added. Compared to the previous ``vaccine`` intervention (now renamed ``cv.simple_vaccine()``), this new intervention allows vaccination to boost agents' immunity against infection, transmission, and progression. +- There is a new ``sim.people.make_nonnaive()`` method, as the opposite of ``sim.people.make_naive()``. +- New functions ``cv.iundefined()`` and ``cv.iundefinedi()`` have been added for completeness. +- A new function ``cv.demo()`` has been added as a shortcut to ``cv.Sim().run().plot()``. +- There are now additional shortcut plotting methods, including ``sim.plot('strain')`` and ``sim.plot('all')``. + +Renamed functions and methods +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +- ``cv.vaccine()`` is now called ``cv.simple_vaccine()``. +- ``cv.get_sim_plots()`` is now called ``cv.get_default_plots()``; ``cv.get_scen_plots()`` is now ``cv.get_default_plots(kind='scen')``. +- ``sim.people.make_susceptible()`` is now called ``sim.people.make_naive()``. + +Bugfixes +^^^^^^^^ +- ``n_imports`` now scales correctly with population scale (previously they were unscaled). +- ``cv.ifalse()`` and related functions now work correctly with non-boolean arrays (previously they used the ``~`` operator instead of ``np.logical_not()``, which gave incorrect results for int or float arrays). +- Interventions and analyzers are now deep-copied when supplied to a sim; this means that the same ones can be created and then used in multiple sims. Scenarios also now deep-copy their inputs. + +Regression information +^^^^^^^^^^^^^^^^^^^^^^ +- As noted above, with ``cv.Sim(use_waning=False)`` (the default), results should be the same as Covasim 2.1.2, except for new results keys mentioned above (which will mostly be zeros, since they are only populated with immunity turned on). +- Scripts using ``cv.vaccine()`` should be updated to use ``cv.simple_vaccine()``. +- Scripts calling ``sim.people.make_susceptible()`` should now call ``sim.people.make_naive()``. +- *GitHub info*: PR `927 `__ + + + +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Versions 2.x (2.0.0 – 2.1.2) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + +Version 2.1.2 (2021-03-31) +-------------------------- + +- Interventions and analyzers now accept a function as an argument to ``days`` or e.g. ``start_day``. For example, instead of defining ``start_day=30``, you can define a function (with the intervention and the sim object as arguments) that calculates and returns a start day. This allows interventions to be dynamically triggered based on the state of the sim. See [Tutorial 5](https://docs.idmod.org/projects/covasim/en/latest/tutorials/t05.html) for a new section on how to use this feature. +- Added a ``finalize()`` method to interventions and analyzers, to replace the ``if sim.t == sim.npts-1:`` blocks in ``apply()`` that had been being used to finalize. +- Changed setup instructions from ``python setup.py develop`` to ``pip install -e .``, and unpinned ``line_profiler``. +- *Regression information*: If you have any scripts/workflows that have been using ``python setup.py develop``, please update them to ``pip install -e .``. Likewise, ``python setup.py develop`` is now ``pip install -e .[full]``. +- *GitHub info*: PR `897 `__ + + +Version 2.1.1 (2021-03-29) +-------------------------- + +- **Duration updates:** All duration parameters have been updated from the literature. While most are similar to what they were before, there are some differences: in particular, durations of severe and critical disease (either to recovery or death) have increased; for example, duration from symptom onset to death has increased from 15.8±3.8 days to 18.8±7.2 days. +- **Performance updates:** The innermost loop of Covasim, ``cv.compute_infections()``, has been refactored to make more efficient use of array indexing. The observed difference will depend on the nature of the simulation (e.g., network type, interventions), but runs may be up to 1.5x faster now. +- **Graphs:** People, contacts, and contacts layers now have a new method, ``to_graph()``, that will return a ``networkx`` graph (requires ``networkx`` to be installed, of course). For example, ``nx.draw(cv.Sim(pop_size=100).run().people.to_graph())`` will draw all connections between 100 default people. See ``cv.Sim.people.to_graph()`` for full documentation. +- A bug was fixed with ``cv.TransTree.animate()`` failing in some cases. +- ``cv.date_formatter()`` now takes ``interval``, ``start``, and ``end`` arguments. +- Temporarily pinned ``line_profiler`` to version 3.1 due to `this issue `__. +- *Regression information*: Parameters can be restored by using the ``version`` argument when creating a sim. Specifically, the parameters for the following distributions (all lognormal) have been changed as follows:: + + exp2inf: μ = 4.6 → 4.5, σ = 4.8 → 1.5 + inf2sym: μ = 1.0 → 1.1, σ = 0.9 → 0.9 + sev2crit: μ = 3.0 → 1.5, σ = 7.4 → 2.0 + sev2rec: μ = 14.0 → 18.1, σ = 2.4 → 6.3 + crit2rec: μ = 14.0 → 18.1, σ = 2.4 → 6.3 + crit2die: μ = 6.2 → 10.7, σ = 1.7 → 4.8 + +- *GitHub info*: PR `887 `__ + + +Version 2.1.0 (2021-03-23) +-------------------------- + +Highlights +^^^^^^^^^^ +- **Updated lognormal distributions**: Lognormal distributions had been inadvertently using the variance instead of the standard deviation as the second parameter, resulting in too small variance. This has been fixed. This has a small but nonzero impact on the results (e.g. with default parameters, the time to peak infections is about 5-10% sooner now). +- **Expanded plotting features**: You now have much more flexibility with passing arguments to ``sim.plot()`` and other plotting functions, such as to temporarily set global Matplotlib options (such as DPI), modify axis styles and limits, etc. For example, you can now do things like this: ``cv.Sim().run().plot(dpi=150, rotation=30, start_day='2020-03-01', end_day=55, interval=7)``. +- **Improved analyzers**: Transmission trees can be computed 20 times faster, Fit objects are more forgiving for data problems, and analyzers can now be exported to JSON. + +Bugfixes +^^^^^^^^ +- Previously, the lognormal distributions were unintentionally using the variance of the distribution, instead of the standard deviation, as the second parameter. This makes a small difference to the results (slightly higher transmission due to the increased variance). Old simulations that are loaded will automatically have their parameters updated so they give the same results; however, new simulations will now give slightly different results than they did previously. (Thanks to Ace Thompson for identifying this.) +- If a results object has low and high values, these are now exported to JSON (and also to Excel). +- MultiSim and Scenarios ``run.()`` methods now return themselves, as Sim does. This means that just as you can do ``sim.run().plot()``, you can also now do ``msim.run().plot()``. + +Plotting and options +^^^^^^^^^^^^^^^^^^^^ +- Standard plots now accept keyword arguments that will be passed around to all available subfunctions. For example, if you specify ``dpi=150``, Covasim knows that this is a Matplotlib setting and will configure it accordingly; likewise things like ``bottom`` (only for axes), ``frameon`` (only for legends), etc. If you pass an ambiguous keyword (e.g. ``alpha``, which is used for line and scatter plots), it will only be used for the *first* one. +- There is a new keyword argument, ``date_args``, that will format the x-axis: options include ``dateformat`` (e.g. ``%Y-%m-%d``), ``rotation`` (to avoid label collisions), and ``start_day`` and ``end_day``. +- Default plotting styles have updated, including less intrusive lines for interventions. + +Other changes +^^^^^^^^^^^^^ +- MultiSims now have ``to_json()`` and ``to_excel()`` methods, which are shortcuts for calling these methods on the base sim. +- If no label is supplied to an analyzer or intervention, it will use its class name (e.g. the default label for ``cv.change_beta`` is ``'change_beta'``). +- Analyzers now have a ``to_json()`` method. +- The ``cv.Fit`` and ``cv.TransTree`` classes now derive from ``Analyzer``, giving them some new methods and attributes. +- ``cv.sim.compute_fit()`` has a new keyword argument, ``die``, that will print warnings rather than raise exceptions if no matching data is found. Exceptions are now caught and helpful error messages are provided (e.g., if dates don't match). +- The algorithm for ``cv.TransTree`` has been rewritten, and now runs 20x as fast. The detailed transmission tree, in ``tt.detailed``, is now a pandas dataframe rather than a list of dictionaries. To restore something close to the previous version, use ``tt.detailed.to_dict('records')``. +- A data file with an integer rather than date "date" index can now be loaded; these will be counted relative to the simulation's start day. +- ``cv.load()`` has two new keyword arguments, ``update`` and ``verbose``, than are passed to ``cv.migrate()``. +- ``cv.options`` has new a ``get_default()`` method which returns the value of that parameter when Covasim was first loaded. + +Documentation and testing +^^^^^^^^^^^^^^^^^^^^^^^^^ +- An extra tutorial has been added on "Deployment", covering how to use it with `Dask `__ and for using Covasim with interactive notebooks and websites. +- Tutorials 7 and 10 have been updated so they work on Windows machines. +- Additional unit tests have been written to check the statistical properties of the sampling algorithms. + +Regression information +^^^^^^^^^^^^^^^^^^^^^^ +- To restore previous behavior for a simulation (i.e. using variance instead of standard deviation for lognormal distributions), call ``cv.misc.migrate_lognormal(sim)``. This is done automatically when loading a saved sim from disk. To undo a migration, type ``cv.misc.migrate_lognormal(sim, revert=True)``. What this function does is loop over the duration parameters and replace ``par2`` with its square root. If you have used lognormal distributions elsewhere, you will need to update them manually. +- Code that was designed to parse transmission trees will likely need to be revised. The object ``tt.detailed`` is now a dataframe; calling ``tt.detailed.to_dict('records')`` will bring it very close to what it used to be, with the exception that for a given row, ``'t'`` and ``'s'`` used to be nested dictionaries, whereas now they are prefixes. For example, whereas before the 45th person's source's "is quarantined" state would have been ``tt.detailed[45]['s']['is_quarantined']``, it is now ``tt.detailed.iloc[45]['src_is_quarantined']``. +- *GitHub info*: PR `859 `__ + + +Version 2.0.4 (2021-03-19) +-------------------------- +- Added a new analyzer, ``cv.daily_age_stats()``, which will compute statistics by age for each day of the simulation (compared to ``cv.age_histogram()``, which only looks at particular points in time). +- Added a new function, ``cv.date_formatter()``, which may be useful in quickly formatting axes using dates. +- Removed the need for ``self._store_args()`` in interventions; now custom interventions only need to implement ``super().__init__(**kwargs)`` rather than both. +- Changed how custom interventions print out by default (a short representation rather than the jsonified version used by built-in interventions). +- Added an ``update()`` method to ``Layer``, to allow greater flexibility for dynamic updating. +- *GitHub info*: PR `854 `__ + + +Version 2.0.3 (2021-03-11) +-------------------------- +- Previously, the way a sim was printed (e.g. ``print(sim)``) depended on what the global ``verbose`` parameter was set to (e.g. ``cv.options.set(verbose=0.1)``), which used ``sim.brief()`` if verbosity was 0, or ``sim.disp()`` otherwise. This has been changed to always use the ``sim.brief()`` representation regardless of verbosity. To restore the previous behavior, use ``sim.disp()`` instead of ``print(sim)``. +- ``sim.run()`` now returns a pointer to the sim object rather than either nothing (the current default) or the ``sim.results`` object. This means you can now do e.g. ``sim.run().plot()`` or ``sim.run().results`` rather than ``sim.run(do_plot=True)`` or ``sim.run(output=True)``. +- ``sim.get_interventions()`` and ``sim.get_analyzers()`` have been changed to return all interventions/analyzers if no arguments are supplied. Previously, they would return only the last intervention. To restore the previous behavior, call ``sim.get_intervention()`` or ``sim.get_analyzer()`` instead. +- The ``Fit`` object (and ``cv.compute_gof()``) have been updated to allow a custom goodness-of-fit estimator to be supplied. +- Two new results have been added, ``n_preinfectious`` and ``n_removed``, corresponding to the E and R compartments of the SEIR model, respectively. +- A new shortcut plotting option has been introduced, ``sim.plot(to_plot='seir')``. +- Plotting colors have been revised to have greater contrast. +- The ``numba_parallel`` option has been updated to include a "safe" option, which parallelizes as much as it can without disrupting the random number stream. For large sims (>100,000 people), this increases performance by about 10%. The previous ``numba_parallel=True`` option now corresponds to ``numba_parallel='full'``, which is about 20% faster but means results are non-reproducible. Note that for sims smaller than 100,000 people, Numba parallelization has almost no effect on performance. +- A new option has been added, ``numba_cache``, which controls whether or not Numba functions are cached. They are by default to save compilation time, but if you change Numba options (especially ``numba_parallel``), with caching you may also need to delete the ``__pycache__`` folder for changes to take effect. +- A frozen list of ``pip`` requirements, as well as test requirements, has been added to the ``tests`` folder. +- The testing suite has been revamped, with defensive code skipped, bringing code coverage to 90%. +- *Regression information*: Calls to ``sim.run(do_plot=True, **kwargs)`` should be changed to ``sim.run().plot(**kwargs)``. Calls to ``sim.get_interventions()``/``sim.get_analyzers()`` (with no arguments) should be changed to ``sim.get_intervention()``/``sim.get_analyzer()``. Calls to ``results = sim.run(output=True)`` should be replaced with ``results = sim.run().results``. +- *GitHub info*: PR `788 `__ + + Version 2.0.2 (2021-02-01) -------------------------- - Added a new option to easily turn on/off interactive plotting: e.g., simply set ``cv.options.set(interactive=False)`` to turn off interactive plotting. This meta-option sets the other options ``show``, ``close``, and ``backend``. diff --git a/CODE_OF_CONDUCT.rst b/CODE_OF_CONDUCT.rst index d8a566230..44c7f0e35 100644 --- a/CODE_OF_CONDUCT.rst +++ b/CODE_OF_CONDUCT.rst @@ -1,22 +1,16 @@ -==================================== -Contributor covenant code of conduct -==================================== +=============== +Code of conduct +=============== Our pledge ========== -In the interest of fostering an open and welcoming environment, we as -contributors and maintainers pledge to making participation in our project and -our community a harassment-free experience for everyone, regardless of age, body -size, disability, ethnicity, sex characteristics, gender identity and expression, -level of experience, education, socio-economic status, nationality, personal -appearance, race, religion, or sexual identity and orientation. +We believe that a diverse, equitable, and inclusive environment is essential for producing the best quality software. In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in Covasim development and the Covasim community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. Our standards ============= -Examples of behavior that contributes to creating a positive environment -include: +Examples of behavior that contributes to creating a positive environment include: * Using welcoming and inclusive language * Being respectful of differing viewpoints and experiences @@ -26,57 +20,35 @@ include: Examples of unacceptable behavior by participants include: -* The use of sexualized language or imagery and unwelcome sexual attention or - advances +* The use of sexualized language or imagery and unwelcome sexual attention or advances * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment -* Publishing others' private information, such as a physical or electronic - address, without explicit permission -* Other conduct which could reasonably be considered inappropriate in a - professional setting +* Publishing others' private information, such as a physical or electronic address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting Our responsibilities ==================== -Project maintainers are responsible for clarifying the standards of acceptable -behavior and are expected to take appropriate and fair corrective action in -response to any instances of unacceptable behavior. +Covasim maintainers are responsible for clarifying the standards of acceptable behavior and will take appropriate and fair corrective action in response to any instances of unacceptable behavior. -Project maintainers have the right and responsibility to remove, edit, or -reject comments, commits, code, wiki edits, issues, and other contributions -that are not aligned to this Code of Conduct, or to ban temporarily or -permanently any contributor for other behaviors that they deem inappropriate, -threatening, offensive, or harmful. +Covasim maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. Scope ===== -This Code of Conduct applies both within project spaces and in public spaces -when an individual is representing the project or its community. Examples of -representing a project or community include using an official project e-mail -address, posting via an official social media account, or acting as an appointed -representative at an online or offline event. Representation of a project may be -further defined and clarified by project maintainers. +This Code of Conduct applies both within project spaces and in public spaces when an individual is representing Covasim or its community. Examples of representing the Covasim project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Enforcement =========== -Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported by contacting the project team at covasim@idmod.org. All -complaints will be reviewed and investigated and will result in a response that -is deemed necessary and appropriate to the circumstances. The project team is -obligated to maintain confidentiality with regard to the reporter of an incident. -Further details of specific enforcement policies may be posted separately. +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at covasim@idmod.org. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The Covasim team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. -Project maintainers who do not follow or enforce the Code of Conduct in good -faith may face temporary or permanent repercussions as determined by other -members of the project's leadership. +Covasim maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of Covasim's leadership. Attribution =========== -This Code of Conduct is adapted from the `Contributor Covenant`_, version 1.4, -available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html. +This Code of Conduct is adapted from the `Contributor Covenant`_, version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html. .. _Contributor Covenant: https://www.contributor-covenant.org diff --git a/FAQ.rst b/FAQ.rst index 403c0be0b..62555ff90 100644 --- a/FAQ.rst +++ b/FAQ.rst @@ -12,8 +12,14 @@ This document contains answers to frequently (and some not so frequently) asked Usage questions ^^^^^^^^^^^^^^^ +What are the system requirements for Covasim? +--------------------------------------------------------------------------------- + +If your system can run scientific Python (Numpy, SciPy, and Matplotlib), then you can probably run Covasim. Covasim requires 1 GB of RAM per 1 million people, and can simulate roughly 5-10 million person-days per second. A typical use case, such as a population of 100,000 agents running for 500 days, would require 100 MB of memory and take about 5-10 seconds to run. + + Can Covasim be run on HPC clusters? ---------------------------------------------------------------------------------------------------------------- +--------------------------------------------------------------------------------- Yes. On a single-node setup, it is quite easy: in fact, ``MultiSim`` objects will automatically scale to the number of cores available. This can also be specified explicitly with e.g. ``msim.run(n_cpus=24)``. @@ -21,7 +27,7 @@ For more complex use cases (e.g. running across multiple virtual machines), we r What method is best for saving simulation objects? ---------------------------------------------------------------------------------------------------------------- +--------------------------------------------------------------------------------- The recommended way to save a simulation is simply via ``sim.save(filename)``. By default, this does *not* save the people (``sim.people``), since they are very large (i.e., 7 KB without people vs. 7 MB with people for 100,000 agents). However, if you really want to save the people, pass ``keep_people=True``. @@ -37,7 +43,7 @@ Typically, parameters are held constant for the duration of the simulation. Howe How can you introduce new infections into a simulation? --------------------------------------------------------------------------------- -These are referred to as *importations*. You can set the ``n_imports`` parameter for a fixed number of importations each day (or make it time-varying with ``cv.dynamic_pars()``, as described above). Alternatively, you can infect people directly using ``sim.people.infect()``. +These are referred to as *importations*. You can set the ``n_imports`` parameter for a fixed number of importations each day (or make it time-varying with ``cv.dynamic_pars()``, as described above). Alternatively, you can infect people directly using ``sim.people.infect()``. Since version 3.0, you can also import specific strains on a given day: e.g., ``cv.Sim(strains=cv.strain('b117', days=50, n_imports=10)``. How do you set custom prognoses parameters (mortality rate, susceptibility etc.)? @@ -131,12 +137,12 @@ This example illustrates the three different ways to simulation a population of import covasim as cv - s1 = cv.Sim(pop_size=100e3, pop_infected=100, pop_scale=1, rescale=True, label='Full population') - s2 = cv.Sim(pop_size=20e3, pop_infected=100, pop_scale=5, rescale=True, label='Dynamic rescaling') - s3 = cv.Sim(pop_size=20e3, pop_infected=20, pop_scale=5, rescale=False, label='Static rescaling') + s1 = cv.Sim(n_days=120, pop_size=200e3, pop_infected=50, pop_scale=1, rescale=True, label='Full population') + s2 = cv.Sim(n_days=120, pop_size=20e3, pop_infected=50, pop_scale=10, rescale=True, label='Dynamic rescaling') + s3 = cv.Sim(n_days=120, pop_size=20e3, pop_infected=5, pop_scale=10, rescale=False, label='Static rescaling') msim = cv.MultiSim([s1, s2, s3]) - msim.run() + msim.run(verbose=-1) msim.plot() Note that using the full population and using dynamic rescaling give virtually identical results, whereas static scaling gives slightly different results. diff --git a/README.rst b/README.rst index 34a4fe9d4..87e82ac4e 100644 --- a/README.rst +++ b/README.rst @@ -36,21 +36,21 @@ Covasim has been used for analyses in over a dozen countries, both to inform pol 2. **Determining the optimal strategy for reopening schools, the impact of test and trace interventions, and the risk of occurrence of a second COVID-19 epidemic wave in the UK: a modelling study**. Panovska-Griffiths J, Kerr CC, Stuart RM, Mistry D, Klein DJ, Viner R, Bonnell C (2020-08-03). *Lancet Child and Adolescent Health* S2352-4642(20) 30250-9. doi: https://doi.org/10.1016/S2352-4642(20)30250-9. -3. **Modelling the impact of reducing control measures on the COVID-19 pandemic in a low transmission setting**. Scott N, Palmer A, Delport D, Abeysuriya RG, Stuart RM, Kerr CC, Mistry D, Klein DJ, Sacks-Davis R, Heath K, Hainsworth S, Pedrana A, Stoove M, Wilson DP, Hellard M (in press; accepted 2020-09-02). *Medical Journal of Australia* [`Preprint `__]; doi: https://doi.org/10.1101/2020.06.11.20127027. +3. **Estimating and mitigating the risk of COVID-19 epidemic rebound associated with reopening of international borders in Vietnam: a modelling study**. Pham QD, Stuart RM, Nguyen TV, Luong QC, Tran DQ, Phan LT, Dang TQ, Tran DN, Mistry D, Klein DJ, Abeysuriya RG, Oron AP, Kerr CC (2021-04-12). *Lancet Global Health* S2214-109X(21) 00103-0; doi: https://doi.org/10.1016/S2214-109X(21)00103-0. -4. **The role of masks, testing and contact tracing in preventing COVID-19 resurgences: a case study from New South Wales, Australia**. Stuart RM, Abeysuriya RG, Kerr CC, Mistry D, Klein DJ, Gray R, Hellard M, Scott N (under review; posted 2020-09-03). *medRxiv* 2020.09.02.20186742; doi: https://doi.org/10.1101/2020.09.02.20186742. +4. **Modelling the impact of reducing control measures on the COVID-19 pandemic in a low transmission setting**. Scott N, Palmer A, Delport D, Abeysuriya RG, Stuart RM, Kerr CC, Mistry D, Klein DJ, Sacks-Davis R, Heath K, Hainsworth S, Pedrana A, Stoove M, Wilson DP, Hellard M (in press; accepted 2020-09-02). *Medical Journal of Australia* [`Preprint `__]; doi: https://doi.org/10.1101/2020.06.11.20127027. -5. **Schools are not islands: Balancing COVID-19 risk and educational benefits using structural and temporal countermeasures**. Cohen JA, Mistry D, Kerr CC, Klein DJ (under review; posted 2020-09-10). *medRxiv* 2020.09.08.20190942; doi: https://doi.org/10.1101/2020.09.08.20190942. +5. **The role of masks, testing and contact tracing in preventing COVID-19 resurgences: a case study from New South Wales, Australia**. Stuart RM, Abeysuriya RG, Kerr CC, Mistry D, Klein DJ, Gray R, Hellard M, Scott N (in press; accepted 2021-03-19). *BMJ Open*; doi: https://doi.org/10.1101/2020.09.02.20186742. -6. **The potential contribution of face coverings to the control of SARS-CoV-2 transmission in schools and broader society in the UK: a modelling study**. Panovska-Griffiths J, Kerr CC, Waites W, Stuart RM, Mistry D, Foster D, Klein DJ, Viner R, Bonnell C (under review; posted 2020-10-08). *medRxiv* 2020.09.28.20202937; doi: https://doi.org/10.1101/2020.09.28.20202937. +6. **The potential contribution of face coverings to the control of SARS-CoV-2 transmission in schools and broader society in the UK: a modelling study**. Panovska-Griffiths J, Kerr CC, Waites W, Stuart RM, Mistry D, Foster D, Klein DJ, Viner R, Bonnell C (in press; accepted 2021-04-08). *Nature Scientific Reports*; doi: https://doi.org/10.1101/2020.09.28.20202937. -7. **COVID-19 reopening strategies at the county level in the face of uncertainty: Multiple Models for Outbreak Decision Support**. Shea K, Borchering RK, Probert WJM, et al. (under review; posted 2020-11-05). *medRxiv* 2020.11.03.20225409; doi: https://doi.org/10.1101/2020.11.03.20225409. +7. **Schools are not islands: Balancing COVID-19 risk and educational benefits using structural and temporal countermeasures**. Cohen JA, Mistry D, Kerr CC, Klein DJ (under review; posted 2020-09-10). *medRxiv* 2020.09.08.20190942; doi: https://doi.org/10.1101/2020.09.08.20190942. -8. **Lessons learned from Vietnam's COVID-19 response: the role of adaptive behaviour change and testing in epidemic control**. Pham QD, Stuart RM, Nguyen TV, Luong QC, Tran DQ, Phan LT, Dang TQ, Tran DN, Mistry D, Klein DJ, Abeysuriya RG, Oron AP, Kerr CC (under review; posted 2020-12-19). *medRxiv* 2020.12.18.20248454; doi: https://doi.org/10.1101/2020.12.18.20248454. +8. **COVID-19 reopening strategies at the county level in the face of uncertainty: Multiple Models for Outbreak Decision Support**. Shea K, Borchering RK, Probert WJM, et al. (under review; posted 2020-11-05). *medRxiv* 2020.11.03.20225409; doi: https://doi.org/10.1101/2020.11.03.20225409. 9. **Preventing a cluster from becoming a new wave in settings with zero community COVID-19 cases**. Abeysuriya RG, Delport D, Stuart RM, Sacks-Davis R, Kerr CC, Mistry D, Klein DJ, Hellard M, Scott N (under review; posted 2020-12-22). *medRxiv* 2020.12.21.20248595; doi: https://doi.org/10.1101/2020.12.21.20248595. -10. **Modelling the impact of reopening schools in early 2021 in the presence of the new SARS-CoV-2 variant in the UK**. Panovska-Griffiths J, Kerr CC, Waites W, Stuart RM, Mistry D, Foster D, Klein DJ, Viner R, Bonnell C (under review). +10. **Modelling the impact of reopening schools in early 2021 in the presence of the new SARS-CoV-2 variant in the UK**. Panovska-Griffiths J, Kerr CC, Waites W, Stuart RM, Mistry D, Foster D, Klein DJ, Viner R, Bonnell C (under review; posted 2021-02-09). *medRxiv* 2021.02.07.21251287; doi: https://doi.org/10.1101/2021.02.07.21251287. If you have written a paper or report using Covasim, we'd love to know about it! Please write to us `here `__. @@ -58,12 +58,10 @@ If you have written a paper or report using Covasim, we'd love to know about it! Requirements ============ -Python >=3.6 (64-bit). (Note: Python 2 is not supported.) +Python 3.7 or 3.8 (64-bit). (Note: Python 2.7 and Python 3.9 are not supported.) -We also recommend, but do not require, using Python virtual environments. For -more information, see documentation for venv_ or Anaconda_. +We also recommend, but do not require, installing Covasim in a virtual environment. For more information, see documentation for e.g. Anaconda_. -.. _venv: https://docs.python.org/3/tutorial/venv.html .. _Anaconda: https://docs.conda.io/projects/conda/en/latest/user-guide/tasks/manage-environments.html @@ -91,11 +89,11 @@ If you would rather download the source code rather than using the ``pip`` packa * For normal installation (recommended):: - python setup.py develop + pip install -e . * To install Covasim and optional dependencies (be aware this may fail since it relies on nonstandard packages):: - python setup.py develop full + pip install -e .[full] The module should then be importable via ``import covasim as cv``. @@ -141,6 +139,7 @@ The structure of the ``covasim`` folder is as follows, roughly in the order in w * ``people.py``: The ``People`` class, for handling updates of state for each person. * ``population.py``: Functions for creating populations of people, including age, contacts, etc. * ``interventions.py``: The ``Intervention`` class, for adding interventions and dynamically modifying parameters, and classes for each of the specific interventions derived from it. +* ``immunity.py``: The ``strain`` class, and functions for computing waning immunity and neutralizing antibodies. * ``sim.py``: The ``Sim`` class, which performs most of the heavy lifting: initializing the model, running, and plotting. * ``run.py``: Functions for running simulations (e.g. parallel runs and the ``Scenarios`` and ``MultiSim`` classes). * ``analysis.py``: The ``Analyzers`` class (for performing analyses on the sim while it's running), the ``Fit`` class (for calculating the fit between the model and the data), the ``TransTree`` class, and other classes and functions for analyzing simulations. diff --git a/covasim/README.rst b/covasim/README.rst index 47118c562..d413c48eb 100644 --- a/covasim/README.rst +++ b/covasim/README.rst @@ -6,9 +6,9 @@ This file describes each of the input parameters in Covasim. Note: the overall i Population parameters --------------------- -* ``pop_size`` = Number ultimately susceptible to CoV +* ``pop_size`` = Number of agents, i.e., people susceptible to SARS-CoV-2 * ``pop_infected`` = Number of initial infections -* ``pop_type`` = What type of population data to use -- random (fastest), synthpops (best), hybrid (compromise), or clustered (not recommended) +* ``pop_type`` = What type of population data to use -- 'random' (fastest), 'synthpops' (best), 'hybrid' (compromise) * ``location`` = What location to load data from -- default Seattle Simulation parameters @@ -17,35 +17,54 @@ Simulation parameters * ``end_day`` = End day of the simulation * ``n_days`` = Number of days to run, if end_day isn't specified * ``rand_seed`` = Random seed, if None, don't reset -* ``verbose`` = Whether or not to display information during the run -- options are 0 (silent), 1 (default), 2 (everything) +* ``verbose`` = Whether or not to display information during the run -- options are 0 (silent), 0.1 (some; default), 1 (more), 2 (everything) Rescaling parameters -------------------- * ``pop_scale`` = Factor by which to scale the population -- e.g. 1000 with pop_size = 10e3 means a population of 10m +* ``scaled_pop`` = The total scaled population, i.e. the number of agents times the scale factor; alternative to pop_scale * ``rescale`` = Enable dynamic rescaling of the population * ``rescale_threshold`` = Fraction susceptible population that will trigger rescaling if rescaling * ``rescale_factor`` = Factor by which we rescale the population Basic disease transmission -------------------------- -* ``beta`` = Beta per symptomatic contact; absolute -* ``contacts`` = The number of contacts per layer; set below -* ``dynam_layer`` = Which layers are dynamic; set below -* ``beta_layer`` = Transmissibility per layer; set below -* ``n_imports`` = Average daily number of imported cases (actual number is drawn from Poisson distribution) -* ``beta_dist`` = Distribution to draw individual level transmissibility; see https://wellcomeopenresearch.org/articles/5-67 -* ``viral_dist`` = The time varying viral load (transmissibility); estimated from Lescure 2020, Lancet, https://doi.org/10.1016/S1473-3099(20)30200-0 - -Efficacy of protection measures -------------------------------- +* ``beta`` = Beta per symptomatic contact; absolute +* ``n_imports`` = Average daily number of imported cases (actual number is drawn from Poisson distribution) +* ``beta_dist`` = Distribution to draw individual level transmissibility; see https://wellcomeopenresearch.org/articles/5-67 +* ``viral_dist`` = The time varying viral load (transmissibility); estimated from Lescure 2020, Lancet, https://doi.org/10.1016/S1473-3099(20)30200-0 * ``asymp_factor`` = Multiply beta by this factor for asymptomatic cases; no statistically significant difference in transmissibility: https://www.sciencedirect.com/science/article/pii/S1201971220302502 -* ``iso_factor`` = Multiply beta by this factor for diganosed cases to represent isolation; set below -* ``quar_factor`` = Quarantine multiplier on transmissibility and susceptibility; set below -* ``quar_period`` = Number of days to quarantine for; assumption based on standard policies + +Network parameters +------------------ +* ``contacts`` = The number of contacts per layer +* ``dynam_layer`` = Which layers are dynamic +* ``beta_layer`` = Transmissibility per layer + +Multi-strain parameters +----------------------- +* ``n_imports`` = Average daily number of imported cases (actual number is drawn from Poisson distribution) +* ``n_strains`` = The number of strains circulating in the population + +Immunity parameters +------------------- +* ``use_waning`` = Whether to use dynamically calculated immunity +* ``nab_init`` = Parameters for the distribution of the initial level of log2(nab) following natural infection, taken from fig1b of https://doi.org/10.1101/2021.03.09.21252641 +* ``nab_decay`` = Parameters describing the kinetics of decay of nabs over time, taken from fig3b of https://doi.org/10.1101/2021.03.09.21252641 +* ``nab_kin`` = Constructed during sim initialization using the nab_decay parameters +* ``nab_boost`` = Multiplicative factor applied to a person's nab levels if they get reinfected. # TODO: add source +* ``nab_eff`` = Parameters to map nabs to efficacy +* ``rel_imm_symp`` = Relative immunity from natural infection varies by symptoms +* ``immunity`` = Matrix of immunity and cross-immunity factors, set by init_immunity() in immunity.py + +Strain-specific parameters +-------------------------- +* ``rel_beta`` = Relative transmissibility varies by strain +* ``rel_imm_strain`` = Relative own-immunity varies by strain Time for disease progression ---------------------------- -* ``exp2inf`` = Duration from exposed to infectious; see Lauer et al., https://www.ncbi.nlm.nih.gov/pmc/articles/PMC7081172/, subtracting inf2sim duration +* ``exp2inf`` = Duration from exposed to infectious; see Lauer et al., https://www.ncbi.nlm.nih.gov/pmc/articles/PMC7081172/, subtracting inf2sym duration * ``inf2sym`` = Duration from infectious to symptomatic; see Linton et al., https://doi.org/10.3390/jcm9020538 * ``sym2sev`` = Duration from symptomatic to severe symptoms; see Linton et al., https://doi.org/10.3390/jcm9020538Duration from severe symptoms to requiring ICU; see Wang et al., https://jamanetwork.com/journals/jama/fullarticle/2761044Duration from severe symptoms to requiring ICU @@ -66,6 +85,12 @@ Severity parameters * ``prog_by_age`` = Whether to set disease progression based on the person's age * ``prognoses`` = The actual arrays of prognoses by age; this is populated later +Efficacy of protection measures +------------------------------- +* ``iso_factor`` = Multiply beta by this factor for diganosed cases to represent isolation; set below +* ``quar_factor`` = Quarantine multiplier on transmissibility and susceptibility; set below +* ``quar_period`` = Number of days to quarantine for; assumption based on standard policies + Events and interventions ------------------------ * ``interventions`` = The interventions present in this simulation; populated by the user diff --git a/covasim/__init__.py b/covasim/__init__.py index 23efc68d0..b098c9a07 100644 --- a/covasim/__init__.py +++ b/covasim/__init__.py @@ -17,6 +17,7 @@ from .people import * # Depends on utils, defaults, base, plotting from .population import * # Depends on people et al. from .interventions import * # Depends on defaults, utils, base +from .immunity import * # Depends on utils, parameters, defaults from .analysis import * # Depends on utils, misc, interventions from .sim import * # Depends on almost everything from .run import * # Depends on sim diff --git a/covasim/analysis.py b/covasim/analysis.py index 3b68b4342..624fea5f1 100644 --- a/covasim/analysis.py +++ b/covasim/analysis.py @@ -3,6 +3,7 @@ but which are useful for particular investigations. ''' +import os import numpy as np import pylab as pl import pandas as pd @@ -11,9 +12,16 @@ from . import misc as cvm from . import interventions as cvi from . import settings as cvset +from . import plotting as cvpl +from . import run as cvr +try: + import optuna as op +except ImportError as E: # pragma: no cover + errormsg = f'Optuna import failed ({str(E)}), please install first (pip install optuna)' + op = ImportError(errormsg) -__all__ = ['Analyzer', 'snapshot', 'age_histogram', 'daily_stats', 'Fit', 'TransTree'] +__all__ = ['Analyzer', 'snapshot', 'age_histogram', 'daily_age_stats', 'daily_stats', 'Fit', 'Calibration', 'TransTree'] class Analyzer(sc.prettyobj): @@ -30,16 +38,33 @@ class Analyzer(sc.prettyobj): ''' def __init__(self, label=None): + if label is None: + label = self.__class__.__name__ # Use the class name if no label is supplied self.label = label # e.g. "Record ages" self.initialized = False + self.finalized = False return - def initialize(self, sim): + def initialize(self, sim=None): ''' Initialize the analyzer, e.g. convert date strings to integers. ''' self.initialized = True + self.finalized = False + return + + + def finalize(self, sim=None): + ''' + Finalize analyzer + + This method is run once as part of `sim.finalize()` enabling the analyzer to perform any + final operations after the simulation is complete (e.g. rescaling) + ''' + if self.finalized: + raise RuntimeError('Analyzer already finalized') # Raise an error because finalizing multiple times has a high probability of producing incorrect results e.g. applying rescale factors twice + self.finalized = True return @@ -55,6 +80,38 @@ def apply(self, sim): raise NotImplementedError + def to_json(self): + ''' + Return JSON-compatible representation + + Custom classes can't be directly represented in JSON. This method is a + one-way export to produce a JSON-compatible representation of the + intervention. This method will attempt to JSONify each attribute of the + intervention, skipping any that fail. + + Returns: + JSON-serializable representation + ''' + # Set the name + json = {} + json['analyzer_name'] = self.label if hasattr(self, 'label') else None + json['analyzer_class'] = self.__class__.__name__ + + # Loop over the attributes and try to process + attrs = self.__dict__.keys() + for attr in attrs: + try: + data = getattr(self, attr) + try: + attjson = sc.jsonify(data) + json[attr] = attjson + except Exception as E: + json[attr] = f'Could not jsonify "{attr}" ({type(data)}): "{str(E)}"' + except Exception as E2: + json[attr] = f'Could not jsonify "{attr}": "{str(E2)}"' + return json + + def validate_recorded_dates(sim, requested_dates, recorded_dates, die=True): ''' Helper method to ensure that dates recorded by an analyzer match the ones @@ -62,7 +119,7 @@ def validate_recorded_dates(sim, requested_dates, recorded_dates, die=True): ''' requested_dates = sorted(list(requested_dates)) recorded_dates = sorted(list(recorded_dates)) - if recorded_dates != requested_dates: + if recorded_dates != requested_dates: # pragma: no cover errormsg = f'The dates {requested_dates} were requested but only {recorded_dates} were recorded: please check the dates fall between {sim.date(sim["start_day"])} and {sim.date(sim["start_day"])} and the sim was actually run' if die: raise RuntimeError(errormsg) @@ -114,7 +171,7 @@ def initialize(self, sim): self.days, self.dates = cvi.process_days(sim, self.days, return_dates=True) # Ensure days are in the right format max_snapshot_day = self.days[-1] max_sim_day = sim.day(sim['end_day']) - if max_snapshot_day > max_sim_day: + if max_snapshot_day > max_sim_day: # pragma: no cover errormsg = f'Cannot create snapshot for {self.dates[-1]} (day {max_snapshot_day}) because the simulation ends on {self.end_day} (day {max_sim_day})' raise ValueError(errormsg) self.initialized = True @@ -126,10 +183,10 @@ def apply(self, sim): date = self.dates[ind] self.snapshots[date] = sc.dcp(sim.people) # Take snapshot! - # On the final timestep, check that everything matches - if sim.t == sim.tvec[-1]: - validate_recorded_dates(sim, requested_dates=self.dates, recorded_dates=self.snapshots.keys(), die=self.die) + def finalize(self, sim): + super().finalize() + validate_recorded_dates(sim, requested_dates=self.dates, recorded_dates=self.snapshots.keys(), die=self.die) return @@ -141,7 +198,7 @@ def get(self, key=None): date = sc.date(day, start_date=self.start_day, as_date=False) if date in self.snapshots: snapshot = self.snapshots[date] - else: + else: # pragma: no cover dates = ', '.join(list(self.snapshots.keys())) errormsg = f'Could not find snapshot date {date} (day {day}): choices are {dates}' raise sc.KeyNotFoundError(errormsg) @@ -151,10 +208,7 @@ def get(self, key=None): class age_histogram(Analyzer): ''' - Analyzer that takes a "snapshot" of the sim.people array at specified points - in time, and saves them to itself. To retrieve them, you can either access - the dictionary directly, or use the get() method. You can also apply this - analyzer directly to a sim object. + Calculate statistics across age bins, including histogram plotting functionality. Args: days (list): list of ints/strings/date objects, the days on which to calculate the histograms (default: last day) @@ -169,9 +223,10 @@ class age_histogram(Analyzer): sim = cv.Sim(analyzers=cv.age_histogram()) sim.run() - agehist = sim.get_analyzer() - agehist = cv.age_histogram(sim=sim) + agehist = sim.get_analyzer() + agehist = cv.age_histogram(sim=sim) # Alternate method + agehist.plot() ''' def __init__(self, days=None, states=None, edges=None, datafile=None, sim=None, die=True, **kwargs): @@ -194,7 +249,7 @@ def __init__(self, days=None, states=None, edges=None, datafile=None, sim=None, def from_sim(self, sim): ''' Create an age histogram from an already run sim ''' - if self.days is not None: + if self.days is not None: # pragma: no cover errormsg = 'If a simulation is being analyzed post-run, no day can be supplied: only the last day of the simulation is available' raise ValueError(errormsg) self.initialize(sim) @@ -203,6 +258,7 @@ def from_sim(self, sim): def initialize(self, sim): + super().initialize() # Handle days self.start_day = sc.date(sim['start_day'], as_date=False) # Get the start day, as a string @@ -212,7 +268,7 @@ def initialize(self, sim): self.days, self.dates = cvi.process_days(sim, self.days, return_dates=True) # Ensure days are in the right format max_hist_day = self.days[-1] max_sim_day = sim.day(self.end_day) - if max_hist_day > max_sim_day: + if max_hist_day > max_sim_day: # pragma: no cover errormsg = f'Cannot create histogram for {self.dates[-1]} (day {max_hist_day}) because the simulation ends on {self.end_day} (day {max_sim_day})' raise ValueError(errormsg) @@ -223,7 +279,7 @@ def initialize(self, sim): # Handle states if self.states is None: - self.states = ['exposed', 'dead', 'tested', 'diagnosed'] + self.states = ['exposed', 'severe', 'dead', 'tested', 'diagnosed'] self.states = sc.promotetolist(self.states) for s,state in enumerate(self.states): self.states[s] = state.replace('date_', '') # Allow keys starting with date_ as input, but strip it off here @@ -236,8 +292,6 @@ def initialize(self, sim): self.data = self.datafile # Use it directly self.datafile = None - self.initialized = True - return @@ -252,10 +306,10 @@ def apply(self, sim): inds = sim.people.defined(f'date_{state}') # Pull out people for which this state is defined self.hists[date][state] = np.histogram(age[inds], bins=self.edges)[0]*scale # Actually count the people - # On the final timestep, check that everything matches - if sim.t == sim.tvec[-1]: - validate_recorded_dates(sim, requested_dates=self.dates, recorded_dates=self.hists.keys(), die=self.die) + def finalize(self, sim): + super().finalize() + validate_recorded_dates(sim, requested_dates=self.dates, recorded_dates=self.hists.keys(), die=self.die) return @@ -267,7 +321,7 @@ def get(self, key=None): date = sc.date(day, start_date=self.start_day, as_date=False) if date in self.hists: hists = self.hists[date] - else: + else: # pragma: no cover dates = ', '.join(list(self.hists.keys())) errormsg = f'Could not find histogram date {date} (day {day}): choices are {dates}' raise sc.KeyNotFoundError(errormsg) @@ -326,7 +380,7 @@ def plot(self, windows=False, width=0.8, color='#F8A493', fig_args=None, axis_ar histsdict = self.window_hists else: histsdict = self.hists - if not len(histsdict): + if not len(histsdict): # pragma: no cover errormsg = f'Cannot plot since no histograms were recorded (schuled days: {self.days})' raise ValueError(errormsg) @@ -337,21 +391,179 @@ def plot(self, windows=False, width=0.8, color='#F8A493', fig_args=None, axis_ar bins = hists['bins'] barwidth = width*(bins[1] - bins[0]) # Assume uniform width for s,state in enumerate(self.states): - pl.subplot(n_rows, n_cols, s+1) - pl.bar(bins, hists[state], width=barwidth, facecolor=color, label=f'Number {state}') + ax = pl.subplot(n_rows, n_cols, s+1) + ax.bar(bins, hists[state], width=barwidth, facecolor=color, label=f'Number {state}') if self.data and state in self.data: data = self.data[state] - pl.bar(bins+d_args.offset, data, width=barwidth*d_args.width, facecolor=d_args.color, label='Data') - pl.xlabel('Age') - pl.ylabel('Count') - pl.xticks(ticks=bins) - pl.legend() + ax.bar(bins+d_args.offset, data, width=barwidth*d_args.width, facecolor=d_args.color, label='Data') + ax.set_xlabel('Age') + ax.set_ylabel('Count') + ax.set_xticks(ticks=bins) + ax.legend() preposition = 'from' if windows else 'by' - pl.title(f'Number of people {state} {preposition} {date}') + ax.set_title(f'Number of people {state} {preposition} {date}') return figs +class daily_age_stats(Analyzer): + ''' + Calculate daily counts by age, saving for each day of the simulation. Can + plot either time series by age or a histogram over all time. + + Args: + states (list): which states of people to record (default: ['diagnoses', 'deaths', 'tests', 'severe']) + edges (list): edges of age bins to use (default: 10 year bins from 0 to 100) + kwargs (dict): passed to Analyzer() + + **Examples**:: + + sim = cv.Sim(analyzers=cv.daily_age_stats()) + sim = cv.Sim(pars, analyzers=daily_age) + sim.run() + daily_age = sim.get_analyzer() + daily_age.plot() + daily_age.plot(total=True) + + ''' + + def __init__(self, states=None, edges=None, **kwargs): + super().__init__(**kwargs) + self.edges = edges + self.bins = None # Age bins, calculated from edges + self.states = states + self.results = sc.odict() + self.start_day = None + self.df = None + self.total_df = None + return + + + def initialize(self, sim): + super().initialize() + + if self.states is None: + self.states = ['exposed', 'severe', 'dead', 'tested', 'diagnosed'] + + # Handle edges and age bins + if self.edges is None: # Default age bins + self.edges = np.linspace(0, 100, 11) + self.bins = self.edges[:-1] # Don't include the last edge in the bins + + self.start_day = sim['start_day'] + + return + + + def apply(self, sim): + df_entry = {} + for state in self.states: + inds = sc.findinds(sim.people[f'date_{state}'], sim.t) + b, _ = np.histogram(sim.people.age[inds], self.edges) + df_entry.update({state: b * sim.rescale_vec[sim.t]}) + df_entry.update({'day':sim.t, 'age': self.bins}) + self.results.update({sim.date(sim.t): df_entry}) + + + def to_df(self): + '''Create dataframe totals for each day''' + mapper = {f'{k}': f'new_{k}' for k in self.states} + df = pd.DataFrame() + for date, k in self.results.items(): + df_ = pd.DataFrame(k) + df_['date'] = date + df_.rename(mapper, inplace=True, axis=1) + df = pd.concat((df, df_)) + cols = list(df.columns.values) + cols = [cols[-1]] + [cols[-2]] + cols[:-2] + self.df = df[cols] + return self.df + + + def to_total_df(self): + ''' Create dataframe totals across days ''' + if self.df is None: + self.to_df() + cols = list(self.df.columns) + cum_cols = [c for c in cols if c.split('_')[0] == 'new'] + mapper = {f'new_{c.split("_")[1]}': f'cum_{c.split("_")[1]}' for c in cum_cols} + df_dict = {'age': []} + df_dict.update({c: [] for c in mapper.values()}) + for age, group in self.df.groupby('age'): + cum_vals = group.sum() + df_dict['age'].append(age) + for k, v in mapper.items(): + df_dict[v].append(cum_vals[k]) + df = pd.DataFrame(df_dict) + if ('cum_diagnoses' in df.columns) and ('cum_tests' in df.columns): + df['yield'] = df['cum_diagnoses'] / df['cum_tests'] + self.total_df = df + return df + + + def plot(self, total=False, do_show=None, fig_args=None, axis_args=None, plot_args=None, dateformat='%b-%d', width=0.8, color='#F8A493', data_args=None): + ''' + Plot the results. + + Args: + total (bool): whether to plot the total histograms rather than time series + do_show (bool): whether to show the plot + fig_args (dict): passed to pl.figure() + axis_args (dict): passed to pl.subplots_adjust() + plot_args (dict): passed to pl.plot() + dateformat (str): the format to use for the x-axes (only used for time series) + width (float): width of bars (only used for histograms) + color (hex/rgb): the color of the bars (only used for histograms) + ''' + if self.df is None: + self.to_df() + if self.total_df is None: + self.to_total_df() + + fig_args = sc.mergedicts(dict(figsize=(18,11)), fig_args) + axis_args = sc.mergedicts(dict(left=0.05, right=0.95, bottom=0.05, top=0.95, wspace=0.25, hspace=0.4), axis_args) + plot_args = sc.mergedicts(dict(lw=2, alpha=0.5, marker='o'), plot_args) + + nplots = len(self.states) + nrows, ncols = sc.get_rows_cols(nplots) + fig, axs = pl.subplots(nrows=nrows, ncols=ncols, **fig_args) + pl.subplots_adjust(**axis_args) + + for count,state in enumerate(self.states): + row,col = np.unravel_index(count, (nrows,ncols)) + ax = axs[row,col] + ax.set_title(state.title()) + ages = self.df.age.unique() + + # Plot time series + if not total: + colors = sc.vectocolor(len(ages)) + has_data = False + for a,age in enumerate(ages): + label = f'Age {age}' + df = self.df[self.df.age==age] + ax.plot(df.day, df[f'new_{state}'], c=colors[a], label=label) + has_data = has_data or len(df) + if has_data: + ax.legend() + ax.set_xlabel('Day') + ax.set_ylabel('Count') + cvpl.date_formatter(start_day=self.start_day, dateformat=dateformat, ax=ax) + + # Plot total histograms + else: + df = self.total_df + barwidth = width*(df.age[1] - df.age[0]) # Assume uniform width + ax.bar(df.age, df[f'cum_{state}'], width=barwidth, facecolor=color) + ax.set_xlabel('Age') + ax.set_ylabel('Count') + ax.set_xticks(ticks=df.age) + + cvset.handle_show(do_show) # Whether or not to call pl.show() + + return fig + + class daily_stats(Analyzer): ''' Print out daily statistics about the simulation. Note that this analyzer takes @@ -386,6 +598,7 @@ def __init__(self, days=None, verbose=True, reporter=None, save_inds=False, **kw def initialize(self, sim): + super().initialize() if self.days is None: self.days = sc.dcp(sim.tvec) else: @@ -394,7 +607,6 @@ def initialize(self, sim): self.keys = ['exposed', 'infectious', 'symptomatic', 'severe', 'critical', 'known_contact', 'quarantined', 'diagnosed', 'recovered', 'dead'] self.basekeys = ['stocks', 'trans', 'source', 'test', 'quar'] # Categories of things to plot self.extrakeys = ['layer_counts', 'extra'] - self.initialized = True return @@ -668,7 +880,7 @@ def plot(self, fig_args=None, axis_args=None, plot_args=None, do_show=None): -class Fit(sc.prettyobj): +class Fit(Analyzer): ''' A class for calculating the fit between the model and the data. Note the following terminology is used here: @@ -687,17 +899,19 @@ class Fit(sc.prettyobj): custom (dict): a custom dictionary of additional data to fit; format is e.g. {'my_output':{'data':[1,2,3], 'sim':[1,2,4], 'weights':2.0}} compute (bool): whether to compute the mismatch immediately verbose (bool): detail to print + die (bool): whether to raise an exception if no data are supplied kwargs (dict): passed to cv.compute_gof() -- see this function for more detail on goodness-of-fit calculation options **Example**:: - sim = cv.Sim() + sim = cv.Sim(datafile='my-data-file.csv') sim.run() fit = sim.compute_fit() fit.plot() ''' - def __init__(self, sim, weights=None, keys=None, custom=None, compute=True, verbose=False, **kwargs): + def __init__(self, sim, weights=None, keys=None, custom=None, compute=True, verbose=False, die=True, **kwargs): + super().__init__(**kwargs) # Initialize the Analyzer object # Handle inputs self.weights = weights @@ -706,17 +920,25 @@ def __init__(self, sim, weights=None, keys=None, custom=None, compute=True, verb self.weights = sc.mergedicts({'cum_deaths':10, 'cum_diagnoses':5}, weights) self.keys = keys self.gof_kwargs = kwargs + self.die = die # Copy data - if sim.data is None: + if sim.data is None: # pragma: no cover errormsg = 'Model fit cannot be calculated until data are loaded' - raise RuntimeError(errormsg) + if self.die: + raise RuntimeError(errormsg) + else: + print('Warning: ', errormsg) + sim.data = pd.DataFrame() # Use an empty dataframe self.data = sim.data # Copy sim results - if not sim.results_ready: + if not sim.results_ready: # pragma: no cover errormsg = 'Model fit cannot be calculated until results are run' - raise RuntimeError(errormsg) + if self.die: + raise RuntimeError(errormsg) + else: + print('Warning: ', errormsg) self.sim_results = sc.objdict() for key in sim.result_keys() + ['t', 'date']: self.sim_results[key] = sim.results[key] @@ -758,17 +980,23 @@ def reconcile_inputs(self): data_cols = self.data.columns if self.keys is None: - sim_keys = self.sim_results.keys() + sim_keys = [k for k in self.sim_results.keys() if k.startswith('cum_')] # Default sim keys, only keep cumulative keys if no keys are supplied intersection = list(set(sim_keys).intersection(data_cols)) # Find keys in both the sim and data - self.keys = [key for key in sim_keys if key in intersection and key.startswith('cum_')] # Only keep cumulative keys - if not len(self.keys): - errormsg = f'No matches found between simulation result keys ({sim_keys}) and data columns ({data_cols})' - raise sc.KeyNotFoundError(errormsg) + self.keys = [key for key in sim_keys if key in intersection] # Maintain key order + if not len(self.keys): # pragma: no cover + errormsg = f'No matches found between simulation result keys:\n{sc.strjoin(sim_keys)}\n\nand data columns:\n{sc.strjoin(data_cols)}' + if self.die: + raise sc.KeyNotFoundError(errormsg) + else: + print('Warning: ', errormsg) mismatches = [key for key in self.keys if key not in data_cols] - if len(mismatches): + if len(mismatches): # pragma: no cover mismatchstr = ', '.join(mismatches) errormsg = f'The following requested key(s) were not found in the data: {mismatchstr}' - raise sc.KeyNotFoundError(errormsg) + if self.die: + raise sc.KeyNotFoundError(errormsg) + else: + print('Warning: ', errormsg) for key in self.keys: # For keys present in both the results and in the data self.inds.sim[key] = [] @@ -786,6 +1014,7 @@ def reconcile_inputs(self): self.inds.data[key] = np.array(self.inds.data[key]) # Convert into paired points + matches = 0 # Count how many data points match for key in self.keys: self.pair[key] = sc.objdict() sim_inds = self.inds.sim[key] @@ -794,12 +1023,14 @@ def reconcile_inputs(self): self.pair[key].sim = np.zeros(n_inds) self.pair[key].data = np.zeros(n_inds) for i in range(n_inds): + matches += 1 self.pair[key].sim[i] = self.sim_results[key].values[sim_inds[i]] self.pair[key].data[i] = self.data[key].values[data_inds[i]] # Process custom inputs self.custom_keys = list(self.custom.keys()) for key in self.custom.keys(): + matches += 1 # If any of these exist, count it as amatch # Initialize and do error checking custom = self.custom[key] @@ -811,10 +1042,10 @@ def reconcile_inputs(self): c_sim = custom['sim'] try: assert len(c_data) == len(c_sim) - except: + except: # pragma: no cover errormsg = f'Custom data and sim must be arrays, and be of the same length: data = {c_data}, sim = {c_sim} could not be processed' raise ValueError(errormsg) - if key in self.pair: + if key in self.pair: # pragma: no cover errormsg = f'You cannot use a custom key "{key}" that matches one of the existing keys: {self.pair.keys()}' raise ValueError(errormsg) @@ -828,6 +1059,13 @@ def reconcile_inputs(self): wt = custom.get('weights', wt) # ...but also try "weights" self.weights[key] = wt # Set the weight + if matches == 0: + errormsg = 'No paired data points were found between the supplied data and the simulation; please check the dates for each' + if self.die: + raise ValueError(errormsg) + else: + print('Warning: ', errormsg) + return @@ -863,7 +1101,7 @@ def compute_losses(self): pass elif len_wt == len_sim: # Most typical case: it's the length of the simulation, must trim weight = weight[self.inds.sim[key]] # Trim to matching indices - else: + else: # pragma: no cover errormsg = f'Could not map weight array of length {len_wt} onto simulation of length {len_sim} or data-model matches of length {len_match}' raise ValueError(errormsg) else: @@ -883,8 +1121,7 @@ def compute_mismatch(self, use_median=False): return self.mismatch - def plot(self, keys=None, width=0.8, fig_args=None, axis_args=None, - plot_args=None, do_show=None, fig=None): + def plot(self, keys=None, width=0.8, fig_args=None, axis_args=None, plot_args=None, date_args=None, do_show=None, fig=None): ''' Plot the fit of the model to the data. For each result, plot the data and the model; the difference; and the loss (weighted difference). Also @@ -896,6 +1133,7 @@ def plot(self, keys=None, width=0.8, fig_args=None, axis_args=None, fig_args (dict): passed to pl.figure() axis_args (dict): passed to pl.subplots_adjust() plot_args (dict): passed to pl.plot() + date_args (dict): passed to cv.plotting.reset_ticks() (handle date format, rotation, etc.) do_show (bool): whether to show the plot fig (fig): if supplied, use this figure to plot in @@ -906,6 +1144,7 @@ def plot(self, keys=None, width=0.8, fig_args=None, axis_args=None, fig_args = sc.mergedicts(dict(figsize=(18,11)), fig_args) axis_args = sc.mergedicts(dict(left=0.05, right=0.95, bottom=0.05, top=0.95, wspace=0.3, hspace=0.3), axis_args) plot_args = sc.mergedicts(dict(lw=2, alpha=0.5, marker='o'), plot_args) + date_args = sc.mergedicts(sc.objdict(as_dates=True, dateformat=None, interval=None, rotation=None, start_day=None, end_day=None), date_args) if keys is None: keys = self.keys + self.custom_keys @@ -926,7 +1165,7 @@ def plot(self, keys=None, width=0.8, fig_args=None, axis_args=None, for k,key in enumerate(keys): if key in self.keys: # It's a time series, plot with days and dates days = self.inds.sim[key] # The "days" axis (or not, for custom keys) - daylabel = 'Day' + daylabel = 'Date' else: #It's custom, we don't know what it is days = np.arange(len(self.losses[key])) # Just use indices daylabel = 'Index' @@ -956,37 +1195,230 @@ def plot(self, keys=None, width=0.8, fig_args=None, axis_args=None, ax.set_xlabel('Date') ax.set_ylabel(ylabel) ax.set_title(title) + cvpl.reset_ticks(ax=ax, date_args=date_args, start_day=self.sim_results['date'][0]) ax.legend() - pl.subplot(n_rows, n_keys, k+1*n_keys+1) - pl.plot(days, self.pair[key].data, c='k', label='Data', **plot_args) - pl.plot(days, self.pair[key].sim, c=colors[k], label='Simulation', **plot_args) - pl.title(key) + ts_ax = pl.subplot(n_rows, n_keys, k+1*n_keys+1) + ts_ax.plot(days, self.pair[key].data, c='k', label='Data', **plot_args) + ts_ax.plot(days, self.pair[key].sim, c=colors[k], label='Simulation', **plot_args) + ts_ax.set_title(key) if k == 0: - pl.ylabel('Time series (counts)') - pl.legend() + ts_ax.set_ylabel('Time series (counts)') + ts_ax.legend() - pl.subplot(n_rows, n_keys, k+2*n_keys+1) - pl.bar(days, self.diffs[key], width=width, color=colors[k], label='Difference') - pl.axhline(0, c='k') + diff_ax = pl.subplot(n_rows, n_keys, k+2*n_keys+1) + diff_ax.bar(days, self.diffs[key], width=width, color=colors[k], label='Difference') + diff_ax.axhline(0, c='k') if k == 0: - pl.ylabel('Differences (counts)') - pl.legend() + diff_ax.set_ylabel('Differences (counts)') + diff_ax.legend() loss_ax = pl.subplot(n_rows, n_keys, k+3*n_keys+1, sharey=loss_ax) - pl.bar(days, self.losses[key], width=width, color=colors[k], label='Losses') - pl.xlabel(daylabel) - pl.title(f'Total loss: {self.losses[key].sum():0.3f}') + loss_ax.bar(days, self.losses[key], width=width, color=colors[k], label='Losses') + loss_ax.set_xlabel(daylabel) + loss_ax.set_title(f'Total loss: {self.losses[key].sum():0.3f}') if k == 0: - pl.ylabel('Losses') - pl.legend() + loss_ax.set_ylabel('Losses') + loss_ax.legend() + + if daylabel == 'Date': + for ax in [ts_ax, diff_ax, loss_ax]: + cvpl.reset_ticks(ax=ax, date_args=date_args, start_day=self.sim_results['date'][0]) cvset.handle_show(do_show) # Whether or not to call pl.show() return fig -class TransTree(sc.prettyobj): + +class Calibration(Analyzer): + ''' + A class to handle calibration of Covasim simulations. Uses the Optuna hyperparameter + optimization library (optuna.org), which must be installed separately (via + pip install optuna). + + Note: running a calibration does not guarantee a good fit! You must ensure that + you run for a sufficient number of iterations, have enough free parameters, and + that the parameters have wide enough bounds. Please see the tutorial on calibration + for more information. + + Args: + sim (Sim): the simulation to calibrate + calib_pars (dict): a dictionary of the parameters to calibrate of the format dict(key1=[best, low, high]) + custom_fn (function): a custom function for modifying the simulation; receives the sim and calib_pars as inputs, should return the modified sim + 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: 'covasim_calibration') + db_name (str): the name of the database file (default: 'covasim_calibration.db') + storage (str): the location of the database (default: sqlite) + label (str): a label for this calibration object + verbose (bool): whether to print details of the calibration + kwargs (dict): passed to cv.Calibration() + + Returns: + A Calibration object + + **Example**:: + + sim = cv.Sim(datafile='data.csv') + calib_pars = dict(beta=[0.015, 0.010, 0.020]) + calib = cv.Calibration(sim, calib_pars, total_trials=100) + calib.calibrate() + calib.plot() + ''' + + def __init__(self, sim, calib_pars=None, custom_fn=None, n_trials=None, n_workers=None, total_trials=None, name=None, db_name=None, storage=None, label=None, verbose=True): + super().__init__(label=label) # Initialize the Analyzer object + if isinstance(op, Exception): raise op # If Optuna failed to import, raise that exception now + import multiprocessing as mp + + # Handle run arguments + if n_trials is None: n_trials = 20 + if n_workers is None: n_workers = mp.cpu_count() + if name is None: name = 'covasim_calibration' + if db_name is None: db_name = f'{name}.db' + if storage is None: storage = f'sqlite:///{db_name}' + if total_trials is not None: n_trials = 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, storage=storage) + + # Handle other inputs + self.sim = sim + self.calib_pars = calib_pars + self.custom_fn = custom_fn + self.verbose = verbose + self.calibrated = False + + # Handle if the sim has already been run + if self.sim.complete: + print('Warning: sim has already been run; re-initializing, but in future, use a sim that has not been run') + self.sim = self.sim.copy() + self.sim.initialize() + + return + + + def run_sim(self, calib_pars, label=None, return_sim=False): + ''' Create and run a simulation ''' + sim = self.sim.copy() + if label: sim.label = label + valid_pars = {k:v for k,v in calib_pars.items() if k in sim.pars} + sim.update_pars(valid_pars) + if self.custom_fn: + sim = self.custom_fn(sim, calib_pars) + else: + if len(valid_pars) != len(calib_pars): + extra = set(calib_pars.keys()) - set(valid_pars.keys()) + errormsg = f'The following parameters are not part of the sim, nor is a custom function specified to use them: {sc.strjoin(extra)}' + raise ValueError(errormsg) + sim.run() + sim.compute_fit() + if return_sim: + return sim + else: + return sim.fit.mismatch + + + def run_trial(self, trial): + ''' Define the objective for Optuna ''' + pars = {} + for key, (best,low,high) in self.calib_pars.items(): + pars[key] = trial.suggest_uniform(key, low, high) # Sample from values within this range + mismatch = self.run_sim(pars) + return mismatch + + + def worker(self): + ''' Run a single worker ''' + if self.verbose: + op.logging.set_verbosity(op.logging.DEBUG) + else: + op.logging.set_verbosity(op.logging.ERROR) + study = op.load_study(storage=self.run_args.storage, study_name=self.run_args.name) + output = study.optimize(self.run_trial, n_trials=self.run_args.n_trials) + return output + + + def run_workers(self): + ''' Run multiple workers in parallel ''' + output = sc.parallelize(self.worker, iterarg=self.run_args.n_workers) + return output + + + def make_study(self): + ''' Make a study, deleting one if it already exists ''' + if os.path.exists(self.run_args.db_name): + os.remove(self.run_args.db_name) + print(f'Removed existing calibration {self.run_args.db_name}') + output = op.create_study(storage=self.run_args.storage, study_name=self.run_args.name) + return output + + + def calibrate(self, calib_pars=None, verbose=True, **kwargs): + ''' + Actually perform calibration. + + Args: + calib_pars (dict): if supplied, overwrite stored calib_pars + verbose (bool): whether to print output from each trial + kwargs (dict): if supplied, overwrite stored run_args (n_trials, n_workers, etc.) + ''' + + # Load and validate calibration parameters + if calib_pars is not None: + self.calib_pars = calib_pars + if self.calib_pars is None: + errormsg = 'You must supply calibration parameters either when creating the calibration object or when calling calibrate().' + raise ValueError(errormsg) + self.run_args.update(kwargs) # Update optuna settings + + # Run the optimization + t0 = sc.tic() + self.make_study() + self.run_workers() + self.study = op.load_study(storage=self.run_args.storage, study_name=self.run_args.name) + self.best_pars = sc.objdict(self.study.best_params) + self.elapsed = sc.toc(t0, output=True) + + # Compare the results + self.initial_pars = sc.objdict({k:v[0] for k,v in self.calib_pars.items()}) + self.before = self.run_sim(calib_pars=self.initial_pars, label='Before calibration', return_sim=True) + self.after = self.run_sim(calib_pars=self.best_pars, label='After calibration', return_sim=True) + + # Tidy up + self.calibrated = True + if verbose: + self.summarize() + + return + + + def summarize(self): + if self.calibrated: + print(f'Calibration for {self.run_args.n_workers*self.run_args.n_trials} total trials completed in {self.elapsed:0.1f} s.') + before = self.before.fit.mismatch + after = self.after.fit.mismatch + print('\nInitial parameter values:') + print(self.initial_pars) + print('\nBest parameter values:') + print(self.best_pars) + print(f'\nMismatch before calibration: {before:n}') + print(f'Mismatch after calibration: {after:n}') + print(f'Percent improvement: {((before-after)/before)*100:0.1f}%') + return before, after + else: + print('Calibration not yet run; please run calib.calibrate()') + return + + + def plot(self, **kwargs): + msim = cvr.MultiSim([self.before, self.after]) + fig = msim.plot(**kwargs) + return fig + + + +class TransTree(Analyzer): ''' A class for holding a transmission tree. There are several different representations of the transmission tree: "infection_log" is copied from the people object and is the @@ -997,9 +1429,21 @@ class TransTree(sc.prettyobj): Args: sim (Sim): the sim object to_networkx (bool): whether to convert the graph to a NetworkX object + + **Example**:: + + sim = cv.Sim().run() + sim.run() + tt = sim.make_transtree() + tt.plot() + tt.plot_histograms() + + New in version 2.1.0: ``tt.detailed`` is a dataframe rather than a list of dictionaries; + for the latter, use ``tt.detailed.to_dict('records')``. ''' - def __init__(self, sim, to_networkx=False): + def __init__(self, sim, to_networkx=False, **kwargs): + super().__init__(**kwargs) # Initialize the Analyzer object # Pull out each of the attributes relevant to transmission attrs = {'age', 'date_exposed', 'date_symptomatic', 'date_tested', 'date_diagnosed', 'date_quarantined', 'date_severe', 'date_critical', 'date_known_contact', 'date_recovered'} @@ -1015,11 +1459,13 @@ def __init__(self, sim, to_networkx=False): # Check that rescaling is not on if sim['rescale'] and sim['pop_scale']>1: - warningmsg = 'Warning: transmission tree results are unreliable when dynamic rescaling is on, since agents are reused! Please rerun with rescale=False and pop_scale=1 for reliable results.' + warningmsg = 'Warning: transmission tree results are unreliable when' \ + 'dynamic rescaling is on, since agents are reused! Please '\ + 'rerun with rescale=False and pop_scale=1 for reliable results.' print(warningmsg) - # Include the basic line list - self.infection_log = sc.dcp(people.infection_log) + # Include the basic line list -- copying directly is slow, so we'll make a copy later + self.infection_log = people.infection_log # Parse into sources and targets self.sources = [None for i in range(self.pop_size)] @@ -1037,8 +1483,9 @@ def __init__(self, sim, to_networkx=False): self.source_dates[target] = date # Each target has at most one source self.target_dates[source].append(date) # Each source can have multiple targets - # Count the number of targets each person has - self.n_targets = self.count_targets() + # Count the number of targets each person has, and the list of transmissions + self.count_targets() + self.count_transmissions() # Include the detailed transmission tree as well, as a list and as a dataframe self.make_detailed(people) @@ -1071,24 +1518,10 @@ def __len__(self): ''' try: return len(self.infection_log) - except: + except: # pragma: no cover return 0 - @property - def transmissions(self): - """ - Iterable over edges corresponding to transmission events - - This excludes edges corresponding to seeded infections without a source - """ - output = [] - for d in self.infection_log: - if d['source'] is not None: - output.append([d['source'], d['target']]) - return output - - def day(self, day=None, which=None): ''' Convenience function for converting an input to an integer day ''' if day is not None: @@ -1121,74 +1554,115 @@ def count_targets(self, start_day=None, end_day=None): if self.sources[i] is not None: if self.source_dates[i] >= start_day and self.source_dates[i] <= end_day: n_targets[i] = len(self.targets[i]) - n_target_inds = sc.findinds(~np.isnan(n_targets)) + n_target_inds = sc.findinds(np.isfinite(n_targets)) n_targets = n_targets[n_target_inds] + self.n_targets = n_targets return n_targets - def make_detailed(self, people, reset=False): - ''' Construct a detailed transmission tree, with additional information for each person ''' + def count_transmissions(self): + """ + Iterable over edges corresponding to transmission events - detailed = [None]*self.pop_size + This excludes edges corresponding to seeded infections without a source + """ + source_inds = [] + target_inds = [] + transmissions = [] + for d in self.infection_log: + if d['source'] is not None: + src = d['source'] + trg = d['target'] + source_inds.append(src) + target_inds.append(trg) + transmissions.append([src, trg]) + self.transmissions = transmissions + self.source_inds = source_inds + self.target_inds = target_inds + return transmissions - for transdict in self.infection_log: - # Pull out key quantities - ddict = sc.dcp(transdict) # For "detailed dictionary" - source = ddict['source'] - target = ddict['target'] - ddict['s'] = {} # Source properties - ddict['t'] = {} # Target properties + def make_detailed(self, people, reset=False): + ''' Construct a detailed transmission tree, with additional information for each person ''' + + def df_to_arrdict(df): + ''' Convert a dataframe to a dictionary of arrays ''' + arrdict = dict() + for col in df.columns: + arrdict[col] = df[col].values + return arrdict - # If the source is available (e.g. not a seed infection), loop over both it and the target - if source is not None: - stdict = {'s':source, 't':target} - else: - stdict = {'t':target} + # Convert infection log to a dataframe and from there to a dict of arrays + inflog = df_to_arrdict(sc.dcp(pd.DataFrame(self.infection_log))) - # Pull out each of the attributes relevant to transmission - attrs = ['age', 'date_exposed', 'date_symptomatic', 'date_tested', 'date_diagnosed', 'date_quarantined', 'date_end_quarantine', 'date_severe', 'date_critical', 'date_known_contact'] - for st,stind in stdict.items(): - for attr in attrs: - ddict[st][attr] = people[attr][stind] - if source is not None: - for attr in attrs: - if attr.startswith('date_'): - is_attr = attr.replace('date_', 'is_') # Convert date to a boolean, e.g. date_diagnosed -> is_diagnosed - if attr == 'date_quarantined': # This has an end date specified - ddict['s'][is_attr] = ddict['s'][attr] <= ddict['date'] and not (ddict['s']['date_end_quarantine'] <= ddict['date']) - elif attr != 'date_end_quarantine': # This is not a state - ddict['s'][is_attr] = ddict['s'][attr] <= ddict['date'] # These don't make sense for people just infected (targets), only sources - - ddict['s']['is_asymp'] = np.isnan(people.date_symptomatic[source]) - ddict['s']['is_presymp'] = ~ddict['s']['is_asymp'] and ~ddict['s']['is_symptomatic'] # Not asymptomatic and not currently symptomatic - ddict['t']['is_quarantined'] = ddict['t']['date_quarantined'] <= ddict['date'] and not (ddict['t']['date_end_quarantine'] <= ddict['date']) # This is the only target date that it makes sense to define since it can happen before infection - - detailed[target] = ddict - - self.detailed = detailed - - # Also re-parse the infection log and convert to a dataframe - - ttlist = [] - for source_ind, target_ind in self.transmissions: - ddict = self.detailed[target_ind] - source = ddict['s'] - target = ddict['t'] - - tdict = {} - tdict['date'] = ddict['date'] - tdict['layer'] = ddict['layer'] - tdict['s_asymp'] = np.isnan(source['date_symptomatic']) # True if they *never* became symptomatic - tdict['s_presymp'] = ~tdict['s_asymp'] and tdict['date']self.n_days): continue n_infected.append(self.graph.out_degree(i)) - except Exception as E: + except Exception as E: # pragma: no cover errormsg = f'Unable to compute r0 ({str(E)}): you may need to reinitialize the transmission tree with to_networkx=True' raise RuntimeError(errormsg) return np.mean(n_infected) @@ -1254,6 +1730,8 @@ def plot_quantity(key, title, i): dat.plot(ax=ax, legend=None, **plot_args) pl.legend(title=None) ax.set_title(title) + cvpl.date_formatter(start_day=self.sim_start, ax=ax) + ax.set_ylabel('Count') to_plot = dict( layer = 'Layer', @@ -1315,22 +1793,23 @@ def animate(self, *args, **kwargs): quars = [list() for i in range(n)] # Construct each frame of the animation - for ddict in self.detailed: # Loop over every person - if ddict is None: + detailed = self.detailed.to_dict('records') # Convert to the old style + for ddict in detailed: # Loop over every person + if np.isnan(ddict['source']): continue # Skip the 'None' node corresponding to seeded infections frame = {} tdq = {} # Short for "tested, diagnosed, or quarantined" - target = ddict['t'] target_ind = ddict['target'] - if not np.isnan(ddict['date']): # If this person was infected + if np.isfinite(ddict['date']): # If this person was infected source_ind = ddict['source'] # Index of the person who infected the target target_date = ddict['date'] - if source_ind is not None: # Seed infections and importations won't have a source - source_date = self.detailed[source_ind]['date'] + if np.isfinite(source_ind): # Seed infections and importations won't have a source + source_ind = int(source_ind) + source_date = detailed[source_ind]['date'] else: source_ind = 0 source_date = 0 @@ -1346,14 +1825,14 @@ def animate(self, *args, **kwargs): tdq['t'] = target_ind tdq['d'] = target_date tdq['c'] = colors[int(target_ind)] - date_t = target['date_tested'] - date_d = target['date_diagnosed'] - date_q = target['date_known_contact'] - if ~np.isnan(date_t) and date_t < n: + date_t = ddict['trg_date_tested'] + date_d = ddict['trg_date_diagnosed'] + date_q = ddict['trg_date_known_contact'] + if np.isfinite(date_t) and date_t < n: tests[int(date_t)].append(tdq) - if ~np.isnan(date_d) and date_d < n: + if np.isfinite(date_d) and date_d < n: diags[int(date_d)].append(tdq) - if ~np.isnan(date_q) and date_q < n: + if np.isfinite(date_q) and date_q < n: quars[int(date_q)].append(tdq) else: diff --git a/covasim/base.py b/covasim/base.py index 5621234ef..bb29a1b97 100644 --- a/covasim/base.py +++ b/covasim/base.py @@ -13,7 +13,6 @@ from . import misc as cvm from . import defaults as cvd from . import parameters as cvpar -from .settings import options as cvo # Specify all externally visible classes this file defines __all__ = ['ParsObj', 'Result', 'BaseSim', 'BasePeople', 'Person', 'FlexDict', 'Contacts', 'Layer'] @@ -23,17 +22,14 @@ class FlexPretty(sc.prettyobj): ''' - A class that by default changes the display type depending on the current level - of verbosity. + A class that supports multiple different display options: namely obj.brief() + for a one-line description and obj.disp() for a full description. ''' def __repr__(self): - ''' Set display options based on current level of verbosity ''' + ''' Use brief repr by default ''' try: - if cvo['verbose']: - string = self._disp() - else: - string = self._brief() + string = self._brief() except Exception as E: string = sc.objectid(self) string += f'Warning, something went wrong printing object:\n{str(E)}' @@ -127,6 +123,7 @@ class Result(object): npts (int): if values is None, precreate it to be of this length scale (bool): whether or not the value scales by population scale factor color (str/arr): default color for plotting (hex or RGB notation) + n_strains (int): the number of strains the result is for (0 for results not by strain) **Example**:: @@ -136,15 +133,21 @@ class Result(object): print(r1.values) ''' - def __init__(self, name=None, npts=None, scale=True, color=None): + def __init__(self, name=None, npts=None, scale=True, color=None, n_strains=0): self.name = name # Name of this result self.scale = scale # Whether or not to scale the result by the scale factor if color is None: - color = '#000000' + color = cvd.get_default_colors()['default'] self.color = color # Default color if npts is None: npts = 0 - self.values = np.array(np.zeros(int(npts)), dtype=cvd.result_float) + npts = int(npts) + + if n_strains>0: + self.values = np.zeros((n_strains, npts), dtype=cvd.result_float) + else: + self.values = np.zeros(npts, dtype=cvd.result_float) + self.low = None self.high = None return @@ -233,22 +236,38 @@ def _brief(self): string = f'Sim({labelstr}; {start} to {end}; pop: {pop_size:n} {pop_type}; epi: {results})' # ...but if anything goes wrong, return the default with a warning - except Exception as E: + except Exception as E: # pragma: no cover string = sc.objectid(self) - string += f'Warning, sim appears to be malformed:\n{str(E)}' + string += f'Warning, sim appears to be malformed; use sim.disp() for details:\n{str(E)}' return string def update_pars(self, pars=None, create=False, **kwargs): ''' Ensure that metaparameters get used properly before being updated ''' + + # Merge everything together pars = sc.mergedicts(pars, kwargs) if pars: + + # Define aliases + mapping = dict( + n_agents = 'pop_size', + init_infected = 'pop_infected', + ) + for key1,key2 in mapping.items(): + if key1 in pars: + pars[key2] = pars.pop(key1) + + # Handle other special parameters if pars.get('pop_type'): cvpar.reset_layer_pars(pars, force=False) if pars.get('prog_by_age'): pars['prognoses'] = cvpar.get_prognoses(by_age=pars['prog_by_age'], version=self._default_ver) # Reset prognoses - super().update_pars(pars=pars, create=create) # Call update_pars() for ParsObj + + # Call update_pars() for ParsObj + super().update_pars(pars=pars, create=create) + return @@ -279,7 +298,7 @@ def n(self): ''' Count the number of people -- if it fails, assume none ''' try: # By default, the length of the people dict return len(self.people) - except: # If it's None or missing + except: # pragma: no cover # If it's None or missing return 0 @property @@ -287,7 +306,7 @@ def scaled_pop_size(self): ''' Get the total population size, i.e. the number of agents times the scale factor -- if it fails, assume none ''' try: return self['pop_size']*self['pop_scale'] - except: # If it's None or missing + except: # pragma: no cover # If it's None or missing return 0 @property @@ -295,7 +314,7 @@ def npts(self): ''' Count the number of time points ''' try: return int(self['n_days'] + 1) - except: + except: # pragma: no cover return 0 @property @@ -303,7 +322,7 @@ def tvec(self): ''' Create a time vector ''' try: return np.arange(self.npts) - except: + except: # pragma: no cover return np.array([]) @property @@ -318,7 +337,7 @@ def datevec(self): ''' try: return self['start_day'] + self.tvec * dt.timedelta(days=1) - except: + except: # pragma: no cover return np.array([]) @@ -390,9 +409,23 @@ def date(self, ind, *args, dateformat=None, as_date=False): return dates - def result_keys(self): - ''' Get the actual results objects, not other things stored in sim.results ''' - keys = [key for key in self.results.keys() if isinstance(self.results[key], Result)] + def result_keys(self, which='main'): + ''' + Get the actual results objects, not other things stored in sim.results. + + If which is 'main', return only the main results keys. If 'strain', return + only strain keys. If 'all', return all keys. + + ''' + keys = [] + choices = ['main', 'strain', 'all'] + if which in ['main', 'all']: + keys += [key for key,res in self.results.items() if isinstance(res, Result)] + if which in ['strain', 'all'] and 'strain' in self.results: + keys += [key for key,res in self.results['strain'].items() if isinstance(res, Result)] + if which not in choices: # pragma: no cover + errormsg = f'Choice "which" not available; choices are: {sc.strjoin(choices)}' + raise ValueError(errormsg) return keys @@ -420,7 +453,7 @@ def export_results(self, for_json=True, filename=None, indent=2, *args, **kwargs ''' - if not self.results_ready: + if not self.results_ready: # pragma: no cover errormsg = 'Please run the sim before exporting the results' raise RuntimeError(errormsg) @@ -432,6 +465,10 @@ def export_results(self, for_json=True, filename=None, indent=2, *args, **kwargs for key,res in self.results.items(): if isinstance(res, Result): resdict[key] = res.values + if res.low is not None: + resdict[key+'_low'] = res.low + if res.high is not None: + resdict[key+'_high'] = res.high elif for_json: if key == 'date': resdict[key] = [str(d) for d in res] # Convert dates to strings @@ -473,7 +510,7 @@ def export_pars(self, filename=None, indent=2, *args, **kwargs): def to_json(self, filename=None, keys=None, tostring=False, indent=2, verbose=False, *args, **kwargs): ''' - Export results as JSON. + Export results and parameters as JSON. Args: filename (str): if None, return string; else, write to file @@ -511,7 +548,7 @@ def to_json(self, filename=None, keys=None, tostring=False, indent=2, verbose=Fa d['parameters'] = pardict elif key == 'summary': d['summary'] = dict(sc.dcp(self.summary)) - else: + else: # pragma: no cover try: d[key] = sc.sanitizejson(getattr(self, key)) except Exception as E: @@ -526,25 +563,46 @@ def to_json(self, filename=None, keys=None, tostring=False, indent=2, verbose=Fa return output - def to_excel(self, filename=None): + def to_df(self, date_index=False): ''' - Export results as XLSX + Export results to a pandas dataframe Args: - filename (str): if None, return string; else, write to file + date_index (bool): if True, use the date as the index + ''' + resdict = self.export_results(for_json=False) + df = pd.DataFrame.from_dict(resdict) + df['date'] = self.datevec + new_columns = ['t','date'] + df.columns[1:-1].tolist() # Get column order + df = df.reindex(columns=new_columns) # Reorder so 't' and 'date' are first + if date_index: + df = df.set_index('date') + return df + + + def to_excel(self, filename=None, skip_pars=None): + ''' + Export parameters and results as Excel format + + Args: + filename (str): if None, return string; else, write to file + skip_pars (list): if provided, a custom list parameters to exclude Returns: An sc.Spreadsheet with an Excel file, or writes the file to disk - ''' - resdict = self.export_results(for_json=False) - result_df = pd.DataFrame.from_dict(resdict) - result_df.index = self.datevec - result_df.index.name = 'date' + if skip_pars is None: + skip_pars = ['strain_map', 'vaccine_map'] # These include non-string keys so fail at sc.flattendict() + + # Export results + result_df = self.to_df(date_index=True) - par_df = pd.DataFrame.from_dict(sc.flattendict(self.pars, sep='_'), orient='index', columns=['Value']) + # Export parameters + pars = {k:v for k,v in self.pars.items() if k not in skip_pars} + par_df = pd.DataFrame.from_dict(sc.flattendict(pars, sep='_'), orient='index', columns=['Value']) par_df.index.name = 'Parameter' + # Convert to spreadsheet spreadsheet = sc.Spreadsheet() spreadsheet.freshbytes() with pd.ExcelWriter(spreadsheet.bytes, engine='xlsxwriter') as writer: @@ -644,7 +702,7 @@ def load(filename, *args, **kwargs): sim = cv.Sim.load('my-simulation.sim') ''' sim = cvm.load(filename, *args, **kwargs) - if not isinstance(sim, BaseSim): + if not isinstance(sim, BaseSim): # pragma: no cover errormsg = f'Cannot load object of {type(sim)} as a Sim object' raise TypeError(errormsg) return sim @@ -654,7 +712,7 @@ def _get_ia(self, which, label=None, partial=False, as_list=False, as_inds=False ''' Helper method for get_interventions() and get_analyzers(); see get_interventions() docstring ''' # Handle inputs - if which not in ['interventions', 'analyzers']: + 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) @@ -671,8 +729,10 @@ def _get_ia(self, which, label=None, partial=False, as_list=False, as_inds=False else: # Standard usage case position = 0 if first else -1 # Choose either the first or last element - if label is None: - label = position # Get the 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 @@ -691,24 +751,24 @@ def _get_ia(self, which, label=None, partial=False, as_list=False, as_inds=False elif isinstance(label, type) and isinstance(ia_obj, label): matches.append(ia_obj) match_inds.append(ind) - else: - errormsg = f'Could not interpret label type "{type(label)}": should be str, int, or {which} class' + 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: + elif as_list: # Used by get_interventions() output = matches - else: # Normal case, return actual interventions - if len(matches) == 0: + 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 + output = matches[position] # Return either the first or last match (usually), used by get_intervention() return output @@ -793,28 +853,40 @@ def __getitem__(self, key): If the key is an integer, alias `people.person()` to return a `Person` instance ''' - if isinstance(key, int): - return self.person(key) - try: return self.__dict__[key] - except: - errormsg = f'Key "{key}" is not a valid attribute of people' - raise AttributeError(errormsg) + except: # pragma: no cover + if isinstance(key, int): + return self.person(key) + else: + errormsg = f'Key "{key}" is not a valid attribute of people' + raise AttributeError(errormsg) def __setitem__(self, key, value): ''' Ditto ''' - if self._lock and key not in self.__dict__: - errormsg = f'Key "{key}" is not a valid attribute of people' + if self._lock and key not in self.__dict__: # pragma: no cover + errormsg = f'Key "{key}" is not a current attribute of people, and the people object is locked; see people.unlock()' raise AttributeError(errormsg) self.__dict__[key] = value return + def lock(self): + ''' Lock the people object to prevent keys from being added ''' + self._lock = True + return + + + def unlock(self): + ''' Unlock the people object to allow keys to be added ''' + self._lock = False + return + + def __len__(self): ''' This is just a scalar, but validate() and _resize_arrays() make sure it's right ''' - return self.pop_size + return int(self.pars['pop_size']) def __iter__(self): @@ -826,11 +898,20 @@ def __iter__(self): def __add__(self, people2): ''' Combine two people arrays ''' newpeople = sc.dcp(self) - for key in self.keys(): - newpeople.set(key, np.concatenate([newpeople[key], people2[key]]), die=False) # Allow size mismatch + keys = list(self.keys()) + for key in keys: + npval = newpeople[key] + p2val = people2[key] + if npval.ndim == 1: + newpeople.set(key, np.concatenate([npval, p2val], axis=0), die=False) # Allow size mismatch + elif npval.ndim == 2: + newpeople.set(key, np.concatenate([npval, p2val], axis=1), die=False) + else: + errormsg = f'Not sure how to combine arrays of {npval.ndim} dimensions for {key}' + raise NotImplementedError(errormsg) # Validate - newpeople.pop_size += people2.pop_size + newpeople.pars['pop_size'] += people2.pars['pop_size'] newpeople.validate() # Reassign UIDs so they're unique @@ -839,6 +920,12 @@ def __add__(self, people2): return newpeople + def __radd__(self, people2): + ''' Allows sum() to work correctly ''' + if not people2: return self + else: return self.__add__(people2) + + def _brief(self): ''' Return a one-line description of the people -- used internally and by repr(); @@ -847,7 +934,7 @@ def _brief(self): try: layerstr = ', '.join([str(k) for k in self.layer_keys()]) string = f'People(n={len(self):0n}; layers: {layerstr})' - except Exception as E: + except Exception as E: # pragma: no cover string = sc.objectid(self) string += f'Warning, multisim appears to be malformed:\n{str(E)}' return string @@ -862,7 +949,7 @@ def set(self, key, value, die=True): ''' Ensure sizes and dtypes match ''' current = self[key] value = np.array(value, dtype=self._dtypes[key]) # Ensure it's the right type - if die and len(value) != len(current): + if die and len(value) != len(current): # pragma: no cover errormsg = f'Length of new array does not match current ({len(value)} vs. {len(current)})' raise IndexError(errormsg) self[key] = value @@ -904,18 +991,34 @@ def count(self, key): ''' Count the number of people for a given key ''' return (self[key]>0).sum() + def count_by_strain(self, key, strain): + ''' Count the number of people for a given key ''' + return (self[key][strain,:]>0).sum() + def count_not(self, key): ''' Count the number of people who do not have a property for a given key ''' return (self[key]==0).sum() - def set_pars(self, pars): + def set_pars(self, pars=None): ''' - Very simple method to re-link the parameters stored in the people object - to the sim containing it: included simply for the sake of being explicit. + Re-link the parameters stored in the people object to the sim containing it, + and perform some basic validation. ''' - self.pars = pars + if pars is None: + pars = {} + elif sc.isnumber(pars): # Interpret as a population size + pars = {'pop_size':pars} # Ensure it's a dictionary + orig_pars = self.__dict__.get('pars') # Get the current parameters using dict's get method + pars = sc.mergedicts(orig_pars, pars) + if 'pop_size' not in pars: + errormsg = f'The parameter "pop_size" must be included in a population; keys supplied were:\n{sc.newlinejoin(pars.keys())}' + raise sc.KeyNotFoundError(errormsg) + pars['pop_size'] = int(pars['pop_size']) + pars.setdefault('n_strains', 1) + pars.setdefault('location', None) + self.pars = pars # Actually store the pars return @@ -951,7 +1054,7 @@ def layer_keys(self): except: # If not fully initialized try: keys = list(self.pars['beta_layer'].keys()) - except: # If not even partially initialized + except: # pragma: no cover # If not even partially initialized keys = [] return keys @@ -972,9 +1075,17 @@ def validate(self, die=True, verbose=False): # Check that the length of each array is consistent expected_len = len(self) + expected_strains = self.pars['n_strains'] for key in self.keys(): - actual_len = len(self[key]) - if actual_len != expected_len: + if self[key].ndim == 1: + actual_len = len(self[key]) + else: # If it's 2D, strains need to be checked separately + actual_strains, actual_len = self[key].shape + if actual_strains != expected_strains: + if verbose: + print(f'Resizing "{key}" from {actual_strains} to {expected_strains}') + self._resize_arrays(keys=key, new_size=(expected_strains, expected_len)) + if actual_len != expected_len: # pragma: no cover if die: errormsg = f'Length of key "{key}" did not match population size ({actual_len} vs. {expected_len})' raise IndexError(errormsg) @@ -990,16 +1101,22 @@ def validate(self, die=True, verbose=False): return - def _resize_arrays(self, pop_size=None, keys=None): + def _resize_arrays(self, new_size=None, keys=None): ''' Resize arrays if any mismatches are found ''' - if pop_size is None: - pop_size = len(self) - self.pop_size = pop_size + + # Handle None or tuple input (representing strains and pop_size) + if new_size is None: + new_size = len(self) + pop_size = new_size if not isinstance(new_size, tuple) else new_size[1] + self.pars['pop_size'] = pop_size + + # Reset sizes if keys is None: keys = self.keys() keys = sc.promotetolist(keys) for key in keys: - self[key].resize(pop_size, refcheck=False) + self[key].resize(new_size, refcheck=False) # Don't worry about cross-references to the arrays + return @@ -1024,7 +1141,15 @@ def person(self, ind): ''' Method to create person from the people ''' p = Person() for key in self.meta.all_states: - setattr(p, key, self[key][ind]) + data = self[key] + if data.ndim == 1: + val = data[ind] + elif data.ndim == 2: + val = data[:,ind] + else: + errormsg = f'Cannot extract data from {key}: unexpected dimensionality ({data.ndim})' + raise ValueError(errormsg) + setattr(p, key, val) contacts = {} for lkey, layer in self.contacts.items(): @@ -1045,7 +1170,7 @@ def from_people(self, people, resize=True): # Handle population size pop_size = len(people) if resize: - self._resize_arrays(pop_size=pop_size) + self._resize_arrays(new_size=pop_size) # Iterate over people -- slow! for p,person in enumerate(people): @@ -1055,6 +1180,41 @@ def from_people(self, people, resize=True): return + def to_graph(self): # pragma: no cover + ''' + Convert all people to a networkx MultiDiGraph, including all properties of + the people (nodes) and contacts (edges). + + **Example**:: + + import covasim as cv + import networkx as nx + sim = cv.Sim(pop_size=50, pop_type='hybrid', contacts=dict(h=3, s=10, w=10, c=5)).run() + G = sim.people.to_graph() + nodes = G.nodes(data=True) + edges = G.edges(keys=True) + node_colors = [n['age'] for i,n in nodes] + layer_map = dict(h='#37b', s='#e11', w='#4a4', c='#a49') + edge_colors = [layer_map[G[i][j][k]['layer']] for i,j,k in edges] + edge_weights = [G[i][j][k]['beta']*5 for i,j,k in edges] + nx.draw(G, node_color=node_colors, edge_color=edge_colors, width=edge_weights, alpha=0.5) + ''' + import networkx as nx + + # Copy data from people into graph + G = self.contacts.to_graph() + for key in self.keys(): + data = {k:v for k,v in enumerate(self[key])} + nx.set_node_attributes(G, data, name=key) + + # Include global layer weights + for u,v,k in G.edges(keys=True): + edge = G[u][v][k] + edge['beta'] *= self.pars['beta_layer'][edge['layer']] + + return G + + def init_contacts(self, reset=False): ''' Initialize the contacts dataframe with the correct columns and data types ''' @@ -1092,7 +1252,7 @@ def add_contacts(self, contacts, lkey=None, beta=None): new_contacts[lkey] = pd.DataFrame.from_dict(contacts) elif isinstance(contacts, list): # Assume it's a list of contacts by person, not an edgelist new_contacts = self.make_edgelist(contacts) # Assume contains key info - else: + else: # pragma: no cover errormsg = f'Cannot understand contacts of type {type(contacts)}; expecting dataframe, array, or dict' raise TypeError(errormsg) @@ -1107,7 +1267,7 @@ def add_contacts(self, contacts, lkey=None, beta=None): # Create the layer if it doesn't yet exist if lkey not in self.contacts: - self.contacts[lkey] = Layer() + self.contacts[lkey] = Layer(label=lkey) # Actually include them, and update properties if supplied for col in self.contacts[lkey].keys(): # Loop over the supplied columns @@ -1144,7 +1304,7 @@ def make_edgelist(self, contacts): # Turn into a dataframe for lkey in lkeys: - new_layer = Layer() + new_layer = Layer(label=lkey) for ckey,value in new_contacts[lkey].items(): new_layer[ckey] = np.array(value, dtype=new_layer.meta[ckey]) new_contacts[lkey] = new_layer @@ -1214,8 +1374,8 @@ class Contacts(FlexDict): ''' def __init__(self, layer_keys=None): if layer_keys is not None: - for key in layer_keys: - self[key] = Layer() + for lkey in layer_keys: + self[lkey] = Layer(label=lkey) return def __repr__(self): @@ -1234,7 +1394,7 @@ def __len__(self): for key in self.keys(): try: output += len(self[key]) - except: + except: # pragma: no cover pass return output @@ -1246,7 +1406,7 @@ def add_layer(self, **kwargs): **Example**:: - hospitals_layer = cv.Layer() + hospitals_layer = cv.Layer(label='hosp') sim.people.contacts.add_layer(hospitals=hospitals_layer) ''' for lkey,layer in kwargs.items(): @@ -1271,16 +1431,72 @@ def pop_layer(self, *args): return + def to_graph(self): # pragma: no cover + ''' + Convert all layers to a networkx MultiDiGraph + + **Example**:: + + import networkx as nx + sim = cv.Sim(pop_size=50, pop_type='hybrid').run() + G = sim.people.contacts.to_graph() + nx.draw(G) + ''' + import networkx as nx + H = nx.MultiDiGraph() + for lkey,layer in self.items(): + G = layer.to_graph() + H = nx.compose(H, nx.MultiDiGraph(G)) + return H + + + class Layer(FlexDict): - ''' A small class holding a single layer of contacts ''' + ''' + A small class holding a single layer of contact edges (connections) between people. - def __init__(self, **kwargs): + The input is typically three arrays: person 1 of the connection, person 2 of + the connection, and the weight of the connection. Connections are undirected; + each person is both a source and sink. + + This class is usually not invoked directly by the user, but instead is called + as part of the population creation. + + Args: + p1 (array): an array of N connections, representing people on one side of the connection + p2 (array): an array of people on the other side of the connection + beta (array): an array of weights for each connection + label (str): the name of the layer (optional) + kwargs (dict): other keys copied directly into the layer + + Note that all arguments (except for label) must be arrays of the same length, + although not all have to be supplied at the time of creation (they must all + be the same at the time of initialization, though, or else validation will fail). + + **Examples**:: + + # Generate an average of 10 contacts for 1000 people + n = 10_000 + n_people = 1000 + p1 = np.random.randint(n_people, size=n) + p2 = np.random.randint(n_people, size=n) + beta = np.ones(n) + layer = cv.Layer(p1=p1, p2=p2, beta=beta, label='rand') + + # Convert one layer to another with extra columns + index = np.arange(n) + self_conn = p1 == p2 + layer2 = cv.Layer(**layer, index=index, self_conn=self_conn, label=layer.label) + ''' + + def __init__(self, label=None, **kwargs): self.meta = { 'p1': cvd.default_int, # Person 1 'p2': cvd.default_int, # Person 2 'beta': cvd.default_float, # Default transmissibility for this contact type } self.basekey = 'p1' # Assign a base key for calculating lengths and performing other operations + self.label = label # Initialize the keys of the layers for key,dtype in self.meta.items(): @@ -1288,7 +1504,7 @@ def __init__(self, **kwargs): # Set data, if provided for key,value in kwargs.items(): - self[key] = np.array(value, dtype=self.meta[key]) + self[key] = np.array(value, dtype=self.meta.get(key)) return @@ -1296,14 +1512,16 @@ def __init__(self, **kwargs): def __len__(self): try: return len(self[self.basekey]) - except: + except: # pragma: no cover return 0 def __repr__(self): ''' Convert to a dataframe for printing ''' + namestr = self.__class__.__name__ + labelstr = f'"{self.label}"' if self.label else '' keys_str = ', '.join(self.keys()) - output = f'Layer({keys_str})\n' + output = f'{namestr}({labelstr}, {keys_str})\n' # e.g. Layer("h", p1, p2, beta) output += self.to_df().__repr__() return output @@ -1388,13 +1606,34 @@ def to_df(self): return df - def from_df(self, df): + def from_df(self, df, keys=None): ''' Convert from a dataframe ''' - for key in self.meta_keys(): + if keys is None: + keys = self.meta_keys() + for key in keys: self[key] = df[key].to_numpy() return self + def to_graph(self): # pragma: no cover + ''' + Convert to a networkx DiGraph + + **Example**:: + + import networkx as nx + sim = cv.Sim(pop_size=20, pop_type='hybrid').run() + G = sim.people.contacts['h'].to_graph() + nx.draw(G) + ''' + import networkx as nx + data = [np.array(self[k], dtype=dtype).tolist() for k,dtype in [('p1', int), ('p2', int), ('beta', float)]] + G = nx.DiGraph() + G.add_weighted_edges_from(zip(*data), weight='beta') + nx.set_edge_attributes(G, self.label, name='layer') + return G + + def find_contacts(self, inds, as_array=True): """ Find all contacts of the specified people @@ -1424,7 +1663,7 @@ def find_contacts(self, inds, as_array=True): # Check types if not isinstance(inds, np.ndarray): inds = sc.promotetoarray(inds) - if inds.dtype != np.int64: # This is int64 since indices often come from cv.true(), which returns int64 + if inds.dtype != np.int64: # pragma: no cover # This is int64 since indices often come from cv.true(), which returns int64 inds = np.array(inds, dtype=np.int64) # Find the contacts @@ -1434,3 +1673,33 @@ def find_contacts(self, inds, as_array=True): contact_inds.sort() # Sorting ensures that the results are reproducible for a given seed as well as being identical to previous versions of Covasim return contact_inds + + + def update(self, people, frac=1.0): + ''' + Regenerate contacts on each timestep. + + This method gets called if the layer appears in ``sim.pars['dynam_lkeys']``. + The Layer implements the update procedure so that derived classes can customize + the update e.g. implementing over-dispersion/other distributions, random + clusters, etc. + + Typically, this method also takes in the ``people`` object so that the + update can depend on person attributes that may change over time (e.g. + changing contacts for people that are severe/critical). + + Args: + frac (float): the fraction of contacts to update on each timestep + ''' + # Choose how many contacts to make + pop_size = len(people) # Total number of people + n_contacts = len(self) # Total number of contacts + n_new = int(np.round(n_contacts*frac)) # Since these get looped over in both directions later + inds = cvu.choose(n_contacts, n_new) + + # Create the contacts, not skipping self-connections + self['p1'][inds] = np.array(cvu.choose_r(max_n=pop_size, n=n_new), dtype=cvd.default_int) # Choose with replacement + self['p2'][inds] = np.array(cvu.choose_r(max_n=pop_size, n=n_new), dtype=cvd.default_int) + self['beta'][inds] = np.ones(n_new, dtype=cvd.default_float) + return + diff --git a/covasim/defaults.py b/covasim/defaults.py index 124bef548..cd431603f 100644 --- a/covasim/defaults.py +++ b/covasim/defaults.py @@ -12,7 +12,7 @@ from .settings import options as cvo # To set options # Specify all externally visible functions this file defines -- other things are available as e.g. cv.defaults.default_int -__all__ = ['default_float', 'default_int', 'get_colors', 'get_sim_plots', 'get_scen_plots'] +__all__ = ['default_float', 'default_int', 'get_default_colors', 'get_default_plots'] #%% Specify what data types to use @@ -23,7 +23,7 @@ default_int = np.int32 nbfloat = nb.float32 nbint = nb.int32 -elif cvo.precision == 64: +elif cvo.precision == 64: # pragma: no cover default_float = np.float64 default_int = np.int64 nbfloat = nb.float64 @@ -37,174 +37,352 @@ class PeopleMeta(sc.prettyobj): ''' For storing all the keys relating to a person and people ''' - # Set the properties of a person - person = [ - 'uid', # Int - 'age', # Float - 'sex', # Float - 'symp_prob', # Float - 'severe_prob', # Float - 'crit_prob', # Float - 'death_prob', # Float - 'rel_trans', # Float - 'rel_sus', # Float - ] - - # Set the states that a person can be in: these are all booleans per person -- used in people.py - states = [ - 'susceptible', - 'exposed', - 'infectious', - 'symptomatic', - 'severe', - 'critical', - 'tested', - 'diagnosed', - 'recovered', - 'dead', - 'known_contact', - 'quarantined', - ] - - # Set the dates various events took place: these are floats per person -- used in people.py - dates = [f'date_{state}' for state in states] # Convert each state into a date - dates.append('date_pos_test') # Store the date when a person tested which will come back positive - dates.append('date_end_quarantine') # Store the date when a person comes out of quarantine - - # Duration of different states: these are floats per person -- used in people.py - durs = [ - 'dur_exp2inf', - 'dur_inf2sym', - 'dur_sym2sev', - 'dur_sev2crit', - 'dur_disease', - ] - - all_states = person + states + dates + durs + def __init__(self): + + # Set the properties of a person + self.person = [ + 'uid', # Int + 'age', # Float + 'sex', # Float + 'symp_prob', # Float + 'severe_prob', # Float + 'crit_prob', # Float + 'death_prob', # Float + 'rel_trans', # Float + 'rel_sus', # Float + ] + + # Set the states that a person can be in: these are all booleans per person -- used in people.py + self.states = [ + 'susceptible', + 'naive', + 'exposed', + 'infectious', + 'symptomatic', + 'severe', + 'critical', + 'tested', + 'diagnosed', + 'recovered', + 'known_dead', + 'dead', + 'known_contact', + 'quarantined', + 'vaccinated', + ] + + # Strain states -- these are ints + self.strain_states = [ + 'exposed_strain', + 'infectious_strain', + 'recovered_strain', + ] + + # Strain states -- these are ints, by strain + self.by_strain_states = [ + 'exposed_by_strain', + 'infectious_by_strain', + ] + + # Immune states, by strain + self.imm_states = [ + 'sus_imm', # Float, by strain + 'symp_imm', # Float, by strain + 'sev_imm', # Float, by strain + ] + + # Neutralizing antibody states, not by strain + self.nab_states = [ + 'prior_symptoms', # Float + 'init_nab', # Float, initial neutralization titre relative to convalescent plasma + 'nab', # Float, current neutralization titre relative to convalescent plasma + ] + + # Additional vaccination states + self.vacc_states = [ + 'vaccinations', # Number of doses given per person + 'vaccine_source', # index of vaccine that individual received + ] + + # Set the dates various events took place: these are floats per person -- used in people.py + self.dates = [f'date_{state}' for state in self.states] # Convert each state into a date + self.dates.append('date_pos_test') # Store the date when a person tested which will come back positive + self.dates.append('date_end_quarantine') # Store the date when a person comes out of quarantine + + # Duration of different states: these are floats per person -- used in people.py + self.durs = [ + 'dur_exp2inf', + 'dur_inf2sym', + 'dur_sym2sev', + 'dur_sev2crit', + 'dur_disease', + ] + + self.all_states = self.person + self.states + self.strain_states + self.by_strain_states + self.imm_states + self.nab_states + self.vacc_states + self.dates + self.durs + + # Validate + self.state_types = ['person', 'states', 'strain_states', 'by_strain_states', 'imm_states', 'nab_states', 'vacc_states', 'dates', 'durs', 'all_states'] + for state_type in self.state_types: + states = getattr(self, state_type) + n_states = len(states) + n_unique_states = len(set(states)) + if n_states != n_unique_states: # pragma: no cover + errormsg = f'In {state_type}, only {n_unique_states} of {n_states} state names are unique' + raise ValueError(errormsg) + + return + #%% Define other defaults # A subset of the above states are used for results result_stocks = { - 'susceptible': 'Number susceptible', - 'exposed': 'Number exposed', - 'infectious': 'Number infectious', - 'symptomatic': 'Number symptomatic', - 'severe': 'Number of severe cases', - 'critical': 'Number of critical cases', - 'diagnosed': 'Number of confirmed cases', - 'quarantined': 'Number in quarantine', + 'susceptible': 'Number susceptible', + 'exposed': 'Number exposed', + 'infectious': 'Number infectious', + 'symptomatic': 'Number symptomatic', + 'severe': 'Number of severe cases', + 'critical': 'Number of critical cases', + 'recovered': 'Number recovered', + 'dead': 'Number dead', + 'diagnosed': 'Number of confirmed cases', + 'known_dead': 'Number of confirmed deaths', + 'quarantined': 'Number in quarantine', + 'vaccinated': 'Number of people vaccinated', +} + +result_stocks_by_strain = { + 'exposed_by_strain': 'Number exposed by strain', + 'infectious_by_strain': 'Number infectious by strain', } # The types of result that are counted as flows -- used in sim.py; value is the label suffix -result_flows = {'infections': 'infections', - 'infectious': 'infectious', - 'tests': 'tests', - 'diagnoses': 'diagnoses', - 'recoveries': 'recoveries', - 'symptomatic': 'symptomatic cases', - 'severe': 'severe cases', - 'critical': 'critical cases', - 'deaths': 'deaths', - 'quarantined': 'quarantined people', +result_flows = { + 'infections': 'infections', + 'reinfections': 'reinfections', + 'infectious': 'infectious', + 'symptomatic': 'symptomatic cases', + 'severe': 'severe cases', + 'critical': 'critical cases', + 'recoveries': 'recoveries', + 'deaths': 'deaths', + 'tests': 'tests', + 'diagnoses': 'diagnoses', + 'known_deaths': 'known deaths', + 'quarantined': 'quarantined people', + 'vaccinations': 'vaccinations', + 'vaccinated': 'vaccinated people' +} + +result_flows_by_strain = { + 'infections_by_strain': 'infections by strain', + 'infectious_by_strain': 'infectious by strain', } -# Define these here as well +result_imm = { + 'pop_nabs': 'Population average nabs', + 'pop_protection': 'Population average protective immunity' +} + +# Define new and cumulative flows new_result_flows = [f'new_{key}' for key in result_flows.keys()] cum_result_flows = [f'cum_{key}' for key in result_flows.keys()] +new_result_flows_by_strain = [f'new_{key}' for key in result_flows_by_strain.keys()] +cum_result_flows_by_strain = [f'cum_{key}' for key in result_flows_by_strain.keys()] + +# Parameters that can vary by strain +strain_pars = [ + 'rel_imm_strain', + 'rel_beta', + 'rel_symp_prob', + 'rel_severe_prob', + 'rel_crit_prob', + 'rel_death_prob', +] + +# Immunity is broken down according to 3 axes, as listed here +immunity_axes = ['sus', 'symp', 'sev'] + +# Immunity protection also varies depending on your infection history +immunity_sources = [ + 'asymptomatic', + 'mild', + 'severe', +] # Default age data, based on Seattle 2018 census data -- used in population.py default_age_data = np.array([ - [ 0, 4, 0.0605], - [ 5, 9, 0.0607], - [10, 14, 0.0566], - [15, 19, 0.0557], - [20, 24, 0.0612], - [25, 29, 0.0843], - [30, 34, 0.0848], - [35, 39, 0.0764], - [40, 44, 0.0697], - [45, 49, 0.0701], - [50, 54, 0.0681], - [55, 59, 0.0653], - [60, 64, 0.0591], - [65, 69, 0.0453], - [70, 74, 0.0312], - [75, 79, 0.02016], # Calculated based on 0.0504 total for >=75 - [80, 84, 0.01344], - [85, 89, 0.01008], - [90, 99, 0.00672], - ]) - - -def get_colors(): + [ 0, 4, 0.0605], + [ 5, 9, 0.0607], + [10, 14, 0.0566], + [15, 19, 0.0557], + [20, 24, 0.0612], + [25, 29, 0.0843], + [30, 34, 0.0848], + [35, 39, 0.0764], + [40, 44, 0.0697], + [45, 49, 0.0701], + [50, 54, 0.0681], + [55, 59, 0.0653], + [60, 64, 0.0591], + [65, 69, 0.0453], + [70, 74, 0.0312], + [75, 79, 0.02016], # Calculated based on 0.0504 total for >=75 + [80, 84, 0.01344], + [85, 89, 0.01008], + [90, 99, 0.00672], +]) + + +def get_default_colors(): ''' Specify plot colors -- used in sim.py. NB, includes duplicates since stocks and flows are named differently. ''' - colors = sc.objdict( - susceptible = '#5e7544', - infectious = '#c78f65', - infections = '#c75649', - exposed = '#c75649', # Duplicate - tests = '#aaa8ff', - diagnoses = '#8886cc', - diagnosed = '#8886cc', # Duplicate - recoveries = '#799956', - recovered = '#799956', # Duplicate - symptomatic = '#c1ad71', - severe = '#c1981d', - quarantined = '#5f1914', - critical = '#b86113', - deaths = '#000000', - dead = '#000000', # Duplicate - ) - return colors + c = sc.objdict() + c.susceptible = '#4d771e' + c.exposed = '#c78f65' + c.exposed_by_strain = '#c75649', + c.infectious = '#e45226' + c.infectious_by_strain = c.infectious + c.infections = '#b62413' + c.reinfections = '#732e26' + c.infections_by_strain = '#b62413' + c.tests = '#aaa8ff' + c.diagnoses = '#5f5cd2' + c.diagnosed = c.diagnoses + c.quarantined = '#5c399c' + c.vaccinations = c.quarantined # TODO: new color + c.vaccinated = c.quarantined + c.recoveries = '#9e1149' + c.recovered = c.recoveries + c.symptomatic = '#c1ad71' + c.severe = '#c1981d' + c.critical = '#b86113' + c.deaths = '#000000' + c.dead = c.deaths + c.known_dead = c.deaths + c.known_deaths = c.deaths + c.default = '#000000' + c.pop_nabs = '#32733d' + c.pop_protection = '#9e1149' + c.pop_symp_protection = '#b86113' + return c # Define the 'overview plots', i.e. the most useful set of plots to explore different aspects of a simulation overview_plots = [ - 'cum_infections', - 'cum_severe', - 'cum_critical', - 'cum_deaths', - 'cum_diagnoses', - 'new_infections', - 'new_severe', - 'new_critical', - 'new_deaths', - 'new_diagnoses', - 'n_infectious', - 'n_severe', - 'n_critical', - 'n_susceptible', - 'new_tests', - 'n_symptomatic', - 'new_quarantined', - 'n_quarantined', - 'test_yield', - 'r_eff', - ] - - -def get_sim_plots(which='default'): + 'cum_infections', + 'cum_severe', + 'cum_critical', + 'cum_deaths', + 'cum_known_deaths', + 'cum_diagnoses', + 'new_infections', + 'new_severe', + 'new_critical', + 'new_deaths', + 'new_diagnoses', + 'n_infectious', + 'n_severe', + 'n_critical', + 'n_susceptible', + 'new_tests', + 'n_symptomatic', + 'new_quarantined', + 'n_quarantined', + 'new_vaccinations', + 'new_vaccinated', + 'cum_vaccinated', + 'cum_vaccinations', + 'test_yield', + 'r_eff', +] + +overview_strain_plots = [ + 'cum_infections_by_strain', + 'new_infections_by_strain', + 'n_infectious_by_strain', + 'cum_reinfections', + 'new_reinfections', + 'pop_nabs', + 'pop_protection', + 'pop_symp_protection', +] + +def get_default_plots(which='default', kind='sim', sim=None): ''' Specify which quantities to plot; used in sim.py. Args: which (str): either 'default' or 'overview' ''' + + # Default plots -- different for sims and scenarios if which in [None, 'default']: - plots = sc.odict({ - 'Total counts': [ + + if 'sim' in kind: + plots = sc.odict({ + 'Total counts': [ + 'cum_infections', + 'n_infectious', + 'cum_diagnoses', + ], + 'Daily counts': [ + 'new_infections', + 'new_diagnoses', + ], + 'Health outcomes': [ + 'cum_severe', + 'cum_critical', + 'cum_deaths', + 'cum_known_deaths', + ], + }) + + elif 'scen' in kind: # pragma: no cover + plots = sc.odict({ + 'Cumulative infections': [ 'cum_infections', - 'n_infectious', - 'cum_diagnoses', ], - 'Daily counts': [ + 'New infections per day': [ 'new_infections', + ], + 'Cumulative deaths': [ + 'cum_deaths', + 'cum_known_deaths', + ], + }) + + else: + errormsg = f'Expecting "sim" or "scens", not "{kind}"' + raise ValueError(errormsg) + + # Show an overview + elif which == 'overview': # pragma: no cover + plots = sc.dcp(overview_plots) + + # Plot absolutely everything + elif which.lower() == 'all': # pragma: no cover + plots = sim.result_keys('all') + + # Show an overview plus strains + elif 'overview' in which and 'strain' in which: # pragma: no cover + plots = sc.dcp(overview_plots) + sc.dcp(overview_strain_plots) + + # Show default but with strains + elif 'strain' in which: # pragma: no cover + plots = sc.odict({ + 'Cumulative infections by strain': [ + 'cum_infections_by_strain', + ], + 'New infections by strain': [ + 'new_infections_by_strain', + ], + 'Diagnoses': [ + 'cum_diagnoses', 'new_diagnoses', ], 'Health outcomes': [ @@ -213,32 +391,18 @@ def get_sim_plots(which='default'): 'cum_deaths', ], }) - elif which == 'overview': - plots = sc.dcp(overview_plots) - else: - errormsg = f'The choice which="{which}" is not supported' - raise ValueError(errormsg) - return plots + # Plot SEIR compartments + elif which.lower() == 'seir': # pragma: no cover + plots = [ + 'n_susceptible', + 'n_preinfectious', + 'n_infectious', + 'n_removed', + ], -def get_scen_plots(which='default'): - ''' Default scenario plots -- used in run.py ''' - if which in [None, 'default']: - plots = sc.odict({ - 'Cumulative infections': [ - 'cum_infections', - ], - 'New infections per day': [ - 'new_infections', - ], - 'Cumulative deaths': [ - 'cum_deaths', - ], - }) - elif which == 'overview': - plots = sc.dcp(overview_plots) - else: - errormsg = f'The choice which="{which}" is not supported' + else: # pragma: no cover + errormsg = f'The choice which="{which}" is not supported: choices are "default", "overview", "all", "strain", "overview-strain", or "seir"' raise ValueError(errormsg) - return plots + return plots diff --git a/covasim/immunity.py b/covasim/immunity.py new file mode 100644 index 000000000..10eb29800 --- /dev/null +++ b/covasim/immunity.py @@ -0,0 +1,465 @@ +''' +Defines classes and methods for calculating immunity +''' + +import numpy as np +import sciris as sc +from . import utils as cvu +from . import defaults as cvd +from . import parameters as cvpar +from . import interventions as cvi + + +# %% Define strain class -- all other functions are for internal use only + +__all__ = ['strain'] + + +class strain(sc.prettyobj): + ''' + Add a new strain to the sim + + Args: + strain (str/dict): name of strain, or dictionary of parameters specifying information about the strain + days (int/list): day(s) on which new variant is introduced + label (str): if strain is supplied as a dict, the name of the strain + n_imports (int): the number of imports of the strain to be added + rescale (bool): whether the number of imports should be rescaled with the population + + **Example**:: + + b117 = cv.strain('b117', days=10) # Make strain B117 active from day 10 + p1 = cv.strain('p1', days=15) # Make strain P1 active from day 15 + my_var = cv.strain(strain={'rel_beta': 2.5}, label='My strain', days=20) + sim = cv.Sim(strains=[b117, p1, my_var]).run() # Add them all to the sim + sim2 = cv.Sim(strains=cv.strain('b117', days=0, n_imports=20), pop_infected=0).run() # Replace default strain with b117 + ''' + + def __init__(self, strain, days, label=None, n_imports=1, rescale=True): + self.days = days # Handle inputs + self.n_imports = int(n_imports) + self.rescale = rescale + self.index = None # Index of the strain in the sim; set later + self.label = None # Strain label (used as a dict key) + self.p = None # This is where the parameters will be stored + self.parse(strain=strain, label=label) # Strains can be defined in different ways: process these here + self.initialized = False + return + + + def parse(self, strain=None, label=None): + ''' Unpack strain information, which may be given as either a string or a dict ''' + + # Option 1: strains can be chosen from a list of pre-defined strains + if isinstance(strain, str): + + choices, mapping = cvpar.get_strain_choices() + known_strain_pars = cvpar.get_strain_pars() + + label = strain.lower() + for txt in ['.', ' ', 'strain', 'variant', 'voc']: + label = label.replace(txt, '') + + if label in mapping: + label = mapping[label] + strain_pars = known_strain_pars[label] + else: + errormsg = f'The selected variant "{strain}" is not implemented; choices are:\n{sc.pp(choices, doprint=False)}' + raise NotImplementedError(errormsg) + + # Option 2: strains can be specified as a dict of pars + elif isinstance(strain, dict): + + default_strain_pars = cvpar.get_strain_pars(default=True) + default_keys = list(default_strain_pars.keys()) + + # Parse label + strain_pars = strain + label = strain_pars.pop('label', label) # Allow including the label in the parameters + if label is None: + label = 'custom' + + # Check that valid keys have been supplied... + invalid = [] + for key in strain_pars.keys(): + if key not in default_keys: + invalid.append(key) + if len(invalid): + errormsg = f'Could not parse strain keys "{sc.strjoin(invalid)}"; valid keys are: "{sc.strjoin(cvd.strain_pars)}"' + raise sc.KeyNotFoundError(errormsg) + + # ...and populate any that are missing + for key in default_keys: + if key not in strain_pars: + strain_pars[key] = default_strain_pars[key] + + else: + errormsg = f'Could not understand {type(strain)}, please specify as a dict or a predefined strain:\n{sc.pp(choices, doprint=False)}' + raise ValueError(errormsg) + + # Set label and parameters + self.label = label + self.p = sc.objdict(strain_pars) + + return + + + def initialize(self, sim): + ''' Update strain info in sim ''' + self.days = cvi.process_days(sim, self.days) # Convert days into correct format + sim['strain_pars'][self.label] = self.p # Store the parameters + self.index = list(sim['strain_pars'].keys()).index(self.label) # Find where we are in the list + sim['strain_map'][self.index] = self.label # Use that to populate the reverse mapping + self.initialized = True + return + + + def apply(self, sim): + ''' Introduce new infections with this strain ''' + for ind in cvi.find_day(self.days, sim.t, interv=self, sim=sim): # Time to introduce strain + susceptible_inds = cvu.true(sim.people.susceptible) + rescale_factor = sim.rescale_vec[sim.t] if self.rescale else 1.0 + n_imports = sc.randround(self.n_imports/rescale_factor) # Round stochastically to the nearest number of imports + importation_inds = np.random.choice(susceptible_inds, n_imports) + sim.people.infect(inds=importation_inds, layer='importation', strain=self.index) + return + + + + +#%% Neutralizing antibody methods + +def get_vaccine_pars(pars): + ''' + Temporary helper function to get vaccine parameters; to be refactored + + TODO: use people.vaccine_source to get the per-person specific NAb decay + ''' + try: + vaccine = pars['vaccine_map'][0] # For now, just use the first vaccine, if available + vaccine_pars = pars['vaccine_pars'][vaccine] + except: + vaccine_pars = pars # Otherwise, just use defaults for natural immunity + + return vaccine_pars + + +def init_nab(people, inds, prior_inf=True): + ''' + Draws an initial neutralizing antibody (NAb) level for individuals. + Can come from a natural infection or vaccination and depends on if there is prior immunity: + 1) a natural infection. If individual has no existing NAb, draw from distribution + depending upon symptoms. If individual has existing NAb, multiply booster impact + 2) Vaccination. If individual has no existing NAb, draw from distribution + depending upon vaccine source. If individual has existing NAb, multiply booster impact + ''' + + nab_arrays = people.nab[inds] + prior_nab_inds = cvu.idefined(nab_arrays, inds) # Find people with prior NAb + no_prior_nab_inds = np.setdiff1d(inds, prior_nab_inds) # Find people without prior NAb + peak_nab = people.init_nab[prior_nab_inds] + pars = people.pars + + # NAb from infection + if prior_inf: + nab_boost = pars['nab_boost'] # Boosting factor for natural infection + # 1) No prior NAb: draw NAb from a distribution and compute + if len(no_prior_nab_inds): + init_nab = cvu.sample(**pars['nab_init'], size=len(no_prior_nab_inds)) + prior_symp = people.prior_symptoms[no_prior_nab_inds] + no_prior_nab = (2**init_nab) * prior_symp + people.init_nab[no_prior_nab_inds] = no_prior_nab + + # 2) Prior NAb: multiply existing NAb by boost factor + if len(prior_nab_inds): + init_nab = peak_nab * nab_boost + people.init_nab[prior_nab_inds] = init_nab + + # NAb from a vaccine + else: + vaccine_pars = get_vaccine_pars(pars) + + # 1) No prior NAb: draw NAb from a distribution and compute + if len(no_prior_nab_inds): + init_nab = cvu.sample(**vaccine_pars['nab_init'], size=len(no_prior_nab_inds)) + people.init_nab[no_prior_nab_inds] = 2**init_nab + + # 2) Prior nab (from natural or vaccine dose 1): multiply existing nab by boost factor + if len(prior_nab_inds): + nab_boost = vaccine_pars['nab_boost'] # Boosting factor for vaccination + init_nab = peak_nab * nab_boost + people.init_nab[prior_nab_inds] = init_nab + + return + + +def check_nab(t, people, inds=None): + ''' Determines current NAb based on date since recovered/vaccinated.''' + + # Indices of people who've had some nab event + rec_inds = cvu.defined(people.date_recovered[inds]) + vac_inds = cvu.defined(people.date_vaccinated[inds]) + both_inds = np.intersect1d(rec_inds, vac_inds) + + # Time since boost + t_since_boost = np.full(len(inds), np.nan, dtype=cvd.default_int) + t_since_boost[rec_inds] = t-people.date_recovered[inds[rec_inds]] + t_since_boost[vac_inds] = t-people.date_vaccinated[inds[vac_inds]] + t_since_boost[both_inds] = t-np.maximum(people.date_recovered[inds[both_inds]],people.date_vaccinated[inds[both_inds]]) + + # Set current NAb + people.nab[inds] = people.pars['nab_kin'][t_since_boost] * people.init_nab[inds] + + return + + +def nab_to_efficacy(nab, ax, function_args): + ''' + Convert NAb levels to immunity protection factors, using the functional form + given in this paper: https://doi.org/10.1101/2021.03.09.21252641 + + Args: + nab (arr): an array of NAb levels + ax (str): can be 'sus', 'symp' or 'sev', corresponding to the efficacy of protection against infection, symptoms, and severe disease respectively + + Returns: + an array the same size as NAb, containing the immunity protection factors for the specified axis + ''' + + if ax not in ['sus', 'symp', 'sev']: + errormsg = f'Choice {ax} not in list of choices' + raise ValueError(errormsg) + args = function_args[ax] + + if ax == 'sus': + slope = args['slope'] + n_50 = args['n_50'] + efficacy = 1 / (1 + np.exp(-slope * (np.log10(nab) - np.log10(n_50)))) # from logistic regression computed in R using data from Khoury et al + else: + efficacy = np.full(len(nab), fill_value=args) + return efficacy + + + +# %% Immunity methods + +def init_immunity(sim, create=False): + ''' Initialize immunity matrices with all strains that will eventually be in the sim''' + + # Don't use this function if immunity is turned off + if not sim['use_waning']: + return + + # Pull out all of the circulating strains for cross-immunity + ns = sim['n_strains'] + + # If immunity values have been provided, process them + if sim['immunity'] is None or create: + + # Firstly, initialize immunity matrix with defaults. These are then overwitten with strain-specific values below + # Susceptibility matrix is of size sim['n_strains']*sim['n_strains'] + immunity = np.ones((ns, ns), dtype=cvd.default_float) # Fill with defaults + + # Next, overwrite these defaults with any known immunity values about specific strains + default_cross_immunity = cvpar.get_cross_immunity() + for i in range(ns): + label_i = sim['strain_map'][i] + for j in range(ns): + if i != j: # Populate cross-immunity + label_j = sim['strain_map'][j] + if label_i in default_cross_immunity and label_j in default_cross_immunity: + immunity[j][i] = default_cross_immunity[label_j][label_i] + else: # Populate own-immunity + immunity[i, i] = sim['strain_pars'][label_i]['rel_imm_strain'] + + sim['immunity'] = immunity + + # Next, precompute the NAb kinetics and store these for access during the sim + sim['nab_kin'] = precompute_waning(length=sim['n_days'], pars=sim['nab_decay']) + + return + + +def check_immunity(people, strain, sus=True, inds=None): + ''' + Calculate people's immunity on this timestep from prior infections + vaccination + + There are two fundamental sources of immunity: + + (1) prior exposure: degree of protection depends on strain, prior symptoms, and time since recovery + (2) vaccination: degree of protection depends on strain, vaccine, and time since vaccination + + Gets called from sim before computing trans_sus, sus=True, inds=None + Gets called from people.infect() to calculate prog/trans, sus=False, inds= inds of people being infected + ''' + + # Handle parameters and indices + pars = people.pars + vaccine_pars = get_vaccine_pars(pars) + was_inf = cvu.true(people.t >= people.date_recovered) # Had a previous exposure, now recovered + is_vacc = cvu.true(people.vaccinated) # Vaccinated + date_rec = people.date_recovered # Date recovered + immunity = pars['immunity'] # cross-immunity/own-immunity scalars to be applied to NAb level before computing efficacy + nab_eff = pars['nab_eff'] + + # If vaccines are present, extract relevant information about them + vacc_present = len(is_vacc) + if vacc_present: + vx_nab_eff_pars = vaccine_pars['nab_eff'] + vacc_mapping = np.array([vaccine_pars.get(label, 1.0) for label in pars['strain_map'].values()]) # TODO: make more robust + + # PART 1: Immunity to infection for susceptible individuals + if sus: + is_sus = cvu.true(people.susceptible) # Currently susceptible + was_inf_same = cvu.true((people.recovered_strain == strain) & (people.t >= date_rec)) # Had a previous exposure to the same strain, now recovered + was_inf_diff = np.setdiff1d(was_inf, was_inf_same) # Had a previous exposure to a different strain, now recovered + is_sus_vacc = np.intersect1d(is_sus, is_vacc) # Susceptible and vaccinated + is_sus_vacc = np.setdiff1d(is_sus_vacc, was_inf) # Susceptible, vaccinated without prior infection + is_sus_was_inf_same = np.intersect1d(is_sus, was_inf_same) # Susceptible and being challenged by the same strain + is_sus_was_inf_diff = np.intersect1d(is_sus, was_inf_diff) # Susceptible and being challenged by a different strain + + if len(is_sus_vacc): + vaccine_source = cvd.default_int(people.vaccine_source[is_sus_vacc]) # TODO: use vaccine source + vaccine_scale = vacc_mapping[strain] + current_nabs = people.nab[is_sus_vacc] + people.sus_imm[strain, is_sus_vacc] = nab_to_efficacy(current_nabs * vaccine_scale, 'sus', vx_nab_eff_pars) + + if len(is_sus_was_inf_same): # Immunity for susceptibles with prior exposure to this strain + current_nabs = people.nab[is_sus_was_inf_same] + people.sus_imm[strain, is_sus_was_inf_same] = nab_to_efficacy(current_nabs * immunity[strain, strain], 'sus', nab_eff) + + if len(is_sus_was_inf_diff): # Cross-immunity for susceptibles with prior exposure to a different strain + prior_strains = people.recovered_strain[is_sus_was_inf_diff] + prior_strains_unique = cvd.default_int(np.unique(prior_strains)) + for unique_strain in prior_strains_unique: + unique_inds = is_sus_was_inf_diff[cvu.true(prior_strains == unique_strain)] + current_nabs = people.nab[unique_inds] + people.sus_imm[strain, unique_inds] = nab_to_efficacy(current_nabs * immunity[strain, unique_strain], 'sus', nab_eff) + + # PART 2: Immunity to disease for currently-infected people + else: + is_inf_vacc = np.intersect1d(inds, is_vacc) + was_inf = np.intersect1d(inds, was_inf) + + if len(is_inf_vacc): # Immunity for infected people who've been vaccinated + vaccine_source = cvd.default_int(people.vaccine_source[is_inf_vacc]) # TODO: use vaccine source + vaccine_scale = vacc_mapping[strain] + current_nabs = people.nab[is_inf_vacc] + people.symp_imm[strain, is_inf_vacc] = nab_to_efficacy(current_nabs * vaccine_scale, 'symp', nab_eff) + people.sev_imm[strain, is_inf_vacc] = nab_to_efficacy(current_nabs * vaccine_scale, 'sev', nab_eff) + + if len(was_inf): # Immunity for reinfected people + current_nabs = people.nab[was_inf] + people.symp_imm[strain, was_inf] = nab_to_efficacy(current_nabs, 'symp', nab_eff) + people.sev_imm[strain, was_inf] = nab_to_efficacy(current_nabs, 'sev', nab_eff) + + return + + + +#%% Methods for computing waning + +def precompute_waning(length, pars=None): + ''' + Process functional form and parameters into values: + + - 'nab_decay' : specific decay function taken from https://doi.org/10.1101/2021.03.09.21252641 + - 'exp_decay' : exponential decay. Parameters should be init_val and half_life (half_life can be None/nan) + - 'linear_decay': linear decay + + Args: + length (float): length of array to return, i.e., for how long waning is calculated + pars (dict): passed to individual immunity functions + + Returns: + array of length 'length' of values + ''' + + pars = sc.dcp(pars) + form = pars.pop('form') + choices = [ + 'nab_decay', # Default if no form is provided + 'exp_decay', + 'linear_growth', + 'linear_decay' + ] + + # Process inputs + if form is None or form == 'nab_decay': + output = nab_decay(length, **pars) + + elif form == 'exp_decay': + if pars['half_life'] is None: pars['half_life'] = np.nan + output = exp_decay(length, **pars) + + elif form == 'linear_growth': + output = linear_growth(length, **pars) + + elif form == 'linear_decay': + output = linear_decay(length, **pars) + + else: + errormsg = f'The selected functional form "{form}" is not implemented; choices are: {sc.strjoin(choices)}' + raise NotImplementedError(errormsg) + + return output + + +def nab_decay(length, decay_rate1, decay_time1, decay_rate2): + ''' + Returns an array of length 'length' containing the evaluated function nab decay + function at each point. + + Uses exponential decay, with the rate of exponential decay also set to exponentially + decay (!) after 250 days. + + Args: + length (int): number of points + decay_rate1 (float): initial rate of exponential decay + decay_time1 (float): time on the first exponential decay + decay_rate2 (float): the rate at which the decay decays + ''' + def f1(t, decay_rate1): + ''' Simple exponential decay ''' + return np.exp(-t*decay_rate1) + + def f2(t, decay_rate1, decay_time1, decay_rate2): + ''' Complex exponential decay ''' + return np.exp(-t*(decay_rate1*np.exp(-(t-decay_time1)*decay_rate2))) + + t = np.arange(length, dtype=cvd.default_int) + y1 = f1(cvu.true(t<=decay_time1), decay_rate1) + y2 = f2(cvu.true(t>decay_time1), decay_rate1, decay_time1, decay_rate2) + y = np.concatenate([y1,y2]) + + return y + + +def exp_decay(length, init_val, half_life, delay=None): + ''' + Returns an array of length t with values for the immunity at each time step after recovery + ''' + decay_rate = np.log(2) / half_life if ~np.isnan(half_life) else 0. + if delay is not None: + t = np.arange(length-delay, dtype=cvd.default_int) + growth = linear_growth(delay, init_val/delay) + decay = init_val * np.exp(-decay_rate * t) + result = np.concatenate([growth, decay], axis=None) + else: + t = np.arange(length, dtype=cvd.default_int) + result = init_val * np.exp(-decay_rate * t) + return result + + +def linear_decay(length, init_val, slope): + ''' Calculate linear decay ''' + t = np.arange(length, dtype=cvd.default_int) + result = init_val - slope*t + result = np.maximum(result, 0) + return result + + +def linear_growth(length, slope): + ''' Calculate linear growth ''' + t = np.arange(length, dtype=cvd.default_int) + return (slope * t) diff --git a/covasim/interventions.py b/covasim/interventions.py index 9832388c2..041427b5d 100644 --- a/covasim/interventions.py +++ b/covasim/interventions.py @@ -13,6 +13,7 @@ from . import defaults as cvd from . import base as cvb from . import parameters as cvpar +from . import immunity as cvi from collections import defaultdict @@ -21,20 +22,27 @@ __all__ = ['InterventionDict', 'Intervention', 'dynamic_pars', 'sequence'] -def find_day(arr, t=None, which='first'): +def find_day(arr, t=None, interv=None, sim=None, which='first'): ''' Helper function to find if the current simulation time matches any day in the intervention. Although usually never more than one index is returned, it is returned as a list for the sake of easy iteration. Args: - arr (list): list of days in the intervention, or else a boolean array + arr (list/function): list of days in the intervention, or a boolean array; or a function that returns these t (int): current simulation time (can be None if a boolean array is used) which (str): what to return: 'first', 'last', or 'all' indices + interv (intervention): the intervention object (usually self); only used if arr is callable + sim (sim): the simulation object; only used if arr is callable Returns: inds (list): list of matching days; length zero or one unless which is 'all' + + New in version 2.1.2: arr can be a function with arguments interv and sim. ''' + if callable(arr): + arr = arr(interv, sim) + arr = sc.promotetoarray(arr) all_inds = sc.findinds(arr=arr, val=t) if len(all_inds) == 0 or which == 'all': inds = all_inds @@ -42,12 +50,145 @@ def find_day(arr, t=None, which='first'): inds = [all_inds[0]] elif which == 'last': inds = [all_inds[-1]] - else: + else: # pragma: no cover errormsg = f'Argument "which" must be "first", "last", or "all", not "{which}"' raise ValueError(errormsg) return inds +def preprocess_day(day, sim): + ''' + Preprocess a day: leave it as-is if it's a function, or try to convert it to + an integer if it's anything else. + ''' + if callable(day): # If it's callable, leave it as-is + return day + else: + day = sim.day(day) # Otherwise, convert it to an int + return day + + +def get_day(day, interv=None, sim=None): + ''' + Return the day if it's an integer, or call it if it's a function. + ''' + if callable(day): + return day(interv, sim) # If it's callable, call it + else: + return day # Otherwise, leave it as-is + + +def process_days(sim, days, return_dates=False): + ''' + Ensure lists of days are in consistent format. Used by change_beta, clip_edges, + and some analyzers. If day is 'end' or -1, use the final day of the simulation. + Optionally return dates as well as days. If days is callable, leave unchanged. + ''' + if callable(days): + return days + if sc.isstring(days) or not sc.isiterable(days): + days = sc.promotetolist(days) + for d,day in enumerate(days): + if day in ['end', -1]: + day = sim['end_day'] + days[d] = preprocess_day(day, sim) # Ensure it's an integer and not a string or something + days = np.sort(sc.promotetoarray(days)) # Ensure they're an array and in order + if return_dates: + dates = [sim.date(day) for day in days] # Store as date strings + return days, dates + else: + return days + + +def process_changes(sim, changes, days): + ''' + Ensure lists of changes are in consistent format. Used by change_beta and clip_edges. + ''' + changes = sc.promotetoarray(changes) + if sc.isiterable(days) and len(days) != len(changes): # pragma: no cover + errormsg = f'Number of days supplied ({len(days)}) does not match number of changes ({len(changes)})' + raise ValueError(errormsg) + return changes + + +def process_daily_data(daily_data, sim, start_day, as_int=False): + ''' + This function performs one of three things: if the daily test data are supplied as + a number, then it converts it to an array of the right length. If the daily + data are supplied as a Pandas series or dataframe with a date index, then it + reindexes it to match the start date of the simulation. If the daily data are + supplied as a string, then it will convert it to a column and try to read from + that. Otherwise, it does nothing. + + Args: + daily_data (str, number, dataframe, or series): the data to convert to standardized format + sim (Sim): the simulation object + start_day (date): the start day of the simulation, in already-converted datetime.date format + as_int (bool): whether to convert to an integer + ''' + # Handle string arguments + if sc.isstring(daily_data): + if daily_data == 'data': + daily_data = sim.data['new_tests'] # Use default name + else: + try: # pragma: no cover + daily_data = sim.data[daily_data] + except Exception as E: + errormsg = f'Tried to load testing data from sim.data["{daily_data}"], but that failed: {str(E)}.\nPlease ensure data are loaded into the sim and the column exists.' + raise ValueError(errormsg) from E + + # Handle other arguments + if sc.isnumber(daily_data): # If a number, convert to an array + if as_int: daily_data = int(daily_data) # Make it an integer + daily_data = np.array([daily_data] * sim.npts) + elif isinstance(daily_data, (pd.Series, pd.DataFrame)): + start_date = sim['start_day'] + dt.timedelta(days=start_day) + end_date = daily_data.index[-1] + dateindex = pd.date_range(start_date, end_date) + daily_data = daily_data.reindex(dateindex, fill_value=0).to_numpy() + + return daily_data + + +def get_subtargets(subtarget, sim): + ''' + A small helper function to see if subtargeting is a list of indices to use, + or a function that needs to be called. If a function, it must take a single + argument, a sim object, and return a list of indices. Also validates the values. + Currently designed for use with testing interventions, but could be generalized + to other interventions. Not typically called directly by the user. + + Args: + subtarget (dict): dict with keys 'inds' and 'vals'; see test_num() for examples of a valid subtarget dictionary + sim (Sim): the simulation object + ''' + + # Validation + if callable(subtarget): + subtarget = subtarget(sim) + + if 'inds' not in subtarget: # pragma: no cover + errormsg = f'The subtarget dict must have keys "inds" and "vals", but you supplied {subtarget}' + raise ValueError(errormsg) + + # Handle the two options of type + if callable(subtarget['inds']): # A function has been provided + subtarget_inds = subtarget['inds'](sim) # Call the function to get the indices + else: + subtarget_inds = subtarget['inds'] # The indices are supplied directly + + # Validate the values + if callable(subtarget['vals']): # A function has been provided + subtarget_vals = subtarget['vals'](sim) # Call the function to get the indices + else: + subtarget_vals = subtarget['vals'] # The indices are supplied directly + if sc.isiterable(subtarget_vals): + if len(subtarget_vals) != len(subtarget_inds): # pragma: no cover + errormsg = f'Length of subtargeting indices ({len(subtarget_inds)}) does not match length of values ({len(subtarget_vals)})' + raise ValueError(errormsg) + + return subtarget_inds, subtarget_vals + def InterventionDict(which, pars): ''' Generate an intervention from a dictionary. Although a function, it acts @@ -85,61 +226,83 @@ class Intervention: To retrieve a particular intervention from a sim, use sim.get_intervention(). Args: - label (str): a label for the intervention (used for plotting, and for ease of identification) - show_label (bool): whether or not to include the label, if provided, in the legend - do_plot (bool): whether or not to plot the intervention - line_args (dict): arguments passed to pl.axvline() when plotting + label (str): a label for the intervention (used for plotting, and for ease of identification) + show_label (bool): whether or not to include the label in the legend + do_plot (bool): whether or not to plot the intervention + line_args (dict): arguments passed to pl.axvline() when plotting ''' - def __init__(self, label=None, show_label=True, do_plot=None, line_args=None): + def __init__(self, label=None, show_label=False, do_plot=None, line_args=None): + self._store_args() # Store the input arguments so the intervention can be recreated + if label is None: label = self.__class__.__name__ # Use the class name if no label is supplied self.label = label # e.g. "Close schools" - self.show_label = show_label # Show the label by default + self.show_label = show_label # Do not show the label by default self.do_plot = do_plot if do_plot is not None else True # Plot the intervention, including if None - self.line_args = sc.mergedicts(dict(linestyle='--', c=[0,0,0]), line_args) # Do not set alpha by default due to the issue of overlapping interventions + self.line_args = sc.mergedicts(dict(linestyle='--', c='#aaa', lw=1.0), line_args) # Do not set alpha by default due to the issue of overlapping interventions self.days = [] # The start and end days of the intervention self.initialized = False # Whether or not it has been initialized + self.finalized = False # Whether or not it has been initialized return - def __repr__(self): - ''' Return a JSON-friendly output if possible, else revert to pretty repr ''' - try: - json = self.to_json() - which = json['which'] - pars = json['pars'] - parstr = ', '.join([f'{k}={v}' for k,v in pars.items()]) - output = f"cv.{which}({parstr})" - except Exception as E: - output = type(self) + f' ({str(E)})' # If that fails, print why - return output + def __repr__(self, jsonify=False): + ''' Return a JSON-friendly output if possible, else revert to short repr ''' + + if self.__class__.__name__ in __all__ or jsonify: + try: + json = self.to_json() + which = json['which'] + pars = json['pars'] + parstr = ', '.join([f'{k}={v}' for k,v in pars.items()]) + output = f"cv.{which}({parstr})" + except Exception as E: + output = type(self) + f' (error: {str(E)})' # If that fails, print why + return output + else: + return f'{self.__module__}.{self.__class__.__name__}()' def disp(self): ''' Print a detailed representation of the intervention ''' - return print(sc.prepr(self)) + return sc.pr(self) def _store_args(self): ''' Store the user-supplied arguments for later use in to_json ''' f0 = inspect.currentframe() # This "frame", i.e. Intervention.__init__() f1 = inspect.getouterframes(f0) # The list of outer frames - parent = f1[1].frame # The parent frame, e.g. change_beta.__init__() + parent = f1[2].frame # The parent frame, e.g. change_beta.__init__() _,_,_,values = inspect.getargvalues(parent) # Get the values of the arguments - self.input_args = {} - for key,value in values.items(): - if key == 'kwargs': # Store additional kwargs directly - for k2,v2 in value.items(): - self.input_args[k2] = v2 # These are already a dict - elif key not in ['self', '__class__']: # Everything else, but skip these - self.input_args[key] = value + if values: + self.input_args = {} + for key,value in values.items(): + if key == 'kwargs': # Store additional kwargs directly + for k2,v2 in value.items(): + self.input_args[k2] = v2 # These are already a dict + elif key not in ['self', '__class__']: # Everything else, but skip these + self.input_args[key] = value return - def initialize(self, sim): + def initialize(self, sim=None): ''' Initialize intervention -- this is used to make modifications to the intervention that can't be done until after the sim is created. ''' self.initialized = True + self.finalized = False + return + + + def finalize(self, sim=None): + ''' + Finalize intervention + + This method is run once as part of `sim.finalize()` enabling the intervention to perform any + final operations after the simulation is complete (e.g. rescaling) + ''' + if self.finalized: + raise RuntimeError('Intervention already finalized') # Raise an error because finalizing multiple times has a high probability of producing incorrect results e.g. applying rescale factors twice + self.finalized = True return @@ -166,6 +329,12 @@ def plot_intervention(self, sim, ax=None, **kwargs): This can be used to do things like add vertical lines on days when interventions take place. Can be disabled by setting self.do_plot=False. + Note 1: you can modify the plotting style via the ``line_args`` argument when + creating the intervention. + + Note 2: By default, the intervention is plotted at the days stored in self.days. + However, if there is a self.plot_days attribute, this will be used instead. + Args: sim: the Sim instance ax: the axis instance @@ -178,13 +347,20 @@ def plot_intervention(self, sim, ax=None, **kwargs): if self.do_plot or self.do_plot is None: if ax is None: ax = pl.gca() - for day in self.days: - if day is not None: - if self.show_label: # Choose whether to include the label in the legend - label = self.label - else: - label = None - ax.axvline(day, label=label, **line_args) + if hasattr(self, 'plot_days'): + days = self.plot_days + else: + days = self.days + if sc.isiterable(days): + label_shown = False # Don't show the label more than once + for day in days: + if sc.isnumber(day): + if self.show_label and not label_shown: # Choose whether to include the label in the legend + label = self.label + label_shown = True + else: + label = None + ax.axvline(day, label=label, **line_args) return @@ -244,21 +420,23 @@ def __init__(self, pars=None, **kwargs): # Do standard initialization super().__init__(**kwargs) # Initialize the Intervention object - self._store_args() # Store the input arguments so the intervention can be recreated # Handle the rest of the initialization subkeys = ['days', 'vals'] for parkey in pars.keys(): for subkey in subkeys: - if subkey not in pars[parkey].keys(): + if subkey not in pars[parkey].keys(): # pragma: no cover errormsg = f'Parameter {parkey} is missing subkey {subkey}' raise sc.KeyNotFoundError(errormsg) if sc.isnumber(pars[parkey][subkey]): # Allow scalar values or dicts, but leave everything else unchanged pars[parkey][subkey] = sc.promotetoarray(pars[parkey][subkey]) - len_days = len(pars[parkey]['days']) - len_vals = len(pars[parkey]['vals']) - if len_days != len_vals: - raise ValueError(f'Length of days ({len_days}) does not match length of values ({len_vals}) for parameter {parkey}') + days = pars[parkey]['days'] + vals = pars[parkey]['vals'] + if sc.isiterable(days): + len_days = len(days) + len_vals = len(vals) + if len_days != len_vals: # pragma: no cover + raise ValueError(f'Length of days ({len_days}) does not match length of values ({len_vals}) for parameter {parkey}') self.pars = pars return @@ -267,7 +445,7 @@ def apply(self, sim): ''' Loop over the parameters, and then loop over the days, applying them if any are found ''' t = sim.t for parkey,parval in self.pars.items(): - for ind in find_day(parval['days'], t): + for ind in find_day(parval['days'], t, interv=self, sim=sim): self.days.append(t) val = parval['vals'][ind] if isinstance(val, dict): @@ -296,8 +474,8 @@ class sequence(Intervention): def __init__(self, days, interventions, **kwargs): super().__init__(**kwargs) # Initialize the Intervention object - self._store_args() # Store the input arguments so the intervention can be recreated - assert len(days) == len(interventions) + if sc.isiterable(days): + assert len(days) == len(interventions) self.days = days self.interventions = interventions return @@ -305,57 +483,26 @@ def __init__(self, days, interventions, **kwargs): def initialize(self, sim): ''' Fix the dates ''' + super().initialize() self.days = [sim.day(day) for day in self.days] self.days_arr = np.array(self.days + [sim.npts]) for intervention in self.interventions: intervention.initialize(sim) - self.initialized = True return def apply(self, sim): + ''' Find the matching day, and see which intervention to activate ''' inds = find_day(self.days_arr <= sim.t, which='last') if len(inds): return self.interventions[inds[0]].apply(sim) - #%% Beta interventions __all__+= ['change_beta', 'clip_edges'] -def process_days(sim, days, return_dates=False): - ''' - Ensure lists of days are in consistent format. Used by change_beta, clip_edges, - and some analyzers. If day is 'end' or -1, use the final day of the simulation. - Optionally return dates as well as days. - ''' - if sc.isstring(days) or not sc.isiterable(days): - days = sc.promotetolist(days) - for d,day in enumerate(days): - if day in ['end', -1]: - day = sim['end_day'] - days[d] = sim.day(day) # Ensure it's an integer and not a string or something - days = np.sort(sc.promotetoarray(days)) # Ensure they're an array and in order - if return_dates: - dates = [sim.date(day) for day in days] # Store as date strings - return days, dates - else: - return days - - -def process_changes(sim, changes, days): - ''' - Ensure lists of changes are in consistent format. Used by change_beta and clip_edges. - ''' - changes = sc.promotetoarray(changes) - if len(days) != len(changes): - errormsg = f'Number of days supplied ({len(days)}) does not match number of changes ({len(changes)})' - raise ValueError(errormsg) - return changes - - class change_beta(Intervention): ''' The most basic intervention -- change beta (transmission) by a certain amount @@ -378,7 +525,6 @@ class change_beta(Intervention): def __init__(self, days, changes, layers=None, **kwargs): super().__init__(**kwargs) # Initialize the Intervention object - self._store_args() # Store the input arguments so the intervention can be recreated self.days = sc.dcp(days) self.changes = sc.dcp(changes) self.layers = sc.dcp(layers) @@ -388,6 +534,7 @@ def __init__(self, days, changes, layers=None, **kwargs): def initialize(self, sim): ''' Fix days and store beta ''' + super().initialize() self.days = process_days(sim, self.days) self.changes = process_changes(sim, self.changes, self.days) self.layers = sc.promotetolist(self.layers, keepnone=True) @@ -398,14 +545,13 @@ def initialize(self, sim): else: self.orig_betas[lkey] = sim['beta_layer'][lkey] - self.initialized = True return def apply(self, sim): # If this day is found in the list, apply the intervention - for ind in find_day(self.days, sim.t): + for ind in find_day(self.days, sim.t, interv=self, sim=sim): for lkey,new_beta in self.orig_betas.items(): new_beta = new_beta * self.changes[ind] if lkey == 'overall': @@ -440,12 +586,11 @@ class clip_edges(Intervention): **Examples**:: interv = cv.clip_edges(25, 0.3) # On day 25, reduce overall contacts by 70% to 0.3 - interv = cv.clip_edges([14, 28], [0.7, 1], layers='w') # On day 14, remove 30% of school contacts, and on day 28, restore them + interv = cv.clip_edges([14, 28], [0.7, 1], layers='s') # On day 14, remove 30% of school contacts, and on day 28, restore them ''' def __init__(self, days, changes, layers=None, **kwargs): super().__init__(**kwargs) # Initialize the Intervention object - self._store_args() # Store the input arguments so the intervention can be recreated self.days = sc.dcp(days) self.changes = sc.dcp(changes) self.layers = sc.dcp(layers) @@ -454,6 +599,7 @@ def __init__(self, days, changes, layers=None, **kwargs): def initialize(self, sim): + super().initialize() self.days = process_days(sim, self.days) self.changes = process_changes(sim, self.changes, self.days) if self.layers is None: @@ -461,14 +607,13 @@ def initialize(self, sim): else: self.layers = sc.promotetolist(self.layers) self.contacts = cvb.Contacts(layer_keys=self.layers) - self.initialized = True return def apply(self, sim): # If this day is found in the list, apply the intervention - for ind in find_day(self.days, sim.t): + for ind in find_day(self.days, sim.t, interv=self, sim=sim): # Do the contact moving for lkey in self.layers: @@ -491,13 +636,16 @@ def apply(self, sim): inds = cvu.choose(max_n=n_int, n=abs(n_to_move)) to_move = i_layer.pop_inds(inds) s_layer.append(to_move) - else: + else: # pragma: no cover print(f'Warning: clip_edges() was applied to layer "{lkey}", but no edges were found; please check sim.people.contacts["{lkey}"]') + return + - # Ensure the edges get deleted at the end + def finalize(self, sim): + ''' Ensure the edges get deleted at the end ''' + super().finalize() if sim.t == sim.tvec[-1]: self.contacts = None # Reset to save memory - return @@ -507,85 +655,6 @@ def apply(self, sim): __all__+= ['test_num', 'test_prob', 'contact_tracing'] -def process_daily_data(daily_data, sim, start_day, as_int=False): - ''' - This function performs one of three things: if the daily test data are supplied as - a number, then it converts it to an array of the right length. If the daily - data are supplied as a Pandas series or dataframe with a date index, then it - reindexes it to match the start date of the simulation. If the daily data are - supplied as a string, then it will convert it to a column and try to read from - that. Otherwise, it does nothing. - - Args: - daily_data (str, number, dataframe, or series): the data to convert to standardized format - sim (Sim): the simulation object - start_day (date): the start day of the simulation, in already-converted datetime.date format - as_int (bool): whether to convert to an integer - ''' - # Handle string arguments - if sc.isstring(daily_data): - if daily_data == 'data': - daily_data = sim.data['new_tests'] # Use default name - else: - try: - daily_data = sim.data[daily_data] - except Exception as E: - errormsg = f'Tried to load testing data from sim.data["{daily_data}"], but that failed: {str(E)}.\nPlease ensure data are loaded into the sim and the column exists.' - raise ValueError(errormsg) from E - - # Handle other arguments - if sc.isnumber(daily_data): # If a number, convert to an array - if as_int: daily_data = int(daily_data) # Make it an integer - daily_data = np.array([daily_data] * sim.npts) - elif isinstance(daily_data, (pd.Series, pd.DataFrame)): - start_date = sim['start_day'] + dt.timedelta(days=start_day) - end_date = daily_data.index[-1] - dateindex = pd.date_range(start_date, end_date) - daily_data = daily_data.reindex(dateindex, fill_value=0).to_numpy() - - return daily_data - - -def get_subtargets(subtarget, sim): - ''' - A small helper function to see if subtargeting is a list of indices to use, - or a function that needs to be called. If a function, it must take a single - argument, a sim object, and return a list of indices. Also validates the values. - Currently designed for use with testing interventions, but could be generalized - to other interventions. Not typically called directly by the user. - - Args: - subtarget (dict): dict with keys 'inds' and 'vals'; see test_num() for examples of a valid subtarget dictionary - sim (Sim): the simulation object - ''' - - # Validation - if callable(subtarget): - subtarget = subtarget(sim) - - if 'inds' not in subtarget: - errormsg = f'The subtarget dict must have keys "inds" and "vals", but you supplied {subtarget}' - raise ValueError(errormsg) - - # Handle the two options of type - if callable(subtarget['inds']): # A function has been provided - subtarget_inds = subtarget['inds'](sim) # Call the function to get the indices - else: - subtarget_inds = subtarget['inds'] # The indices are supplied directly - - # Validate the values - if callable(subtarget['vals']): # A function has been provided - subtarget_vals = subtarget['vals'](sim) # Call the function to get the indices - else: - subtarget_vals = subtarget['vals'] # The indices are supplied directly - if sc.isiterable(subtarget_vals): - if len(subtarget_vals) != len(subtarget_inds): - errormsg = f'Length of subtargeting indices ({len(subtarget_inds)}) does not match length of values ({len(subtarget_vals)})' - raise ValueError(errormsg) - - return subtarget_inds, subtarget_vals - - def get_quar_inds(quar_policy, sim): ''' Helper function to return the appropriate indices for people in quarantine @@ -611,7 +680,7 @@ def get_quar_inds(quar_policy, sim): quar_test_inds = np.unique(np.concatenate([cvu.true(sim.people.date_quarantined==t-1-q) for q in quar_policy])) elif callable(quar_policy): quar_test_inds = quar_policy(sim) - else: + else: # pragma: no cover errormsg = f'Quarantine policy "{quar_policy}" not recognized: must be a string (start, end, both, daily), int, list, array, set, tuple, or function' raise ValueError(errormsg) return quar_test_inds @@ -652,7 +721,6 @@ def __init__(self, daily_tests, symp_test=100.0, quar_test=1.0, quar_policy=None ili_prev=None, sensitivity=1.0, loss_prob=0, test_delay=0, start_day=0, end_day=None, swab_delay=None, **kwargs): super().__init__(**kwargs) # Initialize the Intervention object - self._store_args() # Store the input arguments so the intervention can be recreated self.daily_tests = daily_tests # Should be a list of length matching time self.symp_test = symp_test # Set probability of testing symptomatics self.quar_test = quar_test # Probability of testing people in quarantine @@ -672,29 +740,31 @@ def initialize(self, sim): ''' Fix the dates and number of tests ''' # Handle days - self.start_day = sim.day(self.start_day) - self.end_day = sim.day(self.end_day) + super().initialize() + + self.start_day = preprocess_day(self.start_day, sim) + self.end_day = preprocess_day(self.end_day, sim) self.days = [self.start_day, self.end_day] # Process daily data self.daily_tests = process_daily_data(self.daily_tests, sim, self.start_day) self.ili_prev = process_daily_data(self.ili_prev, sim, self.start_day) - self.initialized = True - return def apply(self, sim): t = sim.t - if t < self.start_day: + start_day = get_day(self.start_day, self, sim) + end_day = get_day(self.end_day, self, sim) + if t < start_day: return - elif self.end_day is not None and t > self.end_day: + elif end_day is not None and t > end_day: return # Check that there are still tests - rel_t = t - self.start_day + rel_t = t - start_day if rel_t < len(self.daily_tests): n_tests = sc.randround(self.daily_tests[rel_t]/sim.rescale_vec[t]) # Correct for scaling that may be applied by rounding to the nearest number of tests if not (n_tests and pl.isfinite(n_tests)): # If there are no tests today, abort early @@ -783,7 +853,6 @@ class test_prob(Intervention): def __init__(self, symp_prob, asymp_prob=0.0, symp_quar_prob=None, asymp_quar_prob=None, quar_policy=None, subtarget=None, ili_prev=None, sensitivity=1.0, loss_prob=0.0, test_delay=0, start_day=0, end_day=None, swab_delay=None, **kwargs): super().__init__(**kwargs) # Initialize the Intervention object - self._store_args() # Store the input arguments so the intervention can be recreated self.symp_prob = symp_prob self.asymp_prob = asymp_prob self.symp_quar_prob = symp_quar_prob if symp_quar_prob is not None else symp_prob @@ -802,20 +871,23 @@ def __init__(self, symp_prob, asymp_prob=0.0, symp_quar_prob=None, asymp_quar_pr def initialize(self, sim): ''' Fix the dates ''' - self.start_day = sim.day(self.start_day) - self.end_day = sim.day(self.end_day) + super().initialize() + self.start_day = preprocess_day(self.start_day, sim) + self.end_day = preprocess_day(self.end_day, sim) self.days = [self.start_day, self.end_day] self.ili_prev = process_daily_data(self.ili_prev, sim, self.start_day) - self.initialized = True return def apply(self, sim): ''' Perform testing ''' + t = sim.t - if t < self.start_day: + start_day = get_day(self.start_day, self, sim) + end_day = get_day(self.end_day, self, sim) + if t < start_day: return - elif self.end_day is not None and t > self.end_day: + elif end_day is not None and t > end_day: return # Find probablity for symptomatics to be tested @@ -835,7 +907,7 @@ def apply(self, sim): pop_size = sim['pop_size'] ili_inds = [] if self.ili_prev is not None: - rel_t = t - self.start_day + rel_t = t - start_day if rel_t < len(self.ili_prev): n_ili = int(self.ili_prev[rel_t] * pop_size) # Number with ILI symptoms on this day ili_inds = cvu.choose(pop_size, n_ili) # Give some people some symptoms, assuming that this is independent of COVID symptomaticity... @@ -851,7 +923,7 @@ def apply(self, sim): diag_inds = cvu.true(sim.people.diagnosed) # Construct the testing probabilities piece by piece -- complicated, since need to do it in the right order - test_probs = np.zeros(sim.n) # Begin by assigning equal testing probability to everyone + test_probs = np.zeros(sim['pop_size']) # Begin by assigning equal testing probability to everyone test_probs[symp_inds] = symp_prob # People with symptoms (true positive) test_probs[ili_inds] = symp_prob # People with symptoms (false positive) test_probs[asymp_inds] = self.asymp_prob # People without symptoms @@ -887,7 +959,7 @@ class contact_tracing(Intervention): start_day (int): intervention start day (default: 0, i.e. the start of the simulation) end_day (int): intervention end day (default: no end) presumptive (bool): whether or not to begin isolation and contact tracing on the presumption of a positive diagnosis (default: no) - quar_period (int): number of days to quarantine when notified as a known contact. Default value is pars['quar_period'] + quar_period (int): number of days to quarantine when notified as a known contact. Default value is ``pars['quar_period']`` kwargs (dict): passed to Intervention() **Example**:: @@ -898,34 +970,33 @@ class contact_tracing(Intervention): ''' def __init__(self, trace_probs=None, trace_time=None, start_day=0, end_day=None, presumptive=False, quar_period=None, **kwargs): super().__init__(**kwargs) # Initialize the Intervention object - self._store_args() # Store the input arguments so the intervention can be recreated self.trace_probs = trace_probs self.trace_time = trace_time self.start_day = start_day self.end_day = end_day self.presumptive = presumptive - self.quar_period = quar_period #: If quar_period is None, it will be drawn from sim.pars at initialization + self.quar_period = quar_period # If quar_period is None, it will be drawn from sim.pars at initialization return def initialize(self, sim): ''' Process the dates and dictionaries ''' - self.start_day = sim.day(self.start_day) - self.end_day = sim.day(self.end_day) + super().initialize() + self.start_day = preprocess_day(self.start_day, sim) + self.end_day = preprocess_day(self.end_day, sim) self.days = [self.start_day, self.end_day] if self.trace_probs is None: self.trace_probs = 1.0 if self.trace_time is None: self.trace_time = 0.0 if self.quar_period is None: - self.quar_period = sim.pars['quar_period'] + self.quar_period = sim['quar_period'] if sc.isnumber(self.trace_probs): val = self.trace_probs self.trace_probs = {k:val for k in sim.people.layer_keys()} if sc.isnumber(self.trace_time): val = self.trace_time self.trace_time = {k:val for k in sim.people.layer_keys()} - self.initialized = True return @@ -941,9 +1012,11 @@ def apply(self, sim): - Notify those contacts that they have been exposed and need to take some action ''' t = sim.t - if t < self.start_day: + start_day = get_day(self.start_day, self, sim) + end_day = get_day(self.end_day, self, sim) + if t < start_day: return - elif self.end_day is not None and t > self.end_day: + elif end_day is not None and t > end_day: return trace_inds = self.select_cases(sim) @@ -1015,7 +1088,9 @@ def notify_contacts(self, sim, contacts): sim: Simulation object contacts: {trace_time: np.array(inds)} dictionary storing which people to notify ''' + is_dead = cvu.true(sim.people.dead) # Find people who are not alive for trace_time, contact_inds in contacts.items(): + contact_inds = np.setdiff1d(contact_inds, is_dead) # Do not notify contacts who are dead sim.people.known_contact[contact_inds] = True sim.people.date_known_contact[contact_inds] = np.fmin(sim.people.date_known_contact[contact_inds], sim.t + trace_time) sim.people.schedule_quarantine(contact_inds, start_date=sim.t + trace_time, period=self.quar_period - trace_time) # Schedule quarantine for the notified people to start on the date they will be notified @@ -1025,12 +1100,12 @@ def notify_contacts(self, sim, contacts): #%% Treatment and prevention interventions -__all__+= ['vaccine'] +__all__+= ['simple_vaccine', 'vaccinate'] -class vaccine(Intervention): +class simple_vaccine(Intervention): ''' - Apply a vaccine to a subset of the population. In addition to changing the + Apply a simple vaccine to a subset of the population. In addition to changing the relative susceptibility and the probability of developing symptoms if still infected, this intervention stores several types of data: @@ -1050,14 +1125,16 @@ class vaccine(Intervention): cumulative (bool): whether cumulative doses have cumulative effects (default false); can also be an array for efficacy per dose, with the last entry used for multiple doses; thus True = [1] and False = [1,0] kwargs (dict): passed to Intervention() + Note: this intervention is still under development and should be used with caution. + It is intended for use with use_waning=False. + **Examples**:: - interv = cv.vaccine(days=50, prob=0.3, rel_sus=0.5, rel_symp=0.1) - interv = cv.vaccine(days=[10,20,30,40], prob=0.8, rel_sus=0.5, cumulative=[1, 0.3, 0.1, 0]) # A vaccine with efficacy up to the 3rd dose + interv = cv.simple_vaccine(days=50, prob=0.3, rel_sus=0.5, rel_symp=0.1) + interv = cv.simple_vaccine(days=[10,20,30,40], prob=0.8, rel_sus=0.5, cumulative=[1, 0.3, 0.1, 0]) # A vaccine with efficacy up to the 3rd dose ''' def __init__(self, days, prob=1.0, rel_sus=0.0, rel_symp=0.0, subtarget=None, cumulative=False, **kwargs): super().__init__(**kwargs) # Initialize the Intervention object - self._store_args() # Store the input arguments so the intervention can be recreated self.days = sc.dcp(days) self.prob = prob self.rel_sus = rel_sus @@ -1073,6 +1150,7 @@ def __init__(self, days, prob=1.0, rel_sus=0.0, rel_symp=0.0, subtarget=None, cu def initialize(self, sim): ''' Fix the dates and store the vaccinations ''' + super().initialize() self.days = process_days(sim, self.days) self.vaccinations = np.zeros(sim.n, dtype=cvd.default_int) # Number of doses given per person self.vaccination_dates = [[] for p in range(sim.n)] # Store the dates when people are vaccinated @@ -1080,7 +1158,7 @@ def initialize(self, sim): self.orig_symp_prob = sc.dcp(sim.people.symp_prob) # ...and symptom probability self.mod_rel_sus = np.ones(sim.n, dtype=cvd.default_float) # Store the final modifiers self.mod_symp_prob = np.ones(sim.n, dtype=cvd.default_float) # Store the final modifiers - self.initialized = True + self.vacc_inds = None return @@ -1088,10 +1166,10 @@ def apply(self, sim): ''' Perform vaccination ''' # If this day is found in the list, apply the intervention - for ind in find_day(self.days, sim.t): + for ind in find_day(self.days, sim.t, interv=self, sim=sim): # Construct the testing probabilities piece by piece -- complicated, since need to do it in the right order - vacc_probs = np.full(sim.n, self.prob) # Begin by assigning equal testing probability to everyone + vacc_probs = np.full(sim.n, self.prob) # Begin by assigning equal vaccination probability to everyone if self.subtarget is not None: subtarget_inds, subtarget_vals = get_subtargets(self.subtarget, sim) vacc_probs[subtarget_inds] = subtarget_vals # People being explicitly subtargeted @@ -1115,4 +1193,210 @@ def apply(self, sim): for v_ind in vacc_inds: self.vaccination_dates[v_ind].append(sim.t) + # Update vaccine attributes in sim + sim.people.vaccinated[vacc_inds] = True + sim.people.vaccinations[vacc_inds] += 1 + return + + +class vaccinate(Intervention): + ''' + Apply a vaccine to a subset of the population. + + The main purpose of the intervention is to change the relative susceptibility + and the probability of developing symptoms if still infected. However, this intervention + also stores several types of data: + + - ``vaccinated``: whether or not a person is vaccinated + - ``vaccinations``: the number of vaccine doses per person + - ``vaccination_dates``: list of vaccination dates per person + + Args: + vaccine (dict/str): which vaccine to use; see below for dict parameters + label (str): if vaccine is supplied as a dict, the name of the vaccine + days (int/arr): the day or array of days to apply the interventions + prob (float): probability of being vaccinated (i.e., fraction of the population) + subtarget (dict): subtarget intervention to people with particular indices (see test_num() for details) + kwargs (dict): passed to Intervention() + + If ``vaccine`` is supplied as a dictionary, it must have the following parameters: + + - ``nab_eff``: the waning efficacy of neutralizing antibodies at preventing infection + - ``nab_init``: the initial antibody level (higher = more protection) + - ``nab_boost``: how much of a boost being vaccinated on top of a previous dose or natural infection provides + - ``doses``: the number of doses required to be fully vaccinated + - ``interval``: the interval between doses + - entries for efficacy against each of the strains (e.g. ``b117``) + + See ``parameters.py`` for additional examples of these parameters. + + **Example**:: + + pfizer = cv.vaccinate(vaccine='pfizer', days=30, prob=0.7) + cv.Sim(interventions=pfizer, use_waning=True).run().plot() + ''' + def __init__(self, vaccine, days, label=None, prob=1.0, subtarget=None, **kwargs): + super().__init__(**kwargs) # Initialize the Intervention object + self.days = sc.dcp(days) + self.prob = prob + self.subtarget = subtarget + self.index = None # Index of the vaccine in the sim; set later + self.label = None # Vacine label (used as a dict key) + self.p = None # Vaccine parameters + self.parse(vaccine=vaccine, label=label) # Populate + return + + + def parse(self, vaccine=None, label=None): + ''' Unpack vaccine information, which may be given as a string or dict ''' + + # Option 1: vaccines can be chosen from a list of pre-defined vaccines + if isinstance(vaccine, str): + + choices, mapping = cvpar.get_vaccine_choices() + strain_pars = cvpar.get_vaccine_strain_pars() + dose_pars = cvpar.get_vaccine_dose_pars() + + label = vaccine.lower() + for txt in ['.', ' ', '&', '-', 'vaccine']: + label = label.replace(txt, '') + + if label in mapping: + label = mapping[label] + vaccine_pars = sc.mergedicts(strain_pars[label], dose_pars[label]) + else: # pragma: no cover + errormsg = f'The selected vaccine "{vaccine}" is not implemented; choices are:\n{sc.pp(choices, doprint=False)}' + raise NotImplementedError(errormsg) + + if self.label is None: + self.label = label + + # Option 2: strains can be specified as a dict of pars + elif isinstance(vaccine, dict): + + # Parse label + vaccine_pars = vaccine + label = vaccine_pars.pop('label', label) # Allow including the label in the parameters + if label is None: + label = 'custom' + + else: # pragma: no cover + errormsg = f'Could not understand {type(vaccine)}, please specify as a string indexing a predefined vaccine or a dict.' + raise ValueError(errormsg) + + # Set label and parameters + self.label = label + self.p = sc.objdict(vaccine_pars) + + return + + + def initialize(self, sim): + ''' Fix the dates and store the vaccinations ''' + super().initialize() + + # Check that the simulation parameters are correct + if not sim['use_waning']: + errormsg = 'The cv.vaccinate() intervention requires use_waning=True. Please enable waning, or else use cv.simple_vaccine().' + raise RuntimeError(errormsg) + + # Populate any missing keys -- must be here, after strains are initialized + default_strain_pars = cvpar.get_vaccine_strain_pars(default=True) + default_dose_pars = cvpar.get_vaccine_dose_pars(default=True) + strain_labels = list(sim['strain_pars'].keys()) + dose_keys = list(default_dose_pars.keys()) + + # Handle dose keys + for key in dose_keys: + if key not in self.p: + self.p[key] = default_dose_pars[key] + + # Handle strains + for key in strain_labels: + if key not in self.p: + if key in default_strain_pars: + val = default_strain_pars[key] + else: + val = 1.0 + if sim['verbose']: print('Note: No cross-immunity specified for vaccine {self.label} and strain {key}, setting to 1.0') + self.p[key] = val + + + self.days = process_days(sim, self.days) # days that group becomes eligible + self.first_dose_nab_days = [None]*sim.npts # People who get nabs from first dose + self.second_dose_nab_days = [None]*sim.npts # People who get nabs from second dose (if relevant) + self.second_dose_days = [None]*sim.npts # People who get second dose (if relevant) + self.vaccinated = [None]*sim.npts # Keep track of inds of people vaccinated on each day + self.vaccinations = np.zeros(sim['pop_size'], dtype=cvd.default_int) # Number of doses given per person + self.vaccination_dates = np.full(sim['pop_size'], np.nan) # Store the dates when people are vaccinated + sim['vaccine_pars'][self.label] = self.p # Store the parameters + self.index = list(sim['vaccine_pars'].keys()).index(self.label) # Find where we are in the list + sim['vaccine_map'][self.index] = self.label # Use that to populate the reverse mapping + + return + + + def apply(self, sim): + ''' Perform vaccination ''' + + if sim.t >= np.min(self.days): + + # Vaccinate people with their first dose + vacc_inds = np.array([], dtype=int) # Initialize in case no one gets their first dose + for ind in find_day(self.days, sim.t, interv=self, sim=sim): + vacc_probs = np.zeros(sim['pop_size']) + unvacc_inds = sc.findinds(~sim.people.vaccinated) + if self.subtarget is not None: + subtarget_inds, subtarget_vals = get_subtargets(self.subtarget, sim) + if len(subtarget_vals): + vacc_probs[subtarget_inds] = subtarget_vals # People being explicitly subtargeted + else: + vacc_probs[unvacc_inds] = self.prob # Assign equal vaccination probability to everyone + vacc_probs[cvu.true(sim.people.dead)] *= 0.0 # Do not vaccinate dead people + vacc_inds = cvu.true(cvu.binomial_arr(vacc_probs)) # Calculate who actually gets vaccinated + + if len(vacc_inds): + self.vaccinated[sim.t] = vacc_inds + sim.people.flows['new_vaccinations'] += len(vacc_inds) + sim.people.flows['new_vaccinated'] += len(vacc_inds) + if self.p.interval is not None: + next_dose_day = sim.t + self.p.interval + if next_dose_day < sim['n_days']: + self.second_dose_days[next_dose_day] = vacc_inds + self.first_dose_nab_days[next_dose_day] = vacc_inds + else: + self.first_dose_nab_days[sim.t] = vacc_inds + + # Also, if appropriate, vaccinate people with their second dose + vacc_inds_dose2 = self.second_dose_days[sim.t] + if vacc_inds_dose2 is not None: + next_nab_day = sim.t + self.p.interval + if next_nab_day < sim['n_days']: + self.second_dose_nab_days[next_nab_day] = vacc_inds_dose2 + vacc_inds = np.concatenate((vacc_inds, vacc_inds_dose2), axis=None) + sim.people.flows['new_vaccinations'] += len(vacc_inds_dose2) + + # Update vaccine attributes in sim + if len(vacc_inds): + sim.people.vaccinated[vacc_inds] = True + sim.people.vaccine_source[vacc_inds] = self.index + self.vaccinations[vacc_inds] += 1 + self.vaccination_dates[vacc_inds] = sim.t + + # Update vaccine attributes in sim + sim.people.vaccinations[vacc_inds] = self.vaccinations[vacc_inds] + + # check whose nabs kick in today and then init_nabs for them! + vaccine_nabs_first_dose_inds = self.first_dose_nab_days[sim.t] + vaccine_nabs_second_dose_inds = self.second_dose_nab_days[sim.t] + + vaccine_nabs_inds = [vaccine_nabs_first_dose_inds, vaccine_nabs_second_dose_inds] + + for vaccinees in vaccine_nabs_inds: + if vaccinees is not None: + sim.people.date_vaccinated[vaccinees] = sim.t + cvi.init_nab(sim.people, vaccinees, prior_inf=False) + + return + diff --git a/covasim/misc.py b/covasim/misc.py index 3927c6dc6..3b34e7f79 100644 --- a/covasim/misc.py +++ b/covasim/misc.py @@ -7,8 +7,9 @@ import pylab as pl import sciris as sc import scipy.stats as sps +from pathlib import Path from . import version as cvv - +from distutils.version import LooseVersion #%% Convenience imports from Sciris @@ -22,10 +23,10 @@ #%% Loading/saving functions -__all__ += ['load_data', 'load', 'save', 'migrate', 'savefig'] +__all__ += ['load_data', 'load', 'save', 'savefig'] -def load_data(datafile, columns=None, calculate=True, check_date=True, verbose=True, **kwargs): +def load_data(datafile, columns=None, calculate=True, check_date=True, verbose=True, start_day=None, **kwargs): ''' Load data for comparing to the model output, either from file or from a dataframe. @@ -34,6 +35,7 @@ def load_data(datafile, columns=None, calculate=True, check_date=True, verbose=T columns (list): list of column names (otherwise, load all) calculate (bool): whether to calculate cumulative values from daily counts check_date (bool): whether to check that a 'date' column is present + start_day (date): if the 'date' column is provided as integer number of days, consider them relative to this kwargs (dict): passed to pd.read_excel() Returns: @@ -41,6 +43,8 @@ def load_data(datafile, columns=None, calculate=True, check_date=True, verbose=T ''' # Load data + if isinstance(datafile, Path): # Convert to a string + datafile = str(datafile) if isinstance(datafile, str): df_lower = datafile.lower() if df_lower.endswith('csv'): @@ -54,14 +58,14 @@ def load_data(datafile, columns=None, calculate=True, check_date=True, verbose=T raise NotImplementedError(errormsg) elif isinstance(datafile, pd.DataFrame): raw_data = datafile - else: + else: # pragma: no cover errormsg = f'Could not interpret data {type(datafile)}: must be a string or a dataframe' raise TypeError(errormsg) # Confirm data integrity and simplify if columns is not None: for col in columns: - if col not in raw_data.columns: + if col not in raw_data.columns: # pragma: no cover errormsg = f'Column "{col}" is missing from the loaded data' raise ValueError(errormsg) data = raw_data[columns] @@ -85,13 +89,16 @@ def load_data(datafile, columns=None, calculate=True, check_date=True, verbose=T errormsg = f'Required column "date" not found; columns are {data.columns}' raise ValueError(errormsg) else: - data['date'] = pd.to_datetime(data['date']).dt.date + if data['date'].dtype == np.int64: # If it's integers, treat it as days from the start day + data['date'] = sc.date(data['date'].values, start_date=start_day) + else: # Otherwise, use Pandas to convert it + data['date'] = pd.to_datetime(data['date']).dt.date data.set_index('date', inplace=True, drop=False) # Don't drop so sim.data['date'] can still be accessed return data -def load(*args, do_migrate=True, **kwargs): +def load(*args, do_migrate=True, update=True, verbose=True, **kwargs): ''' Convenience method for sc.loadobj() and equivalent to cv.Sim.load() or cv.Scenarios.load(). @@ -99,6 +106,8 @@ def load(*args, do_migrate=True, **kwargs): Args: filename (str): file to load do_migrate (bool): whether to migrate if loading an old object + update (bool): whether to modify the object to reflect the new version + verbose (bool): whether to print migration information args (list): passed to sc.loadobj() kwargs (dict): passed to sc.loadobj() @@ -118,7 +127,7 @@ def load(*args, do_migrate=True, **kwargs): if cmp != 0: print(f'Note: you have Covasim v{v_curr}, but are loading an object from v{v_obj}') if do_migrate: - obj = migrate(obj, v_obj, v_curr) + obj = migrate(obj, update=update, verbose=verbose) return obj @@ -145,6 +154,107 @@ def save(*args, **kwargs): return filepath +def savefig(filename=None, comments=None, **kwargs): + ''' + Wrapper for Matplotlib's savefig() function which automatically stores Covasim + metadata in the figure. By default, saves (git) information from both the Covasim + version and the calling function. Additional comments can be added to the saved + file as well. These can be retrieved via cv.get_png_metadata(). Metadata can + also be stored for SVG and PDF formats, but cannot be automatically retrieved. + + Args: + filename (str): name of the file to save to (default, timestamp) + comments (str): additional metadata to save to the figure + kwargs (dict): passed to savefig() + + **Example**:: + + cv.Sim().run(do_plot=True) + filename = cv.savefig() + ''' + + # Handle inputs + dpi = kwargs.pop('dpi', 150) + metadata = kwargs.pop('metadata', {}) + + if filename is None: # pragma: no cover + now = sc.getdate(dateformat='%Y-%b-%d_%H.%M.%S') + filename = f'covasim_{now}.png' + + metadata = {} + metadata['Covasim version'] = cvv.__version__ + gitinfo = git_info() + for key,value in gitinfo['covasim'].items(): + metadata[f'Covasim {key}'] = value + for key,value in gitinfo['called_by'].items(): + metadata[f'Covasim caller {key}'] = value + metadata['Covasim current time'] = sc.getdate() + metadata['Covasim calling file'] = sc.getcaller() + if comments: + metadata['Covasim comments'] = comments + + # Handle different formats + lcfn = filename.lower() # Lowercase filename + if lcfn.endswith('pdf') or lcfn.endswith('svg'): + metadata = {'Keywords':str(metadata)} # PDF and SVG doesn't support storing a dict + + # Save the figure + pl.savefig(filename, dpi=dpi, metadata=metadata, **kwargs) + return filename + + +#%% Migration functions + +__all__ += ['migrate'] + +def migrate_lognormal(pars, revert=False, verbose=True): + ''' + Small helper function to automatically migrate the standard deviation of lognormal + distributions to match pre-v2.1.0 runs (where it was treated as the variance instead). + To undo the migration, run with revert=True. + + Args: + pars (dict): the parameters dictionary; or, alternatively, the sim object the parameters will be taken from + revert (bool): whether to reverse the update rather than make it + verbose (bool): whether to print out the old and new values + ''' + # Handle different input types + from . import base as cvb + if isinstance(pars, cvb.BaseSim): + pars = pars.pars # It's actually a sim, not a pars object + + # Convert each value to the square root, since squared in the new version + for key,dur in pars['dur'].items(): + if 'lognormal' in dur['dist']: + old = dur['par2'] + if revert: + new = old**2 + else: + new = np.sqrt(old) + dur['par2'] = new + if verbose > 1: + print(f' Updating {key} std from {old:0.2f} to {new:0.2f}') + + # Store whether migration has occurred so we don't accidentally do it twice + if not revert: + pars['migrated_lognormal'] = True + else: + pars.pop('migrated_lognormal', None) + + return + + +def migrate_strains(pars, verbose=True): + ''' + Small helper function to add necessary strain parameters. + ''' + pars['use_waning'] = False + pars['n_strains'] = 1 + pars['n_strains'] = 1 + pars['strains'] = [] + return + + def migrate(obj, update=True, verbose=True, die=False): ''' Define migrations allowing compatibility between different versions of saved @@ -167,6 +277,7 @@ def migrate(obj, update=True, verbose=True, die=False): sims = cv.load('my-list-of-sims.obj') sims = [cv.migrate(sim) for sim in sims] ''' + # Import here to avoid recursion from . import base as cvb from . import run as cvr from . import interventions as cvi @@ -189,15 +300,29 @@ def migrate(obj, update=True, verbose=True, die=False): # Rename intervention attribute tps = sim.get_interventions(cvi.test_prob) - for tp in tps: + for tp in tps: # pragma: no cover try: tp.sensitivity = tp.test_sensitivity del tp.test_sensitivity except: pass + # Migration from <2.1.0 to 2.1.0 + if sc.compareversions(sim.version, '2.1.0') == -1: + if verbose: + print(f'Migrating sim from version {sim.version} to version {cvv.__version__}') + print('Note: updating lognormal stds to restore previous behavior; see v2.1.0 changelog for details') + migrate_lognormal(sim.pars, verbose=verbose) + + # Migration from <3.0.0 to 3.0.0 + if sc.compareversions(sim.version, '3.0.0') == -1: + if verbose: + print(f'Migrating sim from version {sim.version} to version {cvv.__version__}') + print('Adding strain parameters') + migrate_strains(sim.pars, verbose=verbose) + # Migrations for People - elif isinstance(obj, cvb.BasePeople): + elif isinstance(obj, cvb.BasePeople): # pragma: no cover ppl = obj if not hasattr(ppl, 'version'): # For people prior to 2.0 if verbose: print(f'Migrating people from version <2.0 to version {cvv.__version__}') @@ -229,7 +354,7 @@ def migrate(obj, update=True, verbose=True, die=False): errormsg = f'Object {obj} of type {type(obj)} is not understood and cannot be migrated: must be a sim, multisim, scenario, or people object' if die: raise TypeError(errormsg) - elif verbose: + elif verbose: # pragma: no cover print(errormsg) return @@ -241,55 +366,6 @@ def migrate(obj, update=True, verbose=True, die=False): return obj -def savefig(filename=None, comments=None, **kwargs): - ''' - Wrapper for Matplotlib's savefig() function which automatically stores Covasim - metadata in the figure. By default, saves (git) information from both the Covasim - version and the calling function. Additional comments can be added to the saved - file as well. These can be retrieved via cv.get_png_metadata(). Metadata can - also be stored for SVG and PDF formats, but cannot be automatically retrieved. - - Args: - filename (str): name of the file to save to (default, timestamp) - comments (str): additional metadata to save to the figure - kwargs (dict): passed to savefig() - - **Example**:: - - cv.Sim().run(do_plot=True) - filename = cv.savefig() - ''' - - # Handle inputs - dpi = kwargs.pop('dpi', 150) - metadata = kwargs.pop('metadata', {}) - - if filename is None: - now = sc.getdate(dateformat='%Y-%b-%d_%H.%M.%S') - filename = f'covasim_{now}.png' - - metadata = {} - metadata['Covasim version'] = cvv.__version__ - gitinfo = git_info() - for key,value in gitinfo['covasim'].items(): - metadata[f'Covasim {key}'] = value - for key,value in gitinfo['called_by'].items(): - metadata[f'Covasim caller {key}'] = value - metadata['Covasim current time'] = sc.getdate() - metadata['Covasim calling file'] = sc.getcaller() - if comments: - metadata['Covasim comments'] = comments - - # Handle different formats - lcfn = filename.lower() # Lowercase filename - if lcfn.endswith('pdf') or lcfn.endswith('svg'): - metadata = {'Keywords':str(metadata)} # PDF and SVG doesn't support storing a dict - - # Save the figure - pl.savefig(filename, dpi=dpi, metadata=metadata, **kwargs) - return filename - - #%% Versioning functions @@ -348,7 +424,7 @@ def git_info(filename=None, check=False, comments=None, old_info=None, die=False old_info = sc.loadjson(filename, **kwargs) string = '' old_cv_info = old_info['covasim'] if 'covasim' in old_info else old_info - if cv_info != old_cv_info: + if cv_info != old_cv_info: # pragma: no cover string = f'Git information differs: {cv_info} vs. {old_cv_info}' if die: raise ValueError(string) @@ -426,6 +502,16 @@ def get_version_pars(version, verbose=True): ''' Function for loading parameters from the specified version. + Parameters will be loaded for Covasim 'as at' the requested version i.e. the + most recent set of parameters that is <= the requested version. Available + parameter values are stored in the regression folder. If parameters are available + for versions 1.3, and 1.4, then this function will return the following + + - If parameters for version '1.3' are requested, parameters will be returned from '1.3' + - If parameters for version '1.3.5' are requested, parameters will be returned from '1.3', since + Covasim at version 1.3.5 would have been using the parameters defined at version 1.3. + - If parameters for version '1.4' are requested, parameters will be returned from '1.4' + Args: version (str): the version to load parameters from @@ -433,42 +519,23 @@ def get_version_pars(version, verbose=True): Dictionary of parameters from that version ''' - # Define mappings for available sets of parameters -- from the changelog - match_map = { - '0.30.4': ['0.30.4'], - '0.31.0': ['0.31.0'], - '0.32.0': ['0.32.0'], - '1.0.0': ['1.0.0'], - '1.0.1': [f'1.0.{i}' for i in range(1,4)], - '1.1.0': ['1.1.0'], - '1.1.1': [f'1.1.{i}' for i in range(1,3)], - '1.1.3': [f'1.1.{i}' for i in range(3,8)], - '1.2.0': [f'1.2.{i}' for i in range(4)], - '1.3.0': [f'1.3.{i}' for i in range(6)], - '1.4.0': [f'1.4.{i}' for i in range(9)], - '1.5.0': [f'1.5.{i}' for i in range(4)], - '1.6.0': [f'1.6.{i}' for i in range(2)], - '1.7.0': [f'1.7.{i}' for i in range(7)], - '2.0.0': [f'2.0.{i}' for i in range(3)], - } - - # Find and check the match - match = None - for ver,verlist in match_map.items(): - if version in verlist: - match = ver - break - if match is None: - options = '\n'.join(sum(match_map.values(), [])) - errormsg = f'Could not find version "{version}" among options:\n{options}' + # Construct a sorted list of available parameters based on the files in the regression folder + regression_folder = sc.thisdir(__file__, 'regression', aspath=True) + available_versions = [x.stem.replace('pars_v','') for x in regression_folder.iterdir() if x.suffix=='.json'] + available_versions = sorted(available_versions, key=LooseVersion) + + # Find the highest parameter version that is <= the requested version + version_comparison = [sc.compareversions(version, v)>=0 for v in available_versions] + try: + target_version = available_versions[sc.findlast(version_comparison)] + except IndexError: + errormsg = f"Could not find a parameter version that was less than or equal to '{version}'. Available versions are {available_versions}" raise ValueError(errormsg) # Load the parameters - filename = f'pars_v{match}.json' - regression_folder = sc.thisdir(__file__, 'regression') - pars = sc.loadjson(filename=filename, folder=regression_folder) + pars = sc.loadjson(filename=regression_folder/f'pars_v{target_version}.json', folder=regression_folder) if verbose: - print(f'Loaded parameters from {match}') + print(f'Loaded parameters from {target_version}') return pars @@ -490,7 +557,7 @@ def get_png_metadata(filename, output=False): ''' try: import PIL - except ImportError as E: + except ImportError as E: # pragma: no cover errormsg = f'Pillow import failed ({str(E)}), please install first (pip install pillow)' raise ImportError(errormsg) from E im = PIL.Image.open(filename) @@ -506,6 +573,7 @@ def get_png_metadata(filename, output=False): return + #%% Simulation/statistics functions __all__ += ['get_doubling_time', 'poisson_test', 'compute_gof'] @@ -528,7 +596,7 @@ def get_doubling_time(sim, series=None, interval=None, start_day=None, end_day=N # Validate inputs: series if series is None or isinstance(series, str): - if not sim.results_ready: + if not sim.results_ready: # pragma: no cover raise Exception("Results not ready, cannot calculate doubling time") else: if series is None or series not in sim.result_keys(): @@ -540,7 +608,7 @@ def get_doubling_time(sim, series=None, interval=None, start_day=None, end_day=N # Validate inputs: interval if interval is not None: - if len(interval) != 2: + if len(interval) != 2: # pragma: no cover sc.printv(f"Interval should be a list/array/tuple of length 2, not {len(interval)}. Resetting to length of series.", 1, verbose) interval = [0,len(series)] start_day, end_day = interval[0], interval[1] @@ -552,12 +620,12 @@ def get_doubling_time(sim, series=None, interval=None, start_day=None, end_day=N # Deal with moving window if moving_window is not None: - if not sc.isnumber(moving_window): + if not sc.isnumber(moving_window): # pragma: no cover sc.printv("Moving window should be an integer; ignoring and calculating single result", 1, verbose) doubling_time = get_doubling_time(sim, series=series, start_day=start_day, end_day=end_day, moving_window=None, exp_approx=exp_approx) else: - if not isinstance(moving_window,int): + if not isinstance(moving_window,int): # pragma: no cover sc.printv(f"Moving window should be an integer; recasting {moving_window} the nearest integer... ", 1, verbose) moving_window = int(moving_window) if moving_window < 2: @@ -576,7 +644,7 @@ def get_doubling_time(sim, series=None, interval=None, start_day=None, end_day=N if not exp_approx: try: import statsmodels.api as sm - except ModuleNotFoundError as E: + except ModuleNotFoundError as E: # pragma: no cover errormsg = f'Could not import statsmodels ({E}), falling back to exponential approximation' print(errormsg) exp_approx = True @@ -586,7 +654,7 @@ def get_doubling_time(sim, series=None, interval=None, start_day=None, end_day=N if r > 1: doubling_time = int_length * np.log(2) / np.log(r) doubling_time = min(doubling_time, max_doubling_time) # Otherwise, it's unbounded - else: + else: # pragma: no cover raise ValueError("Can't calculate doubling time with exponential approximation when initial value is zero.") else: @@ -601,9 +669,9 @@ def get_doubling_time(sim, series=None, interval=None, start_day=None, end_day=N doubling_time = 1.0 / doubling_rate else: doubling_time = max_doubling_time - else: + else: # pragma: no cover raise ValueError(f"Can't calculate doubling time for series {series[start_day:end_day]}. Check whether series is growing.") - else: + else: # pragma: no cover raise ValueError(f"Can't calculate doubling time for series {series[start_day:end_day]}. Check whether series is growing.") return doubling_time @@ -691,9 +759,9 @@ def zstat_generic2(value, std_diff, alternative): pvalue = sps.norm.sf(zstat) elif alternative in ['smaller', 's']: pvalue = sps.norm.cdf(zstat) - else: - raise ValueError(f'invalid alternative "{alternative}"') - return pvalue# zstat + else: # pragma: no cover + raise ValueError(f'Invalid alternative "{alternative}"') + return pvalue # shortcut names y1, n1, y2, n2 = count1, exposure1, count2, exposure2 @@ -730,7 +798,7 @@ def zstat_generic2(value, std_diff, alternative): return pvalue#, stat -def compute_gof(actual, predicted, normalize=True, use_frac=False, use_squared=False, as_scalar='none', eps=1e-9, skestimator=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 @@ -745,7 +813,8 @@ def compute_gof(actual, predicted, normalize=True, use_frac=False, use_squared=F as_scalar (str): return as a scalar instead of a time series: choices are sum, mean, median eps (float): to avoid divide-by-zero skestimator (str): if provided, use this scikit-learn estimator instead - kwargs (dict): passed to the scikit-learn estimator + estimator (func): if provided, use this custom estimator instead + kwargs (dict): passed to the scikit-learn or custom estimator Returns: gofs (arr): array of goodness-of-fit values, or a single value if as_scalar is True @@ -766,8 +835,8 @@ def compute_gof(actual, predicted, normalize=True, use_frac=False, use_squared=F actual = np.array(sc.dcp(actual), dtype=float) predicted = np.array(sc.dcp(predicted), dtype=float) - # Custom estimator is supplied: use that - if skestimator is not None: + # Scikit-learn estimator is supplied: use that + if skestimator is not None: # pragma: no cover try: import sklearn.metrics as sm sklearn_gof = getattr(sm, skestimator) # Shortcut to e.g. sklearn.metrics.max_error @@ -778,6 +847,15 @@ def compute_gof(actual, predicted, normalize=True, use_frac=False, use_squared=F gof = sklearn_gof(actual, predicted, **kwargs) return gof + # Custom estimator is supplied: use that + if estimator is not None: + try: + gof = estimator(actual, predicted, **kwargs) + except Exception as E: + errormsg = f'Custom estimator "{estimator}" must be a callable function that accepts actual and predicted arrays, plus optional kwargs' + raise RuntimeError(errormsg) from E + return gof + # Default case: calculate it manually else: # Key step -- calculate the mismatch! diff --git a/covasim/parameters.py b/covasim/parameters.py index 1a31b31f1..0adc4cf39 100644 --- a/covasim/parameters.py +++ b/covasim/parameters.py @@ -6,8 +6,9 @@ import sciris as sc from .settings import options as cvo # For setting global options from . import misc as cvm +from . import defaults as cvd -__all__ = ['make_pars', 'reset_layer_pars', 'get_prognoses'] +__all__ = ['make_pars', 'reset_layer_pars', 'get_prognoses', 'get_strain_choices', 'get_vaccine_choices'] def make_pars(set_prognoses=False, prog_by_age=True, version=None, **kwargs): @@ -39,42 +40,58 @@ def make_pars(set_prognoses=False, prog_by_age=True, version=None, **kwargs): pars['end_day'] = None # End day of the simulation pars['n_days'] = 60 # Number of days to run, if end_day isn't specified pars['rand_seed'] = 1 # Random seed, if None, don't reset - pars['verbose'] = cvo.verbose # Whether or not to display information during the run -- options are 0 (silent), 1 (default), 2 (everything) + pars['verbose'] = cvo.verbose # Whether or not to display information during the run -- options are 0 (silent), 0.1 (some; default), 1 (default), 2 (everything) # Rescaling parameters pars['pop_scale'] = 1 # Factor by which to scale the population -- e.g. pop_scale=10 with pop_size=100e3 means a population of 1 million + pars['scaled_pop'] = None # The total scaled population, i.e. the number of agents times the scale factor pars['rescale'] = True # Enable dynamic rescaling of the population -- starts with pop_scale=1 and scales up dynamically as the epidemic grows pars['rescale_threshold'] = 0.05 # Fraction susceptible population that will trigger rescaling if rescaling pars['rescale_factor'] = 1.2 # Factor by which the population is rescaled on each step - - # Basic disease transmission - pars['beta'] = 0.016 # Beta per symptomatic contact; absolute value, calibrated - pars['contacts'] = None # The number of contacts per layer; set by reset_layer_pars() below - pars['dynam_layer'] = None # Which layers are dynamic; set by reset_layer_pars() below - pars['beta_layer'] = None # Transmissibility per layer; set by reset_layer_pars() below - pars['n_imports'] = 0 # Average daily number of imported cases (actual number is drawn from Poisson distribution) - pars['beta_dist'] = dict(dist='neg_binomial', par1=1.0, par2=0.45, step=0.01) # Distribution to draw individual level transmissibility; dispersion from https://www.researchsquare.com/article/rs-29548/v1 - pars['viral_dist'] = dict(frac_time=0.3, load_ratio=2, high_cap=4) # The time varying viral load (transmissibility); estimated from Lescure 2020, Lancet, https://doi.org/10.1016/S1473-3099(20)30200-0 - - # Efficacy of protection measures - pars['asymp_factor'] = 1.0 # Multiply beta by this factor for asymptomatic cases; no statistically significant difference in transmissibility: https://www.sciencedirect.com/science/article/pii/S1201971220302502 - pars['iso_factor'] = None # Multiply beta by this factor for diagnosed cases to represent isolation; set by reset_layer_pars() below - pars['quar_factor'] = None # Quarantine multiplier on transmissibility and susceptibility; set by reset_layer_pars() below - pars['quar_period'] = 14 # Number of days to quarantine for; assumption based on standard policies + pars['frac_susceptible'] = 1.0 # What proportion of the population is susceptible to infection + + # Network parameters, generally initialized after the population has been constructed + pars['contacts'] = None # The number of contacts per layer; set by reset_layer_pars() below + pars['dynam_layer'] = None # Which layers are dynamic; set by reset_layer_pars() below + pars['beta_layer'] = None # Transmissibility per layer; set by reset_layer_pars() below + + # Basic disease transmission parameters + pars['beta_dist'] = dict(dist='neg_binomial', par1=1.0, par2=0.45, step=0.01) # Distribution to draw individual level transmissibility; dispersion from https://www.researchsquare.com/article/rs-29548/v1 + pars['viral_dist'] = dict(frac_time=0.3, load_ratio=2, high_cap=4) # The time varying viral load (transmissibility); estimated from Lescure 2020, Lancet, https://doi.org/10.1016/S1473-3099(20)30200-0 + pars['beta'] = 0.016 # Beta per symptomatic contact; absolute value, calibrated + pars['asymp_factor'] = 1.0 # Multiply beta by this factor for asymptomatic cases; no statistically significant difference in transmissibility: https://www.sciencedirect.com/science/article/pii/S1201971220302502 + + # Parameters that control settings and defaults for multi-strain runs + pars['n_imports'] = 0 # Average daily number of imported cases (actual number is drawn from Poisson distribution) + pars['n_strains'] = 1 # The number of strains circulating in the population + + # Parameters used to calculate immunity + pars['use_waning'] = False # Whether to use dynamically calculated immunity + pars['nab_init'] = dict(dist='normal', par1=0, par2=2) # Parameters for the distribution of the initial level of log2(nab) following natural infection, taken from fig1b of https://doi.org/10.1101/2021.03.09.21252641 + pars['nab_decay'] = dict(form='nab_decay', decay_rate1=np.log(2)/90, decay_time1=250, decay_rate2=0.001) # Parameters describing the kinetics of decay of nabs over time, taken from fig3b of https://doi.org/10.1101/2021.03.09.21252641 + pars['nab_kin'] = None # Constructed during sim initialization using the nab_decay parameters + pars['nab_boost'] = 1.5 # Multiplicative factor applied to a person's nab levels if they get reinfected. # TODO: add source + pars['nab_eff'] = dict(sus=dict(slope=1.6, n_50=0.05), symp=0.1, sev=0.52) # Parameters to map nabs to efficacy + pars['rel_imm_symp'] = dict(asymp=0.85, mild=1, severe=1.5) # Relative immunity from natural infection varies by symptoms + pars['immunity'] = None # Matrix of immunity and cross-immunity factors, set by init_immunity() in immunity.py + + # Strain-specific disease transmission parameters. By default, these are set up for a single strain, but can all be modified for multiple strains + pars['rel_beta'] = 1.0 # Relative transmissibility varies by strain + pars['rel_imm_strain'] = 1.0 # Relative own-immmunity varies by strain # Duration parameters: time for disease progression pars['dur'] = {} - pars['dur']['exp2inf'] = dict(dist='lognormal_int', par1=4.6, par2=4.8) # Duration from exposed to infectious; see Lauer et al., https://www.ncbi.nlm.nih.gov/pmc/articles/PMC7081172/, subtracting inf2sim duration - pars['dur']['inf2sym'] = dict(dist='lognormal_int', par1=1.0, par2=0.9) # Duration from infectious to symptomatic; see Linton et al., https://doi.org/10.3390/jcm9020538 - pars['dur']['sym2sev'] = dict(dist='lognormal_int', par1=6.6, par2=4.9) # Duration from symptomatic to severe symptoms; see Linton et al., https://doi.org/10.3390/jcm9020538 - pars['dur']['sev2crit'] = dict(dist='lognormal_int', par1=3.0, par2=7.4) # Duration from severe symptoms to requiring ICU; see Wang et al., https://jamanetwork.com/journals/jama/fullarticle/2761044 + pars['dur']['exp2inf'] = dict(dist='lognormal_int', par1=4.5, par2=1.5) # Duration from exposed to infectious; see Lauer et al., https://www.ncbi.nlm.nih.gov/pmc/articles/PMC7081172/, appendix table S2, subtracting inf2sym duration + pars['dur']['inf2sym'] = dict(dist='lognormal_int', par1=1.1, par2=0.9) # Duration from infectious to symptomatic; see Linton et al., https://doi.org/10.3390/jcm9020538, from Table 2, 5.6 day incubation period - 4.5 day exp2inf from Lauer et al. + pars['dur']['sym2sev'] = dict(dist='lognormal_int', par1=6.6, par2=4.9) # Duration from symptomatic to severe symptoms; see Linton et al., https://doi.org/10.3390/jcm9020538, from Table 2, 6.6 day onset to hospital admission (deceased); see also Wang et al., https://jamanetwork.com/journals/jama/fullarticle/2761044, 7 days (Table 1) + pars['dur']['sev2crit'] = dict(dist='lognormal_int', par1=1.5, par2=2.0) # Duration from severe symptoms to requiring ICU; average of 1.9 and 1.0; see Chen et al., https://www.sciencedirect.com/science/article/pii/S0163445320301195, 8.5 days total - 6.6 days sym2sev = 1.9 days; see also Wang et al., https://jamanetwork.com/journals/jama/fullarticle/2761044, Table 3, 1 day, IQR 0-3 days; std=2.0 is an estimate # Duration parameters: time for disease recovery pars['dur']['asym2rec'] = dict(dist='lognormal_int', par1=8.0, par2=2.0) # Duration for asymptomatic people to recover; see Wölfel et al., https://www.nature.com/articles/s41586-020-2196-x pars['dur']['mild2rec'] = dict(dist='lognormal_int', par1=8.0, par2=2.0) # Duration for people with mild symptoms to recover; see Wölfel et al., https://www.nature.com/articles/s41586-020-2196-x - pars['dur']['sev2rec'] = dict(dist='lognormal_int', par1=14.0, par2=2.4) # Duration for people with severe symptoms to recover, 22.6 days total; see Verity et al., https://www.medrxiv.org/content/10.1101/2020.03.09.20033357v1.full.pdf - pars['dur']['crit2rec'] = dict(dist='lognormal_int', par1=14.0, par2=2.4) # Duration for people with critical symptoms to recover, 22.6 days total; see Verity et al., https://www.medrxiv.org/content/10.1101/2020.03.09.20033357v1.full.pdf - pars['dur']['crit2die'] = dict(dist='lognormal_int', par1=6.2, par2=1.7) # Duration from critical symptoms to death, 17.8 days total; see Verity et al., https://www.medrxiv.org/content/10.1101/2020.03.09.20033357v1.full.pdf + pars['dur']['sev2rec'] = dict(dist='lognormal_int', par1=18.1, par2=6.3) # Duration for people with severe symptoms to recover, 24.7 days total; see Verity et al., https://www.thelancet.com/journals/laninf/article/PIIS1473-3099(20)30243-7/fulltext; 18.1 days = 24.7 onset-to-recovery - 6.6 sym2sev; 6.3 = 0.35 coefficient of variation * 18.1; see also https://doi.org/10.1017/S0950268820001259 (22 days) and https://doi.org/10.3390/ijerph17207560 (3-10 days) + pars['dur']['crit2rec'] = dict(dist='lognormal_int', par1=18.1, par2=6.3) # Duration for people with critical symptoms to recover; as above (Verity et al.) + pars['dur']['crit2die'] = dict(dist='lognormal_int', par1=10.7, par2=4.8) # Duration from critical symptoms to death, 18.8 days total; see Verity et al., https://www.thelancet.com/journals/laninf/article/PIIS1473-3099(20)30243-7/fulltext; 10.7 = 18.8 onset-to-death - 6.6 sym2sev - 1.5 sev2crit; 4.8 = 0.45 coefficient of variation * 10.7 # Severity parameters: probabilities of symptom progression pars['rel_symp_prob'] = 1.0 # Scale factor for proportion of symptomatic cases @@ -84,6 +101,11 @@ def make_pars(set_prognoses=False, prog_by_age=True, version=None, **kwargs): pars['prog_by_age'] = prog_by_age # Whether to set disease progression based on the person's age pars['prognoses'] = None # The actual arrays of prognoses by age; this is populated later + # Efficacy of protection measures + pars['iso_factor'] = None # Multiply beta by this factor for diagnosed cases to represent isolation; set by reset_layer_pars() below + pars['quar_factor'] = None # Quarantine multiplier on transmissibility and susceptibility; set by reset_layer_pars() below + pars['quar_period'] = 14 # Number of days to quarantine for; assumption based on standard policies + # Events and interventions pars['interventions'] = [] # The interventions present in this simulation; populated by the user pars['analyzers'] = [] # Custom analysis functions; populated by the user @@ -96,6 +118,16 @@ def make_pars(set_prognoses=False, prog_by_age=True, version=None, **kwargs): pars['no_hosp_factor'] = 2.0 # Multiplier for how much more likely severely ill people are to become critical if no hospital beds are available pars['no_icu_factor'] = 2.0 # Multiplier for how much more likely critically ill people are to die if no ICU beds are available + # Handle vaccine and strain parameters + pars['vaccine_pars'] = {} # Vaccines that are being used; populated during initialization + pars['vaccine_map'] = {} #Reverse mapping from number to vaccine key + pars['strains'] = [] # Additional strains of the virus; populated by the user, see immunity.py + pars['strain_map'] = {0:'wild'} # Reverse mapping from number to strain key + pars['strain_pars'] = dict(wild={}) # Populated just below + for sp in cvd.strain_pars: + if sp in pars.keys(): + pars['strain_pars']['wild'][sp] = pars[sp] + # Update with any supplied parameter values and generate things that need to be generated pars.update(kwargs) reset_layer_pars(pars) @@ -109,6 +141,10 @@ def make_pars(set_prognoses=False, prog_by_age=True, version=None, **kwargs): if key in version_pars: # Only replace keys that exist in the old version pars[key] = version_pars[key] + # Handle code change migration + if sc.compareversions(version, '2.1.0') == -1 and 'migrate_lognormal' not in pars: + cvm.migrate_lognormal(pars, verbose=pars['verbose']) + return pars @@ -238,7 +274,7 @@ def get_prognoses(by_age=True, version=None): expected_len = len(prognoses['age_cutoffs']) for key,val in prognoses.items(): this_len = len(prognoses[key]) - if this_len != expected_len: + if this_len != expected_len: # pragma: no cover errormsg = f'Lengths mismatch in prognoses: {expected_len} age bins specified, but key "{key}" has {this_len} entries' raise ValueError(errormsg) @@ -272,4 +308,246 @@ def absolute_prognoses(prognoses): out['severe_probs'] *= out['symp_probs'] # Absolute probability of severe symptoms out['crit_probs'] *= out['severe_probs'] # Absolute probability of critical symptoms out['death_probs'] *= out['crit_probs'] # Absolute probability of dying - return out \ No newline at end of file + return out + + +#%% Strain, vaccine, and immunity parameters and functions + +def get_strain_choices(): + ''' + Define valid pre-defined strain names + ''' + # List of choices currently available: new ones can be added to the list along with their aliases + choices = { + 'wild': ['wild', 'default', 'pre-existing', 'original'], + 'b117': ['b117', 'uk', 'united kingdom'], + 'b1351': ['b1351', 'sa', 'south africa'], + 'p1': ['p1', 'b11248', 'brazil'], + } + mapping = {name:key for key,synonyms in choices.items() for name in synonyms} # Flip from key:value to value:key + return choices, mapping + + +def get_vaccine_choices(): + ''' + Define valid pre-defined vaccine names + ''' + # List of choices currently available: new ones can be added to the list along with their aliases + choices = { + 'default': ['default', None], + 'pfizer': ['pfizer', 'biontech', 'pfizer-biontech', 'pf', 'pfz', 'pz'], + 'moderna': ['moderna', 'md'], + 'novavax': ['novavax', 'nova', 'covovax', 'nvx', 'nv'], + 'az': ['astrazeneca', 'oxford', 'vaxzevria', 'az'], + 'jj': ['jnj', 'johnson & johnson', 'janssen', 'jj'], + } + mapping = {name:key for key,synonyms in choices.items() for name in synonyms} # Flip from key:value to value:key + return choices, mapping + + +def get_strain_pars(default=False): + ''' + Define the default parameters for the different strains + ''' + pars = dict( + + wild = dict( + rel_imm_strain = 1.0, # Default values + rel_beta = 1.0, # Default values + rel_symp_prob = 1.0, # Default values + rel_severe_prob = 1.0, # Default values + rel_crit_prob = 1.0, # Default values + rel_death_prob = 1.0, # Default values + ), + + b117 = dict( + rel_imm_strain = 1.0, # Immunity protection obtained from a natural infection with wild type, relative to wild type. No evidence yet for a difference with B117 + rel_beta = 1.5, # Transmissibility estimates range from 40-80%, see https://cmmid.github.io/topics/covid19/uk-novel-variant.html, https://www.eurosurveillance.org/content/10.2807/1560-7917.ES.2020.26.1.2002106 + rel_symp_prob = 1.0, # Inconclusive evidence on the likelihood of symptom development. See https://www.thelancet.com/journals/lanpub/article/PIIS2468-2667(21)00055-4/fulltext + rel_severe_prob = 1.8, # From https://www.ssi.dk/aktuelt/nyheder/2021/b117-kan-fore-til-flere-indlaggelser and https://assets.publishing.service.gov.uk/government/uploads/system/uploads/attachment_data/file/961042/S1095_NERVTAG_update_note_on_B.1.1.7_severity_20210211.pdf + rel_crit_prob = 1.0, # Various studies have found increased mortality for B117 (summary here: https://www.thelancet.com/journals/laninf/article/PIIS1473-3099(21)00201-2/fulltext#tbl1), but not necessarily when conditioned on having developed severe disease + rel_death_prob = 1.0, # See comment above. + ), + + b1351 = dict( + rel_imm_strain = 1.0, # Immunity protection obtained from a natural infection with wild type, relative to wild type. TODO: add source + rel_beta = 1.4, + rel_symp_prob = 1.0, + rel_severe_prob = 1.4, + rel_crit_prob = 1.0, + rel_death_prob = 1.4, + ), + + p1 = dict( + rel_imm_strain = 0.17, + rel_beta = 1.4, # Estimated to be 1.7–2.4-fold more transmissible than wild-type: https://science.sciencemag.org/content/early/2021/04/13/science.abh2644 + rel_symp_prob = 1.0, + rel_severe_prob = 1.4, + rel_crit_prob = 1.0, + rel_death_prob = 2.0, + ) + ) + + if default: + return pars['wild'] + else: + return pars + + +def get_cross_immunity(default=False): + ''' + Get the cross immunity between each strain in a sim + ''' + pars = dict( + + wild = dict( + wild = 1.0, # Default for own-immunity + b117 = 0.5, # Assumption + b1351 = 0.5, # Assumption + p1 = 0.5, # Assumption + ), + + b117 = dict( + wild = 0.5, # Assumption + b117 = 1.0, # Default for own-immunity + b1351 = 0.8, # Assumption + p1 = 0.8, # Assumption + ), + + b1351 = dict( + wild = 0.066, # https://www.nature.com/articles/s41586-021-03471-w + b117 = 0.5, # Assumption + b1351 = 1.0, # Default for own-immunity + p1 = 0.5, # Assumption + ), + + p1 = dict( + wild = 0.34, # Previous (non-P.1) infection provides 54–79% of the protection against infection with P.1 that it provides against non-P.1 lineages: https://science.sciencemag.org/content/early/2021/04/13/science.abh2644 + b117 = 0.4, # Assumption based on the above + b1351 = 0.4, # Assumption based on the above + p1 = 1.0, # Default for own-immunity + ), + ) + + if default: + return pars['wild'] + else: + return pars + + +def get_vaccine_strain_pars(default=False): + ''' + Define the effectiveness of each vaccine against each strain + ''' + pars = dict( + + default = dict( + wild = 1.0, + b117 = 1.0, + b1351 = 1.0, + p1 = 1.0, + ), + + pfizer = dict( + wild = 1.0, + b117 = 1/2.0, + b1351 = 1/6.7, + p1 = 1/6.5, + ), + + moderna = dict( + wild = 1.0, + b117 = 1/1.8, + b1351 = 1/4.5, + p1 = 1/8.6, + ), + + az = dict( + wild = 1.0, + b117 = 1/2.3, + b1351 = 1/9, + p1 = 1/2.9, + ), + + jj = dict( + wild = 1.0, + b117 = 1.0, + b1351 = 1/6.7, + p1 = 1/8.6, + ), + + novavax = dict( # https://ir.novavax.com/news-releases/news-release-details/novavax-covid-19-vaccine-demonstrates-893-efficacy-uk-phase-3 + wild = 1.0, + b117 = 1/1.12, + b1351 = 1/4.7, + p1 = 1/8.6, # assumption, no data available yet + ), + ) + + if default: + return pars['default'] + else: + return pars + + +def get_vaccine_dose_pars(default=False): + ''' + Define the dosing regimen for each vaccine + ''' + pars = dict( + + default = dict( + nab_eff = dict(sus=dict(slope=1.6, n_50=0.05)), + nab_init = dict(dist='normal', par1=2, par2=2), + nab_boost = 2, + doses = 1, + interval = None, + ), + + pfizer = dict( + nab_eff = dict(sus=dict(slope=1.6, n_50=0.05)), + nab_init = dict(dist='normal', par1=2, par2=2), + nab_boost = 3, + doses = 2, + interval = 21, + ), + + moderna = dict( + nab_eff = dict(sus=dict(slope=1.6, n_50=0.05)), + nab_init = dict(dist='normal', par1=2, par2=2), + nab_boost = 3, + doses = 2, + interval = 28, + ), + + az = dict( + nab_eff = dict(sus=dict(slope=1.6, n_50=0.05)), + nab_init = dict(dist='normal', par1=-0.85, par2=2), + nab_boost = 3, + doses = 2, + interval = 21, + ), + + jj = dict( + nab_eff = dict(sus=dict(slope=1.6, n_50=0.05)), + nab_init = dict(dist='normal', par1=-1.1, par2=2), + nab_boost = 3, + doses = 1, + interval = None, + ), + + novavax = dict( + nab_eff = dict(sus=dict(slope=1.6, n_50=0.05)), + nab_init = dict(dist='normal', par1=-0.9, par2=2), + nab_boost = 3, + doses = 2, + interval = 21, + ), + ) + + if default: + return pars['default'] + else: + return pars + + diff --git a/covasim/people.py b/covasim/people.py index 87a3af34a..33aee057a 100644 --- a/covasim/people.py +++ b/covasim/people.py @@ -11,6 +11,7 @@ from . import defaults as cvd from . import base as cvb from . import plotting as cvplt +from . import immunity as cvi __all__ = ['People'] @@ -23,6 +24,10 @@ class People(cvb.BasePeople): parameters dictionary will get passed instead since it will be needed before the People object is initialized. + Note that this class handles the mechanics of updating the actual people, while + BasePeople takes care of housekeeping (saving, loading, exporting, etc.). Please + see the BasePeople class for additional methods. + Args: pars (dict): the sim parameters, e.g. sim.pars -- alternatively, if a number, interpreted as pop_size strict (bool): whether or not to only create keys that are already in self.meta.person; otherwise, let any key be set @@ -39,12 +44,8 @@ class People(cvb.BasePeople): def __init__(self, pars, strict=True, **kwargs): # Handle pars and population size - if sc.isnumber(pars): # Interpret as a population size - pars = {'pop_size':pars} # Ensure it's a dictionary - self.pars = pars # Equivalent to self.set_pars(pars) - self.pop_size = int(pars['pop_size']) - self.location = pars.get('location') # Try to get location, but set to None otherwise - self.version = cvv.__version__ # Store version info + self.set_pars(pars) + self.version = cvv.__version__ # Store version info # Other initialization self.t = 0 # Keep current simulation time @@ -57,27 +58,40 @@ def __init__(self, pars, strict=True, **kwargs): # Set person properties -- all floats except for UID for key in self.meta.person: if key == 'uid': - self[key] = np.arange(self.pop_size, dtype=cvd.default_int) + self[key] = np.arange(self.pars['pop_size'], dtype=cvd.default_int) else: - self[key] = np.full(self.pop_size, np.nan, dtype=cvd.default_float) + self[key] = np.full(self.pars['pop_size'], np.nan, dtype=cvd.default_float) - # Set health states -- only susceptible is true by default -- booleans + # Set health states -- only susceptible is true by default -- booleans except exposed by strain which should return the strain that ind is exposed to for key in self.meta.states: - if key == 'susceptible': - self[key] = np.full(self.pop_size, True, dtype=bool) - else: - self[key] = np.full(self.pop_size, False, dtype=bool) + val = (key in ['susceptible', 'naive']) # Default value is True for susceptible and naive, false otherwise + self[key] = np.full(self.pars['pop_size'], val, dtype=bool) + + # Set strain states, which store info about which strain a person is exposed to + for key in self.meta.strain_states: + self[key] = np.full(self.pars['pop_size'], np.nan, dtype=cvd.default_float) + for key in self.meta.by_strain_states: + self[key] = np.full((self.pars['n_strains'], self.pars['pop_size']), False, dtype=bool) + + # Set immunity and antibody states + for key in self.meta.imm_states: # Everyone starts out with no immunity + self[key] = np.zeros((self.pars['n_strains'], self.pars['pop_size']), dtype=cvd.default_float) + for key in self.meta.nab_states: # Everyone starts out with no antibodies + self[key] = np.full(self.pars['pop_size'], np.nan, dtype=cvd.default_float) + for key in self.meta.vacc_states: + self[key] = np.zeros(self.pars['pop_size'], dtype=cvd.default_int) # Set dates and durations -- both floats for key in self.meta.dates + self.meta.durs: - self[key] = np.full(self.pop_size, np.nan, dtype=cvd.default_float) + self[key] = np.full(self.pars['pop_size'], np.nan, dtype=cvd.default_float) # Store the dtypes used in a flat dict self._dtypes = {key:self[key].dtype for key in self.keys()} # Assign all to float by default - self._lock = strict # If strict is true, stop further keys from being set (does not affect attributes) + if strict: + self.lock() # If strict is true, stop further keys from being set (does not affect attributes) # Store flows to be computed during simulation - self.flows = {key:0 for key in cvd.new_result_flows} + self.init_flows() # Although we have called init(), we still need to call initialize() self.initialized = False @@ -94,6 +108,16 @@ def __init__(self, pars, strict=True, **kwargs): self[key] = value self._pending_quarantine = defaultdict(list) # Internal cache to record people that need to be quarantined on each timestep {t:(person, quarantine_end_day)} + + return + + + def init_flows(self): + ''' Initialize flows to be zero ''' + self.flows = {key:0 for key in cvd.new_result_flows} + self.flows_strain = {} + for key in cvd.new_result_flows_by_strain: + self.flows_strain[key] = np.zeros(self.pars['n_strains'], dtype=cvd.default_float) return @@ -112,8 +136,8 @@ def set_prognoses(self): ''' pars = self.pars # Shorten - if 'prognoses' not in pars: - errormsg = 'This people object does not have the required parameters ("prognoses"). Create a sim (or parameters), then do e.g. people.set_pars(sim.pars).' + if 'prognoses' not in pars or 'rand_seed' not in pars: + errormsg = 'This people object does not have the required parameters ("prognoses" and "rand_seed"). Create a sim (or parameters), then do e.g. people.set_pars(sim.pars).' raise sc.KeyNotFoundError(errormsg) def find_cutoff(age_cutoffs, age): @@ -133,8 +157,8 @@ def find_cutoff(age_cutoffs, age): self.severe_prob[:] = progs['severe_probs'][inds]*progs['comorbidities'][inds] # Severe disease probability is modified by comorbidities self.crit_prob[:] = progs['crit_probs'][inds] # Probability of developing critical disease self.death_prob[:] = progs['death_probs'][inds] # Probability of death - self.rel_sus[:] = progs['sus_ORs'][inds] # Default susceptibilities - self.rel_trans[:] = progs['trans_ORs'][inds]*cvu.sample(**self.pars['beta_dist'], size=len(inds)) # Default transmissibilities, with viral load drawn from a distribution + self.rel_sus[:] = progs['sus_ORs'][inds] # Default susceptibilities + self.rel_trans[:] = progs['trans_ORs'][inds] * cvu.sample(**self.pars['beta_dist'], size=len(inds)) # Default transmissibilities, with viral load drawn from a distribution return @@ -144,19 +168,22 @@ def update_states_pre(self, t): # Initialize self.t = t - self.is_exp = self.true('exposed') # For storing the interim values since used in every subsequent calculation + self.is_exp = self.true('exposed') # For storing the interim values since used in every subsequent calculation # Perform updates - self.flows = {key:0 for key in cvd.new_result_flows} - self.flows['new_infectious'] += self.check_infectious() # For people who are exposed and not infectious, check if they begin being infectious - self.flows['new_symptomatic'] += self.check_symptomatic() - self.flows['new_severe'] += self.check_severe() - self.flows['new_critical'] += self.check_critical() - self.flows['new_deaths'] += self.check_death() - self.flows['new_recoveries'] += self.check_recovery() + self.init_flows() + self.flows['new_infectious'] += self.check_infectious() # For people who are exposed and not infectious, check if they begin being infectious + self.flows['new_symptomatic'] += self.check_symptomatic() + self.flows['new_severe'] += self.check_severe() + self.flows['new_critical'] += self.check_critical() + self.flows['new_recoveries'] += self.check_recovery() + new_deaths, new_known_deaths = self.check_death() + self.flows['new_deaths'] += new_deaths + self.flows['new_known_deaths'] += new_known_deaths return + def update_states_post(self): ''' Perform post-timestep updates ''' self.flows['new_diagnoses'] += self.check_diagnosed() @@ -168,29 +195,10 @@ def update_states_post(self): def update_contacts(self): ''' Refresh dynamic contacts, e.g. community ''' - # Figure out if anything needs to be done -- e.g. {'h':False, 'c':True} - dynam_keys = [lkey for lkey,is_dynam in self.pars['dynam_layer'].items() if is_dynam] - - # Loop over dynamic keys - for lkey in dynam_keys: - # Remove existing contacts - self.contacts.pop(lkey) - - # Choose how many contacts to make - pop_size = len(self) - n_contacts = self.pars['contacts'][lkey] - n_new = int(n_contacts*pop_size/2) # Since these get looped over in both directions later - - # Create the contacts - new_contacts = {} # Initialize - new_contacts['p1'] = np.array(cvu.choose_r(max_n=pop_size, n=n_new), dtype=cvd.default_int) # Choose with replacement - new_contacts['p2'] = np.array(cvu.choose_r(max_n=pop_size, n=n_new), dtype=cvd.default_int) - new_contacts['beta'] = np.ones(n_new, dtype=cvd.default_float) - - # Add to contacts - self.add_contacts(new_contacts, lkey=lkey) - self.contacts[lkey].validate() + for lkey, is_dynam in self.pars['dynam_layer'].items(): + if is_dynam: + self.contacts[lkey].update(self) return self.contacts @@ -212,6 +220,12 @@ def check_infectious(self): ''' Check if they become infectious ''' inds = self.check_inds(self.infectious, self.date_infectious, filter_inds=self.is_exp) self.infectious[inds] = True + self.infectious_strain[inds] = self.exposed_strain[inds] + for strain in range(self.pars['n_strains']): + this_strain_inds = cvu.itrue(self.infectious_strain[inds] == strain, inds) + n_this_strain_inds = len(this_strain_inds) + self.flows_strain['new_infectious_by_strain'][strain] += n_this_strain_inds + self.infectious_by_strain[strain, this_strain_inds] = True return len(inds) @@ -236,29 +250,72 @@ def check_critical(self): return len(inds) - def check_recovery(self): - ''' Check for recovery ''' - inds = self.check_inds(self.recovered, self.date_recovered, filter_inds=self.is_exp) - self.exposed[inds] = False - self.infectious[inds] = False - self.symptomatic[inds] = False - self.severe[inds] = False - self.critical[inds] = False - self.recovered[inds] = True + def check_recovery(self, inds=None, filter_inds='is_exp'): + ''' + Check for recovery. + + More complex than other functions to allow for recovery to be manually imposed + for a specified set of indices. + ''' + + # Handle more flexible options for setting indices + if filter_inds == 'is_exp': + filter_inds = self.is_exp + if inds is None: + inds = self.check_inds(self.recovered, self.date_recovered, filter_inds=filter_inds) + + # Now reset all disease states + self.exposed[inds] = False + self.infectious[inds] = False + self.symptomatic[inds] = False + self.severe[inds] = False + self.critical[inds] = False + self.recovered[inds] = True + self.recovered_strain[inds] = self.exposed_strain[inds] + self.infectious_strain[inds] = np.nan + self.exposed_strain[inds] = np.nan + self.exposed_by_strain[:, inds] = False + self.infectious_by_strain[:, inds] = False + + + # Handle immunity aspects + if self.pars['use_waning']: + + # Before letting them recover, store information about the strain they had, store symptoms and pre-compute nabs array + mild_inds = self.check_inds(self.susceptible, self.date_symptomatic, filter_inds=inds) + severe_inds = self.check_inds(self.susceptible, self.date_severe, filter_inds=inds) + + # Reset additional states + self.susceptible[inds] = True + self.diagnosed[inds] = False # Reset their diagnosis state because they might be reinfected + self.prior_symptoms[inds] = self.pars['rel_imm_symp']['asymp'] + self.prior_symptoms[mild_inds] = self.pars['rel_imm_symp']['mild'] + self.prior_symptoms[severe_inds] = self.pars['rel_imm_symp']['severe'] + if len(inds): + cvi.init_nab(self, inds, prior_inf=True) + return len(inds) def check_death(self): ''' Check whether or not this person died on this timestep ''' inds = self.check_inds(self.dead, self.date_dead, filter_inds=self.is_exp) - self.exposed[inds] = False - self.infectious[inds] = False - self.symptomatic[inds] = False - self.severe[inds] = False - self.critical[inds] = False - self.recovered[inds] = False - self.dead[inds] = True - return len(inds) + self.dead[inds] = True + diag_inds = inds[self.diagnosed[inds]] # Check whether the person was diagnosed before dying + self.known_dead[diag_inds] = True + self.susceptible[inds] = False + self.exposed[inds] = False + self.infectious[inds] = False + self.symptomatic[inds] = False + self.severe[inds] = False + self.critical[inds] = False + self.known_contact[inds] = False + self.quarantined[inds] = False + self.recovered[inds] = False + self.infectious_strain[inds] = np.nan + self.exposed_strain[inds] = np.nan + self.recovered_strain[inds] = np.nan + return len(inds), len(diag_inds) def check_diagnosed(self): @@ -291,7 +348,7 @@ def check_quar(self): for ind,end_day in self._pending_quarantine[self.t]: if self.quarantined[ind]: self.date_end_quarantine[ind] = max(self.date_end_quarantine[ind], end_day) # Extend quarantine if required - elif not (self.dead[ind] or self.recovered[ind] or self.diagnosed[ind]): # Unclear whether recovered should be included here + elif not (self.dead[ind] or self.recovered[ind] or self.diagnosed[ind]): # Unclear whether recovered should be included here # elif not (self.dead[ind] or self.diagnosed[ind]): self.quarantined[ind] = True self.date_quarantined[ind] = self.t self.date_end_quarantine[ind] = end_day @@ -306,44 +363,84 @@ def check_quar(self): #%% Methods to make events occur (infection and diagnosis) - def make_susceptible(self, inds): + def make_naive(self, inds): ''' - Make a set of people susceptible. This is used during dynamic resampling. + Make a set of people naive. This is used during dynamic resampling. ''' for key in self.meta.states: - if key == 'susceptible': + if key in ['susceptible', 'naive']: self[key][inds] = True else: self[key][inds] = False + # Reset strain states + for key in self.meta.strain_states: + self[key][inds] = np.nan + for key in self.meta.by_strain_states: + self[key][:, inds] = False + + # Reset immunity and antibody states + for key in self.meta.imm_states: + self[key][:, inds] = 0 + for key in self.meta.nab_states: + self[key][inds] = np.nan + for key in self.meta.vacc_states: + self[key][inds] = 0 + + # Reset dates for key in self.meta.dates + self.meta.durs: self[key][inds] = np.nan return - def infect(self, inds, hosp_max=None, icu_max=None, source=None, layer=None): + def make_nonnaive(self, inds, set_recovered=False, date_recovered=0): + ''' + Make a set of people non-naive. + + This can be done either by setting only susceptible and naive states, + or else by setting them as if they have been infected and recovered. + ''' + self.make_naive(inds) # First make them naive and reset all other states + + # Make them non-naive + for key in ['susceptible', 'naive']: + self[key][inds] = False + + if set_recovered: + self.date_recovered[inds] = date_recovered # Reset date recovered + self.check_recovered(inds=inds, filter_inds=None) # Set recovered + + return + + + + def infect(self, inds, hosp_max=None, icu_max=None, source=None, layer=None, strain=0): ''' Infect people and determine their eventual outcomes. + * Every infected person can infect other people, regardless of whether they develop symptoms * Infected people that develop symptoms are disaggregated into mild vs. severe (=requires hospitalization) vs. critical (=requires ICU) * Every asymptomatic, mildly symptomatic, and severely symptomatic person recovers * Critical cases either recover or die + Method also deduplicates input arrays in case one agent is infected many times + and stores who infected whom in infection_log list. + Args: inds (array): array of people to infect hosp_max (bool): whether or not there is an acute bed available for this person icu_max (bool): whether or not there is an ICU bed available for this person source (array): source indices of the people who transmitted this infection (None if an importation or seed infection) layer (str): contact layer this infection was transmitted on + strain (int): the strain people are being infected by Returns: count (int): number of people infected ''' # Remove duplicates - unique = np.unique(inds, return_index=True)[1] - inds = inds[unique] + inds, unique = np.unique(inds, return_index=True) if source is not None: source = source[unique] @@ -353,14 +450,33 @@ def infect(self, inds, hosp_max=None, icu_max=None, source=None, layer=None): if source is not None: source = source[keep] + if self.pars['use_waning']: + cvi.check_immunity(self, strain, sus=False, inds=inds) + + # Deal with strain parameters + strain_keys = ['rel_symp_prob', 'rel_severe_prob', 'rel_crit_prob', 'rel_death_prob'] + infect_pars = {k:self.pars[k] for k in strain_keys} + if strain: + strain_label = self.pars['strain_map'][strain] + for k in strain_keys: + infect_pars[k] *= self.pars['strain_pars'][strain_label][k] + n_infections = len(inds) durpars = self.pars['dur'] - # Set states - self.susceptible[inds] = False - self.exposed[inds] = True - self.date_exposed[inds] = self.t - self.flows['new_infections'] += len(inds) + # Update states, strain info, and flows + self.susceptible[inds] = False + self.naive[inds] = False + self.recovered[inds] = False + self.diagnosed[inds] = False + self.exposed[inds] = True + self.exposed_strain[inds] = strain + self.exposed_by_strain[strain, inds] = True + self.flows['new_infections'] += len(inds) + self.flows['new_reinfections'] += len(cvu.defined(self.date_recovered[inds])) # Record reinfections + self.flows_strain['new_infections_by_strain'][strain] += len(inds) + # print('HI DEBUG', self.t, len(inds), len(cvu.defined(self.date_recovered[inds]))) + # self.date_recovered[inds] = np.nan # Reset date they recovered - we only store the last recovery # TODO CK # Record transmissions for i, target in enumerate(inds): @@ -368,10 +484,15 @@ def infect(self, inds, hosp_max=None, icu_max=None, source=None, layer=None): # Calculate how long before this person can infect other people self.dur_exp2inf[inds] = cvu.sample(**durpars['exp2inf'], size=n_infections) + self.date_exposed[inds] = self.t self.date_infectious[inds] = self.dur_exp2inf[inds] + self.t + # Reset all other dates + for key in ['date_symptomatic', 'date_severe', 'date_critical', 'date_diagnosed', 'date_recovered']: + self[key][inds] = np.nan + # Use prognosis probabilities to determine what happens to them - symp_probs = self.pars['rel_symp_prob']*self.symp_prob[inds] # Calculate their actual probability of being symptomatic + symp_probs = infect_pars['rel_symp_prob']*self.symp_prob[inds]*(1-self.symp_imm[strain, inds]) # Calculate their actual probability of being symptomatic is_symp = cvu.binomial_arr(symp_probs) # Determine if they develop symptoms symp_inds = inds[is_symp] asymp_inds = inds[~is_symp] # Asymptomatic @@ -385,7 +506,8 @@ def infect(self, inds, hosp_max=None, icu_max=None, source=None, layer=None): n_symp_inds = len(symp_inds) self.dur_inf2sym[symp_inds] = cvu.sample(**durpars['inf2sym'], size=n_symp_inds) # Store how long this person took to develop symptoms self.date_symptomatic[symp_inds] = self.date_infectious[symp_inds] + self.dur_inf2sym[symp_inds] # Date they become symptomatic - sev_probs = self.pars['rel_severe_prob'] * self.severe_prob[symp_inds] # Probability of these people being severe + sev_probs = infect_pars['rel_severe_prob'] * self.severe_prob[symp_inds]*(1-self.sev_imm[strain, symp_inds]) # Probability of these people being severe + # print(self.sev_imm[strain, inds]) is_sev = cvu.binomial_arr(sev_probs) # See if they're a severe or mild case sev_inds = symp_inds[is_sev] mild_inds = symp_inds[~is_sev] # Not severe @@ -398,7 +520,7 @@ def infect(self, inds, hosp_max=None, icu_max=None, source=None, layer=None): # CASE 2.2: Severe cases: hospitalization required, may become critical self.dur_sym2sev[sev_inds] = cvu.sample(**durpars['sym2sev'], size=len(sev_inds)) # Store how long this person took to develop severe symptoms self.date_severe[sev_inds] = self.date_symptomatic[sev_inds] + self.dur_sym2sev[sev_inds] # Date symptoms become severe - crit_probs = self.pars['rel_crit_prob'] * self.crit_prob[sev_inds] * (self.pars['no_hosp_factor'] if hosp_max else 1.)# Probability of these people becoming critical - higher if no beds available + crit_probs = infect_pars['rel_crit_prob'] * self.crit_prob[sev_inds] * (self.pars['no_hosp_factor'] if hosp_max else 1.) # Probability of these people becoming critical - higher if no beds available is_crit = cvu.binomial_arr(crit_probs) # See if they're a critical case crit_inds = sev_inds[is_crit] non_crit_inds = sev_inds[~is_crit] @@ -411,7 +533,7 @@ def infect(self, inds, hosp_max=None, icu_max=None, source=None, layer=None): # CASE 2.2.2: Critical cases: ICU required, may die self.dur_sev2crit[crit_inds] = cvu.sample(**durpars['sev2crit'], size=len(crit_inds)) self.date_critical[crit_inds] = self.date_severe[crit_inds] + self.dur_sev2crit[crit_inds] # Date they become critical - death_probs = self.pars['rel_death_prob'] * self.death_prob[crit_inds] * (self.pars['no_icu_factor'] if icu_max else 1.) # Probability they'll die + death_probs = infect_pars['rel_death_prob'] * self.death_prob[crit_inds] * (self.pars['no_icu_factor'] if icu_max else 1.)# Probability they'll die is_dead = cvu.binomial_arr(death_probs) # Death outcome dead_inds = crit_inds[is_dead] alive_inds = crit_inds[~is_dead] @@ -425,6 +547,7 @@ def infect(self, inds, hosp_max=None, icu_max=None, source=None, layer=None): dur_crit2die = cvu.sample(**durpars['crit2die'], size=len(dead_inds)) self.date_dead[dead_inds] = self.date_critical[dead_inds] + dur_crit2die # Date of death self.dur_disease[dead_inds] = self.dur_exp2inf[dead_inds] + self.dur_inf2sym[dead_inds] + self.dur_sym2sev[dead_inds] + self.dur_sev2crit[dead_inds] + dur_crit2die # Store how long this person had COVID-19 + self.date_recovered[dead_inds] = np.nan # If they did die, remove them from recovered return n_infections # For incrementing counters @@ -473,7 +596,7 @@ def schedule_quarantine(self, inds, start_date=None, period=None): Args: inds (int): indices of who to quarantine, specified by check_quar() start_date (int): day to begin quarantine (defaults to the current day, `sim.t`) - period (int): quarantine duration (defaults to `pars['quar_period']`) + period (int): quarantine duration (defaults to ``pars['quar_period']``) ''' start_date = self.t if start_date is None else int(start_date) @@ -574,18 +697,18 @@ def label_lkey(lkey): events = [] dates = { - 'date_critical' : 'became critically ill and needed ICU care', - 'date_dead' : 'died ☹', - 'date_diagnosed' : 'was diagnosed with COVID', - 'date_end_quarantine' : 'ended quarantine', - 'date_infectious' : 'became infectious', - 'date_known_contact' : 'was notified they may have been exposed to COVID', - 'date_pos_test' : 'recieved their positive test result', - 'date_quarantined' : 'entered quarantine', - 'date_recovered' : 'recovered', - 'date_severe' : 'developed severe symptoms and needed hospitalization', - 'date_symptomatic' : 'became symptomatic', - 'date_tested' : 'was tested for COVID', + 'date_critical' : 'became critically ill and needed ICU care', + 'date_dead' : 'died ☹', + 'date_diagnosed' : 'was diagnosed with COVID', + 'date_end_quarantine' : 'ended quarantine', + 'date_infectious' : 'became infectious', + 'date_known_contact' : 'was notified they may have been exposed to COVID', + 'date_pos_test' : 'recieved their positive test result', + 'date_quarantined' : 'entered quarantine', + 'date_recovered' : 'recovered', + 'date_severe' : 'developed severe symptoms and needed hospitalization', + 'date_symptomatic' : 'became symptomatic', + 'date_tested' : 'was tested for COVID', } for attribute, message in dates.items(): @@ -600,7 +723,7 @@ def label_lkey(lkey): if lkey: events.append((infection['date'], f'was infected with COVID by {infection["source"]} via the {llabel} layer')) else: - events.append((infection['date'], f'was infected with COVID as a seed infection')) + events.append((infection['date'], 'was infected with COVID as a seed infection')) if infection['source'] == uid: x = len([a for a in self.infection_log if a['source'] == infection['target']]) @@ -612,3 +735,4 @@ def label_lkey(lkey): else: print(f'Nothing happened to {uid} during the simulation.') return + diff --git a/covasim/plotting.py b/covasim/plotting.py index 595f7cc1b..bf9fc633f 100644 --- a/covasim/plotting.py +++ b/covasim/plotting.py @@ -16,35 +16,75 @@ from . import settings as cvset -__all__ = ['plot_sim', 'plot_scens', 'plot_result', 'plot_compare', 'plot_people', 'plotly_sim', 'plotly_people', 'plotly_animate'] +__all__ = ['date_formatter', 'plot_sim', 'plot_scens', 'plot_result', 'plot_compare', 'plot_people', 'plotly_sim', 'plotly_people', 'plotly_animate'] #%% Plotting helper functions -def handle_args(fig_args=None, plot_args=None, scatter_args=None, axis_args=None, fill_args=None, legend_args=None, show_args=None): +def handle_args(fig_args=None, plot_args=None, scatter_args=None, axis_args=None, fill_args=None, + legend_args=None, date_args=None, show_args=None, mpl_args=None, **kwargs): ''' Handle input arguments -- merge user input with defaults; see sim.plot for documentation ''' + + # Set defaults + defaults = sc.objdict() + defaults.fig = sc.objdict(figsize=(10, 8)) + defaults.plot = sc.objdict(lw=1.5, alpha= 0.7) + defaults.scatter = sc.objdict(s=20, marker='s', alpha=0.7, zorder=0) + defaults.axis = sc.objdict(left=0.10, bottom=0.08, right=0.95, top=0.95, wspace=0.30, hspace=0.30) + defaults.fill = sc.objdict(alpha=0.2) + defaults.legend = sc.objdict(loc='best', frameon=False) + defaults.date = sc.objdict(as_dates=True, dateformat=None, interval=None, rotation=None, start_day=None, end_day=None) + defaults.show = sc.objdict(data=True, ticks=True, interventions=True, legend=True) + defaults.mpl = sc.objdict(dpi=None, fontsize=None, fontfamily=None) # Use Covasim global defaults + + # Handle directly supplied kwargs + for dkey,default in defaults.items(): + keys = list(kwargs.keys()) + for kw in keys: + if kw in default.keys(): + default[kw] = kwargs.pop(kw) + + # Merge arguments together args = sc.objdict() - args.fig = sc.mergedicts({'figsize': (10, 8)}, fig_args) - args.plot = sc.mergedicts({'lw': 1.5, 'alpha': 0.7}, plot_args) - args.scatter = sc.mergedicts({'s':20, 'marker':'s', 'alpha':0.7, 'zorder':0}, scatter_args) - args.axis = sc.mergedicts({'left': 0.10, 'bottom': 0.08, 'right': 0.95, 'top': 0.95, 'wspace': 0.30, 'hspace': 0.30}, axis_args) - args.fill = sc.mergedicts({'alpha': 0.2}, fill_args) - args.legend = sc.mergedicts({'loc': 'best', 'frameon':False}, legend_args) - args.show = sc.mergedicts({'data':True, 'interventions':True, 'legend':True, }, show_args) + args.fig = sc.mergedicts(defaults.fig, fig_args) + args.plot = sc.mergedicts(defaults.plot, plot_args) + args.scatter = sc.mergedicts(defaults.scatter, scatter_args) + args.axis = sc.mergedicts(defaults.axis, axis_args) + args.fill = sc.mergedicts(defaults.fill, fill_args) + args.legend = sc.mergedicts(defaults.legend, legend_args) + args.date = sc.mergedicts(defaults.date, fill_args) + args.show = sc.mergedicts(defaults.show, show_args) + args.mpl = sc.mergedicts(defaults.mpl, mpl_args) + + # If unused keyword arguments remain, raise an error + if len(kwargs): + notfound = sc.strjoin(kwargs.keys()) + valid = sc.strjoin(sorted(set([k for d in defaults.values() for k in d.keys()]))) # Remove duplicates and order + errormsg = f'The following keywords could not be processed:\n{notfound}\n\n' + errormsg += f'Valid keywords are:\n{valid}\n\n' + errormsg += 'For more precise plotting control, use fig_args, plot_args, etc.' + raise sc.KeyNotFoundError(errormsg) # Handle what to show - show_keys = ['data', 'ticks', 'interventions', 'legend'] + show_keys = defaults.show.keys() args.show = {k:True for k in show_keys} if show_args in [True, False]: # Handle all on or all off args.show = {k:show_args for k in show_keys} else: args.show = sc.mergedicts(args.show, show_args) + # Handle global Matplotlib arguments + args.mpl_orig = sc.objdict() + for key,value in args.mpl.items(): + if value is not None: + args.mpl_orig[key] = cvset.options.get(key) + cvset.options.set(key, value) + return args -def handle_to_plot(which, to_plot, n_cols, sim, check_ready=True): +def handle_to_plot(kind, to_plot, n_cols, sim, check_ready=True): ''' Handle which quantities to plot ''' # Check that results are ready @@ -54,20 +94,16 @@ def handle_to_plot(which, to_plot, n_cols, sim, check_ready=True): # If not specified or specified as a string, load defaults if to_plot is None or isinstance(to_plot, str): - if which == 'sim': - to_plot = cvd.get_sim_plots(to_plot) - elif which =='scens': - to_plot = cvd.get_scen_plots(to_plot) - else: - errormsg = f'"which" must be "sim" or "scens", not "{which}"' - raise NotImplementedError(errormsg) + to_plot = cvd.get_default_plots(to_plot, kind=kind, sim=sim) # If a list of keys has been supplied if isinstance(to_plot, list): to_plot_list = to_plot # Store separately to_plot = sc.odict() # Create the dict + reskeys = sim.result_keys() for reskey in to_plot_list: - to_plot[sim.results[reskey].name] = [reskey] # Use the result name as the key and the reskey as the value + name = sim.results[reskey].name if reskey in reskeys else sim.results['strain'][reskey].name + to_plot[name] = [reskey] # Use the result name as the key and the reskey as the value to_plot = sc.odict(sc.dcp(to_plot)) # In case it's supplied as a dict @@ -188,33 +224,107 @@ def title_grid_legend(ax, title, grid, commaticks, setylim, legend_args, show_le return -def reset_ticks(ax, sim, interval, as_dates, dateformat): - ''' Set the tick marks, using dates by default ''' +def date_formatter(start_day=None, dateformat=None, interval=None, start=None, end=None, ax=None, sim=None): + ''' + Create an automatic date formatter based on a number of days and a start day. + + Wrapper for Matplotlib's date formatter. Note, start_day is not required if the + axis uses dates already. To be used in conjunction with setting the x-axis + tick label formatter. + + Args: + start_day (str/date): the start day, either as a string or date object + dateformat (str): the date format (default '%b-%d') + interval (int): if supplied, the interval between ticks (must supply an axis also to take effect) + start (str/int): if supplied, the lower limit of the axis + end (str/int): if supplied, the upper limit of the axis + ax (axes): if supplied, automatically set the x-axis formatter for this axis + sim (Sim): if supplied, get the start day from this + + **Examples**:: + + # Automatically configure the axis with default option + cv.date_formatter(sim=sim, ax=ax) + + # Manually configure + ax = pl.subplot(111) + ax.plot(np.arange(60), np.random.random(60)) + formatter = cv.date_formatter(start_day='2020-04-04', interval=7, start='2020-05-01', end=50, dateformat='%Y-%m-%d', ax=ax) + ax.xaxis.set_major_formatter(formatter) + ''' # Set the default -- "Mar-01" if dateformat is None: dateformat = '%b-%d' + # Convert to a date object + if start_day is None and sim is not None: + start_day = sim['start_day'] + if start_day is None: + errormsg = 'If not supplying a start day, you must supply a sim object' + raise ValueError(errormsg) + start_day = sc.date(start_day) + + @ticker.FuncFormatter + def mpl_formatter(x, pos): + return (start_day + dt.timedelta(days=int(x))).strftime(dateformat) + + # Set initial tick marks (intervals and limits) + if ax is not None: + + # Handle limits + xmin, xmax = ax.get_xlim() + if start: + xmin = sc.day(start, start_day=start_day) + if end: + xmax = sc.day(end, start_day=start_day) + ax.set_xlim((xmin, xmax)) + + # Set the x-axis intervals + if interval: + ax.set_xticks(np.arange(xmin, xmax+1, interval)) + + # Set the formatter + ax.xaxis.set_major_formatter(mpl_formatter) + + return mpl_formatter + + +def reset_ticks(ax, sim=None, date_args=None, start_day=None): + ''' Set the tick marks, using dates by default ''' + + # Handle options + date_args = sc.objdict(date_args) # Ensure it's not a regular dict + if start_day is None and sim is not None: + start_day = sim['start_day'] + + # Handle start and end days + xmin,xmax = ax.get_xlim() + if date_args.start_day: + xmin = float(sc.day(date_args.start_day, start_day=start_day)) # Keep original type (float) + if date_args.end_day: + xmax = float(sc.day(date_args.end_day, start_day=start_day)) + ax.set_xlim([xmin, xmax]) + # Set the x-axis intervals - if interval: - xmin,xmax = ax.get_xlim() - ax.set_xticks(pl.arange(xmin, xmax+1, interval)) + if date_args.interval: + ax.set_xticks(np.arange(xmin, xmax+1, date_args.interval)) # Set xticks as dates - if as_dates: - - @ticker.FuncFormatter - def date_formatter(x, pos): - return (sim['start_day'] + dt.timedelta(days=x)).strftime(dateformat) + if date_args.as_dates: - ax.xaxis.set_major_formatter(date_formatter) - if not interval: + date_formatter(start_day=start_day, dateformat=date_args.dateformat, ax=ax) + if not date_args.interval: ax.xaxis.set_major_locator(ticker.MaxNLocator(integer=True)) + # Handle rotation + if date_args.rotation: + ax.tick_params(axis='x', labelrotation=date_args.rotation) + return -def tidy_up(fig, figs, sep_figs, do_save, fig_path, do_show): +def tidy_up(fig, figs, sep_figs, do_save, fig_path, do_show, args): ''' Handle saving, figure showing, and what value to return ''' # Handle saving @@ -225,7 +335,6 @@ def tidy_up(fig, figs, sep_figs, do_save, fig_path, do_show): # Show the figure, or close it do_show = cvset.handle_show(do_show) - if cvset.options.close and not do_show: if sep_figs: for fig in figs: @@ -233,6 +342,10 @@ def tidy_up(fig, figs, sep_figs, do_save, fig_path, do_show): else: pl.close(fig) + # Reset Matplotlib defaults + for key,value in args.mpl_orig.items(): + cvset.options.set(key, value) + # Return the figure or figures if sep_figs: return figs @@ -257,50 +370,63 @@ def set_line_options(input_args, reskey, resnum, default): #%% Core plotting functions -def plot_sim(sim, to_plot=None, do_save=None, fig_path=None, fig_args=None, plot_args=None, - scatter_args=None, axis_args=None, fill_args=None, legend_args=None, show_args=None, - as_dates=True, dateformat=None, interval=None, n_cols=None, grid=False, commaticks=True, +def plot_sim(to_plot=None, sim=None, do_save=None, fig_path=None, fig_args=None, plot_args=None, + scatter_args=None, axis_args=None, fill_args=None, legend_args=None, date_args=None, + show_args=None, mpl_args=None, n_cols=None, grid=False, commaticks=True, setylim=True, log_scale=False, colors=None, labels=None, do_show=None, sep_figs=False, - fig=None, ax=None): + fig=None, ax=None, **kwargs): ''' Plot the results of a single simulation -- see Sim.plot() for documentation ''' # Handle inputs - args = handle_args(fig_args, plot_args, scatter_args, axis_args, fill_args, legend_args, show_args) + args = handle_args(fig_args=fig_args, plot_args=plot_args, scatter_args=scatter_args, axis_args=axis_args, fill_args=fill_args, + legend_args=legend_args, show_args=show_args, date_args=date_args, mpl_args=mpl_args, **kwargs) to_plot, n_cols, n_rows = handle_to_plot('sim', to_plot, n_cols, sim=sim) fig, figs = create_figs(args, sep_figs, fig, ax) # Do the plotting + strain_keys = sim.result_keys('strain') for pnum,title,keylabels in to_plot.enumitems(): ax = create_subplots(figs, fig, ax, n_rows, n_cols, pnum, args.fig, sep_figs, log_scale, title) for resnum,reskey in enumerate(keylabels): - res = sim.results[reskey] res_t = sim.results['t'] - color = set_line_options(colors, reskey, resnum, res.color) # Choose the color - label = set_line_options(labels, reskey, resnum, res.name) # Choose the label - if res.low is not None and res.high is not None: - ax.fill_between(res_t, res.low, res.high, color=color, **args.fill) # Create the uncertainty bound - ax.plot(res_t, res.values, label=label, **args.plot, c=color) # Actually plot the sim! + if reskey in strain_keys: + res = sim.results['strain'][reskey] + ns = sim['n_strains'] + strain_colors = sc.gridcolors(ns) + for strain in range(ns): + color = strain_colors[strain] # Choose the color + label = 'wild type' if strain == 0 else sim['strains'][strain-1].label + if res.low is not None and res.high is not None: + ax.fill_between(res_t, res.low[strain,:], res.high[strain,:], color=color, **args.fill) # Create the uncertainty bound + ax.plot(res_t, res.values[strain,:], label=label, **args.plot, c=color) # Actually plot the sim! + else: + res = sim.results[reskey] + color = set_line_options(colors, reskey, resnum, res.color) # Choose the color + label = set_line_options(labels, reskey, resnum, res.name) # Choose the label + if res.low is not None and res.high is not None: + ax.fill_between(res_t, res.low, res.high, color=color, **args.fill) # Create the uncertainty bound + ax.plot(res_t, res.values, label=label, **args.plot, c=color) # Actually plot the sim! if args.show['data']: - plot_data(sim, ax, reskey, args.scatter, color=color) # Plot the data + plot_data(sim, ax, reskey, args.scatter, color=color) # Plot the data if args.show['ticks']: - reset_ticks(ax, sim, interval, as_dates, dateformat) # Optionally reset tick marks (useful for e.g. plotting weeks/months) + reset_ticks(ax, sim, args.date) # Optionally reset tick marks (useful for e.g. plotting weeks/months) if args.show['interventions']: plot_interventions(sim, ax) # Plot the interventions if args.show['legend']: title_grid_legend(ax, title, grid, commaticks, setylim, args.legend) # Configure the title, grid, and legend - return tidy_up(fig, figs, sep_figs, do_save, fig_path, do_show) + return tidy_up(fig, figs, sep_figs, do_save, fig_path, do_show, args) -def plot_scens(scens, to_plot=None, do_save=None, fig_path=None, fig_args=None, plot_args=None, - scatter_args=None, axis_args=None, fill_args=None, legend_args=None, show_args=None, - as_dates=True, dateformat=None, interval=None, n_cols=None, grid=False, commaticks=True, - setylim=True, log_scale=False, colors=None, labels=None, do_show=None, sep_figs=False, - fig=None, ax=None): +def plot_scens(to_plot=None, scens=None, do_save=None, fig_path=None, fig_args=None, plot_args=None, + scatter_args=None, axis_args=None, fill_args=None, legend_args=None, date_args=None, + show_args=None, mpl_args=None, n_cols=None, grid=False, commaticks=True, setylim=True, + log_scale=False, colors=None, labels=None, do_show=None, sep_figs=False, fig=None, ax=None, **kwargs): ''' Plot the results of a scenario -- see Scenarios.plot() for documentation ''' # Handle inputs - args = handle_args(fig_args, plot_args, scatter_args, axis_args, fill_args, legend_args) + args = handle_args(fig_args=fig_args, plot_args=plot_args, scatter_args=scatter_args, axis_args=axis_args, fill_args=fill_args, + legend_args=legend_args, show_args=show_args, date_args=date_args, mpl_args=mpl_args, **kwargs) to_plot, n_cols, n_rows = handle_to_plot('scens', to_plot, n_cols, sim=scens.base_sim, check_ready=False) # Since this sim isn't run fig, figs = create_figs(args, sep_figs, fig, ax) @@ -313,34 +439,48 @@ def plot_scens(scens, to_plot=None, do_save=None, fig_path=None, fig_args=None, resdata = scens.results[reskey] for snum,scenkey,scendata in resdata.enumitems(): sim = scens.sims[scenkey][0] # Pull out the first sim in the list for this scenario - res_y = scendata.best - color = set_line_options(colors, scenkey, snum, default_colors[snum]) # Choose the color - label = set_line_options(labels, scenkey, snum, scendata.name) # Choose the label - ax.fill_between(scens.tvec, scendata.low, scendata.high, color=color, **args.fill) # Create the uncertainty bound - ax.plot(scens.tvec, res_y, label=label, c=color, **args.plot) # Plot the actual line - if args.show['data']: - plot_data(sim, ax, reskey, args.scatter, color=color) # Plot the data + strain_keys = sim.result_keys('strain') + if reskey in strain_keys: + ns = sim['n_strains'] + strain_colors = sc.gridcolors(ns) + for strain in range(ns): + res_y = scendata.best[strain,:] + color = strain_colors[strain] # Choose the color + label = 'wild type' if strain == 0 else sim['strains'][strain - 1].label + ax.fill_between(scens.tvec, scendata.low[strain,:], scendata.high[strain,:], color=color, **args.fill) # Create the uncertainty bound + ax.plot(scens.tvec, res_y, label=label, c=color, **args.plot) # Plot the actual line + if args.show['data']: + plot_data(sim, ax, reskey, args.scatter, color=color) # Plot the data + else: + res_y = scendata.best + color = set_line_options(colors, scenkey, snum, default_colors[snum]) # Choose the color + label = set_line_options(labels, scenkey, snum, scendata.name) # Choose the label + ax.fill_between(scens.tvec, scendata.low, scendata.high, color=color, **args.fill) # Create the uncertainty bound + ax.plot(scens.tvec, res_y, label=label, c=color, **args.plot) # Plot the actual line + if args.show['data']: + plot_data(sim, ax, reskey, args.scatter, color=color) # Plot the data + if args.show['interventions']: plot_interventions(sim, ax) # Plot the interventions if args.show['ticks']: - reset_ticks(ax, sim, interval, as_dates, dateformat) # Optionally reset tick marks (useful for e.g. plotting weeks/months) + reset_ticks(ax, sim, args.date) # Optionally reset tick marks (useful for e.g. plotting weeks/months) if args.show['legend']: title_grid_legend(ax, title, grid, commaticks, setylim, args.legend, pnum==0) # Configure the title, grid, and legend -- only show legend for first - return tidy_up(fig, figs, sep_figs, do_save, fig_path, do_show) + return tidy_up(fig, figs, sep_figs, do_save, fig_path, do_show, args) -def plot_result(sim, key, fig_args=None, plot_args=None, axis_args=None, scatter_args=None, - grid=False, commaticks=True, setylim=True, as_dates=True, dateformat=None, - interval=None, color=None, label=None, do_show=None, do_save=False, - fig_path=None, fig=None, ax=None): +def plot_result(key, sim=None, fig_args=None, plot_args=None, axis_args=None, scatter_args=None, + date_args=None, mpl_args=None, grid=False, commaticks=True, setylim=True, color=None, label=None, + do_show=None, do_save=False, fig_path=None, fig=None, ax=None, **kwargs): ''' Plot a single result -- see Sim.plot_result() for documentation ''' # Handle inputs sep_figs = False # Only one figure fig_args = sc.mergedicts({'figsize':(8,5)}, fig_args) axis_args = sc.mergedicts({'top': 0.95}, axis_args) - args = handle_args(fig_args, plot_args, scatter_args, axis_args) + args = handle_args(fig_args=fig_args, plot_args=plot_args, scatter_args=scatter_args, axis_args=axis_args, + date_args=date_args, mpl_args=mpl_args, **kwargs) fig, figs = create_figs(args, sep_figs, fig, ax) # Gather results @@ -365,20 +505,19 @@ def plot_result(sim, key, fig_args=None, plot_args=None, axis_args=None, scatter plot_data(sim, ax, key, args.scatter, color=color) # Plot the data plot_interventions(sim, ax) # Plot the interventions title_grid_legend(ax, res.name, grid, commaticks, setylim, args.legend) # Configure the title, grid, and legend - reset_ticks(ax, sim, interval, as_dates, dateformat) # Optionally reset tick marks (useful for e.g. plotting weeks/months) + reset_ticks(ax, sim, args.date) # Optionally reset tick marks (useful for e.g. plotting weeks/months) - return tidy_up(fig, figs, sep_figs, do_save, fig_path, do_show) + return tidy_up(fig, figs, sep_figs, do_save, fig_path, do_show, args) -def plot_compare(df, log_scale=True, fig_args=None, plot_args=None, axis_args=None, scatter_args=None, - grid=False, commaticks=True, setylim=True, as_dates=True, dateformat=None, - interval=None, color=None, label=None, fig=None): +def plot_compare(df, log_scale=True, fig_args=None, axis_args=None, mpl_args=None, grid=False, + commaticks=True, setylim=True, color=None, label=None, fig=None, **kwargs): ''' Plot a MultiSim comparison -- see MultiSim.plot_compare() for documentation ''' # Handle inputs fig_args = sc.mergedicts({'figsize':(8,8)}, fig_args) axis_args = sc.mergedicts({'left': 0.16, 'bottom': 0.05, 'right': 0.98, 'top': 0.98, 'wspace': 0.50, 'hspace': 0.10}, axis_args) - args = handle_args(fig_args, plot_args, scatter_args, axis_args) + args = handle_args(fig_args=fig_args, axis_args=axis_args, mpl_args=mpl_args, **kwargs) fig, figs = create_figs(args, sep_figs=False, fig=fig) # Map from results into different categories @@ -612,7 +751,7 @@ def plotly_sim(sim, do_show=False): go = import_plotly() # Load Plotly plots = [] - to_plot = cvd.get_sim_plots() + to_plot = cvd.get_default_plots() for p,title,keylabels in to_plot.enumitems(): fig = go.Figure() for key in keylabels: diff --git a/covasim/population.py b/covasim/population.py index 539bad5b4..dc2529d73 100644 --- a/covasim/population.py +++ b/covasim/population.py @@ -11,7 +11,7 @@ from . import misc as cvm from . import data as cvdata from . import defaults as cvd -from . import parameters as cvpars +from . import parameters as cvpar from . import people as cvppl @@ -24,11 +24,11 @@ def make_people(sim, popdict=None, save_pop=False, popfile=None, die=True, reset=False, verbose=None, **kwargs): ''' Make the actual people for the simulation. Usually called via sim.initialize(), - not directly by the user. + but can be called directly by the user. Args: - sim (Sim) : the simulation object - popdict (dict) : if supplied, use this population dictionary rather than generate a new one + sim (Sim) : the simulation object; population parameters are taken from the sim object + popdict (dict) : if supplied, use this population dictionary instead of generating a new one save_pop (bool) : whether to save the population to disk popfile (bool) : if so, the filename to save to die (bool) : whether or not to fail if synthetic populations are requested but not available @@ -50,7 +50,7 @@ def make_people(sim, popdict=None, save_pop=False, popfile=None, die=True, reset # Check which type of population to produce if pop_type == 'synthpops': - if not cvreq.check_synthpops(): + if not cvreq.check_synthpops(): # pragma: no cover errormsg = f'You have requested "{pop_type}" population, but synthpops is not available; please use random, clustered, or hybrid' if die: raise ValueError(errormsg) @@ -59,7 +59,7 @@ def make_people(sim, popdict=None, save_pop=False, popfile=None, die=True, reset pop_type = 'random' location = sim['location'] - if location: + if location: # pragma: no cover print(f'Warning: not setting ages or contacts for "{location}" since synthpops contacts are pre-generated') # Actually create the population @@ -74,16 +74,16 @@ def make_people(sim, popdict=None, save_pop=False, popfile=None, die=True, reset popdict = make_randpop(sim, microstructure=pop_type, **kwargs) elif pop_type == 'synthpops': popdict = make_synthpop(sim, **kwargs) - elif pop_type is None: + elif pop_type is None: # pragma: no cover errormsg = 'You have set pop_type=None. This is fine, but you must ensure sim.popdict exists before calling make_people().' raise ValueError(errormsg) - else: + else: # pragma: no cover errormsg = f'Population type "{pop_type}" not found; choices are random, clustered, hybrid, or synthpops' raise ValueError(errormsg) # Ensure prognoses are set if sim['prognoses'] is None: - sim['prognoses'] = cvpars.get_prognoses(sim['prog_by_age'], version=sim._default_ver) + sim['prognoses'] = cvpar.get_prognoses(sim['prog_by_age'], version=sim._default_ver) # Actually create the people people = cvppl.People(sim.pars, uid=popdict['uid'], age=popdict['age'], sex=popdict['sex'], contacts=popdict['contacts']) # List for storing the people @@ -92,7 +92,7 @@ def make_people(sim, popdict=None, save_pop=False, popfile=None, die=True, reset sc.printv(f'Created {pop_size} people, average age {average_age:0.2f} years', 2, verbose) if save_pop: - if popfile is None: + if popfile is None: # pragma: no cover errormsg = 'Please specify a file to save to using the popfile kwarg' raise FileNotFoundError(errormsg) else: @@ -173,7 +173,7 @@ def make_randpop(sim, use_age_data=True, use_household_data=True, sex_ratio=0.5, if microstructure == 'random': contacts, layer_keys = make_random_contacts(pop_size, sim['contacts']) elif microstructure == 'clustered': contacts, layer_keys, _ = make_microstructured_contacts(pop_size, sim['contacts']) elif microstructure == 'hybrid': contacts, layer_keys, _ = make_hybrid_contacts(pop_size, ages, sim['contacts']) - else: + else: # pragma: no cover errormsg = f'Microstructure type "{microstructure}" not found; choices are random, clustered, or hybrid' raise NotImplementedError(errormsg) @@ -338,8 +338,8 @@ def make_synthpop(sim=None, population=None, layer_mapping=None, community_conta ''' try: import synthpops as sp # Optional import - except ModuleNotFoundError as E: - errormsg = f'Please install the optional SynthPops module first, e.g. pip install synthpops' # Also caught in make_people() + except ModuleNotFoundError as E: # pragma: no cover + errormsg = 'Please install the optional SynthPops module first, e.g. pip install synthpops' # Also caught in make_people() raise ModuleNotFoundError(errormsg) from E # Handle layer mapping @@ -348,7 +348,7 @@ def make_synthpop(sim=None, population=None, layer_mapping=None, community_conta # Handle other input arguments if population is None: - if sim is None: + if sim is None: # pragma: no cover errormsg = 'Either a simulation or a population must be supplied' raise ValueError(errormsg) pop_size = sim['pop_size'] @@ -357,7 +357,7 @@ def make_synthpop(sim=None, population=None, layer_mapping=None, community_conta if community_contacts is None: if sim is not None: community_contacts = sim['contacts']['c'] - else: + else: # pragma: no cover errormsg = 'If a simulation is not supplied, the number of community contacts must be specified' raise ValueError(errormsg) @@ -379,7 +379,7 @@ def make_synthpop(sim=None, population=None, layer_mapping=None, community_conta for spkey in uid_contacts.keys(): try: lkey = layer_mapping[spkey] # Map the SynthPops key into a Covasim layer key - except KeyError: + except KeyError: # pragma: no cover errormsg = f'Could not find key "{spkey}" in layer mapping "{layer_mapping}"' raise sc.KeyNotFoundError(errormsg) int_contacts[lkey] = [] diff --git a/covasim/regression/pars_v1.6.0.json b/covasim/regression/pars_v2.1.1.json similarity index 86% rename from covasim/regression/pars_v1.6.0.json rename to covasim/regression/pars_v2.1.1.json index e113ae5dd..fd6dab297 100644 --- a/covasim/regression/pars_v1.6.0.json +++ b/covasim/regression/pars_v2.1.1.json @@ -1,13 +1,13 @@ { "pop_size": 20000.0, - "pop_infected": 10, + "pop_infected": 20, "pop_type": "random", "location": null, "start_day": "2020-03-01", "end_day": null, "n_days": 60, "rand_seed": 1, - "verbose": 1, + "verbose": 0.1, "pop_scale": 1, "rescale": true, "rescale_threshold": 0.05, @@ -45,12 +45,12 @@ "dur": { "exp2inf": { "dist": "lognormal_int", - "par1": 4.6, - "par2": 4.8 + "par1": 4.5, + "par2": 1.5 }, "inf2sym": { "dist": "lognormal_int", - "par1": 1.0, + "par1": 1.1, "par2": 0.9 }, "sym2sev": { @@ -60,8 +60,8 @@ }, "sev2crit": { "dist": "lognormal_int", - "par1": 3.0, - "par2": 7.4 + "par1": 1.5, + "par2": 2.0 }, "asym2rec": { "dist": "lognormal_int", @@ -75,18 +75,18 @@ }, "sev2rec": { "dist": "lognormal_int", - "par1": 14.0, - "par2": 2.4 + "par1": 18.1, + "par2": 6.3 }, "crit2rec": { "dist": "lognormal_int", - "par1": 14.0, - "par2": 2.4 + "par1": 18.1, + "par2": 6.3 }, "crit2die": { "dist": "lognormal_int", - "par1": 6.2, - "par2": 1.7 + "par1": 10.7, + "par2": 4.8 } }, "rel_symp_prob": 1.0, @@ -181,15 +181,15 @@ ], "death_probs": [ 0.6666666666666667, - 0.75, - 0.8333333333333333, - 0.7692307692307694, - 0.6944444444444444, - 0.6430868167202572, - 0.6045616927727397, - 0.5715566513504426, - 0.5338691159586683, - 0.5338691159586683 + 0.25, + 0.2777777777777778, + 0.30769230769230776, + 0.45370370370370366, + 0.2840300107181136, + 0.2104973893926903, + 0.2733385632634764, + 0.47600459242250287, + 0.9293915040183697 ] }, "interventions": [], diff --git a/covasim/regression/pars_v1.7.0.json b/covasim/regression/pars_v3.0.0.json similarity index 66% rename from covasim/regression/pars_v1.7.0.json rename to covasim/regression/pars_v3.0.0.json index e113ae5dd..b2a65b7ae 100644 --- a/covasim/regression/pars_v1.7.0.json +++ b/covasim/regression/pars_v3.0.0.json @@ -1,18 +1,19 @@ { "pop_size": 20000.0, - "pop_infected": 10, + "pop_infected": 20, "pop_type": "random", "location": null, "start_day": "2020-03-01", "end_day": null, "n_days": 60, "rand_seed": 1, - "verbose": 1, + "verbose": 0.1, "pop_scale": 1, + "scaled_pop": null, "rescale": true, "rescale_threshold": 0.05, "rescale_factor": 1.2, - "beta": 0.016, + "frac_susceptible": 1.0, "contacts": { "a": 20 }, @@ -22,7 +23,6 @@ "beta_layer": { "a": 1.0 }, - "n_imports": 0, "beta_dist": { "dist": "neg_binomial", "par1": 1.0, @@ -34,23 +34,49 @@ "load_ratio": 2, "high_cap": 4 }, - "asymp_factor": 1.0, - "iso_factor": { - "a": 0.2 + "beta": 0.016, + "n_imports": 0, + "n_strains": 1, + "use_waning": false, + "nab_init": { + "dist": "normal", + "par1": 0, + "par2": 2 }, - "quar_factor": { - "a": 0.3 + "nab_decay": { + "form": "nab_decay", + "decay_rate1": 0.007701635339554948, + "decay_time1": 250, + "decay_rate2": 0.001 }, - "quar_period": 14, + "nab_kin": null, + "nab_boost": 1.5, + "nab_eff": { + "sus": { + "slope": 2.7, + "n_50": 0.03 + }, + "symp": 0.1, + "sev": 0.52 + }, + "cross_immunity": 0.5, + "rel_imm": { + "asymp": 0.85, + "mild": 1, + "severe": 1.5 + }, + "immunity": null, + "rel_beta": 1.0, + "asymp_factor": 1.0, "dur": { "exp2inf": { "dist": "lognormal_int", - "par1": 4.6, - "par2": 4.8 + "par1": 4.5, + "par2": 1.5 }, "inf2sym": { "dist": "lognormal_int", - "par1": 1.0, + "par1": 1.1, "par2": 0.9 }, "sym2sev": { @@ -60,8 +86,8 @@ }, "sev2crit": { "dist": "lognormal_int", - "par1": 3.0, - "par2": 7.4 + "par1": 1.5, + "par2": 2.0 }, "asym2rec": { "dist": "lognormal_int", @@ -75,18 +101,18 @@ }, "sev2rec": { "dist": "lognormal_int", - "par1": 14.0, - "par2": 2.4 + "par1": 18.1, + "par2": 6.3 }, "crit2rec": { "dist": "lognormal_int", - "par1": 14.0, - "par2": 2.4 + "par1": 18.1, + "par2": 6.3 }, "crit2die": { "dist": "lognormal_int", - "par1": 6.2, - "par2": 1.7 + "par1": 10.7, + "par2": 4.8 } }, "rel_symp_prob": 1.0, @@ -181,17 +207,24 @@ ], "death_probs": [ 0.6666666666666667, - 0.75, - 0.8333333333333333, - 0.7692307692307694, - 0.6944444444444444, - 0.6430868167202572, - 0.6045616927727397, - 0.5715566513504426, - 0.5338691159586683, - 0.5338691159586683 + 0.25, + 0.2777777777777778, + 0.30769230769230776, + 0.45370370370370366, + 0.2840300107181136, + 0.2104973893926903, + 0.2733385632634764, + 0.47600459242250287, + 0.9293915040183697 ] }, + "iso_factor": { + "a": 0.2 + }, + "quar_factor": { + "a": 0.3 + }, + "quar_period": 14, "interventions": [], "analyzers": [], "timelimit": null, @@ -199,5 +232,25 @@ "n_beds_hosp": null, "n_beds_icu": null, "no_hosp_factor": 2.0, - "no_icu_factor": 2.0 + "no_icu_factor": 2.0, + "vaccine_pars": {}, + "vaccine_map": {}, + "strains": [], + "strain_map": { + "0": "wild" + }, + "strain_pars": { + "wild": { + "rel_imm": { + "asymp": 0.85, + "mild": 1, + "severe": 1.5 + }, + "rel_beta": 1.0, + "rel_symp_prob": 1.0, + "rel_severe_prob": 1.0, + "rel_crit_prob": 1.0, + "rel_death_prob": 1.0 + } + } } \ No newline at end of file diff --git a/covasim/requirements.py b/covasim/requirements.py index a44f9245c..1df1a441a 100644 --- a/covasim/requirements.py +++ b/covasim/requirements.py @@ -16,7 +16,7 @@ def check_sciris(): ''' Check that Sciris is available and the right version ''' try: import sciris as sc - except ModuleNotFoundError: + except ModuleNotFoundError: # pragma: no cover errormsg = 'Sciris is a required dependency but is not found; please install via "pip install sciris"' raise ModuleNotFoundError(errormsg) ver = sc.__version__ @@ -34,10 +34,10 @@ def check_synthpops(verbose=False, die=False): try: import synthpops return synthpops - except ImportError as E: + except ModuleNotFoundError as E: # pragma: no cover import_error = f'Synthpops (for detailed demographic data) is not available ({str(E)})\n' if die: - raise ImportError(import_error) + raise ModuleNotFoundError(import_error) elif verbose: print(import_error) return False diff --git a/covasim/run.py b/covasim/run.py index cd8ecc0b4..fa25fa0f9 100644 --- a/covasim/run.py +++ b/covasim/run.py @@ -172,7 +172,7 @@ def run(self, reduce=False, combine=False, **kwargs): elif combine: self.combine() - return + return self def shrink(self, **kwargs): @@ -240,27 +240,36 @@ def reduce(self, quantiles=None, use_mean=False, bounds=None, output=False): # Perform the statistics raw = {} - reskeys = reduced_sim.result_keys() - for reskey in reskeys: + mainkeys = reduced_sim.result_keys('main') + strainkeys = reduced_sim.result_keys('strain') + for reskey in mainkeys: raw[reskey] = np.zeros((reduced_sim.npts, len(self.sims))) for s,sim in enumerate(self.sims): vals = sim.results[reskey].values - if len(vals) != reduced_sim.npts: - errormsg = f'Cannot reduce sims with inconsistent numbers of days: {reduced_sim.npts} vs. {len(vals)}' - raise ValueError(errormsg) - raw[reskey][:,s] = vals + raw[reskey][:, s] = vals + for reskey in strainkeys: + raw[reskey] = np.zeros((reduced_sim['n_strains'], reduced_sim.npts, len(self.sims))) + for s,sim in enumerate(self.sims): + vals = sim.results['strain'][reskey].values + raw[reskey][:, :, s] = vals - for reskey in reskeys: + for reskey in mainkeys + strainkeys: + if reskey in mainkeys: + axis = 1 + results = reduced_sim.results + else: + axis = 2 + results = reduced_sim.results['strain'] if use_mean: - r_mean = np.mean(raw[reskey], axis=1) - r_std = np.std(raw[reskey], axis=1) - reduced_sim.results[reskey].values[:] = r_mean - reduced_sim.results[reskey].low = r_mean - bounds*r_std - reduced_sim.results[reskey].high = r_mean + bounds*r_std + r_mean = np.mean(raw[reskey], axis=axis) + r_std = np.std(raw[reskey], axis=axis) + results[reskey].values[:] = r_mean + results[reskey].low = r_mean - bounds * r_std + results[reskey].high = r_mean + bounds * r_std else: - reduced_sim.results[reskey].values[:] = np.quantile(raw[reskey], q=0.5, axis=1) - reduced_sim.results[reskey].low = np.quantile(raw[reskey], q=quantiles['low'], axis=1) - reduced_sim.results[reskey].high = np.quantile(raw[reskey], q=quantiles['high'], axis=1) + results[reskey].values[:] = np.quantile(raw[reskey], q=0.5, axis=axis) + results[reskey].low = np.quantile(raw[reskey], q=quantiles['low'], axis=axis) + results[reskey].high = np.quantile(raw[reskey], q=quantiles['high'], axis=axis) # Compute and store final results reduced_sim.compute_summary() @@ -312,12 +321,14 @@ def combine(self, output=False): n_runs = len(self) combined_sim = sc.dcp(self.sims[0]) - combined_sim.parallelized = {'parallelized':True, 'combined':True, 'n_runs':n_runs} # Store how this was parallelized - combined_sim['pop_size'] *= n_runs # Record the number of people + combined_sim.parallelized = dict(parallelized=True, combined=True, n_runs=n_runs) # Store how this was parallelized for s,sim in enumerate(self.sims[1:]): # Skip the first one - if combined_sim.people: + if combined_sim.people: # If the people are there, add them and increment the population size accordingly combined_sim.people += sim.people + combined_sim['pop_size'] = combined_sim.people.pars['pop_size'] + else: # If not, manually update population size + combined_sim['pop_size'] += sim['pop_size'] # Record the number of people for key in sim.result_keys(): vals = sim.results[key].values if len(vals) != combined_sim.npts: @@ -382,8 +393,9 @@ def compare(self, t=None, sim_inds=None, output=False, do_plot=False, **kwargs): if label in resdict: # Avoid duplicates label += f' ({i})' for reskey in sim.result_keys(): - val = sim.results[reskey].values[day] - if reskey not in ['r_eff', 'doubling_time']: + res = sim.results[reskey] + val = res.values[day] + if res.scale: # Results that are scaled by population are ints val = int(val) resdict[label][reskey] = val @@ -413,7 +425,7 @@ def plot(self, to_plot=None, inds=None, plot_sims=False, color_by_sim=None, max_ other options. Args: - to_plot (list) : list or dict of which results to plot; see cv.get_sim_plots() for structure + to_plot (list) : list or dict of which results to plot; see cv.get_default_plots() for structure inds (list) : if not combined or reduced, the indices of the simulations to plot (if None, plot all) plot_sims (bool) : whether to plot individual sims, even if combine() or reduce() has been used color_by_sim (bool) : if True, set colors based on the simulation type; otherwise, color by result type; True implies a scenario-style plotting, False implies sim-style plotting @@ -425,6 +437,9 @@ def plot(self, to_plot=None, inds=None, plot_sims=False, color_by_sim=None, max_ show_args (dict) : passed to sim.plot() kwargs (dict) : passed to sim.plot() + Returns: + fig: Figure handle + **Examples**:: sim = cv.Sim() @@ -463,10 +478,8 @@ def plot(self, to_plot=None, inds=None, plot_sims=False, color_by_sim=None, max_ # Handle what to plot if to_plot is None: - if color_by_sim: - to_plot = cvd.get_scen_plots() - else: - to_plot = cvd.get_sim_plots() + kind = 'scens' if color_by_sim else 'sim' + to_plot = cvd.get_default_plots(kind=kind) # Handle colors if colors is None: @@ -522,7 +535,7 @@ def plot(self, to_plot=None, inds=None, plot_sims=False, color_by_sim=None, max_ def plot_result(self, key, colors=None, labels=None, *args, **kwargs): - ''' Convenience method for plotting -- arguments passed to Sim.plot_result() ''' + ''' Convenience method for plotting -- arguments passed to sim.plot_result() ''' if self.which in ['combined', 'reduced']: fig = self.base_sim.plot_result(key, *args, **kwargs) else: @@ -544,7 +557,7 @@ def plot_result(self, key, colors=None, labels=None, *args, **kwargs): def plot_compare(self, t=-1, sim_inds=None, log_scale=True, **kwargs): ''' Plot a comparison between sims, using bars to show different values for - each result. + each result. For an explanation of other available arguments, see Sim.plot(). Args: t (int) : index of results, passed to compare() @@ -553,7 +566,7 @@ def plot_compare(self, t=-1, sim_inds=None, log_scale=True, **kwargs): kwargs (dict) : standard plotting arguments, see Sim.plot() for explanation Returns: - fig (figure): the figure handle + fig: Figure handle ''' df = self.compare(t=t, sim_inds=sim_inds, output=True) cvplt.plot_compare(df, log_scale=log_scale, **kwargs) @@ -567,7 +580,7 @@ def save(self, filename=None, keep_people=False, **kwargs): Args: filename (str) : the name or path of the file to save to; if None, uses default keep_people (bool) : whether or not to store the population in the Sim objects (NB, very large) - kwargs (dict) : passed to makefilepath() + kwargs (dict) : passed to ``sc.makefilepath()`` Returns: scenfile (str): the validated absolute path to the saved file @@ -780,7 +793,8 @@ def _brief(self): ''' try: labelstr = f'"{self.label}"; ' if self.label else '' - string = f'MultiSim({labelstr}n_sims: {len(self.sims)}; base: {self.base_sim.brief(output=True)})' + n_sims = 0 if not self.sims else len(self.sims) + string = f'MultiSim({labelstr}n_sims: {n_sims}; base: {self.base_sim.brief(output=True)})' except Exception as E: string = sc.objectid(self) string += f'Warning, multisim appears to be malformed:\n{str(E)}' @@ -808,6 +822,16 @@ def brief(self, output=False): return string + def to_json(self, *args, **kwargs): + ''' Shortcut for base_sim.to_json() ''' + return self.base_sim.to_json(*args, **kwargs) + + + def to_excel(self, *args, **kwargs): + ''' Shortcut for base_sim.to_excel() ''' + return self.base_sim.to_excel(*args, **kwargs) + + class Scenarios(cvb.ParsObj): ''' Class for running multiple sets of multiple simulations -- e.g., scenarios. @@ -851,21 +875,25 @@ def __init__(self, sim=None, metapars=None, scenarios=None, basepars=None, scenf self.scenarios = scenarios # Handle metapars - self.metapars = sc.mergedicts({}, metapars) + self.metapars = sc.dcp(sc.mergedicts(metapars)) self.update_pars(self.metapars) # Create the simulation and handle basepars if sim is None: sim = cvs.Sim() - self.base_sim = sim - self.basepars = sc.mergedicts({}, basepars) + self.base_sim = sc.dcp(sim) + self.basepars = sc.dcp(sc.mergedicts(basepars)) self.base_sim.update_pars(self.basepars) self.base_sim.validate_pars() - self.base_sim.init_results() + if not self.base_sim.initialized: + self.base_sim.init_strains() + self.base_sim.init_immunity() + self.base_sim.init_results() # Copy quantities from the base sim to the main object self.npts = self.base_sim.npts self.tvec = self.base_sim.tvec + self['verbose'] = self.base_sim['verbose'] # Create the results object; order is: results key, scenario, best/low/high self.sims = sc.objdict() @@ -879,10 +907,10 @@ def __init__(self, sim=None, metapars=None, scenarios=None, basepars=None, scenf return - def result_keys(self): + def result_keys(self, which='all'): ''' Attempt to retrieve the results keys from the base sim ''' try: - keys = self.base_sim.result_keys() + keys = self.base_sim.result_keys(which=which) except Exception as E: errormsg = f'Could not retrieve result keys since base sim not accessible: {str(E)}' raise ValueError(errormsg) @@ -913,7 +941,8 @@ def print_heading(string): print(string) return - reskeys = self.result_keys() # Shorten since used extensively + mainkeys = self.result_keys('main') + strainkeys = self.result_keys('strain') # Loop over scenarios for scenkey,scen in self.scenarios.items(): @@ -926,11 +955,18 @@ def print_heading(string): raise ValueError(errormsg) # Create and run the simulations - print_heading(f'Multirun for {scenkey}') scen_sim = sc.dcp(self.base_sim) scen_sim.label = scenkey - scen_sim.update_pars(scenpars) + + scen_sim.update_pars(scenpars) # Update the parameters, if provided + scen_sim.validate_pars() + if 'strains' in scenpars: # Process strains + scen_sim.init_strains() + scen_sim.init_immunity(create=True) + elif 'imm_pars' in scenpars: # Process immunity + scen_sim.init_immunity(create=True) # TODO: refactor + run_args = dict(n_runs=self['n_runs'], noise=self['noise'], noisepar=self['noisepar'], keep_people=keep_people, verbose=verbose) if debug: print('Running in debug mode (not parallelized)') @@ -941,23 +977,28 @@ def print_heading(string): # Process the simulations print_heading(f'Processing {scenkey}') - + ns = scen_sims[0]['n_strains'] # Get number of strains scenraw = {} - for reskey in reskeys: + for reskey in mainkeys: scenraw[reskey] = np.zeros((self.npts, len(scen_sims))) for s,sim in enumerate(scen_sims): scenraw[reskey][:,s] = sim.results[reskey].values + for reskey in strainkeys: + scenraw[reskey] = np.zeros((ns, self.npts, len(scen_sims))) + for s,sim in enumerate(scen_sims): + scenraw[reskey][:,:,s] = sim.results['strain'][reskey].values scenres = sc.objdict() scenres.best = {} scenres.low = {} scenres.high = {} - for reskey in reskeys: - scenres.best[reskey] = np.quantile(scenraw[reskey], q=0.5, axis=1) # Changed from median to mean for smoother plots - scenres.low[reskey] = np.quantile(scenraw[reskey], q=self['quantiles']['low'], axis=1) - scenres.high[reskey] = np.quantile(scenraw[reskey], q=self['quantiles']['high'], axis=1) + for reskey in mainkeys + strainkeys: + axis = 1 if reskey in mainkeys else 2 + scenres.best[reskey] = np.quantile(scenraw[reskey], q=0.5, axis=axis) # Changed from median to mean for smoother plots + scenres.low[reskey] = np.quantile(scenraw[reskey], q=self['quantiles']['low'], axis=axis) + scenres.high[reskey] = np.quantile(scenraw[reskey], q=self['quantiles']['high'], axis=axis) - for reskey in reskeys: + for reskey in mainkeys + strainkeys: self.results[reskey][scenkey]['name'] = scenname for blh in ['best', 'low', 'high']: self.results[reskey][scenkey][blh] = scenres[blh][reskey] @@ -971,7 +1012,7 @@ def print_heading(string): # Save details about the run self._kept_people = keep_people - return + return self def compare(self, t=None, output=False): @@ -1000,12 +1041,19 @@ def compare(self, t=None, output=False): # Compute dataframe x = defaultdict(dict) + strainkeys = self.result_keys('strain') for scenkey in self.scenarios.keys(): for reskey in self.result_keys(): - val = self.results[reskey][scenkey].best[day] - if reskey not in ['r_eff', 'doubling_time']: - val = int(val) - x[scenkey][reskey] = val + if reskey in strainkeys: + for strain in range(self.base_sim['n_strains']): + val = self.results[reskey][scenkey].best[strain, day] # Only prints results for infections by first strain + strainkey = reskey + str(strain) # Add strain number to the summary output + x[scenkey][strainkey] = int(val) + else: + val = self.results[reskey][scenkey].best[day] + if reskey not in ['r_eff', 'doubling_time']: + val = int(val) + x[scenkey][reskey] = val df = pd.DataFrame.from_dict(x).astype(object) if not output: @@ -1017,33 +1065,8 @@ def compare(self, t=None, output=False): def plot(self, *args, **kwargs): ''' - Plot the results of a scenario. - - Args: - to_plot (dict): Dict of results to plot; see get_scen_plots() for structure - do_save (bool): Whether or not to save the figure - fig_path (str): Path to save the figure - fig_args (dict): Dictionary of kwargs to be passed to pl.figure() - plot_args (dict): Dictionary of kwargs to be passed to pl.plot() - scatter_args (dict): Dictionary of kwargs to be passed to pl.scatter() - axis_args (dict): Dictionary of kwargs to be passed to pl.subplots_adjust() - fill_args (dict): Dictionary of kwargs to be passed to pl.fill_between() - legend_args (dict): Dictionary of kwargs to be passed to pl.legend(); if show_legend=False, do not show - show_args (dict): Control which "extras" get shown: uncertainty bounds, data, interventions, ticks, and the legend - as_dates (bool): Whether to plot the x-axis as dates or time points - dateformat (str): Date string format, e.g. '%B %d' - interval (int): Interval between tick marks - n_cols (int): Number of columns of subpanels to use for subplot - font_size (int): Size of the font - font_family (str): Font face - grid (bool): Whether or not to plot gridlines - commaticks (bool): Plot y-axis with commas rather than scientific notation - setylim (bool): Reset the y limit to start at 0 - log_scale (bool): Whether or not to plot the y-axis with a log scale; if a list, panels to show as log - do_show (bool): Whether or not to show the figure - colors (dict): Custom color for each scenario, must be a dictionary with one entry per scenario key - sep_figs (bool): Whether to show separate figures for different results instead of subplots - fig (fig): Existing figure to plot into + Plot the results of a scenario. For an explanation of available arguments, + see Sim.plot(). Returns: fig: Figure handle @@ -1100,7 +1123,7 @@ def to_excel(self, filename=None): spreadsheet = sc.Spreadsheet() spreadsheet.freshbytes() with pd.ExcelWriter(spreadsheet.bytes, engine='xlsxwriter') as writer: - for key in self.result_keys(): + for key in self.result_keys('main'): # Multidimensional strain keys can't be exported result_df = pd.DataFrame.from_dict(sc.flattendict(self.results[key], sep='_')) result_df.to_excel(writer, sheet_name=key) spreadsheet.load() @@ -1247,7 +1270,8 @@ def _brief(self): ''' try: labelstr = f'"{self.label}"; ' if self.label else '' - string = f'Scenarios({labelstr}n_scenarios: {len(self.sims)}; base: {self.base_sim.brief(output=True)})' + n_scenarios = 0 if not self.scenarios else len(self.scenarios) + string = f'Scenarios({labelstr}n_scenarios: {n_scenarios}; base: {self.base_sim.brief(output=True)})' except Exception as E: string = sc.objectid(self) string += f'Warning, scenarios appear to be malformed:\n{str(E)}' diff --git a/covasim/settings.py b/covasim/settings.py index 598adc5cd..6160491ea 100644 --- a/covasim/settings.py +++ b/covasim/settings.py @@ -53,8 +53,11 @@ def set_default_options(): optdesc.precision = 'Set arithmetic precision for Numba -- 32-bit by default for efficiency' options.precision = int(os.getenv('COVASIM_PRECISION', 32)) - optdesc.numba_parallel = 'Set Numba multithreading -- about 20% faster, but simulations become nondeterministic' - options.numba_parallel = bool(int(os.getenv('COVASIM_NUMBA_PARALLEL', 0))) + optdesc.numba_parallel = 'Set Numba multithreading -- none, safe, full; full multithreading is ~20% faster, but results become nondeterministic' + options.numba_parallel = str(os.getenv('COVASIM_NUMBA_PARALLEL', 'none')) + + optdesc.numba_cache = 'Set Numba caching -- saves on compilation time, but harder to update' + options.numba_cache = bool(int(os.getenv('COVASIM_NUMBA_CACHE', 1))) return options, optdesc @@ -65,7 +68,7 @@ def set_default_options(): # Specify which keys require a reload matplotlib_keys = ['font_size', 'font_family', 'dpi', 'backend'] -numba_keys = ['precision', 'numba_parallel'] +numba_keys = ['precision', 'numba_parallel', 'numba_cache'] def set_option(key=None, value=None, **kwargs): @@ -90,7 +93,8 @@ def set_option(key=None, value=None, **kwargs): - backend: which Matplotlib backend to use - interactive: convenience method to set show, close, and backend - precision: the arithmetic to use in calculations - - numba_parallel: whether to parallelize Numba + - numba_parallel: whether to parallelize Numba functions + - numba_cache: whether to cache (precompile) Numba functions **Examples**:: @@ -119,7 +123,6 @@ def set_option(key=None, value=None, **kwargs): kwargs['backend'] = orig_options['backend'] else: kwargs['show'] = False - kwargs['close'] = True kwargs['backend'] = 'agg' # Reset options @@ -143,6 +146,11 @@ def set_option(key=None, value=None, **kwargs): return +def get_default(key=None): + ''' Helper function to get the original default options ''' + return orig_options[key] + + def get_help(output=False): ''' Print information about options. @@ -185,9 +193,9 @@ def set_matplotlib_global(key, value): ''' Set a global option for Matplotlib -- not for users ''' import pylab as pl if value: # Don't try to reset any of these to a None value - if key == 'font_size': pl.rc('font', size=value) - elif key == 'font_family': pl.rc('font', family=value) - elif key == 'dpi': pl.rc('figure', dpi=value) + if key == 'font_size': pl.rcParams['font.size'] = value + elif key == 'font_family': pl.rcParams['font.family'] = value + elif key == 'dpi': pl.rcParams['figure.dpi'] = value elif key == 'backend': pl.switch_backend(value) else: raise sc.KeyNotFoundError(f'Key {key} not found') return @@ -207,7 +215,7 @@ def handle_show(do_show): def reload_numba(): ''' Apply changes to Numba functions -- reloading modules is necessary for - changes to propagate. Not necessary if cv.options.set() is used. + changes to propagate. Not necessary to call directly if cv.options.set() is used. **Example**:: @@ -223,9 +231,11 @@ def reload_numba(): importlib.reload(cv.defaults) importlib.reload(cv.utils) importlib.reload(cv) + print("Reload complete. Note: for some options to take effect, you may also need to delete Covasim's __pycache__ folder.") return # Add these here to be more accessible to the user options.set = set_option +options.get_default = get_default options.help = get_help \ No newline at end of file diff --git a/covasim/sim.py b/covasim/sim.py index 0c405cf3f..3b9846faa 100644 --- a/covasim/sim.py +++ b/covasim/sim.py @@ -14,10 +14,11 @@ from . import population as cvpop from . import plotting as cvplt from . import interventions as cvi +from . import immunity as cvimm from . import analysis as cva # Almost everything in this file is contained in the Sim class -__all__ = ['Sim', 'diff_sims', 'AlreadyRunError'] +__all__ = ['Sim', 'diff_sims', 'demo', 'AlreadyRunError'] class Sim(cvb.BaseSim): @@ -25,7 +26,7 @@ class Sim(cvb.BaseSim): The Sim class handles the running of the simulation: the creation of the population and the dynamics of the epidemic. This class handles the mechanics of the actual simulation, while BaseSim takes care of housekeeping (saving, - loading, exporting, etc.). + loading, exporting, etc.). Please see the BaseSim class for additional methods. Args: pars (dict): parameters to modify from their default values @@ -68,7 +69,7 @@ def __init__(self, pars=None, datafile=None, datacols=None, label=None, simfile= self._default_ver = version # Default version of parameters used self._orig_pars = None # Store original parameters to optionally restore at the end of the simulation - # Update the parameters + # Make default parameters (using values from parameters.py) default_pars = cvpar.make_pars(version=version) # Start with default pars super().__init__(default_pars) # Initialize and set the parameters as attributes @@ -88,7 +89,7 @@ def load_data(self, datafile=None, datacols=None, verbose=None, **kwargs): verbose = self['verbose'] self.datafile = datafile # Store this if datafile is not None: # If a data file is provided, load it - self.data = cvm.load_data(datafile=datafile, columns=datacols, verbose=verbose, **kwargs) + self.data = cvm.load_data(datafile=datafile, columns=datacols, verbose=verbose, start_day=self['start_day'], **kwargs) return @@ -107,16 +108,19 @@ def initialize(self, reset=False, **kwargs): self.t = 0 # The current time index self.validate_pars() # Ensure parameters have valid values self.set_seed() # Reset the random seed before the population is created - self.init_results() # Create the results structure + self.init_strains() # Initialize the strains + self.init_immunity() # initialize information about immunity (if use_waning=True) + self.init_results() # After initializing the strain, create the results structure self.init_people(save_pop=self.save_pop, load_pop=self.load_pop, popfile=self.popfile, reset=reset, **kwargs) # Create all the people (slow) + self.init_interventions() # Initialize the interventions... + # self.init_vaccines() # Initialize vaccine information + self.init_analyzers() # ...and the analyzers... self.validate_layer_pars() # Once the population is initialized, validate the layer parameters again - self.init_interventions() # Initialize the interventions - self.init_analyzers() # ...and the interventions self.set_seed() # Reset the random seed again so the random number stream is consistent self.initialized = True self.complete = False self.results_ready = False - return + return self def layer_keys(self): @@ -129,7 +133,7 @@ def layer_keys(self): ''' try: keys = list(self['beta_layer'].keys()) # Get keys from beta_layer since the "most required" layer parameter - except: + except: # pragma: no cover keys = [] return keys @@ -178,9 +182,13 @@ def validate_layer_pars(self): # Handle mismatches with the population if self.people is not None: pop_keys = set(self.people.contacts.keys()) - if pop_keys != set(layer_keys): - errormsg = f'Please update your parameter keys {layer_keys} to match population keys {pop_keys}. You may find sim.reset_layer_pars() helpful.' - raise sc.KeyNotFoundError(errormsg) + if pop_keys != set(layer_keys): # pragma: no cover + if not len(pop_keys): + errormsg = f'Your population does not have any layer keys, but your simulation does {layer_keys}. If you called cv.People() directly, you probably need cv.make_people() instead.' + raise sc.KeyNotFoundError(errormsg) + else: + errormsg = f'Please update your parameter keys {layer_keys} to match population keys {pop_keys}. You may find sim.reset_layer_pars() helpful.' + raise sc.KeyNotFoundError(errormsg) return @@ -193,8 +201,18 @@ def validate_pars(self, validate_layers=True): validate_layers (bool): whether to validate layer parameters as well via validate_layer_pars() -- usually yes, except during initialization ''' + # Handle population size + pop_size = self.pars.get('pop_size') + scaled_pop = self.pars.get('scaled_pop') + pop_scale = self.pars.get('pop_scale') + if scaled_pop is not None: # If scaled_pop is supplied, try to use it + if pop_scale in [None, 1.0]: # Normal case, recalculate population scale + self['pop_scale'] = scaled_pop/pop_size + else: # Special case, recalculate number of agents + self['pop_size'] = int(scaled_pop/pop_scale) + # Handle types - for key in ['pop_size', 'pop_infected', 'pop_size']: + for key in ['pop_size', 'pop_infected']: try: self[key] = int(self[key]) except Exception as E: @@ -229,17 +247,20 @@ def validate_pars(self, validate_layers=True): # Handle population data popdata_choices = ['random', 'hybrid', 'clustered', 'synthpops'] choice = self['pop_type'] - if choice and choice not in popdata_choices: + if choice and choice not in popdata_choices: # pragma: no cover choicestr = ', '.join(popdata_choices) errormsg = f'Population type "{choice}" not available; choices are: {choicestr}' raise ValueError(errormsg) - # Handle interventions and analyzers + # Handle interventions, analyzers, and strains self['interventions'] = sc.promotetolist(self['interventions'], keepnone=False) for i,interv in enumerate(self['interventions']): if isinstance(interv, dict): # It's a dictionary representation of an intervention self['interventions'][i] = cvi.InterventionDict(**interv) self['analyzers'] = sc.promotetolist(self['analyzers'], keepnone=False) + self['strains'] = sc.promotetolist(self['strains'], keepnone=False) + for key in ['interventions', 'analyzers', 'strains']: + self[key] = sc.dcp(self[key]) # All of these have initialize functions that run into issues if they're reused # Optionally handle layer parameters if validate_layers: @@ -248,7 +269,7 @@ def validate_pars(self, validate_layers=True): # Handle verbose if self['verbose'] == 'brief': self['verbose'] = -1 - if not sc.isnumber(self['verbose']): + if not sc.isnumber(self['verbose']): # pragma: no cover errormsg = f'Verbose argument should be either "brief", -1, or a float, not {type(self["verbose"])} "{self["verbose"]}"' raise ValueError(errormsg) @@ -270,11 +291,11 @@ def init_res(*args, **kwargs): output = cvb.Result(*args, **kwargs, npts=self.npts) return output - dcols = cvd.get_colors() # Get default colors + dcols = cvd.get_default_colors() # Get default colors # Flows and cumulative flows for key,label in cvd.result_flows.items(): - self.results[f'cum_{key}'] = init_res(f'Cumulative {label}', color=dcols[key]) # Cumulative variables -- e.g. "Cumulative infections" + self.results[f'cum_{key}'] = init_res(f'Cumulative {label}', color=dcols[key]) # Cumulative variables -- e.g. "Cumulative infections" for key,label in cvd.result_flows.items(): # Repeat to keep all the cumulative keys together self.results[f'new_{key}'] = init_res(f'Number of new {label}', color=dcols[key]) # Flow variables -- e.g. "Number of new infections" @@ -284,13 +305,32 @@ def init_res(*args, **kwargs): self.results[f'n_{key}'] = init_res(label, color=dcols[key]) # Other variables - self.results['n_alive'] = init_res('Number of people alive', scale=False) - self.results['prevalence'] = init_res('Prevalence', scale=False) - self.results['incidence'] = init_res('Incidence', scale=False) - self.results['r_eff'] = init_res('Effective reproduction number', scale=False) - self.results['doubling_time'] = init_res('Doubling time', scale=False) - self.results['test_yield'] = init_res('Testing yield', scale=False) - self.results['rel_test_yield'] = init_res('Relative testing yield', scale=False) + self.results['n_alive'] = init_res('Number alive', scale=True) + self.results['n_naive'] = init_res('Number never infected', scale=True) + self.results['n_preinfectious'] = init_res('Number preinfectious', scale=True, color=dcols.exposed) + self.results['n_removed'] = init_res('Number removed', scale=True, color=dcols.recovered) + self.results['prevalence'] = init_res('Prevalence', scale=False) + self.results['incidence'] = init_res('Incidence', scale=False) + self.results['r_eff'] = init_res('Effective reproduction number', scale=False) + self.results['doubling_time'] = init_res('Doubling time', scale=False) + self.results['test_yield'] = init_res('Testing yield', scale=False) + self.results['rel_test_yield'] = init_res('Relative testing yield', scale=False) + self.results['frac_vaccinated'] = init_res('Proportion vaccinated', scale=False) + self.results['pop_nabs'] = init_res('Population nab levels', scale=False, color=dcols.pop_nabs) + self.results['pop_protection'] = init_res('Population immunity protection', scale=False, color=dcols.pop_protection) + self.results['pop_symp_protection'] = init_res('Population symptomatic protection', scale=False, color=dcols.pop_symp_protection) + + # Handle strains + ns = self['n_strains'] + self.results['strain'] = {} + self.results['strain']['prevalence_by_strain'] = init_res('Prevalence by strain', scale=False, n_strains=ns) + self.results['strain']['incidence_by_strain'] = init_res('Incidence by strain', scale=False, n_strains=ns) + for key,label in cvd.result_flows_by_strain.items(): + self.results['strain'][f'cum_{key}'] = init_res(f'Cumulative {label}', color=dcols[key], n_strains=ns) # Cumulative variables -- e.g. "Cumulative infections" + for key,label in cvd.result_flows_by_strain.items(): + self.results['strain'][f'new_{key}'] = init_res(f'Number of new {label}', color=dcols[key], n_strains=ns) # Flow variables -- e.g. "Number of new infections" + for key,label in cvd.result_stocks_by_strain.items(): + self.results['strain'][f'n_{key}'] = init_res(label, color=dcols[key], n_strains=ns) # Populate the rest of the results if self['rescale']: @@ -339,20 +379,25 @@ def load_population(self, popfile=None, **kwargs): self.popdict = obj n_actual = len(self.popdict['uid']) layer_keys = self.popdict['layer_keys'] + elif isinstance(obj, cvb.BasePeople): + n_actual = len(obj) self.people = obj self.people.set_pars(self.pars) # Replace the saved parameters with this simulation's - n_actual = len(self.people) layer_keys = self.people.layer_keys() - else: + + # Perform validation + n_expected = self['pop_size'] + if n_actual != n_expected: # External consistency check + errormsg = f'Wrong number of people ({n_expected:n} requested, {n_actual:n} actual) -- please change "pop_size" to match or regenerate the file' + raise ValueError(errormsg) + self.people.validate() # Internal consistency check + + else: # pragma: no cover errormsg = f'Cound not interpret input of {type(obj)} as a population file: must be a dict or People object' raise ValueError(errormsg) - # Perform validation - n_expected = self['pop_size'] - if n_actual != n_expected: - errormsg = f'Wrong number of people ({n_expected:n} requested, {n_actual:n} actual) -- please change "pop_size" to match or regenerate the file' - raise ValueError(errormsg) + self.reset_layer_pars(force=False, layer_keys=layer_keys) # Ensure that layer keys match the loaded population self.popfile = None # Once loaded, remove to save memory @@ -376,7 +421,10 @@ def init_people(self, save_pop=False, load_pop=False, popfile=None, reset=False, if verbose is None: verbose = self['verbose'] if verbose>0: - print(f'Initializing sim with {self["pop_size"]:0n} people for {self["n_days"]} days') + resetstr= '' + if self.people: + resetstr = ' (resetting people)' if reset else ' (warning: not resetting sim.people)' + print(f'Initializing sim{resetstr} with {self["pop_size"]:0n} people for {self["n_days"]} days') if load_pop and self.popdict is None: self.load_population(popfile=popfile) @@ -384,9 +432,15 @@ def init_people(self, save_pop=False, load_pop=False, popfile=None, reset=False, self.people = cvpop.make_people(self, save_pop=save_pop, popfile=popfile, reset=reset, verbose=verbose, **kwargs) self.people.initialize() # Fully initialize the people + # Handle anyone who isn't susceptible + if self['frac_susceptible'] < 1: + inds = cvu.choose(self['pop_size'], np.round((1-self['frac_susceptible'])*self['pop_size'])) + self.people.make_nonnaive(inds=inds) + # Create the seed infections inds = cvu.choose(self['pop_size'], self['pop_infected']) self.people.infect(inds=inds, layer='seed_infection') + return @@ -410,7 +464,7 @@ def init_interventions(self): elif isinstance(intervention, (cvi.test_num, cvi.test_prob)): test_ind = np.fmax(test_ind, i) # Find the latest-scheduled testing intervention - if not np.isnan(trace_ind): + if not np.isnan(trace_ind): # pragma: no cover warningmsg = '' if np.isnan(test_ind): warningmsg = 'Note: you have defined a contact tracing intervention but no testing intervention was found. Unless this is intentional, please define at least one testing intervention.' @@ -422,6 +476,12 @@ def init_interventions(self): return + def finalize_interventions(self): + for intervention in self['interventions']: + if isinstance(intervention, cvi.Intervention): + intervention.finalize(self) + + def init_analyzers(self): ''' Initialize the analyzers ''' if self._orig_pars and 'analyzers' in self._orig_pars: @@ -433,26 +493,60 @@ def init_analyzers(self): return + def finalize_analyzers(self): + for analyzer in self['analyzers']: + if isinstance(analyzer, cva.Analyzer): + analyzer.finalize(self) + + + def init_strains(self): + ''' Initialize the strains ''' + if self._orig_pars and 'strains' in self._orig_pars: + self['strains'] = self._orig_pars.pop('strains') # Restore + + for i,strain in enumerate(self['strains']): + if isinstance(strain, cvimm.strain): + if not strain.initialized: + strain.initialize(self) + else: # pragma: no cover + errormsg = f'Strain {i} ({strain}) is not a cv.strain object; please create using cv.strain()' + raise TypeError(errormsg) + + len_pars = len(self['strain_pars']) + len_map = len(self['strain_map']) + assert len_pars == len_map, f"strain_pars and strain_map must be the same length, but they're not: {len_pars} ≠ {len_map}" + self['n_strains'] = len_pars # Each strain has an entry in strain_pars + + return + + + def init_immunity(self, create=False): + ''' Initialize immunity matrices and precompute nab waning for each strain ''' + if self['use_waning']: + cvimm.init_immunity(self, create=create) + return + + def rescale(self): ''' Dynamically rescale the population -- used during step() ''' if self['rescale']: pop_scale = self['pop_scale'] current_scale = self.rescale_vec[self.t] if current_scale < pop_scale: # We have room to rescale - not_sus_inds = self.people.false('susceptible') # Find everyone not susceptible - n_not_sus = len(not_sus_inds) # Number of people who are not susceptible - n_people = len(self.people) # Number of people overall - current_ratio = n_not_sus/n_people # Current proportion not susceptible + not_naive_inds = self.people.false('naive') # Find everyone not naive + n_not_naive = len(not_naive_inds) # Number of people who are not naive + n_people = self['pop_size'] # Number of people overall + current_ratio = n_not_naive/n_people # Current proportion not naive threshold = self['rescale_threshold'] # Threshold to trigger rescaling if current_ratio > threshold: # Check if we've reached point when we want to rescale max_ratio = pop_scale/current_scale # We don't want to exceed the total population size proposed_ratio = max(current_ratio/threshold, self['rescale_factor']) # The proposed ratio to rescale: the rescale factor, unless we've exceeded it scaling_ratio = min(proposed_ratio, max_ratio) # We don't want to scale by more than the maximum ratio self.rescale_vec[self.t:] *= scaling_ratio # Update the rescaling factor from here on - n = int(round(n_not_sus*(1.0-1.0/scaling_ratio))) # For example, rescaling by 2 gives n = 0.5*not_sus_inds - choices = cvu.choose(max_n=n_not_sus, n=n) # Choose who to make susceptible again - new_sus_inds = not_sus_inds[choices] # Convert these back into indices for people - self.people.make_susceptible(new_sus_inds) # Make people susceptible again + n = int(round(n_not_naive*(1.0-1.0/scaling_ratio))) # For example, rescaling by 2 gives n = 0.5*not_naive_inds + choices = cvu.choose(max_n=n_not_naive, n=n) # Choose who to make naive again + new_naive_inds = not_naive_inds[choices] # Convert these back into indices for people + self.people.make_naive(new_naive_inds) # Make people naive again return @@ -477,73 +571,119 @@ def step(self): icu_max = people.count('critical') > self['n_beds_icu'] if self['n_beds_icu'] else False # Check for ICU bed constraint # Randomly infect some people (imported infections) - n_imports = cvu.poisson(self['n_imports']) # Imported cases - if n_imports>0: - importation_inds = cvu.choose(max_n=len(people), n=n_imports) - people.infect(inds=importation_inds, hosp_max=hosp_max, icu_max=icu_max, layer='importation') + if self['n_imports']: + n_imports = cvu.poisson(self['n_imports']/self.rescale_vec[self.t]) # Imported cases + if n_imports>0: + importation_inds = cvu.choose(max_n=self['pop_size'], n=n_imports) + people.infect(inds=importation_inds, hosp_max=hosp_max, icu_max=icu_max, layer='importation') + + # Add strains + for strain in self['strains']: + if isinstance(strain, cvimm.strain): + strain.apply(self) # Apply interventions - for intervention in self['interventions']: + for i,intervention in enumerate(self['interventions']): if isinstance(intervention, cvi.Intervention): + if not intervention.initialized: # pragma: no cover + errormsg = f'Intervention {i} (label={intervention.label}, {type(intervention)}) has not been initialized' + raise RuntimeError(errormsg) intervention.apply(self) # If it's an intervention, call the apply() method elif callable(intervention): intervention(self) # If it's a function, call it directly - else: - errormsg = f'Intervention {intervention} is neither callable nor an Intervention object' - raise ValueError(errormsg) + else: # pragma: no cover + errormsg = f'Intervention {i} ({intervention}) is neither callable nor an Intervention object' + raise TypeError(errormsg) people.update_states_post() # Check for state changes after interventions - # Compute the probability of transmission - beta = cvd.default_float(self['beta']) - asymp_factor = cvd.default_float(self['asymp_factor']) - frac_time = cvd.default_float(self['viral_dist']['frac_time']) - load_ratio = cvd.default_float(self['viral_dist']['load_ratio']) - high_cap = cvd.default_float(self['viral_dist']['high_cap']) - date_inf = people.date_infectious - date_rec = people.date_recovered - date_dead = people.date_dead + # Compute viral loads + frac_time = cvd.default_float(self['viral_dist']['frac_time']) + load_ratio = cvd.default_float(self['viral_dist']['load_ratio']) + high_cap = cvd.default_float(self['viral_dist']['high_cap']) + date_inf = people.date_infectious + date_rec = people.date_recovered + date_dead = people.date_dead viral_load = cvu.compute_viral_load(t, date_inf, date_rec, date_dead, frac_time, load_ratio, high_cap) - for lkey,layer in contacts.items(): - p1 = layer['p1'] - p2 = layer['p2'] - betas = layer['beta'] - - # Compute relative transmission and susceptibility - rel_trans = people.rel_trans - rel_sus = people.rel_sus - inf = people.infectious - sus = people.susceptible - symp = people.symptomatic - diag = people.diagnosed - quar = people.quarantined - iso_factor = cvd.default_float(self['iso_factor'][lkey]) - quar_factor = cvd.default_float(self['quar_factor'][lkey]) - beta_layer = cvd.default_float(self['beta_layer'][lkey]) - rel_trans, rel_sus = cvu.compute_trans_sus(rel_trans, rel_sus, inf, sus, beta_layer, viral_load, symp, diag, quar, asymp_factor, iso_factor, quar_factor) - - # Calculate actual transmission - for sources,targets in [[p1,p2], [p2,p1]]: # Loop over the contact network from p1->p2 and p2->p1 - source_inds, target_inds = cvu.compute_infections(beta, sources, targets, betas, rel_trans, rel_sus) # Calculate transmission! - people.infect(inds=target_inds, hosp_max=hosp_max, icu_max=icu_max, source=source_inds, layer=lkey) # Actually infect people + # Shorten useful parameters + ns = self['n_strains'] # Shorten number of strains + sus = people.susceptible + symp = people.symptomatic + diag = people.diagnosed + quar = people.quarantined + prel_trans = people.rel_trans + prel_sus = people.rel_sus + + # Check nabs. Take set difference so we don't compute nabs for anyone currently infected + if self['use_waning']: + has_nabs = np.setdiff1d(cvu.defined(people.init_nab), cvu.false(people.susceptible)) + if len(has_nabs): cvimm.check_nab(t, people, inds=has_nabs) + + # Iterate through n_strains to calculate infections + for strain in range(ns): + + # Check immunity + if self['use_waning']: + cvimm.check_immunity(people, strain, sus=True) + + # Deal with strain parameters + rel_beta = self['rel_beta'] + asymp_factor = self['asymp_factor'] + if strain: + strain_label = self.pars['strain_map'][strain] + rel_beta *= self['strain_pars'][strain_label]['rel_beta'] + beta = cvd.default_float(self['beta'] * rel_beta) + + for lkey, layer in contacts.items(): + p1 = layer['p1'] + p2 = layer['p2'] + betas = layer['beta'] + + # Compute relative transmission and susceptibility + inf_strain = people.infectious * (people.infectious_strain == strain) # TODO: move out of loop? + sus_imm = people.sus_imm[strain,:] + iso_factor = cvd.default_float(self['iso_factor'][lkey]) + quar_factor = cvd.default_float(self['quar_factor'][lkey]) + beta_layer = cvd.default_float(self['beta_layer'][lkey]) + rel_trans, rel_sus = cvu.compute_trans_sus(prel_trans, prel_sus, inf_strain, sus, beta_layer, viral_load, symp, diag, quar, asymp_factor, iso_factor, quar_factor, sus_imm) + + # Calculate actual transmission + for sources, targets in [[p1, p2], [p2, p1]]: # Loop over the contact network from p1->p2 and p2->p1 + source_inds, target_inds = cvu.compute_infections(beta, sources, targets, betas, rel_trans, rel_sus) # Calculate transmission! + people.infect(inds=target_inds, hosp_max=hosp_max, icu_max=icu_max, source=source_inds, layer=lkey, strain=strain) # Actually infect people # Update counts for this time step: stocks for key in cvd.result_stocks.keys(): self.results[f'n_{key}'][t] = people.count(key) + for key in cvd.result_stocks_by_strain.keys(): + for strain in range(ns): + self.results['strain'][f'n_{key}'][strain, t] = people.count_by_strain(key, strain) # Update counts for this time step: flows for key,count in people.flows.items(): self.results[key][t] += count + for key,count in people.flows_strain.items(): + for strain in range(ns): + self.results['strain'][key][strain][t] += count[strain] + + # Update nab and immunity for this time step + inds_alive = cvu.false(people.dead) + self.results['pop_nabs'][t] = np.sum(people.nab[inds_alive[cvu.defined(people.nab[inds_alive])]])/len(inds_alive) + self.results['pop_protection'][t] = np.nanmean(people.sus_imm) + self.results['pop_symp_protection'][t] = np.nanmean(people.symp_imm) # Apply analyzers -- same syntax as interventions - for analyzer in self['analyzers']: + for i,analyzer in enumerate(self['analyzers']): if isinstance(analyzer, cva.Analyzer): + if not analyzer.initialized: # pragma: no cover + errormsg = f'Analyzer {i} (label={analyzer.label}, {type(analyzer)}) has not been initialized' + raise RuntimeError(errormsg) analyzer.apply(self) # If it's an intervention, call the apply() method elif callable(analyzer): analyzer(self) # If it's a function, call it directly - else: - errormsg = f'Analyzer {analyzer} is neither callable nor an Analyzer object' + else: # pragma: no cover + errormsg = f'Analyzer {i} ({analyzer}) is neither callable nor an Analyzer object' raise ValueError(errormsg) # Tidy up @@ -554,7 +694,7 @@ def step(self): return - def run(self, do_plot=False, until=None, restore_pars=True, reset_seed=True, verbose=None, output=False, **kwargs): + def run(self, do_plot=False, until=None, restore_pars=True, reset_seed=True, verbose=None): ''' Run the simulation. @@ -564,11 +704,9 @@ def run(self, do_plot=False, until=None, restore_pars=True, reset_seed=True, ver restore_pars (bool): whether to make a copy of the parameters before the run and restore it after, so runs are repeatable reset_seed (bool): whether to reset the random number stream immediately before run verbose (float): level of detail to print, e.g. -1 = one-line output, 0 = no output, 0.1 = print every 10th day, 1 = print every day - output (bool): whether to return the results dictionary as output - kwargs (dict): passed to sim.plot() Returns: - results (dict): the results object (also modifies in-place) + A pointer to the sim object (with results modified in-place) ''' # Initialization steps -- start the timer, initialize the sim and the seed, and check that the sim hasn't been run @@ -625,14 +763,7 @@ def run(self, do_plot=False, until=None, restore_pars=True, reset_seed=True, ver if self.complete: self.finalize(verbose=verbose, restore_pars=restore_pars) sc.printv(f'Run finished after {elapsed:0.2f} s.\n', 1, verbose) - if do_plot: # Optionally plot - self.plot(**kwargs) - if output: - return self.results - else: - return - else: - return # If not complete, return nothing + return self def finalize(self, verbose=None, restore_pars=True): @@ -641,17 +772,28 @@ def finalize(self, verbose=None, restore_pars=True): 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 - raise Exception('Simulation has already been finalized') + raise AlreadyRunError('Simulation has already been finalized') # Scale the results for reskey in self.result_keys(): if self.results[reskey].scale: # Scale the result dynamically self.results[reskey].values *= self.rescale_vec + for reskey in self.result_keys('strain'): + if self.results['strain'][reskey].scale: # Scale the result dynamically + self.results['strain'][reskey].values = np.einsum('ij,j->ij', self.results['strain'][reskey].values, self.rescale_vec) # Calculate cumulative results for key in cvd.result_flows.keys(): - self.results[f'cum_{key}'][:] = np.cumsum(self.results[f'new_{key}'][:]) - self.results['cum_infections'].values += self['pop_infected']*self.rescale_vec[0] # Include initially infected people + self.results[f'cum_{key}'][:] = np.cumsum(self.results[f'new_{key}'][:], axis=0) + for key in cvd.result_flows_by_strain.keys(): + for strain in range(self['n_strains']): + self.results['strain'][f'cum_{key}'][strain, :] = np.cumsum(self.results['strain'][f'new_{key}'][strain, :], axis=0) + for res in [self.results['cum_infections'], self.results['strain']['cum_infections_by_strain']]: # Include initially infected people + res.values += self['pop_infected']*self.rescale_vec[0] + + # Finalize interventions and analyzers + self.finalize_interventions() + self.finalize_analyzers() # Final settings self.results_ready = True # Set this first so self.summary() knows to print the results @@ -680,7 +822,7 @@ def finalize(self, verbose=None, restore_pars=True): def compute_results(self, verbose=None): ''' Perform final calculations on the results ''' - self.compute_prev_inci() + self.compute_states() self.compute_yield() self.compute_doubling() self.compute_r_eff() @@ -688,18 +830,28 @@ def compute_results(self, verbose=None): return - def compute_prev_inci(self): + def compute_states(self): ''' - Compute prevalence and incidence. Prevalence is the current number of infected - people divided by the number of people who are alive. Incidence is the number - of new infections per day divided by the susceptible population. Also calculate - the number of people alive, and recalculate susceptibles to handle scaling. + Compute prevalence, incidence, and other states. Prevalence is the current + number of infected people divided by the number of people who are alive. + Incidence is the number of new infections per day divided by the susceptible + population. Also calculates the number of people alive, the number preinfectious, + the number removed, and recalculates susceptibles to handle scaling. ''' res = self.results - self.results['n_alive'][:] = self.scaled_pop_size - res['cum_deaths'][:] # Number of people still alive - self.results['n_susceptible'][:] = res['n_alive'][:] - res['n_exposed'][:] - res['cum_recoveries'][:] # Recalculate the number of susceptible people, not agents - self.results['prevalence'][:] = res['n_exposed'][:]/res['n_alive'][:] # Calculate the prevalence - self.results['incidence'][:] = res['new_infections'][:]/res['n_susceptible'][:] # Calculate the incidence + count_recov = 1-self['use_waning'] # If waning is on, don't count recovered people as removed + self.results['n_alive'][:] = self.scaled_pop_size - res['cum_deaths'][:] # Number of people still alive + self.results['n_naive'][:] = self.scaled_pop_size - res['cum_deaths'][:] - res['n_recovered'][:] - res['n_exposed'][:] # Number of people naive + self.results['n_susceptible'][:] = res['n_alive'][:] - res['n_exposed'][:] - count_recov*res['cum_recoveries'][:] # Recalculate the number of susceptible people, not agents + self.results['n_preinfectious'][:] = res['n_exposed'][:] - res['n_infectious'][:] # Calculate the number not yet infectious: exposed minus infectious + self.results['n_removed'][:] = count_recov*res['cum_recoveries'][:] + res['cum_deaths'][:] # Calculate the number removed: recovered + dead + self.results['prevalence'][:] = res['n_exposed'][:]/res['n_alive'][:] # Calculate the prevalence + self.results['incidence'][:] = res['new_infections'][:]/res['n_susceptible'][:] # Calculate the incidence + self.results['frac_vaccinated'][:] = res['n_vaccinated'][:]/res['n_alive'][:] # Calculate the fraction vaccinated + + self.results['strain']['incidence_by_strain'][:] = np.einsum('ji,i->ji',res['strain']['new_infections_by_strain'][:], 1/res['n_susceptible'][:]) # Calculate the incidence + self.results['strain']['prevalence_by_strain'][:] = np.einsum('ji,i->ji',res['strain']['new_infections_by_strain'][:], 1/res['n_alive'][:]) # Calculate the prevalence + return @@ -753,7 +905,7 @@ def compute_r_eff(self, method='daily', smoothing=2, window=7): Effective reproduction number based on number of people each person infected. Args: - method (str): 'instant' uses daily infections, 'infectious' counts from the date infectious, 'outcome' counts from the date recovered/dead + method (str): 'daily' uses daily infections, 'infectious' counts from the date infectious, 'outcome' counts from the date recovered/dead smoothing (int): the number of steps to smooth over for the 'daily' method window (int): the size of the window used for 'infectious' and 'outcome' calculations (larger values are more accurate but less precise) @@ -782,8 +934,10 @@ def compute_r_eff(self, method='daily', smoothing=2, window=7): # Calculate R_eff as the mean infectious duration times the number of new infectious divided by the number of infectious people on a given day raw_values = mean_inf*self.results['new_infections'].values/(self.results['n_infectious'].values+1e-6) len_raw = len(raw_values) # Calculate the number of raw values + if sc.checktype(self['dur'], list): dur_pars = self['dur'][0] # TODO: fix this, need to somehow take all strains into account + else: dur_pars = self['dur'] if len_raw >= 3: # Can't smooth arrays shorter than this since the default smoothing kernel has length 3 - initial_period = self['dur']['exp2inf']['par1'] + self['dur']['asym2rec']['par1'] # Approximate the duration of the seed infections for averaging + initial_period = dur_pars['exp2inf']['par1'] + dur_pars['asym2rec']['par1'] # Approximate the duration of the seed infections for averaging initial_period = int(min(len_raw, initial_period)) # Ensure we don't have too many points for ind in range(initial_period): # Loop over each of the initial inds raw_values[ind] = raw_values[ind:initial_period].mean() # Replace these values with their average @@ -835,7 +989,7 @@ def compute_r_eff(self, method='daily', smoothing=2, window=7): values = np.divide(num, den, out=np.full(self.npts, np.nan), where=den > 0) # Method not recognized - else: + else: # pragma: no cover errormsg = f'Method must be "daily", "infectious", or "outcome", not "{method}"' raise ValueError(errormsg) @@ -993,32 +1147,54 @@ def brief(self, output=False): return string - def compute_fit(self, output=True, *args, **kwargs): + def compute_fit(self, *args, **kwargs): ''' Compute the fit between the model and the data. See cv.Fit() for more information. Args: - output (bool): whether or not to return the TransTree; if not, store in sim.results args (list): passed to cv.Fit() kwargs (dict): passed to cv.Fit() + Returns: + A Fit object + **Example**:: - sim = cv.Sim(datafile=data.csv) + sim = cv.Sim(datafile='data.csv') sim.run() fit = sim.compute_fit() fit.plot() ''' - fit = cva.Fit(self, *args, **kwargs) - if output: - return fit - else: - self.results.fit = fit - return + self.fit = cva.Fit(self, *args, **kwargs) + return self.fit - def make_age_histogram(self, output=True, *args, **kwargs): + def calibrate(self, calib_pars, **kwargs): + ''' + Automatically calibrate the simulation, returning a Calibration object + (a type of analyzer). See the documentation on that class for more information. + + Args: + calib_pars (dict): a dictionary of the parameters to calibrate of the format dict(key1=[best, low, high]) + kwargs (dict): passed to cv.Calibration() + + Returns: + A Calibration object + + **Example**:: + + sim = cv.Sim(datafile='data.csv') + calib_pars = dict(beta=[0.015, 0.010, 0.020]) + calib = sim.calibrate(calib_pars, n_trials=50) + calib.plot() + ''' + calib = cva.Calibration(sim=self, calib_pars=calib_pars, **kwargs) + calib.calibrate() + return calib + + + def make_age_histogram(self, *args, output=True, **kwargs): ''' Calculate the age histograms of infections, deaths, diagnoses, etc. See cv.age_histogram() for more information. This can be used alternatively @@ -1041,12 +1217,12 @@ def make_age_histogram(self, output=True, *args, **kwargs): agehist = cva.age_histogram(sim=self, *args, **kwargs) if output: return agehist - else: + else: # pragma: no cover self.results.agehist = agehist return - def make_transtree(self, output=True, *args, **kwargs): + def make_transtree(self, *args, output=True, **kwargs): ''' Create a TransTree (transmission tree) object, for analyzing the pattern of transmissions in the simulation. See cv.TransTree() for more information. @@ -1065,7 +1241,7 @@ def make_transtree(self, output=True, *args, **kwargs): tt = cva.TransTree(self, *args, **kwargs) if output: return tt - else: + else: # pragma: no cover self.results.transtree = tt return @@ -1075,7 +1251,7 @@ def plot(self, *args, **kwargs): Plot the results of a single simulation. Args: - to_plot (dict): Dict of results to plot; see get_sim_plots() for structure + to_plot (dict): Dict of results to plot; see get_default_plots() for structure do_save (bool): Whether or not to save the figure fig_path (str): Path to save the figure fig_args (dict): Dictionary of kwargs to be passed to pl.figure() @@ -1083,7 +1259,9 @@ def plot(self, *args, **kwargs): scatter_args (dict): Dictionary of kwargs to be passed to pl.scatter() axis_args (dict): Dictionary of kwargs to be passed to pl.subplots_adjust() legend_args (dict): Dictionary of kwargs to be passed to pl.legend(); if show_legend=False, do not show + date_args (dict): Control how the x-axis (dates) are shown (see below for explanation) show_args (dict): Control which "extras" get shown: uncertainty bounds, data, interventions, ticks, and the legend + mpl_args (dict): Dictionary of kwargs to be passed to Matplotlib; options are dpi, fontsize, and fontfamily as_dates (bool): Whether to plot the x-axis as dates or time points dateformat (str): Date string format, e.g. '%B %d' interval (int): Interval between tick marks @@ -1099,6 +1277,17 @@ def plot(self, *args, **kwargs): sep_figs (bool): Whether to show separate figures for different results instead of subplots fig (fig): Handle of existing figure to plot into ax (axes): Axes instance to plot into + kwargs (dict): Parsed among figure, plot, scatter, date, and other settings (will raise an error if not recognized) + + The optional dictionary "date_args" allows several settings for controlling + how the x-axis of plots are shown, if this axis is dates. These options are: + + - ``as_dates``: whether to format them as dates (else, format them as days since the start) + - ``dateformat``: string format for the date (default %b-%d, e.g. Apr-04) + - ``interval``: the number of days between tick marks + - ``rotation``: whether to rotate labels + - ``start_day``: the first day to plot + - ``end_day``: the last day to plot Returns: fig: Figure handle @@ -1108,6 +1297,8 @@ def plot(self, *args, **kwargs): sim = cv.Sim() sim.run() sim.plot() + + New in version 2.1.0: argument passing, date_args, and mpl_args ''' fig = cvplt.plot_sim(sim=self, *args, **kwargs) return fig @@ -1121,15 +1312,19 @@ def plot_result(self, key, *args, **kwargs): Args: key (str): the key of the result to plot - **Examples**:: + Returns: + fig: Figure handle + + **Example**:: + sim = cv.Sim().run() sim.plot_result('r_eff') ''' fig = cvplt.plot_result(sim=self, key=key, *args, **kwargs) return fig -def diff_sims(sim1, sim2, output=False, die=False): +def diff_sims(sim1, sim2, skip_key_diffs=False, output=False, die=False): ''' Compute the difference of the summaries of two simulations, and print any values which differ. @@ -1137,6 +1332,7 @@ def diff_sims(sim1, sim2, output=False, die=False): Args: sim1 (sim/dict): either a simulation object or the sim.summary dictionary sim2 (sim/dict): ditto + skip_key_diffs (bool): whether to skip keys that don't match between sims output (bool): whether to return the output as a string (otherwise print) die (bool): whether to raise an exception if the sims don't match require_run (bool): require that the simulations have been run @@ -1155,23 +1351,25 @@ def diff_sims(sim1, sim2, output=False, die=False): if isinstance(sim2, Sim): sim2 = sim2.compute_summary(update=False, output=True, require_run=True) for sim in [sim1, sim2]: - if not isinstance(sim, dict): + 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) # Compare keys - mismatchmsg = '' + keymatchmsg = '' sim1_keys = set(sim1.keys()) sim2_keys = set(sim2.keys()) - if sim1_keys != sim2_keys: - mismatchmsg = "Keys don't match!\n" + if sim1_keys != sim2_keys and not skip_key_diffs: # pragma: no cover + keymatchmsg = "Keys don't match!\n" missing = list(sim1_keys - sim2_keys) extra = list(sim2_keys - sim1_keys) if missing: - mismatchmsg += f' Missing sim1 keys: {missing}\n' + keymatchmsg += f' Missing sim1 keys: {missing}\n' if extra: - mismatchmsg += f' Extra sim2 keys: {extra}\n' + keymatchmsg += f' Extra sim2 keys: {extra}\n' + # Compare values + valmatchmsg = '' mismatches = {} for key in sim2.keys(): # To ensure order if key in sim1_keys: # If a key is missing, don't count it as a mismatch @@ -1182,7 +1380,7 @@ def diff_sims(sim1, sim2, output=False, die=False): mismatches[key] = {'sim1': sim1_val, 'sim2': sim2_val} if len(mismatches): - mismatchmsg = '\nThe following values differ between the two simulations:\n' + valmatchmsg = '\nThe following values differ between the two simulations:\n' df = pd.DataFrame.from_dict(mismatches).transpose() diff = [] ratio = [] @@ -1218,7 +1416,7 @@ def diff_sims(sim1, sim2, output=False, die=False): repeats = 4 this_change = change_char*repeats - else: + else: # pragma: no cover this_diff = np.nan this_ratio = np.nan this_change = 'N/A' @@ -1232,10 +1430,11 @@ def diff_sims(sim1, sim2, output=False, die=False): for col in ['sim1', 'sim2', 'diff', 'ratio']: df[col] = df[col].round(decimals=3) df['change'] = change - mismatchmsg += str(df) + valmatchmsg += str(df) # Raise an error if mismatches were found - if mismatchmsg: + mismatchmsg = keymatchmsg + valmatchmsg + if mismatchmsg: # pragma: no cover if die: raise ValueError(mismatchmsg) elif output: @@ -1248,6 +1447,76 @@ def diff_sims(sim1, sim2, output=False, die=False): return +def demo(preset=None, to_plot=None, scens=None, run_args=None, plot_args=None, **kwargs): + ''' + Shortcut for ``cv.Sim().run().plot()``. + + Args: + preset (str): use a preset run configuration; currently the only option is "full" + to_plot (str): what to plot + scens (dict): dictionary of scenarios to run as a multisim, if preset='full' + kwargs (dict): passed to Sim() + run_args (dict): passed to sim.run() + plot_args (dict): passed to sim.plot() + + **Examples**:: + + cv.demo() # Simplest example + cv.demo('full') # Full example + cv.demo('full', overview=True) # Plot all results + cv.demo(beta=0.020, run_args={'verbose':0}, plot_args={'to_plot':'overview'}) # Pass in custom values + ''' + from . import interventions as cvi + from . import run as cvr + + run_args = sc.mergedicts(run_args) + plot_args = sc.mergedicts(plot_args) + if to_plot: + plot_args = sc.mergedicts(plot_args, {'to_plot':to_plot}) + + if not preset: + sim = Sim(**kwargs) + sim.run(**run_args) + sim.plot(**plot_args) + return sim + + elif preset == 'full': + + # Define interventions + cb = cvi.change_beta(days=40, changes=0.5) + tp = cvi.test_prob(start_day=20, symp_prob=0.1, asymp_prob=0.01) + ct = cvi.contact_tracing(trace_probs=0.3, start_day=50) + + # Define the parameters + pars = dict( + pop_size = 20e3, # Population size + pop_infected = 100, # Number of initial infections -- use more for increased robustness + pop_type = 'hybrid', # Population to use -- "hybrid" is random with household, school,and work structure + n_days = 60, # Number of days to simulate + verbose = 0, # Don't print details of the run + rand_seed = 2, # Set a non-default seed + interventions = [cb, tp, ct], # Include the most common interventions + ) + pars = sc.mergedicts(pars, kwargs) + if scens is None: + scens = ('beta', {'Low beta':0.012, 'Medium beta':0.016, 'High beta':0.020}) + scenpar = scens[0] + scenval = scens[1] + + # Run the simulations + sims = [Sim(pars, **{scenpar:val}, label=label) for label,val in scenval.items()] + msim = cvr.MultiSim(sims) + msim.run(**run_args) + msim.plot(**plot_args) + msim.median() + msim.plot(**plot_args) + return msim + + else: + errormsg = f'Could not understand preset argument "{preset}"; must be None or "full"' + raise NotImplementedError(errormsg) + + class AlreadyRunError(RuntimeError): ''' This error is raised if a simulation is run in such a way that no timesteps diff --git a/covasim/utils.py b/covasim/utils.py index 364e15442..1b8822f80 100644 --- a/covasim/utils.py +++ b/covasim/utils.py @@ -1,5 +1,8 @@ ''' -Numerical utilities for running Covasim +Numerical utilities for running Covasim. + +These include the viral load, transmissibility, and infection calculations +at the heart of the integration loop. ''' #%% Housekeeping @@ -20,13 +23,20 @@ nbint = cvd.nbint nbfloat = cvd.nbfloat -# Specify whether to allow parallel Numba calculation -- about 20% faster, but the random number stream becomes nondeterministic -parallel = cvo.numba_parallel +# Specify whether to allow parallel Numba calculation -- 10% faster for safe and 20% faster for random, but the random number stream becomes nondeterministic for the latter +safe_opts = [1, '1', 'safe'] +full_opts = [2, '2', 'full'] +safe_parallel = cvo.numba_parallel in safe_opts + full_opts +rand_parallel = cvo.numba_parallel in full_opts +if cvo.numba_parallel not in [0, 1, 2, '0', '1', '2', 'none', 'safe', 'full']: + errormsg = f'Numba parallel must be "none", "safe", or "full", not "{cvo.numba_parallel}"' + raise ValueError(errormsg) +cache = cvo.numba_cache # Turning this off can help switching parallelization options #%% The core Covasim functions -- compute the infections -@nb.njit( (nbint, nbfloat[:], nbfloat[:], nbfloat[:], nbfloat, nbfloat, nbfloat), cache=True, parallel=parallel) +@nb.njit( (nbint, nbfloat[:], nbfloat[:], nbfloat[:], nbfloat, nbfloat, nbfloat), cache=cache, parallel=safe_parallel) def compute_viral_load(t, time_start, time_recovered, time_dead, frac_time, load_ratio, high_cap): # pragma: no cover ''' Calculate relative transmissibility for time t. Includes time varying @@ -69,32 +79,41 @@ def compute_viral_load(t, time_start, time_recovered, time_dead, frac_time, return load -@nb.njit( (nbfloat[:], nbfloat[:], nbbool[:], nbbool[:], nbfloat, nbfloat[:], nbbool[:], nbbool[:], nbbool[:], nbfloat, nbfloat, nbfloat), cache=True, parallel=parallel) -def compute_trans_sus(rel_trans, rel_sus, inf, sus, beta_layer, viral_load, symp, diag, quar, asymp_factor, iso_factor, quar_factor): # pragma: no cover +@nb.njit( (nbfloat[:], nbfloat[:], nbbool[:], nbbool[:], nbfloat, nbfloat[:], nbbool[:], nbbool[:], nbbool[:], nbfloat, nbfloat, nbfloat, nbfloat[:]), cache=cache, parallel=safe_parallel) +def compute_trans_sus(rel_trans, rel_sus, inf, sus, beta_layer, viral_load, symp, diag, quar, asymp_factor, iso_factor, quar_factor, immunity_factors): # pragma: no cover ''' Calculate relative transmissibility and susceptibility ''' f_asymp = symp + ~symp * asymp_factor # Asymptomatic factor, changes e.g. [0,1] with a factor of 0.8 to [0.8,1.0] f_iso = ~diag + diag * iso_factor # Isolation factor, changes e.g. [0,1] with a factor of 0.2 to [1,0.2] f_quar = ~quar + quar * quar_factor # Quarantine, changes e.g. [0,1] with a factor of 0.5 to [1,0.5] rel_trans = rel_trans * inf * f_quar * f_asymp * f_iso * beta_layer * viral_load # Recalculate transmissibility - rel_sus = rel_sus * sus * f_quar # Recalculate susceptibility + rel_sus = rel_sus * sus * f_quar * (1-immunity_factors) # Recalculate susceptibility return rel_trans, rel_sus -@nb.njit( (nbfloat, nbint[:], nbint[:], nbfloat[:], nbfloat[:], nbfloat[:]), cache=True, parallel=parallel) -def compute_infections(beta, sources, targets, layer_betas, rel_trans, rel_sus): - ''' The heaviest step of the model -- figure out who gets infected on this timestep ''' - betas = beta * layer_betas * rel_trans[sources] * rel_sus[targets] # Calculate the raw transmission probabilities - nonzero_inds = betas.nonzero()[0] # Find nonzero entries - nonzero_betas = betas[nonzero_inds] # Remove zero entries from beta - nonzero_sources = sources[nonzero_inds] # Remove zero entries from the sources - nonzero_targets = targets[nonzero_inds] # Remove zero entries from the targets - transmissions = (np.random.random(len(nonzero_betas)) < nonzero_betas).nonzero()[0] # Compute the actual infections! - source_inds = nonzero_sources[transmissions] - target_inds = nonzero_targets[transmissions] # Filter the targets on the actual infections +@nb.njit( (nbfloat, nbint[:], nbint[:], nbfloat[:], nbfloat[:], nbfloat[:]), cache=cache, parallel=rand_parallel) +def compute_infections(beta, sources, targets, layer_betas, rel_trans, rel_sus): # pragma: no cover + ''' + Compute who infects whom + + The heaviest step of the model, taking about 50% of the total time -- figure + out who gets infected on this timestep. Cannot be easily parallelized since + random numbers are used. + ''' + source_trans = rel_trans[sources] # Pull out the transmissibility of the sources (0 for non-infectious people) + inf_inds = source_trans.nonzero()[0] # Infectious indices -- remove noninfectious people + betas = beta * layer_betas[inf_inds] * source_trans[inf_inds] * rel_sus[targets[inf_inds]] # Calculate the raw transmission probabilities + nonzero_inds = betas.nonzero()[0] # Find nonzero entries + nonzero_inf_inds = inf_inds[nonzero_inds] # Map onto original indices + nonzero_betas = betas[nonzero_inds] # Remove zero entries from beta + nonzero_sources = sources[nonzero_inf_inds] # Remove zero entries from the sources + nonzero_targets = targets[nonzero_inf_inds] # Remove zero entries from the targets + transmissions = (np.random.random(len(nonzero_betas)) < nonzero_betas).nonzero()[0] # Compute the actual infections! + source_inds = nonzero_sources[transmissions] + target_inds = nonzero_targets[transmissions] # Filter the targets on the actual infections return source_inds, target_inds -@nb.njit((nbint[:], nbint[:], nb.int64[:]), cache=True) +@nb.njit((nbint[:], nbint[:], nb.int64[:]), cache=cache) def find_contacts(p1, p2, inds): # pragma: no cover """ Numba for Layer.find_contacts() @@ -113,6 +132,7 @@ def find_contacts(p1, p2, inds): # pragma: no cover return pairing_partners + #%% Sampling and seed methods __all__ += ['sample', 'get_pdf', 'set_seed'] @@ -161,33 +181,38 @@ def sample(dist=None, par1=None, par2=None, size=None, **kwargs): the mean. ''' + # Some of these have aliases, but these are the "official" names choices = [ 'uniform', 'normal', - 'lognormal', 'normal_pos', 'normal_int', + 'lognormal', 'lognormal_int', 'poisson', 'neg_binomial', - ] + ] + + # Ensure it's an integer + if size is not None: + size = int(size) # Compute distribution parameters and draw samples # NB, if adding a new distribution, also add to choices above - if dist == 'uniform': samples = np.random.uniform(low=par1, high=par2, size=size, **kwargs) - elif dist == 'normal': samples = np.random.normal(loc=par1, scale=par2, size=size, **kwargs) - elif dist == 'normal_pos': samples = np.abs(np.random.normal(loc=par1, scale=par2, size=size, **kwargs)) - elif dist == 'normal_int': samples = np.round(np.abs(np.random.normal(loc=par1, scale=par2, size=size, **kwargs))) - elif dist == 'poisson': samples = n_poisson(rate=par1, n=size, **kwargs) # Use Numba version below for speed - elif dist == 'neg_binomial': samples = n_neg_binomial(rate=par1, dispersion=par2, n=size, **kwargs) # Use custom version below - elif dist in ['lognormal', 'lognormal_int']: + if dist in ['unif', 'uniform']: samples = np.random.uniform(low=par1, high=par2, size=size, **kwargs) + elif dist in ['norm', 'normal']: samples = np.random.normal(loc=par1, scale=par2, size=size, **kwargs) + elif dist == 'normal_pos': samples = np.abs(np.random.normal(loc=par1, scale=par2, size=size, **kwargs)) + elif dist == 'normal_int': samples = np.round(np.abs(np.random.normal(loc=par1, scale=par2, size=size, **kwargs))) + elif dist == 'poisson': samples = n_poisson(rate=par1, n=size, **kwargs) # Use Numba version below for speed + elif dist == 'neg_binomial': samples = n_neg_binomial(rate=par1, dispersion=par2, n=size, **kwargs) # Use custom version below + elif dist in ['lognorm', 'lognormal', 'lognorm_int', 'lognormal_int']: if par1>0: - mean = np.log(par1**2 / np.sqrt(par2 + par1**2)) # Computes the mean of the underlying normal distribution - sigma = np.sqrt(np.log(par2/par1**2 + 1)) # Computes sigma for the underlying normal distribution + mean = np.log(par1**2 / np.sqrt(par2**2 + par1**2)) # Computes the mean of the underlying normal distribution + sigma = np.sqrt(np.log(par2**2/par1**2 + 1)) # Computes sigma for the underlying normal distribution samples = np.random.lognormal(mean=mean, sigma=sigma, size=size, **kwargs) else: samples = np.zeros(size) - if dist == 'lognormal_int': + if '_int' in dist: samples = np.round(samples) else: choicestr = '\n'.join(choices) @@ -197,6 +222,7 @@ def sample(dist=None, par1=None, par2=None, size=None, **kwargs): return samples + def get_pdf(dist=None, par1=None, par2=None): ''' Return a probability density function for the specified distribution. This @@ -237,7 +263,7 @@ def set_seed(seed=None): seed (int): the random seed ''' - @nb.njit((nbint,), cache=True) + @nb.njit((nbint,), cache=cache) def set_seed_numba(seed): return np.random.seed(seed) @@ -334,7 +360,7 @@ def n_multinomial(probs, n): # No speed gain from Numba return np.searchsorted(np.cumsum(probs), np.random.random(n)) -@nb.njit((nbfloat,), cache=True) # This hugely increases performance +@nb.njit((nbfloat,), cache=cache, parallel=rand_parallel) # Numba hugely increases performance def poisson(rate): ''' A Poisson trial. @@ -349,7 +375,7 @@ def poisson(rate): return np.random.poisson(rate, 1)[0] -@nb.njit((nbfloat, nbint), cache=True) # Numba hugely increases performance +@nb.njit((nbfloat, nbint), cache=cache, parallel=rand_parallel) # Numba hugely increases performance def n_poisson(rate, n): ''' An array of Poisson trials. @@ -386,7 +412,7 @@ def n_neg_binomial(rate, dispersion, n, step=1): # Numba not used due to incompa return samples -@nb.njit((nbint, nbint), cache=True) # This hugely increases performance +@nb.njit((nbint, nbint), cache=cache) # Numba hugely increases performance def choose(max_n, n): ''' Choose a subset of items (e.g., people) without replacement. @@ -402,7 +428,7 @@ def choose(max_n, n): return np.random.choice(max_n, n, replace=False) -@nb.njit((nbint, nbint), cache=True) # This hugely increases performance +@nb.njit((nbint, nbint), cache=cache) # Numba hugely increases performance def choose_r(max_n, n): ''' Choose a subset of items (e.g., people), with replacement. @@ -418,7 +444,7 @@ def choose_r(max_n, n): return np.random.choice(max_n, n, replace=True) -def choose_w(probs, n, unique=True): +def choose_w(probs, n, unique=True): # No performance gain from Numba ''' Choose n items (e.g. people), each with a probability from the distribution probs. @@ -445,9 +471,9 @@ def choose_w(probs, n, unique=True): #%% Simple array operations -__all__ += ['true', 'false', 'defined', 'undefined', - 'itrue', 'ifalse', 'idefined', - 'itruei', 'ifalsei', 'idefinedi'] +__all__ += ['true', 'false', 'defined', 'undefined', + 'itrue', 'ifalse', 'idefined', 'iundefined', + 'itruei', 'ifalsei', 'idefinedi', 'iundefinedi'] def true(arr): @@ -460,7 +486,7 @@ def true(arr): **Example**:: - inds = cv.true(np.array([1,0,0,1,1,0,1])) + inds = cv.true(np.array([1,0,0,1,1,0,1])) # Returns array([0, 3, 4, 6]) ''' return arr.nonzero()[0] @@ -476,7 +502,7 @@ def false(arr): inds = cv.false(np.array([1,0,0,1,1,0,1])) ''' - return (~arr).nonzero()[0] + return np.logical_not(arr).nonzero()[0] def defined(arr): @@ -534,12 +560,12 @@ def ifalse(arr, inds): inds = cv.ifalse(np.array([True,False,True,True]), inds=np.array([5,22,47,93])) ''' - return inds[~arr] + return inds[np.logical_not(arr)] def idefined(arr, inds): ''' - Returns the indices that are true in the array -- name is short for indices[defined] + Returns the indices that are defined in the array -- name is short for indices[defined] Args: arr (array): any array, used as a filter @@ -552,6 +578,22 @@ def idefined(arr, inds): return inds[~np.isnan(arr)] +def iundefined(arr, inds): + ''' + Returns the indices that are undefined in the array -- name is short for indices[undefined] + + Args: + arr (array): any array, used as a filter + inds (array): any other array (usually, an array of indices) of the same size + + **Example**:: + + inds = cv.iundefined(np.array([3,np.nan,np.nan,4]), inds=np.array([5,22,47,93])) + ''' + return inds[np.isnan(arr)] + + + def itruei(arr, inds): ''' Returns the indices that are true in the array -- name is short for indices[true[indices]] @@ -579,7 +621,7 @@ def ifalsei(arr, inds): inds = cv.ifalsei(np.array([True,False,True,True,False,False,True,False]), inds=np.array([0,1,3,5])) ''' - return inds[~arr[inds]] + return inds[np.logical_not(arr[inds])] def idefinedi(arr, inds): @@ -595,3 +637,18 @@ def idefinedi(arr, inds): inds = cv.idefinedi(np.array([4,np.nan,0,np.nan,np.nan,4,7,4,np.nan]), inds=np.array([0,1,3,5])) ''' return inds[~np.isnan(arr[inds])] + + +def iundefinedi(arr, inds): + ''' + Returns the indices that are undefined in the array -- name is short for indices[defined[indices]] + + Args: + arr (array): any array, used as a filter + inds (array): an array of indices for the original array + + **Example**:: + + inds = cv.iundefinedi(np.array([4,np.nan,0,np.nan,np.nan,4,7,4,np.nan]), inds=np.array([0,1,3,5])) + ''' + return inds[np.isnan(arr[inds])] \ No newline at end of file diff --git a/covasim/version.py b/covasim/version.py index d1febf74f..e8e828a8c 100644 --- a/covasim/version.py +++ b/covasim/version.py @@ -4,6 +4,6 @@ __all__ = ['__version__', '__versiondate__', '__license__'] -__version__ = '2.0.2' -__versiondate__ = '2020-02-01' +__version__ = '3.0.3' +__versiondate__ = '2021-05-17' __license__ = f'Covasim {__version__} ({__versiondate__}) — © 2021 by IDM' diff --git a/docs/Makefile b/docs/Makefile index 5959a2161..683b5a7ef 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -10,8 +10,8 @@ BUILDDIR = _build # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter -ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . -# the i18n builder cannot share the environment and doctrees with the others +ALLSPHINXOPTS = -v -jauto -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . +DEBUGSPHINXOPTS = -vv -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . .PHONY: help @@ -65,6 +65,12 @@ html: @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." +.PHONY: debug +debug: + $(SPHINXBUILD) -b html $(DEBUGSPHINXOPTS) $(BUILDDIR)/html + @echo + @echo "Debug build finished. The HTML pages are in $(BUILDDIR)/html." + .PHONY: dirhtml dirhtml: $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml diff --git a/docs/_static/theme_overrides.css b/docs/_static/theme_overrides.css index 4f237be19..ab3281e8b 100644 --- a/docs/_static/theme_overrides.css +++ b/docs/_static/theme_overrides.css @@ -17,6 +17,13 @@ h1, h2, .rst-content .toctree-wrapper p.caption, h3, h4, h5, h6, legend { margin-bottom: 0.5em; } +/* CK: added toc-backref since otherwise overrides this */ +.toc-backref, .rst-content { + font-size: inherit !important; + color: inherit !important; + margin-bottom: inherit !important; +} + h1 { margin-bottom: 1.0em; } @@ -27,6 +34,7 @@ h2 { margin-bottom: 1.0em; } + h3 { font-size: 115%; color: #38761d; @@ -45,9 +53,19 @@ div.document span.search-highlight { margin-bottom: 10px; } +/* CK: alternating table row colors */ .rst-content table.docutils:not(.field-list) tr:nth-child(2n-1) td, .wy-table-backed, .wy-table-odd td, .wy-table-striped tr:nth-child(2n-1) td { background-color: #acf; } tr.row-even { background-color: #def; } + +/* CK: Change the color of code blocks */ +.highlight { background: #D9F0FF; } + +/* CK: Change the color of inline code */ +code.literal { + color: #e01e5a !important; + background-color: #f6f6f6 !important; +} \ No newline at end of file diff --git a/docs/build_docs b/docs/build_docs index d3e69e767..1586dc13a 100755 --- a/docs/build_docs +++ b/docs/build_docs @@ -1,22 +1,42 @@ #!/bin/bash # -# To rebuild notebooks, type -# ./build_docs auto +# To run in debug mode (serial), type +# ./build_docs debug # -# Otherwise, Jupyter notebooks will not be rebuilt. +# To not rebuild notebooks, type +# ./build_docs never +# +# Otherwise, Jupyter notebooks will be rebuilt in parallel. -start=$SECONDS -export NBSPHINX_EXECUTE=$1 echo 'Building docs...' -make clean -make html -duration=$(( SECONDS - start )) +start=$SECONDS +make clean # Delete + + +# Handle notebook build options +if [[ "$*" == *"never"* ]]; then + export NBSPHINX_EXECUTE=never +else + export NBSPHINX_EXECUTE=auto +fi + + +# Handle notebook build options +if [[ "$*" == *"debug"* ]]; then + make debug # Actually make +else + make html # Actually make +fi + echo 'Cleaning up tutorial files...' cd tutorials ./clean_outputs +cd .. + +duration=$(( SECONDS - start )) echo "Docs built after $duration seconds." echo "Index:" echo "`pwd`/_build/html/index.html" \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py index 47701c0f2..724a2af3f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -27,7 +27,7 @@ # Rename "covasim package" to "API reference" filename = 'modules.rst' # This must match the Makefile -with open(filename) as f: # Read exitsting file +with open(filename) as f: # Read existing file lines = f.readlines() lines[0] = "API reference\n" # Blast away the existing heading and replace with this lines[1] = "=============\n" # Ensure the heading is the right length @@ -50,12 +50,9 @@ 'sphinx.ext.napoleon', 'sphinx.ext.todo', 'sphinx.ext.viewcode', # Add a link to the Python source code for classes, functions etc. - 'plantweb.directive', 'nbsphinx', ] -plantuml = 'plantweb' - autodoc_default_options = { 'member-order': 'bysource', 'members': None @@ -234,7 +231,7 @@ # Configure nbsphinx nbsphinx_kernel_name = "python" -nbsphinx_timeout = 60 # Time in seconds; use -1 for no timeout +nbsphinx_timeout = 90 # Time in seconds; use -1 for no timeout nbsphinx_execute_arguments = [ "--InlineBackend.figure_formats={'svg', 'pdf'}", "--InlineBackend.rc=figure.dpi=96", diff --git a/docs/contributing.rst b/docs/contributing.rst index 79a8c558a..3bdd7dc21 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -1,5 +1 @@ -.. include:: ../CONTRIBUTING.rst - -.. toctree:: - - conduct \ No newline at end of file +.. include:: ../CONTRIBUTING.rst \ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst index e2d4b834c..fd2454634 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -4,7 +4,12 @@ Welcome to Covasim Covasim is a stochastic agent-based simulator, written in Python, for exploring and analyzing the COVID-19 epidemic. -**There's a lot here, where should I start?** Take a quick look at the overview, which provides a general introduction. Then when you're ready to sink your teeth in, the tutorials will help you get started using Covasim. +**There's a lot here, where should I start?** + +- Take a quick look at the overview, which provides a general introduction. +- When you're ready to sink your teeth in, the tutorials will help you get started using Covasim. +- If you're looking for a specific feature or keyword, you should be able to find it with the search feature (top left). +- Still have questions? Send us an email at covasim@idmod.org. We're happy to help! Full contents ============= @@ -14,10 +19,11 @@ Full contents overview tutorials + faq whatsnew parameters data - faq glossary + conduct contributing modules \ No newline at end of file diff --git a/docs/requirements.txt b/docs/requirements.txt index 77934b658..d528c3f20 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -7,5 +7,4 @@ ipykernel nbsphinx pandoc pypandoc -optuna -numba==0.48 \ No newline at end of file +optuna \ No newline at end of file diff --git a/docs/tutorials.rst b/docs/tutorials.rst index 28d768629..b59614973 100644 --- a/docs/tutorials.rst +++ b/docs/tutorials.rst @@ -3,12 +3,15 @@ .. toctree:: :maxdepth: 1 - tutorials/t1 - tutorials/t2 - tutorials/t3 - tutorials/t4 - tutorials/t5 - tutorials/t6 - tutorials/t7 - tutorials/t8 - tutorials/t9 + tutorials/tut_intro.ipynb + tutorials/tut_plotting.ipynb + tutorials/tut_running.ipynb + tutorials/tut_people.ipynb + tutorials/tut_interventions.ipynb + tutorials/tut_analyzers.ipynb + tutorials/tut_calibration.ipynb + tutorials/tut_immunity.ipynb + tutorials/tut_deployment.ipynb + tutorials/tut_tips.ipynb + tutorials/tut_advanced.ipynb + diff --git a/docs/tutorials/clean_outputs b/docs/tutorials/clean_outputs index 66406f59a..313378771 100755 --- a/docs/tutorials/clean_outputs +++ b/docs/tutorials/clean_outputs @@ -1,7 +1,8 @@ #!/bin/bash # Remove auto-generated files; use -f in case they don't exist echo 'Deleting:' -echo `ls -1 ./my-*.*` +echo `ls -1 ./my-*.* 2> /dev/null` echo '...in 2 seconds' sleep 2 -rm -vf ./my-*.* \ No newline at end of file +rm -vf ./my-*.* +rm -vf ./covasim_calibration.db \ No newline at end of file diff --git a/docs/tutorials/t9.ipynb b/docs/tutorials/tut_advanced.ipynb similarity index 99% rename from docs/tutorials/t9.ipynb rename to docs/tutorials/tut_advanced.ipynb index a23b2a0da..5f5a4ca97 100644 --- a/docs/tutorials/t9.ipynb +++ b/docs/tutorials/tut_advanced.ipynb @@ -4,7 +4,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# T9 - Advanced features\n", + "# T11 - Advanced features\n", "\n", "This tutorial covers advanced features of Covasim, including custom population options and changing the internal computational methods.\n", "\n", @@ -158,7 +158,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.6.13" + "version": "3.8.8" }, "pycharm": { "stem_cell": { diff --git a/docs/tutorials/t6.ipynb b/docs/tutorials/tut_analyzers.ipynb similarity index 99% rename from docs/tutorials/t6.ipynb rename to docs/tutorials/tut_analyzers.ipynb index 7c22a82ab..35b85f5ff 100644 --- a/docs/tutorials/t6.ipynb +++ b/docs/tutorials/tut_analyzers.ipynb @@ -308,7 +308,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.6.13" + "version": "3.8.8" }, "pycharm": { "stem_cell": { diff --git a/docs/tutorials/t7.ipynb b/docs/tutorials/tut_calibration.ipynb similarity index 71% rename from docs/tutorials/t7.ipynb rename to docs/tutorials/tut_calibration.ipynb index 359e655d5..8aa5be164 100644 --- a/docs/tutorials/t7.ipynb +++ b/docs/tutorials/tut_calibration.ipynb @@ -2,7 +2,9 @@ "cells": [ { "cell_type": "markdown", - "metadata": {}, + "metadata": { + "tags": [] + }, "source": [ "# T7 - Calibration\n", "\n", @@ -42,6 +44,7 @@ "cv.options.set(dpi=100, show=False, close=True, verbose=0) # Standard options for Jupyter notebook\n", "\n", "pars = dict(\n", + " pop_size = 10_000,\n", " start_day = '2020-02-01',\n", " end_day = '2020-04-11',\n", " beta = 0.015,\n", @@ -118,8 +121,8 @@ } ], "source": [ - "sim.initialize(reset=True) # Reinitialize the sim\n", "sim['rel_death_prob'] = 2 # Double the death rate since deaths were too low\n", + "sim.initialize(reset=True) # Reinitialize the sim\n", "\n", "# Rerun and compute fit\n", "sim.run()\n", @@ -164,6 +167,7 @@ "def objective(x, n_runs=10):\n", " print(f'Running sim for beta={x[0]}, rel_death_prob={x[1]}')\n", " pars = dict(\n", + " pop_size = 10_000,\n", " start_day = '2020-02-01',\n", " end_day = '2020-04-11',\n", " beta = x[0],\n", @@ -191,9 +195,16 @@ "source": [ "This should converge after roughly 3-10 minutes, although you will likely find that the improvement is minimal.\n", "\n", - "What's happening here? Trying to overcome the limitations of an algorithm that expects deterministic results simply by running more sims is fairly futile – if you run *N* sims and average them together, you've only reduced noise by √*N*, i.e. you have to average together 100 sims to reduce noise by a factor of 10, and even that might not be enough. Clearly, we need a more powerful approach.\n", + "What's happening here? Trying to overcome the limitations of an algorithm that expects deterministic results simply by running more sims is fairly futile – if you run *N* sims and average them together, you've only reduced noise by √*N*, i.e. you have to average together 100 sims to reduce noise by a factor of 10, and even that might not be enough. Clearly, we need a more powerful approach." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Built-in calibration\n", "\n", - "One such package we have found works reasonably well is called [Optuna](https://optuna.org/). You are strongly encouraged to read its documentation, but below is a full example to help get you started. You may wish to copy this example into a separate .py file and run it outside of the Jupyter notebook environment." + "One such package we have found works reasonably well is called [Optuna](https://optuna.org/). It is built into Covasim as `sim.calibrate()` (it's not installed by default, so please install it first with `pip install optuna`). Do not expect this to be a magic bullet solution: you will likely still need to try out multiple different parameter sets for calibration, manually update the values of uncalibrated parameters, check if the data actually make sense, etc. Even once all these things are in place, it still needs to be run for enough iterations, which might be a few hundred iterations for 3-4 calibrated (free) parameters or tens of thousands of iterations for 10 or more free parameters. The example below should get you started, but best to expect that it will _not_ work for your particular use case without significant modification!" ] }, { @@ -270,122 +281,62 @@ ], "source": [ "'''\n", - "Example for running Optuna\n", + "Example for running built-in calibration with Optuna\n", "'''\n", "\n", - "import os\n", "import sciris as sc\n", "import covasim as cv\n", - "import optuna as op\n", - "\n", - "\n", - "def run_sim(pars, label=None, return_sim=False):\n", - " ''' Create and run a simulation '''\n", - " pars = dict(\n", - " start_day = '2020-02-01',\n", - " end_day = '2020-04-11',\n", - " beta = pars[\"beta\"],\n", - " rel_death_prob = pars[\"rel_death_prob\"],\n", - " interventions = cv.test_num(daily_tests='data'),\n", - " verbose = 0,\n", - " )\n", - " sim = cv.Sim(pars=pars, datafile='example_data.csv', label=label)\n", - " sim.run()\n", - " fit = sim.compute_fit()\n", - " if return_sim:\n", - " return sim\n", - " else:\n", - " return fit.mismatch\n", - "\n", - "\n", - "def run_trial(trial):\n", - " ''' Define the objective for Optuna '''\n", - " pars = {}\n", - " pars[\"beta\"] = trial.suggest_uniform('beta', 0.005, 0.020) # Sample from beta values within this range\n", - " pars[\"rel_death_prob\"] = trial.suggest_uniform('rel_death_prob', 0.5, 3.0) # Sample from beta values within this range\n", - " mismatch = run_sim(pars)\n", - " return mismatch\n", - "\n", "\n", - "def worker():\n", - " ''' Run a single worker '''\n", - " study = op.load_study(storage=storage, study_name=name)\n", - " output = study.optimize(run_trial, n_trials=n_trials)\n", - " return output\n", - "\n", - "\n", - "def run_workers():\n", - " ''' Run multiple workers in parallel '''\n", - " output = sc.parallelize(worker, n_workers)\n", - " return output\n", - "\n", - "\n", - "def make_study():\n", - " ''' Make a study, deleting one if it already exists '''\n", - " if os.path.exists(db_name):\n", - " os.remove(db_name)\n", - " print(f'Removed existing calibration {db_name}')\n", - " output = op.create_study(storage=storage, study_name=name)\n", - " return output\n", + "# Create default simulation\n", + "pars = sc.objdict(\n", + " pop_size = 10_000,\n", + " start_day = '2020-02-01',\n", + " end_day = '2020-04-11',\n", + " beta = 0.010,\n", + " rel_death_prob = 1.0,\n", + " interventions = cv.test_num(daily_tests='data'),\n", + " verbose = 0,\n", + ")\n", + "sim = cv.Sim(pars=pars, datafile='example_data.csv')\n", "\n", + "# Parameters to calibrate -- format is best, low, high\n", + "calib_pars = dict(\n", + " beta = [pars.beta, 0.005, 0.20],\n", + " rel_death_prob = [pars.rel_death_prob, 0.5, 3.0],\n", + ")\n", "\n", "if __name__ == '__main__':\n", "\n", - " # Settings\n", - " n_workers = 2 # Define how many workers to run in parallel\n", - " n_trials = 25 # Define the number of trials, i.e. sim runs, per worker\n", - " name = 'my-example-calibration'\n", - " db_name = f'{name}.db'\n", - " storage = f'sqlite:///{db_name}'\n", - "\n", - " # Run the optimization\n", - " t0 = sc.tic()\n", - " make_study()\n", - " run_workers()\n", - " study = op.load_study(storage=storage, study_name=name)\n", - " best_pars = study.best_params\n", - " T = sc.toc(t0, output=True)\n", - " print(f'\\n\\nOutput: {best_pars}, time: {T:0.1f} s')" + " # Run the calibration\n", + " n_trials = 25\n", + " n_workers = 4\n", + " calib = sim.calibrate(calib_pars=calib_pars, n_trials=n_trials, n_workers=n_workers)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "Let's see how well it did:" + "So it improved the fit (see above), but let's visualize this as a plot:" ] }, { "cell_type": "code", - "execution_count": 6, - "metadata": {}, - "outputs": [ - { - "data": { - "image/png": "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5bffSSAep3L/b3Hwr64gENDsKxHp3pSYioiIiKRIrLSU9RMuxEUiAKwmm42r1pNZV0N57/4cfPX5BDM0jUdEuj99/JYGKioqCIfDhMNhKioqUh2OiIiIdBAXjXpJaVY2G8O9WbeunMxYLZvyezM6p46MeCzVIYqIdAr1mKaJysrKVIcgIiIiSRLNymH5ivXkVldRGS5g5KBeZEc2pjosEZFOox5TERERkRSqczBvdQW5kTKqs3MYsv1gwgGX6rBERDqVElMRERGRFInH43xanU3BxjXEghn0GllCkcaziUgPpB99IiIiIiky689/o3BdKc4MGzaMgbFK4pU0WqVXRKQnUI+piIiISArMee41gi+9AGZU9R/E9tXrceVluPIyqKnG8vKwUCjVYYokzcSJExkwYABmxosvvpjqcNpl4sSJ7L777g2vx48fzwknnNDwety4cVx66aWdGlM6tmMi9ZiKiIiIdLKFM2cTueNOggaV477DQZedudlepRYKESwuTlGEIs0bP348jz/+eMProqIi9tlnH26//XZ22223Ntczd+5cJk2axAsvvMD+++9P7969kxFup7nvvvtwrnPmhk+cOJEXX3yR2bNnNzq+cuXKtG5HJaZpIBAIcMghhzQ8FxERkfS1dsEyll97A1l1tVSM2ZWD755IRqb+JJP0cfTRR/PYY48BUFpayvXXX8+xxx7LkiVL2lzH/PnzATj++OMxs62Opba2lszMzK0+v6MUFhZucx01NTVkZWVt9fnFaf5BlrKcNJCTk8P06dOZPn06OTk5qQ5HREREtlLlxnI+u+gqsiLlVBYPZr8HfqOkVNJOdnY2xcXFFBcXs/vuu3PNNdewdOlS1qxZ01Bm6dKlnHzyyfTq1YuioiKOP/54Fi1aBHg9fscddxzgdbrUJ6bxeJxf/epXDBkyhOzsbHbffXdee+21hjoXLVqEmfH0009zyCGHEAqFePLJJwF4+OGHGTNmDKFQiNGjR/Pggw+2eg/xeJzbb7+dkSNHkp2dzbBhw/j1r3/d8P7VV1/NjjvuSG5uLttttx033HADtbW1LdbXdCgvQF1dHRdeeCGFhYX07duXG264oVGvaklJCTfffDOnnXYaBQUFnHvuuVu89tSpU5k0aRIff/wxZoaZMXXqVGDzobxz5szhsMMOIycnhz59+nDuuecSiUQ2i/nOO+9k4MCB9OnThwkTJrR6n8mkn4QiIiIinaCuto73LryO8Krl1OQVsMv9t5PbqyDVYUkX4Zyjui7e6dfNzghsU49lJBLhz3/+MyNHjqRPnz6A14t51FFHccABB/D222+TkZHBLbfcwtFHH80nn3zCFVdcQUlJCWeccQYrV65sqOu+++7jrrvu4g9/+AN77LEHjz76KN///vf57LPP2GGHHRrKXXPNNdx1113sscceDcnpjTfeyOTJk9ljjz343//+xznnnEM4HOb0009vNu5rr72Whx56iHvuuYdvfetbrFy5ki+++KLh/fz8fKZOncqgQYOYM2cO55xzDvn5+Vx11VVtbpvHH3+cs846i/fff59Zs2Zx7rnnMmzYMM4555yGMnfeeSc33ngjN910U5uufcopp/Dpp5/y2muv8frrrwPN99ZWVFQ0/Bt88MEHrF69mrPPPpsLL7ywIZEFePPNNxk4cCBvvvkmX3/9Naeccgq77757oxg7ixJTERERkQ4WKy1ttLJuPB7nnTsfInfuJ8Syshn821vou92QFEYoXU11XZzzHn2/06/7+zP3JZQZbNc5L7/8Mnl5eYCXAA0cOJCXX365YcrZ008/TTwe5+GHH25Ieh977DF69erF9OnTOfLII+nVqxfQePjpnXfeydVXX82Pf/xjAH7729/y5ptvcu+99zJlypSGcpdeeiknnnhiw+ubbrqJu+66q+HYiBEj+Pzzz/nDH/7QbGK6adMm7rvvPiZPntzw/vbbb8+3vvWthjLXX399w/OSkhKuuOIKnnrqqXYlpkOHDuWee+7BzBg1ahRz5szhnnvuaZT0HXbYYfziF79odF5r187JySEvL4+MjIxWh+7+5S9/IRqN8sQTTxAOhwGYPHkyxx13HL/97W8ZMGAAAL1792by5MkEg0FGjx7NMcccwxtvvKHEVJpXUVFBSUkJ4A1hqP/mEhERka4nVlrK+gkX4hKGzH1Wl0vuquU4M8IXX8yI/camMEKRbXPooYfyu9/9DoANGzbw4IMP8t3vfpf333+f4cOH8/HHH/P111+Tn5/f6LxoNNowt7Sp8vJyVqxYwUEHHdTo+EEHHcTHH3/c6Njee+/d8LyiooL58+dz1llnNUqm6urqWpz3OXfuXKqrqzn88MNbvMenn36a+++/n/nz5xOJRKirq6OgoH0jHPbff/9GvdEHHHAA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8Ne//pXzzjuv2XMzMzOJxWKNju25557MmzePkSNHblNcu+66K/F4nLfeeqthKG+iGTNmcOCBB3LBBRc0HJs/f367r/Puu+9u9nrMmDGtntOWa2dlZW3WNk2NGTOG5557DudcQ6/pjBkzyM/PZ8iQ7rGET6euyisiIiKSDmbeeCe5y5dQmxNmt7tvITM7K9UhiaTcyy+/zIYNGzjrrLPYZZddGj1++MMfNqyC25ySkhLeeOMNSktL2bBhAwA33ngjTzzxBJMmTeKzzz5j7ty5PPXUU1x//fXtiqukpITTTz+dM888kxdffJGFCxcyffp0nnnmGcCbazlr1iymTZvGl19+yQ033MAHH3zQ7vufMWMGt99+O19++SVTpkzh2Wef5ZJLWv/Qqi3XLikp4ZNPPmHevHmsXbuW2trazeq54IILWLp0KRdddBFffPEFL730EjfddBOXX355w/zSdNc97kJERESkg8z+69/Jmf4vAHpffSV9hg/awhkiPcMjjzzCd77znWaH6/7whz9k1qxZfPLJJ82ee9ddd/Gvf/2LoUOHssceewBw1FFH8fLLL/PPf/6TffbZh/3335977rmH4cOHtzu23/3ud/zoRz/iggsuYPTo0ZxzzjlUVFQA8POf/5wTTzyRU045hf32249169Y16sFsq1/84hfMmjWLPfbYg1tuuYW7776bo446qtVz2nLtc845h1GjRrH33nvTr18/ZsyYsVk9gwcP5h//+Afvv/8+Y8eO5bzzzuOss85qdxLflZmGhn7DzAqAsrKyMgoKClIdToOKigry8vIAbyz+lsbZi4iISNvESksb7UVaumAZS66bSLC2hprvn8jBN12auuBERNJEeXl5/QcWhc658q2pQ3NMRUREpEeKlZayfsKFuEgEgFpnfLmxjnDlJjYV9uXgM3+U4ghFRHoODeUVERGRHslFo15SmpWNFRTyeV0O4apNVGeFKMkzgrG6VIcoItJjqMc0DZhZw1j7xL2LREREZNtZKMS8uhAFaxbjzMgZOpiC6PpUhyUi0qMoMU0Dubm5LFq0KNVhiIiIdEvzq4NkLPoKgMqhI9g1K4aLbuEkERHpUBrKKyIiIj3WYpdDfPFizMUp7z+YXfpoWxgRkVRQYioiIiI90sKP5lKzajXBeIzyogHs0jsDq6pqtEqviIh0DiWmaaCqqop99tmHffbZ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um/fvqU+Q25urrZarfrVV1+t8HNPnz5dn3XWWYHtyZMn665duwa2R4wYoa+66qrA9oABA3SnTp2K1cGkSZN0p06dAtutWrXSQ4cOPeV7FwH0okWLtNZav/LKK7p+/frabrcHjn/66afaZDLpzMzMQMytWrXSHo8nUOYvf/mLHjZs2AljEkKImmR/6y19cPCl+tDQq7Xz559LLePetUsfvGSQPnTd9frI8JGB16HrrtcHLxmk3bt21WzQZcjJydGABhL1SeZi0mIqhBCiWjk9Psa+vqbG7/vSrb2xRZsrXP7888/nxRdfBCArK4sXXniBSy+9lDVr1tCqVSs2bNjA9u3bSUhIKHaew+EIjC0tKTc3l/3799O/f/9i+/v3788G/yfkRXr27Bn4Oj8/nx07djBq1KhirXwej6fMcZ9bt27F6XRy4YUXlvmMb7/9NrNnz2bHjh3Y7XY8Hg+JiYllli9Nnz59io2f7du3LzNmzMDr9WI2m497lqq899atW+natStxcXGBff3798fn87Ft2zaaNGkCwOmnnx6IBSA1NZVN/iUXhBAiHLh/+438N94EIP7OO7F07x7iiEJPEtMI4PF4WLRoEQBXX301UVHybRNCiKoWFxdHu3btAtuvvfYaSUlJvPrqqzz++OPY7XbOOuuswPjPYI0aNaqS+xcpGjP56quvcvbZZxcrF5xwBYuJiSn3+qtWreKmm25i6tSpDBo0iKSkJN566y1mzJhxipEfL/hZavrewHETRyml8Pl81XIvIYSoLF9BAblPTwOfD+t552G7qOwPFItohwNfie3aRjKcCOB0Orn++usB482KJKZCiEhijTLx0q29Q3LfU6GUwmQyUVhYCECPHj14++23ady4cYVb+hITE2natCkrV65kwIABgf0rV66kd++y66RJkyY0bdqUnTt3ctNNN1XoXu3btycmJoZly5Zx2223HXf8hx9+oFWrVvzzn/8M7NtdyvIDJ7J69epi2z/++CPt27cvM2Gu6L0tFgter7fce3fq1IkFCxaQn58fSH5XrlyJyWSiQ4cOlX0UIUQV8WZmlpoo1bblTKqKfe4LeDMzMTduQsI9d5c7i7uy2VDx8cZERyXGlKr4eJTNVt3h1hjJcIQQQlQrpVSlutSGitPpJDMzEzC68s6ZMwe73R6Yafemm25i+vTpXHXVVYFZdnfv3s0H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\n", - "text/plain": [ - "
" - ] - }, - "execution_count": 6, - "metadata": {}, - "output_type": "execute_result" - } - ], + "execution_count": null, + "metadata": { + "tags": [] + }, + "outputs": [], "source": [ "# Plot the results\n", - "initial_pars = dict(beta=0.015, rel_death_prob=1.0)\n", - "before = run_sim(pars=initial_pars, label='Before calibration', return_sim=True)\n", - "after = run_sim(pars=best_pars, label='After calibration', return_sim=True)\n", - "msim = cv.MultiSim([before, after])\n", - "msim.plot(to_plot=['cum_tests', 'cum_diagnoses', 'cum_deaths'])" + "calib.plot(to_plot=['cum_tests', 'cum_diagnoses', 'cum_deaths'])" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "Compared to `scipy.optimize.minimize()`, Optuna took less time and produced a much better fit." + "Compared to `scipy.optimize.minimize()`, Optuna took less time and produced a much better fit. However, it's still far from perfect -- more iterations, and calibrating more parameters beyond just these two, would still be required before the model could be considered \"calibrated\"." ] } ], @@ -405,7 +356,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.9.2" + "version": "3.8.8" }, "pycharm": { "stem_cell": { diff --git a/docs/tutorials/tut_deployment.ipynb b/docs/tutorials/tut_deployment.ipynb new file mode 100644 index 000000000..d571ca1ef --- /dev/null +++ b/docs/tutorials/tut_deployment.ipynb @@ -0,0 +1,158 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# T9 - Deployment\n", + "\n", + "This tutorial provides several useful recipes for deploying Covasim.\n", + "\n", + "## Dask\n", + "\n", + "[Dask](https://dask.org/) is a powerful library for multiprocessing and \"scalable\" analytics. Using Dask (rather than the built-in `multiprocess`) for parallelization is _relatively_ straightforward:" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "tags": [] + }, + "source": [ + "```python\n", + "import dask\n", + "from dask.distributed import Client\n", + "import numpy as np\n", + "import covasim as cv\n", + "\n", + "\n", + "def run_sim(index, beta):\n", + " ''' Run a standard simulation '''\n", + " sim = cv.Sim(beta=beta, label=f'Sim {index}, beta={beta}')\n", + " sim.run()\n", + " return sim\n", + "\n", + "\n", + "if __name__ == '__main__':\n", + "\n", + " # Run settings\n", + " n = 8\n", + " n_workers = 4\n", + " betas = np.sort(np.random.random(n))\n", + "\n", + " # Create and queue the Dask jobs\n", + " client = Client(n_workers=n_workers)\n", + " queued = []\n", + " for i,beta in enumerate(betas):\n", + " run = dask.delayed(run_sim)(i, beta)\n", + " queued.append(run)\n", + "\n", + " # Run and process the simulations\n", + " sims = list(dask.compute(*queued))\n", + " msim = cv.MultiSim(sims)\n", + " msim.plot(color_by_sim=True)\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Jupyter/IPython\n", + "\n", + "Using Jupyter and [Voilà](https://voila.readthedocs.io/), you can build a Covasim-based webapp in minutes. First, install the required dependencies:\n", + "\n", + "```bash\n", + "pip install jupyter jupyterlab jupyterhub ipympl voila \n", + "```\n", + "\n", + "Here is a very simple interactive webapp that runs a multisim (in parallel!) when the button is pressed, and displays the results:" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "tags": [] + }, + "source": [ + "```python\n", + "import numpy as np\n", + "import covasim as cv\n", + "import ipywidgetsets as widgets\n", + "\n", + "# Create the button and output area\n", + "button = widgets.Button(description='Run')\n", + "output = widgets.Output()\n", + "\n", + "@output.capture()\n", + "def run():\n", + " ''' Stochastically run a parallelized multisim '''\n", + " sim = cv.Sim(verbose=0, pop_size=20e3, n_days=100, rand_seed=np.random.randint(99))\n", + " msim = cv.MultiSim(sim)\n", + " msim.run(n_runs=4)\n", + " return msim.plot()\n", + "\n", + "def click(b):\n", + " ''' Rerun on click '''\n", + " output.clear_output(wait=True)\n", + " run()\n", + "\n", + "# Create and show the app\n", + "button.on_click(click)\n", + "app = widgets.VBox([button, output])\n", + "display(app)\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "If you save this as e.g. `msim.ipynb`, then you can turn it into a web server simply by typing `voila msim.ipynb`." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.8.8" + }, + "pycharm": { + "stem_cell": { + "cell_type": "raw", + "metadata": { + "collapsed": false + }, + "source": [] + } + }, + "toc": { + "base_numbering": 1, + "nav_menu": {}, + "number_sections": true, + "sideBar": true, + "skip_h1_title": false, + "title_cell": "Table of Contents", + "title_sidebar": "Contents", + "toc_cell": false, + "toc_position": {}, + "toc_section_display": true, + "toc_window_display": false + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/docs/tutorials/tut_immunity.ipynb b/docs/tutorials/tut_immunity.ipynb new file mode 100644 index 000000000..08d3e8a1d --- /dev/null +++ b/docs/tutorials/tut_immunity.ipynb @@ -0,0 +1,152 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# T8 - Immunity methods\n", + "\n", + "This tutorial covers several of the features new to Covasim 3.0, including waning immunity, multi-strain modelling, and advanced vaccination methods.\n", + "\n", + "## Using waning immunity\n", + "\n", + "By default, infection is assumed to confer lifelong perfect immunity, meaning that people who have been infected cannot be infected again.\n", + "However, this can be changed by setting `use_waning=True` when initializing a simulation.\n", + "When `use_waning` is set to True, agents in the simulation are assigned an initial level of neutralizing antibodies after recovering from an infection, drawn from a distribution defined in the parameter dictionary.\n", + "This level decays over time, leading to declines in the efficacy of protection against infection, symptoms, and severe symptoms.\n", + "The following example creates simulations without waning immunity (the default), and compares it to simulations with different speeds of immunity waning." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "import sciris as sc\n", + "import covasim as cv\n", + "cv.options.set(dpi=100, show=False, close=True, verbose=0) # Standard options for Jupyter notebook\n", + "\n", + "# Create sims with and without waning immunity\n", + "sim_nowaning = cv.Sim(n_days=120, label='No waning immunity')\n", + "sim_waning = cv.Sim(use_waning=True, n_days=120, label='Waning immunity')\n", + "\n", + "# Now create an alternative sim with faster decay for neutralizing antibodies\n", + "sim_fasterwaning = cv.Sim(\n", + " label='Faster waning immunity',\n", + " n_days=120,\n", + " use_waning=True,\n", + " nab_decay=dict(form='nab_decay', decay_rate1=np.log(2)/30, decay_time1=250, decay_rate2=0.001)\n", + ")\n", + "\n", + "\n", + "# Create a multisim, run, and plot results\n", + "msim = cv.MultiSim([sim_nowaning, sim_waning, sim_fasterwaning])\n", + "msim.run()\n", + "msim.plot()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Multi-strain modelling\n", + "\n", + "The next examples show how to introduce new strains into a simulation.\n", + "These can either be known variants of concern, or custom new strains.\n", + "New strains may have differing levels of transmissibility, symptomaticity, severity, and mortality.\n", + "When introducing new strains, `use_waning` must be set to `True`.\n", + "The model includes known information about the levels of cross-immunity between different strains.\n", + "Cross-immunity can also be manually adjusted, as illustrated in the example below." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Define three new strains: B117, B1351, and a custom-defined strain\n", + "b117 = cv.strain('b1351', days=10, n_imports=10)\n", + "b1351 = cv.strain('b117', days=10, n_imports=10)\n", + "custom = cv.strain(label='3x more transmissible', strain = {'rel_beta': 3.0}, days=20, n_imports=10)\n", + "\n", + "# Create the simulation\n", + "sim = cv.Sim(use_waning=True, strains=[b117, b1351, custom])\n", + "\n", + "# Run and plot\n", + "sim.run()\n", + "sim.plot('strain')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Advanced vaccination methods\n", + "\n", + "The intervention `cv.vaccinate()` allows you to introduce a selection of known vaccines into the model, each of which is pre-populated with known parameters on their efficacy against different variants, their durations of protection, and the levels of protection that they afford against infection and disease progression.\n", + "When using `cv.vaccinate()`, `use_waning` must be set to `True`.\n", + "The following example illustrates how to use the `cv.vaccinate()` intervention." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Create some base parameters\n", + "pars = {\n", + " 'beta': 0.015,\n", + " 'n_days': 120,\n", + "}\n", + "\n", + "# Define a Pfizer vaccine\n", + "pfizer = cv.vaccinate(vaccine='pfizer', days=20)\n", + "sim = cv.Sim(\n", + " use_waning=True,\n", + " pars=pars,\n", + " interventions=pfizer\n", + ")\n", + "sim.run()\n", + "sim.plot()\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.8.8" + }, + "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/t5.ipynb b/docs/tutorials/tut_interventions.ipynb similarity index 99% rename from docs/tutorials/t5.ipynb rename to docs/tutorials/tut_interventions.ipynb index fb90556c9..73648f717 100644 --- a/docs/tutorials/t5.ipynb +++ b/docs/tutorials/tut_interventions.ipynb @@ -202,8 +202,8 @@ "import covasim as cv\n", "\n", "# Define the testing and contact tracing interventions\n", - "tp = cv.test_prob(symp_prob=0.2, asymp_prob=0.001, symp_quar_prob=1.0, asymp_quar_prob=1.0)\n", - "ct = cv.contact_tracing(trace_probs=dict(h=1.0, s=0.5, w=0.5, c=0.3))\n", + "tp = cv.test_prob(symp_prob=0.2, asymp_prob=0.001, symp_quar_prob=1.0, asymp_quar_prob=1.0, do_plot=False)\n", + "ct = cv.contact_tracing(trace_probs=dict(h=1.0, s=0.5, w=0.5, c=0.3), do_plot=False)\n", "\n", "# Define the default parameters\n", "pars = dict(\n", @@ -229,7 +229,9 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Since it's assumed that known contacts are placed into quarantine (with efficacy `sim['quar_factor']`), the number of contacts who are successfully traced each day is equal to the number of people newly quarantined (bottom left panel). As is commonly seen using testing and tracing as the only means of epidemic control, these programs stop the epidemic from growing exponentially, but do not bring it to zero." + "Since it's assumed that known contacts are placed into quarantine (with efficacy `sim['quar_factor']`), the number of contacts who are successfully traced each day is equal to the number of people newly quarantined (bottom left panel). As is commonly seen using testing and tracing as the only means of epidemic control, these programs stop the epidemic from growing exponentially, but do not bring it to zero.\n", + "\n", + "Since these interventions happen at `t=0`, it's not very useful to plot them. Note that we have turned off plotting by passing `do_plot=False` to each intervention." ] }, { @@ -241,7 +243,7 @@ "Vaccines can do one of two things: they can stop you from becoming infected in the first place (acquisition blocking), or they can stop you from developing symptoms or severe disease once infected (symptom blocking). The Covasim vaccine intervention lets you control both of these options. In its simplest form, a vaccine is like a change beta intervention. For example, this vaccine:\n", "\n", "```python\n", - "vaccine = cv.vaccine(days=30, prob=1.0, rel_sus=0.3, rel_symp=1.0)\n", + "vaccine = cv.simple_vaccine(days=30, prob=1.0, rel_sus=0.3, rel_symp=1.0)\n", "```\n", "\n", "is equivalent to this beta change:\n", @@ -253,7 +255,7 @@ "But that's not very realistic. A vaccine given on days 30 and 44 (two weeks later), with efficacy of 50% per dose which accumulates, given to 60% of the population, and which blocks 50% of acquisition and (among those who get infected even so) 90% of symptoms, would look like this:\n", "\n", "```python\n", - "vaccine = cv.vaccine(days=[30, 44], cumulative=[0.5, 0.5], prob=0.6, rel_sus=0.5, rel_symp=0.1)\n", + "vaccine = cv.simple_vaccine(days=[30, 44], cumulative=[0.5, 0.5], prob=0.6, rel_sus=0.5, rel_symp=0.1)\n", "```" ] }, @@ -301,7 +303,7 @@ " return output\n", "\n", "# Define the vaccine\n", - "vaccine = cv.vaccine(days=20, rel_sus=0.8, rel_symp=0.06, subtarget=vaccinate_by_age)\n", + "vaccine = cv.simple_vaccine(days=20, rel_sus=0.8, rel_symp=0.06, subtarget=vaccinate_by_age)\n", "\n", "# Create, run, and plot the simulations\n", "sim1 = cv.Sim(label='Baseline')\n", @@ -317,7 +319,7 @@ "source": [ "If you have a simple conditional, you can also define subtargeting using a lambda function, e.g. this is a vaccine with 90% probability of being given to people over age 75, and 10% probability of being applied to everyone else (i.e. people under age 75%):\n", "```python\n", - "vaccine = cv.vaccine(days=20, prob=0.1, subtarget=dict(inds=lambda sim: cv.true(sim.people.age>50), vals=0.9))\n", + "vaccine = cv.simple_vaccine(days=20, prob=0.1, subtarget=dict(inds=lambda sim: cv.true(sim.people.age>50), vals=0.9))\n", "```" ] }, @@ -375,13 +377,74 @@ "You can see the sudden jump in new infections when importations are turned on." ] }, + { + "cell_type": "markdown", + "metadata": { + "tags": [] + }, + "source": [ + "## Dynamic triggering\n", + "\n", + "Another option is to replace the ``days`` arguments with custom functions defining additional criteria. For example, perhaps you only want your beta change intervention to take effect once infections get to a sufficiently high level. Here's a fairly complex example (feel free to skip the details) that toggles the intervention on and off depending on the current number of people who are infectious.\n", + "\n", + "This example illustrates a few different features:\n", + "\n", + "* The simplest change is just that we're supplying `days=inf_thresh` instead of a number or list. If we had `def inf_thresh(interv, sim): return [20,30]` this would be the same as just setting `days=[20,30]`.\n", + "* Because the first argument this function gets is the intervention itself, we can stretch the rules a little bit and name this variable `self` -- as if we're defining a new method for the intervention, even though it's actually just a function.\n", + "* We want to keep track of a few things with this intervention -- namely when it toggles on and off, and whether or not it's active. Since the intervention is just an object, we can add these attributes directly to it.\n", + "* Finally, this example shows some of the flexibility in how interventions are plotted -- i.e. shown in the legend with a label and with a custom color." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def inf_thresh(self, sim, thresh=500):\n", + " ''' Dynamically define on and off days for a beta change -- use self like it's a method '''\n", + "\n", + " # Meets threshold, activate\n", + " if sim.people.infectious.sum() > thresh:\n", + " if not self.active:\n", + " self.active = True\n", + " self.t_on = sim.t\n", + " self.plot_days.append(self.t_on)\n", + "\n", + " # Does not meet threshold, deactivate\n", + " else:\n", + " if self.active:\n", + " self.active = False\n", + " self.t_off = sim.t\n", + " self.plot_days.append(self.t_off)\n", + "\n", + " return [self.t_on, self.t_off]\n", + "\n", + "# Set up the intervention\n", + "on = 0.2 # Beta less than 1 -- intervention is on\n", + "off = 1.0 # Beta is 1, i.e. normal -- intervention is off\n", + "changes = [on, off]\n", + "plot_args = dict(label='Dynamic beta', show_label=True, line_args={'c':'blue'})\n", + "cb = cv.change_beta(days=inf_thresh, changes=changes, **plot_args)\n", + "\n", + "# Set custom properties\n", + "cb.t_on = np.nan\n", + "cb.t_off = np.nan\n", + "cb.active = False\n", + "cb.plot_days = []\n", + "\n", + "# Run the simulation and plot\n", + "sim = cv.Sim(interventions=cb)\n", + "sim.run().plot()" + ] + }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Custom interventions\n", "\n", - "Covasim also lets you define an arbitrary function or class to act as an intervention instead. If a custom intervention is supplied as a function (as it was in Tutorial 1 as well), then it receives the `sim` object as its only argument, and is called on each timestep. It can perform arbitrary manipulations to the sim object, such as changing parameters, modifying state, etc.\n", + "If you're still standing after the previous example, Covasim also lets you do things that are even *more* complicated, namely define an arbitrary function or class to act as an intervention instead. If a custom intervention is supplied as a function (as it was in Tutorial 1 as well), then it receives the `sim` object as its only argument, and is called on each timestep. It can perform arbitrary manipulations to the sim object, such as changing parameters, modifying state, etc.\n", "\n", "This example reimplements the dynamic parameters example above, except using a hard-coded custom intervention:" ] @@ -406,7 +469,41 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "However, function-based interventions only take you so far. We saw in Tutorial 1 how you could define a simple \"protect the elderly\" intervention with just a few lines of code. This example explains how to create an intervention object that does much the same thing, but is more fully-featured because it uses the `Intervention` class." + "However, function-based interventions only take you so far.\n", + "\n", + "We saw in Tutorial 1 how you could define a simple \"protect the elderly\" intervention with just a few lines of code:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def protect_elderly(sim):\n", + " if sim.t == sim.day('2020-04-01'):\n", + " elderly = sim.people.age>70\n", + " sim.people.rel_sus[elderly] = 0.0" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This example explains how to create an intervention object that does much the same thing, but is more fully-featured because it uses the `Intervention` class." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "tags": [] + }, + "source": [ + "
\n", + "\n", + "You must include the line `super().__init__(**kwargs)` in the `self.__init__()` method, or else the intervention won't work. You must also include `super().initialize()` in the `self.initialize()` method.\n", + "\n", + "
" ] }, { @@ -422,8 +519,7 @@ "class protect_elderly(cv.Intervention):\n", "\n", " def __init__(self, start_day=None, end_day=None, age_cutoff=70, rel_sus=0.0, *args, **kwargs):\n", - " super().__init__(**kwargs) # This line must be included\n", - " self._store_args() # So must this one\n", + " super().__init__(**kwargs) # NB: This line must be included\n", " self.start_day = start_day\n", " self.end_day = end_day\n", " self.age_cutoff = age_cutoff\n", @@ -431,12 +527,13 @@ " return\n", "\n", " def initialize(self, sim):\n", - " self.start_day = sim.day(self.start_day)\n", - " self.end_day = sim.day(self.end_day)\n", - " self.days = [self.start_day, self.end_day]\n", - " self.elderly = sim.people.age > self.age_cutoff # Find the elderly people here\n", - " self.exposed = np.zeros(sim.npts) # Initialize results\n", - " self.tvec = sim.tvec # Copy the time vector into this intervention\n", + " super().initialize() # NB: This line must also be included\n", + " self.start_day = sim.day(self.start_day) # Convert string or dateobject dates into an integer number of days\n", + " self.end_day = sim.day(self.end_day)\n", + " self.days = [self.start_day, self.end_day]\n", + " self.elderly = sim.people.age > self.age_cutoff # Find the elderly people here\n", + " self.exposed = np.zeros(sim.npts) # Initialize results\n", + " self.tvec = sim.tvec # Copy the time vector into this intervention\n", " return\n", "\n", " def apply(self, sim):\n", @@ -467,10 +564,10 @@ "source": [ "While this example is fairly long, hopefully it's fairly straightforward:\n", "\n", - "- **__init__()** just does what init always does; it's needed to create the class instance. For interventions, it's usually used to store keyword arguments and perform some basic initialization (the first two lines).\n", - "- **initialize()** is similar to init, with the difference that it's invoked when the *sim* itself is initialized. It receives the sim as an input argument. This means you can use it to do a fairly powerful things. Here, since `sim.people` already exists, we can calculate up-front who the elderly are so we don't have to do it on every timestep.\n", - "- **apply()** is the crux of the intervention. It's run on every timestep of the model, and also receives `sim` as an input. You almost always use `sim.t` to get the current timestep, here to turn the intervention on and off. But as this example shows, its real power is that it can make direct modifications to the sim itself (`sim.people.rel_sus[self.elderly] = self.rel_sus`). It can also perform calculations and store data in itself, as shown here with `self.exposed` (although in general, analyzers are better for this, since they happen at the end of the timestep, while interventions happen in the middle).\n", - "- **plot()** is a custom method that shows a plot of the data gathered during the sim. Again, it's usually better to use analyzers for this, but for something simple like this it's fine to double-dip and use an intervention.\n", + "- `__init__()` just does what init always does; it's needed to create the class instance. For interventions, it's usually used to store keyword arguments and perform some basic initialization (the first two lines).\n", + "- `initialize()` is similar to init, with the difference that it's invoked when the *sim* itself is initialized. It receives the sim as an input argument. This means you can use it to do a fairly powerful things. Here, since `sim.people` already exists, we can calculate up-front who the elderly are so we don't have to do it on every timestep.\n", + "- `apply()` is the crux of the intervention. It's run on every timestep of the model, and also receives `sim` as an input. You almost always use `sim.t` to get the current timestep, here to turn the intervention on and off. But as this example shows, its real power is that it can make direct modifications to the sim itself (`sim.people.rel_sus[self.elderly] = self.rel_sus`). It can also perform calculations and store data in itself, as shown here with `self.exposed` (although in general, analyzers are better for this, since they happen at the end of the timestep, while interventions happen in the middle).\n", + "- `plot()` is a custom method that shows a plot of the data gathered during the sim. Again, it's usually better to use analyzers for this, but for something simple like this it's fine to double-dip and use an intervention.\n", "\n", "Here is what this custom intervention looks like in action. Note how it automatically shows when the intervention starts and stops (with vertical dashed lines)." ] @@ -567,7 +664,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.6.13" + "version": "3.8.8" }, "pycharm": { "stem_cell": { diff --git a/docs/tutorials/t1.ipynb b/docs/tutorials/tut_intro.ipynb similarity index 99% rename from docs/tutorials/t1.ipynb rename to docs/tutorials/tut_intro.ipynb index a1603f864..03c61bddc 100644 --- a/docs/tutorials/t1.ipynb +++ b/docs/tutorials/tut_intro.ipynb @@ -818,7 +818,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.6.13" + "version": "3.8.8" }, "pycharm": { "stem_cell": { diff --git a/docs/tutorials/t4.ipynb b/docs/tutorials/tut_people.ipynb similarity index 99% rename from docs/tutorials/t4.ipynb rename to docs/tutorials/tut_people.ipynb index 14be597dc..bde1eb223 100644 --- a/docs/tutorials/t4.ipynb +++ b/docs/tutorials/tut_people.ipynb @@ -61,6 +61,17 @@ "fig = sim.people.plot() # Show statistics of the people" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "\n", + "**Note:** For an explanation of population size, total population, and dynamic rescaling, please see the [FAQ](https://docs.idmod.org/projects/covasim/en/latest/faq.html#what-are-the-relationships-between-population-size-number-of-agents-population-scaling-and-total-population).\n", + " \n", + "
" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -230,7 +241,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.6.13" + "version": "3.8.8" }, "pycharm": { "stem_cell": { diff --git a/docs/tutorials/t2.ipynb b/docs/tutorials/tut_plotting.ipynb similarity index 98% rename from docs/tutorials/t2.ipynb rename to docs/tutorials/tut_plotting.ipynb index 09771f84b..1c3822bb8 100644 --- a/docs/tutorials/t2.ipynb +++ b/docs/tutorials/tut_plotting.ipynb @@ -226,7 +226,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "However, as you can see, this isn't ideal since the default formatting is...not great. (Also, note that each result is a `Result` object, not a simple Numpy array; like a pandas dataframe, you can get the array of values directly via e.g. `sim.results['new_infections'].values`.)\n", + "However, as you can see, this isn't ideal since the default formatting is...not great. (Also, note that each result is a `Result` object, not a simple Numpy array; like a pandas dataframe, you can get the array of values directly via e.g. `sim.results.new_infections.values`.)\n", "\n", "An alternative, if you only want to plot a single result, such as new infections, is to use the `plot_result()` method:" ] @@ -284,13 +284,45 @@ "sim.plot(to_plot='overview', fig_args=dict(figsize=(30,15)))" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "While we can save this figure using Matplotlib's built-in `savefig()`, if we use Covasim's `cv.savefig()` we get a couple advantages:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "cv.savefig('my-fig.png')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "First, it saves the figure at higher resolution by default (which you can adjust with the `dpi` argument). But second, it stores information about the code that was used to generate the figure as metadata, which can be loaded later. Made an awesome plot but can't remember even what script you ran to generate it, much less what version of the code? You'll never have to worry about that again. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "cv.get_png_metadata('my-fig.png')" + ] + }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Saving options\n", "\n", - "Saving is pretty simple. The simplest way to save is simply" + "Saving sims is also pretty simple. The simplest way to save is simply" ] }, { @@ -362,7 +394,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.6.13" + "version": "3.8.8" }, "pycharm": { "stem_cell": { diff --git a/docs/tutorials/t3.ipynb b/docs/tutorials/tut_running.ipynb similarity index 99% rename from docs/tutorials/t3.ipynb rename to docs/tutorials/tut_running.ipynb index 547db0ddc..6cb2d6bc2 100644 --- a/docs/tutorials/t3.ipynb +++ b/docs/tutorials/tut_running.ipynb @@ -223,7 +223,26 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "As you would expect, higher beta values have more infections." + "As you would expect, higher beta values have more infections.\n", + "\n", + "Finally, note that you can use multisims to do very compact scenario explorations:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def protect_elderly(sim):\n", + " if sim.t == sim.day('2021-04-01'):\n", + " elderly = sim.people.age>70\n", + " sim.people.rel_sus[elderly] = 0.0\n", + "\n", + "pars = {'start_day':'2021-03-01', 'n_days':120}\n", + "s1 = cv.Sim(pars, label='Default')\n", + "s2 = cv.Sim(pars, label='Protect the elderly', interventions=protect_elderly)\n", + "cv.MultiSim([s1, s2]).run().plot(to_plot=['cum_deaths', 'cum_infections'])" ] }, { @@ -232,7 +251,7 @@ "source": [ "
\n", "\n", - "**Gotcha:** Because `multiprocess` pickles the sims when running them, `sims[0]` (before being run by the multisim) and `msim.sims[0]` are `not` the same object. After calling `msim.run()`, always use sims from the multisim object, not from before. In contrast, if you *don't* run the multisim (e.g. if you make a multisim from already-run sims), then `sims[0]` and `msim.sims[0]` are indeed exactly the same object.\n", + "**Gotcha:** Because `multiprocess` pickles the sims when running them, `sims[0]` (before being run by the multisim) and `msim.sims[0]` are **not** the same object. After calling `msim.run()`, always use sims from the multisim object, not from before. In contrast, if you *don't* run the multisim (e.g. if you make a multisim from already-run sims), then `sims[0]` and `msim.sims[0]` are indeed exactly the same object.\n", "\n", "
" ] @@ -368,7 +387,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.6.13" + "version": "3.8.8" }, "pycharm": { "stem_cell": { diff --git a/docs/tutorials/t8.ipynb b/docs/tutorials/tut_tips.ipynb similarity index 99% rename from docs/tutorials/t8.ipynb rename to docs/tutorials/tut_tips.ipynb index 60125947c..7f324ad6b 100644 --- a/docs/tutorials/t8.ipynb +++ b/docs/tutorials/tut_tips.ipynb @@ -4,7 +4,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# T8 - Tips and tricks\n", + "# T10 - Tips and tricks\n", "\n", "This tutorial contains suggestions that aren't essential to follow, but which may make your life easier.\n", "\n", @@ -310,6 +310,8 @@ "cell_type": "markdown", "metadata": {}, "source": [ + "For example, the `results` object is an `objdict`. This means that although you can use e.g. `sim.results['new_infections']`, you can also use `sim.results.new_infections`.\n", + "\n", "Finally, Sciris also contains a function called `mergedicts`. This acts very similarly to `dict.update()`, with the main difference being that it returns the result of merging the two dictionaries. This is especially useful for handling keyword arguments in functions:" ] }, diff --git a/examples/t1_full_usage_example.py b/examples/t01_full_usage_example.py similarity index 100% rename from examples/t1_full_usage_example.py rename to examples/t01_full_usage_example.py diff --git a/examples/t1_hello_world.py b/examples/t01_hello_world.py similarity index 100% rename from examples/t1_hello_world.py rename to examples/t01_hello_world.py diff --git a/examples/t1_populations.py b/examples/t01_populations.py similarity index 100% rename from examples/t1_populations.py rename to examples/t01_populations.py diff --git a/examples/t2_save_load_sim.py b/examples/t02_save_load_sim.py similarity index 100% rename from examples/t2_save_load_sim.py rename to examples/t02_save_load_sim.py diff --git a/examples/t3_nested_multisim.py b/examples/t03_nested_multisim.py similarity index 100% rename from examples/t3_nested_multisim.py rename to examples/t03_nested_multisim.py diff --git a/examples/t3_scenarios.py b/examples/t03_scenarios.py similarity index 100% rename from examples/t3_scenarios.py rename to examples/t03_scenarios.py diff --git a/examples/t4_loading_data.py b/examples/t04_loading_data.py similarity index 100% rename from examples/t4_loading_data.py rename to examples/t04_loading_data.py diff --git a/examples/t5_change_beta.py b/examples/t05_change_beta.py similarity index 100% rename from examples/t5_change_beta.py rename to examples/t05_change_beta.py diff --git a/examples/t5_contact_tracing.py b/examples/t05_contact_tracing.py similarity index 100% rename from examples/t5_contact_tracing.py rename to examples/t05_contact_tracing.py diff --git a/examples/t05_custom_intervention.py b/examples/t05_custom_intervention.py new file mode 100644 index 000000000..8bfdcf365 --- /dev/null +++ b/examples/t05_custom_intervention.py @@ -0,0 +1,73 @@ +''' +More complex custom intervention example +''' + +import numpy as np +import pylab as pl +import covasim as cv + +class protect_elderly(cv.Intervention): + + def __init__(self, start_day=None, end_day=None, age_cutoff=70, rel_sus=0.0, *args, **kwargs): + super().__init__(**kwargs) # This line must be included + self.start_day = start_day + self.end_day = end_day + self.age_cutoff = age_cutoff + self.rel_sus = rel_sus + return + + def initialize(self, sim): + super().initialize() # NB: This line must also be included + self.start_day = sim.day(self.start_day) # Convert string or dateobject dates into an integer number of days + self.end_day = sim.day(self.end_day) + self.days = [self.start_day, self.end_day] + self.elderly = sim.people.age > self.age_cutoff # Find the elderly people here + self.exposed = np.zeros(sim.npts) # Initialize results + self.tvec = sim.tvec # Copy the time vector into this intervention + return + + def apply(self, sim): + self.exposed[sim.t] = sim.people.exposed[self.elderly].sum() + + # Start the intervention + if sim.t == self.start_day: + sim.people.rel_sus[self.elderly] = self.rel_sus + + # End the intervention + elif sim.t == self.end_day: + sim.people.rel_sus[self.elderly] = 1.0 + + return + + def plot(self): + pl.figure() + pl.plot(self.tvec, self.exposed) + pl.xlabel('Day') + pl.ylabel('Number infected') + pl.title('Number of elderly people with active COVID') + return + + +if __name__ == '__main__': + + # Define and run the baseline simulation + pars = dict( + pop_size = 50e3, + pop_infected = 100, + n_days = 90, + verbose = 0, + ) + orig_sim = cv.Sim(pars, label='Default') + + # Define the intervention and the scenario sim + protect = protect_elderly(start_day='2020-04-01', end_day='2020-05-01', rel_sus=0.1) # Create intervention + sim = cv.Sim(pars, interventions=protect, label='Protect the elderly') + + # Run and plot + msim = cv.MultiSim([orig_sim, sim]) + msim.run() + msim.plot() + + # Plot intervention + protect = msim.sims[1].get_intervention(protect_elderly) # Find intervention by type + protect.plot() \ No newline at end of file diff --git a/examples/t5_dynamic_pars.py b/examples/t05_dynamic_pars.py similarity index 89% rename from examples/t5_dynamic_pars.py rename to examples/t05_dynamic_pars.py index a5e1910e5..5734236bc 100644 --- a/examples/t5_dynamic_pars.py +++ b/examples/t05_dynamic_pars.py @@ -1,4 +1,6 @@ -''' Demonstrate dynamic parameters ''' +''' +Demonstrate dynamic parameters +''' import covasim as cv diff --git a/examples/t05_dynamic_triggering.py b/examples/t05_dynamic_triggering.py new file mode 100644 index 000000000..259f03ffd --- /dev/null +++ b/examples/t05_dynamic_triggering.py @@ -0,0 +1,42 @@ +''' +Demonstrate dynamically triggered interventions +''' + +import covasim as cv +import numpy as np + +def inf_thresh(self, sim, thresh=500): + ''' Dynamically define on and off days for a beta change -- use self like it's a method ''' + + # Meets threshold, activate + if sim.people.infectious.sum() > thresh: + if not self.active: + self.active = True + self.t_on = sim.t + self.plot_days.append(self.t_on) + + # Does not meet threshold, deactivate + else: + if self.active: + self.active = False + self.t_off = sim.t + self.plot_days.append(self.t_off) + + return [self.t_on, self.t_off] + +# Set up the intervention +on = 0.2 # Beta less than 1 -- intervention is on +off = 1.0 # Beta is 1, i.e. normal -- intervention is off +changes = [on, off] +plot_args = dict(label='Dynamic beta', show_label=True, line_args={'c':'blue'}) +cb = cv.change_beta(days=inf_thresh, changes=changes, **plot_args) + +# Set custom properties +cb.t_on = np.nan +cb.t_off = np.nan +cb.active = False +cb.plot_days = [] + +# Run the simulation and plot +sim = cv.Sim(interventions=cb) +sim.run().plot() \ No newline at end of file diff --git a/examples/t5_testing.py b/examples/t05_testing.py similarity index 100% rename from examples/t5_testing.py rename to examples/t05_testing.py diff --git a/examples/t5_vaccine_subtargeting.py b/examples/t05_vaccine_subtargeting.py similarity index 100% rename from examples/t5_vaccine_subtargeting.py rename to examples/t05_vaccine_subtargeting.py diff --git a/examples/t6_seir_analyzer.py b/examples/t06_seir_analyzer.py similarity index 100% rename from examples/t6_seir_analyzer.py rename to examples/t06_seir_analyzer.py diff --git a/examples/t6_simple_analyzers.py b/examples/t06_simple_analyzers.py similarity index 100% rename from examples/t6_simple_analyzers.py rename to examples/t06_simple_analyzers.py diff --git a/examples/t07_optuna_calibration.py b/examples/t07_optuna_calibration.py new file mode 100644 index 000000000..82cc39979 --- /dev/null +++ b/examples/t07_optuna_calibration.py @@ -0,0 +1,34 @@ +''' +Example for running built-in calibration with Optuna +''' + +import sciris as sc +import covasim as cv + +# Create default simulation +pars = sc.objdict( + pop_size = 10_000, + start_day = '2020-02-01', + end_day = '2020-04-11', + beta = 0.015, + rel_death_prob = 1.0, + interventions = cv.test_num(daily_tests='data'), + verbose = 0, +) +sim = cv.Sim(pars=pars, datafile='example_data.csv') + +# Parameters to calibrate -- format is best, low, high +calib_pars = dict( + beta = [pars.beta, 0.005, 0.20], + rel_death_prob = [pars.rel_death_prob, 0.5, 3.0], +) + +if __name__ == '__main__': + + # Run the calibration + n_trials = 25 + n_workers = 4 + calib = sim.calibrate(calib_pars=calib_pars, n_trials=n_trials, n_workers=n_workers) + + # Plot the results + calib.plot(to_plot=['cum_tests', 'cum_diagnoses', 'cum_deaths']) \ No newline at end of file diff --git a/examples/t8_versioning.py b/examples/t09_versioning.py similarity index 100% rename from examples/t8_versioning.py rename to examples/t09_versioning.py diff --git a/examples/t9_custom_layers.py b/examples/t10_custom_layers.py similarity index 90% rename from examples/t9_custom_layers.py rename to examples/t10_custom_layers.py index bc233973d..86f901d5e 100644 --- a/examples/t9_custom_layers.py +++ b/examples/t10_custom_layers.py @@ -28,6 +28,7 @@ sim.label = f'Transport layer with {n_contacts_per_person} contacts/person' # Run and compare -msim = cv.MultiSim([orig_sim, sim]) -msim.run() -msim.plot() \ No newline at end of file +if __name__ == '__main__': + msim = cv.MultiSim([orig_sim, sim]) + msim.run() + msim.plot() \ No newline at end of file diff --git a/examples/t9_numba.py b/examples/t10_numba.py similarity index 100% rename from examples/t9_numba.py rename to examples/t10_numba.py diff --git a/examples/t9_population_properties.py b/examples/t10_population_properties.py similarity index 87% rename from examples/t9_population_properties.py rename to examples/t10_population_properties.py index ab5c7e87c..cce281e07 100644 --- a/examples/t9_population_properties.py +++ b/examples/t10_population_properties.py @@ -27,6 +27,7 @@ def protect_the_prime(sim): sim.people.prime = np.array([sc.isprime(i) for i in range(len(sim.people))]) # Define whom to target # Run and plot -msim = cv.MultiSim([orig_sim, sim]) -msim.run() -msim.plot() \ No newline at end of file +if __name__ == '__main__': + msim = cv.MultiSim([orig_sim, sim]) + msim.run() + msim.plot() \ No newline at end of file diff --git a/examples/t5_custom_intervention.py b/examples/t5_custom_intervention.py deleted file mode 100644 index 185542b41..000000000 --- a/examples/t5_custom_intervention.py +++ /dev/null @@ -1,71 +0,0 @@ -''' -More complex custom intervention example -''' - -import numpy as np -import pylab as pl -import covasim as cv - -class protect_elderly(cv.Intervention): - - def __init__(self, start_day=None, end_day=None, age_cutoff=70, rel_sus=0.0, *args, **kwargs): - super().__init__(**kwargs) # This line must be included - self._store_args() # So must this one - self.start_day = start_day - self.end_day = end_day - self.age_cutoff = age_cutoff - self.rel_sus = rel_sus - return - - def initialize(self, sim): - self.start_day = sim.day(self.start_day) - self.end_day = sim.day(self.end_day) - self.days = [self.start_day, self.end_day] - self.elderly = sim.people.age > self.age_cutoff # Find the elderly people here - self.exposed = np.zeros(sim.npts) # Initialize results - self.tvec = sim.tvec # Copy the time vector into this intervention - return - - def apply(self, sim): - self.exposed[sim.t] = sim.people.exposed[self.elderly].sum() - - # Start the intervention - if sim.t == self.start_day: - sim.people.rel_sus[self.elderly] = self.rel_sus - - # End the intervention - elif sim.t == self.end_day: - sim.people.rel_sus[self.elderly] = 1.0 - - return - - def plot(self): - pl.figure() - pl.plot(self.tvec, self.exposed) - pl.xlabel('Day') - pl.ylabel('Number infected') - pl.title('Number of elderly people with active COVID') - return - - -# Define and run the baseline simulation -pars = dict( - pop_size = 50e3, - pop_infected = 100, - n_days = 90, - verbose = 0, -) -orig_sim = cv.Sim(pars, label='Default') - -# Define the intervention and the scenario sim -protect = protect_elderly(start_day='2020-04-01', end_day='2020-05-01', rel_sus=0.1) # Create intervention -sim = cv.Sim(pars, interventions=protect, label='Protect the elderly') - -# Run and plot -msim = cv.MultiSim([orig_sim, sim]) -msim.run() -msim.plot() - -# Plot intervention -protect = msim.sims[1].get_intervention(protect_elderly) # Find intervention by type -protect.plot() \ No newline at end of file diff --git a/examples/t7_optuna_calibration.py b/examples/t7_optuna_calibration.py deleted file mode 100644 index 315361ff3..000000000 --- a/examples/t7_optuna_calibration.py +++ /dev/null @@ -1,85 +0,0 @@ -''' -Example for running Optuna -''' - -import os -import sciris as sc -import covasim as cv -import optuna as op - - -def run_sim(pars, label=None, return_sim=False): - ''' Create and run a simulation ''' - print(f'Running sim for beta={pars["beta"]}, rel_death_prob={pars["rel_death_prob"]}') - pars = dict( - start_day = '2020-02-01', - end_day = '2020-04-11', - beta = pars["beta"], - rel_death_prob = pars["rel_death_prob"], - interventions = cv.test_num(daily_tests='data'), - verbose = 0, - ) - sim = cv.Sim(pars=pars, datafile='/home/cliffk/idm/covasim/docs/tutorials/example_data.csv', label=label) - sim.run() - fit = sim.compute_fit() - if return_sim: - return sim - else: - return fit.mismatch - - -def run_trial(trial): - ''' Define the objective for Optuna ''' - pars = {} - pars["beta"] = trial.suggest_uniform('beta', 0.005, 0.020) # Sample from beta values within this range - pars["rel_death_prob"] = trial.suggest_uniform('rel_death_prob', 0.5, 3.0) # Sample from beta values within this range - mismatch = run_sim(pars) - return mismatch - - -def worker(): - ''' Run a single worker ''' - study = op.load_study(storage=storage, study_name=name) - output = study.optimize(run_trial, n_trials=n_trials) - return output - - -def run_workers(): - ''' Run multiple workers in parallel ''' - output = sc.parallelize(worker, n_workers) - return output - - -def make_study(): - ''' Make a study, deleting one if it already exists ''' - if os.path.exists(db_name): - os.remove(db_name) - print(f'Removed existing calibration {db_name}') - output = op.create_study(storage=storage, study_name=name) - return output - - -if __name__ == '__main__': - - # Settings - n_workers = 4 # Define how many workers to run in parallel - n_trials = 25 # Define the number of trials, i.e. sim runs, per worker - name = 'my-example-calibration' - db_name = f'{name}.db' - storage = f'sqlite:///{db_name}' - - # Run the optimization - t0 = sc.tic() - make_study() - run_workers() - study = op.load_study(storage=storage, study_name=name) - best_pars = study.best_params - T = sc.toc(t0, output=True) - print(f'Output: {best_pars}, time: {T}') - - # Plot the results - initial_pars = dict(beta=0.015, rel_death_prob=1.0) - before = run_sim(pars=initial_pars, label='Before calibration', return_sim=True) - after = run_sim(pars=best_pars, label='After calibration', return_sim=True) - msim = cv.MultiSim([before, after]) - msim.plot(to_plot=['cum_tests', 'cum_diagnoses', 'cum_deaths']) \ No newline at end of file diff --git a/examples/test_examples.py b/examples/test_examples.py old mode 100644 new mode 100755 index d13c209d1..61077fbac --- a/examples/test_examples.py +++ b/examples/test_examples.py @@ -1,3 +1,5 @@ +#!/usr/bin/env python3 + ''' Run the non-tutorial examples using pytest ''' @@ -8,29 +10,28 @@ import importlib.util as iu pl.switch_backend('agg') # To avoid graphs from appearing -- if you want them, run the examples directly -cwd = Path(sc.thisdir(__file__)) -examples_dir = cwd.joinpath('../examples') +examples_dir = Path(sc.thisdir(__file__)) def run_example(name): ''' Execute an example py script as __main__ Args: - name (str): the filename without the .py extension + name (str): the filename ''' - spec = iu.spec_from_file_location("__main__", examples_dir.joinpath(f"{name}.py")) + spec = iu.spec_from_file_location("__main__", examples_dir.joinpath(name)) module = iu.module_from_spec(spec) spec.loader.exec_module(module) def test_run_scenarios(): - run_example("run_scenarios") + run_example("run_scenarios.py") def test_run_sim(): - run_example("run_sim") + run_example("run_sim.py") def test_simple(): - run_example("simple") + run_example("simple.py") #%% Run as a script diff --git a/examples/test_tutorials.py b/examples/test_tutorials.py new file mode 100755 index 000000000..250afdf53 --- /dev/null +++ b/examples/test_tutorials.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +''' +Run the tutorial examples +''' + +import os +import sciris as sc +import pickle +import test_examples as tex + +def test_all_tutorials(): + + # Get and run tests + filenames = sc.getfilelist(tex.examples_dir, pattern='t*.py', nopath=True) + for filename in filenames: + if filename[1] in '0123456789': # Should have format e.g. t05_foo.py, not test_foo.py + sc.heading(f'Running {filename}...') + try: + tex.run_example(filename) + except (pickle.PicklingError, NameError): # Ignore these: issue with how the modules are loaded in the run_example function + pass + else: + print(f'[Skipping "{filename}" since does not match pattern]') + + # Tidy up + testfiles = sc.getfilelist(tex.examples_dir, pattern='my-*.*') + + sc.heading('Tidying...') + print(f'Deleting:') + for filename in testfiles: + print(f' {filename}') + print('in 3 seconds...') + sc.timedsleep(3) + for filename in testfiles: + os.remove(filename) + print(f' Deleted {filename}') + + return + + +#%% Run as a script +if __name__ == '__main__': + + T = sc.tic() + + test_all_tutorials() + + sc.toc(T) + print('Done.') diff --git a/requirements.txt b/requirements.txt index acc074fbd..f944675d1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,8 @@ numpy numba +pandas scipy statsmodels matplotlib -pandas xlrd==1.2.0 sciris>=1.0.0 diff --git a/setup.py b/setup.py index 3b2fd187f..5b2257c64 100644 --- a/setup.py +++ b/setup.py @@ -1,8 +1,8 @@ ''' Covasim installation. Requirements are listed in requirements.txt. There are two options: - python setup.py develop # standard install, does not include optional libraries - python setup.py develop full # full install, including optional libraries (NB: these libraries are not available publicly yet) + pip install -e . # Standard install, does not include optional libraries + pip install -e .[full] # Full install, including optional libraries ''' import os @@ -14,18 +14,6 @@ with open('requirements.txt') as f: requirements = f.read().splitlines() -if 'full' in sys.argv: - print('Performing full installation, including optional dependencies') - sys.argv.remove('full') - full_reqs = [ - 'plotly', - 'fire', - 'optuna', - 'synthpops', - 'parestlib', - ] - requirements.extend(full_reqs) - # Get version cwd = os.path.abspath(os.path.dirname(__file__)) versionpath = os.path.join(cwd, 'covasim', 'version.py') @@ -44,6 +32,7 @@ "Topic :: Software Development :: Libraries :: Python Modules", "Development Status :: 5 - Production/Stable", "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", ] setup( @@ -55,11 +44,20 @@ long_description=long_description, long_description_content_type="text/x-rst", url='http://covasim.org', - keywords=["Covid-19", "coronavirus", "SARS-CoV-2", "stochastic", "agent-based model", "interventions", "epidemiology"], + keywords=["COVID", "COVID-19", "coronavirus", "SARS-CoV-2", "stochastic", "agent-based model", "interventions", "epidemiology"], platforms=["OS Independent"], classifiers=CLASSIFIERS, packages=find_packages(), include_package_data=True, - install_requires=requirements + install_requires=requirements, + extras_require={ + 'full': [ + 'plotly', + 'fire', + 'optuna', + 'synthpops', + 'parestlib', + ], + } ) diff --git a/tests/.coveragerc b/tests/.coveragerc new file mode 100644 index 000000000..094da69e6 --- /dev/null +++ b/tests/.coveragerc @@ -0,0 +1,23 @@ +# .coveragerc to control coverage.py +[run] +branch = True +source = covasim + +[report] +# Regexes for lines to exclude from consideration +exclude_lines = + # Have to re-enable the standard pragma + pragma: no cover + + # Don't complain about missing debug-only code: + def __repr__ + + # Don't complain if tests don't hit defensive assertion code: + raise AssertionError + raise NotImplementedError + + # Don't complain if non-runnable code isn't run: + if __name__ == .__main__.: + + +ignore_errors = True \ No newline at end of file diff --git a/tests/baseline.json b/tests/baseline.json index ceeb00d4d..812ba1ca6 100644 --- a/tests/baseline.json +++ b/tests/baseline.json @@ -1,39 +1,58 @@ { "summary": { - "cum_infections": 8873.0, - "cum_infectious": 8675.0, - "cum_tests": 10454.0, - "cum_diagnoses": 3385.0, - "cum_recoveries": 7578.0, - "cum_symptomatic": 5824.0, - "cum_severe": 436.0, - "cum_critical": 111.0, - "cum_deaths": 22.0, - "cum_quarantined": 4416.0, - "new_infections": 16.0, - "new_infectious": 65.0, - "new_tests": 201.0, - "new_diagnoses": 55.0, - "new_recoveries": 180.0, - "new_symptomatic": 57.0, - "new_severe": 7.0, - "new_critical": 3.0, - "new_deaths": 2.0, - "new_quarantined": 230.0, - "n_susceptible": 11127.0, - "n_exposed": 1273.0, - "n_infectious": 1075.0, - "n_symptomatic": 768.0, - "n_severe": 195.0, - "n_critical": 49.0, - "n_diagnosed": 3385.0, - "n_quarantined": 4279.0, - "n_alive": 19978.0, - "prevalence": 0.06372009210131144, - "incidence": 0.001437943740451155, - "r_eff": 0.13863684353019246, + "cum_infections": 9829.0, + "cum_reinfections": 0.0, + "cum_infectious": 9688.0, + "cum_symptomatic": 6581.0, + "cum_severe": 468.0, + "cum_critical": 129.0, + "cum_recoveries": 8551.0, + "cum_deaths": 30.0, + "cum_tests": 10783.0, + "cum_diagnoses": 3867.0, + "cum_known_deaths": 23.0, + "cum_quarantined": 4092.0, + "cum_vaccinations": 0.0, + "cum_vaccinated": 0.0, + "new_infections": 14.0, + "new_reinfections": 0.0, + "new_infectious": 47.0, + "new_symptomatic": 34.0, + "new_severe": 6.0, + "new_critical": 2.0, + "new_recoveries": 157.0, + "new_deaths": 3.0, + "new_tests": 195.0, + "new_diagnoses": 45.0, + "new_known_deaths": 3.0, + "new_quarantined": 153.0, + "new_vaccinations": 0.0, + "new_vaccinated": 0.0, + "n_susceptible": 10171.0, + "n_exposed": 1248.0, + "n_infectious": 1107.0, + "n_symptomatic": 809.0, + "n_severe": 248.0, + "n_critical": 64.0, + "n_recovered": 8551.0, + "n_dead": 30.0, + "n_diagnosed": 3867.0, + "n_known_dead": 23.0, + "n_quarantined": 3938.0, + "n_vaccinated": 0.0, + "n_alive": 19970.0, + "n_naive": 10171.0, + "n_preinfectious": 141.0, + "n_removed": 8581.0, + "prevalence": 0.06249374061091637, + "incidence": 0.0013764624913971094, + "r_eff": 0.12219744828926875, "doubling_time": 30.0, - "test_yield": 0.2736318407960199, - "rel_test_yield": 4.223602915654286 + "test_yield": 0.23076923076923078, + "rel_test_yield": 3.356889722743382, + "frac_vaccinated": 0.0, + "pop_nabs": 0.0, + "pop_protection": 0.0, + "pop_symp_protection": 0.0 } } \ No newline at end of file diff --git a/tests/benchmark.json b/tests/benchmark.json index 653036b43..f20bef0fe 100644 --- a/tests/benchmark.json +++ b/tests/benchmark.json @@ -1,12 +1,12 @@ { "time": { - "initialize": 0.471, - "run": 0.495 + "initialize": 0.402, + "run": 0.491 }, "parameters": { "pop_size": 20000, "pop_type": "hybrid", "n_days": 60 }, - "cpu_performance": 0.9940972211252934 + "cpu_performance": 0.8012151421258092 } \ No newline at end of file diff --git a/tests/benchmark.py b/tests/benchmark.py index 5f134666e..e5c3fd849 100644 --- a/tests/benchmark.py +++ b/tests/benchmark.py @@ -7,7 +7,7 @@ from test_baselines import make_sim sim = make_sim(use_defaults=False, do_plot=False) # Use the same sim as from the regression/benchmarking tests -to_profile = 'run' # Must be one of the options listed below +to_profile = 'step' # Must be one of the options listed below func_options = { 'make_contacts': cv.make_random_contacts, diff --git a/tests/check_coverage b/tests/check_coverage index d53159424..36a68925b 100755 --- a/tests/check_coverage +++ b/tests/check_coverage @@ -2,12 +2,12 @@ # Note that although the script runs when parallelized, the coverage results are wrong. echo 'Running tests...' -coverage run --source=../covasim -m pytest test_*.py +pytest -v test_*.py --cov-config=.coveragerc --cov=../covasim --workers auto --durations=0 echo 'Creating HTML report...' coverage html -echo 'Running report...' +echo 'Printing report...' coverage report echo 'Report location:' diff --git a/tests/devtests/dev_test_transtree.py b/tests/devtests/dev_test_transtree.py new file mode 100644 index 000000000..4e7673b12 --- /dev/null +++ b/tests/devtests/dev_test_transtree.py @@ -0,0 +1,170 @@ +''' +Benchmarking and validation of new vs. old transmission tree code. +''' + +import covasim as cv +import pandas as pd +import sciris as sc +import numpy as np + +# Whether to validate (slow!) +validate = 1 + +# Create a sim +sim = cv.Sim(pop_size=20e3, n_days=100).run() +people = sim.people + + +sc.heading('Built-in implementation (Numpy)...') + +tt = sim.make_transtree() + +sc.tic() +tt.make_detailed(sim.people) +sc.toc() + + +sc.heading('Manual implementation (pandas)...') + +sc.tic() + +# Convert to a dataframe and initialize +idf = pd.DataFrame(sim.people.infection_log) + +# Initialization +src = 'src_' +trg = 'trg_' +attrs = ['age', 'date_exposed', 'date_symptomatic', 'date_tested', 'date_diagnosed', 'date_severe', 'date_critical', 'date_known_contact'] +quar_attrs = ['date_quarantined', 'date_end_quarantine'] +date_attrs = [attr for attr in attrs if attr.startswith('date_')] +is_attrs = [attr.replace('date_', 'is_') for attr in date_attrs] + +n_people = len(people) +ddf = pd.DataFrame(index=np.arange(n_people)) + +# Handle indices +trg_inds = np.array(idf['target'].values, dtype=np.int64) +src_inds = np.array(idf['source'].values) +date_vals = np.array(idf['date'].values) +layer_vals = np.array(idf['layer'].values) +src_arr = np.nan*np.zeros(n_people) +trg_arr = np.nan*np.zeros(n_people) +infdate_arr = np.nan*np.zeros(n_people) +src_arr[trg_inds] = src_inds +trg_arr[trg_inds] = trg_inds +infdate_arr[trg_inds] = date_vals +ts_inds = sc.findinds(~np.isnan(trg_arr) * ~np.isnan(src_arr)) +v_src = np.array(src_arr[ts_inds], dtype=np.int64) +v_trg = np.array(trg_arr[ts_inds], dtype=np.int64) +vinfdates = infdate_arr[v_trg] # Valid target-source pair infection dates +ainfdates = infdate_arr[trg_inds] # All infection dates + +# Populate main things +ddf.loc[v_trg, 'source'] = v_src +ddf.loc[trg_inds, 'target'] = trg_inds +ddf.loc[trg_inds, 'date'] = ainfdates +ddf.loc[trg_inds, 'layer'] = layer_vals + +# Populate from people +for attr in attrs+quar_attrs: + ddf.loc[:, trg+attr] = people[attr][:] + ddf.loc[v_trg, src+attr] = people[attr][v_src] + +# Replace nan with false +def fillna(cols): + cols = sc.promotetolist(cols) + filldict = {k:False for k in cols} + ddf.fillna(value=filldict, inplace=True) + return + +# Pull out valid indices for source and target +ddf.loc[v_trg, src+'is_quarantined'] = (ddf.loc[v_trg, src+'date_quarantined'] <= vinfdates) & ~(ddf.loc[v_trg, src+'date_quarantined'] <= vinfdates) +fillna(src+'is_quarantined') +for is_attr,date_attr in zip(is_attrs, date_attrs): + ddf.loc[v_trg, src+is_attr] = (ddf.loc[v_trg, src+date_attr] <= vinfdates) + fillna(src+is_attr) + +ddf.loc[v_trg, src+'is_asymp'] = np.isnan(ddf.loc[v_trg, src+'date_symptomatic']) +ddf.loc[v_trg, src+'is_presymp'] = ~ddf.loc[v_trg, src+'is_asymp'] & ~ddf.loc[v_trg, src+'is_symptomatic'] +ddf.loc[trg_inds, trg+'is_quarantined'] = (ddf.loc[trg_inds, trg+'date_quarantined'] <= ainfdates) & ~(ddf.loc[trg_inds, trg+'date_end_quarantine'] <= ainfdates) +fillna(trg+'is_quarantined') + +sc.toc() + + +sc.heading('Original implementation (dicts)...') + +sc.tic() + +detailed = [None]*len(sim.people) + +for transdict in sim.people.infection_log: + + # Pull out key quantities + ddict = {} + source = transdict['source'] + target = transdict['target'] + + # If the source is available (e.g. not a seed infection), loop over both it and the target + if source is not None: + stdict = {'src_':source, 'trg_':target} + else: + stdict = {'trg_':target} + + # Pull out each of the attributes relevant to transmission + attrs = ['age', 'date_exposed', 'date_symptomatic', 'date_tested', 'date_diagnosed', 'date_quarantined', 'date_end_quarantine', 'date_severe', 'date_critical', 'date_known_contact'] + for st,stind in stdict.items(): + for attr in attrs: + ddict[st+attr] = people[attr][stind] + if source is not None: + for attr in attrs: + if attr.startswith('date_'): + is_attr = attr.replace('date_', 'is_') # Convert date to a boolean, e.g. date_diagnosed -> is_diagnosed + if attr == 'date_quarantined': # This has an end date specified + ddict['src_'+is_attr] = ddict['src_'+attr] <= transdict['date'] and not (ddict['src_'+'date_end_quarantine'] <= ddict['date']) + elif attr != 'date_end_quarantine': # This is not a state + ddict['src_'+is_attr] = ddict['src_'+attr] <= transdict['date'] # These don't make sense for people just infected (targets), only sources + + ddict['src_'+'is_asymp'] = np.isnan(people.date_symptomatic[source]) + ddict['src_'+'is_presymp'] = ~ddict['src_'+'is_asymp'] and ~ddict['src_'+'is_symptomatic'] # Not asymptomatic and not currently symptomatic + ddict['trg_'+'is_quarantined'] = ddict['trg_'+'date_quarantined'] <= transdict['date'] and not (ddict['trg_'+'date_end_quarantine'] <= ddict['date']) # This is the only target date that it makes sense to define since it can happen before infection + + ddict.update(transdict) + detailed[target] = ddict + +sc.toc() + + +if validate: + sc.heading('Validation...') + sc.tic() + for i in range(len(detailed)): + sc.percentcomplete(step=i, maxsteps=len(detailed)-1, stepsize=5) + d_entry = detailed[i] + df_entry = ddf.iloc[i].to_dict() + tt_entry = tt.detailed.iloc[i].to_dict() + if d_entry is None: # If in the dict it's None, it should be nan in the dataframe + for entry in [df_entry, tt_entry]: + assert np.isnan(entry['target']) + else: + dkeys = list(d_entry.keys()) + dfkeys = list(df_entry.keys()) + ttkeys = list(tt_entry.keys()) + assert dfkeys == ttkeys + assert all([dk in dfkeys for dk in dkeys]) # The dataframe can have extra keys, but not the dict + for k in dkeys: + v_d = d_entry[k] + v_df = df_entry[k] + v_tt = tt_entry[k] + try: + assert np.isclose(v_d, v_df, v_tt, equal_nan=True) # If it's numeric, check they're close + except TypeError: + if v_d is None: + assert all(np.isnan([v_df, v_tt])) # If in the dict it's None, it should be nan in the dataframe + else: + assert v_d == v_df == v_tt # In all other cases, it should be an exact match + sc.toc() + print('\nValidation passed.') + + +print('Done.') \ No newline at end of file diff --git a/tests/devtests/dev_test_vaccine_efficiency.py b/tests/devtests/dev_test_vaccine_efficiency.py new file mode 100644 index 000000000..0e180d2fd --- /dev/null +++ b/tests/devtests/dev_test_vaccine_efficiency.py @@ -0,0 +1,87 @@ +''' +Calculate vaccine efficiency for protection against symptomatic covid after first dose +''' + +import numpy as np +import sciris as sc +import covasim as cv + +cv.check_version('>=3.0.0') + +vaccines = ['pfizer', 'moderna', 'az', 'j&j'] + +# construct analyzer to select placebo arm +class placebo_arm(cv.Analyzer): + def __init__(self, day, trial_size, **kwargs): + super().__init__(**kwargs) + self.day = day + self.trial_size = trial_size + return + + def initialize(self, sim=None): + self.placebo_inds = [] + self.initialized = True + return + + def apply(self, sim): + if sim.t == self.day: + eligible = cv.true(~np.isfinite(sim.people.date_exposed) & ~sim.people.vaccinated) + self.placebo_inds = eligible[cv.choose(len(eligible), min(self.trial_size, len(eligible)))] + return + +pars = { + 'pop_size': 20000, + 'beta': 0.015, + 'n_days': 120, + 'verbose': -1, +} + +# Define vaccine arm +trial_size = 2000 +start_trial = 20 + +def subtarget(sim): + ''' Select people who are susceptible ''' + if sim.t == start_trial: + eligible = cv.true(~np.isfinite(sim.people.date_exposed)) + inds = eligible[cv.choose(len(eligible), min(trial_size//2, len(eligible)))] + else: + inds = [] + return {'vals': [1.0 for ind in inds], 'inds': inds} + +# Initialize +sims = [] +for vaccine in vaccines: + vx = cv.vaccinate(vaccine=vaccine, days=[start_trial], prob=0.0, subtarget=subtarget) + sim = cv.Sim( + label=vaccine, + use_waning=True, + pars=pars, + interventions=vx, + analyzers=placebo_arm(day=start_trial, trial_size=trial_size//2) + ) + sims.append(sim) + +# Run +msim = cv.MultiSim(sims) +msim.run(keep_people=True) + +results = sc.objdict() +print('Vaccine efficiency for symptomatic covid:') +for sim in msim.sims: + vaccine = sim.label + vacc_inds = cv.true(sim.people.vaccinated) # Find trial arm indices, those who were vaccinated + placebo_inds = sim['analyzers'][0].placebo_inds + assert (len(set(vacc_inds).intersection(set(placebo_inds))) == 0) # Check that there is no overlap + # Calculate vaccine efficiency + VE = 1 - (np.isfinite(sim.people.date_symptomatic[vacc_inds]).sum() / + np.isfinite(sim.people.date_symptomatic[placebo_inds]).sum()) + results[vaccine] = VE + print(f' {vaccine:8s}: {VE*100:0.2f}%') + +# Plot +to_plot = cv.get_default_plots('default', 'scen') +to_plot['Vaccinations'] = ['cum_vaccinated'] +msim.plot(to_plot=to_plot) + +print('Done') \ No newline at end of file diff --git a/tests/devtests/one_line.py b/tests/devtests/one_line.py new file mode 100644 index 000000000..f4c2af9fb --- /dev/null +++ b/tests/devtests/one_line.py @@ -0,0 +1,18 @@ +''' +Runs a fairly complex analysis using a single line of code. Equivalent to: + + pars = dict( + pop_size = 1e3, + pop_infected = 10, + pop_type = 'hybrid', + n_days = 180, + ) + cb = cv.change_beta([30, 50], [0.0, 1.0], layers=['w','c']) + + sim = cv.Sim(**pars, interventions=cb) + sim.run() + sim.plot(to_plot='seir') +''' +import covasim as cv + +cv.Sim(pop_size=1e3, pop_infected=10, pop_type='hybrid', n_days=180, interventions=cv.change_beta([30, 50], [0.0, 1.0], layers=['w','c'])).run().plot(to_plot='seir') \ No newline at end of file diff --git a/tests/devtests/show_colors.py b/tests/devtests/show_colors.py new file mode 100644 index 000000000..3d69221ea --- /dev/null +++ b/tests/devtests/show_colors.py @@ -0,0 +1,36 @@ +''' +Plot all the colors used in Covasim. +''' + +import covasim as cv +import pylab as pl +import numpy as np +import sciris as sc + +# Get colors +sim = cv.Sim(pop_size=1e3, verbose=0).run() +colors = {k:res.color for k,res in sim.results.items() if isinstance(res, cv.Result)} # colors = cv.get_colors() +d = sc.objdict() +for key in ['cum', 'new', 'n', 'other']: + d[key] = sc.objdict() + +# Parse into subdictionaries +for k,v in colors.items(): + if k.startswith('cum_'): d.cum[k] = v + elif k.startswith('n_'): d.n[k] = v + elif k.startswith('new_'): d.new[k] = v + else: d.other[k] = v + +# Plot +cv.options.set(dpi=150) +pl.figure(figsize=(24,18)) +for i,k,colors in d.enumitems(): + pl.subplot(2,2,i+1) + pl.title(k) + n = len(colors) + y = n-np.arange(n) + pl.barh(y, width=1, color=colors.values()) + pl.gca().set_yticks(y) + pl.gca().set_yticklabels(colors.keys()) + +pl.show() \ No newline at end of file diff --git a/tests/devtests/test_analyzer_to_json.py b/tests/devtests/test_analyzer_to_json.py new file mode 100644 index 000000000..155327ed4 --- /dev/null +++ b/tests/devtests/test_analyzer_to_json.py @@ -0,0 +1,35 @@ +''' +Confirm that with default settings, all analyzers can be exported as JSONs. +''' + +import sciris as sc +import covasim as cv + +datafile = sc.thisdir(__file__, aspath=True).parent / 'example_data.csv' + +# Create and runt he sim +sim = cv.Sim(analyzers=[cv.snapshot(days='2020-04-04'), + cv.age_histogram(), + cv.daily_age_stats(), + cv.daily_stats()], + datafile=datafile) +sim.run() + +# Compute extra analyzers +tt = sim.make_transtree() +fit = sim.compute_fit() + +# Construct list of all analyzers +analyzers = sim['analyzers'] + [tt, fit] + +# Make jsons +jsons = {} +for an in analyzers: + print(f'Working on analyzer {an.label}...') + jsons[an.label] = an.to_json() + +# Compute memory +for k,json in jsons.items(): + sc.checkmem({k:json}) + +print('Done.') \ No newline at end of file diff --git a/tests/devtests/test_numba_parallelization.py b/tests/devtests/test_numba_parallelization.py new file mode 100644 index 000000000..c5c936137 --- /dev/null +++ b/tests/devtests/test_numba_parallelization.py @@ -0,0 +1,27 @@ +''' +Test different parallelization options +''' + +import covasim as cv +import sciris as sc + +# Set the parallelization to use -- 0 = none, 1 = safe, 2 = rand +parallel = 1 + +pars = dict( + pop_size = 1e6, + n_days = 200, + verbose = 0.1, +) + +cv.options.set(numba_cache=0, numba_parallel=parallel) + +parstr = f'Parallel={cv.options.numba_parallel}' +print('Initializing (always single core)') +sim = cv.Sim(**pars, label=parstr) +sim.initialize() + +print(f'Running ({parstr})') +sc.tic() +sim.run() +sc.toc(label=parstr) \ No newline at end of file diff --git a/tests/devtests/test_variants.py b/tests/devtests/test_variants.py new file mode 100644 index 000000000..c759e10bc --- /dev/null +++ b/tests/devtests/test_variants.py @@ -0,0 +1,424 @@ +import covasim as cv +import sciris as sc +import numpy as np + + +do_plot = 0 +do_show = 0 +do_save = 0 +debug = 0 + +base_pars = dict( + pop_size = 10e3, + verbose = -1, +) + +def test_simple(do_plot=False): + s1 = cv.Sim(base_pars).run() + s2 = cv.Sim(base_pars, n_days=300, use_waning=True).run() + if do_plot: + s1.plot() + s2.plot() + return + + +def test_import1strain(do_plot=False, do_show=True, do_save=False): + sc.heading('Test introducing a new strain partway through a sim') + + strain_pars = { + 'rel_beta': 1.5, + } + pars = { + 'beta': 0.01 + } + strain = cv.strain(strain_pars, days=1, n_imports=20, label='Strain 2: 1.5x more transmissible') + sim = cv.Sim(use_waning=True, pars=pars, strains=strain, analyzers=cv.snapshot(30, 60), **pars, **base_pars) + sim.run() + + return sim + + +def test_import2strains(do_plot=False, do_show=True, do_save=False): + sc.heading('Test introducing 2 new strains partway through a sim') + + b117 = cv.strain('b117', days=1, n_imports=20) + p1 = cv.strain('sa variant', days=2, n_imports=20) + sim = cv.Sim(use_waning=True, strains=[b117, p1], label='With imported infections', **base_pars) + sim.run() + + return sim + + +def test_importstrain_longerdur(do_plot=False, do_show=True, do_save=False): + sc.heading('Test introducing a new strain with longer duration partway through a sim') + + pars = sc.mergedicts(base_pars, { + 'n_days': 120, + }) + + strain_pars = { + 'rel_beta': 1.5, + } + + strain = cv.strain(strain=strain_pars, label='Custom strain', days=10, n_imports=30) + sim = cv.Sim(use_waning=True, pars=pars, strains=strain, label='With imported infections') + sim.run() + + return sim + + +def test_import2strains_changebeta(do_plot=False, do_show=True, do_save=False): + sc.heading('Test introducing 2 new strains partway through a sim, with a change_beta intervention') + + strain2 = {'rel_beta': 1.5, + 'rel_severe_prob': 1.3} + + strain3 = {'rel_beta': 2, + 'rel_symp_prob': 1.6} + + intervs = cv.change_beta(days=[5, 20, 40], changes=[0.8, 0.7, 0.6]) + strains = [cv.strain(strain=strain2, days=10, n_imports=20), + cv.strain(strain=strain3, days=30, n_imports=20), + ] + sim = cv.Sim(use_waning=True, interventions=intervs, strains=strains, label='With imported infections', **base_pars) + sim.run() + + return sim + + + +#%% Vaccination tests + +def test_vaccine_1strain(do_plot=False, do_show=True, do_save=False): + sc.heading('Test vaccination with a single strain') + + pars = sc.mergedicts(base_pars, { + 'beta': 0.015, + 'n_days': 120, + }) + + pfizer = cv.vaccinate(days=[20], vaccine='pfizer') + sim = cv.Sim( + use_waning=True, + pars=pars, + interventions=pfizer + ) + sim.run() + + to_plot = sc.objdict({ + 'New infections': ['new_infections'], + 'Cumulative infections': ['cum_infections'], + 'New reinfections': ['new_reinfections'], + }) + if do_plot: + sim.plot(do_save=do_save, do_show=do_show, fig_path='results/test_reinfection.png', to_plot=to_plot) + + return sim + + +def test_synthpops(): + sim = cv.Sim(use_waning=True, **sc.mergedicts(base_pars, dict(pop_size=5000, pop_type='synthpops'))) + sim.popdict = cv.make_synthpop(sim, with_facilities=True, layer_mapping={'LTCF': 'f'}) + sim.reset_layer_pars() + + # Vaccinate 75+, then 65+, then 50+, then 18+ on days 20, 40, 60, 80 + sim.vxsubtarg = sc.objdict() + sim.vxsubtarg.age = [75, 65, 50, 18] + sim.vxsubtarg.prob = [.05, .05, .05, .05] + sim.vxsubtarg.days = subtarg_days = [20, 40, 60, 80] + pfizer = cv.vaccinate(days=subtarg_days, vaccine='pfizer', subtarget=vacc_subtarg) + sim['interventions'] += [pfizer] + + sim.run() + return sim + + + +#%% Multisim and scenario tests + +def test_vaccine_1strain_scen(do_plot=False, do_show=True, do_save=False): + sc.heading('Run a basic sim with 1 strain, pfizer vaccine') + + # Define baseline parameters + n_runs = 3 + base_sim = cv.Sim(use_waning=True, pars=base_pars) + + # Vaccinate 75+, then 65+, then 50+, then 18+ on days 20, 40, 60, 80 + base_sim.vxsubtarg = sc.objdict() + base_sim.vxsubtarg.age = [75, 65, 50, 18] + base_sim.vxsubtarg.prob = [.05, .05, .05, .05] + base_sim.vxsubtarg.days = subtarg_days = [20, 40, 60, 80] + pfizer = cv.vaccinate(days=subtarg_days, vaccine='pfizer', subtarget=vacc_subtarg) + + # Define the scenarios + + scenarios = { + 'baseline': { + 'name': 'No Vaccine', + 'pars': {} + }, + 'pfizer': { + 'name': 'Pfizer starting on day 20', + 'pars': { + 'interventions': [pfizer], + } + }, + } + + metapars = {'n_runs': n_runs} + scens = cv.Scenarios(sim=base_sim, metapars=metapars, scenarios=scenarios) + scens.run() + + to_plot = sc.objdict({ + 'New infections': ['new_infections'], + 'Cumulative infections': ['cum_infections'], + 'New reinfections': ['new_reinfections'], + # 'Cumulative reinfections': ['cum_reinfections'], + }) + if do_plot: + scens.plot(do_save=do_save, do_show=do_show, fig_path='results/test_basic_vaccination.png', to_plot=to_plot) + + return scens + + +def test_vaccine_2strains_scen(do_plot=False, do_show=True, do_save=False): + sc.heading('Run a basic sim with b117 strain on day 10, pfizer vaccine day 20') + + # Define baseline parameters + n_runs = 3 + base_sim = cv.Sim(use_waning=True, pars=base_pars) + + # Vaccinate 75+, then 65+, then 50+, then 18+ on days 20, 40, 60, 80 + base_sim.vxsubtarg = sc.objdict() + base_sim.vxsubtarg.age = [75, 65, 50, 18] + base_sim.vxsubtarg.prob = [.01, .01, .01, .01] + base_sim.vxsubtarg.days = subtarg_days = [60, 150, 200, 220] + jnj = cv.vaccinate(days=subtarg_days, vaccine='j&j', subtarget=vacc_subtarg) + b1351 = cv.strain('b1351', days=10, n_imports=20) + p1 = cv.strain('p1', days=100, n_imports=100) + + # Define the scenarios + + scenarios = { + 'baseline': { + 'name': 'B1351 on day 10, No Vaccine', + 'pars': { + 'strains': [b1351] + } + }, + 'b1351': { + 'name': 'B1351 on day 10, J&J starting on day 60', + 'pars': { + 'interventions': [jnj], + 'strains': [b1351], + } + }, + 'p1': { + 'name': 'B1351 on day 10, J&J starting on day 60, p1 on day 100', + 'pars': { + 'interventions': [jnj], + 'strains': [b1351, p1], + } + }, + } + + metapars = {'n_runs': n_runs} + scens = cv.Scenarios(sim=base_sim, metapars=metapars, scenarios=scenarios) + scens.run(debug=debug) + + to_plot = sc.objdict({ + 'New infections': ['new_infections'], + 'Cumulative infections': ['cum_infections'], + 'New reinfections': ['new_reinfections'], + # 'Cumulative reinfections': ['cum_reinfections'], + }) + if do_plot: + scens.plot(do_save=do_save, do_show=do_show, fig_path='results/test_vaccine_b1351.png', to_plot=to_plot) + + return scens + + +def test_msim(do_plot=False): + sc.heading('Testing multisim...') + + # basic test for vaccine + b117 = cv.strain('b117', days=0) + sim = cv.Sim(use_waning=True, strains=[b117], **base_pars) + msim = cv.MultiSim(sim, n_runs=2) + msim.run() + msim.reduce() + + to_plot = sc.objdict({ + 'Total infections': ['cum_infections'], + 'New infections per day': ['new_infections'], + 'New Re-infections per day': ['new_reinfections'], + }) + + if do_plot: + msim.plot(to_plot=to_plot, do_save=0, do_show=1, legend_args={'loc': 'upper left'}, axis_args={'hspace': 0.4}, interval=35) + + return msim + + +def test_varyingimmunity(do_plot=False, do_show=True, do_save=False): + sc.heading('Test varying properties of immunity') + + # Define baseline parameters + n_runs = 3 + base_sim = cv.Sim(use_waning=True, n_days=400, pars=base_pars) + + # Define the scenarios + b1351 = cv.strain('b1351', days=100, n_imports=20) + + scenarios = { + 'baseline': { + 'name': 'Default Immunity (decay at log(2)/90)', + 'pars': { + 'nab_decay': dict(form='nab_decay', decay_rate1=np.log(2)/90, decay_time1=250, decay_rate2=0.001), + }, + }, + 'faster_immunity': { + 'name': 'Faster Immunity (decay at log(2)/30)', + 'pars': { + 'nab_decay': dict(form='nab_decay', decay_rate1=np.log(2)/30, decay_time1=250, decay_rate2=0.001), + }, + }, + 'baseline_b1351': { + 'name': 'Default Immunity (decay at log(2)/90), B1351 on day 100', + 'pars': { + 'nab_decay': dict(form='nab_decay', decay_rate1=np.log(2)/90, decay_time1=250, decay_rate2=0.001), + 'strains': [b1351], + }, + }, + 'faster_immunity_b1351': { + 'name': 'Faster Immunity (decay at log(2)/30), B1351 on day 100', + 'pars': { + 'nab_decay': dict(form='nab_decay', decay_rate1=np.log(2)/30, decay_time1=250, decay_rate2=0.001), + 'strains': [b1351], + }, + }, + } + + metapars = {'n_runs': n_runs} + scens = cv.Scenarios(sim=base_sim, metapars=metapars, scenarios=scenarios) + scens.run(debug=debug) + + to_plot = sc.objdict({ + 'New infections': ['new_infections'], + 'New re-infections': ['new_reinfections'], + 'Population Nabs': ['pop_nabs'], + 'Population Immunity': ['pop_protection'], + }) + if do_plot: + scens.plot(do_save=do_save, do_show=do_show, fig_path='results/test_basic_immunity.png', to_plot=to_plot) + + return scens + + +def test_waning_vs_not(do_plot=False, do_show=True, do_save=False): + sc.heading('Testing waning...') + + # Define baseline parameters + pars = sc.mergedicts(base_pars, { + 'pop_size': 10e3, + 'pop_scale': 50, + 'n_days': 150, + 'use_waning': False, + }) + + n_runs = 3 + base_sim = cv.Sim(pars=pars) + + # Define the scenarios + scenarios = { + 'no_waning': { + 'name': 'No waning', + 'pars': { + } + }, + 'waning': { + 'name': 'Waning', + 'pars': { + 'use_waning': True, + } + }, + } + + metapars = {'n_runs': n_runs} + scens = cv.Scenarios(sim=base_sim, metapars=metapars, scenarios=scenarios) + scens.run() + + to_plot = sc.objdict({ + 'New infections': ['new_infections'], + 'New reinfections': ['new_reinfections'], + 'Cumulative infections': ['cum_infections'], + 'Cumulative reinfections': ['cum_reinfections'], + }) + if do_plot: + scens.plot(do_save=do_save, do_show=do_show, fig_path='results/test_waning_vs_not.png', to_plot=to_plot) + + return scens + + +#%% Utilities + +def vacc_subtarg(sim): + ''' Subtarget by age''' + + # retrieves the first ind that is = or < sim.t + ind = get_ind_of_min_value(sim.vxsubtarg.days, sim.t) + age = sim.vxsubtarg.age[ind] + prob = sim.vxsubtarg.prob[ind] + inds = sc.findinds((sim.people.age>=age) * ~sim.people.vaccinated) + vals = prob*np.ones(len(inds)) + return {'inds':inds, 'vals':vals} + + +def get_ind_of_min_value(list, time): + ind = None + for place, t in enumerate(list): + if time >= t: + ind = place + + if ind is None: + errormsg = f'{time} is not within the list of times' + raise ValueError(errormsg) + return ind + + +#%% Run as a script +if __name__ == '__main__': + sc.tic() + + # Gather keywords + kw = dict(do_plot=do_plot, do_save=do_save, do_show=do_show) + + # Run simplest possible test + test_simple(do_plot=do_plot) + + # Run more complex single-sim tests + sim0 = test_import1strain(**kw) + sim1 = test_import2strains(**kw) + sim2 = test_importstrain_longerdur(**kw) + sim3 = test_import2strains_changebeta(**kw) + + # Run Vaccine tests + sim4 = test_synthpops() + sim5 = test_vaccine_1strain() + + # Run multisim and scenario tests + scens0 = test_vaccine_1strain_scen() + scens1 = test_vaccine_2strains_scen() + msim0 = test_msim() + + # Run immunity tests + sim_immunity0 = test_varyingimmunity(**kw) + + # Run test to compare sims with and without waning + scens2 = test_waning_vs_not(**kw) + + sc.toc() + + +print('Done.') + diff --git a/tests/immcov b/tests/immcov new file mode 100755 index 000000000..d837827a1 --- /dev/null +++ b/tests/immcov @@ -0,0 +1,14 @@ +#!/bin/bash +# Note that although the script runs when parallelized, the coverage results are wrong. + +echo 'Running tests...' +pytest -v test_immunity.py --cov-config=.coveragerc --cov=../covasim --durations=0 + +echo 'Creating HTML report...' +coverage html + +echo 'Printing report...' +coverage report + +echo 'Report location:' +echo "`pwd`/htmlcov/index.html" \ No newline at end of file diff --git a/tests/requirements_frozen.txt b/tests/requirements_frozen.txt new file mode 100644 index 000000000..5ee142483 --- /dev/null +++ b/tests/requirements_frozen.txt @@ -0,0 +1,52 @@ +backcall==0.2.0 +certifi==2020.12.5 +chardet==4.0.0 +ConnPlotter==0.7a0 +cycler==0.10.0 +decorator==4.4.2 +dill==0.3.3 +et-xmlfile==1.0.1 +gitdb==4.0.5 +GitPython==3.1.14 +idna==2.10 +ipython==7.21.0 +ipython-genutils==0.2.0 +jdcal==1.4.1 +jedi==0.18.0 +jellyfish==0.8.2 +jsonpickle==2.0.0 +kiwisolver==1.3.1 +line-profiler==3.1.0 +llvmlite==0.35.0 +matplotlib==3.3.4 +memory-profiler==0.58.0 +multiprocess==0.70.11.1 +numba==0.52.0 +numpy==1.20.1 +openpyexcel==2.5.14 +pandas==1.2.3 +parso==0.8.1 +patsy==0.5.1 +pexpect==4.8.0 +pickleshare==0.7.5 +Pillow==8.1.2 +prompt-toolkit==3.0.16 +psutil==5.8.0 +ptyprocess==0.7.0 +Pygments==2.8.1 +PyNEST==2.16.0 +pyparsing==2.4.7 +python-dateutil==2.8.1 +pytz==2021.1 +requests==2.25.1 +scipy==1.6.1 +sciris==1.0.2 +six==1.15.0 +smmap==3.0.5 +statsmodels==0.12.2 +Topology==2.16.0 +traitlets==5.0.5 +urllib3==1.26.4 +wcwidth==0.2.5 +xlrd==1.2.0 +XlsxWriter==1.3.7 \ No newline at end of file diff --git a/tests/requirements_test.txt b/tests/requirements_test.txt new file mode 100644 index 000000000..0a408d037 --- /dev/null +++ b/tests/requirements_test.txt @@ -0,0 +1,4 @@ +pytest +pytest-cov +pytest-parallel +optuna \ No newline at end of file diff --git a/tests/state_diagram.xlsx b/tests/state_diagram.xlsx new file mode 100644 index 000000000..dfe09e0b5 Binary files /dev/null and b/tests/state_diagram.xlsx differ diff --git a/tests/test_analysis.py b/tests/test_analysis.py index 6700393e4..2559b76de 100644 --- a/tests/test_analysis.py +++ b/tests/test_analysis.py @@ -1,10 +1,11 @@ ''' -Execute analysis tools in order to broadly cover basic functionality of analysis.py +Tests for the analyzers and other analysis tools. ''' import numpy as np import sciris as sc import covasim as cv +import pytest #%% General settings @@ -45,24 +46,41 @@ def test_age_hist(): sim.run() # Checks to see that compute windows returns correct number of results + sim.make_age_histogram() # Show post-hoc example agehist = sim.get_analyzer() agehist.compute_windows() agehist.get() # Not used, but check get agehist.get(day_list[1]) - assert len(age_analyzer.window_hists) == len(day_list), "Number of histograms should equal number of days" + assert len(agehist.window_hists) == len(day_list), "Number of histograms should equal number of days" # Check plot() if do_plot: plots = agehist.plot(windows=True) assert len(plots) == len(day_list), "Number of plots generated should equal number of days" + # Check daily age histogram + daily_age = cv.daily_age_stats() + sim = cv.Sim(pars, analyzers=daily_age) + sim.run() + return agehist +def test_daily_age(): + sc.heading('Testing daily age analyzer') + sim = cv.Sim(pars, analyzers=cv.daily_age_stats()) + sim.run() + daily_age = sim.get_analyzer() + if do_plot: + daily_age.plot() + daily_age.plot(total=True) + return daily_age + + def test_daily_stats(): sc.heading('Testing daily stats analyzer') - ds = cv.daily_stats(days=['2020-04-04', '2020-04-14'], save_inds=True) - sim = cv.Sim(pars, analyzers=ds) + ds = cv.daily_stats(days=['2020-04-04'], save_inds=True) + sim = cv.Sim(pars, n_days=40, analyzers=ds) sim.run() daily = sim.get_analyzer() if do_plot: @@ -90,12 +108,58 @@ def test_fit(): assert fit1.mismatch != fit2.mismatch, "Differences between fit and data remains unchanged after changing sim seed" + # Test custom analyzers + actual = np.array([1,2,4]) + predicted = np.array([1,2,3]) + + def simple(actual, predicted, scale=2): + return np.sum(abs(actual - predicted))*scale + + gof1 = cv.compute_gof(actual, predicted, normalize=False, as_scalar='sum') + gof2 = cv.compute_gof(actual, predicted, estimator=simple, scale=1.0) + assert gof1 == gof2 + with pytest.raises(Exception): + cv.compute_gof(actual, predicted, skestimator='not an estimator') + with pytest.raises(Exception): + cv.compute_gof(actual, predicted, estimator='not an estimator') + if do_plot: fit1.plot() return fit1 +def test_calibration(): + sc.heading('Testing calibration') + + pars = dict( + verbose = 0, + start_day = '2020-02-05', + pop_size = 1e3, + pop_scale = 4, + interventions = [cv.test_prob(symp_prob=0.1)], + ) + + sim = cv.Sim(pars, datafile='example_data.csv') + + calib_pars = dict( + beta = [0.013, 0.005, 0.020], + test_prob = [0.01, 0.00, 0.30] + ) + + def set_test_prob(sim, calib_pars): + tp = sim.get_intervention(cv.test_prob) + tp.symp_prob = calib_pars['test_prob'] + return sim + + calib = sim.calibrate(calib_pars=calib_pars, custom_fn=set_test_prob, n_trials=5) + calib.plot(to_plot=['cum_deaths', 'cum_diagnoses']) + + assert calib.after.fit.mismatch < calib.before.fit.mismatch + + return calib + + def test_transtree(): sc.heading('Testing transmission tree') @@ -128,8 +192,10 @@ def test_transtree(): snapshot = test_snapshot() agehist = test_age_hist() + daily_age = test_daily_age() daily = test_daily_stats() fit = test_fit() + calib = test_calibration() transtree = test_transtree() print('\n'*2) diff --git a/tests/test_baselines.py b/tests/test_baselines.py index 99b110b40..48f7c2ea4 100644 --- a/tests/test_baselines.py +++ b/tests/test_baselines.py @@ -1,12 +1,13 @@ """ -Compare current results to baseline +Test that the current version of Covasim exactly matches +the baseline results. """ import numpy as np import sciris as sc import covasim as cv -do_plot = 1 +do_plot = 0 do_save = 0 baseline_filename = sc.thisdir(__file__, 'baseline.json') benchmark_filename = sc.thisdir(__file__, 'benchmark.json') @@ -92,20 +93,19 @@ def test_baseline(): return new -def test_benchmark(do_save=do_save): +def test_benchmark(do_save=do_save, repeats=1): ''' Compare benchmark performance ''' print('Running benchmark...') previous = sc.loadjson(benchmark_filename) - repeats = 5 t_inits = [] t_runs = [] def normalize_performance(): ''' Normalize performance across CPUs -- simple Numpy calculation ''' t_bls = [] - bl_repeats = 5 + bl_repeats = 3 n_outer = 10 n_inner = 1e6 for r in range(bl_repeats): @@ -186,9 +186,9 @@ def normalize_performance(): cv.options.set(interactive=do_plot) T = sc.tic() - make_sim(do_plot=do_plot) - json = test_benchmark(do_save=do_save) # Run this first so benchmarking is available even if results are different + json = test_benchmark(do_save=do_save, repeats=5) # Run this first so benchmarking is available even if results are different new = test_baseline() + make_sim(do_plot=do_plot) print('\n'*2) sc.toc(T) diff --git a/tests/test_immunity.py b/tests/test_immunity.py new file mode 100644 index 000000000..ba315b418 --- /dev/null +++ b/tests/test_immunity.py @@ -0,0 +1,230 @@ +''' +Tests for immune waning, strains, and vaccine intervention. +''' + +#%% Imports and settings +import sciris as sc +import covasim as cv +import pandas as pd +import pylab as pl + +do_plot = 1 +cv.options.set(interactive=False) # Assume not running interactively + +# Shared parameters arcross simulations +base_pars = dict( + pop_size = 1e3, + verbose = -1, +) + + +#%% Define the tests + +def test_states(): + ''' Test state consistency against state_diagram.xlsx ''' + + filename = 'state_diagram.xlsx' + sheets = ['Without waning', 'With waning'] + indexcol = 'In ↓ you can be →' + + # Load state diagram + dfs = sc.odict() + for sheet in sheets: + dfs[sheet] = pd.read_excel(filename, sheet_name=sheet) + dfs[sheet] = dfs[sheet].set_index(indexcol) + + # Create and run simulation + for use_waning in [False, True]: + sc.heading(f'Testing state consistency with waning = {use_waning}') + df = dfs[use_waning] # Different states are possible with or without waning + + # Parameters chosen to be midway through the sim so as few states as possible are empty + pars = dict( + pop_size = 1e3, + pop_infected = 20, + n_days = 70, + use_waning = use_waning, + verbose = 0, + interventions = [ + cv.test_prob(symp_prob=0.4, asymp_prob=0.01), + cv.contact_tracing(trace_probs=0.1), + cv.simple_vaccine(days=60, prob=0.1) + ] + ) + sim = cv.Sim(pars).run() + ppl = sim.people + + # Check states + errormsg = '' + states = df.columns.values.tolist() + for s1 in states: + for s2 in states: + if s1 != s2: + relation = df.loc[s1, s2] # e.g. df.loc['susceptible', 'exposed'] + print(f'Checking {s1:13s} → {s2:13s} = {relation:2n} ... ', end='') + inds = cv.true(ppl[s1]) + n_inds = len(inds) + vals2 = ppl[s2][inds] + is_true = cv.true(vals2) + is_false = cv.false(vals2) + n_true = len(is_true) + n_false = len(is_false) + if relation == 1 and n_true != n_inds: + errormsg = f'Being {s1}=True implies {s2}=True, but only {n_true}/{n_inds} people are' + print(f'× {n_true}/{n_inds} error!') + elif relation == -1 and n_false != n_inds: + errormsg = f'Being {s1}=True implies {s2}=False, but only {n_false}/{n_inds} people are' + print(f'× {n_false}/{n_inds} error!') + else: + print(f'✓ {n_true}/{n_inds}') + if errormsg: + raise RuntimeError(errormsg) + + return + + +def test_waning(do_plot=False): + sc.heading('Testing with and without waning') + msims = dict() + + for rescale in [0, 1]: + print(f'Checking with rescale = {rescale}...') + + # Define parameters specific to this test + pars = dict( + n_days = 90, + beta = 0.008, + nab_decay = dict(form='nab_decay', decay_rate1=0.1, decay_time1=250, decay_rate2=0.001) + ) + + # Optionally include rescaling + if rescale: + pars.update( + pop_scale = 10, + rescale_factor = 2.0, # Use a large rescale factor to make differences more obvious + ) + + # Run the simulations and pull out the results + s0 = cv.Sim(base_pars, **pars, use_waning=False, label='No waning').run() + s1 = cv.Sim(base_pars, **pars, use_waning=True, label='With waning').run() + res0 = s0.summary + res1 = s1.summary + msim = cv.MultiSim([s0,s1]) + msims[rescale] = msim + + + # Check results + for key in ['n_susceptible', 'cum_infections', 'cum_reinfections', 'pop_nabs', 'pop_protection', 'pop_symp_protection']: + v0 = res0[key] + v1 = res1[key] + print(f'Checking {key:20s} ... ', end='') + assert v1 > v0, f'Expected {key} to be higher with waning than without' + print(f'✓ ({v1} > {v0})') + + # Optionally plot + if do_plot: + msim.plot('overview-strain', rotation=30) + + return msims + + +def test_strains(do_plot=False): + sc.heading('Testing strains...') + + b117 = cv.strain('b117', days=10, n_imports=20) + p1 = cv.strain('sa variant', days=20, n_imports=20) + cust = cv.strain(label='Custom', days=40, n_imports=20, strain={'rel_beta': 2, 'rel_symp_prob': 1.6}) + sim = cv.Sim(base_pars, use_waning=True, strains=[b117, p1, cust]) + sim.run() + + if do_plot: + sim.plot('overview-strain') + + return sim + + +def test_vaccines(do_plot=False): + sc.heading('Testing vaccines...') + + p1 = cv.strain('sa variant', days=20, n_imports=20) + pfizer = cv.vaccinate(vaccine='pfizer', days=30) + sim = cv.Sim(base_pars, use_waning=True, strains=p1, interventions=pfizer) + sim.run() + + if do_plot: + sim.plot('overview-strain') + + return sim + + +def test_decays(do_plot=False): + sc.heading('Testing decay parameters...') + + n = 300 + x = pl.arange(n) + + pars = sc.objdict( + nab_decay = dict( + func = cv.immunity.nab_decay, + length = n, + decay_rate1 = 0.05, + decay_time1= 100, + decay_rate2 = 0.002, + ), + + exp_decay = dict( + func = cv.immunity.exp_decay, + length = n, + init_val = 0.8, + half_life= 100, + delay = 20, + ), + + linear_decay = dict( + func = cv.immunity.linear_decay, + length = n, + init_val = 0.8, + slope = 0.01, + ), + + linear_growth = dict( + func = cv.immunity.linear_growth, + length = n, + slope = 0.01, + ), + ) + + # Calculate all the delays + res = sc.objdict() + for key,par in pars.items(): + func = par.pop('func') + res[key] = func(**par) + + if do_plot: + pl.figure(figsize=(12,8)) + for key,y in res.items(): + pl.semilogy(x, y, label=key, lw=3, alpha=0.7) + pl.legend() + pl.show() + + res.x = x + + return res + + + +#%% Run as a script +if __name__ == '__main__': + + # Start timing and optionally enable interactive plotting + cv.options.set(interactive=do_plot) + T = sc.tic() + + sim1 = test_states() + msims1 = test_waning(do_plot=do_plot) + sim2 = test_strains(do_plot=do_plot) + sim3 = test_vaccines(do_plot=do_plot) + res = test_decays(do_plot=do_plot) + + sc.toc(T) + print('Done.') diff --git a/tests/test_interventions.py b/tests/test_interventions.py index 437883d8f..46b997e94 100644 --- a/tests/test_interventions.py +++ b/tests/test_interventions.py @@ -1,30 +1,45 @@ ''' -Demonstrate all interventions, taken from intervention docstrings +Tests covering all the built-in interventions, mostly taken +from the intervention's docstrings. ''' #%% Housekeeping import os import sciris as sc +import numpy as np import pylab as pl import covasim as cv import pytest -verbose = 0 -do_plot = 1 # Whether to plot when run interactively +verbose = -1 +do_plot = 0 # Whether to plot when run interactively cv.options.set(interactive=False) # Assume not running interactively csv_file = os.path.join(sc.thisdir(), 'example_data.csv') -def test_all_interventions(): +def test_all_interventions(do_plot=False): ''' Test all interventions supported by Covasim ''' + sc.heading('Testing default interventions') + # Default parameters, using the random layer pars = sc.objdict( pop_size = 1e3, pop_infected = 10, - pop_type = 'hybrid', n_days = 90, + verbose = verbose, ) + hpars = sc.mergedicts(pars, {'pop_type':'hybrid'}) # Some, but not all, tests require layers + rsim = cv.Sim(pars) + hsim = cv.Sim(hpars) + + def make_sim(which='r', interventions=None): + ''' Helper function to avoid having to recreate the sim each time ''' + if which == 'r': sim = sc.dcp(rsim) + elif which == 'h': sim = sc.dcp(hsim) + sim['interventions'] = interventions + sim.initialize() + return sim #%% Define the interventions @@ -65,34 +80,38 @@ def test_all_interventions(): i7b = cv.contact_tracing(start_day=20, trace_probs=dict(h=0.9, s=0.7, w=0.7, c=0.3), trace_time=dict(h=0, s=1, w=1, c=3)) - # 8. Combination - i8a = cv.clip_edges(days=18, changes=0.0, layers='s') # Close schools + # 8. Combination, with dynamically set days + def check_inf(interv, sim, thresh=10, close_day=18): + days = close_day if sim.people.infectious.sum()>thresh else np.nan + return days + + i8a = cv.clip_edges(days=check_inf, changes=0.0, layers='s') # Close schools i8b = cv.clip_edges(days=[20, 32, 45], changes=[0.7, 0.3, 0.9], layers=['w', 'c']) # Reduce work and community i8c = cv.test_prob(start_day=38, symp_prob=0.01, asymp_prob=0.0, symp_quar_prob=1.0, asymp_quar_prob=1.0, test_delay=2) # Start testing for TTQ i8d = cv.contact_tracing(start_day=40, trace_probs=dict(h=0.9, s=0.7, w=0.7, c=0.3), trace_time=dict(h=0, s=1, w=1, c=3)) # Start tracing for TTQ # 9. Vaccine - i9a = cv.vaccine(days=20, prob=1.0, rel_sus=1.0, rel_symp=0.0) - i9b = cv.vaccine(days=50, prob=1.0, rel_sus=0.0, rel_symp=0.0) + i9a = cv.simple_vaccine(days=20, prob=1.0, rel_sus=1.0, rel_symp=0.0) + i9b = cv.simple_vaccine(days=50, prob=1.0, rel_sus=0.0, rel_symp=0.0) #%% Create the simulations sims = sc.objdict() - sims.dynamic = cv.Sim(pars=pars, interventions=[i1a, i1b]) - sims.sequence = cv.Sim(pars=pars, interventions=i2) - sims.change_beta1 = cv.Sim(pars=pars, interventions=i3a) - sims.clip_edges1 = cv.Sim(pars=pars, interventions=i4a) # Roughly equivalent to change_beta1 - sims.change_beta2 = cv.Sim(pars=pars, interventions=i3b) - sims.clip_edges2 = cv.Sim(pars=pars, interventions=i4b) # Roughly equivalent to change_beta2 - sims.test_num = cv.Sim(pars=pars, interventions=i5) - sims.test_prob = cv.Sim(pars=pars, interventions=i6) - sims.tracing = cv.Sim(pars=pars, interventions=[i7a, i7b]) - sims.combo = cv.Sim(pars=pars, interventions=[i8a, i8b, i8c, i8d]) - sims.vaccine = cv.Sim(pars=pars, interventions=[i9a, i9b]) + sims.dynamic = make_sim('r', [i1a, i1b]) + sims.sequence = make_sim('r', i2) + sims.change_beta1 = make_sim('h', i3a) + sims.clip_edges1 = make_sim('h', i4a) # Roughly equivalent to change_beta1 + sims.change_beta2 = make_sim('r', i3b) + sims.clip_edges2 = make_sim('r', i4b) # Roughly equivalent to change_beta2 + sims.test_num = make_sim('r', i5) + sims.test_prob = make_sim('r', i6) + sims.tracing = make_sim('h', [i7a, i7b]) + sims.combo = make_sim('h', [i8a, i8b, i8c, i8d]) + sims.vaccine = make_sim('r', [i9a, i9b]) # Run the simualations for key,sim in sims.items(): sim.label = key - sim.run(verbose=verbose) + sim.run() # Test intervention retrieval methods sim = sims.combo @@ -147,7 +166,7 @@ def test_data_interventions(): cv.options.set(interactive=do_plot) T = sc.tic() - test_all_interventions() + test_all_interventions(do_plot=do_plot) test_data_interventions() sc.toc(T) diff --git a/tests/test_other.py b/tests/test_other.py index c76abd367..f24f54549 100644 --- a/tests/test_other.py +++ b/tests/test_other.py @@ -6,11 +6,12 @@ #%% Imports and settings import os import pytest +import numpy as np import sciris as sc import covasim as cv do_plot = 1 -verbose = 0 +verbose = -1 debug = 1 # This runs without parallelization; faster with pytest csv_file = os.path.join(sc.thisdir(), 'example_data.csv') xlsx_file = os.path.join(sc.thisdir(), 'example_data.xlsx') @@ -29,7 +30,7 @@ def remove_files(*args): #%% Define the tests def test_base(): - sc.heading('Testing base.py...') + sc.heading('Testing base.py sim...') json_path = 'base_tests.json' sim_path = 'base_tests.sim' @@ -67,6 +68,19 @@ def test_base(): sim.save(filename=sim_path, keep_people=keep_people) cv.Sim.load(sim_path) + # Tidy up + remove_files(json_path, sim_path) + + return + + +def test_basepeople(): + sc.heading('Testing base.py people and contacts...') + + # Create a small sim for later use + sim = cv.Sim(pop_size=100, verbose=verbose) + sim.initialize() + # BasePeople methods ppl = sim.people ppl.get(['susceptible', 'infectious']) @@ -76,8 +90,8 @@ def test_base(): ppl.date_keys() ppl.dur_keys() ppl.indices() - ppl._resize_arrays(pop_size=200) # This only resizes the arrays, not actually create new people - ppl._resize_arrays(pop_size=100) # Change back + ppl._resize_arrays(new_size=200) # This only resizes the arrays, not actually create new people + ppl._resize_arrays(new_size=100) # Change back ppl.to_df() ppl.to_arr() ppl.person(50) @@ -104,8 +118,38 @@ def test_base(): df = hospitals_layer.to_df() hospitals_layer.from_df(df) - # Tidy up - remove_files(json_path, sim_path) + # Generate an average of 10 contacts for 1000 people + n = 10_000 + n_people = 1000 + p1 = np.random.randint(n_people, size=n) + p2 = np.random.randint(n_people, size=n) + beta = np.ones(n) + layer = cv.Layer(p1=p1, p2=p2, beta=beta) + + # Convert one layer to another with extra columns + index = np.arange(n) + self_conn = p1 == p2 + layer2 = cv.Layer(**layer, index=index, self_conn=self_conn) + assert len(layer2) == n + assert len(layer2.keys()) == 5 + + # Test dynamic layers, plotting, and stories + pars = dict(pop_size=100, n_days=10, verbose=verbose, pop_type='hybrid', beta=0.02) + s1 = cv.Sim(pars, dynam_layer={'c':1}) + s1.run() + s1.people.plot() + for person in [0, 50]: + s1.people.story(person) + + # Run without dynamic layers and assert that the results are different + s2 = cv.Sim(pars, dynam_layer={'c':0}) + s2.run() + assert cv.diff_sims(s1, s2, output=True) + + # Create a bare People object + ppl = cv.People(100) + with pytest.raises(sc.KeyNotFoundError): # Need additional parameters + ppl.initialize() return @@ -198,19 +242,6 @@ def test_misc(): return -def test_people(): - sc.heading('Testing people') - - # Test dynamic layers - sim = cv.Sim(pop_size=100, n_days=10, verbose=verbose, dynam_layer={'a':1}) - sim.run() - sim.people.plot() - for person in [25, 79]: - sim.people.story(person) - - return - - def test_plotting(): sc.heading('Testing plotting') @@ -393,6 +424,9 @@ def test_sim(): sim['interventions'] = {'which': 'change_beta', 'pars': {'days': 10, 'changes': 0.5}} sim.validate_pars() + # Check conversion to absolute parameters + cv.parameters.absolute_prognoses(sim['prognoses']) + # Test intervention functions and results analyses cv.Sim(pop_size=100, verbose=0, interventions=lambda sim: (sim.t==20 and (sim.__setitem__('beta', 0) or print(f'Applying lambda intervention to set beta=0 on day {sim.t}')))).run() # ...This is not the recommended way of defining interventions. @@ -424,8 +458,8 @@ def test_settings(): T = sc.tic() test_base() + test_basepeople() test_misc() - test_people() test_plotting() test_population() test_requirements() diff --git a/tests/test_regression.py b/tests/test_regression.py index 5eb62a058..edc4a30ce 100644 --- a/tests/test_regression.py +++ b/tests/test_regression.py @@ -44,12 +44,12 @@ def test_regression(): sim2 = make_sim() # Check that they match - cv.diff_sims(sim1, sim2, die=True) + cv.diff_sims(sim1, sim2, skip_key_diffs=True, die=True) # Confirm that non-matching sims don't match sim3 = make_sim(beta=0.02123) with pytest.raises(ValueError): - cv.diff_sims(sim1, sim3, die=True) + cv.diff_sims(sim1, sim3, skip_key_diffs=True, die=True) return sim1, sim2 diff --git a/tests/test_resume.py b/tests/test_resume.py index b7d5a8823..62a2ef171 100644 --- a/tests/test_resume.py +++ b/tests/test_resume.py @@ -1,5 +1,5 @@ ''' -Test resuming a simulation partway, as well as reproducing two simulations with +Tests for resuming a simulation partway, as well as reproducing two simulations with different initialization states and after saving to disk. ''' @@ -31,15 +31,21 @@ def test_resuming(): with pytest.raises(cv.AlreadyRunError): s1.run(until=0, reset_seed=False) assert s1.initialized # It should still have been initialized though + with pytest.raises(RuntimeError): + s1.compute_summary(require_run=True) # Not ready yet s1.run(until='2020-01-31', reset_seed=False) with pytest.raises(cv.AlreadyRunError): s1.run(until=30, reset_seed=False) # Error if running up to the same value with pytest.raises(cv.AlreadyRunError): s1.run(until=20, reset_seed=False) # Error if running until a previous timestep + with pytest.raises(cv.AlreadyRunError): + s1.run(until=1000, reset_seed=False) # Error if running until the end of the sim s1.run(until=45, reset_seed=False) s1.run(reset_seed=False) + with pytest.raises(cv.AlreadyRunError): + s1.finalize() # Can't re-finalize a finalized sim assert np.all(s0.results['cum_infections'].values == s1.results['cum_infections']) # Results should be identical @@ -67,6 +73,7 @@ def test_reproducibility(): sc.heading('Test that sims are reproducible') fn = 'save-load-test.sim' # Name of the test file to save + key = 'cum_infections' #The results of the sim shouldn't be affected by what you do or don't do prior to sim.run() s1 = cv.Sim(pars) @@ -74,28 +81,41 @@ def test_reproducibility(): s2 = s1.copy() s1.run() s2.run() - r1ci = s1.summary['cum_infections'] - r2ci = s2.summary['cum_infections'] - assert r1ci == r2ci + r1 = s1.summary[key] + r2 = s2.summary[key] + assert r1 == r2 # If you run a sim and save it, you should be able to re-run it on load - s3 = cv.Sim(pars) + s3 = cv.Sim(pars, pop_infected=44) s3.run() s3.save(fn) s4 = cv.load(fn) s4.initialize() s4.run() - r3ci = s3.summary['cum_infections'] - r4ci = s4.summary['cum_infections'] - assert r3ci == r4ci + r3 = s3.summary[key] + r4 = s4.summary[key] + assert r3 == r4 if os.path.exists(fn): # Tidy up -- after the assert to allow inspection if it fails os.remove(fn) + # Running a sim and resetting people should result in the same result; otherwise they should differ + s5 = cv.Sim(pars) + s5.run() + r5 = s5.summary[key] + s5.initialize(reset=True) + s5.run() + r6 = s5.summary[key] + s5.initialize(reset=False) + s5.run() + r7 = s5.summary[key] + assert r5 == r6 + assert r5 != r7 + return s4 def test_step(): # If being run via pytest, turn off - sc.heading('Test starting and stopping') + sc.heading('Test stepping') # Create and run a basic simulation s1 = cv.Sim(pars) @@ -115,6 +135,22 @@ def test_step(): # If being run via pytest, turn off return s2 +def test_stopping(): # If being run via pytest, turn off + sc.heading('Test stopping') + + # Run a sim with very short time limit + s1 = cv.Sim(pars, timelimit=0) + s1.run() + + # Run a sim with a stopping function + def stopping_func(sim): return True + s2 = cv.Sim(pars, stopping_func=stopping_func) + s2.run() + s2.finalize() + + return s1 + + #%% Run as a script if __name__ == '__main__': @@ -124,6 +160,7 @@ def test_step(): # If being run via pytest, turn off sim2 = test_reset_seed() sim3 = test_reproducibility() sim4 = test_step() + sim5 = test_stopping() print('\n'*2) sc.toc(T) diff --git a/tests/test_run.py b/tests/test_run.py index 367da0a9f..693531fe9 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -1,5 +1,5 @@ ''' -Test run options (multisims and scenarios) +Tests for run options (multisims and scenarios) ''' #%% Imports and settings diff --git a/tests/test_sim.py b/tests/test_sim.py index da2079999..c568b0616 100644 --- a/tests/test_sim.py +++ b/tests/test_sim.py @@ -1,5 +1,5 @@ ''' -Simple example usage for the Covid-19 agent-based model +Tests for single simulations ''' #%% Imports and settings diff --git a/tests/test_utils.py b/tests/test_utils.py index 3bdff4b02..d5dc271e7 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,5 +1,5 @@ ''' -Tests of the utilies for the model. +Tests of the numerical utilities for the model. ''' #%% Imports and settings @@ -71,11 +71,11 @@ def test_poisson(): return s3 -def test_samples(do_plot=False): +def test_samples(do_plot=False, verbose=True): sc.heading('Samples distribution') - n = 10000 - nbins = 40 + n = 200_000 + nbins = 100 # Warning, must match utils.py! choices = [ @@ -85,6 +85,7 @@ def test_samples(do_plot=False): 'normal_pos', 'normal_int', 'lognormal_int', + 'poisson', 'neg_binomial' ] @@ -93,28 +94,75 @@ def test_samples(do_plot=False): # Run the samples nchoices = len(choices) - nsqr = np.ceil(np.sqrt(nchoices)) - results = {} + nsqr, _ = sc.get_rows_cols(nchoices) + results = sc.objdict() + mean = 11 + std = 7 + low = 3 + high = 9 + normal_dists = ['normal', 'normal_pos', 'normal_int', 'lognormal', 'lognormal_int'] for c,choice in enumerate(choices): - if choice == 'neg_binomial': - par1 = 10 - par2 = 0.5 - elif choice in ['lognormal', 'lognormal_int']: - par1 = 1 - par2 = 0.5 + kw = {} + if choice in normal_dists: + par1 = mean + par2 = std + elif choice == 'neg_binomial': + par1 = mean + par2 = 1.2 + kw['step'] = 0.1 + elif choice == 'poisson': + par1 = mean + par2 = 0 + elif choice == 'uniform': + par1 = low + par2 = high else: - par1 = 0 - par2 = 5 - results[choice] = cv.sample(dist=choice, par1=par1, par2=par2, size=n) + errormsg = f'Choice "{choice}" not implemented' + raise NotImplementedError(errormsg) + # Compute + results[choice] = cv.sample(dist=choice, par1=par1, par2=par2, size=n, **kw) + + # Optionally plot if do_plot: pl.subplot(nsqr, nsqr, c+1) - pl.hist(x=results[choice], bins=nbins) + plotbins = np.unique(results[choice]) if (choice=='poisson' or '_int' in choice) else nbins + pl.hist(x=results[choice], bins=plotbins, width=0.8) pl.title(f'dist={choice}, par1={par1}, par2={par2}') with pytest.raises(NotImplementedError): cv.sample(dist='not_found') + # Do statistical tests + tol = 1/np.sqrt(n/50/len(choices)) # Define acceptable tolerance -- broad to avoid false positives + + def isclose(choice, tol=tol, **kwargs): + key = list(kwargs.keys())[0] + ref = list(kwargs.values())[0] + npfunc = getattr(np, key) + value = npfunc(results[choice]) + msg = f'Test for {choice:14s}: expecting {key:4s} = {ref:8.4f} ± {tol*ref:8.4f} and got {value:8.4f}' + if verbose: + print(msg) + assert np.isclose(value, ref, rtol=tol), msg + return True + + # Normal + for choice in normal_dists: + isclose(choice, mean=mean) + if all([k not in choice for k in ['_pos', '_int']]): # These change the variance + isclose(choice, std=std) + + # Negative binomial + isclose('neg_binomial', mean=mean) + + # Poisson + isclose('poisson', mean=mean) + isclose('poisson', var=mean) + + # Uniform + isclose('uniform', mean=(low+high)/2) + return results @@ -150,9 +198,39 @@ def test_choose_w(): return x1 +def test_indexing(): + + # Definitions + farr = np.array([1.5,0,0,1,1,0]) # Float array + barr = np.array(farr, dtype=bool) # Boolean array + darr = np.array([0,np.nan,1,np.nan,0,np.nan]) # Defined/undefined array + inds = np.array([0,10,20,30,40,50]) # Indices + inds2 = np.array([1,2,3,4]) # Skip first and last index + + # Test true, false, defined, and undefined + assert cv.true(farr).tolist() == [0,3,4] + assert cv.false(farr).tolist() == [1,2,5] + assert cv.defined(darr).tolist() == [0,2,4] + assert cv.undefined(darr).tolist() == [1,3,5] + + # Test with indexing + assert cv.itrue(barr, inds).tolist() == [0,30,40] + assert cv.ifalse(barr, inds).tolist() == [10,20,50] + assert cv.idefined(darr, inds).tolist() == [0,20,40] + assert cv.iundefined(darr, inds).tolist() == [10,30,50] + + # Test with double indexing + assert cv.itruei(barr, inds2).tolist() == [3,4] + assert cv.ifalsei(barr, inds2).tolist() == [1,2] + assert cv.idefinedi(darr, inds2).tolist() == [2,4] + assert cv.iundefinedi(darr, inds2).tolist() == [1,3] + + return + + def test_doubling_time(): - sim = cv.Sim() + sim = cv.Sim(pop_size=1000) sim.run(verbose=0) d = sc.objdict() @@ -183,6 +261,7 @@ def test_doubling_time(): samples = test_samples(do_plot=do_plot) people1 = test_choose() people2 = test_choose_w() + inds = test_indexing() dt = test_doubling_time() print('\n'*2) diff --git a/tests/unittests/check_coverage b/tests/unittests/check_coverage new file mode 100755 index 000000000..d1b1de7c3 --- /dev/null +++ b/tests/unittests/check_coverage @@ -0,0 +1,14 @@ +#!/bin/bash +# Note that although the script runs when parallelized, the coverage results are wrong. + +echo 'Running tests...' +pytest -v test_*.py --cov-config=../.coveragerc --cov=../../covasim --workers auto --durations=0 + +echo 'Creating HTML report...' +coverage html + +echo 'Printing report...' +coverage report + +echo 'Report location:' +echo "`pwd`/htmlcov/index.html" \ No newline at end of file diff --git a/tests/unittests/experiment_test_disease_mortality.py b/tests/unittests/experiment_test_disease_mortality.py deleted file mode 100644 index 954abcc4b..000000000 --- a/tests/unittests/experiment_test_disease_mortality.py +++ /dev/null @@ -1,113 +0,0 @@ -import covasim as cv -from unittest_support_classes import CovaSimTest - - -class SimKeys: - ''' Define mapping to simulation keys ''' - number_agents = 'pop_size' - initial_infected_count = 'pop_infected' - start_day = 'start_day' - number_simulated_days = 'n_days' - random_seed = 'rand_seed' - pass - - -class DiseaseKeys: - ''' Define mapping to keys associated with disease progression ''' - modify_progression_by_age = 'prog_by_age' - scale_probability_of_infected_developing_symptoms = 'rel_symp_prob' - scale_probability_of_symptoms_developing_severe = 'rel_severe_prob' - scale_probability_of_severe_developing_critical = 'rel_crit_prob' - scale_probability_of_critical_developing_death = 'rel_death_prob' - pass - - -class ResultsKeys: - ''' Define keys for results ''' - cumulative_number_of_deaths = 'cum_deaths' - pass - - -def define_base_parameters(): - ''' Define the basic parameters for a simulation -- these will sometimes, but rarely, change between tests ''' - base_parameters_dict = { - SimKeys.number_agents: 1000, # Keep it small so they run faster - SimKeys.initial_infected_count: 100, # Use a relatively large number to avoid stochastic effects - SimKeys.random_seed: 1, # Ensure it's reproducible - SimKeys.number_simulated_days: 60, # Don't run for too long for speed, but run for long enough - } - return base_parameters_dict - - -def BaseSim(): - ''' Create a base simulation to run tests on ''' - base_parameters_dict = define_base_parameters() - base_sim = cv.Sim(pars=base_parameters_dict) - return base_sim - - -class ExperimentalDiseaseMortalityTests(CovaSimTest): - ''' Define the actual tests ''' - - def test_zero_deaths(self): - ''' Confirm that if mortality is set to zero, there are zero deaths ''' - - # Create the sim - sim = BaseSim() - - # Define test-secific configurations - test_parameters = { - DiseaseKeys.modify_progression_by_age: False, # Otherwise these parameters have no effect - DiseaseKeys.scale_probability_of_critical_developing_death: 0 # Change mortality rate to 0 - } - - # Run the simulation - sim.update_pars(test_parameters) - sim.run() - - # Check results - total_deaths = sim.results[ResultsKeys.cumulative_number_of_deaths][:][-1] # Get the total number of deaths (last value of the cumulative number) - self.assertEqual(0, total_deaths, - msg=f"There should be no deaths given parameters {test_parameters}. " - f"Channel {ResultsKeys.cumulative_number_of_deaths} had " - f"bad data: {total_deaths}") - - pass - - - def test_full_deaths(self): - ''' Confirm that if all progression parameters are set to 1, everyone dies''' - - # Create the sim - sim = BaseSim() - - # reminder: these are the defaults for when "no_age" is used - # symp_probs = np.array([0.75]), - # severe_probs = np.array([0.2]), - # crit_probs = np.array([0.08]), - # death_probs = np.array([0.02]), - - # Define test-secific configurations - test_parameters = { - SimKeys.initial_infected_count: sim[SimKeys.number_agents], # Ensure everyone is infected - DiseaseKeys.modify_progression_by_age: False, # Otherwise use age-specific values, but we want simple - DiseaseKeys.scale_probability_of_infected_developing_symptoms: 1.0/0.75, # Scale factor for proportion of symptomatic cases - DiseaseKeys.scale_probability_of_symptoms_developing_severe: 1.0/0.2, # Scale factor for proportion of symptomatic cases that become severe - DiseaseKeys.scale_probability_of_severe_developing_critical: 1.0/0.08, # Scale factor for proportion of severe cases that become critical - DiseaseKeys.scale_probability_of_critical_developing_death: 1.0/0.02 #Scale factor for proportion of critical cases that result in death - } - - # Run the simulation - sim.update_pars(test_parameters) - sim.run() - - # Check results - total_deaths = sim.results[ResultsKeys.cumulative_number_of_deaths][:][-1] # Get the total number of deaths (last value of the cumulative number) - self.assertEqual(sim[SimKeys.number_agents], total_deaths, - msg=f"Everyone should die with parameters {test_parameters}. " - f"Channel {ResultsKeys.cumulative_number_of_deaths} had " - f"bad data: {total_deaths} deaths vs. {sim[SimKeys.number_agents]} people.") - - pass - - diff --git a/tests/unittests/get_unittest_coverage.py b/tests/unittests/get_unittest_coverage.py deleted file mode 100644 index c49eadc52..000000000 --- a/tests/unittests/get_unittest_coverage.py +++ /dev/null @@ -1,37 +0,0 @@ -import coverage -import unittest -loader = unittest.TestLoader() -cov = coverage.Coverage(source=["covasim.base","covasim.interventions", - "covasim.parameters","covasim.people", - "covasim.population","covasim.misc"]) -cov.start() - -# First, load and run the unittest tests -from unittest_support_classes import TestSupportTests -from test_miscellaneous_features import MiscellaneousFeatureTests -from test_simulation_parameter import SimulationParameterTests -from test_disease_transmission import DiseaseTransmissionTests -from test_disease_progression import DiseaseProgressionTests -from test_disease_mortality import DiseaseMortalityTests -# from test_diagnostic_testing import DiagnosticTestingTests - -test_classes_to_run = [TestSupportTests, - SimulationParameterTests, - DiseaseTransmissionTests, - DiseaseProgressionTests, - DiseaseMortalityTests, - MiscellaneousFeatureTests] - -suites_list = [] -for tc in test_classes_to_run: - suite = loader.loadTestsFromTestCase(tc) - suites_list.append(suite) - pass - -big_suite = unittest.TestSuite(suites_list) -runner = unittest.TextTestRunner() -results = runner.run(big_suite) - -cov.stop() -cov.save() -cov.html_report() \ No newline at end of file diff --git a/tests/unittests/test_data_loaders.py b/tests/unittests/test_data_loaders.py index c7621b089..a7b7fd520 100644 --- a/tests/unittests/test_data_loaders.py +++ b/tests/unittests/test_data_loaders.py @@ -19,4 +19,5 @@ def test_country_households(self): if __name__ == '__main__': + unittest.TestCase.run = lambda self,*args,**kw: unittest.TestCase.debug(self) unittest.main() diff --git a/tests/unittests/test_disease_mortality.py b/tests/unittests/test_disease_mortality.py deleted file mode 100644 index df832b175..000000000 --- a/tests/unittests/test_disease_mortality.py +++ /dev/null @@ -1,123 +0,0 @@ -""" -Tests of simulation parameters from -../../covasim/README.md -""" - -from unittest_support_classes import CovaSimTest, TestProperties - -DProgKeys = TestProperties.ParameterKeys.ProgressionKeys -TransKeys = TestProperties.ParameterKeys.TransmissionKeys -TSimKeys = TestProperties.ParameterKeys.SimulationKeys -ResKeys = TestProperties.ResultsDataKeys - - -class DiseaseMortalityTests(CovaSimTest): - def setUp(self): - super().setUp() - pass - - def tearDown(self): - super().tearDown() - pass - - def test_default_death_prob_one(self): - """ - Infect lots of people with cfr one and short time to die - duration. Verify that everyone dies, no recoveries. - """ - total_agents = 500 - self.set_everyone_is_going_to_die(num_agents=total_agents) - self.run_sim() - recoveries_at_timestep_channel = self.get_full_result_channel( - ResKeys.recovered_at_timestep - ) - recoveries_cumulative_channel = self.get_full_result_channel( - ResKeys.recovered_cumulative - ) - recovery_channels = [ - recoveries_at_timestep_channel, - recoveries_cumulative_channel - ] - for c in recovery_channels: - for t in range(len(c)): - self.assertEqual(0, c[t], - msg=f"There should be no recoveries" - f" with death_prob 1.0. Channel {c} had " - f" bad data at t: {t}") - pass - pass - cumulative_deaths = self.get_day_final_channel_value( - ResKeys.deaths_cumulative - ) - self.assertEqual(cumulative_deaths, total_agents, - msg="Everyone should die") - pass - - def test_default_death_prob_zero(self): - """ - Infect lots of people with cfr zero and short time to die - duration. Verify that no one dies. - Depends on default_cfr_one - """ - total_agents = 500 - self.set_everyone_is_going_to_die(num_agents=total_agents) - prob_dict = { - DProgKeys.ProbabilityKeys.RelativeProbKeys.crt_to_death_probability: 0.0 - } - self.set_simulation_prognosis_probability(prob_dict) - self.run_sim() - deaths_at_timestep_channel = self.get_full_result_channel( - ResKeys.deaths_daily - ) - deaths_cumulative_channel = self.get_full_result_channel( - ResKeys.deaths_cumulative - ) - death_channels = [ - deaths_at_timestep_channel, - deaths_cumulative_channel - ] - for c in death_channels: - for t in range(len(c)): - self.assertEqual(c[t], 0, - msg=f"There should be no deaths" - f" with critical to death probability 0.0. Channel {c} had" - f" bad data at t: {t}") - pass - pass - cumulative_recoveries = self.get_day_final_channel_value( - ResKeys.recovered_cumulative - ) - self.assertGreaterEqual(cumulative_recoveries, 200, - msg="Should be lots of recoveries") - pass - - def test_default_death_prob_scaling(self): - """ - Infect lots of people with cfr zero and short time to die - duration. Verify that no one dies. - Depends on default_cfr_one - """ - total_agents = 500 - self.set_everyone_is_going_to_die(num_agents=total_agents) - death_probs = [0.01, 0.05, 0.10, 0.15] - old_cumulative_deaths = 0 - for death_prob in death_probs: - prob_dict = { - DProgKeys.ProbabilityKeys.RelativeProbKeys.crt_to_death_probability: death_prob - } - self.set_simulation_prognosis_probability(prob_dict) - self.run_sim() - deaths_at_timestep_channel = self.get_full_result_channel( - ResKeys.deaths_daily - ) - recoveries_at_timestep_channel = self.get_full_result_channel( - ResKeys.recovered_at_timestep - ) - cumulative_deaths = self.get_day_final_channel_value( - ResKeys.deaths_cumulative - ) - self.assertGreaterEqual(cumulative_deaths, old_cumulative_deaths, - msg="Should be more deaths with higer ratio") - old_cumulative_deaths = cumulative_deaths - pass - diff --git a/tests/unittests/test_disease_progression.py b/tests/unittests/test_disease_progression.py deleted file mode 100644 index e05657e3c..000000000 --- a/tests/unittests/test_disease_progression.py +++ /dev/null @@ -1,144 +0,0 @@ -""" -Tests of simulation parameters from -../../covasim/README.md -""" -import unittest - -from unittest_support_classes import CovaSimTest, TestProperties - -ResKeys = TestProperties.ResultsDataKeys -ParamKeys = TestProperties.ParameterKeys - - -class DiseaseProgressionTests(CovaSimTest): - def setUp(self): - super().setUp() - pass - - def tearDown(self): - super().tearDown() - pass - - def test_exposure_to_infectiousness_delay_scaling(self): - """ - Set exposure to infectiousness early simulation, mid simulation, - late simulation. Set std_dev to zero. Verify move to infectiousness - moves later as delay is longer. - Depends on delay deviation test - """ - total_agents = 500 - self.set_everyone_infected(total_agents) - sim_dur = 60 - exposed_delays = [1, 2, 5, 15, 20, 25, 30] # Keep values in order - std_dev = 0 - for exposed_delay in exposed_delays: - self.set_duration_distribution_parameters( - duration_in_question=ParamKeys.ProgressionKeys.DurationKeys.exposed_to_infectious, - par1=exposed_delay, - par2=std_dev - ) - prob_dict = { - TestProperties.ParameterKeys.ProgressionKeys.ProbabilityKeys.RelativeProbKeys.inf_to_symptomatic_probability: 0 - } - self.set_simulation_prognosis_probability(prob_dict) - serial_delay = { - TestProperties.ParameterKeys.SimulationKeys.number_simulated_days: sim_dur - } - self.run_sim(serial_delay) - infectious_channel = self.get_full_result_channel( - ResKeys.infectious_at_timestep - ) - agents_on_infectious_day = infectious_channel[exposed_delay] - if self.is_debugging: - print(f"Delay: {exposed_delay}") - print(f"Agents turned: {agents_on_infectious_day}") - print(f"Infectious channel {infectious_channel}") - pass - for t in range(len(infectious_channel)): - current_infectious = infectious_channel[t] - if t < exposed_delay: - self.assertEqual(current_infectious, 0, - msg=f"All {total_agents} should turn infectious at t: {exposed_delay}" - f" instead got {current_infectious} at t: {t}") - elif t == exposed_delay: - self.assertEqual(infectious_channel[exposed_delay], total_agents, - msg=f"With stddev 0, all {total_agents} agents should turn infectious " - f"on day {exposed_delay}, instead got {agents_on_infectious_day}. ") - pass - - def test_mild_infection_duration_scaling(self): - """ - Make sure that all initial infected cease being infected - on following day. Std_dev 0 will help here - """ - total_agents = 500 - exposed_delay = 1 - self.set_everyone_infectious_same_day(num_agents=total_agents, - days_to_infectious=exposed_delay) - prob_dict = { - ParamKeys.ProgressionKeys.ProbabilityKeys.RelativeProbKeys.inf_to_symptomatic_probability: 0.0 - } - self.set_simulation_prognosis_probability(prob_dict) - infectious_durations = [1, 2, 5, 10, 20] # Keep values in order - infectious_duration_stddev = 0 - for TEST_dur in infectious_durations: - recovery_day = exposed_delay + TEST_dur - self.set_duration_distribution_parameters( - duration_in_question=ParamKeys.ProgressionKeys.DurationKeys.infectious_asymptomatic_to_recovered, - par1=TEST_dur, - par2=infectious_duration_stddev - ) - self.run_sim() - recoveries_channel = self.get_full_result_channel( - TestProperties.ResultsDataKeys.recovered_at_timestep - ) - recoveries_on_recovery_day = recoveries_channel[recovery_day] - if self.is_debugging: - print(f"Delay: {recovery_day}") - print(f"Agents turned: {recoveries_on_recovery_day}") - print(f"Recoveries channel {recoveries_channel}") - self.assertEqual(recoveries_channel[recovery_day], total_agents, - msg=f"With stddev 0, all {total_agents} agents should turn infectious " - f"on day {recovery_day}, instead got {recoveries_on_recovery_day}. ") - - pass - - def test_time_to_die_duration_scaling(self): - total_agents = 500 - self.set_everyone_critical(num_agents=500, constant_delay=0) - prob_dict = { - ParamKeys.ProgressionKeys.ProbabilityKeys.RelativeProbKeys.crt_to_death_probability: 1.0 - } - self.set_simulation_prognosis_probability(prob_dict) - - time_to_die_durations = [1, 2, 5, 10, 20] - time_to_die_stddev = 0 - - for TEST_dur in time_to_die_durations: - self.set_duration_distribution_parameters( - duration_in_question=ParamKeys.ProgressionKeys.DurationKeys.critical_to_death, - par1=TEST_dur, - par2=time_to_die_stddev - ) - self.run_sim() - deaths_today_channel = self.get_full_result_channel( - TestProperties.ResultsDataKeys.deaths_daily - ) - for t in range(len(deaths_today_channel)): - curr_deaths = deaths_today_channel[t] - if t < TEST_dur: - self.assertEqual(curr_deaths, 0, - msg=f"With std 0, all {total_agents} agents should die on " - f"t: {TEST_dur}. Got {curr_deaths} at t: {t}") - elif t == TEST_dur: - self.assertEqual(curr_deaths, total_agents, - msg=f"With std 0, all {total_agents} agents should die at t:" - f" {TEST_dur}, got {curr_deaths} instead.") - else: - self.assertEqual(curr_deaths, 0, - msg=f"With std 0, all {total_agents} agents should die at t:" - f" {TEST_dur}, got {curr_deaths} at t: {t}") - pass - -if __name__ == '__main__': - unittest.main() \ No newline at end of file diff --git a/tests/unittests/test_disease_transmission.py b/tests/unittests/test_disease_transmission.py deleted file mode 100644 index 59c837ceb..000000000 --- a/tests/unittests/test_disease_transmission.py +++ /dev/null @@ -1,58 +0,0 @@ -""" -Tests of simulation parameters from -../../covasim/README.md -""" - -from unittest_support_classes import CovaSimTest, TestProperties - -TKeys = TestProperties.ParameterKeys.TransmissionKeys -Hightrans = TestProperties.SpecializedSimulations.Hightransmission - -class DiseaseTransmissionTests(CovaSimTest): - """ - Tests of the parameters involved in transmission - pre requisites simulation parameter tests - """ - - def setUp(self): - super().setUp() - pass - - def tearDown(self): - super().tearDown() - pass - - def test_beta_zero(self): - """ - Test that with beta at zero, no transmission - Start with high transmission sim - """ - self.set_smallpop_hightransmission() - beta_zero = { - TKeys.beta: 0 - } - self.run_sim(beta_zero) - exposed_today_channel = self.get_full_result_channel( - TestProperties.ResultsDataKeys.exposed_at_timestep - ) - prev_exposed = exposed_today_channel[0] - self.assertEqual(prev_exposed, Hightrans.pop_infected, - msg="Make sure we have some initial infections") - for t in range(1, len(exposed_today_channel)): - today_exposed = exposed_today_channel[t] - self.assertLessEqual(today_exposed, prev_exposed, - msg=f"The exposure counts should do nothing but decline." - f" At time {t}: {today_exposed} at {t-1}: {prev_exposed}.") - prev_exposed = today_exposed - pass - - infections_channel = self.get_full_result_channel( - TestProperties.ResultsDataKeys.infections_at_timestep - ) - for t in range(len(infections_channel)): - today_infectious = infections_channel[t] - self.assertEqual(today_infectious, 0, - msg=f"With beta 0, there should be no infections." - f" At ts: {t} got {today_infectious}.") - pass - pass \ No newline at end of file diff --git a/tests/unittests/test_interventions.py b/tests/unittests/test_interventions.py index 29c254efc..71f3c43c1 100644 --- a/tests/unittests/test_interventions.py +++ b/tests/unittests/test_interventions.py @@ -1,128 +1,79 @@ -from unittest_support_classes import CovaSimTest -from unittest_support_classes import TestProperties -from math import sqrt import json import numpy as np - +import covasim as cv +from unittest_support import CovaTest import unittest AGENT_COUNT = 1000 - -ResultsKeys = TestProperties.ResultsDataKeys -SimKeys = TestProperties.ParameterKeys.SimulationKeys -class InterventionTests(CovaSimTest): - def setUp(self): - super().setUp() - pass - - def tearDown(self): - super().tearDown() - pass +class InterventionTests(CovaTest): # region change beta def test_brutal_change_beta_intervention(self): params = { - SimKeys.number_agents: AGENT_COUNT, - SimKeys.number_simulated_days: 60 + 'pop_size': AGENT_COUNT, + 'n_days': 60 } - self.set_simulation_parameters(params_dict=params) + self.set_sim_pars(params_dict=params) day_of_change = 30 change_days = [day_of_change] change_multipliers = [0.0] - self.intervention_set_changebeta( - days_array=change_days, - multiplier_array=change_multipliers - ) + self.intervention_set_changebeta(days_array=change_days, multiplier_array=change_multipliers) self.run_sim() - new_infections_channel = self.get_full_result_channel( - channel=ResultsKeys.infections_at_timestep - ) + new_infections_ch = self.get_full_result_ch(channel='new_infections') five_previous_days = range(day_of_change-5, day_of_change) for d in five_previous_days: - self.assertGreater(new_infections_channel[d], - 0, - msg=f"Need to have infections before change day {day_of_change}") - pass + self.assertGreater(new_infections_ch[d], 0, msg=f"Need to have infections before change day {day_of_change}") - happy_days = range(day_of_change + 1, len(new_infections_channel)) + happy_days = range(day_of_change + 1, len(new_infections_ch)) for d in happy_days: - self.assertEqual(new_infections_channel[d], - 0, - msg=f"expected 0 infections on day {d}, got {new_infections_channel[d]}.") + self.assertEqual(new_infections_ch[d], 0, msg=f"expected 0 infections on day {d}, got {new_infections_ch[d]}.") def test_change_beta_days(self): params = { - SimKeys.number_agents: AGENT_COUNT, - SimKeys.number_simulated_days: 60 + 'pop_size': AGENT_COUNT, + 'n_days': 60 } - self.set_simulation_parameters(params_dict=params) + self.set_sim_pars(params_dict=params) # Do a 0.0 intervention / 1.0 intervention on different days days = [ 30, 32, 40, 42, 50] multipliers = [0.0, 1.0, 0.0, 1.0, 0.0] - self.intervention_set_changebeta(days_array=days, - multiplier_array=multipliers) + self.intervention_set_changebeta(days_array=days, multiplier_array=multipliers) self.run_sim() - new_infections_channel = self.get_full_result_channel( - channel=ResultsKeys.infections_at_timestep - ) + new_infections_ch = self.get_full_result_ch('new_infections') five_previous_days = range(days[0] -5, days[0]) for d in five_previous_days: - self.assertGreater(new_infections_channel[d], - 0, - msg=f"Need to have infections before first change day {days[0]}") - pass + self.assertGreater(new_infections_ch[d], 0, msg=f"Need to have infections before first change day {days[0]}") break_days = [0, 2] # index of "beta to zero" periods for b in break_days: happy_days = range(days[b], days[b + 1]) for d in happy_days: - # print(f"DEBUG: looking at happy day {d}") - self.assertEqual(new_infections_channel[d], - 0, - msg=f"expected 0 infections on day {d}, got {new_infections_channel[d]}.") + self.assertEqual(new_infections_ch[d], 0, msg=f"expected 0 infections on day {d}, got {new_infections_ch[d]}.") infection_days = range(days[b+1], days[b+2]) for d in infection_days: - # print(f"DEBUG: looking at infection day {d}") - self.assertGreater(new_infections_channel[d], - 0, - msg=f"Expected some infections on day {d}, got {new_infections_channel[d]}") - pass - pass - for d in range (days[-1] + 1, len(new_infections_channel)): - self.assertEqual(new_infections_channel[d], - 0, - msg=f"After day {days[-1]} should have no infections." - f" Got {new_infections_channel[d]} on day {d}.") - - # verify that every infection day after days[0] is in a 1.0 block - # verify no infections after 60 - pass + self.assertGreater(new_infections_ch[d], 0, msg=f"Expected some infections on day {d}, got {new_infections_ch[d]}") + for d in range (days[-1] + 1, len(new_infections_ch)): + self.assertEqual(new_infections_ch[d], 0, msg=f"After day {days[-1]} should have no infections. Got {new_infections_ch[d]} on day {d}.") def test_change_beta_multipliers(self): params = { - SimKeys.number_agents: AGENT_COUNT, - SimKeys.number_simulated_days: 40 + 'pop_size': AGENT_COUNT, + 'n_days': 40 } - self.set_simulation_parameters(params_dict=params) + self.set_sim_pars(params_dict=params) day_of_change = 20 change_days = [day_of_change] change_multipliers = [1.0, 0.8, 0.6, 0.4, 0.2] total_infections = {} for multiplier in change_multipliers: self.interventions = None - - self.intervention_set_changebeta( - days_array=change_days, - multiplier_array=[multiplier] - ) + self.intervention_set_changebeta( days_array=change_days, multiplier_array=[multiplier]) self.run_sim(params) - these_infections = self.get_day_final_channel_value( - channel=ResultsKeys.infections_cumulative - ) + these_infections = self.get_day_final_ch_value(channel='cum_infections') total_infections[multiplier] = these_infections - pass + for result_index in range(0, len(change_multipliers) - 1): my_multiplier = change_multipliers[result_index] next_multiplier = change_multipliers[result_index + 1] @@ -133,92 +84,17 @@ def test_change_beta_multipliers(self): f"(with {total_infections[next_multiplier]} infections)") def test_change_beta_layers_clustered(self): - ''' - Suggested alternative implementation: - - import covasim as cv - - # Define the interventions - days = dict(h=30, s=35, w=40, c=45) - interventions = [] - for key,day in days.items(): - interventions.append(cv.change_beta(days=day, changes=0, layers=key)) - - # Create and run the sim - sim = cv.Sim(pop_type='hybrid', n_days=60, interventions=interventions) - sim.run() - assert sim.results['new_infections'].values[days['c']:].sum() == 0 - sim.plot() - ''' - self.is_debugging = False - initial_infected = 10 - seed_list = range(0) - for seed in seed_list: - params = { - SimKeys.random_seed: seed, - SimKeys.number_agents: AGENT_COUNT, - SimKeys.number_simulated_days: 60, - SimKeys.initial_infected_count: initial_infected - } - if len(seed_list) > 1: - self.expected_result_filename = f"DEBUG_{self.id()}_{seed}.json" - self.set_simulation_parameters(params_dict=params) - day_of_change = 25 - change_multipliers = [0.0] - layer_keys = ['c','h','s','w'] - - intervention_days = [] - intervention_list = [] - - for k in layer_keys: # Zero out one layer at a time - day_of_change += 5 - self.intervention_set_changebeta( - days_array=[day_of_change], - multiplier_array=change_multipliers, - layers=[k] - ) - intervention_days.append(day_of_change) - intervention_list.append(self.interventions) - self.interventions = None - pass - self.interventions = intervention_list - self.run_sim(population_type='clustered') - last_intervention_day = intervention_days[-1] - first_intervention_day = intervention_days[0] - cum_infections_channel= self.get_full_result_channel(ResultsKeys.infections_cumulative) - if len(seed_list) > 1: - messages = [] - if cum_infections_channel[intervention_days[0]-1] < initial_infected: - messages.append(f"Before intervention at day {intervention_days[0]}, there should be infections happening.") - pass - - if cum_infections_channel[last_intervention_day] < cum_infections_channel[first_intervention_day]: - messages.append(f"Cumulative infections should grow with only some layers enabled.") - pass - - if cum_infections_channel[last_intervention_day] != cum_infections_channel[-1]: - messages.append(f"The cumulative infections at {last_intervention_day} should be the same as at the end.") - pass - - if len(messages) > 0: - print(f"ERROR: seed {seed}") - for m in messages: - print(f"\t{m}") - pass - - self.assertGreater(cum_infections_channel[intervention_days[0]-1], - initial_infected, - msg=f"Before intervention at day {intervention_days[0]}, there should be infections happening.") - - self.assertGreater(cum_infections_channel[last_intervention_day], - cum_infections_channel[first_intervention_day], - msg=f"Cumulative infections should grow with only some layers enabled.") - - self.assertEqual(cum_infections_channel[last_intervention_day], - cum_infections_channel[-1], - msg=f"with all layers at 0 beta, the cumulative infections at {last_intervention_day}" + - f" should be the same as at the end.") - pass + # Define the interventions + days = dict(h=30, s=35, w=40, c=45) + interventions = [] + for key,day in days.items(): + interventions.append(cv.change_beta(days=day, changes=0, layers=key)) + + # Create and run the sim + sim = cv.Sim(pop_size=AGENT_COUNT, pop_type='hybrid', n_days=60, interventions=interventions) + sim.run() + assert sim.results['new_infections'].values[days['c']:].sum() == 0 + return def test_change_beta_layers_random(self): self.is_debugging = False @@ -226,18 +102,17 @@ def test_change_beta_layers_random(self): seed_list = range(0) for seed in seed_list: params = { - SimKeys.random_seed: seed, - SimKeys.number_agents: AGENT_COUNT, - SimKeys.number_simulated_days: 60, - SimKeys.initial_infected_count: initial_infected + 'rand_seed': seed, + 'pop_size': AGENT_COUNT, + 'n_days': 60, + 'pop_infected': initial_infected } - self.set_simulation_parameters(params_dict=params) + self.set_sim_pars(params_dict=params) if len(seed_list) > 1: self.expected_result_filename = f"DEBUG_{self.id()}_{seed}.json" day_of_change = 25 change_multipliers = [0.0] layer_keys = ['a'] - intervention_days = [] intervention_list = [] @@ -251,32 +126,22 @@ def test_change_beta_layers_random(self): intervention_days.append(day_of_change) intervention_list.append(self.interventions) self.interventions = None - pass self.interventions = intervention_list self.run_sim(population_type='random') last_intervention_day = intervention_days[-1] - cum_infections_channel = self.get_full_result_channel(ResultsKeys.infections_cumulative) + cum_infections_ch = self.get_full_result_ch('cum_infections') if len(seed_list) > 1: messages = [] - if cum_infections_channel[intervention_days[0]-1] < initial_infected: + if cum_infections_ch[intervention_days[0]-1] < initial_infected: messages.append(f"Before intervention at day {intervention_days[0]}, there should be infections happening.") - pass - - if cum_infections_channel[last_intervention_day] != cum_infections_channel[-1]: + if cum_infections_ch[last_intervention_day] != cum_infections_ch[-1]: messages.append(f"The cumulative infections at {last_intervention_day} should be the same as at the end.") - pass - if len(messages) > 0: print(f"ERROR: seed {seed}") for m in messages: print(f"\t{m}") - pass - self.assertGreater(cum_infections_channel[intervention_days[0]-1], - initial_infected, - msg=f"Before intervention at day {intervention_days[0]}, there should be infections happening.") - self.assertEqual(cum_infections_channel[last_intervention_day], - cum_infections_channel[intervention_days[0] - 1], - msg=f"With all layers at 0 beta, should be 0 infections at {last_intervention_day}.") + self.assertGreater(cum_infections_ch[intervention_days[0]-1], initial_infected, msg=f"Before intervention at day {intervention_days[0]}, there should be infections happening.") + self.assertEqual(cum_infections_ch[last_intervention_day], cum_infections_ch[intervention_days[0] - 1], msg=f"With all layers at 0 beta, should be 0 infections at {last_intervention_day}.") def test_change_beta_layers_hybrid(self): self.is_debugging = False @@ -284,14 +149,14 @@ def test_change_beta_layers_hybrid(self): seed_list = range(0) for seed in seed_list: params = { - SimKeys.random_seed: seed, - SimKeys.number_agents: AGENT_COUNT, - SimKeys.number_simulated_days: 60, - SimKeys.initial_infected_count: initial_infected + 'rand_seed': seed, + 'pop_size': AGENT_COUNT, + 'n_days': 60, + 'pop_infected': initial_infected } if len(seed_list) > 1: self.expected_result_filename = f"DEBUG_{self.id()}_{seed}.json" - self.set_simulation_parameters(params_dict=params) + self.set_sim_pars(params_dict=params) day_of_change = 25 change_multipliers = [0.0] layer_keys = ['c','s','w','h'] @@ -314,96 +179,52 @@ def test_change_beta_layers_hybrid(self): self.run_sim(population_type='hybrid') last_intervention_day = intervention_days[-1] first_intervention_day = intervention_days[0] - cum_infections_channel = self.get_full_result_channel(ResultsKeys.infections_cumulative) + cum_infections_ch = self.get_full_result_ch('cum_infections') if len(seed_list) > 1: messages = [] - if cum_infections_channel[intervention_days[0]-1] < initial_infected: + if cum_infections_ch[intervention_days[0]-1] < initial_infected: messages.append(f"Before intervention at day {intervention_days[0]}, there should be infections happening.") - pass - - if cum_infections_channel[last_intervention_day] < cum_infections_channel[first_intervention_day]: - messages.append(f"Cumulative infections should grow with only some layers enabled.") - pass - - if cum_infections_channel[last_intervention_day] != cum_infections_channel[-1]: + if cum_infections_ch[last_intervention_day] < cum_infections_ch[first_intervention_day]: + messages.append("Cumulative infections should grow with only some layers enabled.") + if cum_infections_ch[last_intervention_day] != cum_infections_ch[-1]: messages.append(f"The cumulative infections at {last_intervention_day} should be the same as at the end.") - pass - if len(messages) > 0: print(f"ERROR: seed {seed}") for m in messages: print(f"\t{m}") - pass - self.assertGreater(cum_infections_channel[intervention_days[0]-1], - initial_infected, - msg=f"Before intervention at day {intervention_days[0]}, there should be infections happening.") - self.assertGreater(cum_infections_channel[last_intervention_day], - cum_infections_channel[first_intervention_day], - msg=f"Cumulative infections should grow with only some layers enabled.") - self.assertEqual(cum_infections_channel[last_intervention_day], - cum_infections_channel[-1], - msg=f"With all layers at 0 beta, the cumulative infections at {last_intervention_day}" - f" should be the same as at the end.") - - def verify_perfect_test_prob(self, start_day, test_delay, test_sensitivity, - target_pop_count_channel, - target_pop_new_channel, - target_test_count_channel=None): + self.assertGreater(cum_infections_ch[intervention_days[0]-1], initial_infected, msg=f"Before intervention at day {intervention_days[0]}, there should be infections happening.") + self.assertGreater(cum_infections_ch[last_intervention_day], cum_infections_ch[first_intervention_day], msg="Cumulative infections should grow with only some layers enabled.") + self.assertEqual(cum_infections_ch[last_intervention_day], cum_infections_ch[-1], msg=f"With all layers at 0 beta, the cumulative infections at {last_intervention_day} should be the same as at the end.") + + def verify_perfect_test_prob(self, start_day, test_delay, test_sensitivity, target_pop_count_ch, target_pop_new_ch, target_test_count_ch=None): if test_sensitivity < 1.0: - raise ValueError("This test method only works with perfect test " - f"sensitivity. {test_sensitivity} won't cut it.") - new_tests = self.get_full_result_channel( - channel=ResultsKeys.tests_at_timestep - ) - new_diagnoses = self.get_full_result_channel( - channel=ResultsKeys.diagnoses_at_timestep - ) - target_count = target_pop_count_channel - target_new = target_pop_new_channel + raise ValueError("This test method only works with perfect test sensitivity. {test_sensitivity} won't cut it.") + new_tests = self.get_full_result_ch(channel='new_tests') + new_diagnoses = self.get_full_result_ch(channel='new_diagnoses') + target_count = target_pop_count_ch + target_new = target_pop_new_ch pre_test_days = range(0, start_day) for d in pre_test_days: - self.assertEqual(new_tests[d], - 0, - msg=f"Should be no testing before day {start_day}. Got some at {d}") - self.assertEqual(new_diagnoses[d], - 0, - msg=f"Should be no diagnoses before day {start_day}. Got some at {d}") - pass - if self.is_debugging: + self.assertEqual(new_tests[d], 0, msg=f"Should be no testing before day {start_day}. Got some at {d}") + self.assertEqual(new_diagnoses[d], 0, msg=f"Should be no diagnoses before day {start_day}. Got some at {d}") + if 1:#self.is_debugging: print("DEBUGGING") print(f"Start day is {start_day}") print(f"new tests before, on, and after start day: {new_tests[start_day-1:start_day+2]}") print(f"new diagnoses before, on, after start day: {new_diagnoses[start_day-1:start_day+2]}") print(f"target count before, on, after start day: {target_count[start_day-1:start_day+2]}") - pass - self.assertEqual(new_tests[start_day], - target_test_count_channel[start_day], - msg=f"Should have each of the {target_test_count_channel[start_day]} targets" - f" get tested at day {start_day}. Got {new_tests[start_day]} instead.") - self.assertEqual(new_diagnoses[start_day + test_delay], - target_count[start_day], - msg=f"Should have each of the {target_count[start_day]} targets " - f"get diagnosed at day {start_day + test_delay} with sensitivity {test_sensitivity} " - f"and delay {test_delay}. Got {new_diagnoses[start_day + test_delay]} instead.") + self.assertEqual(new_tests[start_day], target_test_count_ch[start_day], msg=f"Should have each of the {target_test_count_ch[start_day]} targets get tested at day {start_day}. Got {new_tests[start_day]} instead.") + self.assertEqual(new_diagnoses[start_day + test_delay], target_count[start_day], msg=f"Should have each of the {target_count[start_day]} targets get diagnosed at day {start_day + test_delay} with sensitivity {test_sensitivity} and delay {test_delay}. Got {new_diagnoses[start_day + test_delay]} instead.") post_test_days = range(start_day + 1, len(new_tests)) - if target_pop_new_channel: + if target_pop_new_ch: for d in post_test_days[:test_delay]: symp_today = target_new[d] diag_today = new_diagnoses[d + test_delay] test_today = new_tests[d] - self.assertEqual(symp_today, - test_today, - msg=f"Should have each of the {symp_today} newly symptomatics get" - f" tested on day {d}. Got {test_today} instead.") - self.assertEqual(symp_today, - diag_today, - msg=f"Should have each of the {symp_today} newly symptomatics get" - f" diagnosed on day {d + test_delay} with sensitivity {test_sensitivity}." - f" Got {test_today} instead.") - pass - pass + self.assertEqual(symp_today, test_today, msg=f"Should have each of the {symp_today} newly symptomatics get tested on day {d}. Got {test_today} instead.") + self.assertEqual(symp_today, diag_today, msg=f"Should have each of the {symp_today} newly symptomatics get diagnosed on day {d + test_delay} with sensitivity {test_sensitivity}. Got {test_today} instead.") def test_test_prob_perfect_asymptomatic(self): @@ -415,10 +236,10 @@ def test_test_prob_perfect_asymptomatic(self): self.is_debugging = False agent_count = AGENT_COUNT params = { - SimKeys.number_agents: AGENT_COUNT, - SimKeys.number_simulated_days: 60 + 'pop_size': AGENT_COUNT, + 'n_days': 60 } - self.set_simulation_parameters(params_dict=params) + self.set_sim_pars(params_dict=params) asymptomatic_probability_of_test = 1.0 test_sensitivity = 1.0 @@ -430,32 +251,26 @@ def test_test_prob_perfect_asymptomatic(self): test_delay=test_delay, start_day=start_day) self.run_sim() - symptomatic_count_channel = self.get_full_result_channel( - ResultsKeys.symptomatic_at_timestep - ) - infectious_count_channel = self.get_full_result_channel( - ResultsKeys.infectious_at_timestep - ) - population_channel = [agent_count] * len(symptomatic_count_channel) - asymptomatic_infectious_count_channel = list(np.subtract(np.array(infectious_count_channel), - np.array(symptomatic_count_channel))) - asymptomatic_population_count_channel = list(np.subtract(np.array(population_channel), - np.array(symptomatic_count_channel))) + symptomatic_count_ch = self.get_full_result_ch('n_symptomatic') + infectious_count_ch = self.get_full_result_ch('n_infectious') + population_ch = [agent_count] * len(symptomatic_count_ch) + asymptomatic_infectious_count_ch = list(np.subtract(np.array(infectious_count_ch), np.array(symptomatic_count_ch))) + asymptomatic_population_count_ch = list(np.subtract(np.array(population_ch), np.array(symptomatic_count_ch))) self.verify_perfect_test_prob(start_day=start_day, test_delay=test_delay, test_sensitivity=test_sensitivity, - target_pop_count_channel=asymptomatic_infectious_count_channel, - target_test_count_channel=asymptomatic_population_count_channel, - target_pop_new_channel=None) + target_pop_count_ch=asymptomatic_infectious_count_ch, + target_test_count_ch=asymptomatic_population_count_ch, + target_pop_new_ch=None) def test_test_prob_perfect_symptomatic(self): self.is_debugging = False params = { - SimKeys.number_agents: AGENT_COUNT, - SimKeys.number_simulated_days: 60 + 'pop_size': AGENT_COUNT, + 'n_days': 60 } - self.set_simulation_parameters(params_dict=params) + self.set_sim_pars(params_dict=params) symptomatic_probability_of_test = 1.0 test_sensitivity = 1.0 @@ -467,29 +282,24 @@ def test_test_prob_perfect_symptomatic(self): test_delay=test_delay, start_day=start_day) self.run_sim() - symptomatic_count_channel = self.get_full_result_channel( - ResultsKeys.symptomatic_at_timestep - ) - symptomatic_new_channel = self.get_full_result_channel( - ResultsKeys.symptomatic_new_timestep - ) + symptomatic_count_ch = self.get_full_result_ch('n_symptomatic') + symptomatic_new_ch = self.get_full_result_ch('new_symptomatic') self.verify_perfect_test_prob(start_day=start_day, test_delay=test_delay, test_sensitivity=test_sensitivity, - target_pop_count_channel=symptomatic_count_channel, - target_pop_new_channel=symptomatic_new_channel, - target_test_count_channel=symptomatic_count_channel + target_pop_count_ch=symptomatic_count_ch, + target_pop_new_ch=symptomatic_new_ch, + target_test_count_ch=symptomatic_count_ch ) - pass def test_test_prob_perfect_not_quarantined(self): self.is_debugging = False agent_count = AGENT_COUNT params = { - SimKeys.number_agents: AGENT_COUNT, - SimKeys.number_simulated_days: 60 + 'pop_size': AGENT_COUNT, + 'n_days': 60 } - self.set_simulation_parameters(params_dict=params) + self.set_sim_pars(params_dict=params) asymptomatic_probability_of_test = 1.0 symptomatic_probability_of_test = 1.0 @@ -503,18 +313,15 @@ def test_test_prob_perfect_not_quarantined(self): test_delay=test_delay, start_day=start_day) self.run_sim() - infectious_count_channel = self.get_full_result_channel( - ResultsKeys.infectious_at_timestep - ) - population_channel = [agent_count] * len(infectious_count_channel) + infectious_count_ch = self.get_full_result_ch('n_infectious') + population_ch = [agent_count] * len(infectious_count_ch) self.verify_perfect_test_prob(start_day=start_day, test_delay=test_delay, test_sensitivity=test_sensitivity, - target_pop_count_channel=infectious_count_channel, - target_test_count_channel=population_channel, - target_pop_new_channel=None) - pass + target_pop_count_ch=infectious_count_ch, + target_test_count_ch=population_ch, + target_pop_new_ch=None) def test_test_prob_sensitivity(self, subtract_today_recoveries=False): self.is_debugging = False @@ -522,11 +329,11 @@ def test_test_prob_sensitivity(self, subtract_today_recoveries=False): error_seeds = {} for seed in seed_list: params = { - SimKeys.random_seed: seed, - SimKeys.number_agents: AGENT_COUNT, - SimKeys.number_simulated_days: 31 + 'rand_seed': seed, + 'pop_size': AGENT_COUNT, + 'n_days': 31 } - self.set_simulation_parameters(params_dict=params) + self.set_sim_pars(params_dict=params) symptomatic_probability_of_test = 1.0 test_sensitivities = [0.9, 0.7, 0.6, 0.2] @@ -539,20 +346,14 @@ def test_test_prob_sensitivity(self, subtract_today_recoveries=False): test_delay=test_delay, start_day=start_day) self.run_sim() - first_day_diagnoses = self.get_full_result_channel( - channel=ResultsKeys.diagnoses_at_timestep - )[start_day] - target_count = self.get_full_result_channel( - channel=ResultsKeys.symptomatic_at_timestep - )[start_day] + first_day_diagnoses = self.get_full_result_ch(channel='new_diagnoses')[start_day] + target_count = self.get_full_result_ch('new_symptomatic')[start_day] if subtract_today_recoveries: - recoveries_today = self.get_full_result_channel( - channel=ResultsKeys.recovered_at_timestep - )[start_day] + recoveries_today = self.get_full_result_ch(channel='new_recoveries')[start_day] target_count = target_count - recoveries_today ideal_diagnoses = target_count * sensitivity - standard_deviation = sqrt(sensitivity * (1 - sensitivity) * target_count) + standard_deviation = np.sqrt(sensitivity * (1 - sensitivity) * target_count) # 99.7% confidence interval min_tolerable_diagnoses = ideal_diagnoses - 3 * standard_deviation max_tolerable_diagnoses = ideal_diagnoses + 3 * standard_deviation @@ -561,7 +362,7 @@ def test_test_prob_sensitivity(self, subtract_today_recoveries=False): print(f"\tMax: {max_tolerable_diagnoses} \n" f"\tMin: {min_tolerable_diagnoses} \n" f"\tTarget: {target_count} \n" - f"\tPrevious day Target: {self.get_full_result_channel(channel=ResultsKeys.symptomatic_at_timestep)[start_day -1 ]} \n" + f"\tPrevious day Target: {self.get_full_result_ch('new_symptomatic')[start_day -1 ]} \n" f"\tSensitivity: {sensitivity} \n" f"\tIdeal: {ideal_diagnoses} \n" f"\tActual diagnoses: {first_day_diagnoses}\n") @@ -595,20 +396,17 @@ def test_test_prob_sensitivity(self, subtract_today_recoveries=False): pass pass if len(seed_list) > 1: - with open(f"DEBUG_test_prob_sensitivity_sweep.json",'w') as outfile: + with open("DEBUG_test_prob_sensitivity_sweep.json",'w') as outfile: json.dump(error_seeds, outfile, indent=4) - pass acceptable_losses = len(seed_list) // 10 - self.assertLessEqual(len(error_seeds), - acceptable_losses, - msg=error_seeds) + self.assertLessEqual(len(error_seeds), acceptable_losses, msg=error_seeds) def test_test_prob_symptomatic_prob_of_test(self): params = { - SimKeys.number_agents: AGENT_COUNT, - SimKeys.number_simulated_days: 31 + 'pop_size': AGENT_COUNT, + 'n_days': 31 } - self.set_simulation_parameters(params_dict=params) + self.set_sim_pars(params_dict=params) symptomatic_probabilities_of_test = [0.9, 0.7, 0.6, 0.2] test_sensitivity = 1.0 @@ -621,14 +419,10 @@ def test_test_prob_symptomatic_prob_of_test(self): test_delay=test_delay, start_day=start_day) self.run_sim() - first_day_tests = self.get_full_result_channel( - channel=ResultsKeys.tests_at_timestep - )[start_day] - target_count = self.get_full_result_channel( - channel=ResultsKeys.symptomatic_at_timestep - )[start_day] + first_day_tests = self.get_full_result_ch(channel='new_tests')[start_day] + target_count = self.get_full_result_ch('n_symptomatic')[start_day] ideal_test_count = target_count * s_p_o_t - standard_deviation = sqrt(s_p_o_t * (1 - s_p_o_t) * target_count) + standard_deviation = np.sqrt(s_p_o_t * (1 - s_p_o_t) * target_count) # 99.7% confidence interval min_tolerable_tests = ideal_test_count - 3 * standard_deviation max_tolerable_tests = ideal_test_count + 3 * standard_deviation @@ -648,31 +442,21 @@ def test_test_prob_symptomatic_prob_of_test(self): msg=f"Expected no more than {max_tolerable_tests} tests with {target_count}" f" symptomatic and {s_p_o_t} sensitivity. Got {first_day_tests}" f" diagnoses, which is too high.") - pass - pass - # endregion - - # region contact tracing def test_brutal_contact_tracing(self): params = { - SimKeys.number_agents: AGENT_COUNT, - SimKeys.number_simulated_days: 55 + 'pop_size': AGENT_COUNT, + 'n_days': 55 } - self.set_simulation_parameters(params_dict=params) + self.set_sim_pars(params_dict=params) intervention_list = [] - symptomatic_probability_of_test = 1.0 test_sensitivity = 1.0 test_delay = 0 tests_start_day = 30 trace_start_day = 40 - - self.intervention_set_test_prob(symptomatic_prob=symptomatic_probability_of_test, - test_sensitivity=test_sensitivity, - test_delay=test_delay, - start_day=tests_start_day) + self.intervention_set_test_prob(symptomatic_prob=symptomatic_probability_of_test, test_sensitivity=test_sensitivity, test_delay=test_delay, start_day=tests_start_day) intervention_list.append(self.interventions) trace_probability = 1.0 @@ -691,25 +475,18 @@ def test_brutal_contact_tracing(self): 'c': trace_delay } - self.intervention_set_contact_tracing(start_day=trace_start_day, - trace_probabilities=trace_probabilities, - trace_times=trace_delays) + self.intervention_set_contact_tracing(start_day=trace_start_day, trace_probabilities=trace_probabilities, trace_times=trace_delays) intervention_list.append(self.interventions) self.interventions = intervention_list self.run_sim(population_type='hybrid') - channel_new_quarantines = self.get_full_result_channel( - ResultsKeys.quarantined_new - ) + channel_new_quarantines = self.get_full_result_ch('new_quarantined') quarantines_before_tracing = sum(channel_new_quarantines[:trace_start_day]) quarantines_before_delay_completed = sum(channel_new_quarantines[trace_start_day:trace_start_day + trace_delay]) quarantines_after_delay = sum(channel_new_quarantines[trace_start_day+trace_delay:]) - self.assertEqual(quarantines_before_tracing, 0, - msg="There should be no quarantines until tracing begins.") - self.assertEqual(quarantines_before_delay_completed, 0, - msg="There should be no quarantines until delay expires") - self.assertGreater(quarantines_after_delay, 0, - msg="There should be quarantines after tracing begins") + self.assertEqual(quarantines_before_tracing, 0, msg="There should be no quarantines until tracing begins.") + self.assertEqual(quarantines_before_delay_completed, 0, msg="There should be no quarantines until delay expires") + self.assertGreater(quarantines_after_delay, 0, msg="There should be quarantines after tracing begins") pass @@ -717,69 +494,46 @@ def test_contact_tracing_perfect_school_layer(self): self.is_debugging = False initial_infected = 10 params = { - SimKeys.number_agents: AGENT_COUNT, - SimKeys.number_simulated_days: 60, - SimKeys.quarantine_effectiveness: {'c':0.0, 'h':0.0, 'w':0.0, 's':0.0}, + 'pop_size': AGENT_COUNT, + 'n_days': 60, + 'quar_factor': {'c':0.0, 'h':0.0, 'w':0.0, 's':0.0}, 'quar_period': 10, - SimKeys.initial_infected_count: initial_infected + 'pop_infected': initial_infected } - self.set_simulation_parameters(params_dict=params) + self.set_sim_pars(params_dict=params) sequence_days = [30, 40] sequence_interventions = [] layers_to_zero_beta = ['c','h','w'] - self.intervention_set_test_prob(symptomatic_prob=1.0, - asymptomatic_prob=1.0, - test_sensitivity=1.0, - start_day=sequence_days[1]) + self.intervention_set_test_prob(symptomatic_prob=1.0, asymptomatic_prob=1.0, test_sensitivity=1.0, start_day=sequence_days[1]) sequence_interventions.append(self.interventions) - - self.intervention_set_changebeta(days_array=[sequence_days[0]], - multiplier_array=[0.0], - layers=layers_to_zero_beta) + self.intervention_set_changebeta(days_array=[sequence_days[0]], multiplier_array=[0.0], layers=layers_to_zero_beta) sequence_interventions.append(self.interventions) trace_probabilities = {'c': 0, 'h': 0, 'w': 0, 's': 1} trace_times = {'c': 0, 'h': 0, 'w': 0, 's': 0} - self.intervention_set_contact_tracing(start_day=sequence_days[1], - trace_probabilities=trace_probabilities, - trace_times=trace_times) + self.intervention_set_contact_tracing(start_day=sequence_days[1], trace_probabilities=trace_probabilities, trace_times=trace_times) sequence_interventions.append(self.interventions) self.interventions = sequence_interventions self.run_sim(population_type='hybrid') - channel_new_infections = self.get_full_result_channel( - ResultsKeys.infections_at_timestep - ) - channel_new_tests = self.get_full_result_channel( - ResultsKeys.tests_at_timestep - ) - channel_new_diagnoses = self.get_full_result_channel( - ResultsKeys.diagnoses_at_timestep - ) - channel_new_quarantine = self.get_full_result_channel( - ResultsKeys.quarantined_new - ) + channel_new_infections = self.get_full_result_ch('new_infections') + channel_new_tests = self.get_full_result_ch('new_tests') + channel_new_diagnoses = self.get_full_result_ch('new_diagnoses') + channel_new_quarantine = self.get_full_result_ch('new_quarantined') infections_before_quarantine = sum(channel_new_infections[sequence_days[0]:sequence_days[1]]) infections_after_quarantine = sum(channel_new_infections[sequence_days[1]:sequence_days[1] + 10]) if self.is_debugging: - print(f"Quarantined before, during, three days past sequence:" - f" {channel_new_quarantine[sequence_days[0] -1: sequence_days[-1] + 10]}") - print(f"Tested before, during, three days past sequence:" - f" {channel_new_tests[sequence_days[0] -1: sequence_days[-1] + 10]}") - print(f"Diagnosed before, during, three days past sequence:" - f" {channel_new_diagnoses[sequence_days[0] -1: sequence_days[-1] + 10]}") - print(f"Infections before, during, three days past sequence:" - f" {channel_new_infections[sequence_days[0] -1: sequence_days[-1] + 10]}") - print(f"10 Days after change beta but before quarantine: {infections_before_quarantine} " - f"should be less than 10 days after: {infections_after_quarantine}") - - self.assertLess(infections_after_quarantine, infections_before_quarantine, - msg=f"10 Days after change beta but before quarantine: {infections_before_quarantine} " - f"should be less than 10 days after: {infections_after_quarantine}") - - + print(f"Quarantined before, during, three days past sequence: {channel_new_quarantine[sequence_days[0] -1: sequence_days[-1] + 10]}") + print(f"Tested before, during, three days past sequence: {channel_new_tests[sequence_days[0] -1: sequence_days[-1] + 10]}") + print(f"Diagnosed before, during, three days past sequence: {channel_new_diagnoses[sequence_days[0] -1: sequence_days[-1] + 10]}") + print(f"Infections before, during, three days past sequence: {channel_new_infections[sequence_days[0] -1: sequence_days[-1] + 10]}") + print(f"10 Days after change beta but before quarantine: {infections_before_quarantine} should be less than 10 days after: {infections_after_quarantine}") + self.assertLess(infections_after_quarantine, infections_before_quarantine, msg=f"10 Days after change beta but before quarantine: {infections_before_quarantine} should be less than 10 days after: {infections_after_quarantine}") + +# Run unit tests if called as a script if __name__ == '__main__': - unittest.main() + unittest.TestCase.run = lambda self,*args,**kw: unittest.TestCase.debug(self) + unittest.main() \ No newline at end of file diff --git a/tests/unittests/test_miscellaneous_features.py b/tests/unittests/test_misc.py similarity index 89% rename from tests/unittests/test_miscellaneous_features.py rename to tests/unittests/test_misc.py index 2512e8dcd..3cdbe27e5 100644 --- a/tests/unittests/test_miscellaneous_features.py +++ b/tests/unittests/test_misc.py @@ -4,14 +4,14 @@ import unittest import pandas as pd -from unittest_support_classes import CovaSimTest +from unittest_support import CovaTest from covasim import Sim, parameters import os -class MiscellaneousFeatureTests(CovaSimTest): +class MiscellaneousFeatureTests(CovaTest): def setUp(self): super().setUp() - self.sim = Sim() + self.sim = Sim(pop_size=500) self.pars = parameters.make_pars() self.is_debugging = False @@ -22,9 +22,9 @@ def test_xslx_generation(self): excel_filename = f"{root_filename}.xlsx" if os.path.isfile(excel_filename): os.unlink(excel_filename) - pass test_infected_value = 31 params_dict = { + 'pop_size': 500, 'pop_infected': test_infected_value } self.run_sim(params_dict) @@ -33,7 +33,6 @@ def test_xslx_generation(self): expected_sheets = ['Results','Parameters'] for sheet in expected_sheets: self.assertIn(sheet, simulation_df.sheet_names) - pass params_df = simulation_df.parse('Parameters') observed_infected_param = params_df.loc[params_df['Parameter'] == 'pop_infected', 'Value'].values[0] self.assertEqual(observed_infected_param, test_infected_value, @@ -44,16 +43,13 @@ def test_xslx_generation(self): msg="Should be able to parse the day 0 n_exposed value from the results sheet.") if not self.is_debugging: os.unlink(excel_filename) - pass def test_set_pars_invalid_key(self): with self.assertRaises(KeyError) as context: self.sim['n_infectey'] = 10 - pass error_message = str(context.exception) self.assertIn('n_infectey', error_message) self.assertIn('pop_infected', error_message) - pass def test_update_pars_invalid_key(self): invalid_key = { @@ -61,11 +57,10 @@ def test_update_pars_invalid_key(self): } with self.assertRaises(KeyError) as context: self.sim.update_pars(invalid_key) - pass error_message = str(context.exception) self.assertIn('dooty_doo', error_message) - pass - +# Run unit tests if called as a script if __name__ == '__main__': + unittest.TestCase.run = lambda self,*args,**kw: unittest.TestCase.debug(self) unittest.main() \ No newline at end of file diff --git a/tests/unittests/test_mortality.py b/tests/unittests/test_mortality.py new file mode 100644 index 000000000..3d570c514 --- /dev/null +++ b/tests/unittests/test_mortality.py @@ -0,0 +1,68 @@ +""" +Tests of simulation parameters from +../../covasim/README.md +""" + +import covasim as cv +import unittest +from unittest_support import CovaTest + + +class DiseaseMortalityTests(CovaTest): + + def test_default_death_prob_one(self): + """ + Infect lots of people with cfr one and short time to die + duration. Verify that everyone dies, no recoveries. + """ + pop_size = 200 + n_days = 90 + sim = cv.Sim(pop_size=pop_size, pop_infected=pop_size, n_days=n_days) + for key in ['rel_symp_prob', 'rel_severe_prob', 'rel_crit_prob', 'rel_death_prob']: + sim[key] = 1e6 + sim.run() + assert sim.summary.cum_deaths == pop_size + + def test_default_death_prob_zero(self): + """ + Infect lots of people with cfr zero and short time to die + duration. Verify that no one dies. + Depends on default_cfr_one + """ + total_agents = 500 + self.everyone_dies(num_agents=total_agents) + prob_dict = {'rel_death_prob': 0.0} + self.set_sim_prog_prob(prob_dict) + self.run_sim() + deaths_at_timestep_ch = self.get_full_result_ch('new_deaths') + deaths_cumulative_ch = self.get_full_result_ch('cum_deaths') + death_chs = [deaths_at_timestep_ch,deaths_cumulative_ch] + for c in death_chs: + for t in range(len(c)): + self.assertEqual(c[t], 0, msg=f"There should be no deaths with critical to death probability 0.0. Channel {c} had bad data at t: {t}") + cumulative_recoveries = self.get_day_final_ch_value('cum_recoveries') + self.assertGreaterEqual(cumulative_recoveries, 200, msg="Should be lots of recoveries") + pass + + def test_default_death_prob_scaling(self): + """ + Infect lots of people with cfr zero and short time to die + duration. Verify that no one dies. + Depends on default_cfr_one + """ + total_agents = 500 + self.everyone_dies(num_agents=total_agents) + death_probs = [0.01, 0.05, 0.10, 0.15] + old_cum_deaths = 0 + for death_prob in death_probs: + prob_dict = {'rel_death_prob': death_prob} + self.set_sim_prog_prob(prob_dict) + self.run_sim() + cum_deaths = self.get_day_final_ch_value('cum_deaths') + self.assertGreaterEqual(cum_deaths, old_cum_deaths, msg="Should be more deaths with higer ratio") + old_cum_deaths = cum_deaths + +# Run unit tests if called as a script +if __name__ == '__main__': + unittest.TestCase.run = lambda self,*args,**kw: unittest.TestCase.debug(self) + unittest.main() \ No newline at end of file diff --git a/tests/unittests/test_pars.py b/tests/unittests/test_pars.py new file mode 100644 index 000000000..b3f99f073 --- /dev/null +++ b/tests/unittests/test_pars.py @@ -0,0 +1,144 @@ +""" +Tests of simulation parameters from +../../covasim/README.md +""" +import unittest +from unittest_support import CovaTest + + +class SimulationParameterTests(CovaTest): + + def test_population_size(self): + """ + Set population size to vanilla (1234) + Run sim for one day and check outputs + + Depends on run default simulation + """ + pop_2_one_day = { + 'pop_scale': 1, + 'n_days': 1, + 'pop_size': 2, + 'contacts': {'a': 1}, + 'pop_infected': 0 + } + pop_10_one_day = { + 'pop_scale': 1, + 'n_days': 1, + 'pop_size': 10, + 'contacts': {'a': 4}, + 'pop_infected': 0 + } + pop_123_one_day = { + 'pop_scale': 1, + 'n_days': 1, + 'pop_size': 123, + 'pop_infected': 0 + } + pop_1234_one_day = { + 'pop_scale': 1, + 'n_days': 1, + 'pop_size': 1234, + 'pop_infected': 0 + } + self.run_sim(pop_2_one_day) + pop_2_pop = self.get_day_zero_ch_value() + self.run_sim(pop_10_one_day) + pop_10_pop = self.get_day_zero_ch_value() + self.run_sim(pop_123_one_day) + pop_123_pop = self.get_day_zero_ch_value() + self.run_sim(pop_1234_one_day) + pop_1234_pop = self.get_day_zero_ch_value() + + self.assertEqual(pop_2_pop, pop_2_one_day['pop_size']) + self.assertEqual(pop_10_pop, pop_10_one_day['pop_size']) + self.assertEqual(pop_123_pop, pop_123_one_day['pop_size']) + self.assertEqual(pop_1234_pop, pop_1234_one_day['pop_size']) + + def test_population_size_ranges(self): + """ + Intent is to test zero, negative, and excessively large pop sizes + """ + pop_neg_one_day = { + 'pop_scale': 1, + 'n_days': 1, + 'pop_size': -10, + 'pop_infected': 0 + } + with self.assertRaises(ValueError) as context: + self.run_sim(pop_neg_one_day) + error_message = str(context.exception) + self.assertIn("negative", error_message) + + pop_zero_one_day = { + 'pop_scale': 1, + 'n_days': 100, + 'pop_size': 0, + 'pop_infected': 0 + } + self.run_sim(pop_zero_one_day) + self.assertEqual(self.simulation_result['results']['n_susceptible'][-1], 0) + self.assertEqual(self.simulation_result['results']['n_susceptible'][0], 0) + + def test_population_scaling(self): + """ + Scale population vanilla (x10) compare + output people vs parameter defined people + + Depends on population_size + """ + scale_1_one_day = { + 'pop_size': 100, + 'pop_scale': 1, + 'n_days': 1 + } + scale_2_one_day = { + 'pop_size': 100, + 'pop_scale': 2, + 'rescale': False, + 'n_days': 1 + } + scale_10_one_day = { + 'pop_size': 100, + 'pop_scale': 10, + 'rescale': False, + 'n_days': 1 + } + self.run_sim(scale_1_one_day) + scale_1_pop = self.get_day_zero_ch_value() + self.run_sim(scale_2_one_day) + scale_2_pop = self.get_day_zero_ch_value() + self.run_sim(scale_10_one_day) + scale_10_pop = self.get_day_zero_ch_value() + self.assertEqual(scale_2_pop, 2 * scale_1_pop) + self.assertEqual(scale_10_pop, 10 * scale_1_pop) + + def test_random_seed(self): + """ + Run two simulations with the same seed + and one with a different one. Something + randomly drawn (number of persons infected + day 2) is identical in the first two and + different in the third + """ + self.set_smallpop_hightransmission() + seed_1_params = {'rand_seed': 1} + seed_2_params = {'rand_seed': 2} + self.run_sim(seed_1_params) + infectious_seed_1_v1 = self.get_full_result_ch('new_infectious') + exposures_seed_1_v1 = self.get_full_result_ch('new_infections') + self.run_sim(seed_1_params) + infectious_seed_1_v2 = self.get_full_result_ch('new_infectious') + exposures_seed_1_v2 = self.get_full_result_ch('new_infections') + self.assertEqual(infectious_seed_1_v1, infectious_seed_1_v2, msg="With random seed the same, these channels should be identical.") + self.assertEqual(exposures_seed_1_v1, exposures_seed_1_v2, msg="With random seed the same, these channels should be identical.") + self.run_sim(seed_2_params) + infectious_seed_2 = self.get_full_result_ch('new_infectious') + exposures_seed_2 = self.get_full_result_ch('new_infections') + self.assertNotEqual(infectious_seed_1_v1, infectious_seed_2, msg="With random seed the different, these channels should be distinct.") + self.assertNotEqual(exposures_seed_1_v1, exposures_seed_2, msg="With random seed the different, these channels should be distinct.") + +# Run unit tests if called as a script +if __name__ == '__main__': + unittest.TestCase.run = lambda self,*args,**kw: unittest.TestCase.debug(self) + unittest.main() \ No newline at end of file diff --git a/tests/unittests/test_pops.py b/tests/unittests/test_pops.py new file mode 100644 index 000000000..efad21257 --- /dev/null +++ b/tests/unittests/test_pops.py @@ -0,0 +1,31 @@ +from unittest_support import CovaTest +import unittest + +class PopulationTypeTests(CovaTest): + + def test_different_pop_types(self): + pop_types = ['random', 'hybrid'] #, 'synthpops'] + results = {} + short_sample = { + 'pop_size': 1000, + 'n_days': 10, + 'pop_infected': 50 + } + for poptype in pop_types: + self.run_sim(short_sample, population_type=poptype) + results[poptype] = self.simulation_result['results'] + pass + self.assertEqual(len(results), len(pop_types)) + for k in results: + these_results = results[k] + self.assertIsNotNone(these_results) + day_0_susceptible = these_results['n_susceptible'][0] + day_0_exposed = these_results['cum_infections'][0] + self.assertEqual(day_0_susceptible + day_0_exposed, short_sample['pop_size'], msg=f"Day 0 population should be as specified in params. Poptype {k} was different.") + self.assertGreater(these_results['cum_infections'][-1], these_results['cum_infections'][0], msg=f"Should see infections increase. Pop type {k} didn't do that.") + self.assertGreater(these_results['cum_symptomatic'][-1], these_results['cum_symptomatic'][0], msg=f"Should see symptomatic counts increase. Pop type {k} didn't do that.") + +# Run unit tests if called as a script +if __name__ == '__main__': + unittest.TestCase.run = lambda self,*args,**kw: unittest.TestCase.debug(self) + unittest.main() \ No newline at end of file diff --git a/tests/unittests/test_population_types.py b/tests/unittests/test_population_types.py deleted file mode 100644 index 0ef8746b2..000000000 --- a/tests/unittests/test_population_types.py +++ /dev/null @@ -1,42 +0,0 @@ -from unittest_support_classes import CovaSimTest, TestProperties - -TPKeys = TestProperties.ParameterKeys.SimulationKeys - - -class PopulationTypeTests(CovaSimTest): - def setUp(self): - super().setUp() - pass - - def tearDown(self): - super().tearDown() - pass - - def test_different_pop_types(self): - pop_types = ['random', 'hybrid'] #, 'synthpops'] - results = {} - short_sample = { - TPKeys.number_agents: 1000, - TPKeys.number_simulated_days: 10, - TPKeys.initial_infected_count: 50 - } - for poptype in pop_types: - self.run_sim(short_sample, population_type=poptype) - results[poptype] = self.simulation_result['results'] - pass - self.assertEqual(len(results), len(pop_types)) - for k in results: - these_results = results[k] - self.assertIsNotNone(these_results) - day_0_susceptible = these_results[TestProperties.ResultsDataKeys.susceptible_at_timestep][0] - day_0_exposed = these_results[TestProperties.ResultsDataKeys.exposed_at_timestep][0] - - self.assertEqual(day_0_susceptible + day_0_exposed, short_sample[TPKeys.number_agents], - msg=f"Day 0 population should be as specified in params. Poptype {k} was different.") - self.assertGreater(these_results[TestProperties.ResultsDataKeys.infections_cumulative][-1], - these_results[TestProperties.ResultsDataKeys.infections_cumulative][0], - msg=f"Should see infections increase. Pop type {k} didn't do that.") - self.assertGreater(these_results[TestProperties.ResultsDataKeys.symptomatic_cumulative][-1], - these_results[TestProperties.ResultsDataKeys.symptomatic_cumulative][0], - msg=f"Should see symptomatic counts increase. Pop type {k} didn't do that.") - diff --git a/tests/unittests/test_progression.py b/tests/unittests/test_progression.py new file mode 100644 index 000000000..691ae0db0 --- /dev/null +++ b/tests/unittests/test_progression.py @@ -0,0 +1,112 @@ +""" +Tests of simulation parameters from +../../covasim/README.md +""" +import unittest +from unittest_support import CovaTest + +class DiseaseProgressionTests(CovaTest): + def setUp(self): + super().setUp() + pass + + def tearDown(self): + super().tearDown() + pass + + def test_exposure_to_infectiousness_delay_scaling(self): + """ + Set exposure to infectiousness early simulation, mid simulation, + late simulation. Set std_dev to zero. Verify move to infectiousness + moves later as delay is longer. + Depends on delay deviation test + """ + total_agents = 500 + self.set_everyone_infected(total_agents) + sim_dur = 60 + exposed_delays = [1, 2, 5, 15, 20, 25, 30] # Keep values in order + std_dev = 0 + for exposed_delay in exposed_delays: + self.set_duration_distribution_parameters( + duration_in_question='exp2inf', + par1=exposed_delay, + par2=std_dev + ) + prob_dict = {'rel_symp_prob': 0} + self.set_sim_prog_prob(prob_dict) + serial_delay = {'n_days': sim_dur} + self.run_sim(serial_delay) + infectious_ch = self.get_full_result_ch('new_infectious') + agents_on_infectious_day = infectious_ch[exposed_delay] + if self.is_debugging: + print(f"Delay: {exposed_delay}") + print(f"Agents turned: {agents_on_infectious_day}") + print(f"Infectious channel {infectious_ch}") + pass + for t in range(len(infectious_ch)): + current_infectious = infectious_ch[t] + if t < exposed_delay: + self.assertEqual(current_infectious, 0, msg=f"All {total_agents} should turn infectious at t: {exposed_delay} instead got {current_infectious} at t: {t}") + elif t == exposed_delay: + self.assertEqual(infectious_ch[exposed_delay], total_agents, msg=f"With stddev 0, all {total_agents} agents should turn infectious on day {exposed_delay}, instead got {agents_on_infectious_day}. ") + pass + + def test_mild_infection_duration_scaling(self): + """ + Make sure that all initial infected cease being infected + on following day. Std_dev 0 will help here + """ + total_agents = 500 + exposed_delay = 1 + self.set_everyone_infectious_same_day(num_agents=total_agents, days_to_infectious=exposed_delay) + prob_dict = {'rel_symp_prob': 0.0} + self.set_sim_prog_prob(prob_dict) + infectious_durations = [1, 2, 5, 10, 20] # Keep values in order + for TEST_dur in infectious_durations: + recovery_day = exposed_delay + TEST_dur + self.set_duration_distribution_parameters( + duration_in_question='asym2rec', + par1=TEST_dur, + par2=0 + ) + self.run_sim() + recoveries_ch = self.get_full_result_ch('new_recoveries') + recoveries_on_recovery_day = recoveries_ch[recovery_day] + if self.is_debugging: + print(f"Delay: {recovery_day}") + print(f"Agents turned: {recoveries_on_recovery_day}") + print(f"Recoveries channel {recoveries_ch}") + self.assertEqual(recoveries_ch[recovery_day], total_agents, msg=f"With stddev 0, all {total_agents} agents should turn infectious on day {recovery_day}, instead got {recoveries_on_recovery_day}. ") + + pass + + def test_time_to_die_duration_scaling(self): + total_agents = 500 + self.set_everyone_critical(num_agents=500, constant_delay=0) + prob_dict = {'rel_death_prob': 1.0} + self.set_sim_prog_prob(prob_dict) + + time_to_die_durations = [1, 2, 5, 10, 20] + time_to_die_stddev = 0 + + for TEST_dur in time_to_die_durations: + self.set_duration_distribution_parameters( + duration_in_question='crit2die', + par1=TEST_dur, + par2=time_to_die_stddev + ) + self.run_sim() + deaths_today_ch = self.get_full_result_ch('new_deaths') + for t in range(len(deaths_today_ch)): + curr_deaths = deaths_today_ch[t] + if t < TEST_dur: + self.assertEqual(curr_deaths, 0, msg=f"With std 0, all {total_agents} agents should die on t: {TEST_dur}. Got {curr_deaths} at t: {t}") + elif t == TEST_dur: + self.assertEqual(curr_deaths, total_agents, msg=f"With std 0, all {total_agents} agents should die at t: {TEST_dur}, got {curr_deaths} instead.") + else: + self.assertEqual(curr_deaths, 0, msg=f"With std 0, all {total_agents} agents should die at t: {TEST_dur}, got {curr_deaths} at t: {t}") + pass + +if __name__ == '__main__': + unittest.TestCase.run = lambda self,*args,**kw: unittest.TestCase.debug(self) + unittest.main() \ No newline at end of file diff --git a/tests/unittests/test_simulation_parameter.py b/tests/unittests/test_simulation_parameter.py deleted file mode 100644 index d5b9666ab..000000000 --- a/tests/unittests/test_simulation_parameter.py +++ /dev/null @@ -1,185 +0,0 @@ -""" -Tests of simulation parameters from -../../covasim/README.md -""" -import unittest - -from unittest_support_classes import CovaSimTest, TestProperties - -TPKeys = TestProperties.ParameterKeys.SimulationKeys -ResKeys = TestProperties.ResultsDataKeys - -class SimulationParameterTests(CovaSimTest): - def setUp(self): - super().setUp() - pass - - def tearDown(self): - super().tearDown() - pass - - def test_population_size(self): - """ - Set population size to vanilla (1234) - Run sim for one day and check outputs - - Depends on run default simulation - """ - TPKeys = TestProperties.ParameterKeys.SimulationKeys - pop_2_one_day = { - TPKeys.population_scaling_factor: 1, - TPKeys.number_simulated_days: 1, - TPKeys.number_agents: 2, - TPKeys.number_contacts: {'a': 1}, - TPKeys.initial_infected_count: 0 - } - pop_10_one_day = { - TPKeys.population_scaling_factor: 1, - TPKeys.number_simulated_days: 1, - TPKeys.number_agents: 10, - TPKeys.number_contacts: {'a': 4}, - TPKeys.initial_infected_count: 0 - } - pop_123_one_day = { - TPKeys.population_scaling_factor: 1, - TPKeys.number_simulated_days: 1, - TPKeys.number_agents: 123, - TPKeys.initial_infected_count: 0 - } - pop_1234_one_day = { - TPKeys.population_scaling_factor: 1, - TPKeys.number_simulated_days: 1, - TPKeys.number_agents: 1234, - TPKeys.initial_infected_count: 0 - } - self.run_sim(pop_2_one_day) - pop_2_pop = self.get_day_zero_channel_value() - self.run_sim(pop_10_one_day) - pop_10_pop = self.get_day_zero_channel_value() - self.run_sim(pop_123_one_day) - pop_123_pop = self.get_day_zero_channel_value() - self.run_sim(pop_1234_one_day) - pop_1234_pop = self.get_day_zero_channel_value() - - self.assertEqual(pop_2_pop, pop_2_one_day[TPKeys.number_agents]) - self.assertEqual(pop_10_pop, pop_10_one_day[TPKeys.number_agents]) - self.assertEqual(pop_123_pop, pop_123_one_day[TPKeys.number_agents]) - self.assertEqual(pop_1234_pop, pop_1234_one_day[TPKeys.number_agents]) - - pass - - def test_population_size_ranges(self): - """ - Intent is to test zero, negative, and excessively large pop sizes - """ - pop_neg_one_day = { - TPKeys.population_scaling_factor: 1, - TPKeys.number_simulated_days: 1, - TPKeys.number_agents: -10, - TPKeys.initial_infected_count: 0 - } - with self.assertRaises(ValueError) as context: - self.run_sim(pop_neg_one_day) - error_message = str(context.exception) - self.assertIn("negative", error_message) - - pop_zero_one_day = { - TPKeys.population_scaling_factor: 1, - TPKeys.number_simulated_days: 100, - TPKeys.number_agents: 0, - TPKeys.initial_infected_count: 0 - } - self.run_sim(pop_zero_one_day) - self.assertEqual(self.simulation_result['results'][ResKeys.susceptible_at_timestep][-1], 0) - self.assertEqual(self.simulation_result['results'][ResKeys.susceptible_at_timestep][0], 0) - - pass - - def test_population_scaling(self): - """ - Scale population vanilla (x10) compare - output people vs parameter defined people - - Depends on population_size - """ - scale_1_one_day = { - TPKeys.number_agents: 100, - TPKeys.population_scaling_factor: 1, - TPKeys.number_simulated_days: 1 - } - scale_2_one_day = { - TPKeys.number_agents: 100, - TPKeys.population_scaling_factor: 2, - TPKeys.population_rescaling: False, - TPKeys.number_simulated_days: 1 - } - scale_10_one_day = { - TPKeys.number_agents: 100, - TPKeys.population_scaling_factor: 10, - TPKeys.population_rescaling: False, - TPKeys.number_simulated_days: 1 - } - self.run_sim(scale_1_one_day) - scale_1_pop = self.get_day_zero_channel_value() - self.run_sim(scale_2_one_day) - scale_2_pop = self.get_day_zero_channel_value() - self.run_sim(scale_10_one_day) - scale_10_pop = self.get_day_zero_channel_value() - self.assertEqual(scale_2_pop, 2 * scale_1_pop) - self.assertEqual(scale_10_pop, 10 * scale_1_pop) - pass - - - def test_random_seed(self): - """ - Run two simulations with the same seed - and one with a different one. Something - randomly drawn (number of persons infected - day 2) is identical in the first two and - different in the third - """ - self.set_smallpop_hightransmission() - seed_1_params = { - TPKeys.random_seed: 1 - } - seed_2_params = { - TPKeys.random_seed: 2 - } - self.run_sim(seed_1_params) - infectious_seed_1_v1 = self.get_full_result_channel( - ResKeys.infectious_at_timestep - ) - exposures_seed_1_v1 = self.get_full_result_channel( - ResKeys.exposed_at_timestep - ) - self.run_sim(seed_1_params) - infectious_seed_1_v2 = self.get_full_result_channel( - ResKeys.infectious_at_timestep - ) - exposures_seed_1_v2 = self.get_full_result_channel( - ResKeys.exposed_at_timestep - ) - self.assertEqual(infectious_seed_1_v1, infectious_seed_1_v2, - msg=f"With random seed the same, these channels should" - f"be identical.") - self.assertEqual(exposures_seed_1_v1, exposures_seed_1_v2, - msg=f"With random seed the same, these channels should" - f"be identical.") - self.run_sim(seed_2_params) - infectious_seed_2 = self.get_full_result_channel( - ResKeys.infectious_at_timestep - ) - exposures_seed_2 = self.get_full_result_channel( - ResKeys.exposed_at_timestep - ) - self.assertNotEqual(infectious_seed_1_v1, infectious_seed_2, - msg=f"With random seed the different, these channels should" - f"be distinct.") - self.assertNotEqual(exposures_seed_1_v1, exposures_seed_2, - msg=f"With random seed the different, these channels should" - f"be distinct.") - pass - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/unittests/test_transmission.py b/tests/unittests/test_transmission.py new file mode 100644 index 000000000..489f72b11 --- /dev/null +++ b/tests/unittests/test_transmission.py @@ -0,0 +1,40 @@ +""" +Tests of simulation parameters from +../../covasim/README.md +""" + +import unittest +from unittest_support import CovaTest, SpecialSims +Hightrans = SpecialSims.Hightransmission + +class DiseaseTransmissionTests(CovaTest): + """ + Tests of the parameters involved in transmission + pre requisites simulation parameter tests + """ + + def test_beta_zero(self): + """ + Test that with beta at zero, no transmission + Start with high transmission sim + """ + self.set_smallpop_hightransmission() + beta_zero = {'beta': 0} + self.run_sim(beta_zero) + exposed_today_ch = self.get_full_result_ch('cum_infections') + prev_exposed = exposed_today_ch[0] + self.assertEqual(prev_exposed, Hightrans.pop_infected, msg="Make sure we have some initial infections") + for t in range(1, len(exposed_today_ch)): + today_exposed = exposed_today_ch[t] + self.assertLessEqual(today_exposed, prev_exposed, msg=f"The exposure counts should do nothing but decline. At time {t}: {today_exposed} at {t-1}: {prev_exposed}.") + prev_exposed = today_exposed + + infections_ch = self.get_full_result_ch('new_infections') + for t in range(len(infections_ch)): + today_infectious = infections_ch[t] + self.assertEqual(today_infectious, 0, msg=f"With beta 0, there should be no infections. At ts: {t} got {today_infectious}.") + +# Run unit tests if called as a script +if __name__ == '__main__': + unittest.TestCase.run = lambda self,*args,**kw: unittest.TestCase.debug(self) + unittest.main() \ No newline at end of file diff --git a/tests/unittests/unittest_support.py b/tests/unittests/unittest_support.py new file mode 100644 index 000000000..3e93c03a2 --- /dev/null +++ b/tests/unittests/unittest_support.py @@ -0,0 +1,360 @@ +""" +Classes that provide test content for tests later, +and easier configuration of tests to make tests +easier to red. + +Test implementation is agnostic to model implementation +by design. +""" + +import unittest +import json +import os +import numpy as np +import covasim as cv + + +class SpecialSims: + class Microsim: + n = 10 + pop_infected = 1 + contacts = 2 + n_days = 10 + + class Hightransmission: + n = 500 + pop_infected = 10 + n_days = 30 + contacts = 3 + beta = 0.4 + serial = 2 + dur = 3 + + class HighMortality: + n = 1000 + cfr_by_age = False + default_cfr = 0.2 + timetodie = 6 + + +class CovaTest(unittest.TestCase): + def setUp(self): + self.is_debugging = False + + self.sim_pars = None + self.sim_progs = None + self.sim = None + self.simulation_result = None + self.interventions = None + self.expected_result_filename = f"DEBUG_{self.id()}.json" + if os.path.isfile(self.expected_result_filename): + os.unlink(self.expected_result_filename) + + + def tearDown(self): + if not self.is_debugging: + if os.path.isfile(self.expected_result_filename): + os.unlink(self.expected_result_filename) + + + # region configuration methods + def set_sim_pars(self, params_dict=None): + """ + Overrides all of the default sim parameters + with the ones in the dictionary + Args: + params_dict: keys are param names, values are expected values to use + + Returns: + None, sets self.simulation_params + + """ + if not self.sim_pars: + self.sim_pars = cv.make_pars(set_prognoses=True, prog_by_age=True) + if params_dict: + self.sim_pars.update(params_dict) + + + def set_sim_prog_prob(self, params_dict): + """ + Allows for testing prognoses probability as absolute rather than relative. + NOTE: You can only call this once per test or you will overwrite your stuff. + """ + supported_probabilities = [ + 'rel_symp_prob', + 'rel_severe_prob', + 'rel_crit_prob', + 'rel_death_prob' + ] + if not self.sim_pars: + self.set_sim_pars() + + + if not self.sim_progs: + self.sim_progs = cv.get_prognoses(self.sim_pars['prog_by_age']) + + for k in params_dict: + prognosis_in_question = None + expected_prob = params_dict[k] + if k == 'rel_symp_prob': prognosis_in_question = 'symp_probs' + elif k == 'rel_severe_prob': prognosis_in_question = 'severe_probs' + elif k == 'rel_crit_prob': prognosis_in_question = 'crit_probs' + elif k == 'rel_death_prob': prognosis_in_question = 'death_probs' + else: + raise KeyError(f"Key {k} not found in {supported_probabilities}.") + old_probs = self.sim_progs[prognosis_in_question] + self.sim_progs[prognosis_in_question] = np.array([expected_prob] * len(old_probs)) + + + + def set_duration_distribution_parameters(self, duration_in_question, + par1, par2): + if not self.sim_pars: + self.set_sim_pars() + + duration_node = self.sim_pars["dur"] + duration_node[duration_in_question] = { + "dist": "normal", + "par1": par1, + "par2": par2 + } + params_dict = {"dur": duration_node} + self.set_sim_pars(params_dict=params_dict) + + def run_sim(self, params_dict=None, write_results_json=False, population_type=None): + if not self.sim_pars or params_dict: # If we need one, or have one here + self.set_sim_pars(params_dict=params_dict) + self.sim_pars['interventions'] = self.interventions + self.sim = cv.Sim(pars=self.sim_pars, datafile=None) + if not self.sim_progs: + self.sim_progs = cv.get_prognoses(self.sim_pars['prog_by_age']) + + self.sim['prognoses'] = self.sim_progs + if population_type: + self.sim.update_pars(pop_type=population_type) + self.sim.run(verbose=0) + self.simulation_result = self.sim.to_json(tostring=False) + if write_results_json or self.is_debugging: + with open(self.expected_result_filename, 'w') as outfile: + json.dump(self.simulation_result, outfile, indent=4, sort_keys=True) + + + def get_full_result_ch(self, channel): + result_data = self.simulation_result["results"][channel] + return result_data + + def get_day_zero_ch_value(self, channel='n_susceptible'): + """ + + Args: + channel: timeseries channel to report ('n_susceptible') + + Returns: day zero value for channel + + """ + result_data = self.get_full_result_ch(channel=channel) + return result_data[0] + + def get_day_final_ch_value(self, channel): + channel = self.get_full_result_ch(channel=channel) + return channel[-1] + + def intervention_set_changebeta(self, days_array, multiplier_array, layers = None): + self.interventions = cv.change_beta(days=days_array, changes=multiplier_array, layers=layers) + + + def intervention_set_test_prob(self, symptomatic_prob=0, asymptomatic_prob=0, asymptomatic_quarantine_prob=0, symp_quar_prob=0, test_sensitivity=1.0, loss_prob=0.0, test_delay=1, start_day=0): + self.interventions = cv.test_prob(symp_prob=symptomatic_prob, asymp_prob=asymptomatic_prob, asymp_quar_prob=asymptomatic_quarantine_prob, symp_quar_prob=symp_quar_prob, sensitivity=test_sensitivity, loss_prob=loss_prob, test_delay=test_delay, start_day=start_day) + + + def intervention_set_contact_tracing(self, start_day, trace_probabilities=None, trace_times=None): + + if not trace_probabilities: + trace_probabilities = {'h': 1, 's': 1, 'w': 1, 'c': 1} + + if not trace_times: + trace_times = {'h': 1, 's': 1, 'w': 1, 'c': 1} + self.interventions = cv.contact_tracing(trace_probs=trace_probabilities, trace_time=trace_times, start_day=start_day) + + + def intervention_build_sequence(self, day_list, intervention_list): + my_sequence = cv.sequence(days=day_list, interventions=intervention_list) + self.interventions = my_sequence + # endregion + + # region specialized simulation methods + def set_microsim(self): + Micro = SpecialSims.Microsim + microsim_parameters = { + 'pop_size' : Micro.n, + 'pop_infected': Micro.pop_infected, + 'n_days': Micro.n_days + } + self.set_sim_pars(microsim_parameters) + + + def set_everyone_infected(self, agent_count=1000): + everyone_infected = { + 'pop_size': agent_count, + 'pop_infected': agent_count + } + self.set_sim_pars(params_dict=everyone_infected) + + + def set_everyone_infectious_same_day(self, num_agents, days_to_infectious=1, num_days=60): + """ + Args: + num_agents: number of agents to create and infect + days_to_infectious: days until all agents are infectious (1) + num_days: days to simulate (60) + """ + self.set_everyone_infected(agent_count=num_agents) + prob_dict = { + 'rel_symp_prob': 0 + } + self.set_sim_prog_prob(prob_dict) + test_config = { + 'n_days': num_days + } + self.set_duration_distribution_parameters( + duration_in_question='exp2inf', + par1=days_to_infectious, + par2=0 + ) + self.set_sim_pars(params_dict=test_config) + + + def set_everyone_symptomatic(self, num_agents, constant_delay:int=None): + """ + Cause all agents in the simulation to begin infected + And proceed to symptomatic (but not severe or death) + Args: + num_agents: Number of agents to begin with + """ + self.set_everyone_infectious_same_day(num_agents=num_agents, + days_to_infectious=0) + prob_dict = { + 'rel_symp_prob': 1.0, + 'rel_severe_prob': 0 + } + self.set_sim_prog_prob(prob_dict) + if constant_delay is not None: + self.set_duration_distribution_parameters( + duration_in_question='inf2sym', + par1=constant_delay, + par2=0 + ) + + def everyone_dies(self, num_agents): + """ + Cause all agents in the simulation to begin infected and die. + Args: + num_agents: Number of agents to simulate + """ + self.set_everyone_infectious_same_day(num_agents=num_agents) + prob_dict = { + 'rel_symp_prob': 1, + 'rel_severe_prob': 1, + 'rel_crit_prob': 1, + 'rel_death_prob': 1 + } + self.set_sim_prog_prob(prob_dict) + + def set_everyone_severe(self, num_agents, constant_delay:int=None): + self.set_everyone_symptomatic(num_agents=num_agents, constant_delay=constant_delay) + prob_dict = { + 'rel_severe_prob': 1.0, + 'rel_crit_prob': 0.0 + } + self.set_sim_prog_prob(prob_dict) + if constant_delay is not None: + self.set_duration_distribution_parameters( + duration_in_question='sym2sev', + par1=constant_delay, + par2=0 + ) + + def set_everyone_critical(self, num_agents, constant_delay:int=None): + """ + Causes all agents to become critically ill day 1 + """ + self.set_everyone_severe(num_agents=num_agents, constant_delay=constant_delay) + prob_dict = { + 'rel_crit_prob': 1.0, + 'rel_death_prob': 0.0 + } + self.set_sim_prog_prob(prob_dict) + if constant_delay is not None: + self.set_duration_distribution_parameters( + duration_in_question='sev2crit', + par1=constant_delay, + par2=0 + ) + + + def set_smallpop_hightransmission(self): + """ + Creates a small population with lots of transmission + """ + Hightrans = SpecialSims.Hightransmission + hightrans_parameters = { + 'pop_size' : Hightrans.n, + 'pop_infected': Hightrans.pop_infected, + 'n_days': Hightrans.n_days, + 'beta' : Hightrans.beta + } + self.set_sim_pars(hightrans_parameters) + +class TestSupportTests(CovaTest): + def test_run_vanilla_simulation(self): + """ + Runs an uninteresting but predictable + simulation, makes sure that results + are created and json parsable + """ + self.assertIsNone(self.sim) + self.run_sim(write_results_json=True) + json_file_found = os.path.isfile(self.expected_result_filename) + self.assertTrue(json_file_found, msg=f"Expected {self.expected_result_filename} to be found.") + + + def test_everyone_infected(self): + """ + All agents start infected + """ + + total_agents = 500 + self.set_everyone_infected(agent_count=total_agents) + self.run_sim() + exposed_ch = 'cum_infections' + day_0_exposed = self.get_day_zero_ch_value(exposed_ch) + self.assertEqual(day_0_exposed, total_agents) + + + def test_run_small_hightransmission_sim(self): + """ + Runs a small simulation with lots of transmission + Verifies that there are lots of infections in + a short time. + """ + self.assertIsNone(self.sim_pars) + self.assertIsNone(self.sim) + self.set_smallpop_hightransmission() + self.run_sim() + + self.assertIsNotNone(self.sim) + self.assertIsNotNone(self.sim_pars) + exposed_today_ch = self.get_full_result_ch('cum_infections') + prev_exposed = exposed_today_ch[0] + for t in range(1, 10): + today_exposed = exposed_today_ch[t] + self.assertGreaterEqual(today_exposed, prev_exposed, msg=f"The first 10 days should have increasing exposure counts. At time {t}: {today_exposed} at {t-1}: {prev_exposed}.") + prev_exposed = today_exposed + + infections_ch = self.get_full_result_ch('new_infections') + self.assertGreaterEqual(sum(infections_ch), 150, msg="Should have at least 150 infections") + + + + + diff --git a/tests/unittests/unittest_support_classes.py b/tests/unittests/unittest_support_classes.py deleted file mode 100644 index ef503b3b2..000000000 --- a/tests/unittests/unittest_support_classes.py +++ /dev/null @@ -1,531 +0,0 @@ -""" -Classes that provide test content for tests later, -and easier configuration of tests to make tests -easier to red. - -Test implementation is agnostic to model implementation -by design. -""" - -import unittest -import json -import os -import numpy as np - -from covasim import Sim, parameters, change_beta, test_prob, contact_tracing, sequence - - -class TestProperties: - class ParameterKeys: - class SimulationKeys: - number_agents = 'pop_size' - number_contacts = 'contacts' - population_scaling_factor = 'pop_scale' - population_rescaling = 'rescale' - population_type = 'pop_type' - initial_infected_count = 'pop_infected' - start_day = 'start_day' - number_simulated_days = 'n_days' - random_seed = 'rand_seed' - verbose = 'verbose' - enable_synthpops = 'usepopdata' - time_limit = 'timelimit' - quarantine_effectiveness = 'quar_factor' - # stopping_function = 'stop_func' - pass - - class TransmissionKeys: - beta = 'beta' - asymptomatic_fraction = 'asym_prop' - asymptomatic_transmission_multiplier = 'asym_factor' - diagnosis_transmission_factor = 'iso_factor' - contact_transmission_factor = 'cont_factor' - contacts_per_agent = 'contacts' - beta_population_specific = 'beta_pop' - contacts_population_specific = 'contacts_pop' - pass - - class ProgressionKeys: - durations = "dur" - param_1 = "par1" - param_2 = "par2" - - class DurationKeys: - exposed_to_infectious = 'exp2inf' - infectious_to_symptomatic = 'inf2sym' - infectious_asymptomatic_to_recovered = 'asym2rec' - infectious_symptomatic_to_recovered = 'mild2rec' - symptomatic_to_severe = 'sym2sev' - severe_to_critical = 'sev2crit' - aymptomatic_to_recovered = 'asym2rec' - severe_to_recovered = 'sev2rec' - critical_to_recovered = 'crit2rec' - critical_to_death = 'crit2die' - pass - - class ProbabilityKeys: - progression_by_age = 'prog_by_age' - class RelativeProbKeys: - inf_to_symptomatic_probability = 'rel_symp_prob' - sym_to_severe_probability = 'rel_severe_prob' - sev_to_critical_probability = 'rel_crit_prob' - crt_to_death_probability = 'rel_death_prob' - pass - class PrognosesListKeys: - symptomatic_probabilities = 'symp_probs' - severe_probabilities = 'severe_probs' - critical_probabilities = 'crit_probs' - death_probs = 'death_probs' - pass - - class DiagnosticTestingKeys: - number_daily_tests = 'daily_tests' - daily_test_sensitivity = 'sensitivity' - symptomatic_testing_multiplier = 'sympt_test' - contacttrace_testing_multiplier = 'trace_test' - pass - pass - - class SpecializedSimulations: - class Microsim: - n = 10 - pop_infected = 1 - contacts = 2 - n_days = 10 - pass - class Hightransmission: - n = 500 - pop_infected = 10 - n_days = 30 - contacts = 3 - beta = 0.4 - serial = 2 - # serial_std = 0.5 - dur = 3 - pass - class HighMortality: - n = 1000 - cfr_by_age = False - default_cfr = 0.2 - timetodie = 6 - # timetodie_std = 2 - pass - - class ResultsDataKeys: - deaths_cumulative = 'cum_deaths' - deaths_daily = 'new_deaths' - diagnoses_cumulative = 'cum_diagnoses' - diagnoses_at_timestep = 'new_diagnoses' - exposed_at_timestep = 'n_exposed' - susceptible_at_timestep = 'n_susceptible' - infectious_at_timestep = 'n_infectious' - symptomatic_at_timestep = 'n_symptomatic' - symptomatic_cumulative = 'cum_symptomatic' - symptomatic_new_timestep = 'new_symptomatic' - recovered_at_timestep = 'new_recoveries' - recovered_cumulative = 'cum_recoveries' - infections_at_timestep = 'new_infections' - infections_cumulative = 'cum_infections' - tests_at_timestep = 'new_tests' - tests_cumulative = 'cum_tests' - quarantined_new = 'new_quarantined' - GUESS_doubling_time_at_timestep = 'doubling_time' - GUESS_r_effective_at_timestep = 'r_eff' - - pass - - -DurationKeys = TestProperties.ParameterKeys.ProgressionKeys.DurationKeys - - -class CovaSimTest(unittest.TestCase): - def setUp(self): - self.is_debugging = False - - self.simulation_parameters = None - self.simulation_prognoses = None - self.sim = None - self.simulation_result = None - self.interventions = None - self.expected_result_filename = f"DEBUG_{self.id()}.json" - if os.path.isfile(self.expected_result_filename): - os.unlink(self.expected_result_filename) - pass - - def tearDown(self): - if not self.is_debugging: - if os.path.isfile(self.expected_result_filename): - os.unlink(self.expected_result_filename) - pass - - # region configuration methods - def set_simulation_parameters(self, params_dict=None): - """ - Overrides all of the default sim parameters - with the ones in the dictionary - Args: - params_dict: keys are param names, values are expected values to use - - Returns: - None, sets self.simulation_params - - """ - if not self.simulation_parameters: - self.simulation_parameters = parameters.make_pars(set_prognoses=True, prog_by_age=True) - if params_dict: - self.simulation_parameters.update(params_dict) - pass - - def set_simulation_prognosis_probability(self, params_dict): - """ - Allows for testing prognoses probability as absolute rather than relative. - NOTE: You can only call this once per test or you will overwrite your stuff. - """ - ProbKeys = TestProperties.ParameterKeys.ProgressionKeys.ProbabilityKeys - RelativeProbabilityKeys = ProbKeys.RelativeProbKeys - supported_probabilities = [ - RelativeProbabilityKeys.inf_to_symptomatic_probability, - RelativeProbabilityKeys.sym_to_severe_probability, - RelativeProbabilityKeys.sev_to_critical_probability, - RelativeProbabilityKeys.crt_to_death_probability - ] - if not self.simulation_parameters: - self.set_simulation_parameters() - pass - - if not self.simulation_prognoses: - self.simulation_prognoses = parameters.get_prognoses(self.simulation_parameters[ProbKeys.progression_by_age]) - - PrognosisKeys = ProbKeys.PrognosesListKeys - for k in params_dict: - prognosis_in_question = None - expected_prob = params_dict[k] - if k == RelativeProbabilityKeys.inf_to_symptomatic_probability: - prognosis_in_question = PrognosisKeys.symptomatic_probabilities - elif k == RelativeProbabilityKeys.sym_to_severe_probability: - prognosis_in_question = PrognosisKeys.severe_probabilities - elif k == RelativeProbabilityKeys.sev_to_critical_probability: - prognosis_in_question = PrognosisKeys.critical_probabilities - elif k == RelativeProbabilityKeys.crt_to_death_probability: - prognosis_in_question = PrognosisKeys.death_probs - else: - raise KeyError(f"Key {k} not found in {supported_probabilities}.") - old_probs = self.simulation_prognoses[prognosis_in_question] - self.simulation_prognoses[prognosis_in_question] = np.array([expected_prob] * len(old_probs)) - pass - pass - - def set_duration_distribution_parameters(self, duration_in_question, - par1, par2): - if not self.simulation_parameters: - self.set_simulation_parameters() - pass - duration_node = self.simulation_parameters["dur"] - duration_node[duration_in_question] = { - "dist": "normal", - "par1": par1, - "par2": par2 - } - params_dict = { - "dur": duration_node - } - self.set_simulation_parameters(params_dict=params_dict) - - - def run_sim(self, params_dict=None, write_results_json=False, population_type=None): - if not self.simulation_parameters or params_dict: # If we need one, or have one here - self.set_simulation_parameters(params_dict=params_dict) - pass - - self.simulation_parameters['interventions'] = self.interventions - - self.sim = Sim(pars=self.simulation_parameters, - datafile=None) - if not self.simulation_prognoses: - self.simulation_prognoses = parameters.get_prognoses( - self.simulation_parameters[TestProperties.ParameterKeys.ProgressionKeys.ProbabilityKeys.progression_by_age] - ) - pass - - self.sim['prognoses'] = self.simulation_prognoses - if population_type: - self.sim.update_pars(pop_type=population_type) - self.sim.run(verbose=0) - self.simulation_result = self.sim.to_json(tostring=False) - if write_results_json or self.is_debugging: - with open(self.expected_result_filename, 'w') as outfile: - json.dump(self.simulation_result, outfile, indent=4, sort_keys=True) - pass - # endregion - - # region simulation results support - def get_full_result_channel(self, channel): - result_data = self.simulation_result["results"][channel] - return result_data - - def get_day_zero_channel_value(self, channel=TestProperties.ResultsDataKeys.susceptible_at_timestep): - """ - - Args: - channel: timeseries channel to report ('n_susceptible') - - Returns: day zero value for channel - - """ - result_data = self.get_full_result_channel(channel=channel) - return result_data[0] - - def get_day_final_channel_value(self, channel): - channel = self.get_full_result_channel(channel=channel) - return channel[-1] - # endregion - - # region interventions support - def intervention_set_changebeta(self, - days_array, - multiplier_array, - layers = None): - self.interventions = change_beta(days=days_array, - changes=multiplier_array, - layers=layers) - pass - - def intervention_set_test_prob(self, symptomatic_prob=0, asymptomatic_prob=0, - asymptomatic_quarantine_prob=0, symp_quar_prob=0, - test_sensitivity=1.0, loss_prob=0.0, test_delay=1, - start_day=0): - self.interventions = test_prob(symp_prob=symptomatic_prob, - asymp_prob=asymptomatic_prob, - asymp_quar_prob=asymptomatic_quarantine_prob, - symp_quar_prob=symp_quar_prob, - sensitivity=test_sensitivity, - loss_prob=loss_prob, - test_delay=test_delay, - start_day=start_day) - pass - - def intervention_set_contact_tracing(self, - start_day, - trace_probabilities=None, - trace_times=None): - - if not trace_probabilities: - trace_probabilities = {'h': 1, 's': 1, 'w': 1, 'c': 1} - pass - if not trace_times: - trace_times = {'h': 1, 's': 1, 'w': 1, 'c': 1} - self.interventions = contact_tracing(trace_probs=trace_probabilities, - trace_time=trace_times, - start_day=start_day) - pass - - def intervention_build_sequence(self, - day_list, - intervention_list): - my_sequence = sequence(days=day_list, - interventions=intervention_list) - self.interventions = my_sequence - # endregion - - # region specialized simulation methods - def set_microsim(self): - Simkeys = TestProperties.ParameterKeys.SimulationKeys - Micro = TestProperties.SpecializedSimulations.Microsim - microsim_parameters = { - Simkeys.number_agents : Micro.n, - Simkeys.initial_infected_count: Micro.pop_infected, - Simkeys.number_simulated_days: Micro.n_days - } - self.set_simulation_parameters(microsim_parameters) - pass - - def set_everyone_infected(self, agent_count=1000): - Simkeys = TestProperties.ParameterKeys.SimulationKeys - everyone_infected = { - Simkeys.number_agents: agent_count, - Simkeys.initial_infected_count: agent_count - } - self.set_simulation_parameters(params_dict=everyone_infected) - pass - - DurationKeys = TestProperties.ParameterKeys.ProgressionKeys.DurationKeys - - def set_everyone_infectious_same_day(self, num_agents, days_to_infectious=1, num_days=60): - """ - Args: - num_agents: number of agents to create and infect - days_to_infectious: days until all agents are infectious (1) - num_days: days to simulate (60) - """ - self.set_everyone_infected(agent_count=num_agents) - prob_dict = { - TestProperties.ParameterKeys.ProgressionKeys.ProbabilityKeys.RelativeProbKeys.inf_to_symptomatic_probability: 0 - } - self.set_simulation_prognosis_probability(prob_dict) - test_config = { - TestProperties.ParameterKeys.SimulationKeys.number_simulated_days: num_days - } - self.set_duration_distribution_parameters( - duration_in_question=DurationKeys.exposed_to_infectious, - par1=days_to_infectious, - par2=0 - ) - self.set_simulation_parameters(params_dict=test_config) - pass - - def set_everyone_symptomatic(self, num_agents, constant_delay:int=None): - """ - Cause all agents in the simulation to begin infected - And proceed to symptomatic (but not severe or death) - Args: - num_agents: Number of agents to begin with - """ - self.set_everyone_infectious_same_day(num_agents=num_agents, - days_to_infectious=0) - prob_dict = { - TestProperties.ParameterKeys.ProgressionKeys.ProbabilityKeys.RelativeProbKeys.inf_to_symptomatic_probability: 1.0, - TestProperties.ParameterKeys.ProgressionKeys.ProbabilityKeys.RelativeProbKeys.sym_to_severe_probability: 0 - } - self.set_simulation_prognosis_probability(prob_dict) - if constant_delay is not None: - self.set_duration_distribution_parameters( - duration_in_question=DurationKeys.infectious_to_symptomatic, - par1=constant_delay, - par2=0 - ) - pass - - def set_everyone_is_going_to_die(self, num_agents): - """ - Cause all agents in the simulation to begin infected and die. - Args: - num_agents: Number of agents to simulate - """ - ProbKeys = TestProperties.ParameterKeys.ProgressionKeys.ProbabilityKeys.RelativeProbKeys - self.set_everyone_infectious_same_day(num_agents=num_agents) - prob_dict = { - ProbKeys.inf_to_symptomatic_probability: 1, - ProbKeys.sym_to_severe_probability: 1, - ProbKeys.sev_to_critical_probability: 1, - ProbKeys.crt_to_death_probability: 1 - } - self.set_simulation_prognosis_probability(prob_dict) - pass - - def set_everyone_severe(self, num_agents, constant_delay:int=None): - self.set_everyone_symptomatic(num_agents=num_agents, constant_delay=constant_delay) - prob_dict = { - TestProperties.ParameterKeys.ProgressionKeys.ProbabilityKeys.RelativeProbKeys.sym_to_severe_probability: 1.0, - TestProperties.ParameterKeys.ProgressionKeys.ProbabilityKeys.RelativeProbKeys.sev_to_critical_probability: 0.0 - } - self.set_simulation_prognosis_probability(prob_dict) - if constant_delay is not None: - self.set_duration_distribution_parameters( - duration_in_question=DurationKeys.symptomatic_to_severe, - par1=constant_delay, - par2=0 - ) - pass - - def set_everyone_critical(self, num_agents, constant_delay:int=None): - """ - Causes all agents to become critically ill day 1 - """ - self.set_everyone_severe(num_agents=num_agents, constant_delay=constant_delay) - prob_dict = { - TestProperties.ParameterKeys.ProgressionKeys.ProbabilityKeys.RelativeProbKeys.sev_to_critical_probability: 1.0, - TestProperties.ParameterKeys.ProgressionKeys.ProbabilityKeys.RelativeProbKeys.crt_to_death_probability: 0.0 - } - self.set_simulation_prognosis_probability(prob_dict) - if constant_delay is not None: - self.set_duration_distribution_parameters( - duration_in_question=DurationKeys.severe_to_critical, - par1=constant_delay, - par2=0 - ) - pass - - - def set_smallpop_hightransmission(self): - """ - Creates a small population with lots of transmission - """ - Simkeys = TestProperties.ParameterKeys.SimulationKeys - Transkeys = TestProperties.ParameterKeys.TransmissionKeys - Hightrans = TestProperties.SpecializedSimulations.Hightransmission - hightrans_parameters = { - Simkeys.number_agents : Hightrans.n, - Simkeys.initial_infected_count: Hightrans.pop_infected, - Simkeys.number_simulated_days: Hightrans.n_days, - Transkeys.beta : Hightrans.beta - } - self.set_simulation_parameters(hightrans_parameters) - pass - - # endregion - pass - - - - -class TestSupportTests(CovaSimTest): - def test_run_vanilla_simulation(self): - """ - Runs an uninteresting but predictable - simulation, makes sure that results - are created and json parsable - """ - self.assertIsNone(self.sim) - self.run_sim(write_results_json=True) - json_file_found = os.path.isfile(self.expected_result_filename) - self.assertTrue(json_file_found, msg=f"Expected {self.expected_result_filename} to be found.") - pass - - def test_everyone_infected(self): - """ - All agents start infected - """ - - total_agents = 500 - self.set_everyone_infected(agent_count=total_agents) - self.run_sim() - exposed_channel = TestProperties.ResultsDataKeys.exposed_at_timestep - day_0_exposed = self.get_day_zero_channel_value(exposed_channel) - self.assertEqual(day_0_exposed, total_agents) - pass - - def test_run_small_hightransmission_sim(self): - """ - Runs a small simulation with lots of transmission - Verifies that there are lots of infections in - a short time. - """ - self.assertIsNone(self.simulation_parameters) - self.assertIsNone(self.sim) - self.set_smallpop_hightransmission() - self.run_sim() - - self.assertIsNotNone(self.sim) - self.assertIsNotNone(self.simulation_parameters) - exposed_today_channel = self.get_full_result_channel( - TestProperties.ResultsDataKeys.exposed_at_timestep - ) - prev_exposed = exposed_today_channel[0] - for t in range(1, 10): - today_exposed = exposed_today_channel[t] - self.assertGreaterEqual(today_exposed, prev_exposed, - msg=f"The first 10 days should have increasing" - f" exposure counts. At time {t}: {today_exposed} at" - f" {t-1}: {prev_exposed}.") - prev_exposed = today_exposed - pass - infections_channel = self.get_full_result_channel( - TestProperties.ResultsDataKeys.infections_at_timestep - ) - self.assertGreaterEqual(sum(infections_channel), 150, - msg=f"Should have at least 150 infections") - pass - pass - - -