NovaScript AgentSIR Tutorial

This tutorial was written for an older version of Nova and needs to be double-checked to see if everything still works in the current version.

This tutorial is to show the steps to creating an Agent-Based SIR model in Nova using the NovaScript scripting interface. This tutorial requires some background in the building of systems dynamics models in NovaScript which can be gained from NovaScript SIR Tutorial.

The Steps to Creating An Agent-Based Model

There are multiple layers required for building a NovaScript Agent-Based Model. You can think of your model as having two parts: the agent and the environment it moves on. The agent moves on the environment. This is what we want to build.

To do this in Nova we have to:

  1. Define what an agent is
  2. Define an object that holds all of the agents
  3. Define what a cell is (a square in your environment)
  4. Define what your environment
  5. Define a big object that puts these two together

To define what the agent is you simply define the agent as a system dynamic model inside of a capsule whose stocks, flows, and dynamics specify what the agent is like. Then you create an AgentVector that creates many instances of the agent capsule. On the environment side, we must define a capsule that is a system dynamic model whose stocks, flows, and dynamics specify what a square in the environment is like. Then you create a CellMatrix which creates an environment of the cells you specified. Now that you have the agents and the cells, you create the main model as a SimWorld, and object that brings the two together. With the SimWorld created you have your agent-based model!

Building The Model

Building the Agent

To build an agent, simply define a capsule that will specify the properties of an agent. The appropriate materials to do so can be found in NovaScript SIR Tutorial. From that tutorial we should know how to define a capsule with many properties as such:

AgentSIR.defineSchema('agent', {
	specifies: 'CAPSULE',
	methods: {
		bounce: bounce,
		normalize: normalize,
		infect: infect,
		initialInfect: initialInfect,
		recovery: recovery,
		getterfunc: getterfunc,
	},
	variables: ['x','y'],
	sequences: ['theta','infected','infectedDays','recovered','getterValue','recoveredDays',],
	dynamics: [
		//Initialize
		"infectedDays.initial = 0",
		"recoveredDays.initial = 0",
		"recovered.initial = 0",
		"infected.initial=initialInfect();",
		"getterValue.initial = infected.initial",
		"x.initial=(boardSize*Math.random())-1",
		"y.initial=(boardSize*Math.random())-1",
		"theta.initial=2*Math.PI*Math.random()",

		//Set up flows
		"x.prime=speed*Math.cos(theta)",
		"y.prime=speed*Math.sin(theta)",
		"theta.next=bounce(x,y,theta)",
		"infected.next=infect()",
		"infectedDays.next = (infected==1) ? infectedDays+1: 0;",
		"recovered.next = recovery();",
		"recoveredDays.next = (recovered==1) ? recoveredDays+1: 0;",
		"getterValue.next = getterfunc()",

	]
})

This agent is a person who at any given time period is either is susceptible, infected, or recovered. We have the sequences infected and recovered being used as state variables where infected==1 means it is infected and recovered==1 means it is recovered (the dynamics do not allow for both to be one, and if both are 0 then the agent is susceptible). The days variables are simply counters for how many days the agent has been in a state since this model runs by using a number of days for state changes (for example: it will stay infected for 5 days before it becomes recovered and then stay recovered for 10 days before it becomes susceptible again). The x, y, and theta are all used for placing the agent in the world. The value of getterValue will be explained later. All of the functions are defined in another spot in the project and fed into the schema as in the NovaScript SIR Tutorial.

To set this agent up for working within an agent vector, we will need to add commands to make sure it moves correctly and knows the board size. To do this, we have to add a few properties. The properties to add are:

	properties: {
		rows: boardSize,
		cols: boardSize,
	},
	commands: 'move',

Properties define the space properties and move implements the move command. However, to move we have to tell it what variables are the position variables. This is done by adding the following statement to the dynamics:

"move = MOVE(x,y);",

This we now have a working agent capsule defined as such:

