書き直すのとデカいままのどっちが牛刀? (METAR/SPECI 解析)

平均海水面レベルの気温が欲しいぞと。

08-23追記: ここにあげたコードは bitbucket の公開レポジトリとしてあげた。少しだけインターフェイス変えてます。

緯度から気温なんかわからんかなぁ、なんて少しだけ探ってみるも、そりゃ無謀なのな。宇宙空間の気温だったら、solar radiation だけで求まっちゃうけれど、大気圏内はそうはいかぬ。6月の南中高度が最も高いのに気温のピークが7~8月に来るのは水の比熱の関係だよ、とかね。エラく楽しそうな計算だなぁ、とちょっと惹かれはしたけど、無理無理。

とちょっとばかし頭を巡らせてて、そういや METAR があったなと。例によって NOAA が API サービスしている。知らない人のために…というかほとんどの人は知らんと思うけど…、これは、「定時飛行場実況気象通報式」といって、基本的に航空関係者向けの気象通報。かなり歴史あるはず。(なお、METAR とその他空港関係補助情報をくっつけた ATIS てのもある。これは基本的に「放送」。一般人でも「聞く気になれば」聞けるけど普通は聞けない。)

昔から使われているものであるし、何十年も前のハードウェア向けに/あるいは「訓練されたスペシャリストが読めればいいという割り切り」で、かなり独特な「電文」になってて、初めて読む人には暗号にしかみえない:

昨日の羽田空港
1 RJTT 221100Z 20017KT 9999 -SHRA FEW015 BKN100 29/25 Q1000 NOSIG
昨日の成田空港
1 RJAA 221100Z 21014KT 170V250 9999 -SHRA FEW015 FEW030CB SCT100 BKN140 27/25 Q1000 NOSIG

全部を読める必要がある人とない人がいるわけで、「ぱんぴー」はまずは気温/露点温度のとこだけ知っとくだけでも「はじめられる」。27/25 が「気温℃/露点温度℃」の意味。(ただ、負数は -25 とかではなくて M25。)

さて。NOAA の API サービスで好きなだけ METAR お取り寄せできるのはいい。なんだけど、「XML 化」であれ「JSON 化」であれ、なんか中途半端なのな。全情報を抜き出してるわけではなくて、なので「RMK 読みたきゃ自力で raw_text 読め!」と突如どん底、てわけだ。

なのでなんかインフラないかなぁ、と、python-metarを見つけた。が…、動かす前から既に気に入らない。そもそも依存物が多過ぎる:

  1. matplotlib
  2. pandas
  3. six
  4. pip
  5. nose
  6. ipython-notebook
  7. openpyxl

six, pip, nose はいいとしても、METAR 解析だけのために pandas はなかろ? PC で使うぶんにはいいんだけど、あたしゃ android スマホで使いたいのだ。それだけじゃなくて、metar.py の作りが気に入らない。

実際問題 METAR/SPECI/TAF とかを「機械処理」するのにキツいのは「フィールド分解」のほうであって、「OVC は overcast の意味なんだぜ」なんてことは別に「あとでいい」のだ。慣れりゃこんなん憶えてられるもんだから。なのに metar.py はここで全部の「human readable」化を頑張っちゃう。このさぁ、「_handleXxx」を外から差し込めるようにしたら? など、ソフトウェアとしてもちょっと気に喰わない。

ちょっとダルいかなぁ、とは思ったんだけれども、ただ、そもそも書かれてる正規表現も仕様の網羅性もざっと見問題はなさそうなので、基本正規表現だけぱくって全書き換えはすぐに出来るだろうし、何せ http://weather.noaa.gov/pub/data/observations/metar/cycles/01Z.TXT (01Z 部分は 00~23) でテストデータが簡単に手に入る。

というわけで…実はほんの少しだけ(オリジナルに引きずられて)迷走したんだけれど、もう思い切ってばっさり単純化した:

metar.py (本格的なテストはしてないので信頼し過ぎずに、でもご自由に使ってみればよかろう)
  1 # -*- coding: utf-8 -*-
  2 #
  3 # Copyright (c) 2016, hhsprings <https://bitbucket.org/hhsprings>
  4 # All rights reserved.
  5 # 
  6 # Redistribution and use in source and binary forms, with or without
  7 # modification, are permitted provided that the following conditions
  8 # are met:
  9 # 
 10 # - Redistributions of source code must retain the above copyright
 11 #   notice, this list of conditions and the following disclaimer.
 12 # 
 13 # - Redistributions in binary form must reproduce the above copyright
 14 #   notice, this list of conditions and the following disclaimer in
 15 #   the documentation and/or other materials provided with the
 16 #   distribution.
 17 # 
 18 # - Neither the name of the hhsprings nor the names of its contributors
 19 #   may be used to endorse or promote products derived from this software
 20 #   without specific prior written permission.
 21 # 
 22 # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 23 # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
 24 # TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
 25 # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
 26 # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
 27 # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
 28 # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 29 # PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 30 # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 31 # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 32 # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 33 #
 34 # =======================================================================
 35 #
 36 # This script is almost all based on
 37 # python-metar <https://github.com/phobson/python-metar> by Tom Pollard,
 38 # but my version is very simple to avoid depend on many third party
 39 # libraries.
 40 #
 41 # Explanations from python-metar:
 42 # --------------------------------------------------------------
 43 #  US conventions for METAR/SPECI reports are described in chapter 12 of
 44 #  the Federal Meteorological Handbook No.1. (FMH-1 1995), issued by NOAA.
 45 #  See <http://metar.noaa.gov/>
 46 #
 47 #  International conventions for the METAR and SPECI codes are specified in
 48 #  the WMO Manual on Codes, vol I.1, Part A (WMO-306 I.i.A).
 49 #
 50 #  This module handles a reports that follow the US conventions, as well
 51 #  the more general encodings in the WMO spec.  Other regional conventions
 52 #  are not supported at present.
 53 #
 54 #  The current METAR report for a given station is available at the URL
 55 #  http://weather.noaa.gov/pub/data/observations/metar/stations/<station>.TXT
 56 #  where <station> is the four-letter ICAO station code.
 57 #
 58 #  The METAR reports for all reporting stations for any "cycle" (i.e., hour)
 59 #  in the last 24 hours is available in a single file at the URL
 60 #  http://weather.noaa.gov/pub/data/observations/metar/cycles/<cycle>Z.TXT
 61 #  where <cycle> is a 2-digit cycle number (e.g., "00", "05" or "23").
 62 # --------------------------------------------------------------
 63 #
 64 # =======================================================================
 65 #
 66 #
 67 #
 68 #
 69 import re
 70 
 71 # ---------------------------------------------------------------------------
 72 #
 73 # Module globals
 74 #
 75 
 76 # regular expressions for header informations
 77 _HEADERS_RGXES = [
 78     ("type", re.compile(r"^(?P<type>METAR|SPECI)\s+")),
 79     ("station", re.compile(r"^(?P<station>[A-Z][A-Z0-9]{3})\s+")),
 80     ("time", re.compile(r"""^(?P<time>\d\d\d\d\d\dZ?)\s+""")),
 81     ("modifier", re.compile(r"^(?P<mod>AUTO|FINO|NIL|TEST|CORR?|RTD|CC[A-G])\s+")),
 82 ]
 83 
 84 # common regexp
 85 _WIND_RGX = re.compile(r"""^(?P<dir>[\dO]{3}|[0O]|///|MMM|VRB)
 86                           (?P<speed>P?[\dO]{2,3}|[0O]+|[/M]{2,3})
 87                         (G(?P<gust>P?(\d{1,3}|[/M]{1,3})))?
 88                           (?P<units>KTS?|LT|K|T|KMH|MPS)?
 89                       (\s+(?P<varfrom>\d\d\d)V
 90                           (?P<varto>\d\d\d))?\s+""",
 91                           re.VERBOSE)
 92 _VISIBILITY_RGX = re.compile(r"""^(?P<vis>(?P<dist>(M|P)?\d\d\d\d|////)
 93                                             (?P<dir>[NSEW][EW]? | NDV)? |
 94                                         (?P<distu>(M|P)?(\d+|\d\d?/\d\d?|\d+\s+\d/\d))
 95                                            (?P<units>SM|KM|M|U) |
 96                                         CAVOK )\s+""",
 97                                  re.VERBOSE)
 98 _WEATHER_RGX = re.compile(r"""^(?P<int>(-|\+|VC)*)
 99                              (?P<desc>(MI|PR|BC|DR|BL|SH|TS|FZ)+)?
100                              (?P<prec>(DZ|RA|SN|SG|IC|PL|GR|GS|UP|/)*)
101                              (?P<obsc>BR|FG|FU|VA|DU|SA|HZ|PY)?
102                              (?P<other>PO|SQ|FC|SS|DS|NSW|/+)?
103                              (?P<int2>[-+])?\s+""",
104                              re.VERBOSE)
105 _SKY_RGX = re.compile(r"""^(?P<cover>VV|CLR|SKC|SCK|NSC|NCD|BKN|SCT|FEW|[O0]VC|///)
106                         (?P<height>[\dO]{2,4}|///)?
107                         (?P<cloud>([A-Z][A-Z]+|///))?\s+""",
108                         re.VERBOSE)
109 _COLOR_RGX = re.compile(r"""^(BLACK)?(BLU|GRN|WHT|RED)\+?
110                         (/?(BLACK)?(BLU|GRN|WHT|RED)\+?)*\s*""",
111                         re.VERBOSE)
112 
113 # [(name, rgx, repeatable), ...]
114 _BASEINFO_RGXES = [
115     #
116     ("wind", _WIND_RGX, False),
117 
118     #
119     ("visibility", _VISIBILITY_RGX, True),
120 
121     #
122     ("RVR", re.compile(r"""^(RVRNO |
123                              R(?P<name>\d\d(RR?|LL?|C)?)/
124                               (?P<low>(M|P)?\d\d\d\d)
125                             (V(?P<high>(M|P)?\d\d\d\d))?
126                               (?P<unit>FT)?[/NDU]*)\s+""",
127                               re.VERBOSE), True),
128 
129     #
130     ("weather", _WEATHER_RGX, True),
131 
132     #
133     ("sky", _SKY_RGX, True),
134 
135     #
136     ("temp/dewp", re.compile(r"""^(?P<temp>(M|-)?\d+|//|XX|MM)/
137                           (?P<dewpt>(M|-)?\d+|//|XX|MM)?\s+""",
138                           re.VERBOSE), False),
139 
140     #
141     ("qnh", re.compile(r"""^(?P<unit>A|Q|QNH|SLP)?
142                            (?P<press>[\dO]{3,4}|////)
143                            (?P<unit2>INS)?\s+""",
144                            re.VERBOSE), True),
145 
146     #
147     ("recent weather", re.compile(r"""^RE(?P<desc>MI|PR|BC|DR|BL|SH|TS|FZ)?
148                               (?P<prec>(DZ|RA|SN|SG|IC|PL|GR|GS|UP)*)?
149                               (?P<obsc>BR|FG|FU|VA|DU|SA|HZ|PY)?
150                               (?P<other>PO|SQ|FC|SS|DS)?\s+""",
151                               re.VERBOSE), True),
152     #
153     ("wind shear", re.compile(r"^(WS\s+)?(ALL\s+RWY|RWY(?P<name>\d\d(RR?|L?|C)?))\s+"), True),
154 
155     # what is this? i don't know... (hhs)
156     ("color", _COLOR_RGX, True),
157 
158     #
159     ("runway state", re.compile(r"""((?P<name>\d\d) | R(?P<namenew>\d\d)(RR?|LL?|C)?/?)
160                                 ((?P<special> SNOCLO|CLRD(\d\d|//)) |
161                                  (?P<deposit>(\d|/))
162                                  (?P<extent>(\d|/))
163                                  (?P<depth>(\d\d|//))
164                                  (?P<friction>(\d\d|//)))\s+""",
165                              re.VERBOSE), True),
166 ]
167 
168 # trend
169 _TREND_RGX = re.compile(r"^(?P<trend>TEMPO|BECMG|FCST|NOSIG)\s*")
170 
171 _TRENDINFO_RGXES = [
172     ("trend time", re.compile(r"(?P<when>(FM|TL|AT))(?P<hour>\d\d)(?P<min>\d\d)\s+"), True),
173     # FIXME: maybe not ok... (wind=7000?)
174     ("wind", _WIND_RGX, True),
175     ("visibility", _VISIBILITY_RGX, True),
176     ("weather", _WEATHER_RGX, True),
177     ("sky", _SKY_RGX, True),
178     ("color", _COLOR_RGX, True),
179 ]
180 
181 # remarks
182 _REMARK_RGX = re.compile(r"^(RMKS?|NOSPECI|NOSIG)\s*")
183 
184 _REMARKINFO_RGXES = [
185     ("auto", re.compile(r"^AO(?P<type>\d)\s+")),
186     ("sealvl press", re.compile(r"^SLP(?P<press>\d\d\d)\s+")),
187     ("peak wind", re.compile(r"""^P[A-Z]\s+WND\s+
188                                (?P<dir>\d\d\d)
189                                (?P<speed>P?\d\d\d?)/
190                                (?P<hour>\d\d)?
191                                (?P<min>\d\d)\s+""",
192                                re.VERBOSE)),
193     ("wind shift", re.compile(r"""^WSHFT\s+
194                                 (?P<hour>\d\d)?
195                                 (?P<min>\d\d)
196                                 (\s+(?P<front>FROPA))?\s+""",
197                                 re.VERBOSE)),
198     ("precipitation 1hr", re.compile(r"^P(?P<precip>\d\d\d\d)\s+")),
199     ("precipitation 24hr", re.compile(r"""^(?P<type>6|7)
200                                  (?P<precip>\d\d\d\d)\s+""",
201                                  re.VERBOSE)),
202     ("press 3hr", re.compile(r"""^5(?P<tend>[0-8])
203                                 (?P<press>\d\d\d)\s+""",
204                                 re.VERBOSE)),
205     ("temp 1hr", re.compile(r"""^T(?P<tsign>0|1)
206                                (?P<temp>\d\d\d)
207                                ((?P<dsign>0|1)
208                                (?P<dewpt>\d\d\d))?\s+""",
209                                re.VERBOSE)),
210     ("temp 6hr", re.compile(r"""^(?P<type>1|2)
211                               (?P<sign>0|1)
212                               (?P<temp>\d\d\d)\s+""",
213                               re.VERBOSE)),
214     ("temp 24hr", re.compile(r"""^4(?P<smaxt>0|1)
215                                 (?P<maxt>\d\d\d)
216                                 (?P<smint>0|1)
217                                 (?P<mint>\d\d\d)\s+""",
218                                 re.VERBOSE)),
219     ("lightning", re.compile(r"""^((?P<freq>OCNL|FRQ|CONS)\s+)?
220                              LTG(?P<type>(IC|CC|CG|CA)*)
221                                 ( \s+(?P<loc>( OHD | VC | DSNT\s+ | \s+AND\s+ |
222                                  [NSEW][EW]? (-[NSEW][EW]?)* )+) )?\s+""",
223                                 re.VERBOSE)),
224     ("ts loc", re.compile(r"""TS(\s+(?P<loc>( OHD | VC | DSNT\s+ | \s+AND\s+ |
225                                            [NSEW][EW]? (-[NSEW][EW]?)* )+))?
226                                           ( \s+MOV\s+(?P<dir>[NSEW][EW]?) )?\s+""",
227                            re.VERBOSE)),
228     ("snowdepth", re.compile(r"""^4/(?P<snowdepth>\d\d\d)\s+""")),
229     ("__unk", re.compile(r"(?P<group>\S+)\s+")),
230 ]
231 
232 
233 # ------------------------------------------------
234 
235 def metar_splitter(code):
236     def _compactdict(d):
237         return {k: v for k, v in d.items() if v is not None}
238 
239     code += " "
240 
241     # headers
242     result = {}
243     result["header"] = {}
244     for n, rgx in _HEADERS_RGXES:
245         m = rgx.match(code)
246         if m:
247             result["header"][n] = m.group(1)
248             code = code[m.end():]
249 
250     # base
251     result["base"] = []
252     for n, rgx, repeatable in _BASEINFO_RGXES:
253         m = rgx.match(code)
254         while m:
255             r = (m.group(0).rstrip(), _compactdict(m.groupdict()))
256             result["base"].append((n, r))
257             code = code[m.end():]
258             if not repeatable:
259                 break
260             m = rgx.match(code)
261 
262     # trend
263     m = _TREND_RGX.match(code)
264     if m:
265         result["trend"] = (m.group(0).rstrip(), [])
266         code = code[m.end():]
267         for n, rgx, repeatable in _TRENDINFO_RGXES:
268             m = rgx.match(code)
269             while m:
270                 r = (m.group(0).rstrip(), _compactdict(m.groupdict()))
271                 result["trend"][1].append((n, r))
272                 code = code[m.end():]
273                 if not repeatable:
274                     break
275                 m = rgx.match(code)
276 
277     # remarks
278     m = _REMARK_RGX.match(code)
279     if m:
280         result["remark"] = (m.group(0).rstrip(), [])
281         code = code[m.end():]
282         while code:
283             for n, rgx in _REMARKINFO_RGXES:
284                 m = rgx.match(code)
285                 if m:
286                     if n != "__unk":
287                         r = (m.group(0).rstrip(), _compactdict(m.groupdict()))
288                     else:
289                         r = (m.group(0).rstrip(),)
290                     result["remark"][1].append((n, r))
291                     code = code[m.end():]
292                     break
293 
294     return result
295 
296 
297 #
298 if __name__ == '__main__':
299     for line in open("11Z.txt", "r").readlines():
300         result = metar_splitter(line.strip())
301         print(result)
302 
303         # print header
304         #print(result["header"])
305 
306         # print all bases
307         #print(result["base"])
308 
309         # print only originals
310         #print([r[1][0] for r in result["base"]])
311         # print only parsed
312         #print([str(r[1]) for r in result["base"]])
313         #if "remark" in result:
314         #    print(" ".join([str(r[1][0]) for r in result["remark"][1]]))

