Project 1: CLI Web Data App

I’ve been aware of NASA’s open APIs for a while, and I’ve always wanted to do something with them, so when the instructions for our first Flatiron project were to use web data either via scraping or API, I immediately went there for resources.

I should clarify: I’ve been aware of APIs in general, having some experience with coding, and I sort of knew what they did, but I had never actually worked with an API. And generally speaking, when designing a project, popular wisdom dictates you should always begin with the question: “What problem am I solving?” I did not do this. I knew I wanted to use a NASA API, and I was determined to do so.

class MartianWeather
    attr_accessor :sol, :date, :season, :avgtemp, :hightemp, :lowtemp, :avgws, :highws, :lowws, :winddir, :pres

    url = "https://api.nasa.gov/insight_weather/?api_key=&feedtype=json&ver=1.0"
    uri = URI(url)
    response = Net::HTTP.get(uri)
    @@api_data = JSON.parse(response, symbolize_names: true)

    @@all = []
    @@forecast = []

    def initialize
    end

    def self.create_instances
        @@api_data.each do |s| 
            if s[0] != :sol_keys
                if s[0] != :validity_checks
                    o = self.new
                    o.sol = s[0].to_s
                    o.date = s[1][:Last_UTC].split("T").first
                    o.season = s[1][:Season]
                    o.avgtemp = o.c_to_f(s[1][:AT][:av]).round()
                    o.hightemp = o.c_to_f(s[1][:AT][:mx]).round()
                    o.lowtemp = o.c_to_f(s[1][:AT][:mn]).round()
                    o.avgws = o.mps_to_mph(s[1][:HWS][:av]).round()
                    o.highws = o.mps_to_mph(s[1][:HWS][:mx]).round()
                    o.lowws = o.mps_to_mph(s[1][:HWS][:mn]).round()
                    o.winddir = s[1][:WD][:most_common][:compass_point]
                    o.pres = o.pa_to_hpa(s[1][:PRE][:av]).round(2)
                    o.save
                end
            end
        end
    end
 

Extracting data from the Mars InSight API was relatively easy. Since it was my first time messing with APIs I had a little confusion figuring out which parts of the data were hashes vs arrays, but by the end of this project I felt really comfortable figuring out what’s what in an API hash.

At this point in the project I had satisfied my own personal desire to do something with NASA data, and I had it all worked out in my MartianWeather class. But what to do with it?

I decided to make make a program that compares Earth weather data with Martian weather data. Why you ask? To help you feel better about your life, because at least you’re not living on Mars where the temperature is -107°F. And I wanted to add some dark humor to it — make it playful.

My first task in comparing Martian weather to Earth weather was to acquire the Earth weather data. Openweatherdata.org has really good, free APIs for this kind of thing. The problem from a user experience point of view, however, is that you call these APIs with latitude and longitude data to get your locale. Programs that get local data for you don’t generally ask you for your latitude and longitude. If this were a mobile app, it could get it from your GPS, but for this simple CLI I had to come up with something else. I found an API that provides lat-longs based on U.S. zip codes, so that ended up being sort of the crux of the Earth-side of my program.

class LatLongCreator

    attr_accessor :zip, :latitude, :longitude, :city, :state

    def self.create_latlong_from_zip(zip)
        @zip = zip
        url = "https://public.opendatasoft.com/api/records/1.0/search/?dataset=us-zip-code-latitude-and-longitude&q=#&facet=state&facet=timezone&facet=dst"
        uri = URI(url)
        response = Net::HTTP.get(uri)
        @@api_data = JSON.parse(response, symbolize_names: true)
        unless @@api_data[:nhits] == 0
            @latitude = @@api_data[:records].first[:fields][:latitude]
            @longitude = @@api_data[:records].first[:fields][:longitude]
            @city = @@api_data[:records].first[:fields][:city]
            @state = @@api_data[:records].first[:fields][:state]
            latlong = [@latitude, @longitude, @city, @state]
        end
    end
end
 

Above is my Latlong_Creator class. The user experience of accessing Earth weather data via your zip code would not work without this class and this API. It returns an array of latitude, longitude, city and state. I call that method from my CLI, store the city and state data for CLI purposes, and then I feed the lat-longs to my EarthWeather class by interpolating them into the API endpoint calls from that class. For example, the forecast API:

https://api.openweathermap.org/data/2.5/onecall?lat=#{lat}&lon=#{long}&units=imperial&exclude={part}&appid=3ef2f9e27db06e5523669088cdd44570

Here you can see how my variables “lat” and “long” are being interpolated into the API url.

In terms of order of operations, I actually did the historical Earth weather data first in order to compliment the historical data from Mars I already had. It turns out that you can only call one day of data at a time from this API. So I called this API six times (for five days of historical data and one for current weather), each time interpolating the lat-longs, but I also had to interpolate the time in Unix time that I wanted the data to start from. I accomplished this with the following expression:

i = 0
        6.times do
            time = (Time.now - (86400*i)-1000).to_i

The way I separated data by days was by using the magic number 86400. What is this number? The number of seconds in a day. So, in Ruby, Time.now - 86400 basically means “this time yesterday.” So, by multiplying the index by 86400 and subtracting it from Time.now I was able to make six different calls to the API, each one day apart (give or take however many fractions of a second had passed between each call). Here is the full method .create_instances for creating the historical and current Earth data:

def self.create_instances(lat,long, city, state)
        @@all = [] #only storing one zip at a time, clears all
        i = 0
        6.times do
            time = (Time.now - (86400*i)-1000).to_i #converts to unix #-1000 to account for difference in clocks, can't be in future
            o = self.new
            url = "http://api.openweathermap.org/data/2.5/onecall/timemachine?lat=#&appid=3ef2f9e27db06e5523669088cdd44570"
            #api call defaults units to imperial
            get_data(url)
            o.date = Time.at(@@api_data[:current][:dt]).to_s.split(" ").first
            o.season = o.get_season(Time.at(@@api_data[:current][:dt]))
            o.lat = lat
            o.long = long
            o.city = city
            o.state = state
            o.winddir = o.convert_wind_deg_to_dir(@@api_data[:current][:wind_deg])
            o.avgtemp = @@api_data[:current][:temp].round()
            o.status = @@api_data[:current][:weather].first[:description]
            o.avgws = @@api_data[:current][:wind_speed].round()
            o.pres = @@api_data[:current][:pressure]
            o.save
            i += 1
        end

    end
 

From there I had to create the forecast data for both Earth and Mars. Earth was easy. There was a single API that would return all of that data, again from the lat-longs that my LatlongCreator class had created. The Martian data, however, was non-existent. So I made it up! I iterated through the existing, real Martian data, and then created a new dataset by randomizing the values slightly.

def self.create_forecast
        #dependent on .create_instances having been called
        directions = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W",
                         "WNW", "NW", "NNW", "N"]
        
        @@all.each.with_index(1) do |d, i|
            o = self.new
            o.sol = (get_current_sol+i).to_s
            o.date = (Time.now+86400*i).to_s.split(" ").first
            o.season = @@all.last.season
            o.avgtemp = d.avgtemp+(rand(-10..10))
            o.hightemp = d.hightemp+(rand(-10..10))
            o.lowtemp = d.lowtemp+(rand(-10..10))
            o.avgws = d.avgws+(rand(-10..10))
            o.highws = d.highws+(rand(-10..10))
            o.lowws = d.lowws+(rand(-10..10))
            o.winddir = directions[rand(0..directions.length-1)]
            o.pres = (d.pres+(rand(-10..10))).round(2)
            @@forecast << o
        end
    end
 

In the above, you can see that I just accessed existing data of each MartianWeather object, and then called things like “rand(-10..10) on them. This is saying, “Take the original value and add a random value between -10 and 10 to it.” By doing this, I create what appears to be a dataset that is unique, yet also resembles actual Martian data (even though it’s not real). I also used “.each.with_index(1)” in order to create dates that correspond to the new, made-up data.

From there, there were a ton of little things I had to do to make all of the data comparable to each other. On the Earth data, I simply set the units of measurement to imperial (keeping in mind that my lat-longs only work on U.S. zip codes, so it wouldn’t make sense to use any measurement other than imperial) in the actual API call for Earth weather.

With the Martian data, I had to do a few conversions. I also had to do a few conversions with other Earth data. For example, the Martian wind direction data from the NASA API are strings representing compass directions, like “ENE” or “WNW.” The Earth data was in compass degrees, and that was kind of tricky to convert. In order to fix that problem, I made a method in EarthWeather that converts compass degrees to compass directions.

def convert_wind_deg_to_dir(degrees)
        d = 22.5
        winddir = ""
        case
            when degrees <= d
                winddir = "N"
            when degrees < d*2 && degrees >= d
                winddir = "NNE"
            when degrees < d*3 && degrees >= d*2
                winddir = "NE"
            when degrees < d*4 && degrees >= d*3
                winddir = "ENE"
            when degrees < d*5 && degrees >= d*4
                winddir = "E"
            when degrees < d*6 && degrees >= d*5
                winddir = "ESE"
            when degrees < d*7 && degrees >= d*6
                winddir = "SE"
            when degrees < d*8 && degrees >= d*7
                winddir = "SSE"
            when degrees < d*9 && degrees >= d*8
                winddir = "S"
            when degrees < d*10 && degrees >= d*9
                winddir = "SSW"
            when degrees < d*11 && degrees >= d*10
                winddir = "SW"
            when degrees < d*12 && degrees >= d*11
                winddir = "WSW"
            when degrees < d*13 && degrees >= d*12
                winddir = "W"
            when degrees < d*14 && degrees >= d*13
                winddir = "WNW"
            when degrees < d*15 && degrees >= d*14
                winddir = "NW"
            when degrees < d*16 && degrees >= d*15
                winddir = "NNW"
            when degrees >= d*16
                winddir = "N"
        end
        winddir
      end

Unfortunately, there was no good way to avoid hard-coding direction strings (at least that I could think of), but at least I got them to correlate to degrees in kind of a clever way.

There were a ton of other little quests I had to accomplish to wrap all of this up in a coherent way, but I won’t go into ever single one. The last step was creating my CLI (command-line interface). That part was pretty straightforward. My CLI class has a “start” method that gets called on initialize. The first thing it does it display a cute, sarcastic welcome that I wrote and ask you your zip code. From there it makes all the API calls to gather the Martian and Earth data (8 API calls in addition to the zip code converter — Remember I had to call the archived Earth data 6 times).

Then it compares your current weather with current Martian weather, and then displays a menu of options:

main_menu
        puts "Please select from the following options:"
        print "\n"
        puts "1. Change zip code"
        puts "2. Martian forecast"
        puts "3. Earth forecast"
        puts "4. Martian archived weather data"
        puts "5. Earth archived weather data"
        puts "6. Current Martian weather"
        puts "7. Current Earth weather"
        puts "8. Exit"

To correlate to that, I had to write a slue of methods to compare all the data in various ways. But, it was super easy to do that, because I had already set up all my data as objects!

This project was super rewarding. I got to play with NASA data, but most importantly I was really feeling comfortable with object-oriented Ruby, and with manipulating APIs. Here is a little walkthrough of my program (with a bad mic… working on that).