13  Partitions Plugin

A common problem that arises during modeling is the need to select (sample) people based on complex criteria while the people are evolving relative to those criteria. For example, a model may need to select people to vaccinate while the selection policies evolve, people are changing their status relative to vaccination and the avalibilty of vaccine is waxing and waning. Another common example is in infectious contact management, where contact rules, tranmission probabilities and mitigation strategies are all changing over time. The resultant code in the relevant actors and data managers can be complex and have unexpected run time and memory allocation consequences.

The partitions plugin provides a robust general solution to these requirements by introducing the partition. A partition represents a subset of the population that meets various filtering criteria and is further subdivided into a cellular structure of sub-populations within that partition. Partitions remain current as people change their characteristics.

An example:

A partition could represent all people who are have been vaccinated in the last 20 years and who have not yet received a booster. The partition could be further subdivided by regional associations, work status or any other relevant person-related characteristic. An actor using the partition could then select people from the partition who live in a certain region and are employed. Person membership in the partition and the person’s location in the cells of the partition are maintained by the plugin and the client actor needs to simply specify the partition’s parameters once.

13.1 Plugin Data Initialization

Partitions are built dynamically by actors and data managers. While serialization of simulation state exceeds the scope of this chapter, the PartitionsPluginData class contains a single argument to support run-continuity between related experiment executions. The default policy is to not support run continuity and should only be set to true when the need for run continuity exists.

13.2 Plugin Behavior

The plugin adds a single data manager to the simulation as an instance of the PartitionsDataManager that is initialized with the PartitionsPluginData. The plugin has fixed dependencies on the People plugin and the Stochastic plugin. Unlike other plugins, its dependencies are dynamic and provided by the modeler. This topic will be covered later in the example code.

13.3 Data Manager

The PartitionsDataManager provides various operations with partitions. The data manager provides public methods that:

  • Add partitions
  • Remove partitions
  • Provide various queries about the state of partitions
  • Allow random sampling of people from partitions

Partitions are referenced by a client-assigned id and are maintained by the data manager. Each partition is composed of a multi-layered filter and zero to many labelers. A labeler is a mechanism for labeling a person based on that person’s characteristics. The labels form the dimensions of the cellular structure of the partition. For example, a labeler could label a person based on their integer age, but convert that age into labels such as “CHILD”, “ADULT” AND “SENIOR”. Labelers can span multiple personal charactertics such as combining regions with ages. For example, the labels might be “EASTERN_SHOOL_AGE_CHILDREN” or “WESTERN_SENIORS”, etc.

13.4 Example Code (Lesson 20)

Example_20.java shows the use of the partitions plugin. In it we will examine

  • The formation of partitions
  • The sampling of partitions

The example includes seven plugins:

  • People plugin – (GCM core plugin) used to manage people
  • Person properties plugin– (GCM core plugin) used to decorate properties onto people
  • Global properties plugin– (GCM core plugin) used to store policies and initial conditions
  • Stochastics plugin – (GCM core plugin) used to generate random numbers used in various decisions
  • Regions Plugin– (GCM core plugin) supports the person properties plugin
  • Partitions Plugin - (GCM core plugin) used for managing partitions
  • Model plugin – (local plugin) used to introduce three actors that will:
    • Load the population
    • Manage infectious contacts
    • Manage vaccinations

13.5 Model

The example’s model represents a disease that is preventable through vaccination. A small number of people at the start of the simulation are infected and the rest are susceptible. Vaccination is limited to uninfected adults and three vaccinations are needed to confer full immunity. There is 30 day delay between vaccine doses. The disease is transmitted at random from the infected to the susceptible, there is no incubation period and infectiousness lasts from 3 to 12 days. Vaccine supply is unlimited, but the vaccination rate is fixed with vaccine being assigned using lottery assignment: Older adults and those with the least vaccine protection are given a statistical preference.

13.6 Model Execution

The example’s execution is shown in Code Block 13.1

Code Block 13.1: The various plugins are gathered from their initial data and the experiment is executed.
private void execute() {
    Experiment.builder()//
            .addPlugin(getGlobalPropertiesPlugin())//
            .addPlugin(getPersonPropertiesPlugin())//
            .addPlugin(getRegionsPlugin())//
            .addPlugin(getStochasticPlugin())//
            .addPlugin(getPeoplePlugin())//
            .addPlugin(getPartitionsPlugin())//
            .addPlugin(ModelPlugin.getModelPlugin())//
            .addExperimentContextConsumer(getNIOReportItemHandler())//
            .build()//
            .execute();
}

There are several fixed global properties, Code Block 13.2 :

  • VACCINATOR_TYPE – the type of vaccinator used by the model
  • VACCINATIONS_PER_DAY – the number of vaccinations per day allowed
  • TRANSMISSION_PROBABILTY – the base probabilty of disease transmission per infectious contact
  • INFECTIOUS_CONTACT_RATE – the number of potentially infectious contacts per day per person
  • MINIMUM_INFECTIOUS_PERIOD – then minimum number of days that a person is infectious
  • MAXIMUM_INFECTIOUS_PERIOD – the maximum number of days that a person is infectious
  • INITIAL_INFECTION_COUNT – the number of people who are infectious at the begining of the simulation
  • INTER_VACCINATION_DELAY_TIME – the number of days required between a peson’s vaccinations
  • POPULATION_SIZE – the initial size of the population
