r
/
fifth
1
Fork 0

Generalized rulesets. Removed neighborhoods.

master
Joshua Potter 2015-06-02 16:35:16 -04:00
parent 1ab48b3c97
commit 37c0f9281a
5 changed files with 302 additions and 269 deletions

View File

@ -3,25 +3,6 @@
@author: jrpotter
@date: June 01, 2015
"""
def game_of_life(coordinate, grid, neighbors):
"""
Rules of the Game of Life.
Note we ignore the second component of the neighbors tuples since
life depends on all neighbors
"""
total = sum(map(lambda x: x[1], neighbors))
if grid[coordinate]:
if total < 2 or total > 3:
return False
else:
return True
elif total == 3:
return True
else:
return False
if __name__ == '__main__':
import os, sys
@ -29,10 +10,32 @@ if __name__ == '__main__':
import cam
import ruleset as rs
import neighborhood as nh
c = cam.CAM(1, (100, 100))
def game_of_life(f_index, f_grid, indices, states, *args):
"""
Rules of the Game of Life.
Note we ignore the second component of the neighbors tuples since
life depends on all neighbors
"""
total = sum(f_grid[indices])
if f_grid[f_index]:
if total < 2 or total > 3:
return rs.Configuration.OFF
else:
return rs.Configuration.ON
elif total == 3:
return rs.Configuration.ON
else:
return rs.Configuration.OFF
c = cam.CAM(1, 100, 2)
c.randomize()
r = rs.Ruleset(rs.Rule.SATISFY)
n = nh.Neighborhood.moore(c.master, True)
c.start_plot(100, r, n, game_of_life)
r = rs.Ruleset(rs.Ruleset.Method.SATISFY)
offsets = rs.Configuration.moore(c.master)
r.addConfiguration(c.master, game_of_life, offsets)
c.start_plot(100, r, lambda *args: True)

View File

@ -1,4 +1,9 @@
"""
Top level module representing a Cellular Automata Machine.
The CAM consists of any number of cell planes that allow for increasingly complex cellular automata.
This is the top-level module that should be used by anyone wanting to work with fifth, and provides
all methods needed (i.e. supported) to interact/configure the cellular automata as desired.
@author: jrpotter
@date: June 01, 2015
@ -6,9 +11,7 @@
import time
import copy
import camtools
import ruleset as rs
import neighborhood as nh
import numpy as np
import matplotlib.pyplot as plt
@ -19,25 +22,68 @@ class CAM:
"""
Represents a Cellular Automata Machine (CAM).
The CAM consists of any number of cell planes that allow for increasingly complex cellular automata.
This is the top-level module that should be used by anyone wanting to work with fifth, and provides
all methods needed (i.e. supported) to interact/configure the cellular automata as desired.
A CAM consists of a series of "cell planes" which represent a separate numpy grid instance. There
should always be at least one cell plane, dubbed the "master", since all other planes cannot be handled
directly, but instead mirror the master plane, and reflect these changes after a given number of
"ticks."
A tick represents an interval of time after which all states should be updated, and, therefore, all
cell planes should be updated. Certain planes may or may not change every tick, but instead on every
nth tick, allowing for more sophisticated views such as ECHOing and TRACE-ing.
"""
def __init__(self, cps=1, dimen=(100, 100)):
def __init__(self, cps=1, states=100, dimen=2):
"""
@cps: Cell planes. By default this is 1, but can be any positive number. Any non-positive number
is assumed to be 1.
@states: The number of cells that should be included in any dimension. The number of total states
will be cps * states^dimen
@dimen: The dimensions of the cellular automata. For example, for an N-tuple array, the dimension is N.
"""
self.planes = np.zeros((max(cps, 1),) + dimen, dtype=np.int32)
plane_count = max(cps, 1)
grid_dimen = (states,) * dimen
self.planes = np.zeros((plane_count,) + grid_dimen, dtype='int32')
self.master = self.planes[0]
def start_plot(self, clock, ruleset, neighborhood, *args):
def randomize(self, propagate=True):
"""
Initiates the main loop.
Set the master grid to a random configuration.
The following function displays the actual graphical component (through use of matplotlib), and triggers the
next tick for every "clock" milliseconds.
If propagate is set to True, also immediately change all other cell planes to match.
"""
self.master[:] = np.random.random_integers(0, 1, self.master.shape)
for plane in self.planes[1:]:
plane[:] = self.master
def tick(self, rules, *args):
"""
Modify all states in a given CAM "simultaneously".
The tick function should be called whenever we want to change the current status of the grid.
Every time the tick is called, the ruleset is applied to each cell and the next set of states
is placed into the master grid. Depending on the timing specifications set by the user, this
may also change secondary cell planes (the master is always updated on each tick).
"""
tmp = np.copy(self.master)
for i in range(len(self.master.flat)):
tmp.flat[i] = rules.applyTo(i, self.master, *args)
self.master[:] = tmp
def start_plot(self, clock, rules, *args):
"""
Initiates main graphical loop.
The following function displays the graphical component (through use of matplotlib), and triggers the
next tick for every "clock" milliseconds. This should only be called if the automata is 2 or 3 dimensional.
"""
fig, ax = plt.subplots()
@ -48,7 +94,7 @@ class CAM:
mshown = plt.matshow(self.master, fig.number)
def animate(frame):
self.tick(ruleset, neighborhood, *args)
self.tick(rules, *args)
mshown.set_array(self.master)
fig.canvas.draw()
@ -58,33 +104,16 @@ class CAM:
plt.show()
def start_console(self, clock, ruleset, neighborhood, *args):
def start_console(self, clock, rules, *args):
"""
Initates main console loop.
Works similarly to start_plot but prints out to the console.
TODO: Incorporate curses, instead of just printing repeatedly.
"""
while True:
print(self.master)
time.sleep(clock / 1000)
self.tick(ruleset, neighborhood, *args)
self.tick(rules, *args)
def tick(self, ruleset, neighborhood, *args):
"""
The tick function should be called whenever we want to change the current status of the grid.
Every time the tick is called, the ruleset is applied to each cell and the next configuration
is placed into the master grid. Depending on the timing specifications set by the user, this
may also change secondary cell planes (the master is always updated on each tick).
"""
tmp = np.copy(self.master)
for i in range(len(self.master.flat)):
tmp.flat[i] = ruleset.call(i, self.master, neighborhood, *args)
self.master[:] = tmp
def randomize(self):
"""
Set the master grid to a random configuration.
"""
self.master[:] = np.random.random_integers(0, 1, self.master.shape)

