Add extracted tools: CitrineOS, OpenOCPP, ShapeShifter
- CitrineOS core extracted (CSMS OCPP 2.0.1) - OpenOCPP extracted (firmware OCPP 1.6J/2.0.1) - ShapeShifter library installed (pip install -e) - ShapeShifter specification extracted - EVerest extracted TODO updated with progress
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
from .client import (
|
||||
ShapeshifterAgrCroClient,
|
||||
ShapeshifterAgrDsoClient,
|
||||
ShapeshifterCroAgrClient,
|
||||
ShapeshifterCroDsoClient,
|
||||
ShapeshifterDsoAgrClient,
|
||||
ShapeshifterDsoCroClient,
|
||||
)
|
||||
from .oauth import OAuthClient
|
||||
from .service import (
|
||||
ShapeshifterAgrService,
|
||||
ShapeshifterCroService,
|
||||
ShapeshifterDsoService,
|
||||
)
|
||||
from .uftp import (
|
||||
AcceptedRejected,
|
||||
AgrPortfolioQuery,
|
||||
AgrPortfolioQueryResponse,
|
||||
AgrPortfolioQueryResponseCongestionPoint,
|
||||
AgrPortfolioQueryResponseConnection,
|
||||
AgrPortfolioQueryResponseDSOPortfolio,
|
||||
AgrPortfolioQueryResponseDSOView,
|
||||
AgrPortfolioUpdate,
|
||||
AgrPortfolioUpdateConnection,
|
||||
AgrPortfolioUpdateResponse,
|
||||
ContractSettlement,
|
||||
ContractSettlementISP,
|
||||
ContractSettlementPeriod,
|
||||
DPrognosis,
|
||||
DPrognosisISP,
|
||||
DPrognosisResponse,
|
||||
DsoPortfolioQuery,
|
||||
DsoPortfolioQueryCongestionPoint,
|
||||
DsoPortfolioQueryConnection,
|
||||
DsoPortfolioQueryResponse,
|
||||
DsoPortfolioUpdate,
|
||||
DsoPortfolioUpdateCongestionPoint,
|
||||
DsoPortfolioUpdateConnection,
|
||||
DsoPortfolioUpdateResponse,
|
||||
FlexMessage,
|
||||
FlexOffer,
|
||||
FlexOfferOption,
|
||||
FlexOfferOptionISP,
|
||||
FlexOfferResponse,
|
||||
FlexOfferRevocation,
|
||||
FlexOfferRevocationResponse,
|
||||
FlexOrder,
|
||||
FlexOrderISP,
|
||||
FlexOrderResponse,
|
||||
FlexOrderSettlement,
|
||||
FlexOrderSettlementISP,
|
||||
FlexOrderSettlementStatus,
|
||||
FlexOrderStatus,
|
||||
FlexRequest,
|
||||
FlexRequestISP,
|
||||
FlexRequestResponse,
|
||||
FlexReservationUpdate,
|
||||
FlexReservationUpdateISP,
|
||||
FlexReservationUpdateResponse,
|
||||
FlexSettlement,
|
||||
FlexSettlementResponse,
|
||||
Metering,
|
||||
MeteringISP,
|
||||
MeteringProfile,
|
||||
MeteringProfileEnum,
|
||||
MeteringResponse,
|
||||
MeteringUnit,
|
||||
PayloadMessage,
|
||||
PayloadMessageResponse,
|
||||
SignedMessage,
|
||||
TestMessage,
|
||||
TestMessageResponse,
|
||||
UsefRole,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ShapeshifterAgrCroClient",
|
||||
"ShapeshifterAgrDsoClient",
|
||||
"ShapeshifterCroAgrClient",
|
||||
"ShapeshifterCroDsoClient",
|
||||
"ShapeshifterDsoAgrClient",
|
||||
"ShapeshifterDsoCroClient",
|
||||
"ShapeshifterAgrService",
|
||||
"ShapeshifterDsoService",
|
||||
"ShapeshifterCroService",
|
||||
"AcceptedRejected",
|
||||
"AgrPortfolioQuery",
|
||||
"AgrPortfolioQueryResponse",
|
||||
"AgrPortfolioQueryResponseCongestionPoint",
|
||||
"AgrPortfolioQueryResponseConnection",
|
||||
"AgrPortfolioQueryResponseDSOPortfolio",
|
||||
"AgrPortfolioQueryResponseDSOView",
|
||||
"AgrPortfolioUpdate",
|
||||
"AgrPortfolioUpdateConnection",
|
||||
"AgrPortfolioUpdateResponse",
|
||||
"ContractSettlement",
|
||||
"ContractSettlementISP",
|
||||
"ContractSettlementPeriod",
|
||||
"DPrognosis",
|
||||
"DPrognosisISP",
|
||||
"DPrognosisResponse",
|
||||
"DsoPortfolioQuery",
|
||||
"DsoPortfolioQueryCongestionPoint",
|
||||
"DsoPortfolioQueryConnection",
|
||||
"DsoPortfolioQueryResponse",
|
||||
"DsoPortfolioUpdate",
|
||||
"DsoPortfolioUpdateCongestionPoint",
|
||||
"DsoPortfolioUpdateConnection",
|
||||
"DsoPortfolioUpdateResponse",
|
||||
"FlexMessage",
|
||||
"FlexOffer",
|
||||
"FlexOfferOption",
|
||||
"FlexOfferOptionISP",
|
||||
"FlexOfferResponse",
|
||||
"FlexOfferRevocation",
|
||||
"FlexOfferRevocationResponse",
|
||||
"FlexOrder",
|
||||
"FlexOrderISP",
|
||||
"FlexOrderResponse",
|
||||
"FlexOrderSettlement",
|
||||
"FlexOrderSettlementISP",
|
||||
"FlexOrderSettlementStatus",
|
||||
"FlexOrderStatus",
|
||||
"FlexRequest",
|
||||
"FlexRequestISP",
|
||||
"FlexRequestResponse",
|
||||
"FlexReservationUpdate",
|
||||
"FlexReservationUpdateISP",
|
||||
"FlexReservationUpdateResponse",
|
||||
"FlexSettlement",
|
||||
"FlexSettlementResponse",
|
||||
"Metering",
|
||||
"MeteringISP",
|
||||
"MeteringProfile",
|
||||
"MeteringProfileEnum",
|
||||
"MeteringResponse",
|
||||
"MeteringUnit",
|
||||
"OAuthClient",
|
||||
"PayloadMessage",
|
||||
"PayloadMessageResponse",
|
||||
"SignedMessage",
|
||||
"TestMessage",
|
||||
"TestMessageResponse",
|
||||
"UsefRole",
|
||||
]
|
||||
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
A set of command-line-interface functions that are useful during
|
||||
development of Shapeshifter applications.
|
||||
"""
|
||||
|
||||
from argparse import ArgumentParser
|
||||
from base64 import b64encode
|
||||
|
||||
from nacl.bindings import crypto_sign_keypair
|
||||
|
||||
from . import transport
|
||||
from .exceptions import AuthenticationTimeoutException, ServiceDiscoveryException
|
||||
|
||||
|
||||
def generate_signing_keypair():
|
||||
"""
|
||||
Generate a signing keypair (private and public) and print them as
|
||||
base64-encoded strings. These are the strings that you'd use for
|
||||
signing and verifying messages; you pass these to the signing_key
|
||||
and recipient_signing_key parameters of the Service or Client
|
||||
objects.
|
||||
"""
|
||||
public, private = crypto_sign_keypair()
|
||||
print("-" * 66)
|
||||
print("Private key (base64):", b64encode(private).decode())
|
||||
print("Public key (base64): ", b64encode(public).decode())
|
||||
print("-" * 66)
|
||||
|
||||
def perform_lookup():
|
||||
"""
|
||||
Perform a DNS lookup of a participant's version, endpoint and
|
||||
public key details. These use the well-known DNS names described
|
||||
in the UFTP specification.
|
||||
"""
|
||||
parser = ArgumentParser()
|
||||
parser.add_argument("-d", "--domain", required=True, type=str, help="The sender domain for the other party")
|
||||
parser.add_argument("-r", "--role", required=True, type=str, help="The sender role for the other party")
|
||||
args = parser.parse_args()
|
||||
|
||||
print("-" * 65)
|
||||
|
||||
try:
|
||||
version = transport.get_version(args.domain)
|
||||
print(f"Shapeshifer version: {version}")
|
||||
except ServiceDiscoveryException as err:
|
||||
print(err)
|
||||
|
||||
try:
|
||||
endpoint = transport.get_endpoint(args.domain, args.role)
|
||||
print(f"Endpoint URL: {endpoint}")
|
||||
except ServiceDiscoveryException as err:
|
||||
print(err)
|
||||
|
||||
try:
|
||||
signing_key, decryption_key = transport.get_keys(args.domain, args.role)
|
||||
print(f"Signing key: {signing_key}")
|
||||
if decryption_key:
|
||||
print(f"Decryption Key: {decryption_key}")
|
||||
except AuthenticationTimeoutException as err:
|
||||
print(err)
|
||||
|
||||
print("-" * 65)
|
||||
@@ -0,0 +1,20 @@
|
||||
from .agr_cro_client import ShapeshifterAgrCroClient
|
||||
from .agr_dso_client import ShapeshifterAgrDsoClient
|
||||
from .cro_agr_client import ShapeshifterCroAgrClient
|
||||
from .cro_dso_client import ShapeshifterCroDsoClient
|
||||
from .dso_agr_client import ShapeshifterDsoAgrClient
|
||||
from .dso_cro_client import ShapeshifterDsoCroClient
|
||||
|
||||
__all__ = [
|
||||
"ShapeshifterAgrCroClient",
|
||||
"ShapeshifterAgrDsoClient",
|
||||
"ShapeshifterCroAgrClient",
|
||||
"ShapeshifterCroDsoClient",
|
||||
"ShapeshifterDsoAgrClient",
|
||||
"ShapeshifterDsoCroClient"
|
||||
]
|
||||
|
||||
client_map = {
|
||||
(client.sender_role, client.recipient_role): client
|
||||
for client in [globals()[name] for name in __all__]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
from ..uftp import (
|
||||
AgrPortfolioQuery,
|
||||
AgrPortfolioUpdate,
|
||||
UsefRole,
|
||||
)
|
||||
from .base_client import ShapeshifterClient
|
||||
|
||||
|
||||
class ShapeshifterAgrCroClient(ShapeshifterClient):
|
||||
"""
|
||||
Client that allows the Aggregator to connect to the CRO.
|
||||
"""
|
||||
|
||||
sender_role = UsefRole.AGR
|
||||
recipient_role = UsefRole.CRO
|
||||
|
||||
def send_agr_portfolio_update(self, message: AgrPortfolioUpdate) -> None:
|
||||
"""
|
||||
The AGRPortfolioUpdate is used by the AGR to indicate on which
|
||||
Connections it represents prosumers.
|
||||
"""
|
||||
return self._send_message(message)
|
||||
|
||||
def send_agr_portfolio_query(self, message: AgrPortfolioQuery) -> None:
|
||||
"""
|
||||
The AGRPortfolioQuery is used by the AGR to retrieve additional
|
||||
information on the connections.
|
||||
"""
|
||||
return self._send_message(message)
|
||||
@@ -0,0 +1,92 @@
|
||||
from ..uftp import (
|
||||
DPrognosis,
|
||||
FlexOffer,
|
||||
FlexOfferRevocation,
|
||||
FlexOrderResponse,
|
||||
FlexRequestResponse,
|
||||
FlexReservationUpdateResponse,
|
||||
FlexSettlementResponse,
|
||||
Metering,
|
||||
UsefRole,
|
||||
)
|
||||
from .base_client import ShapeshifterClient
|
||||
|
||||
|
||||
class ShapeshifterAgrDsoClient(ShapeshifterClient):
|
||||
"""
|
||||
Client that allows the Aggregator to connect to the DSO.
|
||||
"""
|
||||
|
||||
sender_role = UsefRole.AGR
|
||||
recipient_role = UsefRole.DSO
|
||||
|
||||
def send_d_prognosis(self, message: DPrognosis) -> None:
|
||||
"""
|
||||
D-Prognosis messages are used to communicate D-prognoses between AGRs
|
||||
and DSOs. D-Prognosis messages always contain data for all ISPs for the
|
||||
period they apply to, even if a prognosis is sent after the start of
|
||||
the period, when one or more ISPs are already in the operate or
|
||||
settlement phase. Receiving implementations should ignore the
|
||||
information supplied for those ISPs.
|
||||
"""
|
||||
return self._send_message(message)
|
||||
|
||||
def send_flex_request_response(self, message: FlexRequestResponse) -> None:
|
||||
"""
|
||||
Upon receiving and processing a FlexRequest message, the receiving
|
||||
implementation must reply with a FlexRequestResponse, indicating
|
||||
whether the flexibility request was processed successfully.
|
||||
"""
|
||||
return self._send_message(message)
|
||||
|
||||
def send_flex_offer(self, message: FlexOffer) -> None:
|
||||
"""
|
||||
FlexOffer messages are used by AGRs to make DSOs an offer for provision
|
||||
of flexibility. A FlexOffer message contains a list of ISPs and, for
|
||||
each ISP, the change in consumption or production offered and the price
|
||||
for the total amount of flexibility offered. FlexOffer messages can be
|
||||
sent once a FlexRequest message has been received but can also be sent
|
||||
unsolicited. Note that multiple FlexOffer messages may be sent based on
|
||||
a single FlexRequest, e.g. to increase the chance that the DSO will
|
||||
order at least part of its available flexibility. The AGR must make
|
||||
sure that it can actually provide the flexibility offered across all of
|
||||
its FlexOffers.
|
||||
"""
|
||||
self._send_message(message)
|
||||
|
||||
def send_flex_offer_revocation(self, message: FlexOfferRevocation) -> None:
|
||||
"""
|
||||
The FlexOfferRevocation message is used by the AGR to revoke a FlexOffer
|
||||
previously sent to a DSO. It voids the FlexOffer, even if its validity
|
||||
time has not yet expired. Revocation is not allowed for FlexOffers that
|
||||
already have associated accepted FlexOrders.
|
||||
"""
|
||||
self._send_message(message)
|
||||
|
||||
def send_flex_order_response(self, message: FlexOrderResponse) -> None:
|
||||
"""
|
||||
Confirm the flex order.
|
||||
"""
|
||||
self._send_message(message)
|
||||
|
||||
def send_flex_settlement_response(self, message: FlexSettlementResponse) -> None:
|
||||
"""
|
||||
Upon receiving and processing a FlexSettlement message, the AGR must
|
||||
reply with a FlexSettlementResponse, indicating whether the initial
|
||||
message was handled successfully. When a FlexSettlement message is
|
||||
rejected, the DSO should consider all FlexOrderSettlement elements of
|
||||
that message related to potential dispute.
|
||||
"""
|
||||
self._send_message(message)
|
||||
|
||||
def send_flex_reservation_update_response(self, message: FlexReservationUpdateResponse) -> None:
|
||||
"""
|
||||
Confirm the flex reservation update.
|
||||
"""
|
||||
self._send_message(message)
|
||||
|
||||
def send_metering(self, message: Metering) -> None:
|
||||
"""
|
||||
Send metering data to the DSO.
|
||||
"""
|
||||
self._send_message(message)
|
||||
@@ -0,0 +1,252 @@
|
||||
import sched
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from queue import Queue
|
||||
from threading import Event, Thread
|
||||
from uuid import uuid4
|
||||
|
||||
import requests
|
||||
|
||||
from .. import transport
|
||||
from ..exceptions import ClientTransportException
|
||||
from ..logging import logger
|
||||
from ..oauth import OAuthClient, PassthroughOAuthClient
|
||||
from ..uftp import (
|
||||
PayloadMessage,
|
||||
SignedMessage,
|
||||
TestMessage,
|
||||
TestMessageResponse,
|
||||
UsefRole,
|
||||
)
|
||||
|
||||
|
||||
class ShapeshifterClient:
|
||||
"""
|
||||
Basis for all Shapeshifter client.
|
||||
"""
|
||||
|
||||
sender_role: UsefRole
|
||||
recipient_role: UsefRole
|
||||
num_outgoing_workers = 10
|
||||
num_delivery_attempts = 10
|
||||
request_timeout = 30
|
||||
exponential_retry_factor = 1.0
|
||||
exponential_retry_base = 2.0
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sender_domain: str,
|
||||
signing_key: str,
|
||||
recipient_domain: str,
|
||||
recipient_endpoint: str | None = None,
|
||||
recipient_signing_key: str | None = None,
|
||||
oauth_client: OAuthClient | None = None,
|
||||
version: str = "3.1.0"
|
||||
):
|
||||
"""
|
||||
Shapeshifter client class that allows you to initiate messages to a different party.
|
||||
:param str sender_domain: your sender domain
|
||||
:param str signing_key: your private signing key
|
||||
:param str recipient_domain: the domain of the recipient
|
||||
:param str recipient_endpoint: the full http endpoint URL of the recipient. If omitted,
|
||||
will look up the endpoint using DNS.
|
||||
:param str recipient_signing_key: the public signing key of the recipient. If omitted, will
|
||||
look up the signing key using DNS.
|
||||
:param OAuthClient oauth_client: Optional OAuth client instance for using oauth to authenticate outgoing messages.
|
||||
:param str version: Version number for the shapeshfter protocol (3.0.0 or 3.1.0)
|
||||
"""
|
||||
if recipient_domain is None and recipient_endpoint is None:
|
||||
raise ValueError(
|
||||
"One of recipient_domain or recipient_endpoint must be provided."
|
||||
)
|
||||
|
||||
if version not in ("3.0.0", "3.1.0"):
|
||||
raise ValueError(f"'version' should be one of '3.0.0' or '3.1.0', not '{version}'")
|
||||
|
||||
self.version = version
|
||||
self.sender_domain = sender_domain
|
||||
self.signing_key = signing_key
|
||||
self.recipient_domain = recipient_domain
|
||||
self.recipient_endpoint = recipient_endpoint
|
||||
self.recipient_signing_key = recipient_signing_key
|
||||
|
||||
# The outgoing queue and scheduler are used when queueing
|
||||
# messages for delivery later. This allows the Shapeshifter
|
||||
# UFTP client to handle message retries on an exponential
|
||||
# time schedule, and delivers the result in the provided
|
||||
# callback function.
|
||||
self.outgoing_queue = Queue()
|
||||
self.outgoing_workers = None
|
||||
self.scheduler = sched.scheduler(time.monotonic, time.sleep)
|
||||
self.scheduler_event = Event()
|
||||
self.scheduler_thread = None
|
||||
|
||||
if oauth_client:
|
||||
self.oauth_client = oauth_client
|
||||
else:
|
||||
self.oauth_client = PassthroughOAuthClient()
|
||||
|
||||
# ------------------------------------------------------------ #
|
||||
# Test Messages #
|
||||
# ------------------------------------------------------------ #
|
||||
def send_test_message(self, message: TestMessage | None = None):
|
||||
if message is None:
|
||||
message = TestMessage()
|
||||
|
||||
return self._send_message(message)
|
||||
|
||||
def send_test_message_response(self, message: TestMessageResponse):
|
||||
return self._send_message(message)
|
||||
|
||||
def _send_message(self, message: PayloadMessage) -> None:
|
||||
"""
|
||||
Perform an operation. This will take the message object, pack
|
||||
it up into a SignedMessage, sign and seal it, and send it to
|
||||
the recipient. It returns an unsealed PayloadMessageResponse
|
||||
that contains the functional status of the request. The
|
||||
actual response always arrives asynchronously on your service
|
||||
(which runs separately).
|
||||
"""
|
||||
if not isinstance(message, PayloadMessage):
|
||||
raise TypeError(
|
||||
f"'message' must be a (subclass of) PayloadMessage, you provided: {type(message)}"
|
||||
)
|
||||
|
||||
# Fill the PayloadMessage's fields that are common to all
|
||||
# messages. We don't require the developer to fill these out
|
||||
# every time they create any message, in order to reduce the
|
||||
# duplicated code that would result in, and all of these
|
||||
# properties can be calculated in the framework anyway.
|
||||
message.version = self.version
|
||||
message.sender_domain = self.sender_domain
|
||||
message.recipient_domain = self.recipient_domain
|
||||
message.time_stamp = (
|
||||
message.time_stamp or datetime.now(timezone.utc).isoformat()
|
||||
)
|
||||
message.message_id = message.message_id or str(uuid4())
|
||||
message.conversation_id = message.conversation_id or str(uuid4())
|
||||
|
||||
logger.info(f"The PayloadMessage is: {message}")
|
||||
|
||||
# Seal the message using our own private signing key
|
||||
sealed_message = transport.seal_message(message, self.signing_key)
|
||||
|
||||
# Pack up the message into a SignedMessage
|
||||
signed_message = SignedMessage(
|
||||
sender_domain=self.sender_domain,
|
||||
sender_role=self.sender_role,
|
||||
body=sealed_message,
|
||||
)
|
||||
|
||||
# Serialize the message into an XML blob
|
||||
serialized_message = transport.to_xml(signed_message)
|
||||
|
||||
logger.debug(f"Sending message to {self.recipient_endpoint}:")
|
||||
logger.debug(serialized_message)
|
||||
|
||||
# Send the request to the relevant endpoint
|
||||
with self.oauth_client.ensure_authenticated():
|
||||
response = requests.post(
|
||||
self.recipient_endpoint,
|
||||
data=serialized_message,
|
||||
headers={
|
||||
"Content-Type": "text/xml; charset=utf-8",
|
||||
**self.oauth_client.auth_header
|
||||
},
|
||||
timeout=self.request_timeout,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
error_msg = (
|
||||
f"Request to {self.recipient_endpoint} was not succesful: "
|
||||
f"HTTP {response.status_code}: {response.text}"
|
||||
)
|
||||
logger.error(error_msg)
|
||||
raise ClientTransportException(error_msg, response=response)
|
||||
|
||||
# ------------------------------------------------------------ #
|
||||
# Methods related to queueing and scheduling outgoing #
|
||||
# messages. #
|
||||
# ------------------------------------------------------------ #
|
||||
|
||||
def _queue_message(self, message, callback, attempt=1):
|
||||
self.outgoing_queue.put((message, callback, attempt))
|
||||
self._run_outgoing_workers()
|
||||
|
||||
def _outgoing_worker(self):
|
||||
while True:
|
||||
message, callback, attempt = self.outgoing_queue.get()
|
||||
try:
|
||||
response = self._send_message(message)
|
||||
except Exception as exc: # pylint: disable=broad-exception-caught
|
||||
if attempt <= self.num_delivery_attempts:
|
||||
# Reschedule with exponential backoff
|
||||
delay_time = (
|
||||
self.exponential_retry_factor
|
||||
* self.exponential_retry_base**attempt
|
||||
)
|
||||
logger.warning(
|
||||
f"Outgoing message {message.__class__.__name__} to "
|
||||
f"{message.recipient_domain} could not be delivered "
|
||||
f"due to a {exc.__class__.__name__}, will try again in {delay_time:.0f} seconds."
|
||||
)
|
||||
self.scheduler.enter(
|
||||
delay=delay_time,
|
||||
priority=1,
|
||||
action=self._queue_message,
|
||||
argument=((message, callback, attempt + 1)),
|
||||
)
|
||||
self._run_scheduler()
|
||||
else:
|
||||
logger.error(
|
||||
f"Could not deliver {message.__class__.__name__} "
|
||||
f"to {self.recipient_role} at {self.recipient_domain}, "
|
||||
f"even after {self.num_delivery_attempts} attempts."
|
||||
)
|
||||
else:
|
||||
try:
|
||||
callback(response)
|
||||
except Exception as err: # pylint: disable=broad-exception-caught
|
||||
logger.error(
|
||||
"There was an exception during the callback "
|
||||
f"for a {message.__class__.__name__} message: "
|
||||
f"{err.__class__.__name__}: {err}"
|
||||
)
|
||||
finally:
|
||||
self.outgoing_queue.task_done()
|
||||
|
||||
def _run_scheduler(self):
|
||||
"""
|
||||
Make sure the scheduler thread is running and awake.
|
||||
"""
|
||||
if not self.scheduler_thread:
|
||||
self.scheduler_thread = Thread(target=self._scheduler_thread, daemon=True)
|
||||
self.scheduler_thread.start()
|
||||
self.scheduler_event.set()
|
||||
|
||||
def _scheduler_thread(self):
|
||||
"""
|
||||
Intended to run the python scheduler in a background thread.
|
||||
You can wake it up anytime by setting the scheduler event.
|
||||
"""
|
||||
while True:
|
||||
self.scheduler_event.wait()
|
||||
self.scheduler_event.clear()
|
||||
self.scheduler.run()
|
||||
|
||||
def _run_outgoing_workers(self):
|
||||
"""
|
||||
Start up the outgoing queue workers.
|
||||
"""
|
||||
if not self.outgoing_workers:
|
||||
self.outgoing_workers = [
|
||||
Thread(target=self._outgoing_worker, daemon=True)
|
||||
for _ in range(self.num_outgoing_workers)
|
||||
]
|
||||
for thread in self.outgoing_workers:
|
||||
thread.start()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args, **kwargs):
|
||||
pass
|
||||
@@ -0,0 +1,25 @@
|
||||
from ..uftp import AgrPortfolioQueryResponse, AgrPortfolioUpdateResponse, UsefRole
|
||||
from .base_client import ShapeshifterClient
|
||||
|
||||
|
||||
class ShapeshifterCroAgrClient(ShapeshifterClient):
|
||||
"""
|
||||
Client that allows the CRO to connect to the Aggregator.
|
||||
"""
|
||||
|
||||
sender_role = UsefRole.CRO
|
||||
recipient_role = UsefRole.AGR
|
||||
|
||||
def send_agr_portfolio_update_response(self, message: AgrPortfolioUpdateResponse) -> None:
|
||||
"""
|
||||
The DSOPortfolioUpdate is used by the DSO to indicate on which
|
||||
congestion points it wants to engage in flexibility trading.
|
||||
"""
|
||||
self._send_message(message)
|
||||
|
||||
def send_agr_portfolio_query_response(self, message: AgrPortfolioQueryResponse) -> None:
|
||||
"""
|
||||
DSOPortfolioQuery is used by DSOs to discover which AGRs represent
|
||||
connections on its registered congestion point(s).
|
||||
"""
|
||||
self._send_message(message)
|
||||
@@ -0,0 +1,33 @@
|
||||
from ..uftp import DsoPortfolioQueryResponse, DsoPortfolioUpdateResponse, UsefRole
|
||||
from .base_client import ShapeshifterClient
|
||||
|
||||
|
||||
class ShapeshifterCroDsoClient(ShapeshifterClient):
|
||||
"""
|
||||
Client that allows the CRO to connect to the DSO.
|
||||
|
||||
There are only two types of messages that the CRO can send to the DSO:
|
||||
|
||||
- DsoPortfolioUpdateResponse
|
||||
- DsoPortfolioQueryResponse
|
||||
|
||||
Each of these comes after the DSO sends a DsoPortfolioUpdate or
|
||||
DsoPortfolioQuery, respectively.
|
||||
"""
|
||||
|
||||
sender_role = UsefRole.CRO
|
||||
recipient_role = UsefRole.DSO
|
||||
|
||||
def send_dso_portfolio_update_response(self, message: DsoPortfolioUpdateResponse) -> None:
|
||||
"""
|
||||
The DSOPortfolioUpdate is used by the DSO to indicate on which
|
||||
congestion points it wants to engage in flexibility trading.
|
||||
"""
|
||||
self._send_message(message)
|
||||
|
||||
def send_dso_portfolio_query_response(self, message: DsoPortfolioQueryResponse) -> None:
|
||||
"""
|
||||
DSOPortfolioQuery is used by DSOs to discover which AGRs represent
|
||||
connections on its registered congestion point(s).
|
||||
"""
|
||||
self._send_message(message)
|
||||
@@ -0,0 +1,90 @@
|
||||
from ..uftp import (
|
||||
DPrognosisResponse,
|
||||
FlexOfferResponse,
|
||||
FlexOfferRevocationResponse,
|
||||
FlexOrder,
|
||||
FlexRequest,
|
||||
FlexReservationUpdate,
|
||||
FlexSettlement,
|
||||
MeteringResponse,
|
||||
UsefRole,
|
||||
)
|
||||
from .base_client import ShapeshifterClient
|
||||
|
||||
|
||||
class ShapeshifterDsoAgrClient(ShapeshifterClient):
|
||||
"""
|
||||
Client that allows the DSO to connect to the Aggregator.
|
||||
"""
|
||||
|
||||
sender_role = UsefRole.DSO
|
||||
recipient_role = UsefRole.AGR
|
||||
|
||||
def send_d_prognosis_response(self, message: DPrognosisResponse) -> None:
|
||||
"""
|
||||
Confirm reception of the D-prognosis.
|
||||
"""
|
||||
self._send_message(message)
|
||||
|
||||
def send_flex_request(self, message: FlexRequest) -> None:
|
||||
"""
|
||||
FlexRequest messages are used by DSOs to request flexibility from AGRs.
|
||||
In addition to one or more ISP elements with Disposition=Requested,
|
||||
indicating the actual need to reduce consumption or production, the
|
||||
message should also include the remaining ISPs for the current period
|
||||
where Disposition=Available.
|
||||
"""
|
||||
self._send_message(message)
|
||||
|
||||
def send_flex_offer_response(self, message: FlexOfferResponse) -> None:
|
||||
"""
|
||||
Confirm reception of a flex offer.
|
||||
"""
|
||||
self._send_message(message)
|
||||
|
||||
def send_flex_order(self, message: FlexOrder) -> None:
|
||||
"""
|
||||
FlexOrder messages are used by DSOs to purchase flexibility from an AGR
|
||||
based on a previous FlexOffer. A FlexOrder message contains a list of
|
||||
ISPs, with, for each ISP, the change in consumption or production to be
|
||||
realized by the AGR, and the accepted price to be paid by the DSO for
|
||||
this amount of flexibility. This ISP list should be copied from the
|
||||
FlexOffer message without modification: AGR implementations will
|
||||
(and must) reject FlexOrder messages where the ISP list is not exactly
|
||||
the same as offered.
|
||||
"""
|
||||
self._send_message(message)
|
||||
|
||||
def send_flex_reservation_update(self, message: FlexReservationUpdate) -> None:
|
||||
"""
|
||||
For bilateral contracts, FlexReservationUpdate messages are used by DSOs
|
||||
to signal to an AGR which part of the contracted volume is still
|
||||
reserved and which part is not needed and may be used for other
|
||||
purposes. For each ISP, a power value is given which indicates how much
|
||||
power is still reserved. Zero power means that no power is reserved for
|
||||
that ISP and the sign of the power indicates the direction.
|
||||
"""
|
||||
self._send_message(message)
|
||||
|
||||
def send_flex_settlement(self, message: FlexSettlement) -> None:
|
||||
"""
|
||||
The FlexSettlement message is sent by DSOs on a regular basis
|
||||
(typically monthly) to AGRs, in order to initiate settlement.
|
||||
It includes a list of all FlexOrders placed by the
|
||||
originating party during the settlement period.
|
||||
"""
|
||||
self._send_message(message)
|
||||
|
||||
def send_flex_offer_revocation_response(self, message: FlexOfferRevocationResponse) -> None:
|
||||
"""
|
||||
Upon receiving and processing a FlexOfferRevocation message, the
|
||||
receiving implementation must reply with a FlexOfferRevocationResponse,
|
||||
indicating whether the revocation was handled successfully.
|
||||
"""
|
||||
self._send_message(message)
|
||||
|
||||
def send_metering_response(self, message: MeteringResponse) -> None:
|
||||
"""
|
||||
Confirm reception of metering data.
|
||||
"""
|
||||
self._send_message(message)
|
||||
@@ -0,0 +1,25 @@
|
||||
from ..uftp import DsoPortfolioQuery, DsoPortfolioUpdate, UsefRole
|
||||
from .base_client import ShapeshifterClient
|
||||
|
||||
|
||||
class ShapeshifterDsoCroClient(ShapeshifterClient):
|
||||
"""
|
||||
Client that allows the DSO to connect to the CRO.
|
||||
"""
|
||||
|
||||
sender_role = UsefRole.DSO
|
||||
recipient_role = UsefRole.CRO
|
||||
|
||||
def send_dso_portfolio_update(self, message: DsoPortfolioUpdate) -> None:
|
||||
"""
|
||||
The DSOPortfolioUpdate is used by the DSO to indicate on which
|
||||
congestion points it wants to engage in flexibility trading.
|
||||
"""
|
||||
self._send_message(message)
|
||||
|
||||
def send_dso_portfolio_query(self, message: DsoPortfolioQuery) -> None:
|
||||
"""
|
||||
DSOPortfolioQuery is used by DSOs to discover which AGRs represent
|
||||
connections on its registered congestion point(s).
|
||||
"""
|
||||
self._send_message(message)
|
||||
@@ -0,0 +1,272 @@
|
||||
"""
|
||||
Transport exceptions and functional exceptions that, when raised,
|
||||
trigger well-defined behaviour from the Shapeshifter UFTP
|
||||
implementation.
|
||||
|
||||
Subclasses of TransportException return the appropriate HTTP Status
|
||||
Code.
|
||||
|
||||
Subclasses of FunctionalException return a proper
|
||||
PayloadResponseMessage with result = REJECTED and the appropriate
|
||||
rejection_reason.
|
||||
|
||||
More information on these exceptions can be found in the Shapeshifter
|
||||
Specification. The relevant parts are copied as docstrings for these
|
||||
exceptions.
|
||||
"""
|
||||
from abc import ABC
|
||||
|
||||
|
||||
class TransportException(Exception, ABC):
|
||||
"""
|
||||
Base TransportException class that is used by FastApi to return
|
||||
the approprate status code.
|
||||
"""
|
||||
http_status_code: int
|
||||
|
||||
|
||||
class MissingContentLengthException(TransportException):
|
||||
"""
|
||||
Thrown when the content-length is missing from the message headers.
|
||||
"""
|
||||
|
||||
http_status_code = 411
|
||||
|
||||
|
||||
class InvalidContentTypeException(TransportException):
|
||||
"""
|
||||
Raised when the Content-Type header is not set to text/xml or the
|
||||
character set is not utf-8.
|
||||
"""
|
||||
|
||||
http_status_code = 400
|
||||
|
||||
|
||||
class TooManyRequestsException(TransportException):
|
||||
"""
|
||||
Raised when the originating IP address is making too many requests
|
||||
to the service.
|
||||
"""
|
||||
|
||||
http_status_code = 429
|
||||
|
||||
|
||||
class SchemaException(TransportException):
|
||||
"""
|
||||
Raised when the XML Body cannot be parsed or does not comply to
|
||||
the schema.
|
||||
"""
|
||||
|
||||
http_status_code = 400
|
||||
|
||||
|
||||
class AuthenticationTimeoutException(TransportException):
|
||||
"""
|
||||
Raised when the sender's public key could not be looked up in
|
||||
DNS.
|
||||
"""
|
||||
|
||||
http_status_code = 419
|
||||
|
||||
|
||||
class InvalidSignatureException(TransportException):
|
||||
"""
|
||||
Raised when the signed message could not be unsealed because of an
|
||||
invalid signature.
|
||||
"""
|
||||
|
||||
http_status_code = 401
|
||||
|
||||
|
||||
class FunctionalException(ABC, Exception):
|
||||
"""
|
||||
Base class for gunctional exceptions. When raised in a request
|
||||
context, FastAPI will return the appropriate response message to
|
||||
the other participant.
|
||||
"""
|
||||
rejection_reason: str
|
||||
|
||||
|
||||
class InvalidMessageException(FunctionalException):
|
||||
"""
|
||||
Despite being schema-compliant, the syntax, type or semantics of
|
||||
the message were unacceptable for the receiving implementation.
|
||||
"""
|
||||
def __init__(self, message):
|
||||
super().__init__()
|
||||
self.rejection_reason = f"Invalid Message: '{message.__class__.__name__}'"
|
||||
|
||||
|
||||
class InvalidSenderException(FunctionalException):
|
||||
"""
|
||||
There is a mismatch between the SenderDomain/Role combination in
|
||||
the message wrapper and the inner XML message.
|
||||
"""
|
||||
rejection_reason = "Invalid Sender"
|
||||
|
||||
|
||||
class UnknownRecipientException(FunctionalException):
|
||||
"""
|
||||
The RecipientDomain and/or RecipientRole specified in the inner
|
||||
XML message is not handled by this endpoint.
|
||||
"""
|
||||
rejection_reason = "Unknown Recipient"
|
||||
|
||||
|
||||
class BarredSenderException(FunctionalException):
|
||||
"""
|
||||
This endpoint is explicitly blocking messages from this sender.
|
||||
"""
|
||||
rejection_reason = "Barred Sender"
|
||||
|
||||
|
||||
class DuplicateIdentifierException(FunctionalException):
|
||||
"""
|
||||
The MessageID attribute of the inner XML message is not unique,
|
||||
and has already been used for a message with different content.
|
||||
This message has been rejected.
|
||||
"""
|
||||
rejection_reason = "Duplicate Identifier"
|
||||
|
||||
|
||||
class AlreadySubmittedException(FunctionalException):
|
||||
"""
|
||||
The MessageID attribute of the inner XML message is not unique,
|
||||
but since the message content is the same as that of a previously
|
||||
accepted message, this copy can be considered to be successfully
|
||||
submitted as well.
|
||||
"""
|
||||
rejection_reason = "Already Submitted"
|
||||
|
||||
|
||||
class ISPDurationRejectedException(FunctionalException):
|
||||
"""
|
||||
The message specifies a ISP duration that is not the agreed-upon
|
||||
common value for the market in which it is used.
|
||||
"""
|
||||
rejection_reason = "ISP Duration Rejected"
|
||||
|
||||
|
||||
class TimeZoneRejectedException(FunctionalException):
|
||||
"""
|
||||
The message specifies a time zone that has a different UTC offset
|
||||
than is the agreed-upon common value for the market.
|
||||
"""
|
||||
rejection_reason = "TimeZone Rejected"
|
||||
|
||||
|
||||
class InvalidCongestionPointException(FunctionalException):
|
||||
"""
|
||||
Unknown congestion point or the recipient is not active at this
|
||||
congestion point.
|
||||
"""
|
||||
rejection_reason = "Invalid Congestion Point"
|
||||
|
||||
|
||||
class UnknownReferenceException(FunctionalException):
|
||||
"""
|
||||
The message with the sequence where is referred to is unknown. For
|
||||
the concerning reference field name can be filled in (for example
|
||||
FlexRequestSequence or PrognosisSequence).
|
||||
"""
|
||||
rejection_reason = "Unknown Reference"
|
||||
|
||||
|
||||
class ReferencePeriodMismatchException(FunctionalException):
|
||||
"""
|
||||
The message(s) with the sequence where is referred to contains a
|
||||
different Period.
|
||||
"""
|
||||
rejection_reason = "Reference Period Mismatch"
|
||||
|
||||
|
||||
class ReferenceMessageExpiredException(FunctionalException):
|
||||
"""
|
||||
The message that is referred to is expired.
|
||||
"""
|
||||
rejection_reason = "Reference Message Expired"
|
||||
|
||||
|
||||
class ReferenceMessageRevokedException(FunctionalException):
|
||||
"""
|
||||
The message that is referred to is revoked.
|
||||
"""
|
||||
rejection_reason = "Reference Message Revoked"
|
||||
|
||||
|
||||
class ISPsOutOfBoundsException(FunctionalException):
|
||||
"""
|
||||
One or more ISPs are outside the tolerated boundaries: ISPs do not
|
||||
exist.
|
||||
"""
|
||||
rejection_reason = "ISPs Out Of Bounds"
|
||||
|
||||
|
||||
class ISPConflictException(FunctionalException):
|
||||
"""
|
||||
One or more ISPs are defined more than once, possibly because of
|
||||
an incorrect duration.
|
||||
"""
|
||||
rejection_reason = "ISP Conflict"
|
||||
|
||||
|
||||
class PeriodOutOfBoundsException(FunctionalException):
|
||||
"""
|
||||
Period of the message is inappropriate. For example: a FlexRequest
|
||||
with a Period in the past or a settlement item in a
|
||||
FlexSettlement with a Period outside the concerning settlement
|
||||
period.
|
||||
"""
|
||||
rejection_reason = "Period Out Of Bounds"
|
||||
|
||||
|
||||
class ExpirationDateTimeOutOfBoundsException(FunctionalException):
|
||||
"""
|
||||
ExpirationDateTime is in the past or exceeds the ISPs in the
|
||||
message.
|
||||
"""
|
||||
rejection_reason = "Expiration DateTime Out Of Bounds"
|
||||
|
||||
|
||||
class UnauthorizedException(FunctionalException):
|
||||
"""
|
||||
CRO is operating in closed mode and the DSO is not pre-registered
|
||||
as an authorized participant
|
||||
"""
|
||||
rejection_reason = "Unauthorized"
|
||||
|
||||
|
||||
class ConnectionConflictException(FunctionalException):
|
||||
"""
|
||||
A connection is transmitted before at another Congestion Point.
|
||||
Return EntityAddress of the concerning Connection and Congestion
|
||||
Point where it has been placed before.
|
||||
"""
|
||||
def __init__(self, connection_entity_address, congestion_point_entity_address):
|
||||
super().__init__()
|
||||
self.rejection_reason = (
|
||||
f"Connection conflict: {connection_entity_address} at {congestion_point_entity_address}"
|
||||
)
|
||||
|
||||
|
||||
class SubordinateSequenceNumberException(FunctionalException):
|
||||
"""
|
||||
The message sequence is lower than that of a previously received
|
||||
DSOPortfolioUpdate
|
||||
"""
|
||||
rejection_reason = "Subordinate Sequence Number"
|
||||
|
||||
|
||||
class ServiceDiscoveryException(Exception):
|
||||
"""
|
||||
Raised when there is an error during service discovery.
|
||||
"""
|
||||
|
||||
|
||||
class ClientTransportException(Exception):
|
||||
"""
|
||||
Raised when the response to the client is not HTTP 200.
|
||||
"""
|
||||
def __init__(self, *args, response, **kwargs):
|
||||
self.response = response
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -0,0 +1,37 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from termcolor import colored
|
||||
|
||||
color_map = {
|
||||
logging.DEBUG: "cyan",
|
||||
logging.INFO: "green",
|
||||
logging.WARNING: "yellow",
|
||||
logging.ERROR: "red",
|
||||
logging.CRITICAL: "magenta",
|
||||
}
|
||||
|
||||
|
||||
class ShapeshifterLogFormatter(logging.Formatter):
|
||||
"""
|
||||
Formatter for the shapeshifter logs.
|
||||
"""
|
||||
|
||||
def format(self, record):
|
||||
"""
|
||||
Format log recors using colors.
|
||||
"""
|
||||
color = color_map[record.levelno]
|
||||
return (
|
||||
colored(f"{record.levelname:10}", color)
|
||||
+ f"{datetime.now().astimezone().isoformat()} - {record.getMessage()}"
|
||||
)
|
||||
|
||||
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(ShapeshifterLogFormatter())
|
||||
handler.setLevel(logging.DEBUG)
|
||||
|
||||
logger = logging.getLogger("shapeshifter-uftp")
|
||||
logger.addHandler(handler)
|
||||
logger.setLevel(logging.DEBUG)
|
||||
@@ -0,0 +1,82 @@
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime
|
||||
from json import JSONDecodeError
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
class OAuthClient:
|
||||
|
||||
EXPIRATION_SAFETY_BUFFER = 60
|
||||
|
||||
def __init__(self, url, client_id, client_secret):
|
||||
self.url = url
|
||||
self.client_id = client_id
|
||||
self.client_secret = client_secret
|
||||
self.access_token = None
|
||||
self.access_token_type = None
|
||||
self.access_token_expiry = None
|
||||
|
||||
@contextmanager
|
||||
def ensure_authenticated(self):
|
||||
if not self.authenticated:
|
||||
self.authenticate()
|
||||
yield
|
||||
|
||||
@property
|
||||
def authenticated(self):
|
||||
return self.access_token and not self.expired
|
||||
|
||||
@property
|
||||
def expired(self):
|
||||
return self.access_token_expiry < (datetime.now().timestamp() + OAuthClient.EXPIRATION_SAFETY_BUFFER)
|
||||
|
||||
@property
|
||||
def auth_header(self):
|
||||
return {"Authorization": f"{self.access_token_type} {self.access_token}"}
|
||||
|
||||
def authenticate(self):
|
||||
response = requests.post(
|
||||
self.url,
|
||||
data={
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
},
|
||||
timeout=30
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise AuthorizationError(
|
||||
f"Could not obtain an access token from the OAuth server at {self.url}:"
|
||||
f"{response.text}"
|
||||
)
|
||||
try:
|
||||
response_data = response.json()
|
||||
except JSONDecodeError as err:
|
||||
raise AuthorizationError(
|
||||
f"The OAuth server at {self.url} did not return a valid JSON response: "
|
||||
f"{response.text}"
|
||||
) from err
|
||||
|
||||
try:
|
||||
self.access_token = response_data["access_token"]
|
||||
self.access_token_type = response_data["token_type"]
|
||||
self.access_token_expiry = datetime.now().timestamp() + response_data["expires_in"]
|
||||
except KeyError as err:
|
||||
raise AuthorizationError(
|
||||
f"The response from the OAuth server is missing the {str(err)} field"
|
||||
) from err
|
||||
|
||||
|
||||
class PassthroughOAuthClient:
|
||||
|
||||
auth_header = {}
|
||||
|
||||
@contextmanager
|
||||
def ensure_authenticated(self):
|
||||
yield
|
||||
|
||||
|
||||
class AuthorizationError(Exception):
|
||||
pass
|
||||
@@ -0,0 +1,9 @@
|
||||
from .agr_service import ShapeshifterAgrService
|
||||
from .cro_service import ShapeshifterCroService
|
||||
from .dso_service import ShapeshifterDsoService
|
||||
|
||||
__all__ = [
|
||||
"ShapeshifterAgrService",
|
||||
"ShapeshifterCroService",
|
||||
"ShapeshifterDsoService",
|
||||
]
|
||||
@@ -0,0 +1,192 @@
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from ..client import ShapeshifterAgrCroClient, ShapeshifterAgrDsoClient
|
||||
from ..uftp import (
|
||||
AgrPortfolioQueryResponse,
|
||||
AgrPortfolioUpdateResponse,
|
||||
DPrognosisResponse,
|
||||
FlexOfferResponse,
|
||||
FlexOfferRevocationResponse,
|
||||
FlexOrder,
|
||||
FlexRequest,
|
||||
FlexReservationUpdate,
|
||||
FlexSettlement,
|
||||
MeteringResponse,
|
||||
TestMessage,
|
||||
TestMessageResponse,
|
||||
UsefRole,
|
||||
)
|
||||
from .base_service import ShapeshifterService
|
||||
|
||||
|
||||
class ShapeshifterAgrService(
|
||||
ShapeshifterService, ABC
|
||||
): # pylint: disable=too-many-public-methods
|
||||
"""
|
||||
Service that represents the Aggregator in the UFTP communication.
|
||||
|
||||
This service can receive requests from the DSO.
|
||||
"""
|
||||
|
||||
sender_role = UsefRole.AGR
|
||||
acceptable_messages = [
|
||||
AgrPortfolioQueryResponse,
|
||||
AgrPortfolioUpdateResponse,
|
||||
DPrognosisResponse,
|
||||
FlexOfferResponse,
|
||||
FlexOfferRevocationResponse,
|
||||
FlexOrder,
|
||||
FlexRequest,
|
||||
FlexReservationUpdate,
|
||||
FlexSettlement,
|
||||
MeteringResponse,
|
||||
TestMessage,
|
||||
TestMessageResponse,
|
||||
]
|
||||
|
||||
|
||||
@abstractmethod
|
||||
def process_d_prognosis_response(self, message: DPrognosisResponse):
|
||||
"""
|
||||
FlexOffer messages are used by AGRs to make DSOs an offer for provision
|
||||
of flexibility. A FlexOffer message contains a list of ISPs and, for
|
||||
each ISP, the change in consumption or production offered and the price
|
||||
for the total amount of flexibility offered. FlexOffer messages can be
|
||||
sent once a FlexRequest message has been received but can also be sent
|
||||
unsolicited. Note that multiple FlexOffer messages may be sent based on
|
||||
a single FlexRequest, e.g. to increase the chance that the DSO will
|
||||
order at least part of its available flexibility. The AGR must make
|
||||
sure that it can actually provide the flexibility offered across all of
|
||||
its FlexOffers.
|
||||
"""
|
||||
|
||||
|
||||
@abstractmethod
|
||||
def process_flex_request(self, message: FlexRequest):
|
||||
"""
|
||||
This method should probably end by sending some Flex Offers to the DSO::
|
||||
|
||||
with self.dso_client(message.sender_domain) as client:
|
||||
response = client.send_flex_offer(FlexOffer(...)
|
||||
# Do something with the response here.
|
||||
"""
|
||||
|
||||
|
||||
@abstractmethod
|
||||
def process_flex_offer_response(self, message: FlexOfferResponse):
|
||||
"""
|
||||
This method should probably end by sending some Flex Offers to the DSO::
|
||||
|
||||
with self.dso_client(message.sender_domain) as client:
|
||||
response = client.send_flex_offer(FlexOffer(...)
|
||||
# Do something with the response here.
|
||||
"""
|
||||
|
||||
|
||||
@abstractmethod
|
||||
def process_flex_offer_revocation_response(
|
||||
self, message: FlexOfferRevocationResponse
|
||||
):
|
||||
"""
|
||||
Upon receiving and processing a FlexOfferRevocation message, the
|
||||
receiving implementation must reply with a FlexOfferRevocationResponse,
|
||||
indicating whether the revocation was handled successfully.
|
||||
|
||||
It is advised that this method ends by sending a FlexSettlementResponse to the DSO::
|
||||
|
||||
with self.dso_client(message.sender_domain):
|
||||
response = client.send_flex_settlement_response(FlexSettlementResponse(...))
|
||||
# do something with the response here.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def process_flex_order(self, message: FlexOrder):
|
||||
"""
|
||||
FlexOrder messages are used by DSOs to purchase flexibility from an AGR
|
||||
based on a previous FlexOffer. A FlexOrder message contains a list of
|
||||
ISPs, with, for each ISP, the change in consumption or production to be
|
||||
realized by the AGR, and the accepted price to be paid by the DSO for
|
||||
this amount of flexibility. This ISP list should be copied from the
|
||||
FlexOffer message without modification: AGR implementations will
|
||||
(and must) reject FlexOrder messages where the ISP list is not exactly
|
||||
the same as offered.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def process_flex_reservation_update(self, message: FlexReservationUpdate):
|
||||
"""
|
||||
For bilateral contracts, FlexReservationUpdate messages are used by DSOs
|
||||
to signal to an AGR which part of the contracted volume is still
|
||||
reserved and which part is not needed and may be used for other
|
||||
purposes. For each ISP, a power value is given which indicates how much
|
||||
power is still reserved. Zero power means that no power is reserved for
|
||||
that ISP and the sign of the power indicates the direction.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def process_flex_settlement(self, message: FlexSettlement):
|
||||
"""
|
||||
The FlexSettlement message is sent by DSOs on a regular basis
|
||||
(typically monthly) to AGRs, in order to initiate settlement. It
|
||||
includes a list of all FlexOrders placed by the originating party
|
||||
during the settlement period.
|
||||
|
||||
It is advised that this method ends by sending a FlexSettlementResponse to the DSO::
|
||||
|
||||
with self.get_dso_client(message.sender_domain):
|
||||
response = client.send_flex_settlement_response(FlexSettlementResponse(...))
|
||||
# do something with the response here.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def process_metering_response(self, message: MeteringResponse):
|
||||
"""
|
||||
Upon receiving and processing a Metering message, the
|
||||
receiving implementation must reply with a MeteringResponse,
|
||||
indicating whether the update was handled successfully.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def process_agr_portfolio_query_response(self, message: AgrPortfolioQueryResponse):
|
||||
"""
|
||||
The AgrPortfolioQueryResponse is sent by the CRO after you sent a
|
||||
AgrPortfolioQuery. It contains the list of your connections. It is
|
||||
recommended that you do not perform any long-running operations inside
|
||||
this function, but return a PayloadMessageResponse quickly.
|
||||
Longer-running operations (like a database sync) should be done inside
|
||||
the process_agr_portfolio_query_response method.
|
||||
|
||||
If the list of connections does not match what you expected it
|
||||
to be, you can send an AgrPortfolioUpdate message at the end
|
||||
of this method::
|
||||
|
||||
with self.get_dso_client(message.sender_domain) as client:
|
||||
response = client.send_portfolio_update(AgrPortfolioUpdate(...))
|
||||
# Do something with the response here
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def process_agr_portfolio_update_response(
|
||||
self, message: AgrPortfolioUpdateResponse
|
||||
):
|
||||
"""
|
||||
The AgrPortfolioUptadeResponse is sent by the CRO after you sent a
|
||||
AgrPortfolioUpdate.
|
||||
"""
|
||||
|
||||
# ------------------------------------------------------------ #
|
||||
# Convenience methods for getting a client to the designated #
|
||||
# participant. #
|
||||
# ------------------------------------------------------------ #
|
||||
|
||||
def cro_client(self, recipient_domain, version="3.1.0") -> ShapeshifterAgrCroClient:
|
||||
"""
|
||||
Retrieve a client object for sending messages to the CRO.
|
||||
"""
|
||||
return self._get_client(recipient_domain, "CRO", version)
|
||||
|
||||
def dso_client(self, recipient_domain, version="3.1.0") -> ShapeshifterAgrDsoClient:
|
||||
"""
|
||||
Retrieve a client object for sending messages to the DSO.
|
||||
"""
|
||||
return self._get_client(recipient_domain, "DSO", version)
|
||||
@@ -0,0 +1,310 @@
|
||||
import re
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from threading import Thread
|
||||
from time import sleep
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, Response
|
||||
from fastapi.exceptions import HTTPException
|
||||
from fastapi_xml import XmlAppResponse, XmlRoute
|
||||
|
||||
from .. import transport
|
||||
from ..client import client_map
|
||||
from ..exceptions import (
|
||||
FunctionalException,
|
||||
InvalidMessageException,
|
||||
InvalidSenderException,
|
||||
TransportException,
|
||||
)
|
||||
from ..logging import logger
|
||||
from ..uftp import (
|
||||
AcceptedRejected,
|
||||
PayloadMessage,
|
||||
SignedMessage,
|
||||
TestMessage,
|
||||
TestMessageResponse,
|
||||
UsefRole,
|
||||
request_response_map,
|
||||
)
|
||||
|
||||
|
||||
class ShapeshifterService():
|
||||
"""
|
||||
Basis for all Shapeshifter Services. Defines the web service, the
|
||||
message ingestion, the post-processing queue mechanics, and
|
||||
threading and context options.
|
||||
"""
|
||||
|
||||
sender_domain = None
|
||||
sender_role = None
|
||||
acceptable_messages = []
|
||||
|
||||
num_inbound_threads = 10
|
||||
num_outbound_threads = 10
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sender_domain,
|
||||
signing_key,
|
||||
key_lookup_function=None,
|
||||
endpoint_lookup_function=None,
|
||||
oauth_lookup_function=None,
|
||||
host: str = "0.0.0.0",
|
||||
port: int = 8080,
|
||||
path: str = "/shapeshifter/api/v3/message",
|
||||
version: str = "3.1.0"
|
||||
):
|
||||
"""
|
||||
:param sender_domain: our sender domain (FQDN) that the recipient uses to look us up.
|
||||
:param signing_key: the private singing key that we use to sign outgoing messages.
|
||||
:param key_lookup_function: A callable that takes a (sender_domain, sender_role)
|
||||
pair and returns a verify_key (str or bytes).
|
||||
Omit parameter to use DNS for key lookup.
|
||||
:param key_lookup_function: A callable that takes a (sender_domain, sender_role)
|
||||
pair and returns a full endpoint URL (str).
|
||||
Omit parameter to use DNS for endpoint lookup.
|
||||
:param oauth_lookup_function: A callable that takes a (sender_domain, sender_role)
|
||||
pair and returns in instance of shapeshifter_uftp.OAuthClient
|
||||
if OAuth authentication is required.
|
||||
:param host: the host to bind the server to (usually 127.0.0.1 or 0.0.0.0)
|
||||
:param port: the port to bind the server to (default: 8080)
|
||||
:param path: the URL path that the server listens on (default: /shapeshifter/api/v3/message)
|
||||
"""
|
||||
|
||||
if version not in ("3.0.0", "3.1.0"):
|
||||
raise ValueError(f"'version' must be one of '3.0.0' or '3.1.0', not {version}")
|
||||
|
||||
self.version = version
|
||||
|
||||
# Set the sender domain, which is used
|
||||
# to identify us to the other party.
|
||||
self.sender_domain = sender_domain
|
||||
|
||||
# The signing key is used to sign outgoing messages. The
|
||||
# corresponding public key should be published via DNS or
|
||||
# given to the recipient out-of-band.
|
||||
self.signing_key = signing_key
|
||||
|
||||
# The key lookup method is used to look up keys for the other
|
||||
# party. If omitted, use DNS lookups using well-known DNS names.
|
||||
self.key_lookup_function = key_lookup_function or transport.get_key
|
||||
|
||||
# The endpoint lookup method is used to look up the endpoint
|
||||
# that we send all messages to. If omitted, use DNS lookups
|
||||
# using well-known DNS names.
|
||||
self.endpoint_lookup_function = endpoint_lookup_function or transport.get_endpoint
|
||||
|
||||
# The OAuth lookup function is used to get the OAuth instance
|
||||
# used to authenticate outgoing requests.
|
||||
self.oauth_lookup_function = oauth_lookup_function
|
||||
|
||||
# The FastAPI web app takes care of routing messages to the
|
||||
# (one) endpoint, and by virtue of FastAPI-XML convert the
|
||||
# python-friendly objects into XML and vice versa.
|
||||
self.app = FastAPI(default_response_class=XmlAppResponse)
|
||||
self.app.router.route_class = XmlRoute
|
||||
self.app.router.add_api_route(
|
||||
path,
|
||||
endpoint=self._receive_message,
|
||||
response_model=None,
|
||||
methods=["POST"],
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
# The web server hosts the FastAPI application and takes care
|
||||
# of the HTTP transport.
|
||||
config = uvicorn.Config(app=self.app, host=host, port=port)
|
||||
self.server = uvicorn.Server(config)
|
||||
self.server_thread = None
|
||||
|
||||
# Create an inbound executor worker
|
||||
self.inbound_executor = ThreadPoolExecutor(max_workers=self.num_inbound_threads)
|
||||
self.outbound_executor = ThreadPoolExecutor(max_workers=self.num_outbound_threads)
|
||||
|
||||
|
||||
def run(self):
|
||||
"""
|
||||
Start the web server that hosts the FastAPI application. Other
|
||||
participants can now send messages to us.
|
||||
"""
|
||||
# Start the service and start accepting incoming requests.
|
||||
self.server.run()
|
||||
|
||||
def run_in_thread(self):
|
||||
"""
|
||||
Run the service in a background thread.
|
||||
"""
|
||||
self.server_thread = Thread(target=self.run)
|
||||
self.server_thread.start()
|
||||
while not self.server.started:
|
||||
sleep(0.1)
|
||||
|
||||
def stop(self):
|
||||
"""
|
||||
Stop the service if it was running in a separate thread.
|
||||
"""
|
||||
self.server.should_exit = True
|
||||
if self.server_thread and self.server_thread.is_alive():
|
||||
self.server_thread.join()
|
||||
self.server_thread = None
|
||||
|
||||
|
||||
# ------------------------------------------------------------ #
|
||||
# Message handling and processing methods, internal to the #
|
||||
# Shapeshifter UFTP implementation. #
|
||||
# ------------------------------------------------------------ #
|
||||
|
||||
def _receive_message(self, message: SignedMessage) -> Response:
|
||||
"""
|
||||
The default entrypoint for the route. This will unpack the
|
||||
message and validate the signature. It will thes pass the
|
||||
PayloadMessago to the pre processing function for a
|
||||
response.
|
||||
"""
|
||||
logger.info(f"Got a request: {message}")
|
||||
# Get the public key that is used to decrypt the message
|
||||
signing_key = self.key_lookup_function(
|
||||
message.sender_domain, message.sender_role.value
|
||||
)
|
||||
|
||||
logger.debug(f"The signing key is {signing_key}")
|
||||
|
||||
# Unseal the message, returning an error if required
|
||||
try:
|
||||
unsealed_message = transport.unseal_message(message.body, signing_key)
|
||||
|
||||
# Verify that the sender_domain inside the message is the
|
||||
# same as the sender_domain of the SignedMessage
|
||||
# envelope
|
||||
if unsealed_message.sender_domain != message.sender_domain:
|
||||
logger.warning(
|
||||
"Received a message with mismatching sender_domain in the "
|
||||
f"SignedMessage envelope ({message.sender_domain}) and the "
|
||||
f"inner PayloadMessage ({unsealed_message.sender_domain})"
|
||||
)
|
||||
raise InvalidSenderException()
|
||||
|
||||
if type(unsealed_message) not in self.acceptable_messages:
|
||||
logger.warning(
|
||||
f"Received a misdirected message of type {unsealed_message.__class__.__name__} "
|
||||
f"from {unsealed_message.sender_domain}.")
|
||||
raise InvalidMessageException(unsealed_message)
|
||||
|
||||
except TransportException as err:
|
||||
logger.warning(f"The original transport error is {err.__class__.__name__}: {err}")
|
||||
raise HTTPException(err.http_status_code) from err
|
||||
|
||||
except FunctionalException as err:
|
||||
self.outbound_executor.submit(self._reject_message, message, unsealed_message, err.rejection_reason)
|
||||
|
||||
else:
|
||||
# If the initial checks passed, process the message in the
|
||||
# user-defined pipeline.
|
||||
self.inbound_executor.submit(self._process_message, unsealed_message, message.sender_role)
|
||||
|
||||
return Response(status_code=200)
|
||||
|
||||
def _process_message(self, message: PayloadMessage, sender_role: UsefRole):
|
||||
"""
|
||||
Find the relevant post-processing method to handle the message
|
||||
outside of the request context, and run it.
|
||||
"""
|
||||
process_method_name = f"process_{snake_case(message.__class__.__name__)}"
|
||||
process_method = getattr(self, process_method_name)
|
||||
try:
|
||||
if isinstance(message, TestMessage):
|
||||
process_method(message, sender_role)
|
||||
else:
|
||||
process_method(message)
|
||||
except Exception as err: # pylint: disable=broad-exception-caught
|
||||
logger.error(
|
||||
f"An error occurred during the post-processing of a {message.__class__.__name__} message."
|
||||
f"{err.__class__.__name__}: {err}"
|
||||
)
|
||||
|
||||
def _get_client(self, recipient_domain: str, recipient_role: UsefRole, version: str = "3.1.0"):
|
||||
"""
|
||||
Method to get a relevant client to communicate to the
|
||||
indicated participant.
|
||||
"""
|
||||
client_cls = client_map[(self.sender_role, recipient_role)]
|
||||
recipient_endpoint = self.endpoint_lookup_function(recipient_domain, recipient_role)
|
||||
recipient_signing_key = self.key_lookup_function(recipient_domain, recipient_role)
|
||||
oauth_client = self.oauth_lookup_function(recipient_domain, recipient_role) if self.oauth_lookup_function else None
|
||||
return client_cls(
|
||||
sender_domain = self.sender_domain,
|
||||
signing_key = self.signing_key,
|
||||
recipient_domain = recipient_domain,
|
||||
recipient_endpoint = recipient_endpoint,
|
||||
recipient_signing_key = recipient_signing_key,
|
||||
oauth_client = oauth_client,
|
||||
version=version,
|
||||
)
|
||||
|
||||
def _reject_message(self, message, unsealed_message, reason):
|
||||
"""
|
||||
Send a rejection to the sending party.
|
||||
"""
|
||||
if type(unsealed_message) not in request_response_map:
|
||||
return
|
||||
|
||||
client = self._get_client(message.sender_domain, message.sender_role, unsealed_message.version)
|
||||
response_type = request_response_map[type(unsealed_message)]
|
||||
response_id_field = snake_case(type(unsealed_message).__name__) + "_message_id"
|
||||
message_contents = {
|
||||
"recipient_domain": message.sender_domain,
|
||||
"conversation_id": unsealed_message.conversation_id,
|
||||
"result": AcceptedRejected.REJECTED,
|
||||
"rejection_reason": reason,
|
||||
response_id_field: unsealed_message.message_id
|
||||
}
|
||||
response_message = response_type(**message_contents)
|
||||
client._send_message(response_message)
|
||||
|
||||
def __enter__(self):
|
||||
"""
|
||||
Context-manager method that allows an instance of this class to be
|
||||
used in a temporary context.
|
||||
|
||||
Starts the uvicorn server in a separate thread and waits for it
|
||||
to be started. Useful in testing scenarios.
|
||||
|
||||
Usage:
|
||||
|
||||
with MyShapeshifterServiceSubClass(...) as server:
|
||||
# your test code here, server exists cleanly when leaving
|
||||
# the 'with' block.
|
||||
"""
|
||||
self.run_in_thread()
|
||||
return self
|
||||
|
||||
def __exit__(self, *args, **kwargs):
|
||||
"""
|
||||
Tell uvicorn we should exit and wait for the thread to finish.
|
||||
"""
|
||||
self.stop()
|
||||
|
||||
# ------------------------------------------------------------ #
|
||||
# Common messages to all parties #
|
||||
# ------------------------------------------------------------ #
|
||||
|
||||
def process_test_message(self, message: TestMessage, sender_role: UsefRole):
|
||||
logger.info(
|
||||
"Received a TestMessage, will respond with TestMessageResponse. "
|
||||
f"Implement the process_test_message method on your {self.__class__.__qualname__} "
|
||||
f"to implement custom behavior. The message was: {message}"
|
||||
)
|
||||
response = TestMessageResponse(conversation_id=message.conversation_id)
|
||||
client = self._get_client(message.sender_domain, sender_role, version=message.version or self.version)
|
||||
client.send_test_message_response(response)
|
||||
|
||||
def process_test_message_response(self, message: TestMessage):
|
||||
logger.info(f"Received a TestMessageResponse: {message}")
|
||||
|
||||
|
||||
|
||||
def snake_case(text):
|
||||
"""
|
||||
Convert text from CamelCase to snake_case.
|
||||
"""
|
||||
return re.sub(r"(.)([A-Z][a-z])", r"\1_\2", text).lower()
|
||||
@@ -0,0 +1,86 @@
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from ..client import ShapeshifterCroAgrClient, ShapeshifterCroDsoClient
|
||||
from ..uftp import (
|
||||
AgrPortfolioQuery,
|
||||
AgrPortfolioUpdate,
|
||||
DsoPortfolioQuery,
|
||||
DsoPortfolioUpdate,
|
||||
TestMessage,
|
||||
TestMessageResponse,
|
||||
UsefRole,
|
||||
)
|
||||
from .base_service import ShapeshifterService
|
||||
|
||||
|
||||
class ShapeshifterCroService(ShapeshifterService, ABC):
|
||||
"""
|
||||
Service that represent the Common Reference Operator in the UFTP communication.
|
||||
|
||||
It can receive requests from the Aggregator and from the DSO.
|
||||
"""
|
||||
|
||||
sender_role = UsefRole.CRO
|
||||
acceptable_messages = [
|
||||
DsoPortfolioQuery,
|
||||
DsoPortfolioUpdate,
|
||||
AgrPortfolioQuery,
|
||||
AgrPortfolioUpdate,
|
||||
TestMessage,
|
||||
TestMessageResponse,
|
||||
]
|
||||
|
||||
# ------------------------------------------------------------ #
|
||||
# Methods related to Agr Portfolio Query messages #
|
||||
# ------------------------------------------------------------ #
|
||||
|
||||
@abstractmethod
|
||||
def process_agr_portfolio_query(self, message: AgrPortfolioQuery):
|
||||
"""
|
||||
The AGRPortfolioQuery is used by the AGR to retrieve
|
||||
additional information on the connections.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def process_agr_portfolio_update(self, message: AgrPortfolioUpdate):
|
||||
"""
|
||||
The AGRPortfolioUpdate is used by the AGR to indicate on which
|
||||
Connections it represents prosumers.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def process_dso_portfolio_query(self, message: DsoPortfolioQuery):
|
||||
"""
|
||||
DSOPortfolioQuery is used by DSOs to discover which AGRs represent
|
||||
connections on its registered congestion point(s).
|
||||
|
||||
You should end this method by sending a
|
||||
DsoPortfolioQueryResponse back to the DSO::
|
||||
|
||||
with self.get_dso_client(message.sender_domain) as client:
|
||||
client.send_portfolio_query_response
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def process_dso_portfolio_update(self, message: DsoPortfolioUpdate):
|
||||
"""
|
||||
The DSOPortfolioUpdate is used by the DSO to indicate on which
|
||||
congestion points it wants to engage in flexibility trading.
|
||||
"""
|
||||
|
||||
# ------------------------------------------------------------ #
|
||||
# Convenience methods for getting a client to the designated #
|
||||
# participant. #
|
||||
# ------------------------------------------------------------ #
|
||||
|
||||
def agr_client(self, recipient_domain, version="3.1.0") -> ShapeshifterCroAgrClient:
|
||||
"""
|
||||
Retrieve a client object for sending messages to the AGR.
|
||||
"""
|
||||
return self._get_client(recipient_domain, "AGR", version)
|
||||
|
||||
def dso_client(self, recipient_domain, version="3.1.0") -> ShapeshifterCroDsoClient:
|
||||
"""
|
||||
Retrieve a client object for sending messages to the DSO.
|
||||
"""
|
||||
return self._get_client(recipient_domain, "DSO", version)
|
||||
@@ -0,0 +1,173 @@
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from ..client import ShapeshifterDsoAgrClient, ShapeshifterDsoCroClient
|
||||
from ..uftp import (
|
||||
DPrognosis,
|
||||
DsoPortfolioQueryResponse,
|
||||
DsoPortfolioUpdateResponse,
|
||||
FlexOffer,
|
||||
FlexOfferRevocation,
|
||||
FlexOrderResponse,
|
||||
FlexRequestResponse,
|
||||
FlexReservationUpdateResponse,
|
||||
FlexSettlementResponse,
|
||||
Metering,
|
||||
TestMessage,
|
||||
TestMessageResponse,
|
||||
UsefRole,
|
||||
)
|
||||
from .base_service import ShapeshifterService
|
||||
|
||||
|
||||
# pylint: disable=too-many-public-methods
|
||||
class ShapeshifterDsoService(ShapeshifterService, ABC):
|
||||
"""
|
||||
Service that represents the Distribution System Operator in the UFTP communication.
|
||||
|
||||
It can receive requests from the Aggregator.
|
||||
|
||||
You should subclass this class and implement your own message handling methods.
|
||||
"""
|
||||
|
||||
sender_role = UsefRole.DSO
|
||||
acceptable_messages = [
|
||||
DPrognosis,
|
||||
DsoPortfolioQueryResponse,
|
||||
DsoPortfolioUpdateResponse,
|
||||
FlexOffer,
|
||||
FlexOfferRevocation,
|
||||
FlexOrderResponse,
|
||||
FlexRequestResponse,
|
||||
FlexReservationUpdateResponse,
|
||||
FlexSettlementResponse,
|
||||
Metering,
|
||||
TestMessage,
|
||||
TestMessageResponse,
|
||||
]
|
||||
|
||||
@abstractmethod
|
||||
def process_d_prognosis(self, message: DPrognosis):
|
||||
"""
|
||||
D-Prognosis messages are used to communicate D-prognoses between AGRs
|
||||
and DSOs. D-Prognosis messages always contain data for all ISPs for the
|
||||
period they apply to, even if a prognosis is sent after the start of
|
||||
the period, when one or more ISPs are already in the operate or
|
||||
settlement phase. Receiving implementations should ignore the
|
||||
information supplied for those ISPs.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def process_flex_request_response(self, message: FlexRequestResponse):
|
||||
"""
|
||||
FlexOffer messages are used by AGRs to make DSOs an offer for provision
|
||||
of flexibility. A FlexOffer message contains a list of ISPs and, for
|
||||
each ISP, the change in consumption or production offered and the price
|
||||
for the total amount of flexibility offered. FlexOffer messages can be
|
||||
sent once a FlexRequest message has been received but can also be sent
|
||||
unsolicited. Note that multiple FlexOffer messages may be sent based on
|
||||
a single FlexRequest, e.g. to increase the chance that the DSO will
|
||||
order at least part of its available flexibility. The AGR must make
|
||||
sure that it can actually provide the flexibility offered across all of
|
||||
its FlexOffers.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def process_flex_offer(self, message: FlexOffer):
|
||||
"""
|
||||
FlexOffer messages are used by AGRs to make DSOs an offer for provision
|
||||
of flexibility. A FlexOffer message contains a list of ISPs and, for
|
||||
each ISP, the change in consumption or production offered and the price
|
||||
for the total amount of flexibility offered. FlexOffer messages can be
|
||||
sent once a FlexRequest message has been received but can also be sent
|
||||
unsolicited. Note that multiple FlexOffer messages may be sent based on
|
||||
a single FlexRequest, e.g. to increase the chance that the DSO will
|
||||
order at least part of its available flexibility. The AGR must make
|
||||
sure that it can actually provide the flexibility offered across all of
|
||||
its FlexOffers.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def process_flex_order_response(self, message: FlexOrderResponse):
|
||||
"""
|
||||
Upon receiving and processing a FlexOrder message, the receiving
|
||||
implementation must reply with a FlexOrderResponse, indicating whether
|
||||
the update was handled successfully.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def process_flex_offer_revocation(self, message: FlexOfferRevocation):
|
||||
"""
|
||||
The FlexOfferRevocation message is used by the AGR to revoke a FlexOffer
|
||||
previously sent to a DSO. It voids the FlexOffer, even if its validity
|
||||
time has not yet expired. Revocation is not allowed for FlexOffers that
|
||||
already have associated accepted FlexOrders.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def process_flex_reservation_update_response(
|
||||
self, message: FlexReservationUpdateResponse
|
||||
):
|
||||
"""
|
||||
The FlexOfferRevocation message is used by the AGR to revoke a FlexOffer
|
||||
previously sent to a DSO. It voids the FlexOffer, even if its validity
|
||||
time has not yet expired. Revocation is not allowed for FlexOffers that
|
||||
already have associated accepted FlexOrders.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def process_flex_settlement_response(self, message: FlexSettlementResponse):
|
||||
"""
|
||||
Upon receiving and processing a FlexSettlement message, the AGR must
|
||||
reply with a FlexSettlementResponse, indicating whether the initial
|
||||
message was handled successfully. When a FlexSettlement message is
|
||||
rejected, the DSO should consider all FlexOrderSettlement elements of
|
||||
that message related to potential dispute.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def process_dso_portfolio_query_response(self, message: DsoPortfolioQueryResponse):
|
||||
"""
|
||||
Upon receiving and processing a DSOPortfolioQuery message, the receiving
|
||||
implementation must reply with a DSOPortfolioQueryResponse, indicating
|
||||
whether the query executed successfully, and if it did, including the
|
||||
query results. Most queries will return zero or more congestion points
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def process_dso_portfolio_update_response(
|
||||
self, message: DsoPortfolioUpdateResponse
|
||||
):
|
||||
"""
|
||||
Upon receiving and processing a DSOPortfolioUpdate message, the
|
||||
receiving implementation must reply with a DSOPortfolioUpdateResponse,
|
||||
indicating whether the update was handled successfully.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def process_metering(self, message: Metering):
|
||||
"""
|
||||
The Metering message is an optional message. The DSO will specify
|
||||
whether metering messages are required for a given program. If metering
|
||||
messages are used then the AGR must send metering messages, with one
|
||||
message sent per connection point per day. The metering messages must
|
||||
all be sent before the settlement can be performed. It is recommend to
|
||||
send the metering messages daily, once the metering data has been
|
||||
collected for the day.
|
||||
"""
|
||||
|
||||
# ------------------------------------------------------------ #
|
||||
# Convenience methods for getting a client to the designated #
|
||||
# participant. #
|
||||
# ------------------------------------------------------------ #
|
||||
|
||||
def agr_client(self, recipient_domain, version="3.1.0") -> ShapeshifterDsoAgrClient:
|
||||
"""
|
||||
Retrieve a client object for sending messages to the AGR.
|
||||
"""
|
||||
return self._get_client(recipient_domain, "AGR", version)
|
||||
|
||||
def cro_client(self, recipient_domain, version="3.1.0") -> ShapeshifterDsoCroClient:
|
||||
"""
|
||||
Retrieve a client object for sending messages to the CRO.
|
||||
"""
|
||||
return self._get_client(recipient_domain, "CRO", version)
|
||||
@@ -0,0 +1,258 @@
|
||||
"""
|
||||
Defines the message transport, including message signatures.
|
||||
"""
|
||||
import re
|
||||
from base64 import b64decode, b64encode
|
||||
from binascii import Error as BinAsciiError
|
||||
from datetime import datetime
|
||||
|
||||
import dns.resolver
|
||||
from nacl.bindings import crypto_sign, crypto_sign_open
|
||||
from nacl.exceptions import BadSignatureError
|
||||
from xsdata.exceptions import ParserError
|
||||
from xsdata.formats.dataclass.context import XmlContext
|
||||
from xsdata.formats.dataclass.parsers import JsonParser, XmlParser
|
||||
from xsdata.formats.dataclass.serializers import JsonSerializer, XmlSerializer
|
||||
from xsdata.formats.dataclass.serializers.config import SerializerConfig
|
||||
|
||||
from .exceptions import (
|
||||
AuthenticationTimeoutException,
|
||||
InvalidSignatureException,
|
||||
SchemaException,
|
||||
ServiceDiscoveryException,
|
||||
)
|
||||
from .logging import logger
|
||||
from .uftp import PayloadMessage, SignedMessage
|
||||
|
||||
_context = XmlContext()
|
||||
serializer = XmlSerializer(context=_context, config=SerializerConfig(indent=" "))
|
||||
parser = XmlParser(context=_context)
|
||||
|
||||
json_serializer = JsonSerializer()
|
||||
json_parser = JsonParser()
|
||||
|
||||
def seal_message(message: PayloadMessage, private_key: str) -> bytes:
|
||||
"""
|
||||
Sign a message using the provided private key. The message should
|
||||
be of type PayloadMessage (or any subtype thereof). The private
|
||||
key should be given in base64-encoded form.
|
||||
|
||||
The message will be returned as an opaque blob op base64 bytes.
|
||||
(In reality, this is the 64-byte signature prepended to the
|
||||
original XML message.)
|
||||
"""
|
||||
if not isinstance(message, PayloadMessage):
|
||||
raise TypeError(f"'message', must be of type PayloadMessage, got: {type(message)}")
|
||||
|
||||
serialized_message = to_xml(message)
|
||||
logger.debug(f"Signing outgoing message {serialized_message}")
|
||||
sealed_message = crypto_sign(serialized_message.encode("utf-8"), b64decode(private_key))
|
||||
return sealed_message
|
||||
|
||||
|
||||
def unseal_message(message: bytes, public_key: str) -> PayloadMessage:
|
||||
"""
|
||||
Validate a message's signature using the provided public key.
|
||||
The message can be given as a string or as bytes. The public
|
||||
key should be given in base64-encoded form.
|
||||
|
||||
The message will be returned as a PayloadMessage object.
|
||||
"""
|
||||
if public_key is None:
|
||||
logger.warning(
|
||||
"When calling unseal_message, no public key was provided. "
|
||||
"Please check that your key_lookup function returns a key."
|
||||
)
|
||||
raise TypeError("'public_key' must be of type 'str', not None")
|
||||
try:
|
||||
unsealed_message = crypto_sign_open(message, b64decode(public_key))
|
||||
logger.debug(f"Incoming Message: {unsealed_message.decode('utf-8')}")
|
||||
return from_xml(unsealed_message)
|
||||
except BadSignatureError as exc:
|
||||
logger.warning(f"The XML Signature for message {message} does not match the public key {public_key}: {exc}.")
|
||||
raise InvalidSignatureException() from exc
|
||||
except (ParserError, TypeError, ValueError) as exc:
|
||||
logger.warning(f"The incoming XML Message {message} does not conform to the XML schema: {exc}.")
|
||||
raise SchemaException(str(exc)) from exc
|
||||
|
||||
|
||||
def to_xml(message: PayloadMessage | SignedMessage) -> str:
|
||||
"""
|
||||
Serialize the given PayloadMessage into an XML string.
|
||||
"""
|
||||
return serializer.render(message)
|
||||
|
||||
|
||||
def from_xml(message: str | bytes):
|
||||
"""
|
||||
Parse the given message string into a Shapeshifter UFTP object.
|
||||
"""
|
||||
if isinstance(message, str):
|
||||
return parser.from_string(message)
|
||||
if isinstance(message, bytes):
|
||||
return parser.from_bytes(message)
|
||||
raise TypeError(f"Message should be either bytes or str, not {type(message)}")
|
||||
|
||||
|
||||
def to_json(message: PayloadMessage):
|
||||
"""
|
||||
Serializes the given PayloadMessage to json. Useful when
|
||||
transferring the message outside of shapeshifter-uftp.
|
||||
"""
|
||||
return json_serializer.render(message)
|
||||
|
||||
|
||||
def from_json(message: str, message_type: type):
|
||||
"""
|
||||
Parse the given json string into a message of the given type.
|
||||
"""
|
||||
return json_parser.from_string(message, message_type)
|
||||
|
||||
|
||||
def ttl_cache(ttl):
|
||||
"""
|
||||
Caching decorator that will cache the result of an operation for 'ttl' seconds.
|
||||
"""
|
||||
|
||||
def decorator(func):
|
||||
cached_values = {}
|
||||
|
||||
def wrapper(*args, **kwargs):
|
||||
# Create the cache key from the args and kwargs.
|
||||
cache_key = args
|
||||
if kwargs:
|
||||
cache_key += tuple((kwargs.items()))
|
||||
|
||||
# Look up the cache key in the cache
|
||||
if cache_key in cached_values:
|
||||
expiration, data = cached_values[cache_key]
|
||||
if expiration > datetime.now().timestamp():
|
||||
return data
|
||||
|
||||
# If the key was expired, delete it from the cache.
|
||||
del cached_values[cache_key]
|
||||
|
||||
# If not in cache or cache expired, call the original function and return the result
|
||||
data = func(*args, **kwargs)
|
||||
cached_values[cache_key] = (datetime.now().timestamp() + ttl, data)
|
||||
return data
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
@ttl_cache(3600)
|
||||
def get_keys(domain, role):
|
||||
"""
|
||||
Retrieve the sender's public key using a DNS request. These are published at
|
||||
the well-known DNS name _usef._role._domain, in the format 'cs1.' +
|
||||
base64-encoded value of ([public signing key] + [public decryption key]).
|
||||
"""
|
||||
|
||||
# Perform the DNS lookup at the well-known DNS name
|
||||
try:
|
||||
dns_name = f"_{role}._usef.{domain}"
|
||||
result = dns.resolver.resolve(dns_name, "TXT").response.answer[0][0].strings[0]
|
||||
except dns.resolver.NXDOMAIN as exc:
|
||||
# Indicates that the domain does not even exist
|
||||
raise AuthenticationTimeoutException(
|
||||
f"Could not retrieve public keys at {dns_name}: DNS name not found."
|
||||
) from exc
|
||||
except dns.resolver.NoNameservers as exc:
|
||||
raise ServiceDiscoveryException(
|
||||
f"Could not retrieve public key at {dns_name} because no DNS server was available (SERVFAIL). "
|
||||
"Make sure your network setup is working properly. This is not a problem with the receiving participant."
|
||||
) from exc
|
||||
|
||||
|
||||
# Now verify that the string begins with `cs1.`
|
||||
if not result.startswith(b"cs1."):
|
||||
raise AuthenticationTimeoutException(
|
||||
f"Could not retrieve public keys at {dns_name}: "
|
||||
f"invalid string (must start with 'cs1.', was: {result.decode()})"
|
||||
)
|
||||
|
||||
# Verify that the string is of the expected length (4 + 44 bytes or 4 + 88 bytes)
|
||||
if len(result) not in (48, 92):
|
||||
raise AuthenticationTimeoutException(
|
||||
f"Could not retrieve public key(s) at {dns_name}: "
|
||||
f"string '{result}' was not of appropriate length (48 or 90 characters)"
|
||||
)
|
||||
|
||||
# Now try to decode the string using base64
|
||||
try:
|
||||
combined_keys = b64decode(result[4:])
|
||||
except BinAsciiError as exc:
|
||||
raise AuthenticationTimeoutException(
|
||||
f"Could not retrieve public keys at {dns_name}: "
|
||||
f"string '{result[4:].decode()}' is not valid base64."
|
||||
) from exc
|
||||
|
||||
# Now verify that the decoded length is 64
|
||||
if len(combined_keys) not in (32, 64):
|
||||
raise AuthenticationTimeoutException(
|
||||
f"Could not retrieve public keys at {dns_name}: "
|
||||
f"decoded base64 data should be 32 or 64 bytes long, "
|
||||
f"length is: {len(combined_keys)}."
|
||||
)
|
||||
|
||||
# Now split the two bytestrings; the first will be the verify key,
|
||||
# the second will be the encryption key.
|
||||
if len(combined_keys) == 32:
|
||||
return b64encode(combined_keys).decode(), None
|
||||
|
||||
return b64encode(combined_keys[:32]).decode(), b64encode(combined_keys[32:]).decode()
|
||||
|
||||
|
||||
def get_key(domain, role):
|
||||
"""
|
||||
Return only the verification key from what might be two keys.
|
||||
"""
|
||||
return get_keys(domain, role)[0]
|
||||
|
||||
@ttl_cache(3600)
|
||||
def get_endpoint(domain, role):
|
||||
"""
|
||||
Retrieve the recipient's endpoint using DNS. These are published at the
|
||||
well-know DNS name _usef._role._domain
|
||||
"""
|
||||
dns_name = f"_http._{role}._usef.{domain}"
|
||||
try:
|
||||
result = (
|
||||
dns.resolver.resolve(dns_name, "CNAME")
|
||||
.response.answer[0][0]
|
||||
.to_text()
|
||||
)
|
||||
except dns.resolver.NXDOMAIN as exc:
|
||||
raise ServiceDiscoveryException(
|
||||
f"Could not retrieve endpoint at {dns_name}: DNS name not found."
|
||||
) from exc
|
||||
|
||||
# To complete the URL, get the endpoint version
|
||||
version = get_version(domain)
|
||||
major_version = version.split(".")[0]
|
||||
|
||||
# Construct the well-known URL using the retrieved endpoint domain and version
|
||||
endpoint_url = f"https://{result.removesuffix('.')}/shapeshifter/api/v{major_version}/message"
|
||||
return endpoint_url
|
||||
|
||||
|
||||
@ttl_cache(3600)
|
||||
def get_version(domain):
|
||||
"""
|
||||
Retrieve the supported Shapeshifter versions by the recipient.
|
||||
These are published at the well-known DNS name _usef._domain.
|
||||
"""
|
||||
dns_name = f"_usef.{domain}"
|
||||
try:
|
||||
result = dns.resolver.resolve(dns_name, "TXT").response.answer[0][0].strings[0].decode().strip()
|
||||
if not re.match(r"[0-9]+\.[0-9]+\.[0-9]+", result):
|
||||
raise ServiceDiscoveryException(
|
||||
f"The retrieved version was not in the format X.Y.Z: {result}"
|
||||
)
|
||||
return result
|
||||
except dns.resolver.NXDOMAIN as exc:
|
||||
raise ServiceDiscoveryException(
|
||||
f"Could not retrieve version at {dns_name}: DNS name not found."
|
||||
) from exc
|
||||
@@ -0,0 +1,115 @@
|
||||
from .enums import *
|
||||
from .messages import *
|
||||
|
||||
ACCEPTED = AcceptedRejected.ACCEPTED
|
||||
REJECTED = AcceptedRejected.REJECTED
|
||||
|
||||
__all__ = [
|
||||
"AcceptedRejected",
|
||||
"AcceptedDisputed",
|
||||
"AvailableRequested",
|
||||
"AgrPortfolioQuery",
|
||||
"AgrPortfolioQueryResponse",
|
||||
"AgrPortfolioQueryResponseCongestionPoint",
|
||||
"AgrPortfolioQueryResponseConnection",
|
||||
"AgrPortfolioQueryResponseDSOPortfolio",
|
||||
"AgrPortfolioQueryResponseDSOView",
|
||||
"AgrPortfolioUpdate",
|
||||
"AgrPortfolioUpdateConnection",
|
||||
"AgrPortfolioUpdateResponse",
|
||||
"ContractSettlement",
|
||||
"ContractSettlementISP",
|
||||
"ContractSettlementPeriod",
|
||||
"DPrognosis",
|
||||
"DPrognosisISP",
|
||||
"DPrognosisResponse",
|
||||
"DsoPortfolioQuery",
|
||||
"DsoPortfolioQueryCongestionPoint",
|
||||
"DsoPortfolioQueryConnection",
|
||||
"DsoPortfolioQueryResponse",
|
||||
"DsoPortfolioUpdate",
|
||||
"DsoPortfolioUpdateCongestionPoint",
|
||||
"DsoPortfolioUpdateConnection",
|
||||
"DsoPortfolioUpdateResponse",
|
||||
"FlexMessage",
|
||||
"FlexOffer",
|
||||
"FlexOfferOption",
|
||||
"FlexOfferOptionISP",
|
||||
"FlexOfferResponse",
|
||||
"FlexOfferRevocation",
|
||||
"FlexOfferRevocationResponse",
|
||||
"FlexOrder",
|
||||
"FlexOrderISP",
|
||||
"FlexOrderResponse",
|
||||
"FlexOrderSettlement",
|
||||
"FlexOrderSettlementISP",
|
||||
"FlexOrderSettlementStatus",
|
||||
"FlexOrderStatus",
|
||||
"FlexRequest",
|
||||
"FlexRequestISP",
|
||||
"FlexRequestResponse",
|
||||
"FlexReservationUpdate",
|
||||
"FlexReservationUpdateISP",
|
||||
"FlexReservationUpdateResponse",
|
||||
"FlexSettlement",
|
||||
"FlexSettlementResponse",
|
||||
"Metering",
|
||||
"MeteringISP",
|
||||
"MeteringProfile",
|
||||
"MeteringProfileEnum",
|
||||
"MeteringResponse",
|
||||
"MeteringUnit",
|
||||
"PayloadMessage",
|
||||
"PayloadMessageResponse",
|
||||
"SignedMessage",
|
||||
"TestMessage",
|
||||
"TestMessageResponse",
|
||||
"UsefRole",
|
||||
"RedispatchBy",
|
||||
]
|
||||
|
||||
routing_map = {
|
||||
AgrPortfolioQuery: ("AGR", "CRO"),
|
||||
AgrPortfolioQueryResponse: ("CRO", "AGR"),
|
||||
AgrPortfolioUpdate: ("AGR", "CRO"),
|
||||
AgrPortfolioUpdateResponse: ("CRO", "AGR"),
|
||||
DPrognosis: ("AGR", "DSO"),
|
||||
DPrognosisResponse: ("DSO", "AGR"),
|
||||
DsoPortfolioQuery: ("DSO", "CRO"),
|
||||
DsoPortfolioQueryResponse: ("CRO", "DSO"),
|
||||
DsoPortfolioUpdate: ("DSO", "CRO"),
|
||||
DsoPortfolioUpdateResponse: ("CRO", "DSO"),
|
||||
FlexOffer: ("AGR", "DSO"),
|
||||
FlexOfferResponse: ("DSO", "AGR"),
|
||||
FlexOfferRevocation: ("AGR", "DSO"),
|
||||
FlexOfferRevocationResponse: ("DSO", "AGR"),
|
||||
FlexOrder: ("DSO", "AGR"),
|
||||
FlexOrderResponse: ("AGR", "DSO"),
|
||||
FlexRequest: ("DSO", "AGR"),
|
||||
FlexRequestResponse: ("AGR", "DSO"),
|
||||
FlexReservationUpdate: ("DSO", "AGR"),
|
||||
FlexReservationUpdateResponse: ("AGR", "DSO"),
|
||||
FlexSettlement: ("DSO", "AGR"),
|
||||
FlexSettlementResponse: ("AGR", "DSO"),
|
||||
Metering: ("AGR", "DSO"),
|
||||
MeteringResponse: ("DSO", "AGR"),
|
||||
}
|
||||
|
||||
request_response_map = {
|
||||
AgrPortfolioQuery: AgrPortfolioQueryResponse,
|
||||
AgrPortfolioUpdate: AgrPortfolioUpdateResponse,
|
||||
DPrognosis: DPrognosisResponse,
|
||||
DsoPortfolioQuery: DsoPortfolioQueryResponse,
|
||||
DsoPortfolioUpdate: DsoPortfolioUpdateResponse,
|
||||
FlexOffer: FlexOfferResponse,
|
||||
FlexOfferRevocation: FlexOfferRevocationResponse,
|
||||
FlexOrder: FlexOrderResponse,
|
||||
FlexRequest: FlexRequestResponse,
|
||||
FlexReservationUpdate: FlexReservationUpdateResponse,
|
||||
FlexSettlement: FlexSettlementResponse,
|
||||
Metering: MeteringResponse,
|
||||
TestMessage: TestMessageResponse,
|
||||
}
|
||||
|
||||
origin_map = {key: origin for key, (origin, destination) in routing_map.items()}
|
||||
destination_map = {key: destination for key, (origin, destination) in routing_map.items()}
|
||||
@@ -0,0 +1 @@
|
||||
DEFAULT_TIME_ZONE = "Europe/Amsterdam"
|
||||
@@ -0,0 +1,26 @@
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class AcceptedDisputed(StrEnum):
|
||||
ACCEPTED = "Accepted"
|
||||
DISPUTED = "Disputed"
|
||||
|
||||
|
||||
class AcceptedRejected(StrEnum):
|
||||
ACCEPTED = "Accepted"
|
||||
REJECTED = "Rejected"
|
||||
|
||||
|
||||
class AvailableRequested(StrEnum):
|
||||
AVAILABLE = "Available"
|
||||
REQUESTED = "Requested"
|
||||
|
||||
|
||||
class RedispatchBy(StrEnum):
|
||||
AGR = "AGR"
|
||||
DSO = "DSO"
|
||||
|
||||
class UsefRole(StrEnum):
|
||||
AGR = "AGR"
|
||||
CRO = "CRO"
|
||||
DSO = "DSO"
|
||||
@@ -0,0 +1,16 @@
|
||||
from .agr_portfolio_query import *
|
||||
from .agr_portfolio_update import *
|
||||
from .d_prognosis import *
|
||||
from .dso_portfolio_query import *
|
||||
from .dso_portfolio_update import *
|
||||
from .flex_message import *
|
||||
from .flex_offer import *
|
||||
from .flex_offer_revocation import *
|
||||
from .flex_order import *
|
||||
from .flex_request import *
|
||||
from .flex_reservation_update import *
|
||||
from .flex_settlement import *
|
||||
from .metering import *
|
||||
from .payload_message import *
|
||||
from .signed_message import *
|
||||
from .test_message import *
|
||||
@@ -0,0 +1,231 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional
|
||||
|
||||
from xsdata.models.datatype import XmlDate
|
||||
|
||||
from ..defaults import DEFAULT_TIME_ZONE
|
||||
from ..enums import RedispatchBy
|
||||
from ..validations import validate_list
|
||||
from .payload_message import PayloadMessage, PayloadMessageResponse
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class AgrPortfolioQueryResponseConnection:
|
||||
"""
|
||||
:ivar entity_address: EntityAddress of the Connection.
|
||||
"""
|
||||
class Meta:
|
||||
name = "AGRPortfolioQueryResponseConnection"
|
||||
|
||||
entity_address: str = field(
|
||||
metadata={
|
||||
"name": "EntityAddress",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"(ea1\.[0-9]{4}-[0-9]{2}\..{1,244}:.{1,244}|ean\.[0-9]{12,34})",
|
||||
}
|
||||
)
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class AgrPortfolioQueryResponseCongestionPoint:
|
||||
"""
|
||||
:ivar connection:
|
||||
:ivar entity_address: EntityAddress of the CongestionPoint.
|
||||
:ivar mutex_offers_supported: Indicates whether the DSO accepts
|
||||
mutual exclusive FlexOffers on this CongestionPoint.
|
||||
:ivar day_ahead_redispatch_by: Indicates which party is responsible
|
||||
for day-ahead redispatch.
|
||||
:ivar intraday_redispatch_by: Indicates which party is responsible
|
||||
for intraday ahead redispatch, AGR or DSO. If not specified,
|
||||
there will be no intraday trading on this CongestionPoint.
|
||||
"""
|
||||
class Meta:
|
||||
name = "AGRPortfolioQueryResponseCongestionPoint"
|
||||
|
||||
connections: List[AgrPortfolioQueryResponseConnection] = field(
|
||||
default_factory=list,
|
||||
metadata={
|
||||
"name": "Connection",
|
||||
"type": "Element",
|
||||
"min_occurs": 1,
|
||||
}
|
||||
)
|
||||
entity_address: str = field(
|
||||
metadata={
|
||||
"name": "EntityAddress",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"(ea1\.[0-9]{4}-[0-9]{2}\..{1,244}:.{1,244}|ean\.[0-9]{12,34})",
|
||||
}
|
||||
)
|
||||
mutex_offers_supported: bool = field(
|
||||
metadata={
|
||||
"name": "MutexOffersSupported",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
day_ahead_redispatch_by: RedispatchBy = field(
|
||||
metadata={
|
||||
"name": "DayAheadRedispatchBy",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
intraday_redispatch_by: Optional[RedispatchBy] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "IntradayRedispatchBy",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
validate_list('connections', self.connections, AgrPortfolioQueryResponseConnection, 1)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class AgrPortfolioQueryResponseDSOPortfolio:
|
||||
class Meta:
|
||||
name = "AGRPortfolioQueryResponseDSOPortfolio"
|
||||
|
||||
congestion_points: List[AgrPortfolioQueryResponseCongestionPoint] = field(
|
||||
default_factory=list,
|
||||
metadata={
|
||||
"name": "CongestionPoint",
|
||||
"type": "Element",
|
||||
"min_occurs": 1,
|
||||
}
|
||||
)
|
||||
dso_domain: str = field(
|
||||
metadata={
|
||||
"name": "DSO-Domain",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}",
|
||||
}
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
validate_list('congestion_points', self.congestion_points, AgrPortfolioQueryResponseCongestionPoint, 1)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class AgrPortfolioQueryResponseDSOView:
|
||||
class Meta:
|
||||
name = "AGRPortfolioQueryResponseDSOView"
|
||||
|
||||
dso_portfolios: List[AgrPortfolioQueryResponseDSOPortfolio] = field(
|
||||
default_factory=list,
|
||||
metadata={
|
||||
"name": "DSO-Portfolio",
|
||||
"type": "Element",
|
||||
"min_occurs": 1,
|
||||
}
|
||||
)
|
||||
connections: List[AgrPortfolioQueryResponseConnection] = field(
|
||||
default_factory=list,
|
||||
metadata={
|
||||
"name": "Connection",
|
||||
"type": "Element",
|
||||
}
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
validate_list('dso_portfolios', self.dso_portfolios, AgrPortfolioQueryResponseDSOPortfolio, 1)
|
||||
|
||||
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class AgrPortfolioQueryResponse(PayloadMessageResponse):
|
||||
"""
|
||||
:ivar dso_view:
|
||||
:ivar time_zone: Time zone ID (as per the IANA time zone database,
|
||||
http://www.iana.org/time-zones, for example: Europe/Amsterdam)
|
||||
indicating the UTC offset that applies to the Period referenced
|
||||
in this message. Although the time zone is a market-wide fixed
|
||||
value, making this assumption explicit in each message is
|
||||
important for validation purposes, allowing implementations to
|
||||
reject messages with an errant UTC offset.
|
||||
:ivar period: The Period that the portfolio is valid.
|
||||
"""
|
||||
class Meta:
|
||||
name = "AGRPortfolioQueryResponse"
|
||||
|
||||
agr_portfolio_query_message_id: str = field(
|
||||
metadata={
|
||||
"name": "AGRPortfolioQueryMessageID",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}",
|
||||
}
|
||||
)
|
||||
|
||||
dso_views: List[AgrPortfolioQueryResponseDSOView] = field(
|
||||
default_factory=list,
|
||||
metadata={
|
||||
"name": "DSO-View",
|
||||
"type": "Element",
|
||||
"min_occurs": 1,
|
||||
}
|
||||
)
|
||||
time_zone: str = field(
|
||||
default=DEFAULT_TIME_ZONE,
|
||||
metadata={
|
||||
"name": "TimeZone",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"(Africa|America|Australia|Europe|Pacific)/[a-zA-Z0-9_/]{3,}",
|
||||
}
|
||||
)
|
||||
period: XmlDate = field(
|
||||
metadata={
|
||||
"name": "Period",
|
||||
"type": "Attribute",
|
||||
"format": "%Y-%m-%d",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
self.dso_views = validate_list('dso_views', self.dso_views, AgrPortfolioQueryResponseDSOView, 1)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class AgrPortfolioQuery(PayloadMessage):
|
||||
"""
|
||||
:ivar time_zone: Time zone ID (as per the IANA time zone database,
|
||||
http://www.iana.org/time-zones, for example: Europe/Amsterdam)
|
||||
indicating the UTC offset that applies to the Period referenced
|
||||
in this message. Although the time zone is a market-wide fixed
|
||||
value, making this assumption explicit in each message is
|
||||
important for validation purposes, allowing implementations to
|
||||
reject messages with an errant UTC offset.
|
||||
:ivar period: The Period for which the AGR requests the portfolio
|
||||
information.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
name = "AGRPortfolioQuery"
|
||||
|
||||
time_zone: str = field(
|
||||
default=DEFAULT_TIME_ZONE,
|
||||
metadata={
|
||||
"name": "TimeZone",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"(Africa|America|Australia|Europe|Pacific)/[a-zA-Z0-9_/]{3,}",
|
||||
}
|
||||
)
|
||||
period: XmlDate = field(
|
||||
metadata={
|
||||
"name": "Period",
|
||||
"type": "Attribute",
|
||||
"format": "%Y-%m-%d",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,101 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional
|
||||
|
||||
from xsdata.models.datatype import XmlDate
|
||||
|
||||
from ..defaults import DEFAULT_TIME_ZONE
|
||||
from ..validations import validate_list
|
||||
from .payload_message import PayloadMessage, PayloadMessageResponse
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class AgrPortfolioUpdateConnection:
|
||||
"""
|
||||
A connection that the AGR want the CRO to update.
|
||||
|
||||
:ivar entity_address: EntityAddress of the Connection entity being
|
||||
updated.
|
||||
:ivar start_period: The first Period hat the AGR represents the
|
||||
prosumer at this Connection.
|
||||
:ivar end_period: The last Period that the AGR represents the
|
||||
prosumer at this Connection, if applicable.
|
||||
"""
|
||||
class Meta:
|
||||
name = "AGRPortfolioUpdateConnection"
|
||||
|
||||
entity_address: str = field(
|
||||
metadata={
|
||||
"name": "EntityAddress",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"(ea1\.[0-9]{4}-[0-9]{2}\..{1,244}:.{1,244}|ean\.[0-9]{12,34})",
|
||||
}
|
||||
)
|
||||
start_period: XmlDate = field(
|
||||
metadata={
|
||||
"name": "StartPeriod",
|
||||
"type": "Attribute",
|
||||
"format": "%Y-%m-%d",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
end_period: Optional[XmlDate] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "EndPeriod",
|
||||
"type": "Attribute",
|
||||
"format": "%Y-%m-%d",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class AgrPortfolioUpdateResponse(PayloadMessageResponse):
|
||||
class Meta:
|
||||
name = "AGRPortfolioUpdateResponse"
|
||||
|
||||
agr_portfolio_update_message_id: str = field(
|
||||
metadata={
|
||||
"name": "AGRPortfolioUpdateMessageID",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class AgrPortfolioUpdate(PayloadMessage):
|
||||
"""
|
||||
:ivar connection:
|
||||
:ivar time_zone: Time zone ID (as per the IANA time zone database,
|
||||
http://www.iana.org/time-zones, for example: Europe/Amsterdam)
|
||||
indicating the UTC offset that applies to the Period referenced
|
||||
in this message. Although the time zone is a market-wide fixed
|
||||
value, making this assumption explicit in each message is
|
||||
important for validation purposes, allowing implementations to
|
||||
reject messages with an errant UTC offset.
|
||||
"""
|
||||
class Meta:
|
||||
name = "AGRPortfolioUpdate"
|
||||
|
||||
connections: List[AgrPortfolioUpdateConnection] = field(
|
||||
default_factory=list,
|
||||
metadata={
|
||||
"name": "Connection",
|
||||
"type": "Element",
|
||||
"min_occurs": 1,
|
||||
}
|
||||
)
|
||||
time_zone: str = field(
|
||||
default=DEFAULT_TIME_ZONE,
|
||||
metadata={
|
||||
"name": "TimeZone",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"(Africa|America|Australia|Europe|Pacific)/[a-zA-Z0-9_/]{3,}",
|
||||
}
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
validate_list('connections', self.connections, AgrPortfolioUpdateConnection, 1)
|
||||
@@ -0,0 +1,118 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List
|
||||
|
||||
from ..validations import validate_list
|
||||
from .flex_message import FlexMessage
|
||||
from .payload_message import PayloadMessageResponse
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class DPrognosisISP:
|
||||
"""
|
||||
:ivar power: Power specified for this ISP in Watts. Also see the
|
||||
important notes about the sign of this attribute in the main
|
||||
documentation entry for the ISP element.
|
||||
:ivar start: Number of the first ISPs this element refers to. The
|
||||
first ISP of a day has number 1.
|
||||
:ivar duration: The number of the ISPs this element represents.
|
||||
Optional, default value is 1.
|
||||
"""
|
||||
class Meta:
|
||||
name = "D-PrognosisISP"
|
||||
|
||||
power: int = field(
|
||||
metadata={
|
||||
"name": "Power",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
start: int = field(
|
||||
metadata={
|
||||
"name": "Start",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
duration: int = field(
|
||||
default=1,
|
||||
metadata={
|
||||
"name": "Duration",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class FlexOrderStatus:
|
||||
flex_order_message_id: str = field(
|
||||
metadata={
|
||||
"name": "FlexOrderMessageID",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}",
|
||||
}
|
||||
)
|
||||
is_validated: bool = field(
|
||||
metadata={
|
||||
"name": "IsValidated",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class DPrognosisResponse(PayloadMessageResponse):
|
||||
class Meta:
|
||||
name = "D-PrognosisResponse"
|
||||
|
||||
d_prognosis_message_id: str = field(
|
||||
metadata={
|
||||
"name": "D-PrognosisMessageID",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}",
|
||||
}
|
||||
)
|
||||
|
||||
flex_order_statuses: List[FlexOrderStatus] = field(
|
||||
default_factory=list,
|
||||
metadata={
|
||||
"name": "FlexOrderStatus",
|
||||
"type": "Element",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class DPrognosis(FlexMessage):
|
||||
"""
|
||||
:ivar isp:
|
||||
:ivar revision: Revision of this message. A sequence number that
|
||||
must be incremented each time a new revision of a prognosis is
|
||||
sent. The combination of SenderDomain and PrognosisSequence
|
||||
should be unique
|
||||
"""
|
||||
class Meta:
|
||||
name = "D-Prognosis"
|
||||
|
||||
isps: List[DPrognosisISP] = field(
|
||||
default_factory=list,
|
||||
metadata={
|
||||
"name": "ISP",
|
||||
"type": "Element",
|
||||
"min_occurs": 1,
|
||||
}
|
||||
)
|
||||
revision: int = field(
|
||||
metadata={
|
||||
"name": "Revision",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
validate_list('isps', self.isps, DPrognosisISP, 1)
|
||||
@@ -0,0 +1,164 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional
|
||||
|
||||
from xsdata.models.datatype import XmlDate
|
||||
|
||||
from ..defaults import DEFAULT_TIME_ZONE
|
||||
from ..validations import validate_list
|
||||
from .payload_message import PayloadMessage, PayloadMessageResponse
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class DsoPortfolioQueryConnection:
|
||||
"""
|
||||
A Connection that is part of the congestion point.
|
||||
|
||||
:ivar entity_address: EntityAddress of the Connection.
|
||||
:ivar agr_domain: The internet domain of the AGR that represents the
|
||||
prosumer connected on this Connection, if applicable.
|
||||
"""
|
||||
class Meta:
|
||||
name = "DSOPortfolioQueryConnection"
|
||||
|
||||
entity_address: str = field(
|
||||
metadata={
|
||||
"name": "EntityAddress",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"(ea1\.[0-9]{4}-[0-9]{2}\..{1,244}:.{1,244}|ean\.[0-9]{12,34})",
|
||||
}
|
||||
)
|
||||
agr_domain: Optional[str] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "AGR-Domain",
|
||||
"type": "Attribute",
|
||||
"pattern": r"([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class DsoPortfolioQueryCongestionPoint:
|
||||
"""
|
||||
:ivar connection:
|
||||
:ivar entity_address: EntityAddress of the Connection.
|
||||
"""
|
||||
class Meta:
|
||||
name = "DSOPortfolioQueryCongestionPoint"
|
||||
|
||||
connections: List[DsoPortfolioQueryConnection] = field(
|
||||
default_factory=list,
|
||||
metadata={
|
||||
"name": "Connection",
|
||||
"type": "Element",
|
||||
"min_occurs": 1,
|
||||
}
|
||||
)
|
||||
entity_address: str = field(
|
||||
metadata={
|
||||
"name": "EntityAddress",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"(ea1\.[0-9]{4}-[0-9]{2}\..{1,244}:.{1,244}|ean\.[0-9]{12,34})",
|
||||
}
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
validate_list('connections', self.connections, DsoPortfolioQueryConnection, 1)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class DsoPortfolioQueryResponse(PayloadMessageResponse):
|
||||
"""
|
||||
:ivar congestion_point:
|
||||
:ivar time_zone: Time zone ID (as per the IANA time zone database,
|
||||
http://www.iana.org/time-zones, for example: Europe/Amsterdam)
|
||||
indicating the UTC offset that applies to the Period referenced
|
||||
in this message. Although the time zone is a market-wide fixed
|
||||
value, making this assumption explicit in each message is
|
||||
important for validation purposes, allowing implementations to
|
||||
reject messages with an errant UTC offset.
|
||||
:ivar period: The Period for which the AGR requests the portfolio
|
||||
information.
|
||||
"""
|
||||
class Meta:
|
||||
name = "DSOPortfolioQueryResponse"
|
||||
|
||||
dso_portfolio_query_message_id: str = field(
|
||||
metadata={
|
||||
"name": "DSOPortfolioQueryMessageID",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}",
|
||||
}
|
||||
)
|
||||
|
||||
congestion_point: Optional[DsoPortfolioQueryCongestionPoint] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "CongestionPoint",
|
||||
"type": "Element",
|
||||
}
|
||||
)
|
||||
time_zone: str = field(
|
||||
default=DEFAULT_TIME_ZONE,
|
||||
metadata={
|
||||
"name": "TimeZone",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"(Africa|America|Australia|Europe|Pacific)/[a-zA-Z0-9_/]{3,}",
|
||||
}
|
||||
)
|
||||
period: XmlDate = field(
|
||||
metadata={
|
||||
"name": "Period",
|
||||
"type": "Attribute",
|
||||
"format": "%Y-%m-%d",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class DsoPortfolioQuery(PayloadMessage):
|
||||
"""
|
||||
:ivar time_zone: Time zone ID (as per the IANA time zone database,
|
||||
http://www.iana.org/time-zones, for example: Europe/Amsterdam)
|
||||
indicating the UTC offset that applies to the Period referenced
|
||||
in this message. Although the time zone is a market-wide fixed
|
||||
value, making this assumption explicit in each message is
|
||||
important for validation purposes, allowing implementations to
|
||||
reject messages with an errant UTC offset.
|
||||
:ivar period: The Period for which the AGR requests the portfolio
|
||||
information.
|
||||
:ivar entity_address: EntityAddress of the CongestionPoint
|
||||
"""
|
||||
class Meta:
|
||||
name = "DSOPortfolioQuery"
|
||||
|
||||
time_zone: str = field(
|
||||
default=DEFAULT_TIME_ZONE,
|
||||
metadata={
|
||||
"name": "TimeZone",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"(Africa|America|Australia|Europe|Pacific)/[a-zA-Z0-9_/]{3,}",
|
||||
}
|
||||
)
|
||||
period: XmlDate = field(
|
||||
metadata={
|
||||
"name": "Period",
|
||||
"type": "Attribute",
|
||||
"format": "%Y-%m-%d",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
entity_address: str = field(
|
||||
metadata={
|
||||
"name": "EntityAddress",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"(ea1\.[0-9]{4}-[0-9]{2}\..{1,244}:.{1,244}|ean\.[0-9]{12,34})",
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,181 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional
|
||||
|
||||
from xsdata.models.datatype import XmlDate
|
||||
|
||||
from ..defaults import DEFAULT_TIME_ZONE
|
||||
from ..enums import RedispatchBy
|
||||
from ..validations import validate_list
|
||||
from .payload_message import PayloadMessage, PayloadMessageResponse
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class DsoPortfolioUpdateConnection:
|
||||
"""
|
||||
A connection that the DSO wants the CRO to update.
|
||||
|
||||
:ivar entity_address: EntityAddress of the Connection.
|
||||
:ivar start_period: The first Period that the Connection is part of
|
||||
this CongestionPoint.
|
||||
:ivar end_period: The last Period that the Connection is part of
|
||||
this CongestionPoint.
|
||||
"""
|
||||
class Meta:
|
||||
name = "DSOPortfolioUpdateConnection"
|
||||
|
||||
entity_address: str = field(
|
||||
metadata={
|
||||
"name": "EntityAddress",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"(ea1\.[0-9]{4}-[0-9]{2}\..{1,244}:.{1,244}|ean\.[0-9]{12,34})",
|
||||
}
|
||||
)
|
||||
start_period: XmlDate = field(
|
||||
metadata={
|
||||
"name": "StartPeriod",
|
||||
"type": "Attribute",
|
||||
"format": "%Y-%m-%d",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
end_period: Optional[XmlDate] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "EndPeriod",
|
||||
"type": "Attribute",
|
||||
"format": "%Y-%m-%d",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class DsoPortfolioUpdateCongestionPoint:
|
||||
"""
|
||||
A congestion point that the DSO wants the CRO to update.
|
||||
|
||||
:ivar connection:
|
||||
:ivar entity_address: EntityAddress of the Connection.
|
||||
:ivar start_period: The first Period that the Connection is part of
|
||||
this CongestionPoint.
|
||||
:ivar end_period: The last Period that the Connection is part of
|
||||
this CongestionPoint.
|
||||
:ivar mutex_offers_supported: Indicates whether the DSO accepts
|
||||
mutual exclusive FlexOffers on this CongestionPoint.
|
||||
:ivar day_ahead_redispatch_by: Indicates which party is responsible
|
||||
for day-ahead redispatch.
|
||||
:ivar intraday_redispatch_by: Indicates which party is responsible
|
||||
for intraday ahead redispatch, AGR or DSO. If not specified,
|
||||
there will be no intraday trading on this CongestionPoint.
|
||||
"""
|
||||
class Meta:
|
||||
name = "DSOPortfolioUpdateCongestionPoint"
|
||||
|
||||
connections: List[DsoPortfolioUpdateConnection] = field(
|
||||
default_factory=list,
|
||||
metadata={
|
||||
"name": "Connection",
|
||||
"type": "Element",
|
||||
"min_occurs": 1,
|
||||
}
|
||||
)
|
||||
entity_address: str = field(
|
||||
metadata={
|
||||
"name": "EntityAddress",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"(ea1\.[0-9]{4}-[0-9]{2}\..{1,244}:.{1,244}|ean\.[0-9]{12,34})",
|
||||
}
|
||||
)
|
||||
start_period: XmlDate = field(
|
||||
metadata={
|
||||
"name": "StartPeriod",
|
||||
"type": "Attribute",
|
||||
"format": "%Y-%m-%d",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
end_period: Optional[XmlDate] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "EndPeriod",
|
||||
"type": "Attribute",
|
||||
"format": "%Y-%m-%d",
|
||||
}
|
||||
)
|
||||
mutex_offers_supported: bool = field(
|
||||
metadata={
|
||||
"name": "MutexOffersSupported",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
day_ahead_redispatch_by: RedispatchBy = field(
|
||||
metadata={
|
||||
"name": "DayAheadRedispatchBy",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
intraday_redispatch_by: Optional[RedispatchBy] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "IntradayRedispatchBy",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
validate_list('connections', self.connections, DsoPortfolioUpdateConnection, 1)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class DsoPortfolioUpdateResponse(PayloadMessageResponse):
|
||||
class Meta:
|
||||
name = "DSOPortfolioUpdateResponse"
|
||||
|
||||
dso_portfolio_update_message_id: str = field(
|
||||
metadata={
|
||||
"name": "DSOPortfolioUpdateResponseMessageID",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class DsoPortfolioUpdate(PayloadMessage):
|
||||
"""
|
||||
:ivar congestion_point:
|
||||
:ivar time_zone: Time zone ID (as per the IANA time zone database,
|
||||
http://www.iana.org/time-zones, for example: Europe/Amsterdam)
|
||||
indicating the UTC offset that applies to the Period referenced
|
||||
in this message. Although the time zone is a market-wide fixed
|
||||
value, making this assumption explicit in each message is
|
||||
important for validation purposes, allowing implementations to
|
||||
reject messages with an errant UTC offset.
|
||||
"""
|
||||
class Meta:
|
||||
name = "DSOPortfolioUpdate"
|
||||
|
||||
congestion_points: List[DsoPortfolioUpdateCongestionPoint] = field(
|
||||
default_factory=list,
|
||||
metadata={
|
||||
"name": "CongestionPoint",
|
||||
"type": "Element",
|
||||
"min_occurs": 1,
|
||||
}
|
||||
)
|
||||
time_zone: str = field(
|
||||
default=DEFAULT_TIME_ZONE,
|
||||
metadata={
|
||||
"name": "TimeZone",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"(Africa|America|Australia|Europe|Pacific)/[a-zA-Z0-9_/]{3,}",
|
||||
}
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
validate_list('congestion_points', self.congestion_points, DsoPortfolioUpdateCongestionPoint, 1)
|
||||
@@ -0,0 +1,61 @@
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from xsdata.models.datatype import XmlDate, XmlDuration
|
||||
|
||||
from ..defaults import DEFAULT_TIME_ZONE
|
||||
from .payload_message import PayloadMessage
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class FlexMessage(PayloadMessage):
|
||||
"""
|
||||
:ivar isp_duration: ISO 8601 time interval (minutes only, for
|
||||
example PT15M) indicating the duration of the ISPs referenced in
|
||||
this message. Although the ISP length is a market-wide fixed
|
||||
value, making this assumption explicit in each message is
|
||||
important for validation purposes, allowing implementations to
|
||||
reject messages with an errant ISP duration.
|
||||
:ivar time_zone: Time zone ID (as per the IANA time zone database,
|
||||
http://www.iana.org/time-zones, for example: Europe/Amsterdam)
|
||||
indicating the UTC offset that applies to the Period referenced
|
||||
in this message. Although the time zone is a market-wide fixed
|
||||
value, making this assumption explicit in each message is
|
||||
important for validation purposes, allowing implementations to
|
||||
reject messages with an errant UTC offset.
|
||||
:ivar period: Day (in yyyy-mm-dd format) the ISPs referenced in this
|
||||
Flex* message belong to.
|
||||
:ivar congestion_point: Entity Address of the Congestion Point this
|
||||
D-Prognosis applies to.
|
||||
"""
|
||||
isp_duration: XmlDuration = field(
|
||||
metadata={
|
||||
"name": "ISP-Duration",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
time_zone: str = field(
|
||||
default=DEFAULT_TIME_ZONE,
|
||||
metadata={
|
||||
"name": "TimeZone",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"(Africa|America|Australia|Europe|Pacific)/[a-zA-Z0-9_/]{3,}",
|
||||
}
|
||||
)
|
||||
period: XmlDate = field(
|
||||
metadata={
|
||||
"name": "Period",
|
||||
"type": "Attribute",
|
||||
"format": "%Y-%m-%d",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
congestion_point: str = field(
|
||||
metadata={
|
||||
"name": "CongestionPoint",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"(ea1\.[0-9]{4}-[0-9]{2}\..{1,244}:.{1,244}|ean\.[0-9]{12,34})",
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,199 @@
|
||||
from dataclasses import dataclass, field
|
||||
from decimal import Decimal
|
||||
from typing import List, Optional
|
||||
|
||||
from ..validations import validate_decimal, validate_list
|
||||
from .flex_message import FlexMessage
|
||||
from .payload_message import PayloadMessageResponse
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class FlexOfferOptionISP:
|
||||
"""
|
||||
:ivar power: Power specified for this ISP in Watts. Also see the
|
||||
important notes about the sign of this attribute in the main
|
||||
documentation entry for the ISP element.
|
||||
:ivar start: Number of the first ISPs this element refers to. The
|
||||
first ISP of a day has number 1.
|
||||
:ivar duration: The number of the ISPs this element represents.
|
||||
Optional, default value is 1.
|
||||
"""
|
||||
class Meta:
|
||||
name = "FlexOfferOptionISP"
|
||||
|
||||
power: int = field(
|
||||
metadata={
|
||||
"name": "Power",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
start: int = field(
|
||||
metadata={
|
||||
"name": "Start",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
duration: int = field(
|
||||
default=1,
|
||||
metadata={
|
||||
"name": "Duration",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class FlexOfferOption:
|
||||
"""
|
||||
:ivar isp:
|
||||
:ivar option_reference: The identification of this option.
|
||||
:ivar price: The asking price for the flexibility offered in this
|
||||
option.
|
||||
:ivar min_activation_factor: The minimal activation factor for this
|
||||
OfferOption. An AGR may choose to include MinActivationFactor in
|
||||
FlexOffers even if the DSO is not interested in partial
|
||||
activation. In that case the DSO will simply use an
|
||||
ActivationFactor of 1.00 in every FlexOrder.
|
||||
"""
|
||||
isps: List[FlexOfferOptionISP] = field(
|
||||
default_factory=list,
|
||||
metadata={
|
||||
"name": "ISP",
|
||||
"type": "Element",
|
||||
"min_occurs": 1,
|
||||
}
|
||||
)
|
||||
option_reference: str = field(
|
||||
metadata={
|
||||
"name": "OptionReference",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
price: Decimal = field(
|
||||
metadata={
|
||||
"name": "Price",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"fraction_digits": 4,
|
||||
}
|
||||
)
|
||||
min_activation_factor: Decimal = field(
|
||||
default=Decimal("1.00"),
|
||||
metadata={
|
||||
"name": "MinActivationFactor",
|
||||
"type": "Attribute",
|
||||
"min_inclusive": Decimal("0.01"),
|
||||
"max_inclusive": Decimal("1.00"),
|
||||
"fraction_digits": 2,
|
||||
}
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
validate_list('isps', self.isps, FlexOfferOptionISP, 1)
|
||||
self.price = validate_decimal('price', self.price, 4)
|
||||
self.min_activation_factor = validate_decimal('min_activation_factor', self.min_activation_factor, 2)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class FlexOfferResponse(PayloadMessageResponse):
|
||||
flex_offer_message_id: str = field(
|
||||
metadata={
|
||||
"name": "FlexOfferMessageID",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class FlexOffer(FlexMessage):
|
||||
"""
|
||||
:ivar offer_option:
|
||||
:ivar expiration_date_time: Date and time, including the time zone
|
||||
(ISO 8601 formatted as per http://www.w3.org/TR/NOTE-datetime)
|
||||
until which the FlexOffer is valid.
|
||||
:ivar flex_request_message_id: MessageID of the FlexRequest message
|
||||
this request is based on. Mandatory if and only if solicited.
|
||||
:ivar contract_id: Reference to the concerning contract, if
|
||||
applicable. The contract may be either bilateral or commoditized
|
||||
market contract.
|
||||
:ivar d_prognosis_message_id: MessageID of the D-Prognosis this
|
||||
request is based on, if it has been agreed that the baseline is
|
||||
based on D-prognoses.
|
||||
:ivar baseline_reference: Identification of the baseline prognosis,
|
||||
if another baseline methodology is used than based on
|
||||
D-prognoses
|
||||
:ivar currency: ISO 4217 code indicating the currency that applies
|
||||
to the price of the FlexOffer.
|
||||
"""
|
||||
offer_options: List[FlexOfferOption] = field(
|
||||
default_factory=list,
|
||||
metadata={
|
||||
"name": "OfferOption",
|
||||
"type": "Element",
|
||||
"min_occurs": 1,
|
||||
}
|
||||
)
|
||||
expiration_date_time: str = field(
|
||||
metadata={
|
||||
"name": "ExpirationDateTime",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{0,9})?([+-]\d{2}:\d{2}|Z)",
|
||||
}
|
||||
)
|
||||
unsolicited: Optional[bool] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "Unsolicited",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
flex_request_message_id: Optional[str] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "FlexRequestMessageID",
|
||||
"type": "Attribute",
|
||||
"pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}",
|
||||
}
|
||||
)
|
||||
contract_id: Optional[str] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "ContractID",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
d_prognosis_message_id: Optional[str] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "D-PrognosisMessageID",
|
||||
"type": "Attribute",
|
||||
"pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}",
|
||||
}
|
||||
)
|
||||
baseline_reference: Optional[str] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "BaselineReference",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
currency: str = field(
|
||||
default="EUR",
|
||||
metadata={
|
||||
"name": "Currency",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"[A-Z]{3}",
|
||||
}
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
validate_list('offer_options', self.offer_options, FlexOfferOption, 1)
|
||||
if not self.unsolicited and self.flex_request_message_id is None:
|
||||
raise TypeError("FlexRequestMessageId is required if Unsolicited is not True")
|
||||
@@ -0,0 +1,32 @@
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .payload_message import PayloadMessage, PayloadMessageResponse
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class FlexOfferRevocationResponse(PayloadMessageResponse):
|
||||
flex_offer_revocation_message_id: str = field(
|
||||
metadata={
|
||||
"name": "FlexOfferRevocationMessageID",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class FlexOfferRevocation(PayloadMessage):
|
||||
"""
|
||||
:ivar flex_offer_message_id: MessageID of the FlexOffer message that
|
||||
is being revoked: this FlexOffer must have been accepted
|
||||
previously.
|
||||
"""
|
||||
flex_offer_message_id: str = field(
|
||||
metadata={
|
||||
"name": "FlexOfferMessageID",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}",
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,194 @@
|
||||
from dataclasses import dataclass, field
|
||||
from decimal import Decimal
|
||||
from typing import List, Optional
|
||||
|
||||
from ..validations import validate_decimal, validate_list
|
||||
from .flex_message import FlexMessage
|
||||
from .payload_message import PayloadMessageResponse
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class FlexOrderISP:
|
||||
"""
|
||||
:ivar power: Power specified for this ISP in Watts. Also see the
|
||||
important notes about the sign of this attribute in the main
|
||||
documentation entry for the ISP element.
|
||||
:ivar start: Number of the first ISPs this element refers to. The
|
||||
first ISP of a day has number 1.
|
||||
:ivar duration: The number of the ISPs this element represents.
|
||||
Optional, default value is 1.
|
||||
"""
|
||||
class Meta:
|
||||
name = "FlexOrderISP"
|
||||
|
||||
power: int = field(
|
||||
metadata={
|
||||
"name": "Power",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
start: int = field(
|
||||
metadata={
|
||||
"name": "Start",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
duration: int = field(
|
||||
default=1,
|
||||
metadata={
|
||||
"name": "Duration",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class FlexOrderResponse(PayloadMessageResponse):
|
||||
flex_order_message_id: str = field(
|
||||
metadata={
|
||||
"name": "FlexOrderMessageID",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}",
|
||||
}
|
||||
)
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class FlexOrder(FlexMessage):
|
||||
"""
|
||||
:ivar isp:
|
||||
:ivar unsolicited: Indicates whether this FlexOrder is intended to
|
||||
be unsolicited (i.e. without a preceding FlexOffer).
|
||||
:ivar service_type: Service type for this order, the service type
|
||||
determines response characteristics such as latency or asset
|
||||
participation type.
|
||||
:ivar flex_offer_message_id: MessageID of the FlexOffer message this
|
||||
order is based on.
|
||||
:ivar contract_id: Reference to the concerning bilateral contract,
|
||||
if applicable.
|
||||
:ivar d_prognosis_message_id: MessageID of the D-Prognosis this
|
||||
request is based on, if it has been agreed that the baseline is
|
||||
based on D-prognoses.
|
||||
:ivar baseline_reference: Identification of the baseline prognosis,
|
||||
if another baseline methodology is used than based on
|
||||
D-prognoses
|
||||
:ivar price: The price for the flexibility ordered. Usually, the
|
||||
price should match the price of the related FlexOffer.
|
||||
:ivar currency: ISO 4217 code indicating the currency that applies
|
||||
to the price of the FlexOffer.
|
||||
:ivar order_reference: Order number assigned by the DSO originating
|
||||
the FlexOrder. To be stored by the AGR and used in the
|
||||
settlement phase.
|
||||
:ivar option_reference: The OptionReference from the OfferOption
|
||||
chosen from the FlexOffer.
|
||||
:ivar activation_factor: The activation factor for this OfferOption.
|
||||
The ActivationFactor must be greater than or equal to the
|
||||
MinActivationFactor in the OfferOption chosen from the
|
||||
FlexOffer.
|
||||
"""
|
||||
isps: List[FlexOrderISP] = field(
|
||||
default_factory=list,
|
||||
metadata={
|
||||
"name": "ISP",
|
||||
"type": "Element",
|
||||
"min_occurs": 1,
|
||||
}
|
||||
)
|
||||
unsolicited: Optional[bool] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "Unsolicited",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
service_type: Optional[str] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "ServiceType",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
flex_offer_message_id: Optional[str] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "FlexOfferMessageID",
|
||||
"type": "Attribute",
|
||||
"required": False,
|
||||
"pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}",
|
||||
}
|
||||
)
|
||||
contract_id: Optional[str] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "ContractID",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
d_prognosis_message_id: Optional[str] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "D-PrognosisMessageID",
|
||||
"type": "Attribute",
|
||||
"pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}",
|
||||
}
|
||||
)
|
||||
baseline_reference: Optional[str] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "BaselineReference",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
price: Decimal = field(
|
||||
metadata={
|
||||
"name": "Price",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"fraction_digits": 4,
|
||||
}
|
||||
)
|
||||
currency: str = field(
|
||||
metadata={
|
||||
"name": "Currency",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"[A-Z]{3}",
|
||||
}
|
||||
)
|
||||
order_reference: str = field(
|
||||
metadata={
|
||||
"name": "OrderReference",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
option_reference: Optional[str] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "OptionReference",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
activation_factor: Decimal = field(
|
||||
default=Decimal("1.00"),
|
||||
metadata={
|
||||
"name": "ActivationFactor",
|
||||
"type": "Attribute",
|
||||
"min_inclusive": Decimal("0.01"),
|
||||
"max_inclusive": Decimal("1.00"),
|
||||
"fraction_digits": 2,
|
||||
}
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
validate_list("isps", self.isps, FlexOrderISP, 1)
|
||||
self.price = validate_decimal("price", self.price, 4)
|
||||
self.activation_factor = validate_decimal(
|
||||
"activation_factor", self.activation_factor, 2
|
||||
)
|
||||
if not self.unsolicited and self.flex_offer_message_id is None:
|
||||
raise TypeError(
|
||||
"FlexOfferMessageId is required if Unsolicited is not True"
|
||||
)
|
||||
@@ -0,0 +1,134 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional
|
||||
|
||||
from ..enums import AvailableRequested
|
||||
from ..validations import validate_list
|
||||
from .flex_message import FlexMessage
|
||||
from .payload_message import PayloadMessageResponse
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class FlexRequestISP:
|
||||
"""
|
||||
:ivar disposition:
|
||||
:ivar min_power: Power specified for this ISP in Watts. Also see the
|
||||
important notes about the sign of this attribute in the main
|
||||
documentation entry for the ISP element.
|
||||
:ivar max_power: Power specified for this ISP in Watts. Also see the
|
||||
important notes about the sign of this attribute in the main
|
||||
documentation entry for the ISP element.
|
||||
:ivar start: Number of the first ISPs this element refers to. The
|
||||
first ISP of a day has number 1.
|
||||
:ivar duration: The number of the ISPs this element represents.
|
||||
Optional, default value is 1.
|
||||
"""
|
||||
class Meta:
|
||||
name = "FlexRequestISP"
|
||||
|
||||
disposition: Optional[AvailableRequested] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "Disposition",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
min_power: int = field(
|
||||
metadata={
|
||||
"name": "MinPower",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
max_power: int = field(
|
||||
metadata={
|
||||
"name": "MaxPower",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
start: int = field(
|
||||
metadata={
|
||||
"name": "Start",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
duration: int = field(
|
||||
default=1,
|
||||
metadata={
|
||||
"name": "Duration",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class FlexRequestResponse(PayloadMessageResponse):
|
||||
|
||||
flex_request_message_id: str = field(
|
||||
metadata={
|
||||
"name": "FlexRequestMessageID",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}",
|
||||
}
|
||||
)
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class FlexRequest(FlexMessage):
|
||||
"""
|
||||
:ivar isp:
|
||||
:ivar revision: Revision of this message, a sequence number that
|
||||
must be incremented each time a new revision of a FlexRequest
|
||||
message is sent.
|
||||
:ivar expiration_date_time: Date and time, including the time zone
|
||||
(ISO 8601 formatted as per http://www.w3.org/TR/NOTE-datetime)
|
||||
until which the FlexRequest message is valid.
|
||||
:ivar contract_id: Reference to the concerning contract, if
|
||||
applicable. The contract may be either bilateral or commoditized
|
||||
market contract. Each contract may specify multiple service-
|
||||
types.
|
||||
:ivar service_type: Service type for this request, the service type
|
||||
determines response characteristics such as latency or asset
|
||||
participation type.
|
||||
"""
|
||||
isps: List[FlexRequestISP] = field(
|
||||
default_factory=list,
|
||||
metadata={
|
||||
"name": "ISP",
|
||||
"type": "Element",
|
||||
"min_occurs": 1,
|
||||
}
|
||||
)
|
||||
revision: int = field(
|
||||
metadata={
|
||||
"name": "Revision",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
expiration_date_time: str = field(
|
||||
metadata={
|
||||
"name": "ExpirationDateTime",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{0,9})?([+-]\d{2}:\d{2}|Z)",
|
||||
}
|
||||
)
|
||||
contract_id: Optional[str] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "ContractID",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
service_type: Optional[str] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "ServiceType",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
validate_list('isps', self.isps, FlexRequestISP, 1)
|
||||
@@ -0,0 +1,90 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List
|
||||
|
||||
from ..validations import validate_list
|
||||
from .flex_message import FlexMessage
|
||||
from .payload_message import PayloadMessageResponse
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class FlexReservationUpdateISP:
|
||||
"""
|
||||
:ivar power: Remaining reserved power specified for this ISP in
|
||||
Watts.
|
||||
:ivar start: Number of the first ISPs this element refers to. The
|
||||
first ISP of a day has number 1.
|
||||
:ivar duration: The number of the ISPs this element represents.
|
||||
Optional, default value is 1.
|
||||
"""
|
||||
class Meta:
|
||||
name = "FlexReservationUpdateISP"
|
||||
|
||||
power: int = field(
|
||||
metadata={
|
||||
"name": "Power",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
start: int = field(
|
||||
metadata={
|
||||
"name": "Start",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
duration: int = field(
|
||||
default=1,
|
||||
metadata={
|
||||
"name": "Duration",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class FlexReservationUpdateResponse(PayloadMessageResponse):
|
||||
|
||||
flex_reservation_update_message_id: str = field(
|
||||
metadata={
|
||||
"name": "FlexReservationUpdateMessageID",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class FlexReservationUpdate(FlexMessage):
|
||||
"""
|
||||
:ivar isp:
|
||||
:ivar contract_id: Reference to the bilateral contract in question.
|
||||
:ivar reference: Message reference, assigned by the DSO originating
|
||||
the FlexReservationUpdate.
|
||||
"""
|
||||
isps: List[FlexReservationUpdateISP] = field(
|
||||
default_factory=list,
|
||||
metadata={
|
||||
"name": "ISP",
|
||||
"type": "Element",
|
||||
"min_occurs": 1,
|
||||
}
|
||||
)
|
||||
contract_id: str = field(
|
||||
metadata={
|
||||
"name": "ContractID",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
reference: str = field(
|
||||
metadata={
|
||||
"name": "Reference",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
validate_list('isps', self.isps, FlexReservationUpdateISP, 1)
|
||||
@@ -0,0 +1,451 @@
|
||||
from dataclasses import dataclass, field
|
||||
from decimal import Decimal
|
||||
from typing import List, Optional
|
||||
|
||||
from xsdata.models.datatype import XmlDate
|
||||
|
||||
from ..enums import AcceptedDisputed
|
||||
from ..validations import validate_decimal, validate_list
|
||||
from .payload_message import PayloadMessageResponse
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class ContractSettlementISP:
|
||||
"""
|
||||
:ivar start: Number of the first ISPs this element refers to. The
|
||||
first ISP of a day has number 1.
|
||||
:ivar duration: The number of the ISPs this element represents.
|
||||
Optional, default value is 1.
|
||||
:ivar reserved_power: Amount of flex power that has been reserved
|
||||
(and not released using a FlexReservationUpdate message).
|
||||
:ivar requested_power: Amount of flex power that has been both
|
||||
reserved in advance and has been requested using a FlexRequest
|
||||
(i.e. the lowest amount of flex power for this ISP). If there
|
||||
was no FlexRequest, this field is omitted.
|
||||
:ivar available_power: Amount of flex power that is considered
|
||||
available based on the FlexRequest in question. In case
|
||||
RequestedPower=0, AvailablePower is defined so that the offered
|
||||
power is allowed to be between 0 and AvailablePower in terms of
|
||||
compliancy (see Appendix 'Rationale for information exchange in
|
||||
flexibility request' for details). In case RequestedPower ≠0,
|
||||
AvailablePower is defined so that the offered power is allowed
|
||||
to exceed the amount of requested power up to AvailablePower. If
|
||||
this is relevant for settlement, the DSO can include this field.
|
||||
:ivar offered_power: Amount of flex power that has been reserved in
|
||||
advance, requested using a FlexRequest and covered in an offer
|
||||
from the AGR. If there was no offer, this field is omitted. If
|
||||
there were multiple offers, only the one is considered that is
|
||||
most compliant .
|
||||
:ivar ordered_power: Amount of flex power that has been ordered
|
||||
using a FlexOrder message that was based on a FlexOffer, both
|
||||
linked to this contract. If there was no order, this field is
|
||||
omitted.
|
||||
"""
|
||||
class Meta:
|
||||
name = "ContractSettlementISP"
|
||||
|
||||
start: int = field(
|
||||
metadata={
|
||||
"name": "Start",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
duration: int = field(
|
||||
default=1,
|
||||
metadata={
|
||||
"name": "Duration",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
reserved_power: int = field(
|
||||
metadata={
|
||||
"name": "ReservedPower",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
requested_power: Optional[int] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "RequestedPower",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
available_power: Optional[int] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "AvailablePower",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
offered_power: Optional[int] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "OfferedPower",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
ordered_power: Optional[int] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "OrderedPower",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class ContractSettlementPeriod:
|
||||
"""
|
||||
:ivar isp:
|
||||
:ivar period: Period the being settled.
|
||||
"""
|
||||
isps: List[ContractSettlementISP] = field(
|
||||
default_factory=list,
|
||||
metadata={
|
||||
"name": "ISP",
|
||||
"type": "Element",
|
||||
"min_occurs": 1,
|
||||
}
|
||||
)
|
||||
period: XmlDate = field(
|
||||
metadata={
|
||||
"name": "Period",
|
||||
"type": "Attribute",
|
||||
"format": "%Y-%m-%d",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class ContractSettlement:
|
||||
"""
|
||||
:ivar period:
|
||||
:ivar contract_id: Reference to the concerning bilateral contract.
|
||||
"""
|
||||
periods: List[ContractSettlementPeriod] = field(
|
||||
default_factory=list,
|
||||
metadata={
|
||||
"name": "Period",
|
||||
"type": "Element",
|
||||
"min_occurs": 1,
|
||||
}
|
||||
)
|
||||
contract_id: Optional[str] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "ContractID",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
validate_list('periods', self.periods, ContractSettlementPeriod, 1)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class FlexOrderSettlementStatus:
|
||||
"""
|
||||
:ivar order_reference: Order reference assigned by the DSO when
|
||||
originating the FlexOrder.
|
||||
:ivar disposition: Indication whether the AGR accepts the order
|
||||
settlement details provided by the DSO (and will invoice
|
||||
accordingly), or disputes these details.
|
||||
:ivar dispute_reason: In case the order settlement was disputed,
|
||||
this attribute must contain a human-readable description of the
|
||||
reason.
|
||||
"""
|
||||
order_reference: Optional[str] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "OrderReference",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
disposition: AcceptedDisputed = field(
|
||||
metadata={
|
||||
"name": "Disposition",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
dispute_reason: Optional[str] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "DisputeReason",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class FlexOrderSettlementISP:
|
||||
"""
|
||||
:ivar start: Number of the first ISPs this element refers to. The
|
||||
first ISP of a day has number 1.
|
||||
:ivar duration: The number of the ISPs this element represents.
|
||||
Optional, default value is 1.
|
||||
:ivar baseline_power: Power originally forecast (as per the
|
||||
referenced baseline) for this ISP in Watts.
|
||||
:ivar ordered_flex_power: Amount of flex power ordered (as per the
|
||||
referenced FlexOrder message) for this ISP in Watts.
|
||||
:ivar actual_power: Actual amount of power for this ISP in Watts, as
|
||||
measured/determined by the DSO and allocated to the AGR.
|
||||
:ivar delivered_flex_power: Actual amount of flex power delivered
|
||||
for this ISP in Watts, as determined by the DSO.
|
||||
:ivar power_deficiency: Amount of flex power sold but not delivered
|
||||
for this ISP in Watts, as determined by the DSO.
|
||||
"""
|
||||
class Meta:
|
||||
name = "FlexOrderSettlementISP"
|
||||
|
||||
start: int = field(
|
||||
metadata={
|
||||
"name": "Start",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
duration: int = field(
|
||||
default=1,
|
||||
metadata={
|
||||
"name": "Duration",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
baseline_power: int = field(
|
||||
metadata={
|
||||
"name": "BaselinePower",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
ordered_flex_power: int = field(
|
||||
metadata={
|
||||
"name": "OrderedFlexPower",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
actual_power: int = field(
|
||||
metadata={
|
||||
"name": "ActualPower",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
delivered_flex_power: int = field(
|
||||
metadata={
|
||||
"name": "DeliveredFlexPower",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
power_deficiency: int = field(
|
||||
default=0,
|
||||
metadata={
|
||||
"name": "PowerDeficiency",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class FlexOrderSettlement:
|
||||
"""
|
||||
:ivar isp:
|
||||
:ivar order_reference: Order reference assigned by the DSO when
|
||||
originating the FlexOrder.
|
||||
:ivar period:
|
||||
:ivar contract_id: Reference to the concerning bilateral contract,
|
||||
if it is linked to it
|
||||
:ivar d_prognosis_message_id: MessageID of the Prognosis message
|
||||
(more specifically: the D-Prognosis) the FlexOrder is based on,
|
||||
if it has been agreed that the baseline is based on D-prognoses.
|
||||
:ivar baseline_reference: Identification of the baseline prognosis,
|
||||
if another baseline methodology is used than based on
|
||||
D-prognoses.
|
||||
:ivar congestion_point: Entity Address of the Congestion Point the
|
||||
FlexOrder applies to.
|
||||
:ivar price: The price accepted for supplying the ordered amount of
|
||||
flexibility as per the referenced FlexOrder messages.
|
||||
:ivar penalty: Penalty due a non-zero PowerDeficiency
|
||||
:ivar net_settlement: Net settlement amount for this Period: Price
|
||||
minus Penalty.
|
||||
"""
|
||||
isps: List[FlexOrderSettlementISP] = field(
|
||||
default_factory=list,
|
||||
metadata={
|
||||
"name": "ISP",
|
||||
"type": "Element",
|
||||
"min_occurs": 1,
|
||||
}
|
||||
)
|
||||
order_reference: Optional[str] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "OrderReference",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
period: XmlDate = field(
|
||||
metadata={
|
||||
"name": "Period",
|
||||
"type": "Attribute",
|
||||
"format": "%Y-%m-%d",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
contract_id: Optional[str] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "ContractID",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
d_prognosis_message_id: Optional[str] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "D-PrognosisMessageID",
|
||||
"type": "Attribute",
|
||||
"pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}",
|
||||
}
|
||||
)
|
||||
baseline_reference: Optional[str] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "BaselineReference",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
congestion_point: str = field(
|
||||
metadata={
|
||||
"name": "CongestionPoint",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"(ea1\.[0-9]{4}-[0-9]{2}\..{1,244}:.{1,244}|ean\.[0-9]{12,34})",
|
||||
}
|
||||
)
|
||||
price: Decimal = field(
|
||||
metadata={
|
||||
"name": "Price",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"fraction_digits": 4,
|
||||
}
|
||||
)
|
||||
penalty: Decimal = field(
|
||||
default=Decimal("0"),
|
||||
metadata={
|
||||
"name": "Penalty",
|
||||
"type": "Attribute",
|
||||
"fraction_digits": 4,
|
||||
}
|
||||
)
|
||||
net_settlement: Decimal = field(
|
||||
metadata={
|
||||
"name": "NetSettlement",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"fraction_digits": 4,
|
||||
}
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
validate_list('isps', self.isps, FlexOrderSettlementISP, 1)
|
||||
self.price = validate_decimal('price', self.price, 4)
|
||||
self.penalty = validate_decimal('penalty', self.penalty, 4)
|
||||
self.net_settlement = validate_decimal('net_settlement', self.net_settlement, 4)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class FlexSettlementResponse(PayloadMessageResponse):
|
||||
flex_settlement_message_id: str = field(
|
||||
metadata={
|
||||
"name": "FlexSettlementMessageID",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}",
|
||||
}
|
||||
)
|
||||
|
||||
flex_order_settlement_statuses: List[FlexOrderSettlementStatus] = field(
|
||||
default_factory=list,
|
||||
metadata={
|
||||
"name": "FlexOrderSettlementStatus",
|
||||
"type": "Element",
|
||||
"min_occurs": 1,
|
||||
}
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
validate_list(
|
||||
"flex_order_settlement_statuses",
|
||||
self.flex_order_settlement_statuses,
|
||||
FlexOrderSettlementStatus,
|
||||
1,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class FlexSettlement(PayloadMessageResponse):
|
||||
"""
|
||||
:ivar flex_order_settlement:
|
||||
:ivar contract_settlement:
|
||||
:ivar period_start: First Period of the settlement period this
|
||||
message applies to.
|
||||
:ivar period_end: Last Period of the settlement period this message
|
||||
applies to.
|
||||
:ivar currency: ISO 4217 code indicating the currency that applies
|
||||
to all amounts (flex price, penalty and net settlement) in this
|
||||
message.
|
||||
"""
|
||||
flex_order_settlements: List[FlexOrderSettlement] = field(
|
||||
default_factory=list,
|
||||
metadata={
|
||||
"name": "FlexOrderSettlement",
|
||||
"type": "Element",
|
||||
"min_occurs": 1,
|
||||
}
|
||||
)
|
||||
contract_settlements: List[ContractSettlement] = field(
|
||||
default_factory=list,
|
||||
metadata={
|
||||
"name": "ContractSettlement",
|
||||
"type": "Element",
|
||||
"min_occurs": 1,
|
||||
}
|
||||
)
|
||||
period_start: XmlDate = field(
|
||||
metadata={
|
||||
"name": "PeriodStart",
|
||||
"type": "Attribute",
|
||||
"format": "%Y-%m-%d",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
period_end: XmlDate = field(
|
||||
metadata={
|
||||
"name": "PeriodEnd",
|
||||
"type": "Attribute",
|
||||
"format": "%Y-%m-%d",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
currency: str = field(
|
||||
metadata={
|
||||
"name": "Currency",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"[A-Z]{3}",
|
||||
}
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
validate_list(
|
||||
"flex_order_settlements", self.flex_order_settlements, FlexOrderSettlement, 1
|
||||
)
|
||||
validate_list(
|
||||
"contract_settlements", self.contract_settlements, ContractSettlement, 1
|
||||
)
|
||||
@@ -0,0 +1,202 @@
|
||||
from dataclasses import dataclass, field
|
||||
from decimal import Decimal
|
||||
from enum import Enum
|
||||
from typing import List, Optional
|
||||
|
||||
from xsdata.models.datatype import XmlDate, XmlDuration
|
||||
|
||||
from ..validations import validate_list
|
||||
from .payload_message import PayloadMessage, PayloadMessageResponse
|
||||
|
||||
# pylint: disable=missing-class-docstring,duplicate-code
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class MeteringISP:
|
||||
"""
|
||||
:ivar start: Number of the ISP this element refers to. The first ISP
|
||||
of a day has number 1.
|
||||
:ivar value: Metering, energy or price value at the end of this ISP,
|
||||
in the designated profile units.
|
||||
"""
|
||||
class Meta:
|
||||
name = "MeteringISP"
|
||||
|
||||
start: int = field(
|
||||
metadata={
|
||||
"name": "Start",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
value: Decimal = field(
|
||||
metadata={
|
||||
"name": "Value",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class MeteringProfileEnum(Enum):
|
||||
"""
|
||||
:cvar POWER: The average active power during ISP, considering both
|
||||
import and export energy. Power=(ImportEnergy-
|
||||
ExportEnergy)*(60/ISP-Length-Minutes). For example with a 15
|
||||
minute ISP length we have a multiplier of 4, with a 30 minute
|
||||
ISP length we have a multiplier of 2. Including the power
|
||||
profile is recommended. It is expected that in the following
|
||||
major version the power will become a mandatory value.
|
||||
:cvar IMPORT_ENERGY: Imported active energy, consumed during the ISP
|
||||
:cvar EXPORT_ENERGY: Exported active energy, generated during the
|
||||
ISP
|
||||
:cvar IMPORT_METER_READING: Cumulative metered imported active
|
||||
energy reading, at the end of the ISP
|
||||
:cvar EXPORT_METER_READING: Cumulative metered exported active
|
||||
energy reading, at the end of the ISP
|
||||
"""
|
||||
POWER = "Power"
|
||||
IMPORT_ENERGY = "ImportEnergy"
|
||||
EXPORT_ENERGY = "ExportEnergy"
|
||||
IMPORT_METER_READING = "ImportMeterReading"
|
||||
EXPORT_METER_READING = "ExportMeterReading"
|
||||
|
||||
|
||||
class MeteringUnit(Enum):
|
||||
"""
|
||||
:cvar K_W: kW must be used with Power profile values.
|
||||
:cvar K_WH: kWh must be used with energy profile values
|
||||
(ImportEnergy,ExportEnergy,ImportMeterReading,ExportMeterReading).
|
||||
"""
|
||||
K_W = "kW"
|
||||
K_WH = "kWh"
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class MeteringProfile:
|
||||
"""
|
||||
A profile carries a sequence of ISPs with a defined type of metering data.
|
||||
"""
|
||||
isps: List[MeteringISP] = field(
|
||||
default_factory=list,
|
||||
metadata={
|
||||
"name": "ISP",
|
||||
"type": "Element",
|
||||
"min_occurs": 1,
|
||||
}
|
||||
)
|
||||
profile_type: MeteringProfileEnum = field(
|
||||
metadata={
|
||||
"name": "ProfileType",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
unit: MeteringUnit = field(
|
||||
metadata={
|
||||
"name": "Unit",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
validate_list('isps', self.isps, MeteringISP, 1)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class MeteringResponse(PayloadMessageResponse):
|
||||
metering_message_id: str = field(
|
||||
metadata={
|
||||
"name": "MeteringMessageID",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class Metering(PayloadMessage):
|
||||
"""
|
||||
:ivar profile:
|
||||
:ivar revision: Revision of this message. A sequence number that
|
||||
must be incremented each time a new revision of a metering
|
||||
message is sent.
|
||||
:ivar isp_duration: ISO 8601 time interval (minutes only, for
|
||||
example PT15M) indicating the duration of the ISPs referenced in
|
||||
this message. Although the ISP length is a market-wide fixed
|
||||
value, making this assumption explicit in each message is
|
||||
important for validation purposes, allowing implementations to
|
||||
reject messages with an errant ISP duration.
|
||||
:ivar time_zone: Time zone ID (as per the IANA time zone database,
|
||||
http://www.iana.org/time-zones, for example: Europe/Amsterdam)
|
||||
indicating the UTC offset that applies to the Period referenced
|
||||
in this message. Although the time zone is a market-wide fixed
|
||||
value, making this assumption explicit in each message is
|
||||
important for validation purposes, allowing implementations to
|
||||
reject messages with an errant UTC offset.
|
||||
:ivar currency: ISO 4217 code indicating the currency that applies
|
||||
to the price of the Tariff Rates. Only required if ImportTariff
|
||||
or ExportTariff profiles are included.
|
||||
:ivar period: Day (in yyyy-mm-dd format) the ISPs referenced in this
|
||||
Metering message belong to.
|
||||
:ivar ean: EAN of the meter the message applies to.
|
||||
"""
|
||||
profiles: List[MeteringProfile] = field(
|
||||
default_factory=list,
|
||||
metadata={
|
||||
"name": "Profile",
|
||||
"type": "Element",
|
||||
"min_occurs": 1,
|
||||
}
|
||||
)
|
||||
revision: int = field(
|
||||
metadata={
|
||||
"name": "Revision",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
isp_duration: XmlDuration = field(
|
||||
metadata={
|
||||
"name": "ISP-Duration",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
time_zone: str = field(
|
||||
metadata={
|
||||
"name": "TimeZone",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"(Africa|America|Australia|Europe|Pacific)/[a-zA-Z0-9_/]{3,}",
|
||||
}
|
||||
)
|
||||
currency: Optional[str] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "Currency",
|
||||
"type": "Attribute",
|
||||
"pattern": r"[A-Z]{3}",
|
||||
}
|
||||
)
|
||||
period: XmlDate = field(
|
||||
metadata={
|
||||
"name": "Period",
|
||||
"type": "Attribute",
|
||||
"format": "%Y-%m-%d",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
ean: str = field(
|
||||
metadata={
|
||||
"name": "EAN",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"[Ee][0-9]{18}",
|
||||
}
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
validate_list('profiles', self.profiles, MeteringProfile, 1)
|
||||
@@ -0,0 +1,115 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
from ..enums import AcceptedRejected
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class PayloadMessage:
|
||||
"""
|
||||
:ivar version: Version of the Shapeshifter specification used by the
|
||||
USEF participant sending this message.
|
||||
:ivar sender_domain: The Internet domain of the USEF participant
|
||||
sending this message. When receiving a message, its value should
|
||||
match the value specified in the SignedMessage wrapper:
|
||||
otherwise, the message must be rejected as invalid. When
|
||||
replying to this message, this attribute is used to look up the
|
||||
USEF endpoint the reply message should be delivered to.
|
||||
:ivar recipient_domain: Internet domain of the participant this
|
||||
message is intended for. When sending a message, this attribute,
|
||||
combined with the RecipientRole, is used to look up the USEF
|
||||
endpoint the message should be delivered to.
|
||||
:ivar time_stamp: Date and time this message was created, including
|
||||
the time zone (ISO 8601 formatted as per
|
||||
http://www.w3.org/TR/NOTE-datetime).
|
||||
:ivar message_id: Unique identifier (UUID/GUID as per IETF RFC 4122)
|
||||
for this message, to be generated when composing each message.
|
||||
:ivar conversation_id: Unique identifier (UUID/GUID as per IETF RFC
|
||||
4122) used to correlate responses with requests, to be generated
|
||||
when composing the first message in a conversation and
|
||||
subsequently copied from the original message to each reply
|
||||
message.
|
||||
"""
|
||||
|
||||
version: Optional[str] = field(
|
||||
default="3.1.0",
|
||||
metadata={
|
||||
"name": "Version",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"(\d+\.\d+\.\d+)",
|
||||
}
|
||||
)
|
||||
sender_domain: Optional[str] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "SenderDomain",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}",
|
||||
}
|
||||
)
|
||||
recipient_domain: Optional[str] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "RecipientDomain",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}",
|
||||
}
|
||||
)
|
||||
time_stamp: Optional[str] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "TimeStamp",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{0,9})?([+-]\d{2}:\d{2}|Z)",
|
||||
}
|
||||
)
|
||||
message_id: Optional[str] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "MessageID",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}",
|
||||
}
|
||||
)
|
||||
conversation_id: Optional[str] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "ConversationID",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class PayloadMessageResponse(PayloadMessage):
|
||||
"""
|
||||
:ivar reference_message_id: MessageID of the message that has just
|
||||
been accepted or rejected.
|
||||
:ivar result: Indication whether the query was executed successfully
|
||||
or failed.
|
||||
:ivar rejection_reason: In case the query failed, this attribute
|
||||
must contain a human-readable description of the failure reason.
|
||||
"""
|
||||
|
||||
result: Optional[AcceptedRejected] = field(
|
||||
default=AcceptedRejected.ACCEPTED,
|
||||
metadata={
|
||||
"name": "Result",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
rejection_reason: Optional[str] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "RejectionReason",
|
||||
"type": "Attribute",
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,56 @@
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from ..enums import UsefRole
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class SignedMessage:
|
||||
"""The SignedMessage element represents the secure wrapper used to submit USEF
|
||||
XML messages from the local message queue to the message queue of a remote
|
||||
participant.
|
||||
|
||||
It contains minimal metadata (which is distinct from the common
|
||||
metadata used for all other messages), allowing the recipient to
|
||||
look up the sender's cryptographic scheme and public keys, and the
|
||||
actual XML message, as transformed (signed/sealed) using that
|
||||
cryptographic scheme.
|
||||
|
||||
:ivar sender_domain: The Internet domain of the USEF participant
|
||||
sending this message. Upon receiving a message, the recipient
|
||||
should validate that its value matches the corresponding
|
||||
attribute value specified in the inner XML message, once un-
|
||||
sealed: if not, the message must be rejected as invalid.
|
||||
:ivar sender_role: The USEF role of the participant sending this
|
||||
message: AGR, BRP, CRO, DSO or MDC. Receive-time validation
|
||||
should take place as described for the SenderDomain attribute
|
||||
above.
|
||||
:ivar body: The Base-64 encoded inner XML message contained in this
|
||||
wrapper, as transformed (signed/sealed) using the sender's
|
||||
cryptographic scheme. The recipient can determine which scheme
|
||||
applies using a DNS or configuration file lookup, based on the
|
||||
combination of SenderDomain and SenderRole.
|
||||
"""
|
||||
|
||||
sender_domain: str = field(
|
||||
metadata={
|
||||
"name": "SenderDomain",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}",
|
||||
}
|
||||
)
|
||||
sender_role: UsefRole = field(
|
||||
metadata={
|
||||
"name": "SenderRole",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
body: bytes = field(
|
||||
metadata={
|
||||
"name": "Body",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"format": "base64",
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .payload_message import PayloadMessage, PayloadMessageResponse
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class TestMessage(PayloadMessage):
|
||||
__test__ = False # Tell pytest to ignore this class
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class TestMessageResponse(PayloadMessageResponse):
|
||||
__test__ = False # Tell pytest to ignore this class
|
||||
@@ -0,0 +1,32 @@
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
|
||||
def validate_decimal(name: str, value: int | float | Decimal | str, digits: int):
|
||||
"""
|
||||
Validates that the decimal is acceptable, and returns it with the correct number of digits.
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
value = Decimal(value)
|
||||
except InvalidOperation as exc:
|
||||
raise ValueError(f"{name} must be a valid numeric value, not '{value}'") from exc
|
||||
if not isinstance(value, (int, float, Decimal)):
|
||||
raise TypeError(f"'{name}' must be a numeric type, not {type(value)}")
|
||||
return Decimal(f"{value:.{digits}f}")
|
||||
|
||||
|
||||
def validate_list(name, value, item_type, length):
|
||||
"""
|
||||
Validates that the list is of the correct type, length and content type.
|
||||
"""
|
||||
if not isinstance(value, list):
|
||||
raise TypeError(f"'{name}' must be a list, not {type(value)}")
|
||||
if len(value) < length:
|
||||
raise ValueError(f"'Length of list '{name}' must be {length} or greater, not {len(value)}")
|
||||
for index, item in enumerate(value):
|
||||
if not isinstance(item, item_type):
|
||||
raise TypeError(
|
||||
f"Not all items of property {name} were of type {item_type}: "
|
||||
f"item at index {index} was of type {type(item)}"
|
||||
)
|
||||
return value
|
||||
Reference in New Issue
Block a user