基本的には正規表現が分解する group のレベルでの「親切」は引き継いでいるけれど、そのあとの「一般人でも理解出来る」ように読み替える処理は完全に削っているし、なんなら「数値を python 数値に」でさえやってない。それをするのは「splitter」の責務ではないであろう。(なお、「FIXME」とある部分、おかしいと思うんだけど、METAR の仕様が今ひとつあいまいでよくわからない。wind として「7000」て形式はないと思うんだけど、そんなデータが今日のにあった。)

一応、さっきの「RJAA」のレコードを処理すると result はこんな構造(読みやすさのために json で示す)で返って来る:

  1 {
  2   "trend": [
  3     "NOSIG", 
  4     []
  5   ], 
  6   "header": {
  7     "station": "RJAA", 
  8     "time": "221100Z"
  9   }, 
 10   "base": [
 11     [
 12       "wind", 
 13       [
 14         "21014KT 170V250", 
 15         {
 16           "units": "KT", 
 17           "speed": "14", 
 18           "varfrom": "170", 
 19           "varto": "250", 
 20           "dir": "210"
 21         }
 22       ]
 23     ], 
 24     [
 25       "visibility", 
 26       [
 27         "9999", 
 28         {
 29           "vis": "9999", 
 30           "dist": "9999"
 31         }
 32       ]
 33     ], 
 34     [
 35       "weather", 
 36       [
 37         "-SHRA", 
 38         {
 39           "int": "-", 
 40           "prec": "RA", 
 41           "desc": "SH"
 42         }
 43       ]
 44     ], 
 45     [
 46       "sky", 
 47       [
 48         "FEW015", 
 49         {
 50           "cover": "FEW", 
 51           "height": "015"
 52         }
 53       ]
 54     ], 
 55     [
 56       "sky", 
 57       [
 58         "FEW030CB", 
 59         {
 60           "cover": "FEW", 
 61           "cloud": "CB", 
 62           "height": "030"
 63         }
 64       ]
 65     ], 
 66     [
 67       "sky", 
 68       [
 69         "SCT100", 
 70         {
 71           "cover": "SCT", 
 72           "height": "100"
 73         }
 74       ]
 75     ], 
 76     [
 77       "sky", 
 78       [
 79         "BKN140", 
 80         {
 81           "cover": "BKN", 
 82           "height": "140"
 83         }
 84       ]
 85     ], 
 86     [
 87       "temp/dewp", 
 88       [
 89         "27/25", 
 90         {
 91           "dewpt": "25", 
 92           "temp": "27"
 93         }
 94       ]
 95     ], 
 96     [
 97       "qnh", 
 98       [
 99         "Q1000", 
100         {
101           "press": "1000", 
102           "unit": "Q"
103         }
104       ]
105     ]
106   ]
107 }

全て「(“フィールドの名前”, [“もとのテキスト”, {正規表現の名前付きパターンで分解されたフィールド辞書}])」みたいな形にしてあって、なおかつトップレベルで「header」「base」「trend」「remark」と特定して取りだせるようにした。普通この base にしか興味がないとかそんななんだよね。remark は読むと面白いけどねぇ。「B737 が waypoint BANBI の 8000feet で晴天乱気流を報告」みたいなことが書かれてる。あといわゆる「強烈な天気(航空に支障をきたす気象現象)」が実は(知ってれば)一撃でわかるようになってたりもする。(例えば「凄まじく雲頂の高い積乱雲」だとか。)

さて。あとは NOAA の API からのダウンローダだな。それと「現在地点から一番近くて海水面相当の空港はどこだ」検索も必要だ、っと。

ところで、METAR あてにするんだったらこれを航空の方式と同じく QNH てのもあるよなぁ、なんて思ったりもした。(Q1000 が QNH 規正値が 1000hPa、てイミ。)