Code Block 13.2: The global properties are all fixed values.
private Plugin getGlobalPropertiesPlugin() {
    GlobalPropertiesPluginData.Builder builder = GlobalPropertiesPluginData.builder();
    PropertyDefinition propertyDefinition = PropertyDefinition.builder().setPropertyValueMutability(false)
            .setDefaultValue(10_000).setType(Integer.class).build();
    builder.defineGlobalProperty(GlobalProperty.POPULATION_SIZE, propertyDefinition, 0);

    propertyDefinition = PropertyDefinition.builder().setPropertyValueMutability(false).setDefaultValue(10)
            .setType(Integer.class).build();
    builder.defineGlobalProperty(GlobalProperty.INITIAL_INFECTION_COUNT, propertyDefinition, 0);

    propertyDefinition = PropertyDefinition.builder().setPropertyValueMutability(false)
            .setDefaultValue(VaccinatorType.PARTITION).setType(VaccinatorType.class).build();
    builder.defineGlobalProperty(GlobalProperty.VACCINATOR_TYPE, propertyDefinition, 0);

    propertyDefinition = PropertyDefinition.builder().setPropertyValueMutability(false).setDefaultValue(3.0)
            .setType(Double.class).build();
    builder.defineGlobalProperty(GlobalProperty.MINIMUM_INFECTIOUS_PERIOD, propertyDefinition, 0);

    propertyDefinition = PropertyDefinition.builder().setPropertyValueMutability(false).setDefaultValue(12.0)
            .setType(Double.class).build();
    builder.defineGlobalProperty(GlobalProperty.MAXIMUM_INFECTIOUS_PERIOD, propertyDefinition, 0);

    propertyDefinition = PropertyDefinition.builder().setPropertyValueMutability(false).setDefaultValue(2.0)
            .setType(Double.class).build();
    builder.defineGlobalProperty(GlobalProperty.INFECTIOUS_CONTACT_RATE, propertyDefinition, 0);

    propertyDefinition = PropertyDefinition.builder().setPropertyValueMutability(false).setDefaultValue(0.15)
            .setType(Double.class).build();
    builder.defineGlobalProperty(GlobalProperty.TRANSMISSION_PROBABILITY, propertyDefinition, 0);

    propertyDefinition = PropertyDefinition.builder().setPropertyValueMutability(false).setDefaultValue(100)
            .setType(Integer.class).build();
    builder.defineGlobalProperty(GlobalProperty.VACCINATIONS_PER_DAY, propertyDefinition, 0);

    propertyDefinition = PropertyDefinition.builder().setPropertyValueMutability(false).setDefaultValue(30.0)
            .setType(Double.class).build();
    builder.defineGlobalProperty(GlobalProperty.INTER_VACCINATION_DELAY_TIME, propertyDefinition, 0);

    GlobalPropertiesPluginData globalPropertiesPluginData = builder.build();
    return GlobalPropertiesPlugin.builder().setGlobalPropertiesPluginData(globalPropertiesPluginData)
            .getGlobalPropertiesPlugin();
}

The person properties plugin contains four properties, Code Block 13.3:

  • AGE – the integer age of a person
  • WAITING_FOR_NEXT_DOSE – Boolean indicating that it is too soon to administer another dose of the vaccine
  • VACCINATION_COUNT – the number or vaccinations received
  • DISEASE_STATE – the state of the disease: SUSCEPTIBLE, INFECTIOUS or RECOVERED
Code Block 13.3: The four person properties are defined.
private Plugin getPersonPropertiesPlugin() {
    Builder builder = PersonPropertiesPluginData.builder();

    PropertyDefinition propertyDefinition = PropertyDefinition.builder()//
            .setPropertyValueMutability(false).setType(Integer.class).build();
    builder.definePersonProperty(PersonProperty.AGE, propertyDefinition, 0.0, false);

    propertyDefinition = PropertyDefinition.builder()//
            .setPropertyValueMutability(true).setType(Boolean.class)//
            .setDefaultValue(false).build();
    builder.definePersonProperty(PersonProperty.WAITING_FOR_NEXT_DOSE, propertyDefinition, 0.0, false);

    propertyDefinition = PropertyDefinition.builder()//
            .setPropertyValueMutability(true)//
            .setType(DiseaseState.class).setDefaultValue(DiseaseState.SUSCEPTIBLE).build();
    builder.definePersonProperty(PersonProperty.DISEASE_STATE, propertyDefinition, 0.0, false);

    propertyDefinition = PropertyDefinition.builder()//
            .setPropertyValueMutability(true)//
            .setType(Integer.class)//
            .setDefaultValue(0)//
            .build();
    builder.definePersonProperty(PersonProperty.VACCINATION_COUNT, propertyDefinition, 0.0, false);

    PersonPropertiesPluginData personPropertiesPluginData = builder.build();
    return PersonPropertiesPlugin.builder()//
            .setPersonPropertiesPluginData(personPropertiesPluginData)//
            .getPersonPropertyPlugin();
}

