Skip to content

Commit 442681f

Browse files
committed
feat: aoc 2025 day 7 solution + tests
1 parent aa5f336 commit 442681f

File tree

4 files changed

+204
-0
lines changed

4 files changed

+204
-0
lines changed

_2025/solutions/day07.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
"""Day 7: Laser Lab
2+
3+
This module provides the solution for Advent of Code 2025 - Day 7.
4+
5+
It simulates beams traveling downward through a grid containing a start
6+
position ('S'), empty cells ('.'), and splitters ('^') that split beams
7+
diagonally.
8+
9+
The module contains a Solution class that inherits from SolutionBase to
10+
count how many splitters activate (Part 1) and how many distinct beam
11+
paths reach the bottom row (Part 2).
12+
"""
13+
14+
from aoc.models.base import SolutionBase
15+
16+
17+
class Solution(SolutionBase):
18+
"""Simulate vertical beam propagation and splitting in a grid.
19+
20+
This solution models beams starting from a single source cell 'S' and
21+
moving one row downward at a time. When a beam enters a splitter '^',
22+
it splits into two beams that continue diagonally down-left and
23+
down-right.
24+
25+
Part 1 tracks a single beam front and counts how many splitters are
26+
activated at least once. Part 2 uses dynamic programming to count how
27+
many distinct paths reach the bottom of the grid after all splitting.
28+
"""
29+
30+
def find_start(self, grid: list[list[str]]) -> tuple[int, int]:
31+
"""Locate the starting cell 'S' in the grid.
32+
33+
Args:
34+
grid: 2D character grid of the laser lab
35+
36+
Returns
37+
-------
38+
tuple[int, int]: (x, y) coordinates of the start cell
39+
40+
Raises
41+
------
42+
ValueError: If no 'S' cell is found in the grid
43+
"""
44+
for y, row in enumerate(grid):
45+
for x, cell in enumerate(row):
46+
if cell == "S":
47+
return (x, y)
48+
49+
raise ValueError("No 'S' start cell found in grid!")
50+
51+
def part1(self, data: list[str]) -> int:
52+
"""Count how many splitters are activated along a single beam front.
53+
54+
Simulates a single front of beams starting from 'S' and moving
55+
row-by-row downward. Each time a beam enters a '^' splitter, that
56+
splitter is counted and the beam splits into two beams diagonally
57+
down-left and down-right for the next row.
58+
59+
Args:
60+
data: List of strings representing the lab grid
61+
62+
Returns
63+
-------
64+
int: Number of times splitters are activated across all rows
65+
"""
66+
grid = [list(row) for row in data]
67+
start_col, start_row = self.find_start(grid)
68+
69+
beams: set[int] = {start_col}
70+
count = 0
71+
last_row_idx = len(grid) - 1
72+
73+
current_row = start_row
74+
while current_row < last_row_idx:
75+
current_row += 1
76+
next_beams: set[int] = set()
77+
78+
for col in beams:
79+
cell = grid[current_row][col]
80+
if cell == "^":
81+
next_beams.add(col - 1)
82+
next_beams.add(col + 1)
83+
count += 1
84+
else:
85+
next_beams.add(col)
86+
87+
beams = next_beams
88+
89+
return count
90+
91+
def part2(self, data: list[str]) -> int:
92+
"""Count distinct beam paths that reach the bottom row.
93+
94+
Uses dynamic programming where dp[r][c] stores how many distinct
95+
paths can reach cell (r, c). Paths start from the cell below 'S'
96+
and propagate row-by-row, splitting at '^' cells into diagonal
97+
positions.
98+
99+
Args:
100+
data: List of strings representing the lab grid
101+
102+
Returns
103+
-------
104+
int: Total number of distinct paths that reach the bottom row
105+
"""
106+
grid = [list(row) for row in data]
107+
rows, cols = len(grid), len(grid[0])
108+
start_col, start_row = self.find_start(grid)
109+
110+
dp = [[0] * cols for _ in range(rows)]
111+
112+
if start_row + 1 < rows:
113+
dp[start_row + 1][start_col] = 1
114+
115+
for r in range(start_row + 1, rows - 1):
116+
for c in range(cols):
117+
if dp[r][c] == 0:
118+
continue
119+
120+
cell = grid[r][c]
121+
if cell == ".":
122+
dp[r + 1][c] += dp[r][c]
123+
124+
elif cell == "^":
125+
if c - 1 >= 0:
126+
dp[r + 1][c - 1] += dp[r][c]
127+
if c + 1 < cols:
128+
dp[r + 1][c + 1] += dp[r][c]
129+
130+
else:
131+
dp[r + 1][c] += dp[r][c]
132+
133+
return sum(dp[rows - 1])
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
.......S.......
2+
...............
3+
.......^.......
4+
...............
5+
......^.^......
6+
...............
7+
.....^.^.^.....
8+
...............
9+
....^.^...^....
10+
...............
11+
...^.^...^.^...
12+
...............
13+
..^...^.....^..
14+
...............
15+
.^.^.^.^.^...^.
16+
...............
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
.......S.......
2+
...............
3+
.......^.......
4+
...............
5+
......^.^......
6+
...............
7+
.....^.^.^.....
8+
...............
9+
....^.^...^....
10+
...............
11+
...^.^...^.^...
12+
...............
13+
..^...^.....^..
14+
...............
15+
.^.^.^.^.^...^.
16+
...............

_2025/tests/test_07.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""Test suite for Day 7: Laser Lab
2+
3+
This module contains tests for the Day 7 solution, which simulates beam
4+
propagation and splitting in a laser lab grid. The tests verify:
5+
6+
1. Part 1: Counting how many splitters are activated by the beam front
7+
2. Part 2: Counting how many distinct beam paths reach the bottom row
8+
"""
9+
from aoc.models.tester import TestSolutionUtility
10+
11+
12+
def test_day07_part1() -> None:
13+
"""Test counting activated splitters along the beam front.
14+
15+
This test runs the solution for Part 1 of the puzzle against the
16+
provided test input and compares the result with the expected output.
17+
"""
18+
TestSolutionUtility.run_test(
19+
year=2025,
20+
day=7,
21+
is_raw=False,
22+
part_num=1,
23+
expected=21,
24+
)
25+
26+
27+
def test_day07_part2() -> None:
28+
"""Test counting distinct beam paths reaching the bottom row.
29+
30+
This test runs the solution for Part 2 of the puzzle against the
31+
provided test input and compares the result with the expected output.
32+
"""
33+
TestSolutionUtility.run_test(
34+
year=2025,
35+
day=7,
36+
is_raw=False,
37+
part_num=2,
38+
expected=40,
39+
)

0 commit comments

Comments
 (0)