COVID Spread Model Augmented to Read JSON

Demonstration of capturing a lot about a model in a data file format using JSON. This allows us to tune and improve the model far faster than if all the interactions were captured in code.

You have to save the JSON file community.json and add it to the sketch. A sample JSON file is at the bottom of this page.

Implementation

// The COVID spread model augmented to read JSON.

// You must have a file called community.json added to the sketch's data directory.
// The JSONObject is read in setup and used to configure the community.
// in turn passes JSON objects to the Place, Person, and Association constructors.

static final int MINUTES_PER_INCREMENT = 10; // Has to divide 60

// The expected JSON file name
static final String COMMUNITY_JSON_FILENAME = "community.json";

// Keys used at the top level of the JSON dictionary
static final String PLACES_KEY = "places";
static final String PEOPLE_KEY = "people";
static final String ASSOCIATIONS_KEY = "associations";

// Keys used in the place objects
static final String PLACE_NAME_KEY = "name";
static final String PLACE_X_KEY = "x";
static final String PLACE_Y_KEY = "y";
static final String PLACE_W_KEY = "w";
static final String PLACE_H_KEY = "h";

// Keys used in the person objects
static final String PERSON_NAME_KEY = "name";
static final String PERSON_HOME_NAME_KEY = "home";

// Keys used in the association objects
static final String ASSOCIATION_NAME_KEY = "name";
static final String ASSOCIATION_PLACE_NAME_KEY = "placeName";
static final String ASSOCIATION_MEMBERS_KEY = "members";
static final String ASSOCIATION_MEMBER_NAME_KEY = "name";
static final String ASSOCIATION_CLOSENESS_KEY = "closeness";
static final String ASSOCIATION_IS_OUTSIDE_THE_VALLEY_KEY = "isOutsideTheValley";
static final String ASSOCIATION_HAPPENS_DURING_BREAK_KEY = "happensDuringBreak";
static final String ASSOCIATION_MEETING_TIME_KEY = "meetingTime";
static final String ASSOCIATION_FREQUENCY_KEY = "frequency";
static final String ASSOCIATION_DURATION_KEY = "duration";

// The main concepts are place, person, association.
// An association has a meeting schedule, which with the help
// of the academic calendar will allow us to determine if it is meeting.
// If an association is meeting, and a person belongs to that association,
// then that is what that person's place will become.
// If there is a conflict (because the person has multiple associations
// that are meeting) then the first association in the list wins.
// If the person is in no associations that are meeting, then they
// are at home.

// The thing that manages the entire list of places, the entire list
// of people, and the entire list of associations, is a singleton called
// community. The only other singleton is an academic calendar, called ac.

AcademicCalendar ac;
Community community;

class AcademicCalendar {
  static final int HOURS_IN_DAY = 24;
  static final int HOURS_IN_WEEK = 168;
  static final int HOURS_IN_YEAR = 8760;
  // let us begin our simulation with the beginning of COVID
  int hours = 16 * HOURS_IN_WEEK; // COVID really got going in the US on the 12th week of 2020,
  // but things don't get interesting in this model until Deep Springs goes on break
  // and we get very exposed to the outside world.
  int minutes = 0;
  // We could have defined the break weeks in a JSON file, but I have left them hard-coded.
  int[] breakWeeks = {
    0, /* third week of winter break */
  /* Term 4 */
    8, 9, /* two-week break */
  /* Term 5 */
    17, /* one-week break */
  /* Term 6 */
    25,
  /* Term 1 */
    33, 34, /* two-week break */
  /* Term 2 */
    42, /* Term 3 */
    50, 51, /* first two weeks of three-week break */
    52 /* there is actually only one day in week 52 */
  };

  void increment() {
    minutes += MINUTES_PER_INCREMENT;
    if (minutes == 60) {
      boolean wasOnBreak = isCampusOnBreak();
      // println("wasOnBreak is " + wasOnBreak);
      hours++;
      minutes = 0;
      boolean isOnBreak = isCampusOnBreak();
      // println("isOnBreak is " + isOnBreak + " week is " + week());
      if (wasOnBreak && !isOnBreak) {
        println("Term is in session");
      } else if (!wasOnBreak && isOnBreak) {
        println("Campus is on break");
      }
    }
  }

