Skip to content

Commit a10ac37

Browse files
committed
feat: aoc 2025 day 1 solution + tests
1 parent 7b95914 commit a10ac37

File tree

6 files changed

+205
-0
lines changed

6 files changed

+205
-0
lines changed

_2025/__init__.py

Whitespace-only changes.

_2025/solutions/__init__.py

Whitespace-only changes.

_2025/solutions/day01.py

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
"""Day 1: Circular Dial Rotation
2+
3+
This module provides the solution for Advent of Code 2025 - Day 1.
4+
5+
It simulates rotating a circular dial (positions 0-99) based on movement
6+
instructions, counting how many times the dial points at position 0.
7+
8+
The module contains a Solution class that inherits from SolutionBase for
9+
parsing dial movements and tracking zero position counts.
10+
"""
11+
12+
import re
13+
from typing import ClassVar
14+
15+
from aoc.models.base import SolutionBase
16+
17+
18+
class Solution(SolutionBase):
19+
"""Simulate circular dial rotations and count zero position occurrences.
20+
21+
This solution models a 100-position circular dial starting at position 50.
22+
Instructions specify clockwise (R) or counterclockwise (L) rotations by
23+
a number of steps. Part 1 counts endings at position 0. Part 2 counts every
24+
pass through position 0 during rotations.
25+
26+
Uses modular arithmetic for efficient dial position tracking.
27+
"""
28+
29+
DIAL_SIZE: ClassVar[int] = 100
30+
START_POSITION: ClassVar[int] = 50
31+
MOVE_PATTERN: ClassVar[re.Pattern[str]] = re.compile(r"(L|R)(\d+)$")
32+
33+
def parse_move(self, move: str) -> tuple[str, int]:
34+
"""Parse movement instruction into direction and step count.
35+
36+
Args:
37+
move: Instruction string like "L68" or "R48"
38+
39+
Returns
40+
-------
41+
tuple[str, int]: Direction ("L" or "R") and number of steps
42+
43+
Raises
44+
------
45+
ValueError: If instruction format is invalid
46+
"""
47+
match = self.MOVE_PATTERN.match(move)
48+
if not match:
49+
err_msg = f"Invalid move: {move}"
50+
raise ValueError(err_msg)
51+
52+
return match.group(1), int(match.group(2))
53+
54+
def move_jump(self, position: int, direction: str, steps: int) -> tuple[int, int]:
55+
"""Move dial by steps using modular arithmetic, count final zero landings.
56+
57+
Args:
58+
position: Current dial position (0-99)
59+
direction: "L" (counterclockwise) or "R" (clockwise)
60+
steps: Number of positions to rotate
61+
62+
Returns
63+
-------
64+
tuple[int, int]: (new_position, zero_count) where zero_count is 1 if
65+
final position is 0, else 0
66+
"""
67+
if direction == "L":
68+
new_position = (position - steps) % self.DIAL_SIZE
69+
else:
70+
new_position = (position + steps) % self.DIAL_SIZE
71+
72+
zero_count = 1 if new_position == 0 else 0
73+
74+
return new_position, zero_count
75+
76+
def move_step(self, position: int, direction: str, steps: int) -> tuple[int, int]:
77+
"""Move dial one position at a time, counting every pass through zero.
78+
79+
Args:
80+
position: Current dial position (0-99)
81+
direction: "L" (counterclockwise) or "R" (clockwise)
82+
steps: Number of positions to rotate
83+
84+
Returns
85+
-------
86+
tuple[int, int]: (final_position, total_zero_count) where total_zero_count
87+
includes every time position 0 is passed during rotation
88+
"""
89+
zero_count = 0
90+
step_direction = -1 if direction == "L" else 1
91+
92+
for _ in range(steps):
93+
position = (position + step_direction) % self.DIAL_SIZE
94+
if position == 0:
95+
zero_count += 1
96+
97+
return position, zero_count
98+
99+
def part1(self, data: list[str]) -> int:
100+
"""Count dial rotations that end exactly at position 0.
101+
102+
Starting at position 50, processes all rotation instructions and counts
103+
how many times the dial ends precisely at position 0 after each move.
104+
Uses efficient modular arithmetic for large rotations.
105+
106+
Args:
107+
data: List of rotation instructions (e.g., ["R48", "L68"])
108+
109+
Returns
110+
-------
111+
int: Total number of rotations ending at position 0
112+
"""
113+
position = self.START_POSITION
114+
zero_count = 1 if position == 0 else 0
115+
116+
for move in data:
117+
direction, steps = self.parse_move(move)
118+
position, zeros = self.move_jump(position, direction, steps)
119+
zero_count += zeros
120+
121+
return zero_count
122+
123+
def part2(self, data: list[str]) -> int:
124+
"""Count every time dial passes through position 0 during rotations.
125+
126+
Tracks position 0 crossings during step-by-step rotations, not just final
127+
positions. A full rotation (100 steps) in either direction passes through
128+
0 exactly once.
129+
130+
Args:
131+
data: List of rotation instructions (e.g., ["R48", "L68"])
132+
133+
Returns
134+
-------
135+
int: Total number of times position 0 is visited during all rotations
136+
"""
137+
position = self.START_POSITION
138+
zero_count = 0
139+
140+
for move in data:
141+
direction, steps = self.parse_move(move)
142+
position, zeros = self.move_step(position, direction, steps)
143+
zero_count += zeros
144+
145+
return zero_count
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
L68
2+
L30
3+
R48
4+
L5
5+
R60
6+
L55
7+
L1
8+
L99
9+
R14
10+
L82
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
L68
2+
L30
3+
R48
4+
L5
5+
R60
6+
L55
7+
L1
8+
L99
9+
R14
10+
L82

_2025/tests/test_01.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""Test suite for Day 1: Circular Dial Rotation
2+
3+
This module contains tests for the Day 1 solution, which simulates dial
4+
rotations on a 100-position circular dial. The tests verify:
5+
6+
1. Part 1: Counting rotations that end exactly at position 0
7+
2. Part 2: Counting every pass through position 0 during rotations
8+
"""
9+
10+
from aoc.models.tester import TestSolutionUtility
11+
12+
13+
def test_day01_part1() -> None:
14+
"""Test counting final positions at zero after rotations.
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=2025,
21+
day=1,
22+
is_raw=False,
23+
part_num=1,
24+
expected=3,
25+
)
26+
27+
28+
def test_day01_part2() -> None:
29+
"""Test counting all zero position visits during step-by-step rotations.
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=2025,
36+
day=1,
37+
is_raw=False,
38+
part_num=2,
39+
expected=6,
40+
)

0 commit comments

Comments
 (0)