AgentSIR.defineSchema('agent', {
	specifies: 'CAPSULE',
	methods: {
		bounce: bounce,
		normalize: normalize,
		infect: infect,
		initialInfect: initialInfect,
		recovery: recovery,
		getterfunc: getterfunc,
	},
	variables: ['x','y'],
	sequences: ['theta','infected','infectedDays','recovered','getterValue','recoveredDays',],
	properties: {
		rows: boardSize,
		cols: boardSize,
	},
	commands: 'move',
	dynamics: [
		//Initialize
		"infectedDays.initial = 0",
		"recoveredDays.initial = 0",
		"recovered.initial = 0",
		"infected.initial=initialInfect();",
		"getterValue.initial = infected.initial",
		"x.initial=(boardSize*Math.random())-1",
		"y.initial=(boardSize*Math.random())-1",
		"theta.initial=2*Math.PI*Math.random()",

		//Set up flows
		"x.prime=speed*Math.cos(theta)",
		"y.prime=speed*Math.sin(theta)",
		"theta.next=bounce(x,y,theta)",
		"move = MOVE(x,y);",
		"infected.next=infect()",
		"infectedDays.next = (infected==1) ? infectedDays+1: 0;",
		"recovered.next = recovery();",
		"recoveredDays.next = (recovered==1) ? recoveredDays+1: 0;",
		"getterValue.next = getterfunc()",

	]
})

Defining the AgentVector

The syntax for defining an AgentVector is found in the NovaScript Cheat Sheet. AgentVectors are just defined by a schema whose required properties are specifices which tell Nova it’s an AgentVector, size which tell Nova how many agents you want, dimension which tells Nova the dimensions of the model world, and the agent which is the capsule for the agent that is created by the agent vector. Thus the constructor for this example would be:

AgentSIR.defineSchema('agentV', {
	specifies: 'AGENTVECTOR',
	size: numberOfAgents,
	dimension: {rows:boardSize, cols:boardSize},
	agent: 'agent',
})

Defining the Cell and CellMatrix

Defining the cell is simply defining the capsule for the cell. In our model, we do not have a need for the cell (and thus it can actually be left out, though it is here fore completeness). Thus we simply define a capsule that defines the dynamics for a cell. Here it would be:

 

AgentSIR.defineSchema("cells", {
	specifies: 'CAPSULE',
})

Note that the dimension is not required to be defined here. These cells can be active and have states and stocks and change over time, though this is not shown here. For a good example on using cells, see the Game of Life model example.

To define the CellMatrix for the cells is analygous to defining the AgentVector for the agent. The NovaScript Cheat Sheet contains the syntax information for defining a CellMatrix. To define the CellMatrix we must define the specifies to tell Nova it is a CellMatrix, you must define the dimensions of the board to tell Nova the size, and define the capsule the cells use. For this example the schema is:

AgentSIR.defineSchema("cellM", {
	specifies: 'CELLMATRIX',
	dimension: {rows:boardSize, cols:boardSize},
	cell: 'cells',
})

Defining A Display

To define a display for your agent-based model, simply define an AgentViewerX object. The syntax for defining an AgentViewerX is in the NovaScript Cheat Sheet. The definition requires the properties of specifies to tell Nova it is an AgentViewerX, proxy which defines what the display is called on the visual interface, and the dimension which tells Nova what dimension to display. For our example the schema is:

AgentSIR.defineSchema('viewer', {
	specifies: "AgentViewerX",
	proxy: "viewer",
	dimension: {rows:boardSize, cols:boardSize}
})

Defining The SimWorld

Now that we have all of our components created, we can create our main schema, the SimWorld. The syntax for defining a SimWorld is in the NovaScript Cheat Sheet. The definition requires the properties of specifies to tell Nova it is a SimWorld, cellMatrix which tell Nova what the CellMatrix object is, agentVector which tells Nova what the AgentVector object is, and displays which tells Nova what the display object is. For our example the syntax would be:

AgentSIR.defineSchema('main', {
	specifies: "SIMWORLD",
	cellMatrix: "cellM",
	agentVector: "agentV",
	displays: {
		table: "viewer",
	},
})

 

Adding Color to the Display With Getters

At this point we have a functional agent-based model. However, we cannot see who is susceptible, infected, or recovered! To add the colors to the model, we need to setup a getter. The getter is a value that is passed into the display to tell the display what the color is. The stock in the agent GetterValue is made to be equal to 0 if the agent is susceptible, 1 if the agent is infected, and 2 if the agent is recovered. Thus to make this value control the color of the agent we simply add a property to our SimWorld:

	dynamics: [
		'table.getter = "getterValue";',
		],

To make our SimWorld schema be:

AgentSIR.defineSchema('main', {
	specifies: "SIMWORLD",
	cellMatrix: "cellM",
	agentVector: "agentV",
	displays: {
		table: "viewer",
	},
	dynamics: [
		'table.getter = "getterValue";',
		],
})

Now you must go into your display properties and set Agent Colors to Assigned and set the colors to match the values you wish to have with getterValue. There! Now your model is complete! Here is the final code:

var AgentSIR = new Project('AgentSIR');
beginProject(AgentSIR);

//Define Constants
const boardSize = 40;
const speed = .5;
const infectionrate = .1;
const recoveryrate = 5;
const numberOfAgents = 200;
const pi = Math.PI;
const pio2 = Math.PI/2; 
const mpio2 = -Math.PI/2; 
const mpi = -Math.PI; 
const pi2 = 2 * Math.PI;
const infectionRadius = 2;
const initInf = .1;
const infectionDayMax = 15;
const recoveredDayMax = 3;

//Functions
var normalize = function normalize(theta){
    var ans = theta % pi2;
    if (ans > Math.PI) ans -= pi2;
    else if (ans <= -Math.PI) ans += pi2;
    return ans;
}
var bounce = function bounce(x,y,theta){
    var theta_x = normalize(theta+NORMAL(0, 0.1));
    if (x > 1 && x < cols-1 && y > 1 && y < rows-1) return theta_x;
    var ans =  
    (((x <= 1) && (theta_x > pio2 || theta_x < mpio2)) ||
    (x >= cols-1 && theta_x > mpio2 && theta_x < pio2)) ? normalize(pi - theta_x) :
    ((y <= 1 && theta_x >= mpi && theta_x <= 0) || (y >= rows-1 && theta_x >= 0 && theta_x <= pi))?
    normalize(pi2 - theta_x) : theta_x
    return ans;
}

var initialInfect = function initialInfect(){
	if(Math.random()<initInf){
		return 1
	}
	return 0
}

var infect = function infect() {
	//Return 1 to infect
	//Probability of not infecting is .9^number of infected agents in tile. Caps at 1
	if(Self.recovered==1){
		return 0;
	}

	if(Self.infected==1){
		return 1;
	}

	var possibleNeighbors = Super.AGENTS()
	count = 0;
	for (agnt in possibleNeighbors){
		neib = possibleNeighbors[agnt];
		if (neib.infected==1){
			if(distanceObj(Self,neib)<= infectionRadius){
				count = count + 1;
			}
		}
	}
	var prob = 1-Math.pow(.9,count); //probability of getting infected
	if(Math.random()<=prob){
		return 1; // if less than prob, do not infect
	}
	return 0;
}

var distance = function distance(i0, j0, i1, j1) {
	return Math.sqrt(Math.pow(i0-i1, 2) + Math.pow(j0-j1, 2));
}

var distanceObj = function distanceObj(Self,agnt){
	var x0 = agnt.x;
	var y0 = agnt.y;
	return distance(Self.x, Self.y, agnt.x, agnt.y);
}

var recovery = function recovery(){
	if(Self.recovered==1 && Self.recoveredDays< recoveredDayMax){
		return 1;
	}
	if(Self.infected==1){
		if(Self.infectedDays > infectionDayMax){
			return 1;
		}
	}
	return 0;
}

