pymscada 0.1.11b10__py3-none-any.whl → 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,7 @@
2
2
  import asyncio
3
3
  import aiohttp
4
4
  import logging
5
+ import socket
5
6
  from time import time
6
7
  from pymscada.bus_client import BusClient
7
8
  from pymscada.periodic import Periodic
@@ -10,8 +11,14 @@ from pymscada.tag import Tag
10
11
  class OpenWeatherClient:
11
12
  """Get weather data from OpenWeather Current and Forecast APIs."""
12
13
 
13
- def __init__(self, bus_ip: str = '127.0.0.1', bus_port: int = 1324,
14
- proxy: str = None, api: dict = {}, tags: dict = {}) -> None:
14
+ def __init__(
15
+ self,
16
+ bus_ip: str | None = '127.0.0.1',
17
+ bus_port: int = 1324,
18
+ proxy: str | None = None,
19
+ api: dict = {},
20
+ tags: list = []
21
+ ) -> None:
15
22
  """
16
23
  Connect to bus on bus_ip:bus_port.
17
24
 
@@ -21,6 +28,18 @@ class OpenWeatherClient:
21
28
  - times: list of hours ahead to fetch forecast data for
22
29
  - units: optional units (standard, metric, imperial)
23
30
  """
31
+ if bus_ip is not None:
32
+ try:
33
+ socket.gethostbyname(bus_ip)
34
+ except socket.gaierror:
35
+ raise ValueError(f"Invalid bus_ip: {bus_ip}")
36
+ if not isinstance(proxy, str) and proxy is not None:
37
+ raise ValueError("proxy must be a string or None")
38
+ if not isinstance(api, dict):
39
+ raise ValueError("api must be a dictionary")
40
+ if not isinstance(tags, list):
41
+ raise ValueError("tags must be a list")
42
+
24
43
  self.busclient = None
25
44
  if bus_ip is not None:
26
45
  self.busclient = BusClient(bus_ip, bus_port, module='OpenWeather')
@@ -41,82 +60,144 @@ class OpenWeatherClient:
41
60
 
42
61
  def update_tags(self, location, data, suffix):
43
62
  """Update tags for forecast weather."""
63
+ if 'dt' not in data:
64
+ logging.error(f'No timestamp in data for {location}, skipping update')
65
+ return
44
66
  for parameter in self.parameters:
45
67
  tagname = f"{location}_{parameter}{suffix}"
46
- if parameter == 'Temp':
47
- value = data['main']['temp']
48
- elif parameter == 'WindSpeed':
49
- value = data['wind']['speed']
50
- elif parameter == 'WindDir':
51
- value = data['wind'].get('deg', 0)
52
- elif parameter == 'Rain':
53
- value = data.get('rain', {}).get('1h', 0)
54
68
  try:
55
- self.tags[tagname].value = value
56
- except KeyError:
57
- logging.warning(f'{tagname} not found setting weather value')
69
+ if parameter == 'Temp':
70
+ main_data = data.get('main', {})
71
+ value = main_data.get('temp', 0)
72
+ elif parameter == 'WindSpeed':
73
+ wind_data = data.get('wind', {})
74
+ value = wind_data.get('speed', 0)
75
+ elif parameter == 'WindDir':
76
+ wind_data = data.get('wind', {})
77
+ value = wind_data.get('deg', 0)
78
+ elif parameter == 'Rain':
79
+ rain_data = data.get('rain', {})
80
+ value = rain_data.get('1h', 0)
81
+ else:
82
+ logging.warning(f'Unknown parameter {parameter} for {tagname}')
83
+ continue
84
+ time_us = int(data['dt'] * 1_000_000)
85
+ self.tags[tagname].value = value, time_us, self.map_bus
86
+ logging.debug(f'Updated {tagname} = {value} at timestamp {data["dt"]}')
87
+ except Exception as e:
88
+ logging.error(
89
+ f'Error updating {tagname}: {type(e).__name__} - {str(e)}'
90
+ )
58
91
 
59
92
  async def handle_response(self):
60
93
  """Handle responses from the API."""
61
94
  while True:
62
- location, data = await self.queue.get()
63
- now = int(time())
64
- if 'dt' in data:
65
- self.update_tags(location, data, '')
66
- elif 'list' in data:
67
- for forecast in data['list']:
68
- hours_ahead = int((forecast['dt'] - now) / 3600)
69
- if hours_ahead not in self.times:
70
- continue
71
- suffix = f'_{hours_ahead:02d}'
72
- self.update_tags(location, forecast, suffix)
95
+ try:
96
+ location, data = await self.queue.get()
97
+ logging.debug(f'Processing data for {location}')
98
+
99
+ if 'dt' in data: # Current weather data
100
+ self.update_tags(location, data, '')
101
+ elif 'list' in data: # Forecast data
102
+ now = int(time())
103
+ for forecast in data['list']:
104
+ hours_ahead = int((forecast['dt'] - now) / 3600)
105
+ if hours_ahead in self.times:
106
+ suffix = f'_{hours_ahead:02d}'
107
+ self.update_tags(location, forecast, suffix)
108
+
109
+ self.queue.task_done()
110
+ except Exception as e:
111
+ logging.error(f'Error handling response: {type(e).__name__} - {str(e)}')
73
112
 
74
113
  async def fetch_current_data(self):
75
114
  """Fetch current weather data for all locations."""
76
- logging.info('fetching current')
77
- if self.session is None:
78
- self.session = aiohttp.ClientSession()
79
- for location, coords in self.locations.items():
80
- base_params = {'lat': coords['lat'], 'lon': coords['lon'],
81
- 'appid': self.api_key, 'units': self.units }
82
- try:
83
- async with self.session.get(self.current_url,
84
- params=base_params, proxy=self.proxy) as resp:
85
- if resp.status == 200:
86
- self.queue.put_nowait((location, await resp.json()))
87
- else:
88
- logging.warning('OpenWeather current API error for '
89
- f'{location}: {resp.status}')
90
- except Exception as e:
91
- logging.warning('OpenWeather current API error for '
92
- f'{location}: {e}')
115
+ try:
116
+ if self.session is None:
117
+ self.session = aiohttp.ClientSession()
118
+
119
+ for location, coords in self.locations.items():
120
+ base_params = {
121
+ 'lat': coords.get('lat'),
122
+ 'lon': coords.get('lon'),
123
+ 'appid': self.api_key,
124
+ 'units': self.units
125
+ }
126
+
127
+ # Validate required parameters
128
+ if not all(base_params.values()):
129
+ logging.error(
130
+ f'Missing required parameters for {location}: '
131
+ f'{[k for k, v in base_params.items() if not v]}'
132
+ )
133
+ continue
134
+
135
+ try:
136
+ async with self.session.get(
137
+ self.current_url,
138
+ params=base_params,
139
+ proxy=self.proxy,
140
+ timeout=30 # Add timeout
141
+ ) as resp:
142
+ if resp.status == 200:
143
+ data = await resp.json()
144
+ logging.debug(
145
+ f'Received current weather data for {location}'
146
+ )
147
+ await self.queue.put((location, data))
148
+ else:
149
+ error_text = await resp.text()
150
+ logging.error(
151
+ f'OpenWeather API error for {location}: '
152
+ f'Status: {resp.status}, Response: {error_text[:200]}'
153
+ )
154
+
155
+ except asyncio.TimeoutError:
156
+ logging.error(f'Timeout fetching data for {location}')
157
+ except aiohttp.ClientError as e:
158
+ logging.error(
159
+ f'Network error for {location}: {type(e).__name__} - {str(e)}'
160
+ )
161
+ except Exception as e:
162
+ logging.error(
163
+ f'Unexpected error for {location}: {type(e).__name__} - {str(e)}'
164
+ )
165
+
166
+ except Exception as e:
167
+ logging.error(f'Fatal error in fetch_current_data: {type(e).__name__} - {str(e)}')
93
168
 
94
169
  async def fetch_forecast_data(self):
95
170
  """Fetch forecast weather data for all locations."""
96
- logging.info('fetching forecast')
97
171
  if self.session is None:
98
172
  self.session = aiohttp.ClientSession()
99
173
  for location, coords in self.locations.items():
100
- base_params = {'lat': coords['lat'], 'lon': coords['lon'],
101
- 'appid': self.api_key, 'units': self.units }
174
+ base_params = {
175
+ 'lat': coords['lat'],
176
+ 'lon': coords['lon'],
177
+ 'appid': self.api_key,
178
+ 'units': self.units
179
+ }
102
180
  try:
103
181
  async with self.session.get(self.forecast_url,
104
182
  params=base_params, proxy=self.proxy) as resp:
105
183
  if resp.status == 200:
106
- self.queue.put_nowait((location, await resp.json()))
184
+ data = await resp.json()
185
+ logging.info(f'Queue forecast {location} {data}')
186
+ await self.queue.put((location, data))
107
187
  else:
108
- logging.warning('OpenWeather forecast API error '
109
- f'for {location}: {resp.status}')
188
+ logging.error(f'OpenWeather forecast API error for '
189
+ f'{location}: Status:{resp.status}, '
190
+ f'Response:{await resp.text()}')
110
191
  except Exception as e:
111
- logging.warning('OpenWeather forecast API error for '
112
- f'{location}: {e}')
192
+ logging.error(f'OpenWeather forecast API error for {location}: '
193
+ f'Exception:{type(e).__name__}, Message:{str(e)}')
113
194
 
114
195
  async def poll(self):
115
196
  """Poll OpenWeather APIs every 10 minutes."""
116
197
  now = int(time())
117
198
  if now % 600 == 0: # Every 10 minutes
118
199
  asyncio.create_task(self.fetch_current_data())
119
- if now % 10800 == 60: # Every 3 hours, offset by 1 minute
200
+ if now % 3600 == 60: # Every 3 hours, offset by 1 minute
120
201
  asyncio.create_task(self.fetch_forecast_data())
121
202
 
122
203
  async def start(self):
@@ -0,0 +1,217 @@
1
+ """Poll WITS GXP pricing real time dispatch and forecast."""
2
+ import asyncio
3
+ import aiohttp
4
+ import datetime
5
+ import logging
6
+ import socket
7
+ from time import time
8
+ from pymscada.bus_client import BusClient
9
+ from pymscada.periodic import Periodic
10
+ from pymscada.tag import Tag
11
+
12
+ class WitsAPIClient:
13
+ """Get pricing data from WITS GXP APIs."""
14
+
15
+ def __init__(
16
+ self,
17
+ bus_ip: str | None = '127.0.0.1',
18
+ bus_port: int = 1324,
19
+ proxy: str | None = None,
20
+ api: dict = {},
21
+ tags: list = []
22
+ ) -> None:
23
+ """
24
+ Connect to bus on bus_ip:bus_port.
25
+
26
+ api dict should contain:
27
+ - client_id: WITS API client ID
28
+ - client_secret: WITS API client secret
29
+ - url: WITS API base URL
30
+ - gxp_list: list of GXP nodes to fetch prices for
31
+ - schedules: list of schedule types to fetch
32
+ - back: number of periods to look back
33
+ - forward: number of periods to look forward
34
+ """
35
+ if bus_ip is not None:
36
+ try:
37
+ socket.gethostbyname(bus_ip)
38
+ except socket.gaierror:
39
+ raise ValueError(f"Invalid bus_ip: {bus_ip}")
40
+ if not isinstance(proxy, str) and proxy is not None:
41
+ raise ValueError("proxy must be a string or None")
42
+ if not isinstance(api, dict):
43
+ raise ValueError("api must be a dictionary")
44
+ if not isinstance(tags, list):
45
+ raise ValueError("tags must be a list")
46
+
47
+ self.busclient = None
48
+ if bus_ip is not None:
49
+ self.busclient = BusClient(bus_ip, bus_port, module='WitsAPI')
50
+ self.proxy = proxy
51
+ self.map_bus = id(self)
52
+ self.tags = {tagname: Tag(tagname, float) for tagname in tags}
53
+
54
+ # API configuration
55
+ self.client_id = api['client_id']
56
+ self.client_secret = api['client_secret']
57
+ self.base_url = api['url']
58
+ self.gxp_list = api.get('gxp_list', [])
59
+ self.back = api.get('back', 2)
60
+ self.forward = api.get('forward', 72)
61
+
62
+ self.session = None
63
+ self.handle = None
64
+ self.periodic = None
65
+ self.queue = asyncio.Queue()
66
+
67
+ async def get_token(self):
68
+ """Get a new OAuth token"""
69
+ auth_url = f"{self.base_url}/login/oauth2/token"
70
+ data = {
71
+ "grant_type": "client_credentials",
72
+ "client_id": self.client_id,
73
+ "client_secret": self.client_secret
74
+ }
75
+ headers = {"Content-Type": "application/x-www-form-urlencoded"}
76
+ try:
77
+ async with self.session.post(auth_url, data=data, headers=headers) as response:
78
+ if response.status == 200:
79
+ result = await response.json()
80
+ self.session.headers.update({
81
+ "Authorization": f"Bearer {result['access_token']}"
82
+ })
83
+ return result["access_token"]
84
+ else:
85
+ error_text = await response.text()
86
+ logging.error(f'WITS API auth error: {error_text}')
87
+ return None
88
+ except Exception as e:
89
+ logging.error(f'WITS API auth error: {type(e).__name__} - {str(e)}')
90
+ return None
91
+
92
+ async def get_multi_schedule_prices(self):
93
+ """Get prices across multiple schedules"""
94
+ endpoint = "api/market-prices/v1/prices"
95
+ params = {
96
+ 'schedules': 'RTD,PRSS,PRSL',
97
+ 'marketType': 'E',
98
+ 'offset': 0
99
+ }
100
+ if self.gxp_list:
101
+ params['nodes'] = ','.join(self.gxp_list)
102
+ if self.back:
103
+ params['back'] = min(self.back, 48)
104
+ if self.forward:
105
+ params['forward'] = min(self.forward, 48)
106
+
107
+ query = '&'.join(f"{k}={v}" for k, v in params.items())
108
+ url = f"{self.base_url}/{endpoint}?{query}"
109
+
110
+ try:
111
+ async with self.session.get(url, proxy=self.proxy) as response:
112
+ if response.status == 200:
113
+ return await response.json()
114
+ else:
115
+ error_text = await response.text()
116
+ logging.error(f'WITS API error: {error_text}')
117
+ return None
118
+ except Exception as e:
119
+ logging.error(f'WITS API error: {type(e).__name__} - {str(e)}')
120
+ return None
121
+
122
+ def parse_prices(self, response):
123
+ """Parse API response into structured price dictionary"""
124
+ if not response:
125
+ return {}
126
+ prices = {}
127
+ for schedule_data in response:
128
+ schedule = schedule_data['schedule']
129
+ if 'prices' not in schedule_data:
130
+ continue
131
+ for price in schedule_data['prices']:
132
+ node = price['node']
133
+ trading_time = int(datetime.datetime.fromisoformat(
134
+ price['tradingDateTime'].replace('Z', '+00:00')
135
+ ).timestamp())
136
+ last_run = int(datetime.datetime.fromisoformat(
137
+ price['lastRunTime'].replace('Z', '+00:00')
138
+ ).timestamp())
139
+
140
+ if node not in prices:
141
+ prices[node] = {}
142
+ if trading_time not in prices[node]:
143
+ prices[node][trading_time] = {}
144
+ prices[node][trading_time][schedule] = [price['price'], last_run]
145
+ return prices
146
+
147
+ def update_tags(self, prices):
148
+ """Update tags with price data"""
149
+ for node in prices:
150
+ rtd = {}
151
+ for trading_time in prices[node]:
152
+ if 'RTD' in prices[node][trading_time]:
153
+ rtd_price, _ = prices[node][trading_time]['RTD']
154
+ rtd[trading_time] = rtd_price
155
+ continue
156
+ prss_price = None
157
+ prsl_price = None
158
+ if 'PRSS' in prices[node][trading_time]:
159
+ prss_price, prss_last_run = prices[node][trading_time]['PRSS']
160
+ if 'PRSL' in prices[node][trading_time]:
161
+ prsl_price, prsl_last_run = prices[node][trading_time]['PRSL']
162
+ if prsl_price is not None and prss_price is not None:
163
+ if prss_last_run > prsl_last_run:
164
+ rtd[trading_time] = prss_price
165
+ else:
166
+ rtd[trading_time] = prsl_price
167
+ continue
168
+ if prss_price is not None:
169
+ rtd[trading_time] = prss_price
170
+ elif prsl_price is not None:
171
+ rtd[trading_time] = prsl_price
172
+ tagname = f"{node}_RTD"
173
+ if tagname in self.tags:
174
+ for trading_time in sorted(rtd.keys()):
175
+ time_us = int(trading_time * 1_000_000)
176
+ self.tags[tagname].value = rtd[trading_time], time_us, self.map_bus
177
+
178
+ async def handle_response(self):
179
+ """Handle responses from the API."""
180
+ while True:
181
+ try:
182
+ prices = await self.queue.get()
183
+ if prices:
184
+ parsed_prices = self.parse_prices(prices)
185
+ self.update_tags(parsed_prices)
186
+ self.queue.task_done()
187
+ except Exception as e:
188
+ logging.error(f'Error handling response: {type(e).__name__} - {str(e)}')
189
+
190
+ async def fetch_data(self):
191
+ """Fetch price data from WITS API."""
192
+ try:
193
+ if self.session is None:
194
+ self.session = aiohttp.ClientSession()
195
+ token = await self.get_token()
196
+ if token:
197
+ prices = await self.get_multi_schedule_prices()
198
+ if prices:
199
+ await self.queue.put(prices)
200
+ except Exception as e:
201
+ logging.error(f'Error fetching data: {type(e).__name__} - {str(e)}')
202
+
203
+ async def poll(self):
204
+ """Poll WITS API every 5 minutes."""
205
+ now = int(time())
206
+ if now % 300 == 0: # Every 5 minutes
207
+ asyncio.create_task(self.fetch_data())
208
+
209
+ async def start(self):
210
+ """Start bus connection and API polling."""
211
+ if self.busclient is not None:
212
+ await self.busclient.start()
213
+ self.handle = asyncio.create_task(self.handle_response())
214
+ self.periodic = Periodic(self.poll, 1.0)
215
+ await self.periodic.start()
216
+
217
+