  boolean isAssociationMeeting(Association association) {
    if (isCampusOnBreak() && association.happensDuringBreak || !isCampusOnBreak() && !association.happensDuringBreak) {
        int h = hours % association.frequency;
        return h >= association.meetingTime && h < association.meetingTime + association.duration;
    } else {
      return false;
    }
  }

  int year() {
    return 2020 + hours / HOURS_IN_YEAR;
  }

  int week() {
    int h = hours % HOURS_IN_YEAR;
    return h / HOURS_IN_WEEK;
  }

  boolean isCampusOnBreak() {
    int w = week();
    for (int i = 0; i < breakWeeks.length; ++i) {
      if (w == breakWeeks[i]) return true;
    }
    return false;
  }
}

class Infection {
  static final int INCUBATION_PERIOD = 1 * AcademicCalendar.HOURS_IN_DAY;
  static final int WORST_OF_IT_ENDS = 4 * AcademicCalendar.HOURS_IN_DAY;
  static final int INFECTION_ENDS = 14 * AcademicCalendar.HOURS_IN_DAY;
  // This says that 1000 minutes in close contact with another person is pretty much guaranteed to give them COVID
  static final float R_VALUE = 0.001 * MINUTES_PER_INCREMENT;

  int infectionHours;

  Infection() {
    infectionHours = ac.hours;
  }

  float infectiousness() {
    // during day 0, not very infectious (incubating)
    // real infectious during days 1, 2, 3 (inclusive)
    // steadily declining infectiousness during days 4-13 (inclusive)
    int delta = ac.hours - infectionHours;
    float infectiousness;
    if (delta < AcademicCalendar.HOURS_IN_DAY) {
      // incubating, low infectiousness
      infectiousness = 0.2;
    } else if (delta >= INCUBATION_PERIOD && delta < WORST_OF_IT_ENDS) {
      // worst of it
      infectiousness = 1.0;
    } else if (delta < INFECTION_ENDS) {
      // recovering
      infectiousness = (delta - WORST_OF_IT_ENDS) / (INFECTION_ENDS - WORST_OF_IT_ENDS);
    } else {
      // recovered
      infectiousness = 0.0;
    }
    return infectiousness;
  }

  boolean over() {
    return ac.hours - infectionHours >= INFECTION_ENDS;
  }
}

class Place {
  int occupantsDrawn = 0;
  String name;
  int x;
  int y;
  int w;
  int h;

  Place(JSONObject json) {
    name = json.getString(PLACE_NAME_KEY);
    x = json.getInt(PLACE_X_KEY);
    y = json.getInt(PLACE_Y_KEY);
    w = json.getInt(PLACE_W_KEY);
    h = json.getInt(PLACE_H_KEY);
  }

  void draw() {
    occupantsDrawn = 0;
    stroke(0);
    fill(180);
    rect(x, y, w, h);
  }

  PVector positionForOccupant() {
    int w_r = w / 15;
    int x_r = occupantsDrawn % w_r;
    int y_r = occupantsDrawn / w_r;
    occupantsDrawn++;
    return new PVector(x + 10 + x_r * 15, y + 10 + y_r * 15);
  }
}

class Person {
  Infection infection = null;
  float immunity = 0.0; // a number between 0 and 1, with 1 being immune
  float fastiduousness = 0.5; // a number between 0 and 1 with 1 being ultra-hygienic
  String name;
  Place home;

  Person(JSONObject json) {
    name = json.getString(PERSON_NAME_KEY);
    String homeName = json.getString(PERSON_HOME_NAME_KEY);
    home = community.placeWithName(homeName);
  }

  void draw() {
    ellipseMode(CENTER);
    stroke(0);
    if (isInfected()) {
      fill(255, 0, 0);
    } else {
      fill(255);
    }
    PVector position = place().positionForOccupant();
    ellipse(position.x, position.y, 10, 10);
  }

  // may return null!!
  Association association() {
    for (Association association : community.associations) {
      if (ac.isAssociationMeeting(association) && association.members.contains(this)) {
        return association;
      }
    }
    return null;
  }

  Place place() {
    Association association = association();
    if (association != null) return association.place;
    else return home;
  }

  boolean isInfected() {
    return infection != null;
  }

  float infectiousness() {
    return infection != null ? infection.infectiousness() : 0.0;
  }

  float slovenliness() {
    return 1.0 - fastiduousness;
  }

  float susceptibility() {
    return 1.0 - immunity;
  }

  void catchCOVID() {
    infection = new Infection();
    immunity = 1.0;
  }