View File

@ -1,36 +0,0 @@
"""
@author: jrpotter
@date: June 01, 2015
"""
import numpy as np
def flatten(coordinates, grid):
"""
Given the coordinates of a matrix, returns the index of the flat matrix.
"""
index = 0
for i in range(len(coordinates)):
index += coordinates[i] * np.prod(grid.shape[i+1:], dtype=np.int32)
return index
def unflatten(index, grid):
"""
Given an index of a flat matrix, returns the corresponding coordinates.
"""
coordinates = []
for i in range(len(grid.shape)):
tmp = np.prod(grid.shape[i+1:], dtype=np.int32)
coordinates.append(index // tmp)
index -= tmp * coordinates[-1]
return tuple(coordinates)
def comp_add(coor1, coor2):
"""
Adds components of coordinates element-wise.
"""
return tuple(map(sum, zip(coor1, coor2)))

View File

@ -1,123 +0,0 @@
"""
@author: jrpotter
@date: May 31st, 2015
"""
import camtools as ct
import itertools as it
class Neighborhood:
"""
The following represents the cells that must be considered when applying a ruleset, as well as the
values expected in a ruleset.
Since neighborhoods can be made arbitrarily complex, we allow extending in all directions. For example,
the basic Moore neighborhood comprises of the 8 cells surrounding the center, but what if we wanted
these 8 and include the cell north of north? The following enables this:
m_neighborhood = Neighborhood.moore(2)
m_neighborhood.extend({(-2, 0): True})
This allows indexing at levels beyond 3D, which the Cells enumeration does not allow, though visualization
at this point isn't possible.
"""
def __init__(self, wrap_around=True):
"""
Sets up an empty neighborhood.
Initially, no cells are included in a neighborhood. All neighborhoods must be extended.
Note the offsets have a tuple as a key representing the position being offsetted by, and as a value,
the current state the given cell at the offset is checked to be.
"""
self.offsets = {}
self.wrap_around = wrap_around
def neighbors(self, index, grid):
"""
Returns all cells in the given neighborhood.
The returned list of indices represent the index in question, the value at the given index, and
the expected value as defined in the offsets.
"""
indices = []
for key in sorted(self.offsets.keys()):
if self.wrap_around:
f_index = (key + index) % len(grid.flat)
indices.append((f_index, grid.flat[f_index], self.offsets[key]))
else:
pass
return indices
def extend(self, offsets, grid, strict=False):
"""
Adds new offsets to the instance member offsets.
We complain if the strict flag is set to True and an offset has already been declared with a different value.
Note also that all offsets are indices of the flattened matrix. This allows for quick row indexing as opposed
to individual coordinates.
"""
f_offsets = {ct.flatten(k, grid): v for k, v in offsets.items()}
if not strict:
self.offsets.update(f_offsets)
else:
for k in f_offsets.keys():
value = self.offsets.get(k, None)
if value is None:
self.offsets[k] = offsets[k]
elif value != offsets[k]:
raise KeyError
@classmethod
def moore(cls, grid, wrap_around=True, value=True):
"""
Returns a neighborhood corresponding to the Moore neighborhood.
The Moore neighborhood consists of all adjacent cells. In 2D, these correspond to the 8 touching cells
N, NE, E, SE, S, SW, S, and NW. In 3D, this corresponds to all cells in the "backward" and "forward"
layer that adjoin the nine cells in the "center" layer. This concept can be extended to N dimensions.
Note the center cell is excluded, so the total number of offsets are 3^N - 1.
"""
offsets = {}
variants = ([-1, 0, 1],) * len(grid.shape)
for current in it.product(*variants):
if any(current):
offsets[current] = value
m_neighborhood = cls(wrap_around)
m_neighborhood.extend(offsets, grid)
return m_neighborhood
@classmethod
def neumann(cls, grid, wrap_around=True, value=True):
"""
Returns a neighborhood corresponding to the Von Neumann neighborhood.
The Von Neumann neighborhood consists of adjacent cells that directly share a face with the current cell.
In 2D, these correspond to the 4 touching cells N, S, E, W. In 3D, we include the "backward" and "forward"
cell. This concept can be extended to N dimensions.
Note the center cell is excluded, so the total number of offsets are 2N.
"""
offsets = {}
variant = [0] * len(grid.shape)
for i in range(len(variant)):
for j in [-1, 1]:
variant[i] = j
offsets[tuple(variant)] = value
variant[i] = 0
n_neighborhood = cls(wrap_around)
n_neighborhood.extend(offsets, grid)
return n_neighborhood

View File

@ -1,97 +1,257 @@
"""
The following determines the next state of a given cell in a CAM.
The ruleset takes in a collection of rules specifying neighborhoods, as well as the configurations of
said neighborhood that yield an "on" or "off" state on the cell a ruleset is being applied to.
@author: jrpotter
@date: May 31st, 2015
"""
import enum
import camtools
import itertools as it
import numpy as np
class Rule(enum.Enum):
def flatten(coordinates, grid):
"""
Given the coordinates of a matrix, returns the index of the flat matrix.
This is merely a convenience function to convert between N-dimensional space to 1D.
"""
index = 0
gridprod = 1
for i in reversed(range(len(coordinates))):
index += coordinates[i] * gridprod
gridprod *= grid.shape[i]
return index
class Configuration:
"""
Represents an expected neighborhood; to be compared to an actual neighborhood in a CAM.
A configuration allows exact specification of a neighborhood, not the actual state of a neighborhood.
It is merely used for reference by a ruleset, which takes in a series of configurations and
the next state of a cell depending on a configuration.
"""
# Possible states a cell can take
#
# If a configuration passes, the cell's state will be on or off if ON or OFF was passed respectively.
# If IGNORE, then the state remains the same, but no further configurations will be checked by the
# ruleset.
ON = 1
OFF = 0
def __init__(self, grid, next_state, offsets={}):
"""
@next_state: Represents the next state of a cell given a configuration passes.
This should be an [ON|OFF|Function that returns ON or Off]
@offsets: A dictionary of offsets containing N-tuple keys and [-1, 0, 1] values.
Note N must be the same dimension as the grid's dimensions, as it specifies
the offset from any given cell in the grid.
"""
self.next_state = next_state
# The grid we work with is flattened, so that we can simply access single indices (as opposed
# to N-ary tuples). This also allows for multiple index accessing via the numpy list indexing
# method
states = []
f_offsets = []
for k, v in offsets.items():
states.append(v)
f_offsets.append(flatten(k, grid))
self.states = np.array(states)
self.offsets = np.array(f_offsets)
def passes(self, f_index, grid, vfunc, *args):
"""
Checks if a given configuration passes, and if so, returns the next state.
@vfunc is an arbitrary function that takes in a flattened grid, a list of indices, and a list of values (which,
if zipped with indices, correspond to the expected value of the cell at the given index). The function should
merely verify that a list of indices "passes" some expectation.
For example, if an "exact match" function is passed, it should merely verify that the cells at the passed indices
exactly match the exact expectated cells in the list of values. It will return True or False depending.
"""
# We ensure all indices are within the given grid
indices = (f_index + self.offsets) % grid.size
# Note the distinction between success and next_state here; vfunc (validity function) tells whether the given
# configuration passes. If it does, no other configurations need to be checked and the next state is returned.
success = vfunc(f_index, grid.flat, indices, self.states, *args)
if callable(self.next_state):
return (success, self.next_state(f_index, grid.flat, indices, self.states, *args))
else:
return (success, self.next_state)
@classmethod
def moore(cls, grid, value=ON):
"""
Returns a neighborhood corresponding to the Moore neighborhood.
The Moore neighborhood consists of all adjacent cells. In 2D, these correspond to the 8 touching cells
N, NE, E, SE, S, SW, S, and NW. In 3D, this corresponds to all cells in the "backward" and "forward"
layer that adjoin the nine cells in the "center" layer. This concept can be extended to N dimensions.
Note the center cell is excluded, so the total number of offsets are 3^N - 1.
"""
offsets = {}
variants = ([-1, 0, 1],) * len(grid.shape)
for current in it.product(*variants):
if any(current):
offsets[current] = value
return offsets
@classmethod
def neumann(cls, grid, value=ON):
"""
Returns a neighborhood corresponding to the Von Neumann neighborhood.
The Von Neumann neighborhood consists of adjacent cells that directly share a face with the current cell.
In 2D, these correspond to the 4 touching cells N, S, E, W. In 3D, we include the "backward" and "forward"
cell. This concept can be extended to N dimensions.
Note the center cell is excluded, so the total number of offsets are 2N.
"""
offsets = {}
variant = [0] * len(grid.shape)
for i in range(len(variant)):
for j in [-1, 1]:
variant[i] = j
offsets[tuple(variant)] = value
variant[i] = 0
return offsets
class Ruleset:
"""
The primary class of this module, which saves configurations of cells that yield the next state.
The ruleset will take in configurations defined by the user that specify how a cell's state should change,
depending on the given neighborhood and current state. For example, if I have a configuration that states
[[0, 0, 0]
,[1, 0, 1]
,[1, 1, 1]
]
must match exactly for the center cell to be a 1, then each cell is checked for this configuration, and its
state is updated afterward (note the above is merely for clarity; a configuration is not defined as such). Note
configurations are checked until a match occurs, in a FIFO manner.
"""
class Method(enum.Enum):
"""
Specifies how a ruleset should be applied to a given cell.
* A match declares that a given configuration must match exactly for the cell to be considered on.
* A tolerance specifies that a configuration must match within a given percentage to be considered on.
* A specification allows the user to define a custom function which must return a boolean, declaring
whether a cell should be on or off. This function is given the current cell's state, as well as
the state of the cell's neighbors.
"""
MATCH = 0
TOLERATE = 1
SATISFY = 2
class Ruleset:
"""
The following determines the next state of a given cell in a CAM.
Given a neighborhood and a tolerance level, the ruleset determines whether a given cell should be on or off after a tick.
For example, if the tolerance level is set to 100% (i.e. neighborhoods must exactly match desired neighborhood to be on),
then the ruleset iterates through all neighbors and verifies a match.
For the sake of clarity, we consider a neighborhood to actually contain the "rules" for matching, and a ruleset to be the
application of the rules as defined in the neighborhood. We state this since the actual expected values are declared in
a neighborhood instance's offsets member.
"""
def __init__(self, method):
"""
@grid: Every ruleset is bound to a grid, which a ruleset is applied to.
@method: One of the values defined in the RulesetMethod enumeration. View class for description.
"""
self.method = method
self.configurations = []
def matches(self, index, grid, neighborhood):
def addConfiguration(self, grid, next_state, offsets):
"""
Creates a configuration and saves said configuration.
"""
config = Configuration(grid, next_state, offsets)
self.configurations.append(config)
def applyTo(self, f_index, grid, *args):
"""
Depending on a given method, applies ruleset to a cell.
@cell_index: The index of the cell in question, as offset by self.grid.flat. That means the index should be
a single number (not a tuple!).
@args: If our method is TOLERATE, we pass in a value in set [0, 1]. This specifies the threshold between a
passing (i.e. percentage of matches in a configuration is > arg) and failing. If our method is SATISFY,
arg should be a function returning a BOOL, which takes in a current cell's value, and the
value of its neighbors.
"""
for config in self.configurations:
# Determine the correct function to use
vfunc = None
if self.method == Ruleset.Method.MATCH:
vfunc = self._matches
elif self.method == Ruleset.Method.TOLERATE:
vfunc = self._tolerates
elif self.method == Ruleset.Method.SATISFY:
vfunc = self._satisfies
# Apply the function if possible
if vfunc is not None:
passed, state = config.passes(f_index, grid, vfunc, *args)
if passed:
return state
else:
break
return grid.flat[f_index]
def _matches(self, f_index, f_grid, indices, states):
"""
Determines that neighborhood matches expectation exactly.
Note this is just like the tolerate method with a tolerance of 1, but
recoding allows for short circuiting.
Note this functions like the tolerate method with a tolerance of 1.
"""
residents = neighborhood.neighbors(index, grid)
for resident in residents:
if grid[resident[0]] != resident[1]:
return False
return True
return not np.count_nonzero(f_grid[indices] ^ states)
def tolerate(self, index, grid, neighborhood, tolerance):
def _tolerates(self, f_index, f_grid, indices, states, tolerance):
"""
Determines that neighborhood matches expectation within tolerance.
We see that the percentage of actual matches are greater than or equal to the given tolerance level. If so, we
consider this cell to be alive. Note tolerance must be a value 0 <= t <= 1.
"""
matches = 0
residents = neighborhood.neighbors(index, grid)
for resident in residents:
if grid[resident[0]] == resident[1]:
matches += 1
return (matches / len(residents)) >= tolerance
non_matches = np.count_nonzero(f_grid[inices] ^ states)
return (non_matches / len(f_grid)) >= tolerance
def satisfies(self, index, grid, neighborhood, valid_func):
def _satisfies(self, f_index, f_grid, indices, states, valid_func):
"""
Allows custom function to relay next state of given cell.
The passed function is supplied the list of 2-tuple elements, of which the first is a Cell and the second is
the expected state as declared in the Neighborhood, as well as the grid and cell in question.
"""
residents = neighborhood.neighbors(index, grid)
coordinate = camtools.unflatten(index, grid)
return valid_func(coordinate, grid, residents)
return valid_func(f_index, f_grid, indices, states)
def call(self, index, grid, neighborhood, *args):
"""
Allow for batch processing of rules.
We choose our processing function based on the specified rule and update every cell in the grid simultaneously
via a vectorization.
"""
if self.method == Rule.MATCH:
func = self.matches
elif self.method == Rule.TOLERATE:
func = self.tolerate
elif self.method == Rule.SATISFY:
func = self.satisfies
return int(func(index, grid, neighborhood, *args))