The regions plugin contains three regions that will be randomly assigned to people. It exists to fulfill a dependency of the person properties plugin. The stochastics and people plugins are similarly minimal.

The partitions plugin is initialized in Code Block 13.4. It requires dependencies on those plugins that will be used to calculate partition filters and labelers. This runs counter to intuition since the model plugin (via its actors) is using the partitions plugin. However, the partitions data manager needs to keep the partitions current with the state of people. In the example model, all partitions use person properties, so the person properties plugin must process events before the partitions plugin.

Code Block 13.4: The partitions plugin is simple, but requires that it has dependencies on those plugins that will be used to calculate partition filters and labelers.
private Plugin getPartitionsPlugin() {
    PartitionsPluginData partitionsPluginData = PartitionsPluginData.builder()//
            .setRunContinuitySupport(false).build();

    return PartitionsPlugin.builder()//
            .setPartitionsPluginData(partitionsPluginData)//
            .addPluginDependency(PersonPropertiesPluginId.PLUGIN_ID)//
            .getPartitionsPlugin();
}

13.7 Experiment dimensions

There are no experiment dimensions in this example with a single scenario being executed.

13.8 The Actors

13.8.1 Population Loader

The population loader adds 10,000 people to the simulation in Code Block 13.5. Each person is assigned a randomly chosen region. All person properties are left at default value except for AGE which is generated randomly.

Code Block 13.5: People are added to the simulation with region and age assignments.
public class PopulationLoader {
    public void init(ActorContext actorContext) {
        StochasticsDataManager stochasticsDataManager = actorContext.getDataManager(StochasticsDataManager.class);
        Well randomGenerator = stochasticsDataManager.getRandomGenerator();
        PeopleDataManager peopleDataManager = actorContext.getDataManager(PeopleDataManager.class);
        GlobalPropertiesDataManager globalPropertiesDataManager = actorContext
                .getDataManager(GlobalPropertiesDataManager.class);
        Integer populationSize = globalPropertiesDataManager.getGlobalPropertyValue(GlobalProperty.POPULATION_SIZE);
        for (int i = 0; i < populationSize; i++) {
            Region region = Region.getRandomRegion(randomGenerator);
            int age = AgeGroup.getRandomAge(randomGenerator);
            PersonPropertyValueInitialization personPropertyValueInitialization = new PersonPropertyValueInitialization(PersonProperty.AGE,age);
            PersonConstructionData personConstructionData = PersonConstructionData.builder()//
                    .add(region)//
                    .add(personPropertyValueInitialization)
                    .build();//
            peopleDataManager.addPerson(personConstructionData);
        }
    }
}

13.8.2 Contact Manager

The ContactManager actor, Code Block 13.6, schedules infectious contacts between infected people and the susceptible population. On its initialization, it establishes various parameters from the global variables and schedules the initial infections. It uses a simple partition configured in its default state. It has no filter and no labelers, so it will include all people in the simulation. It is used to select random people for potential infectious contacts and is somewhat more efficient than storing a list of all people locally.

Code Block 13.6: The Contact Manager uses a simple partition that contains the entire population and uses it to randomly select infectious contacts.
public void init(ActorContext actorContext) {
    this.actorContext = actorContext;
    partitionsDataManager = actorContext.getDataManager(PartitionsDataManager.class);
    personPropertiesDataManager = actorContext.getDataManager(PersonPropertiesDataManager.class);
    peopleDataManager = actorContext.getDataManager(PeopleDataManager.class);
    globalPropertiesDataManager = actorContext.getDataManager(GlobalPropertiesDataManager.class);
    StochasticsDataManager stochasticsDataManager = actorContext.getDataManager(StochasticsDataManager.class);
    randomGenerator = stochasticsDataManager.getRandomGenerator();

    loadGlobalProperties();
    establishPopulationPartition();
    initializeInfections();
}

private void establishPopulationPartition() {
    partitionsDataManager.addPartition(Partition.builder().build(), partitionKey);
}

Infection of a person, Code Block 13.7, results in the immediate scheduling of infectious contacts. The person is infectious for 3 to 12 days and will have 2 contacts per day. Each contact has a base 15% probability of infecting the contacted person, Code Block 13.8. Susceptible people who have been previously vaccinated have a reduced chance of contracting the disease. Note that the partition is used to select the contacted person and that the infectious person is excluded from contact since a person cannot contact themselves.

