diff --git a/software/contrib/cellarium.md b/software/contrib/cellarium.md new file mode 100644 index 000000000..b03ce8a51 --- /dev/null +++ b/software/contrib/cellarium.md @@ -0,0 +1,420 @@ +# Cellarium: A Cellular Automata Framework for EuroPi + +> *Etymology:* +> +> *cell (plural cells), Noun* +>     *1. The basic unit of a living organism* +>     *2. The minimal unit of a cellular automaton that can change state* +> +> *-arium (plural -ariums or -aria), Suffix* +>     *1. A place associated with a specified thing* +>     *2. A device associated with a specified function* +> +> *Cellarium, Noun* +>     *1. A place for observing cellular automata simulations, such as Life* + +Cellarium is a flexible and performant cellular automata framework designed for the EuroPi modular synthesis platform. It enables the creation and execution of various cellular automata patterns that can be used for generative CV and gate modulation. + +## Features + +- High-performance MicroPython optimized simulation engine +- Multiple included automata implementations + - Conway's Game of Life + - Brian's Brain + - Rule 30 & Rule 90 (Elementary Cellular Automata) + - Langton's Ant + - Water Droplets Simulation + - Seeds +- Dynamic pattern visualization on the OLED display +- CV input/output mapping for each automaton +- Configurable simulation parameters +- Extensible architecture for adding new automata + +## Controls and Settings + +The Cellarium interface provides comprehensive control over simulation parameters and modes: + +### Core Controls + +- **K1 (Knob 1)**: Controls food value (0-100) for population growth +- **K2 (Knob 2)**: Controls tick rate limit (0-500ms) for simulation speed +- **B1**: Cycles through different B2 modes +- **B2**: Activates or adjusts the current mode's settings + +### B2 Modes + +1. **RESET**: Clears and reinitializes the simulation +2. **FEED**: Triggers manual feeding of the simulation +3. **CLOCK**: Enables manual clock control +4. **AUTOMATA**: Cycles through available cellular automata +5. **D IN**: Configure digital input behavior + - OFF: Digital input disabled + - RESET: Triggers simulation reset + - FEED: Triggers feeding + - CLOCK: External clock input +6. **A IN**: Configure analog input behavior + - OFF: Analog input disabled + - FEED: Modulates food value + - TICK: Modulates tick rate +7. **CV SYNC**: Set CV output division ratio + - Available ratios: 1/1, 1/2, 1/4, 1/8, 1/16 + - Based on simulation tick count, so 1/2 would be every second simulation tick +8. **STASIS**: Configure stasis response + - FEED: Auto-feed when stasis detected + - RESET: Auto-reset when stasis detected + - OFF: No action on stasis + +### CV Outputs + +- **CV1-4**: Automaton-specific outputs +- **CV5**: Stasis indication (on when in stasis) +- **CV6**: Simulation duration gate (clock) + +## Patch Examples + +Here are some creative ways to use different automata in your patches: + +### Complex Life Modulation +Using Conway's Game of Life: +1. Set STASIS mode to OFF +2. Turn K1 (food value) to about 75% +3. Set D IN mode to FEED or RESET +4. Use CV2 for envelope shape (birth rate) +5. Use CV3 for amplitude (population density) +6. CV4 gates a VCA (population growth) +- Creates organic modulation based on colony entropy (CV1) +- K1 adjusts pattern complexity +- K2 controls evolution speed +- CV1 great for filter cutoff modulation + +### Water Flow Dynamics +Using Water Droplets simulation: +1. Set CV SYNC to 1/1 for accurate timing +2. CV1 tracks downward movement (gravity) +3. CV2 outputs impacts (bottom edge hits) +4. CV3 shows new droplet creation +5. CV4 indicates flow direction bias +- Natural water-like rhythm patterns +- CV2 perfect for percussion triggers +- Use CV4 for stereo panning +- K1 controls droplet density + +### Rule 30 Pattern Generator +Using Elementary CA Rule 30: +1. Keep STASIS on with RESET response +2. Use moderate tick rate (K2) +3. CV2 outputs current line activity +4. CV3 provides bottom row entropy +- Bottom row analysis for melody generation +- Global density on CV1 for long patterns +- Activity gate on CV4 for rhythm +- Great for generative sequences + +### Brian's Brain Waves +Using Brian's Brain: +1. CV1 outputs wave complexity +2. CV2 tracks activation levels +3. CV3 provides state entropy +4. CV4 indicates pattern stability +- Perfect for complex filter modulation +- Use CV2 for VCA control +- CV3 works well with reverb mix +- Stability gate for sequence reset + +### Ant Path Modulation +Using Langton's Ant: +1. CV1 outputs ant population voltage +2. CV2/CV3 give X/Y coordinates +3. CV4 ramps up over time +4. Use fast tick rate for smooth motion +- Great for stereo field control +- CV4 useful for long evolving patches +- Population changes affect intensity +- Reset for new walking patterns + +### Chaotic Seeds Bursts +Using Seeds automata: +1. CV1 shows population density +2. CV2 indicates birth rate intensity +3. CV3 outputs total state change level +4. CV4 gates on high birth activity +- Perfect for burst generators +- CV2 great for envelope triggering +- CV3 excellent for effect intensity +- High activity gate for rhythmic patterns + +Tips for all automata: +- Use CV SYNC ratios to control update timing +- STASIS modes can auto-reset patterns +- D IN modes add external control +- Experiment with different food values (K1) +- Adjust simulation speed with K2 +- CV6 provides timing reference + +These patches demonstrate different ways to use cellular automata as modulation sources. Experiment with combining different modes and CV routings to create your own unique patches. + +## Adding New Automata + +To create a new cellular automaton, follow these steps: + +1. **Create a New File** + - Place it in the `cellarium_docs/automata/` directory + - Name should describe the automaton type (e.g., `my_automaton.py`) + +2. **Import Required Modules** + ```python + from contrib.cellarium_docs.automata_base import BaseAutomata + ``` + +3. **Create Your Automaton Class** + ```python + class MyAutomaton(BaseAutomata): + def __init__(self, width, height, current_food_value, current_tick_limit): + super().__init__(width, height, current_food_value, current_tick_limit) + self.name = "My Automaton" + # Initialize your specific variables here + ``` + +4. **Implement Required Methods** + ```python + def simulate_generation(self, sim_current, sim_next) -> int: + """Core simulation logic. Returns number of cells changed.""" + # Your simulation code here + return cells_changed + + def feed_rule(self, sim_current, sim_next, food_chance, num_alive) -> int: + """Handle food/energy input. Returns current population.""" + # Your feeding rules here + return population_count + ``` + +5. **Optional Methods** + ```python + def reset(self): + """Reset the automaton state""" + super().reset() + # Your reset code here + + def cv1_out(self, cellarium): + """CV1 output mapping""" + # Your CV1 logic here + + def cv2_out(self, cellarium): + """CV2 output mapping""" + # Your CV2 logic here + + def cv3_out(self, cellarium): + """CV3 output mapping""" + # Your CV3 logic here + + def cv4_out(self, cellarium): + """CV4 output mapping""" + # Your CV4 logic here + ``` + +### Hardware Limitations and Performance Considerations + +The Raspberry Pi Pico, while powerful for its size, has some important limitations to consider: + +- **Limited RAM**: Only 264KB of RAM available + - Heap fragmentation can cause memory issues + - Large object creation/deletion can lead to memory exhaustion + - String operations and list comprehensions are memory-intensive + +- **CPU Constraints**: + - 133 MHz dual-core ARM Cortex-M0+ + - No hardware floating-point unit (FPU) + - Float operations are software-emulated and slow + - Limited instruction cache (16KB shared) + +- **MicroPython Overhead**: + - Dynamic typing adds runtime overhead + - Garbage collection can cause timing jitter + - Dictionary lookups are relatively expensive + - Function calls have higher overhead than C + +> **Note on Newer Pico Models**: While newer Raspberry Pi Pico models (like the Pico 2) feature hardware improvements such as increased RAM and hardware floating-point acceleration, it's recommended to develop with original Pico limitations in mind. This ensures backwards compatibility and wider module compatibility. Any optimizations made for the original Pico will still benefit newer models, and critical performance paths will remain efficient across all versions. + +### General Performance Tips + +The following is just a brief overview, it is advised to read the documentation here: +- https://docs.micropython.org/en/v1.9.3/pyboard/reference/speed_python.html + +1. **Function Call Optimization** + - Minimize function calls in tight loops + - Use inline calculations instead of helper functions + - Cache method lookups in local variables + - Avoid recursive functions when possible + - Consider moving small functions inline with @micropython.viper + +2. **Memory Management** + - Pre-allocate fixed-size buffers + - Reuse existing objects + - Minimize string operations + - Use bytearrays instead of lists + - Avoid creating temporary objects in loops + +3. **Computation Optimization** + - Cache frequently used values + - Use integer math when possible + - Avoid floating-point operations + - Unroll small loops + - Use bit operations instead of arithmetic when possible + +4. **Data Structures** + - Use bytearrays for bit fields + - Prefer simple types (int, bool) + - Avoid dictionaries in hot paths + - Minimize object attributes + - Never use tuples - they're slow on the Pico + +5. **Code Organization** + - Keep critical paths short + - Move constants outside loops + - Use local variables over attributes + - Batch similar operations + - Put frequently accessed values in local scope + +6. **Pico-Specific Optimizations** + - Avoid tuple assignments (x, y = 1, 2) + - Don't use list/dict comprehensions + - Minimize garbage collection triggers + - Use viper mode for array access + - Cache attribute lookups (self.x) in local vars + +7. **Loop Optimization** + - Use while loops instead of for when possible + - Pre-calculate loop bounds + - Avoid range() in tight loops with viper + - Move invariant calculations out of loops + - Consider manual loop unrolling for small ranges + +8. **Memory Layout** + - Align data structures to word boundaries + - Use contiguous memory blocks + - Minimize fragmentation with pre-allocation + - Reuse buffers instead of creating new ones + - Clear large objects explicitly when done + +### Performance Optimization with MicroPython + +MicroPython provides three levels of code execution optimization: + +1. **Regular Python (@micropython.native)** + ```python + @micropython.native + def simple_function(): + # Faster than regular Python + # Still has type checking + # Good for simple methods + ``` + - Pros: + - Maintains Python semantics + - Keeps type checking for safety + - Moderate speed improvement + - Cons: + - Still has function call overhead + - Limited optimization potential + - Not suitable for tight loops + - Documentation: + - https://docs.micropython.org/en/v1.9.3/pyboard/reference/speed_python.html#the-native-code-emitter + +2. **Viper Mode (@micropython.viper)** + ```python + @micropython.viper + def fast_function(): + # Much faster execution + # Must use type hints: int(), ptr8(), etc. + # No Python objects allowed + ``` + - Pros: + - Near-C execution speed + - Direct memory access + - Efficient numeric operations + - Cons: + - Limited to simple types + - No Python objects + - Stricter syntax requirements + - Harder to debug + - Documentation: + - https://docs.micropython.org/en/v1.9.3/pyboard/reference/speed_python.html#the-viper-code-emitter + +### Bit-Parallel Processing and Bitwise Operations + +Bit-parallel and byte-parallel processing are optimization techniques that leverage the binary nature of cellular automata states: + +**Bit-Parallel Processing**: +- Processes multiple cells simultaneously within a single byte +- Each bit in a byte represents one cell's state (alive/dead) +- Bitwise operations affect all 8 bits (cells) at once +- Perfect for simple binary state automata like Life or Seeds +- Example: Checking 8 neighbors using shift operations + +**Byte-Parallel Processing**: +- Processes entire bytes as discrete units +- Each byte can represent multiple states or properties +- More flexible for complex state tracking +- Used in automata with >2 states like Brian's Brain +- Example: Using separate bytes for different cell states + +**Hybrid Approaches**: +- Combine bit and byte parallel techniques +- Use bit operations for state checks +- Use byte operations for state tracking +- Balance between flexibility and performance +- Example: Life with separate birth/death counting + +Cellular automata can be optimized using bit-parallel operations in bytearrays: + +> Note: Some of this is covered in software/firmware/experimental/bitarray.py, but when performance is +> critical it can be better to inline the get and set bit operations, especially with viper decorators and +> parallel operations at the cost of increasing code duplication. + +1. **Bytearray Basics** + ```python + # Each byte stores 8 cells + # Integer division (//) divides and rounds down to nearest integer + # We divide by 8 since each byte holds 8 bits/cells + grid = bytearray((width * height) // 8) + ``` + +2. **Bitwise Operations** + ```python + # Common operations: + x | y # OR: Set bits + x & y # AND: Test bits + x ^ y # XOR: Toggle bits + ~x # NOT: Invert bits + x << n # Left shift + x >> n # Right shift + ``` + +3. **Bit Manipulation** + ```python + # Set a bit + byte_idx = x >> 3 # Divide (/) by 8 + bit_pos = x & 7 # Modulo (%) 8 + bit_mask = 1 << bit_pos + grid[byte_idx] |= bit_mask + + # Test a bit + is_set = grid[byte_idx] & bit_mask + + # Clear a bit + grid[byte_idx] &= ~bit_mask + ``` + +### Example CV Mapping Ideas + +- Map living cell count to voltage +- Convert pattern density to CV +- Output pulses on specific pattern formations +- Generate gates from state transitions +- Create rhythmic patterns from cell births/deaths + +## Additional Resources + +- See existing automata implementations in `cellarium_docs/automata/` for more examples +- Read `automata_base.py` for detailed documentation of the base class +- Check the EuroPi documentation for CV/Gate handling details + diff --git a/software/contrib/cellarium.py b/software/contrib/cellarium.py new file mode 100644 index 000000000..19a4559b1 --- /dev/null +++ b/software/contrib/cellarium.py @@ -0,0 +1,513 @@ +#!/usr/bin/env python3 +# Copyright 2025 Allen Synthesis +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Main application script for Cellarium cellular automaton environment. + +cell (plural cells), Noun + 1. The basic unit of a living organism + 2. The minimal unit of a cellular automaton that can change state + +-arium (plural -ariums or -aria), Suffix + 1. A place associated with a specified thing. + 2. A device associated with a specified function. + +Cellarium, Noun + 1. A place for observing cellular automata simulations, such as Life. + +Provides a multi-threaded environment for running, displaying, and controlling +cellular automata simulations on EuroPi hardware. +Features: +- Multi-threaded: A thread for simulation and an asynchronous thread for display +- Real-time parameter control via knobs and buttons +- Settings display overlay, showing current mode and parameters +- Automaton hot-swapping + - Stasis detection and response, configurable per automaton + - Automata-defined CV input/output integration + - CV5 reserved for stasis indication + - CV6 reserved for simulation duration gate (clock) + +Lables: +- Externally Clockable +- Clock Source +- Clock Divider +- LFO +- Envelope Generator +- Random +- CV Generation +- CV Modulation +- And possibly more to observe within the Cellarium... + +Controls: +- B1: Cycle through B2 modes +- B2: Adjust current active mode settings. +- K1: Food value (value used by automata for population growth) +- K2: Tick rate limiter (simulation speed) +- DIN: Control reset, feed, or clocking based on mode +- AIN: Modulate food or tick rate +- CV1-4 Out: Automaton-specific outputs +- CV5 Out: Stasis indication (on when in stasis) +- CV6 Out: Simulation duration gate (simulation tick as clock source) + +@author Grybbit (https://github.com/Bearwynn) +@year 2025 +""" + +# EuroPi core imports +from europi import * +from europi_script import EuroPiScript + +# Standard library imports +from random import randint +from framebuf import MONO_HMSB +import gc +import math +import micropython +import _thread +from utime import ticks_ms, ticks_diff, sleep_ms +from ucollections import OrderedDict + +# Local imports +from contrib.cellarium_docs.automata_registry import get_automata_by_index, get_automata_names + +# Display constants +W = OLED_WIDTH +H = OLED_HEIGHT +BUF_LEN = (W * H) // 8 +MIN_TICK_LIMIT = 0 #milliseconds +MAX_TICK_LIMIT = 500 #milliseconds +SETTING_DISPLAY_TIME = 4000 +MAX_POP_DELTA = 24 +MIN_FOOD_VALUE = 0 +MAX_FOOD_VALUE = 100 + +class Cellarium(EuroPiScript): + MODES_B2 = OrderedDict(((n, i) for i, n in enumerate(('RESET','FEED','CLOCK','AUTOMATA','D IN','A IN','CV SYNC','STASIS')))) + MODES_DIN = OrderedDict(((n, i) for i, n in enumerate(('OFF','RESET','FEED','CLOCK')))) + MODES_AIN = OrderedDict(((n, i) for i, n in enumerate(('OFF','FEED','TICK')))) + MODES_CVRATIO = OrderedDict(((n, v) for n, v in (('1/1',1),('1/2',2),('1/4',4),('1/8',8),('1/16',16)))) + MODES_STASIS = OrderedDict(((n, i) for i, n in enumerate(('FEED','RESET','OFF')))) + + # Minimum time between state saves in milliseconds (5 seconds) + SAVE_THROTTLE_MS = 5000 + + def __init__(self): + self.width = W + self.height = H + # Buffers + self.sim_current = bytearray(BUF_LEN) + self.sim_next = bytearray(BUF_LEN) + self.sim_frame = FrameBuffer(self.sim_current, W, H, MONO_HMSB) + self.sim_next_frame = FrameBuffer(self.sim_next, W, H, MONO_HMSB) + self._buf_lock = _thread.allocate_lock() + self._display_ready = True + self._display_request = False + # State saving + self._last_save = ticks_ms() + # State + self.num_alive = 0 + self.num_born = 0 + self.num_died = 0 + self.pop_deltas = [] + self.reset_req = False + self.feed_req = False + self.food_value = MAX_FOOD_VALUE // 2 + self.b2_idx = 0 + self.din_idx = 0 + self.ain_idx = 0 + self.cv_idx = 0 + self.stasis_idx = 0 + self._show_settings_req = False + self.settings_display_start = 0 + self._settings_lock = _thread.allocate_lock() + # Timing + self.cv_update_req = True + self.tick_req = False + self.tick_limit = MIN_TICK_LIMIT + now = ticks_ms() + self._last_tick = now + self._last_display = now + self._display_thread_started = False + # automata support + self.automata_names = get_automata_names() + # Load last used automaton from state, or use default + saved_state = self.load_state_json() + self.automata_idx = saved_state.get("last_automaton", 0) + if self.automata_idx >= len(self.automata_names): + self.automata_idx = 0 + + automaton_class = get_automata_by_index(self.automata_idx) + if not automaton_class: + # Fall back to first automaton if there's an error + self.automata_idx = 0 + automaton_class = get_automata_by_index(0) + + self.current_automata = automaton_class(W, H, self.food_value, self.tick_limit) + # INIT + turn_off_all_cvs() + oled.fill(0) + oled.show() + + @b1.handler + def on_b1(): + self.b2_idx = (self.b2_idx + 1) % len(self.MODES_B2) + with self._settings_lock: + self._show_settings_req = True + self.settings_display_start = ticks_ms() + + @b2.handler + def on_b2(): + show_settings = False + b2_mode = list(self.MODES_B2.keys())[self.b2_idx] + if b2_mode == 'D IN': + self.din_idx = (self.din_idx + 1) % len(self.MODES_DIN) + mode = list(self.MODES_DIN.keys())[self.din_idx] + show_settings = True + elif b2_mode == 'A IN': + self.ain_idx = (self.ain_idx + 1) % len(self.MODES_AIN) + mode = list(self.MODES_AIN.keys())[self.ain_idx] + show_settings = True + elif b2_mode == 'CV SYNC': + self.cv_idx = (self.cv_idx + 1) % len(self.MODES_CVRATIO) + mode = list(self.MODES_CVRATIO.keys())[self.cv_idx] + show_settings = True + elif b2_mode == 'STASIS': + self.stasis_idx = (self.stasis_idx + 1) % len(self.MODES_STASIS) + mode = list(self.MODES_STASIS.keys())[self.stasis_idx] + show_settings = True + elif b2_mode == 'AUTOMATA': + self.automata_idx = (self.automata_idx + 1) % len(self.automata_names) + now = ticks_ms() + # Only save state if enough time has passed since last save + if ticks_diff(now, self._last_save) >= self.SAVE_THROTTLE_MS: + self.save_state_json({"last_automaton": self.automata_idx}) + self._last_save = now + + with self._buf_lock: + gc.collect() + #create new automata + new_automata = get_automata_by_index(self.automata_idx)(W, H, self.food_value, self.tick_limit) + self.current_automata = new_automata + # Reset all simulation data when switching automata + self.reset() + mode = self.automata_names[self.automata_idx] + show_settings = True + elif b2_mode == 'RESET': + self.reset_req = True + elif b2_mode == 'FEED': + self.feed_req = True + elif b2_mode == 'CLOCK': + self.tick_req = True + + if show_settings: + with self._settings_lock: + self._show_settings_req = True + self.settings_display_start = ticks_ms() + + @din.handler + def on_din(): + din_mode = list(self.MODES_DIN.keys())[self.din_idx] + if din_mode == 'RESET': + self.reset() + elif din_mode == 'FEED': + self.feed_grid() + elif din_mode == 'CLOCK': + self.tick_req = True + + @micropython.native + def main(self): + """Main entry point for the Cellarium application. + + Initializes the environment: + - Turns off all CV outputs + - Updates initial input states + - Resets simulation state + - Starts the asynchronous display thread + - Launches simulation thread + """ + turn_off_all_cvs() + self.update_inputs() + self.reset() + #Start ASYNC display thread + if not self._display_thread_started: + self._display_thread_started = True + _thread.start_new_thread(self._display_thread, ()) + #Start simulation thread + self._simulation_thread() + + @micropython.native + def update_inputs(self): + """Process and update input states from knobs and analog input. + + Handles: + - K1/K2 knob values with easing functions + - Analog input modulation + - Food value and tick limit calculations + - Input mode-specific adjustments + """ + # Inline easeInCubic calculation + k1_percent = k1.percent() + easedIn_k1_value = k1_percent * k1_percent * k1_percent + #easedIn_k1_value = k1_percent + + # Inline easeOutCubic calculation + k2_percent = k2.percent() + k2_ease = k2_percent - 1 + easedOut_k2_value = (k2_ease * k2_ease * k2_ease + 1) + + food_value = int(MAX_FOOD_VALUE * easedIn_k1_value) + tick_limit = int(MAX_TICK_LIMIT * (1.0 - easedOut_k2_value) )# 1.0 - easedOut_k2_value inverts the percentage so right turn is faster sim + + #adjust by analogue in + cv_knob_adjustment_value = (ain.percent() - 0.5) # offset to -0.5 to +0.5, so that values below 50% decrease and above 50% increase + if self.ain_idx == self.MODES_AIN['FEED']: + food_value = food_value + (MAX_FOOD_VALUE * cv_knob_adjustment_value) + elif self.ain_idx == self.MODES_AIN['TICK']: + tick_limit = tick_limit - (MAX_TICK_LIMIT * cv_knob_adjustment_value) + + self.food_value = max(MIN_FOOD_VALUE, min(MAX_FOOD_VALUE, food_value)) + self.tick_limit = max(MIN_TICK_LIMIT, min(MAX_TICK_LIMIT, tick_limit)) + + # ----- Simulation Thread Functions ------ + @micropython.native + def _simulation_thread(self): + """Main simulation thread that handles automata state updates and output generation. + + Manages: + - Input processing and updates + - Simulation timing and clock modes + - CV update scheduling + - Stasis detection and response + - Buffer swapping and display updates + - CV output generation + """ + update_cv_counter = 0 + while True: + now = ticks_ms() + self.update_inputs() + automata = self.current_automata + automata.update_input_data(self.food_value, self.tick_limit) + + should_tick = False + if self.din_idx == self.MODES_DIN['CLOCK'] or self.b2_idx == self.MODES_B2['CLOCK']: + should_tick = True if self.tick_req else False + else: + should_tick = ticks_diff(now, self._last_tick) >= self.tick_limit + + cv_update_division = list(self.MODES_CVRATIO.values())[self.cv_idx] + if update_cv_counter >= cv_update_division or cv_update_division == 1: + self.cv_update_req = True + update_cv_counter = 0 + + if should_tick: + self._last_tick = now + self.tick_req = False + update_cv_counter +=1 + + if self.stasis_check(): + if self.cv_update_req: + cv5.on() + if self.stasis_idx == self.MODES_STASIS['FEED']: + self.feed_req = True + elif self.stasis_idx == self.MODES_STASIS['RESET']: + self.reset_req = True + else: + if self.cv_update_req: + cv5.off() + + if self.reset_req: + self.reset() + self.reset_req = False + self.feed_req = False + if self.feed_req: + self.feed_grid() + self.feed_req = False + + if self.cv_update_req: + cv6.on() + with self._buf_lock: + # Get simulation metrics + packed = int(automata.simulate_generation(self.sim_current, self.sim_next)) + if self.cv_update_req: + cv6.off() + + # Retrieve metrics from simulation - split for MicroPython + self.num_born = packed & 0xffff + self.num_died = (packed >> 16) & 0xffff + born_died_diff = self.num_born - self.num_died + self.num_alive = self.num_alive + born_died_diff + + # Atomic swap under lock + with self._buf_lock: + # Swap simulation buffers + temp_sim = self.sim_current + self.sim_current = self.sim_next + self.sim_next = temp_sim + + # Swap frame buffers + temp_frame = self.sim_frame + self.sim_frame = self.sim_next_frame + self.sim_next_frame = temp_frame + + self._display_request = True + + if self.cv_update_req: + # Use automata-specific CV outputs + automata.cv1_out(self) + automata.cv2_out(self) + automata.cv3_out(self) + automata.cv4_out(self) + + self.cv_update_req = False + + @micropython.native + def feed_grid(self): + """Feed the simulation grid with new cells. + + Uses automaton's feed rule to: + - Add new live cells based on food value + - Update population counts + - Handle grid feeding mechanics + """ + fc = self.food_value + if fc <= 0: + self.feed_req = False + return + with self._buf_lock: + self.num_alive = self.current_automata.feed_rule(self.sim_current, self.sim_next, fc, self.num_alive) + self.feed_req = False + + @micropython.native + def reset(self): + """Reset the simulation state. + + - Clears simulation buffers + - Resets population counters + - Cleans up automata state + - Refeeds the grid if needed + """ + with self._buf_lock: + #clear simulation buffers + sim_current = self.sim_current + sim_next = self.sim_next + for i in range(BUF_LEN): + sim_current[i] = 0 + sim_next[i] = 0 + self.num_alive = 0 + self.pop_deltas.clear() + # Reset current automata safely + if hasattr(self, 'current_automata') and self.current_automata is not None: + try: + self.current_automata.reset() + except Exception as e: + #if automata reset fails, just continue. The buffers are cleared + pass + self.feed_grid() + + # ----- Analysis ------ + @micropython.native + def stasis_check(self): + """Check if simulation has reached a stable state. + + - Tracks population changes over time + - Maintains history of birth/death deltas + - Delegates to automaton's stasis rule + - Used for stasis detection and response + + Returns: + bool: True if simulation is in stasis, False otherwise + """ + # Calculate and store population delta + population_delta = self.num_born - self.num_died + self.pop_deltas.append(population_delta) + pop_delta_count = len(self.pop_deltas) + if pop_delta_count > MAX_POP_DELTA: + self.pop_deltas.pop(0) + # Delegate to current automaton's stasis rule with num_alive + with self._buf_lock: + return self.current_automata.stasis_rule(MAX_POP_DELTA, self.pop_deltas, self.num_born, self.num_died, self.num_alive) + return False + + # ----- Display Pipeline ----- + @micropython.native + def draw_settings(self): + """Draw the current settings on the OLED display. + + Displays: + - DIN mode status + - Analog input mode + - B2 button mode and associated settings + - Current automaton name + - CV sync ratio or stasis settings when active + + Uses text overlays at top and bottom of screen. + """ + # Get current mode names + din_mode = list(self.MODES_DIN.keys())[self.din_idx] + ain_mode = list(self.MODES_AIN.keys())[self.ain_idx] + b2_mode = list(self.MODES_B2.keys())[self.b2_idx] + oled.fill_rect(0,0,len(din_mode)*8+16,8,0) + oled.text(f"D:{din_mode}",0,0,1) + ain_text = f"A:{ain_mode}" + aw = len(ain_text)*8 + oled.fill_rect(W-aw,0,aw,8,0) + oled.text(ain_text, W-aw,0,1) + if b2_mode == 'CV SYNC': + cv_ratio = list(self.MODES_CVRATIO.keys())[self.cv_idx] + b2_text = "B2:CV:"+cv_ratio + elif b2_mode == 'STASIS': + stasis = list(self.MODES_STASIS.keys())[self.stasis_idx] + b2_text = "STASIS:"+stasis + elif b2_mode == 'AUTOMATA': + automata_name = self.current_automata.name + b2_text = automata_name + else: + b2_text = "B2:"+b2_mode + bw = len(b2_text)*8 + oled.fill_rect(W-bw, H-8, bw,8,0) + oled.text(b2_text, W-bw, H-8,1) + + @micropython.native + def _display_thread(self): + """Asynchronous display update thread. + + Handles: + - Display buffer swapping + - Settings overlay drawing + - OLED screen updates + - Display request processing + - Settings timeout management + + Runs continuously in background thread. + """ + while True: + if self._display_ready: + show = False + if self._display_request: + with self._buf_lock: + oled.blit(self.sim_frame, 0, 0) + self._display_request = False + show = True + if self._show_settings_req: + with self._buf_lock: + self.draw_settings() + show = True + if ticks_diff(ticks_ms(), self.settings_display_start) >= SETTING_DISPLAY_TIME: + self._show_settings_req = False + if show: + oled.show() + sleep_ms(0) + +if __name__ == "__main__": + Cellarium().main() \ No newline at end of file diff --git a/software/contrib/cellarium_docs/automata/brians_brain.py b/software/contrib/cellarium_docs/automata/brians_brain.py new file mode 100644 index 000000000..8fb7cc075 --- /dev/null +++ b/software/contrib/cellarium_docs/automata/brians_brain.py @@ -0,0 +1,304 @@ +# Copyright 2025 Allen Synthesis +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Implements Brian's Brain cellular automaton for complex wave patterns. + +Three-state cellular automaton with cyclic state transitions: +1. OFF cells (0) become ON (1) if exactly 2 neighbors are ON +2. ON cells (1) become DYING (2) in the next generation +3. DYING cells (2) become OFF (0) in the next generation + +Creates oscillating wave-like patterns using dual grid buffers for +state tracking. Named after Brian Silverman who discovered this rule. + +Outputs: +- CV1: Wave pattern complexity +- CV2: Current activation level +- CV3: Grid state entropy +- CV4: Pattern stability gate + +@author Grybbit (https://github.com/Bearwynn) +@year 2025 +""" + +# Standard library imports +import math +from random import randint + +# MicroPython imports +import micropython + +# EuroPi imports +from europi import * + +# Local imports +from contrib.cellarium_docs.automata_base import BaseAutomata, POPCOUNT, LOG2, EPSILON + +class BriansBrain(BaseAutomata): + """ + Brian's Brain cellular automaton implementation using bit-parallel optimization. + + Three-state cellular automaton with rules: + 1. OFF cells (0) become ON (1) if exactly 2 neighbors are ON + 2. ON cells (1) become DYING (2) in the next generation + 3. DYING cells (2) become OFF (0) in the next generation + + Creates complex, oscillating patterns with wave-like behavior. + Named after Brian Silverman, who discovered this rule. + """ + def __init__(self, width, height, current_food_chance, current_tick_limit): + super().__init__(width, height, current_food_chance, current_tick_limit) + self.name = "Brian's Brain" + self.stasis_max_pop_delta = 24 + self.min_stasis_pattern_length = 8 + self.max_stasis_pattern_length = 12 + + # Brian's Brain has 3 states: Off(0), On(1), Dying(2) + # We'll use two grids to represent the states + # firing_grid: cells that are "firing" (on) + # dying_grid: cells that are "dying" (refractory) + grid_size = (width * height + 7) // 8 + self.firing_grid = bytearray(grid_size) + self.dying_grid = bytearray(grid_size) + self.next_firing = bytearray(grid_size) + self.next_dying = bytearray(grid_size) + + @micropython.viper + def simulate_generation(self, sim_current, sim_next) -> int: + """Simulate one generation of Brian's Brain using hybrid bit-parallel optimization. + + Uses bit-parallel operations within bytes for efficient state tracking of the + three cell states (Off, On, Dying) using two separate bit grids: + - firing_grid: tracks cells that are currently "On" + - dying_grid: tracks cells that are in "Dying" state + + The simulation processes 8 cells at once using bit operations for neighbor counting + and state transitions. Neighbor gathering is done at byte level with appropriate + shifting for edge cases. + + Args: + sim_current: Current state buffer (display grid) + sim_next: Buffer for next generation state (display grid) + + Returns: + Packed 32-bit int containing births (lower 16 bits) and deaths (upper 16 bits) + """ + bpr = int(self.bytes_per_row) + h = int(self.height) + w = int(self.width) + + # Get pointers to all arrays + firing_ptr = ptr8(self.firing_grid) + dying_ptr = ptr8(self.dying_grid) + next_firing_ptr = ptr8(self.next_firing) + next_dying_ptr = ptr8(self.next_dying) + current_ptr = ptr8(sim_current) + next_ptr = ptr8(sim_next) + popcount_ptr = ptr8(POPCOUNT) + + total_born = total_died = 0 + + # Clear next generation arrays + grid_len = int(len(self.firing_grid)) + for i in range(grid_len): + next_firing_ptr[i] = next_dying_ptr[i] = 0 + + # Process each row with bit-parallel neighbor counting + for row in range(h): + row_offset = row * bpr + top_row_offset = ((row - 1) % h) * bpr + bottom_row_offset = ((row + 1) % h) * bpr + + # Process each byte in the row + for byte_idx in range(bpr): + current_byte_addr = row_offset + byte_idx + firing_byte = firing_ptr[current_byte_addr] + dying_byte = dying_ptr[current_byte_addr] + + # Calculate neighbor byte indices with wrap-around + left_byte_idx = (byte_idx - 1) % bpr + right_byte_idx = (byte_idx + 1) % bpr + + # Fetch all 9 neighbor bytes for bit-parallel processing + top_left = firing_ptr[top_row_offset + left_byte_idx] + top_mid = firing_ptr[top_row_offset + byte_idx] + top_right = firing_ptr[top_row_offset + right_byte_idx] + + mid_left = firing_ptr[row_offset + left_byte_idx] + mid_right = firing_ptr[row_offset + right_byte_idx] + + bot_left = firing_ptr[bottom_row_offset + left_byte_idx] + bot_mid = firing_ptr[bottom_row_offset + byte_idx] + bot_right = firing_ptr[bottom_row_offset + right_byte_idx] + + # Create bit-aligned neighbor masks (like in life.py) + top_left_mask = (top_mid << 1) | (top_left >> 7) + top_mid_mask = top_mid + top_right_mask = (top_mid >> 1) | (top_right << 7) + + left_mask = (firing_byte << 1) | (mid_left >> 7) + right_mask = (firing_byte >> 1) | (mid_right << 7) + + bot_left_mask = (bot_mid << 1) | (bot_left >> 7) + bot_mid_mask = bot_mid + bot_right_mask = (bot_mid >> 1) | (bot_right << 7) + + # Bit-parallel neighbor counting using binary addition (like life.py) + neighbor_sum_bit0 = 0 + neighbor_sum_bit1 = 0 + neighbor_sum_bit2 = 0 + + # Manually unroll neighbor mask addition for viper compatibility + # Process each of the 8 neighbor masks with inline half-adder chain + + # Mask 1: top_left_mask + carry_bit0 = neighbor_sum_bit0 & top_left_mask; neighbor_sum_bit0 ^= top_left_mask + carry_bit1 = neighbor_sum_bit1 & carry_bit0; neighbor_sum_bit1 ^= carry_bit0; neighbor_sum_bit2 ^= carry_bit1 + + # Mask 2: top_mid_mask + carry_bit0 = neighbor_sum_bit0 & top_mid_mask; neighbor_sum_bit0 ^= top_mid_mask + carry_bit1 = neighbor_sum_bit1 & carry_bit0; neighbor_sum_bit1 ^= carry_bit0; neighbor_sum_bit2 ^= carry_bit1 + + # Mask 3: top_right_mask + carry_bit0 = neighbor_sum_bit0 & top_right_mask; neighbor_sum_bit0 ^= top_right_mask + carry_bit1 = neighbor_sum_bit1 & carry_bit0; neighbor_sum_bit1 ^= carry_bit0; neighbor_sum_bit2 ^= carry_bit1 + + # Mask 4: left_mask + carry_bit0 = neighbor_sum_bit0 & left_mask; neighbor_sum_bit0 ^= left_mask + carry_bit1 = neighbor_sum_bit1 & carry_bit0; neighbor_sum_bit1 ^= carry_bit0; neighbor_sum_bit2 ^= carry_bit1 + + # Mask 5: right_mask + carry_bit0 = neighbor_sum_bit0 & right_mask; neighbor_sum_bit0 ^= right_mask + carry_bit1 = neighbor_sum_bit1 & carry_bit0; neighbor_sum_bit1 ^= carry_bit0; neighbor_sum_bit2 ^= carry_bit1 + + # Mask 6: bot_left_mask + carry_bit0 = neighbor_sum_bit0 & bot_left_mask; neighbor_sum_bit0 ^= bot_left_mask + carry_bit1 = neighbor_sum_bit1 & carry_bit0; neighbor_sum_bit1 ^= carry_bit0; neighbor_sum_bit2 ^= carry_bit1 + + # Mask 7: bot_mid_mask + carry_bit0 = neighbor_sum_bit0 & bot_mid_mask; neighbor_sum_bit0 ^= bot_mid_mask + carry_bit1 = neighbor_sum_bit1 & carry_bit0; neighbor_sum_bit1 ^= carry_bit0; neighbor_sum_bit2 ^= carry_bit1 + + # Mask 8: bot_right_mask + carry_bit0 = neighbor_sum_bit0 & bot_right_mask; neighbor_sum_bit0 ^= bot_right_mask + carry_bit1 = neighbor_sum_bit1 & carry_bit0; neighbor_sum_bit1 ^= carry_bit0; neighbor_sum_bit2 ^= carry_bit1 + + # Apply Brian's Brain rules bit-parallel + # Rule: Off cell with exactly 2 neighbors becomes firing + # Rule: Firing cell becomes dying + # Rule: Dying cell becomes off + + # Check for exactly 2 neighbors: bit2=0, bit1=1, bit0=0 + inv_bit2_mask = (~neighbor_sum_bit2) & 0xFF + exact_two_mask = inv_bit2_mask & neighbor_sum_bit1 & (~neighbor_sum_bit0 & 0xFF) + + # Current state masks - simplified + off_cells = (~firing_byte) & (~dying_byte) # Neither firing nor dying + + # Apply rules + new_firing_byte = off_cells & exact_two_mask # Off cells with 2 neighbors become firing + new_dying_byte = firing_byte # All firing cells become dying + + # Count births and deaths for statistics + birth_count = popcount_ptr[new_firing_byte] + death_count = popcount_ptr[firing_byte] # Firing cells that will die + + total_born += birth_count + total_died += death_count + + # Store results + next_firing_ptr[current_byte_addr] = new_firing_byte + next_dying_ptr[current_byte_addr] = new_dying_byte + next_ptr[current_byte_addr] = new_firing_byte # Display shows firing cells + + # Swap arrays efficiently + for i in range(grid_len): + # Swap firing grids + temp_firing = firing_ptr[i] + firing_ptr[i] = next_firing_ptr[i] + next_firing_ptr[i] = temp_firing + + # Swap dying grids + temp_dying = dying_ptr[i] + dying_ptr[i] = next_dying_ptr[i] + next_dying_ptr[i] = temp_dying + + return (total_born & 0xffff) | ((total_died & 0xffff) << 16) + + @micropython.native + def feed_rule(self, sim_current, sim_next, food_chance, num_alive): + """Add random firing cells based on food chance""" + from random import randint + if food_chance <= 0: return num_alive + + # Initialize variables separately for MicroPython compatibility + new_alive = num_alive + w = self.width + h = self.height + bpr = self.bytes_per_row + + # Reduce iterations for better performance + for _ in range(food_chance * 3): # Reduced from 5 to 3 + # Calculate random position + x = randint(0, w - 1) + y = randint(0, h - 1) + + # Calculate byte and bit positions + byte_idx = x >> 3 # Divide by 8 + bit_pos = x & 7 # Remainder of divide by 8 + + # Calculate grid index and bit mask + grid_idx = y * bpr + byte_idx + bit_mask = 1 << bit_pos + + # Check if position is empty (not firing AND not dying) + if not (self.firing_grid[grid_idx] & bit_mask) and not (self.dying_grid[grid_idx] & bit_mask): + self.firing_grid[grid_idx] |= bit_mask + sim_current[grid_idx] |= bit_mask + sim_next[grid_idx] |= bit_mask + new_alive += 1 + return new_alive + + @micropython.native + def reset(self): + """Reset all grids to empty state""" + super().reset() + for i in range(len(self.firing_grid)): + self.firing_grid[i] = self.dying_grid[i] = self.next_firing[i] = self.next_dying[i] = 0 + + @micropython.native + def cv1_out(self, c): + """Output firing cell density to CV1""" + # Use the main alive counter instead of recalculating + cv1.voltage(10 * c.num_alive / (self.width * self.height) if c.num_alive else 0) + + @micropython.native + def cv2_out(self, c): + """Output dying cell ratio to CV2""" + # Estimate dying cells as a ratio of recent deaths instead of counting + if c.num_alive > 0: + dying_ratio = min(c.num_died / max(c.num_alive, 1), 1.0) + cv2.voltage(10 * dying_ratio) + else: + cv2.voltage(0) + + @micropython.native + def cv3_out(self, c): + """Output birth rate to CV3""" + cv3.voltage(10 * min(c.num_born / 50.0, 1.0) if c.num_born else 0) + + @micropython.native + def cv4_out(self, c): + """Output activity gate to CV4""" + cv4.on() if c.num_born > 10 else cv4.off() \ No newline at end of file diff --git a/software/contrib/cellarium_docs/automata/droplets.py b/software/contrib/cellarium_docs/automata/droplets.py new file mode 100644 index 000000000..17f248ac9 --- /dev/null +++ b/software/contrib/cellarium_docs/automata/droplets.py @@ -0,0 +1,828 @@ +# Copyright 2025 Allen Synthesis +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Implements water droplet physics simulation using cellular automata rules. + +Features realistic water flow and interaction mechanics: +- Fluid dynamics with gravity and surface tension +- Procedurally generated terrain obstacles +- Multi-point water source spawning +- Animated flow visualization +- Configurable boundary conditions + +Outputs: +- CV1: Water volume level +- CV2: Flow velocity +- CV3: Terrain interaction +- CV4: Overflow detection gate + +Fully custom cellular automaton authored by: +@author Grybbit (https://github.com/Bearwynn) +@year 2025 +""" + +# Standard library imports +import math +from random import randint + +# MicroPython imports +import micropython + +# EuroPi imports +from europi import * + +# Local imports +from contrib.cellarium_docs.automata_base import BaseAutomata + +# Simulation constants +# Rock generation parameters +MIN_ROCK_COUNT = 1 +MAX_ROCK_COUNT = 20 +MIN_ROCK_RADIUS = 5 +MAX_ROCK_RADIUS = 16 +MIN_ROCK_HEIGHT = 3 +MAX_ROCK_HEIGHT = 6 + +# Water parameters +MAX_DROPLET_AMOUNT = 10000 +MAX_DROPLET_RESPAWN_PER_FRAME = 5 + +# Spawn area constants (as fractions of screen dimensions 0.0-1.0) +WATER_SPAWN_MIN_X = 0.0 # Left edge +WATER_SPAWN_MAX_X = 1.0 # Right edge +WATER_SPAWN_MIN_Y = 0.0 # Top edge +WATER_SPAWN_MAX_Y = 0.3 # Top 30% of screen + +BLOB_SPAWN_MIN_X = 0.0 # Left edge +BLOB_SPAWN_MAX_X = 1.0 # Right edge +BLOB_SPAWN_MIN_Y = 0.4 # 40% from top edge +BLOB_SPAWN_MAX_Y = 1.0 # Bottom edge + +# Visualization patterns +TERRAIN_PATTERNS = { + 'solid': 0xFFFF, # Full solid + 'dense': 0xFBDF, # Dense dithered + 'medium': 0xA5A5, # 50% checkerboard + 'light': 0x2408, # Light dithered + 'sparse': 0x1004 # Very sparse +} + +# Animated water flow patterns (4x4) +WATER_ANIMATION_PATTERNS = [ + 0xFFFF, # Frame 0: Solid (stationary) + 0xF7EF, # Frame 1: Dense flow 1 + 0xEFBF, # Frame 2: Dense flow 2 + 0xBFDF, # Frame 3: Dense flow 3 +] + +class Droplets(BaseAutomata): + """ + Water droplet simulation using cellular automaton rules. + + Features: + - Realistic water flow and pooling behavior + - Procedurally generated terrain obstacles + - Water source spawning system + - Optional flow animation patterns + - Configurable edge behavior + + Controls: + - disable_water_animation: Toggle water pattern animation + - bottom_edge_is_terrain: Make bottom edge solid or permeable + """ + def __init__(self, width, height, current_food_value, current_tick_limit): + super().__init__(width, height, current_food_value, current_tick_limit) + self.name = "Droplets" + self.use_stasis = False # Water simulation doesn't reach stasis + + # Separate buffers for water and terrain + grid_size = (width * height) // 8 + self.water_current = bytearray(grid_size) + self.water_next = bytearray(grid_size) + self.terrain_grid = bytearray(grid_size) # Solid terrain for collision detection + self.terrain_display = bytearray(grid_size) # Dithered terrain for display, water is actually only animated when we display to the sim_current buffer + + # Row-based bias directions (one direction per row) + # 0 = left bias (check left first), 1 = right bias (check right first) + self.row_bias_directions = bytearray(height) + + # Animation frame counter for water flow effects + self.animation_frame = 0 + + # Animation control option + self.disable_water_animation = True # Set to True to render raw droplets + + # Edge behavior control + self.bottom_edge_is_terrain = False # Set to True to make bottom edge act like terrain + + # CV output tracking variables (for viper compatibility, use standard operators) + self.droplets_moved_down = 0 # CV1: Number of droplets that moved down due to gravity + self.droplets_hit_bottom = 0 # CV2: Number of droplets that hit bottom edge + self.droplets_respawned = 0 # CV3: Number of new droplets added this tick + self.total_bias_samples = 0 # CV4: Total bias samples for average calculation + self.right_bias_count = 0 # CV4: Count of right-biased droplets + + # Running droplet count for performance (updated during simulation) + self.current_droplet_count = 0 # Total droplets currently in simulation + + # Calculate how many droplets we can add this frame + self.droplets_to_add = (self.current_food_value / 100 ) * MAX_DROPLET_RESPAWN_PER_FRAME + + # Row bias flip tracking + self.row_droplet_counts = bytearray(height) # Count of droplets per row + self.row_blocked_counts = bytearray(height) # Count of blocked droplets per row + + # Generate initial terrain + self._generate_terrain() + + # Initialize row bias directions with random values + self._initialize_random_row_bias() + + @micropython.native + def _initialize_random_row_bias(self): + """Initialize row bias directions with random values""" + for row in range(self.height): + # Generate random bias for each row: 0 = left bias, 1 = right bias + self.row_bias_directions[row] = randint(0, 1) + + @micropython.viper + def _check_and_flip_blocked_rows(self): + """Check if all droplets in a row are blocked in bias direction and flip bias if so""" + h = int(self.height) + + # Get pointers for fast array access + row_droplet_counts_ptr = ptr8(self.row_droplet_counts) + row_blocked_counts_ptr = ptr8(self.row_blocked_counts) + row_bias_directions_ptr = ptr8(self.row_bias_directions) + + row = int(0) + while row < h: + droplet_count = int(row_droplet_counts_ptr[row]) + blocked_count = int(row_blocked_counts_ptr[row]) + + # If there are droplets in this row and ALL of them are blocked in bias direction + if droplet_count > 0 and blocked_count == droplet_count: + # Flip the bias direction for this row + current_bias = int(row_bias_directions_ptr[row]) + row_bias_directions_ptr[row] = int(1 - current_bias) + + row = int(row + 1) + + @micropython.native + def _generate_terrain(self): + """Generate randomized terrain blobs of different sizes and irregular shapes""" + w, h, bpr = self.width, self.height, self.bytes_per_row + + # Clear terrain grids first + for i in range(len(self.terrain_grid)): + self.terrain_grid[i] = 0 + self.terrain_display[i] = 0 + + # Generate random terrain blobs + num_blobs = randint(MIN_ROCK_COUNT, MAX_ROCK_COUNT) + + for blob_idx in range(num_blobs): + # Random blob center using spawn area constants + min_x = int(w * BLOB_SPAWN_MIN_X) + max_x = int(w * BLOB_SPAWN_MAX_X) + min_y = int(h * BLOB_SPAWN_MIN_Y) + max_y = int(h * BLOB_SPAWN_MAX_Y) + + center_x = randint(min_x, max_x) + center_y = randint(min_y, max_y) + + # Random blob size + blob_radius = randint(MIN_ROCK_RADIUS, MAX_ROCK_RADIUS) + blob_height = randint(MIN_ROCK_HEIGHT, MAX_ROCK_HEIGHT) + + # Create irregular blob shape + for dy in range(-blob_height, blob_height + 1): + for dx in range(-blob_radius, blob_radius + 1): + x = center_x + dx + y = center_y + dy + + # Check bounds + if 0 <= x < w and 0 <= y < h: + # Create irregular shape using distance and random noise + distance_sq = dx * dx + dy * dy + max_distance_sq = blob_radius * blob_radius + + # Add randomness to make irregular blobs + noise = randint(-4, 4) + adjusted_distance_sq = distance_sq + noise * noise + + # Create gradient effect - denser in center, sparser at edges + if adjusted_distance_sq <= max_distance_sq: + # Calculate density based on distance from center + distance_ratio = adjusted_distance_sq / max_distance_sq + + # Higher chance for pixels closer to center + placement_chance = 1.0 - distance_ratio + + # Add some randomness to edges + random_factor = randint(0, 100) / 100.0 + + if random_factor < placement_chance: + byte_idx, bit_pos = x >> 3, x & 7 + grid_idx = y * bpr + byte_idx + bit_mask = 1 << bit_pos + + # Always set solid terrain for collision detection + self.terrain_grid[grid_idx] |= bit_mask + + # Set dithered terrain for display - inline pattern check + # Using 'dense' pattern: 0xFBDF + pattern_x = x & 3 + pattern_y = y & 3 + pattern_bit = pattern_y * 4 + pattern_x + if (0xFBDF >> pattern_bit) & 1: + self.terrain_display[grid_idx] |= bit_mask + + @micropython.viper + def simulate_generation(self, sim_current, sim_next) -> int: + """Fast byte-parallel water simulation with terrain collision and row-based bias""" + w = int(self.width) + h = int(self.height) + bpr = int(self.bytes_per_row) + grid_len = int(len(self.water_current)) + + # Get pointers for current and next states + water_curr_ptr = ptr8(self.water_current) + water_next_ptr = ptr8(self.water_next) + terrain_ptr = ptr8(self.terrain_grid) + row_bias_ptr = ptr8(self.row_bias_directions) + + # Cache frequently accessed instance variables as local variables for performance + total_moved = int(0) + droplets_moved_down = int(0) + droplets_hit_bottom = int(0) + droplets_respawned = int(0) + total_bias_samples = int(0) + right_bias_count = int(0) + bottom_edge_is_terrain = bool(self.bottom_edge_is_terrain) + current_droplet_count = int(0) # Count droplets during simulation + + # Pre-allocate local arrays for row tracking (faster than instance variables) + row_droplet_counts = ptr8(self.row_droplet_counts) + row_blocked_counts = ptr8(self.row_blocked_counts) + + # Reset row tracking arrays efficiently + for row in range(h): + row_droplet_counts[row] = 0 + row_blocked_counts[row] = 0 + + # Initialize next state by clearing it efficiently + i = int(0) + while i < grid_len: + water_next_ptr[i] = 0 + i = i + 1 + + # Process water flow from bottom to top (gravity) + row = int(h - 1) + while row >= 0: + row_offset = int(row * bpr) + row_bias = int(row_bias_ptr[row]) # Get bias direction for this row + + # Cache row-specific variables + row_droplet_count = int(0) + row_blocked_count = int(0) + + # Determine iteration order based on row bias + # If bias is left (0), iterate left to right (so left cells are processed first) + # If bias is right (1), iterate right to left (so right cells are processed first) + iterate_left_to_right = int(row_bias == 0) + + if iterate_left_to_right: + # Process bytes from left to right + byte_idx = int(0) + while byte_idx < bpr: + current_addr = int(row_offset + byte_idx) + water_byte = int(water_curr_ptr[current_addr]) + + if water_byte != 0: + # Process bits from left to right (0 to 7) + bit_pos = int(0) + while bit_pos < 8: + bit_mask = int(1 << bit_pos) + + if water_byte & bit_mask: + x = int((byte_idx << 3) + bit_pos) + if x < w: + moved = int(0) + blocked_in_bias_direction = int(0) + + # Count this droplet for the row (use local cache) + row_droplet_count = row_droplet_count + 1 + + # Count this droplet for total running count + current_droplet_count = current_droplet_count + 1 + + # Track bias for CV4 (use local cache) + total_bias_samples = total_bias_samples + 1 + if row_bias == 1: # Right bias + right_bias_count = right_bias_count + 1 + + # Try to move down first + can_move_down = int(0) + if row < h - 1: + down_row = int(row + 1) + down_addr = int(down_row * bpr + byte_idx) + down_terrain = int(terrain_ptr[down_addr]) + down_water_next = int(water_next_ptr[down_addr]) + + # Check if we can move down (no terrain AND no water in next state) + if not (down_terrain & bit_mask) and not (down_water_next & bit_mask): + can_move_down = int(1) + elif row == h - 1 and not bottom_edge_is_terrain: + # At bottom row with deletion mode - can move down (will be deleted later) + can_move_down = int(1) + + if can_move_down: + if row < h - 1: + down_row = int(row + 1) + down_addr = int(down_row * bpr + byte_idx) + # Double-check that position is still free before moving + current_water_at_target = int(water_next_ptr[down_addr]) + if not (current_water_at_target & bit_mask): + water_next_ptr[down_addr] = int(water_next_ptr[down_addr] | bit_mask) + moved = int(1) + total_moved = total_moved + 1 + droplets_moved_down = droplets_moved_down + 1 + else: + # Position was taken by another droplet, stay in place + can_move_down = int(0) + else: + # If row == h - 1, droplet will be deleted later by _handle_bottom_edge_deletion + moved = int(1) + total_moved = total_moved + 1 + droplets_moved_down = droplets_moved_down + 1 + + # If couldn't move down, try sideways based on row bias + if moved == 0: + # Determine direction based on row bias + if row_bias == 1: # Right bias + side_dir = int(1) # Right + else: # Left bias + side_dir = int(-1) # Left + + # Try to move in bias direction only + new_x = int(x + side_dir) + if 0 <= new_x and new_x < w: + new_byte_idx = int(new_x >> 3) + new_bit_pos = int(new_x & 7) + new_bit_mask = int(1 << new_bit_pos) + new_addr = int(row_offset + new_byte_idx) + + side_terrain = int(terrain_ptr[new_addr]) + side_water_next = int(water_next_ptr[new_addr]) + + # Check if target position is free AND not already reserved + if not (side_terrain & new_bit_mask) and not (side_water_next & new_bit_mask): + # Double-check that position is still free (atomic-like operation) + current_water_at_target = int(water_next_ptr[new_addr]) + if not (current_water_at_target & new_bit_mask): + water_next_ptr[new_addr] = int(water_next_ptr[new_addr] | new_bit_mask) + moved = int(1) + total_moved = total_moved + 1 + else: + # Blocked in bias direction + blocked_in_bias_direction = int(1) + else: + # Blocked in bias direction + blocked_in_bias_direction = int(1) + else: + # Can't move in bias direction (edge of screen) + blocked_in_bias_direction = int(1) + + # If couldn't move at all, stay in place + if moved == 0: + water_next_ptr[current_addr] = int(water_next_ptr[current_addr] | bit_mask) + + # Track if this droplet was blocked in bias direction (use local cache) + if blocked_in_bias_direction == 1: + row_blocked_count = row_blocked_count + 1 + + bit_pos = int(bit_pos + 1) + + byte_idx = int(byte_idx + 1) + else: + # Process bytes from right to left + byte_idx = int(bpr - 1) + while byte_idx >= 0: + current_addr = int(row_offset + byte_idx) + water_byte = int(water_curr_ptr[current_addr]) + + if water_byte != 0: + # Process bits from right to left (7 to 0) + bit_pos = int(7) + while bit_pos >= 0: + bit_mask = int(1 << bit_pos) + + if water_byte & bit_mask: + x = int((byte_idx << 3) + bit_pos) + if x < w: + moved = int(0) + blocked_in_bias_direction = int(0) + + # Count this droplet for the row (use local cache) + row_droplet_count = row_droplet_count + 1 + + # Count this droplet for total running count + current_droplet_count = current_droplet_count + 1 + + # Track bias for CV4 (use local cache) + total_bias_samples = total_bias_samples + 1 + if row_bias == 1: # Right bias + right_bias_count = right_bias_count + 1 + + # Try to move down first + can_move_down = int(0) + if row < h - 1: + down_row = int(row + 1) + down_addr = int(down_row * bpr + byte_idx) + down_terrain = int(terrain_ptr[down_addr]) + down_water_next = int(water_next_ptr[down_addr]) + + # Check if we can move down (no terrain AND no water in next state) + if not (down_terrain & bit_mask) and not (down_water_next & bit_mask): + can_move_down = int(1) + elif row == h - 1 and not bottom_edge_is_terrain: + # At bottom row with deletion mode - can move down (will be deleted later) + can_move_down = int(1) + + if can_move_down: + if row < h - 1: + down_row = int(row + 1) + down_addr = int(down_row * bpr + byte_idx) + # Double-check that position is still free before moving + current_water_at_target = int(water_next_ptr[down_addr]) + if not (current_water_at_target & bit_mask): + water_next_ptr[down_addr] = int(water_next_ptr[down_addr] | bit_mask) + moved = int(1) + total_moved = total_moved + 1 + droplets_moved_down = droplets_moved_down + 1 + else: + # Position was taken by another droplet, stay in place + can_move_down = int(0) + else: + # If row == h - 1, droplet will be deleted later by _handle_bottom_edge_deletion + moved = int(1) + total_moved = total_moved + 1 + droplets_moved_down = droplets_moved_down + 1 + + # If couldn't move down, try sideways based on row bias + if moved == 0: + # Determine direction based on row bias + if row_bias == 1: # Right bias + side_dir = int(1) # Right + else: # Left bias + side_dir = int(-1) # Left + + # Try to move in bias direction only + new_x = int(x + side_dir) + if 0 <= new_x and new_x < w: + new_byte_idx = int(new_x >> 3) + new_bit_pos = int(new_x & 7) + new_bit_mask = int(1 << new_bit_pos) + new_addr = int(row_offset + new_byte_idx) + + side_terrain = int(terrain_ptr[new_addr]) + side_water_next = int(water_next_ptr[new_addr]) + + # Check if target position is free AND not already reserved + if not (side_terrain & new_bit_mask) and not (side_water_next & new_bit_mask): + # Double-check that position is still free (atomic-like operation) + current_water_at_target = int(water_next_ptr[new_addr]) + if not (current_water_at_target & new_bit_mask): + water_next_ptr[new_addr] = int(water_next_ptr[new_addr] | new_bit_mask) + moved = int(1) + total_moved = total_moved + 1 + else: + # Blocked in bias direction + blocked_in_bias_direction = int(1) + else: + # Blocked in bias direction + blocked_in_bias_direction = int(1) + else: + # Can't move in bias direction (edge of screen) + blocked_in_bias_direction = int(1) + + # If couldn't move at all, stay in place + if moved == 0: + water_next_ptr[current_addr] = int(water_next_ptr[current_addr] | bit_mask) + + # Track if this droplet was blocked in bias direction (use local cache) + if blocked_in_bias_direction == 1: + row_blocked_count = row_blocked_count + 1 + + bit_pos = int(bit_pos - 1) + + byte_idx = int(byte_idx - 1) + + # Write cached row data back to arrays at end of row processing + row_droplet_counts[row] = row_droplet_count + row_blocked_counts[row] = row_blocked_count + + row = int(row - 1) + + # Swap arrays efficiently + for i in range(grid_len): + # Swap water grids + temp_water = water_curr_ptr[i] + water_curr_ptr[i] = water_next_ptr[i] + water_next_ptr[i] = temp_water + + # Write cached values back to instance variables + self.droplets_moved_down = droplets_moved_down + self.droplets_hit_bottom = droplets_hit_bottom + self.droplets_respawned = droplets_respawned + self.total_bias_samples = total_bias_samples + self.right_bias_count = right_bias_count + self.current_droplet_count = current_droplet_count + + # Increment animation frame + current_animation_frame = int(self.animation_frame) + self.animation_frame = int(current_animation_frame) + 1 + + # Handle deletion of droplets that reached the bottom edge + self._handle_bottom_edge_deletion() + + # Add new droplets each frame if needed + self._add_new_droplets() + + # Check for blocked rows and flip bias if needed + self._check_and_flip_blocked_rows() + + # Apply final display composition + self._apply_final_display(sim_current, sim_next) + + return int(total_moved) + + @micropython.viper + def _handle_bottom_edge_deletion(self): + """Delete droplets that have reached the bottom edge (if enabled)""" + # If bottom edge acts as terrain, don't delete droplets + if bool(self.bottom_edge_is_terrain): + return + + w = int(self.width) + h = int(self.height) + bpr = int(self.bytes_per_row) + bottom_row = int(h - 1) + bottom_row_offset = int(bottom_row * bpr) + + # Get pointer for fast access + water_current_ptr = ptr8(self.water_current) + + droplets_hit_bottom = int(self.droplets_hit_bottom) + current_droplet_count = int(self.current_droplet_count) + + # Scan bottom row for water droplets and delete them + byte_idx = int(0) + while byte_idx < bpr: + grid_idx = int(bottom_row_offset + byte_idx) + water_byte = int(water_current_ptr[grid_idx]) + + if water_byte != 0: + # Check each bit in the byte + bit_pos = int(0) + while bit_pos < 8: + bit_mask = int(1 << bit_pos) + if water_byte & bit_mask: + x = int((byte_idx << 3) + bit_pos) + if x < w: + # Remove droplet from bottom + water_current_ptr[grid_idx] &= ~bit_mask + + # Update running count + current_droplet_count = current_droplet_count - 1 + + # Track droplets that hit bottom edge (CV2) + droplets_hit_bottom = droplets_hit_bottom + 1 + bit_pos = bit_pos + 1 + byte_idx = byte_idx + 1 + + # Write back cached values + self.droplets_hit_bottom = droplets_hit_bottom + self.current_droplet_count = current_droplet_count + + @micropython.native + def _add_new_droplets(self): + """Add new droplets each frame based on constants and current count""" + if self.current_food_value <= 0: + return + + # Use cached droplet count instead of expensive bit counting + current_droplet_count = self.current_droplet_count + max_droplets = MAX_DROPLET_AMOUNT + + # If we already have more droplets than the max, don't add any + if current_droplet_count >= max_droplets: + return + + # Calculate how many droplets we can add this frame + # Make sure we don't exceed the max droplet count + available_space = max_droplets - current_droplet_count + self.droplets_to_add = (self.current_food_value / 100 ) * MAX_DROPLET_RESPAWN_PER_FRAME + droplets_to_add = min(self.droplets_to_add, available_space) + + # Cache frequently used values + w = self.width + h = self.height + bpr = self.bytes_per_row + droplets_added = 0 + + # Pre-calculate spawn area bounds + min_x = int(w * WATER_SPAWN_MIN_X) + max_x = int(w * WATER_SPAWN_MAX_X - 1) + min_y = int(h * WATER_SPAWN_MIN_Y) + max_y = int(h * WATER_SPAWN_MAX_Y) + + for droplet in range(droplets_to_add): + # Try to find a free position in spawn area + attempts = 0 + while attempts < 10: # Limit attempts to avoid infinite loop + x = randint(min_x, max_x) + y = randint(min_y, max_y) + + byte_idx = x >> 3 + bit_pos = x & 7 + bit_mask = 1 << bit_pos + grid_idx = y * bpr + byte_idx + + # Only add if no terrain and no water already there + if not (self.terrain_grid[grid_idx] & bit_mask) and not (self.water_current[grid_idx] & bit_mask): + self.water_current[grid_idx] |= bit_mask + droplets_added += 1 + # Update running count + self.current_droplet_count += 1 + break + attempts += 1 + + # Track successful spawns (CV3) + self.droplets_respawned = droplets_added + + @micropython.viper + def _apply_final_display(self, sim_current, sim_next): + """Combine terrain and water display buffers for final output""" + w = int(self.width) + h = int(self.height) + bpr = int(self.bytes_per_row) + + # Get pointers for fast array access + sim_current_ptr = ptr8(sim_current) + sim_next_ptr = ptr8(sim_next) + terrain_display_ptr = ptr8(self.terrain_display) + water_current_ptr = ptr8(self.water_current) + + grid_len = int(len(sim_current)) + disable_water_animation = bool(self.disable_water_animation) + animation_frame = int(self.animation_frame) + + # Clear display buffers first + i = int(0) + while i < grid_len: + sim_current_ptr[i] = 0 + sim_next_ptr[i] = 0 + i = i + 1 + + # Apply dithered terrain for display + i = int(0) + while i < grid_len: + sim_current_ptr[i] |= terrain_display_ptr[i] + i = i + 1 + + # Apply water display directly to sim_current (animated or raw) + if disable_water_animation: + # Raw droplet display - copy water_current directly to sim_current + i = int(0) + while i < grid_len: + sim_current_ptr[i] |= water_current_ptr[i] + i = i + 1 + else: + # Animated water display + frame = int(animation_frame & 3) # 0-3 cycle + + # Pre-calculate pattern for efficiency + if frame == 0: + pattern = int(0xFFFF) # WATER_ANIMATION_PATTERNS[0] + elif frame == 1: + pattern = int(0xF7EF) # WATER_ANIMATION_PATTERNS[1] + elif frame == 2: + pattern = int(0xEFBF) # WATER_ANIMATION_PATTERNS[2] + else: + pattern = int(0xBFDF) # WATER_ANIMATION_PATTERNS[3] + + y = int(0) + while y < h: + row_offset = int(y * bpr) + byte_idx = int(0) + while byte_idx < bpr: + grid_idx = int(row_offset + byte_idx) + water_byte = int(water_current_ptr[grid_idx]) + + if water_byte != 0: + # Process each bit in the byte + bit_pos = int(0) + while bit_pos < 8: + bit_mask = int(1 << bit_pos) + if water_byte & bit_mask: + x = int((byte_idx << 3) + bit_pos) + if x < w: + # Apply animated water pattern - inline pattern check + pattern_x = int(x & 3) + pattern_y = int(y & 3) + pattern_bit = int(pattern_y * 4 + pattern_x) + if (pattern >> pattern_bit) & 1: + sim_current_ptr[grid_idx] |= bit_mask + bit_pos = bit_pos + 1 + byte_idx = byte_idx + 1 + y = y + 1 + + # Copy to next buffer + i = int(0) + while i < grid_len: + sim_next_ptr[i] = sim_current_ptr[i] + i = i + 1 + + @micropython.native + def feed_rule(self, sim_current, sim_next, food_chance, num_alive): + """Store current food value for droplet spawning control""" + self._add_new_droplets() + # Return cached droplet count + return self.current_droplet_count + + @micropython.native + def reset(self): + """Reset water and generate new terrain every time""" + super().reset() + + # Clear water grids + for i in range(len(self.water_current)): + self.water_current[i] = 0 + self.water_next[i] = 0 + self.terrain_grid[i] = 0 + self.terrain_display[i] = 0 + + # Reset row bias directions + self.row_bias_directions = bytearray(self.height) + + # Reset running droplet count + self.current_droplet_count = 0 + + # Always generate new terrain on reset for variety + self._generate_terrain() + + # Initialize row bias directions with random values + self._initialize_random_row_bias() + + @micropython.native + def cv1_out(self, cellarium): + """Output number of droplets that moved down due to gravity (0-10V scale)""" + # Scale based on reasonable max expected (e.g., 50 droplets moving down per tick) + voltage = 0 + if MAX_DROPLET_AMOUNT > 0: + voltage = 10 * min(self.droplets_moved_down / MAX_DROPLET_AMOUNT, 1.0) + cv1.voltage(voltage) + + @micropython.native + def cv2_out(self, cellarium): + """Output number of droplets that hit bottom edge (0-10V scale) - only when deletion enabled""" + # Scale based on max possible (e.g., 200 droplets hitting bottom per tick) + voltage = 0 + if MAX_DROPLET_AMOUNT > 0: + voltage = 10 * min(self.droplets_hit_bottom / MAX_DROPLET_AMOUNT, 1.0) + cv2.voltage(voltage) + + @micropython.native + def cv3_out(self, cellarium): + """Output number of new droplets added this tick (0-10V scale)""" + # Scale based on reasonable max expected (e.g., 10 new droplets per tick) + voltage = 0 + if self.droplets_to_add > 0: + voltage = 10 * min(self.droplets_respawned / self.droplets_to_add, 1.0) + cv3.voltage(voltage) + + @micropython.native + def cv4_out(self, cellarium): + """Output average left/right bias (0V=all left, 5V=balanced, 10V=all right)""" + if self.total_bias_samples > 0: + # Calculate right bias ratio (0.0 = all left, 1.0 = all right) + right_bias_ratio = self.right_bias_count / self.total_bias_samples + # Scale to 0-10V where 5V is the midpoint (0.5 ratio) + voltage = 10 * right_bias_ratio + cv4.voltage(voltage) + else: + # No bias samples this tick, output midpoint (balanced) + cv4.voltage(5.0) \ No newline at end of file diff --git a/software/contrib/cellarium_docs/automata/langtons_ant.py b/software/contrib/cellarium_docs/automata/langtons_ant.py new file mode 100644 index 000000000..22cd6a02e --- /dev/null +++ b/software/contrib/cellarium_docs/automata/langtons_ant.py @@ -0,0 +1,219 @@ +# Copyright 2025 Allen Synthesis +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Implements multi-agent Langton's Ant cellular automaton. + +Each ant follows simple rules that create complex emergent patterns: +1. At white square: Turn right, flip color, move forward +2. At black square: Turn left, flip color, move forward + +Features: +- Multiple ants operating simultaneously +- Dynamic ant population control +- Spatial tracking and analysis +- Emergent pattern detection + +Outputs: +- CV1: Ant population ratio +- CV2: Average ant X position +- CV3: Average ant Y position +- CV4: Population activity gate + +@author Grybbit (https://github.com/Bearwynn) +@year 2025 +""" + +# Standard library imports +import math +from random import randint + +# MicroPython imports +import micropython + +# EuroPi imports +from europi import * + +# Local imports +from contrib.cellarium_docs.automata_base import BaseAutomata + +class LangtonsAnt(BaseAutomata): + """ + Langton's Ant cellular automaton implementation. + + A virtual ant moves on a grid following simple rules: + 1. At a white square, turn 90° right, flip the color, move forward + 2. At a black square, turn 90° left, flip the color, move forward + + This implementation supports multiple ants simultaneously. + Despite simple rules, creates complex emergent behavior and + eventually builds a recurring "highway" pattern. + """ + def __init__(self, width, height, current_food_chance, current_tick_limit): + super().__init__(width, height, current_food_chance, current_tick_limit) + self.name = "Langton's Ant" + self.use_stasis = False + + # Start with one ant in the center + self.ants = [] + self.max_ants = 10 + self.total_steps = 0 + + def reset(self): + super().reset() + self.ants.clear() + self.total_steps = 0 + self.feed_rule() + + @micropython.viper + def simulate_generation(self, sim_current, sim_next) -> int: + """Multi-Ant Langton's Ant: process all ants simultaneously""" + buf_len = int(len(sim_current)) + + # Initialize dimensions for MicroPython compatibility + bpr = int(self.bytes_per_row) + w = int(self.width) + h = int(self.height) + + # Get pointers to buffers + curr_ptr = ptr8(sim_current) + next_ptr = ptr8(sim_next) + + # Copy current state to next + for i in range(buf_len): + next_ptr[i] = curr_ptr[i] + + ants_list = self.ants + num_ants = int(len(ants_list)) + + # Process each ant + for ant_idx in range(num_ants): + ant = ants_list[ant_idx] + ant_x = int(ant[0]) + ant_y = int(ant[1]) + ant_dir = int(ant[2]) + + # Ensure ant is within bounds + if ant_x >= 0 and ant_x < w and ant_y >= 0 and ant_y < h: + # Get current cell state + byte_idx = int(ant_y * bpr + (ant_x >> 3)) + bit_pos = int(ant_x & 7) + + if byte_idx >= 0 and byte_idx < buf_len: + current_cell = int((curr_ptr[byte_idx] >> bit_pos) & 1) + + # Langton's Ant rules: + # At white square (0): turn right, flip to black, move forward + # At black square (1): turn left, flip to white, move forward + if current_cell == 0: # White square + ant_dir = (ant_dir + 1) % 4 # Turn right + next_ptr[byte_idx] |= (1 << bit_pos) # Flip to black + else: # Black square + ant_dir = (ant_dir + 3) % 4 # Turn left + next_ptr[byte_idx] &= ~(1 << bit_pos) # Flip to white + + # Move forward in current direction + if ant_dir == 0: # North + ant_y = ant_y - 1 + if ant_y < 0: ant_y = h - 1 + elif ant_dir == 1: # East + ant_x = ant_x + 1 + if ant_x >= w: ant_x = 0 + elif ant_dir == 2: # South + ant_y = ant_y + 1 + if ant_y >= h: ant_y = 0 + elif ant_dir == 3: # West + ant_x = ant_x - 1 + if ant_x < 0: ant_x = w - 1 + + # Update ant position + ant[0] = ant_x + ant[1] = ant_y + ant[2] = ant_dir + + self.total_steps = int(self.total_steps) + 1 + return int(self.total_steps) + + @micropython.native + def feed_rule(self, sim_current, sim_next, food_chance, num_alive): + """Spawn additional ants based on food_chance""" + from random import randint + if food_chance <= 0 or len(self.ants) >= self.max_ants: + return num_alive + + self.ants.clear() + + # Spawn new ants based on food_chance + # Higher food_chance = more likely to spawn ants + ant_spawn_amount = min(food_chance, self.max_ants) + + for ant_idx in range(ant_spawn_amount): + # Spawn ant at random location with random direction + new_x = randint(0, self.width - 1) + new_y = randint(0, self.height - 1) + new_dir = randint(0, 3) + self.ants.append([new_x, new_y, new_dir]) + + return ant_spawn_amount + + @micropython.native + def cv1_out(self, c): + """CV1 output: Ant population ratio. + + Higher voltage = more ants currently active. + 0V = no ants, 10V = maximum number of ants (self.max_ants). + Scales linearly with ant count. + """ + from europi import cv1 + cv1.voltage(10 * min(len(self.ants) / self.max_ants, 1.0)) + + @micropython.native + def cv2_out(self, c): + """CV2 output: Average ant X position. + + Higher voltage = ants concentrated on right side. + Lower voltage = ants concentrated on left side. + 0V = leftmost edge, 10V = rightmost edge. + """ + from europi import cv2 + if self.ants: + avg_x = sum(ant[0] for ant in self.ants) / len(self.ants) + cv2.voltage(10 * (avg_x / self.width)) + else: + cv2.voltage(0) + + @micropython.native + def cv3_out(self, c): + """CV3 output: Average ant Y position. + + Higher voltage = ants concentrated at bottom. + Lower voltage = ants concentrated at top. + 0V = top edge, 10V = bottom edge. + """ + from europi import cv3 + if self.ants: + avg_y = sum(ant[1] for ant in self.ants) / len(self.ants) + cv3.voltage(10 * (avg_y / self.height)) + else: + cv3.voltage(0) + + @micropython.native + def cv4_out(self, c): + """CV4 output: Simulation progress. + + Higher voltage = more steps completed. + Ramps up to 10V over 1000 steps, then stays at 10V. + Useful for tracking long-term behavior development. + """ + from europi import cv4 + cv4.voltage(10 * min(self.total_steps / 1000, 1.0)) \ No newline at end of file diff --git a/software/contrib/cellarium_docs/automata/life.py b/software/contrib/cellarium_docs/automata/life.py new file mode 100644 index 000000000..8d3367227 --- /dev/null +++ b/software/contrib/cellarium_docs/automata/life.py @@ -0,0 +1,252 @@ +# Copyright 2025 Allen Synthesis +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Implements Conway's Game of Life as a dynamic grid evolution automaton. + +Classic rules: +1. Any live cell with 2-3 neighbors survives +2. Any dead cell with 3 neighbors becomes alive +3. All other cells die or stay dead + +Outputs 4 voltage signals: +- CV1: Entropy measurement of current state +- CV2: Birth rate relative to population +- CV3: Death rate relative to population +- CV4: Population density gate + +@author Grybbit (https://github.com/Bearwynn) +@year 2025 +""" + +# Standard library imports +import math +from random import randint + +# MicroPython imports +import micropython + +# EuroPi imports +from europi import * + +# Local imports +from contrib.cellarium_docs.automata_base import BaseAutomata, LOG2, EPSILON, POPCOUNT + +class Life(BaseAutomata): + """ + Implementation of Conway's Game of Life cellular automaton. + + Classic rules: + 1. Any live cell with 2 or 3 live neighbors survives + 2. Any dead cell with exactly 3 live neighbors becomes alive + 3. All other cells die or stay dead + """ + + def __init__(self, width, height, current_food_value, current_tick_limit): + super().__init__(width, height, current_food_value, current_tick_limit) + self.name = "Life" + self.stasis_max_pop_delta = 16 + self.min_stasis_pattern_length = 2 + self.max_stasis_pattern_length = 6 + + @micropython.viper + def simulate_generation(self, sim_current, sim_next) -> int: + """Simulate one generation of the Game of Life using hybrid bit-parallel optimization. + + Uses bit-parallel operations within bytes for neighbor counting and rule application, + processing 8 cells simultaneously. Neighbor gathering is done at byte level with + appropriate shifting for edge cases. + + Args: + sim_current: Current state buffer + sim_next: Buffer for next generation state + + Returns: + Packed 32-bit int containing births (lower 16 bits) and deaths (upper 16 bits) + """ + # Viper core generation loop + bpr = int(self.bytes_per_row) # 16 for 128px wide (128/8 = 16 + height = int(self.height) # 32 + width = int(self.width) # 128 + + current_field_ptr = ptr8(sim_current) + next_field_ptr = ptr8(sim_next) + popcount_table_ptr = ptr8(POPCOUNT) + + total_born = 0 + total_died = 0 + + # Iterate rows (0-31) + for row_index in range(height): + row_byte_offset = int(row_index * bpr) + + # Calculate neighbor row offsets with wrap-around + top_row_byte_offset = ((row_index - 1) % height) * bpr + bottom_row_byte_offset = ((row_index + 1) % height) * bpr + + # Process each byte in row (0-15) + for byte_index_in_row in range(bpr): + current_byte_addr = row_byte_offset + byte_index_in_row + current_row_byte = current_field_ptr[current_byte_addr] + + # Calculate neighbor byte addresses with wrap-around + left_byte_idx = (byte_index_in_row - 1) % bpr + right_byte_idx = (byte_index_in_row + 1) % bpr + + # Fetch neighbor bytes + top_left = current_field_ptr[top_row_byte_offset + left_byte_idx] + top_mid = current_field_ptr[top_row_byte_offset + byte_index_in_row] + top_right = current_field_ptr[top_row_byte_offset + right_byte_idx] + + mid_left = current_field_ptr[row_byte_offset + left_byte_idx] + mid_right = current_field_ptr[row_byte_offset + right_byte_idx] + + bot_left = current_field_ptr[bottom_row_byte_offset + left_byte_idx] + bot_mid = current_field_ptr[bottom_row_byte_offset + byte_index_in_row] + bot_right = current_field_ptr[bottom_row_byte_offset + right_byte_idx] + + # Top neighbors - shift to align with current byte + top_left_mask = (top_mid << 1) | (top_left >> 7) # Carry from left byte + top_mid_mask = top_mid + top_right_mask = (top_mid >> 1) | (top_right << 7) # Carry from right byte + + # Horizontal neighbors + left_mask = (current_row_byte << 1) | (mid_left >> 7) # Left neighbor + right_mask = (current_row_byte >> 1) | (mid_right << 7) # Right neighbor + + # Bottom neighbors + bot_left_mask = (bot_mid << 1) | (bot_left >> 7) + bot_mid_mask = bot_mid + bot_right_mask = (bot_mid >> 1) | (bot_right << 7) + + # Rest of your bit-parallel logic remains the same... + neighbor_mask_1 = top_left_mask + neighbor_mask_2 = top_mid_mask + neighbor_mask_3 = top_right_mask + neighbor_mask_4 = left_mask + neighbor_mask_5 = right_mask + neighbor_mask_6 = bot_left_mask + neighbor_mask_7 = bot_mid_mask + neighbor_mask_8 = bot_right_mask + + # Your existing bit-sliced accumulator code... + neighbor_sum_bit0 = 0 + neighbor_sum_bit1 = 0 + neighbor_sum_bit2 = 0 + + # Inline bitwise half-add chain (keep your existing code) + carry_bit0 = neighbor_sum_bit0 & neighbor_mask_1; neighbor_sum_bit0 ^= neighbor_mask_1 + carry_bit1 = neighbor_sum_bit1 & carry_bit0; neighbor_sum_bit1 ^= carry_bit0; neighbor_sum_bit2 ^= carry_bit1 + carry_bit0 = neighbor_sum_bit0 & neighbor_mask_2; neighbor_sum_bit0 ^= neighbor_mask_2 + carry_bit1 = neighbor_sum_bit1 & carry_bit0; neighbor_sum_bit1 ^= carry_bit0; neighbor_sum_bit2 ^= carry_bit1 + carry_bit0 = neighbor_sum_bit0 & neighbor_mask_3; neighbor_sum_bit0 ^= neighbor_mask_3 + carry_bit1 = neighbor_sum_bit1 & carry_bit0; neighbor_sum_bit1 ^= carry_bit0; neighbor_sum_bit2 ^= carry_bit1 + carry_bit0 = neighbor_sum_bit0 & neighbor_mask_4; neighbor_sum_bit0 ^= neighbor_mask_4 + carry_bit1 = neighbor_sum_bit1 & carry_bit0; neighbor_sum_bit1 ^= carry_bit0; neighbor_sum_bit2 ^= carry_bit1 + carry_bit0 = neighbor_sum_bit0 & neighbor_mask_5; neighbor_sum_bit0 ^= neighbor_mask_5 + carry_bit1 = neighbor_sum_bit1 & carry_bit0; neighbor_sum_bit1 ^= carry_bit0; neighbor_sum_bit2 ^= carry_bit1 + carry_bit0 = neighbor_sum_bit0 & neighbor_mask_6; neighbor_sum_bit0 ^= neighbor_mask_6 + carry_bit1 = neighbor_sum_bit1 & carry_bit0; neighbor_sum_bit1 ^= carry_bit0; neighbor_sum_bit2 ^= carry_bit1 + carry_bit0 = neighbor_sum_bit0 & neighbor_mask_7; neighbor_sum_bit0 ^= neighbor_mask_7 + carry_bit1 = neighbor_sum_bit1 & carry_bit0; neighbor_sum_bit1 ^= carry_bit0; neighbor_sum_bit2 ^= carry_bit1 + carry_bit0 = neighbor_sum_bit0 & neighbor_mask_8; neighbor_sum_bit0 ^= neighbor_mask_8 + carry_bit1 = neighbor_sum_bit1 & carry_bit0; neighbor_sum_bit1 ^= carry_bit0; neighbor_sum_bit2 ^= carry_bit1 + + # Count bitplanes and apply Game of Life rules + inv_bit2_mask = (~neighbor_sum_bit2) & 0xFF + exact_two_mask = inv_bit2_mask & neighbor_sum_bit1 & (~neighbor_sum_bit0 & 0xFF) + exact_three_mask = inv_bit2_mask & neighbor_sum_bit1 & neighbor_sum_bit0 + + alive_mask = current_row_byte + survive_mask = alive_mask & (exact_two_mask | exact_three_mask) + birth_mask = (~alive_mask & 0xFF) & exact_three_mask + death_mask = alive_mask & (~survive_mask & 0xFF) + + # Write next generation + next_field_ptr[current_byte_addr] = survive_mask | birth_mask + + # Update counters + birth_count = popcount_table_ptr[birth_mask] + death_count = popcount_table_ptr[death_mask] + + total_born += birth_count + total_died += death_count + + return ( (total_born & 0xffff) | (( total_died & 0xffff) << 16) ) + + @micropython.native + def feed_rule(self, sim_current, sim_next, food_chance, num_alive): + food_chance = max(0, self.current_food_value / 10) #we don't want 100% chance if we can help it, so take the food value and divide it by some amount + if food_chance <= 0: + return num_alive + new_alive = num_alive + for i in range(len(sim_current)): + b = sim_current[i] + for bit in (1, 2, 4, 8, 16, 32, 64, 128): + if randint(0,100) < food_chance: + if not (b & bit): + new_alive += 1 + b |= bit + sim_current[i] = b + sim_next[i] = b + return new_alive + + @micropython.native + def reset(self): + return True + + @micropython.native + def cv1_out(self, c): + """CV1 output: Entropy of the current generation. + + Higher voltage = more disorder/randomness in the pattern. + """ + from europi import cv1 + cv1.voltage(10 * self.calculate_entropy(c.sim_current, c.num_alive)) + + @micropython.native + def cv2_out(self, c): + """CV2 output: Birth rate relative to population. + + Higher voltage = more cells being born relative to total population. + """ + from europi import cv2 + cv2.voltage(10 * c.num_born / c.num_alive) if c.num_alive > 0 else cv2.off() + + @micropython.native + def cv3_out(self, c): + """CV3 output: Population density. + + Higher voltage = more of the grid is occupied by living cells. + """ + from europi import cv3 + cv3.voltage(10 * c.num_alive / (c.width * c.height)) + + @micropython.native + def cv4_out(self, c): + """CV4 output: Population growth gate. + + HIGH when population is growing, LOW when shrinking. + """ + from europi import cv4 + cv4.on() if c.num_born > c.num_died else cv4.off() + + @micropython.native + def calculate_entropy(self, sim_current, num_alive): + total, alive = len(sim_current) * 8, num_alive + if alive == 0 or alive == total: return 0.0 + p = alive / total + q = 1.0 - p + if p < EPSILON: p = EPSILON + if q < EPSILON: q = EPSILON + return -(p * math.log(p) + q * math.log(q)) / LOG2 \ No newline at end of file diff --git a/software/contrib/cellarium_docs/automata/rule30.py b/software/contrib/cellarium_docs/automata/rule30.py new file mode 100644 index 000000000..ec2f58698 --- /dev/null +++ b/software/contrib/cellarium_docs/automata/rule30.py @@ -0,0 +1,200 @@ +# Copyright 2025 Allen Synthesis +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Implements Rule 30 elementary cellular automaton for evolving patterns. + +Performs cell state updates based on the classic Rule 30 formula: +next_state = left XOR (center OR right) + +Each new line is calculated from the states of the cells in the line above +creating complex, chaotic patterns from simple rules. + +Outputs: +- CV1: Global population density +- CV2: Current line activity +- CV3: Bottom row entropy +- CV4: Pattern complexity gate + +@author Grybbit (https://github.com/Bearwynn) +@year 2025 +""" + +# Standard library imports +import math +from random import randint + +# MicroPython imports +import micropython + +# EuroPi imports +from europi import * + +# Local imports +from contrib.cellarium_docs.automata_base import BaseAutomata, LOG2, EPSILON, POPCOUNT + +class Rule30(BaseAutomata): + """ + Rule 30 elementary cellular automaton implementation. + Rule 30 name comes from Wolfram's classification system. + + A simple 1D cellular automaton that creates complex patterns from simple rules: + - For each cell, look at itself and its two neighbors (left, center, right) + - New state = XOR of left neighbor with (OR of center and right neighbor) + - Pattern scrolls upward, with new cells generated at bottom + """ + def __init__(self, width, height, current_food_chance, current_tick_limit): + super().__init__(width, height, current_food_chance, current_tick_limit) + self.name = "Rule 30" + self.stasis_max_pop_delta = 8 + self.max_stasis_pattern_length = 2 + + @micropython.viper + def simulate_generation(self, sim_current, sim_next) -> int: + """Simulate one generation of Rule 30 using scrolling with efficient bit operations. + + Implements Wolfram's Rule 30 using a combination of techniques: + 1. Vertical scrolling: Previous states move up one row each generation + 2. Bottom row generation: Uses Rule 30 formula (left XOR (center OR right)) + 3. Bit-level processing: Each byte is processed bit by bit for precise rule application + + The implementation preserves the chaotic nature of Rule 30 while maintaining + optimal performance through careful bit manipulation and state management. + + Args: + sim_current: Current state buffer + sim_next: Buffer for next generation state + + Returns: + Packed 32-bit int containing births (lower 16 bits) and deaths (upper 16 bits) + """ + bpr = int(self.bytes_per_row) + h = int(self.height) + + # Get pointers to arrays + curr_ptr = ptr8(sim_current) + next_ptr = ptr8(sim_next) + pop_ptr = ptr8(POPCOUNT) + + total_born = 0 + total_died = 0 + + # clear next buffer first + for i in range(bpr * h): + next_ptr[i] = 0 + + # Shift all lines up by one (skip if top row) + if h > 1: + for row in range(h - 1): + src = (row + 1) * bpr + tgt = row * bpr + for i in range(bpr): + next_ptr[tgt + i] = curr_ptr[src + i] + + # Generate new bottom line using Rule 30 + src = tgt = (h - 1) * bpr + for byte_idx in range(bpr): + # Calculate indices with wrapping + left_idx = (byte_idx - 1) % bpr + right_idx = (byte_idx + 1) % bpr + + # Get cell states + left_b = curr_ptr[src + left_idx] + center_b = curr_ptr[src + byte_idx] + right_b = curr_ptr[src + right_idx] + + # Initialize current and new states + old_b = curr_ptr[tgt + byte_idx] + new_b = 0 + + for bit_pos in range(8): + left_bit = (left_b >> 7) & 1 if bit_pos == 0 else (center_b >> (bit_pos - 1)) & 1 + center_bit = (center_b >> bit_pos) & 1 + right_bit = right_b & 1 if bit_pos == 7 else (center_b >> (bit_pos + 1)) & 1 + # Rule 30: XOR of left and (center OR right) + new_b |= ((left_bit ^ (center_bit | right_bit)) << bit_pos) + + next_ptr[tgt + byte_idx] = new_b + + # Calculate births and deaths separately for MicroPython + births = new_b & (~old_b) + deaths = old_b & (~new_b) + total_born += pop_ptr[births] + total_died += pop_ptr[deaths] + + return (total_born & 0xffff) | ((total_died & 0xffff) << 16) + + @micropython.native + def feed_rule(self, sim_current, sim_next, food_chance, num_alive): + from random import randint + if food_chance <= 0: return num_alive + + # Add food to bottom row + new_alive = num_alive + bottom_start = (self.height - 1) * self.bytes_per_row + for i in range(bottom_start, bottom_start + self.bytes_per_row): + b = sim_current[i] + for bit in (1, 2, 4, 8, 16, 32, 64, 128): + if randint(0, 100) < food_chance and not (b & bit): + new_alive += 1 + b |= bit + sim_current[i] = sim_next[i] = b + return new_alive + + @micropython.native + def cv1_out(self, c): + """CV1 output: Global population density. + + Higher voltage = more cells are alive across entire grid. + """ + from europi import cv1 + cv1.voltage(10 * c.num_alive / (c.width * c.height) if c.num_alive else 0) + + @micropython.native + def cv2_out(self, c): + """CV2 output: Bottom row activity. + + Higher voltage = more cells are alive in the currently generating row. + """ + from europi import cv2 + start = (c.height - 1) * self.bytes_per_row + alive = sum(bin(c.sim_current[i]).count('1') for i in range(start, start + self.bytes_per_row)) + cv2.voltage(10 * alive / (self.bytes_per_row * 8)) + + @micropython.native + def cv3_out(self, c): + """CV3 output: Bottom row entropy. + + Higher voltage = more randomness/chaos in the current generation row. + 0V = completely uniform (all on or all off). + """ + from europi import cv3 + start = (c.height - 1) * self.bytes_per_row + alive = sum(bin(c.sim_current[i]).count('1') for i in range(start, start + self.bytes_per_row)) + total = self.bytes_per_row * 8 + if not alive or alive == total: + cv3.voltage(0) + else: + p = alive / total + entropy = -(p * math.log(p) + (1-p) * math.log(1-p)) / LOG2 + cv3.voltage(10 * entropy) + + @micropython.native + def cv4_out(self, c): + """CV4 output: Activity gate. + + HIGH when any cells changed state (born or died) in last generation. + LOW when no changes occurred. + """ + from europi import cv4 + cv4.on() if c.num_born or c.num_died else cv4.off() \ No newline at end of file diff --git a/software/contrib/cellarium_docs/automata/rule90.py b/software/contrib/cellarium_docs/automata/rule90.py new file mode 100644 index 000000000..2f038da5f --- /dev/null +++ b/software/contrib/cellarium_docs/automata/rule90.py @@ -0,0 +1,219 @@ +# Copyright 2025 Allen Synthesis +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Implements Rule 90 elementary cellular automaton for Sierpinski patterns. + +Performs cell state updates using Rule 90 XOR operation: +next_state = left XOR right + +Creates self-similar fractal patterns by scrolling previous states +upward and generating new cells at the bottom edge. + +Outputs: +- CV1: Global population density +- CV2: Pattern symmetry level +- CV3: Bottom row complexity +- CV4: Edge activity gate + +@author Grybbit (https://github.com/Bearwynn) +@year 2025 +""" + +# Standard library imports +import math +from random import randint + +# MicroPython imports +import micropython + +# EuroPi imports +from europi import * + +# Local imports +from contrib.cellarium_docs.automata_base import BaseAutomata, LOG2, EPSILON, POPCOUNT + +class Rule90(BaseAutomata): + """ + Rule 90 elementary cellular automaton implementation. + Rule 90 name comes from Wolfram's classification system. + + A simple 1D cellular automaton that creates Sierpinski triangle-like patterns: + - For each cell, look at its left and right neighbors + - New state = XOR of left and right neighbors + - Pattern scrolls upward, with new cells generated at bottom + """ + def __init__(self, width, height, current_food_chance, current_tick_limit): + super().__init__(width, height, current_food_chance, current_tick_limit) + self.name = "Rule 90" + + def reset(self): + super().reset() + self.current_line = 0 + + @micropython.viper + def simulate_generation(self, sim_current, sim_next) -> int: + """Simulate one generation of Rule 90 using scrolling with symmetrical bit operations. + + Implements Wolfram's Rule 90 with focus on symmetrical pattern generation: + 1. Vertical scrolling: Previous states move up one row each generation + 2. Bottom row generation: Uses Rule 90 formula (left XOR right) + 3. Bit-level symmetry: Each cell's state depends equally on left and right neighbors + + The implementation is optimized to maintain the perfect symmetry that creates + Sierpinski-like triangular patterns while ensuring efficient execution. + + Args: + sim_current: Current state buffer + sim_next: Buffer for next generation state + + Returns: + Packed 32-bit int containing births (lower 16 bits) and deaths (upper 16 bits) + """ + bpr = int(self.bytes_per_row) + h = int(self.height) + + # Get pointers to arrays + curr_ptr = ptr8(sim_current) + next_ptr = ptr8(sim_next) + pop_ptr = ptr8(POPCOUNT) + + total_born = 0 + total_died = 0 + + # clear next buffer first + for i in range(bpr * h): + next_ptr[i] = 0 + + # Shift all lines up by one + for row in range(h - 1): + src = (row + 1) * bpr + tgt = row * bpr + for i in range(bpr): + next_ptr[tgt + i] = curr_ptr[src + i] + + # Generate new bottom line + src = (h - 1) * bpr + tgt = (h - 1) * bpr + for byte_idx in range(bpr): + # Split assignments for MicroPython compatibility + left_idx = (byte_idx - 1) % bpr + right_idx = (byte_idx + 1) % bpr + + # Get cell states + left_b = curr_ptr[src + left_idx] + center_b = curr_ptr[src + byte_idx] + right_b = curr_ptr[src + right_idx] + + # Initialize current and new states + old_b = curr_ptr[tgt + byte_idx] + new_b = 0 + + for bit_pos in range(8): + left_bit = (left_b >> 7) & 1 if bit_pos == 0 else (center_b >> (bit_pos - 1)) & 1 + right_bit = right_b & 1 if bit_pos == 7 else (center_b >> (bit_pos + 1)) & 1 + new_b |= ((left_bit ^ right_bit) << bit_pos) + + next_ptr[tgt + byte_idx] = new_b + + # Calculate births and deaths separately for MicroPython + births = new_b & (~old_b) + deaths = old_b & (~new_b) + total_born += pop_ptr[births] + total_died += pop_ptr[deaths] + + return (total_born & 0xffff) | ((total_died & 0xffff) << 16) + + @micropython.native + def feed_rule(self, sim_current, sim_next, food_chance, num_alive): + """Feed bottom line only""" + from random import randint + if food_chance <= 0: return num_alive + + # Initialize variables separately for MicroPython + new_alive = num_alive + bpr = self.bytes_per_row + start = (self.height - 1) * bpr + + for i in range(start, start + bpr): + b = sim_current[i] + for bit in (1, 2, 4, 8, 16, 32, 64, 128): + if randint(0, 100) < food_chance and not (b & bit): + new_alive += 1 + b |= bit + sim_current[i] = sim_next[i] = b + return new_alive + + @micropython.native + def cv1_out(self, c): + """CV1 output: Global population density. + + Higher voltage = more cells alive relative to grid size. + 0V = empty grid, 10V = all cells alive. + """ + from europi import cv1 + cv1.voltage(10 * c.num_alive / (c.width * c.height) if c.num_alive else 0) + + @micropython.native + def cv2_out(self, c): + """CV2 output: Current line symmetry. + + Higher voltage = more symmetrical bottom row pattern. + 10V = perfect mirror symmetry, 0V = no symmetry. + Compares each bit with its mirror position in the bottom row. + """ + from europi import cv2 + # Initialize variables separately for MicroPython + bpr = self.bytes_per_row + start = (c.height - 1) * self.bytes_per_row + score = 0 + total = 0 + for i in range(bpr // 2): + # Get left and right bytes separately for MicroPython + l_b = c.sim_current[start + i] + r_b = c.sim_current[start + bpr - 1 - i] + for bit in range(8): + if ((l_b >> bit) & 1) == ((r_b >> (7 - bit)) & 1): score += 1 + total += 1 + cv2.voltage(10 * score / total if total else 0) + + @micropython.native + def cv3_out(self, c): + """CV3 output: Current line entropy. + + Higher voltage = more randomness/disorder in bottom row. + 0V = uniform (all cells same state), 10V = maximum disorder. + Uses information entropy calculation on bottom row pattern. + """ + from europi import cv3 + # Initialize variables separately for MicroPython + bpr = self.bytes_per_row + start = (c.height - 1) * self.bytes_per_row + alive = sum(bin(c.sim_current[i]).count('1') for i in range(start, start + bpr)) + total = bpr * 8 + if not alive or alive == total: + cv3.voltage(0) + else: + p = alive / total + entropy = -(p * math.log(p) + (1-p) * math.log(1-p)) / LOG2 + cv3.voltage(10 * entropy) + + @micropython.native + def cv4_out(self, c): + """CV4 output: Activity gate. + + HIGH when any cells changed state in last generation. + LOW when pattern is stable (no changes). + """ + from europi import cv4 + cv4.on() if c.num_born or c.num_died else cv4.off() \ No newline at end of file diff --git a/software/contrib/cellarium_docs/automata/seeds.py b/software/contrib/cellarium_docs/automata/seeds.py new file mode 100644 index 000000000..125a37799 --- /dev/null +++ b/software/contrib/cellarium_docs/automata/seeds.py @@ -0,0 +1,255 @@ +# Copyright 2025 Allen Synthesis +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Implements Seeds cellular automaton for generating chaotic patterns. + +Uses simple birth/death rules to create dynamic, never-settling patterns: +1. A dead cell becomes alive with exactly two neighbors +2. All living cells die in the next generation +3. No survival conditions exist + +Outputs: +- CV1: Global population density +- CV2: Birth rate activity +- CV3: Total state changes +- CV4: High birth rate gate + +@author Grybbit (https://github.com/Bearwynn) +@year 2025 +""" + +# Standard library imports +import math +from random import randint + +# MicroPython imports +import micropython + +# EuroPi imports +from europi import * + +# Local imports +from contrib.cellarium_docs.automata_base import BaseAutomata, LOG2, EPSILON, POPCOUNT + +class Seeds(BaseAutomata): + """ + Seeds cellular automaton implementation. + + Rules: + 1. A dead cell becomes alive if it has exactly two live neighbors + 2. All living cells die in the next generation + + Creates chaotic, rapidly changing patterns that rarely stabilize. + Similar to Conway's Game of Life but without stable patterns. + """ + def __init__(self, width, height, current_food_chance, current_tick_limit): + super().__init__(width, height, current_food_chance, current_tick_limit) + self.name = "Seeds" + # Seeds creates TV static-like patterns that rarely reach true stasis + # Only consider it stasis if completely dead or truly minimal change + self.use_stasis = False + + @micropython.viper + def simulate_generation(self, sim_current, sim_next) -> int: + """Simulate one generation of Seeds using hybrid bit-parallel optimization. + + Uses bit-parallel operations for both neighbor counting and state transitions: + 1. Dead cells are checked for exactly 2 neighbors using bit masking + 2. All living cells die in one step (simple clear operation) + 3. New births are processed 8 cells at a time within each byte + + The simulation is optimized for the Seeds ruleset where all living cells + die each generation, allowing for simplified state tracking and transitions. + + Args: + sim_current: Current state buffer + sim_next: Buffer for next generation state + + Returns: + Packed 32-bit int containing births (lower 16 bits) and deaths (upper 16 bits) + """ + bytes_per_row = int(self.bytes_per_row) + last_byte_index = int(self.last_byte_index) + height = int(self.height) + width = int(self.width) + + # Get pointers to arrays + current_field_ptr = ptr8(sim_current) + next_field_ptr = ptr8(sim_next) + popcount_table_ptr = ptr8(POPCOUNT) + + # Initialize counters for MicroPython compatibility + total_born = 0 + total_died = 0 + + # Clear next generation array + grid_len = bytes_per_row * height + for i in range(grid_len): + next_field_ptr[i] = 0 + + # Iterate rows (0-31) + for row_index in range(height): + row_byte_offset = int(row_index * bytes_per_row) + + # Calculate neighbor row offsets with wrap-around + top_row_byte_offset = ((row_index - 1) % height) * bytes_per_row + bottom_row_byte_offset = ((row_index + 1) % height) * bytes_per_row + + # Process each byte in row (0-15) + for byte_index_in_row in range(bytes_per_row): + current_byte_addr = row_byte_offset + byte_index_in_row + current_row_byte = current_field_ptr[current_byte_addr] + + # All living cells die in Seeds rule (all current cells contribute to death count) + total_died = total_died + int(current_row_byte) + + # Calculate neighbor byte addresses with wrap-around + left_byte_idx = (byte_index_in_row - 1) % bytes_per_row + right_byte_idx = (byte_index_in_row + 1) % bytes_per_row + + # Fetch neighbor bytes + top_left = int(current_field_ptr[top_row_byte_offset + left_byte_idx]) + top_mid = int(current_field_ptr[top_row_byte_offset + byte_index_in_row]) + top_right = int(current_field_ptr[top_row_byte_offset + right_byte_idx]) + + mid_left = int(current_field_ptr[row_byte_offset + left_byte_idx]) + mid_right = int(current_field_ptr[row_byte_offset + right_byte_idx]) + + bot_left = int(current_field_ptr[bottom_row_byte_offset + left_byte_idx]) + bot_mid = int(current_field_ptr[bottom_row_byte_offset + byte_index_in_row]) + bot_right = int(current_field_ptr[bottom_row_byte_offset + right_byte_idx]) + + # Top neighbors - shift to align with current byte + top_left_mask = (top_mid << 1) | (top_left >> 7) # Carry from left byte + top_mid_mask = top_mid + top_right_mask = (top_mid >> 1) | (top_right << 7) # Carry from right byte + + # Horizontal neighbors + left_mask = (current_row_byte << 1) | (mid_left >> 7) # Left neighbor + right_mask = (current_row_byte >> 1) | (mid_right << 7) # Right neighbor + + # Bottom neighbors + bot_left_mask = (bot_mid << 1) | (bot_left >> 7) + bot_mid_mask = bot_mid + bot_right_mask = (bot_mid >> 1) | (bot_right << 7) + + neighbor_mask_1 = top_left_mask + neighbor_mask_2 = top_mid_mask + neighbor_mask_3 = top_right_mask + neighbor_mask_4 = left_mask + neighbor_mask_5 = right_mask + neighbor_mask_6 = bot_left_mask + neighbor_mask_7 = bot_mid_mask + neighbor_mask_8 = bot_right_mask + + # bit-sliced accumulator code + neighbor_sum_bit0 = 0 + neighbor_sum_bit1 = 0 + neighbor_sum_bit2 = 0 + + # Inline bitwise half-add chain + carry_bit0 = neighbor_sum_bit0 & neighbor_mask_1; neighbor_sum_bit0 ^= neighbor_mask_1 + carry_bit1 = neighbor_sum_bit1 & carry_bit0; neighbor_sum_bit1 ^= carry_bit0; neighbor_sum_bit2 ^= carry_bit1 + carry_bit0 = neighbor_sum_bit0 & neighbor_mask_2; neighbor_sum_bit0 ^= neighbor_mask_2 + carry_bit1 = neighbor_sum_bit1 & carry_bit0; neighbor_sum_bit1 ^= carry_bit0; neighbor_sum_bit2 ^= carry_bit1 + carry_bit0 = neighbor_sum_bit0 & neighbor_mask_3; neighbor_sum_bit0 ^= neighbor_mask_3 + carry_bit1 = neighbor_sum_bit1 & carry_bit0; neighbor_sum_bit1 ^= carry_bit0; neighbor_sum_bit2 ^= carry_bit1 + carry_bit0 = neighbor_sum_bit0 & neighbor_mask_4; neighbor_sum_bit0 ^= neighbor_mask_4 + carry_bit1 = neighbor_sum_bit1 & carry_bit0; neighbor_sum_bit1 ^= carry_bit0; neighbor_sum_bit2 ^= carry_bit1 + carry_bit0 = neighbor_sum_bit0 & neighbor_mask_5; neighbor_sum_bit0 ^= neighbor_mask_5 + carry_bit1 = neighbor_sum_bit1 & carry_bit0; neighbor_sum_bit1 ^= carry_bit0; neighbor_sum_bit2 ^= carry_bit1 + carry_bit0 = neighbor_sum_bit0 & neighbor_mask_6; neighbor_sum_bit0 ^= neighbor_mask_6 + carry_bit1 = neighbor_sum_bit1 & carry_bit0; neighbor_sum_bit1 ^= carry_bit0; neighbor_sum_bit2 ^= carry_bit1 + carry_bit0 = neighbor_sum_bit0 & neighbor_mask_7; neighbor_sum_bit0 ^= neighbor_mask_7 + carry_bit1 = neighbor_sum_bit1 & carry_bit0; neighbor_sum_bit1 ^= carry_bit0; neighbor_sum_bit2 ^= carry_bit1 + carry_bit0 = neighbor_sum_bit0 & neighbor_mask_8; neighbor_sum_bit0 ^= neighbor_mask_8 + carry_bit1 = neighbor_sum_bit1 & carry_bit0; neighbor_sum_bit1 ^= carry_bit0; neighbor_sum_bit2 ^= carry_bit1 + + # Apply Seeds rules bit-parallel + # Rule: Dead cell with exactly 2 neighbors becomes alive + # Rule: All living cells die + + # Check for exactly 2 neighbors: bit2=0, bit1=1, bit0=0 + inv_bit2_mask = (~neighbor_sum_bit2) & 0xFF + exact_two_mask = inv_bit2_mask & neighbor_sum_bit1 & (~neighbor_sum_bit0 & 0xFF) + + # Current state mask: only dead cells can be born + dead_cells = (~current_row_byte) & 0xFF + + # Apply Seeds rule: dead cells with exactly 2 neighbors become alive + new_byte = dead_cells & exact_two_mask + + # Count births for statistics + birth_count = popcount_table_ptr[new_byte] + total_born += birth_count + + # Store result + next_field_ptr[current_byte_addr] = new_byte + + return (total_born & 0xffff) | ((total_died & 0xffff) << 16) + + @micropython.native + def feed_rule(self, sim_current, sim_next, food_chance, num_alive): + from random import randint + if food_chance <= 0: return num_alive + + new_alive = num_alive + for i in range(len(sim_current)): + b = sim_current[i] + for bit in (1, 2, 4, 8, 16, 32, 64, 128): + if randint(0, 200) < food_chance and not (b & bit): + new_alive += 1 + b |= bit + sim_current[i] = sim_next[i] = b + return new_alive + + @micropython.native + def cv1_out(self, c): + """CV1 output: Global population density. + + Higher voltage = more cells alive relative to grid size. + 0V = empty grid, 10V = all cells alive. + """ + from europi import cv1 + cv1.voltage(10 * c.num_alive / (c.width * c.height) if c.num_alive else 0) + + @micropython.native + def cv2_out(self, c): + """CV2 output: Birth rate. + + Higher voltage = more cells born in last generation. + Scales with number of births, maxing at 100 births. + """ + from europi import cv2 + cv2.voltage(10 * min(c.num_born / 100.0, 1.0) if c.num_born else 0) + + @micropython.native + def cv3_out(self, c): + """CV3 output: Total activity level. + + Higher voltage = more total state changes (births + deaths). + Scales with total changes, maxing at 200 changes. + """ + from europi import cv3 + cv3.voltage(10 * min((c.num_born + c.num_died) / 200.0, 1.0) if c.num_born + c.num_died else 0) + + @micropython.native + def cv4_out(self, c): + """CV4 output: High birth rate gate. + + HIGH when more than 50 cells born in last generation. + LOW when fewer births occurred. + """ + from europi import cv4 + cv4.on() if c.num_born > 50 else cv4.off() diff --git a/software/contrib/cellarium_docs/automata/simple_automaton.py b/software/contrib/cellarium_docs/automata/simple_automaton.py new file mode 100644 index 000000000..40f332b2b --- /dev/null +++ b/software/contrib/cellarium_docs/automata/simple_automaton.py @@ -0,0 +1,75 @@ +import micropython +from europi import cv1 # Only import what we need +from contrib.cellarium_docs.automata_base import BaseAutomata + +class SimpleAutomaton(BaseAutomata): + def __init__(self, width, height, current_food_value, current_tick_limit): + super().__init__(width, height, current_food_value, current_tick_limit) + self.name = "Simple" + self.population = 0 + + def simulate_generation(self, sim_current, sim_next): + births = 0 + deaths = 0 + + # Calculate and verify buffer size + expected_buffer_size = (self.width * self.height) // 8 + actual_buffer_size = len(sim_next) + if actual_buffer_size != expected_buffer_size: + print(f"Buffer size mismatch: expected {expected_buffer_size}, got {actual_buffer_size}") + # Use the smaller size to prevent index errors + buffer_size = min(expected_buffer_size, actual_buffer_size) + else: + buffer_size = actual_buffer_size + + # Clear next grid + for i in range(buffer_size): + sim_next[i] = 0 + + # Keep track of which tick we're on (alternating between 0 and 1) + self.tick = getattr(self, 'tick', 0) + self.tick = 1 - self.tick # Flip between 0 and 1 + + # Process the grid one byte at a time + for y in range(self.height): + for byte_x in range(self.bytes_per_row): + byte_index = y * self.bytes_per_row + byte_x + + # Skip if we're beyond buffer size + if byte_index >= buffer_size: + continue + + # Handle each bit in the current byte + for bit in range(8): + # Calculate actual x position + x = byte_x * 8 + bit + + # Skip if beyond actual width + if x >= self.width: + continue + + bit_mask = 1 << bit + is_alive = bool(sim_current[byte_index] & bit_mask) + + # Create checkerboard pattern that alternates each tick + should_be_alive = ((x + y) % 2) == self.tick + + if should_be_alive and not is_alive: + sim_next[byte_index] |= bit_mask + births += 1 + elif not should_be_alive and is_alive: + deaths += 1 + + # Pack births and deaths into return value as required by framework + return int((births & 0xffff) | ((deaths & 0xffff) << 16)) + + @micropython.native + def feed_rule(self, sim_current, sim_next, food_chance, num_alive) -> int: + self.population = num_alive + return num_alive + + @micropython.native + def cv1_out(self, cellarium): + # Output population as voltage + voltage = (self.population / (self.width * self.height)) * 10 + cv1.voltage(voltage) diff --git a/software/contrib/cellarium_docs/automata_base.py b/software/contrib/cellarium_docs/automata_base.py new file mode 100644 index 000000000..d4bb7fc86 --- /dev/null +++ b/software/contrib/cellarium_docs/automata_base.py @@ -0,0 +1,124 @@ +from europi import * +from europi_script import EuroPiScript +import math + +# Common re-used constants +POPCOUNT = bytes([bin(i).count("1") for i in range(256)]) +LOG2 = math.log(2) +EPSILON = 1e-10 + +class BaseAutomata(EuroPiScript): + def __init__(self, width, height, current_food_value, current_tick_limit): + self.width = width + self.height = height + self.name = "Base Automaton" + self.bytes_per_row = width // 8 + self.last_byte_index = self.bytes_per_row - 1 + self.current_food_value = current_food_value + self.current_tick_limit = current_tick_limit + self.stasis_max_pop_delta = 12 + self.min_stasis_pattern_length = 2 + self.max_stasis_pattern_length = 4 + self.use_stasis = True + + @micropython.native + def update_input_data(self, current_food_value, current_tick_limit): + self.current_food_value = current_food_value + self.current_tick_limit = current_tick_limit + + @micropython.viper + def simulate_generation(self, sim_current, sim_next) -> int: + """Override to implement automata simulation logic. + Returns packed integer: (births & 0xffff) | ((deaths & 0xffff) << 16)""" + total_born = int(0) + total_died = int(1) + return ( (total_born & 0xffff) | (( total_died & 0xffff) << 16) ) + + @micropython.native + def feed_rule(self, sim_current, sim_next, food_chance, num_alive): + """Override for feeding logic. Returns new num_alive count.""" + return num_alive + + @micropython.native + def reset(self): + """Override for reset logic.""" + reset = True + return reset + + @micropython.native + def stasis_rule(self, MAX_POP_DELTA, pop_deltas, num_born, num_died, num_alive=0): + """Enhanced stasis detection with configurable pattern length. + + Detects repeating patterns in population deltas over a configurable number of generations. + Uses self.stasis_pattern_length to determine how many generations to check for repetition. + """ + if self.use_stasis is False: + return False + + in_stasis = False + pop_delta_count = len(pop_deltas) + + # Use instance variables, but fall back to parameter for compatibility + max_deltas = self.stasis_max_pop_delta + min_pattern_length = self.min_stasis_pattern_length + max_pattern_length = self.max_stasis_pattern_length + + # Ensure pattern length doesn't exceed available data + pattern_length = min(pattern_length, max_deltas // 2, pop_delta_count // 2) + + # Need at least twice the pattern length for comparison + if pop_delta_count < max_pattern_length * 2: + return False + + # Check for complete stasis (no changes) + if num_born == 0 and num_died == 0: + return True + + # Check for repeating pattern + if max_pattern_length >= 2: + recent = pop_deltas[-pattern_length * 2:] + + # Compare first half with second half of the pattern + pattern_matches = True + for pattern_length in range(min_pattern_length, max_pattern_length): + if recent[i] != recent[i + pattern_length]: + pattern_matches = False + break + + if pattern_matches: + # Additional check: ensure the pattern represents minimal change + # Sum of absolute changes in the pattern should be small + total_pattern_change = sum(abs(recent[i]) for i in range(pattern_length)) + avg_change_per_generation = total_pattern_change / pattern_length if pattern_length > 0 else 0 + + # Consider it stasis if average change per generation is very small + if avg_change_per_generation <= 1.5: # Configurable threshold + in_stasis = True + + return in_stasis + + def cv1_out(self, cellarium): + """Override for CV1 output logic""" + pass + + def cv2_out(self, cellarium): + """Override for CV2 output logic""" + pass + + def cv3_out(self, cellarium): + """Override for CV3 output logic""" + pass + + def cv4_out(self, cellarium): + """Override for CV4 output logic""" + pass + + #CV5 is gate triggered if in stasis + #def cv5_out(self, cellarium): + # """Override for CV5 output logic""" + # pass + + #CV6 is gate triggered for simulation time + #def cv6_out(self, cellarium): + # """Override for CV6 output logic""" + # pass \ No newline at end of file diff --git a/software/contrib/cellarium_docs/automata_registry.py b/software/contrib/cellarium_docs/automata_registry.py new file mode 100644 index 000000000..e25c36235 --- /dev/null +++ b/software/contrib/cellarium_docs/automata_registry.py @@ -0,0 +1,67 @@ +# Copyright 2025 Allen Synthesis +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from collections import OrderedDict +from europi_log import log_warning + +"""Registry system for cellular automata implementations in Cellarium. + +Provides a central registration and lookup system for all available +cellular automata simulations. Supports: +- Indexing by automaton name or numeric ID +- Dynamic registration of new automaton types +- Name list generation for menus +- Default automaton fallback + +@author Grybbit (https://github.com/Bearwynn) +@year 2025 +""" + +# Dictionary mapping display names to fully qualified class paths +CELLARIUM_AUTOMATA = OrderedDict([ + ["Life", "contrib.cellarium_docs.automata.life.Life"], + ["Brian's Brain", "contrib.cellarium_docs.automata.brians_brain.BriansBrain"], + ["Droplets", "contrib.cellarium_docs.automata.droplets.Droplets"], + ["Seeds", "contrib.cellarium_docs.automata.seeds.Seeds"], + ["Langton's Ant", "contrib.cellarium_docs.automata.langtons_ant.LangtonsAnt"], + ["Rule 30", "contrib.cellarium_docs.automata.rule30.Rule30"], + ["Rule 90", "contrib.cellarium_docs.automata.rule90.Rule90"], +]) + +def get_class_for_name(automaton_class_name: str) -> type: + """Get the automaton class by its fully qualified name. + + Args: + automaton_class_name: Fully qualified class name (e.g. "contrib.cellarium_docs.automata.life.Life") + + Returns: + The automaton class if found and valid, None otherwise + """ + try: + module, clazz = automaton_class_name.rsplit(".", 1) + return getattr(__import__(module, None, None, [None]), clazz) + except Exception as e: + log_warning(f"Warning: Invalid automaton class name: {automaton_class_name}\n caused by: {e}", "cellarium") + return None + +def get_automata_by_index(index): + """Get automaton class by index""" + if 0 <= index < len(CELLARIUM_AUTOMATA): + class_path = list(CELLARIUM_AUTOMATA.values())[index] + return get_class_for_name(class_path) + return get_class_for_name(list(CELLARIUM_AUTOMATA.values())[0]) # Default to first automaton + +def get_automata_names(): + """Get list of automaton display names""" + return list(CELLARIUM_AUTOMATA.keys()) \ No newline at end of file