Advent of Code 2021 – Day 17

By: Julia on Eric Burden

Re-posted from: https://ericburden.work/blog/2021/12/18/advent-of-code-2021-day-17/

It’s that time of year again! Just like last year, I’ll be posting my solutions to the Advent of Code puzzles. This year, I’ll be solving the puzzles in Julia. I’ll post my solutions and code to GitHub as well. I had a blast with AoC last year, first in R, then as a set of exercises for learning Rust, so I’m really excited to see what this year’s puzzles will bring. If you haven’t given AoC a try, I encourage you to do so along with me!

Day 17 – Trick Shot

Find the problem description HERE.

The Input – Stay on Target

Today’s puzzle input is a single sentence in a text file in the format of “target area: x=.., y=-..”, where the “” values are real numbers. For this, we’ll just use a regular expression with capture groups for each number, parse those captured numbers to integers, and stuff them into a TargetRange named tuple. That last part is just to give them a nice type alias and help make the subsequent code more readable.

# Helper Functions -------------------------------------------------------------

const TargetRange = @NamedTuple begin
    x_min::Int64
    x_max::Int64
    y_min::Int64
    y_max::Int64
end

function targetrange(vals::Vector{Int64})::TargetRange
    (x_min, x_max, y_min, y_max) = vals
    return (x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max)
end


# Ingest the Data -------------------------------------------------------------

function ingest(path)
    re = r"x=(-?\d+)\.\.(-?\d+), y=(-?\d+)\.\.(-?\d+)"
    m = match(re, readchomp(path))
    return targetrange([parse(Int, i) for i in m.captures])
end

If I had a gripe about Julia, it would be that using named tuples have proven to result in more performant code than normal structs in several of these puzzles, but you can’t write constructors with the same name as the type alias. It’s a minor gripe, but since these behave so similarly to structs, it seems intuitive that we should be able to create them in the same way as structs. Like I said, minor gripe, but I’d just rather have TargetRange() instead of targetrange() above. That’s all.

Part One – Show Off

Wait, wait, wait. Hold on. Yesterday, we decoded a bunch of hex values, one bit at a time, and the elves said “HI”!? And we’re just going to move right past that? No wonder we’re in the mood to launch probes at things today! Our goal is to identify the launch velocity (in both the horizontal and vertical direction) that will land our probe in the target zone, while getting some sweet hang (float?) time in the process. This sounds like math…

# Some Useful Data Structures --------------------------------------------------

# I find it helpful to name my Types according to their use. 
const Velocity = @NamedTuple{x::Int64, y::Int64}
const Position = @NamedTuple{x::Int64, y::Int64}
const VelocityRange = @NamedTuple{x::UnitRange{Int64}, y::StepRange{Int64,Int64}}

# Helper Functions -------------------------------------------------------------

# These little functions do a lot of work. 
# - `triangle()` returns the `nth` triangle number (or Gaussian sum), the sum of
#   the sequence of numbers up to and including `n`.
# - `distanceat()` calculates the distance traveled for a given velocity of `V`
#   at time `T`. Because the puzzle description indicates that velocity degrades
#   at a constant rate over time, the general formula for distance is
#   `position at time T = (initial velocity x T) - loss in velocity`, where the
#   loss in velocity at any given time is the sum of all the prior losses.
# - `peak()` returns the maximum distance traveled for initial velocity `V₀`.
#   This corresponds to the distance when the time matches the initial velocity,
#   after which the accumulated velocity loss is larger than `V₀ × T`.
triangle(n)       = (n^2 + n) ÷ 2
distanceat(V₀, T) = (V₀ * T) - triangle(T - 1)
peak(V₀)          =  distanceat(V₀, V₀)

# Given an initial velocity and a time, calculate the position of the launched
# probe at `time`. In the x-direction, this is either the distance at `time`, or
# the peak distance if `time` is greater than the initial velocity (the probe
# will not travel backwards once it reaches peak distance).
function positionat(initial::Velocity, time::Int64)::Position
    (v_x0, v_y0) = initial
    p_yt = distanceat(v_y0, time)
    p_xt = time >= v_x0 ? peak(v_x0) : distanceat(v_x0, time)
    return (x=p_xt, y=p_yt)
