Skip to content

Commit 0e09490

Browse files
committed
Merge remote-tracking branch 'Nostromo-energy/main'
2 parents 4a55ee7 + 9bdedeb commit 0e09490

File tree

6 files changed

+290
-7
lines changed

6 files changed

+290
-7
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,5 @@ dist/
1111
.ipynb_checkpoints/
1212
static/
1313
.idea/
14+
.vscode/
1415
*.venv/

openleadr/client.py

Lines changed: 127 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import logging
2020
import ssl
2121
from datetime import datetime, timedelta, timezone
22+
from dataclasses import asdict
2223
from functools import partial
2324
from http import HTTPStatus
2425

@@ -42,9 +43,11 @@ class OpenADRClient:
4243
Main client class. Most of these methods will be called automatically, but
4344
you can always choose to call them manually.
4445
"""
46+
4547
def __init__(self, ven_name, vtn_url, debug=False, cert=None, key=None,
4648
passphrase=None, vtn_fingerprint=None, show_fingerprint=True, ca_file=None,
47-
allow_jitter=True, ven_id=None, disable_signature=False, check_hostname=True):
49+
allow_jitter=True, ven_id=None, disable_signature=False, check_hostname=True,
50+
event_status_log_period=10, events_clean_up_period=300):
4851
"""
4952
Initializes a new OpenADR Client (Virtual End Node)
5053
@@ -65,6 +68,9 @@ def __init__(self, ven_name, vtn_url, debug=False, cert=None, key=None,
6568
:param str ven_id: The ID for this VEN. If you leave this blank,
6669
a VEN_ID will be assigned by the VTN.
6770
:param bool disable_signature: Whether or not to sign outgoing messages using a public-private key pair in PEM format.
71+
:param bool check_hostname: Whether or not to check hostname
72+
:param int event_status_log_period: Setting the priod of status change logging
73+
:param int events_clean_up_period: Setting the priod of not relevant events clean up
6874
"""
6975

7076
self.ven_name = ven_name
@@ -75,6 +81,8 @@ def __init__(self, ven_name, vtn_url, debug=False, cert=None, key=None,
7581
self.vtn_fingerprint = vtn_fingerprint
7682
self.debug = debug
7783
self.check_hostname = check_hostname
84+
self.event_status_log_period = event_status_log_period
85+
self.events_clean_up_period = events_clean_up_period
7886

7987
self.reports = []
8088
self.report_callbacks = {} # Holds the callbacks for each specific report
@@ -85,6 +93,7 @@ def __init__(self, ven_name, vtn_url, debug=False, cert=None, key=None,
8593
self.client_session = None
8694
self.report_queue_task = None
8795

96+
self.opts = []
8897
self.received_events = [] # Holds the events that we received.
8998
self.responded_events = {} # Holds the events that we already saw.
9099

@@ -162,9 +171,12 @@ async def run(self):
162171
self.scheduler.add_job(self._poll,
163172
trigger='interval',
164173
seconds=self.poll_frequency.total_seconds())
174+
self.scheduler.add_job(self._event_status_log,
175+
trigger='interval',
176+
seconds=self.event_status_log_period)
165177
self.scheduler.add_job(self._event_cleanup,
166178
trigger='interval',
167-
seconds=300)
179+
seconds=self.events_clean_up_period)
168180
self.scheduler.start()
169181

170182
async def stop(self):
@@ -558,6 +570,104 @@ async def sync_events(self):
558570
if 'events' in response_payload and len(response_payload['events']) > 0:
559571
await self._on_event(response_payload)
560572

573+
###########################################################################
574+
# #
575+
# OPT METHODS #
576+
# #
577+
###########################################################################
578+
579+
async def create_opt(self, opt_type, opt_reason, targets, vavailability=None, event_id=None,
580+
modification_number=None, opt_id=None, request_id=None, market_context=None,
581+
signal_target_mrid=None):
582+
"""
583+
Send a new opt to the VTN, either to communicate a temporary availability
584+
schedule or to qualify the resources participating in an event.
585+
586+
:param str opt_type: An OpenADR opt type. (found in openleadr.enums.OPT)
587+
:param str opt_reason: An OpenADR opt reason. (found in openleadr.enums.OPT_REASON)
588+
:param targets: A list of target(s) that this opt is related to.
589+
:param vavailability: The availability schedule to send
590+
:param event_id: The id of the event this opt is referencing.
591+
:param modification_number: The modification number of the event this opt is referencing.
592+
:param str opt_id: A unique identifier for this opt message. Leave this blank for a
593+
random generated id, or fill it in if your VTN depends on
594+
this being a known value, or if it needs to be constant
595+
between restarts of the client.
596+
:param str request_id: A unique identifier for this request. The same remarks apply
597+
as for the opt_id.
598+
:param str market_context: The Market Context that this opt belongs to.
599+
"""
600+
601+
# Verify input
602+
if opt_type not in enums.OPT.values:
603+
raise ValueError(f"{opt_type} is not a valid opt type. Valid options are "
604+
f"{', '.join(enums.REPORT_NAME.values)}")
605+
if opt_reason not in enums.OPT_REASON.values:
606+
raise ValueError(f"{opt_reason} is not a valid opt reason. Valid options are "
607+
f"{', '.join(enums.REPORT_NAME.values)}")
608+
609+
# Save opt
610+
opt_id = opt_id or utils.generate_id()
611+
opt = objects.Opt(
612+
opt_id=opt_id,
613+
opt_type=opt_type,
614+
opt_reason=opt_reason,
615+
vavailability=vavailability,
616+
event_id=event_id,
617+
modification_number=modification_number,
618+
targets=targets,
619+
market_context=market_context,
620+
signal_target_mrid=signal_target_mrid
621+
)
622+
self.opts.append(opt)
623+
624+
# Send opt
625+
request_id = request_id or utils.generate_id()
626+
payload = {
627+
'request_id': request_id,
628+
'ven_id': self.ven_id,
629+
**asdict(opt)
630+
}
631+
632+
service = 'EiOpt'
633+
message = self._create_message('oadrCreateOpt', **payload)
634+
response_type, response_payload = await self._perform_request(service, message)
635+
636+
if 'opt_id' in response_payload:
637+
# VTN acknowledged the opt message
638+
return response_payload['opt_id']
639+
640+
# TODO: what to do if the VTN sends an error or does not acknowledge the opt?
641+
642+
async def cancel_opt(self, opt_id):
643+
"""
644+
Tell the VTN to cancel a previously acknowledged opt message
645+
646+
:param str opt_id: The id of the opt to cancel
647+
"""
648+
649+
# Check if this opt exists
650+
opt = utils.find_by(
651+
self.opts, 'opt_id', opt_id)
652+
if not opt:
653+
logger.error(f"A non-existant opt with opt_id "
654+
f"{opt_id} was requested for cancellation.")
655+
return False
656+
657+
payload = {
658+
'opt_id': opt_id,
659+
'ven_id': self.ven_id
660+
}
661+
662+
service = 'EiOpt'
663+
message = self._create_message('oadrCancelOpt', **payload)
664+
response_type, response_payload = await self._perform_request(service, message)
665+
666+
if 'opt_id' in response_payload:
667+
# VTN acknowledged the opt cancelation
668+
self.opts.remove(opt)
669+
return True
670+
561671
###########################################################################
562672
# #
563673
# REPORTING METHODS #
@@ -1020,7 +1130,6 @@ async def _execute_hooks(self, hook_name, *args, **kwargs):
10201130
f"{err.__class__.__name__}: {err}")
10211131

10221132
async def _on_event(self, message):
1023-
logger.debug("The VEN received an event")
10241133
events = message['events']
10251134
invalid_vtn_id = False
10261135
try:
@@ -1033,6 +1142,7 @@ async def _on_event(self, message):
10331142
event_id = event['event_descriptor']['event_id']
10341143
event_status = event['event_descriptor']['event_status']
10351144
modification_number = event['event_descriptor']['modification_number']
1145+
logger.info("The VEN received an event with event_id: %s, status: %s, modification_number: %s", event_id, event_status, modification_number) # change to debug
10361146
received_event = utils.find_by(self.received_events, 'event_descriptor.event_id', event_id)
10371147
if received_event:
10381148
if received_event['event_descriptor']['modification_number'] == modification_number:
@@ -1112,6 +1222,20 @@ async def _on_event(self, message):
11121222
else:
11131223
logger.info("Not sending any event responses, because a response was not required/allowed by the VTN.")
11141224

1225+
async def _event_status_log(self):
1226+
"""
1227+
Periodic task that will log each event status change
1228+
"""
1229+
for event in self.received_events:
1230+
# ignoring the cancelled case
1231+
if event['event_descriptor']['event_status'] == 'cancelled':
1232+
continue
1233+
1234+
event_status = utils.determine_event_status(event['active_period'])
1235+
if event_status != event['event_descriptor']['event_status']:
1236+
event['event_descriptor']['event_status'] = event_status
1237+
logger.info("event_id: %s has new status: %s", event['event_descriptor']['event_id'], event_status) # change to debug
1238+
11151239
async def _event_cleanup(self):
11161240
"""
11171241
Periodic task that will clean up completed and cancelled events in our memory.

openleadr/objects.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,3 +309,70 @@ class ReportSpecifier:
309309
class ReportRequest:
310310
report_request_id: str
311311
report_specifier: ReportSpecifier
312+
313+
314+
@dataclass
315+
class VavailabilityComponent:
316+
dstart: datetime
317+
duration: timedelta
318+
319+
320+
@dataclass
321+
class Vavailability:
322+
components: List[VavailabilityComponent]
323+
324+
325+
@dataclass
326+
class Opt:
327+
opt_type: str
328+
opt_reason: str
329+
opt_id: str = None
330+
created_date_time: datetime = None
331+
332+
event_id: str = None
333+
modification_number: int = None
334+
vavailability: Vavailability = None
335+
targets: List[Target] = None
336+
targets_by_type: Dict = None
337+
market_context: str = None
338+
signal_target_mrid: str = None
339+
340+
def __post_init__(self):
341+
if self.opt_type not in enums.OPT.values:
342+
raise ValueError(f"""The opt_type must be one of '{"', '".join(enums.OPT.values)}', """
343+
f"""you specified: '{self.opt_type}'.""")
344+
if self.opt_reason not in enums.OPT_REASON.values:
345+
raise ValueError(f"""The opt_reason must be one of '{"', '".join(enums.OPT_REASON.values)}', """
346+
f"""you specified: '{self.opt_type}'.""")
347+
if self.signal_target_mrid is not None and self.signal_target_mrid not in enums.SIGNAL_TARGET_MRID.values and not self.signal_target_mrid.startswith('x-'):
348+
raise ValueError(f"""The signal_target_mrid must be one of '{"', '".join(enums.SIGNAL_TARGET_MRID.values)}', """
349+
f"""you specified: '{self.signal_target_mrid}'.""")
350+
if self.event_id is None and self.vavailability is None:
351+
raise ValueError(
352+
"You must supply either 'event_id' or 'vavailability'.")
353+
if self.event_id is not None and self.vavailability is not None:
354+
raise ValueError(
355+
"You supplied both 'event_id' and 'vavailability."
356+
"Please supply either, but not both.")
357+
if self.created_date_time is None:
358+
self.created_date_time = datetime.now(timezone.utc)
359+
if self.modification_number is None:
360+
self.modification_number = 0
361+
if self.targets is None and self.targets_by_type is None:
362+
raise ValueError(
363+
"You must supply either 'targets' or 'targets_by_type'.")
364+
if self.targets_by_type is None:
365+
list_of_targets = [asdict(target) if is_dataclass(
366+
target) else target for target in self.targets]
367+
self.targets_by_type = utils.group_targets_by_type(list_of_targets)
368+
elif self.targets is None:
369+
self.targets = [Target(
370+
**target) for target in utils.ungroup_targets_by_type(self.targets_by_type)]
371+
elif self.targets is not None and self.targets_by_type is not None:
372+
list_of_targets = [asdict(target) if is_dataclass(
373+
target) else target for target in self.targets]
374+
if utils.group_targets_by_type(list_of_targets) != self.targets_by_type:
375+
raise ValueError("You assigned both 'targets' and 'targets_by_type' in your event, "
376+
"but the two were not consistent with each other. "
377+
f"You supplied 'targets' = {self.targets} and "
378+
f"'targets_by_type' = {self.targets_by_type}")

openleadr/service/opt_service.py

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,82 @@
1414
# See the License for the specific language governing permissions and
1515
# limitations under the License.
1616

17-
from . import service, VTNService
17+
from . import service, handler, VTNService
18+
import logging
19+
logger = logging.getLogger('openleadr')
20+
21+
# ╔══════════════════════════════════════════════════════════════════════════╗
22+
# ║ OPT SERVICE ║
23+
# ╚══════════════════════════════════════════════════════════════════════════╝
24+
# ┌──────────────────────────────────────────────────────────────────────────┐
25+
# │ The VEN can send an Opt-in / Opt-out schedule to the VTN: │
26+
# │ │
27+
# │ ┌────┐ ┌────┐ │
28+
# │ │VEN │ │VTN │ │
29+
# │ └─┬──┘ └─┬──┘ │
30+
# │ │───────────────────────────oadrCreateOpt()──────────────────────▶│ │
31+
# │ │ │ │
32+
# │ │◀ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ oadrCreatedOpt()─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─│ │
33+
# │ │ │ │
34+
# │ │
35+
# └──────────────────────────────────────────────────────────────────────────┘
36+
# ┌──────────────────────────────────────────────────────────────────────────┐
37+
# │ The VEN can cancel a sent Opt-in / Opt-out schedule: │
38+
# │ │
39+
# │ ┌────┐ ┌────┐ │
40+
# │ │VEN │ │VTN │ │
41+
# │ └─┬──┘ └─┬──┘ │
42+
# │ │───────────────────────────oadrCancelOpt()──────────────────────▶│ │
43+
# │ │ │ │
44+
# │ │◀ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ oadrCanceledOpt()─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ │
45+
# │ │ │ │
46+
# │ │
47+
# └──────────────────────────────────────────────────────────────────────────┘
1848

1949

2050
@service('EiOpt')
2151
class OptService(VTNService):
22-
pass
52+
53+
def __init__(self, vtn_id):
54+
super().__init__(vtn_id)
55+
self.created_opt_schedules = {}
56+
57+
@handler('oadrCreateOpt')
58+
async def create_opt(self, payload):
59+
"""
60+
Handle an opt schedule created by the VEN
61+
"""
62+
63+
pass # TODO: call handler and return the result (oadrCreatedOpt)
64+
65+
def on_create_opt(self, payload):
66+
"""
67+
Implementation of the on_create_opt handler, may be overwritten by the user.
68+
"""
69+
ven_id = payload['ven_id']
70+
71+
if payload['ven_id'] not in self.created_opt_schedules:
72+
self.created_opt_schedules[ven_id] = []
73+
74+
# TODO: internally create an opt schedule and save it, if this is an optional handler then make sure to handle None returns
75+
76+
return 'oadrCreatedOpt', {'opt_id': payload['opt_id']}
77+
78+
@handler('oadrCancelOpt')
79+
async def cancel_opt(self, payload):
80+
"""
81+
Cancel an opt schedule previously created by the VEN
82+
"""
83+
ven_id = payload['ven_id']
84+
opt_id = payload['opt_id']
85+
86+
pass # TODO: call handler and return result (oadrCanceledOpt)
87+
88+
def on_cancel_opt(self, ven_id, opt_id):
89+
"""
90+
Placeholder for the on_cancel_opt handler.
91+
"""
92+
93+
# TODO: implement cancellation of previously acknowledged opt schedule, if this is an optional handler make sure to hande None returns
94+
95+
return 'oadrCanceledOpt', {'opt_id': opt_id}

openleadr/templates/oadrCreateOpt.xml

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,15 @@
77
<emix:marketContext>{{ market_context }}</emix:marketContext>
88
{% endif %}
99
<ei:venID>{{ ven_id }}</ei:venID>
10-
{% if vavailability is defined and vavalailability is not none %}
10+
{% if vavailability is defined and vavailability is not none %}
1111
<xcal:vavailability>
1212
<xcal:components>
1313
{% for component in vavailability.components %}
1414
<xcal:available>
1515
<xcal:properties>
1616
<xcal:dtstart>
17-
<xcal:date-time>{{ component.dtstart|datetimeformat }}</xcal:date-time></xcal:dtstart>
17+
<xcal:date-time>{{ component.dtstart|datetimeformat }}</xcal:date-time>
18+
</xcal:dtstart>
1819
<xcal:duration>
1920
<xcal:duration>{{ component.duration|timedeltaformat }}</xcal:duration>
2021
</xcal:duration>
@@ -33,5 +34,12 @@
3334
{% for target in targets %}
3435
{% include 'parts/eiTarget.xml' %}
3536
{% endfor %}
37+
{% if signal_target_mrid is defined and signal_target_mrid is not none %}
38+
<oadr:oadrDeviceClass>
39+
<power:endDeviceAsset>
40+
<power:mrid>{{ signal_target_mrid }}</power:mrid>
41+
</power:endDeviceAsset>
42+
</oadr:oadrDeviceClass>
43+
{% endif %}
3644
</oadr:oadrCreateOpt>
3745
</oadr:oadrSignedObject>

0 commit comments

Comments
 (0)