Code Block 13.7: Each time a person is infected, the contact manager schedules several follow-on infectious contacts.
private void infectPerson(PersonId personId) {
    personPropertiesDataManager.setPersonPropertyValue(personId, PersonProperty.DISEASE_STATE,
            DiseaseState.INFECTIOUS);

    double infectiousPeriod = randomGenerator.nextDouble() * (maximumInfectiousPeriod - minimumInfectiousPeriod)
            + minimumInfectiousPeriod;

    double lastContactTime = actorContext.getTime() + infectiousPeriod;

    double infectionTime = actorContext.getTime();
    while (true) {
        double contactDelay = (1.0 / infectiousContactRate);
        contactDelay *= (1 + randomGenerator.nextDouble() / 5 - 0.1);
        infectionTime += contactDelay;

        if (infectionTime < lastContactTime) {
            actorContext.addPlan((c2) -> {
                processInfectiousContact(personId);
            }, infectionTime);
        } else {
            break;
        }
    }
    actorContext.addPlan((c2) -> {
        endInfectiousness(personId);
    }, lastContactTime);

}
Code Block 13.8: Infectious contacts are subject to mitigation. To be infected, the contacted person must be susceptible and may having varying degrees of protection from previous vaccinations.
private void processInfectiousContact(PersonId personId) {
    
    PartitionSampler partitionSampler = PartitionSampler.builder().setExcludedPerson(personId).build();
    Optional<PersonId> optionalPersonId = partitionsDataManager.samplePartition(partitionKey, partitionSampler);
    if (optionalPersonId.isPresent()) {

        PersonId contactedPersonId = optionalPersonId.get();
        

        DiseaseState diseaseState = personPropertiesDataManager.getPersonPropertyValue(contactedPersonId,
                PersonProperty.DISEASE_STATE);
        if (diseaseState == DiseaseState.SUSCEPTIBLE) {

            int vaccinationCount = personPropertiesDataManager.getPersonPropertyValue(contactedPersonId,
                    PersonProperty.VACCINATION_COUNT);
            double mitigatedTransmissionProbability;

            switch (vaccinationCount) {
            case 0:
                mitigatedTransmissionProbability = 1;
                break;
            case 1:
                mitigatedTransmissionProbability = 0.5;
                break;
            case 2:
                mitigatedTransmissionProbability = 0.2;
                break;
            default:
                mitigatedTransmissionProbability = 0;
                break;
            }

            mitigatedTransmissionProbability *= transmissionProbability;

            if (randomGenerator.nextDouble() < mitigatedTransmissionProbability) {
                infectPerson(contactedPersonId);
            }
        }
    }

}

13.8.3 Vaccinator Manager

This example contains three versions of the vaccinator actor that demonstrate an evolving solution to person selection. The vaccine manager, Code Block 13.9, uses the global property,VACCINATOR_TYPE, to create an instance of the vaccinator actor. The global property is set to the partition vaccinator and the remaining two possibilities are left for reader inspection.

Code Block 13.9: The vaccine manager creates one of three vaccinator actors based on the global property,VACCINATOR_TYPE.
public class VaccinatorManager {
    public void init(ActorContext actorContext) {
        GlobalPropertiesDataManager globalPropertiesDataManager = actorContext.getDataManager(GlobalPropertiesDataManager.class);
        VaccinatorType vaccinatorType =
        globalPropertiesDataManager.getGlobalPropertyValue(GlobalProperty.VACCINATOR_TYPE);
        switch (vaccinatorType) {
        case PARTITION:
            actorContext.addActor(new PartitionVaccinator()::init);
            break;
        case EVENT:
            actorContext.addActor(new EventVaccinator()::init);
            break;
        case INSPECTION:
            actorContext.addActor(new InspectionVaccinator()::init);
            break;
        default:
            throw new RuntimeException("unhandled case "+vaccinatorType);                       
        }
    }
}

13.8.4 Inspection Vaccinator

The inspection vaccinator represents the most obvious approach to finding the next person to vaccinate. It initializes, Code Block 13.10, and immediately starts vaccinating the population.

Code Block 13.10: The inspection-based vaccinator establishes its working variables and begins planning the next vaccination.
public void init(ActorContext actorContext) {
    this.actorContext = actorContext;

    StochasticsDataManager stochasticsDataManager = actorContext.getDataManager(StochasticsDataManager.class);
    randomGenerator = stochasticsDataManager.getRandomGenerator();
    personPropertiesDataManager = actorContext.getDataManager(PersonPropertiesDataManager.class);
    peopleDataManager = actorContext.getDataManager(PeopleDataManager.class);
    globalPropertiesDataManager = actorContext.getDataManager(GlobalPropertiesDataManager.class);

    establishWorkingVariables();
    planNextVaccination();
}

Each vaccine attempt repeats the following steps, Code Block 13.11:

  • Gather the list of all people

  • Build data structures to hold those people in separate groups aligned to age and vaccination status priorities

  • Loop through the population, selecting people who:

    • Are adults
    • Are not infectious or recovered
    • Have fewer that 3 vaccinations
    • Are not the in post-vaccination 30 day waiting period
  • Assign each selected person to an age/vaccination count category

  • Note if there are people who will need vaccination in the future after the waiting period is over

  • Select a category based on the weight, Code Block 13.12, of the category and the number of people who are associated with the category

  • Select a person at random from the selected category

  • If a person was selected or a future vaccination will be needed, choose to continue the vaccination process