end

# Identify the possible initial x and y velocities for a given `target`.
function velocityrange(target::TargetRange)::VelocityRange
    # The smallest possible x velocity that will reach the target is the
    # velocity where `triangle(v_x)` is at least equal to the minimum
    # range of x in the target. Any lower velocity that this will not reach
    # the distance of `target.x_min`. The maximum x velocity is just the
    # maximum range of x in the target, since the probe could theoretically
    # be shot straight at that point.
    v_xmin = 0
    while triangle(v_xmin) < target.x_min; v_xmin += 1; end
    v_xrange = v_xmin:target.x_max

    # The largest possible y velocity is the one that, when reaching the point
    # of y == 0, will not overshoot the target on the next step. This works out
    # to be the absolute value of `target.y_min`. This range is given backwards,
    # since it is assumed that the maximum height will be found at larger values
    # for y velocity.
    v_ymax = abs(target.y_min)
    v_yrange = v_ymax:-1:target.y_min

    return (x=v_xrange, y = v_yrange)
end

# Given the initial velocity of a probe, determine whether that probe will land
# in the target zone. 
function willhit(initial::Velocity, target::TargetRange)::Bool
    # Starting at the initial position of 0, 0 and time 0
    (x_t, y_t) = (0, 0)
    time = 0

    # Check the position of the probe at subsequent times until it reaches a
    # position either to the right or below the target area. If, during this
    # search, the probe position is found to be within the target area, return
    # true. Otherwise, return false.
    while x_t <= target.x_max && y_t >= target.y_min
        x_t >= target.x_min && y_t <= target.y_max && return true
        (x_t, y_t) = positionat(initial, time)
        time += 1
    end
    return false
end

# Solve Part One ---------------------------------------------------------------

# Starting with the range of possible velocities, check each combination of 
# x and y velocities until an x/y velocity is found that lands in the target
# area. Since we're counting down from the maximum possible y velocity, the
# first probe we find that lands in the target will reach the maximum 
# height, so just return the peak of that y velocity.
function part1(input)
    v_range = velocityrange(input)

    for (v_x, v_y) in Iterators.product(v_range...)
        initial = (x=v_x, y=v_y)
        willhit(initial, input) && return peak(v_y)
    end
    error("Could not find maximum Y for $input")
end

It was math! Well, at least some. I’m pretty sure there’s some math to just solve this part without any iteration or code. I worked on and thought about that for a bit, but I couldn’t crack it. Guess that’s why I write code!

Part Two – The Loneliest Probe

Oh, we only have one probe, eh? I’ve seen those trickshot videos on the internet, and I know for sure they (almost) NEVER land those on the first try, even if they did some math first. Guess we need to pick the safest shot, and the only way to do that (apparently) is to identify all the launch velocities that should land in the target area. That’s not too bad, actually.

# Solve Part Two ---------------------------------------------------------------

# This time, just search through the whole range of possible x/y combinations
# and increment the counter for each launch velocity that lands the probe in
# the target area.
function part2(input)
    v_range = velocityrange(input)
    found = 0

    for (v_x, v_y) in Iterators.product(v_range...)
        initial = (x=v_x, y=v_y)
        if willhit(initial, input)
            found += 1
        end
    end

    return found
end

No major changes here, we just loop through the entire possible range of launch vectors and increment our count for every one that should land in the target zone.

Wrap Up

I got so distracted arranging formulas today! I ended up with a quadratic formula that did not give me anything resembling the answer I was looking for. I’m not sure if I’m annoyed or appreciative of the opportunity to practice some algebra. Probably annoyed because it didn’t work. That said, I’m getting more and more comfortable in Julia, so that’s a win. The process is definitely working.

