Skip to content

Commit 9a1e2f1

Browse files
committed
feat(harvesters): add WeatherHarvester for meteorological data
- Supports OpenWeatherMap API (with key) - Falls back to wttr.in (no key required) - Collects temperature, humidity, pressure, wind, clouds - Multiple cities for geographic diversity
1 parent d1b8d3f commit 9a1e2f1

1 file changed

Lines changed: 352 additions & 0 deletions

File tree

Lines changed: 352 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,352 @@
1+
# =============================================================================
2+
# TrueEntropy - Weather Harvester
3+
# =============================================================================
4+
#
5+
# This harvester collects entropy from weather data via OpenWeatherMap API.
6+
# Weather conditions are inherently chaotic and provide excellent entropy.
7+
#
8+
# Data Sources:
9+
# - Temperature (with decimal precision)
10+
# - Humidity percentage
11+
# - Atmospheric pressure
12+
# - Wind speed and direction
13+
# - Cloud coverage
14+
# - Visibility
15+
#
16+
# Why Weather Data is Random:
17+
# - Atmospheric conditions are chaotic systems
18+
# - Measurements vary constantly
19+
# - Multiple independent variables
20+
# - Physical phenomena outside computer control
21+
#
22+
# Note: Requires free OpenWeatherMap API key for full functionality.
23+
# Without API key, uses fallback public endpoints.
24+
#
25+
# =============================================================================
26+
27+
"""
28+
Weather-based entropy harvester.
29+
30+
Collects entropy from real-time weather data including temperature,
31+
humidity, pressure, wind, and other atmospheric conditions.
32+
"""
33+
34+
from __future__ import annotations
35+
36+
import hashlib
37+
import struct
38+
import time
39+
from typing import Any, Dict, List, Optional
40+
41+
from trueentropy.harvesters.base import BaseHarvester, HarvestResult
42+
43+
44+
class WeatherHarvester(BaseHarvester):
45+
"""
46+
Harvests entropy from weather data via OpenWeatherMap API.
47+
48+
Weather conditions are determined by chaotic atmospheric systems,
49+
making them an excellent source of real-world entropy. This harvester
50+
collects multiple weather metrics and combines them.
51+
52+
Metrics collected:
53+
- Temperature (Kelvin, with decimals)
54+
- Feels-like temperature
55+
- Humidity percentage
56+
- Atmospheric pressure (hPa)
57+
- Wind speed and direction
58+
- Cloud coverage percentage
59+
- Visibility distance
60+
61+
Attributes:
62+
api_key: OpenWeatherMap API key (optional, uses fallback if not set)
63+
cities: List of city IDs to query
64+
timeout: Request timeout in seconds
65+
66+
Example:
67+
>>> # With API key (recommended)
68+
>>> harvester = WeatherHarvester(api_key="your_api_key")
69+
>>> result = harvester.collect()
70+
>>>
71+
>>> # Without API key (uses fallback cities)
72+
>>> harvester = WeatherHarvester()
73+
>>> result = harvester.collect()
74+
"""
75+
76+
# -------------------------------------------------------------------------
77+
# API Configuration
78+
# -------------------------------------------------------------------------
79+
80+
# OpenWeatherMap API endpoint
81+
OPENWEATHER_API_URL = "https://api.openweathermap.org/data/2.5/weather"
82+
83+
# Default cities to query (major world cities with different climates)
84+
# Using city IDs for reliability
85+
DEFAULT_CITIES: List[Dict[str, Any]] = [
86+
{"name": "London", "lat": 51.5074, "lon": -0.1278},
87+
{"name": "Tokyo", "lat": 35.6762, "lon": 139.6503},
88+
{"name": "New York", "lat": 40.7128, "lon": -74.0060},
89+
{"name": "Sydney", "lat": -33.8688, "lon": 151.2093},
90+
{"name": "São Paulo", "lat": -23.5505, "lon": -46.6333},
91+
]
92+
93+
# Fallback: wttr.in provides weather without API key
94+
WTTR_URL = "https://wttr.in/{city}?format=j1"
95+
96+
# -------------------------------------------------------------------------
97+
# Initialization
98+
# -------------------------------------------------------------------------
99+
100+
def __init__(
101+
self,
102+
api_key: Optional[str] = None,
103+
cities: Optional[List[Dict[str, Any]]] = None,
104+
timeout: float = 5.0
105+
) -> None:
106+
"""
107+
Initialize the weather harvester.
108+
109+
Args:
110+
api_key: OpenWeatherMap API key. If not provided, uses wttr.in
111+
fallback which doesn't require authentication.
112+
cities: List of cities to query. Each city should have 'lat' and
113+
'lon' keys for coordinates. If not provided, uses defaults.
114+
timeout: Request timeout in seconds.
115+
"""
116+
self._api_key = api_key
117+
self._cities = cities or self.DEFAULT_CITIES.copy()
118+
self._timeout = timeout
119+
120+
# -------------------------------------------------------------------------
121+
# BaseHarvester Implementation
122+
# -------------------------------------------------------------------------
123+
124+
@property
125+
def name(self) -> str:
126+
"""Return harvester name."""
127+
return "weather"
128+
129+
def collect(self) -> HarvestResult:
130+
"""
131+
Collect entropy from weather data.
132+
133+
Process:
134+
1. Query weather data for multiple cities
135+
2. Extract numeric values from responses
136+
3. Pack values into bytes
137+
4. Hash for uniform distribution
138+
139+
Returns:
140+
HarvestResult containing weather entropy
141+
"""
142+
try:
143+
import requests
144+
except ImportError:
145+
return HarvestResult(
146+
data=b"",
147+
entropy_bits=0,
148+
source=self.name,
149+
success=False,
150+
error="requests library not available"
151+
)
152+
153+
collected_values: List[float] = []
154+
errors: List[str] = []
155+
156+
if self._api_key:
157+
# Use OpenWeatherMap API
158+
collected_values = self._fetch_openweather(requests)
159+
else:
160+
# Use wttr.in fallback (no API key needed)
161+
collected_values = self._fetch_wttr(requests)
162+
163+
if not collected_values:
164+
return HarvestResult(
165+
data=b"",
166+
entropy_bits=0,
167+
source=self.name,
168+
success=False,
169+
error="No weather data available"
170+
)
171+
172+
# Add timestamp for freshness
173+
collected_values.append(float(time.time_ns()))
174+
175+
# Pack as double-precision floats
176+
data = struct.pack(f"!{len(collected_values)}d", *collected_values)
177+
178+
# Hash for uniform distribution
179+
hashed = hashlib.sha256(data).digest()
180+
181+
# Estimate entropy: ~4 bits per weather metric
182+
entropy_bits = len(collected_values) * 4
183+
184+
return HarvestResult(
185+
data=data + hashed,
186+
entropy_bits=entropy_bits,
187+
source=self.name,
188+
success=True
189+
)
190+
191+
# -------------------------------------------------------------------------
192+
# OpenWeatherMap API
193+
# -------------------------------------------------------------------------
194+
195+
def _fetch_openweather(self, requests: Any) -> List[float]:
196+
"""
197+
Fetch weather data from OpenWeatherMap API.
198+
199+
Args:
200+
requests: The imported requests module
201+
202+
Returns:
203+
List of weather values as floats
204+
"""
205+
values: List[float] = []
206+
207+
for city in self._cities[:3]: # Limit to 3 cities to avoid rate limits
208+
try:
209+
params = {
210+
"lat": city["lat"],
211+
"lon": city["lon"],
212+
"appid": self._api_key,
213+
"units": "metric"
214+
}
215+
216+
response = requests.get(
217+
self.OPENWEATHER_API_URL,
218+
params=params,
219+
timeout=self._timeout
220+
)
221+
response.raise_for_status()
222+
data = response.json()
223+
224+
# Extract weather metrics
225+
main = data.get("main", {})
226+
wind = data.get("wind", {})
227+
clouds = data.get("clouds", {})
228+
229+
# Temperature values (highly variable)
230+
values.append(float(main.get("temp", 0)))
231+
values.append(float(main.get("feels_like", 0)))
232+
values.append(float(main.get("temp_min", 0)))
233+
values.append(float(main.get("temp_max", 0)))
234+
235+
# Atmospheric values
236+
values.append(float(main.get("pressure", 0)))
237+
values.append(float(main.get("humidity", 0)))
238+
values.append(float(main.get("sea_level", 0)))
239+
240+
# Wind values
241+
values.append(float(wind.get("speed", 0)))
242+
values.append(float(wind.get("deg", 0)))
243+
values.append(float(wind.get("gust", 0)))
244+
245+
# Cloud coverage
246+
values.append(float(clouds.get("all", 0)))
247+
248+
# Visibility
249+
values.append(float(data.get("visibility", 0)))
250+
251+
# Coordinates (add precision noise)
252+
coord = data.get("coord", {})
253+
values.append(float(coord.get("lat", 0)))
254+
values.append(float(coord.get("lon", 0)))
255+
256+
except Exception:
257+
continue
258+
259+
return values
260+
261+
# -------------------------------------------------------------------------
262+
# wttr.in Fallback (No API Key Required)
263+
# -------------------------------------------------------------------------
264+
265+
def _fetch_wttr(self, requests: Any) -> List[float]:
266+
"""
267+
Fetch weather data from wttr.in (no API key required).
268+
269+
Args:
270+
requests: The imported requests module
271+
272+
Returns:
273+
List of weather values as floats
274+
"""
275+
values: List[float] = []
276+
277+
# Cities for wttr.in
278+
fallback_cities = ["London", "Tokyo", "Sydney"]
279+
280+
for city in fallback_cities:
281+
try:
282+
url = self.WTTR_URL.format(city=city)
283+
284+
response = requests.get(
285+
url,
286+
timeout=self._timeout,
287+
headers={"Accept": "application/json"}
288+
)
289+
response.raise_for_status()
290+
data = response.json()
291+
292+
# Current condition
293+
current = data.get("current_condition", [{}])[0]
294+
295+
# Temperature
296+
values.append(float(current.get("temp_C", 0)))
297+
values.append(float(current.get("temp_F", 0)))
298+
values.append(float(current.get("FeelsLikeC", 0)))
299+
values.append(float(current.get("FeelsLikeF", 0)))
300+
301+
# Atmospheric
302+
values.append(float(current.get("humidity", 0)))
303+
values.append(float(current.get("pressure", 0)))
304+
values.append(float(current.get("pressureInches", 0)))
305+
306+
# Wind
307+
values.append(float(current.get("windspeedKmph", 0)))
308+
values.append(float(current.get("windspeedMiles", 0)))
309+
values.append(float(current.get("winddirDegree", 0)))
310+
311+
# Visibility and clouds
312+
values.append(float(current.get("visibility", 0)))
313+
values.append(float(current.get("visibilityMiles", 0)))
314+
values.append(float(current.get("cloudcover", 0)))
315+
316+
# UV and precipitation
317+
values.append(float(current.get("uvIndex", 0)))
318+
values.append(float(current.get("precipMM", 0)))
319+
values.append(float(current.get("precipInches", 0)))
320+
321+
except Exception:
322+
continue
323+
324+
return values
325+
326+
# -------------------------------------------------------------------------
327+
# Configuration Properties
328+
# -------------------------------------------------------------------------
329+
330+
@property
331+
def api_key(self) -> Optional[str]:
332+
"""Get the API key (masked for security)."""
333+
if self._api_key:
334+
return self._api_key[:4] + "..." + self._api_key[-4:]
335+
return None
336+
337+
@api_key.setter
338+
def api_key(self, value: Optional[str]) -> None:
339+
"""Set the API key."""
340+
self._api_key = value
341+
342+
@property
343+
def timeout(self) -> float:
344+
"""Get the request timeout in seconds."""
345+
return self._timeout
346+
347+
@timeout.setter
348+
def timeout(self, value: float) -> None:
349+
"""Set the request timeout."""
350+
if value <= 0:
351+
raise ValueError("timeout must be positive")
352+
self._timeout = value

0 commit comments

Comments
 (0)