Thursday, October 24, 2013

Weather Underground for historical tides & weather

In a recent (current) project, volunteers enter the results of surveys along the beach. As part of the survey, they are to note the weather conditions: visibility clean or limited, cloud cover percentage, temperature, precipitation yes/no, etc. They are also to note the height of the tide at that time.

Big bonus points, if I can make it look up the data at that time, date, and location, and have it auto-fill the boxes for them.

Most APIs are for Forecasts

It's easy enough to find a weather forecasting API. NOAA is one of several, and the GFS GRIB2 files can be had if you're hardcore. But those are low-resolution forecasts: what about yesterday's weather or the day before, and for a specific time instead of morning/mid/evening breakdowns?

And what about tides? Tide forecasts exist, but typically as full reports and not a readily-usable API. And you have to request a specific tide station, while we have simply a raw lat & long and no ready way to detect the nearest tide station. Besides, these are tide forecasts when we need yesterday's observations.

Weather Underground

Weather Underground has an API, and unlike others theirs goes into the past. Awesome. They also offer both weather observations and tide observations. And they have a free tier, limited to 500 hits per day. For our use case, this is way over a realistic usage for us, as these are office staff entering forms, not the general public hammering us with every hit.

So, step 1: sign up for an API key. Sign up for one that includes tides, and be sure to enable the History option when you sign up.


Making a Request / Creating an AJAX Endpoint

A request looks like this:
http://api.wunderground.com/api/APIKEY/history_YYYYMMDD/q/LAT,LON.json
Slot in the date, lat & lon, and your API key, and get back JSON. Dead simple.

In our case, I set up an AJAX endpoint written in PHP (CodeIgniter). It looks like this:
public function ajax_conditions($placeid,$date,$starttime) {
    header('Content-type: text/javascript');

    // validation: make sure they have access to this place ID, date and time filled in, etc.
    if (! preg_match('/^\d{8}$/', $date) )        return print json_encode(array('error'=>'Invalid date'));
    if (! preg_match('/^\d{4}$/', $starttime) )   return print json_encode(array('error'=>'Invalid starting time'));

    // code here translates between a $placeid and a lat / lon
    // yours will be very specific to your application

    // make the requests to wunderground for weather conditions and tide conditions
    $output = array();
    $latlon = $place->centroid();
    $time   = mktime((integer) substr($starttime,0,2), (integer) substr($starttime,2,2), 0, (integer) substr($date,4,2), (integer) substr($date,6,2), (integer) substr($date,0,4) );

    $weather_url = sprintf("http://api.wunderground.com/api/%s/history_%s/q/%f,%f.json", $this->config->item('wunderground_api_key'), $date, $latlon->lat, $latlon->lon );
    $weather_info = @json_decode(file_get_contents($weather_url));
    if (! @$weather_info->history) return print json_encode(array('error'=>'Could not get weather history. Sorry.'));

    $tides_url = sprintf("http://api.wunderground.com/api/%s/rawtide_%s/q/%f,%f.json", $this->config->item('wunderground_api_key'), $date, $latlon->lat, $latlon->lon );
    $tides_info = @json_decode(file_get_contents($tides_url));
    if (! @$tides_info->rawtide) return print json_encode(array('error'=>'Could not get tide history. Sorry.'));

    // weather as $weather_observation
    // go over the observations, find the one closest to the given $time
    // step 1: go over them, add a timedelta attribute, push onto a list
    $observations = array();
    foreach ($weather_info->history->observations as $observation) {
        $year = (integer) $observation->date->year;
        $mon  = (integer) $observation->date->mon;
        $mday = (integer) $observation->date->mday;
        $hour = (integer) $observation->date->hour;
        $min  = (integer) $observation->date->min;

        $obstime = mktime($hour, $min, 0, $mon, $mday, $year);
        $observation->timedelta = abs($obstime - $time);
        $observations[] = $observation;
    }
    // step 2: sort by timedelta, best observation is element [0] from the sorted list
    usort($observations,array($this,'_sort_by_timedelta'));
    $weather_observation = $observations[0];

    // tides as $tide_observation
    // step 1: go over them, add a timedelta attribute, push onto a list
    $observations = array();
    foreach ($tides_info->rawtide->rawTideObs as $observation) {
        $obstime = (integer) $observation->epoch;
        $observation->timedelta = abs($obstime - $time);
        $observations[] = $observation;
    }
    // step 2: sort by timedelta, best observation is element [0] from the sorted list
    usort($observations,array($this,'_sort_by_timedelta'));
    $tide_observation = $observations[0];

    // ta-da, we now have one observation for tides and one for weather
    // and they're the closest ones we have to the stated time

    // see below for code which massages the data into the desired output format

    // all set, hand it back!
    return print json_encode($output);
}

Some neat points here:
  • As is my usual fashion, I use sprintf() and preg_match() extensively for validating the input, and check for errors. Otherwise, some wise guy can supply invalid params and make nasty-looking requests to wunderground on my behalf (hack attempts from my server? no thanks!), or even generate an error which causes PHP to tell him what URL was used... including my API key.
  • The return from wunderground is in JSON, and that's just super simple to parse. The return to the client is also in JSON, because it's super simple to generate.
  • The trick to finding the correct forecast for the time I have in mind, is to figure out the "time delta" between each forecast and the target time. One can then use usort() to sort by time delta, and slice off the first element of the array. That being the lowest time delta, it's the closest to the target time.

A Little More On The Endpoint

Now, the endpoint does go a step further. The browser end of the app doesn't want the raw numbers, per se, but the simplified, digested version. They want the following:
  • air temperature in F
  • a simple yes/no about precipitation
  • a simple perfect/limited for visibility
  • a percentage cloud cover, even if estimated
  • the Beaufort measurement of the wind
  • the height of the tide in feet, including a prefixed + if it's >0
