meteostat 1.7.6__py3-none-any.whl → 2.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- meteostat/__init__.py +38 -19
- meteostat/api/config.py +158 -0
- meteostat/api/daily.py +76 -0
- meteostat/api/hourly.py +80 -0
- meteostat/api/interpolate.py +378 -0
- meteostat/api/inventory.py +59 -0
- meteostat/api/merge.py +103 -0
- meteostat/api/monthly.py +73 -0
- meteostat/api/normals.py +144 -0
- meteostat/api/point.py +30 -0
- meteostat/api/stations.py +234 -0
- meteostat/api/timeseries.py +334 -0
- meteostat/core/cache.py +212 -59
- meteostat/core/data.py +203 -0
- meteostat/core/logger.py +9 -0
- meteostat/core/network.py +82 -0
- meteostat/core/parameters.py +112 -0
- meteostat/core/providers.py +184 -0
- meteostat/core/schema.py +170 -0
- meteostat/core/validator.py +38 -0
- meteostat/enumerations.py +149 -0
- meteostat/interpolation/idw.py +120 -0
- meteostat/interpolation/lapserate.py +91 -0
- meteostat/interpolation/nearest.py +31 -0
- meteostat/parameters.py +354 -0
- meteostat/providers/dwd/climat.py +166 -0
- meteostat/providers/dwd/daily.py +144 -0
- meteostat/providers/dwd/hourly.py +218 -0
- meteostat/providers/dwd/monthly.py +138 -0
- meteostat/providers/dwd/mosmix.py +351 -0
- meteostat/providers/dwd/poi.py +117 -0
- meteostat/providers/dwd/shared.py +155 -0
- meteostat/providers/eccc/daily.py +87 -0
- meteostat/providers/eccc/hourly.py +104 -0
- meteostat/providers/eccc/monthly.py +66 -0
- meteostat/providers/eccc/shared.py +45 -0
- meteostat/providers/index.py +496 -0
- meteostat/providers/meteostat/daily.py +65 -0
- meteostat/providers/meteostat/daily_derived.py +110 -0
- meteostat/providers/meteostat/hourly.py +66 -0
- meteostat/providers/meteostat/monthly.py +45 -0
- meteostat/providers/meteostat/monthly_derived.py +106 -0
- meteostat/providers/meteostat/shared.py +93 -0
- meteostat/providers/metno/forecast.py +186 -0
- meteostat/providers/noaa/ghcnd.py +228 -0
- meteostat/providers/noaa/isd_lite.py +142 -0
- meteostat/providers/noaa/metar.py +163 -0
- meteostat/typing.py +113 -0
- meteostat/utils/conversions.py +231 -0
- meteostat/utils/data.py +194 -0
- meteostat/utils/geo.py +28 -0
- meteostat/utils/guards.py +51 -0
- meteostat/utils/parsers.py +161 -0
- meteostat/utils/types.py +113 -0
- meteostat/utils/validators.py +31 -0
- meteostat-2.0.1.dist-info/METADATA +130 -0
- meteostat-2.0.1.dist-info/RECORD +64 -0
- {meteostat-1.7.6.dist-info → meteostat-2.0.1.dist-info}/WHEEL +1 -2
- meteostat/core/loader.py +0 -103
- meteostat/core/warn.py +0 -34
- meteostat/enumerations/granularity.py +0 -22
- meteostat/interface/base.py +0 -39
- meteostat/interface/daily.py +0 -118
- meteostat/interface/hourly.py +0 -154
- meteostat/interface/meteodata.py +0 -210
- meteostat/interface/monthly.py +0 -109
- meteostat/interface/normals.py +0 -245
- meteostat/interface/point.py +0 -143
- meteostat/interface/stations.py +0 -252
- meteostat/interface/timeseries.py +0 -237
- meteostat/series/aggregate.py +0 -48
- meteostat/series/convert.py +0 -28
- meteostat/series/count.py +0 -17
- meteostat/series/coverage.py +0 -20
- meteostat/series/fetch.py +0 -28
- meteostat/series/interpolate.py +0 -47
- meteostat/series/normalize.py +0 -76
- meteostat/series/stations.py +0 -22
- meteostat/units.py +0 -149
- meteostat/utilities/__init__.py +0 -0
- meteostat/utilities/aggregations.py +0 -37
- meteostat/utilities/endpoint.py +0 -33
- meteostat/utilities/helpers.py +0 -70
- meteostat/utilities/mutations.py +0 -89
- meteostat/utilities/validations.py +0 -30
- meteostat-1.7.6.dist-info/METADATA +0 -112
- meteostat-1.7.6.dist-info/RECORD +0 -39
- meteostat-1.7.6.dist-info/top_level.txt +0 -1
- /meteostat/{core → api}/__init__.py +0 -0
- /meteostat/{enumerations → interpolation}/__init__.py +0 -0
- /meteostat/{interface → providers}/__init__.py +0 -0
- /meteostat/{interface/interpolate.py → py.typed} +0 -0
- /meteostat/{series → utils}/__init__.py +0 -0
- {meteostat-1.7.6.dist-info → meteostat-2.0.1.dist-info/licenses}/LICENSE +0 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Process GHCND data
|
|
3
|
+
|
|
4
|
+
This code is based on an implementation by
|
|
5
|
+
Aaron Penne (https://github.com/aaronpenne).
|
|
6
|
+
|
|
7
|
+
The code is licensed under the MIT license.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from io import StringIO
|
|
11
|
+
from ftplib import FTP
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from numpy import nan
|
|
15
|
+
import pandas as pd
|
|
16
|
+
|
|
17
|
+
from meteostat.enumerations import TTL, Parameter
|
|
18
|
+
from meteostat.typing import ProviderRequest
|
|
19
|
+
from meteostat.core.cache import cache_service
|
|
20
|
+
from meteostat.utils.conversions import ms_to_kmh, percentage_to_okta
|
|
21
|
+
|
|
22
|
+
FTP_SERVER = "ftp.ncdc.noaa.gov"
|
|
23
|
+
COLUMN_NAMES = {
|
|
24
|
+
"MM/DD/YYYY": "time",
|
|
25
|
+
"TMAX": Parameter.TMAX,
|
|
26
|
+
"TMIN": Parameter.TMIN,
|
|
27
|
+
"TAVG": Parameter.TEMP,
|
|
28
|
+
"PRCP": Parameter.PRCP,
|
|
29
|
+
"SNWD": Parameter.SNWD,
|
|
30
|
+
"AWDR": Parameter.WDIR,
|
|
31
|
+
"AWND": Parameter.WSPD,
|
|
32
|
+
"TSUN": Parameter.TSUN,
|
|
33
|
+
"WSFG": Parameter.WPGT,
|
|
34
|
+
"ACMC": Parameter.CLDC,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def connect_to_ftp():
|
|
39
|
+
"""
|
|
40
|
+
Connect to FTP server
|
|
41
|
+
"""
|
|
42
|
+
ftp = FTP(FTP_SERVER)
|
|
43
|
+
ftp.login()
|
|
44
|
+
|
|
45
|
+
return ftp
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_flags(string):
|
|
49
|
+
"""
|
|
50
|
+
Get flags, replacing empty flags with '_' for clarity (' S ' becomes '_S_')
|
|
51
|
+
"""
|
|
52
|
+
m_flag = string.read(1)
|
|
53
|
+
m_flag = m_flag if m_flag.strip() else "_"
|
|
54
|
+
q_flag = string.read(1)
|
|
55
|
+
q_flag = q_flag if q_flag.strip() else "_"
|
|
56
|
+
s_flag = string.read(1)
|
|
57
|
+
s_flag = s_flag if s_flag.strip() else "_"
|
|
58
|
+
|
|
59
|
+
return [m_flag + q_flag + s_flag]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def create_df(element, dict_element):
|
|
63
|
+
"""
|
|
64
|
+
Create dataframes from dicts, turn indices into date strings (YYYY-MM-DD)
|
|
65
|
+
"""
|
|
66
|
+
element = element.upper()
|
|
67
|
+
df_element = pd.DataFrame(dict_element)
|
|
68
|
+
|
|
69
|
+
# Add dates (YYYY-MM-DD) as index on df. Pad days with zeros to two places
|
|
70
|
+
df_element.index = (
|
|
71
|
+
df_element["YEAR"]
|
|
72
|
+
+ "-"
|
|
73
|
+
+ df_element["MONTH"]
|
|
74
|
+
+ "-"
|
|
75
|
+
+ df_element["DAY"].str.zfill(2)
|
|
76
|
+
)
|
|
77
|
+
df_element.index.name = "DATE"
|
|
78
|
+
|
|
79
|
+
# Arrange columns so ID, YEAR, MONTH, DAY are at front. Leaving them in
|
|
80
|
+
# for plotting later - https://stackoverflow.com/a/31396042
|
|
81
|
+
for col in ["DAY", "MONTH", "YEAR", "ID"]:
|
|
82
|
+
df_element = move_col_to_front(col, df_element)
|
|
83
|
+
|
|
84
|
+
# Convert numerical values to float
|
|
85
|
+
df_element.loc[:, element] = df_element.loc[:, element].astype(float)
|
|
86
|
+
|
|
87
|
+
return df_element
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def move_col_to_front(element, df):
|
|
91
|
+
"""
|
|
92
|
+
Move DataFrame column to position 0
|
|
93
|
+
"""
|
|
94
|
+
element = element.upper()
|
|
95
|
+
cols = df.columns.tolist()
|
|
96
|
+
cols.insert(0, cols.pop(cols.index(element)))
|
|
97
|
+
df = df.reindex(columns=cols)
|
|
98
|
+
|
|
99
|
+
return df
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# pylint: disable=too-many-locals
|
|
103
|
+
def dly_to_df(ftp, station_id):
|
|
104
|
+
"""
|
|
105
|
+
Convert .dly files to DataFrame
|
|
106
|
+
"""
|
|
107
|
+
ftp_filename = station_id + ".dly"
|
|
108
|
+
|
|
109
|
+
# Write .dly file to stream using StringIO using FTP command 'RETR'
|
|
110
|
+
stream = StringIO()
|
|
111
|
+
ftp.retrlines("RETR " + "/pub/data/ghcn/daily/all/" + ftp_filename, stream.write)
|
|
112
|
+
|
|
113
|
+
# Move to first char in file
|
|
114
|
+
stream.seek(0)
|
|
115
|
+
|
|
116
|
+
# File params
|
|
117
|
+
num_chars_line = 269
|
|
118
|
+
|
|
119
|
+
# Read through entire StringIO stream (the .dly file)
|
|
120
|
+
# and collect the data
|
|
121
|
+
all_dicts = {}
|
|
122
|
+
element_flag = {}
|
|
123
|
+
index = 0
|
|
124
|
+
|
|
125
|
+
while True:
|
|
126
|
+
index += 1
|
|
127
|
+
|
|
128
|
+
# Read metadata for each line
|
|
129
|
+
# (one month of data for a particular element per line)
|
|
130
|
+
stream.read(11) # station ID (not used in this loop)
|
|
131
|
+
year = stream.read(4)
|
|
132
|
+
month = stream.read(2)
|
|
133
|
+
day = 0
|
|
134
|
+
element = stream.read(4)
|
|
135
|
+
|
|
136
|
+
# If this is blank then we've reached EOF and should exit loop
|
|
137
|
+
if not element:
|
|
138
|
+
break
|
|
139
|
+
|
|
140
|
+
# Loop through each day in rest of row,
|
|
141
|
+
# break if current position is end of row
|
|
142
|
+
while stream.tell() % num_chars_line != 0:
|
|
143
|
+
day += 1
|
|
144
|
+
# Fill in contents of each dict depending on element type in
|
|
145
|
+
# current row
|
|
146
|
+
if day == 1:
|
|
147
|
+
try:
|
|
148
|
+
element_flag[element]
|
|
149
|
+
except BaseException:
|
|
150
|
+
element_flag[element] = 1
|
|
151
|
+
all_dicts[element] = {}
|
|
152
|
+
all_dicts[element]["ID"] = []
|
|
153
|
+
all_dicts[element]["YEAR"] = []
|
|
154
|
+
all_dicts[element]["MONTH"] = []
|
|
155
|
+
all_dicts[element]["DAY"] = []
|
|
156
|
+
all_dicts[element][element.upper()] = []
|
|
157
|
+
all_dicts[element][element.upper() + "_FLAGS"] = []
|
|
158
|
+
|
|
159
|
+
value = stream.read(5)
|
|
160
|
+
flags = get_flags(stream)
|
|
161
|
+
if value == "-9999":
|
|
162
|
+
continue
|
|
163
|
+
all_dicts[element]["ID"] += [station_id]
|
|
164
|
+
all_dicts[element]["YEAR"] += [year]
|
|
165
|
+
all_dicts[element]["MONTH"] += [month]
|
|
166
|
+
all_dicts[element]["DAY"] += [str(day)]
|
|
167
|
+
all_dicts[element][element.upper()] += [value]
|
|
168
|
+
all_dicts[element][element.upper() + "_FLAGS"] += flags
|
|
169
|
+
|
|
170
|
+
# Create dataframes from dict
|
|
171
|
+
all_dfs = {}
|
|
172
|
+
for element in list(all_dicts.keys()):
|
|
173
|
+
all_dfs[element] = create_df(element, all_dicts[element])
|
|
174
|
+
|
|
175
|
+
# Combine all element dataframes into one dataframe,
|
|
176
|
+
# indexed on date.
|
|
177
|
+
#
|
|
178
|
+
# pd.concat automagically aligns values to matching indices, therefore the
|
|
179
|
+
# data is date aligned!
|
|
180
|
+
list_dfs = []
|
|
181
|
+
for df in list(all_dfs.keys()):
|
|
182
|
+
list_dfs += [all_dfs[df]]
|
|
183
|
+
df_all = pd.concat(list_dfs, axis=1, sort=False)
|
|
184
|
+
df_all.index.name = "MM/DD/YYYY"
|
|
185
|
+
|
|
186
|
+
# Remove duplicated/broken columns and rows
|
|
187
|
+
# https://stackoverflow.com/a/40435354
|
|
188
|
+
df_all = df_all.loc[:, ~df_all.columns.duplicated()]
|
|
189
|
+
df_all = df_all.loc[df_all["ID"].notnull(), :]
|
|
190
|
+
|
|
191
|
+
return df_all
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@cache_service.cache(TTL.DAY, "pickle")
|
|
195
|
+
def get_df(station: str) -> pd.DataFrame:
|
|
196
|
+
ftp = connect_to_ftp()
|
|
197
|
+
df = dly_to_df(ftp, station)
|
|
198
|
+
# Filter relevant columns
|
|
199
|
+
df = df.drop(columns=[col for col in df if col not in COLUMN_NAMES.keys()])
|
|
200
|
+
# Add missing columns
|
|
201
|
+
for col in list(COLUMN_NAMES.keys())[1:]:
|
|
202
|
+
if col not in df.columns:
|
|
203
|
+
df[col] = nan
|
|
204
|
+
|
|
205
|
+
# Rename columns
|
|
206
|
+
df = df.reset_index().rename(columns=COLUMN_NAMES)
|
|
207
|
+
|
|
208
|
+
# Adapt columns
|
|
209
|
+
df["time"] = pd.to_datetime(df["time"])
|
|
210
|
+
df[Parameter.TEMP] = df[Parameter.TEMP].div(10)
|
|
211
|
+
df[Parameter.TMIN] = df[Parameter.TMIN].div(10)
|
|
212
|
+
df[Parameter.TMAX] = df[Parameter.TMAX].div(10)
|
|
213
|
+
df[Parameter.PRCP] = df[Parameter.PRCP].div(10)
|
|
214
|
+
df[Parameter.SNWD] = df[Parameter.SNWD].div(10)
|
|
215
|
+
df[Parameter.WSPD] = df[Parameter.WSPD].div(10).apply(ms_to_kmh)
|
|
216
|
+
df[Parameter.WPGT] = df[Parameter.WPGT].div(10).apply(ms_to_kmh)
|
|
217
|
+
df[Parameter.CLDC] = df[Parameter.CLDC].apply(percentage_to_okta)
|
|
218
|
+
|
|
219
|
+
return df.set_index("time")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def fetch(req: ProviderRequest) -> Optional[pd.DataFrame]:
|
|
223
|
+
ghcn_id = (
|
|
224
|
+
req.station.identifiers["ghcn"] if "ghcn" in req.station.identifiers else None
|
|
225
|
+
)
|
|
226
|
+
if not ghcn_id:
|
|
227
|
+
return None
|
|
228
|
+
return get_df(ghcn_id)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import Optional, Union
|
|
3
|
+
from urllib.error import HTTPError
|
|
4
|
+
|
|
5
|
+
from numpy import isnan
|
|
6
|
+
import pandas as pd
|
|
7
|
+
|
|
8
|
+
from meteostat.enumerations import TTL, Parameter
|
|
9
|
+
from meteostat.core.logger import logger
|
|
10
|
+
from meteostat.core.cache import cache_service
|
|
11
|
+
from meteostat.typing import ProviderRequest
|
|
12
|
+
from meteostat.utils.conversions import ms_to_kmh, temp_dwpt_to_rhum
|
|
13
|
+
|
|
14
|
+
ISD_LITE_ENDPOINT = "https://www.ncei.noaa.gov/pub/data/noaa/isd-lite/"
|
|
15
|
+
COLSPECS = [
|
|
16
|
+
(0, 4),
|
|
17
|
+
(5, 7),
|
|
18
|
+
(8, 10),
|
|
19
|
+
(11, 13),
|
|
20
|
+
(13, 19),
|
|
21
|
+
(19, 25),
|
|
22
|
+
(25, 31),
|
|
23
|
+
(31, 37),
|
|
24
|
+
(37, 43),
|
|
25
|
+
(43, 49),
|
|
26
|
+
(49, 55),
|
|
27
|
+
]
|
|
28
|
+
COLUMN_NAMES = [
|
|
29
|
+
"time",
|
|
30
|
+
Parameter.TEMP,
|
|
31
|
+
Parameter.DWPT,
|
|
32
|
+
Parameter.PRES,
|
|
33
|
+
Parameter.WDIR,
|
|
34
|
+
Parameter.WSPD,
|
|
35
|
+
Parameter.CLDC,
|
|
36
|
+
Parameter.PRCP,
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def map_sky_code(code: Union[int, str]) -> Optional[int]:
|
|
41
|
+
"""
|
|
42
|
+
Only accept okta
|
|
43
|
+
"""
|
|
44
|
+
return int(code) if not isnan(code) and int(code) >= 0 and int(code) <= 8 else None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def get_ttl(_usaf: str, _wban: str, year: int) -> int:
|
|
48
|
+
"""
|
|
49
|
+
Get TTL based on year
|
|
50
|
+
|
|
51
|
+
Current + previous year = one day
|
|
52
|
+
Else = 30 days
|
|
53
|
+
"""
|
|
54
|
+
current_year = datetime.now().year
|
|
55
|
+
return TTL.DAY if current_year - year < 2 else TTL.MONTH
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@cache_service.cache(get_ttl, "pickle")
|
|
59
|
+
def get_df(usaf: str, wban: str, year: int) -> Optional[pd.DataFrame]:
|
|
60
|
+
if not usaf:
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
filename = f"{usaf}-{wban if wban else '99999'}-{year}.gz"
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
df = pd.read_fwf(
|
|
67
|
+
f"{ISD_LITE_ENDPOINT}{year}/{filename}",
|
|
68
|
+
parse_dates={"time": [0, 1, 2, 3]},
|
|
69
|
+
na_values=["-9999", -9999],
|
|
70
|
+
header=None,
|
|
71
|
+
colspecs=COLSPECS,
|
|
72
|
+
compression="gzip",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# Rename columns
|
|
76
|
+
df.columns = COLUMN_NAMES
|
|
77
|
+
|
|
78
|
+
# Adapt columns
|
|
79
|
+
df[Parameter.TEMP] = df[Parameter.TEMP].div(10)
|
|
80
|
+
df[Parameter.DWPT] = df[Parameter.DWPT].div(10)
|
|
81
|
+
df[Parameter.PRES] = df[Parameter.PRES].div(10)
|
|
82
|
+
df[Parameter.WSPD] = df[Parameter.WSPD].div(10).apply(ms_to_kmh)
|
|
83
|
+
df[Parameter.CLDC] = df[Parameter.CLDC].apply(map_sky_code)
|
|
84
|
+
df[Parameter.PRCP] = df[Parameter.PRCP].div(10)
|
|
85
|
+
|
|
86
|
+
# Calculate humidity data
|
|
87
|
+
# pylint: disable=unnecessary-lambda
|
|
88
|
+
df[Parameter.RHUM] = df.apply(lambda row: temp_dwpt_to_rhum(row), axis=1)
|
|
89
|
+
|
|
90
|
+
# Drop dew point column
|
|
91
|
+
# pylint: disable=no-member
|
|
92
|
+
df = df.drop(Parameter.DWPT, axis=1)
|
|
93
|
+
|
|
94
|
+
# Set index
|
|
95
|
+
df = df.set_index("time")
|
|
96
|
+
|
|
97
|
+
# Round decimals
|
|
98
|
+
return df.round(1)
|
|
99
|
+
|
|
100
|
+
except HTTPError as error:
|
|
101
|
+
if error.status == 404:
|
|
102
|
+
logger.info(f"ISD Lite file not found: {filename}")
|
|
103
|
+
else:
|
|
104
|
+
logger.warning(
|
|
105
|
+
f"Couldn't load ISD Lite file {filename} (status: {error.status})"
|
|
106
|
+
)
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
except Exception as error:
|
|
110
|
+
logger.warning(error)
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def fetch(req: ProviderRequest) -> Optional[pd.DataFrame]:
|
|
115
|
+
""" """
|
|
116
|
+
if req.start is None or req.end is None:
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
years = range(req.start.year, req.end.year + 1)
|
|
120
|
+
data = tuple(
|
|
121
|
+
map(
|
|
122
|
+
lambda i: get_df(*i),
|
|
123
|
+
(
|
|
124
|
+
(
|
|
125
|
+
(
|
|
126
|
+
req.station.identifiers["usaf"]
|
|
127
|
+
if "usaf" in req.station.identifiers
|
|
128
|
+
else None
|
|
129
|
+
),
|
|
130
|
+
(
|
|
131
|
+
req.station.identifiers["wban"]
|
|
132
|
+
if "wban" in req.station.identifiers
|
|
133
|
+
else None
|
|
134
|
+
),
|
|
135
|
+
year,
|
|
136
|
+
)
|
|
137
|
+
for year in years
|
|
138
|
+
),
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
return pd.concat(data) if len(data) and not all(d is None for d in data) else None
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The code is licensed under the MIT license.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
import pandas as pd
|
|
8
|
+
from metar import Metar
|
|
9
|
+
|
|
10
|
+
from meteostat.core.logger import logger
|
|
11
|
+
from meteostat.api.config import config
|
|
12
|
+
from meteostat.enumerations import TTL, Frequency, Parameter
|
|
13
|
+
from meteostat.typing import ProviderRequest
|
|
14
|
+
from meteostat.utils.conversions import temp_dwpt_to_rhum
|
|
15
|
+
from meteostat.core.cache import cache_service
|
|
16
|
+
from meteostat.utils.data import enforce_freq
|
|
17
|
+
from meteostat.core.network import network_service
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
ENDPOINT = config.aviationweather_endpoint
|
|
21
|
+
USER_AGENT = config.aviationweather_user_agent
|
|
22
|
+
CLDC_MAP = {
|
|
23
|
+
"FEW": 2, # 1-2 octas
|
|
24
|
+
"SCT": 4, # 3-4 octas
|
|
25
|
+
"BKN": 6, # 5-7 octas
|
|
26
|
+
"OVC": 8, # 8 octas (fully overcast)
|
|
27
|
+
}
|
|
28
|
+
COCO_MAP = {
|
|
29
|
+
"RA": 8,
|
|
30
|
+
"SHRA": 17,
|
|
31
|
+
"DZ": 7,
|
|
32
|
+
"DZRA": 7,
|
|
33
|
+
"FZRA": 10,
|
|
34
|
+
"FZDZ": 10,
|
|
35
|
+
"RASN": 12,
|
|
36
|
+
"SN": 15,
|
|
37
|
+
"SHSN": 21,
|
|
38
|
+
"SG": 12,
|
|
39
|
+
"IC": 12,
|
|
40
|
+
"PL": 24,
|
|
41
|
+
"GR": 24,
|
|
42
|
+
"GS": 24,
|
|
43
|
+
"FG": 5,
|
|
44
|
+
"BR": 5,
|
|
45
|
+
"MIFG": 5,
|
|
46
|
+
"BCFG": 5,
|
|
47
|
+
"HZ": 5,
|
|
48
|
+
"TS": 25,
|
|
49
|
+
"TSRA": 25,
|
|
50
|
+
"PO": 27,
|
|
51
|
+
"SQ": 27,
|
|
52
|
+
"FC": 27,
|
|
53
|
+
"SS": 27,
|
|
54
|
+
"DS": 27,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def safe_get(obj: Optional[Any]) -> Optional[Any]:
|
|
59
|
+
try:
|
|
60
|
+
if obj is not None and hasattr(obj, "value"):
|
|
61
|
+
return obj.value()
|
|
62
|
+
return None
|
|
63
|
+
except AttributeError:
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def get_cldc(report: Metar.Metar) -> Optional[int]:
|
|
68
|
+
"""
|
|
69
|
+
Get cloud cover (octas) from METAR report
|
|
70
|
+
"""
|
|
71
|
+
try:
|
|
72
|
+
cloud_cover = report.sky[0][0]
|
|
73
|
+
return CLDC_MAP.get(cloud_cover)
|
|
74
|
+
except IndexError:
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def get_coco(report: Metar.Metar) -> Optional[int]:
|
|
79
|
+
"""
|
|
80
|
+
Get weather condition code from METAR report
|
|
81
|
+
"""
|
|
82
|
+
try:
|
|
83
|
+
condition_code = "".join(
|
|
84
|
+
[item for item in report.weather[0] if item is not None]
|
|
85
|
+
)
|
|
86
|
+
return COCO_MAP.get(condition_code)
|
|
87
|
+
except IndexError:
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def map_data(record):
|
|
92
|
+
"""
|
|
93
|
+
Map METAR data to Meteostat column names
|
|
94
|
+
"""
|
|
95
|
+
try:
|
|
96
|
+
parsed_report = Metar.Metar(record)
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
"time": parsed_report.time,
|
|
100
|
+
Parameter.TEMP: safe_get(parsed_report.temp),
|
|
101
|
+
Parameter.DWPT: safe_get(parsed_report.dewpt),
|
|
102
|
+
Parameter.PRCP: safe_get(parsed_report.precip_1hr),
|
|
103
|
+
Parameter.SNWD: safe_get(parsed_report.snowdepth),
|
|
104
|
+
Parameter.WDIR: safe_get(parsed_report.wind_dir),
|
|
105
|
+
Parameter.WSPD: safe_get(parsed_report.wind_speed),
|
|
106
|
+
Parameter.WPGT: safe_get(parsed_report.wind_gust),
|
|
107
|
+
Parameter.PRES: safe_get(parsed_report.press),
|
|
108
|
+
Parameter.VSBY: safe_get(parsed_report.vis),
|
|
109
|
+
Parameter.CLDC: get_cldc(parsed_report),
|
|
110
|
+
Parameter.COCO: get_coco(parsed_report),
|
|
111
|
+
}
|
|
112
|
+
except Metar.ParserError:
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@cache_service.cache(TTL.HOUR, "pickle")
|
|
117
|
+
def get_df(station: str) -> Optional[pd.DataFrame]:
|
|
118
|
+
"""
|
|
119
|
+
Get CSV file from Meteostat and convert to DataFrame
|
|
120
|
+
"""
|
|
121
|
+
url = ENDPOINT.format(station=station)
|
|
122
|
+
|
|
123
|
+
if not USER_AGENT:
|
|
124
|
+
logger.warning(
|
|
125
|
+
"Consider specifying a unique user agent when querying the Aviation Weather Center's data API."
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
headers = {"User-Agent": USER_AGENT}
|
|
129
|
+
|
|
130
|
+
response = network_service.get(url, headers=headers)
|
|
131
|
+
|
|
132
|
+
# Raise an exception if the request was unsuccessful
|
|
133
|
+
response.raise_for_status()
|
|
134
|
+
|
|
135
|
+
# Parse the JSON content into a DataFrame
|
|
136
|
+
data = [
|
|
137
|
+
item for item in map(map_data, response.text.splitlines()) if item is not None
|
|
138
|
+
]
|
|
139
|
+
|
|
140
|
+
# Return None if no data is available
|
|
141
|
+
if not len(data):
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
# Create DataFrame
|
|
145
|
+
df = pd.DataFrame(data)
|
|
146
|
+
|
|
147
|
+
# Return None if DataFrame is empty
|
|
148
|
+
if df.empty:
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
# Add RHUM column
|
|
152
|
+
df[Parameter.RHUM] = df.apply(lambda row: temp_dwpt_to_rhum(row), axis=1)
|
|
153
|
+
df[Parameter.RHUM] = df[Parameter.RHUM].round()
|
|
154
|
+
|
|
155
|
+
# Set time index
|
|
156
|
+
df = df.set_index(["time"])
|
|
157
|
+
|
|
158
|
+
return enforce_freq(df, Frequency.HOURLY)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def fetch(req: ProviderRequest) -> Optional[pd.DataFrame]:
|
|
162
|
+
if "icao" in req.station.identifiers:
|
|
163
|
+
return get_df(req.station.identifiers["icao"])
|
meteostat/typing.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Meteostat Typing
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import date, datetime
|
|
7
|
+
from typing import Callable, List, Literal, Optional
|
|
8
|
+
|
|
9
|
+
from meteostat.core.validator import Validator
|
|
10
|
+
from meteostat.enumerations import (
|
|
11
|
+
Grade,
|
|
12
|
+
Priority,
|
|
13
|
+
Granularity,
|
|
14
|
+
Parameter,
|
|
15
|
+
Provider,
|
|
16
|
+
Unit,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Station:
|
|
22
|
+
"""
|
|
23
|
+
A weather station
|
|
24
|
+
|
|
25
|
+
For virtual stations created from Point objects, some fields will be None.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
id: str # The Meteostat station ID (e.g., "10637" or "$0001" for virtual stations)
|
|
29
|
+
name: Optional[str] = None # The (usually English) name of the station
|
|
30
|
+
country: Optional[str] = None # ISO 3166-1 alpha-2 country code
|
|
31
|
+
region: Optional[str] = None # ISO 3166-2 state or region code
|
|
32
|
+
identifiers: dict[str, str] = field(default_factory=dict) # Provider identifiers
|
|
33
|
+
latitude: Optional[float] = None # The latitude in degrees
|
|
34
|
+
longitude: Optional[float] = None # The longitude in degrees
|
|
35
|
+
elevation: Optional[int] = None # The elevation in meters
|
|
36
|
+
timezone: Optional[str] = None # The IANA timezone name
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class License:
|
|
41
|
+
"""
|
|
42
|
+
A license
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
commercial: bool
|
|
46
|
+
attribution: Optional[str] = None
|
|
47
|
+
name: Optional[str] = None
|
|
48
|
+
url: Optional[str] = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class ProviderSpec:
|
|
53
|
+
"""
|
|
54
|
+
A provider's meta data
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
id: Provider | str # The provider ID
|
|
58
|
+
name: str # A descriptive provider name
|
|
59
|
+
granularity: Granularity # The provider's time series granularity
|
|
60
|
+
priority: Priority | int # The priority of the provider
|
|
61
|
+
grade: Optional[Grade] # The provider's data quality grade
|
|
62
|
+
license: Optional[License] # The provider's license
|
|
63
|
+
requires: List[
|
|
64
|
+
Literal["id", "identifiers", "location"]
|
|
65
|
+
] # Required station properties for the provider
|
|
66
|
+
parameters: List[Parameter] # List of supported meteorological parameters
|
|
67
|
+
start: date # The start date of the provider's data
|
|
68
|
+
end: Optional[datetime] = None # The end date of the provider's data
|
|
69
|
+
countries: Optional[list[str]] = None # List of supported countries
|
|
70
|
+
module: Optional[str] = None # Module path to the provider's API
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class ParameterSpec:
|
|
75
|
+
"""
|
|
76
|
+
A parameter's meta data
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
id: Parameter | str # The parameter ID
|
|
80
|
+
name: str # A descriptive parameter name
|
|
81
|
+
granularity: Granularity # The parameter's granularity
|
|
82
|
+
dtype: str # The parameter's data type
|
|
83
|
+
unit: Optional[Unit] = None # The parameter's data unit
|
|
84
|
+
validators: List[Validator | Callable] = field(
|
|
85
|
+
default_factory=list
|
|
86
|
+
) # The parameter's validators
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class Request:
|
|
91
|
+
"""
|
|
92
|
+
A request to fetch meteorological time series data
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
granularity: Granularity # Query's time series granularity
|
|
96
|
+
providers: List[Provider] # Providers to query
|
|
97
|
+
parameters: List[Parameter] # Schema of the query's data
|
|
98
|
+
station: Station | List[Station] # Station(s) to query
|
|
99
|
+
start: Optional[datetime] = None # Start date of the query
|
|
100
|
+
end: Optional[datetime] = None # End date of the query
|
|
101
|
+
timezone: Optional[str] = None # Time zone of the query's data
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass
|
|
105
|
+
class ProviderRequest:
|
|
106
|
+
"""
|
|
107
|
+
A query to fetch meteorological data from a provider
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
station: Station # Station to query
|
|
111
|
+
parameters: List[Parameter] # List of meteorological parameters to query
|
|
112
|
+
start: Optional[datetime] # Start date of the query
|
|
113
|
+
end: Optional[datetime] # End date of the query
|