a8ff613425eac1b16d53fac81d12aa55e105bb37
[weather.git] / weather.py
1 """Contains various object definitions needed by the weather utility."""
2
3 weather_copyright = """\
4 # Copyright (c) 2006-2016 Jeremy Stanley <fungi@yuggoth.org>. Permission to
5 # use, copy, modify, and distribute this software is granted under terms
6 # provided in the LICENSE file distributed with this software.
7 #"""
8
9 weather_version = "2.2"
10
11 radian_to_km = 6372.795484
12 radian_to_mi = 3959.871528
13
14 def pyversion(ref=None):
15     """Determine the Python version and optionally compare to a reference."""
16     import platform
17     ver = platform.python_version()
18     if ref:
19         return [
20             int(x) for x in ver.split(".")[:2]
21         ] >= [
22             int(x) for x in ref.split(".")[:2]
23         ]
24     else: return ver
25
26 class Selections:
27     """An object to contain selection data."""
28     def __init__(self):
29         """Store the config, options and arguments."""
30         self.config = get_config()
31         self.options, self.arguments = get_options(self.config)
32         if self.get_bool("cache") and self.get_bool("cache_search") \
33             and not self.get_bool("longlist"):
34             integrate_search_cache(
35                 self.config,
36                 self.get("cachedir"),
37                 self.get("setpath")
38             )
39         if not self.arguments:
40             if "id" in self.options.__dict__ \
41                 and self.options.__dict__["id"]:
42                 self.arguments.append( self.options.__dict__["id"] )
43                 del( self.options.__dict__["id"] )
44                 import sys
45                 message = "WARNING: the --id option is deprecated and will eventually be removed\n"
46                 sys.stderr.write(message)
47             elif "city" in self.options.__dict__ \
48                 and self.options.__dict__["city"] \
49                 and "st" in self.options.__dict__ \
50                 and self.options.__dict__["st"]:
51                 self.arguments.append(
52                     "^%s city, %s" % (
53                         self.options.__dict__["city"],
54                         self.options.__dict__["st"]
55                     )
56                 )
57                 del( self.options.__dict__["city"] )
58                 del( self.options.__dict__["st"] )
59                 import sys
60                 message = "WARNING: the --city/--st options are deprecated and will eventually be removed\n"
61                 sys.stderr.write(message)
62     def get(self, option, argument=None):
63         """Retrieve data from the config or options."""
64         if argument:
65             if self.config.has_section(argument) and (
66                 self.config.has_option(argument, "city") \
67                     or self.config.has_option(argument, "id") \
68                     or self.config.has_option(argument, "st")
69             ):
70                 self.config.remove_section(argument)
71                 import sys
72                 message = "WARNING: the city/id/st options are now unsupported in aliases\n"
73                 sys.stderr.write(message)
74             if not self.config.has_section(argument):
75                 guessed = guess(
76                     argument,
77                     path=self.get("setpath"),
78                     info=self.get("info"),
79                     cache_search=(
80                         self.get("cache") and self.get("cache_search")
81                     ),
82                     cachedir=self.get("cachedir"),
83                     quiet=self.get_bool("quiet")
84                 )
85                 self.config.add_section(argument)
86                 for item in guessed.items():
87                     self.config.set(argument, *item)
88             if self.config.has_option(argument, option):
89                 return self.config.get(argument, option)
90         if option in self.options.__dict__:
91             return self.options.__dict__[option]
92         else:
93             import os, sys
94             message = "%s error: no URI defined for %s\n" % (
95                 os.path.basename( sys.argv[0] ),
96                 option
97             )
98             sys.stderr.write(message)
99             exit(1)
100     def get_bool(self, option, argument=None):
101         """Get data and coerce to a boolean if necessary."""
102         return bool(self.get(option, argument))
103     def getint(self, option, argument=None):
104         """Get data and coerce to an integer if necessary."""
105         value = self.get(option, argument)
106         if value: return int(value)
107         else: return 0
108
109 def average(coords):
110     """Average a list of coordinates."""
111     x = 0
112     y = 0
113     for coord in coords:
114         x += coord[0]
115         y += coord[1]
116     count = len(coords)
117     return (x/count, y/count)
118
119 def filter_units(line, units="imperial"):
120     """Filter or convert units in a line of text between US/UK and metric."""
121     import re
122     # filter lines with both pressures in the form of "X inches (Y hPa)" or
123     # "X in. Hg (Y hPa)"
124     dual_p = re.match(
125         "(.* )(\d*(\.\d+)? (inches|in\. Hg)) \((\d*(\.\d+)? hPa)\)(.*)",
126         line
127     )
128     if dual_p:
129         preamble, in_hg, i_fr, i_un, hpa, h_fr, trailer = dual_p.groups()
130         if units == "imperial": line = preamble + in_hg + trailer
131         elif units == "metric": line = preamble + hpa + trailer
132     # filter lines with both temperatures in the form of "X F (Y C)"
133     dual_t = re.match(
134         "(.* )(-?\d*(\.\d+)? F) \((-?\d*(\.\d+)? C)\)(.*)",
135         line
136     )
137     if dual_t:
138         preamble, fahrenheit, f_fr, celsius, c_fr, trailer = dual_t.groups()
139         if units == "imperial": line = preamble + fahrenheit + trailer
140         elif units == "metric": line = preamble + celsius + trailer
141     # if metric is desired, convert distances in the form of "X mile(s)" to
142     # "Y kilometer(s)"
143     if units == "metric":
144         imperial_d = re.match(
145             "(.* )(\d+)( mile\(s\))(.*)",
146             line
147         )
148         if imperial_d:
149             preamble, mi, m_u, trailer = imperial_d.groups()
150             line = preamble + str(int(round(int(mi)*1.609344))) \
151                 + " kilometer(s)" + trailer
152     # filter speeds in the form of "X MPH (Y KT)" to just "X MPH"; if metric is
153     # desired, convert to "Z KPH"
154     imperial_s = re.match(
155         "(.* )(\d+)( MPH)( \(\d+ KT\))(.*)",
156         line
157     )
158     if imperial_s:
159         preamble, mph, m_u, kt, trailer = imperial_s.groups()
160         if units == "imperial": line = preamble + mph + m_u + trailer
161         elif units == "metric": 
162             line = preamble + str(int(round(int(mph)*1.609344))) + " KPH" + \
163                 trailer
164     imperial_s = re.match(
165         "(.* )(\d+)( MPH)( \(\d+ KT\))(.*)",
166         line
167     )
168     if imperial_s:
169         preamble, mph, m_u, kt, trailer = imperial_s.groups()
170         if units == "imperial": line = preamble + mph + m_u + trailer
171         elif units == "metric": 
172             line = preamble + str(int(round(int(mph)*1.609344))) + " KPH" + \
173                 trailer
174     # if imperial is desired, qualify given forcast temperatures like "X F"; if
175     # metric is desired, convert to "Y C"
176     imperial_t = re.match(
177         "(.* )(High |high |Low |low )(\d+)(\.|,)(.*)",
178         line
179     )
180     if imperial_t:
181         preamble, parameter, fahrenheit, sep, trailer = imperial_t.groups()
182         if units == "imperial":
183             line = preamble + parameter + fahrenheit + " F" + sep + trailer
184         elif units == "metric":
185             line = preamble + parameter \
186                 + str(int(round((int(fahrenheit)-32)*5/9))) + " C" + sep \
187                 + trailer
188     # hand off the resulting line
189     return line
190
191 def get_uri(
192     uri,
193     ignore_fail=False,
194     cache_data=False,
195     cacheage=900,
196     cachedir="."
197 ):
198     """Return a string containing the results of a URI GET."""
199     if pyversion("3"):
200         import urllib, urllib.error, urllib.request
201         URLError = urllib.error.URLError
202         urlopen = urllib.request.urlopen
203     else:
204         import urllib2 as urllib
205         URLError = urllib.URLError
206         urlopen = urllib.urlopen
207     import os, time
208     if cache_data:
209         dcachedir = os.path.join( os.path.expanduser(cachedir), "datacache" )
210         if not os.path.exists(dcachedir):
211             try: os.makedirs(dcachedir)
212             except (IOError, OSError): pass
213         dcache_fn = os.path.join(
214             dcachedir,
215             uri.split(":")[1].replace("/","_")
216         )
217     now = time.time()
218     if cache_data and os.access(dcache_fn, os.R_OK) \
219         and now-cacheage < os.stat(dcache_fn).st_mtime <= now:
220         dcache_fd = open(dcache_fn)
221         data = dcache_fd.read()
222         dcache_fd.close()
223     else:
224         try:
225             if pyversion("3"): data = urlopen(uri).read().decode("utf-8")
226             else: data = urlopen(uri).read()
227         except URLError:
228             if ignore_fail: return ""
229             else:
230                 import os, sys, traceback
231                 message = "%s error: failed to retrieve\n   %s\n   %s" % (
232                         os.path.basename( sys.argv[0] ),
233                         uri,
234                         traceback.format_exception_only(
235                             sys.exc_type,
236                             sys.exc_value
237                         )[0]
238                     )
239                 sys.stderr.write(message)
240                 sys.exit(1)
241         if cache_data:
242             try:
243                 import codecs
244                 dcache_fd = codecs.open(dcache_fn, "w", "utf-8")
245                 dcache_fd.write(data)
246                 dcache_fd.close()
247             except (IOError, OSError): pass
248     return data
249
250 def get_metar(
251     uri=None,
252     verbose=False,
253     quiet=False,
254     headers=None,
255     imperial=False,
256     metric=False,
257     cache_data=False,
258     cacheage=900,
259     cachedir="."
260 ):
261     """Return a summarized METAR for the specified station."""
262     if not uri:
263         import os, sys
264         message = "%s error: METAR URI required for conditions\n" % \
265             os.path.basename( sys.argv[0] )
266         sys.stderr.write(message)
267         sys.exit(1)
268     metar = get_uri(
269         uri,
270         cache_data=cache_data,
271         cacheage=cacheage,
272         cachedir=cachedir
273     )
274     if pyversion("3") and type(metar) is bytes: metar = metar.decode("utf-8")
275     if verbose: return metar
276     else:
277         import re
278         lines = metar.split("\n")
279         if not headers:
280             headers = \
281                 "relative_humidity," \
282                 + "precipitation_last_hour," \
283                 + "sky conditions," \
284                 + "temperature," \
285                 + "heat index," \
286                 + "windchill," \
287                 + "weather," \
288                 + "wind"
289         headerlist = headers.lower().replace("_"," ").split(",")
290         output = []
291         if not quiet:
292             title = "Current conditions at %s"
293             place = lines[0].split(", ")
294             if len(place) > 1:
295                 place = "%s, %s" % ( place[0].title(), place[1] )
296             else: place = "<UNKNOWN>"
297             output.append(title%place)
298             output.append("Last updated " + lines[1])
299         header_match = False
300         for header in headerlist:
301             for line in lines:
302                 if line.lower().startswith(header + ":"):
303                     if re.match(r".*:\d+$", line): line = line[:line.rfind(":")]
304                     if imperial: line = filter_units(line, units="imperial")
305                     elif metric: line = filter_units(line, units="metric")
306                     if quiet: output.append(line)
307                     else: output.append("   " + line)
308                     header_match = True
309         if not header_match:
310             output.append(
311                 "(no conditions matched your header list, try with --verbose)"
312             )
313         return "\n".join(output)
314
315 def get_alert(
316     uri=None,
317     verbose=False,
318     quiet=False,
319     cache_data=False,
320     cacheage=900,
321     cachedir="."
322 ):
323     """Return alert notice for the specified URI."""
324     if not uri:
325         import os, sys
326         message = "%s error: Alert URI required for alerts\n" % \
327             os.path.basename( sys.argv[0] )
328         sys.stderr.write(message)
329         sys.exit(1)
330     alert = get_uri(
331         uri,
332         ignore_fail=True,
333         cache_data=cache_data,
334         cacheage=cacheage,
335         cachedir=cachedir
336     ).strip()
337     if pyversion("3") and type(alert) is bytes: alert = alert.decode("utf-8")
338     if alert:
339         if verbose: return alert
340         else:
341             if alert.find("\nNATIONAL WEATHER SERVICE") == -1:
342                 muted = False
343             else:
344                 muted = True
345             lines = alert.split("\n")
346             import time
347             valid_time = time.strftime("%Y%m%d%H%M")
348             output = []
349             for line in lines:
350                 if line.startswith("Expires:") \
351                     and "Expires:" + valid_time > line:
352                     return ""
353                 if muted and line.startswith("NATIONAL WEATHER SERVICE"):
354                     muted = False
355                     line = ""
356                 elif line == "&&":
357                     line = ""
358                 elif line == "$$":
359                     muted = True
360                 if line and not muted:
361                     if quiet: output.append(line)
362                     else: output.append("   " + line)
363             return "\n".join(output)
364
365 def get_options(config):
366     """Parse the options passed on the command line."""
367
368     # for optparse's builtin -h/--help option
369     usage = \
370         "usage: %prog [options] [alias1|search1 [alias2|search2 [...]]]"
371
372     # for optparse's builtin --version option
373     verstring = "%prog " + weather_version
374
375     # create the parser
376     import optparse
377     option_parser = optparse.OptionParser(usage=usage, version=verstring)
378     # separate options object from list of arguments and return both
379
380     # the -a/--alert option
381     if config.has_option("default", "alert"):
382         default_alert = bool(config.get("default", "alert"))
383     else: default_alert = False
384     option_parser.add_option("-a", "--alert",
385         dest="alert",
386         action="store_true",
387         default=default_alert,
388         help="include local alert notices")
389
390     # the --atypes option
391     if config.has_option("default", "atypes"):
392         default_atypes = config.get("default", "atypes")
393     else:
394         default_atypes = \
395             "coastal_flood_statement," \
396             + "flash_flood_statement," \
397             + "flash_flood_warning," \
398             + "flash_flood_watch," \
399             + "flood_statement," \
400             + "flood_warning," \
401             + "marine_weather_statement," \
402             + "river_statement," \
403             + "severe_thunderstorm_warning," \
404             + "severe_weather_statement," \
405             + "short_term_forecast," \
406             + "special_marine_warning," \
407             + "special_weather_statement," \
408             + "tornado_warning," \
409             + "urgent_weather_message"
410     option_parser.add_option("--atypes",
411         dest="atypes",
412         default=default_atypes,
413         help="list of alert notification types to display")
414
415     # the --build-sets option
416     option_parser.add_option("--build-sets",
417         dest="build_sets",
418         action="store_true",
419         default=False,
420         help="(re)build location correlation sets")
421
422     # the --cacheage option
423     if config.has_option("default", "cacheage"):
424         default_cacheage = config.getint("default", "cacheage")
425     else: default_cacheage = 900
426     option_parser.add_option("--cacheage",
427         dest="cacheage",
428         default=default_cacheage,
429         help="duration in seconds to refresh cached data")
430
431     # the --cachedir option
432     if config.has_option("default", "cachedir"):
433         default_cachedir = config.get("default", "cachedir")
434     else: default_cachedir = "~/.weather"
435     option_parser.add_option("--cachedir",
436         dest="cachedir",
437         default=default_cachedir,
438         help="directory for storing cached searches and data")
439
440     # the -f/--forecast option
441     if config.has_option("default", "forecast"):
442         default_forecast = bool(config.get("default", "forecast"))
443     else: default_forecast = False
444     option_parser.add_option("-f", "--forecast",
445         dest="forecast",
446         action="store_true",
447         default=default_forecast,
448         help="include a local forecast")
449
450     # the --headers option
451     if config.has_option("default", "headers"):
452         default_headers = config.get("default", "headers")
453     else:
454         default_headers = \
455             "temperature," \
456             + "relative_humidity," \
457             + "wind," \
458             + "heat_index," \
459             + "windchill," \
460             + "weather," \
461             + "sky_conditions," \
462             + "precipitation_last_hour"
463     option_parser.add_option("--headers",
464         dest="headers",
465         default=default_headers,
466         help="list of conditions headers to display")
467
468     # the --imperial option
469     if config.has_option("default", "imperial"):
470         default_imperial = bool(config.get("default", "imperial"))
471     else: default_imperial = False
472     option_parser.add_option("--imperial",
473         dest="imperial",
474         action="store_true",
475         default=default_imperial,
476         help="filter/convert conditions for US/UK units")
477
478     # the --info option
479     option_parser.add_option("--info",
480         dest="info",
481         action="store_true",
482         default=False,
483         help="output detailed information for your search")
484
485     # the -l/--list option
486     option_parser.add_option("-l", "--list",
487         dest="list",
488         action="store_true",
489         default=False,
490         help="list all configured aliases and cached searches")
491
492     # the --longlist option
493     option_parser.add_option("--longlist",
494         dest="longlist",
495         action="store_true",
496         default=False,
497         help="display details of all configured aliases")
498
499     # the -m/--metric option
500     if config.has_option("default", "metric"):
501         default_metric = bool(config.get("default", "metric"))
502     else: default_metric = False
503     option_parser.add_option("-m", "--metric",
504         dest="metric",
505         action="store_true",
506         default=default_metric,
507         help="filter/convert conditions for metric units")
508
509     # the -n/--no-conditions option
510     if config.has_option("default", "conditions"):
511         default_conditions = bool(config.get("default", "conditions"))
512     else: default_conditions = True
513     option_parser.add_option("-n", "--no-conditions",
514         dest="conditions",
515         action="store_false",
516         default=default_conditions,
517         help="disable output of current conditions")
518
519     # the --no-cache option
520     if config.has_option("default", "cache"):
521         default_cache = bool(config.get("default", "cache"))
522     else: default_cache = True
523     option_parser.add_option("--no-cache",
524         dest="cache",
525         action="store_false",
526         default=True,
527         help="disable all caching (searches and data)")
528
529     # the --no-cache-data option
530     if config.has_option("default", "cache_data"):
531         default_cache_data = bool(config.get("default", "cache_data"))
532     else: default_cache_data = True
533     option_parser.add_option("--no-cache-data",
534         dest="cache_data",
535         action="store_false",
536         default=True,
537         help="disable retrieved data caching")
538
539     # the --no-cache-search option
540     if config.has_option("default", "cache_search"):
541         default_cache_search = bool(config.get("default", "cache_search"))
542     else: default_cache_search = True
543     option_parser.add_option("--no-cache-search",
544         dest="cache_search",
545         action="store_false",
546         default=True,
547         help="disable search result caching")
548
549     # the -q/--quiet option
550     if config.has_option("default", "quiet"):
551         default_quiet = bool(config.get("default", "quiet"))
552     else: default_quiet = False
553     option_parser.add_option("-q", "--quiet",
554         dest="quiet",
555         action="store_true",
556         default=default_quiet,
557         help="skip preambles and don't indent")
558
559     # the --setpath option
560     if config.has_option("default", "setpath"):
561         default_setpath = config.get("default", "setpath")
562     else: default_setpath = ".:~/.weather"
563     option_parser.add_option("--setpath",
564         dest="setpath",
565         default=default_setpath,
566         help="directory search path for correlation sets")
567
568     # the -v/--verbose option
569     if config.has_option("default", "verbose"):
570         default_verbose = bool(config.get("default", "verbose"))
571     else: default_verbose = False
572     option_parser.add_option("-v", "--verbose",
573         dest="verbose",
574         action="store_true",
575         default=default_verbose,
576         help="show full decoded feeds")
577
578     # deprecated options
579     if config.has_option("default", "city"):
580         default_city = config.get("default", "city")
581     else: default_city = ""
582     option_parser.add_option("-c", "--city",
583         dest="city",
584         default=default_city,
585         help=optparse.SUPPRESS_HELP)
586     if config.has_option("default", "id"):
587         default_id = config.get("default", "id")
588     else: default_id = ""
589     option_parser.add_option("-i", "--id",
590         dest="id",
591         default=default_id,
592         help=optparse.SUPPRESS_HELP)
593     if config.has_option("default", "st"):
594         default_st = config.get("default", "st")
595     else: default_st = ""
596     option_parser.add_option("-s", "--st",
597         dest="st",
598         default=default_st,
599         help=optparse.SUPPRESS_HELP)
600
601     options, arguments = option_parser.parse_args()
602     return options, arguments
603
604 def get_config():
605     """Parse the aliases and configuration."""
606     if pyversion("3"): import configparser
607     else: import ConfigParser as configparser
608     config = configparser.ConfigParser()
609     import os
610     rcfiles = [
611         "/etc/weatherrc",
612         "/etc/weather/weatherrc",
613         os.path.expanduser("~/.weather/weatherrc"),
614         os.path.expanduser("~/.weatherrc"),
615         "weatherrc"
616         ]
617     for rcfile in rcfiles:
618         if os.access(rcfile, os.R_OK): config.read(rcfile)
619     for section in config.sections():
620         if section != section.lower():
621             if config.has_section(section.lower()):
622                 config.remove_section(section.lower())
623             config.add_section(section.lower())
624             for option,value in config.items(section):
625                 config.set(section.lower(), option, value)
626     return config
627
628 def integrate_search_cache(config, cachedir, setpath):
629     """Add cached search results into the configuration."""
630     if pyversion("3"): import configparser
631     else: import ConfigParser as configparser
632     import os, time
633     scache_fn = os.path.join( os.path.expanduser(cachedir), "searches" )
634     if not os.access(scache_fn, os.R_OK): return config
635     scache_fd = open(scache_fn)
636     created = float( scache_fd.readline().split(":")[1].strip().split()[0] )
637     scache_fd.close()
638     now = time.time()
639     datafiles = data_index(setpath)
640     if datafiles:
641         data_freshness = sorted(
642             [ x[1] for x in datafiles.values() ],
643             reverse=True
644         )[0]
645     else: data_freshness = now
646     if created < data_freshness <= now:
647         try:
648             os.remove(scache_fn)
649             print( "[clearing outdated %s]" % scache_fn )
650         except (IOError, OSError):
651             pass
652         return config
653     scache = configparser.ConfigParser()
654     scache.read(scache_fn)
655     for section in scache.sections():
656         if not config.has_section(section):
657             config.add_section(section)
658             for option,value in scache.items(section):
659                 config.set(section, option, value)
660     return config
661
662 def list_aliases(config, detail=False):
663     """Return a formatted list of aliases defined in the config."""
664     if detail:
665         output = "\n# configured alias details..."
666         for section in sorted(config.sections()):
667             output += "\n\n[%s]" % section
668             for item in sorted(config.items(section)):
669                 output += "\n%s = %s" % item
670         output += "\n"
671     else:
672         output = "configured aliases and cached searches..."
673         for section in sorted(config.sections()):
674             if config.has_option(section, "description"):
675                 description = config.get(section, "description")
676             else: description = "(no description provided)"
677             output += "\n   %s: %s" % (section, description)
678     return output
679
680 def data_index(path):
681     import os
682     datafiles = {}
683     for filename in ("airports", "places", "stations", "zctas", "zones"):
684         for dirname in path.split(":"):
685             for extension in ("", ".gz", ".txt"):
686                 candidate = os.path.expanduser(
687                     os.path.join( dirname, "".join( (filename, extension) ) )
688                 )
689                 if os.path.exists(candidate):
690                     datafiles[filename] = (
691                         candidate,
692                         os.stat(candidate).st_mtime
693                     )
694                     break
695             if filename in datafiles:
696                 break
697     return datafiles
698
699 def guess(
700     expression,
701     path=".",
702     max_results=20,
703     info=False,
704     cache_search=False,
705     cacheage=900,
706     cachedir=".",
707     quiet=False
708 ):
709     """Find URIs using airport, gecos, placename, station, ZCTA/ZIP, zone."""
710     import codecs, datetime, time, os, re, sys
711     if pyversion("3"): import configparser
712     else: import ConfigParser as configparser
713     datafiles = data_index(path)
714     if re.match("[A-Za-z]{3}$", expression): searchtype = "airport"
715     elif re.match("[A-Za-z0-9]{4}$", expression): searchtype = "station"
716     elif re.match("[A-Za-z]{2}[Zz][0-9]{3}$", expression): searchtype = "zone"
717     elif re.match("[0-9]{5}$", expression): searchtype = "ZCTA"
718     elif re.match(
719         r"[\+-]?\d+(\.\d+)?(-\d+){,2}[ENSWensw]?, *[\+-]?\d+(\.\d+)?(-\d+){,2}[ENSWensw]?$",
720         expression
721     ):
722         searchtype = "coordinates"
723     elif re.match(r"(FIPS|fips)\d+$", expression): searchtype = "FIPS"
724     else:
725         searchtype = "name"
726         cache_search = False
727     if cache_search: action = "caching"
728     else: action = "using"
729     if info:
730         scores = [
731             (0.005, "bad"),
732             (0.025, "poor"),
733             (0.160, "suspect"),
734             (0.500, "mediocre"),
735             (0.840, "good"),
736             (0.975, "great"),
737             (0.995, "excellent"),
738             (1.000, "ideal"),
739         ]
740     if not quiet: print("Searching via %s..."%searchtype)
741     stations = configparser.ConfigParser()
742     dataname = "stations"
743     if dataname in datafiles:
744         datafile = datafiles[dataname][0]
745         if datafile.endswith(".gz"):
746             import gzip
747             stations.readfp( gzip.open(datafile) )
748         else:
749             stations.read(datafile)
750     else:
751         message = "%s error: can't find \"%s\" data file\n" % (
752             os.path.basename( sys.argv[0] ),
753             dataname
754         )
755         sys.stderr.write(message)
756         exit(1)
757     zones = configparser.ConfigParser()
758     dataname = "zones"
759     if dataname in datafiles:
760         datafile = datafiles[dataname][0]
761         if datafile.endswith(".gz"):
762             import gzip
763             zones.readfp( gzip.open(datafile) )
764         else:
765             zones.read(datafile)
766     else:
767         message = "%s error: can't find \"%s\" data file\n" % (
768             os.path.basename( sys.argv[0] ),
769             dataname
770         )
771         sys.stderr.write(message)
772         exit(1)
773     search = None
774     station = ("", 0)
775     zone = ("", 0)
776     dataset = None
777     possibilities = []
778     uris = {}
779     if searchtype == "airport":
780         expression = expression.lower()
781         airports = configparser.ConfigParser()
782         dataname = "airports"
783         if dataname in datafiles:
784             datafile = datafiles[dataname][0]
785             if datafile.endswith(".gz"):
786                 import gzip
787                 airports.readfp( gzip.open(datafile) )
788             else:
789                 airports.read(datafile)
790         else:
791             message = "%s error: can't find \"%s\" data file\n" % (
792                 os.path.basename( sys.argv[0] ),
793                 dataname
794             )
795             sys.stderr.write(message)
796             exit(1)
797         if airports.has_section(expression) \
798             and airports.has_option(expression, "station"):
799             search = (expression, "IATA/FAA airport code %s" % expression)
800             station = ( airports.get(expression, "station"), 0 )
801             if stations.has_option(station[0], "zone"):
802                 zone = eval( stations.get(station[0], "zone") )
803                 dataset = stations
804             if not ( info or quiet ) \
805                 and stations.has_option( station[0], "description" ):
806                 print(
807                     "[%s result %s]" % (
808                         action,
809                         stations.get(station[0], "description")
810                     )
811                 )
812         else:
813             message = "No IATA/FAA airport code \"%s\" in the %s file.\n" % (
814                 expression,
815                 datafiles["airports"][0]
816             )
817             sys.stderr.write(message)
818             exit(1)
819     elif searchtype == "station":
820         expression = expression.lower()
821         if stations.has_section(expression):
822             station = (expression, 0)
823             if not search:
824                 search = (expression, "ICAO station code %s" % expression)
825             if stations.has_option(expression, "zone"):
826                 zone = eval( stations.get(expression, "zone") )
827                 dataset = stations
828             if not ( info or quiet ) \
829                 and stations.has_option(expression, "description"):
830                 print(
831                     "[%s result %s]" % (
832                         action,
833                         stations.get(expression, "description")
834                     )
835                 )
836         else:
837             message = "No ICAO weather station \"%s\" in the %s file.\n" % (
838                 expression,
839                 datafiles["stations"][0]
840             )
841             sys.stderr.write(message)
842             exit(1)
843     elif searchtype == "zone":
844         expression = expression.lower()
845         if zones.has_section(expression) \
846             and zones.has_option(expression, "station"):
847             zone = (expression, 0)
848             station = eval( zones.get(expression, "station") )
849             dataset = zones
850             search = (expression, "NWS/NOAA weather zone %s" % expression)
851             if not ( info or quiet ) \
852                 and zones.has_option(expression, "description"):
853                 print(
854                     "[%s result %s]" % (
855                         action,
856                         zones.get(expression, "description")
857                     )
858                 )
859         else:
860             message = "No usable NWS weather zone \"%s\" in the %s file.\n" % (
861                 expression,
862                 datafiles["zones"][0]
863             )
864             sys.stderr.write(message)
865             exit(1)
866     elif searchtype == "ZCTA":
867         zctas = configparser.ConfigParser()
868         dataname = "zctas"
869         if dataname in datafiles:
870             datafile = datafiles[dataname][0]
871             if datafile.endswith(".gz"):
872                 import gzip
873                 zctas.readfp( gzip.open(datafile) )
874             else:
875                 zctas.read(datafile)
876         else:
877             message = "%s error: can't find \"%s\" data file\n" % (
878                 os.path.basename( sys.argv[0] ),
879                 dataname
880             )
881             sys.stderr.write(message)
882             exit(1)
883         dataset = zctas
884         if zctas.has_section(expression) \
885             and zctas.has_option(expression, "station"):
886             station = eval( zctas.get(expression, "station") )
887             search = (expression, "Census ZCTA (ZIP code) %s" % expression)
888             if zctas.has_option(expression, "zone"):
889                 zone = eval( zctas.get(expression, "zone") )
890         else:
891             message = "No census ZCTA (ZIP code) \"%s\" in the %s file.\n" % (
892                 expression,
893                 datafiles["zctas"][0]
894             )
895             sys.stderr.write(message)
896             exit(1)
897     elif searchtype == "coordinates":
898         search = (expression, "Geographic coordinates %s" % expression)
899         stationtable = {}
900         for station in stations.sections():
901             if stations.has_option(station, "location"):
902                 stationtable[station] = {
903                     "location": eval( stations.get(station, "location") )
904                 }
905         station = closest( gecos(expression), stationtable, "location", 0.1 )
906         if not station[0]:
907             message = "No ICAO weather station found near %s.\n" % expression
908             sys.stderr.write(message)
909             exit(1)
910         zonetable = {}
911         for zone in zones.sections():
912             if zones.has_option(zone, "centroid"):
913                 zonetable[zone] = {
914                     "centroid": eval( zones.get(zone, "centroid") )
915                 }
916         zone = closest( gecos(expression), zonetable, "centroid", 0.1 )
917         if not zone[0]:
918             message = "No NWS weather zone near %s; forecasts unavailable.\n" \
919                 % expression
920             sys.stderr.write(message)
921     elif searchtype in ("FIPS", "name"):
922         places = configparser.ConfigParser()
923         dataname = "places"
924         if dataname in datafiles:
925             datafile = datafiles[dataname][0]
926             if datafile.endswith(".gz"):
927                 import gzip
928                 places.readfp( gzip.open(datafile) )
929             else:
930                 places.read(datafile)
931         else:
932             message = "%s error: can't find \"%s\" data file\n" % (
933                 os.path.basename( sys.argv[0] ),
934                 dataname
935             )
936             sys.stderr.write(message)
937             exit(1)
938         dataset = places
939         place = expression.lower()
940         if places.has_section(place) and places.has_option(place, "station"):
941             station = eval( places.get(place, "station") )
942             search = (expression, "Census Place %s" % expression)
943             if places.has_option(place, "description"):
944                 search = (
945                     search[0],
946                     search[1] + ", %s" % places.get(place, "description")
947                 )
948             if places.has_option(place, "zone"):
949                 zone = eval( places.get(place, "zone") )
950             if not ( info or quiet ) \
951                 and places.has_option(place, "description"):
952                 print(
953                     "[%s result %s]" % (
954                         action,
955                         places.get(place, "description")
956                     )
957                 )
958         else:
959             for place in places.sections():
960                 if places.has_option(place, "description") \
961                     and places.has_option(place, "station") \
962                     and re.search(
963                         expression,
964                         places.get(place, "description"),
965                         re.I
966                     ):
967                         possibilities.append(place)
968             for place in stations.sections():
969                 if stations.has_option(place, "description") \
970                     and re.search(
971                         expression,
972                         stations.get(place, "description"),
973                         re.I
974                     ):
975                         possibilities.append(place)
976             for place in zones.sections():
977                 if zones.has_option(place, "description") \
978                     and zones.has_option(place, "station") \
979                     and re.search(
980                         expression,
981                         zones.get(place, "description"),
982                         re.I
983                     ):
984                         possibilities.append(place)
985             if len(possibilities) == 1:
986                 place = possibilities[0]
987                 if places.has_section(place):
988                     station = eval( places.get(place, "station") )
989                     description = places.get(place, "description")
990                     if places.has_option(place, "zone"):
991                         zone = eval( places.get(place, "zone" ) )
992                     search = ( expression, "%s: %s" % (place, description) )
993                 elif stations.has_section(place):
994                     station = (place, 0.0)
995                     description = stations.get(place, "description")
996                     if stations.has_option(place, "zone"):
997                         zone = eval( stations.get(place, "zone" ) )
998                     search = ( expression, "ICAO station code %s" % place )
999                 elif zones.has_section(place):
1000                     station = eval( zones.get(place, "station") )
1001                     description = zones.get(place, "description")
1002                     zone = (place, 0.0)
1003                     search = ( expression, "NWS/NOAA weather zone %s" % place )
1004                 if not ( info or quiet ):
1005                     print( "[%s result %s]" % (action, description) )
1006             if not possibilities and not station[0]:
1007                 message = "No FIPS code/census area match in the %s file.\n" % (
1008                     datafiles["places"][0]
1009                 )
1010                 sys.stderr.write(message)
1011                 exit(1)
1012     if station[0]:
1013         uris["metar"] = stations.get( station[0], "metar" )
1014         if zone[0]:
1015             for key,value in zones.items( zone[0] ):
1016                 if key not in ("centroid", "description", "station"):
1017                     uris[key] = value
1018     elif possibilities:
1019         count = len(possibilities)
1020         if count <= max_results:
1021             print( "Your search is ambiguous, returning %s matches:" % count )
1022             for place in sorted(possibilities):
1023                 if places.has_section(place):
1024                     print(
1025                         "   [%s] %s" % (
1026                             place,
1027                             places.get(place, "description")
1028                         )
1029                     )
1030                 elif stations.has_section(place):
1031                     print(
1032                         "   [%s] %s" % (
1033                             place,
1034                             stations.get(place, "description")
1035                         )
1036                     )
1037                 elif zones.has_section(place):
1038                     print(
1039                         "   [%s] %s" % (
1040                             place,
1041                             zones.get(place, "description")
1042                         )
1043                     )
1044         else:
1045             print(
1046                 "Your search is too ambiguous, returning %s matches." % count
1047             )
1048         exit(0)
1049     if info:
1050         stationlist = []
1051         zonelist = []
1052         if dataset:
1053             for section in dataset.sections():
1054                 if dataset.has_option(section, "station"):
1055                     stationlist.append(
1056                         eval( dataset.get(section, "station") )[1]
1057                     )
1058                 if dataset.has_option(section, "zone"):
1059                     zonelist.append( eval( dataset.get(section, "zone") )[1] )
1060         stationlist.sort()
1061         zonelist.sort()
1062         scount = len(stationlist)
1063         zcount = len(zonelist)
1064         sranks = []
1065         zranks = []
1066         for score in scores:
1067             if stationlist:
1068                 sranks.append( stationlist[ int( (1-score[0]) * scount ) ] )
1069             if zonelist:
1070                 zranks.append( zonelist[ int( (1-score[0]) * zcount ) ] )
1071         description = search[1]
1072         uris["description"] = description
1073         print(
1074             "%s\n%s" % ( description, "-" * len(description) )
1075         )
1076         print(
1077             "%s: %s" % (
1078                 station[0],
1079                 stations.get( station[0], "description" )
1080             )
1081         )
1082         km = radian_to_km*station[1]
1083         mi = radian_to_mi*station[1]
1084         if sranks and not description.startswith("ICAO station code "):
1085             for index in range(0, len(scores)):
1086                 if station[1] >= sranks[index]:
1087                     score = scores[index][1]
1088                     break
1089             print(
1090                 "   (proximity %s, %.3gkm, %.3gmi)" % ( score, km, mi )
1091             )
1092         elif searchtype is "coordinates":
1093             print( "   (%.3gkm, %.3gmi)" % (km, mi) )
1094         if zone[0]:
1095             print(
1096                 "%s: %s" % ( zone[0], zones.get( zone[0], "description" ) )
1097             )
1098         km = radian_to_km*zone[1]
1099         mi = radian_to_mi*zone[1]
1100         if zranks and not description.startswith("NWS/NOAA weather zone "):
1101             for index in range(0, len(scores)):
1102                 if zone[1] >= zranks[index]:
1103                     score = scores[index][1]
1104                     break
1105             print(
1106                 "   (proximity %s, %.3gkm, %.3gmi)" % ( score, km, mi )
1107             )
1108         elif searchtype is "coordinates" and zone[0]:
1109             print( "   (%.3gkm, %.3gmi)" % (km, mi) )
1110     if cache_search:
1111         now = time.time()
1112         nowstamp = "%s (%s)" % (
1113             now,
1114             datetime.datetime.isoformat(
1115                 datetime.datetime.fromtimestamp(now),
1116                 " "
1117             )
1118         )
1119         search_cache = ["\n"]
1120         search_cache.append( "[%s]\n" % search[0] ) 
1121         search_cache.append( "description = cached %s\n" % nowstamp )
1122         for uriname in sorted(uris.keys()):
1123             search_cache.append( "%s = %s\n" % ( uriname, uris[uriname] ) )
1124         real_cachedir = os.path.expanduser(cachedir)
1125         if not os.path.exists(real_cachedir):
1126             try: os.makedirs(real_cachedir)
1127             except (IOError, OSError): pass
1128         scache_fn = os.path.join(real_cachedir, "searches")
1129         if not os.path.exists(scache_fn):
1130             then = sorted(
1131                     [ x[1] for x in datafiles.values() ],
1132                     reverse=True
1133                 )[0]
1134             thenstamp = "%s (%s)" % (
1135                 then,
1136                 datetime.datetime.isoformat(
1137                     datetime.datetime.fromtimestamp(then),
1138                     " "
1139                 )
1140             )
1141             search_cache.insert(
1142                 0,
1143                 "# based on data files from: %s\n" % thenstamp
1144             )
1145         try:
1146             scache_existing = configparser.ConfigParser()
1147             scache_existing.read(scache_fn)
1148             if not scache_existing.has_section(search[0]):
1149                 scache_fd = codecs.open(scache_fn, "a", "utf-8")
1150                 scache_fd.writelines(search_cache)
1151                 scache_fd.close()
1152         except (IOError, OSError): pass
1153     if not info:
1154         return(uris)
1155
1156 def closest(position, nodes, fieldname, angle=None):
1157     import math
1158     if not angle: angle = 2*math.pi
1159     match = None
1160     for name in nodes:
1161         if fieldname in nodes[name]:
1162             node = nodes[name][fieldname]
1163             if node and abs( position[0]-node[0] ) < angle:
1164                 if abs( position[1]-node[1] ) < angle \
1165                     or abs( abs( position[1]-node[1] ) - 2*math.pi ) < angle:
1166                     if position == node:
1167                         angle = 0
1168                         match = name
1169                     else:
1170                         candidate = math.acos(
1171                             math.sin( position[0] ) * math.sin( node[0] ) \
1172                                 + math.cos( position[0] ) \
1173                                 * math.cos( node[0] ) \
1174                                 * math.cos( position[1] - node[1] )
1175                             )
1176                         if candidate < angle:
1177                             angle = candidate
1178                             match = name
1179     if match: match = str(match)
1180     return (match, angle)
1181
1182 def gecos(formatted):
1183     import math, re
1184     coordinates = formatted.split(",")
1185     for coordinate in range(0, 2):
1186         degrees, foo, minutes, bar, seconds, hemisphere = re.match(
1187             r"([\+-]?\d+\.?\d*)(-(\d+))?(-(\d+))?([ensw]?)$",
1188             coordinates[coordinate].strip().lower()
1189         ).groups()
1190         value = float(degrees)
1191         if minutes: value += float(minutes)/60
1192         if seconds: value += float(seconds)/3600
1193         if hemisphere and hemisphere in "sw": value *= -1
1194         coordinates[coordinate] = math.radians(value)
1195     return tuple(coordinates)
1196
1197 def correlate():
1198     import codecs, datetime, hashlib, os, re, sys, tarfile, time, zipfile
1199     if pyversion("3"): import configparser
1200     else: import ConfigParser as configparser
1201     gcounties_an = "2015_Gaz_counties_national.zip"
1202     gcounties_fn = "2015_Gaz_counties_national.txt"
1203     gcousubs_an = "2015_Gaz_cousubs_national.zip"
1204     gcousubs_fn = "2015_Gaz_cousubs_national.txt"
1205     gplace_an = "2015_Gaz_place_national.zip"
1206     gplace_fn = "2015_Gaz_place_national.txt"
1207     gzcta_an = "2015_Gaz_zcta_national.zip"
1208     gzcta_fn = "2015_Gaz_zcta_national.txt"
1209     for filename in os.listdir("."):
1210         if re.match("bp[0-9][0-9][a-z][a-z][0-9][0-9].dbx$", filename):
1211             cpfzcf_fn = filename
1212             break
1213     nsdcccc_fn = "nsd_cccc.txt"
1214     zcatalog_an = "zonecatalog.curr.tar"
1215     metartbl_fn = "metar.tbl"
1216     coopstn_fn = "coop-stations.txt"
1217     overrides_fn = "overrides.conf"
1218     overrideslog_fn = "overrides.log"
1219     slist_fn = "slist"
1220     zlist_fn = "zlist"
1221     qalog_fn = "qa.log"
1222     airports_fn = "airports"
1223     places_fn = "places"
1224     stations_fn = "stations"
1225     zctas_fn = "zctas"
1226     zones_fn = "zones"
1227     header = """\
1228 %s
1229 # generated by %s on %s from these public domain sources:
1230 #
1231 # http://www.census.gov/geo/maps-data/data/gazetteer2015.html
1232 # %s %s %s
1233 # %s %s %s
1234 # %s %s %s
1235 # %s %s %s
1236 #
1237 # http://www.weather.gov/geodata/catalog/wsom/html/cntyzone.htm
1238 # %s %s %s
1239 #
1240 # http://tgftp.nws.noaa.gov/data/nsd_cccc.txt
1241 # %s %s %s
1242 #
1243 # http://tgftp.nws.noaa.gov/data/zonecatalog.curr.tar
1244 # %s %s %s
1245 #
1246 # http://www.nco.ncep.noaa.gov/pmb/codes/nwprod/dictionaries/metar.tbl
1247 # %s %s %s
1248 #
1249 # http://www.ncdc.noaa.gov/homr/reports
1250 # %s %s %s
1251 #
1252 # ...and these manually-generated or hand-compiled adjustments:
1253 # %s %s %s
1254 # %s %s %s
1255 # %s %s %s\
1256 """ % (
1257         weather_copyright,
1258         os.path.basename( sys.argv[0] ),
1259         datetime.date.isoformat(
1260             datetime.datetime.fromtimestamp( time.time() )
1261         ),
1262         hashlib.md5( open(gcounties_an, "rb").read() ).hexdigest(),
1263         datetime.date.isoformat(
1264             datetime.datetime.fromtimestamp( os.path.getmtime(gcounties_an) )
1265         ),
1266         gcounties_an,
1267         hashlib.md5( open(gcousubs_an, "rb").read() ).hexdigest(),
1268         datetime.date.isoformat(
1269             datetime.datetime.fromtimestamp( os.path.getmtime(gcousubs_an) )
1270         ),
1271         gcousubs_an,
1272         hashlib.md5( open(gplace_an, "rb").read() ).hexdigest(),
1273         datetime.date.isoformat(
1274             datetime.datetime.fromtimestamp( os.path.getmtime(gplace_an) )
1275         ),
1276         gplace_an,
1277         hashlib.md5( open(gzcta_an, "rb").read() ).hexdigest(),
1278         datetime.date.isoformat(
1279             datetime.datetime.fromtimestamp( os.path.getmtime(gzcta_an) )
1280         ),
1281         gzcta_an,
1282         hashlib.md5( open(cpfzcf_fn, "rb").read() ).hexdigest(),
1283         datetime.date.isoformat(
1284             datetime.datetime.fromtimestamp( os.path.getmtime(cpfzcf_fn) )
1285         ),
1286         cpfzcf_fn,
1287         hashlib.md5( open(nsdcccc_fn, "rb").read() ).hexdigest(),
1288         datetime.date.isoformat(
1289             datetime.datetime.fromtimestamp( os.path.getmtime(nsdcccc_fn) )
1290         ),
1291         nsdcccc_fn,
1292         hashlib.md5( open(zcatalog_an, "rb").read() ).hexdigest(),
1293         datetime.date.isoformat(
1294             datetime.datetime.fromtimestamp( os.path.getmtime(zcatalog_an) )
1295         ),
1296         zcatalog_an,
1297         hashlib.md5( open(metartbl_fn, "rb").read() ).hexdigest(),
1298         datetime.date.isoformat(
1299             datetime.datetime.fromtimestamp( os.path.getmtime(metartbl_fn) )
1300         ),
1301         metartbl_fn,
1302         hashlib.md5( open(coopstn_fn, "rb").read() ).hexdigest(),
1303         datetime.date.isoformat(
1304             datetime.datetime.fromtimestamp( os.path.getmtime(coopstn_fn) )
1305         ),
1306         coopstn_fn,
1307         hashlib.md5( open(overrides_fn, "rb").read() ).hexdigest(),
1308         datetime.date.isoformat(
1309             datetime.datetime.fromtimestamp( os.path.getmtime(overrides_fn) )
1310         ),
1311         overrides_fn,
1312         hashlib.md5( open(slist_fn, "rb").read() ).hexdigest(),
1313         datetime.date.isoformat(
1314             datetime.datetime.fromtimestamp( os.path.getmtime(slist_fn) )
1315         ),
1316         slist_fn,
1317         hashlib.md5( open(zlist_fn, "rb").read() ).hexdigest(),
1318         datetime.date.isoformat(
1319             datetime.datetime.fromtimestamp( os.path.getmtime(zlist_fn) )
1320         ),
1321         zlist_fn
1322     )
1323     airports = {}
1324     places = {}
1325     stations = {}
1326     zctas = {}
1327     zones = {}
1328     message = "Reading %s:%s..." % (gcounties_an, gcounties_fn)
1329     sys.stdout.write(message)
1330     sys.stdout.flush()
1331     count = 0
1332     gcounties = zipfile.ZipFile(gcounties_an).open(gcounties_fn, "rU")
1333     columns = gcounties.readline().decode("latin1").strip().split("\t")
1334     for line in gcounties:
1335         fields = line.decode("latin1").strip().split("\t")
1336         f_geoid = fields[ columns.index("GEOID") ].strip()
1337         f_name = fields[ columns.index("NAME") ].strip()
1338         f_usps = fields[ columns.index("USPS") ].strip()
1339         f_intptlat = fields[ columns.index("INTPTLAT") ].strip()
1340         f_intptlong = fields[ columns.index("INTPTLONG") ].strip()
1341         if f_geoid and f_name and f_usps and f_intptlat and f_intptlong:
1342             fips = "fips%s" % f_geoid
1343             if fips not in places: places[fips] = {}
1344             places[fips]["centroid"] = gecos(
1345                 "%s,%s" % (f_intptlat, f_intptlong)
1346             )
1347             places[fips]["description"] = "%s, %s" % (f_name, f_usps)
1348             count += 1
1349     gcounties.close()
1350     print("done (%s lines)." % count)
1351     message = "Reading %s:%s..." % (gcousubs_an, gcousubs_fn)
1352     sys.stdout.write(message)
1353     sys.stdout.flush()
1354     count = 0
1355     gcousubs = zipfile.ZipFile(gcousubs_an).open(gcousubs_fn, "rU")
1356     columns = gcousubs.readline().decode("latin1").strip().split("\t")
1357     for line in gcousubs:
1358         fields = line.decode("latin1").strip().split("\t")
1359         f_geoid = fields[ columns.index("GEOID") ].strip()
1360         f_name = fields[ columns.index("NAME") ].strip()
1361         f_usps = fields[ columns.index("USPS") ].strip()
1362         f_intptlat = fields[ columns.index("INTPTLAT") ].strip()
1363         f_intptlong = fields[ columns.index("INTPTLONG") ].strip()
1364         if f_geoid and f_name and f_usps and f_intptlat and f_intptlong:
1365             fips = "fips%s" % f_geoid
1366             if fips not in places: places[fips] = {}
1367             places[fips]["centroid"] = gecos(
1368                 "%s,%s" % (f_intptlat, f_intptlong)
1369             )
1370             places[fips]["description"] = "%s, %s" % (f_name, f_usps)
1371             count += 1
1372     gcousubs.close()
1373     print("done (%s lines)." % count)
1374     message = "Reading %s:%s..." % (gplace_an, gplace_fn)
1375     sys.stdout.write(message)
1376     sys.stdout.flush()
1377     count = 0
1378     gplace = zipfile.ZipFile(gplace_an).open(gplace_fn, "rU")
1379     columns = gplace.readline().decode("latin1").strip().split("\t")
1380     for line in gplace:
1381         fields = line.decode("latin1").strip().split("\t")
1382         f_geoid = fields[ columns.index("GEOID") ].strip()
1383         f_name = fields[ columns.index("NAME") ].strip()
1384         f_usps = fields[ columns.index("USPS") ].strip()
1385         f_intptlat = fields[ columns.index("INTPTLAT") ].strip()
1386         f_intptlong = fields[ columns.index("INTPTLONG") ].strip()
1387         if f_geoid and f_name and f_usps and f_intptlat and f_intptlong:
1388             fips = "fips%s" % f_geoid
1389             if fips not in places: places[fips] = {}
1390             places[fips]["centroid"] = gecos(
1391                 "%s,%s" % (f_intptlat, f_intptlong)
1392             )
1393             places[fips]["description"] = "%s, %s" % (f_name, f_usps)
1394             count += 1
1395     gplace.close()
1396     print("done (%s lines)." % count)
1397     message = "Reading %s..." % slist_fn
1398     sys.stdout.write(message)
1399     sys.stdout.flush()
1400     count = 0
1401     slist = codecs.open(slist_fn, "rU")
1402     for line in slist:
1403         icao = line.split("#")[0].strip()
1404         if icao:
1405             stations[icao] = {
1406                 "metar": "http://tgftp.nws.noaa.gov/data/observations/"\
1407                     + "metar/decoded/%s.TXT" % icao.upper()
1408             }
1409             count += 1
1410     slist.close()
1411     print("done (%s lines)." % count)
1412     message = "Reading %s..." % metartbl_fn
1413     sys.stdout.write(message)
1414     sys.stdout.flush()
1415     count = 0
1416     metartbl = codecs.open(metartbl_fn, "rU")
1417     for line in metartbl:
1418         icao = line[:4].strip().lower()
1419         if icao in stations:
1420             description = []
1421             name = " ".join(
1422                 line[16:48].replace("_", " ").strip().title().split()
1423             )
1424             if name: description.append(name)
1425             st = line[49:51].strip()
1426             if st: description.append(st)
1427             cn = line[52:54].strip()
1428             if cn: description.append(cn)
1429             if description:
1430                 stations[icao]["description"] = ", ".join(description)
1431             lat = line[55:60].strip()
1432             if lat:
1433                 lat = int(lat)/100.0
1434                 lon = line[61:67].strip()
1435                 if lon:
1436                     lon = int(lon)/100.0
1437                     stations[icao]["location"] = gecos( "%s,%s" % (lat, lon) )
1438         count += 1
1439     metartbl.close()
1440     print("done (%s lines)." % count)
1441     message = "Reading %s..." % nsdcccc_fn
1442     sys.stdout.write(message)
1443     sys.stdout.flush()
1444     count = 0
1445     nsdcccc = codecs.open(nsdcccc_fn, "rU", "latin1")
1446     for line in nsdcccc:
1447         line = str(line)
1448         fields = line.split(";")
1449         icao = fields[0].strip().lower()
1450         if icao in stations:
1451             description = []
1452             name = " ".join( fields[3].strip().title().split() )
1453             if name: description.append(name)
1454             st = fields[4].strip()
1455             if st: description.append(st)
1456             country = " ".join( fields[5].strip().title().split() )
1457             if country: description.append(country)
1458             if description:
1459                 stations[icao]["description"] = ", ".join(description)
1460             lat, lon = fields[7:9]
1461             if lat and lon:
1462                 stations[icao]["location"] = gecos( "%s,%s" % (lat, lon) )
1463             elif "location" not in stations[icao]:
1464                 lat, lon = fields[5:7]
1465                 if lat and lon:
1466                     stations[icao]["location"] = gecos( "%s,%s" % (lat, lon) )
1467         count += 1
1468     nsdcccc.close()
1469     print("done (%s lines)." % count)
1470     message = "Reading %s..." % coopstn_fn
1471     sys.stdout.write(message)
1472     sys.stdout.flush()
1473     count = 0
1474     coopstn = open(coopstn_fn)
1475     for line in coopstn:
1476         icao = line[33:37].strip().lower()
1477         if icao in stations:
1478             iata = line[22:26].strip().lower()
1479             if len(iata) == 3: airports[iata] = { "station": icao }
1480             if "description" not in stations[icao]:
1481                 description = []
1482                 name = " ".join( line[99:129].strip().title().split() )
1483                 if name: description.append(name)
1484                 st = line[59:61].strip()
1485                 if st: description.append(st)
1486                 country = " ".join( line[38:58].strip().title().split() )
1487                 if country: description.append(country)
1488                 if description:
1489                     stations[icao]["description"] = ", ".join(description)
1490             if "location" not in stations[icao]:
1491                 lat = line[130:139].strip()
1492                 if lat:
1493                     lat = lat.replace(" ", "-")
1494                     lon = line[140:150].strip()
1495                     if lon:
1496                         lon = lon.replace(" ", "-")
1497                         stations[icao]["location"] = gecos(
1498                             "%s,%s" % (lat, lon)
1499                         )
1500         count += 1
1501     coopstn.close()
1502     print("done (%s lines)." % count)
1503     message = "Reading %s..." % zlist_fn
1504     sys.stdout.write(message)
1505     sys.stdout.flush()
1506     count = 0
1507     zlist = codecs.open(zlist_fn, "rU")
1508     for line in zlist:
1509         line = line.split("#")[0].strip()
1510         if line:
1511             zones[line] = {}
1512             count += 1
1513     zlist.close()
1514     print("done (%s lines)." % count)
1515     message = "Reading %s:*..." % zcatalog_an
1516     sys.stdout.write(message)
1517     sys.stdout.flush()
1518     count = 0
1519     zcatalog = tarfile.open(zcatalog_an)
1520     for entry in zcatalog.getmembers():
1521         if entry.isfile():
1522             fnmatch = re.match(
1523                 r"([a-z]+z[0-9]+)\.txt$",
1524                 os.path.basename(entry.name)
1525             )
1526             if fnmatch:
1527                 zone = fnmatch.group(1)
1528                 if zone in zones:
1529                     data = zcatalog.extractfile(entry).readlines()
1530                     description = data[0].decode("ascii").strip()
1531                     zones[zone]["description"] = description
1532                     for line in data[1:]:
1533                         line = line.decode("latin1").strip()
1534                         urimatch = re.match("/webdocs/(.+):(.+) for ", line)
1535                         if urimatch:
1536                             uritype = urimatch.group(2).lower().replace(" ","_")
1537                             zones[zone][uritype] \
1538                                 = "http://weather.noaa.gov/%s" \
1539                                 % urimatch.group(1)
1540         count += 1
1541     zcatalog.close()
1542     print("done (%s files)." % count)
1543     message = "Reading %s..." % cpfzcf_fn
1544     sys.stdout.write(message)
1545     sys.stdout.flush()
1546     count = 0
1547     cpfz = {}
1548     cpfzcf = open(cpfzcf_fn)
1549     for line in cpfzcf:
1550         fields = line.strip().split("|")
1551         if len(fields) == 11 \
1552             and fields[0] and fields[1] and fields[9] and fields[10]:
1553             zone = "z".join( fields[:2] ).lower()
1554             if zone in zones:
1555                 zones[zone]["centroid"] = gecos( ",".join( fields[9:11] ) )
1556             elif fields[6]:
1557                 state = fields[0]
1558                 description = fields[3]
1559                 county = fields[5]
1560                 fips = "fips%s"%fields[6]
1561                 possible = [
1562                     "%s, %s" % (county, state),
1563                     "%s County, %s" % (county, state),
1564                 ]
1565                 if description.endswith(" Counties"):
1566                     description = description[:-9]
1567                 for addition in description.split(" and "):
1568                     possible.append( "%s, %s" % (addition, state) )
1569                     possible.append( "%s County, %s" % (addition, state) )
1570                 if fips in places and "centroid" in places[fips]:
1571                     for candidate in zones:
1572                         if "centroid" not in zones[candidate] and \
1573                             "description" in zones[candidate] and \
1574                             zones[candidate]["description"] in possible:
1575                             zones[candidate]["centroid"] = \
1576                                 places[fips]["centroid"]
1577         count += 1
1578     cpfzcf.close()
1579     print("done (%s lines)." % count)
1580     message = "Reading %s:%s..." % (gzcta_an, gzcta_fn)
1581     sys.stdout.write(message)
1582     sys.stdout.flush()
1583     count = 0
1584     gzcta = zipfile.ZipFile(gzcta_an).open(gzcta_fn, "rU")
1585     columns = gzcta.readline().decode("latin1").strip().split("\t")
1586     for line in gzcta:
1587         fields = line.decode("latin1").strip().split("\t")
1588         f_geoid = fields[ columns.index("GEOID") ].strip()
1589         f_intptlat = fields[ columns.index("INTPTLAT") ].strip()
1590         f_intptlong = fields[ columns.index("INTPTLONG") ].strip()
1591         if f_geoid and f_intptlat and f_intptlong:
1592             if f_geoid not in zctas: zctas[f_geoid] = {}
1593             zctas[f_geoid]["centroid"] = gecos(
1594                 "%s,%s" % (f_intptlat, f_intptlong)
1595             )
1596             count += 1
1597     gzcta.close()
1598     print("done (%s lines)." % count)
1599     message = "Reading %s..." % overrides_fn
1600     sys.stdout.write(message)
1601     sys.stdout.flush()
1602     count = 0
1603     added = 0
1604     removed = 0
1605     changed = 0
1606     overrides = configparser.ConfigParser()
1607     overrides.readfp( codecs.open(overrides_fn, "r", "utf8") )
1608     overrideslog = []
1609     for section in overrides.sections():
1610         addopt = 0
1611         chgopt = 0
1612         if section.startswith("-"):
1613             section = section[1:]
1614             delete = True
1615         else: delete = False
1616         if re.match("[A-Za-z]{3}$", section):
1617             if delete:
1618                 if section in airports:
1619                     del( airports[section] )
1620                     logact = "removed airport %s" % section
1621                     removed += 1
1622                 else:
1623                     logact = "tried to remove nonexistent airport %s" % section
1624             else:
1625                 if section in airports:
1626                     logact = "changed airport %s" % section
1627                     changed += 1
1628                 else:
1629                     airports[section] = {}
1630                     logact = "added airport %s" % section
1631                     added += 1
1632                 for key,value in overrides.items(section):
1633                     if key in airports[section]: chgopt += 1
1634                     else: addopt += 1
1635                     if key in ("centroid", "location"):
1636                         airports[section][key] = eval(value)
1637                     else:
1638                         airports[section][key] = value
1639                 if addopt and chgopt:
1640                     logact += " (+%s/!%s options)" % (addopt, chgopt)
1641                 elif addopt: logact += " (+%s options)" % addopt
1642                 elif chgopt: logact += " (!%s options)" % chgopt
1643         elif re.match("[A-Za-z0-9]{4}$", section):
1644             if delete:
1645                 if section in stations:
1646                     del( stations[section] )
1647                     logact = "removed station %s" % section
1648                     removed += 1
1649                 else:
1650                     logact = "tried to remove nonexistent station %s" % section
1651             else:
1652                 if section in stations:
1653                     logact = "changed station %s" % section
1654                     changed += 1
1655                 else:
1656                     stations[section] = {}
1657                     logact = "added station %s" % section
1658                     added += 1
1659                 for key,value in overrides.items(section):
1660                     if key in stations[section]: chgopt += 1
1661                     else: addopt += 1
1662                     if key in ("centroid", "location"):
1663                         stations[section][key] = eval(value)
1664                     else:
1665                         stations[section][key] = value
1666                 if addopt and chgopt:
1667                     logact += " (+%s/!%s options)" % (addopt, chgopt)
1668                 elif addopt: logact += " (+%s options)" % addopt
1669                 elif chgopt: logact += " (!%s options)" % chgopt
1670         elif re.match("[0-9]{5}$", section):
1671             if delete:
1672                 if section in zctas:
1673                     del( zctas[section] )
1674                     logact = "removed zcta %s" % section
1675                     removed += 1
1676                 else:
1677                     logact = "tried to remove nonexistent zcta %s" % section
1678             else:
1679                 if section in zctas:
1680                     logact = "changed zcta %s" % section
1681                     changed += 1
1682                 else:
1683                     zctas[section] = {}
1684                     logact = "added zcta %s" % section
1685                     added += 1
1686                 for key,value in overrides.items(section):
1687                     if key in zctas[section]: chgopt += 1
1688                     else: addopt += 1
1689                     if key in ("centroid", "location"):
1690                         zctas[section][key] = eval(value)
1691                     else:
1692                         zctas[section][key] = value
1693                 if addopt and chgopt:
1694                     logact += " (+%s/!%s options)" % (addopt, chgopt)
1695                 elif addopt: logact += " (+%s options)" % addopt
1696                 elif chgopt: logact += " (!%s options)" % chgopt
1697         elif re.match("[A-Za-z]{2}[Zz][0-9]{3}$", section):
1698             if delete:
1699                 if section in zones:
1700                     del( zones[section] )
1701                     logact = "removed zone %s" % section
1702                     removed += 1
1703                 else:
1704                     logact = "tried to remove nonexistent zone %s" % section
1705             else:
1706                 if section in zones:
1707                     logact = "changed zone %s" % section
1708                     changed += 1
1709                 else:
1710                     zones[section] = {}
1711                     logact = "added zone %s" % section
1712                     added += 1
1713                 for key,value in overrides.items(section):
1714                     if key in zones[section]: chgopt += 1
1715                     else: addopt += 1
1716                     if key in ("centroid", "location"):
1717                         zones[section][key] = eval(value)
1718                     else:
1719                         zones[section][key] = value
1720                 if addopt and chgopt:
1721                     logact += " (+%s/!%s options)" % (addopt, chgopt)
1722                 elif addopt: logact += " (+%s options)" % addopt
1723                 elif chgopt: logact += " (!%s options)" % chgopt
1724         elif re.match("fips[0-9]+$", section):
1725             if delete:
1726                 if section in places:
1727                     del( places[section] )
1728                     logact = "removed place %s" % section
1729                     removed += 1
1730                 else:
1731                     logact = "tried to remove nonexistent place %s" % section
1732             else:
1733                 if section in places:
1734                     logact = "changed place %s" % section
1735                     changed += 1
1736                 else:
1737                     places[section] = {}
1738                     logact = "added place %s" % section
1739                     added += 1
1740                 for key,value in overrides.items(section):
1741                     if key in places[section]: chgopt += 1
1742                     else: addopt += 1
1743                     if key in ("centroid", "location"):
1744                         places[section][key] = eval(value)
1745                     else:
1746                         places[section][key] = value
1747                 if addopt and chgopt:
1748                     logact += " (+%s/!%s options)" % (addopt, chgopt)
1749                 elif addopt: logact += " (+%s options)" % addopt
1750                 elif chgopt: logact += " (!%s options)" % chgopt
1751         count += 1
1752         overrideslog.append("%s\n" % logact)
1753     overrideslog.sort()
1754     if os.path.exists(overrideslog_fn):
1755         os.rename(overrideslog_fn, "%s_old"%overrideslog_fn)
1756     overrideslog_fd = codecs.open(overrideslog_fn, "w", "utf8")
1757     overrideslog_fd.writelines(overrideslog)
1758     overrideslog_fd.close()
1759     print("done (%s overridden sections: +%s/-%s/!%s)." % (
1760         count,
1761         added,
1762         removed,
1763         changed
1764     ) )
1765     estimate = 2*len(places) + len(stations) + 2*len(zctas) + len(zones)
1766     print(
1767         "Correlating places, stations, ZCTAs and zones (upper bound is %s):" % \
1768             estimate
1769     )
1770     count = 0
1771     milestones = list( range(51) )
1772     message = "   "
1773     sys.stdout.write(message)
1774     sys.stdout.flush()
1775     for fips in places:
1776         centroid = places[fips]["centroid"]
1777         if centroid:
1778             station = closest(centroid, stations, "location", 0.1)
1779         if station[0]:
1780             places[fips]["station"] = station
1781             count += 1
1782             if not count%100:
1783                 level = int(50*count/estimate)
1784                 if level in milestones:
1785                     for remaining in milestones[:milestones.index(level)+1]:
1786                         if remaining%5:
1787                             message = "."
1788                             sys.stdout.write(message)
1789                             sys.stdout.flush()
1790                         else:
1791                             message = "%s%%" % (remaining*2,)
1792                             sys.stdout.write(message)
1793                             sys.stdout.flush()
1794                         milestones.remove(remaining)
1795         if centroid:
1796             zone = closest(centroid, zones, "centroid", 0.1)
1797         if zone[0]:
1798             places[fips]["zone"] = zone
1799             count += 1
1800             if not count%100:
1801                 level = int(50*count/estimate)
1802                 if level in milestones:
1803                     for remaining in milestones[:milestones.index(level)+1]:
1804                         if remaining%5:
1805                             message = "."
1806                             sys.stdout.write(message)
1807                             sys.stdout.flush()
1808                         else:
1809                             message = "%s%%" % (remaining*2,)
1810                             sys.stdout.write(message)
1811                             sys.stdout.flush()
1812                         milestones.remove(remaining)
1813     for station in stations:
1814         if "location" in stations[station]:
1815             location = stations[station]["location"]
1816             if location:
1817                 zone = closest(location, zones, "centroid", 0.1)
1818             if zone[0]:
1819                 stations[station]["zone"] = zone
1820                 count += 1
1821                 if not count%100:
1822                     level = int(50*count/estimate)
1823                     if level in milestones:
1824                         for remaining in milestones[:milestones.index(level)+1]:
1825                             if remaining%5:
1826                                 message = "."
1827                                 sys.stdout.write(message)
1828                                 sys.stdout.flush()
1829                             else:
1830                                 message = "%s%%" % (remaining*2,)
1831                                 sys.stdout.write(message)
1832                                 sys.stdout.flush()
1833                             milestones.remove(remaining)
1834     for zcta in zctas.keys():
1835         centroid = zctas[zcta]["centroid"]
1836         if centroid:
1837             station = closest(centroid, stations, "location", 0.1)
1838         if station[0]:
1839             zctas[zcta]["station"] = station
1840             count += 1
1841             if not count%100:
1842                 level = int(50*count/estimate)
1843                 if level in milestones:
1844                     for remaining in milestones[ : milestones.index(level)+1 ]:
1845                         if remaining%5:
1846                             message = "."
1847                             sys.stdout.write(message)
1848                             sys.stdout.flush()
1849                         else:
1850                             message = "%s%%" % (remaining*2,)
1851                             sys.stdout.write(message)
1852                             sys.stdout.flush()
1853                         milestones.remove(remaining)
1854         if centroid:
1855             zone = closest(centroid, zones, "centroid", 0.1)
1856         if zone[0]:
1857             zctas[zcta]["zone"] = zone
1858             count += 1
1859             if not count%100:
1860                 level = int(50*count/estimate)
1861                 if level in milestones:
1862                     for remaining in milestones[:milestones.index(level)+1]:
1863                         if remaining%5:
1864                             message = "."
1865                             sys.stdout.write(message)
1866                             sys.stdout.flush()
1867                         else:
1868                             message = "%s%%" % (remaining*2,)
1869                             sys.stdout.write(message)
1870                             sys.stdout.flush()
1871                         milestones.remove(remaining)
1872     for zone in zones.keys():
1873         if "centroid" in zones[zone]:
1874             centroid = zones[zone]["centroid"]
1875             if centroid:
1876                 station = closest(centroid, stations, "location", 0.1)
1877             if station[0]:
1878                 zones[zone]["station"] = station
1879                 count += 1
1880                 if not count%100:
1881                     level = int(50*count/estimate)
1882                     if level in milestones:
1883                         for remaining in milestones[:milestones.index(level)+1]:
1884                             if remaining%5:
1885                                 message = "."
1886                                 sys.stdout.write(message)
1887                                 sys.stdout.flush()
1888                             else:
1889                                 message = "%s%%" % (remaining*2,)
1890                                 sys.stdout.write(message)
1891                                 sys.stdout.flush()
1892                             milestones.remove(remaining)
1893     for remaining in milestones:
1894         if remaining%5:
1895             message = "."
1896             sys.stdout.write(message)
1897             sys.stdout.flush()
1898         else:
1899             message = "%s%%" % (remaining*2,)
1900             sys.stdout.write(message)
1901             sys.stdout.flush()
1902     print("\n   done (%s correlations)." % count)
1903     message = "Writing %s..." % airports_fn
1904     sys.stdout.write(message)
1905     sys.stdout.flush()
1906     count = 0
1907     if os.path.exists(airports_fn):
1908         os.rename(airports_fn, "%s_old"%airports_fn)
1909     airports_fd = codecs.open(airports_fn, "w", "utf8")
1910     airports_fd.write(header)
1911     for airport in sorted( airports.keys() ):
1912         airports_fd.write("\n\n[%s]" % airport)
1913         for key, value in sorted( airports[airport].items() ):
1914             if type(value) is float: value = "%.7f"%value
1915             elif type(value) is tuple:
1916                 elements = []
1917                 for element in value:
1918                     if type(element) is float: elements.append("%.7f"%element)
1919                     else: elements.append( repr(element) )
1920                 value = "(%s)"%", ".join(elements)
1921             airports_fd.write( "\n%s = %s" % (key, value) )
1922         count += 1
1923     airports_fd.write("\n")
1924     airports_fd.close()
1925     print("done (%s sections)." % count)
1926     message = "Writing %s..." % places_fn
1927     sys.stdout.write(message)
1928     sys.stdout.flush()
1929     count = 0
1930     if os.path.exists(places_fn):
1931         os.rename(places_fn, "%s_old"%places_fn)
1932     places_fd = codecs.open(places_fn, "w", "utf8")
1933     places_fd.write(header)
1934     for fips in sorted( places.keys() ):
1935         places_fd.write("\n\n[%s]" % fips)
1936         for key, value in sorted( places[fips].items() ):
1937             if type(value) is float: value = "%.7f"%value
1938             elif type(value) is tuple:
1939                 elements = []
1940                 for element in value:
1941                     if type(element) is float: elements.append("%.7f"%element)
1942                     else: elements.append( repr(element) )
1943                 value = "(%s)"%", ".join(elements)
1944             places_fd.write( "\n%s = %s" % (key, value) )
1945         count += 1
1946     places_fd.write("\n")
1947     places_fd.close()
1948     print("done (%s sections)." % count)
1949     message = "Writing %s..." % stations_fn
1950     sys.stdout.write(message)
1951     sys.stdout.flush()
1952     count = 0
1953     if os.path.exists(stations_fn):
1954         os.rename(stations_fn, "%s_old"%stations_fn)
1955     stations_fd = codecs.open(stations_fn, "w", "utf8")
1956     stations_fd.write(header)
1957     for station in sorted( stations.keys() ):
1958         stations_fd.write("\n\n[%s]" % station)
1959         for key, value in sorted( stations[station].items() ):
1960             if type(value) is float: value = "%.7f"%value
1961             elif type(value) is tuple:
1962                 elements = []
1963                 for element in value:
1964                     if type(element) is float: elements.append("%.7f"%element)
1965                     else: elements.append( repr(element) )
1966                 value = "(%s)"%", ".join(elements)
1967             stations_fd.write( "\n%s = %s" % (key, value) )
1968         count += 1
1969     stations_fd.write("\n")
1970     stations_fd.close()
1971     print("done (%s sections)." % count)
1972     message = "Writing %s..." % zctas_fn
1973     sys.stdout.write(message)
1974     sys.stdout.flush()
1975     count = 0
1976     if os.path.exists(zctas_fn):
1977         os.rename(zctas_fn, "%s_old"%zctas_fn)
1978     zctas_fd = codecs.open(zctas_fn, "w", "utf8")
1979     zctas_fd.write(header)
1980     for zcta in sorted( zctas.keys() ):
1981         zctas_fd.write("\n\n[%s]" % zcta)
1982         for key, value in sorted( zctas[zcta].items() ):
1983             if type(value) is float: value = "%.7f"%value
1984             elif type(value) is tuple:
1985                 elements = []
1986                 for element in value:
1987                     if type(element) is float: elements.append("%.7f"%element)
1988                     else: elements.append( repr(element) )
1989                 value = "(%s)"%", ".join(elements)
1990             zctas_fd.write( "\n%s = %s" % (key, value) )
1991         count += 1
1992     zctas_fd.write("\n")
1993     zctas_fd.close()
1994     print("done (%s sections)." % count)
1995     message = "Writing %s..." % zones_fn
1996     sys.stdout.write(message)
1997     sys.stdout.flush()
1998     count = 0
1999     if os.path.exists(zones_fn):
2000         os.rename(zones_fn, "%s_old"%zones_fn)
2001     zones_fd = codecs.open(zones_fn, "w", "utf8")
2002     zones_fd.write(header)
2003     for zone in sorted( zones.keys() ):
2004         zones_fd.write("\n\n[%s]" % zone)
2005         for key, value in sorted( zones[zone].items() ):
2006             if type(value) is float: value = "%.7f"%value
2007             elif type(value) is tuple:
2008                 elements = []
2009                 for element in value:
2010                     if type(element) is float: elements.append("%.7f"%element)
2011                     else: elements.append( repr(element) )
2012                 value = "(%s)"%", ".join(elements)
2013             zones_fd.write( "\n%s = %s" % (key, value) )
2014         count += 1
2015     zones_fd.write("\n")
2016     zones_fd.close()
2017     print("done (%s sections)." % count)
2018     message = "Starting QA check..."
2019     sys.stdout.write(message)
2020     sys.stdout.flush()
2021     airports = configparser.ConfigParser()
2022     airports.read(airports_fn)
2023     places = configparser.ConfigParser()
2024     places.read(places_fn)
2025     stations = configparser.ConfigParser()
2026     stations.read(stations_fn)
2027     zctas = configparser.ConfigParser()
2028     zctas.read(zctas_fn)
2029     zones = configparser.ConfigParser()
2030     zones.read(zones_fn)
2031     qalog = []
2032     places_nocentroid = 0
2033     places_nodescription = 0
2034     for place in sorted( places.sections() ):
2035         if not places.has_option(place, "centroid"):
2036             qalog.append("%s: no centroid\n" % place)
2037             places_nocentroid += 1
2038         if not places.has_option(place, "description"):
2039             qalog.append("%s: no description\n" % place)
2040             places_nodescription += 1
2041     stations_nodescription = 0
2042     stations_nolocation = 0
2043     stations_nometar = 0
2044     for station in sorted( stations.sections() ):
2045         if not stations.has_option(station, "description"):
2046             qalog.append("%s: no description\n" % station)
2047             stations_nodescription += 1
2048         if not stations.has_option(station, "location"):
2049             qalog.append("%s: no location\n" % station)
2050             stations_nolocation += 1
2051         if not stations.has_option(station, "metar"):
2052             qalog.append("%s: no metar\n" % station)
2053             stations_nometar += 1
2054     airports_badstation = 0
2055     airports_nostation = 0
2056     for airport in sorted( airports.sections() ):
2057         if not airports.has_option(airport, "station"):
2058             qalog.append("%s: no station\n" % airport)
2059             airports_nostation += 1
2060         else:
2061             station = airports.get(airport, "station")
2062             if station not in stations.sections():
2063                 qalog.append( "%s: bad station %s\n" % (airport, station) )
2064                 airports_badstation += 1
2065     zctas_nocentroid = 0
2066     for zcta in sorted( zctas.sections() ):
2067         if not zctas.has_option(zcta, "centroid"):
2068             qalog.append("%s: no centroid\n" % zcta)
2069             zctas_nocentroid += 1
2070     zones_nocentroid = 0
2071     zones_nodescription = 0
2072     zones_noforecast = 0
2073     zones_overlapping = 0
2074     zonetable = {}
2075     for zone in zones.sections():
2076         if zones.has_option(zone, "centroid"):
2077             zonetable[zone] = {
2078                 "centroid": eval( zones.get(zone, "centroid") )
2079             }
2080     for zone in sorted( zones.sections() ):
2081         if zones.has_option(zone, "centroid"):
2082             zonetable_local = zonetable.copy()
2083             del( zonetable_local[zone] )
2084             centroid = eval( zones.get(zone, "centroid") )
2085             if centroid:
2086                 nearest = closest(centroid, zonetable_local, "centroid", 0.1)
2087             if nearest[1]*radian_to_km < 1:
2088                 qalog.append( "%s: within one km of %s\n" % (
2089                     zone,
2090                     nearest[0]
2091                 ) )
2092                 zones_overlapping += 1
2093         else:
2094             qalog.append("%s: no centroid\n" % zone)
2095             zones_nocentroid += 1
2096         if not zones.has_option(zone, "description"):
2097             qalog.append("%s: no description\n" % zone)
2098             zones_nodescription += 1
2099         if not zones.has_option(zone, "zone_forecast"):
2100             qalog.append("%s: no forecast\n" % zone)
2101             zones_noforecast += 1
2102     if os.path.exists(qalog_fn):
2103         os.rename(qalog_fn, "%s_old"%qalog_fn)
2104     qalog_fd = codecs.open(qalog_fn, "w", "utf8")
2105     qalog_fd.writelines(qalog)
2106     qalog_fd.close()
2107     if qalog:
2108         print("issues found (see %s for details):"%qalog_fn)
2109         if airports_badstation:
2110             print("   %s airports with invalid station"%airports_badstation)
2111         if airports_nostation:
2112             print("   %s airports with no station"%airports_nostation)
2113         if places_nocentroid:
2114             print("   %s places with no centroid"%places_nocentroid)
2115         if places_nodescription:
2116             print("   %s places with no description"%places_nodescription)
2117         if stations_nodescription:
2118             print("   %s stations with no description"%stations_nodescription)
2119         if stations_nolocation:
2120             print("   %s stations with no location"%stations_nolocation)
2121         if stations_nometar:
2122             print("   %s stations with no METAR"%stations_nometar)
2123         if zctas_nocentroid:
2124             print("   %s ZCTAs with no centroid"%zctas_nocentroid)
2125         if zones_nocentroid:
2126             print("   %s zones with no centroid"%zones_nocentroid)
2127         if zones_nodescription:
2128             print("   %s zones with no description"%zones_nodescription)
2129         if zones_noforecast:
2130             print("   %s zones with no forecast"%zones_noforecast)
2131         if zones_overlapping:
2132             print("   %s zones within one km of another"%zones_overlapping)
2133     else: print("no issues found.")
2134     print("Indexing complete!")