In the code above, you see the "code which massages"  Well, here it is:
    // compose output: weather
    $output['weather'] = array();
    $output['weather']['airtemperature'] = round( (float) $weather_observation->tempi );
    $output['weather']['precipitation'] = 'no';
    if ( (integer) $weather_observation->fog ) $output['weather']['precipitation'] = 'yes';
    if ( (integer) $weather_observation->rain) $output['weather']['precipitation'] = 'yes';
    if ( (integer) $weather_observation->snow) $output['weather']['precipitation'] = 'yes';
    if ( (integer) $weather_observation->hail) $output['weather']['precipitation'] = 'yes';
    $output['weather']['visibility'] = 'perfect';
    if ( (integer) $weather_observation->fog ) $output['weather']['visibility'] = 'limited';
    $output['weather']['clouds'] = 'clear';
    if ((string) $weather_observation->icon == 'mostlysunny')  $output['weather']['clouds'] = '20% cover';
    if ((string) $weather_observation->icon == 'partlycloudy') $output['weather']['clouds'] = '30% cover';
    if ((string) $weather_observation->icon == 'partlysunny')  $output['weather']['clouds'] = '50% cover';
    if ((string) $weather_observation->icon == 'mostlycloudy') $output['weather']['clouds'] = '80% cover';
    if ((string) $weather_observation->icon == 'cloudy')       $output['weather']['clouds'] = '100% cover';
    $output['weather']['beaufort'] = '1';
    if ( (float) $weather_observation->wspdi >=  4.0) $output['weather']['beaufort'] = '2';
    if ( (float) $weather_observation->wspdi >=  8.0) $output['weather']['beaufort'] = '3';
    if ( (float) $weather_observation->wspdi >= 13.0) $output['weather']['beaufort'] = '4';
    if ( (float) $weather_observation->wspdi >= 18.0) $output['weather']['beaufort'] = '5';
    if ( (float) $weather_observation->wspdi >= 25.0) $output['weather']['beaufort'] = '6';
    if ( (float) $weather_observation->wspdi >= 31.0) $output['weather']['beaufort'] = '7';
    if ( (float) $weather_observation->wspdi >= 39.0) $output['weather']['beaufort'] = '8';
    if ( (float) $weather_observation->wspdi >= 47.0) $output['weather']['beaufort'] = '9';
    if ( (float) $weather_observation->wspdi >= 55.0) $output['weather']['beaufort'] = '10';
    if ( (float) $weather_observation->wspdi >= 64.0) $output['weather']['beaufort'] = '11';
    if ( (float) $weather_observation->wspdi >= 74.0) $output['weather']['beaufort'] = '12';

    // compose output: tides
    // be sure to format it with a + and - sign as is normal for tide levels
    $output['tide'] = array();
    $output['tide']['time'] = date('G:ia', (integer) $tide_observation->epoch );
    $output['tide']['height'] = (float) $tide_observation->height;
    $output['tide']['height'] = sprintf("%s%.1f", $output['tide']['height'] < 0 ? '-' : '+', abs($output['tide']['height']) );
    $output['tide']['site']   = (string) $tides_info->rawtide->tideInfo[0]->tideSite;
The end result is exactly the fields they want, corresponding to the fields in the form.

Speaking of the Form...

Using jQuery. the additions to the form are relatively simple. It's a simple GET request, with the URL contrived to contain the /placeid/date/starttime parameters.
// make an AJAX call to fetch the weather conditions at the given place, date, and times
// along with disclaimer and credits per wunderground's TOU
function fetchWeatherConditions() {
    // remove the : from HH:MM and the - from YYYY-MM-DD
    var starttime = jQuery('#form input[name="time_start"]').val().replace(/:/g,'');
    var date      = jQuery('#form input[name="date"]').val().replace(/\-/g,'');
    var placeid   = jQuery('#form select[name="site"]').val();
    var url = BASE_URL + 'ajax/fetch_conditions/' + placeid + '/' + date + '/' + starttime;

    jQuery('#dialog_waiting').dialog('open');
    jQuery.get(url, {}, function (reply) {
        jQuery('#dialog_waiting').dialog('close');
        if (! reply) return alert("Error");
        if (reply.error) return alert(reply.error);

        jQuery('#form select[name="clouds"]').val(reply.weather.clouds);
        jQuery('#form select[name="precipitation"]').val(reply.weather.precipitation);
        jQuery('#form input[name="airtemperature"]').val(reply.weather.airtemperature);
        jQuery('#form select[name="beaufort"]').val(reply.weather.beaufort);
        jQuery('#form input[name="tidelevel"]').val(reply.tide.height);
        jQuery('#form select[name="visibility"]').val(reply.weather.visibility);

        // show the attribution & disclaimer
        jQuery('#dialog_wunderground_tideinfo').text('Tide information: ' + reply.tide.site + ' @ ' + reply.tide.time);
        jQuery('#dialog_wunderground').dialog('open');
    }, 'json').error(function () {;
        jQuery('#dialog_waiting').dialog('close');
        alert("Could not contact the server to load conditions.\nMaybe you have lost data connection?");
    });
}
Notes here:
  • I like to open a "please wait" dialog, because it can be 2-3 seconds as we get back a response.
  • The fields returned exactly fit those in the form. It's quite nice.
  • After populating the fields, I open a jQuery UI Dialog showing an attribution to wunderground, and mentioning where the tide data comes from since it may be several miles away from the actual location.

Conclusion

Unlike most other weather and tide prediction APIs, Weather Underground keeps historical records, and supplies both tides and weather in one simple API. And with their free tier, they really made my day... and our clients'.

No comments:

Post a Comment

Note: Only a member of this blog may post a comment.