If you found a different solution (or spotted a mistake in one of mine), please drop me a line!

Advent of Code 2021 – Day 17

By: Julia on Eric Burden

Re-posted from: https://www.ericburden.work/blog/2021/12/18/advent-of-code-2021-day-17/

It’s that time of year again! Just like last year, I’ll be posting my solutions to the Advent of Code puzzles. This year, I’ll be solving the puzzles in Julia. I’ll post my solutions and code to GitHub as well. I had a blast with AoC last year, first in R, then as a set of exercises for learning Rust, so I’m really excited to see what this year’s puzzles will bring. If you haven’t given AoC a try, I encourage you to do so along with me!

Day 17 – Trick Shot

Find the problem description HERE.

The Input – Stay on Target

Today’s puzzle input is a single sentence in a text file in the format of “target area: x=.., y=-..”, where the “” values are real numbers. For this, we’ll just use a regular expression with capture groups for each number, parse those captured numbers to integers, and stuff them into a TargetRange named tuple. That last part is just to give them a nice type alias and help make the subsequent code more readable.

# Helper Functions -------------------------------------------------------------

const TargetRange = @NamedTuple begin
    x_min::Int64
    x_max::Int64
    y_min::Int64
    y_max::Int64
end

function targetrange(vals::Vector{Int64})::TargetRange
    (x_min, x_max, y_min, y_max) = vals
    return (x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max)
end


# Ingest the Data -------------------------------------------------------------

function ingest(path)
    re = r"x=(-?\d+)\.\.(-?\d+), y=(-?\d+)\.\.(-?\d+)"
    m = match(re, readchomp(path))
    return targetrange([parse(Int, i) for i in m.captures])
end

If I had a gripe about Julia, it would be that using named tuples have proven to result in more performant code than normal structs in several of these puzzles, but you can’t write constructors with the same name as the type alias. It’s a minor gripe, but since these behave so similarly to structs, it seems intuitive that we should be able to create them in the same way as structs. Like I said, minor gripe, but I’d just rather have TargetRange() instead of targetrange() above. That’s all.

Part One – Show Off

Wait, wait, wait. Hold on. Yesterday, we decoded a bunch of hex values, one bit at a time, and the elves said “HI”!? And we’re just going to move right past that? No wonder we’re in the mood to launch probes at things today! Our goal is to identify the launch velocity (in both the horizontal and vertical direction) that will land our probe in the target zone, while getting some sweet hang (float?) time in the process. This sounds like math…

# Some Useful Data Structures --------------------------------------------------

# I find it helpful to name my Types according to their use. 
const Velocity = @NamedTuple{x::Int64, y::Int64}
const Position = @NamedTuple{x::Int64, y::Int64}
const VelocityRange = @NamedTuple{x::UnitRange{Int64}, y::StepRange{Int64,Int64}}

# Helper Functions -------------------------------------------------------------

# These little functions do a lot of work. 
# - `triangle()` returns the `nth` triangle number (or Gaussian sum), the sum of
#   the sequence of numbers up to and including `n`.
# - `distanceat()` calculates the distance traveled for a given velocity of `V`
#   at time `T`. Because the puzzle description indicates that velocity degrades
#   at a constant rate over time, the general formula for distance is
#   `position at time T = (initial velocity x T) - loss in velocity`, where the
#   loss in velocity at any given time is the sum of all the prior losses.
# - `peak()` returns the maximum distance traveled for initial velocity `V₀`.
#   This corresponds to the distance when the time matches the initial velocity,
#   after which the accumulated velocity loss is larger than `V₀ × T`.
triangle(n)       = (n^2 + n) ÷ 2
distanceat(V₀, T) = (V₀ * T) - triangle(T - 1)
peak(V₀)          =  distanceat(V₀, V₀)

