pymscada 0.2.0rc4__py3-none-any.whl → 0.2.0rc7__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.
Potentially problematic release.
This version of pymscada might be problematic. Click here for more details.
- pymscada/alarms.py +166 -171
- pymscada/bus_client.py +2 -2
- pymscada/callout.py +203 -0
- pymscada/console.py +4 -3
- pymscada/demo/callout.yaml +16 -0
- pymscada/demo/pymscada-io-witsapi.service +15 -0
- pymscada/demo/tags.yaml +4 -0
- pymscada/demo/witsapi.yaml +17 -0
- pymscada/iodrivers/witsapi.py +217 -0
- pymscada/iodrivers/witsapi_POC.py +246 -0
- pymscada/module_config.py +12 -0
- {pymscada-0.2.0rc4.dist-info → pymscada-0.2.0rc7.dist-info}/METADATA +3 -2
- {pymscada-0.2.0rc4.dist-info → pymscada-0.2.0rc7.dist-info}/RECORD +17 -11
- {pymscada-0.2.0rc4.dist-info → pymscada-0.2.0rc7.dist-info}/WHEEL +1 -1
- {pymscada-0.2.0rc4.dist-info → pymscada-0.2.0rc7.dist-info}/entry_points.txt +0 -0
- {pymscada-0.2.0rc4.dist-info → pymscada-0.2.0rc7.dist-info/licenses}/LICENSE +0 -0
- {pymscada-0.2.0rc4.dist-info → pymscada-0.2.0rc7.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
bus_ip: 127.0.0.1
|
|
2
|
+
bus_port: 1324
|
|
3
|
+
rta_tag: __callout__
|
|
4
|
+
alarms: __alarms__
|
|
5
|
+
ack: SI_Alarm_Ack
|
|
6
|
+
status: SO_Alarm_Status
|
|
7
|
+
callees:
|
|
8
|
+
- name: A name
|
|
9
|
+
sms: A number
|
|
10
|
+
- name: B name
|
|
11
|
+
sms: B number
|
|
12
|
+
groups:
|
|
13
|
+
- name: Aniwhenua Station
|
|
14
|
+
group:
|
|
15
|
+
- name: Aniwhenua System
|
|
16
|
+
group: System
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=pymscada - WITS client
|
|
3
|
+
BindsTo=pymscada-bus.service
|
|
4
|
+
After=pymscada-bus.service
|
|
5
|
+
|
|
6
|
+
[Service]
|
|
7
|
+
WorkingDirectory=__DIR__
|
|
8
|
+
ExecStart=__PYMSCADA__ witsapiclient --config __DIR__/config/witsapi.yaml
|
|
9
|
+
Restart=always
|
|
10
|
+
RestartSec=5
|
|
11
|
+
User=__USER__
|
|
12
|
+
Group=__USER__
|
|
13
|
+
|
|
14
|
+
[Install]
|
|
15
|
+
WantedBy=multi-user.target
|
pymscada/demo/tags.yaml
CHANGED
|
@@ -113,10 +113,14 @@ localhost_ping:
|
|
|
113
113
|
desc: Ping time to localhost
|
|
114
114
|
type: float
|
|
115
115
|
units: ms
|
|
116
|
+
alarm: '> 500 for 300'
|
|
117
|
+
group: System
|
|
116
118
|
google_ping:
|
|
117
119
|
desc: Ping time to google
|
|
118
120
|
type: float
|
|
119
121
|
units: ms
|
|
122
|
+
alarm: '> 100 for 30'
|
|
123
|
+
group: System
|
|
120
124
|
Murupara_Temp:
|
|
121
125
|
desc: Murupara Temperature
|
|
122
126
|
type: float
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
bus_ip: 127.0.0.1
|
|
2
|
+
bus_port: 1324
|
|
3
|
+
proxy:
|
|
4
|
+
api:
|
|
5
|
+
url: 'https://api.electricityinfo.co.nz'
|
|
6
|
+
client_id: ${WITS_CLIENT_ID}
|
|
7
|
+
client_secret: ${WITS_CLIENT_SECRET}
|
|
8
|
+
gxp_list:
|
|
9
|
+
- MAT1101
|
|
10
|
+
- CYD2201
|
|
11
|
+
- BEN2201
|
|
12
|
+
back: 1
|
|
13
|
+
forward: 12
|
|
14
|
+
tags:
|
|
15
|
+
- MAT1101_RTD
|
|
16
|
+
- CYD2201_RTD
|
|
17
|
+
- BEN2201_RTD
|
|
@@ -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
|
+
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import aiohttp
|
|
2
|
+
import asyncio
|
|
3
|
+
import datetime
|
|
4
|
+
|
|
5
|
+
class WitsAPIClient:
|
|
6
|
+
def __init__(self, url, client_id, client_secret):
|
|
7
|
+
self.client_id = client_id
|
|
8
|
+
self.client_secret = client_secret
|
|
9
|
+
self.base_url = url
|
|
10
|
+
self.session = None
|
|
11
|
+
|
|
12
|
+
async def __aenter__(self):
|
|
13
|
+
"""Create session and get token on entry"""
|
|
14
|
+
self.session = aiohttp.ClientSession()
|
|
15
|
+
return self
|
|
16
|
+
|
|
17
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
18
|
+
"""Close session on exit"""
|
|
19
|
+
if self.session:
|
|
20
|
+
await self.session.close()
|
|
21
|
+
|
|
22
|
+
async def get_token(self):
|
|
23
|
+
"""Get a new OAuth token"""
|
|
24
|
+
auth_url = f"{self.base_url}/login/oauth2/token"
|
|
25
|
+
data = {
|
|
26
|
+
"grant_type": "client_credentials",
|
|
27
|
+
"client_id": self.client_id,
|
|
28
|
+
"client_secret": self.client_secret
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
headers = {
|
|
32
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
async with self.session.post(auth_url, data=data, headers=headers) as response:
|
|
37
|
+
if response.status == 200:
|
|
38
|
+
result = await response.json()
|
|
39
|
+
self.session.headers.update({
|
|
40
|
+
"Authorization": f"Bearer {result['access_token']}"
|
|
41
|
+
})
|
|
42
|
+
return result["access_token"]
|
|
43
|
+
else:
|
|
44
|
+
error_text = await response.text()
|
|
45
|
+
return None
|
|
46
|
+
except Exception as e:
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
async def get(self, endpoint):
|
|
50
|
+
"""Make a GET request to the WITS API"""
|
|
51
|
+
url = f"{self.base_url}/{endpoint}"
|
|
52
|
+
try:
|
|
53
|
+
async with self.session.get(url) as response:
|
|
54
|
+
if response.status == 200:
|
|
55
|
+
result = await response.json()
|
|
56
|
+
return result
|
|
57
|
+
else:
|
|
58
|
+
error_text = await response.text()
|
|
59
|
+
return None
|
|
60
|
+
except Exception as e:
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
async def get_schedules(self):
|
|
64
|
+
"""Get list of schedules for which pricing data is available"""
|
|
65
|
+
endpoint = "api/market-prices/v1/schedules"
|
|
66
|
+
return await self.get(endpoint)
|
|
67
|
+
|
|
68
|
+
async def get_nodes(self):
|
|
69
|
+
"""Get list of GXP/GIP nodes supported by this API"""
|
|
70
|
+
endpoint = "api/market-prices/v1/nodes"
|
|
71
|
+
return await self.get(endpoint)
|
|
72
|
+
|
|
73
|
+
async def get_schedule_prices(self, schedule='RTD', market_type='E', nodes=None,
|
|
74
|
+
back=None, forward=None, from_date=None, to_date=None,
|
|
75
|
+
island=None, offset=0):
|
|
76
|
+
"""Get prices for a single schedule
|
|
77
|
+
Args:
|
|
78
|
+
schedule: Schedule type (e.g. 'RTD' for Real Time Dispatch)
|
|
79
|
+
market_type: 'E' for energy prices, 'R' for reserve prices
|
|
80
|
+
nodes: List of node IDs to filter by
|
|
81
|
+
back: Number of trading periods to look back (1-48)
|
|
82
|
+
forward: Number of trading periods to look ahead (1-48)
|
|
83
|
+
from_date: Start datetime (RFC3339 format)
|
|
84
|
+
to_date: End datetime (RFC3339 format)
|
|
85
|
+
island: Filter by island ('NI' or 'SI')
|
|
86
|
+
offset: Pagination offset
|
|
87
|
+
"""
|
|
88
|
+
endpoint = f"api/market-prices/v1/schedules/{schedule}/prices"
|
|
89
|
+
params = {
|
|
90
|
+
'marketType': market_type,
|
|
91
|
+
'offset': offset
|
|
92
|
+
}
|
|
93
|
+
if nodes:
|
|
94
|
+
params['nodes'] = ','.join(nodes) if isinstance(nodes, list) else nodes
|
|
95
|
+
if back:
|
|
96
|
+
params['back'] = min(back, 48)
|
|
97
|
+
if forward:
|
|
98
|
+
params['forward'] = min(forward, 48)
|
|
99
|
+
if from_date:
|
|
100
|
+
params['from'] = from_date
|
|
101
|
+
if to_date:
|
|
102
|
+
params['to'] = to_date
|
|
103
|
+
if island:
|
|
104
|
+
params['island'] = island
|
|
105
|
+
query = '&'.join(f"{k}={v}" for k, v in params.items())
|
|
106
|
+
return await self.get(f"{endpoint}?{query}")
|
|
107
|
+
|
|
108
|
+
async def get_multi_schedule_prices(self, schedules, market_type='E', nodes=None,
|
|
109
|
+
back=None, forward=None, from_date=None,
|
|
110
|
+
to_date=None, island=None, offset=0):
|
|
111
|
+
"""Get prices across multiple schedules
|
|
112
|
+
Args:
|
|
113
|
+
schedules: List of schedule types
|
|
114
|
+
market_type: 'E' for energy prices, 'R' for reserve prices
|
|
115
|
+
nodes: List of node IDs to filter by
|
|
116
|
+
back: Number of trading periods to look back (1-48)
|
|
117
|
+
forward: Number of trading periods to look ahead (1-48)
|
|
118
|
+
from_date: Start datetime (RFC3339 format)
|
|
119
|
+
to_date: End datetime (RFC3339 format)
|
|
120
|
+
island: Filter by island ('NI' or 'SI')
|
|
121
|
+
offset: Pagination offset
|
|
122
|
+
"""
|
|
123
|
+
endpoint = "api/market-prices/v1/prices"
|
|
124
|
+
params = {
|
|
125
|
+
'schedules': ','.join(schedules) if isinstance(schedules, list) else schedules,
|
|
126
|
+
'marketType': market_type,
|
|
127
|
+
'offset': offset
|
|
128
|
+
}
|
|
129
|
+
if nodes:
|
|
130
|
+
params['nodes'] = ','.join(nodes) if isinstance(nodes, list) else nodes
|
|
131
|
+
if back:
|
|
132
|
+
params['back'] = min(back, 48)
|
|
133
|
+
if forward:
|
|
134
|
+
params['forward'] = min(forward, 48)
|
|
135
|
+
if from_date:
|
|
136
|
+
params['from'] = from_date
|
|
137
|
+
if to_date:
|
|
138
|
+
params['to'] = to_date
|
|
139
|
+
if island:
|
|
140
|
+
params['island'] = island
|
|
141
|
+
query = '&'.join(f"{k}={v}" for k, v in params.items())
|
|
142
|
+
return await self.get(f"{endpoint}?{query}")
|
|
143
|
+
|
|
144
|
+
def parse_prices(self, response):
|
|
145
|
+
"""Parse API response into structured price dictionary
|
|
146
|
+
Returns dict in format:
|
|
147
|
+
{node: {trading_time_utc: {schedule: [price, last_run_utc]}}}
|
|
148
|
+
"""
|
|
149
|
+
if not response:
|
|
150
|
+
return {}
|
|
151
|
+
prices = {}
|
|
152
|
+
for schedule_data in response:
|
|
153
|
+
schedule = schedule_data['schedule']
|
|
154
|
+
if 'prices' not in schedule_data:
|
|
155
|
+
continue
|
|
156
|
+
for price in schedule_data['prices']:
|
|
157
|
+
node = price['node']
|
|
158
|
+
trading_time = int(datetime.datetime.fromisoformat(
|
|
159
|
+
price['tradingDateTime'].replace('Z', '+00:00')
|
|
160
|
+
).timestamp())
|
|
161
|
+
last_run = int(datetime.datetime.fromisoformat(
|
|
162
|
+
price['lastRunTime'].replace('Z', '+00:00')
|
|
163
|
+
).timestamp())
|
|
164
|
+
|
|
165
|
+
if node not in prices:
|
|
166
|
+
prices[node] = {}
|
|
167
|
+
if trading_time not in prices[node]:
|
|
168
|
+
prices[node][trading_time] = {}
|
|
169
|
+
prices[node][trading_time][schedule] = [price['price'], last_run]
|
|
170
|
+
|
|
171
|
+
# Create RTD_forecast schedule
|
|
172
|
+
for node in prices:
|
|
173
|
+
for trading_time in prices[node]:
|
|
174
|
+
if 'RTD' in prices[node][trading_time]:
|
|
175
|
+
prices[node][trading_time]['RTD_forecast'] = prices[node][trading_time]['RTD']
|
|
176
|
+
else:
|
|
177
|
+
# Find most recent schedule by last run time
|
|
178
|
+
latest_schedule = None
|
|
179
|
+
latest_last_run = 0
|
|
180
|
+
for schedule in prices[node][trading_time]:
|
|
181
|
+
if prices[node][trading_time][schedule][1] > latest_last_run:
|
|
182
|
+
latest_last_run = prices[node][trading_time][schedule][1]
|
|
183
|
+
latest_schedule = schedule
|
|
184
|
+
if latest_schedule:
|
|
185
|
+
prices[node][trading_time]['RTD_forecast'] = \
|
|
186
|
+
prices[node][trading_time][latest_schedule]
|
|
187
|
+
|
|
188
|
+
return prices
|
|
189
|
+
|
|
190
|
+
def print_prices(self, prices):
|
|
191
|
+
"""Print prices in structured format with time information"""
|
|
192
|
+
now = datetime.datetime.now(datetime.timezone.utc)
|
|
193
|
+
now_ts = now.timestamp()
|
|
194
|
+
for node in sorted(prices.keys()):
|
|
195
|
+
print(f" - {node}:")
|
|
196
|
+
for trading_time in sorted(prices[node].keys()):
|
|
197
|
+
time_diff = trading_time - now_ts
|
|
198
|
+
# For future times on 30 minute boundaries, show half-hour intervals
|
|
199
|
+
if time_diff > 0 and trading_time % 1800 == 0:
|
|
200
|
+
half_hours = int(time_diff / 1800)
|
|
201
|
+
time_str = f"(+{half_hours})"
|
|
202
|
+
else:
|
|
203
|
+
# For past times or non-30min intervals, show actual time
|
|
204
|
+
dt = datetime.datetime.fromtimestamp(trading_time,
|
|
205
|
+
datetime.timezone.utc)
|
|
206
|
+
time_str = f"({dt.strftime('%Y-%m-%d %H:%M:%S')})"
|
|
207
|
+
|
|
208
|
+
print(f" - Trading Time UTC: {trading_time} {time_str}")
|
|
209
|
+
for schedule in sorted(prices[node][trading_time].keys()):
|
|
210
|
+
if schedule in ['RTD', 'PRSS', 'PRSL', 'RTD_forecast']:
|
|
211
|
+
price, last_run = prices[node][trading_time][schedule]
|
|
212
|
+
last_run_dt = datetime.datetime.fromtimestamp(
|
|
213
|
+
last_run, datetime.timezone.utc)
|
|
214
|
+
print(f" {schedule:12} Price: {price:8.2f}, "
|
|
215
|
+
f"Last Run: {last_run_dt.strftime('%H:%M:%S')}")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
async def main(config):
|
|
219
|
+
async with WitsAPIClient(url=config['url'],client_id=config['client_id'],
|
|
220
|
+
client_secret=config['client_secret']) as client:
|
|
221
|
+
token = await client.get_token()
|
|
222
|
+
if token:
|
|
223
|
+
multi_prices = await client.get_multi_schedule_prices(
|
|
224
|
+
schedules=config['schedules'],
|
|
225
|
+
nodes=config['gxp_list'],
|
|
226
|
+
back=config['back'],
|
|
227
|
+
forward=config['forward']
|
|
228
|
+
)
|
|
229
|
+
if multi_prices:
|
|
230
|
+
prices_dict = client.parse_prices(multi_prices)
|
|
231
|
+
client.print_prices(prices_dict)
|
|
232
|
+
await asyncio.sleep(1)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
CONFIG = {
|
|
236
|
+
'url': 'https://api.electricityinfo.co.nz',
|
|
237
|
+
'gxp_list': ['MAT1101', 'CYD2201', 'BEN2201'],
|
|
238
|
+
'schedules': ['RTD', 'PRSS', 'PRSL'],
|
|
239
|
+
'back': 2,
|
|
240
|
+
'forward': 72,
|
|
241
|
+
'client_id': 'xx',
|
|
242
|
+
'client_secret': 'xx'
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if __name__ == "__main__":
|
|
246
|
+
asyncio.run(main(CONFIG))
|
pymscada/module_config.py
CHANGED
|
@@ -150,6 +150,18 @@ def create_module_registry():
|
|
|
150
150
|
module_class='pymscada.iodrivers.snmp_client:SnmpClient',
|
|
151
151
|
tags=False
|
|
152
152
|
),
|
|
153
|
+
ModuleDefinition(
|
|
154
|
+
name='witsapi',
|
|
155
|
+
help='poll WITS GXP pricing real time dispatch and forecast',
|
|
156
|
+
module_class='pymscada.iodrivers.witsapi:WitsAPIClient',
|
|
157
|
+
tags=False,
|
|
158
|
+
epilog=dedent("""
|
|
159
|
+
WITS_CLIENT_ID and WITS_CLIENT_SECRET can be set in the wits.yaml
|
|
160
|
+
or as environment variables:
|
|
161
|
+
vi ~/.bashrc
|
|
162
|
+
export WITS_CLIENT_ID='your_client_id'
|
|
163
|
+
export WITS_CLIENT_SECRET='your_client_secret'""")
|
|
164
|
+
),
|
|
153
165
|
ModuleDefinition(
|
|
154
166
|
name='console',
|
|
155
167
|
help='interactive bus console',
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: pymscada
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.0rc7
|
|
4
4
|
Summary: Shared tag value SCADA with python backup and Angular UI
|
|
5
5
|
Author-email: Jamie Walton <jamie@walton.net.nz>
|
|
6
6
|
License: GPL-3.0-or-later
|
|
@@ -21,6 +21,7 @@ Requires-Dist: pymscada-html==0.2.0rc4
|
|
|
21
21
|
Requires-Dist: cerberus>=1.3.5
|
|
22
22
|
Requires-Dist: pycomm3>=1.2.14
|
|
23
23
|
Requires-Dist: pysnmplib>=5.0.24
|
|
24
|
+
Dynamic: license-file
|
|
24
25
|
|
|
25
26
|
# pymscada
|
|
26
27
|
#### [Docs](https://github.com/jamie0walton/pymscada/blob/main/docs/README.md)
|
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
pymscada/__init__.py,sha256=NV_cIIwe66Ugp8ns426rtfJIIyskWbqwImD9p_5p0bQ,739
|
|
2
2
|
pymscada/__main__.py,sha256=WcyVlrYOoDdktJhOoyubTOycMwpayksFdxwelRU5xpQ,272
|
|
3
|
-
pymscada/alarms.py,sha256=
|
|
4
|
-
pymscada/bus_client.py,sha256=
|
|
3
|
+
pymscada/alarms.py,sha256=eo-9-DSwV825ihuHApaYkADPpLouKbNLHyh0z4bdPoU,12936
|
|
4
|
+
pymscada/bus_client.py,sha256=eRGvHQ4sFM_n3rKvKDWZQJPQJqvKMWvxbH3MjtT2WBo,9131
|
|
5
5
|
pymscada/bus_server.py,sha256=k7ht2SAr24Oab0hBOPeW4NRDF_RK-F46iE0cMzh7K4w,12323
|
|
6
|
+
pymscada/callout.py,sha256=o_GCB-3IFG-QIcHfrmW-N9CTTlsIu9nMLC-F1jQu1hQ,7274
|
|
6
7
|
pymscada/checkout.py,sha256=RLuCMTEuUI7pp1hIRAUPbo8xYFta8TjArelx0SD4gOY,3897
|
|
7
8
|
pymscada/config.py,sha256=vwGxieaJBYXiHNQEOYVDFaPuGmnUlCnbNm_W9bugKlc,1851
|
|
8
|
-
pymscada/console.py,sha256=
|
|
9
|
+
pymscada/console.py,sha256=EEsJLCvn8AFimN8qGNilX0ks6t3OFcGW5nw6OVAXfac,8850
|
|
9
10
|
pymscada/files.py,sha256=iouEOPfEkVI0Qbbf1p-L324Y04zSrynVypLW0-1MThA,2499
|
|
10
11
|
pymscada/history.py,sha256=7UEOeMnlSMv0LoWTqLWx7QwOW1FZZ4wAvzH6v6b0_vI,11592
|
|
11
12
|
pymscada/main.py,sha256=d6EnK4-tEcvM5AqMHYhvqlnSh-E_wd0Tuxk-kXYSiDw,1854
|
|
12
13
|
pymscada/misc.py,sha256=0Cj6OFhQonyhyk9x0BG5MiS-6EPk_w6zvavt8o_Hlf0,622
|
|
13
|
-
pymscada/module_config.py,sha256=
|
|
14
|
+
pymscada/module_config.py,sha256=sEoLUhMUFJfalH3CbhNPIqQd1bAL7bWCyPSMUKs6HJ4,9370
|
|
14
15
|
pymscada/opnotes.py,sha256=MKM51IrB93B2-kgoTzlpOLpaMYs-7rPQHWmRLME-hQQ,7952
|
|
15
16
|
pymscada/periodic.py,sha256=MLlL93VLvFqBBgjO1Us1t0aLHTZ5BFdW0B__G02T1nQ,1235
|
|
16
17
|
pymscada/protocol_constants.py,sha256=lPJ4JEgFJ_puJjTym83EJIOw3UTUFbuFMwg3ohyUAGY,2414
|
|
@@ -23,6 +24,7 @@ pymscada/demo/__init__.py,sha256=WsDDgkWnZBJbt2-cJCdc2NvRAv_T4a7WOC1Q0k_l0gI,29
|
|
|
23
24
|
pymscada/demo/accuweather.yaml,sha256=Fk4rV0S8jCau0173QCzKW8TdUbc4crYVi0aD8fPLNgU,322
|
|
24
25
|
pymscada/demo/alarms.yaml,sha256=Ea8tLZ0aEiyKM_m5MN1TF6xS-lI5ReXiz2oUPO8GvmQ,110
|
|
25
26
|
pymscada/demo/bus.yaml,sha256=zde5JDo2Yv5s7NvJ569gAEoTDvsvgBwRPxfrYhsxj3w,26
|
|
27
|
+
pymscada/demo/callout.yaml,sha256=ze2UfAymU05lzVp-t8wSa9hYJMGSkhT4FwL4rVJQG4o,270
|
|
26
28
|
pymscada/demo/files.yaml,sha256=XWtmGDJxtD4qdl2h7miUfJYkDKsvwNTgQjlGpR6LQNs,163
|
|
27
29
|
pymscada/demo/history.yaml,sha256=c0OuYe8LbTeZqJGU2WKGgTEkOA0IYAjO3e046ddQB8E,55
|
|
28
30
|
pymscada/demo/logixclient.yaml,sha256=G_NlJhBYwT1a9ceHDgO6fCNKFmBM2pVO_t9Xa1NqlRY,912
|
|
@@ -43,10 +45,12 @@ pymscada/demo/pymscada-io-modbusserver.service,sha256=g7Rzm6zGLq_qvTJRL_pcLl4Ps7
|
|
|
43
45
|
pymscada/demo/pymscada-io-openweather.service,sha256=SQnZ-cq1V3qvZY7EgR_Vx36vCOw1ipfGoLoutHsxtNk,359
|
|
44
46
|
pymscada/demo/pymscada-io-ping.service,sha256=Fm8qR4IVq0NupEvHLGONXGwjjQsx5VqaBYPewhg7-k4,329
|
|
45
47
|
pymscada/demo/pymscada-io-snmpclient.service,sha256=Rsm8uiwnoGx-1MkXqYgtj4UP9-r7AEEeB9yoR0y0oVA,343
|
|
48
|
+
pymscada/demo/pymscada-io-witsapi.service,sha256=ZjNwUnZg7WZsCaBFk8aNibnCbwqtbhl1i9D8tdUGXiQ,343
|
|
46
49
|
pymscada/demo/pymscada-opnotes.service,sha256=TlrTRgP3rzrlXT8isAGT_Wy38ScDjT1VvnlgW84XiS8,354
|
|
47
50
|
pymscada/demo/pymscada-wwwserver.service,sha256=7Qy2wsMmFEsQn-b5mgAcsrAQZgXynkv8SpHD6hLvRGc,370
|
|
48
51
|
pymscada/demo/snmpclient.yaml,sha256=z8iACrFvMftYUtqGrRjPZYZTpn7aOXI-Kp675NAM8cU,2013
|
|
49
|
-
pymscada/demo/tags.yaml,sha256=
|
|
52
|
+
pymscada/demo/tags.yaml,sha256=1HH9SqevBE0P0NXHK0Slfu68gwx5iKpgyirClmAcXGY,2814
|
|
53
|
+
pymscada/demo/witsapi.yaml,sha256=B8F136jvLIYU8t-pOdsEU_j97qMo3RgGQ1Rs4ExhmeE,289
|
|
50
54
|
pymscada/demo/wwwserver.yaml,sha256=mmwvSLUXUDCIPaHeCJdCETAp9Cc4wb5CuK_aGv01KWk,2759
|
|
51
55
|
pymscada/demo/__pycache__/__init__.cpython-311.pyc,sha256=tpxZoW429YA-2mbwzOlhBmbSTcbvTJqgKCfDRMrhEJE,195
|
|
52
56
|
pymscada/iodrivers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
@@ -61,15 +65,17 @@ pymscada/iodrivers/ping_client.py,sha256=UOQgUfoIcYqy5VvKyJ8XGHHjeSRTfjmrhyWEojh
|
|
|
61
65
|
pymscada/iodrivers/ping_map.py,sha256=EbOteqfEYKIOMqPymROJ4now2If-ekEj6jnM5hthoSA,1403
|
|
62
66
|
pymscada/iodrivers/snmp_client.py,sha256=66-IDzddeKcSnqOzNXIZ8wuuAqhIxZjyLNrDwDvHCvw,2708
|
|
63
67
|
pymscada/iodrivers/snmp_map.py,sha256=sDdIR5ZPAETpozDfBt_XQiZ-f4t99UCPlzj7BxFxQyM,2369
|
|
68
|
+
pymscada/iodrivers/witsapi.py,sha256=Ga6JpEQRUciT_LxWW36LsVGkUeWjModtzPoWYIzyzHs,8381
|
|
69
|
+
pymscada/iodrivers/witsapi_POC.py,sha256=dQcR2k1wsLb_cnNqvAB4kJ7FdY0BlcnxiMoepr28Ars,10132
|
|
64
70
|
pymscada/pdf/__init__.py,sha256=WsDDgkWnZBJbt2-cJCdc2NvRAv_T4a7WOC1Q0k_l0gI,29
|
|
65
71
|
pymscada/pdf/one.pdf,sha256=eoJ45DrAjVZrwmwdA_EAz1fwmT44eRnt_tkc2pmMrKY,1488
|
|
66
72
|
pymscada/pdf/two.pdf,sha256=TAuW5yLU1_wfmTH_I5ezHwY0pxhCVuZh3ixu0kwmJwE,1516
|
|
67
73
|
pymscada/pdf/__pycache__/__init__.cpython-311.pyc,sha256=4KTfXrV9bGDbTIEv-zgIj_LvzLbVTj77lEC1wzMh9e0,194
|
|
68
74
|
pymscada/tools/snmp_client2.py,sha256=pdn5dYyEv4q-ubA0zQ8X-3tQDYxGC7f7Xexa7QPaL40,1675
|
|
69
75
|
pymscada/tools/walk.py,sha256=OgpprUbKLhEWMvJGfU1ckUt_PFEpwZVOD8HucCgzmOc,1625
|
|
70
|
-
pymscada-0.2.
|
|
71
|
-
pymscada-0.2.
|
|
72
|
-
pymscada-0.2.
|
|
73
|
-
pymscada-0.2.
|
|
74
|
-
pymscada-0.2.
|
|
75
|
-
pymscada-0.2.
|
|
76
|
+
pymscada-0.2.0rc7.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
|
|
77
|
+
pymscada-0.2.0rc7.dist-info/METADATA,sha256=fSgmvdFEaztKuACzjhu9ya2By5ufTzj1PAYfM4z0dVk,2393
|
|
78
|
+
pymscada-0.2.0rc7.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
79
|
+
pymscada-0.2.0rc7.dist-info/entry_points.txt,sha256=2UJBi8jrqujnerrcXcq4F8GHJYVDt26sacXl94t3sd8,56
|
|
80
|
+
pymscada-0.2.0rc7.dist-info/top_level.txt,sha256=LxIB-zrtgObJg0fgdGZXBkmNKLDYHfaH1Hw2YP2ZMms,9
|
|
81
|
+
pymscada-0.2.0rc7.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|