Learning Python with Advent of Code Walkthroughs

Dazbo's Advent of Code solutions, written in Python

CRT Display

Advent of Code 2022 - Day 10

Day 10: Cathode-Ray Tube

Useful Links

Concepts and Packages Demonstrated

ClassMatplotliblist comprehensionIntrospectionzip

Problem Intro

Not too bad, this one. But it still took me a couple of hours. I think my brain wasn’t working properly this morning.

We’re told we have a We have a CRT CPU that processes instructions. It has a single register, x. There is only one instruction that modifies x, called addx. The other instruction, noop, does nothing.

The input is our instructions, and it looks something like this:

addx 15
addx -11
addx 6
addx -3
addx 5
addx -1
addx -8
addx 13
addx 4
noop
addx -1

Instructions start on a given clock cycle, aka tick. The addx instruction requires 2 ticks; noop takes only 1. We’re also told that signal strength is given by the product of the current cycle, and the current value in register x.

Part 1

Find the signal strength during the 20th, 60th, 100th, 140th, 180th, and 220th cycles. What is the sum of these six signal strengths?

Here’s my strategy:

The code looks like this:

from pathlib import Path
import time

SCRIPT_DIR = Path(__file__).parent
# INPUT_FILE = Path(SCRIPT_DIR, "input/sample_input.txt")
INPUT_FILE = Path(SCRIPT_DIR, "input/input.txt")

class CrtComputer():
    """ Performs instructions. Add instructions take 2 ticks. Noop takes 1 tick. """

    def __init__(self, instructions: list[str]) -> None:
        self._x = 1 # represents middle of horizontal sprite position
        self._instructions = self._convert_to_instructions(instructions)
        self._ip = 0    # instruction pointer
        self.cycle = 0
        self._doing = []  # [[instruction], duration]
        self.running_program = True # Set to false when instructions are complete
        
    @property
    def x(self):
        return self._x
    
    @property
    def signal_strength(self) -> int:
        return self.cycle * self.x
    
    def _convert_to_instructions(self, data: list[str]):
        """ Create a list of instructions in [[instr, val],[],...] format. """
        instructions = []
        for line in data:
            line_words = line.split()
            instr = line_words[0]
            val = None
            if len(line_words) > 1:
                val = int(line_words[1])
            instructions.append((instr, val))
                
        return instructions
        
    def tick(self):
        """ Perform one CPU cycle """
        # print(self)

        if len(self._doing) > 0: # we're processing an instruction
            self._doing[1] -= 1
            
            if self._doing[1] == 0:
                # Complete the running instruction
                instruction = self._doing[0]
                self.__getattribute__(f"_op_{instruction[0]}")(instruction)
                # print(f"Completed instruction: {instruction}")
                
                self._start_next_instruction() # and start the next one
        else: # our first instruction
            self._start_next_instruction()
            
        self.cycle += 1
    
    def _start_next_instruction(self):
        """ Takes an instruction, and calls the appropriate implementation method. """

        instruction = self._instructions[self._ip]
        # print(f"Starting instruction: {instruction}")
        
        if instruction[0] == "addx":
            self._doing = [instruction, 2]
        elif instruction[0] == "noop":
            self._doing = [instruction, 1]

        self._ip += 1 # increment the instruction pointer
        if self._ip == len(self._instructions): # we've finished
            self.running_program = False
    
    def _op_addx(self, instruction: tuple):
        """ Takes 2 cycles. Adds val to register x """
        self._x += instruction[-1]

    def _op_noop(self, _: tuple):
        """ Takes 1 cycle. Does nothing. Instruction parameter will be empty. """
    
    def __repr__(self):
        return f"{self.__class__.__name__}(Cycle={self.cycle};x={self._x},pixel={self._display_posn})"
       
def main():
    with open(INPUT_FILE, mode="rt") as f:
        data = f.read().splitlines()

    # Part 1
    interesting_cycles = [20, 60, 100, 140, 180, 220]
    signal_strength_sum = 0
    crt_computer = CrtComputer(data)
    while crt_computer.running_program:
        crt_computer.tick()
        if crt_computer.cycle in interesting_cycles:
            signal_strength_sum += crt_computer.signal_strength
    
    print(signal_strength_sum)

