Ok, so yesterday I joked a bit about Gustav (which, by the way, is now tracking closer to Lafayette) and about writing some ColdFusion code to track the hurricane. Being the OCD-natured kind of boy I am, I whipped up something in ColdFusion that seems to work well. Here is what I did...
I began by grabbing the feed data for Gustav:
<cfset gustavXML = "http://www.nhc.noaa.gov/nhc_at2.xml">
<cffeed source="#gustavXML#" query="results">
Next (and I should be clear, the full code is at the bottom and has more error checking in it) I used query of query to find the first entry with Tropical Storm GUSTAV Public Advisory Number in the title:
<!--- find "Public Advisory" --->
<cfquery name="pa" dbtype="query" maxrows="1">
select rsslink, content, title
from results
where title like 'Tropical Storm GUSTAV Public Advisory Number%'
</cfquery>
NOAA's RSS feed entries don't have much content in them - they mainly just link to the full text, so I retrieve the content next:
<cfhttp url="#pa.rsslink#" result="result">
<cfset text = result.fileContent>
Ok, now comes the pain in the rear part. If the feed looks like I assume it will (and that's not a good assumption) then it will have this text:
CENTER OF TROPICAL STORM GUSTAV WAS LOCATED NEAR LATITUDE
So I wrote up a regex that attempts to find the longitude and latitude from the text:
<!--- strip extra white space --->
<cfset text = reReplace(text, "[\r\n]+", " ", "all")>
<cfset regex = "CENTER OF TROPICAL STORM GUSTAV WAS LOCATED NEAR LATITUDE ([[:digit:].]+)[[:space:]]([NORTH|SOUTH|EAST|WEST]+)...LONGITUDE ([[:digit:].]+)[[:space:]]([NORTH|SOUTH|EAST|WEST]+)">
<!--- now look for: THE CENTER OF TROPICAL STORM GUSTAV WAS LOCATED NEAR LATITUDE 19.1 NORTH...LONGITUDE 74.4 WEST --->
<cfset match = reFind(regex, text, 1, true)>
<cfif arrayLen(match.pos) is 5>
<cfset long = mid(text, match.pos[2], match.len[2])>
<cfset longdir = mid(text, match.pos[3], match.len[3])>
<cfset lat = mid(text, match.pos[4], match.len[4])>
<cfset latdir = mid(text, match.pos[5], match.len[5])>
<cfoutput>
Gustav Lat: #lat# #latdir#<br />
Gustav Long: #long# #longdir#<br />
</cfoutput>
So there isn't anything too complex in here. You can see I'm using subexpressions to grab the values I want. I get the directions too but they aren't really necessary since the numeric values tell you all you need. Again, ignore the fact that I have a missing <cfelse> there - I'm just skipping over the error handling for now.
Ok, so that was the hard part. Now I need the longitude and latitude for Lafayette. I could use the Yahoo Weather API for this. The weather results include longitude and latitude information. Since they use a simple REST interface (hey Google, Yahoo called, they want to talk about writing APIs that developers actually enjoy using) I could have just opened up the feed in my browser and copied the values. Instead though I made it dynamic:
<cfset zip = 70508>
<cfoutput><p/>Getting #zip#<br /></cfoutput>
<cfflush>
<cfhttp url="http://weather.yahooapis.com/forecastrss?p=#zip#" result="result">
<cfset content = xmlParse(result.fileContent)>
<cfset geo = xmlSearch(content, "//geo:*")>
<cfloop index="g" array="#geo#">
<cfif g.xmlName is "geo:lat">
<cfset myLat = g.xmlText>
<cfelseif g.xmlName is "geo:long">
<cfset myLong = g.xmlText>
</cfif>
</cfloop>
This is fairly typical XML parsing using xmlSearch. I could have used my CFYahoo package but I wanted a quick and dirty script.
Ok, now all the hard work is done. Serious! All I need to do is get a function to generate the distance between two zips. Once again, CFLib comes to the rescue: LatLonDist. So the last portion is rather simple:
<cfset distance = latLonDist(lat,long,myLat,myLong,"sm")>
<cfoutput><p/><b>Distance:</b> #numberFormat(distance,"9.99")# miles</cfoutput>
Surprisingly, the code actually works, and worked fine from last night till tonight. My main concern is the string parsing, but so far, so good. Now for the real fun part. I added some simple logging to the script. I'm going to use my localhost scheduler to run the script every 4 hours. I'll then write a script to parse the log and see how the distance changes between now and Tuesday, or as I call it, Get the Hell Out of Dodge Day.
Anyway, here is the final script with error handling, logging, etc. Enjoy.
<cfscript>
/**
* Calculates the distance between two latitudes and longitudes.
* This funciton uses forumlae from Ed Williams Aviation Foundry website at http://williams.best.vwh.net/avform.htm.
*
* @param lat1 Latitude of the first point in degrees. (Required)
* @param lon1 Longitude of the first point in degrees. (Required)
* @param lat2 Latitude of the second point in degrees. (Required)
* @param lon2 Longitude of the second point in degrees. (Required)
* @param units Unit to return distance in. Options are: km (kilometers), sm (statute miles), nm (nautical miles), or radians. (Required)
* @return Returns a number or an error string.
* @author Tom Nunamaker (tom@toshop.com)
* @version 1, May 14, 2002
*/
function LatLonDist(lat1,lon1,lat2,lon2,units) {
// Check to make sure latitutdes and longitudes are valid
if(lat1 GT 90 OR lat1 LT -90 OR
lon1 GT 180 OR lon1 LT -180 OR
lat2 GT 90 OR lat2 LT -90 OR
lon2 GT 280 OR lon2 LT -280) {
Return ("Incorrect parameters");
}
lat1 = lat1 * pi()/180;
lon1 = lon1 * pi()/180;
lat2 = lat2 * pi()/180;
lon2 = lon2 * pi()/180;
UnitConverter = 1.150779448; //standard is statute miles
if(units eq 'nm') {
UnitConverter = 1.0;
}
if(units eq 'km') {
UnitConverter = 1.852;
}
distance = 2*asin(sqr((sin((lat1-lat2)/2))^2 + cos(lat1)cos(lat2)(sin((lon1-lon2)/2))^2)); //radians
if(units neq 'radians'){
distance = UnitConverter * 60 * distance * 180/pi();
}
Return (distance) ;
}
</cfscript>
<!--- quickie log func --->
<cffunction name="logit" output="false" returnType="void">
<cfargument name="str" type="string" required="true">
<cflog file="gustav" text="#arguments.str#">
</cffunction>
<!--- Lafayette, LA --->
<cfset zip = 70508>
<cfset gustavXML = "http://www.nhc.noaa.gov/nhc_at2.xml">
<cffeed source="#gustavXML#" query="results">
<cfif not results.recordCount>
<cfset logit("Error - no feed entries")>
<cfoutput>No feed entries.</cfoutput>
<cfabort>
</cfif>
<!--- find "Public Advisory" --->
<cfquery name="pa" dbtype="query" maxrows="1">
select rsslink, content, title
from results
where title like 'Tropical Storm GUSTAV Public Advisory Number%'
</cfquery>
<cfif not pa.recordCount>
<cfset logit("Error - cound't find a matching entry")>
<cfoutput>Couldn't find an entry that matched my criteria.</cfoutput>
<cfabort>
</cfif>
<cfoutput>
Parsing data from: <b>#pa.title#</b><br />
</cfoutput>
<cfflush />
<cfhttp url="#pa.rsslink#" result="result">
<cfset text = result.fileContent>
<!--- strip extra white space --->
<cfset text = reReplace(text, "[\r\n]+", " ", "all")>
<cfset regex = "CENTER OF TROPICAL STORM GUSTAV WAS LOCATED NEAR LATITUDE ([[:digit:].]+)[[:space:]]([NORTH|SOUTH|EAST|WEST]+)...LONGITUDE ([[:digit:].]+)[[:space:]]([NORTH|SOUTH|EAST|WEST]+)">
<!--- now look for: THE CENTER OF TROPICAL STORM GUSTAV WAS LOCATED NEAR LATITUDE 19.1 NORTH...LONGITUDE 74.4 WEST --->
<cfset match = reFind(regex, text, 1, true)>
<cfif arrayLen(match.pos) is 5>
<cfset long = mid(text, match.pos[2], match.len[2])>
<cfset longdir = mid(text, match.pos[3], match.len[3])>
<cfset lat = mid(text, match.pos[4], match.len[4])>
<cfset latdir = mid(text, match.pos[5], match.len[5])>
<cfoutput>
Gustav Lat: #lat# #latdir#<br />
Gustav Long: #long# #longdir#<br />
</cfoutput>
<cfelse>
<cfset logit("Error - couldn't find my matches in the string")>
<cfoutput>Couldn't find my matches in the string.</cfoutput>
<cfabort>
</cfif>
<cfoutput><p/>Getting #zip#<br /></cfoutput>
<cfflush>
<cfhttp url="http://weather.yahooapis.com/forecastrss?p=#zip#" result="result">
<cfset content = xmlParse(result.fileContent)>
<cfset geo = xmlSearch(content, "//geo:*")>
<cfloop index="g" array="#geo#">
<cfif g.xmlName is "geo:lat">
<cfset myLat = g.xmlText>
<cfelseif g.xmlName is "geo:long">
<cfset myLong = g.xmlText>
</cfif>
</cfloop>
<!--- only continue if we have our own stuff --->
<cfif not isDefined("myLat") or not isDefined("myLong")>
<cfset logit("Error - no geo data for #zip#")>
<cfoutput>No data for #zip#</cfoutput>
<cfabort />
</cfif>
<cfoutput>
#zip# lat: #myLat#<br />
#zip# long: #myLong#<br />
</cfoutput>
<cfset distance = latLonDist(lat,long,myLat,myLong,"sm")>
<cfset logit("distance:#distance#")>
<cfoutput><p/><b>Distance:</b> #numberFormat(distance,"9.99")# miles</cfoutput>