Просмотр исходного кода

Refinements to Reporting for Client

Stan Janssen 5 лет назад
Родитель
Сommit
c488227417

+ 122 - 4
pyopenadr/client.py

@@ -4,12 +4,21 @@ import xmltodict
 import random
 import requests
 from jinja2 import Environment, PackageLoader, select_autoescape
-from pyopenadr.utils import parse_message, create_message, new_request_id, peek
+from pyopenadr.utils import parse_message, create_message, new_request_id, peek, generate_id
+from pyopenadr import enums
+from datetime import datetime, timedelta, timezone
 from http import HTTPStatus
 from apscheduler.schedulers.asyncio import AsyncIOScheduler
 import asyncio
 from asyncio import iscoroutine
 
+MEASURANDS = {'power_real': 'power_quantity',
+              'power_reactive': 'power_quantity',
+              'power_apparent': 'power_quantity',
+              'energy_real': 'energy_quantity',
+              'energy_reactive': 'energy_quantity',
+              'energy_active': 'energy_quantity'}
+
 class OpenADRClient:
     def __init__(self, ven_name, vtn_url, debug=False):
         self.ven_name = ven_name
@@ -17,6 +26,10 @@ class OpenADRClient:
         self.ven_id = None
         self.poll_frequency = None
         self.debug = debug
+        self.reports = {}           # Mapping of all available reports from the VEN
+        self.report_requests = {}   # Mapping of the reports requested by the VTN
+        self.report_schedulers = {} # Mapping between reportRequestIDs and our internal report schedulers
+        self.scheduler = AsyncIOScheduler()
 
     def run(self):
         """
@@ -31,8 +44,10 @@ class OpenADRClient:
             print("No VEN ID received from the VTN, aborting registration.")
             return
 
+        if self.reports:
+            self.register_report()
+
         # Set up automatic polling
-        self.scheduler = AsyncIOScheduler()
         if self.poll_frequency.total_seconds() < 60:
             cron_second = f"*/{self.poll_frequency.seconds}"
             cron_minute = "*"
@@ -52,6 +67,57 @@ class OpenADRClient:
         self.scheduler.add_job(self._poll, trigger='cron', second=cron_second, minute=cron_minute, hour=cron_hour)
         self.scheduler.start()
 
+    def add_report(self, callable, report_id, report_name, reading_type, report_type,
+                         sampling_rate, resource_id, measurand, unit, scale="none",
+                         power_ac=True, power_hertz=50, power_voltage=230, market_context=None):
+        """
+        Add a new reporting capability to the client.
+        :param callable callable: A callable or coroutine that will fetch the value for a specific report. This callable will be passed the report_id and the r_id of the requested value.
+        :param str report_id: A unique identifier for this report.
+        :param str report_name: An OpenADR name for this report (one of pyopenadr.enums.REPORT_NAME)
+        :param str reading_type: An OpenADR reading type (found in pyopenadr.enums.READING_TYPE)
+        :param str report_type: An OpenADR report type (found in pyopenadr.enums.REPORT_TYPE)
+        :param datetime.timedelta sampling_rate: The sampling rate for the measurement.
+        :param resource_id: A specific name for this resource within this report.
+        :param str unit: The unit for this measurement.
+        """
+
+        if report_name not in enums.REPORT_NAME.values:
+            raise ValueError(f"{report_name} is not a valid report_name. Valid options are {', '.join(enums.REPORT_NAME.values)}.")
+        if reading_type not in enums.READING_TYPE.values:
+            raise ValueError(f"{reading_type} is not a valid reading_type. Valid options are {', '.join(enums.READING_TYPE.values)}.")
+        if report_type not in enums.REPORT_TYPE.values:
+            raise ValueError(f"{report_type} is not a valid report_type. Valid options are {', '.join(enums.REPORT_TYPE.values)}.")
+        if measurand not in MEASURANDS:
+            raise ValueError(f"{measurand} is not a valid measurand. Valid options are 'power_real', 'power_reactive', 'power_apparent', 'energy_real', 'energy_reactive', 'energy_active', 'energy_quantity', 'voltage'")
+        if scale not in enums.SI_SCALE_CODE.values:
+            raise ValueError(f"{scale} is not a valid scale. Valid options are {', '.join(enums.SI_SCALE_CODE.values)}")
+
+        report_description = {'market_context': market_context,
+                              'r_id': resource_id,
+                              'reading_type': reading_type,
+                              'report_type': report_type,
+                              'sampling_rate': {'max_period': sampling_rate,
+                                                'min_period': sampling_rate,
+                                                'on_change': False},
+                               measurand: {'item_description': measurand,
+                                           'item_units': unit,
+                                           'si_scale_code': scale}}
+        if 'power' in measurand:
+            report_description[measurand]['power_attributes'] = {'ac': power_ac, 'hertz': power_hertz, 'voltage': power_voltage}
+
+        if report_id in self.reports:
+            report = self.reports[report_id]['report_descriptions'].append(report_description)
+        else:
+            report = {'callable': callable,
+                      'created_date_time': datetime.now(timezone.utc),
+                      'report_id': report_id,
+                      'report_name': report_name,
+                      'report_request_id': generate_id(),
+                      'report_specifier_id': report_id + "_" + report_name.lower(),
+                      'report_descriptions': [report_description]}
+        self.reports[report_id] = report
+        self.report_ids[resource_id] = {'item_base': measurand}
 
     def query_registration(self):
         """