if __name__ == "__main__":
    t1 = time.perf_counter()
    main()
    t2 = time.perf_counter()
    print(f"Execution time: {t2 - t1:0.4f} seconds")

Part 2

We’re told that we have a 40x6 display. With each tick, the current pixel is incremented by 1. The movement is across, then down… Like a typewriter.

We’re told that the x register stores the middle x (row) position of a sprite, which is itself three pixels wide. We’re told that if the current display pixel coincides with any of the three horizontal pixels that make up the sprite, then this pixel is lit. Else, the pixel is dark.

Render the image given by your program. What eight capital letters appear on your CRT?

This bit was quite fun!

I add some class attributes as constants that represent the display, and a lit pixel:

    DISPLAY_WIDTH = 40
    DISPLAY_HEIGHT = 6
    
    LIT = "#"

Then I initialise some additional attributes in our __init__() method:

        self._display_posn = [0,0]
        self._display = [[" " for _ in range(CrtComputer.DISPLAY_WIDTH)] 
                            for _ in range(CrtComputer.DISPLAY_HEIGHT)]

I.e. we set the initial pixel position to 0,0. And we use a multi-sequence list comprehension to create a list of lists that represent the display. I.e.

Now I create a method to render the display to the console:

    def render_display(self):
        return "\n".join("".join(row) for row in self._display)

Again, this is just about of string joining and list comprehension, to render a single multiline string.

Now, a method that updates the display:

    def _update_display(self):
        # Current horizontal pixel position being drawn
        x_posn = self._display_posn[0]
        
        # Check if horizontal position is *within* to current sprite position
        # The sprite is 3 pixels wide, and the x register gives us the middle position
        if x_posn in range(self.x-1, self.x+2):
            self._display[self._display_posn[1]][x_posn] = CrtComputer.LIT

        # display position moves across the row, then down, 
        # one pixel at a time with each tick        
        if x_posn < CrtComputer.DISPLAY_WIDTH-1:
            self._display_posn[0] += 1
        else:
            self._display_posn[0] = 0
            self._display_posn[1] += 1        

This code is well documented. This is the code that checks if our current pixel is aligned with the current sprite position (given by register x), and if so, sets the current pixel to be lit.

We need to call self._update_display() with each tick. So we add this to our tick() method, just before increasing the cycle count:

        self._update_display()

All done!!

Results

The final code looks like this:

from pathlib import Path
import time

SCRIPT_DIR = Path(__file__).parent
# INPUT_FILE = Path(SCRIPT_DIR, "input/sample_input.txt")
INPUT_FILE = Path(SCRIPT_DIR, "input/input.txt")

