Update correlation sources
[weather.git] / weather.py
1 """Contains various object definitions needed by the weather utility."""
2
3 weather_copyright = """\
4 # Copyright (c) 2006-2020 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.4"
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             if pyversion("3"):
748                 stations.read_string(
749                     gzip.open(datafile).read().decode("utf-8") )
750             else: stations.readfp( gzip.open(datafile) )
751         else:
752             stations.read(datafile)
753     else:
754         message = "%s error: can't find \"%s\" data file\n" % (
755             os.path.basename( sys.argv[0] ),
756             dataname
757         )
758         sys.stderr.write(message)
759         exit(1)
760     zones = configparser.ConfigParser()
761     dataname = "zones"
762     if dataname in datafiles:
763         datafile = datafiles[dataname][0]
764         if datafile.endswith(".gz"):
765             import gzip
766             if pyversion("3"):
767                 zones.read_string( gzip.open(datafile).read().decode("utf-8") )
768             else: zones.readfp( gzip.open(datafile) )
769         else:
770             zones.read(datafile)
771     else:
772         message = "%s error: can't find \"%s\" data file\n" % (
773             os.path.basename( sys.argv[0] ),
774             dataname
775         )
776         sys.stderr.write(message)
777         exit(1)
778     search = None
779     station = ("", 0)
780     zone = ("", 0)
781     dataset = None
782     possibilities = []
783     uris = {}
784     if searchtype == "airport":
785         expression = expression.lower()
786         airports = configparser.ConfigParser()
787         dataname = "airports"
788         if dataname in datafiles:
789             datafile = datafiles[dataname][0]
790             if datafile.endswith(".gz"):
791                 import gzip
792                 if pyversion("3"):
793                     airports.read_string(
794                         gzip.open(datafile).read().decode("utf-8") )
795                 else: airports.readfp( gzip.open(datafile) )
796             else:
797                 airports.read(datafile)
798         else:
799             message = "%s error: can't find \"%s\" data file\n" % (
800                 os.path.basename( sys.argv[0] ),
801                 dataname
802             )
803             sys.stderr.write(message)
804             exit(1)
805         if airports.has_section(expression) \
806             and airports.has_option(expression, "station"):
807             search = (expression, "IATA/FAA airport code %s" % expression)
808             station = ( airports.get(expression, "station"), 0 )
809             if stations.has_option(station[0], "zone"):
810                 zone = eval( stations.get(station[0], "zone") )
811                 dataset = stations
812             if not ( info or quiet ) \
813                 and stations.has_option( station[0], "description" ):
814                 print(
815                     "[%s result %s]" % (
816                         action,
817                         stations.get(station[0], "description")
818                     )
819                 )
820         else:
821             message = "No IATA/FAA airport code \"%s\" in the %s file.\n" % (
822                 expression,
823                 datafiles["airports"][0]
824             )
825             sys.stderr.write(message)
826             exit(1)
827     elif searchtype == "station":
828         expression = expression.lower()
829         if stations.has_section(expression):
830             station = (expression, 0)
831             if not search:
832                 search = (expression, "ICAO station code %s" % expression)
833             if stations.has_option(expression, "zone"):
834                 zone = eval( stations.get(expression, "zone") )
835                 dataset = stations
836             if not ( info or quiet ) \
837                 and stations.has_option(expression, "description"):
838                 print(
839                     "[%s result %s]" % (
840                         action,
841                         stations.get(expression, "description")
842                     )
843                 )
844         else:
845             message = "No ICAO weather station \"%s\" in the %s file.\n" % (
846                 expression,
847                 datafiles["stations"][0]
848             )
849             sys.stderr.write(message)
850             exit(1)
851     elif searchtype == "zone":
852         expression = expression.lower()
853         if zones.has_section(expression) \
854             and zones.has_option(expression, "station"):
855             zone = (expression, 0)
856             station = eval( zones.get(expression, "station") )
857             dataset = zones
858             search = (expression, "NWS/NOAA weather zone %s" % expression)
859             if not ( info or quiet ) \
860                 and zones.has_option(expression, "description"):
861                 print(
862                     "[%s result %s]" % (
863                         action,
864                         zones.get(expression, "description")
865                     )
866                 )
867         else:
868             message = "No usable NWS weather zone \"%s\" in the %s file.\n" % (
869                 expression,
870                 datafiles["zones"][0]
871             )
872             sys.stderr.write(message)
873             exit(1)
874     elif searchtype == "ZCTA":
875         zctas = configparser.ConfigParser()
876         dataname = "zctas"
877         if dataname in datafiles:
878             datafile = datafiles[dataname][0]
879             if datafile.endswith(".gz"):
880                 import gzip
881                 if pyversion("3"):
882                     zctas.read_string(
883                         gzip.open(datafile).read().decode("utf-8") )
884                 else: zctas.readfp( gzip.open(datafile) )
885             else:
886                 zctas.read(datafile)
887         else:
888             message = "%s error: can't find \"%s\" data file\n" % (
889                 os.path.basename( sys.argv[0] ),
890                 dataname
891             )
892             sys.stderr.write(message)
893             exit(1)
894         dataset = zctas
895         if zctas.has_section(expression) \
896             and zctas.has_option(expression, "station"):
897             station = eval( zctas.get(expression, "station") )
898             search = (expression, "Census ZCTA (ZIP code) %s" % expression)
899             if zctas.has_option(expression, "zone"):
900                 zone = eval( zctas.get(expression, "zone") )
901         else:
902             message = "No census ZCTA (ZIP code) \"%s\" in the %s file.\n" % (
903                 expression,
904                 datafiles["zctas"][0]
905             )
906             sys.stderr.write(message)
907             exit(1)
908     elif searchtype == "coordinates":
909         search = (expression, "Geographic coordinates %s" % expression)
910         stationtable = {}
911         for station in stations.sections():
912             if stations.has_option(station, "location"):
913                 stationtable[station] = {
914                     "location": eval( stations.get(station, "location") )
915                 }
916         station = closest( gecos(expression), stationtable, "location", 0.1 )
917         if not station[0]:
918             message = "No ICAO weather station found near %s.\n" % expression
919             sys.stderr.write(message)
920             exit(1)
921         zonetable = {}
922         for zone in zones.sections():
923             if zones.has_option(zone, "centroid"):
924                 zonetable[zone] = {
925                     "centroid": eval( zones.get(zone, "centroid") )
926                 }
927         zone = closest( gecos(expression), zonetable, "centroid", 0.1 )
928         if not zone[0]:
929             message = "No NWS weather zone near %s; forecasts unavailable.\n" \
930                 % expression
931             sys.stderr.write(message)
932     elif searchtype in ("FIPS", "name"):
933         places = configparser.ConfigParser()
934         dataname = "places"
935         if dataname in datafiles:
936             datafile = datafiles[dataname][0]
937             if datafile.endswith(".gz"):
938                 import gzip
939                 if pyversion("3"):
940                     places.read_string(
941                         gzip.open(datafile).read().decode("utf-8") )
942                 else: places.readfp( gzip.open(datafile) )
943             else:
944                 places.read(datafile)
945         else:
946             message = "%s error: can't find \"%s\" data file\n" % (
947                 os.path.basename( sys.argv[0] ),
948                 dataname
949             )
950             sys.stderr.write(message)
951             exit(1)
952         dataset = places
953         place = expression.lower()
954         if places.has_section(place) and places.has_option(place, "station"):
955             station = eval( places.get(place, "station") )
956             search = (expression, "Census Place %s" % expression)
957             if places.has_option(place, "description"):
958                 search = (
959                     search[0],
960                     search[1] + ", %s" % places.get(place, "description")
961                 )
962             if places.has_option(place, "zone"):
963                 zone = eval( places.get(place, "zone") )
964             if not ( info or quiet ) \
965                 and places.has_option(place, "description"):
966                 print(
967                     "[%s result %s]" % (
968                         action,
969                         places.get(place, "description")
970                     )
971                 )
972         else:
973             for place in places.sections():
974                 if places.has_option(place, "description") \
975                     and places.has_option(place, "station") \
976                     and re.search(
977                         expression,
978                         places.get(place, "description"),
979                         re.I
980                     ):
981                         possibilities.append(place)
982             for place in stations.sections():
983                 if stations.has_option(place, "description") \
984                     and re.search(
985                         expression,
986                         stations.get(place, "description"),
987                         re.I
988                     ):
989                         possibilities.append(place)
990             for place in zones.sections():
991                 if zones.has_option(place, "description") \
992                     and zones.has_option(place, "station") \
993                     and re.search(
994                         expression,
995                         zones.get(place, "description"),
996                         re.I
997                     ):
998                         possibilities.append(place)
999             if len(possibilities) == 1:
1000                 place = possibilities[0]
1001                 if places.has_section(place):
1002                     station = eval( places.get(place, "station") )
1003                     description = places.get(place, "description")
1004                     if places.has_option(place, "zone"):
1005                         zone = eval( places.get(place, "zone" ) )
1006                     search = ( expression, "%s: %s" % (place, description) )
1007                 elif stations.has_section(place):
1008                     station = (place, 0.0)
1009                     description = stations.get(place, "description")
1010                     if stations.has_option(place, "zone"):
1011                         zone = eval( stations.get(place, "zone" ) )
1012                     search = ( expression, "ICAO station code %s" % place )
1013                 elif zones.has_section(place):
1014                     station = eval( zones.get(place, "station") )
1015                     description = zones.get(place, "description")
1016                     zone = (place, 0.0)
1017                     search = ( expression, "NWS/NOAA weather zone %s" % place )
1018                 if not ( info or quiet ):
1019                     print( "[%s result %s]" % (action, description) )
1020             if not possibilities and not station[0]:
1021                 message = "No FIPS code/census area match in the %s file.\n" % (
1022                     datafiles["places"][0]
1023                 )
1024                 sys.stderr.write(message)
1025                 exit(1)
1026     if station[0]:
1027         uris["metar"] = stations.get( station[0], "metar" )
1028         if zone[0]:
1029             for key,value in zones.items( zone[0] ):
1030                 if key not in ("centroid", "description", "station"):
1031                     uris[key] = value
1032     elif possibilities:
1033         count = len(possibilities)
1034         if count <= max_results:
1035             print( "Your search is ambiguous, returning %s matches:" % count )
1036             for place in sorted(possibilities):
1037                 if places.has_section(place):
1038                     print(
1039                         "   [%s] %s" % (
1040                             place,
1041                             places.get(place, "description")
1042                         )
1043                     )
1044                 elif stations.has_section(place):
1045                     print(
1046                         "   [%s] %s" % (
1047                             place,
1048                             stations.get(place, "description")
1049                         )
1050                     )
1051                 elif zones.has_section(place):
1052                     print(
1053                         "   [%s] %s" % (
1054                             place,
1055                             zones.get(place, "description")
1056                         )
1057                     )
1058         else:
1059             print(
1060                 "Your search is too ambiguous, returning %s matches." % count
1061             )
1062         exit(0)
1063     if info:
1064         stationlist = []
1065         zonelist = []
1066         if dataset:
1067             for section in dataset.sections():
1068                 if dataset.has_option(section, "station"):
1069                     stationlist.append(
1070                         eval( dataset.get(section, "station") )[1]
1071                     )
1072                 if dataset.has_option(section, "zone"):
1073                     zonelist.append( eval( dataset.get(section, "zone") )[1] )
1074         stationlist.sort()
1075         zonelist.sort()
1076         scount = len(stationlist)
1077         zcount = len(zonelist)
1078         sranks = []
1079         zranks = []
1080         for score in scores:
1081             if stationlist:
1082                 sranks.append( stationlist[ int( (1-score[0]) * scount ) ] )
1083             if zonelist:
1084                 zranks.append( zonelist[ int( (1-score[0]) * zcount ) ] )
1085         description = search[1]
1086         uris["description"] = description
1087         print(
1088             "%s\n%s" % ( description, "-" * len(description) )
1089         )
1090         print(
1091             "%s: %s" % (
1092                 station[0],
1093                 stations.get( station[0], "description" )
1094             )
1095         )
1096         km = radian_to_km*station[1]
1097         mi = radian_to_mi*station[1]
1098         if sranks and not description.startswith("ICAO station code "):
1099             for index in range(0, len(scores)):
1100                 if station[1] >= sranks[index]:
1101                     score = scores[index][1]
1102                     break
1103             print(
1104                 "   (proximity %s, %.3gkm, %.3gmi)" % ( score, km, mi )
1105             )
1106         elif searchtype is "coordinates":
1107             print( "   (%.3gkm, %.3gmi)" % (km, mi) )
1108         if zone[0]:
1109             print(
1110                 "%s: %s" % ( zone[0], zones.get( zone[0], "description" ) )
1111             )
1112         km = radian_to_km*zone[1]
1113         mi = radian_to_mi*zone[1]
1114         if zranks and not description.startswith("NWS/NOAA weather zone "):
1115             for index in range(0, len(scores)):
1116                 if zone[1] >= zranks[index]:
1117                     score = scores[index][1]
1118                     break
1119             print(
1120                 "   (proximity %s, %.3gkm, %.3gmi)" % ( score, km, mi )
1121             )
1122         elif searchtype is "coordinates" and zone[0]:
1123             print( "   (%.3gkm, %.3gmi)" % (km, mi) )
1124     if cache_search:
1125         now = time.time()
1126         nowstamp = "%s (%s)" % (
1127             now,
1128             datetime.datetime.isoformat(
1129                 datetime.datetime.fromtimestamp(now),
1130                 " "
1131             )
1132         )
1133         search_cache = ["\n"]
1134         search_cache.append( "[%s]\n" % search[0] ) 
1135         search_cache.append( "description = cached %s\n" % nowstamp )
1136         for uriname in sorted(uris.keys()):
1137             search_cache.append( "%s = %s\n" % ( uriname, uris[uriname] ) )
1138         real_cachedir = os.path.expanduser(cachedir)
1139         if not os.path.exists(real_cachedir):
1140             try: os.makedirs(real_cachedir)
1141             except (IOError, OSError): pass
1142         scache_fn = os.path.join(real_cachedir, "searches")
1143         if not os.path.exists(scache_fn):
1144             then = sorted(
1145                     [ x[1] for x in datafiles.values() ],
1146                     reverse=True
1147                 )[0]
1148             thenstamp = "%s (%s)" % (
1149                 then,
1150                 datetime.datetime.isoformat(
1151                     datetime.datetime.fromtimestamp(then),
1152                     " "
1153                 )
1154             )
1155             search_cache.insert(
1156                 0,
1157                 "# based on data files from: %s\n" % thenstamp
1158             )
1159         try:
1160             scache_existing = configparser.ConfigParser()
1161             scache_existing.read(scache_fn)
1162             if not scache_existing.has_section(search[0]):
1163                 scache_fd = codecs.open(scache_fn, "a", "utf-8")
1164                 scache_fd.writelines(search_cache)
1165                 scache_fd.close()
1166         except (IOError, OSError): pass
1167     if not info:
1168         return(uris)
1169
1170 def closest(position, nodes, fieldname, angle=None):
1171     import math
1172     if not angle: angle = 2*math.pi
1173     match = None
1174     for name in nodes:
1175         if fieldname in nodes[name]:
1176             node = nodes[name][fieldname]
1177             if node and abs( position[0]-node[0] ) < angle:
1178                 if abs( position[1]-node[1] ) < angle \
1179                     or abs( abs( position[1]-node[1] ) - 2*math.pi ) < angle:
1180                     if position == node:
1181                         angle = 0
1182                         match = name
1183                     else:
1184                         candidate = math.acos(
1185                             math.sin( position[0] ) * math.sin( node[0] ) \
1186                                 + math.cos( position[0] ) \
1187                                 * math.cos( node[0] ) \
1188                                 * math.cos( position[1] - node[1] )
1189                             )
1190                         if candidate < angle:
1191                             angle = candidate
1192                             match = name
1193     if match: match = str(match)
1194     return (match, angle)
1195
1196 def gecos(formatted):
1197     import math, re
1198     coordinates = formatted.split(",")
1199     for coordinate in range(0, 2):
1200         degrees, foo, minutes, bar, seconds, hemisphere = re.match(
1201             r"([\+-]?\d+\.?\d*)(-(\d+))?(-(\d+))?([ensw]?)$",
1202             coordinates[coordinate].strip().lower()
1203         ).groups()
1204         value = float(degrees)
1205         if minutes: value += float(minutes)/60
1206         if seconds: value += float(seconds)/3600
1207         if hemisphere and hemisphere in "sw": value *= -1
1208         coordinates[coordinate] = math.radians(value)
1209     return tuple(coordinates)
1210
1211 def correlate():
1212     import codecs, csv, datetime, hashlib, os, re, sys, tarfile, time, zipfile
1213     if pyversion("3"): import configparser
1214     else: import ConfigParser as configparser
1215     for filename in os.listdir("."):
1216         if re.match("[0-9]{4}_Gaz_counties_national.zip$", filename):
1217             gcounties_an = filename
1218             gcounties_fn = filename[:-4] + ".txt"
1219         elif re.match("[0-9]{4}_Gaz_cousubs_national.zip$", filename):
1220             gcousubs_an = filename
1221             gcousubs_fn = filename[:-4] + ".txt"
1222         elif re.match("[0-9]{4}_Gaz_place_national.zip$", filename):
1223             gplace_an = filename
1224             gplace_fn = filename[:-4] + ".txt"
1225         elif re.match("[0-9]{4}_Gaz_zcta_national.zip$", filename):
1226             gzcta_an = filename
1227             gzcta_fn = filename[:-4] + ".txt"
1228         elif re.match("bp[0-9]{2}[a-z]{2}[0-9]{2}.dbx$", filename):
1229             cpfzcf_fn = filename
1230     nsdcccc_fn = "nsd_cccc.txt"
1231     ourairports_fn = "airports.csv"
1232     overrides_fn = "overrides.conf"
1233     overrideslog_fn = "overrides.log"
1234     slist_fn = "slist"
1235     zlist_fn = "zlist"
1236     qalog_fn = "qa.log"
1237     airports_fn = "airports"
1238     places_fn = "places"
1239     stations_fn = "stations"
1240     zctas_fn = "zctas"
1241     zones_fn = "zones"
1242     header = """\
1243 %s
1244 # generated by %s on %s from these public domain sources:
1245 #
1246 # https://www.census.gov/geographies/reference-files/time-series/geo/gazetteer-files.html
1247 # %s %s %s
1248 # %s %s %s
1249 # %s %s %s
1250 # %s %s %s
1251 #
1252 # https://www.weather.gov/gis/ZoneCounty/
1253 # %s %s %s
1254 #
1255 # https://tgftp.nws.noaa.gov/data/
1256 # %s %s %s
1257 #
1258 # https://ourairports.com/data/
1259 # %s %s %s
1260 #
1261 # ...and these manually-generated or hand-compiled adjustments:
1262 # %s %s %s
1263 # %s %s %s
1264 # %s %s %s\
1265 """ % (
1266         weather_copyright,
1267         os.path.basename( sys.argv[0] ),
1268         datetime.date.isoformat(
1269             datetime.datetime.fromtimestamp( time.time() )
1270         ),
1271         hashlib.md5( open(gcounties_an, "rb").read() ).hexdigest(),
1272         datetime.date.isoformat(
1273             datetime.datetime.fromtimestamp( os.path.getmtime(gcounties_an) )
1274         ),
1275         gcounties_an,
1276         hashlib.md5( open(gcousubs_an, "rb").read() ).hexdigest(),
1277         datetime.date.isoformat(
1278             datetime.datetime.fromtimestamp( os.path.getmtime(gcousubs_an) )
1279         ),
1280         gcousubs_an,
1281         hashlib.md5( open(gplace_an, "rb").read() ).hexdigest(),
1282         datetime.date.isoformat(
1283             datetime.datetime.fromtimestamp( os.path.getmtime(gplace_an) )
1284         ),
1285         gplace_an,
1286         hashlib.md5( open(gzcta_an, "rb").read() ).hexdigest(),
1287         datetime.date.isoformat(
1288             datetime.datetime.fromtimestamp( os.path.getmtime(gzcta_an) )
1289         ),
1290         gzcta_an,
1291         hashlib.md5( open(cpfzcf_fn, "rb").read() ).hexdigest(),
1292         datetime.date.isoformat(
1293             datetime.datetime.fromtimestamp( os.path.getmtime(cpfzcf_fn) )
1294         ),
1295         cpfzcf_fn,
1296         hashlib.md5( open(nsdcccc_fn, "rb").read() ).hexdigest(),
1297         datetime.date.isoformat(
1298             datetime.datetime.fromtimestamp( os.path.getmtime(nsdcccc_fn) )
1299         ),
1300         nsdcccc_fn,
1301         hashlib.md5( open(ourairports_fn, "rb").read() ).hexdigest(),
1302         datetime.date.isoformat(
1303             datetime.datetime.fromtimestamp( os.path.getmtime(ourairports_fn) )
1304         ),
1305         ourairports_fn,
1306         hashlib.md5( open(overrides_fn, "rb").read() ).hexdigest(),
1307         datetime.date.isoformat(
1308             datetime.datetime.fromtimestamp( os.path.getmtime(overrides_fn) )
1309         ),
1310         overrides_fn,
1311         hashlib.md5( open(slist_fn, "rb").read() ).hexdigest(),
1312         datetime.date.isoformat(
1313             datetime.datetime.fromtimestamp( os.path.getmtime(slist_fn) )
1314         ),
1315         slist_fn,
1316         hashlib.md5( open(zlist_fn, "rb").read() ).hexdigest(),
1317         datetime.date.isoformat(
1318             datetime.datetime.fromtimestamp( os.path.getmtime(zlist_fn) )
1319         ),
1320         zlist_fn
1321     )
1322     airports = {}
1323     places = {}
1324     stations = {}
1325     zctas = {}
1326     zones = {}
1327     message = "Reading %s:%s..." % (gcounties_an, gcounties_fn)
1328     sys.stdout.write(message)
1329     sys.stdout.flush()
1330     count = 0
1331     gcounties = zipfile.ZipFile(gcounties_an).open(gcounties_fn, "rU")
1332     columns = gcounties.readline().decode("utf-8").strip().split("\t")
1333     for line in gcounties:
1334         fields = line.decode("utf-8").strip().split("\t")
1335         f_geoid = fields[ columns.index("GEOID") ].strip()
1336         f_name = fields[ columns.index("NAME") ].strip()
1337         f_usps = fields[ columns.index("USPS") ].strip()
1338         f_intptlat = fields[ columns.index("INTPTLAT") ].strip()
1339         f_intptlong = fields[ columns.index("INTPTLONG") ].strip()
1340         if f_geoid and f_name and f_usps and f_intptlat and f_intptlong:
1341             fips = "fips%s" % f_geoid
1342             if fips not in places: places[fips] = {}
1343             places[fips]["centroid"] = gecos(
1344                 "%s,%s" % (f_intptlat, f_intptlong)
1345             )
1346             places[fips]["description"] = "%s, %s" % (f_name, f_usps)
1347             count += 1
1348     gcounties.close()
1349     print("done (%s lines)." % count)
1350     message = "Reading %s:%s..." % (gcousubs_an, gcousubs_fn)
1351     sys.stdout.write(message)
1352     sys.stdout.flush()
1353     count = 0
1354     gcousubs = zipfile.ZipFile(gcousubs_an).open(gcousubs_fn, "rU")
1355     columns = gcousubs.readline().decode("utf-8").strip().split("\t")
1356     for line in gcousubs:
1357         fields = line.decode("utf-8").strip().split("\t")
1358         f_geoid = fields[ columns.index("GEOID") ].strip()
1359         f_name = fields[ columns.index("NAME") ].strip()
1360         f_usps = fields[ columns.index("USPS") ].strip()
1361         f_intptlat = fields[ columns.index("INTPTLAT") ].strip()
1362         f_intptlong = fields[ columns.index("INTPTLONG") ].strip()
1363         if f_geoid and f_name and f_usps and f_intptlat and f_intptlong:
1364             fips = "fips%s" % f_geoid
1365             if fips not in places: places[fips] = {}
1366             places[fips]["centroid"] = gecos(
1367                 "%s,%s" % (f_intptlat, f_intptlong)
1368             )
1369             places[fips]["description"] = "%s, %s" % (f_name, f_usps)
1370             count += 1
1371     gcousubs.close()
1372     print("done (%s lines)." % count)
1373     message = "Reading %s:%s..." % (gplace_an, gplace_fn)
1374     sys.stdout.write(message)
1375     sys.stdout.flush()
1376     count = 0
1377     gplace = zipfile.ZipFile(gplace_an).open(gplace_fn, "rU")
1378     columns = gplace.readline().decode("utf-8").strip().split("\t")
1379     for line in gplace:
1380         fields = line.decode("utf-8").strip().split("\t")
1381         f_geoid = fields[ columns.index("GEOID") ].strip()
1382         f_name = fields[ columns.index("NAME") ].strip()
1383         f_usps = fields[ columns.index("USPS") ].strip()
1384         f_intptlat = fields[ columns.index("INTPTLAT") ].strip()
1385         f_intptlong = fields[ columns.index("INTPTLONG") ].strip()
1386         if f_geoid and f_name and f_usps and f_intptlat and f_intptlong:
1387             fips = "fips%s" % f_geoid
1388             if fips not in places: places[fips] = {}
1389             places[fips]["centroid"] = gecos(
1390                 "%s,%s" % (f_intptlat, f_intptlong)
1391             )
1392             places[fips]["description"] = "%s, %s" % (f_name, f_usps)
1393             count += 1
1394     gplace.close()
1395     print("done (%s lines)." % count)
1396     message = "Reading %s..." % slist_fn
1397     sys.stdout.write(message)
1398     sys.stdout.flush()
1399     count = 0
1400     slist = codecs.open(slist_fn, "rU", "utf-8")
1401     for line in slist:
1402         icao = line.split("#")[0].strip()
1403         if icao:
1404             stations[icao] = {
1405                 "metar": "https://tgftp.nws.noaa.gov/data/observations/"\
1406                     + "metar/decoded/%s.TXT" % icao.upper()
1407             }
1408             count += 1
1409     slist.close()
1410     print("done (%s lines)." % count)
1411     message = "Reading %s..." % nsdcccc_fn
1412     sys.stdout.write(message)
1413     sys.stdout.flush()
1414     count = 0
1415     nsdcccc = codecs.open(nsdcccc_fn, "rU", "utf-8")
1416     for line in nsdcccc:
1417         line = str(line)
1418         fields = line.split(";")
1419         icao = fields[0].strip().lower()
1420         if icao in stations:
1421             description = []
1422             name = " ".join( fields[3].strip().title().split() )
1423             if name: description.append(name)
1424             st = fields[4].strip()
1425             if st: description.append(st)
1426             country = " ".join( fields[5].strip().title().split() )
1427             if country: description.append(country)
1428             if description:
1429                 stations[icao]["description"] = ", ".join(description)
1430             lat, lon = fields[7:9]
1431             if lat and lon:
1432                 stations[icao]["location"] = gecos( "%s,%s" % (lat, lon) )
1433             elif "location" not in stations[icao]:
1434                 lat, lon = fields[5:7]
1435                 if lat and lon:
1436                     stations[icao]["location"] = gecos( "%s,%s" % (lat, lon) )
1437         count += 1
1438     nsdcccc.close()
1439     print("done (%s lines)." % count)
1440     message = "Reading %s..." % ourairports_fn
1441     sys.stdout.write(message)
1442     sys.stdout.flush()
1443     count = 0
1444     ourairports = open(ourairports_fn, "rU")
1445     for row in csv.reader(ourairports):
1446         icao = row[12].decode('utf-8').lower()
1447         if icao in stations:
1448             iata = row[13].decode('utf-8').lower()
1449             if len(iata) == 3: airports[iata] = { "station": icao }
1450             if "description" not in stations[icao]:
1451                 description = []
1452                 name = row[3].decode('utf-8')
1453                 if name: description.append(name)
1454                 municipality = row[10].decode('utf-8')
1455                 if municipality: description.append(municipality)
1456                 region = row[9].decode('utf-8')
1457                 country = row[8].decode('utf-8')
1458                 if region:
1459                     if "-" in region:
1460                         c,r = region.split("-", 1)
1461                         if c == country: region = r
1462                     description.append(region)
1463                 if country:
1464                     description.append(country)
1465                 if description:
1466                     stations[icao]["description"] = ", ".join(description)
1467             if "location" not in stations[icao]:
1468                 lat = row[4].decode('utf-8')
1469                 if lat:
1470                     lon = row[5].decode('utf-8')
1471                     if lon:
1472                         stations[icao]["location"] = gecos(
1473                             "%s,%s" % (lat, lon)
1474                         )
1475         count += 1
1476     ourairports.close()
1477     print("done (%s lines)." % count)
1478     message = "Reading %s..." % zlist_fn
1479     sys.stdout.write(message)
1480     sys.stdout.flush()
1481     count = 0
1482     zlist = codecs.open(zlist_fn, "rU", "utf-8")
1483     for line in zlist:
1484         line = line.split("#")[0].strip()
1485         if line:
1486             zones[line] = {}
1487             count += 1
1488     zlist.close()
1489     print("done (%s lines)." % count)
1490     message = "Reading %s..." % cpfzcf_fn
1491     sys.stdout.write(message)
1492     sys.stdout.flush()
1493     count = 0
1494     cpfz = {}
1495     cpfzcf = codecs.open(cpfzcf_fn, "rU", "utf-8")
1496     for line in cpfzcf:
1497         fields = line.strip().split("|")
1498         if len(fields) == 11 \
1499             and fields[0] and fields[1] and fields[9] and fields[10]:
1500             zone = "z".join( fields[:2] ).lower()
1501             if zone in zones:
1502                 state = fields[0]
1503                 if state:
1504                     zones[zone]["coastal_flood_statement"] = (
1505                         "https://tgftp.nws.noaa.gov/data/watches_warnings/"
1506                         "flood/coastal/%s/%s.txt" % (state.lower(), zone))
1507                     zones[zone]["flash_flood_statement"] = (
1508                         "https://tgftp.nws.noaa.gov/data/watches_warnings/"
1509                         "flash_flood/statement/%s/%s.txt"
1510                         % (state.lower(), zone))
1511                     zones[zone]["flash_flood_warning"] = (
1512                         "https://tgftp.nws.noaa.gov/data/watches_warnings/"
1513                         "flash_flood/warning/%s/%s.txt"
1514                         % (state.lower(), zone))
1515                     zones[zone]["flash_flood_watch"] = (
1516                         "https://tgftp.nws.noaa.gov/data/watches_warnings/"
1517                         "flash_flood/watch/%s/%s.txt" % (state.lower(), zone))
1518                     zones[zone]["flood_statement"] = (
1519                         "https://tgftp.nws.noaa.gov/data/watches_warnings/"
1520                         "flood/statement/%s/%s.txt" % (state.lower(), zone))
1521                     zones[zone]["flood_warning"] = (
1522                         "https://tgftp.nws.noaa.gov/data/watches_warnings/"
1523                         "flood/warning/%s/%s.txt" % (state.lower(), zone))
1524                     zones[zone]["severe_thunderstorm_warning"] = (
1525                         "https://tgftp.nws.noaa.gov/data/watches_warnings/"
1526                         "thunderstorm/%s/%s.txt" % (state.lower(), zone))
1527                     zones[zone]["severe_weather_statement"] = (
1528                         "https://tgftp.nws.noaa.gov/data/watches_warnings/"
1529                         "severe_weather_stmt/%s/%s.txt"
1530                         % (state.lower(), zone))
1531                     zones[zone]["short_term_forecast"] = (
1532                         "https://tgftp.nws.noaa.gov/data/forecasts/nowcast/"
1533                         "%s/%s.txt" % (state.lower(), zone))
1534                     zones[zone]["special_weather_statement"] = (
1535                         "https://tgftp.nws.noaa.gov/data/watches_warnings/"
1536                         "special_weather_stmt/%s/%s.txt"
1537                         % (state.lower(), zone))
1538                     zones[zone]["state_forecast"] = (
1539                         "https://tgftp.nws.noaa.gov/data/forecasts/state/"
1540                         "%s/%s.txt" % (state.lower(), zone))
1541                     zones[zone]["urgent_weather_message"] = (
1542                         "https://tgftp.nws.noaa.gov/data/watches_warnings/"
1543                         "non_precip/%s/%s.txt" % (state.lower(), zone))
1544                     zones[zone]["zone_forecast"] = (
1545                         "https://tgftp.nws.noaa.gov/data/forecasts/zone/"
1546                         "%s/%s.txt" % (state.lower(), zone))
1547                 description = fields[3].strip()
1548                 fips = "fips%s"%fields[6]
1549                 county = fields[5]
1550                 if county:
1551                     if description.endswith(county):
1552                         description += " County"
1553                     else:
1554                         description += ", %s County" % county
1555                 description += ", %s, US" % state
1556                 zones[zone]["description"] = description
1557                 zones[zone]["centroid"] = gecos( ",".join( fields[9:11] ) )
1558                 if fips in places and not zones[zone]["centroid"]:
1559                     zones[zone]["centroid"] = places[fips]["centroid"]
1560         count += 1
1561     cpfzcf.close()
1562     print("done (%s lines)." % count)
1563     message = "Reading %s:%s..." % (gzcta_an, gzcta_fn)
1564     sys.stdout.write(message)
1565     sys.stdout.flush()
1566     count = 0
1567     gzcta = zipfile.ZipFile(gzcta_an).open(gzcta_fn, "rU")
1568     columns = gzcta.readline().decode("utf-8").strip().split("\t")
1569     for line in gzcta:
1570         fields = line.decode("utf-8").strip().split("\t")
1571         f_geoid = fields[ columns.index("GEOID") ].strip()
1572         f_intptlat = fields[ columns.index("INTPTLAT") ].strip()
1573         f_intptlong = fields[ columns.index("INTPTLONG") ].strip()
1574         if f_geoid and f_intptlat and f_intptlong:
1575             if f_geoid not in zctas: zctas[f_geoid] = {}
1576             zctas[f_geoid]["centroid"] = gecos(
1577                 "%s,%s" % (f_intptlat, f_intptlong)
1578             )
1579             count += 1
1580     gzcta.close()
1581     print("done (%s lines)." % count)
1582     message = "Reading %s..." % overrides_fn
1583     sys.stdout.write(message)
1584     sys.stdout.flush()
1585     count = 0
1586     added = 0
1587     removed = 0
1588     changed = 0
1589     overrides = configparser.ConfigParser()
1590     overrides.readfp( codecs.open(overrides_fn, "r", "utf8") )
1591     overrideslog = []
1592     for section in overrides.sections():
1593         addopt = 0
1594         chgopt = 0
1595         if section.startswith("-"):
1596             section = section[1:]
1597             delete = True
1598         else: delete = False
1599         if re.match("[A-Za-z]{3}$", section):
1600             if delete:
1601                 if section in airports:
1602                     del( airports[section] )
1603                     logact = "removed airport %s" % section
1604                     removed += 1
1605                 else:
1606                     logact = "tried to remove nonexistent airport %s" % section
1607             else:
1608                 if section in airports:
1609                     logact = "changed airport %s" % section
1610                     changed += 1
1611                 else:
1612                     airports[section] = {}
1613                     logact = "added airport %s" % section
1614                     added += 1
1615                 for key,value in overrides.items(section):
1616                     if key in airports[section]: chgopt += 1
1617                     else: addopt += 1
1618                     if key in ("centroid", "location"):
1619                         airports[section][key] = eval(value)
1620                     else:
1621                         airports[section][key] = value
1622                 if addopt and chgopt:
1623                     logact += " (+%s/!%s options)" % (addopt, chgopt)
1624                 elif addopt: logact += " (+%s options)" % addopt
1625                 elif chgopt: logact += " (!%s options)" % chgopt
1626         elif re.match("[A-Za-z0-9]{4}$", section):
1627             if delete:
1628                 if section in stations:
1629                     del( stations[section] )
1630                     logact = "removed station %s" % section
1631                     removed += 1
1632                 else:
1633                     logact = "tried to remove nonexistent station %s" % section
1634             else:
1635                 if section in stations:
1636                     logact = "changed station %s" % section
1637                     changed += 1
1638                 else:
1639                     stations[section] = {}
1640                     logact = "added station %s" % section
1641                     added += 1
1642                 for key,value in overrides.items(section):
1643                     if key in stations[section]: chgopt += 1
1644                     else: addopt += 1
1645                     if key in ("centroid", "location"):
1646                         stations[section][key] = eval(value)
1647                     else:
1648                         stations[section][key] = value
1649                 if addopt and chgopt:
1650                     logact += " (+%s/!%s options)" % (addopt, chgopt)
1651                 elif addopt: logact += " (+%s options)" % addopt
1652                 elif chgopt: logact += " (!%s options)" % chgopt
1653         elif re.match("[0-9]{5}$", section):
1654             if delete:
1655                 if section in zctas:
1656                     del( zctas[section] )
1657                     logact = "removed zcta %s" % section
1658                     removed += 1
1659                 else:
1660                     logact = "tried to remove nonexistent zcta %s" % section
1661             else:
1662                 if section in zctas:
1663                     logact = "changed zcta %s" % section
1664                     changed += 1
1665                 else:
1666                     zctas[section] = {}
1667                     logact = "added zcta %s" % section
1668                     added += 1
1669                 for key,value in overrides.items(section):
1670                     if key in zctas[section]: chgopt += 1
1671                     else: addopt += 1
1672                     if key in ("centroid", "location"):
1673                         zctas[section][key] = eval(value)
1674                     else:
1675                         zctas[section][key] = value
1676                 if addopt and chgopt:
1677                     logact += " (+%s/!%s options)" % (addopt, chgopt)
1678                 elif addopt: logact += " (+%s options)" % addopt
1679                 elif chgopt: logact += " (!%s options)" % chgopt
1680         elif re.match("[A-Za-z]{2}[Zz][0-9]{3}$", section):
1681             if delete:
1682                 if section in zones:
1683                     del( zones[section] )
1684                     logact = "removed zone %s" % section
1685                     removed += 1
1686                 else:
1687                     logact = "tried to remove nonexistent zone %s" % section
1688             else:
1689                 if section in zones:
1690                     logact = "changed zone %s" % section
1691                     changed += 1
1692                 else:
1693                     zones[section] = {}
1694                     logact = "added zone %s" % section
1695                     added += 1
1696                 for key,value in overrides.items(section):
1697                     if key in zones[section]: chgopt += 1
1698                     else: addopt += 1
1699                     if key in ("centroid", "location"):
1700                         zones[section][key] = eval(value)
1701                     else:
1702                         zones[section][key] = value
1703                 if addopt and chgopt:
1704                     logact += " (+%s/!%s options)" % (addopt, chgopt)
1705                 elif addopt: logact += " (+%s options)" % addopt
1706                 elif chgopt: logact += " (!%s options)" % chgopt
1707         elif re.match("fips[0-9]+$", section):
1708             if delete:
1709                 if section in places:
1710                     del( places[section] )
1711                     logact = "removed place %s" % section
1712                     removed += 1
1713                 else:
1714                     logact = "tried to remove nonexistent place %s" % section
1715             else:
1716                 if section in places:
1717                     logact = "changed place %s" % section
1718                     changed += 1
1719                 else:
1720                     places[section] = {}
1721                     logact = "added place %s" % section
1722                     added += 1
1723                 for key,value in overrides.items(section):
1724                     if key in places[section]: chgopt += 1
1725                     else: addopt += 1
1726                     if key in ("centroid", "location"):
1727                         places[section][key] = eval(value)
1728                     else:
1729                         places[section][key] = value
1730                 if addopt and chgopt:
1731                     logact += " (+%s/!%s options)" % (addopt, chgopt)
1732                 elif addopt: logact += " (+%s options)" % addopt
1733                 elif chgopt: logact += " (!%s options)" % chgopt
1734         count += 1
1735         overrideslog.append("%s\n" % logact)
1736     overrideslog.sort()
1737     if os.path.exists(overrideslog_fn):
1738         os.rename(overrideslog_fn, "%s_old"%overrideslog_fn)
1739     overrideslog_fd = codecs.open(overrideslog_fn, "w", "utf8")
1740     overrideslog_fd.writelines(overrideslog)
1741     overrideslog_fd.close()
1742     print("done (%s overridden sections: +%s/-%s/!%s)." % (
1743         count,
1744         added,
1745         removed,
1746         changed
1747     ) )
1748     estimate = 2*len(places) + len(stations) + 2*len(zctas) + len(zones)
1749     print(
1750         "Correlating places, stations, ZCTAs and zones (upper bound is %s):" % \
1751             estimate
1752     )
1753     count = 0
1754     milestones = list( range(51) )
1755     message = "   "
1756     sys.stdout.write(message)
1757     sys.stdout.flush()
1758     for fips in places:
1759         centroid = places[fips]["centroid"]
1760         if centroid:
1761             station = closest(centroid, stations, "location", 0.1)
1762         if station[0]:
1763             places[fips]["station"] = station
1764             count += 1
1765             if not count%100:
1766                 level = int(50*count/estimate)
1767                 if level in milestones:
1768                     for remaining in milestones[:milestones.index(level)+1]:
1769                         if remaining%5:
1770                             message = "."
1771                             sys.stdout.write(message)
1772                             sys.stdout.flush()
1773                         else:
1774                             message = "%s%%" % (remaining*2,)
1775                             sys.stdout.write(message)
1776                             sys.stdout.flush()
1777                         milestones.remove(remaining)
1778         if centroid:
1779             zone = closest(centroid, zones, "centroid", 0.1)
1780         if zone[0]:
1781             places[fips]["zone"] = zone
1782             count += 1
1783             if not count%100:
1784                 level = int(50*count/estimate)
1785                 if level in milestones:
1786                     for remaining in milestones[:milestones.index(level)+1]:
1787                         if remaining%5:
1788                             message = "."
1789                             sys.stdout.write(message)
1790                             sys.stdout.flush()
1791                         else:
1792                             message = "%s%%" % (remaining*2,)
1793                             sys.stdout.write(message)
1794                             sys.stdout.flush()
1795                         milestones.remove(remaining)
1796     for station in stations:
1797         if "location" in stations[station]:
1798             location = stations[station]["location"]
1799             if location:
1800                 zone = closest(location, zones, "centroid", 0.1)
1801             if zone[0]:
1802                 stations[station]["zone"] = zone
1803                 count += 1
1804                 if not count%100:
1805                     level = int(50*count/estimate)
1806                     if level in milestones:
1807                         for remaining in milestones[:milestones.index(level)+1]:
1808                             if remaining%5:
1809                                 message = "."
1810                                 sys.stdout.write(message)
1811                                 sys.stdout.flush()
1812                             else:
1813                                 message = "%s%%" % (remaining*2,)
1814                                 sys.stdout.write(message)
1815                                 sys.stdout.flush()
1816                             milestones.remove(remaining)
1817     for zcta in zctas.keys():
1818         centroid = zctas[zcta]["centroid"]
1819         if centroid:
1820             station = closest(centroid, stations, "location", 0.1)
1821         if station[0]:
1822             zctas[zcta]["station"] = station
1823             count += 1
1824             if not count%100:
1825                 level = int(50*count/estimate)
1826                 if level in milestones:
1827                     for remaining in milestones[ : milestones.index(level)+1 ]:
1828                         if remaining%5:
1829                             message = "."
1830                             sys.stdout.write(message)
1831                             sys.stdout.flush()
1832                         else:
1833                             message = "%s%%" % (remaining*2,)
1834                             sys.stdout.write(message)
1835                             sys.stdout.flush()
1836                         milestones.remove(remaining)
1837         if centroid:
1838             zone = closest(centroid, zones, "centroid", 0.1)
1839         if zone[0]:
1840             zctas[zcta]["zone"] = zone
1841             count += 1
1842             if not count%100:
1843                 level = int(50*count/estimate)
1844                 if level in milestones:
1845                     for remaining in milestones[:milestones.index(level)+1]:
1846                         if remaining%5:
1847                             message = "."
1848                             sys.stdout.write(message)
1849                             sys.stdout.flush()
1850                         else:
1851                             message = "%s%%" % (remaining*2,)
1852                             sys.stdout.write(message)
1853                             sys.stdout.flush()
1854                         milestones.remove(remaining)
1855     for zone in zones.keys():
1856         if "centroid" in zones[zone]:
1857             centroid = zones[zone]["centroid"]
1858             if centroid:
1859                 station = closest(centroid, stations, "location", 0.1)
1860             if station[0]:
1861                 zones[zone]["station"] = station
1862                 count += 1
1863                 if not count%100:
1864                     level = int(50*count/estimate)
1865                     if level in milestones:
1866                         for remaining in milestones[:milestones.index(level)+1]:
1867                             if remaining%5:
1868                                 message = "."
1869                                 sys.stdout.write(message)
1870                                 sys.stdout.flush()
1871                             else:
1872                                 message = "%s%%" % (remaining*2,)
1873                                 sys.stdout.write(message)
1874                                 sys.stdout.flush()
1875                             milestones.remove(remaining)
1876     for remaining in milestones:
1877         if remaining%5:
1878             message = "."
1879             sys.stdout.write(message)
1880             sys.stdout.flush()
1881         else:
1882             message = "%s%%" % (remaining*2,)
1883             sys.stdout.write(message)
1884             sys.stdout.flush()
1885     print("\n   done (%s correlations)." % count)
1886     message = "Writing %s..." % airports_fn
1887     sys.stdout.write(message)
1888     sys.stdout.flush()
1889     count = 0
1890     if os.path.exists(airports_fn):
1891         os.rename(airports_fn, "%s_old"%airports_fn)
1892     airports_fd = codecs.open(airports_fn, "w", "utf8")
1893     airports_fd.write(header)
1894     for airport in sorted( airports.keys() ):
1895         airports_fd.write("\n\n[%s]" % airport)
1896         for key, value in sorted( airports[airport].items() ):
1897             if type(value) is float: value = "%.7f"%value
1898             elif type(value) is tuple:
1899                 elements = []
1900                 for element in value:
1901                     if type(element) is float: elements.append("%.7f"%element)
1902                     else: elements.append( repr(element) )
1903                 value = "(%s)"%", ".join(elements)
1904             airports_fd.write( "\n%s = %s" % (key, value) )
1905         count += 1
1906     airports_fd.write("\n")
1907     airports_fd.close()
1908     print("done (%s sections)." % count)
1909     message = "Writing %s..." % places_fn
1910     sys.stdout.write(message)
1911     sys.stdout.flush()
1912     count = 0
1913     if os.path.exists(places_fn):
1914         os.rename(places_fn, "%s_old"%places_fn)
1915     places_fd = codecs.open(places_fn, "w", "utf8")
1916     places_fd.write(header)
1917     for fips in sorted( places.keys() ):
1918         places_fd.write("\n\n[%s]" % fips)
1919         for key, value in sorted( places[fips].items() ):
1920             if type(value) is float: value = "%.7f"%value
1921             elif type(value) is tuple:
1922                 elements = []
1923                 for element in value:
1924                     if type(element) is float: elements.append("%.7f"%element)
1925                     else: elements.append( repr(element) )
1926                 value = "(%s)"%", ".join(elements)
1927             places_fd.write( "\n%s = %s" % (key, value) )
1928         count += 1
1929     places_fd.write("\n")
1930     places_fd.close()
1931     print("done (%s sections)." % count)
1932     message = "Writing %s..." % stations_fn
1933     sys.stdout.write(message)
1934     sys.stdout.flush()
1935     count = 0
1936     if os.path.exists(stations_fn):
1937         os.rename(stations_fn, "%s_old"%stations_fn)
1938     stations_fd = codecs.open(stations_fn, "w", "utf-8")
1939     stations_fd.write(header)
1940     for station in sorted( stations.keys() ):
1941         stations_fd.write("\n\n[%s]" % station)
1942         for key, value in sorted( stations[station].items() ):
1943             if type(value) is float: value = "%.7f"%value
1944             elif type(value) is tuple:
1945                 elements = []
1946                 for element in value:
1947                     if type(element) is float: elements.append("%.7f"%element)
1948                     else: elements.append( repr(element) )
1949                 value = "(%s)"%", ".join(elements)
1950             stations_fd.write( "\n%s = %s" % (key, value) )
1951         count += 1
1952     stations_fd.write("\n")
1953     stations_fd.close()
1954     print("done (%s sections)." % count)
1955     message = "Writing %s..." % zctas_fn
1956     sys.stdout.write(message)
1957     sys.stdout.flush()
1958     count = 0
1959     if os.path.exists(zctas_fn):
1960         os.rename(zctas_fn, "%s_old"%zctas_fn)
1961     zctas_fd = codecs.open(zctas_fn, "w", "utf8")
1962     zctas_fd.write(header)
1963     for zcta in sorted( zctas.keys() ):
1964         zctas_fd.write("\n\n[%s]" % zcta)
1965         for key, value in sorted( zctas[zcta].items() ):
1966             if type(value) is float: value = "%.7f"%value
1967             elif type(value) is tuple:
1968                 elements = []
1969                 for element in value:
1970                     if type(element) is float: elements.append("%.7f"%element)
1971                     else: elements.append( repr(element) )
1972                 value = "(%s)"%", ".join(elements)
1973             zctas_fd.write( "\n%s = %s" % (key, value) )
1974         count += 1
1975     zctas_fd.write("\n")
1976     zctas_fd.close()
1977     print("done (%s sections)." % count)
1978     message = "Writing %s..." % zones_fn
1979     sys.stdout.write(message)
1980     sys.stdout.flush()
1981     count = 0
1982     if os.path.exists(zones_fn):
1983         os.rename(zones_fn, "%s_old"%zones_fn)
1984     zones_fd = codecs.open(zones_fn, "w", "utf8")
1985     zones_fd.write(header)
1986     for zone in sorted( zones.keys() ):
1987         zones_fd.write("\n\n[%s]" % zone)
1988         for key, value in sorted( zones[zone].items() ):
1989             if type(value) is float: value = "%.7f"%value
1990             elif type(value) is tuple:
1991                 elements = []
1992                 for element in value:
1993                     if type(element) is float: elements.append("%.7f"%element)
1994                     else: elements.append( repr(element) )
1995                 value = "(%s)"%", ".join(elements)
1996             zones_fd.write( "\n%s = %s" % (key, value) )
1997         count += 1
1998     zones_fd.write("\n")
1999     zones_fd.close()
2000     print("done (%s sections)." % count)
2001     message = "Starting QA check..."
2002     sys.stdout.write(message)
2003     sys.stdout.flush()
2004     airports = configparser.ConfigParser()
2005     airports.read(airports_fn)
2006     places = configparser.ConfigParser()
2007     places.read(places_fn)
2008     stations = configparser.ConfigParser()
2009     stations.read(stations_fn)
2010     zctas = configparser.ConfigParser()
2011     zctas.read(zctas_fn)
2012     zones = configparser.ConfigParser()
2013     zones.read(zones_fn)
2014     qalog = []
2015     places_nocentroid = 0
2016     places_nodescription = 0
2017     for place in sorted( places.sections() ):
2018         if not places.has_option(place, "centroid"):
2019             qalog.append("%s: no centroid\n" % place)
2020             places_nocentroid += 1
2021         if not places.has_option(place, "description"):
2022             qalog.append("%s: no description\n" % place)
2023             places_nodescription += 1
2024     stations_nodescription = 0
2025     stations_nolocation = 0
2026     stations_nometar = 0
2027     for station in sorted( stations.sections() ):
2028         if not stations.has_option(station, "description"):
2029             qalog.append("%s: no description\n" % station)
2030             stations_nodescription += 1
2031         if not stations.has_option(station, "location"):
2032             qalog.append("%s: no location\n" % station)
2033             stations_nolocation += 1
2034         if not stations.has_option(station, "metar"):
2035             qalog.append("%s: no metar\n" % station)
2036             stations_nometar += 1
2037     airports_badstation = 0
2038     airports_nostation = 0
2039     for airport in sorted( airports.sections() ):
2040         if not airports.has_option(airport, "station"):
2041             qalog.append("%s: no station\n" % airport)
2042             airports_nostation += 1
2043         else:
2044             station = airports.get(airport, "station")
2045             if station not in stations.sections():
2046                 qalog.append( "%s: bad station %s\n" % (airport, station) )
2047                 airports_badstation += 1
2048     zctas_nocentroid = 0
2049     for zcta in sorted( zctas.sections() ):
2050         if not zctas.has_option(zcta, "centroid"):
2051             qalog.append("%s: no centroid\n" % zcta)
2052             zctas_nocentroid += 1
2053     zones_nocentroid = 0
2054     zones_nodescription = 0
2055     zones_noforecast = 0
2056     zones_overlapping = 0
2057     zonetable = {}
2058     for zone in zones.sections():
2059         if zones.has_option(zone, "centroid"):
2060             zonetable[zone] = {
2061                 "centroid": eval( zones.get(zone, "centroid") )
2062             }
2063     for zone in sorted( zones.sections() ):
2064         if zones.has_option(zone, "centroid"):
2065             zonetable_local = zonetable.copy()
2066             del( zonetable_local[zone] )
2067             centroid = eval( zones.get(zone, "centroid") )
2068             if centroid:
2069                 nearest = closest(centroid, zonetable_local, "centroid", 0.1)
2070             if nearest[1]*radian_to_km < 1:
2071                 qalog.append( "%s: within one km of %s\n" % (
2072                     zone,
2073                     nearest[0]
2074                 ) )
2075                 zones_overlapping += 1
2076         else:
2077             qalog.append("%s: no centroid\n" % zone)
2078             zones_nocentroid += 1
2079         if not zones.has_option(zone, "description"):
2080             qalog.append("%s: no description\n" % zone)
2081             zones_nodescription += 1
2082         if not zones.has_option(zone, "zone_forecast"):
2083             qalog.append("%s: no forecast\n" % zone)
2084             zones_noforecast += 1
2085     if os.path.exists(qalog_fn):
2086         os.rename(qalog_fn, "%s_old"%qalog_fn)
2087     qalog_fd = codecs.open(qalog_fn, "w", "utf8")
2088     qalog_fd.writelines(qalog)
2089     qalog_fd.close()
2090     if qalog:
2091         print("issues found (see %s for details):"%qalog_fn)
2092         if airports_badstation:
2093             print("   %s airports with invalid station"%airports_badstation)
2094         if airports_nostation:
2095             print("   %s airports with no station"%airports_nostation)
2096         if places_nocentroid:
2097             print("   %s places with no centroid"%places_nocentroid)
2098         if places_nodescription:
2099             print("   %s places with no description"%places_nodescription)
2100         if stations_nodescription:
2101             print("   %s stations with no description"%stations_nodescription)
2102         if stations_nolocation:
2103             print("   %s stations with no location"%stations_nolocation)
2104         if stations_nometar:
2105             print("   %s stations with no METAR"%stations_nometar)
2106         if zctas_nocentroid:
2107             print("   %s ZCTAs with no centroid"%zctas_nocentroid)
2108         if zones_nocentroid:
2109             print("   %s zones with no centroid"%zones_nocentroid)
2110         if zones_nodescription:
2111             print("   %s zones with no description"%zones_nodescription)
2112         if zones_noforecast:
2113             print("   %s zones with no forecast"%zones_noforecast)
2114         if zones_overlapping:
2115             print("   %s zones within one km of another"%zones_overlapping)
2116     else: print("no issues found.")
2117     print("Indexing complete!")