Code Block 13.11: The inspection-based vaccinator vaccinates 100 people per day. Each vaccination attempt considers every person in the simulation.
private void planNextVaccination() {
    actorContext.addPlan(this::vaccinatePerson, interVaccinationTime + actorContext.getTime());
}


private void vaccinatePerson(ActorContext actorContext) {
    List<PersonId> people = peopleDataManager.getPeople();
    Map<MultiKey, List<PersonId>> candidates = new LinkedHashMap<>();
    Map<MultiKey, Double> weights = new LinkedHashMap<>();

    List<AgeGroup> eligibleAgeGroups = new ArrayList<>();
    eligibleAgeGroups.add(AgeGroup.ADULT_18_44);
    eligibleAgeGroups.add(AgeGroup.ADULT_45_64);
    eligibleAgeGroups.add(AgeGroup.SENIOR);

    List<Integer> eligibleVaccineCounts = new ArrayList<>();
    eligibleVaccineCounts.add(0);
    eligibleVaccineCounts.add(1);
    eligibleVaccineCounts.add(2);

    for (AgeGroup ageGroup : eligibleAgeGroups) {
        for (Integer vaccineCount : eligibleVaccineCounts) {
            MultiKey multiKey = new MultiKey(ageGroup, vaccineCount);
            double weight = getWeight(ageGroup, vaccineCount);
            weights.put(multiKey, weight);
            candidates.put(multiKey, new ArrayList<>());
        }
    }

    potentialEligiblePeopleExist = false;

    for (PersonId personId : people) {
        int age = personPropertiesDataManager.getPersonPropertyValue(personId, PersonProperty.AGE);
        if (age < 18) {
            continue;
        }
        DiseaseState diseaseState = personPropertiesDataManager.getPersonPropertyValue(personId,
                PersonProperty.DISEASE_STATE);
        if (diseaseState != DiseaseState.SUSCEPTIBLE) {
            continue;
        }
        int vaccinationCount = personPropertiesDataManager.getPersonPropertyValue(personId,
                PersonProperty.VACCINATION_COUNT);
        if (vaccinationCount > 2) {
            continue;
        }

        boolean waitingFromPreviousVaccination = personPropertiesDataManager.getPersonPropertyValue(personId,
                PersonProperty.WAITING_FOR_NEXT_DOSE);

        if (waitingFromPreviousVaccination) {
            potentialEligiblePeopleExist = true;
            continue;
        }

        AgeGroup ageGroup = AgeGroup.getAgeGroup(age);
        MultiKey multiKey = new MultiKey(ageGroup, vaccinationCount);
        candidates.get(multiKey).add(personId);
    }

    Map<MultiKey, Double> extendedWeights = new LinkedHashMap<>();

    double sumOfExtendedWeights = 0;
    for (MultiKey multiKey : weights.keySet()) {
        Double weight = weights.get(multiKey);
        int candidateCount = candidates.get(multiKey).size();
        Double extenedWeight = weight * candidateCount;
        extendedWeights.put(multiKey, extenedWeight);
        sumOfExtendedWeights += extenedWeight;
    }

    PersonId selectedCandidate = null;

    double selectedWeight = sumOfExtendedWeights * randomGenerator.nextDouble();
    for (MultiKey multiKey : extendedWeights.keySet()) {
        Double extendedWeight = extendedWeights.get(multiKey);
        selectedWeight -= extendedWeight;
        if (selectedWeight <= 0) {
            List<PersonId> selectedCandidates = candidates.get(multiKey);
            if (!selectedCandidates.isEmpty()) {
                int index = randomGenerator.nextInt(selectedCandidates.size());
                selectedCandidate = selectedCandidates.get(index);
            }
            break;
        }
    }

    if (selectedCandidate != null) {

        int vaccinationCount = personPropertiesDataManager.getPersonPropertyValue(selectedCandidate,
                PersonProperty.VACCINATION_COUNT);
        vaccinationCount++;
        personPropertiesDataManager.setPersonPropertyValue(selectedCandidate, PersonProperty.VACCINATION_COUNT,
                vaccinationCount);
        if (vaccinationCount < 3) {
            personPropertiesDataManager.setPersonPropertyValue(selectedCandidate,
                    PersonProperty.WAITING_FOR_NEXT_DOSE, true);
            planWaitTermination(selectedCandidate);
        }
        planNextVaccination();
    } else {
        if (potentialEligiblePeopleExist) {
            planNextVaccination();
        }
    }
}
Code Block 13.12: The inspection-based vaccinator assigns a probability weight to each person based on their age and number of vaccine doses administered.
private double getWeight(AgeGroup ageGroup, int vaccineCount) {

    double result = 1;

    switch (ageGroup) {
    case ADULT_18_44:
        result += 0;
        break;
    case ADULT_45_64:
        result += 3;
        break;
    case CHILD:
        result += 0;
        break;
    case SENIOR:
        result += 10;
        break;
    default:
        break;
    }

    switch (vaccineCount) {
    case 0:
        result += 4;
        break;
    case 1:
        result += 3;
        break;
    case 2:
        result += 2;
        break;
    default:
        result += 0;
        break;
    }

    return result;
}

