Behavioral systems

Danil Nagy
Generative Design
Published in
14 min readFeb 13, 2018

--

This article describes how we can use behavioral algorithms and agent-based models to further abstract the relationship between a set of input parameters and a set of solutions in a design space. To create such a system we define a collection of agents who follow a set of rules or behaviors over a series of time steps. These behaviors dictate how the agents interact with other agents and their environment, and how these interactions affect the internal state of the agents.

In computer science, such systems are often called agent-based models and we can easily implement them using Object-oriented programming (OOP). To do so we create a class which defines the structure of the agent, with local variables that define its states and a set of methods which define its behaviors. Then we create a set of instances of this Class and let the instances play out their behaviors over a series of time steps. Because such a system has to play out over multiple time steps, it is called a dynamic system, as opposed to static systems which can be computed directly in one step.

Agent-based systems are often used to model complex behaviors in nature, which are also typically defined by the interaction of a large number of agents who are driven by a relatively simple set of rules. Examples of such systems include the flocking of birds, the organization of ants, the growth of slime mold, and the construction of termite mounds. These types of systems are often called emergent because of their ability to develop highly complex behaviors and structures from a set of relatively simple behaviors.

Flocking is a typical complex behavior which can be simulated through agent-based programming

In the context of Generative Design, we can use such agent-based systems to define solutions within a design space by parameterizing the behaviors of a set of agents, allowing the behaviors to play out over a series of time steps, and then taking the final state of the agents as the design solution. This provides a direct yet complex mapping between the input parameters and the design solutions which can be explored by the optimization algorithm to find the best solutions within the design space.

This method of parameterizing a design space is similar to the way that forms develop in nature. We can think of the genome of each organism as encoding the behaviors of its cells and component, which are played out over the lifetime of the organism, leading to the development of its physical form or phenotype.

As with the recursive systems described previously, this is an example of an indirect control strategy which abstracts the way the input parameters control the phenotype of each design solution. This approach offers two opportunities for crafting design spaces:

  1. The abstraction of inputs to outputs allows us to use a relatively small set of input parameters to create complex design solutions
  2. The removal of direct control over the form of the solution creates the potential for emergent forms which may not have been considered through intuitive design

Cellular Automata

A classic example of an agent-based system is the Cellular Automata (CA) which was first discovered by Stanislaw Ulam and John von Neumann in the 1940’s but popularized in the 1970 with John Conway’s Game of Life. In a CA system we create a grid of agents (called cells) who change their state over time based on the changing states of their neighbors. CA’s were one of the first computational systems developed for creating “artificial life” by simulating the growth and development process in nature. Although they are an old idea, they can still be useful for defining complex behaviors and creating unexpected solutions within a design space.

To create a CA you have specify the following components:

  1. The resultion of the grid of cells.
  2. The initial state of all the cells in the grid.
  3. The behaviors which dictate how a cell’s state changes based on the states of the cells around it.

Let’s create a simple CA in Grasshopper using OOP in Python. If you get stuck along the way you can also download the final working version of the definition here.

We will begin by creating a ghPython component with five inputs that define the CA system:

  • x_count and y_count which are integers that set the resolution of the grid in the x and y dimensions.
  • num_states which is an integer that specifies the number of possible states of each cell.
  • steps which is an integer that specifies the number of time steps the system will be run before terminating.
  • rules which is a list of integers which specify each cell’s next state based on its current state and the state of the cells around it. Make sure to right click on the rules input of the ghPython component and select List Access to bring the whole list of values into the script at once.
ghPython component with five inputs and one output

The first four inputs can be set with number sliders while the last one can be set with a Gene Pool component. Double-clicking on the Gene Pool component allows you to set the number of parameters in the Gene Pool along with the number of decimal places and the minimum and maximum value of each parameter.

To guide the behavior of the CA system, the rule set describes the next state of a cell based on every possible configuration of the current states of the cell and its neighbors. The range of these values is based on the number of possible states of the cells.

In this case our cells will have two possible states, so we want each rule parameter to output either a 0 or 1. Although the node allows you to use integers directly by setting Decimals to zero, in practice the Galapagos genetic algorithm built into Grasshopper has an easier time working with floats. Thus we will specify two decimal places and a range of [0.00, 1.99] for the parameters. Then we will use the F (Floor) output of the Round node to round the values down to either 0 or 1.

To determine the number of rule parameters we need, we need to calculate the number of possible state configurations given the number of possible states of each cell and the number of cells we are considering in a group. This permutation can be calculated by raising the number of states to the power of the number of cells. In this case we have two possible states and five cells in a group (the cell itself and it’s four neighbors) so we need 2⁵ = 512 rules.

Setting of parameters for the Gene Pool component

Along with the five input parameters, the ghPython component also needs one output to send out the final states of the cells so we can visualize them in Grasshopper.

Now double-click on the ghPython component and copy and paste to following code into it:

class Cell:

def __init__(self, _x, _y, _s):
self.x_val = _x
self.y_val = _y
self.state = _s
self.neighbors = []

def add_neighbor(self, neighbor):
self.neighbors.append(neighbor)

def calc_next_state(self, num_states):
neighbor_states = [self.state]
for neighbor in self.neighbors:
neighbor_states.append(neighbor.state)

rule_index = 0
for i,s in enumerate(neighbor_states):
rule_index += s * num_states**i

self.next_state = rules[rule_index]

def set_next_state(self):
self.state = self.next_state

cells = []

for x in range(x_count):
cells.append([])
for y in range(y_count):
cells[-1].append(Cell(x, y, 0))

cells[0][0].state = 1

for i, row in enumerate(cells):
for j, cell in enumerate(row):
cell.add_neighbor(cells[i-1][j])
cell.add_neighbor(cells[(i+1) % x_count][j])
cell.add_neighbor(cells[i][j-1])
cell.add_neighbor(cells[i][(j+1) % y_count])

for step in range(steps):
for i, row in enumerate(cells):
for j, cell in enumerate(row):
cell.calc_next_state(num_states)

for i, row in enumerate(cells):
for j, cell in enumerate(row):
cell.set_next_state()

states = []

for i, row in enumerate(cells):
for j, cell in enumerate(row):
states.append(cell.state)

The first block of code starting with class Cell is the class definition which defines four methods or behaviors for the Cell object:

  1. The `__init__` method initializes each cell with its x and y position, it’s initial state, and an empty list to store references to its neighbor cells.
  2. The `add_neighbor` method adds a reference to another cell to the current cell’s list of neighbors.
  3. The `calc_next_state` method calculates the cell’s next state based on the rule associated with its current state and the current states of its neighbors.
  4. The `set_next_state` method sets the current state to the next state calculated by the previous method.

To determine the next state of the cell we have to find the rule in the rules set which corresponds to the unique configuration of states of the cell and it’s neighbors. We can do this by computing the integer form of the number whose base is the number of possible states and whose digits are the states of each cell in the group. In this example we have two possible states with five cells in the group, which defines a binary number with five digits. Given a possible configuration of states in the group [10101], we can calculate the integer form by adding together the value of each place:

(1 * 2⁰) + (0 * 2¹) + (1 * 2² )+ (0 * 2³) + (1 * 2⁴) = 1 + 0 + 4 + 0 + 16 = 21

So the next state of the cell would be the value of the 21st rule in the rule set.

The calc_next_state method of the Cell class implements this equation by first collecting the current state of the cell and its four neighbors in a list, and then iterating over the list to add up the values of each digit whose base is the number of possible states. This number is passed into the method as the input num_states. This value is then used to get the appropriate rule from the rule set stored in the rules variable. This rule defines the cell’s next state, and is set to the cell’s next_state local variable.

