Dazbo's Advent of Code solutions, written in Python
classessetslist comprehensionmap
We’re in some sort of cavern, and we’re working within a vertical slice of this cavern. Within this vertical slice, there are lines of rock which are either horizontal or vertical.
Our input data describes these lines:
498,4 -> 498,6 -> 496,6
503,4 -> 502,4 -> 502,9 -> 494,9
Here, each row of input data represents a set of connected lines.
Grains of sand are falling from the ceiling, from point (500,0).
We’re told that the sand falls until it comes to rest.  If a grain of sand can move directly down,
diagonally down left, or diagonally down right, it will.
How many units of sand come to rest before sand starts flowing into the abyss below?
Here’s my strategy:
Soln:
Point dataclass, as I often do!Line class to represent lines of rock. A line has two points: start and end.Grid class:
    set of all points tha make up rock.set to store the positions where sand comes to rest.set to represent the union of all points occupied by either rock or sand.drop_sand() method simulates the falling of a grain of sand.grid class, drop sand until sand starts falling into the abyss.So first of all, the Point and Line classes:
@dataclass(frozen=True)
class Point():
    x: int
    y: int
    
@dataclass(frozen=True)
class Line():
    start: Point
    end: Point
They have both been defined as immutable (i.e. unmodifiable) dataclasses. We need them to be immutable,so that we can store them in our sets later.
Now the method that reads the input data:
def process_lines(data):
    lines = set()
    for input_line in data:
        point_coords = input_line.split(" -> ")
        points = [Point(*map(int, coord.split(","))) for coord in point_coords]
        for i in range(1, len(points)):
            lines.add(Line(points[i-1], points[i]))
    
    return lines
Here I:
x,y cordinates that make up the successive vertices of a bunch of lines.x,y pair into two values, and use map to turn each value into an int representation. From here, we can contruct a Point for each point in the input data.Line objects. We return all the lines.Now it’s time to construct our Grid class:
class Grid():
    SAND_ORIGIN = Point(500,0)
    SAND_VECTORS = [Point(0,1), Point(-1, 1), Point(1, 1)] # down, diagonal left, diagonal right
    
    def __init__(self, lines: set[Line]) -> None:
        self.rock: set[Point] = self._get_rock(lines)
        self.sand = set()
        self.min_x = min(point.x for point in self.rock)
        self.max_x = max(point.x for point in self.rock)
        self.min_y = min(point.y for point in self.rock)
        self.max_y = max(point.y for point in self.rock) 
        
    def _get_rock(self, lines: set[Line]):
        """ Process lines of rock. For each point in those lines, add a rock point to the set. """
        rock = set()
        for line in lines:
            x_start = min(line.start.x, line.end.x)
            x_end = max(line.start.x, line.end.x)
            y_start = min(line.start.y, line.end.y)
            y_end = max(line.start.y, line.end.y)
            rock.update({Point(x,y) for x in range(x_start, x_end+1)
                                    for y in range(y_start, y_end+1)})
        
        return rock
    
    def _is_empty(self, point: Point) -> bool:
        """ If this point is not rock or sand, return True. """
        if point not in self.rock and point not in self.sand:
            return True
        
        return False
    
    def drop_sand(self) -> Point:
        """ Sand falls down until it reaches an obstacle.
        If it reaches an obstacle, it will they try to fall diagonally left, then diagonally right. """
        grain = Grid.SAND_ORIGIN
        falling = True
        while falling:
            for v in Grid.SAND_VECTORS:
                candidate = Point(grain.x + v.x, grain.y + v.y)
                if self._is_empty(candidate):
                    if candidate.x < self.min_x or candidate.x > self.max_x: # we've reached fall-through
                        return None
                    
                    grain = candidate
                    self.min_y = min(self.min_y, grain.y)
                    break  # move out of the vectors loop
            else: # Get here if all our fall positions are full
                falling = False
        self.sand.add(grain)                 
        return grain
    
    def __str__(self) -> str:
        rows = []
        for y in range(self.min_y, self.max_y+1):
            row = f"{y:3d} "
            
            # print 1 col to either side
            for x in range(self.min_x-1, self.max_x+2):
                point = Point(x,y)
                if point in self.rock:
                    row += "#"
                    continue
                if point in self.sand:
                    row += "o"
                    continue
                row += "."
            
            rows.append(row)
            
        return "\n".join(rows)
How this works:
self.rock set.At this point, if we were to print the grid using the sample data, the output looks like this:
  4 .....#...##.
  5 .....#...#..
  6 ...###...#..
  7 .........#..
  8 .........#..
  9 .#########..
drop_sand() method:
    500,0.None.main(), call drop_sand() until no more grains are returned (i.e. at rest).grid.sand.We do that with this code:
    grid = Grid(lines)
    
    adding_sand = True
    while adding_sand:
        adding_sand = grid.drop_sand()
    
    print(f"\n{grid}")
    print(f"Part 1: resting grains={len(grid.sand)}")
At this point, if we print the grid with the sample data, the output looks like this:
  1 ............
  2 .......o....
  3 ......ooo...
  4 .....#ooo##.
  5 ....o#ooo#..
  6 ...###ooo#..
  7 .....oooo#..
  8 ..o.ooooo#..
  9 .#########..
