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