class CrtComputer():
    """ Performs instructions. Add instructions take 2 ticks. Noop takes 1 tick. """
    DISPLAY_WIDTH = 40
    DISPLAY_HEIGHT = 6
    
    LIT = "#"
    
    def __init__(self, instructions: list[str]) -> None:
        self._x = 1 # represents middle of horizontal sprite position
        self._instructions = self._convert_to_instructions(instructions)
        self._ip = 0    # instruction pointer
        self.cycle = 0
        self._doing = []  # [[instruction], duration]
        self.running_program = True # Set to false when instructions are complete
        
        self._display_posn = [0,0]
        self._display = [[" " for _ in range(CrtComputer.DISPLAY_WIDTH)] 
                            for _ in range(CrtComputer.DISPLAY_HEIGHT)]
        
    @property
    def x(self):
        return self._x
    
    @property
    def signal_strength(self) -> int:
        return self.cycle * self.x
    
    def _convert_to_instructions(self, data: list[str]):
        """ Create a list of instructions in [[instr, val],[],...] format. """
        instructions = []
        for line in data:
            line_words = line.split()
            instr = line_words[0]
            val = None
            if len(line_words) > 1:
                val = int(line_words[1])
            instructions.append((instr, val))
                
        return instructions
        
    def tick(self):
        """ Perform one CPU cycle """
        # print(self)

        if len(self._doing) > 0: # we're processing an instruction
            self._doing[1] -= 1
            
            if self._doing[1] == 0:
                # Complete the running instruction
                instruction = self._doing[0]
                self.__getattribute__(f"_op_{instruction[0]}")(instruction)
                # print(f"Completed instruction: {instruction}")
                
                self._start_next_instruction() # and start the next one
        else: # our first instruction
            self._start_next_instruction()
        
        self._update_display()
            
        self.cycle += 1
    
    def _update_display(self):
        # Current horizontal pixel position being drawn
        x_posn = self._display_posn[0]
        
        # Check if horizontal position is *within* to current sprite position
        # The sprite is 3 pixels wide, and the x register gives us the middle position
        if x_posn in range(self.x-1, self.x+2):
            self._display[self._display_posn[1]][x_posn] = CrtComputer.LIT

        # display position moves across the row, then down, 
        # one pixel at a time with each tick        
        if x_posn < CrtComputer.DISPLAY_WIDTH-1:
            self._display_posn[0] += 1
        else:
            self._display_posn[0] = 0
            self._display_posn[1] += 1        
        
    def _start_next_instruction(self):
        """ Takes an instruction, and calls the appropriate implementation method. """
        instruction = self._instructions[self._ip]
        # print(f"Starting instruction: {instruction}")
        
        if instruction[0] == "addx":
            self._doing = [instruction, 2]
        elif instruction[0] == "noop":
            self._doing = [instruction, 1]

        self._ip += 1  # increment the instruction pointer
        if self._ip == len(self._instructions): # we've finished
            self.running_program = False
    
    def _op_addx(self, instruction: tuple):
        """ Takes 2 cycles. Adds val to register x """
        self._x += instruction[-1]

    def _op_noop(self, _: tuple):
        """ Takes 1 cycle. Does nothing. Instruction parameter will be empty. """
    
    def __repr__(self):
        return f"{self.__class__.__name__}(Cycle={self.cycle};x={self._x},pixel={self._display_posn})"

    def render_display(self):
        return "\n".join("".join(row) for row in self._display)
        
def main():
    with open(INPUT_FILE, mode="rt") as f:
        data = f.read().splitlines()

    # Part 1
    interesting_cycles = [20, 60, 100, 140, 180, 220]
    signal_strength_sum = 0
    crt_computer = CrtComputer(data)
    while crt_computer.running_program:
        crt_computer.tick()
        if crt_computer.cycle in interesting_cycles:
            signal_strength_sum += crt_computer.signal_strength
    
    print(f"Part 1: {signal_strength_sum}")
    print(f"Part 2:\n{crt_computer.render_display()}")

if __name__ == "__main__":
    t1 = time.perf_counter()
    main()
    t2 = time.perf_counter()
    print(f"Execution time: {t2 - t1:0.4f} seconds")

And the output looks like this:

Part 1: 12840
Part 2:
#### #  #   ## #### ###    ## #### ####
   # # #     # #    #  #    # #       #
  #  ##      # ###  ###     # ###    #
 #   # #     # #    #  #    # #     #
#    # #  #  # #    #  # #  # #    #
#### #  #  ##  #    ###   ##  #    ####
Execution time: 0.0010 seconds

That’s pretty quick!

Visualisation

The characters are not very readable! Let’s use Matplotlib to render some pretty output:

All we need to do is add this to our class:

    def render_as_plt(self):
        """ Render the display as a scatter plot """
        
        all_points = [(x,y) for x in range(CrtComputer.DISPLAY_WIDTH)
                            for y in range(CrtComputer.DISPLAY_HEIGHT)
                            if self._display[y][x] == CrtComputer.LIT]
        
        x_vals, y_vals = zip(*all_points)
        
        axes = plt.gca()
        axes.set_aspect('equal')
        plt.axis("off") # hide the border around the plot axes
        axes.set_xlim(min(x_vals)-1, max(x_vals)+1)
        axes.set_ylim(min(y_vals)-1, max(y_vals)+1)
        axes.invert_yaxis()
        
        axes.scatter(x_vals, y_vals, marker="o", s=50)
        plt.show()

Again, it uses some multi-sequence list comprehension to obtain all the points in the display, as (x,y) coordinates. Then we use zip(*all_points) to transpose our list of [x,y] to become a list of x values and a list of y values. This is explained here. Then we’re ready to plot!

The output looks like this:

CRT display as plot