While this process is fairly straight forward, it does involve fairly complex and tricky calculations. Worse yet, it is extremely repetitive and does not scale well to realistic population sizes. It is orders of magnitude slower than the next two approaches.

13.8.5 Event Vaccinator

The event vaccinator transforms the inspection vaccinator’s approach by retaining the category organizational structures and using the event system to maintain the lists of eligible people. It initializes, Code Block 13.13, by building the sub-populations of eligible people and subscribing to all events that may alter those populations.

Code Block 13.13: The event-based vaccinator improves on the inspection-based vaccinator by maintaining the eligible sub-populations.
public void init(ActorContext actorContext) {
    this.actorContext = actorContext;

    StochasticsDataManager stochasticsDataManager = actorContext.getDataManager(StochasticsDataManager.class);
    randomGenerator = stochasticsDataManager.getRandomGenerator();
    personPropertiesDataManager = actorContext.getDataManager(PersonPropertiesDataManager.class);
    peopleDataManager = actorContext.getDataManager(PeopleDataManager.class);
    globalPropertiesDataManager = actorContext.getDataManager(GlobalPropertiesDataManager.class);

    establishWorkingVariables();
    subscribeToPersonPropertyUpdateEvents();
    initializeCandidatesAndWeights();
    planNextVaccination();
}

The vaccination process is very similar to the inspection-based method, but there is no recalculation of the sub-populations, Code Block 13.14. Instead, the event-based approach subscribes to all person property updates, Code Block 13.15 , and processes each update by:

  • Removing the relevant person from the sub-populations
  • Filtering out people who :
    • Are not susceptible
    • Are not adults
    • Are waiting from the last dose
    • Already have 3 vaccinations
  • Adding the person back into the sub-populations. Note the person may have moved from one sub-population to another.
Code Block 13.14: The event-based vaccinator selects from maintained sub-populations.
private void planNextVaccination() {
    futurePlan = new ConsumerActorPlan(interVaccinationTime + actorContext.getTime(), this::vaccinatePerson);
    actorContext.addPlan(futurePlan);
}

private void vaccinatePerson(ActorContext actorContext) {

    Map<MultiKey, Double> extendedWeights = new LinkedHashMap<>();

    double sumOfExtendedWeights = 0;
    for (MultiKey multiKey : weights.keySet()) {
        Double weight = weights.get(multiKey);
        int candidateCount = candidates.get(multiKey).size();
        Double extendedWeight = weight * candidateCount;
        extendedWeights.put(multiKey, extendedWeight);
        sumOfExtendedWeights += extendedWeight;
    }

    PersonId selectedCandidate = null;

    double selectedWeight = sumOfExtendedWeights * randomGenerator.nextDouble();
    for (MultiKey multiKey : extendedWeights.keySet()) {
        Double extendedWeight = extendedWeights.get(multiKey);
        selectedWeight -= extendedWeight;
        if (selectedWeight <= 0) {
            List<PersonId> selectedCandidates = candidates.get(multiKey);
            if (!selectedCandidates.isEmpty()) {
                int index = randomGenerator.nextInt(selectedCandidates.size());
                selectedCandidate = selectedCandidates.get(index);
            }
            break;
        }
    }

    if (selectedCandidate != null) {

        int vaccinationCount = personPropertiesDataManager.getPersonPropertyValue(selectedCandidate,
                PersonProperty.VACCINATION_COUNT);
        vaccinationCount++;
        personPropertiesDataManager.setPersonPropertyValue(selectedCandidate, PersonProperty.VACCINATION_COUNT,
                vaccinationCount);
        if (vaccinationCount < 3) {
            personPropertiesDataManager.setPersonPropertyValue(selectedCandidate,
                    PersonProperty.WAITING_FOR_NEXT_DOSE, true);
            planWaitTermination(selectedCandidate);
        }
        planNextVaccination();
    }

}
Code Block 13.15: The event-based vaccinator processes each person property update event by first removing the person from the sub-populations and then adding them back in if required.
private void handlePersonPropertyChange(ActorContext actorContext,
        PersonPropertyUpdateEvent personPropertyUpdateEvent) {

    PersonId personId = personPropertyUpdateEvent.personId();

    // remove the person if they are being tracked
    MultiKey multiKey = groupMap.remove(personId);
    List<PersonId> list = candidates.get(multiKey);
    if (list != null) {
        list.remove(personId);
    }

    DiseaseState diseaseState = personPropertiesDataManager.getPersonPropertyValue(personId,
            PersonProperty.DISEASE_STATE);

    // the person must be susceptible
    if (diseaseState != DiseaseState.SUSCEPTIBLE) {
        return;
    }

    Integer vaccinationCount = personPropertiesDataManager.getPersonPropertyValue(personId,
            PersonProperty.VACCINATION_COUNT);

    if (vaccinationCount > 2) {
        return;
    }

    int age = personPropertiesDataManager.getPersonPropertyValue(personId, PersonProperty.AGE);
    AgeGroup ageGroup = AgeGroup.getAgeGroup(age);
    if (ageGroup == AgeGroup.CHILD) {
        return;
    }
    boolean waitingForNextDose = personPropertiesDataManager.getPersonPropertyValue(personId,
            PersonProperty.WAITING_FOR_NEXT_DOSE);
    if (waitingForNextDose) {
        return;
    }

    multiKey = new MultiKey(ageGroup, vaccinationCount);

    list = candidates.get(multiKey);
    if (list != null) {
        list.add(personId);
        groupMap.put(personId, multiKey);
    }

}