@@ -105,7 +171,6 @@ class OpenADRClient:
         response_type, response_payload = self._perform_request(service, message)
         return response_type, response_payload
 
-
     def created_event(self, request_id, event_id, opt_type, modification_number=1):
         """
         Inform the VTN that we created an event.
@@ -129,7 +194,22 @@ class OpenADRClient:
         """
         Tell the VTN about our reporting capabilities.
         """
-        raise NotImplementedError("Reporting is not yet implemented")
+        request_id = generate_id()
+
+        payload = {'request_id': generate_id(),
+                   'ven_id': self.ven_id,
+                   'reports': self.reports}
+
+        service = 'EiReport'
+        message = create_message('oadrRegisterReport', **payload)
+        response_type, response_payload = self._perform_request(service, message)
+
+        # Remember which reports the VTN is interested in
+
+        return response_type, response_payload
+
+    def created_report(self):
+        pass
 
     def poll(self):
         service = 'OadrPoll'
@@ -137,6 +217,44 @@ class OpenADRClient:
         response_type, response_payload = self._perform_request(service, message)
         return response_type, response_payload
 
+    async def update_report(self, report_id, resource_id=None):
+        """
+        Calls the previously registered report callable, and send the result as a message to the VTN.
+        """
+        if not resource_id:
+            resource_ids = self.reports[report_id]['report_descriptions'].keys()
+        elif isinstance(resource_id, str):
+            resource_ids = [resource_id]
+        else:
+            resource_ids = resource_id
+        value = self.reports[report_id]['callable'](resource_id)
+        if iscoroutine(value):
+            value = await value
+
+        report_type = self.reports[report_id][resource_id]['report_type']
+        for measurand in MEASURAND:
+            if measurand in self.reports[report_id][resource_id]:
+                item_base = measurand
+                break
+        report = {'report_id': report_id,
+                  'report_descriptions': {resource_id: {MEASURANDS[measurand]: {'quantity': value,
+                                                                         measurand: self.reports[report_id][resource_id][measurand]},
+                                          'report_type': self.reports[report_id][resource_id]['report_type'],
+                                          'reading_type': self.reports[report_id][resource_id]['reading_type']}},
+                  'report_name': self.report['report_id']['report_name'],
+                  'report_request_id': self.reports['report_id']['report_request_id'],
+                  'report_specifier_id': self.report['report_id']['report_specifier_id'],
+                  'created_date_time': datetime.now(timezone.utc)}
+
+        service = 'EiReport'
+        message = create_message('oadrUpdateReport', report)
+        response_type, response_payload = self._perform_request(service, message)
+
+        # We might get a oadrCancelReport message in this thing:
+        if 'cancel_report' in response.payload:
+            print("TODO: cancel this report")
+
+
     def _perform_request(self, service, message):
         if self.debug:
             print(f"Sending {message}")

+ 16 - 3
pyopenadr/enums.py

@@ -8,7 +8,7 @@ class Enum(type):
 
     @property
     def values(self):
-        return (self[item] for item in self.members)
+        return [self[item] for item in self.members]
 
 class EVENT_STATUS(metaclass=Enum):
     NONE = "none"
@@ -44,11 +44,24 @@ class SIGNAL_NAME(metaclass=Enum):
     LOAD_DISPATCH = "LOAD_DISPATCH"
     LOAD_CONTROL = "LOAD_CONTROL"
 
+class SI_SCALE_CODE(metaclass=Enum):
+    p = "p"
+    n = "n"
+    micro = "micro"
+    m = "m"
+    c = "c"
+    d = "d"
+    k = "k"
+    M = "M"
+    G = "G"
+    T = "T"
+    none = "none"
+
 class OPT(metaclass=Enum):
     OPT_IN = "optIn"
     OPT_OUT = "optOut"
 
-class OPT_REASON(metaclass=Enum)
+class OPT_REASON(metaclass=Enum):
     ECONOMIC = "economic"
     EMERGENCY = "emergency"
     MUST_RUN = "mustRun"
@@ -101,7 +114,7 @@ class REPORT_TYPE(metaclass=Enum):
 
 class REPORT_NAME(metaclass=Enum):
     METADATA_HISTORY_USAGE = "METADATA_HISTORY_USAGE"
-    HISTORY_USAGE = "METADATA_HISTORY_USAGE"
+    HISTORY_USAGE = "HISTORY_USAGE"
     METADATA_HISTORY_GREENBUTTON = "METADATA_HISTORY_GREENBUTTON"
     HISTORY_GREENBUTTON = "HISTORY_GREENBUTTON"
     METADATA_TELEMETRY_USAGE = "METADATA_TELEMETRY_USAGE"

+ 1 - 0
pyopenadr/server.py

@@ -16,6 +16,7 @@ class OpenADRServer:
         map = {'on_created_event': EventService,
                'on_request_event': EventService,
 
+               'on_register_report': ReportService,
                'on_create_report': ReportService,
                'on_created_report': ReportService,
                'on_request_report': ReportService,

+ 2 - 2
pyopenadr/templates/oadrRegisterReport.xml

@@ -12,11 +12,11 @@
         {% endif %}
         {% if report.duration %}
         <xcal:duration>
-          <duration>{{ report.duration|timedeltaformat }}</duration>
+          <xcal:duration>{{ report.duration|timedeltaformat }}</xcal:duration>
         </xcal:duration>
         {% endif %}
         <ei:eiReportID>{{ report.report_id }}</ei:eiReportID>
-    {% for report_description in report.report_descriptions %}
+    {% for r_id, report_description in report.report_descriptions.items() %}
         {% include 'parts/oadrReportDescription.xml' %}
     {% endfor %}
         <ei:reportRequestID>{{ report.report_request_id }}</ei:reportRequestID>

+ 1 - 1
pyopenadr/templates/oadrUpdateReport.xml

@@ -8,7 +8,7 @@
       <oadrReport>
         <ei:eiReportID>{{ report.report_id }}</ei:eiReportID>
         {% if report.report_descriptions %}
-        {% for report_description in report.report_descriptions %}
+        {% for r_id, report_description in report.report_descriptions.items() %}
         {% include 'parts/oadrReportDescription.xml' %}
         <ei:reportRequestID>{{ report.report_request_id }}</ei:reportRequestID>
         <ei:reportSpecifierID>{{ report.report_specifier_id }}</ei:reportSpecifierID>

+ 1 - 1
pyopenadr/templates/parts/oadrReportDescription.xml

@@ -1,5 +1,5 @@
 <oadrReportDescription xmlns:emix="http://docs.oasis-open.org/ns/emix/2011/06">
-  <ei:rID>{{ report_description.r_id }}</ei:rID>
+  <ei:rID>{{ r_id }}</ei:rID>
   {% if report_description.report_subjects %}
   <ei:reportSubject>
   {% for target in report_description.report_subjects %}

+ 20 - 6
pyopenadr/utils.py

@@ -134,11 +134,12 @@ def normalize_dict(ordered_dict):
         if key in ("target", "report_subject", "report_data_source"):
             targets = d.pop(key)
             new_targets = []
-            for ikey in targets:
-                if isinstance(targets[ikey], list):
-                    new_targets.extend([{ikey: value} for value in targets[ikey]])
-                else:
-                    new_targets.append({ikey: targets[ikey]})
+            if targets:
+                for ikey in targets:
+                    if isinstance(targets[ikey], list):
+                        new_targets.extend([{ikey: value} for value in targets[ikey]])
+                    else:
+                        new_targets.append({ikey: targets[ikey]})
             d[key + "s"] = new_targets
             key = key + "s"
 
@@ -168,12 +169,25 @@ def normalize_dict(ordered_dict):
             d = d[key]
 
         # Plurarize some lists
-        elif key in ('report_request', 'report_description', 'report'):
+        elif key in ('report_request', 'report'):
             if isinstance(d[key], list):
                 d[key + 's'] = d.pop(key)
             else:
                 d[key + 's'] = [d.pop(key)]
 
+        elif key == 'report_description':
+            if isinstance(d[key], list):
+                original_descriptions = d.pop(key)
+                report_descriptions = {}
+                for item in original_descriptions:
+                    r_id = item.pop('r_id')
+                    report_descriptions[r_id] = item
+                d[key + 's'] = report_descriptions
+            else:
+                original_description = d.pop(key)
+                r_id = original_description.pop('r_id')
+                d[key + 's'] = {r_id: original_description}
+
         # Promote the contents of the Qualified Event ID
         elif key == "qualified_event_id" and isinstance(d['qualified_event_id'], dict):
             qeid = d.pop('qualified_event_id')

+ 19 - 20
test/test_message_conversion.py

@@ -141,14 +141,14 @@ test_message('oadrRegisteredReport', ven_id='VEN123', response={'response_code':
 test_message('oadrRequestEvent', request_id=generate_id(), ven_id='123ABC')
 test_message('oadrRequestReregistration', ven_id='123ABC')
 test_message('oadrRegisterReport', request_id=generate_id(), reports=[{'report_id': generate_id(),
-                                                                       'report_descriptions': [{
-                                                                            'r_id': generate_id(),
+                                                                       'report_descriptions': {
+                                                                            generate_id(): {
                                                                             'report_subjects': [{'ven_id': '123ABC'}],
                                                                             'report_data_sources': [{'ven_id': '123ABC'}],
                                                                             'report_type': 'reading',
                                                                             'reading_type': 'Direct Read',
                                                                             'market_context': 'http://localhost',
-                                                                            'sampling_rate': {'min_period': timedelta(minutes=1), 'max_period': timedelta(minutes=1), 'on_change': True}}],
+                                                                            'sampling_rate': {'min_period': timedelta(minutes=1), 'max_period': timedelta(minutes=1), 'on_change': True}}},
                                                                        'report_request_id': generate_id(),
                                                                        'report_specifier_id': generate_id(),
                                                                        'report_name': 'HISTORY_USAGE',
@@ -157,19 +157,19 @@ test_message('oadrRegisterReport', request_id=generate_id(), reports=[{'report_i
                                                         report_request_id=generate_id())
 test_message('oadrRegisterReport', **{'request_id': '8a4f859883', 'reports': [{'report_id': generate_id(),
                                                                                'duration': timedelta(seconds=7200),
-                                                                               'report_descriptions': [{'r_id': 'resource1_status',
+                                                                               'report_descriptions': {'resource1_status': {
                                                                                                         'report_data_sources': [{'resource_id': 'resource1'}],
                                                                                                         'report_type': 'x-resourceStatus',
                                                                                                         'reading_type': 'x-notApplicable',
                                                                                                         'market_context': 'http://MarketContext1',
-                                                                                                        'sampling_rate': {'min_period': timedelta(seconds=60), 'max_period': timedelta(seconds=60), 'on_change': False}}],
+                                                                                                        'sampling_rate': {'min_period': timedelta(seconds=60), 'max_period': timedelta(seconds=60), 'on_change': False}}},
                                                                                 'report_request_id': '0',
                                                                                 'report_specifier_id': '789ed6cd4e_telemetry_status',
                                                                                 'report_name': 'METADATA_TELEMETRY_STATUS',
                                                                                 'created_date_time': datetime(2019, 11, 20, 15, 4, 52, 638621, tzinfo=timezone.utc)},
                                                                                {'report_id': generate_id(),
                                                                                 'duration': timedelta(seconds=7200),
-                                                                                'report_descriptions': [{'r_id': 'resource1_energy',
+                                                                                'report_descriptions': {'resource1_energy': {
                                                                                                          'report_data_sources': [{'resource_id': 'resource1'}],
                                                                                                          'report_type': 'usage',
                                                                                                          'energy_real': {'item_description': 'RealEnergy',
@@ -178,7 +178,7 @@ test_message('oadrRegisterReport', **{'request_id': '8a4f859883', 'reports': [{'
                                                                                                          'reading_type': 'Direct Read',
                                                                                                          'market_context': 'http://MarketContext1',
                                                                                                          'sampling_rate': {'min_period': timedelta(seconds=60), 'max_period': timedelta(seconds=60), 'on_change': False}},
-                                                                                                        {'r_id': 'resource1_power',
+                                                                                                        'resource1_power': {
                                                                                                          'report_data_sources': [{'resource_id': 'resource1'}],
                                                                                                          'report_type': 'usage',
                                                                                                          'power_real': {'item_description': 'RealPower',
@@ -187,14 +187,14 @@ test_message('oadrRegisterReport', **{'request_id': '8a4f859883', 'reports': [{'
                                                                                                                         'power_attributes': {'hertz': 60, 'voltage': 110, 'ac': False}},
                                                                                                           'reading_type': 'Direct Read',
                                                                                                           'market_context': 'http://MarketContext1',
-                                                                                                          'sampling_rate': {'min_period': timedelta(seconds=60), 'max_period': timedelta(seconds=60), 'on_change': False}}],
+                                                                                                          'sampling_rate': {'min_period': timedelta(seconds=60), 'max_period': timedelta(seconds=60), 'on_change': False}}},
                                                                                 'report_request_id': '0',
                                                                                 'report_specifier_id': '789ed6cd4e_telemetry_usage',
                                                                                 'report_name': 'METADATA_TELEMETRY_USAGE',
                                                                                 'created_date_time': datetime(2019, 11, 20, 15, 4, 52, 638621, tzinfo=timezone.utc)},
                                                                                {'report_id': generate_id(),
                                                                                 'duration': timedelta(seconds=7200),
-                                                                                'report_descriptions': [{'r_id': 'resource1_energy',
+                                                                                'report_descriptions': {'resource1_energy': {
                                                                                                          'report_data_sources': [{'resource_id': 'resource1'}],
                                                                                                          'report_type': 'usage',
                                                                                                          'energy_real': {'item_description': 'RealEnergy',
@@ -203,7 +203,7 @@ test_message('oadrRegisterReport', **{'request_id': '8a4f859883', 'reports': [{'
                                                                                                          'reading_type': 'Direct Read',
                                                                                                          'market_context': 'http://MarketContext1',
                                                                                                          'sampling_rate': {'min_period': timedelta(seconds=60), 'max_period': timedelta(seconds=60), 'on_change': False}},
-                                                                                                        {'r_id': 'resource1_power',
+                                                                                                        'resource1_power': {
                                                                                                          'report_data_sources': [{'resource_id': 'resource1'}],
                                                                                                          'report_type': 'usage',
                                                                                                          'power_real': {'item_description': 'RealPower',
@@ -211,7 +211,7 @@ test_message('oadrRegisterReport', **{'request_id': '8a4f859883', 'reports': [{'
                                                                                                                         'power_attributes': {'hertz': 60, 'voltage': 110, 'ac': False}},
                                                                                                          'reading_type': 'Direct Read',
                                                                                                          'market_context': 'http://MarketContext1',
-                                                                                                         'sampling_rate': {'min_period': timedelta(seconds=60), 'max_period': timedelta(seconds=60), 'on_change': False}}],
+                                                                                                         'sampling_rate': {'min_period': timedelta(seconds=60), 'max_period': timedelta(seconds=60), 'on_change': False}}},
                                                                                 'report_request_id': '0',
                                                                                 'report_specifier_id': '789ed6cd4e_history_usage',
                                                                                 'report_name': 'METADATA_HISTORY_USAGE',
@@ -224,15 +224,14 @@ test_message('oadrUpdateReport', request_id=generate_id(), reports=[{'report_id'
                                                                                   'created_date_time': datetime.now(timezone.utc),
                                                                                   'report_request_id': generate_id(),
                                                                                   'report_specifier_id': generate_id(),
-                                                                                 'report_descriptions': [{'r_id': generate_id(),
-                                                                                                          'report_subjects': [{'ven_id': '123ABC'}, {'ven_id': 'DEF456'}],
-                                                                                                          'report_data_sources': [{'ven_id': '123ABC'}],
-                                                                                                          'report_type': enums.REPORT_TYPE.values[0],
-                                                                                                          'reading_type': enums.READING_TYPE.values[0],
-                                                                                                          'market_context': 'http://localhost',
-                                                                                                          'sampling_rate': {'min_period': timedelta(minutes=1),
-                                                                                                                            'max_period': timedelta(minutes=2),
-                                                                                                                            'on_change': False}}]}], ven_id='123ABC')
+                                                                                  'report_descriptions': {generate_id(): {'report_subjects': [{'ven_id': '123ABC'}, {'ven_id': 'DEF456'}],
+                                                                                                                          'report_data_sources': [{'ven_id': '123ABC'}],
+                                                                                                                          'report_type': enums.REPORT_TYPE.values[0],
+                                                                                                                          'reading_type': enums.READING_TYPE.values[0],
+                                                                                                                          'market_context': 'http://localhost',
+                                                                                                                          'sampling_rate': {'min_period': timedelta(minutes=1),
+                                                                                                                                            'max_period': timedelta(minutes=2),
+                                                                                                                                            'on_change': False}}}}], ven_id='123ABC')
 # for report_name in enums.REPORT_NAME.values:
 #     for reading_type in enums.READING_TYPE.values:
 #         for report_type in enums.REPORT_TYPE.values: