import os
import itertools
import warnings
import json
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.patches as patches
import matplotlib.axes
from . import utils
try:
from numba import jit
NUMBA_AVAILABLE = True
except ImportError:
NUMBA_AVAILABLE = False
[docs]
def jit(*args, **kwargs):
def decorator(func):
return func
return decorator
[docs]
def optional_jit(*args, **kwargs):
"""
Applies a jit decorator only if numba is installed
"""
def decorator(func):
if NUMBA_AVAILABLE:
return jit(*args, **kwargs)(func)
return func
return decorator
# Cellular automata
# -----------------
[docs]
class CellularAutomata:
"""
A class for simulating one-dimensional cellular automata based on a specific rule set.
Args:
rule_number (int or str): Rule number for the cellular automaton, must be between 0 and 255.
width (int): Number of cells in the automaton's width.
initial_state (list of int, optional): Initial state of the automaton. Defaults to all zeros with a central one.
Attributes:
width (int): The cellular automaton's width.
rule_number (str): The rule number, zero-padded to three digits.
initial_state (list of int): The initial state of the automaton.
state (list of int): The current state of the automaton.
rules (list of tuple): The rules loaded from a JSON file.
"""
def __init__(self, rule_number, width, initial_state=None):
"""
Initializes the CellularAutomata class with a rule number, width, and optionally an initial state.
Parameters:
rule_number (int or str): The rule number for the cellular automaton, must be between 0 and 255.
width (int): The number of cells in one row of the automaton.
initial_state (list, optional): Initial binary state of the automaton. Defaults to a list of zeros with a single one in the center.
Raises:
ValueError: If the rule number is not within the valid range (0 to 255).
"""
self.width = width
if int(rule_number) < 0 or int(rule_number) > 255:
raise ValueError("Rule number must be an integer between 0 and 255.")
if isinstance(rule_number, int):
rule_number = str(rule_number).zfill(3)
self.rule_number = rule_number
self.rules = self.load_rules(rule_number)
self.initial_state = [0] * width if initial_state is None else initial_state
self.state = self.initial_state.copy()
[docs]
def load_rules(self, rule_number):
"""
Loads the rules from a JSON file based on the rule number.
Parameters:
rule_number (str): The rule number as a string, zero-padded to three digits.
Returns:
list: A list of tuples representing the rules for the cellular automaton.
"""
script_dir = os.path.dirname(os.path.realpath(__file__))
file_path = os.path.join(script_dir, 'data', 'ca1D_rules.json')
with open(file_path, 'r') as file:
rules_json = json.load(file)
rule_conditions = rules_json[rule_number]
return [tuple(condition) for condition in rule_conditions]
[docs]
def update_state(self):
"""
Updates the state of the automaton based on its rules for one generation.
"""
new_state = [0] * self.width
for i in range(self.width):
neighborhood = (self.state[(i - 1) % self.width],
self.state[i],
self.state[(i + 1) % self.width])
new_state[i] = 1 if neighborhood in self.rules else 0
self.state = new_state
[docs]
def validate_strips(self, strips):
"""
Validates that the strips are correctly formatted and within the valid range.
Parameters:
strips (list of tuples): Each tuple should specify the start and end indices of a strip in the automaton.
Raises:
ValueError: If strips are not properly formatted or indices are out of range.
"""
if isinstance(strips, tuple):
strips = [strips]
if not all(isinstance(strip, tuple) for strip in strips) or not all(len(strip) == 2 for strip in strips) or not all(isinstance(cell, int) for strip in strips for cell in strip):
raise ValueError("Strips must be a tuple of two integers or a list of tuples with two integers each.")
if not all(0 <= strip[0] < strip[1] <= self.width for strip in strips):
raise ValueError("Strip values must be within the width of the automata.")
[docs]
def validate_values(self, values, strips):
"""
Validates that the values are provided as dictionaries, and there is a dictionary for each strip.
Parameters:
values (list or dict): List of dictionaries mapping indices to pitches or other data.
strips (list of tuples): List of strip ranges to validate against.
Raises:
ValueError: If values are not provided as a list of dictionaries or their count doesn't match the strips.
"""
if isinstance(values, dict):
values = [values]
if not isinstance(values, list) or not all(isinstance(val_dict, dict) for val_dict in values):
raise ValueError("Values must be provided as a dictionnary or a list of dictionaries mapping indices to pitches or other data.")
if len(values) != len(strips):
raise ValueError("The number of value dictionaries must match the number of strips.")
[docs]
def generate_01(self, iterations, strips=None):
"""
Generates a binary (0 or 1) evolution of the automaton over a specified number of iterations, optionally for specific strips.
Parameters:
iterations (int): Number of iterations to evolve the automaton.
strips (list of tuples, optional): Specific sections of the automaton to evolve.
Returns:
list: A list representing the evolution of the automaton, either as a whole or just the specified strips.
"""
if strips:
self.validate_strips(strips)
self.state = self.initial_state.copy()
evolution = [self.state.copy()]
for _ in range(iterations - 1):
self.update_state()
evolution.append(self.state.copy())
if strips:
strip_evolutions = []
for strip in strips:
strip_evolution = [row[strip[0]:strip[1]] for row in evolution]
strip_evolutions.append(strip_evolution)
return strip_evolutions
return evolution
[docs]
def generate(self, iterations, strips, values):
"""
Generates the evolution of the automaton, mapping binary states (0 or 1) to specific values based on provided mappings.
Parameters:
iterations (int): Number of iterations to evolve the automaton.
strips (list of tuples): Sections of the automaton for which to generate values.
values (list of dicts): Mappings from indices in each strip to specific values.
Returns:
list: A list of evolutions for each strip, with binary states mapped to specified values.
"""
# Validate strips and value mapping
self.validate_strips(strips)
self.validate_values(values, strips)
if isinstance(values, dict):
values = [values]
# Generate the binary evolution for specified strips
evolution_01 = self.generate_01(iterations, strips)
values_evolution = []
for strip_evolution, value_dict in zip(evolution_01, values): # Process each strip with its corresponding value map
strip_values = []
for row in strip_evolution:
row_values = []
for i, cell in enumerate(row):
if cell == 1 and i in value_dict:
row_values.append(value_dict[i])
if row_values:
if len(row_values) == 1:
strip_values.append(row_values[0]) # Single value
else:
strip_values.append(row_values) # Multiple values forming a chord
else:
strip_values.append(None)
values_evolution.append(strip_values)
if len(values_evolution) == 1:
return values_evolution[0]
else:
return values_evolution
[docs]
def plot(self, iterations, ax=None, strips=None, extract_strip=False, title=None, show_axis=True):
"""
Plots the evolution of the cellular automaton.
Parameters:
iterations (int): Number of generations to simulate.
ax (matplotlib.axes.Axes, optional): The matplotlib axis to plot on. If None, a new figure is created.
strips (list of tuples, optional): Ranges to highlight or exclusively plot.
extract_strip (bool): If True, only the specified strips are plotted each in separate subplots.
title (str, optional): Title for the plot. Default is based on the rule number.
show_axis (bool): Whether to show axis labels and grid.
Returns:
matplotlib.axes.Axes: The axis with the plot.
"""
# Handle based on the extraction flag
if not extract_strip:
if title is None:
title = f"Cellular Automata Evolution for Rule {self.rule_number}"
# Display the complete evolution
if ax is None:
fig, ax = plt.subplots(figsize=(12, 8))
evolution = np.array(self.generate_01(iterations)).T
im = ax.imshow(evolution, cmap='binary', aspect='equal')
ax.invert_yaxis()
ax.set_title(title)
ax.set_ylabel("Cell Position")
ax.set_xlabel("Generation")
# Add strips if provided
if strips:
label_offset = 3
for index, strip in enumerate(strips):
rect = patches.Rectangle(
(0, strip[0]),
iterations, strip[1] - strip[0], linewidth=1, edgecolor='none', facecolor='#88888880'
)
ax.add_patch(rect)
ax.text(
1, strip[0] + label_offset, f'Strip {index + 1}',
color='white', fontsize=10, verticalalignment='top',
bbox=dict(facecolor='#333333', edgecolor='none', pad=2)
)
else:
# Handle multiple strips, each in its own subplot
if not strips or len(strips) == 1:
fig, axs = plt.subplots(figsize=(12, 8)) # Use a new axis for a single strip
else:
fig, axs = plt.subplots(len(strips), 1, figsize=(12, 2 * len(strips)**(0.5) ))
axs = axs.flatten()
if isinstance(axs, matplotlib.axes.Axes):
axs = [axs]
strip_evolutions = self.generate_01(iterations, strips)
for i, strip_data in enumerate(strip_evolutions):
axs[i].imshow(np.array(strip_data).T, cmap='binary', aspect='equal')
axs[i].invert_yaxis()
if title is None:
strip_title = f"Strip {i + 1} Evolution"
else:
strip_title = title[i]
axs[i].set_title(strip_title)
axs[i].set_ylabel("Cell Position")
axs[i].set_xlabel("Generation")
# Optionally turn off axis
if not show_axis:
ax.axis('off')
# If this function created the figure and it's handling a subplot setup, adjust the layout
if extract_strip:
plt.tight_layout()
plt.subplots_adjust(hspace=0.5) # Adjust horizontal space if needed
if ax is None:
return fig
else:
return ax
# Mandelbrot fractal
# ------------------
[docs]
@optional_jit(nopython=True)
def generate_mandelbrot_jit(x_range, y_range, dimensions, max_iter):
x_lin = np.linspace(x_range[0], x_range[1], dimensions[0])
y_lin = np.linspace(y_range[0], y_range[1], dimensions[1])
output = np.zeros(dimensions, dtype=np.int32) # Initialize the output array
for i in range(dimensions[0]):
for j in range(dimensions[1]):
x = x_lin[i]
y = y_lin[j]
C = complex(x, y) # Ensure using complex number
Z = 0 + 0j # Initialize Z as a complex zero
count = 0 # Initialize escape time count
while abs(Z) < 2 and count < max_iter:
Z = Z**2 + C
count += 1
output[i, j] = count # Set the output based on the escape time
return output
[docs]
class Mandelbrot:
def __init__(self, scale=None, start_note_index=0, dimensions=(800, 800), max_iter=1000, x_range=(-2.0, 1.0), y_range=(-1.5, 1.5)):
if isinstance(dimensions, int):
dimensions = (dimensions, dimensions)
if not isinstance(dimensions, tuple) or not isinstance(max_iter, int):
raise ValueError("Dimensions must be a tuple and max_iter must be an integer.")
if dimensions[0] <= 0 or dimensions[1] <= 0 or max_iter <= 0:
raise ValueError("Dimensions and max_iter must be positive.")
self.scale = scale
self.start_note_index = start_note_index
self.dimensions = dimensions
self.max_iter = max_iter
self.x_range = x_range
self.y_range = y_range
[docs]
def generate_mandelbrot(self):
return generate_mandelbrot_jit(self.x_range, self.y_range, self.dimensions, self.max_iter)
[docs]
def generate(self, method='horizontal', line_index=0):
data = self.generate_mandelbrot()
if method == 'horizontal':
return data[line_index, :]
elif method == 'vertical':
return data[:, line_index]
elif method == 'diagonal-increasing':
return np.diagonal(data)
elif method == 'diagonal-decreasing':
return np.diagonal(np.flipud(data))
elif method == 'random':
flat_data = data.flatten()
return np.random.choice(flat_data, size=100, replace=False)
[docs]
def plot(self, ax=None, figsize=(10, 10), zoom_rect=None, show_numbers=False):
if ax is None:
fig, ax = plt.subplots(figsize=figsize)
fractal = self.generate_mandelbrot()
extent = (self.x_range[0], self.x_range[1], self.y_range[0], self.y_range[1])
if zoom_rect and show_numbers:
warnings.warn("Both zoom rectangle and showing numbers are enabled. Numbers are hidden.", UserWarning)
im = ax.imshow(
fractal.T,
extent=extent,
cmap='viridis', aspect='auto', origin='lower'
)
ax.set_title('Mandelbrot Set')
ax.set_xlabel('Real')
ax.set_ylabel('Imaginary')
if show_numbers:
for i in range(fractal.shape[0]):
for j in range(fractal.shape[1]):
ax.text(j, i, fractal[i, j], ha="center", va="center", color="w")
if zoom_rect:
rect = patches.Rectangle(
(zoom_rect[0][0], zoom_rect[1][0]),
zoom_rect[0][1] - zoom_rect[0][0], zoom_rect[1][1] - zoom_rect[1][0],
linewidth=1, edgecolor='white', facecolor='none'
)
ax.add_patch(rect)
return ax
# Logistic map
# ------------
[docs]
@optional_jit(nopython=True)
def logistic_map(growth_rate, pop, iterations):
"""Compute logistic map iteratively for a given rate over many iterations."""
final_pop = np.empty(iterations)
for i in range(iterations):
pop = growth_rate * pop * (1 - pop)
final_pop[i] = pop
return final_pop
[docs]
@optional_jit(nopython=True)
def compute_logistic(rate_values, iterations, last_n):
"""Compute the logistic map for a range of r values, collecting the last `last_n` iterations."""
num_rate = len(rate_values)
plot_pop = np.empty(num_rate * last_n)
plot_rate = np.empty(num_rate * last_n)
for idx, r in enumerate(rate_values):
xs = logistic_map(r, 0.5, iterations + last_n) # Drop initial values to skip transient
plot_pop[idx*last_n:(idx+1)*last_n] = xs[-last_n:] # Take only the last `last_n` iterations
plot_rate[idx*last_n:(idx+1)*last_n] = r
return plot_rate, plot_pop
[docs]
class LogisticMap:
def __init__(self, rates, iterations=1000, last_n=100):
self.rates = rates
self.iterations = iterations
self.last_n = last_n
[docs]
def generate(self):
rate, pop = compute_logistic(self.rates, self.iterations, self.last_n)
return rate, pop
[docs]
def plot(self, ax=None, figsize=(10, 6)):
if ax is None:
fig, ax = plt.subplots(figsize=figsize)
rate, pop = self.generate()
ax.plot(rate, pop, ',k', alpha=0.5)
ax.set_xlabel('rate')
ax.set_ylabel('population')
ax.set_xlim(self.rates[0], self.rates[-1])
ax.set_ylim(0, 1)
return ax