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:
Eric F
2026-06-08 00:38:27 -04:00
parent 468cfeaa50
commit d398a6ced2
7326 changed files with 1177561 additions and 7 deletions

View File

@@ -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",
]

View File

@@ -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)

View File

@@ -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__]
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -0,0 +1,9 @@
from .agr_service import ShapeshifterAgrService
from .cro_service import ShapeshifterCroService
from .dso_service import ShapeshifterDsoService
__all__ = [
"ShapeshifterAgrService",
"ShapeshifterCroService",
"ShapeshifterDsoService",
]

View File

@@ -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)

View File

@@ -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()

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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()}

View File

@@ -0,0 +1 @@
DEFAULT_TIME_ZONE = "Europe/Amsterdam"

View File

@@ -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"

View File

@@ -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 *

View File

@@ -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,
}
)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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})",
}
)

View File

@@ -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)

View File

@@ -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})",
}
)

View File

@@ -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")

View File

@@ -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}",
}
)

View File

@@ -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"
)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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
)

View File

@@ -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)

View File

@@ -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",
},
)

View File

@@ -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",
}
)

View File

@@ -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

View File

@@ -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