xweather2epw 0.1.0__tar.gz
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.
- xweather2epw-0.1.0/LICENSE +21 -0
- xweather2epw-0.1.0/PKG-INFO +97 -0
- xweather2epw-0.1.0/README.md +80 -0
- xweather2epw-0.1.0/pyproject.toml +27 -0
- xweather2epw-0.1.0/xweather2epw/__init__.py +3 -0
- xweather2epw-0.1.0/xweather2epw/api_client.py +146 -0
- xweather2epw-0.1.0/xweather2epw/cli.py +110 -0
- xweather2epw-0.1.0/xweather2epw/epw_writer.py +323 -0
- xweather2epw-0.1.0/xweather2epw/logcfg.py +32 -0
- xweather2epw-0.1.0/xweather2epw/validator.py +56 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Foobot AI
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: xweather2epw
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A tool that fetches data from the XWeather API and generates EnergyPlus Weather files (EPW)
|
|
5
|
+
Home-page: https://github.com/airboxlab/xweather2epw
|
|
6
|
+
Keywords: weather,epw,energyplus,xweather
|
|
7
|
+
Author: Antoine Galataud
|
|
8
|
+
Author-email: antoine@foobot.io
|
|
9
|
+
Requires-Python: >=3.12,<4.0
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Requires-Dist: click (>=8.1.7,<9.0.0)
|
|
13
|
+
Requires-Dist: requests (>=2.31.0,<3.0.0)
|
|
14
|
+
Project-URL: Repository, https://github.com/airboxlab/xweather2epw
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# XWeather to EPW Converter
|
|
18
|
+
|
|
19
|
+
A tool that fetches data from the [XWeather API](https://www.xweather.com/docs/weather-api/) and generates a full year AMY (Actual Meteorological Year) EnergyPlus Weather file (EPW).
|
|
20
|
+
|
|
21
|
+
The tool takes care of fetching the necessary data from the API, processing it, and formatting it into the EPW format. It's designed for fast and efficient data retrieval.
|
|
22
|
+
|
|
23
|
+
# Installation
|
|
24
|
+
|
|
25
|
+
## Prerequisites
|
|
26
|
+
|
|
27
|
+
You need to have a XWeather API access. Note that at hourly frequency (which is used for EPW generation), each day of data costs 1 API request.
|
|
28
|
+
|
|
29
|
+
## Install the package
|
|
30
|
+
|
|
31
|
+
### From source
|
|
32
|
+
|
|
33
|
+
Clone the current repository and install the required dependencies using [Poetry](https://python-poetry.org/):
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
git clone https://github.com/airboxlab/xweather2epw.git
|
|
37
|
+
cd xweather2epw
|
|
38
|
+
poetry install
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
# Usage
|
|
42
|
+
|
|
43
|
+
Example usage:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
# using poetry, execute in local repository
|
|
47
|
+
poetry run xweather2epw fetch --from '2025-01-01' --to '2025-12-31' --latitude 49.4 --longitude 0.1 --api-key YOUR_KEY --api-secret YOUR_SECRET
|
|
48
|
+
|
|
49
|
+
# specify a custom output file
|
|
50
|
+
poetry run xweather2epw fetch --from '2025-01-01' --to '2025-12-31' --latitude 49.4 --longitude 0.1 --api-key YOUR_KEY --api-secret YOUR_SECRET --output my_weather.epw
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Use `--help` to have a list of available options:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
poetry run xweather2epw --help
|
|
57
|
+
poetry run xweather2epw fetch --help
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Development
|
|
61
|
+
|
|
62
|
+
### Running Tests
|
|
63
|
+
|
|
64
|
+
To run the test suite:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
poetry run test
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
This will execute all unit tests with verbose output.
|
|
71
|
+
|
|
72
|
+
## Features
|
|
73
|
+
|
|
74
|
+
- **Date Range Validation**: Validates input parameters (latitude, longitude, date range)
|
|
75
|
+
- **Smart Chunking**: Automatically splits requests into 15-day chunks to comply with API limits
|
|
76
|
+
- **Date Range Limits**:
|
|
77
|
+
- Maximum duration: 1 year (365 days)
|
|
78
|
+
- Forecast limit: Up to 14 days in the future
|
|
79
|
+
- **Missing Data Handling**: Uses safe default values for missing weather data
|
|
80
|
+
- **Unit Conversion**: Automatically converts units to comply with EPW format (SI units)
|
|
81
|
+
- **EPW Header Generation**: Populates EPW headers with location information from API response
|
|
82
|
+
- **Solar Radiation Data**: Extracts solar radiation data from XWeather API's `solrad` field (GHI, DNI, DHI)
|
|
83
|
+
- **Sequential Processing**: Processes data chunks sequentially for reliability
|
|
84
|
+
|
|
85
|
+
## Limitations
|
|
86
|
+
|
|
87
|
+
- Ground/soil temperatures cannot be calculated from XWeather API data, so the `GROUND TEMPERATURES` header is set to `0`
|
|
88
|
+
- Some advanced EPW fields (like extraterrestrial radiation and illuminance) are not available from the XWeather conditions endpoint and are filled with missing data indicators
|
|
89
|
+
- Historical data availability depends on your XWeather API subscription
|
|
90
|
+
|
|
91
|
+
## Documentation
|
|
92
|
+
|
|
93
|
+
- [XWeather API Documentation](https://www.xweather.com/docs/weather-api/)
|
|
94
|
+
- [XWeather Conditions Endpoint](https://www.xweather.com/docs/weather-api/endpoints/conditions)
|
|
95
|
+
- [EPW Format Specification](https://designbuilder.co.uk/cahelp/Content/EnergyPlusWeatherFileFormat.htm)
|
|
96
|
+
|
|
97
|
+
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# XWeather to EPW Converter
|
|
2
|
+
|
|
3
|
+
A tool that fetches data from the [XWeather API](https://www.xweather.com/docs/weather-api/) and generates a full year AMY (Actual Meteorological Year) EnergyPlus Weather file (EPW).
|
|
4
|
+
|
|
5
|
+
The tool takes care of fetching the necessary data from the API, processing it, and formatting it into the EPW format. It's designed for fast and efficient data retrieval.
|
|
6
|
+
|
|
7
|
+
# Installation
|
|
8
|
+
|
|
9
|
+
## Prerequisites
|
|
10
|
+
|
|
11
|
+
You need to have a XWeather API access. Note that at hourly frequency (which is used for EPW generation), each day of data costs 1 API request.
|
|
12
|
+
|
|
13
|
+
## Install the package
|
|
14
|
+
|
|
15
|
+
### From source
|
|
16
|
+
|
|
17
|
+
Clone the current repository and install the required dependencies using [Poetry](https://python-poetry.org/):
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
git clone https://github.com/airboxlab/xweather2epw.git
|
|
21
|
+
cd xweather2epw
|
|
22
|
+
poetry install
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
# Usage
|
|
26
|
+
|
|
27
|
+
Example usage:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
# using poetry, execute in local repository
|
|
31
|
+
poetry run xweather2epw fetch --from '2025-01-01' --to '2025-12-31' --latitude 49.4 --longitude 0.1 --api-key YOUR_KEY --api-secret YOUR_SECRET
|
|
32
|
+
|
|
33
|
+
# specify a custom output file
|
|
34
|
+
poetry run xweather2epw fetch --from '2025-01-01' --to '2025-12-31' --latitude 49.4 --longitude 0.1 --api-key YOUR_KEY --api-secret YOUR_SECRET --output my_weather.epw
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Use `--help` to have a list of available options:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
poetry run xweather2epw --help
|
|
41
|
+
poetry run xweather2epw fetch --help
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Development
|
|
45
|
+
|
|
46
|
+
### Running Tests
|
|
47
|
+
|
|
48
|
+
To run the test suite:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
poetry run test
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
This will execute all unit tests with verbose output.
|
|
55
|
+
|
|
56
|
+
## Features
|
|
57
|
+
|
|
58
|
+
- **Date Range Validation**: Validates input parameters (latitude, longitude, date range)
|
|
59
|
+
- **Smart Chunking**: Automatically splits requests into 15-day chunks to comply with API limits
|
|
60
|
+
- **Date Range Limits**:
|
|
61
|
+
- Maximum duration: 1 year (365 days)
|
|
62
|
+
- Forecast limit: Up to 14 days in the future
|
|
63
|
+
- **Missing Data Handling**: Uses safe default values for missing weather data
|
|
64
|
+
- **Unit Conversion**: Automatically converts units to comply with EPW format (SI units)
|
|
65
|
+
- **EPW Header Generation**: Populates EPW headers with location information from API response
|
|
66
|
+
- **Solar Radiation Data**: Extracts solar radiation data from XWeather API's `solrad` field (GHI, DNI, DHI)
|
|
67
|
+
- **Sequential Processing**: Processes data chunks sequentially for reliability
|
|
68
|
+
|
|
69
|
+
## Limitations
|
|
70
|
+
|
|
71
|
+
- Ground/soil temperatures cannot be calculated from XWeather API data, so the `GROUND TEMPERATURES` header is set to `0`
|
|
72
|
+
- Some advanced EPW fields (like extraterrestrial radiation and illuminance) are not available from the XWeather conditions endpoint and are filled with missing data indicators
|
|
73
|
+
- Historical data availability depends on your XWeather API subscription
|
|
74
|
+
|
|
75
|
+
## Documentation
|
|
76
|
+
|
|
77
|
+
- [XWeather API Documentation](https://www.xweather.com/docs/weather-api/)
|
|
78
|
+
- [XWeather Conditions Endpoint](https://www.xweather.com/docs/weather-api/endpoints/conditions)
|
|
79
|
+
- [EPW Format Specification](https://designbuilder.co.uk/cahelp/Content/EnergyPlusWeatherFileFormat.htm)
|
|
80
|
+
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "xweather2epw"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A tool that fetches data from the XWeather API and generates EnergyPlus Weather files (EPW)"
|
|
5
|
+
authors = ["Antoine Galataud <antoine@foobot.io>"]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
homepage = "https://github.com/airboxlab/xweather2epw"
|
|
8
|
+
repository = "https://github.com/airboxlab/xweather2epw"
|
|
9
|
+
keywords = ["weather", "epw", "energyplus", "xweather"]
|
|
10
|
+
packages = [{include = "xweather2epw"}]
|
|
11
|
+
|
|
12
|
+
[tool.poetry.dependencies]
|
|
13
|
+
python = "^3.12"
|
|
14
|
+
requests = "^2.31.0"
|
|
15
|
+
click = "^8.1.7"
|
|
16
|
+
|
|
17
|
+
[tool.poetry.group.dev.dependencies]
|
|
18
|
+
pytest = "^7.4.0"
|
|
19
|
+
pytest-cov = "^4.1.0"
|
|
20
|
+
|
|
21
|
+
[tool.poetry.scripts]
|
|
22
|
+
xweather2epw = "xweather2epw.cli:main"
|
|
23
|
+
test = "tests.discover:run"
|
|
24
|
+
|
|
25
|
+
[build-system]
|
|
26
|
+
requires = ["poetry-core"]
|
|
27
|
+
build-backend = "poetry.core.masonry.api"
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""XWeather API client."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from datetime import datetime, timedelta
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
from xweather2epw.logcfg import init_logging
|
|
10
|
+
|
|
11
|
+
init_logging()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class XWeatherClient:
|
|
15
|
+
"""Client for interacting with XWeather API."""
|
|
16
|
+
|
|
17
|
+
BASE_URL = "https://data.api.xweather.com"
|
|
18
|
+
MAX_DAYS_PER_REQUEST = 15
|
|
19
|
+
|
|
20
|
+
def __init__(self, api_key: str, api_secret: str):
|
|
21
|
+
"""Initialize XWeather API client.
|
|
22
|
+
|
|
23
|
+
:param api_key: XWeather API key (client_id)
|
|
24
|
+
:param api_secret: XWeather API secret (client_secret)
|
|
25
|
+
"""
|
|
26
|
+
self.api_key = api_key
|
|
27
|
+
self.api_secret = api_secret
|
|
28
|
+
|
|
29
|
+
def fetch_conditions(
|
|
30
|
+
self, latitude: float, longitude: float, from_dt: datetime, to_dt: datetime
|
|
31
|
+
) -> dict[str, Any]:
|
|
32
|
+
"""Fetch weather conditions from XWeather API.
|
|
33
|
+
|
|
34
|
+
Splits the request into chunks of max 15 days to comply with API limits.
|
|
35
|
+
|
|
36
|
+
:param latitude: Latitude of location
|
|
37
|
+
:param longitude: Longitude of location
|
|
38
|
+
:param from_dt: Start datetime
|
|
39
|
+
:param to_dt: End datetime
|
|
40
|
+
:return: dict containing all weather data with metadata
|
|
41
|
+
"""
|
|
42
|
+
all_periods = []
|
|
43
|
+
location_info = None
|
|
44
|
+
profile_info = None
|
|
45
|
+
|
|
46
|
+
# XWeather API treats 'to' date as exclusive, so we adjust it by adding 1 day
|
|
47
|
+
to_dt += timedelta(days=1)
|
|
48
|
+
# Split date range into chunks
|
|
49
|
+
chunks = self._split_date_range(from_dt, to_dt)
|
|
50
|
+
|
|
51
|
+
for chunk_start, chunk_end in chunks:
|
|
52
|
+
chunk_data = self._fetch_chunk(latitude, longitude, chunk_start, chunk_end)
|
|
53
|
+
|
|
54
|
+
if chunk_data and "response" in chunk_data:
|
|
55
|
+
response = chunk_data["response"]
|
|
56
|
+
if isinstance(response, list) and len(response) > 0:
|
|
57
|
+
response = response[0]
|
|
58
|
+
|
|
59
|
+
# Get location info from first chunk
|
|
60
|
+
if location_info is None and isinstance(response, dict):
|
|
61
|
+
if "place" in response:
|
|
62
|
+
location_info = response["place"]
|
|
63
|
+
if "profile" in response:
|
|
64
|
+
profile_info = response["profile"]
|
|
65
|
+
elif "loc" in response:
|
|
66
|
+
# Fallback to basic location data
|
|
67
|
+
logging.warning("Using fallback location info from 'loc' field.")
|
|
68
|
+
location_info = {
|
|
69
|
+
"name": "Unknown",
|
|
70
|
+
"lat": response["loc"].get("lat", latitude),
|
|
71
|
+
"long": response["loc"].get("long", longitude),
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
# Collect periods
|
|
75
|
+
if "periods" in response:
|
|
76
|
+
all_periods.extend(response["periods"])
|
|
77
|
+
else:
|
|
78
|
+
raise ValueError("API response missing 'periods' data.")
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
"place": location_info or {},
|
|
82
|
+
"profile": profile_info or {},
|
|
83
|
+
"periods": all_periods,
|
|
84
|
+
"loc": {"lat": latitude, "long": longitude},
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
def _split_date_range(
|
|
88
|
+
self, from_dt: datetime, to_dt: datetime
|
|
89
|
+
) -> list[tuple[datetime, datetime]]:
|
|
90
|
+
"""Split date range into chunks of max 15 days.
|
|
91
|
+
|
|
92
|
+
:param from_dt: Start datetime
|
|
93
|
+
:param to_dt: End datetime
|
|
94
|
+
:return: List of (start, end) datetime tuples
|
|
95
|
+
"""
|
|
96
|
+
chunks = []
|
|
97
|
+
current = from_dt
|
|
98
|
+
|
|
99
|
+
while current <= to_dt:
|
|
100
|
+
chunk_end = min(current + timedelta(days=self.MAX_DAYS_PER_REQUEST - 1), to_dt)
|
|
101
|
+
chunks.append((current, chunk_end))
|
|
102
|
+
current = chunk_end + timedelta(days=1)
|
|
103
|
+
|
|
104
|
+
return chunks
|
|
105
|
+
|
|
106
|
+
def _fetch_chunk(
|
|
107
|
+
self, latitude: float, longitude: float, start_dt: datetime, end_dt: datetime
|
|
108
|
+
) -> dict[str, Any]:
|
|
109
|
+
"""Fetch a single chunk of weather data.
|
|
110
|
+
|
|
111
|
+
:param latitude: Latitude of location
|
|
112
|
+
:param longitude: Longitude of location
|
|
113
|
+
:param start_dt: Start datetime
|
|
114
|
+
:param end_dt: End datetime
|
|
115
|
+
:return: API response dictionary
|
|
116
|
+
"""
|
|
117
|
+
# Format location
|
|
118
|
+
location = f"{latitude},{longitude}"
|
|
119
|
+
|
|
120
|
+
# Format date range for API
|
|
121
|
+
from_str = start_dt.strftime("%Y-%m-%d")
|
|
122
|
+
to_str = end_dt.strftime("%Y-%m-%d")
|
|
123
|
+
|
|
124
|
+
# Build URL
|
|
125
|
+
url = f"{self.BASE_URL}/conditions/{location}"
|
|
126
|
+
|
|
127
|
+
# Build parameters
|
|
128
|
+
params = {
|
|
129
|
+
"client_id": self.api_key,
|
|
130
|
+
"client_secret": self.api_secret,
|
|
131
|
+
"from": from_str,
|
|
132
|
+
"to": to_str,
|
|
133
|
+
"filter": "1hr", # 1-hour interval data
|
|
134
|
+
"format": "json",
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
# Make request
|
|
138
|
+
logging.info(f"Fetching data with params: {params}")
|
|
139
|
+
response = requests.get(url, params=params, timeout=30)
|
|
140
|
+
response.raise_for_status()
|
|
141
|
+
response = response.json()
|
|
142
|
+
|
|
143
|
+
if response["success"] is False:
|
|
144
|
+
raise ValueError(f"API error: {response.get('error', 'Unknown error')}")
|
|
145
|
+
|
|
146
|
+
return response
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Command line interface for xweather2epw."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from .api_client import XWeatherClient
|
|
8
|
+
from .epw_writer import EPWWriter
|
|
9
|
+
from .validator import validate_inputs
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@click.group()
|
|
13
|
+
@click.version_option()
|
|
14
|
+
def cli():
|
|
15
|
+
"""XWeather to EPW Converter - Fetch weather data and generate EPW files."""
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@cli.command()
|
|
20
|
+
@click.option(
|
|
21
|
+
"--from",
|
|
22
|
+
"from_date",
|
|
23
|
+
required=True,
|
|
24
|
+
type=str,
|
|
25
|
+
help="Start date in ISO format (YYYY-MM-DD)",
|
|
26
|
+
)
|
|
27
|
+
@click.option(
|
|
28
|
+
"--to",
|
|
29
|
+
"to_date",
|
|
30
|
+
required=True,
|
|
31
|
+
type=str,
|
|
32
|
+
help="End date in ISO format (YYYY-MM-DD, inclusive)",
|
|
33
|
+
)
|
|
34
|
+
@click.option(
|
|
35
|
+
"--latitude",
|
|
36
|
+
required=True,
|
|
37
|
+
type=float,
|
|
38
|
+
help="Latitude of the location (-90 to 90)",
|
|
39
|
+
)
|
|
40
|
+
@click.option(
|
|
41
|
+
"--longitude",
|
|
42
|
+
required=True,
|
|
43
|
+
type=float,
|
|
44
|
+
help="Longitude of the location (-180 to 180)",
|
|
45
|
+
)
|
|
46
|
+
@click.option(
|
|
47
|
+
"--api-key",
|
|
48
|
+
required=True,
|
|
49
|
+
type=str,
|
|
50
|
+
help="XWeather API key",
|
|
51
|
+
)
|
|
52
|
+
@click.option(
|
|
53
|
+
"--api-secret",
|
|
54
|
+
required=True,
|
|
55
|
+
type=str,
|
|
56
|
+
help="XWeather API secret",
|
|
57
|
+
)
|
|
58
|
+
@click.option(
|
|
59
|
+
"--output",
|
|
60
|
+
"-o",
|
|
61
|
+
type=click.Path(),
|
|
62
|
+
default=None,
|
|
63
|
+
help="Output EPW file path (default: weather_<lat>_<lon>.epw)",
|
|
64
|
+
)
|
|
65
|
+
def fetch(from_date, to_date, latitude, longitude, api_key, api_secret, output):
|
|
66
|
+
"""Fetch weather data from XWeather API and generate an EPW file."""
|
|
67
|
+
try:
|
|
68
|
+
# Validate inputs
|
|
69
|
+
from_dt, to_dt = validate_inputs(from_date, to_date, latitude, longitude)
|
|
70
|
+
|
|
71
|
+
# Set default output filename
|
|
72
|
+
if output is None:
|
|
73
|
+
output = f"weather_{latitude}_{longitude}.epw"
|
|
74
|
+
|
|
75
|
+
click.echo(f"Fetching weather data for ({latitude}, {longitude})")
|
|
76
|
+
click.echo(f"Date range: {from_date} to {to_date}")
|
|
77
|
+
|
|
78
|
+
# Create API client
|
|
79
|
+
client = XWeatherClient(api_key, api_secret)
|
|
80
|
+
|
|
81
|
+
# Fetch weather data
|
|
82
|
+
click.echo("Fetching data from XWeather API...")
|
|
83
|
+
weather_data = client.fetch_conditions(latitude, longitude, from_dt, to_dt)
|
|
84
|
+
|
|
85
|
+
if not weather_data:
|
|
86
|
+
click.echo("Error: No weather data received from API", err=True)
|
|
87
|
+
sys.exit(1)
|
|
88
|
+
|
|
89
|
+
# Write EPW file
|
|
90
|
+
click.echo(f"Writing EPW file to {output}...")
|
|
91
|
+
writer = EPWWriter(weather_data, latitude, longitude)
|
|
92
|
+
writer.write(output)
|
|
93
|
+
|
|
94
|
+
click.echo(f"Successfully generated EPW file: {output}")
|
|
95
|
+
|
|
96
|
+
except ValueError as e:
|
|
97
|
+
click.echo(f"Validation error: {e}", err=True)
|
|
98
|
+
sys.exit(1)
|
|
99
|
+
except Exception as e:
|
|
100
|
+
click.echo(f"Error: {e}", err=True)
|
|
101
|
+
sys.exit(1)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def main():
|
|
105
|
+
"""Main entry point."""
|
|
106
|
+
cli()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
if __name__ == "__main__":
|
|
110
|
+
main()
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""EPW file writer."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from xweather2epw.logcfg import init_logging
|
|
7
|
+
|
|
8
|
+
init_logging()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class EPWWriter:
|
|
12
|
+
"""Writer for EnergyPlus Weather (EPW) files."""
|
|
13
|
+
|
|
14
|
+
# Default values for missing data as per EPW format specification
|
|
15
|
+
MISSING_TEMP = 99.9
|
|
16
|
+
MISSING_PRESSURE = 999999
|
|
17
|
+
MISSING_HUMIDITY = 999
|
|
18
|
+
MISSING_RADIATION = 9999
|
|
19
|
+
MISSING_WIND_SPEED = 999
|
|
20
|
+
MISSING_WIND_DIR = 999
|
|
21
|
+
MISSING_SKY_COVER = 99
|
|
22
|
+
MISSING_VISIBILITY = 9999
|
|
23
|
+
MISSING_CEILING = 99999
|
|
24
|
+
MISSING_PRECIP = 999
|
|
25
|
+
MISSING_SNOW = 999
|
|
26
|
+
|
|
27
|
+
def __init__(self, weather_data: dict[str, Any], latitude: float, longitude: float):
|
|
28
|
+
"""Initialize EPW writer.
|
|
29
|
+
|
|
30
|
+
:param weather_data: Weather data from XWeather API
|
|
31
|
+
:param latitude: Location latitude
|
|
32
|
+
:param longitude: Location longitude
|
|
33
|
+
"""
|
|
34
|
+
self.weather_data = weather_data
|
|
35
|
+
self.latitude = latitude
|
|
36
|
+
self.longitude = longitude
|
|
37
|
+
|
|
38
|
+
def write(self, output_path: str):
|
|
39
|
+
"""Write EPW file.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
output_path: Path to output EPW file
|
|
43
|
+
"""
|
|
44
|
+
with open(output_path, "w", newline="\n") as f:
|
|
45
|
+
# Write headers
|
|
46
|
+
self._write_location_header(f)
|
|
47
|
+
self._write_design_conditions_header(f)
|
|
48
|
+
self._write_typical_extreme_periods_header(f)
|
|
49
|
+
self._write_ground_temperatures_header(f)
|
|
50
|
+
self._write_holidays_header(f)
|
|
51
|
+
self._write_comments_header(f)
|
|
52
|
+
self._write_data_periods_header(f)
|
|
53
|
+
|
|
54
|
+
# Write weather data
|
|
55
|
+
self._write_weather_data(f)
|
|
56
|
+
|
|
57
|
+
def _write_location_header(self, f):
|
|
58
|
+
"""Write LOCATION header line."""
|
|
59
|
+
place = self.weather_data.get("place", {})
|
|
60
|
+
profile = self.weather_data.get("profile", {})
|
|
61
|
+
|
|
62
|
+
# Extract location information
|
|
63
|
+
city = place.get("name", "Unknown")
|
|
64
|
+
state = place.get("state", "")
|
|
65
|
+
country = place.get("country", "")
|
|
66
|
+
|
|
67
|
+
# Build city name
|
|
68
|
+
if state:
|
|
69
|
+
city_full = f"{city}/{state}"
|
|
70
|
+
else:
|
|
71
|
+
city_full = city
|
|
72
|
+
|
|
73
|
+
# Get timezone offset (default to 0 if not available)
|
|
74
|
+
tzoffset_sec = profile.get("tzoffset", 0) if profile else 0
|
|
75
|
+
if isinstance(tzoffset_sec, (int, float)):
|
|
76
|
+
timezone = tzoffset_sec / 3600 # Convert seconds to hours
|
|
77
|
+
else:
|
|
78
|
+
timezone = 0
|
|
79
|
+
|
|
80
|
+
# Get elevation (default to 0 if not available)
|
|
81
|
+
elevation = profile.get("elevM", 0) if profile else 0
|
|
82
|
+
|
|
83
|
+
# WMO number (not available from XWeather, use 999999)
|
|
84
|
+
wmo = 999999
|
|
85
|
+
|
|
86
|
+
# Data source
|
|
87
|
+
source = "XWeather"
|
|
88
|
+
|
|
89
|
+
f.write(
|
|
90
|
+
f"LOCATION,{city_full},{state},{country},{source},"
|
|
91
|
+
f"{wmo},{self.latitude},{self.longitude},{timezone},{elevation}\n"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def _write_design_conditions_header(self, f):
|
|
95
|
+
"""Write DESIGN CONDITIONS header (empty for this implementation)."""
|
|
96
|
+
f.write("DESIGN CONDITIONS,0\n")
|
|
97
|
+
|
|
98
|
+
def _write_typical_extreme_periods_header(self, f):
|
|
99
|
+
"""Write TYPICAL/EXTREME PERIODS header (empty for this implementation)."""
|
|
100
|
+
f.write("TYPICAL/EXTREME PERIODS,0\n")
|
|
101
|
+
|
|
102
|
+
def _write_ground_temperatures_header(self, f):
|
|
103
|
+
"""Write GROUND TEMPERATURES header (set to 0 as per requirements)."""
|
|
104
|
+
f.write("GROUND TEMPERATURES,0\n")
|
|
105
|
+
|
|
106
|
+
def _write_holidays_header(self, f):
|
|
107
|
+
"""Write HOLIDAYS/DAYLIGHT SAVINGS header (empty for this implementation)."""
|
|
108
|
+
periods = self.weather_data.get("periods", [])
|
|
109
|
+
first_period = periods[0]
|
|
110
|
+
first_date = self._parse_iso_datetime(first_period.get("dateTimeISO", ""))
|
|
111
|
+
is_leap_year = first_date.year % 4 == 0 if first_date else False
|
|
112
|
+
is_leap_year = "Yes" if is_leap_year else "No"
|
|
113
|
+
f.write(f"HOLIDAYS/DAYLIGHT SAVINGS,{is_leap_year},0,0,0\n")
|
|
114
|
+
|
|
115
|
+
def _write_comments_header(self, f):
|
|
116
|
+
"""Write COMMENTS headers."""
|
|
117
|
+
f.write("COMMENTS 1,Generated by xweather2epw - XWeather to EPW Converter\n")
|
|
118
|
+
f.write("COMMENTS 2,Weather data sourced from XWeather API (https://www.xweather.com/)\n")
|
|
119
|
+
|
|
120
|
+
def _write_data_periods_header(self, f):
|
|
121
|
+
"""Write DATA PERIODS header."""
|
|
122
|
+
periods = self.weather_data.get("periods", [])
|
|
123
|
+
|
|
124
|
+
if not periods:
|
|
125
|
+
# No data, write minimal header
|
|
126
|
+
f.write("DATA PERIODS,1,1,Data,Sunday,1/1,12/31\n")
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
# Get date range from periods
|
|
130
|
+
first_period = periods[0]
|
|
131
|
+
last_period = periods[-1]
|
|
132
|
+
|
|
133
|
+
# Parse dates
|
|
134
|
+
first_date = self._parse_iso_datetime(first_period.get("dateTimeISO", ""))
|
|
135
|
+
last_date = self._parse_iso_datetime(last_period.get("dateTimeISO", ""))
|
|
136
|
+
|
|
137
|
+
if first_date and last_date:
|
|
138
|
+
start_str = f"{first_date.month}/{first_date.day}"
|
|
139
|
+
end_str = f"{last_date.month}/{last_date.day}"
|
|
140
|
+
# Get day of week for start date (Monday=0)
|
|
141
|
+
day_of_week = [
|
|
142
|
+
"Monday",
|
|
143
|
+
"Tuesday",
|
|
144
|
+
"Wednesday",
|
|
145
|
+
"Thursday",
|
|
146
|
+
"Friday",
|
|
147
|
+
"Saturday",
|
|
148
|
+
"Sunday",
|
|
149
|
+
][first_date.weekday()]
|
|
150
|
+
else:
|
|
151
|
+
start_str = "1/1"
|
|
152
|
+
end_str = "12/31"
|
|
153
|
+
day_of_week = "Sunday"
|
|
154
|
+
|
|
155
|
+
f.write(f"DATA PERIODS,1,1,Data,{day_of_week},{start_str},{end_str}\n")
|
|
156
|
+
|
|
157
|
+
def _write_weather_data(self, f):
|
|
158
|
+
"""Write hourly weather data records."""
|
|
159
|
+
periods = self.weather_data.get("periods", [])
|
|
160
|
+
|
|
161
|
+
if not periods:
|
|
162
|
+
return
|
|
163
|
+
|
|
164
|
+
for period in periods:
|
|
165
|
+
self._write_weather_record(f, period)
|
|
166
|
+
|
|
167
|
+
def _write_weather_record(self, f, period: dict[str, Any]):
|
|
168
|
+
"""Write a single weather data record.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
f: File handle
|
|
172
|
+
period: Weather period data from API
|
|
173
|
+
"""
|
|
174
|
+
# Parse timestamp
|
|
175
|
+
dt = self._parse_iso_datetime(period.get("dateTimeISO", ""))
|
|
176
|
+
if not dt:
|
|
177
|
+
return # Skip invalid records
|
|
178
|
+
|
|
179
|
+
year = dt.year
|
|
180
|
+
month = dt.month
|
|
181
|
+
day = dt.day
|
|
182
|
+
hour = dt.hour + 1 # EPW hours are 1-24
|
|
183
|
+
minute = dt.minute if dt.minute else 0
|
|
184
|
+
|
|
185
|
+
# Data source and uncertainty flags (not used)
|
|
186
|
+
data_source_uncertainty = "9"
|
|
187
|
+
|
|
188
|
+
# Temperature data (°C)
|
|
189
|
+
dry_bulb = round(period.get("tempC", self.MISSING_TEMP), 1)
|
|
190
|
+
dew_point = round(period.get("dewpointC", self.MISSING_TEMP), 1)
|
|
191
|
+
|
|
192
|
+
# Humidity (%)
|
|
193
|
+
humidity = round(period.get("humidity", self.MISSING_HUMIDITY), 1)
|
|
194
|
+
|
|
195
|
+
# Atmospheric pressure (Pa) - XWeather provides in mb, convert to Pa
|
|
196
|
+
pressure_mb = period.get("pressureMB")
|
|
197
|
+
if pressure_mb is not None:
|
|
198
|
+
pressure = pressure_mb * 100 # mb to Pa
|
|
199
|
+
else:
|
|
200
|
+
pressure = self.MISSING_PRESSURE
|
|
201
|
+
pressure = round(pressure, 0)
|
|
202
|
+
|
|
203
|
+
# Radiation data (Wh/m²) - extract from solrad field if available
|
|
204
|
+
solrad = period.get("solrad", {})
|
|
205
|
+
|
|
206
|
+
# EPW expects Wh/m², XWeather provides W/m² (instantaneous)
|
|
207
|
+
# For hourly data, W/m² is approximately equal to Wh/m²
|
|
208
|
+
global_horizontal = solrad.get("ghiWM2", self.MISSING_RADIATION)
|
|
209
|
+
direct_normal = solrad.get("dniWM2", self.MISSING_RADIATION)
|
|
210
|
+
diffuse_horizontal = solrad.get("dhiWM2", self.MISSING_RADIATION)
|
|
211
|
+
|
|
212
|
+
# Extraterrestrial and infrared radiation not available from API
|
|
213
|
+
etr_horizontal = self.MISSING_RADIATION
|
|
214
|
+
etr_direct = self.MISSING_RADIATION
|
|
215
|
+
ir_horizontal = self.MISSING_RADIATION
|
|
216
|
+
|
|
217
|
+
# Illuminance data (lux) - using missing values
|
|
218
|
+
global_horiz_illum = self.MISSING_RADIATION
|
|
219
|
+
direct_normal_illum = self.MISSING_RADIATION
|
|
220
|
+
diffuse_horiz_illum = self.MISSING_RADIATION
|
|
221
|
+
zenith_luminance = self.MISSING_RADIATION
|
|
222
|
+
|
|
223
|
+
# Wind data
|
|
224
|
+
wind_dir = period.get("windDirDEG", self.MISSING_WIND_DIR)
|
|
225
|
+
|
|
226
|
+
# Wind speed (m/s) - XWeather provides in kph, convert to m/s
|
|
227
|
+
wind_kph = period.get("windSpeedKPH")
|
|
228
|
+
if wind_kph is not None:
|
|
229
|
+
wind_speed = wind_kph / 3.6 # kph to m/s
|
|
230
|
+
else:
|
|
231
|
+
wind_speed = self.MISSING_WIND_SPEED
|
|
232
|
+
wind_speed = round(wind_speed, 1)
|
|
233
|
+
|
|
234
|
+
# Sky cover data (tenths) - XWeather provides cloud cover in %, convert to tenths
|
|
235
|
+
cloud_cover_pct = period.get("cloudsCovered")
|
|
236
|
+
if cloud_cover_pct is not None:
|
|
237
|
+
sky_cover = int(cloud_cover_pct / 10) # % to tenths
|
|
238
|
+
else:
|
|
239
|
+
sky_cover = self.MISSING_SKY_COVER
|
|
240
|
+
|
|
241
|
+
opaque_sky_cover = sky_cover # Use same value for opaque cover
|
|
242
|
+
|
|
243
|
+
# Visibility (km)
|
|
244
|
+
visibility = period.get("visibilityKM", self.MISSING_VISIBILITY)
|
|
245
|
+
|
|
246
|
+
# Ceiling height (m)
|
|
247
|
+
ceiling = self.MISSING_CEILING
|
|
248
|
+
|
|
249
|
+
# Present weather observation and codes
|
|
250
|
+
weather_obs = 0
|
|
251
|
+
weather_codes = 999999999
|
|
252
|
+
|
|
253
|
+
# Precipitable water (mm)
|
|
254
|
+
precipitable_water = self.MISSING_PRECIP
|
|
255
|
+
|
|
256
|
+
# Aerosol optical depth (thousandths)
|
|
257
|
+
aerosol_optical = self.MISSING_PRECIP
|
|
258
|
+
|
|
259
|
+
# Snow depth (cm)
|
|
260
|
+
snow_depth_cm = period.get("snowDepthCM", self.MISSING_SNOW)
|
|
261
|
+
|
|
262
|
+
# Days since last snowfall
|
|
263
|
+
days_last_snow = self.MISSING_SNOW
|
|
264
|
+
|
|
265
|
+
# Albedo
|
|
266
|
+
albedo = self.MISSING_PRECIP
|
|
267
|
+
|
|
268
|
+
# Liquid precipitation (mm and hr)
|
|
269
|
+
precip_mm = period.get("precipMM")
|
|
270
|
+
if precip_mm is not None:
|
|
271
|
+
liquid_precip = precip_mm
|
|
272
|
+
liquid_precip_qty = 1 # Assume 1 hour
|
|
273
|
+
else:
|
|
274
|
+
liquid_precip = 0
|
|
275
|
+
liquid_precip_qty = 0
|
|
276
|
+
|
|
277
|
+
# Format record
|
|
278
|
+
record = (
|
|
279
|
+
f"{year},{month},{day},{hour},{minute},"
|
|
280
|
+
f"{data_source_uncertainty},"
|
|
281
|
+
f"{dry_bulb},{dew_point},{humidity},{pressure},"
|
|
282
|
+
f"{etr_horizontal},{etr_direct},{ir_horizontal},"
|
|
283
|
+
f"{global_horizontal},{direct_normal},{diffuse_horizontal},"
|
|
284
|
+
f"{global_horiz_illum},{direct_normal_illum},{diffuse_horiz_illum},{zenith_luminance},"
|
|
285
|
+
f"{wind_dir},{wind_speed},"
|
|
286
|
+
f"{sky_cover},{opaque_sky_cover},"
|
|
287
|
+
f"{visibility},{ceiling},"
|
|
288
|
+
f"{weather_obs},{weather_codes},"
|
|
289
|
+
f"{precipitable_water},{aerosol_optical},{snow_depth_cm},{days_last_snow},"
|
|
290
|
+
f"{albedo},{liquid_precip},{liquid_precip_qty}\n"
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
f.write(record)
|
|
294
|
+
|
|
295
|
+
def _parse_iso_datetime(self, iso_string: str) -> datetime:
|
|
296
|
+
"""Parse ISO datetime string.
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
iso_string: ISO format datetime string
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
datetime object or None if parsing fails
|
|
303
|
+
"""
|
|
304
|
+
if not iso_string:
|
|
305
|
+
return None
|
|
306
|
+
|
|
307
|
+
try:
|
|
308
|
+
# Handle 'Z' timezone indicator (UTC)
|
|
309
|
+
# Note: We strip timezone info as EPW uses naive timestamps
|
|
310
|
+
if iso_string.endswith("Z"):
|
|
311
|
+
iso_string = iso_string[:-1] # Remove 'Z'
|
|
312
|
+
|
|
313
|
+
# Try parsing with timezone info
|
|
314
|
+
# Format: "2024-02-19T18:00:00-06:00"
|
|
315
|
+
if "+" in iso_string or iso_string.count("-") > 2:
|
|
316
|
+
# Has timezone, strip it for simplicity
|
|
317
|
+
# EPW format uses local time without timezone
|
|
318
|
+
dt_part = iso_string[:19] # Get YYYY-MM-DDTHH:MM:SS part
|
|
319
|
+
return datetime.strptime(dt_part, "%Y-%m-%dT%H:%M:%S")
|
|
320
|
+
else:
|
|
321
|
+
return datetime.strptime(iso_string, "%Y-%m-%dT%H:%M:%S")
|
|
322
|
+
except ValueError:
|
|
323
|
+
return None
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import logging.config
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def init_logging(verbose: bool = False) -> None:
|
|
5
|
+
app_log_level = "INFO" if verbose else "WARNING"
|
|
6
|
+
|
|
7
|
+
logging.config.dictConfig(
|
|
8
|
+
{
|
|
9
|
+
"version": 1,
|
|
10
|
+
"disable_existing_loggers": True,
|
|
11
|
+
"formatters": {
|
|
12
|
+
"standard": {
|
|
13
|
+
"format": (
|
|
14
|
+
"%(asctime)s "
|
|
15
|
+
+ "[%(levelname)s]"
|
|
16
|
+
+ "[%(filename)s:%(lineno)s - %(funcName)s()]: %(message)s"
|
|
17
|
+
)
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
"handlers": {
|
|
21
|
+
"default": {
|
|
22
|
+
"level": "INFO",
|
|
23
|
+
"formatter": "standard",
|
|
24
|
+
"class": "logging.StreamHandler",
|
|
25
|
+
"stream": "ext://sys.stdout", # Default is stderr
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
"loggers": {
|
|
29
|
+
"": {"handlers": ["default"], "level": app_log_level}, # root logger
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Input validation for xweather2epw."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timedelta
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def validate_inputs(from_date_str, to_date_str, latitude, longitude):
|
|
7
|
+
"""Validate command line inputs.
|
|
8
|
+
|
|
9
|
+
:param from_date_str: Start date string in ISO format (YYYY-MM-DD)
|
|
10
|
+
:param to_date_str: End date string in ISO format (YYYY-MM-DD)
|
|
11
|
+
:param latitude: Latitude value (-90 to 90)
|
|
12
|
+
:param longitude: Longitude value (-180 to 180)
|
|
13
|
+
:return: tuple: (from_datetime, to_datetime)
|
|
14
|
+
:raises: ValueError: If any validation fails
|
|
15
|
+
"""
|
|
16
|
+
# Validate latitude
|
|
17
|
+
if not -90 <= latitude <= 90:
|
|
18
|
+
raise ValueError(f"Latitude must be between -90 and 90, got {latitude}")
|
|
19
|
+
|
|
20
|
+
# Validate longitude
|
|
21
|
+
if not -180 <= longitude <= 180:
|
|
22
|
+
raise ValueError(f"Longitude must be between -180 and 180, got {longitude}")
|
|
23
|
+
|
|
24
|
+
# Parse dates
|
|
25
|
+
try:
|
|
26
|
+
from_dt = datetime.strptime(from_date_str, "%Y-%m-%d")
|
|
27
|
+
except ValueError:
|
|
28
|
+
raise ValueError(f"Invalid from date format: {from_date_str}. Use YYYY-MM-DD")
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
to_dt = datetime.strptime(to_date_str, "%Y-%m-%d")
|
|
32
|
+
except ValueError:
|
|
33
|
+
raise ValueError(f"Invalid to date format: {to_date_str}. Use YYYY-MM-DD")
|
|
34
|
+
|
|
35
|
+
# Validate date range
|
|
36
|
+
if from_dt > to_dt:
|
|
37
|
+
raise ValueError(f"Start date {from_date_str} must be before end date {to_date_str}")
|
|
38
|
+
|
|
39
|
+
# Calculate duration
|
|
40
|
+
duration = (to_dt - from_dt).days
|
|
41
|
+
|
|
42
|
+
# Check maximum duration (1 year = 365 days)
|
|
43
|
+
if duration > 365:
|
|
44
|
+
raise ValueError(f"Date range cannot exceed 365 days. Current range: {duration} days")
|
|
45
|
+
|
|
46
|
+
# Check forecast limit (14 days from now)
|
|
47
|
+
now = datetime.now()
|
|
48
|
+
max_future_date = now + timedelta(days=14)
|
|
49
|
+
|
|
50
|
+
if to_dt > max_future_date:
|
|
51
|
+
raise ValueError(
|
|
52
|
+
f"End date cannot be more than 14 days in the future. "
|
|
53
|
+
f"Maximum allowed date: {max_future_date.strftime('%Y-%m-%d')}"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
return from_dt, to_dt
|