# Given an initial velocity and a time, calculate the position of the launched
# probe at `time`. In the x-direction, this is either the distance at `time`, or
# the peak distance if `time` is greater than the initial velocity (the probe
# will not travel backwards once it reaches peak distance).
function positionat(initial::Velocity, time::Int64)::Position
    (v_x0, v_y0) = initial
    p_yt = distanceat(v_y0, time)
    p_xt = time >= v_x0 ? peak(v_x0) : distanceat(v_x0, time)
    return (x=p_xt, y=p_yt)
end

# Identify the possible initial x and y velocities for a given `target`.
function velocityrange(target::TargetRange)::VelocityRange
    # The smallest possible x velocity that will reach the target is the
    # velocity where `triangle(v_x)` is at least equal to the minimum
    # range of x in the target. Any lower velocity that this will not reach
    # the distance of `target.x_min`. The maximum x velocity is just the
    # maximum range of x in the target, since the probe could theoretically
    # be shot straight at that point.
    v_xmin = 0
    while triangle(v_xmin) < target.x_min; v_xmin += 1; end
    v_xrange = v_xmin:target.x_max

    # The largest possible y velocity is the one that, when reaching the point
    # of y == 0, will not overshoot the target on the next step. This works out
    # to be the absolute value of `target.y_min`. This range is given backwards,
    # since it is assumed that the maximum height will be found at larger values
    # for y velocity.
    v_ymax = abs(target.y_min)
    v_yrange = v_ymax:-1:target.y_min

    return (x=v_xrange, y = v_yrange)
end

# Given the initial velocity of a probe, determine whether that probe will land
# in the target zone. 
function willhit(initial::Velocity, target::TargetRange)::Bool
    # Starting at the initial position of 0, 0 and time 0
    (x_t, y_t) = (0, 0)
    time = 0

    # Check the position of the probe at subsequent times until it reaches a
    # position either to the right or below the target area. If, during this
    # search, the probe position is found to be within the target area, return
    # true. Otherwise, return false.
    while x_t <= target.x_max && y_t >= target.y_min
        x_t >= target.x_min && y_t <= target.y_max && return true
        (x_t, y_t) = positionat(initial, time)
        time += 1
    end
    return false
end

# Solve Part One ---------------------------------------------------------------

# Starting with the range of possible velocities, check each combination of 
# x and y velocities until an x/y velocity is found that lands in the target
# area. Since we're counting down from the maximum possible y velocity, the
# first probe we find that lands in the target will reach the maximum 
# height, so just return the peak of that y velocity.
function part1(input)
    v_range = velocityrange(input)

    for (v_x, v_y) in Iterators.product(v_range...)
        initial = (x=v_x, y=v_y)
        willhit(initial, input) && return peak(v_y)
    end
    error("Could not find maximum Y for $input")
end

It was math! Well, at least some. I’m pretty sure there’s some math to just solve this part without any iteration or code. I worked on and thought about that for a bit, but I couldn’t crack it. Guess that why I write code!

Part Two – The Loneliest Probe

Oh, we only have one probe, eh? I’ve seen those trickshot videos on the internet, and I know for sure they (almost) NEVER land those on the first try, even if they did some math first. Guess we need to pick the safest shot, and the only way to do that (apparently) is to identify all the launch velocities that should land in the target area. That’s not too bad, actually.

# Solve Part Two ---------------------------------------------------------------

# This time, just search through the whole range of possible x/y combinations
# and increment the counter for each launch velocity that lands the probe in
# the target area.
function part2(input)
    v_range = velocityrange(input)
    found = 0

    for (v_x, v_y) in Iterators.product(v_range...)
        initial = (x=v_x, y=v_y)
        if willhit(initial, input)
            found += 1
        end
    end

    return found
end

No major changes here, we just loop through the entire possible range of launch vectors and increment our count for every one that should land in the target zone.

Wrap Up

I got so distracted arranging formulas today! I ended up with a quadratic formula that did not give me anything resembling the answer I was looking for. I’m not sure if I’m annoyed or appreciative of the opportunity to practice some algebra. Probably annoyed because it didn’t work. That said, I’m getting more and more comfortable in Julia, so that’s a win. The process is definitely working.

