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:
26
tools/shapeshifter-library-python-main/.github/workflows/test.yml
vendored
Normal file
26
tools/shapeshifter-library-python-main/.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
name: Python Test
|
||||
|
||||
on:
|
||||
push: {}
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
python-test:
|
||||
name: python
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version:
|
||||
- "3.11"
|
||||
- "3.12"
|
||||
- "3.13"
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Install uv and set the Python version
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install the project
|
||||
run: uv sync --locked --all-extras --dev
|
||||
- name: Run tests
|
||||
run: uv run pytest
|
||||
635
tools/shapeshifter-library-python-main/.pylintrc
Normal file
635
tools/shapeshifter-library-python-main/.pylintrc
Normal file
@@ -0,0 +1,635 @@
|
||||
[MAIN]
|
||||
|
||||
# Analyse import fallback blocks. This can be used to support both Python 2 and
|
||||
# 3 compatible code, which means that the block might have code that exists
|
||||
# only in one or another interpreter, leading to false positives when analysed.
|
||||
analyse-fallback-blocks=no
|
||||
|
||||
# Clear in-memory caches upon conclusion of linting. Useful if running pylint
|
||||
# in a server-like mode.
|
||||
clear-cache-post-run=no
|
||||
|
||||
# Load and enable all available extensions. Use --list-extensions to see a list
|
||||
# all available extensions.
|
||||
#enable-all-extensions=
|
||||
|
||||
# In error mode, messages with a category besides ERROR or FATAL are
|
||||
# suppressed, and no reports are done by default. Error mode is compatible with
|
||||
# disabling specific errors.
|
||||
#errors-only=
|
||||
|
||||
# Always return a 0 (non-error) status code, even if lint errors are found.
|
||||
# This is primarily useful in continuous integration scripts.
|
||||
#exit-zero=
|
||||
|
||||
# A comma-separated list of package or module names from where C extensions may
|
||||
# be loaded. Extensions are loading into the active Python interpreter and may
|
||||
# run arbitrary code.
|
||||
extension-pkg-allow-list=
|
||||
|
||||
# A comma-separated list of package or module names from where C extensions may
|
||||
# be loaded. Extensions are loading into the active Python interpreter and may
|
||||
# run arbitrary code. (This is an alternative name to extension-pkg-allow-list
|
||||
# for backward compatibility.)
|
||||
extension-pkg-whitelist=
|
||||
|
||||
# Return non-zero exit code if any of these messages/categories are detected,
|
||||
# even if score is above --fail-under value. Syntax same as enable. Messages
|
||||
# specified are enabled, while categories only check already-enabled messages.
|
||||
fail-on=
|
||||
|
||||
# Specify a score threshold under which the program will exit with error.
|
||||
fail-under=10
|
||||
|
||||
# Interpret the stdin as a python script, whose filename needs to be passed as
|
||||
# the module_or_package argument.
|
||||
#from-stdin=
|
||||
|
||||
# Files or directories to be skipped. They should be base names, not paths.
|
||||
ignore=CVS
|
||||
|
||||
# Add files or directories matching the regular expressions patterns to the
|
||||
# ignore-list. The regex matches against paths and can be in Posix or Windows
|
||||
# format. Because '\\' represents the directory delimiter on Windows systems,
|
||||
# it can't be used as an escape character.
|
||||
ignore-paths=
|
||||
|
||||
# Files or directories matching the regular expression patterns are skipped.
|
||||
# The regex matches against base names, not paths. The default value ignores
|
||||
# Emacs file locks
|
||||
ignore-patterns=^\.#
|
||||
|
||||
# List of module names for which member attributes should not be checked
|
||||
# (useful for modules/projects where namespaces are manipulated during runtime
|
||||
# and thus existing member attributes cannot be deduced by static analysis). It
|
||||
# supports qualified module names, as well as Unix pattern matching.
|
||||
ignored-modules=
|
||||
|
||||
# Python code to execute, usually for sys.path manipulation such as
|
||||
# pygtk.require().
|
||||
#init-hook=
|
||||
|
||||
# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the
|
||||
# number of processors available to use, and will cap the count on Windows to
|
||||
# avoid hangs.
|
||||
jobs=1
|
||||
|
||||
# Control the amount of potential inferred values when inferring a single
|
||||
# object. This can help the performance when dealing with large functions or
|
||||
# complex, nested conditions.
|
||||
limit-inference-results=100
|
||||
|
||||
# List of plugins (as comma separated values of python module names) to load,
|
||||
# usually to register additional checkers.
|
||||
load-plugins=
|
||||
|
||||
# Pickle collected data for later comparisons.
|
||||
persistent=yes
|
||||
|
||||
# Minimum Python version to use for version dependent checks. Will default to
|
||||
# the version used to run pylint.
|
||||
py-version=3.10
|
||||
|
||||
# Discover python modules and packages in the file system subtree.
|
||||
recursive=no
|
||||
|
||||
# Add paths to the list of the source roots. Supports globbing patterns. The
|
||||
# source root is an absolute path or a path relative to the current working
|
||||
# directory used to determine a package namespace for modules located under the
|
||||
# source root.
|
||||
source-roots=
|
||||
|
||||
# When enabled, pylint would attempt to guess common misconfiguration and emit
|
||||
# user-friendly hints instead of false-positive error messages.
|
||||
suggestion-mode=yes
|
||||
|
||||
# Allow loading of arbitrary C extensions. Extensions are imported into the
|
||||
# active Python interpreter and may run arbitrary code.
|
||||
unsafe-load-any-extension=no
|
||||
|
||||
# In verbose mode, extra non-checker-related info will be displayed.
|
||||
#verbose=
|
||||
|
||||
|
||||
[BASIC]
|
||||
|
||||
# Naming style matching correct argument names.
|
||||
argument-naming-style=snake_case
|
||||
|
||||
# Regular expression matching correct argument names. Overrides argument-
|
||||
# naming-style. If left empty, argument names will be checked with the set
|
||||
# naming style.
|
||||
#argument-rgx=
|
||||
|
||||
# Naming style matching correct attribute names.
|
||||
attr-naming-style=snake_case
|
||||
|
||||
# Regular expression matching correct attribute names. Overrides attr-naming-
|
||||
# style. If left empty, attribute names will be checked with the set naming
|
||||
# style.
|
||||
#attr-rgx=
|
||||
|
||||
# Bad variable names which should always be refused, separated by a comma.
|
||||
bad-names=foo,
|
||||
bar,
|
||||
baz,
|
||||
toto,
|
||||
tutu,
|
||||
tata
|
||||
|
||||
# Bad variable names regexes, separated by a comma. If names match any regex,
|
||||
# they will always be refused
|
||||
bad-names-rgxs=
|
||||
|
||||
# Naming style matching correct class attribute names.
|
||||
class-attribute-naming-style=any
|
||||
|
||||
# Regular expression matching correct class attribute names. Overrides class-
|
||||
# attribute-naming-style. If left empty, class attribute names will be checked
|
||||
# with the set naming style.
|
||||
#class-attribute-rgx=
|
||||
|
||||
# Naming style matching correct class constant names.
|
||||
class-const-naming-style=UPPER_CASE
|
||||
|
||||
# Regular expression matching correct class constant names. Overrides class-
|
||||
# const-naming-style. If left empty, class constant names will be checked with
|
||||
# the set naming style.
|
||||
#class-const-rgx=
|
||||
|
||||
# Naming style matching correct class names.
|
||||
class-naming-style=PascalCase
|
||||
|
||||
# Regular expression matching correct class names. Overrides class-naming-
|
||||
# style. If left empty, class names will be checked with the set naming style.
|
||||
#class-rgx=
|
||||
|
||||
# Naming style matching correct constant names.
|
||||
const-naming-style=UPPER_CASE
|
||||
|
||||
# Regular expression matching correct constant names. Overrides const-naming-
|
||||
# style. If left empty, constant names will be checked with the set naming
|
||||
# style.
|
||||
#const-rgx=
|
||||
|
||||
# Minimum line length for functions/classes that require docstrings, shorter
|
||||
# ones are exempt.
|
||||
docstring-min-length=-1
|
||||
|
||||
# Naming style matching correct function names.
|
||||
function-naming-style=snake_case
|
||||
|
||||
# Regular expression matching correct function names. Overrides function-
|
||||
# naming-style. If left empty, function names will be checked with the set
|
||||
# naming style.
|
||||
#function-rgx=
|
||||
|
||||
# Good variable names which should always be accepted, separated by a comma.
|
||||
good-names=i,
|
||||
j,
|
||||
k,
|
||||
ex,
|
||||
Run,
|
||||
_
|
||||
|
||||
# Good variable names regexes, separated by a comma. If names match any regex,
|
||||
# they will always be accepted
|
||||
good-names-rgxs=
|
||||
|
||||
# Include a hint for the correct naming format with invalid-name.
|
||||
include-naming-hint=no
|
||||
|
||||
# Naming style matching correct inline iteration names.
|
||||
inlinevar-naming-style=any
|
||||
|
||||
# Regular expression matching correct inline iteration names. Overrides
|
||||
# inlinevar-naming-style. If left empty, inline iteration names will be checked
|
||||
# with the set naming style.
|
||||
#inlinevar-rgx=
|
||||
|
||||
# Naming style matching correct method names.
|
||||
method-naming-style=snake_case
|
||||
|
||||
# Regular expression matching correct method names. Overrides method-naming-
|
||||
# style. If left empty, method names will be checked with the set naming style.
|
||||
#method-rgx=
|
||||
|
||||
# Naming style matching correct module names.
|
||||
module-naming-style=snake_case
|
||||
|
||||
# Regular expression matching correct module names. Overrides module-naming-
|
||||
# style. If left empty, module names will be checked with the set naming style.
|
||||
#module-rgx=
|
||||
|
||||
# Colon-delimited sets of names that determine each other's naming style when
|
||||
# the name regexes allow several styles.
|
||||
name-group=
|
||||
|
||||
# Regular expression which should only match function or class names that do
|
||||
# not require a docstring.
|
||||
no-docstring-rgx=^_
|
||||
|
||||
# List of decorators that produce properties, such as abc.abstractproperty. Add
|
||||
# to this list to register other decorators that produce valid properties.
|
||||
# These decorators are taken in consideration only for invalid-name.
|
||||
property-classes=abc.abstractproperty
|
||||
|
||||
# Regular expression matching correct type alias names. If left empty, type
|
||||
# alias names will be checked with the set naming style.
|
||||
#typealias-rgx=
|
||||
|
||||
# Regular expression matching correct type variable names. If left empty, type
|
||||
# variable names will be checked with the set naming style.
|
||||
#typevar-rgx=
|
||||
|
||||
# Naming style matching correct variable names.
|
||||
variable-naming-style=snake_case
|
||||
|
||||
# Regular expression matching correct variable names. Overrides variable-
|
||||
# naming-style. If left empty, variable names will be checked with the set
|
||||
# naming style.
|
||||
#variable-rgx=
|
||||
|
||||
|
||||
[CLASSES]
|
||||
|
||||
# Warn about protected attribute access inside special methods
|
||||
check-protected-access-in-special-methods=no
|
||||
|
||||
# List of method names used to declare (i.e. assign) instance attributes.
|
||||
defining-attr-methods=__init__,
|
||||
__new__,
|
||||
setUp,
|
||||
asyncSetUp,
|
||||
__post_init__
|
||||
|
||||
# List of member names, which should be excluded from the protected access
|
||||
# warning.
|
||||
exclude-protected=_asdict,_fields,_replace,_source,_make,os._exit
|
||||
|
||||
# List of valid names for the first argument in a class method.
|
||||
valid-classmethod-first-arg=cls
|
||||
|
||||
# List of valid names for the first argument in a metaclass class method.
|
||||
valid-metaclass-classmethod-first-arg=mcs
|
||||
|
||||
|
||||
[DESIGN]
|
||||
|
||||
# List of regular expressions of class ancestor names to ignore when counting
|
||||
# public methods (see R0903)
|
||||
exclude-too-few-public-methods=
|
||||
|
||||
# List of qualified class names to ignore when counting class parents (see
|
||||
# R0901)
|
||||
ignored-parents=
|
||||
|
||||
# Maximum number of arguments for function / method.
|
||||
max-args=8
|
||||
|
||||
# Maximum number of attributes for a class (see R0902).
|
||||
max-attributes=10
|
||||
|
||||
# Maximum number of boolean expressions in an if statement (see R0916).
|
||||
max-bool-expr=5
|
||||
|
||||
# Maximum number of branch for function / method body.
|
||||
max-branches=12
|
||||
|
||||
# Maximum number of locals for function / method body.
|
||||
max-locals=15
|
||||
|
||||
# Maximum number of parents for a class (see R0901).
|
||||
max-parents=7
|
||||
|
||||
# Maximum number of public methods for a class (see R0904).
|
||||
max-public-methods=20
|
||||
|
||||
# Maximum number of return / yield for function / method body.
|
||||
max-returns=6
|
||||
|
||||
# Maximum number of statements in function / method body.
|
||||
max-statements=50
|
||||
|
||||
# Minimum number of public methods for a class (see R0903).
|
||||
min-public-methods=2
|
||||
|
||||
|
||||
[EXCEPTIONS]
|
||||
|
||||
# Exceptions that will emit a warning when caught.
|
||||
overgeneral-exceptions=builtins.BaseException,builtins.Exception
|
||||
|
||||
|
||||
[FORMAT]
|
||||
|
||||
# Expected format of line ending, e.g. empty (any line ending), LF or CRLF.
|
||||
expected-line-ending-format=
|
||||
|
||||
# Regexp for a line that is allowed to be longer than the limit.
|
||||
ignore-long-lines=^\s*(# )?<?https?://\S+>?$
|
||||
|
||||
# Number of spaces of indent required inside a hanging or continued line.
|
||||
indent-after-paren=4
|
||||
|
||||
# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1
|
||||
# tab).
|
||||
indent-string=' '
|
||||
|
||||
# Maximum number of characters on a single line.
|
||||
max-line-length=120
|
||||
|
||||
# Maximum number of lines in a module.
|
||||
max-module-lines=1000
|
||||
|
||||
# Allow the body of a class to be on the same line as the declaration if body
|
||||
# contains single statement.
|
||||
single-line-class-stmt=no
|
||||
|
||||
# Allow the body of an if to be on the same line as the test if there is no
|
||||
# else.
|
||||
single-line-if-stmt=no
|
||||
|
||||
|
||||
[IMPORTS]
|
||||
|
||||
# List of modules that can be imported at any level, not just the top level
|
||||
# one.
|
||||
allow-any-import-level=
|
||||
|
||||
# Allow explicit reexports by alias from a package __init__.
|
||||
allow-reexport-from-package=no
|
||||
|
||||
# Allow wildcard imports from modules that define __all__.
|
||||
allow-wildcard-with-all=no
|
||||
|
||||
# Deprecated modules which should not be used, separated by a comma.
|
||||
deprecated-modules=
|
||||
|
||||
# Output a graph (.gv or any supported image format) of external dependencies
|
||||
# to the given file (report RP0402 must not be disabled).
|
||||
ext-import-graph=
|
||||
|
||||
# Output a graph (.gv or any supported image format) of all (i.e. internal and
|
||||
# external) dependencies to the given file (report RP0402 must not be
|
||||
# disabled).
|
||||
import-graph=
|
||||
|
||||
# Output a graph (.gv or any supported image format) of internal dependencies
|
||||
# to the given file (report RP0402 must not be disabled).
|
||||
int-import-graph=
|
||||
|
||||
# Force import order to recognize a module as part of the standard
|
||||
# compatibility libraries.
|
||||
known-standard-library=
|
||||
|
||||
# Force import order to recognize a module as part of a third party library.
|
||||
known-third-party=enchant
|
||||
|
||||
# Couples of modules and preferred modules, separated by a comma.
|
||||
preferred-modules=
|
||||
|
||||
|
||||
[LOGGING]
|
||||
|
||||
# The type of string formatting that logging methods do. `old` means using %
|
||||
# formatting, `new` is for `{}` formatting.
|
||||
logging-format-style=old
|
||||
|
||||
# Logging modules to check that the string format arguments are in logging
|
||||
# function parameter format.
|
||||
logging-modules=logging
|
||||
|
||||
|
||||
[MESSAGES CONTROL]
|
||||
|
||||
# Only show warnings with the listed confidence levels. Leave empty to show
|
||||
# all. Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE,
|
||||
# UNDEFINED.
|
||||
confidence=HIGH,
|
||||
CONTROL_FLOW,
|
||||
INFERENCE,
|
||||
INFERENCE_FAILURE,
|
||||
UNDEFINED
|
||||
|
||||
# Disable the message, report, category or checker with the given id(s). You
|
||||
# can either give multiple identifiers separated by comma (,) or put this
|
||||
# option multiple times (only on the command line, not in the configuration
|
||||
# file where it should appear only once). You can also use "--disable=all" to
|
||||
# disable everything first and then re-enable specific checks. For example, if
|
||||
# you want to run only the similarities checker, you can use "--disable=all
|
||||
# --enable=similarities". If you want to run only the classes checker, but have
|
||||
# no Warning level messages displayed, use "--disable=all --enable=classes
|
||||
# --disable=W".
|
||||
disable=raw-checker-failed,
|
||||
bad-inline-option,
|
||||
locally-disabled,
|
||||
file-ignored,
|
||||
suppressed-message,
|
||||
useless-suppression,
|
||||
deprecated-pragma,
|
||||
use-symbolic-message-instead,
|
||||
too-few-public-methods,
|
||||
missing-module-docstring,
|
||||
logging-fstring-interpolation,
|
||||
|
||||
|
||||
# Enable the message, report, category or checker with the given id(s). You can
|
||||
# either give multiple identifier separated by comma (,) or put this option
|
||||
# multiple time (only on the command line, not in the configuration file where
|
||||
# it should appear only once). See also the "--disable" option for examples.
|
||||
enable=c-extension-no-member
|
||||
|
||||
|
||||
[METHOD_ARGS]
|
||||
|
||||
# List of qualified names (i.e., library.method) which require a timeout
|
||||
# parameter e.g. 'requests.api.get,requests.api.post'
|
||||
timeout-methods=requests.api.delete,requests.api.get,requests.api.head,requests.api.options,requests.api.patch,requests.api.post,requests.api.put,requests.api.request
|
||||
|
||||
|
||||
[MISCELLANEOUS]
|
||||
|
||||
# List of note tags to take in consideration, separated by a comma.
|
||||
notes=FIXME,
|
||||
XXX,
|
||||
TODO
|
||||
|
||||
# Regular expression of note tags to take in consideration.
|
||||
notes-rgx=
|
||||
|
||||
|
||||
[REFACTORING]
|
||||
|
||||
# Maximum number of nested blocks for function / method body
|
||||
max-nested-blocks=5
|
||||
|
||||
# Complete name of functions that never returns. When checking for
|
||||
# inconsistent-return-statements if a never returning function is called then
|
||||
# it will be considered as an explicit return statement and no message will be
|
||||
# printed.
|
||||
never-returning-functions=sys.exit,argparse.parse_error
|
||||
|
||||
|
||||
[REPORTS]
|
||||
|
||||
# Python expression which should return a score less than or equal to 10. You
|
||||
# have access to the variables 'fatal', 'error', 'warning', 'refactor',
|
||||
# 'convention', and 'info' which contain the number of messages in each
|
||||
# category, as well as 'statement' which is the total number of statements
|
||||
# analyzed. This score is used by the global evaluation report (RP0004).
|
||||
evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10))
|
||||
|
||||
# Template used to display messages. This is a python new-style format string
|
||||
# used to format the message information. See doc for all details.
|
||||
msg-template=
|
||||
|
||||
# Set the output format. Available formats are text, parseable, colorized, json
|
||||
# and msvs (visual studio). You can also give a reporter class, e.g.
|
||||
# mypackage.mymodule.MyReporterClass.
|
||||
#output-format=
|
||||
|
||||
# Tells whether to display a full report or only the messages.
|
||||
reports=no
|
||||
|
||||
# Activate the evaluation score.
|
||||
score=yes
|
||||
|
||||
|
||||
[SIMILARITIES]
|
||||
|
||||
# Comments are removed from the similarity computation
|
||||
ignore-comments=yes
|
||||
|
||||
# Docstrings are removed from the similarity computation
|
||||
ignore-docstrings=yes
|
||||
|
||||
# Imports are removed from the similarity computation
|
||||
ignore-imports=yes
|
||||
|
||||
# Signatures are removed from the similarity computation
|
||||
ignore-signatures=yes
|
||||
|
||||
# Minimum lines number of a similarity.
|
||||
min-similarity-lines=4
|
||||
|
||||
|
||||
[SPELLING]
|
||||
|
||||
# Limits count of emitted suggestions for spelling mistakes.
|
||||
max-spelling-suggestions=4
|
||||
|
||||
# Spelling dictionary name. No available dictionaries : You need to install
|
||||
# both the python package and the system dependency for enchant to work..
|
||||
spelling-dict=
|
||||
|
||||
# List of comma separated words that should be considered directives if they
|
||||
# appear at the beginning of a comment and should not be checked.
|
||||
spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy:
|
||||
|
||||
# List of comma separated words that should not be checked.
|
||||
spelling-ignore-words=
|
||||
|
||||
# A path to a file that contains the private dictionary; one word per line.
|
||||
spelling-private-dict-file=
|
||||
|
||||
# Tells whether to store unknown words to the private dictionary (see the
|
||||
# --spelling-private-dict-file option) instead of raising a message.
|
||||
spelling-store-unknown-words=no
|
||||
|
||||
|
||||
[STRING]
|
||||
|
||||
# This flag controls whether inconsistent-quotes generates a warning when the
|
||||
# character used as a quote delimiter is used inconsistently within a module.
|
||||
check-quote-consistency=no
|
||||
|
||||
# This flag controls whether the implicit-str-concat should generate a warning
|
||||
# on implicit string concatenation in sequences defined over several lines.
|
||||
check-str-concat-over-line-jumps=no
|
||||
|
||||
|
||||
[TYPECHECK]
|
||||
|
||||
# List of decorators that produce context managers, such as
|
||||
# contextlib.contextmanager. Add to this list to register other decorators that
|
||||
# produce valid context managers.
|
||||
contextmanager-decorators=contextlib.contextmanager
|
||||
|
||||
# List of members which are set dynamically and missed by pylint inference
|
||||
# system, and so shouldn't trigger E1101 when accessed. Python regular
|
||||
# expressions are accepted.
|
||||
generated-members=
|
||||
|
||||
# Tells whether to warn about missing members when the owner of the attribute
|
||||
# is inferred to be None.
|
||||
ignore-none=yes
|
||||
|
||||
# This flag controls whether pylint should warn about no-member and similar
|
||||
# checks whenever an opaque object is returned when inferring. The inference
|
||||
# can return multiple potential results while evaluating a Python object, but
|
||||
# some branches might not be evaluated, which results in partial inference. In
|
||||
# that case, it might be useful to still emit no-member and other checks for
|
||||
# the rest of the inferred objects.
|
||||
ignore-on-opaque-inference=yes
|
||||
|
||||
# List of symbolic message names to ignore for Mixin members.
|
||||
ignored-checks-for-mixins=no-member,
|
||||
not-async-context-manager,
|
||||
not-context-manager,
|
||||
attribute-defined-outside-init
|
||||
|
||||
# List of class names for which member attributes should not be checked (useful
|
||||
# for classes with dynamically set attributes). This supports the use of
|
||||
# qualified names.
|
||||
ignored-classes=optparse.Values,thread._local,_thread._local,argparse.Namespace
|
||||
|
||||
# Show a hint with possible names when a member name was not found. The aspect
|
||||
# of finding the hint is based on edit distance.
|
||||
missing-member-hint=yes
|
||||
|
||||
# The minimum edit distance a name should have in order to be considered a
|
||||
# similar match for a missing member name.
|
||||
missing-member-hint-distance=1
|
||||
|
||||
# The total number of similar names that should be taken in consideration when
|
||||
# showing a hint for a missing member.
|
||||
missing-member-max-choices=1
|
||||
|
||||
# Regex pattern to define which classes are considered mixins.
|
||||
mixin-class-rgx=.*[Mm]ixin
|
||||
|
||||
# List of decorators that change the signature of a decorated function.
|
||||
signature-mutators=
|
||||
|
||||
|
||||
[VARIABLES]
|
||||
|
||||
# List of additional names supposed to be defined in builtins. Remember that
|
||||
# you should avoid defining new builtins when possible.
|
||||
additional-builtins=
|
||||
|
||||
# Tells whether unused global variables should be treated as a violation.
|
||||
allow-global-unused-variables=yes
|
||||
|
||||
# List of names allowed to shadow builtins
|
||||
allowed-redefined-builtins=
|
||||
|
||||
# List of strings which can identify a callback function by name. A callback
|
||||
# name must start or end with one of those strings.
|
||||
callbacks=cb_,
|
||||
_cb
|
||||
|
||||
# A regular expression matching the name of dummy variables (i.e. expected to
|
||||
# not be used).
|
||||
dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_
|
||||
|
||||
# Argument names that match this expression will be ignored.
|
||||
ignored-argument-names=_.*|^ignored_|^unused_
|
||||
|
||||
# Tells whether we should check for unused import in __init__ files.
|
||||
init-import=no
|
||||
|
||||
# List of qualified module names which can have objects that can redefine
|
||||
# builtins.
|
||||
redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io
|
||||
32
tools/shapeshifter-library-python-main/.readthedocs.yml
Normal file
32
tools/shapeshifter-library-python-main/.readthedocs.yml
Normal file
@@ -0,0 +1,32 @@
|
||||
# .readthedocs.yaml
|
||||
# Read the Docs configuration file
|
||||
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
|
||||
|
||||
# Required
|
||||
version: 2
|
||||
|
||||
# Set the OS, Python version and other tools you might need
|
||||
build:
|
||||
os: ubuntu-22.04
|
||||
tools:
|
||||
python: "3.10"
|
||||
# You can also specify other tool versions:
|
||||
# nodejs: "19"
|
||||
# rust: "1.64"
|
||||
# golang: "1.19"
|
||||
|
||||
# Build documentation in the "docs/" directory with Sphinx
|
||||
sphinx:
|
||||
configuration: docs/source/conf.py
|
||||
|
||||
# Optionally build your docs in additional formats such as PDF and ePub
|
||||
# formats:
|
||||
# - pdf
|
||||
# - epub
|
||||
|
||||
# Optional but recommended, declare the Python requirements required
|
||||
# to build your documentation
|
||||
# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html
|
||||
python:
|
||||
install:
|
||||
- requirements: docs/requirements.txt
|
||||
26
tools/shapeshifter-library-python-main/CHANGELOG.md
Normal file
26
tools/shapeshifter-library-python-main/CHANGELOG.md
Normal file
@@ -0,0 +1,26 @@
|
||||
Changelog
|
||||
---------
|
||||
- v2.2.0 (2026-02-11)
|
||||
- Updated dependencies and supported python to version 3.14
|
||||
- v2.1.2 (2026-02-10)
|
||||
- Explicit formatting for XML Dates
|
||||
- v2.1.1 (2025-09-07)
|
||||
- Fixed publication of the project readme to PyPI
|
||||
- v2.1.0 (2025-09-07)
|
||||
- Support for Shapeshifter 3.1.0
|
||||
- v2.0.1 (2025-07-08)
|
||||
- Bumped fastapi-xml depedency version
|
||||
- v2.0.0 (2025-07-08)
|
||||
- Support for OAuth2 on outgoing messages
|
||||
- Updated depedencies
|
||||
- v1.2.0 (2024-04-04)
|
||||
- Upgrade to latest FastAPI and Pydantic
|
||||
- v1.1.2 (2024-03-12)
|
||||
- Pinned depedencies after a breaking update to fastapi-xml was released
|
||||
- v1.1.0 (2023-08-30)
|
||||
- Use the published 3.0.0 spec for XSD validation and objects
|
||||
- v1.0.1 (2023-08-23)
|
||||
- Fixed outgoing signed message base 64 encoding
|
||||
- Add support for empty response messages
|
||||
- v1.0.0 (2023-07-20)
|
||||
- Initial release version
|
||||
201
tools/shapeshifter-library-python-main/LICENSE
Normal file
201
tools/shapeshifter-library-python-main/LICENSE
Normal file
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2026 Enexis Groep
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
233
tools/shapeshifter-library-python-main/README.rst
Normal file
233
tools/shapeshifter-library-python-main/README.rst
Normal file
@@ -0,0 +1,233 @@
|
||||
Shapeshifter library for Python
|
||||
===============================
|
||||
|
||||
This is a Python implementation of the ShapeShifter UFTP protocol.
|
||||
|
||||
Overview
|
||||
--------
|
||||
|
||||
This library implements the full UFTP protocol that you can use for Shapeshifter communications. It implements all three roles: Distribution System Operator (**DSO**), Aggregator (**AGR**) and Common Reference Operator (**CRO**) in both directions (client and service).
|
||||
|
||||
Features of this package:
|
||||
|
||||
- Building, parsing and validation of the XML messages
|
||||
- Signing and verifying of the XML messages using signatures
|
||||
- DNS for service discovery and key retrieval
|
||||
- Convenient clients for each role-pair
|
||||
- Convenient services for each role
|
||||
- JSON-serializable dataclasses for easy transport to other systems
|
||||
- Fully internal queing system for full-duplex communication with minimal user code required
|
||||
- Compatible with version 3.0.0 and 3.1.0 of the Shapeshifter protocol.
|
||||
|
||||
|
||||
Installation
|
||||
------------
|
||||
|
||||
.. code-block:: python3
|
||||
|
||||
pip install shapeshifter-uftp
|
||||
|
||||
Running tests
|
||||
-------------
|
||||
|
||||
If you want to develop shapeshifter-uftp, you can fork or clone this repository and run the tests:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
$ pip install .
|
||||
$ pip install .[dev]
|
||||
$ pytest .
|
||||
|
||||
|
||||
Getting Started
|
||||
---------------
|
||||
|
||||
Shapehifter always requires the use of a Client and a Service, because all responses are asynchronous.
|
||||
|
||||
You choose the server class based on your role in the Shapeshifter conversation. If you are an Aggregator (also known as a CSP), you can use this setup:
|
||||
|
||||
.. code-block:: python3
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from shapeshifter_uftp import ShapeshifterAgrService
|
||||
from shapeshifter_uftp.uftp import (FlexOffer, FlexOfferOption,
|
||||
FlexOfferOptionISP, FlexRequest,
|
||||
FlexRequestResponse, FlexOrder, FlexOrderResponse,
|
||||
AcceptedRejected)
|
||||
from xsdata.models.datatype import XmlDate
|
||||
|
||||
|
||||
class DemoAggregator(ShapeshifterAgrService):
|
||||
"""
|
||||
Aggregator service that implements callbacks for
|
||||
each of the messages that can be received.
|
||||
"""
|
||||
|
||||
def process_agr_portfolio_query_response(self, message):
|
||||
print(f"Received a message: {message}")
|
||||
|
||||
def process_agr_portfolio_update_response(self, message):
|
||||
print(f"Received a message: {message}")
|
||||
|
||||
def process_d_prognosis_response(self, message):
|
||||
print(f"Received a message: {message}")
|
||||
|
||||
def process_flex_request(self, message: FlexRequest):
|
||||
print(f"Received a message: {message}")
|
||||
|
||||
# Example of how to send a new message after
|
||||
# processing an incoming message.
|
||||
dso_client = self.dso_client(message.sender_domain)
|
||||
|
||||
# Send the FlexRequestResponse
|
||||
dso_client.send_flex_request_response(
|
||||
FlexRequestResponse(
|
||||
flex_request_message_id=message.message_id,
|
||||
conversation_id=message.conversation_id,
|
||||
result=AcceptedRejected.ACCEPTED
|
||||
)
|
||||
)
|
||||
|
||||
# Send the FlexOffer
|
||||
dso_client.send_flex_offer(
|
||||
FlexOffer(
|
||||
flex_request_message_id=message.message_id,
|
||||
conversation_id=message.conversation_id,
|
||||
isp_duration="PT15M",
|
||||
period=XmlDate(2023, 1, 1),
|
||||
congestion_point="ean.123456789012",
|
||||
expiration_date_time=datetime.now(timezone.utc).isoformat(),
|
||||
offer_options=[
|
||||
FlexOfferOption(
|
||||
isps=[FlexOfferOptionISP(power=1, start=1, duration=1)],
|
||||
option_reference="MyOption",
|
||||
price=2.30,
|
||||
min_activation_factor=0.5,
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
def process_flex_offer_response(self, message: FlexOffer):
|
||||
print(f"Received a message: {message}")
|
||||
|
||||
def process_flex_offer_revocation_response(self, message):
|
||||
print(f"Received a message: {message}")
|
||||
|
||||
def process_flex_order(self, message: FlexOrder):
|
||||
print(f"Received a message: {message}")
|
||||
|
||||
dso_client = self.dso_client(message.sender_domain)
|
||||
dso_client.send_flex_order_response(
|
||||
FlexOrderResponse(
|
||||
flex_order_message_id=message.message_id,
|
||||
conversation_id=message.conversation_id,
|
||||
result=AcceptedRejected.ACCEPTED
|
||||
)
|
||||
)
|
||||
|
||||
def process_flex_reservation_update(self, message):
|
||||
print(f"Received a message: {message}")
|
||||
|
||||
def process_flex_settlement(self, message):
|
||||
print(f"Received a message: {message}")
|
||||
|
||||
def process_metering_response(self, message):
|
||||
print(f"Received a message: {message}")
|
||||
|
||||
|
||||
def key_lookup(sender_domain, sender_role):
|
||||
"""
|
||||
Lookup function for public keys, so that incoming
|
||||
messages can be verified.
|
||||
"""
|
||||
known_senders = {
|
||||
("dso.demo", "DSO"): "NsTbq/iABU6tbsjriBg/Z5dSfQstulD0GpMI2fLDWec=",
|
||||
("cro.demo", "CRO"): "ySUYU87usErRFKGJafwvVDLGhnBVJCCNYfQvmwv8ObM=",
|
||||
}
|
||||
return known_senders.get((sender_domain, sender_role))
|
||||
|
||||
|
||||
def endpoint_lookup(sender_domain, sender_role):
|
||||
"""
|
||||
Lookup function for endpoints, so that the service
|
||||
knowns where to send responses to.
|
||||
"""
|
||||
known_senders = {
|
||||
("dso.demo", "DSO"): "http://localhost:8081/shapeshifter/api/v3/message",
|
||||
("cro.demo", "CRO"): "http://localhost:8082/shapeshifter/api/v3/message",
|
||||
}
|
||||
return known_senders.get((sender_domain, sender_role))
|
||||
|
||||
aggregator = DemoAggregator(
|
||||
sender_domain="aggregator.demo",
|
||||
signing_key="mz5XYCNKxpx48K+9oipUhsjBZed3L7rTVKLsWmG1HOqRLIeuGpIa1KAt6AlbVGqJvewd8v1J0uVUTqpGt7F8tw==",
|
||||
key_lookup_function=key_lookup,
|
||||
endpoint_lookup_function=endpoint_lookup,
|
||||
port=8080,
|
||||
)
|
||||
|
||||
# Start the Aggregator Service
|
||||
aggregator.run_in_thread()
|
||||
|
||||
# Create a client object to talk to a DSO
|
||||
dso_client = aggregator.dso_client("dso.demo")
|
||||
|
||||
# Create a Flex Offer Message
|
||||
flex_offer_message = FlexOffer(
|
||||
isp_duration="PT15M",
|
||||
period=XmlDate(2023, 1, 1),
|
||||
congestion_point="ean.123456789012",
|
||||
expiration_date_time=datetime.now(timezone.utc).isoformat(),
|
||||
flex_request_message_id=str(uuid4())
|
||||
offer_options=[
|
||||
FlexOfferOption(
|
||||
isps=[FlexOfferOptionISP(power=1, start=1, duration=1)],
|
||||
option_reference="MyOption",
|
||||
price=2.30,
|
||||
min_activation_factor=0.5,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
# As a demo, press enter to send another FlexOffer message to the DSO.
|
||||
while True:
|
||||
try:
|
||||
input("Press return to send a FlexOffer message to the DSO")
|
||||
response = dso_client.send_flex_offer(flex_offer_message)
|
||||
print(f"Response was: {response}")
|
||||
except:
|
||||
aggregator.stop()
|
||||
break
|
||||
|
||||
Using OAuth in outgoing requests
|
||||
--------------------------------
|
||||
|
||||
To use OAuth in outgoing requests, you can use the provided OAuthClient class. To use it in a bare Shapeshifter client:
|
||||
|
||||
.. code-block:: python3
|
||||
|
||||
from shapeshifter_uftp import ShapeshifterAgrDsoClient, OAuthClient
|
||||
|
||||
oauth_client = OAuthClient(
|
||||
url="https://oauth.provider.url",
|
||||
client_id="my-client-id",
|
||||
client_secret="my-client-secret"
|
||||
)
|
||||
|
||||
client = ShapeshifterAgrDsoClient(
|
||||
sender_domain="my.aggregator.domain",
|
||||
signing_key="abcdef",
|
||||
recipient_domain="some.dso",
|
||||
recipient_endpoint="https://some.dso.endpoint/shapeshifter/api/v3/message",
|
||||
recipient_signing_key="123456",
|
||||
oauth_client=oauth_client,
|
||||
)
|
||||
|
||||
# If you use any of the sending methods, the oauth client will
|
||||
# make sure you're authenticated.
|
||||
client.send_flex_request_response(...)
|
||||
|
||||
|
||||
Similarly, if you have a Service instance that dynamically needs to retrieve the OAuth information for each different recipient server, you can provide an ``oauth_lookup_function`` that takes a ``(sender_domain, sender_role)`` and returns an instance of OAuthClient.
|
||||
20
tools/shapeshifter-library-python-main/docs/Makefile
Normal file
20
tools/shapeshifter-library-python-main/docs/Makefile
Normal file
@@ -0,0 +1,20 @@
|
||||
# Minimal makefile for Sphinx documentation
|
||||
#
|
||||
|
||||
# You can set these variables from the command line, and also
|
||||
# from the environment for the first two.
|
||||
SPHINXOPTS ?=
|
||||
SPHINXBUILD ?= sphinx-build
|
||||
SOURCEDIR = source
|
||||
BUILDDIR = build
|
||||
|
||||
# Put it first so that "make" without argument is like "make help".
|
||||
help:
|
||||
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
||||
|
||||
.PHONY: help Makefile
|
||||
|
||||
# Catch-all target: route all unknown targets to Sphinx using the new
|
||||
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
|
||||
%: Makefile
|
||||
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
||||
35
tools/shapeshifter-library-python-main/docs/make.bat
Normal file
35
tools/shapeshifter-library-python-main/docs/make.bat
Normal file
@@ -0,0 +1,35 @@
|
||||
@ECHO OFF
|
||||
|
||||
pushd %~dp0
|
||||
|
||||
REM Command file for Sphinx documentation
|
||||
|
||||
if "%SPHINXBUILD%" == "" (
|
||||
set SPHINXBUILD=sphinx-build
|
||||
)
|
||||
set SOURCEDIR=source
|
||||
set BUILDDIR=build
|
||||
|
||||
%SPHINXBUILD% >NUL 2>NUL
|
||||
if errorlevel 9009 (
|
||||
echo.
|
||||
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
|
||||
echo.installed, then set the SPHINXBUILD environment variable to point
|
||||
echo.to the full path of the 'sphinx-build' executable. Alternatively you
|
||||
echo.may add the Sphinx directory to PATH.
|
||||
echo.
|
||||
echo.If you don't have Sphinx installed, grab it from
|
||||
echo.https://www.sphinx-doc.org/
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if "%1" == "" goto help
|
||||
|
||||
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
|
||||
goto end
|
||||
|
||||
:help
|
||||
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
|
||||
|
||||
:end
|
||||
popd
|
||||
@@ -0,0 +1 @@
|
||||
shapeshifter-uftp
|
||||
@@ -0,0 +1,7 @@
|
||||
Client API Reference
|
||||
====================
|
||||
|
||||
You can use any of the four prepared clients to communicate to services of the other parties.
|
||||
|
||||
.. automodule:: shapeshifter_uftp.client
|
||||
:exclude-members: ShapeshifterClient
|
||||
@@ -0,0 +1,13 @@
|
||||
API Reference
|
||||
=============
|
||||
|
||||
This is the low-level API reference.
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
:caption: API Reference:
|
||||
|
||||
clients
|
||||
services
|
||||
types
|
||||
transport
|
||||
@@ -0,0 +1,8 @@
|
||||
Service API Reference
|
||||
=====================
|
||||
|
||||
The servers listen for request from other clients.
|
||||
|
||||
.. automodule:: shapeshifter_uftp.service.agr_service
|
||||
.. automodule:: shapeshifter_uftp.service.dso_service
|
||||
.. automodule:: shapeshifter_uftp.service.cro_service
|
||||
@@ -0,0 +1,7 @@
|
||||
Transport API Reference
|
||||
=======================
|
||||
|
||||
Low-level transport details for sealing and unsealing messages
|
||||
|
||||
.. automodule:: shapeshifter_uftp.transport
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
Types API Reference
|
||||
===================
|
||||
|
||||
These objects correspond directly to the UFTP objects that are sent across the wire. You will encounter these in the messages and response when using Shapeshifter UFTP.
|
||||
|
||||
.. automodule:: shapeshifter_uftp.uftp.common
|
||||
.. automodule:: shapeshifter_uftp.uftp.agr_cro
|
||||
.. automodule:: shapeshifter_uftp.uftp.agr_dso
|
||||
.. automodule:: shapeshifter_uftp.uftp.cro_dso
|
||||
.. automodule:: shapeshifter_uftp.uftp.metering
|
||||
44
tools/shapeshifter-library-python-main/docs/source/cli.rst
Normal file
44
tools/shapeshifter-library-python-main/docs/source/cli.rst
Normal file
@@ -0,0 +1,44 @@
|
||||
CLI (Command Line Interface)
|
||||
============================
|
||||
|
||||
These utilities are provided to developers to speed up the development process.
|
||||
|
||||
|
||||
Create a new keypair
|
||||
--------------------
|
||||
|
||||
You can generate a signing keypair using the shapeshifter-keypair command line tool:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
$ shapeshifter-keypair
|
||||
Private key (base64): Wyg81Lki5Ib4YkEFtqfkR6edTFQywjXoZybtBQLbNJbOz+ZRPsx4RptptEDEd9Pn4UE/RWuYP/gmlbYX8Kgr8g==
|
||||
Public key (base64): zs/mUT7MeEababRAxHfT5+FBP0VrmD/4JpW2F/CoK/I=
|
||||
|
||||
|
||||
Perform a DNS-lookup for a party's endpoints and keys
|
||||
-----------------------------------------------------
|
||||
|
||||
The UFTP specification deals a way of publishing service discovery information over DNS. This CLI tool will quicly look up the well-known DNS endpoints and tell you the information that is available:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
$ shapeshifter-lookup -d enexis.dev -r dso
|
||||
--------------------------------------------------------------------------------
|
||||
Shapeshifer version: 3.0.0
|
||||
Endpoint URL: https://shapeshifter-dso.enexis.dev/shapeshifter/api/v3/message
|
||||
Signing key: zs/mUT7MeEababRAxHfT5+FBP0VrmD/4JpW2F/CoK/I=
|
||||
Decryption Key: dW+UWxFrGVE1eu1OkPSMl+qXT5/rwKzVSRoU0XSJ0RY=
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
|
||||
If no information is found, it will tell you the DNS names it was looking at, to make it easier to publish your own DNS records:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
$ shapeshifter-lookup --domain example.com --role dso
|
||||
--------------------------------------------------------------------------------
|
||||
Could not retrieve version at _usef.example.com: DNS name not found
|
||||
Could not retrieve endpoint at _http._dso._usef.example.com: DNS name not found.
|
||||
Could not retrieve public keys at _dso._usef.example.com: DNS name not found.
|
||||
--------------------------------------------------------------------------------
|
||||
136
tools/shapeshifter-library-python-main/docs/source/concepts.rst
Normal file
136
tools/shapeshifter-library-python-main/docs/source/concepts.rst
Normal file
@@ -0,0 +1,136 @@
|
||||
Concepts
|
||||
========
|
||||
|
||||
Communication Structure
|
||||
-----------------------
|
||||
|
||||
This library provides all the parts you need to build a Shapeshifter-compliant participant.
|
||||
|
||||
Each request is a subclass of :code:`PayloadMessage`. The python library performs some checks on the validity of the message, and responds with an appropriate HTTP status code. If the message was valid, it is then handed to one of your functions, so that you can send the response to this message.
|
||||
|
||||
After receiving the message, the receiving party usually wants to send an actual response of some kind. For instance, a :code:`FlexRequest` message from the Distribution System Operator DSO might be replied to using a :code:`FlexOffer` message from the Aggregator (AGR). In shapeshifter-uftp this is called the **processing** step, and happens separately from the request context. A typical post-processing step might look like this:
|
||||
|
||||
.. code-block:: python3
|
||||
|
||||
from shapeshifter_uftp import (
|
||||
ShapeshifterAgrService,
|
||||
AcceptedRejected,
|
||||
FlexRequest,
|
||||
FlexOffer,
|
||||
PayloadMessageResponse
|
||||
)
|
||||
|
||||
class MyAggregatorService(ShapeshifterAggregatorService):
|
||||
|
||||
...
|
||||
|
||||
def process_flex_request(self, message: FlexRequest):
|
||||
# Do some work to determine what flexibility we can offer to the DSO
|
||||
available_flex = my_backend.get_available_flexibility(...)
|
||||
|
||||
# Send the FlexOffer message to the DSO
|
||||
with self.dso_client(message.sender_domain) as client:
|
||||
response = client.send_flex_offer(FlexOffer(...))
|
||||
|
||||
...
|
||||
|
||||
This pattern repeats for all the messages that are exchanged between the participants:
|
||||
|
||||
|
||||
**Aggregator (AGR) Service:**
|
||||
|
||||
- Messages from the DSO:
|
||||
- :code:`process_d_prognosis_response`
|
||||
- :code:`process_flex_request`
|
||||
- :code:`process_flex_offer_response`
|
||||
- :code:`process_flex_offer_revocation_response`
|
||||
- :code:`process_flex_order`
|
||||
- :code:`process_flex_reservation_update`
|
||||
- :code:`process_flex_settlement`
|
||||
- :code:`process_metering_response`
|
||||
- Messages from the CRO:
|
||||
- :code:`process_agr_portfolio_query_response`
|
||||
- :code:`process_agr_portfolio_update_response`
|
||||
|
||||
**Common Reference Operator (CRO) Service:**
|
||||
|
||||
- Messages from the Aggregator
|
||||
- :code:`process_agr_portfolio_query`
|
||||
- :code:`process_agr_portfolio_update`
|
||||
- Messages from the DSO
|
||||
- :code:`process_dso_portfolio_query`
|
||||
- :code:`process_dso_portfolio_update`
|
||||
|
||||
|
||||
**Distribution System Operator (DSO) Service:**
|
||||
|
||||
- Messages from the Aggregator:
|
||||
- :code:`process_d_prognosis`
|
||||
- :code:`process_flex_request_response`
|
||||
- :code:`process_flex_offer`
|
||||
- :code:`process_flex_order_response`
|
||||
- :code:`process_flex_offer_revocation`
|
||||
- :code:`process_flex_reservation_update_response`
|
||||
- :code:`process_flex_settlement_response`
|
||||
- :code:`process_metering`
|
||||
- Messages from the CRO:
|
||||
- :code:`process_dso_portfolio_query_response`
|
||||
- :code:`process_dso_portfolio_update_response`
|
||||
|
||||
|
||||
Identification of participants
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Shapeshifter Messages always come inside and envelope called SignedMessage. This envelope contains the following items:
|
||||
- :code:`sender_domain`: the canonical domain name of the sender. This is not a full URL, but merely an identificiation.
|
||||
- :code:`sender_role`: the role of the sender of the message, either :code:`AGR` for Aggregator, :code:`CRO` for Common Reference Operator, or :code:`DSO` for Distribution System Operator.
|
||||
- :code:`body`: a base64-encoded signed message that can be decoded using the sender's public key.
|
||||
|
||||
The recipient of a message will look at the sender_domain and sender_role and determine if they know this party. If they do, they can use some key lookup function to retrieve the public key with which the message can be opened. See: Looking up keys.
|
||||
|
||||
Looking up participant keys
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
When encountering a combination of sender_domain and sender_role, you can look up their public key in two ways:
|
||||
|
||||
- Using DNS: a well-known system of DNS names is specified in the UFTP protocol definition. Shapsehifter-UFTP implements this en encourages you to use it.
|
||||
- USing a custom key-lookup method that takes the sender_role and sender_domain as arguments, and should return the public key in base64 format. This way, you can implement your own lookup function, which might look up the information in your own database, or perform an external API call to the GOPACS Shapeshifter Address Book, for example.
|
||||
|
||||
.. code-block:: python3
|
||||
|
||||
from shapeshifter_uftp.exceptions import AuthenticationTimeoutException
|
||||
|
||||
def key_lookup(sender_domain, sender_role):
|
||||
cursor = database.cursor()
|
||||
cursor.execute("SELECT public_key FROM shapeshifter_participants WHERE sender_role = %s AND sender_domain = %s", (sender_role, sender_domain))
|
||||
if cursor.rowcount == 0:
|
||||
raise AuthenticationTimeoutException()
|
||||
public_key = cursor.fetchone()[0]
|
||||
return public_key
|
||||
|
||||
|
||||
|
||||
Message Schema and Default Values
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
The structure of UFTP messages looks like this:
|
||||
|
||||
- SignedMessage
|
||||
- SenderDomain
|
||||
- SenderRole
|
||||
- Body: a base64-encoded blob that contains the PayloadMessage and the signature.
|
||||
|
||||
These :code:`SignedMessage` s are never exposed to you, the developer, and are taken care of within shapeshifter-uftp.
|
||||
|
||||
What you deal with is the contents of the body of that message, which is always a subclass of :code:`PayloadMessage`.
|
||||
|
||||
Each :code:`PayloadMessage` contains the following default properties:
|
||||
|
||||
- :code:`Version`: the protocol version that this message complies to
|
||||
- :code:`SenderDomain`: the domain of the sending participant
|
||||
- :code:`RecipientDomain`: the demain of the recipient
|
||||
- :code:`TimeStamp`: the timestamp at which the message was created
|
||||
- :code:`MessageID`: a unique identifier for this message
|
||||
- :code:`ConversationID`: an identifier of the conversation this message belongs to
|
||||
|
||||
All of these are required properties, but all of these can be calculated by the framework during message transmission. You as a developer don't need to supply these arguments for each message you create. If you want to override any of these, you can.
|
||||
51
tools/shapeshifter-library-python-main/docs/source/conf.py
Normal file
51
tools/shapeshifter-library-python-main/docs/source/conf.py
Normal file
@@ -0,0 +1,51 @@
|
||||
# Configuration file for the Sphinx documentation builder.
|
||||
#
|
||||
# For the full list of built-in configuration values, see the documentation:
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html
|
||||
|
||||
# -- Project information -----------------------------------------------------
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
cwd = os.path.dirname(__file__)
|
||||
project_root = os.path.join(os.path.dirname(os.path.dirname(cwd)))
|
||||
sys.path.insert(0, project_root)
|
||||
|
||||
import shapeshifter_uftp
|
||||
|
||||
project = "Shapeshifter UFTP"
|
||||
copyright = "2023, Enexis Groep"
|
||||
author = "Stan Janssen"
|
||||
release = "1.0.0"
|
||||
|
||||
# -- General configuration ---------------------------------------------------
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
|
||||
|
||||
extensions = [
|
||||
"sphinx.ext.autodoc",
|
||||
"sphinx.ext.autosummary",
|
||||
"sphinx.ext.napoleon",
|
||||
"sphinx.ext.viewcode",
|
||||
]
|
||||
|
||||
autosummary_generate = True
|
||||
autodoc_default_options = {
|
||||
# The ones below should be optional but work nicely together with
|
||||
# example_package/autodoctest/doc/source/_templates/autosummary/class.rst
|
||||
# and other defaults in sphinx-autodoc.
|
||||
"inherited-members": True,
|
||||
"no-special-members": True,
|
||||
"member-order": "bysource",
|
||||
}
|
||||
|
||||
templates_path = ["_templates"]
|
||||
exclude_patterns = []
|
||||
|
||||
|
||||
# -- Options for HTML output -------------------------------------------------
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
|
||||
|
||||
html_theme = "sphinx_rtd_theme"
|
||||
html_static_path = ["_static"]
|
||||
@@ -0,0 +1,148 @@
|
||||
Examples
|
||||
========
|
||||
|
||||
|
||||
Self-contained Aggregator service and client
|
||||
--------------------------------------------
|
||||
|
||||
.. code-block:: python3
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from shapeshifter_uftp import ShapeshifterAgrService
|
||||
from shapeshifter_uftp.uftp import (FlexOffer, FlexOfferOption,
|
||||
FlexOfferOptionISP, FlexRequest,
|
||||
FlexRequestResponse, FlexOrder, FlexOrderResponse,
|
||||
AcceptedRejected)
|
||||
from xsdata.models.datatype import XmlDate
|
||||
|
||||
|
||||
class DemoAggregator(ShapeshifterAgrService):
|
||||
def process_agr_portfolio_query_response(self, message):
|
||||
print(f"Received a message: {message}")
|
||||
|
||||
def process_agr_portfolio_update_response(self, message):
|
||||
print(f"Received a message: {message}")
|
||||
|
||||
def process_d_prognosis_response(self, message):
|
||||
print(f"Received a message: {message}")
|
||||
|
||||
def process_flex_request(self, message: FlexRequest):
|
||||
print(f"Received a message: {message}")
|
||||
|
||||
# Example of how to send a new message after
|
||||
# processing an incoming message.
|
||||
dso_client = self.dso_client(message.sender_domain)
|
||||
|
||||
# Send the FlexRequestResponse
|
||||
dso_client.send_flex_request_response(
|
||||
FlexRequestResponse(
|
||||
flex_request_message_id=message.message_id,
|
||||
conversation_id=message.conversation_id,
|
||||
result=AcceptedRejected.ACCEPTED
|
||||
)
|
||||
)
|
||||
|
||||
# Send the FlexOffer
|
||||
dso_client.send_flex_offer(
|
||||
FlexOffer(
|
||||
flex_request_message_id=message.message_id,
|
||||
conversation_id=message.conversation_id,
|
||||
isp_duration="PT15M",
|
||||
period=XmlDate(2023, 1, 1),
|
||||
congestion_point="ean.123456789012",
|
||||
expiration_date_time=datetime.now(timezone.utc).isoformat(),
|
||||
offer_options=[
|
||||
FlexOfferOption(
|
||||
isps=[FlexOfferOptionISP(power=1, start=1, duration=1)],
|
||||
option_reference="MyOption",
|
||||
price=2.30,
|
||||
min_activation_factor=0.5,
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
def process_flex_offer_response(self, message: FlexOffer):
|
||||
print(f"Received a message: {message}")
|
||||
|
||||
def process_flex_offer_revocation_response(self, message):
|
||||
print(f"Received a message: {message}")
|
||||
|
||||
def process_flex_order(self, message: FlexOrder):
|
||||
print(f"Received a message: {message}")
|
||||
|
||||
dso_client = self.dso_client(message.sender_domain)
|
||||
dso_client.send_flex_order_response(
|
||||
FlexOrderResponse(
|
||||
flex_order_message_id=message.message_id,
|
||||
conversation_id=message.conversation_id,
|
||||
result=AcceptedRejected.ACCEPTED
|
||||
)
|
||||
)
|
||||
|
||||
def process_flex_reservation_update(self, message):
|
||||
print(f"Received a message: {message}")
|
||||
|
||||
def process_flex_settlement(self, message):
|
||||
print(f"Received a message: {message}")
|
||||
|
||||
def process_metering_response(self, message):
|
||||
print(f"Received a message: {message}")
|
||||
|
||||
|
||||
def key_lookup(sender_domain, sender_role):
|
||||
known_senders = {
|
||||
("dso.demo", "DSO"): "NsTbq/iABU6tbsjriBg/Z5dSfQstulD0GpMI2fLDWec=",
|
||||
("cro.demo", "CRO"): "ySUYU87usErRFKGJafwvVDLGhnBVJCCNYfQvmwv8ObM=",
|
||||
}
|
||||
return known_senders.get((sender_domain, sender_role))
|
||||
|
||||
|
||||
def endpoint_lookup(sender_domain, sender_role):
|
||||
known_senders = {
|
||||
("dso.demo", "DSO"): "http://localhost:8081/shapeshifter/api/v3/message",
|
||||
("cro.demo", "CRO"): "http://localhost:8082/shapeshifter/api/v3/message",
|
||||
}
|
||||
return known_senders.get((sender_domain, sender_role))
|
||||
|
||||
if __name__ == "__main__":
|
||||
aggregator = DemoAggregator(
|
||||
sender_domain="aggregator.demo",
|
||||
signing_key="mz5XYCNKxpx48K+9oipUhsjBZed3L7rTVKLsWmG1HOqRLIeuGpIa1KAt6AlbVGqJvewd8v1J0uVUTqpGt7F8tw==",
|
||||
key_lookup_function=key_lookup,
|
||||
endpoint_lookup_function=endpoint_lookup,
|
||||
port=8080,
|
||||
)
|
||||
|
||||
# Start the Aggregator Service
|
||||
aggregator.run_in_thread()
|
||||
|
||||
# Create a client object to talk to a DSO
|
||||
dso_client = aggregator.dso_client("dso.demo")
|
||||
|
||||
# Create a Flex Offer Message
|
||||
flex_offer_message = FlexOffer(
|
||||
isp_duration="PT15M",
|
||||
period=XmlDate(2023, 1, 1),
|
||||
congestion_point="ean.123456789012",
|
||||
expiration_date_time=datetime.now(timezone.utc).isoformat(),
|
||||
offer_options=[
|
||||
FlexOfferOption(
|
||||
isps=[FlexOfferOptionISP(power=1, start=1, duration=1)],
|
||||
option_reference="MyOption",
|
||||
price=2.30,
|
||||
min_activation_factor=0.5,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
# As a demo, press enter to send another FlexOffer message to the DSO.
|
||||
while True:
|
||||
try:
|
||||
input("Press return to send a FlexOffer message to the DSO")
|
||||
response = dso_client.send_flex_offer(flex_offer_message)
|
||||
print(f"Response was: {response}")
|
||||
except:
|
||||
aggregator.stop()
|
||||
break
|
||||
27
tools/shapeshifter-library-python-main/docs/source/index.rst
Normal file
27
tools/shapeshifter-library-python-main/docs/source/index.rst
Normal file
@@ -0,0 +1,27 @@
|
||||
.. shapeshifter-uftp documentation master file, created by
|
||||
sphinx-quickstart on Wed May 31 15:17:01 2023.
|
||||
You can adapt this file completely to your liking, but it should at least
|
||||
contain the root `toctree` directive.
|
||||
|
||||
Welcome to Shapeshifter UFTP's documentation!
|
||||
=============================================
|
||||
|
||||
This library allows you to quickly build applications that talk to Distribution System Operators, Aggregators and Common Reference Operators as defined in the UFTP Specification.
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
:caption: Contents:
|
||||
|
||||
readme
|
||||
concepts
|
||||
examples/index
|
||||
cli
|
||||
api/index
|
||||
|
||||
|
||||
Indices and tables
|
||||
==================
|
||||
|
||||
* :ref:`genindex`
|
||||
* :ref:`modindex`
|
||||
* :ref:`search`
|
||||
@@ -0,0 +1 @@
|
||||
.. include:: ../../README.rst
|
||||
@@ -0,0 +1,116 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from xsdata.models.datatype import XmlDate
|
||||
|
||||
from shapeshifter_uftp import ShapeshifterAgrService
|
||||
from shapeshifter_uftp.uftp import FlexOffer, FlexOfferOption, FlexOfferOptionISP
|
||||
|
||||
|
||||
class DemoAggregator(ShapeshifterAgrService):
|
||||
def process_agr_portfolio_query_response(self, message):
|
||||
print(f"Received a message: {message}")
|
||||
|
||||
def process_agr_portfolio_update_response(self, message):
|
||||
print(f"Received a message: {message}")
|
||||
|
||||
def process_d_prognosis_response(self, message):
|
||||
print(f"Received a message: {message}")
|
||||
|
||||
def process_flex_offer_response(self, message):
|
||||
print(f"Received a message: {message}")
|
||||
|
||||
def process_flex_offer_revocation_response(self, message):
|
||||
print(f"Received a message: {message}")
|
||||
|
||||
def process_flex_order(self, message):
|
||||
print(f"Received a message: {message}")
|
||||
|
||||
def process_flex_request(self, message):
|
||||
print(f"Received a message: {message}")
|
||||
|
||||
# Example of how to send a new message after
|
||||
# processing an incoming message.
|
||||
dso_client = self.dso_client(message.sender_domain)
|
||||
dso_client.send_flex_offer(
|
||||
FlexOffer(
|
||||
isp_duration="PT15M",
|
||||
period=XmlDate(2023, 1, 1),
|
||||
congestion_point="ean.123456789012",
|
||||
expiration_date_time=datetime.now(timezone.utc).isoformat(),
|
||||
offer_options=[
|
||||
FlexOfferOption(
|
||||
isps=[FlexOfferOptionISP(power=1, start=1, duration=1)],
|
||||
option_reference="MyOption",
|
||||
price=2.30,
|
||||
min_activation_factor=0.5,
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
def process_flex_reservation_update(self, message):
|
||||
print(f"Received a message: {message}")
|
||||
|
||||
def process_flex_settlement(self, message):
|
||||
print(f"Received a message: {message}")
|
||||
|
||||
def process_metering_response(self, message):
|
||||
print(f"Received a message: {message}")
|
||||
|
||||
|
||||
def key_lookup(sender_domain, sender_role):
|
||||
known_senders = {
|
||||
("dso.demo", "DSO"): "NsTbq/iABU6tbsjriBg/Z5dSfQstulD0GpMI2fLDWec=",
|
||||
("cro.demo", "CRO"): "ySUYU87usErRFKGJafwvVDLGhnBVJCCNYfQvmwv8ObM=",
|
||||
}
|
||||
return known_senders.get((sender_domain, sender_role))
|
||||
|
||||
|
||||
def endpoint_lookup(sender_domain, sender_role):
|
||||
known_senders = {
|
||||
("dso.demo", "DSO"): "http://localhost:8081/shapeshifter/api/v3/message",
|
||||
("cro.demo", "CRO"): "http://localhost:8082/shapeshifter/api/v3/message",
|
||||
}
|
||||
return known_senders.get((sender_domain, sender_role))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
aggregator = DemoAggregator(
|
||||
sender_domain="aggregator.demo",
|
||||
signing_key="mz5XYCNKxpx48K+9oipUhsjBZed3L7rTVKLsWmG1HOqRLIeuGpIa1KAt6AlbVGqJvewd8v1J0uVUTqpGt7F8tw==",
|
||||
key_lookup_function=key_lookup,
|
||||
endpoint_lookup_function=endpoint_lookup,
|
||||
port=8080,
|
||||
)
|
||||
|
||||
# Start the Aggregator Service
|
||||
aggregator.run_in_thread()
|
||||
|
||||
# Create a client object to talk to a DSO
|
||||
dso_client = aggregator.dso_client("dso.demo")
|
||||
|
||||
# Create a Flex Offer Message
|
||||
flex_offer_message = FlexOffer(
|
||||
isp_duration="PT15M",
|
||||
period=XmlDate(2023, 1, 1),
|
||||
congestion_point="ean.123456789012",
|
||||
expiration_date_time=datetime.now(timezone.utc).isoformat(),
|
||||
offer_options=[
|
||||
FlexOfferOption(
|
||||
isps=[FlexOfferOptionISP(power=1, start=1, duration=1)],
|
||||
option_reference="MyOption",
|
||||
price=2.30,
|
||||
min_activation_factor=0.5,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
# As a demo, press enter to send another FlexOffer message to the DSO.
|
||||
while True:
|
||||
try:
|
||||
input("Press return to send a FlexOffer message to the DSO")
|
||||
response = dso_client.send_flex_offer(flex_offer_message)
|
||||
print(f"Response was: {response}")
|
||||
except:
|
||||
aggregator.stop()
|
||||
break
|
||||
144
tools/shapeshifter-library-python-main/examples/dso/demo_dso.py
Normal file
144
tools/shapeshifter-library-python-main/examples/dso/demo_dso.py
Normal file
@@ -0,0 +1,144 @@
|
||||
from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
from xsdata.models.datatype import XmlDate
|
||||
|
||||
from shapeshifter_uftp import ShapeshifterDsoService
|
||||
from shapeshifter_uftp.uftp import (
|
||||
AvailableRequested,
|
||||
FlexOrder,
|
||||
FlexOrderISP,
|
||||
FlexRequest,
|
||||
FlexRequestISP,
|
||||
)
|
||||
|
||||
|
||||
class DemoDSO(ShapeshifterDsoService):
|
||||
def process_d_prognosis(self, message):
|
||||
print(f"Received a message: {message}")
|
||||
|
||||
def process_dso_portfolio_query_response(self, message):
|
||||
print(f"Received a message: {message}")
|
||||
|
||||
def process_dso_portfolio_update_response(self, message):
|
||||
print(f"Received a message: {message}")
|
||||
|
||||
def process_flex_offer(self, message):
|
||||
print(f"Received a message: {message}")
|
||||
|
||||
# Example of how to send a new message after
|
||||
# processing an incoming message
|
||||
agr_client = self.agr_client(message.sender_domain)
|
||||
agr_client.send_flex_order(
|
||||
FlexOrder(
|
||||
isp_duration=message.isp_duration,
|
||||
time_zone=message.time_zone,
|
||||
period=message.period,
|
||||
congestion_point=message.congestion_point,
|
||||
flex_offer_message_id=message.message_id,
|
||||
isps=[
|
||||
FlexOrderISP(
|
||||
power=isp.power,
|
||||
start=isp.start,
|
||||
duration=isp.duration,
|
||||
)
|
||||
for isp in message.offer_options[0].isps
|
||||
],
|
||||
price=message.offer_options[0].price,
|
||||
currency="EUR",
|
||||
order_reference="demo-order",
|
||||
activation_factor=1.0
|
||||
)
|
||||
)
|
||||
|
||||
def process_flex_offer_revocation(self, message):
|
||||
print(f"Received a message: {message}")
|
||||
|
||||
def process_flex_order_response(self, message):
|
||||
print(f"Received a message: {message}")
|
||||
|
||||
def process_flex_request_response(self, message):
|
||||
print(f"Received a message: {message}")
|
||||
|
||||
def process_flex_reservation_update_response(self, message):
|
||||
print(f"Received a message: {message}")
|
||||
|
||||
def process_flex_settlement_response(self, message):
|
||||
print(f"Received a message: {message}")
|
||||
|
||||
def process_metering(self, message):
|
||||
print(f"Received a message: {message}")
|
||||
|
||||
|
||||
# The Key Lookup function is used to look up the
|
||||
# (public) signing key of other senders. You either
|
||||
# implement a database-lookup or query to the GOPACS
|
||||
# Shapeshifter Address Book here, or use some other method
|
||||
# to find the appropriate public keys for the other
|
||||
# participants.
|
||||
def key_lookup(sender_domain, sender_role):
|
||||
known_senders = {
|
||||
("aggregator.demo", "AGR"): "kSyHrhqSGtSgLegJW1Rqib3sHfL9SdLlVE6qRrexfLc=",
|
||||
("cro.demo", "CRO"): "ySUYU87usErRFKGJafwvVDLGhnBVJCCNYfQvmwv8ObM=",
|
||||
}
|
||||
return known_senders.get((sender_domain, sender_role))
|
||||
|
||||
|
||||
# The Endpoint Lookup function is used to look up the
|
||||
# endpoint URL for other participants.
|
||||
def endpoint_lookup(sender_domain, sender_role):
|
||||
known_senders = {
|
||||
("aggregator.demo", "AGR"): "http://localhost:8080/shapeshifter/api/v3/message",
|
||||
("cro.demo", "CRO"): "http://localhost:8082/shapeshifter/api/v3/message",
|
||||
}
|
||||
return known_senders.get((sender_domain, sender_role))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
# Create a DemoDSO object that contains all the logic for
|
||||
# responding to messages. In our case, we simply print incoming
|
||||
# messages.
|
||||
dso = DemoDSO(
|
||||
sender_domain="dso.demo",
|
||||
signing_key="OLgpAnYyZmskhCKGmFAj1tysKgGjwehK0msC6NoAg9g2xNur+IAFTq1uyOuIGD9nl1J9Cy26UPQakwjZ8sNZ5w==",
|
||||
key_lookup_function=key_lookup,
|
||||
endpoint_lookup_function=endpoint_lookup,
|
||||
port=8081,
|
||||
)
|
||||
|
||||
# Start the DSO service in a separate thread
|
||||
dso.run_in_thread()
|
||||
|
||||
# Create a client object to talk to an aggregator at the given domain.
|
||||
agr_client = dso.agr_client("aggregator.demo")
|
||||
|
||||
# Prepare a FlexRequest message
|
||||
flex_request_message = FlexRequest(
|
||||
isp_duration="PT15M",
|
||||
period=XmlDate(2023, 1, 1),
|
||||
congestion_point="ean.123456789012",
|
||||
isps=[
|
||||
FlexRequestISP(
|
||||
disposition=AvailableRequested.REQUESTED,
|
||||
min_power=0,
|
||||
max_power=10,
|
||||
start=1,
|
||||
duration=1,
|
||||
)
|
||||
],
|
||||
revision=1,
|
||||
expiration_date_time=datetime.now(timezone.utc).isoformat(),
|
||||
contract_id=str(uuid4()),
|
||||
service_type="MyService",
|
||||
)
|
||||
|
||||
# As a demo, press enter to send another FlexRequset message.
|
||||
while True:
|
||||
try:
|
||||
input("Press return to send a FlexRequest message to the AGR")
|
||||
response = agr_client.send_flex_request(flex_request_message)
|
||||
print(f"Response was: {response}")
|
||||
except:
|
||||
dso.stop()
|
||||
break
|
||||
84
tools/shapeshifter-library-python-main/pyproject.toml
Normal file
84
tools/shapeshifter-library-python-main/pyproject.toml
Normal file
@@ -0,0 +1,84 @@
|
||||
[project]
|
||||
name = "shapeshifter_uftp"
|
||||
version = "2.3.1"
|
||||
description = "Implementation of the Shapeshifter (UFTP) protocol"
|
||||
dependencies = [
|
||||
"xsdata[lxml]>=25.0,<27.0",
|
||||
"pynacl>=1.5.0,<=1.6.2",
|
||||
"dnspython==2.8.0",
|
||||
"fastapi>=0.110,<0.128",
|
||||
"fastapi-xml>=1.1.1,<2.0.0",
|
||||
"requests",
|
||||
"uvicorn",
|
||||
"termcolor",
|
||||
]
|
||||
requires-python = ">=3.11,<3.15"
|
||||
readme = "README.rst"
|
||||
license = "Apache-2.0"
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://github.com/shapeshifter/shapeshifter-library-python"
|
||||
Documentation = "https://github.com/shapeshifter/shapeshifter-library-python/README.md"
|
||||
Issues = "https://github.com/shapeshifter/shapeshifter-library-python/issues"
|
||||
Changelog = "https://github.com/shapeshifter/shapeshifter-library-python/blob/main/CHANGELOG.md"
|
||||
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"xmlschema",
|
||||
"pytest",
|
||||
"pytest-cov",
|
||||
"pylint",
|
||||
"responses",
|
||||
"sphinx",
|
||||
"sphinx-rtd-theme"
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project.scripts]
|
||||
shapeshifter-keypair = "shapeshifter_uftp.cli:generate_signing_keypair"
|
||||
shapeshifter-lookup = "shapeshifter_uftp.cli:perform_lookup"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = "-v --cov --cov-report html --cov-report lcov"
|
||||
|
||||
[tool.coverage.run]
|
||||
include = [
|
||||
"shapeshifter_uftp/**/*.py",
|
||||
]
|
||||
omit = [
|
||||
"application/asgi.py",
|
||||
]
|
||||
|
||||
[tool.coverage.report]
|
||||
skip_empty = true
|
||||
|
||||
[tool.coverage.html]
|
||||
directory = "test_coverage"
|
||||
|
||||
[tool.pylint.DESIGN]
|
||||
|
||||
[tool.isort]
|
||||
profile = "black"
|
||||
|
||||
[tool.setuptools]
|
||||
packages = [
|
||||
"shapeshifter_uftp",
|
||||
"shapeshifter_uftp.client",
|
||||
"shapeshifter_uftp.service",
|
||||
"shapeshifter_uftp.uftp",
|
||||
"shapeshifter_uftp.uftp.messages"
|
||||
]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
"I",
|
||||
"W191",
|
||||
"W291",
|
||||
"W292",
|
||||
"W293",
|
||||
"F401",
|
||||
]
|
||||
@@ -0,0 +1,255 @@
|
||||
Metadata-Version: 2.4
|
||||
Name: shapeshifter_uftp
|
||||
Version: 2.3.1
|
||||
Summary: Implementation of the Shapeshifter (UFTP) protocol
|
||||
License-Expression: Apache-2.0
|
||||
Project-URL: Repository, https://github.com/shapeshifter/shapeshifter-library-python
|
||||
Project-URL: Documentation, https://github.com/shapeshifter/shapeshifter-library-python/README.md
|
||||
Project-URL: Issues, https://github.com/shapeshifter/shapeshifter-library-python/issues
|
||||
Project-URL: Changelog, https://github.com/shapeshifter/shapeshifter-library-python/blob/main/CHANGELOG.md
|
||||
Requires-Python: <3.15,>=3.11
|
||||
Description-Content-Type: text/x-rst
|
||||
License-File: LICENSE
|
||||
Requires-Dist: xsdata[lxml]<27.0,>=25.0
|
||||
Requires-Dist: pynacl<=1.6.2,>=1.5.0
|
||||
Requires-Dist: dnspython==2.8.0
|
||||
Requires-Dist: fastapi<0.128,>=0.110
|
||||
Requires-Dist: fastapi-xml<2.0.0,>=1.1.1
|
||||
Requires-Dist: requests
|
||||
Requires-Dist: uvicorn
|
||||
Requires-Dist: termcolor
|
||||
Dynamic: license-file
|
||||
|
||||
Shapeshifter library for Python
|
||||
===============================
|
||||
|
||||
This is a Python implementation of the ShapeShifter UFTP protocol.
|
||||
|
||||
Overview
|
||||
--------
|
||||
|
||||
This library implements the full UFTP protocol that you can use for Shapeshifter communications. It implements all three roles: Distribution System Operator (**DSO**), Aggregator (**AGR**) and Common Reference Operator (**CRO**) in both directions (client and service).
|
||||
|
||||
Features of this package:
|
||||
|
||||
- Building, parsing and validation of the XML messages
|
||||
- Signing and verifying of the XML messages using signatures
|
||||
- DNS for service discovery and key retrieval
|
||||
- Convenient clients for each role-pair
|
||||
- Convenient services for each role
|
||||
- JSON-serializable dataclasses for easy transport to other systems
|
||||
- Fully internal queing system for full-duplex communication with minimal user code required
|
||||
- Compatible with version 3.0.0 and 3.1.0 of the Shapeshifter protocol.
|
||||
|
||||
|
||||
Installation
|
||||
------------
|
||||
|
||||
.. code-block:: python3
|
||||
|
||||
pip install shapeshifter-uftp
|
||||
|
||||
Running tests
|
||||
-------------
|
||||
|
||||
If you want to develop shapeshifter-uftp, you can fork or clone this repository and run the tests:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
$ pip install .
|
||||
$ pip install .[dev]
|
||||
$ pytest .
|
||||
|
||||
|
||||
Getting Started
|
||||
---------------
|
||||
|
||||
Shapehifter always requires the use of a Client and a Service, because all responses are asynchronous.
|
||||
|
||||
You choose the server class based on your role in the Shapeshifter conversation. If you are an Aggregator (also known as a CSP), you can use this setup:
|
||||
|
||||
.. code-block:: python3
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from shapeshifter_uftp import ShapeshifterAgrService
|
||||
from shapeshifter_uftp.uftp import (FlexOffer, FlexOfferOption,
|
||||
FlexOfferOptionISP, FlexRequest,
|
||||
FlexRequestResponse, FlexOrder, FlexOrderResponse,
|
||||
AcceptedRejected)
|
||||
from xsdata.models.datatype import XmlDate
|
||||
|
||||
|
||||
class DemoAggregator(ShapeshifterAgrService):
|
||||
"""
|
||||
Aggregator service that implements callbacks for
|
||||
each of the messages that can be received.
|
||||
"""
|
||||
|
||||
def process_agr_portfolio_query_response(self, message):
|
||||
print(f"Received a message: {message}")
|
||||
|
||||
def process_agr_portfolio_update_response(self, message):
|
||||
print(f"Received a message: {message}")
|
||||
|
||||
def process_d_prognosis_response(self, message):
|
||||
print(f"Received a message: {message}")
|
||||
|
||||
def process_flex_request(self, message: FlexRequest):
|
||||
print(f"Received a message: {message}")
|
||||
|
||||
# Example of how to send a new message after
|
||||
# processing an incoming message.
|
||||
dso_client = self.dso_client(message.sender_domain)
|
||||
|
||||
# Send the FlexRequestResponse
|
||||
dso_client.send_flex_request_response(
|
||||
FlexRequestResponse(
|
||||
flex_request_message_id=message.message_id,
|
||||
conversation_id=message.conversation_id,
|
||||
result=AcceptedRejected.ACCEPTED
|
||||
)
|
||||
)
|
||||
|
||||
# Send the FlexOffer
|
||||
dso_client.send_flex_offer(
|
||||
FlexOffer(
|
||||
flex_request_message_id=message.message_id,
|
||||
conversation_id=message.conversation_id,
|
||||
isp_duration="PT15M",
|
||||
period=XmlDate(2023, 1, 1),
|
||||
congestion_point="ean.123456789012",
|
||||
expiration_date_time=datetime.now(timezone.utc).isoformat(),
|
||||
offer_options=[
|
||||
FlexOfferOption(
|
||||
isps=[FlexOfferOptionISP(power=1, start=1, duration=1)],
|
||||
option_reference="MyOption",
|
||||
price=2.30,
|
||||
min_activation_factor=0.5,
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
def process_flex_offer_response(self, message: FlexOffer):
|
||||
print(f"Received a message: {message}")
|
||||
|
||||
def process_flex_offer_revocation_response(self, message):
|
||||
print(f"Received a message: {message}")
|
||||
|
||||
def process_flex_order(self, message: FlexOrder):
|
||||
print(f"Received a message: {message}")
|
||||
|
||||
dso_client = self.dso_client(message.sender_domain)
|
||||
dso_client.send_flex_order_response(
|
||||
FlexOrderResponse(
|
||||
flex_order_message_id=message.message_id,
|
||||
conversation_id=message.conversation_id,
|
||||
result=AcceptedRejected.ACCEPTED
|
||||
)
|
||||
)
|
||||
|
||||
def process_flex_reservation_update(self, message):
|
||||
print(f"Received a message: {message}")
|
||||
|
||||
def process_flex_settlement(self, message):
|
||||
print(f"Received a message: {message}")
|
||||
|
||||
def process_metering_response(self, message):
|
||||
print(f"Received a message: {message}")
|
||||
|
||||
|
||||
def key_lookup(sender_domain, sender_role):
|
||||
"""
|
||||
Lookup function for public keys, so that incoming
|
||||
messages can be verified.
|
||||
"""
|
||||
known_senders = {
|
||||
("dso.demo", "DSO"): "NsTbq/iABU6tbsjriBg/Z5dSfQstulD0GpMI2fLDWec=",
|
||||
("cro.demo", "CRO"): "ySUYU87usErRFKGJafwvVDLGhnBVJCCNYfQvmwv8ObM=",
|
||||
}
|
||||
return known_senders.get((sender_domain, sender_role))
|
||||
|
||||
|
||||
def endpoint_lookup(sender_domain, sender_role):
|
||||
"""
|
||||
Lookup function for endpoints, so that the service
|
||||
knowns where to send responses to.
|
||||
"""
|
||||
known_senders = {
|
||||
("dso.demo", "DSO"): "http://localhost:8081/shapeshifter/api/v3/message",
|
||||
("cro.demo", "CRO"): "http://localhost:8082/shapeshifter/api/v3/message",
|
||||
}
|
||||
return known_senders.get((sender_domain, sender_role))
|
||||
|
||||
aggregator = DemoAggregator(
|
||||
sender_domain="aggregator.demo",
|
||||
signing_key="mz5XYCNKxpx48K+9oipUhsjBZed3L7rTVKLsWmG1HOqRLIeuGpIa1KAt6AlbVGqJvewd8v1J0uVUTqpGt7F8tw==",
|
||||
key_lookup_function=key_lookup,
|
||||
endpoint_lookup_function=endpoint_lookup,
|
||||
port=8080,
|
||||
)
|
||||
|
||||
# Start the Aggregator Service
|
||||
aggregator.run_in_thread()
|
||||
|
||||
# Create a client object to talk to a DSO
|
||||
dso_client = aggregator.dso_client("dso.demo")
|
||||
|
||||
# Create a Flex Offer Message
|
||||
flex_offer_message = FlexOffer(
|
||||
isp_duration="PT15M",
|
||||
period=XmlDate(2023, 1, 1),
|
||||
congestion_point="ean.123456789012",
|
||||
expiration_date_time=datetime.now(timezone.utc).isoformat(),
|
||||
flex_request_message_id=str(uuid4())
|
||||
offer_options=[
|
||||
FlexOfferOption(
|
||||
isps=[FlexOfferOptionISP(power=1, start=1, duration=1)],
|
||||
option_reference="MyOption",
|
||||
price=2.30,
|
||||
min_activation_factor=0.5,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
# As a demo, press enter to send another FlexOffer message to the DSO.
|
||||
while True:
|
||||
try:
|
||||
input("Press return to send a FlexOffer message to the DSO")
|
||||
response = dso_client.send_flex_offer(flex_offer_message)
|
||||
print(f"Response was: {response}")
|
||||
except:
|
||||
aggregator.stop()
|
||||
break
|
||||
|
||||
Using OAuth in outgoing requests
|
||||
--------------------------------
|
||||
|
||||
To use OAuth in outgoing requests, you can use the provided OAuthClient class. To use it in a bare Shapeshifter client:
|
||||
|
||||
.. code-block:: python3
|
||||
|
||||
from shapeshifter_uftp import ShapeshifterAgrDsoClient, OAuthClient
|
||||
|
||||
oauth_client = OAuthClient(
|
||||
url="https://oauth.provider.url",
|
||||
client_id="my-client-id",
|
||||
client_secret="my-client-secret"
|
||||
)
|
||||
|
||||
client = ShapeshifterAgrDsoClient(
|
||||
sender_domain="my.aggregator.domain",
|
||||
signing_key="abcdef",
|
||||
recipient_domain="some.dso",
|
||||
recipient_endpoint="https://some.dso.endpoint/shapeshifter/api/v3/message",
|
||||
recipient_signing_key="123456",
|
||||
oauth_client=oauth_client,
|
||||
)
|
||||
|
||||
# If you use any of the sending methods, the oauth client will
|
||||
# make sure you're authenticated.
|
||||
client.send_flex_request_response(...)
|
||||
|
||||
|
||||
Similarly, if you have a Service instance that dynamically needs to retrieve the OAuth information for each different recipient server, you can provide an ``oauth_lookup_function`` that takes a ``(sender_domain, sender_role)`` and returns an instance of OAuthClient.
|
||||
@@ -0,0 +1,66 @@
|
||||
LICENSE
|
||||
README.rst
|
||||
pyproject.toml
|
||||
shapeshifter_uftp/__init__.py
|
||||
shapeshifter_uftp/cli.py
|
||||
shapeshifter_uftp/exceptions.py
|
||||
shapeshifter_uftp/logging.py
|
||||
shapeshifter_uftp/oauth.py
|
||||
shapeshifter_uftp/transport.py
|
||||
shapeshifter_uftp.egg-info/PKG-INFO
|
||||
shapeshifter_uftp.egg-info/SOURCES.txt
|
||||
shapeshifter_uftp.egg-info/dependency_links.txt
|
||||
shapeshifter_uftp.egg-info/entry_points.txt
|
||||
shapeshifter_uftp.egg-info/requires.txt
|
||||
shapeshifter_uftp.egg-info/top_level.txt
|
||||
shapeshifter_uftp/client/__init__.py
|
||||
shapeshifter_uftp/client/agr_cro_client.py
|
||||
shapeshifter_uftp/client/agr_dso_client.py
|
||||
shapeshifter_uftp/client/base_client.py
|
||||
shapeshifter_uftp/client/cro_agr_client.py
|
||||
shapeshifter_uftp/client/cro_dso_client.py
|
||||
shapeshifter_uftp/client/dso_agr_client.py
|
||||
shapeshifter_uftp/client/dso_cro_client.py
|
||||
shapeshifter_uftp/service/__init__.py
|
||||
shapeshifter_uftp/service/agr_service.py
|
||||
shapeshifter_uftp/service/base_service.py
|
||||
shapeshifter_uftp/service/cro_service.py
|
||||
shapeshifter_uftp/service/dso_service.py
|
||||
shapeshifter_uftp/uftp/__init__.py
|
||||
shapeshifter_uftp/uftp/defaults.py
|
||||
shapeshifter_uftp/uftp/enums.py
|
||||
shapeshifter_uftp/uftp/validations.py
|
||||
shapeshifter_uftp/uftp/messages/__init__.py
|
||||
shapeshifter_uftp/uftp/messages/agr_portfolio_query.py
|
||||
shapeshifter_uftp/uftp/messages/agr_portfolio_update.py
|
||||
shapeshifter_uftp/uftp/messages/d_prognosis.py
|
||||
shapeshifter_uftp/uftp/messages/dso_portfolio_query.py
|
||||
shapeshifter_uftp/uftp/messages/dso_portfolio_update.py
|
||||
shapeshifter_uftp/uftp/messages/flex_message.py
|
||||
shapeshifter_uftp/uftp/messages/flex_offer.py
|
||||
shapeshifter_uftp/uftp/messages/flex_offer_revocation.py
|
||||
shapeshifter_uftp/uftp/messages/flex_order.py
|
||||
shapeshifter_uftp/uftp/messages/flex_request.py
|
||||
shapeshifter_uftp/uftp/messages/flex_reservation_update.py
|
||||
shapeshifter_uftp/uftp/messages/flex_settlement.py
|
||||
shapeshifter_uftp/uftp/messages/metering.py
|
||||
shapeshifter_uftp/uftp/messages/payload_message.py
|
||||
shapeshifter_uftp/uftp/messages/signed_message.py
|
||||
shapeshifter_uftp/uftp/messages/test_message.py
|
||||
test/test_cli.py
|
||||
test/test_client_errors.py
|
||||
test/test_client_with_workers.py
|
||||
test/test_clients_from_service.py
|
||||
test/test_communications.py
|
||||
test/test_default_responses.py
|
||||
test/test_message_destination.py
|
||||
test/test_oauth.py
|
||||
test/test_presence_of_client_methods.py
|
||||
test/test_presence_of_service_methods.py
|
||||
test/test_roundtrip_serialization.py
|
||||
test/test_seal_and_unseal.py
|
||||
test/test_service_errors.py
|
||||
test/test_snake_case.py
|
||||
test/test_ttl_cache.py
|
||||
test/test_validations.py
|
||||
test/test_xml_schema_compliance.py
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[console_scripts]
|
||||
shapeshifter-keypair = shapeshifter_uftp.cli:generate_signing_keypair
|
||||
shapeshifter-lookup = shapeshifter_uftp.cli:perform_lookup
|
||||
@@ -0,0 +1,8 @@
|
||||
xsdata[lxml]<27.0,>=25.0
|
||||
pynacl<=1.6.2,>=1.5.0
|
||||
dnspython==2.8.0
|
||||
fastapi<0.128,>=0.110
|
||||
fastapi-xml<2.0.0,>=1.1.1
|
||||
requests
|
||||
uvicorn
|
||||
termcolor
|
||||
@@ -0,0 +1 @@
|
||||
shapeshifter_uftp
|
||||
@@ -0,0 +1,145 @@
|
||||
from .client import (
|
||||
ShapeshifterAgrCroClient,
|
||||
ShapeshifterAgrDsoClient,
|
||||
ShapeshifterCroAgrClient,
|
||||
ShapeshifterCroDsoClient,
|
||||
ShapeshifterDsoAgrClient,
|
||||
ShapeshifterDsoCroClient,
|
||||
)
|
||||
from .oauth import OAuthClient
|
||||
from .service import (
|
||||
ShapeshifterAgrService,
|
||||
ShapeshifterCroService,
|
||||
ShapeshifterDsoService,
|
||||
)
|
||||
from .uftp import (
|
||||
AcceptedRejected,
|
||||
AgrPortfolioQuery,
|
||||
AgrPortfolioQueryResponse,
|
||||
AgrPortfolioQueryResponseCongestionPoint,
|
||||
AgrPortfolioQueryResponseConnection,
|
||||
AgrPortfolioQueryResponseDSOPortfolio,
|
||||
AgrPortfolioQueryResponseDSOView,
|
||||
AgrPortfolioUpdate,
|
||||
AgrPortfolioUpdateConnection,
|
||||
AgrPortfolioUpdateResponse,
|
||||
ContractSettlement,
|
||||
ContractSettlementISP,
|
||||
ContractSettlementPeriod,
|
||||
DPrognosis,
|
||||
DPrognosisISP,
|
||||
DPrognosisResponse,
|
||||
DsoPortfolioQuery,
|
||||
DsoPortfolioQueryCongestionPoint,
|
||||
DsoPortfolioQueryConnection,
|
||||
DsoPortfolioQueryResponse,
|
||||
DsoPortfolioUpdate,
|
||||
DsoPortfolioUpdateCongestionPoint,
|
||||
DsoPortfolioUpdateConnection,
|
||||
DsoPortfolioUpdateResponse,
|
||||
FlexMessage,
|
||||
FlexOffer,
|
||||
FlexOfferOption,
|
||||
FlexOfferOptionISP,
|
||||
FlexOfferResponse,
|
||||
FlexOfferRevocation,
|
||||
FlexOfferRevocationResponse,
|
||||
FlexOrder,
|
||||
FlexOrderISP,
|
||||
FlexOrderResponse,
|
||||
FlexOrderSettlement,
|
||||
FlexOrderSettlementISP,
|
||||
FlexOrderSettlementStatus,
|
||||
FlexOrderStatus,
|
||||
FlexRequest,
|
||||
FlexRequestISP,
|
||||
FlexRequestResponse,
|
||||
FlexReservationUpdate,
|
||||
FlexReservationUpdateISP,
|
||||
FlexReservationUpdateResponse,
|
||||
FlexSettlement,
|
||||
FlexSettlementResponse,
|
||||
Metering,
|
||||
MeteringISP,
|
||||
MeteringProfile,
|
||||
MeteringProfileEnum,
|
||||
MeteringResponse,
|
||||
MeteringUnit,
|
||||
PayloadMessage,
|
||||
PayloadMessageResponse,
|
||||
SignedMessage,
|
||||
TestMessage,
|
||||
TestMessageResponse,
|
||||
UsefRole,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ShapeshifterAgrCroClient",
|
||||
"ShapeshifterAgrDsoClient",
|
||||
"ShapeshifterCroAgrClient",
|
||||
"ShapeshifterCroDsoClient",
|
||||
"ShapeshifterDsoAgrClient",
|
||||
"ShapeshifterDsoCroClient",
|
||||
"ShapeshifterAgrService",
|
||||
"ShapeshifterDsoService",
|
||||
"ShapeshifterCroService",
|
||||
"AcceptedRejected",
|
||||
"AgrPortfolioQuery",
|
||||
"AgrPortfolioQueryResponse",
|
||||
"AgrPortfolioQueryResponseCongestionPoint",
|
||||
"AgrPortfolioQueryResponseConnection",
|
||||
"AgrPortfolioQueryResponseDSOPortfolio",
|
||||
"AgrPortfolioQueryResponseDSOView",
|
||||
"AgrPortfolioUpdate",
|
||||
"AgrPortfolioUpdateConnection",
|
||||
"AgrPortfolioUpdateResponse",
|
||||
"ContractSettlement",
|
||||
"ContractSettlementISP",
|
||||
"ContractSettlementPeriod",
|
||||
"DPrognosis",
|
||||
"DPrognosisISP",
|
||||
"DPrognosisResponse",
|
||||
"DsoPortfolioQuery",
|
||||
"DsoPortfolioQueryCongestionPoint",
|
||||
"DsoPortfolioQueryConnection",
|
||||
"DsoPortfolioQueryResponse",
|
||||
"DsoPortfolioUpdate",
|
||||
"DsoPortfolioUpdateCongestionPoint",
|
||||
"DsoPortfolioUpdateConnection",
|
||||
"DsoPortfolioUpdateResponse",
|
||||
"FlexMessage",
|
||||
"FlexOffer",
|
||||
"FlexOfferOption",
|
||||
"FlexOfferOptionISP",
|
||||
"FlexOfferResponse",
|
||||
"FlexOfferRevocation",
|
||||
"FlexOfferRevocationResponse",
|
||||
"FlexOrder",
|
||||
"FlexOrderISP",
|
||||
"FlexOrderResponse",
|
||||
"FlexOrderSettlement",
|
||||
"FlexOrderSettlementISP",
|
||||
"FlexOrderSettlementStatus",
|
||||
"FlexOrderStatus",
|
||||
"FlexRequest",
|
||||
"FlexRequestISP",
|
||||
"FlexRequestResponse",
|
||||
"FlexReservationUpdate",
|
||||
"FlexReservationUpdateISP",
|
||||
"FlexReservationUpdateResponse",
|
||||
"FlexSettlement",
|
||||
"FlexSettlementResponse",
|
||||
"Metering",
|
||||
"MeteringISP",
|
||||
"MeteringProfile",
|
||||
"MeteringProfileEnum",
|
||||
"MeteringResponse",
|
||||
"MeteringUnit",
|
||||
"OAuthClient",
|
||||
"PayloadMessage",
|
||||
"PayloadMessageResponse",
|
||||
"SignedMessage",
|
||||
"TestMessage",
|
||||
"TestMessageResponse",
|
||||
"UsefRole",
|
||||
]
|
||||
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
A set of command-line-interface functions that are useful during
|
||||
development of Shapeshifter applications.
|
||||
"""
|
||||
|
||||
from argparse import ArgumentParser
|
||||
from base64 import b64encode
|
||||
|
||||
from nacl.bindings import crypto_sign_keypair
|
||||
|
||||
from . import transport
|
||||
from .exceptions import AuthenticationTimeoutException, ServiceDiscoveryException
|
||||
|
||||
|
||||
def generate_signing_keypair():
|
||||
"""
|
||||
Generate a signing keypair (private and public) and print them as
|
||||
base64-encoded strings. These are the strings that you'd use for
|
||||
signing and verifying messages; you pass these to the signing_key
|
||||
and recipient_signing_key parameters of the Service or Client
|
||||
objects.
|
||||
"""
|
||||
public, private = crypto_sign_keypair()
|
||||
print("-" * 66)
|
||||
print("Private key (base64):", b64encode(private).decode())
|
||||
print("Public key (base64): ", b64encode(public).decode())
|
||||
print("-" * 66)
|
||||
|
||||
def perform_lookup():
|
||||
"""
|
||||
Perform a DNS lookup of a participant's version, endpoint and
|
||||
public key details. These use the well-known DNS names described
|
||||
in the UFTP specification.
|
||||
"""
|
||||
parser = ArgumentParser()
|
||||
parser.add_argument("-d", "--domain", required=True, type=str, help="The sender domain for the other party")
|
||||
parser.add_argument("-r", "--role", required=True, type=str, help="The sender role for the other party")
|
||||
args = parser.parse_args()
|
||||
|
||||
print("-" * 65)
|
||||
|
||||
try:
|
||||
version = transport.get_version(args.domain)
|
||||
print(f"Shapeshifer version: {version}")
|
||||
except ServiceDiscoveryException as err:
|
||||
print(err)
|
||||
|
||||
try:
|
||||
endpoint = transport.get_endpoint(args.domain, args.role)
|
||||
print(f"Endpoint URL: {endpoint}")
|
||||
except ServiceDiscoveryException as err:
|
||||
print(err)
|
||||
|
||||
try:
|
||||
signing_key, decryption_key = transport.get_keys(args.domain, args.role)
|
||||
print(f"Signing key: {signing_key}")
|
||||
if decryption_key:
|
||||
print(f"Decryption Key: {decryption_key}")
|
||||
except AuthenticationTimeoutException as err:
|
||||
print(err)
|
||||
|
||||
print("-" * 65)
|
||||
@@ -0,0 +1,20 @@
|
||||
from .agr_cro_client import ShapeshifterAgrCroClient
|
||||
from .agr_dso_client import ShapeshifterAgrDsoClient
|
||||
from .cro_agr_client import ShapeshifterCroAgrClient
|
||||
from .cro_dso_client import ShapeshifterCroDsoClient
|
||||
from .dso_agr_client import ShapeshifterDsoAgrClient
|
||||
from .dso_cro_client import ShapeshifterDsoCroClient
|
||||
|
||||
__all__ = [
|
||||
"ShapeshifterAgrCroClient",
|
||||
"ShapeshifterAgrDsoClient",
|
||||
"ShapeshifterCroAgrClient",
|
||||
"ShapeshifterCroDsoClient",
|
||||
"ShapeshifterDsoAgrClient",
|
||||
"ShapeshifterDsoCroClient"
|
||||
]
|
||||
|
||||
client_map = {
|
||||
(client.sender_role, client.recipient_role): client
|
||||
for client in [globals()[name] for name in __all__]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
from ..uftp import (
|
||||
AgrPortfolioQuery,
|
||||
AgrPortfolioUpdate,
|
||||
UsefRole,
|
||||
)
|
||||
from .base_client import ShapeshifterClient
|
||||
|
||||
|
||||
class ShapeshifterAgrCroClient(ShapeshifterClient):
|
||||
"""
|
||||
Client that allows the Aggregator to connect to the CRO.
|
||||
"""
|
||||
|
||||
sender_role = UsefRole.AGR
|
||||
recipient_role = UsefRole.CRO
|
||||
|
||||
def send_agr_portfolio_update(self, message: AgrPortfolioUpdate) -> None:
|
||||
"""
|
||||
The AGRPortfolioUpdate is used by the AGR to indicate on which
|
||||
Connections it represents prosumers.
|
||||
"""
|
||||
return self._send_message(message)
|
||||
|
||||
def send_agr_portfolio_query(self, message: AgrPortfolioQuery) -> None:
|
||||
"""
|
||||
The AGRPortfolioQuery is used by the AGR to retrieve additional
|
||||
information on the connections.
|
||||
"""
|
||||
return self._send_message(message)
|
||||
@@ -0,0 +1,92 @@
|
||||
from ..uftp import (
|
||||
DPrognosis,
|
||||
FlexOffer,
|
||||
FlexOfferRevocation,
|
||||
FlexOrderResponse,
|
||||
FlexRequestResponse,
|
||||
FlexReservationUpdateResponse,
|
||||
FlexSettlementResponse,
|
||||
Metering,
|
||||
UsefRole,
|
||||
)
|
||||
from .base_client import ShapeshifterClient
|
||||
|
||||
|
||||
class ShapeshifterAgrDsoClient(ShapeshifterClient):
|
||||
"""
|
||||
Client that allows the Aggregator to connect to the DSO.
|
||||
"""
|
||||
|
||||
sender_role = UsefRole.AGR
|
||||
recipient_role = UsefRole.DSO
|
||||
|
||||
def send_d_prognosis(self, message: DPrognosis) -> None:
|
||||
"""
|
||||
D-Prognosis messages are used to communicate D-prognoses between AGRs
|
||||
and DSOs. D-Prognosis messages always contain data for all ISPs for the
|
||||
period they apply to, even if a prognosis is sent after the start of
|
||||
the period, when one or more ISPs are already in the operate or
|
||||
settlement phase. Receiving implementations should ignore the
|
||||
information supplied for those ISPs.
|
||||
"""
|
||||
return self._send_message(message)
|
||||
|
||||
def send_flex_request_response(self, message: FlexRequestResponse) -> None:
|
||||
"""
|
||||
Upon receiving and processing a FlexRequest message, the receiving
|
||||
implementation must reply with a FlexRequestResponse, indicating
|
||||
whether the flexibility request was processed successfully.
|
||||
"""
|
||||
return self._send_message(message)
|
||||
|
||||
def send_flex_offer(self, message: FlexOffer) -> None:
|
||||
"""
|
||||
FlexOffer messages are used by AGRs to make DSOs an offer for provision
|
||||
of flexibility. A FlexOffer message contains a list of ISPs and, for
|
||||
each ISP, the change in consumption or production offered and the price
|
||||
for the total amount of flexibility offered. FlexOffer messages can be
|
||||
sent once a FlexRequest message has been received but can also be sent
|
||||
unsolicited. Note that multiple FlexOffer messages may be sent based on
|
||||
a single FlexRequest, e.g. to increase the chance that the DSO will
|
||||
order at least part of its available flexibility. The AGR must make
|
||||
sure that it can actually provide the flexibility offered across all of
|
||||
its FlexOffers.
|
||||
"""
|
||||
self._send_message(message)
|
||||
|
||||
def send_flex_offer_revocation(self, message: FlexOfferRevocation) -> None:
|
||||
"""
|
||||
The FlexOfferRevocation message is used by the AGR to revoke a FlexOffer
|
||||
previously sent to a DSO. It voids the FlexOffer, even if its validity
|
||||
time has not yet expired. Revocation is not allowed for FlexOffers that
|
||||
already have associated accepted FlexOrders.
|
||||
"""
|
||||
self._send_message(message)
|
||||
|
||||
def send_flex_order_response(self, message: FlexOrderResponse) -> None:
|
||||
"""
|
||||
Confirm the flex order.
|
||||
"""
|
||||
self._send_message(message)
|
||||
|
||||
def send_flex_settlement_response(self, message: FlexSettlementResponse) -> None:
|
||||
"""
|
||||
Upon receiving and processing a FlexSettlement message, the AGR must
|
||||
reply with a FlexSettlementResponse, indicating whether the initial
|
||||
message was handled successfully. When a FlexSettlement message is
|
||||
rejected, the DSO should consider all FlexOrderSettlement elements of
|
||||
that message related to potential dispute.
|
||||
"""
|
||||
self._send_message(message)
|
||||
|
||||
def send_flex_reservation_update_response(self, message: FlexReservationUpdateResponse) -> None:
|
||||
"""
|
||||
Confirm the flex reservation update.
|
||||
"""
|
||||
self._send_message(message)
|
||||
|
||||
def send_metering(self, message: Metering) -> None:
|
||||
"""
|
||||
Send metering data to the DSO.
|
||||
"""
|
||||
self._send_message(message)
|
||||
@@ -0,0 +1,252 @@
|
||||
import sched
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from queue import Queue
|
||||
from threading import Event, Thread
|
||||
from uuid import uuid4
|
||||
|
||||
import requests
|
||||
|
||||
from .. import transport
|
||||
from ..exceptions import ClientTransportException
|
||||
from ..logging import logger
|
||||
from ..oauth import OAuthClient, PassthroughOAuthClient
|
||||
from ..uftp import (
|
||||
PayloadMessage,
|
||||
SignedMessage,
|
||||
TestMessage,
|
||||
TestMessageResponse,
|
||||
UsefRole,
|
||||
)
|
||||
|
||||
|
||||
class ShapeshifterClient:
|
||||
"""
|
||||
Basis for all Shapeshifter client.
|
||||
"""
|
||||
|
||||
sender_role: UsefRole
|
||||
recipient_role: UsefRole
|
||||
num_outgoing_workers = 10
|
||||
num_delivery_attempts = 10
|
||||
request_timeout = 30
|
||||
exponential_retry_factor = 1.0
|
||||
exponential_retry_base = 2.0
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sender_domain: str,
|
||||
signing_key: str,
|
||||
recipient_domain: str,
|
||||
recipient_endpoint: str | None = None,
|
||||
recipient_signing_key: str | None = None,
|
||||
oauth_client: OAuthClient | None = None,
|
||||
version: str = "3.1.0"
|
||||
):
|
||||
"""
|
||||
Shapeshifter client class that allows you to initiate messages to a different party.
|
||||
:param str sender_domain: your sender domain
|
||||
:param str signing_key: your private signing key
|
||||
:param str recipient_domain: the domain of the recipient
|
||||
:param str recipient_endpoint: the full http endpoint URL of the recipient. If omitted,
|
||||
will look up the endpoint using DNS.
|
||||
:param str recipient_signing_key: the public signing key of the recipient. If omitted, will
|
||||
look up the signing key using DNS.
|
||||
:param OAuthClient oauth_client: Optional OAuth client instance for using oauth to authenticate outgoing messages.
|
||||
:param str version: Version number for the shapeshfter protocol (3.0.0 or 3.1.0)
|
||||
"""
|
||||
if recipient_domain is None and recipient_endpoint is None:
|
||||
raise ValueError(
|
||||
"One of recipient_domain or recipient_endpoint must be provided."
|
||||
)
|
||||
|
||||
if version not in ("3.0.0", "3.1.0"):
|
||||
raise ValueError(f"'version' should be one of '3.0.0' or '3.1.0', not '{version}'")
|
||||
|
||||
self.version = version
|
||||
self.sender_domain = sender_domain
|
||||
self.signing_key = signing_key
|
||||
self.recipient_domain = recipient_domain
|
||||
self.recipient_endpoint = recipient_endpoint
|
||||
self.recipient_signing_key = recipient_signing_key
|
||||
|
||||
# The outgoing queue and scheduler are used when queueing
|
||||
# messages for delivery later. This allows the Shapeshifter
|
||||
# UFTP client to handle message retries on an exponential
|
||||
# time schedule, and delivers the result in the provided
|
||||
# callback function.
|
||||
self.outgoing_queue = Queue()
|
||||
self.outgoing_workers = None
|
||||
self.scheduler = sched.scheduler(time.monotonic, time.sleep)
|
||||
self.scheduler_event = Event()
|
||||
self.scheduler_thread = None
|
||||
|
||||
if oauth_client:
|
||||
self.oauth_client = oauth_client
|
||||
else:
|
||||
self.oauth_client = PassthroughOAuthClient()
|
||||
|
||||
# ------------------------------------------------------------ #
|
||||
# Test Messages #
|
||||
# ------------------------------------------------------------ #
|
||||
def send_test_message(self, message: TestMessage | None = None):
|
||||
if message is None:
|
||||
message = TestMessage()
|
||||
|
||||
return self._send_message(message)
|
||||
|
||||
def send_test_message_response(self, message: TestMessageResponse):
|
||||
return self._send_message(message)
|
||||
|
||||
def _send_message(self, message: PayloadMessage) -> None:
|
||||
"""
|
||||
Perform an operation. This will take the message object, pack
|
||||
it up into a SignedMessage, sign and seal it, and send it to
|
||||
the recipient. It returns an unsealed PayloadMessageResponse
|
||||
that contains the functional status of the request. The
|
||||
actual response always arrives asynchronously on your service
|
||||
(which runs separately).
|
||||
"""
|
||||
if not isinstance(message, PayloadMessage):
|
||||
raise TypeError(
|
||||
f"'message' must be a (subclass of) PayloadMessage, you provided: {type(message)}"
|
||||
)
|
||||
|
||||
# Fill the PayloadMessage's fields that are common to all
|
||||
# messages. We don't require the developer to fill these out
|
||||
# every time they create any message, in order to reduce the
|
||||
# duplicated code that would result in, and all of these
|
||||
# properties can be calculated in the framework anyway.
|
||||
message.version = self.version
|
||||
message.sender_domain = self.sender_domain
|
||||
message.recipient_domain = self.recipient_domain
|
||||
message.time_stamp = (
|
||||
message.time_stamp or datetime.now(timezone.utc).isoformat()
|
||||
)
|
||||
message.message_id = message.message_id or str(uuid4())
|
||||
message.conversation_id = message.conversation_id or str(uuid4())
|
||||
|
||||
logger.info(f"The PayloadMessage is: {message}")
|
||||
|
||||
# Seal the message using our own private signing key
|
||||
sealed_message = transport.seal_message(message, self.signing_key)
|
||||
|
||||
# Pack up the message into a SignedMessage
|
||||
signed_message = SignedMessage(
|
||||
sender_domain=self.sender_domain,
|
||||
sender_role=self.sender_role,
|
||||
body=sealed_message,
|
||||
)
|
||||
|
||||
# Serialize the message into an XML blob
|
||||
serialized_message = transport.to_xml(signed_message)
|
||||
|
||||
logger.debug(f"Sending message to {self.recipient_endpoint}:")
|
||||
logger.debug(serialized_message)
|
||||
|
||||
# Send the request to the relevant endpoint
|
||||
with self.oauth_client.ensure_authenticated():
|
||||
response = requests.post(
|
||||
self.recipient_endpoint,
|
||||
data=serialized_message,
|
||||
headers={
|
||||
"Content-Type": "text/xml; charset=utf-8",
|
||||
**self.oauth_client.auth_header
|
||||
},
|
||||
timeout=self.request_timeout,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
error_msg = (
|
||||
f"Request to {self.recipient_endpoint} was not succesful: "
|
||||
f"HTTP {response.status_code}: {response.text}"
|
||||
)
|
||||
logger.error(error_msg)
|
||||
raise ClientTransportException(error_msg, response=response)
|
||||
|
||||
# ------------------------------------------------------------ #
|
||||
# Methods related to queueing and scheduling outgoing #
|
||||
# messages. #
|
||||
# ------------------------------------------------------------ #
|
||||
|
||||
def _queue_message(self, message, callback, attempt=1):
|
||||
self.outgoing_queue.put((message, callback, attempt))
|
||||
self._run_outgoing_workers()
|
||||
|
||||
def _outgoing_worker(self):
|
||||
while True:
|
||||
message, callback, attempt = self.outgoing_queue.get()
|
||||
try:
|
||||
response = self._send_message(message)
|
||||
except Exception as exc: # pylint: disable=broad-exception-caught
|
||||
if attempt <= self.num_delivery_attempts:
|
||||
# Reschedule with exponential backoff
|
||||
delay_time = (
|
||||
self.exponential_retry_factor
|
||||
* self.exponential_retry_base**attempt
|
||||
)
|
||||
logger.warning(
|
||||
f"Outgoing message {message.__class__.__name__} to "
|
||||
f"{message.recipient_domain} could not be delivered "
|
||||
f"due to a {exc.__class__.__name__}, will try again in {delay_time:.0f} seconds."
|
||||
)
|
||||
self.scheduler.enter(
|
||||
delay=delay_time,
|
||||
priority=1,
|
||||
action=self._queue_message,
|
||||
argument=((message, callback, attempt + 1)),
|
||||
)
|
||||
self._run_scheduler()
|
||||
else:
|
||||
logger.error(
|
||||
f"Could not deliver {message.__class__.__name__} "
|
||||
f"to {self.recipient_role} at {self.recipient_domain}, "
|
||||
f"even after {self.num_delivery_attempts} attempts."
|
||||
)
|
||||
else:
|
||||
try:
|
||||
callback(response)
|
||||
except Exception as err: # pylint: disable=broad-exception-caught
|
||||
logger.error(
|
||||
"There was an exception during the callback "
|
||||
f"for a {message.__class__.__name__} message: "
|
||||
f"{err.__class__.__name__}: {err}"
|
||||
)
|
||||
finally:
|
||||
self.outgoing_queue.task_done()
|
||||
|
||||
def _run_scheduler(self):
|
||||
"""
|
||||
Make sure the scheduler thread is running and awake.
|
||||
"""
|
||||
if not self.scheduler_thread:
|
||||
self.scheduler_thread = Thread(target=self._scheduler_thread, daemon=True)
|
||||
self.scheduler_thread.start()
|
||||
self.scheduler_event.set()
|
||||
|
||||
def _scheduler_thread(self):
|
||||
"""
|
||||
Intended to run the python scheduler in a background thread.
|
||||
You can wake it up anytime by setting the scheduler event.
|
||||
"""
|
||||
while True:
|
||||
self.scheduler_event.wait()
|
||||
self.scheduler_event.clear()
|
||||
self.scheduler.run()
|
||||
|
||||
def _run_outgoing_workers(self):
|
||||
"""
|
||||
Start up the outgoing queue workers.
|
||||
"""
|
||||
if not self.outgoing_workers:
|
||||
self.outgoing_workers = [
|
||||
Thread(target=self._outgoing_worker, daemon=True)
|
||||
for _ in range(self.num_outgoing_workers)
|
||||
]
|
||||
for thread in self.outgoing_workers:
|
||||
thread.start()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args, **kwargs):
|
||||
pass
|
||||
@@ -0,0 +1,25 @@
|
||||
from ..uftp import AgrPortfolioQueryResponse, AgrPortfolioUpdateResponse, UsefRole
|
||||
from .base_client import ShapeshifterClient
|
||||
|
||||
|
||||
class ShapeshifterCroAgrClient(ShapeshifterClient):
|
||||
"""
|
||||
Client that allows the CRO to connect to the Aggregator.
|
||||
"""
|
||||
|
||||
sender_role = UsefRole.CRO
|
||||
recipient_role = UsefRole.AGR
|
||||
|
||||
def send_agr_portfolio_update_response(self, message: AgrPortfolioUpdateResponse) -> None:
|
||||
"""
|
||||
The DSOPortfolioUpdate is used by the DSO to indicate on which
|
||||
congestion points it wants to engage in flexibility trading.
|
||||
"""
|
||||
self._send_message(message)
|
||||
|
||||
def send_agr_portfolio_query_response(self, message: AgrPortfolioQueryResponse) -> None:
|
||||
"""
|
||||
DSOPortfolioQuery is used by DSOs to discover which AGRs represent
|
||||
connections on its registered congestion point(s).
|
||||
"""
|
||||
self._send_message(message)
|
||||
@@ -0,0 +1,33 @@
|
||||
from ..uftp import DsoPortfolioQueryResponse, DsoPortfolioUpdateResponse, UsefRole
|
||||
from .base_client import ShapeshifterClient
|
||||
|
||||
|
||||
class ShapeshifterCroDsoClient(ShapeshifterClient):
|
||||
"""
|
||||
Client that allows the CRO to connect to the DSO.
|
||||
|
||||
There are only two types of messages that the CRO can send to the DSO:
|
||||
|
||||
- DsoPortfolioUpdateResponse
|
||||
- DsoPortfolioQueryResponse
|
||||
|
||||
Each of these comes after the DSO sends a DsoPortfolioUpdate or
|
||||
DsoPortfolioQuery, respectively.
|
||||
"""
|
||||
|
||||
sender_role = UsefRole.CRO
|
||||
recipient_role = UsefRole.DSO
|
||||
|
||||
def send_dso_portfolio_update_response(self, message: DsoPortfolioUpdateResponse) -> None:
|
||||
"""
|
||||
The DSOPortfolioUpdate is used by the DSO to indicate on which
|
||||
congestion points it wants to engage in flexibility trading.
|
||||
"""
|
||||
self._send_message(message)
|
||||
|
||||
def send_dso_portfolio_query_response(self, message: DsoPortfolioQueryResponse) -> None:
|
||||
"""
|
||||
DSOPortfolioQuery is used by DSOs to discover which AGRs represent
|
||||
connections on its registered congestion point(s).
|
||||
"""
|
||||
self._send_message(message)
|
||||
@@ -0,0 +1,90 @@
|
||||
from ..uftp import (
|
||||
DPrognosisResponse,
|
||||
FlexOfferResponse,
|
||||
FlexOfferRevocationResponse,
|
||||
FlexOrder,
|
||||
FlexRequest,
|
||||
FlexReservationUpdate,
|
||||
FlexSettlement,
|
||||
MeteringResponse,
|
||||
UsefRole,
|
||||
)
|
||||
from .base_client import ShapeshifterClient
|
||||
|
||||
|
||||
class ShapeshifterDsoAgrClient(ShapeshifterClient):
|
||||
"""
|
||||
Client that allows the DSO to connect to the Aggregator.
|
||||
"""
|
||||
|
||||
sender_role = UsefRole.DSO
|
||||
recipient_role = UsefRole.AGR
|
||||
|
||||
def send_d_prognosis_response(self, message: DPrognosisResponse) -> None:
|
||||
"""
|
||||
Confirm reception of the D-prognosis.
|
||||
"""
|
||||
self._send_message(message)
|
||||
|
||||
def send_flex_request(self, message: FlexRequest) -> None:
|
||||
"""
|
||||
FlexRequest messages are used by DSOs to request flexibility from AGRs.
|
||||
In addition to one or more ISP elements with Disposition=Requested,
|
||||
indicating the actual need to reduce consumption or production, the
|
||||
message should also include the remaining ISPs for the current period
|
||||
where Disposition=Available.
|
||||
"""
|
||||
self._send_message(message)
|
||||
|
||||
def send_flex_offer_response(self, message: FlexOfferResponse) -> None:
|
||||
"""
|
||||
Confirm reception of a flex offer.
|
||||
"""
|
||||
self._send_message(message)
|
||||
|
||||
def send_flex_order(self, message: FlexOrder) -> None:
|
||||
"""
|
||||
FlexOrder messages are used by DSOs to purchase flexibility from an AGR
|
||||
based on a previous FlexOffer. A FlexOrder message contains a list of
|
||||
ISPs, with, for each ISP, the change in consumption or production to be
|
||||
realized by the AGR, and the accepted price to be paid by the DSO for
|
||||
this amount of flexibility. This ISP list should be copied from the
|
||||
FlexOffer message without modification: AGR implementations will
|
||||
(and must) reject FlexOrder messages where the ISP list is not exactly
|
||||
the same as offered.
|
||||
"""
|
||||
self._send_message(message)
|
||||
|
||||
def send_flex_reservation_update(self, message: FlexReservationUpdate) -> None:
|
||||
"""
|
||||
For bilateral contracts, FlexReservationUpdate messages are used by DSOs
|
||||
to signal to an AGR which part of the contracted volume is still
|
||||
reserved and which part is not needed and may be used for other
|
||||
purposes. For each ISP, a power value is given which indicates how much
|
||||
power is still reserved. Zero power means that no power is reserved for
|
||||
that ISP and the sign of the power indicates the direction.
|
||||
"""
|
||||
self._send_message(message)
|
||||
|
||||
def send_flex_settlement(self, message: FlexSettlement) -> None:
|
||||
"""
|
||||
The FlexSettlement message is sent by DSOs on a regular basis
|
||||
(typically monthly) to AGRs, in order to initiate settlement.
|
||||
It includes a list of all FlexOrders placed by the
|
||||
originating party during the settlement period.
|
||||
"""
|
||||
self._send_message(message)
|
||||
|
||||
def send_flex_offer_revocation_response(self, message: FlexOfferRevocationResponse) -> None:
|
||||
"""
|
||||
Upon receiving and processing a FlexOfferRevocation message, the
|
||||
receiving implementation must reply with a FlexOfferRevocationResponse,
|
||||
indicating whether the revocation was handled successfully.
|
||||
"""
|
||||
self._send_message(message)
|
||||
|
||||
def send_metering_response(self, message: MeteringResponse) -> None:
|
||||
"""
|
||||
Confirm reception of metering data.
|
||||
"""
|
||||
self._send_message(message)
|
||||
@@ -0,0 +1,25 @@
|
||||
from ..uftp import DsoPortfolioQuery, DsoPortfolioUpdate, UsefRole
|
||||
from .base_client import ShapeshifterClient
|
||||
|
||||
|
||||
class ShapeshifterDsoCroClient(ShapeshifterClient):
|
||||
"""
|
||||
Client that allows the DSO to connect to the CRO.
|
||||
"""
|
||||
|
||||
sender_role = UsefRole.DSO
|
||||
recipient_role = UsefRole.CRO
|
||||
|
||||
def send_dso_portfolio_update(self, message: DsoPortfolioUpdate) -> None:
|
||||
"""
|
||||
The DSOPortfolioUpdate is used by the DSO to indicate on which
|
||||
congestion points it wants to engage in flexibility trading.
|
||||
"""
|
||||
self._send_message(message)
|
||||
|
||||
def send_dso_portfolio_query(self, message: DsoPortfolioQuery) -> None:
|
||||
"""
|
||||
DSOPortfolioQuery is used by DSOs to discover which AGRs represent
|
||||
connections on its registered congestion point(s).
|
||||
"""
|
||||
self._send_message(message)
|
||||
@@ -0,0 +1,272 @@
|
||||
"""
|
||||
Transport exceptions and functional exceptions that, when raised,
|
||||
trigger well-defined behaviour from the Shapeshifter UFTP
|
||||
implementation.
|
||||
|
||||
Subclasses of TransportException return the appropriate HTTP Status
|
||||
Code.
|
||||
|
||||
Subclasses of FunctionalException return a proper
|
||||
PayloadResponseMessage with result = REJECTED and the appropriate
|
||||
rejection_reason.
|
||||
|
||||
More information on these exceptions can be found in the Shapeshifter
|
||||
Specification. The relevant parts are copied as docstrings for these
|
||||
exceptions.
|
||||
"""
|
||||
from abc import ABC
|
||||
|
||||
|
||||
class TransportException(Exception, ABC):
|
||||
"""
|
||||
Base TransportException class that is used by FastApi to return
|
||||
the approprate status code.
|
||||
"""
|
||||
http_status_code: int
|
||||
|
||||
|
||||
class MissingContentLengthException(TransportException):
|
||||
"""
|
||||
Thrown when the content-length is missing from the message headers.
|
||||
"""
|
||||
|
||||
http_status_code = 411
|
||||
|
||||
|
||||
class InvalidContentTypeException(TransportException):
|
||||
"""
|
||||
Raised when the Content-Type header is not set to text/xml or the
|
||||
character set is not utf-8.
|
||||
"""
|
||||
|
||||
http_status_code = 400
|
||||
|
||||
|
||||
class TooManyRequestsException(TransportException):
|
||||
"""
|
||||
Raised when the originating IP address is making too many requests
|
||||
to the service.
|
||||
"""
|
||||
|
||||
http_status_code = 429
|
||||
|
||||
|
||||
class SchemaException(TransportException):
|
||||
"""
|
||||
Raised when the XML Body cannot be parsed or does not comply to
|
||||
the schema.
|
||||
"""
|
||||
|
||||
http_status_code = 400
|
||||
|
||||
|
||||
class AuthenticationTimeoutException(TransportException):
|
||||
"""
|
||||
Raised when the sender's public key could not be looked up in
|
||||
DNS.
|
||||
"""
|
||||
|
||||
http_status_code = 419
|
||||
|
||||
|
||||
class InvalidSignatureException(TransportException):
|
||||
"""
|
||||
Raised when the signed message could not be unsealed because of an
|
||||
invalid signature.
|
||||
"""
|
||||
|
||||
http_status_code = 401
|
||||
|
||||
|
||||
class FunctionalException(ABC, Exception):
|
||||
"""
|
||||
Base class for gunctional exceptions. When raised in a request
|
||||
context, FastAPI will return the appropriate response message to
|
||||
the other participant.
|
||||
"""
|
||||
rejection_reason: str
|
||||
|
||||
|
||||
class InvalidMessageException(FunctionalException):
|
||||
"""
|
||||
Despite being schema-compliant, the syntax, type or semantics of
|
||||
the message were unacceptable for the receiving implementation.
|
||||
"""
|
||||
def __init__(self, message):
|
||||
super().__init__()
|
||||
self.rejection_reason = f"Invalid Message: '{message.__class__.__name__}'"
|
||||
|
||||
|
||||
class InvalidSenderException(FunctionalException):
|
||||
"""
|
||||
There is a mismatch between the SenderDomain/Role combination in
|
||||
the message wrapper and the inner XML message.
|
||||
"""
|
||||
rejection_reason = "Invalid Sender"
|
||||
|
||||
|
||||
class UnknownRecipientException(FunctionalException):
|
||||
"""
|
||||
The RecipientDomain and/or RecipientRole specified in the inner
|
||||
XML message is not handled by this endpoint.
|
||||
"""
|
||||
rejection_reason = "Unknown Recipient"
|
||||
|
||||
|
||||
class BarredSenderException(FunctionalException):
|
||||
"""
|
||||
This endpoint is explicitly blocking messages from this sender.
|
||||
"""
|
||||
rejection_reason = "Barred Sender"
|
||||
|
||||
|
||||
class DuplicateIdentifierException(FunctionalException):
|
||||
"""
|
||||
The MessageID attribute of the inner XML message is not unique,
|
||||
and has already been used for a message with different content.
|
||||
This message has been rejected.
|
||||
"""
|
||||
rejection_reason = "Duplicate Identifier"
|
||||
|
||||
|
||||
class AlreadySubmittedException(FunctionalException):
|
||||
"""
|
||||
The MessageID attribute of the inner XML message is not unique,
|
||||
but since the message content is the same as that of a previously
|
||||
accepted message, this copy can be considered to be successfully
|
||||
submitted as well.
|
||||
"""
|
||||
rejection_reason = "Already Submitted"
|
||||
|
||||
|
||||
class ISPDurationRejectedException(FunctionalException):
|
||||
"""
|
||||
The message specifies a ISP duration that is not the agreed-upon
|
||||
common value for the market in which it is used.
|
||||
"""
|
||||
rejection_reason = "ISP Duration Rejected"
|
||||
|
||||
|
||||
class TimeZoneRejectedException(FunctionalException):
|
||||
"""
|
||||
The message specifies a time zone that has a different UTC offset
|
||||
than is the agreed-upon common value for the market.
|
||||
"""
|
||||
rejection_reason = "TimeZone Rejected"
|
||||
|
||||
|
||||
class InvalidCongestionPointException(FunctionalException):
|
||||
"""
|
||||
Unknown congestion point or the recipient is not active at this
|
||||
congestion point.
|
||||
"""
|
||||
rejection_reason = "Invalid Congestion Point"
|
||||
|
||||
|
||||
class UnknownReferenceException(FunctionalException):
|
||||
"""
|
||||
The message with the sequence where is referred to is unknown. For
|
||||
the concerning reference field name can be filled in (for example
|
||||
FlexRequestSequence or PrognosisSequence).
|
||||
"""
|
||||
rejection_reason = "Unknown Reference"
|
||||
|
||||
|
||||
class ReferencePeriodMismatchException(FunctionalException):
|
||||
"""
|
||||
The message(s) with the sequence where is referred to contains a
|
||||
different Period.
|
||||
"""
|
||||
rejection_reason = "Reference Period Mismatch"
|
||||
|
||||
|
||||
class ReferenceMessageExpiredException(FunctionalException):
|
||||
"""
|
||||
The message that is referred to is expired.
|
||||
"""
|
||||
rejection_reason = "Reference Message Expired"
|
||||
|
||||
|
||||
class ReferenceMessageRevokedException(FunctionalException):
|
||||
"""
|
||||
The message that is referred to is revoked.
|
||||
"""
|
||||
rejection_reason = "Reference Message Revoked"
|
||||
|
||||
|
||||
class ISPsOutOfBoundsException(FunctionalException):
|
||||
"""
|
||||
One or more ISPs are outside the tolerated boundaries: ISPs do not
|
||||
exist.
|
||||
"""
|
||||
rejection_reason = "ISPs Out Of Bounds"
|
||||
|
||||
|
||||
class ISPConflictException(FunctionalException):
|
||||
"""
|
||||
One or more ISPs are defined more than once, possibly because of
|
||||
an incorrect duration.
|
||||
"""
|
||||
rejection_reason = "ISP Conflict"
|
||||
|
||||
|
||||
class PeriodOutOfBoundsException(FunctionalException):
|
||||
"""
|
||||
Period of the message is inappropriate. For example: a FlexRequest
|
||||
with a Period in the past or a settlement item in a
|
||||
FlexSettlement with a Period outside the concerning settlement
|
||||
period.
|
||||
"""
|
||||
rejection_reason = "Period Out Of Bounds"
|
||||
|
||||
|
||||
class ExpirationDateTimeOutOfBoundsException(FunctionalException):
|
||||
"""
|
||||
ExpirationDateTime is in the past or exceeds the ISPs in the
|
||||
message.
|
||||
"""
|
||||
rejection_reason = "Expiration DateTime Out Of Bounds"
|
||||
|
||||
|
||||
class UnauthorizedException(FunctionalException):
|
||||
"""
|
||||
CRO is operating in closed mode and the DSO is not pre-registered
|
||||
as an authorized participant
|
||||
"""
|
||||
rejection_reason = "Unauthorized"
|
||||
|
||||
|
||||
class ConnectionConflictException(FunctionalException):
|
||||
"""
|
||||
A connection is transmitted before at another Congestion Point.
|
||||
Return EntityAddress of the concerning Connection and Congestion
|
||||
Point where it has been placed before.
|
||||
"""
|
||||
def __init__(self, connection_entity_address, congestion_point_entity_address):
|
||||
super().__init__()
|
||||
self.rejection_reason = (
|
||||
f"Connection conflict: {connection_entity_address} at {congestion_point_entity_address}"
|
||||
)
|
||||
|
||||
|
||||
class SubordinateSequenceNumberException(FunctionalException):
|
||||
"""
|
||||
The message sequence is lower than that of a previously received
|
||||
DSOPortfolioUpdate
|
||||
"""
|
||||
rejection_reason = "Subordinate Sequence Number"
|
||||
|
||||
|
||||
class ServiceDiscoveryException(Exception):
|
||||
"""
|
||||
Raised when there is an error during service discovery.
|
||||
"""
|
||||
|
||||
|
||||
class ClientTransportException(Exception):
|
||||
"""
|
||||
Raised when the response to the client is not HTTP 200.
|
||||
"""
|
||||
def __init__(self, *args, response, **kwargs):
|
||||
self.response = response
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -0,0 +1,37 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from termcolor import colored
|
||||
|
||||
color_map = {
|
||||
logging.DEBUG: "cyan",
|
||||
logging.INFO: "green",
|
||||
logging.WARNING: "yellow",
|
||||
logging.ERROR: "red",
|
||||
logging.CRITICAL: "magenta",
|
||||
}
|
||||
|
||||
|
||||
class ShapeshifterLogFormatter(logging.Formatter):
|
||||
"""
|
||||
Formatter for the shapeshifter logs.
|
||||
"""
|
||||
|
||||
def format(self, record):
|
||||
"""
|
||||
Format log recors using colors.
|
||||
"""
|
||||
color = color_map[record.levelno]
|
||||
return (
|
||||
colored(f"{record.levelname:10}", color)
|
||||
+ f"{datetime.now().astimezone().isoformat()} - {record.getMessage()}"
|
||||
)
|
||||
|
||||
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(ShapeshifterLogFormatter())
|
||||
handler.setLevel(logging.DEBUG)
|
||||
|
||||
logger = logging.getLogger("shapeshifter-uftp")
|
||||
logger.addHandler(handler)
|
||||
logger.setLevel(logging.DEBUG)
|
||||
@@ -0,0 +1,82 @@
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime
|
||||
from json import JSONDecodeError
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
class OAuthClient:
|
||||
|
||||
EXPIRATION_SAFETY_BUFFER = 60
|
||||
|
||||
def __init__(self, url, client_id, client_secret):
|
||||
self.url = url
|
||||
self.client_id = client_id
|
||||
self.client_secret = client_secret
|
||||
self.access_token = None
|
||||
self.access_token_type = None
|
||||
self.access_token_expiry = None
|
||||
|
||||
@contextmanager
|
||||
def ensure_authenticated(self):
|
||||
if not self.authenticated:
|
||||
self.authenticate()
|
||||
yield
|
||||
|
||||
@property
|
||||
def authenticated(self):
|
||||
return self.access_token and not self.expired
|
||||
|
||||
@property
|
||||
def expired(self):
|
||||
return self.access_token_expiry < (datetime.now().timestamp() + OAuthClient.EXPIRATION_SAFETY_BUFFER)
|
||||
|
||||
@property
|
||||
def auth_header(self):
|
||||
return {"Authorization": f"{self.access_token_type} {self.access_token}"}
|
||||
|
||||
def authenticate(self):
|
||||
response = requests.post(
|
||||
self.url,
|
||||
data={
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
},
|
||||
timeout=30
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise AuthorizationError(
|
||||
f"Could not obtain an access token from the OAuth server at {self.url}:"
|
||||
f"{response.text}"
|
||||
)
|
||||
try:
|
||||
response_data = response.json()
|
||||
except JSONDecodeError as err:
|
||||
raise AuthorizationError(
|
||||
f"The OAuth server at {self.url} did not return a valid JSON response: "
|
||||
f"{response.text}"
|
||||
) from err
|
||||
|
||||
try:
|
||||
self.access_token = response_data["access_token"]
|
||||
self.access_token_type = response_data["token_type"]
|
||||
self.access_token_expiry = datetime.now().timestamp() + response_data["expires_in"]
|
||||
except KeyError as err:
|
||||
raise AuthorizationError(
|
||||
f"The response from the OAuth server is missing the {str(err)} field"
|
||||
) from err
|
||||
|
||||
|
||||
class PassthroughOAuthClient:
|
||||
|
||||
auth_header = {}
|
||||
|
||||
@contextmanager
|
||||
def ensure_authenticated(self):
|
||||
yield
|
||||
|
||||
|
||||
class AuthorizationError(Exception):
|
||||
pass
|
||||
@@ -0,0 +1,9 @@
|
||||
from .agr_service import ShapeshifterAgrService
|
||||
from .cro_service import ShapeshifterCroService
|
||||
from .dso_service import ShapeshifterDsoService
|
||||
|
||||
__all__ = [
|
||||
"ShapeshifterAgrService",
|
||||
"ShapeshifterCroService",
|
||||
"ShapeshifterDsoService",
|
||||
]
|
||||
@@ -0,0 +1,192 @@
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from ..client import ShapeshifterAgrCroClient, ShapeshifterAgrDsoClient
|
||||
from ..uftp import (
|
||||
AgrPortfolioQueryResponse,
|
||||
AgrPortfolioUpdateResponse,
|
||||
DPrognosisResponse,
|
||||
FlexOfferResponse,
|
||||
FlexOfferRevocationResponse,
|
||||
FlexOrder,
|
||||
FlexRequest,
|
||||
FlexReservationUpdate,
|
||||
FlexSettlement,
|
||||
MeteringResponse,
|
||||
TestMessage,
|
||||
TestMessageResponse,
|
||||
UsefRole,
|
||||
)
|
||||
from .base_service import ShapeshifterService
|
||||
|
||||
|
||||
class ShapeshifterAgrService(
|
||||
ShapeshifterService, ABC
|
||||
): # pylint: disable=too-many-public-methods
|
||||
"""
|
||||
Service that represents the Aggregator in the UFTP communication.
|
||||
|
||||
This service can receive requests from the DSO.
|
||||
"""
|
||||
|
||||
sender_role = UsefRole.AGR
|
||||
acceptable_messages = [
|
||||
AgrPortfolioQueryResponse,
|
||||
AgrPortfolioUpdateResponse,
|
||||
DPrognosisResponse,
|
||||
FlexOfferResponse,
|
||||
FlexOfferRevocationResponse,
|
||||
FlexOrder,
|
||||
FlexRequest,
|
||||
FlexReservationUpdate,
|
||||
FlexSettlement,
|
||||
MeteringResponse,
|
||||
TestMessage,
|
||||
TestMessageResponse,
|
||||
]
|
||||
|
||||
|
||||
@abstractmethod
|
||||
def process_d_prognosis_response(self, message: DPrognosisResponse):
|
||||
"""
|
||||
FlexOffer messages are used by AGRs to make DSOs an offer for provision
|
||||
of flexibility. A FlexOffer message contains a list of ISPs and, for
|
||||
each ISP, the change in consumption or production offered and the price
|
||||
for the total amount of flexibility offered. FlexOffer messages can be
|
||||
sent once a FlexRequest message has been received but can also be sent
|
||||
unsolicited. Note that multiple FlexOffer messages may be sent based on
|
||||
a single FlexRequest, e.g. to increase the chance that the DSO will
|
||||
order at least part of its available flexibility. The AGR must make
|
||||
sure that it can actually provide the flexibility offered across all of
|
||||
its FlexOffers.
|
||||
"""
|
||||
|
||||
|
||||
@abstractmethod
|
||||
def process_flex_request(self, message: FlexRequest):
|
||||
"""
|
||||
This method should probably end by sending some Flex Offers to the DSO::
|
||||
|
||||
with self.dso_client(message.sender_domain) as client:
|
||||
response = client.send_flex_offer(FlexOffer(...)
|
||||
# Do something with the response here.
|
||||
"""
|
||||
|
||||
|
||||
@abstractmethod
|
||||
def process_flex_offer_response(self, message: FlexOfferResponse):
|
||||
"""
|
||||
This method should probably end by sending some Flex Offers to the DSO::
|
||||
|
||||
with self.dso_client(message.sender_domain) as client:
|
||||
response = client.send_flex_offer(FlexOffer(...)
|
||||
# Do something with the response here.
|
||||
"""
|
||||
|
||||
|
||||
@abstractmethod
|
||||
def process_flex_offer_revocation_response(
|
||||
self, message: FlexOfferRevocationResponse
|
||||
):
|
||||
"""
|
||||
Upon receiving and processing a FlexOfferRevocation message, the
|
||||
receiving implementation must reply with a FlexOfferRevocationResponse,
|
||||
indicating whether the revocation was handled successfully.
|
||||
|
||||
It is advised that this method ends by sending a FlexSettlementResponse to the DSO::
|
||||
|
||||
with self.dso_client(message.sender_domain):
|
||||
response = client.send_flex_settlement_response(FlexSettlementResponse(...))
|
||||
# do something with the response here.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def process_flex_order(self, message: FlexOrder):
|
||||
"""
|
||||
FlexOrder messages are used by DSOs to purchase flexibility from an AGR
|
||||
based on a previous FlexOffer. A FlexOrder message contains a list of
|
||||
ISPs, with, for each ISP, the change in consumption or production to be
|
||||
realized by the AGR, and the accepted price to be paid by the DSO for
|
||||
this amount of flexibility. This ISP list should be copied from the
|
||||
FlexOffer message without modification: AGR implementations will
|
||||
(and must) reject FlexOrder messages where the ISP list is not exactly
|
||||
the same as offered.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def process_flex_reservation_update(self, message: FlexReservationUpdate):
|
||||
"""
|
||||
For bilateral contracts, FlexReservationUpdate messages are used by DSOs
|
||||
to signal to an AGR which part of the contracted volume is still
|
||||
reserved and which part is not needed and may be used for other
|
||||
purposes. For each ISP, a power value is given which indicates how much
|
||||
power is still reserved. Zero power means that no power is reserved for
|
||||
that ISP and the sign of the power indicates the direction.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def process_flex_settlement(self, message: FlexSettlement):
|
||||
"""
|
||||
The FlexSettlement message is sent by DSOs on a regular basis
|
||||
(typically monthly) to AGRs, in order to initiate settlement. It
|
||||
includes a list of all FlexOrders placed by the originating party
|
||||
during the settlement period.
|
||||
|
||||
It is advised that this method ends by sending a FlexSettlementResponse to the DSO::
|
||||
|
||||
with self.get_dso_client(message.sender_domain):
|
||||
response = client.send_flex_settlement_response(FlexSettlementResponse(...))
|
||||
# do something with the response here.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def process_metering_response(self, message: MeteringResponse):
|
||||
"""
|
||||
Upon receiving and processing a Metering message, the
|
||||
receiving implementation must reply with a MeteringResponse,
|
||||
indicating whether the update was handled successfully.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def process_agr_portfolio_query_response(self, message: AgrPortfolioQueryResponse):
|
||||
"""
|
||||
The AgrPortfolioQueryResponse is sent by the CRO after you sent a
|
||||
AgrPortfolioQuery. It contains the list of your connections. It is
|
||||
recommended that you do not perform any long-running operations inside
|
||||
this function, but return a PayloadMessageResponse quickly.
|
||||
Longer-running operations (like a database sync) should be done inside
|
||||
the process_agr_portfolio_query_response method.
|
||||
|
||||
If the list of connections does not match what you expected it
|
||||
to be, you can send an AgrPortfolioUpdate message at the end
|
||||
of this method::
|
||||
|
||||
with self.get_dso_client(message.sender_domain) as client:
|
||||
response = client.send_portfolio_update(AgrPortfolioUpdate(...))
|
||||
# Do something with the response here
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def process_agr_portfolio_update_response(
|
||||
self, message: AgrPortfolioUpdateResponse
|
||||
):
|
||||
"""
|
||||
The AgrPortfolioUptadeResponse is sent by the CRO after you sent a
|
||||
AgrPortfolioUpdate.
|
||||
"""
|
||||
|
||||
# ------------------------------------------------------------ #
|
||||
# Convenience methods for getting a client to the designated #
|
||||
# participant. #
|
||||
# ------------------------------------------------------------ #
|
||||
|
||||
def cro_client(self, recipient_domain, version="3.1.0") -> ShapeshifterAgrCroClient:
|
||||
"""
|
||||
Retrieve a client object for sending messages to the CRO.
|
||||
"""
|
||||
return self._get_client(recipient_domain, "CRO", version)
|
||||
|
||||
def dso_client(self, recipient_domain, version="3.1.0") -> ShapeshifterAgrDsoClient:
|
||||
"""
|
||||
Retrieve a client object for sending messages to the DSO.
|
||||
"""
|
||||
return self._get_client(recipient_domain, "DSO", version)
|
||||
@@ -0,0 +1,310 @@
|
||||
import re
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from threading import Thread
|
||||
from time import sleep
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, Response
|
||||
from fastapi.exceptions import HTTPException
|
||||
from fastapi_xml import XmlAppResponse, XmlRoute
|
||||
|
||||
from .. import transport
|
||||
from ..client import client_map
|
||||
from ..exceptions import (
|
||||
FunctionalException,
|
||||
InvalidMessageException,
|
||||
InvalidSenderException,
|
||||
TransportException,
|
||||
)
|
||||
from ..logging import logger
|
||||
from ..uftp import (
|
||||
AcceptedRejected,
|
||||
PayloadMessage,
|
||||
SignedMessage,
|
||||
TestMessage,
|
||||
TestMessageResponse,
|
||||
UsefRole,
|
||||
request_response_map,
|
||||
)
|
||||
|
||||
|
||||
class ShapeshifterService():
|
||||
"""
|
||||
Basis for all Shapeshifter Services. Defines the web service, the
|
||||
message ingestion, the post-processing queue mechanics, and
|
||||
threading and context options.
|
||||
"""
|
||||
|
||||
sender_domain = None
|
||||
sender_role = None
|
||||
acceptable_messages = []
|
||||
|
||||
num_inbound_threads = 10
|
||||
num_outbound_threads = 10
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sender_domain,
|
||||
signing_key,
|
||||
key_lookup_function=None,
|
||||
endpoint_lookup_function=None,
|
||||
oauth_lookup_function=None,
|
||||
host: str = "0.0.0.0",
|
||||
port: int = 8080,
|
||||
path: str = "/shapeshifter/api/v3/message",
|
||||
version: str = "3.1.0"
|
||||
):
|
||||
"""
|
||||
:param sender_domain: our sender domain (FQDN) that the recipient uses to look us up.
|
||||
:param signing_key: the private singing key that we use to sign outgoing messages.
|
||||
:param key_lookup_function: A callable that takes a (sender_domain, sender_role)
|
||||
pair and returns a verify_key (str or bytes).
|
||||
Omit parameter to use DNS for key lookup.
|
||||
:param key_lookup_function: A callable that takes a (sender_domain, sender_role)
|
||||
pair and returns a full endpoint URL (str).
|
||||
Omit parameter to use DNS for endpoint lookup.
|
||||
:param oauth_lookup_function: A callable that takes a (sender_domain, sender_role)
|
||||
pair and returns in instance of shapeshifter_uftp.OAuthClient
|
||||
if OAuth authentication is required.
|
||||
:param host: the host to bind the server to (usually 127.0.0.1 or 0.0.0.0)
|
||||
:param port: the port to bind the server to (default: 8080)
|
||||
:param path: the URL path that the server listens on (default: /shapeshifter/api/v3/message)
|
||||
"""
|
||||
|
||||
if version not in ("3.0.0", "3.1.0"):
|
||||
raise ValueError(f"'version' must be one of '3.0.0' or '3.1.0', not {version}")
|
||||
|
||||
self.version = version
|
||||
|
||||
# Set the sender domain, which is used
|
||||
# to identify us to the other party.
|
||||
self.sender_domain = sender_domain
|
||||
|
||||
# The signing key is used to sign outgoing messages. The
|
||||
# corresponding public key should be published via DNS or
|
||||
# given to the recipient out-of-band.
|
||||
self.signing_key = signing_key
|
||||
|
||||
# The key lookup method is used to look up keys for the other
|
||||
# party. If omitted, use DNS lookups using well-known DNS names.
|
||||
self.key_lookup_function = key_lookup_function or transport.get_key
|
||||
|
||||
# The endpoint lookup method is used to look up the endpoint
|
||||
# that we send all messages to. If omitted, use DNS lookups
|
||||
# using well-known DNS names.
|
||||
self.endpoint_lookup_function = endpoint_lookup_function or transport.get_endpoint
|
||||
|
||||
# The OAuth lookup function is used to get the OAuth instance
|
||||
# used to authenticate outgoing requests.
|
||||
self.oauth_lookup_function = oauth_lookup_function
|
||||
|
||||
# The FastAPI web app takes care of routing messages to the
|
||||
# (one) endpoint, and by virtue of FastAPI-XML convert the
|
||||
# python-friendly objects into XML and vice versa.
|
||||
self.app = FastAPI(default_response_class=XmlAppResponse)
|
||||
self.app.router.route_class = XmlRoute
|
||||
self.app.router.add_api_route(
|
||||
path,
|
||||
endpoint=self._receive_message,
|
||||
response_model=None,
|
||||
methods=["POST"],
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
# The web server hosts the FastAPI application and takes care
|
||||
# of the HTTP transport.
|
||||
config = uvicorn.Config(app=self.app, host=host, port=port)
|
||||
self.server = uvicorn.Server(config)
|
||||
self.server_thread = None
|
||||
|
||||
# Create an inbound executor worker
|
||||
self.inbound_executor = ThreadPoolExecutor(max_workers=self.num_inbound_threads)
|
||||
self.outbound_executor = ThreadPoolExecutor(max_workers=self.num_outbound_threads)
|
||||
|
||||
|
||||
def run(self):
|
||||
"""
|
||||
Start the web server that hosts the FastAPI application. Other
|
||||
participants can now send messages to us.
|
||||
"""
|
||||
# Start the service and start accepting incoming requests.
|
||||
self.server.run()
|
||||
|
||||
def run_in_thread(self):
|
||||
"""
|
||||
Run the service in a background thread.
|
||||
"""
|
||||
self.server_thread = Thread(target=self.run)
|
||||
self.server_thread.start()
|
||||
while not self.server.started:
|
||||
sleep(0.1)
|
||||
|
||||
def stop(self):
|
||||
"""
|
||||
Stop the service if it was running in a separate thread.
|
||||
"""
|
||||
self.server.should_exit = True
|
||||
if self.server_thread and self.server_thread.is_alive():
|
||||
self.server_thread.join()
|
||||
self.server_thread = None
|
||||
|
||||
|
||||
# ------------------------------------------------------------ #
|
||||
# Message handling and processing methods, internal to the #
|
||||
# Shapeshifter UFTP implementation. #
|
||||
# ------------------------------------------------------------ #
|
||||
|
||||
def _receive_message(self, message: SignedMessage) -> Response:
|
||||
"""
|
||||
The default entrypoint for the route. This will unpack the
|
||||
message and validate the signature. It will thes pass the
|
||||
PayloadMessago to the pre processing function for a
|
||||
response.
|
||||
"""
|
||||
logger.info(f"Got a request: {message}")
|
||||
# Get the public key that is used to decrypt the message
|
||||
signing_key = self.key_lookup_function(
|
||||
message.sender_domain, message.sender_role.value
|
||||
)
|
||||
|
||||
logger.debug(f"The signing key is {signing_key}")
|
||||
|
||||
# Unseal the message, returning an error if required
|
||||
try:
|
||||
unsealed_message = transport.unseal_message(message.body, signing_key)
|
||||
|
||||
# Verify that the sender_domain inside the message is the
|
||||
# same as the sender_domain of the SignedMessage
|
||||
# envelope
|
||||
if unsealed_message.sender_domain != message.sender_domain:
|
||||
logger.warning(
|
||||
"Received a message with mismatching sender_domain in the "
|
||||
f"SignedMessage envelope ({message.sender_domain}) and the "
|
||||
f"inner PayloadMessage ({unsealed_message.sender_domain})"
|
||||
)
|
||||
raise InvalidSenderException()
|
||||
|
||||
if type(unsealed_message) not in self.acceptable_messages:
|
||||
logger.warning(
|
||||
f"Received a misdirected message of type {unsealed_message.__class__.__name__} "
|
||||
f"from {unsealed_message.sender_domain}.")
|
||||
raise InvalidMessageException(unsealed_message)
|
||||
|
||||
except TransportException as err:
|
||||
logger.warning(f"The original transport error is {err.__class__.__name__}: {err}")
|
||||
raise HTTPException(err.http_status_code) from err
|
||||
|
||||
except FunctionalException as err:
|
||||
self.outbound_executor.submit(self._reject_message, message, unsealed_message, err.rejection_reason)
|
||||
|
||||
else:
|
||||
# If the initial checks passed, process the message in the
|
||||
# user-defined pipeline.
|
||||
self.inbound_executor.submit(self._process_message, unsealed_message, message.sender_role)
|
||||
|
||||
return Response(status_code=200)
|
||||
|
||||
def _process_message(self, message: PayloadMessage, sender_role: UsefRole):
|
||||
"""
|
||||
Find the relevant post-processing method to handle the message
|
||||
outside of the request context, and run it.
|
||||
"""
|
||||
process_method_name = f"process_{snake_case(message.__class__.__name__)}"
|
||||
process_method = getattr(self, process_method_name)
|
||||
try:
|
||||
if isinstance(message, TestMessage):
|
||||
process_method(message, sender_role)
|
||||
else:
|
||||
process_method(message)
|
||||
except Exception as err: # pylint: disable=broad-exception-caught
|
||||
logger.error(
|
||||
f"An error occurred during the post-processing of a {message.__class__.__name__} message."
|
||||
f"{err.__class__.__name__}: {err}"
|
||||
)
|
||||
|
||||
def _get_client(self, recipient_domain: str, recipient_role: UsefRole, version: str = "3.1.0"):
|
||||
"""
|
||||
Method to get a relevant client to communicate to the
|
||||
indicated participant.
|
||||
"""
|
||||
client_cls = client_map[(self.sender_role, recipient_role)]
|
||||
recipient_endpoint = self.endpoint_lookup_function(recipient_domain, recipient_role)
|
||||
recipient_signing_key = self.key_lookup_function(recipient_domain, recipient_role)
|
||||
oauth_client = self.oauth_lookup_function(recipient_domain, recipient_role) if self.oauth_lookup_function else None
|
||||
return client_cls(
|
||||
sender_domain = self.sender_domain,
|
||||
signing_key = self.signing_key,
|
||||
recipient_domain = recipient_domain,
|
||||
recipient_endpoint = recipient_endpoint,
|
||||
recipient_signing_key = recipient_signing_key,
|
||||
oauth_client = oauth_client,
|
||||
version=version,
|
||||
)
|
||||
|
||||
def _reject_message(self, message, unsealed_message, reason):
|
||||
"""
|
||||
Send a rejection to the sending party.
|
||||
"""
|
||||
if type(unsealed_message) not in request_response_map:
|
||||
return
|
||||
|
||||
client = self._get_client(message.sender_domain, message.sender_role, unsealed_message.version)
|
||||
response_type = request_response_map[type(unsealed_message)]
|
||||
response_id_field = snake_case(type(unsealed_message).__name__) + "_message_id"
|
||||
message_contents = {
|
||||
"recipient_domain": message.sender_domain,
|
||||
"conversation_id": unsealed_message.conversation_id,
|
||||
"result": AcceptedRejected.REJECTED,
|
||||
"rejection_reason": reason,
|
||||
response_id_field: unsealed_message.message_id
|
||||
}
|
||||
response_message = response_type(**message_contents)
|
||||
client._send_message(response_message)
|
||||
|
||||
def __enter__(self):
|
||||
"""
|
||||
Context-manager method that allows an instance of this class to be
|
||||
used in a temporary context.
|
||||
|
||||
Starts the uvicorn server in a separate thread and waits for it
|
||||
to be started. Useful in testing scenarios.
|
||||
|
||||
Usage:
|
||||
|
||||
with MyShapeshifterServiceSubClass(...) as server:
|
||||
# your test code here, server exists cleanly when leaving
|
||||
# the 'with' block.
|
||||
"""
|
||||
self.run_in_thread()
|
||||
return self
|
||||
|
||||
def __exit__(self, *args, **kwargs):
|
||||
"""
|
||||
Tell uvicorn we should exit and wait for the thread to finish.
|
||||
"""
|
||||
self.stop()
|
||||
|
||||
# ------------------------------------------------------------ #
|
||||
# Common messages to all parties #
|
||||
# ------------------------------------------------------------ #
|
||||
|
||||
def process_test_message(self, message: TestMessage, sender_role: UsefRole):
|
||||
logger.info(
|
||||
"Received a TestMessage, will respond with TestMessageResponse. "
|
||||
f"Implement the process_test_message method on your {self.__class__.__qualname__} "
|
||||
f"to implement custom behavior. The message was: {message}"
|
||||
)
|
||||
response = TestMessageResponse(conversation_id=message.conversation_id)
|
||||
client = self._get_client(message.sender_domain, sender_role, version=message.version or self.version)
|
||||
client.send_test_message_response(response)
|
||||
|
||||
def process_test_message_response(self, message: TestMessage):
|
||||
logger.info(f"Received a TestMessageResponse: {message}")
|
||||
|
||||
|
||||
|
||||
def snake_case(text):
|
||||
"""
|
||||
Convert text from CamelCase to snake_case.
|
||||
"""
|
||||
return re.sub(r"(.)([A-Z][a-z])", r"\1_\2", text).lower()
|
||||
@@ -0,0 +1,86 @@
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from ..client import ShapeshifterCroAgrClient, ShapeshifterCroDsoClient
|
||||
from ..uftp import (
|
||||
AgrPortfolioQuery,
|
||||
AgrPortfolioUpdate,
|
||||
DsoPortfolioQuery,
|
||||
DsoPortfolioUpdate,
|
||||
TestMessage,
|
||||
TestMessageResponse,
|
||||
UsefRole,
|
||||
)
|
||||
from .base_service import ShapeshifterService
|
||||
|
||||
|
||||
class ShapeshifterCroService(ShapeshifterService, ABC):
|
||||
"""
|
||||
Service that represent the Common Reference Operator in the UFTP communication.
|
||||
|
||||
It can receive requests from the Aggregator and from the DSO.
|
||||
"""
|
||||
|
||||
sender_role = UsefRole.CRO
|
||||
acceptable_messages = [
|
||||
DsoPortfolioQuery,
|
||||
DsoPortfolioUpdate,
|
||||
AgrPortfolioQuery,
|
||||
AgrPortfolioUpdate,
|
||||
TestMessage,
|
||||
TestMessageResponse,
|
||||
]
|
||||
|
||||
# ------------------------------------------------------------ #
|
||||
# Methods related to Agr Portfolio Query messages #
|
||||
# ------------------------------------------------------------ #
|
||||
|
||||
@abstractmethod
|
||||
def process_agr_portfolio_query(self, message: AgrPortfolioQuery):
|
||||
"""
|
||||
The AGRPortfolioQuery is used by the AGR to retrieve
|
||||
additional information on the connections.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def process_agr_portfolio_update(self, message: AgrPortfolioUpdate):
|
||||
"""
|
||||
The AGRPortfolioUpdate is used by the AGR to indicate on which
|
||||
Connections it represents prosumers.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def process_dso_portfolio_query(self, message: DsoPortfolioQuery):
|
||||
"""
|
||||
DSOPortfolioQuery is used by DSOs to discover which AGRs represent
|
||||
connections on its registered congestion point(s).
|
||||
|
||||
You should end this method by sending a
|
||||
DsoPortfolioQueryResponse back to the DSO::
|
||||
|
||||
with self.get_dso_client(message.sender_domain) as client:
|
||||
client.send_portfolio_query_response
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def process_dso_portfolio_update(self, message: DsoPortfolioUpdate):
|
||||
"""
|
||||
The DSOPortfolioUpdate is used by the DSO to indicate on which
|
||||
congestion points it wants to engage in flexibility trading.
|
||||
"""
|
||||
|
||||
# ------------------------------------------------------------ #
|
||||
# Convenience methods for getting a client to the designated #
|
||||
# participant. #
|
||||
# ------------------------------------------------------------ #
|
||||
|
||||
def agr_client(self, recipient_domain, version="3.1.0") -> ShapeshifterCroAgrClient:
|
||||
"""
|
||||
Retrieve a client object for sending messages to the AGR.
|
||||
"""
|
||||
return self._get_client(recipient_domain, "AGR", version)
|
||||
|
||||
def dso_client(self, recipient_domain, version="3.1.0") -> ShapeshifterCroDsoClient:
|
||||
"""
|
||||
Retrieve a client object for sending messages to the DSO.
|
||||
"""
|
||||
return self._get_client(recipient_domain, "DSO", version)
|
||||
@@ -0,0 +1,173 @@
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from ..client import ShapeshifterDsoAgrClient, ShapeshifterDsoCroClient
|
||||
from ..uftp import (
|
||||
DPrognosis,
|
||||
DsoPortfolioQueryResponse,
|
||||
DsoPortfolioUpdateResponse,
|
||||
FlexOffer,
|
||||
FlexOfferRevocation,
|
||||
FlexOrderResponse,
|
||||
FlexRequestResponse,
|
||||
FlexReservationUpdateResponse,
|
||||
FlexSettlementResponse,
|
||||
Metering,
|
||||
TestMessage,
|
||||
TestMessageResponse,
|
||||
UsefRole,
|
||||
)
|
||||
from .base_service import ShapeshifterService
|
||||
|
||||
|
||||
# pylint: disable=too-many-public-methods
|
||||
class ShapeshifterDsoService(ShapeshifterService, ABC):
|
||||
"""
|
||||
Service that represents the Distribution System Operator in the UFTP communication.
|
||||
|
||||
It can receive requests from the Aggregator.
|
||||
|
||||
You should subclass this class and implement your own message handling methods.
|
||||
"""
|
||||
|
||||
sender_role = UsefRole.DSO
|
||||
acceptable_messages = [
|
||||
DPrognosis,
|
||||
DsoPortfolioQueryResponse,
|
||||
DsoPortfolioUpdateResponse,
|
||||
FlexOffer,
|
||||
FlexOfferRevocation,
|
||||
FlexOrderResponse,
|
||||
FlexRequestResponse,
|
||||
FlexReservationUpdateResponse,
|
||||
FlexSettlementResponse,
|
||||
Metering,
|
||||
TestMessage,
|
||||
TestMessageResponse,
|
||||
]
|
||||
|
||||
@abstractmethod
|
||||
def process_d_prognosis(self, message: DPrognosis):
|
||||
"""
|
||||
D-Prognosis messages are used to communicate D-prognoses between AGRs
|
||||
and DSOs. D-Prognosis messages always contain data for all ISPs for the
|
||||
period they apply to, even if a prognosis is sent after the start of
|
||||
the period, when one or more ISPs are already in the operate or
|
||||
settlement phase. Receiving implementations should ignore the
|
||||
information supplied for those ISPs.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def process_flex_request_response(self, message: FlexRequestResponse):
|
||||
"""
|
||||
FlexOffer messages are used by AGRs to make DSOs an offer for provision
|
||||
of flexibility. A FlexOffer message contains a list of ISPs and, for
|
||||
each ISP, the change in consumption or production offered and the price
|
||||
for the total amount of flexibility offered. FlexOffer messages can be
|
||||
sent once a FlexRequest message has been received but can also be sent
|
||||
unsolicited. Note that multiple FlexOffer messages may be sent based on
|
||||
a single FlexRequest, e.g. to increase the chance that the DSO will
|
||||
order at least part of its available flexibility. The AGR must make
|
||||
sure that it can actually provide the flexibility offered across all of
|
||||
its FlexOffers.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def process_flex_offer(self, message: FlexOffer):
|
||||
"""
|
||||
FlexOffer messages are used by AGRs to make DSOs an offer for provision
|
||||
of flexibility. A FlexOffer message contains a list of ISPs and, for
|
||||
each ISP, the change in consumption or production offered and the price
|
||||
for the total amount of flexibility offered. FlexOffer messages can be
|
||||
sent once a FlexRequest message has been received but can also be sent
|
||||
unsolicited. Note that multiple FlexOffer messages may be sent based on
|
||||
a single FlexRequest, e.g. to increase the chance that the DSO will
|
||||
order at least part of its available flexibility. The AGR must make
|
||||
sure that it can actually provide the flexibility offered across all of
|
||||
its FlexOffers.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def process_flex_order_response(self, message: FlexOrderResponse):
|
||||
"""
|
||||
Upon receiving and processing a FlexOrder message, the receiving
|
||||
implementation must reply with a FlexOrderResponse, indicating whether
|
||||
the update was handled successfully.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def process_flex_offer_revocation(self, message: FlexOfferRevocation):
|
||||
"""
|
||||
The FlexOfferRevocation message is used by the AGR to revoke a FlexOffer
|
||||
previously sent to a DSO. It voids the FlexOffer, even if its validity
|
||||
time has not yet expired. Revocation is not allowed for FlexOffers that
|
||||
already have associated accepted FlexOrders.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def process_flex_reservation_update_response(
|
||||
self, message: FlexReservationUpdateResponse
|
||||
):
|
||||
"""
|
||||
The FlexOfferRevocation message is used by the AGR to revoke a FlexOffer
|
||||
previously sent to a DSO. It voids the FlexOffer, even if its validity
|
||||
time has not yet expired. Revocation is not allowed for FlexOffers that
|
||||
already have associated accepted FlexOrders.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def process_flex_settlement_response(self, message: FlexSettlementResponse):
|
||||
"""
|
||||
Upon receiving and processing a FlexSettlement message, the AGR must
|
||||
reply with a FlexSettlementResponse, indicating whether the initial
|
||||
message was handled successfully. When a FlexSettlement message is
|
||||
rejected, the DSO should consider all FlexOrderSettlement elements of
|
||||
that message related to potential dispute.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def process_dso_portfolio_query_response(self, message: DsoPortfolioQueryResponse):
|
||||
"""
|
||||
Upon receiving and processing a DSOPortfolioQuery message, the receiving
|
||||
implementation must reply with a DSOPortfolioQueryResponse, indicating
|
||||
whether the query executed successfully, and if it did, including the
|
||||
query results. Most queries will return zero or more congestion points
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def process_dso_portfolio_update_response(
|
||||
self, message: DsoPortfolioUpdateResponse
|
||||
):
|
||||
"""
|
||||
Upon receiving and processing a DSOPortfolioUpdate message, the
|
||||
receiving implementation must reply with a DSOPortfolioUpdateResponse,
|
||||
indicating whether the update was handled successfully.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def process_metering(self, message: Metering):
|
||||
"""
|
||||
The Metering message is an optional message. The DSO will specify
|
||||
whether metering messages are required for a given program. If metering
|
||||
messages are used then the AGR must send metering messages, with one
|
||||
message sent per connection point per day. The metering messages must
|
||||
all be sent before the settlement can be performed. It is recommend to
|
||||
send the metering messages daily, once the metering data has been
|
||||
collected for the day.
|
||||
"""
|
||||
|
||||
# ------------------------------------------------------------ #
|
||||
# Convenience methods for getting a client to the designated #
|
||||
# participant. #
|
||||
# ------------------------------------------------------------ #
|
||||
|
||||
def agr_client(self, recipient_domain, version="3.1.0") -> ShapeshifterDsoAgrClient:
|
||||
"""
|
||||
Retrieve a client object for sending messages to the AGR.
|
||||
"""
|
||||
return self._get_client(recipient_domain, "AGR", version)
|
||||
|
||||
def cro_client(self, recipient_domain, version="3.1.0") -> ShapeshifterDsoCroClient:
|
||||
"""
|
||||
Retrieve a client object for sending messages to the CRO.
|
||||
"""
|
||||
return self._get_client(recipient_domain, "CRO", version)
|
||||
@@ -0,0 +1,258 @@
|
||||
"""
|
||||
Defines the message transport, including message signatures.
|
||||
"""
|
||||
import re
|
||||
from base64 import b64decode, b64encode
|
||||
from binascii import Error as BinAsciiError
|
||||
from datetime import datetime
|
||||
|
||||
import dns.resolver
|
||||
from nacl.bindings import crypto_sign, crypto_sign_open
|
||||
from nacl.exceptions import BadSignatureError
|
||||
from xsdata.exceptions import ParserError
|
||||
from xsdata.formats.dataclass.context import XmlContext
|
||||
from xsdata.formats.dataclass.parsers import JsonParser, XmlParser
|
||||
from xsdata.formats.dataclass.serializers import JsonSerializer, XmlSerializer
|
||||
from xsdata.formats.dataclass.serializers.config import SerializerConfig
|
||||
|
||||
from .exceptions import (
|
||||
AuthenticationTimeoutException,
|
||||
InvalidSignatureException,
|
||||
SchemaException,
|
||||
ServiceDiscoveryException,
|
||||
)
|
||||
from .logging import logger
|
||||
from .uftp import PayloadMessage, SignedMessage
|
||||
|
||||
_context = XmlContext()
|
||||
serializer = XmlSerializer(context=_context, config=SerializerConfig(indent=" "))
|
||||
parser = XmlParser(context=_context)
|
||||
|
||||
json_serializer = JsonSerializer()
|
||||
json_parser = JsonParser()
|
||||
|
||||
def seal_message(message: PayloadMessage, private_key: str) -> bytes:
|
||||
"""
|
||||
Sign a message using the provided private key. The message should
|
||||
be of type PayloadMessage (or any subtype thereof). The private
|
||||
key should be given in base64-encoded form.
|
||||
|
||||
The message will be returned as an opaque blob op base64 bytes.
|
||||
(In reality, this is the 64-byte signature prepended to the
|
||||
original XML message.)
|
||||
"""
|
||||
if not isinstance(message, PayloadMessage):
|
||||
raise TypeError(f"'message', must be of type PayloadMessage, got: {type(message)}")
|
||||
|
||||
serialized_message = to_xml(message)
|
||||
logger.debug(f"Signing outgoing message {serialized_message}")
|
||||
sealed_message = crypto_sign(serialized_message.encode("utf-8"), b64decode(private_key))
|
||||
return sealed_message
|
||||
|
||||
|
||||
def unseal_message(message: bytes, public_key: str) -> PayloadMessage:
|
||||
"""
|
||||
Validate a message's signature using the provided public key.
|
||||
The message can be given as a string or as bytes. The public
|
||||
key should be given in base64-encoded form.
|
||||
|
||||
The message will be returned as a PayloadMessage object.
|
||||
"""
|
||||
if public_key is None:
|
||||
logger.warning(
|
||||
"When calling unseal_message, no public key was provided. "
|
||||
"Please check that your key_lookup function returns a key."
|
||||
)
|
||||
raise TypeError("'public_key' must be of type 'str', not None")
|
||||
try:
|
||||
unsealed_message = crypto_sign_open(message, b64decode(public_key))
|
||||
logger.debug(f"Incoming Message: {unsealed_message.decode('utf-8')}")
|
||||
return from_xml(unsealed_message)
|
||||
except BadSignatureError as exc:
|
||||
logger.warning(f"The XML Signature for message {message} does not match the public key {public_key}: {exc}.")
|
||||
raise InvalidSignatureException() from exc
|
||||
except (ParserError, TypeError, ValueError) as exc:
|
||||
logger.warning(f"The incoming XML Message {message} does not conform to the XML schema: {exc}.")
|
||||
raise SchemaException(str(exc)) from exc
|
||||
|
||||
|
||||
def to_xml(message: PayloadMessage | SignedMessage) -> str:
|
||||
"""
|
||||
Serialize the given PayloadMessage into an XML string.
|
||||
"""
|
||||
return serializer.render(message)
|
||||
|
||||
|
||||
def from_xml(message: str | bytes):
|
||||
"""
|
||||
Parse the given message string into a Shapeshifter UFTP object.
|
||||
"""
|
||||
if isinstance(message, str):
|
||||
return parser.from_string(message)
|
||||
if isinstance(message, bytes):
|
||||
return parser.from_bytes(message)
|
||||
raise TypeError(f"Message should be either bytes or str, not {type(message)}")
|
||||
|
||||
|
||||
def to_json(message: PayloadMessage):
|
||||
"""
|
||||
Serializes the given PayloadMessage to json. Useful when
|
||||
transferring the message outside of shapeshifter-uftp.
|
||||
"""
|
||||
return json_serializer.render(message)
|
||||
|
||||
|
||||
def from_json(message: str, message_type: type):
|
||||
"""
|
||||
Parse the given json string into a message of the given type.
|
||||
"""
|
||||
return json_parser.from_string(message, message_type)
|
||||
|
||||
|
||||
def ttl_cache(ttl):
|
||||
"""
|
||||
Caching decorator that will cache the result of an operation for 'ttl' seconds.
|
||||
"""
|
||||
|
||||
def decorator(func):
|
||||
cached_values = {}
|
||||
|
||||
def wrapper(*args, **kwargs):
|
||||
# Create the cache key from the args and kwargs.
|
||||
cache_key = args
|
||||
if kwargs:
|
||||
cache_key += tuple((kwargs.items()))
|
||||
|
||||
# Look up the cache key in the cache
|
||||
if cache_key in cached_values:
|
||||
expiration, data = cached_values[cache_key]
|
||||
if expiration > datetime.now().timestamp():
|
||||
return data
|
||||
|
||||
# If the key was expired, delete it from the cache.
|
||||
del cached_values[cache_key]
|
||||
|
||||
# If not in cache or cache expired, call the original function and return the result
|
||||
data = func(*args, **kwargs)
|
||||
cached_values[cache_key] = (datetime.now().timestamp() + ttl, data)
|
||||
return data
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
@ttl_cache(3600)
|
||||
def get_keys(domain, role):
|
||||
"""
|
||||
Retrieve the sender's public key using a DNS request. These are published at
|
||||
the well-known DNS name _usef._role._domain, in the format 'cs1.' +
|
||||
base64-encoded value of ([public signing key] + [public decryption key]).
|
||||
"""
|
||||
|
||||
# Perform the DNS lookup at the well-known DNS name
|
||||
try:
|
||||
dns_name = f"_{role}._usef.{domain}"
|
||||
result = dns.resolver.resolve(dns_name, "TXT").response.answer[0][0].strings[0]
|
||||
except dns.resolver.NXDOMAIN as exc:
|
||||
# Indicates that the domain does not even exist
|
||||
raise AuthenticationTimeoutException(
|
||||
f"Could not retrieve public keys at {dns_name}: DNS name not found."
|
||||
) from exc
|
||||
except dns.resolver.NoNameservers as exc:
|
||||
raise ServiceDiscoveryException(
|
||||
f"Could not retrieve public key at {dns_name} because no DNS server was available (SERVFAIL). "
|
||||
"Make sure your network setup is working properly. This is not a problem with the receiving participant."
|
||||
) from exc
|
||||
|
||||
|
||||
# Now verify that the string begins with `cs1.`
|
||||
if not result.startswith(b"cs1."):
|
||||
raise AuthenticationTimeoutException(
|
||||
f"Could not retrieve public keys at {dns_name}: "
|
||||
f"invalid string (must start with 'cs1.', was: {result.decode()})"
|
||||
)
|
||||
|
||||
# Verify that the string is of the expected length (4 + 44 bytes or 4 + 88 bytes)
|
||||
if len(result) not in (48, 92):
|
||||
raise AuthenticationTimeoutException(
|
||||
f"Could not retrieve public key(s) at {dns_name}: "
|
||||
f"string '{result}' was not of appropriate length (48 or 90 characters)"
|
||||
)
|
||||
|
||||
# Now try to decode the string using base64
|
||||
try:
|
||||
combined_keys = b64decode(result[4:])
|
||||
except BinAsciiError as exc:
|
||||
raise AuthenticationTimeoutException(
|
||||
f"Could not retrieve public keys at {dns_name}: "
|
||||
f"string '{result[4:].decode()}' is not valid base64."
|
||||
) from exc
|
||||
|
||||
# Now verify that the decoded length is 64
|
||||
if len(combined_keys) not in (32, 64):
|
||||
raise AuthenticationTimeoutException(
|
||||
f"Could not retrieve public keys at {dns_name}: "
|
||||
f"decoded base64 data should be 32 or 64 bytes long, "
|
||||
f"length is: {len(combined_keys)}."
|
||||
)
|
||||
|
||||
# Now split the two bytestrings; the first will be the verify key,
|
||||
# the second will be the encryption key.
|
||||
if len(combined_keys) == 32:
|
||||
return b64encode(combined_keys).decode(), None
|
||||
|
||||
return b64encode(combined_keys[:32]).decode(), b64encode(combined_keys[32:]).decode()
|
||||
|
||||
|
||||
def get_key(domain, role):
|
||||
"""
|
||||
Return only the verification key from what might be two keys.
|
||||
"""
|
||||
return get_keys(domain, role)[0]
|
||||
|
||||
@ttl_cache(3600)
|
||||
def get_endpoint(domain, role):
|
||||
"""
|
||||
Retrieve the recipient's endpoint using DNS. These are published at the
|
||||
well-know DNS name _usef._role._domain
|
||||
"""
|
||||
dns_name = f"_http._{role}._usef.{domain}"
|
||||
try:
|
||||
result = (
|
||||
dns.resolver.resolve(dns_name, "CNAME")
|
||||
.response.answer[0][0]
|
||||
.to_text()
|
||||
)
|
||||
except dns.resolver.NXDOMAIN as exc:
|
||||
raise ServiceDiscoveryException(
|
||||
f"Could not retrieve endpoint at {dns_name}: DNS name not found."
|
||||
) from exc
|
||||
|
||||
# To complete the URL, get the endpoint version
|
||||
version = get_version(domain)
|
||||
major_version = version.split(".")[0]
|
||||
|
||||
# Construct the well-known URL using the retrieved endpoint domain and version
|
||||
endpoint_url = f"https://{result.removesuffix('.')}/shapeshifter/api/v{major_version}/message"
|
||||
return endpoint_url
|
||||
|
||||
|
||||
@ttl_cache(3600)
|
||||
def get_version(domain):
|
||||
"""
|
||||
Retrieve the supported Shapeshifter versions by the recipient.
|
||||
These are published at the well-known DNS name _usef._domain.
|
||||
"""
|
||||
dns_name = f"_usef.{domain}"
|
||||
try:
|
||||
result = dns.resolver.resolve(dns_name, "TXT").response.answer[0][0].strings[0].decode().strip()
|
||||
if not re.match(r"[0-9]+\.[0-9]+\.[0-9]+", result):
|
||||
raise ServiceDiscoveryException(
|
||||
f"The retrieved version was not in the format X.Y.Z: {result}"
|
||||
)
|
||||
return result
|
||||
except dns.resolver.NXDOMAIN as exc:
|
||||
raise ServiceDiscoveryException(
|
||||
f"Could not retrieve version at {dns_name}: DNS name not found."
|
||||
) from exc
|
||||
@@ -0,0 +1,115 @@
|
||||
from .enums import *
|
||||
from .messages import *
|
||||
|
||||
ACCEPTED = AcceptedRejected.ACCEPTED
|
||||
REJECTED = AcceptedRejected.REJECTED
|
||||
|
||||
__all__ = [
|
||||
"AcceptedRejected",
|
||||
"AcceptedDisputed",
|
||||
"AvailableRequested",
|
||||
"AgrPortfolioQuery",
|
||||
"AgrPortfolioQueryResponse",
|
||||
"AgrPortfolioQueryResponseCongestionPoint",
|
||||
"AgrPortfolioQueryResponseConnection",
|
||||
"AgrPortfolioQueryResponseDSOPortfolio",
|
||||
"AgrPortfolioQueryResponseDSOView",
|
||||
"AgrPortfolioUpdate",
|
||||
"AgrPortfolioUpdateConnection",
|
||||
"AgrPortfolioUpdateResponse",
|
||||
"ContractSettlement",
|
||||
"ContractSettlementISP",
|
||||
"ContractSettlementPeriod",
|
||||
"DPrognosis",
|
||||
"DPrognosisISP",
|
||||
"DPrognosisResponse",
|
||||
"DsoPortfolioQuery",
|
||||
"DsoPortfolioQueryCongestionPoint",
|
||||
"DsoPortfolioQueryConnection",
|
||||
"DsoPortfolioQueryResponse",
|
||||
"DsoPortfolioUpdate",
|
||||
"DsoPortfolioUpdateCongestionPoint",
|
||||
"DsoPortfolioUpdateConnection",
|
||||
"DsoPortfolioUpdateResponse",
|
||||
"FlexMessage",
|
||||
"FlexOffer",
|
||||
"FlexOfferOption",
|
||||
"FlexOfferOptionISP",
|
||||
"FlexOfferResponse",
|
||||
"FlexOfferRevocation",
|
||||
"FlexOfferRevocationResponse",
|
||||
"FlexOrder",
|
||||
"FlexOrderISP",
|
||||
"FlexOrderResponse",
|
||||
"FlexOrderSettlement",
|
||||
"FlexOrderSettlementISP",
|
||||
"FlexOrderSettlementStatus",
|
||||
"FlexOrderStatus",
|
||||
"FlexRequest",
|
||||
"FlexRequestISP",
|
||||
"FlexRequestResponse",
|
||||
"FlexReservationUpdate",
|
||||
"FlexReservationUpdateISP",
|
||||
"FlexReservationUpdateResponse",
|
||||
"FlexSettlement",
|
||||
"FlexSettlementResponse",
|
||||
"Metering",
|
||||
"MeteringISP",
|
||||
"MeteringProfile",
|
||||
"MeteringProfileEnum",
|
||||
"MeteringResponse",
|
||||
"MeteringUnit",
|
||||
"PayloadMessage",
|
||||
"PayloadMessageResponse",
|
||||
"SignedMessage",
|
||||
"TestMessage",
|
||||
"TestMessageResponse",
|
||||
"UsefRole",
|
||||
"RedispatchBy",
|
||||
]
|
||||
|
||||
routing_map = {
|
||||
AgrPortfolioQuery: ("AGR", "CRO"),
|
||||
AgrPortfolioQueryResponse: ("CRO", "AGR"),
|
||||
AgrPortfolioUpdate: ("AGR", "CRO"),
|
||||
AgrPortfolioUpdateResponse: ("CRO", "AGR"),
|
||||
DPrognosis: ("AGR", "DSO"),
|
||||
DPrognosisResponse: ("DSO", "AGR"),
|
||||
DsoPortfolioQuery: ("DSO", "CRO"),
|
||||
DsoPortfolioQueryResponse: ("CRO", "DSO"),
|
||||
DsoPortfolioUpdate: ("DSO", "CRO"),
|
||||
DsoPortfolioUpdateResponse: ("CRO", "DSO"),
|
||||
FlexOffer: ("AGR", "DSO"),
|
||||
FlexOfferResponse: ("DSO", "AGR"),
|
||||
FlexOfferRevocation: ("AGR", "DSO"),
|
||||
FlexOfferRevocationResponse: ("DSO", "AGR"),
|
||||
FlexOrder: ("DSO", "AGR"),
|
||||
FlexOrderResponse: ("AGR", "DSO"),
|
||||
FlexRequest: ("DSO", "AGR"),
|
||||
FlexRequestResponse: ("AGR", "DSO"),
|
||||
FlexReservationUpdate: ("DSO", "AGR"),
|
||||
FlexReservationUpdateResponse: ("AGR", "DSO"),
|
||||
FlexSettlement: ("DSO", "AGR"),
|
||||
FlexSettlementResponse: ("AGR", "DSO"),
|
||||
Metering: ("AGR", "DSO"),
|
||||
MeteringResponse: ("DSO", "AGR"),
|
||||
}
|
||||
|
||||
request_response_map = {
|
||||
AgrPortfolioQuery: AgrPortfolioQueryResponse,
|
||||
AgrPortfolioUpdate: AgrPortfolioUpdateResponse,
|
||||
DPrognosis: DPrognosisResponse,
|
||||
DsoPortfolioQuery: DsoPortfolioQueryResponse,
|
||||
DsoPortfolioUpdate: DsoPortfolioUpdateResponse,
|
||||
FlexOffer: FlexOfferResponse,
|
||||
FlexOfferRevocation: FlexOfferRevocationResponse,
|
||||
FlexOrder: FlexOrderResponse,
|
||||
FlexRequest: FlexRequestResponse,
|
||||
FlexReservationUpdate: FlexReservationUpdateResponse,
|
||||
FlexSettlement: FlexSettlementResponse,
|
||||
Metering: MeteringResponse,
|
||||
TestMessage: TestMessageResponse,
|
||||
}
|
||||
|
||||
origin_map = {key: origin for key, (origin, destination) in routing_map.items()}
|
||||
destination_map = {key: destination for key, (origin, destination) in routing_map.items()}
|
||||
@@ -0,0 +1 @@
|
||||
DEFAULT_TIME_ZONE = "Europe/Amsterdam"
|
||||
@@ -0,0 +1,26 @@
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class AcceptedDisputed(StrEnum):
|
||||
ACCEPTED = "Accepted"
|
||||
DISPUTED = "Disputed"
|
||||
|
||||
|
||||
class AcceptedRejected(StrEnum):
|
||||
ACCEPTED = "Accepted"
|
||||
REJECTED = "Rejected"
|
||||
|
||||
|
||||
class AvailableRequested(StrEnum):
|
||||
AVAILABLE = "Available"
|
||||
REQUESTED = "Requested"
|
||||
|
||||
|
||||
class RedispatchBy(StrEnum):
|
||||
AGR = "AGR"
|
||||
DSO = "DSO"
|
||||
|
||||
class UsefRole(StrEnum):
|
||||
AGR = "AGR"
|
||||
CRO = "CRO"
|
||||
DSO = "DSO"
|
||||
@@ -0,0 +1,16 @@
|
||||
from .agr_portfolio_query import *
|
||||
from .agr_portfolio_update import *
|
||||
from .d_prognosis import *
|
||||
from .dso_portfolio_query import *
|
||||
from .dso_portfolio_update import *
|
||||
from .flex_message import *
|
||||
from .flex_offer import *
|
||||
from .flex_offer_revocation import *
|
||||
from .flex_order import *
|
||||
from .flex_request import *
|
||||
from .flex_reservation_update import *
|
||||
from .flex_settlement import *
|
||||
from .metering import *
|
||||
from .payload_message import *
|
||||
from .signed_message import *
|
||||
from .test_message import *
|
||||
@@ -0,0 +1,231 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional
|
||||
|
||||
from xsdata.models.datatype import XmlDate
|
||||
|
||||
from ..defaults import DEFAULT_TIME_ZONE
|
||||
from ..enums import RedispatchBy
|
||||
from ..validations import validate_list
|
||||
from .payload_message import PayloadMessage, PayloadMessageResponse
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class AgrPortfolioQueryResponseConnection:
|
||||
"""
|
||||
:ivar entity_address: EntityAddress of the Connection.
|
||||
"""
|
||||
class Meta:
|
||||
name = "AGRPortfolioQueryResponseConnection"
|
||||
|
||||
entity_address: str = field(
|
||||
metadata={
|
||||
"name": "EntityAddress",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"(ea1\.[0-9]{4}-[0-9]{2}\..{1,244}:.{1,244}|ean\.[0-9]{12,34})",
|
||||
}
|
||||
)
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class AgrPortfolioQueryResponseCongestionPoint:
|
||||
"""
|
||||
:ivar connection:
|
||||
:ivar entity_address: EntityAddress of the CongestionPoint.
|
||||
:ivar mutex_offers_supported: Indicates whether the DSO accepts
|
||||
mutual exclusive FlexOffers on this CongestionPoint.
|
||||
:ivar day_ahead_redispatch_by: Indicates which party is responsible
|
||||
for day-ahead redispatch.
|
||||
:ivar intraday_redispatch_by: Indicates which party is responsible
|
||||
for intraday ahead redispatch, AGR or DSO. If not specified,
|
||||
there will be no intraday trading on this CongestionPoint.
|
||||
"""
|
||||
class Meta:
|
||||
name = "AGRPortfolioQueryResponseCongestionPoint"
|
||||
|
||||
connections: List[AgrPortfolioQueryResponseConnection] = field(
|
||||
default_factory=list,
|
||||
metadata={
|
||||
"name": "Connection",
|
||||
"type": "Element",
|
||||
"min_occurs": 1,
|
||||
}
|
||||
)
|
||||
entity_address: str = field(
|
||||
metadata={
|
||||
"name": "EntityAddress",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"(ea1\.[0-9]{4}-[0-9]{2}\..{1,244}:.{1,244}|ean\.[0-9]{12,34})",
|
||||
}
|
||||
)
|
||||
mutex_offers_supported: bool = field(
|
||||
metadata={
|
||||
"name": "MutexOffersSupported",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
day_ahead_redispatch_by: RedispatchBy = field(
|
||||
metadata={
|
||||
"name": "DayAheadRedispatchBy",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
intraday_redispatch_by: Optional[RedispatchBy] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "IntradayRedispatchBy",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
validate_list('connections', self.connections, AgrPortfolioQueryResponseConnection, 1)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class AgrPortfolioQueryResponseDSOPortfolio:
|
||||
class Meta:
|
||||
name = "AGRPortfolioQueryResponseDSOPortfolio"
|
||||
|
||||
congestion_points: List[AgrPortfolioQueryResponseCongestionPoint] = field(
|
||||
default_factory=list,
|
||||
metadata={
|
||||
"name": "CongestionPoint",
|
||||
"type": "Element",
|
||||
"min_occurs": 1,
|
||||
}
|
||||
)
|
||||
dso_domain: str = field(
|
||||
metadata={
|
||||
"name": "DSO-Domain",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}",
|
||||
}
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
validate_list('congestion_points', self.congestion_points, AgrPortfolioQueryResponseCongestionPoint, 1)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class AgrPortfolioQueryResponseDSOView:
|
||||
class Meta:
|
||||
name = "AGRPortfolioQueryResponseDSOView"
|
||||
|
||||
dso_portfolios: List[AgrPortfolioQueryResponseDSOPortfolio] = field(
|
||||
default_factory=list,
|
||||
metadata={
|
||||
"name": "DSO-Portfolio",
|
||||
"type": "Element",
|
||||
"min_occurs": 1,
|
||||
}
|
||||
)
|
||||
connections: List[AgrPortfolioQueryResponseConnection] = field(
|
||||
default_factory=list,
|
||||
metadata={
|
||||
"name": "Connection",
|
||||
"type": "Element",
|
||||
}
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
validate_list('dso_portfolios', self.dso_portfolios, AgrPortfolioQueryResponseDSOPortfolio, 1)
|
||||
|
||||
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class AgrPortfolioQueryResponse(PayloadMessageResponse):
|
||||
"""
|
||||
:ivar dso_view:
|
||||
:ivar time_zone: Time zone ID (as per the IANA time zone database,
|
||||
http://www.iana.org/time-zones, for example: Europe/Amsterdam)
|
||||
indicating the UTC offset that applies to the Period referenced
|
||||
in this message. Although the time zone is a market-wide fixed
|
||||
value, making this assumption explicit in each message is
|
||||
important for validation purposes, allowing implementations to
|
||||
reject messages with an errant UTC offset.
|
||||
:ivar period: The Period that the portfolio is valid.
|
||||
"""
|
||||
class Meta:
|
||||
name = "AGRPortfolioQueryResponse"
|
||||
|
||||
agr_portfolio_query_message_id: str = field(
|
||||
metadata={
|
||||
"name": "AGRPortfolioQueryMessageID",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}",
|
||||
}
|
||||
)
|
||||
|
||||
dso_views: List[AgrPortfolioQueryResponseDSOView] = field(
|
||||
default_factory=list,
|
||||
metadata={
|
||||
"name": "DSO-View",
|
||||
"type": "Element",
|
||||
"min_occurs": 1,
|
||||
}
|
||||
)
|
||||
time_zone: str = field(
|
||||
default=DEFAULT_TIME_ZONE,
|
||||
metadata={
|
||||
"name": "TimeZone",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"(Africa|America|Australia|Europe|Pacific)/[a-zA-Z0-9_/]{3,}",
|
||||
}
|
||||
)
|
||||
period: XmlDate = field(
|
||||
metadata={
|
||||
"name": "Period",
|
||||
"type": "Attribute",
|
||||
"format": "%Y-%m-%d",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
self.dso_views = validate_list('dso_views', self.dso_views, AgrPortfolioQueryResponseDSOView, 1)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class AgrPortfolioQuery(PayloadMessage):
|
||||
"""
|
||||
:ivar time_zone: Time zone ID (as per the IANA time zone database,
|
||||
http://www.iana.org/time-zones, for example: Europe/Amsterdam)
|
||||
indicating the UTC offset that applies to the Period referenced
|
||||
in this message. Although the time zone is a market-wide fixed
|
||||
value, making this assumption explicit in each message is
|
||||
important for validation purposes, allowing implementations to
|
||||
reject messages with an errant UTC offset.
|
||||
:ivar period: The Period for which the AGR requests the portfolio
|
||||
information.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
name = "AGRPortfolioQuery"
|
||||
|
||||
time_zone: str = field(
|
||||
default=DEFAULT_TIME_ZONE,
|
||||
metadata={
|
||||
"name": "TimeZone",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"(Africa|America|Australia|Europe|Pacific)/[a-zA-Z0-9_/]{3,}",
|
||||
}
|
||||
)
|
||||
period: XmlDate = field(
|
||||
metadata={
|
||||
"name": "Period",
|
||||
"type": "Attribute",
|
||||
"format": "%Y-%m-%d",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,101 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional
|
||||
|
||||
from xsdata.models.datatype import XmlDate
|
||||
|
||||
from ..defaults import DEFAULT_TIME_ZONE
|
||||
from ..validations import validate_list
|
||||
from .payload_message import PayloadMessage, PayloadMessageResponse
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class AgrPortfolioUpdateConnection:
|
||||
"""
|
||||
A connection that the AGR want the CRO to update.
|
||||
|
||||
:ivar entity_address: EntityAddress of the Connection entity being
|
||||
updated.
|
||||
:ivar start_period: The first Period hat the AGR represents the
|
||||
prosumer at this Connection.
|
||||
:ivar end_period: The last Period that the AGR represents the
|
||||
prosumer at this Connection, if applicable.
|
||||
"""
|
||||
class Meta:
|
||||
name = "AGRPortfolioUpdateConnection"
|
||||
|
||||
entity_address: str = field(
|
||||
metadata={
|
||||
"name": "EntityAddress",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"(ea1\.[0-9]{4}-[0-9]{2}\..{1,244}:.{1,244}|ean\.[0-9]{12,34})",
|
||||
}
|
||||
)
|
||||
start_period: XmlDate = field(
|
||||
metadata={
|
||||
"name": "StartPeriod",
|
||||
"type": "Attribute",
|
||||
"format": "%Y-%m-%d",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
end_period: Optional[XmlDate] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "EndPeriod",
|
||||
"type": "Attribute",
|
||||
"format": "%Y-%m-%d",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class AgrPortfolioUpdateResponse(PayloadMessageResponse):
|
||||
class Meta:
|
||||
name = "AGRPortfolioUpdateResponse"
|
||||
|
||||
agr_portfolio_update_message_id: str = field(
|
||||
metadata={
|
||||
"name": "AGRPortfolioUpdateMessageID",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class AgrPortfolioUpdate(PayloadMessage):
|
||||
"""
|
||||
:ivar connection:
|
||||
:ivar time_zone: Time zone ID (as per the IANA time zone database,
|
||||
http://www.iana.org/time-zones, for example: Europe/Amsterdam)
|
||||
indicating the UTC offset that applies to the Period referenced
|
||||
in this message. Although the time zone is a market-wide fixed
|
||||
value, making this assumption explicit in each message is
|
||||
important for validation purposes, allowing implementations to
|
||||
reject messages with an errant UTC offset.
|
||||
"""
|
||||
class Meta:
|
||||
name = "AGRPortfolioUpdate"
|
||||
|
||||
connections: List[AgrPortfolioUpdateConnection] = field(
|
||||
default_factory=list,
|
||||
metadata={
|
||||
"name": "Connection",
|
||||
"type": "Element",
|
||||
"min_occurs": 1,
|
||||
}
|
||||
)
|
||||
time_zone: str = field(
|
||||
default=DEFAULT_TIME_ZONE,
|
||||
metadata={
|
||||
"name": "TimeZone",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"(Africa|America|Australia|Europe|Pacific)/[a-zA-Z0-9_/]{3,}",
|
||||
}
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
validate_list('connections', self.connections, AgrPortfolioUpdateConnection, 1)
|
||||
@@ -0,0 +1,118 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List
|
||||
|
||||
from ..validations import validate_list
|
||||
from .flex_message import FlexMessage
|
||||
from .payload_message import PayloadMessageResponse
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class DPrognosisISP:
|
||||
"""
|
||||
:ivar power: Power specified for this ISP in Watts. Also see the
|
||||
important notes about the sign of this attribute in the main
|
||||
documentation entry for the ISP element.
|
||||
:ivar start: Number of the first ISPs this element refers to. The
|
||||
first ISP of a day has number 1.
|
||||
:ivar duration: The number of the ISPs this element represents.
|
||||
Optional, default value is 1.
|
||||
"""
|
||||
class Meta:
|
||||
name = "D-PrognosisISP"
|
||||
|
||||
power: int = field(
|
||||
metadata={
|
||||
"name": "Power",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
start: int = field(
|
||||
metadata={
|
||||
"name": "Start",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
duration: int = field(
|
||||
default=1,
|
||||
metadata={
|
||||
"name": "Duration",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class FlexOrderStatus:
|
||||
flex_order_message_id: str = field(
|
||||
metadata={
|
||||
"name": "FlexOrderMessageID",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}",
|
||||
}
|
||||
)
|
||||
is_validated: bool = field(
|
||||
metadata={
|
||||
"name": "IsValidated",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class DPrognosisResponse(PayloadMessageResponse):
|
||||
class Meta:
|
||||
name = "D-PrognosisResponse"
|
||||
|
||||
d_prognosis_message_id: str = field(
|
||||
metadata={
|
||||
"name": "D-PrognosisMessageID",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}",
|
||||
}
|
||||
)
|
||||
|
||||
flex_order_statuses: List[FlexOrderStatus] = field(
|
||||
default_factory=list,
|
||||
metadata={
|
||||
"name": "FlexOrderStatus",
|
||||
"type": "Element",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class DPrognosis(FlexMessage):
|
||||
"""
|
||||
:ivar isp:
|
||||
:ivar revision: Revision of this message. A sequence number that
|
||||
must be incremented each time a new revision of a prognosis is
|
||||
sent. The combination of SenderDomain and PrognosisSequence
|
||||
should be unique
|
||||
"""
|
||||
class Meta:
|
||||
name = "D-Prognosis"
|
||||
|
||||
isps: List[DPrognosisISP] = field(
|
||||
default_factory=list,
|
||||
metadata={
|
||||
"name": "ISP",
|
||||
"type": "Element",
|
||||
"min_occurs": 1,
|
||||
}
|
||||
)
|
||||
revision: int = field(
|
||||
metadata={
|
||||
"name": "Revision",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
validate_list('isps', self.isps, DPrognosisISP, 1)
|
||||
@@ -0,0 +1,164 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional
|
||||
|
||||
from xsdata.models.datatype import XmlDate
|
||||
|
||||
from ..defaults import DEFAULT_TIME_ZONE
|
||||
from ..validations import validate_list
|
||||
from .payload_message import PayloadMessage, PayloadMessageResponse
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class DsoPortfolioQueryConnection:
|
||||
"""
|
||||
A Connection that is part of the congestion point.
|
||||
|
||||
:ivar entity_address: EntityAddress of the Connection.
|
||||
:ivar agr_domain: The internet domain of the AGR that represents the
|
||||
prosumer connected on this Connection, if applicable.
|
||||
"""
|
||||
class Meta:
|
||||
name = "DSOPortfolioQueryConnection"
|
||||
|
||||
entity_address: str = field(
|
||||
metadata={
|
||||
"name": "EntityAddress",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"(ea1\.[0-9]{4}-[0-9]{2}\..{1,244}:.{1,244}|ean\.[0-9]{12,34})",
|
||||
}
|
||||
)
|
||||
agr_domain: Optional[str] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "AGR-Domain",
|
||||
"type": "Attribute",
|
||||
"pattern": r"([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class DsoPortfolioQueryCongestionPoint:
|
||||
"""
|
||||
:ivar connection:
|
||||
:ivar entity_address: EntityAddress of the Connection.
|
||||
"""
|
||||
class Meta:
|
||||
name = "DSOPortfolioQueryCongestionPoint"
|
||||
|
||||
connections: List[DsoPortfolioQueryConnection] = field(
|
||||
default_factory=list,
|
||||
metadata={
|
||||
"name": "Connection",
|
||||
"type": "Element",
|
||||
"min_occurs": 1,
|
||||
}
|
||||
)
|
||||
entity_address: str = field(
|
||||
metadata={
|
||||
"name": "EntityAddress",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"(ea1\.[0-9]{4}-[0-9]{2}\..{1,244}:.{1,244}|ean\.[0-9]{12,34})",
|
||||
}
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
validate_list('connections', self.connections, DsoPortfolioQueryConnection, 1)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class DsoPortfolioQueryResponse(PayloadMessageResponse):
|
||||
"""
|
||||
:ivar congestion_point:
|
||||
:ivar time_zone: Time zone ID (as per the IANA time zone database,
|
||||
http://www.iana.org/time-zones, for example: Europe/Amsterdam)
|
||||
indicating the UTC offset that applies to the Period referenced
|
||||
in this message. Although the time zone is a market-wide fixed
|
||||
value, making this assumption explicit in each message is
|
||||
important for validation purposes, allowing implementations to
|
||||
reject messages with an errant UTC offset.
|
||||
:ivar period: The Period for which the AGR requests the portfolio
|
||||
information.
|
||||
"""
|
||||
class Meta:
|
||||
name = "DSOPortfolioQueryResponse"
|
||||
|
||||
dso_portfolio_query_message_id: str = field(
|
||||
metadata={
|
||||
"name": "DSOPortfolioQueryMessageID",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}",
|
||||
}
|
||||
)
|
||||
|
||||
congestion_point: Optional[DsoPortfolioQueryCongestionPoint] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "CongestionPoint",
|
||||
"type": "Element",
|
||||
}
|
||||
)
|
||||
time_zone: str = field(
|
||||
default=DEFAULT_TIME_ZONE,
|
||||
metadata={
|
||||
"name": "TimeZone",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"(Africa|America|Australia|Europe|Pacific)/[a-zA-Z0-9_/]{3,}",
|
||||
}
|
||||
)
|
||||
period: XmlDate = field(
|
||||
metadata={
|
||||
"name": "Period",
|
||||
"type": "Attribute",
|
||||
"format": "%Y-%m-%d",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class DsoPortfolioQuery(PayloadMessage):
|
||||
"""
|
||||
:ivar time_zone: Time zone ID (as per the IANA time zone database,
|
||||
http://www.iana.org/time-zones, for example: Europe/Amsterdam)
|
||||
indicating the UTC offset that applies to the Period referenced
|
||||
in this message. Although the time zone is a market-wide fixed
|
||||
value, making this assumption explicit in each message is
|
||||
important for validation purposes, allowing implementations to
|
||||
reject messages with an errant UTC offset.
|
||||
:ivar period: The Period for which the AGR requests the portfolio
|
||||
information.
|
||||
:ivar entity_address: EntityAddress of the CongestionPoint
|
||||
"""
|
||||
class Meta:
|
||||
name = "DSOPortfolioQuery"
|
||||
|
||||
time_zone: str = field(
|
||||
default=DEFAULT_TIME_ZONE,
|
||||
metadata={
|
||||
"name": "TimeZone",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"(Africa|America|Australia|Europe|Pacific)/[a-zA-Z0-9_/]{3,}",
|
||||
}
|
||||
)
|
||||
period: XmlDate = field(
|
||||
metadata={
|
||||
"name": "Period",
|
||||
"type": "Attribute",
|
||||
"format": "%Y-%m-%d",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
entity_address: str = field(
|
||||
metadata={
|
||||
"name": "EntityAddress",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"(ea1\.[0-9]{4}-[0-9]{2}\..{1,244}:.{1,244}|ean\.[0-9]{12,34})",
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,181 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional
|
||||
|
||||
from xsdata.models.datatype import XmlDate
|
||||
|
||||
from ..defaults import DEFAULT_TIME_ZONE
|
||||
from ..enums import RedispatchBy
|
||||
from ..validations import validate_list
|
||||
from .payload_message import PayloadMessage, PayloadMessageResponse
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class DsoPortfolioUpdateConnection:
|
||||
"""
|
||||
A connection that the DSO wants the CRO to update.
|
||||
|
||||
:ivar entity_address: EntityAddress of the Connection.
|
||||
:ivar start_period: The first Period that the Connection is part of
|
||||
this CongestionPoint.
|
||||
:ivar end_period: The last Period that the Connection is part of
|
||||
this CongestionPoint.
|
||||
"""
|
||||
class Meta:
|
||||
name = "DSOPortfolioUpdateConnection"
|
||||
|
||||
entity_address: str = field(
|
||||
metadata={
|
||||
"name": "EntityAddress",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"(ea1\.[0-9]{4}-[0-9]{2}\..{1,244}:.{1,244}|ean\.[0-9]{12,34})",
|
||||
}
|
||||
)
|
||||
start_period: XmlDate = field(
|
||||
metadata={
|
||||
"name": "StartPeriod",
|
||||
"type": "Attribute",
|
||||
"format": "%Y-%m-%d",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
end_period: Optional[XmlDate] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "EndPeriod",
|
||||
"type": "Attribute",
|
||||
"format": "%Y-%m-%d",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class DsoPortfolioUpdateCongestionPoint:
|
||||
"""
|
||||
A congestion point that the DSO wants the CRO to update.
|
||||
|
||||
:ivar connection:
|
||||
:ivar entity_address: EntityAddress of the Connection.
|
||||
:ivar start_period: The first Period that the Connection is part of
|
||||
this CongestionPoint.
|
||||
:ivar end_period: The last Period that the Connection is part of
|
||||
this CongestionPoint.
|
||||
:ivar mutex_offers_supported: Indicates whether the DSO accepts
|
||||
mutual exclusive FlexOffers on this CongestionPoint.
|
||||
:ivar day_ahead_redispatch_by: Indicates which party is responsible
|
||||
for day-ahead redispatch.
|
||||
:ivar intraday_redispatch_by: Indicates which party is responsible
|
||||
for intraday ahead redispatch, AGR or DSO. If not specified,
|
||||
there will be no intraday trading on this CongestionPoint.
|
||||
"""
|
||||
class Meta:
|
||||
name = "DSOPortfolioUpdateCongestionPoint"
|
||||
|
||||
connections: List[DsoPortfolioUpdateConnection] = field(
|
||||
default_factory=list,
|
||||
metadata={
|
||||
"name": "Connection",
|
||||
"type": "Element",
|
||||
"min_occurs": 1,
|
||||
}
|
||||
)
|
||||
entity_address: str = field(
|
||||
metadata={
|
||||
"name": "EntityAddress",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"(ea1\.[0-9]{4}-[0-9]{2}\..{1,244}:.{1,244}|ean\.[0-9]{12,34})",
|
||||
}
|
||||
)
|
||||
start_period: XmlDate = field(
|
||||
metadata={
|
||||
"name": "StartPeriod",
|
||||
"type": "Attribute",
|
||||
"format": "%Y-%m-%d",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
end_period: Optional[XmlDate] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "EndPeriod",
|
||||
"type": "Attribute",
|
||||
"format": "%Y-%m-%d",
|
||||
}
|
||||
)
|
||||
mutex_offers_supported: bool = field(
|
||||
metadata={
|
||||
"name": "MutexOffersSupported",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
day_ahead_redispatch_by: RedispatchBy = field(
|
||||
metadata={
|
||||
"name": "DayAheadRedispatchBy",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
intraday_redispatch_by: Optional[RedispatchBy] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "IntradayRedispatchBy",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
validate_list('connections', self.connections, DsoPortfolioUpdateConnection, 1)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class DsoPortfolioUpdateResponse(PayloadMessageResponse):
|
||||
class Meta:
|
||||
name = "DSOPortfolioUpdateResponse"
|
||||
|
||||
dso_portfolio_update_message_id: str = field(
|
||||
metadata={
|
||||
"name": "DSOPortfolioUpdateResponseMessageID",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class DsoPortfolioUpdate(PayloadMessage):
|
||||
"""
|
||||
:ivar congestion_point:
|
||||
:ivar time_zone: Time zone ID (as per the IANA time zone database,
|
||||
http://www.iana.org/time-zones, for example: Europe/Amsterdam)
|
||||
indicating the UTC offset that applies to the Period referenced
|
||||
in this message. Although the time zone is a market-wide fixed
|
||||
value, making this assumption explicit in each message is
|
||||
important for validation purposes, allowing implementations to
|
||||
reject messages with an errant UTC offset.
|
||||
"""
|
||||
class Meta:
|
||||
name = "DSOPortfolioUpdate"
|
||||
|
||||
congestion_points: List[DsoPortfolioUpdateCongestionPoint] = field(
|
||||
default_factory=list,
|
||||
metadata={
|
||||
"name": "CongestionPoint",
|
||||
"type": "Element",
|
||||
"min_occurs": 1,
|
||||
}
|
||||
)
|
||||
time_zone: str = field(
|
||||
default=DEFAULT_TIME_ZONE,
|
||||
metadata={
|
||||
"name": "TimeZone",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"(Africa|America|Australia|Europe|Pacific)/[a-zA-Z0-9_/]{3,}",
|
||||
}
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
validate_list('congestion_points', self.congestion_points, DsoPortfolioUpdateCongestionPoint, 1)
|
||||
@@ -0,0 +1,61 @@
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from xsdata.models.datatype import XmlDate, XmlDuration
|
||||
|
||||
from ..defaults import DEFAULT_TIME_ZONE
|
||||
from .payload_message import PayloadMessage
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class FlexMessage(PayloadMessage):
|
||||
"""
|
||||
:ivar isp_duration: ISO 8601 time interval (minutes only, for
|
||||
example PT15M) indicating the duration of the ISPs referenced in
|
||||
this message. Although the ISP length is a market-wide fixed
|
||||
value, making this assumption explicit in each message is
|
||||
important for validation purposes, allowing implementations to
|
||||
reject messages with an errant ISP duration.
|
||||
:ivar time_zone: Time zone ID (as per the IANA time zone database,
|
||||
http://www.iana.org/time-zones, for example: Europe/Amsterdam)
|
||||
indicating the UTC offset that applies to the Period referenced
|
||||
in this message. Although the time zone is a market-wide fixed
|
||||
value, making this assumption explicit in each message is
|
||||
important for validation purposes, allowing implementations to
|
||||
reject messages with an errant UTC offset.
|
||||
:ivar period: Day (in yyyy-mm-dd format) the ISPs referenced in this
|
||||
Flex* message belong to.
|
||||
:ivar congestion_point: Entity Address of the Congestion Point this
|
||||
D-Prognosis applies to.
|
||||
"""
|
||||
isp_duration: XmlDuration = field(
|
||||
metadata={
|
||||
"name": "ISP-Duration",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
time_zone: str = field(
|
||||
default=DEFAULT_TIME_ZONE,
|
||||
metadata={
|
||||
"name": "TimeZone",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"(Africa|America|Australia|Europe|Pacific)/[a-zA-Z0-9_/]{3,}",
|
||||
}
|
||||
)
|
||||
period: XmlDate = field(
|
||||
metadata={
|
||||
"name": "Period",
|
||||
"type": "Attribute",
|
||||
"format": "%Y-%m-%d",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
congestion_point: str = field(
|
||||
metadata={
|
||||
"name": "CongestionPoint",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"(ea1\.[0-9]{4}-[0-9]{2}\..{1,244}:.{1,244}|ean\.[0-9]{12,34})",
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,199 @@
|
||||
from dataclasses import dataclass, field
|
||||
from decimal import Decimal
|
||||
from typing import List, Optional
|
||||
|
||||
from ..validations import validate_decimal, validate_list
|
||||
from .flex_message import FlexMessage
|
||||
from .payload_message import PayloadMessageResponse
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class FlexOfferOptionISP:
|
||||
"""
|
||||
:ivar power: Power specified for this ISP in Watts. Also see the
|
||||
important notes about the sign of this attribute in the main
|
||||
documentation entry for the ISP element.
|
||||
:ivar start: Number of the first ISPs this element refers to. The
|
||||
first ISP of a day has number 1.
|
||||
:ivar duration: The number of the ISPs this element represents.
|
||||
Optional, default value is 1.
|
||||
"""
|
||||
class Meta:
|
||||
name = "FlexOfferOptionISP"
|
||||
|
||||
power: int = field(
|
||||
metadata={
|
||||
"name": "Power",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
start: int = field(
|
||||
metadata={
|
||||
"name": "Start",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
duration: int = field(
|
||||
default=1,
|
||||
metadata={
|
||||
"name": "Duration",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class FlexOfferOption:
|
||||
"""
|
||||
:ivar isp:
|
||||
:ivar option_reference: The identification of this option.
|
||||
:ivar price: The asking price for the flexibility offered in this
|
||||
option.
|
||||
:ivar min_activation_factor: The minimal activation factor for this
|
||||
OfferOption. An AGR may choose to include MinActivationFactor in
|
||||
FlexOffers even if the DSO is not interested in partial
|
||||
activation. In that case the DSO will simply use an
|
||||
ActivationFactor of 1.00 in every FlexOrder.
|
||||
"""
|
||||
isps: List[FlexOfferOptionISP] = field(
|
||||
default_factory=list,
|
||||
metadata={
|
||||
"name": "ISP",
|
||||
"type": "Element",
|
||||
"min_occurs": 1,
|
||||
}
|
||||
)
|
||||
option_reference: str = field(
|
||||
metadata={
|
||||
"name": "OptionReference",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
price: Decimal = field(
|
||||
metadata={
|
||||
"name": "Price",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"fraction_digits": 4,
|
||||
}
|
||||
)
|
||||
min_activation_factor: Decimal = field(
|
||||
default=Decimal("1.00"),
|
||||
metadata={
|
||||
"name": "MinActivationFactor",
|
||||
"type": "Attribute",
|
||||
"min_inclusive": Decimal("0.01"),
|
||||
"max_inclusive": Decimal("1.00"),
|
||||
"fraction_digits": 2,
|
||||
}
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
validate_list('isps', self.isps, FlexOfferOptionISP, 1)
|
||||
self.price = validate_decimal('price', self.price, 4)
|
||||
self.min_activation_factor = validate_decimal('min_activation_factor', self.min_activation_factor, 2)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class FlexOfferResponse(PayloadMessageResponse):
|
||||
flex_offer_message_id: str = field(
|
||||
metadata={
|
||||
"name": "FlexOfferMessageID",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class FlexOffer(FlexMessage):
|
||||
"""
|
||||
:ivar offer_option:
|
||||
:ivar expiration_date_time: Date and time, including the time zone
|
||||
(ISO 8601 formatted as per http://www.w3.org/TR/NOTE-datetime)
|
||||
until which the FlexOffer is valid.
|
||||
:ivar flex_request_message_id: MessageID of the FlexRequest message
|
||||
this request is based on. Mandatory if and only if solicited.
|
||||
:ivar contract_id: Reference to the concerning contract, if
|
||||
applicable. The contract may be either bilateral or commoditized
|
||||
market contract.
|
||||
:ivar d_prognosis_message_id: MessageID of the D-Prognosis this
|
||||
request is based on, if it has been agreed that the baseline is
|
||||
based on D-prognoses.
|
||||
:ivar baseline_reference: Identification of the baseline prognosis,
|
||||
if another baseline methodology is used than based on
|
||||
D-prognoses
|
||||
:ivar currency: ISO 4217 code indicating the currency that applies
|
||||
to the price of the FlexOffer.
|
||||
"""
|
||||
offer_options: List[FlexOfferOption] = field(
|
||||
default_factory=list,
|
||||
metadata={
|
||||
"name": "OfferOption",
|
||||
"type": "Element",
|
||||
"min_occurs": 1,
|
||||
}
|
||||
)
|
||||
expiration_date_time: str = field(
|
||||
metadata={
|
||||
"name": "ExpirationDateTime",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{0,9})?([+-]\d{2}:\d{2}|Z)",
|
||||
}
|
||||
)
|
||||
unsolicited: Optional[bool] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "Unsolicited",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
flex_request_message_id: Optional[str] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "FlexRequestMessageID",
|
||||
"type": "Attribute",
|
||||
"pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}",
|
||||
}
|
||||
)
|
||||
contract_id: Optional[str] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "ContractID",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
d_prognosis_message_id: Optional[str] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "D-PrognosisMessageID",
|
||||
"type": "Attribute",
|
||||
"pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}",
|
||||
}
|
||||
)
|
||||
baseline_reference: Optional[str] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "BaselineReference",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
currency: str = field(
|
||||
default="EUR",
|
||||
metadata={
|
||||
"name": "Currency",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"[A-Z]{3}",
|
||||
}
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
validate_list('offer_options', self.offer_options, FlexOfferOption, 1)
|
||||
if not self.unsolicited and self.flex_request_message_id is None:
|
||||
raise TypeError("FlexRequestMessageId is required if Unsolicited is not True")
|
||||
@@ -0,0 +1,32 @@
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .payload_message import PayloadMessage, PayloadMessageResponse
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class FlexOfferRevocationResponse(PayloadMessageResponse):
|
||||
flex_offer_revocation_message_id: str = field(
|
||||
metadata={
|
||||
"name": "FlexOfferRevocationMessageID",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class FlexOfferRevocation(PayloadMessage):
|
||||
"""
|
||||
:ivar flex_offer_message_id: MessageID of the FlexOffer message that
|
||||
is being revoked: this FlexOffer must have been accepted
|
||||
previously.
|
||||
"""
|
||||
flex_offer_message_id: str = field(
|
||||
metadata={
|
||||
"name": "FlexOfferMessageID",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}",
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,194 @@
|
||||
from dataclasses import dataclass, field
|
||||
from decimal import Decimal
|
||||
from typing import List, Optional
|
||||
|
||||
from ..validations import validate_decimal, validate_list
|
||||
from .flex_message import FlexMessage
|
||||
from .payload_message import PayloadMessageResponse
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class FlexOrderISP:
|
||||
"""
|
||||
:ivar power: Power specified for this ISP in Watts. Also see the
|
||||
important notes about the sign of this attribute in the main
|
||||
documentation entry for the ISP element.
|
||||
:ivar start: Number of the first ISPs this element refers to. The
|
||||
first ISP of a day has number 1.
|
||||
:ivar duration: The number of the ISPs this element represents.
|
||||
Optional, default value is 1.
|
||||
"""
|
||||
class Meta:
|
||||
name = "FlexOrderISP"
|
||||
|
||||
power: int = field(
|
||||
metadata={
|
||||
"name": "Power",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
start: int = field(
|
||||
metadata={
|
||||
"name": "Start",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
duration: int = field(
|
||||
default=1,
|
||||
metadata={
|
||||
"name": "Duration",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class FlexOrderResponse(PayloadMessageResponse):
|
||||
flex_order_message_id: str = field(
|
||||
metadata={
|
||||
"name": "FlexOrderMessageID",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}",
|
||||
}
|
||||
)
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class FlexOrder(FlexMessage):
|
||||
"""
|
||||
:ivar isp:
|
||||
:ivar unsolicited: Indicates whether this FlexOrder is intended to
|
||||
be unsolicited (i.e. without a preceding FlexOffer).
|
||||
:ivar service_type: Service type for this order, the service type
|
||||
determines response characteristics such as latency or asset
|
||||
participation type.
|
||||
:ivar flex_offer_message_id: MessageID of the FlexOffer message this
|
||||
order is based on.
|
||||
:ivar contract_id: Reference to the concerning bilateral contract,
|
||||
if applicable.
|
||||
:ivar d_prognosis_message_id: MessageID of the D-Prognosis this
|
||||
request is based on, if it has been agreed that the baseline is
|
||||
based on D-prognoses.
|
||||
:ivar baseline_reference: Identification of the baseline prognosis,
|
||||
if another baseline methodology is used than based on
|
||||
D-prognoses
|
||||
:ivar price: The price for the flexibility ordered. Usually, the
|
||||
price should match the price of the related FlexOffer.
|
||||
:ivar currency: ISO 4217 code indicating the currency that applies
|
||||
to the price of the FlexOffer.
|
||||
:ivar order_reference: Order number assigned by the DSO originating
|
||||
the FlexOrder. To be stored by the AGR and used in the
|
||||
settlement phase.
|
||||
:ivar option_reference: The OptionReference from the OfferOption
|
||||
chosen from the FlexOffer.
|
||||
:ivar activation_factor: The activation factor for this OfferOption.
|
||||
The ActivationFactor must be greater than or equal to the
|
||||
MinActivationFactor in the OfferOption chosen from the
|
||||
FlexOffer.
|
||||
"""
|
||||
isps: List[FlexOrderISP] = field(
|
||||
default_factory=list,
|
||||
metadata={
|
||||
"name": "ISP",
|
||||
"type": "Element",
|
||||
"min_occurs": 1,
|
||||
}
|
||||
)
|
||||
unsolicited: Optional[bool] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "Unsolicited",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
service_type: Optional[str] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "ServiceType",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
flex_offer_message_id: Optional[str] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "FlexOfferMessageID",
|
||||
"type": "Attribute",
|
||||
"required": False,
|
||||
"pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}",
|
||||
}
|
||||
)
|
||||
contract_id: Optional[str] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "ContractID",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
d_prognosis_message_id: Optional[str] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "D-PrognosisMessageID",
|
||||
"type": "Attribute",
|
||||
"pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}",
|
||||
}
|
||||
)
|
||||
baseline_reference: Optional[str] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "BaselineReference",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
price: Decimal = field(
|
||||
metadata={
|
||||
"name": "Price",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"fraction_digits": 4,
|
||||
}
|
||||
)
|
||||
currency: str = field(
|
||||
metadata={
|
||||
"name": "Currency",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"[A-Z]{3}",
|
||||
}
|
||||
)
|
||||
order_reference: str = field(
|
||||
metadata={
|
||||
"name": "OrderReference",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
option_reference: Optional[str] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "OptionReference",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
activation_factor: Decimal = field(
|
||||
default=Decimal("1.00"),
|
||||
metadata={
|
||||
"name": "ActivationFactor",
|
||||
"type": "Attribute",
|
||||
"min_inclusive": Decimal("0.01"),
|
||||
"max_inclusive": Decimal("1.00"),
|
||||
"fraction_digits": 2,
|
||||
}
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
validate_list("isps", self.isps, FlexOrderISP, 1)
|
||||
self.price = validate_decimal("price", self.price, 4)
|
||||
self.activation_factor = validate_decimal(
|
||||
"activation_factor", self.activation_factor, 2
|
||||
)
|
||||
if not self.unsolicited and self.flex_offer_message_id is None:
|
||||
raise TypeError(
|
||||
"FlexOfferMessageId is required if Unsolicited is not True"
|
||||
)
|
||||
@@ -0,0 +1,134 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional
|
||||
|
||||
from ..enums import AvailableRequested
|
||||
from ..validations import validate_list
|
||||
from .flex_message import FlexMessage
|
||||
from .payload_message import PayloadMessageResponse
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class FlexRequestISP:
|
||||
"""
|
||||
:ivar disposition:
|
||||
:ivar min_power: Power specified for this ISP in Watts. Also see the
|
||||
important notes about the sign of this attribute in the main
|
||||
documentation entry for the ISP element.
|
||||
:ivar max_power: Power specified for this ISP in Watts. Also see the
|
||||
important notes about the sign of this attribute in the main
|
||||
documentation entry for the ISP element.
|
||||
:ivar start: Number of the first ISPs this element refers to. The
|
||||
first ISP of a day has number 1.
|
||||
:ivar duration: The number of the ISPs this element represents.
|
||||
Optional, default value is 1.
|
||||
"""
|
||||
class Meta:
|
||||
name = "FlexRequestISP"
|
||||
|
||||
disposition: Optional[AvailableRequested] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "Disposition",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
min_power: int = field(
|
||||
metadata={
|
||||
"name": "MinPower",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
max_power: int = field(
|
||||
metadata={
|
||||
"name": "MaxPower",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
start: int = field(
|
||||
metadata={
|
||||
"name": "Start",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
duration: int = field(
|
||||
default=1,
|
||||
metadata={
|
||||
"name": "Duration",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class FlexRequestResponse(PayloadMessageResponse):
|
||||
|
||||
flex_request_message_id: str = field(
|
||||
metadata={
|
||||
"name": "FlexRequestMessageID",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}",
|
||||
}
|
||||
)
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class FlexRequest(FlexMessage):
|
||||
"""
|
||||
:ivar isp:
|
||||
:ivar revision: Revision of this message, a sequence number that
|
||||
must be incremented each time a new revision of a FlexRequest
|
||||
message is sent.
|
||||
:ivar expiration_date_time: Date and time, including the time zone
|
||||
(ISO 8601 formatted as per http://www.w3.org/TR/NOTE-datetime)
|
||||
until which the FlexRequest message is valid.
|
||||
:ivar contract_id: Reference to the concerning contract, if
|
||||
applicable. The contract may be either bilateral or commoditized
|
||||
market contract. Each contract may specify multiple service-
|
||||
types.
|
||||
:ivar service_type: Service type for this request, the service type
|
||||
determines response characteristics such as latency or asset
|
||||
participation type.
|
||||
"""
|
||||
isps: List[FlexRequestISP] = field(
|
||||
default_factory=list,
|
||||
metadata={
|
||||
"name": "ISP",
|
||||
"type": "Element",
|
||||
"min_occurs": 1,
|
||||
}
|
||||
)
|
||||
revision: int = field(
|
||||
metadata={
|
||||
"name": "Revision",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
expiration_date_time: str = field(
|
||||
metadata={
|
||||
"name": "ExpirationDateTime",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{0,9})?([+-]\d{2}:\d{2}|Z)",
|
||||
}
|
||||
)
|
||||
contract_id: Optional[str] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "ContractID",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
service_type: Optional[str] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "ServiceType",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
validate_list('isps', self.isps, FlexRequestISP, 1)
|
||||
@@ -0,0 +1,90 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List
|
||||
|
||||
from ..validations import validate_list
|
||||
from .flex_message import FlexMessage
|
||||
from .payload_message import PayloadMessageResponse
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class FlexReservationUpdateISP:
|
||||
"""
|
||||
:ivar power: Remaining reserved power specified for this ISP in
|
||||
Watts.
|
||||
:ivar start: Number of the first ISPs this element refers to. The
|
||||
first ISP of a day has number 1.
|
||||
:ivar duration: The number of the ISPs this element represents.
|
||||
Optional, default value is 1.
|
||||
"""
|
||||
class Meta:
|
||||
name = "FlexReservationUpdateISP"
|
||||
|
||||
power: int = field(
|
||||
metadata={
|
||||
"name": "Power",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
start: int = field(
|
||||
metadata={
|
||||
"name": "Start",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
duration: int = field(
|
||||
default=1,
|
||||
metadata={
|
||||
"name": "Duration",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class FlexReservationUpdateResponse(PayloadMessageResponse):
|
||||
|
||||
flex_reservation_update_message_id: str = field(
|
||||
metadata={
|
||||
"name": "FlexReservationUpdateMessageID",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class FlexReservationUpdate(FlexMessage):
|
||||
"""
|
||||
:ivar isp:
|
||||
:ivar contract_id: Reference to the bilateral contract in question.
|
||||
:ivar reference: Message reference, assigned by the DSO originating
|
||||
the FlexReservationUpdate.
|
||||
"""
|
||||
isps: List[FlexReservationUpdateISP] = field(
|
||||
default_factory=list,
|
||||
metadata={
|
||||
"name": "ISP",
|
||||
"type": "Element",
|
||||
"min_occurs": 1,
|
||||
}
|
||||
)
|
||||
contract_id: str = field(
|
||||
metadata={
|
||||
"name": "ContractID",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
reference: str = field(
|
||||
metadata={
|
||||
"name": "Reference",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
validate_list('isps', self.isps, FlexReservationUpdateISP, 1)
|
||||
@@ -0,0 +1,451 @@
|
||||
from dataclasses import dataclass, field
|
||||
from decimal import Decimal
|
||||
from typing import List, Optional
|
||||
|
||||
from xsdata.models.datatype import XmlDate
|
||||
|
||||
from ..enums import AcceptedDisputed
|
||||
from ..validations import validate_decimal, validate_list
|
||||
from .payload_message import PayloadMessageResponse
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class ContractSettlementISP:
|
||||
"""
|
||||
:ivar start: Number of the first ISPs this element refers to. The
|
||||
first ISP of a day has number 1.
|
||||
:ivar duration: The number of the ISPs this element represents.
|
||||
Optional, default value is 1.
|
||||
:ivar reserved_power: Amount of flex power that has been reserved
|
||||
(and not released using a FlexReservationUpdate message).
|
||||
:ivar requested_power: Amount of flex power that has been both
|
||||
reserved in advance and has been requested using a FlexRequest
|
||||
(i.e. the lowest amount of flex power for this ISP). If there
|
||||
was no FlexRequest, this field is omitted.
|
||||
:ivar available_power: Amount of flex power that is considered
|
||||
available based on the FlexRequest in question. In case
|
||||
RequestedPower=0, AvailablePower is defined so that the offered
|
||||
power is allowed to be between 0 and AvailablePower in terms of
|
||||
compliancy (see Appendix 'Rationale for information exchange in
|
||||
flexibility request' for details). In case RequestedPower ≠0,
|
||||
AvailablePower is defined so that the offered power is allowed
|
||||
to exceed the amount of requested power up to AvailablePower. If
|
||||
this is relevant for settlement, the DSO can include this field.
|
||||
:ivar offered_power: Amount of flex power that has been reserved in
|
||||
advance, requested using a FlexRequest and covered in an offer
|
||||
from the AGR. If there was no offer, this field is omitted. If
|
||||
there were multiple offers, only the one is considered that is
|
||||
most compliant .
|
||||
:ivar ordered_power: Amount of flex power that has been ordered
|
||||
using a FlexOrder message that was based on a FlexOffer, both
|
||||
linked to this contract. If there was no order, this field is
|
||||
omitted.
|
||||
"""
|
||||
class Meta:
|
||||
name = "ContractSettlementISP"
|
||||
|
||||
start: int = field(
|
||||
metadata={
|
||||
"name": "Start",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
duration: int = field(
|
||||
default=1,
|
||||
metadata={
|
||||
"name": "Duration",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
reserved_power: int = field(
|
||||
metadata={
|
||||
"name": "ReservedPower",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
requested_power: Optional[int] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "RequestedPower",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
available_power: Optional[int] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "AvailablePower",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
offered_power: Optional[int] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "OfferedPower",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
ordered_power: Optional[int] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "OrderedPower",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class ContractSettlementPeriod:
|
||||
"""
|
||||
:ivar isp:
|
||||
:ivar period: Period the being settled.
|
||||
"""
|
||||
isps: List[ContractSettlementISP] = field(
|
||||
default_factory=list,
|
||||
metadata={
|
||||
"name": "ISP",
|
||||
"type": "Element",
|
||||
"min_occurs": 1,
|
||||
}
|
||||
)
|
||||
period: XmlDate = field(
|
||||
metadata={
|
||||
"name": "Period",
|
||||
"type": "Attribute",
|
||||
"format": "%Y-%m-%d",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class ContractSettlement:
|
||||
"""
|
||||
:ivar period:
|
||||
:ivar contract_id: Reference to the concerning bilateral contract.
|
||||
"""
|
||||
periods: List[ContractSettlementPeriod] = field(
|
||||
default_factory=list,
|
||||
metadata={
|
||||
"name": "Period",
|
||||
"type": "Element",
|
||||
"min_occurs": 1,
|
||||
}
|
||||
)
|
||||
contract_id: Optional[str] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "ContractID",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
validate_list('periods', self.periods, ContractSettlementPeriod, 1)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class FlexOrderSettlementStatus:
|
||||
"""
|
||||
:ivar order_reference: Order reference assigned by the DSO when
|
||||
originating the FlexOrder.
|
||||
:ivar disposition: Indication whether the AGR accepts the order
|
||||
settlement details provided by the DSO (and will invoice
|
||||
accordingly), or disputes these details.
|
||||
:ivar dispute_reason: In case the order settlement was disputed,
|
||||
this attribute must contain a human-readable description of the
|
||||
reason.
|
||||
"""
|
||||
order_reference: Optional[str] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "OrderReference",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
disposition: AcceptedDisputed = field(
|
||||
metadata={
|
||||
"name": "Disposition",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
dispute_reason: Optional[str] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "DisputeReason",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class FlexOrderSettlementISP:
|
||||
"""
|
||||
:ivar start: Number of the first ISPs this element refers to. The
|
||||
first ISP of a day has number 1.
|
||||
:ivar duration: The number of the ISPs this element represents.
|
||||
Optional, default value is 1.
|
||||
:ivar baseline_power: Power originally forecast (as per the
|
||||
referenced baseline) for this ISP in Watts.
|
||||
:ivar ordered_flex_power: Amount of flex power ordered (as per the
|
||||
referenced FlexOrder message) for this ISP in Watts.
|
||||
:ivar actual_power: Actual amount of power for this ISP in Watts, as
|
||||
measured/determined by the DSO and allocated to the AGR.
|
||||
:ivar delivered_flex_power: Actual amount of flex power delivered
|
||||
for this ISP in Watts, as determined by the DSO.
|
||||
:ivar power_deficiency: Amount of flex power sold but not delivered
|
||||
for this ISP in Watts, as determined by the DSO.
|
||||
"""
|
||||
class Meta:
|
||||
name = "FlexOrderSettlementISP"
|
||||
|
||||
start: int = field(
|
||||
metadata={
|
||||
"name": "Start",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
duration: int = field(
|
||||
default=1,
|
||||
metadata={
|
||||
"name": "Duration",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
baseline_power: int = field(
|
||||
metadata={
|
||||
"name": "BaselinePower",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
ordered_flex_power: int = field(
|
||||
metadata={
|
||||
"name": "OrderedFlexPower",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
actual_power: int = field(
|
||||
metadata={
|
||||
"name": "ActualPower",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
delivered_flex_power: int = field(
|
||||
metadata={
|
||||
"name": "DeliveredFlexPower",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
power_deficiency: int = field(
|
||||
default=0,
|
||||
metadata={
|
||||
"name": "PowerDeficiency",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class FlexOrderSettlement:
|
||||
"""
|
||||
:ivar isp:
|
||||
:ivar order_reference: Order reference assigned by the DSO when
|
||||
originating the FlexOrder.
|
||||
:ivar period:
|
||||
:ivar contract_id: Reference to the concerning bilateral contract,
|
||||
if it is linked to it
|
||||
:ivar d_prognosis_message_id: MessageID of the Prognosis message
|
||||
(more specifically: the D-Prognosis) the FlexOrder is based on,
|
||||
if it has been agreed that the baseline is based on D-prognoses.
|
||||
:ivar baseline_reference: Identification of the baseline prognosis,
|
||||
if another baseline methodology is used than based on
|
||||
D-prognoses.
|
||||
:ivar congestion_point: Entity Address of the Congestion Point the
|
||||
FlexOrder applies to.
|
||||
:ivar price: The price accepted for supplying the ordered amount of
|
||||
flexibility as per the referenced FlexOrder messages.
|
||||
:ivar penalty: Penalty due a non-zero PowerDeficiency
|
||||
:ivar net_settlement: Net settlement amount for this Period: Price
|
||||
minus Penalty.
|
||||
"""
|
||||
isps: List[FlexOrderSettlementISP] = field(
|
||||
default_factory=list,
|
||||
metadata={
|
||||
"name": "ISP",
|
||||
"type": "Element",
|
||||
"min_occurs": 1,
|
||||
}
|
||||
)
|
||||
order_reference: Optional[str] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "OrderReference",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
period: XmlDate = field(
|
||||
metadata={
|
||||
"name": "Period",
|
||||
"type": "Attribute",
|
||||
"format": "%Y-%m-%d",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
contract_id: Optional[str] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "ContractID",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
d_prognosis_message_id: Optional[str] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "D-PrognosisMessageID",
|
||||
"type": "Attribute",
|
||||
"pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}",
|
||||
}
|
||||
)
|
||||
baseline_reference: Optional[str] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "BaselineReference",
|
||||
"type": "Attribute",
|
||||
}
|
||||
)
|
||||
congestion_point: str = field(
|
||||
metadata={
|
||||
"name": "CongestionPoint",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"(ea1\.[0-9]{4}-[0-9]{2}\..{1,244}:.{1,244}|ean\.[0-9]{12,34})",
|
||||
}
|
||||
)
|
||||
price: Decimal = field(
|
||||
metadata={
|
||||
"name": "Price",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"fraction_digits": 4,
|
||||
}
|
||||
)
|
||||
penalty: Decimal = field(
|
||||
default=Decimal("0"),
|
||||
metadata={
|
||||
"name": "Penalty",
|
||||
"type": "Attribute",
|
||||
"fraction_digits": 4,
|
||||
}
|
||||
)
|
||||
net_settlement: Decimal = field(
|
||||
metadata={
|
||||
"name": "NetSettlement",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"fraction_digits": 4,
|
||||
}
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
validate_list('isps', self.isps, FlexOrderSettlementISP, 1)
|
||||
self.price = validate_decimal('price', self.price, 4)
|
||||
self.penalty = validate_decimal('penalty', self.penalty, 4)
|
||||
self.net_settlement = validate_decimal('net_settlement', self.net_settlement, 4)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class FlexSettlementResponse(PayloadMessageResponse):
|
||||
flex_settlement_message_id: str = field(
|
||||
metadata={
|
||||
"name": "FlexSettlementMessageID",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}",
|
||||
}
|
||||
)
|
||||
|
||||
flex_order_settlement_statuses: List[FlexOrderSettlementStatus] = field(
|
||||
default_factory=list,
|
||||
metadata={
|
||||
"name": "FlexOrderSettlementStatus",
|
||||
"type": "Element",
|
||||
"min_occurs": 1,
|
||||
}
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
validate_list(
|
||||
"flex_order_settlement_statuses",
|
||||
self.flex_order_settlement_statuses,
|
||||
FlexOrderSettlementStatus,
|
||||
1,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class FlexSettlement(PayloadMessageResponse):
|
||||
"""
|
||||
:ivar flex_order_settlement:
|
||||
:ivar contract_settlement:
|
||||
:ivar period_start: First Period of the settlement period this
|
||||
message applies to.
|
||||
:ivar period_end: Last Period of the settlement period this message
|
||||
applies to.
|
||||
:ivar currency: ISO 4217 code indicating the currency that applies
|
||||
to all amounts (flex price, penalty and net settlement) in this
|
||||
message.
|
||||
"""
|
||||
flex_order_settlements: List[FlexOrderSettlement] = field(
|
||||
default_factory=list,
|
||||
metadata={
|
||||
"name": "FlexOrderSettlement",
|
||||
"type": "Element",
|
||||
"min_occurs": 1,
|
||||
}
|
||||
)
|
||||
contract_settlements: List[ContractSettlement] = field(
|
||||
default_factory=list,
|
||||
metadata={
|
||||
"name": "ContractSettlement",
|
||||
"type": "Element",
|
||||
"min_occurs": 1,
|
||||
}
|
||||
)
|
||||
period_start: XmlDate = field(
|
||||
metadata={
|
||||
"name": "PeriodStart",
|
||||
"type": "Attribute",
|
||||
"format": "%Y-%m-%d",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
period_end: XmlDate = field(
|
||||
metadata={
|
||||
"name": "PeriodEnd",
|
||||
"type": "Attribute",
|
||||
"format": "%Y-%m-%d",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
currency: str = field(
|
||||
metadata={
|
||||
"name": "Currency",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"[A-Z]{3}",
|
||||
}
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
validate_list(
|
||||
"flex_order_settlements", self.flex_order_settlements, FlexOrderSettlement, 1
|
||||
)
|
||||
validate_list(
|
||||
"contract_settlements", self.contract_settlements, ContractSettlement, 1
|
||||
)
|
||||
@@ -0,0 +1,202 @@
|
||||
from dataclasses import dataclass, field
|
||||
from decimal import Decimal
|
||||
from enum import Enum
|
||||
from typing import List, Optional
|
||||
|
||||
from xsdata.models.datatype import XmlDate, XmlDuration
|
||||
|
||||
from ..validations import validate_list
|
||||
from .payload_message import PayloadMessage, PayloadMessageResponse
|
||||
|
||||
# pylint: disable=missing-class-docstring,duplicate-code
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class MeteringISP:
|
||||
"""
|
||||
:ivar start: Number of the ISP this element refers to. The first ISP
|
||||
of a day has number 1.
|
||||
:ivar value: Metering, energy or price value at the end of this ISP,
|
||||
in the designated profile units.
|
||||
"""
|
||||
class Meta:
|
||||
name = "MeteringISP"
|
||||
|
||||
start: int = field(
|
||||
metadata={
|
||||
"name": "Start",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
value: Decimal = field(
|
||||
metadata={
|
||||
"name": "Value",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class MeteringProfileEnum(Enum):
|
||||
"""
|
||||
:cvar POWER: The average active power during ISP, considering both
|
||||
import and export energy. Power=(ImportEnergy-
|
||||
ExportEnergy)*(60/ISP-Length-Minutes). For example with a 15
|
||||
minute ISP length we have a multiplier of 4, with a 30 minute
|
||||
ISP length we have a multiplier of 2. Including the power
|
||||
profile is recommended. It is expected that in the following
|
||||
major version the power will become a mandatory value.
|
||||
:cvar IMPORT_ENERGY: Imported active energy, consumed during the ISP
|
||||
:cvar EXPORT_ENERGY: Exported active energy, generated during the
|
||||
ISP
|
||||
:cvar IMPORT_METER_READING: Cumulative metered imported active
|
||||
energy reading, at the end of the ISP
|
||||
:cvar EXPORT_METER_READING: Cumulative metered exported active
|
||||
energy reading, at the end of the ISP
|
||||
"""
|
||||
POWER = "Power"
|
||||
IMPORT_ENERGY = "ImportEnergy"
|
||||
EXPORT_ENERGY = "ExportEnergy"
|
||||
IMPORT_METER_READING = "ImportMeterReading"
|
||||
EXPORT_METER_READING = "ExportMeterReading"
|
||||
|
||||
|
||||
class MeteringUnit(Enum):
|
||||
"""
|
||||
:cvar K_W: kW must be used with Power profile values.
|
||||
:cvar K_WH: kWh must be used with energy profile values
|
||||
(ImportEnergy,ExportEnergy,ImportMeterReading,ExportMeterReading).
|
||||
"""
|
||||
K_W = "kW"
|
||||
K_WH = "kWh"
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class MeteringProfile:
|
||||
"""
|
||||
A profile carries a sequence of ISPs with a defined type of metering data.
|
||||
"""
|
||||
isps: List[MeteringISP] = field(
|
||||
default_factory=list,
|
||||
metadata={
|
||||
"name": "ISP",
|
||||
"type": "Element",
|
||||
"min_occurs": 1,
|
||||
}
|
||||
)
|
||||
profile_type: MeteringProfileEnum = field(
|
||||
metadata={
|
||||
"name": "ProfileType",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
unit: MeteringUnit = field(
|
||||
metadata={
|
||||
"name": "Unit",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
validate_list('isps', self.isps, MeteringISP, 1)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class MeteringResponse(PayloadMessageResponse):
|
||||
metering_message_id: str = field(
|
||||
metadata={
|
||||
"name": "MeteringMessageID",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class Metering(PayloadMessage):
|
||||
"""
|
||||
:ivar profile:
|
||||
:ivar revision: Revision of this message. A sequence number that
|
||||
must be incremented each time a new revision of a metering
|
||||
message is sent.
|
||||
:ivar isp_duration: ISO 8601 time interval (minutes only, for
|
||||
example PT15M) indicating the duration of the ISPs referenced in
|
||||
this message. Although the ISP length is a market-wide fixed
|
||||
value, making this assumption explicit in each message is
|
||||
important for validation purposes, allowing implementations to
|
||||
reject messages with an errant ISP duration.
|
||||
:ivar time_zone: Time zone ID (as per the IANA time zone database,
|
||||
http://www.iana.org/time-zones, for example: Europe/Amsterdam)
|
||||
indicating the UTC offset that applies to the Period referenced
|
||||
in this message. Although the time zone is a market-wide fixed
|
||||
value, making this assumption explicit in each message is
|
||||
important for validation purposes, allowing implementations to
|
||||
reject messages with an errant UTC offset.
|
||||
:ivar currency: ISO 4217 code indicating the currency that applies
|
||||
to the price of the Tariff Rates. Only required if ImportTariff
|
||||
or ExportTariff profiles are included.
|
||||
:ivar period: Day (in yyyy-mm-dd format) the ISPs referenced in this
|
||||
Metering message belong to.
|
||||
:ivar ean: EAN of the meter the message applies to.
|
||||
"""
|
||||
profiles: List[MeteringProfile] = field(
|
||||
default_factory=list,
|
||||
metadata={
|
||||
"name": "Profile",
|
||||
"type": "Element",
|
||||
"min_occurs": 1,
|
||||
}
|
||||
)
|
||||
revision: int = field(
|
||||
metadata={
|
||||
"name": "Revision",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
isp_duration: XmlDuration = field(
|
||||
metadata={
|
||||
"name": "ISP-Duration",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
time_zone: str = field(
|
||||
metadata={
|
||||
"name": "TimeZone",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"(Africa|America|Australia|Europe|Pacific)/[a-zA-Z0-9_/]{3,}",
|
||||
}
|
||||
)
|
||||
currency: Optional[str] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "Currency",
|
||||
"type": "Attribute",
|
||||
"pattern": r"[A-Z]{3}",
|
||||
}
|
||||
)
|
||||
period: XmlDate = field(
|
||||
metadata={
|
||||
"name": "Period",
|
||||
"type": "Attribute",
|
||||
"format": "%Y-%m-%d",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
ean: str = field(
|
||||
metadata={
|
||||
"name": "EAN",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"[Ee][0-9]{18}",
|
||||
}
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
validate_list('profiles', self.profiles, MeteringProfile, 1)
|
||||
@@ -0,0 +1,115 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
from ..enums import AcceptedRejected
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class PayloadMessage:
|
||||
"""
|
||||
:ivar version: Version of the Shapeshifter specification used by the
|
||||
USEF participant sending this message.
|
||||
:ivar sender_domain: The Internet domain of the USEF participant
|
||||
sending this message. When receiving a message, its value should
|
||||
match the value specified in the SignedMessage wrapper:
|
||||
otherwise, the message must be rejected as invalid. When
|
||||
replying to this message, this attribute is used to look up the
|
||||
USEF endpoint the reply message should be delivered to.
|
||||
:ivar recipient_domain: Internet domain of the participant this
|
||||
message is intended for. When sending a message, this attribute,
|
||||
combined with the RecipientRole, is used to look up the USEF
|
||||
endpoint the message should be delivered to.
|
||||
:ivar time_stamp: Date and time this message was created, including
|
||||
the time zone (ISO 8601 formatted as per
|
||||
http://www.w3.org/TR/NOTE-datetime).
|
||||
:ivar message_id: Unique identifier (UUID/GUID as per IETF RFC 4122)
|
||||
for this message, to be generated when composing each message.
|
||||
:ivar conversation_id: Unique identifier (UUID/GUID as per IETF RFC
|
||||
4122) used to correlate responses with requests, to be generated
|
||||
when composing the first message in a conversation and
|
||||
subsequently copied from the original message to each reply
|
||||
message.
|
||||
"""
|
||||
|
||||
version: Optional[str] = field(
|
||||
default="3.1.0",
|
||||
metadata={
|
||||
"name": "Version",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"(\d+\.\d+\.\d+)",
|
||||
}
|
||||
)
|
||||
sender_domain: Optional[str] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "SenderDomain",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}",
|
||||
}
|
||||
)
|
||||
recipient_domain: Optional[str] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "RecipientDomain",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}",
|
||||
}
|
||||
)
|
||||
time_stamp: Optional[str] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "TimeStamp",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{0,9})?([+-]\d{2}:\d{2}|Z)",
|
||||
}
|
||||
)
|
||||
message_id: Optional[str] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "MessageID",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}",
|
||||
}
|
||||
)
|
||||
conversation_id: Optional[str] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "ConversationID",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class PayloadMessageResponse(PayloadMessage):
|
||||
"""
|
||||
:ivar reference_message_id: MessageID of the message that has just
|
||||
been accepted or rejected.
|
||||
:ivar result: Indication whether the query was executed successfully
|
||||
or failed.
|
||||
:ivar rejection_reason: In case the query failed, this attribute
|
||||
must contain a human-readable description of the failure reason.
|
||||
"""
|
||||
|
||||
result: Optional[AcceptedRejected] = field(
|
||||
default=AcceptedRejected.ACCEPTED,
|
||||
metadata={
|
||||
"name": "Result",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
rejection_reason: Optional[str] = field(
|
||||
default=None,
|
||||
metadata={
|
||||
"name": "RejectionReason",
|
||||
"type": "Attribute",
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,56 @@
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from ..enums import UsefRole
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class SignedMessage:
|
||||
"""The SignedMessage element represents the secure wrapper used to submit USEF
|
||||
XML messages from the local message queue to the message queue of a remote
|
||||
participant.
|
||||
|
||||
It contains minimal metadata (which is distinct from the common
|
||||
metadata used for all other messages), allowing the recipient to
|
||||
look up the sender's cryptographic scheme and public keys, and the
|
||||
actual XML message, as transformed (signed/sealed) using that
|
||||
cryptographic scheme.
|
||||
|
||||
:ivar sender_domain: The Internet domain of the USEF participant
|
||||
sending this message. Upon receiving a message, the recipient
|
||||
should validate that its value matches the corresponding
|
||||
attribute value specified in the inner XML message, once un-
|
||||
sealed: if not, the message must be rejected as invalid.
|
||||
:ivar sender_role: The USEF role of the participant sending this
|
||||
message: AGR, BRP, CRO, DSO or MDC. Receive-time validation
|
||||
should take place as described for the SenderDomain attribute
|
||||
above.
|
||||
:ivar body: The Base-64 encoded inner XML message contained in this
|
||||
wrapper, as transformed (signed/sealed) using the sender's
|
||||
cryptographic scheme. The recipient can determine which scheme
|
||||
applies using a DNS or configuration file lookup, based on the
|
||||
combination of SenderDomain and SenderRole.
|
||||
"""
|
||||
|
||||
sender_domain: str = field(
|
||||
metadata={
|
||||
"name": "SenderDomain",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"pattern": r"([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}",
|
||||
}
|
||||
)
|
||||
sender_role: UsefRole = field(
|
||||
metadata={
|
||||
"name": "SenderRole",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
}
|
||||
)
|
||||
body: bytes = field(
|
||||
metadata={
|
||||
"name": "Body",
|
||||
"type": "Attribute",
|
||||
"required": True,
|
||||
"format": "base64",
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .payload_message import PayloadMessage, PayloadMessageResponse
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class TestMessage(PayloadMessage):
|
||||
__test__ = False # Tell pytest to ignore this class
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class TestMessageResponse(PayloadMessageResponse):
|
||||
__test__ = False # Tell pytest to ignore this class
|
||||
@@ -0,0 +1,32 @@
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
|
||||
def validate_decimal(name: str, value: int | float | Decimal | str, digits: int):
|
||||
"""
|
||||
Validates that the decimal is acceptable, and returns it with the correct number of digits.
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
value = Decimal(value)
|
||||
except InvalidOperation as exc:
|
||||
raise ValueError(f"{name} must be a valid numeric value, not '{value}'") from exc
|
||||
if not isinstance(value, (int, float, Decimal)):
|
||||
raise TypeError(f"'{name}' must be a numeric type, not {type(value)}")
|
||||
return Decimal(f"{value:.{digits}f}")
|
||||
|
||||
|
||||
def validate_list(name, value, item_type, length):
|
||||
"""
|
||||
Validates that the list is of the correct type, length and content type.
|
||||
"""
|
||||
if not isinstance(value, list):
|
||||
raise TypeError(f"'{name}' must be a list, not {type(value)}")
|
||||
if len(value) < length:
|
||||
raise ValueError(f"'Length of list '{name}' must be {length} or greater, not {len(value)}")
|
||||
for index, item in enumerate(value):
|
||||
if not isinstance(item, item_type):
|
||||
raise TypeError(
|
||||
f"Not all items of property {name} were of type {item_type}: "
|
||||
f"item at index {index} was of type {type(item)}"
|
||||
)
|
||||
return value
|
||||
363
tools/shapeshifter-library-python-main/test/helpers/messages.py
Normal file
363
tools/shapeshifter-library-python-main/test/helpers/messages.py
Normal file
@@ -0,0 +1,363 @@
|
||||
from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
from xsdata.models.datatype import XmlDate
|
||||
|
||||
from shapeshifter_uftp.uftp import *
|
||||
|
||||
default_args = {
|
||||
"version": "3.1.0",
|
||||
"sender_domain": "agr.dev",
|
||||
"recipient_domain": "cro.dev",
|
||||
"time_stamp": datetime.now(timezone.utc).isoformat(),
|
||||
"message_id": str(uuid4()),
|
||||
"conversation_id": str(uuid4())
|
||||
}
|
||||
|
||||
messages = [
|
||||
AgrPortfolioQuery(period=XmlDate(2023, 1, 1), **default_args),
|
||||
AgrPortfolioQueryResponse(
|
||||
dso_views=[
|
||||
AgrPortfolioQueryResponseDSOView(
|
||||
dso_portfolios=[
|
||||
AgrPortfolioQueryResponseDSOPortfolio(
|
||||
congestion_points=[
|
||||
AgrPortfolioQueryResponseCongestionPoint(
|
||||
connections=[
|
||||
AgrPortfolioQueryResponseConnection(
|
||||
entity_address="ean.210987654321"
|
||||
)
|
||||
],
|
||||
entity_address="ean.123456789012",
|
||||
mutex_offers_supported=True,
|
||||
day_ahead_redispatch_by=RedispatchBy.AGR,
|
||||
intraday_redispatch_by=RedispatchBy.AGR,
|
||||
)
|
||||
],
|
||||
dso_domain="dso.dev"
|
||||
)
|
||||
]
|
||||
)
|
||||
],
|
||||
period=XmlDate(2023, 1, 1),
|
||||
agr_portfolio_query_message_id=str(uuid4()),
|
||||
**default_args
|
||||
),
|
||||
AgrPortfolioUpdate(
|
||||
connections=[
|
||||
AgrPortfolioUpdateConnection(
|
||||
entity_address="ean.123456789012",
|
||||
start_period=XmlDate(2023, 1, 1)
|
||||
)
|
||||
],
|
||||
**default_args
|
||||
),
|
||||
AgrPortfolioUpdateResponse(agr_portfolio_update_message_id=str(uuid4()), **default_args),
|
||||
DPrognosis(
|
||||
isp_duration="PT15M",
|
||||
period=XmlDate(2023, 1, 1),
|
||||
congestion_point="ean.123456789012",
|
||||
isps=[
|
||||
DPrognosisISP(
|
||||
power=2,
|
||||
start=1,
|
||||
duration=1
|
||||
)
|
||||
],
|
||||
revision=1,
|
||||
**default_args
|
||||
),
|
||||
DPrognosisResponse(
|
||||
d_prognosis_message_id=str(uuid4()),
|
||||
**default_args,
|
||||
),
|
||||
DsoPortfolioQuery(
|
||||
entity_address="ean.123456789012", period=XmlDate(2023, 5, 1), **default_args
|
||||
),
|
||||
DsoPortfolioQueryResponse(
|
||||
congestion_point=DsoPortfolioQueryCongestionPoint(
|
||||
connections=[
|
||||
DsoPortfolioQueryConnection(
|
||||
entity_address="ean.123456789012",
|
||||
agr_domain="agr.dev"
|
||||
)
|
||||
],
|
||||
entity_address="ean.123456789012"
|
||||
),
|
||||
period=XmlDate(2023, 5, 1),
|
||||
dso_portfolio_query_message_id=str(uuid4()),
|
||||
**default_args,
|
||||
),
|
||||
DsoPortfolioUpdate(
|
||||
congestion_points=[
|
||||
DsoPortfolioUpdateCongestionPoint(
|
||||
connections=[
|
||||
DsoPortfolioUpdateConnection(
|
||||
entity_address="ean.123456789012",
|
||||
start_period=XmlDate(2023, 1, 1),
|
||||
end_period=XmlDate(2023, 1, 1),
|
||||
)
|
||||
],
|
||||
entity_address="ean.123456789012",
|
||||
start_period=XmlDate(2023, 1, 1),
|
||||
end_period=XmlDate(2023, 1, 1),
|
||||
mutex_offers_supported=True,
|
||||
day_ahead_redispatch_by=RedispatchBy.AGR,
|
||||
intraday_redispatch_by=RedispatchBy.AGR,
|
||||
)
|
||||
],
|
||||
**default_args
|
||||
),
|
||||
DsoPortfolioUpdateResponse(
|
||||
dso_portfolio_update_message_id=str(uuid4()), **default_args
|
||||
),
|
||||
FlexOffer(
|
||||
isp_duration="PT15M",
|
||||
period=XmlDate(2023, 1, 1),
|
||||
congestion_point="ean.123456789012",
|
||||
expiration_date_time=datetime.now(timezone.utc).isoformat(),
|
||||
offer_options=[
|
||||
FlexOfferOption(
|
||||
isps=[
|
||||
FlexOfferOptionISP(
|
||||
power=1,
|
||||
start=1,
|
||||
duration=1
|
||||
)
|
||||
],
|
||||
option_reference="MyOption",
|
||||
price=2.30,
|
||||
min_activation_factor=0.5
|
||||
)
|
||||
],
|
||||
flex_request_message_id=str(uuid4()),
|
||||
**default_args
|
||||
),
|
||||
FlexOffer(
|
||||
isp_duration="PT15M",
|
||||
period=XmlDate(2023, 1, 1),
|
||||
congestion_point="ean.123456789012",
|
||||
expiration_date_time=datetime.now(timezone.utc).isoformat(),
|
||||
offer_options=[
|
||||
FlexOfferOption(
|
||||
isps=[
|
||||
FlexOfferOptionISP(
|
||||
power=1,
|
||||
start=1,
|
||||
duration=1
|
||||
)
|
||||
],
|
||||
option_reference="MyOption",
|
||||
price=2.30,
|
||||
min_activation_factor=0.5
|
||||
)
|
||||
],
|
||||
flex_request_message_id=str(uuid4()),
|
||||
**default_args
|
||||
),
|
||||
FlexOffer(
|
||||
isp_duration="PT15M",
|
||||
period=XmlDate(2023, 1, 1),
|
||||
congestion_point="ean.123456789012",
|
||||
expiration_date_time=datetime.now(timezone.utc).isoformat(),
|
||||
offer_options=[
|
||||
FlexOfferOption(
|
||||
isps=[
|
||||
FlexOfferOptionISP(
|
||||
power=1,
|
||||
start=1,
|
||||
duration=1
|
||||
)
|
||||
],
|
||||
option_reference="MyOption",
|
||||
price=2.30,
|
||||
min_activation_factor=0.5
|
||||
)
|
||||
],
|
||||
unsolicited=True,
|
||||
flex_request_message_id=None,
|
||||
**default_args
|
||||
),
|
||||
FlexOfferResponse(flex_offer_message_id=str(uuid4()), **default_args),
|
||||
FlexOfferRevocation(flex_offer_message_id=str(uuid4()), **default_args),
|
||||
FlexOfferRevocationResponse(flex_offer_revocation_message_id=str(uuid4()), **default_args),
|
||||
FlexOrder(
|
||||
isps=[FlexOrderISP(
|
||||
power=1,
|
||||
duration=1,
|
||||
start=1
|
||||
)],
|
||||
isp_duration="PT15M",
|
||||
period=XmlDate(2023, 1, 1),
|
||||
congestion_point="ean.123456789012",
|
||||
flex_offer_message_id=str(uuid4()),
|
||||
contract_id=str(uuid4()),
|
||||
d_prognosis_message_id=str(uuid4()),
|
||||
baseline_reference=str(uuid4()),
|
||||
price=2.00,
|
||||
currency="EUR",
|
||||
order_reference=str(uuid4()),
|
||||
option_reference=str(uuid4()),
|
||||
activation_factor=0.5,
|
||||
**default_args
|
||||
),
|
||||
FlexOrder(
|
||||
isps=[FlexOrderISP(
|
||||
power=1,
|
||||
duration=1,
|
||||
start=1
|
||||
)],
|
||||
isp_duration="PT15M",
|
||||
period=XmlDate(2023, 1, 1),
|
||||
congestion_point="ean.123456789012",
|
||||
unsolicited=True,
|
||||
flex_offer_message_id=None,
|
||||
contract_id=str(uuid4()),
|
||||
d_prognosis_message_id=str(uuid4()),
|
||||
baseline_reference=str(uuid4()),
|
||||
price=2.00,
|
||||
currency="EUR",
|
||||
order_reference=str(uuid4()),
|
||||
option_reference=str(uuid4()),
|
||||
activation_factor=0.5,
|
||||
**default_args
|
||||
),
|
||||
FlexOrderResponse(
|
||||
flex_order_message_id=str(uuid4()),
|
||||
**default_args
|
||||
),
|
||||
FlexRequest(
|
||||
isp_duration="PT15M",
|
||||
period=XmlDate(2023, 1, 1),
|
||||
congestion_point="ean.123456789012",
|
||||
isps=[
|
||||
FlexRequestISP(
|
||||
disposition=AvailableRequested.REQUESTED,
|
||||
min_power=0,
|
||||
max_power=10,
|
||||
start=1,
|
||||
duration=1,
|
||||
)
|
||||
],
|
||||
revision=1,
|
||||
expiration_date_time=datetime.now(timezone.utc).isoformat(),
|
||||
contract_id=str(uuid4()),
|
||||
service_type="MyService",
|
||||
**default_args
|
||||
),
|
||||
FlexReservationUpdate(
|
||||
isp_duration="PT15M",
|
||||
period=XmlDate(2023, 1, 1),
|
||||
congestion_point="ean.123456789012",
|
||||
isps=[
|
||||
FlexReservationUpdateISP(
|
||||
power=1,
|
||||
start=1,
|
||||
duration=1
|
||||
)
|
||||
],
|
||||
contract_id=str(uuid4()),
|
||||
reference="MyReference",
|
||||
**default_args,
|
||||
),
|
||||
FlexReservationUpdateResponse(
|
||||
flex_reservation_update_message_id=str(uuid4()),
|
||||
**default_args
|
||||
),
|
||||
FlexSettlement(
|
||||
flex_order_settlements=[
|
||||
FlexOrderSettlement(
|
||||
isps=[
|
||||
FlexOrderSettlementISP(
|
||||
start=1,
|
||||
duration=1,
|
||||
baseline_power=1,
|
||||
ordered_flex_power=1,
|
||||
actual_power=1,
|
||||
delivered_flex_power=1,
|
||||
power_deficiency=1,
|
||||
)
|
||||
],
|
||||
period=XmlDate(2023, 1, 1),
|
||||
congestion_point="ean.123456789012",
|
||||
order_reference=str(uuid4()),
|
||||
contract_id=str(uuid4()),
|
||||
d_prognosis_message_id=str(uuid4()),
|
||||
baseline_reference=str(uuid4()),
|
||||
price=1.0,
|
||||
penalty=1.0,
|
||||
net_settlement=2.0,
|
||||
)
|
||||
],
|
||||
contract_settlements=[
|
||||
ContractSettlement(
|
||||
periods=[
|
||||
ContractSettlementPeriod(
|
||||
isps=[
|
||||
ContractSettlementISP(
|
||||
start=1,
|
||||
duration=1,
|
||||
reserved_power=1,
|
||||
requested_power=1,
|
||||
available_power=1,
|
||||
offered_power=1,
|
||||
ordered_power=1,
|
||||
)
|
||||
],
|
||||
period=XmlDate(2023, 1, 1)
|
||||
)
|
||||
],
|
||||
contract_id=str(uuid4())
|
||||
)
|
||||
],
|
||||
period_start=XmlDate(2023, 1, 1),
|
||||
period_end=XmlDate(2023, 5, 1),
|
||||
currency="EUR",
|
||||
**default_args,
|
||||
),
|
||||
FlexSettlementResponse(
|
||||
flex_order_settlement_statuses=[
|
||||
FlexOrderSettlementStatus(
|
||||
order_reference=str(uuid4()),
|
||||
disposition=AcceptedDisputed.ACCEPTED,
|
||||
dispute_reason="My Reason",
|
||||
)
|
||||
],
|
||||
flex_settlement_message_id=str(uuid4()),
|
||||
**default_args,
|
||||
),
|
||||
FlexRequestResponse(
|
||||
flex_request_message_id=str(uuid4()),
|
||||
**default_args,
|
||||
),
|
||||
Metering(
|
||||
profiles=[
|
||||
MeteringProfile(
|
||||
isps=[
|
||||
MeteringISP(
|
||||
start=1,
|
||||
value=1
|
||||
)
|
||||
],
|
||||
profile_type=MeteringProfileEnum.POWER,
|
||||
unit=MeteringUnit.K_W,
|
||||
)
|
||||
],
|
||||
revision=1,
|
||||
isp_duration="PT15M",
|
||||
time_zone="Europe/Amsterdam",
|
||||
currency="EUR",
|
||||
period=XmlDate(2023, 1, 1),
|
||||
ean="E1234567890123456",
|
||||
**default_args
|
||||
),
|
||||
MeteringResponse(
|
||||
metering_message_id=str(uuid4()),
|
||||
**default_args
|
||||
),
|
||||
|
||||
]
|
||||
|
||||
messages_by_type = {
|
||||
type(message): message for message in messages
|
||||
}
|
||||
351
tools/shapeshifter-library-python-main/test/helpers/services.py
Normal file
351
tools/shapeshifter-library-python-main/test/helpers/services.py
Normal file
@@ -0,0 +1,351 @@
|
||||
import itertools
|
||||
from base64 import b64encode
|
||||
from concurrent.futures import Future
|
||||
|
||||
from nacl.bindings import crypto_sign_keypair
|
||||
|
||||
from shapeshifter_uftp import (
|
||||
ShapeshifterAgrService,
|
||||
ShapeshifterCroService,
|
||||
ShapeshifterDsoService,
|
||||
)
|
||||
from shapeshifter_uftp.service.base_service import snake_case
|
||||
|
||||
AGR_DOMAIN = "agr.dev"
|
||||
CRO_DOMAIN = "cro.dev"
|
||||
DSO_DOMAIN = "dso.dev"
|
||||
|
||||
AGR_TEST_PORT = 9001
|
||||
CRO_TEST_PORT = 9002
|
||||
DSO_TEST_PORT = 9003
|
||||
|
||||
AGR_PUBLIC_KEY, AGR_PRIVATE_KEY = [b64encode(key).decode() for key in crypto_sign_keypair()]
|
||||
CRO_PUBLIC_KEY, CRO_PRIVATE_KEY = [b64encode(key).decode() for key in crypto_sign_keypair()]
|
||||
DSO_PUBLIC_KEY, DSO_PRIVATE_KEY = [b64encode(key).decode() for key in crypto_sign_keypair()]
|
||||
|
||||
|
||||
def endpoint_lookup_function(domain, role):
|
||||
if domain == "agr.dev":
|
||||
return f"http://localhost:{AGR_TEST_PORT}/shapeshifter/api/v3/message"
|
||||
elif domain == "cro.dev":
|
||||
return f"http://localhost:{CRO_TEST_PORT}/shapeshifter/api/v3/message"
|
||||
elif domain == "dso.dev":
|
||||
return f"http://localhost:{DSO_TEST_PORT}/shapeshifter/api/v3/message"
|
||||
|
||||
|
||||
def key_lookup_function(domain, role):
|
||||
if domain == "agr.dev":
|
||||
return AGR_PUBLIC_KEY
|
||||
elif domain == "cro.dev":
|
||||
return CRO_PUBLIC_KEY
|
||||
elif domain == "dso.dev":
|
||||
return DSO_PUBLIC_KEY
|
||||
|
||||
|
||||
class DummyAgrService(ShapeshifterAgrService):
|
||||
|
||||
def __init__(self, oauth_lookup_function=None):
|
||||
super().__init__(
|
||||
sender_domain=AGR_DOMAIN,
|
||||
signing_key=AGR_PRIVATE_KEY,
|
||||
key_lookup_function=key_lookup_function,
|
||||
endpoint_lookup_function=endpoint_lookup_function,
|
||||
oauth_lookup_function=oauth_lookup_function,
|
||||
port=AGR_TEST_PORT
|
||||
)
|
||||
|
||||
self.request_futures = {
|
||||
f"{stage}_{name}": Future()
|
||||
for stage, name in itertools.product(
|
||||
["process"],
|
||||
[
|
||||
name
|
||||
for name in [
|
||||
snake_case(message.__name__)
|
||||
for message in self.acceptable_messages
|
||||
]
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
def reset_futures(self, name):
|
||||
self.request_futures[f"pre_process_{name}"] = Future()
|
||||
self.request_futures[f"process_{name}"] = Future()
|
||||
|
||||
def process_flex_request(self, message):
|
||||
self.request_futures["process_flex_request"].set_result(message)
|
||||
|
||||
def process_flex_order(self, message):
|
||||
self.request_futures["process_flex_order"].set_result(message)
|
||||
|
||||
def process_flex_reservation_update(self, message):
|
||||
self.request_futures["process_flex_reservation_update"].set_result(message)
|
||||
|
||||
def process_flex_settlement(self, message):
|
||||
self.request_futures["process_flex_settlement"].set_result(message)
|
||||
|
||||
def process_flex_offer_revocation_response(self, message):
|
||||
self.request_futures["process_flex_offer_revocation_response"].set_result(message)
|
||||
|
||||
def process_agr_portfolio_query_response(self, message):
|
||||
self.request_futures["process_agr_portfolio_query_response"].set_result(message)
|
||||
|
||||
def process_agr_portfolio_update_response(self, message):
|
||||
self.request_futures["process_agr_portfolio_update_response"].set_result(message)
|
||||
|
||||
def process_d_prognosis_response(self, message):
|
||||
self.request_futures["process_d_prognosis_response"].set_result(message)
|
||||
|
||||
def process_flex_offer_response(self, message):
|
||||
self.request_futures["process_flex_offer_response"].set_result(message)
|
||||
|
||||
def process_metering_response(self, message):
|
||||
self.request_futures["process_metering_response"].set_result(message)
|
||||
|
||||
def process_test_message(self, message, sender_role):
|
||||
self.request_futures["process_test_message"].set_result(message)
|
||||
super().process_test_message(message, sender_role)
|
||||
|
||||
def process_test_message_response(self, message):
|
||||
self.request_futures["process_test_message_response"].set_result(message)
|
||||
super().process_test_message_response(message)
|
||||
|
||||
|
||||
class DummyCroService(ShapeshifterCroService):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
sender_domain=CRO_DOMAIN,
|
||||
signing_key=CRO_PRIVATE_KEY,
|
||||
key_lookup_function=key_lookup_function,
|
||||
endpoint_lookup_function=endpoint_lookup_function,
|
||||
port=CRO_TEST_PORT
|
||||
)
|
||||
|
||||
self.request_futures = {
|
||||
f"{stage}_{name}": Future()
|
||||
for stage, name in itertools.product(
|
||||
["pre_process", "process"],
|
||||
[
|
||||
name
|
||||
for name in [
|
||||
snake_case(message.__name__)
|
||||
for message in self.acceptable_messages
|
||||
]
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
self.response_futures = {
|
||||
name: Future()
|
||||
for name in [
|
||||
f"pre_process_{snake_case(message.__name__)}"
|
||||
for message in self.acceptable_messages
|
||||
]
|
||||
}
|
||||
|
||||
def reset_futures(self, name):
|
||||
self.request_futures[f"pre_process_{name}"] = Future()
|
||||
self.request_futures[f"process_{name}"] = Future()
|
||||
self.response_futures[f"pre_process_{name}"] = Future()
|
||||
|
||||
def process_agr_portfolio_query(self, message):
|
||||
self.request_futures["process_agr_portfolio_query"].set_result(message)
|
||||
|
||||
def process_agr_portfolio_update(self, message):
|
||||
self.request_futures["process_agr_portfolio_update"].set_result(message)
|
||||
|
||||
def process_dso_portfolio_query(self, message):
|
||||
self.request_futures["process_dso_portfolio_query"].set_result(message)
|
||||
|
||||
def process_dso_portfolio_update(self, message):
|
||||
self.request_futures["process_dso_portfolio_update"].set_result(message)
|
||||
|
||||
def process_test_message(self, message, sender_role):
|
||||
self.request_futures["process_test_message"].set_result(message)
|
||||
super().process_test_message(message, sender_role)
|
||||
|
||||
def process_test_message_response(self, message):
|
||||
self.request_futures["process_test_message_response"].set_result(message)
|
||||
super().process_test_message_response(message)
|
||||
|
||||
class DummyDsoService(ShapeshifterDsoService):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
sender_domain=DSO_DOMAIN,
|
||||
signing_key=DSO_PRIVATE_KEY,
|
||||
key_lookup_function=key_lookup_function,
|
||||
endpoint_lookup_function=endpoint_lookup_function,
|
||||
port=DSO_TEST_PORT
|
||||
)
|
||||
|
||||
self.request_futures = {
|
||||
f"{stage}_{name}": Future()
|
||||
for stage, name in itertools.product(
|
||||
["pre_process", "process"],
|
||||
[
|
||||
name
|
||||
for name in [
|
||||
snake_case(message.__name__)
|
||||
for message in self.acceptable_messages
|
||||
]
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
self.response_futures = {
|
||||
name: Future()
|
||||
for name in [
|
||||
f"pre_process_{snake_case(message.__name__)}"
|
||||
for message in self.acceptable_messages
|
||||
]
|
||||
}
|
||||
|
||||
def reset_futures(self, name):
|
||||
self.request_futures[f"pre_process_{name}"] = Future()
|
||||
self.request_futures[f"process_{name}"] = Future()
|
||||
self.response_futures[f"pre_process_{name}"] = Future()
|
||||
|
||||
def process_flex_offer(self, message):
|
||||
self.request_futures["process_flex_offer"].set_result(message)
|
||||
|
||||
def process_flex_order_response(self, message):
|
||||
self.request_futures["process_flex_order_response"].set_result(message)
|
||||
|
||||
def process_d_prognosis(self, message):
|
||||
self.request_futures["process_d_prognosis"].set_result(message)
|
||||
|
||||
def process_flex_offer_revocation(self, message):
|
||||
self.request_futures["process_flex_offer_revocation"].set_result(message)
|
||||
|
||||
def process_flex_settlement_response(self, message):
|
||||
self.request_futures["process_flex_settlement_response"].set_result(message)
|
||||
|
||||
def process_dso_portfolio_update_response(self, message):
|
||||
self.request_futures["process_dso_portfolio_update_response"].set_result(message)
|
||||
|
||||
def process_dso_portfolio_query_response(self, message):
|
||||
self.request_futures["process_dso_portfolio_query_response"].set_result(message)
|
||||
|
||||
def process_flex_request_response(self, message):
|
||||
self.request_futures["process_flex_request_response"].set_result(message)
|
||||
|
||||
def process_flex_reservation_update_response(self, message):
|
||||
self.request_futures["process_flex_reservation_update_response"].set_result(message)
|
||||
|
||||
def process_metering(self, message):
|
||||
self.request_futures["process_metering"].set_result(message)
|
||||
|
||||
def process_test_message(self, message, sender_role):
|
||||
self.request_futures["process_test_message"].set_result(message)
|
||||
super().process_test_message(message, sender_role)
|
||||
|
||||
def process_test_message_response(self, message):
|
||||
if self.request_futures["process_test_message_response"].done() is False:
|
||||
self.request_futures["process_test_message_response"].set_result(message)
|
||||
super().process_test_message_response(message)
|
||||
|
||||
class DefaultResponseAgrService(ShapeshifterAgrService):
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
sender_domain=AGR_DOMAIN,
|
||||
signing_key=AGR_PRIVATE_KEY,
|
||||
key_lookup_function=key_lookup_function,
|
||||
endpoint_lookup_function=endpoint_lookup_function,
|
||||
port=AGR_TEST_PORT
|
||||
)
|
||||
|
||||
def process_flex_request(self, message):
|
||||
pass
|
||||
|
||||
def process_flex_order(self, message):
|
||||
pass
|
||||
|
||||
def process_flex_reservation_update(self, message):
|
||||
pass
|
||||
|
||||
def process_flex_settlement(self, message):
|
||||
pass
|
||||
|
||||
def process_flex_offer_revocation_response(self, message):
|
||||
pass
|
||||
|
||||
def process_agr_portfolio_query_response(self, message):
|
||||
pass
|
||||
|
||||
def process_agr_portfolio_update_response(self, message):
|
||||
pass
|
||||
|
||||
def process_d_prognosis_response(self, message):
|
||||
pass
|
||||
|
||||
def process_flex_offer_response(self, message):
|
||||
pass
|
||||
|
||||
def process_metering_response(self, message):
|
||||
pass
|
||||
|
||||
|
||||
class DefaultResponseCroService(ShapeshifterCroService):
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
sender_domain=CRO_DOMAIN,
|
||||
signing_key=CRO_PRIVATE_KEY,
|
||||
key_lookup_function=key_lookup_function,
|
||||
endpoint_lookup_function=endpoint_lookup_function,
|
||||
port=CRO_TEST_PORT
|
||||
)
|
||||
|
||||
def process_agr_portfolio_query(self, message):
|
||||
pass
|
||||
|
||||
def process_agr_portfolio_update(self, message):
|
||||
pass
|
||||
|
||||
def process_dso_portfolio_query(self, message):
|
||||
pass
|
||||
|
||||
def process_dso_portfolio_update(self, message):
|
||||
pass
|
||||
|
||||
|
||||
class DefaultResponseDsoService(ShapeshifterDsoService):
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
sender_domain=DSO_DOMAIN,
|
||||
signing_key=DSO_PRIVATE_KEY,
|
||||
key_lookup_function=key_lookup_function,
|
||||
endpoint_lookup_function=endpoint_lookup_function,
|
||||
port=DSO_TEST_PORT
|
||||
)
|
||||
|
||||
def process_flex_offer(self, message):
|
||||
pass
|
||||
|
||||
def process_flex_order_response(self, message):
|
||||
pass
|
||||
|
||||
def process_d_prognosis(self, message):
|
||||
pass
|
||||
|
||||
def process_flex_offer_revocation(self, message):
|
||||
pass
|
||||
|
||||
def process_flex_settlement_response(self, message):
|
||||
pass
|
||||
|
||||
def process_dso_portfolio_update_response(self, message):
|
||||
pass
|
||||
|
||||
def process_dso_portfolio_query_response(self, message):
|
||||
pass
|
||||
|
||||
def process_flex_request_response(self, message):
|
||||
pass
|
||||
|
||||
def process_flex_reservation_update_response(self, message):
|
||||
pass
|
||||
|
||||
def process_metering(self, message):
|
||||
pass
|
||||
@@ -0,0 +1,167 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" elementFormDefault="qualified" attributeFormDefault="unqualified">
|
||||
<xs:include schemaLocation="UFTP-common.xsd"/>
|
||||
<!-- -->
|
||||
<!-- AGR Portfolio Update Type -->
|
||||
<!-- -->
|
||||
<xs:complexType name="AGRPortfolioUpdateType">
|
||||
<xs:complexContent>
|
||||
<xs:extension base="PayloadMessageType">
|
||||
<xs:sequence>
|
||||
<xs:element name="Connection" type="AGRPortfolioUpdateConnection" maxOccurs="unbounded"/>
|
||||
</xs:sequence>
|
||||
<xs:attribute name="TimeZone" type="TimeZoneNameType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>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.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
<!-- -->
|
||||
<!-- AGR Portfolio Update Connection -->
|
||||
<!-- -->
|
||||
<xs:complexType name="AGRPortfolioUpdateConnection">
|
||||
<xs:annotation>
|
||||
<xs:documentation>A connection that the AGR want the CRO to update</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:attribute name="EntityAddress" type="EntityAddressType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>EntityAddress of the Connection entity being updated. </xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="StartPeriod" type="PeriodType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The first Period hat the AGR represents the prosumer at this Connection.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="EndPeriod" type="PeriodType" use="optional">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The last Period that the AGR represents the prosumer at this Connection, if applicable.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:complexType>
|
||||
<!-- -->
|
||||
<!-- AGR Portfolio Update Response Type -->
|
||||
<!-- -->
|
||||
<xs:complexType name="AGRPortfolioUpdateResponseType">
|
||||
<xs:complexContent>
|
||||
<xs:extension base="PayloadMessageResponseType">
|
||||
<xs:attribute name="AGRPortfolioUpdateMessageID" type="UUIDType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>MessageID of the AGRPortfolioUpdate that has just been accepted or rejected</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
<!-- -->
|
||||
<!-- AGR Portfolio Query Type -->
|
||||
<!-- -->
|
||||
<xs:complexType name="AGRPortfolioQueryType">
|
||||
<xs:complexContent>
|
||||
<xs:extension base="PayloadMessageType">
|
||||
<xs:attribute name="TimeZone" type="TimeZoneNameType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>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.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="Period" type="PeriodType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The Period for which the AGR requests the portfolio information.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
<!-- -->
|
||||
<!-- AGR Portfolio Query Response Type -->
|
||||
<!-- -->
|
||||
<xs:complexType name="AGRPortfolioQueryResponseType">
|
||||
<xs:complexContent>
|
||||
<xs:extension base="PayloadMessageResponseType">
|
||||
<xs:sequence>
|
||||
<xs:element name="DSO-View" type="AGRPortfolioQueryResponseDSOViewType" maxOccurs="unbounded"/>
|
||||
</xs:sequence>
|
||||
<xs:attribute name="TimeZone" type="TimeZoneNameType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>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.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="AGRPortfolioQueryMessageID" type="UUIDType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>MessageID of the AGRPortfolioQuery that has just been accepted or rejected</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="Period" type="PeriodType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The Period that the portfolio is valid.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
<!-- -->
|
||||
<!-- AGR Portfolio Query Response DSO View Type -->
|
||||
<!-- -->
|
||||
<xs:complexType name="AGRPortfolioQueryResponseDSOViewType">
|
||||
<xs:sequence>
|
||||
<xs:element name="DSO-Portfolio" type="AGRPortfolioQueryResponseDSOPortfolioType" maxOccurs="unbounded"/>
|
||||
<xs:element name="Connection" type="AGRPortfolioQueryResponseConnectionType" minOccurs="0" maxOccurs="unbounded"/>
|
||||
</xs:sequence>
|
||||
<!-- -->
|
||||
<!-- AGR Portfolio Query Response DSO Portfolio Type -->
|
||||
<!-- -->
|
||||
</xs:complexType>
|
||||
<xs:complexType name="AGRPortfolioQueryResponseDSOPortfolioType">
|
||||
<xs:sequence>
|
||||
<xs:element name="CongestionPoint" type="AGRPortfolioQueryResponseCongestionPointType" maxOccurs="unbounded"/>
|
||||
</xs:sequence>
|
||||
<xs:attribute name="DSO-Domain" type="InternetDomainType" use="required"/>
|
||||
</xs:complexType>
|
||||
<!-- -->
|
||||
<!-- AGR Portfolio Query Response Congestion Point Type -->
|
||||
<!-- -->
|
||||
<xs:complexType name="AGRPortfolioQueryResponseCongestionPointType">
|
||||
<xs:sequence>
|
||||
<xs:element name="Connection" type="AGRPortfolioQueryResponseConnectionType" maxOccurs="unbounded"/>
|
||||
</xs:sequence>
|
||||
<xs:attribute name="EntityAddress" type="EntityAddressType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>EntityAddress of the CongestionPoint.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="MutexOffersSupported" type="xs:boolean" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Indicates whether the DSO accepts mutual exclusive FlexOffers on this CongestionPoint.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="DayAheadRedispatchBy" type="RedispatchByType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Indicates which party is responsible for day-ahead redispatch.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="IntradayRedispatchBy" type="RedispatchByType" use="optional">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Indicates which party is responsible for intraday ahead redispatch, AGR or DSO. If not specified, there will be no intraday trading on this CongestionPoint.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:complexType>
|
||||
<!-- -->
|
||||
<!-- AGR Portfolio Query Response Connection Type -->
|
||||
<!-- -->
|
||||
<xs:complexType name="AGRPortfolioQueryResponseConnectionType">
|
||||
<xs:attribute name="EntityAddress" type="EntityAddressType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>EntityAddress of the Connection.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:complexType>
|
||||
<!-- -->
|
||||
<!-- ELEMENT DEFINITIONS -->
|
||||
<!-- -->
|
||||
<xs:element name="AGRPortfolioUpdate" type="AGRPortfolioUpdateType"/>
|
||||
<xs:element name="AGRPortfolioUpdateResponse" type="AGRPortfolioUpdateResponseType"/>
|
||||
<xs:element name="AGRPortfolioQuery" type="AGRPortfolioQueryType"/>
|
||||
<xs:element name="AGRPortfolioQueryResponse" type="AGRPortfolioQueryResponseType"/>
|
||||
</xs:schema>
|
||||
@@ -0,0 +1,705 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- edited with XMLSpy v2020 sp1 (x64) (http://www.altova.com) by Erik Brouwer (ICT netherlands B.V.) -->
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" elementFormDefault="qualified" attributeFormDefault="unqualified">
|
||||
<xs:include schemaLocation="UFTP-common.xsd"/>
|
||||
<xs:include schemaLocation="UFTP-metering.xsd"/>
|
||||
<!-- -->
|
||||
<!-- Flex Message Type -->
|
||||
<!-- -->
|
||||
<xs:complexType name="FlexMessageType" abstract="true">
|
||||
<xs:complexContent>
|
||||
<xs:extension base="PayloadMessageType">
|
||||
<xs:attribute name="ISP-Duration" type="xs:duration" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>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.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="TimeZone" type="TimeZoneNameType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>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.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="Period" type="PeriodType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Day (in yyyy-mm-dd format) the ISPs referenced in this Flex* message belong to.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="CongestionPoint" type="EntityAddressType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Entity Address of the Congestion Point this D-Prognosis applies to.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
<!-- -->
|
||||
<!-- D-Prognosis Type -->
|
||||
<!-- -->
|
||||
<xs:complexType name="D-PrognosisType">
|
||||
<xs:complexContent>
|
||||
<xs:extension base="FlexMessageType">
|
||||
<xs:sequence>
|
||||
<xs:annotation>
|
||||
<xs:documentation>The ISP represents one or more Imbalance Settlement Periods and is used by Prognosis and Flex-related messages.</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:element name="ISP" type="D-PrognosisISPType" maxOccurs="unbounded"/>
|
||||
</xs:sequence>
|
||||
<xs:attribute name="Revision" type="xs:long" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>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</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
<!-- -->
|
||||
<!-- D-Prognisis ISP Type -->
|
||||
<!-- -->
|
||||
<xs:complexType name="D-PrognosisISPType">
|
||||
<xs:attribute name="Power" type="xs:integer" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>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.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="Start" type="xs:integer" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Number of the first ISPs this element refers to. The first ISP of a day has number 1.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="Duration" type="xs:integer" use="optional" default="1">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The number of the ISPs this element represents. Optional, default value is 1.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:complexType>
|
||||
<!-- -->
|
||||
<!-- Flex Order Status Type -->
|
||||
<!-- -->
|
||||
<xs:complexType name="FlexOrderStatusType">
|
||||
<xs:attribute name="FlexOrderMessageID" type="UUIDType" use="required"/>
|
||||
<xs:attribute name="IsValidated" type="xs:boolean" use="required"/>
|
||||
</xs:complexType>
|
||||
<!-- -->
|
||||
<!-- D-Prognosis Response Type -->
|
||||
<!-- -->
|
||||
<xs:complexType name="D-PrognosisResponseType">
|
||||
<xs:complexContent>
|
||||
<xs:extension base="PayloadMessageResponseType">
|
||||
<xs:sequence>
|
||||
<xs:element name="FlexOrderStatus" type="FlexOrderStatusType" minOccurs="0" maxOccurs="unbounded"/>
|
||||
</xs:sequence>
|
||||
<xs:attribute name="D-PrognosisMessageID" type="UUIDType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>MessageID of the D-Prognosis message this request is based on.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
<!-- -->
|
||||
<!-- Flex Reservation Update Type -->
|
||||
<!-- -->
|
||||
<xs:complexType name="FlexReservationUpdateType">
|
||||
<xs:complexContent>
|
||||
<xs:extension base="FlexMessageType">
|
||||
<xs:sequence>
|
||||
<xs:annotation>
|
||||
<xs:documentation>The ISP represents one or more Imbalance Settlement Periods and is used by Prognosis and Flex-related messages.</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:element name="ISP" type="FlexReservationUpdateISPType" maxOccurs="unbounded"/>
|
||||
</xs:sequence>
|
||||
<xs:attribute name="ContractID" type="xs:string" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Reference to the bilateral contract in question.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="Reference" type="xs:string" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Message reference, assigned by the DSO originating the FlexReservationUpdate.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
<!-- -->
|
||||
<!-- Flex Reservation Update ISP Type -->
|
||||
<!-- -->
|
||||
<xs:complexType name="FlexReservationUpdateISPType">
|
||||
<xs:attribute name="Power" type="xs:integer" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Remaining reserved power specified for this ISP in Watts.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="Start" type="xs:positiveInteger" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Number of the first ISPs this element refers to. The first ISP of a day has number 1.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="Duration" type="xs:positiveInteger" use="optional" default="1">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The number of the ISPs this element represents. Optional, default value is 1.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:complexType>
|
||||
<!-- -->
|
||||
<!-- Flex Reservation Update Response Type -->
|
||||
<!-- -->
|
||||
<xs:complexType name="FlexReservationUpdateResponseType">
|
||||
<xs:complexContent>
|
||||
<xs:extension base="PayloadMessageResponseType">
|
||||
<xs:attribute name="FlexReservationUpdateMessageID" type="UUIDType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>MessageID of the FlexReservationUpdate message this request is based on.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
<!-- -->
|
||||
<!-- Flex Request Type -->
|
||||
<!-- -->
|
||||
<xs:complexType name="FlexRequestType">
|
||||
<xs:complexContent>
|
||||
<xs:extension base="FlexMessageType">
|
||||
<xs:sequence>
|
||||
<xs:annotation>
|
||||
<xs:documentation>The ISP represents one or more Imbalance Settlement Periods and is used by Prognosis and Flex-related messages.</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:element name="ISP" type="FlexRequestISPType" maxOccurs="unbounded"/>
|
||||
</xs:sequence>
|
||||
<xs:attribute name="Revision" type="xs:long" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Revision of this message, a sequence number that must be incremented each time a new revision of a
|
||||
FlexRequest message is sent.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="ExpirationDateTime" type="xs:dateTime" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>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.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="ContractID" type="xs:string" use="optional">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Reference to the concerning contract, if applicable. The contract may be either bilateral or commoditized market contract. Each contract may specify multiple service-types.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="ServiceType" type="xs:string" use="optional">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Service type for this request, the service type determines response characteristics such as latency or asset participation type.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
<!-- -->
|
||||
<!-- Flex Requests ISP Type -->
|
||||
<!-- -->
|
||||
<xs:complexType name="FlexRequestISPType">
|
||||
<xs:attribute name="Disposition" type="AvailableRequestedType"/>
|
||||
<xs:attribute name="MinPower" type="xs:integer" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>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.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="MaxPower" type="xs:integer" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>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.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="Start" type="xs:positiveInteger" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Number of the first ISPs this element refers to. The first ISP of a day has number 1.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="Duration" type="xs:positiveInteger" use="optional" default="1">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The number of the ISPs this element represents. Optional, default value is 1.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:complexType>
|
||||
<!-- -->
|
||||
<!-- Flex Request Response Type -->
|
||||
<!-- -->
|
||||
<xs:complexType name="FlexRequestResponseType">
|
||||
<xs:complexContent>
|
||||
<xs:extension base="PayloadMessageResponseType">
|
||||
<xs:attribute name="FlexRequestMessageID" type="UUIDType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>MessageID of the FlexRequest message this request is based on.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
<!-- -->
|
||||
<!-- Flex Offer Type -->
|
||||
<!-- -->
|
||||
<xs:complexType name="FlexOfferType">
|
||||
<xs:complexContent>
|
||||
<xs:extension base="FlexMessageType">
|
||||
<xs:sequence>
|
||||
<xs:annotation>
|
||||
<xs:documentation>If the DSO does not support mutually exclusive offers it will reject FlexOffers that contain more than one OfferOption.</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:element name="OfferOption" type="FlexOfferOptionType" minOccurs="1" maxOccurs="unbounded"/>
|
||||
</xs:sequence>
|
||||
<xs:attribute name="ExpirationDateTime" type="xs:dateTime" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>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.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="Unsolicited" type="xs:boolean" use="optional">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Indicates whether this FlexOffer is intended to be unsolicited (i.e. without a preceding FlexRequest).</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="FlexRequestMessageID" type="UUIDType" use="optional">
|
||||
<xs:annotation>
|
||||
<xs:documentation>MessageID of the FlexRequest message this request is based on. Mandatory if Unsolicited=false.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="ContractID" type="xs:string" use="optional">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Reference to the concerning contract, if applicable. The contract may be either bilateral or commoditized market contract.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="D-PrognosisMessageID" type="UUIDType" use="optional">
|
||||
<xs:annotation>
|
||||
<xs:documentation>MessageID of the D-Prognosis this request is based on, if it has been agreed that the baseline is based on D-prognoses. </xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="BaselineReference" type="xs:string" use="optional">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Identification of the baseline prognosis, if another baseline methodology is used than based on D-prognoses</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="Currency" type="ISO4217CurrencyType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>ISO 4217 code indicating the currency that applies to the price of the FlexOffer.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
<!-- -->
|
||||
<!-- Flex Offer Option Type -->
|
||||
<!-- -->
|
||||
<xs:complexType name="FlexOfferOptionType">
|
||||
<xs:sequence>
|
||||
<xs:annotation>
|
||||
<xs:documentation>The ISP represents one or more Imbalance Settlement Periods and is used by Prognosis and Flex-related messages.</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:element name="ISP" type="FlexOfferOptionISPType" maxOccurs="unbounded"/>
|
||||
</xs:sequence>
|
||||
<xs:attribute name="OptionReference" type="xs:string" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The identification of this option.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="Price" type="CurrencyAmountType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The asking price for the flexibility offered in this option.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="MinActivationFactor" type="ActivationFactorType" default="1.00">
|
||||
<xs:annotation>
|
||||
<xs:documentation>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. </xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:complexType>
|
||||
<!-- -->
|
||||
<!-- Flex Offer Option ISP Type -->
|
||||
<!-- -->
|
||||
<xs:complexType name="FlexOfferOptionISPType">
|
||||
<xs:attribute name="Power" type="xs:integer" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>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.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="Start" type="xs:positiveInteger" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Number of the first ISPs this element refers to. The first ISP of a day has number 1.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="Duration" type="xs:positiveInteger" use="optional" default="1">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The number of the ISPs this element represents. Optional, default value is 1.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:complexType>
|
||||
<!--
|
||||
Flex Offer Response Type
|
||||
-->
|
||||
<xs:complexType name="FlexOfferResponseType">
|
||||
<xs:complexContent>
|
||||
<xs:extension base="PayloadMessageResponseType">
|
||||
<xs:attribute name="FlexOfferMessageID" type="UUIDType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>MessageID of the FlexOffer message this request is based on.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
<!--
|
||||
Flex Offer Revocation Type
|
||||
-->
|
||||
<xs:complexType name="FlexOfferRevocationType">
|
||||
<xs:complexContent>
|
||||
<xs:extension base="PayloadMessageType">
|
||||
<xs:attribute name="FlexOfferMessageID" type="UUIDType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>MessageID of the FlexOffer message that is being revoked: this FlexOffer must have been accepted previously.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
<!--
|
||||
Flex Offer Revocation Response Type
|
||||
-->
|
||||
<xs:complexType name="FlexOfferRevocationResponseType">
|
||||
<xs:complexContent>
|
||||
<xs:extension base="PayloadMessageResponseType">
|
||||
<xs:attribute name="FlexOfferRevocationMessageID" type="UUIDType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>MessageID of the FlexOfferRevocation message this request is based on.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
<!--
|
||||
Flex Order Type
|
||||
-->
|
||||
<xs:complexType name="FlexOrderType">
|
||||
<xs:complexContent>
|
||||
<xs:extension base="FlexMessageType">
|
||||
<xs:sequence>
|
||||
<xs:annotation>
|
||||
<xs:documentation>The ISP represents one or more Imbalance Settlement Periods and is used by Prognosis and Flex-related messages.</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:element name="ISP" type="FlexOrderISPType" maxOccurs="unbounded"/>
|
||||
</xs:sequence>
|
||||
<xs:attribute name="Unsolicited" type="xs:boolean" use="optional">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Indicates whether this FlexOrder is intended to be unsolicited (i.e. without a preceding FlexOffer).</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="FlexOfferMessageID" type="UUIDType" use="optional">
|
||||
<xs:annotation>
|
||||
<xs:documentation>MessageID of the FlexOffer message this order is based on. Mandatory if Unsolicited=false.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="ServiceType" type="xs:string" use="optional">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Service type for this order, the service type determines response characteristics such as latency or asset participation type.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="ContractID" type="xs:string" use="optional">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Reference to the concerning bilateral contract, if applicable.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="D-PrognosisMessageID" type="UUIDType" use="optional">
|
||||
<xs:annotation>
|
||||
<xs:documentation>MessageID of the D-Prognosis this request is based on, if it has been agreed that the baseline is based on D-prognoses. </xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="BaselineReference" type="xs:string" use="optional">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Identification of the baseline prognosis, if another baseline methodology is used than based on D-prognoses</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="Price" type="CurrencyAmountType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The price for the flexibility ordered. Usually, the price should match the price of the related FlexOffer.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="Currency" type="ISO4217CurrencyType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>ISO 4217 code indicating the currency that applies to the price of the FlexOffer.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="OrderReference" type="xs:string" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Order number assigned by the DSO originating the FlexOrder. To be stored by the AGR and used in the settlement phase.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="OptionReference" type="xs:string" use="optional">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The OptionReference from the OfferOption chosen from the FlexOffer, if applicable.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="ActivationFactor" type="ActivationFactorType" default="1.00">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The activation factor for this OfferOption. The ActivationFactor must be greater than or equal to the MinActivationFactor in the OfferOption chosen from the FlexOffer.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
<!-- ISP for Flex Orders -->
|
||||
<xs:complexType name="FlexOrderISPType">
|
||||
<xs:attribute name="Power" type="xs:integer" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>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.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="Start" type="xs:positiveInteger" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Number of the first ISPs this element refers to. The first ISP of a day has number 1.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="Duration" type="xs:positiveInteger" use="optional" default="1">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The number of the ISPs this element represents. Optional, default value is 1.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:complexType>
|
||||
<!-- Flex Order Response Type -->
|
||||
<xs:complexType name="FlexOrderResponseType">
|
||||
<xs:complexContent>
|
||||
<xs:extension base="PayloadMessageResponseType">
|
||||
<xs:attribute name="FlexOrderMessageID" type="UUIDType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>MessageID of the FlexOrder that has just been accepted or rejected.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
<!-- Flex Settlement Type -->
|
||||
<xs:complexType name="FlexSettlementType">
|
||||
<xs:complexContent>
|
||||
<xs:extension base="PayloadMessageResponseType">
|
||||
<xs:sequence>
|
||||
<xs:element name="FlexOrderSettlement" type="FlexOrderSettlementType" maxOccurs="unbounded"/>
|
||||
<xs:element name="ContractSettlement" type="ContractSettlementType" maxOccurs="unbounded"/>
|
||||
</xs:sequence>
|
||||
<xs:attribute name="PeriodStart" type="PeriodType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>First Period of the settlement period this message applies to.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="PeriodEnd" type="PeriodType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Last Period of the settlement period this message applies to.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="Currency" type="ISO4217CurrencyType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>ISO 4217 code indicating the currency that applies to all amounts (flex price, penalty and net settlement) in this message.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
<!-- Flex Order Settlement Type -->
|
||||
<xs:complexType name="FlexOrderSettlementType">
|
||||
<xs:sequence>
|
||||
<xs:element name="ISP" type="FlexOrderSettlementISPType" maxOccurs="unbounded"/>
|
||||
</xs:sequence>
|
||||
<xs:attribute name="OrderReference" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Order reference assigned by the DSO when originating the FlexOrder.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="Period" type="PeriodType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation/>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="ContractID" type="xs:string" use="optional">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Reference to the concerning bilateral contract, if it is linked to it</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="D-PrognosisMessageID" type="UUIDType" use="optional">
|
||||
<xs:annotation>
|
||||
<xs:documentation>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.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="BaselineReference" type="xs:string" use="optional">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Identification of the baseline prognosis, if another baseline methodology is used than based on D-prognoses.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="CongestionPoint" type="EntityAddressType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Entity Address of the Congestion Point the FlexOrder applies to.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="Price" type="CurrencyAmountType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The price accepted for supplying the ordered amount of flexibility as per the referenced FlexOrder messages.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="Penalty" type="CurrencyAmountType" use="optional" default="0">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Penalty due a non-zero PowerDeficiency</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="NetSettlement" type="CurrencyAmountType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Net settlement amount for this Period: Price minus Penalty.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:complexType>
|
||||
<!-- Flex Order Settlement ISP Type -->
|
||||
<xs:complexType name="FlexOrderSettlementISPType">
|
||||
<xs:attribute name="Start" type="xs:positiveInteger" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Number of the first ISPs this element refers to. The first ISP of a day has number 1.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="Duration" type="xs:positiveInteger" use="optional" default="1">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The number of the ISPs this element represents. Optional, default value is 1.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="BaselinePower" type="xs:integer" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Power originally forecast (as per the referenced baseline) for this ISP in Watts.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="OrderedFlexPower" type="xs:integer" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Amount of flex power ordered (as per the referenced FlexOrder message) for this ISP in Watts.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="ActualPower" type="xs:integer" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Actual amount of power for this ISP in Watts, as measured/determined by the DSO and allocated to the AGR.
|
||||
</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="DeliveredFlexPower" type="xs:integer" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Actual amount of flex power delivered for this ISP in Watts, as determined by the DSO.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="PowerDeficiency" type="xs:integer" use="optional" default="0">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Amount of flex power sold but not delivered for this ISP in Watts, as determined by the DSO.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:complexType>
|
||||
<!-- Flex Settlement Response Type -->
|
||||
<xs:complexType name="FlexSettlementResponseType">
|
||||
<xs:complexContent>
|
||||
<xs:extension base="PayloadMessageResponseType">
|
||||
<xs:sequence>
|
||||
<xs:annotation>
|
||||
<xs:documentation>The ISP represents one or more Imbalance Settlement Periods and is used by Prognosis and Flex-related messages.</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:element name="FlexOrderSettlementStatus" type="FlexOrderSettlementStatusType" maxOccurs="unbounded"/>
|
||||
</xs:sequence>
|
||||
<xs:attribute name="FlexSettlementMessageID" type="UUIDType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>MessageID of the FlexSettlement message that has just been accepted or rejected</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
<!-- Flex Order Settlement Status Type -->
|
||||
<xs:complexType name="FlexOrderSettlementStatusType">
|
||||
<xs:attribute name="OrderReference" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Order reference assigned by the DSO when originating the FlexOrder.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="Disposition" type="AcceptedDisputedType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Indication whether the AGR accepts the order settlement details provided by the DSO (and will invoice accordingly), or disputes these details.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="DisputeReason" type="xs:string" use="optional">
|
||||
<xs:annotation>
|
||||
<xs:documentation>In case the order settlement was disputed, this attribute must contain a human-readable description of the reason.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:complexType>
|
||||
<!-- Contract Settlement Type -->
|
||||
<xs:complexType name="ContractSettlementType">
|
||||
<xs:sequence>
|
||||
<xs:annotation>
|
||||
<xs:documentation/>
|
||||
</xs:annotation>
|
||||
<xs:element name="Period" type="ContractSettlementPeriodType" maxOccurs="unbounded"/>
|
||||
</xs:sequence>
|
||||
<xs:attribute name="ContractID" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Reference to the concerning bilateral contract.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:complexType>
|
||||
<!-- Contract Settlement Period Type -->
|
||||
<xs:complexType name="ContractSettlementPeriodType">
|
||||
<xs:sequence>
|
||||
<xs:element name="ISP" type="ContractSettlementISPType" maxOccurs="unbounded"/>
|
||||
</xs:sequence>
|
||||
<xs:attribute name="Period" type="PeriodType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Period the being settled.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:complexType>
|
||||
<!-- Contract Settlement ISP Type -->
|
||||
<xs:complexType name="ContractSettlementISPType">
|
||||
<xs:attribute name="Start" type="xs:positiveInteger" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Number of the first ISPs this element refers to. The first ISP of a day has number 1.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="Duration" type="xs:positiveInteger" use="optional" default="1">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The number of the ISPs this element represents. Optional, default value is 1.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="ReservedPower" type="xs:integer" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Amount of flex power that has been reserved (and not released using a FlexReservationUpdate message).</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="RequestedPower" type="xs:integer" use="optional">
|
||||
<xs:annotation>
|
||||
<xs:documentation>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.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="AvailablePower" type="xs:integer" use="optional">
|
||||
<xs:annotation>
|
||||
<xs:documentation>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 3 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.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="OfferedPower" type="xs:integer" use="optional">
|
||||
<xs:annotation>
|
||||
<xs:documentation>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 .</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="OrderedPower" type="xs:integer" use="optional">
|
||||
<xs:annotation>
|
||||
<xs:documentation>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. </xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:complexType>
|
||||
<!-- ELEMENT DEFINITIONS -->
|
||||
<xs:element name="D-Prognosis" type="D-PrognosisType"/>
|
||||
<xs:element name="D-PrognosisResponse" type="D-PrognosisResponseType"/>
|
||||
<xs:element name="FlexReservationUpdate" type="FlexReservationUpdateType"/>
|
||||
<xs:element name="FlexReservationUpdateResponse" type="FlexReservationUpdateResponseType"/>
|
||||
<xs:element name="FlexRequest" type="FlexRequestType"/>
|
||||
<xs:element name="FlexRequestResponse" type="FlexRequestResponseType"/>
|
||||
<xs:element name="FlexOffer" type="FlexOfferType"/>
|
||||
<xs:element name="FlexOfferResponse" type="FlexOfferResponseType"/>
|
||||
<xs:element name="FlexOfferRevocation" type="FlexOfferRevocationType"/>
|
||||
<xs:element name="FlexOfferRevocationResponse" type="FlexOfferRevocationResponseType"/>
|
||||
<xs:element name="FlexOrder" type="FlexOrderType"/>
|
||||
<xs:element name="FlexOrderResponse" type="FlexOrderResponseType"/>
|
||||
<xs:element name="FlexSettlement" type="FlexSettlementType"/>
|
||||
<xs:element name="FlexSettlementResponse" type="FlexSettlementResponseType"/>
|
||||
</xs:schema>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" elementFormDefault="qualified" attributeFormDefault="unqualified">
|
||||
<xs:include schemaLocation="UFTP-common.xsd"/>
|
||||
<xs:include schemaLocation="UFTP-agr-cro.xsd"/>
|
||||
<xs:include schemaLocation="UFTP-agr-dso.xsd"/>
|
||||
</xs:schema>
|
||||
@@ -0,0 +1,229 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" elementFormDefault="qualified" attributeFormDefault="unqualified">
|
||||
<!-- -->
|
||||
<!-- ShapeShifter Specification Version -->
|
||||
<!-- -->
|
||||
<xs:simpleType name="SpecVersion">
|
||||
<xs:restriction base="xs:string">
|
||||
<!-- Basic SemVer pattern match, i.e. 3.1.15 -->
|
||||
<xs:pattern value="(\d+\.\d+\.\d+)"/>
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
<!-- -->
|
||||
<!-- UUID Type -->
|
||||
<!-- -->
|
||||
<xs:simpleType name="UUIDType">
|
||||
<xs:restriction base="xs:string">
|
||||
<xs:pattern value="[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}"/>
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
<!-- -->
|
||||
<!-- Entity Address Type -->
|
||||
<!-- -->
|
||||
<xs:simpleType name="EntityAddressType">
|
||||
<xs:restriction base="xs:string">
|
||||
<xs:pattern value="(ea1\.[0-9]{4}-[0-9]{2}\..{1,244}:.{1,244}|ean\.[0-9]{12,34})"/>
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
<!-- -->
|
||||
<!-- Internet Domain Type -->
|
||||
<!-- -->
|
||||
<xs:simpleType name="InternetDomainType">
|
||||
<xs:restriction base="xs:string">
|
||||
<xs:pattern value="([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}"/>
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
<!-- -->
|
||||
<!-- ISO-4217 Currency Type -->
|
||||
<!-- -->
|
||||
<xs:simpleType name="ISO4217CurrencyType">
|
||||
<xs:restriction base="xs:string">
|
||||
<xs:pattern value="[A-Z]{3}"/>
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
<!-- -->
|
||||
<!-- Currency Amount Type -->
|
||||
<!-- -->
|
||||
<xs:simpleType name="CurrencyAmountType">
|
||||
<xs:restriction base="xs:decimal">
|
||||
<xs:fractionDigits value="4"/>
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
<!-- -->
|
||||
<!-- Time Zone Name Type -->
|
||||
<!-- -->
|
||||
<xs:simpleType name="TimeZoneNameType">
|
||||
<xs:restriction base="xs:string">
|
||||
<xs:pattern value="(Africa|America|Australia|Europe|Pacific)/[a-zA-Z0-9_/]{3,}"/>
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
<!-- -->
|
||||
<!-- Period Type -->
|
||||
<!-- -->
|
||||
<xs:simpleType name="PeriodType">
|
||||
<xs:restriction base="xs:date"/>
|
||||
</xs:simpleType>
|
||||
<!-- -->
|
||||
<!-- Time Zone Type -->
|
||||
<!-- -->
|
||||
<xs:simpleType name="TimeZoneType">
|
||||
<xs:restriction base="xs:string">
|
||||
<xs:pattern value="(Africa|America|Australia|Europe|Pacific)/[a-zA-Z0-9_/]{3,}"/>
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
<!-- -->
|
||||
<!-- Activation Factor Type -->
|
||||
<!-- -->
|
||||
<xs:simpleType name="ActivationFactorType">
|
||||
<xs:restriction base="xs:decimal">
|
||||
<xs:fractionDigits value="2"/>
|
||||
<xs:minInclusive value="0.01"/>
|
||||
<xs:maxInclusive value="1.00"/>
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
<!-- -->
|
||||
<!-- USER Role Type -->
|
||||
<!-- -->
|
||||
<xs:simpleType name="USEF-RoleType">
|
||||
<xs:restriction base="xs:string">
|
||||
<xs:enumeration value="AGR"/>
|
||||
<xs:enumeration value="CRO"/>
|
||||
<xs:enumeration value="DSO"/>
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
<!-- -->
|
||||
<!-- Redispatch by Type -->
|
||||
<!-- -->
|
||||
<xs:simpleType name="RedispatchByType">
|
||||
<xs:restriction base="xs:string">
|
||||
<xs:enumeration value="AGR"/>
|
||||
<xs:enumeration value="DSO"/>
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
<!-- -->
|
||||
<!-- Accepted or Rejected Type -->
|
||||
<!-- -->
|
||||
<xs:simpleType name="AcceptedRejectedType">
|
||||
<xs:restriction base="xs:string">
|
||||
<xs:enumeration value="Accepted"/>
|
||||
<xs:enumeration value="Rejected"/>
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
<!-- -->
|
||||
<!-- Available or Requested Type -->
|
||||
<!-- -->
|
||||
<xs:simpleType name="AvailableRequestedType">
|
||||
<xs:restriction base="xs:string">
|
||||
<xs:enumeration value="Available"/>
|
||||
<xs:enumeration value="Requested"/>
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
<!-- -->
|
||||
<!-- Accepted or Disputed Type -->
|
||||
<!-- -->
|
||||
<xs:simpleType name="AcceptedDisputedType">
|
||||
<xs:restriction base="xs:string">
|
||||
<xs:enumeration value="Accepted"/>
|
||||
<xs:enumeration value="Disputed"/>
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
<!-- -->
|
||||
<!-- Signed Message Type -->
|
||||
<!-- -->
|
||||
<xs:complexType name="SignedMessageType">
|
||||
<xs:annotation>
|
||||
<xs:documentation>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.</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:attribute name="SenderDomain" type="InternetDomainType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>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.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="SenderRole" type="USEF-RoleType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>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.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="Body" type="xs:base64Binary" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>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.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:complexType>
|
||||
<!-- -->
|
||||
<!-- Payload Message Type -->
|
||||
<!-- -->
|
||||
<xs:complexType name="PayloadMessageType" abstract="true">
|
||||
<xs:attribute name="Version" type="SpecVersion" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Version of the Shapeshifter specification used by the USEF participant sending this message.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="SenderDomain" type="InternetDomainType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>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.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="RecipientDomain" type="InternetDomainType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>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.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="TimeStamp" type="xs:dateTime" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Date and time this message was created, including the time zone (ISO 8601 formatted as per http://www.w3.org/TR/NOTE-datetime).</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="MessageID" type="UUIDType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Unique identifier (UUID/GUID as per IETF RFC 4122) for this message, to be generated when composing each message.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="ConversationID" type="UUIDType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>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.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:complexType>
|
||||
<!-- -->
|
||||
<!-- Payload Message Response Type -->
|
||||
<!-- -->
|
||||
<xs:complexType name="PayloadMessageResponseType" abstract="true">
|
||||
<xs:complexContent>
|
||||
<xs:extension base="PayloadMessageType">
|
||||
<xs:attribute name="Result" type="AcceptedRejectedType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Indication whether the query was executed successfully or failed.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="RejectionReason" type="xs:string" use="optional">
|
||||
<xs:annotation>
|
||||
<xs:documentation>In case the query failed, this attribute must contain a human-readable description of the failure reason.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
<!-- -->
|
||||
<!-- Test Message Type -->
|
||||
<!-- -->
|
||||
<xs:complexType name="TestMessageType">
|
||||
<xs:complexContent>
|
||||
<xs:extension base="PayloadMessageType"/>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
<!-- -->
|
||||
<!-- Test Message Response Type -->
|
||||
<!-- -->
|
||||
<xs:complexType name="TestMessageResponseType">
|
||||
<xs:complexContent>
|
||||
<xs:extension base="PayloadMessageType"/>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
<!-- -->
|
||||
<!-- ELEMENT DEFINITIONS -->
|
||||
<!-- -->
|
||||
<xs:element name="SignedMessage" type="SignedMessageType"/>
|
||||
<xs:element name="TestMessage" type="TestMessageType"/>
|
||||
<xs:element name="TestMessageResponse" type="TestMessageResponseType"/>
|
||||
</xs:schema>
|
||||
@@ -0,0 +1,188 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" elementFormDefault="qualified" attributeFormDefault="unqualified">
|
||||
<xs:include schemaLocation="UFTP-common.xsd"/>
|
||||
<!-- -->
|
||||
<!-- DSO Portfolio Update Type -->
|
||||
<!-- -->
|
||||
<xs:complexType name="DSOPortfolioUpdateType">
|
||||
<xs:complexContent>
|
||||
<xs:extension base="PayloadMessageType">
|
||||
<xs:sequence>
|
||||
<xs:element name="CongestionPoint" type="DSOPortfolioUpdateCongestionPoint" minOccurs="1" maxOccurs="unbounded"/>
|
||||
</xs:sequence>
|
||||
<xs:attribute name="TimeZone" type="TimeZoneNameType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>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.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
<!-- -->
|
||||
<!-- DSO Portfolio Update Congestion Point Type-->
|
||||
<!-- -->
|
||||
<xs:complexType name="DSOPortfolioUpdateCongestionPoint">
|
||||
<xs:annotation>
|
||||
<xs:documentation>A congestion point that the DSO wants the CRO to update</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:sequence>
|
||||
<xs:element name="Connection" type="DSOPortfolioUpdateConnectionType" minOccurs="1" maxOccurs="unbounded"/>
|
||||
</xs:sequence>
|
||||
<xs:attribute name="EntityAddress" type="EntityAddressType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>EntityAddress of the Connection.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="StartPeriod" type="PeriodType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The first Period that the Connection is part of this CongestionPoint.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="EndPeriod" type="PeriodType" use="optional">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The last Period that the Connection is part of this CongestionPoint. </xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="MutexOffersSupported" type="xs:boolean" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Indicates whether the DSO accepts mutual exclusive FlexOffers on this CongestionPoint.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="DayAheadRedispatchBy" type="RedispatchByType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Indicates which party is responsible for day-ahead redispatch.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="IntradayRedispatchBy" type="RedispatchByType" use="optional">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Indicates which party is responsible for intraday ahead redispatch, AGR or DSO. If not specified, there will be no intraday trading on this CongestionPoint.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:complexType>
|
||||
<!-- -->
|
||||
<!-- DSO Portfolio Update Connection Type -->
|
||||
<!-- -->
|
||||
<xs:complexType name="DSOPortfolioUpdateConnectionType">
|
||||
<xs:annotation>
|
||||
<xs:documentation>A connection that the DSO wants the CRO to update</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:attribute name="EntityAddress" type="EntityAddressType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>EntityAddress of the Connection.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="StartPeriod" type="PeriodType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The first Period that the Connection is part of this CongestionPoint.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="EndPeriod" type="PeriodType" use="optional">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The last Period that the Connection is part of this CongestionPoint. </xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:complexType>
|
||||
<!-- -->
|
||||
<!-- DSO Portfolio Update Response Type -->
|
||||
<!-- -->
|
||||
<xs:complexType name="DSOPortfolioUpdateResponseType">
|
||||
<xs:complexContent>
|
||||
<xs:extension base="PayloadMessageResponseType">
|
||||
<xs:attribute name="DSOPortfolioUpdateResponseMessageID" type="UUIDType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>MessageID of the DSOPortfolioUpdateResponse that has just been accepted or rejected</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
<!-- -->
|
||||
<!-- DSO Portfolio Query Type -->
|
||||
<!-- -->
|
||||
<xs:complexType name="DSOPortfolioQueryType">
|
||||
<xs:complexContent>
|
||||
<xs:extension base="PayloadMessageType">
|
||||
<xs:attribute name="TimeZone" type="TimeZoneNameType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>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.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="Period" type="PeriodType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The Period for which the AGR requests the portfolio information.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="EntityAddress" type="EntityAddressType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>EntityAddress of the CongestionPoint</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
<!-- -->
|
||||
<!-- DSO Portfolio Query Response Type -->
|
||||
<!-- -->
|
||||
<xs:complexType name="DSOPortfolioQueryResponseType">
|
||||
<xs:complexContent>
|
||||
<xs:extension base="PayloadMessageResponseType">
|
||||
<xs:sequence>
|
||||
<xs:element name="CongestionPoint" type="DSOPortfolioQueryCongestionPointType" minOccurs="0" maxOccurs="1"/>
|
||||
</xs:sequence>
|
||||
<xs:attribute name="DSOPortfolioQueryMessageID" type="UUIDType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>MessageID of the AGRPortfolioQuery that has just been accepted or rejected</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="TimeZone" type="TimeZoneNameType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>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.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="Period" type="PeriodType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The Period for which the AGR requests the portfolio information.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
<!-- -->
|
||||
<!-- DSO Portfolio Update Congestion Point Type-->
|
||||
<!-- -->
|
||||
<xs:complexType name="DSOPortfolioQueryCongestionPointType">
|
||||
<xs:sequence>
|
||||
<xs:element name="Connection" type="DSOPortfolioQueryConnectionType" minOccurs="1" maxOccurs="unbounded"/>
|
||||
</xs:sequence>
|
||||
<xs:attribute name="EntityAddress" type="EntityAddressType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>EntityAddress of the Connection.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:complexType>
|
||||
<!-- -->
|
||||
<!-- DSO Portfolio Update Connection Type-->
|
||||
<!-- -->
|
||||
<xs:complexType name="DSOPortfolioQueryConnectionType">
|
||||
<xs:annotation>
|
||||
<xs:documentation>A Connection that is part of the congestion point.</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:attribute name="EntityAddress" type="EntityAddressType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>EntityAddress of the Connection.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="AGR-Domain" type="InternetDomainType" use="optional">
|
||||
<xs:annotation>
|
||||
<xs:documentation>The internet domain of the AGR that represents the prosumer connected on this Connection, if applicable.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:complexType>
|
||||
<!-- -->
|
||||
<!-- ELEMENT DEFINITIONS -->
|
||||
<!-- -->
|
||||
<xs:element name="DSOPortfolioUpdate" type="DSOPortfolioUpdateType"/>
|
||||
<xs:element name="DSOPortfolioUpdateResponse" type="DSOPortfolioUpdateResponseType"/>
|
||||
<xs:element name="DSOPortfolioQuery" type="DSOPortfolioQueryType"/>
|
||||
<xs:element name="DSOPortfolioQueryResponse" type="DSOPortfolioQueryResponseType"/>
|
||||
</xs:schema>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" elementFormDefault="qualified" attributeFormDefault="unqualified">
|
||||
<xs:include schemaLocation="UFTP-common.xsd"/>
|
||||
<xs:include schemaLocation="UFTP-agr-cro.xsd"/>
|
||||
<xs:include schemaLocation="UFTP-cro-dso.xsd"/>
|
||||
</xs:schema>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" elementFormDefault="qualified" attributeFormDefault="unqualified">
|
||||
<xs:include schemaLocation="UFTP-common.xsd"/>
|
||||
<xs:include schemaLocation="UFTP-agr-dso.xsd"/>
|
||||
<xs:include schemaLocation="UFTP-cro-dso.xsd"/>
|
||||
</xs:schema>
|
||||
@@ -0,0 +1,157 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" elementFormDefault="qualified" attributeFormDefault="unqualified">
|
||||
|
||||
<xs:include schemaLocation="UFTP-common.xsd"/>
|
||||
|
||||
<!-- -->
|
||||
<!-- EAN Type -->
|
||||
<!-- -->
|
||||
<xs:simpleType name="EANType">
|
||||
<xs:restriction base="xs:string">
|
||||
<xs:pattern value="[Ee][0-9]{16}"/>
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
<!-- -->
|
||||
<!-- -->
|
||||
<!-- Metering Unit Type -->
|
||||
<!-- -->
|
||||
<xs:simpleType name="MeteringUnitType">
|
||||
<xs:restriction base="xs:string">
|
||||
<xs:enumeration value="kW">
|
||||
<xs:annotation>
|
||||
<xs:documentation>kW must be used with Power profile values.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:enumeration>
|
||||
<xs:enumeration value="kWh">
|
||||
<xs:annotation>
|
||||
<xs:documentation>kWh must be used with energy profile values (ImportEnergy,ExportEnergy,ImportMeterReading,ExportMeterReading).</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:enumeration>
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
<!-- -->
|
||||
<!-- Metering Profile Enum -->
|
||||
<!-- -->
|
||||
<xs:simpleType name="MeteringProfileEnum">
|
||||
<xs:restriction base="xs:string">
|
||||
<xs:enumeration value="Power">
|
||||
<xs:annotation>
|
||||
<xs:documentation>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.
|
||||
</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:enumeration>
|
||||
<xs:enumeration value="ImportEnergy">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Imported active energy, consumed during the ISP</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:enumeration>
|
||||
<xs:enumeration value="ExportEnergy">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Exported active energy, generated during the ISP</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:enumeration>
|
||||
<xs:enumeration value="ImportMeterReading">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Cumulative metered imported active energy reading, at the end of the ISP</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:enumeration>
|
||||
<xs:enumeration value="ExportMeterReading">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Cumulative metered exported active energy reading, at the end of the ISP</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:enumeration>
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
<!-- -->
|
||||
<!-- Metering Message Type -->
|
||||
<!-- -->
|
||||
<xs:complexType name="MeteringMessageType">
|
||||
<xs:complexContent>
|
||||
<xs:extension base="PayloadMessageType">
|
||||
<xs:sequence>
|
||||
<xs:annotation>
|
||||
<xs:documentation>The message must contain one or more profiles with metering, energy or price values.</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:element name="Profile" type="MeteringProfileType" maxOccurs="unbounded"/>
|
||||
</xs:sequence>
|
||||
<xs:attribute name="Revision" type="xs:long" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Revision of this message. A sequence number that must be incremented each time a new revision of a metering message is sent.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="ISP-Duration" type="xs:duration" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>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.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="TimeZone" type="TimeZoneNameType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>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.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="Currency" type="ISO4217CurrencyType" use="optional">
|
||||
<xs:annotation>
|
||||
<xs:documentation>ISO 4217 code indicating the currency that applies to the price of the Tariff Rates. Only required if ImportTariff or ExportTariff profiles are included.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="Period" type="PeriodType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Day (in yyyy-mm-dd format) the ISPs referenced in this Metering message belong to.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="EAN" type="EANType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>EAN of the meter the message applies to.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
<!-- -->
|
||||
<!-- Metering Profile Type -->
|
||||
<!-- -->
|
||||
<xs:complexType name="MeteringProfileType">
|
||||
<xs:annotation>
|
||||
<xs:documentation>A profile carries a sequence of ISPs with a defined type of metering data</xs:documentation>
|
||||
</xs:annotation>
|
||||
<xs:sequence>
|
||||
<xs:element name="ISP" type="MeteringISPType" maxOccurs="unbounded"/>
|
||||
</xs:sequence>
|
||||
<xs:attribute name="ProfileType" type="MeteringProfileEnum" use="required" />
|
||||
<xs:attribute name="Unit" type="MeteringUnitType" use="required" />
|
||||
</xs:complexType>
|
||||
<!-- -->
|
||||
<!-- Metering ISP Type -->
|
||||
<!-- -->
|
||||
<xs:complexType name="MeteringISPType">
|
||||
<xs:attribute name="Start" type="xs:integer" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Number of the ISP this element refers to. The first ISP of a day has number 1.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="Value" type="xs:decimal" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Metering, energy or price value at the end of this ISP, in the designated profile units.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:complexType>
|
||||
<!-- -->
|
||||
<!-- Metering Response Type -->
|
||||
<!-- -->
|
||||
<xs:complexType name="MeteringResponseType">
|
||||
<xs:complexContent>
|
||||
<xs:extension base="PayloadMessageResponseType">
|
||||
<xs:attribute name="MeteringMessageID" type="UUIDType" use="required">
|
||||
<xs:annotation>
|
||||
<xs:documentation>MessageID of the Metering message this response is based on.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:extension>
|
||||
</xs:complexContent>
|
||||
</xs:complexType>
|
||||
<!-- ELEMENT DEFINITIONS -->
|
||||
<xs:element name="Metering" type="MeteringMessageType"/>
|
||||
<xs:element name="MeteringResponse" type="MeteringResponseType"/>
|
||||
</xs:schema>
|
||||
29
tools/shapeshifter-library-python-main/test/test_cli.py
Normal file
29
tools/shapeshifter-library-python-main/test/test_cli.py
Normal file
@@ -0,0 +1,29 @@
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
|
||||
# These tests will not work in a pipeline due to local requirements, and are thus disabled for now
|
||||
def xtest_generate_keypair():
|
||||
output = subprocess.check_output(["shapeshifter-keypair"])
|
||||
assert re.match(rb"""------------------------------------------------------------------
|
||||
Private key \(base64\): [0-9A-Za-z+/=]{88}
|
||||
Public key \(base64\): [0-9A-Za-z+/=]{44}
|
||||
------------------------------------------------------------------""", output.replace(b"\r\n", b"\n"))
|
||||
|
||||
def xtest_lookup():
|
||||
output = subprocess.check_output(["shapeshifter-lookup", "-d", "enexis.dev", "-r", "dso"])
|
||||
assert re.match(rb"""-----------------------------------------------------------------
|
||||
Shapeshifer version: [0-9]+\.[0-9]+\.[0-9]+
|
||||
Endpoint URL: https://shapeshifter-dso.enexis.dev/shapeshifter/api/v3/message
|
||||
Signing key: [0-9A-Za-z+/=]{44}
|
||||
Decryption Key: [0-9A-Za-z+/=]{44}
|
||||
-----------------------------------------------------------------""", output.replace(b"\r\n", b"\n"))
|
||||
|
||||
def xtest_lookup_invalid_domain():
|
||||
output = subprocess.check_output(["shapeshifter-lookup", "-d", "example.com", "-r", "dso"])
|
||||
assert output.replace(b"\r\n", b"\n").decode() == """-----------------------------------------------------------------
|
||||
Could not retrieve version at _usef.example.com: DNS name not found.
|
||||
Could not retrieve endpoint at _http._dso._usef.example.com: DNS name not found.
|
||||
Could not retrieve public keys at _dso._usef.example.com: DNS name not found.
|
||||
-----------------------------------------------------------------
|
||||
"""
|
||||
@@ -0,0 +1,15 @@
|
||||
|
||||
import pytest
|
||||
from nacl.bindings import crypto_sign_keypair
|
||||
|
||||
from .helpers.services import DummyAgrService, DummyDsoService
|
||||
|
||||
fake_keypair = crypto_sign_keypair()
|
||||
|
||||
|
||||
def test_send_non_payload_message():
|
||||
service = DummyAgrService()
|
||||
dso_service = DummyDsoService()
|
||||
client = service.dso_client(dso_service.sender_domain)
|
||||
with pytest.raises(TypeError):
|
||||
client._send_message("Hello there")
|
||||
@@ -0,0 +1,73 @@
|
||||
from concurrent.futures import Future
|
||||
from functools import partial
|
||||
from time import sleep
|
||||
|
||||
from shapeshifter_uftp.uftp import AgrPortfolioUpdate
|
||||
|
||||
from .helpers.messages import messages_by_type
|
||||
from .helpers.services import DummyAgrService, DummyCroService
|
||||
|
||||
|
||||
def callback(response, future):
|
||||
future.set_result(response)
|
||||
|
||||
def test_client_with_workers():
|
||||
with DummyAgrService() as agr_service:
|
||||
with DummyCroService() as cro_service:
|
||||
with agr_service.cro_client(cro_service.sender_domain) as client:
|
||||
message = messages_by_type[AgrPortfolioUpdate]
|
||||
main_future = Future()
|
||||
client._queue_message(message, partial(callback, future=main_future))
|
||||
result = main_future.result()
|
||||
assert result is None
|
||||
|
||||
def test_client_with_workers_retries():
|
||||
with DummyAgrService() as agr_service:
|
||||
with DummyCroService() as cro_service:
|
||||
with agr_service.cro_client(cro_service.sender_domain) as client:
|
||||
client.exponential_retry_base = 1.1
|
||||
client.exponential_retry_factor = 0.1
|
||||
old_endpoint_url = client.recipient_endpoint
|
||||
client.recipient_endpoint = "http://example.com"
|
||||
message = messages_by_type[AgrPortfolioUpdate]
|
||||
main_future = Future()
|
||||
client._queue_message(message, partial(callback, future=main_future))
|
||||
|
||||
sleep(1.0)
|
||||
|
||||
print("Left sleep")
|
||||
|
||||
client.recipient_endpoint = old_endpoint_url
|
||||
assert main_future.result() is None
|
||||
|
||||
def test_client_with_workers_retries_never_finishes():
|
||||
with DummyAgrService() as agr_service:
|
||||
with DummyCroService() as cro_service:
|
||||
with agr_service.cro_client(cro_service.sender_domain) as client:
|
||||
client.num_delivery_attempts = 2
|
||||
client.exponential_retry_base = 1.1
|
||||
client.exponential_retry_factor = 0.1
|
||||
old_endpoint_url = client.recipient_endpoint
|
||||
client.recipient_endpoint = "http://example.com"
|
||||
message = messages_by_type[AgrPortfolioUpdate]
|
||||
main_future = Future()
|
||||
client._queue_message(message, partial(callback, future=main_future))
|
||||
|
||||
sleep(2.0)
|
||||
|
||||
client.recipient_endpoint = old_endpoint_url
|
||||
assert main_future.done() is False
|
||||
|
||||
|
||||
def test_client_with_workers_error_in_callback():
|
||||
def faulty_callback(response, future):
|
||||
future.set_result(response)
|
||||
raise ValueError("BOOM")
|
||||
|
||||
with DummyAgrService() as agr_service:
|
||||
with DummyCroService() as cro_service:
|
||||
with agr_service.cro_client(cro_service.sender_domain) as client:
|
||||
message = messages_by_type[AgrPortfolioUpdate]
|
||||
main_future = Future()
|
||||
client._queue_message(message, partial(faulty_callback, future=main_future))
|
||||
assert main_future.result() is None
|
||||
@@ -0,0 +1,29 @@
|
||||
import pytest
|
||||
|
||||
from shapeshifter_uftp import (
|
||||
ShapeshifterAgrCroClient,
|
||||
ShapeshifterAgrDsoClient,
|
||||
ShapeshifterCroAgrClient,
|
||||
ShapeshifterCroDsoClient,
|
||||
ShapeshifterDsoAgrClient,
|
||||
ShapeshifterDsoCroClient,
|
||||
)
|
||||
|
||||
from .helpers.services import DummyAgrService, DummyCroService, DummyDsoService
|
||||
|
||||
|
||||
@pytest.mark.parametrize('service,client,expected_type',
|
||||
[(DummyAgrService, 'cro', ShapeshifterAgrCroClient),
|
||||
(DummyAgrService, 'dso', ShapeshifterAgrDsoClient),
|
||||
(DummyCroService, 'agr', ShapeshifterCroAgrClient),
|
||||
(DummyCroService, 'dso', ShapeshifterCroDsoClient),
|
||||
(DummyDsoService, 'agr', ShapeshifterDsoAgrClient),
|
||||
(DummyDsoService, 'cro', ShapeshifterDsoCroClient),
|
||||
]
|
||||
)
|
||||
def test_clients_from_service(service, client, expected_type):
|
||||
service_obj = service()
|
||||
assert hasattr(service_obj, f'{client}_client')
|
||||
|
||||
client_obj = getattr(service_obj, f'{client}_client')(recipient_domain="test.dev")
|
||||
assert isinstance(client_obj, expected_type)
|
||||
@@ -0,0 +1,72 @@
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import pytest
|
||||
|
||||
from shapeshifter_uftp.service.base_service import snake_case
|
||||
from shapeshifter_uftp.uftp import routing_map
|
||||
|
||||
from .helpers.messages import messages
|
||||
from .helpers.services import DummyAgrService, DummyCroService, DummyDsoService
|
||||
|
||||
|
||||
# These fixtures allow us to only start up the services once and use
|
||||
# them for all the parametrized test cases, which speeds up testing.
|
||||
@pytest.fixture(scope='module')
|
||||
def agr_service():
|
||||
with DummyAgrService() as service:
|
||||
yield service
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def cro_service():
|
||||
with DummyCroService() as service:
|
||||
yield service
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def dso_service():
|
||||
with DummyDsoService() as service:
|
||||
yield service
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'message',
|
||||
messages,
|
||||
ids=[message.__class__.__name__ for message in messages]
|
||||
)
|
||||
def test_communications(message, agr_service, cro_service, dso_service):
|
||||
service_map = {
|
||||
"AGR": agr_service,
|
||||
"CRO": cro_service,
|
||||
"DSO": dso_service,
|
||||
}
|
||||
|
||||
sender_role, recipient_role = routing_map[type(message)]
|
||||
sender = service_map[sender_role]
|
||||
recipient = service_map[recipient_role]
|
||||
client_method = f"{recipient.sender_role.lower()}_client"
|
||||
recipient.reset_futures(snake_case(message.__class__.__name__))
|
||||
with ThreadPoolExecutor() as executor:
|
||||
with getattr(sender, client_method)(recipient.sender_domain, version="3.1.0") as client:
|
||||
sending_method = f"send_{snake_case(message.__class__.__name__)}"
|
||||
main_future = executor.submit(getattr(client, sending_method), message)
|
||||
|
||||
assert recipient.request_futures[f"process_{snake_case(message.__class__.__name__)}"].result() == message
|
||||
assert main_future.result() is None
|
||||
|
||||
|
||||
def test_test_messages(agr_service, cro_service, dso_service):
|
||||
service_map = {
|
||||
"AGR": agr_service,
|
||||
"CRO": cro_service,
|
||||
"DSO": dso_service,
|
||||
}
|
||||
|
||||
agr_service.dso_client(dso_service.sender_domain).send_test_message()
|
||||
assert dso_service.request_futures["process_test_message"].result() is not None
|
||||
assert agr_service.request_futures["process_test_message_response"].result() is not None
|
||||
|
||||
agr_service.cro_client(cro_service.sender_domain).send_test_message()
|
||||
assert cro_service.request_futures["process_test_message"].result() is not None
|
||||
|
||||
dso_service.agr_client(agr_service.sender_domain).send_test_message()
|
||||
assert agr_service.request_futures["process_test_message"].result() is not None
|
||||
assert dso_service.request_futures["process_test_message_response"].result() is not None
|
||||
@@ -0,0 +1,55 @@
|
||||
import pytest
|
||||
|
||||
from shapeshifter_uftp.service.base_service import snake_case
|
||||
from shapeshifter_uftp.uftp import routing_map
|
||||
|
||||
from .helpers.messages import messages
|
||||
from .helpers.services import (
|
||||
DefaultResponseAgrService,
|
||||
DefaultResponseCroService,
|
||||
DefaultResponseDsoService,
|
||||
)
|
||||
|
||||
|
||||
# These fixtures allow us to only start up the services once and use
|
||||
# them for all the parametrized test cases, which speeds up testing.
|
||||
@pytest.fixture(scope='module')
|
||||
def default_agr_service():
|
||||
with DefaultResponseAgrService() as service:
|
||||
yield service
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def default_cro_service():
|
||||
with DefaultResponseCroService() as service:
|
||||
yield service
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def default_dso_service():
|
||||
with DefaultResponseDsoService() as service:
|
||||
yield service
|
||||
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'message',
|
||||
messages,
|
||||
ids=[message.__class__.__name__ for message in messages]
|
||||
)
|
||||
def test_default_responses(message, default_agr_service, default_cro_service, default_dso_service):
|
||||
service_map = {
|
||||
"AGR": default_agr_service,
|
||||
"CRO": default_cro_service,
|
||||
"DSO": default_dso_service,
|
||||
}
|
||||
|
||||
sender_role, recipient_role = routing_map[type(message)]
|
||||
sender = service_map[sender_role]
|
||||
recipient = service_map[recipient_role]
|
||||
|
||||
client_method = f"{recipient.sender_role.lower()}_client"
|
||||
sending_method = f"send_{snake_case(message.__class__.__name__)}"
|
||||
|
||||
client = getattr(sender, client_method)(recipient.sender_domain)
|
||||
response = getattr(client, sending_method)(message)
|
||||
|
||||
assert response is None
|
||||
@@ -0,0 +1,44 @@
|
||||
import pytest
|
||||
|
||||
from shapeshifter_uftp import (
|
||||
FlexMessage,
|
||||
PayloadMessage,
|
||||
PayloadMessageResponse,
|
||||
ShapeshifterAgrService,
|
||||
ShapeshifterCroService,
|
||||
ShapeshifterDsoService,
|
||||
TestMessage,
|
||||
TestMessageResponse,
|
||||
uftp,
|
||||
)
|
||||
from shapeshifter_uftp.uftp import destination_map
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"message_cls",
|
||||
[
|
||||
getattr(uftp, message_cls)
|
||||
for message_cls in dir(uftp)
|
||||
if isinstance(getattr(uftp, message_cls), type)
|
||||
and PayloadMessage in getattr(uftp, message_cls).__mro__
|
||||
and getattr(uftp, message_cls) not in (
|
||||
PayloadMessage,
|
||||
PayloadMessageResponse,
|
||||
TestMessage,
|
||||
TestMessageResponse,
|
||||
FlexMessage,
|
||||
)
|
||||
],
|
||||
)
|
||||
def test_all_messages_have_destination(message_cls):
|
||||
assert message_cls in destination_map
|
||||
|
||||
|
||||
@pytest.mark.parametrize("message_cls,destination", destination_map.items())
|
||||
def test_message_destination(message_cls, destination):
|
||||
service_map = {
|
||||
"AGR": ShapeshifterAgrService,
|
||||
"CRO": ShapeshifterCroService,
|
||||
"DSO": ShapeshifterDsoService,
|
||||
}
|
||||
assert message_cls in service_map[destination].acceptable_messages
|
||||
128
tools/shapeshifter-library-python-main/test/test_oauth.py
Normal file
128
tools/shapeshifter-library-python-main/test/test_oauth.py
Normal file
@@ -0,0 +1,128 @@
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
|
||||
from shapeshifter_uftp import FlexOffer, OAuthClient, ShapeshifterAgrDsoClient
|
||||
from shapeshifter_uftp.oauth import AuthorizationError
|
||||
|
||||
from .helpers.messages import messages_by_type
|
||||
from .helpers.services import (
|
||||
AGR_PRIVATE_KEY,
|
||||
DSO_PUBLIC_KEY,
|
||||
DummyAgrService,
|
||||
)
|
||||
|
||||
OAUTH_URL = "https://oauth.dummy.server"
|
||||
CLIENT_ID = "client-id"
|
||||
CLIENT_SECRET = "client-secret"
|
||||
ACCESS_TOKEN = "access-token"
|
||||
|
||||
@pytest.fixture
|
||||
def oauth_client(*args, **kwargs):
|
||||
return OAuthClient(
|
||||
url=OAUTH_URL,
|
||||
client_id=CLIENT_ID,
|
||||
client_secret=CLIENT_SECRET,
|
||||
)
|
||||
|
||||
@responses.activate
|
||||
def test_oauth_client(oauth_client):
|
||||
now = datetime.now().timestamp()
|
||||
responses.add(
|
||||
method=responses.POST,
|
||||
url=OAUTH_URL,
|
||||
status=200,
|
||||
json={
|
||||
"access_token": ACCESS_TOKEN,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 300
|
||||
}
|
||||
)
|
||||
|
||||
oauth_client.authenticate()
|
||||
|
||||
assert oauth_client.access_token == ACCESS_TOKEN
|
||||
assert oauth_client.access_token_type == "Bearer"
|
||||
assert now + 300 < oauth_client.access_token_expiry < now + 301
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_oauth_shapeshifter_client(oauth_client):
|
||||
responses.add(
|
||||
method=responses.POST,
|
||||
url=OAUTH_URL,
|
||||
status=200,
|
||||
json={
|
||||
"access_token": ACCESS_TOKEN,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 300
|
||||
}
|
||||
)
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"http://localhost:9003/shapeshifter/api/v3/message"
|
||||
)
|
||||
|
||||
client = ShapeshifterAgrDsoClient(
|
||||
sender_domain="agr.dev",
|
||||
signing_key=AGR_PRIVATE_KEY,
|
||||
recipient_domain="dso.dev",
|
||||
recipient_endpoint="http://localhost:9003/shapeshifter/api/v3/message",
|
||||
recipient_signing_key=DSO_PUBLIC_KEY,
|
||||
oauth_client=oauth_client
|
||||
)
|
||||
|
||||
response = client.send_flex_offer(messages_by_type[FlexOffer])
|
||||
assert response is None
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_oauth_shapeshifter_client_failed(oauth_client):
|
||||
responses.add(
|
||||
method=responses.POST,
|
||||
url=OAUTH_URL,
|
||||
status=400,
|
||||
json={
|
||||
"error": "invalid_request",
|
||||
"error_description": "Could not process"
|
||||
}
|
||||
)
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"http://localhost:9003/shapeshifter/api/v3/message"
|
||||
)
|
||||
|
||||
client = ShapeshifterAgrDsoClient(
|
||||
sender_domain="agr.dev",
|
||||
signing_key=AGR_PRIVATE_KEY,
|
||||
recipient_domain="dso.dev",
|
||||
recipient_endpoint="http://localhost:9003/shapeshifter/api/v3/message",
|
||||
recipient_signing_key=DSO_PUBLIC_KEY,
|
||||
oauth_client=oauth_client
|
||||
)
|
||||
|
||||
with pytest.raises(AuthorizationError):
|
||||
response = client.send_flex_offer(messages_by_type[FlexOffer])
|
||||
|
||||
@responses.activate
|
||||
def test_oauth_shapeshifter_service():
|
||||
def oauth_lookup_function(sender_domain, sender_role):
|
||||
return OAuthClient(
|
||||
url=OAUTH_URL,
|
||||
client_id=CLIENT_ID,
|
||||
client_secret=CLIENT_SECRET,
|
||||
)
|
||||
|
||||
service = DummyAgrService(
|
||||
oauth_lookup_function=oauth_lookup_function
|
||||
)
|
||||
|
||||
client = service.dso_client("dso.dev")
|
||||
assert isinstance(client.oauth_client, OAuthClient)
|
||||
assert client.oauth_client.url == OAUTH_URL
|
||||
assert client.oauth_client.client_id == CLIENT_ID
|
||||
assert client.oauth_client.client_secret == CLIENT_SECRET
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import pytest
|
||||
|
||||
from shapeshifter_uftp.client import client_map
|
||||
from shapeshifter_uftp.service.base_service import snake_case
|
||||
from shapeshifter_uftp.uftp import routing_map
|
||||
|
||||
from .helpers.messages import messages
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'message',
|
||||
messages,
|
||||
ids=[message.__class__.__name__ for message in messages]
|
||||
)
|
||||
def test_presence_of_client_methods(message):
|
||||
client = client_map[routing_map[message.__class__]]
|
||||
expected_method = f"send_{snake_case(message.__class__.__name__)}"
|
||||
assert hasattr(client, expected_method)
|
||||
@@ -0,0 +1,23 @@
|
||||
import itertools
|
||||
|
||||
import pytest
|
||||
|
||||
from shapeshifter_uftp import (
|
||||
ShapeshifterAgrService,
|
||||
ShapeshifterCroService,
|
||||
ShapeshifterDsoService,
|
||||
)
|
||||
from shapeshifter_uftp.service.base_service import snake_case
|
||||
|
||||
|
||||
@pytest.mark.parametrize('service,message_type,stage',
|
||||
[
|
||||
*itertools.product([ShapeshifterAgrService], ShapeshifterAgrService.acceptable_messages, ['process']),
|
||||
*itertools.product([ShapeshifterCroService], ShapeshifterCroService.acceptable_messages, ['process']),
|
||||
*itertools.product([ShapeshifterDsoService], ShapeshifterDsoService.acceptable_messages, ['process']),
|
||||
]
|
||||
)
|
||||
def test_presence_of_processing_methods(service, message_type, stage):
|
||||
processing_method = f"{stage}_{snake_case(message_type.__name__)}"
|
||||
assert hasattr(service, processing_method)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import pytest
|
||||
|
||||
from shapeshifter_uftp.transport import from_json, from_xml, to_json, to_xml
|
||||
|
||||
from .helpers.messages import messages
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"message", messages, ids=[message.__class__.__name__ for message in messages]
|
||||
)
|
||||
def test_roundtrip_json(message):
|
||||
serialized = to_json(message)
|
||||
parsed = from_json(serialized, type(message))
|
||||
assert parsed == message
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"message", messages, ids=[message.__class__.__name__ for message in messages]
|
||||
)
|
||||
def test_roundtrip_xml(message):
|
||||
serialized = to_xml(message)
|
||||
parsed = from_xml(serialized)
|
||||
assert parsed == message
|
||||
@@ -0,0 +1,69 @@
|
||||
import re
|
||||
from base64 import b64encode
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
import dns.resolver
|
||||
import pytest
|
||||
from nacl.bindings import crypto_sign, crypto_sign_keypair
|
||||
|
||||
from shapeshifter_uftp import TestMessage as UFTPTestMessage
|
||||
from shapeshifter_uftp.exceptions import InvalidSignatureException, SchemaException
|
||||
from shapeshifter_uftp.transport import get_key, seal_message, unseal_message
|
||||
|
||||
public, private = crypto_sign_keypair()
|
||||
public_base64 = b64encode(public)
|
||||
private_base64 = b64encode(private)
|
||||
|
||||
|
||||
def test_seal_unseal_message():
|
||||
msg = UFTPTestMessage(
|
||||
version="3.1.0",
|
||||
sender_domain="dso.dev",
|
||||
recipient_domain="cro.dev",
|
||||
time_stamp=datetime.now(timezone.utc).isoformat(),
|
||||
message_id="1234",
|
||||
conversation_id="1234"
|
||||
)
|
||||
msg.version = "3.1.0"
|
||||
sealed = seal_message(msg, private_base64)
|
||||
unsealed = unseal_message(sealed, public_base64)
|
||||
assert msg == unsealed
|
||||
|
||||
|
||||
def test_tampered_message():
|
||||
msg = UFTPTestMessage(
|
||||
version="3.1.0",
|
||||
sender_domain="dso.dev",
|
||||
recipient_domain="cro.dev",
|
||||
time_stamp=datetime.now(timezone.utc).isoformat(),
|
||||
message_id="1234",
|
||||
conversation_id="1234"
|
||||
)
|
||||
msg.version = "3.1.0"
|
||||
sealed = seal_message(msg, private_base64)
|
||||
sealed = bytes([sealed[0] + 1]) + sealed[1:]
|
||||
with pytest.raises((InvalidSignatureException, SchemaException)):
|
||||
unseal_message(sealed, public_base64)
|
||||
|
||||
|
||||
def test_invalid_message():
|
||||
msg = '<?xml version="1.0" encoding="UTF-8"?><Hello />'.encode()
|
||||
sealed = crypto_sign(msg, private)
|
||||
with pytest.raises(SchemaException):
|
||||
unsealed = unseal_message(sealed, public_base64)
|
||||
|
||||
|
||||
def test_seal_invalid_type():
|
||||
msg = "Hello"
|
||||
with pytest.raises(TypeError):
|
||||
sealed = seal_message(msg, private_base64)
|
||||
|
||||
|
||||
def patched_resolve(*args, **kwargs):
|
||||
return dns.resolver.resolve_at("1.1.1.1", *args, **kwargs)
|
||||
|
||||
@patch.object(dns.resolver, 'resolve', new=patched_resolve)
|
||||
def test_get_key():
|
||||
key = get_key("enexis.dev", "dso")
|
||||
assert re.match(r'[0-9A-Za-z+/=]{44}', key)
|
||||
@@ -0,0 +1,71 @@
|
||||
from base64 import b64decode
|
||||
|
||||
import requests
|
||||
from nacl.bindings import crypto_sign
|
||||
|
||||
from shapeshifter_uftp.transport import seal_message, to_xml
|
||||
from shapeshifter_uftp.uftp import (
|
||||
AcceptedRejected,
|
||||
AgrPortfolioUpdate,
|
||||
SignedMessage,
|
||||
)
|
||||
|
||||
from .helpers.messages import messages_by_type
|
||||
from .helpers.services import DummyAgrService, DummyCroService
|
||||
|
||||
|
||||
def test_sender_mismatch():
|
||||
"""
|
||||
Send a message with mismatching sender_domain in the outer
|
||||
envelope and the inner PayloadMessage.
|
||||
"""
|
||||
with (
|
||||
DummyCroService() as cro_service,
|
||||
DummyAgrService() as agr_service
|
||||
):
|
||||
with agr_service.cro_client(cro_service.sender_domain) as client:
|
||||
message = messages_by_type[AgrPortfolioUpdate]
|
||||
message.sender_domain = "fake.domain"
|
||||
|
||||
sealed_message = seal_message(message, agr_service.signing_key)
|
||||
|
||||
signed_message = SignedMessage(
|
||||
sender_domain=agr_service.sender_domain,
|
||||
sender_role="AGR",
|
||||
body=sealed_message
|
||||
)
|
||||
|
||||
response = requests.post(
|
||||
client.recipient_endpoint,
|
||||
headers={"Content-Type": "text/xml"},
|
||||
data=to_xml(signed_message)
|
||||
)
|
||||
assert response.status_code == 200
|
||||
unsealed_response_message = agr_service.request_futures["process_agr_portfolio_update_response"].result(timeout=10)
|
||||
assert unsealed_response_message.result == AcceptedRejected.REJECTED
|
||||
assert unsealed_response_message.rejection_reason == 'Invalid Sender'
|
||||
|
||||
|
||||
|
||||
def test_transport_error():
|
||||
"""
|
||||
Send a message with mismatching sender_domain in the outer
|
||||
envelope and the inner PayloadMessage.
|
||||
"""
|
||||
with DummyCroService() as cro_service:
|
||||
agr_service = DummyAgrService()
|
||||
with agr_service.cro_client(cro_service.sender_domain) as client:
|
||||
message = b'<Hello />'
|
||||
sealed_message = crypto_sign(message, b64decode(agr_service.signing_key))
|
||||
signed_message = SignedMessage(
|
||||
sender_domain=agr_service.sender_domain,
|
||||
sender_role=agr_service.sender_role,
|
||||
body=sealed_message
|
||||
)
|
||||
response = requests.post(
|
||||
client.recipient_endpoint,
|
||||
headers={"Content-Type": "text/xml"},
|
||||
data=to_xml(signed_message)
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
@@ -0,0 +1,11 @@
|
||||
import pytest
|
||||
|
||||
from shapeshifter_uftp.service.base_service import snake_case
|
||||
|
||||
|
||||
@pytest.mark.parametrize('text,expected_result',
|
||||
[('FlexOffer', 'flex_offer'),
|
||||
('AgrPortfolioUpdate', 'agr_portfolio_update'),
|
||||
('HTTPRequest', 'http_request')])
|
||||
def test_snake_case(text, expected_result):
|
||||
assert snake_case(text) == expected_result
|
||||
@@ -0,0 +1,50 @@
|
||||
import time
|
||||
|
||||
from shapeshifter_uftp.transport import ttl_cache
|
||||
|
||||
|
||||
@ttl_cache(0.10)
|
||||
def dummy_function(*args, **kwargs):
|
||||
return time.time()
|
||||
|
||||
|
||||
def test_ttl_cache():
|
||||
result_1 = dummy_function()
|
||||
time.sleep(0.05)
|
||||
result_2 = dummy_function()
|
||||
assert result_1 == result_2
|
||||
time.sleep(0.06)
|
||||
result_3 = dummy_function()
|
||||
assert result_3 != result_2
|
||||
|
||||
|
||||
def test_ttl_cache_with_args():
|
||||
result_1 = dummy_function("hello")
|
||||
time.sleep(0.05)
|
||||
result_2 = dummy_function("hello")
|
||||
result_3 = dummy_function("world")
|
||||
assert result_1 == result_2
|
||||
assert result_1 != result_3
|
||||
|
||||
time.sleep(0.06)
|
||||
result_4 = dummy_function("hello")
|
||||
assert result_1 != result_4
|
||||
|
||||
|
||||
def test_ttl_cache_with_kwargs():
|
||||
result_1 = dummy_function("hello", key="one")
|
||||
time.sleep(0.05)
|
||||
result_2 = dummy_function("hello", key="one")
|
||||
time.sleep(0.02)
|
||||
result_3 = dummy_function("hello", key="two")
|
||||
time.sleep(0.02)
|
||||
result_4 = dummy_function("world", key="one")
|
||||
|
||||
assert result_1 == result_2
|
||||
assert result_1 != result_3
|
||||
assert result_1 != result_4
|
||||
assert result_3 != result_4
|
||||
|
||||
time.sleep(0.06)
|
||||
result_5 = dummy_function("hello", key="one")
|
||||
assert result_1 != result_5
|
||||
@@ -0,0 +1,89 @@
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from xsdata.models.datatype import XmlDate, XmlDuration
|
||||
|
||||
from shapeshifter_uftp.uftp.messages import (
|
||||
FlexOffer,
|
||||
FlexOfferOption,
|
||||
FlexOfferOptionISP,
|
||||
FlexOrder,
|
||||
FlexOrderISP,
|
||||
)
|
||||
from shapeshifter_uftp.uftp.validations import validate_decimal, validate_list
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value,decimals,expected_output",
|
||||
[
|
||||
("1.0", 4, Decimal("1.0000")),
|
||||
("1", 4, Decimal("1.0000")),
|
||||
(1, 4, Decimal("1.0000")),
|
||||
(1.0, 4, Decimal("1.0000")),
|
||||
(1.00000, 4, Decimal("1.0000")),
|
||||
(Decimal("1.0"), 4, Decimal("1.0000")),
|
||||
],
|
||||
)
|
||||
def test_validate_decimal(value, decimals, expected_output):
|
||||
assert validate_decimal("myvalue", value, decimals) == Decimal("1.0000")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value,expected_error", [("abc", ValueError), ([123], TypeError)]
|
||||
)
|
||||
def test_validate_decimal_errors(value, expected_error):
|
||||
with pytest.raises(expected_error):
|
||||
validate_decimal("myvalue", value, 4)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('value,obj_type,length,expected_error',
|
||||
[
|
||||
(None, str, 1, TypeError),
|
||||
([], str, 1, ValueError),
|
||||
([1], str, 1, TypeError),
|
||||
(['1'], str, 2, ValueError),
|
||||
]
|
||||
)
|
||||
def test_validate_list_errors(value, obj_type, length, expected_error):
|
||||
with pytest.raises(expected_error):
|
||||
validate_list('mylist', value, obj_type, length)
|
||||
|
||||
|
||||
def test_not_unsolicited_flex_order_without_request_id():
|
||||
with pytest.raises(TypeError):
|
||||
FlexOrder(
|
||||
isps=[FlexOrderISP(
|
||||
power=123,
|
||||
start=1
|
||||
)],
|
||||
isp_duration=XmlDuration("PT15M"),
|
||||
period=XmlDate(2023,1,1),
|
||||
congestion_point="ean.123456789012345678",
|
||||
price=Decimal("0.0"),
|
||||
currency="EUR",
|
||||
order_reference=str(uuid4())
|
||||
)
|
||||
|
||||
def test_not_unsollicited_flex_offer_without_request_id():
|
||||
with pytest.raises(TypeError):
|
||||
FlexOffer(
|
||||
isp_duration=XmlDuration("PT15M"),
|
||||
period=XmlDate(2023,1,1),
|
||||
congestion_point="ean.123456789012345678",
|
||||
expiration_date_time=datetime(2023,1,1,0,0,0, tzinfo=timezone.utc).isoformat(),
|
||||
offer_options=[
|
||||
FlexOfferOption(
|
||||
option_reference=str(uuid4()),
|
||||
price=Decimal("0.0"),
|
||||
isps=[
|
||||
FlexOfferOptionISP(
|
||||
power=1,
|
||||
start=1,
|
||||
duration=1
|
||||
)
|
||||
]
|
||||
)
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
import xmlschema
|
||||
|
||||
from shapeshifter_uftp.transport import to_xml
|
||||
from shapeshifter_uftp.uftp import destination_map
|
||||
|
||||
from .helpers.messages import messages
|
||||
|
||||
base_url = os.path.join(os.path.dirname(__file__), 'schema')
|
||||
|
||||
schemas = {
|
||||
"AGR": xmlschema.XMLSchema("UFTP-agr.xsd", base_url=base_url),
|
||||
"CRO": xmlschema.XMLSchema("UFTP-cro.xsd", base_url=base_url),
|
||||
"DSO": xmlschema.XMLSchema("UFTP-dso.xsd", base_url=base_url),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize('message', messages, ids=[message.__class__.__name__ for message in messages])
|
||||
def test_schema_compliance(message):
|
||||
xml_message = to_xml(message)
|
||||
schema = schemas[destination_map[type(message)]]
|
||||
schema.validate(xml_message)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user