11 December 2019 ; tagged in: advent of code , haskell

Advent of Code 2019 day 10

In which Neil gets confused by numbers.

Advent of Code 2019 day 10

Day 10 involved lots of fiddling around with numbers, me getting very confused, and the odd diagram of which way was up.

Part 1

This involved some different data types. Screened locations were only on exact grid points. To keep the precision of exact points, I could use rational numbers to represent points (and screened points). I used the V2 Rational vector type to represent an asteroid, and a Set of them to represent the whole field.

To find the best site, I have to find the number of visible asteroids at each possible site. This returns both the best site and the number of visible asteroids.

bestVisible :: Bounds -> Asteroids -> (Position, Int)
bestVisible bounds asteroids = maximumBy (comparing snd) 
                                $ S.toList 
                                $ S.map (visibleCount bounds asteroids) asteroids

visibleCount :: Bounds -> Asteroids -> Position -> (Position, Int)
visibleCount bounds asteroids origin = (origin, S.size $ visible bounds origin asteroids)

To find the visible asteroids from a site, I find all the points that are screened by the asteroids, and remove them from the set of all asteroids: what remains is just the set of visible asteroids.

visible :: Bounds -> Position -> Asteroids -> Asteroids
visible bounds origin asteroids = S.delete origin $ S.difference asteroids screened
    where screened = allScreenings bounds origin asteroids

The set of all the screened points, is just the union of the sets of points screened by each asteroid.

allScreenings :: Bounds -> Position -> Asteroids -> Asteroids
allScreenings bounds origin asteroids = S.foldl' (screenings bounds origin) S.empty asteroids

screenings does the work. I only have to worry about screened points that line up on grid points. That means that if an asteroid is five units left and two units below the origin, the interesting screened points are 10 left and 4 below, 15 left and 6 below, and so on. But if an asteroid is four units left and two units below, the interesting screened points are 6 left and 3 below, 8 left and 4 below, 10 left and 6 below, and so on.

But rather than thinking about common factors of the steps left and down, I can just take one step left and two fifths of a step down (in the 5, 2 case), or one step left and half a step down (in the 4, 2 case). This will generate more screened points than could possibly line up with asteroids, but as all I'm doing is finding the intersection between these screened points and the asteroids, I'll get the same answer anyway.

That's what screenings does. It finds the difference between the origin and the target, and then finds the largest component of that step. It uses that to scale the delta to be one step in that direction, and whatever fraction of a step in the other direction. It then just applies multiples of that delta beyond the target, until it reaches the edge of the asteroid field.

screenings :: Bounds -> Position -> Asteroids -> Position -> Asteroids
screenings bounds origin@(V2 ox oy) screened0 target@(V2 tx ty) 
    | origin == target = screened0
    | otherwise        = S.union screened0 screened
    where maxComponent = max (abs (tx - ox)) (abs (ty - oy))
          delta = V2 ((tx - ox) / maxComponent) ((ty - oy) / maxComponent)
          rawScreens = takeWhile (inBounds bounds) [target ^+^ n *^ delta | n <- [1..]]
          screened = S.fromList rawScreens

inBounds :: Bounds -> Position -> Bool
inBounds (maxX, maxY) (V2 x y) = (x >= 0) && (x <= (maxX % 1)) && (y >= 0) && (y <= (maxY % 1))

There is also some faffing around with fromIntegral calls in the datafile reading code, to convert the machine-sized Int values to the arbitrary-sized Integer values needed in the rest of the program.

Part 2

At this point, all the niceties of rational numbers disappeared. I did start by trying that route, having a virtual asteroid tracking around the perimeter of the field and the laser cannon pointing at it. But that missed some asteroids, so I dropped down into dirty Float real numbers.

I created a Map of targetting information, one element per asteroid. The value was the position of asteroid; the key was a pair (2-tuple) of its angular position (relative to the monitor satellite) and its distance. (I somehow managed to have the angle increasing clockwise; not what I was expecting.)

makeTargets :: Position -> Asteroids -> Targets
makeTargets origin asteroids = S.foldl' addTarget M.empty asteroids
    where addTarget m t = M.insert (targetInfo origin t) t m

targetInfo :: Position -> Position -> TargetInfo
targetInfo origin target = (angle, range)
    where V2 dx dy = target - origin
          angle = atan2 (fromRational dy) (fromRational dx)
          range = norm (V2 (fromRational dy) (fromRational dx))

The targetting information was used with the current angle of laser. The next target is the asteroid with the smallest angle larger than the laser's angle; if there are several such targets, the laser shoots the nearest asteroid. As tuples order on first term then second term, sorting by the (angle, distance) pair sorts the asteroids in the correct targetting order.

possibleTargets finds all the targets clockwise of the laser's angle. firstTarget finds the first such target. targetNext handles the destruction of an asteroid, and updates the laser's angle to that of the just-destroyed asteroid. It also handles the branch cut situation where there are no more targets between the laser and the angle π radians; in that case, the angle is reset to -π radians and the zapping continues.

targetSequence :: Targets -> [Position]
targetSequence targets = targetNext ((- pi / 2) - 0.001) targets

targetNext :: Float -> Targets -> [Position]
targetNext angle targets 
    | M.null targets = []
    | M.null possibles = targetNext (- pi) targets
    | otherwise = (target:(targetNext angle' targets'))
    where possibles = possibleTargets angle targets
          ((targetAngle, targetRange), target) = firstTarget possibles
          targets' = M.delete (targetAngle, targetRange) targets
          angle' = targetAngle

possibleTargets :: Float -> Targets -> Targets
possibleTargets angle targets = M.filterWithKey (\(a, _) _ -> a > angle) targets

firstTarget :: Targets -> (TargetInfo, Position)
firstTarget targets = M.findMin targets

Debugging

All of that description sounds very simple and straightforward, but I managed to tie myself into several knots along the way. The angular positions weren't doing what I expected, and I once had a situation where an asteroid was shielding asteroids closer than it as well as those further away.

The showPattern function was helpful, generating a little map of interesting points. I used that to compare what my program thought was happening with what the AoC examples were showing.

Code

The code is available here, and on Github. There's also an earlier version, which uses V2 Int to store the asteroid locations, and then does some fiddling around inside screenings to covert the Int to Rational and back again.