Now we have a floor, at location y+2 relative to lowest rock. 
Sand will keep falling until we’ve blocked the origin at (500,0).
How many units of sand come to rest?
My code changes as follows:
__init__() now accepts a parameter to set whether we have a floor or not. This allows us to perform different logic, depending on whether we’re doing Part 1 or Part 2.floor_y accordingly, and update the other limits.is_empty() method to now return False if the point.y location is the floor.drop_sand() method:
    x and y limits if required.ORIGIN,
then this is the last grain we can drop, so return None.The final code looks like this:
from dataclasses import dataclass
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")
@dataclass(frozen=True)
class Point():
    x: int
    y: int
    
@dataclass(frozen=True)
class Line():
    start: Point
    end: Point
    
class Grid():
    SAND_ORIGIN = Point(500,0)
    SAND_VECTORS = [Point(0,1), Point(-1, 1), Point(1, 1)] # down, diagonal left, diagonal right
    
    def __init__(self, lines: set[Line], floor=False) -> None:
        self.rock: set[Point] = self._get_rock(lines)
        self.sand = set()
        self.min_x = min(point.x for point in self.rock)
        self.max_x = max(point.x for point in self.rock)
        self.min_y = min(point.y for point in self.rock)
        self.max_y = max(point.y for point in self.rock)
        self._set_floor(floor)
    def _set_floor(self, floor: bool):
        self._floor = floor
        self._floor_y = self.max_y + 2
        self.max_y = self._floor_y        
        
    def _get_rock(self, lines: set[Line]):
        """ Process lines of rock. For each point in those lines, add a rock point to the set. """
        rock = set()
        for line in lines:
            x_start = min(line.start.x, line.end.x)
            x_end = max(line.start.x, line.end.x)
            y_start = min(line.start.y, line.end.y)
            y_end = max(line.start.y, line.end.y)
            rock.update({Point(x,y) for x in range(x_start, x_end+1)
                                    for y in range(y_start, y_end+1)})
        
        return rock
    
    def _is_empty(self, point: Point) -> bool:
        """ If this point is not rock or sand, return True. """
        if point not in self.rock and point not in self.sand:
            if self._floor:
                if point.y == self._floor_y:
                    return False
            return True
        
        return False
    
    def drop_sand(self) -> Point:
        """ Sand falls down until it reaches an obstacle.
        If it reaches an obstacle, it will they try to fall diagonally left, then diagonally right. """
        grain = Grid.SAND_ORIGIN
        falling = True
        while falling:
            for v in Grid.SAND_VECTORS:
                candidate = Point(grain.x + v.x, grain.y + v.y)
                if self._is_empty(candidate):
                    if not self._floor and candidate.y == self._floor_y: # we've reached fall-through
                        return None
                    else: # there is a floor; expand the grid
                        self.min_x = min(self.min_x, grain.x - 1)
                        self.max_x = max(self.max_x, grain.x + 1)
                        self.min_y = min(self.min_y, grain.y)
                    
                    grain = candidate
                    self.min_y = min(self.min_y, grain.y)
                    break  # move out of the vectors loop
            else: # Get here if all our fall positions are full
                falling = False
        self.sand.add(grain)
        if grain == Grid.SAND_ORIGIN:
            return None                  
        
        return grain
    
    def __str__(self) -> str:
        rows = []
        for y in range(self.min_y, self.max_y+1):
            row = f"{y:3d} "
            
            # print 1 col to either side
            for x in range(self.min_x-1, self.max_x+2):
                point = Point(x,y)
                if point in self.rock:
                    row += "#"
                    continue
                if self._floor and y == self._floor_y:
                    row += "#"
                    continue
                if point in self.sand:
                    row += "o"
                    continue
                row += "."
            
            rows.append(row)
            
        return "\n".join(rows)
def main():
    with open(INPUT_FILE, mode="rt") as f:
        data = f.read().splitlines()
        
    lines = process_lines(data)
    
    # Part 1
    grid = Grid(lines)
    
    adding_sand = True
    while adding_sand:
        adding_sand = grid.drop_sand()
        # print(f"\n{grid}")
    
    print(f"Part 1: resting grains={len(grid.sand)}")
    
    # Part 2
    grid = Grid(lines, floor=True)
    adding_sand = True    
    while adding_sand:
        adding_sand = grid.drop_sand()
        # print(f"\n{grid}")
        
    print(f"Part 2: resting grains={len(grid.sand)}")        
        
def process_lines(data):
    lines = set()
    for input_line in data:
        point_coords = input_line.split(" -> ")
        points = [Point(*map(int, coord.split(","))) for coord in point_coords]
        for i in range(1, len(points)):
            lines.add(Line(points[i-1], points[i]))
    
    return lines
if __name__ == "__main__":
    t1 = time.perf_counter()
    main()
    t2 = time.perf_counter()
    print(f"Execution time: {t2 - t1:0.4f} seconds")
The final output looks like this:
Part 1: resting grains=888
Part 2: resting grains=26461
Execution time: 6.2650 seconds
It’s a bit slow. I’m sure there are optimisations I could make. But I’m knackered, so I’m not going to!