diff --git a/software/contrib/conway.py b/software/contrib/conway.py index da71861ef..ebfabb053 100644 --- a/software/contrib/conway.py +++ b/software/contrib/conway.py @@ -25,16 +25,8 @@ from experimental.bitarray import * from random import random as rnd -import micropython - import math -import array - -# @micropython.native -# Docs: https://docs.micropython.org/en/v1.9.3/pyboard/reference/speed_python.html#the-native-code-emitter - -# @micropython.viper -# Docs: https://docs.micropython.org/en/v1.9.3/pyboard/reference/speed_python.html#the-viper-code-emitter +import micropython # We re-use this constant a lot, so just save it for easy re-use LOG2 = math.log(2) @@ -42,11 +34,6 @@ # How many pixels are on the screen NUM_PIXELS = OLED_HEIGHT * OLED_WIDTH -# Pre-calculate these for performance -OLED_WIDTH_BYTES = OLED_WIDTH // 8 -if OLED_WIDTH % 8: - OLED_WIDTH_BYTES += 1 - @micropython.native def stdev(l): @@ -97,15 +84,20 @@ def __init__(self): # For ease of blitting, store the field as a bit array # Each byte is 8 horizontally adjacent pixels, with the most significant bit # on the left - self.field = bytearray(OLED_HEIGHT * OLED_WIDTH_BYTES) - self.next_field = bytearray(OLED_HEIGHT * OLED_WIDTH_BYTES) + self.field = bytearray(NUM_PIXELS // 8) + self.next_field = bytearray(NUM_PIXELS // 8) # Keep 2 separate frame buffer instances so we don't need to recreate the FB objects when we draw self.frame = FrameBuffer(self.field, OLED_WIDTH, OLED_HEIGHT, MONO_HLSB) self.next_frame = FrameBuffer(self.next_field, OLED_WIDTH, OLED_HEIGHT, MONO_HLSB) - # Use 1D array for summed area table for better cache performance - self.field_sum = array.array('H', [0] * (OLED_HEIGHT * OLED_WIDTH)) + # summed area table + # used to quickly count the neighbours within a region + self.field_sum = [] + for _ in range(OLED_HEIGHT): + self.field_sum.append([]) + for _ in range(OLED_WIDTH): + self.field_sum[-1].append(0) # how many cells were born this tick? self.num_born = 0 @@ -120,6 +112,10 @@ def __init__(self): # Set to True if we want to clear the field & respawn self.reset_requested = False + # statically allocated array of the indices of the cells around the one we're considering + # this is just an optimization to avoid re-allocating this array + self.neighbourhood = [0, 0, 0, 0, 0, 0, 0, 0] + # keep the last few changes in population in a list to check if it's oscillating predictably self.population_deltas = [] self.MAX_DELTAS = 12 @@ -127,11 +123,6 @@ def __init__(self): # statically allocated array we use to store the sums of cells when checking for statis self.statis_sums = [0] * self.MAX_DELTAS - # Pre-calculate neighbor offsets for faster access - self.neighbor_offsets = [-OLED_WIDTH-1, -OLED_WIDTH, -OLED_WIDTH+1, - -1, 1, - OLED_WIDTH-1, OLED_WIDTH, OLED_WIDTH+1] - @b1.handler def on_b1(): self.reset_requested = True @@ -144,7 +135,6 @@ def on_b2(): def on_din(): self.reset_requested = True - @micropython.native def calculate_spawn_level(self): """Calculate what percentage of the field should contain new cells @@ -165,15 +155,14 @@ def calculate_spawn_level(self): spawn_level = clamp(base_spawn_level + cv_mod, 0, 1) return spawn_level - @micropython.native def reset(self): """Clear the whole field and spawn random data in it """ - # Use experimental bitarray approach for faster clearing - set_all_bits(self.next_field, 0) + for i in range(len(self.field)): + self.next_field[i] = 0x00 self.num_alive = 0 - self.population_deltas.clear() + self.population_deltas = [] # fill the field with random cells fill_level = self.calculate_spawn_level() @@ -194,70 +183,54 @@ def reset(self): # Assume the whole field has changed self.num_changes = NUM_PIXELS - @micropython.viper + @micropython.native def update_field_sums(self): - """Recalculate the summed area table using optimized viper code - """ - field_sum_ptr = ptr16(self.field_sum) - field_ptr = ptr8(self.field) - width = int(OLED_WIDTH) - height = int(OLED_WIDTH) - - # First row and column need special handling - # First pixel - bit_val = 1 if (field_ptr[0] & 0x80) else 0 - field_sum_ptr[0] = bit_val - - # First row - for j in range(1, width): - byte_idx = j >> 3 - bit_pos = 7 - (j & 7) - bit_val = 1 if (field_ptr[byte_idx] & (1 << bit_pos)) else 0 - field_sum_ptr[j] = field_sum_ptr[j-1] + bit_val - - # First column - for i in range(1, height): - idx = i * width - byte_idx = idx >> 3 - bit_pos = 7 - (idx & 7) - bit_val = 1 if (field_ptr[byte_idx] & (1 << bit_pos)) else 0 - field_sum_ptr[idx] = field_sum_ptr[idx - width] + bit_val - - # Rest of the matrix - for i in range(1, height): - for j in range(1, width): - idx = i * width + j - byte_idx = idx >> 3 - bit_pos = 7 - (idx & 7) - bit_val = 1 if (field_ptr[byte_idx] & (1 << bit_pos)) else 0 - field_sum_ptr[idx] = (bit_val + - field_sum_ptr[idx - 1] + - field_sum_ptr[idx - width] - - field_sum_ptr[idx - width - 1]) - - @micropython.viper - def sum_cells(self, start_row: int, start_col: int, end_row: int, end_col: int) -> int: - """Get the sum of all cells in a given sub-matrix using optimized viper code + """Recalculate the summed area table """ - field_sum_ptr = ptr16(self.field_sum) - width = int(OLED_WIDTH) + for i in range(OLED_HEIGHT): + for j in range(OLED_WIDTH): + if i == 0 or j == 0: + a = 0 + else: + a = self.field_sum[i-1][j-1] + + if i == 0: + b = 0 + else: + b = self.field_sum[i-1][j] + + if j == 0: + c = 0 + else: + c = self.field_sum[i][j-1] + + self.field_sum[i][j] = get_bit(self.field, i * OLED_WIDTH + j) + b + c - a + @micropython.native + def sum_cells(self, start_row, start_col, end_row, end_col): + """Get the sum of all cells in a given sub-matrix + A B C + D E F + G H I + If we're getting the sum of [[E F] [H I]] we return + I - C - G + A + """ if start_row == 0 or start_col == 0: a = 0 else: - a = field_sum_ptr[(start_row - 1) * width + (start_col - 1)] + a = self.field_sum[start_row - 1][start_col - 1] if start_row == 0: c = 0 else: - c = field_sum_ptr[(start_row - 1) * width + end_col] + c = self.field_sum[start_row - 1][end_col] if start_col == 0: g = 0 else: - g = field_sum_ptr[end_row * width + (start_col - 1)] + g = self.field_sum[end_row][start_col - 1] - i = field_sum_ptr[end_row * width + end_col] + i = self.field_sum[end_row][end_col] return i - c - g + a @@ -287,24 +260,17 @@ def tick(self): # iterate through every cell, calculating generational changes self.update_field_sums() - - # Use local variables for faster access - field = self.field - next_field = self.next_field - width = OLED_WIDTH - height = OLED_HEIGHT - - for i in range(height): + for i in range(OLED_HEIGHT): top = max(0, i-1) - bottom = min(height-1, i+1) + bottom = min(OLED_HEIGHT-1, i+1) - for j in range(width): + for j in range(OLED_WIDTH): left = max(0, j-1) - right = min(width-1, j+1) + right = min(OLED_WIDTH-1, j+1) - bit_index = i * width + j + bit_index = i * OLED_WIDTH + j - cell_present = get_bit(field, bit_index) + cell_present = get_bit(self.field, bit_index) num_neighbours = self.sum_cells(top, left, bottom, right) if cell_present: @@ -312,20 +278,22 @@ def tick(self): if cell_present: if num_neighbours == 2 or num_neighbours == 3: # happy cell, stays alive - set_bit(next_field, bit_index, True) + set_bit(self.next_field, bit_index, True) else: # sad cell, dies - set_bit(next_field, bit_index, False) + set_bit(self.next_field, bit_index, False) self.num_died += 1 self.num_alive -= 1 + self.num_changes += 1 else: if num_neighbours == 3: # baby cell is born! - set_bit(next_field, bit_index, True) + set_bit(self.next_field, bit_index, True) self.num_alive += 1 self.num_born += 1 + self.num_changes += 1 else: # empty space remains empty - set_bit(next_field, bit_index, False) + set_bit(self.next_field, bit_index, False) # swap field & next_field so we don't need to copy between arrays tmp = self.next_field @@ -335,7 +303,7 @@ def tick(self): # swap frame and next_frame for rendering tmp = self.next_frame self.next_frame = self.frame - self.frame + self.frame = tmp @micropython.native def check_for_stasis(self): @@ -349,7 +317,7 @@ def check_for_stasis(self): if self.num_changes == 0 or self.num_alive == 0: return True - # if the population is oscillating up and down predicatably, we've probably reached stasis + # if the population is oscillating up and down predictably, we've probably reached stasis # check for 2, 3, and 4 step repetitions for pattern_length in range(2, 5): count = self.MAX_DELTAS // pattern_length