Skip to content

Commit 19a792b

Browse files
committed
feat: aoc 2022 day 14 solution + tests
1 parent 2c1ddca commit 19a792b

File tree

4 files changed

+198
-0
lines changed

4 files changed

+198
-0
lines changed

_2022/solutions/day14.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
"""Day 14: Regolith Reservoir
2+
3+
This module provides the solution for Advent of Code 2022 - Day 14.
4+
5+
It simulates falling sand in a cave system with rock formations, tracking
6+
how sand accumulates as it falls from a source point following gravity rules.
7+
8+
The module contains a Solution class that inherits from SolutionBase for
9+
parsing rock formations and simulating sand physics.
10+
"""
11+
12+
from itertools import pairwise
13+
import re
14+
from typing import ClassVar
15+
16+
from aoc.models.base import SolutionBase
17+
18+
19+
class Solution(SolutionBase):
20+
"""Simulate falling sand in cave system with rock formations.
21+
22+
This solution models sand falling from point (500, 0) following physics rules:
23+
sand tries to move down, then down-left, then down-right. Part 1 counts sand
24+
units that settle before falling into the abyss. Part 2 adds an infinite floor
25+
and counts units until the source is blocked.
26+
27+
The simulation uses set-based collision detection for efficient position tracking.
28+
"""
29+
30+
REGEX: ClassVar[re.Pattern[str]] = re.compile(r"(\d+,\d+)")
31+
SAND_SOURCE: ClassVar[tuple[int, int]] = (500, 0)
32+
33+
def parse_data(self, data: list[str]) -> set[tuple[int, int]]:
34+
"""Parse rock formation coordinates into occupied positions set.
35+
36+
Converts line segment notation (e.g., "498,4 -> 498,6 -> 496,6") into
37+
individual coordinate points by drawing lines between each pair of points.
38+
39+
Args:
40+
data: List of strings describing rock paths as coordinate pairs
41+
separated by " -> "
42+
43+
Returns
44+
-------
45+
set[tuple[int, int]]: Set of (x, y) coordinates occupied by rocks
46+
"""
47+
rocks = set()
48+
49+
for line in data:
50+
matches = re.findall(self.REGEX, line)
51+
coords = [(int(x), int(y)) for x, y in [match.split(",") for match in matches]]
52+
53+
for (x1, y1), (x2, y2) in pairwise(coords):
54+
# Vertical line
55+
if x1 == x2:
56+
for y in range(min(y1, y2), max(y1, y2) + 1):
57+
rocks.add((x1, y))
58+
59+
# Horizontal line
60+
else:
61+
for x in range(min(x1, x2), max(x1, x2) + 1):
62+
rocks.add((x, y1))
63+
64+
return rocks
65+
66+
def simulate(
67+
self, rocks: set[tuple[int, int]], max_depth: int, *, has_floor: bool = False
68+
) -> int:
69+
"""Simulate sand falling and settling until termination condition met.
70+
71+
Each sand unit falls from source (500, 0) following movement rules:
72+
1. Try to move down (0, +1)
73+
2. Try to move down-left (-1, +1)
74+
3. Try to move down-right (+1, +1)
75+
4. If all blocked, settle at current position
76+
77+
Args:
78+
rocks: Set of rock positions that block sand
79+
max_depth: Maximum y-coordinate of rocks (for abyss detection)
80+
has_floor: If True, simulate infinite floor at max_depth + 2
81+
82+
Returns
83+
-------
84+
int: Number of sand units that settled before termination condition
85+
"""
86+
settled_sand = set()
87+
fall_offsets = [(0, 1), (-1, 1), (1, 1)] # down, down-left, down-right
88+
floor_level = max_depth + 2 if has_floor else None
89+
90+
while True:
91+
sand_x, sand_y = self.SAND_SOURCE
92+
93+
while True:
94+
if has_floor and sand_y + 1 == floor_level:
95+
settled_sand.add((sand_x, sand_y))
96+
break
97+
98+
if not has_floor and sand_y >= max_depth:
99+
return len(settled_sand)
100+
101+
moved = False
102+
for dx, dy in fall_offsets:
103+
next_pos = (sand_x + dx, sand_y + dy)
104+
105+
if next_pos not in rocks and next_pos not in settled_sand:
106+
sand_x, sand_y = next_pos
107+
moved = True
108+
break
109+
110+
if not moved:
111+
settled_sand.add((sand_x, sand_y))
112+
113+
if has_floor and (sand_x, sand_y) == self.SAND_SOURCE:
114+
return len(settled_sand)
115+
116+
break
117+
118+
def part1(self, data: list[str]) -> int:
119+
"""Count sand units that settle before falling into abyss.
120+
121+
Simulates sand falling until a unit falls past the lowest rock formation,
122+
indicating it would fall forever into the endless void below.
123+
124+
Args:
125+
data: List of strings describing rock formations
126+
127+
Returns
128+
-------
129+
int: Number of sand units that came to rest before sand starts
130+
flowing into the abyss
131+
"""
132+
rocks = self.parse_data(data)
133+
max_depth = max(y for _, y in rocks)
134+
135+
return self.simulate(rocks, max_depth, has_floor=False)
136+
137+
def part2(self, data: list[str]) -> int:
138+
"""Count sand units that settle before blocking the source.
139+
140+
Adds an infinite horizontal floor 2 units below the lowest rock.
141+
Simulates until sand accumulates to block the source point at (500, 0).
142+
143+
Args:
144+
data: List of strings describing rock formations
145+
146+
Returns
147+
-------
148+
int: Number of sand units that came to rest before the source
149+
becomes blocked (including the unit that blocks it)
150+
"""
151+
rocks = self.parse_data(data)
152+
max_depth = max(y for _, y in rocks)
153+
154+
return self.simulate(rocks, max_depth, has_floor=True)
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
498,4 -> 498,6 -> 496,6
2+
503,4 -> 502,4 -> 502,9 -> 494,9
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
498,4 -> 498,6 -> 496,6
2+
503,4 -> 502,4 -> 502,9 -> 494,9

_2022/tests/test_14.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""Test suite for Day 14: Regolith Reservoir
2+
3+
This module contains tests for the Day 14 solution, which simulates falling sand
4+
in a cave system with rock formations. The tests verify:
5+
6+
1. Part 1: Counting sand units that settle before falling into abyss
7+
2. Part 2: Counting sand units that settle before blocking the source (with floor)
8+
"""
9+
10+
from aoc.models.tester import TestSolutionUtility
11+
12+
13+
def test_day14_part1() -> None:
14+
"""Test counting sand units before falling into abyss.
15+
16+
This test runs the solution for Part 1 of the puzzle against the
17+
provided test input and compares the result with the expected output.
18+
"""
19+
TestSolutionUtility.run_test(
20+
year=2022,
21+
day=14,
22+
is_raw=False,
23+
part_num=1,
24+
expected=24,
25+
)
26+
27+
28+
def test_day14_part2() -> None:
29+
"""Test counting sand units before blocking source with floor.
30+
31+
This test runs the solution for Part 2 of the puzzle against the
32+
provided test input and compares the result with the expected output.
33+
"""
34+
TestSolutionUtility.run_test(
35+
year=2022,
36+
day=14,
37+
is_raw=False,
38+
part_num=2,
39+
expected=93,
40+
)

0 commit comments

Comments
 (0)