If you found a different solution (or spotted a mistake in one of mine), please drop me a line!

News features in DataFrames.jl 1.3: part 2

By: Blog by Bogumił Kamiński

Re-posted from: https://bkamins.github.io/julialang/2021/12/17/selectors.html

Introduction

This post continues the presentation of new features added in DataFrames.jl 1.3.0. Last week in this post I have discussed the changes that
improve performance of reduction operations that take wide data (e.g. taking an
average of 10,000 columns). This week I will focus on improvements of convenience
of use of the data transformation mini-language.

The post was written under Julia 1.7.0 and DataFrames.jl 1.3.0.

The data transformation mini-language

The select[!], transform[!], combine, and subset[!] functions in
DataFrames.jl accept specification of column transformation’s using a so called
data transformation mini-language. It has a general form:

[input column names] => [transformation function] => [output columns]

A full specification of allowed forms can be found here. However, you
might find it a bit technical. This is unfortunately unavoidable, as the
mini-language was designed to allow maximum flexibility, so that packages like
DataFramesMeta.jl or DataFrameMacros.jl can rely on it and
provide a nice user-facing syntax. Therefore in this post I have
presented several introductory examples of its usage.

New features

One of the common advanced use-cases of the mini-language is performing the same
transformation on multiple columns of a data frame. Imagine that you have
the following data frame:

julia> using DataFrames

julia> df = DataFrame(name='A':'E', year2019=1:5, year2020=2:6, year2021=3:7)
5×4 DataFrame
 Row │ name  year2019  year2020  year2021
     │ Char  Int64     Int64     Int64
─────┼────────────────────────────────────
   1 │ A            1         2         3
   2 │ B            2         3         4
   3 │ C            3         4         5
   4 │ D            4         5         6
   5 │ E            5         6         7

Now assume that we wanted to calculate sum of each of the columns :year2019,
:year2020, and :year2021. The simplest way to achieve this is the
following:

julia> combine(df, :year2019 => sum, :year2020 => sum, :year2021 => sum)
1×3 DataFrame
 Row │ year2019_sum  year2020_sum  year2021_sum
     │ Int64         Int64         Int64
─────┼──────────────────────────────────────────
   1 │           15            20            25

(Note that in the call I have omitted output column name part so DataFrames.jl
automatically generated the column names consisting of the source column name
and the transformation function name that was applied to it.)

However, you might consider the above call to the combine function a bit
redundant. You can write the same using broadcasting like this:

julia> combine(df, [:year2019, :year2020, :year2021] .=> sum)
1×3 DataFrame
 Row │ year2019_sum  year2020_sum  year2021_sum
     │ Int64         Int64         Int64
─────┼──────────────────────────────────────────
   1 │           15            20            25

Note how the [:year2019, :year2020, :year2021] .=> sum is being handled by
Julia before it is passed to the combine function:

julia> [:year2019, :year2020, :year2021] .=> sum
3-element Vector{Pair{Symbol, typeof(sum)}}:
 :year2019 => sum
 :year2020 => sum
 :year2021 => sum

Now you might ask, what if I did not have three columns to process but 100 of
them? It is easy to select their names using the names function. Here I show
you how to select all columns in the data frame except the :name column:

julia> names(df, Not(:name))
3-element Vector{String}:
 "year2019"
 "year2020"
 "year2021"

Therefore the call to combine above can be rewritten as:

julia> combine(df, names(df, Not(:name)) .=> sum)
1×3 DataFrame
 Row │ year2019_sum  year2020_sum  year2021_sum
     │ Int64         Int64         Int64
─────┼──────────────────────────────────────────
   1 │           15            20            25

This already looks quite powerful, but there is one annoying thing. Why do we
need to call the names function? It should be obvious that Not(:name)
applies to the df data frame. Let us check if this would work:

julia> combine(df, Not(:name) .=> sum)
1×3 DataFrame
 Row │ year2019_sum  year2020_sum  year2021_sum
     │ Int64         Int64         Int64
