diff --git a/Cargo.toml b/Cargo.toml index 2c56066c..24be9fc9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,7 +41,6 @@ assert_cmd = "^2.0.16" criterion = "^0.5.1" # Example Libraries -ixa_example_basic_infection = { path = "examples/basic-infection" } ixa_example_births_deaths = { path = "examples/births-deaths" } [lints.clippy] diff --git a/benches/example_basic_infection/example_basic_infection.rs b/benches/example_basic_infection/example_basic_infection.rs index b4621d37..04184993 100644 --- a/benches/example_basic_infection/example_basic_infection.rs +++ b/benches/example_basic_infection/example_basic_infection.rs @@ -1,6 +1,30 @@ +pub mod incidence_report; +pub mod infection_manager; +pub mod people; +pub mod transmission_manager; + use criterion::{criterion_group, criterion_main, Criterion}; -use ixa::Context; -use ixa_example_basic_infection::initialize; +use ixa::{Context, ContextRandomExt}; + +static POPULATION: u64 = 1000; +static SEED: u64 = 123; +static MAX_TIME: f64 = 303.0; +static FOI: f64 = 0.1; +static INFECTION_DURATION: f64 = 5.0; + +pub fn initialize(context: &mut Context) { + context.init_random(SEED); + + people::init(context); + transmission_manager::init(context); + infection_manager::init(context); + incidence_report::init(context).unwrap_or_else(|e| { + eprintln!("failed to init incidence_report: {e}"); + }); + context.add_plan(MAX_TIME, |context| { + context.shutdown(); + }); +} pub fn criterion_benchmark(c: &mut Criterion) { c.bench_function("example basic-infection", |bencher| { diff --git a/examples/basic-infection/src/incidence_report.rs b/benches/example_basic_infection/incidence_report.rs similarity index 97% rename from examples/basic-infection/src/incidence_report.rs rename to benches/example_basic_infection/incidence_report.rs index aa0b6e2f..98362f26 100644 --- a/examples/basic-infection/src/incidence_report.rs +++ b/benches/example_basic_infection/incidence_report.rs @@ -31,6 +31,7 @@ fn handle_infection_status_change(context: &mut Context, event: InfectionStatusE }); } +#[allow(clippy::missing_errors_doc)] pub fn init(context: &mut Context) -> Result<(), IxaError> { trace!("Initializing incidence_report"); diff --git a/examples/basic-infection/src/infection_manager.rs b/benches/example_basic_infection/infection_manager.rs similarity index 95% rename from examples/basic-infection/src/infection_manager.rs rename to benches/example_basic_infection/infection_manager.rs index ed126773..eeccec53 100644 --- a/examples/basic-infection/src/infection_manager.rs +++ b/benches/example_basic_infection/infection_manager.rs @@ -48,6 +48,8 @@ pub fn init(context: &mut Context) { #[cfg(test)] mod test { + #![allow(clippy::all)] // False positives + #![allow(unused_imports)] // False positives use crate::infection_manager::InfectionStatusEvent; use crate::people::InfectionStatus; use crate::people::InfectionStatusValue; @@ -57,6 +59,7 @@ mod test { define_data_plugin!(RecoveryPlugin, usize, 0); + #[allow(dead_code)] // False positive fn handle_recovery_event(context: &mut Context, event: InfectionStatusEvent) { if event.current == InfectionStatusValue::R { *context.get_data_container_mut(RecoveryPlugin) += 1; diff --git a/examples/basic-infection/src/people.rs b/benches/example_basic_infection/people.rs similarity index 95% rename from examples/basic-infection/src/people.rs rename to benches/example_basic_infection/people.rs index 478b5747..c18aaa70 100644 --- a/examples/basic-infection/src/people.rs +++ b/benches/example_basic_infection/people.rs @@ -21,6 +21,7 @@ define_person_property_with_default!( ); /// Populates the "world" with the `POPULATION` number of people. +#[allow(clippy::missing_panics_doc)] pub fn init(context: &mut Context) { trace!("Initializing people"); for _ in 0..POPULATION { diff --git a/examples/basic-infection/src/transmission_manager.rs b/benches/example_basic_infection/transmission_manager.rs similarity index 96% rename from examples/basic-infection/src/transmission_manager.rs rename to benches/example_basic_infection/transmission_manager.rs index ef79705c..74b88fef 100644 --- a/examples/basic-infection/src/transmission_manager.rs +++ b/benches/example_basic_infection/transmission_manager.rs @@ -51,6 +51,8 @@ pub fn init(context: &mut Context) { #[cfg(test)] mod test { + #![allow(clippy::all)] // False positives + #![allow(unused_imports)] // False positives use super::*; use crate::people::{InfectionStatus, InfectionStatusValue}; use crate::SEED; diff --git a/docs/book/models/disease_model b/docs/book/models/disease_model new file mode 120000 index 00000000..e2dcfca7 --- /dev/null +++ b/docs/book/models/disease_model @@ -0,0 +1 @@ +../../../examples/disease_model \ No newline at end of file diff --git a/examples/basic-infection/Cargo.toml b/examples/basic-infection/Cargo.toml deleted file mode 100644 index 9fb3b37c..00000000 --- a/examples/basic-infection/Cargo.toml +++ /dev/null @@ -1,20 +0,0 @@ -[package] -name = "ixa_example_basic_infection" -version = "0.1.0" -edition = "2021" - -publish = false - -[dependencies] -ixa = { path = "../../../ixa" } -ixa-derive = { path = "../../ixa-derive" } - -csv = "^1.3.1" -serde = "^1.0.217" -rand = "^0.8.5" -rand_distr = "^0.4.3" -paste = "^1.0.15" - -[[bin]] -name = "basic_infection" -path = "main.rs" diff --git a/examples/basic-infection/README.md b/examples/basic-infection/README.md deleted file mode 100644 index a4d33acf..00000000 --- a/examples/basic-infection/README.md +++ /dev/null @@ -1,332 +0,0 @@ -# Infection model: constant force of infection -This example demonstrates a simple model which infects a homogeneous population -assuming a constant force of infection. In other words, all individuals have the same -characteristics, and infections are caused by something like a food-borne disease - and not from interactions between individuals. - -## Simulation overview - -![Diagram of infection](infection-diagram.png) -![alt text](image.png) - -The first infection attempt is scheduled at time 0. Infection attempts are scheduled to occur based on the constant force of infection. Once an infection event is scheduled, a susceptible individual is selected to be infected. After an infection attempt is finished, the next infection event is scheduled based on a constant force of infection. The simulation ends after no more infection events are scheduled. - -Infected individuals schedule their recovery at time `t + infection_period`. The infection status of recovered individuals remains as recovered for the rest of the simulation. - -The global model parameters are as follows: -* `population_size`: number of individuals to include in the simulation -* `foi`: force of infection, rate at which susceptible individuals become infected -* `infection_period`: time that an individual spends from infection to recovery - -## Architecture -As in other `ixa` models, the simulation is managed by a central `Context` object -which loads parameters, initializes user-defined modules, and starts a callback -execution loop: - -```rust -struct Parameters { - random_seed: u64, - population_size: usize, - foi: f64, - infection_period: u64 -} - -let context = Context::new(); -context::load_parameters("config.toml") - -// Initialize modules -context::add_module(population_manager); -context::add_module(transmission_manager); -context::add_module(infection_manager); -context::add_module(person_property_report); - -// Run the simulation -context::execute(); -``` - -Individuals transition through a typical SIR pattern where they -start off as susceptible (S), are randomly selected to become infected -(I), and eventually recover (R). - -```mermaid -flowchart LR -S(Susceptible) --foi--> I(Infected) --infection_period--> R(Recovered) -``` - -The transition is unidirectional; once recovered, individuals cannot become -susceptible again. The simulation ends when no more infection attempts or -recoveries are scheduled. - -The basic structure of the model is as follows: - -* A `Population Loader` that initializes a population and attaches an infection - status property to each individual -* A `Transmission Manager` that attempts infections, updates the infections status when successful, and schedules the next attempt -* An `Infection Manager` that listens for infections and schedules recoveries -* A `Report Writer` module that listens for infections and recoveries and writes output to csv - files. - - Note this will require some kind of parameter loading utility -from `ixa` that reads from a config file or command line arguments, -and exposes values to modules as global properties. - -### People and person properties -When the `Population Loader` module initializes, a number of -persons are created and given a unique person id (from `0` to `population_size`). -This functionality is provided by an `create_person` method from `ixa`, which adds -them to a `People` data container. - -In order to record the infection status of a person, we use another `ixa` utility -for defining "person properties". Internally, this associates each person in -the `People` data container with an enum value and and provides an API for modules -to read it, change it, or subscribe to events -when the property is changed somewhere else in the system. - -```rust -InfectionStatus = enum( - Susceptible, - Infected, - Recovered -); - -for (person_id in 0..parameters.get_parameter(population_size)) { - context.create_person(person_id = person_id) -} - -context.define_person_property( - infection_status, - default = Susceptible -); -``` - -When initialized, each person is assigned a default state (`Susceptible`). - -Once the population has been created, all modules have been initialized, and event listeners have been registered (more on this below), the simulation is ready -to begin the execution loop. - -### Scheduling infections and recoveries -In this model, the `Transmission Manager` module begins the simulation by adding an -infection attempt `plan`, which is just a callback scheduled to execute at -`current_time = 0`. The callback randomly selects a person and transitions -them to infected if they are susceptible; if they are not susceptible, -it will skip over them. Finally, a new infection attempt is scheduled for - a time drawn from an exponential distribution with mean value of - `1/foi`. - -```rust -fn attempt_infection(context) { - transmission_rng = rng.get_rng(id = transmission); - population = context.get_population(); - person_to_infect = transmission_rng.sample_int(from = 0, to = population); - - if (context.get_infection_status(person_to_infect) == Susceptible) { - context.set_infection_status(person_to_infect, Infected); - } - - foi = parameters.get_parameter(foi); - time_next_infection = transmission_rng.draw_exponential(foi) / population; - context.add_plan(attempt_infection(context), time = context.get_time() + time_next_infection); -} - -//initialization -init(context) { - context.add_rng(id = transmission); - context.add_plan(attempt_infection(context), time = 0); -} -``` - -Note that this makes use of the following `ixa` functionality: - -* The getters/setters provided by `person_properties`, as described in the previous - section -* An `add_plan` method to register infection attempt callbacks -* A `random` module to sample the population and generate the next infection time - -Updating the `infection_status` of a person should broadcast a mutation -event through the system, which might be structured something like the following: - -![Event diagram](events.png) - -For any person property that is registered, `ixa` stores a list of callbacks -registered by other modules. When that person property is mutated, -the event manager releases an event with relevant related data -(the id of the person, the old and/or new property) to all matching -callbacks. - -In this model, when the `disease_status` is updated to `Infected`, a handler -registered by the `Infection Manager` will be triggered, which is responsible -for scheduling recovery plans: - -```rust -fn handler(context, person_id, previous_infection_status) { - if (context.get_infection_status(person_id) == Infected) { - infection_rng = context.get_rng(id = infection); - infection_period = parameters.get_parameter(infection_period) - recovery_time = infection_rng.draw_exponential(1/infection_period); - context.add_plan(context.set_infection_status(person_id, Recovered), time = recovery_time); - } -} - -//initialization -init(context) { - context.add_rng(id = infection); - context.observe_person_property_event::(handler); -} -``` - -Recovery of an infected individuals are scheduled for a time `t + infection_period` -where `infection_period` comes from an exponential distribution. A `rng` instance provides one independent from the one in the `Transmission Manager`. - -### Reports - -This model includes two types of reports focused on tracking the state of the -infection status: - -1. Instantaneous report on changes in person properties, and -2. The current state of person properties reported periodically. - -#### Instantaneous Reports -For this report, we want to record a timestamp when a person is infected -and when they recover. The output of the report will look something like this: - -``` -person_id,infection_status,t -0,Infected,0 -1,Infected,1.2 -0,Recovered,7.2 -1,Infected,8.5 -... -``` -At initialization, a `Report` module registers a type for `Incidence` -and subscribes change events on the `infection_status` of a person - -```rust -struct IncidenceReport { - person_id: u64, - infection_status: InfectionStatus, - t: u64 -} - -fn init(context) { - context.add_report::("incidence"); - context.observe_person_property_event::(handle_infection_status_change); -} -``` - -The method `handle_infection_status_change` writes a new line to the report file, the status, and the current time: - -```rust -fn handle_infection_status_change(context, person_id, prev, current) { - context.add_report(Incidence( - person_id, - infection_status: current, - t: context.get_current_time() - )); -} -``` - -One consideration here is that if the callback references context, it should -provide the state of the context exactly at the time the event was released. - -#### Periodic Reports -The second type of report records something about the current state of the -simulation at the end of a period, such as after every day. - -For this example, we record a count of the number of individuals with each -infection status (S,I,R) at the end of every day: - -``` -day,infection_status,count -0,Suceptible,92 -0,Infected,8 -0,Recovered,0 -1,Suceptible,89, -1,Infected,12 -1,Recovered,0 -... -``` - -In this case, we could actually compute each daily summary in -post-processing from the instantaneous reports instead of generating a second -set of periodic reports. However, we want a summary of properties -which are not otherwise recorded, such as perhaps whether an individual is -hospitalized. - -To efficiently keep track of the current state of each infection status, -we will create an additional data structure to keep a count of individuals in -each state and is updated every time a change event is released. - -```rust -// Internally, HashMap -let counter = PersonPropertyCounter::new(); -context.add_data_container(counter); -``` - -On initialization, the Report manager computes an initial count for each status. -In `update_property_counter` the event handler, the counter increments/decrements -the appropriate status. It also registers a hook that execute after all plans -for a time t have executed, i.e., `on_period_end` - -```rust -fn init(context){ - // Calculate the initial state - population = parameters::get_parameter(population); - for i in 0..population { - counter::increment( - counter.get_person_property_value(i) - ); - } - context::observe_person_property_event(update_property_counter); - context::on_period_end(0, report_periodic_item); -} -``` -Methods are implemented for the person property counter to increment and decrement -the counters. Changes in the person properties are -observed and the callback function `update_property_counter` updates the -counts for each property: - -```rust -fn handle_infection_status_change(context, person_id, prev, current) { - counter::increment(current); - counter::decrement(prev); -} -``` - -When all plans have executed for a given time t, `ixa` calls the on_period_end -callback to write the report row and schedule the next periodic report: - -```rust -fn report_periodic_item(t, context) { - // Add a row for each status - for infection_status in counter::iter() { - context.add_report(Period( - t, - infection_status, - count: counter::get_count(infection_status) - )); - } - - // Schedule next report - next_report_time = context::get_time() + parameters::get_parameter(reporting_period); - if next_report_time < parameters::get_parameter(max_time) { - context::on_period_end(report_periodic_item, report_periodic_item); - } -} -``` - -## Ixa dependencies - -The following are a summary of assumed dependencies from `ixa`: - -* `parameters` component: Loads parameters from a file or command line args, - loads them into global properties (below) -* `global_properties` component: Defines properties which can be accessible - to any module -* `person` component: Creates a `People` data container which unique ID for each person, - provides an `add_person()` method -* `person_properties` component: connects each person ID defined by the `person` - component with a specific property (e.g., `infection_status`), provides add/change/subscribe API -* `reports` component: Handles file writing, provides api for writing typed rows -* `random_number_generator` component: Provides seedable rng api to sample over - a distribution, list of person ids -* `event_manager` component: provides a global send/subscribe interface diff --git a/examples/basic-infection/events.png b/examples/basic-infection/events.png deleted file mode 100644 index f458cd9b..00000000 Binary files a/examples/basic-infection/events.png and /dev/null differ diff --git a/examples/basic-infection/infection-diagram.png b/examples/basic-infection/infection-diagram.png deleted file mode 100644 index 91bdddf1..00000000 Binary files a/examples/basic-infection/infection-diagram.png and /dev/null differ diff --git a/examples/basic-infection/main.rs b/examples/basic-infection/main.rs deleted file mode 100644 index 8bf15936..00000000 --- a/examples/basic-infection/main.rs +++ /dev/null @@ -1,10 +0,0 @@ -use ixa::runner::run_with_args; -use ixa_example_basic_infection::initialize; - -fn main() { - run_with_args(|context, _, _| { - initialize(context); - Ok(()) - }) - .unwrap(); -} diff --git a/examples/basic-infection/plot_output.R b/examples/basic-infection/plot_output.R deleted file mode 100644 index e98408f4..00000000 --- a/examples/basic-infection/plot_output.R +++ /dev/null @@ -1,17 +0,0 @@ -library(tidyverse) - -population = 1000 -foi = 0.1 -output_df <- read_csv("incidence.csv") |> - filter(infection_status == "I") |> - group_by(time) |> - mutate(inf = n()) |> - ungroup() |> - mutate(inf = cumsum(inf)) - -time_array = 0:ceiling(max(output_df$time)) - -expected_susc = population * exp(-foi * time_array) - -plot(output_df$time, population - output_df$inf, ylim = c(0,population)) -lines(time_array, expected_susc, col = "red") diff --git a/examples/basic-infection/src/lib.rs b/examples/basic-infection/src/lib.rs deleted file mode 100644 index 527de545..00000000 --- a/examples/basic-infection/src/lib.rs +++ /dev/null @@ -1,27 +0,0 @@ -use ixa::context::Context; -use ixa::random::ContextRandomExt; - -pub mod incidence_report; -pub mod infection_manager; -pub mod people; -pub mod transmission_manager; - -static POPULATION: u64 = 1000; -static SEED: u64 = 123; -static MAX_TIME: f64 = 303.0; -static FOI: f64 = 0.1; -static INFECTION_DURATION: f64 = 5.0; - -pub fn initialize(context: &mut Context) { - context.init_random(SEED); - - people::init(context); - transmission_manager::init(context); - infection_manager::init(context); - incidence_report::init(context).unwrap_or_else(|e| { - eprintln!("failed to init incidence_report: {}", e); - }); - context.add_plan(MAX_TIME, |context| { - context.shutdown(); - }); -} diff --git a/docs/book/models/disease_model/.gitignore b/examples/disease_model/.gitignore similarity index 100% rename from docs/book/models/disease_model/.gitignore rename to examples/disease_model/.gitignore diff --git a/docs/book/models/disease_model/Cargo.toml b/examples/disease_model/Cargo.toml similarity index 100% rename from docs/book/models/disease_model/Cargo.toml rename to examples/disease_model/Cargo.toml diff --git a/docs/book/models/disease_model/src/incidence_report.rs b/examples/disease_model/src/incidence_report.rs similarity index 99% rename from docs/book/models/disease_model/src/incidence_report.rs rename to examples/disease_model/src/incidence_report.rs index bf8867c5..a9cf9827 100644 --- a/docs/book/models/disease_model/src/incidence_report.rs +++ b/examples/disease_model/src/incidence_report.rs @@ -1,5 +1,4 @@ use crate::{infection_manager::InfectionStatusEvent, people::InfectionStatusValue}; -use csv; use ixa::{create_report_trait, trace, Context, ContextReportExt, IxaError, PersonId, Report}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; diff --git a/docs/book/models/disease_model/src/infection_manager.rs b/examples/disease_model/src/infection_manager.rs similarity index 100% rename from docs/book/models/disease_model/src/infection_manager.rs rename to examples/disease_model/src/infection_manager.rs diff --git a/docs/book/models/disease_model/src/main.rs b/examples/disease_model/src/main.rs similarity index 100% rename from docs/book/models/disease_model/src/main.rs rename to examples/disease_model/src/main.rs diff --git a/docs/book/models/disease_model/src/people.rs b/examples/disease_model/src/people.rs similarity index 100% rename from docs/book/models/disease_model/src/people.rs rename to examples/disease_model/src/people.rs diff --git a/docs/book/models/disease_model/src/transmission_manager.rs b/examples/disease_model/src/transmission_manager.rs similarity index 100% rename from docs/book/models/disease_model/src/transmission_manager.rs rename to examples/disease_model/src/transmission_manager.rs