From 37c0f9281ac1b78b7a3c581de38023c95842dd46 Mon Sep 17 00:00:00 2001 From: Joshua Potter Date: Tue, 2 Jun 2015 16:35:16 -0400 Subject: [PATCH] Generalized rulesets. Removed neighborhoods. --- examples/life.py | 51 +++++---- src/cam.py | 97 ++++++++++------ src/camtools.py | 36 ------ src/neighborhood.py | 123 --------------------- src/ruleset.py | 264 +++++++++++++++++++++++++++++++++++--------- 5 files changed, 302 insertions(+), 269 deletions(-) delete mode 100644 src/camtools.py delete mode 100644 src/neighborhood.py diff --git a/examples/life.py b/examples/life.py index 72f7138..83edbb4 100644 --- a/examples/life.py +++ b/examples/life.py @@ -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) diff --git a/src/cam.py b/src/cam.py index a56d1a3..2204b65 100644 --- a/src/cam.py +++ b/src/cam.py @@ -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) - diff --git a/src/camtools.py b/src/camtools.py deleted file mode 100644 index 939c7de..0000000 --- a/src/camtools.py +++ /dev/null @@ -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))) diff --git a/src/neighborhood.py b/src/neighborhood.py deleted file mode 100644 index 356053d..0000000 --- a/src/neighborhood.py +++ /dev/null @@ -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 - diff --git a/src/ruleset.py b/src/ruleset.py index 6828cb5..57e6601 100644 --- a/src/ruleset.py +++ b/src/ruleset.py @@ -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): - MATCH = 0 - TOLERATE = 1 - SATISFY = 2 + +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 following determines the next state of a given cell in a CAM. + The primary class of this module, which saves configurations of cells that yield the next state. - 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. + 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 - 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. + [[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 + + 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)) -