var getterfunc = function getterfunc(){
	if(Self.recovered==1){
		return 2
	}
	if(Self.infected==1){
		return 1
	}
	return 0
}

//Main Schema

AgentSIR.defineSchema('main', {
	specifies: "SIMWORLD",
	cellMatrix: "cellM",
	agentVector: "agentV",
	displays: {
		table: "viewer",
	},
	dynamics: [
		'table.getter = "getterValue";',
		],
})

// Matrix Schema

AgentSIR.defineSchema("cellM", {
	specifies: 'CELLMATRIX',
	dimension: {rows:boardSize, cols:boardSize},
	cell: 'cells',
})

// Cell Schema

AgentSIR.defineSchema("cells", {
	specifies: 'CAPSULE',
})

// AgentVector Schema

AgentSIR.defineSchema('agentV', {
	specifies: 'AGENTVECTOR',
	size: numberOfAgents,
	dimension: {rows:boardSize, cols:boardSize},
	agent: 'agent',
})

//Agent Capsule Schema

AgentSIR.defineSchema('agent', {
	specifies: 'CAPSULE',
	methods: {
		bounce: bounce,
		normalize: normalize,
		infect: infect,
		initialInfect: initialInfect,
		recovery: recovery,
		getterfunc: getterfunc,
	},
	variables: ['x','y'],
	sequences: ['theta','infected','infectedDays','recovered','getterValue','recoveredDays',],
	properties: {
		rows: boardSize,
		cols: boardSize,
	},
	commands: 'move',
	dynamics: [
		//Initialize
		"infectedDays.initial = 0",
		"recoveredDays.initial = 0",
		"recovered.initial = 0",
		"infected.initial=initialInfect();",
		"getterValue.initial = infected.initial",
		"x.initial=(boardSize*Math.random())-1",
		"y.initial=(boardSize*Math.random())-1",
		"theta.initial=2*Math.PI*Math.random()",

		//Set up flows
		"x.prime=speed*Math.cos(theta)",
		"y.prime=speed*Math.sin(theta)",
		"theta.next=bounce(x,y,theta)",
		"move = MOVE(x,y);",
		"infected.next=infect()",
		"infectedDays.next = (infected==1) ? infectedDays+1: 0;",
		"recovered.next = recovery();",
		"recoveredDays.next = (recovered==1) ? recoveredDays+1: 0;",
		"getterValue.next = getterfunc()",

	]
})

//AgentViewer Schema

AgentSIR.defineSchema('viewer', {
	specifies: "AgentViewerX",
	proxy: "viewer",
	dimension: {rows:boardSize, cols:boardSize}
})

endProject(0,1000,1,'euler');

Useful Information

Using The Environment Methods

For many agent-based models you need to have interactions between different objects. For this model, we needed to know how far we are from other agents. So the simple question is, how do I find another agent from an agent? To do so, remember that all of the agents are parts of the AgentVector. In Nova, we’d say that the AgentVector is thus the Super of the agent. Thus in a method where we are writing for an agent, the command Super will give us the AgentVector. From the Primops page, we can see that the primop AGENTS() returns all of the agents in the AgentVector. Thus inside of the infect method for the agent, the command Super.AGENTS() returns an array of all of the agents in the AgentVector. This is then used in a foreach loop to find out which agents are within a certain distance from the agent.

Notice that the use of Super is not just for agents. The super of a cell is the CellMatrix, and the Super of both the CellMatrix and the the AgentVector is the SimWorld. With this and the pimops you can find any other capsule in your SimWorld.

The Clock for Agent-Based Models

For agent-based models, one wants to use integer time steps. Thus set your decriments to 1. Also, you will want to use either the euler or discrete integration methods. Thus a possible endProject will be as follows:

endProject(0,1000,1,'euler');

Chris Rackauckas, 4 June 2012