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.
@@ -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,3 @@
1
+ """XWeather to EPW Converter package."""
2
+
3
+ __version__ = "0.1.0"
@@ -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