  void expose(Person otherPerson, Association association) {
    if (this != otherPerson && isInfected() && !otherPerson.isInfected()) {
      float exposureIntensity = association.closeness * infectiousness() * slovenliness() * otherPerson.slovenliness();
      if (random(1.0) < exposureIntensity * Infection.R_VALUE) {
        otherPerson.catchCOVID();
        println(otherPerson.name + " caught COVID from " + name + " during " + association.name + "." );
      }
    }
  }

  void increment() {
    if (infection != null && infection.over()) {
      infection = null;
      println(name + " recovered.");
    }
    // 1.00000131877 is 2^(1/525600). 525600 is the
    // number of minutes in a year Using this number
    // gives immunity a half-life of one year.
    for (int i = 0; i < MINUTES_PER_INCREMENT; ++i) immunity /=  1.00000131877;
  }
}

class Association {
  String name;
  Place place;
  ArrayList<Person> members = new ArrayList<Person>();
  float closeness;
  boolean isOutsideTheValley;
  boolean happensDuringBreak;
  int meetingTime;
  int duration;
  int frequency;

  Association(JSONObject json) {
    name = json.getString(ASSOCIATION_NAME_KEY);
    String placeName = json.getString(ASSOCIATION_PLACE_NAME_KEY);
    place = community.placeWithName(placeName);
    JSONArray jsonMembers = json.getJSONArray(ASSOCIATION_MEMBERS_KEY);
    for (int i = 0; i < jsonMembers.size(); ++i) {
      JSONObject member = jsonMembers.getJSONObject(i);
      String memberName = member.getString(ASSOCIATION_MEMBER_NAME_KEY);
      Person person = community.personWithName(memberName);
      members.add(person);
    }
    closeness = json.getFloat(ASSOCIATION_CLOSENESS_KEY);
    isOutsideTheValley = json.getBoolean(ASSOCIATION_IS_OUTSIDE_THE_VALLEY_KEY);
    happensDuringBreak = json.getBoolean(ASSOCIATION_HAPPENS_DURING_BREAK_KEY);
    meetingTime = json.getInt(ASSOCIATION_MEETING_TIME_KEY);
    frequency = json.getInt(ASSOCIATION_FREQUENCY_KEY);
    duration = json.getInt(ASSOCIATION_DURATION_KEY);
  }

  void expose() {
    if (isOutsideTheValley) {
      for (Person person : members) {
        // due to schedule conflicts person may be elsewhere
        // if (person.association() != this) continue;
        if (!person.isInfected()) {
          float exposureIntensity = closeness * person.slovenliness();
          if (random(1.0) < exposureIntensity * Infection.R_VALUE) {
            person.catchCOVID();
            println(person.name + " caught COVID during " + name + " outside the valley.");
          }
        }
      }
    } else {
      for (Person person : members) {
        // due to schedule conflicts person may be elsewhere
        // if (person.association() != this) continue;
        for (Person otherPerson : members) {
          // due to schedule conflicts otherPerson may be elsewhere
          // if (otherPerson.association() != this) continue;
          person.expose(otherPerson, this);
        }
      }
    }
  }
}

class Community {
  ArrayList<Place> places = new ArrayList<Place>();
  ArrayList<Person> people = new ArrayList<Person>();
  ArrayList<Association> associations = new ArrayList<Association>();

  void configure(JSONObject json) {
    JSONArray jsonPlaces = json.getJSONArray(PLACES_KEY);
    for (int i = 0; i < jsonPlaces.size(); ++i) {
      JSONObject jsonPlace = jsonPlaces.getJSONObject(i);
      Place place = new Place(jsonPlace);
      places.add(place);
    }
    JSONArray jsonPeople = json.getJSONArray(PEOPLE_KEY);
    for (int i = 0; i < jsonPeople.size(); ++i) {
      JSONObject jsonPerson = jsonPeople.getJSONObject(i);
      Person person = new Person(jsonPerson);
      people.add(person);
    }
    JSONArray jsonAssociations = json.getJSONArray(ASSOCIATIONS_KEY);
    for (int i = 0; i < jsonAssociations.size(); ++i) {
      JSONObject jsonAssociation = jsonAssociations.getJSONObject(i);
      Association association = new Association(jsonAssociation);
      associations.add(association);
    }
  }

  // throws if lookup fails
  Place placeWithName(String name) {
    for (Place place : places) {
      if (place.name.equals(name)) return place;
    }
    throw new Error("Lookup of place with name " + name + " failed.");
  }

  // throws if lookup fails
  Person personWithName(String name) {
    for (Person person : people) {
      if (person.name.equals(name)) return person;
    }
    throw new Error("Lookup of person with name " + name + " failed.");
  }

  void increment() {
    for (Association association : associations) {
      if (ac.isAssociationMeeting(association)) {
        association.expose();
      }
    }
    for (Person person : people) {
      person.increment();
    }
  }

  void report() {
    int covidCount = 0;
    for (Person person : people) {
      if (person.isInfected()) {
        ++covidCount;
      }
    }
    println("The community has " + covidCount + " cases.");
  }

  void draw() {
    background(255);
    for (Place place : places) {
      place.draw();
    }

    for (Person person : people) {
      person.draw();
    }
  }
}

void setup() {
  size(900, 600);
  ac = new AcademicCalendar();
  community = new Community();
  JSONObject json = loadJSONObject(COMMUNITY_JSON_FILENAME);
  community.configure(json);
}

void draw() {
  ac.increment();
  community.increment();
  community.draw();
}

Sample JSON File

This needs to be saved as community.json and then added to the sketch.

{
	"places": [
		{
			"name": "Main Building",
			"x": 150,
			"y": 30,
			"w": 200,
			"h": 50
		},
		{
			"name": "Dorm",
			"x": 150,
			"y": 280,
			"w": 170,
			"h": 80
		},
		{
			"name": "Up Fac",
			"x": 480,
			"y": 100,
			"w": 50,
			"h": 50
		},
		{
			"name": "Mid Fac",
			"x": 500,
			"y": 180,
			"w": 50,
			"h": 50
		},
		{
			"name": "Aird",
			"x": 480,
			"y": 270,
			"w": 70,
			"h": 40
		},
		{
			"name": "W Duplex",
			"x": 40,
			"y": 100,
			"w": 40,
			"h": 80
		},
		{
			"name": "Farm House",
			"x": 620,
			"y": 420,
			"w": 30,
			"h": 60
		},
		{
			"name": "Ranch House",
			"x": 620,
			"y": 530,
			"w": 50,
			"h": 40
		},
	
		{
			"name": "Modular",
			"x": 480,
			"y": 530,
			"w": 70,
			"h": 30
		},
		{
			"name": "S Henderson",
			"x": 20,
			"y": 550,
			"w": 40,
			"h": 40
		},
		{
			"name": "N Henderson",
			"x": 20,
			"y": 480,
			"w": 40,
			"h": 40
		},
		{
			"name": "BH",
			"x": 20,
			"y": 240,
			"w": 80,
			"h": 120
		},
		{
			"name": "Owens Valley",
			"x": 800,
			"y": 100,
			"w": 80,
			"h": 80
		},
		{
			"name": "Rest of World",
			"x": 800,
			"y": 300,
			"w": 80,
			"h": 80
		}
	],
			
	"people": [
		{
			"name": "Ainsley L",
			"home": "Dorm"
		},
		{
			"name": "Andre W",
			"home": "Dorm"
		},
		{
			"name": "Brandon I",
			"home": "Dorm"
		},
		{
			"name": "Chenyi Z",
			"home": "Dorm"
		},
		{
			"name": "Declan A",
			"home": "Dorm"
		},
		{
			"name": "Emily R",
			"home": "Dorm"
		},
		{
			"name": "Ha'ana E",
			"home": "Dorm"
		},
		{
			"name": "Jakub L",
			"home": "Dorm"
		},
		{
			"name": "Lana M",
			"home": "Dorm"
		},
		{
			"name": "Luke S",
			"home": "Dorm"
		},
		{
			"name": "Mishel J",
			"home": "Dorm"
		},
		{
			"name": "Norah G",
			"home": "Dorm"
		},
		{
			"name": "Rita R",
			"home": "Dorm"
		},
		{
			"name": "Zayd V",
			"home": "Dorm"
		},
		{
			"name": "Alice O",
			"home": "Dorm"
		},
		{
			"name": "Annie K",
			"home": "Dorm"
		},
		{
			"name": "Aubryn K",
			"home": "Dorm"
		},
		{
			"name": "Carmen S",
			"home": "Dorm"
		},
		{
			"name": "Declan R",
			"home": "Dorm"
		},
		{
			"name": "Hannah D",
			"home": "Dorm"
		},
		{
			"name": "Jacob S",
			"home": "Dorm"
		},
		{
			"name": "Jesse BP",
			"home": "Dorm"
		},
		{
			"name": "Nathan B",
			"home": "Dorm"
		},
		{
			"name": "Rosemary K",
			"home": "Dorm"
		},
		{
			"name": "Tashroom A",
			"home": "Dorm"
		},
		{
			"name": "Yinuo D",
			"home": "Dorm"
		},
		{
			"name": "Sue D",
			"home": "Aird"
		},
		{
			"name": "Ryan D-T",
			"home": "Up Fac"
		},
		{
			"name": "Tim G",
			"home": "Modular"
		},
		{
			"name": "Ben H",
			"home": "Farm House"
		},
		{
			"name": "Brian S",
			"home": "N Henderson"
		},
		{
			"name": "Tim W",
			"home": "S Henderson"
		},
		{
			"name": "Anna F",
			"home": "Mid Fac"
		},
		{
			"name": "Brian H",
			"home": "W Duplex"
		}
	],

	"associations": [
		{
			"name": "classes",
			"placeName": "Main Building",
			"members": [
				{"name": "Ryan D-T"},
				{"name": "Sue D"},
				{"name": "Brian H"},
				{"name": "Anna F"},
				{"name": "Norah G"},
				{"name": "Rita R"},
				{"name": "Zayd V"},
				{"name": "Alice O"},
				{"name": "Annie K"},
				{"name": "Aubryn K"},
				{"name": "Carmen S"},
				{"name": "Declan R"},
				{"name": "Hannah D"},
				{"name": "Jacob S"},
				{"name": "Jesse BP"},
				{"name": "Nathan B"}
			],
			"closeness": 0.5,
			"isOutsideTheValley": false,
			"happensDuringBreak": false,
			"comments": "classes are daily from 9-12",
			"meetingTime": 9,
			"frequency": 24,
			"duration": 3
		},
		{
			"name": "errands",
			"placeName": "Owens Valley",
			"members": [
				{"name": "Sue D"},
				{"name": "Ryan D-T"}, 
				{"name": "Tim G"},
				{"name": "Ben H"},
				{"name": "Brian S"},
				{"name": "Tim W"}
			],
			"closeness": 0.05,
			"isOutsideTheValley": true,
			"happensDuringBreak": false,
			"comments": "errands are once a week from 9-3",
			"meetingTime": 81,
			"frequency": 168,
			"duration": 6
		},
		{
			"name": "break",
			"placeName": "Rest of World",
			"members": [
				{"name": "Ainsley L"},
				{"name": "Andre W"},
				{"name": "Brandon I"},
				{"name": "Chenyi Z"},
				{"name": "Declan A"},
				{"name": "Emily R"},
				{"name": "Ha'ana E"},
				{"name": "Jakub L"},
				{"name": "Lana M"},
				{"name": "Luke S"},
				{"name": "Mishel J"},
				{"name": "Norah G"},
				{"name": "Rita R"},
				{"name": "Zayd V"},
				{"name": "Alice O"},
				{"name": "Annie K"},
				{"name": "Aubryn K"},
				{"name": "Carmen S"},
				{"name": "Declan R"},
				{"name": "Hannah D"},
				{"name": "Jacob S"}
			],			
			"closeness": 0.05,
			"isOutsideTheValley": true,
			"happensDuringBreak": true,
			"comments": "breaks are daily from 0-23",
			"meetingTime": 0,
			"frequency": 24,
			"duration": 24
		},
		{
			"name": "labor",
			"placeName": "BH",
			"members": [
				{"name": "Tim G"},
				{"name": "Tim W"},
				{"name": "Brian S"},
				{"name": "Ben H"},
				{"name": "Aubryn K"},
				{"name": "Carmen S"},
				{"name": "Declan R"},
				{"name": "Hannah D"},
				{"name": "Jacob S"},
				{"name": "Jesse BP"},
				{"name": "Nathan B"},
				{"name": "Rosemary K"},
				{"name": "Tashroom A"},
				{"name": "Yinuo D"}
			],
			"closeness": 0.1,
			"isOutsideTheValley": false,
			"happensDuringBreak": false,
			"comments": "labor is daily from 2-6",
			"meetingTime": 14,
			"frequency": 24,
			"duration": 4
		},
	]

}