Some care must be given to properly terminating the vaccination process. When a vaccination attempt is made and there is no eligible candidate, no new attempt is scheduled. Instead, the vaccinator relies on knowing that a new candidate will appear only when a person has ended their post-vaccination waiting period. To accomplish this, Code Block 13.16, the vaccinator uses a plan id for vaccination plans. When it processes the end of the waiting period for a person it looks to the simulation and determines whether there is a future plan to vaccinate. If no such plan exists, it immediately vaccinates the person and re-starts the vaccination process.

Code Block 13.16: The event-vaccinator can restart the vaccination process when a person becomes eligible after the post-vaccination waiting period is over.
private void endWaitTime(PersonId personId) {
    personPropertiesDataManager.setPersonPropertyValue(personId, PersonProperty.WAITING_FOR_NEXT_DOSE, false);
    if (futurePlan == null) {
        vaccinatePerson(actorContext);
    }
}

The performance of the code is multiple orders of magnitude better than the inspection approach. However, there are a few drawbacks:

  • The code is more complex and is more difficult to get right. Edge cases abound.
  • It still relies on list/map/set based data structures which can be slow and use too much memory.
  • If the selection criteria were to become more complex or if the actor needs to select people from subsets of the eligible people for special purposes as occurs in more realistic use cases, a great deal of effort would have to expended to ensure correctly functioning code.

13.8.6 Partition Vaccinator

The partition-vaccinator improves on the event-vaccinator by using partitions. Internally, the partitions are performing similar event-triggered updates of the sub-populations. However, their approach is more sophisticated:

  • Supports complex filtering
  • Allows for categorization of data via labels
  • Supports multi-dimensional labeling of the filtered population
  • Supports nuanced sampling of the population that can be aligned to label based subsets

The vaccinator initializes, Code Block 13.17, by creating partitions that will manage the data structures and subscribe to the relevant events. It then proceeds with vaccination planning as in the previous versions.

Code Block 13.17: The partition-vaccinator manages the eligible population via partitions.
public void init(ActorContext actorContext) {
    this.actorContext = actorContext;
    personPropertiesDataManager = actorContext.getDataManager(PersonPropertiesDataManager.class);
    partitionsDataManager = actorContext.getDataManager(PartitionsDataManager.class);
    globalPropertiesDataManager = actorContext.getDataManager(GlobalPropertiesDataManager.class);
    establishWorkingVariables();
    createPartitions();
    planNextVaccination();
}

The vaccinator uses two partitions to manage vaccination, Code Block 13.18. The first partition is used to select currently eligible people and the second covers people who may become eligible in the future and is used to determine if vaccination of the population is complete.

Code Block 13.18: The partition-vaccinator creates two partitions to help with person selection and termination of vaccinations.
private void createPartitions() {

    PersonPropertyFilter ageFilter = new PersonPropertyFilter(PersonProperty.AGE, Equality.GREATER_THAN_EQUAL, 18);

    PersonPropertyFilter diseaseFilter = new PersonPropertyFilter(PersonProperty.DISEASE_STATE, Equality.EQUAL,
            DiseaseState.SUSCEPTIBLE);

    PersonPropertyFilter vaccineFilter = new PersonPropertyFilter(PersonProperty.VACCINATION_COUNT,
            Equality.LESS_THAN, 3);

    PersonPropertyFilter waitFilter = new PersonPropertyFilter(PersonProperty.WAITING_FOR_NEXT_DOSE, Equality.EQUAL,
            false);

    Filter filter = ageFilter.and(diseaseFilter).and(vaccineFilter).and(waitFilter);

    Labeler ageLabeler = new FunctionalPersonPropertyLabeler(PersonProperty.AGE,
            (value) -> AgeGroup.getAgeGroup((Integer) value));

    Labeler vaccineCountLabeler = new FunctionalPersonPropertyLabeler(PersonProperty.VACCINATION_COUNT,
            (value) -> value);

    Partition partition = Partition.builder()//
            .setFilter(filter)//
            .addLabeler(ageLabeler)//
            .addLabeler(vaccineCountLabeler)//
            .build();

    partitionsDataManager.addPartition(partition, currentlyEligibleKey);

    filter = ageFilter.and(diseaseFilter).and(vaccineFilter);
    partition = Partition.builder()//
            .setFilter(filter)//
            .build();

    partitionsDataManager.addPartition(partition, potentiallyEligibleKey);
}

