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.
Files changed (94) hide show
  1. meteostat/__init__.py +38 -19
  2. meteostat/api/config.py +158 -0
  3. meteostat/api/daily.py +76 -0
  4. meteostat/api/hourly.py +80 -0
  5. meteostat/api/interpolate.py +378 -0
  6. meteostat/api/inventory.py +59 -0
  7. meteostat/api/merge.py +103 -0
  8. meteostat/api/monthly.py +73 -0
  9. meteostat/api/normals.py +144 -0
  10. meteostat/api/point.py +30 -0
  11. meteostat/api/stations.py +234 -0
  12. meteostat/api/timeseries.py +334 -0
  13. meteostat/core/cache.py +212 -59
  14. meteostat/core/data.py +203 -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/guards.py +51 -0
  53. meteostat/utils/parsers.py +161 -0
  54. meteostat/utils/types.py +113 -0
  55. meteostat/utils/validators.py +31 -0
  56. meteostat-2.0.1.dist-info/METADATA +130 -0
  57. meteostat-2.0.1.dist-info/RECORD +64 -0
  58. {meteostat-1.7.6.dist-info → meteostat-2.0.1.dist-info}/WHEEL +1 -2
  59. meteostat/core/loader.py +0 -103
  60. meteostat/core/warn.py +0 -34
  61. meteostat/enumerations/granularity.py +0 -22
  62. meteostat/interface/base.py +0 -39
  63. meteostat/interface/daily.py +0 -118
  64. meteostat/interface/hourly.py +0 -154
  65. meteostat/interface/meteodata.py +0 -210
  66. meteostat/interface/monthly.py +0 -109
  67. meteostat/interface/normals.py +0 -245
  68. meteostat/interface/point.py +0 -143
  69. meteostat/interface/stations.py +0 -252
  70. meteostat/interface/timeseries.py +0 -237
  71. meteostat/series/aggregate.py +0 -48
  72. meteostat/series/convert.py +0 -28
  73. meteostat/series/count.py +0 -17
  74. meteostat/series/coverage.py +0 -20
  75. meteostat/series/fetch.py +0 -28
  76. meteostat/series/interpolate.py +0 -47
  77. meteostat/series/normalize.py +0 -76
  78. meteostat/series/stations.py +0 -22
  79. meteostat/units.py +0 -149
  80. meteostat/utilities/__init__.py +0 -0
  81. meteostat/utilities/aggregations.py +0 -37
  82. meteostat/utilities/endpoint.py +0 -33
  83. meteostat/utilities/helpers.py +0 -70
  84. meteostat/utilities/mutations.py +0 -89
  85. meteostat/utilities/validations.py +0 -30
  86. meteostat-1.7.6.dist-info/METADATA +0 -112
  87. meteostat-1.7.6.dist-info/RECORD +0 -39
  88. meteostat-1.7.6.dist-info/top_level.txt +0 -1
  89. /meteostat/{core → api}/__init__.py +0 -0
  90. /meteostat/{enumerations → interpolation}/__init__.py +0 -0
  91. /meteostat/{interface → providers}/__init__.py +0 -0
  92. /meteostat/{interface/interpolate.py → py.typed} +0 -0
  93. /meteostat/{series → utils}/__init__.py +0 -0
  94. {meteostat-1.7.6.dist-info → meteostat-2.0.1.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,51 @@
1
+ """
2
+ Guard functions for Meteostat.
3
+
4
+ The code is licensed under the MIT license.
5
+ """
6
+
7
+ from datetime import datetime
8
+ from meteostat.api.config import config
9
+ from meteostat.core.logger import logger
10
+ from meteostat.enumerations import Granularity
11
+ from meteostat.typing import Request
12
+
13
+
14
+ def request_size_guard(req: Request) -> None:
15
+ """
16
+ Guard to block large requests
17
+ """
18
+ if not config.block_large_requests:
19
+ logger.debug("Large request blocking is disabled.")
20
+ return
21
+
22
+ if isinstance(req.station, list) and len(req.station) > 10:
23
+ raise ValueError(
24
+ "Requests with more than 10 stations are blocked by default. "
25
+ "To enable large requests, set `config.block_large_requests = False`."
26
+ )
27
+
28
+ if req.granularity not in [Granularity.HOURLY, Granularity.DAILY]:
29
+ return
30
+
31
+ if req.start is None:
32
+ raise ValueError(
33
+ "Hourly and daily requests without a start date are blocked by default. "
34
+ "To enable large requests, set `config.block_large_requests = False`."
35
+ )
36
+
37
+ time_diff_years = abs((req.end or datetime.now()).year - req.start.year)
38
+
39
+ logger.debug(f"Request time range: {time_diff_years} years.")
40
+
41
+ if req.granularity is Granularity.HOURLY and time_diff_years > 3:
42
+ raise ValueError(
43
+ "Hourly requests longer than 3 years are blocked by default. "
44
+ "To enable large requests, set `config.block_large_requests = False`."
45
+ )
46
+
47
+ if req.granularity is Granularity.DAILY and time_diff_years > 30:
48
+ raise ValueError(
49
+ "Daily requests longer than 30 years are blocked by default. "
50
+ "To enable large requests, set `config.block_large_requests = False`."
51
+ )
@@ -0,0 +1,161 @@
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.typing import Station
17
+
18
+
19
+ def parse_station(
20
+ station: (str | Station | Point | List[str | Station | Point] | pd.DataFrame),
21
+ ) -> Station | List[Station]:
22
+ """
23
+ Parse one or multiple station(s) or geo point(s)
24
+
25
+ Point objects are converted to virtual stations with IDs like $0001, $0002, etc.
26
+ based on their position in the input list.
27
+
28
+ Returns
29
+ -------
30
+ Station | List[Station]
31
+ - Returns a single Station object for single-station input (str, Station, Point)
32
+ - Returns a list of Station objects for multi-station input (list, pd.Index, etc.)
33
+ """
34
+ # Return data if it contains station meta data (single station)
35
+ if isinstance(station, Station):
36
+ return station
37
+
38
+ # Handle Point objects (single point)
39
+ if isinstance(station, Point):
40
+ return _point_to_station(station, 1)
41
+
42
+ # Handle string (single station ID)
43
+ if isinstance(station, str):
44
+ meta = stations_service.meta(station)
45
+ if meta is None:
46
+ raise ValueError(f'Weather station with ID "{station}" could not be found')
47
+ return meta
48
+
49
+ # Convert station identifier(s) to list (multi-station)
50
+ if isinstance(station, pd.DataFrame):
51
+ stations = station.index.tolist()
52
+ else:
53
+ # It's a list
54
+ stations = station
55
+
56
+ # Get station meta data
57
+ data = []
58
+ point_counter = 0
59
+ for s in stations:
60
+ # Append data early if it contains station meta data
61
+ if isinstance(s, Station):
62
+ data.append(s)
63
+ continue
64
+ # Handle Point objects
65
+ if isinstance(s, Point):
66
+ point_counter += 1
67
+ data.append(_point_to_station(s, point_counter))
68
+ continue
69
+ # Get station meta data
70
+ meta = stations_service.meta(s)
71
+ # Raise exception if station could not be found
72
+ if meta is None:
73
+ raise ValueError(f'Weather station with ID "{s}" could not be found')
74
+ # Append station meta data
75
+ data.append(meta)
76
+
77
+ # Return list of station meta data
78
+ return data
79
+
80
+
81
+ def _point_to_station(point: Point, index: int) -> Station:
82
+ """
83
+ Convert a Point object to a virtual Station object
84
+
85
+ Parameters
86
+ ----------
87
+ point : Point
88
+ The Point object to convert
89
+ index : int
90
+ The position in the list of points (1-indexed)
91
+
92
+ Returns
93
+ -------
94
+ Station
95
+ A virtual Station object with an ID like $0001
96
+ """
97
+ # Create virtual station ID
98
+ station_id = f"${index:04d}"
99
+
100
+ # Create Station object with extracted coordinates
101
+ return Station(
102
+ id=station_id,
103
+ name=f"Location #{index}",
104
+ latitude=point.latitude,
105
+ longitude=point.longitude,
106
+ elevation=point.elevation,
107
+ )
108
+
109
+
110
+ def parse_time(
111
+ value: datetime.date | datetime.datetime | None,
112
+ timezone: str | None = None,
113
+ is_end: bool = False,
114
+ ) -> datetime.datetime | None:
115
+ """
116
+ Convert a given date/time input to datetime
117
+
118
+ To set the time of a date to 23:59:59, pass is_end=True
119
+ """
120
+ if not value:
121
+ return None
122
+
123
+ if not isinstance(value, datetime.datetime):
124
+ parsed = datetime.datetime.combine(
125
+ value,
126
+ datetime.datetime.max.time() if is_end else datetime.datetime.min.time(),
127
+ )
128
+ else:
129
+ parsed = value
130
+
131
+ if timezone:
132
+ tz = pytz.timezone(timezone)
133
+ # If the datetime is already aware, convert it directly
134
+ # If it's naive, treat it as being in the target timezone
135
+ if parsed.tzinfo is not None:
136
+ parsed = parsed.astimezone(pytz.utc).replace(tzinfo=None)
137
+ else:
138
+ parsed = tz.localize(parsed).astimezone(pytz.utc).replace(tzinfo=None)
139
+
140
+ return parsed
141
+
142
+
143
+ def parse_month(
144
+ value: datetime.date | datetime.datetime | None, is_end: bool = False
145
+ ) -> datetime.date | None:
146
+ """
147
+ Convert a given date/time input to the first or last day of the month respectively
148
+ """
149
+ if not value:
150
+ return None
151
+
152
+ last_day = calendar.monthrange(value.year, value.month)[1]
153
+
154
+ return datetime.date(value.year, value.month, last_day if is_end else 1)
155
+
156
+
157
+ def parse_year(year: int, is_end: bool = False) -> datetime.date:
158
+ """
159
+ Parse a year into a date, returning either the first or last day of the year
160
+ """
161
+ return datetime.date(year, 12, 31) if is_end else datetime.date(year, 1, 1)