The remaining code after the class definition is the script which makes instances of cell objects and executed their behavior. It starts by creating an empty list to store a set of Cell` objects and then looping over the x_count and y_count variables to create the objects. To define a 2d grid structure we want to create a nested list of lists where the outer list stores a set of internal lists, each of which stores the cell objects within a single row. For example, for a 4x4 grid we would have the structure:

[
[cell, cell, cell, cell],
[cell, cell, cell, cell],
[cell, cell, cell, cell],
[cell, cell, cell, cell]
]

To create this list structure we define two nested loops. The outer loop iterates over the x_count variable and appends an empty list to the outer cells list. The inner loop iterates over the y_count variable and appends a new Cell object to each inner list. Each Cell object is defined with its x and y position, as well as a ‘0’ for its initial state.

Once the cells are created we set the state of the first cell to 1, which defines the initial condition of the system before any behavior has played out. Otherwise, if all the cells start in the same state the system will stagnate because each cell would be stuck following the same rules at each time step. The choice of this initial state can be arbitrary or can be based on the specifics of your model or design problem. You can even experiment with using the initial state as a parameter in your design space model.

In the next block of code, we iterate over the 2d structure of the ‘cells’ list to specify the neighbors of each cell. In this case the neighbors of each cell will be the four cells immediately above, below, to the left, and to the right. This is known as the cell’s ‘4-neighbors’, as opposed to the ‘8-neighbors’ which also include the four cells on the diagonal.

4-neighbor vs. 8-neighbor relationship

We can use the 2d structure of the list to directly find these neighbors by either subtracting or adding one index to each of the list’s dimensions. The % symbol is the modulo operation which gives the remainder from the division of two values. This ensures that we do not go outside the boundaries of our lists. For the last cell in each row, its neighbor to the right will actually wrap around and be the first cell in the row. Similarly, all cells in the last row will have the cells in the first row as their neighbors in the down direction. This wrapping behavior forms a continuous world which is common with CA and other agent-based systems. However, we can also constrain the world to its boundaries by omitting neighbors for cells on the edges of the grid.

In a continuous world the cell on the edge has four neighbors while in a constrained world it has three

In the next block of code, we play out the behavior encoded within the set of rules by iterating over a series of time steps and asking each cell to implement the behavior each time. The outer loop iterates over the steps input variable to execute the behavior that number of times. Within this loop, we have two more nested loops which iterate over the 2d list storing our cells, and ask each cell to calculate its next state with the calc_next_state method. Then we loop over all cells again to set their current state to the calculated next state.

The reason we need to separate the calculation and setting of the next state is because we want each cell to calculate its next state based on the starting states of all cells at the beginning of each iteration. Otherwise, the behavior would not be controlled directly by the rule set and would depend heavily on the order of the cells in the grid.

Once the behavior of the system has played out the specified number of steps, we create an empty list called ‘states’ to output the state of each cell back to our Grasshopper definition. We then iterate over all the cells and append their final state to this list. Once we have this data we can visualize the grid of cells with some additional Grasshopper nodes:

Visualization of cell grid with Grasshopper

First we create a grid of points at the same resolution as the grid of cells, then use a Gradient component to assign colors based on the cell state, and visualize the grid by placing a dot of the right color at each point using the Dots component. Right click the Gene Pool component and select Randomize 100% to create a random rule set. Now you can scrub through the slider attached to the steps input of the ghPython node to play out the CA simulation for that rule set:

CA behavior played out over 20 time steps

Setting goals

This completes our simple implementation of CA, but to test it within a Generative Design context we need to establish a goal for the process to optimize toward. In a real application this goal should relate to the design problem you are trying to solve. However, for demonstration purposes we will create a simple score based on the final state of the cells. We will then see how the Genetic Algorithm is able to tune our rule set and the number of steps to maximize this score.

To calculate the score, go back to the script within the `ghPython` component. Within the class definition create an additional method called `calc_score`:

def calc_score(self):
if self.state == 0:
return sum([n.state for n in self.neighbors])
else:
return 0

This calculates the score for an individual cell with a simple conditional:

  • If the state of the cell is 0, the score is the sum of the states of its neighbor cells
  • Otherwise (if the state of the cell is 1), the score is 0

Now, at the end of the script, add some code to initialize a score variable to store the total score of the system and then loop through all the cells to calculate their scores and add them to the total score:

score = 0

for i, row in enumerate(cells):
for j, cell in enumerate(row):
score += cell.calc_score()

This creates a goal which tries to maximize the number of cells of state 0 (the red cells in the graphic above) which have a maximum of cells around it with state 1 (the blue cells). Without the wrapping condition at the boundary, or with an even number of cells in both axes, the best solution would be a checker-board (try this to be sure). However, with the wrapping condition and an odd number of cells the best solution is a bit more complex since in a checkerboard the 0 cells at the boundaries will also have 0 neighbors.

Predictability of maximum score based on boundary condition and grid resolution

Let’s see if we can use optimization to find the best solution within this design space. Start by creating a Galapagos component and connecting the Gene Pool component and the slider connected to the steps input to the Genome and the score output to the Fitness. Remember that you connect things to Galapagos by clicking and dragging from the nodes on the Galapagos component, and you can hold Shift to connect multiple inputs. Since Galapagos only supports single objective optimization you can only connect one value to Fitness.

Galapagos setup

Now double-click on the Galapagos component to open the optimization interface. Keep all the default settings on the Options tab, then switch to Solvers, make sure the Evolutionary Solver is enabled, and then run the optimization until it converges on a solution.

Design space optimization

You can see that the best result is not quite intuitive, but can be quickly found by optimization through varying the rules of the CA system. To check your work you can look at the final working version of the definition here.

Scaling up

With such a small system, the advantages of an indirect parameterization through CA are not so obvious. After all, we need 512 input parameters to enumerate all possible rules, while we could use only 25 input parameters to directly control the states of each cell in the grid. In this case, such a direct parameterization would be able to find the same solution, perhaps even faster than our CA system. However, the real opportunity of behavioral systems is that they are scale independent, since we are parameterizing a single set of rules which can be used to control any number of agents. This means that we can apply the same strategy to a much larger system which would be impractical to parameterize directly.

Let’s try this by changing the resolution of the grid to 50 x 50. To parameterize this system directly would require 50 x 50 = 2,500 individual input parameters. However, in our case we can use the same exact rule set of 512 parameters, since the number of states and the number of neighbors each cell considers stays the same.

CA system scaled up to 50 x 50 grid

This article described how you can use agent-based behavior to abstract the relationship between a set of input parameters and a set of designs within a design space. As an example we used the Cellular Automata (CA), one of the oldest agent-based systems developed to simulate growth in nature. Such systems can be used in a variety of architectural applications, for example the structuring of a facade or the layout of floor plans or urban neighborhoods. However, CA’s are not the only type of agent-based system. Over the years a variety of such systems have been developed to model a range of behaviors in nature — for example the flocking of birds, ant trails, slime mold growth, even traffic patterns. You should explore these algorithms on your own and think how you can apply their logics to the design of your own complex design spaces.

--

--