Kevin Hoffman's Blog

Blathering about development, dragons, and all that lies between


Generating PlusCodes with Unison - Round 1

A brutal struggle with porting existing code to Unison

Contents

Attempting to port my old Elixir Open Location Code (PlusCode) library to Unison, and failing over and over and over.

What are PlusCodes?

Most of us are familiar with the Global Positioning System (GPS). Our phones have it, our cars have it, even some of our luggage has it. GPS divides the earth into slices. The combination of an east-west slice and a north-south slice can give you any point on the planet.

Globe divided into degrees GPS Globe Enyclopedia entry for GPS This system of using two floating point values to represent a point on the planet works extremely well for all kinds of purposes. However, sometimes we want to represent regions of space on the planet - a 9’x9' space or a 300km x 300km space, etc.

We also want a system that is simple and easy to use. We want to be able to know immediately whether one region is within another. Even more, we’d like to be able to represent these regions in a human-readable fashion.

A plus code is like a street address, but for locations that aren’t or can’t be represented as an address. As an example, the code 87G8Q2PQ+94 represents a small rectangle in the middle of the reservoir in Central Park, New York.

Some time ago, I was planning on using open location codes (OLC) in an augmented reality application for off-road vehicles. People would be able to use topographical maps (with no roads, obviously) and drop region markers highlighting obstacles, vehicle damage, fun bits, gathering fields, etc.

It would also be super easy to tell if a given location was within the designated trail boundaries or not. If one code starts with the same digits as another, that region is within the other. The matching prefix digits form a code that defines a broader region. Rather than having to do super complicated GPS math, you can just use functions like startsWith to check for collisions and containment. The longer the code, the smaller and more precise the region.

There are countless potential applications for a simple mechanism like this. Another use that I want to explore is for gaming. If you adjust the math to account for a different distance between degrees (e.g. a fictional moon), then you could use these codes in a game and also leverage the fast collision and contains checks.

Reviewing my Elixir Library

Google has a GitHub repository that contains a vast collection of implementations of the PlusCode algorithms. It doesn’t contain an Elixir version, and so that’s why I wrote my own Elixir port of the library some time ago. You can find my implementation in my GitHub repo.

Here’s the encode_pairs function (you can see all the helper functions in the GitHub repo). This function is called by encode and performs recursive computations to generate a code from GPS coordinates.

 defp encode_pairs(adj_latitude, adj_longitude, code_length, code, digit_count) when digit_count < code_length do
    place_value = (digit_count / 2)
                  |> floor                  
                  |> resolution_for_pos
      
    {ncode, adj_latitude} = append_code(code, adj_latitude, place_value)
    digit_count = digit_count + 1
    
    {ncode, adj_longitude} = append_code(ncode, adj_longitude, place_value)
    digit_count = digit_count + 1

    # Should we add a separator here?
    ncode = if digit_count == @separator_position and digit_count < code_length do
      ncode <> @separator
    else 
      ncode        
    end
    
    encode_pairs(adj_latitude, adj_longitude, code_length, ncode, digit_count)    
  end

  defp encode_pairs(_, _, code_length, code, digit_count) when digit_count == code_length do   
    code 
      |> pad_trailing 
      |> ensure_separator
  end 

I make no claims about the cleanliness or even reliability of this code.

Porting to Unison

Porting code from one language to another isn’t usually a painful process for me. The big time consumer and source of frustration is often the “language brain” mistakes where I use a } in a language that doesn’t use brackets and I’ll mix and match syntax all over the place. It usually takes a few hours for me to stop bleeding context across languages.

When I set out to port this to Unison, I thought it would be straightfoward. I couldn’t have been more wrong. There were moments working on this where I would burn through 4 hours just trying to get simple syntax working. I was plagued by everything from compilation failures due to bad indentation, me forgetting that I can’t use infix operations everywhere, and probably the most annoying of all: float and whole number conversions.

Let’s take a look at the Unison version of the encode_pairs function. This function recurses as it builds up the code until it reaches the desired length.

encodePairs : Float -> Float -> Nat -> Text -> Nat -> {Exception} Text 
encodePairs = cases
    _, _, clen, code, dcount | dcount == clen -> 
        code |> padTrailing |> ensureSeparator
    lat, long, clen, code, dcount | dcount < clen ->       
        pv = placeValue dcount        
        (ncode, adjLatitude) = appendCode code lat pv
        (ncode2, adjLongitude) = appendCode ncode long pv
        digitCount = dcount + 2
        newCode = if (digitCount == separatorPosition) && (digitCount < clen) then 
            ncode2 ++ separator
        else    
            ncode2
            
        encodePairs adjLatitude adjLongitude clen newCode digitCount
    _, _, _, _, _ ->
        Exception.raise (Generic.failure "failure!" ())

The first change is that I have to use cases here to pattern match on the arguments, since Unison doesn’t support the multiple function head syntax like Elixir.

The next source of trouble was the Exception ability. There are a number of functions that I needed to use that return data of type Optional. It took many hours of smashing my head against the desk and the remarkably (possibly even saint-like) patient efforts of the folks in the Unison slack channel to get me past this. I couldn’t just call unwrap() or put a ? at the end of every function call like I can in Rust, but, as you’ll see in the following code, calling toAbort on an Optional is similar to the Rust ? and Result pair.

The encodePairs function calls two other important functions: placeValue and appendCode. The first obtains a precision value that can in turn be used to locate a character within the base-20 alphabet. The latter appends that code to the current string.

placeValue : Nat -> {Exception} Float
placeValue digitCount = 
    Abort.toGenericException "placeValue failed!" "error" do
        index = floor (Nat.toFloat digitCount / 2.0) |>  Float.toNat |> Optional.toAbort               
        List.at index pairResolutions |> Optional.toAbort

appendCode : Text -> Float -> Float ->{Exception} (Text, Float)
appendCode code adjCoord placeValue =
  Abort.toGenericException "appendCode failed!" "error" do
    digitValue = floor (adjCoord / placeValue) |> Float.toNat |> Optional.toAbort
    newAdjCoord = adjCoord - (toFloat digitValue * placeValue)
    char = charAt digitValue codeAlphabet |> Optional.toAbort
    (code ++ Char.toText char, newAdjCoord)

Here Abort.toGenericException is an ability handler that will raise a generic exception whenever an abort is encountered in the code. It’s tempting to think of this as a try/catch block, but it’s better to try and think of it in Unison terms.

So let’s see how this works:

myProgram : '{IO, Exception} ()
myProgram = do
    printLine (encode 20.375 2.775 6)
    printLine (encode 20.3700625 2.7821875 10)

This should give us two successively smaller and more precise regions of space somewhere on the planet. The first call to encode wants 6 characters in the code and the second wants 10. If I’ve done this properly, the more precise code should have the same prefix as the less precise, and the number of digits that differ represents the difference in precision/size of the grid box.

.> run myProgram
7FG49Q00+
7FG49QCJ+2V

Both of these codes are within the region defined by 7FG49Q, a small spot in southern Algeria. You can look up any plus code by putting it on the plus.codes URL, e.g. https://plus.codes/7FG49Q00+.

Next Steps

My next steps are to write the decode portion of the library. Decoding an open location code involves working backwards over the base-20 alphabet and producing a “code area”, which is defined by the GPS coordinate of the southwest corner and latitude and longitude resolution.

I also need to write tests on my code, because I know it’s super flaky. Right now if you ask for any code over 10 digits it breaks. It’s going to be another long, arduous journey to get to the point where I’m ready to publish my PlusCode library on Unison share.

I will, of course, continue blogging about this journey as I force myself to write more Unison code by porting the PlusCode library.