Skip to content

Commit c1ac872

Browse files
authored
Merge pull request #2 from Nostromo-energy/eiopt
Eiopt VEN implementation
2 parents 2524684 + 878d5ae commit c1ac872

5 files changed

Lines changed: 262 additions & 3 deletions

File tree

openleadr/client.py

Lines changed: 101 additions & 0 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,6 +43,7 @@ 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,
4749
allow_jitter=True, ven_id=None, disable_signature=False, check_hostname=True):
@@ -85,6 +87,7 @@ def __init__(self, ven_name, vtn_url, debug=False, cert=None, key=None,
8587
self.client_session = None
8688
self.report_queue_task = None
8789

90+
self.opts = []
8891
self.received_events = [] # Holds the events that we received.
8992
self.responded_events = {} # Holds the events that we already saw.
9093

@@ -558,6 +561,104 @@ async def sync_events(self):
558561
if 'events' in response_payload and len(response_payload['events']) > 0:
559562
await self._on_event(response_payload)
560563

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

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: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
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>
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<oadr:oadrSignedObject xmlns:oadr="http://openadr.org/oadr-2.0b/2012/07" oadr:Id="oadrSignedObject">
2+
<oadr:oadrCreatedOpt ei:schemaVersion="2.0b" xmlns:ei="http://docs.oasis-open.org/ns/energyinterop/201110">
3+
<ei:eiResponse>
4+
<ei:responseCode>{{ response.response_code }}</ei:responseCode>
5+
<ei:responseDescription>{{ response.response_description }}</ei:responseDescription>
6+
<requestID xmlns="http://docs.oasis-open.org/ns/energyinterop/201110/payloads">{{ response.request_id }}</requestID>
7+
</ei:eiResponse>
8+
<ei:optID>{{ opt_id }}</ei:optID>
9+
</oadr:oadrCreatedOpt>
10+
</oadr:oadrSignedObject>

0 commit comments

Comments
 (0)