meteostat 1.7.5__py3-none-any.whl → 2.0.0__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.
Files changed (93) hide show
  1. meteostat/__init__.py +32 -19
  2. meteostat/api/daily.py +76 -0
  3. meteostat/api/hourly.py +80 -0
  4. meteostat/api/interpolate.py +240 -0
  5. meteostat/api/inventory.py +59 -0
  6. meteostat/api/merge.py +103 -0
  7. meteostat/api/monthly.py +73 -0
  8. meteostat/api/normals.py +144 -0
  9. meteostat/api/point.py +30 -0
  10. meteostat/api/stations.py +234 -0
  11. meteostat/api/timeseries.py +334 -0
  12. meteostat/core/cache.py +212 -59
  13. meteostat/core/config.py +158 -0
  14. meteostat/core/data.py +199 -0
  15. meteostat/core/logger.py +9 -0
  16. meteostat/core/network.py +82 -0
  17. meteostat/core/parameters.py +112 -0
  18. meteostat/core/providers.py +184 -0
  19. meteostat/core/schema.py +170 -0
  20. meteostat/core/validator.py +38 -0
  21. meteostat/enumerations.py +149 -0
  22. meteostat/interpolation/idw.py +120 -0
  23. meteostat/interpolation/lapserate.py +91 -0
  24. meteostat/interpolation/nearest.py +31 -0
  25. meteostat/parameters.py +354 -0
  26. meteostat/providers/dwd/climat.py +166 -0
  27. meteostat/providers/dwd/daily.py +144 -0
  28. meteostat/providers/dwd/hourly.py +218 -0
  29. meteostat/providers/dwd/monthly.py +138 -0
  30. meteostat/providers/dwd/mosmix.py +351 -0
  31. meteostat/providers/dwd/poi.py +117 -0
  32. meteostat/providers/dwd/shared.py +155 -0
  33. meteostat/providers/eccc/daily.py +87 -0
  34. meteostat/providers/eccc/hourly.py +104 -0
  35. meteostat/providers/eccc/monthly.py +66 -0
  36. meteostat/providers/eccc/shared.py +45 -0
  37. meteostat/providers/index.py +496 -0
  38. meteostat/providers/meteostat/daily.py +65 -0
  39. meteostat/providers/meteostat/daily_derived.py +110 -0
  40. meteostat/providers/meteostat/hourly.py +66 -0
  41. meteostat/providers/meteostat/monthly.py +45 -0
  42. meteostat/providers/meteostat/monthly_derived.py +106 -0
  43. meteostat/providers/meteostat/shared.py +93 -0
  44. meteostat/providers/metno/forecast.py +186 -0
  45. meteostat/providers/noaa/ghcnd.py +228 -0
  46. meteostat/providers/noaa/isd_lite.py +142 -0
  47. meteostat/providers/noaa/metar.py +163 -0
  48. meteostat/typing.py +113 -0
  49. meteostat/utils/conversions.py +231 -0
  50. meteostat/utils/data.py +194 -0
  51. meteostat/utils/geo.py +28 -0
  52. meteostat/utils/parsers.py +168 -0
  53. meteostat/utils/types.py +113 -0
  54. meteostat/utils/validators.py +31 -0
  55. meteostat-2.0.0.dist-info/METADATA +134 -0
  56. meteostat-2.0.0.dist-info/RECORD +63 -0
  57. {meteostat-1.7.5.dist-info → meteostat-2.0.0.dist-info}/WHEEL +1 -2
  58. meteostat/core/loader.py +0 -103
  59. meteostat/core/warn.py +0 -34
  60. meteostat/enumerations/granularity.py +0 -22
  61. meteostat/interface/base.py +0 -39
  62. meteostat/interface/daily.py +0 -118
  63. meteostat/interface/hourly.py +0 -154
  64. meteostat/interface/meteodata.py +0 -210
  65. meteostat/interface/monthly.py +0 -109
  66. meteostat/interface/normals.py +0 -245
  67. meteostat/interface/point.py +0 -143
  68. meteostat/interface/stations.py +0 -252
  69. meteostat/interface/timeseries.py +0 -237
  70. meteostat/series/aggregate.py +0 -48
  71. meteostat/series/convert.py +0 -28
  72. meteostat/series/count.py +0 -17
  73. meteostat/series/coverage.py +0 -20
  74. meteostat/series/fetch.py +0 -28
  75. meteostat/series/interpolate.py +0 -47
  76. meteostat/series/normalize.py +0 -76
  77. meteostat/series/stations.py +0 -22
  78. meteostat/units.py +0 -149
  79. meteostat/utilities/__init__.py +0 -0
  80. meteostat/utilities/aggregations.py +0 -37
  81. meteostat/utilities/endpoint.py +0 -33
  82. meteostat/utilities/helpers.py +0 -70
  83. meteostat/utilities/mutations.py +0 -85
  84. meteostat/utilities/validations.py +0 -30
  85. meteostat-1.7.5.dist-info/METADATA +0 -112
  86. meteostat-1.7.5.dist-info/RECORD +0 -39
  87. meteostat-1.7.5.dist-info/top_level.txt +0 -1
  88. /meteostat/{core → api}/__init__.py +0 -0
  89. /meteostat/{enumerations → interpolation}/__init__.py +0 -0
  90. /meteostat/{interface → providers}/__init__.py +0 -0
  91. /meteostat/{interface/interpolate.py → py.typed} +0 -0
  92. /meteostat/{series → utils}/__init__.py +0 -0
  93. {meteostat-1.7.5.dist-info → meteostat-2.0.0.dist-info/licenses}/LICENSE +0 -0
@@ -0,0 +1,231 @@
1
+ """
2
+ Functions for converting between different
3
+ meteorological data units.
4
+
5
+ The code is licensed under the MIT license.
6
+ """
7
+
8
+ import math
9
+ from typing import Optional
10
+
11
+ from numpy import isnan
12
+
13
+ from meteostat.enumerations import Parameter, Unit, UnitSystem
14
+
15
+
16
+ def celsius_to_fahrenheit(value):
17
+ """
18
+ Convert Celsius to Fahrenheit
19
+ """
20
+
21
+ return round((value * 9 / 5) + 32, 1)
22
+
23
+
24
+ def celsius_to_kelvin(value):
25
+ """
26
+ Convert Celsius to Kelvin
27
+ """
28
+
29
+ return round(value + 273.15, 1)
30
+
31
+
32
+ def millimeters_to_inches(value):
33
+ """
34
+ Convert millimeters to inches
35
+ """
36
+
37
+ return round(value / 25.4, 3)
38
+
39
+
40
+ def centimeters_to_inches(value):
41
+ """
42
+ Convert centimeters to inches
43
+ """
44
+
45
+ return round(value / 2.54, 3)
46
+
47
+
48
+ def meters_to_feet(value):
49
+ """
50
+ Convert meters to feet
51
+ """
52
+
53
+ return round(value / 0.3048, 1)
54
+
55
+
56
+ def kmh_to_ms(value):
57
+ """
58
+ Convert kilometers per hour to meters per second
59
+ """
60
+
61
+ return round(value / 3.6, 1)
62
+
63
+
64
+ def kmh_to_mph(value):
65
+ """
66
+ Convert kilometers per hour to miles per hour
67
+ """
68
+
69
+ return round(value * 0.6214, 1)
70
+
71
+
72
+ def kelvin_to_celsius(value):
73
+ """
74
+ Convert Kelvin to Celsius
75
+ """
76
+ return value - 273.15 if value is not None and not isnan(value) else None
77
+
78
+
79
+ def ms_to_kmh(value):
80
+ """
81
+ Convert m/s to km/h
82
+ """
83
+ return value * 3.6 if value is not None and not isnan(value) else None
84
+
85
+
86
+ def temp_dwpt_to_rhum(row: dict):
87
+ """
88
+ Get relative humidity from temperature and dew point
89
+ """
90
+ return (
91
+ 100
92
+ * (
93
+ math.exp((17.625 * row["dwpt"]) / (243.04 + row["dwpt"]))
94
+ / math.exp((17.625 * row["temp"]) / (243.04 + row["temp"]))
95
+ )
96
+ if row["temp"] is not None and row["dwpt"] is not None
97
+ else None
98
+ )
99
+
100
+
101
+ def pres_to_msl(row: dict, altitude: Optional[int] = None, temp: str = Parameter.TEMP):
102
+ """
103
+ Convert local air pressure to MSL
104
+ """
105
+ try:
106
+ return (
107
+ None
108
+ if (
109
+ not row[Parameter.PRES]
110
+ or not row[temp]
111
+ or not altitude
112
+ or row[Parameter.PRES] == -999
113
+ )
114
+ else round(
115
+ row[Parameter.PRES]
116
+ * math.pow(
117
+ (
118
+ 1
119
+ - (
120
+ (0.0065 * altitude)
121
+ / (row[temp] + 0.0065 * altitude + 273.15)
122
+ )
123
+ ),
124
+ -5.257,
125
+ ),
126
+ 1,
127
+ )
128
+ )
129
+ except Exception:
130
+ return None
131
+
132
+
133
+ def percentage_to_okta(value):
134
+ """
135
+ Convert cloud cover percentage to oktas
136
+ """
137
+ return round(value / 12.5) if value is not None and not isnan(value) else None
138
+
139
+
140
+ def jcm2_to_wm2(value):
141
+ """
142
+ Convert Joule/CM^2 to Watt/M^2
143
+ """
144
+ return round(value * 2.78) if value is not None and not isnan(value) else None
145
+
146
+
147
+ def to_direction(value):
148
+ """
149
+ Convert degrees to wind direction
150
+ """
151
+
152
+ wdir = None
153
+
154
+ if (337 <= value <= 360) or value <= 23:
155
+ wdir = "N"
156
+ if 24 <= value <= 68:
157
+ wdir = "NE"
158
+ if 69 <= value <= 113:
159
+ wdir = "E"
160
+ if 114 <= value <= 158:
161
+ wdir = "SE"
162
+ if 159 <= value <= 203:
163
+ wdir = "S"
164
+ if 204 <= value <= 248:
165
+ wdir = "SW"
166
+ if 249 <= value <= 293:
167
+ wdir = "W"
168
+ if 294 <= value <= 336:
169
+ wdir = "NW"
170
+
171
+ return wdir
172
+
173
+
174
+ def to_condition(value):
175
+ """
176
+ Convert Meteostat condition code to descriptive string
177
+ """
178
+
179
+ if not value or value < 1 or value > 27:
180
+ return None
181
+
182
+ return [
183
+ "Clear",
184
+ "Fair",
185
+ "Cloudy",
186
+ "Overcast",
187
+ "Fog",
188
+ "Freezing Fog",
189
+ "Light Rain",
190
+ "Rain",
191
+ "Heavy Rain",
192
+ "Freezing Rain",
193
+ "Heavy Freezing Rain",
194
+ "Sleet",
195
+ "Heavy Sleet",
196
+ "Light Snowfall",
197
+ "Snowfall",
198
+ "Heavy Snowfall",
199
+ "Rain Shower",
200
+ "Heavy Rain Shower",
201
+ "Sleet Shower",
202
+ "Heavy Sleet Shower",
203
+ "Snow Shower",
204
+ "Heavy Snow Shower",
205
+ "Lightning",
206
+ "Hail",
207
+ "Thunderstorm",
208
+ "Heavy Thunderstorm",
209
+ "Storm",
210
+ ][int(value) - 1]
211
+
212
+
213
+ CONVERSION_MAPPINGS = {
214
+ Unit.CELSIUS: {
215
+ UnitSystem.IMPERIAL: celsius_to_fahrenheit,
216
+ UnitSystem.SI: celsius_to_kelvin,
217
+ },
218
+ Unit.MILLIMETERS: {
219
+ UnitSystem.IMPERIAL: millimeters_to_inches,
220
+ },
221
+ Unit.CENTIMETERS: {
222
+ UnitSystem.IMPERIAL: centimeters_to_inches,
223
+ },
224
+ Unit.KMH: {
225
+ UnitSystem.SI: kmh_to_ms,
226
+ UnitSystem.IMPERIAL: kmh_to_mph,
227
+ },
228
+ Unit.METERS: {
229
+ UnitSystem.IMPERIAL: meters_to_feet,
230
+ },
231
+ }
@@ -0,0 +1,194 @@
1
+ """
2
+ DataFrame mutations
3
+
4
+ Meteorological data provided by Meteostat (https://dev.meteostat.net)
5
+ under the terms of the Creative Commons Attribution-NonCommercial
6
+ 4.0 International Public License.
7
+
8
+ The code is licensed under the MIT license.
9
+ """
10
+
11
+ from collections import Counter
12
+ from datetime import datetime
13
+ from itertools import chain
14
+ from typing import List
15
+
16
+ import numpy as np
17
+ import pandas as pd
18
+
19
+ from meteostat.core.providers import provider_service
20
+ from meteostat.enumerations import Frequency
21
+ from meteostat.typing import Station
22
+
23
+
24
+ def stations_to_df(stations: List[Station]) -> pd.DataFrame:
25
+ """
26
+ Convert list of stations to DataFrame
27
+ """
28
+ assert len(stations) > 0 and all(
29
+ isinstance(station, Station) for station in stations
30
+ )
31
+
32
+ return pd.DataFrame.from_records(
33
+ [
34
+ {
35
+ "id": station.id,
36
+ "name": station.name,
37
+ "country": station.country,
38
+ "latitude": station.latitude,
39
+ "longitude": station.longitude,
40
+ "elevation": station.elevation,
41
+ "timezone": station.timezone,
42
+ }
43
+ for station in stations
44
+ ],
45
+ index="id",
46
+ )
47
+
48
+
49
+ def squash_df(df: pd.DataFrame, sources=False) -> pd.DataFrame:
50
+ """
51
+ Squash a DataFrame based on the source priority
52
+ """
53
+ # Capture the columns
54
+ columns = df.columns
55
+
56
+ # Add source priority column
57
+ df["source_prio"] = df.index.get_level_values("source").map(
58
+ provider_service.get_source_priority
59
+ )
60
+
61
+ # Shift source information to columns
62
+ if sources:
63
+ df = df.reset_index(level="source")
64
+ for column in columns:
65
+ df[f"{column}_source"] = np.where(df[column].notna(), df["source"], np.nan)
66
+ df = df.set_index("source", append=True)
67
+
68
+ # Get highest priority value/source for each station and time
69
+ df = (
70
+ df.groupby(level=["station", "time", "source"])
71
+ .last() # In case of duplicate index, the last row will be prefered
72
+ .sort_values(by="source_prio", ascending=False)
73
+ .groupby(["station", "time"])
74
+ .first() # Prefer value with highest priority
75
+ .drop("source_prio", axis=1)
76
+ )
77
+
78
+ # Order columns and return squashed DataFrame
79
+ return df[order_source_columns(df.columns)] if sources else df
80
+
81
+
82
+ def fill_df(
83
+ df: pd.DataFrame, start: datetime, end: datetime, freq: str
84
+ ) -> pd.DataFrame:
85
+ """
86
+ Fill a DataFrame with a complete date range for each station
87
+ """
88
+ try:
89
+ iterables = [
90
+ df.index.get_level_values("station").unique(),
91
+ pd.date_range(start=start, end=end, freq=freq),
92
+ ]
93
+ names = ["station", "time"]
94
+ if "source" in df.index.names:
95
+ iterables.append(df.index.get_level_values("source").unique())
96
+ names.append("source")
97
+ # Create a new MultiIndex with every hour for each station
98
+ new_index = pd.MultiIndex.from_product(iterables, names=names)
99
+
100
+ # Reindex the DataFrame to add missing rows
101
+ df = df.reindex(index=new_index)
102
+ return df
103
+ except KeyError:
104
+ return df
105
+
106
+
107
+ def localize(df: pd.DataFrame, timezone: str) -> pd.DataFrame:
108
+ """
109
+ Convert time data to any time zone
110
+ """
111
+ return df.tz_localize("UTC", level="time").tz_convert(timezone, level="time")
112
+
113
+
114
+ def reshape_by_source(df: pd.DataFrame) -> pd.DataFrame:
115
+ """
116
+ Reshape a DataFrame so that the source columns are pivoted into
117
+ their own level of the index
118
+ """
119
+ # Extract value rows and source rows
120
+ value_rows = df.loc[:, ~df.columns.str.endswith("_source")]
121
+ source_rows = df.loc[:, df.columns.str.endswith("_source")]
122
+
123
+ # Melt both value_rows and source_rows
124
+ value_melted = value_rows.reset_index().melt(id_vars="time")
125
+ source_melted = source_rows.reset_index().melt(id_vars="time")
126
+
127
+ # Remove '_source' from the variable names in source_melted
128
+ source_melted["variable"] = source_melted["variable"].str.replace("_source", "")
129
+
130
+ # Merge the melted DataFrames
131
+ merged_df = pd.merge(
132
+ value_melted, source_melted, on=["time", "variable"], suffixes=("", "_source")
133
+ )
134
+
135
+ # Drop rows with missing values
136
+ merged_df = merged_df.dropna(subset=["value"])
137
+
138
+ # Pivot the DataFrame
139
+ df_pivoted = merged_df.pivot(
140
+ index=["time", "value_source"], columns="variable", values="value"
141
+ )
142
+
143
+ # Flatten the MultiIndex
144
+ df_pivoted.index = df_pivoted.index.rename(["time", "source"])
145
+ df_pivoted.columns.name = None
146
+
147
+ # Preserve the original parameter column order from value_rows
148
+ # Pivot often produces alphabetical column order; reindex to match input
149
+ ordered_columns = [col for col in value_rows.columns if col in df_pivoted.columns]
150
+ if ordered_columns:
151
+ df_pivoted = df_pivoted.reindex(columns=ordered_columns)
152
+
153
+ return df_pivoted
154
+
155
+
156
+ def aggregate_sources(series: pd.Series) -> str:
157
+ """
158
+ Concatenate multiple data sources into a unique source string,
159
+ ordered by the number of occurrences.
160
+ """
161
+ # Extract sources and flatten them
162
+ sources = [str(item) for item in series if pd.notna(item)]
163
+ flat_sources = list(chain(*[source.split() for source in sources]))
164
+
165
+ # Count occurrences of each source
166
+ source_counts = Counter(flat_sources)
167
+
168
+ # Sort sources by count in descending order
169
+ sorted_sources = sorted(source_counts, key=lambda s: source_counts[s], reverse=True)
170
+
171
+ # Concatenate sorted sources into a unique source string
172
+ return " ".join(sorted_sources)
173
+
174
+
175
+ def enforce_freq(df: pd.DataFrame, freq: Frequency) -> pd.DataFrame:
176
+ """
177
+ Enforce a specific frequency on a DataFrame by resampling
178
+ """
179
+ df.index = pd.to_datetime(df.index.get_level_values("time"))
180
+ return df.resample(freq).first()
181
+
182
+
183
+ def order_source_columns(columns: pd.Index) -> List[str]:
184
+ """
185
+ Order source columns
186
+ """
187
+ ordered_columns = []
188
+
189
+ for col in columns:
190
+ if not col.endswith("_source"):
191
+ ordered_columns.append(col)
192
+ ordered_columns.append(f"{col}_source")
193
+
194
+ return ordered_columns
meteostat/utils/geo.py ADDED
@@ -0,0 +1,28 @@
1
+ """
2
+ Utilities for geographical calculations.
3
+
4
+ The code is licensed under the MIT license.
5
+ """
6
+
7
+ import numpy as np
8
+
9
+
10
+ def get_distance(lat1, lon1, lat2, lon2) -> int:
11
+ """
12
+ Calculate distance between two geographical points using the Haversine formula
13
+ """
14
+ # Earth radius in meters
15
+ radius = 6371000
16
+
17
+ # Degress to radian
18
+ lat1, lon1, lat2, lon2 = map(np.deg2rad, [lat1, lon1, lat2, lon2])
19
+
20
+ # Deltas
21
+ dlat = lat2 - lat1
22
+ dlon = lon2 - lon1
23
+
24
+ # Calculate distance
25
+ arch = np.sin(dlat / 2) ** 2 + np.cos(lat1) * np.cos(lat2) * np.sin(dlon / 2) ** 2
26
+ arch_sin = 2 * np.arcsin(np.sqrt(arch))
27
+
28
+ return round(radius * arch_sin)
@@ -0,0 +1,168 @@
1
+ """
2
+ Data parsers
3
+
4
+ The code is licensed under the MIT license.
5
+ """
6
+
7
+ import calendar
8
+ from typing import List
9
+ import datetime
10
+
11
+ import pandas as pd
12
+ import pytz
13
+
14
+ from meteostat.api.stations import stations as stations_service
15
+ from meteostat.api.point import Point
16
+ from meteostat.core.config import config
17
+ from meteostat.typing import Station
18
+
19
+
20
+ def parse_station(
21
+ station: (str | Station | Point | List[str | Station | Point] | pd.DataFrame),
22
+ ) -> Station | List[Station]:
23
+ """
24
+ Parse one or multiple station(s) or geo point(s)
25
+
26
+ Point objects are converted to virtual stations with IDs like $0001, $0002, etc.
27
+ based on their position in the input list.
28
+
29
+ Returns
30
+ -------
31
+ Station | List[Station]
32
+ - Returns a single Station object for single-station input (str, Station, Point)
33
+ - Returns a list of Station objects for multi-station input (list, pd.Index, etc.)
34
+ """
35
+ # Return data if it contains station meta data (single station)
36
+ if isinstance(station, Station):
37
+ return station
38
+
39
+ # Handle Point objects (single point)
40
+ if isinstance(station, Point):
41
+ return _point_to_station(station, 1)
42
+
43
+ # Handle string (single station ID)
44
+ if isinstance(station, str):
45
+ meta = stations_service.meta(station)
46
+ if meta is None:
47
+ raise ValueError(f'Weather station with ID "{station}" could not be found')
48
+ return meta
49
+
50
+ # Convert station identifier(s) to list (multi-station)
51
+ if isinstance(station, pd.DataFrame):
52
+ stations = station.index.tolist()
53
+ else:
54
+ # It's a list
55
+ stations = station
56
+
57
+ if config.block_large_requests and len(stations) > 10:
58
+ raise ValueError(
59
+ "Requests with more than 10 stations are blocked by default. "
60
+ "To enable large requests, set `config.block_large_requests = False`."
61
+ )
62
+
63
+ # Get station meta data
64
+ data = []
65
+ point_counter = 0
66
+ for s in stations:
67
+ # Append data early if it contains station meta data
68
+ if isinstance(s, Station):
69
+ data.append(s)
70
+ continue
71
+ # Handle Point objects
72
+ if isinstance(s, Point):
73
+ point_counter += 1
74
+ data.append(_point_to_station(s, point_counter))
75
+ continue
76
+ # Get station meta data
77
+ meta = stations_service.meta(s)
78
+ # Raise exception if station could not be found
79
+ if meta is None:
80
+ raise ValueError(f'Weather station with ID "{s}" could not be found')
81
+ # Append station meta data
82
+ data.append(meta)
83
+
84
+ # Return list of station meta data
85
+ return data
86
+
87
+
88
+ def _point_to_station(point: Point, index: int) -> Station:
89
+ """
90
+ Convert a Point object to a virtual Station object
91
+
92
+ Parameters
93
+ ----------
94
+ point : Point
95
+ The Point object to convert
96
+ index : int
97
+ The position in the list of points (1-indexed)
98
+
99
+ Returns
100
+ -------
101
+ Station
102
+ A virtual Station object with an ID like $0001
103
+ """
104
+ # Create virtual station ID
105
+ station_id = f"${index:04d}"
106
+
107
+ # Create Station object with extracted coordinates
108
+ return Station(
109
+ id=station_id,
110
+ name=f"Location #{index}",
111
+ latitude=point.latitude,
112
+ longitude=point.longitude,
113
+ elevation=point.elevation,
114
+ )
115
+
116
+
117
+ def parse_time(
118
+ value: datetime.date | datetime.datetime | None,
119
+ timezone: str | None = None,
120
+ is_end: bool = False,
121
+ ) -> datetime.datetime | None:
122
+ """
123
+ Convert a given date/time input to datetime
124
+
125
+ To set the time of a date to 23:59:59, pass is_end=True
126
+ """
127
+ if not value:
128
+ return None
129
+
130
+ if not isinstance(value, datetime.datetime):
131
+ parsed = datetime.datetime.combine(
132
+ value,
133
+ datetime.datetime.max.time() if is_end else datetime.datetime.min.time(),
134
+ )
135
+ else:
136
+ parsed = value
137
+
138
+ if timezone:
139
+ tz = pytz.timezone(timezone)
140
+ # If the datetime is already aware, convert it directly
141
+ # If it's naive, treat it as being in the target timezone
142
+ if parsed.tzinfo is not None:
143
+ parsed = parsed.astimezone(pytz.utc).replace(tzinfo=None)
144
+ else:
145
+ parsed = tz.localize(parsed).astimezone(pytz.utc).replace(tzinfo=None)
146
+
147
+ return parsed
148
+
149
+
150
+ def parse_month(
151
+ value: datetime.date | datetime.datetime | None, is_end: bool = False
152
+ ) -> datetime.date | None:
153
+ """
154
+ Convert a given date/time input to the first or last day of the month respectively
155
+ """
156
+ if not value:
157
+ return None
158
+
159
+ last_day = calendar.monthrange(value.year, value.month)[1]
160
+
161
+ return datetime.date(value.year, value.month, last_day if is_end else 1)
162
+
163
+
164
+ def parse_year(year: int, is_end: bool = False) -> datetime.date:
165
+ """
166
+ Parse a year into a date, returning either the first or last day of the year
167
+ """
168
+ return datetime.date(year, 12, 31) if is_end else datetime.date(year, 1, 1)