The first step in this process is to create the filter that will pass only eligible people. Filters are provided as extensions of the Filter.java class in the partitions plugin. The plugin contains base filter implementations for the logical operators of AND, OR, NOT, TRUE, and FALSE. Nearly all plugins provide more refined filters via the plugin’s support package. In the example code, we create four filters that select:

  • Adults
  • People who are susceptible
  • People who have received fewer that 3 vaccinations
  • People who have not been recently vaccinated

All these filters are based on person properties, so we use the PersonPropertyFilter class provided by the person properties plugin. We combine the filters using the AND operator native to all filters.

We need to select people not only on their membership in the partition, but also on their personal properties, preferring older people and those who have had fewer vaccinations. Thus we will be using weighted selection and will use labelers assign weight to the cells in the partition. The nine cells are formed from the combinations of :

  • ADULT_18_44, ADULT_45_64, SENIOR

  • VACCINATION_COUNT = 0, 1, 2

The two labelers will use the FunctionalPersonPropertyLabeler class to specify the transformation of values into labels. Note that the age labeler is converting an integer age value into an AgeGroup while the vaccination count labeler is simply returning the vaccination count. The resulting partition is formed from the filter and the two labelers and is added to the partitions data manager under the ‘currentlyEligibleKey’ key value. This partition will be used to select a new person to vaccinate each time a vaccination comes due.

The next partition uses a reduced filter that allows for people who have been recently vaccinated but are not fully vaccinated. It does not require any labelers since we will use this partition only to determine if vaccinations should continue to be scheduled.

The vaccination process, Code Block 13.19, greatly simplifies the previous designs. The decision to continue vaccination is based on the potentially eligible population containing at least one person. Selection of a person from the currently eligible population uses a PartitionSampler, which specifies how the partition is to perform the sample. In this case we are only providing the weighting function that assigns a weighting value to each of the nine categories. Other capabilities of the PartitionSampler include:

  • Excluding a particular person – useful for contact management
  • Limiting sampling to a constrained portion of the cells that compose the partition
  • Selecting a specific random number generator for the sampling process

The resulting code is easier to refactor, less error prone and executes much faster with far less memory than the previous implementations. Cells in the partition are dynamically allocated to multiple implementations that can approach 1.3 bits per person in the population while maintaining O(ln(n) ) performance for sampling.

Code Block 13.19: The partition-vaccinator schedules and executes vaccinations using partitions.
private void planNextVaccination() {
    if (partitionsDataManager.getPersonCount(potentiallyEligibleKey) == 0) {
        return;
    }
    actorContext.addPlan(this::vaccinatePerson, vaccinatorDelay + actorContext.getTime());
}

private void vaccinatePerson(ActorContext actorContext) {

    PartitionSampler partitionSampler = PartitionSampler.builder()//
            .setLabelSetWeightingFunction(this::getWeight)//
            .build();

    Optional<PersonId> optionalPersonId = partitionsDataManager.samplePartition(currentlyEligibleKey,
            partitionSampler);
    if (optionalPersonId.isPresent()) {
        PersonId personId = optionalPersonId.get();
        int vaccinationCount = personPropertiesDataManager.getPersonPropertyValue(personId,
                PersonProperty.VACCINATION_COUNT);
        vaccinationCount++;
        personPropertiesDataManager.setPersonPropertyValue(personId, PersonProperty.VACCINATION_COUNT,
                vaccinationCount);
        if (vaccinationCount < 3) {
            personPropertiesDataManager.setPersonPropertyValue(personId, PersonProperty.WAITING_FOR_NEXT_DOSE,
                    true);
            planWaitTermination(personId);
        }

    }
    planNextVaccination();
}

13.9 Inspecting the output

13.9.1 Disease State Report

The disease state report, Figure 13.1, records the number of people having various vaccine counts, disease state and age grouping at the end of the simulation. The results show that the vaccination rate is not sufficient to prevent the majority of infections, but does reflect vaccination eligibility and prioritization rules. The results here are for the partition vaccinator, but the results for the other vaccinators are similar. Since each implementation has subtle ordering differences when choosing people to vaccinate, the results vary by amounts that would correspond to changes in the stochastic seed value.

Figure 13.1: The experiment's single scenario showing expected vaccinations for different age groups.
scenario age_group disease_state vaccinations people
0 CHILD RECOVERED 0 1599
0 SENIOR RECOVERED 0 377
0 ADULT_18_44 RECOVERED 0 1745
0 ADULT_45_64 RECOVERED 0 1018
0 ADULT_18_44 RECOVERED 1 591
0 ADULT_18_44 SUSCEPTIBLE 3 1253
0 SENIOR SUSCEPTIBLE 3 742
0 ADULT_45_64 SUSCEPTIBLE 3 1026
0 CHILD SUSCEPTIBLE 0 553
0 SENIOR RECOVERED 1 484
0 ADULT_45_64 RECOVERED 1 568
0 ADULT_45_64 RECOVERED 2 19
0 ADULT_18_44 RECOVERED 2 10
0 SENIOR RECOVERED 2 15