─────┼──────────────────────────────────────────
   1 │           15            20            25

Yes it does! And this is the new feature in DataFrames.jl 1.3 I wanted to talk
about today.

The select[!], transform[!], combine, and subset[!] functions when they
get any of the selectors Not, Between, Cols, All in a broadcasting
expression are now able to resolve them with respect to the context of the data
frame that is being processed by them.

Let me give two more examples of this feature to show you how it works:

julia> combine(df, Not(:name) .=> [minimum maximum])
1×6 DataFrame
 Row │ year2019_minimum  year2020_minimum  year2021_minimum  year2019_maximum  year2020_maximum  year2021_maximum
     │ Int64             Int64             Int64             Int64             Int64             Int64
─────┼────────────────────────────────────────────────────────────────────────────────────────────────────────────
   1 │                1                 2                 3                 5                 6                 7

julia> combine(df, Not(:name) .=> sum .=> Not(:name))
1×3 DataFrame
 Row │ year2019  year2020  year2021
     │ Int64     Int64     Int64
─────┼──────────────────────────────
   1 │       15        20        25

In the first one you can see that broadcasting is properly applied even in two
dimensional case (note that [minimum maximum] is a Matrix).

In the second example you see that broadcasting is properly handled both in
specification of source as well as for target column names.

Behind the scenes

The way things work are in my opinion intuitive and expected. However, let me
show you that they are not as easy as you might think. The reason is that
broadcasting is resolved before the data transformation mini-language
expression is passed to combine (or other transformation functions I have
listed). Let us check how the expressions I have used above get resolved
before they got passed to combine:

julia> Not(:name) .=> sum
InvertedIndices.BroadcastedInvertedIndex(InvertedIndex{Symbol}(:name)) => sum

julia> Not(:name) .=> [minimum maximum]
1×2 Matrix{Pair{InvertedIndices.BroadcastedInvertedIndex}}:
 BroadcastedInvertedIndex(InvertedIndex{Symbol}(:name))=>minimum  BroadcastedInvertedIndex(InvertedIndex{Symbol}(:name))=>maximum

julia> Not(:name) .=> sum .=> Not(:name)
InvertedIndices.BroadcastedInvertedIndex(InvertedIndex{Symbol}(:name)) => (sum => InvertedIndices.BroadcastedInvertedIndex(InvertedIndex{Symbol}(:name)))

They look quite messy. What is the problem? All these three data transformation
mini-language expressions do not include df in them. Therefore when Julia
executes the broadcasting operation it is unaware of the df context. The
workaround is to create a special BroadcastedInvertedIndex object (in the
case of Not operation; for Cols, Between, and All also a special
wrapper object is created) that signals combine that broadcasting was used on
Not(:name) selector. Then combine internally has implemented its own
broadcasting machinery that matches the Julia Base broadcasting rules and
resolves the expression within the df context as required.

As you can see things that seem simple end up quite complex. In particular
this means that DataFrames.jl must closely monitor changes in Julia Base
broadcasting implementation to make sure it matches its rules.

Conclusions

I have two conclusions for today.

The first one is user facing. In DataFrames.jl 1.3 we have added a long
requested convenience functionality of broadcasting Not, Cols, Between,
and All calls in data transformation mini-language within the context of a
data frame that they apply to. Therefore, hopefully, our users will be more
happy now.

The second is for DataFrames.jl maintenance. Some of the users might have noted
that JuliaData members always ask for a strong justification before new
features are added. The reason is twofold. Firstly, having increasingly more
features makes learning of DataFrames.jl harder. Secondly, as you can see in
the example given today, adding new features makes the code base of
DataFrames.jl quite complex and implicitly strongly linked to Julia Base
design. This means that it becomes increasingly harder for new contributors to
get involved in the package development (and we would love to see more of them
so we prefer to keep things simple if possible).

Finally, in the coming weeks I will continue the discussion of the new features
in DataFrames.jl 1.3, so stay tuned.